17 Commits
Author SHA1 Message Date
wangwei 52e67b0e7b Add LLM token 2026-07-02 22:03:39 +08:00
wangweiandCopilot e3afb8a07a fix: normalize LLM provider key lookup and skip disabled HyDE ping (final review)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-02 21:24:22 +08:00
wangweiandCopilot 6a7fe48c4c fix: use dedicated error message for AI Models card load failure (Task 9 review)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-02 20:03:27 +08:00
wangweiandCopilot 0edbee07d5 fix: add distinct error state for AI Models card load failure (Task 9 review)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-02 18:01:04 +08:00
wangweiandCopilot 2ce4c8a289 feat: add AI Models card to Status page with connection test button
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-02 17:38:33 +08:00
wangweiandCopilot 39a51c9e83 feat: add ModelUsageEntry types and status API client functions
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-02 17:23:43 +08:00
wangweiandCopilot 049da2297b feat: add i18n keys for AI Models card and fix missing .status.error CSS
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-02 17:06:13 +08:00
wangweiandCopilot d83286edd4 fix: honor hyde_enabled toggle and record ping failures before client creation (Task 6 review)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-02 16:42:58 +08:00
wangweiandCopilot 169911ab46 feat: add GET/POST /status/models routes for AI model connection status
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-02 16:20:35 +08:00
wangweiandCopilot 66fc388bfb feat: record reranker call outcome into ModelUsageTracker
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-02 15:42:20 +08:00
wangwei 41096369d3 feat: record embedding call usage into ModelUsageTracker 2026-07-02 15:15:03 +08:00
wangwei 4fea159f5b feat: wrap LLM clients with TrackedLLMClient in LLMFactory 2026-07-02 15:03:12 +08:00
wangweiandCopilot d460397dda fix: add missing test comment for backend commenting standard (Task 2 review)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-02 14:57:55 +08:00
wangweiandCopilot 37ea27fcbe feat: add TrackedLLMClient decorator for transparent usage recording
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-02 14:49:06 +08:00
wangwei 74f327c85e feat: add ModelUsageTracker for per-model token/connection tracking 2026-07-02 14:41:21 +08:00
wangweiandCopilot 4b451ef97c docs: add implementation plan for System Status AI model usage tracking
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-02 14:13:58 +08:00
wangweiandCopilot 55ba922250 docs: add design spec for System Status AI model connection/token usage panel
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-02 13:39:43 +08:00
53 changed files with 5558 additions and 420 deletions
+9 -2
View File
@@ -102,10 +102,10 @@ DOCUMENT_PARSE_ARTIFACT_PREFIX=artifacts
PARSER_FAILURE_MODE=fail
# ===== Reranker 配置 =====
RERANKER_ENABLED=true
RERANKER_ENABLED=false
RERANKER_BASE_URL=http://6.86.80.4:30080/v1
RERANKER_MODEL=BAAI/bge-reranker-v2-m3
RERANKER_API_KEY=
RERANKER_API_KEY=sk-fVr9KmDZNC4pGDBQj0EUWz9bDmFzNxjYC9EzZpe2bVDsxtz8
RERANKER_TOP_K=5
# ===== 会话持久化 =====
@@ -120,3 +120,10 @@ AUTH_ENABLED=true
# ===== CORS =====
CORS_ALLOW_ORIGINS=http://localhost:5173
# ===== HyDE ???? =====
HYDE_ENABLED=true
HYDE_MAX_TOKENS=200
HYDE_LLM_PROVIDER=qwen
HYDE_LLM_MODEL=qwen3.5-flash
+25
View File
@@ -138,6 +138,31 @@ AUTH_TOKEN_EXPIRE_MINUTES=480
# 设为 false 可跳过认证(仅限本地开发调试,生产必须 true)
AUTH_ENABLED=true
# ===== HyDE 查询增强 =====
# HyDE (Hypothetical Document Embeddings): 在检索前让 LLM 生成一段"假设性回答",
# 用该段落的 embedding 代替原始查询 embedding 进行向量检索。
# 无需新模型,复用现有 LLM 和 Embedding 服务。降低此功能可减少每次查询的 LLM 调用次数。
HYDE_ENABLED=true
HYDE_MAX_TOKENS=200
# ?????? LLM;???????????????
HYDE_LLM_PROVIDER=qwen
HYDE_LLM_MODEL=qwen3.5-flash
# ===== Agentic RAG 配置 (P0-1) =====
# 以下参数控制 /api/v1/agent/agentic/stream 多步推理管线
# 意图分类: simple_qa / compare / multi_hop / ambiguous
# compare 和 multi_hop 触发查询分解,最多 AGENTIC_MAX_SUB_QUERIES 个子查询
AGENTIC_MAX_SUB_QUERIES=4
# 引文锚定 fast-path 阈值: avg_score > 此值 且 chunks >= 3 时跳过 LLM grounding check
# 降低此值可让更多查询触发 LLM 二次验证(更准确,但延迟+成本增加)
AGENTIC_GROUNDING_THRESHOLD=0.65
# 各步骤 LLM 最大 token 数(越小越快,越大越准)
AGENTIC_INTENT_MAX_TOKENS=200
AGENTIC_PLAN_MAX_TOKENS=400
AGENTIC_GROUNDING_MAX_TOKENS=250
# ===== CORS =====
# 逗号分隔的允许跨域来源列表,生产环境绝不能使用 *
CORS_ALLOW_ORIGINS=http://localhost:5173
+3
View File
@@ -62,3 +62,6 @@ logs/
# codex
.agents
# personal local records (never commit)
local/
+30 -4
View File
@@ -390,12 +390,38 @@ Demo-glm/
| 下载文档 | `/api/v1/documents/download/{doc_id}` | GET | 下载原文PDF/DOCX |
| 文档列表 | `/api/v1/documents/list` | GET | 列出已上传文档 |
| 检索知识 | `/api/v1/knowledge/search` | POST | 向量检索 |
| 单次问答 | `/api/v1/agent/ask` | POST | 智能问答 |
| 多轮对话 | `/api/v1/agent/chat` | POST | 会话对话 |
| 单次问答 | `/api/v1/agent/ask` | POST | 标准单轮问答 |
| 多轮对话 | `/api/v1/agent/chat` | POST | 标准会话对话 |
| 流式对话 | `/api/v1/agent/chat/stream` | POST | 标准流式问答 (SSE) |
| **Agentic 流式对话** | **`/api/v1/agent/agentic/stream`** | **POST** | **P0-1 多步推理 (SSE):意图分析→查询分解→迭代检索→引文锚定→生成** |
| 会话信息 | `/api/v1/agent/session/{id}` | GET | 获取会话 |
| 删除会话 | `/api/v1/agent/session/{id}` | DELETE | 删除会话 |
| Prompt模板 | `/api/v1/agent/templates` | GET | 模板列表 |
| 可用模型 | `/api/v1/agent/models` | GET | LLM模型列表 |
| 会话历史 | `/api/v1/agent/session/{id}/history` | GET | 获取历史记录 |
| 会话列表 | `/api/v1/agent/sessions` | GET | 列出所有会话 |
### Agentic 流式接口说明 (`/api/v1/agent/agentic/stream`)
**请求体** (同 `/agent/chat/stream`)
```json
{ "query": "GB 18384 与 ECE R100 在电池安全上有哪些差异?", "session_id": null, "top_k": 5 }
```
**额外 SSE 事件** (`thinking`)
```
event: thinking
data: {"step": "intent_analysis", "status": "done", "intent_type": "compare", "requires_decomposition": true}
event: thinking
data: {"step": "query_planning", "status": "done", "sub_queries": ["GB 18384 电池安全要求", "ECE R100 电池安全要求"]}
event: thinking
data: {"step": "retrieving", "status": "done", "query": "GB 18384 电池安全要求", "index": 1, "total": 2, "found": 8}
event: thinking
data: {"step": "grounding_check", "status": "done", "sufficient": true, "confidence": 0.82, "reason": "检索置信度充足"}
```
**意图类型**`simple_qa`(单跳)/ `compare`(对比)/ `multi_hop`(多跳)/ `ambiguous`(模糊)
---
+5
View File
@@ -42,6 +42,11 @@ class ChatRequest(BaseModel):
provider: Optional[str] = None
model: Optional[str] = None
top_k: Optional[int] = Field(default=None, ge=1, le=20)
# Optional document text uploaded by the user as conversation context.
# The text is injected directly into the LLM prompt so the model can
# answer questions about it without vector-store indexing.
context_text: Optional[str] = Field(default=None, max_length=12000)
context_filename: Optional[str] = Field(default=None, max_length=256)
class ChatResponse(BaseModel):
+60 -1
View File
@@ -20,7 +20,11 @@ from app.api.models import (
)
from app.config.settings import settings
from app.shared.async_utils import iter_in_thread
from app.shared.bootstrap import get_agent_conversation_service, get_agent_session_service
from app.shared.bootstrap import (
get_agent_conversation_service,
get_agent_session_service,
get_agentic_conversation_service,
)
# Keep route handlers close to their transport-layer wiring for easier auditing.
@@ -182,3 +186,58 @@ async def submit_feedback(request: FeedbackRequest):
return {"message": "反馈已提交", "session_id": result.session_id, "message_index": result.message_index}
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc))
# ── P0-1: Agentic RAG endpoint ────────────────────────────────────────────────
@router.post("/agentic/stream")
async def agentic_stream(request: ChatRequest):
"""Stream an Agentic RAG response with live multi-step reasoning trace.
Unlike the standard ``/chat/stream`` endpoint this route runs a full pipeline:
intent analysis → query planning → iterative retrieval → grounding check →
answer generation.
Extra SSE event types beyond the standard ones:
* ``thinking`` — reasoning sub-step progress; data is a JSON object with
``step`` (intent_analysis / query_planning / retrieving / grounding_check),
``status`` (running / done), and step-specific fields.
The ``sources``, ``content``, and ``done`` events are identical to the standard
chat-stream contract so the existing frontend parser can handle them without
changes.
"""
async def generate_sse() -> AsyncGenerator[str, None]:
"""Handle SSE generation for the agentic chat endpoint."""
try:
session_id_, event_stream = get_agentic_conversation_service().stream_agentic_chat(
query=request.query,
session_id=request.session_id,
filters=request.filters,
provider=request.provider or settings.llm_provider,
model=request.model or settings.llm_model,
top_k=request.top_k or settings.rag_top_k,
context_text=request.context_text,
context_filename=request.context_filename,
)
yield f"event: session\ndata: {json.dumps({'session_id': session_id_})}\n\n"
async for event_data in iter_in_thread(event_stream):
event_type = event_data.get("event", "content")
data = event_data.get("data", "")
if isinstance(data, (dict, list)):
yield f"event: {event_type}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
else:
yield f"event: {event_type}\ndata: {data}\n\n"
except Exception as exc:
yield f"event: error\ndata: {str(exc)}\n\n"
return StreamingResponse(
generate_sse(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
+23 -7
View File
@@ -85,9 +85,10 @@ async def analyze_stream(
Events: stage | source | finding | done | error
"""
from app.application.compliance.pipeline import (
detect_cross_clause_conflicts,
extract_text_from_doc_id,
extract_text_from_file,
run_clauses_parallel,
run_clauses_streaming,
split_into_clauses,
synthesize_conclusion,
)
@@ -135,23 +136,27 @@ async def analyze_stream(
await asyncio.sleep(0)
clauses: list[str] = await asyncio.to_thread(split_into_clauses, para_text, client)
# ── Stage 3: retrieve + gap check (parallel across all clauses) ────────────
# ── Stage 3: progressive per-clause retrieve + gap check ──────
findings: list[dict] = []
total_clauses = len(clauses)
yield _sse({
"type": "stage",
"stage": "analyzing",
"label": f"Analyzing {len(clauses)} clauses in parallel",
"label": f"Analyzing {total_clauses} clauses…",
})
# Emit initial progress so the frontend can show the total count
yield _sse({"type": "progress", "done": 0, "total": total_clauses})
await asyncio.sleep(0)
clause_results = await run_clauses_parallel(
done_count = 0
# Stream results as each clause completes (not after all finish)
async for res in run_clauses_streaming(
clauses, retrieval_service, client,
top_k=5,
domains=domains or None,
)
for res in clause_results:
):
done_count += 1
i = res["index"]
chunks = res["chunks"]
finding = res["finding"]
@@ -165,14 +170,25 @@ async def analyze_stream(
"score": round(float(getattr(chunk, "score", 0)), 3),
"status": "retrieved",
"full_content": (getattr(chunk, "text", "") or "")[:300],
"clause_index": i,
})
if finding:
findings.append(finding)
yield _sse({"type": "finding", **finding})
# Real progress update after each clause completes
yield _sse({"type": "progress", "done": done_count, "total": total_clauses})
await asyncio.sleep(0)
# ── Stage 3b: cross-clause conflict detection ─────────────────
if findings:
conflicts = await asyncio.to_thread(
detect_cross_clause_conflicts, findings, client
)
if conflicts:
yield _sse({"type": "conflicts", "items": conflicts})
# ── Stage 4: synthesize conclusion ────────────────────────────
yield _sse({"type": "stage", "stage": "concluding", "label": "Generating conclusion…"})
await asyncio.sleep(0)
+3
View File
@@ -241,6 +241,9 @@ async def get_document_management_list():
"updated_at": item.updated_at.isoformat(),
"regulation_type": item.regulation_type,
"version": item.version,
# True only when the original binary file is stored in MinIO.
# Milvus-only synthetic docs have no binary file — download is disabled.
"has_file": bool(item.object_name),
}
for item in documents
],
+82 -3
View File
@@ -3,10 +3,14 @@
from __future__ import annotations
import json
from typing import AsyncGenerator
import os
import re
import tempfile
from typing import AsyncGenerator, Optional
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, File, UploadFile
from fastapi.responses import StreamingResponse
from loguru import logger
from app.api.dependencies.auth import get_current_user
from app.config.settings import settings
@@ -15,6 +19,8 @@ from app.schemas.rag import RagChatRequest, QuickQuestionsResponse, QuickQuestio
from app.shared.async_utils import iter_in_thread
from app.shared.bootstrap import get_agent_conversation_service
# Maximum characters of document text injected as LLM context (≈ 6 000 tokens).
_MAX_CONTEXT_CHARS = 8_000
router = APIRouter(prefix="/rag", tags=["RAG问答"])
@@ -28,17 +34,90 @@ _DEFAULT_QUICK_QUESTIONS = [
]
def _extract_text_from_bytes(content: bytes, filename: str) -> str:
"""Extract plain text from an uploaded file using the document parser.
Tries the configured parser first; falls back to raw UTF-8 decode for
plain-text formats (.txt, .md). Returns at most _MAX_CONTEXT_CHARS characters
so the text fits comfortably inside the LLM context window.
"""
suffix = os.path.splitext(filename or "doc.pdf")[1] or ".pdf"
# Fast path: plain-text files don't need a parser
if suffix.lower() in {".txt", ".md", ".csv"}:
try:
return content.decode("utf-8", errors="replace")[:_MAX_CONTEXT_CHARS]
except Exception:
pass
tmp_path = ""
try:
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
tmp.write(content)
tmp_path = tmp.name
from app.shared.bootstrap import get_document_command_service
svc = get_document_command_service()
parsed = svc.parser.parse(file_path=tmp_path, doc_id="ctx_extract", doc_name=filename)
if parsed.raw_text:
return parsed.raw_text[:_MAX_CONTEXT_CHARS]
# Fallback: join semantic blocks
return "\n".join(
b.get("text", "") for b in parsed.semantic_blocks if b.get("text")
)[:_MAX_CONTEXT_CHARS]
except Exception as exc:
logger.warning("Context text extraction failed for {}: {}", filename, exc)
return ""
finally:
if tmp_path:
try:
os.unlink(tmp_path)
except OSError:
pass
@router.post("/upload-context")
async def upload_context(
file: UploadFile = File(...),
current_user: UserClaims = Depends(get_current_user),
):
"""Extract text from an uploaded document and return it as conversation context.
The client stores the returned text and includes it in subsequent /rag/chat
requests via the context_text field — the LLM receives the document content
directly without requiring vector-store indexing.
"""
content = await file.read()
filename = file.filename or "document"
text = await __import__("asyncio").to_thread(_extract_text_from_bytes, content, filename)
if not text.strip():
from fastapi import HTTPException
raise HTTPException(status_code=422, detail="Could not extract text from the uploaded file.")
return {
"filename": filename,
"text": text,
"char_count": len(text),
"truncated": len(text) >= _MAX_CONTEXT_CHARS,
}
@router.post("/chat")
async def rag_chat(
request: RagChatRequest,
current_user: UserClaims = Depends(get_current_user),
):
"""Stream RAG Q&A using the real agent service."""
"""Stream RAG Q&A using the real agent service.
When request.context_text is provided the document text is passed directly
to the answer generator as a dedicated document context section — RAG
retrieval still runs on the user's original question (not the document text)
so embedding quality is preserved for regulation chunk matching.
"""
session_id, event_stream = get_agent_conversation_service().stream_chat(
query=request.query,
session_id=request.session_id,
filters=request.filters,
top_k=request.top_k or settings.rag_top_k,
context_text=request.context_text,
context_filename=request.context_filename,
)
async def generate() -> AsyncGenerator[str, None]:
+169
View File
@@ -1,18 +1,24 @@
"""Define API routes for status."""
import asyncio
import time
from typing import Any
from fastapi import APIRouter
from app.config.settings import settings
from app.domain.retrieval import RetrievedChunk
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
router = APIRouter(prefix="/status", tags=["系统状态"])
@@ -23,6 +29,16 @@ _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",
}
@router.get("/stats")
async def get_stats():
@@ -111,3 +127,156 @@ async def get_health():
"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]}
+7 -1
View File
@@ -1,7 +1,13 @@
"""Initialize the app.application.agent package."""
from .services import AgentConversationService, AgentSessionFeedbackResult, AgentSessionService
from .agentic_service import AgenticConversationService
# Keep package boundaries explicit so backend imports stay predictable.
__all__ = ["AgentConversationService", "AgentSessionFeedbackResult", "AgentSessionService"]
__all__ = [
"AgentConversationService",
"AgentSessionFeedbackResult",
"AgentSessionService",
"AgenticConversationService",
]
@@ -0,0 +1,453 @@
"""Implement the Agentic RAG pipeline for multi-step reasoning (P0-1).
Architecture
------------
The pipeline adds four explicit reasoning steps before answer generation:
1. Intent Analysis — classify query type (simple_qa / compare / multi_hop / ambiguous)
2. Query Planning — for complex intents, decompose into focused sub-queries
3. Iterative Retrieval — retrieve for each sub-query, merge with deduplication
4. Grounding Check — verify retrieved context is sufficient; refine query when not
5. Answer Generation — stream final answer with citations (reuses AnswerGenerator)
Each step emits SSE ``thinking`` events so the frontend can render the live
reasoning trace. The pipeline is entirely synchronous and returns a generator so
it plugs into the same ``iter_in_thread`` pattern used by the existing chat routes.
"""
from __future__ import annotations
import json
from dataclasses import dataclass, field
from typing import Generator
from loguru import logger
from app.application.knowledge import KnowledgeRetrievalService
from app.application.agent.hyde_expander import HyDEExpander
from app.config.settings import settings
from app.domain.conversation import ConversationStore
from app.domain.retrieval import RetrievedChunk
from app.infrastructure.llm.openai_compatible_answer_generator import OpenAICompatibleAnswerGenerator
from app.services.llm.llm_factory import get_llm_client
# ── Prompts ───────────────────────────────────────────────────────────────────
# Each prompt is kept module-level for easy review and fine-tuning.
_INTENT_SYSTEM = (
"You are a query classifier for a Chinese regulatory compliance knowledge base.\n\n"
"Classify the query into exactly one of:\n"
'- "simple_qa" : Single-hop, factual question about one regulation or clause\n'
'- "compare" : Comparison between two or more regulations, standards, or versions\n'
'- "multi_hop" : Requires chaining facts across multiple regulations to answer\n'
'- "ambiguous" : Too vague or broad to retrieve effectively\n\n'
"Return ONLY valid JSON — no markdown, no extra text:\n"
'{"type": "...", "reason": "one sentence", "requires_decomposition": true/false}\n\n'
'"requires_decomposition" must be true for compare and multi_hop types.'
)
_PLAN_SYSTEM = (
"You are a query planner for a Chinese regulatory compliance knowledge base.\n\n"
"Decompose the query into 2-4 focused, self-contained sub-queries that together fully "
"address the original question. Each sub-query must target one specific regulation, "
"clause, or concept and be independently searchable.\n\n"
"Return ONLY a valid JSON array — no markdown, no extra text:\n"
'["sub-query 1", "sub-query 2", ...]'
)
_GROUNDING_SYSTEM = (
"You are a grounding verifier for a regulatory compliance QA system.\n\n"
"Given a query and retrieved regulation passages, decide whether the passages contain "
"sufficient, accurate information to answer the query.\n\n"
"Return ONLY valid JSON — no markdown, no extra text:\n"
'{"sufficient": true/false, "confidence": 0.0-1.0, "reason": "one sentence", '
'"refined_query": "a more specific search query if not sufficient, else null"}'
)
# ── Result dataclasses ────────────────────────────────────────────────────────
@dataclass
class IntentResult:
"""Capture the output of the intent-analysis step."""
type: str = "simple_qa"
reason: str = ""
requires_decomposition: bool = False
@dataclass
class GroundingResult:
"""Capture the output of the grounding-check step."""
sufficient: bool = True
confidence: float = 1.0
reason: str = ""
refined_query: str | None = None
# ── Service ───────────────────────────────────────────────────────────────────
class AgenticConversationService:
"""Multi-step Agentic RAG pipeline with live reasoning trace via SSE.
The service is intentionally synchronous so it can be wrapped in
``iter_in_thread`` by the route layer without any async boilerplate.
"""
def __init__(
self,
*,
retrieval_service: KnowledgeRetrievalService,
answer_generator: OpenAICompatibleAnswerGenerator,
conversation_store: ConversationStore,
) -> None:
"""Initialise with injected dependencies from the composition root."""
self.retrieval_service = retrieval_service
self.answer_generator = answer_generator
self.conversation_store = conversation_store
# HyDE expander is stateless — one instance shared for all requests.
self._hyde = HyDEExpander()
# ── Private helpers ───────────────────────────────────────────────────────
def _llm_json(
self,
system: str,
user: str,
provider: str | None,
model: str | None,
max_tokens: int = 300,
) -> dict | list | None:
"""Call the LLM with a JSON-only prompt and return the parsed result.
Returns ``None`` on any API or parse failure so callers can degrade
gracefully without raising.
"""
client = get_llm_client(
provider=provider or settings.llm_provider,
model=model or settings.llm_model,
)
resp = client.chat(
[{"role": "system", "content": system}, {"role": "user", "content": user}],
max_tokens=max_tokens,
temperature=0.1,
)
if not resp.is_success:
logger.warning("AgenticService LLM call failed: {}", resp.error)
return None
try:
raw = resp.content.strip()
# Strip accidental markdown code fences the model may add.
if raw.startswith("```"):
parts = raw.split("```")
raw = parts[1] if len(parts) > 1 else raw
if raw.startswith("json"):
raw = raw[4:]
return json.loads(raw.strip())
except (json.JSONDecodeError, IndexError) as exc:
logger.debug("AgenticService JSON parse failed: {} | raw={}", exc, resp.content[:200])
return None
def _analyze_intent(
self, query: str, provider: str | None, model: str | None
) -> IntentResult:
"""Classify query intent to select the appropriate retrieval strategy."""
data = self._llm_json(
_INTENT_SYSTEM,
f"Query: {query}",
provider,
model,
max_tokens=settings.agentic_intent_max_tokens,
)
if isinstance(data, dict):
return IntentResult(
type=str(data.get("type", "simple_qa")),
reason=str(data.get("reason", "")),
requires_decomposition=bool(data.get("requires_decomposition", False)),
)
return IntentResult(type="simple_qa", reason="fallback — classifier returned no JSON", requires_decomposition=False)
def _plan_queries(
self, query: str, intent_type: str, provider: str | None, model: str | None
) -> list[str]:
"""Decompose a complex query into focused, independently-retrievable sub-queries."""
data = self._llm_json(
_PLAN_SYSTEM,
f"Original query ({intent_type}): {query}",
provider,
model,
max_tokens=settings.agentic_plan_max_tokens,
)
if isinstance(data, list) and data:
# Cap at configured maximum to keep latency predictable.
return [str(q) for q in data[:settings.agentic_max_sub_queries] if q]
return [query]
def _check_grounding(
self,
query: str,
chunks: list[RetrievedChunk],
provider: str | None,
model: str | None,
) -> GroundingResult:
"""Verify whether retrieved chunks are sufficient to ground an accurate answer.
Uses a fast score-threshold heuristic first; falls back to an LLM call only
when scores are borderline so that the happy-path adds no extra latency.
"""
if not chunks:
return GroundingResult(
sufficient=False,
confidence=0.0,
reason="未检索到相关内容",
refined_query=None,
)
avg_score = sum(c.score for c in chunks) / len(chunks)
# Fast path: high-confidence retrieval → skip extra LLM call.
if avg_score > settings.agentic_grounding_threshold and len(chunks) >= 3:
return GroundingResult(
sufficient=True,
confidence=round(avg_score, 3),
reason="检索置信度充足,无需二次查询",
refined_query=None,
)
# LLM-based grounding check for borderline retrievals.
context_preview = "\n".join(
f"[{i + 1}] (score={c.score:.2f}) {c.text[:200]}" for i, c in enumerate(chunks[:5])
)
data = self._llm_json(
_GROUNDING_SYSTEM,
f"Query: {query}\n\nRetrieved passages:\n{context_preview}",
provider,
model,
max_tokens=settings.agentic_grounding_max_tokens,
)
if isinstance(data, dict):
return GroundingResult(
sufficient=bool(data.get("sufficient", True)),
confidence=float(data.get("confidence", 0.5)),
reason=str(data.get("reason", "")),
refined_query=data.get("refined_query") or None,
)
return GroundingResult(sufficient=True, confidence=0.5, reason="grounding check skipped (parse error)", refined_query=None)
@staticmethod
def _intent_to_template(intent_type: str) -> str:
"""Map an intent type to the best prompt template name for answer generation."""
mapping = {
"compare": "comparison",
"multi_hop": "compliance_qa",
"simple_qa": "compliance_qa",
"ambiguous": "compliance_qa",
}
return mapping.get(intent_type, "compliance_qa")
@staticmethod
def _deduplicate(chunks: list[RetrievedChunk], max_chunks: int) -> list[RetrievedChunk]:
"""Remove duplicate chunk IDs, preserving first-occurrence order up to max_chunks."""
seen: set[str] = set()
result: list[RetrievedChunk] = []
for chunk in chunks:
if chunk.chunk_id not in seen:
seen.add(chunk.chunk_id)
result.append(chunk)
if len(result) >= max_chunks:
break
return result
# ── Public interface ──────────────────────────────────────────────────────
def stream_agentic_chat(
self,
*,
query: str,
session_id: str | None = None,
filters: str | None = None,
provider: str | None = None,
model: str | None = None,
top_k: int = 5,
context_text: str | None = None,
context_filename: str | None = None,
) -> tuple[str, Generator[dict, None, None]]:
"""Run the full Agentic RAG pipeline and return ``(session_id, event_generator)``.
When context_text is provided (user-attached document) it is:
- Summarised and prepended to the intent-analysis prompt so the classifier
understands what kind of question is being asked.
- Treated as baseline grounding so the pipeline skips unnecessary retries
when the document itself is the primary source.
- Passed to the answer generator so the LLM sees the full document alongside
retrieved regulation chunks.
The generator yields SSE event dicts compatible with the route's
``iter_in_thread`` pattern.
"""
session = self.conversation_store.get_session(session_id) if session_id else None
if session is None:
session = self.conversation_store.create_session()
self.conversation_store.save_message(session.session_id, role="user", content=query)
history = [{"role": msg.role, "content": msg.content} for msg in session.messages[-10:]]
active_session_id = session.session_id
# Build a brief document summary for classifier/planner prompts (avoid
# passing the full text which could overwhelm small-context LLMs).
_doc_summary: str = ""
if context_text and context_text.strip():
_doc_label = context_filename or "document"
_preview = context_text.strip()[:400]
_doc_summary = f"[User has attached document: {_doc_label}]\nDocument preview: {_preview}\n\n"
def event_stream() -> Generator[dict, None, None]:
"""Execute all pipeline steps and yield SSE events."""
# ── Step 1: Intent Analysis ──────────────────────────────────────
yield {"event": "thinking", "data": {"step": "intent_analysis", "status": "running"}}
# Prepend doc summary so the classifier knows what the user is asking about
intent_user_msg = f"{_doc_summary}Query: {query}" if _doc_summary else f"Query: {query}"
data = self._llm_json(
_INTENT_SYSTEM, intent_user_msg, provider, model,
max_tokens=settings.agentic_intent_max_tokens,
)
if isinstance(data, dict):
intent = IntentResult(
type=str(data.get("type", "simple_qa")),
reason=str(data.get("reason", "")),
requires_decomposition=bool(data.get("requires_decomposition", False)),
)
else:
intent = IntentResult(type="simple_qa", reason="fallback", requires_decomposition=False)
logger.debug("Agentic intent: type={} decompose={}", intent.type, intent.requires_decomposition)
yield {
"event": "thinking",
"data": {
"step": "intent_analysis",
"status": "done",
"intent_type": intent.type,
"reason": intent.reason,
"requires_decomposition": intent.requires_decomposition,
},
}
# ── Step 2: Query Planning ───────────────────────────────────────
sub_queries: list[str] = [query]
if intent.requires_decomposition:
yield {"event": "thinking", "data": {"step": "query_planning", "status": "running"}}
plan_user_msg = f"{_doc_summary}Original query ({intent.type}): {query}" if _doc_summary else f"Original query ({intent.type}): {query}"
data_plan = self._llm_json(
_PLAN_SYSTEM, plan_user_msg, provider, model,
max_tokens=settings.agentic_plan_max_tokens,
)
if isinstance(data_plan, list) and data_plan:
sub_queries = [str(q) for q in data_plan[:settings.agentic_max_sub_queries] if q]
logger.debug("Agentic sub-queries ({}): {}", len(sub_queries), sub_queries)
yield {
"event": "thinking",
"data": {"step": "query_planning", "status": "done", "sub_queries": sub_queries},
}
# ── Step 3: Iterative Retrieval ──────────────────────────────────
# Always retrieve using the user's original question (NOT the document
# text) so embedding quality is preserved for regulation matching.
# HyDE enriches the retrieval query with a short hypothetical answer
# to close the vocabulary gap between terse queries and long documents.
candidate_k = max(top_k * 3, 15)
all_chunks: list[RetrievedChunk] = []
# For simple_qa with a single query, HyDE gives the biggest benefit
# (bridging vague/colloquial questions to formal document language).
# For compare/multi_hop, the planner already decomposed into precise
# sub-queries, so HyDE is less critical but still applied per sub-query.
for idx, sq in enumerate(sub_queries, start=1):
yield {
"event": "thinking",
"data": {"step": "retrieving", "status": "running", "query": sq, "index": idx, "total": len(sub_queries)},
}
# HyDE expansion: generate hypothetical answer, embed it for retrieval.
# Falls back to original sub-query if LLM call fails.
retrieval_query = self._hyde.expand(sq)
chunks = self.retrieval_service.retrieve(query=retrieval_query, top_k=candidate_k, filters=filters)
all_chunks.extend(chunks)
yield {
"event": "thinking",
"data": {"step": "retrieving", "status": "done", "query": sq, "index": idx, "total": len(sub_queries), "found": len(chunks)},
}
unique_chunks = self._deduplicate(all_chunks, max_chunks=top_k * 4)
# ── Step 4: Grounding Check ──────────────────────────────────────
yield {"event": "thinking", "data": {"step": "grounding_check", "status": "running"}}
# When the user has attached a document, the document itself provides
# baseline grounding — skip the re-query loop to avoid the LLM asking
# "please provide the document text" as a refined query.
if context_text and context_text.strip():
grounding = GroundingResult(
sufficient=True,
confidence=0.95,
reason="用户已附件上传文档,以文档内容为基础作答",
refined_query=None,
)
else:
grounding = self._check_grounding(query, unique_chunks, provider, model)
yield {
"event": "thinking",
"data": {
"step": "grounding_check",
"status": "done",
"sufficient": grounding.sufficient,
"confidence": grounding.confidence,
"reason": grounding.reason,
},
}
# Only retry from vector store when no document is attached and grounding failed
if not grounding.sufficient and grounding.refined_query and not context_text:
logger.info("Grounding insufficient — re-querying: {}", grounding.refined_query)
yield {
"event": "thinking",
"data": {"step": "retrieving", "status": "running", "query": grounding.refined_query, "index": 1, "total": 1, "retry": True},
}
# Apply HyDE to the refined query as well for better retrieval.
refined_hyde_query = self._hyde.expand(grounding.refined_query)
refined_chunks = self.retrieval_service.retrieve(query=refined_hyde_query, top_k=candidate_k, filters=filters)
all_chunks.extend(refined_chunks)
unique_chunks = self._deduplicate(all_chunks, max_chunks=top_k * 4)
yield {
"event": "thinking",
"data": {"step": "retrieving", "status": "done", "query": grounding.refined_query, "index": 1, "total": 1, "found": len(refined_chunks), "retry": True},
}
final_chunks = unique_chunks[:top_k]
# ── Step 5: Answer Generation ────────────────────────────────────
sources_payload = [s.__dict__ for s in self.answer_generator._sources(final_chunks)]
yield {"event": "sources", "data": sources_payload}
answer_parts: list[str] = []
for event in self.answer_generator.stream_generate(
query=query,
retrieved_chunks=final_chunks,
history=history,
provider=provider,
model=model,
prompt_template=self._intent_to_template(intent.type),
context_text=context_text,
context_filename=context_filename,
):
if event.get("event") == "content":
answer_parts.append(str(event.get("data", "")))
yield event
full_answer = "".join(answer_parts)
self.conversation_store.save_message(
active_session_id,
role="assistant",
content=full_answer,
sources=sources_payload,
)
return active_session_id, event_stream()
@@ -0,0 +1,105 @@
"""Implement HyDE (Hypothetical Document Embeddings) query expansion.
HyDE improves dense retrieval by addressing the vocabulary gap between
short user queries and longer document passages:
User query → [LLM generates hypothetical answer]
embed hypothetical answer (not original query)
retrieve similar real passages from Milvus
The hypothetical answer uses the same vocabulary and phrasing as documents,
so its embedding is much closer to relevant chunks than a terse query embedding.
Usage:
expander = HyDEExpander()
retrieval_query = expander.expand(query, provider=..., model=...)
chunks = retrieval_service.retrieve(query=retrieval_query, ...)
When the LLM call fails, expand() falls back to the original query so the
retrieval pipeline degrades gracefully.
References:
Gao et al. (2022), "Precise Zero-Shot Dense Retrieval without Relevance Labels"
https://arxiv.org/abs/2212.10496
"""
from __future__ import annotations
from loguru import logger
from app.config.settings import settings
from app.services.llm.llm_factory import get_llm_client
# Maximum chars to trim from the hypothetical answer to avoid token overrun.
_MAX_HYPOTHESIS_CHARS = 600
# System prompt that instructs the LLM to write a passage *as if* it were
# from a regulatory document, not a conversation answer.
_HYDE_SYSTEM = (
"你是一位法规知识库专家。用户提出了一个问题,"
"请用50-120字写一段话,模拟如果相关法规文档中存在完美答案,"
"该段落会是什么内容。\n\n"
"要求:\n"
"- 使用与法规文档相同的正式书面语气\n"
"- 包含可能的条款编号、标准名称等关键术语\n"
"- 不要解释你在做什么,直接输出假设性段落\n"
"- 如问题过于模糊,写一段合理的通用法规说明"
)
class HyDEExpander:
"""Generate a hypothetical document passage to improve dense retrieval.
The expander is stateless — instantiate once and call expand() per query.
It requires no external dependencies beyond the project's existing LLM
client infrastructure.
"""
def expand(self, query: str) -> str:
"""Return a combined retrieval query: original query + hypothetical passage.
The combination ensures:
- Dense retrieval uses the enriched hypothetical text (semantic match).
- BM25 retrieval still benefits from the original query keywords.
The model used is ``settings.hyde_llm_model`` (dedicated lightweight model)
falling back to the main ``settings.llm_model`` when not configured.
If the LLM call fails for any reason, returns the original query unchanged.
"""
if not settings.hyde_enabled:
return query
# Use the dedicated HyDE model when configured; fall back to main LLM.
# A lightweight model (e.g. qwen3.5-flash) is sufficient for generating
# a short hypothetical passage and significantly reduces cost + latency.
provider = settings.hyde_llm_provider or settings.llm_provider
model = settings.hyde_llm_model or settings.llm_model
try:
client = get_llm_client(provider=provider, model=model)
resp = client.chat(
messages=[
{"role": "system", "content": _HYDE_SYSTEM},
{"role": "user", "content": f"问题:{query}"},
],
max_tokens=settings.hyde_max_tokens,
# Low temperature: we want a plausible, deterministic passage.
temperature=0.3,
)
if not resp.is_success or not resp.content:
logger.debug("HyDE LLM call failed or empty — using original query")
return query
hypothesis = resp.content.strip()[:_MAX_HYPOTHESIS_CHARS]
logger.debug("HyDE expanded query ({}{} chars)", len(query), len(hypothesis))
# Concatenate: the embedding model will see the full combined text,
# so the resulting vector leans toward the hypothetical document style.
return f"{query}\n\n{hypothesis}"
except Exception as exc: # noqa: BLE001 — intentional broad catch for graceful fallback
logger.warning("HyDE expansion failed: {} — using original query", exc)
return query
+20 -2
View File
@@ -9,6 +9,7 @@ from app.domain.conversation import AnswerGenerator, AnswerResult, ConversationS
from app.domain.retrieval import RetrievedChunk
from app.application.knowledge import KnowledgeRetrievalService
from app.application.agent.hyde_expander import HyDEExpander
# Keep orchestration logic centralized so use-case flow stays easy to trace.
@@ -26,6 +27,8 @@ class AgentConversationService:
self.retrieval_service = retrieval_service
self.answer_generator = answer_generator
self.conversation_store = conversation_store
# Shared HyDE expander — stateless, safe for reuse across requests.
self._hyde = HyDEExpander()
def ask(
self,
@@ -108,14 +111,26 @@ class AgentConversationService:
model: str | None = None,
top_k: int = 5,
prompt_template: str | None = None,
context_text: str | None = None,
context_filename: str | None = None,
) -> tuple[str, Generator[dict, None, None]]:
"""Stream chat for the Agent Conversation Service instance."""
"""Stream chat for the Agent Conversation Service instance.
When context_text is provided the user's document is passed directly to
the answer generator — RAG retrieval still runs on the user's question
(not the document text) to find relevant regulation passages.
"""
session = self.conversation_store.get_session(session_id) if session_id else None
if session is None:
session = self.conversation_store.create_session()
self.conversation_store.save_message(session.session_id, role="user", content=query)
history = [{"role": msg.role, "content": msg.content} for msg in session.messages[-10:]]
retrieved = self.retrieval_service.retrieve(query=query, top_k=top_k, filters=filters)
# HyDE: expand the query with a hypothetical answer to improve dense retrieval.
# For document-context queries, skip HyDE since the document itself guides retrieval.
retrieval_query = self._hyde.expand(query) if not context_text else query
# Retrieve using the enriched query — NOT the document text —
# so embedding quality is preserved for regulation chunk matching.
retrieved = self.retrieval_service.retrieve(query=retrieval_query, top_k=top_k, filters=filters)
def event_stream() -> Generator[dict, None, None]:
"""Handle event stream for the Agent Conversation Service instance."""
@@ -129,6 +144,8 @@ class AgentConversationService:
provider=provider,
model=model,
prompt_template=prompt_template,
context_text=context_text,
context_filename=context_filename,
):
if event.get("event") == "sources":
sources_payload = event.get("data", [])
@@ -189,3 +206,4 @@ class AgentSessionService:
raise ValueError("消息索引不存在")
# Preserve the existing API behavior until a persistent feedback store is introduced.
return AgentSessionFeedbackResult(session_id=session_id, message_index=message_index)
+245 -45
View File
@@ -51,19 +51,36 @@ def _extract_json(text: str):
def extract_text_from_doc_id(doc_id: str) -> str:
"""Fetch the full text of a document by retrieving its chunks filtered by doc_id.
Uses a high top_k and doc_id filter to reconstruct the document in chunk order,
avoiding the previous approach of semantic search by doc_name which could return
chunks from unrelated documents.
"""
from app.shared.bootstrap import get_document_query_service, get_retrieval_service
doc = get_document_query_service().get(doc_id)
if not doc:
raise ValueError(f"Document '{doc_id}' not found")
service = get_retrieval_service()
chunks = service.retrieve(query=doc.doc_name, top_k=30)
doc_chunks = [c for c in chunks if c.doc_id == doc_id]
# Use doc_name as a broad query, filter strictly by doc_id so we only get
# this document's chunks; top_k=100 covers most real-world documents.
chunks = service.retrieve(query=doc.doc_name, top_k=100, filters=doc_id)
doc_chunks = [c for c in chunks if getattr(c, "doc_id", None) == doc_id]
if not doc_chunks:
doc_chunks = chunks[:15]
return "\n\n".join(c.text for c in doc_chunks[:15])
# Fallback: use top results even without doc_id match (e.g., legacy store)
doc_chunks = chunks[:30]
# Sort by chunk_index to preserve document reading order
doc_chunks.sort(key=lambda c: getattr(c, "chunk_index", 0))
return "\n\n".join(c.text for c in doc_chunks[:40])
def extract_text_from_file(content: bytes, filename: str) -> str:
"""Parse an uploaded file and return its full text content.
Removed previous 4000-char cap so large specifications and standards are
fully analysed. The caller is responsible for splitting the text into
clause-sized chunks before passing to the LLM.
"""
from app.shared.bootstrap import get_document_command_service
suffix = os.path.splitext(filename or "doc.pdf")[1] or ".pdf"
tmp_path = ""
@@ -74,10 +91,11 @@ def extract_text_from_file(content: bytes, filename: str) -> str:
service = get_document_command_service()
parsed = service.parser.parse(file_path=tmp_path, doc_id="tmp_analysis", doc_name=filename)
if parsed.raw_text:
return parsed.raw_text[:4000]
# Return full text — truncation happens in split_into_clauses()
return parsed.raw_text
return "\n".join(
b.get("text", "") for b in parsed.semantic_blocks[:30] if b.get("text")
)[:4000]
b.get("text", "") for b in parsed.semantic_blocks if b.get("text")
)
except Exception as exc:
logger.warning("File text extraction failed: {}", exc)
return ""
@@ -88,27 +106,68 @@ def extract_text_from_file(content: bytes, filename: str) -> str:
def split_into_clauses(text: str, client: "BaseLLMClient") -> list[str]:
"""Split a compliance document into semantically independent clauses.
For long texts (> 2 000 chars) the document is processed in overlapping
2 000-char windows so no content is missed. Each window produces up to 4
clauses; results are deduplicated and capped at 12 total to keep analysis
latency reasonable.
"""
# Window size and step for sliding-window clause extraction
_WINDOW = 2000
_STEP = 1800 # 200-char overlap to avoid cutting clauses at boundaries
_MAX_CLAUSES = 12
windows = []
if len(text) <= _WINDOW:
windows = [text]
else:
pos = 0
while pos < len(text):
windows.append(text[pos: pos + _WINDOW])
pos += _STEP
all_clauses: list[str] = []
for window in windows:
prompt = (
"You are a compliance analysis expert. Split the following text into 3-8 "
"semantically complete compliance clauses. Each clause should be an independent "
"compliance requirement or technical statement.\n"
"You are a compliance analysis expert. Split the following text into "
"3-4 semantically complete compliance clauses. Each clause must be an "
"independent requirement or technical statement. Omit section headings, "
"definitions, and non-normative text.\n"
"Return as JSON array of strings, e.g.:\n"
'["Clause one...", "Clause two..."]\n'
"Return ONLY the JSON array.\n\n"
f"Text:\n{text[:2000]}"
f"Text:\n{window}"
)
response = client.chat([{"role": "user", "content": prompt}], max_tokens=1000)
response = client.chat([{"role": "user", "content": prompt}], max_tokens=800)
if response.is_success:
try:
result = _extract_json(response.content)
if isinstance(result, list):
clauses = [str(c).strip() for c in result if str(c).strip()]
if clauses:
return clauses[:8]
all_clauses.extend(clauses[:4])
except (ValueError, TypeError):
logger.warning("Clause split JSON parse failed, using fallback")
sentences = re.split(r"[.?!;\n]+", text)
return [s.strip() for s in sentences if len(s.strip()) > 20][:6]
logger.warning("Clause split JSON parse failed for window, using sentence fallback")
sentences = re.split(r"[.?!;\n]+", window)
all_clauses.extend(s.strip() for s in sentences if len(s.strip()) > 20)
else:
# LLM unavailable — fall back to sentence splitting for this window
sentences = re.split(r"[.?!;\n]+", window)
all_clauses.extend(s.strip() for s in sentences if len(s.strip()) > 20)
if len(all_clauses) >= _MAX_CLAUSES:
break
# Deduplicate near-duplicates (same first 80 chars) that span window boundaries
seen: set[str] = set()
deduped: list[str] = []
for c in all_clauses:
key = c[:80].lower()
if key not in seen:
seen.add(key)
deduped.append(c)
return deduped[:_MAX_CLAUSES]
def retrieve_for_clause(
@@ -117,7 +176,33 @@ def retrieve_for_clause(
top_k: int = 5,
domains: str | None = None,
) -> list["RetrievedChunk"]:
return retrieval_service.retrieve(query=clause, top_k=top_k, filters=domains)
"""Retrieve regulation chunks relevant to a clause.
If the best retrieval score is below 0.55, rewrite the clause into a more
technical query and retry once to improve coverage.
"""
chunks = retrieval_service.retrieve(query=clause, top_k=top_k, filters=domains)
if not chunks:
return chunks
best_score = max((getattr(c, "score", 0) for c in chunks), default=0)
if best_score < 0.55:
# Rewrite clause as technical keyword query and retry
keywords = " ".join(
w for w in re.split(r"\W+", clause) if len(w) > 3
)[:200]
retry_chunks = retrieval_service.retrieve(query=keywords, top_k=top_k, filters=domains)
if retry_chunks:
# Merge: keep unique chunks, prefer higher-score version
seen_ids: set[str] = {getattr(c, "chunk_id", str(i)) for i, c in enumerate(chunks)}
for rc in retry_chunks:
rid = getattr(rc, "chunk_id", "")
if rid not in seen_ids:
chunks.append(rc)
seen_ids.add(rid)
chunks.sort(key=lambda c: getattr(c, "score", 0), reverse=True)
chunks = chunks[:top_k]
return chunks
def process_single_clause(
@@ -130,14 +215,75 @@ def process_single_clause(
) -> dict:
"""Process one clause: retrieve relevant regulations then check compliance.
Returns a dict with keys: index, chunks, finding (may be None on LLM failure).
Returns a dict with keys:
- index: clause position (for ordering)
- chunks: list of RetrievedChunk (for source events)
- finding: dict with title/desc/status/clause_ref/confidence (may be None on LLM failure)
Designed to run inside asyncio.to_thread() for parallel execution.
The finding now includes a 'source_refs' list linking back to the chunks
that informed the verdict, enabling the frontend to correlate sources with findings.
"""
chunks = retrieve_for_clause(clause, retrieval_service, top_k, domains)
finding = check_clause_compliance(clause, chunks, client)
if finding is not None:
# Attach source references so the frontend can link finding ↔ sources
finding["source_refs"] = [
{
"standard": getattr(c, "doc_title", "") or getattr(c, "doc_name", ""),
"clause": getattr(c, "section_title", "") or "",
"score": round(float(getattr(c, "score", 0)), 3),
}
for c in chunks[:3]
]
return {"index": index, "chunks": chunks, "finding": finding}
async def run_clauses_streaming(
clauses: list[str],
retrieval_service: "KnowledgeRetrievalService",
client: "BaseLLMClient",
top_k: int = 5,
domains: str | None = None,
):
"""Process all clauses concurrently and yield each result as it completes.
Unlike the old gather()-based approach, this uses asyncio.Queue so that
findings are emitted to the SSE stream immediately when each clause
finishes — the user sees results progressively rather than waiting for
the slowest clause before seeing any output.
Yields dicts with keys: index, chunks, finding (same schema as
process_single_clause, plus a sentinel {"_done": True} at the end).
"""
queue: asyncio.Queue[dict] = asyncio.Queue()
total = len(clauses)
async def _worker(clause: str, i: int) -> None:
"""Run one clause in a thread and push the result into the queue."""
try:
result = await asyncio.to_thread(
process_single_clause,
clause, i, retrieval_service, client, top_k, domains,
)
except Exception as exc:
logger.warning("Clause {} processing failed: {}", i, exc)
result = {"index": i, "chunks": [], "finding": None}
await queue.put(result)
# Launch all workers concurrently
tasks = [asyncio.create_task(_worker(clause, i)) for i, clause in enumerate(clauses)]
received = 0
while received < total:
result = await queue.get()
yield result
received += 1
# Wait for all tasks to complete (they should already be done by now)
await asyncio.gather(*tasks, return_exceptions=True)
async def run_clauses_parallel(
clauses: list[str],
retrieval_service: "KnowledgeRetrievalService",
@@ -145,31 +291,15 @@ async def run_clauses_parallel(
top_k: int = 5,
domains: str | None = None,
) -> list[dict]:
"""Run all clauses through retrieve+gap-check in parallel.
"""Legacy batch API kept for backward compatibility.
Results are returned in the original clause order even though processing
is concurrent. Exceptions in individual clauses are caught and returned as
dicts with finding=None so the stream continues for remaining clauses.
Both retrieval_service and client must be thread-safe — they are shared
across all asyncio.to_thread() calls without locking.
Collects all streaming results and returns them sorted by clause index.
New code should use run_clauses_streaming() directly.
"""
tasks = [
asyncio.to_thread(
process_single_clause,
clause, i, retrieval_service, client, top_k, domains,
)
for i, clause in enumerate(clauses)
]
raw = await asyncio.gather(*tasks, return_exceptions=True)
results = []
for i, r in enumerate(raw):
if isinstance(r, Exception):
logger.warning("Clause {} processing failed: {}", i, r)
results.append({"index": i, "chunks": [], "finding": None})
else:
results.append(r)
return results
results: list[dict] = []
async for result in run_clauses_streaming(clauses, retrieval_service, client, top_k, domains):
results.append(result)
return sorted(results, key=lambda r: r["index"])
def check_clause_compliance(
@@ -177,6 +307,15 @@ def check_clause_compliance(
chunks: list["RetrievedChunk"],
client: "BaseLLMClient",
) -> dict | None:
"""Check whether a business clause complies with the retrieved regulations.
The prompt explicitly instructs the LLM to:
- extract clause_ref from the retrieved text (not invent it)
- include a confidence score (0-1) reflecting how well the retrieved
chunks cover the clause topic
Returns None only when the LLM call fails after all retries.
"""
reg_context = "\n".join(
f"[{i+1}] {c.doc_title} {c.section_title or ''}: {c.text[:300]}"
for i, c in enumerate(chunks[:5])
@@ -186,14 +325,17 @@ def check_clause_compliance(
"complies with the retrieved regulations.\n\n"
f"Business clause:\n{clause}\n\n"
f"Retrieved regulations:\n{reg_context}\n\n"
"Return JSON:\n"
"Return JSON with these exact fields:\n"
"{\n"
' "status": "ok" | "warn" | "risk",\n'
' "title": "Short finding title (max 30 chars)",\n'
' "desc": "Description (50-120 chars)",\n'
' "clause_ref": "Regulation clause reference e.g. Art.9.1 or Sec.3.1"\n'
' "clause_ref": "Exact clause/article reference copied from the retrieved text above, '
'e.g. Art.9.1 or Sec.3.1. Use null if no specific clause number appears in the retrieved text.",\n'
' "confidence": 0.0-1.0 // how well the retrieved context covers this clause topic\n'
"}\n"
"status: ok=compliant, warn=gap exists, risk=critical/missing\n"
"IMPORTANT: copy clause_ref verbatim from the retrieved text; do NOT invent references.\n"
"Return ONLY the JSON object."
)
@@ -216,7 +358,10 @@ def check_clause_compliance(
"title": str(result.get("title", "Compliance finding")),
"desc": str(result.get("desc", "")),
"status": result.get("status", "info"),
"clause_ref": result.get("clause_ref"),
# None if LLM correctly found no clause number in retrieved text
"clause_ref": result.get("clause_ref") or None,
# Confidence score helps frontend show retrieval quality indicator
"confidence": float(result.get("confidence", 0.5)),
}
except (ValueError, TypeError) as exc:
logger.warning("Gap check JSON parse failed: {}", exc)
@@ -368,3 +513,58 @@ def generate_suggestions(
except (ValueError, TypeError) as exc:
logger.warning("generate_suggestions JSON parse failed: {}", exc)
return fallback
def detect_cross_clause_conflicts(
findings: list[dict],
client: "BaseLLMClient",
) -> list[dict]:
"""Detect contradictions and missing cross-references across all findings.
Runs a single LLM call after all per-clause findings are collected.
Returns a list of conflict dicts: {type, finding_a, finding_b, desc}.
Returns an empty list on LLM failure so the caller can proceed without it.
"""
if len(findings) < 2:
# Need at least 2 findings to compare
return []
findings_text = "\n".join(
f"[{i+1}] [{f['status'].upper()}] {f['title']}: {f['desc']}"
+ (f" (Ref: {f['clause_ref']})" if f.get("clause_ref") else "")
for i, f in enumerate(findings)
)
prompt = (
"You are a compliance expert. Review the following compliance findings from the same document "
"and identify any cross-clause issues:\n\n"
f"Findings:\n{findings_text}\n\n"
"Return JSON array of conflicts (empty array [] if none found):\n"
"[\n"
" {\n"
' "type": "contradiction" | "missing_ref" | "cumulative_risk",\n'
' "finding_a": <1-based index>,\n'
' "finding_b": <1-based index or null>,\n'
' "desc": "Brief description of the cross-clause issue (max 100 chars)"\n'
" }\n"
"]\n"
"Return ONLY the JSON array."
)
try:
response = client.chat([{"role": "user", "content": prompt}], max_tokens=600)
if not response.is_success:
return []
result = _extract_json(response.content)
if isinstance(result, list):
return [
{
"type": str(c.get("type", "contradiction")),
"finding_a": int(c.get("finding_a", 0)),
"finding_b": c.get("finding_b"),
"desc": str(c.get("desc", "")),
}
for c in result
if isinstance(c, dict)
]
except Exception as exc:
logger.warning("detect_cross_clause_conflicts failed: {}", exc)
return []
+81 -6
View File
@@ -526,10 +526,28 @@ class DocumentCommandService:
logger.warning("临时文件清理失败: {}", temp_path)
def delete(self, doc_id: str) -> bool:
"""Delete document record, binary file, and vector chunks."""
"""Delete document record, binary file, and vector chunks.
Handles two cases:
- Normal docs: have a metadata record in the document repository.
- Milvus-only (synthetic) docs: visible in management-list because they
have Milvus vectors but no JSON/PG metadata record. We still clean up
the Milvus chunks so the document disappears from the list.
"""
document = self.document_repository.get(doc_id)
if not document:
# No metadata record — might be a Milvus-only synthetic document.
# Attempt vector cleanup directly; treat as success if any chunks deleted.
try:
deleted_count = self.vector_index.delete_by_document(doc_id)
if deleted_count > 0:
logger.info("Deleted Milvus-only doc (no metadata record): doc_id={} chunks={}", doc_id, deleted_count)
return True
except Exception as exc:
logger.warning("Milvus-only delete failed for doc_id={}: {}", doc_id, exc)
return False
# Normal doc: clean up binary, vectors, artifacts, processing records, metadata.
try:
self.binary_store.delete(document.object_name)
except Exception:
@@ -627,13 +645,16 @@ class DocumentQueryService:
result.append(doc)
# Surface Milvus-only docs that have no metadata record at all.
# MinIO almost certainly has their binaries (they were uploaded), so
# set object_name to the sentinel "{doc_id}/" so the route marks
# has_file=True; the download endpoint will list MinIO to find the file.
for doc_id, row in milvus_by_id.items():
if doc_id not in meta_by_id:
synthetic = Document(
doc_id=doc_id,
doc_name=row.get("doc_title", doc_id),
file_name=row.get("doc_title", doc_id),
object_name="",
object_name=f"{doc_id}/", # sentinel: MinIO prefix exists
content_type="",
size_bytes=0,
status=DocumentStatus.INDEXED,
@@ -646,9 +667,63 @@ class DocumentQueryService:
result.sort(key=lambda d: d.updated_at, reverse=True)
return result[:limit] if limit is not None else result
def download(self, doc_id: str) -> tuple[Document, bytes]:
"""Handle download for the Document Query Service instance."""
def download(self, doc_id: str) -> tuple["Document", bytes]:
"""Return the document record and its binary content from MinIO.
Fallback strategy for Milvus-only docs (no JSON/PG metadata record):
1. Try metadata repository first (normal path).
2. If metadata is missing, list MinIO objects with prefix ``{doc_id}/``
and synthesise a minimal Document from the first object found.
This handles documents whose metadata records were lost but whose
binary files are still in object storage.
3. If neither source has the file, raise FileNotFoundError.
"""
from app.domain.documents import Document, DocumentStatus
document = self.document_repository.get(doc_id)
if not document:
raise FileNotFoundError(f"文档不存在: {doc_id}")
if document and document.object_name and not document.object_name.endswith("/"):
# Normal doc with a concrete object_name — read directly.
return document, self.binary_store.read(document.object_name)
if document and not document.object_name:
raise FileNotFoundError(f"该文档无原始文件(仅含索引数据,无法下载): {doc_id}")
if not document or document.object_name.endswith("/"):
# Metadata missing — try to find the file in MinIO by doc_id prefix.
try:
objects = self.binary_store.list_objects(prefix=f"{doc_id}/")
# Filter out artifact JSON files; prefer the source document.
candidates = [o for o in objects if not o.endswith(".json")]
if not candidates:
candidates = objects # fall back to all objects if only JSON found
if not candidates:
raise FileNotFoundError(f"文档不存在(MinIO 和元数据均无记录): {doc_id}")
object_name = candidates[0]
file_name = object_name.split("/", 1)[-1] if "/" in object_name else object_name
# Guess content type from extension.
ext = file_name.rsplit(".", 1)[-1].lower() if "." in file_name else ""
_ct_map = {
"pdf": "application/pdf",
"docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"doc": "application/msword",
"txt": "text/plain",
}
content_type = _ct_map.get(ext, "application/octet-stream")
# Synthesise a minimal Document so the route can build the response.
document = Document(
doc_id=doc_id,
doc_name=file_name,
file_name=file_name,
object_name=object_name,
content_type=content_type,
size_bytes=0,
status=DocumentStatus.INDEXED,
)
logger.info("MinIO fallback download: doc_id={} object={}", doc_id, object_name)
except FileNotFoundError:
raise
except Exception as exc:
raise FileNotFoundError(f"文档不存在: {doc_id}") from exc
return document, self.binary_store.read(document.object_name)
+36
View File
@@ -133,6 +133,42 @@ class Settings(BaseSettings):
reranker_api_key: str = Field(default="", description="Reranker API 密钥")
reranker_top_k: int = Field(default=5, description="精排后保留的最终结果数量")
# ── HyDE (Hypothetical Document Embeddings) ──────────────────────────────
# When enabled, the agentic and standard RAG pipelines generate a short
# hypothetical answer before retrieval, then embed that text instead of the
# raw query. This closes the vocabulary gap between terse queries and longer
# document passages, typically improving recall by 15-30% on vague queries.
hyde_enabled: bool = Field(default=True, description="启用 HyDE 查询增强(假设文档嵌入)")
hyde_max_tokens: int = Field(default=200, description="HyDE 假设段落最大 token 数")
# Use a lightweight model for HyDE to reduce latency and cost.
# HyDE only needs a short plausible passage — a fast cheap model is sufficient.
# Leave empty to fall back to the main llm_provider / llm_model.
hyde_llm_provider: str = Field(default="", description="HyDE 专用 LLM 提供商(空则复用主 LLM)")
hyde_llm_model: str = Field(default="", description="HyDE 专用 LLM 模型(空则复用主 LLM)")
# ── Agentic RAG (P0-1) ───────────────────────────────────────────────────
# Controls the multi-step reasoning pipeline exposed at /agent/agentic/stream.
agentic_max_sub_queries: int = Field(
default=4,
description="Agentic 模式最大子查询分解数量(compare / multi_hop 意图触发)",
)
agentic_grounding_threshold: float = Field(
default=0.65,
description=(
"引文锚定 fast-path 阈值:avg_score > 此值且 chunks ≥ 3 时跳过 LLM grounding check"
"直接判定为充分;降低此值可让更多问题触发 LLM 二次验证。"
),
)
agentic_intent_max_tokens: int = Field(
default=200, description="意图分析步骤 LLM 最大 token 数"
)
agentic_plan_max_tokens: int = Field(
default=400, description="查询分解步骤 LLM 最大 token 数"
)
agentic_grounding_max_tokens: int = Field(
default=250, description="引文锚定步骤 LLM 最大 token 数"
)
# Keep configuration setup explicit so runtime behavior is easy to reason about.
milvus_index_type: str = Field(default="IVF_FLAT", description="Milvus索引类型")
milvus_nlist: int = Field(default=128, description="Milvus nlist参数")
@@ -3,11 +3,13 @@
from __future__ import annotations
import os
import time
import httpx
from app.config.settings import settings
from app.domain.retrieval import EmbeddingProvider
from app.shared.model_usage_tracker import get_model_usage_tracker
# Keep adapter behavior explicit so integration details remain easy to audit.
EMBEDDING_BATCH_SIZE = 8
@@ -45,6 +47,8 @@ class OpenAICompatibleEmbeddingProvider(EmbeddingProvider):
"""Handle request for this module for the Open A I Compatible Embedding Provider instance."""
if not self.api_key:
raise ValueError("缺少 EMBEDDING_API_KEY / OPENAI_API_KEY")
start = time.time()
try:
response = httpx.post(
f"{self.base_url}/embeddings",
headers={
@@ -56,9 +60,28 @@ class OpenAICompatibleEmbeddingProvider(EmbeddingProvider):
)
self._raise_for_status(response, batch_size=len(texts))
data = response.json()
except Exception as exc:
# Record the failed call so the Status page can show it as an error,
# then re-raise unchanged so existing callers keep their current behavior.
get_model_usage_tracker().record(
provider="embedding",
model=self.model,
success=False,
latency_ms=int((time.time() - start) * 1000),
error=str(exc),
)
raise
vectors = [item["embedding"] for item in sorted(data.get("data", []), key=lambda item: item["index"])]
if any(len(vector) != self.dimension for vector in vectors):
raise ValueError(f"embedding 维度不匹配,期望 {self.dimension}")
# Record token usage from the OpenAI-compatible response, e.g. {"total_tokens": N}.
get_model_usage_tracker().record(
provider="embedding",
model=self.model,
success=True,
usage=data.get("usage", {}),
latency_ms=int((time.time() - start) * 1000),
)
return vectors
def embed_texts(self, texts: list[str]) -> list[list[float]]:
@@ -9,10 +9,12 @@ from app.config.settings import settings
from app.domain.conversation import AnswerGenerator, AnswerResult, AnswerSource
from app.domain.retrieval import RetrievedChunk
from app.services.llm.llm_factory import get_llm_client
from app.services.rag.prompt_templates import PromptTemplates
# Keep adapter behavior explicit so integration details remain easy to audit.
PROMPT_TEMPLATES = {
# Fallback system prompts used when no rich template matches.
_FALLBACK_PROMPTS = {
"default": "你是法规知识问答助手。请仅依据提供的上下文回答;如果上下文不足,明确说明。",
"compliance_qa": "你是法规合规问答助手。优先引用给定法规原文,回答要准确、克制,并注明依据来源。",
}
@@ -38,33 +40,80 @@ class OpenAICompatibleAnswerGenerator(AnswerGenerator):
retrieved_chunks: list[RetrievedChunk],
history: list[dict[str, str]] | None,
prompt_template: str | None,
context_text: str | None = None,
context_filename: str | None = None,
) -> tuple[list[dict[str, str]], int]:
"""Handle build messages for this module for the Open A I Compatible Answer Generator instance."""
system_prompt = PROMPT_TEMPLATES.get(prompt_template or "compliance_qa", PROMPT_TEMPLATES["default"])
"""Build the message list to send to the LLM.
When context_text is provided the user's document is injected as a
dedicated section BEFORE the retrieved regulation chunks so the LLM
can reason about the document directly while still referencing regulations.
The retrieval step uses only the user's question, not the document text,
so embedding quality is preserved.
System prompt selection priority:
1. Rich template from PromptTemplates (compliance_qa / comparison /
compliance_check / clause_interpretation / …)
2. Fallback hardcoded prompt when no rich template matches.
"""
# Look up the rich template first; fall back to simple hardcoded prompts.
tpl_name = prompt_template or "compliance_qa"
rich_tpl = PromptTemplates.get_template(tpl_name)
if rich_tpl:
system_prompt = rich_tpl.system_prompt
else:
system_prompt = _FALLBACK_PROMPTS.get(tpl_name, _FALLBACK_PROMPTS["default"])
context_blocks = []
context_tokens = 0
# ── User document context (if attached) ───────────────────────────────
if context_text and context_text.strip():
doc_label = f"附件文档:{context_filename}" if context_filename else "附件文档"
doc_block = f"[{doc_label}]\n{context_text.strip()}"
doc_tokens = self._estimate_tokens(doc_block)
# Reserve at most half the context budget for the user document
half_budget = settings.rag_max_context_tokens // 2
if doc_tokens > half_budget:
# Truncate document to fit half the budget
ratio = half_budget / doc_tokens
doc_block = doc_block[: int(len(doc_block) * ratio)] + "\n…(文档已截断)"
doc_tokens = half_budget
context_blocks.append(doc_block)
context_tokens += doc_tokens
# ── Retrieved regulation chunks ────────────────────────────────────────
remaining_budget = settings.rag_max_context_tokens - context_tokens
for idx, chunk in enumerate(retrieved_chunks, start=1):
block = (
f"[{idx}] 文档: {chunk.doc_title}\n"
f"[法规{idx}] 文档: {chunk.doc_title}\n"
f"章节: {chunk.section_title or '未标注'}\n"
f"页码: {chunk.page_start}" + (f"-{chunk.page_end}" if chunk.page_end and chunk.page_end != chunk.page_start else "") + "\n"
f"内容: {chunk.text}"
)
block_tokens = self._estimate_tokens(block)
if context_tokens + block_tokens > settings.rag_max_context_tokens:
if block_tokens > remaining_budget:
break
remaining_budget -= block_tokens
context_tokens += block_tokens
context_blocks.append(block)
context = "\n\n".join(context_blocks)
messages = [{"role": "system", "content": system_prompt}]
for item in history or []:
messages.append({"role": item["role"], "content": item["content"]})
messages.append(
{
"role": "user",
"content": f"问题:{query}\n\n参考上下文:\n{context}\n\n请在回答后给出简要引用编号。",
}
# Craft the user turn differently when a document is attached
if context_text and context_text.strip():
user_content = (
f"问题:{query}\n\n"
f"请先基于上方附件文档内容进行分析,再结合法规参考上下文给出合规评估。"
f"\n\n参考上下文:\n{context}\n\n"
f"请在回答中注明引用来源编号(如适用)。"
)
else:
user_content = f"问题:{query}\n\n参考上下文:\n{context}\n\n请在回答后给出简要引用编号。"
messages.append({"role": "user", "content": user_content})
return messages, context_tokens
def _is_context_truncated(self, *, retrieved_chunks: list[RetrievedChunk], context_tokens: int) -> bool:
@@ -112,6 +161,8 @@ class OpenAICompatibleAnswerGenerator(AnswerGenerator):
provider: str | None = None,
model: str | None = None,
prompt_template: str | None = None,
context_text: str | None = None,
context_filename: str | None = None,
) -> AnswerResult:
"""Handle generate for the Open A I Compatible Answer Generator instance."""
start = time.time()
@@ -120,6 +171,8 @@ class OpenAICompatibleAnswerGenerator(AnswerGenerator):
retrieved_chunks=retrieved_chunks,
history=history,
prompt_template=prompt_template,
context_text=context_text,
context_filename=context_filename,
)
client = get_llm_client(provider=provider or settings.llm_provider, model=model or settings.llm_model)
response = client.chat(messages)
@@ -147,6 +200,8 @@ class OpenAICompatibleAnswerGenerator(AnswerGenerator):
provider: str | None = None,
model: str | None = None,
prompt_template: str | None = None,
context_text: str | None = None,
context_filename: str | None = None,
) -> Generator[dict, None, AnswerResult]:
"""Stream generate for the Open A I Compatible Answer Generator instance."""
start = time.time()
@@ -155,6 +210,8 @@ class OpenAICompatibleAnswerGenerator(AnswerGenerator):
retrieved_chunks=retrieved_chunks,
history=history,
prompt_template=prompt_template,
context_text=context_text,
context_filename=context_filename,
)
sources = [source.__dict__ for source in self._sources(retrieved_chunks)]
yield {"event": "sources", "data": sources}
@@ -41,6 +41,10 @@ class MinioDocumentBinaryStore(DocumentBinaryStore):
raise FileNotFoundError(f"对象不存在: {object_name}")
return data
def list_objects(self, prefix: str = "") -> list[str]:
"""List object names in the bucket that start with the given prefix."""
return self.client.list_objects(prefix=prefix)
def delete(self, object_name: str) -> None:
"""Handle delete for the Minio Document Binary Store instance."""
if not self.client.delete_object(object_name):
@@ -9,6 +9,7 @@ from loguru import logger
from app.config.settings import settings
from app.domain.retrieval import Reranker, RetrievedChunk
from app.shared.model_usage_tracker import get_model_usage_tracker
class OpenAICompatibleReranker(Reranker):
@@ -37,10 +38,26 @@ class OpenAICompatibleReranker(Reranker):
scores = self._call_reranker(query, texts)
except Exception as exc:
logger.warning("Reranker call failed ({}), falling back to original order: {}", type(exc).__name__, exc)
# Record the failure so the Status page reflects real reranker health.
get_model_usage_tracker().record(
provider="reranker",
model=self._model,
success=False,
latency_ms=int((time.time() - start) * 1000),
error=str(exc),
)
return chunks[:top_k]
elapsed_ms = int((time.time() - start) * 1000)
logger.debug("Reranker scored {} chunks in {}ms", len(chunks), elapsed_ms)
# TEI/Cohere-style rerank responses carry no token usage field —
# only call success/latency is meaningful for this role.
get_model_usage_tracker().record(
provider="reranker",
model=self._model,
success=True,
latency_ms=elapsed_ms,
)
ranked = sorted(
[(score, chunk) for score, chunk in zip(scores, chunks)],
@@ -54,22 +71,48 @@ class OpenAICompatibleReranker(Reranker):
return result
def _call_reranker(self, query: str, texts: list[str]) -> list[float]:
"""Call the reranker API and return a score per text."""
"""Call the reranker API and return a score per text.
Tries TEI format first (POST /rerank with model+texts), then falls back
to Cohere/OpenAI format (POST /v1/rerank with model+documents).
Both formats now include the model name, which most gateways require.
"""
headers = {"Content-Type": "application/json"}
if self._api_key:
headers["Authorization"] = f"Bearer {self._api_key}"
# Try TEI format first: POST /rerank
payload = {"query": query, "texts": texts, "raw_scores": False, "return_text": False}
# TEI format: POST /rerank — include model name (required by gateway proxies)
payload = {
"model": self._model,
"query": query,
"texts": texts,
"raw_scores": False,
"return_text": False,
}
url = f"{self._base_url}/rerank"
resp = requests.post(url, json=payload, headers=headers, timeout=self._timeout)
if resp.status_code == 404:
# Fall back to Cohere / OpenAI-style: POST /v1/rerank
if resp.status_code in (404, 400):
# Gateway returned an error — try Cohere/OpenAI-style format as fallback.
logger.debug(
"TEI rerank returned {} — trying Cohere format. Body: {}",
resp.status_code,
resp.text[:200],
)
payload_v1 = {"model": self._model, "query": query, "documents": texts}
url = f"{self._base_url}/v1/rerank"
resp = requests.post(url, json=payload_v1, headers=headers, timeout=self._timeout)
if not resp.ok:
# Surface a clear error message so callers can log it meaningfully.
try:
err_body = resp.json()
err_msg = err_body.get("error", {}).get("message", resp.text[:200])
except Exception:
err_msg = resp.text[:200]
resp.raise_for_status() # raises HTTPError with status code
raise ValueError(err_msg) # unreachable but satisfies type checker
resp.raise_for_status()
data = resp.json()
+5
View File
@@ -12,6 +12,11 @@ class RagChatRequest(BaseModel):
top_k: int = 5
session_id: Optional[str] = None
filters: Optional[str] = None
# Optional document text to inject directly as LLM conversation context.
# When provided the document content is prepended to the query so the LLM
# can answer questions about it without requiring vector-store indexing.
context_text: Optional[str] = None
context_filename: Optional[str] = None
class RetrievedDoc(BaseModel):
+21 -2
View File
@@ -1,9 +1,16 @@
"""Provide service-layer logic for base client."""
"""Provide service-layer logic for base client.
P0-0: ``LLMResponse`` now carries an optional ``tool_calls`` list so that any
downstream code (agents, pipelines) can inspect and dispatch tool invocations
without touching the provider-specific adapter layer.
"""
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import List, Dict, Optional, Any
from enum import Enum
from app.services.llm.tool_types import Tool, ToolCall # noqa: F401 re-exported for callers
# Keep provider-specific behavior explicit so debugging stays straightforward.
@@ -24,6 +31,8 @@ class LLMResponse:
finish_reason: str = "stop"
latency_ms: int = 0
error: Optional[str] = None
# P0-0: populated when the model returns tool-call(s) instead of plain text.
tool_calls: List[ToolCall] = field(default_factory=list)
@property
def is_success(self) -> bool:
@@ -63,9 +72,19 @@ class BaseLLMClient(ABC):
messages: List[Dict[str, str]],
max_tokens: Optional[int] = None,
temperature: Optional[float] = None,
tools: Optional[List["Tool"]] = None,
**kwargs
) -> LLMResponse:
"""Handle chat for the Base L L M Client instance."""
"""Handle chat for the Base L L M Client instance.
Args:
messages: OpenAI-format message list.
max_tokens: Override config max_tokens when set.
temperature: Override config temperature when set.
tools: Optional list of Tool definitions to offer the model.
When provided, the model may respond with tool_calls in the
returned LLMResponse instead of (or in addition to) content.
"""
pass
def complete(
+34 -5
View File
@@ -1,4 +1,8 @@
"""Provide service-layer logic for deepseek client."""
"""Provide service-layer logic for deepseek client.
P0-0: ``chat()`` now accepts an optional ``tools`` list and parses ``tool_calls``
from the model response so that callers can dispatch tool invocations.
"""
import time
from typing import List, Dict, Optional
@@ -6,6 +10,7 @@ from loguru import logger
import httpx
from .base_client import BaseLLMClient, LLMResponse, LLMConfig, LLMProvider
from .tool_types import Tool, ToolCall
# Keep provider-specific behavior explicit so debugging stays straightforward.
@@ -46,13 +51,20 @@ class DeepSeekClient(BaseLLMClient):
messages: List[Dict[str, str]],
max_tokens: Optional[int] = None,
temperature: Optional[float] = None,
tools: Optional[List[Tool]] = None,
**kwargs
) -> LLMResponse:
"""Handle chat for the Deep Seek Client instance."""
"""Handle chat for the Deep Seek Client instance.
When ``tools`` is provided the request includes the tool definitions and
``tool_choice="auto"``; any tool_calls returned by the model are parsed
into ``LLMResponse.tool_calls``.
"""
import json
start_time = time.time()
try:
payload = {
payload: Dict = {
"model": self.config.model,
"messages": messages,
"max_tokens": max_tokens or self.config.max_tokens,
@@ -61,6 +73,11 @@ class DeepSeekClient(BaseLLMClient):
"stream": False
}
# P0-0: inject tool definitions when provided.
if tools:
payload["tools"] = [t.to_openai_format() for t in tools]
payload["tool_choice"] = "auto"
response = self._client.post("/chat/completions", json=payload)
response.raise_for_status()
@@ -71,12 +88,24 @@ class DeepSeekClient(BaseLLMClient):
choices = data.get("choices", [{}])
message = choices[0].get("message", {})
# P0-0: parse tool_calls returned by the model.
raw_tool_calls = message.get("tool_calls") or []
parsed_tool_calls: List[ToolCall] = []
for tc in raw_tool_calls:
fn = tc.get("function", {})
try:
args = json.loads(fn.get("arguments", "{}"))
except json.JSONDecodeError:
args = {}
parsed_tool_calls.append(ToolCall(id=tc.get("id", ""), name=fn.get("name", ""), arguments=args))
return LLMResponse(
content=message.get("content", ""),
content=message.get("content", "") or "",
model=data.get("model", self.config.model),
usage=data.get("usage", {}),
finish_reason=choices[0].get("finish_reason", "stop"),
latency_ms=latency_ms
latency_ms=latency_ms,
tool_calls=parsed_tool_calls,
)
except httpx.HTTPStatusError as e:
+12 -5
View File
@@ -7,6 +7,8 @@ from functools import lru_cache
from .base_client import BaseLLMClient, LLMConfig, LLMProvider, LLMResponse
from .deepseek_client import DeepSeekClient
from .qwen_client import QwenClient, QwenVLClient
from .tracked_client import TrackedLLMClient
from app.shared.model_usage_tracker import get_model_usage_tracker
# Keep provider-specific behavior explicit so debugging stays straightforward.
@@ -45,7 +47,7 @@ class LLMFactory:
max_tokens: int = 4096,
temperature: float = 0.7,
**kwargs
) -> BaseLLMClient:
) -> "BaseLLMClient | TrackedLLMClient":
"""Handle create for the L L M Factory instance."""
provider_enum = self._parse_provider(provider)
@@ -76,11 +78,16 @@ class LLMFactory:
# Keep provider-specific behavior explicit so debugging stays straightforward.
client = self._create_client(config)
# Wrap in TrackedLLMClient so every call site (agentic, HyDE, perception,
# compliance, document summarization, main answer generation) is recorded
# without each of them needing to know about usage tracking.
tracked_client = TrackedLLMClient(client, get_model_usage_tracker())
# Keep provider-specific behavior explicit so debugging stays straightforward.
LLMFactory._global_instances[cache_key] = client
LLMFactory._global_instances[cache_key] = tracked_client
logger.info(f"LLM客户端创建成功并缓存: {provider} - {model}")
return client
return tracked_client
def _parse_provider(self, provider: str) -> LLMProvider:
"""Handle parse provider for this module for the L L M Factory instance."""
@@ -137,7 +144,7 @@ class LLMFactory:
return client_class(config)
def get_cached(self, provider: str, model: Optional[str] = None) -> Optional[BaseLLMClient]:
def get_cached(self, provider: str, model: Optional[str] = None) -> "BaseLLMClient | TrackedLLMClient | None":
"""Return cached for the L L M Factory instance."""
provider_enum = self._parse_provider(provider)
model = model or DEFAULT_MODELS.get(provider_enum)
@@ -200,7 +207,7 @@ def get_llm_client(
provider: str = "qwen",
model: Optional[str] = None,
**kwargs
) -> BaseLLMClient:
) -> "BaseLLMClient | TrackedLLMClient":
"""Return llm client."""
factory = get_llm_factory()
+33 -5
View File
@@ -1,4 +1,8 @@
"""Provide service-layer logic for qwen client."""
"""Provide service-layer logic for qwen client.
P0-0: ``chat()`` now accepts an optional ``tools`` list and parses ``tool_calls``
from the model response so that callers can dispatch tool invocations.
"""
import time
import json
@@ -7,6 +11,7 @@ from loguru import logger
import httpx
from .base_client import BaseLLMClient, LLMResponse, LLMConfig, LLMProvider
from .tool_types import Tool, ToolCall
# Keep provider-specific behavior explicit so debugging stays straightforward.
@@ -54,14 +59,20 @@ class QwenClient(BaseLLMClient):
messages: List[Dict[str, str]],
max_tokens: Optional[int] = None,
temperature: Optional[float] = None,
tools: Optional[List[Tool]] = None,
**kwargs
) -> LLMResponse:
"""Handle chat for the Qwen Client instance."""
"""Handle chat for the Qwen Client instance.
When ``tools`` is provided the request includes the tool definitions and
``tool_choice="auto"``; any tool_calls returned by the model are parsed
into ``LLMResponse.tool_calls``.
"""
start_time = time.time()
try:
# Keep provider-specific behavior explicit so debugging stays straightforward.
payload = {
payload: Dict = {
"model": self.config.model,
"messages": messages,
"max_tokens": max_tokens or self.config.max_tokens,
@@ -70,6 +81,11 @@ class QwenClient(BaseLLMClient):
"stream": False
}
# P0-0: inject tool definitions when provided.
if tools:
payload["tools"] = [t.to_openai_format() for t in tools]
payload["tool_choice"] = "auto"
# Keep provider-specific behavior explicit so debugging stays straightforward.
response = self._client.post("/chat/completions", json=payload)
response.raise_for_status()
@@ -82,12 +98,24 @@ class QwenClient(BaseLLMClient):
choices = data.get("choices", [{}])
message = choices[0].get("message", {})
# P0-0: parse tool_calls returned by the model.
raw_tool_calls = message.get("tool_calls") or []
parsed_tool_calls: List[ToolCall] = []
for tc in raw_tool_calls:
fn = tc.get("function", {})
try:
args = json.loads(fn.get("arguments", "{}"))
except json.JSONDecodeError:
args = {}
parsed_tool_calls.append(ToolCall(id=tc.get("id", ""), name=fn.get("name", ""), arguments=args))
return LLMResponse(
content=message.get("content", ""),
content=message.get("content", "") or "",
model=data.get("model", self.config.model),
usage=data.get("usage", {}),
finish_reason=choices[0].get("finish_reason", "stop"),
latency_ms=latency_ms
latency_ms=latency_ms,
tool_calls=parsed_tool_calls,
)
except httpx.HTTPStatusError as e:
+80
View File
@@ -0,0 +1,80 @@
"""Shared tool and tool-call type definitions for LLM function calling (P0-0).
These types implement the OpenAI-compatible tool/function-calling interface so that
any provider whose gateway supports the spec (DeepSeek, Qwen, etc.) can expose
tools to the LLM and receive structured tool invocations in return.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
@dataclass
class ToolCall:
"""Represent a single tool invocation returned by the LLM.
The model fills in ``id``, ``name``, and ``arguments`` when it decides to call
a tool instead of (or in addition to) producing a text response.
"""
# Unique identifier assigned by the model for this call.
id: str
# Name of the tool to invoke, matching the name registered in Tool.
name: str
# Parsed JSON arguments ready for direct use by the tool handler.
arguments: dict[str, Any] = field(default_factory=dict)
@dataclass
class ToolParameter:
"""JSON-Schemacompatible parameter block for a tool definition."""
# Top-level schema type — always "object" for OpenAI-compatible tools.
type: str = "object"
# Map of parameter name → JSON-Schema property descriptor.
properties: dict[str, Any] = field(default_factory=dict)
# List of required parameter names.
required: list[str] = field(default_factory=list)
@dataclass
class Tool:
"""Describe a callable tool that can be offered to the LLM.
Example usage::
search_tool = Tool(
name="search_regulations",
description="Search the compliance knowledge base for relevant regulation clauses.",
parameters=ToolParameter(
properties={"query": {"type": "string", "description": "Search query"}},
required=["query"],
),
)
response = client.chat(messages, tools=[search_tool])
"""
name: str
description: str
parameters: ToolParameter = field(default_factory=ToolParameter)
def to_openai_format(self) -> dict[str, Any]:
"""Serialise this tool to the OpenAI-compatible function-calling schema.
The returned dict can be placed directly in the ``tools`` list of a chat
completions request without any further transformation.
"""
return {
"type": "function",
"function": {
"name": self.name,
"description": self.description,
"parameters": {
"type": self.parameters.type,
"properties": self.parameters.properties,
"required": self.parameters.required,
},
},
}
@@ -0,0 +1,86 @@
"""Transparent decorator around BaseLLMClient implementations.
Records per-call token usage, latency, and success/failure into a
ModelUsageTracker without changing any caller-visible behavior.
"""
from __future__ import annotations
import time
from typing import Any, Dict, List, Optional
from app.shared.model_usage_tracker import ModelUsageTracker
from .base_client import BaseLLMClient, LLMResponse
from .tool_types import Tool
class TrackedLLMClient:
"""Wrap any BaseLLMClient and record its usage into a ModelUsageTracker.
Deliberately does NOT subclass BaseLLMClient: that ABC declares abstract
methods (_init_client, get_available_models) with no meaningful override
here, and subclassing would make Python refuse to instantiate this class
("Can't instantiate abstract class") before __getattr__ ever got a chance
to forward the call. Plain composition + __getattr__ delegation works
because every caller in this codebase only ever uses duck-typed access:
.chat(), .stream_chat(), .get_available_models(), .close(), .config.
"""
def __init__(self, inner: BaseLLMClient, tracker: ModelUsageTracker) -> None:
"""Store the wrapped client and the tracker to report into."""
self._inner = inner
self._tracker = tracker
def chat(
self,
messages: List[Dict[str, str]],
max_tokens: Optional[int] = None,
temperature: Optional[float] = None,
tools: Optional[List[Tool]] = None,
**kwargs: Any,
) -> LLMResponse:
"""Delegate to the wrapped client's chat(), then record the outcome."""
start = time.time()
response = self._inner.chat(messages, max_tokens, temperature, tools, **kwargs)
# Key by the *configured* model, not response.model, so lookups driven
# by settings (llm_model / hyde_llm_model) always match what we recorded.
self._tracker.record(
provider=self._inner.config.provider.value,
model=self._inner.config.model,
success=response.is_success,
usage=response.usage,
latency_ms=int((time.time() - start) * 1000),
error=response.error,
)
return response
def stream_chat(self, messages: List[Dict[str, str]], *args: Any, **kwargs: Any):
"""Delegate to the wrapped client's stream_chat(), recording call outcome only.
Token usage is NOT recorded here: none of the current provider
stream_chat() implementations parse a trailing usage chunk from the
gateway (see the design doc's Known Limitations), so accumulating a
token count here would silently be wrong. Only call success/failure
and latency are tracked for streaming calls.
"""
start = time.time()
error: Optional[str] = None
try:
for chunk in self._inner.stream_chat(messages, *args, **kwargs):
yield chunk
except Exception as exc: # noqa: BLE001 - report, then re-raise unchanged
error = str(exc)
raise
finally:
self._tracker.record(
provider=self._inner.config.provider.value,
model=self._inner.config.model,
success=error is None,
latency_ms=int((time.time() - start) * 1000),
error=error,
)
def __getattr__(self, name: str) -> Any:
"""Forward any other attribute/method access to the wrapped client."""
return getattr(self._inner, name)
+15
View File
@@ -6,6 +6,7 @@ from functools import lru_cache
from typing import Callable
from app.application.agent import AgentConversationService, AgentSessionService
from app.application.agent.agentic_service import AgenticConversationService
from app.application.documents import DocumentCommandService, DocumentQueryService
from app.application.knowledge import KnowledgeRetrievalService
from app.application.perception.services import PerceptionService
@@ -365,6 +366,20 @@ def get_agent_session_service() -> AgentSessionService:
return AgentSessionService(conversation_store=get_conversation_store())
@lru_cache
def get_agentic_conversation_service() -> AgenticConversationService:
"""Return the Agentic RAG service (P0-1).
Uses the same retrieval, generation, and session infrastructure as the
standard chat service so no additional dependencies are required.
"""
return AgenticConversationService(
retrieval_service=get_retrieval_service(),
answer_generator=OpenAICompatibleAnswerGenerator(),
conversation_store=get_conversation_store(),
)
@lru_cache
def get_celery_app():
"""Return the shared Celery application instance.
+113
View File
@@ -0,0 +1,113 @@
"""In-memory registry that tracks per-model call outcomes and token usage.
This module lives in `app/shared` the same cross-cutting-support tier as
`bootstrap.py` because it is not business logic: it exists purely so the
System Status page can show which AI models (main LLM, HyDE LLM, embedding,
reranker) are configured, whether their most recent call succeeded, and how
many tokens they have consumed since this process started. Tracking here
must never disrupt a real user-facing call: every public method swallows its
own exceptions and logs a warning instead of raising.
"""
from __future__ import annotations
import threading
from dataclasses import dataclass
from datetime import datetime, timezone
from functools import lru_cache
from loguru import logger
@dataclass
class ModelUsageEntry:
"""Represent accumulated usage/connection state for one provider+model pair."""
provider: str
model: str
total_tokens: int = 0
prompt_tokens: int = 0
completion_tokens: int = 0
call_count_ok: int = 0
call_count_error: int = 0
last_called_at: datetime | None = None
last_latency_ms: int | None = None
last_error: str | None = None
@property
def status(self) -> str:
"""Derive never_called/ok/error from call history.
The "disabled" status (reranker only, when turned off in settings) is
NOT decided here: this dataclass has no access to live settings. The
API route layer (Task 6) applies that override on top of this value,
so config always wins over stale historical data.
"""
if self.last_called_at is None:
return "never_called"
return "error" if self.last_error else "ok"
class ModelUsageTracker:
"""Thread-safe in-memory registry of per-model call/usage stats.
Keyed by "{provider}:{model}" rather than by business role (main LLM /
HyDE / embedding / reranker) so that any future call site is captured
automatically, even before anyone teaches this class about its role.
"""
def __init__(self) -> None:
"""Initialize an empty registry guarded by a single lock."""
self._entries: dict[str, ModelUsageEntry] = {}
# One coarse lock is enough: record() runs at most a few times per
# request, and snapshot() is only read by the low-traffic status page.
self._lock = threading.Lock()
def record(
self,
*,
provider: str,
model: str,
success: bool,
usage: dict | None = None,
latency_ms: int | None = None,
error: str | None = None,
) -> None:
"""Record the outcome of one call to provider/model.
Never raises: any internal failure is logged and swallowed so a bug
in observability code cannot break a real LLM/embedding/reranker call.
"""
try:
key = f"{provider}:{model}"
usage = usage if isinstance(usage, dict) else {}
with self._lock:
entry = self._entries.setdefault(key, ModelUsageEntry(provider=provider, model=model))
entry.total_tokens += int(usage.get("total_tokens", 0) or 0)
entry.prompt_tokens += int(usage.get("prompt_tokens", 0) or 0)
entry.completion_tokens += int(usage.get("completion_tokens", 0) or 0)
if success:
entry.call_count_ok += 1
entry.last_error = None
else:
entry.call_count_error += 1
entry.last_error = error or "unknown error"
entry.last_called_at = datetime.now(timezone.utc)
entry.last_latency_ms = latency_ms
except Exception as exc: # noqa: BLE001 - tracking must never break a real call
logger.warning("ModelUsageTracker.record failed for {}:{} - {}", provider, model, exc)
def snapshot(self) -> dict[str, ModelUsageEntry]:
"""Return a shallow copy of all tracked entries, safe to mutate by the caller."""
with self._lock:
return dict(self._entries)
def get(self, provider: str, model: str) -> ModelUsageEntry | None:
"""Return the entry for one provider/model pair, or None if never recorded."""
return self.snapshot().get(f"{provider}:{model}")
@lru_cache
def get_model_usage_tracker() -> ModelUsageTracker:
"""Return the process-wide singleton tracker (mirrors get_settings()/get_llm_factory())."""
return ModelUsageTracker()
@@ -0,0 +1,65 @@
"""Verifies OpenAICompatibleEmbeddingProvider records usage into ModelUsageTracker."""
from __future__ import annotations
from unittest.mock import MagicMock, patch
import httpx
import pytest
from app.infrastructure.embedding.openai_compatible_embedding_provider import OpenAICompatibleEmbeddingProvider
from app.shared.model_usage_tracker import get_model_usage_tracker
@pytest.fixture(autouse=True)
def _reset_tracker():
"""Clear the process-wide tracker before and after each test in this file."""
get_model_usage_tracker()._entries.clear()
yield
get_model_usage_tracker()._entries.clear()
def _fake_response(usage: dict) -> MagicMock:
"""Build a fake httpx.Response-like object for a successful embeddings call."""
resp = MagicMock(spec=httpx.Response)
resp.raise_for_status.return_value = None
resp.json.return_value = {
"data": [{"index": 0, "embedding": [0.1] * 1024}],
"usage": usage,
}
return resp
def test_successful_embed_records_usage():
"""A successful embeddings call must record token usage under 'embedding:<model>'."""
provider = OpenAICompatibleEmbeddingProvider()
provider.api_key = "test-key"
with patch("httpx.post", return_value=_fake_response({"prompt_tokens": 3, "total_tokens": 3})):
provider.embed_query("hello")
entry = get_model_usage_tracker().get("embedding", provider.model)
assert entry is not None
assert entry.total_tokens == 3
assert entry.status == "ok"
def test_failed_embed_records_error():
"""An HTTP error from the embeddings endpoint must be recorded as a failure, then re-raised."""
provider = OpenAICompatibleEmbeddingProvider()
provider.api_key = "test-key"
failing_response = MagicMock(spec=httpx.Response)
failing_response.status_code = 500
failing_response.text = "boom"
failing_response.request = MagicMock()
failing_response.request.url = "http://example.com/embeddings"
failing_response.raise_for_status.side_effect = httpx.HTTPStatusError(
"boom", request=failing_response.request, response=failing_response
)
with patch("httpx.post", return_value=failing_response):
with pytest.raises(httpx.HTTPStatusError):
provider.embed_query("hello")
entry = get_model_usage_tracker().get("embedding", provider.model)
assert entry is not None
assert entry.status == "error"
assert entry.call_count_error == 1
@@ -0,0 +1,44 @@
"""Verifies get_llm_client() returns a usage-tracked client end to end."""
from __future__ import annotations
from unittest.mock import MagicMock, patch
import pytest
from app.services.llm.llm_factory import LLMFactory, get_llm_client
from app.services.llm.tracked_client import TrackedLLMClient
from app.shared.model_usage_tracker import get_model_usage_tracker
@pytest.fixture(autouse=True)
def _reset_singletons():
"""Clear the two process-wide singletons this test touches, before and after.
LLMFactory._global_instances and get_model_usage_tracker() both persist
for the life of the process; without this fixture, tests would leak
cached clients/usage data into each other and become order-dependent.
"""
LLMFactory._global_instances.clear()
get_model_usage_tracker().snapshot() # no-op read, just documents intent
get_model_usage_tracker()._entries.clear()
yield
LLMFactory._global_instances.clear()
get_model_usage_tracker()._entries.clear()
def test_get_llm_client_returns_tracked_client():
"""get_llm_client() must return a TrackedLLMClient, not the raw provider client."""
with patch("app.services.llm.llm_factory.DeepSeekClient") as mock_cls:
mock_cls.return_value = MagicMock()
client = get_llm_client(provider="deepseek", model="deepseek-v4-flash", api_key="test-key")
assert isinstance(client, TrackedLLMClient)
def test_get_llm_client_caches_the_tracked_instance():
"""A second call with the same provider/model must return the same TrackedLLMClient."""
with patch("app.services.llm.llm_factory.DeepSeekClient") as mock_cls:
mock_cls.return_value = MagicMock()
first = get_llm_client(provider="deepseek", model="deepseek-v4-flash", api_key="test-key")
second = get_llm_client(provider="deepseek", model="deepseek-v4-flash", api_key="test-key")
assert first is second
@@ -0,0 +1,79 @@
"""Unit tests for ModelUsageTracker — no mocking needed, pure in-memory state."""
from __future__ import annotations
from app.shared.model_usage_tracker import ModelUsageEntry, ModelUsageTracker, get_model_usage_tracker
def test_never_called_model_has_no_entry():
"""A tracker that has never recorded a call returns None from get()."""
tracker = ModelUsageTracker()
assert tracker.get("deepseek", "deepseek-v4-flash") is None
def test_record_success_accumulates_tokens_and_calls():
"""Two successful calls accumulate tokens and call_count_ok."""
tracker = ModelUsageTracker()
tracker.record(
provider="deepseek", model="deepseek-v4-flash", success=True,
usage={"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}, latency_ms=100,
)
tracker.record(
provider="deepseek", model="deepseek-v4-flash", success=True,
usage={"prompt_tokens": 20, "completion_tokens": 8, "total_tokens": 28}, latency_ms=200,
)
entry = tracker.get("deepseek", "deepseek-v4-flash")
assert entry is not None
assert entry.total_tokens == 43
assert entry.prompt_tokens == 30
assert entry.completion_tokens == 13
assert entry.call_count_ok == 2
assert entry.call_count_error == 0
assert entry.status == "ok"
assert entry.last_latency_ms == 200
def test_record_error_sets_error_status_without_losing_prior_tokens():
"""A failed call after successful ones flips status to 'error' but keeps accumulated tokens."""
tracker = ModelUsageTracker()
tracker.record(provider="qwen", model="qwen3.5-flash", success=True, usage={"total_tokens": 50}, latency_ms=50)
tracker.record(provider="qwen", model="qwen3.5-flash", success=False, error="HTTP 500", latency_ms=30)
entry = tracker.get("qwen", "qwen3.5-flash")
assert entry.total_tokens == 50
assert entry.call_count_ok == 1
assert entry.call_count_error == 1
assert entry.status == "error"
assert entry.last_error == "HTTP 500"
def test_record_success_after_error_clears_last_error():
"""A later successful call clears last_error and status returns to 'ok'."""
tracker = ModelUsageTracker()
tracker.record(provider="qwen", model="qwen3.5-flash", success=False, error="timeout", latency_ms=30)
tracker.record(provider="qwen", model="qwen3.5-flash", success=True, usage={"total_tokens": 5}, latency_ms=40)
entry = tracker.get("qwen", "qwen3.5-flash")
assert entry.status == "ok"
assert entry.last_error is None
def test_record_never_raises_on_bad_usage_dict():
"""A malformed usage value (wrong type) is swallowed, not raised, and does not corrupt other entries."""
tracker = ModelUsageTracker()
tracker.record(provider="embedding", model="text-embedding-v3", success=True, usage="not-a-dict", latency_ms=10) # type: ignore[arg-type]
# Must not raise, and must not have created a corrupted entry that breaks snapshot().
snapshot = tracker.snapshot()
assert isinstance(snapshot, dict)
def test_snapshot_returns_independent_copy():
"""snapshot() returns a dict that can be safely mutated without affecting the tracker."""
tracker = ModelUsageTracker()
tracker.record(provider="deepseek", model="deepseek-v4-flash", success=True, usage={"total_tokens": 1}, latency_ms=1)
snap = tracker.snapshot()
snap.clear()
assert tracker.get("deepseek", "deepseek-v4-flash") is not None
def test_get_model_usage_tracker_returns_singleton():
"""get_model_usage_tracker() always returns the same process-wide instance."""
assert get_model_usage_tracker() is get_model_usage_tracker()
@@ -0,0 +1,50 @@
"""Verifies OpenAICompatibleReranker records call outcome (no tokens) into ModelUsageTracker."""
from __future__ import annotations
from unittest.mock import patch
import pytest
from app.domain.retrieval import RetrievedChunk
from app.infrastructure.vectorstore.cross_encoder_reranker import OpenAICompatibleReranker
from app.shared.model_usage_tracker import get_model_usage_tracker
@pytest.fixture(autouse=True)
def _reset_tracker():
"""Clear the process-wide tracker before and after each test in this file."""
get_model_usage_tracker()._entries.clear()
yield
get_model_usage_tracker()._entries.clear()
def _chunk(chunk_id: str, text: str) -> RetrievedChunk:
"""Build a minimal RetrievedChunk for reranker tests."""
return RetrievedChunk(chunk_id=chunk_id, doc_id="doc-1", doc_title="Doc", text=text, score=0.0)
def test_successful_rerank_records_call_without_tokens():
"""A successful rerank() call is recorded with call_count_ok but zero tokens."""
reranker = OpenAICompatibleReranker(base_url="http://example.test", model="bge-reranker-v2-m3")
with patch.object(reranker, "_call_reranker", return_value=[0.9, 0.1]):
result = reranker.rerank("query", [_chunk("c1", "a"), _chunk("c2", "b")], top_k=2)
assert len(result) == 2
entry = get_model_usage_tracker().get("reranker", "bge-reranker-v2-m3")
assert entry is not None
assert entry.call_count_ok == 1
assert entry.total_tokens == 0
def test_failed_rerank_records_error_and_falls_back():
"""A rerank() call that raises internally is recorded as an error but still returns a fallback list."""
reranker = OpenAICompatibleReranker(base_url="http://example.test", model="bge-reranker-v2-m3")
with patch.object(reranker, "_call_reranker", side_effect=RuntimeError("gateway down")):
result = reranker.rerank("query", [_chunk("c1", "a")], top_k=1)
assert len(result) == 1 # existing fallback behavior: original order, unscored
entry = get_model_usage_tracker().get("reranker", "bge-reranker-v2-m3")
assert entry is not None
assert entry.call_count_error == 1
assert entry.status == "error"
@@ -0,0 +1,85 @@
"""Unit tests for TrackedLLMClient — verifies transparent delegation + recording."""
from __future__ import annotations
from unittest.mock import MagicMock
from app.services.llm.base_client import LLMConfig, LLMProvider, LLMResponse
from app.services.llm.tracked_client import TrackedLLMClient
from app.shared.model_usage_tracker import ModelUsageTracker
def _make_inner(model: str = "deepseek-v4-flash") -> MagicMock:
"""Build a MagicMock standing in for a concrete BaseLLMClient subclass."""
# Use MagicMock to avoid requiring a real LLM provider implementation (e.g., DeepseekClient);
# tests focus on TrackedLLMClient's delegation and recording behavior, not provider logic.
inner = MagicMock()
inner.config = LLMConfig(
provider=LLMProvider.DEEPSEEK, model=model, api_key="test-key", base_url="http://example.test/v1",
)
return inner
def test_chat_delegates_and_returns_unchanged_response():
"""chat() must return exactly what the wrapped client returned."""
inner = _make_inner()
expected = LLMResponse(content="hello", model="deepseek-v4-flash", usage={"total_tokens": 12})
inner.chat.return_value = expected
tracker = ModelUsageTracker()
tracked = TrackedLLMClient(inner, tracker)
result = tracked.chat([{"role": "user", "content": "hi"}])
assert result is expected
inner.chat.assert_called_once_with([{"role": "user", "content": "hi"}], None, None, None)
def test_chat_records_success_and_tokens():
"""A successful chat() call must be recorded under 'deepseek:deepseek-v4-flash'."""
inner = _make_inner()
inner.chat.return_value = LLMResponse(content="hi", model="deepseek-v4-flash", usage={"total_tokens": 42})
tracker = ModelUsageTracker()
TrackedLLMClient(inner, tracker).chat([{"role": "user", "content": "hi"}])
entry = tracker.get("deepseek", "deepseek-v4-flash")
assert entry is not None
assert entry.total_tokens == 42
assert entry.status == "ok"
def test_chat_records_error_from_response():
"""A chat() call that returns an error-carrying LLMResponse is recorded as a failure."""
inner = _make_inner()
inner.chat.return_value = LLMResponse(content="", model="deepseek-v4-flash", error="API error: 500")
tracker = ModelUsageTracker()
TrackedLLMClient(inner, tracker).chat([{"role": "user", "content": "hi"}])
entry = tracker.get("deepseek", "deepseek-v4-flash")
assert entry.status == "error"
assert entry.last_error == "API error: 500"
def test_getattr_forwards_to_inner_client():
"""Attributes not defined on TrackedLLMClient must forward to the wrapped client."""
inner = _make_inner()
inner.get_available_models.return_value = ["deepseek-v4-flash"]
tracked = TrackedLLMClient(inner, ModelUsageTracker())
assert tracked.get_available_models() == ["deepseek-v4-flash"]
assert tracked.config is inner.config
def test_stream_chat_records_call_without_token_usage():
"""stream_chat() must record a call (latency/success) but not fabricate token counts."""
inner = _make_inner()
inner.stream_chat.return_value = iter(["chunk-1", "chunk-2"])
tracker = ModelUsageTracker()
chunks = list(TrackedLLMClient(inner, tracker).stream_chat([{"role": "user", "content": "hi"}]))
assert chunks == ["chunk-1", "chunk-2"]
entry = tracker.get("deepseek", "deepseek-v4-flash")
assert entry.call_count_ok == 1
assert entry.total_tokens == 0
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,273 @@
# System Status — Connected AI Models & Token Usage Design
**Date:** 2026-07-02
**Scope:** Extend the existing System Status module with a new "AI Models" panel showing which LLM/Embedding/Reranker models are configured, their connection status, and cumulative token consumption.
**Relationship to existing roadmap:** This is a lightweight, self-contained first slice of the "P0-A observability" priority already identified in `AI_Agent_优化分析报告_2026-06-18.md` (full Langfuse tracing + Ragas evaluation remains a separate, larger future effort — see Out of Scope).
---
## Goals
1. Show all "connected" AI models in one place: main answer-generation LLM, the dedicated HyDE query-expansion LLM, the embedding model, and the reranker (even when disabled).
2. Show connection status per model, derived passively from real traffic (no extra cost), plus an optional manual "test connection" action for an on-demand active check.
3. Show cumulative token consumption per model since process start (in-memory; resets on restart — no new database table).
4. Guarantee accuracy by instrumenting the single shared LLM client factory, so intermediate Agentic RAG steps, HyDE, regulation-perception analysis, compliance review, and document summarization are all captured — not just the final chat answer.
## Non-Goals (see "Out of Scope" at the end)
- Persistent/historical token usage (DB-backed, survives restart) — deferred.
- Cost/spend estimation in currency — deferred (no reliable public pricing for the internal gateway).
- Per-session or per-user token breakdown — deferred.
- Accurate token counting for **streaming** chat responses — deferred (see Known Limitations).
- Full distributed tracing / LLM-as-judge faithfulness scoring (Langfuse + Ragas, `P0-A` in the existing roadmap) — this feature is a lightweight precursor, not a replacement.
---
## Architecture Overview
### Layering (must not be violated — per `docs/architecture/backend-project-architecture.md`)
```
api/routes/status.py → thin handlers, reads tracker + settings, no business logic
shared/model_usage_tracker.py → cross-cutting support (same tier as shared/bootstrap.py)
services/llm/llm_factory.py → wraps clients with TrackedLLMClient at creation time
infrastructure/embedding/… → direct instrumentation (single implementation)
infrastructure/vectorstore/cross_encoder_reranker.py → direct instrumentation (single implementation)
```
No new business orchestration is added to `services/*` or `workflows/*`. The tracker is passive, cross-cutting infrastructure support, consistent with how `shared/bootstrap.py` and `shared/errors.py` are described in the backend README as "composition root 与横切支撑".
### Data Model
`ModelUsageTracker` keys its internal state by **`f"{provider}:{model}"`**, not by business role. This is more robust than keying by role: if a future Agentic sub-step uses a different provider/model, it is still captured under its own key rather than being silently dropped because no role mapping exists for it. "Role" (`main_llm` / `hyde_llm` / `embedding` / `reranker`) is purely a **presentation-layer label**, resolved at read time in the `/status/models` handler by looking up the current `settings` (`llm_provider`/`llm_model`, `hyde_llm_provider`/`hyde_llm_model` with its existing "empty means reuse main" fallback, `embedding_model`, `reranker_model`).
```python
# backend/app/shared/model_usage_tracker.py
@dataclass
class ModelUsageEntry:
"""Represent accumulated usage/connection state for one provider+model pair."""
provider: str
model: str
total_tokens: int = 0
prompt_tokens: int = 0
completion_tokens: int = 0
call_count_ok: int = 0
call_count_error: int = 0
last_called_at: datetime | None = None
last_latency_ms: int | None = None
last_error: str | None = None
@property
def status(self) -> str:
"""Derive display status from call history: never_called | ok | error.
Note: this only reflects the tracker's own history. The route handler
(not this class) overrides the value to "disabled" for the reranker role
when settings.reranker_enabled is False — config always wins over any
stale historical data, e.g. if the reranker was enabled in the past and
later turned off in .env.
"""
if self.last_called_at is None:
return "never_called"
return "error" if self.last_error else "ok"
class ModelUsageTracker:
"""Thread-safe in-memory registry of per-model call/usage stats.
Never raises: a bug here must not break a real user-facing LLM call.
"""
def __init__(self) -> None:
self._entries: dict[str, ModelUsageEntry] = {}
self._lock = threading.Lock()
def record(
self,
*,
provider: str,
model: str,
success: bool,
usage: dict | None = None,
latency_ms: int | None = None,
error: str | None = None,
) -> None:
"""Record the outcome of one call to provider/model. Safe to call from any thread."""
...
def snapshot(self) -> dict[str, ModelUsageEntry]:
"""Return a shallow copy of all tracked entries, safe to iterate without the lock."""
...
@lru_cache
def get_model_usage_tracker() -> ModelUsageTracker:
"""Return the process-wide singleton tracker (mirrors get_settings()/get_llm_factory() pattern)."""
return ModelUsageTracker()
```
All `record()` bodies are wrapped in `try/except Exception: logger.warning(...)` internally — tracking failures are logged and swallowed, never propagated.
### LLM Instrumentation — `TrackedLLMClient` Wrapper
Every LLM call in the codebase goes through `get_llm_client()` in `backend/app/services/llm/llm_factory.py` (confirmed call sites: `agentic_service.py`, `hyde_expander.py`, `perception/services.py`, `perception/llm_pipeline.py`, `api/routes/compliance.py` ×2, `infrastructure/llm/openai_compatible_answer_generator.py` ×2, `services/llm/document_summarizer.py`). `LLMFactory.create()` wraps the concrete client (`DeepSeekClient`/`QwenClient`/`QwenVLClient`) in `TrackedLLMClient` before caching it, so every current and future call site is covered automatically with **one** change point.
```python
# backend/app/services/llm/tracked_client.py
class TrackedLLMClient:
"""Transparent decorator that records usage/latency into ModelUsageTracker.
Deliberately does NOT subclass BaseLLMClient: that ABC declares abstract
methods (_init_client, get_available_models) which would have to be stubbed
out, defeating the point of __getattr__ delegation and instantiation would
fail with "Can't instantiate abstract class" before __getattr__ ever runs.
Plain composition + __getattr__ forwarding is sufficient since callers only
ever use duck-typed access (.chat(), .stream_chat(), .get_available_models(), .close()).
"""
def __init__(self, inner: BaseLLMClient, tracker: ModelUsageTracker) -> None:
self._inner = inner
self._tracker = tracker
def chat(self, messages, max_tokens=None, temperature=None, tools=None, **kwargs) -> LLMResponse:
"""Delegate to the wrapped client's chat(), then record usage/latency/outcome."""
start = time.time()
response = self._inner.chat(messages, max_tokens, temperature, tools, **kwargs)
self._tracker.record(
provider=self._inner.config.provider.value,
model=response.model or self._inner.config.model,
success=response.is_success,
usage=response.usage,
latency_ms=int((time.time() - start) * 1000),
error=response.error,
)
return response
def stream_chat(self, messages, *args, **kwargs):
"""Delegate to stream_chat(); records call success/latency only (no token usage — see Known Limitations)."""
...
def __getattr__(self, name):
"""Forward any other attribute/method access to the wrapped client."""
return getattr(self._inner, name)
```
### Embedding & Reranker Instrumentation
Both have a single concrete implementation today, so they are instrumented directly (no wrapper needed):
- `OpenAICompatibleEmbeddingProvider._request()` — additionally reads `data.get("usage", {})` from the OpenAI-compatible embeddings response and calls `get_model_usage_tracker().record(provider="embedding", model=self.model, ...)`.
- `OpenAICompatibleReranker._call_reranker()` / `rerank()` — records call success/failure + latency only. TEI/Cohere-style rerank responses do not include token usage, so `total_tokens` for the reranker role will always show as unavailable (`—`), which is factually correct, not a bug to fix later.
---
## API
Both endpoints are added to the existing `backend/app/api/routes/status.py` (no new router file), returning plain dicts — matching the existing convention in this file and in `perception.py` (no Pydantic response models for these "reporting" endpoints).
### `GET /status/models`
Passive read: no outbound network calls, just tracker snapshot + settings resolution.
```json
{
"models": [
{
"role": "main_llm",
"role_label": "主问答 LLM",
"provider": "deepseek",
"model": "deepseek-v4-flash",
"enabled": true,
"status": "ok",
"total_tokens": 12345,
"call_count_ok": 42,
"call_count_error": 1,
"last_called_at": "2026-07-02T10:00:00+08:00",
"last_latency_ms": 350,
"last_error": null,
"shares_usage_with": null
}
]
}
```
Always returns exactly 4 entries in a fixed order: `main_llm`, `hyde_llm`, `embedding`, `reranker` — even if a model has never been called (`status: "never_called"`, all counters zero) or is disabled (`reranker.enabled: false` when `settings.reranker_enabled` is `False`). When `hyde_llm_provider`/`hyde_llm_model` are empty (config falls back to the main LLM), `hyde_llm.shares_usage_with` is set to `"main_llm"` and both rows naturally show identical numbers because they resolve to the same tracker key.
`status` precedence (resolved by the route handler, not by `ModelUsageEntry` itself): if the role is disabled by config (`reranker` only, when `reranker_enabled=False`) the handler always reports `"disabled"`, regardless of any historical call data the tracker may still hold from when it was previously enabled. Otherwise it passes through the tracker's own `ok` / `error` / `never_called`.
### `POST /status/models/ping`
Active check, run only for `enabled` models, in parallel (`asyncio.gather` over `run_in_threadpool`, since the underlying clients are synchronous `httpx`):
- `main_llm` / `hyde_llm`: `chat([{"role": "user", "content": "ping"}], max_tokens=1)`
- `embedding`: `embed_query("ping")`
- `reranker`: `rerank("ping", [one placeholder chunk], top_k=1)` — only when `reranker_enabled=True`
Each ping is wrapped independently so one timeout doesn't block the others. Ping calls go through the same instrumented code paths, so they naturally (and honestly) add a small amount to the token counters — this is not hidden or special-cased. Response shape is identical to `GET /status/models`, reflecting the fresh post-ping state.
---
## Frontend
### New Card: "AI Models" in `frontend/src/pages/Status/StatusPage.tsx`
Placed in `panel-left`, directly after the existing "System Health" card (conceptually related — both are live connectivity views).
- Card header: title + a "Test Connection" button (`POST /status/models/ping`, disabled + spinner while in flight).
- Body: 4 rows reusing the existing `StatusIcon` + `service-row` styling, extended with a right-aligned token count column (monospace, `toLocaleString()`, matching `ConfigRow`'s number formatting) and a small last-called relative-time hint.
- `never_called` and `disabled` map to the existing muted/info badge styles already used elsewhere on this page (no new visual language needed).
- **Cleanup**: the existing "Runtime" card (`panel-right`) currently shows a single Reranker enabled/model line — this is removed from that card since the new "AI Models" card now shows it with richer detail (status + tokens), avoiding duplicate information on the page.
### Data & Types
- `frontend/src/api/status.ts`: add `getModelUsage()` (`GET /status/models`) and `pingModelConnections()` (`POST /status/models/ping`).
- `frontend/src/api/index.ts`: add `ModelUsageEntry` / `ModelUsageResponse` types alongside the existing `SystemStats`/`SystemConfig`/`SystemHealth`.
- `StatusPage.tsx`: extend the existing `Promise.allSettled([...])` fetch-on-mount/refresh with a 4th parallel call for model usage, following the same "partial failure doesn't crash the page" pattern already used for stats/health/config.
- i18n: add new keys under the existing `t.status.*` namespace in both `frontend/src/locales/en.ts` and `zh.ts` (card title, role labels, status labels, button label, "shares usage with main LLM" note).
- Desktop-first, no responsive/mobile work, per `AGENTS.md`.
### Files Changed
| File | Action |
|---|---|
| `backend/app/shared/model_usage_tracker.py` | New — `ModelUsageEntry`, `ModelUsageTracker`, `get_model_usage_tracker()` |
| `backend/app/services/llm/tracked_client.py` | New — `TrackedLLMClient` wrapper |
| `backend/app/services/llm/llm_factory.py` | Wrap client with `TrackedLLMClient` in `LLMFactory.create()` before caching |
| `backend/app/infrastructure/embedding/openai_compatible_embedding_provider.py` | Capture `usage` from embeddings response, record to tracker |
| `backend/app/infrastructure/vectorstore/cross_encoder_reranker.py` | Record call success/failure + latency to tracker |
| `backend/app/api/routes/status.py` | Add `GET /status/models`, `POST /status/models/ping` |
| `frontend/src/api/status.ts` | Add `getModelUsage()`, `pingModelConnections()` |
| `frontend/src/api/index.ts` | Add `ModelUsageEntry`/`ModelUsageResponse` types |
| `frontend/src/pages/Status/StatusPage.tsx` | Add "AI Models" card; remove duplicate reranker line from "Runtime" card |
| `frontend/src/locales/en.ts`, `zh.ts` | Add new `status.*` keys |
---
## Error Handling
- Tracker `record()` never raises — internal `try/except Exception: logger.warning(...)`, so a bug in observability code cannot break a real RAG answer, HyDE expansion, or compliance review call.
- `GET /status/models` mirrors the existing per-service try/except pattern already used in `/status/health` — a failure resolving one role's config falls back to a safe "unknown" entry rather than a 500 for the whole endpoint.
- `POST /status/models/ping`: each per-model ping is wrapped individually (`asyncio.gather(..., return_exceptions=True)` or equivalent per-task try/except); one model's timeout/error does not prevent the other three from completing and being reported.
- Frontend: ping failures surface as inline text on that row (existing `service-row` already supports a muted "detail" slot); page-level fetch failures already degrade gracefully via the existing `Promise.allSettled` fallback pattern.
## Testing
Backend (existing `pytest` setup, `backend/tests/`):
- `backend/tests/shared/test_model_usage_tracker.py` (new) — accumulation across multiple `record()` calls, status transitions (`never_called``ok``error`), basic concurrent-write safety.
- Test for `TrackedLLMClient` — verifies it delegates `chat()` faithfully (return value unchanged) while recording usage, and that a wrapped-client exception still propagates correctly.
- `backend/tests/api/test_status_models_routes.py` (new) — `GET /status/models` returns exactly 4 roles with correct defaults when nothing has been called yet (including `reranker.enabled == settings.reranker_enabled`); `POST /status/models/ping` with mocked clients (no real network calls in tests), verifying partial-failure handling.
Frontend: no test framework exists in this repo today (`frontend/package.json` has no test script, no vitest/jest config) — per project convention, this feature does not introduce one. Verification is `npm --prefix frontend run lint` + `npm --prefix frontend run build`, plus manual visual check of the new card.
## Known Limitations
- **Streaming token gap**: `stream_chat()` implementations in `DeepSeekClient`/`QwenClient` currently only yield content deltas and do not parse a trailing `usage` chunk (would require requesting `stream_options: {include_usage: true}` from the gateway and handling the final SSE chunk). This means token counts from streamed chat (the main RAG chat UI's default interaction mode) are **not** captured in this iteration — only call count/latency/success are recorded for streaming calls. Non-streaming calls (HyDE, agentic intent/plan/grounding steps, compliance review, document summarization, perception analysis) are fully captured. This gap is called out explicitly rather than silently under-counting without explanation, and is a natural follow-up.
- In-memory only: counters reset on every backend restart/redeploy; acceptable per explicit product decision in this design (no new DB table).
## Out of Scope (deferred to future iterations)
- Persistent historical token usage (Postgres-backed, time-windowed charts).
- Cost/spend estimation in currency.
- Per-session/per-user attribution.
- Parsing streaming `usage` chunks for exact streaming token counts.
- Full Langfuse distributed tracing + Ragas/LLM-as-Judge faithfulness scoring (existing roadmap `P0-A` remains the larger follow-on effort; this feature's tracker data model is intentionally simple and would need to coexist with, not replace, a future Langfuse integration).
+39
View File
@@ -73,6 +73,22 @@ export interface SSEMessage {
text?: string;
docs?: RetrievedDoc[];
session_id?: string;
// ── P0-1 Agentic-mode thinking-step fields ────────────────────────────────
// Populated when type === 'thinking'; maps to the backend IntentResult /
// GroundingResult / retrieval step payloads emitted by AgenticConversationService.
step?: string; // intent_analysis | query_planning | retrieving | grounding_check
status?: string; // running | done
intent_type?: string; // simple_qa | compare | multi_hop | ambiguous
requires_decomposition?: boolean;
reason?: string;
sub_queries?: string[];
query?: string; // sub-query being retrieved
index?: number; // 1-based sub-query index
total?: number; // total sub-query count
found?: number; // chunks found for this sub-query
retry?: boolean; // true when this is a grounding-failure re-query
sufficient?: boolean; // grounding check result
confidence?: number; // grounding confidence 01
}
export async function streamSSE<TMessage extends SSEMessage>(
@@ -294,4 +310,27 @@ export interface SystemHealth {
sessions: { active: number; max: number };
}
export type ModelRole = 'main_llm' | 'hyde_llm' | 'embedding' | 'reranker';
export type ModelStatus = 'ok' | 'error' | 'never_called' | 'disabled';
export interface ModelUsageEntry {
role: ModelRole;
role_label: string;
provider: string;
model: string;
enabled: boolean;
status: ModelStatus;
total_tokens: number;
call_count_ok: number;
call_count_error: number;
last_called_at: string | null;
last_latency_ms: number | null;
last_error: string | null;
shares_usage_with: ModelRole | null;
}
export interface ModelUsageResponse {
models: ModelUsageEntry[];
}
export { API_BASE_URL };
+91
View File
@@ -76,6 +76,27 @@ function parseSSEChunk(raw: string, onMessage: (data: SSEMessage) => void) {
onMessage({ type: 'error', text: joined });
} else if (eventName === 'status') {
onMessage({ type: 'status', text: joined });
} else if (eventName === 'thinking') {
// P0-1: Agentic reasoning step events from /agent/agentic/stream
try {
const payload = JSON.parse(joined) as Record<string, unknown>;
onMessage({
type: 'thinking',
step: payload.step as string | undefined,
status: payload.status as string | undefined,
intent_type: payload.intent_type as string | undefined,
requires_decomposition: payload.requires_decomposition as boolean | undefined,
reason: payload.reason as string | undefined,
sub_queries: payload.sub_queries as string[] | undefined,
query: payload.query as string | undefined,
index: payload.index as number | undefined,
total: payload.total as number | undefined,
found: payload.found as number | undefined,
retry: payload.retry as boolean | undefined,
sufficient: payload.sufficient as boolean | undefined,
confidence: payload.confidence as number | undefined,
});
} catch { /* ignore */ }
} else if (eventName === 'message') {
// /rag/chat format: event:message + JSON body with type field
try {
@@ -147,3 +168,73 @@ export async function ragChat(
}
export type { QuickQuestionsResponse, SSEMessage };
/**
* P0-1 Agentic RAG chat calls /agent/agentic/stream which runs the full
* intent-analysis query-planning retrieval grounding-check answer pipeline.
*
* The onMessage callback receives the same event types as ragChat plus
* ``type: 'thinking'`` events that carry live reasoning-step progress.
*/
export async function agenticChat(
query: string,
topK: number = 5,
onMessage: (data: SSEMessage) => void,
onError?: (error: Error) => void,
onComplete?: () => void,
filters?: string,
sessionId?: string,
signal?: AbortSignal,
contextText?: string,
contextFilename?: string,
): Promise<void> {
try {
const response = await fetch(`${AGENT_API_BASE}/agent/agentic/stream`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'text/event-stream',
...(getToken() ? { Authorization: `Bearer ${getToken()}` } : {}),
},
body: JSON.stringify({
query,
top_k: topK,
...(filters ? { filters } : {}),
...(sessionId ? { session_id: sessionId } : {}),
...(contextText ? { context_text: contextText, context_filename: contextFilename ?? '' } : {}),
}),
signal,
});
if (!response.ok || !response.body) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const parts = buffer.split('\n\n');
buffer = parts.pop() || '';
parseSSEChunk(parts.join('\n\n'), onMessage);
}
if (buffer.trim()) {
parseSSEChunk(buffer, onMessage);
}
if (onComplete) {
onComplete();
}
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') return;
if (onError) {
onError(error instanceof Error ? error : new Error(String(error)));
}
}
}
+12 -2
View File
@@ -1,4 +1,4 @@
import { fetchAPI, type SystemConfig, type SystemHealth, type SystemStats } from './index';
import { fetchAPI, type ModelUsageResponse, type SystemConfig, type SystemHealth, type SystemStats } from './index';
export async function getSystemStats(): Promise<SystemStats> {
return fetchAPI<SystemStats>('/status/stats');
@@ -12,4 +12,14 @@ export async function getSystemHealth(): Promise<SystemHealth> {
return fetchAPI<SystemHealth>('/status/health');
}
export type { SystemConfig, SystemHealth, SystemStats };
/** Passive read: current connection status + cumulative token usage for all 4 AI model roles. */
export async function getModelUsage(): Promise<ModelUsageResponse> {
return fetchAPI<ModelUsageResponse>('/status/models');
}
/** Active check: sends one minimal request to each enabled model, then returns fresh statuses. */
export async function pingModelConnections(): Promise<ModelUsageResponse> {
return fetchAPI<ModelUsageResponse>('/status/models/ping', { method: 'POST' });
}
export type { ModelUsageResponse, SystemConfig, SystemHealth, SystemStats };
@@ -59,6 +59,8 @@ export interface ComplianceSourceEvent {
score: number;
status: string;
full_content: string;
/** Index of the clause this source was retrieved for (for source↔finding linking) */
clause_index?: number;
}
export interface ComplianceFindingEvent {
@@ -66,6 +68,17 @@ export interface ComplianceFindingEvent {
desc: string;
status: 'ok' | 'warn' | 'risk';
clause_ref?: string;
/** LLM confidence that retrieved context covers the clause topic (01) */
confidence?: number;
/** Top-3 regulation chunks that informed this finding */
source_refs?: Array<{ standard: string; clause: string; score: number }>;
}
export interface ComplianceConflict {
type: 'contradiction' | 'missing_ref' | 'cumulative_risk';
finding_a: number;
finding_b: number | null;
desc: string;
}
export interface ComplianceActionItem {
@@ -103,6 +116,10 @@ export interface ComplianceState {
analysisId: string | null;
isReadOnly: boolean;
activeFindingId: string | null;
/** Real-time per-clause progress {done, total} */
progress: { done: number; total: number } | null;
/** Cross-clause conflicts detected after all findings complete */
conflicts: ComplianceConflict[];
}
const COMPLIANCE_INIT: ComplianceState = {
@@ -117,6 +134,8 @@ const COMPLIANCE_INIT: ComplianceState = {
analysisId: null,
isReadOnly: false,
activeFindingId: null,
progress: null,
conflicts: [],
};
// ── Perception types ──────────────────────────────────────────────────────────
+1
View File
@@ -12,6 +12,7 @@ export type {
ComplianceStatus,
ComplianceSourceEvent,
ComplianceFindingEvent,
ComplianceConflict,
ComplianceDonePayload,
ComplianceMeta,
ComplianceActionItem,
+84
View File
@@ -116,6 +116,7 @@ export interface Translations {
labelChunkBackend: string;
labelParserFailureMode: string;
configLoadError: string;
modelsLoadError: string;
cardBreakdown: string;
breakdownIndexed: string;
breakdownProcessing: string;
@@ -131,6 +132,17 @@ export interface Translations {
footerDegraded: string;
footerChecking: string;
totalChunks: string;
cardModels: string;
testConnectionBtn: string;
testingBtn: string;
roleMainLlm: string;
roleHydeLlm: string;
roleEmbedding: string;
roleReranker: string;
modelStatusNeverCalled: string;
modelStatusDisabled: string;
sharesUsageWithMain: string;
lastCalledNever: string;
};
docs: {
topbarTitle: string;
@@ -226,6 +238,36 @@ export interface Translations {
citationsHeader: string;
citationsEmpty: string;
apiError: string;
// ── Agentic mode ─────────────────────────────────────────────────────────
agenticMode: string;
agenticModeHint: string;
agentThinking: string;
agentDone: string;
stepSuffix: string;
stepIntentAnalysis: string;
stepQueryPlanning: string;
stepRetrieving: string;
stepGrounding: string;
intentSimpleQa: string;
intentCompare: string;
intentMultiHop: string;
intentAmbiguous: string;
intentNeedsDecomposition: string;
subQueriesCountSuffix: string;
chunksFoundSuffix: string;
retryLabel: string;
groundingSufficient: string;
groundingInsufficient: string;
// ── Document attachment in interface ─────────────────────────────────────
attachBtn: string;
attachExtracting: string;
attachReady: string;
attachError: string;
attachClearLabel: string;
attachContextBadge: string;
attachAccept: string;
attachTruncated: string;
attachErrorMsg: string;
};
}
@@ -346,6 +388,7 @@ export const en: Translations = {
labelChunkBackend: 'Chunk backend',
labelParserFailureMode: 'Parser failure mode',
configLoadError: 'Could not load config',
modelsLoadError: 'Could not load model status',
cardBreakdown: 'Document breakdown',
breakdownIndexed: 'Indexed',
breakdownProcessing: 'Processing / Parsed',
@@ -361,6 +404,17 @@ export const en: Translations = {
footerDegraded: 'Degraded',
footerChecking: 'Checking…',
totalChunks: 'Total vector chunks',
cardModels: 'AI Models',
testConnectionBtn: 'Test connection',
testingBtn: 'Testing…',
roleMainLlm: 'Main answer LLM',
roleHydeLlm: 'HyDE query expansion',
roleEmbedding: 'Embedding',
roleReranker: 'Reranker',
modelStatusNeverCalled: 'Not called yet',
modelStatusDisabled: 'Disabled',
sharesUsageWithMain: 'Shares usage with main LLM',
lastCalledNever: 'Never',
},
docs: {
topbarTitle: 'Document Management',
@@ -456,5 +510,35 @@ export const en: Translations = {
citationsHeader: 'Sources',
citationsEmpty: 'Citations will appear here after a response is generated.',
apiError: 'Could not reach the RAG API. Please check the backend.',
// ── Agentic mode ─────────────────────────────────────────────────────────
agenticMode: 'Agentic mode',
agenticModeHint: 'Intent · Planning · Retrieval · Grounding',
agentThinking: 'Agent reasoning…',
agentDone: 'Reasoning complete',
stepSuffix: 'steps',
stepIntentAnalysis: 'Intent analysis',
stepQueryPlanning: 'Query planning',
stepRetrieving: 'Knowledge retrieval',
stepGrounding: 'Citation grounding',
intentSimpleQa: 'Simple Q&A',
intentCompare: 'Comparison',
intentMultiHop: 'Multi-hop',
intentAmbiguous: 'Ambiguous',
intentNeedsDecomposition: 'Decomposed',
subQueriesCountSuffix: 'sub-queries',
chunksFoundSuffix: 'chunks',
retryLabel: '(retry) ',
groundingSufficient: '✓ Sufficient',
groundingInsufficient: '⚠ Re-queried',
// ── Document context attachment ───────────────────────────────────────────
attachBtn: 'Attach document as context',
attachExtracting: 'Extracting text…',
attachReady: 'Context loaded',
attachError: 'Extraction failed',
attachClearLabel: 'Clear',
attachContextBadge: 'Doc context',
attachAccept: '.pdf,.docx,.doc,.txt,.md',
attachTruncated: '(truncated to 8 000 chars)',
attachErrorMsg: 'Could not extract text from this file.',
},
};
+42
View File
@@ -117,6 +117,7 @@ export const zh: Translations = {
labelChunkBackend: '分块后端',
labelParserFailureMode: '解析失败模式',
configLoadError: '无法加载配置',
modelsLoadError: '无法加载模型状态',
cardBreakdown: '文档分布',
breakdownIndexed: '已索引',
breakdownProcessing: '处理中 / 已解析',
@@ -132,6 +133,17 @@ export const zh: Translations = {
footerDegraded: '降级运行',
footerChecking: '检查中…',
totalChunks: '向量分块总数',
cardModels: 'AI 模型',
testConnectionBtn: '测试连接',
testingBtn: '测试中…',
roleMainLlm: '主问答 LLM',
roleHydeLlm: 'HyDE 查询增强',
roleEmbedding: 'Embedding',
roleReranker: 'Reranker',
modelStatusNeverCalled: '尚未调用',
modelStatusDisabled: '已禁用',
sharesUsageWithMain: '与主 LLM 共用统计',
lastCalledNever: '从未',
},
docs: {
topbarTitle: '文档管理',
@@ -227,5 +239,35 @@ export const zh: Translations = {
citationsHeader: '引用来源',
citationsEmpty: '生成回答后,引用来源将显示在此处。',
apiError: '无法连接到 RAG API,请检查后端服务。',
// ── Agentic mode ─────────────────────────────────────────────────────────
agenticMode: 'Agentic 模式',
agenticModeHint: '意图分析 · 查询分解 · 迭代检索 · 引文锚定',
agentThinking: 'Agent 推理中…',
agentDone: '推理完成',
stepSuffix: '步',
stepIntentAnalysis: '意图分析',
stepQueryPlanning: '查询分解',
stepRetrieving: '知识检索',
stepGrounding: '引文锚定',
intentSimpleQa: '单跳问答',
intentCompare: '对比分析',
intentMultiHop: '多跳推理',
intentAmbiguous: '模糊查询',
intentNeedsDecomposition: '需分解',
subQueriesCountSuffix: '个子查询',
chunksFoundSuffix: '条',
retryLabel: '(补充) ',
groundingSufficient: '✓ 充分',
groundingInsufficient: '⚠ 补充检索',
// ── Document context attachment ───────────────────────────────────────────
attachBtn: '上传文档作为对话上下文',
attachExtracting: '正在提取文本…',
attachReady: '上下文已加载',
attachError: '提取失败',
attachClearLabel: '清除',
attachContextBadge: '文档上下文',
attachAccept: '.pdf,.docx,.doc,.txt,.md',
attachTruncated: '(已截断至 8000 字符)',
attachErrorMsg: '无法从该文件提取文本,请检查文件格式。',
},
};
+101 -178
View File
@@ -1,6 +1,6 @@
import { useState, useRef, useEffect } from 'react';
import { useLanguage } from '../../contexts/LanguageContext';
import { Search, Plus, AlertTriangle, Download, MessageSquare, ChevronDown } from 'lucide-react';
import { Search, Plus, Download, MessageSquare, ChevronDown, AlertTriangle } from 'lucide-react';
import { Topbar } from '../../components/layout/Topbar';
import { NewAnalysisModal } from './NewAnalysisModal';
import { useComplianceAnalysis } from './useComplianceAnalysis';
@@ -39,81 +39,8 @@ function formatTs(iso: string) {
} catch { return iso; }
}
// ── Chat state for a single finding ─────────────────────────────────────────
interface ChatMsg { id: number; role: 'user' | 'assistant'; content: string }
function useFindingChat() {
const [open, setOpen] = useState(false);
const [findingIdx, setFindingIdx] = useState<number | null>(null);
const [messages, setMessages] = useState<ChatMsg[]>([]);
const [input, setInput] = useState('');
const [loading, setLoading] = useState(false);
const abortRef = useRef<AbortController | null>(null);
function openFor(idx: number, finding: FindingEvent) {
setFindingIdx(idx);
setOpen(true);
setMessages([{
id: 0,
role: 'assistant',
content: `I'm reviewing finding: **${finding.title}**\n\n${finding.desc}${finding.clause_ref ? `\n\nRef: ${finding.clause_ref}` : ''}\n\nHow can I help?`,
}]);
setInput('');
}
function close() { setOpen(false); abortRef.current?.abort(); }
async function send(segmentContext: string) {
if (!input.trim() || loading) return;
const q = input.trim();
setInput('');
const userMsg: ChatMsg = { id: Date.now(), role: 'user', content: q };
const assistantId = Date.now() + 1;
setMessages(m => [...m, userMsg, { id: assistantId, role: 'assistant', content: '' }]);
setLoading(true);
const ctrl = new AbortController();
abortRef.current = ctrl;
try {
const res = await fetch(`/api/v1/compliance/chat/${findingIdx ?? 0}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...authHeader() },
body: JSON.stringify({ query: q, segment_context: segmentContext }),
signal: ctrl.signal,
});
if (!res.body) { setLoading(false); return; }
const reader = res.body.getReader();
const dec = new TextDecoder();
let buf = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += dec.decode(value, { stream: true });
const blocks = buf.split('\n\n');
buf = blocks.pop() ?? '';
for (const block of blocks) {
const dl = block.split('\n').find(l => l.startsWith('data: '));
if (!dl) continue;
try {
const j = JSON.parse(dl.slice(6));
if (j.type === 'chunk' && j.text) {
setMessages(m => m.map(msg => msg.id === assistantId ? { ...msg, content: msg.content + j.text } : msg));
}
} catch { /* skip */ }
}
}
} catch (e: unknown) {
if (e instanceof Error && e.name === 'AbortError') return;
} finally {
setLoading(false);
}
}
return { open, findingIdx, messages, input, setInput, loading, openFor, close, send };
}
function _FindingChatDrawerWrapper({
/** Wrapper that resolves findingIndex → findingId from the saved analysis, then renders FindingChatDrawer. */
function FindingChatDrawerWrapper({
analysisId,
findingIndex,
finding,
@@ -128,7 +55,7 @@ function _FindingChatDrawerWrapper({
useEffect(() => {
fetch(`/api/v1/compliance/history/${analysisId}`, {
headers: { Authorization: `Bearer ${localStorage.getItem('auth_token') ?? ''}` },
headers: authHeader(),
})
.then(r => r.json())
.then((data: { findings?: Array<{ seq: number; id: string }> }) => {
@@ -153,8 +80,8 @@ export function CompliancePage() {
const [showModal, setShowModal] = useState(false);
const [showExportMenu, setShowExportMenu] = useState(false);
const { state, run, reset } = useComplianceAnalysis();
const chat = useFindingChat();
const [drawerFindingIdx, setDrawerFindingIdx] = useState<number | null>(null);
// drawerFinding holds {index, finding} for the currently-open FindingChatDrawer
const [drawerFinding, setDrawerFinding] = useState<{ idx: number; finding: FindingEvent } | null>(null);
const { setComplianceState } = usePageState();
const { t } = useLanguage();
@@ -198,6 +125,8 @@ export function CompliancePage() {
analysisId: data.id,
isReadOnly: true,
activeFindingId: null,
progress: null,
conflicts: [],
});
}
@@ -258,12 +187,6 @@ export function CompliancePage() {
setShowExportMenu(false);
}
// ── Chat context (finding desc + clause_ref as segment context) ──────────
const activeFinding = chat.findingIdx !== null ? state.findings[chat.findingIdx] : null;
const chatContext = activeFinding
? `Finding: ${activeFinding.title}\n${activeFinding.desc}${activeFinding.clause_ref ? `\nRef: ${activeFinding.clause_ref}` : ''}`
: '';
return (
<div className="compliance-page" style={{ position: 'relative' }}>
<Topbar
@@ -457,6 +380,25 @@ export function CompliancePage() {
<div className="comp-col findings-col">
<div className="col-header">
Findings {state.findings.length > 0 && `(${state.findings.length})`}
{/* Real per-clause progress bar during streaming */}
{isStreaming && state.progress && state.progress.total > 0 && (
<span style={{
marginLeft: 8, fontSize: 10, color: 'var(--muted)',
display: 'inline-flex', alignItems: 'center', gap: 6,
}}>
<span style={{
display: 'inline-block', width: 60, height: 4,
background: 'var(--border)', borderRadius: 2, overflow: 'hidden',
}}>
<span style={{
display: 'block', height: '100%',
width: `${Math.round((state.progress.done / state.progress.total) * 100)}%`,
background: 'var(--accent)', transition: 'width 0.3s ease',
}} />
</span>
{state.progress.done}/{state.progress.total}
</span>
)}
</div>
{state.findings.length === 0 && isStreaming && (
@@ -472,30 +414,85 @@ export function CompliancePage() {
<span className={`status ${f.status}`}>{STATUS_LABEL[f.status] ?? f.status}</span>
</div>
<p className="finding-desc">{f.desc}</p>
{/* Source refs: which retrieved chunks informed this finding */}
{f.source_refs && f.source_refs.length > 0 && (
<div style={{ marginTop: 4, display: 'flex', flexWrap: 'wrap', gap: 4 }}>
{f.source_refs.map((sr, si) => (
<span key={si} style={{
fontSize: 10, padding: '1px 6px',
background: 'var(--bg)', border: '1px solid var(--border)',
borderRadius: 4, color: 'var(--muted)',
}} title={sr.clause}>
📄 {sr.standard ? sr.standard.slice(0, 20) : '—'}
{sr.score > 0 && ` · ${Math.round(sr.score * 100)}%`}
</span>
))}
</div>
)}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginTop: 6 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
{f.clause_ref && (
<div style={{ fontSize: 11, color: 'var(--muted)' }}>Ref: {f.clause_ref}</div>
)}
{/* Confidence dot: green ≥0.7, amber 0.40.7, red <0.4 */}
{f.confidence !== undefined && (
<span
style={{
fontSize: 10, color: 'var(--muted)',
display: 'inline-flex', alignItems: 'center', gap: 3,
}}
title={`Retrieval confidence: ${Math.round(f.confidence * 100)}%`}
>
<span style={{
width: 6, height: 6, borderRadius: '50%',
background: f.confidence >= 0.7 ? '#22c55e' : f.confidence >= 0.4 ? '#f59e0b' : '#ef4444',
}} />
{Math.round(f.confidence * 100)}%
</span>
)}
</div>
{/* Single consolidated chat button — only when analysis is saved */}
{state.analysisId ? (
<button
className="btn sm"
style={{ marginLeft: 'auto', fontSize: 11, padding: '3px 8px', gap: 4 }}
onClick={() => chat.openFor(i, f)}
onClick={() => setDrawerFinding({ idx: i, finding: f })}
>
<MessageSquare size={11} />{t.compliance.askAIBtn}
</button>
{state.analysisId && (
<button
className="btn sm"
onClick={() => setDrawerFindingIdx(i)}
style={{ marginTop: 6 }}
>
💬 {t.compliance.chatBtn}
<MessageSquare size={11} />{t.compliance.chatBtn}
</button>
) : (
/* Fallback for unsaved analyses: show disabled chat hint */
<span style={{ marginLeft: 'auto', fontSize: 10, color: 'var(--muted)' }}>
{t.compliance.askAIBtn}
</span>
)}
</div>
</div>
))}
{/* Cross-clause conflicts panel */}
{state.conflicts && state.conflicts.length > 0 && (
<div className="card" style={{ borderLeft: '3px solid #f59e0b', marginTop: 8 }}>
<div className="card-header" style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<AlertTriangle size={12} color="#f59e0b" />
<span style={{ fontSize: 12, fontWeight: 600 }}>Cross-Clause Issues ({state.conflicts.length})</span>
</div>
{state.conflicts.map((c, ci) => (
<div key={ci} style={{ fontSize: 11, color: 'var(--muted)', padding: '4px 0', borderTop: ci ? '1px solid var(--border)' : 'none' }}>
<span style={{
fontWeight: 600,
color: c.type === 'contradiction' ? '#ef4444' : c.type === 'cumulative_risk' ? '#f59e0b' : 'var(--fg)',
}}>
[{c.type.replace('_', ' ')}]
</span>
{' '}Finding #{c.finding_a}{c.finding_b ? ` ↔ #${c.finding_b}` : ''}: {c.desc}
</div>
))}
</div>
)}
{/* Conclusion */}
{isDone && state.done && (
<div className="card conclusion-box">
@@ -540,92 +537,18 @@ export function CompliancePage() {
</div>
</div>
{/* ── Finding Chat Side Panel ────────────────────────────────── */}
{chat.open && (
<div style={{
position: 'fixed', right: 0, top: 0, bottom: 0, width: 400,
background: 'var(--surface)', borderLeft: '1px solid var(--border)',
display: 'flex', flexDirection: 'column', zIndex: 200,
boxShadow: '-8px 0 32px rgba(0,0,0,.12)',
}}>
{/* Header */}
<div style={{ padding: '16px 20px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<div style={{ fontSize: 13, fontWeight: 600 }}>{t.compliance.chatSidebarHeader}</div>
<div style={{ fontSize: 11, color: 'var(--muted)', marginTop: 2 }}>
Finding #{(chat.findingIdx ?? 0) + 1} · {activeFinding?.title}
</div>
</div>
<button
onClick={chat.close}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--muted)', padding: 4 }}
></button>
</div>
{/* Messages */}
<div style={{ flex: 1, overflowY: 'auto', padding: '16px 20px', display: 'flex', flexDirection: 'column', gap: 12 }}>
{chat.messages.map(msg => (
<div key={msg.id} style={{ display: 'flex', gap: 10, flexDirection: msg.role === 'user' ? 'row-reverse' : 'row' }}>
{msg.role === 'assistant' && (
<div style={{ width: 28, height: 28, borderRadius: 8, background: 'var(--accent)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, fontSize: 11, color: '#fff', fontWeight: 700 }}>AI</div>
)}
<div style={{
maxWidth: '82%', padding: '10px 14px', borderRadius: 10, fontSize: 13, lineHeight: 1.6, whiteSpace: 'pre-wrap',
background: msg.role === 'user' ? 'var(--accent)' : 'var(--bg)',
color: msg.role === 'user' ? '#fff' : 'var(--fg)',
border: msg.role === 'assistant' ? '1px solid var(--border)' : 'none',
}}>{msg.content}</div>
</div>
))}
{chat.loading && (
<div style={{ display: 'flex', gap: 10 }}>
<div style={{ width: 28, height: 28, borderRadius: 8, background: 'var(--accent)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, fontSize: 11, color: '#fff', fontWeight: 700 }}>AI</div>
<div style={{ padding: '10px 14px', borderRadius: 10, border: '1px solid var(--border)', background: 'var(--bg)', fontSize: 13, color: 'var(--muted)' }}>
{t.compliance.chatThinking}
</div>
</div>
)}
</div>
{/* Quick questions */}
<div style={{ padding: '8px 20px', display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{[t.compliance.quickQ1, t.compliance.quickQ2, t.compliance.quickQ3].map(q => (
<button key={q} onClick={() => chat.setInput(q)}
style={{ padding: '4px 10px', fontSize: 11, background: 'var(--bg)', border: '1px solid var(--border)', borderRadius: 6, cursor: 'pointer', color: 'var(--muted)' }}>
{q}
</button>
))}
</div>
{/* Input */}
<div style={{ padding: '12px 20px', borderTop: '1px solid var(--border)', display: 'flex', gap: 8 }}>
<input
value={chat.input}
onChange={e => chat.setInput(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); chat.send(chatContext); } }}
placeholder={t.compliance.chatPlaceholder}
style={{ flex: 1, padding: '9px 12px', fontSize: 13, background: 'var(--bg)', border: '1px solid var(--border)', borderRadius: 8, color: 'var(--fg)', outline: 'none' }}
/>
<button
className="btn primary"
onClick={() => chat.send(chatContext)}
disabled={!chat.input.trim() || chat.loading}
style={{ padding: '9px 14px' }}
>{t.compliance.sendBtn}</button>
</div>
</div>
)}
{drawerFindingIdx !== null && state.analysisId && (
<_FindingChatDrawerWrapper
{/* ── Finding Chat Drawer (single consolidated UI) ───────────── */}
{drawerFinding !== null && state.analysisId && (
<FindingChatDrawerWrapper
analysisId={state.analysisId}
findingIndex={drawerFindingIdx}
findingIndex={drawerFinding.idx}
finding={{
title: state.findings[drawerFindingIdx]?.title ?? '',
desc: state.findings[drawerFindingIdx]?.desc ?? '',
status: state.findings[drawerFindingIdx]?.status ?? 'ok',
clause_ref: state.findings[drawerFindingIdx]?.clause_ref,
title: drawerFinding.finding.title,
desc: drawerFinding.finding.desc,
status: drawerFinding.finding.status,
clause_ref: drawerFinding.finding.clause_ref,
}}
onClose={() => setDrawerFindingIdx(null)}
onClose={() => setDrawerFinding(null)}
/>
)}
</>
@@ -14,9 +14,10 @@ import type {
ComplianceSourceEvent,
ComplianceFindingEvent,
ComplianceDonePayload,
ComplianceConflict,
} from '../../contexts';
export type { ComplianceMeta, ComplianceState, ComplianceSourceEvent as SourceEvent, ComplianceFindingEvent as FindingEvent, ComplianceDonePayload as DonePayload };
export type { ComplianceMeta, ComplianceState, ComplianceSourceEvent as SourceEvent, ComplianceFindingEvent as FindingEvent, ComplianceDonePayload as DonePayload, ComplianceConflict };
export type { ComplianceActionItem as ActionItem } from '../../contexts';
export type AnalysisStatus = import('../../contexts').ComplianceStatus;
export type AnalysisMeta = ComplianceMeta;
@@ -38,6 +39,8 @@ const INITIAL_STATE: ComplianceState = {
errorText: '',
analysisId: null,
isReadOnly: false,
progress: null,
conflicts: [],
};
export function useComplianceAnalysis() {
@@ -92,6 +95,9 @@ export function useComplianceAnalysis() {
if (j.type === 'stage') {
setState(s => ({ ...s, stageLabel: j.label ?? '', stageKey: j.stage ?? '' }));
} else if (j.type === 'progress') {
// Real per-clause progress update from backend
setState(s => ({ ...s, progress: { done: j.done ?? 0, total: j.total ?? 0 } }));
} else if (j.type === 'source') {
const src: ComplianceSourceEvent = {
standard: j.standard ?? '',
@@ -99,6 +105,7 @@ export function useComplianceAnalysis() {
score: j.score ?? 0,
status: j.status ?? 'retrieved',
full_content: j.full_content ?? '',
clause_index: j.clause_index,
};
setState(s => ({ ...s, sources: [...s.sources, src] }));
} else if (j.type === 'finding') {
@@ -107,8 +114,13 @@ export function useComplianceAnalysis() {
desc: j.desc ?? '',
status: j.status ?? 'info',
clause_ref: j.clause_ref,
confidence: j.confidence,
source_refs: j.source_refs,
};
setState(s => ({ ...s, findings: [...s.findings, finding] }));
} else if (j.type === 'conflicts') {
// Cross-clause conflicts detected after all findings finish
setState(s => ({ ...s, conflicts: j.items ?? [] }));
} else if (j.type === 'done') {
const payload: ComplianceDonePayload = {
conclusion: j.conclusion ?? '',
+18 -4
View File
@@ -20,6 +20,7 @@ interface Doc {
sizeBytes: number;
summary?: string;
version?: string;
hasFile: boolean;
}
const STATUS_FILTERS = ['All', 'Ready', 'Processing', 'Failed', 'Pending'];
@@ -102,6 +103,7 @@ export function DocsPage() {
sizeBytes: (item.size_bytes as number) ?? 0,
summary: item.summary as string | undefined,
version: item.version as string | undefined,
hasFile: item.has_file !== false,
})));
setLoading(false);
})
@@ -130,11 +132,21 @@ export function DocsPage() {
}
// ── Download ─────────────────────────────────────────────────────────────
function downloadDoc(id: string, name: string) {
async function downloadDoc(id: string, name: string) {
try {
const resp = await fetch(`/api/v1/documents/download/${id}`, { headers: authHeader() });
if (!resp.ok) throw new Error(`下载失败: ${resp.status}`);
const blob = await resp.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = `/api/v1/documents/download/${id}`;
a.href = url;
a.download = name;
a.click();
URL.revokeObjectURL(url);
} catch (err) {
console.error('Download failed', err);
alert(String(err));
}
}
// ── Retry (re-process failed doc) ────────────────────────────────────────
@@ -289,11 +301,13 @@ export function DocsPage() {
<span className="cell-mono">{formatSize(d.sizeBytes)}</span>
<span className="cell-muted">{d.type}</span>
<span className="row-actions">
{/* Download */}
{/* Download — disabled for Milvus-only docs that have no binary file */}
<button
className="text-link"
title={t.docs.titleDownload}
title={d.hasFile ? t.docs.titleDownload : '无原始文件'}
onClick={() => downloadDoc(d.id, d.name)}
disabled={!d.hasFile}
style={!d.hasFile ? { opacity: 0.3, cursor: 'not-allowed' } : undefined}
>
<Download size={12} />
</button>
+2 -2
View File
@@ -222,7 +222,7 @@ export function UploadModal({ onClose, onComplete }: Props) {
<button className="modal-close" onClick={onClose} aria-label="Close" disabled={submitting}><X size={14} /></button>
{/* ── Left panel: upload form ── */}
<div className="modal-panel">
<div className="modal-panel" style={{ overflowY: 'auto' }}>
<div className="modal-eyebrow">Upload documents</div>
<div className="modal-title">Stage files for parsing and indexing.</div>
<p className="modal-lead">PDF, DOCX, TXT one per API call, processed sequentially.</p>
@@ -254,7 +254,7 @@ export function UploadModal({ onClose, onComplete }: Props) {
</div>
{files.length > 0 && (
<div className="staged-files">
<div className="staged-files" style={{ maxHeight: 220, overflowY: 'auto', overflowX: 'hidden' }}>
{files.map((f, i) => {
const isDone = doneCount > i;
const isActive = submitting && currentFileIdx === i;
+460 -7
View File
@@ -1,9 +1,11 @@
import { useRef, useEffect, useCallback, useState } from 'react';
import { Topbar } from '../../components/layout/Topbar';
import { Send, Download } from 'lucide-react';
import { Send, Download, Zap, Paperclip, X, FileText, AlertCircle } from 'lucide-react';
import { usePageState } from '../../contexts';
import type { RagCitation } from '../../contexts';
import { useLanguage } from '../../contexts/LanguageContext';
import { agenticChat } from '../../api/rag';
import type { SSEMessage } from '../../api/index';
const TOKEN_KEY = 'auth_token';
function authHeader(): Record<string, string> {
@@ -11,6 +13,46 @@ function authHeader(): Record<string, string> {
return t ? { Authorization: `Bearer ${t}` } : {};
}
// ── Document context state ─────────────────────────────────────────────────────
interface DocContext {
filename: string;
text: string;
charCount: number;
truncated: boolean;
/** 'extracting' while the backend is parsing; 'ready' when text is available; 'error' on failure */
status: 'extracting' | 'ready' | 'error';
errorMsg?: string;
}
// ── Agentic-mode types ────────────────────────────────────────────────────────
interface ThinkingStep {
id: string;
step: string;
status: 'running' | 'done';
intent_type?: string;
reason?: string;
requires_decomposition?: boolean;
sub_queries?: string[];
query?: string;
index?: number;
total?: number;
found?: number;
sufficient?: boolean;
confidence?: number;
retry?: boolean;
}
const STEP_ICONS: Record<string, string> = {
intent_analysis: '🔍',
query_planning: '📋',
retrieving: '📚',
grounding_check: '🔗',
};
// ── Helpers ───────────────────────────────────────────────────────────────────
// Map a raw source doc from the backend "retrieved" event to our Citation shape.
function mapSource(s: Record<string, unknown>, idx: number): RagCitation {
const rawScore = typeof s.score === 'number' ? s.score : 0;
@@ -69,10 +111,72 @@ export function RagChatPage() {
const [streaming, setStreaming] = useState(ragStreamingRef.current);
const [quickPrompts, setQuickPrompts] = useState<string[]>(MOCK_QUICK);
// P0-1 Agentic mode state
const [agenticMode, setAgenticMode] = useState(false);
const [thinkingSteps, setThinkingSteps] = useState<ThinkingStep[]>([]);
const [thinkingExpanded, setThinkingExpanded] = useState(true);
// ── Document context state ─────────────────────────────────────────────────
// Holds the extracted text from the attached file; sent to the backend as
// conversation context on every message while it is set.
const [docContext, setDocContext] = useState<DocContext | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const bottomRef = useRef<HTMLDivElement>(null);
const citRailRef = useRef<HTMLDivElement>(null);
const citItemRefs = useRef<Record<number, HTMLDivElement | null>>({});
// ── Document context helpers ───────────────────────────────────────────────
/** Upload file to /rag/upload-context, extract its text, store as context. */
async function handleFileAttach(file: File) {
setDocContext({ filename: file.name, text: '', charCount: 0, truncated: false, status: 'extracting' });
const fd = new FormData();
fd.append('file', file);
try {
const res = await fetch('/api/v1/rag/upload-context', {
method: 'POST',
headers: authHeader(),
body: fd,
});
if (!res.ok) {
const errText = await res.text().catch(() => t.ragchat.attachErrorMsg);
setDocContext(prev => prev ? { ...prev, status: 'error', errorMsg: errText.slice(0, 120) } : null);
return;
}
const data = await res.json();
setDocContext({
filename: data.filename ?? file.name,
text: data.text ?? '',
charCount: data.char_count ?? 0,
truncated: data.truncated ?? false,
status: 'ready',
});
} catch (err) {
setDocContext(prev => prev
? { ...prev, status: 'error', errorMsg: String(err).slice(0, 120) }
: null
);
}
}
function handleFileInputChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (file) void handleFileAttach(file);
// Reset so the same file can be re-selected
e.target.value = '';
}
function handleFileDrop(e: React.DragEvent<HTMLDivElement>) {
e.preventDefault();
const file = Array.from(e.dataTransfer.files).find(f =>
/\.(pdf|docx?|txt|md)$/i.test(f.name)
);
if (file) void handleFileAttach(file);
}
// Fetch quick questions from backend on mount (only once per session)
useEffect(() => {
fetch('/api/v1/rag/quick-questions', { headers: authHeader() })
@@ -102,9 +206,17 @@ export function RagChatPage() {
async function send(text?: string) {
const q = (text ?? inputDraft).trim();
if (!q || ragStreamingRef.current) return;
// Block send while a document is still being extracted
if (!q || ragStreamingRef.current || docContext?.status === 'extracting') return;
setRagState(s => ({ ...s, inputDraft: '' }));
// Show document context badge in user message bubble when active
const docPrefix = docContext?.status === 'ready'
? `📄 ${docContext.filename}\n`
: '';
const displayQuery = docPrefix + q;
const userMsgId = Date.now().toString();
const assistantId = (Date.now() + 1).toString();
@@ -112,7 +224,7 @@ export function RagChatPage() {
...s,
messages: [
...s.messages,
{ id: userMsgId, role: 'user', text: q },
{ id: userMsgId, role: 'user', text: displayQuery },
{ id: assistantId, role: 'assistant', text: '' },
],
citations: [],
@@ -122,12 +234,122 @@ export function RagChatPage() {
setStreaming(true);
setHighlightedCit(null);
// P0-1: reset thinking panel for new query
if (agenticMode) {
setThinkingSteps([]);
setThinkingExpanded(true);
}
const ctrl = new AbortController();
ragAbortRef.current = ctrl;
if (agenticMode) {
// ── Agentic path ────────────────────────────────────────────────────
const newCitations: RagCitation[] = [];
const handleMessage = (msg: SSEMessage) => {
if (msg.type === 'session') {
if (msg.session_id) setRagState(s => ({ ...s, sessionId: msg.session_id! }));
} else if (msg.type === 'thinking') {
// Build a stable step id so we can upsert running→done transitions.
const stepId = `${msg.step}-${msg.retry ? 'retry' : (msg.index ?? 0)}`;
setThinkingSteps(prev => {
const idx = prev.findIndex(s => s.id === stepId);
const stepObj: ThinkingStep = {
id: stepId,
step: msg.step ?? '',
status: (msg.status as 'running' | 'done') ?? 'running',
intent_type: msg.intent_type,
reason: msg.reason,
sub_queries: msg.sub_queries,
query: msg.query,
index: msg.index,
total: msg.total,
found: msg.found,
sufficient: msg.sufficient,
confidence: msg.confidence,
retry: msg.retry,
};
if (idx >= 0) {
const updated = [...prev];
updated[idx] = stepObj;
return updated;
}
return [...prev, stepObj];
});
} else if (msg.type === 'retrieved' && Array.isArray(msg.docs)) {
const mapped = (msg.docs as unknown as Record<string, unknown>[]).map((d, i) => mapSource(d, i + 1));
newCitations.push(...mapped);
setRagState(s => ({ ...s, citations: [...mapped] }));
} else if (msg.type === 'chunk' && msg.text) {
setRagState(s => ({
...s,
messages: s.messages.map(m =>
m.id === assistantId ? { ...m, text: m.text + msg.text! } : m
),
}));
} else if (msg.type === 'done') {
setThinkingExpanded(false);
setRagState(s => ({
...s,
messages: s.messages.map(m => {
if (m.id !== assistantId) return m;
const refs = [...new Set(
[...m.text.matchAll(/\[(\d+)\]/g)].map(r => parseInt(r[1], 10))
)].filter(n => n >= 1 && n <= newCitations.length);
return { ...m, citationRefs: refs };
}),
}));
} else if (msg.type === 'error') {
setRagState(s => ({
...s,
messages: s.messages.map(m =>
m.id === assistantId ? { ...m, text: `Error: ${msg.text ?? 'Unknown error'}` } : m
),
}));
}
};
try {
await agenticChat(
q, 5, handleMessage,
(err) => {
setRagState(s => ({
...s,
messages: s.messages.map(m =>
m.id === assistantId ? { ...m, text: t.ragchat.apiError } : m
),
}));
console.error('agenticChat error:', err);
},
undefined,
undefined,
sessionId ?? undefined,
ctrl.signal,
// Pass document context to agentic pipeline
docContext?.status === 'ready' ? docContext.text : undefined,
docContext?.status === 'ready' ? docContext.filename : undefined,
);
} finally {
ragStreamingRef.current = false;
setStreaming(false);
}
} else {
// ── Standard RAG path (unchanged) ───────────────────────────────────
try {
const body: Record<string, unknown> = { query: q, top_k: 5 };
if (sessionId) body.session_id = sessionId;
// Inject document text as conversation context when a file is attached
if (docContext?.status === 'ready') {
body.context_text = docContext.text;
body.context_filename = docContext.filename;
}
const res = await fetch('/api/v1/rag/chat', {
method: 'POST',
@@ -218,6 +440,7 @@ export function RagChatPage() {
setStreaming(false);
}
}
}
const lastAssistantId = [...messages].reverse().find(m => m.role === 'assistant')?.id;
@@ -254,7 +477,126 @@ export function RagChatPage() {
{/* ── Chat main ── */}
<div className="chat-main">
<div className="messages">
{/* P0-1: Agentic Thinking Panel — shown when agentic mode is active */}
{agenticMode && thinkingSteps.length > 0 && (
<div style={{
margin: '0 0 4px 0',
border: '1px solid var(--border)',
borderRadius: 8,
background: streaming ? 'var(--surface)' : 'var(--surface-2, var(--surface))',
overflow: 'hidden',
transition: 'max-height 0.4s ease',
flexShrink: 0,
}}>
{/* Panel header — clickable to collapse/expand */}
<button
onClick={() => setThinkingExpanded(x => !x)}
style={{
width: '100%',
display: 'flex',
alignItems: 'center',
gap: 6,
padding: '6px 12px',
background: 'none',
border: 'none',
cursor: 'pointer',
fontSize: 12,
color: streaming ? 'var(--accent, #6366f1)' : 'var(--success-fg, #16a34a)',
textAlign: 'left',
}}
>
<span>{streaming ? '⚙' : '✓'}</span>
<span style={{ fontWeight: 600 }}>
{streaming
? t.ragchat.agentThinking
: `${t.ragchat.agentDone} · ${thinkingSteps.filter(s => s.status === 'done').length} ${t.ragchat.stepSuffix}`
}
</span>
<span style={{ marginLeft: 'auto', fontSize: 10 }}>{thinkingExpanded ? '▲' : '▼'}</span>
</button>
{/* Step list */}
{thinkingExpanded && (
<div style={{ padding: '0 12px 8px' }}>
{thinkingSteps.map(step => {
const stepLabels: Record<string, string> = {
intent_analysis: t.ragchat.stepIntentAnalysis,
query_planning: t.ragchat.stepQueryPlanning,
retrieving: t.ragchat.stepRetrieving,
grounding_check: t.ragchat.stepGrounding,
};
const intentLabels: Record<string, string> = {
simple_qa: t.ragchat.intentSimpleQa,
compare: t.ragchat.intentCompare,
multi_hop: t.ragchat.intentMultiHop,
ambiguous: t.ragchat.intentAmbiguous,
};
return (
<div key={step.id} style={{
display: 'flex',
alignItems: 'flex-start',
gap: 6,
fontSize: 12,
padding: '3px 0',
color: step.status === 'done' ? 'var(--fg)' : 'var(--muted)',
}}>
<span style={{ width: 16, textAlign: 'center', flexShrink: 0 }}>
{step.status === 'running'
? <span style={{ animation: 'spin 1s linear infinite', display: 'inline-block' }}></span>
: (STEP_ICONS[step.step] ?? '·')
}
</span>
<span>
<strong>{stepLabels[step.step] ?? step.step}</strong>
{/* Intent analysis detail */}
{step.step === 'intent_analysis' && step.status === 'done' && step.intent_type && (
<span style={{ marginLeft: 6, color: 'var(--muted)' }}>
{intentLabels[step.intent_type] ?? step.intent_type}
{step.requires_decomposition && ` · ${t.ragchat.intentNeedsDecomposition}`}
</span>
)}
{/* Query planning detail */}
{step.step === 'query_planning' && step.status === 'done' && step.sub_queries && (
<span style={{ marginLeft: 6, color: 'var(--muted)' }}>
{step.sub_queries.length} {t.ragchat.subQueriesCountSuffix}
</span>
)}
{/* Retrieval detail */}
{step.step === 'retrieving' && (
<span style={{ marginLeft: 6, color: 'var(--muted)', wordBreak: 'break-all' }}>
{step.total && step.total > 1 && `[${step.index}/${step.total}] `}
{step.retry && t.ragchat.retryLabel}
{step.query && step.query.length > 50
? step.query.slice(0, 50) + '…'
: step.query}
{step.status === 'done' && step.found !== undefined && (
<span style={{ color: step.found > 0 ? 'var(--success-fg, #16a34a)' : 'var(--warning, #ca8a04)' }}>
{' '}· {step.found} {t.ragchat.chunksFoundSuffix}
</span>
)}
</span>
)}
{/* Grounding check detail */}
{step.step === 'grounding_check' && step.status === 'done' && (
<span style={{ marginLeft: 6, color: step.sufficient ? 'var(--success-fg, #16a34a)' : 'var(--warning, #ca8a04)' }}>
{step.sufficient ? t.ragchat.groundingSufficient : t.ragchat.groundingInsufficient}
{step.confidence !== undefined && ` (${Math.round(step.confidence * 100)}%)`}
</span>
)}
</span>
</div>
);
})}
</div>
)}
</div>
)}
{/* Messages area — accepts drag-and-drop document context attachment */}
<div
className="messages"
onDragOver={e => { e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; }}
onDrop={handleFileDrop}
>
{messages.map(msg => (
<div key={msg.id} className={`message msg-${msg.role}`}>
{msg.role === 'assistant' && <div className="msg-avatar">AI</div>}
@@ -281,19 +623,130 @@ export function RagChatPage() {
</button>
))}
</div>
{/* P0-1: Agentic mode toggle */}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
<label style={{
display: 'flex', alignItems: 'center', gap: 5,
fontSize: 12, color: agenticMode ? 'var(--accent, #6366f1)' : 'var(--muted)',
cursor: 'pointer', userSelect: 'none',
}}>
<input
type="checkbox"
checked={agenticMode}
onChange={e => {
setAgenticMode(e.target.checked);
setThinkingSteps([]);
}}
style={{ cursor: 'pointer', accentColor: 'var(--accent, #6366f1)' }}
/>
<Zap size={11} />
<span>{t.ragchat.agenticMode}</span>
</label>
{agenticMode && (
<span style={{ fontSize: 11, color: 'var(--muted)' }}>
{t.ragchat.agenticModeHint}
</span>
)}
</div>
{/* ── Document context badge ── */}
{docContext && (
<div style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '6px 10px', marginBottom: 6,
background: docContext.status === 'error'
? 'rgba(220,38,38,0.06)'
: docContext.status === 'ready'
? 'rgba(34,197,94,0.06)'
: 'rgba(99,102,241,0.06)',
border: `1px solid ${
docContext.status === 'error' ? 'rgba(220,38,38,0.3)'
: docContext.status === 'ready' ? 'rgba(34,197,94,0.3)'
: 'rgba(99,102,241,0.3)'
}`,
borderRadius: 8, fontSize: 12,
}}>
{docContext.status === 'extracting' && (
<span style={{ animation: 'spin 1s linear infinite', display: 'inline-block', color: 'var(--accent,#6366f1)' }}></span>
)}
{docContext.status === 'ready' && <FileText size={13} color="#16a34a" />}
{docContext.status === 'error' && <AlertCircle size={13} color="#dc2626" />}
<span style={{
fontWeight: 600, fontSize: 11,
color: docContext.status === 'error' ? '#dc2626'
: docContext.status === 'ready' ? '#16a34a'
: 'var(--accent,#6366f1)',
flexShrink: 0,
}}>
{t.ragchat.attachContextBadge}
</span>
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', color: 'var(--fg)' }}
title={docContext.filename}>
{docContext.filename}
</span>
{docContext.status === 'ready' && (
<span style={{ fontSize: 10, color: 'var(--muted)', flexShrink: 0 }}>
{(docContext.charCount / 1000).toFixed(1)}k chars
{docContext.truncated ? ` · ${t.ragchat.attachTruncated}` : ''}
</span>
)}
{docContext.status === 'extracting' && (
<span style={{ fontSize: 11, color: 'var(--accent,#6366f1)', flexShrink: 0 }}>
{t.ragchat.attachExtracting}
</span>
)}
{docContext.status === 'error' && (
<span style={{ fontSize: 11, color: '#dc2626', flexShrink: 0 }} title={docContext.errorMsg}>
{t.ragchat.attachError}
</span>
)}
{/* Clear button */}
<button
onClick={() => setDocContext(null)}
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '2px 4px', color: 'var(--muted)', display: 'flex', alignItems: 'center', gap: 2, fontSize: 11, flexShrink: 0 }}
title={t.ragchat.attachClearLabel}
>
<X size={11} /> {t.ragchat.attachClearLabel}
</button>
</div>
)}
{/* Hidden file input */}
<input
ref={fileInputRef}
type="file"
accept={t.ragchat.attachAccept}
style={{ display: 'none' }}
onChange={handleFileInputChange}
/>
<div className="composer-row">
{/* Paperclip button — replaces attached doc when clicked again */}
<button
className="btn icon-btn"
onClick={() => fileInputRef.current?.click()}
disabled={streaming || docContext?.status === 'extracting'}
title={t.ragchat.attachBtn}
style={{ flexShrink: 0, padding: '8px', color: docContext?.status === 'ready' ? 'var(--accent, #6366f1)' : undefined }}
>
<Paperclip size={15} />
</button>
<textarea
className="composer-input"
placeholder={t.ragchat.inputPlaceholder}
value={inputDraft}
onChange={e => setRagState(s => ({ ...s, inputDraft: e.target.value }))}
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); } }}
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); void send(); } }}
rows={2}
/>
<button
className="btn primary"
onClick={() => send()}
disabled={!inputDraft.trim() || streaming}
onClick={() => void send()}
disabled={!inputDraft.trim() || streaming || docContext?.status === 'extracting'}
>
<Send size={14} />
</button>
+92 -8
View File
@@ -3,6 +3,8 @@ import { Topbar } from '../../components/layout/Topbar';
import { Search, Upload, Download, RefreshCw, CheckCircle, XCircle, AlertTriangle, Info } from 'lucide-react';
import { UploadModal } from '../Docs/UploadModal';
import { useLanguage } from '../../contexts/LanguageContext';
import { getModelUsage, pingModelConnections } from '../../api/status';
import type { ModelUsageEntry } from '../../api/index';
const TOKEN_KEY = 'auth_token';
function authHeader(): Record<string, string> {
@@ -81,29 +83,39 @@ export function StatusPage() {
const [config, setConfig] = useState<Config | null>(null);
const [loading, setLoading] = useState(true);
const [healthLoading, setHealthLoading] = useState(true);
const [modelsLoading, setModelsLoading] = useState(true);
const [configOpen, setConfigOpen] = useState(false);
const [refreshKey, setRefreshKey] = useState(0);
const [showUpload, setShowUpload] = useState(false);
const [lastRefresh, setLastRefresh] = useState<Date | null>(null);
const [modelUsage, setModelUsage] = useState<ModelUsageEntry[] | null>(null);
const [pinging, setPinging] = useState(false);
useEffect(() => {
setLoading(true);
setHealthLoading(true);
setModelsLoading(true);
// Fetch all three endpoints in parallel
// Fetch all endpoints in parallel. The first three use raw fetch() (legacy
// pattern already established in this file); model usage uses the typed
// fetchAPI-based client from api/status.ts — new code should prefer that.
Promise.allSettled([
fetch('/api/v1/status/stats', { headers: authHeader() }).then(r => r.json()),
fetch('/api/v1/status/health', { headers: authHeader() }).then(r => r.json()),
fetch('/api/v1/status/config', { headers: authHeader() }).then(r => r.json()),
]).then(([statsRes, healthRes, configRes]) => {
getModelUsage(),
]).then(([statsRes, healthRes, configRes, modelsRes]) => {
if (statsRes.status === 'fulfilled') setStats(statsRes.value);
else setStats({ documents_total: 0, documents_indexed: 0, documents_failed: 0, chunks_total: 0 });
if (healthRes.status === 'fulfilled') setHealth(healthRes.value);
if (configRes.status === 'fulfilled') setConfig(configRes.value);
if (modelsRes.status === 'fulfilled') setModelUsage(modelsRes.value.models);
else setModelUsage(null);
setLoading(false);
setHealthLoading(false);
setModelsLoading(false);
setLastRefresh(new Date());
});
}, [refreshKey]);
@@ -136,6 +148,38 @@ export function StatusPage() {
URL.revokeObjectURL(url);
}
async function handleTestConnections() {
setPinging(true);
try {
const res = await pingModelConnections();
setModelUsage(res.models);
} catch {
// Leave modelUsage as-is; the card below already shows a muted
// "never_called"/error state per row when data can't be refreshed.
} finally {
setPinging(false);
}
}
function modelBadgeStatus(status: ModelUsageEntry['status']): 'ok' | 'error' | 'warn' | 'info' {
if (status === 'ok') return 'ok';
if (status === 'error') return 'error';
if (status === 'disabled') return 'info';
return 'info'; // never_called
}
function modelStatusLabel(entry: ModelUsageEntry): string {
if (entry.status === 'never_called') return t.status.modelStatusNeverCalled;
if (entry.status === 'disabled') return t.status.modelStatusDisabled;
return entry.status === 'ok' ? t.status.badgeOnline : t.status.badgeError;
}
/** Small relative-ish hint shown next to provider/model — "Never" or a local time string. */
function modelLastCalledLabel(entry: ModelUsageEntry): string {
if (!entry.last_called_at) return t.status.lastCalledNever;
return new Date(entry.last_called_at).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
}
return (
<div className="status-page">
<Topbar
@@ -254,6 +298,52 @@ export function StatusPage() {
)}
</div>
{/* AI Models — connection status + cumulative token usage */}
<div className="card">
<div className="card-header" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span>{t.status.cardModels}</span>
<button className="btn sm" onClick={handleTestConnections} disabled={pinging}>
{pinging ? t.status.testingBtn : t.status.testConnectionBtn}
</button>
</div>
{modelsLoading ? (
<div style={{ padding: '12px 0', display: 'flex', flexDirection: 'column', gap: 10 }}>
{[1, 2, 3, 4].map(i => (
<div key={i} className="loading-shimmer" style={{ height: 28, borderRadius: 6 }} />
))}
</div>
) : modelUsage ? (
modelUsage.map(entry => {
const roleLabel = entry.role === 'main_llm' ? t.status.roleMainLlm
: entry.role === 'hyde_llm' ? t.status.roleHydeLlm
: entry.role === 'embedding' ? t.status.roleEmbedding
: t.status.roleReranker;
return (
<div className="service-row" key={entry.role}>
<StatusIcon status={modelBadgeStatus(entry.status)} />
<span className="service-name" style={{ marginLeft: 8 }}>{roleLabel}</span>
<span style={{ fontSize: 11, color: 'var(--muted)', marginLeft: 6, fontFamily: 'var(--font-mono)' }}>
{entry.provider}/{entry.model}
{entry.shares_usage_with && ` · ${t.status.sharesUsageWithMain}`}
{` · ${modelLastCalledLabel(entry)}`}
</span>
<span style={{ marginLeft: 'auto', fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--fg)' }}>
{entry.total_tokens > 0 || entry.status === 'ok' || entry.status === 'error'
? entry.total_tokens.toLocaleString()
: '—'}
</span>
<span className={`status ${modelBadgeStatus(entry.status)}`} style={{ marginLeft: 8 }}>
{modelStatusLabel(entry)}
</span>
</div>
);
})
) : (
<div style={{ padding: '12px 0', color: 'var(--muted)', fontSize: 13 }}>{t.status.modelsLoadError}</div>
)}
</div>
{/* System config (collapsible) */}
<div className="card">
<button
@@ -335,12 +425,6 @@ export function StatusPage() {
<span style={{ color: 'var(--muted)' }}>{t.status.labelSessionCapacity}</span>
<span style={{ fontFamily: 'var(--font-mono)' }}>{health.sessions.max}</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, padding: '4px 0' }}>
<span style={{ color: 'var(--muted)' }}>{t.status.labelReranker}</span>
<span style={{ fontFamily: 'var(--font-mono)', color: health.reranker.enabled ? 'var(--ok)' : 'var(--muted)' }}>
{health.reranker.enabled ? (health.reranker.model ?? t.status.serviceEnabled) : t.status.serviceDisabled}
</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, padding: '4px 0' }}>
<span style={{ color: 'var(--muted)' }}>{t.status.labelBM25}</span>
<span style={{ fontFamily: 'var(--font-mono)', color: health.bm25.available ? 'var(--ok)' : 'var(--muted)' }}>
+2
View File
@@ -136,6 +136,8 @@ body {
.status.warn::before { background: var(--warn); }
.status.risk { color: var(--danger); background: var(--danger-bg); }
.status.risk::before { background: var(--danger); }
.status.error { color: var(--danger); background: var(--danger-bg); }
.status.error::before { background: var(--danger); }
.status.info { color: var(--info); background: var(--info-bg); }
.status.info::before { background: var(--info); }
+219
View File
@@ -0,0 +1,219 @@
"""Integration tests for the /status/models routes.
Uses FastAPI TestClient with mocked LLM/embedding/reranker clients so no
external gateway or database is required.
"""
from __future__ import annotations
from unittest.mock import MagicMock, patch
import pytest
from fastapi.testclient import TestClient
from app.services.llm.base_client import LLMResponse
from app.services.llm.llm_factory import LLMFactory, get_llm_client
from app.shared.model_usage_tracker import get_model_usage_tracker
@pytest.fixture(autouse=True)
def _reset_tracker():
"""Clear the process-wide tracker before and after each test in this file."""
get_model_usage_tracker()._entries.clear()
yield
get_model_usage_tracker()._entries.clear()
@pytest.fixture
def _reset_llm_factory_instances():
"""Clear LLMFactory's process-wide client cache before/after this test only.
Mirrors backend/tests/observability/test_llm_factory_tracking.py's
_reset_singletons fixture: LLMFactory._global_instances persists for the
life of the process, so without this the cache entry created by driving a
real get_llm_client() call in a test would leak into other tests.
"""
LLMFactory._global_instances.clear()
yield
LLMFactory._global_instances.clear()
@pytest.fixture
def client():
"""Return a TestClient for the real app (status routes require no auth)."""
from app.api.main import app
with TestClient(app, raise_server_exceptions=False) as c:
yield c
def test_get_models_returns_four_roles_never_called_by_default(client):
"""With no calls made yet, all 4 roles are returned with status 'never_called' or 'disabled'."""
resp = client.get("/api/v1/status/models")
assert resp.status_code == 200
body = resp.json()
roles = {m["role"] for m in body["models"]}
assert roles == {"main_llm", "hyde_llm", "embedding", "reranker"}
reranker_row = next(m for m in body["models"] if m["role"] == "reranker")
# Default .env.example ships RERANKER_ENABLED=false.
from app.config.settings import settings
assert reranker_row["enabled"] == settings.reranker_enabled
if not settings.reranker_enabled:
assert reranker_row["status"] == "disabled"
def test_get_models_reflects_recorded_usage(client):
"""A previously recorded call must show up in total_tokens/status."""
from app.config.settings import settings
get_model_usage_tracker().record(
provider=settings.llm_provider, model=settings.llm_model, success=True, usage={"total_tokens": 99},
)
resp = client.get("/api/v1/status/models")
main_row = next(m for m in resp.json()["models"] if m["role"] == "main_llm")
assert main_row["total_tokens"] == 99
assert main_row["status"] == "ok"
def test_get_models_hyde_llm_disabled_forces_disabled_status(client):
"""settings.hyde_enabled=False must force hyde_llm to enabled=False/status='disabled',
mirroring the reranker override, even if HyDE previously ran successfully."""
from app.config.settings import settings
get_model_usage_tracker().record(
provider=settings.hyde_llm_provider or settings.llm_provider,
model=settings.hyde_llm_model or settings.llm_model,
success=True,
)
with patch.object(settings, "hyde_enabled", False):
resp = client.get("/api/v1/status/models")
assert resp.status_code == 200
hyde_row = next(m for m in resp.json()["models"] if m["role"] == "hyde_llm")
assert hyde_row["enabled"] is False
assert hyde_row["status"] == "disabled"
def test_ping_models_calls_each_enabled_model_once(client):
"""POST /status/models/ping must invoke chat()/embed_query() and return fresh statuses."""
mock_llm_response = LLMResponse(content="pong", model="test-model", usage={"total_tokens": 1})
mock_llm_client = MagicMock()
mock_llm_client.chat.return_value = mock_llm_response
mock_embedding = MagicMock()
mock_embedding.embed_query.return_value = [0.1]
with patch("app.api.routes.status.get_llm_client", return_value=mock_llm_client), \
patch("app.api.routes.status.get_embedding_provider", return_value=mock_embedding), \
patch("app.api.routes.status.get_reranker", return_value=None):
resp = client.post("/api/v1/status/models/ping")
assert resp.status_code == 200
body = resp.json()
assert len(body["models"]) == 4
assert mock_llm_client.chat.call_count >= 1
mock_embedding.embed_query.assert_called_once()
def test_ping_models_survives_one_model_failing(client):
"""If the LLM ping raises, embedding/reranker pings must still be attempted and a 200 returned."""
mock_embedding = MagicMock()
mock_embedding.embed_query.return_value = [0.1]
with patch("app.api.routes.status.get_llm_client", side_effect=RuntimeError("gateway down")), \
patch("app.api.routes.status.get_embedding_provider", return_value=mock_embedding), \
patch("app.api.routes.status.get_reranker", return_value=None):
resp = client.post("/api/v1/status/models/ping")
assert resp.status_code == 200
mock_embedding.embed_query.assert_called_once()
def test_ping_records_get_llm_client_failure_instead_of_dropping_it(client):
"""A get_llm_client() failure (raised before any TrackedLLMClient exists) must still
be recorded into the tracker, so it is visible afterwards via _build_model_status()
instead of being silently discarded by asyncio.gather(return_exceptions=True)."""
mock_embedding = MagicMock()
mock_embedding.embed_query.return_value = [0.1]
with patch("app.api.routes.status.get_llm_client", side_effect=RuntimeError("missing api key")), \
patch("app.api.routes.status.get_embedding_provider", return_value=mock_embedding), \
patch("app.api.routes.status.get_reranker", return_value=None):
resp = client.post("/api/v1/status/models/ping")
assert resp.status_code == 200
body = resp.json()
main_row = next(m for m in body["models"] if m["role"] == "main_llm")
hyde_row = next(m for m in body["models"] if m["role"] == "hyde_llm")
assert main_row["status"] == "error"
assert main_row["call_count_error"] == 1
assert main_row["last_error"] == "missing api key"
assert hyde_row["status"] == "error"
assert hyde_row["call_count_error"] == 1
assert hyde_row["last_error"] == "missing api key"
def test_ping_skips_hyde_llm_when_hyde_disabled(client):
"""POST /status/models/ping must NOT ping hyde_llm when settings.hyde_enabled is False:
HyDE often reuses the main LLM, so a live ping would just be a redundant duplicate chat
call against the same model for no benefit. main_llm must still always be pinged."""
from app.config.settings import settings
mock_llm_response = LLMResponse(content="pong", model="test-model", usage={"total_tokens": 1})
mock_llm_client = MagicMock()
mock_llm_client.chat.return_value = mock_llm_response
mock_embedding = MagicMock()
mock_embedding.embed_query.return_value = [0.1]
with patch.object(settings, "hyde_enabled", False), \
patch("app.api.routes.status.get_llm_client", return_value=mock_llm_client) as mock_get_llm_client, \
patch("app.api.routes.status.get_embedding_provider", return_value=mock_embedding), \
patch("app.api.routes.status.get_reranker", return_value=None):
resp = client.post("/api/v1/status/models/ping")
assert resp.status_code == 200
# Only main_llm's ping should reach get_llm_client()/.chat(); hyde_llm's must be skipped.
assert mock_get_llm_client.call_count == 1
assert mock_llm_client.chat.call_count == 1
hyde_row = next(m for m in resp.json()["models"] if m["role"] == "hyde_llm")
assert hyde_row["status"] == "disabled"
def test_ping_write_path_and_status_read_path_agree_for_non_canonical_provider_alias(
client, _reset_llm_factory_instances
):
"""Regression for the tracker key mismatch bug: TrackedLLMClient.chat() always records
usage under the NORMALIZED LLMProvider enum value from LLMFactory._parse_provider()
(see tracked_client.py), never the raw provider string a caller passed to
get_llm_client(). Before the fix, _resolve_role_provider_model() returned
settings.llm_provider verbatim with no normalization. So whenever LLM_PROVIDER held a
non-canonical alias (e.g. "deepseek-v3" instead of "deepseek"), the read-side lookup key
("deepseek-v3:<model>") stopped matching the write-side key TrackedLLMClient actually
recorded under ("deepseek:<model>"), and /status/models showed main_llm as perpetually
"never_called" even though it was being actively tracked.
This drives the FULL real path (LLMFactory.create() -> TrackedLLMClient ->
ModelUsageTracker -> the /status/models route), the same way real call sites like
compliance.py invoke get_llm_client(provider=settings.llm_provider, model=settings.llm_model)
-- unlike test_get_models_reflects_recorded_usage above, which shortcuts by recording
directly into the tracker.
"""
from app.config.settings import settings
mock_response = LLMResponse(content="pong", model=settings.llm_model, usage={"total_tokens": 7})
def _fake_deepseek_client(config):
# Carry the REAL LLMConfig built by LLMFactory.create() so TrackedLLMClient.chat()
# records under config.provider.value exactly like it does in production.
fake = MagicMock()
fake.config = config
fake.chat.return_value = mock_response
return fake
with patch.object(settings, "llm_provider", "deepseek-v3"), \
patch("app.services.llm.llm_factory.DeepSeekClient", side_effect=_fake_deepseek_client):
tracked_client = get_llm_client(provider=settings.llm_provider, model=settings.llm_model, api_key="test-key")
tracked_client.chat([{"role": "user", "content": "hi"}])
resp = client.get("/api/v1/status/models")
assert resp.status_code == 200
main_row = next(m for m in resp.json()["models"] if m["role"] == "main_llm")
assert main_row["status"] == "ok"
assert main_row["total_tokens"] == 7
assert main_row["provider"] == "deepseek"