feat: enhance leaderboard AI report
This commit is contained in:
1 parent
22be6325a8
commit
d59f9e218f
5 files changed
+129
-48
No files matched your search
@@ -269,14 +269,6 @@ export function PageSidebar({
|
||||
用户排行榜
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate("/page/strategy/admin/user-leaderboard/settings")}
|
||||
>
|
||||
<Settings className="size-4" />
|
||||
排行榜设置
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem onClick={() => openSettings("account")}>
|
||||
<User className="size-4" />
|
||||
个人信息
|
||||
|
||||
@@ -198,16 +198,6 @@ export function WorkspaceNavMenu() {
|
||||
用户排行榜
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
navigate("/page/strategy/admin/user-leaderboard/settings")
|
||||
}
|
||||
>
|
||||
<Settings2Icon />
|
||||
排行榜设置
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate("/page/workspace/admin")}
|
||||
|
||||
@@ -253,13 +253,6 @@ export const sidebarMenuItems: MenuItem[] = [
|
||||
path: "/page/strategy/admin/user-leaderboard",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
id: "mgmt-user-leaderboard-settings",
|
||||
label: "排行榜设置",
|
||||
icon: Settings,
|
||||
path: "/page/strategy/admin/user-leaderboard/settings",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
id: "mgmt-users",
|
||||
label: "用户管理",
|
||||
|
||||
@@ -42,10 +42,12 @@ import { Input } from "@/components/ui/input";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
|
||||
import type {
|
||||
AdminLeaderboardDimension,
|
||||
AdminLeaderboardOverview,
|
||||
AdminLeaderboardQuestion,
|
||||
AdminLeaderboardResponse,
|
||||
AdminLeaderboardSkill,
|
||||
AdminLeaderboardTrendPoint,
|
||||
AdminLeaderboardUser,
|
||||
@@ -53,7 +55,9 @@ import type {
|
||||
import { streamUserLeaderboardReport } from "@/core/admin/api";
|
||||
import { useUserAnalytics, useUserLeaderboard } from "@/core/admin/hooks";
|
||||
import { useModels } from "@/core/models/hooks";
|
||||
import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { toast } from "sonner";
|
||||
|
||||
type UserRankTab = "activity" | "questions" | "tokens" | "skills" | "efficiency";
|
||||
type RangeState = { since: string; until: string; days?: number };
|
||||
@@ -193,6 +197,39 @@ function exportUsersCsv(users: AdminLeaderboardUser[], tab: UserRankTab) {
|
||||
);
|
||||
}
|
||||
|
||||
function markdownList(items: string[]): string {
|
||||
return items.length ? items.map((item) => `- ${item}`).join("\n") : "- 暂无数据";
|
||||
}
|
||||
|
||||
function buildReportFocusMarkdown(data: AdminLeaderboardResponse | undefined, range: RangeState): string {
|
||||
if (!data) return "";
|
||||
const recentQuestions = data.recent_questions
|
||||
.slice(0, 8)
|
||||
.map((item) => `${clampText(item.text, 120)}(${fmtTime(item.asked_at)})`);
|
||||
const frequentQuestions = data.frequent_questions
|
||||
.slice(0, 8)
|
||||
.map((item) => clampText(item.text, 120));
|
||||
const frequentUsage = [
|
||||
...data.skills.slice(0, 5).map((item) => `技能:${item.name},${fmtNumber(item.call_count)} 次调用`),
|
||||
...data.tools.slice(0, 5).map((item) => `工具:${item.name},${fmtNumber(item.call_count)} 次调用`),
|
||||
];
|
||||
|
||||
return [
|
||||
"# AI 榜单分析报告",
|
||||
"",
|
||||
`统计周期:${range.since} 至 ${range.until}`,
|
||||
"",
|
||||
"## 最近关注",
|
||||
markdownList(recentQuestions),
|
||||
"",
|
||||
"## 高频问题",
|
||||
markdownList(frequentQuestions),
|
||||
"",
|
||||
"## 使用频率重点",
|
||||
markdownList(frequentUsage),
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function parseGroupRules(value: string): string[] {
|
||||
return value
|
||||
.split(/[\n,,;;\s]+/)
|
||||
@@ -1020,8 +1057,14 @@ export default function AdminUserLeaderboardPage() {
|
||||
const [reportStatus, setReportStatus] = useState<"idle" | "streaming" | "done" | "cached" | "error">("idle");
|
||||
const [reportGeneratedAt, setReportGeneratedAt] = useState<string | null>(null);
|
||||
const [reportResolvedModel, setReportResolvedModel] = useState<string | null>(null);
|
||||
const [reportExporting, setReportExporting] = useState(false);
|
||||
const reportAbortRef = useRef<AbortController | null>(null);
|
||||
const reportGenerating = reportStatus === "streaming";
|
||||
const reportFocusMarkdown = useMemo(() => buildReportFocusMarkdown(data, range), [data, range]);
|
||||
const reportWordMarkdown = useMemo(
|
||||
() => [reportFocusMarkdown, reportText.trim()].filter(Boolean).join("\n\n---\n\n"),
|
||||
[reportFocusMarkdown, reportText],
|
||||
);
|
||||
|
||||
const rankedUsers = useMemo(() => {
|
||||
const base = data?.users_by_activity ?? [];
|
||||
@@ -1066,6 +1109,26 @@ export default function AdminUserLeaderboardPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const exportReportAsWord = async () => {
|
||||
if (!reportWordMarkdown.trim()) {
|
||||
toast.error("暂无可导出的报告内容");
|
||||
return;
|
||||
}
|
||||
setReportExporting(true);
|
||||
try {
|
||||
await downloadMarkdownAsDocx(
|
||||
reportWordMarkdown,
|
||||
`user-leaderboard-ai-report-${new Date().toISOString().slice(0, 10)}`,
|
||||
"AI 榜单分析报告",
|
||||
);
|
||||
toast.success("Word 文件已生成");
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "导出 Word 失败");
|
||||
} finally {
|
||||
setReportExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const startReportGeneration = async () => {
|
||||
reportAbortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
@@ -1228,6 +1291,54 @@ export default function AdminUserLeaderboardPage() {
|
||||
{reportGenerating ? "生成中" : "生成报告"}
|
||||
</Button>
|
||||
</div>
|
||||
{data && (
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
<section className="rounded-lg border border-[#F1D6D6] bg-white p-3 dark:border-[#462123] dark:bg-[#230D0F]">
|
||||
<div className="mb-2 flex items-center gap-2 text-sm font-semibold text-[#231815] dark:text-white">
|
||||
<MessageSquareText className="size-4 text-[#C1121F]" />
|
||||
最近关注
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{data.recent_questions.slice(0, 3).map((item, index) => (
|
||||
<div key={`${item.run_id || item.text}-${index}`} className="text-xs leading-5 text-[#5F6368] dark:text-slate-300">
|
||||
<span className="font-medium text-[#C1121F]">{fmtTime(item.asked_at)}</span>
|
||||
<span className="ml-1">{clampText(item.text, 58)}</span>
|
||||
</div>
|
||||
))}
|
||||
{data.recent_questions.length === 0 && <div className="text-xs text-[#8B6B6B]">暂无数据</div>}
|
||||
</div>
|
||||
</section>
|
||||
<section className="rounded-lg border border-[#F1D6D6] bg-white p-3 dark:border-[#462123] dark:bg-[#230D0F]">
|
||||
<div className="mb-2 flex items-center gap-2 text-sm font-semibold text-[#231815] dark:text-white">
|
||||
<TrendingUp className="size-4 text-[#C1121F]" />
|
||||
高频问题
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{data.frequent_questions.slice(0, 3).map((item, index) => (
|
||||
<div key={`${item.run_id || item.text}-${index}`} className="text-xs leading-5 text-[#5F6368] dark:text-slate-300">
|
||||
{index + 1}. {clampText(item.text, 72)}
|
||||
</div>
|
||||
))}
|
||||
{data.frequent_questions.length === 0 && <div className="text-xs text-[#8B6B6B]">暂无数据</div>}
|
||||
</div>
|
||||
</section>
|
||||
<section className="rounded-lg border border-[#F1D6D6] bg-white p-3 dark:border-[#462123] dark:bg-[#230D0F]">
|
||||
<div className="mb-2 flex items-center gap-2 text-sm font-semibold text-[#231815] dark:text-white">
|
||||
<Sparkles className="size-4 text-[#C1121F]" />
|
||||
使用频率
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{[...data.skills.slice(0, 2), ...data.tools.slice(0, 1)].map((item, index) => (
|
||||
<div key={`${item.name}-${index}`} className="flex items-center justify-between gap-2 text-xs leading-5 text-[#5F6368] dark:text-slate-300">
|
||||
<span className="truncate">{item.name}</span>
|
||||
<span className="shrink-0 font-semibold text-[#C1121F]">{fmtNumber(item.call_count)} 次</span>
|
||||
</div>
|
||||
))}
|
||||
{data.skills.length + data.tools.length === 0 && <div className="text-xs text-[#8B6B6B]">暂无数据</div>}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
<ScrollArea className="max-h-[70vh] rounded-lg border border-[#F1D6D6] bg-[#FFF7F7] p-4 dark:border-[#462123] dark:bg-[#19090A]">
|
||||
{reportStatus === "idle" && !reportText && (
|
||||
<div className="flex items-center justify-center py-12 text-sm text-[#7A5A5A] dark:text-[#D8B8B8]">
|
||||
@@ -1246,25 +1357,20 @@ export default function AdminUserLeaderboardPage() {
|
||||
</div>
|
||||
)}
|
||||
{reportText && (
|
||||
<pre className="whitespace-pre-wrap font-sans text-sm leading-7 text-[#231815] dark:text-white">
|
||||
{reportText}
|
||||
</pre>
|
||||
<div className="rounded-lg bg-white px-5 py-4 text-sm leading-7 text-[#231815] shadow-sm dark:bg-[#230D0F] dark:text-white [&_blockquote]:border-l-4 [&_blockquote]:border-[#C1121F] [&_blockquote]:bg-[#FFF7F7] [&_blockquote]:px-3 [&_blockquote]:py-2 [&_code]:rounded [&_code]:bg-[#FFF1F1] [&_code]:px-1 [&_h1]:mb-4 [&_h1]:text-xl [&_h1]:font-semibold [&_h2]:mb-3 [&_h2]:mt-5 [&_h2]:border-b [&_h2]:border-[#F1D6D6] [&_h2]:pb-2 [&_h2]:text-lg [&_h2]:font-semibold [&_h3]:mb-2 [&_h3]:mt-4 [&_h3]:font-semibold [&_li]:my-1 [&_ol]:pl-5 [&_p]:my-2 [&_strong]:text-[#C1121F] [&_table]:my-3 [&_table]:w-full [&_table]:border-collapse [&_td]:border [&_td]:border-[#F1D6D6] [&_td]:p-2 [&_th]:border [&_th]:border-[#F1D6D6] [&_th]:bg-[#FFF1F1] [&_th]:p-2 [&_ul]:pl-5 dark:[&_blockquote]:bg-[#19090A] dark:[&_code]:bg-[#19090A] dark:[&_strong]:text-[#F2B8BB] dark:[&_th]:bg-[#19090A]">
|
||||
<MarkdownContent content={reportText} isLoading={reportGenerating} rehypePlugins={[]} />
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
{reportText && (
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
downloadText(
|
||||
`user-leaderboard-ai-report-${new Date().toISOString().slice(0, 10)}.md`,
|
||||
reportText,
|
||||
"text/markdown;charset=utf-8",
|
||||
)
|
||||
}
|
||||
onClick={exportReportAsWord}
|
||||
disabled={reportExporting}
|
||||
>
|
||||
<FileDown className="mr-1 size-4" />
|
||||
下载报告
|
||||
{reportExporting ? <Loader2 className="mr-1 size-4 animate-spin" /> : <FileDown className="mr-1 size-4" />}
|
||||
导出 Word
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -318,7 +318,7 @@ def _report_cache_key(
|
||||
settings,
|
||||
) -> str:
|
||||
payload = {
|
||||
"version": 3,
|
||||
"version": 4,
|
||||
"since": _iso(since),
|
||||
"until": _iso(until),
|
||||
"limit": limit,
|
||||
@@ -1092,14 +1092,14 @@ async def _build_leaderboard_report_prompt(
|
||||
}
|
||||
return (
|
||||
"请基于以下管理员排行榜统计数据,生成一份中文运营分析报告。"
|
||||
"报告需要有结论,不要只罗列数字。必须分析:"
|
||||
"1) 谁使用次数最多、问答最多、Token 消耗最高;"
|
||||
"2) 用户通常在问什么、相似问题聚类反映了哪些关注方向;"
|
||||
"3) 当前所有用户关注的事件/主题/业务方向;"
|
||||
"4) 从问题重复、失败率、追问密度等信号推断用户是否满意系统回答,注明这是推断;"
|
||||
"5) 哪些技能/工具/模型最常用,可能说明什么;"
|
||||
"6) 给管理员 3-5 条可执行建议。"
|
||||
"输出结构:摘要、关键发现、用户观察、问题主题、满意度推断、建议。"
|
||||
"报告需要有结论,不要只罗列数字;请使用 Markdown 标题、列表和表格,让前端可以直接渲染。"
|
||||
"重点要求:"
|
||||
"1) 优先突出用户最近关注的实时问题、事件、主题和业务方向,必须引用 recent_questions 中的近期问题作为依据;"
|
||||
"2) 单独分析 frequent_questions,归纳高频关注簇,说明这些高频问题反映出的需求强度;"
|
||||
"3) 结合 top_users、top_skills、top_tools、top_models 和 trends,说明使用频率、使用增长、常用能力和重点用户画像;"
|
||||
"4) 可以提及使用强度、关注变化和运营机会,但不要把报告重点放在系统本身的问题、故障、失败率或回答质量缺陷上;"
|
||||
"5) 给管理员 3-5 条可执行建议,建议应围绕用户关注主题、资源配置、内容供给、培训引导和高频能力优化。"
|
||||
"输出结构:摘要、最近关注、高频问题与使用频率、重点用户与常用能力、趋势观察、行动建议。"
|
||||
"\n\n统计数据如下:\n"
|
||||
f"{json.dumps(context, ensure_ascii=False, default=str)}"
|
||||
)
|
||||
@@ -1141,7 +1141,7 @@ async def generate_user_leaderboard_report(
|
||||
model = create_chat_model(name=resolved_model_name, thinking_enabled=thinking_enabled, app_config=config)
|
||||
response = await model.ainvoke(
|
||||
[
|
||||
SystemMessage(content="你是企业 AI 系统的运营分析师,擅长从用户行为、问题文本和调用指标中生成管理报告。"),
|
||||
SystemMessage(content="你是企业 AI 应用的运营分析师,擅长从用户行为、问题文本和调用指标中生成管理报告;报告聚焦用户关注主题和使用频率,不讨论系统本身问题。"),
|
||||
HumanMessage(content=prompt),
|
||||
],
|
||||
config={"run_name": "admin_user_leaderboard_report"},
|
||||
@@ -1206,7 +1206,7 @@ async def stream_user_leaderboard_report(
|
||||
model = create_chat_model(name=resolved_model_name, thinking_enabled=thinking_enabled, app_config=config)
|
||||
async for chunk in model.astream(
|
||||
[
|
||||
SystemMessage(content="你是企业 AI 系统的运营分析师,擅长从用户行为、问题文本和调用指标中生成管理报告。"),
|
||||
SystemMessage(content="你是企业 AI 应用的运营分析师,擅长从用户行为、问题文本和调用指标中生成管理报告;报告聚焦用户关注主题和使用频率,不讨论系统本身问题。"),
|
||||
HumanMessage(content=prompt),
|
||||
],
|
||||
config={"run_name": "admin_user_leaderboard_report"},
|
||||
|
||||
Reference in new issue
Block a user