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

- 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; 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 { export interface AdminLeaderboardTrendPoint {
date: string; date: string;
runs: number; runs: number;
@@ -138,8 +129,6 @@ export interface AdminLeaderboardResponse {
models: AdminLeaderboardDimension[]; models: AdminLeaderboardDimension[];
agents: AdminLeaderboardDimension[]; agents: AdminLeaderboardDimension[];
run_statuses: AdminLeaderboardDimension[]; run_statuses: AdminLeaderboardDimension[];
recent_questions: AdminLeaderboardQuestion[];
frequent_questions: AdminLeaderboardQuestion[];
trends: AdminLeaderboardTrendPoint[]; trends: AdminLeaderboardTrendPoint[];
} }
@@ -154,7 +143,6 @@ export interface AdminUserAnalyticsResponse {
models: AdminLeaderboardDimension[]; models: AdminLeaderboardDimension[];
agents: AdminLeaderboardDimension[]; agents: AdminLeaderboardDimension[];
run_statuses: AdminLeaderboardDimension[]; run_statuses: AdminLeaderboardDimension[];
recent_questions: AdminLeaderboardQuestion[];
trends: AdminLeaderboardTrendPoint[]; trends: AdminLeaderboardTrendPoint[];
} }
@@ -49,7 +49,6 @@ import { MarkdownContent } from "@/components/workspace/messages/markdown-conten
import type { import type {
AdminLeaderboardDimension, AdminLeaderboardDimension,
AdminLeaderboardOverview, AdminLeaderboardOverview,
AdminLeaderboardQuestion,
AdminLeaderboardResponse, AdminLeaderboardResponse,
AdminLeaderboardSkill, AdminLeaderboardSkill,
AdminLeaderboardTrendPoint, AdminLeaderboardTrendPoint,
@@ -66,7 +65,7 @@ type UserRankTab = "activity" | "questions" | "tokens" | "skills" | "efficiency"
type RangeState = { since: string; until: string; days?: number }; type RangeState = { since: string; until: string; days?: number };
type LeaderboardAccent = "crimson" | "rose" | "amber" | "teal"; type LeaderboardAccent = "crimson" | "rose" | "amber" | "teal";
const DEFAULT_REPORT_MODEL = "__default__"; 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 }> = { const accentClasses: Record<LeaderboardAccent, { soft: string; text: string; bar: string; border: string }> = {
crimson: { crimson: {
@@ -132,11 +131,6 @@ function fmtTime(value?: string | null): string {
return Number.isNaN(date.getTime()) ? value : date.toLocaleString(); 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 { function userScore(user: AdminLeaderboardUser, tab: UserRankTab): number {
if (tab === "questions") return user.qa_count; if (tab === "questions") return user.qa_count;
if (tab === "tokens") return user.total_tokens; if (tab === "tokens") return user.total_tokens;
@@ -207,12 +201,6 @@ function markdownList(items: string[]): string {
function buildReportFocusMarkdown(data: AdminLeaderboardResponse | undefined, range: RangeState): string { function buildReportFocusMarkdown(data: AdminLeaderboardResponse | undefined, range: RangeState): string {
if (!data) return ""; 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 = [ const frequentUsage = [
...data.skills.slice(0, 5).map((item) => `技能:${item.name},${fmtNumber(item.call_count)} 次调用`), ...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)} 次调用`), ...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}`, `统计周期:${range.since} 至 ${range.until}`,
"", "",
"## 最近关注",
markdownList(recentQuestions),
"",
"## 高频问题",
markdownList(frequentQuestions),
"",
"## 使用频率重点", "## 使用频率重点",
markdownList(frequentUsage), markdownList(frequentUsage),
].join("\n"); ].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 }) { function Metric({ label, value }: { label: string; value: number }) {
return ( return (
<div className="min-w-0 border-r border-[#F1D6D6] px-3 py-2 last:border-r-0 dark:border-[#213D5E]"> <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> </div>
</section> </section>
<Tabs defaultValue="questions" className="space-y-3"> <Tabs defaultValue="trend" className="space-y-3">
<TabsList className="mb-3"> <TabsList className="mb-3">
<TabsTrigger value="questions">提问</TabsTrigger>
<TabsTrigger value="trend">趋势</TabsTrigger> <TabsTrigger value="trend">趋势</TabsTrigger>
<TabsTrigger value="skills">技能</TabsTrigger> <TabsTrigger value="skills">技能</TabsTrigger>
<TabsTrigger value="dimensions">维度</TabsTrigger> <TabsTrigger value="dimensions">维度</TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="questions" className="min-h-[420px]">
<QuestionPanel title="最近提问" items={data.recent_questions} showUser={false} />
</TabsContent>
<TabsContent value="trend" className="min-h-[420px]"> <TabsContent value="trend" className="min-h-[420px]">
<div className="mb-3 flex items-center gap-2 font-semibold text-[#231815] dark:text-white"> <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]" /> <TrendingUp className="size-4 text-[#C1121F] dark:text-[#7FACF2]" />
@@ -1354,36 +1295,7 @@ export default function AdminUserLeaderboardPage() {
</Button> </Button>
</div> </div>
{data && reportStatus === "idle" && !reportText && ( {data && reportStatus === "idle" && !reportText && (
<div className="mx-6 mt-4 grid shrink-0 gap-3 md:grid-cols-3"> <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">
<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>
<section className="rounded-lg border border-[#F1D6D6] bg-white p-3 dark:border-[#213D5E] dark:bg-[#02244C]"> <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"> <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]" /> <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="Agent 使用排行" icon={Bot} items={data.agents} empty="当前时间段暂无 Agent 运行" />
<DimensionRanking title="运行状态分布" icon={Gauge} items={data.run_statuses} empty="当前时间段暂无运行状态" /> <DimensionRanking title="运行状态分布" icon={Gauge} items={data.run_statuses} empty="当前时间段暂无运行状态" />
</div> </div>
<div className="grid gap-5 xl:grid-cols-2">
<QuestionPanel title="最近经常问的问题" items={data.frequent_questions} />
<QuestionPanel title="最新问题" items={data.recent_questions} />
</div>
</> </>
)} )}
</div> </div>
@@ -34,12 +34,10 @@ import {
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Pagination } from "@/components/ui/pagination"; import { Pagination } from "@/components/ui/pagination";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import type { import type {
AdminLeaderboardDimension, AdminLeaderboardDimension,
AdminLeaderboardOverview, AdminLeaderboardOverview,
AdminLeaderboardQuestion,
AdminLeaderboardResponse, AdminLeaderboardResponse,
AdminLeaderboardSkill, AdminLeaderboardSkill,
AdminLeaderboardTrendPoint, AdminLeaderboardTrendPoint,
@@ -52,8 +50,10 @@ type UserRankTab = "activity" | "questions" | "tokens" | "skills" | "efficiency"
type LeaderboardAccent = "crimson" | "rose" | "amber" | "teal"; type LeaderboardAccent = "crimson" | "rose" | "amber" | "teal";
const RANK_COLORS = ["#C1121F", "#E11D48", "#F59E0B", "#991B1B", "#B45309"] as const; const RANK_COLORS = ["#C1121F", "#E11D48", "#F59E0B", "#991B1B", "#B45309"] as const;
const LEADERBOARD_PAGE_SIZE = 20; const LEADERBOARD_PAGE_SIZE = 10;
const RANGE_DAYS = 30; // 公开页只统计近 7 天:跨日快照在每次请求时合并,天数越多合并越慢(尤其常问问题的
// 模糊聚合是 O(n^2))。7 天足够展示近期热度,同时把请求耗时压到最低。
const RANGE_DAYS = 7;
const accentClasses: Record<LeaderboardAccent, { soft: string; text: string; bar: string; border: string }> = { const accentClasses: Record<LeaderboardAccent, { soft: string; text: string; bar: string; border: string }> = {
crimson: { crimson: {
@@ -98,11 +98,6 @@ function fmtDate(value?: string | null): string {
return Number.isNaN(date.getTime()) ? "" : date.toLocaleString("zh-CN", { hour12: false }); 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 { function userScore(user: AdminLeaderboardUser, tab: UserRankTab): number {
if (tab === "questions") return user.qa_count; if (tab === "questions") return user.qa_count;
if (tab === "tokens") return user.total_tokens; 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="Agent 使用排行" icon={Bot} items={data.agents} empty="当前时间段暂无 Agent 运行" />
<DimensionRanking title="运行状态分布" icon={Gauge} items={data.run_statuses} empty="当前时间段暂无运行状态" /> <DimensionRanking title="运行状态分布" icon={Gauge} items={data.run_statuses} empty="当前时间段暂无运行状态" />
</div> </div>
{/* 问题 */}
<div className="grid gap-5 xl:grid-cols-2">
<QuestionPanel title="最近经常问的问题" items={data.frequent_questions} />
<QuestionPanel title="最新问题" items={data.recent_questions} />
</div>
</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[] }) { 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");
@@ -20,7 +20,6 @@ import os
import re import re
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
from contextvars import ContextVar from contextvars import ContextVar
from difflib import SequenceMatcher
from hashlib import sha256 from hashlib import sha256
from time import perf_counter from time import perf_counter
from typing import Any from typing import Any
@@ -269,15 +268,6 @@ class AdminLeaderboardDimension(BaseModel):
last_seen_at: str = "" 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): class AdminLeaderboardTrendPoint(BaseModel):
date: str date: str
runs: int = 0 runs: int = 0
@@ -314,8 +304,6 @@ class AdminLeaderboardResponse(BaseModel):
models: list[AdminLeaderboardDimension] = Field(default_factory=list) models: list[AdminLeaderboardDimension] = Field(default_factory=list)
agents: list[AdminLeaderboardDimension] = Field(default_factory=list) agents: list[AdminLeaderboardDimension] = Field(default_factory=list)
run_statuses: 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) trends: list[AdminLeaderboardTrendPoint] = Field(default_factory=list)
@@ -330,7 +318,6 @@ class AdminUserAnalyticsResponse(BaseModel):
models: list[AdminLeaderboardDimension] = Field(default_factory=list) models: list[AdminLeaderboardDimension] = Field(default_factory=list)
agents: list[AdminLeaderboardDimension] = Field(default_factory=list) agents: list[AdminLeaderboardDimension] = Field(default_factory=list)
run_statuses: 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) 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( async def _query_trends(
session, session,
since: datetime, since: datetime,
@@ -1306,10 +1170,6 @@ async def _build_user_leaderboard_response(
"include_scheduled": include_scheduled, "include_scheduled": include_scheduled,
"include_admins": include_admins, "include_admins": include_admins,
} }
question_common = {
**common,
"cleanup_system_questions": settings.cleanup_system_questions,
}
overview = await _query_overview(session, since_dt, until_dt, **common) overview = await _query_overview(session, since_dt, until_dt, **common)
await _leaderboard_pause() await _leaderboard_pause()
users = await _query_user_leaderboard(session, since_dt, until_dt, **common) users = await _query_user_leaderboard(session, since_dt, until_dt, **common)
@@ -1326,10 +1186,6 @@ async def _build_user_leaderboard_response(
await _leaderboard_pause() await _leaderboard_pause()
run_statuses = await _query_run_dimensions(session, since_dt, until_dt, user_id=None, field="status", limit=limit, **common) run_statuses = await _query_run_dimensions(session, since_dt, until_dt, user_id=None, field="status", limit=limit, **common)
await _leaderboard_pause() 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) trends = await _query_trends(session, since_dt, until_dt, **common)
return AdminLeaderboardResponse( return AdminLeaderboardResponse(
@@ -1347,8 +1203,6 @@ async def _build_user_leaderboard_response(
models=models, models=models,
agents=agents, agents=agents,
run_statuses=run_statuses, run_statuses=run_statuses,
recent_questions=recent_questions,
frequent_questions=frequent_questions,
trends=trends, 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] 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]: def _merge_trends_from_daily(responses: list[AdminLeaderboardResponse]) -> list[AdminLeaderboardTrendPoint]:
trends_by_date: dict[str, AdminLeaderboardTrendPoint] = {} trends_by_date: dict[str, AdminLeaderboardTrendPoint] = {}
for response in responses: for response in responses:
@@ -1719,8 +1484,6 @@ def _merge_daily_leaderboard_payloads(
models=_merge_dimensions_from_daily(responses, "models", limit), models=_merge_dimensions_from_daily(responses, "models", limit),
agents=_merge_dimensions_from_daily(responses, "agents", limit), agents=_merge_dimensions_from_daily(responses, "agents", limit),
run_statuses=_merge_dimensions_from_daily(responses, "run_statuses", 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), trends=_merge_trends_from_daily(responses),
) )
@@ -2267,8 +2030,6 @@ async def _build_leaderboard_report_prompt(
models = snapshot.models models = snapshot.models
agents = snapshot.agents agents = snapshot.agents
run_statuses = snapshot.run_statuses run_statuses = snapshot.run_statuses
recent_questions = snapshot.recent_questions
frequent_questions = snapshot.frequent_questions
trends = snapshot.trends trends = snapshot.trends
top_users = snapshot.users_by_activity[:12] top_users = snapshot.users_by_activity[:12]
context = { context = {
@@ -2280,8 +2041,6 @@ async def _build_leaderboard_report_prompt(
"top_models": [m.model_dump() for m in models[:12]], "top_models": [m.model_dump() for m in models[:12]],
"agents": [a.model_dump() for a in agents[:12]], "agents": [a.model_dump() for a in agents[:12]],
"run_statuses": [s.model_dump() for s in run_statuses[: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:]], "trends": [t.model_dump() for t in trends[-21:]],
} }
return ( return (
@@ -2289,12 +2048,10 @@ async def _build_leaderboard_report_prompt(
"报告需要有结论,不要只罗列数字;请使用 Markdown 标题、列表和表格,让前端可以直接渲染。" "报告需要有结论,不要只罗列数字;请使用 Markdown 标题、列表和表格,让前端可以直接渲染。"
"重点要求:" "重点要求:"
"1) 你是专门负责分析用户行为的专家,不要自称企业 AI 应用运营分析师;" "1) 你是专门负责分析用户行为的专家,不要自称企业 AI 应用运营分析师;"
"2) 优先突出用户最近常用问题、最近关注的实时问题、事件、主题和业务方向,必须引用 recent_questions 中的近期问题作为依据;" "2) 结合 top_users、top_skills、top_tools、top_models 和 trends,说明使用频率、使用增长、常用能力和重点用户行为画像;"
"3) 单独分析 frequent_questions,归纳高频关注簇,说明这些高频问题反映出的需求强度和重复频率;" "3) 可以提及使用强度、关注变化和行为机会,但不要把报告重点放在系统本身的问题、故障、失败率或回答质量缺陷上;"
"4) 结合 top_users、top_skills、top_tools、top_models 和 trends,说明使用频率、使用增长、常用能力和重点用户行为画像;" "4) 给管理员 3-5 条可执行建议,建议应围绕资源配置、内容供给、培训引导和高频能力优化。"
"5) 可以提及使用强度、关注变化和行为机会,但不要把报告重点放在系统本身的问题、故障、失败率或回答质量缺陷上;" "输出结构:摘要、重点用户行为、常用能力、趋势观察、行动建议。"
"6) 给管理员 3-5 条可执行建议,建议应围绕用户关注主题、资源配置、内容供给、培训引导和高频能力优化。"
"输出结构:摘要、最近关注、高频问题与频率、重点用户行为、常用能力、趋势观察、行动建议。"
"\n\n统计数据如下:\n" "\n\n统计数据如下:\n"
f"{json.dumps(context, ensure_ascii=False, default=str)}" f"{json.dumps(context, ensure_ascii=False, default=str)}"
) )
@@ -2452,10 +2209,6 @@ async def get_user_analytics(
"include_scheduled": settings.include_scheduled, "include_scheduled": settings.include_scheduled,
"include_admins": settings.include_admins, "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) since_dt, until_dt, range_days = _resolve_range(days, since, until)
async with session_factory() as session: async with session_factory() as session:
await _set_analytics_read_timeout(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) 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) 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) 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) trends = await _query_trends(session, since_dt, until_dt, user_id=user_id, **common)
return AdminUserAnalyticsResponse( return AdminUserAnalyticsResponse(
@@ -2484,7 +2236,6 @@ async def get_user_analytics(
models=models, models=models,
agents=agents, agents=agents,
run_statuses=run_statuses, run_statuses=run_statuses,
recent_questions=recent_questions,
trends=trends, trends=trends,
) )
@@ -62,13 +62,6 @@ def _anonymize(resp: AdminLeaderboardResponse) -> AdminLeaderboardResponse:
user.email = _mask_email(user.email) user.email = _mask_email(user.email)
user.user_id = hashed(user.user_id) 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 return resp