Expand sentiment to 10 categories, add DB maintenance job, update README

- Expand LLM sentiment classification from 3 to 10 categories: positive,
  negative, neutral, excited, sarcastic, frustrated, curious, grateful,
  humorous, supportive — with emoji reactions for each
- Add daily DB maintenance job at 03:00 UTC to purge stale caches,
  expired rate limits, old logs, and run SQLite optimize
- Add migrate.sql for upgrading existing databases
- Update README: add !sentiment command, maintenance job, rate limit
  config, in-memory message buffer docs, SDK migration history,
  remove references to previous private repo

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
prosolis
2026-03-08 20:46:18 -07:00
parent 2c7c15b282
commit 298c7bb8f1
6 changed files with 182 additions and 40 deletions

View File

@@ -7,6 +7,7 @@ import (
"os"
"path/filepath"
"sync"
"time"
_ "modernc.org/sqlite"
)
@@ -110,6 +111,74 @@ func CacheSet(key, data string) {
}
}
// RunMaintenance purges stale data from cache tables, old rate limits,
// expired logs, and runs SQLite optimization. Intended to run daily.
func RunMaintenance() {
d := Get()
now := time.Now().UTC()
today := now.Format("2006-01-02")
cutoff7d := now.AddDate(0, 0, -7).Unix()
cutoff30d := now.AddDate(0, 0, -30).Unix()
date30d := now.AddDate(0, 0, -30).Format("2006-01-02")
date90d := now.AddDate(0, 0, -90).Format("2006-01-02")
queries := []struct {
label string
sql string
args []interface{}
}{
// Cache tables — purge entries older than their effective TTL
{"api_cache", `DELETE FROM api_cache WHERE cached_at < ?`, []interface{}{cutoff7d}},
{"releases_cache", `DELETE FROM releases_cache WHERE cached_at < ?`, []interface{}{cutoff7d}},
{"hltb_cache", `DELETE FROM hltb_cache WHERE cached_at < ?`, []interface{}{cutoff7d}},
{"stocks_cache", `DELETE FROM stocks_cache WHERE cached_at < ?`, []interface{}{cutoff7d}},
{"concerts_cache", `DELETE FROM concerts_cache WHERE cached_at < ?`, []interface{}{cutoff7d}},
{"anime_cache", `DELETE FROM anime_cache WHERE cached_at < ?`, []interface{}{cutoff30d}},
{"movie_cache", `DELETE FROM movie_cache WHERE cached_at < ?`, []interface{}{cutoff30d}},
{"retro_cache", `DELETE FROM retro_cache WHERE cached_at < ?`, []interface{}{cutoff30d}},
{"urban_cache", `DELETE FROM urban_cache WHERE cached_at < ?`, []interface{}{cutoff30d}},
{"url_cache", `DELETE FROM url_cache WHERE cached_at < ?`, []interface{}{cutoff30d}},
// Rate limits — purge entries older than today
{"rate_limits", `DELETE FROM rate_limits WHERE date < ?`, []interface{}{today}},
// Daily prefetch log — keep 30 days
{"daily_prefetch", `DELETE FROM daily_prefetch WHERE date < ?`, []interface{}{date30d}},
// Holiday and WOTD logs — keep 90 days
{"holidays_log", `DELETE FROM holidays_log WHERE date < ?`, []interface{}{date90d}},
{"wotd_log", `DELETE FROM wotd_log WHERE date < ?`, []interface{}{date90d}},
{"wotd_usage", `DELETE FROM wotd_usage WHERE date < ?`, []interface{}{date90d}},
// LLM classifications — keep 30 days
{"llm_classifications", `DELETE FROM llm_classifications WHERE timestamp < ?`, []interface{}{cutoff30d}},
// Daily activity older than 1 year
{"daily_activity", `DELETE FROM daily_activity WHERE date < ?`, []interface{}{now.AddDate(-1, 0, 0).Format("2006-01-02")}},
}
totalDeleted := int64(0)
for _, q := range queries {
res, err := d.Exec(q.sql, q.args...)
if err != nil {
slog.Error("maintenance: "+q.label, "err", err)
continue
}
n, _ := res.RowsAffected()
if n > 0 {
slog.Info("maintenance: purged", "table", q.label, "rows", n)
totalDeleted += n
}
}
// SQLite optimization
if _, err := d.Exec(`PRAGMA optimize`); err != nil {
slog.Error("maintenance: pragma optimize", "err", err)
}
slog.Info("maintenance: complete", "total_purged", totalDeleted)
}
const schema = `
-- Users & XP
CREATE TABLE IF NOT EXISTS users (
@@ -508,6 +577,13 @@ CREATE TABLE IF NOT EXISTS sentiment_stats (
positive INTEGER DEFAULT 0,
negative INTEGER DEFAULT 0,
neutral INTEGER DEFAULT 0,
excited INTEGER DEFAULT 0,
sarcastic INTEGER DEFAULT 0,
frustrated INTEGER DEFAULT 0,
curious INTEGER DEFAULT 0,
grateful INTEGER DEFAULT 0,
humorous INTEGER DEFAULT 0,
supportive INTEGER DEFAULT 0,
total_score REAL DEFAULT 0
);

View File

@@ -120,11 +120,13 @@ func (p *HowAmIPlugin) gatherProfile(userID id.UserID) string {
sb.WriteString(fmt.Sprintf("Achievements unlocked: %d\n", achievementCount))
// Sentiment stats
var positive, negative, neutral int
var positive, negative, neutral, excited, sarcastic, frustrated, curious, grateful, humorous, supportive int
if err := d.QueryRow(
`SELECT positive, negative, neutral FROM sentiment_stats WHERE user_id = ?`, uid,
).Scan(&positive, &negative, &neutral); err == nil {
sb.WriteString(fmt.Sprintf("Sentiment: %d positive, %d negative, %d neutral\n", positive, negative, neutral))
`SELECT positive, negative, neutral, excited, sarcastic, frustrated, curious, grateful, humorous, supportive
FROM sentiment_stats WHERE user_id = ?`, uid,
).Scan(&positive, &negative, &neutral, &excited, &sarcastic, &frustrated, &curious, &grateful, &humorous, &supportive); err == nil {
sb.WriteString(fmt.Sprintf("Sentiment: %d positive, %d excited, %d supportive, %d grateful, %d humorous, %d curious, %d neutral, %d sarcastic, %d frustrated, %d negative\n",
positive, excited, supportive, grateful, humorous, curious, neutral, sarcastic, frustrated, negative))
}
// Profanity count

View File

@@ -287,26 +287,22 @@ func (p *LLMPassivePlugin) classifyAndProcess(item queueItem) error {
}
// Aggregate sentiment stats
switch result.Sentiment {
case "positive":
_, _ = d.Exec(
`INSERT INTO sentiment_stats (user_id, positive, total_score) VALUES (?, 1, ?)
ON CONFLICT(user_id) DO UPDATE SET positive = positive + 1, total_score = total_score + ?`,
string(item.UserID), result.SentimentScore, result.SentimentScore,
)
case "negative":
_, _ = d.Exec(
`INSERT INTO sentiment_stats (user_id, negative, total_score) VALUES (?, 1, ?)
ON CONFLICT(user_id) DO UPDATE SET negative = negative + 1, total_score = total_score + ?`,
string(item.UserID), result.SentimentScore, result.SentimentScore,
)
default:
_, _ = d.Exec(
`INSERT INTO sentiment_stats (user_id, neutral, total_score) VALUES (?, 1, ?)
ON CONFLICT(user_id) DO UPDATE SET neutral = neutral + 1, total_score = total_score + ?`,
string(item.UserID), result.SentimentScore, result.SentimentScore,
)
sentimentCol := "neutral"
validSentiments := map[string]bool{
"positive": true, "negative": true, "neutral": true,
"excited": true, "sarcastic": true, "frustrated": true,
"curious": true, "grateful": true, "humorous": true, "supportive": true,
}
if validSentiments[result.Sentiment] {
sentimentCol = result.Sentiment
}
_, _ = d.Exec(
fmt.Sprintf(
`INSERT INTO sentiment_stats (user_id, %s, total_score) VALUES (?, 1, ?)
ON CONFLICT(user_id) DO UPDATE SET %s = %s + 1, total_score = total_score + ?`,
sentimentCol, sentimentCol, sentimentCol),
string(item.UserID), result.SentimentScore, result.SentimentScore,
)
// Track profanity with severity
if result.Profanity {
@@ -360,12 +356,24 @@ func (p *LLMPassivePlugin) classifyAndProcess(item queueItem) error {
)
}
// React with emojis based on classification
if result.Sentiment == "positive" && result.SentimentScore > 0.7 {
_ = p.SendReact(item.RoomID, item.EventID, "\U0001f44d") // thumbsup
// React with emojis based on sentiment
sentimentEmojis := map[string]string{
"positive": "\U0001f44d", // 👍
"negative": "\U0001f44e", // 👎
"excited": "\U0001f525", // 🔥
"sarcastic": "\U0001f928", // 🤨
"frustrated": "\U0001f62e\u200d\U0001f4a8", // 😮‍💨
"curious": "\U0001f9d0", // 🧐
"grateful": "\U0001f49c", // 💜
"humorous": "\U0001f602", // 😂
"supportive": "\U0001f917", // 🤗
}
if result.Sentiment == "negative" && result.SentimentScore < -0.7 {
_ = p.SendReact(item.RoomID, item.EventID, "\U0001f44e") // thumbsdown
if emoji, ok := sentimentEmojis[result.Sentiment]; ok && result.Sentiment != "neutral" {
// Only react to strong sentiments (|score| > 0.5)
score := result.SentimentScore
if score > 0.5 || score < -0.5 {
_ = p.SendReact(item.RoomID, item.EventID, emoji)
}
}
if result.Profanity {
switch result.ProfanitySeverity {
@@ -413,7 +421,7 @@ func (p *LLMPassivePlugin) callOllama(messageText string) (*classificationResult
JSON schema:
{
"sentiment": "positive" | "negative" | "neutral",
"sentiment": "positive" | "negative" | "neutral" | "excited" | "sarcastic" | "frustrated" | "curious" | "grateful" | "humorous" | "supportive",
"sentiment_score": number between -1.0 and 1.0,
"topics": ["topic1", "topic2"],
"profanity": true | false,
@@ -644,18 +652,22 @@ func (p *LLMPassivePlugin) handleSentiment(ctx MessageContext) error {
}
d := db.Get()
var positive, negative, neutral int
var positive, negative, neutral, excited, sarcastic, frustrated, curious, grateful, humorous, supportive int
var totalScore float64
err := d.QueryRow(
`SELECT COALESCE(positive, 0), COALESCE(negative, 0), COALESCE(neutral, 0), COALESCE(total_score, 0)
`SELECT COALESCE(positive, 0), COALESCE(negative, 0), COALESCE(neutral, 0),
COALESCE(excited, 0), COALESCE(sarcastic, 0), COALESCE(frustrated, 0),
COALESCE(curious, 0), COALESCE(grateful, 0), COALESCE(humorous, 0),
COALESCE(supportive, 0), COALESCE(total_score, 0)
FROM sentiment_stats WHERE user_id = ?`,
string(target),
).Scan(&positive, &negative, &neutral, &totalScore)
).Scan(&positive, &negative, &neutral, &excited, &sarcastic, &frustrated,
&curious, &grateful, &humorous, &supportive, &totalScore)
if err != nil {
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("No sentiment data for %s yet.", string(target)))
}
total := positive + negative + neutral
total := positive + negative + neutral + excited + sarcastic + frustrated + curious + grateful + humorous + supportive
avgScore := 0.0
if total > 0 {
avgScore = totalScore / float64(total)
@@ -672,7 +684,33 @@ func (p *LLMPassivePlugin) handleSentiment(ctx MessageContext) error {
mood = "leaning negative"
}
msg := fmt.Sprintf("Sentiment for %s:\n😊 Positive: %s 😐 Neutral: %s 😠 Negative: %s\nAverage mood: %.2f (%s)",
string(target), formatNumber(positive), formatNumber(neutral), formatNumber(negative), avgScore, mood)
return p.SendReply(ctx.RoomID, ctx.EventID, msg)
// Build sentiment breakdown, only showing non-zero counts
type sentEntry struct {
emoji string
label string
count int
}
entries := []sentEntry{
{"👍", "Positive", positive},
{"🔥", "Excited", excited},
{"🤗", "Supportive", supportive},
{"💜", "Grateful", grateful},
{"😂", "Humorous", humorous},
{"🧐", "Curious", curious},
{"😐", "Neutral", neutral},
{"🤨", "Sarcastic", sarcastic},
{"😮\u200d💨", "Frustrated", frustrated},
{"👎", "Negative", negative},
}
var sb strings.Builder
sb.WriteString(fmt.Sprintf("Sentiment for %s:\n", string(target)))
for _, e := range entries {
if e.count > 0 {
sb.WriteString(fmt.Sprintf(" %s %s: %s\n", e.emoji, e.label, formatNumber(e.count)))
}
}
sb.WriteString(fmt.Sprintf("Average mood: %.2f (%s)", avgScore, mood))
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
}