mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-16 08:52:42 +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 {
|
func NewWordlePlugin(client *mautrix.Client, euro *EuroPlugin, dict *dreamclient.Client) *WordlePlugin {
|
||||||
length := 5
|
length := 5
|
||||||
if v := os.Getenv("WORDLE_DEFAULT_LENGTH"); v != "" {
|
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
|
length = n
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -110,7 +110,7 @@ func (p *WordlePlugin) handleHelp(ctx MessageContext) error {
|
|||||||
"`!wordle grid` — Re-post current puzzle grid\n"+
|
"`!wordle grid` — Re-post current puzzle grid\n"+
|
||||||
"`!wordle stats` — All-time leaderboard\n"+
|
"`!wordle stats` — All-time leaderboard\n"+
|
||||||
"`!wordle new` — Start a new puzzle (admin)\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 pt` — Portuguese puzzle (admin)\n"+
|
||||||
"`!wordle new fr` — French puzzle (admin)\n"+
|
"`!wordle new fr` — French puzzle (admin)\n"+
|
||||||
"`!wordle skip` — Reveal answer and end puzzle (admin)")
|
"`!wordle skip` — Reveal answer and end puzzle (admin)")
|
||||||
@@ -244,7 +244,7 @@ func (p *WordlePlugin) handleNew(ctx MessageContext, args string) error {
|
|||||||
case "fr", "french":
|
case "fr", "french":
|
||||||
category = WordleCategoryFR
|
category = WordleCategoryFR
|
||||||
default:
|
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
|
wordLength = n
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -318,14 +318,8 @@ func (p *WordlePlugin) handleStats(ctx MessageContext) error {
|
|||||||
|
|
||||||
// createAndPostPuzzle creates a new puzzle, persists it, and posts the announcement.
|
// createAndPostPuzzle creates a new puzzle, persists it, and posts the announcement.
|
||||||
func (p *WordlePlugin) createAndPostPuzzle(roomID id.RoomID, wordLength int, category WordleCategory) error {
|
func (p *WordlePlugin) createAndPostPuzzle(roomID id.RoomID, wordLength int, category WordleCategory) error {
|
||||||
// Pick word from the appropriate pool.
|
// Pick word from DreamDict, falling back to the local word pool.
|
||||||
var word string
|
word := p.pickWord(wordLength, category)
|
||||||
switch category {
|
|
||||||
case WordleCategoryPT, WordleCategoryFR:
|
|
||||||
word = pickLanguageWord(category, wordLength)
|
|
||||||
default:
|
|
||||||
word = pickFallbackWord(wordLength)
|
|
||||||
}
|
|
||||||
if word == "" {
|
if word == "" {
|
||||||
return p.SendMessage(roomID, "Failed to select a puzzle word — no words available for that length/language.")
|
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,
|
RoomID: roomID,
|
||||||
Answer: word,
|
Answer: word,
|
||||||
WordLength: wordLength,
|
WordLength: wordLength,
|
||||||
MaxGuesses: 6,
|
MaxGuesses: wordleMaxGuesses(wordLength),
|
||||||
Category: category,
|
Category: category,
|
||||||
StartedAt: now,
|
StartedAt: now,
|
||||||
LetterStates: make(map[rune]LetterResult),
|
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))
|
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.
|
// wordleCategoryHint returns the hint string for a puzzle category.
|
||||||
func wordleCategoryHint(category WordleCategory) string {
|
func wordleCategoryHint(category WordleCategory) string {
|
||||||
switch category {
|
switch category {
|
||||||
@@ -665,7 +709,7 @@ func (p *WordlePlugin) rehydratePuzzles() {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if solved == 1 || guessCount >= 6 {
|
if solved == 1 || guessCount >= wordleMaxGuesses(wordLength) {
|
||||||
continue // already done
|
continue // already done
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -689,7 +733,7 @@ func (p *WordlePlugin) rehydratePuzzles() {
|
|||||||
RoomID: roomID,
|
RoomID: roomID,
|
||||||
Answer: pr.answer,
|
Answer: pr.answer,
|
||||||
WordLength: pr.wordLength,
|
WordLength: pr.wordLength,
|
||||||
MaxGuesses: 6,
|
MaxGuesses: wordleMaxGuesses(pr.wordLength),
|
||||||
Category: WordleCategory(pr.category),
|
Category: WordleCategory(pr.category),
|
||||||
StartedAt: pr.startedAt,
|
StartedAt: pr.startedAt,
|
||||||
LetterStates: make(map[rune]LetterResult),
|
LetterStates: make(map[rune]LetterResult),
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ type WordlePuzzle struct {
|
|||||||
RoomID id.RoomID
|
RoomID id.RoomID
|
||||||
Answer string // uppercased
|
Answer string // uppercased
|
||||||
WordLength int
|
WordLength int
|
||||||
MaxGuesses int // always 6
|
MaxGuesses int
|
||||||
Category WordleCategory
|
Category WordleCategory
|
||||||
Guesses []WordleGuess
|
Guesses []WordleGuess
|
||||||
Solved bool
|
Solved bool
|
||||||
|
|||||||
@@ -75,19 +75,21 @@ func (p *WOTDPlugin) OnMessage(ctx MessageContext) error {
|
|||||||
// For non-English languages, requires English translations and filters cognates.
|
// For non-English languages, requires English translations and filters cognates.
|
||||||
// For all languages, fetches synonyms and cross-translations.
|
// For all languages, fetches synonyms and cross-translations.
|
||||||
func (p *WOTDPlugin) pickWord(lang string) (string, string, string, string) {
|
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 {
|
type transTarget struct {
|
||||||
lang string
|
lang string
|
||||||
key string
|
key string
|
||||||
|
required bool
|
||||||
}
|
}
|
||||||
var targets []transTarget
|
var targets []transTarget
|
||||||
switch lang {
|
switch lang {
|
||||||
case "pt-PT":
|
case "pt-PT":
|
||||||
targets = []transTarget{{"en", "en"}, {"fr", "fr"}}
|
targets = []transTarget{{"en", "en", false}, {"fr", "fr", false}}
|
||||||
case "fr":
|
case "fr":
|
||||||
targets = []transTarget{{"en", "en"}, {"pt-PT", "pt-PT"}}
|
targets = []transTarget{{"en", "en", false}, {"pt-PT", "pt-PT", false}}
|
||||||
case "en":
|
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++ {
|
for attempt := 0; attempt < 100; attempt++ {
|
||||||
@@ -106,19 +108,38 @@ func (p *WOTDPlugin) pickWord(lang string) (string, string, string, string) {
|
|||||||
|
|
||||||
for _, t := range targets {
|
for _, t := range targets {
|
||||||
trans, _ := p.dict.Translate(candidate, lang, t.lang)
|
trans, _ := p.dict.Translate(candidate, lang, t.lang)
|
||||||
if len(trans) == 0 {
|
if len(trans) == 0 && t.required {
|
||||||
valid = false
|
valid = false
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
if len(trans) > 0 {
|
||||||
transMap[t.key] = trans
|
transMap[t.key] = trans
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if !valid {
|
if !valid {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// For non-English words, filter cognates where the English translation
|
// For non-English words, reject English words that leaked into the
|
||||||
// is just the word itself
|
// foreign language database.
|
||||||
if lang != "en" {
|
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 {
|
if enTrans, ok := transMap["en"]; ok {
|
||||||
meaningfulEn := false
|
meaningfulEn := false
|
||||||
for _, t := range enTrans {
|
for _, t := range enTrans {
|
||||||
@@ -232,27 +253,6 @@ func (p *WOTDPlugin) prefetchWord(force bool) error {
|
|||||||
return nil
|
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.
|
// 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 {
|
func (p *WOTDPlugin) PostWOTD(roomID id.RoomID) error {
|
||||||
today := time.Now().UTC().Format("2006-01-02")
|
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)
|
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)
|
msg := p.formatWOTD(word, definition, partOfSpeech, example)
|
||||||
if err := p.SendMessage(roomID, msg); err != nil {
|
if err := p.SendMessage(roomID, msg); err != nil {
|
||||||
return fmt.Errorf("wotd: send message: %w", err)
|
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.")
|
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)
|
msg := p.formatWOTD(word, definition, partOfSpeech, example)
|
||||||
return p.SendReply(ctx.RoomID, ctx.EventID, msg)
|
return p.SendReply(ctx.RoomID, ctx.EventID, msg)
|
||||||
}
|
}
|
||||||
@@ -594,6 +562,74 @@ Respond with ONLY "yes" or "no".`, message, word)
|
|||||||
return accepted
|
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.
|
// grantWOTDXP inserts XP directly via SQL to avoid cross-plugin dependency.
|
||||||
func (p *WOTDPlugin) grantWOTDXP(userID id.UserID, amount int) {
|
func (p *WOTDPlugin) grantWOTDXP(userID id.UserID, amount int) {
|
||||||
d := db.Get()
|
d := db.Get()
|
||||||
|
|||||||
Reference in New Issue
Block a user