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
@@ -13,9 +13,12 @@ methods' ``user_id`` parameter is designed for.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from collections.abc import AsyncIterator
|
||||
from difflib import SequenceMatcher
|
||||
from hashlib import sha256
|
||||
@@ -53,6 +56,27 @@ from deerflow.persistence.user.model import UserRow
|
||||
logger = logging.getLogger(__name__)
|
||||
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:
|
||||
"""Reject non-admin callers. In no-auth mode there is no user → allow."""
|
||||
@@ -138,6 +162,9 @@ class AdminLeaderboardResponse(BaseModel):
|
||||
range_days: int
|
||||
since: str
|
||||
until: str
|
||||
status: str = "ready"
|
||||
generated_at: str = ""
|
||||
message: str = ""
|
||||
overview: AdminLeaderboardOverview
|
||||
users_by_activity: 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)
|
||||
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),
|
||||
def _empty_leaderboard_response(
|
||||
since_dt: datetime,
|
||||
until_dt: datetime,
|
||||
range_days: int,
|
||||
*,
|
||||
status: str,
|
||||
message: str,
|
||||
) -> 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")
|
||||
return AdminLeaderboardResponse(
|
||||
range_days=range_days,
|
||||
since=_iso(since_dt),
|
||||
until=_iso(until_dt),
|
||||
status=status,
|
||||
message=message,
|
||||
overview=AdminLeaderboardOverview(),
|
||||
)
|
||||
|
||||
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})
|
||||
include_scheduled = settings.include_scheduled if include_scheduled is None else include_scheduled
|
||||
include_admins = settings.include_admins if include_admins is None else include_admins
|
||||
include_failed = settings.include_failed if include_failed is None else include_failed
|
||||
|
||||
def _leaderboard_cache_key(
|
||||
*,
|
||||
since_dt: datetime,
|
||||
until_dt: datetime,
|
||||
limit: int,
|
||||
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:
|
||||
common = {
|
||||
"exclude_user_ids": excluded,
|
||||
@@ -1023,6 +1080,8 @@ async def get_user_leaderboard(
|
||||
range_days=range_days,
|
||||
since=_iso(since_dt),
|
||||
until=_iso(until_dt),
|
||||
status="ready",
|
||||
generated_at=_iso(datetime.utcnow()),
|
||||
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],
|
||||
@@ -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(
|
||||
session_factory,
|
||||
settings,
|
||||
|
||||
Reference in new issue
Block a user