From 306b4c2ae83cd1f867dca1d1967223ee2370c9ea Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Mon, 30 Mar 2026 21:28:27 -0700 Subject: [PATCH] Fix WOTD translation requirements, LLM sentiment accuracy, arena death timer, and fancy word detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WOTD: Require both English and French translations, validate before display, fall back to English word if pt-PT search exhausts 100 attempts. LLM passive: Inject actual WOTD into classification prompt instead of letting LLM guess at "unusual words". Add DreamDict-based fancy word detection using frequency data — react with 🎓 for rare vocabulary. Arena: Reduce death lockout from next-midnight-UTC to 2 hours. Co-Authored-By: Claude Opus 4.6 --- internal/dreamclient/client.go | 23 ++++++++ internal/plugin/adventure_arena.go | 4 +- internal/plugin/llm_passive.go | 66 ++++++++++++++++++++--- internal/plugin/wotd.go | 87 +++++++++++++++++++++++++----- main.go | 2 +- 5 files changed, 161 insertions(+), 21 deletions(-) diff --git a/internal/dreamclient/client.go b/internal/dreamclient/client.go index 46b26ff..0e538fc 100644 --- a/internal/dreamclient/client.go +++ b/internal/dreamclient/client.go @@ -166,6 +166,29 @@ func (c *Client) Translate(word, from, to string) ([]string, error) { return result.Translations, nil } +// Frequency returns the frequency score for a word in a language. +// Higher values indicate more common words. Returns 0 if unknown. +func (c *Client) Frequency(word, lang string) (int, error) { + u := c.baseURL + "/frequency?" + url.Values{"word": {word}, "lang": {lang}}.Encode() + resp, err := c.httpClient.Get(u) + if err != nil { + return 0, fmt.Errorf("dreamdict: frequency: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return 0, fmt.Errorf("dreamdict: frequency: status %d", resp.StatusCode) + } + + var result struct { + Frequency int `json:"frequency"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return 0, fmt.Errorf("dreamdict: frequency: decode: %w", err) + } + return result.Frequency, nil +} + // Health checks if the DreamDict service is reachable and returns stats. func (c *Client) Health() (*HealthResponse, error) { resp, err := c.httpClient.Get(c.baseURL + "/health") diff --git a/internal/plugin/adventure_arena.go b/internal/plugin/adventure_arena.go index a6da6e4..647e659 100644 --- a/internal/plugin/adventure_arena.go +++ b/internal/plugin/adventure_arena.go @@ -534,10 +534,10 @@ func (p *AdventurePlugin) resolveArenaDeath(ctx MessageContext, run *ArenaRun, c lostEarnings := run.Earnings - // Kill the character (locked out until next midnight UTC) + // Kill the character (locked out for 2 hours) char.Alive = false now := time.Now().UTC() - deadUntil := time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, time.UTC) + deadUntil := now.Add(2 * time.Hour) char.DeadUntil = &deadUntil char.ArenaLosses++ char.CombatXP += arenaParticipationXP // +60 flat participation XP diff --git a/internal/plugin/llm_passive.go b/internal/plugin/llm_passive.go index 33125dd..2b6808d 100644 --- a/internal/plugin/llm_passive.go +++ b/internal/plugin/llm_passive.go @@ -17,6 +17,7 @@ import ( "unicode" "gogobee/internal/db" + "gogobee/internal/dreamclient" "maunium.net/go/mautrix" "maunium.net/go/mautrix/id" @@ -57,6 +58,7 @@ type queueItem struct { type LLMPassivePlugin struct { Base xp *XPPlugin + dict *dreamclient.Client ollamaHost string ollamaModel string sampleRate float64 @@ -71,7 +73,7 @@ type LLMPassivePlugin struct { } // NewLLMPassivePlugin creates a new LLM passive classification plugin. -func NewLLMPassivePlugin(client *mautrix.Client, xp *XPPlugin) *LLMPassivePlugin { +func NewLLMPassivePlugin(client *mautrix.Client, xp *XPPlugin, dict *dreamclient.Client) *LLMPassivePlugin { host := os.Getenv("OLLAMA_HOST") model := os.Getenv("OLLAMA_MODEL") enabled := host != "" && model != "" @@ -86,6 +88,7 @@ func NewLLMPassivePlugin(client *mautrix.Client, xp *XPPlugin) *LLMPassivePlugin p := &LLMPassivePlugin{ Base: NewBase(client), xp: xp, + dict: dict, ollamaHost: host, ollamaModel: model, sampleRate: sampleRate, @@ -309,7 +312,12 @@ func (p *LLMPassivePlugin) classifyAndProcess(item queueItem) error { mentionHint = "\nMentioned users: " + strings.Join(parts, ", ") } - result, err := p.callOllama(item.Body + mentionHint) + // Fetch today's WOTD so the LLM can check for correct usage + today := time.Now().UTC().Format("2006-01-02") + var todayWOTD string + db.Get().QueryRow(`SELECT word FROM wotd_log WHERE date = ?`, today).Scan(&todayWOTD) + + result, err := p.callOllama(item.Body+mentionHint, todayWOTD) if err != nil { return fmt.Errorf("ollama call: %w", err) } @@ -454,7 +462,13 @@ func (p *LLMPassivePlugin) classifyAndProcess(item queueItem) error { } } if result.WOTDUsed { - _ = p.SendReact(item.RoomID, item.EventID, "\U0001f4d6") // open book + _ = p.SendReact(item.RoomID, item.EventID, "\U0001f4d6") // 📖 open book + } + // Check for rare/sophisticated words via DreamDict frequency data + if p.dict != nil { + if p.hasFancyWord(item.Body) { + _ = p.SendReact(item.RoomID, item.EventID, "\U0001f393") // 🎓 graduation cap + } } if result.GratitudeTarget != "" { _ = p.SendReact(item.RoomID, item.EventID, "\U0001f49c") // purple heart @@ -485,7 +499,12 @@ type ollamaResponse struct { } // callOllama sends a classification prompt to Ollama and parses the JSON result. -func (p *LLMPassivePlugin) callOllama(messageText string) (*classificationResult, error) { +func (p *LLMPassivePlugin) callOllama(messageText, wotd string) (*classificationResult, error) { + wotdInstruction := `"wotd_used": false` + if wotd != "" { + wotdInstruction = fmt.Sprintf(`"wotd_used": true | false (whether the message uses the word "%s" correctly and meaningfully — not just mentioning or quoting it)`, wotd) + } + prompt := fmt.Sprintf(`Classify the following chat message. Respond ONLY with valid JSON (no markdown, no explanation). JSON schema: @@ -496,11 +515,11 @@ JSON schema: "profanity": true | false, "profanity_severity": 0 | 1 | 2 | 3 (0=none, 1=mild e.g. damn/hell/crap, 2=moderate e.g. shit/ass/bitch, 3=scorching e.g. fuck/cunt and slurs), "insult_target": "" or "@user:server" if someone is being insulted, - "wotd_used": true | false (if the message uses an unusual/sophisticated word), + %s, "gratitude_target": "" or "@user:server" if thanking someone } -Message: %s`, messageText) +Message: %s`, wotdInstruction, messageText) reqBody := ollamaRequest{ Model: p.ollamaModel, @@ -540,6 +559,41 @@ Message: %s`, messageText) } // parseClassification parses a JSON classification response with repair logic. +// fancyWordMaxFreq is the threshold below which a word is considered rare/sophisticated. +// SCOWL tiers: 10=1000, 20=800, 35=600, 50=400, 60=200, 70=50. +// A frequency of 100 or below corresponds to SCOWL tier 65-70 (rare words). +const fancyWordMaxFreq = 100 + +// fancyWordMinLength filters out short words that may just be uncommon abbreviations. +const fancyWordMinLength = 6 + +// hasFancyWord checks if any word in the message is a rare/sophisticated English word +// by looking up its frequency score in DreamDict. +func (p *LLMPassivePlugin) hasFancyWord(message string) bool { + words := strings.Fields(strings.ToLower(message)) + for _, w := range words { + // Strip punctuation from edges + w = strings.Trim(w, ".,!?;:\"'()[]{}…—–-") + if len(w) < fancyWordMinLength { + continue + } + // Skip URLs and mentions + if strings.Contains(w, "://") || strings.HasPrefix(w, "@") { + continue + } + freq, err := p.dict.Frequency(w, "en") + if err != nil { + continue + } + // freq == 0 means unknown word (not in dictionary), skip it. + // Low but nonzero frequency means rare but real word. + if freq > 0 && freq <= fancyWordMaxFreq { + return true + } + } + return false +} + func parseClassification(raw string) (*classificationResult, error) { cleaned := raw diff --git a/internal/plugin/wotd.go b/internal/plugin/wotd.go index eb658e4..6472a8a 100644 --- a/internal/plugin/wotd.go +++ b/internal/plugin/wotd.go @@ -94,13 +94,14 @@ func (p *WOTDPlugin) prefetchWord(force bool) error { } } - // Pick a random pt-PT word with definitions; prefer one with English translations. + // Pick a random pt-PT word with definitions and both English + French translations. + // Fall back to an English word if no valid pt-PT candidate is found. var word, definition, partOfSpeech, translationsJSON string - for attempt := 0; attempt < 10; attempt++ { + for attempt := 0; attempt < 100; attempt++ { candidate, err := p.dict.RandomWord("pt-PT", "", 4, 14) if err != nil { - slog.Warn("wotd: random word attempt failed", "attempt", attempt+1, "err", err) + slog.Warn("wotd: random word attempt failed", "err", err) continue } @@ -109,13 +110,16 @@ func (p *WOTDPlugin) prefetchWord(force bool) error { continue } - // Check for English translation (preferred but not required after 5 attempts) + // Require at least one English and one French translation enTrans, _ := p.dict.Translate(candidate, "pt-PT", "en") - if len(enTrans) == 0 && attempt < 5 { + if len(enTrans) == 0 { continue } frTrans, _ := p.dict.Translate(candidate, "pt-PT", "fr") + if len(frTrans) == 0 { + continue + } word = candidate definition = defs[0].Gloss @@ -123,20 +127,35 @@ func (p *WOTDPlugin) prefetchWord(force bool) error { // Store translations as JSON in the example column transMap := map[string][]string{} - if len(enTrans) > 0 { - transMap["en"] = enTrans - } - if len(frTrans) > 0 { - transMap["fr"] = frTrans - } + transMap["en"] = enTrans + transMap["fr"] = frTrans if data, err := json.Marshal(transMap); err == nil { translationsJSON = string(data) } break } + // Fallback: pick an English word if pt-PT search somehow fails if word == "" { - return fmt.Errorf("wotd: failed to find a pt-PT word with definitions after 10 attempts") + slog.Warn("wotd: pt-PT search failed, falling back to English word") + for { + candidate, err := p.dict.RandomWord("en", "", 4, 14) + if err != nil { + continue + } + defs, err := p.dict.Define(candidate, "en") + if err != nil || len(defs) == 0 { + continue + } + word = candidate + definition = defs[0].Gloss + partOfSpeech = defs[0].POS + transMap := map[string][]string{"en": {candidate}} + if data, err := json.Marshal(transMap); err == nil { + translationsJSON = string(data) + } + break + } } if force { @@ -163,6 +182,18 @@ func (p *WOTDPlugin) prefetchWord(force bool) error { return nil } +// hasEnglishTranslation checks whether the stored translations JSON includes English. +func hasEnglishTranslation(translationsJSON string) bool { + if translationsJSON == "" { + return false + } + var transMap map[string][]string + if json.Unmarshal([]byte(translationsJSON), &transMap) != nil { + return false + } + return len(transMap["en"]) > 0 +} + // 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") @@ -194,6 +225,20 @@ 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 !hasEnglishTranslation(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) @@ -227,6 +272,24 @@ 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 !hasEnglishTranslation(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) } diff --git a/main.go b/main.go index 026540f..7a53ea8 100644 --- a/main.go +++ b/main.go @@ -155,7 +155,7 @@ func main() { registry.Register(minifluxPlugin) // LLM-powered (passive) - registry.Register(plugin.NewLLMPassivePlugin(client, xpPlugin)) + registry.Register(plugin.NewLLMPassivePlugin(client, xpPlugin, dictClient)) // Scheduled wotdPlugin := plugin.NewWOTDPlugin(client, dictClient)