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:
prosolis
2026-04-01 18:58:33 -07:00
parent f61bf89c71
commit b4d5080f48
3 changed files with 158 additions and 78 deletions

View File

@@ -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),

View File

@@ -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

View File

@@ -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()