Refactor code structure for improved readability and maintainability

This commit is contained in:
ash66
2026-05-14 18:09:15 +08:00
parent 10d04c4083
commit 35cd927d02
105 changed files with 9043 additions and 7720 deletions

59
.gitignore vendored Normal file
View File

@@ -0,0 +1,59 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# Distribution / packaging
build/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
*.egg
MANIFEST
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
.coverage
.coverage.*
.pytest_cache/
.mypy_cache/
.ruff_cache/
.pyre/
.hypothesis/
htmlcov/
coverage.xml
nosetests.xml
*.cover
*.py,cover
# Environments
.env
.env.*
.venv/
venv/
env/
ENV/
# Type checker / notebook / tool state
.python-version
.ipynb_checkpoints/
# IDEs and editors
.idea/
.vscode/
# OS files
.DS_Store
Thumbs.db

39
AGENTS.md Normal file
View File

@@ -0,0 +1,39 @@
# AGENTS.md
## Scope
- This repo uses `backend/app/` for the backend and `frontend/` for the Vite React app.
## Real Entrypoints
- Current backend app entrypoint is `backend/app/main.py`, exporting `app` from `app.api.main`.
- Current backend dev start command is `python -m uvicorn app.main:app --reload` with `PYTHONPATH=backend`.
- `dev.sh start api --foreground` is the repo script flow that encodes the expected backend startup behavior.
- Frontend dev server is `frontend` Vite on port `5173`, proxying `/api` to `http://localhost:8000`.
## Commands
- Backend install: `pip install -r backend/requirements.txt`
- Backend run from repo root: `PYTHONPATH=backend uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload`
- Frontend install: `cd frontend && npm install`
- Frontend dev: `cd frontend && npm run dev`
- Frontend build: `cd frontend && npm run build`
- Frontend lint: `cd frontend && npm run lint`
## Infra And Env
- Backend settings load from root `.env`, not `backend/.env`, because `backend/app/config/settings.py` uses `env_file = ".env"`.
- Docker infra is defined in `docker/docker-compose.yml`; it starts Milvus, MinIO, Redis, and PostgreSQL.
- Default local service ports: Milvus `19530`, MinIO `9000/9001`, Redis `6379`, PostgreSQL `5432`, backend `8000`, frontend `5173`.
- LLM base URLs in `.env.example` point at a shared remote gateway (`http://6.86.80.4:30080/v1`); do not assume offline/local-only LLM execution.
## Verification
- Root pytest config in `pyproject.toml` points at root `tests/`, and those tests import from `backend/app` via `PYTHONPATH` setup.
- For frontend-only changes, run `npm run lint` and `npm run build` in `frontend`.
- For backend changes, prefer focused import/startup verification against `backend/app`, and run root `tests/` when the environment supports it.
## Gotchas
- Backend settings load from root `.env`, not `backend/.env`, because `backend/app/config/settings.py` uses `env_file = ".env"`.
- The root `pyproject.toml` is the active Python package manifest for the repo.

View File

@@ -71,11 +71,11 @@ docker ps
## 三、安装Python依赖
### 方式A使用快速启动脚本(推荐)
### 方式A使用统一初始化脚本(推荐)
```bash
chmod +x quick_start.sh
./quick_start.sh
chmod +x dev.sh
./dev.sh setup
```
脚本自动完成:
@@ -91,7 +91,7 @@ python3 -m venv .venv
source .venv/bin/activate
# 安装依赖
pip install -r requirements.txt
pip install -r backend/requirements.txt
```
---
@@ -115,39 +115,42 @@ python -c "from modelscope import snapshot_download; snapshot_download('Xorbits/
## 五、启动服务
### 整合启动脚本(推荐)
### 统一命令入口(推荐)
```bash
# 赋予脚本执行权限
chmod +x start_all.sh stop_all.sh restart_all.sh status.sh
chmod +x dev.sh
# 启动所有服务API + 前端)
./start_all.sh
./dev.sh start
# 查看服务状态
./status.sh
./dev.sh status
# 重启所有服务
./restart_all.sh
./dev.sh restart
# 停止所有服务
./stop_all.sh
./dev.sh stop
```
### 单独启动(可选)
```bash
# 仅启动API服务前台运行可调试
./start_api.sh
./dev.sh start api --foreground
# 仅启动API服务后台运行
./start_api_background.sh
./dev.sh start api
# 仅停止API服务
./stop_api.sh
./dev.sh stop api
# 仅启动前端服务
./start_frontend.sh
# 仅启动前端服务Vite 开发模式)
./dev.sh start frontend --mode dev
# 仅启动前端服务(静态构建模式)
./dev.sh start frontend --mode static
```
---
@@ -161,9 +164,9 @@ chmod +x start_all.sh stop_all.sh restart_all.sh status.sh
| **API服务** | http://localhost:8000 |
| **API文档** | http://localhost:8000/docs |
| **健康检查** | http://localhost:8000/health |
| **前端测试页面** | http://localhost:3000 |
| **前端测试页面** | http://localhost:5173 |
> 注意:前端测试页面通过 `http://localhost:3000` 访问,自动连接到API服务。
> 注意:前端默认通过 `http://localhost:5173` 访问,自动代理到 API 服务。
---
@@ -221,24 +224,46 @@ curl -X POST http://localhost:8000/api/v1/agent/chat \
| 操作 | 命令 |
|------|------|
| **启动所有服务** | `./start_all.sh` |
| **停止所有服务** | `./stop_all.sh` |
| **重启所有服务** | `./restart_all.sh` |
| **查看服务状态** | `./status.sh` |
| **启动所有服务** | `./dev.sh start` |
| **停止所有服务** | `./dev.sh stop` |
| **重启所有服务** | `./dev.sh restart` |
| **查看服务状态** | `./dev.sh status` |
| 查看API日志 | `tail -f logs/api.log` |
| 查看前端日志 | `tail -f logs/frontend.log` |
| 环境初始化 | `./quick_start.sh` |
| 环境初始化 | `./dev.sh setup` |
| 前台调试 API | `./dev.sh start api --foreground` |
| 静态模式启动前端 | `./dev.sh start frontend --mode static` |
| 重启Docker | `cd docker && docker-compose restart` |
| 下载嵌入模型 | `./download_model.sh` |
---
## 九、服务状态检查
## 九、Linux / Windows 命令对照表
| 操作 | Linux / macOS | Windows |
|------|---------------|---------|
| 环境初始化 | `./dev.sh setup` | `dev.bat setup` |
| 启动所有服务 | `./dev.sh start` | `dev.bat start` |
| 停止所有服务 | `./dev.sh stop` | `dev.bat stop` |
| 重启所有服务 | `./dev.sh restart` | `dev.bat restart` |
| 查看服务状态 | `./dev.sh status` | `dev.bat status` |
| 前台调试 API | `./dev.sh start api --foreground` | `dev.bat start api --foreground` |
| 后台启动 API | `./dev.sh start api` | `dev.bat start api` |
| 启动前端开发模式 | `./dev.sh start frontend --mode dev` | `dev.bat start frontend --mode dev` |
| 启动前端静态模式 | `./dev.sh start frontend --mode static` | `dev.bat start frontend --mode static` |
| 查看 API 日志 | `./dev.sh logs api --follow` | `dev.bat logs api --follow` |
| 查看前端日志 | `./dev.sh logs frontend --follow` | `dev.bat logs frontend --follow` |
> Linux / macOS 首次使用前建议先执行:`chmod +x dev.sh`
---
## 十、服务状态检查
运行状态检查脚本:
```bash
./status.sh
./dev.sh status
```
输出示例:
@@ -256,7 +281,7 @@ API服务:
前端服务:
状态: 运行中 ✓
PID: 12346
地址: http://localhost:3000
地址: http://localhost:5173
Docker服务:
milvus: 运行中 ✓
@@ -271,7 +296,7 @@ Docker服务:
---
## 十、常见问题
## 十、常见问题
### Q1: Milvus连接失败
@@ -334,49 +359,30 @@ rm -f logs/*.pid
---
## 十、目录结构
## 十、目录结构
```
```text
Demo-glm/
├── src/
│ ├── api/ # FastAPI接口
│ ├── main.py # API入口
│ └── routes/
│ │ ├── documents.py # 文档上传
│ │ ├── knowledge.py # 知识检索
│ │ └── agent.py # 智能问答
│ ├── services/ # 核心服务
│ │ ├── llm/ # LLM调用Qwen/DeepSeek
│ │ ├── rag/ # RAG检索
│ │ ├── agent/ # 问答Agent
│ │ ├── parser/ # 文档解析
│ │ ├── embedding/ # 向量嵌入BGE-M3
│ │ └── storage/ # Milvus存储
│ └── config/ # 配置管理
├── frontend/ # 前端测试页面
│ └── index.html # 测试界面
├── docker/ # Docker配置
├── backend/
│ ├── app/ # FastAPI 后端代码
│ ├── requirements.txt # Python 依赖
└── main.py # 后端启动入口
├── frontend/ # Vite React 前端
├── docker/ # Docker 配置
│ └── docker-compose.yml
├── logs/ # 运行日志
│ ├── api.log
│ └── frontend.log
├── tests/ # 测试脚本
├── tests/ # 根级测试脚本
├── .env # 环境配置
├── .env.example # 配置模板
├── requirements.txt # Python依赖
├── quick_start.sh # 环境初始化脚本
├── start_all.sh # 整合启动脚本
├── stop_all.sh # 整合停止脚本
├── restart_all.sh # 重启脚本
├── status.sh # 状态检查脚本
├── start_api.sh # 单独启动API
├── start_frontend.sh # 单独启动前端
├── pyproject.toml # 根级 Python 项目配置
├── dev.sh # Linux/macOS 统一入口
├── dev.bat # Windows 统一入口
└── QUICK_DEPLOY.md # 本文档
```
---
## 十、API接口清单
## 十、API接口清单
| 接口 | 路径 | 方法 | 功能 |
|------|------|------|------|
@@ -419,4 +425,4 @@ Demo-glm/
## 技术支持
- API文档http://localhost:8000/docs
- 问题反馈提交Issue到项目仓库
- 问题反馈提交Issue到项目仓库

View File

@@ -14,38 +14,20 @@
## 项目结构
```
Demo-glm/
├── src/
│ ├── api/ # FastAPI接口层
│ │ ├── main.py # API入口
│ │ ├── routes/
│ │ │ ├── documents.py # 文档上传接口
│ │ │ └── knowledge.py # 知识库检索接口
│ └── models/
│ └── document.py # Pydantic数据模型
│ ├── services/
├── parser/ # 文档解析服务
│ │ │ ├── pdf_parser.py # PDF解析PyMuPDF
│ │ │ ├── docx_parser.py # Word解析
│ │ │ └── mineru_parser.py # MinerU多模态解析
│ │ ├── embedding/ # 嵌入服务
│ │ │ ├── text_chunker.py # 智能分块器
│ │ │ └── bge_m3_embedder.py # BGE-M3嵌入
│ │ ├── storage/
│ │ │ └── milvus_client.py # Milvus客户端
│ │ └── document_processor.py # 文档处理主流程
│ └── config/
│ │ ├── settings.py # 配置管理
│ │ └── logging.py # 日志配置
├── tests/
│ ├── test_parser.py # 解析测试
│ ├── test_embedding.py # 嵌入测试
│ ├── test_milvus.py # Milvus测试
│ └── verify_mvp.py # MVP验证脚本
```text
AIRegulation-DocAnalysis-Demo/
├── backend/
│ ├── app/
│ │ ├── api/ # FastAPI 接口层
│ │ ├── config/ # 配置与日志
│ │ ├── services/ # 解析、分块、嵌入、存储、Agent
│ │ └── workers/
├── requirements.txt
└── main.py
├── frontend/ # Vite React 前端
├── tests/ # 根级测试,导入 backend/app
├── docker/
│ └── docker-compose.yml # Milvus/MinIO部署
├── requirements.txt
│ └── docker-compose.yml
├── pyproject.toml
└── .env.example
```
@@ -55,7 +37,7 @@ Demo-glm/
### 1. 安装依赖
```bash
pip install -r requirements.txt
pip install -r backend/requirements.txt
```
### 2. 启动Milvus向量数据库
@@ -76,10 +58,12 @@ docker-compose logs -f milvus
python tests/verify_mvp.py
```
根级测试脚本会自动把 `backend/` 加入导入路径,并从 `app.*` 加载当前后端代码。
### 4. 启动API服务
```bash
uvicorn src.api.main:app --reload --port 8000
PYTHONPATH=backend uvicorn app.main:app --reload --port 8000
```
访问API文档http://localhost:8000/docs
@@ -140,4 +124,4 @@ CHUNK_SIZE=512
## 许可证
MIT License
MIT License

View File

@@ -1,6 +1,6 @@
# AI+合规智能中枢后端
`backend` 已承接原 `src` 的完整 FastAPI 后端能力,当前正式入口为 `app.main:app`
`backend` 是当前正式使用的 FastAPI 后端目录,入口为 `app.main:app`
## 启动
@@ -9,10 +9,10 @@ pip install -r backend/requirements.txt
PYTHONPATH=backend uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
```
也可以直接使用根目录脚本:
也可以直接使用根目录统一脚本:
```bash
./start_api.sh
./dev.sh start api --foreground
```
## 主要接口
@@ -45,6 +45,5 @@ backend/
## 说明
- `backend/app/api/main.py` 来自原 `src/api/main.py`,已切换为 `app.*` 导入。
- 路由前缀保持为 `/api/v1`,以兼容当前前端。
-`backend/app/api/routes/docs.py``rag.py``compliance.py``status.py` 仍保留在仓库中,但不再作为主路由入口。

View File

@@ -1,2 +1 @@
# src/api/__init__.py
"""API接口模块"""
"""API接口模块"""

View File

@@ -1,4 +1,3 @@
# src/api/models/__init__.py
"""API数据模型"""
from .document import (
@@ -19,4 +18,4 @@ __all__ = [
"SearchResponse",
"DocumentStatusResponse",
"ErrorResponse"
]
]

View File

@@ -1,4 +1,3 @@
# src/api/models/document.py
"""文档相关Pydantic数据模型"""
from pydantic import BaseModel, Field
@@ -60,4 +59,4 @@ class ErrorResponse(BaseModel):
"""错误响应"""
error: str = Field(..., description="错误类型")
message: str = Field(..., description="错误消息")
timestamp: datetime = Field(default_factory=datetime.now, description="时间戳")
timestamp: datetime = Field(default_factory=datetime.now, description="时间戳")

View File

@@ -1,4 +1,3 @@
# src/api/routes/__init__.py
"""API路由模块"""
from fastapi import APIRouter
@@ -14,4 +13,4 @@ api_router.include_router(documents_router)
api_router.include_router(knowledge_router)
api_router.include_router(agent_router)
__all__ = ["api_router", "documents_router", "knowledge_router", "agent_router"]
__all__ = ["api_router", "documents_router", "knowledge_router", "agent_router"]

View File

@@ -1,4 +1,3 @@
# src/api/routes/agent.py
"""Agent API接口 - 问答对话接口"""
from fastapi import APIRouter, HTTPException, Depends

View File

@@ -1,4 +1,3 @@
# src/api/routes/documents.py
"""文档上传与处理接口"""
from fastapi import APIRouter, UploadFile, File, Form, HTTPException

View File

@@ -1,4 +1,3 @@
# src/api/routes/knowledge.py
"""知识库检索接口"""
from fastapi import APIRouter, HTTPException

View File

@@ -1,4 +1,3 @@
# src/config/__init__.py
"""配置模块"""
from .settings import Settings, get_settings, settings

View File

@@ -1,4 +1,3 @@
# src/config/logging.py
"""日志配置"""
from loguru import logger
@@ -29,4 +28,4 @@ def setup_logging(level: str = "INFO"):
compression="zip"
)
return logger
return logger

View File

@@ -1,4 +1,3 @@
# src/config/settings.py
"""配置管理 - 环境变量和默认配置"""
from pydantic_settings import BaseSettings

View File

@@ -1,7 +1,6 @@
# src/services/agent/__init__.py
"""Agent服务模块"""
from .qa_agent import QAAgent, ask_compliance_question
from .session_manager import SessionManager, ChatSession
__all__ = ["QAAgent", "ask_compliance_question", "SessionManager", "ChatSession"]
__all__ = ["QAAgent", "ask_compliance_question", "SessionManager", "ChatSession"]

View File

@@ -1,4 +1,3 @@
# src/services/agent/qa_agent.py
"""RAG问答Agent - 合规智能问答核心实现"""
import time

View File

@@ -1,4 +1,3 @@
# src/services/agent/session_manager.py
"""多轮对话会话管理"""
import time
@@ -244,4 +243,4 @@ class SessionManager:
def clear_all_sessions(self):
"""清空所有会话"""
self._sessions.clear()
logger.info("所有会话已清空")
logger.info("所有会话已清空")

View File

@@ -1,4 +1,3 @@
# src/services/document_processor.py
"""文档处理主流程 - 解析→摘要→分块→嵌入→入库"""
import os

View File

@@ -1,7 +1,6 @@
# src/services/embedding/__init__.py
"""嵌入和分块服务"""
from .text_chunker import RegulationChunker
from .bge_m3_embedder import BGEM3Embedder
__all__ = ["RegulationChunker", "BGEM3Embedder"]
__all__ = ["RegulationChunker", "BGEM3Embedder"]

View File

@@ -1,4 +1,3 @@
# src/services/embedding/bge_m3_embedder.py
"""BGE-M3嵌入服务 - Dense+Sparse双路向量生成"""
import numpy as np
@@ -293,4 +292,4 @@ def embed_single_text(
) -> Dict:
"""便捷函数:对单个文本生成嵌入"""
embedder = BGEM3Embedder(model_name=model_name, **kwargs)
return embedder.embed_single(text)
return embedder.embed_single(text)

View File

@@ -1,4 +1,3 @@
# src/services/embedding/text_chunker.py
"""智能分块器 - 章节级+条款级双粒度切割"""
import re
@@ -446,4 +445,4 @@ def chunk_regulation_document(
doc_name,
regulation_type,
version
)
)

View File

@@ -1,4 +1,3 @@
# src/services/llm/__init__.py
"""LLM服务模块"""
from .llm_factory import LLMFactory, get_llm_client
@@ -12,4 +11,4 @@ __all__ = [
"BaseLLMClient", "LLMResponse", "LLMConfig", "LLMProvider",
"DeepSeekClient", "QwenClient", "QwenVLClient",
"DocumentSummarizer", "summarize_document", "DocumentSummary"
]
]

View File

@@ -1,4 +1,3 @@
# src/services/llm/base_client.py
"""LLM客户端基类 - 统一接口定义"""
from abc import ABC, abstractmethod
@@ -113,4 +112,4 @@ class BaseLLMClient(ABC):
# 中文字符约1.5 token英文约0.25 token
chinese_chars = sum(1 for c in text if '' <= c <= '鿿')
other_chars = len(text) - chinese_chars
return int(chinese_chars * 1.5 + other_chars * 0.25)
return int(chinese_chars * 1.5 + other_chars * 0.25)

View File

@@ -1,4 +1,3 @@
# src/services/llm/deepseek_client.py
"""DeepSeek LLM客户端 - OpenAI兼容API"""
import time

View File

@@ -1,4 +1,3 @@
# src/services/llm/document_summarizer.py
"""文档摘要生成服务 - LLM生成法规文档摘要"""
from typing import Dict, Optional

View File

@@ -1,4 +1,3 @@
# src/services/llm/llm_factory.py
"""LLM工厂 - 统一创建和管理LLM客户端"""
from typing import Optional, Dict, Any

View File

@@ -1,4 +1,3 @@
# src/services/llm/qwen_client.py
"""Qwen LLM客户端 - 支持OpenAI兼容API格式"""
import time

View File

@@ -1,7 +1,6 @@
# src/services/parser/__init__.py
"""文档解析服务"""
from .pdf_parser import PDFParser
from .docx_parser import DocxParser
__all__ = ["PDFParser", "DocxParser"]
__all__ = ["PDFParser", "DocxParser"]

View File

@@ -1,4 +1,3 @@
# src/services/parser/docx_parser.py
"""Word文档解析 - 使用python-docx"""
from docx import Document
@@ -284,4 +283,4 @@ def parse_docx(file_path: str) -> DocxDocumentContent:
def parse_docx_to_markdown(file_path: str) -> str:
"""便捷函数解析Word并返回Markdown"""
parser = DocxParser()
return parser.parse_to_markdown(file_path)
return parser.parse_to_markdown(file_path)

View File

@@ -1,4 +1,3 @@
# src/services/parser/mineru_parser.py
"""MinerU多模态PDF解析 - 版面感知解析"""
from typing import Optional, Dict
@@ -201,4 +200,4 @@ def parse_with_mineru(file_path: str) -> MinerUResult:
def parse_pdf_smart(file_path: str) -> str:
"""便捷函数智能解析PDF自动选择最佳解析器"""
orchestrator = ParserOrchestrator()
return orchestrator.parse_pdf(file_path)
return orchestrator.parse_pdf(file_path)

View File

@@ -1,4 +1,3 @@
# src/services/parser/pdf_parser.py
"""PDF文档解析 - 使用PyMuPDF基础解析"""
import fitz # PyMuPDF
@@ -265,4 +264,4 @@ def parse_pdf(file_path: str, **kwargs) -> PDFDocumentContent:
def parse_pdf_to_markdown(file_path: str) -> str:
"""便捷函数解析PDF并返回Markdown"""
parser = PDFParser()
return parser.parse_to_markdown(file_path)
return parser.parse_to_markdown(file_path)

View File

@@ -1,4 +1,3 @@
# src/services/rag/__init__.py
"""RAG服务模块"""
from .retriever import Retriever, retrieve_regulations
@@ -9,4 +8,4 @@ __all__ = [
"Retriever", "retrieve_regulations",
"ContextBuilder", "build_rag_context",
"PromptTemplates", "get_prompt_template"
]
]

View File

@@ -1,4 +1,3 @@
# src/services/rag/context_builder.py
"""RAG上下文构建服务 - 构建LLM输入上下文"""
from typing import List, Dict, Optional

View File

@@ -1,4 +1,3 @@
# src/services/rag/prompt_templates.py
"""RAG Prompt模板 - 合规问答专用Prompt"""
from typing import Dict, Optional
@@ -293,4 +292,4 @@ def get_prompt_template(name: str) -> PromptTemplate:
template = PromptTemplates.get_template(name)
if not template:
raise ValueError(f"不存在的模板: {name}")
return template
return template

View File

@@ -1,4 +1,3 @@
# src/services/rag/retriever.py
"""RAG检索服务 - 封装Milvus检索"""
from typing import List, Dict, Optional, Any

View File

@@ -1,7 +1,6 @@
# src/services/storage/__init__.py
"""存储服务"""
from .milvus_client import MilvusClient
from .minio_client import MinIOClient
__all__ = ["MilvusClient", "MinIOClient"]
__all__ = ["MilvusClient", "MinIOClient"]

View File

@@ -1,4 +1,3 @@
# src/services/storage/milvus_client.py
"""Milvus向量数据库客户端 - 存储与检索服务"""
from pymilvus import (

View File

@@ -1,4 +1,3 @@
# src/services/storage/minio_client.py
"""MinIO对象存储客户端 - 文档文件存储"""
from minio import Minio

View File

@@ -1,2 +1 @@
# src/workers/__init__.py
"""异步任务Worker模块"""
"""异步任务Worker模块"""

View File

@@ -1,35 +0,0 @@
[project]
name = "ai-regulations-backend"
version = "0.1.0"
description = "Migrated FastAPI backend for AI regulations demo"
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"fastapi>=0.110.0",
"uvicorn[standard]>=0.27.0",
"python-multipart>=0.0.9",
"pydantic>=2.0.0",
"pydantic-settings>=2.0.0",
"python-dotenv>=1.0.0",
"loguru>=0.7.0",
"httpx>=0.25.0",
"tiktoken>=0.5.0",
"tenacity>=8.2.0",
"pymilvus>=2.4.0",
"minio>=7.1.0",
"pymupdf>=1.24.0",
"python-docx>=1.1.0",
"FlagEmbedding>=1.2.0",
"sentence-transformers>=2.2.0",
"torch>=2.0.0",
"numpy>=1.24.0",
"langchain>=0.1.0",
"langchain-milvus>=0.1.0",
]
[project.scripts]
backend = "main:main"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

630
dev.bat Normal file
View File

@@ -0,0 +1,630 @@
@echo off
setlocal EnableExtensions EnableDelayedExpansion
set "ROOT_DIR=%~dp0"
pushd "%ROOT_DIR%" >nul
set "VENV_DIR=.venv"
set "VENV_PYTHON=%VENV_DIR%\Scripts\python.exe"
set "LOG_DIR=logs"
set "API_PID_FILE=%LOG_DIR%\api.pid"
set "FRONTEND_PID_FILE=%LOG_DIR%\frontend.pid"
set "API_LOG_FILE=%CD%\%LOG_DIR%\api.log"
set "FRONTEND_LOG_FILE=%CD%\%LOG_DIR%\frontend.log"
call :load_env
if not defined API_HOST set "API_HOST=0.0.0.0"
if not defined API_PORT set "API_PORT=8000"
if not defined FRONTEND_PORT set "FRONTEND_PORT=5173"
if not defined FRONTEND_MODE set "FRONTEND_MODE=dev"
if "%~1"=="" goto help
if /I "%~1"=="help" goto help
if /I "%~1"=="-h" goto help
if /I "%~1"=="--help" goto help
if /I "%~1"=="setup" goto setup
if /I "%~1"=="start" goto start
if /I "%~1"=="stop" goto stop
if /I "%~1"=="restart" goto restart
if /I "%~1"=="status" goto status
if /I "%~1"=="logs" goto logs
echo Unknown command: %~1
echo Use dev.bat help to see available commands.
exit /b 1
:help
echo.
echo ========================================
echo AI+合规智能中枢统一脚本Windows
echo ========================================
echo.
echo Usage:
echo dev.bat help
echo dev.bat setup
echo dev.bat start [all^|api^|frontend] [--foreground] [--mode dev^|static]
echo dev.bat stop [all^|api^|frontend]
echo dev.bat restart [all^|api^|frontend] [--mode dev^|static]
echo dev.bat status
echo dev.bat logs ^<api^|frontend^> [--follow]
echo.
echo Command details:
echo help
echo Show the full command list, default ports, log paths and examples.
echo.
echo setup
echo Create .venv if missing, install backend dependencies from backend\requirements.txt,
echo run npm install in frontend, then report Docker base service status.
echo.
echo start
echo Start services. Default target is all.
echo start api --foreground Run uvicorn in the current window with --reload for debugging.
echo start frontend --mode static Build the frontend and serve frontend\dist as static files.
echo.
echo stop
echo Stop services. It first uses logs\*.pid, then falls back to listeners on the configured ports.
echo.
echo restart
echo Stop and then start the selected target.
echo.
echo status
echo Show API, frontend and Docker service status. API status also checks /health.
echo.
echo logs
echo Show the last 50 lines by default. Add --follow to stream updates.
echo.
echo Defaults:
echo API_HOST = %API_HOST%
echo API_PORT = %API_PORT%
echo FRONTEND_PORT = %FRONTEND_PORT%
echo FRONTEND_MODE = %FRONTEND_MODE%
echo Log files = logs\api.log, logs\frontend.log
echo.
echo Examples:
echo dev.bat setup
echo dev.bat start
echo dev.bat start api --foreground
echo dev.bat start frontend --mode static
echo dev.bat restart frontend --mode dev
echo dev.bat status
echo dev.bat logs api --follow
echo.
exit /b 0
:setup
call :ensure_log_dir
echo.
echo ========================================
echo AI+合规智能中枢 - 环境初始化
echo ========================================
echo.
call :resolve_python_bootstrap || exit /b 1
call %PYTHON_BOOTSTRAP% -c "import sys; raise SystemExit(0 if sys.version_info ^>= (3, 10) else 1)" || (
echo Python 3.10+ is required.
exit /b 1
)
if not exist "%VENV_PYTHON%" (
call %PYTHON_BOOTSTRAP% -m venv "%VENV_DIR%" || exit /b 1
echo Created virtual environment: %VENV_DIR%
) else (
echo Virtual environment already exists: %VENV_DIR%
)
"%VENV_PYTHON%" -m pip install --upgrade pip || exit /b 1
"%VENV_PYTHON%" -m pip install -r backend\requirements.txt || exit /b 1
echo Backend dependencies installed.
if not exist "frontend\package.json" (
echo Frontend package.json not found.
exit /b 1
)
where npm >nul 2>nul || (
echo npm was not found. Install Node.js 20+ first.
exit /b 1
)
pushd frontend >nul
call npm install || (
popd >nul
exit /b 1
)
popd >nul
echo Frontend dependencies installed.
echo.
echo Docker base services:
for %%C in (milvus minio redis postgres) do call :print_docker_status %%C
echo.
echo Setup complete.
echo dev.bat start
echo dev.bat status
echo dev.bat logs api --follow
exit /b 0
:start
set "TARGET=all"
set "MODE=%FRONTEND_MODE%"
set "FOREGROUND=0"
if /I "%~2"=="all" set "TARGET=all"
if /I "%~2"=="api" set "TARGET=api"
if /I "%~2"=="frontend" set "TARGET=frontend"
call :parse_start_options %* || exit /b 1
if /I "%TARGET%"=="all" (
if "%FOREGROUND%"=="1" (
echo start all does not support --foreground.
exit /b 1
)
call :start_api_background || exit /b 1
call :start_frontend "%MODE%" || exit /b 1
exit /b 0
)
if /I "%TARGET%"=="api" (
if "%FOREGROUND%"=="1" (
call :start_api_foreground
exit /b %errorlevel%
)
call :start_api_background
exit /b %errorlevel%
)
if /I "%TARGET%"=="frontend" (
call :start_frontend "%MODE%"
exit /b %errorlevel%
)
echo Invalid start target.
exit /b 1
:parse_start_options
set "MODE=%FRONTEND_MODE%"
set "FOREGROUND=0"
:parse_start_options_loop
if "%~1"=="" exit /b 0
if /I "%~1"=="start" (
shift
goto parse_start_options_loop
)
if /I "%~1"=="all" (
shift
goto parse_start_options_loop
)
if /I "%~1"=="api" (
shift
goto parse_start_options_loop
)
if /I "%~1"=="frontend" (
shift
goto parse_start_options_loop
)
if /I "%~1"=="--foreground" (
set "FOREGROUND=1"
shift
goto parse_start_options_loop
)
if /I "%~1"=="--mode" (
if "%~2"=="" (
echo --mode requires dev or static.
exit /b 1
)
set "MODE=%~2"
call :validate_mode "%MODE%" || exit /b 1
shift
shift
goto parse_start_options_loop
)
echo Unknown argument: %~1
exit /b 1
:stop
set "TARGET=all"
if /I "%~2"=="api" set "TARGET=api"
if /I "%~2"=="frontend" set "TARGET=frontend"
if /I "%TARGET%"=="all" (
call :stop_frontend
call :stop_api
exit /b 0
)
if /I "%TARGET%"=="api" (
call :stop_api
exit /b 0
)
if /I "%TARGET%"=="frontend" (
call :stop_frontend
exit /b 0
)
echo Invalid stop target.
exit /b 1
:restart
set "TARGET=all"
set "MODE=%FRONTEND_MODE%"
if /I "%~2"=="api" set "TARGET=api"
if /I "%~2"=="frontend" set "TARGET=frontend"
call :parse_restart_options %* || exit /b 1
if /I "%TARGET%"=="all" (
call :stop_frontend
call :stop_api
call :start_api_background || exit /b 1
call :start_frontend "%MODE%" || exit /b 1
exit /b 0
)
if /I "%TARGET%"=="api" (
call :stop_api
call :start_api_background
exit /b %errorlevel%
)
if /I "%TARGET%"=="frontend" (
call :stop_frontend
call :start_frontend "%MODE%"
exit /b %errorlevel%
)
echo Invalid restart target.
exit /b 1
:parse_restart_options
set "MODE=%FRONTEND_MODE%"
:parse_restart_options_loop
if "%~1"=="" exit /b 0
if /I "%~1"=="restart" (
shift
goto parse_restart_options_loop
)
if /I "%~1"=="all" (
shift
goto parse_restart_options_loop
)
if /I "%~1"=="api" (
shift
goto parse_restart_options_loop
)
if /I "%~1"=="frontend" (
shift
goto parse_restart_options_loop
)
if /I "%~1"=="--mode" (
if "%~2"=="" (
echo --mode requires dev or static.
exit /b 1
)
set "MODE=%~2"
call :validate_mode "%MODE%" || exit /b 1
shift
shift
goto parse_restart_options_loop
)
echo Unknown argument: %~1
exit /b 1
:status
call :ensure_log_dir
echo.
echo ========================================
echo AI+合规智能中枢 - 服务状态
echo ========================================
echo.
echo API service:
set "API_PID="
set "API_RUNNING=0"
if exist "%API_PID_FILE%" set /p API_PID=<"%API_PID_FILE%"
if defined API_PID (
call :pid_exists %API_PID%
if not errorlevel 1 (
set "API_RUNNING=1"
echo Status: running
echo PID: %API_PID%
goto api_health
) else (
del /q "%API_PID_FILE%" >nul 2>nul
set "API_PID="
)
)
call :get_listener_pid %API_PORT% API_LISTENER
if defined API_LISTENER (
set "API_RUNNING=1"
echo Status: running (no PID file)
echo PID: %API_LISTENER%
) else (
echo Status: stopped
goto api_done
)
:api_health
if "%API_RUNNING%"=="1" (
call :check_api_health
if not errorlevel 1 (
echo Health: ok
) else (
echo Health: failed
)
)
:api_done
echo URL: http://localhost:%API_PORT%
echo Docs: http://localhost:%API_PORT%/docs
echo.
echo Frontend service:
set "FRONTEND_PID="
if exist "%FRONTEND_PID_FILE%" set /p FRONTEND_PID=<"%FRONTEND_PID_FILE%"
if defined FRONTEND_PID (
call :pid_exists %FRONTEND_PID%
if not errorlevel 1 (
echo Status: running
echo PID: %FRONTEND_PID%
goto frontend_done
) else (
del /q "%FRONTEND_PID_FILE%" >nul 2>nul
set "FRONTEND_PID="
)
)
call :get_listener_pid %FRONTEND_PORT% FRONTEND_LISTENER
if defined FRONTEND_LISTENER (
echo Status: running (no PID file)
echo PID: %FRONTEND_LISTENER%
) else (
echo Status: stopped
)
:frontend_done
echo Mode: %FRONTEND_MODE%
echo URL: http://localhost:%FRONTEND_PORT%
echo.
echo Docker services:
where docker >nul 2>nul || (
echo Docker not installed.
exit /b 0
)
for %%C in (milvus minio redis postgres) do call :print_docker_status %%C
exit /b 0
:logs
if "%~2"=="" (
echo Specify api or frontend.
exit /b 1
)
if /I "%~2"=="api" (
set "LOG_FILE=%API_LOG_FILE%"
)
if /I "%~2"=="frontend" (
set "LOG_FILE=%FRONTEND_LOG_FILE%"
)
if not defined LOG_FILE (
echo Unknown log target: %~2
exit /b 1
)
if not exist "%LOG_FILE%" (
echo Log file not found: %LOG_FILE%
exit /b 1
)
if /I "%~3"=="--follow" (
pwsh -NoProfile -Command "Get-Content -LiteralPath '%LOG_FILE%' -Wait"
) else (
pwsh -NoProfile -Command "Get-Content -LiteralPath '%LOG_FILE%' -Tail 50"
)
exit /b %errorlevel%
:start_api_background
call :ensure_log_dir
if not exist "%VENV_PYTHON%" (
echo Virtual environment not found. Run dev.bat setup first.
exit /b 1
)
set "EXISTING_PID="
if exist "%API_PID_FILE%" set /p EXISTING_PID=<"%API_PID_FILE%"
if defined EXISTING_PID (
call :pid_exists %EXISTING_PID%
if not errorlevel 1 (
echo API is already running. PID: %EXISTING_PID%
exit /b 0
) else del /q "%API_PID_FILE%" >nul 2>nul
)
for /f %%P in ('pwsh -NoProfile -Command "$p = Start-Process -FilePath 'pwsh' -ArgumentList '-NoProfile','-Command',('Set-Location ''%CD%''; $env:PYTHONPATH=''backend''; & ''.\%VENV_PYTHON%'' -m uvicorn app.main:app --host ''%API_HOST%'' --port ''%API_PORT%'' *^> ''%API_LOG_FILE%''') -WindowStyle Hidden -PassThru; $p.Id"') do set "API_PID=%%P"
if not defined API_PID (
echo Failed to start API.
exit /b 1
)
> "%API_PID_FILE%" echo %API_PID%
timeout /t 3 /nobreak >nul
call :pid_exists %API_PID%
if errorlevel 1 (
del /q "%API_PID_FILE%" >nul 2>nul
echo API failed to start. Check logs\api.log
exit /b 1
)
echo API started. PID: %API_PID%
echo URL: http://localhost:%API_PORT%
echo Docs: http://localhost:%API_PORT%/docs
echo Log: logs\api.log
exit /b 0
:start_api_foreground
if not exist "%VENV_PYTHON%" (
echo Virtual environment not found. Run dev.bat setup first.
exit /b 1
)
set "PYTHONPATH=backend;%PYTHONPATH%"
echo API running in foreground with reload enabled.
echo URL: http://localhost:%API_PORT%
echo Docs: http://localhost:%API_PORT%/docs
"%VENV_PYTHON%" -m uvicorn app.main:app --host "%API_HOST%" --port "%API_PORT%" --reload
exit /b %errorlevel%
:start_frontend
set "MODE=%~1"
if "%MODE%"=="" set "MODE=%FRONTEND_MODE%"
call :ensure_log_dir
where npm >nul 2>nul || (
echo npm was not found. Install Node.js 20+ first.
exit /b 1
)
if not exist "frontend\node_modules\vite" (
echo Frontend dependencies are missing. Running npm install...
pushd frontend >nul
call npm install || (
popd >nul
exit /b 1
)
popd >nul
)
set "EXISTING_PID="
if exist "%FRONTEND_PID_FILE%" set /p EXISTING_PID=<"%FRONTEND_PID_FILE%"
if defined EXISTING_PID (
call :pid_exists %EXISTING_PID%
if not errorlevel 1 (
echo Frontend is already running. PID: %EXISTING_PID%
exit /b 0
) else del /q "%FRONTEND_PID_FILE%" >nul 2>nul
)
if /I "%MODE%"=="static" (
pushd frontend >nul
call npm run build || (
popd >nul
exit /b 1
)
popd >nul
call :resolve_python_bootstrap || exit /b 1
for /f %%P in ('pwsh -NoProfile -Command "$p = Start-Process -FilePath 'pwsh' -ArgumentList '-NoProfile','-Command',('Set-Location ''%CD%''; & %PYTHON_BOOTSTRAP% -m http.server %FRONTEND_PORT% --bind 0.0.0.0 --directory frontend/dist *^> ''%FRONTEND_LOG_FILE%''') -WindowStyle Hidden -PassThru; $p.Id"') do set "FRONTEND_PID=%%P"
) else (
for /f %%P in ('pwsh -NoProfile -Command "$p = Start-Process -FilePath 'pwsh' -ArgumentList '-NoProfile','-Command',('Set-Location ''%CD%''; & npm --prefix frontend run dev -- --host 0.0.0.0 --port %FRONTEND_PORT% *^> ''%FRONTEND_LOG_FILE%''') -WindowStyle Hidden -PassThru; $p.Id"') do set "FRONTEND_PID=%%P"
)
if not defined FRONTEND_PID (
echo Failed to start frontend.
exit /b 1
)
> "%FRONTEND_PID_FILE%" echo %FRONTEND_PID%
timeout /t 4 /nobreak >nul
call :pid_exists %FRONTEND_PID%
if errorlevel 1 (
del /q "%FRONTEND_PID_FILE%" >nul 2>nul
echo Frontend failed to start. Check logs\frontend.log
exit /b 1
)
echo Frontend started. PID: %FRONTEND_PID%
echo URL: http://localhost:%FRONTEND_PORT%
echo Mode: %MODE%
echo Log: logs\frontend.log
exit /b 0
:stop_api
set "API_PID="
if exist "%API_PID_FILE%" set /p API_PID=<"%API_PID_FILE%"
if defined API_PID (
call :stop_pid %API_PID%
del /q "%API_PID_FILE%" >nul 2>nul
echo API stopped.
exit /b 0
)
call :get_listener_pid %API_PORT% API_PORT_PID
if defined API_PORT_PID (
call :stop_pid %API_PORT_PID%
echo Stopped process listening on API port %API_PORT%.
exit /b 0
)
echo API is not running.
exit /b 0
:stop_frontend
set "FRONTEND_PID="
if exist "%FRONTEND_PID_FILE%" set /p FRONTEND_PID=<"%FRONTEND_PID_FILE%"
if defined FRONTEND_PID (
call :stop_pid %FRONTEND_PID%
del /q "%FRONTEND_PID_FILE%" >nul 2>nul
echo Frontend stopped.
exit /b 0
)
call :get_listener_pid %FRONTEND_PORT% FRONTEND_PORT_PID
if defined FRONTEND_PORT_PID (
call :stop_pid %FRONTEND_PORT_PID%
echo Stopped process listening on frontend port %FRONTEND_PORT%.
exit /b 0
)
echo Frontend is not running.
exit /b 0
:ensure_log_dir
if not exist "%LOG_DIR%" mkdir "%LOG_DIR%"
exit /b 0
:validate_mode
if /I "%~1"=="dev" exit /b 0
if /I "%~1"=="static" exit /b 0
echo Invalid frontend mode: %~1
exit /b 1
:resolve_python_bootstrap
where python >nul 2>nul && (
set "PYTHON_BOOTSTRAP=python"
exit /b 0
)
where py >nul 2>nul && (
set "PYTHON_BOOTSTRAP=py -3"
exit /b 0
)
echo Python was not found.
exit /b 1
:load_env
for %%K in (API_HOST API_PORT FRONTEND_PORT FRONTEND_MODE) do call :read_env %%K
exit /b 0
:read_env
if not exist ".env" exit /b 0
for /f "usebackq tokens=1,* delims==" %%A in (`findstr /r /b /c:"%~1=" ".env" 2^>nul`) do set "%~1=%%B"
exit /b 0
:pid_exists
pwsh -NoProfile -Command "exit([int](-not [bool](Get-Process -Id %~1 -ErrorAction SilentlyContinue)))"
exit /b %errorlevel%
:stop_pid
pwsh -NoProfile -Command "$p = Get-Process -Id %~1 -ErrorAction SilentlyContinue; if ($p) { Stop-Process -Id %~1 -Force }"
exit /b 0
:get_listener_pid
set "%~2="
for /f %%P in ('pwsh -NoProfile -Command "(Get-NetTCPConnection -LocalPort %~1 -State Listen -ErrorAction SilentlyContinue ^| Select-Object -ExpandProperty OwningProcess -Unique ^| Select-Object -First 1)"') do set "%~2=%%P"
exit /b 0
:check_api_health
pwsh -NoProfile -Command "try { $r = Invoke-WebRequest -Uri 'http://localhost:%API_PORT%/health' -TimeoutSec 3 -UseBasicParsing; exit([int](-not ($r.Content -match 'healthy'))) } catch { exit 1 }"
exit /b %errorlevel%
:print_docker_status
docker ps --format "{{.Names}}" | findstr /x /c:"%~1" >nul && (
echo %~1: running
exit /b 0
)
docker ps -a --format "{{.Names}}" | findstr /x /c:"%~1" >nul && (
echo %~1: stopped
exit /b 0
)
echo %~1: not created
exit /b 0

710
dev.sh Normal file
View File

@@ -0,0 +1,710 @@
#!/bin/bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$ROOT_DIR"
VENV_DIR=".venv"
VENV_PYTHON="$VENV_DIR/bin/python"
LOG_DIR="logs"
API_PID_FILE="$LOG_DIR/api.pid"
FRONTEND_PID_FILE="$LOG_DIR/frontend.pid"
API_LOG_FILE="$LOG_DIR/api.log"
FRONTEND_LOG_FILE="$LOG_DIR/frontend.log"
DOCKER_CONTAINERS="milvus minio redis postgres"
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
NC='\033[0m'
load_env() {
if [ -f ".env" ]; then
while IFS='=' read -r key value; do
key="${key%$'\r'}"
value="${value%$'\r'}"
case "$key" in
""|\#*)
continue
;;
API_HOST|API_PORT|FRONTEND_PORT|FRONTEND_MODE)
export "$key=$value"
;;
esac
done < ".env"
fi
API_HOST="${API_HOST:-0.0.0.0}"
API_PORT="${API_PORT:-8000}"
FRONTEND_PORT="${FRONTEND_PORT:-5173}"
FRONTEND_MODE="${FRONTEND_MODE:-dev}"
}
ensure_log_dir() {
mkdir -p "$LOG_DIR"
}
print_header() {
echo ""
echo -e "${CYAN}========================================${NC}"
echo -e "${CYAN} $1${NC}"
echo -e "${CYAN}========================================${NC}"
echo ""
}
info() {
echo -e "${CYAN}$1${NC}"
}
success() {
echo -e "${GREEN}$1${NC}"
}
warn() {
echo -e "${YELLOW}$1${NC}"
}
error() {
echo -e "${RED}$1${NC}" >&2
}
die() {
error "$1"
exit 1
}
is_pid_running() {
local pid="$1"
[ -n "$pid" ] && ps -p "$pid" > /dev/null 2>&1
}
read_pid() {
local pid_file="$1"
if [ -f "$pid_file" ]; then
cat "$pid_file"
fi
}
cleanup_stale_pid() {
local pid_file="$1"
local pid
pid="$(read_pid "$pid_file")"
if [ -n "$pid" ] && ! is_pid_running "$pid"; then
rm -f "$pid_file"
fi
}
port_pid() {
local port="$1"
if command -v lsof > /dev/null 2>&1; then
lsof -ti tcp:"$port" 2>/dev/null | head -n 1 || true
return 0
fi
if command -v ss > /dev/null 2>&1; then
ss -lptn "sport = :$port" 2>/dev/null | awk -F 'pid=' 'NR>1 && NF>1 {split($2,a,/,/); print a[1]; exit}' || true
return 0
fi
if command -v netstat > /dev/null 2>&1; then
netstat -lntp 2>/dev/null | awk -v port=":$port" '$4 ~ port {split($7,a,"/"); if (a[1] != "-") {print a[1]; exit}}' || true
fi
return 0
}
require_python_bootstrap() {
if command -v python3 > /dev/null 2>&1; then
PYTHON_BOOTSTRAP="python3"
elif command -v python > /dev/null 2>&1; then
PYTHON_BOOTSTRAP="python"
else
die "未找到 Python请先安装 Python 3.10+。"
fi
}
require_venv() {
[ -x "$VENV_PYTHON" ] || die "虚拟环境不存在,请先运行 ./dev.sh setup"
}
ensure_frontend_deps() {
if [ ! -d "frontend" ]; then
die "前端目录不存在: frontend"
fi
if [ ! -d "frontend/node_modules" ] || [ ! -d "frontend/node_modules/vite" ]; then
warn "前端依赖不存在或不完整,正在执行 npm install..."
npm --prefix frontend install
fi
}
validate_frontend_mode() {
case "$1" in
dev|static)
;;
*)
die "前端模式仅支持 dev 或 static当前值: $1"
;;
esac
}
check_python_version() {
local python_cmd="$1"
"$python_cmd" -c 'import sys; raise SystemExit(0 if sys.version_info >= (3, 10) else 1)'
}
run_setup() {
load_env
ensure_log_dir
print_header "AI+合规智能中枢 - 环境初始化"
require_python_bootstrap
info "[1/4] 检查 Python 版本"
check_python_version "$PYTHON_BOOTSTRAP" || die "需要 Python 3.10+"
success "Python 版本检查通过"
echo ""
info "[2/4] 准备虚拟环境"
if [ ! -d "$VENV_DIR" ]; then
"$PYTHON_BOOTSTRAP" -m venv "$VENV_DIR"
success "已创建虚拟环境: $VENV_DIR"
else
success "虚拟环境已存在: $VENV_DIR"
fi
"$VENV_PYTHON" -m pip install --upgrade pip
"$VENV_PYTHON" -m pip install -r backend/requirements.txt
success "后端依赖安装完成"
echo ""
info "[3/4] 准备前端依赖"
if ! command -v npm > /dev/null 2>&1; then
die "未找到 npm请先安装 Node.js 20+。"
fi
npm --prefix frontend install
success "前端依赖安装完成"
echo ""
info "[4/4] 检查 Docker 基础服务"
if command -v docker > /dev/null 2>&1; then
local container
for container in $DOCKER_CONTAINERS; do
if docker ps --format '{{.Names}}' | grep -q "^${container}$"; then
success "${container}: 运行中"
elif docker ps -a --format '{{.Names}}' | grep -q "^${container}$"; then
warn "${container}: 已创建但未运行"
else
warn "${container}: 未找到容器"
fi
done
else
warn "未检测到 Docker已跳过容器检查"
fi
echo ""
success "环境初始化完成"
echo "后续常用命令:"
echo " ./dev.sh start"
echo " ./dev.sh status"
echo " ./dev.sh logs api --follow"
}
api_health_ok() {
if command -v curl > /dev/null 2>&1; then
curl -fsS "http://localhost:$API_PORT/health" > /dev/null 2>&1
return
fi
require_venv
"$VENV_PYTHON" - <<PY > /dev/null 2>&1
import sys
from urllib.request import urlopen
try:
with urlopen("http://localhost:${API_PORT}/health", timeout=3) as response:
body = response.read().decode("utf-8", errors="ignore")
sys.exit(0 if "healthy" in body.lower() else 1)
except Exception:
sys.exit(1)
PY
}
start_api() {
local mode="${1:-background}"
load_env
ensure_log_dir
require_venv
cleanup_stale_pid "$API_PID_FILE"
local pid
pid="$(read_pid "$API_PID_FILE")"
if is_pid_running "$pid"; then
warn "API 已在运行 (PID: $pid)"
return
fi
export PYTHONPATH="backend${PYTHONPATH:+:$PYTHONPATH}"
if [ "$mode" = "foreground" ]; then
print_header "AI+合规智能中枢 - 启动 API"
echo "运行模式: 前台调试(带 --reload"
echo "服务地址: http://localhost:$API_PORT"
echo "文档地址: http://localhost:$API_PORT/docs"
echo "健康检查: http://localhost:$API_PORT/health"
echo ""
exec "$VENV_PYTHON" -m uvicorn app.main:app --host "$API_HOST" --port "$API_PORT" --reload
fi
nohup "$VENV_PYTHON" -m uvicorn app.main:app --host "$API_HOST" --port "$API_PORT" > "$API_LOG_FILE" 2>&1 &
pid=$!
echo "$pid" > "$API_PID_FILE"
sleep 3
if is_pid_running "$pid"; then
success "API 启动成功 (PID: $pid)"
echo " 地址: http://localhost:$API_PORT"
echo " 文档: http://localhost:$API_PORT/docs"
echo " 日志: $API_LOG_FILE"
else
rm -f "$API_PID_FILE"
die "API 启动失败,请查看日志: $API_LOG_FILE"
fi
}
start_frontend() {
local mode="${1:-$FRONTEND_MODE}"
load_env
ensure_log_dir
cleanup_stale_pid "$FRONTEND_PID_FILE"
local pid
pid="$(read_pid "$FRONTEND_PID_FILE")"
if is_pid_running "$pid"; then
warn "前端已在运行 (PID: $pid)"
return
fi
if ! command -v npm > /dev/null 2>&1; then
die "未找到 npm请先安装 Node.js 20+。"
fi
ensure_frontend_deps
if [ "$mode" = "static" ]; then
require_python_bootstrap
npm --prefix frontend run build
nohup "$PYTHON_BOOTSTRAP" -m http.server "$FRONTEND_PORT" --bind 0.0.0.0 --directory frontend/dist > "$FRONTEND_LOG_FILE" 2>&1 &
else
nohup npm --prefix frontend run dev -- --host 0.0.0.0 --port "$FRONTEND_PORT" > "$FRONTEND_LOG_FILE" 2>&1 &
fi
pid=$!
echo "$pid" > "$FRONTEND_PID_FILE"
sleep 4
if is_pid_running "$pid"; then
success "前端启动成功 (PID: $pid)"
echo " 地址: http://localhost:$FRONTEND_PORT"
echo " 模式: $mode"
echo " 日志: $FRONTEND_LOG_FILE"
else
rm -f "$FRONTEND_PID_FILE"
die "前端启动失败,请查看日志: $FRONTEND_LOG_FILE"
fi
}
kill_pid_file_process() {
local pid_file="$1"
local label="$2"
local pid
pid="$(read_pid "$pid_file")"
if ! is_pid_running "$pid"; then
rm -f "$pid_file"
warn "$label 未通过 PID 文件发现运行中的进程"
return 1
fi
kill "$pid" 2>/dev/null || true
sleep 2
if is_pid_running "$pid"; then
kill -9 "$pid" 2>/dev/null || true
fi
rm -f "$pid_file"
success "$label 已停止"
return 0
}
stop_api() {
load_env
ensure_log_dir
if kill_pid_file_process "$API_PID_FILE" "API"; then
return
fi
local port_listener
port_listener="$(port_pid "$API_PORT")"
if [ -n "$port_listener" ]; then
kill "$port_listener" 2>/dev/null || true
sleep 1
if is_pid_running "$port_listener"; then
kill -9 "$port_listener" 2>/dev/null || true
fi
success "已停止监听 API 端口 $API_PORT 的进程 (PID: $port_listener)"
else
warn "未发现运行中的 API 服务"
fi
rm -f "$API_PID_FILE"
}
stop_frontend() {
load_env
ensure_log_dir
if kill_pid_file_process "$FRONTEND_PID_FILE" "前端"; then
return
fi
local port_listener
port_listener="$(port_pid "$FRONTEND_PORT")"
if [ -n "$port_listener" ]; then
kill "$port_listener" 2>/dev/null || true
sleep 1
if is_pid_running "$port_listener"; then
kill -9 "$port_listener" 2>/dev/null || true
fi
success "已停止监听前端端口 $FRONTEND_PORT 的进程 (PID: $port_listener)"
else
warn "未发现运行中的前端服务"
fi
rm -f "$FRONTEND_PID_FILE"
}
run_status() {
load_env
ensure_log_dir
print_header "AI+合规智能中枢 - 服务状态"
cleanup_stale_pid "$API_PID_FILE"
cleanup_stale_pid "$FRONTEND_PID_FILE"
local api_running=false
local frontend_running=false
local pid
local port_listener
echo -e "${YELLOW}API 服务:${NC}"
pid="$(read_pid "$API_PID_FILE")"
if is_pid_running "$pid"; then
api_running=true
success " 状态: 运行中"
echo " PID: $pid"
else
port_listener="$(port_pid "$API_PORT")"
if [ -n "$port_listener" ]; then
api_running=true
success " 状态: 运行中 (无 PID 文件)"
echo " PID: $port_listener"
else
error " 状态: 已停止"
fi
fi
if [ "$api_running" = true ]; then
if api_health_ok; then
success " 健康检查: 正常"
else
warn " 健康检查: 未通过"
fi
fi
echo " 地址: http://localhost:$API_PORT"
echo " 文档: http://localhost:${API_PORT}/docs"
echo ""
echo -e "${YELLOW}前端服务:${NC}"
pid="$(read_pid "$FRONTEND_PID_FILE")"
if is_pid_running "$pid"; then
frontend_running=true
success " 状态: 运行中"
echo " PID: $pid"
else
port_listener="$(port_pid "$FRONTEND_PORT")"
if [ -n "$port_listener" ]; then
frontend_running=true
success " 状态: 运行中 (无 PID 文件)"
echo " PID: $port_listener"
else
error " 状态: 已停止"
fi
fi
echo " 模式: $FRONTEND_MODE"
echo " 地址: http://localhost:$FRONTEND_PORT"
echo ""
echo -e "${YELLOW}Docker 服务:${NC}"
if command -v docker > /dev/null 2>&1; then
local container
for container in $DOCKER_CONTAINERS; do
if docker ps --format '{{.Names}}' | grep -q "^${container}$"; then
success " ${container}: 运行中"
elif docker ps -a --format '{{.Names}}' | grep -q "^${container}$"; then
warn " ${container}: 已停止"
else
warn " ${container}: 未创建"
fi
done
else
warn " Docker 未安装,已跳过"
fi
echo ""
if [ "$api_running" = true ] && [ "$frontend_running" = true ]; then
success "所有核心服务均在运行"
else
warn "存在未运行的服务,可使用 ./dev.sh start 启动"
fi
}
run_logs() {
local target="${1:-}"
local follow="${2:-}"
local log_file
case "$target" in
api)
log_file="$API_LOG_FILE"
;;
frontend)
log_file="$FRONTEND_LOG_FILE"
;;
*)
die "请指定日志类型: api 或 frontend"
;;
esac
[ -f "$log_file" ] || die "日志文件不存在: $log_file"
if [ "$follow" = "--follow" ]; then
tail -f "$log_file"
else
tail -n 50 "$log_file"
fi
}
show_help() {
cat <<'EOF'
AI+合规智能中枢统一脚本
用法:
./dev.sh help
./dev.sh setup
./dev.sh start [all|api|frontend] [--foreground] [--mode dev|static]
./dev.sh stop [all|api|frontend]
./dev.sh restart [all|api|frontend] [--mode dev|static]
./dev.sh status
./dev.sh logs <api|frontend> [--follow]
命令说明:
help
输出完整帮助信息、默认端口、日志目录和常见示例。
setup
进行一次性的本地初始化。
包含 Python 版本检查、.venv 虚拟环境创建、后端依赖安装、前端 npm install、
以及 Docker 基础容器状态检查。
start
启动服务。默认行为等同于 ./dev.sh start all。
可选目标:
all 同时启动 API 和前端。
api 只启动后端 API。
frontend 只启动前端。
可选参数:
--foreground 仅对 start api 生效,前台运行并开启 --reload便于调试。
--mode dev 前端使用 Vite 开发服务器,默认端口 5173。
--mode static 前端先执行 npm run build再以静态文件服务器方式启动。
stop
停止服务。默认行为等同于 ./dev.sh stop all。
会优先读取 logs/*.pidPID 文件失效时会回退到端口探测。
restart
先停止再启动,支持 all/api/frontend。
restart frontend --mode static 可直接切换前端启动模式。
status
查看 API、前端、Docker 基础容器的状态。
API 状态包含健康检查;前端状态包含当前模式和访问地址。
logs
查看日志。默认输出最后 50 行。
追加 --follow 后会持续跟踪日志输出。
默认约定:
API_HOST 默认 0.0.0.0
API_PORT 默认 8000
FRONTEND_PORT 默认 5173
FRONTEND_MODE 默认 dev
日志目录 logs/
PID 文件 logs/api.pid, logs/frontend.pid
常用示例:
./dev.sh setup
./dev.sh start
./dev.sh start api --foreground
./dev.sh start frontend --mode static
./dev.sh restart frontend --mode dev
./dev.sh status
./dev.sh logs api --follow
./dev.sh logs frontend
EOF
}
parse_target() {
local default_target="$1"
local candidate="${2:-}"
case "$candidate" in
all|api|frontend)
echo "$candidate"
;;
*)
echo "$default_target"
;;
esac
}
main() {
local command="${1:-help}"
local target="all"
local mode=""
local foreground=false
local log_target=""
shift || true
load_env
case "$command" in
help|-h|--help)
show_help
;;
setup)
run_setup
;;
start)
target="$(parse_target all "${1:-}")"
if [ "$target" != "all" ]; then
shift || true
fi
while [ $# -gt 0 ]; do
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
;;
stop)
target="$(parse_target all "${1:-}")"
print_header "AI+合规智能中枢 - 停止服务"
case "$target" in
all)
stop_frontend
stop_api
;;
api)
stop_api
;;
frontend)
stop_frontend
;;
esac
;;
restart)
target="$(parse_target all "${1:-}")"
if [ "$target" != "all" ]; then
shift || true
fi
while [ $# -gt 0 ]; do
case "$1" in
--mode)
shift || die "--mode 需要指定 dev 或 static"
mode="$1"
validate_frontend_mode "$mode"
;;
*)
die "未知参数: $1"
;;
esac
shift || true
done
print_header "AI+合规智能中枢 - 重启服务"
case "$target" in
all)
stop_frontend
stop_api
start_api background
start_frontend "${mode:-$FRONTEND_MODE}"
;;
api)
stop_api
start_api background
;;
frontend)
stop_frontend
start_frontend "${mode:-$FRONTEND_MODE}"
;;
esac
;;
status)
run_status
;;
logs)
log_target="${1:-}"
run_logs "$log_target" "${2:-}"
;;
*)
die "未知命令: $command。可使用 ./dev.sh help 查看帮助。"
;;
esac
}
main "$@"

View File

@@ -64,7 +64,7 @@ npm run preview
## 项目结构
```
src/
frontend/src/
├── components/
│ ├── common/ # 通用组件 (Logo, Pattern, ThemeToggle)
│ ├── layout/ # 布局组件 (Header, Tabs, Content)

1968
frontend/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1 +0,0 @@
1894

119
logs/app_2026-05-14.log Normal file
View File

@@ -0,0 +1,119 @@
2026-05-14 16:41:52 | INFO | src.api.main:lifespan:27 - 启动 AI+合规智能中枢 v0.1.0
2026-05-14 16:41:52 | INFO | src.api.main:lifespan:28 - 调试模式: False
2026-05-14 16:41:52 | INFO | src.api.main:lifespan:31 - 预加载LLM客户端...
2026-05-14 16:41:54 | INFO | src.services.llm.qwen_client:_init_client:59 - Qwen客户端初始化完成: http://6.86.80.4:30080/v1 - qwen3.5-flash
2026-05-14 16:41:54 | INFO | src.services.llm.llm_factory:create:113 - LLM客户端创建成功并缓存: qwen - qwen3.5-flash
2026-05-14 16:41:54 | SUCCESS | src.services.llm.llm_factory:preload_clients:201 - 预加载LLM客户端成功: qwen
2026-05-14 16:41:55 | INFO | src.services.llm.deepseek_client:_init_client:50 - DeepSeek客户端初始化完成: http://6.86.80.4:30080/v1 - deepseek-v4-flash
2026-05-14 16:41:55 | INFO | src.services.llm.llm_factory:create:113 - LLM客户端创建成功并缓存: deepseek - deepseek-v4-flash
2026-05-14 16:41:55 | SUCCESS | src.services.llm.llm_factory:preload_clients:201 - 预加载LLM客户端成功: deepseek
2026-05-14 16:42:22 | INFO | src.api.main:lifespan:37 - 应用关闭,执行清理...
2026-05-14 16:42:22 | INFO | src.services.llm.llm_factory:cleanup:226 - 所有LLM客户端已清理
2026-05-14 16:42:28 | INFO | src.api.main:lifespan:27 - 启动 AI+合规智能中枢 v0.1.0
2026-05-14 16:42:28 | INFO | src.api.main:lifespan:28 - 调试模式: False
2026-05-14 16:42:28 | INFO | src.api.main:lifespan:31 - 预加载LLM客户端...
2026-05-14 16:42:28 | INFO | src.services.llm.qwen_client:_init_client:59 - Qwen客户端初始化完成: http://6.86.80.4:30080/v1 - qwen3.5-flash
2026-05-14 16:42:28 | INFO | src.services.llm.llm_factory:create:113 - LLM客户端创建成功并缓存: qwen - qwen3.5-flash
2026-05-14 16:42:28 | SUCCESS | src.services.llm.llm_factory:preload_clients:201 - 预加载LLM客户端成功: qwen
2026-05-14 16:42:29 | INFO | src.services.llm.deepseek_client:_init_client:50 - DeepSeek客户端初始化完成: http://6.86.80.4:30080/v1 - deepseek-v4-flash
2026-05-14 16:42:29 | INFO | src.services.llm.llm_factory:create:113 - LLM客户端创建成功并缓存: deepseek - deepseek-v4-flash
2026-05-14 16:42:29 | SUCCESS | src.services.llm.llm_factory:preload_clients:201 - 预加载LLM客户端成功: deepseek
2026-05-14 16:42:31 | INFO | src.api.main:lifespan:37 - 应用关闭,执行清理...
2026-05-14 16:42:31 | INFO | src.services.llm.llm_factory:cleanup:226 - 所有LLM客户端已清理
2026-05-14 16:42:37 | INFO | src.api.main:lifespan:27 - 启动 AI+合规智能中枢 v0.1.0
2026-05-14 16:42:37 | INFO | src.api.main:lifespan:28 - 调试模式: False
2026-05-14 16:42:37 | INFO | src.api.main:lifespan:31 - 预加载LLM客户端...
2026-05-14 16:42:37 | INFO | src.services.llm.qwen_client:_init_client:59 - Qwen客户端初始化完成: http://6.86.80.4:30080/v1 - qwen3.5-flash
2026-05-14 16:42:37 | INFO | src.services.llm.llm_factory:create:113 - LLM客户端创建成功并缓存: qwen - qwen3.5-flash
2026-05-14 16:42:37 | SUCCESS | src.services.llm.llm_factory:preload_clients:201 - 预加载LLM客户端成功: qwen
2026-05-14 16:42:38 | INFO | src.services.llm.deepseek_client:_init_client:50 - DeepSeek客户端初始化完成: http://6.86.80.4:30080/v1 - deepseek-v4-flash
2026-05-14 16:42:38 | INFO | src.services.llm.llm_factory:create:113 - LLM客户端创建成功并缓存: deepseek - deepseek-v4-flash
2026-05-14 16:42:38 | SUCCESS | src.services.llm.llm_factory:preload_clients:201 - 预加载LLM客户端成功: deepseek
2026-05-14 16:43:28 | INFO | src.api.main:lifespan:37 - 应用关闭,执行清理...
2026-05-14 16:43:28 | INFO | src.services.llm.llm_factory:cleanup:226 - 所有LLM客户端已清理
2026-05-14 16:43:34 | INFO | src.api.main:lifespan:27 - 启动 AI+合规智能中枢 v0.1.0
2026-05-14 16:43:34 | INFO | src.api.main:lifespan:28 - 调试模式: False
2026-05-14 16:43:34 | INFO | src.api.main:lifespan:31 - 预加载LLM客户端...
2026-05-14 16:43:34 | INFO | src.services.llm.qwen_client:_init_client:59 - Qwen客户端初始化完成: http://6.86.80.4:30080/v1 - qwen3.5-flash
2026-05-14 16:43:34 | INFO | src.services.llm.llm_factory:create:113 - LLM客户端创建成功并缓存: qwen - qwen3.5-flash
2026-05-14 16:43:34 | SUCCESS | src.services.llm.llm_factory:preload_clients:201 - 预加载LLM客户端成功: qwen
2026-05-14 16:43:34 | INFO | src.services.llm.deepseek_client:_init_client:50 - DeepSeek客户端初始化完成: http://6.86.80.4:30080/v1 - deepseek-v4-flash
2026-05-14 16:43:34 | INFO | src.services.llm.llm_factory:create:113 - LLM客户端创建成功并缓存: deepseek - deepseek-v4-flash
2026-05-14 16:43:34 | SUCCESS | src.services.llm.llm_factory:preload_clients:201 - 预加载LLM客户端成功: deepseek
2026-05-14 16:43:35 | INFO | src.api.main:lifespan:37 - 应用关闭,执行清理...
2026-05-14 16:43:35 | INFO | src.services.llm.llm_factory:cleanup:226 - 所有LLM客户端已清理
2026-05-14 16:46:25 | INFO | src.api.main:lifespan:27 - 启动 AI+合规智能中枢 v0.1.0
2026-05-14 16:46:25 | INFO | src.api.main:lifespan:28 - 调试模式: False
2026-05-14 16:46:25 | INFO | src.api.main:lifespan:31 - 预加载LLM客户端...
2026-05-14 16:46:26 | INFO | src.services.llm.qwen_client:_init_client:59 - Qwen客户端初始化完成: http://6.86.80.4:30080/v1 - qwen3.5-flash
2026-05-14 16:46:26 | INFO | src.services.llm.llm_factory:create:113 - LLM客户端创建成功并缓存: qwen - qwen3.5-flash
2026-05-14 16:46:26 | SUCCESS | src.services.llm.llm_factory:preload_clients:201 - 预加载LLM客户端成功: qwen
2026-05-14 16:46:27 | INFO | src.services.llm.deepseek_client:_init_client:50 - DeepSeek客户端初始化完成: http://6.86.80.4:30080/v1 - deepseek-v4-flash
2026-05-14 16:46:27 | INFO | src.services.llm.llm_factory:create:113 - LLM客户端创建成功并缓存: deepseek - deepseek-v4-flash
2026-05-14 16:46:27 | SUCCESS | src.services.llm.llm_factory:preload_clients:201 - 预加载LLM客户端成功: deepseek
2026-05-14 16:46:40 | INFO | src.api.main:lifespan:37 - 应用关闭,执行清理...
2026-05-14 16:46:40 | INFO | src.services.llm.llm_factory:cleanup:226 - 所有LLM客户端已清理
2026-05-14 16:47:08 | INFO | src.api.main:lifespan:27 - 启动 AI+合规智能中枢 v0.1.0
2026-05-14 16:47:08 | INFO | src.api.main:lifespan:28 - 调试模式: False
2026-05-14 16:47:08 | INFO | src.api.main:lifespan:31 - 预加载LLM客户端...
2026-05-14 16:47:08 | INFO | src.services.llm.qwen_client:_init_client:59 - Qwen客户端初始化完成: http://6.86.80.4:30080/v1 - qwen3.5-flash
2026-05-14 16:47:08 | INFO | src.services.llm.llm_factory:create:113 - LLM客户端创建成功并缓存: qwen - qwen3.5-flash
2026-05-14 16:47:08 | SUCCESS | src.services.llm.llm_factory:preload_clients:201 - 预加载LLM客户端成功: qwen
2026-05-14 16:47:08 | INFO | src.services.llm.deepseek_client:_init_client:50 - DeepSeek客户端初始化完成: http://6.86.80.4:30080/v1 - deepseek-v4-flash
2026-05-14 16:47:08 | INFO | src.services.llm.llm_factory:create:113 - LLM客户端创建成功并缓存: deepseek - deepseek-v4-flash
2026-05-14 16:47:08 | SUCCESS | src.services.llm.llm_factory:preload_clients:201 - 预加载LLM客户端成功: deepseek
2026-05-14 16:57:16 | INFO | src.api.main:lifespan:37 - 应用关闭,执行清理...
2026-05-14 16:57:16 | INFO | src.services.llm.llm_factory:cleanup:226 - 所有LLM客户端已清理
2026-05-14 16:57:36 | INFO | src.api.main:lifespan:27 - 启动 AI+合规智能中枢 v0.1.0
2026-05-14 16:57:36 | INFO | src.api.main:lifespan:28 - 调试模式: False
2026-05-14 16:57:36 | INFO | src.api.main:lifespan:31 - 预加载LLM客户端...
2026-05-14 16:57:36 | INFO | src.api.main:lifespan:27 - 启动 AI+合规智能中枢 v0.1.0
2026-05-14 16:57:36 | INFO | src.api.main:lifespan:28 - 调试模式: False
2026-05-14 16:57:36 | INFO | src.api.main:lifespan:31 - 预加载LLM客户端...
2026-05-14 16:57:36 | INFO | src.services.llm.qwen_client:_init_client:59 - Qwen客户端初始化完成: http://6.86.80.4:30080/v1 - qwen3.5-flash
2026-05-14 16:57:36 | INFO | src.services.llm.llm_factory:create:113 - LLM客户端创建成功并缓存: qwen - qwen3.5-flash
2026-05-14 16:57:36 | SUCCESS | src.services.llm.llm_factory:preload_clients:201 - 预加载LLM客户端成功: qwen
2026-05-14 16:57:36 | INFO | src.services.llm.qwen_client:_init_client:59 - Qwen客户端初始化完成: http://6.86.80.4:30080/v1 - qwen3.5-flash
2026-05-14 16:57:36 | INFO | src.services.llm.llm_factory:create:113 - LLM客户端创建成功并缓存: qwen - qwen3.5-flash
2026-05-14 16:57:36 | SUCCESS | src.services.llm.llm_factory:preload_clients:201 - 预加载LLM客户端成功: qwen
2026-05-14 16:57:37 | INFO | src.services.llm.deepseek_client:_init_client:50 - DeepSeek客户端初始化完成: http://6.86.80.4:30080/v1 - deepseek-v4-flash
2026-05-14 16:57:37 | INFO | src.services.llm.llm_factory:create:113 - LLM客户端创建成功并缓存: deepseek - deepseek-v4-flash
2026-05-14 16:57:37 | SUCCESS | src.services.llm.llm_factory:preload_clients:201 - 预加载LLM客户端成功: deepseek
2026-05-14 16:57:37 | INFO | src.services.llm.deepseek_client:_init_client:50 - DeepSeek客户端初始化完成: http://6.86.80.4:30080/v1 - deepseek-v4-flash
2026-05-14 16:57:37 | INFO | src.services.llm.llm_factory:create:113 - LLM客户端创建成功并缓存: deepseek - deepseek-v4-flash
2026-05-14 16:57:37 | SUCCESS | src.services.llm.llm_factory:preload_clients:201 - 预加载LLM客户端成功: deepseek
2026-05-14 17:14:37 | INFO | src.api.main:lifespan:37 - 应用关闭,执行清理...
2026-05-14 17:14:37 | INFO | src.services.llm.llm_factory:cleanup:226 - 所有LLM客户端已清理
2026-05-14 17:14:37 | INFO | src.api.main:lifespan:37 - 应用关闭,执行清理...
2026-05-14 17:14:37 | INFO | src.services.llm.llm_factory:cleanup:226 - 所有LLM客户端已清理
2026-05-14 17:14:53 | INFO | src.api.main:lifespan:27 - 启动 AI+合规智能中枢 v0.1.0
2026-05-14 17:14:53 | INFO | src.api.main:lifespan:28 - 调试模式: False
2026-05-14 17:14:53 | INFO | src.api.main:lifespan:31 - 预加载LLM客户端...
2026-05-14 17:14:54 | INFO | src.services.llm.qwen_client:_init_client:59 - Qwen客户端初始化完成: http://6.86.80.4:30080/v1 - qwen3.5-flash
2026-05-14 17:14:54 | INFO | src.services.llm.llm_factory:create:113 - LLM客户端创建成功并缓存: qwen - qwen3.5-flash
2026-05-14 17:14:54 | SUCCESS | src.services.llm.llm_factory:preload_clients:201 - 预加载LLM客户端成功: qwen
2026-05-14 17:14:54 | INFO | src.services.llm.deepseek_client:_init_client:50 - DeepSeek客户端初始化完成: http://6.86.80.4:30080/v1 - deepseek-v4-flash
2026-05-14 17:14:54 | INFO | src.services.llm.llm_factory:create:113 - LLM客户端创建成功并缓存: deepseek - deepseek-v4-flash
2026-05-14 17:14:54 | SUCCESS | src.services.llm.llm_factory:preload_clients:201 - 预加载LLM客户端成功: deepseek
2026-05-14 17:16:10 | INFO | src.api.main:lifespan:37 - 应用关闭,执行清理...
2026-05-14 17:16:10 | INFO | src.services.llm.llm_factory:cleanup:226 - 所有LLM客户端已清理
2026-05-14 17:16:21 | INFO | src.api.main:lifespan:27 - 启动 AI+合规智能中枢 v0.1.0
2026-05-14 17:16:21 | INFO | src.api.main:lifespan:28 - 调试模式: False
2026-05-14 17:16:21 | INFO | src.api.main:lifespan:31 - 预加载LLM客户端...
2026-05-14 17:16:22 | INFO | src.services.llm.qwen_client:_init_client:59 - Qwen客户端初始化完成: http://6.86.80.4:30080/v1 - qwen3.5-flash
2026-05-14 17:16:22 | INFO | src.services.llm.llm_factory:create:113 - LLM客户端创建成功并缓存: qwen - qwen3.5-flash
2026-05-14 17:16:22 | SUCCESS | src.services.llm.llm_factory:preload_clients:201 - 预加载LLM客户端成功: qwen
2026-05-14 17:16:22 | INFO | src.services.llm.deepseek_client:_init_client:50 - DeepSeek客户端初始化完成: http://6.86.80.4:30080/v1 - deepseek-v4-flash
2026-05-14 17:16:22 | INFO | src.services.llm.llm_factory:create:113 - LLM客户端创建成功并缓存: deepseek - deepseek-v4-flash
2026-05-14 17:16:22 | SUCCESS | src.services.llm.llm_factory:preload_clients:201 - 预加载LLM客户端成功: deepseek
2026-05-14 17:17:07 | INFO | src.api.main:lifespan:37 - 应用关闭,执行清理...
2026-05-14 17:17:07 | INFO | src.services.llm.llm_factory:cleanup:226 - 所有LLM客户端已清理
2026-05-14 17:19:47 | INFO | app.api.main:lifespan:22 - 启动 AI+合规智能中枢 v0.1.0
2026-05-14 17:19:47 | INFO | app.api.main:lifespan:23 - 调试模式: False
2026-05-14 17:19:47 | INFO | app.api.main:lifespan:24 - 预加载LLM客户端...
2026-05-14 17:19:48 | INFO | app.services.llm.qwen_client:_init_client:58 - Qwen客户端初始化完成: http://6.86.80.4:30080/v1 - qwen3.5-flash
2026-05-14 17:19:48 | INFO | app.services.llm.llm_factory:create:112 - LLM客户端创建成功并缓存: qwen - qwen3.5-flash
2026-05-14 17:19:48 | SUCCESS | app.services.llm.llm_factory:preload_clients:200 - 预加载LLM客户端成功: qwen
2026-05-14 17:19:49 | INFO | app.services.llm.deepseek_client:_init_client:49 - DeepSeek客户端初始化完成: http://6.86.80.4:30080/v1 - deepseek-v4-flash
2026-05-14 17:19:49 | INFO | app.services.llm.llm_factory:create:112 - LLM客户端创建成功并缓存: deepseek - deepseek-v4-flash
2026-05-14 17:19:49 | SUCCESS | app.services.llm.llm_factory:preload_clients:200 - 预加载LLM客户端成功: deepseek

View File

@@ -26,22 +26,26 @@ dependencies = [
"loguru>=0.7.0",
"tenacity>=8.2.0",
"httpx>=0.24.0",
"celery>=5.3.0",
"redis>=4.5.0",
"minio>=7.1.0",
"psycopg2-binary>=2.9.0"
]
[project.optional-dependencies]
mineru = ["magic-pdf[full]>=0.6.0"]
queue = ["celery>=5.3.0", "redis>=4.5.0"]
storage = ["minio>=7.1.0"]
db = ["psycopg2-binary>=2.9.0"]
dev = ["pytest>=7.0.0", "pytest-asyncio>=0.21.0"]
[dependency-groups]
dev = ["pytest>=7.0.0", "pytest-asyncio>=0.21.0", "isort>=8.0.1"]
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"
[tool.setuptools.packages.find]
where = ["src"]
where = ["backend"]
include = ["app*"]
[tool.pytest.ini_options]
testpaths = ["tests"]
asyncio_mode = "auto"
asyncio_mode = "auto"

View File

@@ -1,250 +0,0 @@
#!/bin/bash
# quick_start.sh - 快速启动脚本
# 适配Docker部署的数据库环境
set -e
echo "========================================"
echo "AI+合规智能中枢 - 快速启动脚本"
echo "========================================"
echo ""
# 颜色定义
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
VENV_DIR=".venv"
# 检查Python版本
echo -e "${YELLOW}[1/8] 检查Python环境...${NC}"
if command -v python3 &> /dev/null; then
PYTHON_CMD=python3
else
PYTHON_CMD=python
fi
PYTHON_VERSION=$($PYTHON_CMD --version 2>&1 | awk '{print $2}')
echo "Python版本: $PYTHON_VERSION"
if [[ ! "$PYTHON_VERSION" =~ ^3\.1[0-9] ]]; then
echo -e "${RED}需要Python 3.10+,当前版本: $PYTHON_VERSION${NC}"
exit 1
fi
echo -e "${GREEN}Python环境检查通过${NC}"
echo ""
# 创建虚拟环境
echo -e "${YELLOW}[2/8] 创建虚拟环境...${NC}"
if [ -d "$VENV_DIR" ]; then
echo "虚拟环境已存在: $VENV_DIR"
else
echo "正在创建虚拟环境..."
$PYTHON_CMD -m venv $VENV_DIR
echo -e "${GREEN}虚拟环境创建成功${NC}"
fi
# 激活虚拟环境
source $VENV_DIR/bin/activate
echo "已激活虚拟环境: $VENV_DIR"
echo ""
# 安装依赖(使用国内镜像源)
echo -e "${YELLOW}[3/8] 安装Python依赖...${NC}"
# 配置pip使用清华镜像源国内加速
PIP_MIRROR="https://mirrors.aliyun.com/pypi/simple"
echo "使用镜像源: $PIP_MIRROR"
pip config set global.index-url $PIP_MIRROR -q
pip install --upgrade pip -q
pip install -r requirements.txt -q
if [ $? -eq 0 ]; then
echo -e "${GREEN}依赖安装完成${NC}"
else
echo -e "${RED}依赖安装失败请检查requirements.txt${NC}"
exit 1
fi
echo ""
# 检查Docker容器状态
echo -e "${YELLOW}[4/8] 检查Docker容器状态...${NC}"
REQUIRED_CONTAINERS="milvus minio redis postgres"
for container in $REQUIRED_CONTAINERS; do
if docker ps --format '{{.Names}}' | grep -q "^${container}$"; then
echo -e "${GREEN}${container} 容器运行正常${NC}"
elif docker ps -a --format '{{.Names}}' | grep -q "^${container}$"; then
echo -e "${YELLOW}${container} 容器已停止,尝试启动...${NC}"
docker start ${container}
sleep 2
if docker ps --format '{{.Names}}' | grep -q "^${container}$"; then
echo -e "${GREEN}${container} 已启动${NC}"
else
echo -e "${RED}${container} 启动失败${NC}"
fi
else
echo -e "${RED}${container} 容器不存在${NC}"
fi
done
echo ""
# 检查PostgreSQL连接通过Python
echo -e "${YELLOW}[5/8] 检查PostgreSQL连接...${NC}"
python << 'EOF'
import sys
try:
import psycopg2
conn = psycopg2.connect(
host="localhost",
port=5432,
user="postgresql",
password="postgresql123456",
database="postgres"
)
cur = conn.cursor()
# 检查compliance_db是否存在
cur.execute("SELECT 1 FROM pg_database WHERE datname='compliance_db'")
exists = cur.fetchone()
if not exists:
cur.execute("CREATE DATABASE compliance_db")
print("数据库 compliance_db 创建成功")
else:
print("数据库 compliance_db 已存在")
conn.commit()
cur.close()
conn.close()
print("PostgreSQL连接成功")
except Exception as e:
print(f"PostgreSQL连接失败: {e}")
sys.exit(1)
EOF
if [ $? -eq 0 ]; then
echo -e "${GREEN}PostgreSQL服务运行正常${NC}"
else
echo -e "${RED}PostgreSQL连接失败${NC}"
exit 1
fi
echo ""
# 检查Redis连接通过Python
echo -e "${YELLOW}[6/8] 检查Redis连接...${NC}"
python << 'EOF'
import sys
try:
import redis
r = redis.Redis(
host="localhost",
port=6379,
password="redis@123",
decode_responses=True
)
result = r.ping()
if result:
print("Redis连接成功")
else:
sys.exit(1)
except Exception as e:
print(f"Redis连接失败: {e}")
sys.exit(1)
EOF
if [ $? -eq 0 ]; then
echo -e "${GREEN}Redis服务运行正常${NC}"
else
echo -e "${RED}Redis连接失败${NC}"
exit 1
fi
echo ""
# 检查Milvus连接通过Python
echo -e "${YELLOW}[7/8] 检查Milvus连接...${NC}"
python << 'EOF'
import sys
try:
from pymilvus import connections, utility
connections.connect(host="localhost", port=19530)
print("Milvus连接成功")
connections.disconnect("default")
except Exception as e:
print(f"Milvus连接失败: {e}")
sys.exit(1)
EOF
if [ $? -eq 0 ]; then
echo -e "${GREEN}Milvus服务运行正常${NC}"
else
echo -e "${RED}Milvus连接失败${NC}"
exit 1
fi
echo ""
# 检查MinIO连接通过Python
echo -e "${YELLOW}[8/8] 检查MinIO连接...${NC}"
python << 'EOF'
import sys
try:
from minio import Minio
client = Minio("localhost:9000", "minioadmin", "minioadmin", secure=False)
# 检查bucket是否存在
bucket = "compliance-docs"
if not client.bucket_exists(bucket):
client.make_bucket(bucket)
print(f"MinIO bucket '{bucket}' 创建成功")
else:
print(f"MinIO bucket '{bucket}' 已存在")
print("MinIO连接成功")
except Exception as e:
print(f"MinIO连接失败: {e}")
sys.exit(1)
EOF
if [ $? -eq 0 ]; then
echo -e "${GREEN}MinIO服务运行正常${NC}"
else
echo -e "${RED}MinIO连接失败${NC}"
exit 1
fi
echo ""
# 预下载BGE-M3模型可选
echo -e "${YELLOW}[提示] BGE-M3嵌入模型...${NC}"
MODEL_CACHE=~/.cache/huggingface/hub/models--BAAI--bge-m3
if [ -d "$MODEL_CACHE" ]; then
echo -e "${GREEN}BGE-M3模型已存在${NC}"
else
echo -e "${YELLOW}模型未下载首次运行时将自动下载约2GB${NC}"
echo "手动预下载: python -c \"from FlagEmbedding import BGEM3FlagModel; BGEM3FlagModel('BAAI/bge-m3')\""
fi
echo ""
# 输出启动命令
echo "========================================"
echo -e "${GREEN}环境检查完成!${NC}"
echo "========================================"
echo ""
echo "虚拟环境: $VENV_DIR"
echo ""
echo "启动API服务:"
echo " ./start_api.sh"
echo ""
echo "启动前端页面:"
echo " ./start_frontend.sh"
echo " 访问: http://localhost:3000/prototype.html"
echo ""
echo "后台启动:"
echo " ./start_api_background.sh"
echo ""
echo "API文档地址:"
echo " http://localhost:8000/docs"
echo " http://localhost:8000/health"
echo ""

View File

@@ -26,11 +26,11 @@ python-docx>=0.8.11
FlagEmbedding>=1.2.0
sentence-transformers>=2.2.0
# 任务队列(可选)
# 任务队列
celery>=5.3.0
redis>=4.5.0
# 对象存储(可选)
# 对象存储
minio>=7.1.0
# 数据库

View File

@@ -1,23 +0,0 @@
#!/bin/bash
# restart_all.sh - 重启所有服务
set -e
# 颜色定义
CYAN='\033[0;36m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
echo ""
echo -e "${CYAN}========================================${NC}"
echo -e "${CYAN} AI+合规智能中枢 - 重启服务${NC}"
echo -e "${CYAN}========================================${NC}"
echo ""
echo -e "${YELLOW}正在停止所有服务...${NC}"
./stop_all.sh
echo ""
echo -e "${YELLOW}正在启动所有服务...${NC}"
./start_all.sh

View File

@@ -13,10 +13,11 @@ import argparse
import sys
import os
# 添加项目路径
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
# 添加 backend 到导入路径
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, os.path.join(PROJECT_ROOT, "backend"))
from src.config.settings import settings
from app.config.settings import settings
from loguru import logger
@@ -52,7 +53,7 @@ def clear_milvus(dry_run: bool = False):
# 重新创建collection可选
logger.info("重新创建collection...")
from src.services.storage.milvus_client import MilvusClient
from app.services.storage.milvus_client import MilvusClient
client = MilvusClient()
client.connect()
client.create_collection(recreate=True)
@@ -190,4 +191,4 @@ def main():
if __name__ == "__main__":
sys.exit(main())
sys.exit(main())

View File

@@ -1,4 +0,0 @@
# src/__init__.py
"""AI+合规智能中枢 - 法律法规文档解析入库功能"""
__version__ = "0.1.0"

View File

@@ -1,2 +0,0 @@
# src/api/__init__.py
"""API接口模块"""

View File

@@ -1,111 +0,0 @@
# src/api/main.py
"""FastAPI应用入口"""
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from contextlib import asynccontextmanager
from loguru import logger
import sys
import os
# 设置日志
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(__file__))))
from src.config.logging import setup_logging
from src.config.settings import settings
from src.api.routes import api_router
from src.api.models import ErrorResponse
from src.services.llm.llm_factory import LLMFactory
# 配置日志
setup_logging(level="INFO" if not settings.debug else "DEBUG")
@asynccontextmanager
async def lifespan(app: FastAPI):
"""应用生命周期管理"""
logger.info(f"启动 {settings.app_name} v{settings.app_version}")
logger.info(f"调试模式: {settings.debug}")
# 启动时预加载LLM客户端
logger.info("预加载LLM客户端...")
LLMFactory.preload_clients(["qwen", "deepseek"])
yield
# 关闭时清理LLM客户端
logger.info("应用关闭,执行清理...")
LLMFactory.cleanup()
# 创建FastAPI应用
app = FastAPI(
title=settings.app_name,
description="AI+合规智能中枢 - 法律法规文档解析入库功能\n\n支持PDF/DOCX文档解析、智能分块、向量嵌入、Milvus存储",
version=settings.app_version,
lifespan=lifespan,
docs_url="/docs",
redoc_url="/redoc"
)
# CORS中间件
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # 生产环境应配置具体域名
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# 注册路由
app.include_router(api_router, prefix="/api/v1")
# 全局异常处理
@app.exception_handler(Exception)
async def global_exception_handler(request: Request, exc: Exception):
"""全局异常处理"""
logger.error(f"未处理的异常: {exc}")
return JSONResponse(
status_code=500,
content=ErrorResponse(
error="InternalServerError",
message=str(exc)
).model_dump()
)
# 健康检查接口
@app.get("/health", tags=["health"])
async def health_check():
"""健康检查"""
return {
"status": "healthy",
"app": settings.app_name,
"version": settings.app_version
}
# 根路径
@app.get("/", tags=["root"])
async def root():
"""根路径"""
return {
"message": f"Welcome to {settings.app_name}",
"version": settings.app_version,
"docs": "/docs",
"health": "/health"
}
if __name__ == "__main__":
import uvicorn
uvicorn.run(
"main:app",
host=settings.api_host,
port=settings.api_port,
reload=settings.debug,
log_level="info"
)

View File

@@ -1,22 +0,0 @@
# src/api/models/__init__.py
"""API数据模型"""
from .document import (
DocumentUploadRequest,
DocumentUploadResponse,
SearchRequest,
SearchResultItem,
SearchResponse,
DocumentStatusResponse,
ErrorResponse
)
__all__ = [
"DocumentUploadRequest",
"DocumentUploadResponse",
"SearchRequest",
"SearchResultItem",
"SearchResponse",
"DocumentStatusResponse",
"ErrorResponse"
]

View File

@@ -1,63 +0,0 @@
# src/api/models/document.py
"""文档相关Pydantic数据模型"""
from pydantic import BaseModel, Field
from typing import List, Optional, Dict, Any
from datetime import datetime
class DocumentUploadRequest(BaseModel):
"""文档上传请求"""
doc_name: Optional[str] = Field(None, description="文档名称")
regulation_type: Optional[str] = Field(None, description="法规类型")
version: Optional[str] = Field(None, description="文档版本")
class DocumentUploadResponse(BaseModel):
"""文档上传响应"""
doc_id: str = Field(..., description="文档ID")
doc_name: str = Field(..., description="文档名称")
status: str = Field(..., description="处理状态")
message: str = Field(default="", description="状态消息")
num_chunks: int = Field(default=0, description="分块数量")
summary: str = Field(default="", description="LLM生成的文档摘要")
summary_latency_ms: int = Field(default=0, description="摘要生成耗时(ms)")
timestamp: datetime = Field(default_factory=datetime.now, description="时间戳")
class SearchRequest(BaseModel):
"""检索请求"""
query: str = Field(..., description="查询文本")
top_k: int = Field(default=10, description="返回结果数量")
filters: Optional[str] = Field(None, description="过滤条件")
class SearchResultItem(BaseModel):
"""单个检索结果"""
id: int = Field(..., description="记录ID")
content: str = Field(..., description="内容")
score: float = Field(..., description="相似度分数")
metadata: Dict[str, Any] = Field(default_factory=dict, description="元数据")
class SearchResponse(BaseModel):
"""检索响应"""
query: str = Field(..., description="查询文本")
total: int = Field(..., description="结果总数")
results: List[SearchResultItem] = Field(default_factory=list, description="结果列表")
timestamp: datetime = Field(default_factory=datetime.now, description="时间戳")
class DocumentStatusResponse(BaseModel):
"""文档状态响应"""
doc_id: str = Field(..., description="文档ID")
status: str = Field(..., description="状态")
num_chunks: Optional[int] = Field(None, description="分块数量")
timestamp: datetime = Field(default_factory=datetime.now, description="时间戳")
class ErrorResponse(BaseModel):
"""错误响应"""
error: str = Field(..., description="错误类型")
message: str = Field(..., description="错误消息")
timestamp: datetime = Field(default_factory=datetime.now, description="时间戳")

View File

@@ -1,17 +0,0 @@
# src/api/routes/__init__.py
"""API路由模块"""
from fastapi import APIRouter
from .documents import router as documents_router
from .knowledge import router as knowledge_router
from .agent import router as agent_router
# 主路由
api_router = APIRouter()
# 注册子路由
api_router.include_router(documents_router)
api_router.include_router(knowledge_router)
api_router.include_router(agent_router)
__all__ = ["api_router", "documents_router", "knowledge_router", "agent_router"]

View File

@@ -1,449 +0,0 @@
# src/api/routes/agent.py
"""Agent API接口 - 问答对话接口"""
from fastapi import APIRouter, HTTPException, Depends
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
from typing import List, Dict, Optional, AsyncGenerator
from loguru import logger
import json
import asyncio
from src.services.agent.qa_agent import QAAgent, AgentResponse, AgentConfig
from src.services.agent.session_manager import SessionManager, ChatSession
from src.config.settings import settings
router = APIRouter(prefix="/agent", tags=["agent"])
# 会话管理器(全局实例)
session_manager = SessionManager()
# ===== Pydantic Models =====
class AskRequest(BaseModel):
"""单次问答请求"""
query: str = Field(..., description="用户问题", min_length=1, max_length=2000)
filters: Optional[str] = Field(None, description="检索过滤条件")
provider: Optional[str] = Field(None, description="LLM提供商 (qwen/deepseek)")
model: Optional[str] = Field(None, description="LLM模型名称")
top_k: Optional[int] = Field(None, description="检索数量", ge=1, le=20)
prompt_template: Optional[str] = Field(None, description="Prompt模板名称")
class AskResponse(BaseModel):
"""问答响应"""
answer: str
sources: List[Dict] = []
model: str = ""
latency_ms: int = 0
retrieved_count: int = 0
context_tokens: int = 0
truncated: bool = False
error: Optional[str] = None
class ChatRequest(BaseModel):
"""多轮对话请求"""
query: str = Field(..., description="用户问题", min_length=1, max_length=2000)
session_id: Optional[str] = Field(None, description="会话ID首次对话可不传")
filters: Optional[str] = Field(None, description="检索过滤条件")
provider: Optional[str] = Field(None, description="LLM提供商")
model: Optional[str] = Field(None, description="LLM模型名称")
class ChatResponse(BaseModel):
"""多轮对话响应"""
session_id: str
answer: str
sources: List[Dict] = []
model: str = ""
latency_ms: int = 0
message_count: int = 0
class SessionInfo(BaseModel):
"""会话信息"""
session_id: str
message_count: int
created_at: int
updated_at: int
class FeedbackRequest(BaseModel):
"""反馈请求"""
session_id: str
message_index: int
rating: int = Field(..., ge=1, le=5, description="评分 1-5")
comment: Optional[str] = Field(None, description="反馈内容")
class TemplateListResponse(BaseModel):
"""模板列表响应"""
templates: Dict[str, str]
# ===== API Endpoints =====
@router.post("/ask", response_model=AskResponse)
async def ask_question(request: AskRequest):
"""
单次问答接口
不保存会话历史,适合单次查询场景。
"""
logger.info(f"收到问答请求: {request.query}")
try:
# 构建Agent配置
config = AgentConfig(
llm_provider=request.provider or settings.llm_provider,
llm_model=request.model or settings.llm_model,
top_k=request.top_k or settings.rag_top_k
)
# 创建Agent并执行问答
agent = QAAgent(config)
response = agent.ask(
query=request.query,
filters=request.filters,
prompt_template=request.prompt_template
)
agent.close()
return AskResponse(
answer=response.answer,
sources=response.sources,
model=response.model,
latency_ms=response.latency_ms,
retrieved_count=response.retrieved_count,
context_tokens=response.context_tokens,
truncated=response.truncated,
error=response.error
)
except Exception as e:
logger.error(f"问答失败: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/chat", response_model=ChatResponse)
async def chat_with_session(request: ChatRequest):
"""
多轮对话接口
支持会话历史记录,适合连续对话场景。
"""
logger.info(f"收到对话请求: session={request.session_id}, query={request.query}")
try:
# 获取或创建会话
if request.session_id:
session = session_manager.get_session(request.session_id)
if not session:
raise HTTPException(status_code=404, detail="会话不存在或已过期")
else:
session = session_manager.create_session()
# 添加用户消息
session.add_user_message(request.query)
# 执行问答
config = AgentConfig(
llm_provider=request.provider or settings.llm_provider,
llm_model=request.model or settings.llm_model
)
agent = QAAgent(config)
response = agent.ask(
query=request.query,
filters=request.filters
)
agent.close()
# 添加助手消息
session.add_assistant_message(
response.answer,
response.sources
)
return ChatResponse(
session_id=session.session_id,
answer=response.answer,
sources=response.sources,
model=response.model,
latency_ms=response.latency_ms,
message_count=session.message_count
)
except HTTPException:
raise
except Exception as e:
logger.error(f"对话失败: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/chat/stream")
async def chat_stream_get(
query: str,
session_id: Optional[str] = None,
filters: Optional[str] = None,
provider: Optional[str] = None,
model: Optional[str] = None
):
"""
流式对话接口SSE- GET版本
EventSource只能发送GET请求因此提供此接口。
query参数通过URL传递。
SSE事件格式
- event: session - 会话ID
- event: status - 状态更新(检索中、生成中)
- event: sources - 引用来源
- event: content - 回答内容片段
- event: done - 完成,包含统计信息
- event: error - 错误信息
"""
logger.info(f"收到GET流式对话请求: session={session_id}, query={query}")
async def generate_sse() -> AsyncGenerator[str, None]:
"""生成SSE事件流"""
try:
# 获取或创建会话
if session_id:
session = session_manager.get_session(session_id)
if not session:
yield f"event: error\ndata: 会话不存在或已过期\n\n"
return
else:
session = session_manager.create_session()
# 发送session_id
yield f"event: session\ndata: {json.dumps({'session_id': session.session_id})}\n\n"
# 添加用户消息
session.add_user_message(query)
# 创建Agent
config = AgentConfig(
llm_provider=provider or settings.llm_provider,
llm_model=model or settings.llm_model
)
agent = QAAgent(config)
# 执行流式问答
full_answer = ""
sources = []
done_data = {}
for event_data in agent.ask_stream(
query=query,
filters=filters
):
event_type = event_data.get("event", "content")
data = event_data.get("data", "")
# 收集完整回答和来源
if event_type == "content":
full_answer += str(data)
elif event_type == "sources":
sources = data
elif event_type == "done":
done_data = data
# 发送SSE事件
if isinstance(data, (dict, list)):
yield f"event: {event_type}\ndata: {json.dumps(data)}\n\n"
else:
yield f"event: {event_type}\ndata: {data}\n\n"
# 小延迟让其他任务有机会执行
await asyncio.sleep(0)
agent.close()
# 保存到会话历史
session.add_assistant_message(full_answer, sources)
except Exception as e:
logger.error(f"流式对话失败: {e}")
yield f"event: error\ndata: {str(e)}\n\n"
return StreamingResponse(
generate_sse(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no" # 禁用nginx缓冲
}
)
@router.post("/chat/stream")
async def chat_stream(request: ChatRequest):
"""
流式对话接口SSE
返回Server-Sent Events格式的流式响应用户可实时看到思考过程和回答生成。
SSE事件格式
- event: status - 状态更新(检索中、生成中)
- event: sources - 引用来源
- event: content - 回答内容片段
- event: done - 完成,包含统计信息
- event: error - 错误信息
"""
logger.info(f"收到流式对话请求: session={request.session_id}, query={request.query}")
async def generate_sse() -> AsyncGenerator[str, None]:
"""生成SSE事件流"""
try:
# 获取或创建会话
if request.session_id:
session = session_manager.get_session(request.session_id)
if not session:
yield f"event: error\ndata: 会话不存在或已过期\n\n"
return
else:
session = session_manager.create_session()
# 发送session_id
yield f"event: session\ndata: {json.dumps({'session_id': session.session_id})}\n\n"
# 添加用户消息
session.add_user_message(request.query)
# 创建Agent
config = AgentConfig(
llm_provider=request.provider or settings.llm_provider,
llm_model=request.model or settings.llm_model
)
agent = QAAgent(config)
# 执行流式问答
full_answer = ""
sources = []
done_data = {}
for event_data in agent.ask_stream(
query=request.query,
filters=request.filters
):
event_type = event_data.get("event", "content")
data = event_data.get("data", "")
# 收集完整回答和来源
if event_type == "content":
full_answer += str(data)
elif event_type == "sources":
sources = data
elif event_type == "done":
done_data = data
# 发送SSE事件
if isinstance(data, (dict, list)):
yield f"event: {event_type}\ndata: {json.dumps(data)}\n\n"
else:
yield f"event: {event_type}\ndata: {data}\n\n"
# 小延迟让其他任务有机会执行
await asyncio.sleep(0)
agent.close()
# 保存到会话历史
session.add_assistant_message(full_answer, sources)
except Exception as e:
logger.error(f"流式对话失败: {e}")
yield f"event: error\ndata: {str(e)}\n\n"
return StreamingResponse(
generate_sse(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no" # 禁用nginx缓冲
}
)
@router.get("/session/{session_id}", response_model=SessionInfo)
async def get_session_info(session_id: str):
"""获取会话信息"""
session = session_manager.get_session(session_id)
if not session:
raise HTTPException(status_code=404, detail="会话不存在或已过期")
return SessionInfo(
session_id=session.session_id,
message_count=session.message_count,
created_at=session.created_at,
updated_at=session.updated_at
)
@router.get("/session/{session_id}/history")
async def get_session_history(session_id: str, max_turns: int = 5):
"""获取会话历史"""
session = session_manager.get_session(session_id)
if not session:
raise HTTPException(status_code=404, detail="会话不存在或已过期")
history = session.get_history(max_turns)
return {"session_id": session_id, "history": history}
@router.delete("/session/{session_id}")
async def delete_session(session_id: str):
"""删除会话"""
success = session_manager.delete_session(session_id)
if not success:
raise HTTPException(status_code=404, detail="会话不存在")
return {"message": "会话已删除", "session_id": session_id}
@router.get("/sessions", response_model=List[SessionInfo])
async def list_sessions():
"""列出所有活跃会话"""
sessions = session_manager.list_sessions()
return [SessionInfo(**s) for s in sessions]
@router.post("/feedback")
async def submit_feedback(request: FeedbackRequest):
"""提交问答反馈"""
session = session_manager.get_session(request.session_id)
if not session:
raise HTTPException(status_code=404, detail="会话不存在")
# 记录反馈(实际应用中可存储到数据库)
logger.info(f"收到反馈: session={request.session_id}, rating={request.rating}, comment={request.comment}")
return {"message": "反馈已记录", "rating": request.rating}
@router.get("/templates", response_model=TemplateListResponse)
async def list_prompt_templates():
"""列出可用的Prompt模板"""
from src.services.rag.prompt_templates import PromptTemplates
templates = PromptTemplates.list_templates()
return TemplateListResponse(templates=templates)
@router.get("/models")
async def list_available_models():
"""列出可用的LLM模型"""
from src.services.llm import LLMFactory
factory = LLMFactory()
models = factory.list_available_providers()
return {"models": models}

View File

@@ -1,291 +0,0 @@
# src/api/routes/documents.py
"""文档上传与处理接口"""
from fastapi import APIRouter, UploadFile, File, Form, HTTPException
from fastapi.responses import FileResponse, StreamingResponse
from typing import Optional
import os
import uuid
import tempfile
from pathlib import Path
from loguru import logger
from io import BytesIO
from urllib.parse import quote
from ..models import DocumentUploadResponse, ErrorResponse
from src.services.document_processor import DocumentProcessor
from src.services.storage.minio_client import MinIOClient
from src.config.settings import settings
router = APIRouter(prefix="/documents", tags=["documents"])
# MinIO客户端用于文档存储
minio_client: Optional[MinIOClient] = None
def get_minio_client() -> MinIOClient:
"""获取MinIO客户端实例"""
global minio_client
if minio_client is None:
minio_client = MinIOClient()
minio_client.connect()
minio_client.ensure_bucket()
return minio_client
def _build_document_records(limit: Optional[int] = None):
"""构建文档列表记录,支持按最近更新时间倒序截断。"""
minio = get_minio_client()
document_records = []
objects = minio.client.list_objects(minio.bucket, recursive=True)
for obj in objects:
parts = obj.object_name.split("/", 1)
if len(parts) != 2:
continue
doc_id, filename = parts
last_modified = getattr(obj, "last_modified", None)
document_records.append({
"doc_id": doc_id,
"filename": filename,
"size": getattr(obj, "size", 0) or 0,
"object_name": obj.object_name,
"download_url": f"/api/v1/documents/download/{doc_id}",
"last_modified": last_modified.isoformat() if last_modified else None,
"_sort_key": last_modified.timestamp() if last_modified else 0,
})
document_records.sort(key=lambda item: item["_sort_key"], reverse=True)
if limit is not None:
document_records = document_records[:limit]
for item in document_records:
item.pop("_sort_key", None)
return document_records
@router.post("/upload", response_model=DocumentUploadResponse)
async def upload_document(
file: UploadFile = File(..., description="上传的文档文件"),
doc_name: Optional[str] = Form(None, description="文档名称"),
regulation_type: Optional[str] = Form(None, description="法规类型"),
version: Optional[str] = Form(None, description="文档版本"),
generate_summary: bool = Form(False, description="是否生成摘要默认不生成可节省约60秒")
):
"""
上传文档并处理
支持格式PDF、DOCX、DOC
处理流程:解析 → 分块 → 嵌入 → 入库(摘要可选)
文件存储MinIO对象存储
参数说明:
- generate_summary: 是否生成LLM摘要默认False。勾选后处理时间增加约60秒。
"""
# 验证文件类型
ext = os.path.splitext(file.filename)[1].lower()
if ext not in [".pdf", ".docx", ".doc"]:
raise HTTPException(
status_code=400,
detail=f"不支持的文件类型: {ext}仅支持PDF、DOCX、DOC"
)
# 验证文件大小
if file.size and file.size > settings.max_file_size_mb * 1024 * 1024:
raise HTTPException(
status_code=400,
detail=f"文件过大,最大支持{settings.max_file_size_mb}MB"
)
# 生成文档ID
doc_id = str(uuid.uuid4())[:8]
# 文档名称
final_doc_name = doc_name or file.filename
# MinIO对象名称
object_name = f"{doc_id}/{file.filename}"
logger.info(f"接收到文件上传: {final_doc_name}, 类型: {ext}, doc_id={doc_id}")
try:
# 读取文件内容
content = await file.read()
# 保存临时文件用于处理
temp_dir = tempfile.gettempdir()
temp_path = os.path.join(temp_dir, f"{doc_id}_{file.filename}")
with open(temp_path, "wb") as f:
f.write(content)
logger.info(f"临时文件已保存到: {temp_path}")
# 上传到MinIO
minio = get_minio_client()
upload_success = minio.upload_bytes(
data=content,
object_name=object_name,
content_type=minio._get_content_type(file.filename),
metadata={
"doc_id": doc_id # 仅传递ASCII安全的metadata
}
)
if upload_success:
logger.success(f"文件已上传到MinIO: {object_name}")
else:
logger.warning(f"MinIO上传失败仅使用本地临时文件")
# 处理文档传入相同的doc_id保持一致性
processor = DocumentProcessor(generate_summary=generate_summary)
result = processor.process(
file_path=temp_path,
doc_id=doc_id, # 使用相同的doc_id
doc_name=final_doc_name,
regulation_type=regulation_type or "",
version=version or ""
)
processor.close()
# 清理临时文件
try:
os.remove(temp_path)
except:
pass
if result.success:
return DocumentUploadResponse(
doc_id=result.doc_id,
doc_name=result.doc_name,
status="success",
message=result.message,
num_chunks=result.num_chunks,
summary=result.summary,
summary_latency_ms=result.summary_latency_ms
)
else:
raise HTTPException(
status_code=500,
detail=result.message
)
except Exception as e:
logger.error(f"文档处理失败: {e}")
raise HTTPException(
status_code=500,
detail=f"文档处理失败: {str(e)}"
)
@router.get("/status/{doc_id}", response_model=DocumentUploadResponse)
async def get_document_status(doc_id: str):
"""
查询文档处理状态
Args:
doc_id: 文档ID
"""
# TODO: 实现状态查询(需要数据库支持)
return DocumentUploadResponse(
doc_id=doc_id,
doc_name="",
status="unknown",
message="状态查询功能待实现"
)
@router.get("/download/{doc_id}")
async def download_document(doc_id: str):
"""
下载文档从MinIO获取
Args:
doc_id: 文档ID
Returns:
文件下载响应
"""
logger.info(f"请求下载文档: doc_id={doc_id}")
try:
minio = get_minio_client()
# 查找该doc_id下的文件MinIO对象名称格式: {doc_id}/{filename}
objects = minio.list_objects(prefix=f"{doc_id}/")
if not objects:
logger.warning(f"MinIO中未找到文档: doc_id={doc_id}")
raise HTTPException(
status_code=404,
detail=f"文档不存在: doc_id={doc_id}"
)
# 获取第一个匹配的对象
object_name = objects[0]
logger.info(f"找到MinIO对象: {object_name}")
# 获取文件数据
file_data = minio.get_object_data(object_name)
if file_data is None:
raise HTTPException(
status_code=500,
detail=f"获取文档数据失败"
)
# 解析原始文件名
original_name = object_name.split("/", 1)[1] if "/" in object_name else object_name
# 获取Content-Type
content_type = minio._get_content_type(original_name)
logger.success(f"文档下载成功: {original_name}, 大小={len(file_data)}")
# 返回文件流URL编码文件名以支持中文
encoded_name = quote(original_name)
return StreamingResponse(
BytesIO(file_data),
media_type=content_type,
headers={
"Content-Disposition": f"attachment; filename*=UTF-8''{encoded_name}"
}
)
except HTTPException:
raise
except Exception as e:
logger.error(f"文档下载失败: {e}")
raise HTTPException(
status_code=500,
detail=f"文档下载失败: {str(e)}"
)
@router.get("/list")
async def list_documents():
"""
列出所有已上传的文档从MinIO获取
"""
try:
documents = _build_document_records()
return {"documents": documents, "total": len(documents)}
except Exception as e:
logger.error(f"列出文档失败: {e}")
return {"documents": [], "total": 0, "error": str(e)}
@router.get("/management-list")
async def get_document_management_list():
"""
文档管理清单接口仅返回最近的10条文档。
"""
try:
documents = _build_document_records(limit=10)
return {"documents": documents, "total": len(documents), "limit": 10}
except Exception as e:
logger.error(f"获取文档管理清单失败: {e}")
return {"documents": [], "total": 0, "limit": 10, "error": str(e)}

View File

@@ -1,81 +0,0 @@
# src/api/routes/knowledge.py
"""知识库检索接口"""
from fastapi import APIRouter, HTTPException
from loguru import logger
from ..models import SearchRequest, SearchResponse, SearchResultItem, ErrorResponse
from src.services.document_processor import DocumentProcessor
router = APIRouter(prefix="/knowledge", tags=["knowledge"])
@router.post("/search", response_model=SearchResponse)
async def search_knowledge(request: SearchRequest):
"""
检索法规知识库
使用混合检索Dense向量 + Sparse向量 + RRF融合
Args:
request: 检索请求参数
"""
if not request.query or len(request.query.strip()) == 0:
raise HTTPException(
status_code=400,
detail="查询文本不能为空"
)
logger.info(f"收到检索请求: {request.query}")
try:
# 执行检索
processor = DocumentProcessor()
results = processor.search(
query=request.query,
top_k=request.top_k,
filters=request.filters
)
processor.close()
# 转换结果格式
result_items = []
for r in results:
item = SearchResultItem(
id=r.get("id", 0),
content=r.get("content", ""),
score=r.get("score", 0.0),
metadata=r.get("metadata", {})
)
result_items.append(item)
return SearchResponse(
query=request.query,
total=len(result_items),
results=result_items
)
except Exception as e:
logger.error(f"检索失败: {e}")
raise HTTPException(
status_code=500,
detail=f"检索失败: {str(e)}"
)
@router.post("/retrieval", response_model=SearchResponse)
async def knowledge_retrieval(request: SearchRequest):
"""
知识检索接口(与架构文档对齐)
该接口实现完整的检索流程:
1. 意图识别
2. BM25关键词检索 + 向量语义检索(双路召回)
3. Cross-Encoder精排
4. 返回结果
Args:
request: 检索请求
"""
# 当前版本使用混合检索,后续可添加精排步骤
return await search_knowledge(request)

View File

@@ -1,6 +0,0 @@
# src/config/__init__.py
"""配置模块"""
from .settings import Settings, get_settings
__all__ = ["Settings", "get_settings"]

View File

@@ -1,32 +0,0 @@
# src/config/logging.py
"""日志配置"""
from loguru import logger
import sys
def setup_logging(level: str = "INFO"):
"""设置日志配置"""
# 移除默认handler
logger.remove()
# 添加控制台输出
logger.add(
sys.stdout,
level=level,
format="<green>{time:YYYY-MM-DD HH:mm:ss}</green> | <level>{level: <8}</level> | <cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> - <level>{message}</level>",
colorize=True
)
# 添加文件输出
logger.add(
"logs/app_{time:YYYY-MM-DD}.log",
level=level,
format="{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {name}:{function}:{line} - {message}",
rotation="00:00",
retention="7 days",
compression="zip"
)
return logger

View File

@@ -1,95 +0,0 @@
# src/config/settings.py
"""配置管理 - 环境变量和默认配置"""
from pydantic_settings import BaseSettings
from pydantic import Field
from typing import Optional
from functools import lru_cache
class Settings(BaseSettings):
"""应用配置"""
# 应用基础配置
app_name: str = Field(default="AI Regulations Demo", description="Application name")
app_version: str = Field(default="0.1.0", description="应用版本")
debug: bool = Field(default=False, description="调试模式")
# Milvus向量数据库配置
milvus_host: str = Field(default="localhost", description="Milvus服务地址")
milvus_port: int = Field(default=19530, description="Milvus服务端口")
milvus_collection: str = Field(default="regulations", description="法规向量集合名称")
milvus_db_name: str = Field(default="default", description="Milvus数据库名称")
# 嵌入模型配置
embedding_model: str = Field(default="BAAI/bge-m3", description="嵌入模型名称")
embedding_dim: int = Field(default=1024, description="嵌入向量维度")
embedding_max_length: int = Field(default=8192, description="最大嵌入长度")
embedding_batch_size: int = Field(default=12, description="嵌入批处理大小")
embedding_use_fp16: bool = Field(default=True, description="使用FP16加速")
# MinIO对象存储配置
minio_endpoint: str = Field(default="localhost:9000", description="MinIO服务地址")
minio_access_key: str = Field(default="minioadmin", description="MinIO访问密钥")
minio_secret_key: str = Field(default="minioadmin123", description="MinIO秘密密钥")
minio_bucket: str = Field(default="upload-files", description="文档存储桶名称")
minio_secure: bool = Field(default=False, description="是否使用HTTPS")
# Redis配置
redis_host: str = Field(default="localhost", description="Redis服务地址")
redis_port: int = Field(default=6379, description="Redis服务端口")
redis_password: str = Field(default="", description="Redis密码")
redis_db: int = Field(default=0, description="Redis数据库编号")
# PostgreSQL配置
postgres_host: str = Field(default="localhost", description="PostgreSQL服务地址")
postgres_port: int = Field(default=5432, description="PostgreSQL服务端口")
postgres_user: str = Field(default="compliance", description="PostgreSQL用户名")
postgres_password: str = Field(default="compliance123", description="PostgreSQL密码")
postgres_db: str = Field(default="compliance_db", description="PostgreSQL数据库名称")
# 文档处理配置
chunk_size: int = Field(default=512, description="分块大小(字符数)")
chunk_overlap: int = Field(default=50, description="分块重叠大小")
max_file_size_mb: int = Field(default=100, description="最大文件大小(MB)")
# API配置
api_host: str = Field(default="0.0.0.0", description="API服务地址")
api_port: int = Field(default=8000, description="API服务端口")
# LLM配置
llm_provider: str = Field(default="deepseek", description="LLM提供商 (deepseek/qwen/qwen_vl)")
llm_model: str = Field(default="deepseek-v4-flash", description="LLM模型名称")
llm_max_tokens: int = Field(default=4096, description="LLM最大输出token数")
llm_temperature: float = Field(default=0.7, description="LLM温度参数")
# DeepSeek配置
deepseek_api_key: str = Field(default="", description="DeepSeek API密钥")
deepseek_base_url: str = Field(default="http://6.86.80.4:30080/v1", description="DeepSeek API地址")
deepseek_model: str = Field(default="deepseek-v4-flash", description="DeepSeek模型")
# Qwen配置通过统一代理API
qwen_api_key: str = Field(default="", description="Qwen API密钥")
qwen_base_url: str = Field(default="http://6.86.80.4:30080/v1", description="Qwen API地址")
qwen_model: str = Field(default="qwen3.5-flash", description="Qwen文本模型")
qwen_vl_model: str = Field(default="qwen3-vl-plus", description="Qwen视觉模型")
# RAG配置
rag_top_k: int = Field(default=5, description="检索召回数量")
rag_max_context_tokens: int = Field(default=2000, description="RAG最大上下文token数")
rag_summary_max_tokens: int = Field(default=10240, description="文档摘要最大token数")
class Config:
env_file = ".env"
env_file_encoding = "utf-8"
extra = "ignore"
@lru_cache
def get_settings() -> Settings:
"""获取配置实例(缓存)"""
return Settings()
# 导出默认配置实例
settings = get_settings()

View File

@@ -1,2 +0,0 @@
# src/services/__init__.py
"""业务服务模块"""

View File

@@ -1,7 +0,0 @@
# src/services/agent/__init__.py
"""Agent服务模块"""
from .qa_agent import QAAgent, ask_compliance_question
from .session_manager import SessionManager, ChatSession
__all__ = ["QAAgent", "ask_compliance_question", "SessionManager", "ChatSession"]

View File

@@ -1,412 +0,0 @@
# src/services/agent/qa_agent.py
"""RAG问答Agent - 合规智能问答核心实现"""
import time
from typing import List, Dict, Optional, Any, Generator
from dataclasses import dataclass, field
from loguru import logger
from src.services.llm import get_llm_client, BaseLLMClient, LLMResponse
from src.services.llm.llm_factory import LLMFactory
from src.services.rag.retriever import Retriever, RetrievedDocument
from src.services.rag.context_builder import ContextBuilder, RAGContext
from src.services.rag.prompt_templates import get_prompt_template, PromptTemplate
from src.config.settings import settings
@dataclass
class AgentResponse:
"""Agent响应结果"""
answer: str
sources: List[Dict] = field(default_factory=list)
model: str = ""
latency_ms: int = 0
retrieved_count: int = 0
context_tokens: int = 0
truncated: bool = False
error: Optional[str] = None
@property
def is_success(self) -> bool:
return self.error is None
@dataclass
class AgentConfig:
"""Agent配置"""
llm_provider: str = "deepseek"
llm_model: str = "deepseek-v4-flash"
top_k: int = 5
min_score: float = 0.3
max_context_tokens: int = 2000
temperature: float = 0.7
prompt_template: str = "compliance_qa"
include_metadata: bool = True
class QAAgent:
"""
合规问答Agent
核心流程:
1. 接收用户问题
2. Milvus混合检索相关法规条款
3. 构建RAG上下文
4. 调用LLM生成回答
5. 返回答案和引用来源
使用示例:
agent = QAAgent()
response = agent.ask("机动车安全技术检验有哪些要求?")
print(response.answer)
for source in response.sources:
print(f"引用: {source['doc_name']} - {source['clause_number']}")
"""
def __init__(self, config: Optional[AgentConfig] = None):
"""
初始化问答Agent
Args:
config: Agent配置可选使用默认配置
"""
self.config = config or AgentConfig(
llm_provider=settings.llm_provider,
llm_model=settings.llm_model,
top_k=settings.rag_top_k,
max_context_tokens=settings.rag_max_context_tokens
)
# 初始化组件(延迟加载)
self.llm: Optional[BaseLLMClient] = None
self.retriever: Optional[Retriever] = None
self.context_builder: Optional[ContextBuilder] = None
logger.info(f"问答Agent初始化: provider={self.config.llm_provider}, model={self.config.llm_model}")
def _init_llm(self):
"""延迟初始化LLM客户端优先使用全局缓存"""
if self.llm is None:
# 尝试先获取全局缓存的客户端
cached = LLMFactory.get_global_client(self.config.llm_provider, self.config.llm_model)
if cached:
self.llm = cached
logger.debug(f"使用全局缓存的LLM客户端: {self.config.llm_provider} - {self.config.llm_model}")
else:
logger.info("创建新的LLM客户端...")
self.llm = get_llm_client(
provider=self.config.llm_provider,
model=self.config.llm_model,
temperature=self.config.temperature
)
def _init_retriever(self):
"""延迟初始化检索器"""
if self.retriever is None:
logger.info("初始化检索器...")
self.retriever = Retriever(
top_k=self.config.top_k,
min_score=self.config.min_score
)
def _init_context_builder(self):
"""延迟初始化上下文构建器"""
if self.context_builder is None:
logger.info("初始化上下文构建器...")
self.context_builder = ContextBuilder(
max_context_tokens=self.config.max_context_tokens,
include_metadata=self.config.include_metadata
)
def ask(
self,
query: str,
filters: Optional[str] = None,
prompt_template: Optional[str] = None
) -> AgentResponse:
"""
回答用户问题
Args:
query: 用户问题
filters: 检索过滤条件(如 "regulation_type=='车辆安全'"
prompt_template: Prompt模板名称可选覆盖默认配置
Returns:
AgentResponse: 包含答案和引用来源的响应对象
"""
start_time = time.time()
logger.info(f"收到问题: {query}")
try:
# Step 1: 检索相关法规
self._init_retriever()
documents = self.retriever.retrieve(query, filters)
retrieved_count = len(documents)
if retrieved_count == 0:
return AgentResponse(
answer="抱歉,未找到与您的问题相关的法规条款。请尝试用不同的关键词重新提问,或提供更具体的法规名称。",
retrieved_count=0,
error="no_retrieved_documents"
)
# Step 2: 构建RAG上下文
self._init_context_builder()
template_name = prompt_template or self.config.prompt_template
template = get_prompt_template(template_name)
context = self.context_builder.build(
query=query,
documents=documents,
system_prompt=template.system_prompt
)
# Step 3: 构建LLM输入消息
messages = self._build_messages(template, context)
# Step 4: 调用LLM生成回答
self._init_llm()
llm_response = self.llm.chat(
messages=messages,
temperature=self.config.temperature
)
if not llm_response.is_success:
return AgentResponse(
answer="",
retrieved_count=retrieved_count,
error=llm_response.error
)
latency_ms = int((time.time() - start_time) * 1000)
# Step 5: 返回结果
logger.success(f"问答完成: {latency_ms}ms, {retrieved_count}条引用")
return AgentResponse(
answer=llm_response.content,
sources=context.sources,
model=llm_response.model,
latency_ms=latency_ms,
retrieved_count=retrieved_count,
context_tokens=context.total_tokens,
truncated=context.truncated
)
except Exception as e:
logger.error(f"问答失败: {e}")
return AgentResponse(
answer="",
error=str(e)
)
def ask_with_context(
self,
query: str,
documents: List[RetrievedDocument],
prompt_template: Optional[str] = None
) -> AgentResponse:
"""
使用提供的文档回答问题(不执行检索)
Args:
query: 用户问题
documents: 已检索的文档列表
prompt_template: Prompt模板名称
Returns:
AgentResponse: 响应结果
"""
start_time = time.time()
try:
self._init_context_builder()
self._init_llm()
template_name = prompt_template or self.config.prompt_template
template = get_prompt_template(template_name)
context = self.context_builder.build(
query=query,
documents=documents,
system_prompt=template.system_prompt
)
messages = self._build_messages(template, context)
llm_response = self.llm.chat(messages)
latency_ms = int((time.time() - start_time) * 1000)
return AgentResponse(
answer=llm_response.content,
sources=context.sources,
model=llm_response.model,
latency_ms=latency_ms,
retrieved_count=len(documents),
context_tokens=context.total_tokens,
truncated=context.truncated
)
except Exception as e:
logger.error(f"问答失败: {e}")
return AgentResponse(answer="", error=str(e))
def _build_messages(
self,
template: PromptTemplate,
context: RAGContext
) -> List[Dict[str, str]]:
"""构建LLM输入消息"""
user_content = template.user_template.format(
context=context.context_text,
query=context.user_query
)
return [
{"role": "system", "content": template.system_prompt},
{"role": "user", "content": user_content}
]
def ask_stream(
self,
query: str,
filters: Optional[str] = None,
prompt_template: Optional[str] = None
) -> Generator[Dict[str, Any], None, None]:
"""
流式回答用户问题SSE模式
返回事件类型:
- {"event": "status", "data": "正在检索..."} - 状态更新
- {"event": "sources", "data": [...]} - 引用来源
- {"event": "content", "data": "文本片段"} - 回答内容
- {"event": "done", "data": {"latency_ms": ..., "model": ...}} - 完成
Args:
query: 用户问题
filters: 检索过滤条件
prompt_template: Prompt模板名称
Yields:
Dict: SSE事件数据
"""
start_time = time.time()
logger.info(f"收到流式问题: {query}")
try:
# Step 1: 检索相关法规
yield {"event": "status", "data": "正在检索相关法规..."}
self._init_retriever()
documents = self.retriever.retrieve(query, filters)
retrieved_count = len(documents)
if retrieved_count == 0:
yield {"event": "status", "data": "未找到相关法规"}
yield {"event": "content", "data": "抱歉,未找到与您的问题相关的法规条款。请尝试用不同的关键词重新提问。"}
yield {"event": "done", "data": {"latency_ms": 0, "retrieved_count": 0}}
return
# Step 2: 发送检索结果
yield {"event": "status", "data": f"找到{retrieved_count}条相关法规,正在生成回答..."}
sources = [
{
"doc_name": doc.doc_name,
"doc_id": doc.doc_id,
"clause_number": doc.clause_number,
"score": doc.score
}
for doc in documents[:5] # 只返回前5条引用
]
yield {"event": "sources", "data": sources}
# Step 3: 构建RAG上下文
self._init_context_builder()
template_name = prompt_template or self.config.prompt_template
template = get_prompt_template(template_name)
context = self.context_builder.build(
query=query,
documents=documents,
system_prompt=template.system_prompt
)
# Step 4: 构建LLM输入消息
messages = self._build_messages(template, context)
# Step 5: 流式调用LLM生成回答
self._init_llm()
full_answer = ""
# 检查LLM是否支持流式输出
if hasattr(self.llm, 'stream_chat'):
yield {"event": "status", "data": "思考中..."}
for chunk in self.llm.stream_chat(
messages=messages,
temperature=self.config.temperature
):
full_answer += chunk
yield {"event": "content", "data": chunk}
else:
# 如果不支持流式,回退到普通调用
yield {"event": "status", "data": "生成回答中..."}
llm_response = self.llm.chat(
messages=messages,
temperature=self.config.temperature
)
if llm_response.is_success:
full_answer = llm_response.content
yield {"event": "content", "data": full_answer}
# Step 6: 发送完成事件
latency_ms = int((time.time() - start_time) * 1000)
logger.success(f"流式问答完成: {latency_ms}ms, {retrieved_count}条引用")
yield {
"event": "done",
"data": {
"latency_ms": latency_ms,
"model": self.config.llm_model,
"retrieved_count": retrieved_count,
"context_tokens": context.total_tokens
}
}
except Exception as e:
logger.error(f"流式问答失败: {e}")
yield {"event": "error", "data": str(e)}
def close(self):
"""关闭Agent资源不关闭LLM客户端因为它全局缓存"""
if self.retriever:
self.retriever.close()
logger.info("问答Agent已关闭")
def ask_compliance_question(
query: str,
provider: str = "deepseek",
model: str = "deepseek-v4-flash",
top_k: int = 10
) -> AgentResponse:
"""
便捷函数:问答合规问题
Args:
query: 用户问题
provider: LLM提供商
model: LLM模型
top_k: 检索数量
Returns:
AgentResponse: 响应结果
"""
config = AgentConfig(
llm_provider=provider,
llm_model=model,
top_k=top_k
)
agent = QAAgent(config)
response = agent.ask(query)
agent.close()
return response

View File

@@ -1,247 +0,0 @@
# src/services/agent/session_manager.py
"""多轮对话会话管理"""
import time
import uuid
from typing import Dict, List, Optional
from dataclasses import dataclass, field
from loguru import logger
@dataclass
class ChatMessage:
"""对话消息"""
role: str # "user" / "assistant" / "system"
content: str
timestamp: int
sources: List[Dict] = field(default_factory=list)
metadata: Dict = field(default_factory=dict)
@dataclass
class ChatSession:
"""对话会话"""
session_id: str
messages: List[ChatMessage] = field(default_factory=list)
created_at: int = field(default_factory=lambda: int(time.time()))
updated_at: int = field(default_factory=lambda: int(time.time()))
metadata: Dict = field(default_factory=dict)
def add_user_message(self, content: str) -> ChatMessage:
"""添加用户消息"""
message = ChatMessage(
role="user",
content=content,
timestamp=int(time.time())
)
self.messages.append(message)
self.updated_at = int(time.time())
return message
def add_assistant_message(
self,
content: str,
sources: List[Dict] = None
) -> ChatMessage:
"""添加助手消息"""
message = ChatMessage(
role="assistant",
content=content,
timestamp=int(time.time()),
sources=sources or []
)
self.messages.append(message)
self.updated_at = int(time.time())
return message
def get_history(self, max_turns: int = 5) -> List[Dict[str, str]]:
"""获取历史对话用于LLM上下文"""
history = []
# 获取最近N轮对话每轮包含user + assistant
recent_messages = self.messages[-(max_turns * 2):]
for msg in recent_messages:
history.append({
"role": msg.role,
"content": msg.content
})
return history
def clear_history(self):
"""清空对话历史"""
self.messages = []
self.updated_at = int(time.time())
logger.info(f"会话历史已清空: {self.session_id}")
@property
def message_count(self) -> int:
"""消息数量"""
return len(self.messages)
@property
def is_empty(self) -> bool:
"""是否为空会话"""
return len(self.messages) == 0
class SessionManager:
"""
会话管理器
功能:
- 创建/获取/删除会话
- 会话超时清理
- 会话历史记录管理
使用示例:
manager = SessionManager()
# 创建会话
session = manager.create_session()
# 添加消息
session.add_user_message("什么是机动车安全技术检验?")
session.add_assistant_message("根据GB 7258...", sources=[...])
# 获取历史用于LLM多轮对话
history = session.get_history(max_turns=3)
"""
def __init__(
self,
max_sessions: int = 100,
session_timeout_minutes: int = 30
):
"""
初始化会话管理器
Args:
max_sessions: 最大会话数量
session_timeout_minutes: 会话超时时间(分钟)
"""
self.max_sessions = max_sessions
self.session_timeout = session_timeout_minutes * 60
# 会话存储(内存)
self._sessions: Dict[str, ChatSession] = {}
logger.info(f"会话管理器初始化: max_sessions={max_sessions}, timeout={session_timeout_minutes}min")
def create_session(self, metadata: Dict = None) -> ChatSession:
"""
创建新会话
Args:
metadata: 会话元数据(可选)
Returns:
ChatSession: 新创建的会话
"""
# 检查会话数量限制
if len(self._sessions) >= self.max_sessions:
# 清理过期会话
self._cleanup_expired_sessions()
# 如果仍然超出限制,删除最老的会话
if len(self._sessions) >= self.max_sessions:
oldest_id = min(
self._sessions.keys(),
key=lambda x: self._sessions[x].created_at
)
self.delete_session(oldest_id)
logger.warning(f"删除最老会话以腾出空间: {oldest_id}")
session_id = str(uuid.uuid4())[:8]
session = ChatSession(
session_id=session_id,
metadata=metadata or {}
)
self._sessions[session_id] = session
logger.info(f"创建新会话: {session_id}")
return session
def get_session(self, session_id: str) -> Optional[ChatSession]:
"""
获取会话
Args:
session_id: 会话ID
Returns:
ChatSession: 会话对象如不存在返回None
"""
session = self._sessions.get(session_id)
if session:
# 检查是否过期
if self._is_session_expired(session):
self.delete_session(session_id)
logger.info(f"会话已过期,已删除: {session_id}")
return None
return session
def delete_session(self, session_id: str) -> bool:
"""
删除会话
Args:
session_id: 会话ID
Returns:
bool: 是否成功删除
"""
if session_id in self._sessions:
del self._sessions[session_id]
logger.info(f"删除会话: {session_id}")
return True
return False
def list_sessions(self) -> List[Dict]:
"""
列出所有会话
Returns:
List[Dict]: 会话列表摘要
"""
return [
{
"session_id": s.session_id,
"message_count": s.message_count,
"created_at": s.created_at,
"updated_at": s.updated_at
}
for s in self._sessions.values()
]
def _is_session_expired(self, session: ChatSession) -> bool:
"""检查会话是否过期"""
current_time = int(time.time())
return (current_time - session.updated_at) > self.session_timeout
def _cleanup_expired_sessions(self) -> int:
"""清理过期会话"""
expired_ids = [
sid for sid, session in self._sessions.items()
if self._is_session_expired(session)
]
for sid in expired_ids:
self.delete_session(sid)
if expired_ids:
logger.info(f"清理过期会话: {len(expired_ids)}")
return len(expired_ids)
def get_session_count(self) -> int:
"""获取当前会话数量"""
return len(self._sessions)
def clear_all_sessions(self):
"""清空所有会话"""
self._sessions.clear()
logger.info("所有会话已清空")

View File

@@ -1,404 +0,0 @@
# src/services/document_processor.py
"""文档处理主流程 - 解析→摘要→分块→嵌入→入库"""
import os
from typing import List, Dict, Optional
from dataclasses import dataclass
from loguru import logger
import uuid
from .parser.pdf_parser import PDFParser
from .parser.docx_parser import DocxParser
from .parser.mineru_parser import ParserOrchestrator
from .embedding.text_chunker import RegulationChunker, TextChunk
from .embedding.bge_m3_embedder import BGEM3Embedder, EmbeddingResult
from .storage.milvus_client import MilvusClient
from .llm.document_summarizer import DocumentSummarizer, DocumentSummary
from src.config.settings import settings
@dataclass
class ProcessingResult:
"""文档处理结果"""
doc_id: str
doc_name: str
success: bool
num_chunks: int = 0
message: str = ""
markdown_text: str = ""
summary: str = ""
summary_latency_ms: int = 0
class DocumentProcessor:
"""
文档处理服务 - 完整处理流程
流程:
1. 文档解析PDF/DOCX → Markdown
2. 智能分块(章节级+条款级)
3. LLM摘要生成可选
4. 向量嵌入BGE-M3 Dense+Sparse
5. 存储入库Milvus向量数据库
"""
def __init__(
self,
chunk_size: int = None,
embedding_model: str = None,
use_mineru: bool = True,
generate_summary: bool = False, # 默认不生成摘要节省约60秒
llm_provider: str = None,
llm_model: str = None
):
"""
初始化文档处理器
Args:
chunk_size: 分块大小
embedding_model: 嵌入模型名称
use_mineru: 是否优先使用MinerU解析
generate_summary: 是否生成文档摘要默认False可节省约60秒处理时间
llm_provider: LLM提供商
llm_model: LLM模型名称
"""
self.chunk_size = chunk_size or settings.chunk_size
self.embedding_model = embedding_model or settings.embedding_model
self.use_mineru = use_mineru
self.generate_summary = generate_summary
self.llm_provider = llm_provider or settings.llm_provider
self.llm_model = llm_model or settings.llm_model
# 初始化各组件
logger.info("初始化文档处理组件...")
# 解析器
self.parser = ParserOrchestrator()
# 分块器
self.chunker = RegulationChunker(chunk_size=self.chunk_size)
# 嵌入模型(延迟加载)
self.embedder: Optional[BGEM3Embedder] = None
# Milvus客户端延迟连接
self.milvus: Optional[MilvusClient] = None
# 摘要生成器(延迟加载)
self.summarizer: Optional[DocumentSummarizer] = None
logger.success("文档处理器初始化完成")
def _init_embedder(self):
"""延迟初始化嵌入模型"""
if self.embedder is None:
logger.info("加载嵌入模型...")
self.embedder = BGEM3Embedder(model_name=self.embedding_model)
def _init_milvus(self):
"""延迟初始化Milvus连接"""
if self.milvus is None:
logger.info("连接Milvus...")
self.milvus = MilvusClient()
self.milvus.connect()
self.milvus.create_collection(recreate=False)
self.milvus.load_collection()
def _init_summarizer(self):
"""延迟初始化摘要生成器"""
if self.summarizer is None:
logger.info("初始化摘要生成器...")
self.summarizer = DocumentSummarizer(
provider=self.llm_provider,
model=self.llm_model
)
def process(
self,
file_path: str,
doc_id: Optional[str] = None,
doc_name: Optional[str] = None,
regulation_type: str = "",
version: str = ""
) -> ProcessingResult:
"""
处理单个文档
Args:
file_path: 文档文件路径
doc_id: 文档ID可选默认自动生成
doc_name: 文档名称(可选,默认从文件名获取)
regulation_type: 法规类型
version: 文档版本
Returns:
ProcessingResult: 处理结果
"""
# 生成或使用传入的文档ID
if doc_id is None:
doc_id = str(uuid.uuid4())[:8]
# 获取文档名称
if doc_name is None:
doc_name = os.path.basename(file_path)
logger.info(f"开始处理文档: {doc_name} (ID: {doc_id})")
# 初始化结果变量
summary = ""
summary_latency_ms = 0
try:
# 1. 文档解析
logger.info("Step 1: 文档解析")
markdown_text = self._parse_document(file_path)
if not markdown_text:
return ProcessingResult(
doc_id=doc_id,
doc_name=doc_name,
success=False,
message="文档解析失败,内容为空"
)
# 2. LLM摘要生成可选
if self.generate_summary:
logger.info("Step 2: LLM摘要生成")
self._init_summarizer()
summary_result = self.summarizer.summarize(
doc_name,
markdown_text,
regulation_type
)
if summary_result.is_success:
summary = summary_result.summary
summary_latency_ms = summary_result.latency_ms
logger.success(f"摘要生成完成: {summary_latency_ms}ms")
else:
logger.warning(f"摘要生成失败: {summary_result.error}")
else:
logger.info("Step 2: 跳过摘要生成未勾选节省约60秒")
# 3. 智能分块
logger.info("Step 3: 智能分块")
chunks = self._chunk_document(
markdown_text,
doc_id,
doc_name,
regulation_type,
version
)
if not chunks:
return ProcessingResult(
doc_id=doc_id,
doc_name=doc_name,
success=False,
message="分块失败,无有效内容",
markdown_text=markdown_text,
summary=summary
)
# 4. 向量嵌入
logger.info("Step 4: 向量嵌入")
embeddings = self._embed_chunks(chunks)
if embeddings is None:
return ProcessingResult(
doc_id=doc_id,
doc_name=doc_name,
success=False,
message="向量嵌入失败",
markdown_text=markdown_text,
summary=summary
)
# 5. 存储入库
logger.info("Step 5: 存储入库")
inserted_ids = self._insert_to_milvus(chunks, embeddings)
logger.success(f"文档处理完成: {doc_name}, 共{len(inserted_ids)}条记录")
return ProcessingResult(
doc_id=doc_id,
doc_name=doc_name,
success=True,
num_chunks=len(inserted_ids),
message="处理成功",
markdown_text=markdown_text,
summary=summary,
summary_latency_ms=summary_latency_ms
)
except Exception as e:
logger.error(f"文档处理失败: {e}")
return ProcessingResult(
doc_id=doc_id,
doc_name=doc_name,
success=False,
message=f"处理失败: {str(e)}"
)
def _parse_document(self, file_path: str) -> str:
"""解析文档"""
ext = os.path.splitext(file_path)[1].lower()
try:
if ext == ".pdf":
# PDF文档解析优先MinerU回退PyMuPDF
markdown_text = self.parser.parse_pdf(file_path, prefer_mineru=self.use_mineru)
elif ext in [".docx", ".doc"]:
# Word文档解析
markdown_text = self.parser.parse_docx(file_path)
else:
logger.warning(f"不支持的文件类型: {ext}")
return ""
logger.success(f"文档解析完成,内容长度: {len(markdown_text)}字符")
return markdown_text
except Exception as e:
logger.error(f"文档解析失败: {e}")
return ""
def _chunk_document(
self,
markdown_text: str,
doc_id: str,
doc_name: str,
regulation_type: str,
version: str
) -> List[TextChunk]:
"""分块文档"""
try:
chunks = self.chunker.chunk_document(
markdown_text,
doc_id=doc_id,
doc_name=doc_name,
regulation_type=regulation_type,
version=version
)
logger.success(f"分块完成,共{len(chunks)}个chunk")
return chunks
except Exception as e:
logger.error(f"分块失败: {e}")
return []
def _embed_chunks(self, chunks: List[TextChunk]) -> Optional[EmbeddingResult]:
"""嵌入分块"""
try:
# 延迟初始化嵌入模型
self._init_embedder()
# 提取文本内容
texts = [chunk.content for chunk in chunks]
# 执行嵌入
embeddings = self.embedder.embed(texts)
logger.success(f"嵌入完成,向量数: {len(embeddings.dense_embeddings)}")
return embeddings
except Exception as e:
logger.error(f"嵌入失败: {e}")
return None
def _insert_to_milvus(
self,
chunks: List[TextChunk],
embeddings: EmbeddingResult
) -> List[int]:
"""插入Milvus"""
try:
# 延迟初始化Milvus
self._init_milvus()
# 执行插入
inserted_ids = self.milvus.insert_chunks(chunks, embeddings)
logger.success(f"入库完成,共{len(inserted_ids)}条记录")
return inserted_ids
except Exception as e:
logger.error(f"入库失败: {e}")
return []
def search(
self,
query: str,
top_k: int = 10,
filters: Optional[str] = None
) -> List[Dict]:
"""
检索法规内容
Args:
query: 查询文本
top_k: 返回结果数量
filters: 过滤条件
Returns:
List[Dict]: 检索结果
"""
logger.info(f"执行检索: {query}")
try:
# 延迟初始化
self._init_embedder()
self._init_milvus()
# 生成查询向量
query_embedding = self.embedder.embed_single(query)
# 执行混合检索
results = self.milvus.hybrid_search(
query_dense=query_embedding['dense'].tolist(),
query_sparse=query_embedding['sparse'],
top_k=top_k,
filters=filters
)
# 转换为字典格式
result_dicts = []
for r in results:
result_dicts.append({
"id": r.id,
"content": r.content,
"score": r.score,
"metadata": r.metadata
})
logger.success(f"检索完成,返回{len(result_dicts)}条结果")
return result_dicts
except Exception as e:
logger.error(f"检索失败: {e}")
return []
def close(self):
"""关闭连接"""
if self.milvus:
self.milvus.disconnect()
logger.info("文档处理器已关闭")
def process_document(
file_path: str,
doc_name: Optional[str] = None,
regulation_type: str = "",
version: str = ""
) -> ProcessingResult:
"""便捷函数:处理单个文档"""
processor = DocumentProcessor()
result = processor.process(file_path, doc_name, regulation_type, version)
processor.close()
return result
def search_regulations(query: str, top_k: int = 10) -> List[Dict]:
"""便捷函数:检索法规"""
processor = DocumentProcessor()
results = processor.search(query, top_k)
processor.close()
return results

View File

@@ -1,7 +0,0 @@
# src/services/embedding/__init__.py
"""嵌入和分块服务"""
from .text_chunker import RegulationChunker
from .bge_m3_embedder import BGEM3Embedder
__all__ = ["RegulationChunker", "BGEM3Embedder"]

View File

@@ -1,296 +0,0 @@
# src/services/embedding/bge_m3_embedder.py
"""BGE-M3嵌入服务 - Dense+Sparse双路向量生成"""
import numpy as np
from typing import List, Dict, Optional, Union
from dataclasses import dataclass, field
from loguru import logger
import torch
import os
# 设置HuggingFace镜像国内网络
if 'HF_ENDPOINT' not in os.environ:
os.environ['HF_ENDPOINT'] = 'https://hf-mirror.com'
# 本地模型路径(按优先级检查)
LOCAL_MODEL_PATHS = [
os.path.expanduser("~/.cache/modelscope/Xorbits/bge-m3"), # ModelScope下载路径
os.path.expanduser("~/.cache/huggingface/hub/models--BAAI--bge-m3/snapshots/main"), # HuggingFace本地路径
]
@dataclass
class EmbeddingResult:
"""嵌入结果"""
dense_embeddings: np.ndarray # Dense向量语义检索
sparse_embeddings: List[Dict[int, float]] # Sparse向量关键词匹配
texts: List[str]
dim: int = 1024
class BGEM3Embedder:
"""
BGE-M3多语言嵌入模型服务
BGE-M3是BAAI发布的多语言嵌入模型支持
- Dense向量用于语义相似度检索
- Sparse向量用于关键词精确匹配BM25风格
- ColBERT向量用于细粒度交互匹配可选
特点:
- 支持100+语言(中英双语优化)
- 8192 tokens超长上下文
- Dense+Sparse双路检索能力
GitHub: https://github.com/FlagOpen/FlagEmbedding
"""
def __init__(
self,
model_name: str = "BAAI/bge-m3",
use_fp16: bool = True,
device: Optional[str] = None,
batch_size: int = 12,
max_length: int = 8192,
local_model_path: Optional[str] = None
):
"""
初始化BGE-M3嵌入模型
Args:
model_name: 模型名称(如果使用本地路径,此参数会被忽略)
use_fp16: 是否使用FP16加速
device: 设备类型cuda/cpu默认自动选择
batch_size: 批处理大小
max_length: 最大序列长度
local_model_path: 本地模型路径(可选,优先使用)
"""
self.use_fp16 = use_fp16
self.batch_size = batch_size
self.max_length = max_length
# 确定模型路径(优先使用本地路径)
if local_model_path and os.path.exists(local_model_path):
self.model_path = local_model_path
self.model_name = "local"
logger.info(f"使用本地模型路径: {local_model_path}")
else:
# 检查多个可能的本地路径
found_local = False
for path in LOCAL_MODEL_PATHS:
if os.path.exists(path) and os.path.exists(os.path.join(path, "config.json")):
self.model_path = path
self.model_name = "local"
logger.info(f"使用本地模型路径: {path}")
found_local = True
break
if not found_local:
self.model_path = model_name
self.model_name = model_name
logger.info(f"使用远程模型: {model_name}")
# 自动选择设备
if device is None:
self.device = "cuda" if torch.cuda.is_available() else "cpu"
else:
self.device = device
logger.info(f"初始化BGE-M3模型, 设备: {self.device}")
self.model = None
self._load_model()
def _load_model(self):
"""加载嵌入模型"""
try:
from FlagEmbedding import BGEM3FlagModel
self.model = BGEM3FlagModel(
self.model_path,
use_fp16=self.use_fp16,
device=self.device
)
logger.success(f"BGE-M3模型加载成功")
except ImportError:
logger.warning("FlagEmbedding库未安装请运行: pip install FlagEmbedding")
raise
except Exception as e:
logger.error(f"模型加载失败: {e}")
raise
def embed(
self,
texts: List[str],
return_dense: bool = True,
return_sparse: bool = True,
return_colbert_vecs: bool = False
) -> EmbeddingResult:
"""
对文本列表生成嵌入向量
Args:
texts: 文本列表
return_dense: 是否返回Dense向量
return_sparse: 是否返回Sparse向量
return_colbert_vecs: 是否返回ColBERT向量
Returns:
EmbeddingResult: 嵌入结果
"""
if not texts:
logger.warning("输入文本列表为空")
return EmbeddingResult(
dense_embeddings=np.array([]),
sparse_embeddings=[],
texts=[],
dim=0
)
logger.info(f"开始嵌入{len(texts)}个文本块")
try:
# 执行嵌入
embeddings = self.model.encode(
texts,
batch_size=self.batch_size,
max_length=self.max_length,
return_dense=return_dense,
return_sparse=return_sparse,
return_colbert_vecs=return_colbert_vecs
)
# 提取结果
dense_embeddings = embeddings.get('dense_vecs', np.array([]))
sparse_embeddings = embeddings.get('lexical_weights', [])
# 获取维度
dim = dense_embeddings.shape[1] if len(dense_embeddings) > 0 else 1024
logger.success(f"嵌入完成,向量维度: {dim}")
return EmbeddingResult(
dense_embeddings=dense_embeddings,
sparse_embeddings=sparse_embeddings,
texts=texts,
dim=dim
)
except Exception as e:
logger.error(f"嵌入失败: {e}")
raise
def embed_single(self, text: str) -> Dict[str, Union[np.ndarray, Dict]]:
"""
对单个文本生成嵌入向量
Args:
text: 输入文本
Returns:
Dict: 包含dense和sparse向量
"""
result = self.embed([text])
return {
'dense': result.dense_embeddings[0],
'sparse': result.sparse_embeddings[0] if result.sparse_embeddings else {},
'dim': result.dim
}
def embed_dense(self, texts: List[str]) -> np.ndarray:
"""只生成Dense向量"""
result = self.embed(texts, return_sparse=False, return_colbert_vecs=False)
return result.dense_embeddings
def embed_sparse(self, texts: List[str]) -> List[Dict[int, float]]:
"""只生成Sparse向量"""
result = self.embed(texts, return_dense=False, return_colbert_vecs=False)
return result.sparse_embeddings
def embed_query(self, query: str) -> Dict:
"""
对查询文本生成嵌入(用于检索)
Args:
query: 查询文本
Returns:
Dict: 包含dense和sparse向量
"""
return self.embed_single(query)
def compute_similarity(
self,
query_embedding: np.ndarray,
doc_embeddings: np.ndarray,
metric: str = "cosine"
) -> np.ndarray:
"""
计算查询与文档的相似度
Args:
query_embedding: 查询向量
doc_embeddings: 文档向量矩阵
metric: 相似度度量cosine/dot
Returns:
np.ndarray: 相似度分数数组
"""
if metric == "cosine":
# 余弦相似度
query_norm = np.linalg.norm(query_embedding)
doc_norms = np.linalg.norm(doc_embeddings, axis=1)
similarities = np.dot(doc_embeddings, query_embedding) / (doc_norms * query_norm)
elif metric == "dot":
# 点积相似度
similarities = np.dot(doc_embeddings, query_embedding)
else:
raise ValueError(f"不支持的相似度度量: {metric}")
return similarities
def sparse_similarity(
self,
query_sparse: Dict[int, float],
doc_sparse: Dict[int, float]
) -> float:
"""
计算Sparse向量的相似度BM25风格
Args:
query_sparse: 查询的Sparse向量词ID -> 权重)
doc_sparse: 文档的Sparse向量
Returns:
float: 相似度分数
"""
# 计算交集词的点积
common_keys = set(query_sparse.keys()) & set(doc_sparse.keys())
score = sum(query_sparse[k] * doc_sparse[k] for k in common_keys)
return score
def embed_texts(
texts: List[str],
model_name: str = "BAAI/bge-m3",
**kwargs
) -> EmbeddingResult:
"""便捷函数:对文本列表生成嵌入"""
embedder = BGEM3Embedder(model_name=model_name, **kwargs)
return embedder.embed(texts)
def embed_single_text(
text: str,
model_name: str = "BAAI/bge-m3",
**kwargs
) -> Dict:
"""便捷函数:对单个文本生成嵌入"""
embedder = BGEM3Embedder(model_name=model_name, **kwargs)
return embedder.embed_single(text)

View File

@@ -1,449 +0,0 @@
# src/services/embedding/text_chunker.py
"""智能分块器 - 章节级+条款级双粒度切割"""
import re
from typing import List, Dict, Optional, Tuple
from dataclasses import dataclass, field
from loguru import logger
@dataclass
class ChunkMetadata:
"""分块元数据"""
doc_id: str = ""
doc_name: str = ""
chunk_id: str = ""
section_number: str = "" # 章节编号(如 "第一章"
section_title: str = "" # 章节标题
clause_number: str = "" # 条款编号(如 "第一条"
page_number: int = 0
start_position: int = 0 # 在原文中的起始位置
end_position: int = 0 # 在原文中的结束位置
regulation_type: str = "" # 法规类型
version: str = ""
@dataclass
class TextChunk:
"""文本分块"""
content: str
metadata: ChunkMetadata
token_count: int = 0 # 估算的token数量
class RegulationChunker:
"""
法规文档智能分块器
实现章节级/条款级双粒度切割适配国标GB文档结构
- 国标文档通常有明确的层级结构:章 > 节 > 条
- 每个条款应作为一个独立的语义单元
- 保留条款完整性,避免跨条款截断
"""
# 法规标题模式
CHAPTER_PATTERN = re.compile(r'^第[一二三四五六七八九十百]+章\s+[^\n]+')
SECTION_PATTERN = re.compile(r'^第[一二三四五六七八九十百]+节\s+[^\n]+')
CLAUSE_PATTERN = re.compile(r'^第[一二三四五六七八九十百]+条\s')
# 条款子项模式
SUB_ITEM_PATTERN = re.compile(r'^[\(][一二三四五六七八九十]+[\)]\s')
NUMBER_ITEM_PATTERN = re.compile(r'^[\d]+[\.、]\s')
def __init__(
self,
chunk_size: int = 512,
chunk_overlap: int = 50,
max_chunk_size: int = 2048,
min_chunk_size: int = 100
):
"""
初始化分块器
Args:
chunk_size: 默认分块大小(字符数)
chunk_overlap: 分块重叠大小
max_chunk_size: 最大分块大小(防止单个条款过长)
min_chunk_size: 最小分块大小(防止碎片化)
"""
self.chunk_size = chunk_size
self.chunk_overlap = chunk_overlap
self.max_chunk_size = max_chunk_size
self.min_chunk_size = min_chunk_size
def chunk_document(
self,
markdown_text: str,
doc_id: str = "",
doc_name: str = "",
regulation_type: str = "",
version: str = ""
) -> List[TextChunk]:
"""
对法规文档进行智能分块
Args:
markdown_text: Markdown格式的文档内容
doc_id: 文档ID
doc_name: 文档名称
regulation_type: 法规类型
version: 文档版本
Returns:
List[TextChunk]: 分块列表
"""
logger.info(f"开始分块文档: {doc_name}")
# 1. 按章节分割(一级分块)
sections = self._split_by_sections(markdown_text)
# 2. 在每个章节内按条款分割(二级分块)
chunks = []
global_position = 0
for section_num, section_title, section_content, section_start in sections:
# 在章节内按条款分割
clause_chunks = self._split_by_clauses(
section_content,
section_num,
section_title,
section_start + global_position
)
for chunk_content, clause_num, clause_title, start_pos, end_pos in clause_chunks:
# 处理过长的条款(进一步细分)
if len(chunk_content) > self.max_chunk_size:
sub_chunks = self._split_long_clause(
chunk_content,
clause_num,
clause_title
)
for sub_content, sub_start, sub_end in sub_chunks:
chunk = self._create_chunk(
sub_content,
doc_id,
doc_name,
section_num,
section_title,
clause_num,
sub_start + start_pos,
sub_end + start_pos,
regulation_type,
version
)
chunks.append(chunk)
else:
chunk = self._create_chunk(
chunk_content,
doc_id,
doc_name,
section_num,
section_title,
clause_num,
start_pos,
end_pos,
regulation_type,
version
)
chunks.append(chunk)
logger.success(f"分块完成,共{len(chunks)}个chunk")
return chunks
def _split_by_sections(self, markdown_text: str) -> List[Tuple[str, str, str, int]]:
"""
按章节分割文档
Returns:
List of (section_number, section_title, section_content, start_position)
"""
sections = []
lines = markdown_text.split('\n')
current_section_num = ""
current_section_title = ""
current_section_content = []
current_section_start = 0
for i, line in enumerate(lines):
# 检测章节标题
chapter_match = self.CHAPTER_PATTERN.match(line.strip())
section_match = self.SECTION_PATTERN.match(line.strip())
if chapter_match or section_match:
# 保存上一个章节
if current_section_content:
content = '\n'.join(current_section_content)
sections.append((
current_section_num,
current_section_title,
content,
current_section_start
))
# 开始新章节
current_section_start = sum(len(l) + 1 for l in lines[:i])
current_section_content = []
if chapter_match:
current_section_num = line.strip()
current_section_title = self._extract_title(line.strip())
else:
current_section_num = line.strip()
current_section_title = self._extract_title(line.strip())
current_section_content.append(line)
# 保存最后一个章节
if current_section_content:
content = '\n'.join(current_section_content)
sections.append((
current_section_num,
current_section_title,
content,
current_section_start
))
# 如果没有检测到章节,将整个文档作为一个大章节
if not sections:
sections.append((
"",
"全文",
markdown_text,
0
))
return sections
def _split_by_clauses(
self,
section_content: str,
section_num: str,
section_title: str,
section_start: int
) -> List[Tuple[str, str, str, int, int]]:
"""
在章节内按条款分割
Returns:
List of (content, clause_number, clause_title, start_position, end_position)
"""
clauses = []
lines = section_content.split('\n')
current_clause_num = ""
current_clause_title = ""
current_clause_content = []
current_clause_start = section_start
for i, line in enumerate(lines):
# 检测条款标题
clause_match = self.CLAUSE_PATTERN.match(line.strip())
if clause_match:
# 保存上一个条款
if current_clause_content:
content = '\n'.join(current_clause_content)
end_pos = current_clause_start + len(content)
clauses.append((
content,
current_clause_num,
current_clause_title,
current_clause_start,
end_pos
))
# 开始新条款
current_clause_start = section_start + sum(len(l) + 1 for l in lines[:i])
current_clause_content = []
current_clause_num = self._extract_clause_number(line.strip())
current_clause_title = line.strip()
current_clause_content.append(line)
# 保存最后一个条款
if current_clause_content:
content = '\n'.join(current_clause_content)
end_pos = current_clause_start + len(content)
clauses.append((
content,
current_clause_num,
current_clause_title,
current_clause_start,
end_pos
))
# 如果没有检测到条款,将整个章节作为一个条款
if not clauses:
clauses.append((
section_content,
"",
section_title,
section_start,
section_start + len(section_content)
))
return clauses
def _split_long_clause(
self,
content: str,
clause_num: str,
clause_title: str
) -> List[Tuple[str, int, int]]:
"""
分割过长的条款内容
按条款子项或段落分割,保持语义完整性
"""
sub_chunks = []
lines = content.split('\n')
# 检测是否有子项结构
has_sub_items = any(
self.SUB_ITEM_PATTERN.match(line.strip()) or
self.NUMBER_ITEM_PATTERN.match(line.strip())
for line in lines
)
if has_sub_items:
# 按子项分割
current_sub_content = []
current_sub_start = 0
for i, line in enumerate(lines):
is_sub_item = (
self.SUB_ITEM_PATTERN.match(line.strip()) or
self.NUMBER_ITEM_PATTERN.match(line.strip())
)
if is_sub_item and current_sub_content:
sub_content = '\n'.join(current_sub_content)
sub_end = current_sub_start + len(sub_content)
if len(sub_content) >= self.min_chunk_size:
sub_chunks.append((sub_content, current_sub_start, sub_end))
current_sub_content = []
current_sub_start = sum(len(l) + 1 for l in lines[:i])
current_sub_content.append(line)
# 保存最后一个子项
if current_sub_content:
sub_content = '\n'.join(current_sub_content)
sub_end = current_sub_start + len(sub_content)
sub_chunks.append((sub_content, current_sub_start, sub_end))
else:
# 按段落分割(滑动窗口)
paragraphs = []
current_para = []
for line in lines:
if line.strip():
current_para.append(line)
else:
if current_para:
paragraphs.append('\n'.join(current_para))
current_para = []
if current_para:
paragraphs.append('\n'.join(current_para))
# 合并段落直到达到chunk_size
current_chunk = []
current_length = 0
chunk_start = 0
for para in paragraphs:
if current_length + len(para) > self.chunk_size and current_chunk:
chunk_content = '\n'.join(current_chunk)
chunk_end = chunk_start + len(chunk_content)
sub_chunks.append((chunk_content, chunk_start, chunk_end))
current_chunk = []
current_length = 0
chunk_start = chunk_end
current_chunk.append(para)
current_length += len(para)
# 保存最后一个chunk
if current_chunk:
chunk_content = '\n'.join(current_chunk)
chunk_end = chunk_start + len(chunk_content)
sub_chunks.append((chunk_content, chunk_start, chunk_end))
return sub_chunks
def _extract_title(self, header_line: str) -> str:
"""从标题行提取标题内容"""
# 移除"第X章"、"第X节"前缀
title = re.sub(r'^第[一二三四五六七八九十百]+[章节]\s+', '', header_line)
return title.strip()
def _extract_clause_number(self, clause_line: str) -> str:
"""从条款行提取条款编号"""
match = self.CLAUSE_PATTERN.match(clause_line)
if match:
return match.group(0).strip()
return ""
def _create_chunk(
self,
content: str,
doc_id: str,
doc_name: str,
section_num: str,
section_title: str,
clause_num: str,
start_pos: int,
end_pos: int,
regulation_type: str,
version: str
) -> TextChunk:
"""创建文本分块"""
# 清理内容
content = content.strip()
# 计算估算token数中文约1.5字符/token
token_count = int(len(content) * 0.7) # 简化估算
# 生成chunk_id
chunk_id = f"{doc_id}_{section_num}_{clause_num}_{start_pos}"
metadata = ChunkMetadata(
doc_id=doc_id,
doc_name=doc_name,
chunk_id=chunk_id,
section_number=section_num,
section_title=section_title,
clause_number=clause_num,
start_position=start_pos,
end_position=end_pos,
regulation_type=regulation_type,
version=version
)
return TextChunk(
content=content,
metadata=metadata,
token_count=token_count
)
def chunk_regulation_document(
markdown_text: str,
doc_id: str = "",
doc_name: str = "",
regulation_type: str = "",
version: str = "",
chunk_size: int = 512
) -> List[TextChunk]:
"""便捷函数:对法规文档进行分块"""
chunker = RegulationChunker(chunk_size=chunk_size)
return chunker.chunk_document(
markdown_text,
doc_id,
doc_name,
regulation_type,
version
)

View File

@@ -1,15 +0,0 @@
# src/services/llm/__init__.py
"""LLM服务模块"""
from .llm_factory import LLMFactory, get_llm_client
from .base_client import BaseLLMClient, LLMResponse, LLMConfig, LLMProvider
from .deepseek_client import DeepSeekClient
from .qwen_client import QwenClient, QwenVLClient
from .document_summarizer import DocumentSummarizer, summarize_document, DocumentSummary
__all__ = [
"LLMFactory", "get_llm_client",
"BaseLLMClient", "LLMResponse", "LLMConfig", "LLMProvider",
"DeepSeekClient", "QwenClient", "QwenVLClient",
"DocumentSummarizer", "summarize_document", "DocumentSummary"
]

View File

@@ -1,116 +0,0 @@
# src/services/llm/base_client.py
"""LLM客户端基类 - 统一接口定义"""
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import List, Dict, Optional, Any
from enum import Enum
class LLMProvider(Enum):
"""LLM提供商"""
DEEPSEEK = "deepseek"
QWEN = "qwen"
QWEN_VL = "qwen_vl"
@dataclass
class LLMResponse:
"""LLM响应结果"""
content: str
model: str
usage: Dict[str, int] = field(default_factory=dict)
finish_reason: str = "stop"
latency_ms: int = 0
error: Optional[str] = None
@property
def is_success(self) -> bool:
return self.error is None
@dataclass
class LLMConfig:
"""LLM配置"""
provider: LLMProvider
model: str
api_key: str
base_url: str
max_tokens: int = 4096
temperature: float = 0.7
top_p: float = 0.9
timeout: int = 300 # 默认超时300秒摘要/Skills生成可能需要较长时间
class BaseLLMClient(ABC):
"""LLM客户端基类"""
def __init__(self, config: LLMConfig):
self.config = config
self._client = None
@abstractmethod
def _init_client(self):
"""初始化客户端"""
pass
@abstractmethod
def chat(
self,
messages: List[Dict[str, str]],
max_tokens: Optional[int] = None,
temperature: Optional[float] = None,
**kwargs
) -> LLMResponse:
"""
对话补全
Args:
messages: 对话消息列表 [{"role": "user/assistant/system", "content": "..."}]
max_tokens: 最大输出token数
temperature: 温度参数
**kwargs: 其他参数
Returns:
LLMResponse: 响应结果
"""
pass
def complete(
self,
prompt: str,
system_prompt: Optional[str] = None,
max_tokens: Optional[int] = None,
temperature: Optional[float] = None,
**kwargs
) -> LLMResponse:
"""
单轮补全(便捷方法)
Args:
prompt: 用户输入
system_prompt: 系统提示词
max_tokens: 最大输出token数
temperature: 温度参数
Returns:
LLMResponse: 响应结果
"""
messages = []
if system_prompt:
messages.append({"role": "system", "content": system_prompt})
messages.append({"role": "user", "content": prompt})
return self.chat(messages, max_tokens, temperature, **kwargs)
@abstractmethod
def get_available_models(self) -> List[str]:
"""获取可用模型列表"""
pass
def estimate_tokens(self, text: str) -> int:
"""估算文本token数粗略估计"""
# 中文字符约1.5 token英文约0.25 token
chinese_chars = sum(1 for c in text if '' <= c <= '鿿')
other_chars = len(text) - chinese_chars
return int(chinese_chars * 1.5 + other_chars * 0.25)

View File

@@ -1,130 +0,0 @@
# src/services/llm/deepseek_client.py
"""DeepSeek LLM客户端 - OpenAI兼容API"""
import time
from typing import List, Dict, Optional
from loguru import logger
import httpx
from .base_client import BaseLLMClient, LLMResponse, LLMConfig, LLMProvider
class DeepSeekClient(BaseLLMClient):
"""
DeepSeek API客户端OpenAI兼容格式
支持模型:
- deepseek-chat
- deepseek-coder
- deepseek-reasoner
- deepseek-v3
- deepseek-v3.2
- deepseek-v4-flash
"""
SUPPORTED_MODELS = [
"deepseek-chat",
"deepseek-coder",
"deepseek-reasoner",
"deepseek-v3",
"deepseek-v3.2",
"deepseek-v4-flash"
]
def __init__(self, config: LLMConfig):
if config.provider != LLMProvider.DEEPSEEK:
raise ValueError(f"配置provider应为DEEPSEEK实际为{config.provider}")
super().__init__(config)
self._init_client()
def _init_client(self):
"""初始化HTTP客户端"""
self._client = httpx.Client(
base_url=self.config.base_url,
headers={
"Authorization": f"Bearer {self.config.api_key}",
"Content-Type": "application/json"
},
timeout=self.config.timeout
)
logger.info(f"DeepSeek客户端初始化完成: {self.config.base_url} - {self.config.model}")
def chat(
self,
messages: List[Dict[str, str]],
max_tokens: Optional[int] = None,
temperature: Optional[float] = None,
**kwargs
) -> LLMResponse:
"""对话补全"""
start_time = time.time()
try:
payload = {
"model": self.config.model,
"messages": messages,
"max_tokens": max_tokens or self.config.max_tokens,
"temperature": temperature or self.config.temperature,
"top_p": kwargs.get("top_p", self.config.top_p),
"stream": False
}
response = self._client.post("/chat/completions", json=payload)
response.raise_for_status()
data = response.json()
latency_ms = int((time.time() - start_time) * 1000)
choices = data.get("choices", [{}])
message = choices[0].get("message", {})
return LLMResponse(
content=message.get("content", ""),
model=data.get("model", self.config.model),
usage=data.get("usage", {}),
finish_reason=choices[0].get("finish_reason", "stop"),
latency_ms=latency_ms
)
except httpx.HTTPStatusError as e:
logger.error(f"DeepSeek API错误: {e.response.status_code} - {e.response.text}")
return LLMResponse(
content="",
model=self.config.model,
error=f"API错误: {e.response.status_code} - {e.response.text[:200]}"
)
except Exception as e:
logger.error(f"DeepSeek调用失败: {e}")
return LLMResponse(
content="",
model=self.config.model,
error=str(e)
)
def get_available_models(self) -> List[str]:
"""获取可用模型列表"""
return self.SUPPORTED_MODELS
def close(self):
"""关闭客户端"""
if self._client:
self._client.close()
def create_deepseek_client(
api_key: str,
model: str = "deepseek-v4-flash",
base_url: str = "http://6.86.80.4:30080/v1",
**kwargs
) -> DeepSeekClient:
"""便捷函数创建DeepSeek客户端"""
config = LLMConfig(
provider=LLMProvider.DEEPSEEK,
model=model,
api_key=api_key,
base_url=base_url,
**kwargs
)
return DeepSeekClient(config)

View File

@@ -1,231 +0,0 @@
# src/services/llm/document_summarizer.py
"""文档摘要生成服务 - LLM生成法规文档摘要"""
from typing import Dict, Optional
from dataclasses import dataclass
from loguru import logger
from src.services.llm import get_llm_client, BaseLLMClient
from src.services.rag.prompt_templates import get_prompt_template
from src.config.settings import settings
@dataclass
class DocumentSummary:
"""文档摘要结果"""
doc_name: str
summary: str
applicable_scope: str
key_clauses: list
key_terms: list
compliance_points: list
model: str
latency_ms: int
error: Optional[str] = None
@property
def is_success(self) -> bool:
return self.error is None
class DocumentSummarizer:
"""
文档摘要生成器
功能:
- 生成法规文档的核心要点摘要
- 提取适用范围
- 突出关键条款
- 列出合规要点
使用示例:
summarizer = DocumentSummarizer()
result = summarizer.summarize("GB 7258-2017", markdown_content)
print(result.summary)
"""
def __init__(
self,
provider: str = None,
model: str = None,
max_tokens: int = None
):
"""
初始化摘要生成器
Args:
provider: LLM提供商
model: LLM模型名称
max_tokens: 最大输出token数
"""
self.provider = provider or settings.llm_provider
self.model = model or settings.llm_model
self.max_tokens = max_tokens or settings.rag_summary_max_tokens
# LLM客户端延迟加载
self.llm: Optional[BaseLLMClient] = None
logger.info(f"摘要生成器初始化: provider={self.provider}, model={self.model}")
def _init_llm(self):
"""延迟初始化LLM"""
if self.llm is None:
self.llm = get_llm_client(
provider=self.provider,
model=self.model
)
def summarize(
self,
doc_name: str,
content: str,
regulation_type: str = "",
max_tokens: Optional[int] = None
) -> DocumentSummary:
"""
生成文档摘要
Args:
doc_name: 文档名称
content: 文档内容Markdown格式
regulation_type: 法规类型
max_tokens: 最大输出token数
Returns:
DocumentSummary: 摘要结果
"""
import time
start_time = time.time()
logger.info(f"生成文档摘要: {doc_name}")
try:
self._init_llm()
# 使用摘要模板
template = get_prompt_template("document_summary")
# 构建用户消息
user_content = template.user_template.format(
doc_name=doc_name,
content=content[:8000] # 截取前8000字符避免超出token限制
)
# 调用LLM
response = self.llm.chat(
messages=[
{"role": "system", "content": template.system_prompt},
{"role": "user", "content": user_content}
],
max_tokens=max_tokens or self.max_tokens,
temperature=0.3 # 低温度保证摘要准确性
)
latency_ms = int((time.time() - start_time) * 1000)
if not response.is_success:
return DocumentSummary(
doc_name=doc_name,
summary="",
applicable_scope="",
key_clauses=[],
key_terms=[],
compliance_points=[],
model=self.model,
latency_ms=latency_ms,
error=response.error
)
# 解析摘要结构
summary_data = self._parse_summary(response.content)
logger.success(f"摘要生成完成: {doc_name}, {latency_ms}ms")
return DocumentSummary(
doc_name=doc_name,
summary=summary_data.get("summary", response.content),
applicable_scope=summary_data.get("applicable_scope", ""),
key_clauses=summary_data.get("key_clauses", []),
key_terms=summary_data.get("key_terms", []),
compliance_points=summary_data.get("compliance_points", []),
model=response.model,
latency_ms=latency_ms
)
except Exception as e:
logger.error(f"摘要生成失败: {e}")
return DocumentSummary(
doc_name=doc_name,
summary="",
applicable_scope="",
key_clauses=[],
key_terms=[],
compliance_points=[],
model=self.model,
latency_ms=0,
error=str(e)
)
def _parse_summary(self, content: str) -> Dict:
"""解析摘要内容(提取结构化信息)"""
result = {
"summary": content,
"applicable_scope": "",
"key_clauses": [],
"key_terms": [],
"compliance_points": []
}
# 简单解析(提取关键信息)
lines = content.split("\n")
for line in lines:
line = line.strip()
# 提取适用范围
if "适用范围" in line or "适用对象" in line:
result["applicable_scope"] = line.split("")[-1].strip() if "" in line else line.split(":")[-1].strip()
# 提取关键条款
if line.startswith("- 【条款") or line.startswith("【条款"):
result["key_clauses"].append(line)
# 提取关键术语
if "关键术语" in line or "术语定义" in line:
# 继续读取后续几行
pass
# 提取合规要点
if "合规要点" in line or "必须满足" in line:
pass
return result
def batch_summarize(
self,
documents: list
) -> list:
"""
批量生成摘要
Args:
documents: 文档列表 [{"doc_name": str, "content": str}, ...]
Returns:
list: 摘要结果列表
"""
results = []
for doc in documents:
result = self.summarize(doc["doc_name"], doc["content"])
results.append(result)
return results
def summarize_document(
doc_name: str,
content: str,
**kwargs
) -> DocumentSummary:
"""便捷函数:生成文档摘要"""
summarizer = DocumentSummarizer(**kwargs)
return summarizer.summarize(doc_name, content)

View File

@@ -1,258 +0,0 @@
# src/services/llm/llm_factory.py
"""LLM工厂 - 统一创建和管理LLM客户端"""
from typing import Optional, Dict, Any
from loguru import logger
from functools import lru_cache
from .base_client import BaseLLMClient, LLMConfig, LLMProvider, LLMResponse
from .deepseek_client import DeepSeekClient
from .qwen_client import QwenClient, QwenVLClient
# 默认模型映射
DEFAULT_MODELS = {
LLMProvider.DEEPSEEK: "deepseek-v4-flash",
LLMProvider.QWEN: "qwen3.5-flash",
LLMProvider.QWEN_VL: "qwen3-vl-plus"
}
# API基础URL使用统一代理服务
DEFAULT_BASE_URLS = {
LLMProvider.DEEPSEEK: "http://6.86.80.4:30080/v1",
LLMProvider.QWEN: "http://6.86.80.4:30080/v1",
LLMProvider.QWEN_VL: "http://6.86.80.4:30080/v1"
}
class LLMFactory:
"""
LLM客户端工厂支持全局缓存
支持的提供商和模型:
- DeepSeek: deepseek-chat (DeepSeek-V3), deepseek-coder
- Qwen: qwen-turbo, qwen-plus, qwen-max, qwen-long
- QwenVL: qwen-vl-plus, qwen-vl-max (多模态)
使用示例:
factory = LLMFactory()
# 使用默认配置
client = factory.create("deepseek")
# 自定义配置
client = factory.create("qwen", model="qwen-max", temperature=0.5)
# 调用LLM
response = client.complete("你好,介绍一下自己")
"""
# 全局客户端缓存(类级别,跨实例共享)
_global_instances: Dict[str, BaseLLMClient] = {}
def __init__(self):
self._config_cache: Dict[str, Any] = {}
def create(
self,
provider: str,
api_key: Optional[str] = None,
model: Optional[str] = None,
base_url: Optional[str] = None,
max_tokens: int = 4096,
temperature: float = 0.7,
**kwargs
) -> BaseLLMClient:
"""
创建LLM客户端
Args:
provider: 提供商名称 ("deepseek", "qwen", "qwen_vl")
api_key: API密钥如未提供从环境变量获取
model: 模型名称(如未提供,使用默认模型)
base_url: API基础URL
max_tokens: 最大输出token数
temperature: 温度参数
**kwargs: 其他配置参数
Returns:
BaseLLMClient: LLM客户端实例
"""
provider_enum = self._parse_provider(provider)
# 获取配置
api_key = api_key or self._get_api_key(provider_enum)
model = model or DEFAULT_MODELS.get(provider_enum)
base_url = base_url or DEFAULT_BASE_URLS.get(provider_enum)
if not api_key:
raise ValueError(f"缺少API密钥请设置环境变量或传入api_key参数")
# 检查全局缓存
cache_key = f"{provider}_{model}"
if cache_key in LLMFactory._global_instances:
logger.debug(f"使用缓存的LLM客户端: {cache_key}")
return LLMFactory._global_instances[cache_key]
config = LLMConfig(
provider=provider_enum,
model=model,
api_key=api_key,
base_url=base_url,
max_tokens=max_tokens,
temperature=temperature,
**kwargs
)
# 创建客户端
client = self._create_client(config)
# 缓存到全局实例
LLMFactory._global_instances[cache_key] = client
logger.info(f"LLM客户端创建成功并缓存: {provider} - {model}")
return client
def _parse_provider(self, provider: str) -> LLMProvider:
"""解析提供商名称"""
provider_map = {
"deepseek": LLMProvider.DEEPSEEK,
"deepseek-v3": LLMProvider.DEEPSEEK,
"deepseek_chat": LLMProvider.DEEPSEEK,
"qwen": LLMProvider.QWEN,
"qwen-turbo": LLMProvider.QWEN,
"qwen-plus": LLMProvider.QWEN,
"qwen-max": LLMProvider.QWEN,
"qwen3.5-flash": LLMProvider.QWEN,
"qwen3.5-plus": LLMProvider.QWEN,
"qwen_vl": LLMProvider.QWEN_VL,
"qwen-vl": LLMProvider.QWEN_VL,
"qwen-vl-plus": LLMProvider.QWEN_VL,
"qwen-vl-max": LLMProvider.QWEN_VL
}
provider_lower = provider.lower()
if provider_lower not in provider_map:
raise ValueError(f"不支持的提供商: {provider},支持的: {list(provider_map.keys())}")
return provider_map[provider_lower]
def _get_api_key(self, provider: LLMProvider) -> Optional[str]:
"""从环境变量获取API密钥"""
import os
key_map = {
LLMProvider.DEEPSEEK: ["DEEPSEEK_API_KEY", "OPENAI_API_KEY"],
LLMProvider.QWEN: ["QWEN_API_KEY", "DASHSCOPE_API_KEY", "ALIBABA_API_KEY"],
LLMProvider.QWEN_VL: ["QWEN_API_KEY", "DASHSCOPE_API_KEY", "ALIBABA_API_KEY"]
}
for key_name in key_map.get(provider, []):
api_key = os.getenv(key_name)
if api_key:
return api_key
return None
def _create_client(self, config: LLMConfig) -> BaseLLMClient:
"""创建具体客户端"""
client_map = {
LLMProvider.DEEPSEEK: DeepSeekClient,
LLMProvider.QWEN: QwenClient,
LLMProvider.QWEN_VL: QwenVLClient
}
client_class = client_map.get(config.provider)
if not client_class:
raise ValueError(f"不支持的提供商: {config.provider}")
return client_class(config)
def get_cached(self, provider: str, model: Optional[str] = None) -> Optional[BaseLLMClient]:
"""获取缓存的客户端"""
provider_enum = self._parse_provider(provider)
model = model or DEFAULT_MODELS.get(provider_enum)
cache_key = f"{provider}_{model}"
return LLMFactory._global_instances.get(cache_key)
def list_available_providers(self) -> Dict[str, list]:
"""列出可用的提供商和模型"""
return {
"deepseek": DeepSeekClient.SUPPORTED_MODELS,
"qwen": QwenClient.SUPPORTED_MODELS,
"qwen_vl": QwenVLClient.SUPPORTED_MODELS
}
@classmethod
def preload_clients(cls, providers: list = None):
"""
预加载LLM客户端应用启动时调用
Args:
providers: 要预加载的提供商列表默认加载qwen和deepseek
"""
if providers is None:
providers = ["qwen", "deepseek"]
factory = cls()
for provider in providers:
try:
client = factory.create(provider)
logger.success(f"预加载LLM客户端成功: {provider}")
except Exception as e:
logger.warning(f"预加载LLM客户端失败: {provider} - {e}")
@classmethod
def get_global_client(cls, provider: str, model: Optional[str] = None) -> Optional[BaseLLMClient]:
"""获取全局缓存的客户端"""
provider_lower = provider.lower()
# 处理模型名作为provider的情况如 qwen3.5-flash
if provider_lower.startswith("qwen"):
provider_lower = "qwen"
model = model or DEFAULT_MODELS.get(LLMProvider.QWEN if provider_lower == "qwen" else LLMProvider.DEEPSEEK)
cache_key = f"{provider_lower}_{model}"
return cls._global_instances.get(cache_key)
@classmethod
def cleanup(cls):
"""清理所有缓存的客户端"""
for cache_key, client in cls._global_instances.items():
try:
client.close()
logger.debug(f"关闭LLM客户端: {cache_key}")
except Exception as e:
logger.warning(f"关闭LLM客户端失败: {cache_key} - {e}")
cls._global_instances.clear()
logger.info("所有LLM客户端已清理")
@lru_cache
def get_llm_factory() -> LLMFactory:
"""获取LLM工厂实例缓存"""
return LLMFactory()
def get_llm_client(
provider: str = "qwen",
model: Optional[str] = None,
**kwargs
) -> BaseLLMClient:
"""
便捷函数获取LLM客户端优先使用缓存
Args:
provider: 提供商名称
model: 模型名称
**kwargs: 其他配置
Returns:
BaseLLMClient: LLM客户端实例
"""
factory = get_llm_factory()
# 先尝试获取缓存的实例
cached = factory.get_cached(provider, model)
if cached:
return cached
return factory.create(provider, model=model, **kwargs)

View File

@@ -1,392 +0,0 @@
# src/services/llm/qwen_client.py
"""Qwen LLM客户端 - 支持OpenAI兼容API格式"""
import time
import json
from typing import List, Dict, Optional, Generator, AsyncGenerator
from loguru import logger
import httpx
from .base_client import BaseLLMClient, LLMResponse, LLMConfig, LLMProvider
class QwenClient(BaseLLMClient):
"""
Qwen API客户端OpenAI兼容格式
支持通过new-api等代理服务调用
- qwen-turbo
- qwen-plus
- qwen-max
- qwen3.5-flash (推荐:快速响应)
- qwen3.5-plus
- qwen-long
- qwen2.5系列
"""
SUPPORTED_MODELS = [
"qwen-turbo",
"qwen-plus",
"qwen-max",
"qwen-max-longcontext",
"qwen-long",
"qwen3.5-flash",
"qwen3.5-plus",
"qwen3-plus",
"qwen2.5-72b-instruct",
"qwen2.5-32b-instruct",
"qwen2.5-14b-instruct",
"qwen2.5-7b-instruct"
]
def __init__(self, config: LLMConfig):
if config.provider not in [LLMProvider.QWEN, LLMProvider.QWEN_VL]:
raise ValueError(f"配置provider应为Qwen实际为{config.provider}")
super().__init__(config)
self._init_client()
def _init_client(self):
"""初始化HTTP客户端"""
# OpenAI兼容API格式
self._client = httpx.Client(
base_url=self.config.base_url,
headers={
"Authorization": f"Bearer {self.config.api_key}",
"Content-Type": "application/json"
},
timeout=self.config.timeout
)
logger.info(f"Qwen客户端初始化完成: {self.config.base_url} - {self.config.model}")
def chat(
self,
messages: List[Dict[str, str]],
max_tokens: Optional[int] = None,
temperature: Optional[float] = None,
**kwargs
) -> LLMResponse:
"""对话补全OpenAI兼容格式"""
start_time = time.time()
try:
# OpenAI兼容格式的请求体
payload = {
"model": self.config.model,
"messages": messages,
"max_tokens": max_tokens or self.config.max_tokens,
"temperature": temperature or self.config.temperature,
"top_p": kwargs.get("top_p", self.config.top_p),
"stream": False
}
# OpenAI兼容接口路径
response = self._client.post("/chat/completions", json=payload)
response.raise_for_status()
data = response.json()
latency_ms = int((time.time() - start_time) * 1000)
# OpenAI兼容格式的响应解析
choices = data.get("choices", [{}])
message = choices[0].get("message", {})
return LLMResponse(
content=message.get("content", ""),
model=data.get("model", self.config.model),
usage=data.get("usage", {}),
finish_reason=choices[0].get("finish_reason", "stop"),
latency_ms=latency_ms
)
except httpx.HTTPStatusError as e:
logger.error(f"Qwen API错误: {e.response.status_code} - {e.response.text}")
return LLMResponse(
content="",
model=self.config.model,
error=f"API错误: {e.response.status_code} - {e.response.text[:200]}"
)
except Exception as e:
logger.error(f"Qwen调用失败: {e}")
return LLMResponse(
content="",
model=self.config.model,
error=str(e)
)
def stream_chat(
self,
messages: List[Dict[str, str]],
max_tokens: Optional[int] = None,
temperature: Optional[float] = None,
**kwargs
) -> Generator[str, None, None]:
"""
流式对话补全SSE格式
Yields:
str: 每次返回一个文本片段
使用示例:
for chunk in client.stream_chat(messages):
print(chunk, end="", flush=True)
"""
try:
# OpenAI兼容格式的请求体启用流式输出
payload = {
"model": self.config.model,
"messages": messages,
"max_tokens": max_tokens or self.config.max_tokens,
"temperature": temperature or self.config.temperature,
"top_p": kwargs.get("top_p", self.config.top_p),
"stream": True # 启用流式输出
}
# 使用stream模式发送请求
with self._client.stream("POST", "/chat/completions", json=payload) as response:
for line in response.iter_lines():
if line:
line = line.strip()
# SSE格式: data: {...}
if line.startswith("data: "):
data_str = line[6:] # 移除 "data: " 前缀
if data_str == "[DONE]":
break
try:
data = json.loads(data_str)
choices = data.get("choices", [])
if not choices:
continue # 跳过空的choices
delta = choices[0].get("delta", {})
content = delta.get("content", "")
if content:
yield content
except json.JSONDecodeError:
continue
except httpx.HTTPStatusError as e:
logger.error(f"Qwen流式API错误: {e.response.status_code}")
yield f"[ERROR: API返回错误 {e.response.status_code}]"
except Exception as e:
logger.error(f"Qwen流式调用失败: {e}")
yield f"[ERROR: {str(e)}]"
async def async_stream_chat(
self,
messages: List[Dict[str, str]],
max_tokens: Optional[int] = None,
temperature: Optional[float] = None,
**kwargs
) -> AsyncGenerator[str, None]:
"""
异步流式对话补全用于FastAPI SSE响应
Yields:
str: 每次返回一个文本片段
"""
import asyncio
# 使用同步流式方法,包装为异步
for chunk in self.stream_chat(messages, max_tokens, temperature, **kwargs):
yield chunk
# 给async循环一个小延迟让其他任务有机会执行
await asyncio.sleep(0)
def get_available_models(self) -> List[str]:
"""获取可用模型列表"""
return self.SUPPORTED_MODELS
def close(self):
"""关闭客户端"""
if self._client:
self._client.close()
class QwenVLClient(BaseLLMClient):
"""
Qwen VL多模态客户端OpenAI兼容格式
支持模型:
- qwen-vl-plus
- qwen-vl-max
- qwen3-vl-plus
- qwen2-vl-7b-instruct
- qwen2-vl-72b-instruct
"""
SUPPORTED_MODELS = [
"qwen-vl-plus",
"qwen-vl-max",
"qwen3-vl-plus",
"qwen2-vl-7b-instruct",
"qwen2-vl-72b-instruct"
]
def __init__(self, config: LLMConfig):
if config.provider != LLMProvider.QWEN_VL:
raise ValueError(f"配置provider应为QWEN_VL实际为{config.provider}")
super().__init__(config)
self._init_client()
def _init_client(self):
"""初始化HTTP客户端"""
self._client = httpx.Client(
base_url=self.config.base_url,
headers={
"Authorization": f"Bearer {self.config.api_key}",
"Content-Type": "application/json"
},
timeout=self.config.timeout
)
logger.info(f"QwenVL客户端初始化完成: {self.config.base_url} - {self.config.model}")
def chat(
self,
messages: List[Dict[str, str]],
max_tokens: Optional[int] = None,
temperature: Optional[float] = None,
**kwargs
) -> LLMResponse:
"""多模态对话补全OpenAI兼容格式
支持图片输入,消息格式:
{
"role": "user",
"content": [
{"type": "image_url", "image_url": {"url": "https://example.com/image.jpg"}},
{"type": "text", "text": "描述这张图片"}
]
}
"""
start_time = time.time()
try:
# OpenAI兼容格式的请求体
payload = {
"model": self.config.model,
"messages": messages,
"max_tokens": max_tokens or self.config.max_tokens,
"temperature": temperature or self.config.temperature,
"top_p": kwargs.get("top_p", self.config.top_p),
"stream": False
}
response = self._client.post("/chat/completions", json=payload)
response.raise_for_status()
data = response.json()
latency_ms = int((time.time() - start_time) * 1000)
choices = data.get("choices", [{}])
message = choices[0].get("message", {})
return LLMResponse(
content=message.get("content", ""),
model=data.get("model", self.config.model),
usage=data.get("usage", {}),
finish_reason=choices[0].get("finish_reason", "stop"),
latency_ms=latency_ms
)
except httpx.HTTPStatusError as e:
logger.error(f"QwenVL API错误: {e.response.status_code} - {e.response.text}")
return LLMResponse(
content="",
model=self.config.model,
error=f"API错误: {e.response.status_code} - {e.response.text[:200]}"
)
except Exception as e:
logger.error(f"QwenVL调用失败: {e}")
return LLMResponse(
content="",
model=self.config.model,
error=str(e)
)
def stream_chat(
self,
messages: List[Dict[str, str]],
max_tokens: Optional[int] = None,
temperature: Optional[float] = None,
**kwargs
) -> Generator[str, None, None]:
"""流式多模态对话补全"""
try:
payload = {
"model": self.config.model,
"messages": messages,
"max_tokens": max_tokens or self.config.max_tokens,
"temperature": temperature or self.config.temperature,
"top_p": kwargs.get("top_p", self.config.top_p),
"stream": True
}
with self._client.stream("POST", "/chat/completions", json=payload) as response:
for line in response.iter_lines():
if line:
line = line.strip()
if line.startswith("data: "):
data_str = line[6:]
if data_str == "[DONE]":
break
try:
data = json.loads(data_str)
choices = data.get("choices", [])
if not choices:
continue # 跳过空的choices
delta = choices[0].get("delta", {})
content = delta.get("content", "")
if content:
yield content
except json.JSONDecodeError:
continue
except Exception as e:
logger.error(f"QwenVL流式调用失败: {e}")
yield f"[ERROR: {str(e)}]"
def get_available_models(self) -> List[str]:
"""获取可用模型列表"""
return self.SUPPORTED_MODELS
def close(self):
"""关闭客户端"""
if self._client:
self._client.close()
def create_qwen_client(
api_key: str,
model: str = "qwen3.5-flash",
base_url: str = "http://6.86.80.4:30080/v1",
**kwargs
) -> QwenClient:
"""便捷函数创建Qwen客户端"""
config = LLMConfig(
provider=LLMProvider.QWEN,
model=model,
api_key=api_key,
base_url=base_url,
**kwargs
)
return QwenClient(config)
def create_qwen_vl_client(
api_key: str,
model: str = "qwen3-vl-plus",
base_url: str = "http://6.86.80.4:30080/v1",
**kwargs
) -> QwenVLClient:
"""便捷函数创建QwenVL客户端"""
config = LLMConfig(
provider=LLMProvider.QWEN_VL,
model=model,
api_key=api_key,
base_url=base_url,
**kwargs
)
return QwenVLClient(config)

View File

@@ -1,7 +0,0 @@
# src/services/parser/__init__.py
"""文档解析服务"""
from .pdf_parser import PDFParser
from .docx_parser import DocxParser
__all__ = ["PDFParser", "DocxParser"]

View File

@@ -1,287 +0,0 @@
# src/services/parser/docx_parser.py
"""Word文档解析 - 使用python-docx"""
from docx import Document
from docx.enum.text import WD_ALIGN_PARAGRAPH
from typing import List, Dict, Optional
from dataclasses import dataclass, field
from loguru import logger
import re
@dataclass
class DocxParagraph:
"""段落内容"""
text: str
level: int = 0 # 标题级别0表示正文
is_list: bool = False
list_number: Optional[str] = None
@dataclass
class DocxTable:
"""表格内容"""
rows: List[List[str]]
markdown: str = ""
@dataclass
class DocxDocumentContent:
"""Word文档完整内容"""
file_path: str
paragraphs: List[DocxParagraph]
tables: List[DocxTable]
metadata: Dict[str, str] = field(default_factory=dict)
markdown_text: str = ""
class DocxParser:
"""Word文档解析器 - 基于python-docx"""
def __init__(self):
self.document = None
def parse(self, file_path: str) -> DocxDocumentContent:
"""
解析Word文档
Args:
file_path: Word文档路径
Returns:
DocxDocumentContent: 解析后的文档内容
"""
logger.info(f"开始解析Word文档: {file_path}")
try:
self.document = Document(file_path)
doc_content = DocxDocumentContent(
file_path=file_path,
paragraphs=[],
tables=[]
)
# 提取文档元数据
doc_content.metadata = self._extract_metadata()
# 提取段落
doc_content.paragraphs = self._extract_paragraphs()
# 提取表格
doc_content.tables = self._extract_tables()
# 生成Markdown格式文本
doc_content.markdown_text = self._generate_markdown(doc_content)
logger.success(f"Word文档解析完成{len(doc_content.paragraphs)}个段落")
return doc_content
except Exception as e:
logger.error(f"Word文档解析失败: {e}")
raise
def _extract_metadata(self) -> Dict[str, str]:
"""提取文档元数据"""
metadata = {}
try:
core_props = self.document.core_properties
metadata = {
"title": core_props.title or "",
"author": core_props.author or "",
"subject": core_props.subject or "",
"keywords": core_props.keywords or "",
"created": str(core_props.created) if core_props.created else "",
"modified": str(core_props.modified) if core_props.modified else "",
}
except Exception as e:
logger.warning(f"提取元数据失败: {e}")
return metadata
def _extract_paragraphs(self) -> List[DocxParagraph]:
"""提取所有段落"""
paragraphs = []
for para in self.document.paragraphs:
text = para.text.strip()
if not text:
continue
# 判断标题级别
level = self._get_paragraph_level(para)
# 判断是否是列表项
is_list, list_number = self._detect_list_item(para)
paragraph = DocxParagraph(
text=text,
level=level,
is_list=is_list,
list_number=list_number
)
paragraphs.append(paragraph)
return paragraphs
def _get_paragraph_level(self, para) -> int:
"""
判断段落标题级别
Returns:
int: 标题级别0表示正文
"""
# 方法1检查段落样式
style_name = para.style.name if para.style else ""
if "Heading" in style_name or "标题" in style_name:
# 从样式名称中提取级别
match = re.search(r'Heading\s*(\d)|标题\s*(\d)', style_name)
if match:
level = int(match.group(1) or match.group(2))
return level
# 方法2检查段落格式字号
# 标题通常字号较大
if para.paragraph_format:
# 可以根据字号判断,这里简化处理
pass
# 方法3根据内容模式判断法规文档特征
text = para.text.strip()
# 第一章、第X章 -> 二级标题
if re.match(r'^第[一二三四五六七八九十百]+章\s', text):
return 2
# 第X节 -> 三级标题
elif re.match(r'^第[一二三四五六七八九十百]+节\s', text):
return 3
# 第X条 -> 四级标题
elif re.match(r'^第[一二三四五六七八九十百]+条\s', text):
return 4
return 0 # 正文
def _detect_list_item(self, para) -> tuple[bool, Optional[str]]:
"""检测是否是列表项"""
text = para.text.strip()
# 数字列表1.、2.、1、[1]等
if re.match(r'^[\d]+[.、)\]]\s', text):
match = re.match(r'^([\d]+[.、)\]])\s', text)
return True, match.group(1) if match else None
# 中文数字列表:一、二、(一)等
if re.match(r'^[一二三四五六七八九十]+[、.)]\s', text):
match = re.match(r'^([一二三四五六七八九十]+[、.)])\s', text)
return True, match.group(1) if match else None
# 检查段落格式中的列表编号
if para.paragraph_format and hasattr(para.paragraph_format, 'left_indent'):
# 有缩进的可能是列表项
pass
return False, None
def _extract_tables(self) -> List[DocxTable]:
"""提取所有表格"""
tables = []
for table in self.document.tables:
rows = []
for row in table.rows:
cells = []
for cell in row.cells:
cells.append(cell.text.strip())
rows.append(cells)
# 转换为Markdown表格
markdown = self._table_to_markdown(rows)
table_content = DocxTable(rows=rows, markdown=markdown)
tables.append(table_content)
return tables
def _table_to_markdown(self, rows: List[List[str]]) -> str:
"""将表格转换为Markdown格式"""
if not rows or len(rows) < 1:
return ""
lines = []
# 表头
if len(rows) >= 1:
header = rows[0]
lines.append("| " + " | ".join(cell for cell in header) + " |")
lines.append("| " + " | ".join("---" for _ in header) + " |")
# 数据行
for row in rows[1:]:
lines.append("| " + " | ".join(cell for cell in row) + " |")
return "\n".join(lines)
def _generate_markdown(self, doc_content: DocxDocumentContent) -> str:
"""生成Markdown格式文本"""
lines = []
# 文档标题
title = doc_content.metadata.get("title", "")
if title:
lines.append(f"# {title}\n")
else:
# 从第一个段落获取标题(如果是标题样式)
for para in doc_content.paragraphs[:5]:
if para.level == 1:
lines.append(f"# {para.text}\n")
break
else:
lines.append(f"# {doc_content.file_path}\n")
# 元数据信息
lines.append("\n## 文档信息\n")
for key, value in doc_content.metadata.items():
if value:
lines.append(f"- **{key}**: {value}")
# 正文内容
lines.append("\n## 正文\n")
table_index = 0
for para in doc_content.paragraphs:
if para.level > 0:
# 标题
prefix = "#" * para.level
lines.append(f"\n{prefix} {para.text}\n")
elif para.is_list:
# 列表项
lines.append(f"- {para.text}")
else:
# 正文
lines.append(para.text)
# 添加表格
if doc_content.tables:
lines.append("\n## 表格\n")
for i, table in enumerate(doc_content.tables):
lines.append(f"\n### 表格 {i + 1}\n")
lines.append(table.markdown + "\n")
return "\n".join(lines)
def parse_to_markdown(self, file_path: str) -> str:
"""直接解析并返回Markdown文本"""
doc_content = self.parse(file_path)
return doc_content.markdown_text
def parse_docx(file_path: str) -> DocxDocumentContent:
"""便捷函数解析Word文档"""
parser = DocxParser()
return parser.parse(file_path)
def parse_docx_to_markdown(file_path: str) -> str:
"""便捷函数解析Word并返回Markdown"""
parser = DocxParser()
return parser.parse_to_markdown(file_path)

View File

@@ -1,204 +0,0 @@
# src/services/parser/mineru_parser.py
"""MinerU多模态PDF解析 - 版面感知解析"""
from typing import Optional, Dict
from dataclasses import dataclass, field
from loguru import logger
import os
@dataclass
class MinerUResult:
"""MinerU解析结果"""
file_path: str
markdown_text: str
metadata: Dict[str, str] = field(default_factory=dict)
success: bool = True
error_message: str = ""
class MinerUParser:
"""
MinerU多模态PDF解析器
MinerU (magic-pdf) 是一个开源的高质量PDF解析工具
支持版面感知解析,能够识别文档中的标题、正文、表格、图片等元素,
并输出结构化的Markdown格式。
GitHub: https://github.com/opendatalab/MinerU
"""
def __init__(self):
self.available = self._check_mineru_available()
def _check_mineru_available(self) -> bool:
"""检查MinerU是否可用"""
try:
from magic_pdf.pipe.UNIPipe import UNIPipe
return True
except ImportError:
logger.warning("MinerU (magic-pdf) 未安装,请运行: pip install magic-pdf[full]")
return False
def parse(self, file_path: str, output_dir: Optional[str] = None) -> MinerUResult:
"""
使用MinerU解析PDF文档
Args:
file_path: PDF文件路径
output_dir: 输出目录(可选,用于保存解析产物)
Returns:
MinerUResult: 解析结果
"""
logger.info(f"尝试使用MinerU解析: {file_path}")
if not self.available:
return MinerUResult(
file_path=file_path,
markdown_text="",
success=False,
error_message="MinerU未安装"
)
try:
from magic_pdf.pipe.UNIPipe import UNIPipe
from magic_pdf.libs.MakeContentConfig import DropMode
# 设置输出目录
if output_dir is None:
output_dir = os.path.dirname(file_path)
# 创建解析管道
# OCR模式可以根据PDF类型选择
# auto: 自动判断是否需要OCR
# txt: 纯文本PDF无OCR
# ocr: 扫描件PDFOCR
pipe = UNIPipe(file_path, output_dir)
# 执行解析
# pipe_mk() 返回Markdown格式文本
markdown_content = pipe.pipe_mk()
logger.success(f"MinerU解析成功")
return MinerUResult(
file_path=file_path,
markdown_text=markdown_content,
metadata=self._extract_metadata(pipe),
success=True
)
except Exception as e:
logger.error(f"MinerU解析失败: {e}")
return MinerUResult(
file_path=file_path,
markdown_text="",
success=False,
error_message=str(e)
)
def _extract_metadata(self, pipe) -> Dict[str, str]:
"""从解析管道提取元数据"""
metadata = {}
try:
# MinerU解析管道中可能包含的元数据信息
if hasattr(pipe, 'pdf_mid_data') and pipe.pdf_mid_data:
mid_data = pipe.pdf_mid_data
# 提取可能的元数据字段
metadata = {
"page_count": str(mid_data.get("page_count", "")),
"language": str(mid_data.get("language", "")),
"is_scanned": str(mid_data.get("is_scanned", "")),
}
except Exception as e:
logger.warning(f"提取MinerU元数据失败: {e}")
return metadata
def parse_to_markdown(self, file_path: str) -> str:
"""直接解析并返回Markdown文本"""
result = self.parse(file_path)
return result.markdown_text if result.success else ""
class ParserOrchestrator:
"""
解析服务编排 - 按优先级选择解析器
解析策略:
1. 优先尝试MinerU版面感知能力强
2. MinerU失败时回退到基础PyMuPDF解析
"""
def __init__(self):
from .pdf_parser import PDFParser
self.mineru_parser = MinerUParser()
self.pdf_parser = PDFParser()
self.mineru_available = self.mineru_parser.available
def parse_pdf(self, file_path: str, prefer_mineru: bool = True) -> str:
"""
解析PDF文档按优先级选择解析器
Args:
file_path: PDF文件路径
prefer_mineru: 是否优先使用MinerU
Returns:
str: Markdown格式文本
"""
markdown_text = ""
if prefer_mineru and self.mineru_available:
# 优先尝试MinerU
result = self.mineru_parser.parse(file_path)
if result.success:
markdown_text = result.markdown_text
logger.info("使用MinerU解析成功")
return markdown_text
else:
logger.warning(f"MinerU解析失败回退到PyMuPDF: {result.error_message}")
# 回退到PyMuPDF基础解析
logger.info("使用PyMuPDF基础解析")
markdown_text = self.pdf_parser.parse_to_markdown(file_path)
return markdown_text
def parse_docx(self, file_path: str) -> str:
"""解析Word文档"""
from .docx_parser import DocxParser
docx_parser = DocxParser()
return docx_parser.parse_to_markdown(file_path)
def parse(self, file_path: str) -> str:
"""
根据文件类型选择解析器
Args:
file_path: 文件路径
Returns:
str: Markdown格式文本
"""
ext = os.path.splitext(file_path)[1].lower()
if ext == ".pdf":
return self.parse_pdf(file_path)
elif ext in [".docx", ".doc"]:
return self.parse_docx(file_path)
else:
raise ValueError(f"不支持的文件类型: {ext}")
def parse_with_mineru(file_path: str) -> MinerUResult:
"""便捷函数使用MinerU解析"""
parser = MinerUParser()
return parser.parse(file_path)
def parse_pdf_smart(file_path: str) -> str:
"""便捷函数智能解析PDF自动选择最佳解析器"""
orchestrator = ParserOrchestrator()
return orchestrator.parse_pdf(file_path)

View File

@@ -1,268 +0,0 @@
# src/services/parser/pdf_parser.py
"""PDF文档解析 - 使用PyMuPDF基础解析"""
import fitz # PyMuPDF
from typing import List, Dict, Optional, Tuple
from dataclasses import dataclass, field
from loguru import logger
import re
@dataclass
class PDFPageContent:
"""PDF页面内容"""
page_number: int
text: str
tables: List[str] = field(default_factory=list)
images: List[str] = field(default_factory=list) # 图片路径列表
blocks: List[Dict] = field(default_factory=list)
@dataclass
class PDFDocumentContent:
"""PDF文档完整内容"""
file_path: str
total_pages: int
pages: List[PDFPageContent]
metadata: Dict[str, str] = field(default_factory=dict)
markdown_text: str = ""
class PDFParser:
"""PDF文档解析器 - 基于PyMuPDF"""
def __init__(self):
self.pdf = None
def parse(self, file_path: str, extract_tables: bool = True, extract_images: bool = False) -> PDFDocumentContent:
"""
解析PDF文档
Args:
file_path: PDF文件路径
extract_tables: 是否提取表格
extract_images: 是否提取图片
Returns:
PDFDocumentContent: 解析后的文档内容
"""
logger.info(f"开始解析PDF文档: {file_path}")
try:
self.pdf = fitz.open(file_path)
doc_content = PDFDocumentContent(
file_path=file_path,
total_pages=self.pdf.page_count,
pages=[]
)
# 提取文档元数据
doc_content.metadata = self._extract_metadata()
# 逐页解析
for page_num in range(self.pdf.page_count):
page = self.pdf[page_num]
page_content = self._parse_page(page, page_num + 1, extract_tables, extract_images)
doc_content.pages.append(page_content)
# 生成Markdown格式文本
doc_content.markdown_text = self._generate_markdown(doc_content)
self.pdf.close()
logger.success(f"PDF解析完成{doc_content.total_pages}")
return doc_content
except Exception as e:
logger.error(f"PDF解析失败: {e}")
raise
def _extract_metadata(self) -> Dict[str, str]:
"""提取PDF元数据"""
metadata = {}
try:
meta = self.pdf.metadata
metadata = {
"title": meta.get("title", ""),
"author": meta.get("author", ""),
"subject": meta.get("subject", ""),
"keywords": meta.get("keywords", ""),
"creator": meta.get("creator", ""),
"producer": meta.get("producer", ""),
"creation_date": meta.get("creationDate", ""),
"mod_date": meta.get("modDate", ""),
}
except Exception as e:
logger.warning(f"提取元数据失败: {e}")
return metadata
def _parse_page(self, page: fitz.Page, page_num: int,
extract_tables: bool, extract_images: bool) -> PDFPageContent:
"""解析单页内容"""
page_content = PDFPageContent(page_number=page_num, text="")
# 提取文本块(保留结构)
blocks = page.get_text("dict", flags=fitz.TEXT_PRESERVE_WHITESPACE)["blocks"]
page_content.blocks = blocks
# 提取纯文本
text = page.get_text("text", flags=fitz.TEXT_PRESERVE_WHITESPACE)
page_content.text = text.strip()
# 提取表格使用PyMuPDF的表格提取功能
if extract_tables:
tables = self._extract_tables_from_page(page)
page_content.tables = tables
# 提取图片
if extract_images:
images = self._extract_images_from_page(page, page_num)
page_content.images = images
return page_content
def _extract_tables_from_page(self, page: fitz.Page) -> List[str]:
"""
从页面提取表格(基于文本块分析)
注意PyMuPDF基础版表格提取能力有限复杂表格建议使用MinerU
"""
tables = []
try:
# 使用PyMuPDF的表格提取方法2.4+版本)
# 对于更复杂的表格需要在mineru_parser中使用更高级的方法
tabs = page.find_tables()
if tabs:
for tab in tabs:
table_text = tab.extract()
# 将表格转换为Markdown格式
markdown_table = self._table_to_markdown(table_text)
tables.append(markdown_table)
except AttributeError:
# 旧版本PyMuPDF没有表格提取功能
logger.warning("PyMuPDF版本不支持表格提取请升级到2.4+版本")
except Exception as e:
logger.warning(f"表格提取失败: {e}")
return tables
def _table_to_markdown(self, table_data: List[List[str]]) -> str:
"""将表格数据转换为Markdown格式"""
if not table_data or len(table_data) < 1:
return ""
lines = []
# 表头
if len(table_data) >= 1:
header = table_data[0]
lines.append("| " + " | ".join(str(cell).strip() for cell in header) + " |")
lines.append("| " + " | ".join("---" for _ in header) + " |")
# 数据行
for row in table_data[1:]:
lines.append("| " + " | ".join(str(cell).strip() for cell in row) + " |")
return "\n".join(lines)
def _extract_images_from_page(self, page: fitz.Page, page_num: int) -> List[str]:
"""提取页面图片"""
images = []
# 图片提取功能(可选实现)
# 这里仅记录图片信息,实际图片需要额外保存
try:
image_list = page.get_images()
for img_index, img in enumerate(image_list):
xref = img[0]
images.append(f"image_p{page_num}_i{img_index}_xref{xref}")
except Exception as e:
logger.warning(f"图片提取失败: {e}")
return images
def _generate_markdown(self, doc_content: PDFDocumentContent) -> str:
"""生成Markdown格式文本"""
lines = []
# 文档标题
title = doc_content.metadata.get("title", "")
if title:
lines.append(f"# {title}\n")
else:
lines.append(f"# {doc_content.file_path}\n")
# 元数据信息
lines.append("\n## 文档信息\n")
for key, value in doc_content.metadata.items():
if value and key in ["author", "subject", "keywords", "creation_date"]:
lines.append(f"- **{key}**: {value}")
# 正文内容
lines.append("\n## 正文\n")
for page in doc_content.pages:
# 页码标记
lines.append(f"\n---\n**第 {page.page_number} 页**\n")
# 处理文本内容,识别标题结构
text = self._process_page_text(page.text, page.blocks)
lines.append(text)
# 添加表格
for table in page.tables:
lines.append("\n" + table + "\n")
return "\n".join(lines)
def _process_page_text(self, text: str, blocks: List[Dict]) -> str:
"""处理页面文本,识别标题结构"""
# 基于字体大小识别标题
processed_text = text
# 尝试识别标题(基于字号)
# 法规文档通常有明确的层级结构:章、节、条
processed_text = self._detect_headers(text, blocks)
return processed_text
def _detect_headers(self, text: str, blocks: List[Dict]) -> str:
"""检测并标记标题(基于字号或内容模式)"""
lines = text.split("\n")
processed_lines = []
for line in lines:
line = line.strip()
if not line:
continue
# 法规标题模式检测
# 第一章、第X章、第X节、第X条等
if re.match(r'^第[一二三四五六七八九十百]+章\s', line):
processed_lines.append(f"\n## {line}\n")
elif re.match(r'^第[一二三四五六七八九十百]+节\s', line):
processed_lines.append(f"\n### {line}\n")
elif re.match(r'^第[一二三四五六七八九十百]+条\s', line):
processed_lines.append(f"\n#### {line}\n")
elif re.match(r'^[一二三四五六七八九十]+\s*[、.]', line):
# 条款子项
processed_lines.append(f"- {line}")
else:
processed_lines.append(line)
return "\n".join(processed_lines)
def parse_to_markdown(self, file_path: str) -> str:
"""直接解析并返回Markdown文本"""
doc_content = self.parse(file_path)
return doc_content.markdown_text
def parse_pdf(file_path: str, **kwargs) -> PDFDocumentContent:
"""便捷函数解析PDF文档"""
parser = PDFParser()
return parser.parse(file_path, **kwargs)
def parse_pdf_to_markdown(file_path: str) -> str:
"""便捷函数解析PDF并返回Markdown"""
parser = PDFParser()
return parser.parse_to_markdown(file_path)

View File

@@ -1,12 +0,0 @@
# src/services/rag/__init__.py
"""RAG服务模块"""
from .retriever import Retriever, retrieve_regulations
from .context_builder import ContextBuilder, build_rag_context
from .prompt_templates import PromptTemplates, get_prompt_template
__all__ = [
"Retriever", "retrieve_regulations",
"ContextBuilder", "build_rag_context",
"PromptTemplates", "get_prompt_template"
]

View File

@@ -1,230 +0,0 @@
# src/services/rag/context_builder.py
"""RAG上下文构建服务 - 构建LLM输入上下文"""
from typing import List, Dict, Optional
from dataclasses import dataclass
from loguru import logger
from .retriever import RetrievedDocument
from src.config.settings import settings
@dataclass
class RAGContext:
"""RAG构建的上下文"""
system_prompt: str
context_text: str
user_query: str
total_tokens: int
sources: List[Dict]
truncated: bool = False
class ContextBuilder:
"""
RAG上下文构建器
功能:
- 格式化检索结果为上下文文本
- 控制上下文长度token限制
- 构建完整的LLM输入格式
"""
def __init__(
self,
max_context_tokens: int = None,
include_metadata: bool = True,
citation_format: str = "【条款{clause}"
):
"""
初始化上下文构建器
Args:
max_context_tokens: 最大上下文token数
include_metadata: 是否包含元数据(文档名、条款号等)
citation_format: 引用格式模板
"""
self.max_context_tokens = max_context_tokens or settings.rag_max_context_tokens
self.include_metadata = include_metadata
self.citation_format = citation_format
logger.info(f"上下文构建器初始化: max_tokens={self.max_context_tokens}")
def build(
self,
query: str,
documents: List[RetrievedDocument],
system_prompt: Optional[str] = None,
max_tokens: Optional[int] = None
) -> RAGContext:
"""
构建RAG上下文
Args:
query: 用户查询
documents: 检索到的文档列表
system_prompt: 系统提示词(可选)
max_tokens: 最大token数可选覆盖默认值
Returns:
RAGContext: 构建的上下文对象
"""
max_tokens = max_tokens or self.max_context_tokens
# 格式化文档内容
context_text, sources, truncated = self._format_documents(
documents,
max_tokens
)
# 构建系统提示词
system_prompt = system_prompt or self._default_system_prompt()
# 估算总token数
total_tokens = self._estimate_tokens(system_prompt + context_text + query)
logger.info(f"上下文构建完成: {len(documents)}条文档, {total_tokens}tokens, truncated={truncated}")
return RAGContext(
system_prompt=system_prompt,
context_text=context_text,
user_query=query,
total_tokens=total_tokens,
sources=sources,
truncated=truncated
)
def _format_documents(
self,
documents: List[RetrievedDocument],
max_tokens: int
) -> tuple:
"""
格式化文档内容
Args:
documents: 文档列表
max_tokens: 最大token数
Returns:
(context_text, sources, truncated)
"""
context_parts = []
sources = []
current_tokens = 0
truncated = False
for i, doc in enumerate(documents):
# 格式化单个文档
formatted = self._format_single_doc(doc, i + 1)
# 估算token数
doc_tokens = self._estimate_tokens(formatted)
# 检查是否超出限制
if current_tokens + doc_tokens > max_tokens:
truncated = True
logger.warning(f"上下文截断: 已达到{max_tokens}tokens限制")
break
context_parts.append(formatted)
current_tokens += doc_tokens
# 记录来源
sources.append({
"index": i + 1,
"doc_id": doc.doc_id,
"doc_name": doc.doc_name,
"section_title": doc.section_title,
"clause_number": doc.clause_number,
"page_number": doc.page_number,
"score": doc.score
})
context_text = "\n\n".join(context_parts)
return context_text, sources, truncated
def _format_single_doc(
self,
doc: RetrievedDocument,
index: int
) -> str:
"""格式化单个文档"""
parts = []
# 索引编号
parts.append(f"[{index}]")
# 元数据(可选)
if self.include_metadata:
meta_parts = []
if doc.doc_name:
meta_parts.append(f"文档: {doc.doc_name}")
if doc.section_title:
meta_parts.append(f"章节: {doc.section_title}")
if doc.clause_number:
clause_text = self.citation_format.format(clause=doc.clause_number)
meta_parts.append(clause_text)
if meta_parts:
parts.append(" | ".join(meta_parts))
# 内容
parts.append(doc.content)
return "\n".join(parts)
def _default_system_prompt(self) -> str:
"""默认系统提示词"""
return """你是合规专家助手,基于提供的法规条款回答问题。
回答要求:
1. 直接回答问题必须引用具体条款编号如【条款5.2.1】)
2. 如引用的条款不完整,说明需要进一步查阅原文
3. 给出明确的合规建议和操作指导
4. 如果检索内容不足以回答问题,如实说明
回答格式:
- 先给出直接结论
- 然后引用支撑条款
- 最后给出合规建议"""
def _estimate_tokens(self, text: str) -> int:
"""估算文本token数"""
# 中文字符约1.5 token英文约0.25 token
chinese_chars = sum(1 for c in text if '' <= c <= '鿿')
other_chars = len(text) - chinese_chars
return int(chinese_chars * 1.5 + other_chars * 0.25)
def build_messages(
self,
context: RAGContext
) -> List[Dict[str, str]]:
"""
构建LLM消息格式
Args:
context: RAG上下文对象
Returns:
List[Dict]: [{"role": "system/user/assistant", "content": "..."}]
"""
messages = [
{"role": "system", "content": context.system_prompt},
{"role": "user", "content": f"参考以下法规条款回答问题。\n\n{context.context_text}\n\n问题:{context.user_query}"}
]
return messages
def build_rag_context(
query: str,
documents: List[RetrievedDocument],
**kwargs
) -> RAGContext:
"""便捷函数构建RAG上下文"""
builder = ContextBuilder()
return builder.build(query, documents, **kwargs)

View File

@@ -1,296 +0,0 @@
# src/services/rag/prompt_templates.py
"""RAG Prompt模板 - 合规问答专用Prompt"""
from typing import Dict, Optional
from dataclasses import dataclass
@dataclass
class PromptTemplate:
"""Prompt模板"""
name: str
system_prompt: str
user_template: str
description: str
class PromptTemplates:
"""
合规问答Prompt模板库
包含多种场景的Prompt模板
- 合规问答(标准)
- 条款解读(详细解释)
- 合规检查(判断合规状态)
- 差异对比(新旧法规对比)
- 报告生成(合规报告)
"""
# 合规问答标准模板
COMPLIANCE_QA = PromptTemplate(
name="compliance_qa",
system_prompt="""你是合规专家助手,专门解答法规合规问题。
角色定位:
- 深谙国家法规标准GB标准、行业标准
- 熟悉车辆安全、数据安全、EHS等领域合规要求
- 提供专业、准确、可操作的合规建议
回答规范:
1. 必须引用具体条款编号如【条款5.2.1】)
2. 优先引用高相关性条款score ≥ 0.5
3. 如条款内容不完整,明确提示需要查阅原文
4. 给出明确的合规结论和建议
5. 如检索内容不足以回答,如实说明
回答格式:
【结论】直接给出合规判断或答案
【条款依据】
- 【条款X.X.X】简要内容摘要相关性: 高/中)
- ...
【合规建议】
1. 具体操作建议
2. 需要注意的风险点
3. 后续行动建议""",
user_template="""请根据以下法规条款回答问题。
【法规条款】
{context}
【用户问题】
{query}""",
description="标准合规问答模板"
)
# 条款解读模板(详细解释)
CLAUSE_INTERPRETATION = PromptTemplate(
name="clause_interpretation",
system_prompt="""你是法规解读专家,负责详细解释法规条款的含义和应用。
解读要求:
1. 逐句解释条款原文的含义
2. 说明条款的目的和背景
3. 举例说明条款的实际应用场景
4. 解释常见的误解和注意事项
解读格式:
【条款原文】完整引用条款
【逐句解读】
- "原文句1":解读含义
- "原文句2":解读含义
...
【应用场景】
具体举例说明该条款在实际工作中如何应用
【注意事项】
常见误解、执行难点、合规风险点""",
user_template="""请解读以下法规条款:
条款编号:{clause_number}
条款内容:{content}
用户关注点:{query}""",
description="条款详细解读模板"
)
# 合规检查模板(判断合规状态)
COMPLIANCE_CHECK = PromptTemplate(
name="compliance_check",
system_prompt="""你是合规检查专家,负责评估企业行为或产品的合规状态。
检查流程:
1. 理解企业行为/产品描述
2. 识别相关的法规条款
3. 逐条对照检查合规状态
4. 给出综合合规结论和整改建议
合规状态分类:
- ✅ 符合:完全满足法规要求
- ⚠️ 需评估:需要进一步核实或补充材料
- ❌ 不符合:明确违反法规要求
- ❓ 无适用条款:检索内容不足以判断
检查格式:
【合规检查报告】
一、检查对象
{描述企业行为/产品}
二、条款对照检查
| 条款编号 | 要求摘要 | 检查状态 | 说明 |
|--------|---------|---------|------|
| 【条款X.X.X】 | ... | ✅/⚠️/❌/❓ | ... |
三、综合结论
合规等级A/B/C/D完全合规/基本合规/部分合规/不合规)
四、整改建议(如需要)
1. ...
2. ...""",
user_template="""请对以下企业行为进行合规检查:
【行为/产品描述】
{query}
【相关法规条款】
{context}""",
description="合规检查评估模板"
)
# 差异对比模板(新旧法规对比)
COMPARISON = PromptTemplate(
name="comparison",
system_prompt="""你是法规变更分析专家,负责对比新旧法规版本的差异。
对比任务:
1. 识别新旧版本的条款差异
2. 分类差异类型(新增/修改/删除)
3. 分析差异的影响范围
4. 给出企业应对建议
差异分类:
- 🆕 新增条款:原版本不存在
- 🔄 修改条款:内容有实质性变更
- ❌ 删除条款:原条款被移除
- ⚖️ 调整条款:仅格式/编号调整,实质内容不变
对比格式:
【法规变更对比分析】
一、变更概述
- 旧版本:{version_old}
- 新版本:{version_new}
- 变更条款数:{count}
二、差异明细
| 类型 | 条款编号 | 旧版本内容 | 新版本内容 | 变化要点 |
|-----|---------|-----------|-----------|---------|
| 🆕 | X.X.X | - | ... | 新增要求... |
三、影响分析
- 高影响:{条款列表}
- 中影响:{条款列表}
- 低影响:{条款列表}
四、应对建议
1. 立即整改项
2. 逐步调整项
3. 持续关注项""",
user_template="""请对比分析以下法规差异:
【用户问题】
{query}
【旧版本条款】
{context_old}
【新版本条款】
{context_new}""",
description="法规版本对比模板"
)
# 报告生成模板
REPORT_GENERATION = PromptTemplate(
name="report_generation",
system_prompt="""你是合规报告撰写专家,负责生成结构化的合规分析报告。
报告要求:
1. 结构清晰、逻辑严谨
2. 数据准确、引用规范
3. 结论明确、建议可操作
4. 语言专业、表达简洁
报告结构:
1. 概述(背景、范围)
2. 分析内容(主体分析)
3. 发现问题(合规差距)
4. 整改建议(具体措施)
5. 附录(引用条款原文)""",
user_template="""请生成以下合规报告:
【报告主题】
{query}
【分析依据】
{context}
【报告要求】
{requirements}""",
description="合规报告生成模板"
)
# 文档摘要生成模板
DOCUMENT_SUMMARY = PromptTemplate(
name="document_summary",
system_prompt="""你是法规文档摘要专家,负责生成法规文档的核心要点摘要。
摘要要求:
1. 精炼核心内容不超过1024字
2. 突出关键合规要求和条款编号
3. 说明适用范围和生效条件
4. 列出重要定义和术语解释
摘要格式:
【法规名称】{doc_name}
【适用范围】{适用范围描述}
【核心条款摘要】
- 【条款X.X.X】{关键要求}(重要度:高)
- ...
【关键术语】
- 术语1定义解释
- ...
【合规要点】
1. 必须满足的核心要求
2. 需要特别注意的条款""",
user_template="""请生成以下法规文档的摘要:
【文档名称】
{doc_name}
【文档内容】
{content}
请生成不超过1024字的摘要。""",
description="文档摘要生成模板"
)
@classmethod
def get_template(cls, name: str) -> Optional[PromptTemplate]:
"""获取指定模板"""
templates = {
"compliance_qa": cls.COMPLIANCE_QA,
"clause_interpretation": cls.CLAUSE_INTERPRETATION,
"compliance_check": cls.COMPLIANCE_CHECK,
"comparison": cls.COMPARISON,
"report": cls.REPORT_GENERATION,
"document_summary": cls.DOCUMENT_SUMMARY
}
return templates.get(name)
@classmethod
def list_templates(cls) -> Dict[str, str]:
"""列出所有模板"""
return {
"compliance_qa": cls.COMPLIANCE_QA.description,
"clause_interpretation": cls.CLAUSE_INTERPRETATION.description,
"compliance_check": cls.COMPLIANCE_CHECK.description,
"comparison": cls.COMPARISON.description,
"report": cls.REPORT_GENERATION.description,
"document_summary": cls.DOCUMENT_SUMMARY.description
}
def get_prompt_template(name: str) -> PromptTemplate:
"""便捷函数获取Prompt模板"""
template = PromptTemplates.get_template(name)
if not template:
raise ValueError(f"不存在的模板: {name}")
return template

View File

@@ -1,193 +0,0 @@
# src/services/rag/retriever.py
"""RAG检索服务 - 封装Milvus检索"""
from typing import List, Dict, Optional, Any
from dataclasses import dataclass, field
from loguru import logger
from src.services.embedding.bge_m3_embedder import BGEM3Embedder
from src.services.storage.milvus_client import MilvusClient, SearchResult
from src.config.settings import settings
@dataclass
class RetrievedDocument:
"""检索到的文档"""
content: str
doc_id: str # 文档ID用于下载
doc_name: str
section_title: str
clause_number: str
page_number: int
score: float
metadata: Dict[str, Any] = field(default_factory=dict)
class Retriever:
"""
RAG检索器
功能:
- 向量检索Dense + Sparse混合
- 重排序(可选)
- 过滤和筛选
"""
def __init__(
self,
top_k: int = None,
rerank: bool = False,
min_score: float = 0.3
):
"""
初始化检索器
Args:
top_k: 检索召回数量
rerank: 是否启用重排序
min_score: 最低相关性分数阈值
"""
self.top_k = top_k or settings.rag_top_k
self.rerank = rerank
self.min_score = min_score
# 嵌入模型(延迟加载)
self.embedder: Optional[BGEM3Embedder] = None
# Milvus客户端延迟连接
self.milvus: Optional[MilvusClient] = None
logger.info(f"检索器初始化: top_k={self.top_k}, rerank={self.rerank}")
def _init_embedder(self):
"""延迟初始化嵌入模型"""
if self.embedder is None:
logger.info("加载嵌入模型...")
self.embedder = BGEM3Embedder(model_name=settings.embedding_model)
def _init_milvus(self):
"""延迟初始化Milvus"""
if self.milvus is None:
logger.info("连接Milvus...")
self.milvus = MilvusClient()
self.milvus.connect()
self.milvus.create_collection(recreate=False)
self.milvus.load_collection()
def retrieve(
self,
query: str,
filters: Optional[str] = None,
top_k: Optional[int] = None
) -> List[RetrievedDocument]:
"""
检索相关文档
Args:
query: 查询文本
filters: 过滤条件(如 "regulation_type=='车辆安全'"
top_k: 返回数量(可选,覆盖默认值)
Returns:
List[RetrievedDocument]: 检索结果列表
"""
logger.info(f"执行检索: {query}")
# 初始化组件
self._init_embedder()
self._init_milvus()
# 生成查询向量
query_embedding = self.embedder.embed_single(query)
# 执行混合检索
results = self.milvus.hybrid_search(
query_dense=query_embedding['dense'].tolist(),
query_sparse=query_embedding['sparse'],
top_k=top_k or self.top_k,
filters=filters
)
# 转换为RetrievedDocument格式
documents = []
for r in results:
if r.score >= self.min_score:
doc = RetrievedDocument(
content=r.content,
doc_id=r.metadata.get("doc_id", ""),
doc_name=r.metadata.get("doc_name", ""),
section_title=r.metadata.get("section_title", ""),
clause_number=r.metadata.get("clause_number", ""),
page_number=r.metadata.get("page_number", 0),
score=r.score,
metadata=r.metadata
)
documents.append(doc)
logger.success(f"检索完成,返回{len(documents)}条结果(阈值过滤后)")
return documents
def retrieve_with_scores(
self,
query: str,
filters: Optional[str] = None
) -> List[Dict]:
"""
检索并返回完整结果(包含分数)
Args:
query: 查询文本
filters: 过滤条件
Returns:
List[Dict]: 包含分数的检索结果
"""
documents = self.retrieve(query, filters)
return [
{
"content": doc.content,
"doc_id": doc.doc_id,
"doc_name": doc.doc_name,
"section_title": doc.section_title,
"clause_number": doc.clause_number,
"page_number": doc.page_number,
"score": doc.score
}
for doc in documents
]
def search_by_doc_name(
self,
query: str,
doc_name: str
) -> List[RetrievedDocument]:
"""按文档名称过滤检索"""
filters = f'doc_name=="{doc_name}"'
return self.retrieve(query, filters)
def search_by_regulation_type(
self,
query: str,
regulation_type: str
) -> List[RetrievedDocument]:
"""按法规类型过滤检索"""
filters = f'regulation_type=="{regulation_type}"'
return self.retrieve(query, filters)
def close(self):
"""关闭连接"""
if self.milvus:
self.milvus.disconnect()
logger.info("检索器已关闭")
def retrieve_regulations(
query: str,
top_k: int = 10,
filters: Optional[str] = None
) -> List[RetrievedDocument]:
"""便捷函数:检索法规"""
retriever = Retriever(top_k=top_k)
results = retriever.retrieve(query, filters)
retriever.close()
return results

View File

@@ -1,7 +0,0 @@
# src/services/storage/__init__.py
"""存储服务"""
from .milvus_client import MilvusClient
from .minio_client import MinIOClient
__all__ = ["MilvusClient", "MinIOClient"]

View File

@@ -1,485 +0,0 @@
# src/services/storage/milvus_client.py
"""Milvus向量数据库客户端 - 存储与检索服务"""
from pymilvus import (
connections,
Collection,
FieldSchema,
CollectionSchema,
DataType,
utility
)
from typing import List, Dict, Optional, Any
from dataclasses import dataclass, field
from loguru import logger
import time
import numpy as np
from ..embedding.text_chunker import TextChunk
from ..embedding.bge_m3_embedder import EmbeddingResult
from src.config.settings import settings
@dataclass
class SearchResult:
"""检索结果"""
id: int
content: str
score: float
metadata: Dict[str, Any] = field(default_factory=dict)
@dataclass
class MilvusDocument:
"""Milvus文档数据结构"""
doc_id: str
chunk_id: str
content: str
dense_vector: List[float]
sparse_vector: Dict[int, float]
doc_name: str
section_title: str
clause_number: str
page_number: int
regulation_type: str
version: str
create_time: int
class MilvusClient:
"""Milvus向量数据库客户端"""
COLLECTION_NAME = "regulations"
SCHEMA_FIELDS = [
FieldSchema(name="id", dtype=DataType.INT64, is_primary=True, auto_id=True),
FieldSchema(name="doc_id", dtype=DataType.VARCHAR, max_length=64),
FieldSchema(name="chunk_id", dtype=DataType.VARCHAR, max_length=128),
FieldSchema(name="content", dtype=DataType.VARCHAR, max_length=8192),
FieldSchema(name="dense_vector", dtype=DataType.FLOAT_VECTOR, dim=1024),
FieldSchema(name="sparse_vector", dtype=DataType.SPARSE_FLOAT_VECTOR),
FieldSchema(name="doc_name", dtype=DataType.VARCHAR, max_length=256),
FieldSchema(name="section_title", dtype=DataType.VARCHAR, max_length=512),
FieldSchema(name="clause_number", dtype=DataType.VARCHAR, max_length=64),
FieldSchema(name="page_number", dtype=DataType.INT64),
FieldSchema(name="regulation_type", dtype=DataType.VARCHAR, max_length=32),
FieldSchema(name="version", dtype=DataType.VARCHAR, max_length=32),
FieldSchema(name="create_time", dtype=DataType.INT64),
]
def __init__(
self,
host: str = None,
port: int = None,
collection_name: str = None,
db_name: str = None
):
self.host = host or settings.milvus_host
self.port = port or settings.milvus_port
self.collection_name = collection_name or settings.milvus_collection
self.db_name = db_name or settings.milvus_db_name
self.collection: Optional[Collection] = None
self.connected = False
logger.info(f"Milvus客户端配置: {self.host}:{self.port}, Collection: {self.collection_name}")
def connect(self) -> bool:
"""连接到Milvus服务器"""
try:
connections.connect(
alias="default",
host=self.host,
port=self.port,
db_name=self.db_name
)
self.connected = True
logger.success(f"Milvus连接成功: {self.host}:{self.port}")
return True
except Exception as e:
logger.error(f"Milvus连接失败: {e}")
self.connected = False
return False
def disconnect(self):
"""断开连接"""
try:
connections.disconnect("default")
self.connected = False
logger.info("Milvus连接已断开")
except Exception as e:
logger.warning(f"断开连接时出错: {e}")
def create_collection(self, recreate: bool = False) -> bool:
"""创建Collection"""
if not self.connected:
logger.warning("未连接到Milvus请先调用connect()")
return False
try:
if utility.has_collection(self.collection_name):
if recreate:
logger.info(f"删除已存在的Collection: {self.collection_name}")
utility.drop_collection(self.collection_name)
else:
logger.info(f"Collection已存在: {self.collection_name}")
self.collection = Collection(self.collection_name)
return True
schema = CollectionSchema(
fields=self.SCHEMA_FIELDS,
description="法规文档向量存储",
enable_dynamic_field=True
)
self.collection = Collection(
name=self.collection_name,
schema=schema
)
self._create_indexes()
logger.success(f"Collection创建成功: {self.collection_name}")
return True
except Exception as e:
logger.error(f"Collection创建失败: {e}")
return False
def _create_indexes(self):
"""创建向量索引"""
if not self.collection:
return
try:
dense_index_params = {
"metric_type": "COSINE",
"index_type": "IVF_FLAT",
"params": {"nlist": 128}
}
self.collection.create_index(
field_name="dense_vector",
index_params=dense_index_params
)
sparse_index_params = {
"metric_type": "IP",
"index_type": "SPARSE_INVERTED_INDEX",
"params": {"drop_ratio_build": 0.2}
}
self.collection.create_index(
field_name="sparse_vector",
index_params=sparse_index_params
)
logger.success("向量索引创建成功")
except Exception as e:
logger.warning(f"创建索引时出错: {e}")
def load_collection(self):
"""加载Collection到内存"""
if self.collection:
self.collection.load()
logger.info(f"Collection已加载: {self.collection_name}")
def release_collection(self):
"""释放Collection内存"""
if self.collection:
self.collection.release()
logger.info(f"Collection已释放: {self.collection_name}")
def insert_chunks(
self,
chunks: List[TextChunk],
embeddings: EmbeddingResult
) -> List[int]:
"""插入文档分块和嵌入向量"""
if not self.collection:
logger.warning("Collection未初始化")
return []
if len(chunks) != len(embeddings.texts):
logger.warning(f"Chunks数量与嵌入数量不匹配")
return []
logger.info(f"准备插入{len(chunks)}个文档分块")
try:
data = []
current_time = int(time.time())
for chunk, dense_emb, sparse_emb in zip(
chunks,
embeddings.dense_embeddings,
embeddings.sparse_embeddings
):
row = {
"doc_id": chunk.metadata.doc_id,
"chunk_id": chunk.metadata.chunk_id,
"content": chunk.content,
"dense_vector": dense_emb.tolist(),
"sparse_vector": sparse_emb,
"doc_name": chunk.metadata.doc_name,
"section_title": chunk.metadata.section_title,
"clause_number": chunk.metadata.clause_number,
"page_number": chunk.metadata.page_number,
"regulation_type": chunk.metadata.regulation_type,
"version": chunk.metadata.version,
"create_time": current_time
}
data.append(row)
result = self.collection.insert(data)
self.collection.flush()
logger.success(f"插入完成,共{len(result.primary_keys)}条记录")
return result.primary_keys
except Exception as e:
logger.error(f"插入数据失败: {e}")
return []
def hybrid_search(
self,
query_dense: List[float],
query_sparse: Dict[int, float],
top_k: int = 10,
filters: Optional[str] = None
) -> List[SearchResult]:
"""混合检索Dense + Sparse"""
if not self.collection:
logger.warning("Collection未初始化")
return []
try:
self.collection.load()
# 使用简单的Dense检索兼容所有版本
dense_results = self.dense_search(query_dense, top_k, filters)
# 可选合并Sparse结果
if query_sparse:
sparse_results = self.sparse_search(query_sparse, top_k, filters)
merged = self._merge_results(dense_results, sparse_results, top_k)
logger.success(f"混合检索完成,返回{len(merged)}条结果")
return merged
return dense_results
except Exception as e:
logger.error(f"混合检索失败: {e}")
return []
def _merge_results(
self,
dense_results: List[SearchResult],
sparse_results: List[SearchResult],
top_k: int,
dense_weight: float = 0.6
) -> List[SearchResult]:
"""手动融合Dense和Sparse结果"""
sparse_weight = 1 - dense_weight
merged_dict = {}
for r in dense_results:
merged_dict[r.id] = {
"result": r,
"dense_score": r.score * dense_weight,
"sparse_score": 0
}
for r in sparse_results:
if r.id in merged_dict:
merged_dict[r.id]["sparse_score"] = r.score * sparse_weight
else:
merged_dict[r.id] = {
"result": r,
"dense_score": 0,
"sparse_score": r.score * sparse_weight
}
final_results = []
for id_, data in merged_dict.items():
result = data["result"]
final_score = data["dense_score"] + data["sparse_score"]
final_results.append(SearchResult(
id=result.id,
content=result.content,
score=final_score,
metadata=result.metadata
))
final_results.sort(key=lambda x: x.score, reverse=True)
return final_results[:top_k]
def dense_search(
self,
query_dense: List[float],
top_k: int = 10,
filters: Optional[str] = None
) -> List[SearchResult]:
"""纯Dense向量检索"""
if not self.collection:
return []
try:
self.collection.load()
search_params = {
"metric_type": "COSINE",
"params": {"nprobe": 16}
}
results = self.collection.search(
data=[query_dense],
anns_field="dense_vector",
param=search_params,
limit=top_k,
filter=filters,
output_fields=[
"doc_id", "chunk_id", "content",
"doc_name", "section_title", "clause_number",
"page_number", "regulation_type", "version"
]
)
search_results = []
for hits in results:
for hit in hits:
result = SearchResult(
id=hit.id,
content=hit.entity.get("content", ""),
score=hit.score,
metadata={
"doc_id": hit.entity.get("doc_id", ""),
"chunk_id": hit.entity.get("chunk_id", ""),
"doc_name": hit.entity.get("doc_name", ""),
"section_title": hit.entity.get("section_title", ""),
"clause_number": hit.entity.get("clause_number", ""),
"page_number": hit.entity.get("page_number", 0),
"regulation_type": hit.entity.get("regulation_type", ""),
"version": hit.entity.get("version", ""),
}
)
search_results.append(result)
return search_results
except Exception as e:
logger.error(f"Dense检索失败: {e}")
return []
def sparse_search(
self,
query_sparse: Dict[int, float],
top_k: int = 10,
filters: Optional[str] = None
) -> List[SearchResult]:
"""纯Sparse向量检索"""
if not self.collection:
return []
try:
self.collection.load()
search_params = {
"metric_type": "IP",
"params": {"drop_ratio_search": 0.2}
}
results = self.collection.search(
data=[query_sparse],
anns_field="sparse_vector",
param=search_params,
limit=top_k,
filter=filters,
output_fields=[
"doc_id", "chunk_id", "content",
"doc_name", "section_title", "clause_number",
"page_number", "regulation_type", "version"
]
)
search_results = []
for hits in results:
for hit in hits:
result = SearchResult(
id=hit.id,
content=hit.entity.get("content", ""),
score=hit.score,
metadata={
"doc_id": hit.entity.get("doc_id", ""),
"chunk_id": hit.entity.get("chunk_id", ""),
"doc_name": hit.entity.get("doc_name", ""),
"section_title": hit.entity.get("section_title", ""),
"clause_number": hit.entity.get("clause_number", ""),
"page_number": hit.entity.get("page_number", 0),
"regulation_type": hit.entity.get("regulation_type", ""),
"version": hit.entity.get("version", ""),
}
)
search_results.append(result)
return search_results
except Exception as e:
logger.error(f"Sparse检索失败: {e}")
return []
def delete_by_doc_id(self, doc_id: str) -> int:
"""根据doc_id删除记录"""
if not self.collection:
return 0
try:
expr = f'doc_id=="{doc_id}"'
result = self.collection.delete(expr)
logger.info(f"删除记录: doc_id={doc_id}, 数量={len(result.primary_keys)}")
return len(result.primary_keys)
except Exception as e:
logger.error(f"删除失败: {e}")
return 0
def get_collection_stats(self) -> Dict[str, Any]:
"""获取Collection统计信息"""
if not self.collection:
return {}
try:
stats = {
"name": self.collection_name,
"num_entities": self.collection.num_entities,
"description": self.collection.description,
}
return stats
except Exception as e:
logger.warning(f"获取统计信息失败: {e}")
return {}
def create_milvus_client() -> MilvusClient:
"""便捷函数创建Milvus客户端"""
client = MilvusClient()
client.connect()
client.create_collection(recreate=False)
return client
def insert_documents(
client: MilvusClient,
chunks: List[TextChunk],
embeddings: EmbeddingResult
) -> List[int]:
"""便捷函数:插入文档"""
return client.insert_chunks(chunks, embeddings)
def search_regulations(
client: MilvusClient,
query_dense: List[float],
query_sparse: Dict[int, float],
top_k: int = 10
) -> List[SearchResult]:
"""便捷函数:检索法规"""
return client.hybrid_search(query_dense, query_sparse, top_k)

View File

@@ -1,352 +0,0 @@
# src/services/storage/minio_client.py
"""MinIO对象存储客户端 - 文档文件存储"""
from minio import Minio
from minio.error import S3Error
from typing import Optional, Dict, Any
from loguru import logger
from io import BytesIO
import os
from src.config.settings import settings
class MinIOClient:
"""MinIO对象存储客户端"""
def __init__(
self,
endpoint: str = None,
access_key: str = None,
secret_key: str = None,
bucket: str = None,
secure: bool = None
):
"""
初始化MinIO客户端
Args:
endpoint: MinIO服务地址
access_key: 访问密钥
secret_key: 秘密密钥
bucket: 存储桶名称
secure: 是否使用HTTPS
"""
self.endpoint = endpoint or settings.minio_endpoint
self.access_key = access_key or settings.minio_access_key
self.secret_key = secret_key or settings.minio_secret_key
self.bucket = bucket or settings.minio_bucket
self.secure = secure or settings.minio_secure
self.client: Optional[Minio] = None
self.connected = False
logger.info(f"MinIO客户端配置: {self.endpoint}, bucket={self.bucket}")
def connect(self) -> bool:
"""连接MinIO服务"""
try:
self.client = Minio(
self.endpoint,
access_key=self.access_key,
secret_key=self.secret_key,
secure=self.secure
)
self.connected = True
logger.success(f"MinIO连接成功: {self.endpoint}")
return True
except Exception as e:
logger.error(f"MinIO连接失败: {e}")
self.connected = False
return False
def ensure_bucket(self) -> bool:
"""确保存储桶存在"""
if not self.connected:
logger.warning("未连接MinIO请先调用connect()")
return False
try:
if not self.client.bucket_exists(self.bucket):
self.client.make_bucket(self.bucket)
logger.success(f"创建存储桶: {self.bucket}")
else:
logger.info(f"存储桶已存在: {self.bucket}")
return True
except S3Error as e:
logger.error(f"存储桶操作失败: {e}")
return False
def upload_file(
self,
file_path: str,
object_name: str,
metadata: Dict[str, Any] = None
) -> bool:
"""
上传本地文件到MinIO
Args:
file_path: 本地文件路径
object_name: MinIO对象名称
metadata: 元数据
Returns:
bool: 是否成功
"""
if not self.connected:
self.connect()
self.ensure_bucket()
try:
file_size = os.stat(file_path).st_size
content_type = self._get_content_type(file_path)
with open(file_path, 'rb') as f:
self.client.put_object(
self.bucket,
object_name,
f,
file_size,
content_type=content_type,
metadata=metadata
)
logger.success(f"文件上传成功: {object_name}, 大小={file_size}")
return True
except S3Error as e:
logger.error(f"文件上传失败: {e}")
return False
def upload_bytes(
self,
data: bytes,
object_name: str,
content_type: str = "application/octet-stream",
metadata: Dict[str, Any] = None
) -> bool:
"""
上传字节数据到MinIO
Args:
data: 文件字节数据
object_name: MinIO对象名称
content_type: 内容类型
metadata: 元数据注意MinIO仅支持US-ASCII字符
Returns:
bool: 是否成功
"""
if not self.connected:
self.connect()
self.ensure_bucket()
try:
data_stream = BytesIO(data)
# 处理metadata仅保留ASCII安全字符
safe_metadata = None
if metadata:
safe_metadata = {}
for key, value in metadata.items():
if isinstance(value, str):
# 只保留ASCII字符或转换为安全格式
try:
value.encode('ascii')
safe_metadata[key] = value
except UnicodeEncodeError:
# 中文字符跳过或用占位符
safe_metadata[key] = ""
else:
safe_metadata[key] = str(value)
self.client.put_object(
self.bucket,
object_name,
data_stream,
len(data),
content_type=content_type,
metadata=safe_metadata
)
logger.success(f"数据上传成功: {object_name}, 大小={len(data)}")
return True
except S3Error as e:
logger.error(f"数据上传失败: {e}")
return False
def download_file(
self,
object_name: str,
file_path: str
) -> bool:
"""
从MinIO下载文件到本地
Args:
object_name: MinIO对象名称
file_path: 本地保存路径
Returns:
bool: 是否成功
"""
if not self.connected:
self.connect()
try:
self.client.fget_object(
self.bucket,
object_name,
file_path
)
logger.success(f"文件下载成功: {object_name} -> {file_path}")
return True
except S3Error as e:
logger.error(f"文件下载失败: {e}")
return False
def get_object_url(
self,
object_name: str,
expires: int = 3600
) -> Optional[str]:
"""
获取对象下载URL临时URL
Args:
object_name: MinIO对象名称
expires: URL有效期
Returns:
str: 下载URL
"""
if not self.connected:
self.connect()
try:
url = self.client.presigned_get_object(
self.bucket,
object_name,
expires=expires
)
return url
except S3Error as e:
logger.error(f"获取URL失败: {e}")
return None
def get_object_data(self, object_name: str) -> Optional[bytes]:
"""
获取对象数据(字节)
Args:
object_name: MinIO对象名称
Returns:
bytes: 文件数据
"""
if not self.connected:
self.connect()
try:
response = self.client.get_object(self.bucket, object_name)
data = response.read()
response.close()
response.release_conn()
return data
except S3Error as e:
logger.error(f"获取对象数据失败: {e}")
return None
def delete_object(self, object_name: str) -> bool:
"""
删除对象
Args:
object_name: MinIO对象名称
Returns:
bool: 是否成功
"""
if not self.connected:
self.connect()
try:
self.client.remove_object(self.bucket, object_name)
logger.info(f"对象删除成功: {object_name}")
return True
except S3Error as e:
logger.error(f"对象删除失败: {e}")
return False
def list_objects(self, prefix: str = "") -> list:
"""
列出存储桶中的对象
Args:
prefix: 对象名称前缀
Returns:
list: 对象列表
"""
if not self.connected:
self.connect()
try:
objects = self.client.list_objects(self.bucket, prefix=prefix)
return [obj.object_name for obj in objects]
except S3Error as e:
logger.error(f"列出对象失败: {e}")
return []
def object_exists(self, object_name: str) -> bool:
"""
检查对象是否存在
Args:
object_name: MinIO对象名称
Returns:
bool: 是否存在
"""
if not self.connected:
self.connect()
try:
self.client.stat_object(self.bucket, object_name)
return True
except S3Error:
return False
def _get_content_type(self, file_path: str) -> str:
"""根据文件扩展名获取Content-Type"""
ext = os.path.splitext(file_path)[1].lower()
content_types = {
'.pdf': 'application/pdf',
'.doc': 'application/msword',
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'.txt': 'text/plain',
'.json': 'application/json',
'.xml': 'application/xml',
}
return content_types.get(ext, 'application/octet-stream')
def close(self):
"""关闭连接MinIO客户端无需显式关闭"""
self.connected = False
logger.info("MinIO客户端已关闭")
def create_minio_client() -> MinIOClient:
"""便捷函数创建MinIO客户端"""
client = MinIOClient()
client.connect()
client.ensure_bucket()
return client

View File

@@ -1,2 +0,0 @@
# src/workers/__init__.py
"""异步任务Worker模块"""

View File

@@ -1,217 +0,0 @@
#!/bin/bash
# start_all.sh - 整合启动脚本API + 前端)
# 前端使用 Vite 开发模式 (npm run dev)
set -e
VENV_DIR=".venv"
LOG_DIR="logs"
PID_FILE_API="$LOG_DIR/api.pid"
PID_FILE_FRONTEND="$LOG_DIR/frontend.pid"
LOG_FILE_API="$LOG_DIR/api.log"
# 创建日志目录
mkdir -p $LOG_DIR
# 颜色定义
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
NC='\033[0m'
echo ""
echo -e "${CYAN}========================================${NC}"
echo -e "${CYAN} AI+合规智能中枢 - 服务启动${NC}"
echo -e "${CYAN}========================================${NC}"
echo ""
# ===== 环境检查 =====
# 检查虚拟环境
if [ ! -d "$VENV_DIR" ]; then
echo -e "${RED}错误: 虚拟环境不存在${NC}"
echo "请先运行: ./quick_start.sh"
exit 1
fi
# 激活虚拟环境
source $VENV_DIR/bin/activate
echo -e "${GREEN}✓ 虚拟环境已激活: $VENV_DIR${NC}"
# 检查.env文件
if [ ! -f ".env" ]; then
echo -e "${YELLOW}警告: .env文件不存在使用默认配置${NC}"
else
echo -e "${GREEN}✓ 配置文件已加载: .env${NC}"
fi
# 加载环境变量
export $(grep -v '^#' .env | xargs 2>/dev/null || true)
# 启动参数
API_HOST=${API_HOST:-0.0.0.0}
API_PORT=${API_PORT:-8000}
FRONTEND_PORT=${FRONTEND_PORT:-3000}
echo ""
# ===== 启动API服务 =====
echo -e "${YELLOW}[1/2] 启动API服务...${NC}"
# 检查是否已运行
if [ -f "$PID_FILE_API" ]; then
OLD_PID=$(cat $PID_FILE_API)
if ps -p $OLD_PID > /dev/null 2>&1; then
echo -e "${YELLOW}API服务已在运行 (PID: $OLD_PID)${NC}"
else
rm -f $PID_FILE_API
fi
fi
# 启动API后台模式
if [ ! -f "$PID_FILE_API" ]; then
nohup $VENV_DIR/bin/python -m uvicorn src.api.main:app \
--host $API_HOST --port $API_PORT \
> $LOG_FILE_API 2>&1 &
API_PID=$!
echo $API_PID > $PID_FILE_API
sleep 2
if ps -p $API_PID > /dev/null 2>&1; then
echo -e "${GREEN}✓ API服务启动成功 (PID: $API_PID)${NC}"
else
echo -e "${RED}✗ API服务启动失败${NC}"
echo "请查看日志: $LOG_FILE_API"
exit 1
fi
fi
echo ""
# ===== 启动前端服务 =====
echo -e "${YELLOW}[2/2] 启动前端服务...${NC}"
# 检查前端目录
FRONTEND_DIR="frontend"
if [ ! -d "$FRONTEND_DIR" ]; then
echo -e "${RED}错误: 前端目录不存在${NC}"
exit 1
fi
# 检查是否已运行
if [ -f "$PID_FILE_FRONTEND" ]; then
OLD_PID=$(cat $PID_FILE_FRONTEND)
if ps -p $OLD_PID > /dev/null 2>&1; then
echo -e "${YELLOW}前端服务已在运行 (PID: $OLD_PID)${NC}"
else
rm -f $PID_FILE_FRONTEND
fi
fi
# 启动前端(后台模式)
if [ ! -f "$PID_FILE_FRONTEND" ]; then
cd $FRONTEND_DIR
# 检查Node版本
NODE_VERSION=$(node -v 2>/dev/null || echo "v0")
echo -e "${CYAN}Node版本: $NODE_VERSION${NC}"
# 检查node_modules是否存在
if [ ! -d "node_modules" ]; then
echo -e "${YELLOW}首次启动,安装前端依赖...${NC}"
npm install
if [ $? -ne 0 ]; then
echo -e "${RED}✗ 前端依赖安装失败${NC}"
echo "请手动安装: cd frontend && npm install"
exit 1
fi
echo -e "${GREEN}✓ 前端依赖安装完成${NC}"
else
echo -e "${GREEN}✓ 前端依赖已存在${NC}"
# 检查vite是否已安装
if [ ! -d "node_modules/vite" ]; then
echo -e "${YELLOW}vite未安装重新安装依赖...${NC}"
npm install
if [ $? -ne 0 ]; then
echo -e "${RED}✗ 前端依赖安装失败${NC}"
exit 1
fi
fi
fi
# 使用 npx vite 启动(确保能找到 vite 命令)
echo -e "${CYAN}使用 Vite 开发模式 (端口: $FRONTEND_PORT)${NC}"
nohup npx vite --host 0.0.0.0 --port $FRONTEND_PORT > ../$LOG_DIR/frontend.log 2>&1 &
FRONTEND_PID=$!
cd ..
echo $FRONTEND_PID > $PID_FILE_FRONTEND
sleep 3
# 检查进程
if ps -p $FRONTEND_PID > /dev/null 2>&1; then
echo -e "${GREEN}✓ 前端服务启动成功 (PID: $FRONTEND_PID)${NC}"
else
# Vite 可能启动较慢,等待更长时间再检查
echo -e "${YELLOW}等待 Vite 启动完成...${NC}"
sleep 5
# 检查是否有 vite 进程
VITE_PID=$(pgrep -f "vite" 2>/dev/null || true)
if [ -n "$VITE_PID" ]; then
echo $VITE_PID > $PID_FILE_FRONTEND
echo -e "${GREEN}✓ Vite 已启动 (PID: $VITE_PID)${NC}"
else
echo -e "${RED}✗ 前端服务启动失败${NC}"
echo "请查看日志: $LOG_DIR/frontend.log"
echo ""
echo "常见问题排查:"
echo " 1. Node版本过低: 需要 Node 20+"
echo " 当前版本: $NODE_VERSION"
echo " 升级命令: curl -fsSL https://rpm.nodesource.com/setup_20.x | sudo bash - && sudo yum install -y nodejs"
echo ""
echo " 2. 查看详细错误:"
echo " cat $LOG_DIR/frontend.log"
exit 1
fi
fi
fi
echo ""
# ===== 输出访问地址 =====
echo -e "${CYAN}========================================${NC}"
echo -e "${CYAN} 服务已启动${NC}"
echo -e "${CYAN}========================================${NC}"
echo ""
echo -e "${GREEN}API服务:${NC}"
echo " 地址: http://localhost:$API_PORT"
echo " 文档: http://localhost:$API_PORT/docs"
echo " 健康检查: http://localhost:$API_PORT/health"
echo ""
echo -e "${GREEN}前端测试页面:${NC}"
echo " 地址: http://localhost:$FRONTEND_PORT"
echo " 模式: Vite 开发模式"
echo ""
echo -e "${GREEN}日志文件:${NC}"
echo " API: $LOG_FILE_API"
echo " 前端: $LOG_DIR/frontend.log"
echo ""
echo -e "${YELLOW}管理命令:${NC}"
echo " 查看状态: ./status.sh"
echo " 查看日志: tail -f $LOG_DIR/frontend.log"
echo " 停止服务: ./stop_all.sh"
echo " 重启服务: ./restart_all.sh"
echo ""

View File

@@ -1,41 +0,0 @@
#!/bin/bash
# start_api.sh - 启动迁移后的 backend API 服务
set -e
VENV_DIR=".venv"
BACKEND_PATH="$PWD/backend"
mkdir -p logs
echo "========================================"
echo "启动 AI+合规智能中枢 API 服务"
echo "========================================"
echo ""
if [ ! -d "$VENV_DIR" ]; then
echo "错误: 虚拟环境不存在,请先运行 ./quick_start.sh"
exit 1
fi
source "$VENV_DIR/bin/activate"
echo "已激活虚拟环境: $VENV_DIR"
echo ""
if [ ! -f ".env" ]; then
echo "警告: 根目录 .env 不存在,使用默认配置"
fi
HOST=${API_HOST:-0.0.0.0}
PORT=${API_PORT:-8000}
export PYTHONPATH="$BACKEND_PATH${PYTHONPATH:+:$PYTHONPATH}"
echo "API地址: http://$HOST:$PORT"
echo "API文档: http://$HOST:$PORT/docs"
echo "健康检查: http://$HOST:$PORT/health"
echo ""
echo "正在启动..."
echo ""
python -m uvicorn app.main:app --host "$HOST" --port "$PORT" --reload

View File

@@ -1,67 +0,0 @@
#!/bin/bash
# start_api_background.sh - 后台启动迁移后的 backend API 服务
set -e
VENV_DIR=".venv"
BACKEND_PATH="$PWD/backend"
mkdir -p logs
PID_FILE=logs/api.pid
LOG_FILE=logs/api.log
echo "========================================"
echo "后台启动 AI+合规智能中枢 API 服务"
echo "========================================"
echo ""
if [ ! -d "$VENV_DIR" ]; then
echo "错误: 虚拟环境不存在,请先运行 ./quick_start.sh"
exit 1
fi
if [ -f "$PID_FILE" ]; then
PID=$(cat "$PID_FILE")
if ps -p "$PID" > /dev/null 2>&1; then
echo "服务已在运行 (PID: $PID)"
echo "如需重启,请先运行: ./stop_api.sh"
exit 1
else
rm -f "$PID_FILE"
fi
fi
HOST=${API_HOST:-0.0.0.0}
PORT=${API_PORT:-8000}
echo "服务地址: http://$HOST:$PORT"
echo "日志文件: $LOG_FILE"
echo ""
echo "正在后台启动..."
PYTHONPATH="$BACKEND_PATH${PYTHONPATH:+:$PYTHONPATH}" \
nohup "$VENV_DIR/bin/python" -m uvicorn app.main:app --host "$HOST" --port "$PORT" > "$LOG_FILE" 2>&1 &
PID=$!
echo "$PID" > "$PID_FILE"
sleep 3
if ps -p "$PID" > /dev/null 2>&1; then
echo "服务启动成功 (PID: $PID)"
echo ""
echo "API地址: http://$HOST:$PORT"
echo "API文档: http://$HOST:$PORT/docs"
echo "健康检查: http://$HOST:$PORT/health"
echo ""
echo "查看日志:"
echo " tail -f $LOG_FILE"
echo ""
echo "停止服务:"
echo " ./stop_api.sh"
else
echo "服务启动失败,请查看日志: $LOG_FILE"
rm -f "$PID_FILE"
exit 1
fi

View File

@@ -1,47 +0,0 @@
#!/bin/bash
# start_frontend.sh - 启动前端测试页面静态服务
set -e
FRONTEND_DIR="frontend"
PORT=${FRONTEND_PORT:-3000}
echo "========================================"
echo "启动前端测试页面服务"
echo "========================================"
echo ""
# 检查前端目录
if [ ! -d "$FRONTEND_DIR" ]; then
echo "错误: 前端目录不存在: $FRONTEND_DIR"
exit 1
fi
# 检查index.html (React + T-Systems风格前端)
if [ ! -f "$FRONTEND_DIR/index.html" ]; then
echo "错误: 前端页面不存在: $FRONTEND_DIR/index.html"
exit 1
fi
echo "前端页面: index.html (React + T-Systems风格)"
echo "前端地址: http://localhost:$PORT"
echo ""
echo "确保API服务已启动:"
echo " ./start_api.sh"
echo ""
echo "正在启动前端服务..."
# 使用Python内置HTTP服务器
cd $FRONTEND_DIR
# 查找Python命令
if command -v python3 &> /dev/null; then
PYTHON_CMD=python3
elif command -v python &> /dev/null; then
PYTHON_CMD=python
else
echo "错误: 未找到Python"
exit 1
fi
$PYTHON_CMD -m http.server $PORT --bind 0.0.0.0

157
status.sh
View File

@@ -1,157 +0,0 @@
#!/bin/bash
# status.sh - 查看服务运行状态
# 支持: Vite开发模式 或 预构建静态服务
set -e
LOG_DIR="logs"
PID_FILE_API="$LOG_DIR/api.pid"
PID_FILE_FRONTEND="$LOG_DIR/frontend.pid"
# 颜色定义
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
NC='\033[0m'
# 加载配置
if [ -f ".env" ]; then
export $(grep -v '^#' .env | grep -E '^(API_PORT|FRONTEND_PORT|FRONTEND_MODE)' | xargs 2>/dev/null || true)
fi
API_PORT=${API_PORT:-8000}
FRONTEND_PORT=${FRONTEND_PORT:-5173}
FRONTEND_MODE=${FRONTEND_MODE:-dev}
echo ""
echo -e "${CYAN}========================================${NC}"
echo -e "${CYAN} AI+合规智能中枢 - 服务状态${NC}"
echo -e "${CYAN}========================================${NC}"
echo ""
# ===== API状态 =====
echo -e "${YELLOW}API服务:${NC}"
API_RUNNING=false
if [ -f "$PID_FILE_API" ]; then
PID=$(cat $PID_FILE_API)
if ps -p $PID > /dev/null 2>&1; then
API_RUNNING=true
echo -e " 状态: ${GREEN}运行中 ✓${NC}"
echo " PID: $PID"
# 检查端口
if command -v curl > /dev/null 2>&1; then
HEALTH=$(curl -s http://localhost:$API_PORT/health 2>/dev/null || echo '{"status":"error"}')
if echo "$HEALTH" | grep -q "healthy"; then
echo -e " 健康检查: ${GREEN}正常 ✓${NC}"
else
echo -e " 健康检查: ${RED}异常${NC}"
fi
fi
else
echo -e " 状态: ${RED}已停止${NC} (PID文件存在但进程不存在)"
rm -f $PID_FILE_API
fi
else
# 尝试查找uvicorn进程
UVICORN_PID=$(pgrep -f "uvicorn app.main:app" 2>/dev/null || true)
if [ -n "$UVICORN_PID" ]; then
echo -e " 状态: ${GREEN}运行中 ✓${NC} (无PID文件)"
echo " PID: $UVICORN_PID"
API_RUNNING=true
else
echo -e " 状态: ${RED}已停止${NC}"
fi
fi
echo " 地址: http://localhost:$API_PORT"
echo " 文档: http://localhost:$API_PORT/docs"
echo ""
# ===== 前端状态 =====
echo -e "${YELLOW}前端服务 (模式: $FRONTEND_MODE):${NC}"
FRONTEND_RUNNING=false
if [ -f "$PID_FILE_FRONTEND" ]; then
PID=$(cat $PID_FILE_FRONTEND)
if ps -p $PID > /dev/null 2>&1; then
FRONTEND_RUNNING=true
echo -e " 状态: ${GREEN}运行中 ✓${NC}"
echo " PID: $PID"
# 显示前端模式
if [ "$FRONTEND_MODE" = "dev" ]; then
echo -e " 模式: ${CYAN}Vite开发模式${NC}"
else
echo -e " 模式: ${CYAN}预构建静态服务${NC}"
fi
else
echo -e " 状态: ${RED}已停止${NC} (PID文件存在但进程不存在)"
rm -f $PID_FILE_FRONTEND
fi
else
# 查找Vite/npm进程
VITE_PID=$(pgrep -f "vite" 2>/dev/null || true)
HTTP_PID=$(pgrep -f "http.server $FRONTEND_PORT" 2>/dev/null || true)
if [ -n "$VITE_PID" ]; then
echo -e " 状态: ${GREEN}运行中 ✓${NC} (无PID文件)"
echo " PID: $VITE_PID (Vite)"
FRONTEND_RUNNING=true
elif [ -n "$HTTP_PID" ]; then
echo -e " 状态: ${GREEN}运行中 ✓${NC} (无PID文件)"
echo " PID: $HTTP_PID (静态)"
FRONTEND_RUNNING=true
else
echo -e " 状态: ${RED}已停止${NC}"
fi
fi
echo " 地址: http://localhost:$FRONTEND_PORT"
echo ""
# ===== Docker容器状态 =====
echo -e "${YELLOW}Docker服务:${NC}"
if command -v docker > /dev/null 2>&1; then
CONTAINERS="milvus minio redis postgres"
for container in $CONTAINERS; do
if docker ps --format '{{.Names}}' | grep -q "^${container}$"; then
echo -e " $container: ${GREEN}运行中 ✓${NC}"
elif docker ps -a --format '{{.Names}}' | grep -q "^${container}$"; then
echo -e " $container: ${RED}已停止${NC}"
else
echo -e " $container: ${YELLOW}不存在${NC}"
fi
done
else
echo -e " ${YELLOW}Docker未安装${NC}"
fi
echo ""
# ===== 总结 =====
echo -e "${CYAN}========================================${NC}"
if [ "$API_RUNNING" = true ] && [ "$FRONTEND_RUNNING" = true ]; then
echo -e "${GREEN} 所有服务正常运行 ✓${NC}"
else
if [ "$API_RUNNING" = false ]; then
echo -e "${RED} API服务未运行${NC}"
fi
if [ "$FRONTEND_RUNNING" = false ]; then
echo -e "${RED} 前端服务未运行${NC}"
fi
echo ""
echo -e "${YELLOW}启动服务: ./start_all.sh${NC}"
fi
echo -e "${CYAN}========================================${NC}"
echo ""

View File

@@ -1,160 +0,0 @@
#!/bin/bash
# stop_all.sh - 停止所有服务API + 前端)
# 支持停止 Vite 开发服务器
set -e
LOG_DIR="logs"
PID_FILE_API="$LOG_DIR/api.pid"
PID_FILE_FRONTEND="$LOG_DIR/frontend.pid"
# 颜色定义
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
NC='\033[0m'
# 加载配置
if [ -f ".env" ]; then
export $(grep -v '^#' .env | grep -E '^FRONTEND_PORT' | xargs 2>/dev/null || true)
fi
FRONTEND_PORT=${FRONTEND_PORT:-3000}
echo ""
echo -e "${CYAN}========================================${NC}"
echo -e "${CYAN} AI+合规智能中枢 - 停止服务${NC}"
echo -e "${CYAN}========================================${NC}"
echo ""
# ===== 停止API服务 =====
echo -e "${YELLOW}[1/2] 停止API服务...${NC}"
if [ -f "$PID_FILE_API" ]; then
PID=$(cat $PID_FILE_API)
if ps -p $PID > /dev/null 2>&1; then
echo "正在停止API服务 (PID: $PID)..."
kill $PID
sleep 2
if ps -p $PID > /dev/null 2>&1; then
echo "进程未响应,强制终止..."
kill -9 $PID
fi
rm -f $PID_FILE_API
echo -e "${GREEN}✓ API服务已停止${NC}"
else
echo "进程已不存在清理PID文件"
rm -f $PID_FILE_API
fi
else
echo -e "${YELLOW}API PID文件不存在${NC}"
# 尝试查找并停止所有uvicorn进程
UVICORN_PIDS=$(pgrep -f "uvicorn src.api.main" 2>/dev/null || true)
if [ -n "$UVICORN_PIDS" ]; then
echo "发现运行中的uvicorn进程: $UVICORN_PIDS"
kill $UVICORN_PIDS 2>/dev/null || true
echo -e "${GREEN}✓ 已停止uvicorn进程${NC}"
else
echo -e "${YELLOW}未发现运行中的API服务${NC}"
fi
fi
echo ""
# ===== 停止前端服务 =====
echo -e "${YELLOW}[2/2] 停止前端服务...${NC}"
STOPPED=false
# 方法1: 通过PID文件停止
if [ -f "$PID_FILE_FRONTEND" ]; then
PID=$(cat $PID_FILE_FRONTEND)
if ps -p $PID > /dev/null 2>&1; then
echo "正在停止前端服务 (PID: $PID)..."
kill $PID
sleep 2
if ps -p $PID > /dev/null 2>&1; then
echo "进程未响应,强制终止..."
kill -9 $PID
fi
rm -f $PID_FILE_FRONTEND
echo -e "${GREEN}✓ 前端服务已停止${NC}"
STOPPED=true
else
echo "进程已不存在清理PID文件"
rm -f $PID_FILE_FRONTEND
fi
fi
# 方法2: 通过进程名查找并停止如果PID文件方式未成功
if [ "$STOPPED" = false ]; then
# 查找 Vite 进程
VITE_PIDS=$(pgrep -f "vite" 2>/dev/null || true)
if [ -n "$VITE_PIDS" ]; then
echo "发现运行中的Vite进程: $VITE_PIDS"
kill $VITE_PIDS 2>/dev/null || true
sleep 1
# 检查是否还有残留进程
VITE_REMAIN=$(pgrep -f "vite" 2>/dev/null || true)
if [ -n "$VITE_REMAIN" ]; then
echo "强制终止残留进程..."
kill -9 $VITE_REMAIN 2>/dev/null || true
fi
echo -e "${GREEN}✓ 已停止Vite进程${NC}"
STOPPED=true
fi
# 查找 npm/node 进程启动Vite的父进程
NPM_PIDS=$(pgrep -f "npm run dev" 2>/dev/null || true)
if [ -n "$NPM_PIDS" ]; then
echo "发现运行中的npm进程: $NPM_PIDS"
kill $NPM_PIDS 2>/dev/null || true
echo -e "${GREEN}✓ 已停止npm进程${NC}"
STOPPED=true
fi
# 查找 node 进程监听前端端口
NODE_PORT_PIDS=$(lsof -ti:$FRONTEND_PORT 2>/dev/null || true)
if [ -n "$NODE_PORT_PIDS" ]; then
echo "发现监听端口 $FRONTEND_PORT 的进程: $NODE_PORT_PIDS"
kill $NODE_PORT_PIDS 2>/dev/null || true
echo -e "${GREEN}✓ 已停止端口监听进程${NC}"
STOPPED=true
fi
# 查找 HTTP 服务器(备用)
HTTP_PIDS=$(pgrep -f "http.server $FRONTEND_PORT" 2>/dev/null || true)
if [ -n "$HTTP_PIDS" ]; then
echo "发现运行中的HTTP服务器: $HTTP_PIDS"
kill $HTTP_PIDS 2>/dev/null || true
echo -e "${GREEN}✓ 已停止HTTP服务${NC}"
STOPPED=true
fi
fi
if [ "$STOPPED" = false ]; then
echo -e "${YELLOW}未发现运行中的前端服务${NC}"
fi
# 清理PID文件
rm -f $PID_FILE_FRONTEND
echo ""
echo -e "${CYAN}========================================${NC}"
echo -e "${GREEN} 所有服务已停止${NC}"
echo -e "${CYAN}========================================${NC}"
echo ""

View File

@@ -1,46 +0,0 @@
#!/bin/bash
# stop_api.sh - 停止API服务
PID_FILE=logs/api.pid
echo "========================================"
echo "停止 AI+合规智能中枢 API服务"
echo "========================================"
echo ""
if [ -f "$PID_FILE" ]; then
PID=$(cat $PID_FILE)
if ps -p $PID > /dev/null 2>&1; then
echo "正在停止服务 (PID: $PID)..."
kill $PID
# 等待进程结束
sleep 2
if ps -p $PID > /dev/null 2>&1; then
echo "进程未响应,强制终止..."
kill -9 $PID
fi
rm -f $PID_FILE
echo "服务已停止"
else
echo "进程已不存在清理PID文件"
rm -f $PID_FILE
fi
else
echo "PID文件不存在服务可能未运行"
# 尝试查找并停止所有uvicorn进程
UVICORN_PIDS=$(pgrep -f "uvicorn app.main:app")
if [ -n "$UVICORN_PIDS" ]; then
echo "发现运行中的uvicorn进程: $UVICORN_PIDS"
echo "是否停止这些进程? (y/n)"
read -r answer
if [ "$answer" = "y" ]; then
kill $UVICORN_PIDS
echo "进程已停止"
fi
fi
fi

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