mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +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
|
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.
|
// Health checks if the DreamDict service is reachable and returns stats.
|
||||||
func (c *Client) Health() (*HealthResponse, error) {
|
func (c *Client) Health() (*HealthResponse, error) {
|
||||||
resp, err := c.httpClient.Get(c.baseURL + "/health")
|
resp, err := c.httpClient.Get(c.baseURL + "/health")
|
||||||
|
|||||||
@@ -534,10 +534,10 @@ func (p *AdventurePlugin) resolveArenaDeath(ctx MessageContext, run *ArenaRun, c
|
|||||||
|
|
||||||
lostEarnings := run.Earnings
|
lostEarnings := run.Earnings
|
||||||
|
|
||||||
// Kill the character (locked out until next midnight UTC)
|
// Kill the character (locked out for 2 hours)
|
||||||
char.Alive = false
|
char.Alive = false
|
||||||
now := time.Now().UTC()
|
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.DeadUntil = &deadUntil
|
||||||
char.ArenaLosses++
|
char.ArenaLosses++
|
||||||
char.CombatXP += arenaParticipationXP // +60 flat participation XP
|
char.CombatXP += arenaParticipationXP // +60 flat participation XP
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import (
|
|||||||
"unicode"
|
"unicode"
|
||||||
|
|
||||||
"gogobee/internal/db"
|
"gogobee/internal/db"
|
||||||
|
"gogobee/internal/dreamclient"
|
||||||
|
|
||||||
"maunium.net/go/mautrix"
|
"maunium.net/go/mautrix"
|
||||||
"maunium.net/go/mautrix/id"
|
"maunium.net/go/mautrix/id"
|
||||||
@@ -57,6 +58,7 @@ type queueItem struct {
|
|||||||
type LLMPassivePlugin struct {
|
type LLMPassivePlugin struct {
|
||||||
Base
|
Base
|
||||||
xp *XPPlugin
|
xp *XPPlugin
|
||||||
|
dict *dreamclient.Client
|
||||||
ollamaHost string
|
ollamaHost string
|
||||||
ollamaModel string
|
ollamaModel string
|
||||||
sampleRate float64
|
sampleRate float64
|
||||||
@@ -71,7 +73,7 @@ type LLMPassivePlugin struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// NewLLMPassivePlugin creates a new LLM passive classification plugin.
|
// 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")
|
host := os.Getenv("OLLAMA_HOST")
|
||||||
model := os.Getenv("OLLAMA_MODEL")
|
model := os.Getenv("OLLAMA_MODEL")
|
||||||
enabled := host != "" && model != ""
|
enabled := host != "" && model != ""
|
||||||
@@ -86,6 +88,7 @@ func NewLLMPassivePlugin(client *mautrix.Client, xp *XPPlugin) *LLMPassivePlugin
|
|||||||
p := &LLMPassivePlugin{
|
p := &LLMPassivePlugin{
|
||||||
Base: NewBase(client),
|
Base: NewBase(client),
|
||||||
xp: xp,
|
xp: xp,
|
||||||
|
dict: dict,
|
||||||
ollamaHost: host,
|
ollamaHost: host,
|
||||||
ollamaModel: model,
|
ollamaModel: model,
|
||||||
sampleRate: sampleRate,
|
sampleRate: sampleRate,
|
||||||
@@ -309,7 +312,12 @@ func (p *LLMPassivePlugin) classifyAndProcess(item queueItem) error {
|
|||||||
mentionHint = "\nMentioned users: " + strings.Join(parts, ", ")
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("ollama call: %w", err)
|
return fmt.Errorf("ollama call: %w", err)
|
||||||
}
|
}
|
||||||
@@ -454,7 +462,13 @@ func (p *LLMPassivePlugin) classifyAndProcess(item queueItem) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if result.WOTDUsed {
|
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 != "" {
|
if result.GratitudeTarget != "" {
|
||||||
_ = p.SendReact(item.RoomID, item.EventID, "\U0001f49c") // purple heart
|
_ = 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.
|
// 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).
|
prompt := fmt.Sprintf(`Classify the following chat message. Respond ONLY with valid JSON (no markdown, no explanation).
|
||||||
|
|
||||||
JSON schema:
|
JSON schema:
|
||||||
@@ -496,11 +515,11 @@ JSON schema:
|
|||||||
"profanity": true | false,
|
"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),
|
"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,
|
"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
|
"gratitude_target": "" or "@user:server" if thanking someone
|
||||||
}
|
}
|
||||||
|
|
||||||
Message: %s`, messageText)
|
Message: %s`, wotdInstruction, messageText)
|
||||||
|
|
||||||
reqBody := ollamaRequest{
|
reqBody := ollamaRequest{
|
||||||
Model: p.ollamaModel,
|
Model: p.ollamaModel,
|
||||||
@@ -540,6 +559,41 @@ Message: %s`, messageText)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// parseClassification parses a JSON classification response with repair logic.
|
// 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) {
|
func parseClassification(raw string) (*classificationResult, error) {
|
||||||
cleaned := raw
|
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
|
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)
|
candidate, err := p.dict.RandomWord("pt-PT", "", 4, 14)
|
||||||
if err != nil {
|
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
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,13 +110,16 @@ func (p *WOTDPlugin) prefetchWord(force bool) error {
|
|||||||
continue
|
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")
|
enTrans, _ := p.dict.Translate(candidate, "pt-PT", "en")
|
||||||
if len(enTrans) == 0 && attempt < 5 {
|
if len(enTrans) == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
frTrans, _ := p.dict.Translate(candidate, "pt-PT", "fr")
|
frTrans, _ := p.dict.Translate(candidate, "pt-PT", "fr")
|
||||||
|
if len(frTrans) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
word = candidate
|
word = candidate
|
||||||
definition = defs[0].Gloss
|
definition = defs[0].Gloss
|
||||||
@@ -123,20 +127,35 @@ func (p *WOTDPlugin) prefetchWord(force bool) error {
|
|||||||
|
|
||||||
// Store translations as JSON in the example column
|
// Store translations as JSON in the example column
|
||||||
transMap := map[string][]string{}
|
transMap := map[string][]string{}
|
||||||
if len(enTrans) > 0 {
|
|
||||||
transMap["en"] = enTrans
|
transMap["en"] = enTrans
|
||||||
}
|
|
||||||
if len(frTrans) > 0 {
|
|
||||||
transMap["fr"] = frTrans
|
transMap["fr"] = frTrans
|
||||||
}
|
|
||||||
if data, err := json.Marshal(transMap); err == nil {
|
if data, err := json.Marshal(transMap); err == nil {
|
||||||
translationsJSON = string(data)
|
translationsJSON = string(data)
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fallback: pick an English word if pt-PT search somehow fails
|
||||||
if word == "" {
|
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 {
|
if force {
|
||||||
@@ -163,6 +182,18 @@ func (p *WOTDPlugin) prefetchWord(force bool) error {
|
|||||||
return nil
|
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.
|
// 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")
|
||||||
@@ -194,6 +225,20 @@ 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 !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)
|
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)
|
||||||
@@ -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.")
|
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)
|
msg := p.formatWOTD(word, definition, partOfSpeech, example)
|
||||||
return p.SendReply(ctx.RoomID, ctx.EventID, msg)
|
return p.SendReply(ctx.RoomID, ctx.EventID, msg)
|
||||||
}
|
}
|
||||||
|
|||||||
2
main.go
2
main.go
@@ -155,7 +155,7 @@ func main() {
|
|||||||
registry.Register(minifluxPlugin)
|
registry.Register(minifluxPlugin)
|
||||||
|
|
||||||
// LLM-powered (passive)
|
// LLM-powered (passive)
|
||||||
registry.Register(plugin.NewLLMPassivePlugin(client, xpPlugin))
|
registry.Register(plugin.NewLLMPassivePlugin(client, xpPlugin, dictClient))
|
||||||
|
|
||||||
// Scheduled
|
// Scheduled
|
||||||
wotdPlugin := plugin.NewWOTDPlugin(client, dictClient)
|
wotdPlugin := plugin.NewWOTDPlugin(client, dictClient)
|
||||||
|
|||||||
Reference in New Issue
Block a user