deerflow-code/offline-backend-20260512/backend/app/gateway/routers/admin_users.py
2026-09-07 18:24:55 +08:00

3009 lines
114 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Admin-only window into another user's activity.
Lets an administrator inspect any user's conversation history (threads + the
Q&A messages inside them) and their accumulated memory (global + per-agent
buckets). All endpoints require ``system_role == "admin"``; they are strictly
read-only.
The underlying stores normally auto-scope every query to the *calling* user
via a contextvar. These endpoints pass an explicit ``user_id`` so the admin
reads the *target* user's data instead — which is exactly what those store
methods' ``user_id`` parameter is designed for.
"""
from __future__ import annotations
import asyncio
import json
import logging
import os
import re
from collections.abc import AsyncIterator
from contextvars import ContextVar
from hashlib import sha256
from time import perf_counter
from typing import Any
from datetime import UTC, datetime, timedelta, time as datetime_time
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
from sqlalchemy import and_, case, desc, func, not_, or_, select, text
from langchain_core.messages import HumanMessage, SystemMessage
from app.gateway.deps import (
get_config,
get_agent_store,
get_checkpointer,
get_current_user_from_request,
get_thread_store,
)
from deerflow.agents.memory import BuiltinFileProvider
from deerflow.config.app_config import AppConfig
from deerflow.config.paths import get_paths
from deerflow.config.system_settings import load_system_settings
from deerflow.models import create_chat_model
from deerflow.runtime import serialize_channel_values
from app.gateway.routers.memory import _build_v1_provider, _is_v1
from deerflow.persistence.engine import get_session_factory
from deerflow.persistence.admin_stats.model import AdminLeaderboardDailyStatRow
from deerflow.persistence.llm_metrics.model import LlmCallMetricRow
from deerflow.persistence.run.model import RunRow
from deerflow.persistence.scheduled_tasks.model import ScheduledTaskRunRow
from deerflow.persistence.thread_meta.model import ThreadMetaRow
from deerflow.persistence.tool_metrics.model import ToolCallMetricRow
from deerflow.persistence.types import BEIJING_TZ
from deerflow.persistence.user.model import UserRow
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/admin/users", tags=["admin-users"])
self_router = APIRouter(prefix="/api/users/me", tags=["user-qa"])
_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_QUERY_PAUSE_SECONDS = max(
0.0,
float(os.getenv("ADMIN_LEADERBOARD_QUERY_PAUSE_SECONDS", "0.05")),
)
_LEADERBOARD_START_DELAY_SECONDS = max(
0.0,
float(os.getenv("ADMIN_LEADERBOARD_START_DELAY_SECONDS", "0.2")),
)
_LEADERBOARD_DAILY_SNAPSHOT_LIMIT = max(
100,
int(os.getenv("ADMIN_LEADERBOARD_DAILY_SNAPSHOT_LIMIT", "1000")),
)
_LEADERBOARD_TODAY_TTL_SECONDS = max(
30.0,
float(os.getenv("ADMIN_LEADERBOARD_TODAY_TTL_SECONDS", "43200")),
)
_LEADERBOARD_QUERY_TIMEOUT_MS = max(
500,
int(os.getenv("ADMIN_LEADERBOARD_QUERY_TIMEOUT_MS", "8000")),
)
_LEADERBOARD_LOCK_WAIT_SECONDS = max(
1,
int(os.getenv("ADMIN_LEADERBOARD_LOCK_WAIT_SECONDS", "5")),
)
_LEADERBOARD_QUEUE_ON_REQUEST_ENABLED = os.getenv(
"ADMIN_LEADERBOARD_QUEUE_ON_REQUEST_ENABLED",
os.getenv("ADMIN_LEADERBOARD_WEB_QUEUE_ENABLED", "true"),
).strip().lower() not in {
"0",
"false",
"no",
"off",
}
_leaderboard_semaphore = asyncio.Semaphore(_LEADERBOARD_MAX_CONCURRENT)
_LEADERBOARD_TRACE_CONTEXT: ContextVar[dict[str, object] | None] = ContextVar(
"leaderboard_trace_context",
default=None,
)
def _leaderboard_log_context(**context: object) -> dict[str, object] | None:
current = _LEADERBOARD_TRACE_CONTEXT.get()
merged: dict[str, object] = dict(current or {})
for key, value in context.items():
if value is None or value == "":
continue
merged[key] = value
return merged or None
def _leaderboard_sql_for_log(stmt) -> str:
try:
return str(stmt.compile(compile_kwargs={"literal_binds": True}))
except Exception:
return str(stmt)
async def _execute_leaderboard_query(session, step: str, stmt, **context: object):
log_context = _leaderboard_log_context(**context)
if not log_context:
return await session.execute(stmt)
started_at = datetime.now(BEIJING_TZ).isoformat()
sql = _leaderboard_sql_for_log(stmt)
logger.info(
"Leaderboard snapshot DB query start: step=%s stat_date=%s job=%s settings_hash=%s started_at=%s sql=%s",
step,
log_context.get("stat_date") or log_context.get("stat_dates") or "",
log_context.get("job_key") or "",
log_context.get("settings_hash") or "",
started_at,
sql,
)
start = perf_counter()
try:
result = await session.execute(stmt)
except Exception:
elapsed_ms = (perf_counter() - start) * 1000
logger.exception(
"Leaderboard snapshot DB query failed: step=%s stat_date=%s job=%s settings_hash=%s elapsed_ms=%.2f",
step,
log_context.get("stat_date") or log_context.get("stat_dates") or "",
log_context.get("job_key") or "",
log_context.get("settings_hash") or "",
elapsed_ms,
)
raise
elapsed_ms = (perf_counter() - start) * 1000
logger.info(
"Leaderboard snapshot DB query done: step=%s stat_date=%s job=%s settings_hash=%s finished_at=%s elapsed_ms=%.2f",
step,
log_context.get("stat_date") or log_context.get("stat_dates") or "",
log_context.get("job_key") or "",
log_context.get("settings_hash") or "",
datetime.now(BEIJING_TZ).isoformat(),
elapsed_ms,
)
return result
async def _time_leaderboard_db_operation(
step: str,
operation,
*,
sql: str = "",
**context: object,
):
log_context = _leaderboard_log_context(**context)
if not log_context:
return await operation()
logger.info(
"Leaderboard snapshot DB query start: step=%s stat_date=%s job=%s settings_hash=%s started_at=%s sql=%s",
step,
log_context.get("stat_date") or log_context.get("stat_dates") or "",
log_context.get("job_key") or "",
log_context.get("settings_hash") or "",
datetime.now(BEIJING_TZ).isoformat(),
sql,
)
start = perf_counter()
try:
result = await operation()
except Exception:
elapsed_ms = (perf_counter() - start) * 1000
logger.exception(
"Leaderboard snapshot DB query failed: step=%s stat_date=%s job=%s settings_hash=%s elapsed_ms=%.2f",
step,
log_context.get("stat_date") or log_context.get("stat_dates") or "",
log_context.get("job_key") or "",
log_context.get("settings_hash") or "",
elapsed_ms,
)
raise
elapsed_ms = (perf_counter() - start) * 1000
logger.info(
"Leaderboard snapshot DB query done: step=%s stat_date=%s job=%s settings_hash=%s finished_at=%s elapsed_ms=%.2f",
step,
log_context.get("stat_date") or log_context.get("stat_dates") or "",
log_context.get("job_key") or "",
log_context.get("settings_hash") or "",
datetime.now(BEIJING_TZ).isoformat(),
elapsed_ms,
)
return result
async def _require_admin(request: Request) -> None:
"""Reject non-admin callers. In no-auth mode there is no user → allow."""
try:
user = await get_current_user_from_request(request)
except HTTPException:
raise
if user is None:
return
if getattr(user, "system_role", None) != "admin":
raise HTTPException(status_code=403, detail="Admin only")
# ── Leaderboard / analytics ─────────────────────────────────────────────────
class AdminLeaderboardUser(BaseModel):
user_id: str
email: str = ""
system_role: str = "user"
thread_count: int = 0
run_count: int = 0
qa_count: int = 0
llm_call_count: int = 0
tool_call_count: int = 0
skill_call_count: int = 0
total_tokens: int = 0
input_tokens: int = 0
output_tokens: int = 0
last_active_at: str = ""
class AdminLeaderboardSkill(BaseModel):
name: str
call_count: int = 0
user_count: int = 0
success_count: int = 0
error_count: int = 0
avg_duration_ms: int = 0
last_used_at: str = ""
class AdminLeaderboardDimension(BaseModel):
name: str
count: int = 0
user_count: int = 0
success_count: int = 0
error_count: int = 0
total_tokens: int = 0
last_seen_at: str = ""
class AdminLeaderboardTrendPoint(BaseModel):
date: str
runs: int = 0
questions: int = 0
tokens: int = 0
users: int = 0
class AdminLeaderboardOverview(BaseModel):
total_users: int = 0
active_users: int = 0
total_threads: int = 0
total_runs: int = 0
total_questions: int = 0
total_tokens: int = 0
total_skill_calls: int = 0
total_tool_calls: int = 0
error_runs: int = 0
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)
users_by_tokens: list[AdminLeaderboardUser] = Field(default_factory=list)
skills: list[AdminLeaderboardSkill] = Field(default_factory=list)
tools: list[AdminLeaderboardSkill] = Field(default_factory=list)
models: list[AdminLeaderboardDimension] = Field(default_factory=list)
agents: list[AdminLeaderboardDimension] = Field(default_factory=list)
run_statuses: list[AdminLeaderboardDimension] = Field(default_factory=list)
trends: list[AdminLeaderboardTrendPoint] = Field(default_factory=list)
class AdminUserAnalyticsResponse(BaseModel):
range_days: int
since: str
until: str
user: AdminLeaderboardUser
overview: AdminLeaderboardOverview
skills: list[AdminLeaderboardSkill] = Field(default_factory=list)
tools: list[AdminLeaderboardSkill] = Field(default_factory=list)
models: list[AdminLeaderboardDimension] = Field(default_factory=list)
agents: list[AdminLeaderboardDimension] = Field(default_factory=list)
run_statuses: list[AdminLeaderboardDimension] = Field(default_factory=list)
trends: list[AdminLeaderboardTrendPoint] = Field(default_factory=list)
class AdminLeaderboardReportResponse(BaseModel):
report: str
generated_at: str
cached: bool = False
model_name: str | None = None
class AdminLeaderboardSnapshotStatusItem(BaseModel):
stat_date: str
status: str = "missing"
has_payload: bool = False
generated_at: str = ""
updated_at: str = ""
message: str = ""
error: str = ""
class AdminLeaderboardSnapshotStatusResponse(BaseModel):
settings_hash: str
since: str
until: str
total_days: int
ready_days: int
queued_days: int
running_days: int
error_days: int
missing_days: int
items: list[AdminLeaderboardSnapshotStatusItem] = Field(default_factory=list)
class AdminLeaderboardSnapshotBackfillRequest(BaseModel):
days: int = Field(default=30, ge=1, le=365)
since: str | None = Field(default=None, description="Inclusive range start, ISO datetime")
until: str | None = Field(default=None, description="Inclusive range end, ISO datetime")
force: bool = Field(default=False, description="Queue dates even when a ready snapshot already exists")
class AdminLeaderboardSnapshotBackfillResponse(BaseModel):
settings_hash: str
since: str
until: str
total_days: int
queued_dates: list[str] = Field(default_factory=list)
message: str = ""
def _now() -> datetime:
return datetime.now()
def _iso(value) -> str:
return value.isoformat() if isinstance(value, datetime) else str(value or "")
def _parse_datetime(value: str | None, fallback: datetime) -> datetime:
if not value:
return fallback
text = value.strip()
if not text:
return fallback
if text.endswith("Z"):
text = text[:-1] + "+00:00"
try:
return datetime.fromisoformat(text)
except ValueError as exc:
raise HTTPException(status_code=400, detail=f"Invalid datetime: {value}") from exc
def _parse_optional_datetime(value: str | None) -> datetime | None:
if not value:
return None
parsed = _parse_datetime(value, _now())
return parsed if parsed.tzinfo is not None else parsed.replace(tzinfo=BEIJING_TZ)
def _resolve_range(days: int, since: str | None, until: str | None) -> tuple[datetime, datetime, int]:
end = _parse_datetime(until, _now())
start = _parse_datetime(since, end - timedelta(days=max(1, min(days, 365))))
if start > end:
raise HTTPException(status_code=400, detail="since must be before until")
span_days = max(1, min(365, (end - start).days + 1))
return start, end, span_days
def _as_beijing(value: datetime) -> datetime:
if value.tzinfo is None:
value = value.replace(tzinfo=UTC)
return value.astimezone(BEIJING_TZ)
def _today_stat_date() -> str:
return datetime.now(BEIJING_TZ).date().isoformat()
def _stat_dates_for_range(since: datetime, until: datetime) -> list[str]:
start_date = _as_beijing(since).date()
end_date = _as_beijing(until).date()
if start_date > end_date:
raise HTTPException(status_code=400, detail="since must be before until")
count = min(366, (end_date - start_date).days + 1)
return [(start_date + timedelta(days=i)).isoformat() for i in range(count)]
def _bounds_for_stat_date(stat_date: str) -> tuple[datetime, datetime]:
try:
day = datetime.strptime(stat_date, "%Y-%m-%d").date()
except ValueError as exc:
raise HTTPException(status_code=400, detail=f"Invalid stat date: {stat_date}") from exc
start = datetime.combine(day, datetime_time.min).replace(tzinfo=BEIJING_TZ)
end = datetime.combine(day, datetime_time.max).replace(tzinfo=BEIJING_TZ)
return start, end
async def _try_set_session_value(session, sql: str) -> None:
try:
await _execute_leaderboard_query(session, "session_setting", text(sql))
except Exception:
logger.debug("Failed to apply analytics session setting: %s", sql, exc_info=True)
try:
await session.rollback()
except Exception:
logger.debug("Failed to rollback after analytics session setting failure", exc_info=True)
def _is_mariadb(dialect) -> bool:
"""SQLAlchemy reports ``dialect.name == 'mysql'`` for MariaDB too."""
if getattr(dialect, "_is_mariadb", False) or getattr(dialect, "is_mariadb", False):
return True
version_info = getattr(dialect, "server_version_info", None) or ()
return any("mariadb" in str(part).lower() for part in version_info)
async def _set_analytics_read_timeout(session) -> None:
"""Best-effort per-statement read timeout for the analytics queries.
The right knob differs by server, and some servers have none at all:
- MySQL >= 5.7.4: ``max_execution_time`` (milliseconds)
- MariaDB: ``max_statement_time`` (seconds)
- MySQL <= 5.6: no per-statement timeout variable — skip it entirely
(5.6 raises "Unknown system variable" otherwise)
- PostgreSQL/openGauss: ``statement_timeout`` (milliseconds)
"""
conn = await session.connection()
dialect = conn.dialect
name = dialect.name
if name == "mysql":
if _is_mariadb(dialect):
# MariaDB: max_statement_time is in SECONDS (fractional ok).
await _try_set_session_value(session, f"SET SESSION max_statement_time = {_LEADERBOARD_QUERY_TIMEOUT_MS / 1000}")
elif (getattr(dialect, "server_version_info", None) or ()) >= (5, 7, 4):
await _try_set_session_value(session, f"SET SESSION max_execution_time = {_LEADERBOARD_QUERY_TIMEOUT_MS}")
# else: MySQL <= 5.6 has no statement-timeout variable — skip silently.
await _try_set_session_value(session, f"SET SESSION innodb_lock_wait_timeout = {_LEADERBOARD_LOCK_WAIT_SECONDS}")
elif name in {"postgresql", "opengauss"}:
await _try_set_session_value(session, f"SET LOCAL statement_timeout = {_LEADERBOARD_QUERY_TIMEOUT_MS}")
def _question_count_expr():
return func.sum(case((RunRow.first_human_message.is_not(None), 1), else_=0))
_SYSTEM_MARKER_RE = re.compile(r"<(?:uploaded_files|attachments|files|system|metadata)>.*?</(?:uploaded_files|attachments|files|system|metadata)>", re.I | re.S)
_TAG_RE = re.compile(r"</?[^>]+>")
def _clean_question_text(value: str | None) -> str:
text = str(value or "").strip()
if not text:
return ""
text = _SYSTEM_MARKER_RE.sub(" ", text)
text = _TAG_RE.sub(" ", text)
text = re.sub(r"\s+", " ", text).strip()
return text
def _is_displayable_question(value: str | None) -> bool:
text = _clean_question_text(value)
if not text:
return False
lowered = text.lower()
return lowered not in {"uploaded_files", "attachments", "files"}
def _question_similarity_key(value: str) -> str:
return re.sub(r"[\W_]+", "", value.lower())
def _message_content_to_text(content) -> str:
if isinstance(content, str):
return content
if isinstance(content, list):
parts: list[str] = []
for item in content:
if isinstance(item, dict):
parts.append(str(item.get("text") or item.get("content") or ""))
else:
parts.append(str(item))
return "\n".join(part for part in parts if part.strip())
return str(content or "")
def _sse(event: str, data: dict) -> str:
return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
def _extract_chunk_parts(chunk: object) -> tuple[str, str]:
"""Return (thinking_delta, text_delta) from a LangChain streaming chunk."""
thinking = ""
text = ""
content = getattr(chunk, "content", "")
if isinstance(content, str):
text = content
elif isinstance(content, list):
for block in content:
if isinstance(block, str):
text += block
elif isinstance(block, dict):
block_type = block.get("type", "")
if block_type in {"thinking", "thinking_delta"}:
thinking += block.get("thinking", "")
elif block_type in {"text", "text_delta"}:
text += block.get("text", "")
elif block_type == "reasoning":
thinking += block.get("content", "")
if not thinking:
kwargs = getattr(chunk, "additional_kwargs", {}) or {}
reasoning = kwargs.get("reasoning_content") or ""
if isinstance(reasoning, str):
thinking = reasoning
return thinking, text
def _report_cache_file():
return get_paths().base_dir / "admin_leaderboard_reports.json"
def _load_report_cache() -> dict:
path = _report_cache_file()
if not path.exists():
return {}
try:
data = json.loads(path.read_text(encoding="utf-8"))
except Exception:
logger.warning("Failed to read leaderboard report cache: %s", path, exc_info=True)
return {}
return data if isinstance(data, dict) else {}
def _save_report_cache(cache: dict) -> None:
path = _report_cache_file()
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(cache, ensure_ascii=False, indent=2), encoding="utf-8")
def _resolve_report_model_name(config: AppConfig, model_name: str | None) -> str | None:
cleaned = (model_name or "").strip()
if cleaned:
return cleaned
return config.models[0].name if config.models else None
def _report_cache_key(
*,
since: datetime,
until: datetime,
limit: int,
model_name: str | None,
settings,
) -> str:
payload = {
"version": 5,
"since": _iso(since),
"until": _iso(until),
"limit": limit,
"model_name": model_name,
"settings": {
"exclude_user_ids": sorted(uid for uid in settings.exclude_user_ids if uid),
"include_scheduled": settings.include_scheduled,
"include_admins": settings.include_admins,
"include_failed": settings.include_failed,
"cleanup_system_questions": settings.cleanup_system_questions,
},
}
raw = json.dumps(payload, ensure_ascii=False, sort_keys=True, default=str)
return sha256(raw.encode("utf-8")).hexdigest()
def _cached_report(cache_key: str) -> dict | None:
item = _load_report_cache().get(cache_key)
if isinstance(item, dict) and isinstance(item.get("report"), str):
return item
return None
def _store_report_cache(cache_key: str, item: dict) -> None:
cache = _load_report_cache()
cache[cache_key] = item
if len(cache) > 80:
ordered = sorted(cache.items(), key=lambda kv: str(kv[1].get("generated_at") or ""))
cache = dict(ordered[-80:])
_save_report_cache(cache)
def _run_time_filter(since: datetime, until: datetime):
return and_(RunRow.created_at >= since, RunRow.created_at <= until)
def _non_scheduled_run_filter():
"""Exclude scheduler-generated runs that predate scheduled_task_runs links."""
return or_(
RunRow.first_human_message.is_(None),
not_(RunRow.first_human_message.like("【定时任务触发】%")),
)
def _non_scheduled_tool_filter():
"""Exclude legacy scheduled runs without joining runs into the metric scan."""
return ~(
select(1)
.select_from(RunRow)
.where(
RunRow.run_id == ToolCallMetricRow.run_id,
RunRow.first_human_message.like("【定时任务触发】%"),
)
.exists()
)
def _tool_time_filter(since: datetime, until: datetime):
return and_(ToolCallMetricRow.created_at >= since, ToolCallMetricRow.created_at <= until)
def _llm_time_filter(since: datetime, until: datetime):
return and_(LlmCallMetricRow.created_at >= since, LlmCallMetricRow.created_at <= until)
def _exclude_user_filter(column, exclude_user_ids: set[str]):
if not exclude_user_ids:
return None
return or_(column.is_(None), column.not_in(list(exclude_user_ids)))
def _apply_exclude(stmt, column, exclude_user_ids: set[str]):
condition = _exclude_user_filter(column, exclude_user_ids)
return stmt.where(condition) if condition is not None else stmt
def _admin_user_ids_stmt():
return select(UserRow.id).where(UserRow.system_role == "admin")
def _not_scheduled_run_id_filter(run_id_column):
"""Use a NULL-safe, index-friendly anti-semi join instead of NOT IN."""
return ~(
select(1)
.select_from(ScheduledTaskRunRow)
.where(ScheduledTaskRunRow.agent_run_id == run_id_column)
.exists()
)
def _apply_user_scope(stmt, column, *, exclude_user_ids: set[str], include_admins: bool):
stmt = _apply_exclude(stmt, column, exclude_user_ids)
if not include_admins:
stmt = stmt.where(or_(column.is_(None), column.not_in(_admin_user_ids_stmt())))
return stmt
def _apply_run_scope(
stmt,
*,
user_column,
run_id_column,
exclude_user_ids: set[str],
include_admins: bool,
include_failed: bool,
include_scheduled: bool,
):
stmt = _apply_user_scope(stmt, user_column, exclude_user_ids=exclude_user_ids, include_admins=include_admins)
if not include_failed:
stmt = stmt.where(RunRow.status != "error")
if not include_scheduled:
stmt = stmt.where(_not_scheduled_run_id_filter(run_id_column))
stmt = stmt.where(_non_scheduled_run_filter())
return stmt
def _apply_tool_scope(
stmt,
*,
exclude_user_ids: set[str],
include_admins: bool,
include_scheduled: bool,
):
stmt = _apply_user_scope(stmt, ToolCallMetricRow.user_id, exclude_user_ids=exclude_user_ids, include_admins=include_admins)
if not include_scheduled:
stmt = stmt.where(_not_scheduled_run_id_filter(ToolCallMetricRow.run_id))
stmt = stmt.where(_non_scheduled_tool_filter())
return stmt
async def _query_user_leaderboard(
session,
since: datetime,
until: datetime,
user_id: str | None = None,
exclude_user_ids: set[str] | None = None,
include_scheduled: bool = True,
include_admins: bool = True,
include_failed: bool = True,
) -> list[AdminLeaderboardUser]:
exclude_user_ids = exclude_user_ids or set()
run_agg = (
select(
RunRow.user_id.label("user_id"),
func.count(RunRow.run_id).label("run_count"),
_question_count_expr().label("qa_count"),
func.coalesce(func.sum(RunRow.llm_call_count), 0).label("llm_call_count"),
func.coalesce(func.sum(RunRow.total_tokens), 0).label("total_tokens"),
func.coalesce(func.sum(RunRow.total_input_tokens), 0).label("input_tokens"),
func.coalesce(func.sum(RunRow.total_output_tokens), 0).label("output_tokens"),
func.max(RunRow.updated_at).label("last_active_at"),
)
.where(_run_time_filter(since, until))
.group_by(RunRow.user_id)
)
run_agg = _apply_run_scope(
run_agg,
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,
).subquery()
thread_agg = (
select(ThreadMetaRow.user_id.label("user_id"), func.count(ThreadMetaRow.thread_id).label("thread_count"))
.where(and_(ThreadMetaRow.created_at >= since, ThreadMetaRow.created_at <= until))
.group_by(ThreadMetaRow.user_id)
)
thread_agg = _apply_user_scope(
thread_agg,
ThreadMetaRow.user_id,
exclude_user_ids=exclude_user_ids,
include_admins=include_admins,
).subquery()
tool_agg = (
select(
ToolCallMetricRow.user_id.label("user_id"),
func.count(ToolCallMetricRow.id).label("tool_call_count"),
func.sum(case((func.nullif(ToolCallMetricRow.skill_name, "").is_not(None), 1), else_=0)).label("skill_call_count"),
)
.where(_tool_time_filter(since, until))
.group_by(ToolCallMetricRow.user_id)
)
tool_agg = _apply_tool_scope(
tool_agg,
exclude_user_ids=exclude_user_ids,
include_admins=include_admins,
include_scheduled=include_scheduled,
).subquery()
stmt = (
select(
UserRow.id,
UserRow.email,
UserRow.system_role,
func.coalesce(thread_agg.c.thread_count, 0),
func.coalesce(run_agg.c.run_count, 0),
func.coalesce(run_agg.c.qa_count, 0),
func.coalesce(run_agg.c.llm_call_count, 0),
func.coalesce(tool_agg.c.tool_call_count, 0),
func.coalesce(tool_agg.c.skill_call_count, 0),
func.coalesce(run_agg.c.total_tokens, 0),
func.coalesce(run_agg.c.input_tokens, 0),
func.coalesce(run_agg.c.output_tokens, 0),
run_agg.c.last_active_at,
)
.outerjoin(run_agg, run_agg.c.user_id == UserRow.id)
.outerjoin(thread_agg, thread_agg.c.user_id == UserRow.id)
.outerjoin(tool_agg, tool_agg.c.user_id == UserRow.id)
)
if user_id:
stmt = stmt.where(UserRow.id == user_id)
stmt = _apply_user_scope(stmt, UserRow.id, exclude_user_ids=exclude_user_ids, include_admins=include_admins)
# 排行榜只统计真正产生过问答的用户;从未问答的账号(仅注册、零活跃)不计入榜单与人数。
# 个人分析(指定 user_id)不受此限制,仍可查看任意单个用户。
if user_id is None:
stmt = stmt.where(func.coalesce(run_agg.c.qa_count, 0) > 0)
stmt = stmt.order_by(
desc(func.coalesce(run_agg.c.run_count, 0) + func.coalesce(tool_agg.c.tool_call_count, 0)),
desc(func.coalesce(run_agg.c.total_tokens, 0)),
)
rows = (await _execute_leaderboard_query(session, "users", stmt)).all()
return [
AdminLeaderboardUser(
user_id=r[0],
email=r[1] or "",
system_role=r[2] or "user",
thread_count=int(r[3] or 0),
run_count=int(r[4] or 0),
qa_count=int(r[5] or 0),
llm_call_count=int(r[6] or 0),
tool_call_count=int(r[7] or 0),
skill_call_count=int(r[8] or 0),
total_tokens=int(r[9] or 0),
input_tokens=int(r[10] or 0),
output_tokens=int(r[11] or 0),
last_active_at=_iso(r[12]),
)
for r in rows
]
async def _query_call_rankings(
session,
since: datetime,
until: datetime,
*,
user_id: str | None,
field: str,
limit: int,
exclude_user_ids: set[str] | None = None,
include_scheduled: bool = True,
include_admins: bool = True,
) -> list[AdminLeaderboardSkill]:
exclude_user_ids = exclude_user_ids or set()
name_col = ToolCallMetricRow.skill_name if field == "skill" else ToolCallMetricRow.tool_name
call_count = func.count(ToolCallMetricRow.id).label("call_count")
last_used_at = func.max(ToolCallMetricRow.created_at).label("last_used_at")
stmt = (
select(
name_col.label("name"),
call_count,
func.count(func.distinct(ToolCallMetricRow.user_id)).label("user_count"),
func.sum(case((ToolCallMetricRow.status == "success", 1), else_=0)).label("success_count"),
func.sum(case((ToolCallMetricRow.status != "success", 1), else_=0)).label("error_count"),
func.coalesce(func.avg(ToolCallMetricRow.duration_ms), 0).label("avg_duration_ms"),
last_used_at,
)
.where(_tool_time_filter(since, until), name_col.is_not(None), name_col != "")
.group_by(name_col)
.order_by(call_count.desc(), last_used_at.desc())
.limit(limit)
)
if user_id:
stmt = stmt.where(ToolCallMetricRow.user_id == user_id)
stmt = _apply_tool_scope(
stmt,
exclude_user_ids=exclude_user_ids,
include_admins=include_admins,
include_scheduled=include_scheduled,
)
rows = (await _execute_leaderboard_query(session, f"{field}_rankings", stmt)).all()
return [
AdminLeaderboardSkill(
name=r[0] or "",
call_count=int(r[1] or 0),
user_count=int(r[2] or 0),
success_count=int(r[3] or 0),
error_count=int(r[4] or 0),
avg_duration_ms=int(r[5] or 0),
last_used_at=_iso(r[6]),
)
for r in rows
]
async def _query_run_dimensions(
session,
since: datetime,
until: datetime,
*,
user_id: str | None,
field: str,
limit: int,
exclude_user_ids: set[str] | None = None,
include_scheduled: bool = True,
include_admins: bool = True,
include_failed: bool = True,
) -> list[AdminLeaderboardDimension]:
exclude_user_ids = exclude_user_ids or set()
if field == "model":
name_col = RunRow.model_name
elif field == "agent":
name_col = RunRow.assistant_id
elif field == "status":
name_col = RunRow.status
else:
raise ValueError(f"Unknown run dimension: {field}")
count = func.count(RunRow.run_id).label("count")
last_seen_at = func.max(RunRow.updated_at).label("last_seen_at")
stmt = (
select(
name_col.label("name"),
count,
func.count(func.distinct(RunRow.user_id)).label("user_count"),
func.sum(case((RunRow.status == "success", 1), else_=0)).label("success_count"),
func.sum(case((RunRow.status == "error", 1), else_=0)).label("error_count"),
func.coalesce(func.sum(RunRow.total_tokens), 0).label("total_tokens"),
last_seen_at,
)
.where(_run_time_filter(since, until), name_col.is_not(None), name_col != "")
.group_by(name_col)
.order_by(count.desc(), last_seen_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, f"{field}_dimensions", stmt)).all()
return [
AdminLeaderboardDimension(
name=r[0] or "",
count=int(r[1] or 0),
user_count=int(r[2] or 0),
success_count=int(r[3] or 0),
error_count=int(r[4] or 0),
total_tokens=int(r[5] or 0),
last_seen_at=_iso(r[6]),
)
for r in rows
]
async def _query_model_rankings(
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,
) -> list[AdminLeaderboardDimension]:
exclude_user_ids = exclude_user_ids or set()
count = func.count(LlmCallMetricRow.id).label("count")
last_seen_at = func.max(LlmCallMetricRow.created_at).label("last_seen_at")
stmt = (
select(
LlmCallMetricRow.model_name.label("name"),
count,
func.count(func.distinct(LlmCallMetricRow.user_id)).label("user_count"),
func.sum(case((LlmCallMetricRow.status == "success", 1), else_=0)).label("success_count"),
func.sum(case((LlmCallMetricRow.status == "error", 1), else_=0)).label("error_count"),
func.coalesce(func.sum(LlmCallMetricRow.total_tokens), 0).label("total_tokens"),
last_seen_at,
)
.where(_llm_time_filter(since, until), LlmCallMetricRow.model_name.is_not(None), LlmCallMetricRow.model_name != "")
.group_by(LlmCallMetricRow.model_name)
.order_by(count.desc(), last_seen_at.desc())
.limit(limit)
)
if user_id:
stmt = stmt.where(LlmCallMetricRow.user_id == user_id)
stmt = _apply_user_scope(
stmt,
LlmCallMetricRow.user_id,
exclude_user_ids=exclude_user_ids,
include_admins=include_admins,
)
if not include_failed:
stmt = stmt.where(LlmCallMetricRow.status != "error")
if not include_scheduled:
stmt = stmt.where(_not_scheduled_run_id_filter(LlmCallMetricRow.run_id))
rows = (await _execute_leaderboard_query(session, "model_rankings", stmt)).all()
return [
AdminLeaderboardDimension(
name=r[0] or "",
count=int(r[1] or 0),
user_count=int(r[2] or 0),
success_count=int(r[3] or 0),
error_count=int(r[4] or 0),
total_tokens=int(r[5] or 0),
last_seen_at=_iso(r[6]),
)
for r in rows
]
async def _query_trends(
session,
since: datetime,
until: datetime,
*,
user_id: str | None = None,
exclude_user_ids: set[str] | None = None,
include_scheduled: bool = True,
include_admins: bool = True,
include_failed: bool = True,
) -> list[AdminLeaderboardTrendPoint]:
exclude_user_ids = exclude_user_ids or set()
bucket = func.date(RunRow.created_at)
stmt = (
select(
bucket.label("date"),
func.count(RunRow.run_id).label("runs"),
_question_count_expr().label("questions"),
func.coalesce(func.sum(RunRow.total_tokens), 0).label("tokens"),
func.count(func.distinct(RunRow.user_id)).label("users"),
)
.where(_run_time_filter(since, until))
.group_by(bucket)
.order_by(bucket.asc())
)
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, "trends", stmt)).all()
return [
AdminLeaderboardTrendPoint(
date=str(r[0]),
runs=int(r[1] or 0),
questions=int(r[2] or 0),
tokens=int(r[3] or 0),
users=int(r[4] or 0),
)
for r in rows
]
async def _query_overview(
session,
since: datetime,
until: datetime,
*,
user_id: str | None = None,
exclude_user_ids: set[str] | None = None,
include_scheduled: bool = True,
include_admins: bool = True,
include_failed: bool = True,
) -> AdminLeaderboardOverview:
exclude_user_ids = exclude_user_ids or set()
run_where = [_run_time_filter(since, until)]
thread_where = [and_(ThreadMetaRow.created_at >= since, ThreadMetaRow.created_at <= until)]
tool_where = [_tool_time_filter(since, until)]
if user_id:
run_where.append(RunRow.user_id == user_id)
thread_where.append(ThreadMetaRow.user_id == user_id)
tool_where.append(ToolCallMetricRow.user_id == user_id)
if exclude_user_ids:
run_where.append(_exclude_user_filter(RunRow.user_id, exclude_user_ids))
thread_where.append(_exclude_user_filter(ThreadMetaRow.user_id, exclude_user_ids))
tool_where.append(_exclude_user_filter(ToolCallMetricRow.user_id, exclude_user_ids))
if not include_admins:
run_where.append(or_(RunRow.user_id.is_(None), RunRow.user_id.not_in(_admin_user_ids_stmt())))
thread_where.append(or_(ThreadMetaRow.user_id.is_(None), ThreadMetaRow.user_id.not_in(_admin_user_ids_stmt())))
tool_where.append(or_(ToolCallMetricRow.user_id.is_(None), ToolCallMetricRow.user_id.not_in(_admin_user_ids_stmt())))
if not include_failed:
run_where.append(RunRow.status != "error")
if not include_scheduled:
run_where.append(_not_scheduled_run_id_filter(RunRow.run_id))
tool_where.append(_not_scheduled_run_id_filter(ToolCallMetricRow.run_id))
tool_where.append(_non_scheduled_tool_filter())
total_users_stmt = select(func.count(UserRow.id))
total_users_stmt = _apply_user_scope(total_users_stmt, UserRow.id, exclude_user_ids=exclude_user_ids, include_admins=include_admins)
total_users = 1 if user_id else (await _execute_leaderboard_query(session, "overview.total_users", total_users_stmt)).scalar_one()
run_summary_stmt = (
select(
func.count(RunRow.run_id),
_question_count_expr(),
func.coalesce(func.sum(RunRow.total_tokens), 0),
func.count(func.distinct(RunRow.user_id)),
func.sum(case((RunRow.status == "error", 1), else_=0)),
).where(*run_where)
)
run_row = (await _execute_leaderboard_query(session, "overview.run_summary", run_summary_stmt)).one()
thread_count_stmt = select(func.count(ThreadMetaRow.thread_id)).where(*thread_where)
thread_count = (await _execute_leaderboard_query(session, "overview.thread_count", thread_count_stmt)).scalar_one()
tool_summary_stmt = (
select(
func.count(ToolCallMetricRow.id),
func.sum(case((func.nullif(ToolCallMetricRow.skill_name, "").is_not(None), 1), else_=0)),
)
.where(*tool_where)
)
tool_row = (await _execute_leaderboard_query(session, "overview.tool_summary", tool_summary_stmt)).one()
return AdminLeaderboardOverview(
total_users=int(total_users or 0),
active_users=int(run_row[3] or 0),
total_threads=int(thread_count or 0),
total_runs=int(run_row[0] or 0),
total_questions=int(run_row[1] or 0),
total_tokens=int(run_row[2] or 0),
total_tool_calls=int(tool_row[0] or 0),
total_skill_calls=int(tool_row[1] or 0),
error_runs=int(run_row[4] or 0),
)
def _empty_leaderboard_response(
since_dt: datetime,
until_dt: datetime,
range_days: int,
*,
status: str,
message: str,
) -> AdminLeaderboardResponse:
return AdminLeaderboardResponse(
range_days=range_days,
since=_iso(since_dt),
until=_iso(until_dt),
status=status,
message=message,
overview=AdminLeaderboardOverview(),
)
async def _leaderboard_pause() -> None:
if _LEADERBOARD_QUERY_PAUSE_SECONDS > 0:
await asyncio.sleep(_LEADERBOARD_QUERY_PAUSE_SECONDS)
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:
await _set_analytics_read_timeout(session)
common = {
"exclude_user_ids": excluded,
"include_scheduled": include_scheduled,
"include_admins": include_admins,
"include_failed": include_failed,
}
tool_common = {
"exclude_user_ids": excluded,
"include_scheduled": include_scheduled,
"include_admins": include_admins,
}
overview = await _query_overview(session, since_dt, until_dt, **common)
await _leaderboard_pause()
users = await _query_user_leaderboard(session, since_dt, until_dt, **common)
await _leaderboard_pause()
skills = await _query_call_rankings(session, since_dt, until_dt, user_id=None, field="skill", limit=limit, **tool_common)
await _leaderboard_pause()
tools = await _query_call_rankings(session, since_dt, until_dt, user_id=None, field="tool", limit=limit, **tool_common)
await _leaderboard_pause()
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)
await _leaderboard_pause()
agents = await _query_run_dimensions(session, since_dt, until_dt, user_id=None, field="agent", limit=limit, **common)
await _leaderboard_pause()
run_statuses = await _query_run_dimensions(session, since_dt, until_dt, user_id=None, field="status", limit=limit, **common)
await _leaderboard_pause()
trends = await _query_trends(session, since_dt, until_dt, **common)
return AdminLeaderboardResponse(
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],
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,
trends=trends,
)
def _leaderboard_daily_settings_hash(
*,
excluded: set[str],
include_scheduled: bool,
include_admins: bool,
include_failed: bool,
cleanup_system_questions: bool,
) -> str:
payload = {
"version": 2,
"snapshot_limit": _LEADERBOARD_DAILY_SNAPSHOT_LIMIT,
"exclude_user_ids": 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()
def _resolve_leaderboard_snapshot_scope(
settings,
*,
exclude_user_ids: list[str] | None = None,
include_scheduled: bool | None = None,
include_admins: bool | None = None,
include_failed: bool | None = None,
) -> tuple[set[str], bool, bool, bool, str]:
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
settings_hash = _leaderboard_daily_settings_hash(
excluded=excluded,
include_scheduled=resolved_include_scheduled,
include_admins=resolved_include_admins,
include_failed=resolved_include_failed,
cleanup_system_questions=settings.cleanup_system_questions,
)
return excluded, resolved_include_scheduled, resolved_include_admins, resolved_include_failed, settings_hash
def _daily_job_key(stat_date: str, settings_hash: str) -> str:
return f"{stat_date}:{settings_hash}"
def _sort_datetime(value: str | None) -> datetime:
if not value:
return datetime.min
text_value = str(value).strip()
if text_value.endswith("Z"):
text_value = text_value[:-1] + "+00:00"
try:
parsed = datetime.fromisoformat(text_value)
except ValueError:
return datetime.min
if parsed.tzinfo is not None:
return parsed.astimezone(UTC).replace(tzinfo=None)
return parsed
def _latest_iso(left: str | None, right: str | None) -> str:
if not left:
return str(right or "")
if not right:
return str(left or "")
return str(right if _sort_datetime(right) > _sort_datetime(left) else left)
def _snapshot_is_fresh(row: AdminLeaderboardDailyStatRow | None, stat_date: str) -> bool:
if row is None or not row.payload_json or row.status != "ready":
return False
if stat_date != _today_stat_date():
return True
generated_at = row.generated_at
if not isinstance(generated_at, datetime):
return False
age = (datetime.now(BEIJING_TZ) - _as_beijing(generated_at)).total_seconds()
return age <= _LEADERBOARD_TODAY_TTL_SECONDS
def _snapshot_response_from_row(row: AdminLeaderboardDailyStatRow | None) -> AdminLeaderboardResponse | None:
if row is None or not row.payload_json:
return None
payload = row.payload_json
if isinstance(payload, str):
try:
payload = json.loads(payload)
except json.JSONDecodeError:
return None
try:
return AdminLeaderboardResponse.model_validate(payload)
except Exception:
logger.warning("Invalid leaderboard snapshot payload: %s/%s", row.stat_date, row.settings_hash, exc_info=True)
return None
def _merge_users_from_daily(responses: list[AdminLeaderboardResponse], limit: int) -> list[AdminLeaderboardUser]:
users_by_id: dict[str, dict] = {}
for response in responses:
day_users: dict[str, AdminLeaderboardUser] = {}
for ranking in (response.users_by_activity, response.users_by_questions, response.users_by_tokens):
for user in ranking:
if user.user_id:
day_users[user.user_id] = user
for user in day_users.values():
item = users_by_id.setdefault(
user.user_id,
{
"user_id": user.user_id,
"email": user.email,
"system_role": user.system_role or "user",
"thread_count": 0,
"run_count": 0,
"qa_count": 0,
"llm_call_count": 0,
"tool_call_count": 0,
"skill_call_count": 0,
"total_tokens": 0,
"input_tokens": 0,
"output_tokens": 0,
"last_active_at": "",
},
)
if user.email:
item["email"] = user.email
if user.system_role:
item["system_role"] = user.system_role
for field in (
"thread_count",
"run_count",
"qa_count",
"llm_call_count",
"tool_call_count",
"skill_call_count",
"total_tokens",
"input_tokens",
"output_tokens",
):
item[field] += int(getattr(user, field) or 0)
item["last_active_at"] = _latest_iso(item["last_active_at"], user.last_active_at)
users = [AdminLeaderboardUser(**item) for item in users_by_id.values()]
return sorted(users, key=lambda u: (u.run_count + u.tool_call_count, u.total_tokens), reverse=True)[:limit]
def _merge_call_rankings_from_daily(
responses: list[AdminLeaderboardResponse],
field_name: str,
limit: int,
) -> list[AdminLeaderboardSkill]:
items_by_name: dict[str, dict] = {}
for response in responses:
for item in getattr(response, field_name):
if not item.name:
continue
merged = items_by_name.setdefault(
item.name,
{
"name": item.name,
"call_count": 0,
"user_count": 0,
"success_count": 0,
"error_count": 0,
"duration_weight": 0,
"last_used_at": "",
},
)
calls = int(item.call_count or 0)
merged["call_count"] += calls
merged["user_count"] += int(item.user_count or 0)
merged["success_count"] += int(item.success_count or 0)
merged["error_count"] += int(item.error_count or 0)
merged["duration_weight"] += int(item.avg_duration_ms or 0) * calls
merged["last_used_at"] = _latest_iso(merged["last_used_at"], item.last_used_at)
merged_items = [
AdminLeaderboardSkill(
name=item["name"],
call_count=item["call_count"],
user_count=item["user_count"],
success_count=item["success_count"],
error_count=item["error_count"],
avg_duration_ms=int(item["duration_weight"] / item["call_count"]) if item["call_count"] else 0,
last_used_at=item["last_used_at"],
)
for item in items_by_name.values()
]
return sorted(merged_items, key=lambda item: (item.call_count, _sort_datetime(item.last_used_at)), reverse=True)[:limit]
def _merge_dimensions_from_daily(
responses: list[AdminLeaderboardResponse],
field_name: str,
limit: int,
) -> list[AdminLeaderboardDimension]:
items_by_name: dict[str, dict] = {}
for response in responses:
for item in getattr(response, field_name):
if not item.name:
continue
merged = items_by_name.setdefault(
item.name,
{
"name": item.name,
"count": 0,
"user_count": 0,
"success_count": 0,
"error_count": 0,
"total_tokens": 0,
"last_seen_at": "",
},
)
for field in ("count", "user_count", "success_count", "error_count", "total_tokens"):
merged[field] += int(getattr(item, field) or 0)
merged["last_seen_at"] = _latest_iso(merged["last_seen_at"], item.last_seen_at)
merged_items = [AdminLeaderboardDimension(**item) for item in items_by_name.values()]
return sorted(merged_items, key=lambda item: (item.count, _sort_datetime(item.last_seen_at)), reverse=True)[:limit]
def _merge_trends_from_daily(responses: list[AdminLeaderboardResponse]) -> list[AdminLeaderboardTrendPoint]:
trends_by_date: dict[str, AdminLeaderboardTrendPoint] = {}
for response in responses:
for point in response.trends:
item = trends_by_date.setdefault(point.date, AdminLeaderboardTrendPoint(date=point.date))
item.runs += int(point.runs or 0)
item.questions += int(point.questions or 0)
item.tokens += int(point.tokens or 0)
item.users += int(point.users or 0)
return sorted(trends_by_date.values(), key=lambda item: item.date)
def _merge_daily_leaderboard_payloads(
responses: list[AdminLeaderboardResponse],
*,
since_dt: datetime,
until_dt: datetime,
range_days: int,
limit: int,
status: str,
message: str,
) -> AdminLeaderboardResponse:
users = _merge_users_from_daily(responses, _LEADERBOARD_DAILY_SNAPSHOT_LIMIT)
active_user_ids = {
user.user_id
for user in users
if user.user_id and (user.run_count or user.qa_count or user.tool_call_count or user.thread_count)
}
overview = AdminLeaderboardOverview(
total_users=max((item.overview.total_users for item in responses), default=0),
active_users=len(active_user_ids) if active_user_ids else sum(item.overview.active_users for item in responses),
total_threads=sum(item.overview.total_threads for item in responses),
total_runs=sum(item.overview.total_runs for item in responses),
total_questions=sum(item.overview.total_questions for item in responses),
total_tokens=sum(item.overview.total_tokens for item in responses),
total_skill_calls=sum(item.overview.total_skill_calls for item in responses),
total_tool_calls=sum(item.overview.total_tool_calls for item in responses),
error_runs=sum(item.overview.error_runs for item in responses),
)
generated_at = ""
for response in responses:
generated_at = _latest_iso(generated_at, response.generated_at)
return AdminLeaderboardResponse(
range_days=range_days,
since=_iso(since_dt),
until=_iso(until_dt),
status=status,
generated_at=generated_at or _iso(datetime.now(BEIJING_TZ)),
message=message,
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=_merge_call_rankings_from_daily(responses, "skills", limit),
tools=_merge_call_rankings_from_daily(responses, "tools", limit),
models=_merge_dimensions_from_daily(responses, "models", limit),
agents=_merge_dimensions_from_daily(responses, "agents", limit),
run_statuses=_merge_dimensions_from_daily(responses, "run_statuses", limit),
trends=_merge_trends_from_daily(responses),
)
async def _load_daily_snapshot_rows(
session_factory,
stat_dates: list[str],
settings_hash: str,
) -> dict[str, AdminLeaderboardDailyStatRow]:
if not stat_dates:
return {}
async with session_factory() as session:
stmt = select(AdminLeaderboardDailyStatRow).where(
AdminLeaderboardDailyStatRow.stat_date.in_(stat_dates),
AdminLeaderboardDailyStatRow.settings_hash == settings_hash,
)
rows = (
await _execute_leaderboard_query(session, "snapshot_rows.load", stmt)
).scalars().all()
return {row.stat_date: row for row in rows}
async def _store_daily_snapshot(
session_factory,
stat_date: str,
settings_hash: str,
*,
status: str,
payload: dict | None = None,
message: str | None = None,
error: str | None = None,
generated_at: datetime | None = None,
) -> None:
async with session_factory() as session:
now = datetime.now(UTC)
row = await _time_leaderboard_db_operation(
"snapshot.store.get",
lambda: session.get(
AdminLeaderboardDailyStatRow,
{"stat_date": stat_date, "settings_hash": settings_hash},
),
sql="SELECT admin_leaderboard_daily_stats by primary key",
stat_date=stat_date,
settings_hash=settings_hash,
)
if row is None:
row = AdminLeaderboardDailyStatRow(
stat_date=stat_date,
settings_hash=settings_hash,
status=status,
payload_json=payload or {},
message=message,
error=error,
generated_at=generated_at,
created_at=now,
updated_at=now,
)
session.add(row)
else:
row.status = status
if payload is not None:
row.payload_json = payload
if message is not None:
row.message = message
row.error = error
if generated_at is not None:
row.generated_at = generated_at
row.updated_at = now
await _time_leaderboard_db_operation(
"snapshot.store.commit",
lambda: session.commit(),
sql="COMMIT",
stat_date=stat_date,
settings_hash=settings_hash,
)
async def _queue_daily_leaderboard_snapshots(
session_factory,
stat_dates: list[str],
settings_hash: str,
*,
force: bool = False,
message: str = "等待排行榜后台统计任务处理。",
) -> list[str]:
queued: list[str] = []
if not stat_dates:
return queued
async with session_factory() as session:
now = datetime.now(UTC)
stmt = select(AdminLeaderboardDailyStatRow).where(
AdminLeaderboardDailyStatRow.stat_date.in_(stat_dates),
AdminLeaderboardDailyStatRow.settings_hash == settings_hash,
)
rows = (
await _execute_leaderboard_query(
session,
"snapshot_queue.load_existing",
stmt,
stat_dates=stat_dates,
settings_hash=settings_hash,
)
).scalars().all()
rows_by_date = {row.stat_date: row for row in rows}
for stat_date in stat_dates:
row = rows_by_date.get(stat_date)
if row is None:
row = AdminLeaderboardDailyStatRow(
stat_date=stat_date,
settings_hash=settings_hash,
status="queued",
payload_json={},
message=message,
created_at=now,
updated_at=now,
)
session.add(row)
queued.append(stat_date)
continue
if not force and _snapshot_is_fresh(row, stat_date):
continue
if row.status == "running":
continue
row.status = "queued"
row.message = message
row.error = None
row.updated_at = now
queued.append(stat_date)
if queued:
await _time_leaderboard_db_operation(
"snapshot_queue.commit",
lambda: session.commit(),
sql="COMMIT",
stat_dates=queued,
settings_hash=settings_hash,
)
return queued
async def _build_daily_leaderboard_snapshot(
session_factory,
settings,
stat_date: str,
*,
excluded: set[str],
include_scheduled: bool,
include_admins: bool,
include_failed: bool,
) -> AdminLeaderboardResponse:
since_dt, until_dt = _bounds_for_stat_date(stat_date)
return await _build_user_leaderboard_response(
session_factory,
settings,
since_dt,
until_dt,
1,
_LEADERBOARD_DAILY_SNAPSHOT_LIMIT,
excluded,
include_scheduled,
include_admins,
include_failed,
)
async def _refresh_daily_leaderboard_snapshot(
job_key: str,
*,
session_factory,
settings,
stat_date: str,
settings_hash: str,
excluded: set[str],
include_scheduled: bool,
include_admins: bool,
include_failed: bool,
) -> None:
acquired = False
trace_token = _LEADERBOARD_TRACE_CONTEXT.set(
{
"job_key": job_key,
"stat_date": stat_date,
"settings_hash": settings_hash,
}
)
snapshot_start = perf_counter()
try:
logger.info(
"Leaderboard snapshot build start: job=%s stat_date=%s settings_hash=%s started_at=%s",
job_key,
stat_date,
settings_hash,
datetime.now(BEIJING_TZ).isoformat(),
)
if _LEADERBOARD_START_DELAY_SECONDS > 0:
await asyncio.sleep(_LEADERBOARD_START_DELAY_SECONDS)
await _store_daily_snapshot(
session_factory,
stat_date,
settings_hash,
status="running",
message="正在生成当天排行榜快照。",
)
await _leaderboard_semaphore.acquire()
acquired = True
response = await asyncio.wait_for(
_build_daily_leaderboard_snapshot(
session_factory,
settings,
stat_date,
excluded=excluded,
include_scheduled=include_scheduled,
include_admins=include_admins,
include_failed=include_failed,
),
timeout=_LEADERBOARD_TIMEOUT_SECONDS,
)
generated_at = datetime.now(BEIJING_TZ)
response.generated_at = _iso(generated_at)
await _store_daily_snapshot(
session_factory,
stat_date,
settings_hash,
status="ready",
payload=response.model_dump(mode="json"),
message="",
error=None,
generated_at=generated_at,
)
logger.info(
"Leaderboard snapshot build done: job=%s stat_date=%s settings_hash=%s finished_at=%s elapsed_ms=%.2f",
job_key,
stat_date,
settings_hash,
datetime.now(BEIJING_TZ).isoformat(),
(perf_counter() - snapshot_start) * 1000,
)
except asyncio.TimeoutError:
logger.warning("Daily leaderboard snapshot timed out: %s", job_key)
await _store_daily_snapshot(
session_factory,
stat_date,
settings_hash,
status="error",
message="当天排行榜快照统计超时,后台会在下次访问时重试。",
error="timeout",
)
except Exception as exc:
logger.exception("Daily leaderboard snapshot failed: %s", job_key)
await _store_daily_snapshot(
session_factory,
stat_date,
settings_hash,
status="error",
message="当天排行榜快照统计失败,后台会在下次访问时重试。",
error=str(exc),
)
finally:
if acquired:
_leaderboard_semaphore.release()
_LEADERBOARD_TRACE_CONTEXT.reset(trace_token)
async def _load_or_schedule_daily_leaderboard(
session_factory,
settings,
since_dt: datetime,
until_dt: datetime,
*,
limit: int,
exclude_user_ids: list[str] | None = None,
include_scheduled: bool | None = None,
include_admins: bool | None = None,
include_failed: bool | None = None,
) -> AdminLeaderboardResponse:
stat_dates = _stat_dates_for_range(since_dt, until_dt)
range_days = len(stat_dates)
(
_excluded,
_resolved_include_scheduled,
_resolved_include_admins,
_resolved_include_failed,
settings_hash,
) = _resolve_leaderboard_snapshot_scope(
settings,
exclude_user_ids=exclude_user_ids,
include_scheduled=include_scheduled,
include_admins=include_admins,
include_failed=include_failed,
)
rows = await _load_daily_snapshot_rows(session_factory, stat_dates, settings_hash)
responses: list[AdminLeaderboardResponse] = []
refresh_dates: list[str] = []
for stat_date in stat_dates:
row = rows.get(stat_date)
response = _snapshot_response_from_row(row)
if response is not None:
responses.append(response)
if not _snapshot_is_fresh(row, stat_date):
refresh_dates.append(stat_date)
queued_dates: list[str] = []
if refresh_dates and _LEADERBOARD_QUEUE_ON_REQUEST_ENABLED:
queued_dates = await _queue_daily_leaderboard_snapshots(
session_factory,
refresh_dates,
settings_hash,
message="排行榜页面请求发现该日快照缺失或过期,已加入后台统计队列。",
)
if responses:
status = "refreshing" if refresh_dates else "ready"
message = ""
if refresh_dates:
if _LEADERBOARD_QUEUE_ON_REQUEST_ENABLED:
message = f"缺失/过期的 {len(refresh_dates)} 天快照已加入队列,当前展示已完成的 {len(responses)} 天数据。后台统计任务会继续补齐。"
else:
message = f"缺失/过期的 {len(refresh_dates)} 天快照尚未生成,当前展示已完成的 {len(responses)} 天数据。"
return _merge_daily_leaderboard_payloads(
responses,
since_dt=since_dt,
until_dt=until_dt,
range_days=range_days,
limit=limit,
status=status,
message=message,
)
return _empty_leaderboard_response(
since_dt,
until_dt,
range_days,
status="running",
message=(
f"该范围暂无可用快照,已加入 {len(queued_dates)} 天统计队列。后台统计任务会继续补齐。"
if _LEADERBOARD_QUEUE_ON_REQUEST_ENABLED
else "该范围暂无可用快照,请开启排行榜后台统计任务或手动补算对应日期。"
),
)
@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)
return await _load_or_schedule_daily_leaderboard(
session_factory,
settings,
since_dt,
until_dt,
limit=limit,
exclude_user_ids=exclude_user_ids,
include_scheduled=include_scheduled,
include_admins=include_admins,
include_failed=include_failed,
)
@router.get("/leaderboard/snapshots", response_model=AdminLeaderboardSnapshotStatusResponse)
async def get_leaderboard_snapshot_status(
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"),
) -> AdminLeaderboardSnapshotStatusResponse:
"""Return per-day snapshot status for the current leaderboard settings."""
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)
stat_dates = _stat_dates_for_range(since_dt, until_dt)
*_scope, settings_hash = _resolve_leaderboard_snapshot_scope(settings)
rows = await _load_daily_snapshot_rows(session_factory, stat_dates, settings_hash)
items: list[AdminLeaderboardSnapshotStatusItem] = []
counts = {"ready": 0, "queued": 0, "running": 0, "error": 0, "missing": 0}
for stat_date in stat_dates:
row = rows.get(stat_date)
if row is None:
counts["missing"] += 1
items.append(AdminLeaderboardSnapshotStatusItem(stat_date=stat_date))
continue
status = row.status or "missing"
counts[status if status in counts else "missing"] += 1
items.append(
AdminLeaderboardSnapshotStatusItem(
stat_date=stat_date,
status=status,
has_payload=bool(row.payload_json),
generated_at=_iso(row.generated_at),
updated_at=_iso(row.updated_at),
message=row.message or "",
error=row.error or "",
)
)
return AdminLeaderboardSnapshotStatusResponse(
settings_hash=settings_hash,
since=_iso(since_dt),
until=_iso(until_dt),
total_days=len(stat_dates),
ready_days=counts["ready"],
queued_days=counts["queued"],
running_days=counts["running"],
error_days=counts["error"],
missing_days=counts["missing"],
items=items,
)
@router.post("/leaderboard/snapshots/backfill", response_model=AdminLeaderboardSnapshotBackfillResponse)
async def queue_leaderboard_snapshot_backfill(
body: AdminLeaderboardSnapshotBackfillRequest,
request: Request,
) -> AdminLeaderboardSnapshotBackfillResponse:
"""Queue a date range for the leaderboard background snapshot scheduler."""
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(body.days, body.since, body.until)
stat_dates = _stat_dates_for_range(since_dt, until_dt)
*_scope, settings_hash = _resolve_leaderboard_snapshot_scope(settings)
queued_dates = await _queue_daily_leaderboard_snapshots(
session_factory,
stat_dates,
settings_hash,
force=body.force,
message="管理员手动补算请求已加入排行榜后台统计队列。",
)
return AdminLeaderboardSnapshotBackfillResponse(
settings_hash=settings_hash,
since=_iso(since_dt),
until=_iso(until_dt),
total_days=len(stat_dates),
queued_dates=queued_dates,
message=f"已加入 {len(queued_dates)} 天快照队列,后台统计任务会继续补齐。",
)
@router.post("/leaderboard/refresh-today", response_model=AdminLeaderboardResponse)
async def refresh_today_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:
"""Force a synchronous rebuild of *today's* snapshot, then return the range.
Today's snapshot otherwise refreshes only every ``_LEADERBOARD_TODAY_TTL_SECONDS``
(~5 min) via the background scheduler. This endpoint lets an admin pull the
latest real-time records into the stats on demand: it rebuilds today's daily
snapshot immediately (bounded by the same semaphore/timeout the scheduler uses)
and then returns the merged leaderboard for the requested range.
"""
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
(
excluded,
resolved_include_scheduled,
resolved_include_admins,
resolved_include_failed,
settings_hash,
) = _resolve_leaderboard_snapshot_scope(
settings,
exclude_user_ids=exclude_user_ids,
include_scheduled=include_scheduled,
include_admins=include_admins,
include_failed=include_failed,
)
today = _today_stat_date()
await _refresh_daily_leaderboard_snapshot(
_daily_job_key(today, settings_hash),
session_factory=session_factory,
settings=settings,
stat_date=today,
settings_hash=settings_hash,
excluded=excluded,
include_scheduled=resolved_include_scheduled,
include_admins=resolved_include_admins,
include_failed=resolved_include_failed,
)
since_dt, until_dt, _range_days = _resolve_range(days, since, until)
return await _load_or_schedule_daily_leaderboard(
session_factory,
settings,
since_dt,
until_dt,
limit=limit,
exclude_user_ids=exclude_user_ids,
include_scheduled=include_scheduled,
include_admins=include_admins,
include_failed=include_failed,
)
async def _build_leaderboard_report_prompt(
session_factory,
settings,
since_dt: datetime,
until_dt: datetime,
limit: int,
) -> str:
snapshot = await _load_or_schedule_daily_leaderboard(
session_factory,
settings,
since_dt,
until_dt,
limit=max(limit, 30),
)
if snapshot.status == "running" and snapshot.overview.total_runs == 0:
raise HTTPException(status_code=202, detail="排行榜快照正在后台生成,请稍后再生成报告。")
overview = snapshot.overview
skills = snapshot.skills
tools = snapshot.tools
models = snapshot.models
agents = snapshot.agents
run_statuses = snapshot.run_statuses
trends = snapshot.trends
top_users = snapshot.users_by_activity[:12]
context = {
"range": {"since": _iso(since_dt), "until": _iso(until_dt)},
"overview": overview.model_dump(),
"top_users": [u.model_dump() for u in top_users],
"top_skills": [s.model_dump() for s in skills[:12]],
"top_tools": [t.model_dump() for t in tools[:12]],
"top_models": [m.model_dump() for m in models[:12]],
"agents": [a.model_dump() for a in agents[:12]],
"run_statuses": [s.model_dump() for s in run_statuses[:12]],
"trends": [t.model_dump() for t in trends[-21:]],
}
return (
"请基于以下管理员排行榜统计数据,生成一份中文用户行为分析报告。"
"报告需要有结论,不要只罗列数字;请使用 Markdown 标题、列表和表格,让前端可以直接渲染。"
"重点要求:"
"1) 你是专门负责分析用户行为的专家,不要自称企业 AI 应用运营分析师;"
"2) 结合 top_users、top_skills、top_tools、top_models 和 trends,说明使用频率、使用增长、常用能力和重点用户行为画像;"
"3) 可以提及使用强度、关注变化和行为机会,但不要把报告重点放在系统本身的问题、故障、失败率或回答质量缺陷上;"
"4) 给管理员 3-5 条可执行建议,建议应围绕资源配置、内容供给、培训引导和高频能力优化。"
"输出结构:摘要、重点用户行为、常用能力、趋势观察、行动建议。"
"\n\n统计数据如下:\n"
f"{json.dumps(context, ensure_ascii=False, default=str)}"
)
@router.post("/leaderboard/report", response_model=AdminLeaderboardReportResponse)
async def generate_user_leaderboard_report(
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"),
limit: int = Query(default=30, ge=10, le=100),
model_name: str | None = Query(default=None, description="Model name used to generate the report"),
config: AppConfig = Depends(get_config),
) -> AdminLeaderboardReportResponse:
"""Generate an LLM-written admin report for the current leaderboard window."""
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)
resolved_model_name = _resolve_report_model_name(config, model_name)
cache_key = _report_cache_key(since=since_dt, until=until_dt, limit=limit, model_name=resolved_model_name, settings=settings)
cached = _cached_report(cache_key)
if cached:
return AdminLeaderboardReportResponse(
report=cached["report"],
generated_at=str(cached.get("generated_at") or ""),
cached=True,
model_name=str(cached.get("model_name") or resolved_model_name or ""),
)
prompt = await _build_leaderboard_report_prompt(session_factory, settings, since_dt, until_dt, limit)
try:
model_cfg = config.get_model_config(resolved_model_name) if resolved_model_name else None
thinking_enabled = bool(model_cfg and getattr(model_cfg, "supports_thinking", False))
model = create_chat_model(name=resolved_model_name, thinking_enabled=thinking_enabled, app_config=config)
response = await model.ainvoke(
[
SystemMessage(content="你是专门负责分析用户行为的专家,擅长从用户最近常用问题、关注主题、使用频率和调用指标中生成管理报告;报告聚焦用户行为,不讨论系统本身问题。"),
HumanMessage(content=prompt),
],
config={"run_name": "admin_user_leaderboard_report"},
)
except Exception as exc:
logger.exception("Failed to generate leaderboard report")
raise HTTPException(status_code=503, detail=f"Report generation failed: {exc}") from exc
report = _message_content_to_text(getattr(response, "content", response)).strip()
generated_at = _iso(_now())
_store_report_cache(cache_key, {"report": report, "generated_at": generated_at, "model_name": resolved_model_name})
return AdminLeaderboardReportResponse(
report=report,
generated_at=generated_at,
cached=False,
model_name=resolved_model_name,
)
@router.post("/leaderboard/report/stream")
async def stream_user_leaderboard_report(
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"),
limit: int = Query(default=30, ge=10, le=100),
model_name: str | None = Query(default=None, description="Model name used to generate the report"),
config: AppConfig = Depends(get_config),
) -> StreamingResponse:
"""Stream an LLM-written admin report, returning cached reports when available."""
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)
resolved_model_name = _resolve_report_model_name(config, model_name)
cache_key = _report_cache_key(since=since_dt, until=until_dt, limit=limit, model_name=resolved_model_name, settings=settings)
async def generate() -> AsyncIterator[str]:
cached = _cached_report(cache_key)
if cached:
yield _sse(
"cached",
{
"report": cached["report"],
"generated_at": cached.get("generated_at"),
"model_name": cached.get("model_name") or resolved_model_name,
},
)
yield _sse("done", {"cached": True, "generated_at": cached.get("generated_at"), "model_name": cached.get("model_name") or resolved_model_name})
return
report_parts: list[str] = []
generated_at = ""
try:
yield _sse("meta", {"cached": False, "model_name": resolved_model_name})
prompt = await _build_leaderboard_report_prompt(session_factory, settings, since_dt, until_dt, limit)
model_cfg = config.get_model_config(resolved_model_name) if resolved_model_name else None
thinking_enabled = bool(model_cfg and getattr(model_cfg, "supports_thinking", False))
model = create_chat_model(name=resolved_model_name, thinking_enabled=thinking_enabled, app_config=config)
async for chunk in model.astream(
[
SystemMessage(content="你是专门负责分析用户行为的专家,擅长从用户最近常用问题、关注主题、使用频率和调用指标中生成管理报告;报告聚焦用户行为,不讨论系统本身问题。"),
HumanMessage(content=prompt),
],
config={"run_name": "admin_user_leaderboard_report"},
):
thinking_delta, text_delta = _extract_chunk_parts(chunk)
if thinking_delta:
yield _sse("thinking", {"chunk": thinking_delta})
if text_delta:
report_parts.append(text_delta)
yield _sse("text", {"chunk": text_delta})
report = "".join(report_parts).strip()
generated_at = _iso(_now())
_store_report_cache(cache_key, {"report": report, "generated_at": generated_at, "model_name": resolved_model_name})
yield _sse("done", {"cached": False, "generated_at": generated_at, "model_name": resolved_model_name, "report": report})
except Exception as exc:
logger.exception("Failed to stream leaderboard report")
yield _sse("error", {"message": str(exc), "generated_at": generated_at, "model_name": resolved_model_name})
return StreamingResponse(
generate(),
media_type="text/event-stream",
headers={"X-Accel-Buffering": "no", "Cache-Control": "no-cache"},
)
@router.get("/{user_id}/analytics", response_model=AdminUserAnalyticsResponse)
async def get_user_analytics(
user_id: str,
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"),
limit: int = Query(default=20, ge=5, le=100),
) -> AdminUserAnalyticsResponse:
"""Return one user's admin analytics profile."""
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
common = {
"include_scheduled": settings.include_scheduled,
"include_admins": settings.include_admins,
"include_failed": settings.include_failed,
}
tool_common = {
"include_scheduled": settings.include_scheduled,
"include_admins": settings.include_admins,
}
since_dt, until_dt, range_days = _resolve_range(days, since, until)
async with session_factory() as session:
await _set_analytics_read_timeout(session)
users = await _query_user_leaderboard(session, since_dt, until_dt, user_id=user_id, **common)
if not users:
raise HTTPException(status_code=404, detail="User not found")
overview = await _query_overview(session, since_dt, until_dt, user_id=user_id, **common)
skills = await _query_call_rankings(session, since_dt, until_dt, user_id=user_id, field="skill", limit=limit, **tool_common)
tools = await _query_call_rankings(session, since_dt, until_dt, user_id=user_id, field="tool", limit=limit, **tool_common)
models = await _query_model_rankings(session, since_dt, until_dt, user_id=user_id, limit=limit, **common)
if not models:
models = await _query_run_dimensions(session, since_dt, until_dt, user_id=user_id, field="model", limit=limit, **common)
agents = await _query_run_dimensions(session, since_dt, until_dt, user_id=user_id, field="agent", limit=limit, **common)
run_statuses = await _query_run_dimensions(session, since_dt, until_dt, user_id=user_id, field="status", limit=limit, **common)
trends = await _query_trends(session, since_dt, until_dt, user_id=user_id, **common)
return AdminUserAnalyticsResponse(
range_days=range_days,
since=_iso(since_dt),
until=_iso(until_dt),
user=users[0],
overview=overview,
skills=skills,
tools=tools,
models=models,
agents=agents,
run_statuses=run_statuses,
trends=trends,
)
# ── Q&A audit list ─────────────────────────────────────────────────────────
class AdminUserQaItem(BaseModel):
run_id: str
thread_id: str = ""
thread_title: str = ""
assistant_id: str = ""
status: str = ""
model_name: str = ""
question: str = ""
answer: str = ""
total_tokens: int = 0
created_at: str = ""
updated_at: str = ""
class AdminUserQaPageResponse(BaseModel):
user_id: str
page: int
page_size: int
total: int
items: list[AdminUserQaItem] = Field(default_factory=list)
def _qa_sort_datetime(value: str | None) -> datetime:
if not value:
return datetime.min.replace(tzinfo=BEIJING_TZ)
text_value = str(value).strip()
if text_value.endswith("Z"):
text_value = text_value[:-1] + "+00:00"
try:
parsed = datetime.fromisoformat(text_value)
except ValueError:
return datetime.min.replace(tzinfo=BEIJING_TZ)
if parsed.tzinfo is None:
return parsed.replace(tzinfo=BEIJING_TZ)
return parsed.astimezone(BEIJING_TZ)
def _qa_dedupe_key(item: AdminUserQaItem) -> tuple[str, str, str]:
question_key = _question_similarity_key(item.question)[:160]
answer_key = _question_similarity_key(item.answer)[:160]
return (item.thread_id, question_key, answer_key)
def _message_value(message: Any, key: str, default: Any = None) -> Any:
if isinstance(message, dict):
return message.get(key, default)
return getattr(message, key, default)
def _qa_message_type(message: Any) -> str:
value = _message_value(message, "type") or _message_value(message, "role") or ""
if value == "user":
return "human"
if value == "assistant":
return "ai"
return str(value)
def _qa_message_id(message: Any, fallback: str) -> str:
return str(_message_value(message, "id") or fallback)
def _qa_message_text(message: Any) -> str:
return _message_content_to_text(_message_value(message, "content")).strip()
async def _read_thread_checkpoint_messages(checkpointer, thread_id: str) -> list[dict]:
config = {"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}}
try:
checkpoint_tuple = await checkpointer.aget_tuple(config)
except Exception:
logger.warning("Failed to read checkpoint while listing Q&A: thread=%s", thread_id, exc_info=True)
return []
if checkpoint_tuple is None:
return []
checkpoint = getattr(checkpoint_tuple, "checkpoint", {}) or {}
channel_values = checkpoint.get("channel_values", {}) if isinstance(checkpoint, dict) else {}
values = serialize_channel_values(channel_values)
messages = values.get("messages", [])
return messages if isinstance(messages, list) else []
def _qa_items_from_checkpoint_messages(
*,
user_id: str,
thread_id: str,
thread_title: str,
assistant_id: str,
thread_time: str,
messages: list[dict],
) -> list[AdminUserQaItem]:
items: list[AdminUserQaItem] = []
current_question = ""
current_answer = ""
current_id = ""
current_index = 0
def flush() -> None:
nonlocal current_question, current_answer, current_id, current_index
question = _clean_question_text(current_question)
if not _is_displayable_question(question):
current_question = ""
current_answer = ""
current_id = ""
return
items.append(
AdminUserQaItem(
run_id=current_id or f"{thread_id}:checkpoint:{current_index}",
thread_id=thread_id,
thread_title=thread_title,
assistant_id=assistant_id,
status="checkpoint",
question=question[:2000],
answer=current_answer.strip()[:6000],
created_at=thread_time,
updated_at=thread_time,
)
)
current_question = ""
current_answer = ""
current_id = ""
for index, message in enumerate(messages):
msg_type = _qa_message_type(message)
if msg_type == "human":
if current_question:
flush()
current_question = _qa_message_text(message)
current_answer = ""
current_id = _qa_message_id(message, f"{thread_id}:checkpoint:{index}")
current_index = index
continue
if msg_type == "ai" and current_question:
text = _qa_message_text(message)
if text:
current_answer = text
if current_question:
flush()
return items
async def _list_thread_checkpoint_qa_items(
*,
request: Request,
user_id: str,
) -> list[AdminUserQaItem]:
repo = get_thread_store(request)
checkpointer = get_checkpointer(request)
items: list[AdminUserQaItem] = []
offset = 0
page_size = 200
while True:
rows = await repo.search(limit=page_size, offset=offset, user_id=user_id)
if not rows:
break
for row in rows:
thread_id = str(row.get("thread_id") or "")
if not thread_id:
continue
messages = await _read_thread_checkpoint_messages(checkpointer, thread_id)
if not messages:
continue
metadata = row.get("metadata") if isinstance(row.get("metadata"), dict) else {}
context = row.get("context") if isinstance(row.get("context"), dict) else {}
assistant_id = str(
row.get("assistant_id")
or (context or {}).get("agent_id")
or (metadata or {}).get("agent_id")
or (context or {}).get("agent_name")
or (metadata or {}).get("agent_name")
or ""
)
thread_time = str(row.get("updated_at") or row.get("created_at") or "")
items.extend(
_qa_items_from_checkpoint_messages(
user_id=user_id,
thread_id=thread_id,
thread_title=str(row.get("display_name") or ""),
assistant_id=assistant_id,
thread_time=thread_time,
messages=messages,
)
)
if len(rows) < page_size:
break
offset += len(rows)
return items
async def _list_run_summary_qa_items(
*,
session_factory,
user_id: str,
since: str | None,
until: str | None,
) -> list[AdminUserQaItem]:
filters = [
RunRow.user_id == user_id,
RunRow.first_human_message.is_not(None),
RunRow.first_human_message != "",
]
if since or until:
since_dt, until_dt, _range_days = _resolve_range(365, since, until)
filters.append(_run_time_filter(since_dt, until_dt))
async with session_factory() as session:
await _set_analytics_read_timeout(session)
rows = (
await session.execute(
select(
RunRow.run_id,
RunRow.thread_id,
ThreadMetaRow.display_name,
RunRow.assistant_id,
RunRow.status,
RunRow.model_name,
RunRow.first_human_message,
RunRow.last_ai_message,
RunRow.total_tokens,
RunRow.created_at,
RunRow.updated_at,
)
.outerjoin(
ThreadMetaRow,
and_(
ThreadMetaRow.thread_id == RunRow.thread_id,
ThreadMetaRow.user_id == RunRow.user_id,
),
)
.where(*filters)
.order_by(RunRow.created_at.desc(), RunRow.run_id.desc())
)
).all()
return [
AdminUserQaItem(
run_id=r[0] or "",
thread_id=r[1] or "",
thread_title=r[2] or "",
assistant_id=r[3] or "",
status=r[4] or "",
model_name=r[5] or "",
question=_clean_question_text(r[6])[:2000],
answer=str(r[7] or "").strip()[:6000],
total_tokens=int(r[8] or 0),
created_at=_iso(r[9]),
updated_at=_iso(r[10]),
)
for r in rows
if _is_displayable_question(r[6])
]
def _filter_qa_items(
items: list[AdminUserQaItem],
*,
q: str | None,
since: str | None,
until: str | None,
) -> list[AdminUserQaItem]:
needle = (q or "").strip().lower()
since_dt = _parse_optional_datetime(since) if since else None
until_dt = _parse_optional_datetime(until) if until else None
filtered: list[AdminUserQaItem] = []
for item in items:
item_dt = _qa_sort_datetime(item.created_at or item.updated_at)
if since_dt and item_dt < since_dt:
continue
if until_dt and item_dt > until_dt:
continue
if needle:
haystack = "\n".join(
[
item.question,
item.answer,
item.run_id,
item.thread_id,
item.thread_title,
item.assistant_id,
]
).lower()
if needle not in haystack:
continue
filtered.append(item)
return filtered
async def _list_user_qa_page(
*,
request: Request,
user_id: str,
page: int,
page_size: int,
q: str | None,
since: str | None,
until: str | None,
include_checkpoints: bool = True,
) -> AdminUserQaPageResponse:
session_factory = get_session_factory()
if session_factory is None:
raise HTTPException(status_code=503, detail="Q&A audit requires database persistence")
items = await _list_run_summary_qa_items(
session_factory=session_factory,
user_id=user_id,
since=since,
until=until,
)
if include_checkpoints:
items.extend(await _list_thread_checkpoint_qa_items(request=request, user_id=user_id))
by_key: dict[tuple[str, str, str], AdminUserQaItem] = {}
for item in items:
key = _qa_dedupe_key(item)
existing = by_key.get(key)
if existing is None or (existing.status == "checkpoint" and item.status != "checkpoint"):
by_key[key] = item
filtered = _filter_qa_items(list(by_key.values()), q=q, since=since, until=until)
filtered.sort(
key=lambda item: (_qa_sort_datetime(item.created_at or item.updated_at), item.thread_id, item.run_id),
reverse=True,
)
offset = (page - 1) * page_size
return AdminUserQaPageResponse(
user_id=user_id,
page=page,
page_size=page_size,
total=len(filtered),
items=filtered[offset : offset + page_size],
)
@router.get("/{user_id}/qa", response_model=AdminUserQaPageResponse)
async def list_user_qa(
user_id: str,
request: Request,
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=100),
q: str | None = Query(default=None, description="Search question, answer, run id, or thread id"),
since: str | None = Query(default=None, description="Inclusive range start, ISO datetime"),
until: str | None = Query(default=None, description="Inclusive range end, ISO datetime"),
) -> AdminUserQaPageResponse:
"""Return one user's Q&A runs, including older checkpoint-only threads."""
await _require_admin(request)
return await _list_user_qa_page(
request=request,
user_id=user_id,
page=page,
page_size=page_size,
q=q,
since=since,
until=until,
)
@self_router.get("/qa", response_model=AdminUserQaPageResponse)
async def list_my_qa(
request: Request,
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=100),
q: str | None = Query(default=None, description="Search question, answer, run id, or thread id"),
since: str | None = Query(default=None, description="Inclusive range start, ISO datetime"),
until: str | None = Query(default=None, description="Inclusive range end, ISO datetime"),
) -> AdminUserQaPageResponse:
"""Return the current user's own Q&A history."""
user = getattr(request.state, "user", None)
if user is None:
user = await get_current_user_from_request(request)
return await _list_user_qa_page(
request=request,
user_id=str(user.id),
page=page,
page_size=page_size,
q=q,
since=since,
until=until,
)
# ── Threads ────────────────────────────────────────────────────────────────
class AdminThreadItem(BaseModel):
thread_id: str
display_name: str = ""
status: str = "idle"
created_at: str = ""
updated_at: str = ""
@router.get("/{user_id}/threads", response_model=list[AdminThreadItem])
async def list_user_threads(
user_id: str,
request: Request,
limit: int = Query(default=100, ge=1, le=500),
offset: int = Query(default=0, ge=0),
) -> list[AdminThreadItem]:
"""List one user's conversation threads, newest first."""
await _require_admin(request)
repo = get_thread_store(request)
rows = await repo.search(limit=limit, offset=offset, user_id=user_id)
return [
AdminThreadItem(
thread_id=r["thread_id"],
display_name=r.get("display_name") or "",
status=r.get("status", "idle"),
created_at=str(r.get("created_at") or ""),
updated_at=str(r.get("updated_at") or ""),
)
for r in rows
]
@router.get("/{user_id}/threads/{thread_id}/messages", response_model=list[dict])
async def list_user_thread_messages(
user_id: str,
thread_id: str,
request: Request,
limit: int = Query(default=400, ge=1, le=1000),
) -> list[dict]:
"""Return the conversation messages of one of the user's threads.
Read straight from the LangGraph checkpoint — the same source the chat UI
renders — rather than the optional ``run_events`` audit log, which may be
unconfigured (``run_events.backend=memory``) or empty for older threads.
"""
await _require_admin(request)
repo = get_thread_store(request)
# ``user_id=None`` bypasses the owner filter so the admin can fetch any row;
# we then assert the thread really belongs to the target user.
thread = await repo.get(thread_id, user_id=None)
if thread is None:
raise HTTPException(status_code=404, detail="Thread not found")
owner = thread.get("user_id")
if owner is not None and owner != user_id:
raise HTTPException(status_code=404, detail="Thread does not belong to this user")
checkpointer = get_checkpointer(request)
config = {"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}}
try:
checkpoint_tuple = await checkpointer.aget_tuple(config)
except Exception as exc: # noqa: BLE001 - surface a clean error
logger.error("Failed to read checkpoint for thread %s: %s", thread_id, exc, exc_info=True)
raise HTTPException(status_code=500, detail="Failed to read thread state") from exc
if checkpoint_tuple is None:
return []
checkpoint = getattr(checkpoint_tuple, "checkpoint", {}) or {}
channel_values = checkpoint.get("channel_values", {}) if isinstance(checkpoint, dict) else {}
values = serialize_channel_values(channel_values)
messages = values.get("messages", [])
if not isinstance(messages, list):
return []
# Keep the most recent ``limit`` messages — long threads stay bounded.
return messages[-limit:]
# ── Memory ─────────────────────────────────────────────────────────────────
class AdminMemoryBucket(BaseModel):
name: str = Field(..., description="Bucket name: 'global' or the agent id (used for CRUD)")
display_name: str = Field("", description="Human-readable name shown in the UI")
data: dict = Field(default_factory=dict, description="Raw memory data for the bucket")
class AdminMemoryEntryAddRequest(BaseModel):
content: str = Field(..., min_length=1, description="Entry content to add")
agent: str | None = Field(None, description="Agent name; None = global USER.md bucket")
class AdminMemoryEntryReplaceRequest(BaseModel):
old_text: str = Field(..., min_length=1, description="Short unique substring to locate the entry")
content: str = Field(..., min_length=1, description="New content to replace with")
agent: str | None = Field(None, description="Agent name; None = global USER.md bucket")
class AdminMemoryResponse(BaseModel):
user_id: str
version: str = Field(default="v2", description="记忆版本:v1 或 v2")
buckets: list[AdminMemoryBucket] = Field(default_factory=list)
def _read_memory_bucket(user_id: str, agent_id: str | None) -> dict:
"""Read one Memory V2 file into a JSON-able ``{"entries": [...]}`` dict."""
provider = BuiltinFileProvider()
provider.initialize(user_id=user_id, agent_id=agent_id)
target = "user" if agent_id is None else "memory"
return {"entries": provider.read(target)}
def _read_v1_memory_bucket(user_id: str, agent_id: str | None) -> dict:
"""Read one V1 memory.json into its raw dict (facts / user / history)."""
provider = _build_v1_provider(user_id, agent_id)
return provider.read_v1_data()
@router.get("/{user_id}/memory", response_model=AdminMemoryResponse)
async def get_user_memory(user_id: str, request: Request) -> AdminMemoryResponse:
"""Return every memory bucket for one user (V1 or V2 depending on config)."""
await _require_admin(request)
# Source agents from the database: only agents owned by this specific user.
agent_store = get_agent_store(request)
all_agents = await agent_store.list_visible(user_id)
owned_agents = sorted(
[a for a in all_agents if a.get("user_id") == user_id],
key=lambda a: (a.get("name") or a["id"]).lower(),
)
if _is_v1(user_id):
# V1 模式:每个桶读 memory.json,内容为 {facts, user, history}
buckets: list[AdminMemoryBucket] = []
try:
buckets.append(AdminMemoryBucket(name="default", display_name="默认助手", data=_read_v1_memory_bucket(user_id, None)))
except Exception:
logger.exception("Failed to read V1 global memory for user %s", user_id)
buckets.append(AdminMemoryBucket(name="default", display_name="默认助手", data={}))
for agent in owned_agents:
agent_id: str = agent["id"]
display_name: str = agent.get("name") or agent_id
try:
data = _read_v1_memory_bucket(user_id, agent_id)
except Exception:
data = {}
buckets.append(AdminMemoryBucket(name=agent_id, display_name=display_name, data=data))
return AdminMemoryResponse(user_id=user_id, version="v1", buckets=buckets)
# V2 模式:读 USER.md / MEMORY.md,内容为 {entries: [...]}
buckets = [
AdminMemoryBucket(name="default", display_name="默认助手", data=_read_memory_bucket(user_id, None))
]
for agent in owned_agents:
agent_id = agent["id"]
display_name = agent.get("name") or agent_id
try:
data = _read_memory_bucket(user_id, agent_id)
except Exception:
data = {"entries": []}
buckets.append(AdminMemoryBucket(name=agent_id, display_name=display_name, data=data))
return AdminMemoryResponse(user_id=user_id, version="v2", buckets=buckets)
@router.post("/{user_id}/memory/entries", response_model=AdminMemoryBucket)
async def admin_add_memory_entry(
user_id: str,
body: AdminMemoryEntryAddRequest,
request: Request,
) -> AdminMemoryBucket:
"""Add a memory entry for a user (admin only)."""
await _require_admin(request)
target = "user" if body.agent is None else "memory"
provider = BuiltinFileProvider()
provider.initialize(user_id=user_id, agent_id=body.agent)
try:
result = provider.add(target, body.content)
if result is False:
raise HTTPException(status_code=400, detail="Failed to add entry (quota exceeded or write error)")
except HTTPException:
raise
except Exception as exc:
logger.error("admin_add_memory_entry failed for user %s: %s", user_id, exc, exc_info=True)
raise HTTPException(status_code=500, detail="Failed to write memory entry") from exc
bucket_name = "global" if body.agent is None else body.agent
return AdminMemoryBucket(name=bucket_name, data=_read_memory_bucket(user_id, body.agent))
@router.put("/{user_id}/memory/entries", response_model=AdminMemoryBucket)
async def admin_replace_memory_entry(
user_id: str,
body: AdminMemoryEntryReplaceRequest,
request: Request,
) -> AdminMemoryBucket:
"""Replace a memory entry for a user (admin only)."""
await _require_admin(request)
target = "user" if body.agent is None else "memory"
provider = BuiltinFileProvider()
provider.initialize(user_id=user_id, agent_id=body.agent)
try:
result = provider.replace(target, body.old_text, body.content)
if result is False:
raise HTTPException(status_code=404, detail="Entry not found or write failed")
except HTTPException:
raise
except Exception as exc:
logger.error("admin_replace_memory_entry failed for user %s: %s", user_id, exc, exc_info=True)
raise HTTPException(status_code=500, detail="Failed to replace memory entry") from exc
bucket_name = "global" if body.agent is None else body.agent
return AdminMemoryBucket(name=bucket_name, data=_read_memory_bucket(user_id, body.agent))
@router.delete("/{user_id}/memory/entries", response_model=AdminMemoryBucket)
async def admin_delete_memory_entry(
user_id: str,
request: Request,
old_text: str = Query(..., min_length=1, description="Short unique substring to locate the entry"),
agent: str | None = Query(None, description="Agent name; None = global USER.md bucket"),
) -> AdminMemoryBucket:
"""Delete a memory entry for a user (admin only)."""
await _require_admin(request)
target = "user" if agent is None else "memory"
provider = BuiltinFileProvider()
provider.initialize(user_id=user_id, agent_id=agent)
try:
result = provider.remove(target, old_text)
if result is False:
raise HTTPException(status_code=404, detail="Entry not found")
except HTTPException:
raise
except Exception as exc:
logger.error("admin_delete_memory_entry failed for user %s: %s", user_id, exc, exc_info=True)
raise HTTPException(status_code=500, detail="Failed to delete memory entry") from exc
bucket_name = "global" if agent is None else agent
return AdminMemoryBucket(name=bucket_name, data=_read_memory_bucket(user_id, agent))
# ── V1 Memory CRUD (admin) ─────────────────────────────────────────────────
_V1_USER_FIELDS = {"workContext", "personalContext", "topOfMind"}
_V1_HISTORY_FIELDS = {"recentMonths", "earlierContext", "longTermBackground"}
class AdminV1SectionUpdateRequest(BaseModel):
section_type: str = Field(..., description="'user' 或 'history'")
field: str = Field(..., description="字段名,如 workContext / recentMonths")
summary: str = Field(..., description="新的摘要文本")
agent: str | None = Field(None, description="Agent id;None 表示用户级全局记忆")
class AdminV1FactAddRequest(BaseModel):
content: str = Field(..., min_length=1)
category: str = Field(default="context")
confidence: float = Field(default=0.8, ge=0.0, le=1.0)
agent: str | None = Field(None)
class AdminV1FactUpdateRequest(BaseModel):
content: str | None = Field(None)
category: str | None = Field(None)
confidence: float | None = Field(None, ge=0.0, le=1.0)
agent: str | None = Field(None)
@router.put("/{user_id}/memory/v1/sections", response_model=AdminMemoryBucket)
async def admin_update_v1_section(
user_id: str,
body: AdminV1SectionUpdateRequest,
request: Request,
) -> AdminMemoryBucket:
"""更新 V1 记忆的某个摘要字段(用户画像 / 历史)。"""
await _require_admin(request)
if body.section_type == "user" and body.field not in _V1_USER_FIELDS:
raise HTTPException(status_code=400, detail=f"无效 user 字段:{body.field}")
if body.section_type == "history" and body.field not in _V1_HISTORY_FIELDS:
raise HTTPException(status_code=400, detail=f"无效 history 字段:{body.field}")
if body.section_type not in ("user", "history"):
raise HTTPException(status_code=400, detail="section_type 须为 'user' 或 'history'")
from deerflow.agents.memory.v1.storage import get_memory_storage, utc_now_iso_z
storage = get_memory_storage()
data = storage.load(body.agent, user_id=user_id)
data.setdefault(body.section_type, {})[body.field] = {
"summary": body.summary,
"updatedAt": utc_now_iso_z(),
}
if not storage.save(data, body.agent, user_id=user_id):
raise HTTPException(status_code=500, detail="写入失败")
bucket_name = "global" if body.agent is None else body.agent
return AdminMemoryBucket(name=bucket_name, data=_read_v1_memory_bucket(user_id, body.agent))
@router.post("/{user_id}/memory/v1/facts", response_model=AdminMemoryBucket)
async def admin_add_v1_fact(
user_id: str,
body: AdminV1FactAddRequest,
request: Request,
) -> AdminMemoryBucket:
"""新增一条 V1 事实记录。"""
await _require_admin(request)
try:
from deerflow.agents.memory.v1.updater import create_memory_fact
create_memory_fact(
body.content,
category=body.category,
confidence=body.confidence,
agent_name=body.agent,
user_id=user_id,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except Exception as exc:
raise HTTPException(status_code=500, detail="新增失败") from exc
bucket_name = "global" if body.agent is None else body.agent
return AdminMemoryBucket(name=bucket_name, data=_read_v1_memory_bucket(user_id, body.agent))
@router.put("/{user_id}/memory/v1/facts/{fact_id}", response_model=AdminMemoryBucket)
async def admin_update_v1_fact(
user_id: str,
fact_id: str,
body: AdminV1FactUpdateRequest,
request: Request,
) -> AdminMemoryBucket:
"""更新一条 V1 事实记录(内容 / 类别 / 置信度)。"""
await _require_admin(request)
try:
from deerflow.agents.memory.v1.updater import update_memory_fact
update_memory_fact(
fact_id,
content=body.content,
category=body.category,
confidence=body.confidence,
agent_name=body.agent,
user_id=user_id,
)
except KeyError:
raise HTTPException(status_code=404, detail=f"事实 '{fact_id}' 不存在")
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except Exception as exc:
raise HTTPException(status_code=500, detail="更新失败") from exc
bucket_name = "global" if body.agent is None else body.agent
return AdminMemoryBucket(name=bucket_name, data=_read_v1_memory_bucket(user_id, body.agent))
@router.delete("/{user_id}/memory/v1/facts/{fact_id}", response_model=AdminMemoryBucket)
async def admin_delete_v1_fact(
user_id: str,
fact_id: str,
request: Request,
agent: str | None = Query(None, description="Agent id;None 表示用户级全局记忆"),
) -> AdminMemoryBucket:
"""删除一条 V1 事实记录。"""
await _require_admin(request)
try:
from deerflow.agents.memory.v1.updater import delete_memory_fact
delete_memory_fact(fact_id, agent_name=agent, user_id=user_id)
except KeyError:
raise HTTPException(status_code=404, detail=f"事实 '{fact_id}' 不存在")
except Exception as exc:
raise HTTPException(status_code=500, detail="删除失败") from exc
bucket_name = "global" if agent is None else agent
return AdminMemoryBucket(name=bucket_name, data=_read_v1_memory_bucket(user_id, agent))