diff --git a/internal/db/db.go b/internal/db/db.go index 6758f6f..b4ec996 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -1081,6 +1081,18 @@ CREATE TABLE IF NOT EXISTS market_daily_summary ( generated_at INTEGER ); +-- Archetype cache (recalculated nightly) +CREATE TABLE IF NOT EXISTS user_archetypes ( + user_id TEXT NOT NULL, + archetype TEXT NOT NULL, + category TEXT NOT NULL DEFAULT '', + signal_score REAL NOT NULL DEFAULT 0, + flavor TEXT NOT NULL DEFAULT '', + assigned_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (user_id, archetype) +); +CREATE INDEX IF NOT EXISTS idx_user_archetypes_user ON user_archetypes(user_id, signal_score DESC); + ` // SeedSchedulerDefaults inserts default scheduler jobs if they don't exist. diff --git a/internal/plugin/archetype.go b/internal/plugin/archetype.go new file mode 100644 index 0000000..da1482d --- /dev/null +++ b/internal/plugin/archetype.go @@ -0,0 +1,872 @@ +package plugin + +import ( + "database/sql" + "fmt" + "log/slog" + "math" + "sort" + "strings" + + "gogobee/internal/db" +) + +// ─── Threshold Constants ────��────────────────────────────────────── + +const ( + // Communication + thNovelistAvgWords = 7 + thNovelistMinMsgs = 100 + thMinimalistMaxAvgWords = 4 + thMinimalistMinMsgs = 50 + thInquisitorPct = 8 + thInquisitorMinMsgs = 50 + thEnthusiastPct = 8 + thEnthusiastMinMsgs = 50 + thChatterboxMinMsgs = 500 + thChatterboxMinAvgWords = 5 + thLinkmasterPct = 5 + thLinkmasterMinMsgs = 50 + + // Temporal + thNightOwlPct = 40 + thNightOwlMinMsgs = 100 + thEarlyBirdPct = 40 + thEarlyBirdMinMsgs = 100 + + // Emotional (LLM-gated) + thEmotionalMinClassified = 100 + thCheerleaderPosPct = 50 + thPhilosopherNeutPct = 40 + thPhilosopherQPct = 10 + thPhilosopherAvgWords = 8 + thAgitatorNegPct = 30 + thAgitatorMinMsgs = 200 + thWildcardStdDev = 0.5 + thWildcardMinClassified = 150 + thHypeMachineExclPct = 20 + thHypeMachinePosPct = 60 + + // Economy + thBrokeSpiritedMaxBalance = 100.0 + thDegenerateMinLosses = 10 + + // Games + thSharkWinRate = 55 + thSharkMinGames = 15 + thWordleMinPuzzles = 10 + thArenaChampMinTier = 4 + thArenaChampWinRate = 50 + thArenaCowardMinRuns = 3 + thArenaCowardMaxAvgTier = 2 + thTriviaNerdMinCorrect = 10 + + // Adventure + thAdvMinDays = 10 + thAdvDiverseMinDays = 15 + thAdvDiverseMinTypes = 3 + thAdvDiverseMaxShare = 40 + thGearheadMinMasterwork = 3 + + // Social + thPatronMinRepGiven = 5 + thPatronRatioMultiplier = 2 + + // Display + maxDisplayArchetypes = 6 +) + +// ─── Flavor Text ─────���───────────────────────────────────────────── + +var archetypeFlavors = map[string]string{ + // Communication + "Novelist": "Writes in paragraphs. Has opinions. Probably re-reads their own messages.", + "Minimalist": "Says a lot with very little. You're never sure if they're fine or not.", + "Inquisitor": "Always asking. Never satisfied with the first answer. Probably has follow-ups.", + "Enthusiast": "Genuinely excited about things. All the things. Possibly all at once.", + "Chatterbox": "Has thoughts. Many thoughts. Shares them all. You wouldn't have it any other way.", + "Linkmaster": "The community's unofficial curator. Their tab count is not your business.", + + // Temporal + "Night Owl": "Awake when they probably shouldn't be. Thriving despite all evidence.", + "Early Bird": "Up before anyone else. Has already formed opinions about the day.", + + // Emotional + "Cheerleader": "Lifts the room. Probably the reason someone didn't log off that one time.", + "Philosopher": "Thinks out loud. Asks questions that don't have comfortable answers.", + "Agitator": "Keeps things interesting. You don't always agree but you're never bored.", + "Wildcard": "You never quite know what you're getting. That's the charm. Mostly.", + "Hype Machine": "Arrived and immediately made everything louder. The room is better for it.", + + // Economy + "Whale": "Has money. Spends money. Has more money somehow. The math is unclear.", + "Degenerate": "Knows exactly what they're doing. Does it anyway. Respects it.", + "Broke But Spirited": "Down but not out. The pot fears them anyway.", + + // Games + "Shark": "Wins more than they should. GogoBee has opinions about this.", + "Wordle Devotee": "Shows up every day. Rain or shine. Five letters at a time.", + "Arena Champion": "Walks into the arena and the monsters check their insurance.", + "Arena Coward": "Shows up. Wins a little. Leaves immediately. Smart, actually.", + "Trivia Nerd": "Has retained an unreasonable amount of information.", + + // Adventure + "Dungeon Crawler": "Always in the dungeon. The dungeon knows their name by now.", + "The Miner": "Steady. Patient. Has more ore than anyone needs. Still mining.", + "The Forager": "Out in the field while everyone else is underground. Knows where the good stuff is.", + "The Angler": "Patient beyond reason. Has caught things nobody else has caught.", + "The Merchant": "Buys. Sells. Optimizes. The economy is a puzzle and they're solving it.", + "Resting Face": "Technically participating. The numbers are still going up. Good enough.", + "The Adventurer": "Does a bit of everything. Refuses to specialize. Somehow works.", + "Gearhead": "Has the good stuff. Spent the time getting it. You can tell.", + + // Social + "Patron": "Gives credit generously. Asks for nothing in return. Suspicious, honestly.", + "Reactor": "Expresses everything through emoji. An entire emotional life, no words required.", + + // Fallback + "Regular": "Shows up. Participates. The backbone of any community, honestly.", +} + +// ─��─ Mutual Exclusions ────��──────────────────────────────────────── + +var mutualExclusions = [][2]string{ + {"Novelist", "Minimalist"}, + {"Early Bird", "Night Owl"}, + {"Arena Champion", "Arena Coward"}, + {"Whale", "Broke But Spirited"}, +} + +// ─── Core Types ────────────���─────────────────────────────────────── + +type archetypeResult struct { + Name string + Category string + SignalScore float64 + Flavor string +} + +// ─── Community Percentiles ──────────────────────���────────────────── + +type communityPercentiles struct { + whaleBalanceThreshold float64 // top 10% + repGivenP75 int // top 25% rep given + reactionsGivenP75 int // top 25% reactions given + medianMsgCount int // median total_messages +} + +func computePercentiles(d *sql.DB) communityPercentiles { + var p communityPercentiles + + // Whale: top 10% balance + var userCount int + d.QueryRow(`SELECT COUNT(*) FROM euro_balances WHERE balance > 0`).Scan(&userCount) + offset := userCount / 10 + if offset < 1 { + offset = 1 + } + d.QueryRow(`SELECT balance FROM euro_balances ORDER BY balance DESC LIMIT 1 OFFSET ?`, offset).Scan(&p.whaleBalanceThreshold) + + // Rep given: top 25% + repGiven := queryIntList(d, `SELECT COUNT(*) FROM rep_cooldowns GROUP BY giver ORDER BY COUNT(*) DESC`) + p.repGivenP75 = percentile75(repGiven) + + // Reactions given: top 25% + reactionsGiven := queryIntList(d, `SELECT COUNT(*) FROM reaction_log GROUP BY sender ORDER BY COUNT(*) DESC`) + p.reactionsGivenP75 = percentile75(reactionsGiven) + + // Median message count + msgCounts := queryIntList(d, `SELECT total_messages FROM user_stats ORDER BY total_messages`) + if len(msgCounts) > 0 { + p.medianMsgCount = msgCounts[len(msgCounts)/2] + } + + return p +} + +func queryIntList(d *sql.DB, query string) []int { + rows, err := d.Query(query) + if err != nil { + return nil + } + defer rows.Close() + var result []int + for rows.Next() { + var v int + if rows.Scan(&v) == nil { + result = append(result, v) + } + } + return result +} + +func percentile75(sorted []int) int { + if len(sorted) == 0 { + return 0 + } + idx := len(sorted) / 4 // top 25% = first quarter of desc-sorted list + if idx >= len(sorted) { + idx = len(sorted) - 1 + } + return sorted[idx] +} + +// ─── Per-User Data Loaders ──────────────────────��────────────────── + +type userData struct { + userID string + + // user_stats + totalMsgs int + totalWords int + totalLinks int + totalImages int + totalQuestions int + totalExcl int + totalEmojis int + nightMsgs int + morningMsgs int + + // sentiment_stats + sentPositive int + sentNegative int + sentNeutral int + + // llm_classifications + classifiedCount int + sentVariance float64 + + // economy + balance float64 + gamblingLosses int + recentGaming bool + + // games + bjPlayed, bjWon int + hmPlayed, hmWon int + holdemPlayed int + holdemNetPositive bool + unoSoloPlayed, unoSoloWon int + unoMultiPlayed, unoMultiWon int + wordlePlayed int + triviaCorrect int + + // arena + arenaHighestTier int + arenaRuns int + arenaWins int + arenaAvgCashTier float64 + + // adventure + advDays int + advActivities map[string]int // activity_type -> count + masterworkCount int + + // social + repGiven int + repReceived int + reactionsGiven int +} + +func loadUserData(d *sql.DB, userID string) userData { + u := userData{userID: userID, advActivities: make(map[string]int)} + + // user_stats + d.QueryRow(`SELECT total_messages, total_words, total_links, total_images, + total_questions, total_exclamations, total_emojis, night_messages, morning_messages + FROM user_stats WHERE user_id = ?`, userID).Scan( + &u.totalMsgs, &u.totalWords, &u.totalLinks, &u.totalImages, + &u.totalQuestions, &u.totalExcl, &u.totalEmojis, &u.nightMsgs, &u.morningMsgs) + + // sentiment_stats + d.QueryRow(`SELECT COALESCE(positive,0), COALESCE(negative,0), COALESCE(neutral,0) + FROM sentiment_stats WHERE user_id = ?`, userID).Scan( + &u.sentPositive, &u.sentNegative, &u.sentNeutral) + + // llm_classifications count + variance + d.QueryRow(`SELECT COUNT(*) FROM llm_classifications WHERE user_id = ?`, userID).Scan(&u.classifiedCount) + if u.classifiedCount > 1 { + d.QueryRow(`SELECT AVG(sentiment_score * sentiment_score) - AVG(sentiment_score) * AVG(sentiment_score) + FROM llm_classifications WHERE user_id = ?`, userID).Scan(&u.sentVariance) + if u.sentVariance < 0 { + u.sentVariance = 0 + } + } + + // economy + d.QueryRow(`SELECT COALESCE(balance, 0) FROM euro_balances WHERE user_id = ?`, userID).Scan(&u.balance) + + // Count actual gambling losses (not bets/antes which are always negative) + d.QueryRow(`SELECT COUNT(*) FROM euro_transactions WHERE user_id = ? AND amount < 0 + AND reason IN ('holdem_loss')`, + userID).Scan(&u.gamblingLosses) + // Add blackjack losses (games played minus games won) + var bjLosses int + d.QueryRow(`SELECT MAX(COALESCE(games_played,0) - COALESCE(games_won,0), 0) FROM blackjack_scores WHERE user_id = ?`, + userID).Scan(&bjLosses) + u.gamblingLosses += bjLosses + + var recentCount int + d.QueryRow(`SELECT COUNT(*) FROM euro_transactions WHERE user_id = ? + AND created_at > datetime('now', '-30 days') + AND reason IN ('blackjack_bet','blackjack_win','holdem_loss','holdem_win', + 'uno_wager','uno_win','uno_multi_ante','uno_multi_win')`, + userID).Scan(&recentCount) + u.recentGaming = recentCount > 0 + + // blackjack + d.QueryRow(`SELECT COALESCE(games_played,0), COALESCE(games_won,0) FROM blackjack_scores WHERE user_id = ?`, + userID).Scan(&u.bjPlayed, &u.bjWon) + + // hangman + d.QueryRow(`SELECT COALESCE(games_played,0), COALESCE(games_won,0) FROM hangman_scores WHERE user_id = ?`, + userID).Scan(&u.hmPlayed, &u.hmWon) + + // holdem + var totalWon, totalLost int + d.QueryRow(`SELECT COALESCE(hands_played,0), COALESCE(total_won,0), COALESCE(total_lost,0) FROM holdem_scores WHERE user_id = ?`, + userID).Scan(&u.holdemPlayed, &totalWon, &totalLost) + u.holdemNetPositive = totalWon > totalLost + + // uno solo + d.QueryRow(`SELECT COUNT(*), COALESCE(SUM(CASE WHEN result='player_win' THEN 1 ELSE 0 END), 0) FROM uno_games WHERE player_id = ?`, + userID).Scan(&u.unoSoloPlayed, &u.unoSoloWon) + + // uno multi + d.QueryRow(`SELECT COUNT(*) FROM uno_multi_games WHERE player_ids LIKE ?`, + "%"+userID+"%").Scan(&u.unoMultiPlayed) + d.QueryRow(`SELECT COUNT(*) FROM uno_multi_games WHERE winner_id = ?`, + userID).Scan(&u.unoMultiWon) + + // wordle + d.QueryRow(`SELECT COALESCE(puzzles_played,0) FROM wordle_stats WHERE user_id = ?`, + userID).Scan(&u.wordlePlayed) + + // trivia + d.QueryRow(`SELECT COALESCE(SUM(correct),0) FROM trivia_scores WHERE user_id = ?`, + userID).Scan(&u.triviaCorrect) + + // arena + d.QueryRow(`SELECT COALESCE(highest_tier,0), COALESCE(total_runs,0) FROM arena_stats WHERE user_id = ?`, + userID).Scan(&u.arenaHighestTier, &u.arenaRuns) + + d.QueryRow(`SELECT COUNT(*) FROM arena_runs WHERE user_id = ? AND status = 'cashed_out'`, + userID).Scan(&u.arenaWins) + + d.QueryRow(`SELECT COALESCE(AVG(tier), 0) FROM arena_runs WHERE user_id = ? AND status = 'cashed_out'`, + userID).Scan(&u.arenaAvgCashTier) + + // adventure + d.QueryRow(`SELECT COUNT(DISTINCT DATE(logged_at)) FROM adventure_activity_log WHERE user_id = ?`, + userID).Scan(&u.advDays) + + rows, err := d.Query(`SELECT activity_type, COUNT(*) FROM adventure_activity_log WHERE user_id = ? GROUP BY activity_type`, userID) + if err == nil { + defer rows.Close() + for rows.Next() { + var atype string + var cnt int + if rows.Scan(&atype, &cnt) == nil { + u.advActivities[atype] = cnt + } + } + } + + d.QueryRow(`SELECT COUNT(*) FROM adventure_equipment WHERE user_id = ? AND masterwork = 1`, + userID).Scan(&u.masterworkCount) + + // social + d.QueryRow(`SELECT COUNT(*) FROM rep_cooldowns WHERE giver = ?`, userID).Scan(&u.repGiven) + d.QueryRow(`SELECT COUNT(*) FROM rep_cooldowns WHERE receiver = ?`, userID).Scan(&u.repReceived) + d.QueryRow(`SELECT COUNT(*) FROM reaction_log WHERE sender = ?`, userID).Scan(&u.reactionsGiven) + + return u +} + +// ���── Archetype Evaluators ────────���───────────────────────────────── + +func evaluateArchetypes(u userData, pct communityPercentiles) []archetypeResult { + var results []archetypeResult + + m := max1arch(u.totalMsgs) + + // ── Communication Style ── + + avgWords := u.totalWords / m + if u.totalMsgs >= thNovelistMinMsgs && avgWords >= thNovelistAvgWords { + results = append(results, archetypeResult{ + Name: "Novelist", Category: "Communication", + SignalScore: clampSignal(float64(avgWords-thNovelistAvgWords) / float64(thNovelistAvgWords)), + }) + } + + if u.totalMsgs >= thMinimalistMinMsgs && avgWords <= thMinimalistMaxAvgWords { + results = append(results, archetypeResult{ + Name: "Minimalist", Category: "Communication", + SignalScore: clampSignal(float64(thMinimalistMaxAvgWords-avgWords+1) / float64(thMinimalistMaxAvgWords)), + }) + } + + qPct := u.totalQuestions * 100 / m + if u.totalMsgs >= thInquisitorMinMsgs && qPct >= thInquisitorPct { + results = append(results, archetypeResult{ + Name: "Inquisitor", Category: "Communication", + SignalScore: clampSignal(float64(qPct-thInquisitorPct) / float64(thInquisitorPct)), + }) + } + + ePct := u.totalExcl * 100 / m + if u.totalMsgs >= thEnthusiastMinMsgs && ePct >= thEnthusiastPct { + results = append(results, archetypeResult{ + Name: "Enthusiast", Category: "Communication", + SignalScore: clampSignal(float64(ePct-thEnthusiastPct) / float64(thEnthusiastPct)), + }) + } + + if u.totalMsgs >= thChatterboxMinMsgs && avgWords >= thChatterboxMinAvgWords { + results = append(results, archetypeResult{ + Name: "Chatterbox", Category: "Communication", + SignalScore: clampSignal(float64(u.totalMsgs-thChatterboxMinMsgs) / float64(thChatterboxMinMsgs)), + }) + } + + lPct := u.totalLinks * 100 / m + if u.totalMsgs >= thLinkmasterMinMsgs && lPct >= thLinkmasterPct { + results = append(results, archetypeResult{ + Name: "Linkmaster", Category: "Communication", + SignalScore: clampSignal(float64(lPct-thLinkmasterPct) / float64(thLinkmasterPct)), + }) + } + + // ── Temporal ── + + if u.totalMsgs >= thNightOwlMinMsgs { + nightPct := u.nightMsgs * 100 / m + if nightPct >= thNightOwlPct { + results = append(results, archetypeResult{ + Name: "Night Owl", Category: "Temporal", + SignalScore: clampSignal(float64(nightPct-thNightOwlPct) / float64(100-thNightOwlPct)), + }) + } + } + + if u.totalMsgs >= thEarlyBirdMinMsgs { + morningPct := u.morningMsgs * 100 / m + if morningPct >= thEarlyBirdPct { + results = append(results, archetypeResult{ + Name: "Early Bird", Category: "Temporal", + SignalScore: clampSignal(float64(morningPct-thEarlyBirdPct) / float64(100-thEarlyBirdPct)), + }) + } + } + + // ── Emotional Signature (LLM-gated) ── + + sentTotal := u.sentPositive + u.sentNegative + u.sentNeutral + if u.classifiedCount >= thEmotionalMinClassified && sentTotal > 0 { + posPct := u.sentPositive * 100 / sentTotal + negPct := u.sentNegative * 100 / sentTotal + neutPct := u.sentNeutral * 100 / sentTotal + + // Cheerleader + if posPct >= thCheerleaderPosPct && u.repGiven >= pct.repGivenP75 && u.reactionsGiven >= pct.reactionsGivenP75 { + results = append(results, archetypeResult{ + Name: "Cheerleader", Category: "Emotional", + SignalScore: clampSignal(float64(posPct-thCheerleaderPosPct) / float64(100-thCheerleaderPosPct)), + }) + } + + // Philosopher + if neutPct >= thPhilosopherNeutPct && qPct >= thPhilosopherQPct && avgWords >= thPhilosopherAvgWords { + results = append(results, archetypeResult{ + Name: "Philosopher", Category: "Emotional", + SignalScore: clampSignal(float64(neutPct-thPhilosopherNeutPct) / float64(100-thPhilosopherNeutPct)), + }) + } + + // Agitator + if negPct >= thAgitatorNegPct && u.totalMsgs >= thAgitatorMinMsgs { + results = append(results, archetypeResult{ + Name: "Agitator", Category: "Emotional", + SignalScore: clampSignal(float64(negPct-thAgitatorNegPct) / float64(100-thAgitatorNegPct)), + }) + } + + // Hype Machine + if ePct >= thHypeMachineExclPct && posPct >= thHypeMachinePosPct && u.reactionsGiven >= pct.reactionsGivenP75 { + results = append(results, archetypeResult{ + Name: "Hype Machine", Category: "Emotional", + SignalScore: clampSignal((float64(ePct-thHypeMachineExclPct)/float64(thHypeMachineExclPct) + + float64(posPct-thHypeMachinePosPct)/float64(100-thHypeMachinePosPct)) / 2), + }) + } + } + + // Wildcard (separate min classified threshold) + if u.classifiedCount >= thWildcardMinClassified { + stddev := math.Sqrt(u.sentVariance) + if stddev >= thWildcardStdDev { + results = append(results, archetypeResult{ + Name: "Wildcard", Category: "Emotional", + SignalScore: clampSignal((stddev - thWildcardStdDev) / thWildcardStdDev), + }) + } + } + + // ── Economy ── + + if pct.whaleBalanceThreshold > 0 && u.balance >= pct.whaleBalanceThreshold { + results = append(results, archetypeResult{ + Name: "Whale", Category: "Economy", + SignalScore: clampSignal(u.balance / pct.whaleBalanceThreshold / 2), + }) + } + + if u.gamblingLosses >= thDegenerateMinLosses && u.recentGaming { + results = append(results, archetypeResult{ + Name: "Degenerate", Category: "Economy", + SignalScore: clampSignal(float64(u.gamblingLosses) / float64(thDegenerateMinLosses*3)), + }) + } + + if u.balance < thBrokeSpiritedMaxBalance && u.recentGaming { + results = append(results, archetypeResult{ + Name: "Broke But Spirited", Category: "Economy", + SignalScore: clampSignal((thBrokeSpiritedMaxBalance - u.balance) / thBrokeSpiritedMaxBalance), + }) + } + + // ── Games ── + + totalGames := u.bjPlayed + u.hmPlayed + u.unoSoloPlayed + u.unoMultiPlayed + totalWins := u.bjWon + u.hmWon + u.unoSoloWon + u.unoMultiWon + if u.holdemPlayed > 0 { + totalGames += u.holdemPlayed + if u.holdemNetPositive { + totalWins += u.holdemPlayed / 2 // approximate: net positive counts as ~50% wins + } + } + if totalGames >= thSharkMinGames { + winRate := totalWins * 100 / totalGames + if winRate >= thSharkWinRate { + results = append(results, archetypeResult{ + Name: "Shark", Category: "Games", + SignalScore: clampSignal(float64(winRate-thSharkWinRate) / float64(100-thSharkWinRate)), + }) + } + } + + if u.wordlePlayed >= thWordleMinPuzzles { + results = append(results, archetypeResult{ + Name: "Wordle Devotee", Category: "Games", + SignalScore: clampSignal(float64(u.wordlePlayed-thWordleMinPuzzles) / float64(thWordleMinPuzzles)), + }) + } + + if u.arenaHighestTier >= thArenaChampMinTier && u.arenaRuns > 0 { + arenaWinRate := u.arenaWins * 100 / max1arch(u.arenaRuns) + if arenaWinRate >= thArenaChampWinRate { + results = append(results, archetypeResult{ + Name: "Arena Champion", Category: "Games", + SignalScore: clampSignal(float64(u.arenaHighestTier-thArenaChampMinTier+1) / 2.0), + }) + } + } + + if u.arenaWins >= thArenaCowardMinRuns && u.arenaAvgCashTier > 0 && u.arenaAvgCashTier <= float64(thArenaCowardMaxAvgTier) { + results = append(results, archetypeResult{ + Name: "Arena Coward", Category: "Games", + SignalScore: clampSignal((float64(thArenaCowardMaxAvgTier) - u.arenaAvgCashTier + 1) / float64(thArenaCowardMaxAvgTier)), + }) + } + + if u.triviaCorrect >= thTriviaNerdMinCorrect { + results = append(results, archetypeResult{ + Name: "Trivia Nerd", Category: "Games", + SignalScore: clampSignal(float64(u.triviaCorrect-thTriviaNerdMinCorrect) / float64(thTriviaNerdMinCorrect)), + }) + } + + // ── Adventure ── + + if u.advDays >= thAdvMinDays { + totalAdv := 0 + for _, c := range u.advActivities { + totalAdv += c + } + if totalAdv > 0 { + // Find plurality activity + var topActivity string + var topCount int + for act, cnt := range u.advActivities { + if cnt > topCount { + topActivity = act + topCount = cnt + } + } + topShare := topCount * 100 / totalAdv + + activityArchetypes := map[string]string{ + "dungeon": "Dungeon Crawler", + "mining": "The Miner", + "foraging": "The Forager", + "fishing": "The Angler", + "shop": "The Merchant", + "rest": "Resting Face", + } + + // Check for diverse adventurer first + if len(u.advActivities) >= thAdvDiverseMinTypes && u.advDays >= thAdvDiverseMinDays && topShare <= thAdvDiverseMaxShare { + results = append(results, archetypeResult{ + Name: "The Adventurer", Category: "Adventure", + SignalScore: clampSignal(float64(len(u.advActivities)) / float64(thAdvDiverseMinTypes+2)), + }) + } else if name, ok := activityArchetypes[topActivity]; ok { + results = append(results, archetypeResult{ + Name: name, Category: "Adventure", + SignalScore: clampSignal(float64(topShare) / 100.0), + }) + } + } + } + + if u.masterworkCount >= thGearheadMinMasterwork { + results = append(results, archetypeResult{ + Name: "Gearhead", Category: "Adventure", + SignalScore: clampSignal(float64(u.masterworkCount) / float64(thGearheadMinMasterwork*2)), + }) + } + + // ── Social ── + + if u.repGiven >= thPatronMinRepGiven { + repReceived := max1arch(u.repReceived) + if u.repGiven >= repReceived*thPatronRatioMultiplier { + results = append(results, archetypeResult{ + Name: "Patron", Category: "Social", + SignalScore: clampSignal(float64(u.repGiven) / float64(thPatronMinRepGiven*3)), + }) + } + } + + if u.reactionsGiven >= pct.reactionsGivenP75 && pct.reactionsGivenP75 > 0 && u.totalMsgs <= pct.medianMsgCount { + results = append(results, archetypeResult{ + Name: "Reactor", Category: "Social", + SignalScore: clampSignal(float64(u.reactionsGiven) / float64(pct.reactionsGivenP75*2)), + }) + } + + // Fill in flavor text + for i := range results { + results[i].Flavor = archetypeFlavors[results[i].Name] + } + + // Apply mutual exclusions + results = applyExclusions(results) + + // Sort by signal score desc + sort.Slice(results, func(i, j int) bool { + return results[i].SignalScore > results[j].SignalScore + }) + + return results +} + +func applyExclusions(results []archetypeResult) []archetypeResult { + nameIdx := make(map[string]int) + for i, r := range results { + nameIdx[r.Name] = i + } + + remove := make(map[int]bool) + for _, pair := range mutualExclusions { + idxA, hasA := nameIdx[pair[0]] + idxB, hasB := nameIdx[pair[1]] + if hasA && hasB { + if results[idxA].SignalScore >= results[idxB].SignalScore { + remove[idxB] = true + } else { + remove[idxA] = true + } + } + } + + if len(remove) == 0 { + return results + } + + var filtered []archetypeResult + for i, r := range results { + if !remove[i] { + filtered = append(filtered, r) + } + } + return filtered +} + +// ─── Cache Read/Write ────────��───────────────────────────────────── + +// GetUserArchetypes returns cached archetypes for a user, sorted by signal_score desc. +func GetUserArchetypes(userID string) []archetypeResult { + d := db.Get() + rows, err := d.Query(`SELECT archetype, category, signal_score, flavor + FROM user_archetypes WHERE user_id = ? ORDER BY signal_score DESC`, userID) + if err != nil { + return nil + } + defer rows.Close() + + var results []archetypeResult + for rows.Next() { + var r archetypeResult + if rows.Scan(&r.Name, &r.Category, &r.SignalScore, &r.Flavor) == nil { + results = append(results, r) + } + } + return results +} + +// GetUserArchetypesLimited returns up to maxDisplayArchetypes for profile cards. +func GetUserArchetypesLimited(userID string) []archetypeResult { + results := GetUserArchetypes(userID) + if len(results) > maxDisplayArchetypes { + results = results[:maxDisplayArchetypes] + } + if len(results) == 0 { + return []archetypeResult{{ + Name: "Regular", Category: "Fallback", + Flavor: archetypeFlavors["Regular"], + }} + } + return results +} + +// ─── Refresh Engine ─────────────────────��────────────────────────── + +// RefreshAllArchetypes recalculates archetypes for all users and writes to user_archetypes. +func RefreshAllArchetypes() { + d := db.Get() + + // Get all user IDs + rows, err := d.Query(`SELECT user_id FROM user_stats WHERE total_messages > 0`) + if err != nil { + slog.Error("archetype: failed to list users", "err", err) + return + } + var userIDs []string + for rows.Next() { + var uid string + if rows.Scan(&uid) == nil { + userIDs = append(userIDs, uid) + } + } + rows.Close() + + if len(userIDs) == 0 { + return + } + + // Compute community percentiles once + pct := computePercentiles(d) + + // Evaluate each user + type userResult struct { + userID string + archs []archetypeResult + } + var allResults []userResult + + for _, uid := range userIDs { + u := loadUserData(d, uid) + archs := evaluateArchetypes(u, pct) + if len(archs) > 0 { + allResults = append(allResults, userResult{uid, archs}) + } + } + + // Write to cache in a transaction + tx, err := d.Begin() + if err != nil { + slog.Error("archetype: failed to begin tx", "err", err) + return + } + + _, err = tx.Exec(`DELETE FROM user_archetypes`) + if err != nil { + tx.Rollback() + slog.Error("archetype: failed to clear cache", "err", err) + return + } + + stmt, err := tx.Prepare(`INSERT INTO user_archetypes (user_id, archetype, category, signal_score, flavor) + VALUES (?, ?, ?, ?, ?)`) + if err != nil { + tx.Rollback() + slog.Error("archetype: failed to prepare insert", "err", err) + return + } + defer stmt.Close() + + totalAssigned := 0 + for _, ur := range allResults { + for _, a := range ur.archs { + if _, err := stmt.Exec(ur.userID, a.Name, a.Category, a.SignalScore, a.Flavor); err != nil { + slog.Error("archetype: insert failed", "user", ur.userID, "arch", a.Name, "err", err) + } + totalAssigned++ + } + } + + if err := tx.Commit(); err != nil { + slog.Error("archetype: commit failed", "err", err) + return + } + + slog.Info("archetype: refresh complete", "users", len(allResults), "archetypes_assigned", totalAssigned) +} + +// ─── Helpers ─────────���───────────────────────────────────────────── + +func max1arch(n int) int { + if n < 1 { + return 1 + } + return n +} + +func clampSignal(v float64) float64 { + if v < 0 { + return 0 + } + if v > 1 { + return 1 + } + return v +} + +// FormatArchetypeNames returns a " · " separated list of archetype names. +func FormatArchetypeNames(archs []archetypeResult) string { + if len(archs) == 0 { + return "Regular" + } + names := make([]string, len(archs)) + for i, a := range archs { + names[i] = a.Name + } + return strings.Join(names, " · ") +} + +// FormatArchetypesFull returns a full display with names and flavor text. +func FormatArchetypesFull(archs []archetypeResult) string { + if len(archs) == 0 { + return fmt.Sprintf("**Regular**\n_%s_", archetypeFlavors["Regular"]) + } + var sb strings.Builder + for i, a := range archs { + if i > 0 { + sb.WriteString("\n") + } + sb.WriteString(fmt.Sprintf("**%s**\n_%s_", a.Name, a.Flavor)) + } + return sb.String() +} diff --git a/internal/plugin/milkcarton.go b/internal/plugin/milkcarton.go index 3782a83..f23d543 100644 --- a/internal/plugin/milkcarton.go +++ b/internal/plugin/milkcarton.go @@ -511,70 +511,26 @@ func (p *MilkCartonPlugin) renderCarton( return buf.Bytes(), nil } -// deriveCharacteristics generates flavor text from user stats. +// deriveCharacteristics generates flavor text from cached archetypes. func (p *MilkCartonPlugin) deriveCharacteristics(userID string) []string { - d := db.Get() + archetypes := GetUserArchetypesLimited(userID) + + // Pick up to 3 archetype flavor texts (highest signal score first) var chars []string - - var totalMsgs, totalWords, totalEmojis, totalQuestions, totalLinks int - err := d.QueryRow( - `SELECT COALESCE(total_messages,0), COALESCE(total_words,0), COALESCE(total_emojis,0), - COALESCE(total_questions,0), COALESCE(total_links,0) - FROM user_stats WHERE user_id = ?`, userID, - ).Scan(&totalMsgs, &totalWords, &totalEmojis, &totalQuestions, &totalLinks) - if err != nil { - return []string{"Whereabouts unknown", "Considered a person of interest"} - } - - avgWords := 0 - if totalMsgs > 0 { - avgWords = totalWords / totalMsgs - } - - // Sentiment data - var positive, negative, sarcastic, humorous int - _ = d.QueryRow( - `SELECT COALESCE(positive,0), COALESCE(negative,0), COALESCE(sarcastic,0), COALESCE(humorous,0) - FROM sentiment_stats WHERE user_id = ?`, userID, - ).Scan(&positive, &negative, &sarcastic, &humorous) - - // Profanity - var profanityCount int - _ = d.QueryRow(`SELECT COALESCE(count,0) FROM potty_mouth WHERE user_id = ?`, userID).Scan(&profanityCount) - - // Build characteristics based on stat thresholds - type trait struct { - condition bool - text string - } - - traits := []trait{ - {totalMsgs > 1000, "Known to be extremely chatty"}, - {totalMsgs > 500 && totalMsgs <= 1000, "Considered a regular contributor"}, - {totalMsgs < 50 && totalMsgs > 0, "Frequently lurks, rarely commits"}, - {avgWords > 12, "Known to post novellas"}, - {avgWords > 0 && avgWords <= 3, "A person of few words"}, - {totalQuestions > totalMsgs/4 && totalQuestions > 20, "Asks questions compulsively"}, - {totalEmojis > totalMsgs/3 && totalEmojis > 30, "Communicates primarily in emoji"}, - {totalLinks > totalMsgs/8 && totalLinks > 20, "Prone to sharing unsolicited links"}, - {profanityCount > 100, "Has a mouth that could strip paint"}, - {profanityCount > 30, "Known to use colorful language"}, - {sarcastic > positive && sarcastic > 10, "Armed with weapons-grade sarcasm"}, - {humorous > 20, "Considered dangerously funny"}, - {negative > positive && negative > 15, "Last seen expressing strong opinions"}, - {positive > 50, "Generally considered a ray of sunshine"}, - } - - for _, t := range traits { - if t.condition { - chars = append(chars, t.text) + for _, a := range archetypes { + if a.Flavor != "" { + chars = append(chars, a.Flavor) } if len(chars) >= 3 { break } } - // Fallbacks + if len(chars) > 0 { + return chars + } + + // Fallback when no archetypes cached yet fallbacks := []string{ "Considered armed with strong opinions", "May be found near a keyboard", diff --git a/internal/plugin/presence.go b/internal/plugin/presence.go index 7b87779..f1a1a77 100644 --- a/internal/plugin/presence.go +++ b/internal/plugin/presence.go @@ -235,5 +235,11 @@ func (p *PresencePlugin) handleWhois(ctx MessageContext) error { sb.WriteString(" Status: 🟢 Online\n") } + // Archetypes + archetypes := GetUserArchetypesLimited(string(targetUser)) + if len(archetypes) > 0 && archetypes[0].Name != "Regular" { + sb.WriteString(fmt.Sprintf("\n🎭 %s\n", FormatArchetypeNames(archetypes))) + } + return p.SendMessage(ctx.RoomID, sb.String()) } diff --git a/internal/plugin/stats.go b/internal/plugin/stats.go index 243d7aa..daf7b76 100644 --- a/internal/plugin/stats.go +++ b/internal/plugin/stats.go @@ -283,41 +283,16 @@ func (p *StatsPlugin) handleRankings(ctx MessageContext) error { } func (p *StatsPlugin) handlePersonality(ctx MessageContext) error { - d := db.Get() - var totalMsg, totalWords, totalChars, totalLinks, totalImages int - var totalQuestions, totalExclamations, totalEmojis int + archetypes := GetUserArchetypesLimited(string(ctx.Sender)) - err := d.QueryRow( - `SELECT total_messages, total_words, total_chars, total_links, total_images, - total_questions, total_exclamations, total_emojis - FROM user_stats WHERE user_id = ?`, string(ctx.Sender), - ).Scan(&totalMsg, &totalWords, &totalChars, &totalLinks, &totalImages, - &totalQuestions, &totalExclamations, &totalEmojis) - - if err == sql.ErrNoRows { + var totalMsg int + db.Get().QueryRow(`SELECT total_messages FROM user_stats WHERE user_id = ?`, string(ctx.Sender)).Scan(&totalMsg) + if totalMsg == 0 { return p.SendReply(ctx.RoomID, ctx.EventID, "Not enough data to determine your personality yet. Keep chatting!") } - if err != nil { - slog.Error("stats: personality query", "err", err) - return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to analyze personality.") - } - msgStats := util.MessageStats{ - Words: totalWords, - Chars: totalChars, - Links: totalLinks, - Images: totalImages, - Questions: totalQuestions, - Exclamations: totalExclamations, - Emojis: totalEmojis, - } - - archetype := util.DeriveArchetype(msgStats, totalMsg) - - msg := fmt.Sprintf( - "🧠 %s, your chat personality is: **%s**\n_%s_\n\nBased on %s messages analyzed.", - string(ctx.Sender), archetype.Name, archetype.Description, formatNumber(totalMsg), - ) + msg := fmt.Sprintf("🧠 %s, your chat personality:\n\n%s\n\nBased on %s messages analyzed.", + string(ctx.Sender), FormatArchetypesFull(archetypes), formatNumber(totalMsg)) return p.SendReply(ctx.RoomID, ctx.EventID, msg) } @@ -361,10 +336,9 @@ func (p *StatsPlugin) handleSuperStats(ctx MessageContext) error { formatNumber(totalMsg), formatNumber(totalWords), formatNumber(totalLinks), formatNumber(totalEmojis), formatNumber(totalQuestions))) - // Personality - msgStats := util.MessageStats{Words: totalWords, Links: totalLinks, Emojis: totalEmojis, Questions: totalQuestions} - archetype := util.DeriveArchetype(msgStats, totalMsg) - sb.WriteString(fmt.Sprintf(" Personality: **%s**\n", archetype.Name)) + // Personality (from cached archetypes) + archetypes := GetUserArchetypesLimited(uid) + sb.WriteString(fmt.Sprintf(" Personality: **%s**\n", FormatArchetypeNames(archetypes))) } // ── Sentiment ── diff --git a/internal/util/parser.go b/internal/util/parser.go index 579005a..ec9c57d 100644 --- a/internal/util/parser.go +++ b/internal/util/parser.go @@ -90,43 +90,6 @@ func ProgressBar(current, max, width int) string { return "[" + strings.Repeat("#", filled) + strings.Repeat("-", empty) + "] " + strconv.Itoa(pct) + "%" } -// Archetype definitions matching the TS version. -type Archetype struct { - Name string - Description string -} - -var archetypes = []struct { - name string - desc string - check func(s MessageStats, totalMessages int) bool -}{ - {"Chatterbox", "You talk a LOT", func(s MessageStats, t int) bool { return t > 500 && s.Words > 0 }}, - {"Novelist", "Long-form writer", func(s MessageStats, t int) bool { return s.Words > 0 && s.Chars/max1(s.Words) > 8 }}, - {"Inquisitor", "Always asking questions", func(s MessageStats, t int) bool { return s.Questions > t/5 && s.Questions > 10 }}, - {"Linkmaster", "Shares lots of links", func(s MessageStats, t int) bool { return s.Links > t/10 && s.Links > 5 }}, - {"Shutterbug", "Posts lots of images", func(s MessageStats, t int) bool { return s.Images > t/10 && s.Images > 5 }}, - {"Enthusiast", "Lots of exclamation marks!", func(s MessageStats, t int) bool { return s.Exclamations > t/4 && s.Exclamations > 10 }}, - {"Regular", "A steady community member", func(_ MessageStats, _ int) bool { return true }}, -} - -// DeriveArchetype picks the best-fitting archetype based on aggregate stats. -func DeriveArchetype(stats MessageStats, totalMessages int) Archetype { - for _, a := range archetypes { - if a.check(stats, totalMessages) { - return Archetype{Name: a.name, Description: a.desc} - } - } - return Archetype{Name: "Regular", Description: "A steady community member"} -} - -func max1(n int) int { - if n < 1 { - return 1 - } - return n -} - // IsCommand checks if body starts with prefix+command (case-insensitive). func IsCommand(body, prefix, command string) bool { cmd := prefix + command diff --git a/main.go b/main.go index a057a27..026540f 100644 --- a/main.go +++ b/main.go @@ -309,6 +309,9 @@ func main() { setupScheduledJobs(scheduler, client, wotdPlugin, holidaysPlugin, gamingPlugin, birthdayPlugin, animePlugin, moviesPlugin, concertsPlugin, esteemedPlugin, forexPlugin, minifluxPlugin, marketPlugin) scheduler.Start() + // ---- Initial archetype calculation ---- + go plugin.RefreshAllArchetypes() + // ---- Start syncing ---- slog.Info("GogoBee starting sync...") @@ -461,6 +464,12 @@ func setupScheduledJobs( plugin.MarkovPurgeExpired() }) + // Archetype refresh at 04:30 daily + c.AddFunc("30 4 * * *", func() { + slog.Info("scheduler: refreshing archetypes") + plugin.RefreshAllArchetypes() + }) + // Miniflux RSS polling if miniflux != nil { interval := fmt.Sprintf("@every %dm", miniflux.PollInterval())