34 Commits
Author SHA1 Message Date
wangwei b2feaeddb4 update for mcp 2026-08-06 11:08:46 +08:00
wangweiandCopilot 31bbf80aeb feat: surface MCP server status in System Status page
Add per-tool in-memory call counters to the MCP module and a
GET /api/v1/status/mcp endpoint that joins them with the live tool
registry and endpoint config, then render it as a new card on the
System Status page with a one-click client-config copy button.

- app/mcp/stats.py: lock-guarded MCPStatsTracker (the mcp SDK runs sync
  tool bodies via anyio.to_thread.run_sync, so this is genuinely
  multi-threaded, unlike the async REST routes)
- app/mcp/server.py: instrument search_regulations, add get_mcp_status()
- app/config/settings.py: optional MCP_PUBLIC_URL override, required
  because the Vite proxy and reverse proxies rewrite the Host header
- StatusPage.tsx: MCP Server card, joins the existing parallel fetch

Counters are process-local by design; token usage is already persisted
by ModelUsageTracker since MCP calls route through ask().

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-03 11:37:22 +08:00
wangweiandCopilot 73e79a610d docs: design spec for MCP status panel
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-03 10:56:56 +08:00
wangweiandCopilot 49ee50c104 fix: harden MCP endpoint after code review
Critical: the MCP SDK auto-enables DNS-rebinding protection when its host
parameter is left at the 127.0.0.1 default, hard-coding a loopback-only Host
allow-list. Every remote client (the only deployment this feature targets) was
refused with HTTP 421 before auth or the tool ran. Now driven by a new
MCP_ALLOWED_HOSTS setting, with '*' as an explicit, logged opt-out.

Also bounds query/top_k to match AskRequest (top_k is amplified 4x downstream,
so an unbounded value was a resource-exhaustion vector), decodes the
Authorization header as latin-1 per the ASGI spec instead of raising a 500 on
malformed bytes, and returns WWW-Authenticate on 401 per RFC 7235.

Moves the psycopg2 import guard into backend/tests/conftest.py: duplicated
across four test modules, it only worked because of alphabetical collection
order, and any earlier-sorting package would have reintroduced a live
connection attempt against the production database.

Registers the mcp module in the authoritative backend architecture doc.

84 backend tests pass. Verified against a live server: allowed remote Host
returns a valid initialize result, unknown Host returns 421, missing token
returns 401 with WWW-Authenticate.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-29 17:11:54 +08:00
wangweiandCopilot bd3dc38d1d feat: add MCP server module exposing search_regulations tool
- New backend/app/mcp/ module: MCPServer instance with a single
  search_regulations tool backed by the existing AgentConversationService.
- MCPAuthMiddleware reuses existing JWT auth (no new auth mechanism).
- Mounted at /mcp/ in api/main.py via Streamable HTTP transport; wired the
  MCP session manager into the existing lifespan() via AsyncExitStack
  (app.mount() does not propagate nested ASGI lifespans automatically).
- Fixed a doubled /mcp/mcp path by setting streamable_http_path to "/"
  (MCPServer.streamable_http_app() defaults to registering its own /mcp route).
- Verified end-to-end with the real mcp Python client: list_tools() returns
  search_regulations, auth correctly 401s without or with an invalid token.
- 7 new tests, 76 total (up from 69), all passing.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-29 13:00:52 +08:00
wangweiandCopilot e78c8a989f docs: add MCP search_regulations implementation plan
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-29 11:03:28 +08:00
wangweiandCopilot 483689c1e8 docs: add MCP search_regulations server design spec
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-29 10:58:01 +08:00
wangweiandCopilot 6aaaff05f5 docs: add implementation plan for status model usage hardening
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-23 15:31:19 +08:00
wangweiandCopilot 0907470a2d fix: offload flush to thread pool, add QwenVL stream_options assert, document single-worker assumption
Fix 1 (bootstrap.py): wrap store.flush() in asyncio.to_thread() inside the
periodic _flush_loop() to avoid blocking the async event loop every 60s.
Synchronous signatures of _start/_stop_model_usage_persistence() and the
one-time seed/shutdown flushes are left unchanged per review scope.

Fix 2 (test_stream_chat_usage_capture.py): add the two-line stream_options
assertion to test_qwen_vl_stream_chat_returns_usage_from_trailing_chunk,
matching the identical check already present in the DeepSeek and Qwen tests.

Fix 3 (design doc): note the single-worker assumption in A3's write strategy
section — multi-worker deployments get last-writer-wins per-row semantics.

Tests: 69 passed, 0 failed (python -m pytest backend/tests -q)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-23 15:30:21 +08:00
wangweiandCopilot 29f79d7434 feat: seed and periodically persist model usage stats to Postgres
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-23 14:32:21 +08:00
wangweiandCopilot 5d132981ad feat: add PostgresModelUsageStore and ModelUsageTracker.seed()
- Add seed() method to ModelUsageTracker for bulk-loading persisted entries at startup
- Create PostgresModelUsageStore for persistence of model usage counters to Postgres
- Store only current cumulative snapshots (no historical time-series)
- Use standard CREATE TABLE IF NOT EXISTS idiom matching other Postgres stores
- Add comprehensive mocked unit tests for both components

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-23 14:18:22 +08:00
wangweiandCopilot 4f6cc4812e revert: disable Cross-Encoder reranker
Live verification via POST /status/models/ping (after confirming the
gateway itself is reachable -- embedding role succeeded, 1.5s latency)
shows http://6.86.80.4:30080/v1/rerank returns a fast, reproducible
'503 Service Unavailable' -- not a timeout/fluke. The gateway's model
catalog (19 models: deepseek-*, glm-*, kimi-*, qwen3*, text-embedding-v3/4)
contains no cross-encoder/rerank-capable model, confirming no reranker
service is deployed behind this gateway today. Leaving RERANKER_ENABLED=true
would be a permanent no-op (graceful fallback to unranked order every call)
plus a misleading permanent error badge on the Status page. Revert until a
reranker model is actually deployed on the gateway.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-23 14:09:33 +08:00
wangweiandCopilot 2547d04b9d chore: enable Cross-Encoder reranker
Sandbox verification via /status/models/ping was inconclusive: the gateway
(6.86.80.4:30080) is unreachable from this environment entirely (embedding
role failed with the identical connection-timeout pattern, which is a
feature that definitely works in real deployment) -- not evidence that
/rerank specifically is unsupported. Reranker code already has graceful
TEI/Cohere fallback + falls back to unranked order on any failure, so this
is zero-risk to retrieval even if misconfigured. Please verify via
POST /status/models/ping in an environment with real gateway access;
revert RERANKER_ENABLED to false if that shows a genuine error.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-23 14:03:43 +08:00
wangweiandCopilot 7adc050968 feat: record streaming token usage in TrackedLLMClient.stream_chat
Implement manual generator driving using next()/StopIteration to capture
the return value (trailing usage dict) from inner stream_chat() implementations,
enabling token tracking for streaming LLM calls.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-23 13:42:33 +08:00
wangweiandCopilot f2bd0deeb3 feat: capture streaming token usage in QwenClient and QwenVLClient
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-23 13:19:23 +08:00
wangweiandCopilot 81a6d54fff feat: capture streaming token usage in DeepSeekClient.stream_chat
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-23 11:24:31 +08:00
wangwei beddc2d976 Merge pull request 'main-ruqi' (#1) from main-ruqi into main
Reviewed-on: #1
2026-07-02 22:05:17 +08:00
wangwei 52e67b0e7b Add LLM token 2026-07-02 22:03:39 +08:00
wangweiandCopilot e3afb8a07a fix: normalize LLM provider key lookup and skip disabled HyDE ping (final review)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-02 21:24:22 +08:00
wangweiandCopilot 6a7fe48c4c fix: use dedicated error message for AI Models card load failure (Task 9 review)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-02 20:03:27 +08:00
wangweiandCopilot 0edbee07d5 fix: add distinct error state for AI Models card load failure (Task 9 review)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-02 18:01:04 +08:00
wangweiandCopilot 2ce4c8a289 feat: add AI Models card to Status page with connection test button
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-02 17:38:33 +08:00
wangweiandCopilot 39a51c9e83 feat: add ModelUsageEntry types and status API client functions
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-02 17:23:43 +08:00
wangweiandCopilot 049da2297b feat: add i18n keys for AI Models card and fix missing .status.error CSS
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-02 17:06:13 +08:00
wangweiandCopilot d83286edd4 fix: honor hyde_enabled toggle and record ping failures before client creation (Task 6 review)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-02 16:42:58 +08:00
wangweiandCopilot 169911ab46 feat: add GET/POST /status/models routes for AI model connection status
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-02 16:20:35 +08:00
wangweiandCopilot 66fc388bfb feat: record reranker call outcome into ModelUsageTracker
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-02 15:42:20 +08:00
wangwei 41096369d3 feat: record embedding call usage into ModelUsageTracker 2026-07-02 15:15:03 +08:00
wangwei 4fea159f5b feat: wrap LLM clients with TrackedLLMClient in LLMFactory 2026-07-02 15:03:12 +08:00
wangweiandCopilot d460397dda fix: add missing test comment for backend commenting standard (Task 2 review)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-02 14:57:55 +08:00
wangweiandCopilot 37ea27fcbe feat: add TrackedLLMClient decorator for transparent usage recording
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-02 14:49:06 +08:00
wangwei 74f327c85e feat: add ModelUsageTracker for per-model token/connection tracking 2026-07-02 14:41:21 +08:00
wangweiandCopilot 4b451ef97c docs: add implementation plan for System Status AI model usage tracking
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-02 14:13:58 +08:00
wangweiandCopilot 55ba922250 docs: add design spec for System Status AI model connection/token usage panel
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-02 13:39:43 +08:00
104 changed files with 11565 additions and 644 deletions
+30 -3
View File
@@ -55,9 +55,16 @@ DOCUMENT_REPOSITORY_BACKEND=postgres
USE_CELERY_WORKER=false
# ===== 法规感知爬取配置 =====
# 单次 HTTP 请求超时(秒),含正文抓取(fetch_full_text)。
PERCEPTION_CRAWL_TIMEOUT_SECONDS=120
# 每个数据源单次爬取的最大条目数。
PERCEPTION_MAX_EVENTS_PER_SOURCE=100
PERCEPTION_DIFF_SIMILARITY_THRESHOLD=0.85
# 变更判定的次要闸门:段落改动字符占比达到该阈值才送 LLM 分类。
# 数字变化(如 30米->20米)或情态词变化(应当/宜/不得等)无视此阈值,始终判定为显著变更。
PERCEPTION_DIFF_MIN_CHANGE_RATIO=0.02
# 定时全量爬取的执行间隔(秒),默认 21600 = 6 小时。
# 仅当 Celery Beat 进程在运行时才生效(./dev.sh start beat),Beat 未启动则完全不会自动爬取。
PERCEPTION_CRAWL_INTERVAL_SECONDS=21600
# ===== API配置 =====
API_HOST=0.0.0.0
@@ -102,10 +109,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 +127,23 @@ 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.6-flash
# ===== MCP 服务配置 =====
# MCP SDK 在传输层绑定回环地址时会自动启用 DNS 重绑定防护:Host 头不在下表内的
# 请求一律返回 HTTP 421,且发生在进入工具逻辑之前。部署在 6.86.80.9 必须显式列出
# 该地址,否则所有远程 MCP 客户端(Claude Desktop / IDE 等)100% 连不上。
# 语法:`:*` 后缀匹配任意端口;填 `*` 表示彻底关闭该防护(不推荐)。
MCP_ALLOWED_HOSTS=6.86.80.9:*,127.0.0.1:*,localhost:*,[::1]:*
# 系统状态页 MCP 卡片展示、以及"复制接入配置"按钮写入的对外访问地址。
# 留空则由后端从请求 Host 头推导;但前端经 Vite 代理(changeOrigin: true)转发后
# Host 会被改写成 API_HOST:API_PORT,推导结果是 0.0.0.0/127.0.0.1,客户端无法使用,
# 因此远程部署必须显式指定。结尾的斜杠不能省略。
MCP_PUBLIC_URL=http://6.86.80.9:8000/mcp/
+49 -1
View File
@@ -60,9 +60,16 @@ DOCUMENT_REPOSITORY_BACKEND=json
USE_CELERY_WORKER=false
# ===== 法规感知爬取配置 =====
# 单次 HTTP 请求超时(秒),含正文抓取(fetch_full_text)。
PERCEPTION_CRAWL_TIMEOUT_SECONDS=120
# 每个数据源单次爬取的最大条目数。
PERCEPTION_MAX_EVENTS_PER_SOURCE=100
PERCEPTION_DIFF_SIMILARITY_THRESHOLD=0.85
# 变更判定的次要闸门:段落改动字符占比达到该阈值才送 LLM 分类。
# 数字变化(如 30米->20米)或情态词变化(应当/宜/不得等)无视此阈值,始终判定为显著变更。
PERCEPTION_DIFF_MIN_CHANGE_RATIO=0.02
# 定时全量爬取的执行间隔(秒),默认 21600 = 6 小时。
# 仅当 Celery Beat 进程在运行时才生效(./dev.sh start beat),Beat 未启动则完全不会自动爬取。
PERCEPTION_CRAWL_INTERVAL_SECONDS=21600
# ===== 阿里云文档解析 =====
ALIBABA_ACCESS_KEY_ID=your_aliyun_access_key_id
@@ -138,6 +145,47 @@ 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.6-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
# ===== MCP (Model Context Protocol) =====
# MCP 端点(/mcp/)的 Host 头白名单,逗号分隔。MCP SDK 默认开启 DNS rebinding
# 防护,任何不在此列表中的 Host 都会被直接返回 HTTP 421,请求根本到不了鉴权和
# 工具逻辑。因此**远程部署必须把真实访问地址写进来**,否则所有外部 MCP 客户端
# Claude Desktop / IDE 等)100% 连不上。
# 语法:`:*` 后缀表示匹配任意端口;填 `*` 表示彻底关闭该防护(不推荐)。
# 例如部署在 6.86.80.9:8000 时:
# MCP_ALLOWED_HOSTS=6.86.80.9:*,127.0.0.1:*,localhost:*
MCP_ALLOWED_HOSTS=127.0.0.1:*,localhost:*,[::1]:*
# 系统状态页展示、以及"复制接入配置"按钮所使用的 MCP 外部访问地址。
# 留空则由后端从请求的 Host 头推导;当前端经 Vite 代理(changeOrigin: true
# 或反向代理改写了 Host 时,推导结果会是 127.0.0.1,此时必须显式指定。
# MCP_PUBLIC_URL=http://6.86.80.9:8000/mcp/
MCP_PUBLIC_URL=
+3
View File
@@ -62,3 +62,6 @@ logs/
# codex
.agents
# personal local records (never commit)
local/
+30 -4
View File
@@ -390,12 +390,38 @@ Demo-glm/
| 下载文档 | `/api/v1/documents/download/{doc_id}` | GET | 下载原文PDF/DOCX |
| 文档列表 | `/api/v1/documents/list` | GET | 列出已上传文档 |
| 检索知识 | `/api/v1/knowledge/search` | POST | 向量检索 |
| 单次问答 | `/api/v1/agent/ask` | POST | 智能问答 |
| 多轮对话 | `/api/v1/agent/chat` | POST | 会话对话 |
| 单次问答 | `/api/v1/agent/ask` | POST | 标准单轮问答 |
| 多轮对话 | `/api/v1/agent/chat` | POST | 标准会话对话 |
| 流式对话 | `/api/v1/agent/chat/stream` | POST | 标准流式问答 (SSE) |
| **Agentic 流式对话** | **`/api/v1/agent/agentic/stream`** | **POST** | **P0-1 多步推理 (SSE):意图分析→查询分解→迭代检索→引文锚定→生成** |
| 会话信息 | `/api/v1/agent/session/{id}` | GET | 获取会话 |
| 删除会话 | `/api/v1/agent/session/{id}` | DELETE | 删除会话 |
| Prompt模板 | `/api/v1/agent/templates` | GET | 模板列表 |
| 可用模型 | `/api/v1/agent/models` | GET | LLM模型列表 |
| 会话历史 | `/api/v1/agent/session/{id}/history` | GET | 获取历史记录 |
| 会话列表 | `/api/v1/agent/sessions` | GET | 列出所有会话 |
### Agentic 流式接口说明 (`/api/v1/agent/agentic/stream`)
**请求体** (同 `/agent/chat/stream`)
```json
{ "query": "GB 18384 与 ECE R100 在电池安全上有哪些差异?", "session_id": null, "top_k": 5 }
```
**额外 SSE 事件** (`thinking`)
```
event: thinking
data: {"step": "intent_analysis", "status": "done", "intent_type": "compare", "requires_decomposition": true}
event: thinking
data: {"step": "query_planning", "status": "done", "sub_queries": ["GB 18384 电池安全要求", "ECE R100 电池安全要求"]}
event: thinking
data: {"step": "retrieving", "status": "done", "query": "GB 18384 电池安全要求", "index": 1, "total": 2, "found": 8}
event: thinking
data: {"step": "grounding_check", "status": "done", "sufficient": true, "confidence": 0.82, "reason": "检索置信度充足"}
```
**意图类型**`simple_qa`(单跳)/ `compare`(对比)/ `multi_hop`(多跳)/ `ambiguous`(模糊)
---
+16 -1
View File
@@ -1,6 +1,6 @@
"""FastAPI application entrypoint."""
from contextlib import asynccontextmanager
from contextlib import AsyncExitStack, asynccontextmanager
from fastapi import FastAPI, Request
from fastapi.encoders import jsonable_encoder
@@ -13,6 +13,7 @@ from app.api.models import ErrorResponse
from app.api.routes import api_router
from app.config.logging import setup_logging
from app.config.settings import settings
from app.mcp.server import build_mcp_asgi_app
from app.shared.bootstrap import cleanup_runtime_dependencies, preload_runtime_dependencies
from app.shared.errors import VectorStoreSchemaError
# Keep module behavior explicit so the backend flow stays easy to audit.
@@ -20,10 +21,23 @@ from app.shared.errors import VectorStoreSchemaError
setup_logging(level="INFO" if not settings.debug else "DEBUG")
# Built once at module scope so both lifespan() and app.mount() below reference
# the same instance — mounting a second, separately-built instance would start
# a second, unrelated MCP session manager.
mcp_app = build_mcp_asgi_app()
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Application lifecycle hooks."""
# FastMCP-style servers own a session manager that only starts via its own
# lifespan context. app.mount() does NOT propagate nested ASGI lifespans
# automatically (confirmed Starlette/ASGI limitation) — without this,
# every search_regulations call would fail because the MCP session
# manager was never started.
async with AsyncExitStack() as stack:
await stack.enter_async_context(mcp_app.router.lifespan_context(mcp_app))
logger.info(f"启动 {settings.app_name} v{settings.app_version}")
logger.info(f"调试模式: {settings.debug}")
logger.info("预加载LLM客户端...")
@@ -65,6 +79,7 @@ app.add_middleware(
app.add_middleware(AuditMiddleware)
app.include_router(api_router, prefix="/api/v1")
app.mount("/mcp", mcp_app)
@app.exception_handler(VectorStoreSchemaError)
+5
View File
@@ -42,6 +42,11 @@ class ChatRequest(BaseModel):
provider: Optional[str] = None
model: Optional[str] = None
top_k: Optional[int] = Field(default=None, ge=1, le=20)
# Optional document text uploaded by the user as conversation context.
# The text is injected directly into the LLM prompt so the model can
# answer questions about it without vector-store indexing.
context_text: Optional[str] = Field(default=None, max_length=12000)
context_filename: Optional[str] = Field(default=None, max_length=256)
class ChatResponse(BaseModel):
+60 -1
View File
@@ -20,7 +20,11 @@ from app.api.models import (
)
from app.config.settings import settings
from app.shared.async_utils import iter_in_thread
from app.shared.bootstrap import get_agent_conversation_service, get_agent_session_service
from app.shared.bootstrap import (
get_agent_conversation_service,
get_agent_session_service,
get_agentic_conversation_service,
)
# Keep route handlers close to their transport-layer wiring for easier auditing.
@@ -182,3 +186,58 @@ async def submit_feedback(request: FeedbackRequest):
return {"message": "反馈已提交", "session_id": result.session_id, "message_index": result.message_index}
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc))
# ── P0-1: Agentic RAG endpoint ────────────────────────────────────────────────
@router.post("/agentic/stream")
async def agentic_stream(request: ChatRequest):
"""Stream an Agentic RAG response with live multi-step reasoning trace.
Unlike the standard ``/chat/stream`` endpoint this route runs a full pipeline:
intent analysis → query planning → iterative retrieval → grounding check →
answer generation.
Extra SSE event types beyond the standard ones:
* ``thinking`` — reasoning sub-step progress; data is a JSON object with
``step`` (intent_analysis / query_planning / retrieving / grounding_check),
``status`` (running / done), and step-specific fields.
The ``sources``, ``content``, and ``done`` events are identical to the standard
chat-stream contract so the existing frontend parser can handle them without
changes.
"""
async def generate_sse() -> AsyncGenerator[str, None]:
"""Handle SSE generation for the agentic chat endpoint."""
try:
session_id_, event_stream = get_agentic_conversation_service().stream_agentic_chat(
query=request.query,
session_id=request.session_id,
filters=request.filters,
provider=request.provider or settings.llm_provider,
model=request.model or settings.llm_model,
top_k=request.top_k or settings.rag_top_k,
context_text=request.context_text,
context_filename=request.context_filename,
)
yield f"event: session\ndata: {json.dumps({'session_id': session_id_})}\n\n"
async for event_data in iter_in_thread(event_stream):
event_type = event_data.get("event", "content")
data = event_data.get("data", "")
if isinstance(data, (dict, list)):
yield f"event: {event_type}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
else:
yield f"event: {event_type}\ndata: {data}\n\n"
except Exception as exc:
yield f"event: error\ndata: {str(exc)}\n\n"
return StreamingResponse(
generate_sse(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
+23 -7
View File
@@ -85,9 +85,10 @@ async def analyze_stream(
Events: stage | source | finding | done | error
"""
from app.application.compliance.pipeline import (
detect_cross_clause_conflicts,
extract_text_from_doc_id,
extract_text_from_file,
run_clauses_parallel,
run_clauses_streaming,
split_into_clauses,
synthesize_conclusion,
)
@@ -135,23 +136,27 @@ async def analyze_stream(
await asyncio.sleep(0)
clauses: list[str] = await asyncio.to_thread(split_into_clauses, para_text, client)
# ── Stage 3: retrieve + gap check (parallel across all clauses) ────────────
# ── Stage 3: progressive per-clause retrieve + gap check ──────
findings: list[dict] = []
total_clauses = len(clauses)
yield _sse({
"type": "stage",
"stage": "analyzing",
"label": f"Analyzing {len(clauses)} clauses in parallel",
"label": f"Analyzing {total_clauses} clauses…",
})
# Emit initial progress so the frontend can show the total count
yield _sse({"type": "progress", "done": 0, "total": total_clauses})
await asyncio.sleep(0)
clause_results = await run_clauses_parallel(
done_count = 0
# Stream results as each clause completes (not after all finish)
async for res in run_clauses_streaming(
clauses, retrieval_service, client,
top_k=5,
domains=domains or None,
)
for res in clause_results:
):
done_count += 1
i = res["index"]
chunks = res["chunks"]
finding = res["finding"]
@@ -165,14 +170,25 @@ async def analyze_stream(
"score": round(float(getattr(chunk, "score", 0)), 3),
"status": "retrieved",
"full_content": (getattr(chunk, "text", "") or "")[:300],
"clause_index": i,
})
if finding:
findings.append(finding)
yield _sse({"type": "finding", **finding})
# Real progress update after each clause completes
yield _sse({"type": "progress", "done": done_count, "total": total_clauses})
await asyncio.sleep(0)
# ── Stage 3b: cross-clause conflict detection ─────────────────
if findings:
conflicts = await asyncio.to_thread(
detect_cross_clause_conflicts, findings, client
)
if conflicts:
yield _sse({"type": "conflicts", "items": conflicts})
# ── Stage 4: synthesize conclusion ────────────────────────────
yield _sse({"type": "stage", "stage": "concluding", "label": "Generating conclusion…"})
await asyncio.sleep(0)
+3
View File
@@ -241,6 +241,9 @@ async def get_document_management_list():
"updated_at": item.updated_at.isoformat(),
"regulation_type": item.regulation_type,
"version": item.version,
# True only when the original binary file is stored in MinIO.
# Milvus-only synthetic docs have no binary file — download is disabled.
"has_file": bool(item.object_name),
}
for item in documents
],
+29 -1
View File
@@ -7,7 +7,12 @@ import json
from fastapi import APIRouter, Depends, Query
from fastapi.responses import StreamingResponse
from app.shared.bootstrap import get_crawl_service, get_event_store, get_perception_service
from app.shared.bootstrap import (
get_crawl_service,
get_event_store,
get_notification_store,
get_perception_service,
)
from app.api.dependencies.auth import get_current_user
from app.domain.auth.models import UserClaims
from app.shared.async_utils import iter_in_thread
@@ -141,3 +146,26 @@ async def get_event_diff(event_id: str):
"previous_hash": event.get("previous_hash"),
"content_hash": event.get("content_hash"),
}
@router.get("/notifications")
async def list_notifications(
limit: int = Query(default=20, ge=1, le=100),
current_user: UserClaims = Depends(get_current_user),
):
"""Return the newest in-app notifications plus this user's unread count.
Every logged-in user sees the same broadcast feed — there is no per-role
or per-topic subscription. "read" per item and the aggregate unread_count
both reflect only the calling user's own read receipts.
"""
store = get_notification_store()
items = store.list_for_user(current_user.user_id, limit=limit)
return {"items": items, "unread_count": store.unread_count(current_user.user_id)}
@router.post("/notifications/read")
async def mark_notifications_read(current_user: UserClaims = Depends(get_current_user)):
"""Mark every currently-unread notification read for the calling user."""
marked = get_notification_store().mark_all_read(current_user.user_id)
return {"marked": marked}
+82 -3
View File
@@ -3,10 +3,14 @@
from __future__ import annotations
import json
from typing import AsyncGenerator
import os
import re
import tempfile
from typing import AsyncGenerator, Optional
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, File, UploadFile
from fastapi.responses import StreamingResponse
from loguru import logger
from app.api.dependencies.auth import get_current_user
from app.config.settings import settings
@@ -15,6 +19,8 @@ from app.schemas.rag import RagChatRequest, QuickQuestionsResponse, QuickQuestio
from app.shared.async_utils import iter_in_thread
from app.shared.bootstrap import get_agent_conversation_service
# Maximum characters of document text injected as LLM context (≈ 6 000 tokens).
_MAX_CONTEXT_CHARS = 8_000
router = APIRouter(prefix="/rag", tags=["RAG问答"])
@@ -28,17 +34,90 @@ _DEFAULT_QUICK_QUESTIONS = [
]
def _extract_text_from_bytes(content: bytes, filename: str) -> str:
"""Extract plain text from an uploaded file using the document parser.
Tries the configured parser first; falls back to raw UTF-8 decode for
plain-text formats (.txt, .md). Returns at most _MAX_CONTEXT_CHARS characters
so the text fits comfortably inside the LLM context window.
"""
suffix = os.path.splitext(filename or "doc.pdf")[1] or ".pdf"
# Fast path: plain-text files don't need a parser
if suffix.lower() in {".txt", ".md", ".csv"}:
try:
return content.decode("utf-8", errors="replace")[:_MAX_CONTEXT_CHARS]
except Exception:
pass
tmp_path = ""
try:
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
tmp.write(content)
tmp_path = tmp.name
from app.shared.bootstrap import get_document_command_service
svc = get_document_command_service()
parsed = svc.parser.parse(file_path=tmp_path, doc_id="ctx_extract", doc_name=filename)
if parsed.raw_text:
return parsed.raw_text[:_MAX_CONTEXT_CHARS]
# Fallback: join semantic blocks
return "\n".join(
b.get("text", "") for b in parsed.semantic_blocks if b.get("text")
)[:_MAX_CONTEXT_CHARS]
except Exception as exc:
logger.warning("Context text extraction failed for {}: {}", filename, exc)
return ""
finally:
if tmp_path:
try:
os.unlink(tmp_path)
except OSError:
pass
@router.post("/upload-context")
async def upload_context(
file: UploadFile = File(...),
current_user: UserClaims = Depends(get_current_user),
):
"""Extract text from an uploaded document and return it as conversation context.
The client stores the returned text and includes it in subsequent /rag/chat
requests via the context_text field — the LLM receives the document content
directly without requiring vector-store indexing.
"""
content = await file.read()
filename = file.filename or "document"
text = await __import__("asyncio").to_thread(_extract_text_from_bytes, content, filename)
if not text.strip():
from fastapi import HTTPException
raise HTTPException(status_code=422, detail="Could not extract text from the uploaded file.")
return {
"filename": filename,
"text": text,
"char_count": len(text),
"truncated": len(text) >= _MAX_CONTEXT_CHARS,
}
@router.post("/chat")
async def rag_chat(
request: RagChatRequest,
current_user: UserClaims = Depends(get_current_user),
):
"""Stream RAG Q&A using the real agent service."""
"""Stream RAG Q&A using the real agent service.
When request.context_text is provided the document text is passed directly
to the answer generator as a dedicated document context section — RAG
retrieval still runs on the user's original question (not the document text)
so embedding quality is preserved for regulation chunk matching.
"""
session_id, event_stream = get_agent_conversation_service().stream_chat(
query=request.query,
session_id=request.session_id,
filters=request.filters,
top_k=request.top_k or settings.rag_top_k,
context_text=request.context_text,
context_filename=request.context_filename,
)
async def generate() -> AsyncGenerator[str, None]:
+185 -1
View File
@@ -1,18 +1,25 @@
"""Define API routes for status."""
import asyncio
import time
from typing import Any
from fastapi import APIRouter
from fastapi import APIRouter, Request
from app.config.settings import settings
from app.domain.retrieval import RetrievedChunk
from app.mcp.server import get_mcp_status
from app.services.llm.llm_factory import get_llm_client, get_llm_factory
from app.shared.bootstrap import (
get_bm25_retriever,
get_binary_store,
get_conversation_store,
get_document_query_service,
get_embedding_provider,
get_reranker,
get_vector_index,
)
from app.shared.model_usage_tracker import get_model_usage_tracker
router = APIRouter(prefix="/status", tags=["系统状态"])
@@ -23,6 +30,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 +128,170 @@ 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]}
@router.get("/mcp")
async def get_mcp_server_status(request: Request):
"""Return MCP endpoint config, advertised tools, and per-tool call counters.
This route is a thin HTTP adapter: everything MCP-specific is assembled by
app.mcp.server.get_mcp_status(). The only thing decided here is the public
URL, because only the HTTP layer knows how the client reached us.
"""
# request.base_url already carries scheme/host/port and a trailing slash;
# strip it before appending so the result is ".../mcp/", not ".../mcp//".
public_url = settings.mcp_public_url or f"{str(request.base_url).rstrip('/')}/mcp/"
return await get_mcp_status(public_url)
+7 -1
View File
@@ -1,7 +1,13 @@
"""Initialize the app.application.agent package."""
from .services import AgentConversationService, AgentSessionFeedbackResult, AgentSessionService
from .agentic_service import AgenticConversationService
# Keep package boundaries explicit so backend imports stay predictable.
__all__ = ["AgentConversationService", "AgentSessionFeedbackResult", "AgentSessionService"]
__all__ = [
"AgentConversationService",
"AgentSessionFeedbackResult",
"AgentSessionService",
"AgenticConversationService",
]
@@ -0,0 +1,453 @@
"""Implement the Agentic RAG pipeline for multi-step reasoning (P0-1).
Architecture
------------
The pipeline adds four explicit reasoning steps before answer generation:
1. Intent Analysis — classify query type (simple_qa / compare / multi_hop / ambiguous)
2. Query Planning — for complex intents, decompose into focused sub-queries
3. Iterative Retrieval — retrieve for each sub-query, merge with deduplication
4. Grounding Check — verify retrieved context is sufficient; refine query when not
5. Answer Generation — stream final answer with citations (reuses AnswerGenerator)
Each step emits SSE ``thinking`` events so the frontend can render the live
reasoning trace. The pipeline is entirely synchronous and returns a generator so
it plugs into the same ``iter_in_thread`` pattern used by the existing chat routes.
"""
from __future__ import annotations
import json
from dataclasses import dataclass, field
from typing import Generator
from loguru import logger
from app.application.knowledge import KnowledgeRetrievalService
from app.application.agent.hyde_expander import HyDEExpander
from app.config.settings import settings
from app.domain.conversation import ConversationStore
from app.domain.retrieval import RetrievedChunk
from app.infrastructure.llm.openai_compatible_answer_generator import OpenAICompatibleAnswerGenerator
from app.services.llm.llm_factory import get_llm_client
# ── Prompts ───────────────────────────────────────────────────────────────────
# Each prompt is kept module-level for easy review and fine-tuning.
_INTENT_SYSTEM = (
"You are a query classifier for a Chinese regulatory compliance knowledge base.\n\n"
"Classify the query into exactly one of:\n"
'- "simple_qa" : Single-hop, factual question about one regulation or clause\n'
'- "compare" : Comparison between two or more regulations, standards, or versions\n'
'- "multi_hop" : Requires chaining facts across multiple regulations to answer\n'
'- "ambiguous" : Too vague or broad to retrieve effectively\n\n'
"Return ONLY valid JSON — no markdown, no extra text:\n"
'{"type": "...", "reason": "one sentence", "requires_decomposition": true/false}\n\n'
'"requires_decomposition" must be true for compare and multi_hop types.'
)
_PLAN_SYSTEM = (
"You are a query planner for a Chinese regulatory compliance knowledge base.\n\n"
"Decompose the query into 2-4 focused, self-contained sub-queries that together fully "
"address the original question. Each sub-query must target one specific regulation, "
"clause, or concept and be independently searchable.\n\n"
"Return ONLY a valid JSON array — no markdown, no extra text:\n"
'["sub-query 1", "sub-query 2", ...]'
)
_GROUNDING_SYSTEM = (
"You are a grounding verifier for a regulatory compliance QA system.\n\n"
"Given a query and retrieved regulation passages, decide whether the passages contain "
"sufficient, accurate information to answer the query.\n\n"
"Return ONLY valid JSON — no markdown, no extra text:\n"
'{"sufficient": true/false, "confidence": 0.0-1.0, "reason": "one sentence", '
'"refined_query": "a more specific search query if not sufficient, else null"}'
)
# ── Result dataclasses ────────────────────────────────────────────────────────
@dataclass
class IntentResult:
"""Capture the output of the intent-analysis step."""
type: str = "simple_qa"
reason: str = ""
requires_decomposition: bool = False
@dataclass
class GroundingResult:
"""Capture the output of the grounding-check step."""
sufficient: bool = True
confidence: float = 1.0
reason: str = ""
refined_query: str | None = None
# ── Service ───────────────────────────────────────────────────────────────────
class AgenticConversationService:
"""Multi-step Agentic RAG pipeline with live reasoning trace via SSE.
The service is intentionally synchronous so it can be wrapped in
``iter_in_thread`` by the route layer without any async boilerplate.
"""
def __init__(
self,
*,
retrieval_service: KnowledgeRetrievalService,
answer_generator: OpenAICompatibleAnswerGenerator,
conversation_store: ConversationStore,
) -> None:
"""Initialise with injected dependencies from the composition root."""
self.retrieval_service = retrieval_service
self.answer_generator = answer_generator
self.conversation_store = conversation_store
# HyDE expander is stateless — one instance shared for all requests.
self._hyde = HyDEExpander()
# ── Private helpers ───────────────────────────────────────────────────────
def _llm_json(
self,
system: str,
user: str,
provider: str | None,
model: str | None,
max_tokens: int = 300,
) -> dict | list | None:
"""Call the LLM with a JSON-only prompt and return the parsed result.
Returns ``None`` on any API or parse failure so callers can degrade
gracefully without raising.
"""
client = get_llm_client(
provider=provider or settings.llm_provider,
model=model or settings.llm_model,
)
resp = client.chat(
[{"role": "system", "content": system}, {"role": "user", "content": user}],
max_tokens=max_tokens,
temperature=0.1,
)
if not resp.is_success:
logger.warning("AgenticService LLM call failed: {}", resp.error)
return None
try:
raw = resp.content.strip()
# Strip accidental markdown code fences the model may add.
if raw.startswith("```"):
parts = raw.split("```")
raw = parts[1] if len(parts) > 1 else raw
if raw.startswith("json"):
raw = raw[4:]
return json.loads(raw.strip())
except (json.JSONDecodeError, IndexError) as exc:
logger.debug("AgenticService JSON parse failed: {} | raw={}", exc, resp.content[:200])
return None
def _analyze_intent(
self, query: str, provider: str | None, model: str | None
) -> IntentResult:
"""Classify query intent to select the appropriate retrieval strategy."""
data = self._llm_json(
_INTENT_SYSTEM,
f"Query: {query}",
provider,
model,
max_tokens=settings.agentic_intent_max_tokens,
)
if isinstance(data, dict):
return IntentResult(
type=str(data.get("type", "simple_qa")),
reason=str(data.get("reason", "")),
requires_decomposition=bool(data.get("requires_decomposition", False)),
)
return IntentResult(type="simple_qa", reason="fallback — classifier returned no JSON", requires_decomposition=False)
def _plan_queries(
self, query: str, intent_type: str, provider: str | None, model: str | None
) -> list[str]:
"""Decompose a complex query into focused, independently-retrievable sub-queries."""
data = self._llm_json(
_PLAN_SYSTEM,
f"Original query ({intent_type}): {query}",
provider,
model,
max_tokens=settings.agentic_plan_max_tokens,
)
if isinstance(data, list) and data:
# Cap at configured maximum to keep latency predictable.
return [str(q) for q in data[:settings.agentic_max_sub_queries] if q]
return [query]
def _check_grounding(
self,
query: str,
chunks: list[RetrievedChunk],
provider: str | None,
model: str | None,
) -> GroundingResult:
"""Verify whether retrieved chunks are sufficient to ground an accurate answer.
Uses a fast score-threshold heuristic first; falls back to an LLM call only
when scores are borderline so that the happy-path adds no extra latency.
"""
if not chunks:
return GroundingResult(
sufficient=False,
confidence=0.0,
reason="未检索到相关内容",
refined_query=None,
)
avg_score = sum(c.score for c in chunks) / len(chunks)
# Fast path: high-confidence retrieval → skip extra LLM call.
if avg_score > settings.agentic_grounding_threshold and len(chunks) >= 3:
return GroundingResult(
sufficient=True,
confidence=round(avg_score, 3),
reason="检索置信度充足,无需二次查询",
refined_query=None,
)
# LLM-based grounding check for borderline retrievals.
context_preview = "\n".join(
f"[{i + 1}] (score={c.score:.2f}) {c.text[:200]}" for i, c in enumerate(chunks[:5])
)
data = self._llm_json(
_GROUNDING_SYSTEM,
f"Query: {query}\n\nRetrieved passages:\n{context_preview}",
provider,
model,
max_tokens=settings.agentic_grounding_max_tokens,
)
if isinstance(data, dict):
return GroundingResult(
sufficient=bool(data.get("sufficient", True)),
confidence=float(data.get("confidence", 0.5)),
reason=str(data.get("reason", "")),
refined_query=data.get("refined_query") or None,
)
return GroundingResult(sufficient=True, confidence=0.5, reason="grounding check skipped (parse error)", refined_query=None)
@staticmethod
def _intent_to_template(intent_type: str) -> str:
"""Map an intent type to the best prompt template name for answer generation."""
mapping = {
"compare": "comparison",
"multi_hop": "compliance_qa",
"simple_qa": "compliance_qa",
"ambiguous": "compliance_qa",
}
return mapping.get(intent_type, "compliance_qa")
@staticmethod
def _deduplicate(chunks: list[RetrievedChunk], max_chunks: int) -> list[RetrievedChunk]:
"""Remove duplicate chunk IDs, preserving first-occurrence order up to max_chunks."""
seen: set[str] = set()
result: list[RetrievedChunk] = []
for chunk in chunks:
if chunk.chunk_id not in seen:
seen.add(chunk.chunk_id)
result.append(chunk)
if len(result) >= max_chunks:
break
return result
# ── Public interface ──────────────────────────────────────────────────────
def stream_agentic_chat(
self,
*,
query: str,
session_id: str | None = None,
filters: str | None = None,
provider: str | None = None,
model: str | None = None,
top_k: int = 5,
context_text: str | None = None,
context_filename: str | None = None,
) -> tuple[str, Generator[dict, None, None]]:
"""Run the full Agentic RAG pipeline and return ``(session_id, event_generator)``.
When context_text is provided (user-attached document) it is:
- Summarised and prepended to the intent-analysis prompt so the classifier
understands what kind of question is being asked.
- Treated as baseline grounding so the pipeline skips unnecessary retries
when the document itself is the primary source.
- Passed to the answer generator so the LLM sees the full document alongside
retrieved regulation chunks.
The generator yields SSE event dicts compatible with the route's
``iter_in_thread`` pattern.
"""
session = self.conversation_store.get_session(session_id) if session_id else None
if session is None:
session = self.conversation_store.create_session()
self.conversation_store.save_message(session.session_id, role="user", content=query)
history = [{"role": msg.role, "content": msg.content} for msg in session.messages[-10:]]
active_session_id = session.session_id
# Build a brief document summary for classifier/planner prompts (avoid
# passing the full text which could overwhelm small-context LLMs).
_doc_summary: str = ""
if context_text and context_text.strip():
_doc_label = context_filename or "document"
_preview = context_text.strip()[:400]
_doc_summary = f"[User has attached document: {_doc_label}]\nDocument preview: {_preview}\n\n"
def event_stream() -> Generator[dict, None, None]:
"""Execute all pipeline steps and yield SSE events."""
# ── Step 1: Intent Analysis ──────────────────────────────────────
yield {"event": "thinking", "data": {"step": "intent_analysis", "status": "running"}}
# Prepend doc summary so the classifier knows what the user is asking about
intent_user_msg = f"{_doc_summary}Query: {query}" if _doc_summary else f"Query: {query}"
data = self._llm_json(
_INTENT_SYSTEM, intent_user_msg, provider, model,
max_tokens=settings.agentic_intent_max_tokens,
)
if isinstance(data, dict):
intent = IntentResult(
type=str(data.get("type", "simple_qa")),
reason=str(data.get("reason", "")),
requires_decomposition=bool(data.get("requires_decomposition", False)),
)
else:
intent = IntentResult(type="simple_qa", reason="fallback", requires_decomposition=False)
logger.debug("Agentic intent: type={} decompose={}", intent.type, intent.requires_decomposition)
yield {
"event": "thinking",
"data": {
"step": "intent_analysis",
"status": "done",
"intent_type": intent.type,
"reason": intent.reason,
"requires_decomposition": intent.requires_decomposition,
},
}
# ── Step 2: Query Planning ───────────────────────────────────────
sub_queries: list[str] = [query]
if intent.requires_decomposition:
yield {"event": "thinking", "data": {"step": "query_planning", "status": "running"}}
plan_user_msg = f"{_doc_summary}Original query ({intent.type}): {query}" if _doc_summary else f"Original query ({intent.type}): {query}"
data_plan = self._llm_json(
_PLAN_SYSTEM, plan_user_msg, provider, model,
max_tokens=settings.agentic_plan_max_tokens,
)
if isinstance(data_plan, list) and data_plan:
sub_queries = [str(q) for q in data_plan[:settings.agentic_max_sub_queries] if q]
logger.debug("Agentic sub-queries ({}): {}", len(sub_queries), sub_queries)
yield {
"event": "thinking",
"data": {"step": "query_planning", "status": "done", "sub_queries": sub_queries},
}
# ── Step 3: Iterative Retrieval ──────────────────────────────────
# Always retrieve using the user's original question (NOT the document
# text) so embedding quality is preserved for regulation matching.
# HyDE enriches the retrieval query with a short hypothetical answer
# to close the vocabulary gap between terse queries and long documents.
candidate_k = max(top_k * 3, 15)
all_chunks: list[RetrievedChunk] = []
# For simple_qa with a single query, HyDE gives the biggest benefit
# (bridging vague/colloquial questions to formal document language).
# For compare/multi_hop, the planner already decomposed into precise
# sub-queries, so HyDE is less critical but still applied per sub-query.
for idx, sq in enumerate(sub_queries, start=1):
yield {
"event": "thinking",
"data": {"step": "retrieving", "status": "running", "query": sq, "index": idx, "total": len(sub_queries)},
}
# HyDE expansion: generate hypothetical answer, embed it for retrieval.
# Falls back to original sub-query if LLM call fails.
retrieval_query = self._hyde.expand(sq)
chunks = self.retrieval_service.retrieve(query=retrieval_query, top_k=candidate_k, filters=filters)
all_chunks.extend(chunks)
yield {
"event": "thinking",
"data": {"step": "retrieving", "status": "done", "query": sq, "index": idx, "total": len(sub_queries), "found": len(chunks)},
}
unique_chunks = self._deduplicate(all_chunks, max_chunks=top_k * 4)
# ── Step 4: Grounding Check ──────────────────────────────────────
yield {"event": "thinking", "data": {"step": "grounding_check", "status": "running"}}
# When the user has attached a document, the document itself provides
# baseline grounding — skip the re-query loop to avoid the LLM asking
# "please provide the document text" as a refined query.
if context_text and context_text.strip():
grounding = GroundingResult(
sufficient=True,
confidence=0.95,
reason="用户已附件上传文档,以文档内容为基础作答",
refined_query=None,
)
else:
grounding = self._check_grounding(query, unique_chunks, provider, model)
yield {
"event": "thinking",
"data": {
"step": "grounding_check",
"status": "done",
"sufficient": grounding.sufficient,
"confidence": grounding.confidence,
"reason": grounding.reason,
},
}
# Only retry from vector store when no document is attached and grounding failed
if not grounding.sufficient and grounding.refined_query and not context_text:
logger.info("Grounding insufficient — re-querying: {}", grounding.refined_query)
yield {
"event": "thinking",
"data": {"step": "retrieving", "status": "running", "query": grounding.refined_query, "index": 1, "total": 1, "retry": True},
}
# Apply HyDE to the refined query as well for better retrieval.
refined_hyde_query = self._hyde.expand(grounding.refined_query)
refined_chunks = self.retrieval_service.retrieve(query=refined_hyde_query, top_k=candidate_k, filters=filters)
all_chunks.extend(refined_chunks)
unique_chunks = self._deduplicate(all_chunks, max_chunks=top_k * 4)
yield {
"event": "thinking",
"data": {"step": "retrieving", "status": "done", "query": grounding.refined_query, "index": 1, "total": 1, "found": len(refined_chunks), "retry": True},
}
final_chunks = unique_chunks[:top_k]
# ── Step 5: Answer Generation ────────────────────────────────────
sources_payload = [s.__dict__ for s in self.answer_generator._sources(final_chunks)]
yield {"event": "sources", "data": sources_payload}
answer_parts: list[str] = []
for event in self.answer_generator.stream_generate(
query=query,
retrieved_chunks=final_chunks,
history=history,
provider=provider,
model=model,
prompt_template=self._intent_to_template(intent.type),
context_text=context_text,
context_filename=context_filename,
):
if event.get("event") == "content":
answer_parts.append(str(event.get("data", "")))
yield event
full_answer = "".join(answer_parts)
self.conversation_store.save_message(
active_session_id,
role="assistant",
content=full_answer,
sources=sources_payload,
)
return active_session_id, event_stream()
@@ -0,0 +1,105 @@
"""Implement HyDE (Hypothetical Document Embeddings) query expansion.
HyDE improves dense retrieval by addressing the vocabulary gap between
short user queries and longer document passages:
User query → [LLM generates hypothetical answer]
embed hypothetical answer (not original query)
retrieve similar real passages from Milvus
The hypothetical answer uses the same vocabulary and phrasing as documents,
so its embedding is much closer to relevant chunks than a terse query embedding.
Usage:
expander = HyDEExpander()
retrieval_query = expander.expand(query, provider=..., model=...)
chunks = retrieval_service.retrieve(query=retrieval_query, ...)
When the LLM call fails, expand() falls back to the original query so the
retrieval pipeline degrades gracefully.
References:
Gao et al. (2022), "Precise Zero-Shot Dense Retrieval without Relevance Labels"
https://arxiv.org/abs/2212.10496
"""
from __future__ import annotations
from loguru import logger
from app.config.settings import settings
from app.services.llm.llm_factory import get_llm_client
# Maximum chars to trim from the hypothetical answer to avoid token overrun.
_MAX_HYPOTHESIS_CHARS = 600
# System prompt that instructs the LLM to write a passage *as if* it were
# from a regulatory document, not a conversation answer.
_HYDE_SYSTEM = (
"你是一位法规知识库专家。用户提出了一个问题,"
"请用50-120字写一段话,模拟如果相关法规文档中存在完美答案,"
"该段落会是什么内容。\n\n"
"要求:\n"
"- 使用与法规文档相同的正式书面语气\n"
"- 包含可能的条款编号、标准名称等关键术语\n"
"- 不要解释你在做什么,直接输出假设性段落\n"
"- 如问题过于模糊,写一段合理的通用法规说明"
)
class HyDEExpander:
"""Generate a hypothetical document passage to improve dense retrieval.
The expander is stateless — instantiate once and call expand() per query.
It requires no external dependencies beyond the project's existing LLM
client infrastructure.
"""
def expand(self, query: str) -> str:
"""Return a combined retrieval query: original query + hypothetical passage.
The combination ensures:
- Dense retrieval uses the enriched hypothetical text (semantic match).
- BM25 retrieval still benefits from the original query keywords.
The model used is ``settings.hyde_llm_model`` (dedicated lightweight model)
falling back to the main ``settings.llm_model`` when not configured.
If the LLM call fails for any reason, returns the original query unchanged.
"""
if not settings.hyde_enabled:
return query
# Use the dedicated HyDE model when configured; fall back to main LLM.
# A lightweight model (e.g. qwen3.6-flash) is sufficient for generating
# a short hypothetical passage and significantly reduces cost + latency.
provider = settings.hyde_llm_provider or settings.llm_provider
model = settings.hyde_llm_model or settings.llm_model
try:
client = get_llm_client(provider=provider, model=model)
resp = client.chat(
messages=[
{"role": "system", "content": _HYDE_SYSTEM},
{"role": "user", "content": f"问题:{query}"},
],
max_tokens=settings.hyde_max_tokens,
# Low temperature: we want a plausible, deterministic passage.
temperature=0.3,
)
if not resp.is_success or not resp.content:
logger.debug("HyDE LLM call failed or empty — using original query")
return query
hypothesis = resp.content.strip()[:_MAX_HYPOTHESIS_CHARS]
logger.debug("HyDE expanded query ({}{} chars)", len(query), len(hypothesis))
# Concatenate: the embedding model will see the full combined text,
# so the resulting vector leans toward the hypothetical document style.
return f"{query}\n\n{hypothesis}"
except Exception as exc: # noqa: BLE001 — intentional broad catch for graceful fallback
logger.warning("HyDE expansion failed: {} — using original query", exc)
return query
+20 -2
View File
@@ -9,6 +9,7 @@ from app.domain.conversation import AnswerGenerator, AnswerResult, ConversationS
from app.domain.retrieval import RetrievedChunk
from app.application.knowledge import KnowledgeRetrievalService
from app.application.agent.hyde_expander import HyDEExpander
# Keep orchestration logic centralized so use-case flow stays easy to trace.
@@ -26,6 +27,8 @@ class AgentConversationService:
self.retrieval_service = retrieval_service
self.answer_generator = answer_generator
self.conversation_store = conversation_store
# Shared HyDE expander — stateless, safe for reuse across requests.
self._hyde = HyDEExpander()
def ask(
self,
@@ -108,14 +111,26 @@ class AgentConversationService:
model: str | None = None,
top_k: int = 5,
prompt_template: str | None = None,
context_text: str | None = None,
context_filename: str | None = None,
) -> tuple[str, Generator[dict, None, None]]:
"""Stream chat for the Agent Conversation Service instance."""
"""Stream chat for the Agent Conversation Service instance.
When context_text is provided the user's document is passed directly to
the answer generator — RAG retrieval still runs on the user's question
(not the document text) to find relevant regulation passages.
"""
session = self.conversation_store.get_session(session_id) if session_id else None
if session is None:
session = self.conversation_store.create_session()
self.conversation_store.save_message(session.session_id, role="user", content=query)
history = [{"role": msg.role, "content": msg.content} for msg in session.messages[-10:]]
retrieved = self.retrieval_service.retrieve(query=query, top_k=top_k, filters=filters)
# HyDE: expand the query with a hypothetical answer to improve dense retrieval.
# For document-context queries, skip HyDE since the document itself guides retrieval.
retrieval_query = self._hyde.expand(query) if not context_text else query
# Retrieve using the enriched query — NOT the document text —
# so embedding quality is preserved for regulation chunk matching.
retrieved = self.retrieval_service.retrieve(query=retrieval_query, top_k=top_k, filters=filters)
def event_stream() -> Generator[dict, None, None]:
"""Handle event stream for the Agent Conversation Service instance."""
@@ -129,6 +144,8 @@ class AgentConversationService:
provider=provider,
model=model,
prompt_template=prompt_template,
context_text=context_text,
context_filename=context_filename,
):
if event.get("event") == "sources":
sources_payload = event.get("data", [])
@@ -189,3 +206,4 @@ class AgentSessionService:
raise ValueError("消息索引不存在")
# Preserve the existing API behavior until a persistent feedback store is introduced.
return AgentSessionFeedbackResult(session_id=session_id, message_index=message_index)
+245 -45
View File
@@ -51,19 +51,36 @@ def _extract_json(text: str):
def extract_text_from_doc_id(doc_id: str) -> str:
"""Fetch the full text of a document by retrieving its chunks filtered by doc_id.
Uses a high top_k and doc_id filter to reconstruct the document in chunk order,
avoiding the previous approach of semantic search by doc_name which could return
chunks from unrelated documents.
"""
from app.shared.bootstrap import get_document_query_service, get_retrieval_service
doc = get_document_query_service().get(doc_id)
if not doc:
raise ValueError(f"Document '{doc_id}' not found")
service = get_retrieval_service()
chunks = service.retrieve(query=doc.doc_name, top_k=30)
doc_chunks = [c for c in chunks if c.doc_id == doc_id]
# Use doc_name as a broad query, filter strictly by doc_id so we only get
# this document's chunks; top_k=100 covers most real-world documents.
chunks = service.retrieve(query=doc.doc_name, top_k=100, filters=doc_id)
doc_chunks = [c for c in chunks if getattr(c, "doc_id", None) == doc_id]
if not doc_chunks:
doc_chunks = chunks[:15]
return "\n\n".join(c.text for c in doc_chunks[:15])
# Fallback: use top results even without doc_id match (e.g., legacy store)
doc_chunks = chunks[:30]
# Sort by chunk_index to preserve document reading order
doc_chunks.sort(key=lambda c: getattr(c, "chunk_index", 0))
return "\n\n".join(c.text for c in doc_chunks[:40])
def extract_text_from_file(content: bytes, filename: str) -> str:
"""Parse an uploaded file and return its full text content.
Removed previous 4000-char cap so large specifications and standards are
fully analysed. The caller is responsible for splitting the text into
clause-sized chunks before passing to the LLM.
"""
from app.shared.bootstrap import get_document_command_service
suffix = os.path.splitext(filename or "doc.pdf")[1] or ".pdf"
tmp_path = ""
@@ -74,10 +91,11 @@ def extract_text_from_file(content: bytes, filename: str) -> str:
service = get_document_command_service()
parsed = service.parser.parse(file_path=tmp_path, doc_id="tmp_analysis", doc_name=filename)
if parsed.raw_text:
return parsed.raw_text[:4000]
# Return full text — truncation happens in split_into_clauses()
return parsed.raw_text
return "\n".join(
b.get("text", "") for b in parsed.semantic_blocks[:30] if b.get("text")
)[:4000]
b.get("text", "") for b in parsed.semantic_blocks if b.get("text")
)
except Exception as exc:
logger.warning("File text extraction failed: {}", exc)
return ""
@@ -88,27 +106,68 @@ def extract_text_from_file(content: bytes, filename: str) -> str:
def split_into_clauses(text: str, client: "BaseLLMClient") -> list[str]:
"""Split a compliance document into semantically independent clauses.
For long texts (> 2 000 chars) the document is processed in overlapping
2 000-char windows so no content is missed. Each window produces up to 4
clauses; results are deduplicated and capped at 12 total to keep analysis
latency reasonable.
"""
# Window size and step for sliding-window clause extraction
_WINDOW = 2000
_STEP = 1800 # 200-char overlap to avoid cutting clauses at boundaries
_MAX_CLAUSES = 12
windows = []
if len(text) <= _WINDOW:
windows = [text]
else:
pos = 0
while pos < len(text):
windows.append(text[pos: pos + _WINDOW])
pos += _STEP
all_clauses: list[str] = []
for window in windows:
prompt = (
"You are a compliance analysis expert. Split the following text into 3-8 "
"semantically complete compliance clauses. Each clause should be an independent "
"compliance requirement or technical statement.\n"
"You are a compliance analysis expert. Split the following text into "
"3-4 semantically complete compliance clauses. Each clause must be an "
"independent requirement or technical statement. Omit section headings, "
"definitions, and non-normative text.\n"
"Return as JSON array of strings, e.g.:\n"
'["Clause one...", "Clause two..."]\n'
"Return ONLY the JSON array.\n\n"
f"Text:\n{text[:2000]}"
f"Text:\n{window}"
)
response = client.chat([{"role": "user", "content": prompt}], max_tokens=1000)
response = client.chat([{"role": "user", "content": prompt}], max_tokens=800)
if response.is_success:
try:
result = _extract_json(response.content)
if isinstance(result, list):
clauses = [str(c).strip() for c in result if str(c).strip()]
if clauses:
return clauses[:8]
all_clauses.extend(clauses[:4])
except (ValueError, TypeError):
logger.warning("Clause split JSON parse failed, using fallback")
sentences = re.split(r"[.?!;\n]+", text)
return [s.strip() for s in sentences if len(s.strip()) > 20][:6]
logger.warning("Clause split JSON parse failed for window, using sentence fallback")
sentences = re.split(r"[.?!;\n]+", window)
all_clauses.extend(s.strip() for s in sentences if len(s.strip()) > 20)
else:
# LLM unavailable — fall back to sentence splitting for this window
sentences = re.split(r"[.?!;\n]+", window)
all_clauses.extend(s.strip() for s in sentences if len(s.strip()) > 20)
if len(all_clauses) >= _MAX_CLAUSES:
break
# Deduplicate near-duplicates (same first 80 chars) that span window boundaries
seen: set[str] = set()
deduped: list[str] = []
for c in all_clauses:
key = c[:80].lower()
if key not in seen:
seen.add(key)
deduped.append(c)
return deduped[:_MAX_CLAUSES]
def retrieve_for_clause(
@@ -117,7 +176,33 @@ def retrieve_for_clause(
top_k: int = 5,
domains: str | None = None,
) -> list["RetrievedChunk"]:
return retrieval_service.retrieve(query=clause, top_k=top_k, filters=domains)
"""Retrieve regulation chunks relevant to a clause.
If the best retrieval score is below 0.55, rewrite the clause into a more
technical query and retry once to improve coverage.
"""
chunks = retrieval_service.retrieve(query=clause, top_k=top_k, filters=domains)
if not chunks:
return chunks
best_score = max((getattr(c, "score", 0) for c in chunks), default=0)
if best_score < 0.55:
# Rewrite clause as technical keyword query and retry
keywords = " ".join(
w for w in re.split(r"\W+", clause) if len(w) > 3
)[:200]
retry_chunks = retrieval_service.retrieve(query=keywords, top_k=top_k, filters=domains)
if retry_chunks:
# Merge: keep unique chunks, prefer higher-score version
seen_ids: set[str] = {getattr(c, "chunk_id", str(i)) for i, c in enumerate(chunks)}
for rc in retry_chunks:
rid = getattr(rc, "chunk_id", "")
if rid not in seen_ids:
chunks.append(rc)
seen_ids.add(rid)
chunks.sort(key=lambda c: getattr(c, "score", 0), reverse=True)
chunks = chunks[:top_k]
return chunks
def process_single_clause(
@@ -130,14 +215,75 @@ def process_single_clause(
) -> dict:
"""Process one clause: retrieve relevant regulations then check compliance.
Returns a dict with keys: index, chunks, finding (may be None on LLM failure).
Returns a dict with keys:
- index: clause position (for ordering)
- chunks: list of RetrievedChunk (for source events)
- finding: dict with title/desc/status/clause_ref/confidence (may be None on LLM failure)
Designed to run inside asyncio.to_thread() for parallel execution.
The finding now includes a 'source_refs' list linking back to the chunks
that informed the verdict, enabling the frontend to correlate sources with findings.
"""
chunks = retrieve_for_clause(clause, retrieval_service, top_k, domains)
finding = check_clause_compliance(clause, chunks, client)
if finding is not None:
# Attach source references so the frontend can link finding ↔ sources
finding["source_refs"] = [
{
"standard": getattr(c, "doc_title", "") or getattr(c, "doc_name", ""),
"clause": getattr(c, "section_title", "") or "",
"score": round(float(getattr(c, "score", 0)), 3),
}
for c in chunks[:3]
]
return {"index": index, "chunks": chunks, "finding": finding}
async def run_clauses_streaming(
clauses: list[str],
retrieval_service: "KnowledgeRetrievalService",
client: "BaseLLMClient",
top_k: int = 5,
domains: str | None = None,
):
"""Process all clauses concurrently and yield each result as it completes.
Unlike the old gather()-based approach, this uses asyncio.Queue so that
findings are emitted to the SSE stream immediately when each clause
finishes — the user sees results progressively rather than waiting for
the slowest clause before seeing any output.
Yields dicts with keys: index, chunks, finding (same schema as
process_single_clause, plus a sentinel {"_done": True} at the end).
"""
queue: asyncio.Queue[dict] = asyncio.Queue()
total = len(clauses)
async def _worker(clause: str, i: int) -> None:
"""Run one clause in a thread and push the result into the queue."""
try:
result = await asyncio.to_thread(
process_single_clause,
clause, i, retrieval_service, client, top_k, domains,
)
except Exception as exc:
logger.warning("Clause {} processing failed: {}", i, exc)
result = {"index": i, "chunks": [], "finding": None}
await queue.put(result)
# Launch all workers concurrently
tasks = [asyncio.create_task(_worker(clause, i)) for i, clause in enumerate(clauses)]
received = 0
while received < total:
result = await queue.get()
yield result
received += 1
# Wait for all tasks to complete (they should already be done by now)
await asyncio.gather(*tasks, return_exceptions=True)
async def run_clauses_parallel(
clauses: list[str],
retrieval_service: "KnowledgeRetrievalService",
@@ -145,31 +291,15 @@ async def run_clauses_parallel(
top_k: int = 5,
domains: str | None = None,
) -> list[dict]:
"""Run all clauses through retrieve+gap-check in parallel.
"""Legacy batch API kept for backward compatibility.
Results are returned in the original clause order even though processing
is concurrent. Exceptions in individual clauses are caught and returned as
dicts with finding=None so the stream continues for remaining clauses.
Both retrieval_service and client must be thread-safe — they are shared
across all asyncio.to_thread() calls without locking.
Collects all streaming results and returns them sorted by clause index.
New code should use run_clauses_streaming() directly.
"""
tasks = [
asyncio.to_thread(
process_single_clause,
clause, i, retrieval_service, client, top_k, domains,
)
for i, clause in enumerate(clauses)
]
raw = await asyncio.gather(*tasks, return_exceptions=True)
results = []
for i, r in enumerate(raw):
if isinstance(r, Exception):
logger.warning("Clause {} processing failed: {}", i, r)
results.append({"index": i, "chunks": [], "finding": None})
else:
results.append(r)
return results
results: list[dict] = []
async for result in run_clauses_streaming(clauses, retrieval_service, client, top_k, domains):
results.append(result)
return sorted(results, key=lambda r: r["index"])
def check_clause_compliance(
@@ -177,6 +307,15 @@ def check_clause_compliance(
chunks: list["RetrievedChunk"],
client: "BaseLLMClient",
) -> dict | None:
"""Check whether a business clause complies with the retrieved regulations.
The prompt explicitly instructs the LLM to:
- extract clause_ref from the retrieved text (not invent it)
- include a confidence score (0-1) reflecting how well the retrieved
chunks cover the clause topic
Returns None only when the LLM call fails after all retries.
"""
reg_context = "\n".join(
f"[{i+1}] {c.doc_title} {c.section_title or ''}: {c.text[:300]}"
for i, c in enumerate(chunks[:5])
@@ -186,14 +325,17 @@ def check_clause_compliance(
"complies with the retrieved regulations.\n\n"
f"Business clause:\n{clause}\n\n"
f"Retrieved regulations:\n{reg_context}\n\n"
"Return JSON:\n"
"Return JSON with these exact fields:\n"
"{\n"
' "status": "ok" | "warn" | "risk",\n'
' "title": "Short finding title (max 30 chars)",\n'
' "desc": "Description (50-120 chars)",\n'
' "clause_ref": "Regulation clause reference e.g. Art.9.1 or Sec.3.1"\n'
' "clause_ref": "Exact clause/article reference copied from the retrieved text above, '
'e.g. Art.9.1 or Sec.3.1. Use null if no specific clause number appears in the retrieved text.",\n'
' "confidence": 0.0-1.0 // how well the retrieved context covers this clause topic\n'
"}\n"
"status: ok=compliant, warn=gap exists, risk=critical/missing\n"
"IMPORTANT: copy clause_ref verbatim from the retrieved text; do NOT invent references.\n"
"Return ONLY the JSON object."
)
@@ -216,7 +358,10 @@ def check_clause_compliance(
"title": str(result.get("title", "Compliance finding")),
"desc": str(result.get("desc", "")),
"status": result.get("status", "info"),
"clause_ref": result.get("clause_ref"),
# None if LLM correctly found no clause number in retrieved text
"clause_ref": result.get("clause_ref") or None,
# Confidence score helps frontend show retrieval quality indicator
"confidence": float(result.get("confidence", 0.5)),
}
except (ValueError, TypeError) as exc:
logger.warning("Gap check JSON parse failed: {}", exc)
@@ -368,3 +513,58 @@ def generate_suggestions(
except (ValueError, TypeError) as exc:
logger.warning("generate_suggestions JSON parse failed: {}", exc)
return fallback
def detect_cross_clause_conflicts(
findings: list[dict],
client: "BaseLLMClient",
) -> list[dict]:
"""Detect contradictions and missing cross-references across all findings.
Runs a single LLM call after all per-clause findings are collected.
Returns a list of conflict dicts: {type, finding_a, finding_b, desc}.
Returns an empty list on LLM failure so the caller can proceed without it.
"""
if len(findings) < 2:
# Need at least 2 findings to compare
return []
findings_text = "\n".join(
f"[{i+1}] [{f['status'].upper()}] {f['title']}: {f['desc']}"
+ (f" (Ref: {f['clause_ref']})" if f.get("clause_ref") else "")
for i, f in enumerate(findings)
)
prompt = (
"You are a compliance expert. Review the following compliance findings from the same document "
"and identify any cross-clause issues:\n\n"
f"Findings:\n{findings_text}\n\n"
"Return JSON array of conflicts (empty array [] if none found):\n"
"[\n"
" {\n"
' "type": "contradiction" | "missing_ref" | "cumulative_risk",\n'
' "finding_a": <1-based index>,\n'
' "finding_b": <1-based index or null>,\n'
' "desc": "Brief description of the cross-clause issue (max 100 chars)"\n'
" }\n"
"]\n"
"Return ONLY the JSON array."
)
try:
response = client.chat([{"role": "user", "content": prompt}], max_tokens=600)
if not response.is_success:
return []
result = _extract_json(response.content)
if isinstance(result, list):
return [
{
"type": str(c.get("type", "contradiction")),
"finding_a": int(c.get("finding_a", 0)),
"finding_b": c.get("finding_b"),
"desc": str(c.get("desc", "")),
}
for c in result
if isinstance(c, dict)
]
except Exception as exc:
logger.warning("detect_cross_clause_conflicts failed: {}", exc)
return []
+81 -6
View File
@@ -526,10 +526,28 @@ class DocumentCommandService:
logger.warning("临时文件清理失败: {}", temp_path)
def delete(self, doc_id: str) -> bool:
"""Delete document record, binary file, and vector chunks."""
"""Delete document record, binary file, and vector chunks.
Handles two cases:
- Normal docs: have a metadata record in the document repository.
- Milvus-only (synthetic) docs: visible in management-list because they
have Milvus vectors but no JSON/PG metadata record. We still clean up
the Milvus chunks so the document disappears from the list.
"""
document = self.document_repository.get(doc_id)
if not document:
# No metadata record — might be a Milvus-only synthetic document.
# Attempt vector cleanup directly; treat as success if any chunks deleted.
try:
deleted_count = self.vector_index.delete_by_document(doc_id)
if deleted_count > 0:
logger.info("Deleted Milvus-only doc (no metadata record): doc_id={} chunks={}", doc_id, deleted_count)
return True
except Exception as exc:
logger.warning("Milvus-only delete failed for doc_id={}: {}", doc_id, exc)
return False
# Normal doc: clean up binary, vectors, artifacts, processing records, metadata.
try:
self.binary_store.delete(document.object_name)
except Exception:
@@ -627,13 +645,16 @@ class DocumentQueryService:
result.append(doc)
# Surface Milvus-only docs that have no metadata record at all.
# MinIO almost certainly has their binaries (they were uploaded), so
# set object_name to the sentinel "{doc_id}/" so the route marks
# has_file=True; the download endpoint will list MinIO to find the file.
for doc_id, row in milvus_by_id.items():
if doc_id not in meta_by_id:
synthetic = Document(
doc_id=doc_id,
doc_name=row.get("doc_title", doc_id),
file_name=row.get("doc_title", doc_id),
object_name="",
object_name=f"{doc_id}/", # sentinel: MinIO prefix exists
content_type="",
size_bytes=0,
status=DocumentStatus.INDEXED,
@@ -646,9 +667,63 @@ class DocumentQueryService:
result.sort(key=lambda d: d.updated_at, reverse=True)
return result[:limit] if limit is not None else result
def download(self, doc_id: str) -> tuple[Document, bytes]:
"""Handle download for the Document Query Service instance."""
def download(self, doc_id: str) -> tuple["Document", bytes]:
"""Return the document record and its binary content from MinIO.
Fallback strategy for Milvus-only docs (no JSON/PG metadata record):
1. Try metadata repository first (normal path).
2. If metadata is missing, list MinIO objects with prefix ``{doc_id}/``
and synthesise a minimal Document from the first object found.
This handles documents whose metadata records were lost but whose
binary files are still in object storage.
3. If neither source has the file, raise FileNotFoundError.
"""
from app.domain.documents import Document, DocumentStatus
document = self.document_repository.get(doc_id)
if not document:
raise FileNotFoundError(f"文档不存在: {doc_id}")
if document and document.object_name and not document.object_name.endswith("/"):
# Normal doc with a concrete object_name — read directly.
return document, self.binary_store.read(document.object_name)
if document and not document.object_name:
raise FileNotFoundError(f"该文档无原始文件(仅含索引数据,无法下载): {doc_id}")
if not document or document.object_name.endswith("/"):
# Metadata missing — try to find the file in MinIO by doc_id prefix.
try:
objects = self.binary_store.list_objects(prefix=f"{doc_id}/")
# Filter out artifact JSON files; prefer the source document.
candidates = [o for o in objects if not o.endswith(".json")]
if not candidates:
candidates = objects # fall back to all objects if only JSON found
if not candidates:
raise FileNotFoundError(f"文档不存在(MinIO 和元数据均无记录): {doc_id}")
object_name = candidates[0]
file_name = object_name.split("/", 1)[-1] if "/" in object_name else object_name
# Guess content type from extension.
ext = file_name.rsplit(".", 1)[-1].lower() if "." in file_name else ""
_ct_map = {
"pdf": "application/pdf",
"docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"doc": "application/msword",
"txt": "text/plain",
}
content_type = _ct_map.get(ext, "application/octet-stream")
# Synthesise a minimal Document so the route can build the response.
document = Document(
doc_id=doc_id,
doc_name=file_name,
file_name=file_name,
object_name=object_name,
content_type=content_type,
size_bytes=0,
status=DocumentStatus.INDEXED,
)
logger.info("MinIO fallback download: doc_id={} object={}", doc_id, object_name)
except FileNotFoundError:
raise
except Exception as exc:
raise FileNotFoundError(f"文档不存在: {doc_id}") from exc
return document, self.binary_store.read(document.object_name)
@@ -7,9 +7,13 @@ from typing import Any, Generator
from loguru import logger
from app.config.settings import settings
from app.domain.documents import ParsedDocument
from app.infrastructure.perception.base_event_store import BaseEventStore
from app.infrastructure.perception.base_notification_store import BaseNotificationStore
from app.infrastructure.perception.crawlers.base import BaseCrawler, RawEvent
from app.infrastructure.perception.llm_pipeline import LlmPipeline
from app.infrastructure.parser.local_chunk_builder import LocalRegulationChunkBuilder
def _event_id(source: str, standard_code: str) -> str:
@@ -21,7 +25,68 @@ def _content_hash(raw_text: str) -> str:
return hashlib.sha256(raw_text.encode()).hexdigest()
def _raw_to_dict(raw: RawEvent, event_id: str, content_hash: str) -> dict:
def _is_significant(changed_sections: list[dict]) -> bool:
"""Report whether any changed section is worth notifying every user about.
changed_sections legitimately includes cosmetic edits — the differ
(subproject 1) still reports a fixed typo or a dropped trailing period as
a change, it just doesn't send those to the LLM. Broadcasting a
notification for every cosmetic edit would train people to ignore it, so
this reuses the same significance test the differ's own LLM gate applies:
a numeric or deontic change, or a whole paragraph added or removed.
"""
return any(
section.get("numeric_changed")
or section.get("deontic_changed")
or section.get("change_type") in ("added", "removed")
for section in changed_sections
)
def _index_in_knowledge_base(event: dict, *, embedding_provider: Any, vector_index: Any) -> None:
"""Chunk, embed, and upsert a regulation's text into the shared knowledge base.
Always uses the local markdown chunker, never get_chunk_builder() — that
bootstrap function resolves to AliyunVectorChunkBuilder when
settings.chunk_backend == "aliyun" (the deployed value), which consumes
Aliyun DocMind's structured parse output. Crawled text has no such parse
output; it is already plain text (trafilatura, subproject 1), which is
exactly what LocalRegulationChunkBuilder chunks directly.
delete_by_document runs unconditionally before upsert — a no-op for a
brand-new event, and the only way to keep a changed regulation from
leaving its superseded text retrievable alongside the new version.
"""
vector_index.delete_by_document(event["id"])
parsed = ParsedDocument(
doc_id=event["id"],
doc_name=event.get("title", ""),
structure_nodes=[],
semantic_blocks=[],
vector_chunks=[],
parser_name="perception_crawl",
raw_text=event.get("raw_text") or "",
)
builder = LocalRegulationChunkBuilder(
chunk_size=settings.chunk_size, chunk_overlap=settings.chunk_overlap,
)
chunks = builder.build(
parsed_document=parsed,
# regulation_type/version fill the same slots a manually uploaded
# document's form fields would, so the two intake paths are
# indistinguishable to retrieval and compliance analysis.
regulation_type=event.get("category", ""),
version=event.get("standard_code", ""),
)
if not chunks:
return
vectors = embedding_provider.embed_texts([c.embedding_text for c in chunks])
vector_index.upsert(chunks, vectors)
def _raw_to_dict(raw: RawEvent, event_id: str, content_hash: str, raw_text: str) -> dict:
return {
"id": event_id,
"source": raw.source,
@@ -36,6 +101,10 @@ def _raw_to_dict(raw: RawEvent, event_id: str, content_hash: str) -> dict:
"effective_at": raw.effective_at,
"category": raw.category,
"tags": raw.tags,
# Persisted so the next crawl has a baseline to diff against. Without
# this the change detector has nothing to compare and every update
# looks like a first sighting.
"raw_text": raw_text,
"content_hash": content_hash,
"previous_hash": None,
}
@@ -50,11 +119,17 @@ class CrawlService:
event_store: BaseEventStore,
llm_pipeline: LlmPipeline,
retrieval_service: Any,
notification_store: BaseNotificationStore,
embedding_provider: Any,
vector_index: Any,
) -> None:
self._crawlers = crawlers
self._store = event_store
self._pipeline = llm_pipeline
self._retrieval = retrieval_service
self._notifications = notification_store
self._embedding_provider = embedding_provider
self._vector_index = vector_index
def run_crawl(
self, sources: list[str] | None = None
@@ -72,7 +147,7 @@ class CrawlService:
yield {"event": "progress", "data": {"source": source_key, "stage": "fetching"}}
try:
raw_events = crawler.fetch(limit=100)
raw_events = crawler.fetch(limit=settings.perception_max_events_per_source)
except Exception as exc:
logger.exception("Crawler failed source={}", source_key)
yield {"event": "error", "data": {"source": source_key, "message": str(exc)}}
@@ -88,17 +163,21 @@ class CrawlService:
for raw in raw_events:
eid = _event_id(raw.source, raw.standard_code)
new_hash = _content_hash(raw.raw_text or raw.title)
# List pages carry only a code and a title, which is not enough
# to detect a change in the regulation itself. Fetch the body,
# degrading to whatever the list page gave us if that fails.
body_text = crawler.fetch_full_text(raw.full_text_url) or raw.raw_text or raw.title
new_hash = _content_hash(body_text)
existing = self._store.get(eid)
if existing and existing.get("content_hash") == new_hash:
continue
is_update = existing is not None
old_text = existing.get("summary", "") if is_update else ""
old_body = existing.get("raw_text") or "" if is_update else ""
previous_hash = existing.get("content_hash") if is_update else None
event_dict = _raw_to_dict(raw, eid, new_hash)
event_dict = _raw_to_dict(raw, eid, new_hash, body_text)
event_dict["previous_hash"] = previous_hash
try:
@@ -113,9 +192,11 @@ class CrawlService:
except Exception as exc:
logger.warning("Impact assessment failed id={} err={}", eid, exc)
if is_update and old_text and raw.raw_text:
# Events stored before raw_text was persisted have no baseline,
# so they are treated as a first sighting and establish one now.
if is_update and old_body and body_text:
try:
diff = self._pipeline.compute_diff(old_text, raw.raw_text)
diff = self._pipeline.compute_diff(old_body, body_text)
event_dict["change_summary"] = diff.get("change_summary")
event_dict["changed_sections"] = diff.get("changed_sections")
except Exception as exc:
@@ -123,6 +204,33 @@ class CrawlService:
self._store.upsert(event_dict)
should_index = not is_update or _is_significant(event_dict.get("changed_sections") or [])
try:
if not is_update:
self._notifications.create(
event_id=eid, kind="new", title=raw.title,
impact_level=event_dict.get("impact_level"), summary=None,
)
elif should_index: # significant change, already computed above
self._notifications.create(
event_id=eid, kind="changed", title=raw.title,
impact_level=event_dict.get("impact_level"),
summary=event_dict.get("change_summary"),
)
except Exception as exc:
logger.warning("Notification create failed id={} err={}", eid, exc)
if should_index:
try:
_index_in_knowledge_base(
event_dict,
embedding_provider=self._embedding_provider,
vector_index=self._vector_index,
)
except Exception as exc:
logger.warning("Knowledge base indexing failed id={} err={}", eid, exc)
if is_update:
updated_count += 1
else:
+78 -4
View File
@@ -94,9 +94,21 @@ class Settings(BaseSettings):
perception_max_events_per_source: int = Field(
default=100, description="Maximum events fetched per source per crawl run."
)
perception_diff_similarity_threshold: float = Field(
default=0.85,
description="Cosine similarity below which a paragraph is flagged as changed.",
perception_diff_min_change_ratio: float = Field(
default=0.02,
description=(
"Fraction of characters that must differ before an otherwise "
"unremarkable paragraph edit is worth an LLM classification call. "
"Numeric and deontic changes bypass this gate entirely."
),
)
perception_crawl_interval_seconds: int = Field(
default=21600,
description=(
"How often Celery Beat runs the scheduled crawl-all-sources task, "
"in seconds. Default 21600 = 6 hours. Only takes effect when a "
"Beat process is running (./dev.sh start beat)."
),
)
# Keep configuration setup explicit so runtime behavior is easy to reason about.
@@ -117,7 +129,7 @@ class Settings(BaseSettings):
# Keep configuration setup explicit so runtime behavior is easy to reason about.
qwen_api_key: str = Field(default="", description="Qwen API密钥")
qwen_base_url: str = Field(default="http://6.86.80.4:30080/v1", description="Qwen API地址")
qwen_model: str = Field(default="qwen3.5-flash", description="Qwen文本模型")
qwen_model: str = Field(default="qwen3.6-flash", description="Qwen文本模型")
qwen_vl_model: str = Field(default="qwen3-vl-plus", description="Qwen视觉模型")
# Keep configuration setup explicit so runtime behavior is easy to reason about.
@@ -133,6 +145,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参数")
@@ -162,6 +210,32 @@ class Settings(BaseSettings):
description="Comma-separated allowed CORS origins. Never use * in production.",
)
# ── MCP ───────────────────────────────────────────────────────────────────
# The MCP SDK enables DNS-rebinding protection whenever the transport is
# bound to a loopback host, which rejects any Host header not in this list
# with HTTP 421. Deployments reachable by a real hostname/IP must list it
# here or every remote MCP client is refused before the handler runs.
mcp_allowed_hosts: str = Field(
default="127.0.0.1:*,localhost:*,[::1]:*",
description=(
"Comma-separated Host header values accepted by the MCP endpoint. "
"A ':*' suffix matches any port. Set to '*' to disable DNS-rebinding "
"protection entirely (not recommended)."
),
)
# Optional override for the URL shown on the System Status page and copied
# into client configs. Needed because request.base_url reflects the Host
# header, which the Vite dev proxy (changeOrigin: true) and reverse proxies
# that do not forward the original Host both rewrite.
mcp_public_url: str = Field(
default="",
description=(
"Externally reachable MCP endpoint URL, e.g. http://6.86.80.9:8000/mcp/. "
"Leave empty to derive it from the incoming request."
),
)
@lru_cache
def get_settings() -> Settings:
"""Return settings."""
@@ -3,11 +3,13 @@
from __future__ import annotations
import os
import time
import httpx
from app.config.settings import settings
from app.domain.retrieval import EmbeddingProvider
from app.shared.model_usage_tracker import get_model_usage_tracker
# Keep adapter behavior explicit so integration details remain easy to audit.
EMBEDDING_BATCH_SIZE = 8
@@ -45,6 +47,8 @@ class OpenAICompatibleEmbeddingProvider(EmbeddingProvider):
"""Handle request for this module for the Open A I Compatible Embedding Provider instance."""
if not self.api_key:
raise ValueError("缺少 EMBEDDING_API_KEY / OPENAI_API_KEY")
start = time.time()
try:
response = httpx.post(
f"{self.base_url}/embeddings",
headers={
@@ -56,9 +60,28 @@ class OpenAICompatibleEmbeddingProvider(EmbeddingProvider):
)
self._raise_for_status(response, batch_size=len(texts))
data = response.json()
except Exception as exc:
# Record the failed call so the Status page can show it as an error,
# then re-raise unchanged so existing callers keep their current behavior.
get_model_usage_tracker().record(
provider="embedding",
model=self.model,
success=False,
latency_ms=int((time.time() - start) * 1000),
error=str(exc),
)
raise
vectors = [item["embedding"] for item in sorted(data.get("data", []), key=lambda item: item["index"])]
if any(len(vector) != self.dimension for vector in vectors):
raise ValueError(f"embedding 维度不匹配,期望 {self.dimension}")
# Record token usage from the OpenAI-compatible response, e.g. {"total_tokens": N}.
get_model_usage_tracker().record(
provider="embedding",
model=self.model,
success=True,
usage=data.get("usage", {}),
latency_ms=int((time.time() - start) * 1000),
)
return vectors
def embed_texts(self, texts: list[str]) -> list[list[float]]:
@@ -9,10 +9,12 @@ from app.config.settings import settings
from app.domain.conversation import AnswerGenerator, AnswerResult, AnswerSource
from app.domain.retrieval import RetrievedChunk
from app.services.llm.llm_factory import get_llm_client
from app.services.rag.prompt_templates import PromptTemplates
# Keep adapter behavior explicit so integration details remain easy to audit.
PROMPT_TEMPLATES = {
# Fallback system prompts used when no rich template matches.
_FALLBACK_PROMPTS = {
"default": "你是法规知识问答助手。请仅依据提供的上下文回答;如果上下文不足,明确说明。",
"compliance_qa": "你是法规合规问答助手。优先引用给定法规原文,回答要准确、克制,并注明依据来源。",
}
@@ -38,33 +40,80 @@ class OpenAICompatibleAnswerGenerator(AnswerGenerator):
retrieved_chunks: list[RetrievedChunk],
history: list[dict[str, str]] | None,
prompt_template: str | None,
context_text: str | None = None,
context_filename: str | None = None,
) -> tuple[list[dict[str, str]], int]:
"""Handle build messages for this module for the Open A I Compatible Answer Generator instance."""
system_prompt = PROMPT_TEMPLATES.get(prompt_template or "compliance_qa", PROMPT_TEMPLATES["default"])
"""Build the message list to send to the LLM.
When context_text is provided the user's document is injected as a
dedicated section BEFORE the retrieved regulation chunks so the LLM
can reason about the document directly while still referencing regulations.
The retrieval step uses only the user's question, not the document text,
so embedding quality is preserved.
System prompt selection priority:
1. Rich template from PromptTemplates (compliance_qa / comparison /
compliance_check / clause_interpretation / …)
2. Fallback hardcoded prompt when no rich template matches.
"""
# Look up the rich template first; fall back to simple hardcoded prompts.
tpl_name = prompt_template or "compliance_qa"
rich_tpl = PromptTemplates.get_template(tpl_name)
if rich_tpl:
system_prompt = rich_tpl.system_prompt
else:
system_prompt = _FALLBACK_PROMPTS.get(tpl_name, _FALLBACK_PROMPTS["default"])
context_blocks = []
context_tokens = 0
# ── User document context (if attached) ───────────────────────────────
if context_text and context_text.strip():
doc_label = f"附件文档:{context_filename}" if context_filename else "附件文档"
doc_block = f"[{doc_label}]\n{context_text.strip()}"
doc_tokens = self._estimate_tokens(doc_block)
# Reserve at most half the context budget for the user document
half_budget = settings.rag_max_context_tokens // 2
if doc_tokens > half_budget:
# Truncate document to fit half the budget
ratio = half_budget / doc_tokens
doc_block = doc_block[: int(len(doc_block) * ratio)] + "\n…(文档已截断)"
doc_tokens = half_budget
context_blocks.append(doc_block)
context_tokens += doc_tokens
# ── Retrieved regulation chunks ────────────────────────────────────────
remaining_budget = settings.rag_max_context_tokens - context_tokens
for idx, chunk in enumerate(retrieved_chunks, start=1):
block = (
f"[{idx}] 文档: {chunk.doc_title}\n"
f"[法规{idx}] 文档: {chunk.doc_title}\n"
f"章节: {chunk.section_title or '未标注'}\n"
f"页码: {chunk.page_start}" + (f"-{chunk.page_end}" if chunk.page_end and chunk.page_end != chunk.page_start else "") + "\n"
f"内容: {chunk.text}"
)
block_tokens = self._estimate_tokens(block)
if context_tokens + block_tokens > settings.rag_max_context_tokens:
if block_tokens > remaining_budget:
break
remaining_budget -= block_tokens
context_tokens += block_tokens
context_blocks.append(block)
context = "\n\n".join(context_blocks)
messages = [{"role": "system", "content": system_prompt}]
for item in history or []:
messages.append({"role": item["role"], "content": item["content"]})
messages.append(
{
"role": "user",
"content": f"问题:{query}\n\n参考上下文:\n{context}\n\n请在回答后给出简要引用编号。",
}
# Craft the user turn differently when a document is attached
if context_text and context_text.strip():
user_content = (
f"问题:{query}\n\n"
f"请先基于上方附件文档内容进行分析,再结合法规参考上下文给出合规评估。"
f"\n\n参考上下文:\n{context}\n\n"
f"请在回答中注明引用来源编号(如适用)。"
)
else:
user_content = f"问题:{query}\n\n参考上下文:\n{context}\n\n请在回答后给出简要引用编号。"
messages.append({"role": "user", "content": user_content})
return messages, context_tokens
def _is_context_truncated(self, *, retrieved_chunks: list[RetrievedChunk], context_tokens: int) -> bool:
@@ -112,6 +161,8 @@ class OpenAICompatibleAnswerGenerator(AnswerGenerator):
provider: str | None = None,
model: str | None = None,
prompt_template: str | None = None,
context_text: str | None = None,
context_filename: str | None = None,
) -> AnswerResult:
"""Handle generate for the Open A I Compatible Answer Generator instance."""
start = time.time()
@@ -120,6 +171,8 @@ class OpenAICompatibleAnswerGenerator(AnswerGenerator):
retrieved_chunks=retrieved_chunks,
history=history,
prompt_template=prompt_template,
context_text=context_text,
context_filename=context_filename,
)
client = get_llm_client(provider=provider or settings.llm_provider, model=model or settings.llm_model)
response = client.chat(messages)
@@ -147,6 +200,8 @@ class OpenAICompatibleAnswerGenerator(AnswerGenerator):
provider: str | None = None,
model: str | None = None,
prompt_template: str | None = None,
context_text: str | None = None,
context_filename: str | None = None,
) -> Generator[dict, None, AnswerResult]:
"""Stream generate for the Open A I Compatible Answer Generator instance."""
start = time.time()
@@ -155,6 +210,8 @@ class OpenAICompatibleAnswerGenerator(AnswerGenerator):
retrieved_chunks=retrieved_chunks,
history=history,
prompt_template=prompt_template,
context_text=context_text,
context_filename=context_filename,
)
sources = [source.__dict__ for source in self._sources(retrieved_chunks)]
yield {"event": "sources", "data": sources}
@@ -0,0 +1,47 @@
"""Abstract base class for in-app regulatory-signal notifications.
A notification is created once per triggering event (a brand-new regulation,
or a significant change to an existing one) and broadcast to every logged-in
user. There is no per-user subscription targeting — see the design doc for why.
Per-user "read" state is tracked separately from the notification itself, so
one notification row serves every user rather than being fanned out on create.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
class BaseNotificationStore(ABC):
"""Port interface for perception notification persistence."""
@abstractmethod
def create(
self,
*,
event_id: str,
kind: str,
title: str,
impact_level: str | None,
summary: str | None,
) -> None:
"""Record a new notification. kind is 'new' or 'changed'."""
@abstractmethod
def list_for_user(self, user_id: str, limit: int = 20) -> list[dict]:
"""Return the most recent notifications, newest first.
Each item includes a "read" boolean reflecting whether `user_id` has
marked it read.
"""
@abstractmethod
def unread_count(self, user_id: str) -> int:
"""Return how many notifications `user_id` has not yet read."""
@abstractmethod
def mark_all_read(self, user_id: str) -> int:
"""Mark every currently-unread notification read for `user_id`.
Returns the number of notifications newly marked.
"""
@@ -5,6 +5,12 @@ from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
import httpx
import trafilatura
from loguru import logger
from app.config.settings import settings
@dataclass
class RawEvent:
@@ -21,7 +27,10 @@ class RawEvent:
effective_at: str | None
category: str
tags: list[str] = field(default_factory=list)
raw_text: str = "" # full crawled text for hashing + LLM
# Whatever text the list page yields. CrawlService upgrades this by calling
# fetch_full_text(full_text_url); this value is the fallback when that
# fails. Used for change hashing and for the version diff.
raw_text: str = ""
class BaseCrawler(ABC):
@@ -30,3 +39,37 @@ class BaseCrawler(ABC):
@abstractmethod
def fetch(self, limit: int = 50) -> list[RawEvent]:
"""Fetch up to `limit` recent events from the data source."""
def fetch_full_text(self, url: str) -> str:
"""Download a regulation detail page and extract its body text.
Change detection is only as good as the text it compares, and list
pages carry nothing but a standard code and a title. This default
implementation serves all current sources; a source that needs PDF
extraction or authentication overrides this one method.
Returns an empty string on any failure rather than raising, so one
unreachable page cannot abort a whole crawl run. The caller decides how
to degrade.
"""
if not url:
return ""
try:
response = httpx.get(
url,
timeout=settings.perception_crawl_timeout_seconds,
follow_redirects=True,
)
response.raise_for_status()
except Exception as exc: # noqa: BLE001 - any transport error degrades the same way
logger.warning("Full-text fetch failed url={} err={}", url, exc)
return ""
# trafilatura scores 0.92 F1 on government pages against 0.78 for
# readability-lxml, and handles CJK content; include_tables matters
# because regulatory limits are frequently tabulated.
extracted = trafilatura.extract(response.text, include_tables=True)
if not extracted:
logger.warning("Full-text extraction returned nothing url={}", url)
return ""
return extracted.strip()
@@ -8,6 +8,7 @@ import httpx
from bs4 import BeautifulSoup
from loguru import logger
from app.config.settings import settings
from app.infrastructure.perception.crawlers.base import BaseCrawler, RawEvent
from ._utils import extract_tags, parse_date
@@ -33,7 +34,11 @@ class CatarcCrawler(BaseCrawler):
while len(events) < limit and page <= max_pages:
url = f"{_BASE_URL}?page={page}"
try:
resp = httpx.get(url, timeout=30, follow_redirects=True)
resp = httpx.get(
url,
timeout=settings.perception_crawl_timeout_seconds,
follow_redirects=True,
)
resp.raise_for_status()
except Exception as exc:
logger.warning("CATARC fetch failed page={} err={}", page, exc)
@@ -9,6 +9,7 @@ import httpx
from bs4 import BeautifulSoup
from loguru import logger
from app.config.settings import settings
from app.infrastructure.perception.crawlers.base import BaseCrawler, RawEvent
from ._utils import parse_date
@@ -53,7 +54,11 @@ class EurlexCrawler(BaseCrawler):
if len(events) >= limit:
break
try:
resp = httpx.get(rss_url, timeout=30, follow_redirects=True)
resp = httpx.get(
rss_url,
timeout=settings.perception_crawl_timeout_seconds,
follow_redirects=True,
)
resp.raise_for_status()
except Exception as exc:
logger.warning("EUR-Lex RSS fetch failed url={} err={}", rss_url, exc)
@@ -5,6 +5,7 @@ from __future__ import annotations
import httpx
from loguru import logger
from app.config.settings import settings
from app.infrastructure.perception.crawlers.base import BaseCrawler, RawEvent
from ._utils import extract_tags, parse_date
@@ -22,7 +23,12 @@ def _fetch_page(std_type: int, page: int, page_size: int) -> list[dict]:
"p.p7": page_size,
}
try:
resp = httpx.get(_BASE_URL, params=params, headers=_HEADERS, timeout=30)
resp = httpx.get(
_BASE_URL,
params=params,
headers=_HEADERS,
timeout=settings.perception_crawl_timeout_seconds,
)
resp.raise_for_status()
data = resp.json()
return data.get("rows", []) or []
@@ -3,14 +3,14 @@
from __future__ import annotations
import json
import math
from typing import Any
from loguru import logger
from app.config.settings import settings
from app.infrastructure.embedding.openai_compatible_embedding_provider import (
OpenAICompatibleEmbeddingProvider,
from app.infrastructure.perception.regulation_differ import (
ParagraphChange,
RegulationDiffer,
)
from app.services.llm.llm_factory import get_llm_client
@@ -27,21 +27,31 @@ _ASSESS_SYSTEM = (
)
_DIFF_SYSTEM = (
"You are a regulatory change analyst. Given an old and new version of a regulation paragraph, "
"classify the type of change and summarise it. "
"Return JSON only: {\"change_type\": \"tightened|relaxed|added|removed\", \"summary\": \"...\"}"
"You are a regulatory change analyst. You are given the OLD and NEW version of "
"one regulation paragraph, with the exact edits marked <DEL>removed</DEL> and "
"<INS>added</INS>. Classify the legal effect of the change. "
"Return JSON only: {\"change_type\": \"tightened|relaxed|numeric|clarified|scope\", "
"\"legal_effect\": \"one sentence on what this means for compliance\"}"
)
_SIMILARITY_THRESHOLD = 0.85
def _marked_diff(change: ParagraphChange) -> str:
"""Render a paragraph change with the exact edits marked for the model.
def _cosine(a: list[float], b: list[float]) -> float:
dot = sum(x * y for x, y in zip(a, b))
norm_a = math.sqrt(sum(x * x for x in a))
norm_b = math.sqrt(sum(x * x for x in b))
if norm_a == 0 or norm_b == 0:
return 0.0
return dot / (norm_a * norm_b)
The model is shown where the edit is rather than being asked to find it,
and is never asked to reproduce the changed text the differ already
computed those spans exactly, so there is nothing for the model to
hallucinate.
"""
marked = "".join(
text if op == 0 else (f"<DEL>{text}</DEL>" if op < 0 else f"<INS>{text}</INS>")
for op, text in change.diff_spans
)
return (
f"OLD: {change.old_text[:500]}\n"
f"NEW: {change.new_text[:500]}\n"
f"MARKED: {marked[:800]}"
)
def _llm_json(client: Any, messages: list[dict]) -> Any:
@@ -67,7 +77,9 @@ class LlmPipeline:
provider=settings.llm_provider,
model=settings.llm_model,
)
self._embedder = OpenAICompatibleEmbeddingProvider()
# Change detection is deterministic; the differ needs no model and no
# network, so the pipeline no longer constructs an embedding provider.
self._differ = RegulationDiffer()
# ------------------------------------------------------------------
# Step 1: Structure extraction
@@ -166,76 +178,68 @@ For each document, assess impact and recommend action. Return JSON array:
return doc_excerpts
# ------------------------------------------------------------------
# Step 3: Semantic diff
# Step 3: Deterministic diff with gated LLM classification
# ------------------------------------------------------------------
def compute_diff(self, old_text: str, new_text: str) -> dict:
"""Compare old and new regulation text; return changed sections and summary."""
old_paras = [p.strip() for p in old_text.split("\n") if p.strip()]
new_paras = [p.strip() for p in new_text.split("\n") if p.strip()]
"""Compare old and new regulation text; return changed sections and summary.
if not old_paras or not new_paras:
return {"changed_sections": [], "change_summary": "No comparable text."}
Detection is deterministic see regulation_differ for why embedding
similarity was removed. The LLM is called only for paragraphs the
differ marked significant, and only to explain the legal effect of a
change that has already been located exactly.
"""
changes = self._differ.diff(old_text, new_text)
if not changes:
return {
"changed_sections": [],
"change_summary": "No substantive changes detected between versions.",
}
all_paras = old_paras + new_paras
try:
all_embeddings = self._embedder.embed_texts(all_paras)
except Exception as exc:
logger.warning("Embedding for diff failed: {}", exc)
return {"changed_sections": [], "change_summary": "Diff unavailable (embedding error)."}
changed_sections = [self._describe(change) for change in changes]
old_embeddings = all_embeddings[: len(old_paras)]
new_embeddings = all_embeddings[len(old_paras):]
changed_sections: list[dict] = []
max_len = max(len(old_paras), len(new_paras))
for i in range(max_len):
if i >= len(old_paras):
# New paragraph added
changed_sections.append({
"old_text": "",
"new_text": new_paras[i][:300],
"similarity": 0.0,
"change_type": "added",
"summary": "New paragraph added.",
})
continue
if i >= len(new_paras):
# Old paragraph removed
changed_sections.append({
"old_text": old_paras[i][:300],
"new_text": "",
"similarity": 0.0,
"change_type": "removed",
"summary": "Paragraph removed.",
})
continue
# Both exist — compare via embeddings
sim = _cosine(old_embeddings[i], new_embeddings[i])
if sim < _SIMILARITY_THRESHOLD:
messages = [
{"role": "system", "content": _DIFF_SYSTEM},
{"role": "user", "content": f"OLD: {old_paras[i][:500]}\nNEW: {new_paras[i][:500]}"},
]
classification = _llm_json(self._client, messages) or {}
changed_sections.append({
"old_text": old_paras[i][:300],
"new_text": new_paras[i][:300],
"similarity": round(sim, 3),
"change_type": classification.get("change_type", "modified"),
"summary": classification.get("summary", ""),
})
if not changed_sections:
change_summary = "No substantive changes detected between versions."
else:
types = [s["change_type"] for s in changed_sections]
types = sorted({section["change_type"] for section in changed_sections})
gated = sum(1 for change in changes if change.needs_llm)
change_summary = (
f"{len(changed_sections)} paragraph(s) changed: "
+ ", ".join(f"{t}" for t in set(types))
+ ". "
+ (changed_sections[0].get("summary", "") if changed_sections else "")
)
f"{len(changed_sections)} paragraph(s) changed ({', '.join(types)}); "
f"{gated} significant. "
+ (changed_sections[0].get("summary") or "")
).strip()
return {"changed_sections": changed_sections, "change_summary": change_summary}
def _describe(self, change: ParagraphChange) -> dict:
"""Turn one detected change into the API payload, classifying if warranted."""
section = {
"old_text": change.old_text[:300],
"new_text": change.new_text[:300],
"change_type": change.change_type,
"change_ratio": round(change.change_ratio, 3),
"numeric_changed": change.numeric_changed,
"deontic_changed": change.deontic_changed,
"summary": "",
}
if not change.needs_llm:
return section
classification = _llm_json(
self._client,
[
{"role": "system", "content": _DIFF_SYSTEM},
{"role": "user", "content": _marked_diff(change)},
],
)
if isinstance(classification, dict):
section["change_type"] = classification.get("change_type") or change.change_type
section["summary"] = classification.get("legal_effect") or ""
# A failed or malformed model response must not discard a change that
# deterministic analysis already proved real; the section keeps its
# spans, flags, and alignment-derived type with an empty summary.
if change.numeric_changed:
# Models routinely label a changed threshold as "clarified". The
# deterministic pass already knows a number moved, so it wins.
section["change_type"] = "numeric"
return section
@@ -0,0 +1,66 @@
"""In-memory notification store used when Postgres is not configured.
Mirrors MockEventStore's role for BaseEventStore: keeps the feature usable in
local dev and in tests without a live database, and matches
DOCUMENT_REPOSITORY_BACKEND's existing Mock/Postgres split.
"""
from __future__ import annotations
from datetime import UTC, datetime
from app.infrastructure.perception.base_notification_store import BaseNotificationStore
class MockNotificationStore(BaseNotificationStore):
"""Dict-backed notification store. Data does not survive a process restart."""
def __init__(self) -> None:
"""Start with an empty feed and no read receipts."""
self._notifications: list[dict] = []
self._next_id = 1
# (notification_id, user_id) pairs — presence means read.
self._reads: set[tuple[int, str]] = set()
def create(
self,
*,
event_id: str,
kind: str,
title: str,
impact_level: str | None,
summary: str | None,
) -> None:
"""Append a notification with an auto-incrementing id."""
self._notifications.append({
"id": self._next_id,
"event_id": event_id,
"kind": kind,
"title": title,
"impact_level": impact_level,
"summary": summary,
"created_at": datetime.now(UTC).isoformat(),
})
self._next_id += 1
def list_for_user(self, user_id: str, limit: int = 20) -> list[dict]:
"""Return the newest `limit` notifications with this user's read state."""
ordered = sorted(self._notifications, key=lambda n: n["id"], reverse=True)
return [
{**n, "read": (n["id"], user_id) in self._reads}
for n in ordered[:limit]
]
def unread_count(self, user_id: str) -> int:
"""Count notifications this user has not yet read."""
return sum(1 for n in self._notifications if (n["id"], user_id) not in self._reads)
def mark_all_read(self, user_id: str) -> int:
"""Add a read receipt for every currently-unread notification."""
marked = 0
for n in self._notifications:
key = (n["id"], user_id)
if key not in self._reads:
self._reads.add(key)
marked += 1
return marked
@@ -40,7 +40,8 @@ CREATE TABLE IF NOT EXISTS regulation_events (
affected_docs JSONB,
crawled_at TIMESTAMPTZ DEFAULT now(),
processed_at TIMESTAMPTZ,
raw_storage_key TEXT
raw_storage_key TEXT,
raw_text TEXT
);
CREATE INDEX IF NOT EXISTS reg_events_source_date
ON regulation_events (source, published_at DESC);
@@ -48,12 +49,16 @@ CREATE INDEX IF NOT EXISTS reg_events_impact_date
ON regulation_events (impact_level, published_at DESC);
"""
_ADD_COLUMNS = """
ALTER TABLE regulation_events ADD COLUMN IF NOT EXISTS raw_text TEXT;
"""
_ALL_COLUMNS = (
"id", "source", "source_label", "standard_code", "title", "summary",
"full_text_url", "status", "impact_level", "published_at", "effective_at",
"category", "tags", "obligations", "deadlines", "scope", "penalties",
"content_hash", "previous_hash", "change_summary", "changed_sections",
"affected_docs", "crawled_at", "processed_at", "raw_storage_key",
"affected_docs", "crawled_at", "processed_at", "raw_storage_key", "raw_text",
)
@@ -97,6 +102,10 @@ class PostgresEventStore(BaseEventStore):
try:
with conn.cursor() as cur:
cur.execute(_CREATE_TABLE)
# CREATE TABLE IF NOT EXISTS is a no-op on deployments that
# already have this table, so new columns must be added
# explicitly or existing installations silently lack them.
cur.execute(_ADD_COLUMNS)
conn.commit()
except Exception:
conn.rollback()
@@ -0,0 +1,157 @@
"""PostgreSQL-backed notification store.
One row per triggering event, shared by every user; a separate read-receipt
table tracks per-user read state so broadcasting to everyone needs no fan-out
insert per user. See base_notification_store.py for the port contract and the
design doc for why this shape was chosen over per-user subscriptions.
"""
from __future__ import annotations
from contextlib import contextmanager
from typing import Any
import psycopg2
import psycopg2.extras
from psycopg2.pool import ThreadedConnectionPool
from app.config.settings import settings
from app.infrastructure.perception.base_notification_store import BaseNotificationStore
_CREATE_TABLES = """
CREATE TABLE IF NOT EXISTS perception_notifications (
id SERIAL PRIMARY KEY,
event_id TEXT NOT NULL REFERENCES regulation_events(id) ON DELETE CASCADE,
kind TEXT NOT NULL,
title TEXT NOT NULL,
impact_level TEXT,
summary TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS perception_notification_reads (
notification_id INTEGER NOT NULL REFERENCES perception_notifications(id) ON DELETE CASCADE,
user_id TEXT NOT NULL,
read_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (notification_id, user_id)
);
CREATE INDEX IF NOT EXISTS perception_notif_created
ON perception_notifications (created_at DESC);
"""
def _row_to_dict(row: dict[str, Any]) -> dict:
"""Convert a psycopg2 RealDictRow to a plain dict with an ISO timestamp."""
d = dict(row)
if d.get("created_at") is not None:
d["created_at"] = d["created_at"].isoformat()
return d
class PostgresNotificationStore(BaseNotificationStore):
"""Notification store backed by PostgreSQL."""
def __init__(self) -> None:
"""Open a connection pool and ensure both tables exist."""
self._pool = ThreadedConnectionPool(
minconn=1,
maxconn=5,
host=settings.postgres_host,
port=settings.postgres_port,
user=settings.postgres_user,
password=settings.postgres_password,
dbname=settings.postgres_db,
)
self._ensure_schema()
def _ensure_schema(self) -> None:
with self._conn() as conn:
try:
with conn.cursor() as cur:
cur.execute(_CREATE_TABLES)
conn.commit()
except Exception:
conn.rollback()
raise
@contextmanager
def _conn(self):
conn = None
try:
conn = self._pool.getconn()
yield conn
finally:
if conn is not None:
self._pool.putconn(conn)
def create(
self,
*,
event_id: str,
kind: str,
title: str,
impact_level: str | None,
summary: str | None,
) -> None:
"""Insert one notification row for the triggering event."""
with self._conn() as conn:
with conn.cursor() as cur:
cur.execute(
"INSERT INTO perception_notifications "
"(event_id, kind, title, impact_level, summary) "
"VALUES (%s, %s, %s, %s, %s)",
(event_id, kind, title, impact_level, summary),
)
conn.commit()
def list_for_user(self, user_id: str, limit: int = 20) -> list[dict]:
"""Return the newest notifications with this user's read state joined in."""
with self._conn() as conn:
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
cur.execute(
"""
SELECT n.*, (r.user_id IS NOT NULL) AS read
FROM perception_notifications n
LEFT JOIN perception_notification_reads r
ON r.notification_id = n.id AND r.user_id = %s
ORDER BY n.created_at DESC
LIMIT %s
""",
(user_id, limit),
)
return [_row_to_dict(r) for r in cur.fetchall()]
def unread_count(self, user_id: str) -> int:
"""Count notifications with no read receipt for this user."""
with self._conn() as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT COUNT(*) FROM perception_notifications n
WHERE NOT EXISTS (
SELECT 1 FROM perception_notification_reads r
WHERE r.notification_id = n.id AND r.user_id = %s
)
""",
(user_id,),
)
return cur.fetchone()[0]
def mark_all_read(self, user_id: str) -> int:
"""Insert a read receipt for every notification this user hasn't read."""
with self._conn() as conn:
with conn.cursor() as cur:
cur.execute(
"""
INSERT INTO perception_notification_reads (notification_id, user_id)
SELECT n.id, %s FROM perception_notifications n
WHERE NOT EXISTS (
SELECT 1 FROM perception_notification_reads r
WHERE r.notification_id = n.id AND r.user_id = %s
)
ON CONFLICT (notification_id, user_id) DO NOTHING
""",
(user_id, user_id),
)
marked = cur.rowcount
conn.commit()
return marked
@@ -0,0 +1,224 @@
"""Deterministic change detection between two versions of a regulation.
This module deliberately contains no LLM call, no network access, and no
embedding lookup. It exists because the previous implementation decided whether
a paragraph had changed by comparing embedding cosine similarity against a 0.85
threshold, which is blind to exactly the edits that matter in regulation.
Measured against the deployed text-embedding-v3 gateway, tightening a braking
limit from 30 to 20 scores 0.9153 and relaxing 应当 to scores 0.9162
both far above the threshold, both undetected while an entirely unrelated
clause scores 0.6862 and is the only thing that fires. Cosine is scale
invariant, so it cannot represent a change in magnitude or certainty
(arXiv:2403.05440, ACM Web Conference 2024); no threshold recovers the signal.
The replacement is the production consensus for legal text: align paragraphs
with a longest-common-subsequence matcher, run a literal character diff on the
aligned pairs, and let cheap deterministic rules decide whether a change is
significant enough to spend an LLM call classifying.
"""
from __future__ import annotations
import re
import unicodedata
from dataclasses import dataclass, field
from diff_match_patch import diff_match_patch
from difflib import SequenceMatcher
from app.config.settings import settings
# Chinese regulatory drafting uses a small, near-unambiguous set of deontic
# markers, so a regex pre-pass identifies legally significant edits without an
# LLM. Adding or removing any of these changes what the provision compels.
_DEONTIC_PATTERN = re.compile(r"应当|须|禁止|不得|可以|允许|宜")
# Matches digit runs including decimals, so "30" -> "20" and "0.85" -> "0.9"
# are both treated as numeric changes.
_NUMBER_PATTERN = re.compile(r"\d+(?:\.\d+)?")
# diff_match_patch operation codes.
_DMP_DELETE = -1
_DMP_INSERT = 1
_DMP_EQUAL = 0
@dataclass(frozen=True)
class ParagraphChange:
"""One detected difference between the old and new version of a regulation.
`needs_llm` is the gate: it records whether this change is worth the cost of
an LLM classification call. The deterministic flags that drive it are kept
on the record so downstream code can act on them even when the LLM call
fails or is skipped.
"""
change_type: str
old_text: str
new_text: str
numeric_changed: bool
deontic_changed: bool
change_ratio: float
needs_llm: bool
# (op, text) pairs from diff_match_patch, for rendering a redline view.
diff_spans: list[tuple[int, str]] = field(default_factory=list)
def _split_paragraphs(text: str) -> list[str]:
"""Split regulation text into comparable units, dropping blank lines.
ponytail: newline splitting, not clause parsing. Upgrade to 第X条 / X.X.X
segmentation only if paragraph granularity proves too coarse in practice.
"""
return [line.strip() for line in (text or "").split("\n") if line.strip()]
def _numbers_differ(old: str, new: str) -> bool:
"""Report whether the two spans contain a different sequence of numbers."""
return _NUMBER_PATTERN.findall(old) != _NUMBER_PATTERN.findall(new)
def _deontic_differs(old: str, new: str) -> bool:
"""Report whether obligation markers were added, removed, or swapped."""
return sorted(_DEONTIC_PATTERN.findall(old)) != sorted(_DEONTIC_PATTERN.findall(new))
def _is_cosmetic(spans: list[tuple[int, str]]) -> bool:
"""Report whether the edit touched nothing but punctuation and whitespace.
A change ratio alone cannot answer this for Chinese regulation text. Clauses
run 20-60 characters, so deleting a single is a 4% change and clears any
threshold low enough to still catch real edits in longer paragraphs. Testing
what actually changed is both cheaper and exact.
"""
changed = "".join(text for op, text in spans if op != _DMP_EQUAL)
# Unicode categories P (punctuation), Z (separator) and C (control) cover
# Chinese and ASCII punctuation plus every flavour of whitespace.
return all(unicodedata.category(char)[0] in {"P", "Z", "C"} for char in changed)
class RegulationDiffer:
"""Align two regulation versions and classify what changed, without an LLM."""
def __init__(self, min_change_ratio: float | None = None) -> None:
"""Store the gate threshold, defaulting to the configured value.
The explicit argument exists so tests never depend on the deployed .env.
"""
self._min_change_ratio = (
settings.perception_diff_min_change_ratio
if min_change_ratio is None
else min_change_ratio
)
self._dmp = diff_match_patch()
def diff(self, old_text: str, new_text: str) -> list[ParagraphChange]:
"""Return every changed paragraph between two versions.
Unchanged paragraphs are not returned. An empty old version means there
is no baseline to compare against the caller's first crawl — so no
changes are reported rather than the whole document being called new.
"""
old_paras = _split_paragraphs(old_text)
new_paras = _split_paragraphs(new_text)
if not old_paras or not new_paras:
return []
# autojunk=False is load-bearing: the default treats any element
# appearing in over 1% of a sequence of 200+ items as junk, and
# regulations repeat boilerplate paragraphs that alignment depends on
# as anchors.
matcher = SequenceMatcher(None, old_paras, new_paras, autojunk=False)
changes: list[ParagraphChange] = []
for tag, i1, i2, j1, j2 in matcher.get_opcodes():
if tag == "equal":
continue
if tag == "insert":
changes.extend(self._added(p) for p in new_paras[j1:j2])
elif tag == "delete":
changes.extend(self._removed(p) for p in old_paras[i1:i2])
elif tag == "replace":
changes.extend(self._replaced(old_paras[i1:i2], new_paras[j1:j2]))
return changes
def _added(self, paragraph: str) -> ParagraphChange:
"""Build a record for a provision present only in the new version."""
return ParagraphChange(
change_type="added",
old_text="",
new_text=paragraph,
numeric_changed=False,
deontic_changed=bool(_DEONTIC_PATTERN.search(paragraph)),
change_ratio=1.0,
# A new provision always carries new obligations, so it is always
# worth classifying.
needs_llm=True,
diff_spans=[(_DMP_INSERT, paragraph)],
)
def _removed(self, paragraph: str) -> ParagraphChange:
"""Build a record for a provision dropped from the new version."""
return ParagraphChange(
change_type="removed",
old_text=paragraph,
new_text="",
numeric_changed=False,
deontic_changed=bool(_DEONTIC_PATTERN.search(paragraph)),
change_ratio=1.0,
needs_llm=True,
diff_spans=[(_DMP_DELETE, paragraph)],
)
def _replaced(self, old_block: list[str], new_block: list[str]) -> list[ParagraphChange]:
"""Compare a run of rewritten paragraphs pairwise, reporting the remainder.
SequenceMatcher emits `replace` for a whole run at once, and the two
sides may differ in length. Pairing by position within the run is safe
here because alignment has already established that this run as a whole
corresponds; any surplus on either side is a genuine insertion or
deletion.
"""
results: list[ParagraphChange] = []
for index in range(max(len(old_block), len(new_block))):
if index >= len(old_block):
results.append(self._added(new_block[index]))
elif index >= len(new_block):
results.append(self._removed(old_block[index]))
else:
results.append(self._modified(old_block[index], new_block[index]))
return results
def _modified(self, old: str, new: str) -> ParagraphChange:
"""Character-diff an aligned pair and decide whether it warrants an LLM call."""
spans = self._dmp.diff_main(old, new)
# Merges single-character edits into human-meaningful chunks so the
# redline view and the change ratio both reflect real edits.
self._dmp.diff_cleanupSemantic(spans)
changed_chars = sum(len(text) for op, text in spans if op != _DMP_EQUAL)
denominator = max(len(old), len(new), 1)
change_ratio = changed_chars / denominator
numeric_changed = _numbers_differ(old, new)
deontic_changed = _deontic_differs(old, new)
# A changed limit or obligation marker is always significant no matter
# how few characters moved. Everything else must be substantive and
# clear the ratio gate to be worth a model call.
significant = numeric_changed or deontic_changed or (
not _is_cosmetic(spans) and change_ratio >= self._min_change_ratio
)
return ParagraphChange(
change_type="modified",
old_text=old,
new_text=new,
numeric_changed=numeric_changed,
deontic_changed=deontic_changed,
change_ratio=change_ratio,
needs_llm=significant,
diff_spans=[(op, text) for op, text in spans],
)
@@ -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):
@@ -0,0 +1,142 @@
"""Postgres-backed persistence for cumulative AI model usage counters.
Keeps ModelUsageTracker (an in-memory, process-lifetime-only registry defined
in app/shared/model_usage_tracker.py) from losing its counters on every
backend restart. This store only ever persists the *current cumulative
snapshot* per provider+model not a historical time-series log matching
the "durable counters" scope decided in
docs/superpowers/specs/2026-07-23-status-model-usage-hardening-design.md.
"""
from __future__ import annotations
from contextlib import contextmanager
import psycopg2
import psycopg2.extras
from psycopg2.pool import ThreadedConnectionPool
from app.config.settings import settings
from app.shared.model_usage_tracker import ModelUsageEntry
# Table creation follows the same CREATE TABLE IF NOT EXISTS idiom used by
# every other Postgres store in this codebase — no migration framework.
_CREATE_TABLE = """
CREATE TABLE IF NOT EXISTS model_usage_stats (
provider VARCHAR(64) NOT NULL,
model VARCHAR(128) NOT NULL,
total_tokens BIGINT NOT NULL DEFAULT 0,
prompt_tokens BIGINT NOT NULL DEFAULT 0,
completion_tokens BIGINT NOT NULL DEFAULT 0,
call_count_ok BIGINT NOT NULL DEFAULT 0,
call_count_error BIGINT NOT NULL DEFAULT 0,
last_called_at TIMESTAMPTZ,
last_latency_ms INTEGER,
last_error TEXT,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (provider, model)
);
"""
_UPSERT = """
INSERT INTO model_usage_stats
(provider, model, total_tokens, prompt_tokens, completion_tokens,
call_count_ok, call_count_error, last_called_at, last_latency_ms, last_error, updated_at)
VALUES
(%(provider)s, %(model)s, %(total_tokens)s, %(prompt_tokens)s, %(completion_tokens)s,
%(call_count_ok)s, %(call_count_error)s, %(last_called_at)s, %(last_latency_ms)s, %(last_error)s, NOW())
ON CONFLICT (provider, model) DO UPDATE SET
total_tokens = EXCLUDED.total_tokens,
prompt_tokens = EXCLUDED.prompt_tokens,
completion_tokens = EXCLUDED.completion_tokens,
call_count_ok = EXCLUDED.call_count_ok,
call_count_error = EXCLUDED.call_count_error,
last_called_at = EXCLUDED.last_called_at,
last_latency_ms = EXCLUDED.last_latency_ms,
last_error = EXCLUDED.last_error,
updated_at = NOW();
"""
class PostgresModelUsageStore:
"""Load and flush ModelUsageTracker snapshots to/from a Postgres table."""
def __init__(self) -> None:
"""Open a small connection pool and ensure the table exists."""
self._pool = ThreadedConnectionPool(
minconn=1,
maxconn=3,
host=settings.postgres_host,
port=settings.postgres_port,
user=settings.postgres_user,
password=settings.postgres_password,
dbname=settings.postgres_db,
)
self._ensure_schema()
def _ensure_schema(self) -> None:
"""Create the model_usage_stats table if it does not already exist."""
with self._conn() as conn:
with conn.cursor() as cur:
cur.execute(_CREATE_TABLE)
conn.commit()
@contextmanager
def _conn(self):
"""Borrow a pooled connection and always return it, even on error."""
conn = self._pool.getconn()
try:
yield conn
finally:
self._pool.putconn(conn)
def load_all(self) -> dict[str, ModelUsageEntry]:
"""Return every persisted row as {"provider:model": ModelUsageEntry}."""
with self._conn() as conn:
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
cur.execute("SELECT * FROM model_usage_stats")
rows = cur.fetchall()
entries: dict[str, ModelUsageEntry] = {}
for row in rows:
entry = ModelUsageEntry(
provider=row["provider"],
model=row["model"],
total_tokens=row["total_tokens"],
prompt_tokens=row["prompt_tokens"],
completion_tokens=row["completion_tokens"],
call_count_ok=row["call_count_ok"],
call_count_error=row["call_count_error"],
last_called_at=row["last_called_at"],
last_latency_ms=row["last_latency_ms"],
last_error=row["last_error"],
)
entries[f"{entry.provider}:{entry.model}"] = entry
return entries
def flush(self, entries: dict[str, ModelUsageEntry]) -> None:
"""Upsert the current cumulative snapshot of every tracked entry.
A no-op for an empty snapshot avoids opening a connection for nothing
(e.g. before any LLM/embedding/reranker call has happened yet).
"""
if not entries:
return
with self._conn() as conn:
with conn.cursor() as cur:
for entry in entries.values():
cur.execute(
_UPSERT,
{
"provider": entry.provider,
"model": entry.model,
"total_tokens": entry.total_tokens,
"prompt_tokens": entry.prompt_tokens,
"completion_tokens": entry.completion_tokens,
"call_count_ok": entry.call_count_ok,
"call_count_error": entry.call_count_error,
"last_called_at": entry.last_called_at,
"last_latency_ms": entry.last_latency_ms,
"last_error": entry.last_error,
},
)
conn.commit()
+12 -1
View File
@@ -28,7 +28,10 @@ celery_app = Celery(
"compliance_hub",
broker=_BROKER,
backend=_BACKEND,
include=["app.infrastructure.tasks.document_tasks"],
include=[
"app.infrastructure.tasks.document_tasks",
"app.infrastructure.tasks.perception_tasks",
],
)
celery_app.conf.update(
@@ -42,4 +45,12 @@ celery_app.conf.update(
task_reject_on_worker_lost=True,
# Keep results for 1 hour for status polling.
result_expires=3600,
# Scheduled counterpart to the Perception page's manual "Refresh" button.
# Only takes effect while a Beat process is running (./dev.sh start beat).
beat_schedule={
"crawl-regulations-periodic": {
"task": "app.infrastructure.tasks.perception_tasks.crawl_regulations_task",
"schedule": settings.perception_crawl_interval_seconds,
},
},
)
@@ -0,0 +1,63 @@
"""Celery task for scheduled regulatory source crawling.
This is the scheduled counterpart to the Perception page's manual "Refresh"
button (POST /perception/crawl). Every architecture reference document
describes source monitoring as continuous ("定时爬取"), not operator-triggered,
so this task is what Celery Beat runs on a fixed interval once an operator
starts a Beat process.
"""
from __future__ import annotations
from loguru import logger
from app.infrastructure.tasks.celery_app import celery_app
@celery_app.task(
name="app.infrastructure.tasks.perception_tasks.crawl_regulations_task",
bind=True,
)
def crawl_regulations_task(self) -> dict:
"""Crawl every registered regulatory source and enrich new/changed events.
Drains CrawlService.run_crawl(), which already isolates each source's
fetch and each event's enrichment behind its own try/except — a source
outage or a single bad event yields an "error" progress item and the
generator continues. Re-catching those here would only hide problems the
service has already handled, so this task's job is limited to counting
them and logging a summary.
No automatic retry is configured. An exception escaping run_crawl itself
means something broke in a way the service's own error handling did not
anticipate; the next scheduled tick already provides a retry within
settings.perception_crawl_interval_seconds, so an immediate retry against
the same failure is not worth the added complexity.
ponytail: relies on a single worker process to serialize scheduled runs
(Celery's default concurrency processes one task at a time, so a run that
outlasts the interval delays the next tick rather than overlapping it).
Add a Redis-based lock (e.g. SETNX on a per-task key) if this queue is
ever served by more than one worker.
"""
from app.shared.bootstrap import get_crawl_service
error_count = 0
new_count = 0
updated_count = 0
for item in get_crawl_service().run_crawl():
event = item.get("event")
if event == "error":
error_count += 1
logger.warning("Scheduled crawl source error: {}", item.get("data"))
elif event == "done":
data = item.get("data") or {}
new_count = data.get("total_new", 0)
updated_count = data.get("total_updated", 0)
logger.info(
"Scheduled crawl finished: new={} updated={} source_errors={}",
new_count, updated_count, error_count,
)
return {"new": new_count, "updated": updated_count, "source_errors": error_count}
@@ -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()
+8
View File
@@ -0,0 +1,8 @@
"""MCP (Model Context Protocol) server module.
Exposes selected read-only platform capabilities currently only regulation
search as MCP tools so external MCP clients (Claude Desktop, GitHub Copilot,
Cursor, etc.) can query this platform's compliance knowledge base directly.
"""
# Kept deliberately empty beyond this docstring — see server.py for the
# actual FastMCP instance and tool/middleware definitions.
+201
View File
@@ -0,0 +1,201 @@
"""MCPServer instance exposing the compliance knowledge base as an MCP tool.
This module is a pure protocol adapter: search_regulations() below calls the
existing AgentConversationService.ask() (the same application service backing
the /api/v1/agent/ask REST endpoint) and reshapes its result into a plain
dict. No new retrieval, ranking, or LLM orchestration logic lives here.
"""
from __future__ import annotations
import logging
import time
from typing import Annotated
from mcp.server import MCPServer
from mcp.server.transport_security import TransportSecuritySettings
from pydantic import Field
from starlette.responses import PlainTextResponse
from starlette.types import ASGIApp, Receive, Scope, Send
from app.config.settings import settings
from app.mcp.stats import get_mcp_stats_tracker
from app.shared.bootstrap import get_agent_conversation_service, get_jwt_handler
logger = logging.getLogger(__name__)
# Single shared MCPServer instance — analogous to the single shared FastAPI
# `app` instance in app/api/main.py. Tools registered via @mcp.tool() below.
# Note: the installed mcp SDK (2.0.0) renamed the older "FastMCP" class to
# "MCPServer" (mcp.server.mcpserver.MCPServer); the .tool()/.streamable_http_app()
# API surface used here is unchanged across that rename.
mcp = MCPServer("ai-regulations")
@mcp.tool()
def search_regulations(
query: Annotated[str, Field(min_length=1, max_length=2000)],
top_k: Annotated[int, Field(ge=1, le=20)] = 5,
) -> dict:
"""Search the compliance knowledge base and return a grounded answer.
query: Natural-language search question, e.g. "国六排放标准最新要求".
top_k: Maximum number of cited sources to return (1-20, default 5).
"""
# Bounds mirror AskRequest in app/api/models/agent.py so the MCP path cannot
# be used to bypass the REST endpoint's limits. They matter more here than
# there: KnowledgeRetrievalService amplifies top_k (candidate_k = top_k * 4)
# when reranking, and an LLM client can easily hallucinate a huge value.
# Declaring them via Annotated puts them in the advertised JSON schema too,
# so well-behaved clients never send an out-of-range value in the first place.
#
# No session_id is passed: this keeps each call stateless (no
# ConversationStore reads/writes), matching "search" semantics rather
# than multi-turn chat semantics.
started = time.perf_counter()
try:
_, result = get_agent_conversation_service().ask(query=query, top_k=top_k)
except Exception:
# Record the failure, then re-raise unchanged so the MCP SDK still
# converts it into a protocol-level error for the client. Swallowing
# it here would report success to the caller.
get_mcp_stats_tracker().record(
tool="search_regulations",
duration_ms=(time.perf_counter() - started) * 1000,
success=False,
)
raise
get_mcp_stats_tracker().record(
tool="search_regulations",
duration_ms=(time.perf_counter() - started) * 1000,
success=True,
)
return {
"answer": result.answer,
"sources": [source.__dict__ for source in result.sources],
}
class MCPAuthMiddleware:
"""Reject unauthenticated requests before they reach the MCP protocol handler.
Mirrors the existing get_current_user dependency's behavior (auth.py) but
implemented as raw ASGI middleware, since the mounted MCP app is a plain
ASGI app, not a FastAPI/APIRouter instance that supports Depends().
"""
def __init__(self, app: ASGIApp) -> None:
"""Store the wrapped ASGI app to delegate to once auth passes."""
self.app = app
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
"""Validate the bearer token for HTTP requests; pass non-HTTP scopes through."""
# Only HTTP requests carry an Authorization header to check; lifespan
# and other scope types must always pass through untouched.
if scope["type"] != "http" or not settings.auth_enabled:
await self.app(scope, receive, send)
return
headers = dict(scope["headers"])
# ASGI header values are raw bytes specified as latin-1, not UTF-8;
# decoding strictly as UTF-8 would raise on a malformed byte and turn a
# bad request into an unhandled 500.
auth_header = headers.get(b"authorization", b"").decode("latin-1")
token = auth_header.removeprefix("Bearer ").strip()
try:
get_jwt_handler().decode_token(token)
except ValueError as exc:
# Reject before the MCP session/protocol layer ever sees the request.
# WWW-Authenticate matches the get_current_user dependency (auth.py)
# and is required by RFC 7235 so clients can tell "needs credentials"
# apart from a generic failure.
response = PlainTextResponse(
str(exc), status_code=401, headers={"WWW-Authenticate": "Bearer"}
)
await response(scope, receive, send)
return
await self.app(scope, receive, send)
def _parse_allowed_hosts() -> list[str]:
"""Split the configured MCP host allow-list into individual entries."""
# Shared by the transport-security builder and the status endpoint so the
# panel can never display an allow-list different from the enforced one.
return [h.strip() for h in settings.mcp_allowed_hosts.split(",") if h.strip()]
def _build_transport_security() -> TransportSecuritySettings:
"""Translate the configured MCP host allow-list into SDK transport settings.
Without this the SDK infers its own allow-list from the bind host, which
defaults to 127.0.0.1 and therefore rejects every remote client with HTTP
421 fatal for a remotely deployed backend.
"""
allowed = _parse_allowed_hosts()
if "*" in allowed:
# Explicit, logged opt-out. Kept as an escape hatch for environments
# behind a proxy that rewrites Host unpredictably, but never the default.
logger.warning(
"MCP DNS-rebinding protection is disabled (mcp_allowed_hosts='*'). "
"Set MCP_ALLOWED_HOSTS to the real deployment host(s) instead."
)
return TransportSecuritySettings(enable_dns_rebinding_protection=False)
return TransportSecuritySettings(
enable_dns_rebinding_protection=True,
allowed_hosts=allowed,
# Browser clients send Origin; reuse the already-maintained CORS list so
# there is one place to declare trusted web origins. Non-browser MCP
# clients send no Origin at all, which the SDK treats as allowed.
allowed_origins=[o.strip() for o in settings.cors_allow_origins.split(",") if o.strip()],
)
def build_mcp_asgi_app() -> ASGIApp:
"""Return the Streamable HTTP ASGI app for the MCP server, auth-guarded.
streamable_http_path="/" is required here: MCPServer.streamable_http_app()
registers its own internal route at "/mcp" by default, and this app is
itself mounted at "/mcp" in api/main.py without overriding the internal
path to "/", the effective external path would be the confusing "/mcp/mcp"
instead of "/mcp".
"""
asgi_app = mcp.streamable_http_app(
streamable_http_path="/",
transport_security=_build_transport_security(),
)
asgi_app.add_middleware(MCPAuthMiddleware)
return asgi_app
async def get_mcp_status(public_url: str) -> dict:
"""Assemble the MCP status payload shown on the System Status page.
Owned by this module rather than the status route so that MCP internals
(the tool registry, the allow-list format, the stats tracker) stay behind
one boundary; the route only supplies public_url, which is the one value
only the HTTP layer can know.
"""
stats = get_mcp_stats_tracker().snapshot()
# list_tools() reads the in-memory registry populated by @mcp.tool() at
# import time, so the panel always reflects what is actually advertised
# rather than a hand-maintained duplicate list.
tools = await mcp.list_tools()
return {
"endpoint_url": public_url,
"auth_required": settings.auth_enabled,
"allowed_hosts": _parse_allowed_hosts(),
"tools": [
{
"name": tool.name,
"description": (tool.description or "").strip().split("\n")[0],
"calls": entry.calls if entry else 0,
"errors": entry.errors if entry else 0,
"avg_duration_ms": entry.avg_duration_ms if entry else None,
"last_called_at": (
entry.last_called_at.isoformat() if entry and entry.last_called_at else None
),
}
for tool, entry in ((tool, stats.get(tool.name)) for tool in tools)
],
}
+91
View File
@@ -0,0 +1,91 @@
"""In-memory per-tool call counters for the MCP server.
Lives in `app/mcp/` rather than `app/shared/` because these counters are
meaningful only for the MCP transport: they answer "is anything actually
calling our MCP endpoint, and does it work?" for the System Status page.
Token consumption is deliberately not tracked here MCP tool calls route
through AgentConversationService.ask() like every other caller, so the
existing ModelUsageTracker already accounts for it.
Counters are process-local and reset on restart. That is an accepted
tradeoff, recorded in the design spec: nothing billable depends on them.
"""
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 MCPToolStats:
"""Accumulated call outcomes for a single MCP tool."""
calls: int = 0
errors: int = 0
total_duration_ms: float = 0.0
last_called_at: datetime | None = None
@property
def avg_duration_ms(self) -> float | None:
"""Mean call duration, or None when the tool has never been called.
Returning None rather than 0.0 keeps "never called" distinguishable
from "called, but instantaneous" in the status UI.
"""
if self.calls == 0:
return None
return self.total_duration_ms / self.calls
class MCPStatsTracker:
"""Thread-safe registry of per-tool MCP call statistics.
The lock is load-bearing, not defensive habit: the mcp SDK dispatches
synchronous tool functions through anyio.to_thread.run_sync, so tool
bodies genuinely run on multiple worker threads at once unlike the
async REST routes, which are serialized by the event loop.
"""
def __init__(self) -> None:
"""Initialize an empty registry guarded by a single lock."""
self._tools: dict[str, MCPToolStats] = {}
# One coarse lock is enough: record() runs once per MCP tool call and
# snapshot() is only read by the low-traffic status endpoint.
self._lock = threading.Lock()
def record(self, *, tool: str, duration_ms: float, success: bool) -> None:
"""Record the outcome of one MCP tool invocation.
Never raises: a defect in observability code must not turn a working
tool call into a protocol error for the client.
"""
try:
# Coerce outside the lock so a bad argument cannot abort mid-update
# and leave calls incremented but duration unaccounted for.
duration = float(duration_ms)
now = datetime.now(timezone.utc)
with self._lock:
stats = self._tools.setdefault(tool, MCPToolStats())
stats.calls += 1
if not success:
stats.errors += 1
stats.total_duration_ms += duration
stats.last_called_at = now
except Exception as exc: # noqa: BLE001 - tracking must never break a real call
logger.warning("MCPStatsTracker.record failed for tool {} - {}", tool, exc)
def snapshot(self) -> dict[str, MCPToolStats]:
"""Return a shallow copy of all tracked tools, safe to read outside the lock."""
with self._lock:
return dict(self._tools)
@lru_cache
def get_mcp_stats_tracker() -> MCPStatsTracker:
"""Return the process-wide singleton tracker (mirrors get_model_usage_tracker())."""
return MCPStatsTracker()
+5
View File
@@ -12,6 +12,11 @@ class RagChatRequest(BaseModel):
top_k: int = 5
session_id: Optional[str] = None
filters: Optional[str] = None
# Optional document text to inject directly as LLM conversation context.
# When provided the document content is prepended to the query so the LLM
# can answer questions about it without requiring vector-store indexing.
context_text: Optional[str] = None
context_filename: Optional[str] = None
class RetrievedDoc(BaseModel):
+21 -2
View File
@@ -1,9 +1,16 @@
"""Provide service-layer logic for base client."""
"""Provide service-layer logic for base client.
P0-0: ``LLMResponse`` now carries an optional ``tool_calls`` list so that any
downstream code (agents, pipelines) can inspect and dispatch tool invocations
without touching the provider-specific adapter layer.
"""
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import List, Dict, Optional, Any
from enum import Enum
from app.services.llm.tool_types import Tool, ToolCall # noqa: F401 re-exported for callers
# Keep provider-specific behavior explicit so debugging stays straightforward.
@@ -24,6 +31,8 @@ class LLMResponse:
finish_reason: str = "stop"
latency_ms: int = 0
error: Optional[str] = None
# P0-0: populated when the model returns tool-call(s) instead of plain text.
tool_calls: List[ToolCall] = field(default_factory=list)
@property
def is_success(self) -> bool:
@@ -63,9 +72,19 @@ class BaseLLMClient(ABC):
messages: List[Dict[str, str]],
max_tokens: Optional[int] = None,
temperature: Optional[float] = None,
tools: Optional[List["Tool"]] = None,
**kwargs
) -> LLMResponse:
"""Handle chat for the Base L L M Client instance."""
"""Handle chat for the Base L L M Client instance.
Args:
messages: OpenAI-format message list.
max_tokens: Override config max_tokens when set.
temperature: Override config temperature when set.
tools: Optional list of Tool definitions to offer the model.
When provided, the model may respond with tool_calls in the
returned LLMResponse instead of (or in addition to) content.
"""
pass
def complete(
+50 -9
View File
@@ -1,11 +1,16 @@
"""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
from typing import List, Dict, Optional, Generator
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:
@@ -101,8 +130,14 @@ class DeepSeekClient(BaseLLMClient):
max_tokens: Optional[int] = None,
temperature: Optional[float] = None,
**kwargs
):
"""Stream chat for the Deep Seek Client instance."""
) -> Generator[str, None, Optional[Dict[str, int]]]:
"""Stream chat for the Deep Seek Client instance.
Returns the trailing token-usage dict as the generator's return value
(read via StopIteration.value when manually driven with next()) when
the gateway sends one via stream_options.include_usage, else None.
"""
usage: Optional[Dict[str, int]] = None
try:
payload = {
"model": self.config.model,
@@ -110,7 +145,8 @@ class DeepSeekClient(BaseLLMClient):
"max_tokens": max_tokens or self.config.max_tokens,
"temperature": temperature or self.config.temperature,
"top_p": kwargs.get("top_p", self.config.top_p),
"stream": True
"stream": True,
"stream_options": {"include_usage": True}
}
with self._client.stream("POST", "/chat/completions", json=payload) as response:
@@ -139,6 +175,9 @@ class DeepSeekClient(BaseLLMClient):
content = delta.get("content", "")
if content:
yield content
elif data.get("usage"):
# Trailing usage-only chunk — no content to yield, just capture it.
usage = data["usage"]
except json.JSONDecodeError:
continue
@@ -149,6 +188,8 @@ class DeepSeekClient(BaseLLMClient):
logger.error(f"DeepSeek Stream调用失败: {e}")
yield ""
return usage
def get_available_models(self) -> List[str]:
"""Return available models for the Deep Seek Client instance."""
return self.SUPPORTED_MODELS
+15 -6
View File
@@ -7,6 +7,8 @@ from functools import lru_cache
from .base_client import BaseLLMClient, LLMConfig, LLMProvider, LLMResponse
from .deepseek_client import DeepSeekClient
from .qwen_client import QwenClient, QwenVLClient
from .tracked_client import TrackedLLMClient
from app.shared.model_usage_tracker import get_model_usage_tracker
# Keep provider-specific behavior explicit so debugging stays straightforward.
@@ -14,7 +16,7 @@ from .qwen_client import QwenClient, QwenVLClient
# Keep provider-specific behavior explicit so debugging stays straightforward.
DEFAULT_MODELS = {
LLMProvider.DEEPSEEK: "deepseek-v4-flash",
LLMProvider.QWEN: "qwen3.5-flash",
LLMProvider.QWEN: "qwen3.6-flash",
LLMProvider.QWEN_VL: "qwen3-vl-plus"
}
@@ -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."""
@@ -94,6 +101,8 @@ class LLMFactory:
"qwen-max": LLMProvider.QWEN,
"qwen3.5-flash": LLMProvider.QWEN,
"qwen3.5-plus": LLMProvider.QWEN,
"qwen3.6-flash": LLMProvider.QWEN,
"qwen3.6-plus": LLMProvider.QWEN,
"qwen_vl": LLMProvider.QWEN_VL,
"qwen-vl": LLMProvider.QWEN_VL,
"qwen-vl-plus": LLMProvider.QWEN_VL,
@@ -137,7 +146,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 +209,7 @@ def get_llm_client(
provider: str = "qwen",
model: Optional[str] = None,
**kwargs
) -> BaseLLMClient:
) -> "BaseLLMClient | TrackedLLMClient":
"""Return llm client."""
factory = get_llm_factory()
+66 -12
View File
@@ -1,4 +1,8 @@
"""Provide service-layer logic for qwen client."""
"""Provide service-layer logic for qwen client.
P0-0: ``chat()`` now accepts an optional ``tools`` list and parses ``tool_calls``
from the model response so that callers can dispatch tool invocations.
"""
import time
import json
@@ -7,6 +11,7 @@ from loguru import logger
import httpx
from .base_client import BaseLLMClient, LLMResponse, LLMConfig, LLMProvider
from .tool_types import Tool, ToolCall
# Keep provider-specific behavior explicit so debugging stays straightforward.
@@ -22,6 +27,8 @@ class QwenClient(BaseLLMClient):
"qwen-long",
"qwen3.5-flash",
"qwen3.5-plus",
"qwen3.6-flash",
"qwen3.6-plus",
"qwen3-plus",
"qwen2.5-72b-instruct",
"qwen2.5-32b-instruct",
@@ -54,14 +61,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 +83,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 +100,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:
@@ -112,8 +142,14 @@ class QwenClient(BaseLLMClient):
max_tokens: Optional[int] = None,
temperature: Optional[float] = None,
**kwargs
) -> Generator[str, None, None]:
"""Stream chat for the Qwen Client instance."""
) -> Generator[str, None, Optional[Dict[str, int]]]:
"""Stream chat for the Qwen Client instance.
Returns the trailing token-usage dict as the generator's return value
(read via StopIteration.value when manually driven with next()) when
the gateway sends one via stream_options.include_usage, else None.
"""
usage: Optional[Dict[str, int]] = None
try:
# Keep provider-specific behavior explicit so debugging stays straightforward.
payload = {
@@ -122,7 +158,8 @@ class QwenClient(BaseLLMClient):
"max_tokens": max_tokens or self.config.max_tokens,
"temperature": temperature or self.config.temperature,
"top_p": kwargs.get("top_p", self.config.top_p),
"stream": True # Keep provider-specific behavior explicit so debugging stays straightforward.
"stream": True, # Keep provider-specific behavior explicit so debugging stays straightforward.
"stream_options": {"include_usage": True}
}
# Keep provider-specific behavior explicit so debugging stays straightforward.
@@ -139,6 +176,9 @@ class QwenClient(BaseLLMClient):
data = json.loads(data_str)
choices = data.get("choices", [])
if not choices:
if data.get("usage"):
# Trailing usage-only chunk — capture it, nothing to yield.
usage = data["usage"]
continue # Keep provider-specific behavior explicit so debugging stays straightforward.
delta = choices[0].get("delta", {})
content = delta.get("content", "")
@@ -155,6 +195,8 @@ class QwenClient(BaseLLMClient):
logger.error(f"Qwen流式调用失败: {e}")
yield f"[ERROR: {str(e)}]"
return usage
async def async_stream_chat(
self,
messages: List[Dict[str, str]],
@@ -271,8 +313,14 @@ class QwenVLClient(BaseLLMClient):
max_tokens: Optional[int] = None,
temperature: Optional[float] = None,
**kwargs
) -> Generator[str, None, None]:
"""Stream chat for the Qwen V L Client instance."""
) -> Generator[str, None, Optional[Dict[str, int]]]:
"""Stream chat for the Qwen V L Client instance.
Returns the trailing token-usage dict as the generator's return value
(read via StopIteration.value when manually driven with next()) when
the gateway sends one via stream_options.include_usage, else None.
"""
usage: Optional[Dict[str, int]] = None
try:
payload = {
"model": self.config.model,
@@ -280,7 +328,8 @@ class QwenVLClient(BaseLLMClient):
"max_tokens": max_tokens or self.config.max_tokens,
"temperature": temperature or self.config.temperature,
"top_p": kwargs.get("top_p", self.config.top_p),
"stream": True
"stream": True,
"stream_options": {"include_usage": True}
}
with self._client.stream("POST", "/chat/completions", json=payload) as response:
@@ -295,6 +344,9 @@ class QwenVLClient(BaseLLMClient):
data = json.loads(data_str)
choices = data.get("choices", [])
if not choices:
if data.get("usage"):
# Trailing usage-only chunk — capture it, nothing to yield.
usage = data["usage"]
continue # Keep provider-specific behavior explicit so debugging stays straightforward.
delta = choices[0].get("delta", {})
content = delta.get("content", "")
@@ -307,6 +359,8 @@ class QwenVLClient(BaseLLMClient):
logger.error(f"QwenVL流式调用失败: {e}")
yield f"[ERROR: {str(e)}]"
return usage
def get_available_models(self) -> List[str]:
"""Return available models for the Qwen V L Client instance."""
return self.SUPPORTED_MODELS
@@ -319,7 +373,7 @@ class QwenVLClient(BaseLLMClient):
def create_qwen_client(
api_key: str,
model: str = "qwen3.5-flash",
model: str = "qwen3.6-flash",
base_url: str = "http://6.86.80.4:30080/v1",
**kwargs
) -> QwenClient:
+80
View File
@@ -0,0 +1,80 @@
"""Shared tool and tool-call type definitions for LLM function calling (P0-0).
These types implement the OpenAI-compatible tool/function-calling interface so that
any provider whose gateway supports the spec (DeepSeek, Qwen, etc.) can expose
tools to the LLM and receive structured tool invocations in return.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
@dataclass
class ToolCall:
"""Represent a single tool invocation returned by the LLM.
The model fills in ``id``, ``name``, and ``arguments`` when it decides to call
a tool instead of (or in addition to) producing a text response.
"""
# Unique identifier assigned by the model for this call.
id: str
# Name of the tool to invoke, matching the name registered in Tool.
name: str
# Parsed JSON arguments ready for direct use by the tool handler.
arguments: dict[str, Any] = field(default_factory=dict)
@dataclass
class ToolParameter:
"""JSON-Schemacompatible parameter block for a tool definition."""
# Top-level schema type — always "object" for OpenAI-compatible tools.
type: str = "object"
# Map of parameter name → JSON-Schema property descriptor.
properties: dict[str, Any] = field(default_factory=dict)
# List of required parameter names.
required: list[str] = field(default_factory=list)
@dataclass
class Tool:
"""Describe a callable tool that can be offered to the LLM.
Example usage::
search_tool = Tool(
name="search_regulations",
description="Search the compliance knowledge base for relevant regulation clauses.",
parameters=ToolParameter(
properties={"query": {"type": "string", "description": "Search query"}},
required=["query"],
),
)
response = client.chat(messages, tools=[search_tool])
"""
name: str
description: str
parameters: ToolParameter = field(default_factory=ToolParameter)
def to_openai_format(self) -> dict[str, Any]:
"""Serialise this tool to the OpenAI-compatible function-calling schema.
The returned dict can be placed directly in the ``tools`` list of a chat
completions request without any further transformation.
"""
return {
"type": "function",
"function": {
"name": self.name,
"description": self.description,
"parameters": {
"type": self.parameters.type,
"properties": self.parameters.properties,
"required": self.parameters.required,
},
},
}
@@ -0,0 +1,93 @@
"""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 and usage.
Drives the inner generator manually (instead of a plain `for` loop) so
it can capture the generator's return value via StopIteration.value —
the trailing token-usage dict the inner client captures from a
stream_options.include_usage chunk, if the gateway sent one.
"""
start = time.time()
error: Optional[str] = None
usage: Optional[Dict[str, int]] = None
gen = self._inner.stream_chat(messages, *args, **kwargs)
try:
while True:
try:
chunk = next(gen)
except StopIteration as stop:
usage = stop.value
break
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,
usage=usage,
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)
+108
View File
@@ -2,10 +2,14 @@
from __future__ import annotations
import asyncio
from functools import lru_cache
from typing import Callable
from loguru import logger
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
@@ -19,8 +23,10 @@ from app.infrastructure.parser.local_chunk_builder import LocalRegulationChunkBu
from app.infrastructure.parser.local_document_parser import LocalDocumentParser
from app.infrastructure.parser.vector_chunk_builder import AliyunVectorChunkBuilder
from app.infrastructure.perception.mock_event_store import MockEventStore
from app.infrastructure.perception.mock_notification_store import MockNotificationStore
from app.application.perception.crawl_service import CrawlService
from app.infrastructure.perception.base_event_store import BaseEventStore
from app.infrastructure.perception.base_notification_store import BaseNotificationStore
from app.infrastructure.perception.crawlers.catarc_crawler import CatarcCrawler
from app.infrastructure.perception.crawlers.guobiao_crawler import (
GuobiaoMandatoryCrawler,
@@ -35,6 +41,7 @@ from app.infrastructure.storage.minio_binary_store import MinioDocumentBinarySto
from app.infrastructure.storage.postgres_document_processing_store import PostgresDocumentProcessingStore
from app.infrastructure.storage.postgres_document_repository import PostgresDocumentRepository
from app.infrastructure.storage.postgres_parse_artifact_store import PostgresParseArtifactStore
from app.infrastructure.storage.postgres_model_usage_store import PostgresModelUsageStore
from app.infrastructure.vectorstore.bm25_retriever import BM25Retriever
from app.infrastructure.vectorstore.cross_encoder_reranker import OpenAICompatibleReranker
from app.infrastructure.vectorstore.dense_retriever import DenseRetriever
@@ -42,6 +49,7 @@ from app.infrastructure.vectorstore.milvus_vector_index import MilvusVectorIndex
from app.services.llm.llm_factory import LLMFactory
from app.domain.compliance.ports import ComplianceRepository
from app.infrastructure.compliance.repository import PostgresComplianceRepository
from app.shared.model_usage_tracker import get_model_usage_tracker
# Keep shared wiring centralized so dependency construction remains consistent.
@@ -161,6 +169,14 @@ def get_parse_artifact_store():
return None
@lru_cache
def get_model_usage_store():
"""Return the Postgres model-usage store, or None when postgres backend is not enabled."""
if settings.document_repository_backend == "postgres":
return PostgresModelUsageStore()
return None
@lru_cache
def get_document_processing_store():
"""Return document processing store for the active repository backend."""
@@ -313,6 +329,22 @@ def get_event_store() -> BaseEventStore:
return MockEventStore()
@lru_cache
def get_notification_store() -> BaseNotificationStore:
"""Return notification store selected by DOCUMENT_REPOSITORY_BACKEND setting.
Mirrors get_event_store()'s gate: Mock in-memory when Postgres isn't
configured, so the feature works in local dev and tests without a
database.
"""
if settings.document_repository_backend == "postgres":
from app.infrastructure.perception.postgres_notification_store import (
PostgresNotificationStore,
)
return PostgresNotificationStore()
return MockNotificationStore()
@lru_cache
def get_compliance_repository() -> ComplianceRepository:
"""Return the compliance analysis repository.
@@ -356,6 +388,9 @@ def get_crawl_service() -> CrawlService:
event_store=get_event_store(),
llm_pipeline=LlmPipeline(),
retrieval_service=get_retrieval_service(),
notification_store=get_notification_store(),
embedding_provider=get_embedding_provider(),
vector_index=get_vector_index(),
)
@@ -365,6 +400,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.
@@ -397,8 +446,67 @@ def get_user_store():
def preload_runtime_dependencies() -> None:
"""Warm dependencies that are safe and useful to preload during startup."""
LLMFactory.preload_clients(["qwen", "deepseek"])
_start_model_usage_persistence()
def cleanup_runtime_dependencies() -> None:
"""Release runtime dependencies that expose explicit cleanup hooks."""
LLMFactory.cleanup()
_stop_model_usage_persistence()
_model_usage_flush_task: "asyncio.Task | None" = None
def _start_model_usage_persistence() -> None:
"""Seed ModelUsageTracker from Postgres and start its periodic flush loop.
No-op when document_repository_backend != "postgres" ModelUsageTracker
then keeps behaving exactly as it always has: purely in-memory, reset on
every restart. Never raises: persistence must not block app startup.
"""
global _model_usage_flush_task
try:
store = get_model_usage_store()
except Exception as exc: # noqa: BLE001 - persistence must never block startup
logger.warning("Failed to initialize model usage persistence: {}", exc)
return
if store is None:
return
tracker = get_model_usage_tracker()
try:
tracker.seed(store.load_all())
except Exception as exc: # noqa: BLE001 - a bad load must not block startup
logger.warning("Failed to load persisted model usage stats: {}", exc)
async def _flush_loop() -> None:
"""Snapshot the tracker into Postgres every 60 seconds until cancelled."""
while True:
await asyncio.sleep(60)
try:
await asyncio.to_thread(store.flush, tracker.snapshot())
except Exception as exc: # noqa: BLE001 - one bad cycle must not kill the loop
logger.warning("Failed to flush model usage stats: {}", exc)
_model_usage_flush_task = asyncio.create_task(_flush_loop())
def _stop_model_usage_persistence() -> None:
"""Cancel the periodic flush task and perform one best-effort final flush."""
global _model_usage_flush_task
if _model_usage_flush_task is not None:
_model_usage_flush_task.cancel()
_model_usage_flush_task = None
try:
store = get_model_usage_store()
except Exception as exc: # noqa: BLE001 - shutdown must not crash on this
logger.warning("Failed to access model usage store during shutdown: {}", exc)
return
if store is None:
return
try:
store.flush(get_model_usage_tracker().snapshot())
except Exception as exc: # noqa: BLE001 - shutdown must not crash on a flush failure
logger.warning("Failed final model usage flush: {}", exc)
+123
View File
@@ -0,0 +1,123 @@
"""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 seed(self, entries: dict[str, ModelUsageEntry]) -> None:
"""Bulk-load persisted entries (called once at startup, before any traffic).
Unlike record(), this replaces entries wholesale rather than
accumulating deltas it exists to restore counters saved by a
previous process run, not to record a new call.
"""
with self._lock:
self._entries.update(entries)
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()
+9
View File
@@ -2,6 +2,10 @@
fastapi>=0.110.0
uvicorn[standard]>=0.27.0
python-multipart>=0.0.9
# MCP server module (backend/app/mcp/) — pin >=2.0.0: that release renamed the
# older "FastMCP" class to "MCPServer" (mcp.server.MCPServer), which is the
# class actually used in app/mcp/server.py.
mcp>=2.0.0
# ── Config & utilities ────────────────────────────────────────────────────────
pydantic>=2.0.0
@@ -13,6 +17,11 @@ beautifulsoup4>=4.12.0
lxml>=5.0.0
tiktoken>=0.5.0
tenacity>=8.2.0
# Regulatory signal crawling (backend/app/infrastructure/perception/) — main-content
# extraction from crawled regulation detail pages and character-level diff for change
# detection. Import name for diff-match-patch is diff_match_patch (underscored).
trafilatura>=2.0.0
diff-match-patch>=20241021
# ── Auth ──────────────────────────────────────────────────────────────────────
python-jose[cryptography]>=3.3.0
+27
View File
@@ -0,0 +1,27 @@
"""Shared pytest fixtures and import-time guards for the backend test suite.
pytest imports this file before any test module beneath backend/tests/, which
makes it the only reliable place to install import-time guards: individual test
modules cannot guarantee they run first, because collection order follows
directory names.
"""
from __future__ import annotations
import sys
from unittest.mock import MagicMock
# app/shared/bootstrap.py (the composition root) eagerly imports the Postgres
# store modules, which do `import psycopg2` at their own module scope and later
# open a real connection pool. Any test that transitively imports bootstrap
# would therefore bind the real driver and attempt a live TCP connection to the
# configured production database, surfacing as a multi-second timeout rather
# than an obvious error. Binding mocks here — before the first test module is
# imported — makes that impossible regardless of collection order.
# setdefault (not assignment) keeps a real psycopg2 in place if something has
# already imported it deliberately.
_mock_psycopg2 = MagicMock()
_mock_psycopg2.extras = MagicMock()
sys.modules.setdefault("psycopg2", _mock_psycopg2)
sys.modules.setdefault("psycopg2.extras", _mock_psycopg2.extras)
sys.modules.setdefault("psycopg2.pool", MagicMock())
+2
View File
@@ -0,0 +1,2 @@
"""Test package for the MCP module (backend/app/mcp/)."""
# Empty package marker — no shared fixtures needed yet for this small test suite.
@@ -0,0 +1,93 @@
"""Unit tests for MCPAuthMiddleware.
Wraps a minimal dummy ASGI app (not the real MCP app) so these tests exercise
only the auth gate, not the MCP protocol itself keeps the test fast and
independent of FastMCP internals.
"""
from __future__ import annotations
from unittest.mock import patch
from starlette.applications import Starlette
from starlette.responses import PlainTextResponse
from starlette.routing import Route
from starlette.testclient import TestClient
from app.mcp.server import MCPAuthMiddleware
def _dummy_app() -> Starlette:
"""Build a minimal Starlette app that MCPAuthMiddleware can wrap."""
async def _ok(request):
"""Return a fixed 200 response so tests can assert pass-through."""
return PlainTextResponse("ok")
app = Starlette(routes=[Route("/ping", _ok)])
app.add_middleware(MCPAuthMiddleware)
return app
def test_missing_token_rejected_when_auth_enabled():
"""No Authorization header + auth_enabled=True -> 401."""
with patch("app.mcp.server.settings") as fake_settings:
fake_settings.auth_enabled = True
client = TestClient(_dummy_app())
response = client.get("/ping")
assert response.status_code == 401
def test_invalid_token_rejected_when_auth_enabled():
"""A token that fails decode_token() -> 401, request never reaches the app."""
fake_handler = type("H", (), {"decode_token": lambda self, t: (_ for _ in ()).throw(ValueError("bad token"))})()
with patch("app.mcp.server.settings") as fake_settings, \
patch("app.mcp.server.get_jwt_handler", return_value=fake_handler):
fake_settings.auth_enabled = True
client = TestClient(_dummy_app())
response = client.get("/ping", headers={"Authorization": "Bearer garbage"})
assert response.status_code == 401
def test_valid_token_passes_through_when_auth_enabled():
"""A token that decodes successfully -> request reaches the wrapped app."""
fake_handler = type("H", (), {"decode_token": lambda self, t: object()})()
with patch("app.mcp.server.settings") as fake_settings, \
patch("app.mcp.server.get_jwt_handler", return_value=fake_handler):
fake_settings.auth_enabled = True
client = TestClient(_dummy_app())
response = client.get("/ping", headers={"Authorization": "Bearer good"})
assert response.status_code == 200
assert response.text == "ok"
def test_auth_disabled_always_passes_through():
"""auth_enabled=False (dev mode) -> no token needed, matches get_current_user's dev bypass."""
with patch("app.mcp.server.settings") as fake_settings:
fake_settings.auth_enabled = False
client = TestClient(_dummy_app())
response = client.get("/ping")
assert response.status_code == 200
def test_401_includes_www_authenticate_header():
"""RFC 7235 requires WWW-Authenticate on 401 so clients can tell why they failed."""
with patch("app.mcp.server.settings") as fake_settings:
fake_settings.auth_enabled = True
client = TestClient(_dummy_app())
response = client.get("/ping")
assert response.status_code == 401
assert response.headers["WWW-Authenticate"] == "Bearer"
def test_non_utf8_authorization_header_is_rejected_not_crashed():
"""A non-UTF-8 header byte must yield a clean 401, not an unhandled 500.
ASGI header values are latin-1 bytes, so any remote client could otherwise
trigger a UnicodeDecodeError inside the middleware at will.
"""
with patch("app.mcp.server.settings") as fake_settings:
fake_settings.auth_enabled = True
client = TestClient(_dummy_app(), raise_server_exceptions=False)
# Bypass the http client's own header encoding by writing raw bytes.
response = client.get("/ping", headers={"Authorization": b"Bearer \xff\xfe"})
assert response.status_code == 401
+100
View File
@@ -0,0 +1,100 @@
"""Tests for the in-memory MCP per-tool statistics tracker.
These pin the two properties the status panel depends on: counters stay exact
under the concurrent thread dispatch the mcp SDK uses, and recording never
raises into a live tool call.
"""
from __future__ import annotations
import threading
from app.mcp.stats import MCPStatsTracker, MCPToolStats, get_mcp_stats_tracker
def test_avg_duration_is_none_before_any_call():
"""A never-called tool reports None, not 0.0, so the UI can distinguish them."""
assert MCPToolStats().avg_duration_ms is None
def test_avg_duration_is_the_mean_of_recorded_durations():
"""Average is computed over all calls, successful or not."""
tracker = MCPStatsTracker()
for duration in (100.0, 200.0, 300.0):
tracker.record(tool="search_regulations", duration_ms=duration, success=True)
stats = tracker.snapshot()["search_regulations"]
assert stats.calls == 3
assert stats.avg_duration_ms == 200.0
def test_failures_increment_both_calls_and_errors():
"""errors is a subset of calls, so the UI can show "2 of 3 failed" honestly."""
tracker = MCPStatsTracker()
tracker.record(tool="t", duration_ms=1.0, success=True)
tracker.record(tool="t", duration_ms=1.0, success=False)
tracker.record(tool="t", duration_ms=1.0, success=False)
stats = tracker.snapshot()["t"]
assert stats.calls == 3
assert stats.errors == 2
def test_last_called_at_is_set_and_timezone_aware():
"""The panel renders this as a local time, which requires an aware datetime."""
tracker = MCPStatsTracker()
tracker.record(tool="t", duration_ms=1.0, success=True)
last_called = tracker.snapshot()["t"].last_called_at
assert last_called is not None
assert last_called.tzinfo is not None
def test_concurrent_record_calls_are_not_lost():
"""8 threads x 100 calls must total exactly 800.
Without the lock this loses increments non-deterministically. The mcp SDK
runs synchronous tool bodies via anyio.to_thread.run_sync, so this is the
real dispatch model, not a hypothetical.
"""
tracker = MCPStatsTracker()
def hammer() -> None:
for _ in range(100):
tracker.record(tool="search_regulations", duration_ms=1.0, success=True)
threads = [threading.Thread(target=hammer) for _ in range(8)]
for thread in threads:
thread.start()
for thread in threads:
thread.join()
stats = tracker.snapshot()["search_regulations"]
assert stats.calls == 800
assert stats.total_duration_ms == 800.0
def test_record_swallows_bad_input_instead_of_raising():
"""A malformed duration must not propagate into the caller's tool call."""
tracker = MCPStatsTracker()
tracker.record(tool="t", duration_ms="not-a-number", success=True) # type: ignore[arg-type]
# Coercion happens before the lock is taken, so the entry is never created
# in a half-updated state.
assert tracker.snapshot() == {}
def test_snapshot_is_a_copy_not_the_live_dict():
"""Callers mutating the snapshot must not corrupt the tracker."""
tracker = MCPStatsTracker()
tracker.record(tool="t", duration_ms=1.0, success=True)
snapshot = tracker.snapshot()
snapshot.clear()
assert "t" in tracker.snapshot()
def test_get_mcp_stats_tracker_returns_a_singleton():
"""Instrumentation and the status route must observe the same counters."""
assert get_mcp_stats_tracker() is get_mcp_stats_tracker()
+79
View File
@@ -0,0 +1,79 @@
"""Tests for get_mcp_status(), the payload behind the System Status MCP card.
Covers the join between the live tool registry and the stats tracker, plus
the two values the route supplies or the settings decide.
"""
from __future__ import annotations
import asyncio
from unittest.mock import patch
from app.mcp.stats import MCPStatsTracker
def _status(tracker: MCPStatsTracker | None = None, **setting_overrides) -> dict:
"""Call get_mcp_status() with an isolated tracker and patched settings.
The real tracker is a process-wide singleton, so tests must inject their
own instance or they leak counters into each other.
"""
from app.mcp.server import get_mcp_status, settings
patched = settings.model_copy(update=setting_overrides)
with (
patch("app.mcp.server.settings", patched),
patch("app.mcp.server.get_mcp_stats_tracker", return_value=tracker or MCPStatsTracker()),
):
return asyncio.run(get_mcp_status("http://6.86.80.9:8000/mcp/"))
def test_public_url_is_passed_through_unmodified():
"""The route owns URL resolution; get_mcp_status() must not rewrite it."""
assert _status()["endpoint_url"] == "http://6.86.80.9:8000/mcp/"
def test_auth_required_follows_settings():
"""The panel's auth badge must reflect live config, not a hard-coded value."""
assert _status(auth_enabled=True)["auth_required"] is True
assert _status(auth_enabled=False)["auth_required"] is False
def test_allowed_hosts_are_split_and_stripped():
"""Displayed allow-list must match the one the transport actually enforces."""
status = _status(mcp_allowed_hosts="6.86.80.9:* , 127.0.0.1:*,")
assert status["allowed_hosts"] == ["6.86.80.9:*", "127.0.0.1:*"]
def test_tools_come_from_the_live_registry_with_zeroed_stats():
"""An advertised but never-called tool reports zeros, not absence."""
tools = {tool["name"]: tool for tool in _status()["tools"]}
assert "search_regulations" in tools
assert tools["search_regulations"]["calls"] == 0
assert tools["search_regulations"]["errors"] == 0
assert tools["search_regulations"]["avg_duration_ms"] is None
assert tools["search_regulations"]["last_called_at"] is None
def test_recorded_stats_are_joined_onto_the_matching_tool():
"""Counters recorded by the instrumented tool must surface on that tool's row."""
tracker = MCPStatsTracker()
tracker.record(tool="search_regulations", duration_ms=120.0, success=True)
tracker.record(tool="search_regulations", duration_ms=80.0, success=False)
tool = next(t for t in _status(tracker)["tools"] if t["name"] == "search_regulations")
assert tool["calls"] == 2
assert tool["errors"] == 1
assert tool["avg_duration_ms"] == 100.0
# Serialized for JSON transport; the frontend parses it with new Date().
assert isinstance(tool["last_called_at"], str)
def test_description_is_the_first_docstring_line():
"""Multi-line tool docstrings must not blow up the card's row height."""
tool = next(t for t in _status()["tools"] if t["name"] == "search_regulations")
assert "\n" not in tool["description"]
assert tool["description"].startswith("Search the compliance knowledge base")
@@ -0,0 +1,107 @@
"""Tests for the MCP endpoint's DNS-rebinding (Host header) protection.
The MCP SDK auto-enables DNS-rebinding protection and derives its allow-list
from the bind host, which defaults to 127.0.0.1. Left alone, that rejects every
request whose Host header is the real deployment address (6.86.80.9:8000) with
HTTP 421 before the auth middleware or the tool ever runs. These tests pin
the configured allow-list behavior so that failure mode cannot come back.
"""
from __future__ import annotations
import json
from contextlib import contextmanager
from unittest.mock import patch
from starlette.testclient import TestClient
from app.mcp.server import _build_transport_security, build_mcp_asgi_app
# A minimal JSON-RPC initialize call. Reaching the MCP handler at all is what
# matters here; transport security rejects the request long before this body is
# parsed, so its exact contents only need to be structurally valid.
_INITIALIZE = {
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": {"name": "test", "version": "1.0"},
},
}
_HEADERS = {
"Content-Type": "application/json",
"Accept": "application/json, text/event-stream",
}
@contextmanager
def _mcp_client(allowed_hosts: str):
"""Yield a TestClient over the real MCP app with auth off and hosts configured.
The settings patch must stay active for the requests themselves, not just
for app construction, because MCPAuthMiddleware reads settings per request.
Entering the TestClient as a context manager is also required: it runs the
app's lifespan, without which the SDK's session manager task group is never
initialized and every request raises RuntimeError.
"""
with patch("app.mcp.server.settings") as fake_settings:
fake_settings.mcp_allowed_hosts = allowed_hosts
fake_settings.cors_allow_origins = "http://localhost:5173"
fake_settings.auth_enabled = False
with TestClient(build_mcp_asgi_app()) as client:
yield client
def test_remote_host_allowed_when_configured():
"""A configured non-loopback Host must reach the MCP handler, not 421."""
with _mcp_client("6.86.80.9:*,127.0.0.1:*") as client:
response = client.post(
"/", json=_INITIALIZE, headers={**_HEADERS, "Host": "6.86.80.9:8000"}
)
assert response.status_code == 200
assert "Invalid Host header" not in response.text
def test_unconfigured_host_still_rejected():
"""Protection must stay on: a Host outside the allow-list is refused with 421."""
with _mcp_client("6.86.80.9:*") as client:
response = client.post(
"/", json=_INITIALIZE, headers={**_HEADERS, "Host": "evil.example.com"}
)
assert response.status_code == 421
def test_initialize_response_is_event_stream():
"""Sanity check that a permitted request really completes the MCP handshake."""
with _mcp_client("6.86.80.9:*") as client:
response = client.post(
"/", json=_INITIALIZE, headers={**_HEADERS, "Host": "6.86.80.9:8000"}
)
assert response.status_code == 200
# The Streamable HTTP transport replies as SSE; the JSON-RPC result is
# embedded in a "data:" line rather than being the whole body.
payload = json.loads(response.text.split("data:", 1)[1].strip())
assert payload["result"]["serverInfo"]["name"] == "ai-regulations"
def test_wildcard_disables_protection_explicitly():
"""'*' is the documented opt-out; it must disable the check, not allow-list '*'."""
with patch("app.mcp.server.settings") as fake_settings:
fake_settings.mcp_allowed_hosts = "*"
fake_settings.cors_allow_origins = "http://localhost:5173"
security = _build_transport_security()
assert security.enable_dns_rebinding_protection is False
def test_allow_list_is_parsed_into_transport_settings():
"""Comma-separated config must become the SDK's allowed_hosts list verbatim."""
with patch("app.mcp.server.settings") as fake_settings:
fake_settings.mcp_allowed_hosts = "6.86.80.9:*, localhost:* ,"
fake_settings.cors_allow_origins = "http://localhost:5173"
security = _build_transport_security()
assert security.enable_dns_rebinding_protection is True
assert security.allowed_hosts == ["6.86.80.9:*", "localhost:*"]
assert security.allowed_origins == ["http://localhost:5173"]
@@ -0,0 +1,98 @@
"""Unit tests for the search_regulations MCP tool function.
Mocks AgentConversationService so no real retrieval/LLM call happens
verifies only the protocol-adapter contract: correct call shape in,
correct dict shape out.
"""
from __future__ import annotations
import asyncio
from dataclasses import dataclass
from unittest.mock import MagicMock, patch
@dataclass
class _FakeSource:
"""Minimal stand-in for a real Source dataclass (only __dict__ is used)."""
# A dataclass, not a MagicMock: the adapter serializes sources via
# source.__dict__, and a MagicMock's __dict__ is full of internal mock
# attributes, which would make the assertions meaningless.
doc_id: str
doc_title: str
score: float
@dataclass
class _FakeAnswerResult:
"""Minimal stand-in for AnswerResult — only .answer/.sources are read."""
answer: str
sources: list
def test_search_regulations_calls_agent_ask_without_session():
"""search_regulations must call ask() with no session_id (stateless search)."""
from app.mcp.server import search_regulations
fake_service = MagicMock()
fake_service.ask.return_value = (
None,
_FakeAnswerResult(answer="国六排放标准要求...", sources=[_FakeSource("doc-1", "国六标准", 0.9)]),
)
with patch("app.mcp.server.get_agent_conversation_service", return_value=fake_service):
search_regulations(query="国六排放标准最新要求", top_k=3)
fake_service.ask.assert_called_once_with(query="国六排放标准最新要求", top_k=3)
assert "session_id" not in fake_service.ask.call_args.kwargs
def test_search_regulations_shapes_response_dict():
"""The returned dict must expose 'answer' and 'sources' (list of plain dicts)."""
from app.mcp.server import search_regulations
fake_service = MagicMock()
fake_service.ask.return_value = (
None,
_FakeAnswerResult(answer="答案文本", sources=[_FakeSource("doc-2", "国标GB1589", 0.8)]),
)
with patch("app.mcp.server.get_agent_conversation_service", return_value=fake_service):
result = search_regulations(query="q")
assert result == {
"answer": "答案文本",
"sources": [{"doc_id": "doc-2", "doc_title": "国标GB1589", "score": 0.8}],
}
def test_search_regulations_default_top_k():
"""top_k defaults to 5 when the caller omits it."""
from app.mcp.server import search_regulations
fake_service = MagicMock()
fake_service.ask.return_value = (None, _FakeAnswerResult(answer="a", sources=[]))
with patch("app.mcp.server.get_agent_conversation_service", return_value=fake_service):
search_regulations(query="q")
assert fake_service.ask.call_args.kwargs["top_k"] == 5
def test_advertised_schema_bounds_top_k_and_query():
"""The advertised JSON schema must carry the same bounds as AskRequest.
Bounds declared via Annotated are what the SDK validates against and what
clients see, so asserting on the generated schema is the only way to catch
a regression that silently drops them.
"""
from app.mcp.server import mcp
schema = asyncio.run(mcp.list_tools())[0].input_schema["properties"]
assert schema["top_k"]["minimum"] == 1
assert schema["top_k"]["maximum"] == 20
assert schema["query"]["minLength"] == 1
assert schema["query"]["maxLength"] == 2000
@@ -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,104 @@
"""Unit tests for the model-usage persistence wiring in app.shared.bootstrap.
get_model_usage_store()'s settings-gating is tested the same way
tests/test_reranker_bootstrap.py tests get_reranker() by patching
"app.shared.bootstrap.settings" wholesale, matching this codebase's
established convention for testing @lru_cache settings-gated factories.
The remaining tests isolate _start_model_usage_persistence() /
_stop_model_usage_persistence() from get_model_usage_store() entirely (via
monkeypatch on the module-level function), so no real database or event loop
is needed anywhere in this file asyncio.create_task itself is also mocked.
"""
from __future__ import annotations
from unittest.mock import MagicMock, patch
# psycopg2 is mocked centrally in backend/tests/conftest.py, which pytest
# imports before any test module regardless of collection order.
from app.shared import bootstrap
from app.shared.model_usage_tracker import ModelUsageEntry, ModelUsageTracker
def test_get_model_usage_store_returns_none_when_not_postgres_backend():
"""get_model_usage_store() must be None unless document_repository_backend == 'postgres'."""
bootstrap.get_model_usage_store.cache_clear()
with patch("app.shared.bootstrap.settings") as mock_settings:
mock_settings.document_repository_backend = "json"
result = bootstrap.get_model_usage_store()
bootstrap.get_model_usage_store.cache_clear()
assert result is None
def test_get_model_usage_store_returns_instance_when_postgres_backend():
"""get_model_usage_store() must return a PostgresModelUsageStore when enabled.
ThreadedConnectionPool is mocked so no real connection is attempted; the
postgres_host/port/user/password/db values PostgresModelUsageStore reads
come from app.config.settings.settings directly (not from the
app.shared.bootstrap.settings reference mocked below), so they don't need
to be set here only document_repository_backend gates this factory.
"""
bootstrap.get_model_usage_store.cache_clear()
with patch("psycopg2.pool.ThreadedConnectionPool"), \
patch(
"app.infrastructure.storage.postgres_model_usage_store.PostgresModelUsageStore._ensure_schema"
), \
patch("app.shared.bootstrap.settings") as mock_settings:
mock_settings.document_repository_backend = "postgres"
result = bootstrap.get_model_usage_store()
bootstrap.get_model_usage_store.cache_clear()
from app.infrastructure.storage.postgres_model_usage_store import PostgresModelUsageStore
assert isinstance(result, PostgresModelUsageStore)
def test_start_model_usage_persistence_seeds_tracker_and_starts_flush_loop(monkeypatch):
"""When a store is available, startup must seed the tracker and schedule the flush task."""
fake_store = MagicMock()
fake_store.load_all.return_value = {
"deepseek:deepseek-v4-flash": ModelUsageEntry(
provider="deepseek", model="deepseek-v4-flash", total_tokens=99,
),
}
tracker = ModelUsageTracker()
monkeypatch.setattr(bootstrap, "get_model_usage_store", lambda: fake_store)
monkeypatch.setattr(bootstrap, "get_model_usage_tracker", lambda: tracker)
with patch("asyncio.create_task") as mock_create_task:
bootstrap._start_model_usage_persistence()
# Close the coroutine object passed to the mock so pytest doesn't warn
# about "coroutine was never awaited" — it was never meant to run here.
mock_create_task.call_args[0][0].close()
assert tracker.get("deepseek", "deepseek-v4-flash").total_tokens == 99
mock_create_task.assert_called_once()
bootstrap._stop_model_usage_persistence() # reset the module-level task handle
def test_start_model_usage_persistence_is_a_no_op_without_a_store(monkeypatch):
"""No store configured (json backend) — startup must not touch asyncio or the tracker."""
monkeypatch.setattr(bootstrap, "get_model_usage_store", lambda: None)
with patch("asyncio.create_task") as mock_create_task:
bootstrap._start_model_usage_persistence()
mock_create_task.assert_not_called()
def test_stop_model_usage_persistence_cancels_task_and_flushes(monkeypatch):
"""Shutdown must cancel the running flush task and perform one final flush."""
fake_store = MagicMock()
monkeypatch.setattr(bootstrap, "get_model_usage_store", lambda: fake_store)
fake_task = MagicMock()
bootstrap._model_usage_flush_task = fake_task
bootstrap._stop_model_usage_persistence()
fake_task.cancel.assert_called_once()
fake_store.flush.assert_called_once()
assert bootstrap._model_usage_flush_task is None
@@ -0,0 +1,102 @@
"""Unit tests for PostgresModelUsageStore, using a mocked psycopg2 pool.
Mirrors the mocking pattern in backend/tests/perception/test_postgres_event_store.py
no real database is needed.
"""
from __future__ import annotations
from datetime import datetime, timezone
from unittest.mock import MagicMock, patch
# psycopg2 is mocked centrally in backend/tests/conftest.py, so importing the
# module under test here never binds the real driver.
from app.shared.model_usage_tracker import ModelUsageEntry
def _cursor_returning(rows):
"""Build a MagicMock standing in for a psycopg2 cursor context manager."""
cursor = MagicMock()
cursor.__enter__ = lambda s: s
cursor.__exit__ = MagicMock(return_value=False)
cursor.fetchall.return_value = rows
return cursor
@patch("app.infrastructure.storage.postgres_model_usage_store.PostgresModelUsageStore._ensure_schema")
@patch("app.infrastructure.storage.postgres_model_usage_store.ThreadedConnectionPool")
def test_load_all_returns_entries_keyed_by_provider_model(mock_pool_class, mock_ensure):
"""load_all() must turn each row into a ModelUsageEntry keyed by 'provider:model'."""
row = {
"provider": "deepseek",
"model": "deepseek-v4-flash",
"total_tokens": 100,
"prompt_tokens": 60,
"completion_tokens": 40,
"call_count_ok": 5,
"call_count_error": 1,
"last_called_at": datetime(2026, 7, 23, tzinfo=timezone.utc),
"last_latency_ms": 250,
"last_error": None,
}
mock_pool = MagicMock()
mock_pool_class.return_value = mock_pool
conn = MagicMock()
conn.__enter__ = lambda s: s
conn.__exit__ = MagicMock(return_value=False)
conn.cursor.return_value = _cursor_returning([row])
mock_pool.getconn.return_value = conn
from app.infrastructure.storage.postgres_model_usage_store import PostgresModelUsageStore
store = PostgresModelUsageStore()
entries = store.load_all()
assert "deepseek:deepseek-v4-flash" in entries
entry = entries["deepseek:deepseek-v4-flash"]
assert isinstance(entry, ModelUsageEntry)
assert entry.total_tokens == 100
assert entry.call_count_error == 1
@patch("app.infrastructure.storage.postgres_model_usage_store.PostgresModelUsageStore._ensure_schema")
@patch("app.infrastructure.storage.postgres_model_usage_store.ThreadedConnectionPool")
def test_flush_upserts_every_entry(mock_pool_class, mock_ensure):
"""flush() must execute one UPSERT per tracked entry and commit once."""
mock_pool = MagicMock()
mock_pool_class.return_value = mock_pool
conn = MagicMock()
conn.__enter__ = lambda s: s
conn.__exit__ = MagicMock(return_value=False)
cursor = MagicMock()
cursor.__enter__ = lambda s: s
cursor.__exit__ = MagicMock(return_value=False)
conn.cursor.return_value = cursor
mock_pool.getconn.return_value = conn
from app.infrastructure.storage.postgres_model_usage_store import PostgresModelUsageStore
store = PostgresModelUsageStore()
entries = {
"deepseek:deepseek-v4-flash": ModelUsageEntry(
provider="deepseek", model="deepseek-v4-flash", total_tokens=100, call_count_ok=5,
),
}
store.flush(entries)
assert cursor.execute.call_count == 1
conn.commit.assert_called_once()
@patch("app.infrastructure.storage.postgres_model_usage_store.PostgresModelUsageStore._ensure_schema")
@patch("app.infrastructure.storage.postgres_model_usage_store.ThreadedConnectionPool")
def test_flush_with_no_entries_does_not_touch_the_database(mock_pool_class, mock_ensure):
"""flush({}) must be a no-op — no point opening a connection for nothing."""
mock_pool = MagicMock()
mock_pool_class.return_value = mock_pool
from app.infrastructure.storage.postgres_model_usage_store import PostgresModelUsageStore
store = PostgresModelUsageStore()
store.flush({})
mock_pool.getconn.assert_not_called()
@@ -0,0 +1,109 @@
"""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()
def test_seed_populates_registry_from_persisted_entries():
"""seed() must bulk-load entries (e.g. from Postgres at startup) into the registry."""
tracker = ModelUsageTracker()
persisted = {
"deepseek:deepseek-v4-flash": ModelUsageEntry(
provider="deepseek", model="deepseek-v4-flash", total_tokens=500, call_count_ok=20,
),
}
tracker.seed(persisted)
entry = tracker.get("deepseek", "deepseek-v4-flash")
assert entry.total_tokens == 500
assert entry.call_count_ok == 20
def test_seed_then_record_accumulates_on_top_of_seeded_value():
"""A call recorded after seeding must add to the seeded total, not replace it."""
tracker = ModelUsageTracker()
tracker.seed({
"deepseek:deepseek-v4-flash": ModelUsageEntry(
provider="deepseek", model="deepseek-v4-flash", total_tokens=500,
),
})
tracker.record(provider="deepseek", model="deepseek-v4-flash", success=True, usage={"total_tokens": 10})
assert tracker.get("deepseek", "deepseek-v4-flash").total_tokens == 510
@@ -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,116 @@
"""Unit tests verifying stream_chat() captures a trailing usage-only SSE chunk.
Exercises DeepSeekClient, QwenClient, and QwenVLClient directly (not through
TrackedLLMClient) by mocking the underlying httpx.Client.stream() call none
of these tests make a real network call.
"""
from __future__ import annotations
import json
from unittest.mock import MagicMock
from app.services.llm.base_client import LLMConfig, LLMProvider
from app.services.llm.deepseek_client import DeepSeekClient
def _sse_lines(*chunks: str, usage: dict | None = None) -> list[str]:
"""Build raw SSE 'data: ...' lines the way an OpenAI-compatible gateway sends them."""
lines = [
f'data: {json.dumps({"choices": [{"delta": {"content": c}}]})}'
for c in chunks
]
if usage is not None:
# Trailing usage-only chunk, as sent when stream_options.include_usage=true.
lines.append(f'data: {json.dumps({"choices": [], "usage": usage})}')
lines.append("data: [DONE]")
return lines
def _mock_streaming_client(lines: list[str]) -> MagicMock:
"""Build a MagicMock standing in for httpx.Client, configured for .stream()."""
fake_response = MagicMock()
fake_response.raise_for_status.return_value = None
fake_response.iter_lines.return_value = lines
stream_cm = MagicMock()
stream_cm.__enter__.return_value = fake_response
stream_cm.__exit__.return_value = False
client = MagicMock()
client.stream.return_value = stream_cm
return client
def _drain(gen):
"""Manually drive a generator, returning (yielded_chunks, stop_iteration_value)."""
chunks = []
value = None
while True:
try:
chunks.append(next(gen))
except StopIteration as stop:
value = stop.value
break
return chunks, value
def test_deepseek_stream_chat_returns_usage_from_trailing_chunk():
"""DeepSeekClient.stream_chat() must return the trailing usage dict."""
config = LLMConfig(provider=LLMProvider.DEEPSEEK, model="deepseek-v4-flash", api_key="k", base_url="http://x/v1")
client = DeepSeekClient(config)
usage = {"prompt_tokens": 5, "completion_tokens": 3, "total_tokens": 8}
client._client = _mock_streaming_client(_sse_lines("Hello", " world", usage=usage))
chunks, returned_usage = _drain(client.stream_chat([{"role": "user", "content": "hi"}]))
assert chunks == ["Hello", " world"]
assert returned_usage == usage
# The gateway must actually be asked to include usage in the stream.
sent_payload = client._client.stream.call_args.kwargs["json"]
assert sent_payload["stream_options"] == {"include_usage": True}
def test_deepseek_stream_chat_without_usage_chunk_returns_none():
"""If the gateway never sends a usage chunk, the generator returns None (unchanged behavior)."""
config = LLMConfig(provider=LLMProvider.DEEPSEEK, model="deepseek-v4-flash", api_key="k", base_url="http://x/v1")
client = DeepSeekClient(config)
client._client = _mock_streaming_client(_sse_lines("Hi"))
chunks, returned_usage = _drain(client.stream_chat([{"role": "user", "content": "hi"}]))
assert chunks == ["Hi"]
assert returned_usage is None
from app.services.llm.qwen_client import QwenClient, QwenVLClient
def test_qwen_stream_chat_returns_usage_from_trailing_chunk():
"""QwenClient.stream_chat() must return the trailing usage dict."""
config = LLMConfig(provider=LLMProvider.QWEN, model="qwen3.5-flash", api_key="k", base_url="http://x/v1")
client = QwenClient(config)
usage = {"prompt_tokens": 10, "completion_tokens": 4, "total_tokens": 14}
client._client = _mock_streaming_client(_sse_lines("Bonjour", usage=usage))
chunks, returned_usage = _drain(client.stream_chat([{"role": "user", "content": "hi"}]))
assert chunks == ["Bonjour"]
assert returned_usage == usage
sent_payload = client._client.stream.call_args.kwargs["json"]
assert sent_payload["stream_options"] == {"include_usage": True}
def test_qwen_vl_stream_chat_returns_usage_from_trailing_chunk():
"""QwenVLClient.stream_chat() must return the trailing usage dict."""
config = LLMConfig(provider=LLMProvider.QWEN_VL, model="qwen3-vl-plus", api_key="k", base_url="http://x/v1")
client = QwenVLClient(config)
usage = {"prompt_tokens": 20, "completion_tokens": 6, "total_tokens": 26}
client._client = _mock_streaming_client(_sse_lines("Describing image", usage=usage))
chunks, returned_usage = _drain(client.stream_chat([{"role": "user", "content": "describe"}]))
assert chunks == ["Describing image"]
assert returned_usage == usage
sent_payload = client._client.stream.call_args.kwargs["json"]
assert sent_payload["stream_options"] == {"include_usage": True}
@@ -0,0 +1,105 @@
"""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
def test_stream_chat_records_usage_from_generator_return_value():
"""stream_chat() must forward the inner generator's returned usage dict to record()."""
inner = _make_inner()
def fake_stream(*args, **kwargs):
yield "chunk-1"
yield "chunk-2"
return {"prompt_tokens": 6, "completion_tokens": 2, "total_tokens": 8}
inner.stream_chat.side_effect = fake_stream
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.total_tokens == 8
assert entry.call_count_ok == 1
+349 -7
View File
@@ -6,6 +6,7 @@ import pytest
from app.infrastructure.perception.crawlers.base import RawEvent
from app.infrastructure.perception.mock_event_store import MockEventStore
from app.infrastructure.perception.mock_notification_store import MockNotificationStore
def _make_raw_event(code="TST-001"):
@@ -17,11 +18,18 @@ def _make_raw_event(code="TST-001"):
)
def _make_crawler(raw_events, full_text="full body text"):
"""Build a mock crawler. `full_text=""` simulates a failed detail fetch."""
mock_crawler = MagicMock()
mock_crawler.fetch.return_value = raw_events
mock_crawler.fetch_full_text.return_value = full_text
return mock_crawler
def _make_service(raw_events):
from app.application.perception.crawl_service import CrawlService
mock_crawler = MagicMock()
mock_crawler.fetch.return_value = raw_events
mock_crawler = _make_crawler(raw_events)
mock_pipeline = MagicMock()
mock_pipeline.extract_structure.return_value = {
@@ -41,6 +49,9 @@ def _make_service(raw_events):
event_store=store,
llm_pipeline=mock_pipeline,
retrieval_service=mock_retrieval,
notification_store=MockNotificationStore(),
embedding_provider=MagicMock(),
vector_index=MagicMock(),
)
@@ -54,8 +65,7 @@ def test_crawl_yields_progress_and_done():
def test_crawl_upserts_to_store():
store = MockEventStore()
from app.application.perception.crawl_service import CrawlService
mock_crawler = MagicMock()
mock_crawler.fetch.return_value = [_make_raw_event("NEW-001")]
mock_crawler = _make_crawler([_make_raw_event("NEW-001")])
mock_pipeline = MagicMock()
mock_pipeline.extract_structure.return_value = {
"obligations": [], "deadlines": [], "scope": "",
@@ -70,6 +80,9 @@ def test_crawl_upserts_to_store():
event_store=store,
llm_pipeline=mock_pipeline,
retrieval_service=MagicMock(),
notification_store=MockNotificationStore(),
embedding_provider=MagicMock(),
vector_index=MagicMock(),
)
list(svc.run_crawl())
result = store.get_by_standard_code("NEW-001")
@@ -80,7 +93,8 @@ def test_crawl_upserts_to_store():
def test_crawl_skips_unchanged_events():
store = MockEventStore()
raw = _make_raw_event("SKIP-001")
content_hash = hashlib.sha256(raw.raw_text.encode()).hexdigest()
body = "full body text"
content_hash = hashlib.sha256(body.encode()).hexdigest()
store.upsert({
"id": hashlib.sha256(f"TEST-SKIP-001".encode()).hexdigest()[:12],
"standard_code": "SKIP-001",
@@ -99,13 +113,341 @@ def test_crawl_skips_unchanged_events():
})
mock_pipeline = MagicMock()
from app.application.perception.crawl_service import CrawlService
mock_crawler = MagicMock()
mock_crawler.fetch.return_value = [raw]
mock_crawler = _make_crawler([raw], full_text=body)
svc = CrawlService(
crawlers={"TEST": mock_crawler},
event_store=store,
llm_pipeline=mock_pipeline,
retrieval_service=MagicMock(),
notification_store=MockNotificationStore(),
embedding_provider=MagicMock(),
vector_index=MagicMock(),
)
list(svc.run_crawl())
mock_pipeline.extract_structure.assert_not_called()
def test_crawl_stores_the_fetched_body_for_the_next_diff():
"""The body must be persisted, or the next crawl has no baseline to compare."""
store = MockEventStore()
from app.application.perception.crawl_service import CrawlService
mock_pipeline = MagicMock()
mock_pipeline.extract_structure.return_value = {}
mock_pipeline.assess_impact.return_value = []
svc = CrawlService(
crawlers={"TEST": _make_crawler([_make_raw_event("BODY-001")], full_text="第一条 正文内容。")},
event_store=store,
llm_pipeline=mock_pipeline,
retrieval_service=MagicMock(),
notification_store=MockNotificationStore(),
embedding_provider=MagicMock(),
vector_index=MagicMock(),
)
list(svc.run_crawl())
stored = store.get_by_standard_code("BODY-001")
assert stored["raw_text"] == "第一条 正文内容。"
def test_crawl_falls_back_when_full_text_fetch_fails():
"""An unreachable detail page degrades to the list-page text, never crashes."""
store = MockEventStore()
from app.application.perception.crawl_service import CrawlService
mock_pipeline = MagicMock()
mock_pipeline.extract_structure.return_value = {}
mock_pipeline.assess_impact.return_value = []
svc = CrawlService(
crawlers={"TEST": _make_crawler([_make_raw_event("FALL-001")], full_text="")},
event_store=store,
llm_pipeline=mock_pipeline,
retrieval_service=MagicMock(),
notification_store=MockNotificationStore(),
embedding_provider=MagicMock(),
vector_index=MagicMock(),
)
list(svc.run_crawl())
stored = store.get_by_standard_code("FALL-001")
assert stored is not None
assert stored["raw_text"] == "full text"
def test_crawl_skips_diff_when_no_previous_body_exists():
"""Rows stored before raw_text was persisted must not be diffed against nothing."""
store = MockEventStore()
from app.application.perception.crawl_service import CrawlService
event_id = hashlib.sha256(b"TEST-OLD-001").hexdigest()[:12]
store.upsert({
"id": event_id,
"standard_code": "OLD-001",
"source": "TEST",
"title": "Test OLD-001",
"summary": "legacy row",
"impact_level": "low",
"published_at": "2026-01-01",
"tags": [],
"content_hash": "stale-hash-from-before-this-change",
})
mock_pipeline = MagicMock()
mock_pipeline.extract_structure.return_value = {}
mock_pipeline.assess_impact.return_value = []
svc = CrawlService(
crawlers={"TEST": _make_crawler([_make_raw_event("OLD-001")], full_text="第一条 新正文。")},
event_store=store,
llm_pipeline=mock_pipeline,
retrieval_service=MagicMock(),
notification_store=MockNotificationStore(),
embedding_provider=MagicMock(),
vector_index=MagicMock(),
)
list(svc.run_crawl())
mock_pipeline.compute_diff.assert_not_called()
assert store.get(event_id)["raw_text"] == "第一条 新正文。"
def test_new_event_creates_a_new_notification():
"""A brand-new event must produce exactly one kind='new' notification."""
from app.application.perception.crawl_service import CrawlService
store = MockEventStore()
notifications = MockNotificationStore()
mock_pipeline = MagicMock()
mock_pipeline.extract_structure.return_value = {}
mock_pipeline.assess_impact.return_value = []
svc = CrawlService(
crawlers={"TEST": _make_crawler([_make_raw_event("NOTIF-NEW")])},
event_store=store,
llm_pipeline=mock_pipeline,
retrieval_service=MagicMock(),
notification_store=notifications,
embedding_provider=MagicMock(),
vector_index=MagicMock(),
)
list(svc.run_crawl())
items = notifications.list_for_user("any-user")
assert len(items) == 1
assert items[0]["kind"] == "new"
def test_significant_change_creates_a_changed_notification():
"""A numeric or deontic change must produce a kind='changed' notification."""
from app.application.perception.crawl_service import CrawlService
store = MockEventStore()
event_id = hashlib.sha256(b"TEST-SIG-001").hexdigest()[:12]
store.upsert({
"id": event_id, "standard_code": "SIG-001", "source": "TEST",
"title": "Test SIG-001", "summary": "", "impact_level": "medium",
"published_at": "2026-01-01", "tags": [],
"content_hash": "old-hash", "raw_text": "old body",
})
notifications = MockNotificationStore()
mock_pipeline = MagicMock()
mock_pipeline.extract_structure.return_value = {}
mock_pipeline.assess_impact.return_value = []
mock_pipeline.compute_diff.return_value = {
"changed_sections": [{"change_type": "modified", "numeric_changed": True, "deontic_changed": False}],
"change_summary": "1 paragraph changed (numeric).",
}
svc = CrawlService(
crawlers={"TEST": _make_crawler([_make_raw_event("SIG-001")], full_text="new body")},
event_store=store,
llm_pipeline=mock_pipeline,
retrieval_service=MagicMock(),
notification_store=notifications,
embedding_provider=MagicMock(),
vector_index=MagicMock(),
)
list(svc.run_crawl())
items = notifications.list_for_user("any-user")
assert len(items) == 1
assert items[0]["kind"] == "changed"
def test_cosmetic_only_change_creates_no_notification():
"""A change with no numeric/deontic/added/removed section must not notify."""
from app.application.perception.crawl_service import CrawlService
store = MockEventStore()
event_id = hashlib.sha256(b"TEST-COS-001").hexdigest()[:12]
store.upsert({
"id": event_id, "standard_code": "COS-001", "source": "TEST",
"title": "Test COS-001", "summary": "", "impact_level": "low",
"published_at": "2026-01-01", "tags": [],
"content_hash": "old-hash", "raw_text": "old body.",
})
notifications = MockNotificationStore()
mock_pipeline = MagicMock()
mock_pipeline.extract_structure.return_value = {}
mock_pipeline.assess_impact.return_value = []
mock_pipeline.compute_diff.return_value = {
"changed_sections": [{"change_type": "modified", "numeric_changed": False, "deontic_changed": False}],
"change_summary": "cosmetic only",
}
svc = CrawlService(
crawlers={"TEST": _make_crawler([_make_raw_event("COS-001")], full_text="old body")},
event_store=store,
llm_pipeline=mock_pipeline,
retrieval_service=MagicMock(),
notification_store=notifications,
embedding_provider=MagicMock(),
vector_index=MagicMock(),
)
list(svc.run_crawl())
assert notifications.list_for_user("any-user") == []
def test_notification_store_failure_does_not_abort_the_crawl():
"""A broken notification store must not stop the crawl or raise."""
from app.application.perception.crawl_service import CrawlService
store = MockEventStore()
broken_notifications = MagicMock()
broken_notifications.create.side_effect = RuntimeError("notification db down")
mock_pipeline = MagicMock()
mock_pipeline.extract_structure.return_value = {}
mock_pipeline.assess_impact.return_value = []
svc = CrawlService(
crawlers={"TEST": _make_crawler([_make_raw_event("BROKEN-001")])},
event_store=store,
llm_pipeline=mock_pipeline,
retrieval_service=MagicMock(),
notification_store=broken_notifications,
embedding_provider=MagicMock(),
vector_index=MagicMock(),
)
events = list(svc.run_crawl())
assert any(e.get("event") == "done" for e in events)
assert store.get_by_standard_code("BROKEN-001") is not None
def test_new_event_is_indexed_in_the_knowledge_base():
"""A brand-new event must be chunked, embedded, and upserted into Milvus."""
from app.application.perception.crawl_service import CrawlService
body = "第一条 本标准规定了车辆制动系统的技术要求。\n第二条 车辆制动系统应在时速50公里条件下于30米内完全停止。"
embedding_provider = MagicMock()
embedding_provider.embed_texts.return_value = [[0.1] * 8]
vector_index = MagicMock()
mock_pipeline = MagicMock()
mock_pipeline.extract_structure.return_value = {}
mock_pipeline.assess_impact.return_value = []
svc = CrawlService(
crawlers={"TEST": _make_crawler([_make_raw_event("IDX-NEW")], full_text=body)},
event_store=MockEventStore(),
llm_pipeline=mock_pipeline,
retrieval_service=MagicMock(),
notification_store=MockNotificationStore(),
embedding_provider=embedding_provider,
vector_index=vector_index,
)
list(svc.run_crawl())
event_id = hashlib.sha256(b"TEST-IDX-NEW").hexdigest()[:12]
vector_index.delete_by_document.assert_called_once_with(event_id)
vector_index.upsert.assert_called_once()
chunks_arg = vector_index.upsert.call_args.args[0]
assert len(chunks_arg) > 0
def test_significant_change_reindexes_the_knowledge_base():
"""A numeric/deontic change must delete the stale chunks and upsert new ones."""
from app.application.perception.crawl_service import CrawlService
store = MockEventStore()
event_id = hashlib.sha256(b"TEST-IDX-SIG").hexdigest()[:12]
store.upsert({
"id": event_id, "standard_code": "IDX-SIG", "source": "TEST",
"title": "Test IDX-SIG", "summary": "", "impact_level": "medium",
"published_at": "2026-01-01", "tags": [],
"content_hash": "old-hash", "raw_text": "old body",
})
embedding_provider = MagicMock()
embedding_provider.embed_texts.return_value = [[0.1] * 8]
vector_index = MagicMock()
mock_pipeline = MagicMock()
mock_pipeline.extract_structure.return_value = {}
mock_pipeline.assess_impact.return_value = []
mock_pipeline.compute_diff.return_value = {
"changed_sections": [{"change_type": "modified", "numeric_changed": True, "deontic_changed": False}],
"change_summary": "numeric change",
}
svc = CrawlService(
crawlers={"TEST": _make_crawler([_make_raw_event("IDX-SIG")], full_text="new body with a number 20米")},
event_store=store,
llm_pipeline=mock_pipeline,
retrieval_service=MagicMock(),
notification_store=MockNotificationStore(),
embedding_provider=embedding_provider,
vector_index=vector_index,
)
list(svc.run_crawl())
vector_index.delete_by_document.assert_called_once_with(event_id)
vector_index.upsert.assert_called_once()
def test_cosmetic_only_change_does_not_touch_the_knowledge_base():
"""A punctuation-only edit must not trigger embedding or a Milvus write."""
from app.application.perception.crawl_service import CrawlService
store = MockEventStore()
event_id = hashlib.sha256(b"TEST-IDX-COS").hexdigest()[:12]
store.upsert({
"id": event_id, "standard_code": "IDX-COS", "source": "TEST",
"title": "Test IDX-COS", "summary": "", "impact_level": "low",
"published_at": "2026-01-01", "tags": [],
"content_hash": "old-hash", "raw_text": "old body.",
})
embedding_provider = MagicMock()
vector_index = MagicMock()
mock_pipeline = MagicMock()
mock_pipeline.extract_structure.return_value = {}
mock_pipeline.assess_impact.return_value = []
mock_pipeline.compute_diff.return_value = {
"changed_sections": [{"change_type": "modified", "numeric_changed": False, "deontic_changed": False}],
"change_summary": "cosmetic only",
}
svc = CrawlService(
crawlers={"TEST": _make_crawler([_make_raw_event("IDX-COS")], full_text="old body")},
event_store=store,
llm_pipeline=mock_pipeline,
retrieval_service=MagicMock(),
notification_store=MockNotificationStore(),
embedding_provider=embedding_provider,
vector_index=vector_index,
)
list(svc.run_crawl())
embedding_provider.embed_texts.assert_not_called()
vector_index.upsert.assert_not_called()
def test_vector_index_failure_does_not_abort_the_crawl():
"""A broken vector index must not stop the crawl or raise."""
from app.application.perception.crawl_service import CrawlService
broken_vector_index = MagicMock()
broken_vector_index.upsert.side_effect = RuntimeError("milvus unreachable")
store = MockEventStore()
mock_pipeline = MagicMock()
mock_pipeline.extract_structure.return_value = {}
mock_pipeline.assess_impact.return_value = []
svc = CrawlService(
crawlers={"TEST": _make_crawler([_make_raw_event("IDX-FAIL")], full_text="第一条 正文内容。")},
event_store=store,
llm_pipeline=mock_pipeline,
retrieval_service=MagicMock(),
notification_store=MockNotificationStore(),
embedding_provider=MagicMock(embed_texts=MagicMock(return_value=[[0.1] * 8])),
vector_index=broken_vector_index,
)
events = list(svc.run_crawl())
assert any(e.get("event") == "done" for e in events)
assert store.get_by_standard_code("IDX-FAIL") is not None
+121 -34
View File
@@ -1,28 +1,34 @@
"""Unit tests for LlmPipeline mock LLM client and embedding provider."""
"""Unit tests for LlmPipeline with a mocked LLM client.
The pipeline no longer constructs an embedding provider: change detection moved
to the deterministic RegulationDiffer, and the LLM is called only to explain
changes that determinism already located. These tests pin that gating contract.
"""
from __future__ import annotations
from unittest.mock import MagicMock, patch
import json
import pytest
def _make_pipeline():
with patch("app.infrastructure.perception.llm_pipeline.get_llm_client") as mock_llm_fn, \
patch("app.infrastructure.perception.llm_pipeline.OpenAICompatibleEmbeddingProvider") as mock_emb_cls:
def _make_pipeline(content: str | None = None):
"""Build a pipeline whose LLM client is a mock returning `content`."""
default = (
'{"obligations":[{"text":"test obligation","deontic":"must","subject":"OEM",'
'"object":"system","condition":""}],"deadlines":[{"date":"2026-07-01",'
'"description":"实施截止"}],"scope":"适用于M1类车辆","penalties":"罚款",'
'"impact_level":"high"}'
)
with patch("app.infrastructure.perception.llm_pipeline.get_llm_client") as mock_llm_fn:
mock_client = MagicMock()
mock_client.chat.return_value = MagicMock(content='{"obligations":[{"text":"test obligation","deontic":"must","subject":"OEM","object":"system","condition":""}],"deadlines":[{"date":"2026-07-01","description":"实施截止"}],"scope":"适用于M1类车辆","penalties":"罚款","impact_level":"high"}')
mock_client.chat.return_value = MagicMock(content=content or default)
mock_llm_fn.return_value = mock_client
mock_emb = MagicMock()
mock_emb.embed_texts.return_value = [[0.1] * 1024, [0.9] * 1024]
mock_emb_cls.return_value = mock_emb
from app.infrastructure.perception.llm_pipeline import LlmPipeline
return LlmPipeline(), mock_client, mock_emb
return LlmPipeline(), mock_client
def test_extract_structure_returns_dict():
pipeline, mock_client, _ = _make_pipeline()
"""Structure extraction still returns the enrichment keys callers expect."""
pipeline, _ = _make_pipeline()
event = {
"id": "evt-001",
"standard_code": "GB 18384-2025",
@@ -38,8 +44,11 @@ def test_extract_structure_returns_dict():
def test_assess_impact_returns_list():
pipeline, mock_client, _ = _make_pipeline()
mock_client.chat.return_value = MagicMock(content='[{"doc_id":"d1","doc_name":"Safety Manual","score":0.85,"key_clauses":"§4.2","recommendation":"更新第4章"}]')
"""Impact assessment still returns a list of affected documents."""
pipeline, _ = _make_pipeline(
'[{"doc_id":"d1","doc_name":"Safety Manual","score":0.85,'
'"key_clauses":"§4.2","recommendation":"更新第4章"}]'
)
mock_retrieval = MagicMock()
chunk = MagicMock()
chunk.doc_id = "d1"
@@ -53,25 +62,103 @@ def test_assess_impact_returns_list():
"title": "电动汽车安全要求",
"obligations": [{"text": "OEM shall comply"}],
}
result = pipeline.assess_impact(event, mock_retrieval)
assert isinstance(result, list)
assert isinstance(pipeline.assess_impact(event, mock_retrieval), list)
def test_compute_diff_no_change():
pipeline, _, mock_emb = _make_pipeline()
mock_emb.embed_texts.return_value = [[0.5] * 1024, [0.5] * 1024]
result = pipeline.compute_diff("paragraph one", "paragraph one")
assert isinstance(result, dict)
assert "changed_sections" in result
assert "change_summary" in result
def test_compute_diff_no_change_costs_no_llm_call():
"""Identical text must short-circuit before reaching the model."""
pipeline, mock_client = _make_pipeline()
mock_client.chat.reset_mock()
result = pipeline.compute_diff("第一条 保持不变的条款。", "第一条 保持不变的条款。")
assert result["changed_sections"] == []
assert "No substantive changes" in result["change_summary"]
mock_client.chat.assert_not_called()
def test_compute_diff_detects_change():
pipeline, mock_client, mock_emb = _make_pipeline()
mock_emb.embed_texts.return_value = [
[1.0] + [0.0] * 1023,
[0.0] + [1.0] + [0.0] * 1022,
]
mock_client.chat.return_value = MagicMock(content='{"change_type":"tightened","summary":"Requirement tightened"}')
result = pipeline.compute_diff("old paragraph text", "new tighter requirement text")
assert isinstance(result["changed_sections"], list)
def test_compute_diff_classifies_a_real_change():
"""A gated change is classified and the model's legal_effect is surfaced."""
pipeline, _ = _make_pipeline(
'{"change_type":"tightened","legal_effect":"Requirement tightened."}'
)
result = pipeline.compute_diff(
"第三条 生产企业应当每年开展一次安全评估。",
"第三条 生产企业宜每年开展一次安全评估。",
)
sections = result["changed_sections"]
assert len(sections) == 1
assert sections[0]["change_type"] == "tightened"
assert sections[0]["summary"] == "Requirement tightened."
def test_numeric_change_overrides_the_model_label():
"""A moved number wins over the model, which routinely calls it 'clarified'."""
pipeline, _ = _make_pipeline(
'{"change_type":"clarified","legal_effect":"Minor wording update."}'
)
result = pipeline.compute_diff(
"第二条 车辆制动系统应在30米内完全停止。",
"第二条 车辆制动系统应在20米内完全停止。",
)
section = result["changed_sections"][0]
assert section["numeric_changed"] is True
assert section["change_type"] == "numeric"
def test_cosmetic_change_is_never_sent_to_the_model():
"""Punctuation-only edits are recorded but must not cost a model call."""
pipeline, mock_client = _make_pipeline()
mock_client.chat.reset_mock()
result = pipeline.compute_diff(
"第五条 本标准由全国汽车标准化技术委员会归口管理。",
"第五条 本标准由全国汽车标准化技术委员会归口管理",
)
assert len(result["changed_sections"]) == 1
mock_client.chat.assert_not_called()
def test_llm_failure_preserves_the_deterministic_record():
"""A model error must not discard a change deterministic analysis proved real."""
pipeline, mock_client = _make_pipeline()
mock_client.chat.side_effect = RuntimeError("gateway down")
result = pipeline.compute_diff(
"第二条 车辆制动系统应在30米内完全停止。",
"第二条 车辆制动系统应在20米内完全停止。",
)
section = result["changed_sections"][0]
assert section["numeric_changed"] is True
assert section["change_type"] == "numeric"
assert section["summary"] == ""
assert "第二条" in section["old_text"]
def test_only_gated_paragraphs_reach_the_model():
"""One significant change among cosmetic ones yields exactly one model call."""
pipeline, mock_client = _make_pipeline(
'{"change_type":"tightened","legal_effect":"Tighter limit."}'
)
mock_client.chat.reset_mock()
old = "\n".join([
"第一条 本标准规定了车辆制动系统的技术要求。",
"第二条 车辆制动系统应在30米内完全停止。",
"第三条 本标准由全国汽车标准化技术委员会归口管理。",
])
new = "\n".join([
"第一条 本标准规定了车辆制动系统的技术要求。",
"第二条 车辆制动系统应在20米内完全停止。",
"第三条 本标准由全国汽车标准化技术委员会归口管理",
])
result = pipeline.compute_diff(old, new)
# Two paragraphs changed; only the numeric one clears the gate.
assert len(result["changed_sections"]) == 2
assert mock_client.chat.call_count == 1
@@ -0,0 +1,83 @@
"""Tests for the notification store's per-user read-state contract.
These pin the core property that makes broadcast-to-everyone work without a
subscription model: one notification row is shared by all users, and each
user's read state is tracked independently against it.
"""
from __future__ import annotations
from app.infrastructure.perception.mock_notification_store import MockNotificationStore
def _store_with_one_notification() -> MockNotificationStore:
store = MockNotificationStore()
store.create(
event_id="evt-001",
kind="new",
title="《电动汽车安全要求》国家标准第三版正式发布",
impact_level="high",
summary=None,
)
return store
def test_a_new_notification_is_unread_for_everyone():
"""Nobody has read it yet, so unread_count is 1 for any user."""
store = _store_with_one_notification()
assert store.unread_count("user-a") == 1
assert store.unread_count("user-b") == 1
def test_mark_all_read_zeroes_the_count_for_that_user():
"""Reading clears the count for the user who read it."""
store = _store_with_one_notification()
marked = store.mark_all_read("user-a")
assert marked == 1
assert store.unread_count("user-a") == 0
def test_one_users_read_state_does_not_affect_another():
"""The whole point of read receipts over per-user fan-out: independence."""
store = _store_with_one_notification()
store.mark_all_read("user-a")
assert store.unread_count("user-a") == 0
assert store.unread_count("user-b") == 1
def test_list_for_user_reports_the_read_flag_correctly():
"""The list endpoint must reflect this user's own read state per item."""
store = _store_with_one_notification()
store.mark_all_read("user-a")
items_a = store.list_for_user("user-a")
items_b = store.list_for_user("user-b")
assert len(items_a) == 1
assert items_a[0]["read"] is True
assert items_b[0]["read"] is False
def test_list_for_user_orders_newest_first():
"""Notifications appear most-recent-first regardless of creation order."""
store = MockNotificationStore()
store.create(event_id="evt-1", kind="new", title="first", impact_level="low", summary=None)
store.create(event_id="evt-2", kind="new", title="second", impact_level="low", summary=None)
items = store.list_for_user("user-a")
assert [item["title"] for item in items] == ["second", "first"]
def test_mark_all_read_is_a_no_op_on_an_empty_feed():
"""Reading an empty feed must not raise and reports zero marked."""
store = MockNotificationStore()
assert store.mark_all_read("user-a") == 0
def test_mark_all_read_does_not_recount_already_read_notifications():
"""Calling mark_all_read twice must not double-count as newly marked."""
store = _store_with_one_notification()
first = store.mark_all_read("user-a")
second = store.mark_all_read("user-a")
assert first == 1
assert second == 0
@@ -0,0 +1,72 @@
"""Tests for the notification API routes.
Follows the same direct-call convention as backend/tests/mcp/test_mcp_status.py:
patch the bootstrap singleton getter where the route module imports it, then
call the async route function directly with asyncio.run rather than standing
up a FastAPI TestClient this repo has no existing TestClient harness, and
these route handlers are thin enough that exercising them directly tests the
same logic without inventing a new testing convention for two pass-through
endpoints.
"""
from __future__ import annotations
import asyncio
from unittest.mock import patch
from app.domain.auth.models import UserClaims, UserRole
from app.infrastructure.perception.mock_notification_store import MockNotificationStore
def _user(user_id: str) -> UserClaims:
return UserClaims(user_id=user_id, username=user_id, role=UserRole.READONLY)
def _list_notifications(store, user_id: str, limit: int = 20) -> dict:
from app.api.routes.perception import list_notifications
with patch("app.api.routes.perception.get_notification_store", return_value=store):
return asyncio.run(list_notifications(limit=limit, current_user=_user(user_id)))
def _mark_read(store, user_id: str) -> dict:
from app.api.routes.perception import mark_notifications_read
with patch("app.api.routes.perception.get_notification_store", return_value=store):
return asyncio.run(mark_notifications_read(current_user=_user(user_id)))
def test_a_fresh_user_sees_the_notification_as_unread():
"""GET .../notifications reports the caller's own unread_count."""
store = MockNotificationStore()
store.create(event_id="evt-1", kind="new", title="新法规发布", impact_level="high", summary=None)
result = _list_notifications(store, "user-a")
assert result["unread_count"] == 1
assert len(result["items"]) == 1
assert result["items"][0]["read"] is False
def test_mark_read_zeroes_a_subsequent_get():
"""POST .../read must clear unread_count for the next GET by the same user."""
store = MockNotificationStore()
store.create(event_id="evt-1", kind="new", title="新法规发布", impact_level="high", summary=None)
marked = _mark_read(store, "user-a")
result = _list_notifications(store, "user-a")
assert marked == {"marked": 1}
assert result["unread_count"] == 0
assert result["items"][0]["read"] is True
def test_mark_read_for_one_user_does_not_affect_another():
"""Read state is per-user — the whole point of the read-receipt design."""
store = MockNotificationStore()
store.create(event_id="evt-1", kind="new", title="新法规发布", impact_level="high", summary=None)
_mark_read(store, "user-a")
result_b = _list_notifications(store, "user-b")
assert result_b["unread_count"] == 1
@@ -0,0 +1,77 @@
"""Tests for the scheduled crawl Celery task.
These pin the draining contract: the task must consume every item from
CrawlService.run_crawl(), tally per-source errors without raising on them, and
let a genuine whole-crawl exception propagate rather than swallowing it.
Patches target app.shared.bootstrap.get_crawl_service not
perception_tasks.get_crawl_service because the task imports it inside its
own function body (see perception_tasks.py's docstring for why), so there is
no module-level name in perception_tasks to intercept.
"""
from __future__ import annotations
from unittest.mock import MagicMock, patch
import pytest
def _fake_crawl_service(events):
"""Build a fake whose run_crawl() yields the given fixed event sequence."""
service = MagicMock()
service.run_crawl.return_value = iter(events)
return service
def test_task_drains_generator_and_summarizes_errors():
"""One source error among two must not stop the run or raise."""
events = [
{"event": "progress", "data": {"source": "CATARC", "stage": "fetching"}},
{"event": "error", "data": {"source": "CATARC", "message": "timeout"}},
{"event": "progress", "data": {"source": "EUR-Lex", "stage": "fetching"}},
{"event": "done", "data": {"total_new": 2, "total_updated": 1}},
]
with patch(
"app.shared.bootstrap.get_crawl_service",
return_value=_fake_crawl_service(events),
):
from app.infrastructure.tasks.perception_tasks import crawl_regulations_task
result = crawl_regulations_task()
assert result == {"new": 2, "updated": 1, "source_errors": 1}
def test_task_reports_zero_errors_on_a_clean_run():
"""A run with no source errors must report source_errors: 0."""
events = [
{"event": "progress", "data": {"source": "CATARC", "stage": "fetching"}},
{"event": "done", "data": {"total_new": 0, "total_updated": 0}},
]
with patch(
"app.shared.bootstrap.get_crawl_service",
return_value=_fake_crawl_service(events),
):
from app.infrastructure.tasks.perception_tasks import crawl_regulations_task
result = crawl_regulations_task()
assert result == {"new": 0, "updated": 0, "source_errors": 0}
def test_whole_crawl_exception_is_not_swallowed():
"""A failure below run_crawl's own error handling must propagate.
Per-source failures are already handled inside run_crawl and never raise;
an exception escaping the generator entirely means something unexpected
broke, and Celery's own failure handling — not a silent catch here — is
the intended backstop.
"""
broken_service = MagicMock()
broken_service.run_crawl.side_effect = RuntimeError("event store unreachable")
with patch(
"app.shared.bootstrap.get_crawl_service",
return_value=broken_service,
):
from app.infrastructure.tasks.perception_tasks import crawl_regulations_task
with pytest.raises(RuntimeError, match="event store unreachable"):
crawl_regulations_task()
@@ -4,14 +4,8 @@ import json
from unittest.mock import MagicMock, patch
import pytest
# Patch psycopg2 before importing the module under test
import sys
mock_psycopg2 = MagicMock()
mock_psycopg2.extras = MagicMock()
sys.modules.setdefault("psycopg2", mock_psycopg2)
sys.modules.setdefault("psycopg2.extras", mock_psycopg2.extras)
sys.modules.setdefault("psycopg2.pool", MagicMock())
# psycopg2 is mocked centrally in backend/tests/conftest.py, so importing the
# module under test here never binds the real driver.
from app.infrastructure.perception.base_event_store import BaseEventStore
@@ -0,0 +1,157 @@
"""Tests for the deterministic regulation differ.
These tests pin the behaviour that the previous cosine-similarity implementation
could not deliver: real regulatory edits (numeric limits, deontic modals) must be
detected, and inserting a paragraph must not report unrelated paragraphs as
changed. Everything here runs offline no LLM, no network, no embeddings.
"""
from __future__ import annotations
from app.infrastructure.perception.regulation_differ import RegulationDiffer
def _differ() -> RegulationDiffer:
"""Build a differ with an explicit ratio so tests never depend on .env."""
return RegulationDiffer(min_change_ratio=0.02)
def test_identical_documents_report_no_changes():
"""An unchanged regulation must produce an empty change list."""
text = "第一条 车辆制动系统应在时速50公里条件下于30米内完全停止。\n第二条 驾驶员座椅面料的阻燃性能应符合附录B的规定。"
assert _differ().diff(text, text) == []
def test_numeric_tightening_is_detected():
"""A changed numeric limit is the case cosine similarity scored 0.9153 and missed."""
old = "第一条 车辆制动系统应在时速50公里条件下于30米内完全停止。"
new = "第一条 车辆制动系统应在时速50公里条件下于20米内完全停止。"
changes = _differ().diff(old, new)
assert len(changes) == 1
change = changes[0]
assert change.change_type == "modified"
assert change.numeric_changed is True
assert change.needs_llm is True
def test_deontic_relaxation_is_detected():
"""Weakening 应当 to 宜 changes the legal force and must be flagged."""
old = "第三条 生产企业应当每年开展一次安全评估。"
new = "第三条 生产企业宜每年开展一次安全评估。"
changes = _differ().diff(old, new)
assert len(changes) == 1
assert changes[0].deontic_changed is True
assert changes[0].needs_llm is True
def test_prohibition_removal_is_detected():
"""Dropping 不得 flips a prohibition into a permission."""
old = "第四条 车辆不得使用未经认证的电池组。"
new = "第四条 车辆可以使用经备案的电池组。"
changes = _differ().diff(old, new)
assert len(changes) == 1
assert changes[0].deontic_changed is True
def test_inserted_paragraph_does_not_shift_the_rest():
"""Regression test for positional alignment.
The previous implementation compared old[i] to new[i], so inserting one
paragraph at the top reported every following paragraph as changed. With
sequence alignment only the inserted paragraph is new.
"""
old = "\n".join([
"第一条 本标准规定了车辆制动系统的技术要求。",
"第二条 车辆制动系统应在时速50公里条件下于30米内完全停止。",
"第三条 驾驶员座椅面料的阻燃性能应符合附录B的规定。",
])
new = "\n".join([
"第零条 本标准适用于所有M1类车辆。",
"第一条 本标准规定了车辆制动系统的技术要求。",
"第二条 车辆制动系统应在时速50公里条件下于30米内完全停止。",
"第三条 驾驶员座椅面料的阻燃性能应符合附录B的规定。",
])
changes = _differ().diff(old, new)
assert len(changes) == 1, f"expected only the inserted paragraph, got {changes}"
assert changes[0].change_type == "added"
assert "第零条" in changes[0].new_text
assert changes[0].old_text == ""
def test_deleted_paragraph_is_reported_once():
"""Removing a provision yields exactly one 'removed' record."""
old = "\n".join([
"第一条 本标准规定了车辆制动系统的技术要求。",
"第二条 车辆制动系统应在时速50公里条件下于30米内完全停止。",
"第三条 驾驶员座椅面料的阻燃性能应符合附录B的规定。",
])
new = "\n".join([
"第一条 本标准规定了车辆制动系统的技术要求。",
"第三条 驾驶员座椅面料的阻燃性能应符合附录B的规定。",
])
changes = _differ().diff(old, new)
assert len(changes) == 1
assert changes[0].change_type == "removed"
assert "第二条" in changes[0].old_text
assert changes[0].new_text == ""
assert changes[0].needs_llm is True
def test_trivial_edit_is_not_sent_to_the_llm():
"""A cosmetic edit with no number or modal change must not cost an LLM call."""
old = "第五条 本标准由全国汽车标准化技术委员会归口管理。"
new = "第五条 本标准由全国汽车标准化技术委员会归口管理"
changes = _differ().diff(old, new)
for change in changes:
assert change.numeric_changed is False
assert change.deontic_changed is False
assert change.needs_llm is False, f"trivial edit was gated to the LLM: {change}"
def test_large_rewrite_is_sent_to_the_llm():
"""A substantial rewrite clears the change-ratio gate even without numbers or modals."""
old = "第六条 本标准参考了国际同类标准的相关内容。"
new = "第六条 本条款描述了完全不同的主题内容,涉及整车认证流程与型式试验的组织安排。"
changes = _differ().diff(old, new)
assert len(changes) == 1
assert changes[0].change_ratio >= 0.02
assert changes[0].needs_llm is True
def test_empty_old_text_yields_no_changes():
"""A first crawl has no baseline, so there is nothing to diff."""
assert _differ().diff("", "第一条 任意内容。") == []
def test_unchanged_paragraphs_are_never_returned():
"""Only changed paragraphs appear; equal ones are dropped."""
old = "\n".join([
"第一条 保持不变的条款。",
"第二条 车辆制动距离不得超过30米。",
"第三条 另一条保持不变的条款。",
])
new = "\n".join([
"第一条 保持不变的条款。",
"第二条 车辆制动距离不得超过20米。",
"第三条 另一条保持不变的条款。",
])
changes = _differ().diff(old, new)
assert len(changes) == 1
assert changes[0].numeric_changed is True
assert "第二条" in changes[0].old_text
@@ -206,6 +206,7 @@
```text
backend/app/
api/
mcp/
application/
documents/
knowledge/
@@ -314,6 +315,53 @@ backend/app/
- `backend/app/shared/bootstrap.py` 是现阶段的 composition root,负责把端口实现、基础设施适配器和 application service 连接起来。
- 后续如果新增 wiring 入口,应继续保持在同一类装配边界内,不要把依赖装配拆回各个路由或 service 构造函数中。
### 4.6 `mcp`
职责:
- 以 Model Context Protocol 对外暴露平台已有能力
- MCP tool 注册与入参 schema 绑定
- MCP 专用鉴权(复用现有 JWT)与 Streamable HTTP 子 ASGI 应用装配
- MCP 传输自身的可观测性:进程内 per-tool 调用计数(`stats.py`),以及供 System Status 页面读取的状态汇总 `get_mcp_status()`
非职责:
- 不实现任何新的检索、问答或业务编排逻辑
- 不直接访问 Milvus、MinIO、LLM SDK
- 不统计 token 消耗 —— MCP 调用经由 `AgentConversationService.ask()`,已由 `shared/model_usage_tracker.py` 记账
说明:
- `mcp``api` 是并列的两个 transport 适配层:`api` 面向 HTTP REST 客户端,`mcp` 面向 MCP 客户端(Claude Desktop、IDE 等)。二者共用同一套 application service。
- 它独立成顶层模块而不是放进 `api/routes/`,因为 MCP 使用装饰器式 tool 注册和自带的子 ASGI 应用,与 `APIRouter` 是不同的传输机制。
- 当前实现见 `backend/app/mcp/server.py`,只暴露 `search_regulations` 一个 tool,内部直接调用 `get_agent_conversation_service()`
- `api/routes/status.py``GET /status/mcp` 是薄适配层:它只负责解析对外可达的 URL(`settings.mcp_public_url` 或从请求推导),其余全部交给 `mcp.server.get_mcp_status()`,路由不得直接读取 MCP 内部结构。
### 4.7 `perception`
职责:
- 爬取外部法规源(CATARC、国标委强制性/推荐性、EUR-Lex)的列表页与正文(`infrastructure/perception/crawlers/`
- 基于内容哈希的变更检测入口,以及**确定性**的段落级差异分析(`regulation_differ.py`:对齐 + 字符级 diff + 数字/情态词/新增删除闸门),只有通过闸门的段落才调用 LLM 分类(`llm_pipeline.py`
- 站内通知的广播存储与每用户已读状态(`base_notification_store.py` 及 Mock/Postgres 实现)——广播给所有登录用户,不做订阅/角色过滤
- 通过 Celery Beat 定时调度全量爬取(`infrastructure/tasks/perception_tasks.py`),调度间隔由 `perception_crawl_interval_seconds` 配置
- 新事件或"显著变更"(数字/情态词变化,或整段增删)自动写入知识库:本地 markdown 分块(`LocalRegulationChunkBuilder`,与 `chunk_backend=aliyun` 的上传流程无关)→ 复用既有 `embedding_provider`/`vector_index` → 写入与 `/documents` 上传管线**同一个** Milvus collection
非职责:
- 不维护第二套知识库或第二套向量索引——爬取入库与手动上传共用 `get_embedding_provider()` / `get_vector_index()` 这两个端口实现,二者在检索侧不可区分
- 不做外部推送渠道(Email/Teams/飞书/钉钉)——当前部署无 SMTP/Webhook 凭据,做了也无法验证;只做站内通知
- 不做订阅/偏好引擎——所有登录用户收到同一份广播,按用户区分的只有"已读"状态
- 不做整改任务追踪(责任人、期限、验收、证据归档)——PPT 原文将其标注为"扩展功能",规模上属独立子项目,尚未开始
- 变更检测判据不依赖 embedding 余弦相似度——该方法被证明无法区分数值/情态词变化(如"30米"→"20米"、"应当"→"宜"),已被字符级 diff + 语言学规则取代
说明:
- `application/perception/crawl_service.py``CrawlService.run_crawl()` 是本模块的核心编排:单个事件的每一步(结构抽取、影响评估、diff、通知、知识库索引)各自 try/except 包裹,任一步失败只记警告、不中断整次爬取。
- `_is_significant()``should_index` 是同一份判据,被通知创建和知识库索引两处复用,避免出现"值得通知"和"值得入库"两套互相漂移的标准。
- `postgres_event_store.py` / `postgres_notification_store.py` 与对应的 Mock 实现共享同一个开关 `settings.document_repository_backend == "postgres"`,与文档处理模块的 backend 切换方式一致。
- MinIO 上的 `raw_storage_key` 字段(schema 中声明)目前无人写入;正文改为直接存 `regulation_events.raw_text` 列,供下次爬取做基线对比。
## 5. Module Responsibilities
### 5.1 `api`
@@ -637,6 +685,7 @@ infrastructure -> external systems
具体规则如下:
- `api` 可以依赖 `application` 和 API 自己的 request/response models
- `mcp``api` 同级,只能依赖 `application` 和 composition root,不能依赖 `infrastructure` 或反过来被 `application` 依赖
- `application` 只能依赖 `domain`、端口接口,以及通过 composition root 注入进来的实现实例
- `domain` 不能依赖 `api``infrastructure`
- `infrastructure` 可以依赖 `domain` 定义的端口和数据模型,但不能反向驱动 application 逻辑
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,486 @@
# MCP Regulation Search Server — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [x]`) syntax for tracking.
**Goal:** Expose the existing compliance knowledge base as an MCP tool (`search_regulations`) via a standalone `backend/app/mcp/` module, mounted into the existing FastAPI backend over Streamable HTTP, reusing existing JWT auth and the existing `AgentConversationService`.
**Architecture:** A new top-level `backend/app/mcp/server.py` builds a `FastMCP` instance with one `@mcp.tool()`-decorated `search_regulations` function that calls the existing `get_agent_conversation_service().ask(...)` (zero new business logic). A small `MCPAuthMiddleware` ASGI middleware validates the existing JWT bearer scheme in front of the mounted MCP app. `backend/app/api/main.py` mounts the resulting ASGI app at `/mcp` and wires its lifespan into the existing `lifespan()` function via `AsyncExitStack` (required — `app.mount()` does not propagate nested ASGI lifespans, so without this the MCP session manager never starts and every tool call fails).
**Tech Stack:** Python 3.12, FastAPI, Starlette, official `mcp` SDK (`mcp.server.fastmcp.FastMCP`), pytest, unittest.mock, Starlette `TestClient`.
## Global Constraints
- Design source of truth: `docs/superpowers/specs/2026-07-29-mcp-search-regulations-design.md`.
- **Correction discovered during implementation:** the `mcp` package's latest release is `2.0.0`, which renamed `FastMCP` to `MCPServer` (`mcp.server.MCPServer`) and its client helper `streamablehttp_client` to `streamable_http_client`. `mcp>=2.0.0` is pinned in `requirements.txt` (not `>=1.9.0` as originally estimated below) since the shipped code uses the `MCPServer` name. Also discovered: `MCPServer.streamable_http_app()` defaults to registering its route at `/mcp`, requiring `streamable_http_path="/"` to avoid a doubled `/mcp/mcp` when mounted at `/mcp`; the effective client URL is `/mcp/` (trailing slash, due to Starlette's `Mount` redirect behavior).
- All comments and docstrings in `backend/**/*.py` must be in English; every function/method needs a docstring; every file (including `__init__.py`) needs a module docstring + at least one meaningful `#` comment (`AGENTS.md`).
- No new business orchestration — `search_regulations` is a thin protocol adapter over the existing `AgentConversationService`, same tier as `app/api/routes/agent.py`.
- Python interpreter for this repo checkout: `C:\software\Python312\python.exe` (no `.venv` present in this checkout; this is the interpreter with all project dependencies already installed and is what the previous session's work was verified against).
- Verified baseline test command (run from repo root, before any change in this plan): `C:\software\Python312\python.exe -m pytest backend/tests -q``69 passed` (9.10s). Re-run this after every task.
- Confirmed via direct import check: `starlette` is installed and `starlette.testclient.TestClient` works; the `mcp` package is **not yet installed** (`ModuleNotFoundError: No module named 'mcp'`) — Task 3 installs it.
- Latest published `mcp` version on PyPI at plan time: `2.0.0`. Pin `mcp>=1.9.0` in requirements (first version line with stable `streamable_http_app()` support) and let pip resolve to latest.
---
### Task 1: `search_regulations` MCP tool
**Files:**
- Create: `backend/app/mcp/__init__.py`
- Create: `backend/app/mcp/server.py` (tool definition only — middleware and ASGI app builder added in Task 2)
- Create: `backend/tests/mcp/__init__.py`
- Create: `backend/tests/mcp/test_search_regulations_tool.py`
**Interfaces:**
- Consumes: `app.shared.bootstrap.get_agent_conversation_service()` (existing, returns `AgentConversationService`).
- Produces: `app.mcp.server.search_regulations(query: str, top_k: int = 5) -> dict` — a plain function (before the `@mcp.tool()` decorator is applied, it remains directly callable/testable; the decorator only adds MCP schema metadata, it does not change the function's Python call signature or return value).
- [x] **Step 1: Write the failing test**
Create `backend/tests/mcp/__init__.py`:
```python
"""Test package for the MCP module (backend/app/mcp/)."""
# Empty package marker — no shared fixtures needed yet for this small test suite.
```
Create `backend/tests/mcp/test_search_regulations_tool.py`:
```python
"""Unit tests for the search_regulations MCP tool function.
Mocks AgentConversationService so no real retrieval/LLM call happens —
verifies only the protocol-adapter contract: correct call shape in,
correct dict shape out.
"""
from __future__ import annotations
from dataclasses import dataclass
from unittest.mock import MagicMock, patch
@dataclass
class _FakeSource:
"""Minimal stand-in for a real Source dataclass (only __dict__ is used)."""
doc_id: str
doc_title: str
score: float
@dataclass
class _FakeAnswerResult:
"""Minimal stand-in for AnswerResult — only .answer/.sources are read."""
answer: str
sources: list
def test_search_regulations_calls_agent_ask_without_session():
"""search_regulations must call ask() with no session_id (stateless search)."""
from app.mcp.server import search_regulations
fake_service = MagicMock()
fake_service.ask.return_value = (
None,
_FakeAnswerResult(answer="国六排放标准要求...", sources=[_FakeSource("doc-1", "国六标准", 0.9)]),
)
with patch("app.mcp.server.get_agent_conversation_service", return_value=fake_service):
result = search_regulations(query="国六排放标准最新要求", top_k=3)
fake_service.ask.assert_called_once_with(query="国六排放标准最新要求", top_k=3)
assert "session_id" not in fake_service.ask.call_args.kwargs
def test_search_regulations_shapes_response_dict():
"""The returned dict must expose 'answer' and 'sources' (list of plain dicts)."""
from app.mcp.server import search_regulations
fake_service = MagicMock()
fake_service.ask.return_value = (
None,
_FakeAnswerResult(answer="答案文本", sources=[_FakeSource("doc-2", "国标GB1589", 0.8)]),
)
with patch("app.mcp.server.get_agent_conversation_service", return_value=fake_service):
result = search_regulations(query="q")
assert result == {
"answer": "答案文本",
"sources": [{"doc_id": "doc-2", "doc_title": "国标GB1589", "score": 0.8}],
}
def test_search_regulations_default_top_k():
"""top_k defaults to 5 when the caller omits it."""
from app.mcp.server import search_regulations
fake_service = MagicMock()
fake_service.ask.return_value = (None, _FakeAnswerResult(answer="a", sources=[]))
with patch("app.mcp.server.get_agent_conversation_service", return_value=fake_service):
search_regulations(query="q")
assert fake_service.ask.call_args.kwargs["top_k"] == 5
```
Run it — confirm it fails on import (`app.mcp.server` does not exist yet):
```powershell
C:\software\Python312\python.exe -m pytest backend/tests/mcp/test_search_regulations_tool.py -v
```
- [x] **Step 2: Implement the tool**
Create `backend/app/mcp/__init__.py`:
```python
"""MCP (Model Context Protocol) server module.
Exposes selected read-only platform capabilities — currently only regulation
search — as MCP tools so external MCP clients (Claude Desktop, GitHub Copilot,
Cursor, etc.) can query this platform's compliance knowledge base directly.
"""
# Kept deliberately empty beyond this docstring — see server.py for the
# actual FastMCP instance and tool/middleware definitions.
```
Create `backend/app/mcp/server.py`:
```python
"""FastMCP server exposing the compliance knowledge base as an MCP tool.
This module is a pure protocol adapter: search_regulations() below calls the
existing AgentConversationService.ask() (the same application service backing
the /api/v1/agent/ask REST endpoint) and reshapes its result into a plain
dict. No new retrieval, ranking, or LLM orchestration logic lives here.
"""
from __future__ import annotations
from mcp.server.fastmcp import FastMCP
from app.shared.bootstrap import get_agent_conversation_service
# Single shared FastMCP instance — analogous to the single shared FastAPI
# `app` instance in app/api/main.py. Tools registered via @mcp.tool() below.
mcp = FastMCP("ai-regulations")
@mcp.tool()
def search_regulations(query: str, top_k: int = 5) -> dict:
"""Search the compliance knowledge base and return a grounded answer.
query: Natural-language search question, e.g. "国六排放标准最新要求".
top_k: Maximum number of cited sources to return (default 5).
"""
# No session_id is passed: this keeps each call stateless (no
# ConversationStore reads/writes), matching "search" semantics rather
# than multi-turn chat semantics.
_, result = get_agent_conversation_service().ask(query=query, top_k=top_k)
return {
"answer": result.answer,
"sources": [source.__dict__ for source in result.sources],
}
```
- [x] **Step 3: Run the test — confirm it passes**
```powershell
C:\software\Python312\python.exe -m pytest backend/tests/mcp/test_search_regulations_tool.py -v
```
Expected: 3 passed. Note: this step imports `mcp.server.fastmcp`, which is not yet installed — if it fails with `ModuleNotFoundError: No module named 'mcp'`, that is expected until Task 3 installs the dependency; run `C:\software\Python312\python.exe -m pip install "mcp>=1.9.0"` locally first so this task's tests can actually execute now (Task 3 formalizes the requirements.txt entry — installing it now is just so this task's own tests are green before moving on).
---
### Task 2: `MCPAuthMiddleware` — reuse existing JWT auth
**Files:**
- Modify: `backend/app/mcp/server.py` (add middleware + ASGI app builder)
- Create: `backend/tests/mcp/test_mcp_auth_middleware.py`
**Interfaces:**
- Consumes: `app.config.settings.settings.auth_enabled` (existing), `app.shared.bootstrap.get_jwt_handler()` (existing, returns `JWTHandler`).
- Produces: `app.mcp.server.MCPAuthMiddleware` (ASGI middleware class), `app.mcp.server.build_mcp_asgi_app() -> ASGIApp` (returns `mcp.streamable_http_app()` with the middleware already attached). Task 3's `main.py` change consumes `build_mcp_asgi_app()` directly — it does not need to attach the middleware itself.
- [x] **Step 1: Write the failing test**
Create `backend/tests/mcp/test_mcp_auth_middleware.py`:
```python
"""Unit tests for MCPAuthMiddleware.
Wraps a minimal dummy ASGI app (not the real MCP app) so these tests exercise
only the auth gate, not the MCP protocol itself — keeps the test fast and
independent of FastMCP internals.
"""
from __future__ import annotations
from unittest.mock import patch
from starlette.applications import Starlette
from starlette.responses import PlainTextResponse
from starlette.routing import Route
from starlette.testclient import TestClient
from app.mcp.server import MCPAuthMiddleware
def _dummy_app() -> Starlette:
"""Build a minimal Starlette app that MCPAuthMiddleware can wrap."""
async def _ok(request):
"""Return a fixed 200 response so tests can assert pass-through."""
return PlainTextResponse("ok")
app = Starlette(routes=[Route("/ping", _ok)])
app.add_middleware(MCPAuthMiddleware)
return app
def test_missing_token_rejected_when_auth_enabled():
"""No Authorization header + auth_enabled=True -> 401."""
with patch("app.mcp.server.settings") as fake_settings:
fake_settings.auth_enabled = True
client = TestClient(_dummy_app())
response = client.get("/ping")
assert response.status_code == 401
def test_invalid_token_rejected_when_auth_enabled():
"""A token that fails decode_token() -> 401, request never reaches the app."""
fake_handler = type("H", (), {"decode_token": lambda self, t: (_ for _ in ()).throw(ValueError("bad token"))})()
with patch("app.mcp.server.settings") as fake_settings, \
patch("app.mcp.server.get_jwt_handler", return_value=fake_handler):
fake_settings.auth_enabled = True
client = TestClient(_dummy_app())
response = client.get("/ping", headers={"Authorization": "Bearer garbage"})
assert response.status_code == 401
def test_valid_token_passes_through_when_auth_enabled():
"""A token that decodes successfully -> request reaches the wrapped app."""
fake_handler = type("H", (), {"decode_token": lambda self, t: object()})()
with patch("app.mcp.server.settings") as fake_settings, \
patch("app.mcp.server.get_jwt_handler", return_value=fake_handler):
fake_settings.auth_enabled = True
client = TestClient(_dummy_app())
response = client.get("/ping", headers={"Authorization": "Bearer good"})
assert response.status_code == 200
assert response.text == "ok"
def test_auth_disabled_always_passes_through():
"""auth_enabled=False (dev mode) -> no token needed, matches get_current_user's dev bypass."""
with patch("app.mcp.server.settings") as fake_settings:
fake_settings.auth_enabled = False
client = TestClient(_dummy_app())
response = client.get("/ping")
assert response.status_code == 200
```
Run it — confirm it fails (`MCPAuthMiddleware` does not exist yet):
```powershell
C:\software\Python312\python.exe -m pytest backend/tests/mcp/test_mcp_auth_middleware.py -v
```
- [x] **Step 2: Implement the middleware and ASGI app builder**
Append to `backend/app/mcp/server.py`:
```python
from starlette.responses import PlainTextResponse
from starlette.types import ASGIApp, Receive, Scope, Send
from app.config.settings import settings
from app.shared.bootstrap import get_jwt_handler
class MCPAuthMiddleware:
"""Reject unauthenticated requests before they reach the MCP protocol handler.
Mirrors the existing get_current_user dependency's behavior (auth.py) but
implemented as raw ASGI middleware, since the mounted MCP app is a plain
ASGI app, not a FastAPI/APIRouter instance that supports Depends().
"""
def __init__(self, app: ASGIApp) -> None:
"""Store the wrapped ASGI app to delegate to once auth passes."""
self.app = app
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
"""Validate the bearer token for HTTP requests; pass non-HTTP scopes through."""
# Only HTTP requests carry an Authorization header to check; lifespan
# and other scope types must always pass through untouched.
if scope["type"] != "http" or not settings.auth_enabled:
await self.app(scope, receive, send)
return
headers = dict(scope["headers"])
auth_header = headers.get(b"authorization", b"").decode()
token = auth_header.removeprefix("Bearer ").strip()
try:
get_jwt_handler().decode_token(token)
except ValueError as exc:
# Reject before the MCP session/protocol layer ever sees the request.
response = PlainTextResponse(str(exc), status_code=401)
await response(scope, receive, send)
return
await self.app(scope, receive, send)
def build_mcp_asgi_app() -> ASGIApp:
"""Return the Streamable HTTP ASGI app for the MCP server, auth-guarded."""
asgi_app = mcp.streamable_http_app()
asgi_app.add_middleware(MCPAuthMiddleware)
return asgi_app
```
- [x] **Step 3: Run the test — confirm it passes**
```powershell
C:\software\Python312\python.exe -m pytest backend/tests/mcp/test_mcp_auth_middleware.py -v
```
Expected: 4 passed.
---
### Task 3: Mount into the FastAPI app
**Files:**
- Modify: `backend/requirements.txt` (add `mcp` dependency)
- Modify: `backend/app/api/main.py` (mount `/mcp`, wire lifespan via `AsyncExitStack`)
**Interfaces:**
- Consumes: `app.mcp.server.build_mcp_asgi_app()` (from Task 2).
- Produces: a running `/mcp` Streamable HTTP endpoint on the existing FastAPI app/port — no new port, process, or deployment step.
- [x] **Step 1: Add the dependency**
In `backend/requirements.txt`, add to the "Web framework" section (or a new small section — either is fine, keep it near `fastapi`/`uvicorn` since it is another transport-layer concern):
```
mcp>=1.9.0
```
Install it (already done ad hoc in Task 1 to unblock those tests — this step just formalizes the pin in the manifest; re-run install to be certain the pinned version resolves cleanly):
```powershell
C:\software\Python312\python.exe -m pip install -r backend/requirements.txt
```
- [x] **Step 2: Mount the MCP app and fix the lifespan gap**
In `backend/app/api/main.py`, add the import and build the ASGI app at module scope (before `lifespan()` is defined, since `lifespan()` needs to reference it):
```python
from contextlib import AsyncExitStack
from app.mcp.server import build_mcp_asgi_app
```
Add right after the existing imports, before `@asynccontextmanager def lifespan(...)`:
```python
# Built once at module scope so both lifespan() and app.mount() below reference
# the same instance — mounting a second, separately-built instance would start
# a second, unrelated MCP session manager.
mcp_app = build_mcp_asgi_app()
```
Replace the existing `lifespan()` function body:
```python
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Application lifecycle hooks."""
# FastMCP's streamable_http_app() owns a session manager that only starts
# via its own lifespan context. app.mount() does NOT propagate nested ASGI
# lifespans automatically (confirmed Starlette/ASGI limitation — see
# https://github.com/modelcontextprotocol/python-sdk/issues/1367) — without
# this, every search_regulations call fails because the session manager
# was never started.
async with AsyncExitStack() as stack:
await stack.enter_async_context(mcp_app.router.lifespan_context(mcp_app))
logger.info(f"启动 {settings.app_name} v{settings.app_version}")
logger.info(f"调试模式: {settings.debug}")
logger.info("预加载LLM客户端...")
preload_runtime_dependencies()
yield
logger.info("应用关闭,执行清理...")
cleanup_runtime_dependencies()
```
Add the mount call right after the existing `app.include_router(api_router, prefix="/api/v1")` line:
```python
app.include_router(api_router, prefix="/api/v1")
app.mount("/mcp", mcp_app)
```
- [x] **Step 3: Run the full backend test suite**
```powershell
C:\software\Python312\python.exe -m pytest backend/tests -q
```
Expected: `76 passed` (69 existing + 3 + 4 new from Tasks 12). No regressions.
- [x] **Step 4: Manual end-to-end verification (not automated)**
Start the backend the normal way (`dev.bat start api --foreground` or the documented `uvicorn` command) and, from a separate shell, run:
```powershell
C:\software\Python312\python.exe -c "
import asyncio
from mcp import ClientSession
from mcp.client.streamable_http import streamablehttp_client
async def main():
url = 'http://127.0.0.1:8000/mcp'
headers = {'Authorization': 'Bearer <put a real JWT here if AUTH_ENABLED=true>'}
async with streamablehttp_client(url, headers=headers) as (read, write, _):
async with ClientSession(read, write) as session:
await session.initialize()
tools = await session.list_tools()
print([t.name for t in tools.tools])
result = await session.call_tool('search_regulations', {'query': '国六排放标准'})
print(result)
asyncio.run(main())
"
```
Confirm `search_regulations` appears in the tool list and returns a real answer + sources. If `AUTH_ENABLED=false` locally, omit the `headers` argument entirely.
---
## Summary
| Task | New files | Modified files | Tests added |
|---|---|---|---|
| 1 | `app/mcp/__init__.py`, `app/mcp/server.py`, `tests/mcp/__init__.py`, `tests/mcp/test_search_regulations_tool.py` | — | 3 |
| 2 | `tests/mcp/test_mcp_auth_middleware.py` | `app/mcp/server.py` | 4 |
| 3 | — | `requirements.txt`, `api/main.py` | 0 (full-suite regression check + manual e2e) |
## Task 4 (post-review): code-review fixes
Added after a code review of the three implementation commits. Findings and resolutions:
- [x] **Critical — every remote client rejected with HTTP 421.** The MCP SDK auto-enables DNS-rebinding protection when its `host` parameter is left at the `127.0.0.1` default, hard-coding a loopback-only `Host` allow-list; a client at `http://6.86.80.9:8000/mcp/` was refused before auth or the tool ran, making the feature non-functional in the only deployment it targets. Fixed by passing an explicit `TransportSecuritySettings` built from a new `MCP_ALLOWED_HOSTS` setting (`app/config/settings.py`, documented in `.env.example`), with `*` as a logged, explicit opt-out. Deliberately *not* fixed by passing `host="0.0.0.0"`, which would silently disable the protection.
- [x] **Important — `top_k` unbounded on the MCP path.** `AskRequest` constrains the same parameter to 120, but the tool accepted any integer and `KnowledgeRetrievalService` amplifies it (`top_k * 4`), so `top_k=100000` would request 400,000 Milvus candidates. Fixed with `Annotated[int, Field(ge=1, le=20)]` (and `query` bounded to 12000 chars), which also publishes the bounds in the advertised JSON schema.
- [x] **Important — order-dependent `psycopg2` test guard.** The guard was duplicated across four test modules and only worked because of pytest's alphabetical collection order; any new test package sorting earlier would have reintroduced a multi-second TCP timeout against the production database. Moved into a single `backend/tests/conftest.py` (imported before any test module regardless of order) and the four in-file copies deleted. `bootstrap.py`'s eager imports were left alone — restructuring the composition root every route depends on is disproportionate to a test-harness ordering problem.
- [x] **Minor — non-UTF-8 `Authorization` header caused a 500.** ASGI header values are latin-1; strict UTF-8 decoding let any remote client trigger an unhandled `UnicodeDecodeError`. Now decoded as latin-1, yielding a clean 401.
- [x] **Minor — 401 missing `WWW-Authenticate`.** Added `WWW-Authenticate: Bearer`, matching `get_current_user` and RFC 7235.
- [x] **Minor — missing `#` comment** in `tests/mcp/test_search_regulations_tool.py` (AGENTS.md requires at least one per file). Added.
Reviewer-confirmed as correct, no change needed: the `AsyncExitStack` lifespan wiring (including its failure path), the absence of auth-bypass vectors, and the statelessness of `ask()` without a `session_id`.
New tests: `tests/mcp/test_mcp_transport_security.py` (5, exercising the real MCP app end-to-end) plus 3 more across the existing two files — 84 backend tests pass. Verified against a live server: `Host: 6.86.80.9:8000` → 200 with a valid `initialize` result, `Host: evil.example.com` → 421, no token → 401 with `WWW-Authenticate: Bearer`.
@@ -0,0 +1,89 @@
# MCP Status Panel — Implementation Plan
Spec: `docs/superpowers/specs/2026-08-03-mcp-status-panel-design.md`
Backend first (tests alongside), then frontend, then verify. Each task is
independently reviewable.
## Task 1 — `app/mcp/stats.py`
- [x] `MCPToolStats` dataclass: `calls: int = 0`, `errors: int = 0`,
`total_duration_ms: float = 0.0`, `last_called_at: datetime | None = None`;
`avg_duration_ms` property returning `None` when `calls == 0`.
- [x] `MCPStatsTracker` with `threading.Lock`, `record(tool, duration_ms, success)`,
`snapshot()` returning a shallow copy.
- [x] `record()` wraps its body in `try/except Exception``logger.warning`, never raises.
- [x] `get_mcp_stats_tracker()` with `@lru_cache`.
- [x] Module docstring + at least one `#` comment (AGENTS.md).
## Task 2 — `backend/tests/mcp/test_mcp_stats.py`
- [x] 8 threads × 100 `record()` calls → `calls == 800` exactly.
- [x] `avg_duration_ms` is `None` at zero calls, correct mean afterwards.
- [x] `success=False` increments `errors` and `calls`.
- [x] `record()` with a non-numeric duration logs and does not raise.
## Task 3 — instrument `app/mcp/server.py`
- [x] Wrap `search_regulations` body: `time.perf_counter()` start,
`try/except` records `success=False` and re-raises, `finally` not needed
once both branches record.
- [x] `async def get_mcp_status(public_url: str) -> dict` returning
`{endpoint_url, auth_required, allowed_hosts, tools: [...]}` where each
tool is `{name, description, calls, errors, avg_duration_ms, last_called_at}`.
- [x] `allowed_hosts` parsed from `settings.mcp_allowed_hosts` with the same
split/strip logic `_build_transport_security()` already uses.
- [x] `last_called_at` serialized as ISO-8601 string or `None`.
## Task 4 — `mcp_public_url` setting
- [x] `app/config/settings.py`: `mcp_public_url: str = ""` in the existing `# ── MCP ──` block.
- [x] `.env.example`: documented under the existing MCP section, in Chinese,
with the `http://6.86.80.9:8000/mcp/` example and a note that it is only
needed when a proxy rewrites `Host`.
## Task 5 — `GET /status/mcp`
- [x] Add route to `backend/app/api/routes/status.py`, taking `request: Request`.
- [x] `public_url = settings.mcp_public_url or f"{str(request.base_url).rstrip('/')}/mcp/"`.
- [x] Delegate to `get_mcp_status()`; no MCP internals in the route.
## Task 6 — `backend/tests/mcp/test_mcp_status.py`
- [x] Tool advertised with zeroed stats before any call.
- [x] Stats reflected after `record()`.
- [x] `auth_required` follows a patched `settings.auth_enabled`.
- [x] `public_url` passes through unmodified.
## Task 7 — frontend types + client
- [x] `frontend/src/api/index.ts`: `MCPToolEntry`, `MCPStatusResponse`.
- [x] `frontend/src/api/status.ts`: `getMCPStatus()` + re-export.
## Task 8 — MCP Server card
- [x] Add `getMCPStatus()` to the existing `Promise.allSettled` batch in
`StatusPage.tsx`, with its own `mcpLoading` state.
- [x] Card below "AI Models": endpoint row + one row per tool.
- [x] Copy-config button: builds the `mcpServers` JSON, embeds the
`localStorage` token when `auth_required`, writes via
`navigator.clipboard.writeText`, and reflects success/failure in its label
for ~2s.
- [x] `handleExport()` includes `mcp`.
- [x] Reuse `card` / `card-header` / `service-row` / `StatusIcon`. No new CSS.
## Task 9 — i18n
- [x] `locales/zh.ts` and `locales/en.ts`: `cardMcp`, `mcpEndpoint`,
`mcpAuthRequired`, `mcpAuthDisabled`, `mcpAllowedHosts`, `mcpCopyConfig`,
`mcpCopied`, `mcpCopyFailed`, `mcpCalls`, `mcpErrors`, `mcpAvgDuration`,
`mcpNoTools`, `mcpUnavailable`.
- [x] Both files must stay structurally identical (`en.ts` is typed against `zh.ts`).
## Task 10 — verify
- [x] `python -m pytest backend/tests -q` — all pass.
- [x] `npm --prefix frontend run lint`.
- [x] `npm --prefix frontend run build`.
- [x] Live check: start uvicorn, `GET /api/v1/status/mcp`, confirm the tool is
listed and counters move after a real MCP `tools/call`.
@@ -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).
@@ -0,0 +1,139 @@
# System Status — AI Models Panel Hardening Design
**Date:** 2026-07-23
**Scope:** Close three gaps left open by the already-shipped "AI Models" card on the System Status page (`docs/superpowers/specs/2026-07-02-status-llm-model-usage-design.md`): streaming calls don't report token usage, the Cross-Encoder reranker is still disabled, and usage counters reset on every backend restart.
**Relationship to existing roadmap:** This is a direct continuation of the 2026-07-02 feature, not a new module. It also closes two long-standing "Quick Win" items from `AI_Agent_优化分析报告_2026-06-18.md` (reranker enablement, and — partially — observability of RAG quality). It does not attempt full Langfuse/Ragas tracing (`P0-A` in that roadmap); that remains a separate, larger effort.
---
## Goals
1. **A1 — Streaming token capture.** `stream_chat()` calls (the default interaction mode for the main RAG chat UI) currently report call success/latency but not token usage — an explicitly documented gap in the 2026-07-02 design. Close it using the OpenAI-compatible `stream_options: {include_usage: true}` mechanism, so streaming and non-streaming calls are accounted for consistently.
2. **A2 — Enable the reranker.** `reranker_enabled` has been `False` by default since before the first internal analysis report (2026-06-11); both that report and the 2026-06-18 follow-up flag it as the single highest-ROI, lowest-risk unfinished item (+1525% retrieval precision, typically a one-line config change).
3. **A3 — Durable usage counters.** `ModelUsageTracker` is in-memory only; counts reset on every restart/redeploy. Persist them so the Status page reflects cumulative usage across the process lifetime, not just since the last restart.
## Non-Goals
- Cost/spend estimation in currency (still no reliable pricing for the internal gateway).
- Per-session/per-user token attribution.
- Historical time-series / usage-over-time charts (explicitly deferred by user decision during brainstorming — this iteration persists **current cumulative counters only**, not a time-series log).
- Full Langfuse/Ragas distributed tracing and faithfulness scoring (`P0-A`, separate future effort).
---
## A1 — Streaming Token Capture
### Current behavior (confirmed by reading the code)
`DeepSeekClient.stream_chat()` and both `QwenClient.stream_chat()` / `QwenVLClient.stream_chat()` (`backend/app/services/llm/deepseek_client.py`, `backend/app/services/llm/qwen_client.py`) parse each SSE `data: {...}` line, and today explicitly skip any chunk whose `choices` array is empty:
```python
choices = data.get("choices", [])
if not choices:
continue # <- a trailing usage-only chunk is silently dropped here today
delta = choices[0].get("delta", {})
content = delta.get("content", "")
```
`TrackedLLMClient.stream_chat()` (`backend/app/services/llm/tracked_client.py`) wraps this with a plain `for chunk in self._inner.stream_chat(...): yield chunk`, then records call success/latency only — by design, since "none of the current provider `stream_chat()` implementations parse a trailing usage chunk."
### Change
1. Add `"stream_options": {"include_usage": True}` to the request payload built in each of the three `stream_chat()` implementations. This is the standard OpenAI-compatible mechanism: the gateway appends one final chunk with `"choices": []` and a populated `"usage"` object after the normal content chunks.
2. In each generator, when a parsed chunk has empty `choices` **and** a non-empty `usage` field, capture it into a local variable (function-local — safe even though the underlying client instance is a shared/cached singleton, because each call to `stream_chat()` creates its own generator frame). At the end of the generator, `return` that captured usage dict instead of falling off the end with an implicit `None`. This is accessible to a manual consumer via `StopIteration.value`.
3. Per-chunk content yields are **unchanged** — this keeps the change backward compatible for all seven existing call sites (`api/routes/rag.py`, `compliance.py`, `agent.py`, `application/perception/services.py`, `infrastructure/llm/openai_compatible_answer_generator.py`, `services/agent/qa_agent.py`) that just do `for chunk in stream_chat(...): ...` and will continue to work untouched, silently ignoring the new return value.
4. `TrackedLLMClient.stream_chat()` is the **only** call site that needs the return value. Replace its plain `for` loop with a manually-driven loop (`next()` in a `try/except StopIteration`) so it can capture `StopIteration.value` and pass it into the **same existing** `self._tracker.record(...)` call in its `finally` block — no new tracker call, no double-counting of `call_count_ok`/`call_count_error`.
### Known limitation carried forward
If the gateway does not honor `stream_options.include_usage` (some OpenAI-compatible proxies ignore unknown fields silently rather than erroring), streaming usage will simply remain absent, same as today — this is a graceful no-op, not a new failure mode.
---
## A2 — Enable the Reranker
### Current behavior (confirmed)
`.env` has `RERANKER_ENABLED=false`. `OpenAICompatibleReranker` (`backend/app/infrastructure/vectorstore/cross_encoder_reranker.py`) already:
- Tries TEI format (`POST /rerank`), falls back to Cohere format (`POST /v1/rerank`) on 400/404.
- On any failure, logs a warning, records the failure into `ModelUsageTracker` (`provider="reranker"`), and **falls back to the original unranked order** rather than raising — retrieval keeps working even if the reranker is broken.
### Change
Flip `RERANKER_ENABLED=true` in root `.env`. No code change. `rag_retrieval_top_k=20` / `rag_top_k=5` are already set to reasonable pre/post-rerank values (`backend/app/config/settings.py:124-125`).
### Verification
Use the already-shipped `POST /status/models/ping` endpoint to actively confirm the gateway's rerank endpoint responds before considering this done. If it errors, the Status page's "AI Models" card will show the reranker row as `error` (existing behavior, not new) — revert the flag in that case rather than leaving retrieval silently degraded to "reranker enabled but always failing over."
---
## A3 — Durable Usage Counters
### Current behavior (confirmed)
`ModelUsageTracker` (`backend/app/shared/model_usage_tracker.py`) holds all state in an in-memory `dict` guarded by a `threading.Lock`. Nothing writes it to disk; a restart or redeploy zeroes every counter.
### Design decision (confirmed with user during brainstorming)
Persist **current cumulative counters only** — one row per `provider:model`, no historical/time-series log. This is the smaller, already-shaped slice of what the 2026-07-02 spec deferred; a time-series log can be layered on top later if trend charts are ever requested, without reworking this table.
### Data model
New table, created the same way every other Postgres store in this codebase creates its table — a `CREATE TABLE IF NOT EXISTS` string executed on first use, no migration framework (matches `postgres_event_store.py`, `postgres_document_repository.py`, `postgres_document_processing_store.py`, `user_store.py`, `compliance/repository.py` — all follow this idiom):
```sql
CREATE TABLE IF NOT EXISTS model_usage_stats (
provider VARCHAR(64) NOT NULL,
model VARCHAR(128) NOT NULL,
total_tokens BIGINT NOT NULL DEFAULT 0,
prompt_tokens BIGINT NOT NULL DEFAULT 0,
completion_tokens BIGINT NOT NULL DEFAULT 0,
call_count_ok BIGINT NOT NULL DEFAULT 0,
call_count_error BIGINT NOT NULL DEFAULT 0,
last_called_at TIMESTAMPTZ,
last_latency_ms INTEGER,
last_error TEXT,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (provider, model)
);
```
### Gating — reuse the existing backend toggle, don't add a new one
Gate persistence behind the **existing** `settings.document_repository_backend == "postgres"` flag (the same one `documents`/`compliance` already key off in `backend/app/shared/bootstrap.py`), rather than introducing a new setting. When it is `"json"` (today's default), `ModelUsageTracker` behaves exactly as it does today — purely in-memory, zero new hard requirement on a running Postgres for local/dev use.
### Write strategy: periodic snapshot flush, not per-call write-through
**Single-worker assumption:** this design assumes a single backend worker process. In a multi-worker deployment (e.g., multiple Uvicorn workers or replicas), each worker holds its own in-memory `ModelUsageTracker` and its own periodic flush will overwrite the same `(provider, model)` row with only that worker's partial counts (last-writer-wins semantics), so persisted totals would under-count versus true cross-worker totals — a pre-existing limitation of `ModelUsageTracker` being per-process, now also reflected in what gets persisted.
Rejected: writing to Postgres synchronously inside `record()` on every single LLM/embedding/reranker call — this would add blocking DB I/O to the hot path of every chat/RAG/compliance request, contradicting the tracker's own documented principle that tracking "must never disrupt a real user-facing call."
Chosen approach:
- **On startup** (in the existing `lifespan()` hook in `backend/app/api/main.py`, alongside the existing `preload_runtime_dependencies()` call): if the postgres backend is active, load existing `model_usage_stats` rows and seed `ModelUsageTracker`'s in-memory dict, so counts continue cumulatively instead of restarting at zero.
- **Every 60 seconds**, a background `asyncio` task (started at the same point, cancelled in the existing shutdown/`cleanup_runtime_dependencies()` path) snapshots the tracker (`tracker.snapshot()`, already exists) and `UPSERT`s each entry (`INSERT ... ON CONFLICT (provider, model) DO UPDATE`) — overwriting with the current cumulative value, not incrementing, so a missed cycle is never double-counted.
- **Best-effort flush on shutdown** as a bonus on top of the periodic flush (not the primary durability mechanism — a `SIGKILL`/OOM crash will not trigger it, which is an acceptable, explicitly-noted gap for an observability feature: worst case, up to 60s of counters are lost, not corrupted).
---
## Error Handling
- A1: if a client's `stream_chat()` never emits a trailing usage chunk (gateway doesn't support `stream_options`), the generator simply returns `None`; `TrackedLLMClient` already treats "no usage" as a no-op for the token fields (existing `record()` behavior — `usage or {}`).
- A2: unchanged — already-shipped graceful fallback and error surfacing.
- A3: the flush task wraps each cycle in `try/except Exception: logger.warning(...)` — a transient Postgres blip must not crash the flush loop or the app; it simply retries on the next 60s tick. Startup load failure (e.g., Postgres unreachable at boot) logs a warning and leaves the tracker empty, exactly as it behaves today with no persistence at all — it does not block app startup.
## Testing
Mirrors existing conventions (`backend/tests/observability/`, `backend/tests/perception/test_postgres_event_store.py` for the mocked-psycopg2 pattern — no real database needed):
- Extend `backend/tests/observability/test_tracked_client.py`: streaming usage now flows into the same `record()` call (assert the returned `StopIteration.value` path is wired correctly).
- New tests for `DeepSeekClient.stream_chat()` / `QwenClient.stream_chat()` / `QwenVLClient.stream_chat()`: trailing usage chunk is parsed and returned; ordinary content chunks are unaffected; a stream with no usage chunk still returns `None` without error.
- New `backend/tests/observability/test_model_usage_persistence.py`: mocked `psycopg2` (same pattern as `test_postgres_event_store.py`) — verifies startup load seeds the tracker, the flush cycle upserts a snapshot, and everything is a no-op when `document_repository_backend != "postgres"`.
- A2 needs no new test — it is a configuration change exercised by existing reranker tests and manual `/status/models/ping` verification.
## Out of Scope (deferred to future iterations)
- Time-series/historical usage log and trend charts (explicit user decision this iteration — durable counters only).
- Cost/spend estimation in currency.
- Per-session/per-user attribution.
- Full Langfuse/Ragas tracing and LLM-as-judge faithfulness scoring (`P0-A`, tracked separately).
@@ -0,0 +1,205 @@
# MCP Regulation Search Server — Design
**Date:** 2026-07-29
**Scope:** Expose the existing compliance knowledge base as a standalone Model Context Protocol (MCP) server module, mounted into the existing FastAPI backend, so external MCP clients (Claude Desktop, GitHub Copilot, Cursor, etc.) can call a single `search_regulations` tool over the network.
**Relationship to existing roadmap:** This is the first half ("Direction A" — expose our data) of the MCP integration opportunity identified during the 2026-07-29 brainstorming session. "Direction B" (consuming external MCP servers, e.g. for US/UK regulatory data) was researched and explicitly rejected for this iteration — no existing open-source regulation MCP server covers this platform's actual sources (国标委/GB standards, CATARC, EUR-Lex); the closest match (`lamcearber-spec/eu-legal-mcp`) is a 0-star, month-old project that only duplicates EUR-Lex data this platform already crawls itself. Direction B is deferred until a concrete need for a jurisdiction this platform doesn't already cover arises.
---
## Goals
1. Expose exactly one MCP tool, `search_regulations`, backed by the **existing** `AgentConversationService.ask()` application service (`backend/app/application/agent/services.py`) — the same code path already used by the `/api/v1/agent/ask` REST endpoint. Zero new retrieval/answering logic.
2. Package the MCP server as its own self-contained module (`backend/app/mcp/`), then mount it into the existing FastAPI app (`backend/app/api/main.py`) so it ships with the current deployment — no new process, no new deployment pipeline.
3. Use the Streamable HTTP transport (not stdio) — the backend is deployed remotely (6.86.80.9), so external MCP clients must connect over the network, not via a locally-spawned subprocess.
4. Reuse the existing JWT auth mechanism — no new auth system. Any authenticated user (any of the four roles) may call `search_regulations`, matching the existing `/agent/ask` endpoint's access level and the `UserRole` docstring ("knowledge query" is available to all four roles including `READONLY`).
## Non-Goals
- Direction B (this platform's agent consuming external MCP servers) — deferred, see rejection rationale above.
- Additional tools beyond `search_regulations` (e.g. perception event queries, compliance checks) — explicit user decision to ship the minimal viable version first.
- Role-based restriction of the tool (e.g. ADMIN-only) — all four roles already have knowledge-query access per the existing RBAC model; no new restriction needed.
- stdio transport / local-only usage — not useful for a remotely-deployed backend.
- Rate limiting or per-client quotas on the MCP endpoint — no existing precedent in this codebase for any endpoint; out of scope until a concrete abuse case appears.
---
## Architecture
> **Implementation note (post-design correction):** the `mcp` PyPI package released version `2.0.0` shortly before implementation and renamed the `FastMCP` class referenced below to `MCPServer` (import path `mcp.server.MCPServer` instead of `mcp.server.fastmcp.FastMCP`). The `.tool()` / `.streamable_http_app()` API surface used throughout this doc is otherwise unchanged. `backend/app/mcp/server.py` uses the actual shipped `MCPServer` name — treat every `FastMCP` mention below as that rename. Two other corrections discovered during implementation: (1) `MCPServer.streamable_http_app()` registers its own internal route at a fixed `/mcp` path, so mounting it at `/mcp` in `api/main.py` would double the path to `/mcp/mcp` — fixed by calling `mcp.streamable_http_app(streamable_http_path="/")`; (2) the effective external URL for clients is `/mcp/` (**with** a trailing slash) — Starlette's `Mount` 307-redirects the bare `/mcp` to `/mcp/`, which most HTTP clients follow automatically, but it is more robust to configure clients with the trailing slash directly.
### Module layout
```
backend/app/mcp/
__init__.py
server.py # FastMCP instance, search_regulations tool, auth wrapper, ASGI app builder
```
This sits as a new top-level package alongside `app/api/`, `app/application/`, `app/services/`, `app/shared/` — not nested inside `app/api/routes/`, because MCP tool registration (decorator-based schema binding) and its own sub-ASGI-app are a fundamentally different transport mechanism from the FastAPI `APIRouter` REST routes there. Keeping it as its own top-level module satisfies "list MCP as its own module" and keeps the REST route directory free of non-REST concerns.
`server.py` contains **zero new business logic** — it is a protocol adapter that calls the existing composition root (`app.shared.bootstrap.get_agent_conversation_service()`), the same function `backend/app/api/routes/agent.py` already calls. This is consistent with the architecture rule that new business orchestration belongs in `application/`, not scattered across transport adapters — there is no new orchestration here at all.
### Tool definition
```python
# backend/app/mcp/server.py
from mcp.server.fastmcp import FastMCP
from app.shared.bootstrap import get_agent_conversation_service
mcp = FastMCP("ai-regulations")
@mcp.tool()
def search_regulations(query: str, top_k: int = 5) -> dict:
"""检索法规知识库,返回基于检索结果生成的答案及引用来源。
query: 自然语言检索问题,例如"国六排放标准最新要求"。
top_k: 返回的引用来源条数上限,默认5条。
"""
_, result = get_agent_conversation_service().ask(query=query, top_k=top_k)
return {
"answer": result.answer,
"sources": [source.__dict__ for source in result.sources],
}
```
Calling `ask()` **without** `session_id` is intentional: it skips all `ConversationStore` reads/writes (see `AgentConversationService.ask()` — history/session logic is only engaged when `session_id` is passed), so each MCP tool call is stateless and side-effect-free, matching the "search" semantics (not a multi-turn chat).
### Transport & mounting
```python
# backend/app/mcp/server.py (continued)
def build_mcp_asgi_app():
"""Return the mounted MCP ASGI app (Streamable HTTP transport)."""
return mcp.streamable_http_app()
```
```python
# backend/app/api/main.py (modified)
from contextlib import AsyncExitStack
from app.mcp.server import build_mcp_asgi_app, MCPAuthMiddleware
mcp_app = build_mcp_asgi_app()
mcp_app.add_middleware(MCPAuthMiddleware) # see Auth section
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Application lifecycle hooks."""
async with AsyncExitStack() as stack:
# FastMCP's streamable_http_app() owns a session manager that must be
# started via its own lifespan context. app.mount() does NOT propagate
# nested ASGI lifespans automatically (confirmed Starlette/ASGI limitation:
# https://github.com/modelcontextprotocol/python-sdk/issues/1367) — without
# this, every search_regulations call would fail because the MCP session
# manager was never started.
await stack.enter_async_context(mcp_app.router.lifespan_context(mcp_app))
logger.info(f"启动 {settings.app_name} v{settings.app_version}")
preload_runtime_dependencies()
yield
cleanup_runtime_dependencies()
app.mount("/mcp", mcp_app)
```
This is the one non-obvious infrastructure detail in this design: naively mounting `mcp.streamable_http_app()` via `app.mount()` without wiring its lifespan results in tool calls failing at runtime because the MCP session manager was never started — this is not a hypothetical, it is a confirmed, documented limitation of nested ASGI apps. Using stdlib `AsyncExitStack` inside the **existing** `lifespan()` function avoids adding any new dependency to solve it.
### Auth
The `/mcp` mount point is protected by a small ASGI middleware (not a FastAPI `Depends`, since the mounted app is not a `FastAPI`/`APIRouter` instance) that:
1. Reads the `Authorization: Bearer <token>` header from the incoming ASGI scope.
2. When `settings.auth_enabled` is `False` (dev mode) — passes through unchanged, matching the existing `get_current_user` dev bypass behavior.
3. When `settings.auth_enabled` is `True` — validates the token via the **existing** `get_jwt_handler().decode_token(token)` (`backend/app/infrastructure/auth/jwt_handler.py`). On `ValueError` (expired/invalid/missing), returns an HTTP 401 before the request ever reaches the MCP protocol handler. On success, the request proceeds — no role check, since all roles already have knowledge-query access.
```python
# backend/app/mcp/server.py (continued)
from starlette.types import ASGIApp, Receive, Scope, Send
from starlette.responses import PlainTextResponse
from app.config.settings import settings
from app.shared.bootstrap import get_jwt_handler
class MCPAuthMiddleware:
"""Reject unauthenticated requests to the mounted MCP app before they reach FastMCP."""
def __init__(self, app: ASGIApp) -> None:
self.app = app
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] != "http" or not settings.auth_enabled:
await self.app(scope, receive, send)
return
headers = dict(scope["headers"])
auth_header = headers.get(b"authorization", b"").decode()
token = auth_header.removeprefix("Bearer ").strip()
try:
get_jwt_handler().decode_token(token)
except ValueError as exc:
response = PlainTextResponse(str(exc), status_code=401)
await response(scope, receive, send)
return
await self.app(scope, receive, send)
```
**Operational consequence:** to connect an external MCP client (Claude Desktop, Copilot, etc.) to this server, the user must configure a static bearer token (a JWT obtained via the existing `/api/v1/auth/login` flow) in that client's MCP server config, e.g.:
```json
{
"mcpServers": {
"ai-regulations": {
"url": "http://6.86.80.9:8000/mcp/",
"headers": { "Authorization": "Bearer <jwt>" }
}
}
}
```
JWTs expire after `expire_minutes` (480 by default, see `JWTHandler`) — long-lived external tool connections will need a token refresh story, but that is an existing limitation of the JWT scheme generally (not new to MCP), so it is not addressed differently here.
### Transport security (Host allow-list)
> **Added post-design after code review.** This was missed in the original design and would have made the feature 100% non-functional in the target deployment.
The MCP SDK enables DNS-rebinding protection automatically whenever the transport's bind host is a loopback address (its `host` parameter defaults to `127.0.0.1`), and then hard-codes the allow-list to `127.0.0.1:*`, `localhost:*`, `[::1]:*`. `TransportSecurityMiddleware` rejects any request whose `Host` header is not on that list with **HTTP 421**, *before* `MCPAuthMiddleware` or the tool runs. A client pointed at `http://6.86.80.9:8000/mcp/` sends `Host: 6.86.80.9:8000` and is therefore refused every time.
The module resolves this by passing an explicit `TransportSecuritySettings` built from a new setting, `MCP_ALLOWED_HOSTS` (comma-separated, `:*` suffix matches any port, documented in `.env.example`):
- Default `127.0.0.1:*,localhost:*,[::1]:*` — safe for local development.
- Deployments must add their real address, e.g. `MCP_ALLOWED_HOSTS=6.86.80.9:*,127.0.0.1:*,localhost:*`.
- The literal value `*` disables the protection entirely. This is deliberately an explicit, log-warned opt-out rather than the default, since binding to `0.0.0.0` to sidestep the check would silently switch DNS-rebinding protection off.
- `allowed_origins` reuses the existing `CORS_ALLOW_ORIGINS` list, so trusted browser origins are declared in exactly one place. Non-browser MCP clients send no `Origin` header, which the SDK treats as allowed.
### Tool input bounds
`search_regulations` declares `query` as 12000 characters and `top_k` as 120 via `Annotated[..., Field(...)]`, matching `AskRequest` in `app/api/models/agent.py`. This is load-bearing rather than cosmetic: `KnowledgeRetrievalService.retrieve()` amplifies the value (`candidate_k = max(top_k * 4, 20)`) when reranking is active, so an unbounded `top_k` is a cheap resource-exhaustion vector — and an LLM client hallucinating a large value is the likelier trigger than an attacker. Declaring the bounds via `Annotated` also publishes them in the advertised JSON schema, so well-behaved clients never send an out-of-range value at all.
---
## Error Handling
- **Auth failure** (missing/expired/invalid token, when `auth_enabled=True`): HTTP 401 from `MCPAuthMiddleware`, before the MCP protocol layer is invoked at all. The response carries `WWW-Authenticate: Bearer`, matching the `get_current_user` dependency and RFC 7235.
- **Malformed `Authorization` header bytes**: ASGI header values are latin-1, so the middleware decodes as latin-1; a non-UTF-8 byte yields a normal 401 rather than an unhandled `UnicodeDecodeError`/500.
- **Rejected `Host` header**: HTTP 421 from the SDK's transport-security middleware (see above), before auth.
- **Tool execution failure** (e.g. the underlying retrieval/LLM call raises): FastMCP's own tool-call error handling catches exceptions raised inside `@mcp.tool()`-decorated functions and returns them as a normal MCP tool-error result to the calling client — no special handling needed in `search_regulations` itself, consistent with how `/agent/ask`'s REST handler already lets the global FastAPI exception handler in `main.py` catch unexpected errors.
- **Lifespan startup failure** (e.g. `mcp_app`'s session manager fails to start): surfaces the same way any other `lifespan()` failure does today — the app fails to start, visible immediately in logs, not a silent partial-degradation.
## Testing
- `backend/tests/mcp/test_search_regulations_tool.py` — unit test for the tool function with a mocked `AgentConversationService` (same mocking style as existing `application/agent` tests): asserts `search_regulations()` calls `.ask(query=..., top_k=...)` with no `session_id`, shapes the returned dict correctly (`answer`, `sources`), and that the advertised JSON schema carries the `query`/`top_k` bounds.
- `backend/tests/mcp/test_mcp_auth_middleware.py` — unit test for `MCPAuthMiddleware`: no token → 401; invalid/expired token → 401; valid token → request passed through to the wrapped app; `auth_enabled=False` → always passed through; 401 carries `WWW-Authenticate`; non-UTF-8 header bytes → 401 not 500. Uses Starlette's `TestClient` against a minimal dummy inner ASGI app, no real MCP protocol handshake needed.
- `backend/tests/mcp/test_mcp_transport_security.py` — exercises the **real** MCP app over `TestClient`: a configured remote `Host` completes a real JSON-RPC `initialize` handshake; an unconfigured `Host` is refused with 421; `*` disables protection; the comma-separated setting parses correctly. These run the app's lifespan via `with TestClient(...)`, without which the SDK's session-manager task group is uninitialized.
- `backend/tests/conftest.py` — mocks `psycopg2` at import time for the whole suite. Individual test modules cannot do this reliably, because whether a module runs before the one that needs the mock depends on alphabetical collection order; `conftest.py` is imported before any test module in the tree.
- **Manual end-to-end verification** (not automated): use the official `mcp` Python client (`mcp.client.streamable_http.streamable_http_client` + `mcp.ClientSession`) to connect to a locally running instance, call `list_tools()`, then call `search_regulations` with a real query, confirming a real answer + sources come back. This is a one-time manual check, not a CI test.
## Dependencies
- Add `mcp` (official Model Context Protocol Python SDK, provides `mcp.server.fastmcp.FastMCP`) to `backend/requirements.txt`. No existing dependency implements the MCP protocol (JSON-RPC 2.0 framing + Streamable HTTP transport + capability negotiation); hand-rolling this would be substantially more code and more fragile than the official SDK.
## Out of Scope (deferred to future iterations)
- Direction B: consuming external MCP servers from this platform's own Agentic RAG pipeline.
- Additional MCP tools (perception event queries, compliance checks).
- Per-role tool restrictions.
- Token refresh / long-lived credential story for external MCP clients beyond the existing JWT expiry behavior.
- Rate limiting on the `/mcp` endpoint.
@@ -0,0 +1,198 @@
# MCP Status Panel — Design
Date: 2026-08-03
Status: Approved
## Problem
`app/mcp/` already exposes the compliance knowledge base over MCP
(`search_regulations`, Streamable HTTP at `/mcp/`, JWT-guarded, Host
allow-listed). It is completely invisible from the product: an operator
looking at the System Status page cannot tell whether the MCP endpoint is
enabled, what URL a client should point at, which tools are advertised, or
whether anything has ever called it.
This design adds that visibility, and only that.
## Goals
- Show MCP endpoint configuration (public URL, auth on/off, Host allow-list).
- Show the advertised tool list, read from the live MCP registry rather than
hard-coded.
- Show per-tool call counters: total calls, errors, average duration, last
call time.
- Give the operator a one-click "copy client config" JSON they can paste into
Claude Desktop / Cursor.
## Non-Goals
- No persistence. Counters are in-memory and reset on restart. Token
consumption caused by MCP calls is already persisted by the existing
`ModelUsageTracker` (MCP calls route through `AgentConversationService.ask()`
like every other caller), so nothing billable is lost.
- No historical trends or time-series charts.
- No active-client / session list. That would require hooking the MCP SDK's
internal `StreamableHTTPSessionManager`, which is private API and breaks on
SDK upgrades.
- No per-client attribution.
- No new frontend test framework — the project has none, and this change does
not justify introducing one.
## Architecture
Module boundary is unchanged: everything new on the backend lands inside the
existing `app/mcp/` module, plus one thin HTTP adapter route.
```
frontend/src/pages/Status/StatusPage.tsx
│ GET /api/v1/status/mcp
backend/app/api/routes/status.py ← HTTP adapter only
│ get_mcp_status(public_url)
backend/app/mcp/server.py ← assembles the status payload
├── settings (endpoint / auth / allowed hosts)
├── mcp.list_tools() ← live tool registry
└── app/mcp/stats.py ← in-memory counters
```
The status route must not reach into the MCP server's internals. It passes in
the resolved public URL (the one thing only the HTTP layer knows) and receives
a finished dict. This keeps the MCP protocol details in one module.
## Components
### `app/mcp/stats.py` (new)
Mirrors `app/shared/model_usage_tracker.py` in shape and in defensive posture.
- `MCPToolStats` dataclass: `calls`, `errors`, `last_called_at`,
`total_duration_ms`; `avg_duration_ms` computed as a property.
- `MCPStatsTracker`: one `threading.Lock` guarding a
`dict[str, MCPToolStats]`. `record(tool, duration_ms, success)` and
`snapshot()`.
- `get_mcp_stats_tracker()`, `@lru_cache` singleton.
The lock is not optional. The mcp SDK (2.0.0) dispatches synchronous tool
functions via `anyio.to_thread.run_sync`, so `search_regulations` genuinely
runs on multiple worker threads concurrently — unlike the async REST routes,
which serialize on the event loop.
`record()` swallows and logs its own exceptions, matching `ModelUsageTracker`:
a defect in observability code must never fail a real MCP tool call.
### `app/mcp/server.py` (modified)
- `search_regulations` gets a `try/except/finally` wrapper that measures
elapsed time with `time.perf_counter()` and records success or failure. The
exception is re-raised after recording — the MCP SDK still needs to turn it
into a protocol-level error.
- New `async def get_mcp_status(public_url: str) -> dict` merges three
sources: settings, `await mcp.list_tools()`, and the stats snapshot. Tools
are matched to their stats by name; a tool that has never been called
reports zeros.
### `app/api/routes/status.py` (modified)
`GET /status/mcp` resolves the public URL, then delegates:
```python
public_url = settings.mcp_public_url or f"{str(request.base_url).rstrip('/')}/mcp/"
return await get_mcp_status(public_url)
```
### `app/config/settings.py` + `.env.example` (modified)
New optional `mcp_public_url: str = ""`.
This override is required, not cosmetic. The Vite dev proxy sets
`changeOrigin: true` (`frontend/vite.config.ts`), which rewrites the `Host`
header to the proxy target, so `request.base_url` on the backend reads
`http://127.0.0.1:8000/` in development regardless of how the operator
actually reached the page. Deployments behind a reverse proxy that does not
forward the original Host have the same problem. When unset, the derived
value is correct for the common same-origin case.
### Frontend
- `api/index.ts`: `MCPToolEntry` and `MCPStatusResponse` types, alongside the
existing `ModelUsageEntry` / `SystemHealth` types.
- `api/status.ts`: `getMCPStatus()`, using the typed `fetchAPI` client.
- `StatusPage.tsx`: new "MCP Server" card in the left column, directly below
the existing "AI Models" card. It joins the existing
`Promise.allSettled([...])` batch, so it refreshes with the page's existing
Refresh button and needs no independent polling. `handleExport()` includes
the MCP payload.
- Header row: title + "copy client config" button.
- Endpoint row: `StatusIcon` + monospace URL + auth badge + Host allow-list.
- One row per tool: name, calls, errors, average duration, last call time.
- Reuses the existing `card`, `card-header`, `service-row` classes and the
`StatusIcon` component. No new CSS.
- `locales/zh.ts` / `locales/en.ts`: new keys under the existing `status`
section.
### Copy client config
Produces the Streamable HTTP form both Claude Desktop and Cursor accept:
```json
{
"mcpServers": {
"ai-regulations": {
"url": "http://6.86.80.9:8000/mcp/",
"headers": { "Authorization": "Bearer <token>" }
}
}
}
```
The real JWT from `localStorage` is embedded, because a config with a
placeholder does not work when pasted and defeats the button's purpose. This
is the operator's own token, already present in their own browser; the button
moves it from one local store to another local store on the same machine. The
`headers` key is omitted entirely when `auth_required` is false.
## Data Flow
1. StatusPage mounts (or Refresh is pressed) → `getMCPStatus()` in the
existing parallel batch.
2. Route resolves the public URL and calls `get_mcp_status()`.
3. `get_mcp_status()` reads settings, awaits `mcp.list_tools()`, snapshots
stats, joins tools to stats by name.
4. Card renders. Counters advance only when a real MCP client calls a tool.
## Error Handling
- `GET /status/mcp` fails or times out → `Promise.allSettled` leaves the state
`null` → card renders a muted "unavailable" body. This is the same pattern
the existing model-usage card already uses; one failing status endpoint must
never blank the whole page.
- `mcp.list_tools()` reads an in-memory registry populated at import time and
has no failure mode worth special-casing; an unexpected exception surfaces
as a 500 on this one endpoint and is contained by the point above.
- `MCPStatsTracker.record()` never raises (logged and swallowed).
- `navigator.clipboard.writeText` rejects on insecure origins and when
permission is denied. The button reports failure in its own label rather
than throwing — a silent no-op would leave the operator believing they
copied something.
## Testing
`backend/tests/mcp/test_mcp_stats.py`:
- Concurrent `record()` from multiple threads yields an exact total (proves
the lock).
- `avg_duration_ms` is correct across several calls, and is `None` with zero
calls (no division by zero).
- Successes and failures land in `calls` vs `errors` correctly.
- `record()` on malformed input logs instead of raising.
`backend/tests/mcp/test_mcp_status.py`:
- `get_mcp_status()` returns the advertised tool with zeroed stats before any
call, and reflects recorded stats after.
- `auth_required` follows `settings.auth_enabled`.
- The passed-in `public_url` appears unmodified in the payload.
Frontend: no new tests; verified via `npm --prefix frontend run lint` and
`npm --prefix frontend run build`.
+57
View File
@@ -73,6 +73,22 @@ export interface SSEMessage {
text?: string;
docs?: RetrievedDoc[];
session_id?: string;
// ── P0-1 Agentic-mode thinking-step fields ────────────────────────────────
// Populated when type === 'thinking'; maps to the backend IntentResult /
// GroundingResult / retrieval step payloads emitted by AgenticConversationService.
step?: string; // intent_analysis | query_planning | retrieving | grounding_check
status?: string; // running | done
intent_type?: string; // simple_qa | compare | multi_hop | ambiguous
requires_decomposition?: boolean;
reason?: string;
sub_queries?: string[];
query?: string; // sub-query being retrieved
index?: number; // 1-based sub-query index
total?: number; // total sub-query count
found?: number; // chunks found for this sub-query
retry?: boolean; // true when this is a grounding-failure re-query
sufficient?: boolean; // grounding check result
confidence?: number; // grounding confidence 01
}
export async function streamSSE<TMessage extends SSEMessage>(
@@ -294,4 +310,45 @@ 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[];
}
/** One tool advertised by the MCP server, joined with its in-memory call counters. */
export interface MCPToolEntry {
name: string;
description: string;
calls: number;
errors: number;
/** null when the tool has never been called — distinct from an average of 0. */
avg_duration_ms: number | null;
last_called_at: string | null;
}
export interface MCPStatusResponse {
endpoint_url: string;
auth_required: boolean;
allowed_hosts: string[];
tools: MCPToolEntry[];
}
export { API_BASE_URL };
+33
View File
@@ -52,6 +52,39 @@ export interface AnalysisSSEMessage {
text?: string;
}
export interface PerceptionNotification {
id: number;
event_id: string;
kind: 'new' | 'changed';
title: string;
impact_level: string | null;
summary: string | null;
created_at: string;
read: boolean;
}
export interface NotificationListResponse {
items: PerceptionNotification[];
unread_count: number;
}
/** Broadcast feed shared by every logged-in user; read state is per-caller. */
export async function getNotifications(limit = 20): Promise<NotificationListResponse> {
const res = await fetch(`${PERCEPTION_API_BASE}/perception/notifications?limit=${limit}`, { headers: authHeader() });
if (!res.ok) throw new Error(`notifications failed: ${res.status}`);
return res.json() as Promise<NotificationListResponse>;
}
/** Marks every currently-unread notification read for the calling user. */
export async function markNotificationsRead(): Promise<{ marked: number }> {
const res = await fetch(`${PERCEPTION_API_BASE}/perception/notifications/read`, {
method: 'POST',
headers: authHeader(),
});
if (!res.ok) throw new Error(`mark read failed: ${res.status}`);
return res.json() as Promise<{ marked: number }>;
}
export async function getPerceptionStats(): Promise<PerceptionStats> {
const res = await fetch(`${PERCEPTION_API_BASE}/perception/stats`, { headers: authHeader() });
if (!res.ok) throw new Error(`stats failed: ${res.status}`);
+91
View File
@@ -76,6 +76,27 @@ function parseSSEChunk(raw: string, onMessage: (data: SSEMessage) => void) {
onMessage({ type: 'error', text: joined });
} else if (eventName === 'status') {
onMessage({ type: 'status', text: joined });
} else if (eventName === 'thinking') {
// P0-1: Agentic reasoning step events from /agent/agentic/stream
try {
const payload = JSON.parse(joined) as Record<string, unknown>;
onMessage({
type: 'thinking',
step: payload.step as string | undefined,
status: payload.status as string | undefined,
intent_type: payload.intent_type as string | undefined,
requires_decomposition: payload.requires_decomposition as boolean | undefined,
reason: payload.reason as string | undefined,
sub_queries: payload.sub_queries as string[] | undefined,
query: payload.query as string | undefined,
index: payload.index as number | undefined,
total: payload.total as number | undefined,
found: payload.found as number | undefined,
retry: payload.retry as boolean | undefined,
sufficient: payload.sufficient as boolean | undefined,
confidence: payload.confidence as number | undefined,
});
} catch { /* ignore */ }
} else if (eventName === 'message') {
// /rag/chat format: event:message + JSON body with type field
try {
@@ -147,3 +168,73 @@ export async function ragChat(
}
export type { QuickQuestionsResponse, SSEMessage };
/**
* P0-1 Agentic RAG chat calls /agent/agentic/stream which runs the full
* intent-analysis query-planning retrieval grounding-check answer pipeline.
*
* The onMessage callback receives the same event types as ragChat plus
* ``type: 'thinking'`` events that carry live reasoning-step progress.
*/
export async function agenticChat(
query: string,
topK: number = 5,
onMessage: (data: SSEMessage) => void,
onError?: (error: Error) => void,
onComplete?: () => void,
filters?: string,
sessionId?: string,
signal?: AbortSignal,
contextText?: string,
contextFilename?: string,
): Promise<void> {
try {
const response = await fetch(`${AGENT_API_BASE}/agent/agentic/stream`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'text/event-stream',
...(getToken() ? { Authorization: `Bearer ${getToken()}` } : {}),
},
body: JSON.stringify({
query,
top_k: topK,
...(filters ? { filters } : {}),
...(sessionId ? { session_id: sessionId } : {}),
...(contextText ? { context_text: contextText, context_filename: contextFilename ?? '' } : {}),
}),
signal,
});
if (!response.ok || !response.body) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const parts = buffer.split('\n\n');
buffer = parts.pop() || '';
parseSSEChunk(parts.join('\n\n'), onMessage);
}
if (buffer.trim()) {
parseSSEChunk(buffer, onMessage);
}
if (onComplete) {
onComplete();
}
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') return;
if (onError) {
onError(error instanceof Error ? error : new Error(String(error)));
}
}
}
+17 -2
View File
@@ -1,4 +1,4 @@
import { fetchAPI, type SystemConfig, type SystemHealth, type SystemStats } from './index';
import { fetchAPI, type MCPStatusResponse, type ModelUsageResponse, type SystemConfig, type SystemHealth, type SystemStats } from './index';
export async function getSystemStats(): Promise<SystemStats> {
return fetchAPI<SystemStats>('/status/stats');
@@ -12,4 +12,19 @@ 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' });
}
/** MCP endpoint config, advertised tools, and per-tool call counters. */
export async function getMCPStatus(): Promise<MCPStatusResponse> {
return fetchAPI<MCPStatusResponse>('/status/mcp');
}
export type { MCPStatusResponse, ModelUsageResponse, SystemConfig, SystemHealth, SystemStats };
+22 -1
View File
@@ -1,3 +1,4 @@
import { useEffect, useState } from 'react';
import { NavLink } from 'react-router-dom';
import {
LayoutDashboard, Radio, Monitor, FileText,
@@ -6,6 +7,12 @@ import {
import { useTheme } from '../../contexts/ThemeContext';
import { useAuth } from '../../contexts/AuthContext';
import { useLanguage } from '../../contexts/LanguageContext';
import { getNotifications } from '../../api/perception';
// How often the sidebar re-checks the unread count. A plain UI refresh
// cadence, not an infrastructure setting — unlike the crawl interval, this
// never needs to be tuned per deployment.
const UNREAD_POLL_MS = 60_000;
interface NavItem {
to: string;
@@ -47,10 +54,24 @@ export function Sidebar() {
const { theme, toggleTheme } = useTheme();
const { user, logout } = useAuth();
const { lang, t, toggleLang } = useLanguage();
const [unreadSignals, setUnreadSignals] = useState(0);
// Sidebar only mounts inside RequireAuth, so a token always exists here.
// Polling (not push) keeps this simple — at one crawl every 6 hours, a
// 60s badge refresh is more than fast enough to feel current.
useEffect(() => {
let cancelled = false;
function poll() {
getNotifications().then(r => { if (!cancelled) setUnreadSignals(r.unread_count); }).catch(() => {});
}
poll();
const timer = setInterval(poll, UNREAD_POLL_MS);
return () => { cancelled = true; clearInterval(timer); };
}, []);
const mainNav: NavItem[] = [
{ to: '/', icon: <LayoutDashboard size={16} />, label: t.nav.overview },
{ to: '/signals', icon: <Radio size={16} />, label: t.nav.signals },
{ to: '/signals', icon: <Radio size={16} />, label: t.nav.signals, badge: unreadSignals },
{ to: '/status', icon: <Monitor size={16} />, label: t.nav.status },
];
+18 -14
View File
@@ -12,6 +12,7 @@
*/
import React, { createContext, useContext, useState, useCallback, useRef } from 'react';
import { COMPLIANCE_INIT } from './pageStateDefaults';
// ── RagChat types ─────────────────────────────────────────────────────────────
@@ -59,6 +60,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 +69,17 @@ export interface ComplianceFindingEvent {
desc: string;
status: 'ok' | 'warn' | 'risk';
clause_ref?: string;
/** LLM confidence that retrieved context covers the clause topic (01) */
confidence?: number;
/** Top-3 regulation chunks that informed this finding */
source_refs?: Array<{ standard: string; clause: string; score: number }>;
}
export interface ComplianceConflict {
type: 'contradiction' | 'missing_ref' | 'cumulative_risk';
finding_a: number;
finding_b: number | null;
desc: string;
}
export interface ComplianceActionItem {
@@ -103,22 +117,12 @@ 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 = {
status: 'idle',
stageLabel: '',
stageKey: '',
meta: null,
sources: [],
findings: [],
done: null,
errorText: '',
analysisId: null,
isReadOnly: false,
activeFindingId: null,
};
// ── Perception types ──────────────────────────────────────────────────────────
export interface PerceptionSignal {
+2
View File
@@ -2,6 +2,7 @@ export { ThemeProvider, useTheme } from './ThemeContext';
export { AuthProvider, useAuth } from './AuthContext';
export type { AuthUser } from './AuthContext';
export { PageStateProvider, usePageState } from './PageStateContext';
export { COMPLIANCE_INIT } from './pageStateDefaults';
export { LanguageProvider, useLanguage } from './LanguageContext';
export type { Lang } from './LanguageContext';
export type {
@@ -12,6 +13,7 @@ export type {
ComplianceStatus,
ComplianceSourceEvent,
ComplianceFindingEvent,
ComplianceConflict,
ComplianceDonePayload,
ComplianceMeta,
ComplianceActionItem,
@@ -0,0 +1,27 @@
/**
* Default values for PageStateContext slices.
*
* These live outside PageStateContext.tsx because that file exports React
* components, and `react-refresh/only-export-components` requires shared
* constants to sit in their own module. Keeping the defaults here also gives
* consumers a single canonical initial state to spread from, instead of each
* page maintaining its own copy that silently drifts when a field is added.
*/
import type { ComplianceState } from './PageStateContext';
export const COMPLIANCE_INIT: ComplianceState = {
status: 'idle',
stageLabel: '',
stageKey: '',
meta: null,
sources: [],
findings: [],
done: null,
errorText: '',
analysisId: null,
isReadOnly: false,
activeFindingId: null,
progress: null,
conflicts: [],
};
+110
View File
@@ -116,6 +116,7 @@ export interface Translations {
labelChunkBackend: string;
labelParserFailureMode: string;
configLoadError: string;
modelsLoadError: string;
cardBreakdown: string;
breakdownIndexed: string;
breakdownProcessing: string;
@@ -131,6 +132,30 @@ 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;
cardMcp: string;
mcpEndpoint: string;
mcpAuthRequired: string;
mcpAuthDisabled: string;
mcpAllowedHosts: string;
mcpCopyConfig: string;
mcpCopied: string;
mcpCopyFailed: string;
mcpCalls: string;
mcpErrors: string;
mcpAvgDuration: string;
mcpNoTools: string;
mcpUnavailable: string;
};
docs: {
topbarTitle: string;
@@ -226,6 +251,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 +401,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 +417,30 @@ 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',
cardMcp: 'MCP Server',
mcpEndpoint: 'Endpoint',
mcpAuthRequired: 'Auth required',
mcpAuthDisabled: 'No auth',
mcpAllowedHosts: 'Allowed hosts',
mcpCopyConfig: 'Copy client config',
mcpCopied: 'Copied',
mcpCopyFailed: 'Copy failed',
mcpCalls: 'calls',
mcpErrors: 'errors',
mcpAvgDuration: 'avg',
mcpNoTools: 'No MCP tools registered',
mcpUnavailable: 'MCP status endpoint unavailable',
},
docs: {
topbarTitle: 'Document Management',
@@ -456,5 +536,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.',
},
};
+55
View File
@@ -117,6 +117,7 @@ export const zh: Translations = {
labelChunkBackend: '分块后端',
labelParserFailureMode: '解析失败模式',
configLoadError: '无法加载配置',
modelsLoadError: '无法加载模型状态',
cardBreakdown: '文档分布',
breakdownIndexed: '已索引',
breakdownProcessing: '处理中 / 已解析',
@@ -132,6 +133,30 @@ export const zh: Translations = {
footerDegraded: '降级运行',
footerChecking: '检查中…',
totalChunks: '向量分块总数',
cardModels: 'AI 模型',
testConnectionBtn: '测试连接',
testingBtn: '测试中…',
roleMainLlm: '主问答 LLM',
roleHydeLlm: 'HyDE 查询增强',
roleEmbedding: 'Embedding',
roleReranker: 'Reranker',
modelStatusNeverCalled: '尚未调用',
modelStatusDisabled: '已禁用',
sharesUsageWithMain: '与主 LLM 共用统计',
lastCalledNever: '从未',
cardMcp: 'MCP 服务',
mcpEndpoint: '接入端点',
mcpAuthRequired: '需鉴权',
mcpAuthDisabled: '未鉴权',
mcpAllowedHosts: 'Host 白名单',
mcpCopyConfig: '复制接入配置',
mcpCopied: '已复制',
mcpCopyFailed: '复制失败',
mcpCalls: '调用',
mcpErrors: '失败',
mcpAvgDuration: '平均',
mcpNoTools: '未注册任何 MCP 工具',
mcpUnavailable: 'MCP 状态接口不可用',
},
docs: {
topbarTitle: '文档管理',
@@ -227,5 +252,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: '无法从该文件提取文本,请检查文件格式。',
},
};
+102 -179
View File
@@ -1,13 +1,13 @@
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';
import { usePageState } from '../../contexts';
import { HistoryRail } from './HistoryRail';
import { FindingChatDrawer } from './FindingChatDrawer';
import type { FindingEvent, SourceEvent, AnalysisMeta } from './useComplianceAnalysis';
import type { FindingEvent, SourceEvent } from './useComplianceAnalysis';
const TOKEN_KEY = 'auth_token';
function authHeader(): Record<string, string> {
@@ -39,81 +39,8 @@ function formatTs(iso: string) {
} catch { return iso; }
}
// ── Chat state for a single finding ─────────────────────────────────────────
interface ChatMsg { id: number; role: 'user' | 'assistant'; content: string }
function useFindingChat() {
const [open, setOpen] = useState(false);
const [findingIdx, setFindingIdx] = useState<number | null>(null);
const [messages, setMessages] = useState<ChatMsg[]>([]);
const [input, setInput] = useState('');
const [loading, setLoading] = useState(false);
const abortRef = useRef<AbortController | null>(null);
function openFor(idx: number, finding: FindingEvent) {
setFindingIdx(idx);
setOpen(true);
setMessages([{
id: 0,
role: 'assistant',
content: `I'm reviewing finding: **${finding.title}**\n\n${finding.desc}${finding.clause_ref ? `\n\nRef: ${finding.clause_ref}` : ''}\n\nHow can I help?`,
}]);
setInput('');
}
function close() { setOpen(false); abortRef.current?.abort(); }
async function send(segmentContext: string) {
if (!input.trim() || loading) return;
const q = input.trim();
setInput('');
const userMsg: ChatMsg = { id: Date.now(), role: 'user', content: q };
const assistantId = Date.now() + 1;
setMessages(m => [...m, userMsg, { id: assistantId, role: 'assistant', content: '' }]);
setLoading(true);
const ctrl = new AbortController();
abortRef.current = ctrl;
try {
const res = await fetch(`/api/v1/compliance/chat/${findingIdx ?? 0}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...authHeader() },
body: JSON.stringify({ query: q, segment_context: segmentContext }),
signal: ctrl.signal,
});
if (!res.body) { setLoading(false); return; }
const reader = res.body.getReader();
const dec = new TextDecoder();
let buf = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += dec.decode(value, { stream: true });
const blocks = buf.split('\n\n');
buf = blocks.pop() ?? '';
for (const block of blocks) {
const dl = block.split('\n').find(l => l.startsWith('data: '));
if (!dl) continue;
try {
const j = JSON.parse(dl.slice(6));
if (j.type === 'chunk' && j.text) {
setMessages(m => m.map(msg => msg.id === assistantId ? { ...msg, content: msg.content + j.text } : msg));
}
} catch { /* skip */ }
}
}
} catch (e: unknown) {
if (e instanceof Error && e.name === 'AbortError') return;
} finally {
setLoading(false);
}
}
return { open, findingIdx, messages, input, setInput, loading, openFor, close, send };
}
function _FindingChatDrawerWrapper({
/** Wrapper that resolves findingIndex → findingId from the saved analysis, then renders FindingChatDrawer. */
function FindingChatDrawerWrapper({
analysisId,
findingIndex,
finding,
@@ -128,7 +55,7 @@ function _FindingChatDrawerWrapper({
useEffect(() => {
fetch(`/api/v1/compliance/history/${analysisId}`, {
headers: { Authorization: `Bearer ${localStorage.getItem('auth_token') ?? ''}` },
headers: authHeader(),
})
.then(r => r.json())
.then((data: { findings?: Array<{ seq: number; id: string }> }) => {
@@ -153,8 +80,8 @@ export function CompliancePage() {
const [showModal, setShowModal] = useState(false);
const [showExportMenu, setShowExportMenu] = useState(false);
const { state, run, reset } = useComplianceAnalysis();
const chat = useFindingChat();
const [drawerFindingIdx, setDrawerFindingIdx] = useState<number | null>(null);
// drawerFinding holds {index, finding} for the currently-open FindingChatDrawer
const [drawerFinding, setDrawerFinding] = useState<{ idx: number; finding: FindingEvent } | null>(null);
const { setComplianceState } = usePageState();
const { t } = useLanguage();
@@ -198,6 +125,8 @@ export function CompliancePage() {
analysisId: data.id,
isReadOnly: true,
activeFindingId: null,
progress: null,
conflicts: [],
});
}
@@ -258,12 +187,6 @@ export function CompliancePage() {
setShowExportMenu(false);
}
// ── Chat context (finding desc + clause_ref as segment context) ──────────
const activeFinding = chat.findingIdx !== null ? state.findings[chat.findingIdx] : null;
const chatContext = activeFinding
? `Finding: ${activeFinding.title}\n${activeFinding.desc}${activeFinding.clause_ref ? `\nRef: ${activeFinding.clause_ref}` : ''}`
: '';
return (
<div className="compliance-page" style={{ position: 'relative' }}>
<Topbar
@@ -457,6 +380,25 @@ export function CompliancePage() {
<div className="comp-col findings-col">
<div className="col-header">
Findings {state.findings.length > 0 && `(${state.findings.length})`}
{/* Real per-clause progress bar during streaming */}
{isStreaming && state.progress && state.progress.total > 0 && (
<span style={{
marginLeft: 8, fontSize: 10, color: 'var(--muted)',
display: 'inline-flex', alignItems: 'center', gap: 6,
}}>
<span style={{
display: 'inline-block', width: 60, height: 4,
background: 'var(--border)', borderRadius: 2, overflow: 'hidden',
}}>
<span style={{
display: 'block', height: '100%',
width: `${Math.round((state.progress.done / state.progress.total) * 100)}%`,
background: 'var(--accent)', transition: 'width 0.3s ease',
}} />
</span>
{state.progress.done}/{state.progress.total}
</span>
)}
</div>
{state.findings.length === 0 && isStreaming && (
@@ -472,30 +414,85 @@ export function CompliancePage() {
<span className={`status ${f.status}`}>{STATUS_LABEL[f.status] ?? f.status}</span>
</div>
<p className="finding-desc">{f.desc}</p>
{/* Source refs: which retrieved chunks informed this finding */}
{f.source_refs && f.source_refs.length > 0 && (
<div style={{ marginTop: 4, display: 'flex', flexWrap: 'wrap', gap: 4 }}>
{f.source_refs.map((sr, si) => (
<span key={si} style={{
fontSize: 10, padding: '1px 6px',
background: 'var(--bg)', border: '1px solid var(--border)',
borderRadius: 4, color: 'var(--muted)',
}} title={sr.clause}>
📄 {sr.standard ? sr.standard.slice(0, 20) : '—'}
{sr.score > 0 && ` · ${Math.round(sr.score * 100)}%`}
</span>
))}
</div>
)}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginTop: 6 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
{f.clause_ref && (
<div style={{ fontSize: 11, color: 'var(--muted)' }}>Ref: {f.clause_ref}</div>
)}
{/* Confidence dot: green ≥0.7, amber 0.40.7, red <0.4 */}
{f.confidence !== undefined && (
<span
style={{
fontSize: 10, color: 'var(--muted)',
display: 'inline-flex', alignItems: 'center', gap: 3,
}}
title={`Retrieval confidence: ${Math.round(f.confidence * 100)}%`}
>
<span style={{
width: 6, height: 6, borderRadius: '50%',
background: f.confidence >= 0.7 ? '#22c55e' : f.confidence >= 0.4 ? '#f59e0b' : '#ef4444',
}} />
{Math.round(f.confidence * 100)}%
</span>
)}
</div>
{/* Single consolidated chat button — only when analysis is saved */}
{state.analysisId ? (
<button
className="btn sm"
style={{ marginLeft: 'auto', fontSize: 11, padding: '3px 8px', gap: 4 }}
onClick={() => chat.openFor(i, f)}
onClick={() => setDrawerFinding({ idx: i, finding: f })}
>
<MessageSquare size={11} />{t.compliance.askAIBtn}
</button>
{state.analysisId && (
<button
className="btn sm"
onClick={() => setDrawerFindingIdx(i)}
style={{ marginTop: 6 }}
>
💬 {t.compliance.chatBtn}
<MessageSquare size={11} />{t.compliance.chatBtn}
</button>
) : (
/* Fallback for unsaved analyses: show disabled chat hint */
<span style={{ marginLeft: 'auto', fontSize: 10, color: 'var(--muted)' }}>
{t.compliance.askAIBtn}
</span>
)}
</div>
</div>
))}
{/* Cross-clause conflicts panel */}
{state.conflicts && state.conflicts.length > 0 && (
<div className="card" style={{ borderLeft: '3px solid #f59e0b', marginTop: 8 }}>
<div className="card-header" style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<AlertTriangle size={12} color="#f59e0b" />
<span style={{ fontSize: 12, fontWeight: 600 }}>Cross-Clause Issues ({state.conflicts.length})</span>
</div>
{state.conflicts.map((c, ci) => (
<div key={ci} style={{ fontSize: 11, color: 'var(--muted)', padding: '4px 0', borderTop: ci ? '1px solid var(--border)' : 'none' }}>
<span style={{
fontWeight: 600,
color: c.type === 'contradiction' ? '#ef4444' : c.type === 'cumulative_risk' ? '#f59e0b' : 'var(--fg)',
}}>
[{c.type.replace('_', ' ')}]
</span>
{' '}Finding #{c.finding_a}{c.finding_b ? ` ↔ #${c.finding_b}` : ''}: {c.desc}
</div>
))}
</div>
)}
{/* Conclusion */}
{isDone && state.done && (
<div className="card conclusion-box">
@@ -540,92 +537,18 @@ export function CompliancePage() {
</div>
</div>
{/* ── Finding Chat Side Panel ────────────────────────────────── */}
{chat.open && (
<div style={{
position: 'fixed', right: 0, top: 0, bottom: 0, width: 400,
background: 'var(--surface)', borderLeft: '1px solid var(--border)',
display: 'flex', flexDirection: 'column', zIndex: 200,
boxShadow: '-8px 0 32px rgba(0,0,0,.12)',
}}>
{/* Header */}
<div style={{ padding: '16px 20px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<div style={{ fontSize: 13, fontWeight: 600 }}>{t.compliance.chatSidebarHeader}</div>
<div style={{ fontSize: 11, color: 'var(--muted)', marginTop: 2 }}>
Finding #{(chat.findingIdx ?? 0) + 1} · {activeFinding?.title}
</div>
</div>
<button
onClick={chat.close}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--muted)', padding: 4 }}
></button>
</div>
{/* Messages */}
<div style={{ flex: 1, overflowY: 'auto', padding: '16px 20px', display: 'flex', flexDirection: 'column', gap: 12 }}>
{chat.messages.map(msg => (
<div key={msg.id} style={{ display: 'flex', gap: 10, flexDirection: msg.role === 'user' ? 'row-reverse' : 'row' }}>
{msg.role === 'assistant' && (
<div style={{ width: 28, height: 28, borderRadius: 8, background: 'var(--accent)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, fontSize: 11, color: '#fff', fontWeight: 700 }}>AI</div>
)}
<div style={{
maxWidth: '82%', padding: '10px 14px', borderRadius: 10, fontSize: 13, lineHeight: 1.6, whiteSpace: 'pre-wrap',
background: msg.role === 'user' ? 'var(--accent)' : 'var(--bg)',
color: msg.role === 'user' ? '#fff' : 'var(--fg)',
border: msg.role === 'assistant' ? '1px solid var(--border)' : 'none',
}}>{msg.content}</div>
</div>
))}
{chat.loading && (
<div style={{ display: 'flex', gap: 10 }}>
<div style={{ width: 28, height: 28, borderRadius: 8, background: 'var(--accent)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, fontSize: 11, color: '#fff', fontWeight: 700 }}>AI</div>
<div style={{ padding: '10px 14px', borderRadius: 10, border: '1px solid var(--border)', background: 'var(--bg)', fontSize: 13, color: 'var(--muted)' }}>
{t.compliance.chatThinking}
</div>
</div>
)}
</div>
{/* Quick questions */}
<div style={{ padding: '8px 20px', display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{[t.compliance.quickQ1, t.compliance.quickQ2, t.compliance.quickQ3].map(q => (
<button key={q} onClick={() => chat.setInput(q)}
style={{ padding: '4px 10px', fontSize: 11, background: 'var(--bg)', border: '1px solid var(--border)', borderRadius: 6, cursor: 'pointer', color: 'var(--muted)' }}>
{q}
</button>
))}
</div>
{/* Input */}
<div style={{ padding: '12px 20px', borderTop: '1px solid var(--border)', display: 'flex', gap: 8 }}>
<input
value={chat.input}
onChange={e => chat.setInput(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); chat.send(chatContext); } }}
placeholder={t.compliance.chatPlaceholder}
style={{ flex: 1, padding: '9px 12px', fontSize: 13, background: 'var(--bg)', border: '1px solid var(--border)', borderRadius: 8, color: 'var(--fg)', outline: 'none' }}
/>
<button
className="btn primary"
onClick={() => chat.send(chatContext)}
disabled={!chat.input.trim() || chat.loading}
style={{ padding: '9px 14px' }}
>{t.compliance.sendBtn}</button>
</div>
</div>
)}
{drawerFindingIdx !== null && state.analysisId && (
<_FindingChatDrawerWrapper
{/* ── Finding Chat Drawer (single consolidated UI) ───────────── */}
{drawerFinding !== null && state.analysisId && (
<FindingChatDrawerWrapper
analysisId={state.analysisId}
findingIndex={drawerFindingIdx}
findingIndex={drawerFinding.idx}
finding={{
title: state.findings[drawerFindingIdx]?.title ?? '',
desc: state.findings[drawerFindingIdx]?.desc ?? '',
status: state.findings[drawerFindingIdx]?.status ?? 'ok',
clause_ref: state.findings[drawerFindingIdx]?.clause_ref,
title: drawerFinding.finding.title,
desc: drawerFinding.finding.desc,
status: drawerFinding.finding.status,
clause_ref: drawerFinding.finding.clause_ref,
}}
onClose={() => setDrawerFindingIdx(null)}
onClose={() => setDrawerFinding(null)}
/>
)}
</>
@@ -7,16 +7,17 @@
*/
import { useCallback } from 'react';
import { usePageState } from '../../contexts';
import { usePageState, COMPLIANCE_INIT } from '../../contexts';
import type {
ComplianceMeta,
ComplianceState,
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;
@@ -27,19 +28,6 @@ function authHeader(): Record<string, string> {
return t ? { Authorization: `Bearer ${t}` } : {};
}
const INITIAL_STATE: ComplianceState = {
status: 'idle',
stageLabel: '',
stageKey: '',
meta: null,
sources: [],
findings: [],
done: null,
errorText: '',
analysisId: null,
isReadOnly: false,
};
export function useComplianceAnalysis() {
const { complianceState: state, setComplianceState: setState, complianceAbortRef, resetCompliance: reset } = usePageState();
@@ -48,7 +36,7 @@ export function useComplianceAnalysis() {
const ctrl = new AbortController();
complianceAbortRef.current = ctrl;
setState({ ...INITIAL_STATE, status: 'streaming', stageLabel: 'Starting…', meta });
setState({ ...COMPLIANCE_INIT, status: 'streaming', stageLabel: 'Starting…', meta });
try {
const res = await fetch('/api/v1/compliance/analyze-stream', {
@@ -92,6 +80,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 +90,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 +99,13 @@ export function useComplianceAnalysis() {
desc: j.desc ?? '',
status: j.status ?? 'info',
clause_ref: j.clause_ref,
confidence: j.confidence,
source_refs: j.source_refs,
};
setState(s => ({ ...s, findings: [...s.findings, finding] }));
} else if (j.type === 'conflicts') {
// Cross-clause conflicts detected after all findings finish
setState(s => ({ ...s, conflicts: j.items ?? [] }));
} else if (j.type === 'done') {
const payload: ComplianceDonePayload = {
conclusion: j.conclusion ?? '',
+18 -4
View File
@@ -20,6 +20,7 @@ interface Doc {
sizeBytes: number;
summary?: string;
version?: string;
hasFile: boolean;
}
const STATUS_FILTERS = ['All', 'Ready', 'Processing', 'Failed', 'Pending'];
@@ -102,6 +103,7 @@ export function DocsPage() {
sizeBytes: (item.size_bytes as number) ?? 0,
summary: item.summary as string | undefined,
version: item.version as string | undefined,
hasFile: item.has_file !== false,
})));
setLoading(false);
})
@@ -130,11 +132,21 @@ export function DocsPage() {
}
// ── Download ─────────────────────────────────────────────────────────────
function downloadDoc(id: string, name: string) {
async function downloadDoc(id: string, name: string) {
try {
const resp = await fetch(`/api/v1/documents/download/${id}`, { headers: authHeader() });
if (!resp.ok) throw new Error(`下载失败: ${resp.status}`);
const blob = await resp.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = `/api/v1/documents/download/${id}`;
a.href = url;
a.download = name;
a.click();
URL.revokeObjectURL(url);
} catch (err) {
console.error('Download failed', err);
alert(String(err));
}
}
// ── Retry (re-process failed doc) ────────────────────────────────────────
@@ -289,11 +301,13 @@ export function DocsPage() {
<span className="cell-mono">{formatSize(d.sizeBytes)}</span>
<span className="cell-muted">{d.type}</span>
<span className="row-actions">
{/* Download */}
{/* Download — disabled for Milvus-only docs that have no binary file */}
<button
className="text-link"
title={t.docs.titleDownload}
title={d.hasFile ? t.docs.titleDownload : '无原始文件'}
onClick={() => downloadDoc(d.id, d.name)}
disabled={!d.hasFile}
style={!d.hasFile ? { opacity: 0.3, cursor: 'not-allowed' } : undefined}
>
<Download size={12} />
</button>
+2 -2
View File
@@ -222,7 +222,7 @@ export function UploadModal({ onClose, onComplete }: Props) {
<button className="modal-close" onClick={onClose} aria-label="Close" disabled={submitting}><X size={14} /></button>
{/* ── Left panel: upload form ── */}
<div className="modal-panel">
<div className="modal-panel" style={{ overflowY: 'auto' }}>
<div className="modal-eyebrow">Upload documents</div>
<div className="modal-title">Stage files for parsing and indexing.</div>
<p className="modal-lead">PDF, DOCX, TXT one per API call, processed sequentially.</p>
@@ -254,7 +254,7 @@ export function UploadModal({ onClose, onComplete }: Props) {
</div>
{files.length > 0 && (
<div className="staged-files">
<div className="staged-files" style={{ maxHeight: 220, overflowY: 'auto', overflowX: 'hidden' }}>
{files.map((f, i) => {
const isDone = doneCount > i;
const isActive = submitting && currentFileIdx === i;
+1 -1
View File
@@ -1,4 +1,4 @@
import React, { FormEvent, useState } from 'react';
import { useState, type FormEvent } from 'react';
import { useAuth } from '../../contexts';
export function LoginPage() {
@@ -1,4 +1,4 @@
import { useState, useEffect, useRef } from 'react';
import { useState, useEffect } from 'react';
import { Topbar } from '../../components/layout/Topbar';
import { RefreshCw, Play, Square, ExternalLink } from 'lucide-react';
import { usePageState } from '../../contexts';
@@ -15,23 +15,24 @@ interface Stats {
total: number;
high_impact: number;
medium_impact: number;
last_90_days: number;
recent_90d: number;
}
const SOURCES = ['All', 'MIIT', 'UN-ECE', 'ISO', 'GB Comm.', 'EUR-Lex', 'IATF'];
const IMPACTS = ['All', 'High', 'Medium', 'Low'];
// Backend event → Signal
function mapEvent(e: Record<string, unknown>): PerceptionSignal {
const impact = String(e.impact_level ?? '').toLowerCase();
// The backend publishes a lifecycle stage, not a severity. Mapping it through
// an impact-level vocabulary sent every real value to the default branch,
// which renders as "已发布" — so consultation drafts were labelled as enacted.
const backendStatus = String(e.status ?? '').toLowerCase();
return {
id: String(e.id ?? e.event_id ?? ''),
source: String(e.source ?? ''),
standard: String(e.standard ?? e.standard_code ?? e.regulation_id ?? ''),
status: backendStatus === 'high' || backendStatus === 'urgent' ? 'risk'
: backendStatus === 'medium' || backendStatus === 'draft' ? 'warn'
: backendStatus === 'low' || backendStatus === 'final' ? 'ok'
status: backendStatus === 'enacted' ? 'ok'
: backendStatus === 'draft' || backendStatus === 'consultation' ? 'warn'
: 'info',
title: String(e.title ?? ''),
summary: String(e.summary ?? e.description ?? ''),
@@ -80,7 +81,13 @@ export function PerceptionPage() {
fetch('/api/v1/perception/stats', { headers: authHeader() })
.then(r => r.json())
.then(setStats)
.catch(() => setStats({ total: 47, high_impact: 7, medium_impact: 18, last_90_days: 14 }));
.catch(() => setStats({ total: 47, high_impact: 7, medium_impact: 18, recent_90d: 14 }));
}, []);
// Landing on this page is the acknowledgement — clear the sidebar badge by
// marking every currently-unread notification read. No dismiss UI needed.
useEffect(() => {
fetch('/api/v1/perception/notifications/read', { method: 'POST', headers: authHeader() }).catch(() => {});
}, []);
// Fetch signal list on first mount only (if empty), otherwise preserve context state
@@ -114,6 +121,17 @@ export function PerceptionPage() {
const selected = signals.find(s => s.id === selectedId) ?? null;
// Derived from the loaded data rather than hardcoded. The previous fixed list
// was written against the mock fixtures, so the two sources the crawlers
// actually produce — CATARC and 国标委 — had no chip and could never be
// filtered. Deriving them also means a new crawler needs no frontend change.
// sourceFilter survives navigation in PageStateContext, so a filter chosen
// against an earlier dataset is kept in the list; dropping it would strand
// the user on an empty list with no chip to click their way out of.
const sources = ['All', ...Array.from(
new Set([...signals.map(s => s.source), sourceFilter].filter(s => s && s !== 'All')),
).sort()];
const filtered = signals.filter(s => {
if (sourceFilter !== 'All' && s.source !== sourceFilter) return false;
if (impactFilter !== 'All' && s.impact !== impactFilter) return false;
@@ -178,6 +196,11 @@ export function PerceptionPage() {
}
async function runCrawl() {
// A crawl already in flight is superseded — cancel it so its SSE reader
// stops writing status text for a run the user has replaced.
perceptionCrawlAbortRef.current?.abort();
const ctrl = new AbortController();
perceptionCrawlAbortRef.current = ctrl;
setCrawling(true);
setPerceptionState(s => ({ ...s, crawlStatus: t.signals.statusConnecting }));
try {
@@ -185,6 +208,7 @@ export function PerceptionPage() {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...authHeader() },
body: JSON.stringify({}),
signal: ctrl.signal,
});
if (!res.body) {
setPerceptionState(s => ({ ...s, crawlStatus: 'No stream' }));
@@ -232,11 +256,15 @@ export function PerceptionPage() {
}
}
} catch (e: unknown) {
// An abort is a deliberate supersede, not a backend failure — leaving the
// status untouched avoids reporting "connection failed" to the user.
if (!(e instanceof DOMException && e.name === 'AbortError')) {
setPerceptionState(s => ({
...s,
crawlStatus: t.signals.statusConnFailed.replace('{message}', e instanceof Error ? e.message : String(e)),
}));
}
}
setCrawling(false);
}
@@ -293,14 +321,14 @@ export function PerceptionPage() {
<span className="sbar-lbl">{t.signals.statMedium}</span>
</div>
<div className="sbar-cell accent">
<span className="sbar-val">{stats?.last_90_days ?? '—'}</span>
<span className="sbar-val">{stats?.recent_90d ?? '—'}</span>
<span className="sbar-lbl">{t.signals.statLast90}</span>
</div>
</div>
<div className="filter-bar">
<div className="chip-group">
{SOURCES.map(s => (
{sources.map(s => (
<button
key={s}
className={`chip${sourceFilter === s ? ' active' : ''}`}
@@ -365,7 +393,7 @@ export function PerceptionPage() {
<span className={`status ${selected.status}`}>
{selected.status === 'risk' ? t.signals.badgeUrgent : selected.status === 'warn' ? t.signals.badgeDraft : t.signals.badgePublished}
</span>
{selectedFull?.change_summary && (
{Boolean(selectedFull?.change_summary) && (
<span className="status warn" style={{ marginLeft: 'auto' }}>CHANGED</span>
)}
</div>
@@ -411,9 +439,9 @@ export function PerceptionPage() {
<p className="detail-summary" style={{ marginTop: 8 }}>
{(selectedFull?.scope as string) || selected.summary}
</p>
{selectedFull?.penalties && (
{Boolean(selectedFull?.penalties) && (
<p style={{ fontSize: 13, color: 'var(--danger)', marginTop: 6 }}>
{selectedFull.penalties as string}
{selectedFull?.penalties as string}
</p>
)}
</div>
@@ -486,8 +514,8 @@ export function PerceptionPage() {
{String(d.doc_name || '')}
<span className="doc-clause">{String(d.key_clauses || d.clause || '')}</span>
</div>
{d.snippet && <div className="doc-snippet">{String(d.snippet)}</div>}
{d.recommendation && (
{Boolean(d.snippet) && <div className="doc-snippet">{String(d.snippet)}</div>}
{Boolean(d.recommendation) && (
<div style={{ fontSize: 12, color: 'var(--accent)', marginTop: 2 }}> {String(d.recommendation)}</div>
)}
</div>
@@ -523,7 +551,7 @@ export function PerceptionPage() {
{String(s.new_text || '')}
</div>
</div>
{s.summary && <p style={{ fontSize: 12, marginTop: 6, color: 'var(--text-secondary)' }}>{String(s.summary)}</p>}
{Boolean(s.summary) && <p style={{ fontSize: 12, marginTop: 6, color: 'var(--text-secondary)' }}>{String(s.summary)}</p>}
</div>
));
})()}
+460 -7
View File
@@ -1,9 +1,11 @@
import { useRef, useEffect, useCallback, useState } from 'react';
import { Topbar } from '../../components/layout/Topbar';
import { Send, Download } from 'lucide-react';
import { Send, Download, Zap, Paperclip, X, FileText, AlertCircle } from 'lucide-react';
import { usePageState } from '../../contexts';
import type { RagCitation } from '../../contexts';
import { useLanguage } from '../../contexts/LanguageContext';
import { agenticChat } from '../../api/rag';
import type { SSEMessage } from '../../api/index';
const TOKEN_KEY = 'auth_token';
function authHeader(): Record<string, string> {
@@ -11,6 +13,46 @@ function authHeader(): Record<string, string> {
return t ? { Authorization: `Bearer ${t}` } : {};
}
// ── Document context state ─────────────────────────────────────────────────────
interface DocContext {
filename: string;
text: string;
charCount: number;
truncated: boolean;
/** 'extracting' while the backend is parsing; 'ready' when text is available; 'error' on failure */
status: 'extracting' | 'ready' | 'error';
errorMsg?: string;
}
// ── Agentic-mode types ────────────────────────────────────────────────────────
interface ThinkingStep {
id: string;
step: string;
status: 'running' | 'done';
intent_type?: string;
reason?: string;
requires_decomposition?: boolean;
sub_queries?: string[];
query?: string;
index?: number;
total?: number;
found?: number;
sufficient?: boolean;
confidence?: number;
retry?: boolean;
}
const STEP_ICONS: Record<string, string> = {
intent_analysis: '🔍',
query_planning: '📋',
retrieving: '📚',
grounding_check: '🔗',
};
// ── Helpers ───────────────────────────────────────────────────────────────────
// Map a raw source doc from the backend "retrieved" event to our Citation shape.
function mapSource(s: Record<string, unknown>, idx: number): RagCitation {
const rawScore = typeof s.score === 'number' ? s.score : 0;
@@ -69,10 +111,72 @@ export function RagChatPage() {
const [streaming, setStreaming] = useState(ragStreamingRef.current);
const [quickPrompts, setQuickPrompts] = useState<string[]>(MOCK_QUICK);
// P0-1 Agentic mode state
const [agenticMode, setAgenticMode] = useState(false);
const [thinkingSteps, setThinkingSteps] = useState<ThinkingStep[]>([]);
const [thinkingExpanded, setThinkingExpanded] = useState(true);
// ── Document context state ─────────────────────────────────────────────────
// Holds the extracted text from the attached file; sent to the backend as
// conversation context on every message while it is set.
const [docContext, setDocContext] = useState<DocContext | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const bottomRef = useRef<HTMLDivElement>(null);
const citRailRef = useRef<HTMLDivElement>(null);
const citItemRefs = useRef<Record<number, HTMLDivElement | null>>({});
// ── Document context helpers ───────────────────────────────────────────────
/** Upload file to /rag/upload-context, extract its text, store as context. */
async function handleFileAttach(file: File) {
setDocContext({ filename: file.name, text: '', charCount: 0, truncated: false, status: 'extracting' });
const fd = new FormData();
fd.append('file', file);
try {
const res = await fetch('/api/v1/rag/upload-context', {
method: 'POST',
headers: authHeader(),
body: fd,
});
if (!res.ok) {
const errText = await res.text().catch(() => t.ragchat.attachErrorMsg);
setDocContext(prev => prev ? { ...prev, status: 'error', errorMsg: errText.slice(0, 120) } : null);
return;
}
const data = await res.json();
setDocContext({
filename: data.filename ?? file.name,
text: data.text ?? '',
charCount: data.char_count ?? 0,
truncated: data.truncated ?? false,
status: 'ready',
});
} catch (err) {
setDocContext(prev => prev
? { ...prev, status: 'error', errorMsg: String(err).slice(0, 120) }
: null
);
}
}
function handleFileInputChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (file) void handleFileAttach(file);
// Reset so the same file can be re-selected
e.target.value = '';
}
function handleFileDrop(e: React.DragEvent<HTMLDivElement>) {
e.preventDefault();
const file = Array.from(e.dataTransfer.files).find(f =>
/\.(pdf|docx?|txt|md)$/i.test(f.name)
);
if (file) void handleFileAttach(file);
}
// Fetch quick questions from backend on mount (only once per session)
useEffect(() => {
fetch('/api/v1/rag/quick-questions', { headers: authHeader() })
@@ -102,9 +206,17 @@ export function RagChatPage() {
async function send(text?: string) {
const q = (text ?? inputDraft).trim();
if (!q || ragStreamingRef.current) return;
// Block send while a document is still being extracted
if (!q || ragStreamingRef.current || docContext?.status === 'extracting') return;
setRagState(s => ({ ...s, inputDraft: '' }));
// Show document context badge in user message bubble when active
const docPrefix = docContext?.status === 'ready'
? `📄 ${docContext.filename}\n`
: '';
const displayQuery = docPrefix + q;
const userMsgId = Date.now().toString();
const assistantId = (Date.now() + 1).toString();
@@ -112,7 +224,7 @@ export function RagChatPage() {
...s,
messages: [
...s.messages,
{ id: userMsgId, role: 'user', text: q },
{ id: userMsgId, role: 'user', text: displayQuery },
{ id: assistantId, role: 'assistant', text: '' },
],
citations: [],
@@ -122,12 +234,122 @@ export function RagChatPage() {
setStreaming(true);
setHighlightedCit(null);
// P0-1: reset thinking panel for new query
if (agenticMode) {
setThinkingSteps([]);
setThinkingExpanded(true);
}
const ctrl = new AbortController();
ragAbortRef.current = ctrl;
if (agenticMode) {
// ── Agentic path ────────────────────────────────────────────────────
const newCitations: RagCitation[] = [];
const handleMessage = (msg: SSEMessage) => {
if (msg.type === 'session') {
if (msg.session_id) setRagState(s => ({ ...s, sessionId: msg.session_id! }));
} else if (msg.type === 'thinking') {
// Build a stable step id so we can upsert running→done transitions.
const stepId = `${msg.step}-${msg.retry ? 'retry' : (msg.index ?? 0)}`;
setThinkingSteps(prev => {
const idx = prev.findIndex(s => s.id === stepId);
const stepObj: ThinkingStep = {
id: stepId,
step: msg.step ?? '',
status: (msg.status as 'running' | 'done') ?? 'running',
intent_type: msg.intent_type,
reason: msg.reason,
sub_queries: msg.sub_queries,
query: msg.query,
index: msg.index,
total: msg.total,
found: msg.found,
sufficient: msg.sufficient,
confidence: msg.confidence,
retry: msg.retry,
};
if (idx >= 0) {
const updated = [...prev];
updated[idx] = stepObj;
return updated;
}
return [...prev, stepObj];
});
} else if (msg.type === 'retrieved' && Array.isArray(msg.docs)) {
const mapped = (msg.docs as unknown as Record<string, unknown>[]).map((d, i) => mapSource(d, i + 1));
newCitations.push(...mapped);
setRagState(s => ({ ...s, citations: [...mapped] }));
} else if (msg.type === 'chunk' && msg.text) {
setRagState(s => ({
...s,
messages: s.messages.map(m =>
m.id === assistantId ? { ...m, text: m.text + msg.text! } : m
),
}));
} else if (msg.type === 'done') {
setThinkingExpanded(false);
setRagState(s => ({
...s,
messages: s.messages.map(m => {
if (m.id !== assistantId) return m;
const refs = [...new Set(
[...m.text.matchAll(/\[(\d+)\]/g)].map(r => parseInt(r[1], 10))
)].filter(n => n >= 1 && n <= newCitations.length);
return { ...m, citationRefs: refs };
}),
}));
} else if (msg.type === 'error') {
setRagState(s => ({
...s,
messages: s.messages.map(m =>
m.id === assistantId ? { ...m, text: `Error: ${msg.text ?? 'Unknown error'}` } : m
),
}));
}
};
try {
await agenticChat(
q, 5, handleMessage,
(err) => {
setRagState(s => ({
...s,
messages: s.messages.map(m =>
m.id === assistantId ? { ...m, text: t.ragchat.apiError } : m
),
}));
console.error('agenticChat error:', err);
},
undefined,
undefined,
sessionId ?? undefined,
ctrl.signal,
// Pass document context to agentic pipeline
docContext?.status === 'ready' ? docContext.text : undefined,
docContext?.status === 'ready' ? docContext.filename : undefined,
);
} finally {
ragStreamingRef.current = false;
setStreaming(false);
}
} else {
// ── Standard RAG path (unchanged) ───────────────────────────────────
try {
const body: Record<string, unknown> = { query: q, top_k: 5 };
if (sessionId) body.session_id = sessionId;
// Inject document text as conversation context when a file is attached
if (docContext?.status === 'ready') {
body.context_text = docContext.text;
body.context_filename = docContext.filename;
}
const res = await fetch('/api/v1/rag/chat', {
method: 'POST',
@@ -218,6 +440,7 @@ export function RagChatPage() {
setStreaming(false);
}
}
}
const lastAssistantId = [...messages].reverse().find(m => m.role === 'assistant')?.id;
@@ -254,7 +477,126 @@ export function RagChatPage() {
{/* ── Chat main ── */}
<div className="chat-main">
<div className="messages">
{/* P0-1: Agentic Thinking Panel — shown when agentic mode is active */}
{agenticMode && thinkingSteps.length > 0 && (
<div style={{
margin: '0 0 4px 0',
border: '1px solid var(--border)',
borderRadius: 8,
background: streaming ? 'var(--surface)' : 'var(--surface-2, var(--surface))',
overflow: 'hidden',
transition: 'max-height 0.4s ease',
flexShrink: 0,
}}>
{/* Panel header — clickable to collapse/expand */}
<button
onClick={() => setThinkingExpanded(x => !x)}
style={{
width: '100%',
display: 'flex',
alignItems: 'center',
gap: 6,
padding: '6px 12px',
background: 'none',
border: 'none',
cursor: 'pointer',
fontSize: 12,
color: streaming ? 'var(--accent, #6366f1)' : 'var(--success-fg, #16a34a)',
textAlign: 'left',
}}
>
<span>{streaming ? '⚙' : '✓'}</span>
<span style={{ fontWeight: 600 }}>
{streaming
? t.ragchat.agentThinking
: `${t.ragchat.agentDone} · ${thinkingSteps.filter(s => s.status === 'done').length} ${t.ragchat.stepSuffix}`
}
</span>
<span style={{ marginLeft: 'auto', fontSize: 10 }}>{thinkingExpanded ? '▲' : '▼'}</span>
</button>
{/* Step list */}
{thinkingExpanded && (
<div style={{ padding: '0 12px 8px' }}>
{thinkingSteps.map(step => {
const stepLabels: Record<string, string> = {
intent_analysis: t.ragchat.stepIntentAnalysis,
query_planning: t.ragchat.stepQueryPlanning,
retrieving: t.ragchat.stepRetrieving,
grounding_check: t.ragchat.stepGrounding,
};
const intentLabels: Record<string, string> = {
simple_qa: t.ragchat.intentSimpleQa,
compare: t.ragchat.intentCompare,
multi_hop: t.ragchat.intentMultiHop,
ambiguous: t.ragchat.intentAmbiguous,
};
return (
<div key={step.id} style={{
display: 'flex',
alignItems: 'flex-start',
gap: 6,
fontSize: 12,
padding: '3px 0',
color: step.status === 'done' ? 'var(--fg)' : 'var(--muted)',
}}>
<span style={{ width: 16, textAlign: 'center', flexShrink: 0 }}>
{step.status === 'running'
? <span style={{ animation: 'spin 1s linear infinite', display: 'inline-block' }}></span>
: (STEP_ICONS[step.step] ?? '·')
}
</span>
<span>
<strong>{stepLabels[step.step] ?? step.step}</strong>
{/* Intent analysis detail */}
{step.step === 'intent_analysis' && step.status === 'done' && step.intent_type && (
<span style={{ marginLeft: 6, color: 'var(--muted)' }}>
{intentLabels[step.intent_type] ?? step.intent_type}
{step.requires_decomposition && ` · ${t.ragchat.intentNeedsDecomposition}`}
</span>
)}
{/* Query planning detail */}
{step.step === 'query_planning' && step.status === 'done' && step.sub_queries && (
<span style={{ marginLeft: 6, color: 'var(--muted)' }}>
{step.sub_queries.length} {t.ragchat.subQueriesCountSuffix}
</span>
)}
{/* Retrieval detail */}
{step.step === 'retrieving' && (
<span style={{ marginLeft: 6, color: 'var(--muted)', wordBreak: 'break-all' }}>
{step.total && step.total > 1 && `[${step.index}/${step.total}] `}
{step.retry && t.ragchat.retryLabel}
{step.query && step.query.length > 50
? step.query.slice(0, 50) + '…'
: step.query}
{step.status === 'done' && step.found !== undefined && (
<span style={{ color: step.found > 0 ? 'var(--success-fg, #16a34a)' : 'var(--warning, #ca8a04)' }}>
{' '}· {step.found} {t.ragchat.chunksFoundSuffix}
</span>
)}
</span>
)}
{/* Grounding check detail */}
{step.step === 'grounding_check' && step.status === 'done' && (
<span style={{ marginLeft: 6, color: step.sufficient ? 'var(--success-fg, #16a34a)' : 'var(--warning, #ca8a04)' }}>
{step.sufficient ? t.ragchat.groundingSufficient : t.ragchat.groundingInsufficient}
{step.confidence !== undefined && ` (${Math.round(step.confidence * 100)}%)`}
</span>
)}
</span>
</div>
);
})}
</div>
)}
</div>
)}
{/* Messages area — accepts drag-and-drop document context attachment */}
<div
className="messages"
onDragOver={e => { e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; }}
onDrop={handleFileDrop}
>
{messages.map(msg => (
<div key={msg.id} className={`message msg-${msg.role}`}>
{msg.role === 'assistant' && <div className="msg-avatar">AI</div>}
@@ -281,19 +623,130 @@ export function RagChatPage() {
</button>
))}
</div>
{/* P0-1: Agentic mode toggle */}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
<label style={{
display: 'flex', alignItems: 'center', gap: 5,
fontSize: 12, color: agenticMode ? 'var(--accent, #6366f1)' : 'var(--muted)',
cursor: 'pointer', userSelect: 'none',
}}>
<input
type="checkbox"
checked={agenticMode}
onChange={e => {
setAgenticMode(e.target.checked);
setThinkingSteps([]);
}}
style={{ cursor: 'pointer', accentColor: 'var(--accent, #6366f1)' }}
/>
<Zap size={11} />
<span>{t.ragchat.agenticMode}</span>
</label>
{agenticMode && (
<span style={{ fontSize: 11, color: 'var(--muted)' }}>
{t.ragchat.agenticModeHint}
</span>
)}
</div>
{/* ── Document context badge ── */}
{docContext && (
<div style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '6px 10px', marginBottom: 6,
background: docContext.status === 'error'
? 'rgba(220,38,38,0.06)'
: docContext.status === 'ready'
? 'rgba(34,197,94,0.06)'
: 'rgba(99,102,241,0.06)',
border: `1px solid ${
docContext.status === 'error' ? 'rgba(220,38,38,0.3)'
: docContext.status === 'ready' ? 'rgba(34,197,94,0.3)'
: 'rgba(99,102,241,0.3)'
}`,
borderRadius: 8, fontSize: 12,
}}>
{docContext.status === 'extracting' && (
<span style={{ animation: 'spin 1s linear infinite', display: 'inline-block', color: 'var(--accent,#6366f1)' }}></span>
)}
{docContext.status === 'ready' && <FileText size={13} color="#16a34a" />}
{docContext.status === 'error' && <AlertCircle size={13} color="#dc2626" />}
<span style={{
fontWeight: 600, fontSize: 11,
color: docContext.status === 'error' ? '#dc2626'
: docContext.status === 'ready' ? '#16a34a'
: 'var(--accent,#6366f1)',
flexShrink: 0,
}}>
{t.ragchat.attachContextBadge}
</span>
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', color: 'var(--fg)' }}
title={docContext.filename}>
{docContext.filename}
</span>
{docContext.status === 'ready' && (
<span style={{ fontSize: 10, color: 'var(--muted)', flexShrink: 0 }}>
{(docContext.charCount / 1000).toFixed(1)}k chars
{docContext.truncated ? ` · ${t.ragchat.attachTruncated}` : ''}
</span>
)}
{docContext.status === 'extracting' && (
<span style={{ fontSize: 11, color: 'var(--accent,#6366f1)', flexShrink: 0 }}>
{t.ragchat.attachExtracting}
</span>
)}
{docContext.status === 'error' && (
<span style={{ fontSize: 11, color: '#dc2626', flexShrink: 0 }} title={docContext.errorMsg}>
{t.ragchat.attachError}
</span>
)}
{/* Clear button */}
<button
onClick={() => setDocContext(null)}
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '2px 4px', color: 'var(--muted)', display: 'flex', alignItems: 'center', gap: 2, fontSize: 11, flexShrink: 0 }}
title={t.ragchat.attachClearLabel}
>
<X size={11} /> {t.ragchat.attachClearLabel}
</button>
</div>
)}
{/* Hidden file input */}
<input
ref={fileInputRef}
type="file"
accept={t.ragchat.attachAccept}
style={{ display: 'none' }}
onChange={handleFileInputChange}
/>
<div className="composer-row">
{/* Paperclip button — replaces attached doc when clicked again */}
<button
className="btn icon-btn"
onClick={() => fileInputRef.current?.click()}
disabled={streaming || docContext?.status === 'extracting'}
title={t.ragchat.attachBtn}
style={{ flexShrink: 0, padding: '8px', color: docContext?.status === 'ready' ? 'var(--accent, #6366f1)' : undefined }}
>
<Paperclip size={15} />
</button>
<textarea
className="composer-input"
placeholder={t.ragchat.inputPlaceholder}
value={inputDraft}
onChange={e => setRagState(s => ({ ...s, inputDraft: e.target.value }))}
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); } }}
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); void send(); } }}
rows={2}
/>
<button
className="btn primary"
onClick={() => send()}
disabled={!inputDraft.trim() || streaming}
onClick={() => void send()}
disabled={!inputDraft.trim() || streaming || docContext?.status === 'extracting'}
>
<Send size={14} />
</button>
+189 -10
View File
@@ -1,8 +1,10 @@
import { useState, useEffect } from 'react';
import { Topbar } from '../../components/layout/Topbar';
import { Search, Upload, Download, RefreshCw, CheckCircle, XCircle, AlertTriangle, Info } from 'lucide-react';
import { Search, Upload, Download, RefreshCw, CheckCircle, XCircle, AlertTriangle, Info, Copy } from 'lucide-react';
import { UploadModal } from '../Docs/UploadModal';
import { useLanguage } from '../../contexts/LanguageContext';
import { getMCPStatus, getModelUsage, pingModelConnections } from '../../api/status';
import type { MCPStatusResponse, ModelUsageEntry } from '../../api/index';
const TOKEN_KEY = 'auth_token';
function authHeader(): Record<string, string> {
@@ -81,29 +83,47 @@ 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);
const [mcp, setMcp] = useState<MCPStatusResponse | null>(null);
const [mcpLoading, setMcpLoading] = useState(true);
const [copyState, setCopyState] = useState<'idle' | 'ok' | 'fail'>('idle');
useEffect(() => {
setLoading(true);
setHealthLoading(true);
setModelsLoading(true);
setMcpLoading(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(),
getMCPStatus(),
]).then(([statsRes, healthRes, configRes, modelsRes, mcpRes]) => {
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);
// A failing MCP endpoint must degrade to a muted card, never blank the page.
setMcp(mcpRes.status === 'fulfilled' ? mcpRes.value : null);
setLoading(false);
setHealthLoading(false);
setModelsLoading(false);
setMcpLoading(false);
setLastRefresh(new Date());
});
}, [refreshKey]);
@@ -128,7 +148,7 @@ export function StatusPage() {
// ── Export ───────────────────────────────────────────────────────────────
function handleExport() {
const data = { stats, health, config, exportedAt: new Date().toISOString() };
const data = { stats, health, config, mcp, exportedAt: new Date().toISOString() };
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
@@ -136,6 +156,66 @@ 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' });
}
/** Build the mcpServers block Claude Desktop / Cursor accept for a Streamable HTTP server. */
function buildMCPClientConfig(status: MCPStatusResponse): string {
const token = localStorage.getItem(TOKEN_KEY);
const server: Record<string, unknown> = { url: status.endpoint_url };
// Omit the header entirely when the backend runs unauthenticated, so the
// pasted config never carries a stale "Bearer null".
if (status.auth_required && token) server.headers = { Authorization: `Bearer ${token}` };
return JSON.stringify({ mcpServers: { 'ai-regulations': server } }, null, 2);
}
async function handleCopyMCPConfig() {
if (!mcp) return;
try {
await navigator.clipboard.writeText(buildMCPClientConfig(mcp));
setCopyState('ok');
} catch {
// clipboard.writeText rejects on insecure origins and denied permissions.
// Surface it: a silent no-op would leave the operator pasting stale data.
setCopyState('fail');
}
setTimeout(() => setCopyState('idle'), 2000);
}
function mcpDurationLabel(ms: number | null): string {
if (ms === null) return '—';
return ms >= 1000 ? `${(ms / 1000).toFixed(1)}s` : `${Math.round(ms)}ms`;
}
return (
<div className="status-page">
<Topbar
@@ -254,6 +334,111 @@ 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>
{/* MCP server — endpoint config + advertised tools + call counters */}
<div className="card">
<div className="card-header" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span>{t.status.cardMcp}</span>
<button className="btn sm" onClick={handleCopyMCPConfig} disabled={!mcp}>
<Copy size={13} />
{copyState === 'ok' ? t.status.mcpCopied : copyState === 'fail' ? t.status.mcpCopyFailed : t.status.mcpCopyConfig}
</button>
</div>
{mcpLoading ? (
<div style={{ padding: '12px 0', display: 'flex', flexDirection: 'column', gap: 10 }}>
{[1, 2].map(i => <div key={i} className="loading-shimmer" style={{ height: 28, borderRadius: 6 }} />)}
</div>
) : mcp ? (
<>
<div className="service-row">
<StatusIcon status="ok" />
<span className="service-name" style={{ marginLeft: 8 }}>{t.status.mcpEndpoint}</span>
<span style={{ fontSize: 11, color: 'var(--muted)', marginLeft: 6, fontFamily: 'var(--font-mono)', wordBreak: 'break-all' }}>
{mcp.endpoint_url}
</span>
<span className={`status ${mcp.auth_required ? 'ok' : 'warn'}`} style={{ marginLeft: 'auto' }}>
{mcp.auth_required ? t.status.mcpAuthRequired : t.status.mcpAuthDisabled}
</span>
</div>
<div className="service-row">
<StatusIcon status="info" />
<span className="service-name" style={{ marginLeft: 8 }}>{t.status.mcpAllowedHosts}</span>
<span style={{ fontSize: 11, color: 'var(--muted)', marginLeft: 6, fontFamily: 'var(--font-mono)', wordBreak: 'break-all' }}>
{mcp.allowed_hosts.join(', ') || '—'}
</span>
</div>
{mcp.tools.length === 0 ? (
<div style={{ padding: '12px 0', color: 'var(--muted)', fontSize: 13 }}>{t.status.mcpNoTools}</div>
) : mcp.tools.map(tool => (
<div className="service-row" key={tool.name}>
<StatusIcon status={tool.errors > 0 ? 'warn' : tool.calls > 0 ? 'ok' : 'info'} />
<span className="service-name" style={{ marginLeft: 8, fontFamily: 'var(--font-mono)' }}>{tool.name}</span>
<span style={{ fontSize: 11, color: 'var(--muted)', marginLeft: 6 }}>
{`${t.status.mcpCalls} ${tool.calls}`}
{tool.errors > 0 && ` · ${t.status.mcpErrors} ${tool.errors}`}
{` · ${t.status.mcpAvgDuration} ${mcpDurationLabel(tool.avg_duration_ms)}`}
</span>
<span style={{ marginLeft: 'auto', fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--muted)' }}>
{tool.last_called_at
? new Date(tool.last_called_at).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })
: t.status.lastCalledNever}
</span>
</div>
))}
</>
) : (
<div style={{ padding: '12px 0', color: 'var(--muted)', fontSize: 13 }}>{t.status.mcpUnavailable}</div>
)}
</div>
{/* System config (collapsible) */}
<div className="card">
<button
@@ -335,12 +520,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)' }}>

Some files were not shown because too many files have changed in this diff Show More