Files
gogobee/internal/plugin/archetype.go
prosolis 2c6f4e48c9 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>
2026-03-30 17:11:45 -07:00

873 lines
28 KiB
Go
Raw Blame History

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()
}