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[];
|
trends: AdminLeaderboardTrendPoint[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AdminLeaderboardReportResponse {
|
||||||
|
report: string;
|
||||||
|
generated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
const base = () => `${getBackendBaseURL()}/api/admin/users`;
|
const base = () => `${getBackendBaseURL()}/api/admin/users`;
|
||||||
|
|
||||||
async function parse<T>(res: Response, what: string): Promise<T> {
|
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(
|
export async function addAdminMemoryEntry(
|
||||||
userId: string,
|
userId: string,
|
||||||
content: string,
|
content: string,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
addAdminV1Fact,
|
addAdminV1Fact,
|
||||||
deleteAdminMemoryEntry,
|
deleteAdminMemoryEntry,
|
||||||
deleteAdminV1Fact,
|
deleteAdminV1Fact,
|
||||||
|
generateUserLeaderboardReport,
|
||||||
getUserMemory,
|
getUserMemory,
|
||||||
getUserAnalytics,
|
getUserAnalytics,
|
||||||
getUserLeaderboard,
|
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. */
|
/** Threads owned by a given user. Disabled until a user is picked. */
|
||||||
export function useUserThreads(userId: string | null) {
|
export function useUserThreads(userId: string | null) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ import {
|
|||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
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 {
|
import type {
|
||||||
AdminLeaderboardDimension,
|
AdminLeaderboardDimension,
|
||||||
AdminLeaderboardOverview,
|
AdminLeaderboardOverview,
|
||||||
@@ -49,7 +49,7 @@ import type {
|
|||||||
AdminLeaderboardTrendPoint,
|
AdminLeaderboardTrendPoint,
|
||||||
AdminLeaderboardUser,
|
AdminLeaderboardUser,
|
||||||
} from "@/core/admin/api";
|
} from "@/core/admin/api";
|
||||||
import { useUserAnalytics, useUserLeaderboard } from "@/core/admin/hooks";
|
import { useGenerateUserLeaderboardReport, useUserAnalytics, useUserLeaderboard } from "@/core/admin/hooks";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
type UserRankTab = "activity" | "questions" | "tokens" | "skills" | "efficiency";
|
type UserRankTab = "activity" | "questions" | "tokens" | "skills" | "efficiency";
|
||||||
@@ -189,37 +189,36 @@ function exportUsersCsv(users: AdminLeaderboardUser[], tab: UserRankTab) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function exportReport(data: {
|
function parseGroupRules(value: string): string[] {
|
||||||
overview: AdminLeaderboardOverview;
|
return value
|
||||||
users: AdminLeaderboardUser[];
|
.split(/[\n,,;;\s]+/)
|
||||||
skills: AdminLeaderboardSkill[];
|
.map((item) => item.trim())
|
||||||
models: AdminLeaderboardDimension[];
|
.filter(Boolean);
|
||||||
questions: AdminLeaderboardQuestion[];
|
}
|
||||||
range: RangeState;
|
|
||||||
}) {
|
function userGroupKey(user: AdminLeaderboardUser, rules: string[]): string {
|
||||||
const lines = [
|
const source = `${user.email || ""} ${user.user_id || ""}`.toLowerCase();
|
||||||
`用户排行榜统计报告`,
|
const matched = rules.find((rule) => source.includes(rule.toLowerCase()));
|
||||||
`时间范围:${data.range.since} 至 ${data.range.until}`,
|
return matched || "其他";
|
||||||
``,
|
}
|
||||||
`总用户:${fmtNumber(data.overview.total_users)}`,
|
|
||||||
`活跃用户:${fmtNumber(data.overview.active_users)}`,
|
function buildGroupStats(users: AdminLeaderboardUser[], rules: string[]) {
|
||||||
`使用次数:${fmtNumber(data.overview.total_runs)}`,
|
const groups = new Map<string, AdminLeaderboardUser[]>();
|
||||||
`问答次数:${fmtNumber(data.overview.total_questions)}`,
|
for (const user of users) {
|
||||||
`Token:${fmtNumber(data.overview.total_tokens)}`,
|
const key = userGroupKey(user, rules);
|
||||||
``,
|
groups.set(key, [...(groups.get(key) || []), user]);
|
||||||
`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)}`),
|
return [...groups.entries()]
|
||||||
``,
|
.map(([name, members]) => ({
|
||||||
`Top 技能`,
|
name,
|
||||||
...data.skills.slice(0, 10).map((item, index) => `${index + 1}. ${item.name} - ${fmtNumber(item.call_count)} 次`),
|
members: members.sort((a, b) => userScore(b, "activity") - userScore(a, "activity")),
|
||||||
``,
|
userCount: members.length,
|
||||||
`Top 模型`,
|
runs: members.reduce((sum, user) => sum + user.run_count, 0),
|
||||||
...data.models.slice(0, 10).map((item, index) => `${index + 1}. ${item.name} - ${fmtNumber(item.count)} 次 / Token ${fmtNumber(item.total_tokens)}`),
|
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),
|
||||||
...data.questions.slice(0, 10).map((item, index) => `${index + 1}. ${item.text}`),
|
}))
|
||||||
];
|
.sort((a, b) => b.runs + b.questions - (a.runs + a.questions));
|
||||||
downloadText(`user-leaderboard-report-${new Date().toISOString().slice(0, 10)}.txt`, lines.join("\n"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function initials(user: AdminLeaderboardUser): string {
|
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({
|
function PodiumCard({
|
||||||
user,
|
user,
|
||||||
rank,
|
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[] }) {
|
function TrendBars({ points }: { points: AdminLeaderboardTrendPoint[] }) {
|
||||||
type TrendMetric = keyof Pick<AdminLeaderboardTrendPoint, "runs" | "questions" | "users" | "tokens">;
|
type TrendMetric = keyof Pick<AdminLeaderboardTrendPoint, "runs" | "questions" | "users" | "tokens">;
|
||||||
const [metric, setMetric] = useState<"all" | TrendMetric>("all");
|
const [metric, setMetric] = useState<"all" | TrendMetric>("all");
|
||||||
@@ -910,20 +881,34 @@ function UserDetailPanel({
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="rounded-lg border border-[#F1D6D6] bg-white p-4 dark:border-[#462123] dark:bg-[#230D0F]">
|
<Tabs defaultValue="skills" className="space-y-3">
|
||||||
<div className="mb-3 flex items-center gap-2 font-semibold text-[#231815] dark:text-white">
|
<TabsList className="mb-3">
|
||||||
<Layers3 className="size-4 text-[#C1121F]" />
|
<TabsTrigger value="skills">技能</TabsTrigger>
|
||||||
个人维度
|
<TabsTrigger value="questions">提问</TabsTrigger>
|
||||||
</div>
|
<TabsTrigger value="trend">趋势</TabsTrigger>
|
||||||
<div className="grid grid-cols-3 gap-2 text-sm">
|
<TabsTrigger value="dimensions">维度</TabsTrigger>
|
||||||
<Metric label="模型数" value={data.models.length} />
|
</TabsList>
|
||||||
<Metric label="Agent 数" value={data.agents.length} />
|
<TabsContent value="skills" className="min-h-[420px]">
|
||||||
<Metric label="状态数" value={data.run_statuses.length} />
|
<SkillRanking title="个人常用技能" icon={Sparkles} items={data.skills.length ? data.skills : data.tools} empty="暂无技能调用" />
|
||||||
</div>
|
</TabsContent>
|
||||||
</section>
|
<TabsContent value="questions" className="min-h-[420px]">
|
||||||
|
<QuestionPanel title="最近提问" items={data.recent_questions} showUser={false} />
|
||||||
<SkillRanking title="个人常用技能" icon={Sparkles} items={data.skills.length ? data.skills : data.tools} empty="暂无技能调用" />
|
</TabsContent>
|
||||||
<QuestionPanel title="最近提问" items={data.recent_questions} showUser={false} />
|
<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>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
);
|
);
|
||||||
@@ -934,12 +919,15 @@ export default function AdminUserLeaderboardPage() {
|
|||||||
const [range, setRange] = useState<RangeState>(() => defaultRange(30));
|
const [range, setRange] = useState<RangeState>(() => defaultRange(30));
|
||||||
const [tab, setTab] = useState<UserRankTab>("activity");
|
const [tab, setTab] = useState<UserRankTab>("activity");
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
|
const [groupRules, setGroupRules] = useState("zero,two");
|
||||||
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
|
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
|
||||||
const apiRange = useMemo(
|
const apiRange = useMemo(
|
||||||
() => rangeForApi(range),
|
() => rangeForApi(range),
|
||||||
[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 rankedUsers = useMemo(() => {
|
||||||
const base = data?.users_by_activity ?? [];
|
const base = data?.users_by_activity ?? [];
|
||||||
@@ -1006,10 +994,11 @@ export default function AdminUserLeaderboardPage() {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => navigate("/page/strategy/admin/user-leaderboard/settings")}
|
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>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -1021,31 +1010,74 @@ export default function AdminUserLeaderboardPage() {
|
|||||||
<FileDown className="mr-1 size-4" />
|
<FileDown className="mr-1 size-4" />
|
||||||
导出 Excel
|
导出 Excel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Dialog open={reportOpen} onOpenChange={setReportOpen}>
|
||||||
variant="outline"
|
<DialogTrigger asChild>
|
||||||
size="sm"
|
<Button
|
||||||
disabled={!data || !overview}
|
variant="outline"
|
||||||
onClick={() => {
|
size="sm"
|
||||||
if (!data || !overview) return;
|
disabled={!data || reportMutation.isPending}
|
||||||
exportReport({
|
onClick={() => reportMutation.mutate({ range: apiRange, limit: 40 })}
|
||||||
overview,
|
className="h-9"
|
||||||
users: rankedUsers,
|
>
|
||||||
skills: data.skills,
|
{reportMutation.isPending ? (
|
||||||
models: data.models,
|
<Loader2 className="mr-1 size-4 animate-spin" />
|
||||||
questions: data.recent_questions,
|
) : (
|
||||||
range,
|
<Sparkles className="mr-1 size-4" />
|
||||||
});
|
)}
|
||||||
}}
|
生成报告
|
||||||
className="h-9"
|
</Button>
|
||||||
>
|
</DialogTrigger>
|
||||||
<FileDown className="mr-1 size-4" />
|
<DialogContent className="max-w-[min(920px,calc(100vw-2rem))]">
|
||||||
导出报告
|
<DialogHeader>
|
||||||
</Button>
|
<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">
|
<div className="flex flex-col gap-1">
|
||||||
<label className="text-xs text-[#5F6368] dark:text-slate-400">开始日期</label>
|
<label className="text-xs text-[#5F6368] dark:text-slate-400">开始日期</label>
|
||||||
<Input
|
<Input
|
||||||
type="date"
|
type="date"
|
||||||
value={range.since}
|
value={range.since}
|
||||||
|
onClick={(event) => event.currentTarget.showPicker?.()}
|
||||||
|
onFocus={(event) => event.currentTarget.showPicker?.()}
|
||||||
onChange={(event) => setRange((prev) => ({ ...prev, since: event.target.value }))}
|
onChange={(event) => setRange((prev) => ({ ...prev, since: event.target.value }))}
|
||||||
className="h-9 w-36 bg-white dark:bg-[#0B2A50]"
|
className="h-9 w-36 bg-white dark:bg-[#0B2A50]"
|
||||||
/>
|
/>
|
||||||
@@ -1055,6 +1087,8 @@ export default function AdminUserLeaderboardPage() {
|
|||||||
<Input
|
<Input
|
||||||
type="date"
|
type="date"
|
||||||
value={range.until}
|
value={range.until}
|
||||||
|
onClick={(event) => event.currentTarget.showPicker?.()}
|
||||||
|
onFocus={(event) => event.currentTarget.showPicker?.()}
|
||||||
onChange={(event) => setRange((prev) => ({ ...prev, until: event.target.value }))}
|
onChange={(event) => setRange((prev) => ({ ...prev, until: event.target.value }))}
|
||||||
className="h-9 w-36 bg-white dark:bg-[#0B2A50]"
|
className="h-9 w-36 bg-white dark:bg-[#0B2A50]"
|
||||||
/>
|
/>
|
||||||
@@ -1097,10 +1131,11 @@ export default function AdminUserLeaderboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
<InsightStrip overview={overview} />
|
<InsightStrip overview={overview} />
|
||||||
|
|
||||||
<SpotlightCarousel
|
<GroupComparison
|
||||||
users={rankedUsers}
|
users={rankedUsers}
|
||||||
tab={tab}
|
rulesText={groupRules}
|
||||||
onSelect={(user) => setSelectedUserId(user.user_id)}
|
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]">
|
<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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
|
from difflib import SequenceMatcher
|
||||||
|
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Query, Request
|
from fastapi import APIRouter, HTTPException, Query, Request
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from sqlalchemy import and_, case, desc, func, or_, select
|
from sqlalchemy import and_, case, desc, func, or_, select
|
||||||
|
from langchain_core.messages import HumanMessage, SystemMessage
|
||||||
|
|
||||||
from app.gateway.deps import (
|
from app.gateway.deps import (
|
||||||
get_agent_store,
|
get_agent_store,
|
||||||
@@ -31,6 +34,7 @@ from app.gateway.deps import (
|
|||||||
from deerflow.agents.memory import BuiltinFileProvider
|
from deerflow.agents.memory import BuiltinFileProvider
|
||||||
from deerflow.config.paths import get_paths
|
from deerflow.config.paths import get_paths
|
||||||
from deerflow.config.system_settings import load_system_settings
|
from deerflow.config.system_settings import load_system_settings
|
||||||
|
from deerflow.models import create_chat_model
|
||||||
from deerflow.runtime import serialize_channel_values
|
from deerflow.runtime import serialize_channel_values
|
||||||
from app.gateway.routers.memory import _build_v1_provider, _is_v1
|
from app.gateway.routers.memory import _build_v1_provider, _is_v1
|
||||||
from deerflow.persistence.engine import get_session_factory
|
from deerflow.persistence.engine import get_session_factory
|
||||||
@@ -158,6 +162,11 @@ class AdminUserAnalyticsResponse(BaseModel):
|
|||||||
trends: list[AdminLeaderboardTrendPoint] = Field(default_factory=list)
|
trends: list[AdminLeaderboardTrendPoint] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class AdminLeaderboardReportResponse(BaseModel):
|
||||||
|
report: str
|
||||||
|
generated_at: str
|
||||||
|
|
||||||
|
|
||||||
def _now() -> datetime:
|
def _now() -> datetime:
|
||||||
return datetime.now()
|
return datetime.now()
|
||||||
|
|
||||||
@@ -215,6 +224,24 @@ def _is_displayable_question(value: str | None) -> bool:
|
|||||||
return lowered not in {"uploaded_files", "attachments", "files"}
|
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):
|
def _run_time_filter(since: datetime, until: datetime):
|
||||||
return and_(RunRow.created_at >= since, RunRow.created_at <= until)
|
return and_(RunRow.created_at >= since, RunRow.created_at <= until)
|
||||||
|
|
||||||
@@ -640,17 +667,14 @@ async def _query_frequent_questions(
|
|||||||
stmt = (
|
stmt = (
|
||||||
select(
|
select(
|
||||||
RunRow.first_human_message,
|
RunRow.first_human_message,
|
||||||
func.count(RunRow.run_id).label("count"),
|
RunRow.user_id,
|
||||||
func.max(RunRow.user_id).label("user_id"),
|
UserRow.email,
|
||||||
func.max(UserRow.email).label("email"),
|
RunRow.created_at,
|
||||||
func.max(RunRow.created_at).label("asked_at"),
|
|
||||||
)
|
)
|
||||||
.outerjoin(UserRow, UserRow.id == RunRow.user_id)
|
.outerjoin(UserRow, UserRow.id == RunRow.user_id)
|
||||||
.where(_run_time_filter(since, until), RunRow.first_human_message.is_not(None), RunRow.first_human_message != "")
|
.where(_run_time_filter(since, until), RunRow.first_human_message.is_not(None), RunRow.first_human_message != "")
|
||||||
.group_by(RunRow.first_human_message)
|
.order_by(RunRow.created_at.desc())
|
||||||
.having(func.count(RunRow.run_id) > 1)
|
.limit(min(max(limit * 20, 200), 1000))
|
||||||
.order_by(desc("count"), desc("asked_at"))
|
|
||||||
.limit(limit)
|
|
||||||
)
|
)
|
||||||
stmt = _apply_run_scope(
|
stmt = _apply_run_scope(
|
||||||
stmt,
|
stmt,
|
||||||
@@ -662,17 +686,39 @@ async def _query_frequent_questions(
|
|||||||
include_scheduled=include_scheduled,
|
include_scheduled=include_scheduled,
|
||||||
)
|
)
|
||||||
rows = (await session.execute(stmt)).all()
|
rows = (await session.execute(stmt)).all()
|
||||||
questions: list[AdminLeaderboardQuestion] = []
|
groups: list[dict] = []
|
||||||
for r in rows:
|
for r in rows:
|
||||||
text = _clean_question_text(r[0]) if cleanup_system_questions else str(r[0] or "").strip()
|
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):
|
if cleanup_system_questions and not _is_displayable_question(text):
|
||||||
continue
|
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(
|
questions.append(
|
||||||
AdminLeaderboardQuestion(
|
AdminLeaderboardQuestion(
|
||||||
text=text[:500],
|
text=text,
|
||||||
user_id=r[2] or "",
|
user_id=group["user_id"],
|
||||||
email=r[3] or "",
|
email=group["email"],
|
||||||
asked_at=_iso(r[4]),
|
asked_at=_iso(group.get("asked_at")),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return questions
|
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)
|
@router.get("/{user_id}/analytics", response_model=AdminUserAnalyticsResponse)
|
||||||
async def get_user_analytics(
|
async def get_user_analytics(
|
||||||
user_id: str,
|
user_id: str,
|
||||||
|
|||||||
Reference in new issue
Block a user