新增批量人物蒸馏脚本,精简排行榜相关接口

- batch-distill: 独立单文件脚本,按时间窗调用线上服务批量蒸馏人物档案
- 精简 admin_users / public_leaderboard 路由及前端排行榜页面

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
吉鑫升andClaude Opus 4.8 committed 2026-06-11 09:34:34 +08:00
1 parent 3a04ba5726
commit 01696f75e9
8 files changed
+552 -418

No files matched your search

+33
View File
@@ -0,0 +1,33 @@
# 批量人物蒸馏 → llmwiki
一个**完全独立的单文件脚本 + 内置定时器**:读 Excel 名单,串行调用线上 DeerFlow 服务问答接口,
复用「女娲 / huashu-nuwa」技能逐个蒸馏人物,**由脚本抓取回复并保存**成 llmwiki 风格 markdown。
- **不联网**:只用已配置的知识库查询技能(`knowledge-search-v2`)检索资料。
- **定时执行**:每天 **23:00 ~ 次日 08:00** 自动跑,到点暂停、次日继续。
- **md 由 py 保存**:agent 只产出内容,脚本抓最终回复写成 `.md`(不让项目自己写文件)。
## 你只需要做两件事
1. **改一行地址**:打开 `distill_people.py`,把顶部 `BASE_URL = "..."` 改成你的线上服务地址。
2. **放名单**:同目录放 `people.xlsx`(或 `people.csv`),**第一列 = 人名**,其余列=可选提示。
其余(用户名 `distiller`、口令 `123ewq`、技能名、时间窗)都已内置,不用填。
## 运行
```bash
python distill_people.py # 常驻,按 23:00~08:00 窗口自动执行(推荐挂后台)
python distill_people.py --now # 忽略时间窗,立刻把名单跑完(测试用)
python distill_people.py --redo # 忽略已完成,全部重做
```
读 `.xlsx` 需要 `openpyxl`;用 `.csv` 则零额外依赖。
## 输出(都在 output/ 一个文件夹)
- `<人名>.md` —— 每个人的 llmwiki 档案
- `manifest.json` —— 执行清单(状态/耗时/run_id)
- `index.md` —— 可读清单表(谁完成/失败,点开看 md)
**断点续跑**:已完成的人自动跳过;中途往名单里加人,下个执行窗会带上。
+504
View File
@@ -0,0 +1,504 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
批量人物蒸馏 → llmwiki markdown(完全独立的单文件客户端 + 内置定时器)
特点
----
- 串行(一个一个来)调用正在运行的 DeerFlow 服务问答接口
- 复用线上「女娲 / huashu-nuwa」技能做提炼;**不联网**,只用已配置的
知识库查询技能(knowledge-search-v2)检索资料
- agent 只负责产出内容,**markdown 由本脚本抓取最终回复后自己保存**
- 内置时间窗:每天 23:00 ~ 次日 08:00 才执行,到点自动暂停、次日继续
- 断点续跑:已完成的人自动跳过;中途加名单也会被带上
你只需要改一处:下面的 BASE_URL(线上服务地址)。其余都不用填。
名单
----
脚本同目录放 people.xlsx 或 people.csv,**第一列 = 人名**,其余列=可选提示。
运行
----
python distill_people.py # 常驻,按 23:00~08:00 窗口自动执行
python distill_people.py --now # 忽略时间窗,立刻把名单跑完(测试用)
python distill_people.py --redo # 忽略已完成,全部重做
"""
from __future__ import annotations
import argparse
import csv
import json
import re
import sys
import time
from datetime import datetime, time as dtime
from pathlib import Path
try:
import requests
except ImportError:
sys.exit("缺少依赖 requests:pip install requests")
# ════════════════════════════════════════════════════════════════════════
# 只改这一行:你的线上服务地址(不带结尾斜杠)
# ════════════════════════════════════════════════════════════════════════
BASE_URL = "http://localhost:8001"
# ════════════════════════════════════════════════════════════════════════
# ---- 以下都已内置,正常无需改动 ----
USERNAME = "distiller" # 免密自动注册
PASSWORD = "123ewq" # 共享口令(你们开了口令门)
NUWA_SKILL = "huashu-nuwa" # 女娲技能
KB_SKILL = "knowledge-search-v2" # 知识库查询技能(不联网,用它检索)
DEPTH = "full" # full 完整 / fast 精简
HERE = Path(__file__).resolve().parent
OUTPUT_DIR = HERE / "output"
PEOPLE_FILE_DEFAULT = HERE / "people.xlsx"
# 执行时间窗:23:00 ~ 次日 08:00(跨午夜)
WIN_START = dtime(23, 0)
WIN_END = dtime(8, 0)
PER_PERSON_TIMEOUT = 1800 # 单人最长等待(秒)
POLL_INTERVAL = 5 # 轮询间隔(秒)
HTTP_TIMEOUT = 60
TERMINAL_STATUS = {"success", "error", "timeout", "interrupted"}
# ================================ llmwiki 模板 ================================
LLMWIKI_TEMPLATE = """\
---
name: <中文名/常用名>
aliases: [<别名/英文名...>]
type: person
era: <在世 | 历史人物>
domains: [<领域1>, <领域2>]
distilled_at: <YYYY-MM-DD>
sources_count: <检索到的来源条数>
primary_source_ratio: <一手来源占比, 0~1 的小数>
confidence: <high | medium | low>
---
# <人物名>
> **一句话定位**:<此人看世界的独特方式,一句话>
## 身份卡
- **活跃年代**:
- **核心身份**:
- **自述(50字, 以其语气)**:
## 核心观点(真信念 · 反复出现的主张)
1. ...
## 心智模型(3–7 个;每个含:一句话 / 证据场景≥2 / 应用 / 局限 / 来源)
### 1. <模型名>
- **一句话**:
- **证据场景**:
- **应用方式**:
- **局限**:
- **来源**:
## 决策启发式(5–10 条 · 如果 X 则 Y,带案例)
- 若 <X> → <Y>
## 表达 DNA
| 维度 | 特征 |
|------|------|
| 句式 | |
| 高频词 | |
| 幽默 | |
| 确定性 | |
| 引用习惯 | |
## 价值观与反模式
- **价值观排序**:
- **明确反对**:
## 内在张力(≥2 对矛盾)
- <A> ↔ <B>
## 经历时间线
| 年份 | 事件 | 思想意义 |
|------|------|---------|
| | | |
## 代表语录
- “...” —(出处/年份,标一手/二手)
## 智识谱系
- **受影响于**:
- **影响了**:
## 外部评价与争议
-
## 诚实边界
- <至少 3 条具体局限,含信息截止日期>
## 调研来源
**一手(N)**:
**二手(N)**:
"""
def build_prompt(name: str, hints: str, depth: str) -> str:
depth_note = (
"本次走【完整模式】:尽量覆盖著作/对话/表达/他者评价/决策/时间线 6 个维度。"
if depth == "full"
else "本次走【精简模式】:重点覆盖核心观点、心智模型、时间线三块即可。"
)
hint_block = f"\n补充提示(来自名单其它列,可作为检索关键词):{hints}\n" if hints.strip() else ""
return f"""\
你的任务:把人物【{name}】蒸馏成一份 llmwiki 风格的人物档案 markdown。
请复用本服务中已启用的「女娲 / {NUWA_SKILL}」技能的提炼方法论
(心智模型三重验证、表达DNA分析、诚实边界)。
⚠️ 本环境**无法联网,严禁使用 WebSearch 等任何联网工具**。
所有资料**只能通过已配置的知识库查询技能「{KB_SKILL}」检索获取**。
请充分调用该知识库技能,围绕该人物多角度检索(生平、观点、著作、语录、决策、评价等),
基于检索到的内容做提炼。知识库里查不到的部分,如实在「诚实边界」标注,不要编造。
{depth_note}
{hint_block}
输出要求:
1. **不要写任何文件,不要调用 write_file / present_files**。
2. 直接在你的回复正文里输出**完整中文 markdown 全文**,严格按下面模板结构
(保留各级标题,逐项填实,不要保留尖括号占位符),**不要任何额外解说、前言或结语**:
------------------ 模板开始 ------------------
{LLMWIKI_TEMPLATE}
------------------ 模板结束 ------------------
现在开始,独立完成,不要向我提问。"""
# ================================ HTTP 客户端 ================================
class DeerFlowClient:
def __init__(self, base_url: str):
self.base = base_url.rstrip("/")
self.s = requests.Session()
self.token: str | None = None
def _headers(self) -> dict:
h = {"Content-Type": "application/json"}
if self.token:
h["Authorization"] = f"Bearer {self.token}" # 只用 Bearer,不带 cookie → 跳过 CSRF
return h
def login(self) -> bool:
url = f"{self.base}/api/v1/auth/login/username"
params = {"password": PASSWORD} if PASSWORD else None
try:
r = self.s.post(url, params=params, json={"username": USERNAME}, timeout=HTTP_TIMEOUT)
except requests.RequestException as e:
print(f" [登录] 连接失败:{e}")
return False
if r.status_code == 200:
self.token = r.json().get("access_token")
self.s.cookies.clear()
print(f" [登录] 成功,用户={USERNAME}")
return True
print(f" [登录] 返回 {r.status_code}:{r.text[:200]}")
return False
def ensure_skill_enabled(self, name: str) -> None:
try:
r = self.s.get(f"{self.base}/api/skills", headers=self._headers(), timeout=HTTP_TIMEOUT)
if r.status_code == 200:
skills = r.json().get("skills", [])
hit = next((x for x in skills if x.get("name") == name), None)
if hit is None:
print(f" [技能] 警告:服务里没找到 '{name}'")
return
if hit.get("enabled"):
print(f" [技能] '{name}' 已启用")
return
pr = self.s.put(f"{self.base}/api/skills/{name}", headers=self._headers(),
json={"enabled": True}, timeout=HTTP_TIMEOUT)
print(f" [技能] 启用 '{name}' → {pr.status_code}")
except requests.RequestException as e:
print(f" [技能] 检查 '{name}' 失败(忽略):{e}")
def create_thread(self) -> str:
r = self.s.post(f"{self.base}/api/threads", headers=self._headers(), json={}, timeout=HTTP_TIMEOUT)
r.raise_for_status()
return r.json()["thread_id"]
def start_run(self, thread_id: str, prompt: str) -> str:
body = {
"input": {"messages": [{"role": "user", "content": prompt}]},
"excluded_tools": ["ask_clarification"], # 禁澄清,无人值守不卡住
}
r = self.s.post(f"{self.base}/api/threads/{thread_id}/runs",
headers=self._headers(), json=body, timeout=HTTP_TIMEOUT)
r.raise_for_status()
return r.json()["run_id"]
def poll_run(self, thread_id: str, run_id: str) -> str:
deadline = time.monotonic() + PER_PERSON_TIMEOUT
last = ""
while time.monotonic() < deadline:
try:
r = self.s.get(f"{self.base}/api/threads/{thread_id}/runs/{run_id}",
headers=self._headers(), timeout=HTTP_TIMEOUT)
if r.status_code == 200:
st = r.json().get("status", "")
if st != last:
print(f" ...状态:{st}")
last = st
if st in TERMINAL_STATUS:
return st
except requests.RequestException as e:
print(f" 轮询出错(重试):{e}")
time.sleep(POLL_INTERVAL)
return "timeout"
def fetch_result_text(self, thread_id: str, run_id: str) -> str | None:
"""抓取该 run 的最终 AI 回复文本(由 py 保存为 md)。"""
try:
r = self.s.get(f"{self.base}/api/threads/{thread_id}/runs/{run_id}/messages",
headers=self._headers(), params={"limit": 200}, timeout=HTTP_TIMEOUT)
if r.status_code == 200:
data = r.json().get("data", [])
for msg in reversed(data):
if msg.get("event_type") == "ai_message" or msg.get("type") == "ai":
txt = _extract_text(msg)
if txt and txt.strip():
return txt
except requests.RequestException as e:
print(f" 取回复出错:{e}")
return None
def _extract_text(msg: dict) -> str:
c = msg.get("content") or msg.get("text") or ""
if isinstance(c, str):
return c
if isinstance(c, list):
parts = []
for b in c:
if isinstance(b, dict):
parts.append(b.get("text") or b.get("content") or "")
else:
parts.append(str(b))
return "\n".join(p for p in parts if p)
return str(c)
def _clean_markdown(text: str) -> str:
"""去掉 agent 可能套上的 ```markdown ... ``` 围栏。"""
t = text.strip()
m = re.match(r"^```(?:markdown|md)?\s*\n(.*)\n```$", t, re.S)
if m:
t = m.group(1).strip()
return t
# ================================ 名单读取 ================================
def slugify(name: str) -> str:
s = re.sub(r'[\\/:*?"<>|]+', "", name).strip()
s = re.sub(r"\s+", "_", s)
return s or "unnamed"
def read_people(path: Path) -> list[dict]:
if not path.exists():
return []
rows: list[list[str]] = []
if path.suffix.lower() in (".xlsx", ".xlsm"):
try:
import openpyxl
except ImportError:
sys.exit("读取 .xlsx 需要 openpyxl:pip install openpyxl(或把名单存成 .csv)")
wb = openpyxl.load_workbook(path, read_only=True, data_only=True)
for row in wb.active.iter_rows(values_only=True):
rows.append(["" if c is None else str(c).strip() for c in row])
elif path.suffix.lower() == ".csv":
with open(path, encoding="utf-8-sig", newline="") as f:
rows = [[c.strip() for c in r] for r in csv.reader(f)]
else:
sys.exit(f"不支持的名单格式:{path.suffix}(用 .xlsx 或 .csv)")
if rows and re.search(r"(姓名|名字|人物|name)", rows[0][0] if rows[0] else "", re.I):
rows = rows[1:]
people, seen = [], set()
for r in rows:
if not r or not r[0].strip():
continue
name = r[0].strip()
if name in seen:
continue
seen.add(name)
hints = " | ".join(x for x in r[1:] if x.strip())
people.append({"name": name, "hints": hints})
return people
# ================================ 时间窗 ================================
def in_window(now: datetime | None = None) -> bool:
now = now or datetime.now()
t = now.time()
return t >= WIN_START or t < WIN_END # 跨午夜
def seconds_until_window(now: datetime | None = None) -> int:
now = now or datetime.now()
if in_window(now):
return 0
target = now.replace(hour=WIN_START.hour, minute=WIN_START.minute, second=0, microsecond=0)
return max(1, int((target - now).total_seconds()))
def sleep_interruptible(total: int) -> None:
end = time.monotonic() + total
while time.monotonic() < end:
time.sleep(min(30, max(1, int(end - time.monotonic()))))
# ================================ 蒸馏单人 ================================
def distill_one(client: DeerFlowClient, person: dict, out_dir: Path,
manifest: dict, save_manifest, depth: str) -> None:
name, hints = person["name"], person["hints"]
slug = slugify(name)
md_path = out_dir / f"{slug}.md"
t0 = time.time()
rec = {"name": name, "slug": slug, "status": "running",
"started_at": _now(), "file": f"{slug}.md"}
manifest[name] = rec
save_manifest()
try:
tid = client.create_thread()
run_id = client.start_run(tid, build_prompt(name, hints, depth))
status = client.poll_run(tid, run_id)
rec.update(thread_id=tid, run_id=run_id, run_status=status)
text = client.fetch_result_text(tid, run_id) if status == "success" else None
if status == "success" and text:
md = _clean_markdown(text)
md_path.write_text(md, encoding="utf-8")
rec["status"] = "succeeded"
rec["chars"] = len(md)
print(f" ✅ 完成 → {md_path.name}({len(md)} 字,耗时 {int(time.time()-t0)}s)")
else:
rec["status"] = "failed"
rec["error"] = f"run={status}, text={'有' if text else '无'}"
if text:
(out_dir / f"{slug}.partial.md").write_text(text, encoding="utf-8")
print(f" ❌ 失败(run={status})")
except Exception as e:
rec["status"] = "failed"
rec["error"] = str(e)
print(f" ❌ 异常:{e}")
rec["finished_at"] = _now()
rec["seconds"] = int(time.time() - t0)
manifest[name] = rec
save_manifest()
# ================================ 主流程 ================================
def main():
ap = argparse.ArgumentParser(description="批量人物蒸馏 → llmwiki(定时窗 23:00~08:00)")
ap.add_argument("excel", nargs="?", default=str(PEOPLE_FILE_DEFAULT),
help="名单文件(.xlsx 或 .csv),默认同目录 people.xlsx")
ap.add_argument("--now", action="store_true", help="忽略时间窗,立刻跑完整批(测试用)")
ap.add_argument("--redo", action="store_true", help="忽略已完成,全部重做")
args = ap.parse_args()
excel = Path(args.excel)
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
manifest_path = OUTPUT_DIR / "manifest.json"
manifest: dict = {}
if manifest_path.exists():
try:
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
except Exception:
manifest = {}
# 校验名单存在
if not read_people(excel):
sys.exit(f"名单为空或不存在:{excel}\n请放 people.xlsx(第一列写人名)。")
client = DeerFlowClient(BASE_URL)
if not client.login():
sys.exit("登录失败。请检查 BASE_URL 是否指向正在运行的服务、口令是否正确。")
client.ensure_skill_enabled(NUWA_SKILL)
client.ensure_skill_enabled(KB_SKILL)
def save_manifest():
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
_write_index(OUTPUT_DIR, read_people(excel), manifest)
def pending() -> list[dict]:
people = read_people(excel)
out = []
for p in people:
r = manifest.get(p["name"], {})
done = (not args.redo) and r.get("status") == "succeeded" and (OUTPUT_DIR / f"{slugify(p['name'])}.md").exists()
if not done:
out.append(p)
return out
print(f"启动 | 服务={BASE_URL} | 输出={OUTPUT_DIR}")
print(f"模式={'立即跑完' if args.now else '定时窗 23:00~08:00'} | 名单={excel}")
try:
while True:
todo = pending()
if not todo:
print(f"[{_now()}] 全部已完成,待命中(每小时复查名单)...")
if args.now:
break
sleep_interruptible(3600)
continue
if not args.now and not in_window():
wait = seconds_until_window()
nxt = datetime.now().replace(hour=WIN_START.hour, minute=0, second=0, microsecond=0)
print(f"[{_now()}] 不在执行窗口,剩 {len(todo)} 人待蒸;"
f"休眠到 {nxt.strftime('%H:%M')}(约 {wait//60} 分钟)...")
sleep_interruptible(wait)
continue
# 在窗口内(或 --now):处理一个人,然后回到循环重新判断窗口/名单
p = todo[0]
idx_total = len(read_people(excel))
done_n = sum(1 for v in manifest.values() if v.get("status") == "succeeded")
print(f"[{_now()}] ({done_n}/{idx_total} 已完成) 蒸馏:{p['name']}")
distill_one(client, p, OUTPUT_DIR, manifest, save_manifest, DEPTH)
except KeyboardInterrupt:
print("\n已手动停止。重跑脚本会从未完成处继续。")
ok = sum(1 for r in manifest.values() if r.get("status") == "succeeded")
fail = sum(1 for r in manifest.values() if r.get("status") == "failed")
print(f"\n结束:成功 {ok} / 失败 {fail}")
print(f"结果目录:{OUTPUT_DIR} | 清单:{manifest_path} | 索引:{OUTPUT_DIR/'index.md'}")
def _now() -> str:
return time.strftime("%Y-%m-%d %H:%M:%S")
def _write_index(out_dir: Path, people: list[dict], manifest: dict) -> None:
lines = ["# 蒸馏执行清单", "", f"更新时间:{_now()}", "",
"| # | 人物 | 状态 | 字数 | 耗时 | 文件 |",
"|---|------|------|------|------|------|"]
badge = {"succeeded": "✅完成", "failed": "❌失败", "running": "⏳进行中"}
for i, p in enumerate(people, 1):
r = manifest.get(p["name"], {})
st = badge.get(r.get("status", ""), "⌛排队")
f = r.get("file", "")
link = f"[{f}]({f})" if r.get("status") == "succeeded" and f else "-"
lines.append(f"| {i} | {p['name']} | {st} | {r.get('chars','-')} | {r.get('seconds','-')} | {link} |")
(out_dir / "index.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
if __name__ == "__main__":
main()
+4
View File
@@ -0,0 +1,4 @@
姓名,领域(可选提示),资料链接或备注(可选)
查理·芒格,投资/多元思维,
理查德·费曼,物理/教学,
纳瓦尔·拉维坎特,创业/杠杆,https://nav.al/
1 姓名 领域(可选提示) 资料链接或备注(可选)
2 查理·芒格 投资/多元思维
3 理查德·费曼 物理/教学
4 纳瓦尔·拉维坎特 创业/杠杆 https://nav.al/
-12
View File
@@ -93,15 +93,6 @@ export interface AdminLeaderboardDimension {
last_seen_at: string;
}
export interface AdminLeaderboardQuestion {
text: string;
user_id: string;
email: string;
thread_id: string;
run_id: string;
asked_at: string;
}
export interface AdminLeaderboardTrendPoint {
date: string;
runs: number;
@@ -138,8 +129,6 @@ export interface AdminLeaderboardResponse {
models: AdminLeaderboardDimension[];
agents: AdminLeaderboardDimension[];
run_statuses: AdminLeaderboardDimension[];
recent_questions: AdminLeaderboardQuestion[];
frequent_questions: AdminLeaderboardQuestion[];
trends: AdminLeaderboardTrendPoint[];
}
@@ -154,7 +143,6 @@ export interface AdminUserAnalyticsResponse {
models: AdminLeaderboardDimension[];
agents: AdminLeaderboardDimension[];
run_statuses: AdminLeaderboardDimension[];
recent_questions: AdminLeaderboardQuestion[];
trends: AdminLeaderboardTrendPoint[];
}
@@ -49,7 +49,6 @@ import { MarkdownContent } from "@/components/workspace/messages/markdown-conten
import type {
AdminLeaderboardDimension,
AdminLeaderboardOverview,
AdminLeaderboardQuestion,
AdminLeaderboardResponse,
AdminLeaderboardSkill,
AdminLeaderboardTrendPoint,
@@ -66,7 +65,7 @@ type UserRankTab = "activity" | "questions" | "tokens" | "skills" | "efficiency"
type RangeState = { since: string; until: string; days?: number };
type LeaderboardAccent = "crimson" | "rose" | "amber" | "teal";
const DEFAULT_REPORT_MODEL = "__default__";
const LEADERBOARD_PAGE_SIZE = 20;
const LEADERBOARD_PAGE_SIZE = 10;
const accentClasses: Record<LeaderboardAccent, { soft: string; text: string; bar: string; border: string }> = {
crimson: {
@@ -132,11 +131,6 @@ function fmtTime(value?: string | null): string {
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
}
function clampText(value: string, max = 120): string {
const text = value.trim();
return text.length > max ? `${text.slice(0, max)}...` : text;
}
function userScore(user: AdminLeaderboardUser, tab: UserRankTab): number {
if (tab === "questions") return user.qa_count;
if (tab === "tokens") return user.total_tokens;
@@ -207,12 +201,6 @@ function markdownList(items: string[]): string {
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)} 次调用`),
@@ -223,12 +211,6 @@ function buildReportFocusMarkdown(data: AdminLeaderboardResponse | undefined, ra
"",
`统计周期:${range.since} 至 ${range.until}`,
"",
"## 最近关注",
markdownList(recentQuestions),
"",
"## 高频问题",
markdownList(frequentQuestions),
"",
"## 使用频率重点",
markdownList(frequentUsage),
].join("\n");
@@ -905,43 +887,6 @@ function DimensionRanking({
);
}
function QuestionPanel({
title,
items,
showUser = true,
}: {
title: string;
items: AdminLeaderboardQuestion[];
showUser?: boolean;
}) {
return (
<section className="flex h-[430px] flex-col overflow-hidden rounded-lg border border-[#F1D6D6] bg-white shadow-sm dark:border-[#213D5E] dark:bg-[#02244C]">
<div className="flex items-center justify-between gap-3 border-b border-[#F1D6D6] px-4 py-3 dark:border-[#213D5E]">
<div className="flex items-center gap-2">
<MessageSquareText className="size-4 text-[#5F6368] dark:text-slate-300" />
<h2 className="font-semibold text-[#202124] dark:text-white">{title}</h2>
</div>
<Badge variant="outline">{items.length}</Badge>
</div>
<ScrollArea className="min-h-0 flex-1">
<div className="divide-y divide-[#F1D6D6] dark:divide-[#213D5E]">
{items.length === 0 && <div className="py-12 text-center text-sm text-[#5F6368] dark:text-slate-400">暂无问题记录</div>}
{items.map((item, index) => (
<div key={`${item.run_id || item.text}-${index}`} className="px-4 py-3">
<div className="text-sm leading-6 text-[#202124] dark:text-slate-100">{clampText(item.text, 180)}</div>
<div className="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-xs text-[#5F6368] dark:text-slate-400">
{showUser && <span>{item.email || item.user_id || "未知用户"}</span>}
<span>{fmtTime(item.asked_at)}</span>
{item.thread_id && <span>会话 {item.thread_id.slice(0, 8)}</span>}
</div>
</div>
))}
</div>
</ScrollArea>
</section>
);
}
function Metric({ label, value }: { label: string; value: number }) {
return (
<div className="min-w-0 border-r border-[#F1D6D6] px-3 py-2 last:border-r-0 dark:border-[#213D5E]">
@@ -1006,16 +951,12 @@ function UserDetailPanel({
</div>
</section>
<Tabs defaultValue="questions" className="space-y-3">
<Tabs defaultValue="trend" className="space-y-3">
<TabsList className="mb-3">
<TabsTrigger value="questions">提问</TabsTrigger>
<TabsTrigger value="trend">趋势</TabsTrigger>
<TabsTrigger value="skills">技能</TabsTrigger>
<TabsTrigger value="dimensions">维度</TabsTrigger>
</TabsList>
<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] dark:text-[#7FACF2]" />
@@ -1354,36 +1295,7 @@ export default function AdminUserLeaderboardPage() {
</Button>
</div>
{data && reportStatus === "idle" && !reportText && (
<div className="mx-6 mt-4 grid shrink-0 gap-3 md:grid-cols-3">
<section className="rounded-lg border border-[#F1D6D6] bg-white p-3 dark:border-[#213D5E] dark:bg-[#02244C]">
<div className="mb-2 flex items-center gap-2 text-sm font-semibold text-[#231815] dark:text-white">
<MessageSquareText className="size-4 text-[#C1121F] dark:text-[#7FACF2]" />
最近关注
</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] dark:text-[#7FACF2]">{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] dark:text-[#7FACF2]">暂无数据</div>}
</div>
</section>
<section className="rounded-lg border border-[#F1D6D6] bg-white p-3 dark:border-[#213D5E] dark:bg-[#02244C]">
<div className="mb-2 flex items-center gap-2 text-sm font-semibold text-[#231815] dark:text-white">
<TrendingUp className="size-4 text-[#C1121F] dark:text-[#7FACF2]" />
高频问题
</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] dark:text-[#7FACF2]">暂无数据</div>}
</div>
</section>
<div className="mx-6 mt-4 grid shrink-0 gap-3">
<section className="rounded-lg border border-[#F1D6D6] bg-white p-3 dark:border-[#213D5E] dark:bg-[#02244C]">
<div className="mb-2 flex items-center gap-2 text-sm font-semibold text-[#231815] dark:text-white">
<Sparkles className="size-4 text-[#C1121F] dark:text-[#7FACF2]" />
@@ -1645,12 +1557,6 @@ export default function AdminUserLeaderboardPage() {
<DimensionRanking title="Agent 使用排行" icon={Bot} items={data.agents} empty="当前时间段暂无 Agent 运行" />
<DimensionRanking title="运行状态分布" icon={Gauge} items={data.run_statuses} empty="当前时间段暂无运行状态" />
</div>
<div className="grid gap-5 xl:grid-cols-2">
<QuestionPanel title="最近经常问的问题" items={data.frequent_questions} />
<QuestionPanel title="最新问题" items={data.recent_questions} />
</div>
</>
)}
</div>
@@ -34,12 +34,10 @@ import {
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Pagination } from "@/components/ui/pagination";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import type {
AdminLeaderboardDimension,
AdminLeaderboardOverview,
AdminLeaderboardQuestion,
AdminLeaderboardResponse,
AdminLeaderboardSkill,
AdminLeaderboardTrendPoint,
@@ -52,8 +50,10 @@ type UserRankTab = "activity" | "questions" | "tokens" | "skills" | "efficiency"
type LeaderboardAccent = "crimson" | "rose" | "amber" | "teal";
const RANK_COLORS = ["#C1121F", "#E11D48", "#F59E0B", "#991B1B", "#B45309"] as const;
const LEADERBOARD_PAGE_SIZE = 20;
const RANGE_DAYS = 30;
const LEADERBOARD_PAGE_SIZE = 10;
// 公开页只统计近 7 天:跨日快照在每次请求时合并,天数越多合并越慢(尤其常问问题的
// 模糊聚合是 O(n^2))。7 天足够展示近期热度,同时把请求耗时压到最低。
const RANGE_DAYS = 7;
const accentClasses: Record<LeaderboardAccent, { soft: string; text: string; bar: string; border: string }> = {
crimson: {
@@ -98,11 +98,6 @@ function fmtDate(value?: string | null): string {
return Number.isNaN(date.getTime()) ? "" : date.toLocaleString("zh-CN", { hour12: false });
}
function clampText(value: string, max = 120): string {
const text = value.trim();
return text.length > max ? `${text.slice(0, max)}...` : text;
}
function userScore(user: AdminLeaderboardUser, tab: UserRankTab): number {
if (tab === "questions") return user.qa_count;
if (tab === "tokens") return user.total_tokens;
@@ -367,12 +362,6 @@ export default function PublicLeaderboardPage() {
<DimensionRanking title="Agent 使用排行" icon={Bot} items={data.agents} empty="当前时间段暂无 Agent 运行" />
<DimensionRanking title="运行状态分布" icon={Gauge} items={data.run_statuses} empty="当前时间段暂无运行状态" />
</div>
{/* 问题 */}
<div className="grid gap-5 xl:grid-cols-2">
<QuestionPanel title="最近经常问的问题" items={data.frequent_questions} />
<QuestionPanel title="最新问题" items={data.recent_questions} />
</div>
</div>
)}
@@ -688,40 +677,6 @@ function DimensionRanking({
);
}
function QuestionPanel({
title,
items,
}: {
title: string;
items: AdminLeaderboardQuestion[];
}) {
return (
<section className="flex h-[430px] flex-col overflow-hidden rounded-lg border border-[#F1D6D6] bg-white shadow-sm dark:border-[#213D5E] dark:bg-[#02244C]">
<div className="flex items-center justify-between gap-3 border-b border-[#F1D6D6] px-4 py-3 dark:border-[#213D5E]">
<div className="flex items-center gap-2">
<MessageSquareText className="size-4 text-[#5F6368] dark:text-slate-300" />
<h2 className="font-semibold text-[#202124] dark:text-white">{title}</h2>
</div>
<Badge variant="outline">{items.length}</Badge>
</div>
<ScrollArea className="min-h-0 flex-1">
<div className="divide-y divide-[#F1D6D6] dark:divide-[#213D5E]">
{items.length === 0 && <div className="py-12 text-center text-sm text-[#5F6368] dark:text-slate-400">暂无问题记录</div>}
{items.map((item, index) => (
<div key={`${item.run_id || item.text}-${index}`} className="px-4 py-3">
<div className="text-sm leading-6 text-[#202124] dark:text-slate-100">{clampText(item.text, 180)}</div>
<div className="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-xs text-[#5F6368] dark:text-slate-400">
<span>{item.email || item.user_id || "未知用户"}</span>
<span>{fmtTime(item.asked_at)}</span>
</div>
</div>
))}
</div>
</ScrollArea>
</section>
);
}
function TrendBars({ points }: { points: AdminLeaderboardTrendPoint[] }) {
type TrendMetric = keyof Pick<AdminLeaderboardTrendPoint, "runs" | "questions" | "users" | "tokens">;
const [metric, setMetric] = useState<"all" | TrendMetric>("all");
@@ -20,7 +20,6 @@ import os
import re
from collections.abc import AsyncIterator
from contextvars import ContextVar
from difflib import SequenceMatcher
from hashlib import sha256
from time import perf_counter
from typing import Any
@@ -269,15 +268,6 @@ class AdminLeaderboardDimension(BaseModel):
last_seen_at: str = ""
class AdminLeaderboardQuestion(BaseModel):
text: str
user_id: str = ""
email: str = ""
thread_id: str = ""
run_id: str = ""
asked_at: str = ""
class AdminLeaderboardTrendPoint(BaseModel):
date: str
runs: int = 0
@@ -314,8 +304,6 @@ class AdminLeaderboardResponse(BaseModel):
models: list[AdminLeaderboardDimension] = Field(default_factory=list)
agents: list[AdminLeaderboardDimension] = Field(default_factory=list)
run_statuses: list[AdminLeaderboardDimension] = Field(default_factory=list)
recent_questions: list[AdminLeaderboardQuestion] = Field(default_factory=list)
frequent_questions: list[AdminLeaderboardQuestion] = Field(default_factory=list)
trends: list[AdminLeaderboardTrendPoint] = Field(default_factory=list)
@@ -330,7 +318,6 @@ class AdminUserAnalyticsResponse(BaseModel):
models: list[AdminLeaderboardDimension] = Field(default_factory=list)
agents: list[AdminLeaderboardDimension] = Field(default_factory=list)
run_statuses: list[AdminLeaderboardDimension] = Field(default_factory=list)
recent_questions: list[AdminLeaderboardQuestion] = Field(default_factory=list)
trends: list[AdminLeaderboardTrendPoint] = Field(default_factory=list)
@@ -1014,129 +1001,6 @@ async def _query_model_rankings(
]
async def _query_recent_questions(
session,
since: datetime,
until: datetime,
*,
user_id: str | None,
limit: int,
exclude_user_ids: set[str] | None = None,
include_scheduled: bool = True,
include_admins: bool = True,
include_failed: bool = True,
cleanup_system_questions: bool = True,
) -> list[AdminLeaderboardQuestion]:
exclude_user_ids = exclude_user_ids or set()
stmt = (
select(RunRow.first_human_message, RunRow.user_id, UserRow.email, RunRow.thread_id, RunRow.run_id, RunRow.created_at)
.outerjoin(UserRow, UserRow.id == RunRow.user_id)
.where(_run_time_filter(since, until), RunRow.first_human_message.is_not(None), RunRow.first_human_message != "")
.order_by(RunRow.created_at.desc())
.limit(limit)
)
if user_id:
stmt = stmt.where(RunRow.user_id == user_id)
stmt = _apply_run_scope(
stmt,
user_column=RunRow.user_id,
run_id_column=RunRow.run_id,
exclude_user_ids=exclude_user_ids,
include_admins=include_admins,
include_failed=include_failed,
include_scheduled=include_scheduled,
)
rows = (await _execute_leaderboard_query(session, "recent_questions", stmt)).all()
questions: list[AdminLeaderboardQuestion] = []
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
questions.append(
AdminLeaderboardQuestion(
text=text[:500],
user_id=r[1] or "",
email=r[2] or "",
thread_id=r[3] or "",
run_id=r[4] or "",
asked_at=_iso(r[5]),
)
)
return questions
async def _query_frequent_questions(
session,
since: datetime,
until: datetime,
limit: int,
exclude_user_ids: set[str] | None = None,
include_scheduled: bool = True,
include_admins: bool = True,
include_failed: bool = True,
cleanup_system_questions: bool = True,
) -> list[AdminLeaderboardQuestion]:
exclude_user_ids = exclude_user_ids or set()
stmt = (
select(
RunRow.first_human_message,
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 != "")
.order_by(RunRow.created_at.desc())
.limit(min(max(limit * 20, 200), 1000))
)
stmt = _apply_run_scope(
stmt,
user_column=RunRow.user_id,
run_id_column=RunRow.run_id,
exclude_user_ids=exclude_user_ids,
include_admins=include_admins,
include_failed=include_failed,
include_scheduled=include_scheduled,
)
rows = (await _execute_leaderboard_query(session, "frequent_questions", stmt)).all()
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,
user_id=group["user_id"],
email=group["email"],
asked_at=_iso(group.get("asked_at")),
)
)
return questions
async def _query_trends(
session,
since: datetime,
@@ -1306,10 +1170,6 @@ async def _build_user_leaderboard_response(
"include_scheduled": include_scheduled,
"include_admins": include_admins,
}
question_common = {
**common,
"cleanup_system_questions": settings.cleanup_system_questions,
}
overview = await _query_overview(session, since_dt, until_dt, **common)
await _leaderboard_pause()
users = await _query_user_leaderboard(session, since_dt, until_dt, **common)
@@ -1326,10 +1186,6 @@ async def _build_user_leaderboard_response(
await _leaderboard_pause()
run_statuses = await _query_run_dimensions(session, since_dt, until_dt, user_id=None, field="status", limit=limit, **common)
await _leaderboard_pause()
recent_questions = await _query_recent_questions(session, since_dt, until_dt, user_id=None, limit=limit, **question_common)
await _leaderboard_pause()
frequent_questions = await _query_frequent_questions(session, since_dt, until_dt, limit=limit, **question_common)
await _leaderboard_pause()
trends = await _query_trends(session, since_dt, until_dt, **common)
return AdminLeaderboardResponse(
@@ -1347,8 +1203,6 @@ async def _build_user_leaderboard_response(
models=models,
agents=agents,
run_statuses=run_statuses,
recent_questions=recent_questions,
frequent_questions=frequent_questions,
trends=trends,
)
@@ -1572,95 +1426,6 @@ def _merge_dimensions_from_daily(
return sorted(merged_items, key=lambda item: (item.count, _sort_datetime(item.last_seen_at)), reverse=True)[:limit]
def _merge_recent_questions_from_daily(
responses: list[AdminLeaderboardResponse],
limit: int,
) -> list[AdminLeaderboardQuestion]:
questions: list[AdminLeaderboardQuestion] = []
seen: set[str] = set()
for response in responses:
for question in response.recent_questions:
key = question.run_id or f"{question.user_id}:{question.asked_at}:{question.text}"
if key in seen:
continue
seen.add(key)
questions.append(question)
questions.sort(key=lambda item: _sort_datetime(item.asked_at), reverse=True)
return questions[:limit]
_FREQUENT_QUESTION_COUNT_RE = re.compile(r"(相似问题\s+(\d+)\s+次)$")
def _split_frequent_question_count(text: str) -> tuple[str, int]:
match = _FREQUENT_QUESTION_COUNT_RE.search(text)
if not match:
return text, 1
return _FREQUENT_QUESTION_COUNT_RE.sub("", text).strip(), max(1, int(match.group(1)))
def _merge_frequent_questions_from_daily(
responses: list[AdminLeaderboardResponse],
limit: int,
) -> list[AdminLeaderboardQuestion]:
groups: list[dict] = []
for response in responses:
for question in response.frequent_questions:
base_text, count = _split_frequent_question_count(question.text)
key = _question_similarity_key(base_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": base_text,
"count": count,
"user_id": question.user_id,
"email": question.email,
"thread_id": question.thread_id,
"run_id": question.run_id,
"asked_at": question.asked_at,
}
)
else:
target["count"] += count
if _sort_datetime(question.asked_at) > _sort_datetime(target.get("asked_at")):
target.update(
{
"text": base_text,
"user_id": question.user_id,
"email": question.email,
"thread_id": question.thread_id,
"run_id": question.run_id,
"asked_at": question.asked_at,
}
)
groups.sort(key=lambda item: (item["count"], _sort_datetime(item.get("asked_at"))), reverse=True)
questions: list[AdminLeaderboardQuestion] = []
for group in groups[:limit]:
text_value = group["text"][:460]
if group["count"] > 1:
text_value = f"{text_value}(相似问题 {group['count']} 次)"
questions.append(
AdminLeaderboardQuestion(
text=text_value,
user_id=group["user_id"],
email=group["email"],
thread_id=group["thread_id"],
run_id=group["run_id"],
asked_at=group["asked_at"],
)
)
return questions
def _merge_trends_from_daily(responses: list[AdminLeaderboardResponse]) -> list[AdminLeaderboardTrendPoint]:
trends_by_date: dict[str, AdminLeaderboardTrendPoint] = {}
for response in responses:
@@ -1719,8 +1484,6 @@ def _merge_daily_leaderboard_payloads(
models=_merge_dimensions_from_daily(responses, "models", limit),
agents=_merge_dimensions_from_daily(responses, "agents", limit),
run_statuses=_merge_dimensions_from_daily(responses, "run_statuses", limit),
recent_questions=_merge_recent_questions_from_daily(responses, limit),
frequent_questions=_merge_frequent_questions_from_daily(responses, limit),
trends=_merge_trends_from_daily(responses),
)
@@ -2267,8 +2030,6 @@ async def _build_leaderboard_report_prompt(
models = snapshot.models
agents = snapshot.agents
run_statuses = snapshot.run_statuses
recent_questions = snapshot.recent_questions
frequent_questions = snapshot.frequent_questions
trends = snapshot.trends
top_users = snapshot.users_by_activity[:12]
context = {
@@ -2280,8 +2041,6 @@ async def _build_leaderboard_report_prompt(
"top_models": [m.model_dump() for m in models[:12]],
"agents": [a.model_dump() for a in agents[:12]],
"run_statuses": [s.model_dump() for s in run_statuses[:12]],
"frequent_questions": [q.model_dump() for q in frequent_questions[:30]],
"recent_questions": [q.model_dump() for q in recent_questions[:40]],
"trends": [t.model_dump() for t in trends[-21:]],
}
return (
@@ -2289,12 +2048,10 @@ async def _build_leaderboard_report_prompt(
"报告需要有结论,不要只罗列数字;请使用 Markdown 标题、列表和表格,让前端可以直接渲染。"
"重点要求:"
"1) 你是专门负责分析用户行为的专家,不要自称企业 AI 应用运营分析师;"
"2) 优先突出用户最近常用问题、最近关注的实时问题、事件、主题和业务方向,必须引用 recent_questions 中的近期问题作为依据;"
"3) 单独分析 frequent_questions,归纳高频关注簇,说明这些高频问题反映出的需求强度和重复频率;"
"4) 结合 top_users、top_skills、top_tools、top_models 和 trends,说明使用频率、使用增长、常用能力和重点用户行为画像;"
"5) 可以提及使用强度、关注变化和行为机会,但不要把报告重点放在系统本身的问题、故障、失败率或回答质量缺陷上;"
"6) 给管理员 3-5 条可执行建议,建议应围绕用户关注主题、资源配置、内容供给、培训引导和高频能力优化。"
"输出结构:摘要、最近关注、高频问题与频率、重点用户行为、常用能力、趋势观察、行动建议。"
"2) 结合 top_users、top_skills、top_tools、top_models 和 trends,说明使用频率、使用增长、常用能力和重点用户行为画像;"
"3) 可以提及使用强度、关注变化和行为机会,但不要把报告重点放在系统本身的问题、故障、失败率或回答质量缺陷上;"
"4) 给管理员 3-5 条可执行建议,建议应围绕资源配置、内容供给、培训引导和高频能力优化。"
"输出结构:摘要、重点用户行为、常用能力、趋势观察、行动建议。"
"\n\n统计数据如下:\n"
f"{json.dumps(context, ensure_ascii=False, default=str)}"
)
@@ -2452,10 +2209,6 @@ async def get_user_analytics(
"include_scheduled": settings.include_scheduled,
"include_admins": settings.include_admins,
}
question_common = {
**common,
"cleanup_system_questions": settings.cleanup_system_questions,
}
since_dt, until_dt, range_days = _resolve_range(days, since, until)
async with session_factory() as session:
await _set_analytics_read_timeout(session)
@@ -2470,7 +2223,6 @@ async def get_user_analytics(
models = await _query_run_dimensions(session, since_dt, until_dt, user_id=user_id, field="model", limit=limit, **common)
agents = await _query_run_dimensions(session, since_dt, until_dt, user_id=user_id, field="agent", limit=limit, **common)
run_statuses = await _query_run_dimensions(session, since_dt, until_dt, user_id=user_id, field="status", limit=limit, **common)
recent_questions = await _query_recent_questions(session, since_dt, until_dt, user_id=user_id, limit=limit, **question_common)
trends = await _query_trends(session, since_dt, until_dt, user_id=user_id, **common)
return AdminUserAnalyticsResponse(
@@ -2484,7 +2236,6 @@ async def get_user_analytics(
models=models,
agents=agents,
run_statuses=run_statuses,
recent_questions=recent_questions,
trends=trends,
)
@@ -62,13 +62,6 @@ def _anonymize(resp: AdminLeaderboardResponse) -> AdminLeaderboardResponse:
user.email = _mask_email(user.email)
user.user_id = hashed(user.user_id)
for bucket in (resp.recent_questions, resp.frequent_questions):
for question in bucket:
question.email = _mask_email(question.email)
question.user_id = hashed(question.user_id)
question.thread_id = ""
question.run_id = ""
return resp