Fix WOTD translation requirements, LLM sentiment accuracy, arena death timer, and fancy word detection

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 <noreply@anthropic.com>
This commit is contained in:
prosolis
2026-03-30 21:28:27 -07:00
parent 2c6f4e48c9
commit 306b4c2ae8
5 changed files with 161 additions and 21 deletions

View File

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

View File

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

View File

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

View File

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

View File

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