Merge pull request 'main-ruqi' (#1) from main-ruqi into main
Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+4
-1
@@ -61,4 +61,7 @@ Thumbs.db
|
||||
logs/
|
||||
|
||||
# codex
|
||||
.agents
|
||||
.agents
|
||||
|
||||
# personal local records (never commit)
|
||||
local/
|
||||
+30
-4
@@ -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`(模糊)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
],
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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]}
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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]:
|
||||
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"
|
||||
"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]}"
|
||||
)
|
||||
response = client.chat([{"role": "user", "content": prompt}], max_tokens=1000)
|
||||
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]
|
||||
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]
|
||||
"""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-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{window}"
|
||||
)
|
||||
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()]
|
||||
all_clauses.extend(clauses[:4])
|
||||
except (ValueError, TypeError):
|
||||
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 []
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,20 +47,41 @@ 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")
|
||||
response = httpx.post(
|
||||
f"{self.base_url}/embeddings",
|
||||
headers={
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json={"model": self.model, "input": texts},
|
||||
timeout=self.timeout,
|
||||
)
|
||||
self._raise_for_status(response, batch_size=len(texts))
|
||||
data = response.json()
|
||||
start = time.time()
|
||||
try:
|
||||
response = httpx.post(
|
||||
f"{self.base_url}/embeddings",
|
||||
headers={
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json={"model": self.model, "input": texts},
|
||||
timeout=self.timeout,
|
||||
)
|
||||
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()
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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-Schema–compatible 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)
|
||||
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
@@ -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 0–1
|
||||
}
|
||||
|
||||
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 };
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 (0–1) */
|
||||
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 ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -12,6 +12,7 @@ export type {
|
||||
ComplianceStatus,
|
||||
ComplianceSourceEvent,
|
||||
ComplianceFindingEvent,
|
||||
ComplianceConflict,
|
||||
ComplianceDonePayload,
|
||||
ComplianceMeta,
|
||||
ComplianceActionItem,
|
||||
|
||||
@@ -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.',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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: '无法从该文件提取文本,请检查文件格式。',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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 }}>
|
||||
{f.clause_ref && (
|
||||
<div style={{ fontSize: 11, color: 'var(--muted)' }}>Ref: {f.clause_ref}</div>
|
||||
)}
|
||||
<button
|
||||
className="btn sm"
|
||||
style={{ marginLeft: 'auto', fontSize: 11, padding: '3px 8px', gap: 4 }}
|
||||
onClick={() => chat.openFor(i, f)}
|
||||
>
|
||||
<MessageSquare size={11} />{t.compliance.askAIBtn}
|
||||
</button>
|
||||
{state.analysisId && (
|
||||
<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.4–0.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"
|
||||
onClick={() => setDrawerFindingIdx(i)}
|
||||
style={{ marginTop: 6 }}
|
||||
style={{ marginLeft: 'auto', fontSize: 11, padding: '3px 8px', gap: 4 }}
|
||||
onClick={() => setDrawerFinding({ idx: i, finding: f })}
|
||||
>
|
||||
💬 {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 ?? '',
|
||||
|
||||
@@ -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) {
|
||||
const a = document.createElement('a');
|
||||
a.href = `/api/v1/documents/download/${id}`;
|
||||
a.download = name;
|
||||
a.click();
|
||||
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 = 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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,100 +234,211 @@ 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;
|
||||
|
||||
try {
|
||||
const body: Record<string, unknown> = { query: q, top_k: 5 };
|
||||
if (sessionId) body.session_id = sessionId;
|
||||
|
||||
const res = await fetch('/api/v1/rag/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...authHeader() },
|
||||
body: JSON.stringify(body),
|
||||
signal: ctrl.signal,
|
||||
});
|
||||
|
||||
if (!res.body) throw new Error('No stream');
|
||||
const reader = res.body.getReader();
|
||||
const dec = new TextDecoder();
|
||||
let buffer = '';
|
||||
if (agenticMode) {
|
||||
// ── Agentic path ────────────────────────────────────────────────────
|
||||
const newCitations: RagCitation[] = [];
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += dec.decode(value, { stream: true });
|
||||
const handleMessage = (msg: SSEMessage) => {
|
||||
if (msg.type === 'session') {
|
||||
if (msg.session_id) setRagState(s => ({ ...s, sessionId: msg.session_id! }));
|
||||
|
||||
const blocks = buffer.split('\n\n');
|
||||
buffer = blocks.pop() ?? '';
|
||||
|
||||
for (const block of blocks) {
|
||||
const dataLine = block.split('\n').find(l => l.startsWith('data: '));
|
||||
if (!dataLine) continue;
|
||||
const raw = dataLine.slice(6).trim();
|
||||
if (!raw) continue;
|
||||
try {
|
||||
const j = JSON.parse(raw);
|
||||
|
||||
if (j.type === 'session') {
|
||||
if (j.session_id) setRagState(s => ({ ...s, sessionId: j.session_id }));
|
||||
|
||||
} else if (j.type === 'retrieved' && Array.isArray(j.docs)) {
|
||||
const mapped = j.docs.map((d: Record<string, unknown>, i: number) => mapSource(d, i + 1));
|
||||
newCitations.push(...mapped);
|
||||
setRagState(s => ({ ...s, citations: [...mapped] }));
|
||||
|
||||
} else if (j.type === 'chunk' && j.text) {
|
||||
setRagState(s => ({
|
||||
...s,
|
||||
messages: s.messages.map(msg =>
|
||||
msg.id === assistantId
|
||||
? { ...msg, text: msg.text + (j.text as string) }
|
||||
: msg
|
||||
),
|
||||
}));
|
||||
|
||||
} else if (j.type === 'done') {
|
||||
setRagState(s => ({
|
||||
...s,
|
||||
messages: s.messages.map(msg => {
|
||||
if (msg.id !== assistantId) return msg;
|
||||
const refs = [...new Set(
|
||||
[...msg.text.matchAll(/\[(\d+)\]/g)].map(r => parseInt(r[1], 10))
|
||||
)].filter(n => n >= 1 && n <= newCitations.length);
|
||||
return { ...msg, citationRefs: refs };
|
||||
}),
|
||||
}));
|
||||
break;
|
||||
|
||||
} else if (j.type === 'error') {
|
||||
setRagState(s => ({
|
||||
...s,
|
||||
messages: s.messages.map(msg =>
|
||||
msg.id === assistantId
|
||||
? { ...msg, text: `Error: ${j.text ?? 'Unknown error'}` }
|
||||
: msg
|
||||
),
|
||||
}));
|
||||
} 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;
|
||||
}
|
||||
} catch { /* malformed JSON chunk, skip */ }
|
||||
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);
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof Error && e.name !== 'AbortError') {
|
||||
setRagState(s => ({
|
||||
...s,
|
||||
messages: s.messages.map(msg =>
|
||||
msg.id === assistantId
|
||||
? { ...msg, text: t.ragchat.apiError }
|
||||
: msg
|
||||
),
|
||||
}));
|
||||
|
||||
} 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',
|
||||
headers: { 'Content-Type': 'application/json', ...authHeader() },
|
||||
body: JSON.stringify(body),
|
||||
signal: ctrl.signal,
|
||||
});
|
||||
|
||||
if (!res.body) throw new Error('No stream');
|
||||
const reader = res.body.getReader();
|
||||
const dec = new TextDecoder();
|
||||
let buffer = '';
|
||||
const newCitations: RagCitation[] = [];
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += dec.decode(value, { stream: true });
|
||||
|
||||
const blocks = buffer.split('\n\n');
|
||||
buffer = blocks.pop() ?? '';
|
||||
|
||||
for (const block of blocks) {
|
||||
const dataLine = block.split('\n').find(l => l.startsWith('data: '));
|
||||
if (!dataLine) continue;
|
||||
const raw = dataLine.slice(6).trim();
|
||||
if (!raw) continue;
|
||||
try {
|
||||
const j = JSON.parse(raw);
|
||||
|
||||
if (j.type === 'session') {
|
||||
if (j.session_id) setRagState(s => ({ ...s, sessionId: j.session_id }));
|
||||
|
||||
} else if (j.type === 'retrieved' && Array.isArray(j.docs)) {
|
||||
const mapped = j.docs.map((d: Record<string, unknown>, i: number) => mapSource(d, i + 1));
|
||||
newCitations.push(...mapped);
|
||||
setRagState(s => ({ ...s, citations: [...mapped] }));
|
||||
|
||||
} else if (j.type === 'chunk' && j.text) {
|
||||
setRagState(s => ({
|
||||
...s,
|
||||
messages: s.messages.map(msg =>
|
||||
msg.id === assistantId
|
||||
? { ...msg, text: msg.text + (j.text as string) }
|
||||
: msg
|
||||
),
|
||||
}));
|
||||
|
||||
} else if (j.type === 'done') {
|
||||
setRagState(s => ({
|
||||
...s,
|
||||
messages: s.messages.map(msg => {
|
||||
if (msg.id !== assistantId) return msg;
|
||||
const refs = [...new Set(
|
||||
[...msg.text.matchAll(/\[(\d+)\]/g)].map(r => parseInt(r[1], 10))
|
||||
)].filter(n => n >= 1 && n <= newCitations.length);
|
||||
return { ...msg, citationRefs: refs };
|
||||
}),
|
||||
}));
|
||||
break;
|
||||
|
||||
} else if (j.type === 'error') {
|
||||
setRagState(s => ({
|
||||
...s,
|
||||
messages: s.messages.map(msg =>
|
||||
msg.id === assistantId
|
||||
? { ...msg, text: `Error: ${j.text ?? 'Unknown error'}` }
|
||||
: msg
|
||||
),
|
||||
}));
|
||||
}
|
||||
} catch { /* malformed JSON chunk, skip */ }
|
||||
}
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof Error && e.name !== 'AbortError') {
|
||||
setRagState(s => ({
|
||||
...s,
|
||||
messages: s.messages.map(msg =>
|
||||
msg.id === assistantId
|
||||
? { ...msg, text: t.ragchat.apiError }
|
||||
: msg
|
||||
),
|
||||
}));
|
||||
}
|
||||
} finally {
|
||||
ragStreamingRef.current = false;
|
||||
setStreaming(false);
|
||||
}
|
||||
} finally {
|
||||
ragStreamingRef.current = false;
|
||||
setStreaming(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)' }}>
|
||||
|
||||
@@ -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); }
|
||||
|
||||
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user