diff --git a/internal/plugin/wordle.go b/internal/plugin/wordle.go index 37f40fb..b3628cf 100644 --- a/internal/plugin/wordle.go +++ b/internal/plugin/wordle.go @@ -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), diff --git a/internal/plugin/wordle_game.go b/internal/plugin/wordle_game.go index 038b6f2..14aa22a 100644 --- a/internal/plugin/wordle_game.go +++ b/internal/plugin/wordle_game.go @@ -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 diff --git a/internal/plugin/wotd.go b/internal/plugin/wotd.go index 26c5a49..b8f24e5 100644 --- a/internal/plugin/wotd.go +++ b/internal/plugin/wotd.go @@ -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, ""); i != -1 { + if j := strings.Index(response, ""); j != -1 { + response = response[:i] + response[j+len(""):] + } + } + 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()