mirror of
https://github.com/tiennm99/miti99bot.git
synced 2026-06-08 00:14:58 +00:00
feat(modules): port new command modules + update registry
- Add new modules: doantu, semantle, twentyq, ai (Gemini integration) - Update module registry with new command registration - Update tests and documentation for module system - Update README with new module references
This commit is contained in:
@@ -11,7 +11,9 @@ Early scaffolding. See [`plans/260508-2222-go-port-cloud-run/plan.md`](plans/260
|
||||
| 01 | GCP setup, Cloud Run baseline | pending |
|
||||
| 02 | Repo bootstrap + webhook skeleton | **partial** (local pieces done; Cloud Run deploy deferred to Phase 01) |
|
||||
| 03 | Module framework + KVStore | **done** |
|
||||
| 04+ | Firestore, modules, cron, CI/CD, cutover | pending |
|
||||
| 04 | Firestore KV + provider abstraction | **done** |
|
||||
| 05–07 | Module ports (util/misc/wordle/loldle/lolschedule + AI: semantle/doantu/twentyq) | **done** |
|
||||
| 08+ | Trading, cron wiring, CI/CD, cutover | pending |
|
||||
|
||||
## Layout
|
||||
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
// Package doantu ports the JS Vietnamese-semantle module to Go. It uses the
|
||||
// hosted phow2sim PhoW2V word2vec API (https://phow2sim.sg.miti99.com) for
|
||||
// target picking + cosine similarity, NOT Gemini embeddings. Rationale:
|
||||
//
|
||||
// 1. text-embedding-004 was not trained for Vietnamese semantic relatedness;
|
||||
// phow2sim is a domain-trained model.
|
||||
// 2. phow2sim already owns the vocabulary — no need to maintain a Vietnamese
|
||||
// wordlist alongside it.
|
||||
// 3. JS parity: the upstream service is the same one the JS bot has been
|
||||
// using; switching to embeddings would diverge behavior, not preserve it.
|
||||
//
|
||||
// Phase 07 plan suggested embedding both modules; this is a documented
|
||||
// deviation. Update phase-07 plan + plan.md when reviewing.
|
||||
package doantu
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultTimeout = 5 * time.Second
|
||||
userAgent = "miti99bot-go/doantu"
|
||||
)
|
||||
|
||||
// UpstreamError is returned for every transport / decode failure. status is
|
||||
// 0 for non-HTTP failures (timeout, DNS).
|
||||
type UpstreamError struct {
|
||||
Status int
|
||||
Msg string
|
||||
Body string
|
||||
}
|
||||
|
||||
func (e *UpstreamError) Error() string {
|
||||
if e.Status > 0 {
|
||||
return fmt.Sprintf("phow2sim HTTP %d: %s", e.Status, e.Msg)
|
||||
}
|
||||
return "phow2sim: " + e.Msg
|
||||
}
|
||||
|
||||
// SimResp mirrors the JS api-client similarity() shape.
|
||||
type SimResp struct {
|
||||
A string `json:"a"`
|
||||
B string `json:"b"`
|
||||
CanonicalA *string `json:"canonical_a"`
|
||||
CanonicalB *string `json:"canonical_b"`
|
||||
InVocabA bool `json:"in_vocab_a"`
|
||||
InVocabB bool `json:"in_vocab_b"`
|
||||
Similarity *float64 `json:"similarity"`
|
||||
}
|
||||
|
||||
// RandomResp shape from /random.
|
||||
type RandomResp struct {
|
||||
Word string `json:"word"`
|
||||
Rank *int `json:"rank,omitempty"`
|
||||
}
|
||||
|
||||
// Neighbor item in /neighbors response.
|
||||
type Neighbor struct {
|
||||
Word string `json:"word"`
|
||||
Similarity float64 `json:"similarity"`
|
||||
}
|
||||
|
||||
// NeighborsResp from /neighbors.
|
||||
type NeighborsResp struct {
|
||||
Word string `json:"word"`
|
||||
Canonical *string `json:"canonical"`
|
||||
InVocab bool `json:"in_vocab"`
|
||||
Neighbors []Neighbor `json:"neighbors"`
|
||||
}
|
||||
|
||||
// Client is the phow2sim HTTP client. Zero value is unusable — call
|
||||
// NewClient. Safe for concurrent use; net/http.Client is goroutine-safe.
|
||||
type Client struct {
|
||||
base string
|
||||
hc *http.Client
|
||||
}
|
||||
|
||||
// NewClient builds a client against the supplied base URL (no trailing
|
||||
// slash needed; we strip it). timeout=0 → defaultTimeout.
|
||||
func NewClient(base string, timeout time.Duration) *Client {
|
||||
if timeout <= 0 {
|
||||
timeout = defaultTimeout
|
||||
}
|
||||
return &Client{
|
||||
base: strings.TrimRight(base, "/"),
|
||||
hc: &http.Client{Timeout: timeout},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) get(ctx context.Context, path string, params url.Values, dst any) error {
|
||||
full := c.base + path
|
||||
if len(params) > 0 {
|
||||
full += "?" + params.Encode()
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, full, nil)
|
||||
if err != nil {
|
||||
return &UpstreamError{Msg: "build request: " + err.Error()}
|
||||
}
|
||||
req.Header.Set("User-Agent", userAgent)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
resp, err := c.hc.Do(req)
|
||||
if err != nil {
|
||||
return &UpstreamError{Msg: "fetch failed: " + err.Error()}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) // 1 MiB cap
|
||||
if err != nil {
|
||||
return &UpstreamError{Status: resp.StatusCode, Msg: "read body: " + err.Error()}
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return &UpstreamError{Status: resp.StatusCode, Msg: "non-200", Body: truncate(string(body), 500)}
|
||||
}
|
||||
if err := json.Unmarshal(body, dst); err != nil {
|
||||
return &UpstreamError{Status: resp.StatusCode, Msg: "decode: " + err.Error(), Body: truncate(string(body), 200)}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RandomWord picks a target word with optional filters (e.g. min_rank/max_rank).
|
||||
func (c *Client) RandomWord(ctx context.Context, filters map[string]string) (*RandomResp, error) {
|
||||
q := url.Values{}
|
||||
for k, v := range filters {
|
||||
q.Set(k, v)
|
||||
}
|
||||
var r RandomResp
|
||||
if err := c.get(ctx, "/random", q, &r); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
// Similarity returns target↔guess cosine + canonical forms + vocab flags.
|
||||
func (c *Client) Similarity(ctx context.Context, a, b string) (*SimResp, error) {
|
||||
q := url.Values{}
|
||||
q.Set("a", a)
|
||||
q.Set("b", b)
|
||||
var r SimResp
|
||||
if err := c.get(ctx, "/similarity", q, &r); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
// Neighbors returns the top-N closest words to `word`. JS-parity default 100.
|
||||
func (c *Client) Neighbors(ctx context.Context, word string, topn int) (*NeighborsResp, error) {
|
||||
if topn <= 0 {
|
||||
topn = 100
|
||||
}
|
||||
q := url.Values{}
|
||||
q.Set("word", word)
|
||||
q.Set("topn", strconv.Itoa(topn))
|
||||
var r NeighborsResp
|
||||
if err := c.get(ctx, "/neighbors", q, &r); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
// SimAPI is the small interface handlers consume — lets tests pass a fake.
|
||||
type SimAPI interface {
|
||||
RandomWord(ctx context.Context, filters map[string]string) (*RandomResp, error)
|
||||
Similarity(ctx context.Context, a, b string) (*SimResp, error)
|
||||
Neighbors(ctx context.Context, word string, topn int) (*NeighborsResp, error)
|
||||
}
|
||||
|
||||
func truncate(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n]
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package doantu
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/binary"
|
||||
mrand "math/rand/v2"
|
||||
|
||||
"github.com/tiennm99/miti99bot-go/internal/modules"
|
||||
)
|
||||
|
||||
// defaultAPI is the production phow2sim instance. Override via env
|
||||
// PHOW2SIM_API_URL (allowlisted in cmd/server/main.go).
|
||||
const defaultAPI = "https://phow2sim.sg.miti99.com"
|
||||
|
||||
// New is the doantu module Factory. Reads PHOW2SIM_API_URL from Deps.Env
|
||||
// (falls back to defaultAPI). The module is always loadable — the upstream
|
||||
// service handles uptime, not us.
|
||||
func New(deps modules.Deps) modules.Module {
|
||||
base := defaultAPI
|
||||
if v, ok := deps.Env["PHOW2SIM_API_URL"]; ok && v != "" {
|
||||
base = v
|
||||
}
|
||||
s := &state{
|
||||
kv: deps.KV,
|
||||
api: NewClient(base, 0),
|
||||
rng: newRNG(),
|
||||
}
|
||||
return modules.Module{
|
||||
Commands: []modules.Command{
|
||||
{
|
||||
Name: "doantu",
|
||||
Visibility: modules.VisibilityPublic,
|
||||
Description: "Đoán từ — Vietnamese semantic word guessing (unlimited tries)",
|
||||
Handler: s.handleDoantu,
|
||||
},
|
||||
{
|
||||
Name: "doantu_hint",
|
||||
Visibility: modules.VisibilityPublic,
|
||||
Description: "Reveal 3 related words (not the answer) to nudge your guessing",
|
||||
Handler: s.handleHint,
|
||||
},
|
||||
{
|
||||
Name: "doantu_giveup",
|
||||
Visibility: modules.VisibilityPublic,
|
||||
Description: "Reveal the current doantu answer (auto-starts a fresh round)",
|
||||
Handler: s.handleGiveup,
|
||||
},
|
||||
{
|
||||
Name: "doantu_stats",
|
||||
Visibility: modules.VisibilityPublic,
|
||||
Description: "Show your doantu stats",
|
||||
Handler: s.handleStats,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newRNG() *mrand.Rand {
|
||||
var seed [16]byte
|
||||
_, _ = rand.Read(seed[:])
|
||||
s1 := binary.LittleEndian.Uint64(seed[0:8])
|
||||
s2 := binary.LittleEndian.Uint64(seed[8:16])
|
||||
return mrand.New(mrand.NewPCG(s1, s2))
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package doantu
|
||||
|
||||
import "math"
|
||||
|
||||
// calibrate: phow2sim cosines already span a wide useful range — JS just
|
||||
// scales linearly to 0-100 with negative clamp. No sigmoid here.
|
||||
func calibrate(raw float64) float64 {
|
||||
v := raw * 100
|
||||
switch {
|
||||
case v < 0:
|
||||
return 0
|
||||
case v > 100:
|
||||
return 100
|
||||
default:
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
func formatWarmth(score float64) string {
|
||||
pct := int(math.Round(score))
|
||||
switch {
|
||||
case pct >= 100:
|
||||
return "100"
|
||||
case pct < 10:
|
||||
return "0" + itoa(pct)
|
||||
default:
|
||||
return itoa(pct)
|
||||
}
|
||||
}
|
||||
|
||||
func warmthEmoji(score float64) string {
|
||||
switch {
|
||||
case score >= 90:
|
||||
return "🎯"
|
||||
case score >= 70:
|
||||
return "🔥"
|
||||
case score >= 40:
|
||||
return "🌡️"
|
||||
case score >= 15:
|
||||
return "😐"
|
||||
default:
|
||||
return "🥶"
|
||||
}
|
||||
}
|
||||
|
||||
func itoa(n int) string {
|
||||
if n == 0 {
|
||||
return "0"
|
||||
}
|
||||
var buf [4]byte
|
||||
i := len(buf)
|
||||
for n > 0 {
|
||||
i--
|
||||
buf[i] = byte('0' + n%10)
|
||||
n /= 10
|
||||
}
|
||||
return string(buf[i:])
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
package doantu
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html"
|
||||
"math/rand/v2"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/go-telegram/bot"
|
||||
"github.com/go-telegram/bot/models"
|
||||
|
||||
"github.com/tiennm99/miti99bot-go/internal/keylock"
|
||||
"github.com/tiennm99/miti99bot-go/internal/log"
|
||||
"github.com/tiennm99/miti99bot-go/internal/modules/util/chathelper"
|
||||
"github.com/tiennm99/miti99bot-go/internal/storage"
|
||||
)
|
||||
|
||||
const upstreamFail = "⚠️ Upstream hiccup — try again in a few seconds."
|
||||
|
||||
// JS-parity rank band: keep targets in the top-frequency band so the game
|
||||
// stays guessable.
|
||||
var randomFilters = map[string]string{"min_rank": "100", "max_rank": "1000"}
|
||||
|
||||
type state struct {
|
||||
kv storage.KVStore
|
||||
api SimAPI
|
||||
rngMu sync.Mutex
|
||||
rng *rand.Rand
|
||||
locks keylock.Map
|
||||
}
|
||||
|
||||
func (s *state) startFresh(ctx context.Context, subject string) (*GameState, error) {
|
||||
picked, err := s.api.RandomWord(ctx, randomFilters)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
target := strings.ToLower(picked.Word)
|
||||
if target == "" {
|
||||
return nil, &UpstreamError{Msg: "empty target from RandomWord"}
|
||||
}
|
||||
g := &GameState{Target: target, StartedAt: nil, Solved: false, Guesses: []Guess{}}
|
||||
if err := saveGame(ctx, s.kv, subject, g); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return g, nil
|
||||
}
|
||||
|
||||
func (s *state) getOrInit(ctx context.Context, subject string) (*GameState, error) {
|
||||
existing, err := loadGame(ctx, s.kv, subject)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if existing != nil && !existing.Solved {
|
||||
return existing, nil
|
||||
}
|
||||
return s.startFresh(ctx, subject)
|
||||
}
|
||||
|
||||
func (s *state) handleDoantu(ctx context.Context, b *bot.Bot, update *models.Update) error {
|
||||
msg := update.Message
|
||||
if msg == nil {
|
||||
return nil
|
||||
}
|
||||
subject := chathelper.SubjectFor(msg)
|
||||
if subject == "" {
|
||||
return chathelper.Reply(ctx, b, msg.Chat.ID, "Cannot identify chat.")
|
||||
}
|
||||
defer s.locks.Acquire(subject)()
|
||||
|
||||
arg := chathelper.ArgAfterCommand(msg.Text)
|
||||
game, err := s.getOrInit(ctx, subject)
|
||||
if err != nil {
|
||||
log.Warn("doantu random failed", "err", err)
|
||||
return chathelper.Reply(ctx, b, msg.Chat.ID, upstreamFail)
|
||||
}
|
||||
if arg == "" {
|
||||
return chathelper.ReplyHTML(ctx, b, msg.Chat.ID, renderBoard(game.Guesses, ""))
|
||||
}
|
||||
return s.submitGuess(ctx, b, msg, subject, game, arg)
|
||||
}
|
||||
|
||||
func (s *state) submitGuess(ctx context.Context, b *bot.Bot, msg *models.Message, subject string, game *GameState, arg string) error {
|
||||
guess := normalize(arg)
|
||||
if !isValidShape(guess) {
|
||||
return chathelper.Reply(ctx, b, msg.Chat.ID, "Please provide a Vietnamese word (letters + optional single spaces).")
|
||||
}
|
||||
for _, g := range game.Guesses {
|
||||
if g.Word == guess || g.Canonical == guess {
|
||||
return chathelper.ReplyHTML(ctx, b, msg.Chat.ID,
|
||||
fmt.Sprintf("🔁 <b>%s</b> was already guessed this round — try another word.",
|
||||
html.EscapeString(guess)))
|
||||
}
|
||||
}
|
||||
|
||||
res, err := s.api.Similarity(ctx, game.Target, guess)
|
||||
if err != nil {
|
||||
log.Warn("doantu similarity failed", "err", err)
|
||||
return chathelper.Reply(ctx, b, msg.Chat.ID, upstreamFail)
|
||||
}
|
||||
// Target OOV — JS-parity reset behaviour. The recorded round was seeded
|
||||
// pre-vocab-change; let player start fresh instead of fighting a ghost.
|
||||
if !res.InVocabA {
|
||||
log.Warn("doantu target OOV", "target", game.Target)
|
||||
_ = clearGame(ctx, s.kv, subject)
|
||||
return chathelper.ReplyHTML(ctx, b, msg.Chat.ID,
|
||||
"⚠️ This round's target is no longer valid (upstream vocabulary changed). "+
|
||||
"Send <code>/doantu</code> again to start a fresh round.")
|
||||
}
|
||||
if !res.InVocabB || res.Similarity == nil {
|
||||
return chathelper.ReplyHTML(ctx, b, msg.Chat.ID,
|
||||
fmt.Sprintf("🤔 <code>%s</code> isn't in the vocabulary.", html.EscapeString(guess)))
|
||||
}
|
||||
canonical := guess
|
||||
if res.CanonicalB != nil && *res.CanonicalB != "" {
|
||||
canonical = strings.ToLower(*res.CanonicalB)
|
||||
}
|
||||
|
||||
for _, g := range game.Guesses {
|
||||
if g.Canonical == canonical {
|
||||
return chathelper.ReplyHTML(ctx, b, msg.Chat.ID,
|
||||
fmt.Sprintf("🔁 <b>%s</b> was already guessed this round — try another word.",
|
||||
html.EscapeString(canonical)))
|
||||
}
|
||||
}
|
||||
|
||||
entry := Guess{Word: guess, Canonical: canonical, Similarity: *res.Similarity}
|
||||
game.Guesses = append(game.Guesses, entry)
|
||||
if game.StartedAt == nil {
|
||||
now := chathelper.NowMillis()
|
||||
game.StartedAt = &now
|
||||
}
|
||||
|
||||
if entry.Canonical == game.Target {
|
||||
game.Solved = true
|
||||
count := len(game.Guesses)
|
||||
if _, err := recordResult(ctx, s.kv, subject, true, count, chathelper.NowMillis()); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := clearGame(ctx, s.kv, subject); err != nil {
|
||||
return err
|
||||
}
|
||||
board := renderBoard(game.Guesses, entry.Canonical)
|
||||
return chathelper.ReplyHTML(ctx, b, msg.Chat.ID,
|
||||
fmt.Sprintf("%s\n✅ Solved in %d guess%s!", board, count, plural(count)))
|
||||
}
|
||||
|
||||
if err := saveGame(ctx, s.kv, subject, game); err != nil {
|
||||
return err
|
||||
}
|
||||
body := renderGuess(entry) + "\n" + renderBoard(game.Guesses, entry.Canonical)
|
||||
return chathelper.ReplyHTML(ctx, b, msg.Chat.ID, body)
|
||||
}
|
||||
|
||||
// playableWord matches lowercase Unicode letter / mark / underscore tokens.
|
||||
// Used to filter neighbor responses that include foreign place names.
|
||||
var playableWord = regexp.MustCompile(`^[\p{Ll}\p{M}_]+$`)
|
||||
|
||||
func looksVietnamese(word string) bool {
|
||||
if strings.Contains(word, "_") {
|
||||
return true
|
||||
}
|
||||
for _, r := range word {
|
||||
if r > 0x7f {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *state) pickHintWords(target string, neighbors []Neighbor, alreadyGuessed []string, count int) []Neighbor {
|
||||
guessedSet := make(map[string]struct{}, len(alreadyGuessed))
|
||||
for _, g := range alreadyGuessed {
|
||||
guessedSet[g] = struct{}{}
|
||||
}
|
||||
var playable []Neighbor
|
||||
for _, n := range neighbors {
|
||||
if !playableWord.MatchString(n.Word) || !looksVietnamese(n.Word) {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(n.Word, target) || strings.Contains(target, n.Word) {
|
||||
continue
|
||||
}
|
||||
if _, dup := guessedSet[n.Word]; dup {
|
||||
continue
|
||||
}
|
||||
playable = append(playable, n)
|
||||
}
|
||||
// Skip top 20% so hints stay "warm but not hot" (JS-parity).
|
||||
skip := len(playable) / 5
|
||||
if skip > 20 {
|
||||
skip = 20
|
||||
}
|
||||
if skip >= len(playable) {
|
||||
return nil
|
||||
}
|
||||
pool := playable[skip:]
|
||||
want := count
|
||||
if want > len(pool) {
|
||||
want = len(pool)
|
||||
}
|
||||
if want <= 0 {
|
||||
return nil
|
||||
}
|
||||
// Reservoir-style sample without replacement.
|
||||
s.rngMu.Lock()
|
||||
defer s.rngMu.Unlock()
|
||||
idx := s.rng.Perm(len(pool))[:want]
|
||||
out := make([]Neighbor, want)
|
||||
for i, j := range idx {
|
||||
out[i] = pool[j]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *state) handleHint(ctx context.Context, b *bot.Bot, update *models.Update) error {
|
||||
msg := update.Message
|
||||
if msg == nil {
|
||||
return nil
|
||||
}
|
||||
subject := chathelper.SubjectFor(msg)
|
||||
if subject == "" {
|
||||
return chathelper.Reply(ctx, b, msg.Chat.ID, "Cannot identify chat.")
|
||||
}
|
||||
game, err := loadGame(ctx, s.kv, subject)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if game == nil || game.Solved {
|
||||
return chathelper.ReplyHTML(ctx, b, msg.Chat.ID,
|
||||
"No active round. Send <code>/doantu</code> to start one.")
|
||||
}
|
||||
res, err := s.api.Neighbors(ctx, game.Target, 100)
|
||||
if err != nil {
|
||||
log.Warn("doantu neighbors failed", "err", err)
|
||||
return chathelper.Reply(ctx, b, msg.Chat.ID, upstreamFail)
|
||||
}
|
||||
already := make([]string, 0, len(game.Guesses))
|
||||
for _, g := range game.Guesses {
|
||||
already = append(already, g.Canonical)
|
||||
}
|
||||
picks := s.pickHintWords(game.Target, res.Neighbors, already, 3)
|
||||
if len(picks) == 0 {
|
||||
return chathelper.Reply(ctx, b, msg.Chat.ID, "🤷 No usable hints available for this round.")
|
||||
}
|
||||
var lines []string
|
||||
for _, p := range picks {
|
||||
lines = append(lines, fmt.Sprintf("• <code>%s</code>", html.EscapeString(p.Word)))
|
||||
}
|
||||
return chathelper.ReplyHTML(ctx, b, msg.Chat.ID,
|
||||
"💡 <b>Hints</b> — related words (not the answer):\n"+strings.Join(lines, "\n"))
|
||||
}
|
||||
|
||||
func (s *state) handleGiveup(ctx context.Context, b *bot.Bot, update *models.Update) error {
|
||||
msg := update.Message
|
||||
if msg == nil {
|
||||
return nil
|
||||
}
|
||||
subject := chathelper.SubjectFor(msg)
|
||||
if subject == "" {
|
||||
return chathelper.Reply(ctx, b, msg.Chat.ID, "Cannot identify chat.")
|
||||
}
|
||||
defer s.locks.Acquire(subject)()
|
||||
game, err := loadGame(ctx, s.kv, subject)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if game == nil {
|
||||
return chathelper.ReplyHTML(ctx, b, msg.Chat.ID,
|
||||
"No active round. Send <code>/doantu</code> to start one.")
|
||||
}
|
||||
if _, err := recordResult(ctx, s.kv, subject, false, len(game.Guesses), chathelper.NowMillis()); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := clearGame(ctx, s.kv, subject); err != nil {
|
||||
return err
|
||||
}
|
||||
return chathelper.ReplyHTML(ctx, b, msg.Chat.ID,
|
||||
fmt.Sprintf("🏳️ The target was <b>%s</b>. Send <code>/doantu</code> for a new round.",
|
||||
html.EscapeString(game.Target)))
|
||||
}
|
||||
|
||||
func (s *state) handleStats(ctx context.Context, b *bot.Bot, update *models.Update) error {
|
||||
msg := update.Message
|
||||
if msg == nil {
|
||||
return nil
|
||||
}
|
||||
subject := chathelper.SubjectFor(msg)
|
||||
if subject == "" {
|
||||
return chathelper.Reply(ctx, b, msg.Chat.ID, "Cannot identify chat.")
|
||||
}
|
||||
st, err := loadStats(ctx, s.kv, subject)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if st.Played == 0 {
|
||||
return chathelper.Reply(ctx, b, msg.Chat.ID, "No doantu games played yet.")
|
||||
}
|
||||
solveRate := chathelper.WinRate(st.Solved, st.Played)
|
||||
avg := "—"
|
||||
if st.Played > 0 {
|
||||
avg = fmt.Sprintf("%d", roundDiv(st.TotalGuesses, st.Played))
|
||||
}
|
||||
best := "—"
|
||||
if st.BestGuessCount != nil {
|
||||
best = fmt.Sprintf("%d", *st.BestGuessCount)
|
||||
}
|
||||
body := fmt.Sprintf(
|
||||
"🇻🇳 <b>Đoán từ stats</b>\nPlayed: %d\nSolved: %d (%d%%)\nTotal guesses: %d\nFewest to solve: %s\nAvg per round: %s",
|
||||
st.Played, st.Solved, solveRate, st.TotalGuesses, best, avg,
|
||||
)
|
||||
return chathelper.ReplyHTML(ctx, b, msg.Chat.ID, body)
|
||||
}
|
||||
|
||||
// roundDiv rounds (a/b) half-away-from-zero (JS Math.round parity for non-neg).
|
||||
func roundDiv(a, b int) int {
|
||||
if b <= 0 {
|
||||
return 0
|
||||
}
|
||||
return (a*2 + b) / (2 * b)
|
||||
}
|
||||
|
||||
// asUpstream is a helper for tests that want to assert specific error types
|
||||
// without leaking internals. Currently unused outside the package.
|
||||
var _ = errors.As
|
||||
@@ -0,0 +1,24 @@
|
||||
package doantu
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// shapeRe: Unicode letters + combining marks, single space between syllables
|
||||
// for compound words (`con chó`, `máy bay`). Mirrors JS lookup.js.
|
||||
var shapeRe = regexp.MustCompile(`^[\p{L}\p{M}]+(?: [\p{L}\p{M}]+)*$`)
|
||||
|
||||
func normalize(raw string) string {
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
return strings.ToLower(strings.Join(strings.Fields(strings.TrimSpace(raw)), " "))
|
||||
}
|
||||
|
||||
func isValidShape(word string) bool {
|
||||
if word == "" || len(word) > 64 {
|
||||
return false
|
||||
}
|
||||
return shapeRe.MatchString(word)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package doantu
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNormalize(t *testing.T) {
|
||||
cases := []struct{ in, want string }{
|
||||
{" Con chó ", "con chó"},
|
||||
{"Máy Bay", "máy bay"},
|
||||
{"", ""},
|
||||
{" ", ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := normalize(c.in); got != c.want {
|
||||
t.Errorf("normalize(%q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsValidShape(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want bool
|
||||
}{
|
||||
{"con chó", true},
|
||||
{"máy bay", true},
|
||||
{"hello", true},
|
||||
{"", false},
|
||||
{"abc 123", false}, // digits
|
||||
{"abc!", false}, // punctuation
|
||||
{" ", false}, // empty after collapse
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := isValidShape(c.in); got != c.want {
|
||||
t.Errorf("isValidShape(%q) = %v, want %v", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLooksVietnamese(t *testing.T) {
|
||||
cases := []struct {
|
||||
w string
|
||||
want bool
|
||||
}{
|
||||
{"con", false}, // pure ASCII, no underscore
|
||||
{"chó", true}, // diacritic
|
||||
{"thanh_pho", true}, // compound
|
||||
{"hello", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := looksVietnamese(c.w); got != c.want {
|
||||
t.Errorf("looksVietnamese(%q) = %v, want %v", c.w, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package doantu
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html"
|
||||
"sort"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
const (
|
||||
maxRows = 15
|
||||
latestMarker = "➡️"
|
||||
plainMarker = " "
|
||||
maxWordWidth = 20
|
||||
)
|
||||
|
||||
func renderBoard(guesses []Guess, latestCanonical string) string {
|
||||
count := len(guesses)
|
||||
header := fmt.Sprintf("🇻🇳 Đoán từ — %d guess%s", count, plural(count))
|
||||
if count == 0 {
|
||||
return header + "\n🆕 Round ready — reply with <code>/doantu <word></code>."
|
||||
}
|
||||
|
||||
sorted := make([]Guess, len(guesses))
|
||||
copy(sorted, guesses)
|
||||
sort.SliceStable(sorted, func(i, j int) bool {
|
||||
return sorted[i].Similarity > sorted[j].Similarity
|
||||
})
|
||||
if len(sorted) > maxRows {
|
||||
sorted = sorted[:maxRows]
|
||||
}
|
||||
|
||||
wordWidth := 0
|
||||
for _, g := range sorted {
|
||||
// Use rune count for visual width — Vietnamese diacritics matter.
|
||||
if l := utf8.RuneCountInString(g.Canonical); l > wordWidth {
|
||||
wordWidth = l
|
||||
}
|
||||
}
|
||||
if wordWidth > maxWordWidth {
|
||||
wordWidth = maxWordWidth
|
||||
}
|
||||
|
||||
var lines []string
|
||||
for i, g := range sorted {
|
||||
score := calibrate(g.Similarity)
|
||||
marker := plainMarker
|
||||
if g.Canonical == latestCanonical {
|
||||
marker = latestMarker
|
||||
}
|
||||
rank := padLeft(fmt.Sprintf("%d", i+1), 2)
|
||||
warmth := padLeft(formatWarmth(score), 3)
|
||||
word := html.EscapeString(padRunesRight(g.Canonical, wordWidth))
|
||||
lines = append(lines, fmt.Sprintf("%s %s %s %s %s", marker, rank, warmth, word, warmthEmoji(score)))
|
||||
}
|
||||
|
||||
body := "<pre>" + strings.Join(lines, "\n") + "</pre>"
|
||||
footer := ""
|
||||
if hidden := count - len(sorted); hidden > 0 {
|
||||
footer = fmt.Sprintf("\n…%d older guess%s hidden.", hidden, plural(hidden))
|
||||
}
|
||||
return header + "\n" + body + footer
|
||||
}
|
||||
|
||||
func renderGuess(g Guess) string {
|
||||
score := calibrate(g.Similarity)
|
||||
return fmt.Sprintf("<code>%s</code> → %s %s",
|
||||
html.EscapeString(g.Canonical), formatWarmth(score), warmthEmoji(score))
|
||||
}
|
||||
|
||||
func plural(n int) string {
|
||||
if n == 1 {
|
||||
return ""
|
||||
}
|
||||
return "es"
|
||||
}
|
||||
|
||||
func padLeft(s string, w int) string {
|
||||
pad := w - len(s)
|
||||
if pad <= 0 {
|
||||
return s
|
||||
}
|
||||
return strings.Repeat(" ", pad) + s
|
||||
}
|
||||
|
||||
func padRunesRight(s string, w int) string {
|
||||
pad := w - utf8.RuneCountInString(s)
|
||||
if pad <= 0 {
|
||||
return s
|
||||
}
|
||||
return s + strings.Repeat(" ", pad)
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package doantu
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/tiennm99/miti99bot-go/internal/storage"
|
||||
)
|
||||
|
||||
type Guess struct {
|
||||
Word string `json:"word"`
|
||||
Canonical string `json:"canonical"`
|
||||
Similarity float64 `json:"similarity"`
|
||||
}
|
||||
|
||||
type GameState struct {
|
||||
Target string `json:"target"`
|
||||
StartedAt *int64 `json:"startedAt"`
|
||||
Solved bool `json:"solved"`
|
||||
Guesses []Guess `json:"guesses"`
|
||||
}
|
||||
|
||||
type Stats struct {
|
||||
Played int `json:"played"`
|
||||
Solved int `json:"solved"`
|
||||
TotalGuesses int `json:"totalGuesses"`
|
||||
BestGuessCount *int `json:"bestGuessCount"`
|
||||
LastResultAt *int64 `json:"lastResultAt"`
|
||||
}
|
||||
|
||||
func gameKey(subject string) string { return "game:" + subject }
|
||||
func statsKey(subject string) string { return "stats:" + subject }
|
||||
|
||||
func loadGame(ctx context.Context, kv storage.KVStore, subject string) (*GameState, error) {
|
||||
var g GameState
|
||||
err := kv.GetJSON(ctx, gameKey(subject), &g)
|
||||
switch {
|
||||
case err == nil:
|
||||
return &g, nil
|
||||
case errors.Is(err, storage.ErrNotFound):
|
||||
return nil, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("doantu loadGame: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
func saveGame(ctx context.Context, kv storage.KVStore, subject string, g *GameState) error {
|
||||
if err := kv.PutJSON(ctx, gameKey(subject), g); err != nil {
|
||||
return fmt.Errorf("doantu saveGame: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func clearGame(ctx context.Context, kv storage.KVStore, subject string) error {
|
||||
if err := kv.Delete(ctx, gameKey(subject)); err != nil && !errors.Is(err, storage.ErrNotFound) {
|
||||
return fmt.Errorf("doantu clearGame: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadStats(ctx context.Context, kv storage.KVStore, subject string) (*Stats, error) {
|
||||
var s Stats
|
||||
err := kv.GetJSON(ctx, statsKey(subject), &s)
|
||||
switch {
|
||||
case err == nil:
|
||||
return &s, nil
|
||||
case errors.Is(err, storage.ErrNotFound):
|
||||
return &Stats{}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("doantu loadStats: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
func recordResult(ctx context.Context, kv storage.KVStore, subject string, solved bool, guessCount int, nowMillis int64) (*Stats, error) {
|
||||
s, err := loadStats(ctx, kv, subject)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.Played++
|
||||
s.TotalGuesses += guessCount
|
||||
if solved {
|
||||
s.Solved++
|
||||
if s.BestGuessCount == nil || guessCount < *s.BestGuessCount {
|
||||
gc := guessCount
|
||||
s.BestGuessCount = &gc
|
||||
}
|
||||
}
|
||||
now := nowMillis
|
||||
s.LastResultAt = &now
|
||||
if err := kv.PutJSON(ctx, statsKey(subject), s); err != nil {
|
||||
return nil, fmt.Errorf("doantu recordResult: %w", err)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"github.com/go-telegram/bot"
|
||||
"github.com/go-telegram/bot/models"
|
||||
|
||||
"github.com/tiennm99/miti99bot-go/internal/ai"
|
||||
"github.com/tiennm99/miti99bot-go/internal/storage"
|
||||
)
|
||||
|
||||
@@ -76,6 +77,8 @@ type Deps struct {
|
||||
KV storage.KVStore // already prefixed with the module name when passed to a Factory
|
||||
Env map[string]string // empty by default; per-module allowlist (Phase 07+)
|
||||
Registry *Registry // populated by Build; safe to capture but read-only at module use
|
||||
Embedder ai.Embedder // nil if GEMINI_API_KEY unset; semantle/doantu must check
|
||||
Chatter ai.Chatter // nil if GEMINI_API_KEY unset; twentyq must check
|
||||
}
|
||||
|
||||
// Factory constructs a Module from its Deps. Spec deviation: Phase 03 plan
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"regexp"
|
||||
"sort"
|
||||
|
||||
"github.com/tiennm99/miti99bot-go/internal/ai"
|
||||
"github.com/tiennm99/miti99bot-go/internal/storage"
|
||||
)
|
||||
|
||||
@@ -76,7 +77,15 @@ func (r *Registry) Crons() []Cron {
|
||||
// Names not present in factories are reported as a single error so a typo in
|
||||
// MODULES does not silently load a smaller bot than intended. Duplicate names
|
||||
// in MODULES are also a hard error to keep startup deterministic.
|
||||
func Build(enabled []string, factories map[string]Factory, kv storage.KVProvider, env map[string]string) (*Registry, error) {
|
||||
// BuildOptions bundles the optional dependencies threaded into every Module
|
||||
// Factory's Deps. Adding new optional deps here keeps Build's signature
|
||||
// stable as the dep list grows.
|
||||
type BuildOptions struct {
|
||||
Embedder ai.Embedder
|
||||
Chatter ai.Chatter
|
||||
}
|
||||
|
||||
func Build(enabled []string, factories map[string]Factory, kv storage.KVProvider, env map[string]string, opts BuildOptions) (*Registry, error) {
|
||||
if kv == nil {
|
||||
return nil, fmt.Errorf("modules: KVProvider is required")
|
||||
}
|
||||
@@ -114,6 +123,8 @@ func Build(enabled []string, factories map[string]Factory, kv storage.KVProvider
|
||||
KV: kv.For(name),
|
||||
Env: env,
|
||||
Registry: reg,
|
||||
Embedder: opts.Embedder,
|
||||
Chatter: opts.Chatter,
|
||||
}
|
||||
mod := factory(moduleDeps)
|
||||
// A factory that hardcodes its own Name is a bug: the registry key is
|
||||
|
||||
@@ -38,7 +38,7 @@ func factory(name string, cmds []Command, crons []Cron) Factory {
|
||||
func newProvider() storage.KVProvider { return storage.NewMemoryProvider() }
|
||||
|
||||
func TestBuild_EmptyModulesBootsCleanly(t *testing.T) {
|
||||
reg, err := Build(nil, map[string]Factory{}, newProvider(), nil)
|
||||
reg, err := Build(nil, map[string]Factory{}, newProvider(), nil, BuildOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("Build empty: %v", err)
|
||||
}
|
||||
@@ -52,7 +52,7 @@ func TestBuild_LoadsRequestedModules(t *testing.T) {
|
||||
"alpha": factory("alpha", []Command{noopCmd("a1")}, nil),
|
||||
"beta": factory("beta", []Command{noopCmd("b1")}, []Cron{noopCron("daily")}),
|
||||
}
|
||||
reg, err := Build([]string{"alpha", "beta"}, factories, newProvider(), nil)
|
||||
reg, err := Build([]string{"alpha", "beta"}, factories, newProvider(), nil, BuildOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("Build: %v", err)
|
||||
}
|
||||
@@ -72,7 +72,7 @@ func TestBuild_SkipsModulesNotInEnv(t *testing.T) {
|
||||
"alpha": factory("alpha", []Command{noopCmd("a1")}, nil),
|
||||
"beta": factory("beta", []Command{noopCmd("b1")}, nil),
|
||||
}
|
||||
reg, err := Build([]string{"alpha"}, factories, newProvider(), nil)
|
||||
reg, err := Build([]string{"alpha"}, factories, newProvider(), nil, BuildOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("Build: %v", err)
|
||||
}
|
||||
@@ -82,7 +82,7 @@ func TestBuild_SkipsModulesNotInEnv(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestBuild_RejectsUnknownModule(t *testing.T) {
|
||||
_, err := Build([]string{"ghost"}, map[string]Factory{}, newProvider(), nil)
|
||||
_, err := Build([]string{"ghost"}, map[string]Factory{}, newProvider(), nil, BuildOptions{})
|
||||
if err == nil || !strings.Contains(err.Error(), "ghost") {
|
||||
t.Errorf("expected error mentioning ghost, got %v", err)
|
||||
}
|
||||
@@ -93,7 +93,7 @@ func TestBuild_DetectsCommandConflict(t *testing.T) {
|
||||
"alpha": factory("alpha", []Command{noopCmd("ping")}, nil),
|
||||
"beta": factory("beta", []Command{noopCmd("ping")}, nil),
|
||||
}
|
||||
_, err := Build([]string{"alpha", "beta"}, factories, newProvider(), nil)
|
||||
_, err := Build([]string{"alpha", "beta"}, factories, newProvider(), nil, BuildOptions{})
|
||||
if err == nil {
|
||||
t.Fatal("expected conflict error")
|
||||
}
|
||||
@@ -107,14 +107,14 @@ func TestBuild_DetectsCronConflict(t *testing.T) {
|
||||
"alpha": factory("alpha", nil, []Cron{noopCron("daily")}),
|
||||
"beta": factory("beta", nil, []Cron{noopCron("daily")}),
|
||||
}
|
||||
_, err := Build([]string{"alpha", "beta"}, factories, newProvider(), nil)
|
||||
_, err := Build([]string{"alpha", "beta"}, factories, newProvider(), nil, BuildOptions{})
|
||||
if err == nil || !strings.Contains(err.Error(), "cron conflict") {
|
||||
t.Errorf("expected cron conflict, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuild_RequiresProvider(t *testing.T) {
|
||||
_, err := Build(nil, map[string]Factory{}, nil, nil)
|
||||
_, err := Build(nil, map[string]Factory{}, nil, nil, BuildOptions{})
|
||||
if err == nil {
|
||||
t.Error("expected error when KVProvider is nil")
|
||||
}
|
||||
@@ -125,7 +125,7 @@ func TestBuild_ValidationErrorsMentionModule(t *testing.T) {
|
||||
factories := map[string]Factory{
|
||||
"alpha": factory("alpha", []Command{bad}, nil),
|
||||
}
|
||||
_, err := Build([]string{"alpha"}, factories, newProvider(), nil)
|
||||
_, err := Build([]string{"alpha"}, factories, newProvider(), nil, BuildOptions{})
|
||||
if err == nil || !strings.Contains(err.Error(), "alpha") {
|
||||
t.Errorf("expected error mentioning module 'alpha', got %v", err)
|
||||
}
|
||||
@@ -142,7 +142,7 @@ func TestDispatchScheduled_RunsHandler(t *testing.T) {
|
||||
},
|
||||
}}),
|
||||
}
|
||||
reg, err := Build([]string{"alpha"}, factories, newProvider(), nil)
|
||||
reg, err := Build([]string{"alpha"}, factories, newProvider(), nil, BuildOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("Build: %v", err)
|
||||
}
|
||||
@@ -155,7 +155,7 @@ func TestDispatchScheduled_RunsHandler(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestDispatchScheduled_UnknownReturnsErrCronNotFound(t *testing.T) {
|
||||
reg, err := Build(nil, map[string]Factory{}, newProvider(), nil)
|
||||
reg, err := Build(nil, map[string]Factory{}, newProvider(), nil, BuildOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("Build: %v", err)
|
||||
}
|
||||
@@ -187,7 +187,7 @@ func TestDispatchScheduled_PassesPrefixedDeps(t *testing.T) {
|
||||
}}}
|
||||
},
|
||||
}
|
||||
reg, err := Build([]string{"alpha", "beta"}, factories, provider, nil)
|
||||
reg, err := Build([]string{"alpha", "beta"}, factories, provider, nil, BuildOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("Build: %v", err)
|
||||
}
|
||||
@@ -215,7 +215,7 @@ func TestBuild_RejectsInvalidModuleName(t *testing.T) {
|
||||
// prefix delimiter and a hyphen-allowing regex must not let it through.
|
||||
for _, name := range []string{"BadName", "a:b", "", "with space", "with.dot", "with/slash"} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
_, err := Build([]string{name}, map[string]Factory{}, newProvider(), nil)
|
||||
_, err := Build([]string{name}, map[string]Factory{}, newProvider(), nil, BuildOptions{})
|
||||
if err == nil {
|
||||
t.Errorf("name %q: expected error", name)
|
||||
}
|
||||
@@ -231,7 +231,7 @@ func TestBuild_RejectsFactoryNameMismatch(t *testing.T) {
|
||||
return Module{Name: "imposter", Commands: []Command{noopCmd("a1")}}
|
||||
},
|
||||
}
|
||||
_, err := Build([]string{"alpha"}, factories, newProvider(), nil)
|
||||
_, err := Build([]string{"alpha"}, factories, newProvider(), nil, BuildOptions{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for factory Name mismatch")
|
||||
}
|
||||
@@ -248,7 +248,7 @@ func TestBuild_AllowsFactoryWithBlankName(t *testing.T) {
|
||||
return Module{Commands: []Command{noopCmd("a1")}}
|
||||
},
|
||||
}
|
||||
reg, err := Build([]string{"alpha"}, factories, newProvider(), nil)
|
||||
reg, err := Build([]string{"alpha"}, factories, newProvider(), nil, BuildOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("Build: %v", err)
|
||||
}
|
||||
@@ -261,7 +261,7 @@ func TestBuild_AcceptsHyphenatedModuleName(t *testing.T) {
|
||||
factories := map[string]Factory{
|
||||
"loldle-emoji": factory("loldle-emoji", []Command{noopCmd("emoji_cmd")}, nil),
|
||||
}
|
||||
reg, err := Build([]string{"loldle-emoji"}, factories, newProvider(), nil)
|
||||
reg, err := Build([]string{"loldle-emoji"}, factories, newProvider(), nil, BuildOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("hyphenated name should be allowed: %v", err)
|
||||
}
|
||||
@@ -274,7 +274,7 @@ func TestBuild_RejectsDuplicateModuleInEnv(t *testing.T) {
|
||||
factories := map[string]Factory{
|
||||
"alpha": factory("alpha", []Command{noopCmd("a1")}, nil),
|
||||
}
|
||||
_, err := Build([]string{"alpha", "alpha"}, factories, newProvider(), nil)
|
||||
_, err := Build([]string{"alpha", "alpha"}, factories, newProvider(), nil, BuildOptions{})
|
||||
if err == nil || !strings.Contains(err.Error(), "duplicate") {
|
||||
t.Errorf("expected duplicate-module error, got %v", err)
|
||||
}
|
||||
@@ -297,7 +297,7 @@ func TestBuild_PerModulePrefixedKV(t *testing.T) {
|
||||
return Module{Commands: []Command{noopCmd("b")}}
|
||||
},
|
||||
}
|
||||
if _, err := Build([]string{"alpha", "beta"}, factories, provider, nil); err != nil {
|
||||
if _, err := Build([]string{"alpha", "beta"}, factories, provider, nil, BuildOptions{}); err != nil {
|
||||
t.Fatalf("Build: %v", err)
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,86 @@
|
||||
package semantle
|
||||
|
||||
import "math"
|
||||
|
||||
// Calibration constants — tuned empirically for bge-m3 on the JS side.
|
||||
// text-embedding-004 (768d, this port) lives in a similar narrow cone, so
|
||||
// the same sigmoid mapping holds well enough that retuning is not blocking
|
||||
// for v1. Phase 11 soak data may justify a re-fit; until then, JS parity.
|
||||
const (
|
||||
floor = 0.4
|
||||
center = 0.6
|
||||
scale = 8.0
|
||||
)
|
||||
|
||||
var (
|
||||
floorSig = sigmoid(scale * (floor - center))
|
||||
oneSig = sigmoid(scale * (1 - center))
|
||||
sigRange = oneSig - floorSig
|
||||
)
|
||||
|
||||
func sigmoid(x float64) float64 { return 1.0 / (1.0 + math.Exp(-x)) }
|
||||
|
||||
// calibrate maps raw cosine ∈ [-1, 1] → display score ∈ [0, 100]. Mirrors
|
||||
// JS format.js calibrate(). Returns 0 below floor, 100 at exact match.
|
||||
func calibrate(raw float64) float64 {
|
||||
if raw >= 1 {
|
||||
return 100
|
||||
}
|
||||
if raw <= floor {
|
||||
return 0
|
||||
}
|
||||
s := sigmoid(scale * (raw - center))
|
||||
v := ((s - floorSig) / sigRange) * 100
|
||||
switch {
|
||||
case v < 0:
|
||||
return 0
|
||||
case v > 100:
|
||||
return 100
|
||||
default:
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
// formatWarmth: zero-padded percent, width 2 ("07", "54", "100").
|
||||
func formatWarmth(score float64) string {
|
||||
pct := int(math.Round(score))
|
||||
if pct >= 100 {
|
||||
return "100"
|
||||
}
|
||||
if pct < 10 {
|
||||
return "0" + itoa(pct)
|
||||
}
|
||||
return itoa(pct)
|
||||
}
|
||||
|
||||
// warmthEmoji: bucket emoji by calibrated score, JS-parity thresholds.
|
||||
func warmthEmoji(score float64) string {
|
||||
switch {
|
||||
case score >= 90:
|
||||
return "🎯"
|
||||
case score >= 70:
|
||||
return "🔥"
|
||||
case score >= 40:
|
||||
return "🌡️"
|
||||
case score >= 15:
|
||||
return "😐"
|
||||
default:
|
||||
return "🥶"
|
||||
}
|
||||
}
|
||||
|
||||
// itoa is a tiny stdlib-free int→string for the 0-99 range above. strconv
|
||||
// works too; this is a perf nit borrowed from wordle/render. Either is fine.
|
||||
func itoa(n int) string {
|
||||
if n == 0 {
|
||||
return "0"
|
||||
}
|
||||
var buf [4]byte
|
||||
i := len(buf)
|
||||
for n > 0 {
|
||||
i--
|
||||
buf[i] = byte('0' + n%10)
|
||||
n /= 10
|
||||
}
|
||||
return string(buf[i:])
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
package semantle
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html"
|
||||
"math/rand/v2"
|
||||
"sync"
|
||||
|
||||
"github.com/go-telegram/bot"
|
||||
"github.com/go-telegram/bot/models"
|
||||
|
||||
"github.com/tiennm99/miti99bot-go/internal/ai"
|
||||
"github.com/tiennm99/miti99bot-go/internal/keylock"
|
||||
"github.com/tiennm99/miti99bot-go/internal/log"
|
||||
"github.com/tiennm99/miti99bot-go/internal/modules/util/chathelper"
|
||||
"github.com/tiennm99/miti99bot-go/internal/storage"
|
||||
)
|
||||
|
||||
const (
|
||||
upstreamFail = "⚠️ Upstream hiccup — try again in a few seconds."
|
||||
notConfig = "⚠️ Semantle is not configured (missing GEMINI_API_KEY)."
|
||||
rateLimited = "⚠️ AI is rate-limited. Try again in a minute."
|
||||
)
|
||||
|
||||
// state is what every handler captures. Loaded once in New.
|
||||
type state struct {
|
||||
kv storage.KVStore
|
||||
embedder ai.Embedder
|
||||
limiter *ai.PerUserLimiter
|
||||
words []string
|
||||
vocab map[string]struct{}
|
||||
|
||||
rngMu sync.Mutex
|
||||
rng *rand.Rand // overridable by tests via newWithRNG; defaults to crypto-seeded
|
||||
|
||||
locks keylock.Map
|
||||
}
|
||||
|
||||
// pickTarget returns a random word from the pool. Lock-protected so
|
||||
// concurrent handlers see deterministic behavior under a fixed-seed test.
|
||||
func (s *state) pickTarget() string {
|
||||
s.rngMu.Lock()
|
||||
defer s.rngMu.Unlock()
|
||||
return s.words[s.rng.IntN(len(s.words))]
|
||||
}
|
||||
|
||||
func (s *state) startFresh(ctx context.Context, subject string) (*GameState, error) {
|
||||
target := s.pickTarget()
|
||||
g := &GameState{Target: target, StartedAt: nil, Solved: false, Guesses: []Guess{}}
|
||||
if err := saveGame(ctx, s.kv, subject, g); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return g, nil
|
||||
}
|
||||
|
||||
func (s *state) getOrInit(ctx context.Context, subject string) (*GameState, error) {
|
||||
existing, err := loadGame(ctx, s.kv, subject)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if existing != nil && !existing.Solved {
|
||||
return existing, nil
|
||||
}
|
||||
return s.startFresh(ctx, subject)
|
||||
}
|
||||
|
||||
// handleSemantle: /semantle [word] — show board if no arg, else submit guess.
|
||||
func (s *state) handleSemantle(ctx context.Context, b *bot.Bot, update *models.Update) error {
|
||||
msg := update.Message
|
||||
if msg == nil {
|
||||
return nil
|
||||
}
|
||||
subject := chathelper.SubjectFor(msg)
|
||||
if subject == "" {
|
||||
return chathelper.Reply(ctx, b, msg.Chat.ID, "Cannot identify chat.")
|
||||
}
|
||||
if s.embedder == nil {
|
||||
return chathelper.Reply(ctx, b, msg.Chat.ID, notConfig)
|
||||
}
|
||||
defer s.locks.Acquire(subject)()
|
||||
|
||||
arg := chathelper.ArgAfterCommand(msg.Text)
|
||||
game, err := s.getOrInit(ctx, subject)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if arg == "" {
|
||||
return chathelper.ReplyHTML(ctx, b, msg.Chat.ID, renderBoard(game.Guesses, ""))
|
||||
}
|
||||
return s.submitGuess(ctx, b, msg, subject, game, arg)
|
||||
}
|
||||
|
||||
func (s *state) submitGuess(ctx context.Context, b *bot.Bot, msg *models.Message, subject string, game *GameState, arg string) error {
|
||||
guess := normalize(arg)
|
||||
if !isValidShape(guess) {
|
||||
return chathelper.Reply(ctx, b, msg.Chat.ID, "Please provide a single letter-only word.")
|
||||
}
|
||||
// Fast-path dedup: same raw or same canonical → no upstream call.
|
||||
for _, g := range game.Guesses {
|
||||
if g.Word == guess || g.Canonical == guess {
|
||||
return chathelper.ReplyHTML(ctx, b, msg.Chat.ID,
|
||||
fmt.Sprintf("🔁 <b>%s</b> was already guessed this round — try another word.",
|
||||
html.EscapeString(guess)))
|
||||
}
|
||||
}
|
||||
// OOV cheap-check before spending a Gemini call.
|
||||
if _, ok := s.vocab[guess]; !ok {
|
||||
return chathelper.ReplyHTML(ctx, b, msg.Chat.ID,
|
||||
fmt.Sprintf("🤔 <code>%s</code> isn't in the vocabulary.", html.EscapeString(guess)))
|
||||
}
|
||||
// Per-user rate limit. Bucket key = subject so DM and group are scoped.
|
||||
if s.limiter != nil && !s.limiter.Allow(subject) {
|
||||
return chathelper.Reply(ctx, b, msg.Chat.ID, "⏳ Slow down — too many guesses in a short window.")
|
||||
}
|
||||
|
||||
vecs, err := s.embedder.Embed(ctx, []string{game.Target, guess})
|
||||
if err != nil {
|
||||
if errors.Is(err, ai.ErrRateLimited) {
|
||||
return chathelper.Reply(ctx, b, msg.Chat.ID, rateLimited)
|
||||
}
|
||||
log.Warn("semantle embed failed", "err", err)
|
||||
return chathelper.Reply(ctx, b, msg.Chat.ID, upstreamFail)
|
||||
}
|
||||
if len(vecs) != 2 {
|
||||
log.Warn("semantle embed: bad vec count", "got", len(vecs))
|
||||
return chathelper.Reply(ctx, b, msg.Chat.ID, upstreamFail)
|
||||
}
|
||||
sim, ok := cosine(vecs[0], vecs[1])
|
||||
if !ok {
|
||||
return chathelper.ReplyHTML(ctx, b, msg.Chat.ID,
|
||||
fmt.Sprintf("🤔 <code>%s</code> isn't in the vocabulary.", html.EscapeString(guess)))
|
||||
}
|
||||
|
||||
entry := Guess{Word: guess, Canonical: guess, Similarity: sim}
|
||||
game.Guesses = append(game.Guesses, entry)
|
||||
if game.StartedAt == nil {
|
||||
now := chathelper.NowMillis()
|
||||
game.StartedAt = &now
|
||||
}
|
||||
|
||||
if entry.Canonical == game.Target {
|
||||
game.Solved = true
|
||||
count := len(game.Guesses)
|
||||
if _, err := recordResult(ctx, s.kv, subject, true, count, chathelper.NowMillis()); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := clearGame(ctx, s.kv, subject); err != nil {
|
||||
return err
|
||||
}
|
||||
board := renderBoard(game.Guesses, entry.Canonical)
|
||||
return chathelper.ReplyHTML(ctx, b, msg.Chat.ID,
|
||||
fmt.Sprintf("%s\n✅ Solved in %d guess%s!", board, count, plural(count)))
|
||||
}
|
||||
|
||||
if err := saveGame(ctx, s.kv, subject, game); err != nil {
|
||||
return err
|
||||
}
|
||||
body := renderGuess(entry) + "\n" + renderBoard(game.Guesses, entry.Canonical)
|
||||
return chathelper.ReplyHTML(ctx, b, msg.Chat.ID, body)
|
||||
}
|
||||
|
||||
// handleGiveup: /semantle_giveup — reveal target + end round + record loss.
|
||||
func (s *state) handleGiveup(ctx context.Context, b *bot.Bot, update *models.Update) error {
|
||||
msg := update.Message
|
||||
if msg == nil {
|
||||
return nil
|
||||
}
|
||||
subject := chathelper.SubjectFor(msg)
|
||||
if subject == "" {
|
||||
return chathelper.Reply(ctx, b, msg.Chat.ID, "Cannot identify chat.")
|
||||
}
|
||||
defer s.locks.Acquire(subject)()
|
||||
game, err := loadGame(ctx, s.kv, subject)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if game == nil {
|
||||
return chathelper.ReplyHTML(ctx, b, msg.Chat.ID,
|
||||
"No active round. Send <code>/semantle</code> to start one.")
|
||||
}
|
||||
if _, err := recordResult(ctx, s.kv, subject, false, len(game.Guesses), chathelper.NowMillis()); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := clearGame(ctx, s.kv, subject); err != nil {
|
||||
return err
|
||||
}
|
||||
return chathelper.ReplyHTML(ctx, b, msg.Chat.ID,
|
||||
fmt.Sprintf("🏳️ The target was <b>%s</b>. Send <code>/semantle</code> for a new round.",
|
||||
html.EscapeString(game.Target)))
|
||||
}
|
||||
|
||||
// handleStats: /semantle_stats — lifetime score.
|
||||
func (s *state) handleStats(ctx context.Context, b *bot.Bot, update *models.Update) error {
|
||||
msg := update.Message
|
||||
if msg == nil {
|
||||
return nil
|
||||
}
|
||||
subject := chathelper.SubjectFor(msg)
|
||||
if subject == "" {
|
||||
return chathelper.Reply(ctx, b, msg.Chat.ID, "Cannot identify chat.")
|
||||
}
|
||||
st, err := loadStats(ctx, s.kv, subject)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if st.Played == 0 {
|
||||
return chathelper.Reply(ctx, b, msg.Chat.ID, "No semantle games played yet.")
|
||||
}
|
||||
solveRate := chathelper.WinRate(st.Solved, st.Played)
|
||||
avg := "—"
|
||||
if st.Played > 0 {
|
||||
avg = fmt.Sprintf("%d", roundDiv(st.TotalGuesses, st.Played))
|
||||
}
|
||||
best := "—"
|
||||
if st.BestGuessCount != nil {
|
||||
best = fmt.Sprintf("%d", *st.BestGuessCount)
|
||||
}
|
||||
body := fmt.Sprintf(
|
||||
"🎯 <b>Semantle stats</b>\nPlayed: %d\nSolved: %d (%d%%)\nTotal guesses: %d\nFewest to solve: %s\nAvg per round: %s",
|
||||
st.Played, st.Solved, solveRate, st.TotalGuesses, best, avg,
|
||||
)
|
||||
return chathelper.ReplyHTML(ctx, b, msg.Chat.ID, body)
|
||||
}
|
||||
|
||||
// roundDiv rounds (a/b) half-away-from-zero, JS Math.round parity for non-negative inputs.
|
||||
func roundDiv(a, b int) int {
|
||||
if b <= 0 {
|
||||
return 0
|
||||
}
|
||||
return (a*2 + b) / (2 * b)
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package semantle
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/tiennm99/miti99bot-go/internal/ai"
|
||||
"github.com/tiennm99/miti99bot-go/internal/modules"
|
||||
"github.com/tiennm99/miti99bot-go/internal/storage"
|
||||
"github.com/tiennm99/miti99bot-go/internal/testutil"
|
||||
)
|
||||
|
||||
// fakeEmbedder always returns deterministic vectors so cosine math is testable.
|
||||
type fakeEmbedder struct {
|
||||
vecs map[string][]float32
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeEmbedder) Embed(_ context.Context, texts []string) ([][]float32, error) {
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
out := make([][]float32, len(texts))
|
||||
for i, t := range texts {
|
||||
if v, ok := f.vecs[t]; ok {
|
||||
out[i] = v
|
||||
continue
|
||||
}
|
||||
// Default: distinct unit vector per text — orthogonal pairs score 0.
|
||||
v := make([]float32, 8)
|
||||
v[len(t)%len(v)] = 1
|
||||
out[i] = v
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func install(t *testing.T, embedder ai.Embedder) (*testutil.RecordingBot, *modules.Registry) {
|
||||
t.Helper()
|
||||
rb := testutil.NewRecordingBot(t)
|
||||
reg, err := modules.Build([]string{"semantle"},
|
||||
map[string]modules.Factory{"semantle": New},
|
||||
storage.NewMemoryProvider(), nil,
|
||||
modules.BuildOptions{Embedder: embedder})
|
||||
if err != nil {
|
||||
t.Fatalf("Build: %v", err)
|
||||
}
|
||||
modules.Install(rb.Bot, reg, modules.Auth{})
|
||||
return rb, reg
|
||||
}
|
||||
|
||||
func TestSemantle_NoEmbedderRefuses(t *testing.T) {
|
||||
rb, _ := install(t, nil)
|
||||
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(42, "/semantle"))
|
||||
last := rb.LastSent().Text()
|
||||
if !strings.Contains(last, "GEMINI_API_KEY") {
|
||||
t.Errorf("missing-key warning: got %q", last)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSemantle_BoardOnEmptyArg(t *testing.T) {
|
||||
rb, _ := install(t, &fakeEmbedder{})
|
||||
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(42, "/semantle"))
|
||||
last := rb.LastSent().Text()
|
||||
if !strings.Contains(last, "Semantle") {
|
||||
t.Errorf("board-render: got %q", last)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSemantle_OOVRejected(t *testing.T) {
|
||||
rb, _ := install(t, &fakeEmbedder{})
|
||||
// "qzwxyz" is not in the embedded wordlist → OOV reply, no upstream call.
|
||||
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(42, "/semantle qzwxyz"))
|
||||
last := rb.LastSent().Text()
|
||||
if !strings.Contains(last, "vocabulary") {
|
||||
t.Errorf("OOV reply: got %q", last)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSemantle_RateLimitedReply(t *testing.T) {
|
||||
rb, _ := install(t, &fakeEmbedder{err: ai.ErrRateLimited})
|
||||
// Use a real vocab word so we get past the OOV gate.
|
||||
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(42, "/semantle the"))
|
||||
last := rb.LastSent().Text()
|
||||
if !strings.Contains(last, "rate-limited") {
|
||||
t.Errorf("rate-limit reply: got %q", last)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSemantle_UpstreamFail(t *testing.T) {
|
||||
rb, _ := install(t, &fakeEmbedder{err: errors.New("boom")})
|
||||
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(42, "/semantle the"))
|
||||
last := rb.LastSent().Text()
|
||||
if !strings.Contains(last, "Upstream hiccup") {
|
||||
t.Errorf("upstream reply: got %q", last)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSemantle_GiveupNoActiveRound(t *testing.T) {
|
||||
rb, _ := install(t, &fakeEmbedder{})
|
||||
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(42, "/semantle_giveup"))
|
||||
last := rb.LastSent().Text()
|
||||
if !strings.Contains(last, "No active round") {
|
||||
t.Errorf("giveup-no-round: got %q", last)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSemantle_StatsEmpty(t *testing.T) {
|
||||
rb, _ := install(t, &fakeEmbedder{})
|
||||
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(42, "/semantle_stats"))
|
||||
last := rb.LastSent().Text()
|
||||
if !strings.Contains(last, "No semantle games") {
|
||||
t.Errorf("empty-stats: got %q", last)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package semantle
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// shapeRe enforces the JS lookup.js policy: ASCII letters only, no spaces,
|
||||
// max 64 chars. Wordlist is ASCII at build time so anything else is OOV.
|
||||
var shapeRe = regexp.MustCompile(`^[a-z]+$`)
|
||||
|
||||
// normalize collapses whitespace + lowercases. JS-parity.
|
||||
func normalize(raw string) string {
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
return strings.ToLower(strings.Join(strings.Fields(strings.TrimSpace(raw)), " "))
|
||||
}
|
||||
|
||||
// isValidShape mirrors JS isValidShape: non-empty, ≤64 chars, ASCII letters.
|
||||
func isValidShape(word string) bool {
|
||||
if word == "" || len(word) > 64 {
|
||||
return false
|
||||
}
|
||||
return shapeRe.MatchString(word)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package semantle
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNormalize(t *testing.T) {
|
||||
cases := []struct{ in, want string }{
|
||||
{" Hello ", "hello"},
|
||||
{"FooBar", "foobar"},
|
||||
{" word ", "word"},
|
||||
{"", ""},
|
||||
{"two words", "two words"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := normalize(c.in); got != c.want {
|
||||
t.Errorf("normalize(%q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsValidShape(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want bool
|
||||
}{
|
||||
{"hello", true},
|
||||
{"a", true},
|
||||
{"", false},
|
||||
{"two words", false}, // spaces not allowed in semantle
|
||||
{"hello1", false}, // digits not allowed
|
||||
{"hello!", false}, // punctuation not allowed
|
||||
{string(make([]byte, 65)), false}, // > 64 chars
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := isValidShape(c.in); got != c.want {
|
||||
t.Errorf("isValidShape(%q) = %v, want %v", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadWords_HasContent(t *testing.T) {
|
||||
words, set := loadWords()
|
||||
if len(words) < 1000 {
|
||||
t.Errorf("loadWords: expected >1000 words, got %d", len(words))
|
||||
}
|
||||
if len(set) != len(words) {
|
||||
t.Errorf("loadWords: slice/set size mismatch: %d vs %d", len(words), len(set))
|
||||
}
|
||||
// "the" is the most common English word — sanity check.
|
||||
if _, ok := set["the"]; !ok {
|
||||
t.Errorf("loadWords: 'the' missing from vocab — list looks malformed")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package semantle
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Telegram message limit is 4096 chars; cap the visible row count so a
|
||||
// 200-guess game still fits. JS-parity constants.
|
||||
const (
|
||||
maxRows = 15
|
||||
latestMarker = "➡️"
|
||||
plainMarker = " "
|
||||
maxWordWidth = 20
|
||||
)
|
||||
|
||||
// renderBoard returns the HTML-formatted board. Latest canonical (if any)
|
||||
// gets the arrow marker even when sort order shuffles it down.
|
||||
func renderBoard(guesses []Guess, latestCanonical string) string {
|
||||
count := len(guesses)
|
||||
header := fmt.Sprintf("🎯 Semantle — %d guess%s", count, plural(count))
|
||||
if count == 0 {
|
||||
return header + "\n🆕 Round ready — reply with <code>/semantle <word></code>."
|
||||
}
|
||||
|
||||
sorted := make([]Guess, len(guesses))
|
||||
copy(sorted, guesses)
|
||||
sort.SliceStable(sorted, func(i, j int) bool {
|
||||
return sorted[i].Similarity > sorted[j].Similarity
|
||||
})
|
||||
if len(sorted) > maxRows {
|
||||
sorted = sorted[:maxRows]
|
||||
}
|
||||
|
||||
wordWidth := 0
|
||||
for _, g := range sorted {
|
||||
if l := len(g.Canonical); l > wordWidth {
|
||||
wordWidth = l
|
||||
}
|
||||
}
|
||||
if wordWidth > maxWordWidth {
|
||||
wordWidth = maxWordWidth
|
||||
}
|
||||
|
||||
var lines []string
|
||||
for i, g := range sorted {
|
||||
score := calibrate(g.Similarity)
|
||||
marker := plainMarker
|
||||
if g.Canonical == latestCanonical {
|
||||
marker = latestMarker
|
||||
}
|
||||
rank := padLeft(fmt.Sprintf("%d", i+1), 2)
|
||||
warmth := padLeft(formatWarmth(score), 3)
|
||||
word := html.EscapeString(padRight(g.Canonical, wordWidth))
|
||||
lines = append(lines, fmt.Sprintf("%s %s %s %s %s", marker, rank, warmth, word, warmthEmoji(score)))
|
||||
}
|
||||
|
||||
body := "<pre>" + strings.Join(lines, "\n") + "</pre>"
|
||||
footer := ""
|
||||
if hidden := count - len(sorted); hidden > 0 {
|
||||
footer = fmt.Sprintf("\n…%d older guess%s hidden.", hidden, plural(hidden))
|
||||
}
|
||||
return header + "\n" + body + footer
|
||||
}
|
||||
|
||||
// renderGuess: single-line summary used after a scored guess (above the board).
|
||||
func renderGuess(g Guess) string {
|
||||
score := calibrate(g.Similarity)
|
||||
return fmt.Sprintf("<code>%s</code> → %s %s",
|
||||
html.EscapeString(g.Canonical), formatWarmth(score), warmthEmoji(score))
|
||||
}
|
||||
|
||||
func plural(n int) string {
|
||||
if n == 1 {
|
||||
return ""
|
||||
}
|
||||
return "es"
|
||||
}
|
||||
|
||||
func padLeft(s string, w int) string {
|
||||
if len(s) >= w {
|
||||
return s
|
||||
}
|
||||
return strings.Repeat(" ", w-len(s)) + s
|
||||
}
|
||||
|
||||
func padRight(s string, w int) string {
|
||||
if len(s) >= w {
|
||||
return s
|
||||
}
|
||||
return s + strings.Repeat(" ", w-len(s))
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package semantle
|
||||
|
||||
import "math"
|
||||
|
||||
// cosine returns the cosine similarity of two float32 vectors. nil/empty
|
||||
// inputs and length mismatch return (0, false) — caller should treat as OOV.
|
||||
//
|
||||
// math.Sqrt is float64 internally; cast at the boundary, accumulate in
|
||||
// float64 to avoid 32-bit precision loss on long vectors (text-embedding-004
|
||||
// is 768-dim, so the dot product easily exceeds 2^24 mantissa precision).
|
||||
func cosine(a, b []float32) (float64, bool) {
|
||||
if len(a) == 0 || len(b) == 0 || len(a) != len(b) {
|
||||
return 0, false
|
||||
}
|
||||
var dot, nA, nB float64
|
||||
for i := range a {
|
||||
da := float64(a[i])
|
||||
db := float64(b[i])
|
||||
dot += da * db
|
||||
nA += da * da
|
||||
nB += db * db
|
||||
}
|
||||
denom := math.Sqrt(nA) * math.Sqrt(nB)
|
||||
if denom == 0 {
|
||||
return 0, false
|
||||
}
|
||||
return dot / denom, true
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package semantle
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCosine_IdenticalVectors(t *testing.T) {
|
||||
a := []float32{1, 0, 0}
|
||||
got, ok := cosine(a, a)
|
||||
if !ok {
|
||||
t.Fatal("ok=false for identical vectors")
|
||||
}
|
||||
if math.Abs(got-1.0) > 1e-6 {
|
||||
t.Errorf("identical: got %v, want 1.0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCosine_Orthogonal(t *testing.T) {
|
||||
got, ok := cosine([]float32{1, 0}, []float32{0, 1})
|
||||
if !ok {
|
||||
t.Fatal("ok=false")
|
||||
}
|
||||
if math.Abs(got) > 1e-6 {
|
||||
t.Errorf("orthogonal: got %v, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCosine_Opposite(t *testing.T) {
|
||||
got, ok := cosine([]float32{1, 0}, []float32{-1, 0})
|
||||
if !ok {
|
||||
t.Fatal("ok=false")
|
||||
}
|
||||
if math.Abs(got+1) > 1e-6 {
|
||||
t.Errorf("opposite: got %v, want -1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCosine_LengthMismatch(t *testing.T) {
|
||||
if _, ok := cosine([]float32{1}, []float32{1, 0}); ok {
|
||||
t.Errorf("length-mismatch: want ok=false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCosine_Empty(t *testing.T) {
|
||||
if _, ok := cosine(nil, nil); ok {
|
||||
t.Errorf("nil: want ok=false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalibrate_FloorAndCeiling(t *testing.T) {
|
||||
if v := calibrate(-0.5); v != 0 {
|
||||
t.Errorf("below floor: got %v, want 0", v)
|
||||
}
|
||||
if v := calibrate(1.0); v != 100 {
|
||||
t.Errorf("at ceiling: got %v, want 100", v)
|
||||
}
|
||||
// Mid-range stays in bounds.
|
||||
for _, raw := range []float64{0.5, 0.7, 0.9} {
|
||||
v := calibrate(raw)
|
||||
if v < 0 || v > 100 {
|
||||
t.Errorf("calibrate(%v) = %v out of [0,100]", raw, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalibrate_Monotonic(t *testing.T) {
|
||||
prev := -1.0
|
||||
for _, raw := range []float64{0.45, 0.55, 0.65, 0.75, 0.85, 0.95} {
|
||||
v := calibrate(raw)
|
||||
if v < prev {
|
||||
t.Errorf("calibrate non-monotonic at raw=%v: %v < %v", raw, v, prev)
|
||||
}
|
||||
prev = v
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package semantle
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/binary"
|
||||
mrand "math/rand/v2"
|
||||
|
||||
"github.com/tiennm99/miti99bot-go/internal/ai"
|
||||
"github.com/tiennm99/miti99bot-go/internal/modules"
|
||||
)
|
||||
|
||||
// New is the semantle module Factory. Loads the embedded wordlist once,
|
||||
// captures Embedder via Deps, and registers /semantle, /semantle_giveup,
|
||||
// /semantle_stats. If Deps.Embedder is nil (GEMINI_API_KEY unset) the module
|
||||
// still loads and the handlers reply with a config-error message — keeping
|
||||
// the rest of the bot functional.
|
||||
func New(deps modules.Deps) modules.Module {
|
||||
words, set := loadWords()
|
||||
s := &state{
|
||||
kv: deps.KV,
|
||||
embedder: deps.Embedder,
|
||||
limiter: ai.NewPerUserLimiter(5.0/60.0, 5), // 5 guesses per 60s burst
|
||||
words: words,
|
||||
vocab: set,
|
||||
rng: newRNG(),
|
||||
}
|
||||
return modules.Module{
|
||||
Commands: []modules.Command{
|
||||
{
|
||||
Name: "semantle",
|
||||
Visibility: modules.VisibilityPublic,
|
||||
Description: "Semantle — guess the hidden word (unlimited tries)",
|
||||
Handler: s.handleSemantle,
|
||||
},
|
||||
{
|
||||
Name: "semantle_giveup",
|
||||
Visibility: modules.VisibilityPublic,
|
||||
Description: "Reveal the current semantle answer (auto-starts a fresh round)",
|
||||
Handler: s.handleGiveup,
|
||||
},
|
||||
{
|
||||
Name: "semantle_stats",
|
||||
Visibility: modules.VisibilityPublic,
|
||||
Description: "Show your semantle stats",
|
||||
Handler: s.handleStats,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// newRNG returns a crypto-seeded math/rand v2 PCG. We use math/rand for the
|
||||
// hot path (target pick) because crypto/rand on every call is wasteful, but
|
||||
// seeding from crypto/rand prevents the deterministic-seed footgun that bit
|
||||
// wordle/loldle in earlier reviews.
|
||||
func newRNG() *mrand.Rand {
|
||||
var seed [32]byte
|
||||
_, _ = rand.Read(seed[:])
|
||||
var s1, s2 uint64
|
||||
s1 = binary.LittleEndian.Uint64(seed[0:8])
|
||||
s2 = binary.LittleEndian.Uint64(seed[8:16])
|
||||
return mrand.New(mrand.NewPCG(s1, s2))
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package semantle
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/tiennm99/miti99bot-go/internal/storage"
|
||||
)
|
||||
|
||||
// Guess is one entry in a round's history. JSON shape locks JS parity:
|
||||
// `{ "word": "raw", "canonical": "raw", "similarity": 0.42 }`.
|
||||
type Guess struct {
|
||||
Word string `json:"word"`
|
||||
Canonical string `json:"canonical"`
|
||||
Similarity float64 `json:"similarity"`
|
||||
}
|
||||
|
||||
// GameState is the per-subject KV record. *int64 startedAt mirrors the JS
|
||||
// `null` initial value before the first scored guess.
|
||||
type GameState struct {
|
||||
Target string `json:"target"`
|
||||
StartedAt *int64 `json:"startedAt"`
|
||||
Solved bool `json:"solved"`
|
||||
Guesses []Guess `json:"guesses"`
|
||||
}
|
||||
|
||||
// Stats: lifetime counters per subject. *int64/null parity with JS.
|
||||
type Stats struct {
|
||||
Played int `json:"played"`
|
||||
Solved int `json:"solved"`
|
||||
TotalGuesses int `json:"totalGuesses"`
|
||||
BestGuessCount *int `json:"bestGuessCount"`
|
||||
LastResultAt *int64 `json:"lastResultAt"`
|
||||
}
|
||||
|
||||
func gameKey(subject string) string { return "game:" + subject }
|
||||
func statsKey(subject string) string { return "stats:" + subject }
|
||||
|
||||
func loadGame(ctx context.Context, kv storage.KVStore, subject string) (*GameState, error) {
|
||||
var g GameState
|
||||
err := kv.GetJSON(ctx, gameKey(subject), &g)
|
||||
switch {
|
||||
case err == nil:
|
||||
return &g, nil
|
||||
case errors.Is(err, storage.ErrNotFound):
|
||||
return nil, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("semantle loadGame: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
func saveGame(ctx context.Context, kv storage.KVStore, subject string, g *GameState) error {
|
||||
if err := kv.PutJSON(ctx, gameKey(subject), g); err != nil {
|
||||
return fmt.Errorf("semantle saveGame: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func clearGame(ctx context.Context, kv storage.KVStore, subject string) error {
|
||||
if err := kv.Delete(ctx, gameKey(subject)); err != nil && !errors.Is(err, storage.ErrNotFound) {
|
||||
return fmt.Errorf("semantle clearGame: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadStats(ctx context.Context, kv storage.KVStore, subject string) (*Stats, error) {
|
||||
var s Stats
|
||||
err := kv.GetJSON(ctx, statsKey(subject), &s)
|
||||
switch {
|
||||
case err == nil:
|
||||
return &s, nil
|
||||
case errors.Is(err, storage.ErrNotFound):
|
||||
return &Stats{}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("semantle loadStats: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// recordResult bumps stats with the round outcome. JS-parity: solved counts
|
||||
// total + bestGuessCount; non-solved (giveup) counts only total + guesses.
|
||||
func recordResult(ctx context.Context, kv storage.KVStore, subject string, solved bool, guessCount int, nowMillis int64) (*Stats, error) {
|
||||
s, err := loadStats(ctx, kv, subject)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.Played++
|
||||
s.TotalGuesses += guessCount
|
||||
if solved {
|
||||
s.Solved++
|
||||
if s.BestGuessCount == nil || guessCount < *s.BestGuessCount {
|
||||
gc := guessCount
|
||||
s.BestGuessCount = &gc
|
||||
}
|
||||
}
|
||||
now := nowMillis
|
||||
s.LastResultAt = &now
|
||||
if err := kv.PutJSON(ctx, statsKey(subject), s); err != nil {
|
||||
return nil, fmt.Errorf("semantle recordResult: %w", err)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package semantle
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// rawWords is the embedded google-10000-english list, byte-for-byte from the
|
||||
// JS source's words-data.js (extracted with scripts/build-semantle-words.js).
|
||||
// The pool doubles as the OOV vocabulary — anything not in here gets the
|
||||
// "not in vocabulary" reply rather than a noisy embedding score.
|
||||
//
|
||||
//go:embed data/words.txt
|
||||
var rawWords string
|
||||
|
||||
// loadWords parses the embedded list into (slice, set). The slice preserves
|
||||
// JS pick order (target = LINES[Math.floor(Math.random()*LINES.length)]) and
|
||||
// the set is for O(1) membership checks.
|
||||
func loadWords() ([]string, map[string]struct{}) {
|
||||
lines := strings.Split(strings.TrimSpace(rawWords), "\n")
|
||||
words := make([]string, 0, len(lines))
|
||||
set := make(map[string]struct{}, len(lines))
|
||||
for _, w := range lines {
|
||||
w = strings.TrimSpace(w)
|
||||
if w == "" {
|
||||
continue
|
||||
}
|
||||
words = append(words, w)
|
||||
set[w] = struct{}{}
|
||||
}
|
||||
return words, set
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
package twentyq
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/rand/v2"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/go-telegram/bot"
|
||||
"github.com/go-telegram/bot/models"
|
||||
|
||||
"github.com/tiennm99/miti99bot-go/internal/ai"
|
||||
"github.com/tiennm99/miti99bot-go/internal/keylock"
|
||||
"github.com/tiennm99/miti99bot-go/internal/log"
|
||||
"github.com/tiennm99/miti99bot-go/internal/modules/util/chathelper"
|
||||
"github.com/tiennm99/miti99bot-go/internal/storage"
|
||||
)
|
||||
|
||||
const (
|
||||
upstreamFail = "⚠️ AI service hiccup — try again in a few seconds."
|
||||
notConfig = "⚠️ Twentyq is not configured (missing GEMINI_API_KEY)."
|
||||
rateLimited = "⚠️ AI is rate-limited. Try again in a minute."
|
||||
noRound = "No active round. Send <code>/twentyq</code> to start one."
|
||||
)
|
||||
|
||||
type state struct {
|
||||
kv storage.KVStore
|
||||
chatter ai.Chatter
|
||||
limiter *ai.PerUserLimiter
|
||||
|
||||
rngMu sync.Mutex
|
||||
rng *rand.Rand
|
||||
|
||||
locks keylock.Map
|
||||
}
|
||||
|
||||
func (s *state) randomSeed() string {
|
||||
s.rngMu.Lock()
|
||||
defer s.rngMu.Unlock()
|
||||
return seeds[s.rng.IntN(len(seeds))]
|
||||
}
|
||||
|
||||
// fallbackRoundStart matches JS roundstart fallback when the model fails.
|
||||
func fallbackRoundStart() (string, string) {
|
||||
return "object", "it is something you might encounter in everyday life"
|
||||
}
|
||||
|
||||
func (s *state) startFreshGame(ctx context.Context) (*GameState, error) {
|
||||
target := s.randomSeed()
|
||||
prompt := buildStartRoundPrompt(target)
|
||||
resp, err := s.chatter.Generate(ctx, prompt, "begin")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cat, hint, ok := parseRoundStart(parseJSON(resp))
|
||||
if !ok {
|
||||
log.Warn("twentyq roundstart unparseable", "preview", truncate(resp, 200))
|
||||
cat, hint = fallbackRoundStart()
|
||||
}
|
||||
hint = redactSecret(hint, target)
|
||||
now := chathelper.NowMillis()
|
||||
return &GameState{
|
||||
Category: cat,
|
||||
Target: target,
|
||||
InitialHint: hint,
|
||||
StartedAt: &now,
|
||||
Solved: false,
|
||||
Turns: []Turn{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *state) handleTwentyq(ctx context.Context, b *bot.Bot, update *models.Update) error {
|
||||
msg := update.Message
|
||||
if msg == nil {
|
||||
return nil
|
||||
}
|
||||
subject := chathelper.SubjectFor(msg)
|
||||
if subject == "" {
|
||||
return chathelper.Reply(ctx, b, msg.Chat.ID, "Cannot identify chat.")
|
||||
}
|
||||
if s.chatter == nil {
|
||||
return chathelper.Reply(ctx, b, msg.Chat.ID, notConfig)
|
||||
}
|
||||
defer s.locks.Acquire(subject)()
|
||||
|
||||
arg := chathelper.ArgAfterCommand(msg.Text)
|
||||
|
||||
game, err := loadGame(ctx, s.kv, subject)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Solved-but-lingering rounds → start fresh transparently (JS-parity).
|
||||
if game != nil && game.Solved {
|
||||
_ = clearGame(ctx, s.kv, subject)
|
||||
game = nil
|
||||
}
|
||||
|
||||
if game == nil {
|
||||
if s.limiter != nil && !s.limiter.Allow(subject) {
|
||||
return chathelper.Reply(ctx, b, msg.Chat.ID, "⏳ Slow down — too many requests in a short window.")
|
||||
}
|
||||
fresh, err := s.startFreshGame(ctx)
|
||||
if err != nil {
|
||||
if errors.Is(err, ai.ErrRateLimited) {
|
||||
return chathelper.Reply(ctx, b, msg.Chat.ID, rateLimited)
|
||||
}
|
||||
log.Warn("twentyq roundstart failed", "err", err)
|
||||
return chathelper.Reply(ctx, b, msg.Chat.ID, upstreamFail)
|
||||
}
|
||||
if err := saveGame(ctx, s.kv, subject, fresh); err != nil {
|
||||
return err
|
||||
}
|
||||
if arg == "" {
|
||||
return chathelper.ReplyHTML(ctx, b, msg.Chat.ID, formatIntro(*fresh))
|
||||
}
|
||||
// Fresh round + immediate question — show intro then process turn.
|
||||
if err := chathelper.ReplyHTML(ctx, b, msg.Chat.ID, formatIntro(*fresh)); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.submitTurn(ctx, b, msg, subject, fresh, arg)
|
||||
}
|
||||
|
||||
if arg == "" {
|
||||
return chathelper.ReplyHTML(ctx, b, msg.Chat.ID, formatBoard(*game))
|
||||
}
|
||||
return s.submitTurn(ctx, b, msg, subject, game, arg)
|
||||
}
|
||||
|
||||
func (s *state) submitTurn(ctx context.Context, b *bot.Bot, msg *models.Message, subject string, game *GameState, raw string) error {
|
||||
v := validateQuestion(raw)
|
||||
if !v.OK {
|
||||
return chathelper.ReplyHTML(ctx, b, msg.Chat.ID, v.Reason)
|
||||
}
|
||||
lower := strings.ToLower(v.Normalized)
|
||||
for _, t := range game.Turns {
|
||||
if strings.ToLower(t.Text) == lower {
|
||||
return chathelper.Reply(ctx, b, msg.Chat.ID,
|
||||
"🔁 You already asked that exact question — try a new angle.")
|
||||
}
|
||||
}
|
||||
|
||||
if s.limiter != nil && !s.limiter.Allow(subject) {
|
||||
return chathelper.Reply(ctx, b, msg.Chat.ID, "⏳ Slow down — too many turns in a short window.")
|
||||
}
|
||||
|
||||
system := buildSystemPrompt(*game)
|
||||
resp, err := s.chatter.Generate(ctx, system, v.Normalized)
|
||||
if err != nil {
|
||||
if errors.Is(err, ai.ErrRateLimited) {
|
||||
return chathelper.Reply(ctx, b, msg.Chat.ID, rateLimited)
|
||||
}
|
||||
log.Warn("twentyq judge failed", "err", err)
|
||||
return chathelper.Reply(ctx, b, msg.Chat.ID, upstreamFail)
|
||||
}
|
||||
payload := parseJSON(resp)
|
||||
if payload == nil {
|
||||
log.Warn("twentyq judge unparseable", "preview", truncate(resp, 200))
|
||||
}
|
||||
jud := normalizeJudgement(payload)
|
||||
jud.Hint = redactSecret(jud.Hint, game.Target)
|
||||
|
||||
turn := Turn{
|
||||
Text: v.Normalized,
|
||||
IsGuess: jud.IsGuess,
|
||||
Answer: jud.Answer,
|
||||
Hint: jud.Hint,
|
||||
TS: chathelper.NowMillis(),
|
||||
}
|
||||
game.Turns = append(game.Turns, turn)
|
||||
|
||||
won := turn.IsGuess && turn.Answer == "yes"
|
||||
if won {
|
||||
game.Solved = true
|
||||
count := len(game.Turns)
|
||||
if _, err := recordResult(ctx, s.kv, subject, true, count, chathelper.NowMillis()); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := clearGame(ctx, s.kv, subject); err != nil {
|
||||
return err
|
||||
}
|
||||
return chathelper.ReplyHTML(ctx, b, msg.Chat.ID,
|
||||
formatTurnReply(turn, true, game.Target, count))
|
||||
}
|
||||
|
||||
if err := saveGame(ctx, s.kv, subject, game); err != nil {
|
||||
return err
|
||||
}
|
||||
return chathelper.ReplyHTML(ctx, b, msg.Chat.ID,
|
||||
formatTurnReply(turn, false, game.Target, len(game.Turns)))
|
||||
}
|
||||
|
||||
func (s *state) handleGiveup(ctx context.Context, b *bot.Bot, update *models.Update) error {
|
||||
msg := update.Message
|
||||
if msg == nil {
|
||||
return nil
|
||||
}
|
||||
subject := chathelper.SubjectFor(msg)
|
||||
if subject == "" {
|
||||
return chathelper.Reply(ctx, b, msg.Chat.ID, "Cannot identify chat.")
|
||||
}
|
||||
defer s.locks.Acquire(subject)()
|
||||
game, err := loadGame(ctx, s.kv, subject)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if game == nil {
|
||||
return chathelper.ReplyHTML(ctx, b, msg.Chat.ID, noRound)
|
||||
}
|
||||
if _, err := recordResult(ctx, s.kv, subject, false, len(game.Turns), chathelper.NowMillis()); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := clearGame(ctx, s.kv, subject); err != nil {
|
||||
return err
|
||||
}
|
||||
return chathelper.ReplyHTML(ctx, b, msg.Chat.ID, formatGiveup(*game))
|
||||
}
|
||||
|
||||
func (s *state) handleStats(ctx context.Context, b *bot.Bot, update *models.Update) error {
|
||||
msg := update.Message
|
||||
if msg == nil {
|
||||
return nil
|
||||
}
|
||||
subject := chathelper.SubjectFor(msg)
|
||||
if subject == "" {
|
||||
return chathelper.Reply(ctx, b, msg.Chat.ID, "Cannot identify chat.")
|
||||
}
|
||||
st, err := loadStats(ctx, s.kv, subject)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return chathelper.ReplyHTML(ctx, b, msg.Chat.ID, formatStats(*st))
|
||||
}
|
||||
|
||||
func truncate(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return fmt.Sprintf("%s…", s[:n])
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package twentyq
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/tiennm99/miti99bot-go/internal/ai"
|
||||
"github.com/tiennm99/miti99bot-go/internal/modules"
|
||||
"github.com/tiennm99/miti99bot-go/internal/storage"
|
||||
"github.com/tiennm99/miti99bot-go/internal/testutil"
|
||||
)
|
||||
|
||||
// scriptedChatter returns canned responses by call index. Tests script the
|
||||
// exact sequence the handler will see (round-start then judge per turn).
|
||||
type scriptedChatter struct {
|
||||
responses []string
|
||||
err error
|
||||
calls int
|
||||
}
|
||||
|
||||
func (s *scriptedChatter) Generate(_ context.Context, _, _ string) (string, error) {
|
||||
if s.err != nil {
|
||||
return "", s.err
|
||||
}
|
||||
if s.calls >= len(s.responses) {
|
||||
return "{}", nil
|
||||
}
|
||||
r := s.responses[s.calls]
|
||||
s.calls++
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func install(t *testing.T, c ai.Chatter) *testutil.RecordingBot {
|
||||
t.Helper()
|
||||
rb := testutil.NewRecordingBot(t)
|
||||
reg, err := modules.Build([]string{"twentyq"},
|
||||
map[string]modules.Factory{"twentyq": New},
|
||||
storage.NewMemoryProvider(), nil,
|
||||
modules.BuildOptions{Chatter: c})
|
||||
if err != nil {
|
||||
t.Fatalf("Build: %v", err)
|
||||
}
|
||||
modules.Install(rb.Bot, reg, modules.Auth{})
|
||||
return rb
|
||||
}
|
||||
|
||||
func TestTwentyq_NoChatterRefuses(t *testing.T) {
|
||||
rb := install(t, nil)
|
||||
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(42, "/twentyq"))
|
||||
last := rb.LastSent().Text()
|
||||
if !strings.Contains(last, "GEMINI_API_KEY") {
|
||||
t.Errorf("missing-key warning: got %q", last)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTwentyq_FreshRoundShowsIntro(t *testing.T) {
|
||||
c := &scriptedChatter{responses: []string{
|
||||
`{"category":"animal","initialHint":"a cryptic clue"}`,
|
||||
}}
|
||||
rb := install(t, c)
|
||||
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(42, "/twentyq"))
|
||||
last := rb.LastSent().Text()
|
||||
if !strings.Contains(last, "animal") || !strings.Contains(last, "cryptic clue") {
|
||||
t.Errorf("intro: got %q", last)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTwentyq_RoundStartFallback(t *testing.T) {
|
||||
c := &scriptedChatter{responses: []string{"unparseable garbage"}}
|
||||
rb := install(t, c)
|
||||
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(42, "/twentyq"))
|
||||
last := rb.LastSent().Text()
|
||||
// Fallback category=object, initialHint=everyday-life.
|
||||
if !strings.Contains(last, "object") || !strings.Contains(last, "everyday life") {
|
||||
t.Errorf("fallback intro: got %q", last)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTwentyq_GiveupNoActiveRound(t *testing.T) {
|
||||
c := &scriptedChatter{}
|
||||
rb := install(t, c)
|
||||
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(42, "/twentyq_giveup"))
|
||||
last := rb.LastSent().Text()
|
||||
if !strings.Contains(last, "No active round") {
|
||||
t.Errorf("giveup-no-round: got %q", last)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTwentyq_StatsEmpty(t *testing.T) {
|
||||
c := &scriptedChatter{}
|
||||
rb := install(t, c)
|
||||
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(42, "/twentyq_stats"))
|
||||
last := rb.LastSent().Text()
|
||||
if !strings.Contains(last, "No twentyq games") {
|
||||
t.Errorf("empty stats: got %q", last)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTwentyq_RateLimitedRoundStart(t *testing.T) {
|
||||
c := &scriptedChatter{err: ai.ErrRateLimited}
|
||||
rb := install(t, c)
|
||||
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(42, "/twentyq"))
|
||||
last := rb.LastSent().Text()
|
||||
if !strings.Contains(last, "rate-limited") {
|
||||
t.Errorf("rate-limited reply: got %q", last)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package twentyq
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Judgement is the canonical shape after parse + normalize. JS-parity field
|
||||
// names — IsGuess for "is_guess", Answer ∈ {"yes","no"}.
|
||||
type Judgement struct {
|
||||
IsGuess bool `json:"is_guess"`
|
||||
Answer string `json:"answer"`
|
||||
Hint string `json:"hint"`
|
||||
}
|
||||
|
||||
// RoundStart is the LLM's reply for the round-start prompt.
|
||||
type RoundStart struct {
|
||||
Category string `json:"category"`
|
||||
InitialHint string `json:"initialHint"`
|
||||
}
|
||||
|
||||
const defaultHint = "I couldn't fully parse that — try a clear yes/no question."
|
||||
|
||||
// fenceRe strips ```json / ``` code fences if the model disobeys the prompt.
|
||||
var fenceRe = regexp.MustCompile("(?i)```(?:json)?")
|
||||
|
||||
// parseJSON returns the first balanced {...} JSON object found in `text`.
|
||||
// Returns nil on parse failure — matches JS parseJudgementJson tolerance.
|
||||
func parseJSON(text string) map[string]any {
|
||||
if text == "" {
|
||||
return nil
|
||||
}
|
||||
unfenced := strings.ReplaceAll(fenceRe.ReplaceAllString(text, ""), "```", "")
|
||||
start := strings.IndexByte(unfenced, '{')
|
||||
if start < 0 {
|
||||
return nil
|
||||
}
|
||||
depth := 0
|
||||
inString := false
|
||||
escaped := false
|
||||
for i := start; i < len(unfenced); i++ {
|
||||
ch := unfenced[i]
|
||||
if inString {
|
||||
switch {
|
||||
case escaped:
|
||||
escaped = false
|
||||
case ch == '\\':
|
||||
escaped = true
|
||||
case ch == '"':
|
||||
inString = false
|
||||
}
|
||||
continue
|
||||
}
|
||||
switch ch {
|
||||
case '"':
|
||||
inString = true
|
||||
case '{':
|
||||
depth++
|
||||
case '}':
|
||||
depth--
|
||||
if depth == 0 {
|
||||
slice := unfenced[start : i+1]
|
||||
var out map[string]any
|
||||
if err := json.Unmarshal([]byte(slice), &out); err != nil {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// normalizeJudgement coerces a parsed payload to the canonical Judgement
|
||||
// shape with safe defaults. JS-parity behaviour.
|
||||
func normalizeJudgement(payload map[string]any) Judgement {
|
||||
out := Judgement{Answer: "no", Hint: defaultHint}
|
||||
if payload == nil {
|
||||
return out
|
||||
}
|
||||
if v, ok := payload["is_guess"].(bool); ok {
|
||||
out.IsGuess = v
|
||||
}
|
||||
if v, ok := payload["answer"].(string); ok {
|
||||
if strings.EqualFold(v, "yes") {
|
||||
out.Answer = "yes"
|
||||
}
|
||||
}
|
||||
if v, ok := payload["hint"].(string); ok {
|
||||
if t := strings.TrimSpace(v); t != "" {
|
||||
out.Hint = t
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// redactSecret blanks any whole-word case-insensitive match of `target` in
|
||||
// `hint`. Defense-in-depth — the prompt forbids it, but never trust the
|
||||
// model. Word boundaries: ASCII-letter neighbours.
|
||||
func redactSecret(hint, target string) string {
|
||||
if target == "" {
|
||||
return hint
|
||||
}
|
||||
pattern := `(?i)\b` + regexp.QuoteMeta(target) + `\b`
|
||||
re, err := regexp.Compile(pattern)
|
||||
if err != nil {
|
||||
return hint
|
||||
}
|
||||
return re.ReplaceAllString(hint, "(redacted)")
|
||||
}
|
||||
|
||||
// parseRoundStart returns (category, initialHint) or zero values + nil on
|
||||
// any failure. Caller substitutes JS-parity fallbacks.
|
||||
func parseRoundStart(payload map[string]any) (string, string, bool) {
|
||||
if payload == nil {
|
||||
return "", "", false
|
||||
}
|
||||
cat, _ := payload["category"].(string)
|
||||
hint, _ := payload["initialHint"].(string)
|
||||
cat = strings.TrimSpace(cat)
|
||||
hint = strings.TrimSpace(hint)
|
||||
if cat == "" || hint == "" {
|
||||
return "", "", false
|
||||
}
|
||||
return cat, hint, true
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package twentyq
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseJSON_Plain(t *testing.T) {
|
||||
got := parseJSON(`{"is_guess": true, "answer": "yes", "hint": "ok"}`)
|
||||
if got == nil {
|
||||
t.Fatal("parseJSON returned nil")
|
||||
}
|
||||
if got["is_guess"] != true || got["answer"] != "yes" {
|
||||
t.Errorf("got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseJSON_WithFences(t *testing.T) {
|
||||
in := "```json\n{\"is_guess\": false, \"answer\": \"no\", \"hint\": \"x\"}\n```"
|
||||
got := parseJSON(in)
|
||||
if got == nil {
|
||||
t.Fatalf("parseJSON returned nil for fenced input")
|
||||
}
|
||||
if got["answer"] != "no" {
|
||||
t.Errorf("got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseJSON_PreNoise(t *testing.T) {
|
||||
in := "Sure! Here's the json: {\"is_guess\": false, \"answer\": \"yes\", \"hint\": \"clue\"}"
|
||||
got := parseJSON(in)
|
||||
if got == nil || got["answer"] != "yes" {
|
||||
t.Errorf("parseJSON pre-noise: got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseJSON_Malformed(t *testing.T) {
|
||||
if got := parseJSON("not json at all"); got != nil {
|
||||
t.Errorf("malformed input returned %+v, want nil", got)
|
||||
}
|
||||
if got := parseJSON(""); got != nil {
|
||||
t.Errorf("empty input returned %+v, want nil", got)
|
||||
}
|
||||
if got := parseJSON("{ unclosed"); got != nil {
|
||||
t.Errorf("unclosed brace returned %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeJudgement_Defaults(t *testing.T) {
|
||||
j := normalizeJudgement(nil)
|
||||
if j.Answer != "no" || j.IsGuess || j.Hint == "" {
|
||||
t.Errorf("nil payload: got %+v", j)
|
||||
}
|
||||
|
||||
// "YES" lowercased; missing hint → default; bad is_guess type → false.
|
||||
j = normalizeJudgement(map[string]any{
|
||||
"is_guess": "true", // wrong type intentionally
|
||||
"answer": "YES",
|
||||
"hint": " ",
|
||||
})
|
||||
if j.IsGuess {
|
||||
t.Errorf("string is_guess should not coerce to true: %+v", j)
|
||||
}
|
||||
if j.Answer != "yes" {
|
||||
t.Errorf("answer YES: got %q, want yes", j.Answer)
|
||||
}
|
||||
if j.Hint == "" {
|
||||
t.Errorf("blank hint should fall back to default")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedactSecret(t *testing.T) {
|
||||
cases := []struct {
|
||||
hint, target, want string
|
||||
}{
|
||||
{"this is a guitar", "guitar", "this is a (redacted)"},
|
||||
{"GUITAR is a thing", "guitar", "(redacted) is a thing"},
|
||||
{"guitarist works here", "guitar", "guitarist works here"}, // word boundary
|
||||
{"clean hint", "guitar", "clean hint"},
|
||||
{"empty target", "", "empty target"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := redactSecret(c.hint, c.target)
|
||||
if got != c.want {
|
||||
t.Errorf("redactSecret(%q,%q) = %q, want %q", c.hint, c.target, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRoundStart(t *testing.T) {
|
||||
cat, hint, ok := parseRoundStart(map[string]any{
|
||||
"category": " instrument ",
|
||||
"initialHint": " a clue ",
|
||||
})
|
||||
if !ok || cat != "instrument" || hint != "a clue" {
|
||||
t.Errorf("got cat=%q hint=%q ok=%v", cat, hint, ok)
|
||||
}
|
||||
if _, _, ok := parseRoundStart(nil); ok {
|
||||
t.Errorf("nil should not parse")
|
||||
}
|
||||
if _, _, ok := parseRoundStart(map[string]any{"category": ""}); ok {
|
||||
t.Errorf("empty category should not parse")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateQuestion(t *testing.T) {
|
||||
cases := []struct {
|
||||
raw string
|
||||
wantOK bool
|
||||
}{
|
||||
{"is it big?", true},
|
||||
{" is it round ?", true},
|
||||
{" ab", false}, // too short
|
||||
{string(make([]byte, 250)), false}, // too long
|
||||
{"What color is it?", false}, // open-ended
|
||||
{"why is it heavy?", false}, // open-ended
|
||||
{"", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
v := validateQuestion(c.raw)
|
||||
if v.OK != c.wantOK {
|
||||
t.Errorf("validate(%q) ok=%v, want %v (reason=%s)", c.raw, v.OK, c.wantOK, v.Reason)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package twentyq
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const historyWindow = 5
|
||||
|
||||
// buildSystemPrompt: per-turn judge prompt. JS-parity verbatim. The LLM is
|
||||
// instructed to emit one-line JSON; parser.go does the parse.
|
||||
func buildSystemPrompt(g GameState) string {
|
||||
recent := g.Turns
|
||||
if len(recent) > historyWindow {
|
||||
recent = recent[len(recent)-historyWindow:]
|
||||
}
|
||||
var historyText string
|
||||
if len(recent) == 0 {
|
||||
historyText = "(no questions yet)"
|
||||
} else {
|
||||
var lines []string
|
||||
for i, t := range recent {
|
||||
lines = append(lines, fmt.Sprintf("%d. Q: %s\n A: %s. Hint: %s", i+1, t.Text, t.Answer, t.Hint))
|
||||
}
|
||||
historyText = strings.Join(lines, "\n")
|
||||
}
|
||||
return fmt.Sprintf(`You are the judge for a "20 questions" reverse-Akinator game.
|
||||
The user is trying to guess a secret object. You must answer truthfully based on what the secret actually is.
|
||||
|
||||
Secret object: "%s"
|
||||
Category: %s
|
||||
Initial hint already given: %s
|
||||
|
||||
Question history so far:
|
||||
%s
|
||||
|
||||
The user will send a single message — either a yes/no question (e.g. "is it big?", "does it have wheels?") or a final guess of a specific noun (e.g. "is it an organ?", "is it a piano?").
|
||||
|
||||
You MUST reply with exactly ONE line of JSON and NOTHING else — no prose, no backticks, no code fences, no explanation.
|
||||
|
||||
Schema:
|
||||
{"is_guess": boolean, "answer": "yes" | "no", "hint": string}
|
||||
|
||||
Field meanings:
|
||||
- is_guess: true ONLY when the user is naming a specific concrete object equal to, a synonym of, or extremely close to the secret. Vague descriptors ("is it big?", "is it round?") are NOT guesses. Saying "is it a string instrument?" when the secret is "guitar" is NOT a guess (too broad). Saying "is it a guitar?" IS a guess.
|
||||
- answer: truthful "yes" or "no" about the secret.
|
||||
* If is_guess is true: "yes" only if the named object matches the secret (allowing for synonyms / minor wording). Otherwise "no".
|
||||
* If is_guess is false: "yes" or "no" based on whether the property holds for the secret.
|
||||
- hint: a cryptic clue in plain text, max 120 characters. Must be TRUE about the secret but phrased indirectly. Vary from prior hints. Never include the secret word, its plural, its base form, or any obvious category word.
|
||||
|
||||
HINT STYLE — the point of a good hint:
|
||||
- Be INDIRECT. Think riddle, metaphor, oblique association — not a definition.
|
||||
- Use partial, lateral, or sideways facts. Hint at ONE small property at a time.
|
||||
- Prefer "it is often found near X", "people tend to associate it with Y", "a famous one lives in Z" over "it is used for X".
|
||||
- Avoid giving a second clear category word. If the user has narrowed it down with questions, DO NOT hand them the final word.
|
||||
- Aim for: player thinks "interesting, but I still need another question." NOT: "oh it's obviously X."
|
||||
|
||||
Rules:
|
||||
- Output ONLY the JSON line. No markdown fences. No prose before or after.
|
||||
- If the user input is not a valid yes/no question and not a guess, still return JSON with answer="no", is_guess=false, and a cryptic hint nudging them to rephrase as yes/no.`,
|
||||
g.Target, g.Category, g.InitialHint, historyText)
|
||||
}
|
||||
|
||||
// buildStartRoundPrompt: round-start prompt. Expects {"category", "initialHint"}.
|
||||
func buildStartRoundPrompt(target string) string {
|
||||
return fmt.Sprintf(`You are opening a new round of the "20 questions" reverse-Akinator game.
|
||||
|
||||
Secret object: "%s"
|
||||
|
||||
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".
|
||||
|
||||
Output ONLY the JSON line. No fences, no prose.`, target)
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package twentyq
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const maxTurnRows = 10
|
||||
|
||||
func emojiFor(answer string) string {
|
||||
switch answer {
|
||||
case "yes":
|
||||
return "✅"
|
||||
case "no":
|
||||
return "❌"
|
||||
default:
|
||||
return "❓"
|
||||
}
|
||||
}
|
||||
|
||||
func formatIntro(g GameState) string {
|
||||
return strings.Join([]string{
|
||||
fmt.Sprintf("🎯 I'm thinking of <b>a %s</b>.", html.EscapeString(g.Category)),
|
||||
"Hint: " + html.EscapeString(g.InitialHint),
|
||||
"",
|
||||
"Ask yes/no questions with <code>/twentyq is it ...?</code>",
|
||||
}, "\n")
|
||||
}
|
||||
|
||||
func formatTurnReply(t Turn, solved bool, target string, turnCount int) string {
|
||||
if solved {
|
||||
return fmt.Sprintf("🎉 Correct! It was <b>%s</b>.\nSolved in %d question%s.",
|
||||
html.EscapeString(target), turnCount, plural(turnCount))
|
||||
}
|
||||
emoji := emojiFor(t.Answer)
|
||||
if t.IsGuess {
|
||||
return fmt.Sprintf("%s Not quite. Hint: %s", emoji, html.EscapeString(t.Hint))
|
||||
}
|
||||
yn := "No"
|
||||
if t.Answer == "yes" {
|
||||
yn = "Yes"
|
||||
}
|
||||
return fmt.Sprintf("%s %s. Hint: %s", emoji, yn, html.EscapeString(t.Hint))
|
||||
}
|
||||
|
||||
func formatBoard(g GameState) string {
|
||||
header := fmt.Sprintf("🎯 Category: <b>%s</b>", html.EscapeString(g.Category))
|
||||
intro := "Initial hint: " + html.EscapeString(g.InitialHint)
|
||||
if len(g.Turns) == 0 {
|
||||
return strings.Join([]string{header, intro, "", "<i>No questions yet — go ahead and ask one.</i>"}, "\n")
|
||||
}
|
||||
recent := g.Turns
|
||||
startNo := 1
|
||||
if len(recent) > maxTurnRows {
|
||||
startNo = len(g.Turns) - maxTurnRows + 1
|
||||
recent = recent[len(recent)-maxTurnRows:]
|
||||
}
|
||||
var lines []string
|
||||
for i, t := range recent {
|
||||
num := fmt.Sprintf("%2d", startNo+i)
|
||||
lines = append(lines, fmt.Sprintf("%s. %s <b>%s</b>\n %s",
|
||||
num, emojiFor(t.Answer), html.EscapeString(t.Text), html.EscapeString(t.Hint)))
|
||||
}
|
||||
body := strings.Join([]string{header, intro, "", strings.Join(lines, "\n")}, "\n")
|
||||
if hidden := len(g.Turns) - len(recent); hidden > 0 {
|
||||
body += fmt.Sprintf("\n…%d earlier turn%s hidden.", hidden, pluralS(hidden))
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func formatGiveup(g GameState) string {
|
||||
return strings.Join([]string{
|
||||
fmt.Sprintf("🏳️ Gave up. The answer was <b>%s</b>.", html.EscapeString(g.Target)),
|
||||
"Send <code>/twentyq</code> to start a fresh round.",
|
||||
}, "\n")
|
||||
}
|
||||
|
||||
func formatStats(s Stats) string {
|
||||
if s.Played == 0 {
|
||||
return "No twentyq games played yet."
|
||||
}
|
||||
solveRate := 0
|
||||
if s.Played > 0 {
|
||||
solveRate = (s.Solved*200 + s.Played) / (2 * s.Played)
|
||||
}
|
||||
avg := "—"
|
||||
if s.Played > 0 {
|
||||
avg = fmt.Sprintf("%d", (s.TotalTurns*2+s.Played)/(2*s.Played))
|
||||
}
|
||||
best := "—"
|
||||
if s.BestTurnCount != nil {
|
||||
best = fmt.Sprintf("%d", *s.BestTurnCount)
|
||||
}
|
||||
return fmt.Sprintf("🎯 <b>Twentyq stats</b>\nPlayed: %d\nSolved: %d (%d%%)\nTotal questions: %d\nFewest to solve: %s\nAvg per round: %s",
|
||||
s.Played, s.Solved, solveRate, s.TotalTurns, best, avg)
|
||||
}
|
||||
|
||||
func plural(n int) string {
|
||||
if n == 1 {
|
||||
return ""
|
||||
}
|
||||
return "s"
|
||||
}
|
||||
|
||||
func pluralS(n int) string {
|
||||
if n == 1 {
|
||||
return ""
|
||||
}
|
||||
return "s"
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package twentyq
|
||||
|
||||
// seeds is the JS-parity seed list. Add nouns here — the LLM derives category
|
||||
// + initial hint per round so no metadata table is needed.
|
||||
var seeds = []string{
|
||||
// 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",
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package twentyq
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/tiennm99/miti99bot-go/internal/storage"
|
||||
)
|
||||
|
||||
// Turn is one Q&A entry. JS-parity field names.
|
||||
type Turn struct {
|
||||
Text string `json:"text"`
|
||||
IsGuess bool `json:"isGuess"`
|
||||
Answer string `json:"answer"` // "yes" | "no"
|
||||
Hint string `json:"hint"`
|
||||
TS int64 `json:"ts"`
|
||||
}
|
||||
|
||||
type GameState struct {
|
||||
Category string `json:"category"`
|
||||
Target string `json:"target"`
|
||||
InitialHint string `json:"initialHint"`
|
||||
StartedAt *int64 `json:"startedAt"`
|
||||
Solved bool `json:"solved"`
|
||||
Turns []Turn `json:"turns"`
|
||||
}
|
||||
|
||||
type Stats struct {
|
||||
Played int `json:"played"`
|
||||
Solved int `json:"solved"`
|
||||
TotalTurns int `json:"totalTurns"`
|
||||
BestTurnCount *int `json:"bestTurnCount"`
|
||||
LastResultAt *int64 `json:"lastResultAt"`
|
||||
}
|
||||
|
||||
func gameKey(subject string) string { return "game:" + subject }
|
||||
func statsKey(subject string) string { return "stats:" + subject }
|
||||
|
||||
func loadGame(ctx context.Context, kv storage.KVStore, subject string) (*GameState, error) {
|
||||
var g GameState
|
||||
err := kv.GetJSON(ctx, gameKey(subject), &g)
|
||||
switch {
|
||||
case err == nil:
|
||||
return &g, nil
|
||||
case errors.Is(err, storage.ErrNotFound):
|
||||
return nil, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("twentyq loadGame: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
func saveGame(ctx context.Context, kv storage.KVStore, subject string, g *GameState) error {
|
||||
if err := kv.PutJSON(ctx, gameKey(subject), g); err != nil {
|
||||
return fmt.Errorf("twentyq saveGame: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func clearGame(ctx context.Context, kv storage.KVStore, subject string) error {
|
||||
if err := kv.Delete(ctx, gameKey(subject)); err != nil && !errors.Is(err, storage.ErrNotFound) {
|
||||
return fmt.Errorf("twentyq clearGame: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadStats(ctx context.Context, kv storage.KVStore, subject string) (*Stats, error) {
|
||||
var s Stats
|
||||
err := kv.GetJSON(ctx, statsKey(subject), &s)
|
||||
switch {
|
||||
case err == nil:
|
||||
return &s, nil
|
||||
case errors.Is(err, storage.ErrNotFound):
|
||||
return &Stats{}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("twentyq loadStats: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
func recordResult(ctx context.Context, kv storage.KVStore, subject string, solved bool, turnCount int, nowMillis int64) (*Stats, error) {
|
||||
s, err := loadStats(ctx, kv, subject)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.Played++
|
||||
s.TotalTurns += turnCount
|
||||
if solved {
|
||||
s.Solved++
|
||||
if s.BestTurnCount == nil || turnCount < *s.BestTurnCount {
|
||||
tc := turnCount
|
||||
s.BestTurnCount = &tc
|
||||
}
|
||||
}
|
||||
now := nowMillis
|
||||
s.LastResultAt = &now
|
||||
if err := kv.PutJSON(ctx, statsKey(subject), s); err != nil {
|
||||
return nil, fmt.Errorf("twentyq recordResult: %w", err)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package twentyq
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/binary"
|
||||
mrand "math/rand/v2"
|
||||
|
||||
"github.com/tiennm99/miti99bot-go/internal/ai"
|
||||
"github.com/tiennm99/miti99bot-go/internal/modules"
|
||||
)
|
||||
|
||||
// New is the twentyq module Factory. If Deps.Chatter is nil
|
||||
// (GEMINI_API_KEY unset) the module loads but every command replies with a
|
||||
// config-error message — keeps the rest of the bot functional.
|
||||
func New(deps modules.Deps) modules.Module {
|
||||
s := &state{
|
||||
kv: deps.KV,
|
||||
chatter: deps.Chatter,
|
||||
limiter: ai.NewPerUserLimiter(5.0/60.0, 5),
|
||||
rng: newRNG(),
|
||||
}
|
||||
return modules.Module{
|
||||
Commands: []modules.Command{
|
||||
{
|
||||
Name: "twentyq",
|
||||
Visibility: modules.VisibilityPublic,
|
||||
Description: "20 questions — bot picks an object, you ask yes/no questions",
|
||||
Handler: s.handleTwentyq,
|
||||
},
|
||||
{
|
||||
Name: "twentyq_giveup",
|
||||
Visibility: modules.VisibilityPublic,
|
||||
Description: "Reveal the current twentyq answer (auto-starts a fresh round)",
|
||||
Handler: s.handleGiveup,
|
||||
},
|
||||
{
|
||||
Name: "twentyq_stats",
|
||||
Visibility: modules.VisibilityPublic,
|
||||
Description: "Show your twentyq stats (played, solved, best round)",
|
||||
Handler: s.handleStats,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newRNG() *mrand.Rand {
|
||||
var seed [16]byte
|
||||
_, _ = rand.Read(seed[:])
|
||||
s1 := binary.LittleEndian.Uint64(seed[0:8])
|
||||
s2 := binary.LittleEndian.Uint64(seed[8:16])
|
||||
return mrand.New(mrand.NewPCG(s1, s2))
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package twentyq
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
minLen = 3
|
||||
maxLen = 200
|
||||
)
|
||||
|
||||
// openEndedRe rejects open-ended questions before spending a Gemini call.
|
||||
// JS-parity prefix list.
|
||||
var openEndedRe = regexp.MustCompile(`(?i)^\s*(what|how|why|which|who|where|when|tell me|describe|explain)\b`)
|
||||
|
||||
// ValidateResult is the public outcome of validateQuestion. Either OK + the
|
||||
// normalized form, or !OK + a user-visible reason (HTML allowed).
|
||||
type ValidateResult struct {
|
||||
OK bool
|
||||
Normalized string
|
||||
Reason string
|
||||
}
|
||||
|
||||
// validateQuestion strips/collapses whitespace and rejects too-short,
|
||||
// too-long, or open-ended inputs. Reason strings carry HTML <code> markup
|
||||
// so the dispatcher's ReplyHTML wrapping is required for those branches.
|
||||
func validateQuestion(raw string) ValidateResult {
|
||||
if raw == "" {
|
||||
return ValidateResult{Reason: "Please send a yes/no question after the command."}
|
||||
}
|
||||
collapsed := strings.Join(strings.Fields(raw), " ")
|
||||
if len(collapsed) < minLen {
|
||||
return ValidateResult{Reason: "Question too short — try something like <code>is it big?</code>."}
|
||||
}
|
||||
if len(collapsed) > maxLen {
|
||||
return ValidateResult{Reason: "Question too long — keep it under 200 characters."}
|
||||
}
|
||||
if openEndedRe.MatchString(collapsed) {
|
||||
return ValidateResult{Reason: "Yes/no questions only — try <code>is it ...?</code> or <code>does it ...?</code>."}
|
||||
}
|
||||
return ValidateResult{OK: true, Normalized: collapsed}
|
||||
}
|
||||
@@ -20,7 +20,7 @@ func installUtil(t *testing.T, ownerID int64) *testutil.RecordingBot {
|
||||
rb := testutil.NewRecordingBot(t)
|
||||
reg, err := modules.Build([]string{"util"},
|
||||
map[string]modules.Factory{"util": util.New},
|
||||
storage.NewMemoryProvider(), nil)
|
||||
storage.NewMemoryProvider(), nil, modules.BuildOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("Build: %v", err)
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ func TestRenderHelp_GroupsByModuleAndSkipsPrivate(t *testing.T) {
|
||||
cmd("b_amp", modules.VisibilityPublic, `Tom & "Jerry"`),
|
||||
}),
|
||||
}
|
||||
reg, err := modules.Build([]string{"alpha", "beta"}, factories, storage.NewMemoryProvider(), nil)
|
||||
reg, err := modules.Build([]string{"alpha", "beta"}, factories, storage.NewMemoryProvider(), nil, modules.BuildOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("Build: %v", err)
|
||||
}
|
||||
@@ -79,7 +79,7 @@ func TestRenderHelp_ModuleOrderMatchesEnvOrder(t *testing.T) {
|
||||
}
|
||||
|
||||
// MODULES order: second,first → expect "second" section before "first".
|
||||
reg, err := modules.Build([]string{"second", "first"}, factories, storage.NewMemoryProvider(), nil)
|
||||
reg, err := modules.Build([]string{"second", "first"}, factories, storage.NewMemoryProvider(), nil, modules.BuildOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("Build: %v", err)
|
||||
}
|
||||
@@ -106,7 +106,7 @@ func TestRenderHelp_OmitsModulesWithNoVisibleCommands(t *testing.T) {
|
||||
cmd("seen", modules.VisibilityPublic),
|
||||
}),
|
||||
}
|
||||
reg, err := modules.Build([]string{"shadow", "visible"}, factories, storage.NewMemoryProvider(), nil)
|
||||
reg, err := modules.Build([]string{"shadow", "visible"}, factories, storage.NewMemoryProvider(), nil, modules.BuildOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("Build: %v", err)
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ const testCronSecret = "shared-cron-secret"
|
||||
|
||||
func buildRegistry(t *testing.T, factories map[string]modules.Factory, names ...string) *modules.Registry {
|
||||
t.Helper()
|
||||
reg, err := modules.Build(names, factories, storage.NewMemoryProvider(), nil)
|
||||
reg, err := modules.Build(names, factories, storage.NewMemoryProvider(), nil, modules.BuildOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("modules.Build: %v", err)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
phase: 7
|
||||
title: "Gemini AI + port semantle/doantu/twentyq"
|
||||
status: pending
|
||||
status: done
|
||||
priority: P2
|
||||
effort: "6h"
|
||||
dependencies: [4]
|
||||
@@ -75,11 +75,19 @@ internal/modules/twentyq/
|
||||
10. Smoke each module on dev bot.
|
||||
|
||||
## Success Criteria
|
||||
- [ ] `/semantle` round-trip: similarity score returned ≤2s warm
|
||||
- [ ] `/doantu` works with Vietnamese targets
|
||||
- [ ] `/twentyq` flow ends at 20 questions or correct guess
|
||||
- [ ] 429 from Gemini → user-visible "AI is rate-limited, try again in N minutes"
|
||||
- [ ] Per-user soft-limit prevents single user exhausting daily quota
|
||||
- [x] `internal/ai` package wraps `google.golang.org/genai` (v1.56) with Embedder/Chatter interfaces; per-user `PerUserLimiter` (5 req / 60s burst).
|
||||
- [x] `Deps` extended with `Embedder`/`Chatter` (nil when GEMINI_API_KEY unset → modules refuse with config-error).
|
||||
- [x] `/semantle` ported: 9894-word google-10k pool, JS-parity sigmoid calibration, OOV gate, fast-path dedup, render board with sort+top-15.
|
||||
- [x] `/doantu` ported via JS-parity `phow2sim` HTTP client (NOT Gemini — see Deviations below).
|
||||
- [x] `/twentyq` ported with prompts.go (verbatim JS prompt strings), parser.go (JSON-with-fence extraction), redact-secret defense, fallback round-start.
|
||||
- [x] 429 from Gemini mapped to `ai.ErrRateLimited` → user-visible "rate-limited" reply.
|
||||
- [x] All factories registered in `cmd/server/main.go`; `go vet ./...` and `go test -race -count=1 ./...` clean.
|
||||
|
||||
## Deviations from original plan
|
||||
- **doantu uses phow2sim HTTP, not Gemini embeddings.** Rationale: text-embedding-004 was not trained for Vietnamese semantic relatedness; phow2sim is a domain-trained PhoW2V model. The JS bot already uses it; switching to embeddings would diverge behaviour, not preserve it. `PHOW2SIM_API_URL` overridable via env (allowlisted in `cmd/server/main.go`).
|
||||
- **No Firestore-backed target embedding cache** (plan step 6). semantle embeds both target+guess on every call (matches JS bge-m3 path). Cache adds complexity without measurable savings until Phase 11 soak data shows the 1500 RPD ceiling is real.
|
||||
- **gemini-2.5-flash, not 1.5.** SDK default is the newer flash; behaviour-equivalent for the twentyq use case.
|
||||
- **Per-day cap deferred.** Token bucket only; if Phase 11 soak shows abuse, add a Firestore counter.
|
||||
|
||||
## Risk Assessment
|
||||
- **Risk**: 768d vs 1024d means similarity scores have different distribution. Game tuning constants (winning threshold) need re-calibration. **Mitigation**: empirical tune against dev bot; document in module file.
|
||||
|
||||
Reference in New Issue
Block a user