feat: add admin user leaderboard analytics

This commit is contained in:
Administrator committed 2026-06-04 19:35:11 +08:00
1 parent 1e4ef070cf
commit 8f80b7c1ef
7 files changed
+1060 -1

No files matched your search

+111
View File
@@ -35,6 +35,88 @@ export interface AdminMemoryResponse {
buckets: AdminMemoryBucket[];
}
export interface AdminLeaderboardUser {
user_id: string;
email: string;
system_role: string;
thread_count: number;
run_count: number;
qa_count: number;
llm_call_count: number;
tool_call_count: number;
skill_call_count: number;
total_tokens: number;
input_tokens: number;
output_tokens: number;
last_active_at: string;
}
export interface AdminLeaderboardSkill {
name: string;
call_count: number;
user_count: number;
success_count: number;
error_count: number;
avg_duration_ms: number;
last_used_at: string;
}
export interface AdminLeaderboardQuestion {
text: string;
user_id: string;
email: string;
thread_id: string;
run_id: string;
asked_at: string;
}
export interface AdminLeaderboardTrendPoint {
date: string;
runs: number;
questions: number;
tokens: number;
users: number;
}
export interface AdminLeaderboardOverview {
total_users: number;
active_users: number;
total_threads: number;
total_runs: number;
total_questions: number;
total_tokens: number;
total_skill_calls: number;
total_tool_calls: number;
error_runs: number;
}
export interface AdminLeaderboardResponse {
range_days: number;
since: string;
until: string;
overview: AdminLeaderboardOverview;
users_by_activity: AdminLeaderboardUser[];
users_by_questions: AdminLeaderboardUser[];
users_by_tokens: AdminLeaderboardUser[];
skills: AdminLeaderboardSkill[];
tools: AdminLeaderboardSkill[];
recent_questions: AdminLeaderboardQuestion[];
frequent_questions: AdminLeaderboardQuestion[];
trends: AdminLeaderboardTrendPoint[];
}
export interface AdminUserAnalyticsResponse {
range_days: number;
since: string;
until: string;
user: AdminLeaderboardUser;
overview: AdminLeaderboardOverview;
skills: AdminLeaderboardSkill[];
tools: AdminLeaderboardSkill[];
recent_questions: AdminLeaderboardQuestion[];
trends: AdminLeaderboardTrendPoint[];
}
const base = () => `${getBackendBaseURL()}/api/admin/users`;
async function parse<T>(res: Response, what: string): Promise<T> {
@@ -73,6 +155,35 @@ export async function getUserMemory(
);
}
export async function getUserLeaderboard(
days: number,
limit = 20,
): Promise<AdminLeaderboardResponse> {
const params = new URLSearchParams({
days: String(days),
limit: String(limit),
});
return parse(
await apiFetch(`${base()}/leaderboard?${params}`),
"Failed to load leaderboard",
);
}
export async function getUserAnalytics(
userId: string,
days: number,
limit = 20,
): Promise<AdminUserAnalyticsResponse> {
const params = new URLSearchParams({
days: String(days),
limit: String(limit),
});
return parse(
await apiFetch(`${base()}/${encodeURIComponent(userId)}/analytics?${params}`),
"Failed to load user analytics",
);
}
export async function addAdminMemoryEntry(
userId: string,
content: string,
+17
View File
@@ -6,6 +6,8 @@ import {
deleteAdminMemoryEntry,
deleteAdminV1Fact,
getUserMemory,
getUserAnalytics,
getUserLeaderboard,
listUserThreadMessages,
listUserThreads,
replaceAdminMemoryEntry,
@@ -13,6 +15,21 @@ import {
updateAdminV1Section,
} from "./api";
export function useUserLeaderboard(days: number, limit = 20) {
return useQuery({
queryKey: ["admin-activity", "leaderboard", days, limit],
queryFn: () => getUserLeaderboard(days, limit),
});
}
export function useUserAnalytics(userId: string | null, days: number, limit = 20) {
return useQuery({
queryKey: ["admin-activity", "analytics", userId, days, limit],
queryFn: () => getUserAnalytics(userId!, days, limit),
enabled: !!userId,
});
}
/** Threads owned by a given user. Disabled until a user is picked. */
export function useUserThreads(userId: string | null) {
return useQuery({
@@ -35,6 +35,7 @@ import {
ShieldCheck,
Sparkles,
Target,
Trophy,
Users,
} from "lucide-react";
@@ -259,6 +260,13 @@ export const sidebarMenuItems: MenuItem[] = [
path: "/page/strategy/admin/scheduled-tasks",
adminOnly: true,
},
{
id: "mgmt-user-leaderboard",
label: "用户排行榜",
icon: Trophy,
path: "/page/strategy/admin/user-leaderboard",
adminOnly: true,
},
{
id: "mgmt-user-activity",
label: "用户活动审计",
@@ -1,7 +1,8 @@
"use client";
import { Loader2, MessageSquareIcon, SearchIcon, UserIcon } from "lucide-react";
import { useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { useSearchParams } from "react-router-dom";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
@@ -109,11 +110,13 @@ function MemoryBucketView({ bucket }: { bucket: AdminMemoryBucket }) {
}
export default function AdminUserActivityPage() {
const [searchParams] = useSearchParams();
const { data: users, isLoading: usersLoading } = useAllUsers(true);
const [userId, setUserId] = useState<string | null>(null);
const [threadId, setThreadId] = useState<string | null>(null);
const [userQuery, setUserQuery] = useState("");
const [tab, setTab] = useState<"qa" | "memory">("qa");
const targetUserId = searchParams.get("user");
const { data: threads, isLoading: threadsLoading } = useUserThreads(userId);
const { data: messages, isLoading: messagesLoading } = useUserThreadMessages(
@@ -122,6 +125,14 @@ export default function AdminUserActivityPage() {
);
const { data: memory, isLoading: memoryLoading } = useUserMemory(userId);
useEffect(() => {
if (targetUserId) {
setUserId(targetUserId);
setThreadId(null);
setTab("qa");
}
}, [targetUserId]);
const filteredUsers = useMemo(() => {
const needle = userQuery.trim().toLowerCase();
const list = users ?? [];
@@ -0,0 +1,470 @@
"use client";
import {
Activity,
BarChart3,
Clock3,
Loader2,
Medal,
MessageSquareText,
MousePointerClick,
Search,
Sparkles,
TrendingUp,
UserRound,
Users,
type LucideIcon,
} from "lucide-react";
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import type {
AdminLeaderboardQuestion,
AdminLeaderboardSkill,
AdminLeaderboardTrendPoint,
AdminLeaderboardUser,
} from "@/core/admin/api";
import { useUserAnalytics, useUserLeaderboard } from "@/core/admin/hooks";
import { cn } from "@/lib/utils";
type UserRankTab = "activity" | "questions" | "tokens";
function fmtNumber(value?: number | null): string {
return Number(value ?? 0).toLocaleString();
}
function fmtTime(value?: string | null): string {
if (!value) return "-";
const date = new Date(value);
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
}
function scoreOf(user: AdminLeaderboardUser): number {
return user.run_count + user.tool_call_count;
}
function clampText(value: string, max = 120): string {
const text = value.trim();
return text.length > max ? `${text.slice(0, max)}...` : text;
}
function OverviewCard({
icon: Icon,
label,
value,
hint,
}: {
icon: LucideIcon;
label: string;
value: string;
hint: string;
}) {
return (
<div className="rounded-lg border bg-card p-4 shadow-sm">
<div className="flex items-center justify-between gap-3">
<span className="text-muted-foreground text-sm">{label}</span>
<Icon className="text-muted-foreground size-4" />
</div>
<div className="mt-3 text-2xl font-semibold tabular-nums">{value}</div>
<div className="text-muted-foreground mt-1 text-xs">{hint}</div>
</div>
);
}
function MiniBars({ points }: { points: AdminLeaderboardTrendPoint[] }) {
const max = Math.max(1, ...points.map((p) => p.runs));
return (
<div className="flex h-24 items-end gap-1">
{points.length === 0 ? (
<div className="text-muted-foreground flex w-full items-center justify-center text-sm">
暂无趋势数据
</div>
) : (
points.slice(-30).map((p) => (
<div
key={p.date}
title={`${p.date}: ${p.runs} 次使用,${p.questions} 次问答`}
className="min-w-2 flex-1 rounded-t bg-primary/70"
style={{ height: `${Math.max(8, (p.runs / max) * 96)}px` }}
/>
))
)}
</div>
);
}
function UserRankList({
users,
activeUserId,
onSelect,
tab,
}: {
users: AdminLeaderboardUser[];
activeUserId: string | null;
onSelect: (user: AdminLeaderboardUser) => void;
tab: UserRankTab;
}) {
if (users.length === 0) {
return <div className="text-muted-foreground py-10 text-center text-sm">暂无用户统计</div>;
}
return (
<div className="flex flex-col">
{users.map((user, index) => {
const main =
tab === "questions"
? `${fmtNumber(user.qa_count)} 次问答`
: tab === "tokens"
? `${fmtNumber(user.total_tokens)} tokens`
: `${fmtNumber(scoreOf(user))} 次使用`;
return (
<button
key={user.user_id}
type="button"
onClick={() => onSelect(user)}
className={cn(
"grid grid-cols-[44px_minmax(0,1fr)_auto] items-center gap-3 border-b px-4 py-3 text-left hover:bg-muted/70",
activeUserId === user.user_id && "bg-primary/5",
)}
>
<div className="flex size-8 items-center justify-center rounded-full bg-muted text-sm font-semibold">
{index < 3 ? <Medal className="size-4 text-primary" /> : index + 1}
</div>
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="truncate font-medium">{user.email || user.user_id}</span>
{user.system_role === "admin" && (
<Badge variant="outline" className="shrink-0 text-xs">
管理员
</Badge>
)}
</div>
<div className="text-muted-foreground mt-1 flex flex-wrap gap-x-3 gap-y-1 text-xs">
<span>会话 {fmtNumber(user.thread_count)}</span>
<span>运行 {fmtNumber(user.run_count)}</span>
<span>技能 {fmtNumber(user.skill_call_count)}</span>
<span>最近 {fmtTime(user.last_active_at)}</span>
</div>
</div>
<div className="text-right">
<div className="font-semibold tabular-nums">{main}</div>
<div className="text-muted-foreground mt-1 text-xs">
LLM {fmtNumber(user.llm_call_count)}
</div>
</div>
</button>
);
})}
</div>
);
}
function RankingTable({
title,
icon: Icon,
items,
empty,
}: {
title: string;
icon: LucideIcon;
items: AdminLeaderboardSkill[];
empty: string;
}) {
return (
<div className="rounded-lg border bg-card">
<div className="flex items-center gap-2 border-b px-4 py-3">
<Icon className="text-muted-foreground size-4" />
<h2 className="font-semibold">{title}</h2>
</div>
<div className="divide-y">
{items.length === 0 && (
<div className="text-muted-foreground py-10 text-center text-sm">{empty}</div>
)}
{items.map((item, index) => (
<div key={item.name} className="grid grid-cols-[32px_minmax(0,1fr)_auto] gap-3 px-4 py-3">
<span className="text-muted-foreground text-sm tabular-nums">{index + 1}</span>
<div className="min-w-0">
<div className="truncate font-medium">{item.name}</div>
<div className="text-muted-foreground mt-1 text-xs">
{fmtNumber(item.user_count)} 位用户 · 平均 {fmtNumber(item.avg_duration_ms)} ms · 最近 {fmtTime(item.last_used_at)}
</div>
</div>
<div className="text-right text-sm">
<div className="font-semibold tabular-nums">{fmtNumber(item.call_count)}</div>
<div className="text-muted-foreground text-xs">
成功 {fmtNumber(item.success_count)} / 失败 {fmtNumber(item.error_count)}
</div>
</div>
</div>
))}
</div>
</div>
);
}
function QuestionList({
title,
items,
showUser = true,
}: {
title: string;
items: AdminLeaderboardQuestion[];
showUser?: boolean;
}) {
return (
<div className="rounded-lg border bg-card">
<div className="flex items-center gap-2 border-b px-4 py-3">
<MessageSquareText className="text-muted-foreground size-4" />
<h2 className="font-semibold">{title}</h2>
</div>
<div className="divide-y">
{items.length === 0 && (
<div className="text-muted-foreground py-10 text-center text-sm">暂无问题记录</div>
)}
{items.map((item, index) => (
<div key={`${item.run_id || item.text}-${index}`} className="px-4 py-3">
<div className="text-sm leading-6">{clampText(item.text, 180)}</div>
<div className="text-muted-foreground mt-2 flex flex-wrap gap-x-3 gap-y-1 text-xs">
{showUser && <span>{item.email || item.user_id || "未知用户"}</span>}
<span>{fmtTime(item.asked_at)}</span>
{item.thread_id && <span>会话 {item.thread_id.slice(0, 8)}</span>}
</div>
</div>
))}
</div>
</div>
);
}
function UserDetailPanel({
userId,
days,
onOpenAudit,
}: {
userId: string | null;
days: number;
onOpenAudit: (userId: string) => void;
}) {
const { data, isLoading } = useUserAnalytics(userId, days, 12);
if (!userId) {
return (
<div className="text-muted-foreground flex h-full items-center justify-center text-sm">
点击排行榜中的用户查看个人统计
</div>
);
}
if (isLoading || !data) {
return (
<div className="text-muted-foreground flex h-full items-center justify-center gap-2 text-sm">
<Loader2 className="size-4 animate-spin" />
加载用户详情
</div>
);
}
return (
<ScrollArea className="h-full">
<div className="flex flex-col gap-4 p-4">
<div className="rounded-lg border bg-card p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-lg font-semibold">{data.user.email || data.user.user_id}</div>
<div className="text-muted-foreground mt-1 text-xs">最近活跃 {fmtTime(data.user.last_active_at)}</div>
</div>
<Button size="sm" variant="outline" onClick={() => onOpenAudit(data.user.user_id)}>
审计详情
</Button>
</div>
<div className="mt-4 grid grid-cols-2 gap-3 text-sm">
<Metric label="问答次数" value={data.user.qa_count} />
<Metric label="运行次数" value={data.user.run_count} />
<Metric label="技能调用" value={data.user.skill_call_count} />
<Metric label="总 Tokens" value={data.user.total_tokens} />
</div>
</div>
<div className="rounded-lg border bg-card p-4">
<div className="mb-3 flex items-center gap-2 font-semibold">
<TrendingUp className="text-muted-foreground size-4" />
个人使用趋势
</div>
<MiniBars points={data.trends} />
</div>
<RankingTable title="个人常用技能" icon={Sparkles} items={data.skills} empty="暂无技能调用" />
<QuestionList title="最近提问" items={data.recent_questions} showUser={false} />
</div>
</ScrollArea>
);
}
function Metric({ label, value }: { label: string; value: number }) {
return (
<div className="rounded-md bg-muted/60 px-3 py-2">
<div className="text-muted-foreground text-xs">{label}</div>
<div className="mt-1 font-semibold tabular-nums">{fmtNumber(value)}</div>
</div>
);
}
export default function AdminUserLeaderboardPage() {
const navigate = useNavigate();
const [days, setDays] = useState(30);
const [tab, setTab] = useState<UserRankTab>("activity");
const [query, setQuery] = useState("");
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
const { data, isLoading, error } = useUserLeaderboard(days, 30);
const users = useMemo(() => {
const source =
tab === "questions"
? data?.users_by_questions
: tab === "tokens"
? data?.users_by_tokens
: data?.users_by_activity;
const needle = query.trim().toLowerCase();
return (source ?? []).filter((user) =>
needle ? `${user.email} ${user.user_id}`.toLowerCase().includes(needle) : true,
);
}, [data, query, tab]);
const overview = data?.overview;
return (
<div className="flex size-full min-h-0 bg-muted/20">
<main className="flex min-w-0 flex-1 flex-col">
<div className="border-b bg-background px-6 py-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h1 className="text-xl font-semibold">用户排行榜</h1>
<p className="text-muted-foreground mt-1 text-sm">
汇总用户活跃、问答、Token、技能、工具和近期问题,按时间范围统计。
</p>
</div>
<Select value={String(days)} onValueChange={(value) => setDays(Number(value))}>
<SelectTrigger className="w-36">
<Clock3 className="mr-2 size-4" />
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="7">近 7 天</SelectItem>
<SelectItem value="30">近 30 天</SelectItem>
<SelectItem value="90">近 90 天</SelectItem>
<SelectItem value="365">近 1 年</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<ScrollArea className="min-h-0 flex-1">
<div className="flex flex-col gap-4 p-6">
{isLoading && (
<div className="text-muted-foreground flex items-center justify-center gap-2 py-16 text-sm">
<Loader2 className="size-4 animate-spin" />
加载统计数据
</div>
)}
{!isLoading && error && (
<div className="text-destructive rounded-lg border bg-card p-6 text-sm">
加载失败:{error.message}
</div>
)}
{!isLoading && data && overview && (
<>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
<OverviewCard icon={Users} label="总用户" value={fmtNumber(overview.total_users)} hint={`${fmtNumber(overview.active_users)} 位在当前周期活跃`} />
<OverviewCard icon={MousePointerClick} label="使用次数" value={fmtNumber(overview.total_runs)} hint={`${fmtNumber(overview.total_threads)} 个会话`} />
<OverviewCard icon={MessageSquareText} label="问答次数" value={fmtNumber(overview.total_questions)} hint={`失败运行 ${fmtNumber(overview.error_runs)} 次`} />
<OverviewCard icon={Sparkles} label="技能/工具调用" value={fmtNumber(overview.total_skill_calls)} hint={`工具总调用 ${fmtNumber(overview.total_tool_calls)} 次`} />
</div>
<div className="grid gap-4 xl:grid-cols-[minmax(0,1.4fr)_minmax(360px,0.8fr)]">
<div className="rounded-lg border bg-card">
<div className="flex flex-wrap items-center justify-between gap-3 border-b px-4 py-3">
<div className="flex items-center gap-2">
<BarChart3 className="text-muted-foreground size-4" />
<h2 className="font-semibold">用户排行</h2>
</div>
<div className="flex flex-wrap items-center gap-2">
<div className="relative">
<Search className="text-muted-foreground absolute left-2.5 top-1/2 size-4 -translate-y-1/2" />
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="搜索用户"
className="h-9 w-52 pl-8"
/>
</div>
<Tabs value={tab} onValueChange={(value) => setTab(value as UserRankTab)}>
<TabsList>
<TabsTrigger value="activity">使用</TabsTrigger>
<TabsTrigger value="questions">问答</TabsTrigger>
<TabsTrigger value="tokens">消耗</TabsTrigger>
</TabsList>
</Tabs>
</div>
</div>
<UserRankList
users={users}
activeUserId={selectedUserId}
tab={tab}
onSelect={(user) => setSelectedUserId(user.user_id)}
/>
</div>
<div className="rounded-lg border bg-card">
<div className="flex items-center gap-2 border-b px-4 py-3">
<UserRound className="text-muted-foreground size-4" />
<h2 className="font-semibold">用户详情</h2>
</div>
<div className="h-[560px]">
<UserDetailPanel
userId={selectedUserId}
days={days}
onOpenAudit={(userId) => {
navigate(`/page/strategy/admin/user-activity?user=${encodeURIComponent(userId)}`);
}}
/>
</div>
</div>
</div>
<div className="rounded-lg border bg-card p-4">
<div className="mb-3 flex items-center gap-2 font-semibold">
<TrendingUp className="text-muted-foreground size-4" />
时间趋势
</div>
<MiniBars points={data.trends} />
</div>
<div className="grid gap-4 xl:grid-cols-2">
<RankingTable title="最常用技能" icon={Sparkles} items={data.skills} empty="当前周期暂无技能调用" />
<RankingTable title="最常用工具" icon={Activity} items={data.tools} empty="当前周期暂无工具调用" />
</div>
<div className="grid gap-4 xl:grid-cols-2">
<QuestionList title="最近经常问的问题" items={data.frequent_questions} />
<QuestionList title="最新问题" items={data.recent_questions} />
</div>
</>
)}
</div>
</ScrollArea>
</main>
</div>
);
}
+11
View File
@@ -24,6 +24,7 @@ import LlmMetricsPage from "./LlmMetricsPage";
import UsersPage from "./UsersPage";
import AdminScheduledTasksPage from "./AdminScheduledTasksPage";
import AdminUserActivityPage from "./AdminUserActivityPage";
import AdminUserLeaderboardPage from "./AdminUserLeaderboardPage";
import TagsPage from "./TagsPage";
import LightAppNewNotePage from "./LightAppNewNotePage";
import LightAppNoteListPage from "./LightAppNoteListPage";
@@ -244,6 +245,16 @@ export default function StrategyRoutes() {
)
}
/>
<Route
path="admin/user-leaderboard"
element={
getStoredAuth()?.system_role === "admin" ? (
<AdminUserLeaderboardPage />
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
path="admin/user-activity"
element={
@@ -15,8 +15,11 @@ from __future__ import annotations
import logging
from datetime import datetime, timedelta
from fastapi import APIRouter, HTTPException, Query, Request
from pydantic import BaseModel, Field
from sqlalchemy import and_, case, desc, func, select
from app.gateway.deps import (
get_agent_store,
@@ -28,6 +31,11 @@ from deerflow.agents.memory import BuiltinFileProvider
from deerflow.config.paths import get_paths
from deerflow.runtime import serialize_channel_values
from app.gateway.routers.memory import _build_v1_provider, _is_v1
from deerflow.persistence.engine import get_session_factory
from deerflow.persistence.run.model import RunRow
from deerflow.persistence.thread_meta.model import ThreadMetaRow
from deerflow.persistence.tool_metrics.model import ToolCallMetricRow
from deerflow.persistence.user.model import UserRow
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/admin/users", tags=["admin-users"])
@@ -45,6 +53,429 @@ async def _require_admin(request: Request) -> None:
raise HTTPException(status_code=403, detail="Admin only")
# ── Leaderboard / analytics ─────────────────────────────────────────────────
class AdminLeaderboardUser(BaseModel):
user_id: str
email: str = ""
system_role: str = "user"
thread_count: int = 0
run_count: int = 0
qa_count: int = 0
llm_call_count: int = 0
tool_call_count: int = 0
skill_call_count: int = 0
total_tokens: int = 0
input_tokens: int = 0
output_tokens: int = 0
last_active_at: str = ""
class AdminLeaderboardSkill(BaseModel):
name: str
call_count: int = 0
user_count: int = 0
success_count: int = 0
error_count: int = 0
avg_duration_ms: int = 0
last_used_at: str = ""
class AdminLeaderboardQuestion(BaseModel):
text: str
user_id: str = ""
email: str = ""
thread_id: str = ""
run_id: str = ""
asked_at: str = ""
class AdminLeaderboardTrendPoint(BaseModel):
date: str
runs: int = 0
questions: int = 0
tokens: int = 0
users: int = 0
class AdminLeaderboardOverview(BaseModel):
total_users: int = 0
active_users: int = 0
total_threads: int = 0
total_runs: int = 0
total_questions: int = 0
total_tokens: int = 0
total_skill_calls: int = 0
total_tool_calls: int = 0
error_runs: int = 0
class AdminLeaderboardResponse(BaseModel):
range_days: int
since: str
until: str
overview: AdminLeaderboardOverview
users_by_activity: list[AdminLeaderboardUser] = Field(default_factory=list)
users_by_questions: list[AdminLeaderboardUser] = Field(default_factory=list)
users_by_tokens: list[AdminLeaderboardUser] = Field(default_factory=list)
skills: list[AdminLeaderboardSkill] = Field(default_factory=list)
tools: list[AdminLeaderboardSkill] = Field(default_factory=list)
recent_questions: list[AdminLeaderboardQuestion] = Field(default_factory=list)
frequent_questions: list[AdminLeaderboardQuestion] = Field(default_factory=list)
trends: list[AdminLeaderboardTrendPoint] = Field(default_factory=list)
class AdminUserAnalyticsResponse(BaseModel):
range_days: int
since: str
until: str
user: AdminLeaderboardUser
overview: AdminLeaderboardOverview
skills: list[AdminLeaderboardSkill] = Field(default_factory=list)
tools: list[AdminLeaderboardSkill] = Field(default_factory=list)
recent_questions: list[AdminLeaderboardQuestion] = Field(default_factory=list)
trends: list[AdminLeaderboardTrendPoint] = Field(default_factory=list)
def _now() -> datetime:
return datetime.now()
def _iso(value) -> str:
return value.isoformat() if isinstance(value, datetime) else str(value or "")
def _range_start(days: int) -> datetime:
return _now() - timedelta(days=max(1, min(days, 365)))
def _question_count_expr():
return func.sum(case((RunRow.first_human_message.is_not(None), 1), else_=0))
def _run_time_filter(since: datetime, until: datetime):
return and_(RunRow.created_at >= since, RunRow.created_at <= until)
def _tool_time_filter(since: datetime, until: datetime):
return and_(ToolCallMetricRow.created_at >= since, ToolCallMetricRow.created_at <= until)
async def _query_user_leaderboard(session, since: datetime, until: datetime, user_id: str | None = None) -> list[AdminLeaderboardUser]:
run_agg = (
select(
RunRow.user_id.label("user_id"),
func.count(RunRow.run_id).label("run_count"),
_question_count_expr().label("qa_count"),
func.coalesce(func.sum(RunRow.llm_call_count), 0).label("llm_call_count"),
func.coalesce(func.sum(RunRow.total_tokens), 0).label("total_tokens"),
func.coalesce(func.sum(RunRow.total_input_tokens), 0).label("input_tokens"),
func.coalesce(func.sum(RunRow.total_output_tokens), 0).label("output_tokens"),
func.max(RunRow.updated_at).label("last_active_at"),
)
.where(_run_time_filter(since, until))
.group_by(RunRow.user_id)
.subquery()
)
thread_agg = (
select(ThreadMetaRow.user_id.label("user_id"), func.count(ThreadMetaRow.thread_id).label("thread_count"))
.where(and_(ThreadMetaRow.created_at >= since, ThreadMetaRow.created_at <= until))
.group_by(ThreadMetaRow.user_id)
.subquery()
)
tool_agg = (
select(
ToolCallMetricRow.user_id.label("user_id"),
func.count(ToolCallMetricRow.id).label("tool_call_count"),
func.sum(case((ToolCallMetricRow.skill_name.is_not(None), 1), else_=0)).label("skill_call_count"),
)
.where(_tool_time_filter(since, until))
.group_by(ToolCallMetricRow.user_id)
.subquery()
)
stmt = (
select(
UserRow.id,
UserRow.email,
UserRow.system_role,
func.coalesce(thread_agg.c.thread_count, 0),
func.coalesce(run_agg.c.run_count, 0),
func.coalesce(run_agg.c.qa_count, 0),
func.coalesce(run_agg.c.llm_call_count, 0),
func.coalesce(tool_agg.c.tool_call_count, 0),
func.coalesce(tool_agg.c.skill_call_count, 0),
func.coalesce(run_agg.c.total_tokens, 0),
func.coalesce(run_agg.c.input_tokens, 0),
func.coalesce(run_agg.c.output_tokens, 0),
run_agg.c.last_active_at,
)
.outerjoin(run_agg, run_agg.c.user_id == UserRow.id)
.outerjoin(thread_agg, thread_agg.c.user_id == UserRow.id)
.outerjoin(tool_agg, tool_agg.c.user_id == UserRow.id)
)
if user_id:
stmt = stmt.where(UserRow.id == user_id)
stmt = stmt.order_by(
desc(func.coalesce(run_agg.c.run_count, 0) + func.coalesce(tool_agg.c.tool_call_count, 0)),
desc(func.coalesce(run_agg.c.total_tokens, 0)),
)
rows = (await session.execute(stmt)).all()
return [
AdminLeaderboardUser(
user_id=r[0],
email=r[1] or "",
system_role=r[2] or "user",
thread_count=int(r[3] or 0),
run_count=int(r[4] or 0),
qa_count=int(r[5] or 0),
llm_call_count=int(r[6] or 0),
tool_call_count=int(r[7] or 0),
skill_call_count=int(r[8] or 0),
total_tokens=int(r[9] or 0),
input_tokens=int(r[10] or 0),
output_tokens=int(r[11] or 0),
last_active_at=_iso(r[12]),
)
for r in rows
]
async def _query_call_rankings(session, since: datetime, until: datetime, *, user_id: str | None, field: str, limit: int) -> list[AdminLeaderboardSkill]:
name_col = ToolCallMetricRow.skill_name if field == "skill" else ToolCallMetricRow.tool_name
call_count = func.count(ToolCallMetricRow.id).label("call_count")
last_used_at = func.max(ToolCallMetricRow.created_at).label("last_used_at")
stmt = (
select(
name_col.label("name"),
call_count,
func.count(func.distinct(ToolCallMetricRow.user_id)).label("user_count"),
func.sum(case((ToolCallMetricRow.status == "success", 1), else_=0)).label("success_count"),
func.sum(case((ToolCallMetricRow.status != "success", 1), else_=0)).label("error_count"),
func.coalesce(func.avg(ToolCallMetricRow.duration_ms), 0).label("avg_duration_ms"),
last_used_at,
)
.where(_tool_time_filter(since, until), name_col.is_not(None), name_col != "")
.group_by(name_col)
.order_by(call_count.desc(), last_used_at.desc())
.limit(limit)
)
if user_id:
stmt = stmt.where(ToolCallMetricRow.user_id == user_id)
rows = (await session.execute(stmt)).all()
return [
AdminLeaderboardSkill(
name=r[0] or "",
call_count=int(r[1] or 0),
user_count=int(r[2] or 0),
success_count=int(r[3] or 0),
error_count=int(r[4] or 0),
avg_duration_ms=int(r[5] or 0),
last_used_at=_iso(r[6]),
)
for r in rows
]
async def _query_recent_questions(session, since: datetime, until: datetime, *, user_id: str | None, limit: int) -> list[AdminLeaderboardQuestion]:
stmt = (
select(RunRow.first_human_message, RunRow.user_id, UserRow.email, RunRow.thread_id, RunRow.run_id, RunRow.created_at)
.outerjoin(UserRow, UserRow.id == RunRow.user_id)
.where(_run_time_filter(since, until), RunRow.first_human_message.is_not(None), RunRow.first_human_message != "")
.order_by(RunRow.created_at.desc())
.limit(limit)
)
if user_id:
stmt = stmt.where(RunRow.user_id == user_id)
rows = (await session.execute(stmt)).all()
return [
AdminLeaderboardQuestion(
text=(r[0] or "")[:500],
user_id=r[1] or "",
email=r[2] or "",
thread_id=r[3] or "",
run_id=r[4] or "",
asked_at=_iso(r[5]),
)
for r in rows
]
async def _query_frequent_questions(session, since: datetime, until: datetime, limit: int) -> list[AdminLeaderboardQuestion]:
stmt = (
select(
RunRow.first_human_message,
func.count(RunRow.run_id).label("count"),
func.max(RunRow.user_id).label("user_id"),
func.max(UserRow.email).label("email"),
func.max(RunRow.created_at).label("asked_at"),
)
.outerjoin(UserRow, UserRow.id == RunRow.user_id)
.where(_run_time_filter(since, until), RunRow.first_human_message.is_not(None), RunRow.first_human_message != "")
.group_by(RunRow.first_human_message)
.having(func.count(RunRow.run_id) > 1)
.order_by(desc("count"), desc("asked_at"))
.limit(limit)
)
rows = (await session.execute(stmt)).all()
return [
AdminLeaderboardQuestion(
text=(r[0] or "")[:500],
user_id=r[2] or "",
email=r[3] or "",
asked_at=_iso(r[4]),
)
for r in rows
]
async def _query_trends(session, since: datetime, until: datetime, *, user_id: str | None = None) -> list[AdminLeaderboardTrendPoint]:
bucket = func.date(RunRow.created_at)
stmt = (
select(
bucket.label("date"),
func.count(RunRow.run_id).label("runs"),
_question_count_expr().label("questions"),
func.coalesce(func.sum(RunRow.total_tokens), 0).label("tokens"),
func.count(func.distinct(RunRow.user_id)).label("users"),
)
.where(_run_time_filter(since, until))
.group_by(bucket)
.order_by(bucket.asc())
)
if user_id:
stmt = stmt.where(RunRow.user_id == user_id)
rows = (await session.execute(stmt)).all()
return [
AdminLeaderboardTrendPoint(
date=str(r[0]),
runs=int(r[1] or 0),
questions=int(r[2] or 0),
tokens=int(r[3] or 0),
users=int(r[4] or 0),
)
for r in rows
]
async def _query_overview(session, since: datetime, until: datetime, *, user_id: str | None = None) -> AdminLeaderboardOverview:
run_where = [_run_time_filter(since, until)]
thread_where = [and_(ThreadMetaRow.created_at >= since, ThreadMetaRow.created_at <= until)]
tool_where = [_tool_time_filter(since, until)]
if user_id:
run_where.append(RunRow.user_id == user_id)
thread_where.append(ThreadMetaRow.user_id == user_id)
tool_where.append(ToolCallMetricRow.user_id == user_id)
total_users = 1 if user_id else (await session.execute(select(func.count(UserRow.id)))).scalar_one()
run_row = (await session.execute(
select(
func.count(RunRow.run_id),
_question_count_expr(),
func.coalesce(func.sum(RunRow.total_tokens), 0),
func.count(func.distinct(RunRow.user_id)),
func.sum(case((RunRow.status == "error", 1), else_=0)),
).where(*run_where)
)).one()
thread_count = (await session.execute(select(func.count(ThreadMetaRow.thread_id)).where(*thread_where))).scalar_one()
tool_row = (await session.execute(
select(
func.count(ToolCallMetricRow.id),
func.sum(case((ToolCallMetricRow.skill_name.is_not(None), 1), else_=0)),
).where(*tool_where)
)).one()
return AdminLeaderboardOverview(
total_users=int(total_users or 0),
active_users=int(run_row[3] or 0),
total_threads=int(thread_count or 0),
total_runs=int(run_row[0] or 0),
total_questions=int(run_row[1] or 0),
total_tokens=int(run_row[2] or 0),
total_tool_calls=int(tool_row[0] or 0),
total_skill_calls=int(tool_row[1] or 0),
error_runs=int(run_row[4] or 0),
)
@router.get("/leaderboard", response_model=AdminLeaderboardResponse)
async def get_user_leaderboard(
request: Request,
days: int = Query(default=30, ge=1, le=365),
limit: int = Query(default=20, ge=5, le=100),
) -> AdminLeaderboardResponse:
"""Return admin analytics for user activity, Q&A, skills, questions, and trends."""
await _require_admin(request)
session_factory = get_session_factory()
if session_factory is None:
raise HTTPException(status_code=503, detail="Analytics requires database persistence")
until = _now()
since = _range_start(days)
async with session_factory() as session:
overview = await _query_overview(session, since, until)
users = await _query_user_leaderboard(session, since, until)
skills = await _query_call_rankings(session, since, until, user_id=None, field="skill", limit=limit)
tools = await _query_call_rankings(session, since, until, user_id=None, field="tool", limit=limit)
recent_questions = await _query_recent_questions(session, since, until, user_id=None, limit=limit)
frequent_questions = await _query_frequent_questions(session, since, until, limit=limit)
trends = await _query_trends(session, since, until)
return AdminLeaderboardResponse(
range_days=days,
since=_iso(since),
until=_iso(until),
overview=overview,
users_by_activity=sorted(users, key=lambda u: (u.run_count + u.tool_call_count, u.total_tokens), reverse=True)[:limit],
users_by_questions=sorted(users, key=lambda u: (u.qa_count, u.run_count), reverse=True)[:limit],
users_by_tokens=sorted(users, key=lambda u: u.total_tokens, reverse=True)[:limit],
skills=skills,
tools=tools,
recent_questions=recent_questions,
frequent_questions=frequent_questions,
trends=trends,
)
@router.get("/{user_id}/analytics", response_model=AdminUserAnalyticsResponse)
async def get_user_analytics(
user_id: str,
request: Request,
days: int = Query(default=30, ge=1, le=365),
limit: int = Query(default=20, ge=5, le=100),
) -> AdminUserAnalyticsResponse:
"""Return one user's admin analytics profile."""
await _require_admin(request)
session_factory = get_session_factory()
if session_factory is None:
raise HTTPException(status_code=503, detail="Analytics requires database persistence")
until = _now()
since = _range_start(days)
async with session_factory() as session:
users = await _query_user_leaderboard(session, since, until, user_id=user_id)
if not users:
raise HTTPException(status_code=404, detail="User not found")
overview = await _query_overview(session, since, until, user_id=user_id)
skills = await _query_call_rankings(session, since, until, user_id=user_id, field="skill", limit=limit)
tools = await _query_call_rankings(session, since, until, user_id=user_id, field="tool", limit=limit)
recent_questions = await _query_recent_questions(session, since, until, user_id=user_id, limit=limit)
trends = await _query_trends(session, since, until, user_id=user_id)
return AdminUserAnalyticsResponse(
range_days=days,
since=_iso(since),
until=_iso(until),
user=users[0],
overview=overview,
skills=skills,
tools=tools,
recent_questions=recent_questions,
trends=trends,
)
# ── Threads ────────────────────────────────────────────────────────────────