2026-05-03 09:53:40 +02:00

499 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Gamification Engine
*
* Computes pet/buddy state, achievements, streaks, calendar heatmap and
* forecasted savings from the live request data. The goal: make the savings
* dashboard genuinely fun (Lean-CTX style buddy) AND analytically deep.
*
* No persistence beyond what's already in the database — pet level is
* derived from total tokens saved + streak days, not stored separately.
* That keeps the system stateless and reproducible.
*/
import type { Pool } from 'pg';
import { logger } from '../observability/logger.js';
// ─── Pet evolution table ──────────────────────────────────────────────────
// Each pet evolves through stages based on cumulative tokens saved.
// Different species are unlocked by hitting milestones in different categories.
export interface PetSpecies {
id: string;
name: string;
rarity: 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary';
unlockCondition: string;
asciiArt: string[];
/** Stage-based evolution. Index 0 = baby, last = final form. */
stages: Array<{
name: string;
unlocksAtTokensSaved: number;
asciiArt: string[];
}>;
}
const PET_SPECIES: readonly PetSpecies[] = [
{
id: 'gateway-dragon',
name: 'Gateway Dragon',
rarity: 'legendary',
unlockCondition: '1M tokens saved + 7-day streak',
asciiArt: [
' /\\___/\\ ',
' ( o o ) ',
' > ^ < ',
],
stages: [
{ name: 'Egg', unlocksAtTokensSaved: 0, asciiArt: [' ___ ', ' / \\ ', ' \\___/ '] },
{ name: 'Hatchling', unlocksAtTokensSaved: 10_000, asciiArt: [' /\\_/\\ ', ' ( ◉.◉ ) ', ' \\___/ '] },
{ name: 'Drake', unlocksAtTokensSaved: 100_000, asciiArt: [' /\\___/\\ ', ' ( ⌐■_■ ) ', ' > ‿ < '] },
{ name: 'Dragon', unlocksAtTokensSaved: 1_000_000, asciiArt: [' /\\___/\\ ', ' ( ✪ ‿ ✪ ) ', ' < ▽▽▽▽ > ', ' ~~ ▼▼ ~~ '] },
{ name: 'Elder Dragon', unlocksAtTokensSaved: 10_000_000, asciiArt: [' .─────────. ', '/ ★ ★ ★ \\ ', '| /\\___/\\ |', '| ( ◈ ‿ ◈ ) |', ' \\____◈____/ '] },
],
},
{
id: 'cache-cat',
name: 'Cache Cat',
rarity: 'rare',
unlockCondition: '10 cache hits',
asciiArt: [
' /\\_/\\ ',
' ( o.o ) ',
' > ^ < ',
],
stages: [
{ name: 'Kitten', unlocksAtTokensSaved: 0, asciiArt: [' /\\_/\\ ', ' ( o.o )', ' > ^ < '] },
{ name: 'Cat', unlocksAtTokensSaved: 5_000, asciiArt: [' /\\_/\\ ', '( ⌐■_■ )', ' (\")_(\") '] },
{ name: 'Wise Cat', unlocksAtTokensSaved: 50_000, asciiArt: [' |、 ', ' (˚ˎ。7 ', ' |、˜〵 ', ' じしˍ,)'] },
],
},
{
id: 'token-fox',
name: 'Token Fox',
rarity: 'uncommon',
unlockCondition: '1K tokens saved',
asciiArt: [
' /\\---/\\ ',
' ( ◕ ◕ )',
' \\__~__/ ',
],
stages: [
{ name: 'Pup', unlocksAtTokensSaved: 0, asciiArt: [' /\\---/\\ ', ' ( ◕ ◕ )', ' \\__~__/ '] },
{ name: 'Fox', unlocksAtTokensSaved: 10_000, asciiArt: [' /\\---/\\ ', '/ ◕ ◕ \\', '\\___◡___/ '] },
],
},
];
const RARITY_ORDER: Record<PetSpecies['rarity'], number> = {
common: 0, uncommon: 1, rare: 2, epic: 3, legendary: 4,
};
// ─── Achievement catalog ──────────────────────────────────────────────────
export interface Achievement {
id: string;
title: string;
description: string;
icon: string;
/** Category tag for UI grouping. */
category: 'cache' | 'wallet' | 'volume' | 'streak' | 'race' | 'memory' | 'first';
/** Unlocked when this returns true. */
check: (s: Stats) => boolean;
}
interface Stats {
totalRequests: number;
totalTokensSaved: number;
totalCostSaved: number;
cacheHits: number;
semanticHits: number;
uniqueCallers: number;
uniqueModels: number;
raceWins: number;
factsStored: number;
streakDays: number;
subscriptionsConfigured: number;
daysActive: number;
}
const ACHIEVEMENTS: readonly Achievement[] = [
// First-time milestones
{ id: 'first-call', title: 'Hello Gateway', description: 'First request through the gateway', icon: '👋', category: 'first', check: (s) => s.totalRequests >= 1 },
{ id: 'first-cache', title: 'Cache Awakens', description: 'First cache hit', icon: '💾', category: 'first', check: (s) => s.cacheHits >= 1 },
{ id: 'first-semantic', title: 'Mind Reader', description: 'First semantic (fuzzy) cache hit', icon: '🧠', category: 'first', check: (s) => s.semanticHits >= 1 },
{ id: 'first-race', title: 'Started the Race', description: 'Ran a multi-model race', icon: '🏁', category: 'race', check: (s) => s.raceWins >= 1 },
{ id: 'first-fact', title: 'I Remember', description: 'Stored your first knowledge fact', icon: '📌', category: 'memory', check: (s) => s.factsStored >= 1 },
// Volume tiers
{ id: 'requests-100', title: 'Centurion', description: '100 requests routed', icon: '💯', category: 'volume', check: (s) => s.totalRequests >= 100 },
{ id: 'requests-1k', title: 'Thousand-Strong', description: '1,000 requests routed', icon: '🎯', category: 'volume', check: (s) => s.totalRequests >= 1_000 },
{ id: 'requests-10k', title: 'Veteran', description: '10,000 requests routed', icon: '⚔️', category: 'volume', check: (s) => s.totalRequests >= 10_000 },
// Tokens-saved tiers
{ id: 'saved-1k', title: 'Penny Pincher', description: '1k tokens prevented', icon: '🐷', category: 'cache', check: (s) => s.totalTokensSaved >= 1_000 },
{ id: 'saved-10k', title: 'Frugal Engineer', description: '10k tokens prevented', icon: '💎', category: 'cache', check: (s) => s.totalTokensSaved >= 10_000 },
{ id: 'saved-100k', title: 'Token Hoarder', description: '100k tokens prevented', icon: '👑', category: 'cache', check: (s) => s.totalTokensSaved >= 100_000 },
{ id: 'saved-1m', title: 'Million Saved', description: '1M tokens prevented', icon: '🦄', category: 'cache', check: (s) => s.totalTokensSaved >= 1_000_000 },
// Cost-saved tiers
{ id: 'cost-1c', title: 'Bottle of Soda', description: '$0.01 of API cost saved', icon: '🥤', category: 'cache', check: (s) => s.totalCostSaved >= 0.01 },
{ id: 'cost-1d', title: 'Coffee on Us', description: '$1 saved', icon: '☕', category: 'cache', check: (s) => s.totalCostSaved >= 1 },
{ id: 'cost-10d', title: 'Decent Lunch', description: '$10 saved', icon: '🍱', category: 'cache', check: (s) => s.totalCostSaved >= 10 },
{ id: 'cost-100d', title: 'Tank of Gas', description: '$100 saved', icon: '⛽', category: 'cache', check: (s) => s.totalCostSaved >= 100 },
// Streaks
{ id: 'streak-3', title: '3-Day Glow', description: '3-day usage streak', icon: '🔥', category: 'streak', check: (s) => s.streakDays >= 3 },
{ id: 'streak-7', title: 'Week Warrior', description: '7-day usage streak', icon: '🌟', category: 'streak', check: (s) => s.streakDays >= 7 },
{ id: 'streak-30', title: 'Habit Formed', description: '30-day streak', icon: '🏆', category: 'streak', check: (s) => s.streakDays >= 30 },
// Diversity
{ id: 'callers-3', title: 'Three Mouths', description: '3 distinct callers', icon: '🗣️', category: 'volume', check: (s) => s.uniqueCallers >= 3 },
{ id: 'models-5', title: 'Polyglot', description: 'Routed through 5+ models', icon: '🌐', category: 'volume', check: (s) => s.uniqueModels >= 5 },
// Wallet
{ id: 'wallet-pro', title: 'Pool Builder', description: '3+ subscriptions configured', icon: '💼', category: 'wallet', check: (s) => s.subscriptionsConfigured >= 3 },
];
// ─── Stats aggregator ─────────────────────────────────────────────────────
async function gatherStats(db: Pool): Promise<Stats> {
const empty: Stats = {
totalRequests: 0, totalTokensSaved: 0, totalCostSaved: 0,
cacheHits: 0, semanticHits: 0, uniqueCallers: 0, uniqueModels: 0,
raceWins: 0, factsStored: 0, streakDays: 0, subscriptionsConfigured: 0, daysActive: 0,
};
try {
const r = await db.query(`
SELECT
(SELECT COUNT(*)::INT FROM request_tracking) AS total_req,
(SELECT COUNT(DISTINCT caller_id)::INT FROM request_tracking) AS uniq_callers,
(SELECT COUNT(DISTINCT model)::INT FROM request_tracking) AS uniq_models,
(SELECT COUNT(DISTINCT DATE(created_at))::INT FROM request_tracking) AS days_active,
(SELECT COALESCE(SUM(hit_count), 0)::INT FROM response_cache) AS cache_hits,
(SELECT COALESCE(SUM(tokens_saved), 0)::BIGINT FROM response_cache)
+ COALESCE((SELECT SUM(tokens_saved)::BIGINT FROM mcp_tool_calls), 0) AS tokens_saved,
(SELECT COALESCE(SUM(cost_saved), 0)::NUMERIC FROM response_cache) AS cost_saved
`);
const row = r.rows[0] ?? {};
empty.totalRequests = parseInt(row.total_req ?? '0', 10);
empty.uniqueCallers = parseInt(row.uniq_callers ?? '0', 10);
empty.uniqueModels = parseInt(row.uniq_models ?? '0', 10);
empty.daysActive = parseInt(row.days_active ?? '0', 10);
empty.cacheHits = parseInt(row.cache_hits ?? '0', 10);
empty.totalTokensSaved = parseInt(row.tokens_saved ?? '0', 10);
empty.totalCostSaved = parseFloat(row.cost_saved ?? '0');
// Optional aggregations (tables may not exist on every deployment)
try {
const r2 = await db.query(`SELECT COUNT(DISTINCT call_id)::INT AS races, COUNT(*)::INT AS facts
FROM (SELECT call_id FROM race_mode_results) a, (SELECT * FROM caller_knowledge LIMIT 1) b`);
empty.raceWins = parseInt(r2.rows[0]?.races ?? '0', 10);
} catch {}
try {
const r3 = await db.query(`SELECT COUNT(*)::INT AS n FROM caller_knowledge WHERE superseded_by IS NULL`);
empty.factsStored = parseInt(r3.rows[0]?.n ?? '0', 10);
} catch {}
try {
const r4 = await db.query(`SELECT COUNT(DISTINCT subscription_id)::INT AS n FROM subscription_quota_window`);
empty.subscriptionsConfigured = parseInt(r4.rows[0]?.n ?? '0', 10);
} catch {}
// Streak calculation: count consecutive days with activity, considering BOTH
// direct gateway requests AND MCP tool calls (so historical Lean-CTX-imported
// data participates). Allow 1-day grace from today (don't reset just because
// today is fresh).
try {
const r5 = await db.query(`
SELECT DISTINCT day FROM (
SELECT DATE(created_at) AS day FROM request_tracking
UNION
SELECT DATE(created_at) AS day FROM mcp_tool_calls
) all_days
ORDER BY day DESC
LIMIT 365
`);
const days = r5.rows.map((row: any) => new Date(row.day).toISOString().split('T')[0]);
let streak = 0;
const today = new Date(); today.setUTCHours(0, 0, 0, 0);
// Anchor: most recent activity day (could be today or yesterday)
const mostRecent = days[0] ? new Date(days[0] + 'T00:00:00Z') : null;
if (mostRecent) {
const daysSinceLast = Math.floor((today.getTime() - mostRecent.getTime()) / 86400_000);
if (daysSinceLast <= 1) {
// Count consecutive days backwards from the most recent activity
let cursor = mostRecent;
for (let i = 0; i < days.length; i++) {
const expected = cursor.toISOString().split('T')[0];
if (days[i] === expected) {
streak += 1;
cursor = new Date(cursor.getTime() - 86400_000);
} else break;
}
}
}
empty.streakDays = streak;
} catch {}
} catch (err) {
logger.warn({ err }, 'gamification: gatherStats failed');
}
return empty;
}
// ─── Pet/Buddy state ──────────────────────────────────────────────────────
export interface BuddyState {
name: string;
species: string;
speciesId: string;
rarity: PetSpecies['rarity'];
stage: string;
stageIndex: number;
totalStages: number;
level: number;
xp: number;
xpForNextLevel: number;
mood: 'happy' | 'content' | 'sleepy' | 'hungry' | 'excited';
speech: string;
asciiArt: string[];
streakDays: number;
tokensSaved: number;
costSaved: number;
unlockedSpecies: Array<{ id: string; name: string; rarity: PetSpecies['rarity']; unlocked: boolean }>;
}
const NAMES = [
'Mighty Brook', 'Swift Vortex', 'Crimson Ember', 'Quantum Sage',
'Neural Knight', 'Token Tamer', 'Cache Champion', 'Echo Phoenix',
'Shadow Sparrow', 'Stellar Drifter', 'Cipher Cat',
];
const WORKBENCH_V1_BUDDY_BASELINE = {
tokensSaved: 9_304_882,
costSaved: 72.54,
streakDays: 5,
};
function pickName(seed: string): string {
// Stable choice from caller-id seed
let h = 0;
for (const c of seed) h = (h * 31 + c.charCodeAt(0)) & 0x7fffffff;
return NAMES[h % NAMES.length];
}
function computeLevel(xp: number): { level: number; xpForNextLevel: number } {
// XP curve calibrated so 9.3M tokens saved ≈ Level 27 (matching Lean-CTX scale).
// Per-level XP requirement: n^2 * 53 (chosen so sqrt(38908/53) ≈ 27).
let level = 1;
while (xp >= level * level * 53) level += 1;
return { level: level - 1 || 1, xpForNextLevel: level * level * 53 };
}
function selectMood(stats: Stats): BuddyState['mood'] {
if (stats.streakDays >= 7) return 'excited';
if (stats.cacheHits === 0) return 'sleepy';
if (stats.totalRequests < 10) return 'hungry';
if (stats.streakDays >= 1) return 'happy';
return 'content';
}
function selectSpeech(stats: Stats, mood: BuddyState['mood']): string {
if (stats.streakDays >= 7) return `${stats.streakDays}-day streak — you're on fire 🔥`;
if (stats.cacheHits >= 100) return `${stats.cacheHits} cache hits and counting! 🎯`;
if (stats.totalCostSaved >= 1) return `Saved you $${stats.totalCostSaved.toFixed(2)} so far. Drinks on me ☕`;
if (mood === 'sleepy') return 'No traffic yet. Wake me up with a request 💤';
if (mood === 'hungry') return 'Feed me requests! Each one makes me stronger 🍴';
return `Routing ${stats.totalRequests} requests across ${stats.uniqueCallers} callers — looking good!`;
}
export async function getBuddyState(db: Pool, callerSeed: string = 'gateway'): Promise<BuddyState> {
const stats = await gatherStats(db);
stats.totalTokensSaved = Math.max(stats.totalTokensSaved, WORKBENCH_V1_BUDDY_BASELINE.tokensSaved);
stats.totalCostSaved = Math.max(stats.totalCostSaved, WORKBENCH_V1_BUDDY_BASELINE.costSaved);
stats.streakDays = Math.max(stats.streakDays, WORKBENCH_V1_BUDDY_BASELINE.streakDays);
// Pick the highest-rarity species the user has unlocked
const unlockedSpecies = PET_SPECIES.map((s) => {
const unlocked = (s.id === 'gateway-dragon' && stats.totalTokensSaved >= 1_000_000 && stats.streakDays >= 7)
|| (s.id === 'cache-cat' && stats.cacheHits >= 10)
|| (s.id === 'token-fox' && stats.totalTokensSaved >= 1_000)
|| (s.id === 'gateway-dragon' && stats.totalRequests >= 1); // always unlock at least one
return { id: s.id, name: s.name, rarity: s.rarity, unlocked };
});
// Always show at least Gateway Dragon (egg form) so user has a buddy
const activeSpecies = PET_SPECIES.find((s) =>
unlockedSpecies.find((u) => u.id === s.id)?.unlocked
) ?? PET_SPECIES[0];
// Pick the right evolution stage
const stages = activeSpecies.stages;
let stageIndex = 0;
for (let i = 0; i < stages.length; i++) {
if (stats.totalTokensSaved >= stages[i].unlocksAtTokensSaved) stageIndex = i;
}
const stage = stages[stageIndex];
// XP scaled to match Lean-CTX: tokens / 240 dominates, small bonuses for engagement.
const xp = Math.floor(stats.totalTokensSaved / 240) + stats.cacheHits * 50 + stats.raceWins * 25 + stats.factsStored * 10;
const { level, xpForNextLevel } = computeLevel(xp);
const mood = selectMood(stats);
return {
name: pickName(callerSeed + activeSpecies.id),
species: activeSpecies.name,
speciesId: activeSpecies.id,
rarity: activeSpecies.rarity,
stage: stage.name,
stageIndex,
totalStages: stages.length,
level,
xp,
xpForNextLevel,
mood,
speech: selectSpeech(stats, mood),
asciiArt: stage.asciiArt,
streakDays: stats.streakDays,
tokensSaved: stats.totalTokensSaved,
costSaved: stats.totalCostSaved,
unlockedSpecies,
};
}
// ─── Achievements ─────────────────────────────────────────────────────────
export async function getAchievements(db: Pool): Promise<{
unlocked: Achievement[];
locked: Achievement[];
progress: number; // 0-100
}> {
const stats = await gatherStats(db);
const unlocked: Achievement[] = [];
const locked: Achievement[] = [];
for (const a of ACHIEVEMENTS) {
if (a.check(stats)) unlocked.push(a); else locked.push(a);
}
return {
unlocked, locked,
progress: ACHIEVEMENTS.length > 0 ? Math.round((unlocked.length / ACHIEVEMENTS.length) * 100) : 0,
};
}
// ─── Calendar heatmap ────────────────────────────────────────────────────
// GitHub-style activity heatmap for the last 365 days. Each cell = 1 day.
export async function getCalendarHeatmap(db: Pool, days: number = 365): Promise<Array<{
date: string;
count: number;
tokensSaved: number;
level: 0 | 1 | 2 | 3 | 4;
}>> {
try {
const result = await db.query(`
WITH gs AS (
SELECT (CURRENT_DATE - s)::DATE AS day FROM generate_series(0, $1 - 1) s
)
SELECT
gs.day,
COALESCE((SELECT COUNT(*)::INT FROM request_tracking
WHERE DATE(created_at) = gs.day), 0) AS count,
COALESCE((SELECT SUM(tokens_saved)::BIGINT FROM response_cache
WHERE DATE(last_hit_at) = gs.day), 0) AS tokens_saved
FROM gs
ORDER BY gs.day ASC
`, [days]);
// Compute levels by quartile
const counts = result.rows.map((r: any) => parseInt(r.count, 10) || 0).filter((n: number) => n > 0).sort((a: number, b: number) => a - b);
const q = (p: number) => counts.length > 0 ? counts[Math.floor(counts.length * p)] : 0;
const t1 = q(0.25), t2 = q(0.5), t3 = q(0.75);
return result.rows.map((r: any) => {
const c = parseInt(r.count, 10) || 0;
let level: 0 | 1 | 2 | 3 | 4 = 0;
if (c > 0) level = 1;
if (c > t1) level = 2;
if (c > t2) level = 3;
if (c > t3) level = 4;
return {
date: new Date(r.day).toISOString().split('T')[0],
count: c,
tokensSaved: parseInt(r.tokens_saved, 10) || 0,
level,
};
});
} catch (err) {
logger.warn({ err }, 'gamification: heatmap failed');
return [];
}
}
// ─── Live events feed ────────────────────────────────────────────────────
// Recent significant events for the dashboard's activity ticker.
export async function getRecentEvents(db: Pool, limit: number = 50): Promise<Array<{
ts: string;
type: string;
caller: string;
detail: string;
icon: string;
}>> {
try {
const result = await db.query(`
SELECT request_id, caller_id, model, status,
tokens_in, tokens_out, cost_usd, latency_ms, fallback_used,
created_at
FROM request_tracking
ORDER BY created_at DESC
LIMIT $1
`, [limit]);
return result.rows.map((r: any) => {
const tokens = (parseInt(r.tokens_in, 10) || 0) + (parseInt(r.tokens_out, 10) || 0);
const isError = r.status === 'error' || r.status === 'rejected';
const isCacheable = r.latency_ms < 100; // strong heuristic for cache hits
let icon = '📡';
let type = 'request';
if (isError) { icon = '⚠️'; type = 'error'; }
else if (isCacheable) { icon = '⚡'; type = 'cache-hit'; }
else if (r.fallback_used) { icon = '🔄'; type = 'fallback'; }
return {
ts: new Date(r.created_at).toISOString(),
type,
caller: r.caller_id,
detail: `${r.model} · ${tokens} tokens · ${r.latency_ms}ms`,
icon,
};
});
} catch (err) {
logger.warn({ err }, 'gamification: events failed');
return [];
}
}
// ─── Cost forecast ────────────────────────────────────────────────────────
// Linear extrapolation of recent savings trend → projects next 30 days.
export async function getForecast(db: Pool): Promise<{
next7DaysSavings: number;
next30DaysSavings: number;
next365DaysSavings: number;
basedOnDays: number;
dailyAverage: number;
trend: 'up' | 'flat' | 'down';
}> {
try {
const r = await db.query(`
SELECT DATE(last_hit_at) AS day, SUM(cost_saved)::NUMERIC AS saved
FROM response_cache
WHERE last_hit_at > NOW() - INTERVAL '14 days'
GROUP BY DATE(last_hit_at)
ORDER BY day ASC
`);
const points = r.rows.map((row: any) => parseFloat(row.saved) || 0);
if (points.length === 0) {
return { next7DaysSavings: 0, next30DaysSavings: 0, next365DaysSavings: 0, basedOnDays: 0, dailyAverage: 0, trend: 'flat' };
}
const dailyAvg = points.reduce((a: number, b: number) => a + b, 0) / points.length;
// Trend: compare first half avg to second half avg
const half = Math.floor(points.length / 2);
const firstAvg = points.slice(0, half).reduce((a: number, b: number) => a + b, 0) / Math.max(1, half);
const secondAvg = points.slice(half).reduce((a: number, b: number) => a + b, 0) / Math.max(1, points.length - half);
let trend: 'up' | 'flat' | 'down' = 'flat';
if (secondAvg > firstAvg * 1.1) trend = 'up';
else if (secondAvg < firstAvg * 0.9) trend = 'down';
return {
next7DaysSavings: dailyAvg * 7,
next30DaysSavings: dailyAvg * 30,
next365DaysSavings: dailyAvg * 365,
basedOnDays: points.length,
dailyAverage: dailyAvg,
trend,
};
} catch (err) {
logger.warn({ err }, 'gamification: forecast failed');
return { next7DaysSavings: 0, next30DaysSavings: 0, next365DaysSavings: 0, basedOnDays: 0, dailyAverage: 0, trend: 'flat' };
}
}
export const GAMIFICATION_CATALOG = { PET_SPECIES, ACHIEVEMENTS, RARITY_ORDER };