mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 00:32:40 +00:00
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:
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
2
main.go
2
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)
|
||||
|
||||
Reference in New Issue
Block a user