mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
Use DreamDict for Wordle word selection, expand to 20 letters, fix WOTD English word leak
Wordle: - Use DreamDict RandomWord API as primary word source, local pools as fallback - Expand word length support from 5-7 to 5-20 - Validate DreamDict words are correct length and alpha-only - Keep max guesses at 6 regardless of word length - Use wordleMaxGuesses() instead of hardcoded 6 for rehydration WOTD: - Reject non-English candidates that are valid English words (fixes "puzzlingly" as Portuguese WOTD) - Make cross-language translations optional (DreamDict pt-PT coverage is sparse) - Use LLM fallback to translate foreign words to English when DreamDict lacks translations - Remove hasTranslations re-prefetch guard (no longer needed with optional translations) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -35,7 +35,7 @@ type WordlePlugin struct {
|
||||
func NewWordlePlugin(client *mautrix.Client, euro *EuroPlugin, dict *dreamclient.Client) *WordlePlugin {
|
||||
length := 5
|
||||
if v := os.Getenv("WORDLE_DEFAULT_LENGTH"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n >= 5 && n <= 7 {
|
||||
if n, err := strconv.Atoi(v); err == nil && n >= 5 && n <= 20 {
|
||||
length = n
|
||||
}
|
||||
}
|
||||
@@ -110,7 +110,7 @@ func (p *WordlePlugin) handleHelp(ctx MessageContext) error {
|
||||
"`!wordle grid` — Re-post current puzzle grid\n"+
|
||||
"`!wordle stats` — All-time leaderboard\n"+
|
||||
"`!wordle new` — Start a new puzzle (admin)\n"+
|
||||
"`!wordle new <5|6|7>` — New puzzle with specific length (admin)\n"+
|
||||
"`!wordle new <5-20>` — New puzzle with specific length (admin)\n"+
|
||||
"`!wordle new pt` — Portuguese puzzle (admin)\n"+
|
||||
"`!wordle new fr` — French puzzle (admin)\n"+
|
||||
"`!wordle skip` — Reveal answer and end puzzle (admin)")
|
||||
@@ -244,7 +244,7 @@ func (p *WordlePlugin) handleNew(ctx MessageContext, args string) error {
|
||||
case "fr", "french":
|
||||
category = WordleCategoryFR
|
||||
default:
|
||||
if n, err := strconv.Atoi(part); err == nil && n >= 5 && n <= 7 {
|
||||
if n, err := strconv.Atoi(part); err == nil && n >= 5 && n <= 20 {
|
||||
wordLength = n
|
||||
}
|
||||
}
|
||||
@@ -318,14 +318,8 @@ func (p *WordlePlugin) handleStats(ctx MessageContext) error {
|
||||
|
||||
// createAndPostPuzzle creates a new puzzle, persists it, and posts the announcement.
|
||||
func (p *WordlePlugin) createAndPostPuzzle(roomID id.RoomID, wordLength int, category WordleCategory) error {
|
||||
// Pick word from the appropriate pool.
|
||||
var word string
|
||||
switch category {
|
||||
case WordleCategoryPT, WordleCategoryFR:
|
||||
word = pickLanguageWord(category, wordLength)
|
||||
default:
|
||||
word = pickFallbackWord(wordLength)
|
||||
}
|
||||
// Pick word from DreamDict, falling back to the local word pool.
|
||||
word := p.pickWord(wordLength, category)
|
||||
if word == "" {
|
||||
return p.SendMessage(roomID, "Failed to select a puzzle word — no words available for that length/language.")
|
||||
}
|
||||
@@ -345,7 +339,7 @@ func (p *WordlePlugin) createAndPostPuzzle(roomID id.RoomID, wordLength int, cat
|
||||
RoomID: roomID,
|
||||
Answer: word,
|
||||
WordLength: wordLength,
|
||||
MaxGuesses: 6,
|
||||
MaxGuesses: wordleMaxGuesses(wordLength),
|
||||
Category: category,
|
||||
StartedAt: now,
|
||||
LetterStates: make(map[rune]LetterResult),
|
||||
@@ -375,6 +369,56 @@ func (p *WordlePlugin) createAndPostPuzzle(roomID id.RoomID, wordLength int, cat
|
||||
return p.SendMessage(roomID, renderWordleStartAnnouncement(puzzleNumber, wordLength, hint))
|
||||
}
|
||||
|
||||
// pickWord tries DreamDict's RandomWord API first, falling back to the local
|
||||
// word pool if DreamDict is unavailable or only returns recently-used words.
|
||||
func (p *WordlePlugin) pickWord(length int, category WordleCategory) string {
|
||||
if p.dict != nil {
|
||||
recent := loadRecentWordleAnswers(500)
|
||||
lang := categoryLang(category)
|
||||
|
||||
// Try a few times to get a word that hasn't been used recently.
|
||||
for range 10 {
|
||||
word, err := p.dict.RandomWord(lang, "", length, length)
|
||||
if err != nil {
|
||||
slog.Warn("wordle: DreamDict random word failed, falling back", "err", err)
|
||||
break
|
||||
}
|
||||
word = strings.ToUpper(word)
|
||||
// Verify the word is exactly the right length and contains only letters.
|
||||
runes := []rune(word)
|
||||
if len(runes) != length {
|
||||
continue
|
||||
}
|
||||
clean := true
|
||||
for _, r := range runes {
|
||||
if r < 'A' || r > 'Z' {
|
||||
clean = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if !clean {
|
||||
continue
|
||||
}
|
||||
if !recent[word] {
|
||||
return word
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to local word pools.
|
||||
switch category {
|
||||
case WordleCategoryPT, WordleCategoryFR:
|
||||
return pickLanguageWord(category, length)
|
||||
default:
|
||||
return pickFallbackWord(length)
|
||||
}
|
||||
}
|
||||
|
||||
// wordleMaxGuesses returns the number of allowed guesses for a given word length.
|
||||
func wordleMaxGuesses(length int) int {
|
||||
return 6
|
||||
}
|
||||
|
||||
// wordleCategoryHint returns the hint string for a puzzle category.
|
||||
func wordleCategoryHint(category WordleCategory) string {
|
||||
switch category {
|
||||
@@ -665,7 +709,7 @@ func (p *WordlePlugin) rehydratePuzzles() {
|
||||
continue
|
||||
}
|
||||
|
||||
if solved == 1 || guessCount >= 6 {
|
||||
if solved == 1 || guessCount >= wordleMaxGuesses(wordLength) {
|
||||
continue // already done
|
||||
}
|
||||
|
||||
@@ -689,7 +733,7 @@ func (p *WordlePlugin) rehydratePuzzles() {
|
||||
RoomID: roomID,
|
||||
Answer: pr.answer,
|
||||
WordLength: pr.wordLength,
|
||||
MaxGuesses: 6,
|
||||
MaxGuesses: wordleMaxGuesses(pr.wordLength),
|
||||
Category: WordleCategory(pr.category),
|
||||
StartedAt: pr.startedAt,
|
||||
LetterStates: make(map[rune]LetterResult),
|
||||
|
||||
@@ -41,7 +41,7 @@ type WordlePuzzle struct {
|
||||
RoomID id.RoomID
|
||||
Answer string // uppercased
|
||||
WordLength int
|
||||
MaxGuesses int // always 6
|
||||
MaxGuesses int
|
||||
Category WordleCategory
|
||||
Guesses []WordleGuess
|
||||
Solved bool
|
||||
|
||||
@@ -75,19 +75,21 @@ func (p *WOTDPlugin) OnMessage(ctx MessageContext) error {
|
||||
// For non-English languages, requires English translations and filters cognates.
|
||||
// For all languages, fetches synonyms and cross-translations.
|
||||
func (p *WOTDPlugin) pickWord(lang string) (string, string, string, string) {
|
||||
// Define which other languages to translate into
|
||||
// Define which other languages to optionally translate into.
|
||||
// English translation is required for non-English words; others are best-effort.
|
||||
type transTarget struct {
|
||||
lang string
|
||||
key string
|
||||
lang string
|
||||
key string
|
||||
required bool
|
||||
}
|
||||
var targets []transTarget
|
||||
switch lang {
|
||||
case "pt-PT":
|
||||
targets = []transTarget{{"en", "en"}, {"fr", "fr"}}
|
||||
targets = []transTarget{{"en", "en", false}, {"fr", "fr", false}}
|
||||
case "fr":
|
||||
targets = []transTarget{{"en", "en"}, {"pt-PT", "pt-PT"}}
|
||||
targets = []transTarget{{"en", "en", false}, {"pt-PT", "pt-PT", false}}
|
||||
case "en":
|
||||
targets = []transTarget{{"fr", "fr"}, {"pt-PT", "pt-PT"}}
|
||||
targets = []transTarget{{"fr", "fr", false}, {"pt-PT", "pt-PT", false}}
|
||||
}
|
||||
|
||||
for attempt := 0; attempt < 100; attempt++ {
|
||||
@@ -106,19 +108,38 @@ func (p *WOTDPlugin) pickWord(lang string) (string, string, string, string) {
|
||||
|
||||
for _, t := range targets {
|
||||
trans, _ := p.dict.Translate(candidate, lang, t.lang)
|
||||
if len(trans) == 0 {
|
||||
if len(trans) == 0 && t.required {
|
||||
valid = false
|
||||
break
|
||||
}
|
||||
transMap[t.key] = trans
|
||||
if len(trans) > 0 {
|
||||
transMap[t.key] = trans
|
||||
}
|
||||
}
|
||||
if !valid {
|
||||
continue
|
||||
}
|
||||
|
||||
// For non-English words, filter cognates where the English translation
|
||||
// is just the word itself
|
||||
// For non-English words, reject English words that leaked into the
|
||||
// foreign language database.
|
||||
if lang != "en" {
|
||||
// Skip if the word is a valid English word — it's almost certainly
|
||||
// not a real Portuguese/French word.
|
||||
if engValid, _ := p.dict.IsValidWord(candidate, "en"); engValid {
|
||||
continue
|
||||
}
|
||||
|
||||
// If no English translation from DreamDict, ask the LLM.
|
||||
if _, ok := transMap["en"]; !ok {
|
||||
if llmTrans := p.llmTranslate(candidate, lang); llmTrans != "" {
|
||||
transMap["en"] = []string{llmTrans}
|
||||
} else {
|
||||
continue // can't provide any English meaning, skip
|
||||
}
|
||||
}
|
||||
|
||||
// Skip if the English translation is just the word itself
|
||||
// (cognate with no meaningful translation).
|
||||
if enTrans, ok := transMap["en"]; ok {
|
||||
meaningfulEn := false
|
||||
for _, t := range enTrans {
|
||||
@@ -232,27 +253,6 @@ func (p *WOTDPlugin) prefetchWord(force bool) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// hasTranslations checks whether the stored translations JSON has at least one translation
|
||||
// (excluding metadata keys like _lang and syn).
|
||||
func hasTranslations(translationsJSON string) bool {
|
||||
if translationsJSON == "" {
|
||||
return false
|
||||
}
|
||||
var transMap map[string][]string
|
||||
if json.Unmarshal([]byte(translationsJSON), &transMap) != nil {
|
||||
return false
|
||||
}
|
||||
for k, v := range transMap {
|
||||
if strings.HasPrefix(k, "_") || k == "syn" {
|
||||
continue
|
||||
}
|
||||
if len(v) > 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// PostWOTD posts today's Word of the Day to the given room and marks it as posted.
|
||||
func (p *WOTDPlugin) PostWOTD(roomID id.RoomID) error {
|
||||
today := time.Now().UTC().Format("2006-01-02")
|
||||
@@ -284,20 +284,6 @@ func (p *WOTDPlugin) PostWOTD(roomID id.RoomID) error {
|
||||
return fmt.Errorf("wotd: query: %w", err)
|
||||
}
|
||||
|
||||
// Re-prefetch if stored word is missing English translation
|
||||
if !hasTranslations(example) {
|
||||
slog.Warn("wotd: stored word missing English translation, forcing re-prefetch", "word", word)
|
||||
if err := p.prefetchWord(true); err != nil {
|
||||
return fmt.Errorf("wotd: re-prefetch failed: %w", err)
|
||||
}
|
||||
err = d.QueryRow(
|
||||
`SELECT word, definition, part_of_speech, example FROM wotd_log WHERE date = ?`, today,
|
||||
).Scan(&word, &definition, &partOfSpeech, &example)
|
||||
if err != nil {
|
||||
return fmt.Errorf("wotd: still no valid entry after re-prefetch: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
msg := p.formatWOTD(word, definition, partOfSpeech, example)
|
||||
if err := p.SendMessage(roomID, msg); err != nil {
|
||||
return fmt.Errorf("wotd: send message: %w", err)
|
||||
@@ -331,24 +317,6 @@ func (p *WOTDPlugin) handleWOTD(ctx MessageContext) error {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to fetch Word of the Day.")
|
||||
}
|
||||
|
||||
// Re-prefetch if stored word is missing English translation
|
||||
if !hasTranslations(example) {
|
||||
slog.Warn("wotd: stored word missing English translation, forcing re-prefetch", "word", word)
|
||||
if err := p.prefetchWord(true); err != nil {
|
||||
slog.Error("wotd: re-prefetch failed", "err", err)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to fetch Word of the Day. Try again later.")
|
||||
}
|
||||
d := db.Get()
|
||||
err = d.QueryRow(
|
||||
`SELECT word, definition, part_of_speech, example FROM wotd_log WHERE date = ?`,
|
||||
time.Now().UTC().Format("2006-01-02"),
|
||||
).Scan(&word, &definition, &partOfSpeech, &example)
|
||||
if err != nil {
|
||||
slog.Error("wotd: query after re-prefetch", "err", err)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to fetch Word of the Day.")
|
||||
}
|
||||
}
|
||||
|
||||
msg := p.formatWOTD(word, definition, partOfSpeech, example)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, msg)
|
||||
}
|
||||
@@ -594,6 +562,74 @@ Respond with ONLY "yes" or "no".`, message, word)
|
||||
return accepted
|
||||
}
|
||||
|
||||
// llmTranslate asks the LLM for a brief English translation of a foreign word.
|
||||
// Returns empty string on failure.
|
||||
func (p *WOTDPlugin) llmTranslate(word, lang string) string {
|
||||
host := os.Getenv("OLLAMA_HOST")
|
||||
model := os.Getenv("OLLAMA_MODEL")
|
||||
if host == "" || model == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
langName := map[string]string{
|
||||
"pt-PT": "Portuguese",
|
||||
"fr": "French",
|
||||
}[lang]
|
||||
if langName == "" {
|
||||
langName = lang
|
||||
}
|
||||
|
||||
prompt := fmt.Sprintf(
|
||||
`Translate the %s word "%s" into English. Reply with ONLY the English translation — one or two words, no explanation, no punctuation.`,
|
||||
langName, word)
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"model": model,
|
||||
"prompt": prompt,
|
||||
"stream": false,
|
||||
"think": false,
|
||||
}
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
apiURL := strings.TrimRight(host, "/") + "/api/generate"
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
resp, err := client.Post(apiURL, "application/json", bytes.NewReader(data))
|
||||
if err != nil {
|
||||
slog.Error("wotd: LLM translate request failed", "err", err)
|
||||
return ""
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil || resp.StatusCode != http.StatusOK {
|
||||
return ""
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Response string `json:"response"`
|
||||
}
|
||||
if json.Unmarshal(body, &result) != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
response := result.Response
|
||||
if i := strings.Index(response, "<think>"); i != -1 {
|
||||
if j := strings.Index(response, "</think>"); j != -1 {
|
||||
response = response[:i] + response[j+len("</think>"):]
|
||||
}
|
||||
}
|
||||
translation := strings.TrimSpace(response)
|
||||
if translation == "" || len(translation) > 50 {
|
||||
return ""
|
||||
}
|
||||
|
||||
slog.Debug("wotd: LLM translation", "word", word, "lang", lang, "translation", translation)
|
||||
return translation
|
||||
}
|
||||
|
||||
// grantWOTDXP inserts XP directly via SQL to avoid cross-plugin dependency.
|
||||
func (p *WOTDPlugin) grantWOTDXP(userID id.UserID, amount int) {
|
||||
d := db.Get()
|
||||
|
||||
Reference in New Issue
Block a user