Compare commits
34
Commits
9212747e1b
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b2feaeddb4 | ||
|
|
31bbf80aeb | ||
|
|
73e79a610d | ||
|
|
49ee50c104 | ||
|
|
bd3dc38d1d | ||
|
|
e78c8a989f | ||
|
|
483689c1e8 | ||
|
|
6aaaff05f5 | ||
|
|
0907470a2d | ||
|
|
29f79d7434 | ||
|
|
5d132981ad | ||
|
|
4f6cc4812e | ||
|
|
2547d04b9d | ||
|
|
7adc050968 | ||
|
|
f2bd0deeb3 | ||
|
|
81a6d54fff | ||
|
|
beddc2d976 | ||
|
|
52e67b0e7b | ||
|
|
e3afb8a07a | ||
|
|
6a7fe48c4c | ||
|
|
0edbee07d5 | ||
|
|
2ce4c8a289 | ||
|
|
39a51c9e83 | ||
|
|
049da2297b | ||
|
|
d83286edd4 | ||
|
|
169911ab46 | ||
|
|
66fc388bfb | ||
|
|
41096369d3 | ||
|
|
4fea159f5b | ||
|
|
d460397dda | ||
|
|
37ea27fcbe | ||
|
|
74f327c85e | ||
|
|
4b451ef97c | ||
|
|
55ba922250 |
@@ -55,9 +55,16 @@ DOCUMENT_REPOSITORY_BACKEND=postgres
|
|||||||
USE_CELERY_WORKER=false
|
USE_CELERY_WORKER=false
|
||||||
|
|
||||||
# ===== 法规感知爬取配置 =====
|
# ===== 法规感知爬取配置 =====
|
||||||
|
# 单次 HTTP 请求超时(秒),含正文抓取(fetch_full_text)。
|
||||||
PERCEPTION_CRAWL_TIMEOUT_SECONDS=120
|
PERCEPTION_CRAWL_TIMEOUT_SECONDS=120
|
||||||
|
# 每个数据源单次爬取的最大条目数。
|
||||||
PERCEPTION_MAX_EVENTS_PER_SOURCE=100
|
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配置 =====
|
||||||
API_HOST=0.0.0.0
|
API_HOST=0.0.0.0
|
||||||
@@ -102,10 +109,10 @@ DOCUMENT_PARSE_ARTIFACT_PREFIX=artifacts
|
|||||||
PARSER_FAILURE_MODE=fail
|
PARSER_FAILURE_MODE=fail
|
||||||
|
|
||||||
# ===== Reranker 配置 =====
|
# ===== Reranker 配置 =====
|
||||||
RERANKER_ENABLED=true
|
RERANKER_ENABLED=false
|
||||||
RERANKER_BASE_URL=http://6.86.80.4:30080/v1
|
RERANKER_BASE_URL=http://6.86.80.4:30080/v1
|
||||||
RERANKER_MODEL=BAAI/bge-reranker-v2-m3
|
RERANKER_MODEL=BAAI/bge-reranker-v2-m3
|
||||||
RERANKER_API_KEY=
|
RERANKER_API_KEY=sk-fVr9KmDZNC4pGDBQj0EUWz9bDmFzNxjYC9EzZpe2bVDsxtz8
|
||||||
RERANKER_TOP_K=5
|
RERANKER_TOP_K=5
|
||||||
|
|
||||||
# ===== 会话持久化 =====
|
# ===== 会话持久化 =====
|
||||||
@@ -120,3 +127,23 @@ AUTH_ENABLED=true
|
|||||||
|
|
||||||
# ===== CORS =====
|
# ===== CORS =====
|
||||||
CORS_ALLOW_ORIGINS=http://localhost:5173
|
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
@@ -60,9 +60,16 @@ DOCUMENT_REPOSITORY_BACKEND=json
|
|||||||
USE_CELERY_WORKER=false
|
USE_CELERY_WORKER=false
|
||||||
|
|
||||||
# ===== 法规感知爬取配置 =====
|
# ===== 法规感知爬取配置 =====
|
||||||
|
# 单次 HTTP 请求超时(秒),含正文抓取(fetch_full_text)。
|
||||||
PERCEPTION_CRAWL_TIMEOUT_SECONDS=120
|
PERCEPTION_CRAWL_TIMEOUT_SECONDS=120
|
||||||
|
# 每个数据源单次爬取的最大条目数。
|
||||||
PERCEPTION_MAX_EVENTS_PER_SOURCE=100
|
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
|
ALIBABA_ACCESS_KEY_ID=your_aliyun_access_key_id
|
||||||
@@ -138,6 +145,47 @@ AUTH_TOKEN_EXPIRE_MINUTES=480
|
|||||||
# 设为 false 可跳过认证(仅限本地开发调试,生产必须 true)
|
# 设为 false 可跳过认证(仅限本地开发调试,生产必须 true)
|
||||||
AUTH_ENABLED=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 =====
|
||||||
# 逗号分隔的允许跨域来源列表,生产环境绝不能使用 *
|
# 逗号分隔的允许跨域来源列表,生产环境绝不能使用 *
|
||||||
CORS_ALLOW_ORIGINS=http://localhost:5173
|
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=
|
||||||
|
|
||||||
|
|||||||
+4
-1
@@ -61,4 +61,7 @@ Thumbs.db
|
|||||||
logs/
|
logs/
|
||||||
|
|
||||||
# codex
|
# codex
|
||||||
.agents
|
.agents
|
||||||
|
|
||||||
|
# personal local records (never commit)
|
||||||
|
local/
|
||||||
+30
-4
@@ -390,12 +390,38 @@ Demo-glm/
|
|||||||
| 下载文档 | `/api/v1/documents/download/{doc_id}` | GET | 下载原文PDF/DOCX |
|
| 下载文档 | `/api/v1/documents/download/{doc_id}` | GET | 下载原文PDF/DOCX |
|
||||||
| 文档列表 | `/api/v1/documents/list` | GET | 列出已上传文档 |
|
| 文档列表 | `/api/v1/documents/list` | GET | 列出已上传文档 |
|
||||||
| 检索知识 | `/api/v1/knowledge/search` | POST | 向量检索 |
|
| 检索知识 | `/api/v1/knowledge/search` | POST | 向量检索 |
|
||||||
| 单次问答 | `/api/v1/agent/ask` | POST | 智能问答 |
|
| 单次问答 | `/api/v1/agent/ask` | POST | 标准单轮问答 |
|
||||||
| 多轮对话 | `/api/v1/agent/chat` | 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}` | GET | 获取会话 |
|
||||||
| 删除会话 | `/api/v1/agent/session/{id}` | DELETE | 删除会话 |
|
| 删除会话 | `/api/v1/agent/session/{id}` | DELETE | 删除会话 |
|
||||||
| Prompt模板 | `/api/v1/agent/templates` | GET | 模板列表 |
|
| 会话历史 | `/api/v1/agent/session/{id}/history` | GET | 获取历史记录 |
|
||||||
| 可用模型 | `/api/v1/agent/models` | GET | LLM模型列表 |
|
| 会话列表 | `/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`(模糊)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+23
-8
@@ -1,6 +1,6 @@
|
|||||||
"""FastAPI application entrypoint."""
|
"""FastAPI application entrypoint."""
|
||||||
|
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import AsyncExitStack, asynccontextmanager
|
||||||
|
|
||||||
from fastapi import FastAPI, Request
|
from fastapi import FastAPI, Request
|
||||||
from fastapi.encoders import jsonable_encoder
|
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.api.routes import api_router
|
||||||
from app.config.logging import setup_logging
|
from app.config.logging import setup_logging
|
||||||
from app.config.settings import settings
|
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.bootstrap import cleanup_runtime_dependencies, preload_runtime_dependencies
|
||||||
from app.shared.errors import VectorStoreSchemaError
|
from app.shared.errors import VectorStoreSchemaError
|
||||||
# Keep module behavior explicit so the backend flow stays easy to audit.
|
# Keep module behavior explicit so the backend flow stays easy to audit.
|
||||||
@@ -20,19 +21,32 @@ from app.shared.errors import VectorStoreSchemaError
|
|||||||
|
|
||||||
setup_logging(level="INFO" if not settings.debug else "DEBUG")
|
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
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
"""Application lifecycle hooks."""
|
"""Application lifecycle hooks."""
|
||||||
logger.info(f"启动 {settings.app_name} v{settings.app_version}")
|
# FastMCP-style servers own a session manager that only starts via its own
|
||||||
logger.info(f"调试模式: {settings.debug}")
|
# lifespan context. app.mount() does NOT propagate nested ASGI lifespans
|
||||||
logger.info("预加载LLM客户端...")
|
# automatically (confirmed Starlette/ASGI limitation) — without this,
|
||||||
preload_runtime_dependencies()
|
# 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))
|
||||||
|
|
||||||
yield
|
logger.info(f"启动 {settings.app_name} v{settings.app_version}")
|
||||||
|
logger.info(f"调试模式: {settings.debug}")
|
||||||
|
logger.info("预加载LLM客户端...")
|
||||||
|
preload_runtime_dependencies()
|
||||||
|
|
||||||
logger.info("应用关闭,执行清理...")
|
yield
|
||||||
cleanup_runtime_dependencies()
|
|
||||||
|
logger.info("应用关闭,执行清理...")
|
||||||
|
cleanup_runtime_dependencies()
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
@@ -65,6 +79,7 @@ app.add_middleware(
|
|||||||
app.add_middleware(AuditMiddleware)
|
app.add_middleware(AuditMiddleware)
|
||||||
|
|
||||||
app.include_router(api_router, prefix="/api/v1")
|
app.include_router(api_router, prefix="/api/v1")
|
||||||
|
app.mount("/mcp", mcp_app)
|
||||||
|
|
||||||
|
|
||||||
@app.exception_handler(VectorStoreSchemaError)
|
@app.exception_handler(VectorStoreSchemaError)
|
||||||
|
|||||||
@@ -42,6 +42,11 @@ class ChatRequest(BaseModel):
|
|||||||
provider: Optional[str] = None
|
provider: Optional[str] = None
|
||||||
model: Optional[str] = None
|
model: Optional[str] = None
|
||||||
top_k: Optional[int] = Field(default=None, ge=1, le=20)
|
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):
|
class ChatResponse(BaseModel):
|
||||||
|
|||||||
@@ -20,7 +20,11 @@ from app.api.models import (
|
|||||||
)
|
)
|
||||||
from app.config.settings import settings
|
from app.config.settings import settings
|
||||||
from app.shared.async_utils import iter_in_thread
|
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.
|
# 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}
|
return {"message": "反馈已提交", "session_id": result.session_id, "message_index": result.message_index}
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(status_code=404, detail=str(exc))
|
raise HTTPException(status_code=404, detail=str(exc))
|
||||||
|
|
||||||
|
|
||||||
|
# ── P0-1: Agentic RAG endpoint ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.post("/agentic/stream")
|
||||||
|
async def agentic_stream(request: ChatRequest):
|
||||||
|
"""Stream an Agentic RAG response with live multi-step reasoning trace.
|
||||||
|
|
||||||
|
Unlike the standard ``/chat/stream`` endpoint this route runs a full pipeline:
|
||||||
|
intent analysis → query planning → iterative retrieval → grounding check →
|
||||||
|
answer generation.
|
||||||
|
|
||||||
|
Extra SSE event types beyond the standard ones:
|
||||||
|
|
||||||
|
* ``thinking`` — reasoning sub-step progress; data is a JSON object with
|
||||||
|
``step`` (intent_analysis / query_planning / retrieving / grounding_check),
|
||||||
|
``status`` (running / done), and step-specific fields.
|
||||||
|
|
||||||
|
The ``sources``, ``content``, and ``done`` events are identical to the standard
|
||||||
|
chat-stream contract so the existing frontend parser can handle them without
|
||||||
|
changes.
|
||||||
|
"""
|
||||||
|
async def generate_sse() -> AsyncGenerator[str, None]:
|
||||||
|
"""Handle SSE generation for the agentic chat endpoint."""
|
||||||
|
try:
|
||||||
|
session_id_, event_stream = get_agentic_conversation_service().stream_agentic_chat(
|
||||||
|
query=request.query,
|
||||||
|
session_id=request.session_id,
|
||||||
|
filters=request.filters,
|
||||||
|
provider=request.provider or settings.llm_provider,
|
||||||
|
model=request.model or settings.llm_model,
|
||||||
|
top_k=request.top_k or settings.rag_top_k,
|
||||||
|
context_text=request.context_text,
|
||||||
|
context_filename=request.context_filename,
|
||||||
|
)
|
||||||
|
yield f"event: session\ndata: {json.dumps({'session_id': session_id_})}\n\n"
|
||||||
|
async for event_data in iter_in_thread(event_stream):
|
||||||
|
event_type = event_data.get("event", "content")
|
||||||
|
data = event_data.get("data", "")
|
||||||
|
if isinstance(data, (dict, list)):
|
||||||
|
yield f"event: {event_type}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
|
||||||
|
else:
|
||||||
|
yield f"event: {event_type}\ndata: {data}\n\n"
|
||||||
|
except Exception as exc:
|
||||||
|
yield f"event: error\ndata: {str(exc)}\n\n"
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
generate_sse(),
|
||||||
|
media_type="text/event-stream",
|
||||||
|
headers={
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
"Connection": "keep-alive",
|
||||||
|
"X-Accel-Buffering": "no",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|||||||
@@ -85,9 +85,10 @@ async def analyze_stream(
|
|||||||
Events: stage | source | finding | done | error
|
Events: stage | source | finding | done | error
|
||||||
"""
|
"""
|
||||||
from app.application.compliance.pipeline import (
|
from app.application.compliance.pipeline import (
|
||||||
|
detect_cross_clause_conflicts,
|
||||||
extract_text_from_doc_id,
|
extract_text_from_doc_id,
|
||||||
extract_text_from_file,
|
extract_text_from_file,
|
||||||
run_clauses_parallel,
|
run_clauses_streaming,
|
||||||
split_into_clauses,
|
split_into_clauses,
|
||||||
synthesize_conclusion,
|
synthesize_conclusion,
|
||||||
)
|
)
|
||||||
@@ -135,23 +136,27 @@ async def analyze_stream(
|
|||||||
await asyncio.sleep(0)
|
await asyncio.sleep(0)
|
||||||
clauses: list[str] = await asyncio.to_thread(split_into_clauses, para_text, client)
|
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] = []
|
findings: list[dict] = []
|
||||||
|
total_clauses = len(clauses)
|
||||||
|
|
||||||
yield _sse({
|
yield _sse({
|
||||||
"type": "stage",
|
"type": "stage",
|
||||||
"stage": "analyzing",
|
"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)
|
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,
|
clauses, retrieval_service, client,
|
||||||
top_k=5,
|
top_k=5,
|
||||||
domains=domains or None,
|
domains=domains or None,
|
||||||
)
|
):
|
||||||
|
done_count += 1
|
||||||
for res in clause_results:
|
|
||||||
i = res["index"]
|
i = res["index"]
|
||||||
chunks = res["chunks"]
|
chunks = res["chunks"]
|
||||||
finding = res["finding"]
|
finding = res["finding"]
|
||||||
@@ -165,14 +170,25 @@ async def analyze_stream(
|
|||||||
"score": round(float(getattr(chunk, "score", 0)), 3),
|
"score": round(float(getattr(chunk, "score", 0)), 3),
|
||||||
"status": "retrieved",
|
"status": "retrieved",
|
||||||
"full_content": (getattr(chunk, "text", "") or "")[:300],
|
"full_content": (getattr(chunk, "text", "") or "")[:300],
|
||||||
|
"clause_index": i,
|
||||||
})
|
})
|
||||||
|
|
||||||
if finding:
|
if finding:
|
||||||
findings.append(finding)
|
findings.append(finding)
|
||||||
yield _sse({"type": "finding", **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)
|
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 ────────────────────────────
|
# ── Stage 4: synthesize conclusion ────────────────────────────
|
||||||
yield _sse({"type": "stage", "stage": "concluding", "label": "Generating conclusion…"})
|
yield _sse({"type": "stage", "stage": "concluding", "label": "Generating conclusion…"})
|
||||||
await asyncio.sleep(0)
|
await asyncio.sleep(0)
|
||||||
|
|||||||
@@ -241,6 +241,9 @@ async def get_document_management_list():
|
|||||||
"updated_at": item.updated_at.isoformat(),
|
"updated_at": item.updated_at.isoformat(),
|
||||||
"regulation_type": item.regulation_type,
|
"regulation_type": item.regulation_type,
|
||||||
"version": item.version,
|
"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
|
for item in documents
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -7,7 +7,12 @@ import json
|
|||||||
from fastapi import APIRouter, Depends, Query
|
from fastapi import APIRouter, Depends, Query
|
||||||
from fastapi.responses import StreamingResponse
|
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.api.dependencies.auth import get_current_user
|
||||||
from app.domain.auth.models import UserClaims
|
from app.domain.auth.models import UserClaims
|
||||||
from app.shared.async_utils import iter_in_thread
|
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"),
|
"previous_hash": event.get("previous_hash"),
|
||||||
"content_hash": event.get("content_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}
|
||||||
|
|||||||
@@ -3,10 +3,14 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
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 fastapi.responses import StreamingResponse
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
from app.api.dependencies.auth import get_current_user
|
from app.api.dependencies.auth import get_current_user
|
||||||
from app.config.settings import settings
|
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.async_utils import iter_in_thread
|
||||||
from app.shared.bootstrap import get_agent_conversation_service
|
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问答"])
|
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")
|
@router.post("/chat")
|
||||||
async def rag_chat(
|
async def rag_chat(
|
||||||
request: RagChatRequest,
|
request: RagChatRequest,
|
||||||
current_user: UserClaims = Depends(get_current_user),
|
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(
|
session_id, event_stream = get_agent_conversation_service().stream_chat(
|
||||||
query=request.query,
|
query=request.query,
|
||||||
session_id=request.session_id,
|
session_id=request.session_id,
|
||||||
filters=request.filters,
|
filters=request.filters,
|
||||||
top_k=request.top_k or settings.rag_top_k,
|
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]:
|
async def generate() -> AsyncGenerator[str, None]:
|
||||||
|
|||||||
@@ -1,18 +1,25 @@
|
|||||||
"""Define API routes for status."""
|
"""Define API routes for status."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import time
|
import time
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter, Request
|
||||||
|
|
||||||
from app.config.settings import settings
|
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 (
|
from app.shared.bootstrap import (
|
||||||
get_bm25_retriever,
|
get_bm25_retriever,
|
||||||
get_binary_store,
|
get_binary_store,
|
||||||
get_conversation_store,
|
get_conversation_store,
|
||||||
get_document_query_service,
|
get_document_query_service,
|
||||||
|
get_embedding_provider,
|
||||||
|
get_reranker,
|
||||||
get_vector_index,
|
get_vector_index,
|
||||||
)
|
)
|
||||||
|
from app.shared.model_usage_tracker import get_model_usage_tracker
|
||||||
|
|
||||||
router = APIRouter(prefix="/status", tags=["系统状态"])
|
router = APIRouter(prefix="/status", tags=["系统状态"])
|
||||||
|
|
||||||
@@ -23,6 +30,16 @@ _stats_cache: dict[str, Any] = {}
|
|||||||
_stats_cache_time: float = 0.0
|
_stats_cache_time: float = 0.0
|
||||||
_STATS_TTL_SECONDS: float = 10.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")
|
@router.get("/stats")
|
||||||
async def get_stats():
|
async def get_stats():
|
||||||
@@ -111,3 +128,170 @@ async def get_health():
|
|||||||
"max": settings.session_max_sessions,
|
"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)
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
"""Initialize the app.application.agent package."""
|
"""Initialize the app.application.agent package."""
|
||||||
|
|
||||||
from .services import AgentConversationService, AgentSessionFeedbackResult, AgentSessionService
|
from .services import AgentConversationService, AgentSessionFeedbackResult, AgentSessionService
|
||||||
|
from .agentic_service import AgenticConversationService
|
||||||
# Keep package boundaries explicit so backend imports stay predictable.
|
# 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
|
||||||
@@ -9,6 +9,7 @@ from app.domain.conversation import AnswerGenerator, AnswerResult, ConversationS
|
|||||||
from app.domain.retrieval import RetrievedChunk
|
from app.domain.retrieval import RetrievedChunk
|
||||||
|
|
||||||
from app.application.knowledge import KnowledgeRetrievalService
|
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.
|
# Keep orchestration logic centralized so use-case flow stays easy to trace.
|
||||||
|
|
||||||
|
|
||||||
@@ -26,6 +27,8 @@ class AgentConversationService:
|
|||||||
self.retrieval_service = retrieval_service
|
self.retrieval_service = retrieval_service
|
||||||
self.answer_generator = answer_generator
|
self.answer_generator = answer_generator
|
||||||
self.conversation_store = conversation_store
|
self.conversation_store = conversation_store
|
||||||
|
# Shared HyDE expander — stateless, safe for reuse across requests.
|
||||||
|
self._hyde = HyDEExpander()
|
||||||
|
|
||||||
def ask(
|
def ask(
|
||||||
self,
|
self,
|
||||||
@@ -108,14 +111,26 @@ class AgentConversationService:
|
|||||||
model: str | None = None,
|
model: str | None = None,
|
||||||
top_k: int = 5,
|
top_k: int = 5,
|
||||||
prompt_template: str | None = None,
|
prompt_template: str | None = None,
|
||||||
|
context_text: str | None = None,
|
||||||
|
context_filename: str | None = None,
|
||||||
) -> tuple[str, Generator[dict, 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
|
session = self.conversation_store.get_session(session_id) if session_id else None
|
||||||
if session is None:
|
if session is None:
|
||||||
session = self.conversation_store.create_session()
|
session = self.conversation_store.create_session()
|
||||||
self.conversation_store.save_message(session.session_id, role="user", content=query)
|
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:]]
|
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]:
|
def event_stream() -> Generator[dict, None, None]:
|
||||||
"""Handle event stream for the Agent Conversation Service instance."""
|
"""Handle event stream for the Agent Conversation Service instance."""
|
||||||
@@ -129,6 +144,8 @@ class AgentConversationService:
|
|||||||
provider=provider,
|
provider=provider,
|
||||||
model=model,
|
model=model,
|
||||||
prompt_template=prompt_template,
|
prompt_template=prompt_template,
|
||||||
|
context_text=context_text,
|
||||||
|
context_filename=context_filename,
|
||||||
):
|
):
|
||||||
if event.get("event") == "sources":
|
if event.get("event") == "sources":
|
||||||
sources_payload = event.get("data", [])
|
sources_payload = event.get("data", [])
|
||||||
@@ -189,3 +206,4 @@ class AgentSessionService:
|
|||||||
raise ValueError("消息索引不存在")
|
raise ValueError("消息索引不存在")
|
||||||
# Preserve the existing API behavior until a persistent feedback store is introduced.
|
# Preserve the existing API behavior until a persistent feedback store is introduced.
|
||||||
return AgentSessionFeedbackResult(session_id=session_id, message_index=message_index)
|
return AgentSessionFeedbackResult(session_id=session_id, message_index=message_index)
|
||||||
|
|
||||||
|
|||||||
@@ -51,19 +51,36 @@ def _extract_json(text: str):
|
|||||||
|
|
||||||
|
|
||||||
def extract_text_from_doc_id(doc_id: str) -> str:
|
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
|
from app.shared.bootstrap import get_document_query_service, get_retrieval_service
|
||||||
doc = get_document_query_service().get(doc_id)
|
doc = get_document_query_service().get(doc_id)
|
||||||
if not doc:
|
if not doc:
|
||||||
raise ValueError(f"Document '{doc_id}' not found")
|
raise ValueError(f"Document '{doc_id}' not found")
|
||||||
service = get_retrieval_service()
|
service = get_retrieval_service()
|
||||||
chunks = service.retrieve(query=doc.doc_name, top_k=30)
|
# Use doc_name as a broad query, filter strictly by doc_id so we only get
|
||||||
doc_chunks = [c for c in chunks if c.doc_id == doc_id]
|
# 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:
|
if not doc_chunks:
|
||||||
doc_chunks = chunks[:15]
|
# Fallback: use top results even without doc_id match (e.g., legacy store)
|
||||||
return "\n\n".join(c.text for c in doc_chunks[:15])
|
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:
|
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
|
from app.shared.bootstrap import get_document_command_service
|
||||||
suffix = os.path.splitext(filename or "doc.pdf")[1] or ".pdf"
|
suffix = os.path.splitext(filename or "doc.pdf")[1] or ".pdf"
|
||||||
tmp_path = ""
|
tmp_path = ""
|
||||||
@@ -74,10 +91,11 @@ def extract_text_from_file(content: bytes, filename: str) -> str:
|
|||||||
service = get_document_command_service()
|
service = get_document_command_service()
|
||||||
parsed = service.parser.parse(file_path=tmp_path, doc_id="tmp_analysis", doc_name=filename)
|
parsed = service.parser.parse(file_path=tmp_path, doc_id="tmp_analysis", doc_name=filename)
|
||||||
if parsed.raw_text:
|
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(
|
return "\n".join(
|
||||||
b.get("text", "") for b in parsed.semantic_blocks[:30] if b.get("text")
|
b.get("text", "") for b in parsed.semantic_blocks if b.get("text")
|
||||||
)[:4000]
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("File text extraction failed: {}", exc)
|
logger.warning("File text extraction failed: {}", exc)
|
||||||
return ""
|
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]:
|
def split_into_clauses(text: str, client: "BaseLLMClient") -> list[str]:
|
||||||
prompt = (
|
"""Split a compliance document into semantically independent clauses.
|
||||||
"You are a compliance analysis expert. Split the following text into 3-8 "
|
|
||||||
"semantically complete compliance clauses. Each clause should be an independent "
|
For long texts (> 2 000 chars) the document is processed in overlapping
|
||||||
"compliance requirement or technical statement.\n"
|
2 000-char windows so no content is missed. Each window produces up to 4
|
||||||
"Return as JSON array of strings, e.g.:\n"
|
clauses; results are deduplicated and capped at 12 total to keep analysis
|
||||||
'["Clause one...", "Clause two..."]\n'
|
latency reasonable.
|
||||||
"Return ONLY the JSON array.\n\n"
|
"""
|
||||||
f"Text:\n{text[:2000]}"
|
# Window size and step for sliding-window clause extraction
|
||||||
)
|
_WINDOW = 2000
|
||||||
response = client.chat([{"role": "user", "content": prompt}], max_tokens=1000)
|
_STEP = 1800 # 200-char overlap to avoid cutting clauses at boundaries
|
||||||
if response.is_success:
|
_MAX_CLAUSES = 12
|
||||||
try:
|
|
||||||
result = _extract_json(response.content)
|
windows = []
|
||||||
if isinstance(result, list):
|
if len(text) <= _WINDOW:
|
||||||
clauses = [str(c).strip() for c in result if str(c).strip()]
|
windows = [text]
|
||||||
if clauses:
|
else:
|
||||||
return clauses[:8]
|
pos = 0
|
||||||
except (ValueError, TypeError):
|
while pos < len(text):
|
||||||
logger.warning("Clause split JSON parse failed, using fallback")
|
windows.append(text[pos: pos + _WINDOW])
|
||||||
sentences = re.split(r"[.?!;\n]+", text)
|
pos += _STEP
|
||||||
return [s.strip() for s in sentences if len(s.strip()) > 20][:6]
|
|
||||||
|
all_clauses: list[str] = []
|
||||||
|
for window in windows:
|
||||||
|
prompt = (
|
||||||
|
"You are a compliance analysis expert. Split the following text into "
|
||||||
|
"3-4 semantically complete compliance clauses. Each clause must be an "
|
||||||
|
"independent requirement or technical statement. Omit section headings, "
|
||||||
|
"definitions, and non-normative text.\n"
|
||||||
|
"Return as JSON array of strings, e.g.:\n"
|
||||||
|
'["Clause one...", "Clause two..."]\n'
|
||||||
|
"Return ONLY the JSON array.\n\n"
|
||||||
|
f"Text:\n{window}"
|
||||||
|
)
|
||||||
|
response = client.chat([{"role": "user", "content": prompt}], max_tokens=800)
|
||||||
|
if response.is_success:
|
||||||
|
try:
|
||||||
|
result = _extract_json(response.content)
|
||||||
|
if isinstance(result, list):
|
||||||
|
clauses = [str(c).strip() for c in result if str(c).strip()]
|
||||||
|
all_clauses.extend(clauses[:4])
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
logger.warning("Clause split JSON parse failed for window, using sentence fallback")
|
||||||
|
sentences = re.split(r"[.?!;\n]+", window)
|
||||||
|
all_clauses.extend(s.strip() for s in sentences if len(s.strip()) > 20)
|
||||||
|
else:
|
||||||
|
# LLM unavailable — fall back to sentence splitting for this window
|
||||||
|
sentences = re.split(r"[.?!;\n]+", window)
|
||||||
|
all_clauses.extend(s.strip() for s in sentences if len(s.strip()) > 20)
|
||||||
|
|
||||||
|
if len(all_clauses) >= _MAX_CLAUSES:
|
||||||
|
break
|
||||||
|
|
||||||
|
# Deduplicate near-duplicates (same first 80 chars) that span window boundaries
|
||||||
|
seen: set[str] = set()
|
||||||
|
deduped: list[str] = []
|
||||||
|
for c in all_clauses:
|
||||||
|
key = c[:80].lower()
|
||||||
|
if key not in seen:
|
||||||
|
seen.add(key)
|
||||||
|
deduped.append(c)
|
||||||
|
|
||||||
|
return deduped[:_MAX_CLAUSES]
|
||||||
|
|
||||||
|
|
||||||
def retrieve_for_clause(
|
def retrieve_for_clause(
|
||||||
@@ -117,7 +176,33 @@ def retrieve_for_clause(
|
|||||||
top_k: int = 5,
|
top_k: int = 5,
|
||||||
domains: str | None = None,
|
domains: str | None = None,
|
||||||
) -> list["RetrievedChunk"]:
|
) -> 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(
|
def process_single_clause(
|
||||||
@@ -130,14 +215,75 @@ def process_single_clause(
|
|||||||
) -> dict:
|
) -> dict:
|
||||||
"""Process one clause: retrieve relevant regulations then check compliance.
|
"""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.
|
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)
|
chunks = retrieve_for_clause(clause, retrieval_service, top_k, domains)
|
||||||
finding = check_clause_compliance(clause, chunks, client)
|
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}
|
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(
|
async def run_clauses_parallel(
|
||||||
clauses: list[str],
|
clauses: list[str],
|
||||||
retrieval_service: "KnowledgeRetrievalService",
|
retrieval_service: "KnowledgeRetrievalService",
|
||||||
@@ -145,31 +291,15 @@ async def run_clauses_parallel(
|
|||||||
top_k: int = 5,
|
top_k: int = 5,
|
||||||
domains: str | None = None,
|
domains: str | None = None,
|
||||||
) -> list[dict]:
|
) -> 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
|
Collects all streaming results and returns them sorted by clause index.
|
||||||
is concurrent. Exceptions in individual clauses are caught and returned as
|
New code should use run_clauses_streaming() directly.
|
||||||
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.
|
|
||||||
"""
|
"""
|
||||||
tasks = [
|
results: list[dict] = []
|
||||||
asyncio.to_thread(
|
async for result in run_clauses_streaming(clauses, retrieval_service, client, top_k, domains):
|
||||||
process_single_clause,
|
results.append(result)
|
||||||
clause, i, retrieval_service, client, top_k, domains,
|
return sorted(results, key=lambda r: r["index"])
|
||||||
)
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
def check_clause_compliance(
|
def check_clause_compliance(
|
||||||
@@ -177,6 +307,15 @@ def check_clause_compliance(
|
|||||||
chunks: list["RetrievedChunk"],
|
chunks: list["RetrievedChunk"],
|
||||||
client: "BaseLLMClient",
|
client: "BaseLLMClient",
|
||||||
) -> dict | None:
|
) -> 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(
|
reg_context = "\n".join(
|
||||||
f"[{i+1}] {c.doc_title} {c.section_title or ''}: {c.text[:300]}"
|
f"[{i+1}] {c.doc_title} {c.section_title or ''}: {c.text[:300]}"
|
||||||
for i, c in enumerate(chunks[:5])
|
for i, c in enumerate(chunks[:5])
|
||||||
@@ -186,14 +325,17 @@ def check_clause_compliance(
|
|||||||
"complies with the retrieved regulations.\n\n"
|
"complies with the retrieved regulations.\n\n"
|
||||||
f"Business clause:\n{clause}\n\n"
|
f"Business clause:\n{clause}\n\n"
|
||||||
f"Retrieved regulations:\n{reg_context}\n\n"
|
f"Retrieved regulations:\n{reg_context}\n\n"
|
||||||
"Return JSON:\n"
|
"Return JSON with these exact fields:\n"
|
||||||
"{\n"
|
"{\n"
|
||||||
' "status": "ok" | "warn" | "risk",\n'
|
' "status": "ok" | "warn" | "risk",\n'
|
||||||
' "title": "Short finding title (max 30 chars)",\n'
|
' "title": "Short finding title (max 30 chars)",\n'
|
||||||
' "desc": "Description (50-120 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"
|
"}\n"
|
||||||
"status: ok=compliant, warn=gap exists, risk=critical/missing\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."
|
"Return ONLY the JSON object."
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -216,7 +358,10 @@ def check_clause_compliance(
|
|||||||
"title": str(result.get("title", "Compliance finding")),
|
"title": str(result.get("title", "Compliance finding")),
|
||||||
"desc": str(result.get("desc", "")),
|
"desc": str(result.get("desc", "")),
|
||||||
"status": result.get("status", "info"),
|
"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:
|
except (ValueError, TypeError) as exc:
|
||||||
logger.warning("Gap check JSON parse failed: {}", exc)
|
logger.warning("Gap check JSON parse failed: {}", exc)
|
||||||
@@ -368,3 +513,58 @@ def generate_suggestions(
|
|||||||
except (ValueError, TypeError) as exc:
|
except (ValueError, TypeError) as exc:
|
||||||
logger.warning("generate_suggestions JSON parse failed: {}", exc)
|
logger.warning("generate_suggestions JSON parse failed: {}", exc)
|
||||||
return fallback
|
return fallback
|
||||||
|
|
||||||
|
|
||||||
|
def detect_cross_clause_conflicts(
|
||||||
|
findings: list[dict],
|
||||||
|
client: "BaseLLMClient",
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Detect contradictions and missing cross-references across all findings.
|
||||||
|
|
||||||
|
Runs a single LLM call after all per-clause findings are collected.
|
||||||
|
Returns a list of conflict dicts: {type, finding_a, finding_b, desc}.
|
||||||
|
Returns an empty list on LLM failure so the caller can proceed without it.
|
||||||
|
"""
|
||||||
|
if len(findings) < 2:
|
||||||
|
# Need at least 2 findings to compare
|
||||||
|
return []
|
||||||
|
|
||||||
|
findings_text = "\n".join(
|
||||||
|
f"[{i+1}] [{f['status'].upper()}] {f['title']}: {f['desc']}"
|
||||||
|
+ (f" (Ref: {f['clause_ref']})" if f.get("clause_ref") else "")
|
||||||
|
for i, f in enumerate(findings)
|
||||||
|
)
|
||||||
|
prompt = (
|
||||||
|
"You are a compliance expert. Review the following compliance findings from the same document "
|
||||||
|
"and identify any cross-clause issues:\n\n"
|
||||||
|
f"Findings:\n{findings_text}\n\n"
|
||||||
|
"Return JSON array of conflicts (empty array [] if none found):\n"
|
||||||
|
"[\n"
|
||||||
|
" {\n"
|
||||||
|
' "type": "contradiction" | "missing_ref" | "cumulative_risk",\n'
|
||||||
|
' "finding_a": <1-based index>,\n'
|
||||||
|
' "finding_b": <1-based index or null>,\n'
|
||||||
|
' "desc": "Brief description of the cross-clause issue (max 100 chars)"\n'
|
||||||
|
" }\n"
|
||||||
|
"]\n"
|
||||||
|
"Return ONLY the JSON array."
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
response = client.chat([{"role": "user", "content": prompt}], max_tokens=600)
|
||||||
|
if not response.is_success:
|
||||||
|
return []
|
||||||
|
result = _extract_json(response.content)
|
||||||
|
if isinstance(result, list):
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"type": str(c.get("type", "contradiction")),
|
||||||
|
"finding_a": int(c.get("finding_a", 0)),
|
||||||
|
"finding_b": c.get("finding_b"),
|
||||||
|
"desc": str(c.get("desc", "")),
|
||||||
|
}
|
||||||
|
for c in result
|
||||||
|
if isinstance(c, dict)
|
||||||
|
]
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("detect_cross_clause_conflicts failed: {}", exc)
|
||||||
|
return []
|
||||||
|
|||||||
@@ -526,10 +526,28 @@ class DocumentCommandService:
|
|||||||
logger.warning("临时文件清理失败: {}", temp_path)
|
logger.warning("临时文件清理失败: {}", temp_path)
|
||||||
|
|
||||||
def delete(self, doc_id: str) -> bool:
|
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)
|
document = self.document_repository.get(doc_id)
|
||||||
if not document:
|
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
|
return False
|
||||||
|
|
||||||
|
# Normal doc: clean up binary, vectors, artifacts, processing records, metadata.
|
||||||
try:
|
try:
|
||||||
self.binary_store.delete(document.object_name)
|
self.binary_store.delete(document.object_name)
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -627,13 +645,16 @@ class DocumentQueryService:
|
|||||||
result.append(doc)
|
result.append(doc)
|
||||||
|
|
||||||
# Surface Milvus-only docs that have no metadata record at all.
|
# 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():
|
for doc_id, row in milvus_by_id.items():
|
||||||
if doc_id not in meta_by_id:
|
if doc_id not in meta_by_id:
|
||||||
synthetic = Document(
|
synthetic = Document(
|
||||||
doc_id=doc_id,
|
doc_id=doc_id,
|
||||||
doc_name=row.get("doc_title", doc_id),
|
doc_name=row.get("doc_title", doc_id),
|
||||||
file_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="",
|
content_type="",
|
||||||
size_bytes=0,
|
size_bytes=0,
|
||||||
status=DocumentStatus.INDEXED,
|
status=DocumentStatus.INDEXED,
|
||||||
@@ -646,9 +667,63 @@ class DocumentQueryService:
|
|||||||
result.sort(key=lambda d: d.updated_at, reverse=True)
|
result.sort(key=lambda d: d.updated_at, reverse=True)
|
||||||
return result[:limit] if limit is not None else result
|
return result[:limit] if limit is not None else result
|
||||||
|
|
||||||
def download(self, doc_id: str) -> tuple[Document, bytes]:
|
def download(self, doc_id: str) -> tuple["Document", bytes]:
|
||||||
"""Handle download for the Document Query Service instance."""
|
"""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)
|
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)
|
return document, self.binary_store.read(document.object_name)
|
||||||
|
|||||||
@@ -7,9 +7,13 @@ from typing import Any, Generator
|
|||||||
|
|
||||||
from loguru import logger
|
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_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.crawlers.base import BaseCrawler, RawEvent
|
||||||
from app.infrastructure.perception.llm_pipeline import LlmPipeline
|
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:
|
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()
|
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 {
|
return {
|
||||||
"id": event_id,
|
"id": event_id,
|
||||||
"source": raw.source,
|
"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,
|
"effective_at": raw.effective_at,
|
||||||
"category": raw.category,
|
"category": raw.category,
|
||||||
"tags": raw.tags,
|
"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,
|
"content_hash": content_hash,
|
||||||
"previous_hash": None,
|
"previous_hash": None,
|
||||||
}
|
}
|
||||||
@@ -50,11 +119,17 @@ class CrawlService:
|
|||||||
event_store: BaseEventStore,
|
event_store: BaseEventStore,
|
||||||
llm_pipeline: LlmPipeline,
|
llm_pipeline: LlmPipeline,
|
||||||
retrieval_service: Any,
|
retrieval_service: Any,
|
||||||
|
notification_store: BaseNotificationStore,
|
||||||
|
embedding_provider: Any,
|
||||||
|
vector_index: Any,
|
||||||
) -> None:
|
) -> None:
|
||||||
self._crawlers = crawlers
|
self._crawlers = crawlers
|
||||||
self._store = event_store
|
self._store = event_store
|
||||||
self._pipeline = llm_pipeline
|
self._pipeline = llm_pipeline
|
||||||
self._retrieval = retrieval_service
|
self._retrieval = retrieval_service
|
||||||
|
self._notifications = notification_store
|
||||||
|
self._embedding_provider = embedding_provider
|
||||||
|
self._vector_index = vector_index
|
||||||
|
|
||||||
def run_crawl(
|
def run_crawl(
|
||||||
self, sources: list[str] | None = None
|
self, sources: list[str] | None = None
|
||||||
@@ -72,7 +147,7 @@ class CrawlService:
|
|||||||
|
|
||||||
yield {"event": "progress", "data": {"source": source_key, "stage": "fetching"}}
|
yield {"event": "progress", "data": {"source": source_key, "stage": "fetching"}}
|
||||||
try:
|
try:
|
||||||
raw_events = crawler.fetch(limit=100)
|
raw_events = crawler.fetch(limit=settings.perception_max_events_per_source)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.exception("Crawler failed source={}", source_key)
|
logger.exception("Crawler failed source={}", source_key)
|
||||||
yield {"event": "error", "data": {"source": source_key, "message": str(exc)}}
|
yield {"event": "error", "data": {"source": source_key, "message": str(exc)}}
|
||||||
@@ -88,17 +163,21 @@ class CrawlService:
|
|||||||
|
|
||||||
for raw in raw_events:
|
for raw in raw_events:
|
||||||
eid = _event_id(raw.source, raw.standard_code)
|
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)
|
existing = self._store.get(eid)
|
||||||
|
|
||||||
if existing and existing.get("content_hash") == new_hash:
|
if existing and existing.get("content_hash") == new_hash:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
is_update = existing is not None
|
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
|
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
|
event_dict["previous_hash"] = previous_hash
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -113,9 +192,11 @@ class CrawlService:
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("Impact assessment failed id={} err={}", eid, 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:
|
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["change_summary"] = diff.get("change_summary")
|
||||||
event_dict["changed_sections"] = diff.get("changed_sections")
|
event_dict["changed_sections"] = diff.get("changed_sections")
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -123,6 +204,33 @@ class CrawlService:
|
|||||||
|
|
||||||
self._store.upsert(event_dict)
|
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:
|
if is_update:
|
||||||
updated_count += 1
|
updated_count += 1
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -94,9 +94,21 @@ class Settings(BaseSettings):
|
|||||||
perception_max_events_per_source: int = Field(
|
perception_max_events_per_source: int = Field(
|
||||||
default=100, description="Maximum events fetched per source per crawl run."
|
default=100, description="Maximum events fetched per source per crawl run."
|
||||||
)
|
)
|
||||||
perception_diff_similarity_threshold: float = Field(
|
perception_diff_min_change_ratio: float = Field(
|
||||||
default=0.85,
|
default=0.02,
|
||||||
description="Cosine similarity below which a paragraph is flagged as changed.",
|
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.
|
# 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.
|
# Keep configuration setup explicit so runtime behavior is easy to reason about.
|
||||||
qwen_api_key: str = Field(default="", description="Qwen API密钥")
|
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_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视觉模型")
|
qwen_vl_model: str = Field(default="qwen3-vl-plus", description="Qwen视觉模型")
|
||||||
|
|
||||||
# Keep configuration setup explicit so runtime behavior is easy to reason about.
|
# 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_api_key: str = Field(default="", description="Reranker API 密钥")
|
||||||
reranker_top_k: int = Field(default=5, description="精排后保留的最终结果数量")
|
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.
|
# Keep configuration setup explicit so runtime behavior is easy to reason about.
|
||||||
milvus_index_type: str = Field(default="IVF_FLAT", description="Milvus索引类型")
|
milvus_index_type: str = Field(default="IVF_FLAT", description="Milvus索引类型")
|
||||||
milvus_nlist: int = Field(default=128, description="Milvus nlist参数")
|
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.",
|
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
|
@lru_cache
|
||||||
def get_settings() -> Settings:
|
def get_settings() -> Settings:
|
||||||
"""Return settings."""
|
"""Return settings."""
|
||||||
|
|||||||
@@ -3,11 +3,13 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import time
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from app.config.settings import settings
|
from app.config.settings import settings
|
||||||
from app.domain.retrieval import EmbeddingProvider
|
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.
|
# Keep adapter behavior explicit so integration details remain easy to audit.
|
||||||
|
|
||||||
EMBEDDING_BATCH_SIZE = 8
|
EMBEDDING_BATCH_SIZE = 8
|
||||||
@@ -45,20 +47,41 @@ class OpenAICompatibleEmbeddingProvider(EmbeddingProvider):
|
|||||||
"""Handle request for this module for the Open A I Compatible Embedding Provider instance."""
|
"""Handle request for this module for the Open A I Compatible Embedding Provider instance."""
|
||||||
if not self.api_key:
|
if not self.api_key:
|
||||||
raise ValueError("缺少 EMBEDDING_API_KEY / OPENAI_API_KEY")
|
raise ValueError("缺少 EMBEDDING_API_KEY / OPENAI_API_KEY")
|
||||||
response = httpx.post(
|
start = time.time()
|
||||||
f"{self.base_url}/embeddings",
|
try:
|
||||||
headers={
|
response = httpx.post(
|
||||||
"Authorization": f"Bearer {self.api_key}",
|
f"{self.base_url}/embeddings",
|
||||||
"Content-Type": "application/json",
|
headers={
|
||||||
},
|
"Authorization": f"Bearer {self.api_key}",
|
||||||
json={"model": self.model, "input": texts},
|
"Content-Type": "application/json",
|
||||||
timeout=self.timeout,
|
},
|
||||||
)
|
json={"model": self.model, "input": texts},
|
||||||
self._raise_for_status(response, batch_size=len(texts))
|
timeout=self.timeout,
|
||||||
data = response.json()
|
)
|
||||||
|
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"])]
|
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):
|
if any(len(vector) != self.dimension for vector in vectors):
|
||||||
raise ValueError(f"embedding 维度不匹配,期望 {self.dimension}")
|
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
|
return vectors
|
||||||
|
|
||||||
def embed_texts(self, texts: list[str]) -> list[list[float]]:
|
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.conversation import AnswerGenerator, AnswerResult, AnswerSource
|
||||||
from app.domain.retrieval import RetrievedChunk
|
from app.domain.retrieval import RetrievedChunk
|
||||||
from app.services.llm.llm_factory import get_llm_client
|
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.
|
# 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": "你是法规知识问答助手。请仅依据提供的上下文回答;如果上下文不足,明确说明。",
|
"default": "你是法规知识问答助手。请仅依据提供的上下文回答;如果上下文不足,明确说明。",
|
||||||
"compliance_qa": "你是法规合规问答助手。优先引用给定法规原文,回答要准确、克制,并注明依据来源。",
|
"compliance_qa": "你是法规合规问答助手。优先引用给定法规原文,回答要准确、克制,并注明依据来源。",
|
||||||
}
|
}
|
||||||
@@ -38,33 +40,80 @@ class OpenAICompatibleAnswerGenerator(AnswerGenerator):
|
|||||||
retrieved_chunks: list[RetrievedChunk],
|
retrieved_chunks: list[RetrievedChunk],
|
||||||
history: list[dict[str, str]] | None,
|
history: list[dict[str, str]] | None,
|
||||||
prompt_template: str | None,
|
prompt_template: str | None,
|
||||||
|
context_text: str | None = None,
|
||||||
|
context_filename: str | None = None,
|
||||||
) -> tuple[list[dict[str, str]], int]:
|
) -> tuple[list[dict[str, str]], int]:
|
||||||
"""Handle build messages for this module for the Open A I Compatible Answer Generator instance."""
|
"""Build the message list to send to the LLM.
|
||||||
system_prompt = PROMPT_TEMPLATES.get(prompt_template or "compliance_qa", PROMPT_TEMPLATES["default"])
|
|
||||||
|
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_blocks = []
|
||||||
context_tokens = 0
|
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):
|
for idx, chunk in enumerate(retrieved_chunks, start=1):
|
||||||
block = (
|
block = (
|
||||||
f"[{idx}] 文档: {chunk.doc_title}\n"
|
f"[法规{idx}] 文档: {chunk.doc_title}\n"
|
||||||
f"章节: {chunk.section_title or '未标注'}\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.page_start}" + (f"-{chunk.page_end}" if chunk.page_end and chunk.page_end != chunk.page_start else "") + "\n"
|
||||||
f"内容: {chunk.text}"
|
f"内容: {chunk.text}"
|
||||||
)
|
)
|
||||||
block_tokens = self._estimate_tokens(block)
|
block_tokens = self._estimate_tokens(block)
|
||||||
if context_tokens + block_tokens > settings.rag_max_context_tokens:
|
if block_tokens > remaining_budget:
|
||||||
break
|
break
|
||||||
|
remaining_budget -= block_tokens
|
||||||
context_tokens += block_tokens
|
context_tokens += block_tokens
|
||||||
context_blocks.append(block)
|
context_blocks.append(block)
|
||||||
|
|
||||||
context = "\n\n".join(context_blocks)
|
context = "\n\n".join(context_blocks)
|
||||||
messages = [{"role": "system", "content": system_prompt}]
|
messages = [{"role": "system", "content": system_prompt}]
|
||||||
for item in history or []:
|
for item in history or []:
|
||||||
messages.append({"role": item["role"], "content": item["content"]})
|
messages.append({"role": item["role"], "content": item["content"]})
|
||||||
messages.append(
|
|
||||||
{
|
# Craft the user turn differently when a document is attached
|
||||||
"role": "user",
|
if context_text and context_text.strip():
|
||||||
"content": f"问题:{query}\n\n参考上下文:\n{context}\n\n请在回答后给出简要引用编号。",
|
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
|
return messages, context_tokens
|
||||||
|
|
||||||
def _is_context_truncated(self, *, retrieved_chunks: list[RetrievedChunk], context_tokens: int) -> bool:
|
def _is_context_truncated(self, *, retrieved_chunks: list[RetrievedChunk], context_tokens: int) -> bool:
|
||||||
@@ -112,6 +161,8 @@ class OpenAICompatibleAnswerGenerator(AnswerGenerator):
|
|||||||
provider: str | None = None,
|
provider: str | None = None,
|
||||||
model: str | None = None,
|
model: str | None = None,
|
||||||
prompt_template: str | None = None,
|
prompt_template: str | None = None,
|
||||||
|
context_text: str | None = None,
|
||||||
|
context_filename: str | None = None,
|
||||||
) -> AnswerResult:
|
) -> AnswerResult:
|
||||||
"""Handle generate for the Open A I Compatible Answer Generator instance."""
|
"""Handle generate for the Open A I Compatible Answer Generator instance."""
|
||||||
start = time.time()
|
start = time.time()
|
||||||
@@ -120,6 +171,8 @@ class OpenAICompatibleAnswerGenerator(AnswerGenerator):
|
|||||||
retrieved_chunks=retrieved_chunks,
|
retrieved_chunks=retrieved_chunks,
|
||||||
history=history,
|
history=history,
|
||||||
prompt_template=prompt_template,
|
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)
|
client = get_llm_client(provider=provider or settings.llm_provider, model=model or settings.llm_model)
|
||||||
response = client.chat(messages)
|
response = client.chat(messages)
|
||||||
@@ -147,6 +200,8 @@ class OpenAICompatibleAnswerGenerator(AnswerGenerator):
|
|||||||
provider: str | None = None,
|
provider: str | None = None,
|
||||||
model: str | None = None,
|
model: str | None = None,
|
||||||
prompt_template: str | None = None,
|
prompt_template: str | None = None,
|
||||||
|
context_text: str | None = None,
|
||||||
|
context_filename: str | None = None,
|
||||||
) -> Generator[dict, None, AnswerResult]:
|
) -> Generator[dict, None, AnswerResult]:
|
||||||
"""Stream generate for the Open A I Compatible Answer Generator instance."""
|
"""Stream generate for the Open A I Compatible Answer Generator instance."""
|
||||||
start = time.time()
|
start = time.time()
|
||||||
@@ -155,6 +210,8 @@ class OpenAICompatibleAnswerGenerator(AnswerGenerator):
|
|||||||
retrieved_chunks=retrieved_chunks,
|
retrieved_chunks=retrieved_chunks,
|
||||||
history=history,
|
history=history,
|
||||||
prompt_template=prompt_template,
|
prompt_template=prompt_template,
|
||||||
|
context_text=context_text,
|
||||||
|
context_filename=context_filename,
|
||||||
)
|
)
|
||||||
sources = [source.__dict__ for source in self._sources(retrieved_chunks)]
|
sources = [source.__dict__ for source in self._sources(retrieved_chunks)]
|
||||||
yield {"event": "sources", "data": sources}
|
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 abc import ABC, abstractmethod
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import trafilatura
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from app.config.settings import settings
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class RawEvent:
|
class RawEvent:
|
||||||
@@ -21,7 +27,10 @@ class RawEvent:
|
|||||||
effective_at: str | None
|
effective_at: str | None
|
||||||
category: str
|
category: str
|
||||||
tags: list[str] = field(default_factory=list)
|
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):
|
class BaseCrawler(ABC):
|
||||||
@@ -30,3 +39,37 @@ class BaseCrawler(ABC):
|
|||||||
@abstractmethod
|
@abstractmethod
|
||||||
def fetch(self, limit: int = 50) -> list[RawEvent]:
|
def fetch(self, limit: int = 50) -> list[RawEvent]:
|
||||||
"""Fetch up to `limit` recent events from the data source."""
|
"""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 bs4 import BeautifulSoup
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
|
from app.config.settings import settings
|
||||||
from app.infrastructure.perception.crawlers.base import BaseCrawler, RawEvent
|
from app.infrastructure.perception.crawlers.base import BaseCrawler, RawEvent
|
||||||
from ._utils import extract_tags, parse_date
|
from ._utils import extract_tags, parse_date
|
||||||
|
|
||||||
@@ -33,7 +34,11 @@ class CatarcCrawler(BaseCrawler):
|
|||||||
while len(events) < limit and page <= max_pages:
|
while len(events) < limit and page <= max_pages:
|
||||||
url = f"{_BASE_URL}?page={page}"
|
url = f"{_BASE_URL}?page={page}"
|
||||||
try:
|
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()
|
resp.raise_for_status()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("CATARC fetch failed page={} err={}", page, exc)
|
logger.warning("CATARC fetch failed page={} err={}", page, exc)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import httpx
|
|||||||
from bs4 import BeautifulSoup
|
from bs4 import BeautifulSoup
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
|
from app.config.settings import settings
|
||||||
from app.infrastructure.perception.crawlers.base import BaseCrawler, RawEvent
|
from app.infrastructure.perception.crawlers.base import BaseCrawler, RawEvent
|
||||||
from ._utils import parse_date
|
from ._utils import parse_date
|
||||||
|
|
||||||
@@ -53,7 +54,11 @@ class EurlexCrawler(BaseCrawler):
|
|||||||
if len(events) >= limit:
|
if len(events) >= limit:
|
||||||
break
|
break
|
||||||
try:
|
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()
|
resp.raise_for_status()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("EUR-Lex RSS fetch failed url={} err={}", rss_url, exc)
|
logger.warning("EUR-Lex RSS fetch failed url={} err={}", rss_url, exc)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|||||||
import httpx
|
import httpx
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
|
from app.config.settings import settings
|
||||||
from app.infrastructure.perception.crawlers.base import BaseCrawler, RawEvent
|
from app.infrastructure.perception.crawlers.base import BaseCrawler, RawEvent
|
||||||
from ._utils import extract_tags, parse_date
|
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,
|
"p.p7": page_size,
|
||||||
}
|
}
|
||||||
try:
|
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()
|
resp.raise_for_status()
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
return data.get("rows", []) or []
|
return data.get("rows", []) or []
|
||||||
|
|||||||
@@ -3,14 +3,14 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import math
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from app.config.settings import settings
|
from app.config.settings import settings
|
||||||
from app.infrastructure.embedding.openai_compatible_embedding_provider import (
|
from app.infrastructure.perception.regulation_differ import (
|
||||||
OpenAICompatibleEmbeddingProvider,
|
ParagraphChange,
|
||||||
|
RegulationDiffer,
|
||||||
)
|
)
|
||||||
from app.services.llm.llm_factory import get_llm_client
|
from app.services.llm.llm_factory import get_llm_client
|
||||||
|
|
||||||
@@ -27,21 +27,31 @@ _ASSESS_SYSTEM = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
_DIFF_SYSTEM = (
|
_DIFF_SYSTEM = (
|
||||||
"You are a regulatory change analyst. Given an old and new version of a regulation paragraph, "
|
"You are a regulatory change analyst. You are given the OLD and NEW version of "
|
||||||
"classify the type of change and summarise it. "
|
"one regulation paragraph, with the exact edits marked <DEL>removed</DEL> and "
|
||||||
"Return JSON only: {\"change_type\": \"tightened|relaxed|added|removed\", \"summary\": \"...\"}"
|
"<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:
|
The model is shown where the edit is rather than being asked to find it,
|
||||||
dot = sum(x * y for x, y in zip(a, b))
|
and is never asked to reproduce the changed text — the differ already
|
||||||
norm_a = math.sqrt(sum(x * x for x in a))
|
computed those spans exactly, so there is nothing for the model to
|
||||||
norm_b = math.sqrt(sum(x * x for x in b))
|
hallucinate.
|
||||||
if norm_a == 0 or norm_b == 0:
|
"""
|
||||||
return 0.0
|
marked = "".join(
|
||||||
return dot / (norm_a * norm_b)
|
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:
|
def _llm_json(client: Any, messages: list[dict]) -> Any:
|
||||||
@@ -67,7 +77,9 @@ class LlmPipeline:
|
|||||||
provider=settings.llm_provider,
|
provider=settings.llm_provider,
|
||||||
model=settings.llm_model,
|
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
|
# Step 1: Structure extraction
|
||||||
@@ -166,76 +178,68 @@ For each document, assess impact and recommend action. Return JSON array:
|
|||||||
return doc_excerpts
|
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:
|
def compute_diff(self, old_text: str, new_text: str) -> dict:
|
||||||
"""Compare old and new regulation text; return changed sections and summary."""
|
"""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()]
|
|
||||||
|
|
||||||
if not old_paras or not new_paras:
|
Detection is deterministic — see regulation_differ for why embedding
|
||||||
return {"changed_sections": [], "change_summary": "No comparable text."}
|
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
|
changed_sections = [self._describe(change) for change in changes]
|
||||||
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)."}
|
|
||||||
|
|
||||||
old_embeddings = all_embeddings[: len(old_paras)]
|
types = sorted({section["change_type"] for section in changed_sections})
|
||||||
new_embeddings = all_embeddings[len(old_paras):]
|
gated = sum(1 for change in changes if change.needs_llm)
|
||||||
|
change_summary = (
|
||||||
changed_sections: list[dict] = []
|
f"{len(changed_sections)} paragraph(s) changed ({', '.join(types)}); "
|
||||||
max_len = max(len(old_paras), len(new_paras))
|
f"{gated} significant. "
|
||||||
|
+ (changed_sections[0].get("summary") or "")
|
||||||
for i in range(max_len):
|
).strip()
|
||||||
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]
|
|
||||||
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 "")
|
|
||||||
)
|
|
||||||
|
|
||||||
return {"changed_sections": changed_sections, "change_summary": change_summary}
|
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,
|
affected_docs JSONB,
|
||||||
crawled_at TIMESTAMPTZ DEFAULT now(),
|
crawled_at TIMESTAMPTZ DEFAULT now(),
|
||||||
processed_at TIMESTAMPTZ,
|
processed_at TIMESTAMPTZ,
|
||||||
raw_storage_key TEXT
|
raw_storage_key TEXT,
|
||||||
|
raw_text TEXT
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS reg_events_source_date
|
CREATE INDEX IF NOT EXISTS reg_events_source_date
|
||||||
ON regulation_events (source, published_at DESC);
|
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);
|
ON regulation_events (impact_level, published_at DESC);
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
_ADD_COLUMNS = """
|
||||||
|
ALTER TABLE regulation_events ADD COLUMN IF NOT EXISTS raw_text TEXT;
|
||||||
|
"""
|
||||||
|
|
||||||
_ALL_COLUMNS = (
|
_ALL_COLUMNS = (
|
||||||
"id", "source", "source_label", "standard_code", "title", "summary",
|
"id", "source", "source_label", "standard_code", "title", "summary",
|
||||||
"full_text_url", "status", "impact_level", "published_at", "effective_at",
|
"full_text_url", "status", "impact_level", "published_at", "effective_at",
|
||||||
"category", "tags", "obligations", "deadlines", "scope", "penalties",
|
"category", "tags", "obligations", "deadlines", "scope", "penalties",
|
||||||
"content_hash", "previous_hash", "change_summary", "changed_sections",
|
"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:
|
try:
|
||||||
with conn.cursor() as cur:
|
with conn.cursor() as cur:
|
||||||
cur.execute(_CREATE_TABLE)
|
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()
|
conn.commit()
|
||||||
except Exception:
|
except Exception:
|
||||||
conn.rollback()
|
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}")
|
raise FileNotFoundError(f"对象不存在: {object_name}")
|
||||||
return data
|
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:
|
def delete(self, object_name: str) -> None:
|
||||||
"""Handle delete for the Minio Document Binary Store instance."""
|
"""Handle delete for the Minio Document Binary Store instance."""
|
||||||
if not self.client.delete_object(object_name):
|
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()
|
||||||
@@ -28,7 +28,10 @@ celery_app = Celery(
|
|||||||
"compliance_hub",
|
"compliance_hub",
|
||||||
broker=_BROKER,
|
broker=_BROKER,
|
||||||
backend=_BACKEND,
|
backend=_BACKEND,
|
||||||
include=["app.infrastructure.tasks.document_tasks"],
|
include=[
|
||||||
|
"app.infrastructure.tasks.document_tasks",
|
||||||
|
"app.infrastructure.tasks.perception_tasks",
|
||||||
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
celery_app.conf.update(
|
celery_app.conf.update(
|
||||||
@@ -42,4 +45,12 @@ celery_app.conf.update(
|
|||||||
task_reject_on_worker_lost=True,
|
task_reject_on_worker_lost=True,
|
||||||
# Keep results for 1 hour for status polling.
|
# Keep results for 1 hour for status polling.
|
||||||
result_expires=3600,
|
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.config.settings import settings
|
||||||
from app.domain.retrieval import Reranker, RetrievedChunk
|
from app.domain.retrieval import Reranker, RetrievedChunk
|
||||||
|
from app.shared.model_usage_tracker import get_model_usage_tracker
|
||||||
|
|
||||||
|
|
||||||
class OpenAICompatibleReranker(Reranker):
|
class OpenAICompatibleReranker(Reranker):
|
||||||
@@ -37,10 +38,26 @@ class OpenAICompatibleReranker(Reranker):
|
|||||||
scores = self._call_reranker(query, texts)
|
scores = self._call_reranker(query, texts)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("Reranker call failed ({}), falling back to original order: {}", type(exc).__name__, 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]
|
return chunks[:top_k]
|
||||||
|
|
||||||
elapsed_ms = int((time.time() - start) * 1000)
|
elapsed_ms = int((time.time() - start) * 1000)
|
||||||
logger.debug("Reranker scored {} chunks in {}ms", len(chunks), elapsed_ms)
|
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(
|
ranked = sorted(
|
||||||
[(score, chunk) for score, chunk in zip(scores, chunks)],
|
[(score, chunk) for score, chunk in zip(scores, chunks)],
|
||||||
@@ -54,22 +71,48 @@ class OpenAICompatibleReranker(Reranker):
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
def _call_reranker(self, query: str, texts: list[str]) -> list[float]:
|
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"}
|
headers = {"Content-Type": "application/json"}
|
||||||
if self._api_key:
|
if self._api_key:
|
||||||
headers["Authorization"] = f"Bearer {self._api_key}"
|
headers["Authorization"] = f"Bearer {self._api_key}"
|
||||||
|
|
||||||
# Try TEI format first: POST /rerank
|
# TEI format: POST /rerank — include model name (required by gateway proxies)
|
||||||
payload = {"query": query, "texts": texts, "raw_scores": False, "return_text": False}
|
payload = {
|
||||||
|
"model": self._model,
|
||||||
|
"query": query,
|
||||||
|
"texts": texts,
|
||||||
|
"raw_scores": False,
|
||||||
|
"return_text": False,
|
||||||
|
}
|
||||||
url = f"{self._base_url}/rerank"
|
url = f"{self._base_url}/rerank"
|
||||||
resp = requests.post(url, json=payload, headers=headers, timeout=self._timeout)
|
resp = requests.post(url, json=payload, headers=headers, timeout=self._timeout)
|
||||||
|
|
||||||
if resp.status_code == 404:
|
if resp.status_code in (404, 400):
|
||||||
# Fall back to Cohere / OpenAI-style: POST /v1/rerank
|
# 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}
|
payload_v1 = {"model": self._model, "query": query, "documents": texts}
|
||||||
url = f"{self._base_url}/v1/rerank"
|
url = f"{self._base_url}/v1/rerank"
|
||||||
resp = requests.post(url, json=payload_v1, headers=headers, timeout=self._timeout)
|
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()
|
resp.raise_for_status()
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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)
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
@@ -12,6 +12,11 @@ class RagChatRequest(BaseModel):
|
|||||||
top_k: int = 5
|
top_k: int = 5
|
||||||
session_id: Optional[str] = None
|
session_id: Optional[str] = None
|
||||||
filters: 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):
|
class RetrievedDoc(BaseModel):
|
||||||
|
|||||||
@@ -1,9 +1,16 @@
|
|||||||
"""Provide service-layer logic for base client."""
|
"""Provide service-layer logic for base client.
|
||||||
|
|
||||||
|
P0-0: ``LLMResponse`` now carries an optional ``tool_calls`` list so that any
|
||||||
|
downstream code (agents, pipelines) can inspect and dispatch tool invocations
|
||||||
|
without touching the provider-specific adapter layer.
|
||||||
|
"""
|
||||||
|
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import List, Dict, Optional, Any
|
from typing import List, Dict, Optional, Any
|
||||||
from enum import Enum
|
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.
|
# Keep provider-specific behavior explicit so debugging stays straightforward.
|
||||||
|
|
||||||
|
|
||||||
@@ -24,6 +31,8 @@ class LLMResponse:
|
|||||||
finish_reason: str = "stop"
|
finish_reason: str = "stop"
|
||||||
latency_ms: int = 0
|
latency_ms: int = 0
|
||||||
error: Optional[str] = None
|
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
|
@property
|
||||||
def is_success(self) -> bool:
|
def is_success(self) -> bool:
|
||||||
@@ -63,9 +72,19 @@ class BaseLLMClient(ABC):
|
|||||||
messages: List[Dict[str, str]],
|
messages: List[Dict[str, str]],
|
||||||
max_tokens: Optional[int] = None,
|
max_tokens: Optional[int] = None,
|
||||||
temperature: Optional[float] = None,
|
temperature: Optional[float] = None,
|
||||||
|
tools: Optional[List["Tool"]] = None,
|
||||||
**kwargs
|
**kwargs
|
||||||
) -> LLMResponse:
|
) -> 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
|
pass
|
||||||
|
|
||||||
def complete(
|
def complete(
|
||||||
|
|||||||
@@ -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
|
import time
|
||||||
from typing import List, Dict, Optional
|
from typing import List, Dict, Optional, Generator
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from .base_client import BaseLLMClient, LLMResponse, LLMConfig, LLMProvider
|
from .base_client import BaseLLMClient, LLMResponse, LLMConfig, LLMProvider
|
||||||
|
from .tool_types import Tool, ToolCall
|
||||||
# Keep provider-specific behavior explicit so debugging stays straightforward.
|
# Keep provider-specific behavior explicit so debugging stays straightforward.
|
||||||
|
|
||||||
|
|
||||||
@@ -46,13 +51,20 @@ class DeepSeekClient(BaseLLMClient):
|
|||||||
messages: List[Dict[str, str]],
|
messages: List[Dict[str, str]],
|
||||||
max_tokens: Optional[int] = None,
|
max_tokens: Optional[int] = None,
|
||||||
temperature: Optional[float] = None,
|
temperature: Optional[float] = None,
|
||||||
|
tools: Optional[List[Tool]] = None,
|
||||||
**kwargs
|
**kwargs
|
||||||
) -> LLMResponse:
|
) -> 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()
|
start_time = time.time()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
payload = {
|
payload: Dict = {
|
||||||
"model": self.config.model,
|
"model": self.config.model,
|
||||||
"messages": messages,
|
"messages": messages,
|
||||||
"max_tokens": max_tokens or self.config.max_tokens,
|
"max_tokens": max_tokens or self.config.max_tokens,
|
||||||
@@ -61,6 +73,11 @@ class DeepSeekClient(BaseLLMClient):
|
|||||||
"stream": False
|
"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 = self._client.post("/chat/completions", json=payload)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
|
||||||
@@ -71,12 +88,24 @@ class DeepSeekClient(BaseLLMClient):
|
|||||||
choices = data.get("choices", [{}])
|
choices = data.get("choices", [{}])
|
||||||
message = choices[0].get("message", {})
|
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(
|
return LLMResponse(
|
||||||
content=message.get("content", ""),
|
content=message.get("content", "") or "",
|
||||||
model=data.get("model", self.config.model),
|
model=data.get("model", self.config.model),
|
||||||
usage=data.get("usage", {}),
|
usage=data.get("usage", {}),
|
||||||
finish_reason=choices[0].get("finish_reason", "stop"),
|
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:
|
except httpx.HTTPStatusError as e:
|
||||||
@@ -101,8 +130,14 @@ class DeepSeekClient(BaseLLMClient):
|
|||||||
max_tokens: Optional[int] = None,
|
max_tokens: Optional[int] = None,
|
||||||
temperature: Optional[float] = None,
|
temperature: Optional[float] = None,
|
||||||
**kwargs
|
**kwargs
|
||||||
):
|
) -> Generator[str, None, Optional[Dict[str, int]]]:
|
||||||
"""Stream chat for the Deep Seek Client instance."""
|
"""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:
|
try:
|
||||||
payload = {
|
payload = {
|
||||||
"model": self.config.model,
|
"model": self.config.model,
|
||||||
@@ -110,7 +145,8 @@ class DeepSeekClient(BaseLLMClient):
|
|||||||
"max_tokens": max_tokens or self.config.max_tokens,
|
"max_tokens": max_tokens or self.config.max_tokens,
|
||||||
"temperature": temperature or self.config.temperature,
|
"temperature": temperature or self.config.temperature,
|
||||||
"top_p": kwargs.get("top_p", self.config.top_p),
|
"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:
|
with self._client.stream("POST", "/chat/completions", json=payload) as response:
|
||||||
@@ -139,6 +175,9 @@ class DeepSeekClient(BaseLLMClient):
|
|||||||
content = delta.get("content", "")
|
content = delta.get("content", "")
|
||||||
if content:
|
if content:
|
||||||
yield content
|
yield content
|
||||||
|
elif data.get("usage"):
|
||||||
|
# Trailing usage-only chunk — no content to yield, just capture it.
|
||||||
|
usage = data["usage"]
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -149,6 +188,8 @@ class DeepSeekClient(BaseLLMClient):
|
|||||||
logger.error(f"DeepSeek Stream调用失败: {e}")
|
logger.error(f"DeepSeek Stream调用失败: {e}")
|
||||||
yield ""
|
yield ""
|
||||||
|
|
||||||
|
return usage
|
||||||
|
|
||||||
def get_available_models(self) -> List[str]:
|
def get_available_models(self) -> List[str]:
|
||||||
"""Return available models for the Deep Seek Client instance."""
|
"""Return available models for the Deep Seek Client instance."""
|
||||||
return self.SUPPORTED_MODELS
|
return self.SUPPORTED_MODELS
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ from functools import lru_cache
|
|||||||
from .base_client import BaseLLMClient, LLMConfig, LLMProvider, LLMResponse
|
from .base_client import BaseLLMClient, LLMConfig, LLMProvider, LLMResponse
|
||||||
from .deepseek_client import DeepSeekClient
|
from .deepseek_client import DeepSeekClient
|
||||||
from .qwen_client import QwenClient, QwenVLClient
|
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.
|
# 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.
|
# Keep provider-specific behavior explicit so debugging stays straightforward.
|
||||||
DEFAULT_MODELS = {
|
DEFAULT_MODELS = {
|
||||||
LLMProvider.DEEPSEEK: "deepseek-v4-flash",
|
LLMProvider.DEEPSEEK: "deepseek-v4-flash",
|
||||||
LLMProvider.QWEN: "qwen3.5-flash",
|
LLMProvider.QWEN: "qwen3.6-flash",
|
||||||
LLMProvider.QWEN_VL: "qwen3-vl-plus"
|
LLMProvider.QWEN_VL: "qwen3-vl-plus"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,7 +47,7 @@ class LLMFactory:
|
|||||||
max_tokens: int = 4096,
|
max_tokens: int = 4096,
|
||||||
temperature: float = 0.7,
|
temperature: float = 0.7,
|
||||||
**kwargs
|
**kwargs
|
||||||
) -> BaseLLMClient:
|
) -> "BaseLLMClient | TrackedLLMClient":
|
||||||
"""Handle create for the L L M Factory instance."""
|
"""Handle create for the L L M Factory instance."""
|
||||||
provider_enum = self._parse_provider(provider)
|
provider_enum = self._parse_provider(provider)
|
||||||
|
|
||||||
@@ -76,11 +78,16 @@ class LLMFactory:
|
|||||||
# Keep provider-specific behavior explicit so debugging stays straightforward.
|
# Keep provider-specific behavior explicit so debugging stays straightforward.
|
||||||
client = self._create_client(config)
|
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.
|
# 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}")
|
logger.info(f"LLM客户端创建成功并缓存: {provider} - {model}")
|
||||||
return client
|
return tracked_client
|
||||||
|
|
||||||
def _parse_provider(self, provider: str) -> LLMProvider:
|
def _parse_provider(self, provider: str) -> LLMProvider:
|
||||||
"""Handle parse provider for this module for the L L M Factory instance."""
|
"""Handle parse provider for this module for the L L M Factory instance."""
|
||||||
@@ -94,6 +101,8 @@ class LLMFactory:
|
|||||||
"qwen-max": LLMProvider.QWEN,
|
"qwen-max": LLMProvider.QWEN,
|
||||||
"qwen3.5-flash": LLMProvider.QWEN,
|
"qwen3.5-flash": LLMProvider.QWEN,
|
||||||
"qwen3.5-plus": 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": LLMProvider.QWEN_VL,
|
"qwen-vl": LLMProvider.QWEN_VL,
|
||||||
"qwen-vl-plus": LLMProvider.QWEN_VL,
|
"qwen-vl-plus": LLMProvider.QWEN_VL,
|
||||||
@@ -137,7 +146,7 @@ class LLMFactory:
|
|||||||
|
|
||||||
return client_class(config)
|
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."""
|
"""Return cached for the L L M Factory instance."""
|
||||||
provider_enum = self._parse_provider(provider)
|
provider_enum = self._parse_provider(provider)
|
||||||
model = model or DEFAULT_MODELS.get(provider_enum)
|
model = model or DEFAULT_MODELS.get(provider_enum)
|
||||||
@@ -200,7 +209,7 @@ def get_llm_client(
|
|||||||
provider: str = "qwen",
|
provider: str = "qwen",
|
||||||
model: Optional[str] = None,
|
model: Optional[str] = None,
|
||||||
**kwargs
|
**kwargs
|
||||||
) -> BaseLLMClient:
|
) -> "BaseLLMClient | TrackedLLMClient":
|
||||||
"""Return llm client."""
|
"""Return llm client."""
|
||||||
factory = get_llm_factory()
|
factory = get_llm_factory()
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
"""Provide service-layer logic for qwen client."""
|
"""Provide service-layer logic for qwen client.
|
||||||
|
|
||||||
|
P0-0: ``chat()`` now accepts an optional ``tools`` list and parses ``tool_calls``
|
||||||
|
from the model response so that callers can dispatch tool invocations.
|
||||||
|
"""
|
||||||
|
|
||||||
import time
|
import time
|
||||||
import json
|
import json
|
||||||
@@ -7,6 +11,7 @@ from loguru import logger
|
|||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from .base_client import BaseLLMClient, LLMResponse, LLMConfig, LLMProvider
|
from .base_client import BaseLLMClient, LLMResponse, LLMConfig, LLMProvider
|
||||||
|
from .tool_types import Tool, ToolCall
|
||||||
# Keep provider-specific behavior explicit so debugging stays straightforward.
|
# Keep provider-specific behavior explicit so debugging stays straightforward.
|
||||||
|
|
||||||
|
|
||||||
@@ -22,6 +27,8 @@ class QwenClient(BaseLLMClient):
|
|||||||
"qwen-long",
|
"qwen-long",
|
||||||
"qwen3.5-flash",
|
"qwen3.5-flash",
|
||||||
"qwen3.5-plus",
|
"qwen3.5-plus",
|
||||||
|
"qwen3.6-flash",
|
||||||
|
"qwen3.6-plus",
|
||||||
"qwen3-plus",
|
"qwen3-plus",
|
||||||
"qwen2.5-72b-instruct",
|
"qwen2.5-72b-instruct",
|
||||||
"qwen2.5-32b-instruct",
|
"qwen2.5-32b-instruct",
|
||||||
@@ -54,14 +61,20 @@ class QwenClient(BaseLLMClient):
|
|||||||
messages: List[Dict[str, str]],
|
messages: List[Dict[str, str]],
|
||||||
max_tokens: Optional[int] = None,
|
max_tokens: Optional[int] = None,
|
||||||
temperature: Optional[float] = None,
|
temperature: Optional[float] = None,
|
||||||
|
tools: Optional[List[Tool]] = None,
|
||||||
**kwargs
|
**kwargs
|
||||||
) -> LLMResponse:
|
) -> 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()
|
start_time = time.time()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Keep provider-specific behavior explicit so debugging stays straightforward.
|
# Keep provider-specific behavior explicit so debugging stays straightforward.
|
||||||
payload = {
|
payload: Dict = {
|
||||||
"model": self.config.model,
|
"model": self.config.model,
|
||||||
"messages": messages,
|
"messages": messages,
|
||||||
"max_tokens": max_tokens or self.config.max_tokens,
|
"max_tokens": max_tokens or self.config.max_tokens,
|
||||||
@@ -70,6 +83,11 @@ class QwenClient(BaseLLMClient):
|
|||||||
"stream": False
|
"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.
|
# Keep provider-specific behavior explicit so debugging stays straightforward.
|
||||||
response = self._client.post("/chat/completions", json=payload)
|
response = self._client.post("/chat/completions", json=payload)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
@@ -82,12 +100,24 @@ class QwenClient(BaseLLMClient):
|
|||||||
choices = data.get("choices", [{}])
|
choices = data.get("choices", [{}])
|
||||||
message = choices[0].get("message", {})
|
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(
|
return LLMResponse(
|
||||||
content=message.get("content", ""),
|
content=message.get("content", "") or "",
|
||||||
model=data.get("model", self.config.model),
|
model=data.get("model", self.config.model),
|
||||||
usage=data.get("usage", {}),
|
usage=data.get("usage", {}),
|
||||||
finish_reason=choices[0].get("finish_reason", "stop"),
|
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:
|
except httpx.HTTPStatusError as e:
|
||||||
@@ -112,8 +142,14 @@ class QwenClient(BaseLLMClient):
|
|||||||
max_tokens: Optional[int] = None,
|
max_tokens: Optional[int] = None,
|
||||||
temperature: Optional[float] = None,
|
temperature: Optional[float] = None,
|
||||||
**kwargs
|
**kwargs
|
||||||
) -> Generator[str, None, None]:
|
) -> Generator[str, None, Optional[Dict[str, int]]]:
|
||||||
"""Stream chat for the Qwen Client instance."""
|
"""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:
|
try:
|
||||||
# Keep provider-specific behavior explicit so debugging stays straightforward.
|
# Keep provider-specific behavior explicit so debugging stays straightforward.
|
||||||
payload = {
|
payload = {
|
||||||
@@ -122,7 +158,8 @@ class QwenClient(BaseLLMClient):
|
|||||||
"max_tokens": max_tokens or self.config.max_tokens,
|
"max_tokens": max_tokens or self.config.max_tokens,
|
||||||
"temperature": temperature or self.config.temperature,
|
"temperature": temperature or self.config.temperature,
|
||||||
"top_p": kwargs.get("top_p", self.config.top_p),
|
"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.
|
# Keep provider-specific behavior explicit so debugging stays straightforward.
|
||||||
@@ -139,6 +176,9 @@ class QwenClient(BaseLLMClient):
|
|||||||
data = json.loads(data_str)
|
data = json.loads(data_str)
|
||||||
choices = data.get("choices", [])
|
choices = data.get("choices", [])
|
||||||
if not 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.
|
continue # Keep provider-specific behavior explicit so debugging stays straightforward.
|
||||||
delta = choices[0].get("delta", {})
|
delta = choices[0].get("delta", {})
|
||||||
content = delta.get("content", "")
|
content = delta.get("content", "")
|
||||||
@@ -155,6 +195,8 @@ class QwenClient(BaseLLMClient):
|
|||||||
logger.error(f"Qwen流式调用失败: {e}")
|
logger.error(f"Qwen流式调用失败: {e}")
|
||||||
yield f"[ERROR: {str(e)}]"
|
yield f"[ERROR: {str(e)}]"
|
||||||
|
|
||||||
|
return usage
|
||||||
|
|
||||||
async def async_stream_chat(
|
async def async_stream_chat(
|
||||||
self,
|
self,
|
||||||
messages: List[Dict[str, str]],
|
messages: List[Dict[str, str]],
|
||||||
@@ -271,8 +313,14 @@ class QwenVLClient(BaseLLMClient):
|
|||||||
max_tokens: Optional[int] = None,
|
max_tokens: Optional[int] = None,
|
||||||
temperature: Optional[float] = None,
|
temperature: Optional[float] = None,
|
||||||
**kwargs
|
**kwargs
|
||||||
) -> Generator[str, None, None]:
|
) -> Generator[str, None, Optional[Dict[str, int]]]:
|
||||||
"""Stream chat for the Qwen V L Client instance."""
|
"""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:
|
try:
|
||||||
payload = {
|
payload = {
|
||||||
"model": self.config.model,
|
"model": self.config.model,
|
||||||
@@ -280,7 +328,8 @@ class QwenVLClient(BaseLLMClient):
|
|||||||
"max_tokens": max_tokens or self.config.max_tokens,
|
"max_tokens": max_tokens or self.config.max_tokens,
|
||||||
"temperature": temperature or self.config.temperature,
|
"temperature": temperature or self.config.temperature,
|
||||||
"top_p": kwargs.get("top_p", self.config.top_p),
|
"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:
|
with self._client.stream("POST", "/chat/completions", json=payload) as response:
|
||||||
@@ -295,6 +344,9 @@ class QwenVLClient(BaseLLMClient):
|
|||||||
data = json.loads(data_str)
|
data = json.loads(data_str)
|
||||||
choices = data.get("choices", [])
|
choices = data.get("choices", [])
|
||||||
if not 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.
|
continue # Keep provider-specific behavior explicit so debugging stays straightforward.
|
||||||
delta = choices[0].get("delta", {})
|
delta = choices[0].get("delta", {})
|
||||||
content = delta.get("content", "")
|
content = delta.get("content", "")
|
||||||
@@ -307,6 +359,8 @@ class QwenVLClient(BaseLLMClient):
|
|||||||
logger.error(f"QwenVL流式调用失败: {e}")
|
logger.error(f"QwenVL流式调用失败: {e}")
|
||||||
yield f"[ERROR: {str(e)}]"
|
yield f"[ERROR: {str(e)}]"
|
||||||
|
|
||||||
|
return usage
|
||||||
|
|
||||||
def get_available_models(self) -> List[str]:
|
def get_available_models(self) -> List[str]:
|
||||||
"""Return available models for the Qwen V L Client instance."""
|
"""Return available models for the Qwen V L Client instance."""
|
||||||
return self.SUPPORTED_MODELS
|
return self.SUPPORTED_MODELS
|
||||||
@@ -319,7 +373,7 @@ class QwenVLClient(BaseLLMClient):
|
|||||||
|
|
||||||
def create_qwen_client(
|
def create_qwen_client(
|
||||||
api_key: str,
|
api_key: str,
|
||||||
model: str = "qwen3.5-flash",
|
model: str = "qwen3.6-flash",
|
||||||
base_url: str = "http://6.86.80.4:30080/v1",
|
base_url: str = "http://6.86.80.4:30080/v1",
|
||||||
**kwargs
|
**kwargs
|
||||||
) -> QwenClient:
|
) -> QwenClient:
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
"""Shared tool and tool-call type definitions for LLM function calling (P0-0).
|
||||||
|
|
||||||
|
These types implement the OpenAI-compatible tool/function-calling interface so that
|
||||||
|
any provider whose gateway supports the spec (DeepSeek, Qwen, etc.) can expose
|
||||||
|
tools to the LLM and receive structured tool invocations in return.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ToolCall:
|
||||||
|
"""Represent a single tool invocation returned by the LLM.
|
||||||
|
|
||||||
|
The model fills in ``id``, ``name``, and ``arguments`` when it decides to call
|
||||||
|
a tool instead of (or in addition to) producing a text response.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Unique identifier assigned by the model for this call.
|
||||||
|
id: str
|
||||||
|
# Name of the tool to invoke, matching the name registered in Tool.
|
||||||
|
name: str
|
||||||
|
# Parsed JSON arguments ready for direct use by the tool handler.
|
||||||
|
arguments: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ToolParameter:
|
||||||
|
"""JSON-Schema–compatible parameter block for a tool definition."""
|
||||||
|
|
||||||
|
# Top-level schema type — always "object" for OpenAI-compatible tools.
|
||||||
|
type: str = "object"
|
||||||
|
# Map of parameter name → JSON-Schema property descriptor.
|
||||||
|
properties: dict[str, Any] = field(default_factory=dict)
|
||||||
|
# List of required parameter names.
|
||||||
|
required: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Tool:
|
||||||
|
"""Describe a callable tool that can be offered to the LLM.
|
||||||
|
|
||||||
|
Example usage::
|
||||||
|
|
||||||
|
search_tool = Tool(
|
||||||
|
name="search_regulations",
|
||||||
|
description="Search the compliance knowledge base for relevant regulation clauses.",
|
||||||
|
parameters=ToolParameter(
|
||||||
|
properties={"query": {"type": "string", "description": "Search query"}},
|
||||||
|
required=["query"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
response = client.chat(messages, tools=[search_tool])
|
||||||
|
"""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
description: str
|
||||||
|
parameters: ToolParameter = field(default_factory=ToolParameter)
|
||||||
|
|
||||||
|
def to_openai_format(self) -> dict[str, Any]:
|
||||||
|
"""Serialise this tool to the OpenAI-compatible function-calling schema.
|
||||||
|
|
||||||
|
The returned dict can be placed directly in the ``tools`` list of a chat
|
||||||
|
completions request without any further transformation.
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": self.name,
|
||||||
|
"description": self.description,
|
||||||
|
"parameters": {
|
||||||
|
"type": self.parameters.type,
|
||||||
|
"properties": self.parameters.properties,
|
||||||
|
"required": self.parameters.required,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,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)
|
||||||
@@ -2,10 +2,14 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
from typing import Callable
|
from typing import Callable
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
from app.application.agent import AgentConversationService, AgentSessionService
|
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.documents import DocumentCommandService, DocumentQueryService
|
||||||
from app.application.knowledge import KnowledgeRetrievalService
|
from app.application.knowledge import KnowledgeRetrievalService
|
||||||
from app.application.perception.services import PerceptionService
|
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.local_document_parser import LocalDocumentParser
|
||||||
from app.infrastructure.parser.vector_chunk_builder import AliyunVectorChunkBuilder
|
from app.infrastructure.parser.vector_chunk_builder import AliyunVectorChunkBuilder
|
||||||
from app.infrastructure.perception.mock_event_store import MockEventStore
|
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.application.perception.crawl_service import CrawlService
|
||||||
from app.infrastructure.perception.base_event_store import BaseEventStore
|
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.catarc_crawler import CatarcCrawler
|
||||||
from app.infrastructure.perception.crawlers.guobiao_crawler import (
|
from app.infrastructure.perception.crawlers.guobiao_crawler import (
|
||||||
GuobiaoMandatoryCrawler,
|
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_processing_store import PostgresDocumentProcessingStore
|
||||||
from app.infrastructure.storage.postgres_document_repository import PostgresDocumentRepository
|
from app.infrastructure.storage.postgres_document_repository import PostgresDocumentRepository
|
||||||
from app.infrastructure.storage.postgres_parse_artifact_store import PostgresParseArtifactStore
|
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.bm25_retriever import BM25Retriever
|
||||||
from app.infrastructure.vectorstore.cross_encoder_reranker import OpenAICompatibleReranker
|
from app.infrastructure.vectorstore.cross_encoder_reranker import OpenAICompatibleReranker
|
||||||
from app.infrastructure.vectorstore.dense_retriever import DenseRetriever
|
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.services.llm.llm_factory import LLMFactory
|
||||||
from app.domain.compliance.ports import ComplianceRepository
|
from app.domain.compliance.ports import ComplianceRepository
|
||||||
from app.infrastructure.compliance.repository import PostgresComplianceRepository
|
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.
|
# Keep shared wiring centralized so dependency construction remains consistent.
|
||||||
|
|
||||||
|
|
||||||
@@ -161,6 +169,14 @@ def get_parse_artifact_store():
|
|||||||
return None
|
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
|
@lru_cache
|
||||||
def get_document_processing_store():
|
def get_document_processing_store():
|
||||||
"""Return document processing store for the active repository backend."""
|
"""Return document processing store for the active repository backend."""
|
||||||
@@ -313,6 +329,22 @@ def get_event_store() -> BaseEventStore:
|
|||||||
return MockEventStore()
|
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
|
@lru_cache
|
||||||
def get_compliance_repository() -> ComplianceRepository:
|
def get_compliance_repository() -> ComplianceRepository:
|
||||||
"""Return the compliance analysis repository.
|
"""Return the compliance analysis repository.
|
||||||
@@ -356,6 +388,9 @@ def get_crawl_service() -> CrawlService:
|
|||||||
event_store=get_event_store(),
|
event_store=get_event_store(),
|
||||||
llm_pipeline=LlmPipeline(),
|
llm_pipeline=LlmPipeline(),
|
||||||
retrieval_service=get_retrieval_service(),
|
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())
|
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
|
@lru_cache
|
||||||
def get_celery_app():
|
def get_celery_app():
|
||||||
"""Return the shared Celery application instance.
|
"""Return the shared Celery application instance.
|
||||||
@@ -397,8 +446,67 @@ def get_user_store():
|
|||||||
def preload_runtime_dependencies() -> None:
|
def preload_runtime_dependencies() -> None:
|
||||||
"""Warm dependencies that are safe and useful to preload during startup."""
|
"""Warm dependencies that are safe and useful to preload during startup."""
|
||||||
LLMFactory.preload_clients(["qwen", "deepseek"])
|
LLMFactory.preload_clients(["qwen", "deepseek"])
|
||||||
|
_start_model_usage_persistence()
|
||||||
|
|
||||||
|
|
||||||
def cleanup_runtime_dependencies() -> None:
|
def cleanup_runtime_dependencies() -> None:
|
||||||
"""Release runtime dependencies that expose explicit cleanup hooks."""
|
"""Release runtime dependencies that expose explicit cleanup hooks."""
|
||||||
LLMFactory.cleanup()
|
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)
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -2,6 +2,10 @@
|
|||||||
fastapi>=0.110.0
|
fastapi>=0.110.0
|
||||||
uvicorn[standard]>=0.27.0
|
uvicorn[standard]>=0.27.0
|
||||||
python-multipart>=0.0.9
|
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 ────────────────────────────────────────────────────────
|
# ── Config & utilities ────────────────────────────────────────────────────────
|
||||||
pydantic>=2.0.0
|
pydantic>=2.0.0
|
||||||
@@ -13,6 +17,11 @@ beautifulsoup4>=4.12.0
|
|||||||
lxml>=5.0.0
|
lxml>=5.0.0
|
||||||
tiktoken>=0.5.0
|
tiktoken>=0.5.0
|
||||||
tenacity>=8.2.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 ──────────────────────────────────────────────────────────────────────
|
# ── Auth ──────────────────────────────────────────────────────────────────────
|
||||||
python-jose[cryptography]>=3.3.0
|
python-jose[cryptography]>=3.3.0
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -6,6 +6,7 @@ import pytest
|
|||||||
|
|
||||||
from app.infrastructure.perception.crawlers.base import RawEvent
|
from app.infrastructure.perception.crawlers.base import RawEvent
|
||||||
from app.infrastructure.perception.mock_event_store import MockEventStore
|
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"):
|
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):
|
def _make_service(raw_events):
|
||||||
from app.application.perception.crawl_service import CrawlService
|
from app.application.perception.crawl_service import CrawlService
|
||||||
|
|
||||||
mock_crawler = MagicMock()
|
mock_crawler = _make_crawler(raw_events)
|
||||||
mock_crawler.fetch.return_value = raw_events
|
|
||||||
|
|
||||||
mock_pipeline = MagicMock()
|
mock_pipeline = MagicMock()
|
||||||
mock_pipeline.extract_structure.return_value = {
|
mock_pipeline.extract_structure.return_value = {
|
||||||
@@ -41,6 +49,9 @@ def _make_service(raw_events):
|
|||||||
event_store=store,
|
event_store=store,
|
||||||
llm_pipeline=mock_pipeline,
|
llm_pipeline=mock_pipeline,
|
||||||
retrieval_service=mock_retrieval,
|
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():
|
def test_crawl_upserts_to_store():
|
||||||
store = MockEventStore()
|
store = MockEventStore()
|
||||||
from app.application.perception.crawl_service import CrawlService
|
from app.application.perception.crawl_service import CrawlService
|
||||||
mock_crawler = MagicMock()
|
mock_crawler = _make_crawler([_make_raw_event("NEW-001")])
|
||||||
mock_crawler.fetch.return_value = [_make_raw_event("NEW-001")]
|
|
||||||
mock_pipeline = MagicMock()
|
mock_pipeline = MagicMock()
|
||||||
mock_pipeline.extract_structure.return_value = {
|
mock_pipeline.extract_structure.return_value = {
|
||||||
"obligations": [], "deadlines": [], "scope": "",
|
"obligations": [], "deadlines": [], "scope": "",
|
||||||
@@ -70,6 +80,9 @@ def test_crawl_upserts_to_store():
|
|||||||
event_store=store,
|
event_store=store,
|
||||||
llm_pipeline=mock_pipeline,
|
llm_pipeline=mock_pipeline,
|
||||||
retrieval_service=MagicMock(),
|
retrieval_service=MagicMock(),
|
||||||
|
notification_store=MockNotificationStore(),
|
||||||
|
embedding_provider=MagicMock(),
|
||||||
|
vector_index=MagicMock(),
|
||||||
)
|
)
|
||||||
list(svc.run_crawl())
|
list(svc.run_crawl())
|
||||||
result = store.get_by_standard_code("NEW-001")
|
result = store.get_by_standard_code("NEW-001")
|
||||||
@@ -80,7 +93,8 @@ def test_crawl_upserts_to_store():
|
|||||||
def test_crawl_skips_unchanged_events():
|
def test_crawl_skips_unchanged_events():
|
||||||
store = MockEventStore()
|
store = MockEventStore()
|
||||||
raw = _make_raw_event("SKIP-001")
|
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({
|
store.upsert({
|
||||||
"id": hashlib.sha256(f"TEST-SKIP-001".encode()).hexdigest()[:12],
|
"id": hashlib.sha256(f"TEST-SKIP-001".encode()).hexdigest()[:12],
|
||||||
"standard_code": "SKIP-001",
|
"standard_code": "SKIP-001",
|
||||||
@@ -99,13 +113,341 @@ def test_crawl_skips_unchanged_events():
|
|||||||
})
|
})
|
||||||
mock_pipeline = MagicMock()
|
mock_pipeline = MagicMock()
|
||||||
from app.application.perception.crawl_service import CrawlService
|
from app.application.perception.crawl_service import CrawlService
|
||||||
mock_crawler = MagicMock()
|
mock_crawler = _make_crawler([raw], full_text=body)
|
||||||
mock_crawler.fetch.return_value = [raw]
|
|
||||||
svc = CrawlService(
|
svc = CrawlService(
|
||||||
crawlers={"TEST": mock_crawler},
|
crawlers={"TEST": mock_crawler},
|
||||||
event_store=store,
|
event_store=store,
|
||||||
llm_pipeline=mock_pipeline,
|
llm_pipeline=mock_pipeline,
|
||||||
retrieval_service=MagicMock(),
|
retrieval_service=MagicMock(),
|
||||||
|
notification_store=MockNotificationStore(),
|
||||||
|
embedding_provider=MagicMock(),
|
||||||
|
vector_index=MagicMock(),
|
||||||
)
|
)
|
||||||
list(svc.run_crawl())
|
list(svc.run_crawl())
|
||||||
mock_pipeline.extract_structure.assert_not_called()
|
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
|
||||||
|
|||||||
@@ -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 __future__ import annotations
|
||||||
|
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
import json
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
|
|
||||||
def _make_pipeline():
|
def _make_pipeline(content: str | None = None):
|
||||||
with patch("app.infrastructure.perception.llm_pipeline.get_llm_client") as mock_llm_fn, \
|
"""Build a pipeline whose LLM client is a mock returning `content`."""
|
||||||
patch("app.infrastructure.perception.llm_pipeline.OpenAICompatibleEmbeddingProvider") as mock_emb_cls:
|
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 = 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_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
|
from app.infrastructure.perception.llm_pipeline import LlmPipeline
|
||||||
return LlmPipeline(), mock_client, mock_emb
|
return LlmPipeline(), mock_client
|
||||||
|
|
||||||
|
|
||||||
def test_extract_structure_returns_dict():
|
def test_extract_structure_returns_dict():
|
||||||
pipeline, mock_client, _ = _make_pipeline()
|
"""Structure extraction still returns the enrichment keys callers expect."""
|
||||||
|
pipeline, _ = _make_pipeline()
|
||||||
event = {
|
event = {
|
||||||
"id": "evt-001",
|
"id": "evt-001",
|
||||||
"standard_code": "GB 18384-2025",
|
"standard_code": "GB 18384-2025",
|
||||||
@@ -38,8 +44,11 @@ def test_extract_structure_returns_dict():
|
|||||||
|
|
||||||
|
|
||||||
def test_assess_impact_returns_list():
|
def test_assess_impact_returns_list():
|
||||||
pipeline, mock_client, _ = _make_pipeline()
|
"""Impact assessment still returns a list of affected documents."""
|
||||||
mock_client.chat.return_value = MagicMock(content='[{"doc_id":"d1","doc_name":"Safety Manual","score":0.85,"key_clauses":"§4.2","recommendation":"更新第4章"}]')
|
pipeline, _ = _make_pipeline(
|
||||||
|
'[{"doc_id":"d1","doc_name":"Safety Manual","score":0.85,'
|
||||||
|
'"key_clauses":"§4.2","recommendation":"更新第4章"}]'
|
||||||
|
)
|
||||||
mock_retrieval = MagicMock()
|
mock_retrieval = MagicMock()
|
||||||
chunk = MagicMock()
|
chunk = MagicMock()
|
||||||
chunk.doc_id = "d1"
|
chunk.doc_id = "d1"
|
||||||
@@ -53,25 +62,103 @@ def test_assess_impact_returns_list():
|
|||||||
"title": "电动汽车安全要求",
|
"title": "电动汽车安全要求",
|
||||||
"obligations": [{"text": "OEM shall comply"}],
|
"obligations": [{"text": "OEM shall comply"}],
|
||||||
}
|
}
|
||||||
result = pipeline.assess_impact(event, mock_retrieval)
|
assert isinstance(pipeline.assess_impact(event, mock_retrieval), list)
|
||||||
assert isinstance(result, list)
|
|
||||||
|
|
||||||
|
|
||||||
def test_compute_diff_no_change():
|
def test_compute_diff_no_change_costs_no_llm_call():
|
||||||
pipeline, _, mock_emb = _make_pipeline()
|
"""Identical text must short-circuit before reaching the model."""
|
||||||
mock_emb.embed_texts.return_value = [[0.5] * 1024, [0.5] * 1024]
|
pipeline, mock_client = _make_pipeline()
|
||||||
result = pipeline.compute_diff("paragraph one", "paragraph one")
|
mock_client.chat.reset_mock()
|
||||||
assert isinstance(result, dict)
|
|
||||||
assert "changed_sections" in result
|
result = pipeline.compute_diff("第一条 保持不变的条款。", "第一条 保持不变的条款。")
|
||||||
assert "change_summary" in result
|
|
||||||
|
assert result["changed_sections"] == []
|
||||||
|
assert "No substantive changes" in result["change_summary"]
|
||||||
|
mock_client.chat.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
def test_compute_diff_detects_change():
|
def test_compute_diff_classifies_a_real_change():
|
||||||
pipeline, mock_client, mock_emb = _make_pipeline()
|
"""A gated change is classified and the model's legal_effect is surfaced."""
|
||||||
mock_emb.embed_texts.return_value = [
|
pipeline, _ = _make_pipeline(
|
||||||
[1.0] + [0.0] * 1023,
|
'{"change_type":"tightened","legal_effect":"Requirement tightened."}'
|
||||||
[0.0] + [1.0] + [0.0] * 1022,
|
)
|
||||||
]
|
result = pipeline.compute_diff(
|
||||||
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)
|
)
|
||||||
|
|
||||||
|
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
|
from unittest.mock import MagicMock, patch
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
# Patch psycopg2 before importing the module under test
|
# psycopg2 is mocked centrally in backend/tests/conftest.py, so importing the
|
||||||
import sys
|
# module under test here never binds the real driver.
|
||||||
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())
|
|
||||||
|
|
||||||
from app.infrastructure.perception.base_event_store import BaseEventStore
|
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
|
```text
|
||||||
backend/app/
|
backend/app/
|
||||||
api/
|
api/
|
||||||
|
mcp/
|
||||||
application/
|
application/
|
||||||
documents/
|
documents/
|
||||||
knowledge/
|
knowledge/
|
||||||
@@ -314,6 +315,53 @@ backend/app/
|
|||||||
- `backend/app/shared/bootstrap.py` 是现阶段的 composition root,负责把端口实现、基础设施适配器和 application service 连接起来。
|
- `backend/app/shared/bootstrap.py` 是现阶段的 composition root,负责把端口实现、基础设施适配器和 application service 连接起来。
|
||||||
- 后续如果新增 wiring 入口,应继续保持在同一类装配边界内,不要把依赖装配拆回各个路由或 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. Module Responsibilities
|
||||||
|
|
||||||
### 5.1 `api`
|
### 5.1 `api`
|
||||||
@@ -637,6 +685,7 @@ infrastructure -> external systems
|
|||||||
具体规则如下:
|
具体规则如下:
|
||||||
|
|
||||||
- `api` 可以依赖 `application` 和 API 自己的 request/response models
|
- `api` 可以依赖 `application` 和 API 自己的 request/response models
|
||||||
|
- `mcp` 与 `api` 同级,只能依赖 `application` 和 composition root,不能依赖 `infrastructure` 或反过来被 `application` 依赖
|
||||||
- `application` 只能依赖 `domain`、端口接口,以及通过 composition root 注入进来的实现实例
|
- `application` 只能依赖 `domain`、端口接口,以及通过 composition root 注入进来的实现实例
|
||||||
- `domain` 不能依赖 `api` 或 `infrastructure`
|
- `domain` 不能依赖 `api` 或 `infrastructure`
|
||||||
- `infrastructure` 可以依赖 `domain` 定义的端口和数据模型,但不能反向驱动 application 逻辑
|
- `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 1–2). 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 1–20, 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 1–2000 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 (+15–25% 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 1–2000 characters and `top_k` as 1–20 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`.
|
||||||
@@ -73,6 +73,22 @@ export interface SSEMessage {
|
|||||||
text?: string;
|
text?: string;
|
||||||
docs?: RetrievedDoc[];
|
docs?: RetrievedDoc[];
|
||||||
session_id?: string;
|
session_id?: string;
|
||||||
|
// ── P0-1 Agentic-mode thinking-step fields ────────────────────────────────
|
||||||
|
// Populated when type === 'thinking'; maps to the backend IntentResult /
|
||||||
|
// GroundingResult / retrieval step payloads emitted by AgenticConversationService.
|
||||||
|
step?: string; // intent_analysis | query_planning | retrieving | grounding_check
|
||||||
|
status?: string; // running | done
|
||||||
|
intent_type?: string; // simple_qa | compare | multi_hop | ambiguous
|
||||||
|
requires_decomposition?: boolean;
|
||||||
|
reason?: string;
|
||||||
|
sub_queries?: string[];
|
||||||
|
query?: string; // sub-query being retrieved
|
||||||
|
index?: number; // 1-based sub-query index
|
||||||
|
total?: number; // total sub-query count
|
||||||
|
found?: number; // chunks found for this sub-query
|
||||||
|
retry?: boolean; // true when this is a grounding-failure re-query
|
||||||
|
sufficient?: boolean; // grounding check result
|
||||||
|
confidence?: number; // grounding confidence 0–1
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function streamSSE<TMessage extends SSEMessage>(
|
export async function streamSSE<TMessage extends SSEMessage>(
|
||||||
@@ -294,4 +310,45 @@ export interface SystemHealth {
|
|||||||
sessions: { active: number; max: number };
|
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 };
|
export { API_BASE_URL };
|
||||||
|
|||||||
@@ -52,6 +52,39 @@ export interface AnalysisSSEMessage {
|
|||||||
text?: string;
|
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> {
|
export async function getPerceptionStats(): Promise<PerceptionStats> {
|
||||||
const res = await fetch(`${PERCEPTION_API_BASE}/perception/stats`, { headers: authHeader() });
|
const res = await fetch(`${PERCEPTION_API_BASE}/perception/stats`, { headers: authHeader() });
|
||||||
if (!res.ok) throw new Error(`stats failed: ${res.status}`);
|
if (!res.ok) throw new Error(`stats failed: ${res.status}`);
|
||||||
|
|||||||
@@ -76,6 +76,27 @@ function parseSSEChunk(raw: string, onMessage: (data: SSEMessage) => void) {
|
|||||||
onMessage({ type: 'error', text: joined });
|
onMessage({ type: 'error', text: joined });
|
||||||
} else if (eventName === 'status') {
|
} else if (eventName === 'status') {
|
||||||
onMessage({ type: 'status', text: joined });
|
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') {
|
} else if (eventName === 'message') {
|
||||||
// /rag/chat format: event:message + JSON body with type field
|
// /rag/chat format: event:message + JSON body with type field
|
||||||
try {
|
try {
|
||||||
@@ -147,3 +168,73 @@ export async function ragChat(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type { QuickQuestionsResponse, SSEMessage };
|
export type { QuickQuestionsResponse, SSEMessage };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* P0-1 Agentic RAG chat — calls /agent/agentic/stream which runs the full
|
||||||
|
* intent-analysis → query-planning → retrieval → grounding-check → answer pipeline.
|
||||||
|
*
|
||||||
|
* The onMessage callback receives the same event types as ragChat plus
|
||||||
|
* ``type: 'thinking'`` events that carry live reasoning-step progress.
|
||||||
|
*/
|
||||||
|
export async function agenticChat(
|
||||||
|
query: string,
|
||||||
|
topK: number = 5,
|
||||||
|
onMessage: (data: SSEMessage) => void,
|
||||||
|
onError?: (error: Error) => void,
|
||||||
|
onComplete?: () => void,
|
||||||
|
filters?: string,
|
||||||
|
sessionId?: string,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
contextText?: string,
|
||||||
|
contextFilename?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${AGENT_API_BASE}/agent/agentic/stream`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Accept: 'text/event-stream',
|
||||||
|
...(getToken() ? { Authorization: `Bearer ${getToken()}` } : {}),
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
query,
|
||||||
|
top_k: topK,
|
||||||
|
...(filters ? { filters } : {}),
|
||||||
|
...(sessionId ? { session_id: sessionId } : {}),
|
||||||
|
...(contextText ? { context_text: contextText, context_filename: contextFilename ?? '' } : {}),
|
||||||
|
}),
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok || !response.body) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = '';
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
const parts = buffer.split('\n\n');
|
||||||
|
buffer = parts.pop() || '';
|
||||||
|
parseSSEChunk(parts.join('\n\n'), onMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (buffer.trim()) {
|
||||||
|
parseSSEChunk(buffer, onMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onComplete) {
|
||||||
|
onComplete();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof DOMException && error.name === 'AbortError') return;
|
||||||
|
if (onError) {
|
||||||
|
onError(error instanceof Error ? error : new Error(String(error)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { fetchAPI, type SystemConfig, type SystemHealth, type SystemStats } from './index';
|
import { fetchAPI, type MCPStatusResponse, type ModelUsageResponse, type SystemConfig, type SystemHealth, type SystemStats } from './index';
|
||||||
|
|
||||||
export async function getSystemStats(): Promise<SystemStats> {
|
export async function getSystemStats(): Promise<SystemStats> {
|
||||||
return fetchAPI<SystemStats>('/status/stats');
|
return fetchAPI<SystemStats>('/status/stats');
|
||||||
@@ -12,4 +12,19 @@ export async function getSystemHealth(): Promise<SystemHealth> {
|
|||||||
return fetchAPI<SystemHealth>('/status/health');
|
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 };
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
import { NavLink } from 'react-router-dom';
|
import { NavLink } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
LayoutDashboard, Radio, Monitor, FileText,
|
LayoutDashboard, Radio, Monitor, FileText,
|
||||||
@@ -6,6 +7,12 @@ import {
|
|||||||
import { useTheme } from '../../contexts/ThemeContext';
|
import { useTheme } from '../../contexts/ThemeContext';
|
||||||
import { useAuth } from '../../contexts/AuthContext';
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
import { useLanguage } from '../../contexts/LanguageContext';
|
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 {
|
interface NavItem {
|
||||||
to: string;
|
to: string;
|
||||||
@@ -47,10 +54,24 @@ export function Sidebar() {
|
|||||||
const { theme, toggleTheme } = useTheme();
|
const { theme, toggleTheme } = useTheme();
|
||||||
const { user, logout } = useAuth();
|
const { user, logout } = useAuth();
|
||||||
const { lang, t, toggleLang } = useLanguage();
|
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[] = [
|
const mainNav: NavItem[] = [
|
||||||
{ to: '/', icon: <LayoutDashboard size={16} />, label: t.nav.overview },
|
{ 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 },
|
{ to: '/status', icon: <Monitor size={16} />, label: t.nav.status },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { createContext, useContext, useState, useCallback, useRef } from 'react';
|
import React, { createContext, useContext, useState, useCallback, useRef } from 'react';
|
||||||
|
import { COMPLIANCE_INIT } from './pageStateDefaults';
|
||||||
|
|
||||||
// ── RagChat types ─────────────────────────────────────────────────────────────
|
// ── RagChat types ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -59,6 +60,8 @@ export interface ComplianceSourceEvent {
|
|||||||
score: number;
|
score: number;
|
||||||
status: string;
|
status: string;
|
||||||
full_content: string;
|
full_content: string;
|
||||||
|
/** Index of the clause this source was retrieved for (for source↔finding linking) */
|
||||||
|
clause_index?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ComplianceFindingEvent {
|
export interface ComplianceFindingEvent {
|
||||||
@@ -66,6 +69,17 @@ export interface ComplianceFindingEvent {
|
|||||||
desc: string;
|
desc: string;
|
||||||
status: 'ok' | 'warn' | 'risk';
|
status: 'ok' | 'warn' | 'risk';
|
||||||
clause_ref?: string;
|
clause_ref?: string;
|
||||||
|
/** LLM confidence that retrieved context covers the clause topic (0–1) */
|
||||||
|
confidence?: number;
|
||||||
|
/** Top-3 regulation chunks that informed this finding */
|
||||||
|
source_refs?: Array<{ standard: string; clause: string; score: number }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ComplianceConflict {
|
||||||
|
type: 'contradiction' | 'missing_ref' | 'cumulative_risk';
|
||||||
|
finding_a: number;
|
||||||
|
finding_b: number | null;
|
||||||
|
desc: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ComplianceActionItem {
|
export interface ComplianceActionItem {
|
||||||
@@ -103,22 +117,12 @@ export interface ComplianceState {
|
|||||||
analysisId: string | null;
|
analysisId: string | null;
|
||||||
isReadOnly: boolean;
|
isReadOnly: boolean;
|
||||||
activeFindingId: string | null;
|
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 ──────────────────────────────────────────────────────────
|
// ── Perception types ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface PerceptionSignal {
|
export interface PerceptionSignal {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ export { ThemeProvider, useTheme } from './ThemeContext';
|
|||||||
export { AuthProvider, useAuth } from './AuthContext';
|
export { AuthProvider, useAuth } from './AuthContext';
|
||||||
export type { AuthUser } from './AuthContext';
|
export type { AuthUser } from './AuthContext';
|
||||||
export { PageStateProvider, usePageState } from './PageStateContext';
|
export { PageStateProvider, usePageState } from './PageStateContext';
|
||||||
|
export { COMPLIANCE_INIT } from './pageStateDefaults';
|
||||||
export { LanguageProvider, useLanguage } from './LanguageContext';
|
export { LanguageProvider, useLanguage } from './LanguageContext';
|
||||||
export type { Lang } from './LanguageContext';
|
export type { Lang } from './LanguageContext';
|
||||||
export type {
|
export type {
|
||||||
@@ -12,6 +13,7 @@ export type {
|
|||||||
ComplianceStatus,
|
ComplianceStatus,
|
||||||
ComplianceSourceEvent,
|
ComplianceSourceEvent,
|
||||||
ComplianceFindingEvent,
|
ComplianceFindingEvent,
|
||||||
|
ComplianceConflict,
|
||||||
ComplianceDonePayload,
|
ComplianceDonePayload,
|
||||||
ComplianceMeta,
|
ComplianceMeta,
|
||||||
ComplianceActionItem,
|
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: [],
|
||||||
|
};
|
||||||
@@ -116,6 +116,7 @@ export interface Translations {
|
|||||||
labelChunkBackend: string;
|
labelChunkBackend: string;
|
||||||
labelParserFailureMode: string;
|
labelParserFailureMode: string;
|
||||||
configLoadError: string;
|
configLoadError: string;
|
||||||
|
modelsLoadError: string;
|
||||||
cardBreakdown: string;
|
cardBreakdown: string;
|
||||||
breakdownIndexed: string;
|
breakdownIndexed: string;
|
||||||
breakdownProcessing: string;
|
breakdownProcessing: string;
|
||||||
@@ -131,6 +132,30 @@ export interface Translations {
|
|||||||
footerDegraded: string;
|
footerDegraded: string;
|
||||||
footerChecking: string;
|
footerChecking: string;
|
||||||
totalChunks: 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: {
|
docs: {
|
||||||
topbarTitle: string;
|
topbarTitle: string;
|
||||||
@@ -226,6 +251,36 @@ export interface Translations {
|
|||||||
citationsHeader: string;
|
citationsHeader: string;
|
||||||
citationsEmpty: string;
|
citationsEmpty: string;
|
||||||
apiError: 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',
|
labelChunkBackend: 'Chunk backend',
|
||||||
labelParserFailureMode: 'Parser failure mode',
|
labelParserFailureMode: 'Parser failure mode',
|
||||||
configLoadError: 'Could not load config',
|
configLoadError: 'Could not load config',
|
||||||
|
modelsLoadError: 'Could not load model status',
|
||||||
cardBreakdown: 'Document breakdown',
|
cardBreakdown: 'Document breakdown',
|
||||||
breakdownIndexed: 'Indexed',
|
breakdownIndexed: 'Indexed',
|
||||||
breakdownProcessing: 'Processing / Parsed',
|
breakdownProcessing: 'Processing / Parsed',
|
||||||
@@ -361,6 +417,30 @@ export const en: Translations = {
|
|||||||
footerDegraded: 'Degraded',
|
footerDegraded: 'Degraded',
|
||||||
footerChecking: 'Checking…',
|
footerChecking: 'Checking…',
|
||||||
totalChunks: 'Total vector chunks',
|
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: {
|
docs: {
|
||||||
topbarTitle: 'Document Management',
|
topbarTitle: 'Document Management',
|
||||||
@@ -456,5 +536,35 @@ export const en: Translations = {
|
|||||||
citationsHeader: 'Sources',
|
citationsHeader: 'Sources',
|
||||||
citationsEmpty: 'Citations will appear here after a response is generated.',
|
citationsEmpty: 'Citations will appear here after a response is generated.',
|
||||||
apiError: 'Could not reach the RAG API. Please check the backend.',
|
apiError: 'Could not reach the RAG API. Please check the backend.',
|
||||||
|
// ── Agentic mode ─────────────────────────────────────────────────────────
|
||||||
|
agenticMode: 'Agentic mode',
|
||||||
|
agenticModeHint: 'Intent · Planning · Retrieval · Grounding',
|
||||||
|
agentThinking: 'Agent reasoning…',
|
||||||
|
agentDone: 'Reasoning complete',
|
||||||
|
stepSuffix: 'steps',
|
||||||
|
stepIntentAnalysis: 'Intent analysis',
|
||||||
|
stepQueryPlanning: 'Query planning',
|
||||||
|
stepRetrieving: 'Knowledge retrieval',
|
||||||
|
stepGrounding: 'Citation grounding',
|
||||||
|
intentSimpleQa: 'Simple Q&A',
|
||||||
|
intentCompare: 'Comparison',
|
||||||
|
intentMultiHop: 'Multi-hop',
|
||||||
|
intentAmbiguous: 'Ambiguous',
|
||||||
|
intentNeedsDecomposition: 'Decomposed',
|
||||||
|
subQueriesCountSuffix: 'sub-queries',
|
||||||
|
chunksFoundSuffix: 'chunks',
|
||||||
|
retryLabel: '(retry) ',
|
||||||
|
groundingSufficient: '✓ Sufficient',
|
||||||
|
groundingInsufficient: '⚠ Re-queried',
|
||||||
|
// ── Document context attachment ───────────────────────────────────────────
|
||||||
|
attachBtn: 'Attach document as context',
|
||||||
|
attachExtracting: 'Extracting text…',
|
||||||
|
attachReady: 'Context loaded',
|
||||||
|
attachError: 'Extraction failed',
|
||||||
|
attachClearLabel: 'Clear',
|
||||||
|
attachContextBadge: 'Doc context',
|
||||||
|
attachAccept: '.pdf,.docx,.doc,.txt,.md',
|
||||||
|
attachTruncated: '(truncated to 8 000 chars)',
|
||||||
|
attachErrorMsg: 'Could not extract text from this file.',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -117,6 +117,7 @@ export const zh: Translations = {
|
|||||||
labelChunkBackend: '分块后端',
|
labelChunkBackend: '分块后端',
|
||||||
labelParserFailureMode: '解析失败模式',
|
labelParserFailureMode: '解析失败模式',
|
||||||
configLoadError: '无法加载配置',
|
configLoadError: '无法加载配置',
|
||||||
|
modelsLoadError: '无法加载模型状态',
|
||||||
cardBreakdown: '文档分布',
|
cardBreakdown: '文档分布',
|
||||||
breakdownIndexed: '已索引',
|
breakdownIndexed: '已索引',
|
||||||
breakdownProcessing: '处理中 / 已解析',
|
breakdownProcessing: '处理中 / 已解析',
|
||||||
@@ -132,6 +133,30 @@ export const zh: Translations = {
|
|||||||
footerDegraded: '降级运行',
|
footerDegraded: '降级运行',
|
||||||
footerChecking: '检查中…',
|
footerChecking: '检查中…',
|
||||||
totalChunks: '向量分块总数',
|
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: {
|
docs: {
|
||||||
topbarTitle: '文档管理',
|
topbarTitle: '文档管理',
|
||||||
@@ -227,5 +252,35 @@ export const zh: Translations = {
|
|||||||
citationsHeader: '引用来源',
|
citationsHeader: '引用来源',
|
||||||
citationsEmpty: '生成回答后,引用来源将显示在此处。',
|
citationsEmpty: '生成回答后,引用来源将显示在此处。',
|
||||||
apiError: '无法连接到 RAG API,请检查后端服务。',
|
apiError: '无法连接到 RAG API,请检查后端服务。',
|
||||||
|
// ── Agentic mode ─────────────────────────────────────────────────────────
|
||||||
|
agenticMode: 'Agentic 模式',
|
||||||
|
agenticModeHint: '意图分析 · 查询分解 · 迭代检索 · 引文锚定',
|
||||||
|
agentThinking: 'Agent 推理中…',
|
||||||
|
agentDone: '推理完成',
|
||||||
|
stepSuffix: '步',
|
||||||
|
stepIntentAnalysis: '意图分析',
|
||||||
|
stepQueryPlanning: '查询分解',
|
||||||
|
stepRetrieving: '知识检索',
|
||||||
|
stepGrounding: '引文锚定',
|
||||||
|
intentSimpleQa: '单跳问答',
|
||||||
|
intentCompare: '对比分析',
|
||||||
|
intentMultiHop: '多跳推理',
|
||||||
|
intentAmbiguous: '模糊查询',
|
||||||
|
intentNeedsDecomposition: '需分解',
|
||||||
|
subQueriesCountSuffix: '个子查询',
|
||||||
|
chunksFoundSuffix: '条',
|
||||||
|
retryLabel: '(补充) ',
|
||||||
|
groundingSufficient: '✓ 充分',
|
||||||
|
groundingInsufficient: '⚠ 补充检索',
|
||||||
|
// ── Document context attachment ───────────────────────────────────────────
|
||||||
|
attachBtn: '上传文档作为对话上下文',
|
||||||
|
attachExtracting: '正在提取文本…',
|
||||||
|
attachReady: '上下文已加载',
|
||||||
|
attachError: '提取失败',
|
||||||
|
attachClearLabel: '清除',
|
||||||
|
attachContextBadge: '文档上下文',
|
||||||
|
attachAccept: '.pdf,.docx,.doc,.txt,.md',
|
||||||
|
attachTruncated: '(已截断至 8000 字符)',
|
||||||
|
attachErrorMsg: '无法从该文件提取文本,请检查文件格式。',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { useState, useRef, useEffect } from 'react';
|
import { useState, useRef, useEffect } from 'react';
|
||||||
import { useLanguage } from '../../contexts/LanguageContext';
|
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 { Topbar } from '../../components/layout/Topbar';
|
||||||
import { NewAnalysisModal } from './NewAnalysisModal';
|
import { NewAnalysisModal } from './NewAnalysisModal';
|
||||||
import { useComplianceAnalysis } from './useComplianceAnalysis';
|
import { useComplianceAnalysis } from './useComplianceAnalysis';
|
||||||
import { usePageState } from '../../contexts';
|
import { usePageState } from '../../contexts';
|
||||||
import { HistoryRail } from './HistoryRail';
|
import { HistoryRail } from './HistoryRail';
|
||||||
import { FindingChatDrawer } from './FindingChatDrawer';
|
import { FindingChatDrawer } from './FindingChatDrawer';
|
||||||
import type { FindingEvent, SourceEvent, AnalysisMeta } from './useComplianceAnalysis';
|
import type { FindingEvent, SourceEvent } from './useComplianceAnalysis';
|
||||||
|
|
||||||
const TOKEN_KEY = 'auth_token';
|
const TOKEN_KEY = 'auth_token';
|
||||||
function authHeader(): Record<string, string> {
|
function authHeader(): Record<string, string> {
|
||||||
@@ -39,81 +39,8 @@ function formatTs(iso: string) {
|
|||||||
} catch { return iso; }
|
} catch { return iso; }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Chat state for a single finding ─────────────────────────────────────────
|
/** Wrapper that resolves findingIndex → findingId from the saved analysis, then renders FindingChatDrawer. */
|
||||||
interface ChatMsg { id: number; role: 'user' | 'assistant'; content: string }
|
function FindingChatDrawerWrapper({
|
||||||
|
|
||||||
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({
|
|
||||||
analysisId,
|
analysisId,
|
||||||
findingIndex,
|
findingIndex,
|
||||||
finding,
|
finding,
|
||||||
@@ -128,7 +55,7 @@ function _FindingChatDrawerWrapper({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch(`/api/v1/compliance/history/${analysisId}`, {
|
fetch(`/api/v1/compliance/history/${analysisId}`, {
|
||||||
headers: { Authorization: `Bearer ${localStorage.getItem('auth_token') ?? ''}` },
|
headers: authHeader(),
|
||||||
})
|
})
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then((data: { findings?: Array<{ seq: number; id: string }> }) => {
|
.then((data: { findings?: Array<{ seq: number; id: string }> }) => {
|
||||||
@@ -153,8 +80,8 @@ export function CompliancePage() {
|
|||||||
const [showModal, setShowModal] = useState(false);
|
const [showModal, setShowModal] = useState(false);
|
||||||
const [showExportMenu, setShowExportMenu] = useState(false);
|
const [showExportMenu, setShowExportMenu] = useState(false);
|
||||||
const { state, run, reset } = useComplianceAnalysis();
|
const { state, run, reset } = useComplianceAnalysis();
|
||||||
const chat = useFindingChat();
|
// drawerFinding holds {index, finding} for the currently-open FindingChatDrawer
|
||||||
const [drawerFindingIdx, setDrawerFindingIdx] = useState<number | null>(null);
|
const [drawerFinding, setDrawerFinding] = useState<{ idx: number; finding: FindingEvent } | null>(null);
|
||||||
|
|
||||||
const { setComplianceState } = usePageState();
|
const { setComplianceState } = usePageState();
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
@@ -198,6 +125,8 @@ export function CompliancePage() {
|
|||||||
analysisId: data.id,
|
analysisId: data.id,
|
||||||
isReadOnly: true,
|
isReadOnly: true,
|
||||||
activeFindingId: null,
|
activeFindingId: null,
|
||||||
|
progress: null,
|
||||||
|
conflicts: [],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -258,12 +187,6 @@ export function CompliancePage() {
|
|||||||
setShowExportMenu(false);
|
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 (
|
return (
|
||||||
<div className="compliance-page" style={{ position: 'relative' }}>
|
<div className="compliance-page" style={{ position: 'relative' }}>
|
||||||
<Topbar
|
<Topbar
|
||||||
@@ -457,6 +380,25 @@ export function CompliancePage() {
|
|||||||
<div className="comp-col findings-col">
|
<div className="comp-col findings-col">
|
||||||
<div className="col-header">
|
<div className="col-header">
|
||||||
Findings {state.findings.length > 0 && `(${state.findings.length})`}
|
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>
|
</div>
|
||||||
|
|
||||||
{state.findings.length === 0 && isStreaming && (
|
{state.findings.length === 0 && isStreaming && (
|
||||||
@@ -472,30 +414,85 @@ export function CompliancePage() {
|
|||||||
<span className={`status ${f.status}`}>{STATUS_LABEL[f.status] ?? f.status}</span>
|
<span className={`status ${f.status}`}>{STATUS_LABEL[f.status] ?? f.status}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="finding-desc">{f.desc}</p>
|
<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', justifyContent: 'space-between', marginTop: 6 }}>
|
||||||
{f.clause_ref && (
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
<div style={{ fontSize: 11, color: 'var(--muted)' }}>Ref: {f.clause_ref}</div>
|
{f.clause_ref && (
|
||||||
)}
|
<div style={{ fontSize: 11, color: 'var(--muted)' }}>Ref: {f.clause_ref}</div>
|
||||||
<button
|
)}
|
||||||
className="btn sm"
|
{/* Confidence dot: green ≥0.7, amber 0.4–0.7, red <0.4 */}
|
||||||
style={{ marginLeft: 'auto', fontSize: 11, padding: '3px 8px', gap: 4 }}
|
{f.confidence !== undefined && (
|
||||||
onClick={() => chat.openFor(i, f)}
|
<span
|
||||||
>
|
style={{
|
||||||
<MessageSquare size={11} />{t.compliance.askAIBtn}
|
fontSize: 10, color: 'var(--muted)',
|
||||||
</button>
|
display: 'inline-flex', alignItems: 'center', gap: 3,
|
||||||
{state.analysisId && (
|
}}
|
||||||
|
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
|
<button
|
||||||
className="btn sm"
|
className="btn sm"
|
||||||
onClick={() => setDrawerFindingIdx(i)}
|
style={{ marginLeft: 'auto', fontSize: 11, padding: '3px 8px', gap: 4 }}
|
||||||
style={{ marginTop: 6 }}
|
onClick={() => setDrawerFinding({ idx: i, finding: f })}
|
||||||
>
|
>
|
||||||
💬 {t.compliance.chatBtn}
|
<MessageSquare size={11} />{t.compliance.chatBtn}
|
||||||
</button>
|
</button>
|
||||||
|
) : (
|
||||||
|
/* Fallback for unsaved analyses: show disabled chat hint */
|
||||||
|
<span style={{ marginLeft: 'auto', fontSize: 10, color: 'var(--muted)' }}>
|
||||||
|
{t.compliance.askAIBtn}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* Conclusion */}
|
||||||
{isDone && state.done && (
|
{isDone && state.done && (
|
||||||
<div className="card conclusion-box">
|
<div className="card conclusion-box">
|
||||||
@@ -540,92 +537,18 @@ export function CompliancePage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Finding Chat Side Panel ────────────────────────────────── */}
|
{/* ── Finding Chat Drawer (single consolidated UI) ───────────── */}
|
||||||
{chat.open && (
|
{drawerFinding !== null && state.analysisId && (
|
||||||
<div style={{
|
<FindingChatDrawerWrapper
|
||||||
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
|
|
||||||
analysisId={state.analysisId}
|
analysisId={state.analysisId}
|
||||||
findingIndex={drawerFindingIdx}
|
findingIndex={drawerFinding.idx}
|
||||||
finding={{
|
finding={{
|
||||||
title: state.findings[drawerFindingIdx]?.title ?? '',
|
title: drawerFinding.finding.title,
|
||||||
desc: state.findings[drawerFindingIdx]?.desc ?? '',
|
desc: drawerFinding.finding.desc,
|
||||||
status: state.findings[drawerFindingIdx]?.status ?? 'ok',
|
status: drawerFinding.finding.status,
|
||||||
clause_ref: state.findings[drawerFindingIdx]?.clause_ref,
|
clause_ref: drawerFinding.finding.clause_ref,
|
||||||
}}
|
}}
|
||||||
onClose={() => setDrawerFindingIdx(null)}
|
onClose={() => setDrawerFinding(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -7,16 +7,17 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { usePageState } from '../../contexts';
|
import { usePageState, COMPLIANCE_INIT } from '../../contexts';
|
||||||
import type {
|
import type {
|
||||||
ComplianceMeta,
|
ComplianceMeta,
|
||||||
ComplianceState,
|
ComplianceState,
|
||||||
ComplianceSourceEvent,
|
ComplianceSourceEvent,
|
||||||
ComplianceFindingEvent,
|
ComplianceFindingEvent,
|
||||||
ComplianceDonePayload,
|
ComplianceDonePayload,
|
||||||
|
ComplianceConflict,
|
||||||
} from '../../contexts';
|
} 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 { ComplianceActionItem as ActionItem } from '../../contexts';
|
||||||
export type AnalysisStatus = import('../../contexts').ComplianceStatus;
|
export type AnalysisStatus = import('../../contexts').ComplianceStatus;
|
||||||
export type AnalysisMeta = ComplianceMeta;
|
export type AnalysisMeta = ComplianceMeta;
|
||||||
@@ -27,19 +28,6 @@ function authHeader(): Record<string, string> {
|
|||||||
return t ? { Authorization: `Bearer ${t}` } : {};
|
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() {
|
export function useComplianceAnalysis() {
|
||||||
const { complianceState: state, setComplianceState: setState, complianceAbortRef, resetCompliance: reset } = usePageState();
|
const { complianceState: state, setComplianceState: setState, complianceAbortRef, resetCompliance: reset } = usePageState();
|
||||||
|
|
||||||
@@ -48,7 +36,7 @@ export function useComplianceAnalysis() {
|
|||||||
const ctrl = new AbortController();
|
const ctrl = new AbortController();
|
||||||
complianceAbortRef.current = ctrl;
|
complianceAbortRef.current = ctrl;
|
||||||
|
|
||||||
setState({ ...INITIAL_STATE, status: 'streaming', stageLabel: 'Starting…', meta });
|
setState({ ...COMPLIANCE_INIT, status: 'streaming', stageLabel: 'Starting…', meta });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/v1/compliance/analyze-stream', {
|
const res = await fetch('/api/v1/compliance/analyze-stream', {
|
||||||
@@ -92,6 +80,9 @@ export function useComplianceAnalysis() {
|
|||||||
|
|
||||||
if (j.type === 'stage') {
|
if (j.type === 'stage') {
|
||||||
setState(s => ({ ...s, stageLabel: j.label ?? '', stageKey: j.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') {
|
} else if (j.type === 'source') {
|
||||||
const src: ComplianceSourceEvent = {
|
const src: ComplianceSourceEvent = {
|
||||||
standard: j.standard ?? '',
|
standard: j.standard ?? '',
|
||||||
@@ -99,6 +90,7 @@ export function useComplianceAnalysis() {
|
|||||||
score: j.score ?? 0,
|
score: j.score ?? 0,
|
||||||
status: j.status ?? 'retrieved',
|
status: j.status ?? 'retrieved',
|
||||||
full_content: j.full_content ?? '',
|
full_content: j.full_content ?? '',
|
||||||
|
clause_index: j.clause_index,
|
||||||
};
|
};
|
||||||
setState(s => ({ ...s, sources: [...s.sources, src] }));
|
setState(s => ({ ...s, sources: [...s.sources, src] }));
|
||||||
} else if (j.type === 'finding') {
|
} else if (j.type === 'finding') {
|
||||||
@@ -107,8 +99,13 @@ export function useComplianceAnalysis() {
|
|||||||
desc: j.desc ?? '',
|
desc: j.desc ?? '',
|
||||||
status: j.status ?? 'info',
|
status: j.status ?? 'info',
|
||||||
clause_ref: j.clause_ref,
|
clause_ref: j.clause_ref,
|
||||||
|
confidence: j.confidence,
|
||||||
|
source_refs: j.source_refs,
|
||||||
};
|
};
|
||||||
setState(s => ({ ...s, findings: [...s.findings, finding] }));
|
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') {
|
} else if (j.type === 'done') {
|
||||||
const payload: ComplianceDonePayload = {
|
const payload: ComplianceDonePayload = {
|
||||||
conclusion: j.conclusion ?? '',
|
conclusion: j.conclusion ?? '',
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ interface Doc {
|
|||||||
sizeBytes: number;
|
sizeBytes: number;
|
||||||
summary?: string;
|
summary?: string;
|
||||||
version?: string;
|
version?: string;
|
||||||
|
hasFile: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_FILTERS = ['All', 'Ready', 'Processing', 'Failed', 'Pending'];
|
const STATUS_FILTERS = ['All', 'Ready', 'Processing', 'Failed', 'Pending'];
|
||||||
@@ -102,6 +103,7 @@ export function DocsPage() {
|
|||||||
sizeBytes: (item.size_bytes as number) ?? 0,
|
sizeBytes: (item.size_bytes as number) ?? 0,
|
||||||
summary: item.summary as string | undefined,
|
summary: item.summary as string | undefined,
|
||||||
version: item.version as string | undefined,
|
version: item.version as string | undefined,
|
||||||
|
hasFile: item.has_file !== false,
|
||||||
})));
|
})));
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
})
|
})
|
||||||
@@ -130,11 +132,21 @@ export function DocsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Download ─────────────────────────────────────────────────────────────
|
// ── Download ─────────────────────────────────────────────────────────────
|
||||||
function downloadDoc(id: string, name: string) {
|
async function downloadDoc(id: string, name: string) {
|
||||||
const a = document.createElement('a');
|
try {
|
||||||
a.href = `/api/v1/documents/download/${id}`;
|
const resp = await fetch(`/api/v1/documents/download/${id}`, { headers: authHeader() });
|
||||||
a.download = name;
|
if (!resp.ok) throw new Error(`下载失败: ${resp.status}`);
|
||||||
a.click();
|
const blob = await resp.blob();
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = name;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Download failed', err);
|
||||||
|
alert(String(err));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Retry (re-process failed doc) ────────────────────────────────────────
|
// ── Retry (re-process failed doc) ────────────────────────────────────────
|
||||||
@@ -289,11 +301,13 @@ export function DocsPage() {
|
|||||||
<span className="cell-mono">{formatSize(d.sizeBytes)}</span>
|
<span className="cell-mono">{formatSize(d.sizeBytes)}</span>
|
||||||
<span className="cell-muted">{d.type}</span>
|
<span className="cell-muted">{d.type}</span>
|
||||||
<span className="row-actions">
|
<span className="row-actions">
|
||||||
{/* Download */}
|
{/* Download — disabled for Milvus-only docs that have no binary file */}
|
||||||
<button
|
<button
|
||||||
className="text-link"
|
className="text-link"
|
||||||
title={t.docs.titleDownload}
|
title={d.hasFile ? t.docs.titleDownload : '无原始文件'}
|
||||||
onClick={() => downloadDoc(d.id, d.name)}
|
onClick={() => downloadDoc(d.id, d.name)}
|
||||||
|
disabled={!d.hasFile}
|
||||||
|
style={!d.hasFile ? { opacity: 0.3, cursor: 'not-allowed' } : undefined}
|
||||||
>
|
>
|
||||||
<Download size={12} />
|
<Download size={12} />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -222,7 +222,7 @@ export function UploadModal({ onClose, onComplete }: Props) {
|
|||||||
<button className="modal-close" onClick={onClose} aria-label="Close" disabled={submitting}><X size={14} /></button>
|
<button className="modal-close" onClick={onClose} aria-label="Close" disabled={submitting}><X size={14} /></button>
|
||||||
|
|
||||||
{/* ── Left panel: upload form ── */}
|
{/* ── 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-eyebrow">Upload documents</div>
|
||||||
<div className="modal-title">Stage files for parsing and indexing.</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>
|
<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>
|
</div>
|
||||||
|
|
||||||
{files.length > 0 && (
|
{files.length > 0 && (
|
||||||
<div className="staged-files">
|
<div className="staged-files" style={{ maxHeight: 220, overflowY: 'auto', overflowX: 'hidden' }}>
|
||||||
{files.map((f, i) => {
|
{files.map((f, i) => {
|
||||||
const isDone = doneCount > i;
|
const isDone = doneCount > i;
|
||||||
const isActive = submitting && currentFileIdx === i;
|
const isActive = submitting && currentFileIdx === i;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { FormEvent, useState } from 'react';
|
import { useState, type FormEvent } from 'react';
|
||||||
import { useAuth } from '../../contexts';
|
import { useAuth } from '../../contexts';
|
||||||
|
|
||||||
export function LoginPage() {
|
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 { Topbar } from '../../components/layout/Topbar';
|
||||||
import { RefreshCw, Play, Square, ExternalLink } from 'lucide-react';
|
import { RefreshCw, Play, Square, ExternalLink } from 'lucide-react';
|
||||||
import { usePageState } from '../../contexts';
|
import { usePageState } from '../../contexts';
|
||||||
@@ -15,23 +15,24 @@ interface Stats {
|
|||||||
total: number;
|
total: number;
|
||||||
high_impact: number;
|
high_impact: number;
|
||||||
medium_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'];
|
const IMPACTS = ['All', 'High', 'Medium', 'Low'];
|
||||||
|
|
||||||
// Backend event → Signal
|
// Backend event → Signal
|
||||||
function mapEvent(e: Record<string, unknown>): PerceptionSignal {
|
function mapEvent(e: Record<string, unknown>): PerceptionSignal {
|
||||||
const impact = String(e.impact_level ?? '').toLowerCase();
|
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();
|
const backendStatus = String(e.status ?? '').toLowerCase();
|
||||||
return {
|
return {
|
||||||
id: String(e.id ?? e.event_id ?? ''),
|
id: String(e.id ?? e.event_id ?? ''),
|
||||||
source: String(e.source ?? ''),
|
source: String(e.source ?? ''),
|
||||||
standard: String(e.standard ?? e.standard_code ?? e.regulation_id ?? ''),
|
standard: String(e.standard ?? e.standard_code ?? e.regulation_id ?? ''),
|
||||||
status: backendStatus === 'high' || backendStatus === 'urgent' ? 'risk'
|
status: backendStatus === 'enacted' ? 'ok'
|
||||||
: backendStatus === 'medium' || backendStatus === 'draft' ? 'warn'
|
: backendStatus === 'draft' || backendStatus === 'consultation' ? 'warn'
|
||||||
: backendStatus === 'low' || backendStatus === 'final' ? 'ok'
|
|
||||||
: 'info',
|
: 'info',
|
||||||
title: String(e.title ?? ''),
|
title: String(e.title ?? ''),
|
||||||
summary: String(e.summary ?? e.description ?? ''),
|
summary: String(e.summary ?? e.description ?? ''),
|
||||||
@@ -80,7 +81,13 @@ export function PerceptionPage() {
|
|||||||
fetch('/api/v1/perception/stats', { headers: authHeader() })
|
fetch('/api/v1/perception/stats', { headers: authHeader() })
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(setStats)
|
.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
|
// 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;
|
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 => {
|
const filtered = signals.filter(s => {
|
||||||
if (sourceFilter !== 'All' && s.source !== sourceFilter) return false;
|
if (sourceFilter !== 'All' && s.source !== sourceFilter) return false;
|
||||||
if (impactFilter !== 'All' && s.impact !== impactFilter) return false;
|
if (impactFilter !== 'All' && s.impact !== impactFilter) return false;
|
||||||
@@ -178,6 +196,11 @@ export function PerceptionPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function runCrawl() {
|
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);
|
setCrawling(true);
|
||||||
setPerceptionState(s => ({ ...s, crawlStatus: t.signals.statusConnecting }));
|
setPerceptionState(s => ({ ...s, crawlStatus: t.signals.statusConnecting }));
|
||||||
try {
|
try {
|
||||||
@@ -185,6 +208,7 @@ export function PerceptionPage() {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json', ...authHeader() },
|
headers: { 'Content-Type': 'application/json', ...authHeader() },
|
||||||
body: JSON.stringify({}),
|
body: JSON.stringify({}),
|
||||||
|
signal: ctrl.signal,
|
||||||
});
|
});
|
||||||
if (!res.body) {
|
if (!res.body) {
|
||||||
setPerceptionState(s => ({ ...s, crawlStatus: 'No stream' }));
|
setPerceptionState(s => ({ ...s, crawlStatus: 'No stream' }));
|
||||||
@@ -232,10 +256,14 @@ export function PerceptionPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
setPerceptionState(s => ({
|
// An abort is a deliberate supersede, not a backend failure — leaving the
|
||||||
...s,
|
// status untouched avoids reporting "connection failed" to the user.
|
||||||
crawlStatus: t.signals.statusConnFailed.replace('{message}', e instanceof Error ? e.message : String(e)),
|
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);
|
setCrawling(false);
|
||||||
}
|
}
|
||||||
@@ -293,14 +321,14 @@ export function PerceptionPage() {
|
|||||||
<span className="sbar-lbl">{t.signals.statMedium}</span>
|
<span className="sbar-lbl">{t.signals.statMedium}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="sbar-cell accent">
|
<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>
|
<span className="sbar-lbl">{t.signals.statLast90}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="filter-bar">
|
<div className="filter-bar">
|
||||||
<div className="chip-group">
|
<div className="chip-group">
|
||||||
{SOURCES.map(s => (
|
{sources.map(s => (
|
||||||
<button
|
<button
|
||||||
key={s}
|
key={s}
|
||||||
className={`chip${sourceFilter === s ? ' active' : ''}`}
|
className={`chip${sourceFilter === s ? ' active' : ''}`}
|
||||||
@@ -365,7 +393,7 @@ export function PerceptionPage() {
|
|||||||
<span className={`status ${selected.status}`}>
|
<span className={`status ${selected.status}`}>
|
||||||
{selected.status === 'risk' ? t.signals.badgeUrgent : selected.status === 'warn' ? t.signals.badgeDraft : t.signals.badgePublished}
|
{selected.status === 'risk' ? t.signals.badgeUrgent : selected.status === 'warn' ? t.signals.badgeDraft : t.signals.badgePublished}
|
||||||
</span>
|
</span>
|
||||||
{selectedFull?.change_summary && (
|
{Boolean(selectedFull?.change_summary) && (
|
||||||
<span className="status warn" style={{ marginLeft: 'auto' }}>CHANGED</span>
|
<span className="status warn" style={{ marginLeft: 'auto' }}>CHANGED</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -411,9 +439,9 @@ export function PerceptionPage() {
|
|||||||
<p className="detail-summary" style={{ marginTop: 8 }}>
|
<p className="detail-summary" style={{ marginTop: 8 }}>
|
||||||
{(selectedFull?.scope as string) || selected.summary}
|
{(selectedFull?.scope as string) || selected.summary}
|
||||||
</p>
|
</p>
|
||||||
{selectedFull?.penalties && (
|
{Boolean(selectedFull?.penalties) && (
|
||||||
<p style={{ fontSize: 13, color: 'var(--danger)', marginTop: 6 }}>
|
<p style={{ fontSize: 13, color: 'var(--danger)', marginTop: 6 }}>
|
||||||
⚠ {selectedFull.penalties as string}
|
⚠ {selectedFull?.penalties as string}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -486,8 +514,8 @@ export function PerceptionPage() {
|
|||||||
{String(d.doc_name || '')}
|
{String(d.doc_name || '')}
|
||||||
<span className="doc-clause">{String(d.key_clauses || d.clause || '')}</span>
|
<span className="doc-clause">{String(d.key_clauses || d.clause || '')}</span>
|
||||||
</div>
|
</div>
|
||||||
{d.snippet && <div className="doc-snippet">{String(d.snippet)}</div>}
|
{Boolean(d.snippet) && <div className="doc-snippet">{String(d.snippet)}</div>}
|
||||||
{d.recommendation && (
|
{Boolean(d.recommendation) && (
|
||||||
<div style={{ fontSize: 12, color: 'var(--accent)', marginTop: 2 }}>→ {String(d.recommendation)}</div>
|
<div style={{ fontSize: 12, color: 'var(--accent)', marginTop: 2 }}>→ {String(d.recommendation)}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -523,7 +551,7 @@ export function PerceptionPage() {
|
|||||||
{String(s.new_text || '')}
|
{String(s.new_text || '')}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
));
|
));
|
||||||
})()}
|
})()}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { useRef, useEffect, useCallback, useState } from 'react';
|
import { useRef, useEffect, useCallback, useState } from 'react';
|
||||||
import { Topbar } from '../../components/layout/Topbar';
|
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 { usePageState } from '../../contexts';
|
||||||
import type { RagCitation } from '../../contexts';
|
import type { RagCitation } from '../../contexts';
|
||||||
import { useLanguage } from '../../contexts/LanguageContext';
|
import { useLanguage } from '../../contexts/LanguageContext';
|
||||||
|
import { agenticChat } from '../../api/rag';
|
||||||
|
import type { SSEMessage } from '../../api/index';
|
||||||
|
|
||||||
const TOKEN_KEY = 'auth_token';
|
const TOKEN_KEY = 'auth_token';
|
||||||
function authHeader(): Record<string, string> {
|
function authHeader(): Record<string, string> {
|
||||||
@@ -11,6 +13,46 @@ function authHeader(): Record<string, string> {
|
|||||||
return t ? { Authorization: `Bearer ${t}` } : {};
|
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.
|
// Map a raw source doc from the backend "retrieved" event to our Citation shape.
|
||||||
function mapSource(s: Record<string, unknown>, idx: number): RagCitation {
|
function mapSource(s: Record<string, unknown>, idx: number): RagCitation {
|
||||||
const rawScore = typeof s.score === 'number' ? s.score : 0;
|
const rawScore = typeof s.score === 'number' ? s.score : 0;
|
||||||
@@ -69,10 +111,72 @@ export function RagChatPage() {
|
|||||||
const [streaming, setStreaming] = useState(ragStreamingRef.current);
|
const [streaming, setStreaming] = useState(ragStreamingRef.current);
|
||||||
const [quickPrompts, setQuickPrompts] = useState<string[]>(MOCK_QUICK);
|
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 bottomRef = useRef<HTMLDivElement>(null);
|
||||||
const citRailRef = useRef<HTMLDivElement>(null);
|
const citRailRef = useRef<HTMLDivElement>(null);
|
||||||
const citItemRefs = useRef<Record<number, 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)
|
// Fetch quick questions from backend on mount (only once per session)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch('/api/v1/rag/quick-questions', { headers: authHeader() })
|
fetch('/api/v1/rag/quick-questions', { headers: authHeader() })
|
||||||
@@ -102,9 +206,17 @@ export function RagChatPage() {
|
|||||||
|
|
||||||
async function send(text?: string) {
|
async function send(text?: string) {
|
||||||
const q = (text ?? inputDraft).trim();
|
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: '' }));
|
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 userMsgId = Date.now().toString();
|
||||||
const assistantId = (Date.now() + 1).toString();
|
const assistantId = (Date.now() + 1).toString();
|
||||||
|
|
||||||
@@ -112,7 +224,7 @@ export function RagChatPage() {
|
|||||||
...s,
|
...s,
|
||||||
messages: [
|
messages: [
|
||||||
...s.messages,
|
...s.messages,
|
||||||
{ id: userMsgId, role: 'user', text: q },
|
{ id: userMsgId, role: 'user', text: displayQuery },
|
||||||
{ id: assistantId, role: 'assistant', text: '' },
|
{ id: assistantId, role: 'assistant', text: '' },
|
||||||
],
|
],
|
||||||
citations: [],
|
citations: [],
|
||||||
@@ -122,100 +234,211 @@ export function RagChatPage() {
|
|||||||
setStreaming(true);
|
setStreaming(true);
|
||||||
setHighlightedCit(null);
|
setHighlightedCit(null);
|
||||||
|
|
||||||
|
// P0-1: reset thinking panel for new query
|
||||||
|
if (agenticMode) {
|
||||||
|
setThinkingSteps([]);
|
||||||
|
setThinkingExpanded(true);
|
||||||
|
}
|
||||||
|
|
||||||
const ctrl = new AbortController();
|
const ctrl = new AbortController();
|
||||||
ragAbortRef.current = ctrl;
|
ragAbortRef.current = ctrl;
|
||||||
|
|
||||||
try {
|
if (agenticMode) {
|
||||||
const body: Record<string, unknown> = { query: q, top_k: 5 };
|
// ── Agentic path ────────────────────────────────────────────────────
|
||||||
if (sessionId) body.session_id = sessionId;
|
|
||||||
|
|
||||||
const res = await fetch('/api/v1/rag/chat', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json', ...authHeader() },
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
signal: ctrl.signal,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.body) throw new Error('No stream');
|
|
||||||
const reader = res.body.getReader();
|
|
||||||
const dec = new TextDecoder();
|
|
||||||
let buffer = '';
|
|
||||||
const newCitations: RagCitation[] = [];
|
const newCitations: RagCitation[] = [];
|
||||||
|
|
||||||
while (true) {
|
const handleMessage = (msg: SSEMessage) => {
|
||||||
const { done, value } = await reader.read();
|
if (msg.type === 'session') {
|
||||||
if (done) break;
|
if (msg.session_id) setRagState(s => ({ ...s, sessionId: msg.session_id! }));
|
||||||
buffer += dec.decode(value, { stream: true });
|
|
||||||
|
|
||||||
const blocks = buffer.split('\n\n');
|
} else if (msg.type === 'thinking') {
|
||||||
buffer = blocks.pop() ?? '';
|
// Build a stable step id so we can upsert running→done transitions.
|
||||||
|
const stepId = `${msg.step}-${msg.retry ? 'retry' : (msg.index ?? 0)}`;
|
||||||
for (const block of blocks) {
|
setThinkingSteps(prev => {
|
||||||
const dataLine = block.split('\n').find(l => l.startsWith('data: '));
|
const idx = prev.findIndex(s => s.id === stepId);
|
||||||
if (!dataLine) continue;
|
const stepObj: ThinkingStep = {
|
||||||
const raw = dataLine.slice(6).trim();
|
id: stepId,
|
||||||
if (!raw) continue;
|
step: msg.step ?? '',
|
||||||
try {
|
status: (msg.status as 'running' | 'done') ?? 'running',
|
||||||
const j = JSON.parse(raw);
|
intent_type: msg.intent_type,
|
||||||
|
reason: msg.reason,
|
||||||
if (j.type === 'session') {
|
sub_queries: msg.sub_queries,
|
||||||
if (j.session_id) setRagState(s => ({ ...s, sessionId: j.session_id }));
|
query: msg.query,
|
||||||
|
index: msg.index,
|
||||||
} else if (j.type === 'retrieved' && Array.isArray(j.docs)) {
|
total: msg.total,
|
||||||
const mapped = j.docs.map((d: Record<string, unknown>, i: number) => mapSource(d, i + 1));
|
found: msg.found,
|
||||||
newCitations.push(...mapped);
|
sufficient: msg.sufficient,
|
||||||
setRagState(s => ({ ...s, citations: [...mapped] }));
|
confidence: msg.confidence,
|
||||||
|
retry: msg.retry,
|
||||||
} else if (j.type === 'chunk' && j.text) {
|
};
|
||||||
setRagState(s => ({
|
if (idx >= 0) {
|
||||||
...s,
|
const updated = [...prev];
|
||||||
messages: s.messages.map(msg =>
|
updated[idx] = stepObj;
|
||||||
msg.id === assistantId
|
return updated;
|
||||||
? { ...msg, text: msg.text + (j.text as string) }
|
|
||||||
: msg
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
|
|
||||||
} else if (j.type === 'done') {
|
|
||||||
setRagState(s => ({
|
|
||||||
...s,
|
|
||||||
messages: s.messages.map(msg => {
|
|
||||||
if (msg.id !== assistantId) return msg;
|
|
||||||
const refs = [...new Set(
|
|
||||||
[...msg.text.matchAll(/\[(\d+)\]/g)].map(r => parseInt(r[1], 10))
|
|
||||||
)].filter(n => n >= 1 && n <= newCitations.length);
|
|
||||||
return { ...msg, citationRefs: refs };
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
break;
|
|
||||||
|
|
||||||
} else if (j.type === 'error') {
|
|
||||||
setRagState(s => ({
|
|
||||||
...s,
|
|
||||||
messages: s.messages.map(msg =>
|
|
||||||
msg.id === assistantId
|
|
||||||
? { ...msg, text: `Error: ${j.text ?? 'Unknown error'}` }
|
|
||||||
: msg
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
} catch { /* malformed JSON chunk, skip */ }
|
return [...prev, stepObj];
|
||||||
|
});
|
||||||
|
|
||||||
|
} else if (msg.type === 'retrieved' && Array.isArray(msg.docs)) {
|
||||||
|
const mapped = (msg.docs as unknown as Record<string, unknown>[]).map((d, i) => mapSource(d, i + 1));
|
||||||
|
newCitations.push(...mapped);
|
||||||
|
setRagState(s => ({ ...s, citations: [...mapped] }));
|
||||||
|
|
||||||
|
} else if (msg.type === 'chunk' && msg.text) {
|
||||||
|
setRagState(s => ({
|
||||||
|
...s,
|
||||||
|
messages: s.messages.map(m =>
|
||||||
|
m.id === assistantId ? { ...m, text: m.text + msg.text! } : m
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
} else if (msg.type === 'done') {
|
||||||
|
setThinkingExpanded(false);
|
||||||
|
setRagState(s => ({
|
||||||
|
...s,
|
||||||
|
messages: s.messages.map(m => {
|
||||||
|
if (m.id !== assistantId) return m;
|
||||||
|
const refs = [...new Set(
|
||||||
|
[...m.text.matchAll(/\[(\d+)\]/g)].map(r => parseInt(r[1], 10))
|
||||||
|
)].filter(n => n >= 1 && n <= newCitations.length);
|
||||||
|
return { ...m, citationRefs: refs };
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
} else if (msg.type === 'error') {
|
||||||
|
setRagState(s => ({
|
||||||
|
...s,
|
||||||
|
messages: s.messages.map(m =>
|
||||||
|
m.id === assistantId ? { ...m, text: `Error: ${msg.text ?? 'Unknown error'}` } : m
|
||||||
|
),
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await agenticChat(
|
||||||
|
q, 5, handleMessage,
|
||||||
|
(err) => {
|
||||||
|
setRagState(s => ({
|
||||||
|
...s,
|
||||||
|
messages: s.messages.map(m =>
|
||||||
|
m.id === assistantId ? { ...m, text: t.ragchat.apiError } : m
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
console.error('agenticChat error:', err);
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
sessionId ?? undefined,
|
||||||
|
ctrl.signal,
|
||||||
|
// Pass document context to agentic pipeline
|
||||||
|
docContext?.status === 'ready' ? docContext.text : undefined,
|
||||||
|
docContext?.status === 'ready' ? docContext.filename : undefined,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
ragStreamingRef.current = false;
|
||||||
|
setStreaming(false);
|
||||||
}
|
}
|
||||||
} catch (e: unknown) {
|
|
||||||
if (e instanceof Error && e.name !== 'AbortError') {
|
} else {
|
||||||
setRagState(s => ({
|
// ── Standard RAG path (unchanged) ───────────────────────────────────
|
||||||
...s,
|
try {
|
||||||
messages: s.messages.map(msg =>
|
const body: Record<string, unknown> = { query: q, top_k: 5 };
|
||||||
msg.id === assistantId
|
if (sessionId) body.session_id = sessionId;
|
||||||
? { ...msg, text: t.ragchat.apiError }
|
// Inject document text as conversation context when a file is attached
|
||||||
: msg
|
if (docContext?.status === 'ready') {
|
||||||
),
|
body.context_text = docContext.text;
|
||||||
}));
|
body.context_filename = docContext.filename;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch('/api/v1/rag/chat', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', ...authHeader() },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
signal: ctrl.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.body) throw new Error('No stream');
|
||||||
|
const reader = res.body.getReader();
|
||||||
|
const dec = new TextDecoder();
|
||||||
|
let buffer = '';
|
||||||
|
const newCitations: RagCitation[] = [];
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
buffer += dec.decode(value, { stream: true });
|
||||||
|
|
||||||
|
const blocks = buffer.split('\n\n');
|
||||||
|
buffer = blocks.pop() ?? '';
|
||||||
|
|
||||||
|
for (const block of blocks) {
|
||||||
|
const dataLine = block.split('\n').find(l => l.startsWith('data: '));
|
||||||
|
if (!dataLine) continue;
|
||||||
|
const raw = dataLine.slice(6).trim();
|
||||||
|
if (!raw) continue;
|
||||||
|
try {
|
||||||
|
const j = JSON.parse(raw);
|
||||||
|
|
||||||
|
if (j.type === 'session') {
|
||||||
|
if (j.session_id) setRagState(s => ({ ...s, sessionId: j.session_id }));
|
||||||
|
|
||||||
|
} else if (j.type === 'retrieved' && Array.isArray(j.docs)) {
|
||||||
|
const mapped = j.docs.map((d: Record<string, unknown>, i: number) => mapSource(d, i + 1));
|
||||||
|
newCitations.push(...mapped);
|
||||||
|
setRagState(s => ({ ...s, citations: [...mapped] }));
|
||||||
|
|
||||||
|
} else if (j.type === 'chunk' && j.text) {
|
||||||
|
setRagState(s => ({
|
||||||
|
...s,
|
||||||
|
messages: s.messages.map(msg =>
|
||||||
|
msg.id === assistantId
|
||||||
|
? { ...msg, text: msg.text + (j.text as string) }
|
||||||
|
: msg
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
} else if (j.type === 'done') {
|
||||||
|
setRagState(s => ({
|
||||||
|
...s,
|
||||||
|
messages: s.messages.map(msg => {
|
||||||
|
if (msg.id !== assistantId) return msg;
|
||||||
|
const refs = [...new Set(
|
||||||
|
[...msg.text.matchAll(/\[(\d+)\]/g)].map(r => parseInt(r[1], 10))
|
||||||
|
)].filter(n => n >= 1 && n <= newCitations.length);
|
||||||
|
return { ...msg, citationRefs: refs };
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
break;
|
||||||
|
|
||||||
|
} else if (j.type === 'error') {
|
||||||
|
setRagState(s => ({
|
||||||
|
...s,
|
||||||
|
messages: s.messages.map(msg =>
|
||||||
|
msg.id === assistantId
|
||||||
|
? { ...msg, text: `Error: ${j.text ?? 'Unknown error'}` }
|
||||||
|
: msg
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} catch { /* malformed JSON chunk, skip */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: unknown) {
|
||||||
|
if (e instanceof Error && e.name !== 'AbortError') {
|
||||||
|
setRagState(s => ({
|
||||||
|
...s,
|
||||||
|
messages: s.messages.map(msg =>
|
||||||
|
msg.id === assistantId
|
||||||
|
? { ...msg, text: t.ragchat.apiError }
|
||||||
|
: msg
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
ragStreamingRef.current = false;
|
||||||
|
setStreaming(false);
|
||||||
}
|
}
|
||||||
} finally {
|
|
||||||
ragStreamingRef.current = false;
|
|
||||||
setStreaming(false);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -254,7 +477,126 @@ export function RagChatPage() {
|
|||||||
|
|
||||||
{/* ── Chat main ── */}
|
{/* ── Chat main ── */}
|
||||||
<div className="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 => (
|
{messages.map(msg => (
|
||||||
<div key={msg.id} className={`message msg-${msg.role}`}>
|
<div key={msg.id} className={`message msg-${msg.role}`}>
|
||||||
{msg.role === 'assistant' && <div className="msg-avatar">AI</div>}
|
{msg.role === 'assistant' && <div className="msg-avatar">AI</div>}
|
||||||
@@ -281,19 +623,130 @@ export function RagChatPage() {
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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">
|
<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
|
<textarea
|
||||||
className="composer-input"
|
className="composer-input"
|
||||||
placeholder={t.ragchat.inputPlaceholder}
|
placeholder={t.ragchat.inputPlaceholder}
|
||||||
value={inputDraft}
|
value={inputDraft}
|
||||||
onChange={e => setRagState(s => ({ ...s, inputDraft: e.target.value }))}
|
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}
|
rows={2}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
className="btn primary"
|
className="btn primary"
|
||||||
onClick={() => send()}
|
onClick={() => void send()}
|
||||||
disabled={!inputDraft.trim() || streaming}
|
disabled={!inputDraft.trim() || streaming || docContext?.status === 'extracting'}
|
||||||
>
|
>
|
||||||
<Send size={14} />
|
<Send size={14} />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Topbar } from '../../components/layout/Topbar';
|
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 { UploadModal } from '../Docs/UploadModal';
|
||||||
import { useLanguage } from '../../contexts/LanguageContext';
|
import { useLanguage } from '../../contexts/LanguageContext';
|
||||||
|
import { getMCPStatus, getModelUsage, pingModelConnections } from '../../api/status';
|
||||||
|
import type { MCPStatusResponse, ModelUsageEntry } from '../../api/index';
|
||||||
|
|
||||||
const TOKEN_KEY = 'auth_token';
|
const TOKEN_KEY = 'auth_token';
|
||||||
function authHeader(): Record<string, string> {
|
function authHeader(): Record<string, string> {
|
||||||
@@ -81,29 +83,47 @@ export function StatusPage() {
|
|||||||
const [config, setConfig] = useState<Config | null>(null);
|
const [config, setConfig] = useState<Config | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [healthLoading, setHealthLoading] = useState(true);
|
const [healthLoading, setHealthLoading] = useState(true);
|
||||||
|
const [modelsLoading, setModelsLoading] = useState(true);
|
||||||
const [configOpen, setConfigOpen] = useState(false);
|
const [configOpen, setConfigOpen] = useState(false);
|
||||||
const [refreshKey, setRefreshKey] = useState(0);
|
const [refreshKey, setRefreshKey] = useState(0);
|
||||||
const [showUpload, setShowUpload] = useState(false);
|
const [showUpload, setShowUpload] = useState(false);
|
||||||
const [lastRefresh, setLastRefresh] = useState<Date | null>(null);
|
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(() => {
|
useEffect(() => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setHealthLoading(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([
|
Promise.allSettled([
|
||||||
fetch('/api/v1/status/stats', { headers: authHeader() }).then(r => r.json()),
|
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/health', { headers: authHeader() }).then(r => r.json()),
|
||||||
fetch('/api/v1/status/config', { 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);
|
if (statsRes.status === 'fulfilled') setStats(statsRes.value);
|
||||||
else setStats({ documents_total: 0, documents_indexed: 0, documents_failed: 0, chunks_total: 0 });
|
else setStats({ documents_total: 0, documents_indexed: 0, documents_failed: 0, chunks_total: 0 });
|
||||||
|
|
||||||
if (healthRes.status === 'fulfilled') setHealth(healthRes.value);
|
if (healthRes.status === 'fulfilled') setHealth(healthRes.value);
|
||||||
if (configRes.status === 'fulfilled') setConfig(configRes.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);
|
setLoading(false);
|
||||||
setHealthLoading(false);
|
setHealthLoading(false);
|
||||||
|
setModelsLoading(false);
|
||||||
|
setMcpLoading(false);
|
||||||
setLastRefresh(new Date());
|
setLastRefresh(new Date());
|
||||||
});
|
});
|
||||||
}, [refreshKey]);
|
}, [refreshKey]);
|
||||||
@@ -128,7 +148,7 @@ export function StatusPage() {
|
|||||||
|
|
||||||
// ── Export ───────────────────────────────────────────────────────────────
|
// ── Export ───────────────────────────────────────────────────────────────
|
||||||
function handleExport() {
|
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 blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
@@ -136,6 +156,66 @@ export function StatusPage() {
|
|||||||
URL.revokeObjectURL(url);
|
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 (
|
return (
|
||||||
<div className="status-page">
|
<div className="status-page">
|
||||||
<Topbar
|
<Topbar
|
||||||
@@ -254,6 +334,111 @@ export function StatusPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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) */}
|
{/* System config (collapsible) */}
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<button
|
<button
|
||||||
@@ -335,12 +520,6 @@ export function StatusPage() {
|
|||||||
<span style={{ color: 'var(--muted)' }}>{t.status.labelSessionCapacity}</span>
|
<span style={{ color: 'var(--muted)' }}>{t.status.labelSessionCapacity}</span>
|
||||||
<span style={{ fontFamily: 'var(--font-mono)' }}>{health.sessions.max}</span>
|
<span style={{ fontFamily: 'var(--font-mono)' }}>{health.sessions.max}</span>
|
||||||
</div>
|
</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' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, padding: '4px 0' }}>
|
||||||
<span style={{ color: 'var(--muted)' }}>{t.status.labelBM25}</span>
|
<span style={{ color: 'var(--muted)' }}>{t.status.labelBM25}</span>
|
||||||
<span style={{ fontFamily: 'var(--font-mono)', color: health.bm25.available ? 'var(--ok)' : 'var(--muted)' }}>
|
<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
Reference in New Issue
Block a user