enhance leaderboard reporting and grouping
This commit is contained in:
1 parent
88c83b4dd7
commit
a5897caec4
4 files changed
+386
-182
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user