mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
Add multi-tag personality archetype system
Replace single first-match archetype with comprehensive multi-tag system spanning 25 archetypes across 8 categories (Communication, Temporal, Emotional, Economy, Games, Adventure, Social). Archetypes are computed nightly via cron job querying 15+ tables and cached in user_archetypes. Thresholds calibrated against real community data. Integrates with !personality, !superstatsexplusalpha, !whois, and milk carton flavor text. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1081,6 +1081,18 @@ CREATE TABLE IF NOT EXISTS market_daily_summary (
|
|||||||
generated_at INTEGER
|
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.
|
// SeedSchedulerDefaults inserts default scheduler jobs if they don't exist.
|
||||||
|
|||||||
872
internal/plugin/archetype.go
Normal file
872
internal/plugin/archetype.go
Normal file
@@ -0,0 +1,872 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"math"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gogobee/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ─── Threshold Constants ────<E29480><E29480>──────────────────────────────────────
|
||||||
|
|
||||||
|
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 ─────<E29480><E29480><EFBFBD>─────────────────────────────────────────────
|
||||||
|
|
||||||
|
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.",
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─<><E29480>─ Mutual Exclusions ────<E29480><E29480>────────────────────────────────────────
|
||||||
|
|
||||||
|
var mutualExclusions = [][2]string{
|
||||||
|
{"Novelist", "Minimalist"},
|
||||||
|
{"Early Bird", "Night Owl"},
|
||||||
|
{"Arena Champion", "Arena Coward"},
|
||||||
|
{"Whale", "Broke But Spirited"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Core Types ────────────<E29480><E29480><EFBFBD>───────────────────────────────────────
|
||||||
|
|
||||||
|
type archetypeResult struct {
|
||||||
|
Name string
|
||||||
|
Category string
|
||||||
|
SignalScore float64
|
||||||
|
Flavor string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Community Percentiles ──────────────────────<E29480><E29480><EFBFBD>──────────────────
|
||||||
|
|
||||||
|
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 ──────────────────────<E29480><E29480>──────────────────
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// <20><><EFBFBD>── Archetype Evaluators ────────<E29480><E29480><EFBFBD>─────────────────────────────────
|
||||||
|
|
||||||
|
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 ────────<E29480><E29480>─────────────────────────────────────
|
||||||
|
|
||||||
|
// 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 ─────────────────────<E29480><E29480>──────────────────────────
|
||||||
|
|
||||||
|
// 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 ─────────<E29480><E29480><EFBFBD>─────────────────────────────────────────────
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
@@ -511,70 +511,26 @@ func (p *MilkCartonPlugin) renderCarton(
|
|||||||
return buf.Bytes(), nil
|
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 {
|
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 chars []string
|
||||||
|
for _, a := range archetypes {
|
||||||
var totalMsgs, totalWords, totalEmojis, totalQuestions, totalLinks int
|
if a.Flavor != "" {
|
||||||
err := d.QueryRow(
|
chars = append(chars, a.Flavor)
|
||||||
`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)
|
|
||||||
}
|
}
|
||||||
if len(chars) >= 3 {
|
if len(chars) >= 3 {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallbacks
|
if len(chars) > 0 {
|
||||||
|
return chars
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback when no archetypes cached yet
|
||||||
fallbacks := []string{
|
fallbacks := []string{
|
||||||
"Considered armed with strong opinions",
|
"Considered armed with strong opinions",
|
||||||
"May be found near a keyboard",
|
"May be found near a keyboard",
|
||||||
|
|||||||
@@ -235,5 +235,11 @@ func (p *PresencePlugin) handleWhois(ctx MessageContext) error {
|
|||||||
sb.WriteString(" Status: 🟢 Online\n")
|
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())
|
return p.SendMessage(ctx.RoomID, sb.String())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -283,41 +283,16 @@ func (p *StatsPlugin) handleRankings(ctx MessageContext) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *StatsPlugin) handlePersonality(ctx MessageContext) error {
|
func (p *StatsPlugin) handlePersonality(ctx MessageContext) error {
|
||||||
d := db.Get()
|
archetypes := GetUserArchetypesLimited(string(ctx.Sender))
|
||||||
var totalMsg, totalWords, totalChars, totalLinks, totalImages int
|
|
||||||
var totalQuestions, totalExclamations, totalEmojis int
|
|
||||||
|
|
||||||
err := d.QueryRow(
|
var totalMsg int
|
||||||
`SELECT total_messages, total_words, total_chars, total_links, total_images,
|
db.Get().QueryRow(`SELECT total_messages FROM user_stats WHERE user_id = ?`, string(ctx.Sender)).Scan(&totalMsg)
|
||||||
total_questions, total_exclamations, total_emojis
|
if totalMsg == 0 {
|
||||||
FROM user_stats WHERE user_id = ?`, string(ctx.Sender),
|
|
||||||
).Scan(&totalMsg, &totalWords, &totalChars, &totalLinks, &totalImages,
|
|
||||||
&totalQuestions, &totalExclamations, &totalEmojis)
|
|
||||||
|
|
||||||
if err == sql.ErrNoRows {
|
|
||||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Not enough data to determine your personality yet. Keep chatting!")
|
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{
|
msg := fmt.Sprintf("🧠 %s, your chat personality:\n\n%s\n\nBased on %s messages analyzed.",
|
||||||
Words: totalWords,
|
string(ctx.Sender), FormatArchetypesFull(archetypes), formatNumber(totalMsg))
|
||||||
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),
|
|
||||||
)
|
|
||||||
return p.SendReply(ctx.RoomID, ctx.EventID, msg)
|
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(totalMsg), formatNumber(totalWords), formatNumber(totalLinks),
|
||||||
formatNumber(totalEmojis), formatNumber(totalQuestions)))
|
formatNumber(totalEmojis), formatNumber(totalQuestions)))
|
||||||
|
|
||||||
// Personality
|
// Personality (from cached archetypes)
|
||||||
msgStats := util.MessageStats{Words: totalWords, Links: totalLinks, Emojis: totalEmojis, Questions: totalQuestions}
|
archetypes := GetUserArchetypesLimited(uid)
|
||||||
archetype := util.DeriveArchetype(msgStats, totalMsg)
|
sb.WriteString(fmt.Sprintf(" Personality: **%s**\n", FormatArchetypeNames(archetypes)))
|
||||||
sb.WriteString(fmt.Sprintf(" Personality: **%s**\n", archetype.Name))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Sentiment ──
|
// ── Sentiment ──
|
||||||
|
|||||||
@@ -90,43 +90,6 @@ func ProgressBar(current, max, width int) string {
|
|||||||
return "[" + strings.Repeat("#", filled) + strings.Repeat("-", empty) + "] " + strconv.Itoa(pct) + "%"
|
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).
|
// IsCommand checks if body starts with prefix+command (case-insensitive).
|
||||||
func IsCommand(body, prefix, command string) bool {
|
func IsCommand(body, prefix, command string) bool {
|
||||||
cmd := prefix + command
|
cmd := prefix + command
|
||||||
|
|||||||
9
main.go
9
main.go
@@ -309,6 +309,9 @@ func main() {
|
|||||||
setupScheduledJobs(scheduler, client, wotdPlugin, holidaysPlugin, gamingPlugin, birthdayPlugin, animePlugin, moviesPlugin, concertsPlugin, esteemedPlugin, forexPlugin, minifluxPlugin, marketPlugin)
|
setupScheduledJobs(scheduler, client, wotdPlugin, holidaysPlugin, gamingPlugin, birthdayPlugin, animePlugin, moviesPlugin, concertsPlugin, esteemedPlugin, forexPlugin, minifluxPlugin, marketPlugin)
|
||||||
scheduler.Start()
|
scheduler.Start()
|
||||||
|
|
||||||
|
// ---- Initial archetype calculation ----
|
||||||
|
go plugin.RefreshAllArchetypes()
|
||||||
|
|
||||||
// ---- Start syncing ----
|
// ---- Start syncing ----
|
||||||
slog.Info("GogoBee starting sync...")
|
slog.Info("GogoBee starting sync...")
|
||||||
|
|
||||||
@@ -461,6 +464,12 @@ func setupScheduledJobs(
|
|||||||
plugin.MarkovPurgeExpired()
|
plugin.MarkovPurgeExpired()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Archetype refresh at 04:30 daily
|
||||||
|
c.AddFunc("30 4 * * *", func() {
|
||||||
|
slog.Info("scheduler: refreshing archetypes")
|
||||||
|
plugin.RefreshAllArchetypes()
|
||||||
|
})
|
||||||
|
|
||||||
// Miniflux RSS polling
|
// Miniflux RSS polling
|
||||||
if miniflux != nil {
|
if miniflux != nil {
|
||||||
interval := fmt.Sprintf("@every %dm", miniflux.PollInterval())
|
interval := fmt.Sprintf("@every %dm", miniflux.PollInterval())
|
||||||
|
|||||||
Reference in New Issue
Block a user