Compare commits
21
Commits
746513cc54
...
main-ruqi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
52e67b0e7b | ||
|
|
e3afb8a07a | ||
|
|
6a7fe48c4c | ||
|
|
0edbee07d5 | ||
|
|
2ce4c8a289 | ||
|
|
39a51c9e83 | ||
|
|
049da2297b | ||
|
|
d83286edd4 | ||
|
|
169911ab46 | ||
|
|
66fc388bfb | ||
|
|
41096369d3 | ||
|
|
4fea159f5b | ||
|
|
d460397dda | ||
|
|
37ea27fcbe | ||
|
|
74f327c85e | ||
|
|
4b451ef97c | ||
|
|
55ba922250 | ||
|
|
9212747e1b | ||
|
|
e7963b267e | ||
|
|
9fea9c6a53 | ||
|
|
06e0967128 |
@@ -48,8 +48,16 @@ CHUNK_OVERLAP=50
|
|||||||
MAX_FILE_SIZE_MB=100
|
MAX_FILE_SIZE_MB=100
|
||||||
PARSER_BACKEND=aliyun
|
PARSER_BACKEND=aliyun
|
||||||
CHUNK_BACKEND=aliyun
|
CHUNK_BACKEND=aliyun
|
||||||
# 文档元数据存储后端:json(默认)或 postgres
|
# 文档元数据存储后端:启用 postgres 以激活合规分析历史记录(Direction B)及 Finding Chat 持久化(Direction C)
|
||||||
DOCUMENT_REPOSITORY_BACKEND=json
|
DOCUMENT_REPOSITORY_BACKEND=postgres
|
||||||
|
# Set to true only when a Celery worker is actually running (./dev.sh start worker).
|
||||||
|
# Default false: processing runs in FastAPI's threadpool — no external worker needed.
|
||||||
|
USE_CELERY_WORKER=false
|
||||||
|
|
||||||
|
# ===== 法规感知爬取配置 =====
|
||||||
|
PERCEPTION_CRAWL_TIMEOUT_SECONDS=120
|
||||||
|
PERCEPTION_MAX_EVENTS_PER_SOURCE=100
|
||||||
|
PERCEPTION_DIFF_SIMILARITY_THRESHOLD=0.85
|
||||||
|
|
||||||
# ===== API配置 =====
|
# ===== API配置 =====
|
||||||
API_HOST=0.0.0.0
|
API_HOST=0.0.0.0
|
||||||
@@ -92,3 +100,30 @@ ALIYUN_LLM_ENHANCEMENT=true
|
|||||||
ALIYUN_ENHANCEMENT_MODE=VLM
|
ALIYUN_ENHANCEMENT_MODE=VLM
|
||||||
DOCUMENT_PARSE_ARTIFACT_PREFIX=artifacts
|
DOCUMENT_PARSE_ARTIFACT_PREFIX=artifacts
|
||||||
PARSER_FAILURE_MODE=fail
|
PARSER_FAILURE_MODE=fail
|
||||||
|
|
||||||
|
# ===== Reranker 配置 =====
|
||||||
|
RERANKER_ENABLED=false
|
||||||
|
RERANKER_BASE_URL=http://6.86.80.4:30080/v1
|
||||||
|
RERANKER_MODEL=BAAI/bge-reranker-v2-m3
|
||||||
|
RERANKER_API_KEY=sk-fVr9KmDZNC4pGDBQj0EUWz9bDmFzNxjYC9EzZpe2bVDsxtz8
|
||||||
|
RERANKER_TOP_K=5
|
||||||
|
|
||||||
|
# ===== 会话持久化 =====
|
||||||
|
SESSION_BACKEND=redis
|
||||||
|
|
||||||
|
# ===== 认证配置 =====
|
||||||
|
# 生产环境请修改为强随机密钥: python -c "import secrets; print(secrets.token_hex(32))"
|
||||||
|
AUTH_SECRET_KEY=ai-compliance-hub-jwt-secret-2026-tsystems
|
||||||
|
AUTH_ALGORITHM=HS256
|
||||||
|
AUTH_TOKEN_EXPIRE_MINUTES=480
|
||||||
|
AUTH_ENABLED=true
|
||||||
|
|
||||||
|
# ===== CORS =====
|
||||||
|
CORS_ALLOW_ORIGINS=http://localhost:5173
|
||||||
|
|
||||||
|
# ===== HyDE ???? =====
|
||||||
|
HYDE_ENABLED=true
|
||||||
|
HYDE_MAX_TOKENS=200
|
||||||
|
HYDE_LLM_PROVIDER=qwen
|
||||||
|
HYDE_LLM_MODEL=qwen3.5-flash
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -31,5 +31,5 @@ POSTGRES_PASSWORD=postgresql123456
|
|||||||
POSTGRES_DB=compliance_db
|
POSTGRES_DB=compliance_db
|
||||||
|
|
||||||
# ===== 文档元数据后端 =====
|
# ===== 文档元数据后端 =====
|
||||||
# 改为 postgres 以启用 PG 持久化(structure_nodes + semantic_blocks 入库)
|
# 改为 postgres 以启用合规分析历史记录(Direction B)和 Finding Chat(Direction C)
|
||||||
DOCUMENT_REPOSITORY_BACKEND=json
|
DOCUMENT_REPOSITORY_BACKEND=json
|
||||||
|
|||||||
+62
-4
@@ -50,7 +50,19 @@ DOCUMENT_METADATA_PATH=backend/data/documents.json
|
|||||||
PARSER_BACKEND=aliyun
|
PARSER_BACKEND=aliyun
|
||||||
CHUNK_BACKEND=aliyun
|
CHUNK_BACKEND=aliyun
|
||||||
# 文档元数据存储后端:json(默认,无需数据库)或 postgres(启用 PG 持久化)
|
# 文档元数据存储后端:json(默认,无需数据库)或 postgres(启用 PG 持久化)
|
||||||
|
# ⚠ 以下功能需要 postgres(设为 json 时功能静默降级或报 500):
|
||||||
|
# - Direction B: 合规分析历史记录 (/compliance/history/*)
|
||||||
|
# - Direction B: DOCX 报告下载
|
||||||
|
# - Direction C: Finding Chat 消息持久化
|
||||||
DOCUMENT_REPOSITORY_BACKEND=json
|
DOCUMENT_REPOSITORY_BACKEND=json
|
||||||
|
# Set to true only when a Celery worker is running (./dev.sh start worker).
|
||||||
|
# Default false: document processing runs in FastAPI's threadpool (no external worker needed).
|
||||||
|
USE_CELERY_WORKER=false
|
||||||
|
|
||||||
|
# ===== 法规感知爬取配置 =====
|
||||||
|
PERCEPTION_CRAWL_TIMEOUT_SECONDS=120
|
||||||
|
PERCEPTION_MAX_EVENTS_PER_SOURCE=100
|
||||||
|
PERCEPTION_DIFF_SIMILARITY_THRESHOLD=0.85
|
||||||
|
|
||||||
# ===== 阿里云文档解析 =====
|
# ===== 阿里云文档解析 =====
|
||||||
ALIBABA_ACCESS_KEY_ID=your_aliyun_access_key_id
|
ALIBABA_ACCESS_KEY_ID=your_aliyun_access_key_id
|
||||||
@@ -96,11 +108,15 @@ RAG_TOP_K=10
|
|||||||
RAG_RETRIEVAL_TOP_K=20
|
RAG_RETRIEVAL_TOP_K=20
|
||||||
RAG_MAX_CONTEXT_TOKENS=4000
|
RAG_MAX_CONTEXT_TOKENS=4000
|
||||||
RAG_SUMMARY_MAX_TOKENS=1024
|
RAG_SUMMARY_MAX_TOKENS=1024
|
||||||
|
RAG_SKILLS_MAX_TOKENS=2048
|
||||||
|
|
||||||
# ===== Reranker配置(Cross-Encoder精排,默认关闭)=====
|
# ── Reranker (Cross-Encoder) ──────────────────────────────────────────────────
|
||||||
# 设置 RERANKER_ENABLED=true 并配置 RERANKER_BASE_URL 以启用精排
|
# Set RERANKER_ENABLED=true and point to a TEI or Cohere-compatible rerank API.
|
||||||
RERANKER_ENABLED=false
|
# Recommended model: BAAI/bge-reranker-v2.5-gemma2-lightweight (lighter) or
|
||||||
RERANKER_BASE_URL=
|
# BAAI/bge-reranker-v2-m3 (heavier, higher quality).
|
||||||
|
# The endpoint must expose POST /rerank (TEI style) or POST /v1/rerank (Cohere style).
|
||||||
|
RERANKER_ENABLED=true
|
||||||
|
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=
|
||||||
RERANKER_TOP_K=5
|
RERANKER_TOP_K=5
|
||||||
@@ -108,3 +124,45 @@ RERANKER_TOP_K=5
|
|||||||
# ===== 会话配置 =====
|
# ===== 会话配置 =====
|
||||||
SESSION_MAX_SESSIONS=100
|
SESSION_MAX_SESSIONS=100
|
||||||
SESSION_TIMEOUT_MINUTES=30
|
SESSION_TIMEOUT_MINUTES=30
|
||||||
|
# SESSION_BACKEND=redis 启用 Redis 持久化会话(需要 Redis 可用,推荐生产环境)
|
||||||
|
# SESSION_BACKEND=memory 使用内存会话(重启丢失,适合本地开发)
|
||||||
|
SESSION_BACKEND=memory
|
||||||
|
|
||||||
|
# ===== 认证配置 (Auth) =====
|
||||||
|
# 生产环境必须替换为强随机密钥:
|
||||||
|
# python -c "import secrets; print(secrets.token_hex(32))"
|
||||||
|
AUTH_SECRET_KEY=change-me-in-production-must-be-32-or-more-characters-long
|
||||||
|
AUTH_ALGORITHM=HS256
|
||||||
|
# Token 有效期(分钟),默认 8 小时
|
||||||
|
AUTH_TOKEN_EXPIRE_MINUTES=480
|
||||||
|
# 设为 false 可跳过认证(仅限本地开发调试,生产必须 true)
|
||||||
|
AUTH_ENABLED=true
|
||||||
|
|
||||||
|
|
||||||
|
# ===== HyDE 查询增强 =====
|
||||||
|
# HyDE (Hypothetical Document Embeddings): 在检索前让 LLM 生成一段"假设性回答",
|
||||||
|
# 用该段落的 embedding 代替原始查询 embedding 进行向量检索。
|
||||||
|
# 无需新模型,复用现有 LLM 和 Embedding 服务。降低此功能可减少每次查询的 LLM 调用次数。
|
||||||
|
HYDE_ENABLED=true
|
||||||
|
HYDE_MAX_TOKENS=200
|
||||||
|
# ?????? LLM;???????????????
|
||||||
|
HYDE_LLM_PROVIDER=qwen
|
||||||
|
HYDE_LLM_MODEL=qwen3.5-flash
|
||||||
|
|
||||||
|
# ===== Agentic RAG 配置 (P0-1) =====
|
||||||
|
# 以下参数控制 /api/v1/agent/agentic/stream 多步推理管线
|
||||||
|
# 意图分类: simple_qa / compare / multi_hop / ambiguous
|
||||||
|
# compare 和 multi_hop 触发查询分解,最多 AGENTIC_MAX_SUB_QUERIES 个子查询
|
||||||
|
AGENTIC_MAX_SUB_QUERIES=4
|
||||||
|
# 引文锚定 fast-path 阈值: avg_score > 此值 且 chunks >= 3 时跳过 LLM grounding check
|
||||||
|
# 降低此值可让更多查询触发 LLM 二次验证(更准确,但延迟+成本增加)
|
||||||
|
AGENTIC_GROUNDING_THRESHOLD=0.65
|
||||||
|
# 各步骤 LLM 最大 token 数(越小越快,越大越准)
|
||||||
|
AGENTIC_INTENT_MAX_TOKENS=200
|
||||||
|
AGENTIC_PLAN_MAX_TOKENS=400
|
||||||
|
AGENTIC_GROUNDING_MAX_TOKENS=250
|
||||||
|
|
||||||
|
# ===== CORS =====
|
||||||
|
# 逗号分隔的允许跨域来源列表,生产环境绝不能使用 *
|
||||||
|
CORS_ALLOW_ORIGINS=http://localhost:5173
|
||||||
|
|
||||||
|
|||||||
@@ -62,3 +62,6 @@ logs/
|
|||||||
|
|
||||||
# codex
|
# codex
|
||||||
.agents
|
.agents
|
||||||
|
|
||||||
|
# personal local records (never commit)
|
||||||
|
local/
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<h2>Compliance Analysis — 哪个方向最值得优化?</h2>
|
||||||
|
<p class="subtitle">基于代码深度分析,发现了 4 个有价值的改进方向。选择你最希望深入的那个。</p>
|
||||||
|
|
||||||
|
<div class="options">
|
||||||
|
|
||||||
|
<div class="option" data-choice="A" onclick="toggleSelect(this)">
|
||||||
|
<div class="letter">A</div>
|
||||||
|
<div class="content">
|
||||||
|
<h3>⚡ 分析质量提升</h3>
|
||||||
|
<p>并行子句处理(速度 3–5×)、跨编码器重排序、置信度过滤、修复 highlight_terms 失效 Bug、减少 LLM 静默失败。</p>
|
||||||
|
<div class="pros-cons" style="margin-top:10px">
|
||||||
|
<div class="pros"><h4>收益</h4><ul><li>更快、更准确的分析</li><li>消除当前 Bug</li></ul></div>
|
||||||
|
<div class="cons"><h4>难度</h4><ul><li>需要改造 pipeline.py</li></ul></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="option" data-choice="B" onclick="toggleSelect(this)">
|
||||||
|
<div class="letter">B</div>
|
||||||
|
<div class="content">
|
||||||
|
<h3>📋 分析历史 & 专业报告</h3>
|
||||||
|
<p>持久化分析记录(PostgreSQL)、历史对比、PDF/DOCX 专业报告导出、分析版本追踪。</p>
|
||||||
|
<div class="pros-cons" style="margin-top:10px">
|
||||||
|
<div class="pros"><h4>收益</h4><ul><li>结果不再丢失</li><li>可交付给客户的报告</li></ul></div>
|
||||||
|
<div class="cons"><h4>难度</h4><ul><li>需要新增数据库表</li></ul></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="option" data-choice="C" onclick="toggleSelect(this)">
|
||||||
|
<div class="letter">C</div>
|
||||||
|
<div class="content">
|
||||||
|
<h3>💬 深度 Chat 增强</h3>
|
||||||
|
<p>每个 Finding 独立对话线程(持久化)、Chat 上下文绑定真实检索到的法规原文、多轮追问记忆、快捷建议问句生成。</p>
|
||||||
|
<div class="pros-cons" style="margin-top:10px">
|
||||||
|
<div class="pros"><h4>收益</h4><ul><li>Finding 解读深度大幅提升</li><li>用户粘性强</li></ul></div>
|
||||||
|
<div class="cons"><h4>难度</h4><ul><li>需重构 chat 端点</li></ul></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="option" data-choice="D" onclick="toggleSelect(this)">
|
||||||
|
<div class="letter">D</div>
|
||||||
|
<div class="content">
|
||||||
|
<h3>📑 自定义规则 & 模板</h3>
|
||||||
|
<p>用户自定义合规规则库、按行业预设模板(汽车/金融/医疗)、Prompt 版本管理、A/B 测试不同提示策略。</p>
|
||||||
|
<div class="pros-cons" style="margin-top:10px">
|
||||||
|
<div class="pros"><h4>收益</h4><ul><li>适应不同行业场景</li><li>可配置,无需改代码</li></ul></div>
|
||||||
|
<div class="cons"><h4>难度</h4><ul><li>需要规则管理 UI</li></ul></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="subtitle" style="margin-top:20px">💡 也可以多选,或者在终端告诉我你有其他想法。</p>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{"type":"click","text":"C\n \n 💬 深度 Chat 增强\n 每个 Finding 独立对话线程(持久化)、Chat 上下文绑定真实检索到的法规原文、多轮追问记忆、快捷建议问句生成。\n \n 收益Finding 解读深度大幅提升用户粘性强\n 难度需重构 chat 端点","choice":"C","id":null,"timestamp":1780897984866}
|
||||||
|
{"type":"click","text":"B\n \n 📋 分析历史 & 专业报告\n 持久化分析记录(PostgreSQL)、历史对比、PDF/DOCX 专业报告导出、分析版本追踪。\n \n 收益结果不再丢失可交付给客户的报告\n 难度需要新增数据库表","choice":"B","id":null,"timestamp":1780897985879}
|
||||||
|
{"type":"click","text":"A\n \n ⚡ 分析质量提升\n 并行子句处理(速度 3–5×)、跨编码器重排序、置信度过滤、修复 highlight_terms 失效 Bug、减少 LLM 静默失败。\n \n 收益更快、更准确的分析消除当前 Bug\n 难度需要改造 pipeline.py","choice":"A","id":null,"timestamp":1780897986554}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"reason":"idle timeout","timestamp":1780894411095}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
1055
|
||||||
+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`(模糊)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""FastAPI dependency functions for authentication and authorisation.
|
||||||
|
|
||||||
|
Import `get_current_user` or `require_role` into route modules to protect
|
||||||
|
endpoints. Both use the shared JWTHandler wired through bootstrap.
|
||||||
|
"""
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
"""FastAPI dependencies for JWT authentication.
|
||||||
|
|
||||||
|
Usage in a route:
|
||||||
|
from app.api.dependencies.auth import get_current_user, require_role
|
||||||
|
from app.domain.auth.models import UserRole
|
||||||
|
|
||||||
|
@router.get("/protected")
|
||||||
|
async def protected(user: UserClaims = Depends(get_current_user)):
|
||||||
|
return {"user": user.username}
|
||||||
|
|
||||||
|
@router.delete("/admin-only")
|
||||||
|
async def admin_only(user: UserClaims = Depends(require_role(UserRole.ADMIN))):
|
||||||
|
...
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import Depends, HTTPException, status
|
||||||
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||||
|
|
||||||
|
from app.config.settings import settings
|
||||||
|
from app.domain.auth.models import UserClaims, UserRole
|
||||||
|
from app.shared.bootstrap import get_jwt_handler
|
||||||
|
|
||||||
|
# Use Bearer token scheme — client sends `Authorization: Bearer <token>`.
|
||||||
|
_bearer = HTTPBearer(auto_error=False)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_current_user(
|
||||||
|
credentials: HTTPAuthorizationCredentials | None = Depends(_bearer),
|
||||||
|
) -> UserClaims:
|
||||||
|
"""Extract and validate the JWT from the Authorization header.
|
||||||
|
|
||||||
|
Returns the decoded UserClaims on success.
|
||||||
|
Raises HTTP 401 when the token is missing, expired, or invalid.
|
||||||
|
When auth_enabled=False (development), returns a synthetic admin user.
|
||||||
|
"""
|
||||||
|
if not settings.auth_enabled:
|
||||||
|
# Development bypass — never enable this in production.
|
||||||
|
return UserClaims(user_id="dev", username="dev-admin", role=UserRole.ADMIN)
|
||||||
|
|
||||||
|
if credentials is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Missing authentication token",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
return get_jwt_handler().decode_token(credentials.credentials)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail=str(exc),
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def require_role(*roles: UserRole):
|
||||||
|
"""Return a dependency that enforces one of the given roles.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
Depends(require_role(UserRole.ADMIN, UserRole.LEGAL))
|
||||||
|
"""
|
||||||
|
async def _check(user: UserClaims = Depends(get_current_user)) -> UserClaims:
|
||||||
|
"""Verify the user holds one of the required roles."""
|
||||||
|
if user.role not in roles:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail=f"Role '{user.role}' is not permitted. Required: {[r.value for r in roles]}",
|
||||||
|
)
|
||||||
|
return user
|
||||||
|
return _check
|
||||||
+11
-1
@@ -8,6 +8,7 @@ from fastapi.middleware.cors import CORSMiddleware
|
|||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
|
from app.api.middleware.audit import AuditMiddleware
|
||||||
from app.api.models import ErrorResponse
|
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
|
||||||
@@ -46,14 +47,23 @@ app = FastAPI(
|
|||||||
redoc_url="/redoc",
|
redoc_url="/redoc",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Tighten CORS — only allow configured origins.
|
||||||
|
# Set CORS_ALLOW_ORIGINS in .env to the real frontend URL in production.
|
||||||
|
_ORIGINS = [o.strip() for o in settings.cors_allow_origins.split(",") if o.strip()]
|
||||||
|
if not _ORIGINS:
|
||||||
|
_ORIGINS = ["http://localhost:5173"]
|
||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=["*"],
|
allow_origins=_ORIGINS,
|
||||||
allow_credentials=True,
|
allow_credentials=True,
|
||||||
allow_methods=["*"],
|
allow_methods=["*"],
|
||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Audit middleware logs every authenticated API call for compliance traceability.
|
||||||
|
app.add_middleware(AuditMiddleware)
|
||||||
|
|
||||||
app.include_router(api_router, prefix="/api/v1")
|
app.include_router(api_router, prefix="/api/v1")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
"""HTTP middleware for cross-cutting concerns: audit logging."""
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
"""Audit logging middleware.
|
||||||
|
|
||||||
|
Logs every API request with method, path, status code, response time,
|
||||||
|
and the authenticated user identity (extracted from the JWT when present).
|
||||||
|
Log lines are structured so they can be ingested by ELK / Loki.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
|
||||||
|
from fastapi import Request, Response
|
||||||
|
from loguru import logger
|
||||||
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
|
|
||||||
|
|
||||||
|
class AuditMiddleware(BaseHTTPMiddleware):
|
||||||
|
"""Log all API calls. Skips health/docs paths to reduce noise."""
|
||||||
|
|
||||||
|
# Paths that produce no audit log entry.
|
||||||
|
_SKIP_PREFIXES = ("/health", "/docs", "/redoc", "/openapi.json")
|
||||||
|
|
||||||
|
async def dispatch(self, request: Request, call_next) -> Response:
|
||||||
|
"""Intercept the request, call the handler, and log the outcome."""
|
||||||
|
path = request.url.path
|
||||||
|
if path == "/" or any(path == p or path.startswith(p + "/") for p in self._SKIP_PREFIXES):
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
start = time.perf_counter()
|
||||||
|
response = await call_next(request)
|
||||||
|
elapsed_ms = int((time.perf_counter() - start) * 1000)
|
||||||
|
|
||||||
|
# Extract user identity from JWT header for structured audit records.
|
||||||
|
# The token is not re-validated here — auth dependencies do that upstream.
|
||||||
|
user_id = "anonymous"
|
||||||
|
username = "anonymous"
|
||||||
|
auth_header = request.headers.get("authorization", "")
|
||||||
|
if auth_header.startswith("Bearer "):
|
||||||
|
try:
|
||||||
|
from app.shared.bootstrap import get_jwt_handler
|
||||||
|
claims = get_jwt_handler().decode_token(auth_header[7:])
|
||||||
|
user_id = claims.user_id
|
||||||
|
username = claims.username
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"AUDIT method={} path={} status={} elapsed_ms={} user_id={} username={}",
|
||||||
|
request.method,
|
||||||
|
path,
|
||||||
|
response.status_code,
|
||||||
|
elapsed_ms,
|
||||||
|
user_id,
|
||||||
|
username,
|
||||||
|
)
|
||||||
|
return response
|
||||||
@@ -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):
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""Initialize the app.api.routes package."""
|
"""Initialize the app.api.routes package."""
|
||||||
|
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
from .auth import router as auth_router
|
||||||
from .compliance import router as compliance_router
|
from .compliance import router as compliance_router
|
||||||
from .documents import router as documents_router
|
from .documents import router as documents_router
|
||||||
from .knowledge import router as knowledge_router
|
from .knowledge import router as knowledge_router
|
||||||
@@ -14,7 +15,8 @@ from .rag import router as rag_router
|
|||||||
# Keep package boundaries explicit so backend imports stay predictable.
|
# Keep package boundaries explicit so backend imports stay predictable.
|
||||||
api_router = APIRouter()
|
api_router = APIRouter()
|
||||||
|
|
||||||
# Keep package boundaries explicit so backend imports stay predictable.
|
# Auth routes first so /auth/token is easy to discover.
|
||||||
|
api_router.include_router(auth_router)
|
||||||
api_router.include_router(documents_router)
|
api_router.include_router(documents_router)
|
||||||
api_router.include_router(knowledge_router)
|
api_router.include_router(knowledge_router)
|
||||||
api_router.include_router(agent_router)
|
api_router.include_router(agent_router)
|
||||||
@@ -25,6 +27,7 @@ api_router.include_router(rag_router)
|
|||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"api_router",
|
"api_router",
|
||||||
|
"auth_router",
|
||||||
"documents_router",
|
"documents_router",
|
||||||
"knowledge_router",
|
"knowledge_router",
|
||||||
"agent_router",
|
"agent_router",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
"""Authentication routes — token issuance only.
|
||||||
|
|
||||||
|
POST /auth/token — exchange username + password for a JWT.
|
||||||
|
GET /auth/me — return the current user identity (requires token).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from fastapi.security import OAuth2PasswordRequestForm
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from app.api.dependencies.auth import get_current_user
|
||||||
|
from app.config.settings import settings
|
||||||
|
from app.domain.auth.models import UserClaims
|
||||||
|
from app.shared.bootstrap import get_jwt_handler, get_user_store
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/auth", tags=["认证"])
|
||||||
|
|
||||||
|
|
||||||
|
class TokenResponse(BaseModel):
|
||||||
|
"""JWT token response body."""
|
||||||
|
|
||||||
|
access_token: str
|
||||||
|
token_type: str = "bearer"
|
||||||
|
expires_in: int
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/token", response_model=TokenResponse)
|
||||||
|
async def login(form: OAuth2PasswordRequestForm = Depends()):
|
||||||
|
"""Issue a JWT for valid username + password credentials.
|
||||||
|
|
||||||
|
Uses standard OAuth2 password grant form fields — compatible with
|
||||||
|
Swagger UI Authorize button.
|
||||||
|
"""
|
||||||
|
user = get_user_store().authenticate(form.username, form.password)
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Incorrect username or password",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
token = get_jwt_handler().create_access_token(
|
||||||
|
user_id=user.id,
|
||||||
|
username=user.username,
|
||||||
|
role=user.role,
|
||||||
|
)
|
||||||
|
return TokenResponse(
|
||||||
|
access_token=token,
|
||||||
|
token_type="bearer",
|
||||||
|
expires_in=settings.auth_token_expire_minutes * 60,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me")
|
||||||
|
async def get_me(current_user: UserClaims = Depends(get_current_user)):
|
||||||
|
"""Return the identity of the currently authenticated user."""
|
||||||
|
return {
|
||||||
|
"user_id": current_user.user_id,
|
||||||
|
"username": current_user.username,
|
||||||
|
"role": current_user.role.value,
|
||||||
|
}
|
||||||
@@ -5,17 +5,21 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import AsyncGenerator
|
from typing import AsyncGenerator, Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, File, UploadFile
|
from fastapi import APIRouter, Depends, File, Form, 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.domain.auth.models import UserClaims
|
||||||
from app.schemas.compliance import (
|
from app.schemas.compliance import (
|
||||||
AnalyzeResponse,
|
AnalyzeResponse,
|
||||||
ComplianceChatRequest,
|
ComplianceChatRequest,
|
||||||
)
|
)
|
||||||
from app.services.mock_data import generate_task_id, get_mock_compliance_result
|
from app.services.mock_data import generate_task_id, get_mock_compliance_result
|
||||||
from app.shared.bootstrap import get_agent_conversation_service
|
from app.shared.bootstrap import get_agent_conversation_service, get_retrieval_service
|
||||||
|
from app.config.settings import settings
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/compliance", tags=["合规分析"])
|
router = APIRouter(prefix="/compliance", tags=["合规分析"])
|
||||||
@@ -62,6 +66,188 @@ async def get_result(task_id: str):
|
|||||||
return task["result"]
|
return task["result"]
|
||||||
|
|
||||||
|
|
||||||
|
def _sse(data: dict) -> str:
|
||||||
|
return f"event: message\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/analyze-stream")
|
||||||
|
async def analyze_stream(
|
||||||
|
text: Optional[str] = Form(None),
|
||||||
|
doc_id: Optional[str] = Form(None),
|
||||||
|
file: Optional[UploadFile] = File(None),
|
||||||
|
domains: Optional[str] = Form(None),
|
||||||
|
title: Optional[str] = Form(None),
|
||||||
|
current_user: UserClaims = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Stream compliance analysis as SSE events.
|
||||||
|
|
||||||
|
Stages: clause_split → retrieval (per clause) → gap_check → conclusion
|
||||||
|
Events: stage | source | finding | done | error
|
||||||
|
"""
|
||||||
|
from app.application.compliance.pipeline import (
|
||||||
|
detect_cross_clause_conflicts,
|
||||||
|
extract_text_from_doc_id,
|
||||||
|
extract_text_from_file,
|
||||||
|
run_clauses_streaming,
|
||||||
|
split_into_clauses,
|
||||||
|
synthesize_conclusion,
|
||||||
|
)
|
||||||
|
from app.services.llm.llm_factory import get_llm_client
|
||||||
|
from app.shared.bootstrap import get_retrieval_service
|
||||||
|
|
||||||
|
# Read file content eagerly (before async generator)
|
||||||
|
file_content: bytes | None = None
|
||||||
|
file_name: str | None = None
|
||||||
|
if file is not None:
|
||||||
|
file_content = await file.read()
|
||||||
|
file_name = file.filename
|
||||||
|
|
||||||
|
async def generate() -> AsyncGenerator[str, None]:
|
||||||
|
try:
|
||||||
|
client = get_llm_client(provider=settings.llm_provider, model=settings.llm_model)
|
||||||
|
retrieval_service = get_retrieval_service()
|
||||||
|
|
||||||
|
# ── Stage 1: extract text ─────────────────────────────────────
|
||||||
|
yield _sse({"type": "stage", "stage": "extracting", "label": "Extracting text…"})
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
if text:
|
||||||
|
para_text = text.strip()
|
||||||
|
elif doc_id:
|
||||||
|
try:
|
||||||
|
para_text = await asyncio.to_thread(extract_text_from_doc_id, doc_id)
|
||||||
|
except Exception as exc:
|
||||||
|
yield _sse({"type": "error", "text": f"Document not found: {exc}"})
|
||||||
|
return
|
||||||
|
elif file_content is not None:
|
||||||
|
para_text = await asyncio.to_thread(
|
||||||
|
extract_text_from_file, file_content, file_name or "upload"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
yield _sse({"type": "error", "text": "No input provided"})
|
||||||
|
return
|
||||||
|
|
||||||
|
if not para_text.strip():
|
||||||
|
yield _sse({"type": "error", "text": "Could not extract text from the provided input"})
|
||||||
|
return
|
||||||
|
|
||||||
|
# ── Stage 2: split into clauses ───────────────────────────────
|
||||||
|
yield _sse({"type": "stage", "stage": "splitting", "label": "Splitting into clauses…"})
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
clauses: list[str] = await asyncio.to_thread(split_into_clauses, para_text, client)
|
||||||
|
|
||||||
|
# ── Stage 3: progressive per-clause retrieve + gap check ──────
|
||||||
|
findings: list[dict] = []
|
||||||
|
total_clauses = len(clauses)
|
||||||
|
|
||||||
|
yield _sse({
|
||||||
|
"type": "stage",
|
||||||
|
"stage": "analyzing",
|
||||||
|
"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)
|
||||||
|
|
||||||
|
done_count = 0
|
||||||
|
# Stream results as each clause completes (not after all finish)
|
||||||
|
async for res in run_clauses_streaming(
|
||||||
|
clauses, retrieval_service, client,
|
||||||
|
top_k=5,
|
||||||
|
domains=domains or None,
|
||||||
|
):
|
||||||
|
done_count += 1
|
||||||
|
i = res["index"]
|
||||||
|
chunks = res["chunks"]
|
||||||
|
finding = res["finding"]
|
||||||
|
|
||||||
|
# Emit source events for this clause
|
||||||
|
for chunk in chunks[:3]:
|
||||||
|
yield _sse({
|
||||||
|
"type": "source",
|
||||||
|
"standard": getattr(chunk, "doc_title", "") or getattr(chunk, "doc_name", ""),
|
||||||
|
"clause": getattr(chunk, "section_title", "") or "",
|
||||||
|
"score": round(float(getattr(chunk, "score", 0)), 3),
|
||||||
|
"status": "retrieved",
|
||||||
|
"full_content": (getattr(chunk, "text", "") or "")[:300],
|
||||||
|
"clause_index": i,
|
||||||
|
})
|
||||||
|
|
||||||
|
if finding:
|
||||||
|
findings.append(finding)
|
||||||
|
yield _sse({"type": "finding", **finding})
|
||||||
|
|
||||||
|
# Real progress update after each clause completes
|
||||||
|
yield _sse({"type": "progress", "done": done_count, "total": total_clauses})
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
# ── Stage 3b: cross-clause conflict detection ─────────────────
|
||||||
|
if findings:
|
||||||
|
conflicts = await asyncio.to_thread(
|
||||||
|
detect_cross_clause_conflicts, findings, client
|
||||||
|
)
|
||||||
|
if conflicts:
|
||||||
|
yield _sse({"type": "conflicts", "items": conflicts})
|
||||||
|
|
||||||
|
# ── Stage 4: synthesize conclusion ────────────────────────────
|
||||||
|
yield _sse({"type": "stage", "stage": "concluding", "label": "Generating conclusion…"})
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
conclusion_data = await asyncio.to_thread(
|
||||||
|
synthesize_conclusion, para_text, findings, client
|
||||||
|
)
|
||||||
|
yield _sse({"type": "done", **conclusion_data})
|
||||||
|
|
||||||
|
# Auto-save analysis to database
|
||||||
|
try:
|
||||||
|
from app.shared.bootstrap import get_compliance_repository
|
||||||
|
from app.domain.compliance.ports import AnalysisRecord, FindingRecord
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
repo = get_compliance_repository()
|
||||||
|
finding_records = [
|
||||||
|
FindingRecord(
|
||||||
|
id="",
|
||||||
|
analysis_id="",
|
||||||
|
seq=i,
|
||||||
|
title=f.get("title", ""),
|
||||||
|
description=f.get("desc", ""),
|
||||||
|
status=f.get("status", "ok"),
|
||||||
|
clause_ref=f.get("clause_ref"),
|
||||||
|
)
|
||||||
|
for i, f in enumerate(findings)
|
||||||
|
]
|
||||||
|
record = AnalysisRecord(
|
||||||
|
id="",
|
||||||
|
created_at=datetime.utcnow(),
|
||||||
|
created_by=current_user.username if hasattr(current_user, "username") else None,
|
||||||
|
doc_name=file_name or (title or "Pasted text"),
|
||||||
|
standard_name=title or "",
|
||||||
|
risk_score=conclusion_data.get("risk_score", 0),
|
||||||
|
conclusion=conclusion_data.get("conclusion", ""),
|
||||||
|
actions=conclusion_data.get("actions", []),
|
||||||
|
para_text=conclusion_data.get("para_text", ""),
|
||||||
|
highlight_terms=conclusion_data.get("highlight_terms", []),
|
||||||
|
findings=finding_records,
|
||||||
|
)
|
||||||
|
analysis_id = await asyncio.to_thread(repo.save_analysis, record)
|
||||||
|
yield _sse({"type": "saved", "analysis_id": analysis_id})
|
||||||
|
except NotImplementedError:
|
||||||
|
pass # No postgres backend configured — skip saving
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Failed to auto-save compliance analysis: {}", exc)
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("analyze-stream pipeline error")
|
||||||
|
yield _sse({"type": "error", "text": str(exc)})
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
generate(),
|
||||||
|
media_type="text/event-stream",
|
||||||
|
headers={"Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/chat/{segment_id}")
|
@router.post("/chat/{segment_id}")
|
||||||
async def compliance_chat(segment_id: int, request: ComplianceChatRequest):
|
async def compliance_chat(segment_id: int, request: ComplianceChatRequest):
|
||||||
"""Stream compliance Q&A grounded in real vector retrieval."""
|
"""Stream compliance Q&A grounded in real vector retrieval."""
|
||||||
@@ -98,3 +284,226 @@ async def compliance_chat(segment_id: int, request: ComplianceChatRequest):
|
|||||||
media_type="text/event-stream",
|
media_type="text/event-stream",
|
||||||
headers={"Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no"},
|
headers={"Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no"},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/history")
|
||||||
|
async def list_history(
|
||||||
|
limit: int = 20,
|
||||||
|
offset: int = 0,
|
||||||
|
current_user: UserClaims = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Return paginated list of saved compliance analyses (newest first)."""
|
||||||
|
from app.shared.bootstrap import get_compliance_repository
|
||||||
|
try:
|
||||||
|
repo = get_compliance_repository()
|
||||||
|
records = await asyncio.to_thread(repo.list_analyses, limit, offset)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": r.id,
|
||||||
|
"created_at": r.created_at.isoformat(),
|
||||||
|
"created_by": r.created_by,
|
||||||
|
"doc_name": r.doc_name,
|
||||||
|
"standard_name": r.standard_name,
|
||||||
|
"risk_score": r.risk_score,
|
||||||
|
"finding_count": len(r.findings),
|
||||||
|
}
|
||||||
|
for r in records
|
||||||
|
]
|
||||||
|
except NotImplementedError:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/history/{analysis_id}")
|
||||||
|
async def get_history_item(
|
||||||
|
analysis_id: str,
|
||||||
|
current_user: UserClaims = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Return full analysis record including findings."""
|
||||||
|
from app.shared.bootstrap import get_compliance_repository
|
||||||
|
from fastapi import HTTPException
|
||||||
|
repo = get_compliance_repository()
|
||||||
|
record = await asyncio.to_thread(repo.get_analysis, analysis_id)
|
||||||
|
if not record:
|
||||||
|
raise HTTPException(status_code=404, detail="Analysis not found")
|
||||||
|
return {
|
||||||
|
"id": record.id,
|
||||||
|
"created_at": record.created_at.isoformat(),
|
||||||
|
"created_by": record.created_by,
|
||||||
|
"doc_name": record.doc_name,
|
||||||
|
"standard_name": record.standard_name,
|
||||||
|
"risk_score": record.risk_score,
|
||||||
|
"conclusion": record.conclusion,
|
||||||
|
"actions": record.actions,
|
||||||
|
"para_text": record.para_text,
|
||||||
|
"highlight_terms": record.highlight_terms,
|
||||||
|
"findings": [
|
||||||
|
{
|
||||||
|
"id": f.id,
|
||||||
|
"seq": f.seq,
|
||||||
|
"title": f.title,
|
||||||
|
"description": f.description,
|
||||||
|
"status": f.status,
|
||||||
|
"clause_ref": f.clause_ref,
|
||||||
|
}
|
||||||
|
for f in record.findings
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/history/{analysis_id}", status_code=204)
|
||||||
|
async def delete_history_item(
|
||||||
|
analysis_id: str,
|
||||||
|
current_user: UserClaims = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Delete a saved analysis (cascade removes findings and chat messages)."""
|
||||||
|
from app.shared.bootstrap import get_compliance_repository
|
||||||
|
repo = get_compliance_repository()
|
||||||
|
await asyncio.to_thread(repo.delete_analysis, analysis_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/history/{analysis_id}/download")
|
||||||
|
async def download_history_docx(
|
||||||
|
analysis_id: str,
|
||||||
|
current_user: UserClaims = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Return a DOCX compliance report for the given analysis."""
|
||||||
|
from app.shared.bootstrap import get_compliance_repository
|
||||||
|
from app.infrastructure.compliance.docx_export import generate_docx
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from fastapi.responses import Response
|
||||||
|
|
||||||
|
repo = get_compliance_repository()
|
||||||
|
record = await asyncio.to_thread(repo.get_analysis, analysis_id)
|
||||||
|
if not record:
|
||||||
|
raise HTTPException(status_code=404, detail="Analysis not found")
|
||||||
|
|
||||||
|
docx_bytes = await asyncio.to_thread(generate_docx, record)
|
||||||
|
safe_name = (record.doc_name or "report").replace(" ", "_")[:50]
|
||||||
|
filename = f"compliance_{safe_name}_{record.created_at.strftime('%Y%m%d')}.docx"
|
||||||
|
return Response(
|
||||||
|
content=docx_bytes,
|
||||||
|
media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||||
|
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/analyses/{analysis_id}/findings/{finding_id}/chat")
|
||||||
|
async def get_finding_chat_history(
|
||||||
|
analysis_id: str,
|
||||||
|
finding_id: str,
|
||||||
|
current_user: UserClaims = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Return persisted chat messages for a finding thread, oldest first."""
|
||||||
|
from app.shared.bootstrap import get_compliance_repository
|
||||||
|
try:
|
||||||
|
repo = get_compliance_repository()
|
||||||
|
messages = await asyncio.to_thread(repo.get_messages, finding_id)
|
||||||
|
return messages
|
||||||
|
except NotImplementedError:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/analyses/{analysis_id}/findings/{finding_id}/suggestions")
|
||||||
|
async def get_finding_suggestions(
|
||||||
|
analysis_id: str,
|
||||||
|
finding_id: str,
|
||||||
|
current_user: UserClaims = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Generate 3 LLM-powered follow-up question suggestions for a finding."""
|
||||||
|
from app.application.compliance.pipeline import generate_suggestions
|
||||||
|
from app.shared.bootstrap import get_compliance_repository
|
||||||
|
from app.services.llm.llm_factory import get_llm_client
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
repo = get_compliance_repository()
|
||||||
|
analysis = await asyncio.to_thread(repo.get_analysis, analysis_id)
|
||||||
|
if not analysis:
|
||||||
|
raise HTTPException(status_code=404, detail="Analysis not found")
|
||||||
|
|
||||||
|
finding = next((f for f in analysis.findings if f.id == finding_id), None)
|
||||||
|
if not finding:
|
||||||
|
raise HTTPException(status_code=404, detail="Finding not found")
|
||||||
|
|
||||||
|
client = get_llm_client(provider=settings.llm_provider, model=settings.llm_model)
|
||||||
|
questions = await asyncio.to_thread(generate_suggestions, finding, analysis, client)
|
||||||
|
return {"questions": questions}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/analyses/{analysis_id}/findings/{finding_id}/chat")
|
||||||
|
async def finding_chat(
|
||||||
|
analysis_id: str,
|
||||||
|
finding_id: str,
|
||||||
|
request: ComplianceChatRequest,
|
||||||
|
current_user: UserClaims = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Stream a grounded chat response for a specific finding.
|
||||||
|
|
||||||
|
Loads the finding and analysis from DB to build grounded context.
|
||||||
|
Persists both user message and assistant response to finding_chat_messages.
|
||||||
|
"""
|
||||||
|
from app.application.compliance.pipeline import build_finding_context
|
||||||
|
from app.shared.bootstrap import get_compliance_repository
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
repo = get_compliance_repository()
|
||||||
|
analysis = await asyncio.to_thread(repo.get_analysis, analysis_id)
|
||||||
|
if not analysis:
|
||||||
|
raise HTTPException(status_code=404, detail="Analysis not found")
|
||||||
|
finding = next((f for f in analysis.findings if f.id == finding_id), None)
|
||||||
|
if not finding:
|
||||||
|
raise HTTPException(status_code=404, detail="Finding not found")
|
||||||
|
|
||||||
|
# Persist user message
|
||||||
|
await asyncio.to_thread(
|
||||||
|
repo.save_message, analysis_id, finding_id, "user", request.query
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build message history (last 10 messages = 5 turns)
|
||||||
|
history = await asyncio.to_thread(repo.get_messages, finding_id)
|
||||||
|
history_messages = [
|
||||||
|
{"role": m["role"], "content": m["content"]}
|
||||||
|
for m in history[-10:]
|
||||||
|
]
|
||||||
|
|
||||||
|
# Build grounded system context
|
||||||
|
system_context = build_finding_context(finding, analysis)
|
||||||
|
full_query = f"[Compliance Finding Context]\n{system_context}\n\nUser question: {request.query}"
|
||||||
|
|
||||||
|
assistant_buffer: list[str] = []
|
||||||
|
|
||||||
|
async def generate() -> AsyncGenerator[str, None]:
|
||||||
|
try:
|
||||||
|
_, event_stream = get_agent_conversation_service().stream_chat(
|
||||||
|
query=full_query,
|
||||||
|
top_k=5,
|
||||||
|
prompt_template="compliance_qa",
|
||||||
|
)
|
||||||
|
for event in event_stream:
|
||||||
|
event_type = event.get("event", "")
|
||||||
|
if event_type == "content":
|
||||||
|
text = event.get("data", "")
|
||||||
|
if text:
|
||||||
|
assistant_buffer.append(text)
|
||||||
|
yield _sse({"type": "chunk", "text": text})
|
||||||
|
elif event_type == "done":
|
||||||
|
yield _sse({"type": "done"})
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("finding_chat stream error")
|
||||||
|
yield _sse({"type": "error", "text": str(exc)})
|
||||||
|
finally:
|
||||||
|
# Persist assistant response after stream completes
|
||||||
|
full_response = "".join(assistant_buffer)
|
||||||
|
if full_response:
|
||||||
|
try:
|
||||||
|
await asyncio.to_thread(
|
||||||
|
repo.save_message, analysis_id, finding_id, "assistant", full_response
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Failed to persist assistant message: {}", exc)
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
generate(),
|
||||||
|
media_type="text/event-stream",
|
||||||
|
headers={"Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no"},
|
||||||
|
)
|
||||||
|
|||||||
@@ -5,12 +5,15 @@ from __future__ import annotations
|
|||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
|
|
||||||
from fastapi import APIRouter, File, Form, HTTPException, UploadFile
|
from fastapi import APIRouter, BackgroundTasks, Depends, File, Form, HTTPException, UploadFile
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
|
from app.api.dependencies.auth import get_current_user
|
||||||
from app.api.models import DocumentUploadResponse
|
from app.api.models import DocumentUploadResponse
|
||||||
from app.application.documents import DocumentProcessResult
|
from app.application.documents import DocumentProcessResult
|
||||||
|
from app.config.settings import settings
|
||||||
|
from app.domain.auth.models import UserClaims
|
||||||
from app.shared.bootstrap import get_document_command_service, get_document_query_service
|
from app.shared.bootstrap import get_document_command_service, get_document_query_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.
|
||||||
|
|
||||||
@@ -31,16 +34,60 @@ def _document_response(result: DocumentProcessResult) -> DocumentUploadResponse:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_process_in_background(
|
||||||
|
*,
|
||||||
|
doc_id: str,
|
||||||
|
file_name: str,
|
||||||
|
final_doc_name: str,
|
||||||
|
content: bytes,
|
||||||
|
regulation_type: str,
|
||||||
|
version: str,
|
||||||
|
generate_summary: bool,
|
||||||
|
run_id: str | None,
|
||||||
|
) -> None:
|
||||||
|
"""Run document processing synchronously inside a FastAPI BackgroundTask thread.
|
||||||
|
|
||||||
|
FastAPI executes BackgroundTasks in a threadpool executor, so blocking I/O
|
||||||
|
(parser API calls, embedding, Milvus upsert) is safe here.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
svc = get_document_command_service()
|
||||||
|
svc._process_document(
|
||||||
|
doc_id=doc_id,
|
||||||
|
file_name=file_name,
|
||||||
|
final_doc_name=final_doc_name,
|
||||||
|
content=content,
|
||||||
|
regulation_type=regulation_type,
|
||||||
|
version=version,
|
||||||
|
generate_summary=generate_summary,
|
||||||
|
run_id=run_id,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("BackgroundTask document processing failed: doc_id={}", doc_id)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/upload", response_model=DocumentUploadResponse)
|
@router.post("/upload", response_model=DocumentUploadResponse)
|
||||||
async def upload_document(
|
async def upload_document(
|
||||||
|
background_tasks: BackgroundTasks,
|
||||||
file: UploadFile = File(..., description="上传的文档文件"),
|
file: UploadFile = File(..., description="上传的文档文件"),
|
||||||
doc_id: str | None = Form(None, description="客户端预分配的文档ID,不传则自动生成"),
|
doc_id: str | None = Form(None, description="客户端预分配的文档ID,不传则自动生成"),
|
||||||
doc_name: str | None = Form(None, description="文档名称"),
|
doc_name: str | None = Form(None, description="文档名称"),
|
||||||
regulation_type: str | None = Form(None, description="法规类型"),
|
regulation_type: str | None = Form(None, description="法规类型"),
|
||||||
version: str | None = Form(None, description="文档版本"),
|
version: str | None = Form(None, description="文档版本"),
|
||||||
generate_summary: bool = Form(False, description="是否生成摘要"),
|
generate_summary: bool = Form(False, description="是否生成摘要"),
|
||||||
|
sync: bool = Form(False, description="同步处理(演示/测试用,默认异步处理)"),
|
||||||
|
current_user: UserClaims = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""Handle upload document."""
|
"""Upload a document and process it asynchronously.
|
||||||
|
|
||||||
|
Default path (sync=false):
|
||||||
|
1. Store binary to MinIO immediately — returns within seconds.
|
||||||
|
2. Schedule parse→embed→index as a FastAPI BackgroundTask (same process,
|
||||||
|
threadpool) OR enqueue to Celery workers when USE_CELERY_WORKER=true.
|
||||||
|
3. Poll GET /documents/status/{doc_id} for progress.
|
||||||
|
|
||||||
|
sync=true path: full inline processing, blocks until complete (demo / CI use).
|
||||||
|
"""
|
||||||
content = await file.read()
|
content = await file.read()
|
||||||
if not file.filename:
|
if not file.filename:
|
||||||
raise HTTPException(status_code=400, detail="文件名不能为空")
|
raise HTTPException(status_code=400, detail="文件名不能为空")
|
||||||
@@ -48,19 +95,73 @@ async def upload_document(
|
|||||||
raise HTTPException(status_code=400, detail="上传文件为空")
|
raise HTTPException(status_code=400, detail="上传文件为空")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = get_document_command_service().upload_and_process(
|
svc = get_document_command_service()
|
||||||
doc_id=doc_id,
|
|
||||||
file_name=file.filename,
|
if sync:
|
||||||
content=content,
|
# Synchronous fallback: full inline processing.
|
||||||
content_type=file.content_type or "application/octet-stream",
|
result = svc.upload_and_process(
|
||||||
doc_name=doc_name,
|
doc_id=doc_id,
|
||||||
regulation_type=regulation_type or "",
|
file_name=file.filename,
|
||||||
version=version or "",
|
content=content,
|
||||||
generate_summary=generate_summary,
|
content_type=file.content_type or "application/octet-stream",
|
||||||
)
|
doc_name=doc_name,
|
||||||
|
regulation_type=regulation_type or "",
|
||||||
|
version=version or "",
|
||||||
|
generate_summary=generate_summary,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Step 1: store binary and create the document record (fast, sync).
|
||||||
|
stored_doc_id, run_id = svc.store_document(
|
||||||
|
doc_id=doc_id,
|
||||||
|
file_name=file.filename,
|
||||||
|
content=content,
|
||||||
|
content_type=file.content_type or "application/octet-stream",
|
||||||
|
doc_name=doc_name,
|
||||||
|
regulation_type=regulation_type or "",
|
||||||
|
version=version or "",
|
||||||
|
generate_summary=generate_summary,
|
||||||
|
)
|
||||||
|
final_doc_name = doc_name or file.filename
|
||||||
|
|
||||||
|
# Step 2: schedule processing via Celery worker OR FastAPI BackgroundTask.
|
||||||
|
if settings.use_celery_worker:
|
||||||
|
from app.infrastructure.tasks.document_tasks import process_document_task
|
||||||
|
process_document_task.delay(
|
||||||
|
doc_id=stored_doc_id,
|
||||||
|
file_name=file.filename,
|
||||||
|
doc_name=final_doc_name,
|
||||||
|
regulation_type=regulation_type or "",
|
||||||
|
version=version or "",
|
||||||
|
generate_summary=generate_summary,
|
||||||
|
run_id=run_id,
|
||||||
|
)
|
||||||
|
processing_note = "已入 Celery 队列,由 Worker 处理。"
|
||||||
|
else:
|
||||||
|
# Default: run in FastAPI's threadpool — no external worker needed.
|
||||||
|
background_tasks.add_task(
|
||||||
|
_run_process_in_background,
|
||||||
|
doc_id=stored_doc_id,
|
||||||
|
file_name=file.filename,
|
||||||
|
final_doc_name=final_doc_name,
|
||||||
|
content=content,
|
||||||
|
regulation_type=regulation_type or "",
|
||||||
|
version=version or "",
|
||||||
|
generate_summary=generate_summary,
|
||||||
|
run_id=run_id,
|
||||||
|
)
|
||||||
|
processing_note = "正在后台处理。"
|
||||||
|
|
||||||
|
result = DocumentProcessResult(
|
||||||
|
doc_id=stored_doc_id,
|
||||||
|
doc_name=final_doc_name,
|
||||||
|
status="stored",
|
||||||
|
message=f"文件已存储,{processing_note}请轮询 GET /documents/status/{{doc_id}} 查看进度。",
|
||||||
|
)
|
||||||
|
|
||||||
if result.status == "failed":
|
if result.status == "failed":
|
||||||
raise HTTPException(status_code=500, detail=result.message)
|
raise HTTPException(status_code=500, detail=result.message)
|
||||||
return _document_response(result)
|
return _document_response(result)
|
||||||
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -106,7 +207,7 @@ async def download_document(doc_id: str):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/list")
|
@router.get("/list")
|
||||||
async def list_documents():
|
async def list_documents(current_user: UserClaims = Depends(get_current_user)):
|
||||||
"""List documents."""
|
"""List documents."""
|
||||||
documents = get_document_query_service().list_documents()
|
documents = get_document_query_service().list_documents()
|
||||||
return {
|
return {
|
||||||
@@ -140,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
|
||||||
],
|
],
|
||||||
@@ -148,7 +252,7 @@ async def get_document_management_list():
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/{doc_id}")
|
@router.delete("/{doc_id}")
|
||||||
async def delete_document(doc_id: str):
|
async def delete_document(doc_id: str, current_user: UserClaims = Depends(get_current_user)):
|
||||||
"""Delete a document and its associated data."""
|
"""Delete a document and its associated data."""
|
||||||
deleted = get_document_command_service().delete(doc_id)
|
deleted = get_document_command_service().delete(doc_id)
|
||||||
if not deleted:
|
if not deleted:
|
||||||
|
|||||||
@@ -4,10 +4,12 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
|
|
||||||
from fastapi import APIRouter, Query
|
from fastapi import APIRouter, Depends, Query
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
|
|
||||||
from app.shared.bootstrap import get_perception_service
|
from app.shared.bootstrap import get_crawl_service, get_event_store, get_perception_service
|
||||||
|
from app.api.dependencies.auth import get_current_user
|
||||||
|
from app.domain.auth.models import UserClaims
|
||||||
from app.shared.async_utils import iter_in_thread
|
from app.shared.async_utils import iter_in_thread
|
||||||
|
|
||||||
router = APIRouter(prefix="/perception", tags=["智能感知"])
|
router = APIRouter(prefix="/perception", tags=["智能感知"])
|
||||||
@@ -65,3 +67,77 @@ async def analyze_event(event_id: str):
|
|||||||
"X-Accel-Buffering": "no",
|
"X-Accel-Buffering": "no",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/crawl")
|
||||||
|
async def run_crawl(
|
||||||
|
body: dict = None,
|
||||||
|
current_user: UserClaims = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Trigger manual crawl of regulatory sources. Streams SSE progress.
|
||||||
|
|
||||||
|
Body (optional): {"sources": ["CATARC", "国标委·强制性", "EUR-Lex"]}
|
||||||
|
Omit sources to crawl all registered sources.
|
||||||
|
"""
|
||||||
|
sources: list[str] | None = (body or {}).get("sources")
|
||||||
|
crawl_svc = get_crawl_service()
|
||||||
|
|
||||||
|
async def crawl_stream():
|
||||||
|
async for item in iter_in_thread(crawl_svc.run_crawl(sources=sources)):
|
||||||
|
event_name = item.get("event", "message")
|
||||||
|
data = item.get("data", "")
|
||||||
|
if isinstance(data, (dict, list)):
|
||||||
|
data = json.dumps(data, ensure_ascii=False)
|
||||||
|
yield f"event: {event_name}\ndata: {data}\n\n"
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
crawl_stream(),
|
||||||
|
media_type="text/event-stream",
|
||||||
|
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/events/{event_id}/process")
|
||||||
|
async def process_event(
|
||||||
|
event_id: str,
|
||||||
|
current_user: UserClaims = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Trigger LLM pipeline (extract + assess + diff) for a single event."""
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from app.infrastructure.perception.llm_pipeline import LlmPipeline
|
||||||
|
from app.shared.bootstrap import get_retrieval_service
|
||||||
|
|
||||||
|
event = get_perception_service().get_event(event_id)
|
||||||
|
if not event:
|
||||||
|
from fastapi import HTTPException
|
||||||
|
raise HTTPException(status_code=404, detail=f"Event {event_id} not found")
|
||||||
|
|
||||||
|
store = get_event_store()
|
||||||
|
pipeline = LlmPipeline()
|
||||||
|
|
||||||
|
structure = pipeline.extract_structure(event)
|
||||||
|
event.update(structure)
|
||||||
|
event["affected_docs"] = pipeline.assess_impact(event, get_retrieval_service())
|
||||||
|
event["processed_at"] = datetime.now(UTC).isoformat()
|
||||||
|
store.upsert(event)
|
||||||
|
|
||||||
|
return {"status": "ok", "event_id": event_id, "processed_at": event["processed_at"]}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/events/{event_id}/diff")
|
||||||
|
async def get_event_diff(event_id: str):
|
||||||
|
"""Return semantic diff detail for an event (only available if previously crawled twice)."""
|
||||||
|
event = get_perception_service().get_event(event_id)
|
||||||
|
if not event:
|
||||||
|
from fastapi import HTTPException
|
||||||
|
raise HTTPException(status_code=404, detail=f"Event {event_id} not found")
|
||||||
|
if not event.get("change_summary"):
|
||||||
|
from fastapi import HTTPException
|
||||||
|
raise HTTPException(status_code=404, detail="No diff available for this event")
|
||||||
|
return {
|
||||||
|
"event_id": event_id,
|
||||||
|
"change_summary": event.get("change_summary"),
|
||||||
|
"changed_sections": event.get("changed_sections") or [],
|
||||||
|
"previous_hash": event.get("previous_hash"),
|
||||||
|
"content_hash": event.get("content_hash"),
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,16 +3,24 @@
|
|||||||
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
|
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.config.settings import settings
|
from app.config.settings import settings
|
||||||
|
from app.domain.auth.models import UserClaims
|
||||||
from app.schemas.rag import RagChatRequest, QuickQuestionsResponse, QuickQuestion
|
from app.schemas.rag import RagChatRequest, QuickQuestionsResponse, QuickQuestion
|
||||||
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问答"])
|
||||||
|
|
||||||
@@ -26,14 +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(request: RagChatRequest):
|
async def rag_chat(
|
||||||
"""Stream RAG Q&A using the real agent service."""
|
request: RagChatRequest,
|
||||||
|
current_user: UserClaims = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""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,24 @@
|
|||||||
"""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
|
||||||
|
|
||||||
from app.config.settings import settings
|
from app.config.settings import settings
|
||||||
|
from app.domain.retrieval import RetrievedChunk
|
||||||
|
from app.services.llm.llm_factory import get_llm_client, get_llm_factory
|
||||||
from app.shared.bootstrap import (
|
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 +29,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 +127,156 @@ 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]}
|
||||||
|
|||||||
@@ -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.5-flash) is sufficient for generating
|
||||||
|
# a short hypothetical passage and significantly reduces cost + latency.
|
||||||
|
provider = settings.hyde_llm_provider or settings.llm_provider
|
||||||
|
model = settings.hyde_llm_model or settings.llm_model
|
||||||
|
|
||||||
|
try:
|
||||||
|
client = get_llm_client(provider=provider, model=model)
|
||||||
|
resp = client.chat(
|
||||||
|
messages=[
|
||||||
|
{"role": "system", "content": _HYDE_SYSTEM},
|
||||||
|
{"role": "user", "content": f"问题:{query}"},
|
||||||
|
],
|
||||||
|
max_tokens=settings.hyde_max_tokens,
|
||||||
|
# Low temperature: we want a plausible, deterministic passage.
|
||||||
|
temperature=0.3,
|
||||||
|
)
|
||||||
|
if not resp.is_success or not resp.content:
|
||||||
|
logger.debug("HyDE LLM call failed or empty — using original query")
|
||||||
|
return query
|
||||||
|
|
||||||
|
hypothesis = resp.content.strip()[:_MAX_HYPOTHESIS_CHARS]
|
||||||
|
logger.debug("HyDE expanded query ({}→{} chars)", len(query), len(hypothesis))
|
||||||
|
|
||||||
|
# Concatenate: the embedding model will see the full combined text,
|
||||||
|
# so the resulting vector leans toward the hypothetical document style.
|
||||||
|
return f"{query}\n\n{hypothesis}"
|
||||||
|
|
||||||
|
except Exception as exc: # noqa: BLE001 — intentional broad catch for graceful fallback
|
||||||
|
logger.warning("HyDE expansion failed: {} — using original query", exc)
|
||||||
|
return query
|
||||||
@@ -9,6 +9,7 @@ from app.domain.conversation import AnswerGenerator, AnswerResult, ConversationS
|
|||||||
from app.domain.retrieval import RetrievedChunk
|
from app.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)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Compliance application layer."""
|
||||||
@@ -0,0 +1,570 @@
|
|||||||
|
"""Compliance analysis pipeline helpers.
|
||||||
|
|
||||||
|
All functions are synchronous — call them via asyncio.to_thread() in async SSE generators.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import tempfile
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
|
from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_exponential
|
||||||
|
|
||||||
|
# Shared retry policy for LLM calls: 3 attempts, exponential back-off 1–4 s.
|
||||||
|
_llm_retry = retry(
|
||||||
|
stop=stop_after_attempt(3),
|
||||||
|
wait=wait_exponential(multiplier=1, min=1, max=4),
|
||||||
|
retry=retry_if_exception_type((ValueError, TimeoutError, ConnectionError)),
|
||||||
|
reraise=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from app.application.knowledge import KnowledgeRetrievalService
|
||||||
|
from app.domain.retrieval import RetrievedChunk
|
||||||
|
from app.domain.compliance.ports import AnalysisRecord, FindingRecord
|
||||||
|
from app.services.llm.base_client import BaseLLMClient
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_json(text: str):
|
||||||
|
"""Extract JSON from LLM response, tolerating markdown wrappers."""
|
||||||
|
stripped = text.strip()
|
||||||
|
match = re.search(r"```(?:json)?\s*([\s\S]*?)```", stripped)
|
||||||
|
if match:
|
||||||
|
stripped = match.group(1).strip()
|
||||||
|
try:
|
||||||
|
return json.loads(stripped)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
for pattern in (r"(\[[\s\S]*\])", r"(\{[\s\S]*\})"):
|
||||||
|
m = re.search(pattern, stripped)
|
||||||
|
if m:
|
||||||
|
try:
|
||||||
|
return json.loads(m.group(1))
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
raise ValueError(f"No valid JSON found in LLM response: {text[:300]}")
|
||||||
|
|
||||||
|
|
||||||
|
def extract_text_from_doc_id(doc_id: str) -> str:
|
||||||
|
"""Fetch the full text of a document by retrieving its chunks filtered by doc_id.
|
||||||
|
|
||||||
|
Uses a high top_k and doc_id filter to reconstruct the document in chunk order,
|
||||||
|
avoiding the previous approach of semantic search by doc_name which could return
|
||||||
|
chunks from unrelated documents.
|
||||||
|
"""
|
||||||
|
from app.shared.bootstrap import get_document_query_service, get_retrieval_service
|
||||||
|
doc = get_document_query_service().get(doc_id)
|
||||||
|
if not doc:
|
||||||
|
raise ValueError(f"Document '{doc_id}' not found")
|
||||||
|
service = get_retrieval_service()
|
||||||
|
# Use doc_name as a broad query, filter strictly by doc_id so we only get
|
||||||
|
# this document's chunks; top_k=100 covers most real-world documents.
|
||||||
|
chunks = service.retrieve(query=doc.doc_name, top_k=100, filters=doc_id)
|
||||||
|
doc_chunks = [c for c in chunks if getattr(c, "doc_id", None) == doc_id]
|
||||||
|
if not doc_chunks:
|
||||||
|
# Fallback: use top results even without doc_id match (e.g., legacy store)
|
||||||
|
doc_chunks = chunks[:30]
|
||||||
|
# Sort by chunk_index to preserve document reading order
|
||||||
|
doc_chunks.sort(key=lambda c: getattr(c, "chunk_index", 0))
|
||||||
|
return "\n\n".join(c.text for c in doc_chunks[:40])
|
||||||
|
|
||||||
|
|
||||||
|
def extract_text_from_file(content: bytes, filename: str) -> str:
|
||||||
|
"""Parse an uploaded file and return its full text content.
|
||||||
|
|
||||||
|
Removed previous 4000-char cap so large specifications and standards are
|
||||||
|
fully analysed. The caller is responsible for splitting the text into
|
||||||
|
clause-sized chunks before passing to the LLM.
|
||||||
|
"""
|
||||||
|
from app.shared.bootstrap import get_document_command_service
|
||||||
|
suffix = os.path.splitext(filename or "doc.pdf")[1] or ".pdf"
|
||||||
|
tmp_path = ""
|
||||||
|
try:
|
||||||
|
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
|
||||||
|
tmp.write(content)
|
||||||
|
tmp_path = tmp.name
|
||||||
|
service = get_document_command_service()
|
||||||
|
parsed = service.parser.parse(file_path=tmp_path, doc_id="tmp_analysis", doc_name=filename)
|
||||||
|
if parsed.raw_text:
|
||||||
|
# Return full text — truncation happens in split_into_clauses()
|
||||||
|
return parsed.raw_text
|
||||||
|
return "\n".join(
|
||||||
|
b.get("text", "") for b in parsed.semantic_blocks if b.get("text")
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("File text extraction failed: {}", exc)
|
||||||
|
return ""
|
||||||
|
finally:
|
||||||
|
if tmp_path:
|
||||||
|
try: os.unlink(tmp_path)
|
||||||
|
except OSError: pass
|
||||||
|
|
||||||
|
|
||||||
|
def split_into_clauses(text: str, client: "BaseLLMClient") -> list[str]:
|
||||||
|
"""Split a compliance document into semantically independent clauses.
|
||||||
|
|
||||||
|
For long texts (> 2 000 chars) the document is processed in overlapping
|
||||||
|
2 000-char windows so no content is missed. Each window produces up to 4
|
||||||
|
clauses; results are deduplicated and capped at 12 total to keep analysis
|
||||||
|
latency reasonable.
|
||||||
|
"""
|
||||||
|
# Window size and step for sliding-window clause extraction
|
||||||
|
_WINDOW = 2000
|
||||||
|
_STEP = 1800 # 200-char overlap to avoid cutting clauses at boundaries
|
||||||
|
_MAX_CLAUSES = 12
|
||||||
|
|
||||||
|
windows = []
|
||||||
|
if len(text) <= _WINDOW:
|
||||||
|
windows = [text]
|
||||||
|
else:
|
||||||
|
pos = 0
|
||||||
|
while pos < len(text):
|
||||||
|
windows.append(text[pos: pos + _WINDOW])
|
||||||
|
pos += _STEP
|
||||||
|
|
||||||
|
all_clauses: list[str] = []
|
||||||
|
for window in windows:
|
||||||
|
prompt = (
|
||||||
|
"You are a compliance analysis expert. Split the following text into "
|
||||||
|
"3-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(
|
||||||
|
clause: str,
|
||||||
|
retrieval_service: "KnowledgeRetrievalService",
|
||||||
|
top_k: int = 5,
|
||||||
|
domains: str | None = None,
|
||||||
|
) -> list["RetrievedChunk"]:
|
||||||
|
"""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(
|
||||||
|
clause: str,
|
||||||
|
index: int,
|
||||||
|
retrieval_service: "KnowledgeRetrievalService",
|
||||||
|
client: "BaseLLMClient",
|
||||||
|
top_k: int = 5,
|
||||||
|
domains: str | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Process one clause: retrieve relevant regulations then check compliance.
|
||||||
|
|
||||||
|
Returns a dict with keys:
|
||||||
|
- index: clause position (for ordering)
|
||||||
|
- chunks: list of RetrievedChunk (for source events)
|
||||||
|
- finding: dict with title/desc/status/clause_ref/confidence (may be None on LLM failure)
|
||||||
|
|
||||||
|
Designed to run inside asyncio.to_thread() for parallel execution.
|
||||||
|
The finding now includes a 'source_refs' list linking back to the chunks
|
||||||
|
that informed the verdict, enabling the frontend to correlate sources with findings.
|
||||||
|
"""
|
||||||
|
chunks = retrieve_for_clause(clause, retrieval_service, top_k, domains)
|
||||||
|
finding = check_clause_compliance(clause, chunks, client)
|
||||||
|
if finding is not None:
|
||||||
|
# Attach source references so the frontend can link finding ↔ sources
|
||||||
|
finding["source_refs"] = [
|
||||||
|
{
|
||||||
|
"standard": getattr(c, "doc_title", "") or getattr(c, "doc_name", ""),
|
||||||
|
"clause": getattr(c, "section_title", "") or "",
|
||||||
|
"score": round(float(getattr(c, "score", 0)), 3),
|
||||||
|
}
|
||||||
|
for c in chunks[:3]
|
||||||
|
]
|
||||||
|
return {"index": index, "chunks": chunks, "finding": finding}
|
||||||
|
|
||||||
|
|
||||||
|
async def run_clauses_streaming(
|
||||||
|
clauses: list[str],
|
||||||
|
retrieval_service: "KnowledgeRetrievalService",
|
||||||
|
client: "BaseLLMClient",
|
||||||
|
top_k: int = 5,
|
||||||
|
domains: str | None = None,
|
||||||
|
):
|
||||||
|
"""Process all clauses concurrently and yield each result as it completes.
|
||||||
|
|
||||||
|
Unlike the old gather()-based approach, this uses asyncio.Queue so that
|
||||||
|
findings are emitted to the SSE stream immediately when each clause
|
||||||
|
finishes — the user sees results progressively rather than waiting for
|
||||||
|
the slowest clause before seeing any output.
|
||||||
|
|
||||||
|
Yields dicts with keys: index, chunks, finding (same schema as
|
||||||
|
process_single_clause, plus a sentinel {"_done": True} at the end).
|
||||||
|
"""
|
||||||
|
queue: asyncio.Queue[dict] = asyncio.Queue()
|
||||||
|
total = len(clauses)
|
||||||
|
|
||||||
|
async def _worker(clause: str, i: int) -> None:
|
||||||
|
"""Run one clause in a thread and push the result into the queue."""
|
||||||
|
try:
|
||||||
|
result = await asyncio.to_thread(
|
||||||
|
process_single_clause,
|
||||||
|
clause, i, retrieval_service, client, top_k, domains,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Clause {} processing failed: {}", i, exc)
|
||||||
|
result = {"index": i, "chunks": [], "finding": None}
|
||||||
|
await queue.put(result)
|
||||||
|
|
||||||
|
# Launch all workers concurrently
|
||||||
|
tasks = [asyncio.create_task(_worker(clause, i)) for i, clause in enumerate(clauses)]
|
||||||
|
|
||||||
|
received = 0
|
||||||
|
while received < total:
|
||||||
|
result = await queue.get()
|
||||||
|
yield result
|
||||||
|
received += 1
|
||||||
|
|
||||||
|
# Wait for all tasks to complete (they should already be done by now)
|
||||||
|
await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
|
||||||
|
|
||||||
|
async def run_clauses_parallel(
|
||||||
|
clauses: list[str],
|
||||||
|
retrieval_service: "KnowledgeRetrievalService",
|
||||||
|
client: "BaseLLMClient",
|
||||||
|
top_k: int = 5,
|
||||||
|
domains: str | None = None,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Legacy batch API kept for backward compatibility.
|
||||||
|
|
||||||
|
Collects all streaming results and returns them sorted by clause index.
|
||||||
|
New code should use run_clauses_streaming() directly.
|
||||||
|
"""
|
||||||
|
results: list[dict] = []
|
||||||
|
async for result in run_clauses_streaming(clauses, retrieval_service, client, top_k, domains):
|
||||||
|
results.append(result)
|
||||||
|
return sorted(results, key=lambda r: r["index"])
|
||||||
|
|
||||||
|
|
||||||
|
def check_clause_compliance(
|
||||||
|
clause: str,
|
||||||
|
chunks: list["RetrievedChunk"],
|
||||||
|
client: "BaseLLMClient",
|
||||||
|
) -> dict | None:
|
||||||
|
"""Check whether a business clause complies with the retrieved regulations.
|
||||||
|
|
||||||
|
The prompt explicitly instructs the LLM to:
|
||||||
|
- extract clause_ref from the retrieved text (not invent it)
|
||||||
|
- include a confidence score (0-1) reflecting how well the retrieved
|
||||||
|
chunks cover the clause topic
|
||||||
|
|
||||||
|
Returns None only when the LLM call fails after all retries.
|
||||||
|
"""
|
||||||
|
reg_context = "\n".join(
|
||||||
|
f"[{i+1}] {c.doc_title} {c.section_title or ''}: {c.text[:300]}"
|
||||||
|
for i, c in enumerate(chunks[:5])
|
||||||
|
) if chunks else "(no regulatory context retrieved)"
|
||||||
|
prompt = (
|
||||||
|
"You are a compliance expert. Judge whether the following business clause "
|
||||||
|
"complies with the retrieved regulations.\n\n"
|
||||||
|
f"Business clause:\n{clause}\n\n"
|
||||||
|
f"Retrieved regulations:\n{reg_context}\n\n"
|
||||||
|
"Return JSON with these exact fields:\n"
|
||||||
|
"{\n"
|
||||||
|
' "status": "ok" | "warn" | "risk",\n'
|
||||||
|
' "title": "Short finding title (max 30 chars)",\n'
|
||||||
|
' "desc": "Description (50-120 chars)",\n'
|
||||||
|
' "clause_ref": "Exact clause/article reference copied from the retrieved text above, '
|
||||||
|
'e.g. Art.9.1 or Sec.3.1. Use null if no specific clause number appears in the retrieved text.",\n'
|
||||||
|
' "confidence": 0.0-1.0 // how well the retrieved context covers this clause topic\n'
|
||||||
|
"}\n"
|
||||||
|
"status: ok=compliant, warn=gap exists, risk=critical/missing\n"
|
||||||
|
"IMPORTANT: copy clause_ref verbatim from the retrieved text; do NOT invent references.\n"
|
||||||
|
"Return ONLY the JSON object."
|
||||||
|
)
|
||||||
|
|
||||||
|
def _do_check():
|
||||||
|
resp = client.chat([{"role": "user", "content": prompt}], max_tokens=500)
|
||||||
|
if not resp.is_success:
|
||||||
|
raise ValueError("LLM returned non-success for gap check")
|
||||||
|
return resp
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = _llm_retry(_do_check)()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("check_clause_compliance LLM call failed after retries: {}", exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = _extract_json(response.content)
|
||||||
|
if isinstance(result, dict) and "status" in result:
|
||||||
|
return {
|
||||||
|
"title": str(result.get("title", "Compliance finding")),
|
||||||
|
"desc": str(result.get("desc", "")),
|
||||||
|
"status": result.get("status", "info"),
|
||||||
|
# None if LLM correctly found no clause number in retrieved text
|
||||||
|
"clause_ref": result.get("clause_ref") or None,
|
||||||
|
# Confidence score helps frontend show retrieval quality indicator
|
||||||
|
"confidence": float(result.get("confidence", 0.5)),
|
||||||
|
}
|
||||||
|
except (ValueError, TypeError) as exc:
|
||||||
|
logger.warning("Gap check JSON parse failed: {}", exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def synthesize_conclusion(
|
||||||
|
para_text: str,
|
||||||
|
findings: list[dict],
|
||||||
|
client: "BaseLLMClient",
|
||||||
|
) -> dict:
|
||||||
|
if not findings:
|
||||||
|
return {
|
||||||
|
"conclusion": "No significant compliance gaps found. Continue monitoring regulation updates.",
|
||||||
|
"actions": [{"label": "Next action", "value": "Monitor regulation updates"}],
|
||||||
|
"risk_score": 10,
|
||||||
|
"highlight_terms": [],
|
||||||
|
"para_text": para_text[:800],
|
||||||
|
}
|
||||||
|
findings_text = "\n".join(
|
||||||
|
f"- [{f['status'].upper()}] {f['title']}: {f['desc']}"
|
||||||
|
for f in findings
|
||||||
|
)
|
||||||
|
prompt = (
|
||||||
|
"You are a compliance analysis expert. Generate a summary report "
|
||||||
|
"based on the following compliance findings.\n\n"
|
||||||
|
f"Original text (first 600 chars):\n{para_text[:600]}\n\n"
|
||||||
|
f"Findings:\n{findings_text}\n\n"
|
||||||
|
"Return JSON:\n"
|
||||||
|
"{\n"
|
||||||
|
' "conclusion": "Overall compliance conclusion (100-200 chars)",\n'
|
||||||
|
' "actions": [\n'
|
||||||
|
' {"label": "Action label", "value": "Description"},\n'
|
||||||
|
' {"label": "Priority", "value": "High/Medium/Low", "risk": true}\n'
|
||||||
|
' ],\n'
|
||||||
|
' "risk_score": 0-100 (integer, higher=riskier),\n'
|
||||||
|
' "highlight_terms": ["term1", "term2"], // up to 10 key technical/legal terms actually present in the text\n'
|
||||||
|
' "para_text": "Original text or summary (max 600 chars)"\n'
|
||||||
|
"}\n"
|
||||||
|
"Return ONLY the JSON object."
|
||||||
|
)
|
||||||
|
fallback = {
|
||||||
|
"conclusion": "Compliance analysis complete. Review findings and create remediation plan.",
|
||||||
|
"actions": [
|
||||||
|
{"label": "Next action", "value": "Review critical findings"},
|
||||||
|
{"label": "Escalation", "value": "Legal review required", "risk": True},
|
||||||
|
],
|
||||||
|
"risk_score": 60,
|
||||||
|
"highlight_terms": [],
|
||||||
|
"para_text": para_text[:800],
|
||||||
|
}
|
||||||
|
|
||||||
|
def _do_synthesize():
|
||||||
|
resp = client.chat([{"role": "user", "content": prompt}], max_tokens=1200)
|
||||||
|
if not resp.is_success:
|
||||||
|
raise ValueError("LLM returned non-success for synthesis")
|
||||||
|
return resp
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = _llm_retry(_do_synthesize)()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("synthesize_conclusion LLM call failed after retries: {}", exc)
|
||||||
|
return fallback
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = _extract_json(response.content)
|
||||||
|
if isinstance(result, dict):
|
||||||
|
return {
|
||||||
|
"conclusion": str(result.get("conclusion", fallback["conclusion"])),
|
||||||
|
"actions": result.get("actions", fallback["actions"]),
|
||||||
|
"risk_score": int(result.get("risk_score", 60)),
|
||||||
|
"highlight_terms": result.get("highlight_terms", []),
|
||||||
|
"para_text": str(result.get("para_text", para_text[:800])),
|
||||||
|
}
|
||||||
|
except (ValueError, TypeError) as exc:
|
||||||
|
logger.warning("Conclusion synthesis JSON parse failed: {}", exc)
|
||||||
|
return fallback
|
||||||
|
|
||||||
|
|
||||||
|
_SUGGESTION_FOCUS = {
|
||||||
|
"risk": "Focus on remediation steps, required certifications, and timeline to resolve.",
|
||||||
|
"warn": "Focus on identifying the specific compliance gap and how to close it.",
|
||||||
|
"ok": "Focus on maintaining compliance evidence and monitoring future changes.",
|
||||||
|
}
|
||||||
|
|
||||||
|
_SUGGESTION_FALLBACK = {
|
||||||
|
"risk": [
|
||||||
|
"What specific certifications or documents are required to remediate this finding?",
|
||||||
|
"What is the typical remediation timeline for this type of non-compliance?",
|
||||||
|
"Which regulation clause defines the exact requirement?",
|
||||||
|
],
|
||||||
|
"warn": [
|
||||||
|
"What is the exact gap between the current state and the requirement?",
|
||||||
|
"What evidence would demonstrate partial compliance?",
|
||||||
|
"Which regulation clause applies to this warning?",
|
||||||
|
],
|
||||||
|
"ok": [
|
||||||
|
"What documentation should be maintained to evidence this compliance?",
|
||||||
|
"How should this area be monitored as regulations evolve?",
|
||||||
|
"Are there related clauses that may affect this compliant area?",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_finding_context(finding: "FindingRecord", analysis: "AnalysisRecord") -> str:
|
||||||
|
"""Build a grounded system context string for a finding chat thread.
|
||||||
|
|
||||||
|
Combines finding details with analysis metadata so the LLM has full
|
||||||
|
context without relying on the frontend to pass segment_context.
|
||||||
|
"""
|
||||||
|
return (
|
||||||
|
f"Document: {analysis.doc_name}\n"
|
||||||
|
f"Standard: {analysis.standard_name}\n"
|
||||||
|
f"Finding [{finding.seq + 1}]: {finding.title}\n"
|
||||||
|
f"Status: {finding.status}\n"
|
||||||
|
f"Clause reference: {finding.clause_ref or 'N/A'}\n"
|
||||||
|
f"Description: {finding.description}\n"
|
||||||
|
f"Overall conclusion: {analysis.conclusion}\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_suggestions(
|
||||||
|
finding: "FindingRecord",
|
||||||
|
analysis: "AnalysisRecord",
|
||||||
|
client: "BaseLLMClient",
|
||||||
|
) -> list[str]:
|
||||||
|
"""Generate 3 context-aware follow-up questions for a finding chat thread.
|
||||||
|
|
||||||
|
Returns exactly 3 question strings. Falls back to static templates on error.
|
||||||
|
"""
|
||||||
|
fallback = _SUGGESTION_FALLBACK.get(finding.status, _SUGGESTION_FALLBACK["warn"])
|
||||||
|
context = build_finding_context(finding, analysis)
|
||||||
|
focus = _SUGGESTION_FOCUS.get(finding.status, _SUGGESTION_FOCUS["warn"])
|
||||||
|
prompt = (
|
||||||
|
f"{context}\n\n"
|
||||||
|
f"Task: {focus}\n"
|
||||||
|
"Generate exactly 3 concise follow-up questions a compliance analyst would ask.\n"
|
||||||
|
'Return JSON: {"questions": ["question 1", "question 2", "question 3"]}\n'
|
||||||
|
"Return ONLY the JSON object."
|
||||||
|
)
|
||||||
|
response = client.chat([{"role": "user", "content": prompt}], max_tokens=300)
|
||||||
|
if not response.is_success:
|
||||||
|
return fallback
|
||||||
|
try:
|
||||||
|
result = _extract_json(response.content)
|
||||||
|
questions = result.get("questions", [])
|
||||||
|
if isinstance(questions, list) and len(questions) >= 3:
|
||||||
|
return [str(q) for q in questions[:3]]
|
||||||
|
except (ValueError, TypeError) as exc:
|
||||||
|
logger.warning("generate_suggestions JSON parse failed: {}", exc)
|
||||||
|
return fallback
|
||||||
|
|
||||||
|
|
||||||
|
def detect_cross_clause_conflicts(
|
||||||
|
findings: list[dict],
|
||||||
|
client: "BaseLLMClient",
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Detect contradictions and missing cross-references across all findings.
|
||||||
|
|
||||||
|
Runs a single LLM call after all per-clause findings are collected.
|
||||||
|
Returns a list of conflict dicts: {type, finding_a, finding_b, desc}.
|
||||||
|
Returns an empty list on LLM failure so the caller can proceed without it.
|
||||||
|
"""
|
||||||
|
if len(findings) < 2:
|
||||||
|
# Need at least 2 findings to compare
|
||||||
|
return []
|
||||||
|
|
||||||
|
findings_text = "\n".join(
|
||||||
|
f"[{i+1}] [{f['status'].upper()}] {f['title']}: {f['desc']}"
|
||||||
|
+ (f" (Ref: {f['clause_ref']})" if f.get("clause_ref") else "")
|
||||||
|
for i, f in enumerate(findings)
|
||||||
|
)
|
||||||
|
prompt = (
|
||||||
|
"You are a compliance expert. Review the following compliance findings from the same document "
|
||||||
|
"and identify any cross-clause issues:\n\n"
|
||||||
|
f"Findings:\n{findings_text}\n\n"
|
||||||
|
"Return JSON array of conflicts (empty array [] if none found):\n"
|
||||||
|
"[\n"
|
||||||
|
" {\n"
|
||||||
|
' "type": "contradiction" | "missing_ref" | "cumulative_risk",\n'
|
||||||
|
' "finding_a": <1-based index>,\n'
|
||||||
|
' "finding_b": <1-based index or null>,\n'
|
||||||
|
' "desc": "Brief description of the cross-clause issue (max 100 chars)"\n'
|
||||||
|
" }\n"
|
||||||
|
"]\n"
|
||||||
|
"Return ONLY the JSON array."
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
response = client.chat([{"role": "user", "content": prompt}], max_tokens=600)
|
||||||
|
if not response.is_success:
|
||||||
|
return []
|
||||||
|
result = _extract_json(response.content)
|
||||||
|
if isinstance(result, list):
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"type": str(c.get("type", "contradiction")),
|
||||||
|
"finding_a": int(c.get("finding_a", 0)),
|
||||||
|
"finding_b": c.get("finding_b"),
|
||||||
|
"desc": str(c.get("desc", "")),
|
||||||
|
}
|
||||||
|
for c in result
|
||||||
|
if isinstance(c, dict)
|
||||||
|
]
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("detect_cross_clause_conflicts failed: {}", exc)
|
||||||
|
return []
|
||||||
@@ -277,7 +277,6 @@ class DocumentCommandService:
|
|||||||
message="Document record created",
|
message="Document record created",
|
||||||
)
|
)
|
||||||
|
|
||||||
temp_path = ""
|
|
||||||
try:
|
try:
|
||||||
self.binary_store.save(
|
self.binary_store.save(
|
||||||
object_name=object_name,
|
object_name=object_name,
|
||||||
@@ -297,117 +296,20 @@ class DocumentCommandService:
|
|||||||
stage="store",
|
stage="store",
|
||||||
message="Source file stored",
|
message="Source file stored",
|
||||||
)
|
)
|
||||||
|
# Delegate parse → embed → index to the shared processing method.
|
||||||
suffix = os.path.splitext(file_name)[1]
|
# This same method is invoked by the Celery worker for async processing.
|
||||||
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_file:
|
return self._process_document(
|
||||||
temp_file.write(content)
|
|
||||||
temp_path = temp_file.name
|
|
||||||
|
|
||||||
parsed_document = self.parser.parse(
|
|
||||||
file_path=temp_path,
|
|
||||||
doc_id=doc_id,
|
doc_id=doc_id,
|
||||||
doc_name=final_doc_name,
|
file_name=file_name,
|
||||||
)
|
final_doc_name=final_doc_name,
|
||||||
self._safe_mark_run_parsed(doc_id=doc_id, run_id=run_id, parsed_document=parsed_document)
|
content=content,
|
||||||
|
|
||||||
artifact_keys: dict[str, str] = {}
|
|
||||||
try:
|
|
||||||
artifact_keys = self._save_parse_artifacts(doc_id=doc_id, parsed_document=parsed_document)
|
|
||||||
except Exception:
|
|
||||||
logger.warning("Parse artifact binary persistence failed for doc_id={}", doc_id)
|
|
||||||
self.document_repository.update_status(
|
|
||||||
doc_id,
|
|
||||||
DocumentStatus.PARSED,
|
|
||||||
parser_name=parsed_document.parser_name,
|
|
||||||
metadata={
|
|
||||||
"parser_backend": parsed_document.parser_name,
|
|
||||||
"parse_task_id": parsed_document.metadata.get("task_id", ""),
|
|
||||||
"layout_count": parsed_document.metadata.get("layout_count", len(parsed_document.raw_layouts)),
|
|
||||||
"structure_node_count": len(parsed_document.structure_nodes),
|
|
||||||
"semantic_block_count": len(parsed_document.semantic_blocks),
|
|
||||||
"vector_chunk_count": len(parsed_document.vector_chunks),
|
|
||||||
"artifact_keys": artifact_keys,
|
|
||||||
"processing_stage": "parsed",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
current_status = DocumentStatus.PARSED
|
|
||||||
current_stage = "embed"
|
|
||||||
self._safe_replace_processing_artifacts(doc_id=doc_id, run_id=run_id, artifact_keys=artifact_keys)
|
|
||||||
self._safe_append_status_event(
|
|
||||||
doc_id=doc_id,
|
|
||||||
run_id=run_id,
|
|
||||||
from_status=DocumentStatus.STORED.value,
|
|
||||||
to_status=DocumentStatus.PARSED.value,
|
|
||||||
stage="parse",
|
|
||||||
message="Document parsed",
|
|
||||||
metadata={"artifact_count": len(artifact_keys)},
|
|
||||||
)
|
|
||||||
if self.parse_artifact_store:
|
|
||||||
try:
|
|
||||||
self.parse_artifact_store.save(
|
|
||||||
doc_id,
|
|
||||||
parsed_document.structure_nodes,
|
|
||||||
parsed_document.semantic_blocks,
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
logger.warning("ParseArtifactStore.save failed for doc_id={}", doc_id)
|
|
||||||
|
|
||||||
chunks = self.chunk_builder.build(
|
|
||||||
parsed_document=parsed_document,
|
|
||||||
regulation_type=regulation_type,
|
regulation_type=regulation_type,
|
||||||
version=version,
|
version=version,
|
||||||
)
|
generate_summary=generate_summary,
|
||||||
if not chunks:
|
|
||||||
raise ValueError("解析完成但没有生成可入库的 chunks")
|
|
||||||
|
|
||||||
vectors = self.embedding_provider.embed_texts([chunk.embedding_text for chunk in chunks])
|
|
||||||
current_stage = "index"
|
|
||||||
inserted = self.vector_index.upsert(chunks, vectors)
|
|
||||||
if inserted != len(chunks):
|
|
||||||
logger.warning("Milvus upsert count mismatched: inserted={}, chunks={}", inserted, len(chunks))
|
|
||||||
|
|
||||||
health = self.vector_index.health()
|
|
||||||
self.document_repository.update_status(
|
|
||||||
doc_id,
|
|
||||||
DocumentStatus.INDEXED,
|
|
||||||
chunk_count=len(chunks),
|
|
||||||
summary="",
|
|
||||||
summary_latency_ms=0,
|
|
||||||
index_name=health.get("collection_name", ""),
|
|
||||||
metadata={
|
|
||||||
"index_collection": health.get("collection_name", ""),
|
|
||||||
"processing_stage": "indexed",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
current_status = DocumentStatus.INDEXED
|
|
||||||
index_name = health.get("collection_name", "")
|
|
||||||
self._safe_mark_run_indexed(
|
|
||||||
doc_id=doc_id,
|
|
||||||
run_id=run_id,
|
run_id=run_id,
|
||||||
chunk_count=len(chunks),
|
|
||||||
index_name=index_name,
|
|
||||||
)
|
|
||||||
self._safe_append_status_event(
|
|
||||||
doc_id=doc_id,
|
|
||||||
run_id=run_id,
|
|
||||||
from_status=DocumentStatus.PARSED.value,
|
|
||||||
to_status=DocumentStatus.INDEXED.value,
|
|
||||||
stage="index",
|
|
||||||
message="Document indexed",
|
|
||||||
metadata={"chunk_count": len(chunks), "index_name": index_name},
|
|
||||||
)
|
|
||||||
stored = self.document_repository.get(doc_id)
|
|
||||||
return DocumentProcessResult(
|
|
||||||
doc_id=doc_id,
|
|
||||||
doc_name=final_doc_name,
|
|
||||||
status=(stored.status.value if stored else DocumentStatus.INDEXED.value),
|
|
||||||
message="处理成功",
|
|
||||||
num_chunks=len(chunks),
|
|
||||||
summary=stored.summary if stored else "",
|
|
||||||
summary_latency_ms=stored.summary_latency_ms if stored else 0,
|
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.exception("文档处理失败: doc_id={}", doc_id)
|
logger.exception("文档存储失败: doc_id={}", doc_id)
|
||||||
failure_stage = current_stage
|
failure_stage = current_stage
|
||||||
self.document_repository.update_status(
|
self.document_repository.update_status(
|
||||||
doc_id,
|
doc_id,
|
||||||
@@ -439,6 +341,183 @@ class DocumentCommandService:
|
|||||||
status=DocumentStatus.FAILED.value,
|
status=DocumentStatus.FAILED.value,
|
||||||
message=f"文档处理失败: {exc}",
|
message=f"文档处理失败: {exc}",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def store_document(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
doc_id: str | None = None,
|
||||||
|
file_name: str,
|
||||||
|
content: bytes,
|
||||||
|
content_type: str,
|
||||||
|
doc_name: str | None,
|
||||||
|
regulation_type: str,
|
||||||
|
version: str,
|
||||||
|
generate_summary: bool,
|
||||||
|
) -> tuple[str, str | None]:
|
||||||
|
"""Store the binary file and create the Document record.
|
||||||
|
|
||||||
|
Returns (doc_id, run_id). Does NOT parse, embed, or index.
|
||||||
|
This is the fast synchronous first step; processing is enqueued separately.
|
||||||
|
The caller is responsible for enqueuing the follow-up process_document_task.
|
||||||
|
"""
|
||||||
|
doc_id = doc_id or str(uuid.uuid4())[:8]
|
||||||
|
final_doc_name = doc_name or file_name
|
||||||
|
object_name = f"{doc_id}/{file_name}"
|
||||||
|
|
||||||
|
document = Document(
|
||||||
|
doc_id=doc_id,
|
||||||
|
doc_name=final_doc_name,
|
||||||
|
file_name=file_name,
|
||||||
|
object_name=object_name,
|
||||||
|
content_type=content_type,
|
||||||
|
size_bytes=len(content),
|
||||||
|
regulation_type=regulation_type,
|
||||||
|
version=version,
|
||||||
|
metadata={"generate_summary": generate_summary},
|
||||||
|
)
|
||||||
|
self.document_repository.create(document)
|
||||||
|
run_id = self._safe_create_processing_run(
|
||||||
|
doc_id=doc_id, trigger_type="upload", generate_summary=generate_summary
|
||||||
|
)
|
||||||
|
self.binary_store.save(
|
||||||
|
object_name=object_name, data=content,
|
||||||
|
content_type=content_type, metadata={"doc_id": doc_id},
|
||||||
|
)
|
||||||
|
self.document_repository.update_status(doc_id, DocumentStatus.STORED)
|
||||||
|
self._safe_mark_run_stored(doc_id=doc_id, run_id=run_id)
|
||||||
|
self._safe_append_status_event(
|
||||||
|
doc_id=doc_id, run_id=run_id,
|
||||||
|
from_status=DocumentStatus.PENDING.value, to_status=DocumentStatus.STORED.value,
|
||||||
|
stage="store", message="Source file stored",
|
||||||
|
)
|
||||||
|
return doc_id, run_id
|
||||||
|
|
||||||
|
def _process_document(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
doc_id: str,
|
||||||
|
file_name: str,
|
||||||
|
final_doc_name: str,
|
||||||
|
content: bytes,
|
||||||
|
regulation_type: str,
|
||||||
|
version: str,
|
||||||
|
generate_summary: bool,
|
||||||
|
run_id: str | None = None,
|
||||||
|
) -> DocumentProcessResult:
|
||||||
|
"""Run parse → chunk → embed → index for a document that is already stored.
|
||||||
|
|
||||||
|
Called both synchronously (from upload_and_process) and asynchronously
|
||||||
|
(from the Celery process_document_task worker). All side-effects write
|
||||||
|
through DocumentProcessingStore so callers can poll progress.
|
||||||
|
"""
|
||||||
|
current_status = DocumentStatus.STORED
|
||||||
|
current_stage = "parse"
|
||||||
|
temp_path = ""
|
||||||
|
try:
|
||||||
|
suffix = os.path.splitext(file_name)[1]
|
||||||
|
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_file:
|
||||||
|
temp_file.write(content)
|
||||||
|
temp_path = temp_file.name
|
||||||
|
|
||||||
|
parsed_document = self.parser.parse(
|
||||||
|
file_path=temp_path,
|
||||||
|
doc_id=doc_id,
|
||||||
|
doc_name=final_doc_name,
|
||||||
|
)
|
||||||
|
self._safe_mark_run_parsed(doc_id=doc_id, run_id=run_id, parsed_document=parsed_document)
|
||||||
|
|
||||||
|
artifact_keys: dict[str, str] = {}
|
||||||
|
try:
|
||||||
|
artifact_keys = self._save_parse_artifacts(doc_id=doc_id, parsed_document=parsed_document)
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Parse artifact binary persistence failed for doc_id={}", doc_id)
|
||||||
|
|
||||||
|
self.document_repository.update_status(
|
||||||
|
doc_id,
|
||||||
|
DocumentStatus.PARSED,
|
||||||
|
parser_name=parsed_document.parser_name,
|
||||||
|
metadata={
|
||||||
|
"parser_backend": parsed_document.parser_name,
|
||||||
|
"parse_task_id": parsed_document.metadata.get("task_id", ""),
|
||||||
|
"layout_count": parsed_document.metadata.get("layout_count", len(parsed_document.raw_layouts)),
|
||||||
|
"structure_node_count": len(parsed_document.structure_nodes),
|
||||||
|
"semantic_block_count": len(parsed_document.semantic_blocks),
|
||||||
|
"vector_chunk_count": len(parsed_document.vector_chunks),
|
||||||
|
"artifact_keys": artifact_keys,
|
||||||
|
"processing_stage": "parsed",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
current_status = DocumentStatus.PARSED
|
||||||
|
current_stage = "embed"
|
||||||
|
self._safe_replace_processing_artifacts(doc_id=doc_id, run_id=run_id, artifact_keys=artifact_keys)
|
||||||
|
self._safe_append_status_event(
|
||||||
|
doc_id=doc_id, run_id=run_id,
|
||||||
|
from_status=DocumentStatus.STORED.value, to_status=DocumentStatus.PARSED.value,
|
||||||
|
stage="parse", message="Document parsed", metadata={"artifact_count": len(artifact_keys)},
|
||||||
|
)
|
||||||
|
if self.parse_artifact_store:
|
||||||
|
try:
|
||||||
|
self.parse_artifact_store.save(
|
||||||
|
doc_id, parsed_document.structure_nodes, parsed_document.semantic_blocks,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.warning("ParseArtifactStore.save failed for doc_id={}", doc_id)
|
||||||
|
|
||||||
|
chunks = self.chunk_builder.build(
|
||||||
|
parsed_document=parsed_document,
|
||||||
|
regulation_type=regulation_type,
|
||||||
|
version=version,
|
||||||
|
)
|
||||||
|
if not chunks:
|
||||||
|
raise ValueError("解析完成但没有生成可入库的 chunks")
|
||||||
|
|
||||||
|
vectors = self.embedding_provider.embed_texts([chunk.embedding_text for chunk in chunks])
|
||||||
|
current_stage = "index"
|
||||||
|
inserted = self.vector_index.upsert(chunks, vectors)
|
||||||
|
if inserted != len(chunks):
|
||||||
|
logger.warning("Milvus upsert count mismatched: inserted={}, chunks={}", inserted, len(chunks))
|
||||||
|
|
||||||
|
health = self.vector_index.health()
|
||||||
|
index_name = health.get("collection_name", "")
|
||||||
|
self.document_repository.update_status(
|
||||||
|
doc_id, DocumentStatus.INDEXED,
|
||||||
|
chunk_count=len(chunks), summary="", summary_latency_ms=0,
|
||||||
|
index_name=index_name,
|
||||||
|
metadata={"index_collection": index_name, "processing_stage": "indexed"},
|
||||||
|
)
|
||||||
|
self._safe_mark_run_indexed(doc_id=doc_id, run_id=run_id, chunk_count=len(chunks), index_name=index_name)
|
||||||
|
self._safe_append_status_event(
|
||||||
|
doc_id=doc_id, run_id=run_id,
|
||||||
|
from_status=DocumentStatus.PARSED.value, to_status=DocumentStatus.INDEXED.value,
|
||||||
|
stage="index", message="Document indexed",
|
||||||
|
metadata={"chunk_count": len(chunks), "index_name": index_name},
|
||||||
|
)
|
||||||
|
stored = self.document_repository.get(doc_id)
|
||||||
|
return DocumentProcessResult(
|
||||||
|
doc_id=doc_id, doc_name=final_doc_name,
|
||||||
|
status=(stored.status.value if stored else DocumentStatus.INDEXED.value),
|
||||||
|
message="处理成功", num_chunks=len(chunks),
|
||||||
|
summary=stored.summary if stored else "",
|
||||||
|
summary_latency_ms=stored.summary_latency_ms if stored else 0,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("文档处理失败: doc_id={}", doc_id)
|
||||||
|
self.document_repository.update_status(
|
||||||
|
doc_id, DocumentStatus.FAILED, error_message=str(exc),
|
||||||
|
metadata={"failure_reason": str(exc), "processing_stage": "failed", "failure_stage": current_stage},
|
||||||
|
)
|
||||||
|
self._safe_mark_run_failed(
|
||||||
|
doc_id=doc_id, run_id=run_id, failure_stage=current_stage, error_message=str(exc)
|
||||||
|
)
|
||||||
|
self._safe_append_status_event(
|
||||||
|
doc_id=doc_id, run_id=run_id,
|
||||||
|
from_status=current_status.value, to_status=DocumentStatus.FAILED.value,
|
||||||
|
stage=current_stage, message=str(exc),
|
||||||
|
)
|
||||||
|
return DocumentProcessResult(
|
||||||
|
doc_id=doc_id, doc_name=final_doc_name,
|
||||||
|
status=DocumentStatus.FAILED.value, message=f"文档处理失败: {exc}",
|
||||||
|
)
|
||||||
finally:
|
finally:
|
||||||
if temp_path and os.path.exists(temp_path):
|
if temp_path and os.path.exists(temp_path):
|
||||||
try:
|
try:
|
||||||
@@ -446,12 +525,29 @@ class DocumentCommandService:
|
|||||||
except OSError:
|
except OSError:
|
||||||
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:
|
||||||
@@ -549,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,
|
||||||
@@ -568,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)
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
"""Orchestrates regulatory source crawlers and LLM enrichment pipeline."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
from typing import Any, Generator
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from app.infrastructure.perception.base_event_store import BaseEventStore
|
||||||
|
from app.infrastructure.perception.crawlers.base import BaseCrawler, RawEvent
|
||||||
|
from app.infrastructure.perception.llm_pipeline import LlmPipeline
|
||||||
|
|
||||||
|
|
||||||
|
def _event_id(source: str, standard_code: str) -> str:
|
||||||
|
"""Deterministic 12-char ID from source + standard_code."""
|
||||||
|
return hashlib.sha256(f"{source}-{standard_code}".encode()).hexdigest()[:12]
|
||||||
|
|
||||||
|
|
||||||
|
def _content_hash(raw_text: str) -> str:
|
||||||
|
return hashlib.sha256(raw_text.encode()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _raw_to_dict(raw: RawEvent, event_id: str, content_hash: str) -> dict:
|
||||||
|
return {
|
||||||
|
"id": event_id,
|
||||||
|
"source": raw.source,
|
||||||
|
"source_label": raw.source_label,
|
||||||
|
"standard_code": raw.standard_code,
|
||||||
|
"title": raw.title,
|
||||||
|
"summary": raw.summary,
|
||||||
|
"full_text_url": raw.full_text_url,
|
||||||
|
"status": raw.status,
|
||||||
|
"impact_level": "medium",
|
||||||
|
"published_at": raw.published_at,
|
||||||
|
"effective_at": raw.effective_at,
|
||||||
|
"category": raw.category,
|
||||||
|
"tags": raw.tags,
|
||||||
|
"content_hash": content_hash,
|
||||||
|
"previous_hash": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class CrawlService:
|
||||||
|
"""Orchestrate crawlers, hash-based change detection, and LLM enrichment."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
crawlers: dict[str, BaseCrawler],
|
||||||
|
event_store: BaseEventStore,
|
||||||
|
llm_pipeline: LlmPipeline,
|
||||||
|
retrieval_service: Any,
|
||||||
|
) -> None:
|
||||||
|
self._crawlers = crawlers
|
||||||
|
self._store = event_store
|
||||||
|
self._pipeline = llm_pipeline
|
||||||
|
self._retrieval = retrieval_service
|
||||||
|
|
||||||
|
def run_crawl(
|
||||||
|
self, sources: list[str] | None = None
|
||||||
|
) -> Generator[dict, None, None]:
|
||||||
|
"""Run crawl for selected sources. Yields SSE-ready progress dicts."""
|
||||||
|
targets = sources or list(self._crawlers.keys())
|
||||||
|
total_new = 0
|
||||||
|
total_updated = 0
|
||||||
|
|
||||||
|
for source_key in targets:
|
||||||
|
crawler = self._crawlers.get(source_key)
|
||||||
|
if not crawler:
|
||||||
|
yield {"event": "error", "data": f"Unknown source: {source_key}"}
|
||||||
|
continue
|
||||||
|
|
||||||
|
yield {"event": "progress", "data": {"source": source_key, "stage": "fetching"}}
|
||||||
|
try:
|
||||||
|
raw_events = crawler.fetch(limit=100)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("Crawler failed source={}", source_key)
|
||||||
|
yield {"event": "error", "data": {"source": source_key, "message": str(exc)}}
|
||||||
|
continue
|
||||||
|
|
||||||
|
yield {
|
||||||
|
"event": "progress",
|
||||||
|
"data": {"source": source_key, "stage": "processing", "fetched": len(raw_events)},
|
||||||
|
}
|
||||||
|
|
||||||
|
new_count = 0
|
||||||
|
updated_count = 0
|
||||||
|
|
||||||
|
for raw in raw_events:
|
||||||
|
eid = _event_id(raw.source, raw.standard_code)
|
||||||
|
new_hash = _content_hash(raw.raw_text or raw.title)
|
||||||
|
existing = self._store.get(eid)
|
||||||
|
|
||||||
|
if existing and existing.get("content_hash") == new_hash:
|
||||||
|
continue
|
||||||
|
|
||||||
|
is_update = existing is not None
|
||||||
|
old_text = existing.get("summary", "") if is_update else ""
|
||||||
|
previous_hash = existing.get("content_hash") if is_update else None
|
||||||
|
|
||||||
|
event_dict = _raw_to_dict(raw, eid, new_hash)
|
||||||
|
event_dict["previous_hash"] = previous_hash
|
||||||
|
|
||||||
|
try:
|
||||||
|
structure = self._pipeline.extract_structure(event_dict)
|
||||||
|
event_dict.update(structure)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Structure extraction failed id={} err={}", eid, exc)
|
||||||
|
|
||||||
|
try:
|
||||||
|
affected = self._pipeline.assess_impact(event_dict, self._retrieval)
|
||||||
|
event_dict["affected_docs"] = affected
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Impact assessment failed id={} err={}", eid, exc)
|
||||||
|
|
||||||
|
if is_update and old_text and raw.raw_text:
|
||||||
|
try:
|
||||||
|
diff = self._pipeline.compute_diff(old_text, raw.raw_text)
|
||||||
|
event_dict["change_summary"] = diff.get("change_summary")
|
||||||
|
event_dict["changed_sections"] = diff.get("changed_sections")
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Diff failed id={} err={}", eid, exc)
|
||||||
|
|
||||||
|
self._store.upsert(event_dict)
|
||||||
|
|
||||||
|
if is_update:
|
||||||
|
updated_count += 1
|
||||||
|
else:
|
||||||
|
new_count += 1
|
||||||
|
|
||||||
|
total_new += new_count
|
||||||
|
total_updated += updated_count
|
||||||
|
|
||||||
|
yield {
|
||||||
|
"event": "progress",
|
||||||
|
"data": {
|
||||||
|
"source": source_key,
|
||||||
|
"stage": "done",
|
||||||
|
"new": new_count,
|
||||||
|
"updated": updated_count,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
yield {
|
||||||
|
"event": "done",
|
||||||
|
"data": {"total_new": total_new, "total_updated": total_updated},
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ import json
|
|||||||
from typing import Generator
|
from typing import Generator
|
||||||
|
|
||||||
from app.application.knowledge.services import KnowledgeRetrievalService
|
from app.application.knowledge.services import KnowledgeRetrievalService
|
||||||
from app.infrastructure.perception.mock_event_store import MockEventStore
|
from app.infrastructure.perception.base_event_store import BaseEventStore
|
||||||
from app.services.llm.llm_factory import get_llm_client
|
from app.services.llm.llm_factory import get_llm_client
|
||||||
from app.config.settings import settings
|
from app.config.settings import settings
|
||||||
|
|
||||||
@@ -22,7 +22,7 @@ class PerceptionService:
|
|||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
event_store: MockEventStore,
|
event_store: BaseEventStore,
|
||||||
retrieval_service: KnowledgeRetrievalService,
|
retrieval_service: KnowledgeRetrievalService,
|
||||||
) -> None:
|
) -> None:
|
||||||
self._store = event_store
|
self._store = event_store
|
||||||
|
|||||||
@@ -82,6 +82,22 @@ class Settings(BaseSettings):
|
|||||||
parser_backend: str = Field(default="aliyun", description="解析后端(local/aliyun)")
|
parser_backend: str = Field(default="aliyun", description="解析后端(local/aliyun)")
|
||||||
chunk_backend: str = Field(default="aliyun", description="分块后端(local/aliyun)")
|
chunk_backend: str = Field(default="aliyun", description="分块后端(local/aliyun)")
|
||||||
document_repository_backend: str = Field(default="json", description="文档元数据存储后端 (json/postgres)")
|
document_repository_backend: str = Field(default="json", description="文档元数据存储后端 (json/postgres)")
|
||||||
|
# When True, document processing is enqueued to Celery workers via Redis.
|
||||||
|
# When False (default), processing runs in a FastAPI BackgroundTask in the same process —
|
||||||
|
# no external worker needed. Switch to True only when a Celery worker is running.
|
||||||
|
use_celery_worker: bool = Field(default=False, description="使用 Celery Worker 异步处理文档 (需要 Worker 运行中)")
|
||||||
|
|
||||||
|
# ── Perception crawl ──────────────────────────────────────────────────────
|
||||||
|
perception_crawl_timeout_seconds: int = Field(
|
||||||
|
default=120, description="HTTP timeout for regulatory source crawlers."
|
||||||
|
)
|
||||||
|
perception_max_events_per_source: int = Field(
|
||||||
|
default=100, description="Maximum events fetched per source per crawl run."
|
||||||
|
)
|
||||||
|
perception_diff_similarity_threshold: float = Field(
|
||||||
|
default=0.85,
|
||||||
|
description="Cosine similarity below which a paragraph is flagged as changed.",
|
||||||
|
)
|
||||||
|
|
||||||
# Keep configuration setup explicit so runtime behavior is easy to reason about.
|
# Keep configuration setup explicit so runtime behavior is easy to reason about.
|
||||||
api_host: str = Field(default="0.0.0.0", description="API服务地址")
|
api_host: str = Field(default="0.0.0.0", description="API服务地址")
|
||||||
@@ -109,6 +125,7 @@ class Settings(BaseSettings):
|
|||||||
rag_retrieval_top_k: int = Field(default=20, description="精排前召回候选数量(reranker 启用时生效)")
|
rag_retrieval_top_k: int = Field(default=20, description="精排前召回候选数量(reranker 启用时生效)")
|
||||||
rag_max_context_tokens: int = Field(default=2000, description="RAG最大上下文token数")
|
rag_max_context_tokens: int = Field(default=2000, description="RAG最大上下文token数")
|
||||||
rag_summary_max_tokens: int = Field(default=10240, description="文档摘要最大token数")
|
rag_summary_max_tokens: int = Field(default=10240, description="文档摘要最大token数")
|
||||||
|
rag_skills_max_tokens: int = Field(default=2048, description="技能类 RAG 最大 token 数")
|
||||||
|
|
||||||
reranker_enabled: bool = Field(default=False, description="是否启用 Cross-Encoder 精排")
|
reranker_enabled: bool = Field(default=False, description="是否启用 Cross-Encoder 精排")
|
||||||
reranker_base_url: str = Field(default="", description="Reranker API 地址")
|
reranker_base_url: str = Field(default="", description="Reranker API 地址")
|
||||||
@@ -116,6 +133,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参数")
|
||||||
@@ -124,6 +177,26 @@ 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.
|
||||||
session_max_sessions: int = Field(default=100, description="最大会话数量")
|
session_max_sessions: int = Field(default=100, description="最大会话数量")
|
||||||
session_timeout_minutes: int = Field(default=30, description="会话超时时间(分钟)")
|
session_timeout_minutes: int = Field(default=30, description="会话超时时间(分钟)")
|
||||||
|
session_backend: str = Field(
|
||||||
|
default="memory",
|
||||||
|
description="会话存储后端 (memory | redis)。redis 需要 Redis 可用。",
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Auth ──────────────────────────────────────────────────────────────────
|
||||||
|
# Generate a strong secret: python -c "import secrets; print(secrets.token_hex(32))"
|
||||||
|
auth_secret_key: str = Field(
|
||||||
|
default="change-me-in-production-must-be-32-or-more-characters-long",
|
||||||
|
description="JWT signing secret. MUST be changed in production.",
|
||||||
|
)
|
||||||
|
auth_algorithm: str = Field(default="HS256", description="JWT signing algorithm.")
|
||||||
|
auth_token_expire_minutes: int = Field(default=480, description="JWT TTL in minutes (default 8 hours).")
|
||||||
|
auth_enabled: bool = Field(default=True, description="Set False to bypass auth (development only).")
|
||||||
|
|
||||||
|
# ── CORS ──────────────────────────────────────────────────────────────────
|
||||||
|
cors_allow_origins: str = Field(
|
||||||
|
default="http://localhost:5173",
|
||||||
|
description="Comma-separated allowed CORS origins. Never use * in production.",
|
||||||
|
)
|
||||||
|
|
||||||
@lru_cache
|
@lru_cache
|
||||||
def get_settings() -> Settings:
|
def get_settings() -> Settings:
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
"""Auth domain: role definitions and token claim models.
|
||||||
|
|
||||||
|
The domain layer defines what a user identity looks like (UserClaims) and
|
||||||
|
what roles exist (UserRole). Infrastructure details (JWT, bcrypt, PostgreSQL)
|
||||||
|
live under infrastructure/auth and never leak into this package.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .models import UserClaims, UserRole
|
||||||
|
|
||||||
|
__all__ = ["UserClaims", "UserRole"]
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
"""Auth domain models: roles and token claims.
|
||||||
|
|
||||||
|
UserRole defines the four roles from PPT Slide 12.
|
||||||
|
UserClaims is what the JWT decodes to — it is the identity object passed
|
||||||
|
through FastAPI dependency injection to route handlers.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import enum
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
class UserRole(str, enum.Enum):
|
||||||
|
"""Access roles mirroring the four-role RBAC matrix from the product spec.
|
||||||
|
|
||||||
|
ADMIN — full platform access including system management.
|
||||||
|
LEGAL — knowledge query, document review, compliance checks.
|
||||||
|
EHS — knowledge query, perception/regulatory signals.
|
||||||
|
READONLY — knowledge query only.
|
||||||
|
"""
|
||||||
|
|
||||||
|
ADMIN = "admin"
|
||||||
|
LEGAL = "legal"
|
||||||
|
EHS = "ehs"
|
||||||
|
READONLY = "readonly"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class UserClaims:
|
||||||
|
"""Decoded JWT payload representing an authenticated user.
|
||||||
|
|
||||||
|
Instances are created by JWTHandler.decode_token() and injected into
|
||||||
|
route handlers via the get_current_user FastAPI dependency.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Unique user identifier (UUID string stored in PostgreSQL users table).
|
||||||
|
user_id: str
|
||||||
|
# Display name used for audit log entries.
|
||||||
|
username: str
|
||||||
|
# Role determines which resources the user may access.
|
||||||
|
role: UserRole
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
"""Domain ports for compliance history persistence."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FindingRecord:
|
||||||
|
"""Single finding row linked to an analysis."""
|
||||||
|
id: str
|
||||||
|
analysis_id: str
|
||||||
|
seq: int
|
||||||
|
title: str
|
||||||
|
description: str
|
||||||
|
status: str # "ok" | "warn" | "risk"
|
||||||
|
clause_ref: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AnalysisRecord:
|
||||||
|
"""Full compliance analysis record with nested findings."""
|
||||||
|
id: str # UUID string; empty string means not yet persisted
|
||||||
|
created_at: datetime
|
||||||
|
created_by: Optional[str]
|
||||||
|
doc_name: str
|
||||||
|
standard_name: str
|
||||||
|
risk_score: int
|
||||||
|
conclusion: str
|
||||||
|
actions: list # list[dict] — serialised action items
|
||||||
|
para_text: str
|
||||||
|
highlight_terms: list # list[str]
|
||||||
|
findings: list[FindingRecord] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class ComplianceRepository(ABC):
|
||||||
|
"""Port for persisting and retrieving compliance analysis records."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def save_analysis(self, record: AnalysisRecord) -> str:
|
||||||
|
"""Persist a new analysis record and return the assigned UUID string."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def list_analyses(self, limit: int = 50, offset: int = 0) -> list[AnalysisRecord]:
|
||||||
|
"""Return analyses ordered by created_at DESC, without nested findings."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def get_analysis(self, analysis_id: str) -> Optional[AnalysisRecord]:
|
||||||
|
"""Return a single analysis with all nested findings, or None."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def delete_analysis(self, analysis_id: str) -> None:
|
||||||
|
"""Delete an analysis and all related findings and chat messages (cascade)."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def save_message(self, analysis_id: str, finding_id: str, role: str, content: str) -> str:
|
||||||
|
"""Persist a chat message and return its UUID string."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def get_messages(self, finding_id: str) -> list[dict]:
|
||||||
|
"""Return chat messages for a finding ordered by created_at ASC.
|
||||||
|
|
||||||
|
Each dict has keys: id, role, content, created_at (ISO string).
|
||||||
|
"""
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""JWT token creation and validation infrastructure.
|
||||||
|
|
||||||
|
JWTHandler is the only component in this package. It is wired through
|
||||||
|
shared/bootstrap.py and injected into FastAPI dependencies.
|
||||||
|
"""
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
"""JWT access token creation and decoding.
|
||||||
|
|
||||||
|
Uses python-jose for HS256 token signing. Token expiry is enforced at
|
||||||
|
decode time so expired tokens are rejected even if the signature is valid.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from jose import JWTError, jwt
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from app.domain.auth.models import UserClaims, UserRole
|
||||||
|
|
||||||
|
|
||||||
|
class JWTHandler:
|
||||||
|
"""Create and validate HS256 JWT access tokens.
|
||||||
|
|
||||||
|
A single shared instance is wired by bootstrap.py. Use
|
||||||
|
get_jwt_handler() from shared.bootstrap for all token operations.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
secret_key: str,
|
||||||
|
algorithm: str = "HS256",
|
||||||
|
expire_minutes: int = 480,
|
||||||
|
) -> None:
|
||||||
|
"""Initialise the handler with signing credentials and token lifetime."""
|
||||||
|
self._secret = secret_key
|
||||||
|
self._algorithm = algorithm
|
||||||
|
self._expire_minutes = expire_minutes
|
||||||
|
|
||||||
|
def create_access_token(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
user_id: str,
|
||||||
|
username: str,
|
||||||
|
role: str,
|
||||||
|
) -> str:
|
||||||
|
"""Return a signed JWT containing user identity and role claims."""
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
"sub": user_id,
|
||||||
|
"username": username,
|
||||||
|
"role": role,
|
||||||
|
"iat": now,
|
||||||
|
"exp": now + timedelta(minutes=self._expire_minutes),
|
||||||
|
}
|
||||||
|
return jwt.encode(payload, self._secret, algorithm=self._algorithm)
|
||||||
|
|
||||||
|
def decode_token(self, token: str) -> UserClaims:
|
||||||
|
"""Decode and validate a JWT, returning UserClaims.
|
||||||
|
|
||||||
|
Raises ValueError with a descriptive message on expiry, tampering,
|
||||||
|
or any other validation failure so callers do not need to know jose.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(token, self._secret, algorithms=[self._algorithm])
|
||||||
|
except JWTError as exc:
|
||||||
|
msg = str(exc).lower()
|
||||||
|
if "expired" in msg:
|
||||||
|
raise ValueError("Token expired") from exc
|
||||||
|
raise ValueError(f"Invalid token: {exc}") from exc
|
||||||
|
|
||||||
|
user_id = payload.get("sub")
|
||||||
|
username = payload.get("username", "")
|
||||||
|
role_str = payload.get("role", UserRole.READONLY.value)
|
||||||
|
|
||||||
|
if not user_id:
|
||||||
|
raise ValueError("Token missing subject claim")
|
||||||
|
|
||||||
|
try:
|
||||||
|
role = UserRole(role_str)
|
||||||
|
except ValueError:
|
||||||
|
logger.warning("Unknown role in token: {}, defaulting to readonly", role_str)
|
||||||
|
role = UserRole.READONLY
|
||||||
|
|
||||||
|
return UserClaims(user_id=user_id, username=username, role=role)
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
"""PostgreSQL-backed user store for authentication.
|
||||||
|
|
||||||
|
Manages a `users` table with hashed passwords and roles.
|
||||||
|
Provides lookup by username for the login flow.
|
||||||
|
Table DDL is auto-applied on first connection.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import psycopg2
|
||||||
|
import psycopg2.extras
|
||||||
|
from loguru import logger
|
||||||
|
from passlib.context import CryptContext
|
||||||
|
|
||||||
|
from app.config.settings import settings
|
||||||
|
|
||||||
|
|
||||||
|
# bcrypt context — work factor 12 is a good production default.
|
||||||
|
_PWD_CTX = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||||
|
|
||||||
|
# DDL executed once to ensure the table exists.
|
||||||
|
_CREATE_TABLE_SQL = """
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
username VARCHAR(100) UNIQUE NOT NULL,
|
||||||
|
hashed_pw TEXT NOT NULL,
|
||||||
|
role VARCHAR(50) NOT NULL DEFAULT 'readonly',
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class UserRecord:
|
||||||
|
"""A single row from the users table."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
username: str
|
||||||
|
hashed_pw: str
|
||||||
|
role: str
|
||||||
|
is_active: bool
|
||||||
|
|
||||||
|
|
||||||
|
class PostgresUserStore:
|
||||||
|
"""Read and verify users stored in the PostgreSQL users table.
|
||||||
|
|
||||||
|
The connection is opened on first use and shared for the lifetime
|
||||||
|
of the singleton instance wired by bootstrap.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
"""Initialise the store and ensure the users table exists."""
|
||||||
|
self._conn = psycopg2.connect(
|
||||||
|
host=settings.postgres_host,
|
||||||
|
port=settings.postgres_port,
|
||||||
|
user=settings.postgres_user,
|
||||||
|
password=settings.postgres_password,
|
||||||
|
dbname=settings.postgres_db,
|
||||||
|
cursor_factory=psycopg2.extras.RealDictCursor,
|
||||||
|
)
|
||||||
|
self._conn.autocommit = True
|
||||||
|
self._ensure_table()
|
||||||
|
|
||||||
|
def _ensure_table(self) -> None:
|
||||||
|
"""Create the users table if it does not already exist."""
|
||||||
|
with self._conn.cursor() as cur:
|
||||||
|
# Enable pgcrypto so gen_random_uuid() is available for UUID primary keys.
|
||||||
|
try:
|
||||||
|
cur.execute("CREATE EXTENSION IF NOT EXISTS pgcrypto;")
|
||||||
|
except Exception:
|
||||||
|
self._conn.rollback()
|
||||||
|
cur.execute(_CREATE_TABLE_SQL)
|
||||||
|
|
||||||
|
def get_by_username(self, username: str) -> Optional[UserRecord]:
|
||||||
|
"""Return a UserRecord for the given username, or None if not found."""
|
||||||
|
with self._conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"SELECT id, username, hashed_pw, role, is_active "
|
||||||
|
"FROM users WHERE username = %s",
|
||||||
|
(username,),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
return UserRecord(
|
||||||
|
id=str(row["id"]),
|
||||||
|
username=row["username"],
|
||||||
|
hashed_pw=row["hashed_pw"],
|
||||||
|
role=row["role"],
|
||||||
|
is_active=row["is_active"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def verify_password(self, plain: str, hashed: str) -> bool:
|
||||||
|
"""Return True if `plain` matches the stored bcrypt hash."""
|
||||||
|
return _PWD_CTX.verify(plain, hashed)
|
||||||
|
|
||||||
|
def authenticate(self, username: str, password: str) -> Optional[UserRecord]:
|
||||||
|
"""Return the UserRecord if credentials are valid, else None."""
|
||||||
|
user = self.get_by_username(username)
|
||||||
|
if user is None or not user.is_active:
|
||||||
|
return None
|
||||||
|
if not self.verify_password(password, user.hashed_pw):
|
||||||
|
return None
|
||||||
|
return user
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def hash_password(plain: str) -> str:
|
||||||
|
"""Hash a plain-text password with bcrypt."""
|
||||||
|
return _PWD_CTX.hash(plain)
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
"""DOCX report generator for compliance analysis results.
|
||||||
|
|
||||||
|
Uses python-docx (already in requirements.txt). Returns raw bytes so the
|
||||||
|
caller can stream the response without writing to disk.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from io import BytesIO
|
||||||
|
|
||||||
|
from docx import Document
|
||||||
|
from docx.shared import Pt, RGBColor
|
||||||
|
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
||||||
|
|
||||||
|
from app.domain.compliance.ports import AnalysisRecord
|
||||||
|
|
||||||
|
_STATUS_LABEL = {"ok": "Compliant", "warn": "Warning", "risk": "Non-Compliant"}
|
||||||
|
_STATUS_COLOR = {
|
||||||
|
"ok": RGBColor(0x22, 0x8B, 0x22),
|
||||||
|
"warn": RGBColor(0xFF, 0x8C, 0x00),
|
||||||
|
"risk": RGBColor(0xDC, 0x14, 0x3C),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def generate_docx(record: AnalysisRecord) -> bytes:
|
||||||
|
"""Generate a compliance report DOCX and return its raw bytes.
|
||||||
|
|
||||||
|
Structure:
|
||||||
|
- Cover: document name, standard, date, risk score
|
||||||
|
- Executive summary (conclusion)
|
||||||
|
- Findings table
|
||||||
|
- Recommended actions
|
||||||
|
- Footer note
|
||||||
|
"""
|
||||||
|
doc = Document()
|
||||||
|
|
||||||
|
# ── Cover ──────────────────────────────────────────────────────────────────
|
||||||
|
title_para = doc.add_heading("Compliance Analysis Report", level=0)
|
||||||
|
title_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||||
|
|
||||||
|
doc.add_paragraph("")
|
||||||
|
meta_table = doc.add_table(rows=4, cols=2)
|
||||||
|
meta_table.style = "Table Grid"
|
||||||
|
labels = ["Document", "Standard", "Date", "Risk Score"]
|
||||||
|
values = [
|
||||||
|
record.doc_name,
|
||||||
|
record.standard_name,
|
||||||
|
record.created_at.strftime("%Y-%m-%d %H:%M UTC") if record.created_at else "",
|
||||||
|
f"{record.risk_score} / 100",
|
||||||
|
]
|
||||||
|
for i, (label, value) in enumerate(zip(labels, values)):
|
||||||
|
meta_table.cell(i, 0).text = label
|
||||||
|
meta_table.cell(i, 1).text = value
|
||||||
|
|
||||||
|
# ── Executive Summary ──────────────────────────────────────────────────────
|
||||||
|
doc.add_heading("Executive Summary", level=1)
|
||||||
|
doc.add_paragraph(record.conclusion)
|
||||||
|
|
||||||
|
# ── Findings ───────────────────────────────────────────────────────────────
|
||||||
|
doc.add_heading("Findings", level=1)
|
||||||
|
if record.findings:
|
||||||
|
table = doc.add_table(rows=1, cols=4)
|
||||||
|
table.style = "Table Grid"
|
||||||
|
hdr = table.rows[0].cells
|
||||||
|
for i, h in enumerate(["#", "Status", "Title", "Description / Clause"]):
|
||||||
|
hdr[i].text = h
|
||||||
|
for run in hdr[i].paragraphs[0].runs:
|
||||||
|
run.bold = True
|
||||||
|
|
||||||
|
for f in record.findings:
|
||||||
|
row = table.add_row().cells
|
||||||
|
row[0].text = str(f.seq + 1)
|
||||||
|
row[1].text = _STATUS_LABEL.get(f.status, f.status)
|
||||||
|
row[2].text = f.title
|
||||||
|
desc = f.description
|
||||||
|
if f.clause_ref:
|
||||||
|
desc += f"\n[{f.clause_ref}]"
|
||||||
|
row[3].text = desc
|
||||||
|
else:
|
||||||
|
doc.add_paragraph("No findings recorded.")
|
||||||
|
|
||||||
|
# ── Recommended Actions ────────────────────────────────────────────────────
|
||||||
|
doc.add_heading("Recommended Actions", level=1)
|
||||||
|
for i, action in enumerate(record.actions, start=1):
|
||||||
|
label = action.get("label", "Action")
|
||||||
|
value = action.get("value", "")
|
||||||
|
doc.add_paragraph(f"{i}. {label}: {value}", style="List Number")
|
||||||
|
|
||||||
|
# ── Footer note ────────────────────────────────────────────────────────────
|
||||||
|
doc.add_paragraph("")
|
||||||
|
footer = doc.add_paragraph(
|
||||||
|
f"Generated by AI Regulation Analysis System — {datetime.now(timezone.utc).strftime('%Y-%m-%d')}"
|
||||||
|
)
|
||||||
|
footer.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||||
|
for run in footer.runs:
|
||||||
|
run.font.size = Pt(8)
|
||||||
|
run.font.color.rgb = RGBColor(0x88, 0x88, 0x88)
|
||||||
|
|
||||||
|
buf = BytesIO()
|
||||||
|
doc.save(buf)
|
||||||
|
return buf.getvalue()
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
# backend/app/infrastructure/compliance/repository.py
|
||||||
|
"""PostgreSQL-backed compliance analysis repository.
|
||||||
|
|
||||||
|
Follows the same psycopg2 pattern as PostgresDocumentRepository:
|
||||||
|
ThreadedConnectionPool + RealDictCursor for reads, _ensure_schema on init.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import psycopg2
|
||||||
|
import psycopg2.extras
|
||||||
|
import psycopg2.pool
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from app.domain.compliance.ports import (
|
||||||
|
AnalysisRecord,
|
||||||
|
ComplianceRepository,
|
||||||
|
FindingRecord,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PostgresComplianceRepository(ComplianceRepository):
|
||||||
|
"""Stores compliance analyses, findings, and finding chat messages in PostgreSQL."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
host: str,
|
||||||
|
port: int,
|
||||||
|
user: str,
|
||||||
|
password: str,
|
||||||
|
dbname: str,
|
||||||
|
minconn: int = 1,
|
||||||
|
maxconn: int = 5,
|
||||||
|
) -> None:
|
||||||
|
self._pool = psycopg2.pool.ThreadedConnectionPool(
|
||||||
|
minconn=minconn,
|
||||||
|
maxconn=maxconn,
|
||||||
|
host=host,
|
||||||
|
port=port,
|
||||||
|
user=user,
|
||||||
|
password=password,
|
||||||
|
dbname=dbname,
|
||||||
|
)
|
||||||
|
self._ensure_schema()
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def _conn(self):
|
||||||
|
conn = self._pool.getconn()
|
||||||
|
try:
|
||||||
|
yield conn
|
||||||
|
finally:
|
||||||
|
self._pool.putconn(conn)
|
||||||
|
|
||||||
|
def _ensure_schema(self) -> None:
|
||||||
|
"""Create tables if they do not exist."""
|
||||||
|
with self._conn() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS compliance_analyses (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
created_by VARCHAR(255),
|
||||||
|
doc_name VARCHAR(500),
|
||||||
|
standard_name VARCHAR(500),
|
||||||
|
risk_score INTEGER,
|
||||||
|
conclusion TEXT,
|
||||||
|
actions JSONB,
|
||||||
|
para_text TEXT,
|
||||||
|
highlight_terms JSONB
|
||||||
|
);
|
||||||
|
""")
|
||||||
|
cur.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS compliance_findings (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
analysis_id UUID NOT NULL REFERENCES compliance_analyses(id) ON DELETE CASCADE,
|
||||||
|
seq INTEGER NOT NULL,
|
||||||
|
title VARCHAR(500),
|
||||||
|
description TEXT,
|
||||||
|
status VARCHAR(50),
|
||||||
|
clause_ref VARCHAR(200)
|
||||||
|
);
|
||||||
|
""")
|
||||||
|
cur.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS finding_chat_messages (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
analysis_id UUID NOT NULL REFERENCES compliance_analyses(id) ON DELETE CASCADE,
|
||||||
|
finding_id UUID NOT NULL REFERENCES compliance_findings(id) ON DELETE CASCADE,
|
||||||
|
role VARCHAR(20) NOT NULL,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
""")
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
def save_analysis(self, record: AnalysisRecord) -> str:
|
||||||
|
"""Insert analysis + findings; return the new analysis UUID."""
|
||||||
|
with self._conn() as conn:
|
||||||
|
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO compliance_analyses
|
||||||
|
(created_by, doc_name, standard_name, risk_score,
|
||||||
|
conclusion, actions, para_text, highlight_terms)
|
||||||
|
VALUES
|
||||||
|
(%(created_by)s, %(doc_name)s, %(standard_name)s, %(risk_score)s,
|
||||||
|
%(conclusion)s, %(actions)s, %(para_text)s, %(highlight_terms)s)
|
||||||
|
RETURNING id
|
||||||
|
""",
|
||||||
|
{
|
||||||
|
"created_by": record.created_by,
|
||||||
|
"doc_name": record.doc_name,
|
||||||
|
"standard_name": record.standard_name,
|
||||||
|
"risk_score": record.risk_score,
|
||||||
|
"conclusion": record.conclusion,
|
||||||
|
"actions": json.dumps(record.actions, ensure_ascii=False),
|
||||||
|
"para_text": record.para_text,
|
||||||
|
"highlight_terms": json.dumps(record.highlight_terms, ensure_ascii=False),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
analysis_id = str(row["id"])
|
||||||
|
|
||||||
|
if record.findings:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
for f in record.findings:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO compliance_findings
|
||||||
|
(analysis_id, seq, title, description, status, clause_ref)
|
||||||
|
VALUES
|
||||||
|
(%(analysis_id)s, %(seq)s, %(title)s, %(desc)s, %(status)s, %(clause_ref)s)
|
||||||
|
""",
|
||||||
|
{
|
||||||
|
"analysis_id": analysis_id,
|
||||||
|
"seq": f.seq,
|
||||||
|
"title": f.title,
|
||||||
|
"desc": f.description,
|
||||||
|
"status": f.status,
|
||||||
|
"clause_ref": f.clause_ref,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return analysis_id
|
||||||
|
|
||||||
|
def list_analyses(self, limit: int = 50, offset: int = 0) -> list[AnalysisRecord]:
|
||||||
|
"""Return analyses without nested findings, ordered newest first."""
|
||||||
|
with self._conn() as conn:
|
||||||
|
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, created_at, created_by, doc_name, standard_name,
|
||||||
|
risk_score, conclusion, actions, para_text, highlight_terms
|
||||||
|
FROM compliance_analyses
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT %(limit)s OFFSET %(offset)s
|
||||||
|
""",
|
||||||
|
{"limit": limit, "offset": offset},
|
||||||
|
)
|
||||||
|
rows = cur.fetchall()
|
||||||
|
return [self._row_to_record(dict(r)) for r in rows]
|
||||||
|
|
||||||
|
def get_analysis(self, analysis_id: str) -> Optional[AnalysisRecord]:
|
||||||
|
"""Return analysis with nested findings list."""
|
||||||
|
with self._conn() as conn:
|
||||||
|
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
||||||
|
cur.execute(
|
||||||
|
"SELECT * FROM compliance_analyses WHERE id = %(id)s",
|
||||||
|
{"id": analysis_id},
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
record = self._row_to_record(dict(row))
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, analysis_id, seq, title, description, status, clause_ref
|
||||||
|
FROM compliance_findings
|
||||||
|
WHERE analysis_id = %(id)s
|
||||||
|
ORDER BY seq
|
||||||
|
""",
|
||||||
|
{"id": analysis_id},
|
||||||
|
)
|
||||||
|
findings = [
|
||||||
|
FindingRecord(
|
||||||
|
id=str(r["id"]),
|
||||||
|
analysis_id=str(r["analysis_id"]),
|
||||||
|
seq=r["seq"],
|
||||||
|
title=r["title"] or "",
|
||||||
|
description=r["description"] or "",
|
||||||
|
status=r["status"] or "ok",
|
||||||
|
clause_ref=r["clause_ref"],
|
||||||
|
)
|
||||||
|
for r in cur.fetchall()
|
||||||
|
]
|
||||||
|
record.findings = findings
|
||||||
|
return record
|
||||||
|
|
||||||
|
def delete_analysis(self, analysis_id: str) -> None:
|
||||||
|
"""Delete analysis; findings and chat messages cascade automatically."""
|
||||||
|
with self._conn() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"DELETE FROM compliance_analyses WHERE id = %(id)s",
|
||||||
|
{"id": analysis_id},
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
def save_message(self, analysis_id: str, finding_id: str, role: str, content: str) -> str:
|
||||||
|
"""Persist a chat message; return its UUID."""
|
||||||
|
with self._conn() as conn:
|
||||||
|
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO finding_chat_messages
|
||||||
|
(analysis_id, finding_id, role, content)
|
||||||
|
VALUES
|
||||||
|
(%(analysis_id)s, %(finding_id)s, %(role)s, %(content)s)
|
||||||
|
RETURNING id
|
||||||
|
""",
|
||||||
|
{
|
||||||
|
"analysis_id": analysis_id,
|
||||||
|
"finding_id": finding_id,
|
||||||
|
"role": role,
|
||||||
|
"content": content,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
conn.commit()
|
||||||
|
return str(row["id"])
|
||||||
|
|
||||||
|
def get_messages(self, finding_id: str) -> list[dict]:
|
||||||
|
"""Return messages for a finding, oldest first."""
|
||||||
|
with self._conn() as conn:
|
||||||
|
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, role, content, created_at
|
||||||
|
FROM finding_chat_messages
|
||||||
|
WHERE finding_id = %(finding_id)s
|
||||||
|
ORDER BY created_at ASC
|
||||||
|
""",
|
||||||
|
{"finding_id": finding_id},
|
||||||
|
)
|
||||||
|
rows = cur.fetchall()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": str(r["id"]),
|
||||||
|
"role": r["role"],
|
||||||
|
"content": r["content"],
|
||||||
|
"created_at": r["created_at"].isoformat() if r["created_at"] else "",
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
def _row_to_record(self, row: dict) -> AnalysisRecord:
|
||||||
|
"""Convert a RealDictCursor row to an AnalysisRecord (no findings)."""
|
||||||
|
actions = row.get("actions") or []
|
||||||
|
if isinstance(actions, str):
|
||||||
|
actions = json.loads(actions)
|
||||||
|
highlight_terms = row.get("highlight_terms") or []
|
||||||
|
if isinstance(highlight_terms, str):
|
||||||
|
highlight_terms = json.loads(highlight_terms)
|
||||||
|
return AnalysisRecord(
|
||||||
|
id=str(row["id"]),
|
||||||
|
created_at=row["created_at"] if isinstance(row["created_at"], datetime) else datetime.utcnow(),
|
||||||
|
created_by=row.get("created_by"),
|
||||||
|
doc_name=row.get("doc_name") or "",
|
||||||
|
standard_name=row.get("standard_name") or "",
|
||||||
|
risk_score=int(row.get("risk_score") or 0),
|
||||||
|
conclusion=row.get("conclusion") or "",
|
||||||
|
actions=actions,
|
||||||
|
para_text=row.get("para_text") or "",
|
||||||
|
highlight_terms=highlight_terms,
|
||||||
|
findings=[],
|
||||||
|
)
|
||||||
@@ -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,39 @@
|
|||||||
|
"""Abstract base class for regulatory event stores."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
|
||||||
|
|
||||||
|
class BaseEventStore(ABC):
|
||||||
|
"""Port interface for regulatory event persistence."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def all(self) -> list[dict]:
|
||||||
|
"""Return all events, most-recent first."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def get(self, event_id: str) -> dict | None:
|
||||||
|
"""Return a single event by ID, or None."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def filter(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
source: str | None = None,
|
||||||
|
impact_level: str | None = None,
|
||||||
|
limit: int = 50,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Return filtered events sorted by published_at descending."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def stats(self) -> dict:
|
||||||
|
"""Return {total, high_impact, medium_impact, low_impact, recent_90d}."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def upsert(self, event: dict) -> None:
|
||||||
|
"""Insert or update an event record."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def get_by_standard_code(self, standard_code: str) -> dict | None:
|
||||||
|
"""Return the most-recent event with matching standard_code, or None."""
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
"""Shared utility functions for crawlers."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
|
||||||
|
def parse_date(text: str) -> str:
|
||||||
|
"""Return YYYY-MM-DD from common Chinese date formats, or today's date."""
|
||||||
|
text = text.strip()
|
||||||
|
if not text:
|
||||||
|
return date.today().isoformat()
|
||||||
|
m = re.search(r"(\d{4})[/-](\d{1,2})[/-](\d{1,2})", text)
|
||||||
|
if m:
|
||||||
|
try:
|
||||||
|
return date(int(m.group(1)), int(m.group(2)), int(m.group(3))).isoformat()
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
m2 = re.search(r"(\d{4})年(\d{1,2})月(\d{1,2})日?", text)
|
||||||
|
if m2:
|
||||||
|
try:
|
||||||
|
return date(int(m2.group(1)), int(m2.group(2)), int(m2.group(3))).isoformat()
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
return date.today().isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def extract_tags(standard_code: str, title: str) -> list[str]:
|
||||||
|
"""Derive simple keyword tags from standard code and title."""
|
||||||
|
tags: list[str] = []
|
||||||
|
code_upper = standard_code.upper()
|
||||||
|
if "GB" in code_upper:
|
||||||
|
tags.append("国家标准")
|
||||||
|
if "/T" in code_upper:
|
||||||
|
tags.append("推荐性")
|
||||||
|
else:
|
||||||
|
tags.append("强制性")
|
||||||
|
keywords = ["电动", "安全", "自动驾驶", "充电", "智能网联", "碰撞", "排放", "网络安全"]
|
||||||
|
for kw in keywords:
|
||||||
|
if kw in title:
|
||||||
|
tags.append(kw)
|
||||||
|
return tags[:5]
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"""Shared contracts for regulatory source crawlers."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RawEvent:
|
||||||
|
"""Raw regulatory event returned by a crawler before enrichment."""
|
||||||
|
|
||||||
|
source: str
|
||||||
|
source_label: str
|
||||||
|
standard_code: str
|
||||||
|
title: str
|
||||||
|
summary: str
|
||||||
|
full_text_url: str
|
||||||
|
status: str # 'enacted' | 'draft' | 'consultation'
|
||||||
|
published_at: str # YYYY-MM-DD string
|
||||||
|
effective_at: str | None
|
||||||
|
category: str
|
||||||
|
tags: list[str] = field(default_factory=list)
|
||||||
|
raw_text: str = "" # full crawled text for hashing + LLM
|
||||||
|
|
||||||
|
|
||||||
|
class BaseCrawler(ABC):
|
||||||
|
"""Abstract regulatory source crawler."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def fetch(self, limit: int = 50) -> list[RawEvent]:
|
||||||
|
"""Fetch up to `limit` recent events from the data source."""
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
"""Crawler for CATARC automotive standard catalogue."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from urllib.parse import urljoin
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from app.infrastructure.perception.crawlers.base import BaseCrawler, RawEvent
|
||||||
|
from ._utils import extract_tags, parse_date
|
||||||
|
|
||||||
|
_BASE_URL = "https://www.catarc.org.cn/bzzxd/qcbz/index.html"
|
||||||
|
_HOST = "https://www.catarc.org.cn"
|
||||||
|
|
||||||
|
_STATUS_MAP = {
|
||||||
|
"现行": "enacted",
|
||||||
|
"即将实施": "enacted",
|
||||||
|
"废止": "enacted",
|
||||||
|
"征求意见": "consultation",
|
||||||
|
"报批": "draft",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class CatarcCrawler(BaseCrawler):
|
||||||
|
"""Scrape the CATARC automotive standard list page."""
|
||||||
|
|
||||||
|
def fetch(self, limit: int = 50) -> list[RawEvent]:
|
||||||
|
events: list[RawEvent] = []
|
||||||
|
page = 1
|
||||||
|
max_pages = max(10, limit)
|
||||||
|
while len(events) < limit and page <= max_pages:
|
||||||
|
url = f"{_BASE_URL}?page={page}"
|
||||||
|
try:
|
||||||
|
resp = httpx.get(url, timeout=30, follow_redirects=True)
|
||||||
|
resp.raise_for_status()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("CATARC fetch failed page={} err={}", page, exc)
|
||||||
|
break
|
||||||
|
|
||||||
|
soup = BeautifulSoup(resp.text, "lxml")
|
||||||
|
rows = soup.select("table tr")
|
||||||
|
if not rows:
|
||||||
|
break
|
||||||
|
|
||||||
|
batch: list[RawEvent] = []
|
||||||
|
for row in rows:
|
||||||
|
cells = row.find_all("td")
|
||||||
|
if len(cells) < 3:
|
||||||
|
continue
|
||||||
|
link = cells[0].find("a")
|
||||||
|
standard_code = link.get_text(strip=True) if link else cells[0].get_text(strip=True)
|
||||||
|
title = cells[1].get_text(strip=True) if len(cells) > 1 else standard_code
|
||||||
|
date_text = cells[2].get_text(strip=True) if len(cells) > 2 else ""
|
||||||
|
published_at = parse_date(date_text)
|
||||||
|
status_text = cells[3].get_text(strip=True) if len(cells) > 3 else ""
|
||||||
|
status = _STATUS_MAP.get(status_text, "enacted")
|
||||||
|
detail_url = urljoin(_HOST, link["href"]) if link and link.get("href") else url
|
||||||
|
raw_text = f"{standard_code} {title}"
|
||||||
|
batch.append(RawEvent(
|
||||||
|
source="CATARC",
|
||||||
|
source_label="全国汽车标准化技术委员会",
|
||||||
|
standard_code=standard_code,
|
||||||
|
title=title,
|
||||||
|
summary=title,
|
||||||
|
full_text_url=detail_url,
|
||||||
|
status=status,
|
||||||
|
published_at=published_at,
|
||||||
|
effective_at=None,
|
||||||
|
category="汽车标准",
|
||||||
|
tags=extract_tags(standard_code, title),
|
||||||
|
raw_text=raw_text,
|
||||||
|
))
|
||||||
|
|
||||||
|
if not batch:
|
||||||
|
break
|
||||||
|
events.extend(batch)
|
||||||
|
page += 1
|
||||||
|
|
||||||
|
return events[:limit]
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
"""Crawler for EUR-Lex RSS feeds covering EU AI Act and automotive regulations."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from email.utils import parsedate_to_datetime
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from app.infrastructure.perception.crawlers.base import BaseCrawler, RawEvent
|
||||||
|
from ._utils import parse_date
|
||||||
|
|
||||||
|
_EURLEX_RSS_URLS = [
|
||||||
|
"https://eur-lex.europa.eu/rss-feed/OJ-L.rss",
|
||||||
|
]
|
||||||
|
|
||||||
|
_AUTOMOTIVE_KEYWORDS = [
|
||||||
|
"vehicle", "automotive", "motor", "tyre", "emission", "ADAS", "autonomous",
|
||||||
|
"AI Act", "artificial intelligence", "cybersecurity", "software update",
|
||||||
|
"R155", "R156", "汽车", "车辆",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
_AUTOMOTIVE_KEYWORDS_LOWER = [kw.lower() for kw in _AUTOMOTIVE_KEYWORDS]
|
||||||
|
|
||||||
|
|
||||||
|
def _is_automotive_relevant(title: str, description: str) -> bool:
|
||||||
|
combined = (title + " " + description).lower()
|
||||||
|
return any(kw in combined for kw in _AUTOMOTIVE_KEYWORDS_LOWER)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_celex(url: str) -> str:
|
||||||
|
m = re.search(r"CELEX[:/]([0-9A-Z]+)", url)
|
||||||
|
return m.group(1) if m else ""
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_rss_date(rfc2822: str) -> str:
|
||||||
|
try:
|
||||||
|
dt = parsedate_to_datetime(rfc2822)
|
||||||
|
return dt.date().isoformat()
|
||||||
|
except Exception:
|
||||||
|
return parse_date(rfc2822)
|
||||||
|
|
||||||
|
|
||||||
|
class EurlexCrawler(BaseCrawler):
|
||||||
|
"""Fetch automotive-relevant EU regulations from EUR-Lex RSS feeds."""
|
||||||
|
|
||||||
|
def fetch(self, limit: int = 50) -> list[RawEvent]:
|
||||||
|
events: list[RawEvent] = []
|
||||||
|
for rss_url in _EURLEX_RSS_URLS:
|
||||||
|
if len(events) >= limit:
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
resp = httpx.get(rss_url, timeout=30, follow_redirects=True)
|
||||||
|
resp.raise_for_status()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("EUR-Lex RSS fetch failed url={} err={}", rss_url, exc)
|
||||||
|
continue
|
||||||
|
|
||||||
|
soup = BeautifulSoup(resp.content, "lxml-xml")
|
||||||
|
for item in soup.find_all("item"):
|
||||||
|
if len(events) >= limit:
|
||||||
|
break
|
||||||
|
title_tag = item.find("title")
|
||||||
|
title = title_tag.get_text(strip=True) if title_tag else ""
|
||||||
|
desc_tag = item.find("description")
|
||||||
|
description = desc_tag.get_text(strip=True) if desc_tag else ""
|
||||||
|
link_tag = item.find("link")
|
||||||
|
link = link_tag.get_text(strip=True) if link_tag else ""
|
||||||
|
pub_date_tag = item.find("pubDate")
|
||||||
|
pub_date = pub_date_tag.get_text(strip=True) if pub_date_tag else ""
|
||||||
|
|
||||||
|
if not _is_automotive_relevant(title, description):
|
||||||
|
continue
|
||||||
|
|
||||||
|
celex = _extract_celex(link)
|
||||||
|
standard_code = celex if celex else title[:60]
|
||||||
|
published_at = _parse_rss_date(pub_date) if pub_date else ""
|
||||||
|
|
||||||
|
events.append(RawEvent(
|
||||||
|
source="EUR-Lex",
|
||||||
|
source_label="欧盟官方公报",
|
||||||
|
standard_code=standard_code,
|
||||||
|
title=title,
|
||||||
|
summary=description[:500],
|
||||||
|
full_text_url=link,
|
||||||
|
status="enacted",
|
||||||
|
published_at=published_at,
|
||||||
|
effective_at=None,
|
||||||
|
category="EU法规",
|
||||||
|
tags=_extract_eurlex_tags(title, description),
|
||||||
|
raw_text=f"{title}\n{description}",
|
||||||
|
))
|
||||||
|
|
||||||
|
return events[:limit]
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_eurlex_tags(title: str, description: str) -> list[str]:
|
||||||
|
combined = title + " " + description
|
||||||
|
tag_map = {
|
||||||
|
"AI Act": "EU AI Act",
|
||||||
|
"artificial intelligence": "EU AI Act",
|
||||||
|
"R155": "UN R155",
|
||||||
|
"R156": "UN R156",
|
||||||
|
"cybersecurity": "网络安全",
|
||||||
|
"emission": "排放",
|
||||||
|
"autonomous": "自动驾驶",
|
||||||
|
"ADAS": "ADAS",
|
||||||
|
}
|
||||||
|
combined_lower = combined.lower()
|
||||||
|
tags = []
|
||||||
|
for kw, tag in tag_map.items():
|
||||||
|
if kw.lower() in combined_lower:
|
||||||
|
tags.append(tag)
|
||||||
|
return tags[:5]
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
"""Crawlers for the 国标委 (SAMR) standard information platform."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from app.infrastructure.perception.crawlers.base import BaseCrawler, RawEvent
|
||||||
|
from ._utils import extract_tags, parse_date
|
||||||
|
|
||||||
|
_BASE_URL = "https://openstd.samr.gov.cn/bzgk/std/std_list_type"
|
||||||
|
_HEADERS = {"User-Agent": "Mozilla/5.0 (compatible; RegulatoryBot/1.0)"}
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_page(std_type: int, page: int, page_size: int) -> list[dict]:
|
||||||
|
params = {
|
||||||
|
"p.p1": std_type,
|
||||||
|
"p.p2": "车",
|
||||||
|
"p.p90": "circulation_date",
|
||||||
|
"p.p91": "desc",
|
||||||
|
"p.p6": page,
|
||||||
|
"p.p7": page_size,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
resp = httpx.get(_BASE_URL, params=params, headers=_HEADERS, timeout=30)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
return data.get("rows", []) or []
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("国标委 fetch failed type={} page={} err={}", std_type, page, exc)
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _row_to_raw_event(row: dict, source_label: str) -> RawEvent:
|
||||||
|
standard_code = row.get("std_code", "")
|
||||||
|
title = row.get("std_name", standard_code)
|
||||||
|
published_at = parse_date(row.get("release_date", ""))
|
||||||
|
effective_at_raw = row.get("implement_date", "")
|
||||||
|
effective_at = parse_date(effective_at_raw) if effective_at_raw else None
|
||||||
|
status_text = row.get("std_status", "")
|
||||||
|
if "征求意见" in status_text:
|
||||||
|
status = "consultation"
|
||||||
|
elif "报批" in status_text or "草案" in status_text:
|
||||||
|
status = "draft"
|
||||||
|
else:
|
||||||
|
status = "enacted"
|
||||||
|
return RawEvent(
|
||||||
|
source="国标委",
|
||||||
|
source_label=source_label,
|
||||||
|
standard_code=standard_code,
|
||||||
|
title=title,
|
||||||
|
summary=title,
|
||||||
|
full_text_url=f"https://openstd.samr.gov.cn/bzgk/std/detail?id={row.get('id', '')}",
|
||||||
|
status=status,
|
||||||
|
published_at=published_at,
|
||||||
|
effective_at=effective_at,
|
||||||
|
category=row.get("std_type", "国家标准"),
|
||||||
|
tags=extract_tags(standard_code, title),
|
||||||
|
raw_text=f"{standard_code} {title}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class GuobiaoMandatoryCrawler(BaseCrawler):
|
||||||
|
"""Fetch mandatory national standards (强制性) related to vehicles."""
|
||||||
|
|
||||||
|
def fetch(self, limit: int = 50) -> list[RawEvent]:
|
||||||
|
events: list[RawEvent] = []
|
||||||
|
page = 1
|
||||||
|
max_pages = max(10, limit)
|
||||||
|
while len(events) < limit and page <= max_pages:
|
||||||
|
rows = _fetch_page(std_type=1, page=page, page_size=20)
|
||||||
|
if not rows:
|
||||||
|
break
|
||||||
|
events.extend(_row_to_raw_event(r, "国标委·强制性") for r in rows)
|
||||||
|
page += 1
|
||||||
|
return events[:limit]
|
||||||
|
|
||||||
|
|
||||||
|
class GuobiaoRecommendedCrawler(BaseCrawler):
|
||||||
|
"""Fetch recommended national standards (推荐性) related to vehicles."""
|
||||||
|
|
||||||
|
def fetch(self, limit: int = 50) -> list[RawEvent]:
|
||||||
|
events: list[RawEvent] = []
|
||||||
|
page = 1
|
||||||
|
max_pages = max(10, limit)
|
||||||
|
while len(events) < limit and page <= max_pages:
|
||||||
|
rows = _fetch_page(std_type=2, page=page, page_size=20)
|
||||||
|
if not rows:
|
||||||
|
break
|
||||||
|
events.extend(_row_to_raw_event(r, "国标委·推荐性") for r in rows)
|
||||||
|
page += 1
|
||||||
|
return events[:limit]
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
"""LLM-driven pipeline for regulatory event enrichment."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from app.config.settings import settings
|
||||||
|
from app.infrastructure.embedding.openai_compatible_embedding_provider import (
|
||||||
|
OpenAICompatibleEmbeddingProvider,
|
||||||
|
)
|
||||||
|
from app.services.llm.llm_factory import get_llm_client
|
||||||
|
|
||||||
|
_EXTRACT_SYSTEM = (
|
||||||
|
"You are a regulatory compliance expert specialising in automotive standards "
|
||||||
|
"(GB, UN-ECE, ISO, EU). Extract structured information from regulation text. "
|
||||||
|
"Return valid JSON only — no markdown fences, no extra keys."
|
||||||
|
)
|
||||||
|
|
||||||
|
_ASSESS_SYSTEM = (
|
||||||
|
"You are an automotive compliance analyst. Given a regulation and related document excerpts, "
|
||||||
|
"identify which documents are affected and what actions are required. "
|
||||||
|
"Return a JSON array only."
|
||||||
|
)
|
||||||
|
|
||||||
|
_DIFF_SYSTEM = (
|
||||||
|
"You are a regulatory change analyst. Given an old and new version of a regulation paragraph, "
|
||||||
|
"classify the type of change and summarise it. "
|
||||||
|
"Return JSON only: {\"change_type\": \"tightened|relaxed|added|removed\", \"summary\": \"...\"}"
|
||||||
|
)
|
||||||
|
|
||||||
|
_SIMILARITY_THRESHOLD = 0.85
|
||||||
|
|
||||||
|
|
||||||
|
def _cosine(a: list[float], b: list[float]) -> float:
|
||||||
|
dot = sum(x * y for x, y in zip(a, b))
|
||||||
|
norm_a = math.sqrt(sum(x * x for x in a))
|
||||||
|
norm_b = math.sqrt(sum(x * x for x in b))
|
||||||
|
if norm_a == 0 or norm_b == 0:
|
||||||
|
return 0.0
|
||||||
|
return dot / (norm_a * norm_b)
|
||||||
|
|
||||||
|
|
||||||
|
def _llm_json(client: Any, messages: list[dict]) -> Any:
|
||||||
|
"""Call LLM and parse JSON response; return None on failure."""
|
||||||
|
try:
|
||||||
|
resp = client.chat(messages)
|
||||||
|
text = (resp.content or "").strip()
|
||||||
|
if text.startswith("```"):
|
||||||
|
text = text.split("```")[1]
|
||||||
|
if text.startswith("json"):
|
||||||
|
text = text[4:]
|
||||||
|
return json.loads(text)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("LLM JSON parse failed: {}", exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class LlmPipeline:
|
||||||
|
"""Three-step enrichment pipeline for crawled regulatory events."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._client = get_llm_client(
|
||||||
|
provider=settings.llm_provider,
|
||||||
|
model=settings.llm_model,
|
||||||
|
)
|
||||||
|
self._embedder = OpenAICompatibleEmbeddingProvider()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Step 1: Structure extraction
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def extract_structure(self, event: dict) -> dict:
|
||||||
|
"""Extract obligations, deadlines, scope, penalties, impact_level from event text."""
|
||||||
|
prompt = f"""Extract structured compliance information from this regulation:
|
||||||
|
|
||||||
|
Standard: {event.get('standard_code', '')}
|
||||||
|
Title: {event.get('title', '')}
|
||||||
|
Source: {event.get('source_label', '')}
|
||||||
|
Summary: {event.get('summary', '')}
|
||||||
|
Tags: {', '.join(event.get('tags') or [])}
|
||||||
|
|
||||||
|
Return JSON with exactly these keys:
|
||||||
|
{{
|
||||||
|
"obligations": [{{"text": "...", "deontic": "must|shall|may|prohibited", "subject": "...", "object": "...", "condition": ""}}],
|
||||||
|
"deadlines": [{{"date": "YYYY-MM-DD or null", "description": "..."}}],
|
||||||
|
"scope": "one sentence describing who/what this applies to",
|
||||||
|
"penalties": "one sentence on consequences of non-compliance, or null",
|
||||||
|
"impact_level": "high|medium|low"
|
||||||
|
}}"""
|
||||||
|
|
||||||
|
messages = [
|
||||||
|
{"role": "system", "content": _EXTRACT_SYSTEM},
|
||||||
|
{"role": "user", "content": prompt},
|
||||||
|
]
|
||||||
|
result = _llm_json(self._client, messages)
|
||||||
|
if not isinstance(result, dict):
|
||||||
|
return {
|
||||||
|
"obligations": [],
|
||||||
|
"deadlines": [],
|
||||||
|
"scope": "",
|
||||||
|
"penalties": "",
|
||||||
|
"impact_level": "medium",
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Step 2: Impact assessment
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def assess_impact(self, event: dict, retrieval_service: Any) -> list[dict]:
|
||||||
|
"""Use RAG to find affected documents and generate recommendations."""
|
||||||
|
obligations = event.get("obligations") or []
|
||||||
|
obligation_texts = " ".join(o.get("text", "") for o in obligations[:3])
|
||||||
|
query = f"{event.get('standard_code', '')} {event.get('title', '')} {obligation_texts}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
chunks = retrieval_service.retrieve(query=query, top_k=5)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("RAG retrieval failed: {}", exc)
|
||||||
|
return []
|
||||||
|
|
||||||
|
if not chunks:
|
||||||
|
return []
|
||||||
|
|
||||||
|
seen: set[str] = set()
|
||||||
|
doc_excerpts: list[dict] = []
|
||||||
|
for chunk in chunks:
|
||||||
|
if chunk.doc_id not in seen:
|
||||||
|
seen.add(chunk.doc_id)
|
||||||
|
doc_excerpts.append({
|
||||||
|
"doc_id": chunk.doc_id,
|
||||||
|
"doc_name": chunk.doc_title,
|
||||||
|
"score": round(float(chunk.score if chunk.score is not None else 0), 4),
|
||||||
|
"snippet": (chunk.text or "")[:300],
|
||||||
|
"clause": getattr(chunk, "section_title", "") or "",
|
||||||
|
})
|
||||||
|
|
||||||
|
context = "\n".join(
|
||||||
|
f"[{d['doc_name']} {d['clause']}] score={d['score']}: {d['snippet']}"
|
||||||
|
for d in doc_excerpts
|
||||||
|
)
|
||||||
|
prompt = f"""Regulation: {event.get('standard_code')} — {event.get('title')}
|
||||||
|
Obligations: {obligation_texts or event.get('summary', '')}
|
||||||
|
|
||||||
|
Affected documents found in knowledge base:
|
||||||
|
{context}
|
||||||
|
|
||||||
|
For each document, assess impact and recommend action. Return JSON array:
|
||||||
|
[{{"doc_id":"...","doc_name":"...","score":0.0,"key_clauses":"...","recommendation":"one sentence action"}}]"""
|
||||||
|
|
||||||
|
messages = [
|
||||||
|
{"role": "system", "content": _ASSESS_SYSTEM},
|
||||||
|
{"role": "user", "content": prompt},
|
||||||
|
]
|
||||||
|
result = _llm_json(self._client, messages)
|
||||||
|
if isinstance(result, list):
|
||||||
|
score_map = {d["doc_id"]: d["score"] for d in doc_excerpts}
|
||||||
|
for item in result:
|
||||||
|
if isinstance(item, dict) and item.get("doc_id") in score_map:
|
||||||
|
item["score"] = score_map[item["doc_id"]]
|
||||||
|
return result
|
||||||
|
return doc_excerpts
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Step 3: Semantic diff
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def compute_diff(self, old_text: str, new_text: str) -> dict:
|
||||||
|
"""Compare old and new regulation text; return changed sections and summary."""
|
||||||
|
old_paras = [p.strip() for p in old_text.split("\n") if p.strip()]
|
||||||
|
new_paras = [p.strip() for p in new_text.split("\n") if p.strip()]
|
||||||
|
|
||||||
|
if not old_paras or not new_paras:
|
||||||
|
return {"changed_sections": [], "change_summary": "No comparable text."}
|
||||||
|
|
||||||
|
all_paras = old_paras + new_paras
|
||||||
|
try:
|
||||||
|
all_embeddings = self._embedder.embed_texts(all_paras)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Embedding for diff failed: {}", exc)
|
||||||
|
return {"changed_sections": [], "change_summary": "Diff unavailable (embedding error)."}
|
||||||
|
|
||||||
|
old_embeddings = all_embeddings[: len(old_paras)]
|
||||||
|
new_embeddings = all_embeddings[len(old_paras):]
|
||||||
|
|
||||||
|
changed_sections: list[dict] = []
|
||||||
|
max_len = max(len(old_paras), len(new_paras))
|
||||||
|
|
||||||
|
for i in range(max_len):
|
||||||
|
if i >= len(old_paras):
|
||||||
|
# New paragraph added
|
||||||
|
changed_sections.append({
|
||||||
|
"old_text": "",
|
||||||
|
"new_text": new_paras[i][:300],
|
||||||
|
"similarity": 0.0,
|
||||||
|
"change_type": "added",
|
||||||
|
"summary": "New paragraph added.",
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
if i >= len(new_paras):
|
||||||
|
# Old paragraph removed
|
||||||
|
changed_sections.append({
|
||||||
|
"old_text": old_paras[i][:300],
|
||||||
|
"new_text": "",
|
||||||
|
"similarity": 0.0,
|
||||||
|
"change_type": "removed",
|
||||||
|
"summary": "Paragraph removed.",
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
# Both exist — compare via embeddings
|
||||||
|
sim = _cosine(old_embeddings[i], new_embeddings[i])
|
||||||
|
if sim < _SIMILARITY_THRESHOLD:
|
||||||
|
messages = [
|
||||||
|
{"role": "system", "content": _DIFF_SYSTEM},
|
||||||
|
{"role": "user", "content": f"OLD: {old_paras[i][:500]}\nNEW: {new_paras[i][:500]}"},
|
||||||
|
]
|
||||||
|
classification = _llm_json(self._client, messages) or {}
|
||||||
|
changed_sections.append({
|
||||||
|
"old_text": old_paras[i][:300],
|
||||||
|
"new_text": new_paras[i][:300],
|
||||||
|
"similarity": round(sim, 3),
|
||||||
|
"change_type": classification.get("change_type", "modified"),
|
||||||
|
"summary": classification.get("summary", ""),
|
||||||
|
})
|
||||||
|
|
||||||
|
if not changed_sections:
|
||||||
|
change_summary = "No substantive changes detected between versions."
|
||||||
|
else:
|
||||||
|
types = [s["change_type"] for s in changed_sections]
|
||||||
|
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}
|
||||||
@@ -4,6 +4,8 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from app.infrastructure.perception.base_event_store import BaseEventStore
|
||||||
|
|
||||||
MOCK_EVENTS: list[dict[str, Any]] = [
|
MOCK_EVENTS: list[dict[str, Any]] = [
|
||||||
# ------------------------------------------------------------------ HIGH
|
# ------------------------------------------------------------------ HIGH
|
||||||
{
|
{
|
||||||
@@ -379,18 +381,18 @@ MOCK_EVENTS: list[dict[str, Any]] = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
# Index for fast lookup
|
class MockEventStore(BaseEventStore):
|
||||||
_EVENT_INDEX: dict[str, dict] = {e["id"]: e for e in MOCK_EVENTS}
|
|
||||||
|
|
||||||
|
|
||||||
class MockEventStore:
|
|
||||||
"""In-memory mock store for regulatory events."""
|
"""In-memory mock store for regulatory events."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._events: list[dict] = [dict(e) for e in MOCK_EVENTS]
|
||||||
|
self._index: dict[str, dict] = {e["id"]: e for e in self._events}
|
||||||
|
|
||||||
def all(self) -> list[dict]:
|
def all(self) -> list[dict]:
|
||||||
return list(MOCK_EVENTS)
|
return list(self._events)
|
||||||
|
|
||||||
def get(self, event_id: str) -> dict | None:
|
def get(self, event_id: str) -> dict | None:
|
||||||
return _EVENT_INDEX.get(event_id)
|
return self._index.get(event_id)
|
||||||
|
|
||||||
def filter(
|
def filter(
|
||||||
self,
|
self,
|
||||||
@@ -399,23 +401,39 @@ class MockEventStore:
|
|||||||
impact_level: str | None = None,
|
impact_level: str | None = None,
|
||||||
limit: int = 50,
|
limit: int = 50,
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
events = list(MOCK_EVENTS)
|
events = list(self._events)
|
||||||
if source:
|
if source:
|
||||||
events = [e for e in events if e["source"] == source]
|
events = [e for e in events if e["source"] == source]
|
||||||
if impact_level:
|
if impact_level:
|
||||||
events = [e for e in events if e["impact_level"] == impact_level]
|
events = [e for e in events if e["impact_level"] == impact_level]
|
||||||
events.sort(key=lambda e: e["published_at"], reverse=True)
|
events.sort(key=lambda e: e.get("published_at") or "", reverse=True)
|
||||||
return events[:limit]
|
return events[:limit]
|
||||||
|
|
||||||
def stats(self) -> dict:
|
def stats(self) -> dict:
|
||||||
from datetime import date, timedelta
|
from datetime import date, timedelta
|
||||||
|
|
||||||
events = MOCK_EVENTS
|
events = self._events
|
||||||
cutoff = (date.today() - timedelta(days=90)).isoformat()
|
cutoff = (date.today() - timedelta(days=90)).isoformat()
|
||||||
return {
|
return {
|
||||||
"total": len(events),
|
"total": len(events),
|
||||||
"high_impact": sum(1 for e in events if e["impact_level"] == "high"),
|
"high_impact": sum(1 for e in events if e["impact_level"] == "high"),
|
||||||
"medium_impact": sum(1 for e in events if e["impact_level"] == "medium"),
|
"medium_impact": sum(1 for e in events if e["impact_level"] == "medium"),
|
||||||
"low_impact": sum(1 for e in events if e["impact_level"] == "low"),
|
"low_impact": sum(1 for e in events if e["impact_level"] == "low"),
|
||||||
"recent_90d": sum(1 for e in events if e["published_at"] >= cutoff),
|
"recent_90d": sum(1 for e in events if (e.get("published_at") or "") >= cutoff),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def upsert(self, event: dict) -> None:
|
||||||
|
"""Insert or update event in the in-memory list (used in tests)."""
|
||||||
|
existing = self._index.get(event["id"])
|
||||||
|
if existing:
|
||||||
|
existing.update(event)
|
||||||
|
else:
|
||||||
|
self._events.append(event)
|
||||||
|
self._index[event["id"]] = event
|
||||||
|
|
||||||
|
def get_by_standard_code(self, standard_code: str) -> dict | None:
|
||||||
|
"""Return most-recent event with matching standard_code."""
|
||||||
|
matches = [e for e in self._events if e.get("standard_code") == standard_code]
|
||||||
|
if not matches:
|
||||||
|
return None
|
||||||
|
return max(matches, key=lambda e: e.get("published_at", ""))
|
||||||
|
|||||||
@@ -0,0 +1,225 @@
|
|||||||
|
"""PostgreSQL-backed regulatory event store."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from datetime import UTC, date, datetime, timedelta
|
||||||
|
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_event_store import BaseEventStore
|
||||||
|
|
||||||
|
_CREATE_TABLE = """
|
||||||
|
CREATE TABLE IF NOT EXISTS regulation_events (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
source TEXT NOT NULL,
|
||||||
|
source_label TEXT,
|
||||||
|
standard_code TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
summary TEXT,
|
||||||
|
full_text_url TEXT,
|
||||||
|
status TEXT,
|
||||||
|
impact_level TEXT,
|
||||||
|
published_at DATE,
|
||||||
|
effective_at DATE,
|
||||||
|
category TEXT,
|
||||||
|
tags TEXT[],
|
||||||
|
obligations JSONB,
|
||||||
|
deadlines JSONB,
|
||||||
|
scope TEXT,
|
||||||
|
penalties TEXT,
|
||||||
|
content_hash TEXT,
|
||||||
|
previous_hash TEXT,
|
||||||
|
change_summary TEXT,
|
||||||
|
changed_sections JSONB,
|
||||||
|
affected_docs JSONB,
|
||||||
|
crawled_at TIMESTAMPTZ DEFAULT now(),
|
||||||
|
processed_at TIMESTAMPTZ,
|
||||||
|
raw_storage_key TEXT
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS reg_events_source_date
|
||||||
|
ON regulation_events (source, published_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS reg_events_impact_date
|
||||||
|
ON regulation_events (impact_level, published_at DESC);
|
||||||
|
"""
|
||||||
|
|
||||||
|
_ALL_COLUMNS = (
|
||||||
|
"id", "source", "source_label", "standard_code", "title", "summary",
|
||||||
|
"full_text_url", "status", "impact_level", "published_at", "effective_at",
|
||||||
|
"category", "tags", "obligations", "deadlines", "scope", "penalties",
|
||||||
|
"content_hash", "previous_hash", "change_summary", "changed_sections",
|
||||||
|
"affected_docs", "crawled_at", "processed_at", "raw_storage_key",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _row_to_dict(row: dict[str, Any]) -> dict:
|
||||||
|
"""Convert a psycopg2 RealDictRow to a plain dict with serialized JSON fields."""
|
||||||
|
d = dict(row)
|
||||||
|
for field in ("obligations", "deadlines", "changed_sections", "affected_docs"):
|
||||||
|
val = d.get(field)
|
||||||
|
if isinstance(val, str):
|
||||||
|
d[field] = json.loads(val)
|
||||||
|
for date_field in ("published_at", "effective_at"):
|
||||||
|
val = d.get(date_field)
|
||||||
|
if isinstance(val, datetime):
|
||||||
|
d[date_field] = val.date().isoformat()
|
||||||
|
elif isinstance(val, date):
|
||||||
|
d[date_field] = val.isoformat()
|
||||||
|
for ts_field in ("crawled_at", "processed_at"):
|
||||||
|
val = d.get(ts_field)
|
||||||
|
if isinstance(val, datetime):
|
||||||
|
d[ts_field] = val.isoformat()
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
class PostgresEventStore(BaseEventStore):
|
||||||
|
"""Regulatory event store backed by PostgreSQL."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
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_TABLE)
|
||||||
|
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 all(self) -> list[dict]:
|
||||||
|
with self._conn() as conn:
|
||||||
|
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
||||||
|
cur.execute(
|
||||||
|
"SELECT * FROM regulation_events ORDER BY published_at DESC NULLS LAST"
|
||||||
|
)
|
||||||
|
return [_row_to_dict(r) for r in cur.fetchall()]
|
||||||
|
|
||||||
|
def get(self, event_id: str) -> dict | None:
|
||||||
|
with self._conn() as conn:
|
||||||
|
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
||||||
|
cur.execute(
|
||||||
|
"SELECT * FROM regulation_events WHERE id = %s", (event_id,)
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
return _row_to_dict(row) if row else None
|
||||||
|
|
||||||
|
def filter(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
source: str | None = None,
|
||||||
|
impact_level: str | None = None,
|
||||||
|
limit: int = 50,
|
||||||
|
) -> list[dict]:
|
||||||
|
conditions: list[str] = []
|
||||||
|
params: list[Any] = []
|
||||||
|
if source:
|
||||||
|
conditions.append("source = %s")
|
||||||
|
params.append(source)
|
||||||
|
if impact_level:
|
||||||
|
conditions.append("impact_level = %s")
|
||||||
|
params.append(impact_level)
|
||||||
|
where = ("WHERE " + " AND ".join(conditions)) if conditions else ""
|
||||||
|
params.append(limit)
|
||||||
|
sql = f"""
|
||||||
|
SELECT * FROM regulation_events
|
||||||
|
{where}
|
||||||
|
ORDER BY published_at DESC NULLS LAST
|
||||||
|
LIMIT %s
|
||||||
|
"""
|
||||||
|
with self._conn() as conn:
|
||||||
|
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
||||||
|
cur.execute(sql, params)
|
||||||
|
return [_row_to_dict(r) for r in cur.fetchall()]
|
||||||
|
|
||||||
|
def stats(self) -> dict:
|
||||||
|
cutoff = (date.today() - timedelta(days=90)).isoformat()
|
||||||
|
with self._conn() as conn:
|
||||||
|
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
||||||
|
cur.execute("SELECT COUNT(*) AS count FROM regulation_events")
|
||||||
|
total = (cur.fetchone() or {}).get("count", 0)
|
||||||
|
cur.execute(
|
||||||
|
"SELECT COUNT(*) AS count FROM regulation_events WHERE impact_level = 'high'"
|
||||||
|
)
|
||||||
|
high = (cur.fetchone() or {}).get("count", 0)
|
||||||
|
cur.execute(
|
||||||
|
"SELECT COUNT(*) AS count FROM regulation_events WHERE impact_level = 'medium'"
|
||||||
|
)
|
||||||
|
medium = (cur.fetchone() or {}).get("count", 0)
|
||||||
|
cur.execute(
|
||||||
|
"SELECT COUNT(*) AS count FROM regulation_events WHERE published_at >= %s",
|
||||||
|
(cutoff,),
|
||||||
|
)
|
||||||
|
recent = (cur.fetchone() or {}).get("count", 0)
|
||||||
|
return {
|
||||||
|
"total": int(total),
|
||||||
|
"high_impact": int(high),
|
||||||
|
"medium_impact": int(medium),
|
||||||
|
"recent_90d": int(recent),
|
||||||
|
}
|
||||||
|
|
||||||
|
def upsert(self, event: dict) -> None:
|
||||||
|
"""Insert or update a regulation event."""
|
||||||
|
cols = [c for c in _ALL_COLUMNS if c in event]
|
||||||
|
placeholders = ", ".join(f"%({c})s" for c in cols)
|
||||||
|
updates = ", ".join(f"{c} = EXCLUDED.{c}" for c in cols if c != "id")
|
||||||
|
sql = f"""
|
||||||
|
INSERT INTO regulation_events ({', '.join(cols)})
|
||||||
|
VALUES ({placeholders})
|
||||||
|
ON CONFLICT (id) DO UPDATE SET {updates}
|
||||||
|
"""
|
||||||
|
row: dict[str, Any] = {}
|
||||||
|
for c in cols:
|
||||||
|
val = event.get(c)
|
||||||
|
if c in ("obligations", "deadlines", "changed_sections", "affected_docs") and val is not None:
|
||||||
|
row[c] = json.dumps(val, ensure_ascii=False)
|
||||||
|
elif c == "tags" and isinstance(val, list):
|
||||||
|
row[c] = val
|
||||||
|
else:
|
||||||
|
row[c] = val
|
||||||
|
with self._conn() as conn:
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(sql, row)
|
||||||
|
conn.commit()
|
||||||
|
except Exception:
|
||||||
|
conn.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
def get_by_standard_code(self, standard_code: str) -> dict | None:
|
||||||
|
with self._conn() as conn:
|
||||||
|
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""SELECT * FROM regulation_events
|
||||||
|
WHERE standard_code = %s
|
||||||
|
ORDER BY published_at DESC NULLS LAST
|
||||||
|
LIMIT 1""",
|
||||||
|
(standard_code,),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
return _row_to_dict(row) if row else None
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
"""Redis-backed conversation store for persistent chat sessions.
|
||||||
|
|
||||||
|
Sessions are stored as JSON strings under the key `session:{session_id}`.
|
||||||
|
The Redis TTL is refreshed on every write so active sessions stay alive.
|
||||||
|
On expiry, `get_session` returns None — callers should create a new session.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from app.domain.conversation import ConversationMessage, ConversationSession, ConversationStore
|
||||||
|
|
||||||
|
|
||||||
|
class RedisConversationStore(ConversationStore):
|
||||||
|
"""Store conversation sessions in Redis with automatic TTL expiry.
|
||||||
|
|
||||||
|
Each session is serialised as a JSON object at key ``session:{session_id}``.
|
||||||
|
The TTL is reset on every write so sessions stay alive as long as they are active.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Prefix for all session keys to avoid collisions with other Redis consumers.
|
||||||
|
_PREFIX = "session:"
|
||||||
|
|
||||||
|
def __init__(self, *, redis_client: Any, timeout_seconds: int = 1800) -> None:
|
||||||
|
"""Initialise the store with an existing Redis client and a TTL in seconds."""
|
||||||
|
self._redis = redis_client
|
||||||
|
self._ttl = timeout_seconds
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Internal helpers
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _key(self, session_id: str) -> str:
|
||||||
|
"""Build the Redis key for a session."""
|
||||||
|
return f"{self._PREFIX}{session_id}"
|
||||||
|
|
||||||
|
def _serialise(self, session: ConversationSession) -> str:
|
||||||
|
"""Serialise a ConversationSession to a JSON string."""
|
||||||
|
return json.dumps(
|
||||||
|
{
|
||||||
|
"session_id": session.session_id,
|
||||||
|
"created_at": session.created_at,
|
||||||
|
"updated_at": session.updated_at,
|
||||||
|
"metadata": session.metadata,
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"role": msg.role,
|
||||||
|
"content": msg.content,
|
||||||
|
"timestamp": msg.timestamp,
|
||||||
|
"sources": msg.sources,
|
||||||
|
}
|
||||||
|
for msg in session.messages
|
||||||
|
],
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _deserialise(self, raw: bytes | str) -> ConversationSession:
|
||||||
|
"""Deserialise a JSON string back into a ConversationSession."""
|
||||||
|
data = json.loads(raw)
|
||||||
|
messages = [
|
||||||
|
ConversationMessage(
|
||||||
|
role=m["role"],
|
||||||
|
content=m["content"],
|
||||||
|
timestamp=m["timestamp"],
|
||||||
|
sources=m.get("sources", []),
|
||||||
|
)
|
||||||
|
for m in data.get("messages", [])
|
||||||
|
]
|
||||||
|
session = ConversationSession(
|
||||||
|
session_id=data["session_id"],
|
||||||
|
created_at=data.get("created_at", 0),
|
||||||
|
updated_at=data.get("updated_at", 0),
|
||||||
|
metadata=data.get("metadata", {}),
|
||||||
|
)
|
||||||
|
session.messages = messages
|
||||||
|
return session
|
||||||
|
|
||||||
|
def _save(self, session: ConversationSession) -> None:
|
||||||
|
"""Persist a session to Redis and refresh its TTL."""
|
||||||
|
self._redis.setex(self._key(session.session_id), self._ttl, self._serialise(session))
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# ConversationStore protocol
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def create_session(self, metadata: dict | None = None) -> ConversationSession:
|
||||||
|
"""Create a new empty session and persist it immediately."""
|
||||||
|
now = int(time.time())
|
||||||
|
session = ConversationSession(
|
||||||
|
session_id=str(uuid.uuid4())[:8],
|
||||||
|
created_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
metadata=metadata or {},
|
||||||
|
)
|
||||||
|
self._save(session)
|
||||||
|
return session
|
||||||
|
|
||||||
|
def get_session(self, session_id: str) -> ConversationSession | None:
|
||||||
|
"""Return a session by ID, or None if it does not exist or has expired."""
|
||||||
|
raw = self._redis.get(self._key(session_id))
|
||||||
|
if raw is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return self._deserialise(raw)
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Failed to deserialise session: {}", session_id)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def save_message(
|
||||||
|
self,
|
||||||
|
session_id: str,
|
||||||
|
*,
|
||||||
|
role: str,
|
||||||
|
content: str,
|
||||||
|
sources: list[dict] | None = None,
|
||||||
|
) -> ConversationSession | None:
|
||||||
|
"""Append a message to a session and refresh its TTL."""
|
||||||
|
session = self.get_session(session_id)
|
||||||
|
if session is None:
|
||||||
|
return None
|
||||||
|
session.messages.append(
|
||||||
|
ConversationMessage(
|
||||||
|
role=role,
|
||||||
|
content=content,
|
||||||
|
timestamp=int(time.time()),
|
||||||
|
sources=sources or [],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.updated_at = int(time.time())
|
||||||
|
self._save(session)
|
||||||
|
return session
|
||||||
|
|
||||||
|
def delete_session(self, session_id: str) -> bool:
|
||||||
|
"""Delete a session. Returns True if it existed, False otherwise."""
|
||||||
|
deleted = self._redis.delete(self._key(session_id))
|
||||||
|
return bool(deleted)
|
||||||
|
|
||||||
|
def list_sessions(self) -> list[dict]:
|
||||||
|
"""Return summary dicts for all live sessions visible in this Redis DB.
|
||||||
|
|
||||||
|
Note: KEYS is used for simplicity; replace with SCAN for large deployments.
|
||||||
|
"""
|
||||||
|
pattern = f"{self._PREFIX}*"
|
||||||
|
keys = self._redis.keys(pattern)
|
||||||
|
result = []
|
||||||
|
for key in keys:
|
||||||
|
raw = self._redis.get(key)
|
||||||
|
if raw is None:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
data = json.loads(raw)
|
||||||
|
result.append(
|
||||||
|
{
|
||||||
|
"session_id": data["session_id"],
|
||||||
|
"message_count": len(data.get("messages", [])),
|
||||||
|
"created_at": data.get("created_at", 0),
|
||||||
|
"updated_at": data.get("updated_at", 0),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
return result
|
||||||
@@ -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,5 @@
|
|||||||
|
"""Celery task definitions for background processing.
|
||||||
|
|
||||||
|
This package exposes the shared Celery application instance and all
|
||||||
|
registered task functions used by API routes to enqueue work.
|
||||||
|
"""
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"""Shared Celery application instance for background task processing.
|
||||||
|
|
||||||
|
All workers and enqueueing call sites import `celery_app` from this module
|
||||||
|
so the broker/backend configuration stays in one place.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from celery import Celery
|
||||||
|
|
||||||
|
from app.config.settings import settings
|
||||||
|
|
||||||
|
|
||||||
|
def _redis_url() -> str:
|
||||||
|
"""Return a Redis connection URL from application settings."""
|
||||||
|
if settings.redis_password:
|
||||||
|
return (
|
||||||
|
f"redis://:{settings.redis_password}@"
|
||||||
|
f"{settings.redis_host}:{settings.redis_port}/{settings.redis_db}"
|
||||||
|
)
|
||||||
|
return f"redis://{settings.redis_host}:{settings.redis_port}/{settings.redis_db}"
|
||||||
|
|
||||||
|
|
||||||
|
_BROKER = _redis_url()
|
||||||
|
_BACKEND = _redis_url()
|
||||||
|
|
||||||
|
celery_app = Celery(
|
||||||
|
"compliance_hub",
|
||||||
|
broker=_BROKER,
|
||||||
|
backend=_BACKEND,
|
||||||
|
include=["app.infrastructure.tasks.document_tasks"],
|
||||||
|
)
|
||||||
|
|
||||||
|
celery_app.conf.update(
|
||||||
|
task_serializer="json",
|
||||||
|
result_serializer="json",
|
||||||
|
accept_content=["json"],
|
||||||
|
timezone="UTC",
|
||||||
|
enable_utc=True,
|
||||||
|
# Acknowledge task only after successful execution to avoid data loss.
|
||||||
|
task_acks_late=True,
|
||||||
|
task_reject_on_worker_lost=True,
|
||||||
|
# Keep results for 1 hour for status polling.
|
||||||
|
result_expires=3600,
|
||||||
|
)
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
"""Celery tasks for document processing.
|
||||||
|
|
||||||
|
Each task is a thin wrapper that retrieves the already-stored document
|
||||||
|
binary and delegates to DocumentCommandService._process_document.
|
||||||
|
The task does not accept raw file bytes — it reads them from the binary
|
||||||
|
store using the doc_id, so the Celery message payload stays small.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from app.infrastructure.tasks.celery_app import celery_app
|
||||||
|
|
||||||
|
|
||||||
|
@celery_app.task(
|
||||||
|
name="app.infrastructure.tasks.document_tasks.process_document_task",
|
||||||
|
bind=True,
|
||||||
|
max_retries=3,
|
||||||
|
default_retry_delay=30,
|
||||||
|
acks_late=True,
|
||||||
|
)
|
||||||
|
def process_document_task(
|
||||||
|
self,
|
||||||
|
doc_id: str,
|
||||||
|
file_name: str,
|
||||||
|
doc_name: str,
|
||||||
|
regulation_type: str,
|
||||||
|
version: str,
|
||||||
|
generate_summary: bool,
|
||||||
|
run_id: str | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Parse, embed, and index a document that has already been stored.
|
||||||
|
|
||||||
|
The task reads the file binary from MinIO using doc_id so the Celery
|
||||||
|
message stays small. Retries up to 3 times with a 30-second delay on
|
||||||
|
transient infrastructure errors.
|
||||||
|
"""
|
||||||
|
# Import inside the task function to avoid pickling issues and to ensure
|
||||||
|
# that each worker process initialises its own bootstrap singletons.
|
||||||
|
from app.shared.bootstrap import get_document_command_service, get_document_query_service
|
||||||
|
|
||||||
|
logger.info("process_document_task started: doc_id={}", doc_id)
|
||||||
|
try:
|
||||||
|
svc = get_document_command_service()
|
||||||
|
doc = get_document_query_service().get(doc_id)
|
||||||
|
if not doc:
|
||||||
|
raise ValueError(f"Document record not found: {doc_id}")
|
||||||
|
|
||||||
|
# Read the stored binary from MinIO — avoids passing raw bytes in the task message.
|
||||||
|
content = svc.binary_store.read(doc.object_name)
|
||||||
|
|
||||||
|
result = svc._process_document(
|
||||||
|
doc_id=doc_id,
|
||||||
|
file_name=file_name,
|
||||||
|
final_doc_name=doc_name,
|
||||||
|
content=content,
|
||||||
|
regulation_type=regulation_type,
|
||||||
|
version=version,
|
||||||
|
generate_summary=generate_summary,
|
||||||
|
run_id=run_id,
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"process_document_task completed: doc_id={} status={} chunks={}",
|
||||||
|
doc_id, result.status, result.num_chunks,
|
||||||
|
)
|
||||||
|
return {"doc_id": result.doc_id, "status": result.status, "num_chunks": result.num_chunks}
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("process_document_task failed: doc_id={}", doc_id)
|
||||||
|
# Retry on transient errors; permanent errors (bad file, parse failure)
|
||||||
|
# will exhaust retries and leave the document in FAILED state.
|
||||||
|
raise self.retry(exc=exc)
|
||||||
@@ -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,21 @@
|
|||||||
|
"""No-op reranker stub.
|
||||||
|
|
||||||
|
Returns the original candidate list sliced to top_k.
|
||||||
|
Replace with CrossEncoderReranker when a local cross-encoder model is available.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.domain.retrieval.models import RetrievedChunk
|
||||||
|
from app.domain.retrieval.ports import Reranker
|
||||||
|
|
||||||
|
|
||||||
|
class PassThroughReranker(Reranker):
|
||||||
|
"""Pass-through reranker that preserves original retrieval order.
|
||||||
|
|
||||||
|
Acts as a placeholder for future cross-encoder reranking (e.g. ms-marco-MiniLM).
|
||||||
|
Wire via bootstrap.get_compliance_reranker() when ready to swap.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def rerank(self, query: str, chunks: list[RetrievedChunk], top_k: int) -> list[RetrievedChunk]:
|
||||||
|
"""Return the first top_k chunks without reordering."""
|
||||||
|
return chunks[:top_k]
|
||||||
@@ -81,3 +81,29 @@ class AnalyzeResponse(BaseModel):
|
|||||||
"""Define the Analyze Response API model."""
|
"""Define the Analyze Response API model."""
|
||||||
task_id: str
|
task_id: str
|
||||||
status: str = "processing"
|
status: str = "processing"
|
||||||
|
|
||||||
|
|
||||||
|
class AnalyzeStreamSource(BaseModel):
|
||||||
|
"""SSE source event payload for analyze-stream."""
|
||||||
|
standard: str
|
||||||
|
clause: str
|
||||||
|
score: float
|
||||||
|
status: str
|
||||||
|
full_content: str
|
||||||
|
|
||||||
|
|
||||||
|
class AnalyzeStreamFinding(BaseModel):
|
||||||
|
"""SSE finding event payload for analyze-stream."""
|
||||||
|
title: str
|
||||||
|
desc: str
|
||||||
|
status: str
|
||||||
|
clause_ref: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class AnalyzeStreamDone(BaseModel):
|
||||||
|
"""SSE done event payload for analyze-stream."""
|
||||||
|
conclusion: str
|
||||||
|
actions: list[dict]
|
||||||
|
risk_score: int
|
||||||
|
highlight_terms: list[str]
|
||||||
|
para_text: str
|
||||||
@@ -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,4 +1,8 @@
|
|||||||
"""Provide service-layer logic for deepseek client."""
|
"""Provide service-layer logic for deepseek client.
|
||||||
|
|
||||||
|
P0-0: ``chat()`` now accepts an optional ``tools`` list and parses ``tool_calls``
|
||||||
|
from the model response so that callers can dispatch tool invocations.
|
||||||
|
"""
|
||||||
|
|
||||||
import time
|
import time
|
||||||
from typing import List, Dict, Optional
|
from typing import List, Dict, Optional
|
||||||
@@ -6,6 +10,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.
|
||||||
|
|
||||||
|
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|
||||||
@@ -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."""
|
||||||
@@ -137,7 +144,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 +207,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.
|
||||||
|
|
||||||
|
|
||||||
@@ -54,14 +59,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 +81,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 +98,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:
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
"""Shared tool and tool-call type definitions for LLM function calling (P0-0).
|
||||||
|
|
||||||
|
These types implement the OpenAI-compatible tool/function-calling interface so that
|
||||||
|
any provider whose gateway supports the spec (DeepSeek, Qwen, etc.) can expose
|
||||||
|
tools to the LLM and receive structured tool invocations in return.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ToolCall:
|
||||||
|
"""Represent a single tool invocation returned by the LLM.
|
||||||
|
|
||||||
|
The model fills in ``id``, ``name``, and ``arguments`` when it decides to call
|
||||||
|
a tool instead of (or in addition to) producing a text response.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Unique identifier assigned by the model for this call.
|
||||||
|
id: str
|
||||||
|
# Name of the tool to invoke, matching the name registered in Tool.
|
||||||
|
name: str
|
||||||
|
# Parsed JSON arguments ready for direct use by the tool handler.
|
||||||
|
arguments: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ToolParameter:
|
||||||
|
"""JSON-Schema–compatible parameter block for a tool definition."""
|
||||||
|
|
||||||
|
# Top-level schema type — always "object" for OpenAI-compatible tools.
|
||||||
|
type: str = "object"
|
||||||
|
# Map of parameter name → JSON-Schema property descriptor.
|
||||||
|
properties: dict[str, Any] = field(default_factory=dict)
|
||||||
|
# List of required parameter names.
|
||||||
|
required: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Tool:
|
||||||
|
"""Describe a callable tool that can be offered to the LLM.
|
||||||
|
|
||||||
|
Example usage::
|
||||||
|
|
||||||
|
search_tool = Tool(
|
||||||
|
name="search_regulations",
|
||||||
|
description="Search the compliance knowledge base for relevant regulation clauses.",
|
||||||
|
parameters=ToolParameter(
|
||||||
|
properties={"query": {"type": "string", "description": "Search query"}},
|
||||||
|
required=["query"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
response = client.chat(messages, tools=[search_tool])
|
||||||
|
"""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
description: str
|
||||||
|
parameters: ToolParameter = field(default_factory=ToolParameter)
|
||||||
|
|
||||||
|
def to_openai_format(self) -> dict[str, Any]:
|
||||||
|
"""Serialise this tool to the OpenAI-compatible function-calling schema.
|
||||||
|
|
||||||
|
The returned dict can be placed directly in the ``tools`` list of a chat
|
||||||
|
completions request without any further transformation.
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": self.name,
|
||||||
|
"description": self.description,
|
||||||
|
"parameters": {
|
||||||
|
"type": self.parameters.type,
|
||||||
|
"properties": self.parameters.properties,
|
||||||
|
"required": self.parameters.required,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
"""Transparent decorator around BaseLLMClient implementations.
|
||||||
|
|
||||||
|
Records per-call token usage, latency, and success/failure into a
|
||||||
|
ModelUsageTracker without changing any caller-visible behavior.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
from app.shared.model_usage_tracker import ModelUsageTracker
|
||||||
|
|
||||||
|
from .base_client import BaseLLMClient, LLMResponse
|
||||||
|
from .tool_types import Tool
|
||||||
|
|
||||||
|
|
||||||
|
class TrackedLLMClient:
|
||||||
|
"""Wrap any BaseLLMClient and record its usage into a ModelUsageTracker.
|
||||||
|
|
||||||
|
Deliberately does NOT subclass BaseLLMClient: that ABC declares abstract
|
||||||
|
methods (_init_client, get_available_models) with no meaningful override
|
||||||
|
here, and subclassing would make Python refuse to instantiate this class
|
||||||
|
("Can't instantiate abstract class") before __getattr__ ever got a chance
|
||||||
|
to forward the call. Plain composition + __getattr__ delegation works
|
||||||
|
because every caller in this codebase only ever uses duck-typed access:
|
||||||
|
.chat(), .stream_chat(), .get_available_models(), .close(), .config.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, inner: BaseLLMClient, tracker: ModelUsageTracker) -> None:
|
||||||
|
"""Store the wrapped client and the tracker to report into."""
|
||||||
|
self._inner = inner
|
||||||
|
self._tracker = tracker
|
||||||
|
|
||||||
|
def chat(
|
||||||
|
self,
|
||||||
|
messages: List[Dict[str, str]],
|
||||||
|
max_tokens: Optional[int] = None,
|
||||||
|
temperature: Optional[float] = None,
|
||||||
|
tools: Optional[List[Tool]] = None,
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> LLMResponse:
|
||||||
|
"""Delegate to the wrapped client's chat(), then record the outcome."""
|
||||||
|
start = time.time()
|
||||||
|
response = self._inner.chat(messages, max_tokens, temperature, tools, **kwargs)
|
||||||
|
# Key by the *configured* model, not response.model, so lookups driven
|
||||||
|
# by settings (llm_model / hyde_llm_model) always match what we recorded.
|
||||||
|
self._tracker.record(
|
||||||
|
provider=self._inner.config.provider.value,
|
||||||
|
model=self._inner.config.model,
|
||||||
|
success=response.is_success,
|
||||||
|
usage=response.usage,
|
||||||
|
latency_ms=int((time.time() - start) * 1000),
|
||||||
|
error=response.error,
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|
||||||
|
def stream_chat(self, messages: List[Dict[str, str]], *args: Any, **kwargs: Any):
|
||||||
|
"""Delegate to the wrapped client's stream_chat(), recording call outcome only.
|
||||||
|
|
||||||
|
Token usage is NOT recorded here: none of the current provider
|
||||||
|
stream_chat() implementations parse a trailing usage chunk from the
|
||||||
|
gateway (see the design doc's Known Limitations), so accumulating a
|
||||||
|
token count here would silently be wrong. Only call success/failure
|
||||||
|
and latency are tracked for streaming calls.
|
||||||
|
"""
|
||||||
|
start = time.time()
|
||||||
|
error: Optional[str] = None
|
||||||
|
try:
|
||||||
|
for chunk in self._inner.stream_chat(messages, *args, **kwargs):
|
||||||
|
yield chunk
|
||||||
|
except Exception as exc: # noqa: BLE001 - report, then re-raise unchanged
|
||||||
|
error = str(exc)
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
self._tracker.record(
|
||||||
|
provider=self._inner.config.provider.value,
|
||||||
|
model=self._inner.config.model,
|
||||||
|
success=error is None,
|
||||||
|
latency_ms=int((time.time() - start) * 1000),
|
||||||
|
error=error,
|
||||||
|
)
|
||||||
|
|
||||||
|
def __getattr__(self, name: str) -> Any:
|
||||||
|
"""Forward any other attribute/method access to the wrapped client."""
|
||||||
|
return getattr(self._inner, name)
|
||||||
@@ -6,6 +6,7 @@ from functools import lru_cache
|
|||||||
from typing import Callable
|
from typing import Callable
|
||||||
|
|
||||||
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,6 +20,15 @@ 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.application.perception.crawl_service import CrawlService
|
||||||
|
from app.infrastructure.perception.base_event_store import BaseEventStore
|
||||||
|
from app.infrastructure.perception.crawlers.catarc_crawler import CatarcCrawler
|
||||||
|
from app.infrastructure.perception.crawlers.guobiao_crawler import (
|
||||||
|
GuobiaoMandatoryCrawler,
|
||||||
|
GuobiaoRecommendedCrawler,
|
||||||
|
)
|
||||||
|
from app.infrastructure.perception.crawlers.eurlex_crawler import EurlexCrawler
|
||||||
|
from app.infrastructure.perception.llm_pipeline import LlmPipeline
|
||||||
from app.infrastructure.session.in_memory_conversation_store import InMemoryConversationStore
|
from app.infrastructure.session.in_memory_conversation_store import InMemoryConversationStore
|
||||||
from app.infrastructure.storage.json_document_processing_store import JsonDocumentProcessingStore
|
from app.infrastructure.storage.json_document_processing_store import JsonDocumentProcessingStore
|
||||||
from app.infrastructure.storage.json_document_repository import JsonDocumentRepository
|
from app.infrastructure.storage.json_document_repository import JsonDocumentRepository
|
||||||
@@ -31,6 +41,8 @@ from app.infrastructure.vectorstore.cross_encoder_reranker import OpenAICompatib
|
|||||||
from app.infrastructure.vectorstore.dense_retriever import DenseRetriever
|
from app.infrastructure.vectorstore.dense_retriever import DenseRetriever
|
||||||
from app.infrastructure.vectorstore.milvus_vector_index import MilvusVectorIndex
|
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.infrastructure.compliance.repository import PostgresComplianceRepository
|
||||||
# Keep shared wiring centralized so dependency construction remains consistent.
|
# Keep shared wiring centralized so dependency construction remains consistent.
|
||||||
|
|
||||||
|
|
||||||
@@ -252,7 +264,31 @@ def get_document_query_service() -> DocumentQueryService:
|
|||||||
|
|
||||||
@lru_cache
|
@lru_cache
|
||||||
def get_conversation_store() -> InMemoryConversationStore:
|
def get_conversation_store() -> InMemoryConversationStore:
|
||||||
"""Return conversation store."""
|
"""Return the active conversation store based on settings.
|
||||||
|
|
||||||
|
When session_backend='redis', sessions survive backend restarts and scale
|
||||||
|
across multiple API worker processes. When session_backend='memory' (default),
|
||||||
|
sessions are process-local and lost on restart.
|
||||||
|
"""
|
||||||
|
if settings.session_backend == "redis":
|
||||||
|
import redis as redis_lib
|
||||||
|
from app.infrastructure.session.redis_conversation_store import RedisConversationStore
|
||||||
|
|
||||||
|
# Build the Redis client from the same connection settings used by Celery.
|
||||||
|
kwargs: dict = {
|
||||||
|
"host": settings.redis_host,
|
||||||
|
"port": settings.redis_port,
|
||||||
|
"db": settings.redis_db,
|
||||||
|
"decode_responses": False,
|
||||||
|
}
|
||||||
|
if settings.redis_password:
|
||||||
|
kwargs["password"] = settings.redis_password
|
||||||
|
|
||||||
|
redis_client = redis_lib.Redis(**kwargs)
|
||||||
|
return RedisConversationStore( # type: ignore[return-value]
|
||||||
|
redis_client=redis_client,
|
||||||
|
timeout_seconds=settings.session_timeout_minutes * 60,
|
||||||
|
)
|
||||||
return InMemoryConversationStore(
|
return InMemoryConversationStore(
|
||||||
max_sessions=settings.session_max_sessions,
|
max_sessions=settings.session_max_sessions,
|
||||||
timeout_minutes=settings.session_timeout_minutes,
|
timeout_minutes=settings.session_timeout_minutes,
|
||||||
@@ -269,11 +305,57 @@ def get_agent_conversation_service() -> AgentConversationService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def get_event_store() -> BaseEventStore:
|
||||||
|
"""Return event store selected by DOCUMENT_REPOSITORY_BACKEND setting."""
|
||||||
|
if settings.document_repository_backend == "postgres":
|
||||||
|
from app.infrastructure.perception.postgres_event_store import PostgresEventStore
|
||||||
|
return PostgresEventStore()
|
||||||
|
return MockEventStore()
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def get_compliance_repository() -> ComplianceRepository:
|
||||||
|
"""Return the compliance analysis repository.
|
||||||
|
|
||||||
|
Requires document_repository_backend=postgres and valid postgres_* settings.
|
||||||
|
Raises NotImplementedError for any other backend value.
|
||||||
|
"""
|
||||||
|
if settings.document_repository_backend != "postgres":
|
||||||
|
raise NotImplementedError(
|
||||||
|
f"ComplianceRepository requires document_repository_backend=postgres, "
|
||||||
|
f"got '{settings.document_repository_backend}'. "
|
||||||
|
"Set DOCUMENT_REPOSITORY_BACKEND=postgres in your .env file."
|
||||||
|
)
|
||||||
|
return PostgresComplianceRepository(
|
||||||
|
host=settings.postgres_host,
|
||||||
|
port=settings.postgres_port,
|
||||||
|
user=settings.postgres_user,
|
||||||
|
password=settings.postgres_password,
|
||||||
|
dbname=settings.postgres_db,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@lru_cache
|
@lru_cache
|
||||||
def get_perception_service() -> PerceptionService:
|
def get_perception_service() -> PerceptionService:
|
||||||
"""Return perception service for regulatory intelligence."""
|
|
||||||
return PerceptionService(
|
return PerceptionService(
|
||||||
event_store=MockEventStore(),
|
event_store=get_event_store(),
|
||||||
|
retrieval_service=get_retrieval_service(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def get_crawl_service() -> CrawlService:
|
||||||
|
crawlers = {
|
||||||
|
"CATARC": CatarcCrawler(),
|
||||||
|
"国标委·强制性": GuobiaoMandatoryCrawler(),
|
||||||
|
"国标委·推荐性": GuobiaoRecommendedCrawler(),
|
||||||
|
"EUR-Lex": EurlexCrawler(),
|
||||||
|
}
|
||||||
|
return CrawlService(
|
||||||
|
crawlers=crawlers,
|
||||||
|
event_store=get_event_store(),
|
||||||
|
llm_pipeline=LlmPipeline(),
|
||||||
retrieval_service=get_retrieval_service(),
|
retrieval_service=get_retrieval_service(),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -284,6 +366,49 @@ 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
|
||||||
|
def get_celery_app():
|
||||||
|
"""Return the shared Celery application instance.
|
||||||
|
|
||||||
|
Imported lazily so Celery is not required when running without workers
|
||||||
|
(e.g., tests that mock bootstrap or dev without Redis).
|
||||||
|
"""
|
||||||
|
from app.infrastructure.tasks.celery_app import celery_app
|
||||||
|
return celery_app
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def get_jwt_handler():
|
||||||
|
"""Return the shared JWTHandler instance for token creation and validation."""
|
||||||
|
from app.infrastructure.auth.jwt_handler import JWTHandler
|
||||||
|
return JWTHandler(
|
||||||
|
secret_key=settings.auth_secret_key,
|
||||||
|
algorithm=settings.auth_algorithm,
|
||||||
|
expire_minutes=settings.auth_token_expire_minutes,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def get_user_store():
|
||||||
|
"""Return the PostgreSQL user store (lazy-connects on first call)."""
|
||||||
|
from app.infrastructure.auth.user_store import PostgresUserStore
|
||||||
|
return PostgresUserStore()
|
||||||
|
|
||||||
|
|
||||||
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"])
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
"""In-memory registry that tracks per-model call outcomes and token usage.
|
||||||
|
|
||||||
|
This module lives in `app/shared` — the same cross-cutting-support tier as
|
||||||
|
`bootstrap.py` — because it is not business logic: it exists purely so the
|
||||||
|
System Status page can show which AI models (main LLM, HyDE LLM, embedding,
|
||||||
|
reranker) are configured, whether their most recent call succeeded, and how
|
||||||
|
many tokens they have consumed since this process started. Tracking here
|
||||||
|
must never disrupt a real user-facing call: every public method swallows its
|
||||||
|
own exceptions and logs a warning instead of raising.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import threading
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from functools import lru_cache
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ModelUsageEntry:
|
||||||
|
"""Represent accumulated usage/connection state for one provider+model pair."""
|
||||||
|
|
||||||
|
provider: str
|
||||||
|
model: str
|
||||||
|
total_tokens: int = 0
|
||||||
|
prompt_tokens: int = 0
|
||||||
|
completion_tokens: int = 0
|
||||||
|
call_count_ok: int = 0
|
||||||
|
call_count_error: int = 0
|
||||||
|
last_called_at: datetime | None = None
|
||||||
|
last_latency_ms: int | None = None
|
||||||
|
last_error: str | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def status(self) -> str:
|
||||||
|
"""Derive never_called/ok/error from call history.
|
||||||
|
|
||||||
|
The "disabled" status (reranker only, when turned off in settings) is
|
||||||
|
NOT decided here: this dataclass has no access to live settings. The
|
||||||
|
API route layer (Task 6) applies that override on top of this value,
|
||||||
|
so config always wins over stale historical data.
|
||||||
|
"""
|
||||||
|
if self.last_called_at is None:
|
||||||
|
return "never_called"
|
||||||
|
return "error" if self.last_error else "ok"
|
||||||
|
|
||||||
|
|
||||||
|
class ModelUsageTracker:
|
||||||
|
"""Thread-safe in-memory registry of per-model call/usage stats.
|
||||||
|
|
||||||
|
Keyed by "{provider}:{model}" rather than by business role (main LLM /
|
||||||
|
HyDE / embedding / reranker) so that any future call site is captured
|
||||||
|
automatically, even before anyone teaches this class about its role.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
"""Initialize an empty registry guarded by a single lock."""
|
||||||
|
self._entries: dict[str, ModelUsageEntry] = {}
|
||||||
|
# One coarse lock is enough: record() runs at most a few times per
|
||||||
|
# request, and snapshot() is only read by the low-traffic status page.
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
|
def record(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
provider: str,
|
||||||
|
model: str,
|
||||||
|
success: bool,
|
||||||
|
usage: dict | None = None,
|
||||||
|
latency_ms: int | None = None,
|
||||||
|
error: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Record the outcome of one call to provider/model.
|
||||||
|
|
||||||
|
Never raises: any internal failure is logged and swallowed so a bug
|
||||||
|
in observability code cannot break a real LLM/embedding/reranker call.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
key = f"{provider}:{model}"
|
||||||
|
usage = usage if isinstance(usage, dict) else {}
|
||||||
|
with self._lock:
|
||||||
|
entry = self._entries.setdefault(key, ModelUsageEntry(provider=provider, model=model))
|
||||||
|
entry.total_tokens += int(usage.get("total_tokens", 0) or 0)
|
||||||
|
entry.prompt_tokens += int(usage.get("prompt_tokens", 0) or 0)
|
||||||
|
entry.completion_tokens += int(usage.get("completion_tokens", 0) or 0)
|
||||||
|
if success:
|
||||||
|
entry.call_count_ok += 1
|
||||||
|
entry.last_error = None
|
||||||
|
else:
|
||||||
|
entry.call_count_error += 1
|
||||||
|
entry.last_error = error or "unknown error"
|
||||||
|
entry.last_called_at = datetime.now(timezone.utc)
|
||||||
|
entry.last_latency_ms = latency_ms
|
||||||
|
except Exception as exc: # noqa: BLE001 - tracking must never break a real call
|
||||||
|
logger.warning("ModelUsageTracker.record failed for {}:{} - {}", provider, model, exc)
|
||||||
|
|
||||||
|
def snapshot(self) -> dict[str, ModelUsageEntry]:
|
||||||
|
"""Return a shallow copy of all tracked entries, safe to mutate by the caller."""
|
||||||
|
with self._lock:
|
||||||
|
return dict(self._entries)
|
||||||
|
|
||||||
|
def get(self, provider: str, model: str) -> ModelUsageEntry | None:
|
||||||
|
"""Return the entry for one provider/model pair, or None if never recorded."""
|
||||||
|
return self.snapshot().get(f"{provider}:{model}")
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def get_model_usage_tracker() -> ModelUsageTracker:
|
||||||
|
"""Return the process-wide singleton tracker (mirrors get_settings()/get_llm_factory())."""
|
||||||
|
return ModelUsageTracker()
|
||||||
@@ -1,30 +1,48 @@
|
|||||||
|
# ── Web framework ─────────────────────────────────────────────────────────────
|
||||||
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
|
||||||
|
|
||||||
|
# ── Config & utilities ────────────────────────────────────────────────────────
|
||||||
pydantic>=2.0.0
|
pydantic>=2.0.0
|
||||||
pydantic-settings>=2.0.0
|
pydantic-settings>=2.0.0
|
||||||
python-dotenv>=1.0.0
|
python-dotenv>=1.0.0
|
||||||
loguru>=0.7.0
|
loguru>=0.7.0
|
||||||
|
|
||||||
httpx>=0.25.0
|
httpx>=0.25.0
|
||||||
|
beautifulsoup4>=4.12.0
|
||||||
|
lxml>=5.0.0
|
||||||
tiktoken>=0.5.0
|
tiktoken>=0.5.0
|
||||||
tenacity>=8.2.0
|
tenacity>=8.2.0
|
||||||
|
|
||||||
|
# ── Auth ──────────────────────────────────────────────────────────────────────
|
||||||
|
python-jose[cryptography]>=3.3.0
|
||||||
|
# passlib is incompatible with bcrypt>=4.0 (removed __about__, strict 72-byte limit).
|
||||||
|
# Pin bcrypt to 3.x until passlib ships a fix.
|
||||||
|
passlib[bcrypt]>=1.7.4
|
||||||
|
bcrypt>=3.2.0,<4.0.0
|
||||||
|
|
||||||
|
# ── Async task queue ──────────────────────────────────────────────────────────
|
||||||
|
celery>=5.3.0
|
||||||
|
redis>=4.5.0
|
||||||
|
|
||||||
|
# ── Storage & databases ───────────────────────────────────────────────────────
|
||||||
pymilvus>=2.4.0
|
pymilvus>=2.4.0
|
||||||
minio>=7.1.0
|
minio>=7.1.0
|
||||||
psycopg2-binary>=2.9.0
|
psycopg2-binary>=2.9.0
|
||||||
|
|
||||||
|
# ── Document parsing ─────────────────────────────────────────────────────────
|
||||||
pymupdf>=1.24.0
|
pymupdf>=1.24.0
|
||||||
python-docx>=1.1.0
|
python-docx>=1.1.0
|
||||||
|
|
||||||
numpy>=1.24.0
|
|
||||||
alibabacloud-docmind-api20220711>=1.0.6
|
alibabacloud-docmind-api20220711>=1.0.6
|
||||||
alibabacloud-tea-openapi>=0.3.11
|
alibabacloud-tea-openapi>=0.3.11
|
||||||
alibabacloud-tea-util>=0.3.13
|
alibabacloud-tea-util>=0.3.13
|
||||||
|
|
||||||
|
# ── RAG / LangChain ───────────────────────────────────────────────────────────
|
||||||
langchain>=0.1.0
|
langchain>=0.1.0
|
||||||
langchain-milvus>=0.1.0
|
langchain-milvus>=0.1.0
|
||||||
|
numpy>=1.24.0
|
||||||
|
|
||||||
|
# ── Testing ───────────────────────────────────────────────────────────────────
|
||||||
pytest>=7.4.0
|
pytest>=7.4.0
|
||||||
pytest-asyncio>=0.21.0
|
pytest-asyncio>=0.21.0
|
||||||
|
fakeredis>=2.0.0
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import asyncio
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from app.infrastructure.vectorstore.pass_through_reranker import PassThroughReranker
|
||||||
|
from app.domain.retrieval.models import RetrievedChunk
|
||||||
|
from app.domain.compliance.ports import AnalysisRecord, FindingRecord
|
||||||
|
|
||||||
|
|
||||||
|
# ── helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _make_chunk(score: float) -> RetrievedChunk:
|
||||||
|
return RetrievedChunk(
|
||||||
|
chunk_id="c1",
|
||||||
|
doc_id="d1",
|
||||||
|
doc_title="Test Doc",
|
||||||
|
section_title="S1",
|
||||||
|
text="some text",
|
||||||
|
score=score,
|
||||||
|
page_start=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_mock_client(content: str = '{"status":"ok","title":"T","desc":"D","clause_ref":"A1"}'):
|
||||||
|
client = MagicMock()
|
||||||
|
response = MagicMock()
|
||||||
|
response.is_success = True
|
||||||
|
response.content = content
|
||||||
|
client.chat.return_value = response
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
def _make_mock_retrieval():
|
||||||
|
svc = MagicMock()
|
||||||
|
svc.retrieve.return_value = []
|
||||||
|
return svc
|
||||||
|
|
||||||
|
|
||||||
|
# ── existing tests ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_pass_through_returns_top_k():
|
||||||
|
reranker = PassThroughReranker()
|
||||||
|
chunks = [_make_chunk(0.9), _make_chunk(0.8), _make_chunk(0.7)]
|
||||||
|
result = reranker.rerank(query="test", chunks=chunks, top_k=2)
|
||||||
|
assert len(result) == 2
|
||||||
|
assert result[0].score == 0.9
|
||||||
|
|
||||||
|
|
||||||
|
def test_pass_through_returns_all_when_top_k_exceeds():
|
||||||
|
reranker = PassThroughReranker()
|
||||||
|
chunks = [_make_chunk(0.5)]
|
||||||
|
result = reranker.rerank(query="test", chunks=chunks, top_k=10)
|
||||||
|
assert len(result) == 1
|
||||||
|
|
||||||
|
|
||||||
|
# ── new tests ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_process_single_clause_returns_finding():
|
||||||
|
from app.application.compliance.pipeline import process_single_clause
|
||||||
|
client = _make_mock_client()
|
||||||
|
svc = _make_mock_retrieval()
|
||||||
|
result = process_single_clause("test clause", 0, svc, client)
|
||||||
|
assert result["finding"] is not None
|
||||||
|
assert result["index"] == 0
|
||||||
|
assert result["chunks"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_clauses_parallel_runs_all():
|
||||||
|
from app.application.compliance.pipeline import run_clauses_parallel
|
||||||
|
client = _make_mock_client()
|
||||||
|
svc = _make_mock_retrieval()
|
||||||
|
clauses = ["clause one", "clause two", "clause three"]
|
||||||
|
results = asyncio.run(run_clauses_parallel(clauses, svc, client))
|
||||||
|
assert len(results) == 3
|
||||||
|
assert all(r["index"] == i for i, r in enumerate(results))
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_clauses_parallel_handles_clause_failure():
|
||||||
|
from app.application.compliance.pipeline import run_clauses_parallel
|
||||||
|
svc = _make_mock_retrieval()
|
||||||
|
bad_client = MagicMock()
|
||||||
|
bad_client.chat.side_effect = RuntimeError("LLM exploded")
|
||||||
|
results = asyncio.run(run_clauses_parallel(
|
||||||
|
["clause one", "clause two"], svc, bad_client
|
||||||
|
))
|
||||||
|
assert len(results) == 2
|
||||||
|
assert all(r["finding"] is None for r in results)
|
||||||
|
assert all(r["chunks"] == [] for r in results)
|
||||||
|
|
||||||
|
|
||||||
|
# ── helpers for new tests ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _sample_analysis() -> AnalysisRecord:
|
||||||
|
return AnalysisRecord(
|
||||||
|
id="a1", created_at=datetime(2026, 6, 8), created_by="u",
|
||||||
|
doc_name="doc.pdf", standard_name="EU AI Act",
|
||||||
|
risk_score=72, conclusion="Gaps found.", actions=[], para_text="para",
|
||||||
|
highlight_terms=[], findings=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _sample_finding(status: str = "risk") -> FindingRecord:
|
||||||
|
return FindingRecord(
|
||||||
|
id="f1", analysis_id="a1", seq=0,
|
||||||
|
title="Missing CSMS", description="No CSMS certification.",
|
||||||
|
status=status, clause_ref="Art.9.1",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── new tests ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_build_finding_context_contains_required_fields():
|
||||||
|
from app.application.compliance.pipeline import build_finding_context
|
||||||
|
ctx = build_finding_context(_sample_finding(), _sample_analysis())
|
||||||
|
assert "doc.pdf" in ctx
|
||||||
|
assert "EU AI Act" in ctx
|
||||||
|
assert "Missing CSMS" in ctx
|
||||||
|
assert "Art.9.1" in ctx
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_suggestions_returns_three_questions():
|
||||||
|
from app.application.compliance.pipeline import generate_suggestions
|
||||||
|
client = _make_mock_client(
|
||||||
|
'{"questions": ["Q1?", "Q2?", "Q3?"]}'
|
||||||
|
)
|
||||||
|
questions = generate_suggestions(_sample_finding("risk"), _sample_analysis(), client)
|
||||||
|
assert len(questions) == 3
|
||||||
|
assert all(isinstance(q, str) for q in questions)
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_suggestions_falls_back_on_error():
|
||||||
|
from app.application.compliance.pipeline import generate_suggestions
|
||||||
|
bad_client = MagicMock()
|
||||||
|
bad_resp = MagicMock()
|
||||||
|
bad_resp.is_success = False
|
||||||
|
bad_client.chat.return_value = bad_resp
|
||||||
|
questions = generate_suggestions(_sample_finding(), _sample_analysis(), bad_client)
|
||||||
|
assert len(questions) == 3 # fallback always returns 3
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
from datetime import datetime
|
||||||
|
from app.domain.compliance.ports import (
|
||||||
|
AnalysisRecord,
|
||||||
|
FindingRecord,
|
||||||
|
ComplianceRepository,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _mock_pool():
|
||||||
|
"""Return a mock psycopg2 ThreadedConnectionPool."""
|
||||||
|
conn = MagicMock()
|
||||||
|
cursor = MagicMock()
|
||||||
|
cursor.__enter__ = MagicMock(return_value=cursor)
|
||||||
|
cursor.__exit__ = MagicMock(return_value=False)
|
||||||
|
conn.cursor.return_value = cursor
|
||||||
|
pool = MagicMock()
|
||||||
|
pool.getconn.return_value = conn
|
||||||
|
return pool, conn, cursor
|
||||||
|
|
||||||
|
|
||||||
|
@patch("app.infrastructure.compliance.repository.psycopg2.pool.ThreadedConnectionPool")
|
||||||
|
def test_save_analysis_returns_uuid(mock_pool_cls):
|
||||||
|
from app.infrastructure.compliance.repository import PostgresComplianceRepository
|
||||||
|
pool, conn, cursor = _mock_pool()
|
||||||
|
mock_pool_cls.return_value = pool
|
||||||
|
cursor.fetchone.return_value = {"id": "abc-123"}
|
||||||
|
|
||||||
|
repo = PostgresComplianceRepository(
|
||||||
|
host="localhost", port=5432, user="u", password="p", dbname="db"
|
||||||
|
)
|
||||||
|
record = AnalysisRecord(
|
||||||
|
id="", created_at=datetime.utcnow(), created_by="user1",
|
||||||
|
doc_name="doc.pdf", standard_name="EU AI Act",
|
||||||
|
risk_score=50, conclusion="OK", actions=[], para_text="p",
|
||||||
|
highlight_terms=[], findings=[],
|
||||||
|
)
|
||||||
|
result = repo.save_analysis(record)
|
||||||
|
assert result == "abc-123"
|
||||||
|
|
||||||
|
|
||||||
|
def test_analysis_record_construction():
|
||||||
|
record = AnalysisRecord(
|
||||||
|
id="",
|
||||||
|
created_at=datetime.utcnow(),
|
||||||
|
created_by="user1",
|
||||||
|
doc_name="test.pdf",
|
||||||
|
standard_name="EU AI Act",
|
||||||
|
risk_score=72,
|
||||||
|
conclusion="Several gaps found.",
|
||||||
|
actions=[{"label": "Fix", "value": "Update docs"}],
|
||||||
|
para_text="The system shall...",
|
||||||
|
highlight_terms=["CSMS", "ISO 21434"],
|
||||||
|
findings=[
|
||||||
|
FindingRecord(
|
||||||
|
id="",
|
||||||
|
analysis_id="",
|
||||||
|
seq=0,
|
||||||
|
title="Missing CSMS",
|
||||||
|
description="No CSMS certification found.",
|
||||||
|
status="risk",
|
||||||
|
clause_ref="Art.9.1",
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert record.doc_name == "test.pdf"
|
||||||
|
assert len(record.findings) == 1
|
||||||
|
assert record.findings[0].status == "risk"
|
||||||
|
|
||||||
|
|
||||||
|
def test_compliance_repository_is_abstract():
|
||||||
|
import inspect
|
||||||
|
assert inspect.isabstract(ComplianceRepository)
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_docx_returns_bytes():
|
||||||
|
from app.infrastructure.compliance.docx_export import generate_docx
|
||||||
|
record = AnalysisRecord(
|
||||||
|
id="test-id", created_at=datetime(2026, 6, 8), created_by="user1",
|
||||||
|
doc_name="test.pdf", standard_name="EU AI Act",
|
||||||
|
risk_score=72, conclusion="Several gaps found.",
|
||||||
|
actions=[{"label": "Fix", "value": "Update CSMS docs"}],
|
||||||
|
para_text="The system shall implement CSMS.",
|
||||||
|
highlight_terms=["CSMS"],
|
||||||
|
findings=[
|
||||||
|
FindingRecord(
|
||||||
|
id="f1", analysis_id="test-id", seq=0,
|
||||||
|
title="Missing CSMS", description="No CSMS cert.",
|
||||||
|
status="risk", clause_ref="Art.9.1",
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
data = generate_docx(record)
|
||||||
|
assert isinstance(data, bytes)
|
||||||
|
assert len(data) > 1000 # DOCX is at minimum a ZIP with ~1 KB overhead
|
||||||
|
# Verify it's a valid ZIP (DOCX = ZIP container)
|
||||||
|
import zipfile, io
|
||||||
|
assert zipfile.is_zipfile(io.BytesIO(data))
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
"""Verifies OpenAICompatibleEmbeddingProvider records usage into ModelUsageTracker."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.infrastructure.embedding.openai_compatible_embedding_provider import OpenAICompatibleEmbeddingProvider
|
||||||
|
from app.shared.model_usage_tracker import get_model_usage_tracker
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _reset_tracker():
|
||||||
|
"""Clear the process-wide tracker before and after each test in this file."""
|
||||||
|
get_model_usage_tracker()._entries.clear()
|
||||||
|
yield
|
||||||
|
get_model_usage_tracker()._entries.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def _fake_response(usage: dict) -> MagicMock:
|
||||||
|
"""Build a fake httpx.Response-like object for a successful embeddings call."""
|
||||||
|
resp = MagicMock(spec=httpx.Response)
|
||||||
|
resp.raise_for_status.return_value = None
|
||||||
|
resp.json.return_value = {
|
||||||
|
"data": [{"index": 0, "embedding": [0.1] * 1024}],
|
||||||
|
"usage": usage,
|
||||||
|
}
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
def test_successful_embed_records_usage():
|
||||||
|
"""A successful embeddings call must record token usage under 'embedding:<model>'."""
|
||||||
|
provider = OpenAICompatibleEmbeddingProvider()
|
||||||
|
provider.api_key = "test-key"
|
||||||
|
with patch("httpx.post", return_value=_fake_response({"prompt_tokens": 3, "total_tokens": 3})):
|
||||||
|
provider.embed_query("hello")
|
||||||
|
|
||||||
|
entry = get_model_usage_tracker().get("embedding", provider.model)
|
||||||
|
assert entry is not None
|
||||||
|
assert entry.total_tokens == 3
|
||||||
|
assert entry.status == "ok"
|
||||||
|
|
||||||
|
|
||||||
|
def test_failed_embed_records_error():
|
||||||
|
"""An HTTP error from the embeddings endpoint must be recorded as a failure, then re-raised."""
|
||||||
|
provider = OpenAICompatibleEmbeddingProvider()
|
||||||
|
provider.api_key = "test-key"
|
||||||
|
failing_response = MagicMock(spec=httpx.Response)
|
||||||
|
failing_response.status_code = 500
|
||||||
|
failing_response.text = "boom"
|
||||||
|
failing_response.request = MagicMock()
|
||||||
|
failing_response.request.url = "http://example.com/embeddings"
|
||||||
|
failing_response.raise_for_status.side_effect = httpx.HTTPStatusError(
|
||||||
|
"boom", request=failing_response.request, response=failing_response
|
||||||
|
)
|
||||||
|
with patch("httpx.post", return_value=failing_response):
|
||||||
|
with pytest.raises(httpx.HTTPStatusError):
|
||||||
|
provider.embed_query("hello")
|
||||||
|
|
||||||
|
entry = get_model_usage_tracker().get("embedding", provider.model)
|
||||||
|
assert entry is not None
|
||||||
|
assert entry.status == "error"
|
||||||
|
assert entry.call_count_error == 1
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"""Verifies get_llm_client() returns a usage-tracked client end to end."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.services.llm.llm_factory import LLMFactory, get_llm_client
|
||||||
|
from app.services.llm.tracked_client import TrackedLLMClient
|
||||||
|
from app.shared.model_usage_tracker import get_model_usage_tracker
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _reset_singletons():
|
||||||
|
"""Clear the two process-wide singletons this test touches, before and after.
|
||||||
|
|
||||||
|
LLMFactory._global_instances and get_model_usage_tracker() both persist
|
||||||
|
for the life of the process; without this fixture, tests would leak
|
||||||
|
cached clients/usage data into each other and become order-dependent.
|
||||||
|
"""
|
||||||
|
LLMFactory._global_instances.clear()
|
||||||
|
get_model_usage_tracker().snapshot() # no-op read, just documents intent
|
||||||
|
get_model_usage_tracker()._entries.clear()
|
||||||
|
yield
|
||||||
|
LLMFactory._global_instances.clear()
|
||||||
|
get_model_usage_tracker()._entries.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_llm_client_returns_tracked_client():
|
||||||
|
"""get_llm_client() must return a TrackedLLMClient, not the raw provider client."""
|
||||||
|
with patch("app.services.llm.llm_factory.DeepSeekClient") as mock_cls:
|
||||||
|
mock_cls.return_value = MagicMock()
|
||||||
|
client = get_llm_client(provider="deepseek", model="deepseek-v4-flash", api_key="test-key")
|
||||||
|
assert isinstance(client, TrackedLLMClient)
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_llm_client_caches_the_tracked_instance():
|
||||||
|
"""A second call with the same provider/model must return the same TrackedLLMClient."""
|
||||||
|
with patch("app.services.llm.llm_factory.DeepSeekClient") as mock_cls:
|
||||||
|
mock_cls.return_value = MagicMock()
|
||||||
|
first = get_llm_client(provider="deepseek", model="deepseek-v4-flash", api_key="test-key")
|
||||||
|
second = get_llm_client(provider="deepseek", model="deepseek-v4-flash", api_key="test-key")
|
||||||
|
assert first is second
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"""Unit tests for ModelUsageTracker — no mocking needed, pure in-memory state."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.shared.model_usage_tracker import ModelUsageEntry, ModelUsageTracker, get_model_usage_tracker
|
||||||
|
|
||||||
|
|
||||||
|
def test_never_called_model_has_no_entry():
|
||||||
|
"""A tracker that has never recorded a call returns None from get()."""
|
||||||
|
tracker = ModelUsageTracker()
|
||||||
|
assert tracker.get("deepseek", "deepseek-v4-flash") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_record_success_accumulates_tokens_and_calls():
|
||||||
|
"""Two successful calls accumulate tokens and call_count_ok."""
|
||||||
|
tracker = ModelUsageTracker()
|
||||||
|
tracker.record(
|
||||||
|
provider="deepseek", model="deepseek-v4-flash", success=True,
|
||||||
|
usage={"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}, latency_ms=100,
|
||||||
|
)
|
||||||
|
tracker.record(
|
||||||
|
provider="deepseek", model="deepseek-v4-flash", success=True,
|
||||||
|
usage={"prompt_tokens": 20, "completion_tokens": 8, "total_tokens": 28}, latency_ms=200,
|
||||||
|
)
|
||||||
|
entry = tracker.get("deepseek", "deepseek-v4-flash")
|
||||||
|
assert entry is not None
|
||||||
|
assert entry.total_tokens == 43
|
||||||
|
assert entry.prompt_tokens == 30
|
||||||
|
assert entry.completion_tokens == 13
|
||||||
|
assert entry.call_count_ok == 2
|
||||||
|
assert entry.call_count_error == 0
|
||||||
|
assert entry.status == "ok"
|
||||||
|
assert entry.last_latency_ms == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_record_error_sets_error_status_without_losing_prior_tokens():
|
||||||
|
"""A failed call after successful ones flips status to 'error' but keeps accumulated tokens."""
|
||||||
|
tracker = ModelUsageTracker()
|
||||||
|
tracker.record(provider="qwen", model="qwen3.5-flash", success=True, usage={"total_tokens": 50}, latency_ms=50)
|
||||||
|
tracker.record(provider="qwen", model="qwen3.5-flash", success=False, error="HTTP 500", latency_ms=30)
|
||||||
|
entry = tracker.get("qwen", "qwen3.5-flash")
|
||||||
|
assert entry.total_tokens == 50
|
||||||
|
assert entry.call_count_ok == 1
|
||||||
|
assert entry.call_count_error == 1
|
||||||
|
assert entry.status == "error"
|
||||||
|
assert entry.last_error == "HTTP 500"
|
||||||
|
|
||||||
|
|
||||||
|
def test_record_success_after_error_clears_last_error():
|
||||||
|
"""A later successful call clears last_error and status returns to 'ok'."""
|
||||||
|
tracker = ModelUsageTracker()
|
||||||
|
tracker.record(provider="qwen", model="qwen3.5-flash", success=False, error="timeout", latency_ms=30)
|
||||||
|
tracker.record(provider="qwen", model="qwen3.5-flash", success=True, usage={"total_tokens": 5}, latency_ms=40)
|
||||||
|
entry = tracker.get("qwen", "qwen3.5-flash")
|
||||||
|
assert entry.status == "ok"
|
||||||
|
assert entry.last_error is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_record_never_raises_on_bad_usage_dict():
|
||||||
|
"""A malformed usage value (wrong type) is swallowed, not raised, and does not corrupt other entries."""
|
||||||
|
tracker = ModelUsageTracker()
|
||||||
|
tracker.record(provider="embedding", model="text-embedding-v3", success=True, usage="not-a-dict", latency_ms=10) # type: ignore[arg-type]
|
||||||
|
# Must not raise, and must not have created a corrupted entry that breaks snapshot().
|
||||||
|
snapshot = tracker.snapshot()
|
||||||
|
assert isinstance(snapshot, dict)
|
||||||
|
|
||||||
|
|
||||||
|
def test_snapshot_returns_independent_copy():
|
||||||
|
"""snapshot() returns a dict that can be safely mutated without affecting the tracker."""
|
||||||
|
tracker = ModelUsageTracker()
|
||||||
|
tracker.record(provider="deepseek", model="deepseek-v4-flash", success=True, usage={"total_tokens": 1}, latency_ms=1)
|
||||||
|
snap = tracker.snapshot()
|
||||||
|
snap.clear()
|
||||||
|
assert tracker.get("deepseek", "deepseek-v4-flash") is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_model_usage_tracker_returns_singleton():
|
||||||
|
"""get_model_usage_tracker() always returns the same process-wide instance."""
|
||||||
|
assert get_model_usage_tracker() is get_model_usage_tracker()
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"""Verifies OpenAICompatibleReranker records call outcome (no tokens) into ModelUsageTracker."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.domain.retrieval import RetrievedChunk
|
||||||
|
from app.infrastructure.vectorstore.cross_encoder_reranker import OpenAICompatibleReranker
|
||||||
|
from app.shared.model_usage_tracker import get_model_usage_tracker
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _reset_tracker():
|
||||||
|
"""Clear the process-wide tracker before and after each test in this file."""
|
||||||
|
get_model_usage_tracker()._entries.clear()
|
||||||
|
yield
|
||||||
|
get_model_usage_tracker()._entries.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def _chunk(chunk_id: str, text: str) -> RetrievedChunk:
|
||||||
|
"""Build a minimal RetrievedChunk for reranker tests."""
|
||||||
|
return RetrievedChunk(chunk_id=chunk_id, doc_id="doc-1", doc_title="Doc", text=text, score=0.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_successful_rerank_records_call_without_tokens():
|
||||||
|
"""A successful rerank() call is recorded with call_count_ok but zero tokens."""
|
||||||
|
reranker = OpenAICompatibleReranker(base_url="http://example.test", model="bge-reranker-v2-m3")
|
||||||
|
with patch.object(reranker, "_call_reranker", return_value=[0.9, 0.1]):
|
||||||
|
result = reranker.rerank("query", [_chunk("c1", "a"), _chunk("c2", "b")], top_k=2)
|
||||||
|
|
||||||
|
assert len(result) == 2
|
||||||
|
entry = get_model_usage_tracker().get("reranker", "bge-reranker-v2-m3")
|
||||||
|
assert entry is not None
|
||||||
|
assert entry.call_count_ok == 1
|
||||||
|
assert entry.total_tokens == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_failed_rerank_records_error_and_falls_back():
|
||||||
|
"""A rerank() call that raises internally is recorded as an error but still returns a fallback list."""
|
||||||
|
reranker = OpenAICompatibleReranker(base_url="http://example.test", model="bge-reranker-v2-m3")
|
||||||
|
with patch.object(reranker, "_call_reranker", side_effect=RuntimeError("gateway down")):
|
||||||
|
result = reranker.rerank("query", [_chunk("c1", "a")], top_k=1)
|
||||||
|
|
||||||
|
assert len(result) == 1 # existing fallback behavior: original order, unscored
|
||||||
|
entry = get_model_usage_tracker().get("reranker", "bge-reranker-v2-m3")
|
||||||
|
assert entry is not None
|
||||||
|
assert entry.call_count_error == 1
|
||||||
|
assert entry.status == "error"
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
"""Unit tests for TrackedLLMClient — verifies transparent delegation + recording."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
from app.services.llm.base_client import LLMConfig, LLMProvider, LLMResponse
|
||||||
|
from app.services.llm.tracked_client import TrackedLLMClient
|
||||||
|
from app.shared.model_usage_tracker import ModelUsageTracker
|
||||||
|
|
||||||
|
|
||||||
|
def _make_inner(model: str = "deepseek-v4-flash") -> MagicMock:
|
||||||
|
"""Build a MagicMock standing in for a concrete BaseLLMClient subclass."""
|
||||||
|
# Use MagicMock to avoid requiring a real LLM provider implementation (e.g., DeepseekClient);
|
||||||
|
# tests focus on TrackedLLMClient's delegation and recording behavior, not provider logic.
|
||||||
|
inner = MagicMock()
|
||||||
|
inner.config = LLMConfig(
|
||||||
|
provider=LLMProvider.DEEPSEEK, model=model, api_key="test-key", base_url="http://example.test/v1",
|
||||||
|
)
|
||||||
|
return inner
|
||||||
|
|
||||||
|
|
||||||
|
def test_chat_delegates_and_returns_unchanged_response():
|
||||||
|
"""chat() must return exactly what the wrapped client returned."""
|
||||||
|
inner = _make_inner()
|
||||||
|
expected = LLMResponse(content="hello", model="deepseek-v4-flash", usage={"total_tokens": 12})
|
||||||
|
inner.chat.return_value = expected
|
||||||
|
tracker = ModelUsageTracker()
|
||||||
|
|
||||||
|
tracked = TrackedLLMClient(inner, tracker)
|
||||||
|
result = tracked.chat([{"role": "user", "content": "hi"}])
|
||||||
|
|
||||||
|
assert result is expected
|
||||||
|
inner.chat.assert_called_once_with([{"role": "user", "content": "hi"}], None, None, None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_chat_records_success_and_tokens():
|
||||||
|
"""A successful chat() call must be recorded under 'deepseek:deepseek-v4-flash'."""
|
||||||
|
inner = _make_inner()
|
||||||
|
inner.chat.return_value = LLMResponse(content="hi", model="deepseek-v4-flash", usage={"total_tokens": 42})
|
||||||
|
tracker = ModelUsageTracker()
|
||||||
|
|
||||||
|
TrackedLLMClient(inner, tracker).chat([{"role": "user", "content": "hi"}])
|
||||||
|
|
||||||
|
entry = tracker.get("deepseek", "deepseek-v4-flash")
|
||||||
|
assert entry is not None
|
||||||
|
assert entry.total_tokens == 42
|
||||||
|
assert entry.status == "ok"
|
||||||
|
|
||||||
|
|
||||||
|
def test_chat_records_error_from_response():
|
||||||
|
"""A chat() call that returns an error-carrying LLMResponse is recorded as a failure."""
|
||||||
|
inner = _make_inner()
|
||||||
|
inner.chat.return_value = LLMResponse(content="", model="deepseek-v4-flash", error="API error: 500")
|
||||||
|
tracker = ModelUsageTracker()
|
||||||
|
|
||||||
|
TrackedLLMClient(inner, tracker).chat([{"role": "user", "content": "hi"}])
|
||||||
|
|
||||||
|
entry = tracker.get("deepseek", "deepseek-v4-flash")
|
||||||
|
assert entry.status == "error"
|
||||||
|
assert entry.last_error == "API error: 500"
|
||||||
|
|
||||||
|
|
||||||
|
def test_getattr_forwards_to_inner_client():
|
||||||
|
"""Attributes not defined on TrackedLLMClient must forward to the wrapped client."""
|
||||||
|
inner = _make_inner()
|
||||||
|
inner.get_available_models.return_value = ["deepseek-v4-flash"]
|
||||||
|
tracked = TrackedLLMClient(inner, ModelUsageTracker())
|
||||||
|
|
||||||
|
assert tracked.get_available_models() == ["deepseek-v4-flash"]
|
||||||
|
assert tracked.config is inner.config
|
||||||
|
|
||||||
|
|
||||||
|
def test_stream_chat_records_call_without_token_usage():
|
||||||
|
"""stream_chat() must record a call (latency/success) but not fabricate token counts."""
|
||||||
|
inner = _make_inner()
|
||||||
|
inner.stream_chat.return_value = iter(["chunk-1", "chunk-2"])
|
||||||
|
tracker = ModelUsageTracker()
|
||||||
|
|
||||||
|
chunks = list(TrackedLLMClient(inner, tracker).stream_chat([{"role": "user", "content": "hi"}]))
|
||||||
|
|
||||||
|
assert chunks == ["chunk-1", "chunk-2"]
|
||||||
|
entry = tracker.get("deepseek", "deepseek-v4-flash")
|
||||||
|
assert entry.call_count_ok == 1
|
||||||
|
assert entry.total_tokens == 0
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
"""Contract tests: any BaseEventStore implementation must pass these."""
|
||||||
|
from app.infrastructure.perception.base_event_store import BaseEventStore
|
||||||
|
from app.infrastructure.perception.mock_event_store import MockEventStore
|
||||||
|
|
||||||
|
|
||||||
|
def _store() -> BaseEventStore:
|
||||||
|
return MockEventStore()
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_base_event_store():
|
||||||
|
assert isinstance(_store(), BaseEventStore)
|
||||||
|
|
||||||
|
|
||||||
|
def test_all_returns_list():
|
||||||
|
result = _store().all()
|
||||||
|
assert isinstance(result, list)
|
||||||
|
assert len(result) > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_known_id():
|
||||||
|
store = _store()
|
||||||
|
first = store.all()[0]
|
||||||
|
result = store.get(first["id"])
|
||||||
|
assert result is not None
|
||||||
|
assert result["id"] == first["id"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_unknown_returns_none():
|
||||||
|
assert _store().get("does-not-exist") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_filter_by_impact():
|
||||||
|
store = _store()
|
||||||
|
highs = store.filter(impact_level="high", limit=100)
|
||||||
|
assert all(e["impact_level"] == "high" for e in highs)
|
||||||
|
|
||||||
|
|
||||||
|
def test_filter_limit():
|
||||||
|
store = _store()
|
||||||
|
result = store.filter(limit=3)
|
||||||
|
assert len(result) <= 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_stats_keys():
|
||||||
|
stats = _store().stats()
|
||||||
|
for key in ("total", "high_impact", "medium_impact", "recent_90d"):
|
||||||
|
assert key in stats, f"missing key: {key}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_upsert_and_get():
|
||||||
|
store = _store()
|
||||||
|
event = {
|
||||||
|
"id": "test-upsert-001",
|
||||||
|
"source": "TEST",
|
||||||
|
"source_label": "Test Source",
|
||||||
|
"standard_code": "TST-001",
|
||||||
|
"title": "Test Event",
|
||||||
|
"summary": "A test event",
|
||||||
|
"full_text_url": "https://example.com",
|
||||||
|
"status": "draft",
|
||||||
|
"impact_level": "low",
|
||||||
|
"published_at": "2026-01-01",
|
||||||
|
"effective_at": None,
|
||||||
|
"category": "test",
|
||||||
|
"tags": ["test"],
|
||||||
|
"content_hash": "abc123",
|
||||||
|
"previous_hash": None,
|
||||||
|
}
|
||||||
|
store.upsert(event)
|
||||||
|
result = store.get("test-upsert-001")
|
||||||
|
assert result is not None
|
||||||
|
assert result["title"] == "Test Event"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_by_standard_code():
|
||||||
|
store = _store()
|
||||||
|
first = store.all()[0]
|
||||||
|
result = store.get_by_standard_code(first["standard_code"])
|
||||||
|
assert result is not None
|
||||||
|
assert result["standard_code"] == first["standard_code"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_upsert_updates_existing():
|
||||||
|
store = _store()
|
||||||
|
first = store.all()[0]
|
||||||
|
original_id = first["id"]
|
||||||
|
store.upsert({"id": original_id, "title": "Updated Title", "impact_level": first["impact_level"],
|
||||||
|
"standard_code": first.get("standard_code", ""), "source": first["source"],
|
||||||
|
"source_label": first.get("source_label", ""), "summary": "Updated",
|
||||||
|
"full_text_url": "", "status": first["status"], "published_at": first.get("published_at", ""),
|
||||||
|
"effective_at": None, "category": first.get("category", ""), "tags": [],
|
||||||
|
"content_hash": "newhash", "previous_hash": None})
|
||||||
|
result = store.get(original_id)
|
||||||
|
assert result is not None
|
||||||
|
assert result["title"] == "Updated Title"
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
"""Integration tests for CrawlService."""
|
||||||
|
from __future__ import annotations
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
import hashlib
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.infrastructure.perception.crawlers.base import RawEvent
|
||||||
|
from app.infrastructure.perception.mock_event_store import MockEventStore
|
||||||
|
|
||||||
|
|
||||||
|
def _make_raw_event(code="TST-001"):
|
||||||
|
return RawEvent(
|
||||||
|
source="TEST", source_label="Test", standard_code=code,
|
||||||
|
title=f"Test {code}", summary="Summary", full_text_url="https://example.com",
|
||||||
|
status="enacted", published_at="2026-01-01", effective_at=None,
|
||||||
|
category="test", tags=["test"], raw_text="full text",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_service(raw_events):
|
||||||
|
from app.application.perception.crawl_service import CrawlService
|
||||||
|
|
||||||
|
mock_crawler = MagicMock()
|
||||||
|
mock_crawler.fetch.return_value = raw_events
|
||||||
|
|
||||||
|
mock_pipeline = MagicMock()
|
||||||
|
mock_pipeline.extract_structure.return_value = {
|
||||||
|
"obligations": [], "deadlines": [], "scope": "test",
|
||||||
|
"penalties": None, "impact_level": "low",
|
||||||
|
}
|
||||||
|
mock_pipeline.assess_impact.return_value = []
|
||||||
|
mock_pipeline.compute_diff.return_value = {
|
||||||
|
"changed_sections": [], "change_summary": "No changes.",
|
||||||
|
}
|
||||||
|
|
||||||
|
mock_retrieval = MagicMock()
|
||||||
|
store = MockEventStore()
|
||||||
|
|
||||||
|
return CrawlService(
|
||||||
|
crawlers={"TEST": mock_crawler},
|
||||||
|
event_store=store,
|
||||||
|
llm_pipeline=mock_pipeline,
|
||||||
|
retrieval_service=mock_retrieval,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_crawl_yields_progress_and_done():
|
||||||
|
svc = _make_service([_make_raw_event("TST-001")])
|
||||||
|
events = list(svc.run_crawl())
|
||||||
|
event_types = [e.get("event") for e in events]
|
||||||
|
assert "done" in event_types
|
||||||
|
|
||||||
|
|
||||||
|
def test_crawl_upserts_to_store():
|
||||||
|
store = MockEventStore()
|
||||||
|
from app.application.perception.crawl_service import CrawlService
|
||||||
|
mock_crawler = MagicMock()
|
||||||
|
mock_crawler.fetch.return_value = [_make_raw_event("NEW-001")]
|
||||||
|
mock_pipeline = MagicMock()
|
||||||
|
mock_pipeline.extract_structure.return_value = {
|
||||||
|
"obligations": [], "deadlines": [], "scope": "",
|
||||||
|
"penalties": None, "impact_level": "medium",
|
||||||
|
}
|
||||||
|
mock_pipeline.assess_impact.return_value = []
|
||||||
|
mock_pipeline.compute_diff.return_value = {
|
||||||
|
"changed_sections": [], "change_summary": "",
|
||||||
|
}
|
||||||
|
svc = CrawlService(
|
||||||
|
crawlers={"TEST": mock_crawler},
|
||||||
|
event_store=store,
|
||||||
|
llm_pipeline=mock_pipeline,
|
||||||
|
retrieval_service=MagicMock(),
|
||||||
|
)
|
||||||
|
list(svc.run_crawl())
|
||||||
|
result = store.get_by_standard_code("NEW-001")
|
||||||
|
assert result is not None
|
||||||
|
assert result["title"] == "Test NEW-001"
|
||||||
|
|
||||||
|
|
||||||
|
def test_crawl_skips_unchanged_events():
|
||||||
|
store = MockEventStore()
|
||||||
|
raw = _make_raw_event("SKIP-001")
|
||||||
|
content_hash = hashlib.sha256(raw.raw_text.encode()).hexdigest()
|
||||||
|
store.upsert({
|
||||||
|
"id": hashlib.sha256(f"TEST-SKIP-001".encode()).hexdigest()[:12],
|
||||||
|
"standard_code": "SKIP-001",
|
||||||
|
"source": "TEST",
|
||||||
|
"source_label": "Test",
|
||||||
|
"title": "Test SKIP-001",
|
||||||
|
"summary": "",
|
||||||
|
"full_text_url": "",
|
||||||
|
"status": "enacted",
|
||||||
|
"impact_level": "low",
|
||||||
|
"published_at": "2026-01-01",
|
||||||
|
"effective_at": None,
|
||||||
|
"category": "test",
|
||||||
|
"tags": [],
|
||||||
|
"content_hash": content_hash,
|
||||||
|
})
|
||||||
|
mock_pipeline = MagicMock()
|
||||||
|
from app.application.perception.crawl_service import CrawlService
|
||||||
|
mock_crawler = MagicMock()
|
||||||
|
mock_crawler.fetch.return_value = [raw]
|
||||||
|
svc = CrawlService(
|
||||||
|
crawlers={"TEST": mock_crawler},
|
||||||
|
event_store=store,
|
||||||
|
llm_pipeline=mock_pipeline,
|
||||||
|
retrieval_service=MagicMock(),
|
||||||
|
)
|
||||||
|
list(svc.run_crawl())
|
||||||
|
mock_pipeline.extract_structure.assert_not_called()
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
"""Unit tests for crawlers — mock httpx responses."""
|
||||||
|
from __future__ import annotations
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.infrastructure.perception.crawlers.base import RawEvent, BaseCrawler
|
||||||
|
|
||||||
|
|
||||||
|
def test_raw_event_fields():
|
||||||
|
ev = RawEvent(
|
||||||
|
source="TEST",
|
||||||
|
source_label="Test",
|
||||||
|
standard_code="TST-001",
|
||||||
|
title="Test",
|
||||||
|
summary="Summary",
|
||||||
|
full_text_url="https://example.com",
|
||||||
|
status="enacted",
|
||||||
|
published_at="2026-01-01",
|
||||||
|
effective_at=None,
|
||||||
|
category="test",
|
||||||
|
tags=["a"],
|
||||||
|
raw_text="full text here",
|
||||||
|
)
|
||||||
|
assert ev.source == "TEST"
|
||||||
|
assert ev.tags == ["a"]
|
||||||
|
|
||||||
|
|
||||||
|
CATARC_HTML = """
|
||||||
|
<html><body>
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<td><a href="/std/detail/123">GB 18384-2025</a></td>
|
||||||
|
<td>电动汽车安全要求</td>
|
||||||
|
<td>2025-11-15</td>
|
||||||
|
<td>现行</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><a href="/std/detail/456">GB/T 40429-2026</a></td>
|
||||||
|
<td>汽车驾驶自动化分级</td>
|
||||||
|
<td>2026-02-01</td>
|
||||||
|
<td>即将实施</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body></html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def test_catarc_crawler_parses_html():
|
||||||
|
from app.infrastructure.perception.crawlers.catarc_crawler import CatarcCrawler
|
||||||
|
|
||||||
|
mock_resp = MagicMock()
|
||||||
|
mock_resp.status_code = 200
|
||||||
|
mock_resp.text = CATARC_HTML
|
||||||
|
mock_resp.raise_for_status = MagicMock()
|
||||||
|
|
||||||
|
with patch("httpx.get", return_value=mock_resp):
|
||||||
|
crawler = CatarcCrawler()
|
||||||
|
events = crawler.fetch(limit=10)
|
||||||
|
|
||||||
|
assert isinstance(events, list)
|
||||||
|
assert len(events) >= 1
|
||||||
|
assert all(isinstance(e, RawEvent) for e in events)
|
||||||
|
codes = [e.standard_code for e in events]
|
||||||
|
assert "GB 18384-2025" in codes
|
||||||
|
|
||||||
|
|
||||||
|
GUOBIAO_JSON = {
|
||||||
|
"rows": [
|
||||||
|
{
|
||||||
|
"std_code": "GB 18384-2025",
|
||||||
|
"std_name": "电动汽车安全要求",
|
||||||
|
"release_date": "2025-11-15",
|
||||||
|
"implement_date": "2026-07-01",
|
||||||
|
"std_status": "现行",
|
||||||
|
"std_type": "强制性",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_guobiao_crawler_parses_json():
|
||||||
|
from app.infrastructure.perception.crawlers.guobiao_crawler import GuobiaoMandatoryCrawler
|
||||||
|
|
||||||
|
mock_resp = MagicMock()
|
||||||
|
mock_resp.status_code = 200
|
||||||
|
mock_resp.json.return_value = GUOBIAO_JSON
|
||||||
|
mock_resp.raise_for_status = MagicMock()
|
||||||
|
|
||||||
|
with patch("httpx.get", return_value=mock_resp):
|
||||||
|
crawler = GuobiaoMandatoryCrawler()
|
||||||
|
events = crawler.fetch(limit=10)
|
||||||
|
|
||||||
|
assert len(events) >= 1
|
||||||
|
assert events[0].source == "国标委"
|
||||||
|
assert events[0].standard_code == "GB 18384-2025"
|
||||||
|
|
||||||
|
|
||||||
|
EURLEX_RSS = """<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<rss version="2.0">
|
||||||
|
<channel>
|
||||||
|
<title>EUR-Lex</title>
|
||||||
|
<item>
|
||||||
|
<title>Regulation (EU) 2024/1689 — AI Act</title>
|
||||||
|
<link>https://eur-lex.europa.eu/legal-content/EN/TXT/?uri=CELEX:32024R1689</link>
|
||||||
|
<description>The EU Artificial Intelligence Act enters into force.</description>
|
||||||
|
<pubDate>Fri, 12 Jul 2024 00:00:00 GMT</pubDate>
|
||||||
|
</item>
|
||||||
|
</channel>
|
||||||
|
</rss>"""
|
||||||
|
|
||||||
|
|
||||||
|
def test_eurlex_crawler_parses_rss():
|
||||||
|
from app.infrastructure.perception.crawlers.eurlex_crawler import EurlexCrawler
|
||||||
|
|
||||||
|
mock_resp = MagicMock()
|
||||||
|
mock_resp.status_code = 200
|
||||||
|
mock_resp.text = EURLEX_RSS
|
||||||
|
mock_resp.content = EURLEX_RSS
|
||||||
|
mock_resp.raise_for_status = MagicMock()
|
||||||
|
|
||||||
|
with patch("httpx.get", return_value=mock_resp):
|
||||||
|
crawler = EurlexCrawler()
|
||||||
|
events = crawler.fetch(limit=5)
|
||||||
|
|
||||||
|
assert isinstance(events, list)
|
||||||
|
assert len(events) >= 1
|
||||||
|
assert events[0].source == "EUR-Lex"
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
"""Unit tests for LlmPipeline — mock LLM client and embedding provider."""
|
||||||
|
from __future__ import annotations
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
import json
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
def _make_pipeline():
|
||||||
|
with patch("app.infrastructure.perception.llm_pipeline.get_llm_client") as mock_llm_fn, \
|
||||||
|
patch("app.infrastructure.perception.llm_pipeline.OpenAICompatibleEmbeddingProvider") as mock_emb_cls:
|
||||||
|
|
||||||
|
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_llm_fn.return_value = mock_client
|
||||||
|
|
||||||
|
mock_emb = MagicMock()
|
||||||
|
mock_emb.embed_texts.return_value = [[0.1] * 1024, [0.9] * 1024]
|
||||||
|
mock_emb_cls.return_value = mock_emb
|
||||||
|
|
||||||
|
from app.infrastructure.perception.llm_pipeline import LlmPipeline
|
||||||
|
return LlmPipeline(), mock_client, mock_emb
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_structure_returns_dict():
|
||||||
|
pipeline, mock_client, _ = _make_pipeline()
|
||||||
|
event = {
|
||||||
|
"id": "evt-001",
|
||||||
|
"standard_code": "GB 18384-2025",
|
||||||
|
"title": "电动汽车安全要求",
|
||||||
|
"summary": "新增 IP67 级别防护",
|
||||||
|
"source_label": "CATARC",
|
||||||
|
"tags": ["电池安全"],
|
||||||
|
}
|
||||||
|
result = pipeline.extract_structure(event)
|
||||||
|
assert isinstance(result, dict)
|
||||||
|
assert "obligations" in result
|
||||||
|
assert "impact_level" in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_assess_impact_returns_list():
|
||||||
|
pipeline, mock_client, _ = _make_pipeline()
|
||||||
|
mock_client.chat.return_value = MagicMock(content='[{"doc_id":"d1","doc_name":"Safety Manual","score":0.85,"key_clauses":"§4.2","recommendation":"更新第4章"}]')
|
||||||
|
mock_retrieval = MagicMock()
|
||||||
|
chunk = MagicMock()
|
||||||
|
chunk.doc_id = "d1"
|
||||||
|
chunk.doc_title = "Safety Manual"
|
||||||
|
chunk.score = 0.85
|
||||||
|
chunk.text = "relevant text"
|
||||||
|
chunk.section_title = "§4.2"
|
||||||
|
mock_retrieval.retrieve.return_value = [chunk]
|
||||||
|
event = {
|
||||||
|
"standard_code": "GB 18384-2025",
|
||||||
|
"title": "电动汽车安全要求",
|
||||||
|
"obligations": [{"text": "OEM shall comply"}],
|
||||||
|
}
|
||||||
|
result = pipeline.assess_impact(event, mock_retrieval)
|
||||||
|
assert isinstance(result, list)
|
||||||
|
|
||||||
|
|
||||||
|
def test_compute_diff_no_change():
|
||||||
|
pipeline, _, mock_emb = _make_pipeline()
|
||||||
|
mock_emb.embed_texts.return_value = [[0.5] * 1024, [0.5] * 1024]
|
||||||
|
result = pipeline.compute_diff("paragraph one", "paragraph one")
|
||||||
|
assert isinstance(result, dict)
|
||||||
|
assert "changed_sections" in result
|
||||||
|
assert "change_summary" in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_compute_diff_detects_change():
|
||||||
|
pipeline, mock_client, mock_emb = _make_pipeline()
|
||||||
|
mock_emb.embed_texts.return_value = [
|
||||||
|
[1.0] + [0.0] * 1023,
|
||||||
|
[0.0] + [1.0] + [0.0] * 1022,
|
||||||
|
]
|
||||||
|
mock_client.chat.return_value = MagicMock(content='{"change_type":"tightened","summary":"Requirement tightened"}')
|
||||||
|
result = pipeline.compute_diff("old paragraph text", "new tighter requirement text")
|
||||||
|
assert isinstance(result["changed_sections"], list)
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
"""Unit tests for PostgresEventStore using a mocked psycopg2 pool."""
|
||||||
|
from __future__ import annotations
|
||||||
|
import json
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
# Patch psycopg2 before importing the module under test
|
||||||
|
import sys
|
||||||
|
mock_psycopg2 = MagicMock()
|
||||||
|
mock_psycopg2.extras = MagicMock()
|
||||||
|
sys.modules.setdefault("psycopg2", mock_psycopg2)
|
||||||
|
sys.modules.setdefault("psycopg2.extras", mock_psycopg2.extras)
|
||||||
|
sys.modules.setdefault("psycopg2.pool", MagicMock())
|
||||||
|
|
||||||
|
from app.infrastructure.perception.base_event_store import BaseEventStore
|
||||||
|
|
||||||
|
|
||||||
|
SAMPLE_ROW = {
|
||||||
|
"id": "pg-001",
|
||||||
|
"source": "国标委",
|
||||||
|
"source_label": "国家标准化管理委员会",
|
||||||
|
"standard_code": "GB 18384-2025",
|
||||||
|
"title": "电动汽车安全要求",
|
||||||
|
"summary": "新增要求",
|
||||||
|
"full_text_url": "https://openstd.samr.gov.cn",
|
||||||
|
"status": "enacted",
|
||||||
|
"impact_level": "high",
|
||||||
|
"published_at": "2025-11-15",
|
||||||
|
"effective_at": "2026-07-01",
|
||||||
|
"category": "电动汽车安全",
|
||||||
|
"tags": ["电池安全"],
|
||||||
|
"obligations": None,
|
||||||
|
"deadlines": None,
|
||||||
|
"scope": None,
|
||||||
|
"penalties": None,
|
||||||
|
"content_hash": "abc123",
|
||||||
|
"previous_hash": None,
|
||||||
|
"change_summary": None,
|
||||||
|
"changed_sections": None,
|
||||||
|
"affected_docs": None,
|
||||||
|
"crawled_at": "2026-06-05T10:00:00+00:00",
|
||||||
|
"processed_at": None,
|
||||||
|
"raw_storage_key": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _make_store_with_pool(mock_pool):
|
||||||
|
with patch("psycopg2.pool.ThreadedConnectionPool", return_value=mock_pool):
|
||||||
|
with patch(
|
||||||
|
"app.infrastructure.perception.postgres_event_store.PostgresEventStore._ensure_schema"
|
||||||
|
):
|
||||||
|
from app.infrastructure.perception.postgres_event_store import PostgresEventStore
|
||||||
|
return PostgresEventStore()
|
||||||
|
|
||||||
|
|
||||||
|
def _cursor_returning(rows):
|
||||||
|
cursor = MagicMock()
|
||||||
|
cursor.__enter__ = lambda s: s
|
||||||
|
cursor.__exit__ = MagicMock(return_value=False)
|
||||||
|
cursor.fetchall.return_value = rows
|
||||||
|
cursor.fetchone.return_value = rows[0] if rows else None
|
||||||
|
return cursor
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_base_event_store():
|
||||||
|
mock_pool = MagicMock()
|
||||||
|
store = _make_store_with_pool(mock_pool)
|
||||||
|
assert isinstance(store, BaseEventStore)
|
||||||
|
|
||||||
|
|
||||||
|
def test_filter_returns_list():
|
||||||
|
mock_pool = MagicMock()
|
||||||
|
conn = MagicMock()
|
||||||
|
conn.__enter__ = lambda s: s
|
||||||
|
conn.__exit__ = MagicMock(return_value=False)
|
||||||
|
cursor = _cursor_returning([SAMPLE_ROW])
|
||||||
|
conn.cursor.return_value = cursor
|
||||||
|
mock_pool.getconn.return_value = conn
|
||||||
|
store = _make_store_with_pool(mock_pool)
|
||||||
|
result = store.filter(limit=10)
|
||||||
|
assert isinstance(result, list)
|
||||||
|
|
||||||
|
|
||||||
|
def test_stats_returns_correct_keys():
|
||||||
|
mock_pool = MagicMock()
|
||||||
|
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)
|
||||||
|
cursor.fetchone.return_value = {"count": 5}
|
||||||
|
conn.cursor.return_value = cursor
|
||||||
|
mock_pool.getconn.return_value = conn
|
||||||
|
store = _make_store_with_pool(mock_pool)
|
||||||
|
stats = store.stats()
|
||||||
|
for key in ("total", "high_impact", "medium_impact", "recent_90d"):
|
||||||
|
assert key in stats
|
||||||
@@ -549,7 +549,7 @@ AI+合规智能中枢统一脚本
|
|||||||
用法:
|
用法:
|
||||||
./dev.sh help
|
./dev.sh help
|
||||||
./dev.sh setup
|
./dev.sh setup
|
||||||
./dev.sh start [all|api|frontend] [--foreground] [--mode dev|static]
|
./dev.sh start [all|api|frontend|worker|beat] [--foreground] [--mode dev|static]
|
||||||
./dev.sh stop [all|api|frontend]
|
./dev.sh stop [all|api|frontend]
|
||||||
./dev.sh restart [all|api|frontend] [--mode dev|static]
|
./dev.sh restart [all|api|frontend] [--mode dev|static]
|
||||||
./dev.sh status
|
./dev.sh status
|
||||||
@@ -563,6 +563,9 @@ AI+合规智能中枢统一脚本
|
|||||||
进行一次性的本地初始化。
|
进行一次性的本地初始化。
|
||||||
包含 Python 版本检查、.venv 虚拟环境创建、后端依赖安装、前端 npm install、
|
包含 Python 版本检查、.venv 虚拟环境创建、后端依赖安装、前端 npm install、
|
||||||
以及 6.86.80.8 基础服务端口连通性检查。
|
以及 6.86.80.8 基础服务端口连通性检查。
|
||||||
|
初始化完成后,首次运行前还需执行:
|
||||||
|
PYTHONPATH=backend .venv/bin/python scripts/seed_users.py
|
||||||
|
以创建 admin/legal/ehs/readonly 四个演示用户。
|
||||||
|
|
||||||
start
|
start
|
||||||
启动服务。默认行为等同于 ./dev.sh start all。
|
启动服务。默认行为等同于 ./dev.sh start all。
|
||||||
@@ -570,6 +573,8 @@ AI+合规智能中枢统一脚本
|
|||||||
all 同时启动 API 和前端。
|
all 同时启动 API 和前端。
|
||||||
api 只启动后端 API。
|
api 只启动后端 API。
|
||||||
frontend 只启动前端。
|
frontend 只启动前端。
|
||||||
|
worker 启动 Celery 文档处理 worker(前台运行,需要 Redis)。
|
||||||
|
beat 启动 Celery Beat 定时调度器(前台运行,需要 Redis)。
|
||||||
可选参数:
|
可选参数:
|
||||||
--foreground 仅对 start api 生效,前台运行并开启 --reload,便于调试。
|
--foreground 仅对 start api 生效,前台运行并开启 --reload,便于调试。
|
||||||
--mode dev 前端使用 Vite 开发服务器,默认端口 5173。
|
--mode dev 前端使用 Vite 开发服务器,默认端口 5173。
|
||||||
@@ -578,6 +583,7 @@ AI+合规智能中枢统一脚本
|
|||||||
stop
|
stop
|
||||||
停止服务。默认行为等同于 ./dev.sh stop all。
|
停止服务。默认行为等同于 ./dev.sh stop all。
|
||||||
会优先读取 logs/*.pid,PID 文件失效时会回退到端口探测。
|
会优先读取 logs/*.pid,PID 文件失效时会回退到端口探测。
|
||||||
|
注意: worker 和 beat 为前台进程,直接 Ctrl+C 停止。
|
||||||
|
|
||||||
restart
|
restart
|
||||||
先停止再启动,支持 all/api/frontend。
|
先停止再启动,支持 all/api/frontend。
|
||||||
@@ -601,8 +607,11 @@ AI+合规智能中枢统一脚本
|
|||||||
|
|
||||||
常用示例:
|
常用示例:
|
||||||
./dev.sh setup
|
./dev.sh setup
|
||||||
|
PYTHONPATH=backend .venv/bin/python scripts/seed_users.py
|
||||||
./dev.sh start
|
./dev.sh start
|
||||||
./dev.sh start api --foreground
|
./dev.sh start api --foreground
|
||||||
|
./dev.sh start worker
|
||||||
|
./dev.sh start beat
|
||||||
./dev.sh start frontend --mode static
|
./dev.sh start frontend --mode static
|
||||||
./dev.sh restart frontend --mode dev
|
./dev.sh restart frontend --mode dev
|
||||||
./dev.sh status
|
./dev.sh status
|
||||||
@@ -615,7 +624,7 @@ parse_target() {
|
|||||||
local default_target="$1"
|
local default_target="$1"
|
||||||
local candidate="${2:-}"
|
local candidate="${2:-}"
|
||||||
case "$candidate" in
|
case "$candidate" in
|
||||||
all|api|frontend)
|
all|api|frontend|worker|beat)
|
||||||
echo "$candidate"
|
echo "$candidate"
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
@@ -646,41 +655,64 @@ main() {
|
|||||||
shift || true
|
shift || true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
while [ $# -gt 0 ]; do
|
# worker and beat are pass-through — forward remaining args to celery directly.
|
||||||
case "$1" in
|
|
||||||
--foreground)
|
|
||||||
foreground=true
|
|
||||||
;;
|
|
||||||
--mode)
|
|
||||||
shift || die "--mode 需要指定 dev 或 static"
|
|
||||||
mode="$1"
|
|
||||||
validate_frontend_mode "$mode"
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
die "未知参数: $1"
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
shift || true
|
|
||||||
done
|
|
||||||
|
|
||||||
case "$target" in
|
case "$target" in
|
||||||
all)
|
worker)
|
||||||
[ "$foreground" = false ] || die "start all 不支持 --foreground,请使用 start api --foreground"
|
print_header "AI+合规智能中枢 - 启动 Celery Worker"
|
||||||
print_header "AI+合规智能中枢 - 启动服务"
|
require_venv
|
||||||
start_api background
|
export PYTHONPATH="backend${PYTHONPATH:+:$PYTHONPATH}"
|
||||||
start_frontend "${mode:-$FRONTEND_MODE}"
|
"$VENV_PYTHON" -m celery -A app.infrastructure.tasks.celery_app worker \
|
||||||
|
--loglevel=info \
|
||||||
|
--concurrency=2 \
|
||||||
|
--queues=celery \
|
||||||
|
"$@"
|
||||||
;;
|
;;
|
||||||
api)
|
beat)
|
||||||
if [ "$foreground" = true ]; then
|
print_header "AI+合规智能中枢 - 启动 Celery Beat"
|
||||||
start_api foreground
|
require_venv
|
||||||
else
|
export PYTHONPATH="backend${PYTHONPATH:+:$PYTHONPATH}"
|
||||||
print_header "AI+合规智能中枢 - 启动 API"
|
"$VENV_PYTHON" -m celery -A app.infrastructure.tasks.celery_app beat \
|
||||||
start_api background
|
--loglevel=info \
|
||||||
fi
|
"$@"
|
||||||
;;
|
;;
|
||||||
frontend)
|
*)
|
||||||
print_header "AI+合规智能中枢 - 启动前端"
|
while [ $# -gt 0 ]; do
|
||||||
start_frontend "${mode:-$FRONTEND_MODE}"
|
case "$1" in
|
||||||
|
--foreground)
|
||||||
|
foreground=true
|
||||||
|
;;
|
||||||
|
--mode)
|
||||||
|
shift || die "--mode 需要指定 dev 或 static"
|
||||||
|
mode="$1"
|
||||||
|
validate_frontend_mode "$mode"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
die "未知参数: $1"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
shift || true
|
||||||
|
done
|
||||||
|
|
||||||
|
case "$target" in
|
||||||
|
all)
|
||||||
|
[ "$foreground" = false ] || die "start all 不支持 --foreground,请使用 start api --foreground"
|
||||||
|
print_header "AI+合规智能中枢 - 启动服务"
|
||||||
|
start_api background
|
||||||
|
start_frontend "${mode:-$FRONTEND_MODE}"
|
||||||
|
;;
|
||||||
|
api)
|
||||||
|
if [ "$foreground" = true ]; then
|
||||||
|
start_api foreground
|
||||||
|
else
|
||||||
|
print_header "AI+合规智能中枢 - 启动 API"
|
||||||
|
start_api background
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
frontend)
|
||||||
|
print_header "AI+合规智能中枢 - 启动前端"
|
||||||
|
start_frontend "${mode:-$FRONTEND_MODE}"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
;;
|
;;
|
||||||
|
|||||||
@@ -58,7 +58,8 @@ services:
|
|||||||
retries: 5
|
retries: 5
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
# PostgreSQL数据库 (可选,启用 DOCUMENT_REPOSITORY_BACKEND=postgres 时使用)
|
# PostgreSQL数据库 (启用 DOCUMENT_REPOSITORY_BACKEND=postgres 时使用;
|
||||||
|
# 合规分析历史记录 Direction B、DOCX 报告下载及 Finding Chat 持久化 Direction C 均依赖此服务)
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:15-alpine
|
image: postgres:15-alpine
|
||||||
container_name: postgres
|
container_name: postgres
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,289 @@
|
|||||||
|
# AI+合规智能中枢 — 下一步开发与优化路线图(设计文档)
|
||||||
|
|
||||||
|
- 日期:2026-06-05
|
||||||
|
- 定位:试点 MVP 走向生产
|
||||||
|
- 范围:全景清单 + 异步任务化(设计①)+ 法规感知闭环(设计②)深入方案 + 三阶段实施路线图
|
||||||
|
- 作者:AI Regulations Team(brainstorming 产出)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. 背景与目的
|
||||||
|
|
||||||
|
本文档基于对当前仓库前后端真实代码的逐文件探查,结合四份愿景文档(`AI_Regulations_Report.pptx`、`AI_Regulations_Architecture.docx`、`01_Architecture.html`、`02_Architecture_Detail.html`)与最新开源 AI 技术调研,给出**下一步可继续开发与优化的方向清单**,并对两个最高价值方向给出可落地的深入设计。
|
||||||
|
|
||||||
|
本文档是**方向性设计(spec)**,不是实施计划(plan)。阶段一、阶段二的具体落地由后续 writing-plans 环节拆分为分步计划。
|
||||||
|
|
||||||
|
### 0.1 现状一句话
|
||||||
|
|
||||||
|
后端是一套结构清晰的 DDD 风格 FastAPI RAG 系统(上传 → 解析 → 分块 → BGE-M3 嵌入 → Milvus → 混合检索 → 流式问答 + 合规分析),**真实可用**。但愿景文档中的多个旗舰能力(知识图谱、法规感知闭环、RBAC、EHS、异步化)目前为 **mock 或缺失**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 现状盘点(基于真实代码)
|
||||||
|
|
||||||
|
### 1.1 已实现且真实可用
|
||||||
|
|
||||||
|
- **文档处理主链路**:`application/documents/services.py::DocumentCommandService.upload_and_process` — 存储 → 解析(阿里云 DocMind / 本地)→ 分块 → BGE-M3 嵌入 → Milvus 入库,含 `DocumentProcessingStore` 全程状态事件记录。
|
||||||
|
- **混合检索**:`application/knowledge/services.py::KnowledgeRetrievalService` — Dense(`DenseRetriever`)+ BM25(jieba)+ Reciprocal Rank Fusion + 可选 Cross-Encoder 重排。
|
||||||
|
- **流式 RAG 问答**:`application/agent/services.py::AgentConversationService.stream_chat` + `api/routes/rag.py` — 真实检索 + 引文 + 会话历史 + SSE。
|
||||||
|
- **合规分析管线**:`application/compliance/pipeline.py` — clause_split → retrieve → gap_check → conclusion,真实 LLM + 真实检索,SSE 流式(`api/routes/compliance.py::analyze_stream`)。
|
||||||
|
- **状态/健康面板**:`api/routes/status.py` + 前端 `StatusPage.tsx` — Milvus/MinIO/BM25/Reranker/会话实时状态。
|
||||||
|
- **存储后端**:PostgreSQL / MinIO 适配器齐全;JSON 与 Postgres 双后端可切换。
|
||||||
|
- **前端**:React 19 + Vite + Tailwind,6 个页面(Overview/Status/Perception/Docs/Compliance/RagChat)。
|
||||||
|
|
||||||
|
### 1.2 愿景已规划但代码缺失或为 mock
|
||||||
|
|
||||||
|
| 能力 | 愿景出处 | 代码现状 |
|
||||||
|
|------|---------|---------|
|
||||||
|
| 知识图谱 / Neo4j 多跳推理 | 架构图 L4/L5、Slide 5 | 全代码 0 处 neo4j/graph |
|
||||||
|
| 法规感知自动更新闭环 | 01_Architecture.html L157-193、Slide 11 | `PerceptionService` 喂 `MockEventStore`(20 条死数据) |
|
||||||
|
| 认证 / RBAC / 审计日志 | Slide 12 四角色权限矩阵 | 全代码 0 处 auth/jwt/rbac;`main.py` CORS=`*` |
|
||||||
|
| 异步任务 / Worker 集群 | 架构图"Worker 集群"、Slide 9 | `app/workers/` 空目录;处理全同步 |
|
||||||
|
| EHS 隐患识别(SIF/四维根因) | Slide 7 | 未实现 |
|
||||||
|
| 多渠道推送(Email/Teams/飞书) | Slide 8 | 未实现 |
|
||||||
|
| 闭环整改跟踪、可观测性 | 架构图右栏 | 缺失 |
|
||||||
|
|
||||||
|
### 1.3 关键发现
|
||||||
|
|
||||||
|
- **`requirements.txt:28` 已有 `celery>=5.3.0` + `redis>=4.5.0`**,`docker-compose.yml` 已配 Redis 7,`settings.py` 已有 redis 配置 —— **异步化是"接线",不是"从零搭建"**。
|
||||||
|
- **`DocumentProcessingStore` 已能记录 run 状态/状态事件** —— 是天然的任务进度表。
|
||||||
|
- **`PerceptionService.analyze_event` 的 LLM 影响分析与 RAG 关联检索是真的** —— 感知闭环缺的只是前半段(采集 → Diff → 入库)。
|
||||||
|
- 后端正处于 legacy 迁移期:`services/*`、`workflows/*` 为兼容层(见 `docs/architecture/backend-project-architecture.md`)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 全景机会清单
|
||||||
|
|
||||||
|
类型标记:`[新能力]`=愿景缺口补齐,`[加固]`=已实现能力优化。价值 ★(1-5),工作量 S/M/L。
|
||||||
|
|
||||||
|
### P0 — 生产地基(阻断"走向生产"的硬伤)
|
||||||
|
|
||||||
|
| # | 机会点 | 类型 | 现状证据 | 价值 | 工作量 |
|
||||||
|
|---|--------|------|---------|------|--------|
|
||||||
|
| 1 | 异步任务化(Celery + 已配 Redis):解析/嵌入/感知/推送下沉 worker | 加固 | `workers/` 空;`documents.py:34` 上传同步阻塞 | ★★★★★ | L |
|
||||||
|
| 2 | 认证 + RBAC + 审计日志,收紧 CORS | 新能力 | 0 处 auth;`main.py` CORS=`*`;Slide 12 | ★★★★★ | M |
|
||||||
|
| 3 | 会话 & 任务持久化(内存 → Redis/PG) | 加固 | `bootstrap.py:254` 内存会话;`compliance.py:25` 内存字典 | ★★★★ | M |
|
||||||
|
| 4 | 基础可观测性(Prometheus + 结构化日志 + 追踪) | 加固 | 仅 loguru;架构图右栏全缺 | ★★★ | M |
|
||||||
|
|
||||||
|
### P1 — 高价值能力补齐 + RAG 质量
|
||||||
|
|
||||||
|
| # | 机会点 | 类型 | 现状证据 | 价值 | 工作量 |
|
||||||
|
|---|--------|------|---------|------|--------|
|
||||||
|
| 5 | 启用并升级 Reranker(`bge-reranker-v2.5-gemma2-lightweight`) | 加固 | `settings.py:113` 默认关;管线已写好 | ★★★★ | S |
|
||||||
|
| 6 | Agentic 检索(查询改写/意图理解/多路召回) | 加固 | `agent/services.py` 直接 retrieve,无 rewrite/HyDE | ★★★★ | M |
|
||||||
|
| 7 | 知识图谱 / GraphRAG(Neo4j + LightRAG v1.5) | 新能力 | 0 处 neo4j;LightRAG v1.5 原生支持 | ★★★★★ | L |
|
||||||
|
| 8 | 法规感知自动更新闭环(真实采集 + 版本 Diff + 增量重索引) | 新能力 | `perception/services.py` 用 MockEventStore | ★★★★★ | L |
|
||||||
|
| 9 | 引文置信度评分(Slide 5 承诺"置信度评分+页码溯源") | 加固 | `rag.py` sources 无 confidence | ★★★ | S |
|
||||||
|
| 10 | 检索评估 harness(recall@k / faithfulness) | 加固 | `tests/` 需真实服务,无离线 RAG 评估 | ★★★ | M |
|
||||||
|
|
||||||
|
### P2 — 视野扩展(独立子项目)
|
||||||
|
|
||||||
|
| # | 机会点 | 类型 | 价值 | 工作量 |
|
||||||
|
|---|--------|------|------|--------|
|
||||||
|
| 11 | EHS 隐患识别(SIF 评分 + 四维根因 + ISO 45001 扫描,Slide 7) | 新能力 | ★★★★ | L |
|
||||||
|
| 12 | 多渠道推送 + 订阅规则引擎(Email/Teams/飞书,Slide 8) | 新能力 | ★★★ | M |
|
||||||
|
| 13 | 闭环整改跟踪(任务派发 → 进度 → 验收归档) | 新能力 | ★★★ | M |
|
||||||
|
| 14 | 企业系统集成(PLM/ERP/OA/MES Webhook) | 新能力 | ★★ | L |
|
||||||
|
| 15 | MinerU 3.1 升级(已转 Apache 协议,VLM 解析)作本地兜底 | 加固 | ★★ | S |
|
||||||
|
| 16 | 前端加固(清 mock 数据、补 error/loading 态、KG 可视化、登录态) | 加固 | ★★★ | M |
|
||||||
|
| 17 | 收口 legacy 迁移(`services/*`、`workflows/*` 按架构文档归位) | 加固 | ★★ | M |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 深入设计 ① — 异步任务化
|
||||||
|
|
||||||
|
### 3.1 问题
|
||||||
|
|
||||||
|
`upload_document`(`api/routes/documents.py:34`)在单个 HTTP 请求内同步跑完 存储 → 解析(阿里云云端可达 900 秒,`settings.py:49`)→ 嵌入 → Milvus 入库。大体量 GB 标准必然超时;`compliance.py` 的 `/analyze` 为假异步(立即返回 mock);perception 爬取闭环无执行载体。PPT Slide 9 已将"大文件性能"列为关键挑战,对策正是"流式处理 + 异步队列 + 实时进度"。
|
||||||
|
|
||||||
|
### 3.2 关键前提:基建已就位
|
||||||
|
|
||||||
|
- `requirements.txt:28` 已含 `celery>=5.3.0` + `redis>=4.5.0`
|
||||||
|
- `docker-compose.yml:46` Redis 7 已配置;`settings.py:64` 已有 redis 连接配置
|
||||||
|
- `PostgresDocumentProcessingStore` 已记录 run 状态/状态事件 —— 天然任务进度表
|
||||||
|
- `app/workers/` 为空目录(唯一缺口)
|
||||||
|
|
||||||
|
### 3.3 架构(遵循 AGENTS.md 的 `api → application → domain ports → infrastructure`)
|
||||||
|
|
||||||
|
```
|
||||||
|
api/routes/documents.py POST /upload
|
||||||
|
│ 1. 存二进制 + 建 Document 记录(快,同步)
|
||||||
|
│ 2. enqueue task → 立即返回 {doc_id, status:"queued", run_id}
|
||||||
|
▼
|
||||||
|
infrastructure/tasks/ ← 新增
|
||||||
|
celery_app.py broker=redis, backend=redis
|
||||||
|
document_tasks.py @task process_document(doc_id) → DocumentCommandService
|
||||||
|
│ 复用现有 upload_and_process 的 parse→embed→index 段
|
||||||
|
▼
|
||||||
|
application/documents/services.py(拆分:store 与 process 解耦)
|
||||||
|
│ 每阶段写 DocumentProcessingStore(已存在)→ 进度可查
|
||||||
|
▼
|
||||||
|
api/routes/documents.py GET /status/{doc_id} ← 已存在,读 run 状态即可
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.4 落地步骤(增量、不破坏现有同步路径)
|
||||||
|
|
||||||
|
1. 新增 `infrastructure/tasks/celery_app.py` — Celery 实例,broker/backend 指向已配 Redis。
|
||||||
|
2. 拆分 `upload_and_process` → `store_document`(同步快)+ `process_document`(可异步),复用现有逻辑,零重写解析/嵌入代码。
|
||||||
|
3. 新增 `document_tasks.py` — `@celery_app.task` 包裹 `process_document`,失败用 `tenacity`(已在 deps)重试 + 死信。
|
||||||
|
4. 改 `documents.py` 上传 — 默认入队(保留 `?sync=true` 同步回退便于演示);`GET /status/{doc_id}` 读 `DocumentProcessingStore` 返回阶段进度。
|
||||||
|
5. 前端 `DocsPage.tsx` — 上传后轮询/SSE 进度条(架构图 Worker"心跳/状态上报"已是既定设计)。
|
||||||
|
6. `dev.sh`/`dev.bat` 加 worker 启动:`celery -A app.infrastructure.tasks.celery_app worker`。
|
||||||
|
|
||||||
|
### 3.5 工作量与风险
|
||||||
|
|
||||||
|
- **M(中),3-5 天。**
|
||||||
|
- 最大风险:Celery worker 进程内 `PYTHONPATH=backend` 与 bootstrap `lru_cache` 单例需重新初始化 —— 可控,因 bootstrap 已是懒加载。
|
||||||
|
- YAGNI 边界:本期仅异步化"文档处理"一条链;compliance/perception 复用同一 Celery 基建后续接入。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 深入设计 ② — 法规感知自动更新闭环
|
||||||
|
|
||||||
|
### 4.1 问题
|
||||||
|
|
||||||
|
感知闭环是愿景旗舰能力(`01_Architecture.html` L157-193、Slide 11)。现状:`PerceptionService` 喂 `MockEventStore`(`mock_event_store.py:7`,20 条手写死数据),`list_events`/`stats` 全静态,`source_url` 真实但从不访问。**LLM 影响分析与 RAG 关联检索是真的** —— 闭环缺的是前半段:真实采集 → 变更感知(Diff)→ 入库。
|
||||||
|
|
||||||
|
### 4.2 六步现状对照
|
||||||
|
|
||||||
|
| 步骤 | 愿景设计 | 现状 | 本期目标 |
|
||||||
|
|------|---------|------|---------|
|
||||||
|
| ① 法规源监控 | 定时爬国标网/MIIT/UN-ECE/EUR-Lex | ❌ 无 | ✅ 适配器+定时 |
|
||||||
|
| ② 智能变更感知 | NLP 比对新旧版本 Diff | ❌ 无 | ✅ 内容指纹+LLM Diff |
|
||||||
|
| ③ 自动解析入库 | MinerU→分块→BGE-M3→Milvus | ✅ 已有(复用设计①管线) | ✅ 接线 |
|
||||||
|
| ④ 知识图谱更新 | Neo4j 关系同步 | ❌ 无 | ⏭️ 本期不做(归 GraphRAG 专项) |
|
||||||
|
| ⑤ 差距分析&推送 | AI 比对+按角色推送 | 🟡 analyze_event 已有分析,无推送 | 🟡 分析复用,推送下期 |
|
||||||
|
| ⑥ 触发整改闭环 | 整改任务跟踪 | ❌ 无 | ⏭️ 下期 |
|
||||||
|
|
||||||
|
本期聚焦 ①②③,复用设计①异步管线与已有解析/嵌入/检索/分析能力。
|
||||||
|
|
||||||
|
### 4.3 架构(端口与适配器)
|
||||||
|
|
||||||
|
```
|
||||||
|
domain/perception/ports.py ← 新增
|
||||||
|
RegulationSource (Protocol) fetch_latest() → list[RawRegulation]
|
||||||
|
EventStore (Protocol) 抽象掉 MockEventStore(现有 mock 成为一个实现)
|
||||||
|
ChangeDetector (Protocol) diff(old, new) → ChangeSet
|
||||||
|
|
||||||
|
infrastructure/perception/
|
||||||
|
sources/ ← 新增,每法规源一个适配器
|
||||||
|
gb_openstd_source.py 国标网 (openstd.samr.gov.cn)
|
||||||
|
miit_source.py 工信部
|
||||||
|
base_html_source.py 通用 HTML 抓取基类(httpx 已在 deps)
|
||||||
|
postgres_event_store.py ← 替换 MockEventStore(真实持久化)
|
||||||
|
content_fingerprint_detector.py 哈希指纹 + LLM 语义 Diff
|
||||||
|
|
||||||
|
application/perception/services.py(扩展现有)
|
||||||
|
ingest_cycle() ← 新增:①抓取 → ②Diff → ③入队解析(设计①的 task)
|
||||||
|
(list_events/analyze_event 保持不变,已是真实逻辑)
|
||||||
|
|
||||||
|
infrastructure/tasks/perception_tasks.py ← 复用设计①的 Celery
|
||||||
|
@task perception_crawl_cycle() Celery Beat 定时触发
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.4 关键设计决策
|
||||||
|
|
||||||
|
1. **接口契约零改动**:`PostgresEventStore` 输出与 `MockEventStore` 完全相同的 dict 结构(mock_event_store.py 的 20 字段),故 `perception.ts` 前端契约、`PerceptionPage.tsx`、`analyze_event` 全部不改。Mock 退化为种子数据/演示回退,通过 `perception_event_store=mock|postgres` 开关切换(对齐现有 `document_repository_backend` 模式)。
|
||||||
|
2. **变更感知分两层**:廉价层(内容哈希指纹判断"是否变了")+ 智能层(变了才调 LLM 做"新增/修订/废止条款"结构化 Diff,复用 `get_llm_client`,prompt 风格照搬 `compliance/pipeline.py::_extract_json`)。
|
||||||
|
3. **合规防滥用**:尊重 `robots.txt` + 限速 + `tenacity` 重试 + 抓取失败不污染已有数据;适配器隔离,单源故障不影响其它。
|
||||||
|
4. **入库复用设计①**:抓到新法规 PDF → 丢进 `process_document` task → 自动走完解析/嵌入/索引。
|
||||||
|
|
||||||
|
### 4.5 落地步骤
|
||||||
|
|
||||||
|
1. 抽 `domain/perception/ports.py`,让现有 `MockEventStore` 实现 `EventStore` 协议(纯重构,行为不变)。
|
||||||
|
2. `PostgresEventStore` + 建表(参照 `aliyun_parser/schema.sql` 风格)+ 20 条 mock 作 seed。
|
||||||
|
3. 先做 1 个真实源适配器(建议国标网,结构最稳)跑通 ①→②→③,验证端到端。
|
||||||
|
4. `content_fingerprint_detector` + LLM Diff。
|
||||||
|
5. `perception_crawl_cycle` Celery Beat 定时(每日);新事件落 PostgresEventStore + 新法规入队解析。
|
||||||
|
6. 前端 `PerceptionPage` 加"最近同步时间/本次新增 N 条"(stats 已有结构,加 2 字段)。
|
||||||
|
|
||||||
|
### 4.6 工作量与风险
|
||||||
|
|
||||||
|
- **L(大),5-8 天**,依赖设计①先落地(共用 Celery)。
|
||||||
|
- 最大风险:外部源站不可控(改版/反爬)。缓解:适配器隔离 + mock 永久保留为回退 + 先攻 1 个源验证(对齐 Slide 13"选取 2-3 个场景 POC 验证")。
|
||||||
|
- YAGNI 边界:④Neo4j 图谱、⑥整改闭环、多渠道推送本期不做,各自独立子项目。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 三阶段实施路线图
|
||||||
|
|
||||||
|
### 5.1 核心主线
|
||||||
|
|
||||||
|
项目不缺"能力点",缺的是**让能力点从同步脚本变成可运营的系统**。主线是**异步化基建**:既是文档处理性能解药(设计①),又是感知闭环执行载体(设计②),也是未来 EHS/推送的统一底座。路线图以它为"第 0 块地基",其余能力挂载其上。
|
||||||
|
|
||||||
|
### 5.2 与 PPT 三阶段映射(Slide 10)
|
||||||
|
|
||||||
|
```
|
||||||
|
PPT 规划 代码现状 本路线图补齐
|
||||||
|
─────────────────────────────────────────────────────
|
||||||
|
一阶段 知识库+基础问答 ✅ 大体已实现 → 加固 (P0/P1)
|
||||||
|
二阶段 文档审查+API集成 🟡 审查真/API半 → 异步化+感知闭环
|
||||||
|
三阶段 EHS+个性化+图谱 ❌ 基本缺失 → 子项目 (P2)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 阶段一 · 生产地基(2-3 周)— "让它扛得住生产"
|
||||||
|
|
||||||
|
| 顺序 | 事项 | 依据 | 估时 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| 1 | 设计① 异步任务化 | celery/redis 已在 deps,workers/ 空 | M, 3-5d |
|
||||||
|
| 2 | 认证 + RBAC + 审计 + 收紧 CORS | 0 处 auth;Slide 12 矩阵 | M, 3-5d |
|
||||||
|
| 3 | 会话/任务持久化(内存 → Redis/PG) | InMemoryConversationStore 重启即丢 | M, 2-3d |
|
||||||
|
| 4 | 快赢:启用 Reranker | settings 默认关,管线已写好 | S, 0.5d |
|
||||||
|
|
||||||
|
### 5.4 阶段二 · 招牌能力(2-3 周)— "让它有亮点"
|
||||||
|
|
||||||
|
建议**感知闭环优先于图谱**(前者复用阶段一异步基建,ROI 更高)。
|
||||||
|
|
||||||
|
| 顺序 | 事项 | 依据 | 估时 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| 5 | 设计② 法规感知闭环 ①②③ | MockEventStore → 真实采集 | L, 5-8d |
|
||||||
|
| 6 | Agentic 检索(查询改写/意图理解) | Slide 5"意图理解",代码是直检索 | M, 3-4d |
|
||||||
|
| 7 | 引文置信度评分 + 基础可观测性 | Slide 5 承诺;架构图右栏全缺 | S+M, 3-4d |
|
||||||
|
|
||||||
|
### 5.5 阶段三 · 视野扩展(按需,各为独立子项目)— "让它成体系"
|
||||||
|
|
||||||
|
每项单独 brainstorm → spec → 实施,本期不细化:
|
||||||
|
|
||||||
|
- 知识图谱 / GraphRAG(Neo4j + LightRAG v1.5,接感知闭环第④步)
|
||||||
|
- EHS 隐患识别(SIF + 四维根因,Slide 7)
|
||||||
|
- 多渠道推送 + 订阅规则引擎(Slide 8)→ 闭环整改跟踪(第⑤⑥步)
|
||||||
|
- 持续加固:MinerU 3.1 升级、前端清 mock、legacy 收口
|
||||||
|
|
||||||
|
### 5.6 决策建议
|
||||||
|
|
||||||
|
1. 强烈建议按阶段顺序:地基 → 招牌 → 扩展。跳过地基直接做招牌,会在生产暴露超时/无鉴权/数据丢失。
|
||||||
|
2. 阶段一第 4 项(Reranker)可立即做 —— 半天见效,与其它解耦,适合先尝甜头。
|
||||||
|
3. 阶段二二选一先行:要 demo 冲击力选"感知闭环";要问答质量选"Agentic 检索"。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 最新 AI 技术调研(支撑选型)
|
||||||
|
|
||||||
|
| 技术 | 版本/状态(2026) | 对应机会点 |
|
||||||
|
|------|------------------|-----------|
|
||||||
|
| LightRAG | v1.5.0(2026-06),EMNLP 2025;KG-RAG,原生支持 Neo4j + MinerU/Docling,含 Web UI 图谱可视化 | #7 知识图谱 |
|
||||||
|
| MinerU | v3.1.0(2026-04),协议转为 Apache 2.0 基础的开源协议,VLM 解析(MinerU2.5-Pro),109 语言 OCR | #15 本地解析兜底 |
|
||||||
|
| BGE Reranker | `bge-reranker-v2.5-gemma2-lightweight`(token 压缩 + 分层轻量化,生产推荐) | #5 Reranker 升级 |
|
||||||
|
| BGE-M3 | 100+ 语言,8192 上下文,dense+sparse+colbert 统一(现已在用) | 现有嵌入 |
|
||||||
|
| RAGFlow | 2026 支持 DeepSeek v4 / MCP / 跨语言查询;agentic RAG 参考实现 | #6 Agentic 检索参考 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 验收与边界
|
||||||
|
|
||||||
|
### 7.1 本文档明确不做(YAGNI)
|
||||||
|
|
||||||
|
- 阶段三所有子项目(图谱、EHS、推送、整改闭环、企业集成)仅列方向,不在本期展开。
|
||||||
|
- 移动端适配(AGENTS.md 明确 desktop-first)。
|
||||||
|
- 感知闭环的第④⑤⑥步(图谱同步、推送、整改)。
|
||||||
|
|
||||||
|
### 7.2 架构约束(必须遵守)
|
||||||
|
|
||||||
|
- 后端遵循 `api → application → domain ports → infrastructure`(`docs/architecture/backend-project-architecture.md` 为权威)。
|
||||||
|
- 新业务逻辑不得落入 `services/*`、`workflows/*`(legacy 迁移区)。
|
||||||
|
- `shared/bootstrap.py` 为依赖装配 composition root,新依赖在此接线。
|
||||||
|
- 后端注释/docstring 全英文(AGENTS.md 规范)。
|
||||||
|
|
||||||
|
### 7.3 下一步
|
||||||
|
|
||||||
|
经用户审阅本 spec 后,对**阶段一**(异步任务化优先)调用 writing-plans 拆分为分步实施计划。
|
||||||
@@ -0,0 +1,328 @@
|
|||||||
|
# Regulatory Signals Intelligence Enhancement — Design Spec
|
||||||
|
|
||||||
|
> **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 (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Replace the 20-item hardcoded MockEventStore with real regulatory data from Chinese and international sources, add LLM-driven structured extraction, impact assessment, and semantic change diff — all accessible through a manual-trigger crawl in the frontend.
|
||||||
|
|
||||||
|
**Architecture:** Crawler Service (httpx + BeautifulSoup) → PostgreSQL EventStore → LLM Pipeline (extract → assess → diff) → existing PerceptionService interface. New code follows `api → application → domain ports → infrastructure` layering; no new files in `services/*` or `workflows/*`; `shared/bootstrap.py` is the composition root.
|
||||||
|
|
||||||
|
**Tech Stack:** httpx, BeautifulSoup4, sentence-transformers (for diff), existing LLM factory (deepseek/qwen), existing KnowledgeRetrievalService (RAG), PostgreSQL (already available), existing SSE infrastructure.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Data Sources
|
||||||
|
|
||||||
|
| Source | URL | Method | Coverage |
|
||||||
|
|--------|-----|--------|----------|
|
||||||
|
| CATARC 汽车标准 | `https://www.catarc.org.cn/bzzxd/qcbz/index.html` | httpx + BeautifulSoup (static pages) | 国家/行业汽车标准列表 |
|
||||||
|
| 国标委强制性标准 | `https://openstd.samr.gov.cn/bzgk/std/std_list_type?p.p1=1&p.p2=车&p.p90=circulation_date&p.p91=desc` | httpx + JSON API parse | 强制性国家标准,按"车"过滤 |
|
||||||
|
| 国标委推荐性标准 | `https://openstd.samr.gov.cn/bzgk/std/std_list_type?p.p1=2&p.p2=车&p.p90=circulation_date&p.p91=desc` | httpx + JSON API parse | 推荐性国家标准,按"车"过滤 |
|
||||||
|
| EUR-Lex | RSS + CELLAR REST API | pyeurlex / httpx | EU AI Act, automotive directives |
|
||||||
|
| UN R155/R156 | CELLAR REST API (CELEX lookup) | httpx | UN-ECE cybersecurity/OTA regulations |
|
||||||
|
|
||||||
|
Crawl is **manual-trigger only** — no cron/Celery Beat. Admin clicks "刷新数据源" in the frontend UI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Database Schema
|
||||||
|
|
||||||
|
### New table: `regulation_events`
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE IF NOT EXISTS regulation_events (
|
||||||
|
id TEXT PRIMARY KEY, -- sha256(source + standard_code)[:12]
|
||||||
|
source TEXT NOT NULL, -- 'CATARC' | '国标委' | 'EUR-Lex' | 'UN-ECE'
|
||||||
|
source_label TEXT, -- Human-readable source label
|
||||||
|
standard_code TEXT NOT NULL, -- e.g. "GB 18384-2025", "EU/2024/1689"
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
summary TEXT, -- Crawled abstract or first paragraph
|
||||||
|
full_text_url TEXT, -- Original page URL
|
||||||
|
status TEXT, -- 'enacted' | 'draft' | 'consultation'
|
||||||
|
impact_level TEXT, -- 'high' | 'medium' | 'low' (LLM-assigned)
|
||||||
|
published_at DATE,
|
||||||
|
effective_at DATE,
|
||||||
|
category TEXT,
|
||||||
|
tags TEXT[],
|
||||||
|
-- LLM structured extraction
|
||||||
|
obligations JSONB, -- [{text, deontic, subject, object, condition}]
|
||||||
|
deadlines JSONB, -- [{date, description}]
|
||||||
|
scope TEXT, -- Applicability scope summary
|
||||||
|
penalties TEXT, -- Penalty / consequence summary
|
||||||
|
-- Change tracking
|
||||||
|
content_hash TEXT, -- SHA256 of crawled full text
|
||||||
|
previous_hash TEXT, -- Hash from prior crawl (NULL on first crawl)
|
||||||
|
change_summary TEXT, -- LLM-generated description of changes
|
||||||
|
changed_sections JSONB, -- [{old_text, new_text, change_type}] where cosine<0.85
|
||||||
|
-- Impact assessment
|
||||||
|
affected_docs JSONB, -- [{doc_id, doc_name, score, key_clauses, recommendation}]
|
||||||
|
-- Metadata
|
||||||
|
crawled_at TIMESTAMPTZ DEFAULT now(),
|
||||||
|
processed_at TIMESTAMPTZ,
|
||||||
|
raw_storage_key TEXT -- MinIO path for raw HTML/PDF (optional)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS regulation_events_source_date
|
||||||
|
ON regulation_events (source, published_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS regulation_events_impact_date
|
||||||
|
ON regulation_events (impact_level, published_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS regulation_events_tags
|
||||||
|
ON regulation_events USING gin(tags);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Backend Architecture
|
||||||
|
|
||||||
|
### 3.1 File Map
|
||||||
|
|
||||||
|
**New files (infrastructure layer):**
|
||||||
|
- `backend/app/infrastructure/perception/crawlers/catarc_crawler.py` — CATARC scraper
|
||||||
|
- `backend/app/infrastructure/perception/crawlers/guobiao_crawler.py` — 国标委 JSON API crawler
|
||||||
|
- `backend/app/infrastructure/perception/crawlers/eurlex_crawler.py` — EUR-Lex RSS + CELLAR
|
||||||
|
- `backend/app/infrastructure/perception/crawlers/base.py` — Abstract base class
|
||||||
|
- `backend/app/infrastructure/perception/postgres_event_store.py` — PostgresEventStore (replaces MockEventStore)
|
||||||
|
- `backend/app/infrastructure/perception/llm_pipeline.py` — Extract / assess / diff pipeline
|
||||||
|
|
||||||
|
**New files (application layer):**
|
||||||
|
- `backend/app/application/perception/crawl_service.py` — Orchestrates crawlers + LLM pipeline, exposes `run_crawl(sources)` + progress generator
|
||||||
|
|
||||||
|
**Modified files:**
|
||||||
|
- `backend/app/api/routes/perception.py` — Add `POST /crawl`, `GET /crawl/status` (SSE), `POST /events/{id}/process`, `GET /events/{id}/diff`
|
||||||
|
- `backend/app/shared/bootstrap.py` — Wire `PostgresEventStore` + `CrawlService` + `LlmPipeline` when `DOCUMENT_REPOSITORY_BACKEND=postgres`; fallback to `MockEventStore` when `json`
|
||||||
|
- `backend/app/config/settings.py` — Add `perception_crawl_timeout_seconds`, `perception_max_events_per_source`
|
||||||
|
|
||||||
|
**Unchanged files:**
|
||||||
|
- `backend/app/application/perception/services.py` — `PerceptionService` interface unchanged; only `_store` swap
|
||||||
|
- `backend/app/infrastructure/perception/mock_event_store.py` — Kept for `json` backend mode
|
||||||
|
|
||||||
|
### 3.2 Domain Port (Abstract Interface)
|
||||||
|
|
||||||
|
```python
|
||||||
|
# backend/app/infrastructure/perception/base_event_store.py
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
|
||||||
|
class BaseEventStore(ABC):
|
||||||
|
@abstractmethod
|
||||||
|
def all(self) -> list[dict]: ...
|
||||||
|
@abstractmethod
|
||||||
|
def get(self, event_id: str) -> dict | None: ...
|
||||||
|
@abstractmethod
|
||||||
|
def filter(self, source=None, impact_level=None, limit=50) -> list[dict]: ...
|
||||||
|
@abstractmethod
|
||||||
|
def stats(self) -> dict: ...
|
||||||
|
@abstractmethod
|
||||||
|
def upsert(self, event: dict) -> None: ... # new — needed for crawl writes
|
||||||
|
@abstractmethod
|
||||||
|
def get_by_standard_code(self, code: str) -> dict | None: ... # for change detection
|
||||||
|
```
|
||||||
|
|
||||||
|
`MockEventStore` and `PostgresEventStore` both implement this interface.
|
||||||
|
|
||||||
|
### 3.3 Crawler Base Contract
|
||||||
|
|
||||||
|
```python
|
||||||
|
# backend/app/infrastructure/perception/crawlers/base.py
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RawEvent:
|
||||||
|
source: str
|
||||||
|
source_label: str
|
||||||
|
standard_code: str
|
||||||
|
title: str
|
||||||
|
summary: str
|
||||||
|
full_text_url: str
|
||||||
|
status: str # 'enacted' | 'draft' | 'consultation'
|
||||||
|
published_at: str # YYYY-MM-DD string
|
||||||
|
effective_at: str | None
|
||||||
|
category: str
|
||||||
|
tags: list[str]
|
||||||
|
raw_text: str # full crawled text for hashing + LLM
|
||||||
|
|
||||||
|
class BaseCrawler(ABC):
|
||||||
|
@abstractmethod
|
||||||
|
def fetch(self, limit: int = 50) -> list[RawEvent]: ...
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.4 LLM Pipeline
|
||||||
|
|
||||||
|
```python
|
||||||
|
# backend/app/infrastructure/perception/llm_pipeline.py
|
||||||
|
|
||||||
|
class LlmPipeline:
|
||||||
|
"""Runs three sequential LLM steps on a regulation event."""
|
||||||
|
|
||||||
|
def extract_structure(self, event: dict) -> dict:
|
||||||
|
"""Step 1: Extract obligations, deadlines, scope, penalties, impact_level.
|
||||||
|
|
||||||
|
Returns dict with keys: obligations, deadlines, scope, penalties, impact_level.
|
||||||
|
Uses JSON-mode or structured prompt; model retries once on parse failure.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def assess_impact(self, event: dict, retrieval_service) -> list[dict]:
|
||||||
|
"""Step 2: RAG-based impact on existing knowledge base documents.
|
||||||
|
|
||||||
|
Query = standard_code + title + first obligation texts.
|
||||||
|
Returns list of {doc_id, doc_name, score, key_clauses, recommendation}.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def compute_diff(self, old_text: str, new_text: str) -> dict:
|
||||||
|
"""Step 3: Semantic diff between old and new regulation text.
|
||||||
|
|
||||||
|
Splits both texts by paragraph. Calls existing EmbeddingService (text-embedding-v3
|
||||||
|
via EMBEDDING_BASE_URL) to embed each paragraph, then computes cosine similarity.
|
||||||
|
Changed paragraphs (cosine < 0.85) sent to LLM for change_type classification:
|
||||||
|
'tightened' | 'relaxed' | 'added' | 'removed'
|
||||||
|
Returns {changed_sections: [...], change_summary: str}.
|
||||||
|
Only called when content_hash differs from previous_hash.
|
||||||
|
"""
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.5 CrawlService
|
||||||
|
|
||||||
|
```python
|
||||||
|
# backend/app/application/perception/crawl_service.py
|
||||||
|
|
||||||
|
class CrawlService:
|
||||||
|
def __init__(self, crawlers, event_store, llm_pipeline, retrieval_service): ...
|
||||||
|
|
||||||
|
def run_crawl(self, sources: list[str] | None = None) -> Generator[dict, None, None]:
|
||||||
|
"""Manual-trigger crawl. Yields progress SSE dicts:
|
||||||
|
{event: 'progress', data: {source, fetched, new, updated, stage}}
|
||||||
|
{event: 'done', data: {total_new, total_updated, duration_ms}}
|
||||||
|
{event: 'error', data: {source, message}}
|
||||||
|
|
||||||
|
For each crawler:
|
||||||
|
1. fetch() RawEvents
|
||||||
|
2. hash check vs stored event → skip if unchanged
|
||||||
|
3. upsert raw event to DB
|
||||||
|
4. run LLM pipeline (extract → assess → diff)
|
||||||
|
5. upsert enriched event to DB
|
||||||
|
6. yield progress
|
||||||
|
"""
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. API Endpoints
|
||||||
|
|
||||||
|
### Existing (unchanged interface, new store backend)
|
||||||
|
- `GET /api/v1/perception/stats`
|
||||||
|
- `GET /api/v1/perception/events`
|
||||||
|
- `GET /api/v1/perception/events/{id}`
|
||||||
|
- `POST /api/v1/perception/events/{id}/analyze` (streaming)
|
||||||
|
|
||||||
|
### New endpoints
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/v1/perception/crawl
|
||||||
|
Body: { sources?: ["CATARC", "国标委", "EUR-Lex", "UN-ECE"] }
|
||||||
|
Response: text/event-stream (SSE)
|
||||||
|
Auth: requires current_user (admin/legal role)
|
||||||
|
Streams progress events until done or error.
|
||||||
|
|
||||||
|
POST /api/v1/perception/events/{id}/process
|
||||||
|
Trigger LLM pipeline for a single already-crawled event.
|
||||||
|
Response: { status: "ok", processed_at: "..." }
|
||||||
|
Auth: requires current_user
|
||||||
|
|
||||||
|
GET /api/v1/perception/events/{id}/diff
|
||||||
|
Returns: { changed_sections: [...], change_summary: str, previous_hash: str }
|
||||||
|
Returns 404 if no diff available (first crawl or no change detected).
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Frontend Changes
|
||||||
|
|
||||||
|
### 5.1 New: Crawl Control Bar (top of PerceptionPage)
|
||||||
|
|
||||||
|
Above the stats-bar, add a `<CrawlBar>` component:
|
||||||
|
- "刷新数据源" button — triggers `POST /crawl` (all sources)
|
||||||
|
- Inline progress display: shows SSE progress events as a mini status line
|
||||||
|
- e.g. "CATARC: 抓取中… | 国标委: 12 条新增 | EUR-Lex: 等待中"
|
||||||
|
- On completion: shows "更新完成 — 新增 N 条,更新 M 条"
|
||||||
|
- Disabled while crawl is in progress (prevents double-trigger)
|
||||||
|
|
||||||
|
### 5.2 Signal Card Enhancement
|
||||||
|
|
||||||
|
Existing cards get two new indicators:
|
||||||
|
- **NEW badge** — shown when `crawled_at` is within last 24h (green dot)
|
||||||
|
- **CHANGED badge** — shown when `previous_hash != content_hash` and `change_summary` exists
|
||||||
|
|
||||||
|
### 5.3 Right Panel — Structured Tab
|
||||||
|
|
||||||
|
Right detail panel adds a tab bar: **概览 | 义务条款 | 影响评估 | 变更对比**
|
||||||
|
|
||||||
|
**义务条款 tab:**
|
||||||
|
- Table: 义务描述 | 主体 | 对象 | 截止日期
|
||||||
|
- Tags for deontic type: 强制 / 禁止 / 允许
|
||||||
|
- Shows `obligations[]` + `deadlines[]` from DB
|
||||||
|
|
||||||
|
**影响评估 tab:**
|
||||||
|
- Replaces hardcoded MOCK_DOCS with real `affected_docs[]` from DB
|
||||||
|
- Each row: document name, similarity score (%), key clause excerpt, LLM recommendation
|
||||||
|
- "Run fresh assessment" button → triggers `POST /events/{id}/process`
|
||||||
|
|
||||||
|
**变更对比 tab:**
|
||||||
|
- Only visible when `change_summary` is non-null
|
||||||
|
- Top: `change_summary` text (LLM prose)
|
||||||
|
- Below: diff table with old/new paragraph pairs, change_type badge per row
|
||||||
|
- Hidden (tab disabled) on first-crawl events with no prior version
|
||||||
|
|
||||||
|
### 5.4 Existing behavior preserved
|
||||||
|
- `analyze` streaming (AI analysis) unchanged
|
||||||
|
- Search/filter (source, impact) unchanged — now hits real DB data
|
||||||
|
- Stats bar — now reflects real counts from PostgreSQL
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Settings Additions
|
||||||
|
|
||||||
|
```python
|
||||||
|
# backend/app/config/settings.py additions
|
||||||
|
perception_crawl_timeout_seconds: int = Field(default=120, ...)
|
||||||
|
perception_max_events_per_source: int = Field(default=100, ...)
|
||||||
|
perception_diff_similarity_threshold: float = Field(default=0.85, ...)
|
||||||
|
```
|
||||||
|
|
||||||
|
```env
|
||||||
|
# .env additions
|
||||||
|
PERCEPTION_CRAWL_TIMEOUT_SECONDS=120
|
||||||
|
PERCEPTION_MAX_EVENTS_PER_SOURCE=100
|
||||||
|
PERCEPTION_DIFF_SIMILARITY_THRESHOLD=0.85
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Dependencies
|
||||||
|
|
||||||
|
```
|
||||||
|
# requirements.txt additions
|
||||||
|
httpx>=0.27.0 # already likely present; confirm
|
||||||
|
beautifulsoup4>=4.12.0 # HTML parsing for CATARC
|
||||||
|
lxml>=5.0.0 # BeautifulSoup parser backend
|
||||||
|
# sentence-transformers NOT added — diff uses existing text-embedding-v3 API (EMBEDDING_BASE_URL)
|
||||||
|
```
|
||||||
|
|
||||||
|
No new infrastructure required (PostgreSQL + MinIO + Milvus already available).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Backward Compatibility
|
||||||
|
|
||||||
|
- `DOCUMENT_REPOSITORY_BACKEND=json` → `bootstrap.py` uses `MockEventStore` (unchanged behavior)
|
||||||
|
- `DOCUMENT_REPOSITORY_BACKEND=postgres` → uses `PostgresEventStore`
|
||||||
|
- Migration: run `CREATE TABLE` SQL on first startup (idempotent `CREATE TABLE IF NOT EXISTS`)
|
||||||
|
- Existing 20 mock events are not seeded to PostgreSQL; PostgreSQL starts empty until first crawl
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Out of Scope (this phase)
|
||||||
|
|
||||||
|
- Automatic/scheduled crawling (Celery Beat) — manual trigger only
|
||||||
|
- Playwright-based JS-rendered pages — all target sites work with httpx
|
||||||
|
- Knowledge Graph (Neo4j / LightRAG) — future phase
|
||||||
|
- Email/Slack webhook notifications — future phase
|
||||||
|
- User-facing diff history (versioning beyond one prior snapshot) — future phase
|
||||||
@@ -0,0 +1,459 @@
|
|||||||
|
# Compliance Analysis Enhancement Design
|
||||||
|
|
||||||
|
**Date:** 2026-06-08
|
||||||
|
**Directions:** A (Analysis Quality) + B (History & Reports) + C (Deep Chat)
|
||||||
|
**Approach:** Three independent but coordinated feature sets sharing one DB schema (method one / structured tables).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
1. **A — Analysis Quality:** Parallel clause processing (3-5× speed), fix `highlight_terms` bug (always returns empty), add LLM retry with tenacity, reserve `PassThroughReranker` for future cross-encoder work.
|
||||||
|
2. **B — Analysis History & Reports:** Auto-save every completed analysis to PostgreSQL, history rail in UI, per-record DOCX export, delete with confirmation.
|
||||||
|
3. **C — Deep Chat:** Per-finding persistent chat threads grounded in real retrieved text, LLM-generated suggestion questions, multi-turn memory.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
### Layering Rules (must not be violated)
|
||||||
|
|
||||||
|
```
|
||||||
|
api/routes/ → thin HTTP handlers, SSE generators only
|
||||||
|
application/ → orchestration logic (pipeline.py)
|
||||||
|
domain/ports/ → ABCs, no implementation
|
||||||
|
infrastructure/ → DB, docx, external calls
|
||||||
|
shared/bootstrap.py → composition root, wires everything
|
||||||
|
```
|
||||||
|
|
||||||
|
New business logic goes in `application/compliance/pipeline.py` and domain ports. Never in `services/*` or `workflows/*`.
|
||||||
|
|
||||||
|
### Shared Database Schema (B + C)
|
||||||
|
|
||||||
|
Three tables, created together so C's FK references are valid from day one:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE compliance_analyses (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
created_by VARCHAR(255),
|
||||||
|
doc_name VARCHAR(500),
|
||||||
|
standard_name VARCHAR(500),
|
||||||
|
risk_score INTEGER,
|
||||||
|
conclusion TEXT,
|
||||||
|
actions JSONB,
|
||||||
|
para_text TEXT,
|
||||||
|
highlight_terms JSONB
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE compliance_findings (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
analysis_id UUID NOT NULL REFERENCES compliance_analyses(id) ON DELETE CASCADE,
|
||||||
|
seq INTEGER NOT NULL,
|
||||||
|
title VARCHAR(500),
|
||||||
|
description TEXT,
|
||||||
|
status VARCHAR(50),
|
||||||
|
clause_ref VARCHAR(200)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE finding_chat_messages (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
analysis_id UUID NOT NULL REFERENCES compliance_analyses(id) ON DELETE CASCADE,
|
||||||
|
finding_id UUID NOT NULL REFERENCES compliance_findings(id) ON DELETE CASCADE,
|
||||||
|
role VARCHAR(20) NOT NULL, -- 'user' | 'assistant'
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Direction A — Analysis Quality
|
||||||
|
|
||||||
|
### A1: Parallel Clause Processing
|
||||||
|
|
||||||
|
**Current:** Route handler has a sequential `for i, clause in enumerate(clauses)` loop. Each iteration calls `retrieve_for_clause()` then `check_clause_compliance()` synchronously via `asyncio.to_thread`.
|
||||||
|
|
||||||
|
**Change:** Extract a `process_single_clause(clause, idx, ...) -> dict` function in `pipeline.py`, then replace the loop with `asyncio.gather`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def run_clauses_parallel(clauses, retrieval_svc, llm_client, standard_name, para_text):
|
||||||
|
tasks = [
|
||||||
|
asyncio.to_thread(process_single_clause, clause, i, retrieval_svc, llm_client, standard_name, para_text)
|
||||||
|
for i, clause in enumerate(clauses)
|
||||||
|
]
|
||||||
|
return await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
```
|
||||||
|
|
||||||
|
Results are yielded to the SSE stream in original order. Exceptions from individual clauses are caught and emitted as `{type: "error", clause_index: i}` events rather than crashing the whole stream.
|
||||||
|
|
||||||
|
### A2: Fix highlight_terms
|
||||||
|
|
||||||
|
**Root cause:** `synthesize_conclusion()` passes the LLM response through `json.loads()` but the LLM often wraps output in markdown fences (` ```json ... ``` `), causing a parse failure and silent fallback to `[]`.
|
||||||
|
|
||||||
|
**Fix in `pipeline.py`:**
|
||||||
|
|
||||||
|
```python
|
||||||
|
import re
|
||||||
|
|
||||||
|
def _extract_json(text: str) -> dict:
|
||||||
|
"""Strip markdown fences then parse JSON. Raises ValueError on failure."""
|
||||||
|
cleaned = re.sub(r"^```(?:json)?\s*|\s*```$", "", text.strip(), flags=re.MULTILINE)
|
||||||
|
return json.loads(cleaned)
|
||||||
|
```
|
||||||
|
|
||||||
|
Apply `_extract_json` in `synthesize_conclusion()` instead of bare `json.loads`. Wrap with `@retry` (see A3) so transient parse failures get a second attempt.
|
||||||
|
|
||||||
|
### A3: LLM Retry with tenacity
|
||||||
|
|
||||||
|
`tenacity` is already in `requirements.txt` but unused. Add to all LLM calls in `pipeline.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type
|
||||||
|
|
||||||
|
@retry(
|
||||||
|
stop=stop_after_attempt(3),
|
||||||
|
wait=wait_exponential(multiplier=1, min=1, max=4),
|
||||||
|
retry=retry_if_exception_type((httpx.HTTPError, ValueError)),
|
||||||
|
reraise=True,
|
||||||
|
)
|
||||||
|
def _call_llm_with_retry(client, prompt: str) -> str:
|
||||||
|
"""Call LLM and return raw text. Retries on HTTP errors and JSON parse failures."""
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
On final failure, the calling function catches and emits `{type: "error", text: "LLM call failed after 3 attempts"}` to the SSE stream.
|
||||||
|
|
||||||
|
### A4: PassThroughReranker (future-ready stub)
|
||||||
|
|
||||||
|
`domain/retrieval/ports.py` already defines a `Reranker` ABC. Add the no-op implementation:
|
||||||
|
|
||||||
|
**New file:** `backend/app/infrastructure/retrieval/reranker.py`
|
||||||
|
|
||||||
|
```python
|
||||||
|
from app.domain.retrieval.ports import Reranker, RetrievedChunk
|
||||||
|
|
||||||
|
class PassThroughReranker(Reranker):
|
||||||
|
"""No-op reranker. Replace with CrossEncoderReranker when a local model is available."""
|
||||||
|
|
||||||
|
def rerank(self, query: str, chunks: list[RetrievedChunk], top_k: int) -> list[RetrievedChunk]:
|
||||||
|
return chunks[:top_k]
|
||||||
|
```
|
||||||
|
|
||||||
|
Register in `shared/bootstrap.py` as the default `Reranker` implementation.
|
||||||
|
|
||||||
|
### A — Files Changed
|
||||||
|
|
||||||
|
| File | Action |
|
||||||
|
|------|--------|
|
||||||
|
| `backend/app/application/compliance/pipeline.py` | Add `process_single_clause`, `run_clauses_parallel`, `_extract_json`, `_call_llm_with_retry` |
|
||||||
|
| `backend/app/api/routes/compliance.py` | Replace sequential loop with `await run_clauses_parallel(...)` |
|
||||||
|
| `backend/app/infrastructure/retrieval/reranker.py` | New — `PassThroughReranker` |
|
||||||
|
| `backend/app/shared/bootstrap.py` | Register `PassThroughReranker` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Direction B — History & Reports
|
||||||
|
|
||||||
|
### B1: Domain Port
|
||||||
|
|
||||||
|
**New file:** `backend/app/domain/compliance/ports.py`
|
||||||
|
|
||||||
|
```python
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FindingRecord:
|
||||||
|
id: str
|
||||||
|
analysis_id: str
|
||||||
|
seq: int
|
||||||
|
title: str
|
||||||
|
description: str
|
||||||
|
status: str
|
||||||
|
clause_ref: Optional[str] = None
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AnalysisRecord:
|
||||||
|
id: str
|
||||||
|
created_at: datetime
|
||||||
|
created_by: Optional[str]
|
||||||
|
doc_name: str
|
||||||
|
standard_name: str
|
||||||
|
risk_score: int
|
||||||
|
conclusion: str
|
||||||
|
actions: list
|
||||||
|
para_text: str
|
||||||
|
highlight_terms: list
|
||||||
|
findings: list[FindingRecord] = field(default_factory=list)
|
||||||
|
|
||||||
|
class ComplianceRepository(ABC):
|
||||||
|
@abstractmethod
|
||||||
|
def save_analysis(self, record: AnalysisRecord) -> str: ...
|
||||||
|
@abstractmethod
|
||||||
|
def list_analyses(self, limit: int = 50, offset: int = 0) -> list[AnalysisRecord]: ...
|
||||||
|
@abstractmethod
|
||||||
|
def get_analysis(self, analysis_id: str) -> Optional[AnalysisRecord]: ...
|
||||||
|
@abstractmethod
|
||||||
|
def delete_analysis(self, analysis_id: str) -> None: ...
|
||||||
|
@abstractmethod
|
||||||
|
def save_message(self, analysis_id: str, finding_id: str, role: str, content: str) -> str: ...
|
||||||
|
@abstractmethod
|
||||||
|
def get_messages(self, finding_id: str) -> list[dict]: ...
|
||||||
|
```
|
||||||
|
|
||||||
|
### B2: PostgresComplianceRepository
|
||||||
|
|
||||||
|
**New file:** `backend/app/infrastructure/compliance/repository.py`
|
||||||
|
|
||||||
|
Implements `ComplianceRepository` using `psycopg2` (already in requirements). Connection string from `settings.DATABASE_URL`. Key methods:
|
||||||
|
|
||||||
|
- `save_analysis`: INSERT into `compliance_analyses`, then bulk INSERT findings into `compliance_findings`, return `analysis_id` (UUID string).
|
||||||
|
- `list_analyses`: SELECT with JOIN on findings count, ORDER BY `created_at DESC`, supports limit/offset.
|
||||||
|
- `get_analysis`: SELECT analysis + all findings by `analysis_id`.
|
||||||
|
- `delete_analysis`: DELETE cascades to findings and chat messages via FK.
|
||||||
|
- `save_message` / `get_messages`: INSERT/SELECT on `finding_chat_messages`.
|
||||||
|
|
||||||
|
Uses a connection pool (simple `psycopg2.pool.ThreadedConnectionPool`, min=1, max=5).
|
||||||
|
|
||||||
|
### B3: Auto-save Hook
|
||||||
|
|
||||||
|
In the SSE generator in `compliance.py`, after the `done` event is assembled:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# After yielding the done event
|
||||||
|
if repo is not None:
|
||||||
|
record = AnalysisRecord(
|
||||||
|
id="", # will be assigned by DB
|
||||||
|
created_at=datetime.utcnow(),
|
||||||
|
created_by=current_user,
|
||||||
|
doc_name=doc_name,
|
||||||
|
standard_name=standard_name,
|
||||||
|
risk_score=done_payload["risk_score"],
|
||||||
|
conclusion=done_payload["conclusion"],
|
||||||
|
actions=done_payload["actions"],
|
||||||
|
para_text=done_payload["para_text"],
|
||||||
|
highlight_terms=done_payload["highlight_terms"],
|
||||||
|
findings=[FindingRecord(...) for f in accumulated_findings],
|
||||||
|
)
|
||||||
|
analysis_id = await asyncio.to_thread(repo.save_analysis, record)
|
||||||
|
# Emit an extra SSE event so frontend receives the analysis_id
|
||||||
|
yield f"data: {json.dumps({'type': 'saved', 'analysis_id': analysis_id})}\n\n"
|
||||||
|
```
|
||||||
|
|
||||||
|
### B4: New API Endpoints
|
||||||
|
|
||||||
|
Added to `backend/app/api/routes/compliance.py`:
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/compliance/history
|
||||||
|
Query params: limit=20&offset=0
|
||||||
|
Response: [{id, created_at, doc_name, standard_name, risk_score, finding_count}]
|
||||||
|
|
||||||
|
GET /api/v1/compliance/history/{analysis_id}
|
||||||
|
Response: full AnalysisRecord including findings list
|
||||||
|
|
||||||
|
DELETE /api/v1/compliance/history/{analysis_id}
|
||||||
|
Response: 204 No Content
|
||||||
|
|
||||||
|
GET /api/v1/compliance/history/{analysis_id}/download
|
||||||
|
Response: DOCX file (application/vnd.openxmlformats-officedocument.wordprocessingml.document)
|
||||||
|
```
|
||||||
|
|
||||||
|
### B5: DOCX Export
|
||||||
|
|
||||||
|
**New file:** `backend/app/infrastructure/compliance/docx_export.py`
|
||||||
|
|
||||||
|
Uses `python-docx` (already in requirements). Generates a structured report:
|
||||||
|
|
||||||
|
- Cover: document name, standard, date, risk score badge
|
||||||
|
- Executive summary: conclusion paragraph
|
||||||
|
- Findings table: seq / title / status / clause_ref / description
|
||||||
|
- Action items: numbered list
|
||||||
|
- Footer: generated by AI Regulation Analysis System
|
||||||
|
|
||||||
|
```python
|
||||||
|
def generate_docx(record: AnalysisRecord) -> bytes:
|
||||||
|
"""Generate a DOCX compliance report and return as bytes."""
|
||||||
|
doc = Document()
|
||||||
|
# ... build document ...
|
||||||
|
buf = BytesIO()
|
||||||
|
doc.save(buf)
|
||||||
|
return buf.getvalue()
|
||||||
|
```
|
||||||
|
|
||||||
|
### B6: Frontend — History Rail
|
||||||
|
|
||||||
|
`CompliancePage.tsx` gains a left rail (same layout pattern as RagChat's `history-pane`):
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────┬─────────────────────────────────┐
|
||||||
|
│ History │ Main Analysis Area │
|
||||||
|
│ ────────── │ │
|
||||||
|
│ 2026-06-08 │ (current analysis or loaded │
|
||||||
|
│ doc.pdf │ read-only historical record) │
|
||||||
|
│ ⚠ 72 [↓][×]│ │
|
||||||
|
│ ────────── │ │
|
||||||
|
│ 2026-06-07 │ │
|
||||||
|
│ csms.pdf │ │
|
||||||
|
│ ✓ 15 [↓][×]│ │
|
||||||
|
└──────────────┴─────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- `[↓]` triggers `GET /history/{id}/download` and saves the DOCX file
|
||||||
|
- `[×]` shows a confirmation dialog, then calls `DELETE /history/{id}`
|
||||||
|
- Clicking a row loads that analysis into the main area in read-only mode
|
||||||
|
- `PageStateContext.ComplianceState` gains `analysisId: string | null` and `isReadOnly: boolean`
|
||||||
|
|
||||||
|
On mount, the rail calls `GET /history?limit=20` to populate the list. The list re-fetches after delete or after a new analysis completes (triggered by the `saved` SSE event).
|
||||||
|
|
||||||
|
### B — Files Changed
|
||||||
|
|
||||||
|
| File | Action |
|
||||||
|
|------|--------|
|
||||||
|
| `backend/app/domain/compliance/ports.py` | New — `ComplianceRepository` ABC + data classes |
|
||||||
|
| `backend/app/infrastructure/compliance/repository.py` | New — `PostgresComplianceRepository` |
|
||||||
|
| `backend/app/infrastructure/compliance/docx_export.py` | New — `generate_docx()` |
|
||||||
|
| `backend/app/api/routes/compliance.py` | Add history endpoints + auto-save hook |
|
||||||
|
| `backend/app/shared/bootstrap.py` | Register `PostgresComplianceRepository` |
|
||||||
|
| `frontend/src/pages/Compliance/CompliancePage.tsx` | Add History Rail |
|
||||||
|
| `frontend/src/contexts/PageStateContext.tsx` | Add `analysisId`, `isReadOnly` to `ComplianceState` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Direction C — Deep Chat
|
||||||
|
|
||||||
|
### C1: New Chat Endpoints
|
||||||
|
|
||||||
|
Replace the existing `/compliance/chat/{segment_id}` (kept for backward compatibility but deprecated) with finding-scoped endpoints:
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/v1/compliance/analyses/{analysis_id}/findings/{finding_id}/chat
|
||||||
|
Body: {query: string}
|
||||||
|
Response: SSE stream — chunk / done / error events
|
||||||
|
|
||||||
|
GET /api/v1/compliance/analyses/{analysis_id}/findings/{finding_id}/chat
|
||||||
|
Response: [{id, role, content, created_at}]
|
||||||
|
|
||||||
|
POST /api/v1/compliance/analyses/{analysis_id}/findings/{finding_id}/suggestions
|
||||||
|
Response: {questions: [string, string, string]}
|
||||||
|
```
|
||||||
|
|
||||||
|
### C2: Grounded Context Construction
|
||||||
|
|
||||||
|
New function in `pipeline.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def build_finding_context(finding: FindingRecord, analysis: AnalysisRecord) -> str:
|
||||||
|
"""
|
||||||
|
Build a grounded system context string for a finding chat thread.
|
||||||
|
Combines finding details with analysis metadata for LLM grounding.
|
||||||
|
"""
|
||||||
|
return (
|
||||||
|
f"Document: {analysis.doc_name}\n"
|
||||||
|
f"Standard: {analysis.standard_name}\n"
|
||||||
|
f"Finding [{finding.seq}]: {finding.title}\n"
|
||||||
|
f"Status: {finding.status}\n"
|
||||||
|
f"Clause reference: {finding.clause_ref or 'N/A'}\n"
|
||||||
|
f"Description: {finding.description}\n"
|
||||||
|
f"Overall conclusion: {analysis.conclusion}\n"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
This string is prepended to the system prompt for every chat call — replacing the fragile `segment_context` approach.
|
||||||
|
|
||||||
|
### C3: Multi-turn Context
|
||||||
|
|
||||||
|
Chat handler fetches existing messages from `finding_chat_messages` via `repo.get_messages(finding_id)` and prepends them to the LLM call as `[{"role": "user"/"assistant", "content": "..."}]` message history. Max history: 10 most recent messages (5 turns) to avoid token overflow.
|
||||||
|
|
||||||
|
After each LLM response, both the user message and assistant message are saved via `repo.save_message()`.
|
||||||
|
|
||||||
|
### C4: Suggestion Generation
|
||||||
|
|
||||||
|
New function in `pipeline.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
SUGGESTION_PROMPTS = {
|
||||||
|
"non_compliant": "Generate 3 questions focused on remediation steps and timeline.",
|
||||||
|
"partial": "Generate 3 questions focused on identifying the compliance gap.",
|
||||||
|
"compliant": "Generate 3 questions focused on maintaining and evidencing compliance.",
|
||||||
|
}
|
||||||
|
|
||||||
|
def generate_suggestions(finding: FindingRecord, analysis: AnalysisRecord, llm_client) -> list[str]:
|
||||||
|
"""
|
||||||
|
Generate 3 context-aware follow-up questions for a finding chat thread.
|
||||||
|
Returns a list of 3 question strings. Falls back to generic questions on error.
|
||||||
|
"""
|
||||||
|
focus = SUGGESTION_PROMPTS.get(finding.status, SUGGESTION_PROMPTS["partial"])
|
||||||
|
context = build_finding_context(finding, analysis)
|
||||||
|
prompt = f"{context}\n\n{focus}\nReturn JSON: {{\"questions\": [\"...\", \"...\", \"...\"]}}"
|
||||||
|
# ... call LLM, parse JSON, return list ...
|
||||||
|
# Fallback on error:
|
||||||
|
return ["What are the specific requirements?", "What is the remediation timeline?", "Which regulation clause applies?"]
|
||||||
|
```
|
||||||
|
|
||||||
|
### C5: Frontend — Finding Chat Drawer
|
||||||
|
|
||||||
|
New component: `frontend/src/pages/Compliance/FindingChatDrawer.tsx`
|
||||||
|
|
||||||
|
Drawer slides in from the right (CSS: `position: fixed; right: 0; width: 420px`), reusing existing CSS variables (`--surface`, `--border`, `--accent`).
|
||||||
|
|
||||||
|
Structure:
|
||||||
|
- Header: finding title + close button
|
||||||
|
- Suggestions section: 3 chip buttons (only shown before first user message; hidden after)
|
||||||
|
- Message list: scrollable, same bubble style as RagChat
|
||||||
|
- Composer: textarea + send button, same pattern as RagChat composer
|
||||||
|
|
||||||
|
State managed in `PageStateContext.ComplianceState`:
|
||||||
|
- `activeFindingId: string | null` — which finding's drawer is open
|
||||||
|
- Drawer open/close controlled by `activeFindingId !== null`
|
||||||
|
|
||||||
|
On open:
|
||||||
|
1. `GET /analyses/{id}/findings/{fid}/chat` → restore history
|
||||||
|
2. If history is empty: `POST /findings/{fid}/suggestions` → show chips
|
||||||
|
|
||||||
|
Each finding card in `CompliancePage.tsx` gains a `💬 Chat` button that sets `activeFindingId`.
|
||||||
|
|
||||||
|
### C — Files Changed
|
||||||
|
|
||||||
|
| File | Action |
|
||||||
|
|------|--------|
|
||||||
|
| `backend/app/api/routes/compliance.py` | Add 3 new finding-chat endpoints |
|
||||||
|
| `backend/app/application/compliance/pipeline.py` | Add `build_finding_context`, `generate_suggestions` |
|
||||||
|
| `backend/app/infrastructure/compliance/repository.py` | Add `save_message`, `get_messages` (already in port) |
|
||||||
|
| `frontend/src/pages/Compliance/FindingChatDrawer.tsx` | New component |
|
||||||
|
| `frontend/src/pages/Compliance/CompliancePage.tsx` | Add Chat button to finding cards, render drawer |
|
||||||
|
| `frontend/src/contexts/PageStateContext.tsx` | Add `activeFindingId` to `ComplianceState` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Order
|
||||||
|
|
||||||
|
Direction A must be completed first (parallel processing changes the route handler that B's auto-save hook attaches to). B must be completed before C (C's FK references require B's tables and repository).
|
||||||
|
|
||||||
|
```
|
||||||
|
A (parallel + bug fixes + reranker stub)
|
||||||
|
└→ B (schema migration + history + DOCX)
|
||||||
|
└→ C (finding chat + suggestions)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- PDF export (DOCX only; users convert via Word/WPS)
|
||||||
|
- Cross-encoder reranking (stub reserved, not implemented)
|
||||||
|
- Scheduled/automatic crawling
|
||||||
|
- User-level history isolation (all users share history — global visibility)
|
||||||
|
- Prompt version management or A/B testing
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- Backend comments and docstrings: English only
|
||||||
|
- No new top-level libraries beyond those already in `requirements.txt` (`tenacity`, `python-docx`, `psycopg2-binary` are all present)
|
||||||
|
- `DOCUMENT_REPOSITORY_BACKEND=postgres` → `PostgresComplianceRepository`; any other value → raise `NotImplementedError` with a clear message (no mock fallback for compliance history)
|
||||||
|
- Git commits are made by the user, never automated
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user