Files
AIRegulation-DocAnalysis/backend/app/api/routes/status.py
T

298 lines
12 KiB
Python
Raw Normal View History

"""Define API routes for status."""
import asyncio
import time
from typing import Any
from fastapi import APIRouter, Request
from app.config.settings import settings
from app.domain.retrieval import RetrievedChunk
from app.mcp.server import get_mcp_status
from app.services.llm.llm_factory import get_llm_client, get_llm_factory
from app.shared.bootstrap import (
get_bm25_retriever,
get_binary_store,
get_conversation_store,
get_document_query_service,
get_embedding_provider,
get_reranker,
get_vector_index,
)
from app.shared.model_usage_tracker import get_model_usage_tracker
2026-05-14 15:07:34 +08:00
router = APIRouter(prefix="/status", tags=["系统状态"])
# ---------------------------------------------------------------------------
# Simple TTL cache for /stats (avoids O(N) doc scan on every request)
# ---------------------------------------------------------------------------
_stats_cache: dict[str, Any] = {}
_stats_cache_time: float = 0.0
_STATS_TTL_SECONDS: float = 10.0
# ---------------------------------------------------------------------------
# AI model roles surfaced on the Status page (Task: System Status AI models)
# ---------------------------------------------------------------------------
_MODEL_ROLES: dict[str, str] = {
"main_llm": "主问答 LLM",
"hyde_llm": "HyDE 查询增强",
"embedding": "Embedding",
"reranker": "Reranker",
}
2026-05-14 15:07:34 +08:00
@router.get("/stats")
async def get_stats():
"""Return document statistics (cached for 10 s)."""
global _stats_cache, _stats_cache_time
now = time.time()
if _stats_cache and (now - _stats_cache_time) < _STATS_TTL_SECONDS:
return _stats_cache
documents = get_document_query_service().list_documents()
indexed = sum(1 for d in documents if d.status.value == "indexed")
failed = sum(1 for d in documents if d.status.value == "failed")
_stats_cache = {
"documents_total": len(documents),
"documents_indexed": indexed,
"documents_failed": failed,
"chunks_total": sum(d.chunk_count for d in documents),
}
_stats_cache_time = now
return _stats_cache
2026-05-14 15:07:34 +08:00
@router.get("/config")
async def get_config():
"""Return system configuration."""
return {
"embedding_model": settings.embedding_model,
"embedding_dim": settings.embedding_dim,
"embedding_base_url": settings.embedding_base_url,
"milvus_collection": settings.milvus_collection,
"parser_backend": settings.parser_backend,
"chunk_backend": settings.chunk_backend,
"artifact_prefix": settings.document_parse_artifact_prefix,
"parser_failure_mode": settings.parser_failure_mode,
"llm_provider": settings.llm_provider,
"llm_model": settings.llm_model,
"document_metadata_path": settings.document_metadata_path,
}
2026-05-14 15:07:34 +08:00
@router.get("/milvus/health")
async def milvus_health():
"""Return Milvus health (kept for backwards compat)."""
return get_vector_index().health()
@router.get("/health")
async def get_health():
"""Return aggregate health of all backend services."""
# --- Milvus ---
try:
milvus_info = get_vector_index().health()
milvus_status = "ok" if milvus_info.get("connected") else "error"
except Exception as exc: # noqa: BLE001
milvus_info = {}
milvus_status = "error"
milvus_info["error"] = str(exc)
# --- MinIO ---
try:
minio_connected = get_binary_store().client.connected
minio_status = "ok" if minio_connected else "error"
except Exception: # noqa: BLE001
minio_status = "error"
minio_connected = False
# --- BM25 ---
bm25 = get_bm25_retriever()
# --- Sessions ---
try:
session_count = len(get_conversation_store().list_sessions())
except Exception: # noqa: BLE001
session_count = 0
return {
"milvus": {"status": milvus_status, **milvus_info},
"minio": {"status": minio_status, "connected": minio_connected},
"bm25": {"available": bm25 is not None},
"reranker": {
"enabled": settings.reranker_enabled,
"model": settings.reranker_model if settings.reranker_enabled else None,
},
"sessions": {
"active": session_count,
"max": settings.session_max_sessions,
},
}
def _normalize_llm_provider(raw_provider: str) -> str:
"""Normalize a raw LLM_PROVIDER/HYDE_LLM_PROVIDER settings string to the
canonical LLMProvider enum value, the SAME way LLMFactory.create() does.
TrackedLLMClient.chat() (tracked_client.py) always records usage under
`self._inner.config.provider.value` — the NORMALIZED enum value produced by
LLMFactory._parse_provider() — never the raw string a caller passed to
get_llm_client(). Reusing that same normalization here (instead of
duplicating the alias table) guarantees the tracker key this route reads
always agrees with the key TrackedLLMClient wrote, even when the raw
settings value is a non-canonical alias (e.g. "deepseek-v3") or different
casing. Falls back to the raw string, unchanged, if it does not match any
known provider/alias, so this passive status endpoint still renders
(as "never_called") instead of raising on a misconfigured provider string.
"""
try:
return get_llm_factory()._parse_provider(raw_provider).value
except ValueError:
return raw_provider
def _resolve_role_provider_model(role: str) -> tuple[str, str]:
"""Return the (provider, model) pair currently configured for one AI model role.
For "hyde_llm" this mirrors the exact fallback logic already used in
hyde_expander.py (settings.hyde_llm_provider or settings.llm_provider, same
for model) so tracker lookups here always match what TrackedLLMClient
recorded when HyDE actually ran.
"""
if role == "main_llm":
return _normalize_llm_provider(settings.llm_provider), settings.llm_model
if role == "hyde_llm":
return (
_normalize_llm_provider(settings.hyde_llm_provider or settings.llm_provider),
settings.hyde_llm_model or settings.llm_model,
)
if role == "embedding":
return "embedding", settings.embedding_model
if role == "reranker":
return "reranker", settings.reranker_model
raise ValueError(f"unknown model role: {role}") # pragma: no cover - internal roles are fixed
def _build_model_status(role: str) -> dict[str, Any]:
"""Build one /status/models row for the given role from tracker data + live settings."""
provider, model = _resolve_role_provider_model(role)
entry = get_model_usage_tracker().get(provider, model)
main_provider, main_model = _resolve_role_provider_model("main_llm")
shares_usage_with = (
"main_llm" if role != "main_llm" and (provider, model) == (main_provider, main_model) else None
)
enabled = True
status = entry.status if entry else "never_called"
if role == "reranker":
enabled = settings.reranker_enabled
if not enabled:
# Config always wins: report "disabled" even if the reranker was
# enabled and called successfully earlier in this process's life.
status = "disabled"
elif role == "hyde_llm":
enabled = settings.hyde_enabled
if not enabled:
# Same "config always wins" override as the reranker branch above:
# report "disabled" even if HyDE ran successfully before being
# turned off in settings during this process's life.
status = "disabled"
return {
"role": role,
"role_label": _MODEL_ROLES[role],
"provider": provider,
"model": model,
"enabled": enabled,
"status": status,
"total_tokens": entry.total_tokens if entry else 0,
"call_count_ok": entry.call_count_ok if entry else 0,
"call_count_error": entry.call_count_error if entry else 0,
"last_called_at": entry.last_called_at.isoformat() if entry and entry.last_called_at else None,
"last_latency_ms": entry.last_latency_ms if entry else None,
"last_error": entry.last_error if entry else None,
"shares_usage_with": shares_usage_with,
}
@router.get("/models")
async def get_model_statuses():
"""Return connection status + cumulative token usage for all 4 tracked AI model roles.
Passive: reads tracker state + settings only, makes no outbound network calls.
"""
return {"models": [_build_model_status(role) for role in _MODEL_ROLES]}
async def _ping_main_or_hyde(role: str) -> None:
"""Send one minimal chat completion to the LLM configured for `role`.
Skipped entirely for "hyde_llm" when settings.hyde_enabled is False,
mirroring _ping_reranker()'s disabled-skip pattern: when HyDE is turned
off (or reuses the main LLM, the default), issuing this ping would just be
a redundant duplicate chat call against the same model for no benefit.
"main_llm" is always pinged regardless of this check.
"""
if role == "hyde_llm" and not settings.hyde_enabled:
return
provider, model = _resolve_role_provider_model(role)
try:
client = get_llm_client(provider=provider, model=model)
except Exception as exc: # noqa: BLE001 - record, then re-raise so gather() still isolates this ping
# get_llm_client() can fail before any TrackedLLMClient exists to
# record the outcome itself (e.g. missing API key, unsupported
# provider string), so record the failure here directly, otherwise it
# would be invisible on the /status/models page afterward.
get_model_usage_tracker().record(provider=provider, model=model, success=False, error=str(exc))
raise
await asyncio.to_thread(client.chat, [{"role": "user", "content": "ping"}], max_tokens=1)
async def _ping_embedding() -> None:
"""Send one minimal embedding request."""
await asyncio.to_thread(get_embedding_provider().embed_query, "ping")
async def _ping_reranker() -> None:
"""Send one minimal rerank request, only when the reranker is enabled."""
reranker = get_reranker()
if reranker is None:
return
# Minimal single-chunk probe — real content doesn't matter, only round-trip success.
placeholder = RetrievedChunk(chunk_id="ping", doc_id="ping", doc_title="ping", text="ping", score=0.0)
await asyncio.to_thread(reranker.rerank, "ping", [placeholder], 1)
@router.post("/models/ping")
async def ping_model_connections():
"""Actively test each configured model with a minimal request, then return fresh statuses.
Each ping is isolated with return_exceptions=True so one model timing out
or erroring does not prevent the other three from completing and being
reported. Failures are still visible afterwards via _build_model_status()
because the underlying clients record their own outcome into the tracker.
"""
tasks = [
_ping_main_or_hyde("main_llm"),
_ping_main_or_hyde("hyde_llm"),
_ping_embedding(),
_ping_reranker(),
]
await asyncio.gather(*tasks, return_exceptions=True)
return {"models": [_build_model_status(role) for role in _MODEL_ROLES]}
@router.get("/mcp")
async def get_mcp_server_status(request: Request):
"""Return MCP endpoint config, advertised tools, and per-tool call counters.
This route is a thin HTTP adapter: everything MCP-specific is assembled by
app.mcp.server.get_mcp_status(). The only thing decided here is the public
URL, because only the HTTP layer knows how the client reached us.
"""
# request.base_url already carries scheme/host/port and a trailing slash;
# strip it before appending so the result is ".../mcp/", not ".../mcp//".
public_url = settings.mcp_public_url or f"{str(request.base_url).rstrip('/')}/mcp/"
return await get_mcp_status(public_url)