Merge branch 'ji' of http://192.168.0.200/knowledge-base-bj/deerflow-server into ji
This commit is contained in:
commit
cbbde887d4
5 files changed
+375
-31
No files matched your search
@@ -104,6 +104,9 @@ export interface AdminLeaderboardResponse {
|
|||||||
range_days: number;
|
range_days: number;
|
||||||
since: string;
|
since: string;
|
||||||
until: string;
|
until: string;
|
||||||
|
status?: "ready" | "running" | "refreshing";
|
||||||
|
generated_at?: string;
|
||||||
|
message?: string;
|
||||||
overview: AdminLeaderboardOverview;
|
overview: AdminLeaderboardOverview;
|
||||||
users_by_activity: AdminLeaderboardUser[];
|
users_by_activity: AdminLeaderboardUser[];
|
||||||
users_by_questions: AdminLeaderboardUser[];
|
users_by_questions: AdminLeaderboardUser[];
|
||||||
|
|||||||
@@ -33,6 +33,10 @@ export function useUserLeaderboard(
|
|||||||
limit,
|
limit,
|
||||||
],
|
],
|
||||||
queryFn: () => getUserLeaderboard(range, limit),
|
queryFn: () => getUserLeaderboard(range, limit),
|
||||||
|
refetchInterval: (query) => {
|
||||||
|
const status = query.state.data?.status;
|
||||||
|
return status === "running" || status === "refreshing" ? 3000 : false;
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import {
|
|||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} 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 { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
@@ -63,6 +64,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 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: {
|
||||||
@@ -722,11 +724,13 @@ function UserTable({
|
|||||||
users,
|
users,
|
||||||
tab,
|
tab,
|
||||||
selectedUserId,
|
selectedUserId,
|
||||||
|
rankOffset = 0,
|
||||||
onSelect,
|
onSelect,
|
||||||
}: {
|
}: {
|
||||||
users: AdminLeaderboardUser[];
|
users: AdminLeaderboardUser[];
|
||||||
tab: UserRankTab;
|
tab: UserRankTab;
|
||||||
selectedUserId: string | null;
|
selectedUserId: string | null;
|
||||||
|
rankOffset?: number;
|
||||||
onSelect: (user: AdminLeaderboardUser) => void;
|
onSelect: (user: AdminLeaderboardUser) => void;
|
||||||
}) {
|
}) {
|
||||||
const max = Math.max(1, ...users.map((user) => userScore(user, tab)));
|
const max = Math.max(1, ...users.map((user) => userScore(user, tab)));
|
||||||
@@ -749,7 +753,7 @@ function UserTable({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex size-8 items-center justify-center rounded-md bg-[#FFF1F1] text-sm font-semibold text-[#C1121F] dark:bg-[#3A1215] dark:text-[#F2B8BB]">
|
<div className="flex size-8 items-center justify-center rounded-md bg-[#FFF1F1] text-sm font-semibold text-[#C1121F] dark:bg-[#3A1215] dark:text-[#F2B8BB]">
|
||||||
{index + 1}
|
{rankOffset + index + 1}
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -1041,6 +1045,7 @@ export default function AdminUserLeaderboardPage() {
|
|||||||
const [groupRules, setGroupRules] = useState("zero,two");
|
const [groupRules, setGroupRules] = useState("zero,two");
|
||||||
const [showGroupStats, setShowGroupStats] = useState(false);
|
const [showGroupStats, setShowGroupStats] = useState(false);
|
||||||
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
|
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
|
||||||
|
const [userPage, setUserPage] = useState(1);
|
||||||
const apiRange = useMemo(
|
const apiRange = useMemo(
|
||||||
() => rangeForApi(range),
|
() => rangeForApi(range),
|
||||||
[range],
|
[range],
|
||||||
@@ -1082,7 +1087,23 @@ export default function AdminUserLeaderboardPage() {
|
|||||||
}, [data, query, tab]);
|
}, [data, query, tab]);
|
||||||
|
|
||||||
const overview = data?.overview;
|
const overview = data?.overview;
|
||||||
|
const leaderboardStatus = data?.status;
|
||||||
|
const leaderboardPending = leaderboardStatus === "running" || leaderboardStatus === "refreshing";
|
||||||
|
const leaderboardHasRows = rankedUsers.length > 0 || (overview?.total_runs ?? 0) > 0 || (overview?.total_users ?? 0) > 0;
|
||||||
const topUsers = rankedUsers.slice(0, 3);
|
const topUsers = rankedUsers.slice(0, 3);
|
||||||
|
const userPageCount = Math.max(1, Math.ceil(rankedUsers.length / LEADERBOARD_PAGE_SIZE));
|
||||||
|
const visibleUsers = useMemo(() => {
|
||||||
|
const start = (userPage - 1) * LEADERBOARD_PAGE_SIZE;
|
||||||
|
return rankedUsers.slice(start, start + LEADERBOARD_PAGE_SIZE);
|
||||||
|
}, [rankedUsers, userPage]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setUserPage(1);
|
||||||
|
}, [apiRange.since, apiRange.until, query, tab]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setUserPage((page) => Math.min(page, userPageCount));
|
||||||
|
}, [userPageCount]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (rankedUsers.length === 0) {
|
if (rankedUsers.length === 0) {
|
||||||
@@ -1411,9 +1432,14 @@ export default function AdminUserLeaderboardPage() {
|
|||||||
<ScrollArea className="min-h-0 flex-1">
|
<ScrollArea className="min-h-0 flex-1">
|
||||||
<div className="space-y-5 p-6">
|
<div className="space-y-5 p-6">
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div className="flex items-center justify-center gap-2 py-20 text-sm text-[#5F6368]">
|
<div className="flex flex-col items-center justify-center gap-3 py-20 text-center text-sm text-[#5F6368]">
|
||||||
<Loader2 className="size-4 animate-spin" />
|
<div className="flex items-center gap-2">
|
||||||
加载统计数据
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
<span>正在统计排行榜数据</span>
|
||||||
|
</div>
|
||||||
|
<div className="max-w-md text-xs leading-5 text-[#7A5A5A] dark:text-[#D8B8B8]">
|
||||||
|
线上用户较多或时间范围较大时,统计时间可能较长,请耐心等待。为避免影响其他接口,系统会限制排行榜统计的并发数量。
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1423,8 +1449,29 @@ export default function AdminUserLeaderboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isLoading && data && overview && (
|
{!isLoading && !error && leaderboardPending && !leaderboardHasRows && (
|
||||||
|
<div className="flex flex-col items-center justify-center gap-3 rounded-lg border border-[#F1D6D6] bg-white px-6 py-16 text-center text-sm text-[#5F6368] shadow-sm dark:border-[#462123] dark:bg-[#230D0F]">
|
||||||
|
<div className="flex items-center gap-2 font-medium text-[#202124] dark:text-white">
|
||||||
|
<Loader2 className="size-4 animate-spin text-[#C1121F]" />
|
||||||
|
<span>排行榜正在后台统计</span>
|
||||||
|
</div>
|
||||||
|
<div className="max-w-lg text-xs leading-5 text-[#7A5A5A] dark:text-[#D8B8B8]">
|
||||||
|
{data?.message || "统计任务已放到后台执行,页面会自动刷新结果。这样排行榜慢慢算,不会长时间占住当前接口请求。"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLoading && data && overview && (!leaderboardPending || leaderboardHasRows) && (
|
||||||
<>
|
<>
|
||||||
|
{leaderboardPending && leaderboardHasRows && (
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-[#FDE68A] bg-[#FFFBEB] px-4 py-3 text-sm text-[#92400E] dark:border-amber-200/25 dark:bg-amber-300/10 dark:text-amber-100">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
<span>{data.message || "正在后台刷新排行榜,当前展示缓存结果。"}</span>
|
||||||
|
</div>
|
||||||
|
{data.generated_at && <span className="text-xs">缓存生成于 {fmtTime(data.generated_at)}</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||||
<StatCard icon={Users} accent="crimson" label="总用户" value={fmtNumber(overview.total_users)} note={`${fmtNumber(overview.active_users)} 位在当前时间段活跃`} />
|
<StatCard icon={Users} accent="crimson" label="总用户" value={fmtNumber(overview.total_users)} note={`${fmtNumber(overview.active_users)} 位在当前时间段活跃`} />
|
||||||
<StatCard icon={MousePointerClick} accent="teal" label="使用次数" value={fmtNumber(overview.total_runs)} note={`${fmtNumber(overview.total_threads)} 个会话被创建`} />
|
<StatCard icon={MousePointerClick} accent="teal" label="使用次数" value={fmtNumber(overview.total_runs)} note={`${fmtNumber(overview.total_threads)} 个会话被创建`} />
|
||||||
@@ -1492,11 +1539,23 @@ export default function AdminUserLeaderboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<UserTable
|
<UserTable
|
||||||
users={rankedUsers}
|
users={visibleUsers}
|
||||||
tab={tab}
|
tab={tab}
|
||||||
selectedUserId={selectedUserId}
|
selectedUserId={selectedUserId}
|
||||||
|
rankOffset={(userPage - 1) * LEADERBOARD_PAGE_SIZE}
|
||||||
onSelect={(user) => setSelectedUserId(user.user_id)}
|
onSelect={(user) => setSelectedUserId(user.user_id)}
|
||||||
/>
|
/>
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3 text-xs text-[#6B7280] dark:text-slate-400">
|
||||||
|
<span>
|
||||||
|
共 {fmtNumber(rankedUsers.length)} 位用户,当前第 {userPage} / {userPageCount} 页
|
||||||
|
</span>
|
||||||
|
<Pagination
|
||||||
|
page={userPage}
|
||||||
|
pageSize={LEADERBOARD_PAGE_SIZE}
|
||||||
|
total={rankedUsers.length}
|
||||||
|
onPageChange={setUserPage}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="overflow-hidden rounded-lg border border-[#F1D6D6] bg-white shadow-sm dark:border-[#462123] dark:bg-[#230D0F]">
|
<section className="overflow-hidden rounded-lg border border-[#F1D6D6] bg-white shadow-sm dark:border-[#462123] dark:bg-[#230D0F]">
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ export default function AgentChatPage() {
|
|||||||
const pendingMessageHandled = useRef(false);
|
const pendingMessageHandled = useRef(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (pendingMessageHandled.current) return;
|
if (pendingMessageHandled.current) return;
|
||||||
if (!agent_id || !isNewThread || hasStartedConversation) return;
|
if (!agent_id || !isNewThread) return;
|
||||||
let pending: { agentId?: string; text?: string } | null = null;
|
let pending: { agentId?: string; text?: string } | null = null;
|
||||||
try {
|
try {
|
||||||
const raw = sessionStorage.getItem("pendingAgentMessage");
|
const raw = sessionStorage.getItem("pendingAgentMessage");
|
||||||
@@ -143,7 +143,7 @@ export default function AgentChatPage() {
|
|||||||
pendingMessageHandled.current = true;
|
pendingMessageHandled.current = true;
|
||||||
sessionStorage.removeItem("pendingAgentMessage");
|
sessionStorage.removeItem("pendingAgentMessage");
|
||||||
handleSubmit({ text: pending.text, files: [] });
|
handleSubmit({ text: pending.text, files: [] });
|
||||||
}, [agent_id, isNewThread, hasStartedConversation, handleSubmit]);
|
}, [agent_id, isNewThread, handleSubmit]);
|
||||||
|
|
||||||
const handleClarificationSubmit = useCallback(
|
const handleClarificationSubmit = useCallback(
|
||||||
(answer: string) => {
|
(answer: string) => {
|
||||||
|
|||||||
@@ -13,9 +13,12 @@ methods' ``user_id`` parameter is designed for.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
|
import time
|
||||||
from collections.abc import AsyncIterator
|
from collections.abc import AsyncIterator
|
||||||
from difflib import SequenceMatcher
|
from difflib import SequenceMatcher
|
||||||
from hashlib import sha256
|
from hashlib import sha256
|
||||||
@@ -53,6 +56,27 @@ from deerflow.persistence.user.model import UserRow
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter(prefix="/api/admin/users", tags=["admin-users"])
|
router = APIRouter(prefix="/api/admin/users", tags=["admin-users"])
|
||||||
|
|
||||||
|
_LEADERBOARD_MAX_CONCURRENT = max(
|
||||||
|
1,
|
||||||
|
int(os.getenv("ADMIN_LEADERBOARD_MAX_CONCURRENT", "1")),
|
||||||
|
)
|
||||||
|
_LEADERBOARD_TIMEOUT_SECONDS = max(
|
||||||
|
5.0,
|
||||||
|
float(os.getenv("ADMIN_LEADERBOARD_TIMEOUT_SECONDS", "45")),
|
||||||
|
)
|
||||||
|
_LEADERBOARD_ACQUIRE_TIMEOUT_SECONDS = max(
|
||||||
|
0.1,
|
||||||
|
float(os.getenv("ADMIN_LEADERBOARD_ACQUIRE_TIMEOUT_SECONDS", "0.5")),
|
||||||
|
)
|
||||||
|
_LEADERBOARD_CACHE_TTL_SECONDS = max(
|
||||||
|
5.0,
|
||||||
|
float(os.getenv("ADMIN_LEADERBOARD_CACHE_TTL_SECONDS", "300")),
|
||||||
|
)
|
||||||
|
_leaderboard_semaphore = asyncio.Semaphore(_LEADERBOARD_MAX_CONCURRENT)
|
||||||
|
_leaderboard_jobs: dict[str, asyncio.Task] = {}
|
||||||
|
_leaderboard_cache: dict[str, dict] = {}
|
||||||
|
_leaderboard_jobs_lock = asyncio.Lock()
|
||||||
|
|
||||||
|
|
||||||
async def _require_admin(request: Request) -> None:
|
async def _require_admin(request: Request) -> None:
|
||||||
"""Reject non-admin callers. In no-auth mode there is no user → allow."""
|
"""Reject non-admin callers. In no-auth mode there is no user → allow."""
|
||||||
@@ -138,6 +162,9 @@ class AdminLeaderboardResponse(BaseModel):
|
|||||||
range_days: int
|
range_days: int
|
||||||
since: str
|
since: str
|
||||||
until: str
|
until: str
|
||||||
|
status: str = "ready"
|
||||||
|
generated_at: str = ""
|
||||||
|
message: str = ""
|
||||||
overview: AdminLeaderboardOverview
|
overview: AdminLeaderboardOverview
|
||||||
users_by_activity: list[AdminLeaderboardUser] = Field(default_factory=list)
|
users_by_activity: list[AdminLeaderboardUser] = Field(default_factory=list)
|
||||||
users_by_questions: list[AdminLeaderboardUser] = Field(default_factory=list)
|
users_by_questions: list[AdminLeaderboardUser] = Field(default_factory=list)
|
||||||
@@ -965,31 +992,61 @@ async def _query_overview(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/leaderboard", response_model=AdminLeaderboardResponse)
|
def _empty_leaderboard_response(
|
||||||
async def get_user_leaderboard(
|
since_dt: datetime,
|
||||||
request: Request,
|
until_dt: datetime,
|
||||||
days: int = Query(default=30, ge=1, le=365),
|
range_days: int,
|
||||||
since: str | None = Query(default=None, description="Inclusive range start, ISO datetime"),
|
*,
|
||||||
until: str | None = Query(default=None, description="Inclusive range end, ISO datetime"),
|
status: str,
|
||||||
exclude_user_ids: list[str] | None = Query(default=None, description="User ids excluded from this leaderboard aggregation"),
|
message: str,
|
||||||
include_scheduled: bool | None = Query(default=None, description="Include runs created by scheduled tasks"),
|
|
||||||
include_admins: bool | None = Query(default=None, description="Include admin accounts in leaderboard aggregation"),
|
|
||||||
include_failed: bool | None = Query(default=None, description="Include failed runs and LLM calls"),
|
|
||||||
limit: int = Query(default=20, ge=5, le=100),
|
|
||||||
) -> AdminLeaderboardResponse:
|
) -> AdminLeaderboardResponse:
|
||||||
"""Return admin analytics for user activity, Q&A, skills, questions, and trends."""
|
return AdminLeaderboardResponse(
|
||||||
await _require_admin(request)
|
range_days=range_days,
|
||||||
session_factory = get_session_factory()
|
since=_iso(since_dt),
|
||||||
if session_factory is None:
|
until=_iso(until_dt),
|
||||||
raise HTTPException(status_code=503, detail="Analytics requires database persistence")
|
status=status,
|
||||||
|
message=message,
|
||||||
|
overview=AdminLeaderboardOverview(),
|
||||||
|
)
|
||||||
|
|
||||||
settings = load_system_settings().leaderboard
|
|
||||||
since_dt, until_dt, range_days = _resolve_range(days, since, until)
|
def _leaderboard_cache_key(
|
||||||
excluded = {uid for uid in settings.exclude_user_ids if uid}
|
*,
|
||||||
excluded.update({uid for uid in (exclude_user_ids or []) if uid})
|
since_dt: datetime,
|
||||||
include_scheduled = settings.include_scheduled if include_scheduled is None else include_scheduled
|
until_dt: datetime,
|
||||||
include_admins = settings.include_admins if include_admins is None else include_admins
|
limit: int,
|
||||||
include_failed = settings.include_failed if include_failed is None else include_failed
|
excluded: set[str],
|
||||||
|
include_scheduled: bool,
|
||||||
|
include_admins: bool,
|
||||||
|
include_failed: bool,
|
||||||
|
cleanup_system_questions: bool,
|
||||||
|
) -> str:
|
||||||
|
payload = {
|
||||||
|
"version": 2,
|
||||||
|
"since": _iso(since_dt),
|
||||||
|
"until": _iso(until_dt),
|
||||||
|
"limit": limit,
|
||||||
|
"excluded": sorted(excluded),
|
||||||
|
"include_scheduled": include_scheduled,
|
||||||
|
"include_admins": include_admins,
|
||||||
|
"include_failed": include_failed,
|
||||||
|
"cleanup_system_questions": cleanup_system_questions,
|
||||||
|
}
|
||||||
|
return sha256(json.dumps(payload, sort_keys=True, ensure_ascii=False).encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
async def _build_user_leaderboard_response(
|
||||||
|
session_factory,
|
||||||
|
settings,
|
||||||
|
since_dt: datetime,
|
||||||
|
until_dt: datetime,
|
||||||
|
range_days: int,
|
||||||
|
limit: int,
|
||||||
|
excluded: set[str],
|
||||||
|
include_scheduled: bool,
|
||||||
|
include_admins: bool,
|
||||||
|
include_failed: bool,
|
||||||
|
) -> AdminLeaderboardResponse:
|
||||||
async with session_factory() as session:
|
async with session_factory() as session:
|
||||||
common = {
|
common = {
|
||||||
"exclude_user_ids": excluded,
|
"exclude_user_ids": excluded,
|
||||||
@@ -1023,6 +1080,8 @@ async def get_user_leaderboard(
|
|||||||
range_days=range_days,
|
range_days=range_days,
|
||||||
since=_iso(since_dt),
|
since=_iso(since_dt),
|
||||||
until=_iso(until_dt),
|
until=_iso(until_dt),
|
||||||
|
status="ready",
|
||||||
|
generated_at=_iso(datetime.utcnow()),
|
||||||
overview=overview,
|
overview=overview,
|
||||||
users_by_activity=sorted(users, key=lambda u: (u.run_count + u.tool_call_count, u.total_tokens), reverse=True)[:limit],
|
users_by_activity=sorted(users, key=lambda u: (u.run_count + u.tool_call_count, u.total_tokens), reverse=True)[:limit],
|
||||||
users_by_questions=sorted(users, key=lambda u: (u.qa_count, u.run_count), reverse=True)[:limit],
|
users_by_questions=sorted(users, key=lambda u: (u.qa_count, u.run_count), reverse=True)[:limit],
|
||||||
@@ -1038,6 +1097,225 @@ async def get_user_leaderboard(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _refresh_leaderboard_cache(
|
||||||
|
cache_key: str,
|
||||||
|
*,
|
||||||
|
session_factory,
|
||||||
|
settings,
|
||||||
|
since_dt: datetime,
|
||||||
|
until_dt: datetime,
|
||||||
|
range_days: int,
|
||||||
|
limit: int,
|
||||||
|
excluded: set[str],
|
||||||
|
include_scheduled: bool,
|
||||||
|
include_admins: bool,
|
||||||
|
include_failed: bool,
|
||||||
|
) -> None:
|
||||||
|
acquired = False
|
||||||
|
try:
|
||||||
|
await _leaderboard_semaphore.acquire()
|
||||||
|
acquired = True
|
||||||
|
response = await asyncio.wait_for(
|
||||||
|
_build_user_leaderboard_response(
|
||||||
|
session_factory,
|
||||||
|
settings,
|
||||||
|
since_dt,
|
||||||
|
until_dt,
|
||||||
|
range_days,
|
||||||
|
limit,
|
||||||
|
excluded,
|
||||||
|
include_scheduled,
|
||||||
|
include_admins,
|
||||||
|
include_failed,
|
||||||
|
),
|
||||||
|
timeout=_LEADERBOARD_TIMEOUT_SECONDS,
|
||||||
|
)
|
||||||
|
_leaderboard_cache[cache_key] = {
|
||||||
|
"created_at": time.monotonic(),
|
||||||
|
"response": response,
|
||||||
|
}
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
logger.warning("Leaderboard refresh timed out for key=%s", cache_key)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Leaderboard refresh failed for key=%s", cache_key)
|
||||||
|
finally:
|
||||||
|
if acquired:
|
||||||
|
_leaderboard_semaphore.release()
|
||||||
|
async with _leaderboard_jobs_lock:
|
||||||
|
_leaderboard_jobs.pop(cache_key, None)
|
||||||
|
|
||||||
|
|
||||||
|
async def _ensure_leaderboard_refresh(cache_key: str, **kwargs) -> None:
|
||||||
|
async with _leaderboard_jobs_lock:
|
||||||
|
task = _leaderboard_jobs.get(cache_key)
|
||||||
|
if task and not task.done():
|
||||||
|
return
|
||||||
|
_leaderboard_jobs[cache_key] = asyncio.create_task(
|
||||||
|
_refresh_leaderboard_cache(cache_key, **kwargs)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/leaderboard", response_model=AdminLeaderboardResponse)
|
||||||
|
async def get_user_leaderboard(
|
||||||
|
request: Request,
|
||||||
|
days: int = Query(default=30, ge=1, le=365),
|
||||||
|
since: str | None = Query(default=None, description="Inclusive range start, ISO datetime"),
|
||||||
|
until: str | None = Query(default=None, description="Inclusive range end, ISO datetime"),
|
||||||
|
exclude_user_ids: list[str] | None = Query(default=None, description="User ids excluded from this leaderboard aggregation"),
|
||||||
|
include_scheduled: bool | None = Query(default=None, description="Include runs created by scheduled tasks"),
|
||||||
|
include_admins: bool | None = Query(default=None, description="Include admin accounts in leaderboard aggregation"),
|
||||||
|
include_failed: bool | None = Query(default=None, description="Include failed runs and LLM calls"),
|
||||||
|
limit: int = Query(default=20, ge=5, le=100),
|
||||||
|
) -> AdminLeaderboardResponse:
|
||||||
|
"""Return admin analytics for user activity, Q&A, skills, questions, and trends."""
|
||||||
|
await _require_admin(request)
|
||||||
|
session_factory = get_session_factory()
|
||||||
|
if session_factory is None:
|
||||||
|
raise HTTPException(status_code=503, detail="Analytics requires database persistence")
|
||||||
|
|
||||||
|
settings = load_system_settings().leaderboard
|
||||||
|
since_dt, until_dt, range_days = _resolve_range(days, since, until)
|
||||||
|
excluded = {uid for uid in settings.exclude_user_ids if uid}
|
||||||
|
excluded.update({uid for uid in (exclude_user_ids or []) if uid})
|
||||||
|
resolved_include_scheduled = settings.include_scheduled if include_scheduled is None else include_scheduled
|
||||||
|
resolved_include_admins = settings.include_admins if include_admins is None else include_admins
|
||||||
|
resolved_include_failed = settings.include_failed if include_failed is None else include_failed
|
||||||
|
cache_key = _leaderboard_cache_key(
|
||||||
|
since_dt=since_dt,
|
||||||
|
until_dt=until_dt,
|
||||||
|
limit=limit,
|
||||||
|
excluded=excluded,
|
||||||
|
include_scheduled=resolved_include_scheduled,
|
||||||
|
include_admins=resolved_include_admins,
|
||||||
|
include_failed=resolved_include_failed,
|
||||||
|
cleanup_system_questions=settings.cleanup_system_questions,
|
||||||
|
)
|
||||||
|
|
||||||
|
cached = _leaderboard_cache.get(cache_key)
|
||||||
|
if cached:
|
||||||
|
response = cached["response"].model_copy()
|
||||||
|
age = time.monotonic() - float(cached["created_at"])
|
||||||
|
if age <= _LEADERBOARD_CACHE_TTL_SECONDS:
|
||||||
|
response.status = "ready"
|
||||||
|
return response
|
||||||
|
response.status = "refreshing"
|
||||||
|
response.message = "排行榜缓存已返回,后台正在刷新最新统计。"
|
||||||
|
await _ensure_leaderboard_refresh(
|
||||||
|
cache_key,
|
||||||
|
session_factory=session_factory,
|
||||||
|
settings=settings,
|
||||||
|
since_dt=since_dt,
|
||||||
|
until_dt=until_dt,
|
||||||
|
range_days=range_days,
|
||||||
|
limit=limit,
|
||||||
|
excluded=excluded,
|
||||||
|
include_scheduled=resolved_include_scheduled,
|
||||||
|
include_admins=resolved_include_admins,
|
||||||
|
include_failed=resolved_include_failed,
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|
||||||
|
await _ensure_leaderboard_refresh(
|
||||||
|
cache_key,
|
||||||
|
session_factory=session_factory,
|
||||||
|
settings=settings,
|
||||||
|
since_dt=since_dt,
|
||||||
|
until_dt=until_dt,
|
||||||
|
range_days=range_days,
|
||||||
|
limit=limit,
|
||||||
|
excluded=excluded,
|
||||||
|
include_scheduled=resolved_include_scheduled,
|
||||||
|
include_admins=resolved_include_admins,
|
||||||
|
include_failed=resolved_include_failed,
|
||||||
|
)
|
||||||
|
return _empty_leaderboard_response(
|
||||||
|
since_dt,
|
||||||
|
until_dt,
|
||||||
|
range_days,
|
||||||
|
status="running",
|
||||||
|
message="排行榜统计已在后台运行。线上用户较多时会慢一些,页面会自动刷新结果。",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(
|
||||||
|
_leaderboard_semaphore.acquire(),
|
||||||
|
timeout=_LEADERBOARD_ACQUIRE_TIMEOUT_SECONDS,
|
||||||
|
)
|
||||||
|
except TimeoutError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=429,
|
||||||
|
detail="排行榜正在统计中,请稍后再试。线上用户较多时统计时间可能较长,请耐心等待。",
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
async def build_response() -> AdminLeaderboardResponse:
|
||||||
|
settings = load_system_settings().leaderboard
|
||||||
|
since_dt, until_dt, range_days = _resolve_range(days, since, until)
|
||||||
|
excluded = {uid for uid in settings.exclude_user_ids if uid}
|
||||||
|
excluded.update({uid for uid in (exclude_user_ids or []) if uid})
|
||||||
|
resolved_include_scheduled = settings.include_scheduled if include_scheduled is None else include_scheduled
|
||||||
|
resolved_include_admins = settings.include_admins if include_admins is None else include_admins
|
||||||
|
resolved_include_failed = settings.include_failed if include_failed is None else include_failed
|
||||||
|
async with session_factory() as session:
|
||||||
|
common = {
|
||||||
|
"exclude_user_ids": excluded,
|
||||||
|
"include_scheduled": resolved_include_scheduled,
|
||||||
|
"include_admins": resolved_include_admins,
|
||||||
|
"include_failed": resolved_include_failed,
|
||||||
|
}
|
||||||
|
tool_common = {
|
||||||
|
"exclude_user_ids": excluded,
|
||||||
|
"include_scheduled": resolved_include_scheduled,
|
||||||
|
"include_admins": resolved_include_admins,
|
||||||
|
}
|
||||||
|
question_common = {
|
||||||
|
**common,
|
||||||
|
"cleanup_system_questions": settings.cleanup_system_questions,
|
||||||
|
}
|
||||||
|
overview = await _query_overview(session, since_dt, until_dt, **common)
|
||||||
|
users = await _query_user_leaderboard(session, since_dt, until_dt, **common)
|
||||||
|
skills = await _query_call_rankings(session, since_dt, until_dt, user_id=None, field="skill", limit=limit, **tool_common)
|
||||||
|
tools = await _query_call_rankings(session, since_dt, until_dt, user_id=None, field="tool", limit=limit, **tool_common)
|
||||||
|
models = await _query_model_rankings(session, since_dt, until_dt, user_id=None, limit=limit, **common)
|
||||||
|
if not models:
|
||||||
|
models = await _query_run_dimensions(session, since_dt, until_dt, user_id=None, field="model", limit=limit, **common)
|
||||||
|
agents = await _query_run_dimensions(session, since_dt, until_dt, user_id=None, field="agent", limit=limit, **common)
|
||||||
|
run_statuses = await _query_run_dimensions(session, since_dt, until_dt, user_id=None, field="status", limit=limit, **common)
|
||||||
|
recent_questions = await _query_recent_questions(session, since_dt, until_dt, user_id=None, limit=limit, **question_common)
|
||||||
|
frequent_questions = await _query_frequent_questions(session, since_dt, until_dt, limit=limit, **question_common)
|
||||||
|
trends = await _query_trends(session, since_dt, until_dt, **common)
|
||||||
|
|
||||||
|
return AdminLeaderboardResponse(
|
||||||
|
range_days=range_days,
|
||||||
|
since=_iso(since_dt),
|
||||||
|
until=_iso(until_dt),
|
||||||
|
overview=overview,
|
||||||
|
users_by_activity=sorted(users, key=lambda u: (u.run_count + u.tool_call_count, u.total_tokens), reverse=True)[:limit],
|
||||||
|
users_by_questions=sorted(users, key=lambda u: (u.qa_count, u.run_count), reverse=True)[:limit],
|
||||||
|
users_by_tokens=sorted(users, key=lambda u: u.total_tokens, reverse=True)[:limit],
|
||||||
|
skills=skills,
|
||||||
|
tools=tools,
|
||||||
|
models=models,
|
||||||
|
agents=agents,
|
||||||
|
run_statuses=run_statuses,
|
||||||
|
recent_questions=recent_questions,
|
||||||
|
frequent_questions=frequent_questions,
|
||||||
|
trends=trends,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
return await asyncio.wait_for(
|
||||||
|
build_response(),
|
||||||
|
timeout=_LEADERBOARD_TIMEOUT_SECONDS,
|
||||||
|
)
|
||||||
|
except TimeoutError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=504,
|
||||||
|
detail="排行榜统计时间过长,请缩短时间范围后重试,或稍后再查看。",
|
||||||
|
) from exc
|
||||||
|
finally:
|
||||||
|
_leaderboard_semaphore.release()
|
||||||
|
|
||||||
|
|
||||||
async def _build_leaderboard_report_prompt(
|
async def _build_leaderboard_report_prompt(
|
||||||
session_factory,
|
session_factory,
|
||||||
settings,
|
settings,
|
||||||
|
|||||||
Reference in new issue
Block a user