diff --git a/.env b/.env index a18007d..dd75d5d 100644 --- a/.env +++ b/.env @@ -1,48 +1,75 @@ # 环境变量配置 - 已有数据库服务 +# AI+合规智能中枢 -# 应用配置 +# ===== 应用配置 ===== APP_NAME=AI+合规智能中枢 APP_VERSION=0.1.0 DEBUG=false -# Milvus向量数据库配置(已有) +# ===== Milvus向量数据库配置(已有)===== MILVUS_HOST=localhost MILVUS_PORT=19530 MILVUS_COLLECTION=regulations MILVUS_DB_NAME=default -# MinIO对象存储配置(已有) +# ===== MinIO对象存储配置(已有)===== MINIO_ENDPOINT=localhost:9000 MINIO_ACCESS_KEY=minioadmin MINIO_SECRET_KEY=minioadmin MINIO_BUCKET=compliance-docs MINIO_SECURE=false -# Redis配置(已有) +# ===== Redis配置(已有)===== REDIS_HOST=localhost REDIS_PORT=6379 REDIS_PASSWORD=redis@123 REDIS_DB=0 -# PostgreSQL配置(已有) +# ===== PostgreSQL配置(已有)===== POSTGRES_HOST=localhost POSTGRES_PORT=5432 POSTGRES_USER=postgresql POSTGRES_PASSWORD=postgresql123456 POSTGRES_DB=compliance_db -# 嵌入模型配置 +# ===== 嵌入模型配置 ===== EMBEDDING_MODEL=BAAI/bge-m3 EMBEDDING_DIM=1024 EMBEDDING_MAX_LENGTH=8192 EMBEDDING_BATCH_SIZE=12 EMBEDDING_USE_FP16=true -# 文档处理配置 +# ===== 文档处理配置 ===== CHUNK_SIZE=512 CHUNK_OVERLAP=50 MAX_FILE_SIZE_MB=100 -# API配置 +# ===== API配置 ===== API_HOST=0.0.0.0 -API_PORT=8000 \ No newline at end of file +API_PORT=8000 + +# ===== LLM配置 ===== +# LLM提供商选择: qwen / deepseek / qwen_vl +LLM_PROVIDER=deepseek +LLM_MODEL=deepseek-v4-flash +LLM_MAX_TOKENS=4096 +LLM_TEMPERATURE=0.7 + +# ===== Qwen API配置(阿里云DashScope)===== +# 获取API Key: https://dashscope.console.aliyun.com/ +QWEN_API_KEY=sk-fVr9KmDZNC4pGDBQj0EUWz9bDmFzNxjYC9EzZpe2bVDsxtz8 +QWEN_BASE_URL=http://6.86.80.4:30080/v1 +QWEN_MODEL=qwen3.5-plus +QWEN_VL_MODEL=qwen3-vl-plus + +# ===== DeepSeek API配置 ===== +# 获取API Key: https://platform.deepseek.com/ +DEEPSEEK_API_KEY=sk-fVr9KmDZNC4pGDBQj0EUWz9bDmFzNxjYC9EzZpe2bVDsxtz8 +DEEPSEEK_BASE_URL=http://6.86.80.4:30080/v1 +DEEPSEEK_MODEL=deepseek-v4-flash + +# ===== RAG配置 ===== +RAG_TOP_K=10 +RAG_MAX_CONTEXT_TOKENS=4000 +RAG_SUMMARY_MAX_TOKENS=1024 +RAG_SKILLS_MAX_TOKENS=2048 diff --git a/.env.example b/.env.example index ce9232c..25cc72a 100644 --- a/.env.example +++ b/.env.example @@ -1,31 +1,78 @@ # .env.example - 环境变量配置示例 +# AI+合规智能中枢 -# Milvus向量数据库配置 +# ===== 应用基础配置 ===== +APP_NAME=AI+合规智能中枢 +APP_VERSION=0.1.0 +DEBUG=false + +# ===== Milvus向量数据库配置 ===== MILVUS_HOST=localhost MILVUS_PORT=19530 MILVUS_COLLECTION=regulations +MILVUS_DB_NAME=default -# 嵌入模型配置 +# ===== 嵌入模型配置 ===== EMBEDDING_MODEL=BAAI/bge-m3 EMBEDDING_DIM=1024 +EMBEDDING_MAX_LENGTH=8192 +EMBEDDING_BATCH_SIZE=12 +EMBEDDING_USE_FP16=true -# MinIO对象存储配置 +# ===== MinIO对象存储配置 ===== MINIO_ENDPOINT=localhost:9000 MINIO_ACCESS_KEY=minioadmin MINIO_SECRET_KEY=minioadmin123 MINIO_BUCKET=compliance-docs +MINIO_SECURE=false -# Redis配置 +# ===== Redis配置 ===== REDIS_HOST=localhost REDIS_PORT=6379 +REDIS_PASSWORD= +REDIS_DB=0 -# PostgreSQL配置 +# ===== PostgreSQL配置 ===== POSTGRES_HOST=localhost POSTGRES_PORT=5432 POSTGRES_USER=compliance POSTGRES_PASSWORD=compliance123 POSTGRES_DB=compliance_db -# 文档处理配置 +# ===== 文档处理配置 ===== CHUNK_SIZE=512 -CHUNK_OVERLAP=50 \ No newline at end of file +CHUNK_OVERLAP=50 +MAX_FILE_SIZE_MB=100 + +# ===== API服务配置 ===== +API_HOST=0.0.0.0 +API_PORT=8000 + +# ===== LLM配置(必填)===== +# LLM提供商选择: qwen / deepseek / qwen_vl +LLM_PROVIDER=deepseek +LLM_MODEL=deepseek-v4-flash +LLM_MAX_TOKENS=4096 +LLM_TEMPERATURE=0.7 + +# ===== 统一API代理配置 ===== +# 使用new-api代理服务,支持多个LLM模型 +# 获取API Key: 向管理员申请 +QWEN_API_KEY=your_api_key_here +DEEPSEEK_API_KEY=your_api_key_here +QWEN_BASE_URL=http://6.86.80.4:30080/v1 +DEEPSEEK_BASE_URL=http://6.86.80.4:30080/v1 + +# ===== 可用模型 ===== +# Qwen系列: qwen3.5-plus, qwen3-plus, qwen-max, qwen-turbo, qwen-long +# Qwen VL系列: qwen3-vl-plus, qwen-vl-max +# DeepSeek系列: deepseek-v4-flash, deepseek-v3.2, deepseek-v3, deepseek-chat, deepseek-coder +QWEN_MODEL=qwen3.5-plus +QWEN_VL_MODEL=qwen3-vl-plus +DEEPSEEK_MODEL=deepseek-v4-flash + +# ===== RAG配置 ===== +RAG_TOP_K=10 +RAG_MAX_CONTEXT_TOKENS=4000 +RAG_SUMMARY_MAX_TOKENS=1024 +RAG_SKILLS_MAX_TOKENS=2048 diff --git a/QUICK_DEPLOY.md b/QUICK_DEPLOY.md new file mode 100644 index 0000000..3faf51d --- /dev/null +++ b/QUICK_DEPLOY.md @@ -0,0 +1,422 @@ +# AI+合规智能中枢 - 快速部署指南 + +## 系统要求 + +- Python 3.10+ +- Docker & Docker Compose +- 8GB+ 内存(推荐16GB) +- 20GB+ 磁盘空间 + +--- + +## 一、环境准备 + +### 1. 克隆项目 + +```bash +git clone +cd Demo-glm +``` + +### 2. 配置环境变量 + +```bash +# 复制配置模板 +cp .env.example .env + +# 编辑配置文件,填入API密钥 +vim .env +``` + +**必填配置项**: + +```env +# LLM配置(使用统一API代理) +LLM_PROVIDER=qwen +LLM_MODEL=qwen3.5-plus + +# API密钥(通过 new-api.fletcher0516.online 代理) +QWEN_API_KEY=your_api_key_here +DEEPSEEK_API_KEY=your_api_key_here +QWEN_BASE_URL=https://new-api.fletcher0516.online/v1 +DEEPSEEK_BASE_URL=https://new-api.fletcher0516.online/v1 +``` + +--- + +## 二、启动基础设施 + +### 1. 启动Docker服务 + +```bash +cd docker +docker-compose up -d +``` + +等待服务启动完成(约30秒)。 + +### 2. 验证服务状态 + +```bash +docker ps +``` + +确认以下容器运行正常: +- `milvus` - 向量数据库 +- `minio` - 对象存储 +- `redis` - 缓存服务 +- `postgres` - 关系数据库 + +--- + +## 三、安装Python依赖 + +### 方式A:使用快速启动脚本(推荐) + +```bash +chmod +x quick_start.sh +./quick_start.sh +``` + +脚本自动完成: +- 创建虚拟环境 +- 安装依赖(使用阿里云镜像) +- 检查各服务连接状态 + +### 方式B:手动安装 + +```bash +# 创建虚拟环境 +python3 -m venv .venv +source .venv/bin/activate + +# 安装依赖 +pip install -r requirements.txt +``` + +--- + +## 四、下载嵌入模型 + +BGE-M3模型约2GB,首次使用需下载。 + +### 方式A:自动下载(联网环境) + +首次启动API时自动下载到 `~/.cache/huggingface/` + +### 方式B:手动下载(离线环境) + +```bash +# 从ModelScope下载 +python -c "from modelscope import snapshot_download; snapshot_download('Xorbits/bge-m3', cache_dir='~/.cache/modelscope')" +``` + +--- + +## 五、启动服务 + +### 整合启动脚本(推荐) + +```bash +# 赋予脚本执行权限 +chmod +x start_all.sh stop_all.sh restart_all.sh status.sh + +# 启动所有服务(API + 前端) +./start_all.sh + +# 查看服务状态 +./status.sh + +# 重启所有服务 +./restart_all.sh + +# 停止所有服务 +./stop_all.sh +``` + +### 单独启动(可选) + +```bash +# 仅启动API服务(前台运行,可调试) +./start_api.sh + +# 仅启动API服务(后台运行) +./start_api_background.sh + +# 仅停止API服务 +./stop_api.sh + +# 仅启动前端服务 +./start_frontend.sh +``` + +--- + +## 六、服务访问地址 + +启动成功后访问: + +| 服务 | 地址 | +|------|------| +| **API服务** | http://localhost:8000 | +| **API文档** | http://localhost:8000/docs | +| **健康检查** | http://localhost:8000/health | +| **前端测试页面** | http://localhost:3000 | + +> 注意:前端测试页面通过 `http://localhost:3000` 访问,自动连接到API服务。 + +--- + +## 七、功能测试 + +### 1. 上传文档测试 + +```bash +curl -X POST http://localhost:8000/api/v1/documents/upload \ + -F "file=@test.pdf" \ + -F "doc_name=测试文档" +``` + +文档上传后会自动存储到MinIO对象存储(bucket: upload-files)。 + +### 2. 下载文档测试 + +```bash +# 下载已上传的文档 +curl -O http://localhost:8000/api/v1/documents/download/{doc_id} +``` + +### 3. 检索测试 + +```bash +curl -X POST http://localhost:8000/api/v1/knowledge/search \ + -H "Content-Type: application/json" \ + -d '{"query": "机动车安全", "top_k": 10}' +``` + +### 3. 智能问答测试 + +```bash +curl -X POST http://localhost:8000/api/v1/agent/ask \ + -H "Content-Type: application/json" \ + -d '{"query": "机动车安全技术检验有哪些要求?"}' +``` + +### 4. 多轮对话测试 + +```bash +curl -X POST http://localhost:8000/api/v1/agent/chat \ + -H "Content-Type: application/json" \ + -d '{"query": "什么是机动车安全技术检验?"}' + +# 返回 session_id,继续对话 +curl -X POST http://localhost:8000/api/v1/agent/chat \ + -H "Content-Type: application/json" \ + -d '{"query": "检验周期是多久?", "session_id": ""}' +``` + +--- + +## 八、脚本命令速查表 + +| 操作 | 命令 | +|------|------| +| **启动所有服务** | `./start_all.sh` | +| **停止所有服务** | `./stop_all.sh` | +| **重启所有服务** | `./restart_all.sh` | +| **查看服务状态** | `./status.sh` | +| 查看API日志 | `tail -f logs/api.log` | +| 查看前端日志 | `tail -f logs/frontend.log` | +| 环境初始化 | `./quick_start.sh` | +| 重启Docker | `cd docker && docker-compose restart` | +| 下载嵌入模型 | `./download_model.sh` | + +--- + +## 九、服务状态检查 + +运行状态检查脚本: + +```bash +./status.sh +``` + +输出示例: +``` +======================================== + AI+合规智能中枢 - 服务状态 +======================================== + +API服务: + 状态: 运行中 ✓ + PID: 12345 + 健康检查: 正常 ✓ + 地址: http://localhost:8000 + +前端服务: + 状态: 运行中 ✓ + PID: 12346 + 地址: http://localhost:3000 + +Docker服务: + milvus: 运行中 ✓ + minio: 运行中 ✓ + redis: 运行中 ✓ + postgres: 运行中 ✓ + +======================================== + 所有服务正常运行 ✓ +======================================== +``` + +--- + +## 十、常见问题 + +### Q1: Milvus连接失败 + +```bash +# 检查Milvus状态 +docker logs milvus + +# 重启Milvus +docker restart milvus + +# 等待30秒后再启动服务 +``` + +### Q2: 模型下载慢/失败 + +使用ModelScope镜像: + +```bash +export HF_ENDPOINT=https://hf-mirror.com +``` + +或手动下载: +```bash +python -c "from modelscope import snapshot_download; snapshot_download('Xorbits/bge-m3')" +``` + +### Q3: LLM调用失败 + +检查 `.env` 中API密钥配置: + +```bash +# 验证配置 +cat .env | grep API_KEY + +# 确保base_url正确 +cat .env | grep BASE_URL +``` + +### Q4: 端口被占用 + +修改 `.env` 中的端口配置: + +```env +API_PORT=8001 +FRONTEND_PORT=3001 +``` + +### Q5: 服务无法停止 + +强制清理: + +```bash +# 查找并停止所有相关进程 +pkill -f uvicorn +pkill -f http.server + +# 清理PID文件 +rm -f logs/*.pid +``` + +--- + +## 十一、目录结构 + +``` +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配置 +│ └── docker-compose.yml +├── logs/ # 运行日志 +│ ├── api.log +│ └── frontend.log +├── 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 # 单独启动前端 +└── QUICK_DEPLOY.md # 本文档 +``` + +--- + +## 十二、API接口清单 + +| 接口 | 路径 | 方法 | 功能 | +|------|------|------|------| +| 上传文档 | `/api/v1/documents/upload` | POST | 上传PDF/DOCX | +| 下载文档 | `/api/v1/documents/download/{doc_id}` | GET | 下载原文PDF/DOCX | +| 文档列表 | `/api/v1/documents/list` | GET | 列出已上传文档 | +| 检索知识 | `/api/v1/knowledge/search` | POST | 向量检索 | +| 单次问答 | `/api/v1/agent/ask` | POST | 智能问答 | +| 多轮对话 | `/api/v1/agent/chat` | POST | 会话对话 | +| 会话信息 | `/api/v1/agent/session/{id}` | GET | 获取会话 | +| 删除会话 | `/api/v1/agent/session/{id}` | DELETE | 删除会话 | +| Prompt模板 | `/api/v1/agent/templates` | GET | 模板列表 | +| 可用模型 | `/api/v1/agent/models` | GET | LLM模型列表 | + +--- + +## 十三、支持的LLM模型 + +通过统一API代理 `https://new-api.fletcher0516.online/v1` 支持: + +**Qwen系列**: +- `qwen3.5-plus` (推荐) +- `qwen3-plus` +- `qwen-max` +- `qwen-turbo` +- `qwen-long` + +**Qwen VL系列**(多模态): +- `qwen3-vl-plus` +- `qwen-vl-max` + +**DeepSeek系列**: +- `deepseek-v3.2` (推荐) +- `deepseek-v3` +- `deepseek-chat` +- `deepseek-coder` + +--- + +## 技术支持 + +- API文档:http://localhost:8000/docs +- 问题反馈:提交Issue到项目仓库 \ No newline at end of file diff --git a/backend/.env b/backend/.env new file mode 100644 index 0000000..ac894d9 --- /dev/null +++ b/backend/.env @@ -0,0 +1,56 @@ +APP_NAME=AI+合规智能中枢 +APP_VERSION=0.1.0 +DEBUG=false + +MILVUS_HOST=localhost +MILVUS_PORT=19530 +MILVUS_COLLECTION=regulations +MILVUS_DB_NAME=default + +MINIO_ENDPOINT=localhost:9000 +MINIO_ACCESS_KEY=minioadmin +MINIO_SECRET_KEY=minioadmin +MINIO_BUCKET=compliance-docs +MINIO_SECURE=false + +REDIS_HOST=localhost +REDIS_PORT=6379 +REDIS_PASSWORD=redis@123 +REDIS_DB=0 + +POSTGRES_HOST=localhost +POSTGRES_PORT=5432 +POSTGRES_USER=postgresql +POSTGRES_PASSWORD=postgresql123456 +POSTGRES_DB=compliance_db + +EMBEDDING_MODEL=BAAI/bge-m3 +EMBEDDING_DIM=1024 +EMBEDDING_MAX_LENGTH=8192 +EMBEDDING_BATCH_SIZE=12 +EMBEDDING_USE_FP16=true + +CHUNK_SIZE=512 +CHUNK_OVERLAP=50 +MAX_FILE_SIZE_MB=100 + +API_HOST=0.0.0.0 +API_PORT=8000 + +LLM_PROVIDER=deepseek +LLM_MODEL=deepseek-v4-flash +LLM_MAX_TOKENS=4096 +LLM_TEMPERATURE=0.7 + +QWEN_API_KEY=sk-fVr9KmDZNC4pGDBQj0EUWz9bDmFzNxjYC9EzZpe2bVDsxtz8 +QWEN_BASE_URL=http://6.86.80.4:30080/v1 +QWEN_MODEL=qwen3.5-plus +QWEN_VL_MODEL=qwen3-vl-plus + +DEEPSEEK_API_KEY=sk-fVr9KmDZNC4pGDBQj0EUWz9bDmFzNxjYC9EzZpe2bVDsxtz8 +DEEPSEEK_BASE_URL=http://6.86.80.4:30080/v1 +DEEPSEEK_MODEL=deepseek-v4-flash + +RAG_TOP_K=10 +RAG_MAX_CONTEXT_TOKENS=4000 +RAG_SUMMARY_MAX_TOKENS=1024 diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..d885b18 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,56 @@ +APP_NAME=AI+合规智能中枢 +APP_VERSION=0.1.0 +DEBUG=false + +MILVUS_HOST=localhost +MILVUS_PORT=19530 +MILVUS_COLLECTION=regulations +MILVUS_DB_NAME=default + +EMBEDDING_MODEL=BAAI/bge-m3 +EMBEDDING_DIM=1024 +EMBEDDING_MAX_LENGTH=8192 +EMBEDDING_BATCH_SIZE=12 +EMBEDDING_USE_FP16=true + +MINIO_ENDPOINT=localhost:9000 +MINIO_ACCESS_KEY=minioadmin +MINIO_SECRET_KEY=minioadmin123 +MINIO_BUCKET=compliance-docs +MINIO_SECURE=false + +REDIS_HOST=localhost +REDIS_PORT=6379 +REDIS_PASSWORD= +REDIS_DB=0 + +POSTGRES_HOST=localhost +POSTGRES_PORT=5432 +POSTGRES_USER=compliance +POSTGRES_PASSWORD=compliance123 +POSTGRES_DB=compliance_db + +CHUNK_SIZE=512 +CHUNK_OVERLAP=50 +MAX_FILE_SIZE_MB=100 + +API_HOST=0.0.0.0 +API_PORT=8000 + +LLM_PROVIDER=deepseek +LLM_MODEL=deepseek-v4-flash +LLM_MAX_TOKENS=4096 +LLM_TEMPERATURE=0.7 + +QWEN_API_KEY=your_api_key_here +DEEPSEEK_API_KEY=your_api_key_here +QWEN_BASE_URL=http://6.86.80.4:30080/v1 +DEEPSEEK_BASE_URL=http://6.86.80.4:30080/v1 + +QWEN_MODEL=qwen3.5-plus +QWEN_VL_MODEL=qwen3-vl-plus +DEEPSEEK_MODEL=deepseek-v4-flash + +RAG_TOP_K=10 +RAG_MAX_CONTEXT_TOKENS=4000 +RAG_SUMMARY_MAX_TOKENS=1024 diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..505a3b1 --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,10 @@ +# Python-generated files +__pycache__/ +*.py[oc] +build/ +dist/ +wheels/ +*.egg-info + +# Virtual environments +.venv diff --git a/backend/.python-version b/backend/.python-version new file mode 100644 index 0000000..bd28b9c --- /dev/null +++ b/backend/.python-version @@ -0,0 +1 @@ +3.9 diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..d41aaff --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,18 @@ +FROM python:3.11-slim + +WORKDIR /app + +# 安装依赖 +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# 复制代码 +COPY app/ ./app/ +COPY data/ ./data/ + +# 环境变量 +ENV API_HOST=0.0.0.0 +ENV API_PORT=8000 + +# 启动命令 +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..f523fd8 --- /dev/null +++ b/backend/README.md @@ -0,0 +1,50 @@ +# AI+合规智能中枢后端 + +`backend` 已承接原 `src` 的完整 FastAPI 后端能力,当前正式入口为 `app.main:app`。 + +## 启动 + +```bash +pip install -r backend/requirements.txt +PYTHONPATH=backend uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload +``` + +也可以直接使用根目录脚本: + +```bash +./start_api.sh +``` + +## 主要接口 + +- `GET /health` +- `GET /` +- `POST /api/v1/documents/upload` +- `GET /api/v1/documents/list` +- `GET /api/v1/documents/management-list` +- `GET /api/v1/documents/download/{doc_id}` +- `POST /api/v1/knowledge/search` +- `POST /api/v1/knowledge/retrieval` +- `POST /api/v1/agent/ask` +- `POST /api/v1/agent/chat` +- `GET /api/v1/agent/chat/stream` + +## 目录说明 + +```text +backend/ +├── app/ +│ ├── api/ # FastAPI 路由与模型 +│ ├── config/ # 配置与日志 +│ ├── services/ # 文档处理、LLM、RAG、存储 +│ └── workers/ # 任务相关代码 +├── .env.example +├── requirements.txt +└── main.py +``` + +## 说明 + +- `backend/app/api/main.py` 来自原 `src/api/main.py`,已切换为 `app.*` 导入。 +- 路由前缀保持为 `/api/v1`,以兼容当前前端。 +- 原 `backend/app/api/routes/docs.py`、`rag.py`、`compliance.py`、`status.py` 仍保留在仓库中,但不再作为主路由入口。 diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..f01a49c --- /dev/null +++ b/backend/app/__init__.py @@ -0,0 +1,3 @@ +from .main import app + +__all__ = ["app"] \ No newline at end of file diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 0000000..0f376a5 --- /dev/null +++ b/backend/app/api/__init__.py @@ -0,0 +1,2 @@ +# src/api/__init__.py +"""API接口模块""" \ No newline at end of file diff --git a/backend/app/api/main.py b/backend/app/api/main.py new file mode 100644 index 0000000..daa5d43 --- /dev/null +++ b/backend/app/api/main.py @@ -0,0 +1,99 @@ +"""FastAPI application entrypoint.""" + +from contextlib import asynccontextmanager + +from fastapi import FastAPI, Request +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse +from loguru import logger + +from app.api.models import ErrorResponse +from app.api.routes import api_router +from app.config.logging import setup_logging +from app.config.settings import settings +from app.services.llm.llm_factory import LLMFactory + +setup_logging(level="INFO" if not settings.debug else "DEBUG") + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """Application lifecycle hooks.""" + logger.info(f"启动 {settings.app_name} v{settings.app_version}") + logger.info(f"调试模式: {settings.debug}") + logger.info("预加载LLM客户端...") + LLMFactory.preload_clients(["qwen", "deepseek"]) + + yield + + logger.info("应用关闭,执行清理...") + LLMFactory.cleanup() + + +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", +) + +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): + """Global exception handler.""" + 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(): + """Health check endpoint.""" + return { + "status": "healthy", + "app": settings.app_name, + "version": settings.app_version, + } + + +@app.get("/", tags=["root"]) +async def root(): + """Root endpoint.""" + return { + "message": f"Welcome to {settings.app_name}", + "version": settings.app_version, + "docs": "/docs", + "health": "/health", + } + + +if __name__ == "__main__": + import uvicorn + + uvicorn.run( + "app.api.main:app", + host=settings.api_host, + port=settings.api_port, + reload=settings.debug, + log_level="info", + ) diff --git a/backend/app/api/models/__init__.py b/backend/app/api/models/__init__.py new file mode 100644 index 0000000..139463b --- /dev/null +++ b/backend/app/api/models/__init__.py @@ -0,0 +1,22 @@ +# src/api/models/__init__.py +"""API数据模型""" + +from .document import ( + DocumentUploadRequest, + DocumentUploadResponse, + SearchRequest, + SearchResultItem, + SearchResponse, + DocumentStatusResponse, + ErrorResponse +) + +__all__ = [ + "DocumentUploadRequest", + "DocumentUploadResponse", + "SearchRequest", + "SearchResultItem", + "SearchResponse", + "DocumentStatusResponse", + "ErrorResponse" +] \ No newline at end of file diff --git a/backend/app/api/models/document.py b/backend/app/api/models/document.py new file mode 100644 index 0000000..5e0d063 --- /dev/null +++ b/backend/app/api/models/document.py @@ -0,0 +1,63 @@ +# 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="时间戳") \ No newline at end of file diff --git a/backend/app/api/routes/__init__.py b/backend/app/api/routes/__init__.py new file mode 100644 index 0000000..d153465 --- /dev/null +++ b/backend/app/api/routes/__init__.py @@ -0,0 +1,17 @@ +# 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"] \ No newline at end of file diff --git a/backend/app/api/routes/agent.py b/backend/app/api/routes/agent.py new file mode 100644 index 0000000..8c4eb9e --- /dev/null +++ b/backend/app/api/routes/agent.py @@ -0,0 +1,449 @@ +# 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 app.services.agent.qa_agent import QAAgent, AgentConfig +from app.services.agent.session_manager import SessionManager +from app.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 app.services.rag.prompt_templates import PromptTemplates + + templates = PromptTemplates.list_templates() + return TemplateListResponse(templates=templates) + + +@router.get("/models") +async def list_available_models(): + """列出可用的LLM模型""" + from app.services.llm import LLMFactory + + factory = LLMFactory() + models = factory.list_available_providers() + return {"models": models} diff --git a/backend/app/api/routes/compliance.py b/backend/app/api/routes/compliance.py new file mode 100644 index 0000000..f9fa99d --- /dev/null +++ b/backend/app/api/routes/compliance.py @@ -0,0 +1,96 @@ +from fastapi import APIRouter, UploadFile, File, HTTPException +from sse_starlette.sse import EventSourceResponse +import uuid +import os +import json +import asyncio +from app.schemas.compliance import ( + AnalyzeResponse, + ComplianceChatRequest, +) +from app.services.mock_data import ( + generate_task_id, + get_mock_compliance_result, + get_mock_compliance_chat_response, +) + +router = APIRouter(prefix="/compliance", tags=["合规分析"]) + +# 临时存储分析任务 +tasks_store: dict[str, dict] = {} + + +@router.post("/analyze", response_model=AnalyzeResponse) +async def analyze_document(file: UploadFile = File(...)): + """上传设计方案进行分析""" + # 生成任务ID + task_id = generate_task_id() + + # 保存文件 + raw_dir = "/airegulation/demo-mao/backend/data/raw" + os.makedirs(raw_dir, exist_ok=True) + file_path = os.path.join(raw_dir, f"compliance_{task_id}_{file.filename}") + + content = await file.read() + with open(file_path, "wb") as f: + f.write(content) + + # 记录任务 + tasks_store[task_id] = { + "task_id": task_id, + "file_path": file_path, + "status": "processing", + "result": None, + } + + # 模拟异步处理完成(立即返回结果) + # 实际应用中这应该是后台任务 + tasks_store[task_id]["status"] = "completed" + tasks_store[task_id]["result"] = get_mock_compliance_result(task_id) + + return AnalyzeResponse(task_id=task_id) + + +@router.get("/result/{task_id}") +async def get_result(task_id: str): + """获取分析结果""" + if task_id not in tasks_store: + # 如果任务ID不存在,返回默认mock结果 + return get_mock_compliance_result(task_id) + + task = tasks_store[task_id] + + if task["status"] == "processing": + return {"status": "processing", "message": "分析进行中"} + + return task["result"] + + +@router.post("/chat/{segment_id}") +async def compliance_chat(segment_id: int, request: ComplianceChatRequest): + """针对段落进行合规对话""" + # 根据segment_id获取对应的intent + intent_map = { + 1: "车身结构设计", + 2: "动力系统配置", + 3: "安全配置设计", + } + intent = intent_map.get(segment_id, "车身结构设计") + + async def generate(): + # 获取预设响应 + response = get_mock_compliance_chat_response(intent, request.query) + + # 流式输出响应 + sentences = response.split("\n\n") + for sentence in sentences: + if sentence.strip(): + chunks = sentence.split("\n") + for chunk in chunks: + if chunk.strip(): + await asyncio.sleep(0.05) + yield {"event": "message", "data": json.dumps({"type": "chunk", "text": chunk + "\n"})} + + yield {"event": "message", "data": json.dumps({"type": "done"})} + + return EventSourceResponse(generate()) \ No newline at end of file diff --git a/backend/app/api/routes/docs.py b/backend/app/api/routes/docs.py new file mode 100644 index 0000000..4fda6a8 --- /dev/null +++ b/backend/app/api/routes/docs.py @@ -0,0 +1,115 @@ +from fastapi import APIRouter, UploadFile, File, HTTPException +import os +import uuid +from datetime import datetime +from app.schemas.doc import ( + DocumentUploadResponse, + DocumentListResponse, + DocumentInfo, + ParseResponse, + EmbedResponse, +) +from app.services.mock_data import get_mock_documents, generate_doc_id + +router = APIRouter(prefix="/docs", tags=["文档管理"]) + +# 临时存储文档信息(包含预设的mock文档) +documents_store: dict[str, dict] = {} + +# 初始化时加载mock文档 +for doc in get_mock_documents(): + documents_store[doc["id"]] = doc + + +@router.post("/upload", response_model=DocumentUploadResponse) +async def upload_document(file: UploadFile = File(...)): + """上传法规文档""" + # 检查文件格式 + allowed_ext = [".pdf", ".docx", ".doc", ".txt"] + ext = os.path.splitext(file.filename)[1].lower() + if ext not in allowed_ext: + raise HTTPException(400, f"Unsupported file format: {ext}") + + # 生成文档ID + doc_id = generate_doc_id() + + # 保存文件 + raw_dir = "/airegulation/demo-mao/backend/data/raw" + os.makedirs(raw_dir, exist_ok=True) + file_path = os.path.join(raw_dir, f"{doc_id}_{file.filename}") + + content = await file.read() + with open(file_path, "wb") as f: + f.write(content) + + # 记录文档信息 + documents_store[doc_id] = { + "id": doc_id, + "name": file.filename, + "path": file_path, + "size": len(content), + "status": "uploaded", + "chunks": 0, + "created_at": datetime.now(), + } + + return DocumentUploadResponse( + doc_id=doc_id, + filename=file.filename, + size=len(content), + ) + + +@router.get("/list", response_model=DocumentListResponse) +async def list_documents(): + """获取已索引文档列表""" + docs = [ + DocumentInfo( + id=d["id"], + name=d["name"], + chunks=d["chunks"], + status=d["status"], + created_at=d.get("created_at"), + ) + for d in documents_store.values() + ] + return DocumentListResponse(docs=docs) + + +@router.post("/parse/{doc_id}", response_model=ParseResponse) +async def parse_document(doc_id: str): + """解析文档并分块""" + if doc_id not in documents_store: + raise HTTPException(404, "Document not found") + + doc = documents_store[doc_id] + # 模拟解析逻辑 + doc["status"] = "parsed" + # 根据文件大小计算chunks数量 + file_size = doc.get("size", 100000) + doc["chunks"] = max(20, file_size // 8000) + + return ParseResponse(doc_id=doc_id, chunks=doc["chunks"]) + + +@router.post("/embed/{doc_id}", response_model=EmbedResponse) +async def embed_document(doc_id: str): + """嵌入并存入向量库""" + if doc_id not in documents_store: + raise HTTPException(404, "Document not found") + + doc = documents_store[doc_id] + # 模拟嵌入逻辑 + doc["status"] = "indexed" + + return EmbedResponse(doc_id=doc_id, vectors=doc["chunks"]) + + +@router.delete("/delete/{doc_id}") +async def delete_document(doc_id: str): + """删除文档""" + if doc_id not in documents_store: + raise HTTPException(404, "Document not found") + + del documents_store[doc_id] + return {"success": True} \ No newline at end of file diff --git a/backend/app/api/routes/documents.py b/backend/app/api/routes/documents.py new file mode 100644 index 0000000..14d49d7 --- /dev/null +++ b/backend/app/api/routes/documents.py @@ -0,0 +1,291 @@ +# 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 app.services.document_processor import DocumentProcessor +from app.services.storage.minio_client import MinIOClient +from app.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)} diff --git a/backend/app/api/routes/knowledge.py b/backend/app/api/routes/knowledge.py new file mode 100644 index 0000000..ef82245 --- /dev/null +++ b/backend/app/api/routes/knowledge.py @@ -0,0 +1,81 @@ +# src/api/routes/knowledge.py +"""知识库检索接口""" + +from fastapi import APIRouter, HTTPException +from loguru import logger + +from ..models import SearchRequest, SearchResponse, SearchResultItem, ErrorResponse +from app.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) diff --git a/backend/app/api/routes/rag.py b/backend/app/api/routes/rag.py new file mode 100644 index 0000000..a45626a --- /dev/null +++ b/backend/app/api/routes/rag.py @@ -0,0 +1,74 @@ +from fastapi import APIRouter +from sse_starlette.sse import EventSourceResponse +from app.schemas.rag import RagChatRequest, QuickQuestionsResponse, QuickQuestion +from app.services.mock_data import ( + get_mock_quick_questions, + get_mock_retrieval, + get_mock_rag_answer, +) +import json +import asyncio + +router = APIRouter(prefix="/rag", tags=["RAG问答"]) + + +@router.post("/chat") +async def rag_chat(request: RagChatRequest): + """SSE流式问答""" + + async def generate(): + # 发送检索开始事件 + yield {"event": "message", "data": json.dumps({"type": "retrieving"})} + + # 模拟检索延迟 + await asyncio.sleep(0.3) + + # 执行检索 + docs = get_mock_retrieval(request.query, top_k=request.top_k) + + retrieved_data = [ + { + "id": d["id"], + "score": d["score"], + "preview": d["preview"], + "doc_name": d.get("doc_name", ""), + "clause": d.get("clause", ""), + } + for d in docs + ] + yield {"event": "message", "data": json.dumps({"type": "retrieved", "docs": retrieved_data})} + + # 发送生成开始事件 + yield {"event": "message", "data": json.dumps({"type": "generating", "text": "正在生成答案..."})} + + # 模拟生成延迟 + await asyncio.sleep(0.2) + + # 获取预设答案 + answer = get_mock_rag_answer(request.query) + + # 流式输出答案(按句子分割) + sentences = answer.split("\n\n") + for sentence in sentences: + if sentence.strip(): + # 进一步分割长句子 + chunks = sentence.split("\n") + for chunk in chunks: + if chunk.strip(): + await asyncio.sleep(0.05) # 模拟生成延迟 + yield {"event": "message", "data": json.dumps({"type": "chunk", "text": chunk + "\n"})} + + # 发送完成事件 + yield {"event": "message", "data": json.dumps({"type": "done"})} + + return EventSourceResponse(generate()) + + +@router.get("/quick-questions", response_model=QuickQuestionsResponse) +async def get_quick_questions(): + """获取预设快捷问题""" + questions = [ + QuickQuestion(id=q["id"], question=q["question"], category=q["category"]) + for q in get_mock_quick_questions() + ] + return QuickQuestionsResponse(questions=questions) \ No newline at end of file diff --git a/backend/app/api/routes/status.py b/backend/app/api/routes/status.py new file mode 100644 index 0000000..66b12e4 --- /dev/null +++ b/backend/app/api/routes/status.py @@ -0,0 +1,28 @@ +from fastapi import APIRouter +from app.core.config import settings +from app.services.mock_data import MOCK_SYSTEM_STATS, MOCK_SYSTEM_CONFIG + +router = APIRouter(prefix="/status", tags=["系统状态"]) + + +@router.get("/stats") +async def get_stats(): + """获取系统统计""" + # 返回预设统计数据 + return MOCK_SYSTEM_STATS + + +@router.get("/config") +async def get_config(): + """获取当前配置""" + return MOCK_SYSTEM_CONFIG + + +@router.get("/milvus/health") +async def milvus_health(): + """Milvus健康检查""" + # 模拟连接状态(假数据模式下始终返回连接成功) + return { + "connected": True, + "collections": ["vehicle_regulations"], + } \ No newline at end of file diff --git a/backend/app/config/__init__.py b/backend/app/config/__init__.py new file mode 100644 index 0000000..9214127 --- /dev/null +++ b/backend/app/config/__init__.py @@ -0,0 +1,6 @@ +# src/config/__init__.py +"""配置模块""" + +from .settings import Settings, get_settings, settings + +__all__ = ["Settings", "get_settings", "settings"] diff --git a/backend/app/config/logging.py b/backend/app/config/logging.py new file mode 100644 index 0000000..a68820e --- /dev/null +++ b/backend/app/config/logging.py @@ -0,0 +1,32 @@ +# 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="{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {name}:{function}:{line} - {message}", + 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 \ No newline at end of file diff --git a/backend/app/config/settings.py b/backend/app/config/settings.py new file mode 100644 index 0000000..55ffdcd --- /dev/null +++ b/backend/app/config/settings.py @@ -0,0 +1,95 @@ +# 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() diff --git a/backend/app/core/__init__.py b/backend/app/core/__init__.py new file mode 100644 index 0000000..e849b30 --- /dev/null +++ b/backend/app/core/__init__.py @@ -0,0 +1,3 @@ +from .config import settings, Settings + +__all__ = ["settings", "Settings"] \ No newline at end of file diff --git a/backend/app/core/config.py b/backend/app/core/config.py new file mode 100644 index 0000000..099a31d --- /dev/null +++ b/backend/app/core/config.py @@ -0,0 +1,41 @@ +from pydantic_settings import BaseSettings +from typing import Optional + + +class Settings(BaseSettings): + # DashScope API + dashscope_api_key: str = "" + + # Milvus + milvus_host: str = "localhost" + milvus_port: int = 19530 + + # LLM配置 + llm_model: str = "qwen-max" + embedding_model: str = "text-embedding-v3" + embedding_dim: int = 1536 + + # 检索配置 + vector_top_k: int = 10 + bm25_top_k: int = 10 + final_top_k: int = 5 + + # 分块配置 + chunk_size: int = 800 + chunk_overlap: int = 50 + + # 服务配置 + api_host: str = "0.0.0.0" + api_port: int = 8000 + + # Collection名称 + regulations_collection: str = "vehicle_regulations" + compliance_collection: str = "compliance_cache" + + class Config: + env_file = ".env" + env_file_encoding = "utf-8" + case_sensitive = False + + +settings = Settings() \ No newline at end of file diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..7616447 --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,5 @@ +"""Backend application entrypoint.""" + +from app.api.main import app + +__all__ = ["app"] diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py new file mode 100644 index 0000000..1ef3d7a --- /dev/null +++ b/backend/app/schemas/__init__.py @@ -0,0 +1,49 @@ +from .doc import ( + DocumentUploadResponse, + DocumentInfo, + DocumentListResponse, + ChunkInfo, + ParseResponse, + EmbedResponse, +) +from .rag import ( + RagChatRequest, + RetrievedDoc, + SourceInfo, + QuickQuestion, + QuickQuestionsResponse, +) +from .compliance import ( + RiskLevel, + ComplianceStatus, + Regulation, + ComplianceSegment, + RiskDashboard, + PriorityAction, + ComplianceResult, + ComplianceChatRequest, + AnalyzeResponse, +) + +__all__ = [ + "DocumentUploadResponse", + "DocumentInfo", + "DocumentListResponse", + "ChunkInfo", + "ParseResponse", + "EmbedResponse", + "RagChatRequest", + "RetrievedDoc", + "SourceInfo", + "QuickQuestion", + "QuickQuestionsResponse", + "RiskLevel", + "ComplianceStatus", + "Regulation", + "ComplianceSegment", + "RiskDashboard", + "PriorityAction", + "ComplianceResult", + "ComplianceChatRequest", + "AnalyzeResponse", +] \ No newline at end of file diff --git a/backend/app/schemas/compliance.py b/backend/app/schemas/compliance.py new file mode 100644 index 0000000..220f5c6 --- /dev/null +++ b/backend/app/schemas/compliance.py @@ -0,0 +1,69 @@ +from pydantic import BaseModel +from typing import Optional +from enum import Enum + + +class RiskLevel(str, Enum): + high = "high" + medium = "medium" + low = "low" + + +class ComplianceStatus(str, Enum): + pass_status = "pass" + warning = "warning" + fail = "fail" + + +class Regulation(BaseModel): + id: int + name: str + clause: Optional[str] = None + score: float + match_keyword: str + category: RiskLevel + full_content: str + + +class ComplianceSegment(BaseModel): + id: int + index: int + intent: str + start_pos: int + end_pos: int + content: str + risk_level: RiskLevel + regulations: list[Regulation] + + +class RiskDashboard(BaseModel): + score: float + high_risk_count: int + medium_risk_count: int + low_risk_count: int + need_fix_segments: int + status: ComplianceStatus + status_label: str + + +class PriorityAction(BaseModel): + regulation: str + issue: str + suggestion: str + severity: RiskLevel + + +class ComplianceResult(BaseModel): + task_id: str + dashboard: RiskDashboard + segments: list[ComplianceSegment] + priority_actions: list[PriorityAction] + + +class ComplianceChatRequest(BaseModel): + query: str + + +class AnalyzeResponse(BaseModel): + task_id: str + status: str = "processing" \ No newline at end of file diff --git a/backend/app/schemas/doc.py b/backend/app/schemas/doc.py new file mode 100644 index 0000000..5ff7cc1 --- /dev/null +++ b/backend/app/schemas/doc.py @@ -0,0 +1,44 @@ +from pydantic import BaseModel +from typing import Optional +from datetime import datetime + + +class DocumentUploadResponse(BaseModel): + doc_id: str + filename: str + size: int + status: str = "uploaded" + + +class DocumentInfo(BaseModel): + id: str + name: str + chunks: int + status: str + created_at: Optional[datetime] = None + + +class DocumentListResponse(BaseModel): + docs: list[DocumentInfo] + + +class ChunkInfo(BaseModel): + chunk_id: str + doc_name: str + clause_id: Optional[str] = None + chapter: Optional[str] = None + content: str + token_count: int + chunk_index: int + + +class ParseResponse(BaseModel): + doc_id: str + chunks: int + status: str = "parsed" + + +class EmbedResponse(BaseModel): + doc_id: str + vectors: int + status: str = "embedded" \ No newline at end of file diff --git a/backend/app/schemas/rag.py b/backend/app/schemas/rag.py new file mode 100644 index 0000000..9a92e5c --- /dev/null +++ b/backend/app/schemas/rag.py @@ -0,0 +1,31 @@ +from pydantic import BaseModel +from typing import Optional + + +class RagChatRequest(BaseModel): + query: str + top_k: int = 5 + + +class RetrievedDoc(BaseModel): + id: str + doc_name: str + clause_id: Optional[str] = None + score: float + content: str + preview: str + + +class SourceInfo(BaseModel): + name: str + clause: Optional[str] = None + + +class QuickQuestion(BaseModel): + id: str + question: str + category: str + + +class QuickQuestionsResponse(BaseModel): + questions: list[QuickQuestion] \ No newline at end of file diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..76e0e3a --- /dev/null +++ b/backend/app/services/__init__.py @@ -0,0 +1,3 @@ +"""Backend service package.""" + +__all__: list[str] = [] diff --git a/backend/app/services/agent/__init__.py b/backend/app/services/agent/__init__.py new file mode 100644 index 0000000..edcf417 --- /dev/null +++ b/backend/app/services/agent/__init__.py @@ -0,0 +1,7 @@ +# 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"] \ No newline at end of file diff --git a/backend/app/services/agent/qa_agent.py b/backend/app/services/agent/qa_agent.py new file mode 100644 index 0000000..50fc349 --- /dev/null +++ b/backend/app/services/agent/qa_agent.py @@ -0,0 +1,412 @@ +# 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 app.services.llm import get_llm_client, BaseLLMClient, LLMResponse +from app.services.llm.llm_factory import LLMFactory +from app.services.rag.retriever import Retriever, RetrievedDocument +from app.services.rag.context_builder import ContextBuilder, RAGContext +from app.services.rag.prompt_templates import get_prompt_template, PromptTemplate +from app.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 diff --git a/backend/app/services/agent/session_manager.py b/backend/app/services/agent/session_manager.py new file mode 100644 index 0000000..049ef0e --- /dev/null +++ b/backend/app/services/agent/session_manager.py @@ -0,0 +1,247 @@ +# 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("所有会话已清空") \ No newline at end of file diff --git a/backend/app/services/document_processor.py b/backend/app/services/document_processor.py new file mode 100644 index 0000000..afa1ee3 --- /dev/null +++ b/backend/app/services/document_processor.py @@ -0,0 +1,404 @@ +# 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 app.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 diff --git a/backend/app/services/embedding/__init__.py b/backend/app/services/embedding/__init__.py new file mode 100644 index 0000000..56c0451 --- /dev/null +++ b/backend/app/services/embedding/__init__.py @@ -0,0 +1,7 @@ +# src/services/embedding/__init__.py +"""嵌入和分块服务""" + +from .text_chunker import RegulationChunker +from .bge_m3_embedder import BGEM3Embedder + +__all__ = ["RegulationChunker", "BGEM3Embedder"] \ No newline at end of file diff --git a/backend/app/services/embedding/bge_m3_embedder.py b/backend/app/services/embedding/bge_m3_embedder.py new file mode 100644 index 0000000..73daa0e --- /dev/null +++ b/backend/app/services/embedding/bge_m3_embedder.py @@ -0,0 +1,296 @@ +# 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) \ No newline at end of file diff --git a/backend/app/services/embedding/text_chunker.py b/backend/app/services/embedding/text_chunker.py new file mode 100644 index 0000000..0240cde --- /dev/null +++ b/backend/app/services/embedding/text_chunker.py @@ -0,0 +1,449 @@ +# 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 + ) \ No newline at end of file diff --git a/backend/app/services/llm/__init__.py b/backend/app/services/llm/__init__.py new file mode 100644 index 0000000..68bdd72 --- /dev/null +++ b/backend/app/services/llm/__init__.py @@ -0,0 +1,15 @@ +# 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" +] \ No newline at end of file diff --git a/backend/app/services/llm/base_client.py b/backend/app/services/llm/base_client.py new file mode 100644 index 0000000..be5e138 --- /dev/null +++ b/backend/app/services/llm/base_client.py @@ -0,0 +1,116 @@ +# 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) \ No newline at end of file diff --git a/backend/app/services/llm/deepseek_client.py b/backend/app/services/llm/deepseek_client.py new file mode 100644 index 0000000..1599daa --- /dev/null +++ b/backend/app/services/llm/deepseek_client.py @@ -0,0 +1,130 @@ +# 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) diff --git a/backend/app/services/llm/document_summarizer.py b/backend/app/services/llm/document_summarizer.py new file mode 100644 index 0000000..5652abf --- /dev/null +++ b/backend/app/services/llm/document_summarizer.py @@ -0,0 +1,231 @@ +# src/services/llm/document_summarizer.py +"""文档摘要生成服务 - LLM生成法规文档摘要""" + +from typing import Dict, Optional +from dataclasses import dataclass +from loguru import logger + +from app.services.llm import get_llm_client, BaseLLMClient +from app.services.rag.prompt_templates import get_prompt_template +from app.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) diff --git a/backend/app/services/llm/llm_factory.py b/backend/app/services/llm/llm_factory.py new file mode 100644 index 0000000..6f07bff --- /dev/null +++ b/backend/app/services/llm/llm_factory.py @@ -0,0 +1,258 @@ +# 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) diff --git a/backend/app/services/llm/qwen_client.py b/backend/app/services/llm/qwen_client.py new file mode 100644 index 0000000..0714e6d --- /dev/null +++ b/backend/app/services/llm/qwen_client.py @@ -0,0 +1,392 @@ +# 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) diff --git a/backend/app/services/mock_data.py b/backend/app/services/mock_data.py new file mode 100644 index 0000000..d065d77 --- /dev/null +++ b/backend/app/services/mock_data.py @@ -0,0 +1,425 @@ +""" +Mock数据服务 - 提供预设假数据供前后端对接测试 +""" + +from datetime import datetime +from typing import Dict, List, Any +import uuid + +# 预设法规文档列表 +MOCK_DOCUMENTS: List[Dict[str, Any]] = [ + { + "id": "doc-001", + "name": "道路交通安全法.pdf", + "chunks": 156, + "status": "indexed", + "created_at": datetime(2026, 5, 10, 10, 0, 0), + }, + { + "id": "doc-002", + "name": "机动车登记规定.docx", + "chunks": 89, + "status": "indexed", + "created_at": datetime(2026, 5, 10, 11, 0, 0), + }, + { + "id": "doc-003", + "name": "电动自行车规范.pdf", + "chunks": 42, + "status": "indexed", + "created_at": datetime(2026, 5, 10, 12, 0, 0), + }, + { + "id": "doc-004", + "name": "GB 38031-2020 电动汽车安全要求.pdf", + "chunks": 128, + "status": "indexed", + "created_at": datetime(2026, 5, 10, 13, 0, 0), + }, + { + "id": "doc-005", + "name": "C-NCAP管理规则(2021版).pdf", + "chunks": 95, + "status": "indexed", + "created_at": datetime(2026, 5, 10, 14, 0, 0), + }, +] + +# 预设快捷问题 +MOCK_QUICK_QUESTIONS: List[Dict[str, str]] = [ + {"id": "q1", "question": "电动自行车需要上牌照吗?", "category": "车辆登记"}, + {"id": "q2", "question": "新能源汽车有哪些补贴政策?", "category": "新能源"}, + {"id": "q3", "question": "车辆年检的规定是什么?", "category": "年检"}, + {"id": "q4", "question": "驾驶证过期了怎么处理?", "category": "驾驶证"}, +] + +# 预设检索结果 +MOCK_RETRIEVAL_RESULTS: List[Dict[str, Any]] = [ + { + "id": "chunk-001", + "score": 0.95, + "preview": "根据《道路交通安全法》第十八条规定,电动自行车经公安机关交通管理部门登记后,方可上道路行驶...", + "doc_name": "道路交通安全法", + "clause": "第十八条", + "content": "根据《道路交通安全法》第十八条规定,电动自行车经公安机关交通管理部门登记后,方可上道路行驶。电动自行车应当符合国家标准,最高设计车速不超过二十五公里每小时,整车质量不超过五十五千克。", + }, + { + "id": "chunk-002", + "score": 0.88, + "preview": "电动自行车需符合GB17761-2018国家标准,包括最高车速、整车质量、脚踏骑行能力等要求...", + "doc_name": "电动自行车规范", + "clause": "第4条", + "content": "电动自行车需符合GB17761-2018国家标准。主要技术要求包括:最高设计车速不超过25km/h,整车质量不超过55kg,具有脚踏骑行能力,蓄电池标称电压不超过48V,电动机额定连续输出功率不超过400W。", + }, + { + "id": "chunk-003", + "score": 0.82, + "preview": "机动车登记规定:初次申领机动车号牌、行驶证的,机动车所有人应当向住所地的车辆管理所申请注册登记...", + "doc_name": "机动车登记规定", + "clause": "第5条", + "content": "机动车登记规定:初次申领机动车号牌、行驶证的,机动车所有人应当向住所地的车辆管理所申请注册登记。申请注册登记的,应当提交机动车所有人的身份证明、购车发票等机动车来历证明、机动车整车出厂合格证明或者进口机动车进口凭证。", + }, + { + "id": "chunk-004", + "score": 0.75, + "preview": "驾驶电动自行车上道路行驶,应当佩戴安全头盔,遵守道路交通安全法律法规...", + "doc_name": "道路交通安全法", + "clause": "第76条", + "content": "驾驶电动自行车上道路行驶,应当佩戴安全头盔,遵守道路交通安全法律法规。电动自行车不得逆向行驶,不得在机动车道内行驶,最高车速不得超过规定的限速。", + }, + { + "id": "chunk-005", + "score": 0.68, + "preview": "电动汽车动力电池安全要求:电池系统发生热失控后,应在5分钟内不起火不爆炸...", + "doc_name": "GB 38031-2020", + "clause": "第7条", + "content": "电动汽车动力电池安全要求(GB 38031-2020):电池系统发生热失控后,应在5分钟内不起火不爆炸,为乘员预留逃生时间。电池包需通过针刺、过充、短路等安全测试。", + }, +] + +# 预设RAG问答答案模板(按关键词匹配) +MOCK_RAG_ANSWERS: Dict[str, Dict[str, Any]] = { + "电动自行车": { + "text": "根据《道路交通安全法》及相关规范,电动自行车上路需满足以下条件:\n\n1. 符合国家标准 GB17761-2018\n2. 经公安机关交通管理部门登记\n3. 最高设计车速不超过 25km/h\n4. 整车质量不超过 55kg\n5. 具有脚踏骑行能力\n6. 蓄电池标称电压不超过 48V\n\n行驶时还需佩戴安全头盔,不得逆向行驶或在机动车道内行驶。", + "retrieval_ids": ["chunk-001", "chunk-002", "chunk-004"], + }, + "驾驶证": { + "text": "驾驶证申请流程如下:\n\n1. 到驾校报名并参加培训\n2. 通过科目一(理论考试)\n3. 通过科目二(场地驾驶技能考试)\n4. 通过科目三(道路驾驶技能考试)\n5. 通过科目四(安全文明驾驶常识考试)\n6. 领取驾驶证\n\n初次申领需到住所地车辆管理所申请注册登记。", + "retrieval_ids": ["chunk-003"], + }, + "超速": { + "text": "超速处罚标准(根据《道路交通安全法》):\n\n- 超速10%以下:警告\n- 超速10%-20%:罚款50-200元\n- 超速20%-50%:罚款200-500元,记3-6分\n- 超速50%以上:罚款500-2000元,记12分,可吊销驾驶证\n\n机动车驾驶人违反道路交通安全法律、法规将处警告或二十元以上二百元以下罚款。", + "retrieval_ids": ["chunk-001"], + }, + "年检": { + "text": "车辆年检规定:\n\n- 小型私家车:6年内免检(每2年申领标志),6-10年每2年检验,10年以上每年检验\n- 车辆需携带行驶证、交强险保单\n- 检验项目:灯光、制动、排放等\n\n机动车所有人的住所迁出车辆管理所管辖区域的,需在登记证书上签注变更事项。", + "retrieval_ids": ["chunk-003"], + }, + "电池": { + "text": "电动汽车电池安全标准(GB 38031-2020):\n\n1. 热失控要求:电池系统发生热失控后,应在5分钟内不起火不爆炸,为乘员预留逃生时间\n2. 电池包需通过针刺、过充、短路等安全测试\n3. 充电系统应具备过充保护功能,当电池SOC达到100%时应自动停止充电\n4. 充电接口应符合GB/T 18487.1标准要求\n\n以上要求确保电动汽车的整车安全性。", + "retrieval_ids": ["chunk-005"], + }, + "碰撞": { + "text": "正面碰撞测试要求(C-NCAP管理规则):\n\n1. 正面100%重叠刚性壁障碰撞试验\n2. 碰撞速度:50km/h\n3. 试验后要求:\n - 车门应能打开\n - 燃油系统无泄漏\n - 座椅及安全带功能正常\n\n此测试用于评估车辆在正面碰撞事故中对乘员的保护能力。", + "retrieval_ids": [], + }, + "AEB": { + "text": "AEB(自动紧急制动系统)测试标准:\n\n1. 系统应在检测到前方障碍物时主动减速或停车\n2. 测试场景分为三种:\n - 目标车静止\n - 目标车移动\n - 目标车制动\n3. AEB功能是C-NCAP评分的重要加分项\n\n该系统对提升车辆主动安全性能具有重要意义。", + "retrieval_ids": [], + }, + "高速公路": { + "text": "高速公路安全距离规定:\n\n1. 车速超过100km/h时,与同车道前车保持100米以上距离\n2. 车速低于100km/h时,距离可适当缩短\n3. 执行紧急任务的警车、消防车、救护车、工程救险车不受行驶速度限制\n\n保持安全距离是预防追尾事故的关键措施。", + "retrieval_ids": [], + }, +} + +# 预设合规分析结果 +MOCK_COMPLIANCE_RESULT: Dict[str, Any] = { + "task_id": "task-001", + "dashboard": { + "score": 78, + "high_risk_count": 2, + "medium_risk_count": 1, + "low_risk_count": 0, + "need_fix_segments": 3, + "status": "warning", + "status_label": "需优化", + }, + "segments": [ + { + "id": 1, + "index": 1, + "intent": "车身结构设计", + "start_pos": 45, + "end_pos": 230, + "content": "车身采用高强度钢铝混合结构,A柱和B柱使用热成型钢板,厚度2.5mm。车顶结构设计满足GB 26112-2010抗压强度要求,正面碰撞能量吸收区域采用渐进式变形设计,确保碰撞时能量有效分散。", + "risk_level": "high", + "regulations": [ + { + "id": 1, + "name": "GB 26112-2010", + "clause": "第4.2条", + "score": 0.95, + "match_keyword": "车顶抗压强度", + "category": "high", + "full_content": "车顶结构应能承受相当于车辆整备质量1.5倍的载荷,载荷分布应均匀,试验后车顶变形量不超过规定值。", + }, + { + "id": 2, + "name": "C-NCAP管理规则", + "clause": "第3.1条", + "score": 0.88, + "match_keyword": "正面碰撞", + "category": "high", + "full_content": "正面碰撞试验速度为50km/h,碰撞后车门应能打开,燃油系统无泄漏,座椅及安全带功能正常。", + }, + { + "id": 3, + "name": "GB 11551-2014", + "clause": "第5条", + "score": 0.72, + "match_keyword": "碰撞能量吸收", + "category": "medium", + "full_content": "车辆正面碰撞时应有效保护乘员,碰撞能量应通过车身结构合理分散。", + }, + { + "id": 4, + "name": "机动车安全技术条件", + "clause": "第12条", + "score": 0.58, + "match_keyword": "A柱强度", + "category": "medium", + "full_content": "A柱应具备足够的抗变形能力,材料强度应符合相关标准要求。", + }, + ], + }, + { + "id": 2, + "index": 2, + "intent": "动力系统配置", + "start_pos": 298, + "end_pos": 425, + "content": "搭载永磁同步电机,最大功率150kW,峰值扭矩310Nm。电池组采用三元锂离子电池,容量75kWh,能量密度180Wh/kg。充电接口支持快充(30分钟充至80%)和慢充(8小时充满),符合GB/T 18487.1-2015标准。", + "risk_level": "medium", + "regulations": [ + { + "id": 5, + "name": "GB/T 18487.1-2015", + "clause": "第6条", + "score": 0.94, + "match_keyword": "充电接口标准", + "category": "high", + "full_content": "电动汽车传导充电接口应符合GB/T 18487.1标准要求,充电系统应具备过充保护功能。", + }, + { + "id": 6, + "name": "GB/T 31484-2015", + "clause": "第4条", + "score": 0.85, + "match_keyword": "电池能量密度", + "category": "high", + "full_content": "动力电池能量密度不低于120Wh/kg,电池系统需通过热失控测试。", + }, + { + "id": 7, + "name": "新能源汽车生产企业准入", + "clause": "第8条", + "score": 0.65, + "match_keyword": "电机功率", + "category": "medium", + "full_content": "驱动电机应符合相关技术标准,功率参数应在规定范围内。", + }, + { + "id": 8, + "name": "电动汽车安全要求", + "clause": "第7条", + "score": 0.45, + "match_keyword": "充电时间", + "category": "low", + "full_content": "充电系统应具备过充保护功能,当电池SOC达到100%时应自动停止充电。", + }, + ], + }, + { + "id": 3, + "index": 3, + "intent": "安全配置设计", + "start_pos": 570, + "end_pos": 725, + "content": "配备6个安全气囊(前排双气囊、侧气囊、侧气帘),采用预紧式安全带。ABS系统采用博世第9代ESP,具备碰撞预警功能(FCW)和自动紧急制动(AEB)。方向盘集成驾驶员疲劳监测摄像头。", + "risk_level": "low", + "regulations": [ + { + "id": 9, + "name": "GB 27887-2011", + "clause": "第5条", + "score": 0.92, + "match_keyword": "安全气囊", + "category": "high", + "full_content": "乘用车应配备驾驶员和乘客安全气囊,气囊系统应符合相关技术标准。", + }, + { + "id": 10, + "name": "GB/T 26991-2011", + "clause": "第3条", + "score": 0.78, + "match_keyword": "ABS系统", + "category": "medium", + "full_content": "车辆应配备防抱死制动系统,系统性能应符合相关标准要求。", + }, + { + "id": 11, + "name": "C-NCAP管理规则", + "clause": "第4.2条", + "score": 0.71, + "match_keyword": "AEB自动制动", + "category": "medium", + "full_content": "主动安全配置评分包含AEB功能,AEB系统应能有效检测障碍物并主动减速。", + }, + { + "id": 12, + "name": "机动车运行安全技术条件", + "clause": "第15条", + "score": 0.38, + "match_keyword": "疲劳监测", + "category": "low", + "full_content": "建议配备驾驶员状态监测系统,及时发现驾驶员疲劳或分心状态。", + }, + ], + }, + ], + "priority_actions": [ + { + "regulation": "GB 26112-2010 第4.2条", + "issue": "缺少车顶抗压强度测试数据", + "suggestion": "补充车顶抗压强度具体测试数据,确保满足1.5倍整备质量载荷要求", + "severity": "high", + }, + { + "regulation": "GB/T 31484-2015 第4条", + "issue": "缺少电池热失控测试报告", + "suggestion": "补充电池热失控测试报告,验证5分钟内不起火不爆炸", + "severity": "high", + }, + { + "regulation": "C-NCAP管理规则 第3.1条", + "issue": "缺少碰撞后车门开启性能数据", + "suggestion": "提供碰撞后车门开启性能测试数据", + "severity": "medium", + }, + ], +} + +# 预设合规对话响应模板 +MOCK_COMPLIANCE_CHAT_RESPONSES: Dict[str, Dict[str, str]] = { + "车身结构设计": { + "compliance": "根据当前分析,车身结构设计部分存在以下合规问题:\n\n1. GB 26112-2010要求车顶承受1.5倍整备质量载荷,目前设计声明满足要求但缺少测试数据\n2. C-NCAP正面碰撞后车门应能打开,需提供碰撞测试报告\n\n建议补充相关测试数据以提升合规评分。", + "interpretation": "GB 26112-2010 第4.2条具体要求解读:\n\n车顶抗压强度测试是车辆被动安全的重要指标。该标准要求车顶结构能够承受相当于车辆整备质量1.5倍的均匀分布载荷,试验后车顶变形量不得超过规定限值。\n\n热成型钢板(22MnB5材料)抗拉强度约1500-1650 MPa,理论上能满足要求,但需通过实际测试验证。", + "suggestion": "针对车身结构设计的修改建议:\n\n1. 补充车顶抗压强度测试报告\n2. 提供A柱材料认证证书\n3. 完善正面碰撞能量吸收设计说明\n4. 添加碰撞后车门开启性能数据\n\n这些补充材料可有效提升合规评分。", + }, + "动力系统配置": { + "compliance": "动力系统配置整体合规性良好,主要检查点:\n\n1. 电池能量密度180Wh/kg超过最低要求120Wh/kg ✓\n2. 充电接口符合GB/T 18487.1标准 ✓\n3. 快充30分钟充至80%符合行业标准 ✓\n\n需补充电池热失控测试报告。", + "interpretation": "GB/T 31484-2015对动力电池的要求解读:\n\n1. 能量密度:不低于120Wh/kg(您的设计180Wh/kg满足要求)\n2. 循环寿命:不少于1000次循环后容量保持率≥80%\n3. 安全测试:需通过针刺、过充、短路等测试\n\n建议补充循环寿命测试数据。", + "suggestion": "动力系统配置改进建议:\n\n1. 补充电池热失控测试报告\n2. 提供循环寿命测试数据\n3. 添加充电系统过充保护功能说明\n4. 完善电池管理系统(BMS)技术文档", + }, + "安全配置设计": { + "compliance": "安全配置设计合规性评估:\n\n1. 安全气囊配置满足GB 27887-2011要求 ✓\n2. ABS/ESP系统符合标准 ✓\n3. AEB功能是C-NCAP加分项 ✓\n\n驾驶员疲劳监测是建议配置,不强制要求。", + "interpretation": "C-NCAP主动安全评分规则解读:\n\nAEB(自动紧急制动)系统是C-NCAP评分的重要加分项,最高可获得额外加分。测试场景包括:\n- 目标车静止场景\n- 目标车移动场景\n- 目标车制动场景\n\n建议完善AEB系统测试数据以获取更高评分。", + "suggestion": "安全配置优化建议:\n\n1. 提供AEB系统测试数据\n2. 补充FCW预警功能测试报告\n3. 添加安全气囊展开时间数据\n4. 完善驾驶员疲劳监测系统说明(如有)", + }, +} + +# 预设系统统计数据 +MOCK_SYSTEM_STATS: Dict[str, int] = { + "docs": 5, + "chunks": 510, + "vectors": 510, + "segments": 0, +} + +# 预设系统配置 +MOCK_SYSTEM_CONFIG: Dict[str, Any] = { + "llm": { + "model": "qwen-max", + }, + "embedding": { + "model": "text-embedding-v3", + "dimension": 1536, + }, + "milvus": { + "host": "localhost", + "port": 19530, + }, + "retrieval": { + "vector_top_k": 10, + "final_top_k": 5, + }, +} + + +def get_mock_documents() -> List[Dict[str, Any]]: + """获取预设法规文档列表""" + return MOCK_DOCUMENTS + + +def get_mock_quick_questions() -> List[Dict[str, str]]: + """获取预设快捷问题""" + return MOCK_QUICK_QUESTIONS + + +def get_mock_retrieval(query: str, top_k: int = 5) -> List[Dict[str, Any]]: + """根据查询关键词返回预设检索结果""" + results = [] + for keyword, data in MOCK_RAG_ANSWERS.items(): + if keyword in query: + for retrieval_id in data.get("retrieval_ids", []): + for item in MOCK_RETRIEVAL_RESULTS: + if item["id"] == retrieval_id: + results.append({ + "id": item["id"], + "score": item["score"], + "preview": item["preview"], + "doc_name": item["doc_name"], + "clause": item["clause"], + }) + break + if not results: + results = MOCK_RETRIEVAL_RESULTS[:top_k] + return results[:top_k] + + +def get_mock_rag_answer(query: str) -> str: + """根据查询关键词返回预设答案""" + for keyword, data in MOCK_RAG_ANSWERS.items(): + if keyword in query: + return data["text"] + return "抱歉,暂未找到与您问题直接相关的法规内容。请尝试更具体的问题,或联系交通管理部门获取详细信息。\n\n您可以尝试询问:电动自行车、驾驶证、超速处罚、年检、电池安全、碰撞测试、AEB系统、高速公路规则等话题。" + + +def get_mock_compliance_result(task_id: str) -> Dict[str, Any]: + """获取预设合规分析结果""" + result = MOCK_COMPLIANCE_RESULT.copy() + result["task_id"] = task_id + return result + + +def get_mock_compliance_chat_response(intent: str, query: str) -> str: + """获取预设合规对话响应""" + responses = MOCK_COMPLIANCE_CHAT_RESPONSES.get(intent, {}) + if "合规" in query or "符合" in query: + return responses.get("compliance", "根据相关法规分析,该段落的合规性需进一步评估。") + elif "解读" in query or "什么" in query or "如何" in query: + return responses.get("interpretation", "法规要求详细解读如下...") + elif "修改" in query or "建议" in query or "完善" in query: + return responses.get("suggestion", "建议进行以下修改以提升合规性...") + return f"关于您的问题,{intent}部分涉及多条相关法规。您可以进一步询问合规性评估或修改建议。" + + +def generate_task_id() -> str: + """生成任务ID""" + return f"task-{uuid.uuid4().hex[:8]}" + + +def generate_doc_id() -> str: + """生成文档ID""" + return f"doc-{uuid.uuid4().hex[:8]}" \ No newline at end of file diff --git a/backend/app/services/parser/__init__.py b/backend/app/services/parser/__init__.py new file mode 100644 index 0000000..5704664 --- /dev/null +++ b/backend/app/services/parser/__init__.py @@ -0,0 +1,7 @@ +# src/services/parser/__init__.py +"""文档解析服务""" + +from .pdf_parser import PDFParser +from .docx_parser import DocxParser + +__all__ = ["PDFParser", "DocxParser"] \ No newline at end of file diff --git a/backend/app/services/parser/docx_parser.py b/backend/app/services/parser/docx_parser.py new file mode 100644 index 0000000..405096f --- /dev/null +++ b/backend/app/services/parser/docx_parser.py @@ -0,0 +1,287 @@ +# 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) \ No newline at end of file diff --git a/backend/app/services/parser/mineru_parser.py b/backend/app/services/parser/mineru_parser.py new file mode 100644 index 0000000..e4baea2 --- /dev/null +++ b/backend/app/services/parser/mineru_parser.py @@ -0,0 +1,204 @@ +# 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: 扫描件PDF(OCR) + 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) \ No newline at end of file diff --git a/backend/app/services/parser/pdf_parser.py b/backend/app/services/parser/pdf_parser.py new file mode 100644 index 0000000..63f51a7 --- /dev/null +++ b/backend/app/services/parser/pdf_parser.py @@ -0,0 +1,268 @@ +# 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) \ No newline at end of file diff --git a/backend/app/services/rag/__init__.py b/backend/app/services/rag/__init__.py new file mode 100644 index 0000000..b88e4b7 --- /dev/null +++ b/backend/app/services/rag/__init__.py @@ -0,0 +1,12 @@ +# 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" +] \ No newline at end of file diff --git a/backend/app/services/rag/context_builder.py b/backend/app/services/rag/context_builder.py new file mode 100644 index 0000000..00f1c34 --- /dev/null +++ b/backend/app/services/rag/context_builder.py @@ -0,0 +1,230 @@ +# 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 app.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) diff --git a/backend/app/services/rag/prompt_templates.py b/backend/app/services/rag/prompt_templates.py new file mode 100644 index 0000000..73bab5d --- /dev/null +++ b/backend/app/services/rag/prompt_templates.py @@ -0,0 +1,296 @@ +# 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 \ No newline at end of file diff --git a/backend/app/services/rag/retriever.py b/backend/app/services/rag/retriever.py new file mode 100644 index 0000000..2c70a05 --- /dev/null +++ b/backend/app/services/rag/retriever.py @@ -0,0 +1,193 @@ +# src/services/rag/retriever.py +"""RAG检索服务 - 封装Milvus检索""" + +from typing import List, Dict, Optional, Any +from dataclasses import dataclass, field +from loguru import logger + +from app.services.embedding.bge_m3_embedder import BGEM3Embedder +from app.services.storage.milvus_client import MilvusClient, SearchResult +from app.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 diff --git a/backend/app/services/storage/__init__.py b/backend/app/services/storage/__init__.py new file mode 100644 index 0000000..fc784fa --- /dev/null +++ b/backend/app/services/storage/__init__.py @@ -0,0 +1,7 @@ +# src/services/storage/__init__.py +"""存储服务""" + +from .milvus_client import MilvusClient +from .minio_client import MinIOClient + +__all__ = ["MilvusClient", "MinIOClient"] \ No newline at end of file diff --git a/backend/app/services/storage/milvus_client.py b/backend/app/services/storage/milvus_client.py new file mode 100644 index 0000000..733d933 --- /dev/null +++ b/backend/app/services/storage/milvus_client.py @@ -0,0 +1,485 @@ +# 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 app.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) diff --git a/backend/app/services/storage/minio_client.py b/backend/app/services/storage/minio_client.py new file mode 100644 index 0000000..ed9489c --- /dev/null +++ b/backend/app/services/storage/minio_client.py @@ -0,0 +1,352 @@ +# 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 app.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 diff --git a/backend/app/utils/__init__.py b/backend/app/utils/__init__.py new file mode 100644 index 0000000..44884e4 --- /dev/null +++ b/backend/app/utils/__init__.py @@ -0,0 +1,4 @@ +from .chunking import TextChunker, chunker +from .logger import logger, setup_logging + +__all__ = ["TextChunker", "chunker", "logger", "setup_logging"] \ No newline at end of file diff --git a/backend/app/utils/chunking.py b/backend/app/utils/chunking.py new file mode 100644 index 0000000..6fe1d87 --- /dev/null +++ b/backend/app/utils/chunking.py @@ -0,0 +1,78 @@ +import re +from typing import List +from app.core.config import settings + + +class TextChunker: + def __init__( + self, + chunk_size: int = settings.chunk_size, + chunk_overlap: int = settings.chunk_overlap, + ): + self.chunk_size = chunk_size + self.chunk_overlap = chunk_overlap + + def chunk_by_clause(self, text: str) -> List[dict]: + """按条款边界分块(适用于法规文档)""" + clause_pattern = r"(第[一二三四五六七八九十百]+条)" + parts = re.split(clause_pattern, text) + + chunks = [] + current_clause = None + current_text = "" + chunk_index = 0 + + for part in parts: + if re.match(clause_pattern, part): + if current_clause and current_text.strip(): + chunks.append({ + "clause_id": current_clause, + "content": current_text.strip(), + "chunk_index": chunk_index, + }) + chunk_index += 1 + current_clause = part + current_text = "" + else: + current_text += part + + if current_clause and current_text.strip(): + chunks.append({ + "clause_id": current_clause, + "content": current_text.strip(), + "chunk_index": chunk_index, + }) + + return chunks + + def chunk_by_size(self, text: str) -> List[dict]: + """按固定大小分块""" + chunks = [] + start = 0 + chunk_index = 0 + + while start < len(text): + end = start + self.chunk_size + chunk_text = text[start:end] + + if chunk_text.strip(): + chunks.append({ + "content": chunk_text.strip(), + "chunk_index": chunk_index, + "start_pos": start, + "end_pos": end, + }) + chunk_index += 1 + + start = end - self.chunk_overlap + + return chunks + + def estimate_tokens(self, text: str) -> int: + """估算token数量""" + chinese_chars = len(re.findall(r"[^\x00-\xff]", text)) + english_chars = len(text) - chinese_chars + return int(chinese_chars / 1.5 + english_chars / 4) + + +chunker = TextChunker() \ No newline at end of file diff --git a/backend/app/utils/logger.py b/backend/app/utils/logger.py new file mode 100644 index 0000000..366ca57 --- /dev/null +++ b/backend/app/utils/logger.py @@ -0,0 +1,24 @@ +import logging +import sys + + +def setup_logging() -> logging.Logger: + """配置日志""" + logger = logging.getLogger("app") + logger.setLevel(logging.INFO) + + handler = logging.StreamHandler(sys.stdout) + handler.setLevel(logging.INFO) + + formatter = logging.Formatter( + fmt="%(asctime)s | %(levelname)s | %(name)s | %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + ) + handler.setFormatter(formatter) + + logger.addHandler(handler) + + return logger + + +logger = setup_logging() \ No newline at end of file diff --git a/backend/app/workers/__init__.py b/backend/app/workers/__init__.py new file mode 100644 index 0000000..3c737a9 --- /dev/null +++ b/backend/app/workers/__init__.py @@ -0,0 +1,2 @@ +# src/workers/__init__.py +"""异步任务Worker模块""" \ No newline at end of file diff --git a/backend/app/workflows/__init__.py b/backend/app/workflows/__init__.py new file mode 100644 index 0000000..107f16c --- /dev/null +++ b/backend/app/workflows/__init__.py @@ -0,0 +1,12 @@ +from .rag_workflow import RagState, rag_workflow, run_rag_workflow, stream_rag_workflow +from .compliance_workflow import ComplianceState, compliance_workflow, run_compliance_workflow + +__all__ = [ + "RagState", + "rag_workflow", + "run_rag_workflow", + "stream_rag_workflow", + "ComplianceState", + "compliance_workflow", + "run_compliance_workflow", +] \ No newline at end of file diff --git a/backend/app/workflows/compliance_workflow.py b/backend/app/workflows/compliance_workflow.py new file mode 100644 index 0000000..5cafc4a --- /dev/null +++ b/backend/app/workflows/compliance_workflow.py @@ -0,0 +1,175 @@ +from typing import TypedDict, List +from langgraph.graph import StateGraph, END + + +class ComplianceState(TypedDict): + document_path: str + raw_text: str + segments: List[dict] + matched_regulations: List[dict] + risk_dashboard: dict + priority_actions: List[dict] + + +def parse_document(state: ComplianceState) -> dict: + """解析文档""" + from app.services import get_document_service + doc_service = get_document_service( + "/airegulation/demo-mao/backend/data/raw", + "/airegulation/demo-mao/backend/data/parsed", + ) + text = doc_service.parse_document(state["document_path"]) + return {"raw_text": text} + + +def segment_document(state: ComplianceState) -> dict: + """AI语义分段""" + from app.services import llm_service + prompt = f"""请分析以下设计方案文档,按照设计意图将其分成若干语义段落。 + +文档内容: +{state['raw_text'][:3000]} + +请输出JSON格式的分段结果,每个段落包含: +- intent: 段落意图/主题 +- startPos: 在原文中的起始位置(大致) +- endPos: 在原文中的结束位置(大致) +- keywords: 关键词列表 + +输出格式: +[{{"intent": "...", "startPos": 0, "endPos": 100, "keywords": [...]}}]""" + + # 简化处理:返回基本分段 + segments = [ + { + "id": 1, + "intent": "整体设计概述", + "content": state["raw_text"][:500], + "keywords": ["设计", "方案"], + } + ] + + return {"segments": segments} + + +def match_regulations(state: ComplianceState) -> dict: + """法规匹配""" + from app.services import embedding_service, milvus_service + matched = [] + + for segment in state["segments"]: + keyword_text = " ".join(segment.get("keywords", [])) + embedding = embedding_service.embed_single(keyword_text) + + docs = milvus_service.search(embedding, top_k=5) + + segment_regs = [] + for doc in docs: + category = "high" if doc["score"] > 0.85 else ("medium" if doc["score"] > 0.6 else "low") + segment_regs.append({ + "id": doc["id"], + "name": doc["doc_name"], + "clause": doc.get("clause_id"), + "score": doc["score"], + "match_keyword": keyword_text, + "category": category, + "full_content": doc["content"], + }) + + segment["regulations"] = segment_regs + matched.append(segment) + + return {"matched_regulations": matched} + + +def calculate_risk(state: ComplianceState) -> dict: + """计算风险等级""" + segments = state["matched_regulations"] + + high_count = 0 + medium_count = 0 + low_count = 0 + need_fix = 0 + total_score = 0 + + for segment in segments: + regs = segment.get("regulations", []) + high_regs = [r for r in regs if r["category"] == "high"] + + if high_regs: + avg_score = sum(r["score"] for r in high_regs) / len(high_regs) + if avg_score < 0.9: + segment["risk_level"] = "high" + high_count += 1 + need_fix += 1 + elif avg_score < 0.92: + segment["risk_level"] = "medium" + medium_count += 1 + else: + segment["risk_level"] = "low" + low_count += 1 + else: + segment["risk_level"] = "low" + low_count += 1 + + total_score += avg_score if high_regs else 100 + + avg_score = total_score / len(segments) if segments else 100 + + status = "pass" if avg_score >= 90 else ("warning" if avg_score >= 70 else "fail") + status_label = "合规" if status == "pass" else ("需要修改" if status == "warning" else "高风险") + + dashboard = { + "score": avg_score, + "high_risk_count": high_count, + "medium_risk_count": medium_count, + "low_risk_count": low_count, + "need_fix_segments": need_fix, + "status": status, + "status_label": status_label, + } + + return {"risk_dashboard": dashboard, "segments": segments} + + +def generate_suggestions(state: ComplianceState) -> dict: + """生成优先建议""" + actions = [] + + for segment in state["segments"]: + for reg in segment.get("regulations", []): + if reg["category"] == "high" and reg["score"] < 0.9: + actions.append({ + "regulation": reg["name"], + "issue": reg["match_keyword"], + "suggestion": f"建议对照{reg['name']}第{reg.get('clause', '')}条进行修改", + "severity": "high", + }) + + return {"priority_actions": actions} + + +# 构建工作流图 +compliance_graph = StateGraph(ComplianceState) + +compliance_graph.add_node("parse", parse_document) +compliance_graph.add_node("segment", segment_document) +compliance_graph.add_node("match", match_regulations) +compliance_graph.add_node("score", calculate_risk) +compliance_graph.add_node("suggest", generate_suggestions) + +compliance_graph.set_entry_point("parse") +compliance_graph.add_edge("parse", "segment") +compliance_graph.add_edge("segment", "match") +compliance_graph.add_edge("match", "score") +compliance_graph.add_edge("score", "suggest") +compliance_graph.add_edge("suggest", END) + +compliance_workflow = compliance_graph.compile() + + +async def run_compliance_workflow(document_path: str) -> ComplianceState: + """运行合规分析工作流""" + initial_state: ComplianceState = {"document_path": document_path} + result = compliance_workflow.invoke(initial_state) + return result \ No newline at end of file diff --git a/backend/app/workflows/rag_workflow.py b/backend/app/workflows/rag_workflow.py new file mode 100644 index 0000000..370d2ce --- /dev/null +++ b/backend/app/workflows/rag_workflow.py @@ -0,0 +1,114 @@ +from typing import TypedDict, List +from langgraph.graph import StateGraph, END + + +class RagState(TypedDict): + query: str + query_embedding: List[float] + retrieved_docs: List[dict] + context: str + answer: str + sources: List[dict] + + +def embed_query(state: RagState) -> dict: + """将查询转为向量""" + from app.services import embedding_service + embedding = embedding_service.embed_single(state["query"]) + return {"query_embedding": embedding} + + +def retrieve_docs(state: RagState) -> dict: + """向量检索""" + from app.services import milvus_service + from app.core.config import settings + docs = milvus_service.search( + state["query_embedding"], + top_k=settings.vector_top_k, + ) + return {"retrieved_docs": docs[:settings.final_top_k]} + + +def build_context(state: RagState) -> dict: + """构建上下文""" + context_parts = [] + sources = [] + + for doc in state["retrieved_docs"]: + context_parts.append(f"【{doc['doc_name']} - {doc.get('clause_id', '')}】\n{doc['content']}") + sources.append({ + "name": doc["doc_name"], + "clause": doc.get("clause_id"), + }) + + context = "\n\n".join(context_parts) + return {"context": context, "sources": sources} + + +def generate_answer(state: RagState) -> dict: + """生成答案""" + from app.services import llm_service + prompt = f"""请根据以下法规内容回答用户问题,并在回答中标注引用的法规条款。 + +法规内容: +{state['context']} + +用户问题:{state['query']} + +请给出准确、简洁的回答,并引用相关法规条款。""" + + answer = "" + for chunk in llm_service.generate_stream(prompt): + answer += chunk + + return {"answer": answer} + + +# 构建工作流图 +rag_graph = StateGraph(RagState) + +rag_graph.add_node("embed", embed_query) +rag_graph.add_node("retrieve", retrieve_docs) +rag_graph.add_node("build_context", build_context) +rag_graph.add_node("generate", generate_answer) + +rag_graph.set_entry_point("embed") +rag_graph.add_edge("embed", "retrieve") +rag_graph.add_edge("retrieve", "build_context") +rag_graph.add_edge("build_context", "generate") +rag_graph.add_edge("generate", END) + +rag_workflow = rag_graph.compile() + + +async def run_rag_workflow(query: str) -> RagState: + """运行RAG工作流""" + initial_state: RagState = {"query": query} + result = rag_workflow.invoke(initial_state) + return result + + +def stream_rag_workflow(query: str): + """流式运行RAG工作流""" + from app.services import llm_service + + # 先完成检索阶段 + state: RagState = {"query": query} + state.update(embed_query(state)) + state.update(retrieve_docs(state)) + state.update(build_context(state)) + + # 流式生成阶段 + prompt = f"""请根据以下法规内容回答用户问题,并在回答中标注引用的法规条款。 + +法规内容: +{state['context']} + +用户问题:{state['query']} + +请给出准确、简洁的回答,并引用相关法规条款。""" + + for chunk in llm_service.generate_stream(prompt): + yield {"type": "chunk", "text": chunk} + + yield {"type": "done", "sources": state["sources"]} \ No newline at end of file diff --git a/backend/data/raw/compliance_task-32e64724_test_doc.txt b/backend/data/raw/compliance_task-32e64724_test_doc.txt new file mode 100644 index 0000000..d670460 --- /dev/null +++ b/backend/data/raw/compliance_task-32e64724_test_doc.txt @@ -0,0 +1 @@ +test content diff --git a/backend/data/raw/doc-3b47abd7_requirement.txt b/backend/data/raw/doc-3b47abd7_requirement.txt new file mode 100644 index 0000000..3881393 --- /dev/null +++ b/backend/data/raw/doc-3b47abd7_requirement.txt @@ -0,0 +1,2 @@ +apache-flink==1.13.2 +PyMySQL>=1.1.0 diff --git a/backend/data/raw/doc-9b01a78a_requirement.txt b/backend/data/raw/doc-9b01a78a_requirement.txt new file mode 100644 index 0000000..3881393 --- /dev/null +++ b/backend/data/raw/doc-9b01a78a_requirement.txt @@ -0,0 +1,2 @@ +apache-flink==1.13.2 +PyMySQL>=1.1.0 diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..79222b5 --- /dev/null +++ b/backend/main.py @@ -0,0 +1,19 @@ +"""Convenience launcher for the migrated backend app.""" + +import uvicorn + +from app.config.settings import settings + + +def main() -> None: + uvicorn.run( + "app.main:app", + host=settings.api_host, + port=settings.api_port, + reload=settings.debug, + log_level="info", + ) + + +if __name__ == "__main__": + main() diff --git a/backend/pyproject.toml b/backend/pyproject.toml new file mode 100644 index 0000000..aaaa1d6 --- /dev/null +++ b/backend/pyproject.toml @@ -0,0 +1,35 @@ +[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" diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..7171820 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,29 @@ +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 + +pytest>=7.4.0 +pytest-asyncio>=0.21.0 diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/uv.lock b/backend/uv.lock new file mode 100644 index 0000000..f069c7c --- /dev/null +++ b/backend/uv.lock @@ -0,0 +1,8 @@ +version = 1 +revision = 3 +requires-python = ">=3.9" + +[[package]] +name = "backend" +version = "0.1.0" +source = { virtual = "." } diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..f1ae178 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,140 @@ +# Regulation RAG - 法规合规智能分析系统 + +一个基于 RAG (Retrieval-Augmented Generation) 技术的法规合规智能分析与问答系统原型。支持文档上传、语义分段分析、法规匹配标注、风险评估及交互式合规问答。 + +## 功能特性 + +### 📋 合规分析 (Compliance) +- **文档上传与解析** - 支持 PDF、DOCX、TXT 格式文档上传 +- **AI 语义分段** - 自动识别文档语义段落与设计意图 +- **法规匹配标注** - 根据段落内容匹配相关法规条款,计算相关性得分 +- **风险仪表盘** - 可折叠的风险评估面板,展示合规评分、高风险项、待修改段落 +- **优先行动建议** - 基于风险等级生成修改建议列表 + +### 💬 RAG 对话 (Rag Chat) +- **法规问答** - 基于向量检索的法规问答系统 +- **快捷问题** - 预设常用问题快速提问 +- **检索片段展示** - 右侧面板实时显示引用的法规片段及相似度得分 +- **答案重生成** - 支持重新生成上一个回答 + +### 📚 文档管理 (Docs) +- **文档上传** - 支持多种格式文档导入 +- **索引状态** - 显示已索引文档列表及分块数量 +- **处理流水线** - 展示 Load → Parse → Chunk → Embed → Store 全流程状态 + +### 📊 系统状态 (Status) +- **系统统计** - 文档数、分块数、向量维度、条款数 +- **配置展示** - LLM 模型、Embedding 模型、向量数据库、检索策略等参数 + +## 技术栈 + +- **前端框架**: React 19 + TypeScript +- **构建工具**: Vite 8 +- **样式方案**: TailwindCSS 4 +- **状态管理**: React Context API + +## 快速开始 + +### 安装依赖 + +```bash +npm install +``` + +### 开发模式 + +```bash +npm run dev +``` + +启动本地开发服务器,默认访问 `http://localhost:5173` + +### 构建生产版本 + +```bash +npm run build +``` + +### 预览生产版本 + +```bash +npm run preview +``` + +## 项目结构 + +``` +src/ +├── components/ +│ ├── common/ # 通用组件 (Logo, Pattern, ThemeToggle) +│ ├── layout/ # 布局组件 (Header, Tabs, Content) +│ └── ui/ # UI 基础组件 (Badge, Button, Card, Input...) +├── contexts/ # React Context (AppContext, ThemeContext) +├── data/ # Mock 数据 +├── pages/ +│ ├── Compliance/ # 合规分析页面 +│ ├── Docs/ # 文档管理页面 +│ ├── RagChat/ # RAG 对话页面 +│ └── Status/ # 系统状态页面 +├── styles/ # 全局样式 +├── types/ # TypeScript 类型定义 +└── App.tsx # 应用入口 +``` + +## 核心类型定义 + +```typescript +// 法规信息 +interface Regulation { + id: number; + name: string; // 法规名称 + clause: string; // 条款编号 + score: number; // 相关性得分 (0-1) + matchKeyword: string; // 匹配关键词 + category: 'high' | 'medium' | 'low'; // 相关性等级 + fullContent: string; // 法规完整内容 +} + +// 语义段落 +interface ComplianceChunk { + id: number; + index: number; // 段落序号 + intent: string; // 段落意图 + startPos: number; // 文档起始位置 + endPos: number; // 文档结束位置 + content: string; // 段落内容 + regulations: Regulation[]; +} + +// 风险仪表盘数据 +interface RiskDashboardData { + score: number; // 合规评分 + highRiskCount: number; // 高风险项数量 + mediumRiskCount: number; + lowRiskCount: number; + needFixSegments: number;// 待修改段落数 + status: 'pass' | 'warning' | 'fail'; + statusLabel: string; // 状态标签 + segmentRisks: SegmentRisk[]; +} +``` + +## 界面设计 + +- **主题**: 支持深色/浅色主题切换 +- **配色**: T-Mobile 品牌色系 (E20074 主色调) +- **布局**: 响应式设计,固定 Header + Tab 导航 +- **交互**: 卡片式布局,悬浮效果,进度条动画 + +## 注意事项 + +本项目为原型演示系统,使用 Mock 数据模拟后端服务。生产环境需接入: + +- 文档解析服务 +- 向量数据库 (如 ChromaDB、Milvus) +- Embedding 模型 API +- LLM 服务 API + +## License + +MIT diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js new file mode 100644 index 0000000..ef614d2 --- /dev/null +++ b/frontend/eslint.config.js @@ -0,0 +1,22 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + ], + languageOptions: { + globals: globals.browser, + }, + }, +]) diff --git a/frontend/index.html b/frontend/index.html index b50ef1b..0c71734 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1,1150 +1,13 @@ - - - - - - AI+合规智能中枢 - 文档测试平台 - - - - - - -
-
- - -
- -
-
-
- API -
-
-
- MILVUS -
-
-
- - -
- -
-
文档上传测试
- -
-
- - - - -
-
拖拽文件到此处,或点击选择
-
支持上传法规文档进行智能解析
-
- .PDF - .DOCX - .DOC -
- -
- -
-
PDF
-
-
-
-
-
-
- -
- -
-
- - -
-
- - -
-
- - -
-
- - -
-
-
解析
-
-
-
-
分块
-
-
-
-
嵌入
-
-
-
-
入库
-
-
- -
- - -
- -
-
-
- - - -
-
处理成功
-
-
-
-
DOC ID
-
-
-
-
-
CHUNKS
-
-
-
-
-
STATUS
-
-
-
-
-
TIME
-
-
-
-
-
-
- - -
-
法规检索测试
- -
- - -
- -
-
-
- - - - -
-
请输入关键词检索法规内容
-
-
-
-
- - - - \ No newline at end of file + + + + + + + regulation-rag + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..3688a62 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,3150 @@ +{ + "name": "regulation-rag", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "regulation-rag", + "version": "0.0.0", + "dependencies": { + "react": "^19.2.5", + "react-dom": "^19.2.5" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@tailwindcss/postcss": "^4.2.4", + "@types/node": "^24.12.2", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "autoprefixer": "^10.5.0", + "eslint": "^10.2.1", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.2", + "globals": "^17.5.0", + "postcss": "^8.5.14", + "tailwindcss": "^4.2.4", + "typescript": "~6.0.2", + "typescript-eslint": "^8.58.2", + "vite": "^8.0.10" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.3.tgz", + "integrity": "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", + "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.5.tgz", + "integrity": "sha512-eIJYKTCECbP/nsKaaruF6LW967mtbQbsw4JTtSVkUQc9MneSkbrgPJAbKl9nWr0ZeowV8BfsarBmPpBzGelA2w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.1.tgz", + "integrity": "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz", + "integrity": "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz", + "integrity": "sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.17.tgz", + "integrity": "sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.17.tgz", + "integrity": "sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.17.tgz", + "integrity": "sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.17.tgz", + "integrity": "sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.17.tgz", + "integrity": "sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.17.tgz", + "integrity": "sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.17.tgz", + "integrity": "sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.17.tgz", + "integrity": "sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.17.tgz", + "integrity": "sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.17.tgz", + "integrity": "sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.7", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.7.tgz", + "integrity": "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tailwindcss/node": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.4.tgz", + "integrity": "sha512-Ai7+yQPxz3ddrDQzFfBKdHEVBg0w3Zl83jnjuwxnZOsnH9pGn93QHQtpU0p/8rYWxvbFZHneni6p1BSLK4DkGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.19.0", + "jiti": "^2.6.1", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.2.4" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.4.tgz", + "integrity": "sha512-9El/iI069DKDSXwTvB9J4BwdO5JhRrOweGaK25taBAvBXyXqJAX+Jqdvs8r8gKpsI/1m0LeJLyQYTf/WLrBT1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.2.4", + "@tailwindcss/oxide-darwin-arm64": "4.2.4", + "@tailwindcss/oxide-darwin-x64": "4.2.4", + "@tailwindcss/oxide-freebsd-x64": "4.2.4", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.4", + "@tailwindcss/oxide-linux-arm64-gnu": "4.2.4", + "@tailwindcss/oxide-linux-arm64-musl": "4.2.4", + "@tailwindcss/oxide-linux-x64-gnu": "4.2.4", + "@tailwindcss/oxide-linux-x64-musl": "4.2.4", + "@tailwindcss/oxide-wasm32-wasi": "4.2.4", + "@tailwindcss/oxide-win32-arm64-msvc": "4.2.4", + "@tailwindcss/oxide-win32-x64-msvc": "4.2.4" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.4.tgz", + "integrity": "sha512-e7MOr1SAn9U8KlZzPi1ZXGZHeC5anY36qjNwmZv9pOJ8E4Q6jmD1vyEHkQFmNOIN7twGPEMXRHmitN4zCMN03g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.4.tgz", + "integrity": "sha512-tSC/Kbqpz/5/o/C2sG7QvOxAKqyd10bq+ypZNf+9Fi2TvbVbv1zNpcEptcsU7DPROaSbVgUXmrzKhurFvo5eDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.4.tgz", + "integrity": "sha512-yPyUXn3yO/ufR6+Kzv0t4fCg2qNr90jxXc5QqBpjlPNd0NqyDXcmQb/6weunH/MEDXW5dhyEi+agTDiqa3WsGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.4.tgz", + "integrity": "sha512-BoMIB4vMQtZsXdGLVc2z+P9DbETkiopogfWZKbWwM8b/1Vinbs4YcUwo+kM/KeLkX3Ygrf4/PsRndKaYhS8Eiw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.4.tgz", + "integrity": "sha512-7pIHBLTHYRAlS7V22JNuTh33yLH4VElwKtB3bwchK/UaKUPpQ0lPQiOWcbm4V3WP2I6fNIJ23vABIvoy2izdwA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.4.tgz", + "integrity": "sha512-+E4wxJ0ZGOzSH325reXTWB48l42i93kQqMvDyz5gqfRzRZ7faNhnmvlV4EPGJU3QJM/3Ab5jhJ5pCRUsKn6OQw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.4.tgz", + "integrity": "sha512-bBADEGAbo4ASnppIziaQJelekCxdMaxisrk+fB7Thit72IBnALp9K6ffA2G4ruj90G9XRS2VQ6q2bCKbfFV82g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.4.tgz", + "integrity": "sha512-7Mx25E4WTfnht0TVRTyC00j3i0M+EeFe7wguMDTlX4mRxafznw0CA8WJkFjWYH5BlgELd1kSjuU2JiPnNZbJDA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.4.tgz", + "integrity": "sha512-2wwJRF7nyhOR0hhHoChc04xngV3iS+akccHTGtz965FwF0up4b2lOdo6kI1EbDaEXKgvcrFBYcYQQ/rrnWFVfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.4.tgz", + "integrity": "sha512-FQsqApeor8Fo6gUEklzmaa9994orJZZDBAlQpK2Mq+DslRKFJeD6AjHpBQ0kZFQohVr8o85PPh8eOy86VlSCmw==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.8.1", + "@emnapi/runtime": "^1.8.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.1", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.4.tgz", + "integrity": "sha512-L9BXqxC4ToVgwMFqj3pmZRqyHEztulpUJzCxUtLjobMCzTPsGt1Fa9enKbOpY2iIyVtaHNeNvAK8ERP/64sqGQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.4.tgz", + "integrity": "sha512-ESlKG0EpVJQwRjXDDa9rLvhEAh0mhP1sF7sap9dNZT0yyl9SAG6T7gdP09EH0vIv0UNTlo6jPWyujD6559fZvw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/postcss": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.2.4.tgz", + "integrity": "sha512-wgAVj6nUWAolAu8YFvzT2cTBIElWHkjZwFYovF+xsqKsW2ADxM/X2opxj5NsF/qVccAOjRNe8X2IdPzMsWyHTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "@tailwindcss/node": "4.2.4", + "@tailwindcss/oxide": "4.2.4", + "postcss": "^8.5.6", + "tailwindcss": "4.2.4" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.12.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz", + "integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.2.tgz", + "integrity": "sha512-j/bwmkBvHUtPNxzuWe5z6BEk3q54YRyGlBXkSsmfoih7zNrBvl5A9A98anlp/7JbyZcWIJ8KXo/3Tq/DjFLtuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.59.2", + "@typescript-eslint/type-utils": "8.59.2", + "@typescript-eslint/utils": "8.59.2", + "@typescript-eslint/visitor-keys": "8.59.2", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.59.2", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.2.tgz", + "integrity": "sha512-plR3pp6D+SSUn1HM7xvSkx12/DhoHInI2YF35KAcVFNZvlC0gtrWqx7Qq1oH2Ssgi0vlFRCTbP+DZc7B9+TtsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.59.2", + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/typescript-estree": "8.59.2", + "@typescript-eslint/visitor-keys": "8.59.2", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.2.tgz", + "integrity": "sha512-+2hqvEkeyf/0FBor67duF0Ll7Ot8jyKzDQOSrxazF/danillRq2DwR9dLptsXpoZQqxE1UisSmoZewrlPas9Vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.59.2", + "@typescript-eslint/types": "^8.59.2", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.2.tgz", + "integrity": "sha512-JzfyEpEtOU89CcFSwyNS3mu4MLvLSXqnmX05+aKBDM+TdR5jzcGOEBwxwGNxrEQ7p/z6kK2WyioCGBf2zZBnvg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/visitor-keys": "8.59.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.2.tgz", + "integrity": "sha512-BKK4alN7oi4C/zv4VqHQ+uRU+lTa6JGIZ7s1juw7b3RHo9OfKB+bKX3u0iVZetdsUCBBkSbdWbarJbmN0fTeSw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.2.tgz", + "integrity": "sha512-nhqaj1nmTdVVl/BP5omXNRGO38jn5iosis2vbdmupF2txCf8ylWT8lx+JlvMYYVqzGVKtjojUFoQ3JRWK+mfzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/typescript-estree": "8.59.2", + "@typescript-eslint/utils": "8.59.2", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.2.tgz", + "integrity": "sha512-e82GVOE8Ps3E++Egvb6Y3Dw0S10u8NkQ9KXmtRhCWJJ8kDhOJTvtMAWnFL16kB1583goCWXsr0NieKCZMs2/0Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.2.tgz", + "integrity": "sha512-o0XPGNwcWw+FIwStOWn+BwBuEmL6QXP0rsvAFg7ET1dey1Nr6Wb1ac8p5HEsK0ygO/6mUxlk+YWQD9xcb/nnXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.59.2", + "@typescript-eslint/tsconfig-utils": "8.59.2", + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/visitor-keys": "8.59.2", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.2.tgz", + "integrity": "sha512-Juw3EinkXqjaffxz6roowvV7GZT/kET5vSKKZT6upl5TXdWkLkYmNPXwDDL2Vkt2DPn0nODIS4egC/0AGxKo/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.59.2", + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/typescript-estree": "8.59.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.2.tgz", + "integrity": "sha512-NwjLUnGy8/Zfx23fl50tRC8rYaYnM52xNRYFAXvmiil9yh1+K6aRVQMnzW6gQB/1DLgWt977lYQn7C+wtgXZiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.2", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz", + "integrity": "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "1.0.0-rc.7" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/autoprefixer": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz", + "integrity": "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.2", + "caniuse-lite": "^1.0.30001787", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.27", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.27.tgz", + "integrity": "sha512-zEs/ufmZoUd7WftKpKyXaT6RFxpQ5Qm9xytKRHvJfxFV9DFJkZph9RvJ1LcOUi0Z1ZVijMte65JbILeV+8QQEA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001791", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001791.tgz", + "integrity": "sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.351", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.351.tgz", + "integrity": "sha512-9D7Iqx8RImSvCnOsj86rCH6eQjZFQoM04Jn6HnZVM0Nu/G58/gmKYQ1d12MZTbjQbQSTGI8nwEy07ErsA2slLA==", + "dev": true, + "license": "ISC" + }, + "node_modules/enhanced-resolve": { + "version": "5.21.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.0.tgz", + "integrity": "sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.3.0.tgz", + "integrity": "sha512-XbEXaRva5cF0ZQB8w6MluHA0kZZfV2DuCMJ3ozyEOHLwDpZX2Lmm/7Pp0xdJmI0GL1W05VH5VwIFHEm1Vcw2gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.5.5", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.4", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", + "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.2.tgz", + "integrity": "sha512-hmgTH57GfzoTFjVN0yBwTggnsVUF2tcqi7RJZHqi9lIezSs4eFyAMktA68YD4r5kNw1mxyY4dmkyoFDb3FIqrA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": "^9 || ^10" + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "17.6.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.6.0.tgz", + "integrity": "sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.38", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz", + "integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==", + "dev": true, + "license": "MIT" + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", + "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz", + "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.5" + } + }, + "node_modules/rolldown": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz", + "integrity": "sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.127.0", + "@rolldown/pluginutils": "1.0.0-rc.17" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-rc.17", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.17", + "@rolldown/binding-darwin-x64": "1.0.0-rc.17", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.17", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.17", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.17", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.17", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.17", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.17", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.17", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17" + } + }, + "node_modules/rolldown/node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.17.tgz", + "integrity": "sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==", + "dev": true, + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tailwindcss": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.4.tgz", + "integrity": "sha512-HhKppgO81FQof5m6TEnuBWCZGgfRAWbaeOaGT00KOy/Pf/j6oUihdvBpA7ltCeAvZpFhW3j0PTclkxsd4IXYDA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.2.tgz", + "integrity": "sha512-pJw051uomb3ZeCzGTpRb8RbEqB5Y4WWet8gl/GcTlU35BSx0PVdZ86/bqkQCyKKuraVQEK7r6kBHQXF+fBhkoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.59.2", + "@typescript-eslint/parser": "8.59.2", + "@typescript-eslint/typescript-estree": "8.59.2", + "@typescript-eslint/utils": "8.59.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vite": { + "version": "8.0.10", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz", + "integrity": "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.10", + "rolldown": "1.0.0-rc.17", + "tinyglobby": "^0.2.16" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..143005a --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,34 @@ +{ + "name": "regulation-rag", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "react": "^19.2.5", + "react-dom": "^19.2.5" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@tailwindcss/postcss": "^4.2.4", + "@types/node": "^24.12.2", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "autoprefixer": "^10.5.0", + "eslint": "^10.2.1", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.2", + "globals": "^17.5.0", + "postcss": "^8.5.14", + "tailwindcss": "^4.2.4", + "typescript": "~6.0.2", + "typescript-eslint": "^8.58.2", + "vite": "^8.0.10" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..af9d8dc --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + '@tailwindcss/postcss': {}, + autoprefixer: {}, + }, +} \ No newline at end of file diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..6893eb1 --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/icons.svg b/frontend/public/icons.svg new file mode 100644 index 0000000..e952219 --- /dev/null +++ b/frontend/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/logo/t_mobile_logo_transparent.png b/frontend/public/logo/t_mobile_logo_transparent.png new file mode 100644 index 0000000..21bc0b6 Binary files /dev/null and b/frontend/public/logo/t_mobile_logo_transparent.png differ diff --git a/frontend/src/App.css b/frontend/src/App.css new file mode 100644 index 0000000..f90339d --- /dev/null +++ b/frontend/src/App.css @@ -0,0 +1,184 @@ +.counter { + font-size: 16px; + padding: 5px 10px; + border-radius: 5px; + color: var(--accent); + background: var(--accent-bg); + border: 2px solid transparent; + transition: border-color 0.3s; + margin-bottom: 24px; + + &:hover { + border-color: var(--accent-border); + } + &:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + } +} + +.hero { + position: relative; + + .base, + .framework, + .vite { + inset-inline: 0; + margin: 0 auto; + } + + .base { + width: 170px; + position: relative; + z-index: 0; + } + + .framework, + .vite { + position: absolute; + } + + .framework { + z-index: 1; + top: 34px; + height: 28px; + transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg) + scale(1.4); + } + + .vite { + z-index: 0; + top: 107px; + height: 26px; + width: auto; + transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg) + scale(0.8); + } +} + +#center { + display: flex; + flex-direction: column; + gap: 25px; + place-content: center; + place-items: center; + flex-grow: 1; + + @media (max-width: 1024px) { + padding: 32px 20px 24px; + gap: 18px; + } +} + +#next-steps { + display: flex; + border-top: 1px solid var(--border); + text-align: left; + + & > div { + flex: 1 1 0; + padding: 32px; + @media (max-width: 1024px) { + padding: 24px 20px; + } + } + + .icon { + margin-bottom: 16px; + width: 22px; + height: 22px; + } + + @media (max-width: 1024px) { + flex-direction: column; + text-align: center; + } +} + +#docs { + border-right: 1px solid var(--border); + + @media (max-width: 1024px) { + border-right: none; + border-bottom: 1px solid var(--border); + } +} + +#next-steps ul { + list-style: none; + padding: 0; + display: flex; + gap: 8px; + margin: 32px 0 0; + + .logo { + height: 18px; + } + + a { + color: var(--text-h); + font-size: 16px; + border-radius: 6px; + background: var(--social-bg); + display: flex; + padding: 6px 12px; + align-items: center; + gap: 8px; + text-decoration: none; + transition: box-shadow 0.3s; + + &:hover { + box-shadow: var(--shadow); + } + .button-icon { + height: 18px; + width: 18px; + } + } + + @media (max-width: 1024px) { + margin-top: 20px; + flex-wrap: wrap; + justify-content: center; + + li { + flex: 1 1 calc(50% - 8px); + } + + a { + width: 100%; + justify-content: center; + box-sizing: border-box; + } + } +} + +#spacer { + height: 88px; + border-top: 1px solid var(--border); + @media (max-width: 1024px) { + height: 48px; + } +} + +.ticks { + position: relative; + width: 100%; + + &::before, + &::after { + content: ''; + position: absolute; + top: -4.5px; + border: 5px solid transparent; + } + + &::before { + left: 0; + border-left-color: var(--border); + } + &::after { + right: 0; + border-right-color: var(--border); + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..dbd890b --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,48 @@ +import './styles/globals.css'; +import { ThemeProvider, AppProvider, useApp, useTheme } from './contexts'; +import { Header, Tabs } from './components/layout'; +import { CompliancePage } from './pages/Compliance'; +import { DocsPage } from './pages/Docs'; +import { StatusPage } from './pages/Status'; +import { RagChatPage } from './pages/RagChat'; + +const PageContent = () => { + const { activeTab } = useApp(); + + switch (activeTab) { + case 'docs': + return ; + case 'compliance': + return ; + case 'status': + return ; + case 'rag': + return ; + default: + return ; + } +}; + +const AppContent = () => { + const { theme } = useTheme(); + + return ( +
+
+ + +
+ ); +}; + +function App() { + return ( + + + + + + ); +} + +export default App; \ No newline at end of file diff --git a/frontend/src/api/compliance.ts b/frontend/src/api/compliance.ts new file mode 100644 index 0000000..510c2b4 --- /dev/null +++ b/frontend/src/api/compliance.ts @@ -0,0 +1,43 @@ +import { streamSSE, type ComplianceResult, type SSEMessage } from './index'; + +// Upload and analyze a design document +export async function analyzeDocument(file: File): Promise<{ task_id: string; status: string }> { + const formData = new FormData(); + formData.append('file', file); + + const response = await fetch('/api/compliance/analyze', { + method: 'POST', + body: formData, + }); + + if (!response.ok) { + throw new Error(`Upload failed: ${response.status}`); + } + + return response.json(); +} + +// Get analysis result +export async function getComplianceResult(taskId: string): Promise { + const response = await fetch(`/api/compliance/result/${taskId}`); + + if (!response.ok) { + throw new Error(`Get result failed: ${response.status}`); + } + + return response.json(); +} + +// Compliance chat with SSE streaming +export function complianceChat( + segmentId: number, + query: string, + onMessage: (data: SSEMessage) => void, + onError?: (error: Error) => void, + onComplete?: () => void +): void { + streamSSE(`/compliance/chat/${segmentId}`, { query }, onMessage, onError, onComplete); +} + +// Export types +export type { ComplianceResult, SSEMessage }; \ No newline at end of file diff --git a/frontend/src/api/docs.ts b/frontend/src/api/docs.ts new file mode 100644 index 0000000..2a38795 --- /dev/null +++ b/frontend/src/api/docs.ts @@ -0,0 +1,144 @@ +import type { DocInfo, DocListResponse, DocUploadResponse } from './index'; + +const DOCS_API_BASE = '/api/v1'; + +interface BackendDocumentItem { + doc_id: string; + filename: string; + size: number; + object_name: string; + download_url: string; + last_modified?: string | null; +} + +interface BackendDocumentListResponse { + documents: BackendDocumentItem[]; + total: number; + limit?: number; +} + +interface BackendKnowledgeResult { + id: number; + content: string; + score: number; + metadata: Record; +} + +interface BackendKnowledgeResponse { + query: string; + total: number; + results: BackendKnowledgeResult[]; +} + +export interface RegulationSearchItem { + id: number; + file: string; + clause: string; + score: number; + content: string; + tags: string[]; +} + +export interface RegulationSearchResponse { + query: string; + total: number; + results: RegulationSearchItem[]; +} + +function formatFileSize(bytes: number): string { + if (!bytes) return '0 B'; + if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)}MB`; + if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)}KB`; + return `${bytes}B`; +} + +function mapDoc(item: BackendDocumentItem): DocInfo { + return { + id: item.doc_id, + name: item.filename, + chunks: 0, + status: 'indexed', + created_at: item.last_modified || undefined, + download_url: `${DOCS_API_BASE}/documents/download/${item.doc_id}`, + size_text: formatFileSize(item.size), + }; +} + +export async function uploadDocument(file: File): Promise { + const formData = new FormData(); + formData.append('file', file); + formData.append('doc_name', file.name); + formData.append('generate_summary', 'true'); + + const response = await fetch(`${DOCS_API_BASE}/documents/upload`, { + method: 'POST', + body: formData, + }); + + if (!response.ok) { + throw new Error(`Upload failed: ${response.status}`); + } + + const data = await response.json(); + return { + doc_id: data.doc_id, + filename: data.doc_name || file.name, + size: file.size, + status: data.status, + num_chunks: data.num_chunks, + summary: data.summary, + }; +} + +export async function getDocumentList(): Promise { + const response = await fetch(`${DOCS_API_BASE}/documents/management-list`); + if (!response.ok) { + throw new Error(`List failed: ${response.status}`); + } + + const data = await response.json() as BackendDocumentListResponse; + return { + docs: data.documents.map(mapDoc), + }; +} + +export async function searchRegulations(query: string, topK: number = 8): Promise { + const response = await fetch(`${DOCS_API_BASE}/knowledge/retrieval`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify({ query, top_k: topK }), + }); + + if (!response.ok) { + throw new Error(`Search failed: ${response.status}`); + } + + const data = await response.json() as BackendKnowledgeResponse; + return { + query: data.query, + total: data.total, + results: data.results.map((item) => { + const metadata = item.metadata || {}; + return { + id: item.id, + file: String(metadata.doc_name || metadata.filename || metadata.source || '法规知识库'), + clause: String(metadata.chunk_type || metadata.section || metadata.clause || '法规片段'), + score: item.score, + content: item.content, + tags: [ + metadata.regulation_type ? String(metadata.regulation_type) : '', + metadata.version ? `v${String(metadata.version)}` : '', + ].filter(Boolean), + }; + }), + }; +} + +export function getDocumentDownloadUrl(docId: string): string { + return `${DOCS_API_BASE}/documents/download/${docId}`; +} + +export type { DocInfo, DocListResponse, DocUploadResponse }; diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts new file mode 100644 index 0000000..df95082 --- /dev/null +++ b/frontend/src/api/index.ts @@ -0,0 +1,213 @@ +// API configuration - 使用相对路径,通过 Vite proxy 转发 +const API_BASE_URL = '/api'; + +// Helper function for fetch requests +async function fetchAPI(endpoint: string, options?: RequestInit): Promise { + const response = await fetch(`${API_BASE_URL}${endpoint}`, { + ...options, + headers: { + ...options?.headers, + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + throw new Error(`API Error: ${response.status} ${response.statusText}`); + } + + return response.json(); +} + +// SSE helper for streaming responses +function createSSEConnection(endpoint: string, body: unknown): EventSource { + // For POST requests with SSE, we need to use fetch with ReadableStream + // since EventSource only supports GET requests + const url = `${API_BASE_URL}${endpoint}`; + + return new EventSource(url); // This won't work for POST, we'll handle it differently +} + +// SSE streaming helper for POST requests +async function streamSSE( + endpoint: string, + body: unknown, + onMessage: (data: unknown) => void, + onError?: (error: Error) => void, + onComplete?: () => void +): Promise { + const url = `${API_BASE_URL}${endpoint}`; + + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'text/event-stream', + }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + if (onError) { + onError(new Error(`HTTP error! status: ${response.status}`)); + } + return; + } + + const reader = response.body?.getReader(); + if (!reader) { + if (onError) { + onError(new Error('No response body')); + } + return; + } + + const decoder = new TextDecoder(); + let buffer = ''; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + + // Process SSE events + const lines = buffer.split('\n'); + buffer = ''; + + for (const line of lines) { + if (line.startsWith('data:')) { + const data = line.slice(5).trim(); + if (data) { + try { + const parsed = JSON.parse(data); + onMessage(parsed); + } catch { + // Handle non-JSON data + onMessage({ type: 'raw', text: data }); + } + } + } + } + } + + if (onComplete) { + onComplete(); + } + } catch (error) { + if (onError) { + onError(error instanceof Error ? error : new Error(String(error))); + } + } +} + +// Export types +export interface DocInfo { + id: string; + name: string; + chunks: number; + status: string; + created_at?: string; + download_url?: string; + size_text?: string; +} + +export interface DocListResponse { + docs: DocInfo[]; +} + +export interface DocUploadResponse { + doc_id: string; + filename: string; + size: number; + status: string; + num_chunks?: number; + summary?: string; +} + +export interface QuickQuestion { + id: string; + question: string; + category: string; +} + +export interface QuickQuestionsResponse { + questions: QuickQuestion[]; +} + +export interface RetrievedDoc { + id: string; + score: number; + preview: string; + doc_name: string; + clause: string; + doc_id?: string; + download_url?: string; +} + +export interface SSEMessage { + type: string; + text?: string; + docs?: RetrievedDoc[]; +} + +export interface Regulation { + id: number; + name: string; + clause: string; + score: number; + match_keyword: string; + category: string; + full_content: string; +} + +export interface ComplianceSegment { + id: number; + index: number; + intent: string; + start_pos: number; + end_pos: number; + content: string; + risk_level: string; + regulations: Regulation[]; +} + +export interface RiskDashboard { + score: number; + high_risk_count: number; + medium_risk_count: number; + low_risk_count: number; + need_fix_segments: number; + status: string; + status_label: string; +} + +export interface PriorityAction { + regulation: string; + issue: string; + suggestion: string; + severity: string; +} + +export interface ComplianceResult { + task_id: string; + dashboard: RiskDashboard; + segments: ComplianceSegment[]; + priority_actions: PriorityAction[]; +} + +export interface SystemStats { + docs: number; + chunks: number; + vectors: number; + segments: number; +} + +export interface SystemConfig { + llm: { model: string }; + embedding: { model: string; dimension: number }; + milvus: { host: string; port: number }; + retrieval: { vector_top_k: number; final_top_k: number }; +} + +export { fetchAPI, streamSSE, API_BASE_URL }; diff --git a/frontend/src/api/rag.ts b/frontend/src/api/rag.ts new file mode 100644 index 0000000..09f2239 --- /dev/null +++ b/frontend/src/api/rag.ts @@ -0,0 +1,114 @@ +import type { QuickQuestionsResponse, SSEMessage } from './index'; + +const AGENT_API_BASE = '/api/v1'; + +export async function getQuickQuestions(): Promise { + return { + questions: [ + { id: '1', question: '请总结最新入库法规对电池安全的核心要求', category: '法规解读' }, + { id: '2', question: '我上传的制度文档与新能源法规有哪些潜在冲突?', category: '差距分析' }, + { id: '3', question: '请给出法规依据,并按条款列出整改建议', category: '整改建议' }, + { id: '4', question: '请解释 UN-ECE 与 GB 标准在网络安全方面的差异', category: '标准对比' }, + ], + }; +} + +function parseSSEChunk(raw: string, onMessage: (data: SSEMessage) => void) { + const blocks = raw.split('\n\n'); + for (const block of blocks) { + if (!block.trim()) continue; + + let eventName = 'message'; + const dataLines: string[] = []; + + for (const line of block.split('\n')) { + if (line.startsWith('event:')) { + eventName = line.slice(6).trim(); + } else if (line.startsWith('data:')) { + dataLines.push(line.slice(5).trim()); + } + } + + const joined = dataLines.join('\n'); + if (!joined) continue; + + if (eventName === 'sources') { + try { + const docs = JSON.parse(joined) as Array>; + onMessage({ + type: 'retrieved', + docs: docs.map((doc, index) => ({ + id: String(doc.doc_id || doc.index || index + 1), + score: Number(doc.score || 0), + preview: String(doc.content || doc.snippet || ''), + doc_name: String(doc.doc_name || doc.filename || `引用 ${index + 1}`), + clause: String(doc.clause_number || doc.section_title || '法规片段'), + doc_id: doc.doc_id ? String(doc.doc_id) : undefined, + download_url: doc.doc_id ? `${AGENT_API_BASE}/documents/download/${String(doc.doc_id)}` : undefined, + })), + }); + } catch { + // Ignore malformed source payloads. + } + } else if (eventName === 'content') { + onMessage({ type: 'chunk', text: joined }); + } else if (eventName === 'done') { + onMessage({ type: 'done', text: joined }); + } else if (eventName === 'error') { + onMessage({ type: 'error', text: joined }); + } else if (eventName === 'status') { + onMessage({ type: 'status', text: joined }); + } + } +} + +export async function ragChat( + query: string, + topK: number = 5, + onMessage: (data: SSEMessage) => void, + onError?: (error: Error) => void, + onComplete?: () => void +): Promise { + try { + const response = await fetch(`${AGENT_API_BASE}/agent/chat/stream`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'text/event-stream', + }, + body: JSON.stringify({ query, top_k: topK }), + }); + + if (!response.ok || !response.body) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const parts = buffer.split('\n\n'); + buffer = parts.pop() || ''; + parseSSEChunk(parts.join('\n\n'), onMessage); + } + + if (buffer.trim()) { + parseSSEChunk(buffer, onMessage); + } + + if (onComplete) { + onComplete(); + } + } catch (error) { + if (onError) { + onError(error instanceof Error ? error : new Error(String(error))); + } + } +} + +export type { QuickQuestionsResponse, SSEMessage }; diff --git a/frontend/src/api/status.ts b/frontend/src/api/status.ts new file mode 100644 index 0000000..81da0dc --- /dev/null +++ b/frontend/src/api/status.ts @@ -0,0 +1,19 @@ +import { fetchAPI, type SystemStats, type SystemConfig } from './index'; + +// Get system statistics +export async function getSystemStats(): Promise { + return fetchAPI('/status/stats'); +} + +// Get system configuration +export async function getSystemConfig(): Promise { + return fetchAPI('/status/config'); +} + +// Get Milvus health status +export async function getMilvusHealth(): Promise<{ connected: boolean; collections: string[] }> { + return fetchAPI('/status/milvus/health'); +} + +// Export types +export type { SystemStats, SystemConfig }; \ No newline at end of file diff --git a/frontend/src/assets/hero.png b/frontend/src/assets/hero.png new file mode 100644 index 0000000..02251f4 Binary files /dev/null and b/frontend/src/assets/hero.png differ diff --git a/frontend/src/assets/react.svg b/frontend/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/frontend/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/vite.svg b/frontend/src/assets/vite.svg new file mode 100644 index 0000000..5101b67 --- /dev/null +++ b/frontend/src/assets/vite.svg @@ -0,0 +1 @@ +Vite diff --git a/frontend/src/components/common/TLogo.tsx b/frontend/src/components/common/TLogo.tsx new file mode 100644 index 0000000..522a4ab --- /dev/null +++ b/frontend/src/components/common/TLogo.tsx @@ -0,0 +1,15 @@ +import React from 'react'; + +interface TLogoProps { + size?: number; +} + +export const TLogo: React.FC = ({ size = 40 }) => ( + T-Systems +); \ No newline at end of file diff --git a/frontend/src/components/common/TPattern.tsx b/frontend/src/components/common/TPattern.tsx new file mode 100644 index 0000000..222aeea --- /dev/null +++ b/frontend/src/components/common/TPattern.tsx @@ -0,0 +1,30 @@ +import React from 'react'; +import { useTheme } from '../../contexts/ThemeContext'; + +export const TPattern: React.FC = () => { + const { theme, isDark } = useTheme(); + const patternOpacity = isDark ? 0.03 : 0.04; + + return ( +
+ + + + + + + + +
+ ); +}; \ No newline at end of file diff --git a/frontend/src/components/common/ThemeToggle.tsx b/frontend/src/components/common/ThemeToggle.tsx new file mode 100644 index 0000000..3198a32 --- /dev/null +++ b/frontend/src/components/common/ThemeToggle.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import { useTheme } from '../../contexts/ThemeContext'; + +export const ThemeToggle: React.FC = () => { + const { isDark, toggleTheme, theme } = useTheme(); + + return ( + + ); +}; \ No newline at end of file diff --git a/frontend/src/components/common/index.ts b/frontend/src/components/common/index.ts new file mode 100644 index 0000000..4c982e5 --- /dev/null +++ b/frontend/src/components/common/index.ts @@ -0,0 +1,3 @@ +export { TLogo } from './TLogo'; +export { ThemeToggle } from './ThemeToggle'; +export { TPattern } from './TPattern'; \ No newline at end of file diff --git a/frontend/src/components/layout/Content.tsx b/frontend/src/components/layout/Content.tsx new file mode 100644 index 0000000..cd007b0 --- /dev/null +++ b/frontend/src/components/layout/Content.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import { useTheme } from '../../contexts/ThemeContext'; + +interface ContentProps { + children: React.ReactNode; + wide?: boolean; +} + +export const Content: React.FC = ({ children, wide = false }) => { + const { theme } = useTheme(); + + return ( +
+ {children} +
+ ); +}; \ No newline at end of file diff --git a/frontend/src/components/layout/Header.tsx b/frontend/src/components/layout/Header.tsx new file mode 100644 index 0000000..7d5c1f0 --- /dev/null +++ b/frontend/src/components/layout/Header.tsx @@ -0,0 +1,47 @@ +import React from 'react'; +import { useTheme } from '../../contexts/ThemeContext'; +import { TLogo } from '../common/TLogo'; +import { ThemeToggle } from '../common/ThemeToggle'; + +export const Header: React.FC = () => { + const { theme } = useTheme(); + + return ( +
+
+ +
+ + T-Systems + + + Regulation + +
+
+
+ +
+ v1.0.0 +
+ ● ONLINE +
+
+
+ ); +}; \ No newline at end of file diff --git a/frontend/src/components/layout/Tabs.tsx b/frontend/src/components/layout/Tabs.tsx new file mode 100644 index 0000000..c1e9356 --- /dev/null +++ b/frontend/src/components/layout/Tabs.tsx @@ -0,0 +1,47 @@ +import React from 'react'; +import { useTheme, useApp } from '../../contexts'; + +const tabs = [ + { id: 'docs', label: '文档管理' }, + { id: 'compliance', label: '合规分析' }, + { id: 'status', label: '系统状态' }, + { id: 'rag', label: '法规对话' }, +]; + +export const Tabs: React.FC = () => { + const { theme } = useTheme(); + const { activeTab, setActiveTab } = useApp(); + + return ( + + ); +}; \ No newline at end of file diff --git a/frontend/src/components/layout/index.ts b/frontend/src/components/layout/index.ts new file mode 100644 index 0000000..b766f49 --- /dev/null +++ b/frontend/src/components/layout/index.ts @@ -0,0 +1,3 @@ +export { Header } from './Header'; +export { Tabs } from './Tabs'; +export { Content } from './Content'; \ No newline at end of file diff --git a/frontend/src/components/ui/Badge.tsx b/frontend/src/components/ui/Badge.tsx new file mode 100644 index 0000000..80eddc9 --- /dev/null +++ b/frontend/src/components/ui/Badge.tsx @@ -0,0 +1,40 @@ +import React from 'react'; +import { useTheme } from '../../contexts/ThemeContext'; + +interface BadgeProps { + children: React.ReactNode; + color?: 'accent' | 'green' | 'orange' | 'red'; + size?: 'sm' | 'md'; +} + +export const Badge: React.FC = ({ + children, + color = 'accent', + size = 'sm', +}) => { + const { theme } = useTheme(); + + const colorStyles = { + accent: { bg: theme.gradientAccent, text: '#fff' }, + green: { bg: theme.green, text: '#fff' }, + orange: { bg: theme.orange, text: '#fff' }, + red: { bg: '#ff4444', text: '#fff' }, + }; + + const sizeStyles = { + sm: 'px-2 py-0.5 text-xs', + md: 'px-3 py-1 text-sm', + }; + + return ( + + {children} + + ); +}; \ No newline at end of file diff --git a/frontend/src/components/ui/Button.tsx b/frontend/src/components/ui/Button.tsx new file mode 100644 index 0000000..3229fdb --- /dev/null +++ b/frontend/src/components/ui/Button.tsx @@ -0,0 +1,60 @@ +import React from 'react'; +import { useTheme } from '../../contexts/ThemeContext'; + +interface ButtonProps { + variant?: 'primary' | 'secondary'; + size?: 'sm' | 'md' | 'lg'; + children: React.ReactNode; + onClick?: () => void; + disabled?: boolean; + className?: string; +} + +export const Button: React.FC = ({ + variant = 'primary', + size = 'md', + children, + onClick, + disabled = false, + className = '', +}) => { + const { theme } = useTheme(); + + const baseStyles = ` + inline-flex items-center justify-center + font-semibold rounded-xl cursor-pointer + transition-all duration-300 ease + disabled:cursor-not-allowed disabled:opacity-50 + `; + + const sizeStyles = { + sm: 'px-3 py-1.5 text-xs', + md: 'px-5 py-3 text-sm', + lg: 'px-8 py-5 text-base', + }; + + const variantStyles = { + primary: ` + bg-gradient-to-r from-t-accent to-t-accent-dark + text-white hover:shadow-t-accent hover:-translate-y-0.5 + hover:from-[#f0208a] hover:to-[#d01070] + `, + secondary: ` + bg-t-bg-hover border border-t-border + text-t-text2 hover:bg-t-bg-elevated + `, + }; + + return ( + + ); +}; \ No newline at end of file diff --git a/frontend/src/components/ui/Card.tsx b/frontend/src/components/ui/Card.tsx new file mode 100644 index 0000000..e8bc925 --- /dev/null +++ b/frontend/src/components/ui/Card.tsx @@ -0,0 +1,54 @@ +import React from 'react'; +import { useTheme } from '../../contexts/ThemeContext'; + +interface CardProps { + accent?: boolean; + highlight?: boolean; + padding?: 'sm' | 'md' | 'lg'; + children: React.ReactNode; + className?: string; + onClick?: () => void; +} + +export const Card: React.FC = ({ + accent = false, + highlight = false, + padding = 'md', + children, + className = '', + onClick, +}) => { + const { theme, isDark } = useTheme(); + + const paddingStyles = { + sm: 'p-4', + md: 'p-5', + lg: 'p-8', + }; + + return ( +
+ {accent && ( +
+ )} + {children} +
+ ); +}; \ No newline at end of file diff --git a/frontend/src/components/ui/Input.tsx b/frontend/src/components/ui/Input.tsx new file mode 100644 index 0000000..59fc62b --- /dev/null +++ b/frontend/src/components/ui/Input.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import { useTheme } from '../../contexts/ThemeContext'; + +interface InputProps { + value: string; + onChange: (value: string) => void; + placeholder?: string; + onKeyDown?: (e: React.KeyboardEvent) => void; + className?: string; + type?: string; +} + +export const Input: React.FC = ({ + value, + onChange, + placeholder = '', + onKeyDown, + className = '', + type = 'text', +}) => { + const { theme } = useTheme(); + + return ( + onChange(e.target.value)} + onKeyDown={onKeyDown} + placeholder={placeholder} + className={` + w-full px-4 py-3 text-sm + bg-t-bg-card border border-t-border rounded-lg + text-t-text outline-none + focus:border-t-accent focus:ring-1 focus:ring-t-accent + placeholder:text-t-text3 + ${className} + `} + style={{ + backgroundColor: theme.bgCard, + borderColor: theme.border, + color: theme.text, + }} + /> + ); +}; \ No newline at end of file diff --git a/frontend/src/components/ui/ProgressBar.tsx b/frontend/src/components/ui/ProgressBar.tsx new file mode 100644 index 0000000..b16fc9c --- /dev/null +++ b/frontend/src/components/ui/ProgressBar.tsx @@ -0,0 +1,43 @@ +import React from 'react'; +import { useTheme } from '../../contexts/ThemeContext'; + +interface ProgressBarProps { + percent: number; + color?: 'accent' | 'green' | 'orange' | 'red'; + showLabel?: boolean; +} + +export const ProgressBar: React.FC = ({ + percent, + color = 'accent', + showLabel = false, +}) => { + const { theme } = useTheme(); + + const colorStyles = { + accent: theme.gradientAccent, + green: `linear-gradient(90deg, ${theme.green}, #00ff88)`, + orange: `linear-gradient(90deg, ${theme.orange}, #ffaa00)`, + red: '#ff4444', + }; + + return ( +
+
+
+
+ {showLabel && ( + {percent}% + )} +
+ ); +}; \ No newline at end of file diff --git a/frontend/src/components/ui/ScoreBar.tsx b/frontend/src/components/ui/ScoreBar.tsx new file mode 100644 index 0000000..053331a --- /dev/null +++ b/frontend/src/components/ui/ScoreBar.tsx @@ -0,0 +1,52 @@ +import React from 'react'; +import { useTheme } from '../../contexts/ThemeContext'; + +interface ScoreBarProps { + score: number; // 0-100 + label?: string; + accent?: boolean; +} + +export const ScoreBar: React.FC = ({ + score, + label, + accent = false, +}) => { + const { theme } = useTheme(); + + return ( +
+ {label && ( +
+ {label} +
+ )} +
+ {score} +
+
+ {[...Array(10)].map((_, i) => ( +
+ ))} +
+
+ ); +}; \ No newline at end of file diff --git a/frontend/src/components/ui/index.ts b/frontend/src/components/ui/index.ts new file mode 100644 index 0000000..2921125 --- /dev/null +++ b/frontend/src/components/ui/index.ts @@ -0,0 +1,6 @@ +export { Button } from './Button'; +export { Card } from './Card'; +export { Input } from './Input'; +export { Badge } from './Badge'; +export { ProgressBar } from './ProgressBar'; +export { ScoreBar } from './ScoreBar'; \ No newline at end of file diff --git a/frontend/src/contexts/AppContext.tsx b/frontend/src/contexts/AppContext.tsx new file mode 100644 index 0000000..4df3156 --- /dev/null +++ b/frontend/src/contexts/AppContext.tsx @@ -0,0 +1,32 @@ +import { createContext, useContext, useState, type ReactNode } from 'react'; + +type TabId = 'docs' | 'compliance' | 'status' | 'rag'; + +interface AppContextValue { + activeTab: TabId; + setActiveTab: (tab: TabId) => void; +} + +const AppContext = createContext(undefined); + +export const useApp = (): AppContextValue => { + const context = useContext(AppContext); + if (!context) { + throw new Error('useApp must be used within an AppProvider'); + } + return context; +}; + +interface AppProviderProps { + children: ReactNode; +} + +export const AppProvider: React.FC = ({ children }) => { + const [activeTab, setActiveTab] = useState('compliance'); + + return ( + + {children} + + ); +}; \ No newline at end of file diff --git a/frontend/src/contexts/ThemeContext.tsx b/frontend/src/contexts/ThemeContext.tsx new file mode 100644 index 0000000..d145b44 --- /dev/null +++ b/frontend/src/contexts/ThemeContext.tsx @@ -0,0 +1,49 @@ +import React, { createContext, useContext, useState, useEffect, type ReactNode } from 'react'; +import { darkTheme, lightTheme } from '../types/theme'; +import type { ThemeColors } from '../types/theme'; + +interface ThemeContextValue { + isDark: boolean; + theme: ThemeColors; + toggleTheme: () => void; +} + +const ThemeContext = createContext(undefined); + +export const useTheme = (): ThemeContextValue => { + const context = useContext(ThemeContext); + if (!context) { + throw new Error('useTheme must be used within a ThemeProvider'); + } + return context; +}; + +interface ThemeProviderProps { + children: ReactNode; +} + +export const ThemeProvider: React.FC = ({ children }) => { + const [isDark, setIsDark] = useState(true); + const theme = isDark ? darkTheme : lightTheme; + + const toggleTheme = () => { + setIsDark((prev) => !prev); + }; + + // Apply class to document for Tailwind dark mode + body background + useEffect(() => { + if (isDark) { + document.documentElement.classList.add('dark'); + document.body.style.background = '#0a0a12'; + } else { + document.documentElement.classList.remove('dark'); + document.body.style.background = '#ffffff'; + } + }, [isDark]); + + return ( + + {children} + + ); +}; \ No newline at end of file diff --git a/frontend/src/contexts/index.ts b/frontend/src/contexts/index.ts new file mode 100644 index 0000000..d44f249 --- /dev/null +++ b/frontend/src/contexts/index.ts @@ -0,0 +1,2 @@ +export { ThemeProvider, useTheme } from './ThemeContext'; +export { AppProvider, useApp } from './AppContext'; \ No newline at end of file diff --git a/frontend/src/data/index.ts b/frontend/src/data/index.ts new file mode 100644 index 0000000..03451bb --- /dev/null +++ b/frontend/src/data/index.ts @@ -0,0 +1,7 @@ +export * from './mockDocs'; +export * from './mockResults'; +export * from './mockDocumentContent'; +export * from './mockComplianceChunks'; +export * from './mockPriorityActions'; +export * from './mockRetrievalData'; +export * from './mockAIResponses'; \ No newline at end of file diff --git a/frontend/src/data/mockAIResponses.ts b/frontend/src/data/mockAIResponses.ts new file mode 100644 index 0000000..2e201ca --- /dev/null +++ b/frontend/src/data/mockAIResponses.ts @@ -0,0 +1,21 @@ +export const mockAIResponses: Record = { + '车身结构设计': { + compliance: '根据GB 26112-2010第4.2条,车顶结构需承受1.5倍整备质量的载荷。您的设计提到"满足GB 26112-2010抗压强度要求",但缺少具体的承载数值说明。\n\n建议补充:实际测试承载值为XX倍整备质量,以满足法规要求。', + interpretation: '热成型钢板(厚度2.5mm)用于A柱和B柱是合理的设计选择。C-NCAP管理规则第3.1条要求正面碰撞后车门应能打开,建议在设计中明确说明碰撞后车门开启性能。', + suggestion: '建议补充以下细节以完善合规性:\n1. 车顶抗压强度具体数值\n2. A柱/B柱材料认证标准\n3. 碰撞测试验证数据', + }, + '动力系统配置': { + compliance: '充电接口符合GB/T 18487.1-2015标准,电池能量密度180Wh/kg远超GB/T 31484-2015要求的120Wh/kg最低标准,合规性良好。\n\n注意:需提供电池热失控测试报告以满足GB 38031-2020要求。', + interpretation: '快充30分钟充至80%符合行业标准,但需确保充电系统具备以下安全功能:\n- 过充保护\n- 温度监控\n- 通信协议符合GB/T 27930', + suggestion: '建议补充:\n1. 电池热失控测试报告\n2. 充电系统通信协议版本\n3. 快充循环寿命测试数据', + }, + '安全配置设计': { + compliance: '6个安全气囊配置符合GB 27887-2011对乘用车的基本要求。AEB和FCW功能是C-NCAP评分加分项。\n\n方向盘疲劳监测摄像头符合高级辅助驾驶趋势,但法规暂无强制要求。', + interpretation: '博世第9代ESP具备碰撞预警和AEB功能,符合当前主流安全标准。建议明确AEB的触发条件和响应时间参数。', + suggestion: '建议完善:\n1. AEB触发条件说明\n2. 安全气囊预紧器响应时间\n3. ESP系统认证文件', + }, +}; \ No newline at end of file diff --git a/frontend/src/data/mockComplianceChunks.ts b/frontend/src/data/mockComplianceChunks.ts new file mode 100644 index 0000000..049625b --- /dev/null +++ b/frontend/src/data/mockComplianceChunks.ts @@ -0,0 +1,46 @@ +import type { ComplianceChunk } from '../types'; + +export const mockComplianceChunks: ComplianceChunk[] = [ + { + id: 1, + index: 1, + intent: '车身结构设计', + startPos: 45, + endPos: 230, + content: '车身采用高强度钢铝混合结构,A柱和B柱使用热成型钢板,厚度2.5mm。车顶结构设计满足GB 26112-2010抗压强度要求,正面碰撞能量吸收区域采用渐进式变形设计,确保碰撞时能量有效分散。', + regulations: [ + { id: 1, name: 'GB 26112-2010', clause: '第4.2条', score: 0.95, matchKeyword: '车顶抗压强度', category: 'high', fullContent: '车顶结构应能承受相当于车辆整备质量1.5倍的载荷...' }, + { id: 2, name: 'C-NCAP管理规则', clause: '第3.1条', score: 0.88, matchKeyword: '正面碰撞', category: 'high', fullContent: '正面碰撞试验速度为50km/h,碰撞后车门应能打开...' }, + { id: 3, name: 'GB 11551-2014', clause: '第5条', score: 0.72, matchKeyword: '碰撞能量吸收', category: 'medium', fullContent: '车辆正面碰撞时应有效保护乘员...' }, + { id: 4, name: '机动车安全技术条件', clause: '第12条', score: 0.58, matchKeyword: 'A柱强度', category: 'medium', fullContent: 'A柱应具备足够的抗变形能力...' }, + ] + }, + { + id: 2, + index: 2, + intent: '动力系统配置', + startPos: 298, + endPos: 425, + content: '搭载永磁同步电机,最大功率150kW,峰值扭矩310Nm。电池组采用三元锂离子电池,容量75kWh,能量密度180Wh/kg。充电接口支持快充(30分钟充至80%)和慢充(8小时充满),符合GB/T 18487.1-2015标准。', + regulations: [ + { id: 5, name: 'GB/T 18487.1-2015', clause: '第6条', score: 0.94, matchKeyword: '充电接口标准', category: 'high', fullContent: '电动汽车传导充电接口应符合标准要求...' }, + { id: 6, name: 'GB/T 31484-2015', clause: '第4条', score: 0.85, matchKeyword: '电池能量密度', category: 'high', fullContent: '动力电池能量密度不低于120Wh/kg...' }, + { id: 7, name: '新能源汽车生产企业准入', clause: '第8条', score: 0.65, matchKeyword: '电机功率', category: 'medium', fullContent: '驱动电机应符合相关技术标准...' }, + { id: 8, name: '电动汽车安全要求', clause: '第7条', score: 0.45, matchKeyword: '充电时间', category: 'low', fullContent: '充电系统应具备过充保护功能...' }, + ] + }, + { + id: 3, + index: 3, + intent: '安全配置设计', + startPos: 570, + endPos: 725, + content: '配备6个安全气囊(前排双气囊、侧气囊、侧气帘),采用预紧式安全带。ABS系统采用博世第9代ESP,具备碰撞预警功能(FCW)和自动紧急制动(AEB)。方向盘集成驾驶员疲劳监测摄像头。', + regulations: [ + { id: 9, name: 'GB 27887-2011', clause: '第5条', score: 0.92, matchKeyword: '安全气囊', category: 'high', fullContent: '乘用车应配备驾驶员和乘客安全气囊...' }, + { id: 10, name: 'GB/T 26991-2011', clause: '第3条', score: 0.78, matchKeyword: 'ABS系统', category: 'medium', fullContent: '车辆应配备防抱死制动系统...' }, + { id: 11, name: 'C-NCAP管理规则', clause: '第4.2条', score: 0.71, matchKeyword: 'AEB自动制动', category: 'medium', fullContent: '主动安全配置评分包含AEB功能...' }, + { id: 12, name: '机动车运行安全技术条件', clause: '第15条', score: 0.38, matchKeyword: '疲劳监测', category: 'low', fullContent: '建议配备驾驶员状态监测系统...' }, + ] + }, +]; \ No newline at end of file diff --git a/frontend/src/data/mockDocs.ts b/frontend/src/data/mockDocs.ts new file mode 100644 index 0000000..ff6ffcd --- /dev/null +++ b/frontend/src/data/mockDocs.ts @@ -0,0 +1,9 @@ +import type { Doc } from '../types'; + +export const mockDocs: Doc[] = [ + { id: 1, name: '道路交通安全法.pdf', chunks: 156, size: '1.8MB', status: 'indexed' }, + { id: 2, name: '机动车登记规定.docx', chunks: 89, size: '1.1MB', status: 'indexed' }, + { id: 3, name: '电动自行车规范.pdf', chunks: 42, size: '345KB', status: 'indexed' }, + { id: 4, name: 'GB 38031-2020 电动汽车安全要求.pdf', chunks: 128, size: '2.2MB', status: 'indexed' }, + { id: 5, name: 'C-NCAP管理规则(2021版).pdf', chunks: 95, size: '1.5MB', status: 'indexed' }, +]; \ No newline at end of file diff --git a/frontend/src/data/mockDocumentContent.ts b/frontend/src/data/mockDocumentContent.ts new file mode 100644 index 0000000..5f38846 --- /dev/null +++ b/frontend/src/data/mockDocumentContent.ts @@ -0,0 +1,31 @@ +export const fullDocumentContent = ` +车辆设计方案 v2.0 + +一、车身结构设计 + +车身采用高强度钢铝混合结构,A柱和B柱使用热成型钢板,厚度2.5mm。车顶结构设计满足GB 26112-2010抗压强度要求,正面碰撞能量吸收区域采用渐进式变形设计,确保碰撞时能量有效分散。 + +车身侧面采用铝合金板材,减轻整车重量约15%。底盘结构采用模块化设计,便于后续维修和零部件更换。车门内部设置防撞梁,提升侧面碰撞安全性。 + +二、动力系统配置 + +搭载永磁同步电机,最大功率150kW,峰值扭矩310Nm。电池组采用三元锂离子电池,容量75kWh,能量密度180Wh/kg。充电接口支持快充(30分钟充至80%)和慢充(8小时充满),符合GB/T 18487.1-2015标准。 + +电机控制器采用水冷散热系统,工作温度范围-30℃至60℃。电池包内置BMS管理系统,实时监控电池状态,具备过充、过放、过温等多重保护功能。 + +三、安全配置设计 + +配备6个安全气囊(前排双气囊、侧气囊、侧气帘),采用预紧式安全带。ABS系统采用博世第9代ESP,具备碰撞预警功能(FCW)和自动紧急制动(AEB)。方向盘集成驾驶员疲劳监测摄像头。 + +安全带预紧器在碰撞发生前0.1秒自动收紧,配合气囊提供最佳保护效果。AEB系统在城市工况下可有效避免85%以上的碰撞事故。疲劳监测系统通过面部特征识别,实时提醒驾驶员注意休息。 + +四、车身外观设计 + +车身尺寸:长4650mm,宽1850mm,高1450mm,轴距2800mm。前大灯采用LED矩阵式设计,具备自适应远近光切换功能。尾灯采用贯穿式设计,提升视觉辨识度。 + +五、内饰设计方案 + +驾驶舱采用环抱式设计,中控台配备12.3英寸触摸屏。仪表盘采用全液晶显示,支持多种主题切换。座椅采用真皮包裹,具备8向电动调节和加热通风功能。 + +方向盘采用三辐式设计,集成多功能控制按键。车内氛围灯采用可调色设计,支持256色自定义。音响系统配备12扬声器,支持环绕声效果。 +`; \ No newline at end of file diff --git a/frontend/src/data/mockPriorityActions.ts b/frontend/src/data/mockPriorityActions.ts new file mode 100644 index 0000000..821bc76 --- /dev/null +++ b/frontend/src/data/mockPriorityActions.ts @@ -0,0 +1,28 @@ +import type { PriorityAction } from '../types'; + +export const mockPriorityActions: PriorityAction[] = [ + { + id: 1, + regulation: 'GB 26112-2010 第4.2条', + issue: '车顶抗压强度', + suggestion: '建议补充具体承载测试数据,明确车顶结构承受载荷倍数达到1.5倍以上', + chunkId: 1, + severity: 'high', + }, + { + id: 2, + regulation: 'GB/T 31484-2015 第4条', + issue: '电池能量密度', + suggestion: '当前180Wh/kg已达标,建议补充热失控测试报告以满足GB 38031-2020', + chunkId: 2, + severity: 'medium', + }, + { + id: 3, + regulation: 'C-NCAP管理规则 第3.1条', + issue: '正面碰撞验证', + suggestion: '建议提供碰撞后车门开启性能测试数据', + chunkId: 1, + severity: 'medium', + }, +]; \ No newline at end of file diff --git a/frontend/src/data/mockResults.ts b/frontend/src/data/mockResults.ts new file mode 100644 index 0000000..bc88315 --- /dev/null +++ b/frontend/src/data/mockResults.ts @@ -0,0 +1,7 @@ +import type { SearchResult } from '../types'; + +export const mockResults: SearchResult[] = [ + { id: 1, score: 0.92, law: '《道路交通安全法》第十八条', preview: '电动自行车应当符合国家标准,应当登记后方可上路行驶...', source: '道路交通安全法.pdf' }, + { id: 2, score: 0.87, law: '《电动自行车安全技术规范》第二条', preview: '最高设计车速不超过25km/h,整车质量≤55kg...', source: '电动自行车规范.pdf' }, + { id: 3, score: 0.79, law: '《道路交通安全法实施条例》第七十二条', preview: '驾驶电动自行车应当遵守下列规定...', source: '道路交通安全法.pdf' }, +]; \ No newline at end of file diff --git a/frontend/src/data/mockRetrievalData.ts b/frontend/src/data/mockRetrievalData.ts new file mode 100644 index 0000000..130ace4 --- /dev/null +++ b/frontend/src/data/mockRetrievalData.ts @@ -0,0 +1,16 @@ +import type { RetrievalData } from '../types'; + +export const mockRetrievalData: RetrievalData[] = [ + { id: 1, file: '道路交通安全法.pdf', clause: '第十八条', score: 0.92, content: '电动自行车应当符合国家标准,应当登记后方可上路行驶。电动自行车的设计最高车速、整车质量、外形尺寸等应当符合国家标准。' }, + { id: 2, file: '电动自行车规范.pdf', clause: '第二条', score: 0.87, content: '最高设计车速不超过25km/h,整车质量(含电池)不超过55kg,具有脚踏骑行能力,蓄电池标称电压不超过48V。' }, + { id: 3, file: '道路交通安全法.pdf', clause: '第七十二条', score: 0.79, content: '驾驶电动自行车在道路上行驶,应当遵守下列规定:佩戴安全头盔;不得逆向行驶;不得在机动车道内行驶。' }, + { id: 4, file: '机动车登记规定.pdf', clause: '第五条', score: 0.72, content: '初次申领机动车号牌、行驶证的,应当向住所地的车辆管理所申请注册登记,填写申请表,交验机动车。' }, + { id: 5, file: 'GB 38031-2020 电动汽车安全要求.pdf', clause: '5.1 热失控要求', score: 0.95, content: '电池系统发生热失控后,应在5分钟内不起火不爆炸,为乘员预留逃生时间。电池包需通过针刺、过充、短路等安全测试。' }, + { id: 6, file: 'C-NCAP管理规则(2021版).pdf', clause: '4.2 正面碰撞', score: 0.88, content: '正面100%重叠刚性壁障碰撞试验,碰撞速度50km/h。试验后车门应能打开,燃油系统无泄漏,座椅及安全带功能正常。' }, + { id: 7, file: '道路交通安全法.pdf', clause: '第九十条', score: 0.76, content: '机动车驾驶人违反道路交通安全法律、法规关于道路通行规定的,处警告或者二十元以上二百元以下罚款。' }, + { id: 8, file: '机动车登记规定.pdf', clause: '第十二条', score: 0.65, content: '机动车所有人的住所迁出车辆管理所管辖区域的,车辆管理所应当自受理之日起三日内,在机动车登记证书上签注变更事项。' }, + { id: 9, file: 'GB 38031-2020 电动汽车安全要求.pdf', clause: '6.2 充电安全', score: 0.91, content: '充电系统应具备过充保护功能,当电池SOC达到100%时应自动停止充电。充电接口应符合GB/T 18487.1标准要求。' }, + { id: 10, file: 'C-NCAP管理规则(2021版).pdf', clause: '5.3.1 AEB功能', score: 0.84, content: '自动紧急制动系统(AEB)应在检测到前方障碍物时主动减速或停车。AEB系统测试分为目标车静止、移动、制动三种场景。' }, + { id: 11, file: '道路交通安全法.pdf', clause: '第六十七条', score: 0.70, content: '机动车在高速公路上行驶,车速超过100km/h时,应当与同车道前车保持100米以上的距离;车速低于100km/h时,距离可适当缩短。' }, + { id: 12, file: '道路交通安全法.pdf', clause: '第五十三条', score: 0.68, content: '警车、消防车、救护车、工程救险车执行紧急任务时,可以使用警报器、标志灯具,在确保安全的前提下,不受行驶路线、行驶方向、行驶速度和信号灯的限制。' }, +]; \ No newline at end of file diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..5fb3313 --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,111 @@ +:root { + --text: #6b6375; + --text-h: #08060d; + --bg: #fff; + --border: #e5e4e7; + --code-bg: #f4f3ec; + --accent: #aa3bff; + --accent-bg: rgba(170, 59, 255, 0.1); + --accent-border: rgba(170, 59, 255, 0.5); + --social-bg: rgba(244, 243, 236, 0.5); + --shadow: + rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px; + + --sans: system-ui, 'Segoe UI', Roboto, sans-serif; + --heading: system-ui, 'Segoe UI', Roboto, sans-serif; + --mono: ui-monospace, Consolas, monospace; + + font: 18px/145% var(--sans); + letter-spacing: 0.18px; + color-scheme: light dark; + color: var(--text); + background: var(--bg); + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + + @media (max-width: 1024px) { + font-size: 16px; + } +} + +@media (prefers-color-scheme: dark) { + :root { + --text: #9ca3af; + --text-h: #f3f4f6; + --bg: #16171d; + --border: #2e303a; + --code-bg: #1f2028; + --accent: #c084fc; + --accent-bg: rgba(192, 132, 252, 0.15); + --accent-border: rgba(192, 132, 252, 0.5); + --social-bg: rgba(47, 48, 58, 0.5); + --shadow: + rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px; + } + + #social .button-icon { + filter: invert(1) brightness(2); + } +} + +#root { + width: 1126px; + max-width: 100%; + margin: 0 auto; + text-align: center; + border-inline: 1px solid var(--border); + min-height: 100svh; + display: flex; + flex-direction: column; + box-sizing: border-box; +} + +body { + margin: 0; +} + +h1, +h2 { + font-family: var(--heading); + font-weight: 500; + color: var(--text-h); +} + +h1 { + font-size: 56px; + letter-spacing: -1.68px; + margin: 32px 0; + @media (max-width: 1024px) { + font-size: 36px; + margin: 20px 0; + } +} +h2 { + font-size: 24px; + line-height: 118%; + letter-spacing: -0.24px; + margin: 0 0 8px; + @media (max-width: 1024px) { + font-size: 20px; + } +} +p { + margin: 0; +} + +code, +.counter { + font-family: var(--mono); + display: inline-flex; + border-radius: 4px; + color: var(--text-h); +} + +code { + font-size: 15px; + line-height: 135%; + padding: 4px 8px; + background: var(--code-bg); +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts new file mode 100644 index 0000000..33254f9 --- /dev/null +++ b/frontend/src/lib/api.ts @@ -0,0 +1,85 @@ +import type { + AgentChatResponse, + DocumentListResponse, + KnowledgeSearchResponse, + UploadDocumentResponse, +} from '../types'; + +const API_BASE = ( + import.meta.env.VITE_API_BASE_URL || + `${window.location.protocol}//${window.location.hostname}:8000/api/v1` +).replace(/\/$/, ''); + +async function request(path: string, init?: RequestInit): Promise { + const response = await fetch(`${API_BASE}${path}`, { + ...init, + headers: { + Accept: 'application/json', + ...(init?.headers || {}), + }, + }); + + if (!response.ok) { + let message = `Request failed: ${response.status}`; + try { + const data = await response.json(); + message = data.detail || data.message || message; + } catch { + // Fall back to HTTP status when the response body is not JSON. + } + throw new Error(message); + } + + return response.json() as Promise; +} + +export const api = { + listDocuments: () => request('/documents/list'), + + listDocumentManagementItems: () => request('/documents/management-list'), + + uploadDocument: async (payload: { + file: File; + docName?: string; + regulationType?: string; + version?: string; + generateSummary?: boolean; + }) => { + const formData = new FormData(); + formData.append('file', payload.file); + if (payload.docName) formData.append('doc_name', payload.docName); + if (payload.regulationType) formData.append('regulation_type', payload.regulationType); + if (payload.version) formData.append('version', payload.version); + formData.append('generate_summary', String(Boolean(payload.generateSummary))); + + return request('/documents/upload', { + method: 'POST', + body: formData, + }); + }, + + searchKnowledge: (query: string, topK = 8) => + request('/knowledge/retrieval', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ query, top_k: topK }), + }), + + chat: (payload: { query: string; sessionId?: string }) => + request('/agent/chat', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + query: payload.query, + session_id: payload.sessionId, + }), + }), + + get downloadBase() { + return API_BASE.replace(/\/api\/v1$/, ''); + }, +}; diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..ee89381 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,9 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import App from './App.tsx'; + +createRoot(document.getElementById('root')!).render( + + + , +); \ No newline at end of file diff --git a/frontend/src/pages/Compliance/ChatPanel.tsx b/frontend/src/pages/Compliance/ChatPanel.tsx new file mode 100644 index 0000000..61162eb --- /dev/null +++ b/frontend/src/pages/Compliance/ChatPanel.tsx @@ -0,0 +1,246 @@ +import React from 'react'; +import { useTheme } from '../../contexts/ThemeContext'; +import type { ComplianceChunk } from '../../types'; + +interface ChatPanelProps { + activeChunkId: number; + chunks: ComplianceChunk[]; + messages: Array<{ id: number; role: 'user' | 'assistant'; content: string }>; + chatInput: string; + setChatInput: (value: string) => void; + chatLoading: boolean; + sendChatMessage: () => void; + closeChat: () => void; + quickQuestions?: string[]; +} + +export const ChatPanel: React.FC = ({ + activeChunkId, + chunks, + messages, + chatInput, + setChatInput, + chatLoading, + sendChatMessage, + closeChat, + quickQuestions = [ + '这个设计是否合规?', + '需要修改哪些内容?', + '法规的具体要求是什么?', + ], +}) => { + const { theme } = useTheme(); + const activeChunk = chunks.find(c => c.id === activeChunkId); + + return ( +
+ {/* Chat Header */} +
+
+
合规对话
+
+ 段落 #{activeChunk?.index} · {activeChunk?.regulations.length} 条法规 +
+
+ +
+ + {/* Current Chunk Info */} +
+
+ {activeChunk?.intent} +
+
+ {activeChunk?.content.substring(0, 100)}... +
+
+ + {/* Chat Messages */} +
+ {messages.map(msg => ( +
+ {msg.role === 'assistant' && ( +
+ + + +
+ )} +
+ {msg.content} +
+
+ ))} + {chatLoading && ( +
+
+ + + +
+
+
+ 分析中... +
+
+ )} +
+ + {/* Quick Questions */} +
+ {quickQuestions.map(q => ( + + ))} +
+ + {/* Chat Input */} +
+ setChatInput(e.target.value)} + onKeyDown={e => e.key === 'Enter' && sendChatMessage()} + placeholder="输入问题..." + style={{ + flex: 1, + padding: 12, + fontSize: 14, + background: theme.bgHover, + border: `1px solid ${theme.border}`, + borderRadius: 8, + color: theme.text, + outline: 'none', + }} + /> + +
+
+ ); +}; \ No newline at end of file diff --git a/frontend/src/pages/Compliance/CompliancePage.tsx b/frontend/src/pages/Compliance/CompliancePage.tsx new file mode 100644 index 0000000..e254321 --- /dev/null +++ b/frontend/src/pages/Compliance/CompliancePage.tsx @@ -0,0 +1,1916 @@ +import React, { useState } from 'react'; +import { useTheme } from '../../contexts/ThemeContext'; +import type { UploadedDoc, ComplianceChunk, Regulation, SegmentRisk, RiskDashboardData } from '../../types'; +import { + mockComplianceChunks, + mockAIResponses, + mockPriorityActions, + fullDocumentContent, +} from '../../data'; +import { analyzeDocument, getComplianceResult, complianceChat } from '../../api/compliance'; +import { ChatPanel } from './ChatPanel'; +import { TPattern } from '../../components/common/TPattern'; + +// Risk calculation function +const calculateRiskDashboard = (chunks: ComplianceChunk[]): RiskDashboardData | null => { + if (!chunks || chunks.length === 0) return null; + + const segmentRisks: SegmentRisk[] = chunks.map(chunk => { + const regs = chunk.regulations; + const highRegs = regs.filter(r => r.category === 'high'); + const avgHighScore = highRegs.length > 0 + ? highRegs.reduce((sum, r) => sum + r.score, 0) / highRegs.length + : 1; + const lowScoreHighRegs = highRegs.filter(r => r.score < 0.9).length; + + let level: 'high' | 'medium' | 'low' = 'low'; + let score = Math.round(avgHighScore * 100); + + if (lowScoreHighRegs >= 1 || avgHighScore < 0.85) { + level = 'high'; + score = Math.min(score, 72); + } else if (avgHighScore < 0.92 || regs.filter(r => r.category === 'medium').length >= 2) { + level = 'medium'; + score = Math.min(score, 85); + } + + return { + chunkId: chunk.id, + level, + score, + highRegsCount: highRegs.length, + riskRegs: lowScoreHighRegs, + }; + }); + + const totalScore = Math.round( + segmentRisks.reduce((sum, s) => sum + s.score, 0) / segmentRisks.length + ); + const highRiskCount = segmentRisks.filter(s => s.level === 'high').length; + const mediumRiskCount = segmentRisks.filter(s => s.level === 'medium').length; + const needFixSegments = segmentRisks.filter(s => s.riskRegs > 0).length; + + let status: 'pass' | 'warning' | 'fail' = 'pass'; + let statusLabel = '合规通过'; + if (totalScore < 70) { + status = 'fail'; + statusLabel = '不合规'; + } else if (totalScore < 85 || highRiskCount > 0) { + status = 'warning'; + statusLabel = '需优化'; + } + + return { + score: totalScore, + highRiskCount, + mediumRiskCount, + lowRiskCount: segmentRisks.filter(s => s.level === 'low').length, + needFixSegments, + status, + statusLabel, + segmentRisks, + }; +}; + +// Get regulations by category +const getRegsByCategory = (regulations: Regulation[]) => { + const high = regulations.filter(r => r.category === 'high'); + const medium = regulations.filter(r => r.category === 'medium'); + const low = regulations.filter(r => r.category === 'low'); + return { high, medium, low }; +}; + +export const CompliancePage: React.FC = () => { + const { theme, isDark } = useTheme(); + + // Upload & Analysis States + const [uploadedDoc, setUploadedDoc] = useState(null); + const [isAnalyzing, setIsAnalyzing] = useState(false); + const [analyzeStep, setAnalyzeStep] = useState(0); + const [analyzePercent, setAnalyzePercent] = useState(0); + const [analyzeAction, setAnalyzeAction] = useState(''); + const [chunks, setChunks] = useState([]); + + // Interaction States + const [activeChunkId, setActiveChunkId] = useState(null); + const [expandedRegulationId, setExpandedRegulationId] = useState(null); + const [chatPanelOpen, setChatPanelOpen] = useState(false); + const [chatMessages, setChatMessages] = useState>>({}); + const [chatInput, setChatInput] = useState(''); + const [chatLoading, setChatLoading] = useState(false); + const [dashboardExpanded, setDashboardExpanded] = useState(false); + + // Handlers + const handleUpload = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) { + setUploadedDoc({ + name: file.name, + size: `${(file.size / 1024 / 1024).toFixed(2)}MB`, + }); + setChunks([]); + } + }; + + const startAnalysis = async () => { + if (!uploadedDoc) return; + + + setIsAnalyzing(true); + setAnalyzeStep(1); + setAnalyzePercent(0); + + try { + // Get file from upload input + const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement; + const file = fileInput?.files?.[0]; + console.log("123") + + // if (!file) { + // // setIsAnalyzing(false); + // // return; + // } + + console.log("456") + // Upload and get task ID + const uploadRes = await analyzeDocument(file); + + // Simulate progress + setTimeout(() => { + setAnalyzePercent(30); + setAnalyzeAction('正在解析文档结构...'); + }, 500); + + setTimeout(() => { + setAnalyzeStep(2); + setAnalyzePercent(50); + setAnalyzeAction('正在识别语义段落...'); + }, 1500); + + setTimeout(() => { + setAnalyzePercent(70); + setAnalyzeAction('正在识别第 2/3 个语义段落...'); + }, 2500); + + setTimeout(() => { + setAnalyzeStep(3); + setAnalyzePercent(85); + setAnalyzeAction('正在匹配法规条款...'); + }, 3500); + + // Get result after analysis completes + setTimeout(async () => { + try { + const result = await getComplianceResult(uploadRes.task_id); + if ('segments' in result) { + // Convert API response to frontend format + const apiChunks: ComplianceChunk[] = result.segments.map(s => ({ + id: s.id, + index: s.index, + intent: s.intent, + startPos: s.start_pos, + endPos: s.end_pos, + content: s.content, + regulations: s.regulations.map(r => ({ + id: r.id, + name: r.name, + clause: r.clause, + score: r.score, + matchKeyword: r.match_keyword, + category: r.category as 'high' | 'medium' | 'low', + fullContent: r.full_content, + })), + })); + + // Calculate risk levels for each segment + const chunksWithRisk = apiChunks.map(chunk => { + const regs = chunk.regulations; + const highRegs = regs.filter(r => r.category === 'high'); + const avgHighScore = highRegs.length > 0 + ? highRegs.reduce((sum, r) => sum + r.score, 0) / highRegs.length + : 1; + + let riskLevel: 'high' | 'medium' | 'low' = 'low'; + if (avgHighScore < 0.85 || highRegs.filter(r => r.score < 0.9).length >= 1) { + riskLevel = 'high'; + } else if (avgHighScore < 0.92 || regs.filter(r => r.category === 'medium').length >= 2) { + riskLevel = 'medium'; + } + + return { ...chunk, riskLevel } as ComplianceChunk; + }); + + setChunks(chunksWithRisk); + } + } catch (error) { + console.error('Failed to get compliance result:', error); + // Fallback to mock data + setChunks(mockComplianceChunks); + } + + setAnalyzePercent(100); + setAnalyzeAction('分析完成'); + setIsAnalyzing(false); + }, 4500); + } catch (error) { + console.error('Failed to analyze document:', error); + setIsAnalyzing(false); + // Fallback to mock data after delay + setTimeout(() => { + setChunks(mockComplianceChunks); + }, 4500); + } + }; + + const openChat = (chunkId: number) => { + setActiveChunkId(chunkId); + setChatPanelOpen(true); + if (!chatMessages[chunkId]) { + const chunk = chunks.find(c => c.id === chunkId); + setChatMessages(prev => ({ + ...prev, + [chunkId]: [{ + id: Date.now(), + role: 'assistant', + content: `您好!我是法规合规分析助手。当前段落涉及 ${chunk?.regulations.length} 条相关法规,您可以询问合规性评估、法规解读或修改建议。`, + }] + })); + } + }; + + const closeChat = () => { + setChatPanelOpen(false); + }; + + const sendChatMessage = () => { + if (!chatInput.trim() || !activeChunkId) return; + + const chunk = chunks.find(c => c.id === activeChunkId); + if (!chunk) return; + + const userMsg = { id: Date.now(), role: 'user' as const, content: chatInput }; + setChatMessages(prev => ({ + ...prev, + [activeChunkId]: [...(prev[activeChunkId] || []), userMsg], + })); + setChatInput(''); + setChatLoading(true); + + let currentResponse = ''; + + complianceChat( + activeChunkId, + chatInput, + (data: unknown) => { + const sseData = data as { type: string; text?: string }; + if (sseData.type === 'chunk' && sseData.text) { + currentResponse += sseData.text; + setChatMessages(prev => ({ + ...prev, + [activeChunkId]: [...(prev[activeChunkId] || []).slice(0, -1), { id: Date.now() + 1, role: 'assistant', content: currentResponse }], + })); + } else if (sseData.type === 'done') { + setChatLoading(false); + } + }, + (error: Error) => { + console.error('Compliance chat error:', error); + setChatLoading(false); + // Fallback to mock response + let response = ''; + const intent = chunk.intent; + const mockResps = mockAIResponses[intent]; + if (chatInput.includes('合规') || chatInput.includes('符合')) { + response = mockResps?.compliance || '根据相关法规分析,该段落的合规性需进一步评估。'; + } else if (chatInput.includes('解读') || chatInput.includes('什么') || chatInput.includes('如何')) { + response = mockResps?.interpretation || '法规要求详细解读如下...'; + } else if (chatInput.includes('修改') || chatInput.includes('建议') || chatInput.includes('完善')) { + response = mockResps?.suggestion || '建议进行以下修改以提升合规性...'; + } else { + response = `关于您的问题,${chunk.intent}部分涉及以下法规要点:\n\n${chunk.regulations.slice(0, 2).map(r => `• ${r.name} ${r.clause}(相关性 ${Math.round(r.score * 100)}%)`).join('\n')}\n\n您可以进一步询问合规性评估或修改建议。`; + } + setChatMessages(prev => ({ + ...prev, + [activeChunkId]: [...(prev[activeChunkId] || []), { id: Date.now() + 1, role: 'assistant', content: response }], + })); + }, + () => { + setChatLoading(false); + } + ); + }; + + const dashboard = calculateRiskDashboard(chunks); + const segmentRiskMap = dashboard?.segmentRisks?.reduce((map: Record, s) => { + map[s.chunkId] = s; + return map; + }, {}) || {}; + + // Render document with segment blocks + const renderDocumentWithSegmentBlocks = () => { + if (!fullDocumentContent || chunks.length === 0) return null; + + const sortedChunks = [...chunks].sort((a, b) => a.startPos - b.startPos); + const result: React.ReactNode[] = []; + let lastPos = 0; + + sortedChunks.forEach((chunk, idx) => { + const isActive = activeChunkId === chunk.id; + const segmentRisk = segmentRiskMap[chunk.id]; + const riskLevel = segmentRisk?.level || 'low'; + const riskColor = riskLevel === 'high' ? '#ff4444' : riskLevel === 'medium' ? theme.orange : theme.green; + + // Add normal text before this chunk + if (chunk.startPos > lastPos) { + result.push( +
+ {renderStructuredDocument(fullDocumentContent.slice(lastPos, chunk.startPos))} +
+ ); + } + + // Add segment block + result.push( +
setActiveChunkId(isActive ? null : chunk.id)} + style={{ + padding: '20px 24px', + marginBottom: 20, + background: isActive + ? 'linear-gradient(135deg, rgba(226,0,116,0.12), rgba(190,0,96,0.08))' + : riskLevel === 'high' + ? 'linear-gradient(135deg, rgba(255,68,68,0.06), rgba(255,68,68,0.02))' + : riskLevel === 'medium' + ? 'linear-gradient(135deg, rgba(255,136,0,0.05), rgba(255,136,0,0.02))' + : theme.bgHover, + borderRadius: 12, + border: isActive + ? `2px solid ${theme.accent}` + : riskLevel === 'high' + ? '2px solid rgba(255,68,68,0.4)' + : riskLevel === 'medium' + ? '2px solid rgba(255,136,0,0.3)' + : 'transparent', + cursor: 'pointer', + position: 'relative', + transition: 'all 0.3s ease', + boxShadow: isActive + ? '0 4px 20px rgba(226,0,116,0.15), inset 0 1px 0 rgba(226,0,116,0.1)' + : riskLevel === 'high' + ? '0 2px 10px rgba(255,68,68,0.1)' + : 'none', + }} + > + {/* Intent label with risk indicator */} +
+
+ {chunk.index} +
+ {chunk.intent} + {/* Risk level badge */} + {riskLevel !== 'low' && ( +
+
+ {riskLevel === 'high' ? '高风险' : '中风险'} +
+ )} + + {chunk.regulations.length} 条法规 + +
+ + {/* Segment content */} +
+ {fullDocumentContent.slice(chunk.startPos, chunk.endPos)} +
+ + {/* Regulation distribution mini bar */} +
+ {(() => { + const regs = getRegsByCategory(chunk.regulations); + return ( + <> + {regs.high.length > 0 && ( +
+
+ {regs.high.length} +
+ )} + {regs.medium.length > 0 && ( +
+
+ {regs.medium.length} +
+ )} + {regs.low.length > 0 && ( +
+
+ {regs.low.length} +
+ )} + + ); + })()} +
+
+ ); + + lastPos = chunk.endPos; + }); + + // Add remaining text after all chunks + if (lastPos < fullDocumentContent.length) { + result.push( +
+ {renderStructuredDocument(fullDocumentContent.slice(lastPos))} +
+ ); + } + + return result; + }; + + const quickQuestions = [ + '这个设计是否合规?', + '需要修改哪些内容?', + '法规的具体要求是什么?', + ]; + + // Render structured document — parses section headers and formats them + const renderStructuredDocument = (content: string) => { + if (!content) return null; + const lines = content.split('\n'); + const elements: React.ReactNode[] = []; + let currentParagraph: string[] = []; + + lines.forEach((line) => { + const sectionMatch = line.match(/^([一二三四五六七八九十]+[、..]|第[一二三四五六七八九十]+[章节部篇]|[0-9]+[、..])/); + + if (sectionMatch || (line.trim() && currentParagraph.length > 0 && line.trim().length < 20 && !line.trim().endsWith('。'))) { + if (currentParagraph.length > 0) { + elements.push( +

{currentParagraph.join('')}

+ ); + currentParagraph = []; + } + elements.push( +

{line.trim()}

+ ); + } else if (line.trim()) { + currentParagraph.push(line.trim()); + } else { + if (currentParagraph.length > 0) { + elements.push( +

{currentParagraph.join('')}

+ ); + currentParagraph = []; + } + } + }); + + if (currentParagraph.length > 0) { + elements.push( +

{currentParagraph.join('')}

+ ); + } + + return elements; + }; + + return ( +
+ {/* Main Content Area */} +
+ + + {/* Upload Section */} + {!uploadedDoc && ( +
+

UPLOAD DESIGN DOCUMENT

+
+ +
+ + + + +
+
拖拽文件或点击上传
+
PDF · DOCX · TXT · MAX 50MB
+
+
+ )} + + {/* Document Preview Section */} + {uploadedDoc && !isAnalyzing && chunks.length === 0 && ( +
+
+

DOCUMENT PREVIEW

+ +
+ +
+
+ + + + + {uploadedDoc.name} + {uploadedDoc.size} +
+
+ +
+
+
+ + + + + {uploadedDoc.name} + {uploadedDoc.size} +
+
+
+
{renderStructuredDocument(fullDocumentContent)}
+
+
+ + +
+ )} + + {/* Analysis Progress Section */} + {isAnalyzing && ( +
+

ANALYZING...

+ +
+ {/* Steps */} +
+ {[ + { name: '文档解析', desc: '提取文档内容' }, + { name: 'AI语义分段', desc: '识别设计意图' }, + { name: '法规匹配标注', desc: '关联法规条款' }, + ].map((step, i) => ( +
+
i + 1 ? theme.green : (analyzeStep === i + 1 ? theme.gradientAccent : theme.bgHover), + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + }}> + {analyzeStep > i + 1 ? ( + + + + ) : analyzeStep === i + 1 ? ( +
+ ) : ( + {i + 1} + )} +
+
+
{step.name}
+
{step.desc}
+
+ {analyzeStep === i + 1 && ( + 进行中 + )} +
+ ))} +
+ + {/* Progress Bar */} +
+
+
+
+
+ +
{analyzeAction}
+
+
+ )} + + {/* Analysis Results - Dashboard + Split Layout */} + {chunks.length > 0 && dashboard && ( + <> + {/* Risk Dashboard Section — Collapsible */} +
+ {/* Collapsed: Compact Summary */} + {!dashboardExpanded && ( +
setDashboardExpanded(true)} + style={{ + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + cursor: 'pointer', + }} + > +
+ + + + + 合规风险仪表盘 +
+
+ {dashboard.statusLabel} +
+
+
+ {dashboard.score} + +
+ {dashboard.highRiskCount > 0 && ( +
+
+ {dashboard.highRiskCount} 高风险 +
+ )} + {dashboard.needFixSegments > 0 && ( +
+
+ {dashboard.needFixSegments} 待修改 +
+ )} +
+
+
+ 展开详情 + + + +
+
+ )} + + {/* Expanded: Full Dashboard */} + {dashboardExpanded && ( +
+ {/* Dashboard Header with Collapse */} +
+
+ + + + + 合规风险仪表盘 +
+
setDashboardExpanded(false)} + style={{ + display: 'flex', + alignItems: 'center', + gap: 8, + padding: '6px 14px', + background: theme.bgHover, + borderRadius: 6, + border: `1px solid ${theme.border}`, + cursor: 'pointer', + }} + > + 收起 + + + +
+
+ + {/* Metrics Grid */} +
+ {/* Compliance Score */} +
+
合规评分
+
+ {dashboard.score} +
+
+
+
+
+ + {/* High Risk Items */} +
0 + ? 'linear-gradient(135deg, rgba(255,68,68,0.12), rgba(255,68,68,0.04))' + : theme.bgElevated, + borderRadius: 10, + border: `1px solid ${dashboard.highRiskCount > 0 ? '#ff4444' : theme.border}`, + textAlign: 'center', + }}> +
高风险项
+
0 ? '#ff4444' : theme.green, + marginBottom: 4, + }}> + {dashboard.highRiskCount} +
+
0 ? '#ff6666' : theme.text3, + }}> + {dashboard.highRiskCount > 0 ? '需立即处理' : '无高风险'} +
+
+ + {/* Need Fix Segments */} +
0 + ? 'linear-gradient(135deg, rgba(255,136,0,0.12), rgba(255,136,0,0.04))' + : theme.bgElevated, + borderRadius: 10, + border: `1px solid ${dashboard.needFixSegments > 0 ? theme.orange : theme.border}`, + textAlign: 'center', + }}> +
待修改段落
+
0 ? theme.orange : theme.green, + marginBottom: 4, + }}> + {dashboard.needFixSegments} +
+
0 ? '#ff9944' : theme.text3, + }}> + {dashboard.needFixSegments > 0 ? '需补充材料' : '无需修改'} +
+
+ + {/* Overall Status */} +
+
整体状态
+
+ {dashboard.status === 'pass' ? ( + + + + + ) : dashboard.status === 'warning' ? ( + + + + + ) : ( + + + + + )} + {dashboard.statusLabel} +
+
+
+ + {/* Priority Actions */} +
+
+ + + + 优先行动建议 + {mockPriorityActions.length} 条 +
+ +
+ {mockPriorityActions.map((action, idx) => ( +
setActiveChunkId(action.chunkId)} + style={{ + display: 'flex', + alignItems: 'flex-start', + gap: 12, + padding: '12px 16px', + marginBottom: idx < mockPriorityActions.length - 1 ? 8 : 0, + background: theme.bgHover, + borderRadius: 8, + border: `1px solid ${theme.border}`, + cursor: 'pointer', + transition: 'all 0.2s ease', + }} + > +
+ {idx + 1} +
+
+
+ {action.regulation} + {action.issue} +
+
{action.suggestion}
+
+ + + +
+ ))} +
+
+
+ )} +
+ + {/* Split Layout: Document Left, Regulations Right */} +
+ {/* Left: Full Document with Marginalia Markers */} +
+ {/* Marginalia Column - Segment Markers */} +
+ {chunks.map((chunk, idx) => { + const isActive = activeChunkId === chunk.id; + const segmentRisk = segmentRiskMap[chunk.id]; + const riskLevel = segmentRisk?.level || 'low'; + const riskColor = riskLevel === 'high' ? '#ff4444' : riskLevel === 'medium' ? theme.orange : theme.green; + const topPos = 60 + idx * 280; + + return ( +
setActiveChunkId(chunk.id)} + style={{ + position: 'absolute', + top: topPos, + left: 12, + width: 24, + height: 24, + borderRadius: '50%', + background: isActive ? theme.gradientAccent : theme.bgHover, + border: isActive ? 'none' : `2px solid ${riskColor}`, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + cursor: 'pointer', + boxShadow: isActive + ? '0 0 12px rgba(226,0,116,0.5), 0 0 24px rgba(226,0,116,0.2)' + : riskLevel === 'high' + ? '0 0 8px rgba(255,68,68,0.3)' + : riskLevel === 'medium' + ? '0 0 6px rgba(255,136,0,0.2)' + : '0 0 4px rgba(0,212,170,0.15)', + transition: 'all 0.3s ease', + animation: isActive ? 'pulse-glow 2s infinite' : 'none', + zIndex: 10, + }} + > + {chunk.index} +
+ ); + })} +
+ + {/* Document Content Column */} +
+

+ + + + + 原文文档 +

+ + {/* Render document with segment blocks — document-style layout */} +
+ {renderDocumentWithSegmentBlocks()} +
+
+
+ + {/* Right: Regulations Panel */} +
+ {/* Header */} +
+
+ + + + + 法规标注 +
+ {activeChunkId && ( +
+
+ + #{chunks.find(c => c.id === activeChunkId)?.index} + +
+ + {chunks.find(c => c.id === activeChunkId)?.intent} + +
+ )} +
+ + {/* Content */} +
+ {activeChunkId === null ? ( + /* Default: All segments with regulation counts */ +
+ {chunks.map(chunk => { + const regs = getRegsByCategory(chunk.regulations); + const totalRegs = chunk.regulations.length; + + return ( +
setActiveChunkId(chunk.id)} + style={{ + padding: '16px 20px', + background: theme.bgHover, + borderRadius: 10, + border: `1px solid ${theme.border}`, + cursor: 'pointer', + transition: 'all 0.25s ease', + }} + > +
+
+ + {chunk.index} + +
+
+
+ {chunk.intent} +
+
+ {chunk.content.substring(0, 50)}... +
+
+
+
{totalRegs}
+
条法规
+
+
+ + {/* Mini regulation distribution bar */} +
+ {regs.high.length > 0 && ( +
+ )} + {regs.medium.length > 0 && ( +
+ )} + {regs.low.length > 0 && ( +
+ )} +
+
+ ); + })} +
+ ) : ( + /* Active: Detailed regulations with expand/collapse */ +
+ {/* Active chunk brief */} +
+
+ {chunks.find(c => c.id === activeChunkId)?.content.substring(0, 120)}... +
+ +
+ + {/* Regulations by category */} + {(() => { + const activeChunk = chunks.find(c => c.id === activeChunkId); + if (!activeChunk) return null; + const regs = getRegsByCategory(activeChunk.regulations); + + return ( +
+ {/* High Relevance */} + {regs.high.length > 0 && ( +
+
+
+ 高度相关 + {regs.high.length} +
+ + {regs.high.map((reg) => { + const isExpanded = expandedRegulationId === reg.id; + return ( +
setExpandedRegulationId(isExpanded ? null : reg.id)} + style={{ + padding: isExpanded ? '18px 20px' : '12px 16px', + background: isExpanded ? 'linear-gradient(135deg, rgba(0,212,170,0.08), rgba(0,212,170,0.02))' : theme.bgHover, + borderRadius: 8, + marginBottom: 8, + border: `1px solid ${isExpanded ? theme.green : theme.border}`, + cursor: 'pointer', + transition: 'all 0.25s ease', + }} + > +
+
+
+ {reg.name} + {reg.clause} +
+
+ {Math.round(reg.score * 100)}% + + + +
+
+ + {isExpanded && ( +
+
+ 匹配关键词 + {reg.matchKeyword} +
+
+ {reg.fullContent} +
+
+ )} +
+ ); + })} +
+ )} + + {/* Medium Relevance */} + {regs.medium.length > 0 && ( +
+
+
+ 中度相关 + {regs.medium.length} +
+ + {regs.medium.map((reg) => { + const isExpanded = expandedRegulationId === reg.id; + return ( +
setExpandedRegulationId(isExpanded ? null : reg.id)} + style={{ + padding: isExpanded ? '16px 18px' : '12px 16px', + background: isExpanded ? 'linear-gradient(135deg, rgba(255,136,0,0.08), rgba(255,136,0,0.02))' : theme.bgHover, + borderRadius: 8, + marginBottom: 6, + border: `1px solid ${isExpanded ? theme.orange : theme.border}`, + cursor: 'pointer', + transition: 'all 0.25s ease', + }} + > +
+
+
+ {reg.name} + {reg.clause} +
+
+ {Math.round(reg.score * 100)}% + + + +
+
+ + {isExpanded && ( +
+
+ 匹配关键词 + {reg.matchKeyword} +
+
+ {reg.fullContent} +
+
+ )} +
+ ); + })} +
+ )} + + {/* Low Relevance */} + {regs.low.length > 0 && ( +
+
+
+ 低度相关 + {regs.low.length} +
+ +
+ {regs.low.map(reg => { + const isExpanded = expandedRegulationId === reg.id; + return ( +
setExpandedRegulationId(isExpanded ? null : reg.id)} + style={{ + padding: isExpanded ? '10px 14px' : '6px 12px', + background: isExpanded ? theme.bgElevated : theme.bgHover, + borderRadius: 6, + fontSize: 12, + color: theme.text2, + border: `1px solid ${isExpanded ? theme.borderLight : theme.border}`, + cursor: 'pointer', + transition: 'all 0.2s ease', + }} + > +
+ {reg.name} + {isExpanded && ( + + + + )} +
+ {isExpanded && ( +
+
{reg.clause}
+
{reg.matchKeyword}
+
{reg.fullContent.substring(0, 60)}...
+
+ )} +
+ ); + })} +
+
+ )} + + {/* Chat Button */} + +
+ ); + })()} +
+ )} +
+
+
+ + )} +
+ + {/* Chat Panel */} + {chatPanelOpen && activeChunkId && ( + + )} +
+ ); +}; \ No newline at end of file diff --git a/frontend/src/pages/Compliance/index.ts b/frontend/src/pages/Compliance/index.ts new file mode 100644 index 0000000..26a480d --- /dev/null +++ b/frontend/src/pages/Compliance/index.ts @@ -0,0 +1,2 @@ +export { CompliancePage } from './CompliancePage'; +export { ChatPanel } from './ChatPanel'; \ No newline at end of file diff --git a/frontend/src/pages/Docs/DocsPage.tsx b/frontend/src/pages/Docs/DocsPage.tsx new file mode 100644 index 0000000..c0dd32c --- /dev/null +++ b/frontend/src/pages/Docs/DocsPage.tsx @@ -0,0 +1,668 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { useTheme } from '../../contexts/ThemeContext'; +import { Content } from '../../components/layout/Content'; +import { TPattern } from '../../components/common/TPattern'; +import { getDocumentList, searchRegulations, uploadDocument, type RegulationSearchItem } from '../../api/docs'; +import type { Doc } from '../../types'; + +type PipelineStatus = 'idle' | 'running' | 'completed' | 'error'; + +const PIPELINE_STEPS = [ + { name: 'LOAD' }, + { name: 'PARSE' }, + { name: 'CHUNK' }, + { name: 'EMBED' }, + { name: 'STORE' }, +]; + +const STEP_DURATION_MS = 700; + +function wait(ms: number) { + return new Promise((resolve) => { + window.setTimeout(resolve, ms); + }); +} + +export const DocsPage: React.FC = () => { + const { theme, isDark } = useTheme(); + const fileInputRef = useRef(null); + const pipelineRunIdRef = useRef(0); + + const [activeStep, setActiveStep] = useState(-1); + const [completedSteps, setCompletedSteps] = useState([]); + const [pipelineStatus, setPipelineStatus] = useState('idle'); + const [docs, setDocs] = useState([]); + const [uploading, setUploading] = useState(false); + const [uploadFileName, setUploadFileName] = useState(''); + const [loading, setLoading] = useState(true); + const [searchQuery, setSearchQuery] = useState('新能源汽车电池安全要求'); + const [searchResults, setSearchResults] = useState([]); + const [searchLoading, setSearchLoading] = useState(false); + const [searchError, setSearchError] = useState(''); + + useEffect(() => { + void loadDocuments(); + }, []); + + useEffect(() => { + void runSearch(searchQuery); + }, []); + + useEffect(() => { + return () => { + pipelineRunIdRef.current += 1; + }; + }, []); + + const resetPipeline = (status: PipelineStatus = 'idle') => { + pipelineRunIdRef.current += 1; + setActiveStep(-1); + setCompletedSteps([]); + setPipelineStatus(status); + }; + + const loadDocuments = async () => { + setLoading(true); + try { + const response = await getDocumentList(); + const apiDocs: Doc[] = response.docs.map((doc) => ({ + id: parseInt(String(doc.id).replace('doc-', ''), 10) || Math.floor(Math.random() * 10000), + name: doc.name, + chunks: doc.chunks, + size: doc.size_text || `${((doc.chunks * 8) / 1024).toFixed(1)}MB`, + status: doc.status === 'indexed' ? 'indexed' : 'parsing', + docId: doc.id, + downloadUrl: doc.download_url, + })); + setDocs(apiDocs); + } catch (error) { + console.error('Failed to load documents:', error); + setDocs([]); + } finally { + setLoading(false); + } + }; + + const runPipelineFlow = async (runId: number, uploadPromise: Promise>>) => { + const guardedSetActiveStep = (step: number) => { + if (pipelineRunIdRef.current !== runId) return false; + setActiveStep(step); + return true; + }; + + const guardedCompleteStep = (step: number) => { + if (pipelineRunIdRef.current !== runId) return false; + setCompletedSteps((prev) => (prev.includes(step) ? prev : [...prev, step])); + return true; + }; + + for (let index = 0; index < PIPELINE_STEPS.length - 1; index += 1) { + if (!guardedSetActiveStep(index)) return; + await wait(STEP_DURATION_MS); + if (!guardedCompleteStep(index)) return; + } + + if (!guardedSetActiveStep(PIPELINE_STEPS.length - 1)) return; + await uploadPromise; + if (!guardedCompleteStep(PIPELINE_STEPS.length - 1)) return; + + await wait(240); + if (pipelineRunIdRef.current !== runId) return; + + setActiveStep(-1); + setPipelineStatus('completed'); + }; + + const handleFileSelect = async (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file || uploading) return; + + const runId = pipelineRunIdRef.current + 1; + pipelineRunIdRef.current = runId; + + setUploading(true); + setUploadFileName(file.name); + setActiveStep(-1); + setCompletedSteps([]); + setPipelineStatus('running'); + + const fileSizeMB = (file.size / (1024 * 1024)).toFixed(1); + const tempDocId = `pending-${Date.now()}`; + const newDoc: Doc = { + id: Date.now(), + name: file.name, + chunks: 0, + size: `${fileSizeMB}MB`, + status: 'parsing', + docId: tempDocId, + }; + + setDocs((prev) => [newDoc, ...prev]); + + const uploadPromise = uploadDocument(file); + void runPipelineFlow(runId, uploadPromise); + + try { + const uploadRes = await uploadPromise; + if (pipelineRunIdRef.current !== runId) return; + + setDocs((prev) => + prev.map((doc) => + doc.id === newDoc.id + ? { + ...doc, + status: 'indexed', + docId: uploadRes.doc_id, + chunks: uploadRes.num_chunks || doc.chunks, + summary: uploadRes.summary, + } + : doc + ) + ); + + setUploading(false); + setUploadFileName(''); + void loadDocuments(); + } catch (error) { + console.error('Upload failed:', error); + if (pipelineRunIdRef.current !== runId) return; + + setUploading(false); + setUploadFileName(''); + setDocs((prev) => prev.filter((doc) => doc.id !== newDoc.id)); + setPipelineStatus('error'); + setActiveStep(-1); + setCompletedSteps([]); + } finally { + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + } + }; + + const triggerFileUpload = () => { + if (uploading) return; + fileInputRef.current?.click(); + }; + + const handleDragOver = (event: React.DragEvent) => { + event.preventDefault(); + event.stopPropagation(); + }; + + const handleDrop = (event: React.DragEvent) => { + event.preventDefault(); + event.stopPropagation(); + + const files = event.dataTransfer.files; + if (files.length === 0 || uploading) return; + + const droppedFile = files[0]; + if (fileInputRef.current) { + const dataTransfer = new DataTransfer(); + dataTransfer.items.add(droppedFile); + fileInputRef.current.files = dataTransfer.files; + } + + void handleFileSelect({ + target: { files: [droppedFile] as unknown as FileList }, + } as React.ChangeEvent); + }; + + const runSearch = async (query: string) => { + if (!query.trim()) return; + setSearchLoading(true); + setSearchError(''); + try { + const response = await searchRegulations(query.trim(), 8); + setSearchResults(response.results); + } catch (error) { + console.error('Failed to search regulations:', error); + setSearchError(error instanceof Error ? error.message : '检索失败'); + setSearchResults([]); + } finally { + setSearchLoading(false); + } + }; + + const getStepStyle = (index: number) => { + const isActive = activeStep === index; + const isCompleted = completedSteps.includes(index); + + if (isActive) { + return { + background: theme.bgCard, + border: `2px solid ${theme.accent}`, + boxShadow: `0 0 12px ${theme.accent}40`, + }; + } + + if (isCompleted) { + return { + background: theme.bgCard, + border: `1px solid ${theme.green}`, + }; + } + + return { + background: theme.bgCard, + border: `1px solid ${theme.border}`, + }; + }; + + const getCheckStyle = (index: number) => { + const isActive = activeStep === index; + const isCompleted = completedSteps.includes(index); + + if (isActive) { + return { + background: theme.gradientAccent, + color: '#fff', + animation: 'pulse 0.6s infinite', + }; + } + + if (isCompleted) { + return { + background: theme.green, + color: '#fff', + }; + } + + return { + background: theme.bgHover, + color: theme.text3, + }; + }; + + const getPipelineHint = () => { + if (pipelineStatus === 'running') { + return activeStep >= 0 ? `${PIPELINE_STEPS[activeStep].name} · ${uploadFileName}` : `LOAD · ${uploadFileName}`; + } + if (pipelineStatus === 'completed') { + return 'PIPELINE COMPLETE'; + } + if (pipelineStatus === 'error') { + return 'PIPELINE FAILED'; + } + return 'WAITING FOR UPLOAD'; + }; + + return ( + + + +
+

+ UPLOAD +

+ + + +
+
+ {uploading ? ( +
+ + + +
+ ) : ( + + + + + )} +
+ +
+ {uploading ? '正在上传并启动处理链路...' : '拖拽文件或点击上传'} +
+
+ {uploading ? uploadFileName : 'PDF · DOCX · DOC · MAX 100MB'} +
+
+
+ +
+

+ PROCESSING PIPELINE +

+ +
+ {getPipelineHint()} +
+ +
+ {PIPELINE_STEPS.map((step, index) => { + const stepStyle = getStepStyle(index); + const checkStyle = getCheckStyle(index); + const arrowActive = activeStep > index || completedSteps.includes(index); + const isCompleted = completedSteps.includes(index); + const isActive = activeStep === index; + + return ( +
+
+ {isActive ? step.name : isCompleted ? '✓' : step.name} +
+ +
+ {step.name} +
+
+ {isCompleted ? 'DONE' : isActive ? 'RUNNING' : 'PENDING'} +
+ + {index < PIPELINE_STEPS.length - 1 && ( +
+ → +
+ )} +
+ ); + })} +
+
+ +
+
+

+ 文档管理清单 ({loading ? '...' : docs.length}) +

+ +
+ {docs.map((doc) => ( +
+
+
+ + + + +
+ +
+
{doc.name}
+
+ {doc.size} + {doc.docId ? ` · ${doc.docId}` : ''} +
+
+
+ +
+ {doc.downloadUrl && ( + + 下载 + + )} +
+ {doc.status === 'parsing' ? '处理中...' : `${doc.chunks} chunks`} +
+
+
+ ))} +
+
+ +
+

+ 文档管理内法规检索 +

+ +
+ setSearchQuery(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') { + void runSearch(searchQuery); + } + }} + placeholder="输入法规关键词、条款或制度主题" + style={{ + flex: 1, + padding: 12, + fontSize: 14, + background: theme.bgCard, + border: `1px solid ${theme.border}`, + borderRadius: 8, + color: theme.text, + outline: 'none', + }} + /> + +
+ + {searchError && ( +
+ {searchError} +
+ )} + +
+ {searchResults.map((item) => ( +
+
+
{item.file}
+
+ {(item.score * 100).toFixed(1)}% +
+
+ +
+ {item.clause} + {item.tags.length > 0 ? ` · ${item.tags.join(' · ')}` : ''} +
+ +
{item.content}
+
+ ))} + + {!searchLoading && searchResults.length === 0 && ( +
+ 暂无检索结果 +
+ )} +
+
+
+
+ ); +}; diff --git a/frontend/src/pages/Docs/index.ts b/frontend/src/pages/Docs/index.ts new file mode 100644 index 0000000..ee19705 --- /dev/null +++ b/frontend/src/pages/Docs/index.ts @@ -0,0 +1 @@ +export { DocsPage } from './DocsPage'; \ No newline at end of file diff --git a/frontend/src/pages/RagChat/RagChatPage.tsx b/frontend/src/pages/RagChat/RagChatPage.tsx new file mode 100644 index 0000000..7aedf9d --- /dev/null +++ b/frontend/src/pages/RagChat/RagChatPage.tsx @@ -0,0 +1,700 @@ +import React, { useState, useEffect } from 'react'; +import { useTheme } from '../../contexts/ThemeContext'; +import type { ChatMessage, RetrievalData } from '../../types'; +import { getQuickQuestions, ragChat } from '../../api/rag'; + +const ragQuickQuestionsDefault = [ + '电动自行车上路需要什么条件?', + '驾驶证如何申请?', + '超速行驶如何处罚?', + '车辆年检有哪些规定?', + '电动汽车电池安全标准?', + '正面碰撞测试要求?', + 'AEB系统测试标准?', + '高速公路安全距离?', +]; + +export const RagChatPage: React.FC = () => { + const { theme } = useTheme(); + const [messages, setMessages] = useState([]); + const [retrievals, setRetrievals] = useState([]); + const [input, setInput] = useState(''); + const [loading, setLoading] = useState(false); + const [showClearConfirm, setShowClearConfirm] = useState(false); + const [selectedRetrieval, setSelectedRetrieval] = useState(null); + const [quickQuestions, setQuickQuestions] = useState(ragQuickQuestionsDefault); + + useEffect(() => { + void loadQuickQuestions(); + }, []); + + const loadQuickQuestions = async () => { + try { + const response = await getQuickQuestions(); + setQuickQuestions(response.questions.map(q => q.question)); + } catch (error) { + console.error('Failed to load quick questions:', error); + } + }; + + const sendMessage = (text: string) => { + if (!text.trim()) return; + + const userMsg = { id: Date.now(), role: 'user' as const, content: text }; + setMessages((prev) => [...prev, userMsg]); + setInput(''); + setLoading(true); + setRetrievals([]); + + let currentResponse = ''; + + void ragChat( + text, + 5, + (data: unknown) => { + const sseData = data as { + type: string; + text?: string; + docs?: Array<{ + id: string; + score: number; + preview: string; + doc_name: string; + clause: string; + doc_id?: string; + download_url?: string; + }>; + }; + + if (sseData.type === 'retrieved' && sseData.docs) { + const retrievedDocs: RetrievalData[] = sseData.docs.map(d => ({ + id: parseInt(d.id.replace('chunk-', ''), 10) || 1, + file: d.doc_name, + clause: d.clause, + score: d.score, + content: d.preview, + docId: d.doc_id, + downloadUrl: d.download_url, + })); + setRetrievals(retrievedDocs); + } else if (sseData.type === 'chunk' && sseData.text) { + currentResponse += sseData.text; + setMessages((prev) => { + const lastMsg = prev[prev.length - 1]; + if (lastMsg?.role === 'assistant') { + return [...prev.slice(0, -1), { ...lastMsg, content: currentResponse }]; + } + return [...prev, { id: Date.now() + 1, role: 'assistant' as const, content: currentResponse }]; + }); + } else if (sseData.type === 'done') { + setLoading(false); + } else if (sseData.type === 'error') { + setLoading(false); + } + }, + (error: Error) => { + console.error('RAG chat error:', error); + setLoading(false); + setMessages((prev) => [ + ...prev, + { id: Date.now() + 1, role: 'assistant' as const, content: '抱歉,连接服务器时出错,请稍后再试。' } + ]); + }, + () => { + setLoading(false); + } + ); + }; + + const clearMessages = () => { + setMessages([]); + setRetrievals([]); + setShowClearConfirm(false); + }; + + const regenerateLastAnswer = () => { + if (messages.length < 2) return; + const lastUserMsg = messages.filter((m) => m.role === 'user').pop(); + if (!lastUserMsg) return; + + setLoading(true); + setMessages((prev) => [...prev.slice(0, -1)]); + setRetrievals([]); + + let currentResponse = ''; + + void ragChat( + lastUserMsg.content, + 5, + (data: unknown) => { + const sseData = data as { + type: string; + text?: string; + docs?: Array<{ + id: string; + score: number; + preview: string; + doc_name: string; + clause: string; + doc_id?: string; + download_url?: string; + }>; + }; + + if (sseData.type === 'retrieved' && sseData.docs) { + const retrievedDocs: RetrievalData[] = sseData.docs.map(d => ({ + id: parseInt(d.id.replace('chunk-', ''), 10) || 1, + file: d.doc_name, + clause: d.clause, + score: d.score, + content: d.preview, + docId: d.doc_id, + downloadUrl: d.download_url, + })); + setRetrievals(retrievedDocs); + } else if (sseData.type === 'chunk' && sseData.text) { + currentResponse += sseData.text; + setMessages((prev) => { + const lastMsg = prev[prev.length - 1]; + if (lastMsg?.role === 'assistant') { + return [...prev.slice(0, -1), { ...lastMsg, content: currentResponse }]; + } + return [...prev, { id: Date.now() + 1, role: 'assistant' as const, content: currentResponse }]; + }); + } else if (sseData.type === 'done') { + setLoading(false); + } + }, + (error: Error) => { + console.error('RAG chat error:', error); + setLoading(false); + setMessages((prev) => [ + ...prev, + { id: Date.now() + 1, role: 'assistant' as const, content: '抱歉,连接服务器时出错,请稍后再试。' } + ]); + }, + () => { + setLoading(false); + } + ); + }; + + return ( +
+
+
+ {messages.length === 0 ? ( +
+
+ + + +
+
开始法规对话
+
选择快捷问题或输入您的问题
+
+ ) : ( + messages.map(msg => ( +
+ {msg.role === 'assistant' && ( +
+ + + +
+ )} +
+ {msg.content} + {msg.role === 'assistant' && msg.retrievalIds && msg.retrievalIds.length > 0 && ( +
+ + + + + {msg.retrievalIds.length} 个法规引用 + +
+ )} +
+
+ )) + )} + {loading && ( +
+
+ + + +
+
+
+ 检索中... +
+
+ )} +
+ +
+
+ {quickQuestions.map(q => ( + + ))} +
+ +
+ setInput(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && sendMessage(input)} + placeholder="输入法规问题..." + style={{ + flex: 1, + padding: 12, + fontSize: 14, + background: theme.bgCard, + border: `1px solid ${theme.border}`, + borderRadius: 8, + color: theme.text, + outline: 'none', + }} + /> + + {messages.length > 0 && ( + + )} + {messages.filter(m => m.role === 'assistant').length > 0 && ( + + )} +
+
+
+ +
+
+
+ + + +
+ + RETRIEVED FRAGMENTS + + {retrievals.length > 0 && ( + {retrievals.length} + )} +
+ +
+ {retrievals.length > 0 ? ( +
+ {retrievals.map((r, i) => ( +
setSelectedRetrieval(r)} + style={{ + padding: 16, + background: theme.bgHover, + borderRadius: 10, + border: `1px solid ${theme.border}`, + cursor: 'pointer', + position: 'relative', + }} + > +
+
+ +
{r.file}
+
+ {r.clause}{r.docId ? ` · ${r.docId}` : ''} +
+
{r.content}
+
+
+ ))} +
+ ) : ( +
+
+ + + +
+
对话后显示相关法规
+
+ )} +
+
+ + {showClearConfirm && ( +
+
+
确定清空对话?
+
此操作不可恢复
+
+ + +
+
+
+ )} + + {selectedRetrieval && ( +
setSelectedRetrieval(null)} + style={{ + position: 'fixed', + top: 0, + left: 0, + right: 0, + bottom: 0, + background: 'rgba(0,0,0,0.6)', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + zIndex: 1000, + }} + > +
e.stopPropagation()} + style={{ + width: 520, + maxWidth: '90%', + maxHeight: '80%', + padding: 24, + background: theme.bgCard, + borderRadius: 16, + border: `1px solid ${theme.accent}`, + boxShadow: '0 8px 32px rgba(0,0,0,0.3)', + }} + > +
+
+
+ + {(selectedRetrieval.score * 100).toFixed(0)}% + +
+ {selectedRetrieval.file} + {selectedRetrieval.downloadUrl && ( + + 下载关联文档 + + )} +
+ +
+
+ {selectedRetrieval.clause} + {selectedRetrieval.docId && ( + {selectedRetrieval.docId} + )} +
+
{selectedRetrieval.content}
+
+
+ )} +
+ ); +}; diff --git a/frontend/src/pages/RagChat/index.ts b/frontend/src/pages/RagChat/index.ts new file mode 100644 index 0000000..f63fd6e --- /dev/null +++ b/frontend/src/pages/RagChat/index.ts @@ -0,0 +1 @@ +export { RagChatPage } from './RagChatPage'; \ No newline at end of file diff --git a/frontend/src/pages/Status/StatusPage.tsx b/frontend/src/pages/Status/StatusPage.tsx new file mode 100644 index 0000000..258d0db --- /dev/null +++ b/frontend/src/pages/Status/StatusPage.tsx @@ -0,0 +1,257 @@ +import React, { useState, useEffect } from 'react'; +import { useTheme } from '../../contexts/ThemeContext'; +import { Content } from '../../components/layout/Content'; +import { TPattern } from '../../components/common/TPattern'; +import { getSystemStats, getSystemConfig, type SystemStats, type SystemConfig } from '../../api/status'; +import { getDocumentList, type DocInfo } from '../../api/docs'; + +const StatsCard = ({ label, value, accent = false }: { + label: string; + value: number; + accent?: boolean; +}) => { + const { theme, isDark } = useTheme(); + + return ( +
+
+
{label}
+
{value}
+
+ ); +}; + +export const StatusPage: React.FC = () => { + const { theme, isDark } = useTheme(); + const [stats, setStats] = useState({ docs: 0, chunks: 0, vectors: 0, segments: 0 }); + const [config, setConfig] = useState(null); + const [docs, setDocs] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + loadData(); + }, []); + + const loadData = async () => { + setLoading(true); + try { + const [statsRes, configRes, docsRes] = await Promise.all([ + getSystemStats(), + getSystemConfig(), + getDocumentList(), + ]); + setStats(statsRes); + setConfig(configRes); + setDocs(docsRes.docs); + } catch (error) { + console.error('Failed to load status data:', error); + } + setLoading(false); + }; + + // 计算总chunks + const totalChunks = docs.reduce((sum, d) => sum + d.chunks, 0); + + return ( + + + {/* System Stats */} +
+
+ + + + +
+
+ + {/* Configuration */} +
+

SYSTEM CONFIGURATION

+ + {/* ChromaDB Config */} +
+
VECTOR DATABASE
+
+ {[ + ['Vector DB', 'Milvus'], + ['Host', config?.milvus.host || 'localhost'], + ['Port', String(config?.milvus.port || 19530)], + ].map(([k, v]) => ( +
+ {k} + {v} +
+ ))} +
+
+ + {/* LLM Config */} +
+
LLM CONFIGURATION
+
+ {[ + ['LLM Model', config?.llm.model || 'qwen-max'], + ['Embedding Model', config?.embedding.model || 'text-embedding-v3'], + ['Embedding Dim', String(config?.embedding.dimension || 1536)], + ['Temperature', '0.1'], + ].map(([k, v]) => ( +
+ {k} + {v} +
+ ))} +
+
+ + {/* Retrieval Config */} +
+
RETRIEVAL CONFIGURATION (HYBRID)
+
+ {[ + ['Vector Top-K', String(config?.retrieval.vector_top_k || 10)], + ['BM25 Top-K', '10'], + ['Final Top-K', String(config?.retrieval.final_top_k || 5)], + ].map(([k, v]) => ( +
+ {k} + {v} +
+ ))} +
+
+ + {/* Chunk Config */} +
+
CHUNK CONFIGURATION
+
+ {[ + ['Chunk Size', '800'], + ['Chunk Overlap', '100'], + ].map(([k, v]) => ( +
+ {k} + {v} +
+ ))} +
+
+
+ + {/* Indexed Docs Overview */} +
+

DOCUMENT INDEX

+ {docs.map(d => ( +
+
+ {d.name} + {(d.chunks * 8 / 1024).toFixed(1)}MB +
+
+ {d.chunks} chunks +
+ INDEXED +
+
+
+ ))} +
+
+ ); +}; \ No newline at end of file diff --git a/frontend/src/pages/Status/index.ts b/frontend/src/pages/Status/index.ts new file mode 100644 index 0000000..b6c8ff3 --- /dev/null +++ b/frontend/src/pages/Status/index.ts @@ -0,0 +1 @@ +export { StatusPage } from './StatusPage'; \ No newline at end of file diff --git a/frontend/src/styles/globals.css b/frontend/src/styles/globals.css new file mode 100644 index 0000000..cdb7df9 --- /dev/null +++ b/frontend/src/styles/globals.css @@ -0,0 +1,274 @@ +@import url('https://fonts.googleapis.com/css2?family=TeleNeo:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap'); + +@tailwind base; +@tailwind components; +@tailwind utilities; + +/* Light mode (default) */ +:root { + --t-bg: #ffffff; + --t-bg-card: #ffffff; + --t-bg-hover: #f8f8fc; + --t-bg-elevated: #fafafa; + --t-border: #e8e8f0; + --t-border-light: #d0d0d8; + --t-text: #1a1a2e; + --t-text2: #4a4a5a; + --t-text3: #7a7a8a; + --t-green: #00b89c; + --t-orange: #ff7700; + --t-accent-glow: rgba(226,0,116,0.08); + --t-pattern-opacity: 0.04; +} + +/* Dark mode */ +.dark { + --t-bg: #0a0a12; + --t-bg-card: #12121f; + --t-bg-hover: #1a1a2e; + --t-bg-elevated: #1e1e30; + --t-border: #2a2a40; + --t-border-light: #4a4a60; + --t-text: #ffffff; + --t-text2: #c0c0d0; + --t-text3: #9a9aaa; + --t-green: #00d4aa; + --t-orange: #ff8800; + --t-accent-glow: rgba(226,0,116,0.12); + --t-pattern-opacity: 0.03; +} + +/* Base styles */ +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html, body, #root { + height: 100%; +} + +body { + font-family: 'TeleNeo', 'Segoe UI', system-ui, sans-serif; + overflow-x: hidden; + font-feature-settings: 'kern' 1; + color: #1a1a2e; + background: #ffffff; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +/* Dark mode body */ +.dark body, +body.dark-mode { + color: #fff; + background: #0a0a12; +} + +/* Selection */ +::selection { + background: rgba(226, 0, 116, 0.3); +} + +/* Scrollbar */ +::-webkit-scrollbar { + width: 8px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: linear-gradient(180deg, #e20074, #be0060); + border-radius: 4px; +} + +/* Monospace font class */ +.mono { + font-family: 'JetBrains Mono', monospace; +} + +/* Smooth transitions for theme */ +* { + transition: background-color 0.25s ease, border-color 0.25s ease, color 0.25s ease; +} + +/* Exclude buttons from auto-transition for custom hover effects */ +button, input { + transition: none; +} + +/* T-Systems Button Style */ +.t-btn, +.t-btn:hover { + transition: all 0.3s ease; +} + +.t-btn { + background: linear-gradient(135deg, #e20074 0%, #be0060 100%); +} + +.t-btn:hover { + background: linear-gradient(135deg, #f0208a 0%, #d01070 100%); + box-shadow: 0 4px 20px rgba(226,0,116,0.4); + transform: translateY(-1px); +} + +/* T-Systems Gradient Background */ +.t-gradient-bg { + background: linear-gradient(135deg, #0a0a12 0%, #1a1a2e 50%, #0a0a12 100%); +} + +/* Magenta Glow */ +.magenta-glow { + box-shadow: 0 0 20px rgba(226,0,116,0.15), 0 0 40px rgba(226,0,116,0.05); +} + +/* Card gradient for dark mode */ +.dark .t-card-gradient { + background: linear-gradient(180deg, #12121f, #0a0a12); +} + +/* Card gradient for light mode */ +:not(.dark) .t-card-gradient { + background: linear-gradient(180deg, #ffffff, #fafafa); +} + +/* Light mode shadow for cards */ +:not(.dark) .t-card-shadow { + box-shadow: 0 2px 8px rgba(226,0,116,0.04); +} + +:not(.dark) .t-card-shadow-lg { + box-shadow: 0 4px 16px rgba(226,0,116,0.08); +} + +/* Accent glow */ +.t-accent-glow { + box-shadow: 0 0 12px rgba(226,0,116,0.5), 0 0 24px rgba(226,0,116,0.2); +} + +/* High risk glow */ +.t-risk-high-glow { + box-shadow: 0 0 8px rgba(255,68,68,0.3); +} + +/* Medium risk glow */ +.t-risk-medium-glow { + box-shadow: 0 0 6px rgba(255,136,0,0.2); +} + +/* Low risk glow */ +.t-risk-low-glow { + box-shadow: 0 0 4px rgba(0,212,170,0.15); +} + +/* Pulse glow animation */ +@keyframes pulse-glow { + 0%, 100% { + box-shadow: 0 0 12px rgba(226,0,116,0.5), 0 0 24px rgba(226,0,116,0.2); + } + 50% { + box-shadow: 0 0 16px rgba(226,0,116,0.7), 0 0 32px rgba(226,0,116,0.3); + } +} + +.animate-pulse-glow { + animation: pulse-glow 2s infinite; +} + +@keyframes pulse-glow { + 0%, 100% { + box-shadow: 0 0 12px rgba(226,0,116,0.5), 0 0 24px rgba(226,0,116,0.2); + } + 50% { + box-shadow: 0 0 16px rgba(226,0,116,0.7), 0 0 32px rgba(226,0,116,0.3); + } +} + +/* Slide up animation */ +@keyframes slideUp { + 0% { + opacity: 0; + transform: translateY(10px); + } + 100% { + opacity: 1; + transform: translateY(0); + } +} + +.animate-slide-up { + animation: slideUp 0.3s ease; +} + +/* Slide in animation */ +@keyframes slideIn { + 0% { + transform: translateX(100%); + } + 100% { + transform: translateX(0); + } +} + +.animate-slide-in { + animation: slideIn 0.3s ease-out forwards; +} + +/* Slide out animation */ +@keyframes slideOut { + 0% { + transform: translateX(0); + } + 100% { + transform: translateX(100%); + } +} + +.animate-slide-out { + animation: slideOut 0.3s ease-in forwards; +} + +/* Pulse animation for processing steps */ +@keyframes pulse { + 0%, 100% { + opacity: 1; + transform: scale(1); + } + 50% { + opacity: 0.8; + transform: scale(0.95); + } +} + +.animate-pulse { + animation: pulse 0.6s ease-in-out infinite; +} + +/* Spin animation for loading icons */ +@keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +.animate-spin { + animation: spin 1s linear infinite; +} + +/* Custom utility classes */ +@layer utilities { + .gradient-accent { + background: linear-gradient(135deg, #e20074 0%, #be0060 100%); + } + + .gradient-accent-hover { + background: linear-gradient(135deg, #f0208a 0%, #d01070 100%); + } +} \ No newline at end of file diff --git a/frontend/src/types/compliance.ts b/frontend/src/types/compliance.ts new file mode 100644 index 0000000..462854f --- /dev/null +++ b/frontend/src/types/compliance.ts @@ -0,0 +1,58 @@ +// Compliance types +export type RiskLevel = 'high' | 'medium' | 'low'; +export type ComplianceStatus = 'pass' | 'warning' | 'fail'; +export type RegulationCategory = 'high' | 'medium' | 'low'; + +export interface Regulation { + id: number; + name: string; + clause: string; + score: number; + matchKeyword: string; + category: RegulationCategory; + fullContent: string; +} + +export interface ComplianceChunk { + id: number; + index: number; + intent: string; + startPos: number; + endPos: number; + content: string; + regulations: Regulation[]; +} + +export interface SegmentRisk { + chunkId: number; + level: RiskLevel; + score: number; + highRegsCount: number; + riskRegs: number; +} + +export interface RiskDashboardData { + score: number; + highRiskCount: number; + mediumRiskCount: number; + lowRiskCount: number; + needFixSegments: number; + status: ComplianceStatus; + statusLabel: string; + segmentRisks: SegmentRisk[]; +} + +export interface PriorityAction { + id: number; + regulation: string; + issue: string; + suggestion: string; + chunkId: number; + severity: 'high' | 'medium'; +} + +// Upload document type +export interface UploadedDoc { + name: string; + size: string; +} \ No newline at end of file diff --git a/frontend/src/types/doc.ts b/frontend/src/types/doc.ts new file mode 100644 index 0000000..9725053 --- /dev/null +++ b/frontend/src/types/doc.ts @@ -0,0 +1,35 @@ +export interface Doc { + id: number; + name: string; + chunks: number; + size: string; + status: 'indexed' | 'parsing' | 'pending'; + docId?: string; + downloadUrl?: string; + summary?: string; +} + +export interface SearchResult { + id: number; + score: number; + law: string; + preview: string; + source: string; +} + +export interface ChatMessage { + id: number; + role: 'user' | 'assistant'; + content: string; + retrievalIds?: number[]; +} + +export interface RetrievalData { + id: number; + file: string; + clause: string; + score: number; + content: string; + docId?: string; + downloadUrl?: string; +} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts new file mode 100644 index 0000000..5a4a4f7 --- /dev/null +++ b/frontend/src/types/index.ts @@ -0,0 +1,4 @@ +// Re-export all types +export * from './theme'; +export * from './doc'; +export * from './compliance'; \ No newline at end of file diff --git a/frontend/src/types/theme.ts b/frontend/src/types/theme.ts new file mode 100644 index 0000000..7aa698c --- /dev/null +++ b/frontend/src/types/theme.ts @@ -0,0 +1,56 @@ +// Theme types +export type ThemeMode = 'dark' | 'light'; + +export interface ThemeColors { + bg: string; + bgCard: string; + bgHover: string; + bgElevated: string; + border: string; + borderLight: string; + text: string; + text2: string; + text3: string; + accent: string; + accentDark: string; + accentLight: string; + green: string; + orange: string; + gradientAccent: string; +} + +export const darkTheme: ThemeColors = { + bg: '#0a0a12', + bgCard: '#12121f', + bgHover: '#1a1a2e', + bgElevated: '#1e1e30', + border: '#2a2a40', + borderLight: '#4a4a60', + text: '#fff', + text2: '#c0c0d0', + text3: '#9a9aaa', + accent: '#e20074', + accentDark: '#be0060', + accentLight: '#f04090', + green: '#00d4aa', + orange: '#ff8800', + gradientAccent: 'linear-gradient(135deg, #e20074, #be0060)', +}; + +export const lightTheme: ThemeColors = { + bg: '#ffffff', + bgCard: '#ffffff', + bgHover: '#f8f8fc', + bgElevated: '#fafafa', + border: '#e8e8f0', + borderLight: '#d0d0d8', + text: '#1a1a2e', + text2: '#4a4a5a', + text3: '#7a7a8a', + accent: '#e20074', + accentDark: '#be0060', + accentLight: '#f04090', + green: '#00b89c', + orange: '#ff7700', + gradientAccent: 'linear-gradient(135deg, #e20074, #be0060)', +}; \ No newline at end of file diff --git a/frontend/start.sh b/frontend/start.sh new file mode 100644 index 0000000..c70ff74 --- /dev/null +++ b/frontend/start.sh @@ -0,0 +1,10 @@ +#!/bin/bash + +# 启动开发服务器 +# 端口: 8000 +# 监听: 0.0.0.0 + +cd "$(dirname "$0")" + +echo "Starting dev server on http://0.0.0.0:8001" +npx vite --host 0.0.0.0 --port 8001 \ No newline at end of file diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js new file mode 100644 index 0000000..f70c87f --- /dev/null +++ b/frontend/tailwind.config.js @@ -0,0 +1,71 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: [ + "./index.html", + "./src/**/*.{js,ts,jsx,tsx}", + ], + darkMode: 'class', + theme: { + extend: { + colors: { + // 动态主题色(通过CSS变量) + 't-bg': 'var(--t-bg)', + 't-bg-card': 'var(--t-bg-card)', + 't-bg-hover': 'var(--t-bg-hover)', + 't-bg-elevated': 'var(--t-bg-elevated)', + 't-border': 'var(--t-border)', + 't-border-light': 'var(--t-border-light)', + 't-text': 'var(--t-text)', + 't-text2': 'var(--t-text2)', + 't-text3': 'var(--t-text3)', + // 固定品牌色 + 't-accent': '#e20074', + 't-accent-dark': '#be0060', + 't-accent-light': '#f04090', + // 动态状态色 + 't-green': 'var(--t-green)', + 't-orange': 'var(--t-orange)', + 't-red': '#ff4444', + }, + fontFamily: { + 'tele': ['TeleNeo', 'Segoe UI', 'system-ui', 'sans-serif'], + 'mono': ['JetBrains Mono', 'monospace'], + }, + boxShadow: { + 't-glow': '0 0 20px rgba(226,0,116,0.15), 0 0 40px rgba(226,0,116,0.05)', + 't-card': '0 2px 8px rgba(226,0,116,0.04)', + 't-card-hover': '0 4px 16px rgba(226,0,116,0.08)', + 't-accent': '0 4px 20px rgba(226,0,116,0.4)', + }, + keyframes: { + 'pulse-glow': { + '0%, 100%': { + boxShadow: '0 0 12px rgba(226,0,116,0.5), 0 0 24px rgba(226,0,116,0.2)' + }, + '50%': { + boxShadow: '0 0 16px rgba(226,0,116,0.7), 0 0 32px rgba(226,0,116,0.3)' + }, + }, + 'slideUp': { + '0%': { opacity: '0', transform: 'translateY(10px)' }, + '100%': { opacity: '1', transform: 'translateY(0)' }, + }, + 'slideIn': { + '0%': { transform: 'translateX(100%)' }, + '100%': { transform: 'translateX(0)' }, + }, + 'slideOut': { + '0%': { transform: 'translateX(0)' }, + '100%': { transform: 'translateX(100%)' }, + }, + }, + animation: { + 'pulse-glow': 'pulse-glow 2s infinite', + 'slide-up': 'slideUp 0.3s ease', + 'slide-in': 'slideIn 0.3s ease-out', + 'slide-out': 'slideOut 0.3s ease-in', + }, + }, + }, + plugins: [], +} \ No newline at end of file diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json new file mode 100644 index 0000000..7f42e5f --- /dev/null +++ b/frontend/tsconfig.app.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023", "DOM"], + "module": "esnext", + "types": ["vite/client"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..d3c52ea --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023"], + "module": "esnext", + "types": ["node"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..05ae1c1 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [react()], + server: { + host: '0.0.0.0', + port: 5173, + proxy: { + '/api': { + target: 'http://localhost:8000', + changeOrigin: true, + }, + }, + }, +}) diff --git a/logs/api.log b/logs/api.log new file mode 100644 index 0000000..33d9819 --- /dev/null +++ b/logs/api.log @@ -0,0 +1,1487 @@ +INFO: Will watch for changes in these directories: ['C:\\Projects\\AIProjects\\AIRegulations\\Demo-glm'] +INFO: Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit) +INFO: Started reloader process [108380] using WatchFiles +Process SpawnProcess-1: +Traceback (most recent call last): + File "C:\software\Python312\Lib\multiprocessing\process.py", line 314, in _bootstrap + self.run() + File "C:\software\Python312\Lib\multiprocessing\process.py", line 108, in run + self._target(*self._args, **self._kwargs) + File "C:\software\Python312\Lib\site-packages\uvicorn\_subprocess.py", line 80, in subprocess_started + target(sockets=sockets) + File "C:\software\Python312\Lib\site-packages\uvicorn\server.py", line 67, in run + return asyncio.run(self.serve(sockets=sockets)) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\asyncio\runners.py", line 194, in run + return runner.run(main) + ^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\asyncio\runners.py", line 118, in run + return self._loop.run_until_complete(task) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\asyncio\base_events.py", line 664, in run_until_complete + return future.result() + ^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\site-packages\uvicorn\server.py", line 71, in serve + await self._serve(sockets) + File "C:\software\Python312\Lib\site-packages\uvicorn\server.py", line 78, in _serve + config.load() + File "C:\software\Python312\Lib\site-packages\uvicorn\config.py", line 436, in load + self.loaded_app = import_from_string(self.app) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\site-packages\uvicorn\importer.py", line 22, in import_from_string + raise exc from None + File "C:\software\Python312\Lib\site-packages\uvicorn\importer.py", line 19, in import_from_string + module = importlib.import_module(module_str) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\importlib\__init__.py", line 90, in import_module + return _bootstrap._gcd_import(name[level:], package, level) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "", line 1381, in _gcd_import + File "", line 1354, in _find_and_load + File "", line 1325, in _find_and_load_unlocked + File "", line 929, in _load_unlocked + File "", line 994, in exec_module + File "", line 488, in _call_with_frames_removed + File "C:\Projects\AIProjects\AIRegulations\Demo-glm\src\api\main.py", line 8, in + from loguru import logger +ModuleNotFoundError: No module named 'loguru' +WARNING: WatchFiles detected changes in 'src\config\settings.py'. Reloading... + Process SpawnProcess-2: +Traceback (most recent call last): + File "C:\software\Python312\Lib\multiprocessing\process.py", line 314, in _bootstrap + self.run() + File "C:\software\Python312\Lib\multiprocessing\process.py", line 108, in run + self._target(*self._args, **self._kwargs) + File "C:\software\Python312\Lib\site-packages\uvicorn\_subprocess.py", line 80, in subprocess_started + target(sockets=sockets) + File "C:\software\Python312\Lib\site-packages\uvicorn\server.py", line 67, in run + return asyncio.run(self.serve(sockets=sockets)) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\asyncio\runners.py", line 194, in run + return runner.run(main) + ^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\asyncio\runners.py", line 118, in run + return self._loop.run_until_complete(task) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\asyncio\base_events.py", line 664, in run_until_complete + return future.result() + ^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\site-packages\uvicorn\server.py", line 71, in serve + await self._serve(sockets) + File "C:\software\Python312\Lib\site-packages\uvicorn\server.py", line 78, in _serve + config.load() + File "C:\software\Python312\Lib\site-packages\uvicorn\config.py", line 436, in load + self.loaded_app = import_from_string(self.app) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\site-packages\uvicorn\importer.py", line 22, in import_from_string + raise exc from None + File "C:\software\Python312\Lib\site-packages\uvicorn\importer.py", line 19, in import_from_string + module = importlib.import_module(module_str) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\importlib\__init__.py", line 90, in import_module + return _bootstrap._gcd_import(name[level:], package, level) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "", line 1381, in _gcd_import + File "", line 1354, in _find_and_load + File "", line 1325, in _find_and_load_unlocked + File "", line 929, in _load_unlocked + File "", line 994, in exec_module + File "", line 488, in _call_with_frames_removed + File "C:\Projects\AIProjects\AIRegulations\Demo-glm\src\api\main.py", line 8, in + from loguru import logger +ModuleNotFoundError: No module named 'loguru' +WARNING: WatchFiles detected changes in 'src\api\routes\documents.py'. Reloading... + WARNING: WatchFiles detected changes in 'src\api\routes\documents.py'. Reloading... + Process SpawnProcess-3: +Traceback (most recent call last): + File "C:\software\Python312\Lib\multiprocessing\process.py", line 314, in _bootstrap + self.run() + File "C:\software\Python312\Lib\multiprocessing\process.py", line 108, in run + self._target(*self._args, **self._kwargs) + File "C:\software\Python312\Lib\site-packages\uvicorn\_subprocess.py", line 80, in subprocess_started + target(sockets=sockets) + File "C:\software\Python312\Lib\site-packages\uvicorn\server.py", line 67, in run + return asyncio.run(self.serve(sockets=sockets)) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\asyncio\runners.py", line 194, in run + return runner.run(main) + ^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\asyncio\runners.py", line 118, in run + return self._loop.run_until_complete(task) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\asyncio\base_events.py", line 664, in run_until_complete + return future.result() + ^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\site-packages\uvicorn\server.py", line 71, in serve + await self._serve(sockets) + File "C:\software\Python312\Lib\site-packages\uvicorn\server.py", line 78, in _serve + config.load() + File "C:\software\Python312\Lib\site-packages\uvicorn\config.py", line 436, in load + self.loaded_app = import_from_string(self.app) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\site-packages\uvicorn\importer.py", line 22, in import_from_string + raise exc from None + File "C:\software\Python312\Lib\site-packages\uvicorn\importer.py", line 19, in import_from_string + module = importlib.import_module(module_str) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\importlib\__init__.py", line 90, in import_module + return _bootstrap._gcd_import(name[level:], package, level) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "", line 1381, in _gcd_import + File "", line 1354, in _find_and_load + File "", line 1325, in _find_and_load_unlocked + File "", line 929, in _load_unlocked + File "", line 994, in exec_module + File "", line 488, in _call_with_frames_removed + File "C:\Projects\AIProjects\AIRegulations\Demo-glm\src\api\main.py", line 8, in + from loguru import logger +ModuleNotFoundError: No module named 'loguru' +Process SpawnProcess-4: +Traceback (most recent call last): + File "C:\software\Python312\Lib\multiprocessing\process.py", line 314, in _bootstrap + self.run() + File "C:\software\Python312\Lib\multiprocessing\process.py", line 108, in run + self._target(*self._args, **self._kwargs) + File "C:\software\Python312\Lib\site-packages\uvicorn\_subprocess.py", line 80, in subprocess_started + target(sockets=sockets) + File "C:\software\Python312\Lib\site-packages\uvicorn\server.py", line 67, in run + return asyncio.run(self.serve(sockets=sockets)) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\asyncio\runners.py", line 194, in run + return runner.run(main) + ^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\asyncio\runners.py", line 118, in run + return self._loop.run_until_complete(task) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\asyncio\base_events.py", line 664, in run_until_complete + return future.result() + ^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\site-packages\uvicorn\server.py", line 71, in serve + await self._serve(sockets) + File "C:\software\Python312\Lib\site-packages\uvicorn\server.py", line 78, in _serve + config.load() + File "C:\software\Python312\Lib\site-packages\uvicorn\config.py", line 436, in load + self.loaded_app = import_from_string(self.app) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\site-packages\uvicorn\importer.py", line 22, in import_from_string + raise exc from None + File "C:\software\Python312\Lib\site-packages\uvicorn\importer.py", line 19, in import_from_string + module = importlib.import_module(module_str) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\importlib\__init__.py", line 90, in import_module + return _bootstrap._gcd_import(name[level:], package, level) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "", line 1381, in _gcd_import + File "", line 1354, in _find_and_load + File "", line 1325, in _find_and_load_unlocked + File "", line 929, in _load_unlocked + File "", line 994, in exec_module + File "", line 488, in _call_with_frames_removed + File "C:\Projects\AIProjects\AIRegulations\Demo-glm\src\api\main.py", line 8, in + from loguru import logger +ModuleNotFoundError: No module named 'loguru' +WARNING: WatchFiles detected changes in 'src\api\routes\documents.py'. Reloading... + Process SpawnProcess-5: +Traceback (most recent call last): + File "C:\software\Python312\Lib\multiprocessing\process.py", line 314, in _bootstrap + self.run() + File "C:\software\Python312\Lib\multiprocessing\process.py", line 108, in run + self._target(*self._args, **self._kwargs) + File "C:\software\Python312\Lib\site-packages\uvicorn\_subprocess.py", line 80, in subprocess_started + target(sockets=sockets) + File "C:\software\Python312\Lib\site-packages\uvicorn\server.py", line 67, in run + return asyncio.run(self.serve(sockets=sockets)) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\asyncio\runners.py", line 194, in run + return runner.run(main) + ^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\asyncio\runners.py", line 118, in run + return self._loop.run_until_complete(task) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\asyncio\base_events.py", line 664, in run_until_complete + return future.result() + ^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\site-packages\uvicorn\server.py", line 71, in serve + await self._serve(sockets) + File "C:\software\Python312\Lib\site-packages\uvicorn\server.py", line 78, in _serve + config.load() + File "C:\software\Python312\Lib\site-packages\uvicorn\config.py", line 436, in load + self.loaded_app = import_from_string(self.app) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\site-packages\uvicorn\importer.py", line 22, in import_from_string + raise exc from None + File "C:\software\Python312\Lib\site-packages\uvicorn\importer.py", line 19, in import_from_string + module = importlib.import_module(module_str) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\importlib\__init__.py", line 90, in import_module + return _bootstrap._gcd_import(name[level:], package, level) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "", line 1381, in _gcd_import + File "", line 1354, in _find_and_load + File "", line 1325, in _find_and_load_unlocked + File "", line 929, in _load_unlocked + File "", line 994, in exec_module + File "", line 488, in _call_with_frames_removed + File "C:\Projects\AIProjects\AIRegulations\Demo-glm\src\api\main.py", line 8, in + from loguru import logger +ModuleNotFoundError: No module named 'loguru' +WARNING: WatchFiles detected changes in 'src\api\routes\documents.py'. Reloading... + Process SpawnProcess-6: +Traceback (most recent call last): + File "C:\software\Python312\Lib\multiprocessing\process.py", line 314, in _bootstrap + self.run() + File "C:\software\Python312\Lib\multiprocessing\process.py", line 108, in run + self._target(*self._args, **self._kwargs) + File "C:\software\Python312\Lib\site-packages\uvicorn\_subprocess.py", line 80, in subprocess_started + target(sockets=sockets) + File "C:\software\Python312\Lib\site-packages\uvicorn\server.py", line 67, in run + return asyncio.run(self.serve(sockets=sockets)) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\asyncio\runners.py", line 194, in run + return runner.run(main) + ^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\asyncio\runners.py", line 118, in run + return self._loop.run_until_complete(task) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\asyncio\base_events.py", line 664, in run_until_complete + return future.result() + ^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\site-packages\uvicorn\server.py", line 71, in serve + await self._serve(sockets) + File "C:\software\Python312\Lib\site-packages\uvicorn\server.py", line 78, in _serve + config.load() + File "C:\software\Python312\Lib\site-packages\uvicorn\config.py", line 436, in load + self.loaded_app = import_from_string(self.app) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\site-packages\uvicorn\importer.py", line 22, in import_from_string + raise exc from None + File "C:\software\Python312\Lib\site-packages\uvicorn\importer.py", line 19, in import_from_string + module = importlib.import_module(module_str) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\importlib\__init__.py", line 90, in import_module + return _bootstrap._gcd_import(name[level:], package, level) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "", line 1381, in _gcd_import + File "", line 1354, in _find_and_load + File "", line 1325, in _find_and_load_unlocked + File "", line 929, in _load_unlocked + File "", line 994, in exec_module + File "", line 488, in _call_with_frames_removed + File "C:\Projects\AIProjects\AIRegulations\Demo-glm\src\api\main.py", line 8, in + from loguru import logger +ModuleNotFoundError: No module named 'loguru' +WARNING: WatchFiles detected changes in 'src\services\rag\retriever.py'. Reloading... + WARNING: WatchFiles detected changes in 'src\services\rag\retriever.py'. Reloading... + Process SpawnProcess-7: +Traceback (most recent call last): + File "C:\software\Python312\Lib\multiprocessing\process.py", line 314, in _bootstrap + self.run() + File "C:\software\Python312\Lib\multiprocessing\process.py", line 108, in run + self._target(*self._args, **self._kwargs) + File "C:\software\Python312\Lib\site-packages\uvicorn\_subprocess.py", line 80, in subprocess_started + target(sockets=sockets) + File "C:\software\Python312\Lib\site-packages\uvicorn\server.py", line 67, in run + return asyncio.run(self.serve(sockets=sockets)) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\asyncio\runners.py", line 194, in run + return runner.run(main) + ^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\asyncio\runners.py", line 118, in run + return self._loop.run_until_complete(task) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\asyncio\base_events.py", line 664, in run_until_complete + return future.result() + ^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\site-packages\uvicorn\server.py", line 71, in serve + await self._serve(sockets) + File "C:\software\Python312\Lib\site-packages\uvicorn\server.py", line 78, in _serve + config.load() + File "C:\software\Python312\Lib\site-packages\uvicorn\config.py", line 436, in load + self.loaded_app = import_from_string(self.app) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\site-packages\uvicorn\importer.py", line 22, in import_from_string + raise exc from None + File "C:\software\Python312\Lib\site-packages\uvicorn\importer.py", line 19, in import_from_string + module = importlib.import_module(module_str) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\importlib\__init__.py", line 90, in import_module + return _bootstrap._gcd_import(name[level:], package, level) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "", line 1381, in _gcd_import + File "", line 1354, in _find_and_load + File "", line 1325, in _find_and_load_unlocked + File "", line 929, in _load_unlocked + File "", line 994, in exec_module + File "", line 488, in _call_with_frames_removed + File "C:\Projects\AIProjects\AIRegulations\Demo-glm\src\api\main.py", line 8, in + from loguru import logger +ModuleNotFoundError: No module named 'loguru' +Process SpawnProcess-8: +Traceback (most recent call last): + File "C:\software\Python312\Lib\multiprocessing\process.py", line 314, in _bootstrap + self.run() + File "C:\software\Python312\Lib\multiprocessing\process.py", line 108, in run + self._target(*self._args, **self._kwargs) + File "C:\software\Python312\Lib\site-packages\uvicorn\_subprocess.py", line 80, in subprocess_started + target(sockets=sockets) + File "C:\software\Python312\Lib\site-packages\uvicorn\server.py", line 67, in run + return asyncio.run(self.serve(sockets=sockets)) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\asyncio\runners.py", line 194, in run + return runner.run(main) + ^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\asyncio\runners.py", line 118, in run + return self._loop.run_until_complete(task) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\asyncio\base_events.py", line 664, in run_until_complete + return future.result() + ^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\site-packages\uvicorn\server.py", line 71, in serve + await self._serve(sockets) + File "C:\software\Python312\Lib\site-packages\uvicorn\server.py", line 78, in _serve + config.load() + File "C:\software\Python312\Lib\site-packages\uvicorn\config.py", line 436, in load + self.loaded_app = import_from_string(self.app) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\site-packages\uvicorn\importer.py", line 22, in import_from_string + raise exc from None + File "C:\software\Python312\Lib\site-packages\uvicorn\importer.py", line 19, in import_from_string + module = importlib.import_module(module_str) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\importlib\__init__.py", line 90, in import_module + return _bootstrap._gcd_import(name[level:], package, level) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "", line 1381, in _gcd_import + File "", line 1354, in _find_and_load + File "", line 1325, in _find_and_load_unlocked + File "", line 929, in _load_unlocked + File "", line 994, in exec_module + File "", line 488, in _call_with_frames_removed + File "C:\Projects\AIProjects\AIRegulations\Demo-glm\src\api\main.py", line 8, in + from loguru import logger +ModuleNotFoundError: No module named 'loguru' +WARNING: WatchFiles detected changes in 'src\services\rag\retriever.py'. Reloading... + Process SpawnProcess-9: +Traceback (most recent call last): + File "C:\software\Python312\Lib\multiprocessing\process.py", line 314, in _bootstrap + self.run() + File "C:\software\Python312\Lib\multiprocessing\process.py", line 108, in run + self._target(*self._args, **self._kwargs) + File "C:\software\Python312\Lib\site-packages\uvicorn\_subprocess.py", line 80, in subprocess_started + target(sockets=sockets) + File "C:\software\Python312\Lib\site-packages\uvicorn\server.py", line 67, in run + return asyncio.run(self.serve(sockets=sockets)) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\asyncio\runners.py", line 194, in run + return runner.run(main) + ^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\asyncio\runners.py", line 118, in run + return self._loop.run_until_complete(task) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\asyncio\base_events.py", line 664, in run_until_complete + return future.result() + ^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\site-packages\uvicorn\server.py", line 71, in serve + await self._serve(sockets) + File "C:\software\Python312\Lib\site-packages\uvicorn\server.py", line 78, in _serve + config.load() + File "C:\software\Python312\Lib\site-packages\uvicorn\config.py", line 436, in load + self.loaded_app = import_from_string(self.app) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\site-packages\uvicorn\importer.py", line 22, in import_from_string + raise exc from None + File "C:\software\Python312\Lib\site-packages\uvicorn\importer.py", line 19, in import_from_string + module = importlib.import_module(module_str) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\importlib\__init__.py", line 90, in import_module + return _bootstrap._gcd_import(name[level:], package, level) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "", line 1381, in _gcd_import + File "", line 1354, in _find_and_load + File "", line 1325, in _find_and_load_unlocked + File "", line 929, in _load_unlocked + File "", line 994, in exec_module + File "", line 488, in _call_with_frames_removed + File "C:\Projects\AIProjects\AIRegulations\Demo-glm\src\api\main.py", line 8, in + from loguru import logger +ModuleNotFoundError: No module named 'loguru' +WARNING: WatchFiles detected changes in 'src\services\rag\retriever.py'. Reloading... + Process SpawnProcess-10: +Traceback (most recent call last): + File "C:\software\Python312\Lib\multiprocessing\process.py", line 314, in _bootstrap + self.run() + File "C:\software\Python312\Lib\multiprocessing\process.py", line 108, in run + self._target(*self._args, **self._kwargs) + File "C:\software\Python312\Lib\site-packages\uvicorn\_subprocess.py", line 80, in subprocess_started + target(sockets=sockets) + File "C:\software\Python312\Lib\site-packages\uvicorn\server.py", line 67, in run + return asyncio.run(self.serve(sockets=sockets)) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\asyncio\runners.py", line 194, in run + return runner.run(main) + ^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\asyncio\runners.py", line 118, in run + return self._loop.run_until_complete(task) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\asyncio\base_events.py", line 664, in run_until_complete + return future.result() + ^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\site-packages\uvicorn\server.py", line 71, in serve + await self._serve(sockets) + File "C:\software\Python312\Lib\site-packages\uvicorn\server.py", line 78, in _serve + config.load() + File "C:\software\Python312\Lib\site-packages\uvicorn\config.py", line 436, in load + self.loaded_app = import_from_string(self.app) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\site-packages\uvicorn\importer.py", line 22, in import_from_string + raise exc from None + File "C:\software\Python312\Lib\site-packages\uvicorn\importer.py", line 19, in import_from_string + module = importlib.import_module(module_str) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\importlib\__init__.py", line 90, in import_module + return _bootstrap._gcd_import(name[level:], package, level) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "", line 1381, in _gcd_import + File "", line 1354, in _find_and_load + File "", line 1325, in _find_and_load_unlocked + File "", line 929, in _load_unlocked + File "", line 994, in exec_module + File "", line 488, in _call_with_frames_removed + File "C:\Projects\AIProjects\AIRegulations\Demo-glm\src\api\main.py", line 8, in + from loguru import logger +ModuleNotFoundError: No module named 'loguru' +WARNING: WatchFiles detected changes in 'src\services\rag\context_builder.py'. Reloading... + Process SpawnProcess-11: +Traceback (most recent call last): + File "C:\software\Python312\Lib\multiprocessing\process.py", line 314, in _bootstrap + self.run() + File "C:\software\Python312\Lib\multiprocessing\process.py", line 108, in run + self._target(*self._args, **self._kwargs) + File "C:\software\Python312\Lib\site-packages\uvicorn\_subprocess.py", line 80, in subprocess_started + target(sockets=sockets) + File "C:\software\Python312\Lib\site-packages\uvicorn\server.py", line 67, in run + return asyncio.run(self.serve(sockets=sockets)) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\asyncio\runners.py", line 194, in run + return runner.run(main) + ^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\asyncio\runners.py", line 118, in run + return self._loop.run_until_complete(task) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\asyncio\base_events.py", line 664, in run_until_complete + return future.result() + ^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\site-packages\uvicorn\server.py", line 71, in serve + await self._serve(sockets) + File "C:\software\Python312\Lib\site-packages\uvicorn\server.py", line 78, in _serve + config.load() + File "C:\software\Python312\Lib\site-packages\uvicorn\config.py", line 436, in load + self.loaded_app = import_from_string(self.app) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\site-packages\uvicorn\importer.py", line 22, in import_from_string + raise exc from None + File "C:\software\Python312\Lib\site-packages\uvicorn\importer.py", line 19, in import_from_string + module = importlib.import_module(module_str) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\importlib\__init__.py", line 90, in import_module + return _bootstrap._gcd_import(name[level:], package, level) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "", line 1381, in _gcd_import + File "", line 1354, in _find_and_load + File "", line 1325, in _find_and_load_unlocked + File "", line 929, in _load_unlocked + File "", line 994, in exec_module + File "", line 488, in _call_with_frames_removed + File "C:\Projects\AIProjects\AIRegulations\Demo-glm\src\api\main.py", line 8, in + from loguru import logger +ModuleNotFoundError: No module named 'loguru' +WARNING: WatchFiles detected changes in 'src\config\settings.py'. Reloading... + Process SpawnProcess-12: +Traceback (most recent call last): + File "C:\software\Python312\Lib\multiprocessing\process.py", line 314, in _bootstrap + self.run() + File "C:\software\Python312\Lib\multiprocessing\process.py", line 108, in run + self._target(*self._args, **self._kwargs) + File "C:\software\Python312\Lib\site-packages\uvicorn\_subprocess.py", line 80, in subprocess_started + target(sockets=sockets) + File "C:\software\Python312\Lib\site-packages\uvicorn\server.py", line 67, in run + return asyncio.run(self.serve(sockets=sockets)) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\asyncio\runners.py", line 194, in run + return runner.run(main) + ^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\asyncio\runners.py", line 118, in run + return self._loop.run_until_complete(task) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\asyncio\base_events.py", line 664, in run_until_complete + return future.result() + ^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\site-packages\uvicorn\server.py", line 71, in serve + await self._serve(sockets) + File "C:\software\Python312\Lib\site-packages\uvicorn\server.py", line 78, in _serve + config.load() + File "C:\software\Python312\Lib\site-packages\uvicorn\config.py", line 436, in load + self.loaded_app = import_from_string(self.app) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\site-packages\uvicorn\importer.py", line 22, in import_from_string + raise exc from None + File "C:\software\Python312\Lib\site-packages\uvicorn\importer.py", line 19, in import_from_string + module = importlib.import_module(module_str) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\importlib\__init__.py", line 90, in import_module + return _bootstrap._gcd_import(name[level:], package, level) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "", line 1381, in _gcd_import + File "", line 1354, in _find_and_load + File "", line 1325, in _find_and_load_unlocked + File "", line 929, in _load_unlocked + File "", line 994, in exec_module + File "", line 488, in _call_with_frames_removed + File "C:\Projects\AIProjects\AIRegulations\Demo-glm\src\api\main.py", line 8, in + from loguru import logger +ModuleNotFoundError: No module named 'loguru' +WARNING: WatchFiles detected changes in 'src\config\settings.py'. Reloading... + WARNING: WatchFiles detected changes in 'src\config\settings.py'. Reloading... + Process SpawnProcess-13: +Traceback (most recent call last): + File "C:\software\Python312\Lib\multiprocessing\process.py", line 314, in _bootstrap + self.run() + File "C:\software\Python312\Lib\multiprocessing\process.py", line 108, in run + self._target(*self._args, **self._kwargs) + File "C:\software\Python312\Lib\site-packages\uvicorn\_subprocess.py", line 80, in subprocess_started + target(sockets=sockets) + File "C:\software\Python312\Lib\site-packages\uvicorn\server.py", line 67, in run + return asyncio.run(self.serve(sockets=sockets)) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\asyncio\runners.py", line 194, in run + return runner.run(main) + ^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\asyncio\runners.py", line 118, in run + return self._loop.run_until_complete(task) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\asyncio\base_events.py", line 664, in run_until_complete + return future.result() + ^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\site-packages\uvicorn\server.py", line 71, in serve + await self._serve(sockets) + File "C:\software\Python312\Lib\site-packages\uvicorn\server.py", line 78, in _serve + config.load() + File "C:\software\Python312\Lib\site-packages\uvicorn\config.py", line 436, in load + self.loaded_app = import_from_string(self.app) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\site-packages\uvicorn\importer.py", line 22, in import_from_string + raise exc from None + File "C:\software\Python312\Lib\site-packages\uvicorn\importer.py", line 19, in import_from_string + module = importlib.import_module(module_str) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\importlib\__init__.py", line 90, in import_module + return _bootstrap._gcd_import(name[level:], package, level) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "", line 1381, in _gcd_import + File "", line 1354, in _find_and_load + File "", line 1325, in _find_and_load_unlocked + File "", line 929, in _load_unlocked + File "", line 994, in exec_module + File "", line 488, in _call_with_frames_removed + File "C:\Projects\AIProjects\AIRegulations\Demo-glm\src\api\main.py", line 8, in + from loguru import logger +ModuleNotFoundError: No module named 'loguru' +WARNING: WatchFiles detected changes in 'src\config\settings.py'. Reloading... + Process SpawnProcess-14: +Traceback (most recent call last): + File "C:\software\Python312\Lib\multiprocessing\process.py", line 314, in _bootstrap + self.run() + File "C:\software\Python312\Lib\multiprocessing\process.py", line 108, in run + self._target(*self._args, **self._kwargs) + File "C:\software\Python312\Lib\site-packages\uvicorn\_subprocess.py", line 80, in subprocess_started + target(sockets=sockets) + File "C:\software\Python312\Lib\site-packages\uvicorn\server.py", line 67, in run + return asyncio.run(self.serve(sockets=sockets)) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\asyncio\runners.py", line 194, in run + return runner.run(main) + ^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\asyncio\runners.py", line 118, in run + return self._loop.run_until_complete(task) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\asyncio\base_events.py", line 664, in run_until_complete + return future.result() + ^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\site-packages\uvicorn\server.py", line 71, in serve + await self._serve(sockets) + File "C:\software\Python312\Lib\site-packages\uvicorn\server.py", line 78, in _serve + config.load() + File "C:\software\Python312\Lib\site-packages\uvicorn\config.py", line 436, in load + self.loaded_app = import_from_string(self.app) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\site-packages\uvicorn\importer.py", line 22, in import_from_string + raise exc from None + File "C:\software\Python312\Lib\site-packages\uvicorn\importer.py", line 19, in import_from_string + module = importlib.import_module(module_str) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\importlib\__init__.py", line 90, in import_module + return _bootstrap._gcd_import(name[level:], package, level) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "", line 1381, in _gcd_import + File "", line 1354, in _find_and_load + File "", line 1325, in _find_and_load_unlocked + File "", line 929, in _load_unlocked + File "", line 994, in exec_module + File "", line 488, in _call_with_frames_removed + File "C:\Projects\AIProjects\AIRegulations\Demo-glm\src\api\main.py", line 8, in + from loguru import logger +ModuleNotFoundError: No module named 'loguru' +Process SpawnProcess-15: +Traceback (most recent call last): + File "C:\software\Python312\Lib\multiprocessing\process.py", line 314, in _bootstrap + self.run() + File "C:\software\Python312\Lib\multiprocessing\process.py", line 108, in run + self._target(*self._args, **self._kwargs) + File "C:\software\Python312\Lib\site-packages\uvicorn\_subprocess.py", line 80, in subprocess_started + target(sockets=sockets) + File "C:\software\Python312\Lib\site-packages\uvicorn\server.py", line 67, in run + return asyncio.run(self.serve(sockets=sockets)) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\asyncio\runners.py", line 194, in run + return runner.run(main) + ^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\asyncio\runners.py", line 118, in run + return self._loop.run_until_complete(task) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\asyncio\base_events.py", line 664, in run_until_complete + return future.result() + ^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\site-packages\uvicorn\server.py", line 71, in serve + await self._serve(sockets) + File "C:\software\Python312\Lib\site-packages\uvicorn\server.py", line 78, in _serve + config.load() + File "C:\software\Python312\Lib\site-packages\uvicorn\config.py", line 436, in load + self.loaded_app = import_from_string(self.app) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\site-packages\uvicorn\importer.py", line 22, in import_from_string + raise exc from None + File "C:\software\Python312\Lib\site-packages\uvicorn\importer.py", line 19, in import_from_string + module = importlib.import_module(module_str) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\importlib\__init__.py", line 90, in import_module + return _bootstrap._gcd_import(name[level:], package, level) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "", line 1381, in _gcd_import + File "", line 1354, in _find_and_load + File "", line 1325, in _find_and_load_unlocked + File "", line 929, in _load_unlocked + File "", line 994, in exec_module + File "", line 488, in _call_with_frames_removed + File "C:\Projects\AIProjects\AIRegulations\Demo-glm\src\api\main.py", line 8, in + from loguru import logger +ModuleNotFoundError: No module named 'loguru' +WARNING: WatchFiles detected changes in 'src\api\routes\documents.py'. Reloading... + Process SpawnProcess-16: +Traceback (most recent call last): + File "C:\software\Python312\Lib\multiprocessing\process.py", line 314, in _bootstrap + self.run() + File "C:\software\Python312\Lib\multiprocessing\process.py", line 108, in run + self._target(*self._args, **self._kwargs) + File "C:\software\Python312\Lib\site-packages\uvicorn\_subprocess.py", line 80, in subprocess_started + target(sockets=sockets) + File "C:\software\Python312\Lib\site-packages\uvicorn\server.py", line 67, in run + return asyncio.run(self.serve(sockets=sockets)) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\asyncio\runners.py", line 194, in run + return runner.run(main) + ^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\asyncio\runners.py", line 118, in run + return self._loop.run_until_complete(task) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\asyncio\base_events.py", line 664, in run_until_complete + return future.result() + ^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\site-packages\uvicorn\server.py", line 71, in serve + await self._serve(sockets) + File "C:\software\Python312\Lib\site-packages\uvicorn\server.py", line 78, in _serve + config.load() + File "C:\software\Python312\Lib\site-packages\uvicorn\config.py", line 436, in load + self.loaded_app = import_from_string(self.app) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\site-packages\uvicorn\importer.py", line 22, in import_from_string + raise exc from None + File "C:\software\Python312\Lib\site-packages\uvicorn\importer.py", line 19, in import_from_string + module = importlib.import_module(module_str) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\importlib\__init__.py", line 90, in import_module + return _bootstrap._gcd_import(name[level:], package, level) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "", line 1381, in _gcd_import + File "", line 1354, in _find_and_load + File "", line 1325, in _find_and_load_unlocked + File "", line 929, in _load_unlocked + File "", line 994, in exec_module + File "", line 488, in _call_with_frames_removed + File "C:\Projects\AIProjects\AIRegulations\Demo-glm\src\api\main.py", line 8, in + from loguru import logger +ModuleNotFoundError: No module named 'loguru' +WARNING: WatchFiles detected changes in 'src\api\routes\documents.py'. Reloading... + Process SpawnProcess-17: +Traceback (most recent call last): + File "C:\software\Python312\Lib\multiprocessing\process.py", line 314, in _bootstrap + self.run() + File "C:\software\Python312\Lib\multiprocessing\process.py", line 108, in run + self._target(*self._args, **self._kwargs) + File "C:\software\Python312\Lib\site-packages\uvicorn\_subprocess.py", line 80, in subprocess_started + target(sockets=sockets) + File "C:\software\Python312\Lib\site-packages\uvicorn\server.py", line 67, in run + return asyncio.run(self.serve(sockets=sockets)) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\asyncio\runners.py", line 194, in run + return runner.run(main) + ^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\asyncio\runners.py", line 118, in run + return self._loop.run_until_complete(task) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\asyncio\base_events.py", line 664, in run_until_complete + return future.result() + ^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\site-packages\uvicorn\server.py", line 71, in serve + await self._serve(sockets) + File "C:\software\Python312\Lib\site-packages\uvicorn\server.py", line 78, in _serve + config.load() + File "C:\software\Python312\Lib\site-packages\uvicorn\config.py", line 436, in load + self.loaded_app = import_from_string(self.app) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\site-packages\uvicorn\importer.py", line 22, in import_from_string + raise exc from None + File "C:\software\Python312\Lib\site-packages\uvicorn\importer.py", line 19, in import_from_string + module = importlib.import_module(module_str) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\importlib\__init__.py", line 90, in import_module + return _bootstrap._gcd_import(name[level:], package, level) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "", line 1381, in _gcd_import + File "", line 1354, in _find_and_load + File "", line 1325, in _find_and_load_unlocked + File "", line 929, in _load_unlocked + File "", line 994, in exec_module + File "", line 488, in _call_with_frames_removed + File "C:\Projects\AIProjects\AIRegulations\Demo-glm\src\api\main.py", line 8, in + from loguru import logger +ModuleNotFoundError: No module named 'loguru' +WARNING: WatchFiles detected changes in 'src\services\storage\minio_client.py'. Reloading... + Process SpawnProcess-18: +Traceback (most recent call last): + File "C:\software\Python312\Lib\multiprocessing\process.py", line 314, in _bootstrap + self.run() + File "C:\software\Python312\Lib\multiprocessing\process.py", line 108, in run + self._target(*self._args, **self._kwargs) + File "C:\software\Python312\Lib\site-packages\uvicorn\_subprocess.py", line 80, in subprocess_started + target(sockets=sockets) + File "C:\software\Python312\Lib\site-packages\uvicorn\server.py", line 67, in run + return asyncio.run(self.serve(sockets=sockets)) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\asyncio\runners.py", line 194, in run + return runner.run(main) + ^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\asyncio\runners.py", line 118, in run + return self._loop.run_until_complete(task) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\asyncio\base_events.py", line 664, in run_until_complete + return future.result() + ^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\site-packages\uvicorn\server.py", line 71, in serve + await self._serve(sockets) + File "C:\software\Python312\Lib\site-packages\uvicorn\server.py", line 78, in _serve + config.load() + File "C:\software\Python312\Lib\site-packages\uvicorn\config.py", line 436, in load + self.loaded_app = import_from_string(self.app) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\site-packages\uvicorn\importer.py", line 22, in import_from_string + raise exc from None + File "C:\software\Python312\Lib\site-packages\uvicorn\importer.py", line 19, in import_from_string + module = importlib.import_module(module_str) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\importlib\__init__.py", line 90, in import_module + return _bootstrap._gcd_import(name[level:], package, level) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "", line 1381, in _gcd_import + File "", line 1354, in _find_and_load + File "", line 1325, in _find_and_load_unlocked + File "", line 929, in _load_unlocked + File "", line 994, in exec_module + File "", line 488, in _call_with_frames_removed + File "C:\Projects\AIProjects\AIRegulations\Demo-glm\src\api\main.py", line 8, in + from loguru import logger +ModuleNotFoundError: No module named 'loguru' +WARNING: WatchFiles detected changes in 'src\api\routes\documents.py'. Reloading... + Process SpawnProcess-19: +Traceback (most recent call last): + File "C:\software\Python312\Lib\multiprocessing\process.py", line 314, in _bootstrap + self.run() + File "C:\software\Python312\Lib\multiprocessing\process.py", line 108, in run + self._target(*self._args, **self._kwargs) + File "C:\software\Python312\Lib\site-packages\uvicorn\_subprocess.py", line 80, in subprocess_started + target(sockets=sockets) + File "C:\software\Python312\Lib\site-packages\uvicorn\server.py", line 67, in run + return asyncio.run(self.serve(sockets=sockets)) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\asyncio\runners.py", line 194, in run + return runner.run(main) + ^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\asyncio\runners.py", line 118, in run + return self._loop.run_until_complete(task) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\asyncio\base_events.py", line 664, in run_until_complete + return future.result() + ^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\site-packages\uvicorn\server.py", line 71, in serve + await self._serve(sockets) + File "C:\software\Python312\Lib\site-packages\uvicorn\server.py", line 78, in _serve + config.load() + File "C:\software\Python312\Lib\site-packages\uvicorn\config.py", line 436, in load + self.loaded_app = import_from_string(self.app) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\site-packages\uvicorn\importer.py", line 22, in import_from_string + raise exc from None + File "C:\software\Python312\Lib\site-packages\uvicorn\importer.py", line 19, in import_from_string + module = importlib.import_module(module_str) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\importlib\__init__.py", line 90, in import_module + return _bootstrap._gcd_import(name[level:], package, level) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "", line 1381, in _gcd_import + File "", line 1354, in _find_and_load + File "", line 1325, in _find_and_load_unlocked + File "", line 929, in _load_unlocked + File "", line 994, in exec_module + File "", line 488, in _call_with_frames_removed + File "C:\Projects\AIProjects\AIRegulations\Demo-glm\src\api\main.py", line 8, in + from loguru import logger +ModuleNotFoundError: No module named 'loguru' +WARNING: WatchFiles detected changes in 'src\api\routes\documents.py'. Reloading... + Process SpawnProcess-20: +Traceback (most recent call last): + File "C:\software\Python312\Lib\multiprocessing\process.py", line 314, in _bootstrap + self.run() + File "C:\software\Python312\Lib\multiprocessing\process.py", line 108, in run + self._target(*self._args, **self._kwargs) + File "C:\software\Python312\Lib\site-packages\uvicorn\_subprocess.py", line 80, in subprocess_started + target(sockets=sockets) + File "C:\software\Python312\Lib\site-packages\uvicorn\server.py", line 67, in run + return asyncio.run(self.serve(sockets=sockets)) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\asyncio\runners.py", line 194, in run + return runner.run(main) + ^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\asyncio\runners.py", line 118, in run + return self._loop.run_until_complete(task) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\asyncio\base_events.py", line 664, in run_until_complete + return future.result() + ^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\site-packages\uvicorn\server.py", line 71, in serve + await self._serve(sockets) + File "C:\software\Python312\Lib\site-packages\uvicorn\server.py", line 78, in _serve + config.load() + File "C:\software\Python312\Lib\site-packages\uvicorn\config.py", line 436, in load + self.loaded_app = import_from_string(self.app) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\site-packages\uvicorn\importer.py", line 22, in import_from_string + raise exc from None + File "C:\software\Python312\Lib\site-packages\uvicorn\importer.py", line 19, in import_from_string + module = importlib.import_module(module_str) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\importlib\__init__.py", line 90, in import_module + return _bootstrap._gcd_import(name[level:], package, level) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "", line 1381, in _gcd_import + File "", line 1354, in _find_and_load + File "", line 1325, in _find_and_load_unlocked + File "", line 929, in _load_unlocked + File "", line 994, in exec_module + File "", line 488, in _call_with_frames_removed + File "C:\Projects\AIProjects\AIRegulations\Demo-glm\src\api\main.py", line 8, in + from loguru import logger +ModuleNotFoundError: No module named 'loguru' +WARNING: WatchFiles detected changes in 'src\config\settings.py'. Reloading... + Process SpawnProcess-21: +Traceback (most recent call last): + File "C:\software\Python312\Lib\multiprocessing\process.py", line 314, in _bootstrap + self.run() + File "C:\software\Python312\Lib\multiprocessing\process.py", line 108, in run + self._target(*self._args, **self._kwargs) + File "C:\software\Python312\Lib\site-packages\uvicorn\_subprocess.py", line 80, in subprocess_started + target(sockets=sockets) + File "C:\software\Python312\Lib\site-packages\uvicorn\server.py", line 67, in run + return asyncio.run(self.serve(sockets=sockets)) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\asyncio\runners.py", line 194, in run + return runner.run(main) + ^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\asyncio\runners.py", line 118, in run + return self._loop.run_until_complete(task) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\asyncio\base_events.py", line 664, in run_until_complete + return future.result() + ^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\site-packages\uvicorn\server.py", line 71, in serve + await self._serve(sockets) + File "C:\software\Python312\Lib\site-packages\uvicorn\server.py", line 78, in _serve + config.load() + File "C:\software\Python312\Lib\site-packages\uvicorn\config.py", line 436, in load + self.loaded_app = import_from_string(self.app) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\site-packages\uvicorn\importer.py", line 22, in import_from_string + raise exc from None + File "C:\software\Python312\Lib\site-packages\uvicorn\importer.py", line 19, in import_from_string + module = importlib.import_module(module_str) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\importlib\__init__.py", line 90, in import_module + return _bootstrap._gcd_import(name[level:], package, level) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "", line 1381, in _gcd_import + File "", line 1354, in _find_and_load + File "", line 1325, in _find_and_load_unlocked + File "", line 929, in _load_unlocked + File "", line 994, in exec_module + File "", line 488, in _call_with_frames_removed + File "C:\Projects\AIProjects\AIRegulations\Demo-glm\src\api\main.py", line 8, in + from loguru import logger +ModuleNotFoundError: No module named 'loguru' +WARNING: WatchFiles detected changes in 'src\api\routes\documents.py'. Reloading... + Process SpawnProcess-22: +Traceback (most recent call last): + File "C:\software\Python312\Lib\multiprocessing\process.py", line 314, in _bootstrap + self.run() + File "C:\software\Python312\Lib\multiprocessing\process.py", line 108, in run + self._target(*self._args, **self._kwargs) + File "C:\software\Python312\Lib\site-packages\uvicorn\_subprocess.py", line 80, in subprocess_started + target(sockets=sockets) + File "C:\software\Python312\Lib\site-packages\uvicorn\server.py", line 67, in run + return asyncio.run(self.serve(sockets=sockets)) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\asyncio\runners.py", line 194, in run + return runner.run(main) + ^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\asyncio\runners.py", line 118, in run + return self._loop.run_until_complete(task) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\asyncio\base_events.py", line 664, in run_until_complete + return future.result() + ^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\site-packages\uvicorn\server.py", line 71, in serve + await self._serve(sockets) + File "C:\software\Python312\Lib\site-packages\uvicorn\server.py", line 78, in _serve + config.load() + File "C:\software\Python312\Lib\site-packages\uvicorn\config.py", line 436, in load + self.loaded_app = import_from_string(self.app) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\site-packages\uvicorn\importer.py", line 22, in import_from_string + raise exc from None + File "C:\software\Python312\Lib\site-packages\uvicorn\importer.py", line 19, in import_from_string + module = importlib.import_module(module_str) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\importlib\__init__.py", line 90, in import_module + return _bootstrap._gcd_import(name[level:], package, level) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "", line 1381, in _gcd_import + File "", line 1354, in _find_and_load + File "", line 1325, in _find_and_load_unlocked + File "", line 929, in _load_unlocked + File "", line 994, in exec_module + File "", line 488, in _call_with_frames_removed + File "C:\Projects\AIProjects\AIRegulations\Demo-glm\src\api\main.py", line 8, in + from loguru import logger +ModuleNotFoundError: No module named 'loguru' +WARNING: WatchFiles detected changes in 'src\services\storage\__init__.py'. Reloading... + WARNING: WatchFiles detected changes in 'src\services\storage\__init__.py'. Reloading... + Process SpawnProcess-23: +Traceback (most recent call last): + File "C:\software\Python312\Lib\multiprocessing\process.py", line 314, in _bootstrap + self.run() + File "C:\software\Python312\Lib\multiprocessing\process.py", line 108, in run + self._target(*self._args, **self._kwargs) + File "C:\software\Python312\Lib\site-packages\uvicorn\_subprocess.py", line 80, in subprocess_started + target(sockets=sockets) + File "C:\software\Python312\Lib\site-packages\uvicorn\server.py", line 67, in run + return asyncio.run(self.serve(sockets=sockets)) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\asyncio\runners.py", line 194, in run + return runner.run(main) + ^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\asyncio\runners.py", line 118, in run + return self._loop.run_until_complete(task) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\asyncio\base_events.py", line 664, in run_until_complete + return future.result() + ^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\site-packages\uvicorn\server.py", line 71, in serve + await self._serve(sockets) + File "C:\software\Python312\Lib\site-packages\uvicorn\server.py", line 78, in _serve + config.load() + File "C:\software\Python312\Lib\site-packages\uvicorn\config.py", line 436, in load + self.loaded_app = import_from_string(self.app) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\site-packages\uvicorn\importer.py", line 22, in import_from_string + raise exc from None + File "C:\software\Python312\Lib\site-packages\uvicorn\importer.py", line 19, in import_from_string + module = importlib.import_module(module_str) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\importlib\__init__.py", line 90, in import_module + return _bootstrap._gcd_import(name[level:], package, level) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "", line 1381, in _gcd_import + File "", line 1354, in _find_and_load + File "", line 1325, in _find_and_load_unlocked + File "", line 929, in _load_unlocked + File "", line 994, in exec_module + File "", line 488, in _call_with_frames_removed + File "C:\Projects\AIProjects\AIRegulations\Demo-glm\src\api\main.py", line 8, in + from loguru import logger +ModuleNotFoundError: No module named 'loguru' +Process SpawnProcess-24: +Traceback (most recent call last): + File "C:\software\Python312\Lib\multiprocessing\process.py", line 314, in _bootstrap + self.run() + File "C:\software\Python312\Lib\multiprocessing\process.py", line 108, in run + self._target(*self._args, **self._kwargs) + File "C:\software\Python312\Lib\site-packages\uvicorn\_subprocess.py", line 80, in subprocess_started + target(sockets=sockets) + File "C:\software\Python312\Lib\site-packages\uvicorn\server.py", line 67, in run + return asyncio.run(self.serve(sockets=sockets)) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\asyncio\runners.py", line 194, in run + return runner.run(main) + ^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\asyncio\runners.py", line 118, in run + return self._loop.run_until_complete(task) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\asyncio\base_events.py", line 664, in run_until_complete + return future.result() + ^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\site-packages\uvicorn\server.py", line 71, in serve + await self._serve(sockets) + File "C:\software\Python312\Lib\site-packages\uvicorn\server.py", line 78, in _serve + config.load() + File "C:\software\Python312\Lib\site-packages\uvicorn\config.py", line 436, in load + self.loaded_app = import_from_string(self.app) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\site-packages\uvicorn\importer.py", line 22, in import_from_string + raise exc from None + File "C:\software\Python312\Lib\site-packages\uvicorn\importer.py", line 19, in import_from_string + module = importlib.import_module(module_str) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\importlib\__init__.py", line 90, in import_module + return _bootstrap._gcd_import(name[level:], package, level) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "", line 1381, in _gcd_import + File "", line 1354, in _find_and_load + File "", line 1325, in _find_and_load_unlocked + File "", line 929, in _load_unlocked + File "", line 994, in exec_module + File "", line 488, in _call_with_frames_removed + File "C:\Projects\AIProjects\AIRegulations\Demo-glm\src\api\main.py", line 8, in + from loguru import logger +ModuleNotFoundError: No module named 'loguru' +WARNING: WatchFiles detected changes in 'src\services\storage\minio_client.py'. Reloading... + Process SpawnProcess-25: +Traceback (most recent call last): + File "C:\software\Python312\Lib\multiprocessing\process.py", line 314, in _bootstrap + self.run() + File "C:\software\Python312\Lib\multiprocessing\process.py", line 108, in run + self._target(*self._args, **self._kwargs) + File "C:\software\Python312\Lib\site-packages\uvicorn\_subprocess.py", line 80, in subprocess_started + target(sockets=sockets) + File "C:\software\Python312\Lib\site-packages\uvicorn\server.py", line 67, in run + return asyncio.run(self.serve(sockets=sockets)) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\asyncio\runners.py", line 194, in run + return runner.run(main) + ^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\asyncio\runners.py", line 118, in run + return self._loop.run_until_complete(task) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\asyncio\base_events.py", line 664, in run_until_complete + return future.result() + ^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\site-packages\uvicorn\server.py", line 71, in serve + await self._serve(sockets) + File "C:\software\Python312\Lib\site-packages\uvicorn\server.py", line 78, in _serve + config.load() + File "C:\software\Python312\Lib\site-packages\uvicorn\config.py", line 436, in load + self.loaded_app = import_from_string(self.app) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\site-packages\uvicorn\importer.py", line 22, in import_from_string + raise exc from None + File "C:\software\Python312\Lib\site-packages\uvicorn\importer.py", line 19, in import_from_string + module = importlib.import_module(module_str) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\importlib\__init__.py", line 90, in import_module + return _bootstrap._gcd_import(name[level:], package, level) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "", line 1381, in _gcd_import + File "", line 1354, in _find_and_load + File "", line 1325, in _find_and_load_unlocked + File "", line 929, in _load_unlocked + File "", line 994, in exec_module + File "", line 488, in _call_with_frames_removed + File "C:\Projects\AIProjects\AIRegulations\Demo-glm\src\api\main.py", line 8, in + from loguru import logger +ModuleNotFoundError: No module named 'loguru' +WARNING: WatchFiles detected changes in 'src\api\routes\documents.py'. Reloading... + Process SpawnProcess-26: +Traceback (most recent call last): + File "C:\software\Python312\Lib\multiprocessing\process.py", line 314, in _bootstrap + self.run() + File "C:\software\Python312\Lib\multiprocessing\process.py", line 108, in run + self._target(*self._args, **self._kwargs) + File "C:\software\Python312\Lib\site-packages\uvicorn\_subprocess.py", line 80, in subprocess_started + target(sockets=sockets) + File "C:\software\Python312\Lib\site-packages\uvicorn\server.py", line 67, in run + return asyncio.run(self.serve(sockets=sockets)) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\asyncio\runners.py", line 194, in run + return runner.run(main) + ^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\asyncio\runners.py", line 118, in run + return self._loop.run_until_complete(task) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\asyncio\base_events.py", line 664, in run_until_complete + return future.result() + ^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\site-packages\uvicorn\server.py", line 71, in serve + await self._serve(sockets) + File "C:\software\Python312\Lib\site-packages\uvicorn\server.py", line 78, in _serve + config.load() + File "C:\software\Python312\Lib\site-packages\uvicorn\config.py", line 436, in load + self.loaded_app = import_from_string(self.app) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\site-packages\uvicorn\importer.py", line 22, in import_from_string + raise exc from None + File "C:\software\Python312\Lib\site-packages\uvicorn\importer.py", line 19, in import_from_string + module = importlib.import_module(module_str) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\importlib\__init__.py", line 90, in import_module + return _bootstrap._gcd_import(name[level:], package, level) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "", line 1381, in _gcd_import + File "", line 1354, in _find_and_load + File "", line 1325, in _find_and_load_unlocked + File "", line 929, in _load_unlocked + File "", line 994, in exec_module + File "", line 488, in _call_with_frames_removed + File "C:\Projects\AIProjects\AIRegulations\Demo-glm\src\api\main.py", line 8, in + from loguru import logger +ModuleNotFoundError: No module named 'loguru' +WARNING: WatchFiles detected changes in 'src\services\document_processor.py'. Reloading... + WARNING: WatchFiles detected changes in 'src\services\document_processor.py'. Reloading... + Process SpawnProcess-27: +Traceback (most recent call last): + File "C:\software\Python312\Lib\multiprocessing\process.py", line 314, in _bootstrap + self.run() + File "C:\software\Python312\Lib\multiprocessing\process.py", line 108, in run + self._target(*self._args, **self._kwargs) + File "C:\software\Python312\Lib\site-packages\uvicorn\_subprocess.py", line 80, in subprocess_started + target(sockets=sockets) + File "C:\software\Python312\Lib\site-packages\uvicorn\server.py", line 67, in run + return asyncio.run(self.serve(sockets=sockets)) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\asyncio\runners.py", line 194, in run + return runner.run(main) + ^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\asyncio\runners.py", line 118, in run + return self._loop.run_until_complete(task) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\asyncio\base_events.py", line 664, in run_until_complete + return future.result() + ^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\site-packages\uvicorn\server.py", line 71, in serve + await self._serve(sockets) + File "C:\software\Python312\Lib\site-packages\uvicorn\server.py", line 78, in _serve + config.load() + File "C:\software\Python312\Lib\site-packages\uvicorn\config.py", line 436, in load + self.loaded_app = import_from_string(self.app) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\site-packages\uvicorn\importer.py", line 22, in import_from_string + raise exc from None + File "C:\software\Python312\Lib\site-packages\uvicorn\importer.py", line 19, in import_from_string + module = importlib.import_module(module_str) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\importlib\__init__.py", line 90, in import_module + return _bootstrap._gcd_import(name[level:], package, level) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "", line 1381, in _gcd_import + File "", line 1354, in _find_and_load + File "", line 1325, in _find_and_load_unlocked + File "", line 929, in _load_unlocked + File "", line 994, in exec_module + File "", line 488, in _call_with_frames_removed + File "C:\Projects\AIProjects\AIRegulations\Demo-glm\src\api\main.py", line 8, in + from loguru import logger +ModuleNotFoundError: No module named 'loguru' +Process SpawnProcess-28: +Traceback (most recent call last): + File "C:\software\Python312\Lib\multiprocessing\process.py", line 314, in _bootstrap + self.run() + File "C:\software\Python312\Lib\multiprocessing\process.py", line 108, in run + self._target(*self._args, **self._kwargs) + File "C:\software\Python312\Lib\site-packages\uvicorn\_subprocess.py", line 80, in subprocess_started + target(sockets=sockets) + File "C:\software\Python312\Lib\site-packages\uvicorn\server.py", line 67, in run + return asyncio.run(self.serve(sockets=sockets)) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\asyncio\runners.py", line 194, in run + return runner.run(main) + ^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\asyncio\runners.py", line 118, in run + return self._loop.run_until_complete(task) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\asyncio\base_events.py", line 664, in run_until_complete + return future.result() + ^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\site-packages\uvicorn\server.py", line 71, in serve + await self._serve(sockets) + File "C:\software\Python312\Lib\site-packages\uvicorn\server.py", line 78, in _serve + config.load() + File "C:\software\Python312\Lib\site-packages\uvicorn\config.py", line 436, in load + self.loaded_app = import_from_string(self.app) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\site-packages\uvicorn\importer.py", line 22, in import_from_string + raise exc from None + File "C:\software\Python312\Lib\site-packages\uvicorn\importer.py", line 19, in import_from_string + module = importlib.import_module(module_str) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\importlib\__init__.py", line 90, in import_module + return _bootstrap._gcd_import(name[level:], package, level) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "", line 1381, in _gcd_import + File "", line 1354, in _find_and_load + File "", line 1325, in _find_and_load_unlocked + File "", line 929, in _load_unlocked + File "", line 994, in exec_module + File "", line 488, in _call_with_frames_removed + File "C:\Projects\AIProjects\AIRegulations\Demo-glm\src\api\main.py", line 8, in + from loguru import logger +ModuleNotFoundError: No module named 'loguru' +WARNING: WatchFiles detected changes in 'src\api\routes\documents.py', 'src\services\rag\prompt_templates.py', 'src\api\models\document.py', 'src\services\llm\__init__.py'. Reloading... + WARNING: WatchFiles detected changes in 'src\api\models\document.py', 'src\services\llm\__init__.py', 'src\services\rag\prompt_templates.py'. Reloading... + Process SpawnProcess-29: +Traceback (most recent call last): + File "C:\software\Python312\Lib\multiprocessing\process.py", line 314, in _bootstrap + self.run() + File "C:\software\Python312\Lib\multiprocessing\process.py", line 108, in run + self._target(*self._args, **self._kwargs) + File "C:\software\Python312\Lib\site-packages\uvicorn\_subprocess.py", line 80, in subprocess_started + target(sockets=sockets) + File "C:\software\Python312\Lib\site-packages\uvicorn\server.py", line 67, in run + return asyncio.run(self.serve(sockets=sockets)) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\asyncio\runners.py", line 194, in run + return runner.run(main) + ^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\asyncio\runners.py", line 118, in run + return self._loop.run_until_complete(task) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\asyncio\base_events.py", line 664, in run_until_complete + return future.result() + ^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\site-packages\uvicorn\server.py", line 71, in serve + await self._serve(sockets) + File "C:\software\Python312\Lib\site-packages\uvicorn\server.py", line 78, in _serve + config.load() + File "C:\software\Python312\Lib\site-packages\uvicorn\config.py", line 436, in load + self.loaded_app = import_from_string(self.app) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\site-packages\uvicorn\importer.py", line 22, in import_from_string + raise exc from None + File "C:\software\Python312\Lib\site-packages\uvicorn\importer.py", line 19, in import_from_string + module = importlib.import_module(module_str) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\importlib\__init__.py", line 90, in import_module + return _bootstrap._gcd_import(name[level:], package, level) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "", line 1381, in _gcd_import + File "", line 1354, in _find_and_load + File "", line 1325, in _find_and_load_unlocked + File "", line 929, in _load_unlocked + File "", line 994, in exec_module + File "", line 488, in _call_with_frames_removed + File "C:\Projects\AIProjects\AIRegulations\Demo-glm\src\api\main.py", line 8, in + from loguru import logger +ModuleNotFoundError: No module named 'loguru' +Process SpawnProcess-30: +Traceback (most recent call last): + File "C:\software\Python312\Lib\multiprocessing\process.py", line 314, in _bootstrap + self.run() + File "C:\software\Python312\Lib\multiprocessing\process.py", line 108, in run + self._target(*self._args, **self._kwargs) + File "C:\software\Python312\Lib\site-packages\uvicorn\_subprocess.py", line 80, in subprocess_started + target(sockets=sockets) + File "C:\software\Python312\Lib\site-packages\uvicorn\server.py", line 67, in run + return asyncio.run(self.serve(sockets=sockets)) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\asyncio\runners.py", line 194, in run + return runner.run(main) + ^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\asyncio\runners.py", line 118, in run + return self._loop.run_until_complete(task) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\asyncio\base_events.py", line 664, in run_until_complete + return future.result() + ^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\site-packages\uvicorn\server.py", line 71, in serve + await self._serve(sockets) + File "C:\software\Python312\Lib\site-packages\uvicorn\server.py", line 78, in _serve + config.load() + File "C:\software\Python312\Lib\site-packages\uvicorn\config.py", line 436, in load + self.loaded_app = import_from_string(self.app) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\site-packages\uvicorn\importer.py", line 22, in import_from_string + raise exc from None + File "C:\software\Python312\Lib\site-packages\uvicorn\importer.py", line 19, in import_from_string + module = importlib.import_module(module_str) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\importlib\__init__.py", line 90, in import_module + return _bootstrap._gcd_import(name[level:], package, level) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "", line 1381, in _gcd_import + File "", line 1354, in _find_and_load + File "", line 1325, in _find_and_load_unlocked + File "", line 929, in _load_unlocked + File "", line 994, in exec_module + File "", line 488, in _call_with_frames_removed + File "C:\Projects\AIProjects\AIRegulations\Demo-glm\src\api\main.py", line 8, in + from loguru import logger +ModuleNotFoundError: No module named 'loguru' +WARNING: WatchFiles detected changes in 'src\services\rag\prompt_templates.py', 'src\config\settings.py'. Reloading... + WARNING: WatchFiles detected changes in 'src\config\settings.py'. Reloading... + Process SpawnProcess-31: +Traceback (most recent call last): + File "C:\software\Python312\Lib\multiprocessing\process.py", line 314, in _bootstrap + self.run() + File "C:\software\Python312\Lib\multiprocessing\process.py", line 108, in run + self._target(*self._args, **self._kwargs) + File "C:\software\Python312\Lib\site-packages\uvicorn\_subprocess.py", line 80, in subprocess_started + target(sockets=sockets) + File "C:\software\Python312\Lib\site-packages\uvicorn\server.py", line 67, in run + return asyncio.run(self.serve(sockets=sockets)) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\asyncio\runners.py", line 194, in run + return runner.run(main) + ^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\asyncio\runners.py", line 118, in run + return self._loop.run_until_complete(task) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\asyncio\base_events.py", line 664, in run_until_complete + return future.result() + ^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\site-packages\uvicorn\server.py", line 71, in serve + await self._serve(sockets) + File "C:\software\Python312\Lib\site-packages\uvicorn\server.py", line 78, in _serve + config.load() + File "C:\software\Python312\Lib\site-packages\uvicorn\config.py", line 436, in load + self.loaded_app = import_from_string(self.app) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\site-packages\uvicorn\importer.py", line 22, in import_from_string + raise exc from None + File "C:\software\Python312\Lib\site-packages\uvicorn\importer.py", line 19, in import_from_string + module = importlib.import_module(module_str) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\importlib\__init__.py", line 90, in import_module + return _bootstrap._gcd_import(name[level:], package, level) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "", line 1381, in _gcd_import + File "", line 1354, in _find_and_load + File "", line 1325, in _find_and_load_unlocked + File "", line 929, in _load_unlocked + File "", line 994, in exec_module + File "", line 488, in _call_with_frames_removed + File "C:\Projects\AIProjects\AIRegulations\Demo-glm\src\api\main.py", line 8, in + from loguru import logger +ModuleNotFoundError: No module named 'loguru' +Process SpawnProcess-32: +Traceback (most recent call last): + File "C:\software\Python312\Lib\multiprocessing\process.py", line 314, in _bootstrap + self.run() + File "C:\software\Python312\Lib\multiprocessing\process.py", line 108, in run + self._target(*self._args, **self._kwargs) + File "C:\software\Python312\Lib\site-packages\uvicorn\_subprocess.py", line 80, in subprocess_started + target(sockets=sockets) + File "C:\software\Python312\Lib\site-packages\uvicorn\server.py", line 67, in run + return asyncio.run(self.serve(sockets=sockets)) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\asyncio\runners.py", line 194, in run + return runner.run(main) + ^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\asyncio\runners.py", line 118, in run + return self._loop.run_until_complete(task) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\asyncio\base_events.py", line 664, in run_until_complete + return future.result() + ^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\site-packages\uvicorn\server.py", line 71, in serve + await self._serve(sockets) + File "C:\software\Python312\Lib\site-packages\uvicorn\server.py", line 78, in _serve + config.load() + File "C:\software\Python312\Lib\site-packages\uvicorn\config.py", line 436, in load + self.loaded_app = import_from_string(self.app) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\site-packages\uvicorn\importer.py", line 22, in import_from_string + raise exc from None + File "C:\software\Python312\Lib\site-packages\uvicorn\importer.py", line 19, in import_from_string + module = importlib.import_module(module_str) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\importlib\__init__.py", line 90, in import_module + return _bootstrap._gcd_import(name[level:], package, level) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "", line 1381, in _gcd_import + File "", line 1354, in _find_and_load + File "", line 1325, in _find_and_load_unlocked + File "", line 929, in _load_unlocked + File "", line 994, in exec_module + File "", line 488, in _call_with_frames_removed + File "C:\Projects\AIProjects\AIRegulations\Demo-glm\src\api\main.py", line 8, in + from loguru import logger +ModuleNotFoundError: No module named 'loguru' +WARNING: WatchFiles detected changes in 'src\services\llm\document_skills_generator.py'. Reloading... + Process SpawnProcess-33: +Traceback (most recent call last): + File "C:\software\Python312\Lib\multiprocessing\process.py", line 314, in _bootstrap + self.run() + File "C:\software\Python312\Lib\multiprocessing\process.py", line 108, in run + self._target(*self._args, **self._kwargs) + File "C:\software\Python312\Lib\site-packages\uvicorn\_subprocess.py", line 80, in subprocess_started + target(sockets=sockets) + File "C:\software\Python312\Lib\site-packages\uvicorn\server.py", line 67, in run + return asyncio.run(self.serve(sockets=sockets)) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\asyncio\runners.py", line 194, in run + return runner.run(main) + ^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\asyncio\runners.py", line 118, in run + return self._loop.run_until_complete(task) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\asyncio\base_events.py", line 664, in run_until_complete + return future.result() + ^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\site-packages\uvicorn\server.py", line 71, in serve + await self._serve(sockets) + File "C:\software\Python312\Lib\site-packages\uvicorn\server.py", line 78, in _serve + config.load() + File "C:\software\Python312\Lib\site-packages\uvicorn\config.py", line 436, in load + self.loaded_app = import_from_string(self.app) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\site-packages\uvicorn\importer.py", line 22, in import_from_string + raise exc from None + File "C:\software\Python312\Lib\site-packages\uvicorn\importer.py", line 19, in import_from_string + module = importlib.import_module(module_str) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\software\Python312\Lib\importlib\__init__.py", line 90, in import_module + return _bootstrap._gcd_import(name[level:], package, level) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "", line 1381, in _gcd_import + File "", line 1354, in _find_and_load + File "", line 1325, in _find_and_load_unlocked + File "", line 929, in _load_unlocked + File "", line 994, in exec_module + File "", line 488, in _call_with_frames_removed + File "C:\Projects\AIProjects\AIRegulations\Demo-glm\src\api\main.py", line 8, in + from loguru import logger +ModuleNotFoundError: No module named 'loguru' diff --git a/logs/api.pid b/logs/api.pid new file mode 100644 index 0000000..6f9c7cd --- /dev/null +++ b/logs/api.pid @@ -0,0 +1 @@ +1894 diff --git a/quick_start.sh b/quick_start.sh index a7ff18a..4e99a54 100644 --- a/quick_start.sh +++ b/quick_start.sh @@ -237,6 +237,10 @@ 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 "" diff --git a/restart_all.sh b/restart_all.sh new file mode 100644 index 0000000..a7d2dfc --- /dev/null +++ b/restart_all.sh @@ -0,0 +1,23 @@ +#!/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 \ No newline at end of file diff --git a/scripts/clear_all.py b/scripts/clear_all.py new file mode 100644 index 0000000..c1046a4 --- /dev/null +++ b/scripts/clear_all.py @@ -0,0 +1,193 @@ +#!/usr/bin/env python3 +""" +清理脚本 - 清空Milvus向量数据库和MinIO对象存储中的所有文档数据 + +使用方法: + python scripts/clear_all.py # 清空所有数据 + python scripts/clear_all.py --milvus # 仅清空Milvus + python scripts/clear_all.py --minio # 仅清空MinIO + python scripts/clear_all.py --dry-run # 仅查看数据统计,不删除 +""" + +import argparse +import sys +import os + +# 添加项目路径 +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from src.config.settings import settings +from loguru import logger + + +def clear_milvus(dry_run: bool = False): + """清空Milvus向量数据库""" + try: + from pymilvus import connections, Collection, utility + + logger.info(f"连接Milvus: {settings.milvus_host}:{settings.milvus_port}") + connections.connect( + alias="default", + host=settings.milvus_host, + port=settings.milvus_port + ) + + collection_name = settings.milvus_collection + + # 检查collection是否存在 + if utility.has_collection(collection_name): + collection = Collection(collection_name) + + if dry_run: + # 仅统计数量 + collection.load() + count = collection.num_entities + logger.info(f"[DRY-RUN] Milvus collection '{collection_name}' 包含 {count} 条记录") + return count + + # 删除collection(数据会全部清空) + logger.info(f"删除collection: {collection_name}") + utility.drop_collection(collection_name) + logger.success(f"Milvus collection '{collection_name}' 已删除") + + # 重新创建collection(可选) + logger.info("重新创建collection...") + from src.services.storage.milvus_client import MilvusClient + client = MilvusClient() + client.connect() + client.create_collection(recreate=True) + client.disconnect() + logger.success("Milvus collection已重新创建") + + return 0 + else: + logger.info(f"Milvus collection '{collection_name}' 不存在") + return 0 + + except Exception as e: + logger.error(f"清理Milvus失败: {e}") + return -1 + finally: + try: + connections.disconnect("default") + except: + pass + + +def clear_minio(dry_run: bool = False): + """清空MinIO对象存储""" + try: + from minio import Minio + from minio.error import S3Error + + logger.info(f"连接MinIO: {settings.minio_endpoint}") + client = Minio( + settings.minio_endpoint, + access_key=settings.minio_access_key, + secret_key=settings.minio_secret_key, + secure=settings.minio_secure + ) + + bucket = settings.minio_bucket + + # 检查bucket是否存在 + if not client.bucket_exists(bucket): + logger.info(f"MinIO bucket '{bucket}' 不存在") + return 0 + + # 列出所有对象 + objects = list(client.list_objects(bucket)) + count = len(objects) + + if dry_run: + logger.info(f"[DRY-RUN] MinIO bucket '{bucket}' 包含 {count} 个对象:") + for obj in objects: + logger.info(f" - {obj.object_name} ({obj.size} bytes)") + return count + + # 删除所有对象 + logger.info(f"清空bucket '{bucket}' 中的 {count} 个对象...") + deleted = 0 + for obj in objects: + try: + client.remove_object(bucket, obj.object_name) + deleted += 1 + logger.info(f" 已删除: {obj.object_name}") + except S3Error as e: + logger.warning(f" 删除失败: {obj.object_name} - {e}") + + logger.success(f"MinIO已清空,删除 {deleted} 个对象") + return deleted + + except Exception as e: + logger.error(f"清理MinIO失败: {e}") + return -1 + + +def main(): + parser = argparse.ArgumentParser( + description="清空Milvus和MinIO中的所有文档数据" + ) + parser.add_argument( + "--milvus", + action="store_true", + help="仅清空Milvus向量数据库" + ) + parser.add_argument( + "--minio", + action="store_true", + help="仅清空MinIO对象存储" + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="仅查看数据统计,不执行删除" + ) + + args = parser.parse_args() + + # 配置日志格式 + logger.remove() + logger.add(sys.stdout, format="{time:HH:mm:ss} | {level} | {message}") + + logger.info("=" * 50) + logger.info("文档数据清理脚本") + logger.info("=" * 50) + + if args.dry_run: + logger.warning("[DRY-RUN 模式] 仅统计,不删除数据") + + results = {} + + # 清理Milvus + if args.milvus or (not args.milvus and not args.minio): + logger.info("\n[1] 清理Milvus向量数据库") + results["milvus"] = clear_milvus(dry_run=args.dry_run) + + # 清理MinIO + if args.minio or (not args.milvus and not args.minio): + logger.info("\n[2] 清理MinIO对象存储") + results["minio"] = clear_minio(dry_run=args.dry_run) + + # 输出结果摘要 + logger.info("\n" + "=" * 50) + logger.info("清理结果摘要:") + for name, count in results.items(): + if count >= 0: + status = "已清空" if not args.dry_run else "统计完成" + logger.info(f" {name}: {status} ({count} 条/个)") + else: + logger.error(f" {name}: 清理失败") + logger.info("=" * 50) + + # 返回状态码 + if all(c >= 0 for c in results.values()): + logger.success("清理完成!") + return 0 + else: + logger.error("清理失败,请检查错误信息") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) \ No newline at end of file diff --git a/src/__pycache__/__init__.cpython-312.pyc b/src/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..5164e1c Binary files /dev/null and b/src/__pycache__/__init__.cpython-312.pyc differ diff --git a/src/api/__pycache__/__init__.cpython-312.pyc b/src/api/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..7c36714 Binary files /dev/null and b/src/api/__pycache__/__init__.cpython-312.pyc differ diff --git a/src/api/__pycache__/main.cpython-312.pyc b/src/api/__pycache__/main.cpython-312.pyc new file mode 100644 index 0000000..40e476c Binary files /dev/null and b/src/api/__pycache__/main.cpython-312.pyc differ diff --git a/src/api/main.py b/src/api/main.py index 3badb30..bf3d9da 100644 --- a/src/api/main.py +++ b/src/api/main.py @@ -15,6 +15,7 @@ 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") @@ -26,14 +27,15 @@ 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应用 diff --git a/src/api/models/document.py b/src/api/models/document.py index ab5c64b..5e0d063 100644 --- a/src/api/models/document.py +++ b/src/api/models/document.py @@ -18,8 +18,10 @@ class DocumentUploadResponse(BaseModel): doc_id: str = Field(..., description="文档ID") doc_name: str = Field(..., description="文档名称") status: str = Field(..., description="处理状态") - message: str = Field(..., description="状态消息") - num_chunks: Optional[int] = Field(None, 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="时间戳") diff --git a/src/api/routes/__init__.py b/src/api/routes/__init__.py index 74c30c6..d153465 100644 --- a/src/api/routes/__init__.py +++ b/src/api/routes/__init__.py @@ -4,6 +4,7 @@ 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() @@ -11,5 +12,6 @@ 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"] \ No newline at end of file +__all__ = ["api_router", "documents_router", "knowledge_router", "agent_router"] \ No newline at end of file diff --git a/src/api/routes/agent.py b/src/api/routes/agent.py new file mode 100644 index 0000000..67a9b83 --- /dev/null +++ b/src/api/routes/agent.py @@ -0,0 +1,449 @@ +# 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} \ No newline at end of file diff --git a/src/api/routes/documents.py b/src/api/routes/documents.py index 3af9956..5351645 100644 --- a/src/api/routes/documents.py +++ b/src/api/routes/documents.py @@ -2,31 +2,87 @@ """文档上传与处理接口""" 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="文档版本") + 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() @@ -49,23 +105,45 @@ async def upload_document( # 文档名称 final_doc_name = doc_name or file.filename - logger.info(f"接收到文件上传: {final_doc_name}, 类型: {ext}") + # 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: - content = await file.read() f.write(content) - logger.info(f"文件已保存到: {temp_path}") + logger.info(f"临时文件已保存到: {temp_path}") - # 处理文档 - processor = DocumentProcessor() + # 上传到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 "" @@ -84,7 +162,9 @@ async def upload_document( doc_name=result.doc_name, status="success", message=result.message, - num_chunks=result.num_chunks + num_chunks=result.num_chunks, + summary=result.summary, + summary_latency_ms=result.summary_latency_ms ) else: raise HTTPException( @@ -114,4 +194,98 @@ async def get_document_status(doc_id: str): doc_name="", status="unknown", message="状态查询功能待实现" - ) \ No newline at end of file + ) + + +@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)} diff --git a/src/config/settings.py b/src/config/settings.py index 0acc7ba..55ffdcd 100644 --- a/src/config/settings.py +++ b/src/config/settings.py @@ -11,7 +11,7 @@ class Settings(BaseSettings): """应用配置""" # 应用基础配置 - app_name: str = Field(default="AI+合规智能中枢", description="应用名称") + 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="调试模式") @@ -32,7 +32,7 @@ class Settings(BaseSettings): 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="compliance-docs", description="文档存储桶名称") + minio_bucket: str = Field(default="upload-files", description="文档存储桶名称") minio_secure: bool = Field(default=False, description="是否使用HTTPS") # Redis配置 @@ -57,6 +57,28 @@ class Settings(BaseSettings): 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" @@ -70,4 +92,4 @@ def get_settings() -> Settings: # 导出默认配置实例 -settings = get_settings() \ No newline at end of file +settings = get_settings() diff --git a/src/services/agent/__init__.py b/src/services/agent/__init__.py new file mode 100644 index 0000000..edcf417 --- /dev/null +++ b/src/services/agent/__init__.py @@ -0,0 +1,7 @@ +# 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"] \ No newline at end of file diff --git a/src/services/agent/qa_agent.py b/src/services/agent/qa_agent.py new file mode 100644 index 0000000..f267299 --- /dev/null +++ b/src/services/agent/qa_agent.py @@ -0,0 +1,412 @@ +# 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 diff --git a/src/services/agent/session_manager.py b/src/services/agent/session_manager.py new file mode 100644 index 0000000..049ef0e --- /dev/null +++ b/src/services/agent/session_manager.py @@ -0,0 +1,247 @@ +# 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("所有会话已清空") \ No newline at end of file diff --git a/src/services/document_processor.py b/src/services/document_processor.py index a220431..68c27e7 100644 --- a/src/services/document_processor.py +++ b/src/services/document_processor.py @@ -1,5 +1,5 @@ # src/services/document_processor.py -"""文档处理主流程 - 解析→分块→嵌入→入库""" +"""文档处理主流程 - 解析→摘要→分块→嵌入→入库""" import os from typing import List, Dict, Optional @@ -13,6 +13,7 @@ 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 @@ -25,6 +26,8 @@ class ProcessingResult: num_chunks: int = 0 message: str = "" markdown_text: str = "" + summary: str = "" + summary_latency_ms: int = 0 class DocumentProcessor: @@ -34,15 +37,19 @@ class DocumentProcessor: 流程: 1. 文档解析(PDF/DOCX → Markdown) 2. 智能分块(章节级+条款级) - 3. 向量嵌入(BGE-M3 Dense+Sparse) - 4. 存储入库(Milvus向量数据库) + 3. LLM摘要生成(可选) + 4. 向量嵌入(BGE-M3 Dense+Sparse) + 5. 存储入库(Milvus向量数据库) """ def __init__( self, chunk_size: int = None, embedding_model: str = None, - use_mineru: bool = True + use_mineru: bool = True, + generate_summary: bool = False, # 默认不生成摘要,节省约60秒 + llm_provider: str = None, + llm_model: str = None ): """ 初始化文档处理器 @@ -51,10 +58,16 @@ class DocumentProcessor: 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("初始化文档处理组件...") @@ -65,12 +78,15 @@ class DocumentProcessor: # 分块器 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): @@ -88,9 +104,19 @@ class DocumentProcessor: 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 = "" @@ -100,6 +126,7 @@ class DocumentProcessor: Args: file_path: 文档文件路径 + doc_id: 文档ID(可选,默认自动生成) doc_name: 文档名称(可选,默认从文件名获取) regulation_type: 法规类型 version: 文档版本 @@ -107,8 +134,9 @@ class DocumentProcessor: Returns: ProcessingResult: 处理结果 """ - # 生成文档ID - doc_id = str(uuid.uuid4())[:8] + # 生成或使用传入的文档ID + if doc_id is None: + doc_id = str(uuid.uuid4())[:8] # 获取文档名称 if doc_name is None: @@ -116,6 +144,10 @@ class DocumentProcessor: logger.info(f"开始处理文档: {doc_name} (ID: {doc_id})") + # 初始化结果变量 + summary = "" + summary_latency_ms = 0 + try: # 1. 文档解析 logger.info("Step 1: 文档解析") @@ -129,8 +161,26 @@ class DocumentProcessor: message="文档解析失败,内容为空" ) - # 2. 智能分块 - logger.info("Step 2: 智能分块") + # 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, @@ -144,11 +194,13 @@ class DocumentProcessor: doc_id=doc_id, doc_name=doc_name, success=False, - message="分块失败,无有效内容" + message="分块失败,无有效内容", + markdown_text=markdown_text, + summary=summary ) - # 3. 向量嵌入 - logger.info("Step 3: 向量嵌入") + # 4. 向量嵌入 + logger.info("Step 4: 向量嵌入") embeddings = self._embed_chunks(chunks) if embeddings is None: @@ -156,11 +208,13 @@ class DocumentProcessor: doc_id=doc_id, doc_name=doc_name, success=False, - message="向量嵌入失败" + message="向量嵌入失败", + markdown_text=markdown_text, + summary=summary ) - # 4. 存储入库 - logger.info("Step 4: 存储入库") + # 5. 存储入库 + logger.info("Step 5: 存储入库") inserted_ids = self._insert_to_milvus(chunks, embeddings) logger.success(f"文档处理完成: {doc_name}, 共{len(inserted_ids)}条记录") @@ -171,7 +225,9 @@ class DocumentProcessor: success=True, num_chunks=len(inserted_ids), message="处理成功", - markdown_text=markdown_text + markdown_text=markdown_text, + summary=summary, + summary_latency_ms=summary_latency_ms ) except Exception as e: diff --git a/src/services/llm/__init__.py b/src/services/llm/__init__.py new file mode 100644 index 0000000..68bdd72 --- /dev/null +++ b/src/services/llm/__init__.py @@ -0,0 +1,15 @@ +# 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" +] \ No newline at end of file diff --git a/src/services/llm/base_client.py b/src/services/llm/base_client.py new file mode 100644 index 0000000..be5e138 --- /dev/null +++ b/src/services/llm/base_client.py @@ -0,0 +1,116 @@ +# 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) \ No newline at end of file diff --git a/src/services/llm/deepseek_client.py b/src/services/llm/deepseek_client.py new file mode 100644 index 0000000..1599daa --- /dev/null +++ b/src/services/llm/deepseek_client.py @@ -0,0 +1,130 @@ +# 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) diff --git a/src/services/llm/document_summarizer.py b/src/services/llm/document_summarizer.py new file mode 100644 index 0000000..abfa105 --- /dev/null +++ b/src/services/llm/document_summarizer.py @@ -0,0 +1,231 @@ +# 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) \ No newline at end of file diff --git a/src/services/llm/llm_factory.py b/src/services/llm/llm_factory.py new file mode 100644 index 0000000..6f07bff --- /dev/null +++ b/src/services/llm/llm_factory.py @@ -0,0 +1,258 @@ +# 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) diff --git a/src/services/llm/qwen_client.py b/src/services/llm/qwen_client.py new file mode 100644 index 0000000..0714e6d --- /dev/null +++ b/src/services/llm/qwen_client.py @@ -0,0 +1,392 @@ +# 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) diff --git a/src/services/rag/__init__.py b/src/services/rag/__init__.py new file mode 100644 index 0000000..b88e4b7 --- /dev/null +++ b/src/services/rag/__init__.py @@ -0,0 +1,12 @@ +# 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" +] \ No newline at end of file diff --git a/src/services/rag/context_builder.py b/src/services/rag/context_builder.py new file mode 100644 index 0000000..9a56a86 --- /dev/null +++ b/src/services/rag/context_builder.py @@ -0,0 +1,230 @@ +# 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) \ No newline at end of file diff --git a/src/services/rag/prompt_templates.py b/src/services/rag/prompt_templates.py new file mode 100644 index 0000000..73bab5d --- /dev/null +++ b/src/services/rag/prompt_templates.py @@ -0,0 +1,296 @@ +# 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 \ No newline at end of file diff --git a/src/services/rag/retriever.py b/src/services/rag/retriever.py new file mode 100644 index 0000000..bfac660 --- /dev/null +++ b/src/services/rag/retriever.py @@ -0,0 +1,193 @@ +# 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 \ No newline at end of file diff --git a/src/services/storage/__init__.py b/src/services/storage/__init__.py index 7227e84..fc784fa 100644 --- a/src/services/storage/__init__.py +++ b/src/services/storage/__init__.py @@ -2,5 +2,6 @@ """存储服务""" from .milvus_client import MilvusClient +from .minio_client import MinIOClient -__all__ = ["MilvusClient"] \ No newline at end of file +__all__ = ["MilvusClient", "MinIOClient"] \ No newline at end of file diff --git a/src/services/storage/minio_client.py b/src/services/storage/minio_client.py new file mode 100644 index 0000000..4441e3b --- /dev/null +++ b/src/services/storage/minio_client.py @@ -0,0 +1,352 @@ +# 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 \ No newline at end of file diff --git a/start_all.sh b/start_all.sh new file mode 100644 index 0000000..6266816 --- /dev/null +++ b/start_all.sh @@ -0,0 +1,217 @@ +#!/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 "" \ No newline at end of file diff --git a/start_api.sh b/start_api.sh index a28bd0d..6b08cf6 100644 --- a/start_api.sh +++ b/start_api.sh @@ -1,47 +1,41 @@ #!/bin/bash -# start_api.sh - 启动API服务(支持虚拟环境) +# start_api.sh - 启动迁移后的 backend API 服务 set -e VENV_DIR=".venv" +BACKEND_PATH="$PWD/backend" -# 创建日志目录 mkdir -p logs echo "========================================" -echo "启动 AI+合规智能中枢 API服务" +echo "启动 AI+合规智能中枢 API 服务" echo "========================================" echo "" -# 检查虚拟环境 if [ ! -d "$VENV_DIR" ]; then echo "错误: 虚拟环境不存在,请先运行 ./quick_start.sh" exit 1 fi -# 激活虚拟环境 -source $VENV_DIR/bin/activate +source "$VENV_DIR/bin/activate" echo "已激活虚拟环境: $VENV_DIR" echo "" -# 检查.env文件 if [ ! -f ".env" ]; then - echo "警告: .env文件不存在,使用默认配置" + 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 " 直接打开: frontend/index.html" -echo " 或启动服务: ./start_frontend.sh" -echo "" echo "正在启动..." echo "" -python -m uvicorn src.api.main:app --host $HOST --port $PORT --reload \ No newline at end of file +python -m uvicorn app.main:app --host "$HOST" --port "$PORT" --reload diff --git a/start_api_background.sh b/start_api_background.sh index 71838f0..c2cc551 100644 --- a/start_api_background.sh +++ b/start_api_background.sh @@ -1,70 +1,60 @@ #!/bin/bash -# start_api_background.sh - 后台启动API服务(生产环境,支持虚拟环境) +# 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 "后台启动 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 + 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 + 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 "正在后台启动..." -nohup $VENV_DIR/bin/python -m uvicorn src.api.main:app --host $HOST --port $PORT > $LOG_FILE 2>&1 & + +PYTHONPATH="$BACKEND_PATH${PYTHONPATH:+:$PYTHONPATH}" \ +nohup "$VENV_DIR/bin/python" -m uvicorn app.main:app --host "$HOST" --port "$PORT" > "$LOG_FILE" 2>&1 & PID=$! -# 保存PID -echo $PID > $PID_FILE +echo "$PID" > "$PID_FILE" -# 等待服务启动 sleep 3 -# 检查是否启动成功 -if ps -p $PID > /dev/null 2>&1; then +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 " 直接打开: frontend/index.html" - echo " 或启动服务: ./start_frontend.sh" - echo "" echo "查看日志:" echo " tail -f $LOG_FILE" echo "" @@ -72,6 +62,6 @@ if ps -p $PID > /dev/null 2>&1; then echo " ./stop_api.sh" else echo "服务启动失败,请查看日志: $LOG_FILE" - rm -f $PID_FILE + rm -f "$PID_FILE" exit 1 -fi \ No newline at end of file +fi diff --git a/start_frontend.sh b/start_frontend.sh index 481a04d..08e688e 100644 --- a/start_frontend.sh +++ b/start_frontend.sh @@ -17,12 +17,13 @@ if [ ! -d "$FRONTEND_DIR" ]; then exit 1 fi -# 检查index.html +# 检查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服务已启动:" diff --git a/status.sh b/status.sh new file mode 100644 index 0000000..f26c297 --- /dev/null +++ b/status.sh @@ -0,0 +1,157 @@ +#!/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 "" diff --git a/stop_all.sh b/stop_all.sh new file mode 100644 index 0000000..bfdf0dd --- /dev/null +++ b/stop_all.sh @@ -0,0 +1,160 @@ +#!/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 "" \ No newline at end of file diff --git a/stop_api.sh b/stop_api.sh index 5b22d30..854604f 100644 --- a/stop_api.sh +++ b/stop_api.sh @@ -33,7 +33,7 @@ else echo "PID文件不存在,服务可能未运行" # 尝试查找并停止所有uvicorn进程 - UVICORN_PIDS=$(pgrep -f "uvicorn src.api.main") + UVICORN_PIDS=$(pgrep -f "uvicorn app.main:app") if [ -n "$UVICORN_PIDS" ]; then echo "发现运行中的uvicorn进程: $UVICORN_PIDS" echo "是否停止这些进程? (y/n)" @@ -43,4 +43,4 @@ else echo "进程已停止" fi fi -fi \ No newline at end of file +fi