From 1e4ef070cf30216815bc0f57f7c22642524555e8 Mon Sep 17 00:00:00 2001 From: Administrator Date: Thu, 4 Jun 2026 18:45:16 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20=E8=B0=83=E6=95=B4=E6=8F=90=E7=A4=BA?= =?UTF-8?q?=E8=AF=8D=E5=92=8C=20agent=20=E7=AE=A1=E7=90=86=E5=85=A5?= =?UTF-8?q?=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../workspace/agents/agent-manage-dialog.tsx | 14 ++--- .../workspace/agents/agent-selector.tsx | 6 +- .../src/components/workspace/input-box.tsx | 11 +++- .../settings/skill-settings-page.tsx | 61 ++++++++----------- 4 files changed, 42 insertions(+), 50 deletions(-) diff --git a/frontend-web/src/components/workspace/agents/agent-manage-dialog.tsx b/frontend-web/src/components/workspace/agents/agent-manage-dialog.tsx index 5cebb4e5..12977b66 100644 --- a/frontend-web/src/components/workspace/agents/agent-manage-dialog.tsx +++ b/frontend-web/src/components/workspace/agents/agent-manage-dialog.tsx @@ -136,21 +136,21 @@ export function AgentManageDialog({ {agents.map((agent, index) => (
{index + 1} - +
{agent.name} {agent.description && ( - + {agent.description} )}
-
+
- -
- +
+ void }) {
{t.common.loading}
)} {error &&
Error: {error.message}
} - {!isLoading && !error && filteredSkills.length === 0 && ( - - )} + {!isLoading && !error && filteredSkills.length === 0 && ( + + )} {/* —— 原纵向列表布局(保留备查)—— {!isLoading && !error && @@ -986,10 +976,10 @@ export function SkillRow({ ); } -function EmptySkill({ onCreateSkill }: { onCreateSkill: () => void }) { - const { t } = useI18n(); - return ( - +function EmptySkill() { + const { t } = useI18n(); + return ( + @@ -999,12 +989,9 @@ function EmptySkill({ onCreateSkill }: { onCreateSkill: () => void }) { {t.settings.skills.emptyDescription} - - - - - ); -} + + ); +} function InstallSkillDialog({ open, From 8f80b7c1eff1e969911197b969bd44da0cb8b474 Mon Sep 17 00:00:00 2001 From: Administrator Date: Thu, 4 Jun 2026 19:35:11 +0800 Subject: [PATCH 2/2] feat: add admin user leaderboard analytics --- frontend-web/src/core/admin/api.ts | 111 +++++ frontend-web/src/core/admin/hooks.ts | 17 + .../src/core/page-layout/sidebar-menu.ts | 8 + .../src/pages/AdminUserActivityPage.tsx | 13 +- .../src/pages/AdminUserLeaderboardPage.tsx | 470 ++++++++++++++++++ frontend-web/src/pages/StrategyRoutes.tsx | 11 + .../app/gateway/routers/admin_users.py | 431 ++++++++++++++++ 7 files changed, 1060 insertions(+), 1 deletion(-) create mode 100644 frontend-web/src/pages/AdminUserLeaderboardPage.tsx diff --git a/frontend-web/src/core/admin/api.ts b/frontend-web/src/core/admin/api.ts index fe1b465b..e0622b57 100644 --- a/frontend-web/src/core/admin/api.ts +++ b/frontend-web/src/core/admin/api.ts @@ -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(res: Response, what: string): Promise { @@ -73,6 +155,35 @@ export async function getUserMemory( ); } +export async function getUserLeaderboard( + days: number, + limit = 20, +): Promise { + 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 { + 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, diff --git a/frontend-web/src/core/admin/hooks.ts b/frontend-web/src/core/admin/hooks.ts index 89ac12f3..1eb5ad36 100644 --- a/frontend-web/src/core/admin/hooks.ts +++ b/frontend-web/src/core/admin/hooks.ts @@ -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({ diff --git a/frontend-web/src/core/page-layout/sidebar-menu.ts b/frontend-web/src/core/page-layout/sidebar-menu.ts index d2fe690a..c15ef50e 100644 --- a/frontend-web/src/core/page-layout/sidebar-menu.ts +++ b/frontend-web/src/core/page-layout/sidebar-menu.ts @@ -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: "用户活动审计", diff --git a/frontend-web/src/pages/AdminUserActivityPage.tsx b/frontend-web/src/pages/AdminUserActivityPage.tsx index ac22c7d2..0998af6a 100644 --- a/frontend-web/src/pages/AdminUserActivityPage.tsx +++ b/frontend-web/src/pages/AdminUserActivityPage.tsx @@ -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(null); const [threadId, setThreadId] = useState(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 ?? []; diff --git a/frontend-web/src/pages/AdminUserLeaderboardPage.tsx b/frontend-web/src/pages/AdminUserLeaderboardPage.tsx new file mode 100644 index 00000000..906660c3 --- /dev/null +++ b/frontend-web/src/pages/AdminUserLeaderboardPage.tsx @@ -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 ( +
+
+ {label} + +
+
{value}
+
{hint}
+
+ ); +} + +function MiniBars({ points }: { points: AdminLeaderboardTrendPoint[] }) { + const max = Math.max(1, ...points.map((p) => p.runs)); + return ( +
+ {points.length === 0 ? ( +
+ 暂无趋势数据 +
+ ) : ( + points.slice(-30).map((p) => ( +
+ )) + )} +
+ ); +} + +function UserRankList({ + users, + activeUserId, + onSelect, + tab, +}: { + users: AdminLeaderboardUser[]; + activeUserId: string | null; + onSelect: (user: AdminLeaderboardUser) => void; + tab: UserRankTab; +}) { + if (users.length === 0) { + return
暂无用户统计
; + } + + return ( +
+ {users.map((user, index) => { + const main = + tab === "questions" + ? `${fmtNumber(user.qa_count)} 次问答` + : tab === "tokens" + ? `${fmtNumber(user.total_tokens)} tokens` + : `${fmtNumber(scoreOf(user))} 次使用`; + return ( + + ); + })} +
+ ); +} + +function RankingTable({ + title, + icon: Icon, + items, + empty, +}: { + title: string; + icon: LucideIcon; + items: AdminLeaderboardSkill[]; + empty: string; +}) { + return ( +
+
+ +

{title}

+
+
+ {items.length === 0 && ( +
{empty}
+ )} + {items.map((item, index) => ( +
+ {index + 1} +
+
{item.name}
+
+ {fmtNumber(item.user_count)} 位用户 · 平均 {fmtNumber(item.avg_duration_ms)} ms · 最近 {fmtTime(item.last_used_at)} +
+
+
+
{fmtNumber(item.call_count)}
+
+ 成功 {fmtNumber(item.success_count)} / 失败 {fmtNumber(item.error_count)} +
+
+
+ ))} +
+
+ ); +} + +function QuestionList({ + title, + items, + showUser = true, +}: { + title: string; + items: AdminLeaderboardQuestion[]; + showUser?: boolean; +}) { + return ( +
+
+ +

{title}

+
+
+ {items.length === 0 && ( +
暂无问题记录
+ )} + {items.map((item, index) => ( +
+
{clampText(item.text, 180)}
+
+ {showUser && {item.email || item.user_id || "未知用户"}} + {fmtTime(item.asked_at)} + {item.thread_id && 会话 {item.thread_id.slice(0, 8)}} +
+
+ ))} +
+
+ ); +} + +function UserDetailPanel({ + userId, + days, + onOpenAudit, +}: { + userId: string | null; + days: number; + onOpenAudit: (userId: string) => void; +}) { + const { data, isLoading } = useUserAnalytics(userId, days, 12); + + if (!userId) { + return ( +
+ 点击排行榜中的用户查看个人统计 +
+ ); + } + + if (isLoading || !data) { + return ( +
+ + 加载用户详情 +
+ ); + } + + return ( + +
+
+
+
+
{data.user.email || data.user.user_id}
+
最近活跃 {fmtTime(data.user.last_active_at)}
+
+ +
+
+ + + + +
+
+ +
+
+ + 个人使用趋势 +
+ +
+ + + +
+
+ ); +} + +function Metric({ label, value }: { label: string; value: number }) { + return ( +
+
{label}
+
{fmtNumber(value)}
+
+ ); +} + +export default function AdminUserLeaderboardPage() { + const navigate = useNavigate(); + const [days, setDays] = useState(30); + const [tab, setTab] = useState("activity"); + const [query, setQuery] = useState(""); + const [selectedUserId, setSelectedUserId] = useState(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 ( +
+
+
+
+
+

用户排行榜

+

+ 汇总用户活跃、问答、Token、技能、工具和近期问题,按时间范围统计。 +

+
+ +
+
+ + +
+ {isLoading && ( +
+ + 加载统计数据 +
+ )} + {!isLoading && error && ( +
+ 加载失败:{error.message} +
+ )} + {!isLoading && data && overview && ( + <> +
+ + + + +
+ +
+
+
+
+ +

用户排行

+
+
+
+ + setQuery(event.target.value)} + placeholder="搜索用户" + className="h-9 w-52 pl-8" + /> +
+ setTab(value as UserRankTab)}> + + 使用 + 问答 + 消耗 + + +
+
+ setSelectedUserId(user.user_id)} + /> +
+ +
+
+ +

用户详情

+
+
+ { + navigate(`/page/strategy/admin/user-activity?user=${encodeURIComponent(userId)}`); + }} + /> +
+
+
+ +
+
+ + 时间趋势 +
+ +
+ +
+ + +
+ +
+ + +
+ + )} +
+
+
+
+ ); +} diff --git a/frontend-web/src/pages/StrategyRoutes.tsx b/frontend-web/src/pages/StrategyRoutes.tsx index f49d0805..88c3eedf 100644 --- a/frontend-web/src/pages/StrategyRoutes.tsx +++ b/frontend-web/src/pages/StrategyRoutes.tsx @@ -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() { ) } /> + + ) : ( + + ) + } + /> 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 ────────────────────────────────────────────────────────────────