enhance leaderboard reporting and grouping

This commit is contained in:
Administrator committed 2026-06-05 00:02:05 +08:00
1 parent 88c83b4dd7
commit a5897caec4
4 files changed
+386 -182

No files matched your search

+23
View File
@@ -133,6 +133,11 @@ export interface AdminUserAnalyticsResponse {
trends: AdminLeaderboardTrendPoint[];
}
export interface AdminLeaderboardReportResponse {
report: string;
generated_at: string;
}
const base = () => `${getBackendBaseURL()}/api/admin/users`;
async function parse<T>(res: Response, what: string): Promise<T> {
@@ -208,6 +213,24 @@ export async function getUserAnalytics(
);
}
export async function generateUserLeaderboardReport(
range: { since: string; until: string; days?: number },
limit = 30,
): Promise<AdminLeaderboardReportResponse> {
const params = new URLSearchParams({
days: String(range.days ?? 30),
since: range.since,
until: range.until,
limit: String(limit),
});
return parse(
await apiFetch(`${base()}/leaderboard/report?${params}`, {
method: "POST",
}),
"Failed to generate leaderboard report",
);
}
export async function addAdminMemoryEntry(
userId: string,
content: string,
+13
View File
@@ -5,6 +5,7 @@ import {
addAdminV1Fact,
deleteAdminMemoryEntry,
deleteAdminV1Fact,
generateUserLeaderboardReport,
getUserMemory,
getUserAnalytics,
getUserLeaderboard,
@@ -47,6 +48,18 @@ export function useUserAnalytics(
});
}
export function useGenerateUserLeaderboardReport() {
return useMutation({
mutationFn: ({
range,
limit,
}: {
range: { since: string; until: string; days?: number };
limit?: number;
}) => generateUserLeaderboardReport(range, limit ?? 30),
});
}
/** Threads owned by a given user. Disabled until a user is picked. */
export function useUserThreads(userId: string | null) {
return useQuery({
@@ -40,7 +40,7 @@ import {
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import type {
AdminLeaderboardDimension,
AdminLeaderboardOverview,
@@ -49,7 +49,7 @@ import type {
AdminLeaderboardTrendPoint,
AdminLeaderboardUser,
} from "@/core/admin/api";
import { useUserAnalytics, useUserLeaderboard } from "@/core/admin/hooks";
import { useGenerateUserLeaderboardReport, useUserAnalytics, useUserLeaderboard } from "@/core/admin/hooks";
import { cn } from "@/lib/utils";
type UserRankTab = "activity" | "questions" | "tokens" | "skills" | "efficiency";
@@ -189,37 +189,36 @@ function exportUsersCsv(users: AdminLeaderboardUser[], tab: UserRankTab) {
);
}
function exportReport(data: {
overview: AdminLeaderboardOverview;
users: AdminLeaderboardUser[];
skills: AdminLeaderboardSkill[];
models: AdminLeaderboardDimension[];
questions: AdminLeaderboardQuestion[];
range: RangeState;
}) {
const lines = [
`用户排行榜统计报告`,
`时间范围:${data.range.since} 至 ${data.range.until}`,
``,
`总用户:${fmtNumber(data.overview.total_users)}`,
`活跃用户:${fmtNumber(data.overview.active_users)}`,
`使用次数:${fmtNumber(data.overview.total_runs)}`,
`问答次数:${fmtNumber(data.overview.total_questions)}`,
`Token:${fmtNumber(data.overview.total_tokens)}`,
``,
`Top 用户`,
...data.users.slice(0, 10).map((user, index) => `${index + 1}. ${user.email || user.user_id} - 使用 ${fmtNumber(user.run_count)} / 问答 ${fmtNumber(user.qa_count)} / Token ${fmtNumber(user.total_tokens)}`),
``,
`Top 技能`,
...data.skills.slice(0, 10).map((item, index) => `${index + 1}. ${item.name} - ${fmtNumber(item.call_count)} 次`),
``,
`Top 模型`,
...data.models.slice(0, 10).map((item, index) => `${index + 1}. ${item.name} - ${fmtNumber(item.count)} 次 / Token ${fmtNumber(item.total_tokens)}`),
``,
`近期问题`,
...data.questions.slice(0, 10).map((item, index) => `${index + 1}. ${item.text}`),
];
downloadText(`user-leaderboard-report-${new Date().toISOString().slice(0, 10)}.txt`, lines.join("\n"));
function parseGroupRules(value: string): string[] {
return value
.split(/[\n,,;;\s]+/)
.map((item) => item.trim())
.filter(Boolean);
}
function userGroupKey(user: AdminLeaderboardUser, rules: string[]): string {
const source = `${user.email || ""} ${user.user_id || ""}`.toLowerCase();
const matched = rules.find((rule) => source.includes(rule.toLowerCase()));
return matched || "其他";
}
function buildGroupStats(users: AdminLeaderboardUser[], rules: string[]) {
const groups = new Map<string, AdminLeaderboardUser[]>();
for (const user of users) {
const key = userGroupKey(user, rules);
groups.set(key, [...(groups.get(key) || []), user]);
}
return [...groups.entries()]
.map(([name, members]) => ({
name,
members: members.sort((a, b) => userScore(b, "activity") - userScore(a, "activity")),
userCount: members.length,
runs: members.reduce((sum, user) => sum + user.run_count, 0),
questions: members.reduce((sum, user) => sum + user.qa_count, 0),
tokens: members.reduce((sum, user) => sum + user.total_tokens, 0),
skills: members.reduce((sum, user) => sum + user.skill_call_count, 0),
}))
.sort((a, b) => b.runs + b.questions - (a.runs + a.questions));
}
function initials(user: AdminLeaderboardUser): string {
@@ -282,6 +281,73 @@ function InsightStrip({ overview }: { overview: AdminLeaderboardOverview }) {
);
}
function GroupComparison({
users,
rulesText,
onRulesChange,
onSelectUser,
}: {
users: AdminLeaderboardUser[];
rulesText: string;
onRulesChange: (value: string) => void;
onSelectUser: (user: AdminLeaderboardUser) => void;
}) {
const rules = parseGroupRules(rulesText);
const groups = buildGroupStats(users, rules);
return (
<section className="rounded-lg border border-[#F1D6D6] bg-white p-4 shadow-sm dark:border-[#462123] dark:bg-[#230D0F]">
<div className="mb-4 flex flex-wrap items-start justify-between gap-3">
<div>
<div className="flex items-center gap-2 font-semibold text-[#231815] dark:text-white">
<Users className="size-4 text-[#C1121F]" />
分组统计
</div>
<p className="mt-1 text-sm text-[#7A5A5A] dark:text-[#D8B8B8]">
输入账号前缀或关键字,用逗号分隔,例如 zero,two;未命中的用户归入其他。
</p>
</div>
<Input
value={rulesText}
onChange={(event) => onRulesChange(event.target.value)}
placeholder="zero,two"
className="h-9 w-72 border-[#F1D6D6] bg-white dark:bg-[#19090A]"
/>
</div>
<div className="grid gap-3 xl:grid-cols-3">
{groups.map((group) => (
<div key={group.name} className="rounded-lg border border-[#F1D6D6] bg-[#FFF7F7] p-3 dark:border-[#462123] dark:bg-[#19090A]">
<div className="mb-3 flex items-center justify-between gap-2">
<div className="font-semibold text-[#231815] dark:text-white">{group.name}</div>
<Badge variant="outline">{group.userCount} 人</Badge>
</div>
<div className="grid grid-cols-4 gap-2 text-xs">
<Metric label="运行" value={group.runs} />
<Metric label="问答" value={group.questions} />
<Metric label="技能" value={group.skills} />
<Metric label="Token" value={group.tokens} />
</div>
<div className="mt-3 space-y-1.5">
{group.members.slice(0, 5).map((user, index) => (
<button
key={user.user_id}
type="button"
onClick={() => onSelectUser(user)}
className="grid w-full grid-cols-[24px_minmax(0,1fr)_64px] items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs hover:bg-white dark:hover:bg-[#230D0F]"
>
<span className="font-semibold text-[#C1121F]">{index + 1}</span>
<span className="truncate">{user.email || user.user_id}</span>
<span className="text-right tabular-nums">{fmtNumber(userScore(user, "activity"))}</span>
</button>
))}
</div>
</div>
))}
</div>
</section>
);
}
function PodiumCard({
user,
rank,
@@ -342,101 +408,6 @@ function PodiumCard({
);
}
function SpotlightCarousel({
users,
tab,
onSelect,
}: {
users: AdminLeaderboardUser[];
tab: UserRankTab;
onSelect: (user: AdminLeaderboardUser) => void;
}) {
const [index, setIndex] = useState(0);
const visible = users.slice(0, 5);
const active = visible[index % Math.max(visible.length, 1)];
useEffect(() => {
if (visible.length <= 1) return;
const timer = window.setInterval(() => {
setIndex((value) => (value + 1) % visible.length);
}, 5000);
return () => window.clearInterval(timer);
}, [visible.length]);
useEffect(() => {
if (index >= visible.length) setIndex(0);
}, [index, visible.length]);
if (!active) {
return (
<section className="rounded-lg border border-dashed border-[#DADCE0] bg-white p-5 dark:border-[#213D5E] dark:bg-[#06213F]">
<div className="text-sm text-[#5F6368]">暂无可轮播用户</div>
</section>
);
}
const maxScore = Math.max(1, ...visible.map((user) => userScore(user, tab)));
return (
<section className="rounded-lg border border-[#F1D6D6] bg-white p-4 shadow-sm dark:border-[#462123] dark:bg-[#230D0F]">
<div className="mb-3 flex items-center justify-between gap-3">
<div className="flex items-center gap-2 font-semibold text-[#231815] dark:text-white">
<Crown className="size-4 text-[#C1121F]" />
榜单焦点
</div>
<span className="text-xs text-[#8B6B6B]">自动轮播 Top 5</span>
</div>
<div className="grid gap-3 lg:grid-cols-[260px_minmax(0,1fr)]">
<button
type="button"
onClick={() => onSelect(active)}
className="rounded-lg border border-[#F1D6D6] bg-[#FFF7F7] p-4 text-left transition hover:border-[#C1121F] dark:border-[#462123] dark:bg-[#19090A]"
>
<div className="flex items-center gap-3">
<div className="flex size-11 items-center justify-center rounded-lg bg-[#C1121F] text-base font-semibold text-white">
{initials(active)}
</div>
<div className="min-w-0">
<div className="truncate font-semibold text-[#231815] dark:text-white">{active.email || active.user_id}</div>
<div className="mt-1 text-sm font-medium text-[#C1121F]">{scoreLabel(active, tab)}</div>
</div>
</div>
<div className="mt-3 h-2 overflow-hidden rounded-full bg-[#F1D6D6] dark:bg-[#462123]">
<div
className="h-full rounded-full bg-[#C1121F]"
style={{ width: `${Math.min(100, Math.max(8, (userScore(active, tab) / maxScore) * 100))}%` }}
/>
</div>
</button>
<div className="grid gap-2 md:grid-cols-2 xl:grid-cols-5">
{visible.map((user, itemIndex) => (
<button
key={user.user_id}
type="button"
onClick={() => {
setIndex(itemIndex);
onSelect(user);
}}
className={cn(
"min-w-0 rounded-lg border px-3 py-2 text-left transition hover:border-[#C1121F]",
itemIndex === index
? "border-[#C1121F] bg-[#FFF1F1] dark:bg-[#3A1215]"
: "border-[#F1D6D6] bg-white dark:border-[#462123] dark:bg-[#230D0F]",
)}
>
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-semibold text-[#C1121F]">#{itemIndex + 1}</span>
<span className="text-xs font-medium tabular-nums text-[#7A5A5A] dark:text-[#D8B8B8]">{fmtNumber(userScore(user, tab))}</span>
</div>
<div className="mt-1 truncate text-sm font-medium text-[#231815] dark:text-white">{user.email || user.user_id}</div>
</button>
))}
</div>
</div>
</section>
);
}
function TrendBars({ points }: { points: AdminLeaderboardTrendPoint[] }) {
type TrendMetric = keyof Pick<AdminLeaderboardTrendPoint, "runs" | "questions" | "users" | "tokens">;
const [metric, setMetric] = useState<"all" | TrendMetric>("all");
@@ -910,20 +881,34 @@ function UserDetailPanel({
</div>
</section>
<section className="rounded-lg border border-[#F1D6D6] bg-white p-4 dark:border-[#462123] dark:bg-[#230D0F]">
<div className="mb-3 flex items-center gap-2 font-semibold text-[#231815] dark:text-white">
<Layers3 className="size-4 text-[#C1121F]" />
个人维度
</div>
<div className="grid grid-cols-3 gap-2 text-sm">
<Metric label="模型数" value={data.models.length} />
<Metric label="Agent 数" value={data.agents.length} />
<Metric label="状态数" value={data.run_statuses.length} />
</div>
</section>
<SkillRanking title="个人常用技能" icon={Sparkles} items={data.skills.length ? data.skills : data.tools} empty="暂无技能调用" />
<QuestionPanel title="最近提问" items={data.recent_questions} showUser={false} />
<Tabs defaultValue="skills" className="space-y-3">
<TabsList className="mb-3">
<TabsTrigger value="skills">技能</TabsTrigger>
<TabsTrigger value="questions">提问</TabsTrigger>
<TabsTrigger value="trend">趋势</TabsTrigger>
<TabsTrigger value="dimensions">维度</TabsTrigger>
</TabsList>
<TabsContent value="skills" className="min-h-[420px]">
<SkillRanking title="个人常用技能" icon={Sparkles} items={data.skills.length ? data.skills : data.tools} empty="暂无技能调用" />
</TabsContent>
<TabsContent value="questions" className="min-h-[420px]">
<QuestionPanel title="最近提问" items={data.recent_questions} showUser={false} />
</TabsContent>
<TabsContent value="trend" className="min-h-[420px]">
<div className="mb-3 flex items-center gap-2 font-semibold text-[#231815] dark:text-white">
<TrendingUp className="size-4 text-[#C1121F]" />
个人使用趋势
</div>
<TrendBars points={data.trends} />
</TabsContent>
<TabsContent value="dimensions" className="min-h-[420px]">
<div className="grid gap-3">
<DimensionRanking title="个人模型" icon={Layers3} items={data.models} empty="暂无模型调用" />
<DimensionRanking title="个人 Agent" icon={Bot} items={data.agents} empty="暂无 Agent 运行" />
<DimensionRanking title="个人状态" icon={Gauge} items={data.run_statuses} empty="暂无运行状态" />
</div>
</TabsContent>
</Tabs>
</div>
</ScrollArea>
);
@@ -934,12 +919,15 @@ export default function AdminUserLeaderboardPage() {
const [range, setRange] = useState<RangeState>(() => defaultRange(30));
const [tab, setTab] = useState<UserRankTab>("activity");
const [query, setQuery] = useState("");
const [groupRules, setGroupRules] = useState("zero,two");
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
const apiRange = useMemo(
() => rangeForApi(range),
[range],
);
const { data, isLoading, error } = useUserLeaderboard(apiRange, 30);
const { data, isLoading, error } = useUserLeaderboard(apiRange, 100);
const reportMutation = useGenerateUserLeaderboardReport();
const [reportOpen, setReportOpen] = useState(false);
const rankedUsers = useMemo(() => {
const base = data?.users_by_activity ?? [];
@@ -1006,10 +994,11 @@ export default function AdminUserLeaderboardPage() {
variant="outline"
size="sm"
onClick={() => navigate("/page/strategy/admin/user-leaderboard/settings")}
className="h-9"
className="h-9 w-9 px-0"
title="排行榜设置"
aria-label="排行榜设置"
>
<Settings2 className="mr-1 size-4" />
排行榜设置
<Settings2 className="size-4" />
</Button>
<Button
variant="outline"
@@ -1021,31 +1010,74 @@ export default function AdminUserLeaderboardPage() {
<FileDown className="mr-1 size-4" />
导出 Excel
</Button>
<Button
variant="outline"
size="sm"
disabled={!data || !overview}
onClick={() => {
if (!data || !overview) return;
exportReport({
overview,
users: rankedUsers,
skills: data.skills,
models: data.models,
questions: data.recent_questions,
range,
});
}}
className="h-9"
>
<FileDown className="mr-1 size-4" />
导出报告
</Button>
<Dialog open={reportOpen} onOpenChange={setReportOpen}>
<DialogTrigger asChild>
<Button
variant="outline"
size="sm"
disabled={!data || reportMutation.isPending}
onClick={() => reportMutation.mutate({ range: apiRange, limit: 40 })}
className="h-9"
>
{reportMutation.isPending ? (
<Loader2 className="mr-1 size-4 animate-spin" />
) : (
<Sparkles className="mr-1 size-4" />
)}
生成报告
</Button>
</DialogTrigger>
<DialogContent className="max-w-[min(920px,calc(100vw-2rem))]">
<DialogHeader>
<DialogTitle>AI 榜单分析报告</DialogTitle>
<DialogDescription>
基于当前时间段的问答、用户、技能、模型和趋势统计生成运营分析。
</DialogDescription>
</DialogHeader>
<ScrollArea className="max-h-[70vh] rounded-lg border border-[#F1D6D6] bg-[#FFF7F7] p-4 dark:border-[#462123] dark:bg-[#19090A]">
{reportMutation.isPending && (
<div className="flex items-center justify-center gap-2 py-12 text-sm text-[#7A5A5A] dark:text-[#D8B8B8]">
<Loader2 className="size-4 animate-spin" />
正在调用大模型生成报告
</div>
)}
{reportMutation.error && (
<div className="rounded-lg border border-[#FAD2CF] bg-[#FCE8E6] p-3 text-sm text-[#DB4437] dark:border-[#DB4437]/30 dark:bg-[#DB4437]/10 dark:text-[#F28B82]">
生成失败:{reportMutation.error.message}
</div>
)}
{reportMutation.data?.report && (
<pre className="whitespace-pre-wrap font-sans text-sm leading-7 text-[#231815] dark:text-white">
{reportMutation.data.report}
</pre>
)}
</ScrollArea>
{reportMutation.data?.report && (
<div className="flex justify-end">
<Button
variant="outline"
onClick={() =>
downloadText(
`user-leaderboard-ai-report-${new Date().toISOString().slice(0, 10)}.md`,
reportMutation.data.report,
"text/markdown;charset=utf-8",
)
}
>
<FileDown className="mr-1 size-4" />
下载报告
</Button>
</div>
)}
</DialogContent>
</Dialog>
<div className="flex flex-col gap-1">
<label className="text-xs text-[#5F6368] dark:text-slate-400">开始日期</label>
<Input
type="date"
value={range.since}
onClick={(event) => event.currentTarget.showPicker?.()}
onFocus={(event) => event.currentTarget.showPicker?.()}
onChange={(event) => setRange((prev) => ({ ...prev, since: event.target.value }))}
className="h-9 w-36 bg-white dark:bg-[#0B2A50]"
/>
@@ -1055,6 +1087,8 @@ export default function AdminUserLeaderboardPage() {
<Input
type="date"
value={range.until}
onClick={(event) => event.currentTarget.showPicker?.()}
onFocus={(event) => event.currentTarget.showPicker?.()}
onChange={(event) => setRange((prev) => ({ ...prev, until: event.target.value }))}
className="h-9 w-36 bg-white dark:bg-[#0B2A50]"
/>
@@ -1097,10 +1131,11 @@ export default function AdminUserLeaderboardPage() {
</div>
<InsightStrip overview={overview} />
<SpotlightCarousel
<GroupComparison
users={rankedUsers}
tab={tab}
onSelect={(user) => setSelectedUserId(user.user_id)}
rulesText={groupRules}
onRulesChange={setGroupRules}
onSelectUser={(user) => setSelectedUserId(user.user_id)}
/>
<section className="rounded-lg border border-[#DADCE0] bg-white p-4 shadow-sm dark:border-[#213D5E] dark:bg-[#06213F]">
@@ -13,14 +13,17 @@ methods' ``user_id`` parameter is designed for.
from __future__ import annotations
import json
import logging
import re
from difflib import SequenceMatcher
from datetime import datetime, timedelta
from fastapi import APIRouter, HTTPException, Query, Request
from pydantic import BaseModel, Field
from sqlalchemy import and_, case, desc, func, or_, select
from langchain_core.messages import HumanMessage, SystemMessage
from app.gateway.deps import (
get_agent_store,
@@ -31,6 +34,7 @@ from app.gateway.deps import (
from deerflow.agents.memory import BuiltinFileProvider
from deerflow.config.paths import get_paths
from deerflow.config.system_settings import load_system_settings
from deerflow.models import create_chat_model
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
@@ -158,6 +162,11 @@ class AdminUserAnalyticsResponse(BaseModel):
trends: list[AdminLeaderboardTrendPoint] = Field(default_factory=list)
class AdminLeaderboardReportResponse(BaseModel):
report: str
generated_at: str
def _now() -> datetime:
return datetime.now()
@@ -215,6 +224,24 @@ def _is_displayable_question(value: str | None) -> bool:
return lowered not in {"uploaded_files", "attachments", "files"}
def _question_similarity_key(value: str) -> str:
return re.sub(r"[\W_]+", "", value.lower())
def _message_content_to_text(content) -> str:
if isinstance(content, str):
return content
if isinstance(content, list):
parts: list[str] = []
for item in content:
if isinstance(item, dict):
parts.append(str(item.get("text") or item.get("content") or ""))
else:
parts.append(str(item))
return "\n".join(part for part in parts if part.strip())
return str(content or "")
def _run_time_filter(since: datetime, until: datetime):
return and_(RunRow.created_at >= since, RunRow.created_at <= until)
@@ -640,17 +667,14 @@ async def _query_frequent_questions(
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"),
RunRow.user_id,
UserRow.email,
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 != "")
.group_by(RunRow.first_human_message)
.having(func.count(RunRow.run_id) > 1)
.order_by(desc("count"), desc("asked_at"))
.limit(limit)
.order_by(RunRow.created_at.desc())
.limit(min(max(limit * 20, 200), 1000))
)
stmt = _apply_run_scope(
stmt,
@@ -662,17 +686,39 @@ async def _query_frequent_questions(
include_scheduled=include_scheduled,
)
rows = (await session.execute(stmt)).all()
questions: list[AdminLeaderboardQuestion] = []
groups: list[dict] = []
for r in rows:
text = _clean_question_text(r[0]) if cleanup_system_questions else str(r[0] or "").strip()
if cleanup_system_questions and not _is_displayable_question(text):
continue
key = _question_similarity_key(text)
if not key:
continue
target = None
for group in groups:
score = SequenceMatcher(None, key, group["key"]).ratio()
if score >= 0.72 or key in group["key"] or group["key"] in key:
target = group
break
if target is None:
groups.append({"key": key, "text": text, "count": 1, "user_id": r[1] or "", "email": r[2] or "", "asked_at": r[3]})
else:
target["count"] += 1
if r[3] and (target.get("asked_at") is None or r[3] > target["asked_at"]):
target.update({"text": text, "user_id": r[1] or "", "email": r[2] or "", "asked_at": r[3]})
groups.sort(key=lambda item: (item["count"], _iso(item.get("asked_at"))), reverse=True)
questions: list[AdminLeaderboardQuestion] = []
for group in groups[:limit]:
text = group["text"][:460]
if group["count"] > 1:
text = f"{text}(相似问题 {group['count']} 次)"
questions.append(
AdminLeaderboardQuestion(
text=text[:500],
user_id=r[2] or "",
email=r[3] or "",
asked_at=_iso(r[4]),
text=text,
user_id=group["user_id"],
email=group["email"],
asked_at=_iso(group.get("asked_at")),
)
)
return questions
@@ -866,6 +912,93 @@ async def get_user_leaderboard(
)
@router.post("/leaderboard/report", response_model=AdminLeaderboardReportResponse)
async def generate_user_leaderboard_report(
request: Request,
days: int = Query(default=30, ge=1, le=365),
since: str | None = Query(default=None, description="Inclusive range start, ISO datetime"),
until: str | None = Query(default=None, description="Inclusive range end, ISO datetime"),
limit: int = Query(default=30, ge=10, le=100),
) -> AdminLeaderboardReportResponse:
"""Generate an LLM-written admin report for the current leaderboard window."""
await _require_admin(request)
session_factory = get_session_factory()
if session_factory is None:
raise HTTPException(status_code=503, detail="Analytics requires database persistence")
settings = load_system_settings().leaderboard
since_dt, until_dt, _range_days = _resolve_range(days, since, until)
excluded = {uid for uid in settings.exclude_user_ids if uid}
common = {
"exclude_user_ids": excluded,
"include_scheduled": settings.include_scheduled,
"include_admins": settings.include_admins,
"include_failed": settings.include_failed,
}
tool_common = {
"exclude_user_ids": excluded,
"include_scheduled": settings.include_scheduled,
"include_admins": settings.include_admins,
}
question_common = {
**common,
"cleanup_system_questions": settings.cleanup_system_questions,
}
async with session_factory() as session:
overview = await _query_overview(session, since_dt, until_dt, **common)
users = await _query_user_leaderboard(session, since_dt, until_dt, **common)
skills = await _query_call_rankings(session, since_dt, until_dt, user_id=None, field="skill", limit=limit, **tool_common)
tools = await _query_call_rankings(session, since_dt, until_dt, user_id=None, field="tool", limit=limit, **tool_common)
models = await _query_model_rankings(session, since_dt, until_dt, user_id=None, limit=limit, **common)
recent_questions = await _query_recent_questions(session, since_dt, until_dt, user_id=None, limit=limit, **question_common)
frequent_questions = await _query_frequent_questions(session, since_dt, until_dt, limit=limit, **question_common)
trends = await _query_trends(session, since_dt, until_dt, **common)
top_users = sorted(users, key=lambda u: (u.run_count + u.tool_call_count, u.total_tokens), reverse=True)[:12]
context = {
"range": {"since": _iso(since_dt), "until": _iso(until_dt)},
"overview": overview.model_dump(),
"top_users": [u.model_dump() for u in top_users],
"top_skills": [s.model_dump() for s in skills[:12]],
"top_tools": [t.model_dump() for t in tools[:12]],
"top_models": [m.model_dump() for m in models[:12]],
"frequent_questions": [q.model_dump() for q in frequent_questions[:20]],
"recent_questions": [q.model_dump() for q in recent_questions[:30]],
"trends": [t.model_dump() for t in trends[-14:]],
}
prompt = (
"请基于以下管理员排行榜统计数据,生成一份中文运营分析报告。"
"报告需要有结论,不要只罗列数字。必须分析:"
"1) 谁使用次数最多、问答最多、Token 消耗最高;"
"2) 用户通常在问什么、相似问题聚类反映了哪些关注方向;"
"3) 当前所有用户关注的事件/主题/业务方向;"
"4) 从问题重复、失败率、追问密度等信号推断用户是否满意系统回答,注明这是推断;"
"5) 哪些技能/工具/模型最常用,可能说明什么;"
"6) 给管理员 3-5 条可执行建议。"
"输出结构:摘要、关键发现、用户与分组观察、问题主题、满意度推断、建议。"
"\n\n统计数据如下:\n"
f"{json.dumps(context, ensure_ascii=False, default=str)}"
)
try:
model = create_chat_model(name=None, thinking_enabled=False)
response = await model.ainvoke(
[
SystemMessage(content="你是企业 AI 系统的运营分析师,擅长从用户行为、问题文本和调用指标中生成管理报告。"),
HumanMessage(content=prompt),
],
config={"run_name": "admin_user_leaderboard_report"},
)
except Exception as exc:
logger.exception("Failed to generate leaderboard report")
raise HTTPException(status_code=503, detail=f"Report generation failed: {exc}") from exc
return AdminLeaderboardReportResponse(
report=_message_content_to_text(getattr(response, "content", response)).strip(),
generated_at=_iso(_now()),
)
@router.get("/{user_id}/analytics", response_model=AdminUserAnalyticsResponse)
async def get_user_analytics(
user_id: str,