Compare commits

..

13 Commits

Author SHA1 Message Date
kplam db568740c5 feat: 入库改为独立页面,更新 API 说明文档与 Agent skill
- 新增 Ingest.vue 独立入库页(文本/文件/批量 + 任务进度列表),移除入库 Drawer
- 导航栏去掉「入库进度」,新增「文档入库」入口;知识库页「入库」跳转独立页
- API 说明页接口清单与后端对齐(补齐用户管理/上传批量/任务重试删除/reingest 等,修正鉴权标注)
- 更新 qmdsearch-agent skill 与 api-examples(鉴权说明、会话 token、新端点示例)
2026-08-04 20:50:53 +08:00
kplam 224eac0048 feat: 概览统计卡片单行自适应,最小宽度后横向滚动(隐藏滚动条) 2026-08-04 18:05:04 +08:00
kplam b5342b32a4 feat: 概览页移除类目分布,检索按钮与输入框同高对齐 2026-08-04 17:45:39 +08:00
kplam 55689e04fc feat: 知识库文档详情页内展示,概览检索框居中放大并参数持久化
- 知识库点击文档改为页面内展示详情,移除文档详情抽屉
- 概览检索框居中放大,检索参数(top_k/AI总结)改由 Modal 设置并持久化到 localStorage
- 概览统计使用 Statistic 样式展示在检索框上方
2026-08-04 17:37:19 +08:00
kplam 01b8c05984 fix: 修复知识库树形目录图标渲染为 [object Object] 2026-08-04 17:05:57 +08:00
kplam 35b7decd49 feat: 前端页面优化与入库任务重试/删除
- 概览与检索合并为单一概览页
- 类目/文档管理/入库整合为树形目录知识库页(Library),支持文档搜索
- 入库进度改为右侧 Drawer,支持任务详情、重试、删除
- 后端新增任务重试与删除接口
2026-08-04 12:58:11 +08:00
kplam f0fc20a9b6 feat: 前端补齐批量上传、重新摘要入库与入库进度页
- api/documents.js: 新增 uploadBatch / reingest / taskList 封装
- Ingest.vue: 新增「批量上传」标签页,多文件逐项异步入库并展示结果
- Documents.vue: 文档操作列新增「重新摘要」按钮(读原文件→删旧→新任务)
- 新增 Tasks.vue 入库进度页:近期任务列表 + 2s 自动轮询 + 状态徽标
- 路由与菜单新增「入库进度」入口
2026-08-04 11:52:14 +08:00
kplam bdd30f0a88 fix: 修复会话鉴权「登录后 token 无效」并补齐 NAS 部署流程
- deps.py: _create_redis_client 增加同步 ping 校验,Redis 不可达时正确降级为内存模式
- main.py: lifespan 复用 deps 的 UserStore 单例,避免 admin 与 API 请求实例不一致
- 前端: 强制改密弹窗(must_change_password 用户)、兼容新旧登录返回格式、markPasswordChanged
- 新增 NAS SSH 部署脚本与批处理测试;gitignore 前端构建产物
2026-08-04 11:38:28 +08:00
kplam 884cf78033 fix: 恢复被误删的 5 个运行时依赖
364fede 重构时误将 python-multipart/pypdf/python-docx/pypdfium2/
rapidocr-onnxruntime 从 pyproject 删除,导致:
- python-multipart 缺失使 app 启动即崩(Form 登录依赖)
- 其余为文档解析/OCR 必需(file_parser.py lazy import)
恢复为 92b062c 已验证可稳定运行的依赖基线。
2026-08-03 10:55:01 +08:00
kplam 364fede1b2 build: 精简 Dockerfile,移除镜像内前端构建阶段
前端采用挂载式部署(compose 将 ./frontend/dist 挂到 /app/app/static/admin),
镜像内烘焙前端纯属冗余且导致 NAS 构建时 npm ci 访问 registry 失败。
改为镜像只负责 Python 运行时+依赖,前端由本地 npm run build 后 rsync。
2026-08-01 21:48:41 +08:00
kplam c5d0f4c63e fix: 修复会话鉴权重构导致的测试回归
- 移除 conftest 中对 app.api.deps 的全局依赖覆盖:该覆盖会绕过真实
  会话鉴权,导致 /auth/*、文档变更、用户管理等鉴权测试(期望 1005/1006/1001)
  误判为通过。鉴权测试现走真实 UserStore/SessionStore。
- 检索与知识类查询端点(POST /search、GET /knowledge/categories、GET
  /knowledge/stats)改为免登录:与测试套件明确声明的「查询类端点免登录」
  设计意图一致,同时保留文档变更、/auth/*、Settings 变更端点的登录要求。

变更文件:tests/conftest.py、app/api/v1/search.py、app/api/v1/knowledge.py
2026-08-01 00:48:42 +08:00
kplam a1b80566bb feat: 升级 Ollama 本地模型 qwen2.5:1.5b → qwen3:1.7b
- config.py / .env.example / docker-compose.yml:默认模型改为 qwen3:1.7b(备选 qwen3:4b),compose 同步拉取
- 经 runtime_settings 注入 summarize/query/classify 三用途,改默认值即全局生效
- README / CLAUDE.md / 前端 Settings.vue 占位符同步更新
2026-08-01 00:28:50 +08:00
kplam 83eb8d0dd0 feat: 新增 QMDSearch Agent Skill 包与下载入口
- 新增 skills/qmdsearch-agent(SKILL.md + references/api-examples.md),文档化
  AI Agent 鉴权、文本/文件上传入库(异步轮询)与分层检索调用方式
- 打包 QMDSearch-Agent-Skill.zip 至 app/static/agent-skill/
- 后端新增 GET /agent-skill 免登录下载路由(随 ./app 卷挂载,restart 即生效)
- 前端 API 说明页新增「AI Agent Skill 下载」卡片,链接至 /agent-skill
2026-08-01 00:11:05 +08:00
47 changed files with 4955 additions and 1244 deletions
+2 -2
View File
@@ -25,8 +25,8 @@ REDIS_PORT=6379
# --- Ollama 本地模型(文档三级总结)--- # --- Ollama 本地模型(文档三级总结)---
OLLAMA_PORT=11434 OLLAMA_PORT=11434
OLLAMA_MODEL=qwen2.5:1.5b OLLAMA_MODEL=qwen3:1.7b
# 备选模型: qwen2.5:3b(更好的总结质量,需更多内存) # 备选模型: qwen3:4b(更好的总结质量,需更多内存)
# EMBEDDING_PROVIDER=local 时使用的 Ollama 嵌入模型 # EMBEDDING_PROVIDER=local 时使用的 Ollama 嵌入模型
OLLAMA_EMBEDDING_MODEL=bge-m3 OLLAMA_EMBEDDING_MODEL=bge-m3
+3
View File
@@ -9,6 +9,9 @@ build/
# Frontend # Frontend
node_modules/ node_modules/
# 前端构建产物(本地残留的 Vue 构建 / 符号链接,勿提交;线上由 frontend/dist 挂载)
app/static/admin
# Env # Env
.env .env
.env.local .env.local
+8 -5
View File
@@ -12,7 +12,7 @@ QMDSearch 是面向 AI Agent 的分层信息检索服务,支持多层级知识
- **缓存**: Redis (Docker) - **缓存**: Redis (Docker)
- **关系数据库**: PostgreSQL (Docker, 可选) - **关系数据库**: PostgreSQL (Docker, 可选)
- **嵌入模型**: OpenAI / 本地模型 (通过配置切换) - **嵌入模型**: OpenAI / 本地模型 (通过配置切换)
- **本地推理模型**: Ollama (Docker) — 用于文档三级总结,默认 qwen2.5:1.5b - **本地推理模型**: Ollama (Docker) — 用于文档三级总结,默认 qwen3:1.7b
- **部署**: Docker Compose on NAS - **部署**: Docker Compose on NAS
## 项目结构 ## 项目结构
@@ -90,12 +90,15 @@ QMDSearch/
| POST | `/api/v1/search` | 分层检索 | 免登录 | | POST | `/api/v1/search` | 分层检索 | 免登录 |
| POST | `/api/v1/documents` | 文档入库(202 异步入库,返回 task_id | Bearer | | POST | `/api/v1/documents` | 文档入库(202 异步入库,返回 task_id | Bearer |
| POST | `/api/v1/documents/upload` | multipart 文件上传入库(202 异步,支持 .txt/.md/.html/.htm/.pdf/.docx | Bearer | | POST | `/api/v1/documents/upload` | multipart 文件上传入库(202 异步,支持 .txt/.md/.html/.htm/.pdf/.docx | Bearer |
| POST | `/api/v1/documents/upload-batch` | 批量文件上传入库(多文件,202 异步) | Bearer |
| GET | `/api/v1/documents/tasks` | 入库任务列表(近期,按时间降序) | Bearer |
| GET | `/api/v1/documents/tasks/{task_id}` | 入库任务状态查询(done 附 resultfailed 附 error | 免登录 | | GET | `/api/v1/documents/tasks/{task_id}` | 入库任务状态查询(done 附 resultfailed 附 error | 免登录 |
| GET | `/api/v1/knowledge/categories` | 知识分类类目集 | 免登录 | | GET | `/api/v1/knowledge/categories` | 知识分类类目集 | 免登录 |
| GET | `/api/v1/knowledge/stats` | 统计(四层点数 + 类目分布 + uncategorized 数) | 免登录 | | GET | `/api/v1/knowledge/stats` | 统计(四层点数 + 类目分布 + uncategorized 数) | 免登录 |
| GET | `/api/v1/documents` | 文档列表(limit/offset 分页) | 免登录 | | GET | `/api/v1/documents` | 文档列表(limit/offset 分页) | 免登录 |
| GET | `/api/v1/documents/{doc_id}` | 文档详情 | 免登录 | | GET | `/api/v1/documents/{doc_id}` | 文档详情 | 免登录 |
| GET | `/api/v1/documents/{doc_id}/file` | 下载关联的原始文件(免登录) | 免登录 | | GET | `/api/v1/documents/{doc_id}/file` | 下载关联的原始文件(免登录) | 免登录 |
| POST | `/api/v1/documents/{doc_id}/reingest` | 重新摘要入库(读原文件→删旧→重跑流水线) | Bearer |
| DELETE | `/api/v1/documents/{doc_id}` | 删除文档(幂等) | Bearer | | DELETE | `/api/v1/documents/{doc_id}` | 删除文档(幂等) | Bearer |
| POST | `/api/v1/auth/login` | 用户名密码登录,签发 session tokenTTL 12h | 免登录 | | POST | `/api/v1/auth/login` | 用户名密码登录,签发 session tokenTTL 12h | 免登录 |
| POST | `/api/v1/auth/logout` | 退出登录(删除当前 session | Bearer | | POST | `/api/v1/auth/logout` | 退出登录(删除当前 session | Bearer |
@@ -108,13 +111,13 @@ QMDSearch/
| DELETE | `/api/v1/auth/users/{username}` | 删除用户(清除其 session;禁删自己/最后一个 admin | Bearer + admin | | DELETE | `/api/v1/auth/users/{username}` | 删除用户(清除其 session;禁删自己/最后一个 admin | Bearer + admin |
| GET | `/admin` | 管理页面 | 页面登录门禁 | | GET | `/admin` | 管理页面 | 页面登录门禁 |
「Bearer」指请求头 `Authorization: Bearer <token>`token 经 `/api/v1/auth/login` 获取;变更类文档端点(POST /documents、POST /documents/upload、DELETE /documents/{id}需 Bearer tokenadmin/user 角色均可),查询类端点免登录。 「Bearer」指请求头 `Authorization: Bearer <token>`token 经 `/api/v1/auth/login` 获取;变更类文档端点(POST /documents、POST /documents/upload、POST /documents/upload-batch、POST /documents/{id}/reingest、DELETE /documents/{id})与 GET /documents/tasks 任务列表需 Bearer tokenadmin/user 角色均可),其余查询类端点免登录。
入库任务状态持久化在 Redis(key: `ingest_task:{task_id}`):进行中与 done 保留 24hfailed 保留 7 天;Redis 不可用时降级为纯内存。 入库任务状态持久化在 Redis(key: `ingest_task:{task_id}`):进行中与 done 保留 24hfailed 保留 7 天;Redis 不可用时降级为纯内存。
## 管理页面 ## 管理页面
浏览器访问 `/admin`,页面带登录门禁(未登录/token 失效自动回登录卡片;must_change_password 用户先强制改密后方可进入)。单页面含个区块(admin 视角;user 角色无用户管理区块):概览、个人中心(user 角色默认进入且可见,含账号信息/改密/退出)、文档管理(列表/详情/删除)、文档入库(文本 + 文件上传)、检索测试台、类目列表、API 指南(端点清单 + 在线测试台)、用户管理(仅 admin 角色挂载,含搜索筛选/行内编辑角色与启用状态/弹窗式重置密码与删除)。 浏览器访问 `/admin`,页面带登录门禁(未登录/token 失效自动回登录卡片;must_change_password 用户先强制改密后方可进入)。单页面含个区块(admin 视角;user 角色无用户管理区块):概览、个人中心(user 角色默认进入且可见,含账号信息/改密/退出)、文档管理(列表/详情/删除)、文档入库(文本 + 文件上传,文件上传支持批量多选)、入库进度(展示近期任务与状态,2s 轮询自动刷新)、检索测试台、类目列表、API 指南(端点清单 + 在线测试台)、用户管理(仅 admin 角色挂载,含搜索筛选/行内编辑角色与启用状态/弹窗式重置密码与删除)。
## 认证与用户 ## 认证与用户
@@ -168,8 +171,8 @@ QMDSearch/
### 本地模型 (Ollama) ### 本地模型 (Ollama)
- 服务: Ollama 容器,默认模型 `qwen2.5:1.5b`(约 1GB,适合 NAS 低资源环境) - 服务: Ollama 容器,默认模型 `qwen3:1.7b`(约 1.3GB,适合 NAS 低资源环境)
- 备选模型: `qwen2.5:3b`(更好的总结质量,约 2GB - 备选模型: `qwen3:4b`(更好的总结质量,约 2.5GB
- 模型通过环境变量 `OLLAMA_MODEL` 配置 - 模型通过环境变量 `OLLAMA_MODEL` 配置
- 首次启动时自动拉取模型,需 NAS 可访问外网 - 首次启动时自动拉取模型,需 NAS 可访问外网
+8 -18
View File
@@ -1,17 +1,10 @@
# ---------- Stage 1: 前端构建 ---------- # ---------- Python 后端运行时镜像 ----------
FROM node:20-slim AS frontend-builder # 部署模型说明:
# 前端采用「挂载式」部署——docker-compose 将宿主机的 ./frontend/dist
WORKDIR /frontend # 挂到容器的 /app/app/static/admin,运行时直接覆盖镜像内同名目录。
# 因此镜像【不再构建前端】,只负责 Python 运行时 + 依赖;前端由本地
# 依赖层(利用 Docker 层缓存) # `npm run build` 后 rsync 到 NAS,或挂载既有 dist。
COPY frontend/package.json frontend/package-lock.json ./ # 仅当 pyproject.toml / uv.lock 变化时才需 `docker build -t qmdsearch-app .` 一次。
RUN npm ci
# 源码 + 构建
COPY frontend/ ./
RUN npm run build
# ---------- Stage 2: Python 后端 ----------
FROM python:3.12-slim AS base FROM python:3.12-slim AS base
WORKDIR /app WORKDIR /app
@@ -19,7 +12,7 @@ WORKDIR /app
# 安装 uv(放到 PATH 中,供 uv sync 与 CMD 的 uv run 使用) # 安装 uv(放到 PATH 中,供 uv sync 与 CMD 的 uv run 使用)
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
# 依赖层 # 依赖层(利用 Docker 层缓存)
COPY pyproject.toml uv.lock ./ COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-dev RUN uv sync --frozen --no-dev
@@ -27,9 +20,6 @@ RUN uv sync --frozen --no-dev
COPY app/ app/ COPY app/ app/
COPY scripts/ scripts/ COPY scripts/ scripts/
# 前端构建产物 → 后端静态目录(SPA 由 /admin 路由服务)
COPY --from=frontend-builder /frontend/dist /app/app/static/admin
EXPOSE 8000 EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \ HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
+3 -3
View File
@@ -22,7 +22,7 @@
| 向量数据库 | QdrantDocker | | 向量数据库 | QdrantDocker |
| 缓存 | RedisDocker | | 缓存 | RedisDocker |
| 嵌入模型 | OpenAI / Ollama 本地模型(可切换) | | 嵌入模型 | OpenAI / Ollama 本地模型(可切换) |
| 本地推理 | Ollama + qwen2.5:1.5b(文档总结) | | 本地推理 | Ollama + qwen3:1.7b(文档总结) |
| OCR | rapidocr-onnxruntime + pypdfium2(扫描件 PDF | | OCR | rapidocr-onnxruntime + pypdfium2(扫描件 PDF |
| 部署 | Docker Compose on NAS | | 部署 | Docker Compose on NAS |
@@ -123,7 +123,7 @@ docker compose up -d
### 4. 拉取 Ollama 模型(首次启动后) ### 4. 拉取 Ollama 模型(首次启动后)
```bash ```bash
docker exec qmdsearch-ollama ollama pull qwen2.5:1.5b docker exec qmdsearch-ollama ollama pull qwen3:1.7b
``` ```
### 5. 验证服务 ### 5. 验证服务
@@ -232,7 +232,7 @@ curl -X POST http://localhost:8000/api/v1/documents/upload \
| `EMBEDDING_PROVIDER` | 嵌入模型提供商(openai / local | `openai` | | `EMBEDDING_PROVIDER` | 嵌入模型提供商(openai / local | `openai` |
| `OPENAI_API_KEY` | OpenAI API Key | - | | `OPENAI_API_KEY` | OpenAI API Key | - |
| `EMBEDDING_MODEL` | 嵌入模型名称 | `text-embedding-3-small` | | `EMBEDDING_MODEL` | 嵌入模型名称 | `text-embedding-3-small` |
| `OLLAMA_MODEL` | Ollama 本地模型 | `qwen2.5:1.5b` | | `OLLAMA_MODEL` | Ollama 本地模型 | `qwen3:1.7b` |
| `RETRIEVAL_TOP_K` | 检索召回数 | `20` | | `RETRIEVAL_TOP_K` | 检索召回数 | `20` |
| `RETRIEVAL_FINAL_K` | 最终返回数 | `5` | | `RETRIEVAL_FINAL_K` | 最终返回数 | `5` |
| `INGEST_MAX_CONCURRENCY` | 入库并发上限 | `2` | | `INGEST_MAX_CONCURRENCY` | 入库并发上限 | `2` |
+12 -2
View File
@@ -8,6 +8,7 @@ UserStore/SessionStore 为模块级懒加载单例:Redis 客户端创建失败
import structlog import structlog
from fastapi import Depends, Header from fastapi import Depends, Header
from redis import asyncio as redis_async from redis import asyncio as redis_async
from redis import Redis as RedisSync
from app.api.response import ApiError from app.api.response import ApiError
from app.config import settings from app.config import settings
@@ -22,11 +23,20 @@ _session_store: SessionStore | None = None
def _create_redis_client() -> redis_async.Redis | None: def _create_redis_client() -> redis_async.Redis | None:
"""创建 redis.asyncio 客户端(decode_responses=True);失败返回 None 走内存降级""" """创建 redis.asyncio 客户端(decode_responses=True);连接失败返回 None 走内存降级
注意:redis.asyncio.from_url() 仅构造客户端对象,不会实际连接 Redis,
因此需要用同步客户端 ping 一次确认连接可用,否则后续操作才会报错,
导致 UserStore/SessionStore 无法降级为内存模式。
"""
try: try:
# 同步 ping 确认 Redis 可达(from_url 不会实际连接)
sync_client = RedisSync.from_url(settings.redis_url, decode_responses=True)
sync_client.ping()
sync_client.close()
return redis_async.from_url(settings.redis_url, decode_responses=True) return redis_async.from_url(settings.redis_url, decode_responses=True)
except Exception: except Exception:
logger.warning("Redis 客户端创建失败,认证存储降级为内存模式", exc_info=True) logger.warning("Redis 连接失败,认证存储降级为内存模式", exc_info=True)
return None return None
+165
View File
@@ -183,6 +183,149 @@ async def upload_document(
) )
async def _process_single_upload(file: UploadFile) -> dict[str, Any]:
"""处理单个上传文件:校验 → 提取文本 → 落盘 → 提交入库
供批量上传复用:返回成功 {"filename", "task_id"} 或失败 {"filename", "error"}。
title/source 使用文件名默认值,不接受用户传入的额外表单字段。
"""
original_filename = file.filename or "unnamed"
ext = Path(original_filename).suffix.lower()
# 1. 扩展名校验
allowed = _allowed_extensions()
if ext not in allowed:
return {"filename": original_filename, "error": f"不支持的文件类型: {ext or '(无扩展名)'}"}
# 2. 读取字节并校验大小
try:
content = await file.read()
except Exception as exc:
return {"filename": original_filename, "error": f"文件读取失败: {exc}"}
max_bytes = settings.upload_max_size_mb * 1024 * 1024
if len(content) > max_bytes:
return {"filename": original_filename, "error": f"文件超过大小上限: {settings.upload_max_size_mb}MB"}
# 3. 提取文本
try:
text = parse_file(original_filename, content)
except ValueError as exc:
return {"filename": original_filename, "error": str(exc)}
if not text.strip():
return {"filename": original_filename, "error": "无法从文件提取文本"}
# 4. 落盘(按 YYYY/MM 日期分片;失败仅 warning,不阻塞入库)
doc_id = uuid.uuid4().hex
metadata_dict: dict[str, str] = {}
try:
upload_dir = Path(settings.upload_dir).resolve()
shard_subdir = datetime.now(UTC).strftime("%Y/%m")
target_dir = upload_dir / shard_subdir
target_dir.mkdir(parents=True, exist_ok=True)
target = target_dir / f"{doc_id}_{original_filename}"
target.write_bytes(content)
metadata_dict.update(
{
"raw_file_path": str(target),
"original_filename": original_filename,
"original_size_bytes": str(len(content)),
}
)
logger.info(
"上传文件已落盘", doc_id=doc_id, saved_path=str(target), size=len(content)
)
except Exception:
logger.warning(
"上传文件落盘失败,仅做文本入库",
doc_id=doc_id,
filename=original_filename,
exc_info=True,
)
# 5. 默认 title / source
title = Path(original_filename).stem
source = f"file:{original_filename}"
# 6. 提交入库流水线
doc_input = DocumentInput(text=text, title=title, source=source, metadata=metadata_dict)
task_id = await _get_task_manager().submit(doc_input)
return {"filename": original_filename, "task_id": task_id}
@router.post("/documents/upload-batch")
async def upload_batch(
files: list[UploadFile] = File(...),
user: UserRecord = Depends(get_current_user),
) -> JSONResponse:
"""批量文件上传入库:逐文件复用单文件逻辑,收集成功与失败结果
返回 202 + {tasks: [{filename, task_id}], failed: [{filename, error}]}
空文件列表返回 1001。
"""
if not files:
raise ApiError(1001, "未提供任何文件")
tasks: list[dict[str, Any]] = []
failed: list[dict[str, Any]] = []
for file in files:
result = await _process_single_upload(file)
if "task_id" in result:
tasks.append({"filename": result["filename"], "task_id": result["task_id"]})
else:
failed.append({"filename": result["filename"], "error": result["error"]})
return JSONResponse(status_code=202, content=ok({"tasks": tasks, "failed": failed}))
@router.get("/documents/tasks")
async def list_ingest_tasks(
limit: int = Query(default=20, ge=1, le=100),
user: UserRecord = Depends(get_current_user),
) -> dict[str, Any]:
"""列出近期入库任务(合并内存与 Redis 镜像,按 updated_at 降序)"""
tasks = await _get_task_manager().list_tasks(limit=limit)
return ok({"items": tasks, "total": len(tasks)})
@router.post("/documents/{doc_id}/reingest")
async def reingest_document(
doc_id: str, user: UserRecord = Depends(get_current_user)
) -> JSONResponse:
"""重新入库:读原始文件 → 删旧数据 → 提交新入库任务
仅对有原始文件落盘记录的文档可重新入库;重新入库会生成新 doc_id。
"""
meta = await _get_qdrant().get_l1_metadata(doc_id)
if meta is None:
raise ApiError(1004, "文档不存在")
raw_file_path = meta.get("raw_file_path", "")
if not raw_file_path:
raise ApiError(1001, "该文档无原始文件,无法重新入库")
path = Path(raw_file_path)
if not path.is_file():
raise ApiError(1004, "文件不存在")
# 读原文件并重新提取文本
try:
text = parse_file(meta.get("original_filename", path.name), path.read_bytes())
except ValueError as exc:
raise ApiError(1001, str(exc)) from exc
if not text.strip():
raise ApiError(1001, "无法从文件提取文本")
# 删旧数据(四层集合按 doc_id 清除,幂等)
await _get_qdrant().delete_by_doc_id(doc_id)
# 构造新文档输入(保留原 metadatatitle/source 从 meta 取或文件名回退)
original_filename = meta.get("original_filename", path.name)
title = meta.get("title") or Path(original_filename).stem
source = meta.get("source") or f"file:{original_filename}"
doc_input = DocumentInput(text=text, title=title, source=source, metadata=dict(meta))
task_id = await _get_task_manager().submit(doc_input)
return JSONResponse(
status_code=202, content=ok({"task_id": task_id, "status": "pending"})
)
@router.get("/documents/tasks/{task_id}") @router.get("/documents/tasks/{task_id}")
async def get_ingest_task(task_id: str) -> dict[str, Any]: async def get_ingest_task(task_id: str) -> dict[str, Any]:
"""查询入库任务状态:含 task_id/status/created_at/updated_atdone 附 resultfailed 附 error""" """查询入库任务状态:含 task_id/status/created_at/updated_atdone 附 resultfailed 附 error"""
@@ -192,6 +335,28 @@ async def get_ingest_task(task_id: str) -> dict[str, Any]:
return ok(task) return ok(task)
@router.post("/documents/tasks/{task_id}/retry")
async def retry_ingest_task(
task_id: str, user: UserRecord = Depends(get_current_user)
) -> JSONResponse:
"""重试入库任务:用原 source 重新提交一个新任务,返回新 task_id"""
new_task_id = await _get_task_manager().retry(task_id)
if new_task_id is None:
raise ApiError(1004, "任务不存在或缺少重试所需的源信息")
return JSONResponse(
status_code=202, content=ok({"task_id": new_task_id, "status": "pending"})
)
@router.delete("/documents/tasks/{task_id}")
async def delete_ingest_task(
task_id: str, user: UserRecord = Depends(get_current_user)
) -> dict[str, Any]:
"""删除入库任务(内存注册表 + Redis 镜像),幂等"""
deleted = await _get_task_manager().delete(task_id)
return ok({"task_id": task_id, "deleted": deleted})
@router.get("/documents") @router.get("/documents")
async def list_documents( async def list_documents(
limit: int = Query(default=20, ge=1, le=100), limit: int = Query(default=20, ge=1, le=100),
+3 -4
View File
@@ -4,11 +4,10 @@ from functools import lru_cache
from typing import Any from typing import Any
import structlog import structlog
from fastapi import APIRouter, Depends from fastapi import APIRouter
from app.api.response import ApiError, ok from app.api.response import ApiError, ok
from app.config import settings from app.config import settings
from app.core.auth import AuthUser, get_current_user
from app.models.knowledge import UNCATEGORIZED, TaxonomyCategory, load_taxonomy from app.models.knowledge import UNCATEGORIZED, TaxonomyCategory, load_taxonomy
from app.services.qdrant import ALL_COLLECTIONS, COLLECTION_L1, QdrantService from app.services.qdrant import ALL_COLLECTIONS, COLLECTION_L1, QdrantService
@@ -37,14 +36,14 @@ def _get_taxonomy() -> list[TaxonomyCategory]:
@router.get("/knowledge/categories") @router.get("/knowledge/categories")
async def list_categories(user: AuthUser = Depends(get_current_user)) -> dict[str, Any]: async def list_categories() -> dict[str, Any]:
"""返回完整知识分类类目集""" """返回完整知识分类类目集"""
categories = _get_taxonomy() categories = _get_taxonomy()
return ok({"categories": [c.model_dump() for c in categories], "count": len(categories)}) return ok({"categories": [c.model_dump() for c in categories], "count": len(categories)})
@router.get("/knowledge/stats") @router.get("/knowledge/stats")
async def knowledge_stats(user: AuthUser = Depends(get_current_user)) -> dict[str, Any]: async def knowledge_stats() -> dict[str, Any]:
"""返回四层集合规模与 L1 类目分布统计""" """返回四层集合规模与 L1 类目分布统计"""
service = _get_qdrant() service = _get_qdrant()
try: try:
+2 -3
View File
@@ -4,10 +4,9 @@ from hashlib import sha256
from typing import Any from typing import Any
import structlog import structlog
from fastapi import APIRouter, Depends from fastapi import APIRouter
from app.api.response import ApiError, ok from app.api.response import ApiError, ok
from app.core.auth import AuthUser, get_current_user
from app.core.retriever import Retriever from app.core.retriever import Retriever
from app.models.search import SearchRequest from app.models.search import SearchRequest
from app.services.redis import get_cache from app.services.redis import get_cache
@@ -34,7 +33,7 @@ def _cache_key(request: SearchRequest) -> str:
@router.post("/search") @router.post("/search")
async def search(request: SearchRequest, user: AuthUser = Depends(get_current_user)) -> dict[str, Any]: async def search(request: SearchRequest) -> dict[str, Any]:
"""分层检索入口,返回统一包装的 SearchResponse """分层检索入口,返回统一包装的 SearchResponse
先查 Redis 缓存:命中直接返回缓存的响应;未命中走检索流程并回写缓存。 先查 Redis 缓存:命中直接返回缓存的响应;未命中走检索流程并回写缓存。
+6 -5
View File
@@ -11,7 +11,8 @@ from fastapi import APIRouter, Depends
from pydantic import BaseModel from pydantic import BaseModel
from app.api.response import ApiError, ok from app.api.response import ApiError, ok
from app.core.auth import AuthUser, get_current_user, require_admin from app.api.deps import get_current_user, require_admin
from app.core.users import UserRecord
from app.core.dedup import invalidate_dedup_strategy_cache from app.core.dedup import invalidate_dedup_strategy_cache
from app.core.file_parser import ( from app.core.file_parser import (
list_docx_plugins, list_docx_plugins,
@@ -46,7 +47,7 @@ class SettingsUpdateRequest(BaseModel):
@router.get("/settings") @router.get("/settings")
async def get_settings(user: AuthUser = Depends(get_current_user)) -> dict[str, Any]: async def get_settings(user: UserRecord = Depends(get_current_user)) -> dict[str, Any]:
"""返回当前 RuntimeSettings(任何登录用户可读)""" """返回当前 RuntimeSettings(任何登录用户可读)"""
cfg = get_runtime_settings() cfg = get_runtime_settings()
return ok(cfg.model_dump(mode="json")) return ok(cfg.model_dump(mode="json"))
@@ -55,7 +56,7 @@ async def get_settings(user: AuthUser = Depends(get_current_user)) -> dict[str,
@router.put("/settings") @router.put("/settings")
async def update_settings( async def update_settings(
body: SettingsUpdateRequest, body: SettingsUpdateRequest,
user: AuthUser = Depends(require_admin), user: UserRecord = Depends(require_admin),
) -> dict[str, Any]: ) -> dict[str, Any]:
"""部分更新 RuntimeSettings(仅 admin """部分更新 RuntimeSettings(仅 admin
@@ -82,7 +83,7 @@ async def update_settings(
@router.get("/settings/schema") @router.get("/settings/schema")
async def get_settings_schema( async def get_settings_schema(
user: AuthUser = Depends(get_current_user), user: UserRecord = Depends(get_current_user),
) -> dict[str, Any]: ) -> dict[str, Any]:
"""返回可选插件与策略列表(前端 Settings 页渲染选项用)""" """返回可选插件与策略列表(前端 Settings 页渲染选项用)"""
return ok( return ok(
@@ -98,7 +99,7 @@ async def get_settings_schema(
@router.post("/settings/reset") @router.post("/settings/reset")
async def reset_settings( async def reset_settings(
user: AuthUser = Depends(require_admin), user: UserRecord = Depends(require_admin),
) -> dict[str, Any]: ) -> dict[str, Any]:
"""重置 RuntimeSettings 为默认值(仅 admin),同时清缓存""" """重置 RuntimeSettings 为默认值(仅 admin),同时清缓存"""
try: try:
+1 -1
View File
@@ -18,7 +18,7 @@ class Settings(BaseSettings):
# Ollama 本地模型(用于文档三级总结) # Ollama 本地模型(用于文档三级总结)
ollama_base_url: str = "http://localhost:11434" ollama_base_url: str = "http://localhost:11434"
ollama_model: str = "qwen2.5:1.5b" # 备选: qwen2.5:3b ollama_model: str = "qwen3:1.7b" # 备选: qwen3:4b(更好的总结质量,需更多内存)
ollama_embedding_model: str = "bge-m3" # embedding_provider=local 时使用的嵌入模型 ollama_embedding_model: str = "bge-m3" # embedding_provider=local 时使用的嵌入模型
# 重排模型(cross-encoder,经 Ollama /api/rerank,对 chunk 候选做最终精排) # 重排模型(cross-encoder,经 Ollama /api/rerank,对 chunk 候选做最终精排)
+120 -1
View File
@@ -20,7 +20,7 @@ from typing import Any
import structlog import structlog
from app.config import Settings from app.config import Settings
from app.core.dedup import DEDUP_KEY_PREFIX, get_dedup_strategy from app.core.dedup import get_dedup_strategy
from app.core.ingestion import Ingester, IngestionError from app.core.ingestion import Ingester, IngestionError
from app.models.document import DocumentInput from app.models.document import DocumentInput
from app.services.redis import RedisCache from app.services.redis import RedisCache
@@ -79,6 +79,8 @@ class IngestTaskManager:
""" """
task_id = uuid.uuid4().hex task_id = uuid.uuid4().hex
now = _utc_now_iso() now = _utc_now_iso()
filename = self._extract_filename(doc)
source = self._extract_source(doc)
dedup = get_dedup_strategy(self._redis) dedup = get_dedup_strategy(self._redis)
# 1. 去重命中:直接置 done,复用旧结果,不调 _run # 1. 去重命中:直接置 done,复用旧结果,不调 _run
@@ -93,6 +95,8 @@ class IngestTaskManager:
"updated_at": now, "updated_at": now,
"result": result_dict, "result": result_dict,
"error": None, "error": None,
"filename": filename,
"source": source,
} }
self._schedule_mirror(task_id) self._schedule_mirror(task_id)
logger.info( logger.info(
@@ -110,6 +114,8 @@ class IngestTaskManager:
"updated_at": now, "updated_at": now,
"result": None, "result": None,
"error": None, "error": None,
"filename": filename,
"source": source,
} }
self._schedule_mirror(task_id) self._schedule_mirror(task_id)
background = asyncio.create_task(self._run(task_id, doc, dedup)) background = asyncio.create_task(self._run(task_id, doc, dedup))
@@ -135,6 +141,119 @@ class IngestTaskManager:
) )
return None return None
async def list_tasks(self, limit: int = 20) -> list[dict[str, Any]]:
"""合并内存注册表与 Redis 镜像,去重,按 updated_at 降序,截断 limit
每项提取 {task_id, status, filename, created_at, updated_at, doc_id}
doc_id 从 done 任务的 result.document_id 提取,其余状态为 None。
"""
records: dict[str, dict[str, Any]] = {}
# 1. 内存注册表(主)
for task_id, record in self._tasks.items():
records[task_id] = record
# 2. Redis 镜像补充内存中没有的(内存未命中的任务,如重启后只存在 Redis 的历史记录)
for task_id, record in await self._scan_redis_tasks():
records.setdefault(task_id, record)
# 3. 按 updated_at 降序,截断 limit
sorted_records = sorted(
records.values(),
key=lambda r: r.get("updated_at") or "",
reverse=True,
)[:limit]
# 4. 提取展示字段
return [self._to_list_item(r) for r in sorted_records]
async def _scan_redis_tasks(self) -> list[tuple[str, dict[str, Any]]]:
"""扫描 Redis 中 ingest_task:* 键,返回 (task_id, record) 列表
RedisCache 未暴露公开扫描接口,借道底层 _get_client().scan_iter
任何异常降级为空列表,不影响 list_tasks 主流程。
"""
if self._redis is None:
return []
get_client = getattr(self._redis, "_get_client", None)
if not callable(get_client):
return []
try:
client = get_client()
tasks: list[tuple[str, dict[str, Any]]] = []
async for key in client.scan_iter(match=f"{REDIS_KEY_PREFIX}*"):
key_str = key if isinstance(key, str) else key.decode("utf-8", "replace")
task_id = key_str[len(REDIS_KEY_PREFIX) :]
record = await self._redis.get_json(key_str)
if isinstance(record, dict):
tasks.append((task_id, record))
return tasks
except Exception:
logger.warning("扫描 Redis 任务键失败", exc_info=True)
return []
@staticmethod
def _extract_filename(doc: DocumentInput) -> str | None:
"""从文档输入提取展示用文件名:优先 metadata.original_filename,其次 title"""
return doc.metadata.get("original_filename") or doc.title or None
@staticmethod
def _extract_source(doc: DocumentInput) -> dict[str, Any]:
"""从文档输入提取重试所需的源信息(供 retry 复用,含文件路径/大小)"""
return {
"text": doc.text,
"title": doc.title,
"source": doc.source,
"metadata": dict(doc.metadata),
}
async def retry(self, task_id: str) -> str | None:
"""重试失败/已完成的入库任务:用原 source 重新提交一个新任务
返回新 task_id;任务不存在或缺少 source 时返回 None。
"""
record = await self.get(task_id)
if record is None:
return None
source = record.get("source")
if not isinstance(source, dict) or not source.get("text"):
return None
doc = DocumentInput(
text=source["text"],
title=source.get("title") or "",
source=source.get("source") or "",
metadata=source.get("metadata") or {},
)
return await self.submit(doc)
async def delete(self, task_id: str) -> bool:
"""删除入库任务(内存注册表 + Redis 镜像),不存在返回 False"""
existed = self._tasks.pop(task_id, None) is not None
if self._redis is not None:
try:
get_client = getattr(self._redis, "_get_client", None)
if callable(get_client):
await get_client().delete(f"{REDIS_KEY_PREFIX}{task_id}")
existed = True
except Exception:
logger.warning("删除 Redis 任务镜像失败", task_id=task_id, exc_info=True)
return existed
@staticmethod
def _to_list_item(record: dict[str, Any]) -> dict[str, Any]:
"""从完整任务记录提取列表展示字段"""
result = record.get("result")
doc_id = result.get("document_id") if isinstance(result, dict) else None
source = record.get("source") or {}
metadata = source.get("metadata") or {}
return {
"task_id": record.get("task_id"),
"status": record.get("status"),
"filename": record.get("filename"),
"title": source.get("title"),
"source": source.get("source"),
"size_bytes": metadata.get("original_size_bytes"),
"created_at": record.get("created_at"),
"updated_at": record.get("updated_at"),
"doc_id": doc_id,
}
async def wait_done(self, task_id: str, timeout: float = 30.0) -> dict[str, Any]: async def wait_done(self, task_id: str, timeout: float = 30.0) -> dict[str, Any]:
"""轮询内存注册表直到任务进入终态(done/failed)或超时 """轮询内存注册表直到任务进入终态(done/failed)或超时
+25 -8
View File
@@ -6,7 +6,6 @@ import structlog
from fastapi import FastAPI, Request from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError from fastapi.exceptions import RequestValidationError
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
from redis import asyncio as redis_async
from app.api.response import ApiError, error from app.api.response import ApiError, error
from app.api.v1.auth import router as auth_router from app.api.v1.auth import router as auth_router
@@ -15,7 +14,7 @@ from app.api.v1.knowledge import router as knowledge_router
from app.api.v1.search import router as search_router from app.api.v1.search import router as search_router
from app.api.v1.settings import router as settings_router from app.api.v1.settings import router as settings_router
from app.config import settings from app.config import settings
from app.core.users import UserStore, bootstrap_admin from app.core.users import bootstrap_admin
from app.services.qdrant import QdrantService from app.services.qdrant import QdrantService
logger = structlog.get_logger() logger = structlog.get_logger()
@@ -33,13 +32,13 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]:
except Exception: except Exception:
logger.error("Qdrant 集合初始化失败,跳过初始化继续启动") logger.error("Qdrant 集合初始化失败,跳过初始化继续启动")
try: try:
# Redis 客户端创建失败(如 URL 非法)时传 None,UserStore 降级为内存模式 # 复用 deps 的 UserStore 单例,确保 lifespan 创建的 admin 与 API 请求用的是同一实例
redis_client = redis_async.from_url(settings.redis_url, decode_responses=True) # _get_user_store 内部会检测 Redis 可达性,不可达时降级为内存模式
except Exception: from app.api.deps import _get_user_store
redis_client = None
try: user_store = _get_user_store()
# 空库引导默认管理员;明文密码由 bootstrap_admin 内部 warning 打印一次 # 空库引导默认管理员;明文密码由 bootstrap_admin 内部 warning 打印一次
await bootstrap_admin(UserStore(redis_client), logger) await bootstrap_admin(user_store, logger)
except Exception: except Exception:
logger.warning("默认管理员初始化失败,跳过", exc_info=True) logger.warning("默认管理员初始化失败,跳过", exc_info=True)
try: try:
@@ -144,3 +143,21 @@ async def admin_spa(rest: str):
return FileResponse(file_path) return FileResponse(file_path)
# SPA 客户端路由兜底 # SPA 客户端路由兜底
return FileResponse(spa_dir / "index.html", media_type="text/html") return FileResponse(spa_dir / "index.html", media_type="text/html")
# AI Agent Skill 压缩包(用于 Agent 上传文档与检索),随 ./app 卷挂载,restart 即生效
_AGENT_SKILL_ZIP = _STATIC_DIR / "agent-skill" / "QMDSearch-Agent-Skill.zip"
@app.get("/agent-skill", include_in_schema=False, response_model=None)
async def agent_skill_download():
"""下载 AI Agent Skill 压缩包(qmdsearch-agent skill,含 SKILL.md 与示例)"""
if not _AGENT_SKILL_ZIP.is_file():
return JSONResponse(
status_code=404, content={"code": 1002, "message": "Skill 包不存在"}
)
return FileResponse(
_AGENT_SKILL_ZIP,
media_type="application/zip",
filename="QMDSearch-Agent-Skill.zip",
)
+221 -25
View File
@@ -259,6 +259,7 @@
<button type="button" data-target="section-overview" class="active">概览</button> <button type="button" data-target="section-overview" class="active">概览</button>
<button type="button" data-target="section-docs">文档管理</button> <button type="button" data-target="section-docs">文档管理</button>
<button type="button" data-target="section-ingest">文档入库</button> <button type="button" data-target="section-ingest">文档入库</button>
<button type="button" data-target="section-progress" id="nav-progress">入库进度</button>
<button type="button" data-target="section-search">检索测试台</button> <button type="button" data-target="section-search">检索测试台</button>
<button type="button" data-target="section-categories">类目列表</button> <button type="button" data-target="section-categories">类目列表</button>
<button type="button" data-target="section-api-guide">API 指南</button> <button type="button" data-target="section-api-guide">API 指南</button>
@@ -340,8 +341,8 @@
<h3 style="font-size:14px; margin-top:24px;">或上传文件</h3> <h3 style="font-size:14px; margin-top:24px;">或上传文件</h3>
<form id="upload-form"> <form id="upload-form">
<div class="field"> <div class="field">
<label for="upload-file">选择文件(支持 .txt/.md/.html/.htm/.pdf/.docx</label> <label for="upload-file">选择文件(支持多选,.txt/.md/.html/.htm/.pdf/.docx</label>
<input type="file" id="upload-file" name="file" accept=".txt,.md,.html,.htm,.pdf,.docx" required> <input type="file" id="upload-file" name="file" accept=".txt,.md,.html,.htm,.pdf,.docx" multiple required>
</div> </div>
<div class="field"> <div class="field">
<label for="upload-title">标题(可选,默认取文件名)</label> <label for="upload-title">标题(可选,默认取文件名)</label>
@@ -356,6 +357,21 @@
<div class="result-box hidden" id="ingest-result"></div> <div class="result-box hidden" id="ingest-result"></div>
</section> </section>
<section id="section-progress" class="hidden">
<h2>入库进度</h2>
<div class="error-bar hidden" id="error-progress"></div>
<div class="toolbar">
<button type="button" class="action" id="btn-refresh-progress">刷新</button>
<span class="muted" id="progress-stats"></span>
</div>
<table>
<thead>
<tr><th>文件名</th><th>状态</th><th>创建时间</th><th>更新时间</th><th>操作</th></tr>
</thead>
<tbody id="progress-tbody"></tbody>
</table>
</section>
<section id="section-search" class="hidden"> <section id="section-search" class="hidden">
<h2>检索测试台</h2> <h2>检索测试台</h2>
<div class="error-bar hidden" id="error-search"></div> <div class="error-bar hidden" id="error-search"></div>
@@ -505,6 +521,7 @@ function showLogin() {
document.getElementById("password-overlay").classList.add("hidden"); document.getElementById("password-overlay").classList.add("hidden");
document.getElementById("user-area").classList.add("hidden"); document.getElementById("user-area").classList.add("hidden");
loadedOnce = {}; loadedOnce = {};
stopProgressPolling();
unmountUsersSection(); unmountUsersSection();
} }
@@ -858,6 +875,8 @@ function truncate(text, maxLen) {
var loadedOnce = {}; var loadedOnce = {};
function activateSection(targetId) { function activateSection(targetId) {
/* 切走前停止进度区块轮询(切到其他区块即暂停) */
stopProgressPolling();
var sections = document.querySelectorAll("main section"); var sections = document.querySelectorAll("main section");
sections.forEach(function (sec) { sections.forEach(function (sec) {
sec.classList.toggle("hidden", sec.id !== targetId); sec.classList.toggle("hidden", sec.id !== targetId);
@@ -875,6 +894,8 @@ function activateSection(targetId) {
if (targetId === "section-api-guide") { renderApiGuide(); } if (targetId === "section-api-guide") { renderApiGuide(); }
if (targetId === "section-profile") { loadProfile(); } if (targetId === "section-profile") { loadProfile(); }
} }
/* 切到入库进度区块时恢复轮询 */
if (targetId === "section-progress") { startProgressPolling(); }
} }
document.getElementById("nav").addEventListener("click", function (event) { document.getElementById("nav").addEventListener("click", function (event) {
@@ -1158,12 +1179,17 @@ function restoreIngestButton() {
} }
function makeStatusBadge(status) { function makeStatusBadge(status) {
var badge = statusBadgeEl(status);
badge.id = "ingest-status-badge";
return badge;
}
/* 状态徽章构造:复用 INGEST_STATUS_TEXT 中文映射与 status-* 配色,无 id 可重复使用 */
function statusBadgeEl(status) {
var cls = "status-running"; var cls = "status-running";
if (status === "done") { cls = "status-done"; } if (status === "done") { cls = "status-done"; }
if (status === "failed") { cls = "status-failed"; } if (status === "failed") { cls = "status-failed"; }
var badge = el("span", INGEST_STATUS_TEXT[status] || status, "status-badge " + cls); return el("span", INGEST_STATUS_TEXT[status] || status, "status-badge " + cls);
badge.id = "ingest-status-badge";
return badge;
} }
function updateIngestStatus(status) { function updateIngestStatus(status) {
@@ -1290,26 +1316,178 @@ document.getElementById("upload-form").addEventListener("submit", function (even
var submitBtn = document.getElementById("btn-upload-submit"); var submitBtn = document.getElementById("btn-upload-submit");
submitBtn.disabled = true; submitBtn.disabled = true;
submitBtn.textContent = "上传中…"; submitBtn.textContent = "上传中…";
var formData = new FormData();
formData.append("file", fileInput.files[0]);
var title = document.getElementById("upload-title").value; var title = document.getElementById("upload-title").value;
var source = document.getElementById("upload-source").value; var source = document.getElementById("upload-source").value;
if (title) { formData.append("title", title); } if (fileInput.files.length > 1) {
if (source) { formData.append("source", source); } /* 批量上传:FormData 用 files 字段逐文件 append,走 upload-batch 端点 */
api("/api/v1/documents/upload", { var batchForm = new FormData();
method: "POST", for (var i = 0; i < fileInput.files.length; i++) {
body: formData batchForm.append("files", fileInput.files[i]);
}).then(function (data) { }
renderIngestHeader(data.task_id, data.status || "pending"); api("/api/v1/documents/upload-batch", {
startIngestPolling(data.task_id); method: "POST",
}).catch(function (err) { body: batchForm
showError("error-ingest", err); }).then(function (data) {
}).finally(function () { renderBatchUploadResult(data);
submitBtn.disabled = false; /* 自动切到入库进度区块查看进度 */
submitBtn.textContent = "上传入库"; activateSection("section-progress");
}); }).catch(function (err) {
showError("error-ingest", err);
}).finally(function () {
submitBtn.disabled = false;
submitBtn.textContent = "上传入库";
});
} else {
/* 单文件上传:保持原逻辑,走 upload 端点 */
var formData = new FormData();
formData.append("file", fileInput.files[0]);
if (title) { formData.append("title", title); }
if (source) { formData.append("source", source); }
api("/api/v1/documents/upload", {
method: "POST",
body: formData
}).then(function (data) {
renderIngestHeader(data.task_id, data.status || "pending");
startIngestPolling(data.task_id);
}).catch(function (err) {
showError("error-ingest", err);
}).finally(function () {
submitBtn.disabled = false;
submitBtn.textContent = "上传入库";
});
}
}); });
/* 批量上传结果展示:成功 N 个任务 + 失败 N 个,纯 textContent 防 XSS */
function renderBatchUploadResult(data) {
var box = document.getElementById("ingest-result");
clearChildren(box);
box.classList.remove("hidden");
var tasks = data.tasks || [];
var failed = data.failed || [];
var headRow = el("div", null, "kv");
headRow.appendChild(el("span", "批量上传完成:成功 " + tasks.length + " 个任务,失败 " + failed.length + " 个"));
box.appendChild(headRow);
if (tasks.length > 0) {
var tasksRow = el("div", null, "kv");
tasksRow.appendChild(el("span", "已提交任务:", "k"));
var tasksList = tasks.map(function (t) {
return (t.filename || "") + "" + (t.task_id || "") + "";
}).join("");
tasksRow.appendChild(el("span", tasksList));
box.appendChild(tasksRow);
}
if (failed.length > 0) {
var failedRow = el("div", null, "kv");
failedRow.appendChild(el("span", "失败文件:", "k"));
var failedList = failed.map(function (f) {
return (f.filename || "") + "" + (f.error || "") + "";
}).join("");
failedRow.appendChild(el("span", failedList));
box.appendChild(failedRow);
}
}
/* ---------- 3.5 入库进度区块(2s 轮询,区块可见时轮询、切走暂停) ---------- */
var PROGRESS_POLL_INTERVAL_MS = 2000;
var progressPollTimer = null;
function stopProgressPolling() {
if (progressPollTimer !== null) {
clearInterval(progressPollTimer);
progressPollTimer = null;
}
}
function startProgressPolling() {
stopProgressPolling();
loadProgress();
progressPollTimer = setInterval(loadProgress, PROGRESS_POLL_INTERVAL_MS);
}
/* GET /documents/tasks 拉取任务列表,渲染表格与状态统计 */
function loadProgress() {
hideError("error-progress");
api("/api/v1/documents/tasks?limit=50").then(function (data) {
renderProgress(data.items || [], data.total || 0);
}).catch(function (err) { showError("error-progress", err); });
}
function renderProgress(items, total) {
var tbody = document.getElementById("progress-tbody");
clearChildren(tbody);
var running = 0, done = 0, failed = 0;
items.forEach(function (it) {
if (it.status === "done") { done += 1; }
else if (it.status === "failed") { failed += 1; }
else { running += 1; }
tbody.appendChild(buildProgressRow(it));
});
document.getElementById("progress-stats").textContent =
"总数 " + total + " · 进行中 " + running + " · 完成 " + done + " · 失败 " + failed;
if (items.length === 0) {
var tr = el("tr");
var td = el("td", "暂无入库任务", "muted");
td.setAttribute("colspan", "5");
tr.appendChild(td);
tbody.appendChild(tr);
}
}
/* 单行渲染:文件名 / 状态徽章 / 创建时间 / 更新时间 / 操作(done 查看文档+重新入库,failed 重试) */
function buildProgressRow(item) {
var tr = el("tr");
tr.appendChild(el("td", item.filename || "(未知文件)"));
var statusTd = el("td");
statusTd.appendChild(statusBadgeEl(item.status));
tr.appendChild(statusTd);
tr.appendChild(el("td", item.created_at || ""));
tr.appendChild(el("td", item.updated_at || ""));
var opsTd = el("td");
if (item.status === "done" && item.doc_id) {
var viewBtn = el("button", "查看文档", "action");
viewBtn.type = "button";
viewBtn.addEventListener("click", function () {
activateSection("section-docs");
loadDocDetail(item.doc_id);
});
var reingestBtn = el("button", "重新入库", "action");
reingestBtn.type = "button";
reingestBtn.addEventListener("click", function () {
reingestDocument(item.doc_id, item.filename);
});
opsTd.appendChild(viewBtn);
opsTd.appendChild(reingestBtn);
} else if (item.status === "failed" && item.doc_id) {
var retryBtn = el("button", "重试", "action");
retryBtn.type = "button";
retryBtn.addEventListener("click", function () {
reingestDocument(item.doc_id, item.filename);
});
opsTd.appendChild(retryBtn);
}
tr.appendChild(opsTd);
return tr;
}
/* 重新入库:POST /documents/{doc_id}/reingest,成功后刷新进度表格 */
function reingestDocument(docId, filename) {
hideError("error-progress");
api("/api/v1/documents/" + encodeURIComponent(docId) + "/reingest", {
method: "POST"
}).then(function (data) {
var stats = document.getElementById("progress-stats");
stats.textContent = "已提交「" + (filename || docId) + "」重新入库:task_id=" + (data.task_id || "");
loadProgress();
}).catch(function (err) { showError("error-progress", err); });
}
document.getElementById("btn-refresh-progress").addEventListener("click", loadProgress);
/* ---------- 4. 检索测试台 ---------- */ /* ---------- 4. 检索测试台 ---------- */
document.getElementById("search-form").addEventListener("submit", function (event) { document.getElementById("search-form").addEventListener("submit", function (event) {
@@ -1768,7 +1946,15 @@ var API_GUIDE = [
{ name: "title", desc: "可选标题,默认取文件名去扩展", def: "" }, { name: "title", desc: "可选标题,默认取文件名去扩展", def: "" },
{ name: "source", desc: "可选来源标识,默认 file:原文件名", def: "" } { name: "source", desc: "可选来源标识,默认 file:原文件名", def: "" }
], ],
body: null, upload: true }, body: null, upload: true, uploadField: "file", uploadMultiple: false },
{ method: "POST", path: "/api/v1/documents/upload-batch", desc: "批量文件上传入库(multipart files 字段,逐文件复用单文件逻辑,202 异步)", auth: "bearer",
query: [], pathParams: [], form: [],
body: null, upload: true, uploadField: "files", uploadMultiple: true },
{ method: "GET", path: "/api/v1/documents/tasks", desc: "入库任务列表(合并内存与 Redis 镜像,按 updated_at 降序)", auth: "bearer",
query: [
{ name: "limit", desc: "返回条数(1-100", def: "20" }
],
pathParams: [], form: [], body: null, upload: false },
{ method: "GET", path: "/api/v1/documents/tasks/{task_id}", desc: "入库任务状态查询(done 附 resultfailed 附 error", auth: "none", { method: "GET", path: "/api/v1/documents/tasks/{task_id}", desc: "入库任务状态查询(done 附 resultfailed 附 error", auth: "none",
query: [], query: [],
pathParams: [{ name: "task_id", desc: "入库任务 ID", def: "" }], pathParams: [{ name: "task_id", desc: "入库任务 ID", def: "" }],
@@ -1787,6 +1973,10 @@ var API_GUIDE = [
query: [], query: [],
pathParams: [{ name: "doc_id", desc: "文档 ID", def: "" }], pathParams: [{ name: "doc_id", desc: "文档 ID", def: "" }],
form: [], body: null, upload: false }, form: [], body: null, upload: false },
{ method: "POST", path: "/api/v1/documents/{doc_id}/reingest", desc: "重新入库(读原文件→删旧数据→提交新任务,生成新 doc_id)", auth: "bearer",
query: [],
pathParams: [{ name: "doc_id", desc: "文档 ID", def: "" }],
form: [], body: null, upload: false },
{ method: "GET", path: "/api/v1/knowledge/categories", desc: "知识分类类目集", auth: "none", { method: "GET", path: "/api/v1/knowledge/categories", desc: "知识分类类目集", auth: "none",
query: [], pathParams: [], form: [], body: null, upload: false }, query: [], pathParams: [], form: [], body: null, upload: false },
{ method: "GET", path: "/api/v1/knowledge/stats", desc: "统计(四层点数 + 类目分布 + uncategorized 数)", auth: "none", { method: "GET", path: "/api/v1/knowledge/stats", desc: "统计(四层点数 + 类目分布 + uncategorized 数)", auth: "none",
@@ -1841,7 +2031,8 @@ function buildCurl(ep) {
parts.push('-H "Authorization: Bearer <TOKEN>"'); parts.push('-H "Authorization: Bearer <TOKEN>"');
} }
if (ep.upload) { if (ep.upload) {
parts.push('-F "file=@/path/to/file.md"'); var fieldName = ep.uploadField || "file";
parts.push('-F "' + fieldName + '=@/path/to/file.md"');
} else if (ep.body) { } else if (ep.body) {
parts.push('-H "Content-Type: application/json"'); parts.push('-H "Content-Type: application/json"');
parts.push("-d '" + JSON.stringify(ep.body) + "'"); parts.push("-d '" + JSON.stringify(ep.body) + "'");
@@ -1964,7 +2155,11 @@ function sendApiTry(idx, panel) {
} }
/* FormData 由浏览器自动生成 multipart 边界,不设置 Content-Type 头 */ /* FormData 由浏览器自动生成 multipart 边界,不设置 Content-Type 头 */
var formData = new FormData(); var formData = new FormData();
formData.append("file", fileInput.files[0]); /* uploadField 默认 file;批量上传用 files 字段逐文件 append */
var upField = ep.uploadField || "file";
for (var fi = 0; fi < fileInput.files.length; fi++) {
formData.append(upField, fileInput.files[fi]);
}
var formInputs = panel.querySelectorAll(".try-form-input"); var formInputs = panel.querySelectorAll(".try-form-input");
for (var k = 0; k < formInputs.length; k++) { for (var k = 0; k < formInputs.length; k++) {
var fVal = formInputs[k].value.trim(); var fVal = formInputs[k].value.trim();
@@ -2044,11 +2239,12 @@ function renderApiItem(ep, idx) {
if (ep.upload) { if (ep.upload) {
renderTryInputs(panel, "form", ep.form); renderTryInputs(panel, "form", ep.form);
var fileField = el("div", null, "field"); var fileField = el("div", null, "field");
fileField.appendChild(el("label", "文件(.txt/.md/.html/.htm/.pdf/.docx")); fileField.appendChild(el("label", "文件(.txt/.md/.html/.htm/.pdf/.docx" + (ep.uploadMultiple ? ",可多选" : "") + ""));
var fileInput = el("input"); var fileInput = el("input");
fileInput.type = "file"; fileInput.type = "file";
fileInput.className = "try-file"; fileInput.className = "try-file";
fileInput.accept = ".txt,.md,.html,.htm,.pdf,.docx"; fileInput.accept = ".txt,.md,.html,.htm,.pdf,.docx";
if (ep.uploadMultiple) { fileInput.setAttribute("multiple", ""); }
fileField.appendChild(fileInput); fileField.appendChild(fileInput);
panel.appendChild(fileField); panel.appendChild(fileField);
} }
Binary file not shown.
+2 -2
View File
@@ -96,7 +96,7 @@ services:
- OLLAMA_MAX_LOADED_MODELS=3 - OLLAMA_MAX_LOADED_MODELS=3
- OLLAMA_NUM_THREADS=16 - OLLAMA_NUM_THREADS=16
- OLLAMA_KV_CACHE_TYPE=q8_0 - OLLAMA_KV_CACHE_TYPE=q8_0
# 首次启动自动拉取所需模型(qwen2.5:1.5b 总结 + bge-m3 嵌入 + qwen3-reranker 重排), # 首次启动自动拉取所需模型(qwen3:1.7b 总结 + bge-m3 嵌入 + qwen3-reranker 重排),
# 下载完成后转交常驻 ollama serve。已存在时仅做健康检查。 # 下载完成后转交常驻 ollama serve。已存在时仅做健康检查。
entrypoint: /bin/bash entrypoint: /bin/bash
command: command:
@@ -105,7 +105,7 @@ services:
ollama serve & ollama serve &
SERVE_PID=$$! SERVE_PID=$$!
sleep 6 sleep 6
ollama pull qwen2.5:1.5b ollama pull qwen3:1.7b
ollama pull bge-m3 ollama pull bge-m3
ollama pull qwen3-reranker:0.6b ollama pull qwen3-reranker:0.6b
wait $$SERVE_PID wait $$SERVE_PID
+12
View File
@@ -63,3 +63,15 @@ export function resetPassword(username, newPassword) {
export function deleteUser(username) { export function deleteUser(username) {
return http.delete(`/api/v1/auth/users/${encodeURIComponent(username)}`) return http.delete(`/api/v1/auth/users/${encodeURIComponent(username)}`)
} }
/**
* 修改自己的密码(当前登录用户)
* @param {string} oldPassword
* @param {string} newPassword
*/
export function changeMyPassword(oldPassword, newPassword) {
return http.post('/api/v1/auth/password', {
old_password: oldPassword,
new_password: newPassword
})
}
+49
View File
@@ -52,6 +52,55 @@ export function upload(formData) {
}) })
} }
/**
* multipart 批量文件上传入库(异步,逐文件创建任务)
* @param {File[]} files
* @returns {Promise<{tasks: Array<{filename:string, task_id:string}>, failed: Array<{filename:string, error:string}>}>}
*/
export function uploadBatch(files) {
const formData = new FormData()
files.forEach((f) => formData.append('files', f))
return http.post('/api/v1/documents/upload-batch', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
/**
* 重新摘要入库(读原文件→删旧数据→新建任务)
* @param {string} docId
* @returns {Promise<{task_id:string, status:string}>}
*/
export function reingest(docId) {
return http.post(`/api/v1/documents/${encodeURIComponent(docId)}/reingest`)
}
/**
* 列出近期入库任务(按 updated_at 降序)
* @param {number} [limit=20]
* @returns {Promise<{items: Array, total: number}>}
*/
export function taskList(limit = 20) {
return http.get('/api/v1/documents/tasks', { params: { limit } })
}
/**
* 重试入库任务(用原 source 重新提交新任务)
* @param {string} taskId
* @returns {Promise<{task_id:string, status:string}>}
*/
export function retryTask(taskId) {
return http.post(`/api/v1/documents/tasks/${encodeURIComponent(taskId)}/retry`)
}
/**
* 删除入库任务
* @param {string} taskId
* @returns {Promise<{task_id:string, deleted:boolean}>}
*/
export function deleteTask(taskId) {
return http.delete(`/api/v1/documents/tasks/${encodeURIComponent(taskId)}`)
}
/** /**
* 查询入库任务状态 * 查询入库任务状态
* @param {string} taskId * @param {string} taskId
@@ -0,0 +1,21 @@
import { reactive } from 'vue'
/**
* 入库进度右侧 Drawer 的全局共享开关状态
*
* 该 Drawer 渲染在 MainLayout 中,但可由任意子页面(如 Library)触发打开,
* 因此通过单例 reactive 状态 + open()/close() 方法解耦,避免多层 prop 传递。
*/
const state = reactive({ open: false })
export function useTasksDrawer() {
return {
state,
open() {
state.open = true
},
close() {
state.open = false
}
}
}
+119 -10
View File
@@ -1,13 +1,10 @@
<script setup> <script setup>
import { computed, h, ref } from 'vue' import { computed, h, reactive, ref, watch } from 'vue'
import { useRoute, useRouter, RouterView } from 'vue-router' import { useRoute, useRouter, RouterView } from 'vue-router'
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
import { Modal } from 'ant-design-vue' import { Modal, message } from 'ant-design-vue'
import { import {
DashboardOutlined, DashboardOutlined,
FileTextOutlined,
UploadOutlined,
SearchOutlined,
AppstoreOutlined, AppstoreOutlined,
SettingOutlined, SettingOutlined,
MenuFoldOutlined, MenuFoldOutlined,
@@ -15,9 +12,12 @@ import {
LogoutOutlined, LogoutOutlined,
DatabaseOutlined, DatabaseOutlined,
ApiOutlined, ApiOutlined,
TeamOutlined TeamOutlined,
UploadOutlined
} from '@ant-design/icons-vue' } from '@ant-design/icons-vue'
import { useAuthStore } from '@/stores/useAuthStore' import { useAuthStore } from '@/stores/useAuthStore'
import { changeMyPassword } from '@/api/auth'
import { callApi } from '@/api/client'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@@ -34,11 +34,9 @@ const openKeys = ref(['main'])
const menuItems = computed(() => { const menuItems = computed(() => {
const items = [ const items = [
{ key: 'overview', icon: () => h(DashboardOutlined), label: '概览' }, { key: 'overview', icon: () => h(DashboardOutlined), label: '概览与检索' },
{ key: 'documents', icon: () => h(FileTextOutlined), label: '文档管理' }, { key: 'library', icon: () => h(AppstoreOutlined), label: '知识库' },
{ key: 'ingest', icon: () => h(UploadOutlined), label: '文档入库' }, { key: 'ingest', icon: () => h(UploadOutlined), label: '文档入库' },
{ key: 'search', icon: () => h(SearchOutlined), label: '检索测试台' },
{ key: 'categories', icon: () => h(AppstoreOutlined), label: '类目列表' },
{ key: 'settings', icon: () => h(SettingOutlined), label: '设置' }, { key: 'settings', icon: () => h(SettingOutlined), label: '设置' },
{ key: 'api-docs', icon: () => h(ApiOutlined), label: 'API 说明' } { key: 'api-docs', icon: () => h(ApiOutlined), label: 'API 说明' }
] ]
@@ -66,6 +64,68 @@ function handleLogout() {
} }
}) })
} }
/* ---------- 强制改密弹窗(must_change_password 用户) ---------- */
const passwordModalVisible = ref(false)
const passwordSubmitting = ref(false)
const passwordFormRef = ref(null)
const passwordForm = reactive({
old_password: '',
new_password: '',
confirm_password: ''
})
const passwordRules = {
old_password: [{ required: true, message: '请输入当前密码', trigger: 'blur' }],
new_password: [
{ required: true, message: '请输入新密码', trigger: 'blur' },
{ min: 8, message: '密码至少 8 位', trigger: 'blur' }
],
confirm_password: [
{ required: true, message: '请确认新密码', trigger: 'blur' },
{
validator: (_rule, value) =>
value === passwordForm.new_password
? Promise.resolve()
: Promise.reject(new Error('两次输入的密码不一致')),
trigger: 'blur'
}
]
}
// must_change_password 为 true 时自动弹出改密弹窗(不可关闭)
watch(
() => user.value?.must_change_password,
(val) => {
passwordModalVisible.value = val === true
},
{ immediate: true }
)
async function handleSubmitPassword() {
try {
await passwordFormRef.value.validate()
} catch {
return
}
passwordSubmitting.value = true
try {
await callApi(
() => changeMyPassword(passwordForm.old_password, passwordForm.new_password),
{ errorText: '修改密码失败' }
)
authStore.markPasswordChanged()
message.success('密码修改成功')
passwordModalVisible.value = false
passwordForm.old_password = ''
passwordForm.new_password = ''
passwordForm.confirm_password = ''
} catch {
// 错误已由 callApi 弹出
} finally {
passwordSubmitting.value = false
}
}
</script> </script>
<template> <template>
@@ -127,6 +187,55 @@ function handleLogout() {
QMDSearch Admin · 当前用户{{ user?.username || '-' }} QMDSearch Admin · 当前用户{{ user?.username || '-' }}
</a-layout-footer> </a-layout-footer>
</a-layout> </a-layout>
<!-- 强制改密弹窗must_change_password 用户不可关闭 -->
<a-modal
v-model:open="passwordModalVisible"
:mask-closable="false"
:closable="false"
:keyboard="false"
:destroy-on-close="true"
title="首次登录须修改密码"
:confirm-loading="passwordSubmitting"
ok-text="确认修改"
:cancel-button-props="{ style: { display: 'none' } }"
@ok="handleSubmitPassword"
>
<a-alert
type="warning"
show-icon
message="检测到首次登录或管理员重置密码,请立即修改密码后方可使用系统。"
style="margin-bottom: 16px"
/>
<a-form
ref="passwordFormRef"
:model="passwordForm"
:rules="passwordRules"
layout="vertical"
>
<a-form-item label="当前密码" name="old_password">
<a-input-password
v-model:value="passwordForm.old_password"
placeholder="请输入当前密码"
autocomplete="current-password"
/>
</a-form-item>
<a-form-item label="新密码" name="new_password">
<a-input-password
v-model:value="passwordForm.new_password"
placeholder="至少 8 位"
autocomplete="new-password"
/>
</a-form-item>
<a-form-item label="确认新密码" name="confirm_password">
<a-input-password
v-model:value="passwordForm.confirm_password"
placeholder="再次输入新密码"
autocomplete="new-password"
/>
</a-form-item>
</a-form>
</a-modal>
</a-layout> </a-layout>
</template> </template>
+5 -17
View File
@@ -18,13 +18,13 @@ const routes = [
path: 'overview', path: 'overview',
name: 'overview', name: 'overview',
component: () => import('@/views/Overview.vue'), component: () => import('@/views/Overview.vue'),
meta: { title: '概览', requiresAuth: true } meta: { title: '概览与检索', requiresAuth: true }
}, },
{ {
path: 'documents', path: 'library',
name: 'documents', name: 'library',
component: () => import('@/views/Documents.vue'), component: () => import('@/views/Library.vue'),
meta: { title: '文档管理', requiresAuth: true } meta: { title: '知识库', requiresAuth: true }
}, },
{ {
path: 'ingest', path: 'ingest',
@@ -32,18 +32,6 @@ const routes = [
component: () => import('@/views/Ingest.vue'), component: () => import('@/views/Ingest.vue'),
meta: { title: '文档入库', requiresAuth: true } meta: { title: '文档入库', requiresAuth: true }
}, },
{
path: 'search',
name: 'search',
component: () => import('@/views/Search.vue'),
meta: { title: '检索测试台', requiresAuth: true }
},
{
path: 'categories',
name: 'categories',
component: () => import('@/views/Categories.vue'),
meta: { title: '类目列表', requiresAuth: true }
},
{ {
path: 'settings', path: 'settings',
name: 'settings', name: 'settings',
+26 -5
View File
@@ -39,13 +39,27 @@ export const useAuthStore = defineStore('auth', {
actions: { actions: {
/** /**
* 登录成功后保存 token + user * 登录成功后保存 token + user
* @param {{access_token:string, user:object}} data * 兼容后端两种返回:
* - 新格式 { token, username, role, must_change_password }
* - 旧格式 { access_token, user: { username, role, ... } }
* @param {object} data
*/ */
setAuth(data) { setAuth(data) {
this.token = data.access_token const token = data?.access_token ?? data?.token
this.user = data.user const user =
localStorage.setItem(TOKEN_STORAGE_KEY, data.access_token) data?.user ??
localStorage.setItem(USER_STORAGE_KEY, JSON.stringify(data.user)) (data
? {
username: data.username,
role: data.role,
must_change_password: data.must_change_password ?? false
}
: null)
if (!token || !user) return
this.token = token
this.user = user
localStorage.setItem(TOKEN_STORAGE_KEY, token)
localStorage.setItem(USER_STORAGE_KEY, JSON.stringify(user))
}, },
/** 清除登录态(登出 / 401) */ /** 清除登录态(登出 / 401) */
@@ -56,6 +70,13 @@ export const useAuthStore = defineStore('auth', {
localStorage.removeItem(USER_STORAGE_KEY) localStorage.removeItem(USER_STORAGE_KEY)
}, },
/** 改密成功后更新本地用户状态(清除 must_change_password 标记) */
markPasswordChanged() {
if (!this.user) return
this.user = { ...this.user, must_change_password: false }
localStorage.setItem(USER_STORAGE_KEY, JSON.stringify(this.user))
},
/** /**
* 登出 * 登出
*/ */
+14
View File
@@ -10,6 +10,20 @@ export function truncate(text, maxLen = 80) {
return s.length > maxLen ? `${s.slice(0, maxLen)}` : s return s.length > maxLen ? `${s.slice(0, maxLen)}` : s
} }
/**
* 格式化字节数为人类可读大小
* @param {number|null|undefined} bytes
* @returns {string}
*/
export function formatSize(bytes) {
if (bytes == null || bytes === '') return '-'
const n = Number(bytes)
if (Number.isNaN(n)) return '-'
if (n < 1024) return `${n} B`
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`
return `${(n / 1024 / 1024).toFixed(1)} MB`
}
/** /**
* 安全拼接类名 * 安全拼接类名
* @param {...(string | false | null | undefined)} args * @param {...(string | false | null | undefined)} args
+70 -20
View File
@@ -1,22 +1,34 @@
<script setup> <script setup>
import { computed } from 'vue' import { computed } from 'vue'
import { ApiOutlined, SafetyCertificateOutlined, CodeOutlined } from '@ant-design/icons-vue' import { ApiOutlined, SafetyCertificateOutlined, CodeOutlined, DownloadOutlined } from '@ant-design/icons-vue'
// 以下内容整理自项目 README.md 的「API 文档」章节 // 以下内容与后端 app/api/v1 的路由实现保持一致
const apiList = [ const apiList = [
{ method: 'GET', path: '/api/v1/health', desc: '健康检查', auth: false }, { method: 'GET', path: '/api/v1/health', desc: '健康检查', auth: false },
{ method: 'POST', path: '/api/v1/auth/register', desc: '用户注册(可关闭', auth: false }, { method: 'POST', path: '/api/v1/auth/login', desc: '用户登录,签发 session tokenTTL 12h', auth: false },
{ method: 'POST', path: '/api/v1/auth/login', desc: '用户登录,返回 JWT token', auth: false }, { method: 'POST', path: '/api/v1/auth/logout', desc: '退出登录(删除当前会话)', auth: true },
{ method: 'GET', path: '/api/v1/auth/me', desc: '获取当前用户信息', auth: true }, { method: 'POST', path: '/api/v1/auth/password', desc: '修改自己的密码', auth: true },
{ method: 'POST', path: '/api/v1/search', desc: '分层检索', auth: true }, { method: 'GET', path: '/api/v1/auth/me', desc: '当前登录用户信息(脱敏)', auth: true },
{ method: 'GET', path: '/api/v1/auth/users', desc: '用户列表(脱敏)', auth: 'admin' },
{ method: 'POST', path: '/api/v1/auth/users', desc: '创建用户', auth: 'admin' },
{ method: 'PATCH', path: '/api/v1/auth/users/{username}', desc: '更新用户角色 / 启用状态', auth: 'admin' },
{ method: 'POST', path: '/api/v1/auth/users/{username}/password', desc: '重置指定用户密码', auth: 'admin' },
{ method: 'DELETE', path: '/api/v1/auth/users/{username}', desc: '删除用户', auth: 'admin' },
{ method: 'POST', path: '/api/v1/search', desc: '分层检索(L1→L2→L3→chunk', auth: false },
{ method: 'POST', path: '/api/v1/documents', desc: '文档入库(JSON 文本,202 异步入库)', auth: true }, { method: 'POST', path: '/api/v1/documents', desc: '文档入库(JSON 文本,202 异步入库)', auth: true },
{ method: 'POST', path: '/api/v1/documents/upload', desc: '文件上传入库(multipart202 异步)', auth: true }, { method: 'POST', path: '/api/v1/documents/upload', desc: '文件上传入库(multipart202 异步)', auth: true },
{ method: 'GET', path: '/api/v1/documents/tasks/{task_id}', desc: '入库任务状态查询', auth: true }, { method: 'POST', path: '/api/v1/documents/upload-batch', desc: '批量文件上传入库(多文件,202 异步)', auth: true },
{ method: 'GET', path: '/api/v1/documents', desc: '文档列表(分页', auth: true }, { method: 'GET', path: '/api/v1/documents/tasks', desc: '入库任务列表(按时间降序', auth: true },
{ method: 'GET', path: '/api/v1/documents/{doc_id}', desc: '文档详情', auth: true }, { method: 'GET', path: '/api/v1/documents/tasks/{task_id}', desc: '入库任务状态查询', auth: false },
{ method: 'POST', path: '/api/v1/documents/tasks/{task_id}/retry', desc: '重试入库任务(返回新 task_id', auth: true },
{ method: 'DELETE', path: '/api/v1/documents/tasks/{task_id}', desc: '删除入库任务(幂等)', auth: true },
{ method: 'GET', path: '/api/v1/documents', desc: '文档列表(limit/offset 分页)', auth: false },
{ method: 'GET', path: '/api/v1/documents/{doc_id}', desc: '文档详情', auth: false },
{ method: 'GET', path: '/api/v1/documents/{doc_id}/file', desc: '下载关联的原始文件', auth: false },
{ method: 'POST', path: '/api/v1/documents/{doc_id}/reingest', desc: '重新摘要入库(读原文件→删旧→重跑)', auth: true },
{ method: 'DELETE', path: '/api/v1/documents/{doc_id}', desc: '删除文档(幂等)', auth: true }, { method: 'DELETE', path: '/api/v1/documents/{doc_id}', desc: '删除文档(幂等)', auth: true },
{ method: 'GET', path: '/api/v1/knowledge/categories', desc: '知识分类类目集', auth: true }, { method: 'GET', path: '/api/v1/knowledge/categories', desc: '知识分类类目集', auth: false },
{ method: 'GET', path: '/api/v1/knowledge/stats', desc: '统计(四层点数 + 类目分布)', auth: true }, { method: 'GET', path: '/api/v1/knowledge/stats', desc: '统计(四层点数 + 类目分布)', auth: false },
{ method: 'GET', path: '/admin', desc: '管理后台(本页)', auth: false } { method: 'GET', path: '/admin', desc: '管理后台(本页)', auth: false }
] ]
@@ -24,23 +36,37 @@ const methodColor = {
GET: 'green', GET: 'green',
POST: 'blue', POST: 'blue',
DELETE: 'red', DELETE: 'red',
PUT: 'orange' PUT: 'orange',
PATCH: 'purple'
}
function authLabel(auth) {
if (auth === 'admin') return '仅 admin'
return auth ? '需鉴权' : '否'
}
function authColor(auth) {
if (auth === 'admin') return 'gold'
return auth ? 'volcano' : 'default'
} }
const baseUrl = computed(() => `${window.location.origin}/admin/`.replace(/\/admin\/$/, '')) const baseUrl = computed(() => `${window.location.origin}/admin/`.replace(/\/admin\/$/, ''))
const agentSkillUrl = computed(() => `${window.location.origin}/agent-skill`)
const loginExample = `curl -X POST ${baseUrl.value}/api/v1/auth/login \\ const loginExample = `curl -X POST ${baseUrl.value}/api/v1/auth/login \\
-H "Content-Type: application/json" \\ -H "Content-Type: application/json" \\
-d '{"username": "admin", "password": "your-password"}'` -d '{"username": "admin", "password": "your-password"}'
# → {"code":0,"data":{"token":"<session-token>","username":"admin","role":"admin","must_change_password":false},"message":"ok"}`
const searchExample = `curl -X POST ${baseUrl.value}/api/v1/search \\ const searchExample = `curl -X POST ${baseUrl.value}/api/v1/search \\
-H "Content-Type: application/json" \\ -H "Content-Type: application/json" \\
-H "Authorization: Bearer <token>" \\ -d '{"query": "如何配置 Redis 缓存", "top_k": 5, "summarize": false}'`
-d '{"query": "如何配置 Redis 缓存", "top_k": 5}'`
const uploadExample = `curl -X POST ${baseUrl.value}/api/v1/documents/upload \\ const uploadExample = `curl -X POST ${baseUrl.value}/api/v1/documents/upload \\
-H "Authorization: Bearer <token>" \\ -H "Authorization: Bearer <token>" \\
-F "file=@document.pdf"` -F "file=@document.pdf" \\
-F "source=manual"`
</script> </script>
<template> <template>
@@ -56,9 +82,26 @@ const uploadExample = `curl -X POST ${baseUrl.value}/api/v1/documents/upload \\
class="apidocs__tip" class="apidocs__tip"
type="info" type="info"
show-icon show-icon
message="需要鉴权的接口请在请求头携带 Authorization: Bearer &lt;token&gt;token 通过 /auth/login 获取。" message="需要鉴权的接口请在请求头携带 Authorization: Bearer &lt;token&gt;token 通过 /auth/login 获取;用户管理类接口(/auth/users*)仅 admin 角色可用。查询类接口(检索 / 文档列表 / 详情 / 下载 / 类目 / 统计 / 任务状态)免登录。"
/> />
<!-- AI Agent Skill 下载 -->
<section class="apidocs__block apidocs__skill">
<h3 class="section-subtitle">
<DownloadOutlined /> AI Agent Skill 下载
</h3>
<p class="apidocs__p">
AI Agent 提供的接入包 <code>SKILL.md</code> Python 客户端示例覆盖文档上传文本 / 文件与分层检索
下载后解压到 Agent skills 目录即可启用
</p>
<a :href="agentSkillUrl" target="_blank" rel="noopener">
<a-button type="primary">
<template #icon><DownloadOutlined /></template>
下载 QMDSearch-Agent-Skill.zip
</a-button>
</a>
</section>
<!-- 统一响应格式 --> <!-- 统一响应格式 -->
<section class="apidocs__block"> <section class="apidocs__block">
<h3 class="section-subtitle"> <h3 class="section-subtitle">
@@ -98,8 +141,8 @@ const uploadExample = `curl -X POST ${baseUrl.value}/api/v1/documents/upload \\
<code class="api-path">{{ text }}</code> <code class="api-path">{{ text }}</code>
</template> </template>
<template v-else-if="column.key === 'auth'"> <template v-else-if="column.key === 'auth'">
<a-tag :color="record.auth ? 'volcano' : 'default'"> <a-tag :color="authColor(record.auth)">
{{ record.auth ? '需鉴权' : '否' }} {{ authLabel(record.auth) }}
</a-tag> </a-tag>
</template> </template>
</template> </template>
@@ -111,7 +154,7 @@ const uploadExample = `curl -X POST ${baseUrl.value}/api/v1/documents/upload \\
<h3 class="section-subtitle"> <h3 class="section-subtitle">
<SafetyCertificateOutlined /> 获取 Token <SafetyCertificateOutlined /> 获取 Token
</h3> </h3>
<p class="apidocs__p">先调用登录接口获取 JWT再在后续请求 Header 中携带</p> <p class="apidocs__p">先调用登录接口获取 session token再在后续需要鉴权的请求 Header 中携带</p>
<pre class="code-block">{{ loginExample }}</pre> <pre class="code-block">{{ loginExample }}</pre>
</section> </section>
@@ -152,6 +195,13 @@ const uploadExample = `curl -X POST ${baseUrl.value}/api/v1/documents/upload \\
margin-bottom: 20px; margin-bottom: 20px;
} }
.apidocs__skill {
padding: 16px 18px;
border: 1px solid rgba(22, 119, 255, 0.25);
border-radius: 10px;
background: rgba(22, 119, 255, 0.04);
}
.apidocs__block { .apidocs__block {
margin-bottom: 24px; margin-bottom: 24px;
} }
-70
View File
@@ -1,70 +0,0 @@
<script setup>
import { onMounted, ref } from 'vue'
import { message } from 'ant-design-vue'
import { categories as fetchCategories } from '@/api/knowledge'
const isLoading = ref(false)
const categoriesData = ref([])
const columns = [
{ title: '名称', dataIndex: 'name', key: 'name' },
{ title: '描述', dataIndex: 'description', key: 'description' }
]
async function loadCategories() {
isLoading.value = true
try {
const data = await fetchCategories()
categoriesData.value = data.categories || []
} catch (err) {
message.error(err?.message || '加载类目列表失败')
} finally {
isLoading.value = false
}
}
onMounted(() => {
loadCategories()
})
</script>
<template>
<div class="categories page-section">
<div class="categories__header">
<h2 class="categories__title">类目列表</h2>
<a-button :loading="isLoading" @click="loadCategories">刷新</a-button>
</div>
<a-table
:columns="columns"
:data-source="categoriesData"
:pagination="false"
:loading="isLoading"
row-key="name"
size="small"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'name'">
<a-tag color="blue">{{ record.name }}</a-tag>
</template>
<template v-else-if="column.key === 'description'">
<span>{{ record.description || '-' }}</span>
</template>
</template>
</a-table>
</div>
</template>
<style scoped>
.categories__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.categories__title {
font-size: 16px;
margin: 0;
}
</style>
-316
View File
@@ -1,316 +0,0 @@
<script setup>
import { onMounted, reactive, ref } from 'vue'
import { message, Modal } from 'ant-design-vue'
import { FileOutlined } from '@ant-design/icons-vue'
import {
list as fetchDocuments,
detail as fetchDocumentDetail,
remove as deleteDocument
} from '@/api/documents'
import { truncate } from '@/utils/format'
function formatSize(bytes) {
if (bytes == null) return ''
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
}
const isLoading = ref(false)
const isLoadingDetail = ref(false)
const isDeleting = ref(false)
const documents = ref([])
const nextOffset = ref(null)
const detailVisible = ref(false)
const detailData = ref(null)
const statusText = reactive({
text: ''
})
function updateStatusText() {
statusText.text =
nextOffset.value === null ? '已加载全部' : '还有更多,可继续加载'
}
async function loadDocuments(reset = false) {
isLoading.value = true
try {
const offset = reset ? null : nextOffset.value
const data = await fetchDocuments(20, offset)
if (reset) {
documents.value = data.items || []
} else {
documents.value = documents.value.concat(data.items || [])
}
nextOffset.value = data.next_offset ?? null
updateStatusText()
} catch (err) {
message.error(err?.message || '加载文档列表失败')
} finally {
isLoading.value = false
}
}
async function handleLoadMore() {
await loadDocuments(false)
}
function handleViewDetail(docId) {
detailVisible.value = true
detailData.value = null
loadDocDetail(docId)
}
async function loadDocDetail(docId) {
isLoadingDetail.value = true
try {
detailData.value = await fetchDocumentDetail(docId)
} catch (err) {
message.error(err?.message || '加载文档详情失败')
} finally {
isLoadingDetail.value = false
}
}
function handleDelete(doc) {
const title = doc.title || doc.doc_id
Modal.confirm({
title: '确认删除',
content: `确定删除文档「${title}」(${doc.doc_id}) 吗?该操作将删除四层集合中的全部数据,不可恢复。`,
okText: '删除',
okType: 'danger',
cancelText: '取消',
async onOk() {
isDeleting.value = true
try {
const data = await deleteDocument(doc.doc_id)
message.success(`已删除 ${data.deleted_total ?? 0} 条数据`)
detailVisible.value = false
await loadDocuments(true)
} catch (err) {
message.error(err?.message || '删除文档失败')
} finally {
isDeleting.value = false
}
}
})
}
function handleCloseDetail() {
detailVisible.value = false
detailData.value = null
}
const columns = [
{ title: '标题', dataIndex: 'title', key: 'title', ellipsis: true },
{ title: '类目', dataIndex: 'category', key: 'category', width: 140 },
{ title: '标签', dataIndex: 'tags', key: 'tags', width: 220 },
{ title: 'L1 摘要', dataIndex: 'summary', key: 'summary', ellipsis: true },
{ title: '操作', key: 'action', width: 160, fixed: 'right' }
]
function getDocTitle(record) {
return record?.title || '(无标题)'
}
onMounted(() => {
loadDocuments(true)
})
</script>
<template>
<div class="documents page-section">
<div class="documents__header">
<h2 class="documents__title">文档管理</h2>
</div>
<a-table
:columns="columns"
:data-source="documents"
:pagination="false"
:loading="isLoading"
row-key="doc_id"
size="small"
:scroll="{ x: 900 }"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'title'">
<span :title="record.title">{{ getDocTitle(record) }}</span>
</template>
<template v-else-if="column.key === 'category'">
<a-tag v-if="record.category" color="blue">{{ record.category }}</a-tag>
<span v-else class="text-muted">-</span>
</template>
<template v-else-if="column.key === 'tags'">
<template v-if="record.tags && record.tags.length">
<a-tag v-for="tag in record.tags" :key="tag">{{ tag }}</a-tag>
</template>
<span v-else class="text-muted">-</span>
</template>
<template v-else-if="column.key === 'summary'">
<span :title="record.summary">{{ truncate(record.summary, 80) }}</span>
</template>
<template v-else-if="column.key === 'action'">
<a-space>
<a-button type="link" size="small" @click="handleViewDetail(record.doc_id)">
详情
</a-button>
<a-button
type="link"
size="small"
danger
:loading="isDeleting"
@click="handleDelete(record)"
>
删除
</a-button>
</a-space>
</template>
</template>
</a-table>
<div class="toolbar documents__toolbar">
<a-button
:loading="isLoading"
:disabled="nextOffset === null"
@click="handleLoadMore"
>
加载更多
</a-button>
<span class="text-muted" style="margin-left: 12px">{{ statusText.text }}</span>
</div>
<a-drawer
:open="detailVisible"
title="文档详情"
placement="right"
width="640"
:destroy-on-close="true"
@close="handleCloseDetail"
>
<a-spin :spinning="isLoadingDetail">
<div v-if="detailData">
<a-descriptions :column="1" size="small" bordered>
<a-descriptions-item label="doc_id">
{{ detailData.l1?.doc_id || '-' }}
</a-descriptions-item>
<a-descriptions-item label="标题">
{{ detailData.l1?.title || '-' }}
</a-descriptions-item>
<a-descriptions-item label="类目">
{{ detailData.l1?.category || '-' }}
</a-descriptions-item>
<a-descriptions-item label="标签">
<template v-if="detailData.l1?.tags && detailData.l1.tags.length">
<a-tag v-for="tag in detailData.l1.tags" :key="tag">{{ tag }}</a-tag>
</template>
<span v-else class="text-muted">-</span>
</a-descriptions-item>
<a-descriptions-item label="chunks_count">
{{ detailData.chunks_count ?? 0 }}
</a-descriptions-item>
<a-descriptions-item label="原文文件" v-if="detailData.file">
<a :href="detailData.file.url" target="_blank" rel="noopener">
<FileOutlined />
{{ detailData.file.filename }}
<span class="text-muted" v-if="detailData.file.size_bytes">
{{ formatSize(detailData.file.size_bytes) }}
</span>
</a>
</a-descriptions-item>
</a-descriptions>
<h3 class="documents__section-title">L1 全文</h3>
<pre class="documents__pre">{{ detailData.l1?.text || '' }}</pre>
<h3 class="documents__section-title">
L2 节点{{ (detailData.l2_nodes || []).length }}
</h3>
<div v-if="(detailData.l2_nodes || []).length === 0" class="text-muted"></div>
<div
v-for="(node, idx) in detailData.l2_nodes || []"
:key="`l2-${idx}`"
class="documents__node"
>
<div class="documents__node-path">{{ node.section_path || '' }}</div>
<div class="documents__node-text text-break">{{ node.text || '' }}</div>
</div>
<h3 class="documents__section-title">
L3 节点{{ (detailData.l3_nodes || []).length }}
</h3>
<div v-if="(detailData.l3_nodes || []).length === 0" class="text-muted"></div>
<div
v-for="(node, idx) in detailData.l3_nodes || []"
:key="`l3-${idx}`"
class="documents__node"
>
<div class="documents__node-path">{{ node.section_path || '' }}</div>
<div class="documents__node-text text-break">{{ node.text || '' }}</div>
</div>
</div>
<div v-else-if="!isLoadingDetail" class="text-muted">暂无数据</div>
</a-spin>
</a-drawer>
</div>
</template>
<style scoped>
.documents__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.documents__title {
font-size: 16px;
margin: 0;
}
.documents__toolbar {
display: flex;
align-items: center;
}
.documents__section-title {
font-size: 14px;
margin: 16px 0 8px;
color: #374151;
}
.documents__pre {
background: #f9fafb;
border: 1px solid #e5e7eb;
border-radius: 4px;
padding: 8px;
white-space: pre-wrap;
word-break: break-word;
font-size: 12px;
max-height: 260px;
overflow: auto;
margin: 0;
}
.documents__node {
border-left: 3px solid #93c5fd;
padding: 6px 10px;
margin-bottom: 6px;
background: #f8fafc;
border-radius: 0 4px 4px 0;
}
.documents__node-path {
font-size: 12px;
color: #6b7280;
margin-bottom: 4px;
}
.documents__node-text {
font-size: 13px;
color: #1f2937;
}
</style>
+595 -218
View File
@@ -1,60 +1,56 @@
<script setup> <script setup>
import { computed, reactive, ref } from 'vue' import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { message } from 'ant-design-vue' import { message, Modal } from 'ant-design-vue'
import { ingest as ingestDocument, upload as uploadDocument } from '@/api/documents'
import { useIngestPolling } from '@/composables/useIngestPolling'
import { import {
INGEST_STATUS_TEXT, UploadOutlined,
INGEST_STATUS_COLOR ReloadOutlined,
} from '@/constants/ingest' EyeOutlined,
ArrowLeftOutlined,
UndoOutlined,
DeleteOutlined
} from '@ant-design/icons-vue'
import {
ingest as ingestDocument,
upload as uploadDocument,
uploadBatch,
taskList,
taskStatus,
retryTask,
deleteTask
} from '@/api/documents'
import { useIngestPolling } from '@/composables/useIngestPolling'
import { INGEST_STATUS_TEXT, INGEST_STATUS_COLOR } from '@/constants/ingest'
import { formatSize } from '@/utils/format'
/* ---------- 入库表单 ---------- */
const activeTab = ref('text')
const { state: pollState, startPolling, stopPolling } = useIngestPolling() const { state: pollState, startPolling, stopPolling } = useIngestPolling()
const activeTab = ref('text')
const isSubmittingText = ref(false)
const isSubmittingFile = ref(false)
const textFormRef = ref(null) const textFormRef = ref(null)
const textForm = reactive({ const textForm = reactive({ title: '', source: '', text: '' })
title: '',
source: '',
text: ''
})
const textRules = { const textRules = {
title: [{ required: true, message: '请输入标题', trigger: 'blur' }], title: [{ required: true, message: '请输入标题', trigger: 'blur' }],
text: [{ required: true, message: '请输入正文', trigger: 'blur' }] text: [{ required: true, message: '请输入正文', trigger: 'blur' }]
} }
const isSubmittingText = ref(false)
const isSubmittingFile = ref(false)
const isSubmittingBatch = ref(false)
const fileForm = reactive({ const fileForm = reactive({ title: '', source: '' })
title: '',
source: ''
})
const fileList = ref([]) const fileList = ref([])
const rawFile = ref(null) const rawFile = ref(null)
const batchResult = ref(null)
const ACCEPTED_EXTENSIONS = '.txt,.md,.html,.htm,.pdf,.docx' const ACCEPTED_EXTENSIONS = '.txt,.md,.html,.htm,.pdf,.docx'
const statusBadgeColor = computed(() => { const statusBadgeColor = computed(() => INGEST_STATUS_COLOR[pollState.status] || 'default')
return INGEST_STATUS_COLOR[pollState.status] || 'default' const statusBadgeText = computed(() => INGEST_STATUS_TEXT[pollState.status] || pollState.status || '-')
}) const isTerminal = computed(() => ['done', 'failed'].includes(pollState.status))
const statusBadgeText = computed(() => {
return INGEST_STATUS_TEXT[pollState.status] || pollState.status || '-'
})
const isTerminal = computed(() =>
['done', 'failed'].includes(pollState.status)
)
const resultData = computed(() => pollState.task?.result || null) const resultData = computed(() => pollState.task?.result || null)
const errorData = computed(() => pollState.task?.error || pollState.error || null) const errorData = computed(() => pollState.task?.error || pollState.error || null)
const summaryData = computed(() => resultData.value?.summary || {}) const summaryData = computed(() => resultData.value?.summary || {})
const partialSummary = computed(() => errorData.value?.partial_summary || null) const partialSummary = computed(() => errorData.value?.partial_summary || null)
function handleFileChange(file) { function handleFileChange(file) {
// a-upload before-upload 返回 false 表示不自动上传;保存原始 File 供 FormData 使用
rawFile.value = file rawFile.value = file
fileList.value = [file] fileList.value = [file]
return false return false
@@ -65,6 +61,22 @@ function handleFileRemove() {
fileList.value = [] fileList.value = []
} }
function handleBatchFileChange(_file, fileListArg) {
fileList.value = fileListArg.map((f) => f.originFileObj || f)
batchResult.value = null
return false
}
function handleBatchRemove(file) {
fileList.value = fileList.value.filter((f) => f !== file && f.name !== file.name)
batchResult.value = null
}
function handleBatchClear() {
fileList.value = []
batchResult.value = null
}
async function handleSubmitText() { async function handleSubmitText() {
try { try {
await textFormRef.value.validate() await textFormRef.value.validate()
@@ -73,16 +85,12 @@ async function handleSubmitText() {
} }
isSubmittingText.value = true isSubmittingText.value = true
try { try {
const payload = { const payload = { title: textForm.title, text: textForm.text }
title: textForm.title, if (textForm.source) payload.source = textForm.source
text: textForm.text
}
if (textForm.source) {
payload.source = textForm.source
}
const data = await ingestDocument(payload) const data = await ingestDocument(payload)
message.success('任务已提交') message.success('任务已提交')
startPolling(data.task_id) startPolling(data.task_id)
await loadTasks(false)
} catch (err) { } catch (err) {
message.error(err?.message || '提交入库失败') message.error(err?.message || '提交入库失败')
} finally { } finally {
@@ -99,15 +107,12 @@ async function handleSubmitFile() {
try { try {
const formData = new FormData() const formData = new FormData()
formData.append('file', rawFile.value) formData.append('file', rawFile.value)
if (fileForm.title) { if (fileForm.title) formData.append('title', fileForm.title)
formData.append('title', fileForm.title) if (fileForm.source) formData.append('source', fileForm.source)
}
if (fileForm.source) {
formData.append('source', fileForm.source)
}
const data = await uploadDocument(formData) const data = await uploadDocument(formData)
message.success('任务已提交') message.success('任务已提交')
startPolling(data.task_id) startPolling(data.task_id)
await loadTasks(false)
} catch (err) { } catch (err) {
message.error(err?.message || '上传入库失败') message.error(err?.message || '上传入库失败')
} finally { } finally {
@@ -115,194 +120,536 @@ async function handleSubmitFile() {
} }
} }
async function handleSubmitBatch() {
if (!fileList.value.length) {
message.warning('请选择文件')
return
}
isSubmittingBatch.value = true
try {
const data = await uploadBatch(fileList.value)
batchResult.value = data
const okCount = data.tasks?.length || 0
const failCount = data.failed?.length || 0
if (okCount > 0) {
message.success(`已提交 ${okCount} 个任务${failCount > 0 ? `${failCount} 个失败` : ''}`)
} else if (failCount > 0) {
message.error(`全部 ${failCount} 个文件上传失败`)
}
if (data.tasks?.length === 1 && failCount === 0) {
startPolling(data.tasks[0].task_id)
}
await loadTasks(false)
} catch (err) {
message.error(err?.message || '批量上传失败')
} finally {
isSubmittingBatch.value = false
}
}
function handleCancelPolling() { function handleCancelPolling() {
stopPolling() stopPolling()
message.info('已停止轮询') message.info('已停止轮询')
} }
const batchColumns = [
{ title: '文件名', dataIndex: 'filename', key: 'filename', ellipsis: true },
{ title: 'task_id', dataIndex: 'task_id', key: 'task_id', width: 240 }
]
const failedColumns = [
{ title: '文件名', dataIndex: 'filename', key: 'filename', ellipsis: true },
{ title: '错误', dataIndex: 'error', key: 'error' }
]
/* ---------- 入库任务列表 ---------- */
const tasks = ref([])
const total = ref(0)
const isLoading = ref(false)
const isPolling = ref(false)
const selectedTask = ref(null)
const isLoadingDetail = ref(false)
const isRetrying = ref(false)
const isDeleting = ref(false)
let timer = null
function formatTime(iso) {
if (!iso) return '-'
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return iso
const pad = (n) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
}
function statusText(s) {
return INGEST_STATUS_TEXT[s] || s || '-'
}
function statusColor(s) {
return INGEST_STATUS_COLOR[s] || 'default'
}
async function loadTasks(showSpinner = false) {
if (showSpinner) isLoading.value = true
try {
const data = await taskList(50)
tasks.value = data.items || []
total.value = data.total ?? tasks.value.length
} catch (err) {
message.error(err?.message || '加载入库任务失败')
} finally {
if (showSpinner) isLoading.value = false
}
}
function startListPolling() {
if (timer) return
isPolling.value = true
timer = setInterval(async () => {
await loadTasks(false)
}, 2000)
}
function stopListPolling() {
if (timer) {
clearInterval(timer)
timer = null
}
isPolling.value = false
}
function toggleListPolling() {
if (isPolling.value) stopListPolling()
else startListPolling()
}
async function openDetail(task) {
selectedTask.value = null
isLoadingDetail.value = true
try {
selectedTask.value = await taskStatus(task.task_id)
} catch (err) {
message.error(err?.message || '加载任务详情失败')
} finally {
isLoadingDetail.value = false
}
}
function backToList() {
selectedTask.value = null
}
const detailIsTerminal = computed(() => ['done', 'failed'].includes(selectedTask.value?.status))
const detailResult = computed(() => selectedTask.value?.result || null)
const detailError = computed(() => selectedTask.value?.error || null)
const detailSummary = computed(() => detailResult.value?.summary || {})
const canRetry = computed(() => ['failed', 'done'].includes(selectedTask.value?.status))
const canDelete = computed(() => ['failed', 'done'].includes(selectedTask.value?.status))
const detailComputed = computed(() => {
const task = selectedTask.value
if (!task) return null
const source = task.source || {}
const metadata = source.metadata || {}
return {
title: task.title || source.title || '-',
source: task.source_text || source.source || '-',
size: task.size_bytes != null ? formatSize(task.size_bytes) : formatSize(metadata.original_size_bytes)
}
})
async function handleRetry(task) {
const taskId = task?.task_id || selectedTask.value?.task_id
if (!taskId) return
isRetrying.value = true
try {
const data = await retryTask(taskId)
message.success(`已提交新任务:${data.task_id}`)
selectedTask.value = null
await loadTasks(false)
} catch (err) {
message.error(err?.message || '重试失败')
} finally {
isRetrying.value = false
}
}
function handleDelete(task) {
const taskId = task?.task_id || selectedTask.value?.task_id
if (!taskId) return
Modal.confirm({
title: '删除入库任务',
content: `确定删除任务「${taskId}」吗?删除后不可恢复。`,
okText: '删除',
okType: 'danger',
cancelText: '取消',
async onOk() {
isDeleting.value = true
try {
await deleteTask(taskId)
message.success('任务已删除')
selectedTask.value = null
await loadTasks(false)
} catch (err) {
message.error(err?.message || '删除任务失败')
} finally {
isDeleting.value = false
}
}
})
}
onMounted(() => {
loadTasks(true)
startListPolling()
})
onBeforeUnmount(() => {
stopListPolling()
stopPolling()
})
const columns = [
{ title: '状态', dataIndex: 'status', key: 'status', width: 90 },
{ title: '文件名', dataIndex: 'filename', key: 'filename', ellipsis: true },
{ title: 'task_id', dataIndex: 'task_id', key: 'task_id', width: 200 },
{ title: 'doc_id', dataIndex: 'doc_id', key: 'doc_id', width: 200 },
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at', width: 160 },
{ title: '操作', dataIndex: 'action', key: 'action', width: 130 }
]
</script> </script>
<template> <template>
<div class="ingest page-section"> <div class="ingest page-section">
<div class="ingest__header"> <div class="ingest__header">
<h2 class="ingest__title">文档入库</h2> <h2 class="ingest__title"><UploadOutlined /> 文档入库</h2>
</div> </div>
<a-tabs v-model:activeKey="activeTab"> <div class="ingest__card">
<a-tab-pane key="text" tab="文本入库"> <a-tabs v-model:activeKey="activeTab">
<a-form <a-tab-pane key="text" tab="文本入库">
ref="textFormRef" <a-form ref="textFormRef" :model="textForm" :rules="textRules" layout="vertical">
:model="textForm" <a-form-item label="标题" name="title">
:rules="textRules" <a-input v-model:value="textForm.title" placeholder="请输入标题" allow-clear />
layout="vertical" </a-form-item>
> <a-form-item label="来源" name="source">
<a-form-item label="标题" name="title"> <a-input v-model:value="textForm.source" placeholder="例如:manual / web / file" allow-clear />
<a-input v-model:value="textForm.title" placeholder="请输入标题" allow-clear /> </a-form-item>
</a-form-item> <a-form-item label="正文" name="text">
<a-form-item label="来源" name="source"> <a-textarea v-model:value="textForm.text" placeholder="请输入文档正文" :auto-size="{ minRows: 8, maxRows: 18 }" />
<a-input </a-form-item>
v-model:value="textForm.source" <a-form-item>
placeholder="例如:manual / web / file" <a-button type="primary" :loading="isSubmittingText || pollState.isPolling" @click="handleSubmitText">
allow-clear 提交入库
</a-button>
</a-form-item>
</a-form>
</a-tab-pane>
<a-tab-pane key="file" tab="文件上传">
<a-form :model="fileForm" layout="vertical">
<a-form-item label="选择文件">
<a-upload :file-list="fileList" :accept="ACCEPTED_EXTENSIONS" :max-count="1" :before-upload="handleFileChange" @remove="handleFileRemove">
<a-button :disabled="fileList.length >= 1">选择文件</a-button>
</a-upload>
<div class="text-muted" style="margin-top: 4px">支持 .txt/.md/.html/.htm/.pdf/.docx</div>
</a-form-item>
<a-form-item label="标题(可选,默认取文件名)" name="title">
<a-input v-model:value="fileForm.title" placeholder="留空则使用文件名去扩展" allow-clear />
</a-form-item>
<a-form-item label="来源(可选,默认 file:原文件名)" name="source">
<a-input v-model:value="fileForm.source" placeholder="例如:manual / web" allow-clear />
</a-form-item>
<a-form-item>
<a-button type="primary" :loading="isSubmittingFile || pollState.isPolling" @click="handleSubmitFile">
上传入库
</a-button>
</a-form-item>
</a-form>
</a-tab-pane>
<a-tab-pane key="batch" tab="批量上传">
<a-form :model="fileForm" layout="vertical">
<a-form-item label="选择文件(可多选)">
<a-upload multiple :file-list="fileList" :accept="ACCEPTED_EXTENSIONS" :before-upload="handleBatchFileChange" @remove="handleBatchRemove">
<a-button>选择文件</a-button>
</a-upload>
<div class="text-muted" style="margin-top: 4px">支持 .txt/.md/.html/.htm/.pdf/.docx可多选逐文件异步入库</div>
</a-form-item>
<a-form-item v-if="fileList.length">
<a-button type="primary" :loading="isSubmittingBatch" @click="handleSubmitBatch">
批量上传入库{{ fileList.length }} 个文件
</a-button>
<a-button style="margin-left: 8px" :disabled="isSubmittingBatch" @click="handleBatchClear">清空</a-button>
</a-form-item>
</a-form>
<div v-if="batchResult" class="ingest__batch">
<a-alert
v-if="batchResult.failed && batchResult.failed.length"
class="ingest__alert"
type="error"
show-icon
:message="`${batchResult.failed.length} 个文件上传失败`"
/> />
</a-form-item> <a-table
<a-form-item label="正文" name="text"> v-if="batchResult.tasks && batchResult.tasks.length"
<a-textarea :data-source="batchResult.tasks"
v-model:value="textForm.text" :columns="batchColumns"
placeholder="请输入文档正文" :pagination="false"
:auto-size="{ minRows: 8, maxRows: 18 }" size="small"
/> row-key="task_id"
</a-form-item>
<a-form-item>
<a-button
type="primary"
:loading="isSubmittingText || pollState.isPolling"
@click="handleSubmitText"
> >
提交入库 <template #bodyCell="{ column, record }">
</a-button> <template v-if="column.key === 'task_id'">
</a-form-item> <a-typography-text copyable :style="{ fontSize: '12px' }">{{ record.task_id }}</a-typography-text>
</a-form> </template>
</a-tab-pane> </template>
</a-table>
<a-tab-pane key="file" tab="文件上传"> <a-table
<a-form v-if="batchResult.failed && batchResult.failed.length"
:model="fileForm" :data-source="batchResult.failed"
layout="vertical" :columns="failedColumns"
> :pagination="false"
<a-form-item label="选择文件"> size="small"
<a-upload row-key="filename"
:file-list="fileList" style="margin-top: 12px"
:accept="ACCEPTED_EXTENSIONS"
:max-count="1"
:before-upload="handleFileChange"
@remove="handleFileRemove"
> >
<a-button :disabled="fileList.length >= 1">选择文件</a-button> <template #bodyCell="{ column, record }">
</a-upload> <template v-if="column.key === 'error'">
<div class="text-muted" style="margin-top: 4px"> <span class="text-break">{{ record.error }}</span>
支持 .txt/.md/.html/.htm/.pdf/.docx </template>
</div> </template>
</a-form-item> </a-table>
<a-form-item label="标题(可选,默认取文件名)" name="title"> </div>
<a-input </a-tab-pane>
v-model:value="fileForm.title" </a-tabs>
placeholder="留空则使用文件名去扩展"
allow-clear
/>
</a-form-item>
<a-form-item label="来源(可选,默认 file:原文件名)" name="source">
<a-input
v-model:value="fileForm.source"
placeholder="例如:manual / web"
allow-clear
/>
</a-form-item>
<a-form-item>
<a-button
type="primary"
:loading="isSubmittingFile || pollState.isPolling"
@click="handleSubmitFile"
>
上传入库
</a-button>
</a-form-item>
</a-form>
</a-tab-pane>
</a-tabs>
<div v-if="pollState.taskId" class="ingest__result"> <div v-if="pollState.taskId" class="ingest__result">
<div class="ingest__result-header"> <div class="ingest__result-header">
<span>任务已提交{{ pollState.taskId }}</span> <span>任务已提交{{ pollState.taskId }}</span>
<a-tag :color="statusBadgeColor">{{ statusBadgeText }}</a-tag> <a-tag :color="statusBadgeColor">{{ statusBadgeText }}</a-tag>
<a-button <a-button v-if="pollState.isPolling" type="link" size="small" @click="handleCancelPolling">停止轮询</a-button>
v-if="pollState.isPolling" </div>
type="link" <a-alert
size="small" v-if="pollState.isTimeout"
@click="handleCancelPolling" class="ingest__alert"
> type="warning"
停止轮询 show-icon
</a-button> :message="`任务仍在进行,可稍后凭 task_id 查询:${pollState.taskId}`"
</div> />
<div v-if="isTerminal && pollState.status === 'done' && resultData" class="ingest__done">
<a-alert <a-descriptions :column="1" size="small" bordered>
v-if="pollState.isTimeout" <a-descriptions-item label="document_id">{{ resultData.document_id || '-' }}</a-descriptions-item>
class="ingest__alert" <a-descriptions-item label="类目">{{ resultData.category || '-' }}置信度 {{ resultData.category_confidence ?? '-' }}</a-descriptions-item>
type="warning" <a-descriptions-item label="标签">
show-icon <template v-if="resultData.tags && resultData.tags.length">
:message="`任务仍在进行,可稍后凭 task_id 查询:${pollState.taskId}`" <a-tag v-for="tag in resultData.tags" :key="tag">{{ tag }}</a-tag>
/> </template>
<span v-else class="text-muted">-</span>
<a-alert </a-descriptions-item>
v-if="pollState.error && !pollState.task" <a-descriptions-item label="总结层级">{{ summaryData.level ?? '-' }}</a-descriptions-item>
class="ingest__alert" <a-descriptions-item label="chunks_count">{{ resultData.chunks_count ?? 0 }}</a-descriptions-item>
type="error" </a-descriptions>
show-icon
:message="pollState.error?.message || '轮询失败'"
/>
<div v-if="isTerminal && pollState.status === 'done' && resultData" class="ingest__done">
<a-descriptions :column="1" size="small" bordered>
<a-descriptions-item label="document_id">
{{ resultData.document_id || '-' }}
</a-descriptions-item>
<a-descriptions-item label="类目">
{{ resultData.category || '-' }}置信度 {{ resultData.category_confidence ?? '-' }}
</a-descriptions-item>
<a-descriptions-item label="标签">
<template v-if="resultData.tags && resultData.tags.length">
<a-tag v-for="tag in resultData.tags" :key="tag">{{ tag }}</a-tag>
</template>
<span v-else class="text-muted">-</span>
</a-descriptions-item>
<a-descriptions-item label="总结层级">
{{ summaryData.level ?? '-' }}
</a-descriptions-item>
<a-descriptions-item label="写入集合">
{{ resultData.collection || '-' }}
</a-descriptions-item>
<a-descriptions-item label="chunks_count">
{{ resultData.chunks_count ?? 0 }}
</a-descriptions-item>
</a-descriptions>
<h3 class="ingest__section-title">L1 总结</h3>
<pre class="ingest__pre">{{ summaryData.l1_summary || '' }}</pre>
</div>
<div v-if="isTerminal && pollState.status === 'failed'" class="ingest__failed">
<a-descriptions :column="1" size="small" bordered>
<a-descriptions-item label="失败阶段">
{{ errorData?.stage || '-' }}
</a-descriptions-item>
<a-descriptions-item label="错误信息">
<span class="text-break">{{ errorData?.message || '-' }}</span>
</a-descriptions-item>
</a-descriptions>
<template v-if="partialSummary && partialSummary.l1_summary">
<a-alert
class="ingest__alert"
type="info"
show-icon
message="已产出总结保留:任务失败前已生成 L1 摘要"
/>
<h3 class="ingest__section-title">L1 总结</h3> <h3 class="ingest__section-title">L1 总结</h3>
<pre class="ingest__pre">{{ partialSummary.l1_summary }}</pre> <pre class="ingest__pre">{{ summaryData.l1_summary || '' }}</pre>
</template> </div>
<div v-if="isTerminal && pollState.status === 'failed'" class="ingest__failed">
<a-descriptions :column="1" size="small" bordered>
<a-descriptions-item label="失败阶段">{{ errorData?.stage || '-' }}</a-descriptions-item>
<a-descriptions-item label="错误信息"><span class="text-break">{{ errorData?.message || '-' }}</span></a-descriptions-item>
</a-descriptions>
<template v-if="partialSummary && partialSummary.l1_summary">
<h3 class="ingest__section-title">L1 总结</h3>
<pre class="ingest__pre">{{ partialSummary.l1_summary }}</pre>
</template>
</div>
</div> </div>
</div> </div>
<div class="ingest__card">
<div class="ingest__tasks-header">
<h3 class="ingest__tasks-title"><ClockCircleOutlined /> 入库任务 {{ total }} </h3>
<div class="ingest__tasks-actions">
<a-button size="small" :loading="isLoading" @click="loadTasks(true)">
<template #icon><ReloadOutlined /></template>
刷新
</a-button>
<a-button size="small" :type="isPolling ? 'default' : 'primary'" @click="toggleListPolling">
{{ isPolling ? '停止轮询' : '开始轮询' }}
</a-button>
</div>
</div>
<a-alert
v-if="isPolling"
type="info"
show-icon
message="每 2 秒自动刷新近期入库任务。"
style="margin-bottom: 12px"
/>
<!-- 列表视图 -->
<template v-if="!selectedTask">
<a-table
:columns="columns"
:data-source="tasks"
:pagination="false"
:loading="isLoading"
row-key="task_id"
size="small"
:scroll="{ x: 720 }"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'status'">
<a-tag :color="statusColor(record.status)">{{ statusText(record.status) }}</a-tag>
</template>
<template v-else-if="column.key === 'filename'">
<span :title="record.filename">{{ record.filename || '-' }}</span>
</template>
<template v-else-if="column.key === 'task_id'">
<a-typography-text copyable :style="{ fontSize: '12px' }">{{ record.task_id }}</a-typography-text>
</template>
<template v-else-if="column.key === 'doc_id'">
<a-typography-text
v-if="record.doc_id"
copyable
:style="{ fontSize: '12px' }"
>
{{ record.doc_id }}
</a-typography-text>
<span v-else class="text-muted">-</span>
</template>
<template v-else-if="column.key === 'created_at'">
{{ formatTime(record.created_at) }}
</template>
<template v-else-if="column.key === 'action'">
<a-button type="link" size="small" @click="openDetail(record)">
<template #icon><EyeOutlined /></template>
详情
</a-button>
<a-button
v-if="['failed', 'done'].includes(record.status)"
type="link"
size="small"
@click="handleRetry(record)"
>
<template #icon><UndoOutlined /></template>
重试
</a-button>
<a-button
v-if="['done', 'failed'].includes(record.status)"
type="link"
size="small"
danger
@click="handleDelete(record)"
>
<template #icon><DeleteOutlined /></template>
删除
</a-button>
</template>
</template>
</a-table>
</template>
<!-- 详情视图 -->
<template v-else>
<a-button size="small" type="link" style="margin-bottom: 12px; padding-left: 0" @click="backToList">
<template #icon><ArrowLeftOutlined /></template>
返回列表
</a-button>
<a-spin :spinning="isLoadingDetail">
<template v-if="selectedTask">
<a-descriptions :column="1" size="small" bordered>
<a-descriptions-item label="状态">
<a-tag :color="statusColor(selectedTask.status)">{{ statusText(selectedTask.status) }}</a-tag>
</a-descriptions-item>
<a-descriptions-item label="task_id">{{ selectedTask.task_id || '-' }}</a-descriptions-item>
<a-descriptions-item label="文件名">{{ selectedTask.filename || '-' }}</a-descriptions-item>
<a-descriptions-item label="标题">{{ detailComputed?.title || '-' }}</a-descriptions-item>
<a-descriptions-item label="来源">{{ detailComputed?.source || '-' }}</a-descriptions-item>
<a-descriptions-item label="文件大小">{{ detailComputed?.size || '-' }}</a-descriptions-item>
<a-descriptions-item label="doc_id">{{ selectedTask.doc_id || '-' }}</a-descriptions-item>
<a-descriptions-item label="创建时间">{{ formatTime(selectedTask.created_at) }}</a-descriptions-item>
<a-descriptions-item label="更新时间">{{ formatTime(selectedTask.updated_at) }}</a-descriptions-item>
</a-descriptions>
<div v-if="detailIsTerminal && selectedTask.status === 'done' && detailResult" class="ingest__block">
<h4 class="ingest__block-title">入库结果</h4>
<a-descriptions :column="1" size="small" bordered>
<a-descriptions-item label="document_id">{{ detailResult.document_id || '-' }}</a-descriptions-item>
<a-descriptions-item label="类目">{{ detailResult.category || '-' }}置信度 {{ detailResult.category_confidence ?? '-' }}</a-descriptions-item>
<a-descriptions-item label="标签">
<template v-if="detailResult.tags && detailResult.tags.length">
<a-tag v-for="tag in detailResult.tags" :key="tag">{{ tag }}</a-tag>
</template>
<span v-else class="text-muted">-</span>
</a-descriptions-item>
<a-descriptions-item label="总结层级">{{ detailSummary.level ?? '-' }}</a-descriptions-item>
<a-descriptions-item label="chunks_count">{{ detailResult.chunks_count ?? 0 }}</a-descriptions-item>
<a-descriptions-item v-if="detailResult.deduplicated" label="去重">复用既有文档</a-descriptions-item>
</a-descriptions>
<h4 class="ingest__block-title">L1 总结</h4>
<pre class="ingest__pre">{{ detailSummary.l1_summary || '' }}</pre>
</div>
<div v-if="detailIsTerminal && selectedTask.status === 'failed' && detailError" class="ingest__block">
<a-alert
type="error"
show-icon
:message="`失败阶段:${detailError.stage || '-'}`"
:description="detailError.message || '-'"
/>
<template v-if="detailError.partial_summary && detailError.partial_summary.l1_summary">
<h4 class="ingest__block-title">L1 总结部分</h4>
<pre class="ingest__pre">{{ detailError.partial_summary.l1_summary }}</pre>
</template>
</div>
<div class="ingest__actions">
<a-button v-if="canRetry" :loading="isRetrying" @click="handleRetry(selectedTask)">
<template #icon><UndoOutlined /></template>
重试
</a-button>
<a-button v-if="canDelete" danger :loading="isDeleting" @click="handleDelete(selectedTask)">
<template #icon><DeleteOutlined /></template>
删除
</a-button>
</div>
</template>
</a-spin>
</template>
</div>
</div> </div>
</template> </template>
<style scoped> <style scoped>
.ingest__header { .ingest__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px; margin-bottom: 12px;
} }
.ingest__title { .ingest__title {
font-size: 16px; font-size: 16px;
margin: 0; margin: 0;
display: flex;
align-items: center;
gap: 6px;
}
.ingest__card {
background: #fff;
border: 1px solid #f0f0f0;
border-radius: 8px;
padding: 16px;
margin-bottom: 16px;
}
.ingest__batch {
margin-top: 8px;
}
.ingest__alert {
margin: 8px 0;
} }
.ingest__result { .ingest__result {
@@ -322,18 +669,9 @@ function handleCancelPolling() {
margin-bottom: 8px; margin-bottom: 8px;
} }
.ingest__alert {
margin: 8px 0;
}
.ingest__done,
.ingest__failed {
margin-top: 8px;
}
.ingest__section-title { .ingest__section-title {
font-size: 14px; font-size: 14px;
margin: 12px 0 8px; margin: 16px 0 8px;
color: #374151; color: #374151;
} }
@@ -349,4 +687,43 @@ function handleCancelPolling() {
overflow: auto; overflow: auto;
margin: 0; margin: 0;
} }
.ingest__tasks-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
flex-wrap: wrap;
gap: 8px;
}
.ingest__tasks-title {
font-size: 15px;
margin: 0;
display: flex;
align-items: center;
gap: 6px;
}
.ingest__tasks-actions {
display: flex;
align-items: center;
gap: 8px;
}
.ingest__block {
margin-top: 16px;
}
.ingest__block-title {
font-size: 13px;
margin: 12px 0 6px;
color: #374151;
}
.ingest__actions {
margin-top: 16px;
display: flex;
gap: 8px;
}
</style> </style>
+498
View File
@@ -0,0 +1,498 @@
<script setup>
import { computed, h, onMounted, reactive, ref } from 'vue'
import { message, Modal } from 'ant-design-vue'
import {
FolderOutlined,
FileTextOutlined,
ReloadOutlined,
SearchOutlined,
TagsOutlined
} from '@ant-design/icons-vue'
import { useRouter } from 'vue-router'
import {
categories as fetchCategories,
stats as fetchStats
} from '@/api/knowledge'
import {
list as fetchDocuments,
detail as fetchDocumentDetail,
remove as deleteDocument,
reingest as reingestDocument
} from '@/api/documents'
const router = useRouter()
/* ---------- 树形目录 ---------- */
const isLoading = ref(false)
const treeData = ref([])
const categoriesData = ref([])
const categoryDesc = ref(null)
const selectedCategory = ref(null)
const allDocs = ref([])
const searchKeyword = ref('')
const UNCATEGORIZED = 'uncategorized'
function formatSize(bytes) {
if (bytes == null) return ''
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
}
function buildTree() {
const docs = allDocs.value
const filtered = searchKeyword.value
? docs.filter((d) => {
const kw = searchKeyword.value.toLowerCase()
return (
(d.title || '').toLowerCase().includes(kw) ||
(d.summary || '').toLowerCase().includes(kw) ||
(d.category || '').toLowerCase().includes(kw)
)
})
: docs
// 类目 → 文档映射
const byCat = {}
filtered.forEach((d) => {
const cat = d.category || UNCATEGORIZED
if (!byCat[cat]) byCat[cat] = []
byCat[cat].push(d)
})
const nodes = []
// 定义类目顺序:taxonomy 中定义的类目在前,未定义但存在的类目追加
const defined = categoriesData.value.map((c) => c.name)
const known = new Set(defined)
Object.keys(byCat).forEach((cat) => {
if (!known.has(cat)) known.add(cat)
})
defined.forEach((cat) => {
if (byCat[cat]) {
nodes.push(makeCategoryNode(cat, byCat[cat]))
}
})
// 未在 taxonomy 中但实际存在的类目
Object.keys(byCat).forEach((cat) => {
if (!defined.includes(cat)) {
nodes.push(makeCategoryNode(cat, byCat[cat]))
}
})
return nodes
}
function makeCategoryNode(cat, docs) {
return {
key: `cat:${cat}`,
title: cat,
icon: () => h(FolderOutlined),
isLeaf: docs.length === 0,
category: cat,
count: docs.length,
children: docs.map((d) => ({
key: `doc:${d.doc_id}`,
title: d.title || '(无标题)',
icon: () => h(FileTextOutlined),
isLeaf: true,
doc: d
}))
}
}
const treeDataComputed = computed(() => buildTree())
function handleTreeSelect(keys, info) {
const node = info.selectedNodes?.[0]
if (!node) return
const key = node.key
if (key.startsWith('cat:')) {
selectedCategory.value = node.category
categoryDesc.value = categoriesData.value.find((c) => c.name === node.category)?.description || ''
closeDocDetail()
} else if (key.startsWith('doc:')) {
selectedCategory.value = null
categoryDesc.value = null
openDocDetail(node.doc)
}
}
async function loadAll() {
isLoading.value = true
try {
const [cats, stats] = await Promise.all([fetchCategories(), fetchStats()])
categoriesData.value = cats.categories || []
// 加载全部文档(分页直到取完)
const docs = []
let offset = null
for (;;) {
const page = await fetchDocuments(100, offset)
docs.push(...(page.items || []))
offset = page.next_offset ?? null
if (!offset) break
}
allDocs.value = docs
// 更新类目计数(可选)
} catch (err) {
message.error(err?.message || '加载知识库失败')
} finally {
isLoading.value = false
}
}
function handleRefresh() {
loadAll()
}
onMounted(() => {
loadAll()
})
/* ---------- 文档详情(页面内展示) ---------- */
const selectedDoc = ref(null)
const docDetailData = ref(null)
const isLoadingDetail = ref(false)
const isDeleting = ref(false)
const isReingesting = ref(false)
function openDocDetail(doc) {
selectedDoc.value = doc
docDetailData.value = null
loadDocDetail(doc.doc_id)
}
function closeDocDetail() {
selectedDoc.value = null
docDetailData.value = null
}
async function loadDocDetail(docId) {
isLoadingDetail.value = true
try {
docDetailData.value = await fetchDocumentDetail(docId)
} catch (err) {
message.error(err?.message || '加载文档详情失败')
} finally {
isLoadingDetail.value = false
}
}
function handleDelete(doc) {
const docId = doc.doc_id
const title = doc.title || docId
Modal.confirm({
title: '确认删除',
content: `确定删除文档「${title}」(${docId}) 吗?该操作将删除四层集合中的全部数据,不可恢复。`,
okText: '删除',
okType: 'danger',
cancelText: '取消',
async onOk() {
isDeleting.value = true
try {
await deleteDocument(docId)
message.success('已删除')
closeDocDetail()
await loadAll()
} catch (err) {
message.error(err?.message || '删除文档失败')
} finally {
isDeleting.value = false
}
}
})
}
function handleReingest(doc) {
const docId = doc.doc_id
const title = doc.title || docId
Modal.confirm({
title: '重新摘要入库',
content: `将重新读取文档「${title}」(${docId}) 的原始文件,删除旧数据后重新生成三级总结并入库。生成新的 doc_id。是否继续?`,
okText: '重新入库',
cancelText: '取消',
async onOk() {
isReingesting.value = true
try {
const data = await reingestDocument(docId)
message.success(`已提交重新入库任务:${data.task_id}`)
} catch (err) {
message.error(err?.message || '重新入库失败')
} finally {
isReingesting.value = false
}
}
})
}
</script>
<template>
<div class="library page-section">
<div class="library__header">
<h2 class="library__title">知识库</h2>
<div class="library__actions">
<a-input
v-model:value="searchKeyword"
placeholder="搜索标题 / 摘要 / 类目"
allow-clear
style="width: 260px"
>
<template #prefix><SearchOutlined /></template>
</a-input>
<a-button :loading="isLoading" @click="handleRefresh">
<template #icon><ReloadOutlined /></template>
刷新
</a-button>
<a-button type="primary" @click="router.push({ name: 'ingest' })">
<template #icon><UploadOutlined /></template>
入库
</a-button>
</div>
</div>
<div class="library__body">
<div class="library__tree">
<a-spin :spinning="isLoading">
<a-tree
:tree-data="treeDataComputed"
:default-expand-all="true"
:show-icon="true"
block-node
@select="handleTreeSelect"
>
<template #title="{ title, count, category }">
<span v-if="category !== undefined" class="library__node-cat">
<span>{{ title }}</span>
<span class="text-muted library__node-count">{{ count }}</span>
</span>
<span v-else>{{ title }}</span>
</template>
</a-tree>
</a-spin>
</div>
<div class="library__detail">
<!-- 类目说明 -->
<div v-if="selectedCategory" class="library__panel">
<h3 class="library__panel-title">
<TagsOutlined /> {{ selectedCategory }}
</h3>
<div class="text-muted">{{ categoryDesc || '(无描述)' }}</div>
</div>
<!-- 文档详情页面内展示 -->
<div v-else-if="selectedDoc" class="library__panel library__doc-detail">
<div class="library__doc-head">
<h3 class="library__panel-title library__doc-title">
<FileTextOutlined /> {{ selectedDoc.title || '(无标题)' }}
</h3>
<div class="library__detail-actions">
<a-button size="small" :loading="isReingesting" @click="handleReingest(selectedDoc)">
重新摘要
</a-button>
<a-button size="small" danger :loading="isDeleting" @click="handleDelete(selectedDoc)">
删除
</a-button>
</div>
</div>
<a-spin :spinning="isLoadingDetail">
<div v-if="docDetailData">
<a-descriptions :column="1" size="small" bordered>
<a-descriptions-item label="doc_id">{{ docDetailData.l1?.doc_id || '-' }}</a-descriptions-item>
<a-descriptions-item label="标题">{{ docDetailData.l1?.title || '-' }}</a-descriptions-item>
<a-descriptions-item label="类目">{{ docDetailData.l1?.category || '-' }}</a-descriptions-item>
<a-descriptions-item label="标签">
<template v-if="docDetailData.l1?.tags && docDetailData.l1.tags.length">
<a-tag v-for="tag in docDetailData.l1.tags" :key="tag">{{ tag }}</a-tag>
</template>
<span v-else class="text-muted">-</span>
</a-descriptions-item>
<a-descriptions-item label="chunks_count">{{ docDetailData.chunks_count ?? 0 }}</a-descriptions-item>
<a-descriptions-item v-if="docDetailData.file" label="原文文件">
<a :href="docDetailData.file.url" target="_blank" rel="noopener">
{{ docDetailData.file.filename }}
<span v-if="docDetailData.file.size_bytes" class="text-muted">
{{ formatSize(docDetailData.file.size_bytes) }}
</span>
</a>
</a-descriptions-item>
</a-descriptions>
<h3 class="library__section-title">L1 全文</h3>
<pre class="library__pre">{{ docDetailData.l1?.text || '' }}</pre>
<h3 class="library__section-title">L2 节点{{ (docDetailData.l2_nodes || []).length }}</h3>
<div v-if="(docDetailData.l2_nodes || []).length === 0" class="text-muted"></div>
<div v-for="(node, idx) in docDetailData.l2_nodes || []" :key="`l2-${idx}`" class="library__node">
<div class="library__node-path">{{ node.section_path || '' }}</div>
<div class="text-break">{{ node.text || '' }}</div>
</div>
<h3 class="library__section-title">L3 节点{{ (docDetailData.l3_nodes || []).length }}</h3>
<div v-if="(docDetailData.l3_nodes || []).length === 0" class="text-muted"></div>
<div v-for="(node, idx) in docDetailData.l3_nodes || []" :key="`l3-${idx}`" class="library__node">
<div class="library__node-path">{{ node.section_path || '' }}</div>
<div class="text-break">{{ node.text || '' }}</div>
</div>
</div>
<div v-else-if="!isLoadingDetail" class="text-muted">暂无数据</div>
</a-spin>
</div>
<!-- 未选中提示 -->
<div v-else class="library__placeholder text-muted">
从左侧选择类目或文档查看详情
</div>
</div>
</div>
</div>
</template>
<style scoped>
.library__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
flex-wrap: wrap;
gap: 8px;
}
.library__title {
font-size: 16px;
margin: 0;
}
.library__actions {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.library__body {
display: flex;
gap: 16px;
min-height: 480px;
}
.library__tree {
width: 320px;
flex: 0 0 auto;
background: #fff;
border: 1px solid #f0f0f0;
border-radius: 8px;
padding: 12px;
max-height: 70vh;
overflow: auto;
}
.library__node-cat {
display: inline-flex;
align-items: center;
gap: 6px;
}
.library__node-count {
font-size: 12px;
}
.library__detail {
flex: 1;
min-width: 0;
}
.library__panel {
background: #fff;
border: 1px solid #f0f0f0;
border-radius: 8px;
padding: 16px;
}
.library__panel-title {
font-size: 15px;
margin: 0 0 8px;
display: flex;
align-items: center;
gap: 6px;
}
.library__placeholder {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
min-height: 300px;
border: 1px dashed #d9d9d9;
border-radius: 8px;
}
.library__detail-actions {
display: flex;
gap: 8px;
}
.library__doc-detail {
min-height: 300px;
}
.library__doc-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.library__doc-title {
margin: 0;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.library__section-title {
font-size: 14px;
margin: 16px 0 8px;
color: #374151;
}
.library__pre {
background: #f9fafb;
border: 1px solid #e5e7eb;
border-radius: 4px;
padding: 8px;
white-space: pre-wrap;
word-break: break-word;
font-size: 12px;
max-height: 260px;
overflow: auto;
margin: 0;
}
.library__node {
border-left: 3px solid #93c5fd;
padding: 6px 10px;
margin-bottom: 6px;
background: #f8fafc;
border-radius: 0 4px 4px 0;
}
.library__node-path {
font-size: 12px;
color: #6b7280;
margin-bottom: 4px;
}
@media (max-width: 768px) {
.library__body {
flex-direction: column;
}
.library__tree {
width: 100%;
}
}
</style>
+348 -246
View File
@@ -1,5 +1,5 @@
<script setup> <script setup>
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, reactive, ref } from 'vue'
import { message } from 'ant-design-vue' import { message } from 'ant-design-vue'
import { import {
DatabaseOutlined, DatabaseOutlined,
@@ -9,103 +9,143 @@ import {
FileTextOutlined, FileTextOutlined,
QuestionCircleOutlined, QuestionCircleOutlined,
ReloadOutlined, ReloadOutlined,
AppstoreOutlined SearchOutlined,
SettingOutlined
} from '@ant-design/icons-vue' } from '@ant-design/icons-vue'
import { stats as fetchStats } from '@/api/knowledge' import { stats as fetchStats } from '@/api/knowledge'
import { search as searchApi } from '@/api/search'
/* ---------- 检索参数(localStorage 持久化) ---------- */
const STORAGE_KEY = 'qdsearch:searchParams'
const searchForm = reactive({
query: '',
top_k: 5,
summarize: false
})
function loadParams() {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return
const p = JSON.parse(raw)
if (typeof p.top_k === 'number' && p.top_k >= 1 && p.top_k <= 50) {
searchForm.top_k = p.top_k
}
if (typeof p.summarize === 'boolean') {
searchForm.summarize = p.summarize
}
} catch {
/* 忽略损坏的本地缓存 */
}
}
function persistParams() {
try {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({ top_k: searchForm.top_k, summarize: searchForm.summarize })
)
} catch {
/* localStorage 不可用时忽略 */
}
}
/* ---------- 检索 ---------- */
const isSearching = ref(false)
const resultData = ref(null)
const searchFormRef = ref(null)
const searchRules = {
query: [{ required: true, message: '请输入查询语句', trigger: 'blur' }]
}
const routedCategoriesText = (cats) => {
if (!cats || !cats.length) return '(无)'
return cats.join(', ')
}
function eiValue(value) {
if (value === undefined || value === null || value === '') return '(无)'
if (Array.isArray(value)) {
return value.length ? value.join(', ') : '(无)'
}
return String(value)
}
const hits = (data) => data?.hits || []
async function handleSearch() {
try {
await searchFormRef.value.validate()
} catch {
return
}
isSearching.value = true
resultData.value = null
try {
resultData.value = await searchApi({
query: searchForm.query,
top_k: searchForm.top_k,
summarize: searchForm.summarize
})
} catch (err) {
message.error(err?.message || '检索失败')
} finally {
isSearching.value = false
}
}
/* ---------- 检索参数设置弹窗 ---------- */
const paramsVisible = ref(false)
const paramsFormRef = ref(null)
const paramsForm = reactive({ top_k: 5, summarize: false })
const paramsRules = {
top_k: [{ type: 'number', min: 1, max: 50, message: 'top_k 范围 1~50', trigger: 'change' }]
}
function openParams() {
paramsForm.top_k = searchForm.top_k
paramsForm.summarize = searchForm.summarize
paramsVisible.value = true
}
async function saveParams() {
try {
await paramsFormRef.value.validate()
} catch {
return
}
searchForm.top_k = paramsForm.top_k
searchForm.summarize = paramsForm.summarize
persistParams()
paramsVisible.value = false
message.success('检索参数已保存')
}
/* ---------- 概览统计 ---------- */
const isLoading = ref(false) const isLoading = ref(false)
const statsData = ref(null) const statsData = ref(null)
const cardMeta = [ const cardMeta = [
{ { key: 'doc_l1', label: 'L1 文档总结', icon: DatabaseOutlined, color: '#1677ff' },
key: 'doc_l1', { key: 'doc_l2', label: 'L2 大纲节点', icon: ApartmentOutlined, color: '#722ed1' },
label: 'L1 文档总结', { key: 'doc_l3', label: 'L3 内容大纲', icon: FileSearchOutlined, color: '#13c2c2' },
icon: DatabaseOutlined, { key: 'chunks', label: 'Chunks', icon: BlockOutlined, color: '#fa8c16' },
color: '#1677ff', { key: '__documents_total', label: '文档总数', icon: FileTextOutlined, color: '#52c41a' },
bg: 'rgba(22, 119, 255, 0.12)' { key: '__uncategorized', label: '未分类文档', icon: QuestionCircleOutlined, color: '#ff4d4f' }
},
{
key: 'doc_l2',
label: 'L2 大纲节点',
icon: ApartmentOutlined,
color: '#722ed1',
bg: 'rgba(114, 46, 209, 0.12)'
},
{
key: 'doc_l3',
label: 'L3 内容大纲',
icon: FileSearchOutlined,
color: '#13c2c2',
bg: 'rgba(19, 194, 194, 0.12)'
},
{
key: 'chunks',
label: 'Chunks',
icon: BlockOutlined,
color: '#fa8c16',
bg: 'rgba(250, 140, 22, 0.12)'
},
{
key: '__documents_total',
label: '文档总数',
icon: FileTextOutlined,
color: '#52c41a',
bg: 'rgba(82, 196, 26, 0.12)'
},
{
key: '__uncategorized',
label: '未分类文档',
icon: QuestionCircleOutlined,
color: '#ff4d4f',
bg: 'rgba(255, 77, 79, 0.12)'
}
] ]
const cards = computed(() => { const cards = computed(() => {
const collections = statsData.value?.collections || {} const collections = statsData.value?.collections || {}
return cardMeta.map((m) => { return cardMeta.map((m) => {
let value = 0 let value = 0
if (m.key === '__documents_total') { if (m.key === '__documents_total') value = statsData.value?.documents_total ?? 0
value = statsData.value?.documents_total ?? 0 else if (m.key === '__uncategorized') value = statsData.value?.uncategorized_count ?? 0
} else if (m.key === '__uncategorized') { else value = collections[m.key] ?? 0
value = statsData.value?.uncategorized_count ?? 0
} else {
value = collections[m.key] ?? 0
}
return { ...m, value } return { ...m, value }
}) })
}) })
const categoryBars = computed(() => {
const categories = statsData.value?.categories || {}
const entries = Object.entries(categories).map(([name, count]) => ({ name, count }))
entries.sort((a, b) => b.count - a.count)
const max = entries.reduce((acc, item) => Math.max(acc, item.count), 1)
const total = entries.reduce((acc, item) => acc + item.count, 0)
return entries.map((item) => ({
...item,
percent: Math.round((item.count / max) * 100),
ratio: total ? Math.round((item.count / total) * 100) : 0
}))
})
const gradientColors = [
'linear-gradient(90deg, #1677ff 0%, #4096ff 100%)',
'linear-gradient(90deg, #722ed1 0%, #9254de 100%)',
'linear-gradient(90deg, #13c2c2 0%, #36cfc9 100%)',
'linear-gradient(90deg, #fa8c16 0%, #ffa940 100%)',
'linear-gradient(90deg, #52c41a 0%, #73d13d 100%)',
'linear-gradient(90deg, #eb2f96 0%, #f759ab 100%)',
'linear-gradient(90deg, #fa541c 0%, #ff7a45 100%)',
'linear-gradient(90deg, #2f54eb 0%, #597ef7 100%)',
'linear-gradient(90deg, #08979c 0%, #13c2c2 100%)',
'linear-gradient(90deg, #c41d7f 0%, #eb2f96 100%)'
]
function barGradient(index) {
return gradientColors[index % gradientColors.length]
}
async function loadStats() { async function loadStats() {
isLoading.value = true isLoading.value = true
try { try {
@@ -118,6 +158,7 @@ async function loadStats() {
} }
onMounted(() => { onMounted(() => {
loadParams()
loadStats() loadStats()
}) })
</script> </script>
@@ -126,69 +167,135 @@ onMounted(() => {
<div class="overview page-section"> <div class="overview page-section">
<div class="overview__header"> <div class="overview__header">
<h2 class="page-title">概览</h2> <h2 class="page-title">概览</h2>
<a-space> <a-button :loading="isLoading" @click="loadStats">
<a-button :loading="isLoading" @click="loadStats"> <template #icon><ReloadOutlined /></template>
<template #icon><ReloadOutlined /></template> 刷新
刷新 </a-button>
</a-button>
</a-space>
</div> </div>
<!-- 概览统计Statistic 样式位于检索框上方 -->
<a-spin :spinning="isLoading"> <a-spin :spinning="isLoading">
<div class="overview__cards"> <div class="overview__stats">
<div <a-card
v-for="card in cards" v-for="card in cards"
:key="card.key" :key="card.key"
class="overview__card" class="overview__stat-card"
:bordered="false"
> >
<div class="overview__card-body"> <a-statistic
<div :title="card.label"
class="overview__card-icon" :value="card.value"
:style="{ background: card.bg, color: card.color }" :value-style="{ color: card.color }"
>
<component :is="card.icon" />
</div>
<div class="overview__card-info">
<div class="overview__card-num">{{ card.value }}</div>
<div class="overview__card-label">{{ card.label }}</div>
</div>
</div>
</div>
</div>
<div class="overview__section">
<div class="overview__section-head">
<h3 class="overview__subtitle">
<AppstoreOutlined />
<span>类目分布</span>
</h3>
<span v-if="categoryBars.length" class="text-muted overview__section-meta">
{{ categoryBars.length }} 个类目
</span>
</div>
<div v-if="categoryBars.length === 0" class="overview__empty text-muted">
暂无数据
</div>
<div v-else class="overview__bars">
<div
v-for="(bar, idx) in categoryBars"
:key="bar.name"
class="overview__bar-row"
:title="`${bar.name}: ${bar.count} (${bar.ratio}%)`"
> >
<span class="overview__bar-name" :title="bar.name">{{ bar.name }}</span> <template #prefix>
<div class="overview__bar-track"> <component :is="card.icon" />
<div </template>
class="overview__bar-fill" </a-statistic>
:style="{ width: bar.percent + '%', background: barGradient(idx) }" </a-card>
/>
</div>
<span class="overview__bar-count">{{ bar.count }}</span>
<span class="overview__bar-ratio">{{ bar.ratio }}%</span>
</div>
</div>
</div> </div>
</a-spin> </a-spin>
<!-- 检索区居中放大 -->
<div class="overview__search-well">
<div class="overview__search-bar">
<a-form ref="searchFormRef" :model="searchForm" :rules="searchRules" class="overview__search-form">
<a-form-item name="query" class="overview__search-form-item">
<a-input
v-model:value="searchForm.query"
size="large"
class="overview__search-input"
placeholder="在知识库中检索,输入查询语句后按回车"
allow-clear
@pressEnter="handleSearch"
>
<template #prefix><SearchOutlined /></template>
</a-input>
</a-form-item>
</a-form>
<a-button type="primary" size="large" :loading="isSearching" @click="handleSearch">
检索
</a-button>
</div>
<div class="overview__search-params">
<a-button size="small" type="text" @click="openParams">
<template #icon><SettingOutlined /></template>
参数设置
</a-button>
<span class="text-muted">
top_k = {{ searchForm.top_k }}AI 总结{{ searchForm.summarize ? '开' : '关' }}
</span>
</div>
</div>
<!-- 检索结果 -->
<div v-if="resultData" class="overview__result">
<a-descriptions :column="1" size="small" bordered>
<a-descriptions-item label="routed_categories">
{{ routedCategoriesText(resultData.routed_categories) }}
</a-descriptions-item>
</a-descriptions>
<a-alert
v-if="resultData.fallback"
class="overview__alert"
type="warning"
show-icon
message="fallback:本次检索触发了回退策略(路由类目无命中,已降级全局检索)"
/>
<div v-if="resultData.summary" class="overview__summary">
<div class="overview__summary-title">AI 总结</div>
<div class="text-break">{{ resultData.summary }}</div>
</div>
<div v-if="resultData.extracted_info" class="overview__extracted">
<div class="overview__extracted-title">AI 提取的关键信息</div>
<a-descriptions :column="1" size="small" bordered>
<a-descriptions-item label="改写">{{ eiValue(resultData.extracted_info.rewrite) }}</a-descriptions-item>
<a-descriptions-item label="关键词">{{ eiValue(resultData.extracted_info.keywords) }}</a-descriptions-item>
<a-descriptions-item label="实体">{{ eiValue(resultData.extracted_info.entities) }}</a-descriptions-item>
<a-descriptions-item label="意图">{{ eiValue(resultData.extracted_info.intent) }}</a-descriptions-item>
<a-descriptions-item label="时间范围">{{ eiValue(resultData.extracted_info.time_range) }}</a-descriptions-item>
<a-descriptions-item label="命中类目">{{ eiValue(resultData.extracted_info.categories) }}</a-descriptions-item>
</a-descriptions>
</div>
<div class="text-muted" style="margin: 12px 0 8px">
命中 {{ hits(resultData).length }}
</div>
<div
v-for="(hit, idx) in hits(resultData)"
:key="`hit-${idx}`"
class="overview__hit"
>
<div class="overview__hit-meta">
score={{ hit.score }} | doc_id={{ hit.doc_id }} | 标题={{ hit.title || '' }} | section={{ hit.section_path || '' }}
</div>
<div class="overview__hit-snippet text-break">{{ hit.text || '' }}</div>
<div v-if="hit.doc_summary" class="overview__hit-meta">
文档摘要{{ hit.doc_summary }}
</div>
</div>
</div>
<!-- 检索参数设置弹窗 -->
<a-modal
v-model:open="paramsVisible"
title="检索参数设置"
ok-text="保存"
cancel-text="取消"
@ok="saveParams"
>
<a-form ref="paramsFormRef" :model="paramsForm" :rules="paramsRules" layout="vertical">
<a-form-item label="Top-K(返回条数)" name="top_k">
<a-input-number v-model:value="paramsForm.top_k" :min="1" :max="50" style="width: 100%" />
</a-form-item>
<a-form-item label="AI 总结" name="summarize">
<a-switch v-model:checked="paramsForm.summarize" />
</a-form-item>
</a-form>
</a-modal>
</div> </div>
</template> </template>
@@ -200,150 +307,145 @@ onMounted(() => {
margin-bottom: 16px; margin-bottom: 16px;
} }
.overview__cards { /* 概览统计(Statistic 卡片,单行自适应 + 无滚动条横向滚动) */
display: grid; .overview__stats {
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); display: flex;
flex-wrap: nowrap;
gap: 16px; gap: 16px;
margin-bottom: 24px; margin-bottom: 28px;
overflow-x: auto;
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE/Edge */
} }
.overview__card { .overview__stats::-webkit-scrollbar {
display: none;
}
.overview__stat-card {
flex: 1 1 0;
min-width: 160px;
background: #fff; background: #fff;
border: 1px solid #f0f0f0; border: 1px solid #f0f0f0;
border-radius: 8px; border-radius: 8px;
padding: 16px 18px;
transition: all 0.2s;
box-shadow: 0 1px 2px rgba(0, 21, 41, 0.04); box-shadow: 0 1px 2px rgba(0, 21, 41, 0.04);
} }
.overview__card:hover { .overview__stat-card:hover {
box-shadow: 0 4px 12px rgba(0, 21, 41, 0.08); box-shadow: 0 4px 12px rgba(0, 21, 41, 0.08);
transform: translateY(-2px); transform: translateY(-2px);
} }
.overview__card-body { /* 检索区(居中放大) */
display: flex; .overview__search-well {
align-items: center; background: linear-gradient(135deg, #f0f7ff 0%, #eef2ff 100%);
gap: 14px; border: 1px solid #e0e7ff;
border-radius: 12px;
padding: 36px 24px;
text-align: center;
margin-bottom: 24px;
} }
.overview__card-icon { .overview__search-bar {
display: inline-flex; display: flex;
align-items: stretch;
justify-content: center;
gap: 12px;
max-width: 760px;
margin: 0 auto;
}
.overview__search-form {
flex: 1;
min-width: 0;
display: flex;
align-items: stretch;
}
.overview__search-form-item {
flex: 1;
margin-bottom: 0;
}
.overview__search-form-item .ant-form-item-control {
height: 100%;
}
.overview__search-input {
font-size: 16px;
height: 40px;
}
.overview__search-params {
display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 44px; gap: 12px;
height: 44px; margin-top: 12px;
border-radius: 8px;
font-size: 22px;
flex: 0 0 auto;
}
.overview__card-info {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.overview__card-num {
font-size: 26px;
font-weight: 700;
color: #1f2937;
line-height: 1.1;
}
.overview__card-label {
font-size: 12px;
color: #6b7280;
}
.overview__section {
margin-top: 8px;
}
.overview__section-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.overview__subtitle {
font-size: 14px;
font-weight: 600;
color: #374151;
margin: 0;
display: flex;
align-items: center;
gap: 6px;
}
.overview__section-meta {
font-size: 12px;
}
.overview__empty {
text-align: center;
padding: 32px 0;
}
.overview__bars {
display: flex;
flex-direction: column;
gap: 8px;
}
.overview__bar-row {
display: flex;
align-items: center;
font-size: 13px; font-size: 13px;
cursor: default;
} }
.overview__bar-name { .overview__result {
width: 180px; margin-top: 16px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: #374151;
flex: 0 0 auto;
} }
.overview__bar-track { .overview__alert {
flex: 1; margin: 12px 0;
background: #f3f4f6;
border-radius: 4px;
height: 18px;
margin: 0 10px;
overflow: hidden;
} }
.overview__bar-fill { .overview__summary {
height: 100%; background: #f0fdf4;
border-radius: 4px; border: 1px solid #bbf7d0;
min-width: 2px; border-radius: 6px;
transition: width 0.4s ease; padding: 12px 14px;
margin: 12px 0;
font-size: 13px;
} }
.overview__bar-count { .overview__summary-title {
width: 48px;
text-align: right;
color: #1f2937;
font-weight: 600; font-weight: 600;
flex: 0 0 auto; margin-bottom: 6px;
color: #15803d;
} }
.overview__bar-ratio { .overview__extracted {
width: 48px; background: #f0f9ff;
text-align: right; border: 1px solid #bae6fd;
color: #6b7280; border-radius: 6px;
padding: 12px 14px;
margin: 12px 0;
font-size: 13px;
}
.overview__extracted-title {
font-weight: 600;
margin-bottom: 8px;
color: #0369a1;
}
.overview__hit {
border: 1px solid #e5e7eb;
border-radius: 6px;
padding: 10px 12px;
margin-bottom: 10px;
background: #fff;
}
.overview__hit-meta {
font-size: 12px; font-size: 12px;
flex: 0 0 auto; color: #6b7280;
margin-bottom: 6px;
word-break: break-all;
}
.overview__hit-snippet {
font-size: 13px;
color: #1f2937;
} }
@media (max-width: 768px) { @media (max-width: 768px) {
.overview__bar-name { .overview__search-bar {
width: 120px; flex-direction: column;
} }
} }
</style> </style>
-234
View File
@@ -1,234 +0,0 @@
<script setup>
import { reactive, ref } from 'vue'
import { message } from 'ant-design-vue'
import { search as searchApi } from '@/api/search'
const isLoading = ref(false)
const resultData = ref(null)
const formRef = ref(null)
const formState = reactive({
query: '',
top_k: 5,
summarize: false
})
const rules = {
query: [{ required: true, message: '请输入查询语句', trigger: 'blur' }],
top_k: [{ type: 'number', min: 1, max: 50, message: 'top_k 范围 1~50', trigger: 'change' }]
}
const routedCategoriesText = (cats) => {
if (!cats || !cats.length) return '(无)'
return cats.join(', ')
}
async function handleSearch() {
try {
await formRef.value.validate()
} catch {
return
}
isLoading.value = true
resultData.value = null
try {
const payload = {
query: formState.query,
top_k: formState.top_k,
summarize: formState.summarize
}
resultData.value = await searchApi(payload)
} catch (err) {
message.error(err?.message || '检索失败')
} finally {
isLoading.value = false
}
}
function eiValue(value) {
if (value === undefined || value === null || value === '') return '(无)'
if (Array.isArray(value)) {
return value.length ? value.join(', ') : '(无)'
}
return String(value)
}
const hits = (data) => data?.hits || []
</script>
<template>
<div class="search page-section">
<div class="search__header">
<h2 class="search__title">检索测试台</h2>
</div>
<a-form
ref="formRef"
:model="formState"
:rules="rules"
layout="vertical"
>
<a-form-item label="查询语句" name="query">
<a-input
v-model:value="formState.query"
placeholder="请输入查询语句"
allow-clear
@pressEnter="handleSearch"
/>
</a-form-item>
<a-form-item label="top_k" name="top_k">
<a-input-number
v-model:value="formState.top_k"
:min="1"
:max="50"
style="width: 160px"
/>
</a-form-item>
<a-form-item name="summarize">
<a-checkbox v-model:checked="formState.summarize">
对结果生成 AI 总结
</a-checkbox>
</a-form-item>
<a-form-item>
<a-button type="primary" :loading="isLoading" @click="handleSearch">
检索
</a-button>
</a-form-item>
</a-form>
<div v-if="resultData" class="search__result">
<a-descriptions :column="1" size="small" bordered>
<a-descriptions-item label="routed_categories">
{{ routedCategoriesText(resultData.routed_categories) }}
</a-descriptions-item>
</a-descriptions>
<a-alert
v-if="resultData.fallback"
class="search__alert"
type="warning"
show-icon
message="fallback:本次检索触发了回退策略(路由类目无命中,已降级全局检索)"
/>
<div v-if="resultData.summary" class="search__summary">
<div class="search__summary-title">AI 总结</div>
<div class="text-break">{{ resultData.summary }}</div>
</div>
<div v-if="resultData.extracted_info" class="search__extracted">
<div class="search__extracted-title">AI 提取的关键信息</div>
<a-descriptions :column="1" size="small" bordered>
<a-descriptions-item label="改写">
{{ eiValue(resultData.extracted_info.rewrite) }}
</a-descriptions-item>
<a-descriptions-item label="关键词">
{{ eiValue(resultData.extracted_info.keywords) }}
</a-descriptions-item>
<a-descriptions-item label="实体">
{{ eiValue(resultData.extracted_info.entities) }}
</a-descriptions-item>
<a-descriptions-item label="意图">
{{ eiValue(resultData.extracted_info.intent) }}
</a-descriptions-item>
<a-descriptions-item label="时间范围">
{{ eiValue(resultData.extracted_info.time_range) }}
</a-descriptions-item>
<a-descriptions-item label="命中类目">
{{ eiValue(resultData.extracted_info.categories) }}
</a-descriptions-item>
</a-descriptions>
</div>
<div class="text-muted" style="margin: 12px 0 8px">
命中 {{ hits(resultData).length }}
</div>
<div
v-for="(hit, idx) in hits(resultData)"
:key="`hit-${idx}`"
class="search__hit"
>
<div class="search__hit-meta">
score={{ hit.score }} | doc_id={{ hit.doc_id }} | 标题={{ hit.title || '' }} | section={{ hit.section_path || '' }}
</div>
<div class="search__hit-snippet text-break">{{ hit.text || '' }}</div>
<div v-if="hit.doc_summary" class="search__hit-meta">
文档摘要{{ hit.doc_summary }}
</div>
</div>
</div>
</div>
</template>
<style scoped>
.search__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.search__title {
font-size: 16px;
margin: 0;
}
.search__result {
margin-top: 16px;
}
.search__alert {
margin: 12px 0;
}
.search__summary {
background: #f0fdf4;
border: 1px solid #bbf7d0;
border-radius: 6px;
padding: 12px 14px;
margin: 12px 0;
font-size: 13px;
}
.search__summary-title {
font-weight: 600;
margin-bottom: 6px;
color: #15803d;
}
.search__extracted {
background: #f0f9ff;
border: 1px solid #bae6fd;
border-radius: 6px;
padding: 12px 14px;
margin: 12px 0;
font-size: 13px;
}
.search__extracted-title {
font-weight: 600;
margin-bottom: 8px;
color: #0369a1;
}
.search__hit {
border: 1px solid #e5e7eb;
border-radius: 6px;
padding: 10px 12px;
margin-bottom: 10px;
background: #fff;
}
.search__hit-meta {
font-size: 12px;
color: #6b7280;
margin-bottom: 6px;
word-break: break-all;
}
.search__hit-snippet {
font-size: 13px;
color: #1f2937;
}
</style>
+1 -1
View File
@@ -318,7 +318,7 @@ onMounted(() => {
<a-form-item label="model"> <a-form-item label="model">
<a-input <a-input
v-model:value="form.models[key].model" v-model:value="form.models[key].model"
placeholder="例如 qwen2.5:1.5b" placeholder="例如 qwen3:1.7b"
allow-clear allow-clear
/> />
</a-form-item> </a-form-item>
+394
View File
@@ -0,0 +1,394 @@
<script setup>
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { message, Modal } from 'ant-design-vue'
import {
ReloadOutlined,
ClockCircleOutlined,
EyeOutlined,
ArrowLeftOutlined,
UndoOutlined,
DeleteOutlined
} from '@ant-design/icons-vue'
import {
taskList,
taskStatus,
retryTask,
deleteTask
} from '@/api/documents'
import {
INGEST_STATUS_TEXT,
INGEST_STATUS_COLOR
} from '@/constants/ingest'
import { useTasksDrawer } from '@/composables/useTasksDrawer'
import { formatSize } from '@/utils/format'
const { state, close } = useTasksDrawer()
const tasks = ref([])
const total = ref(0)
const isLoading = ref(false)
const isPolling = ref(false)
/* 选中任务详情 */
const selectedTask = ref(null)
const isLoadingDetail = ref(false)
const isRetrying = ref(false)
const isDeleting = ref(false)
let timer = null
function formatTime(iso) {
if (!iso) return '-'
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return iso
const pad = (n) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
}
function statusText(s) {
return INGEST_STATUS_TEXT[s] || s || '-'
}
function statusColor(s) {
return INGEST_STATUS_COLOR[s] || 'default'
}
const isTerminal = computed(() => ['done', 'failed'].includes(selectedTask.value?.status))
const detailResult = computed(() => selectedTask.value?.result || null)
const detailError = computed(() => selectedTask.value?.error || null)
const detailSummary = computed(() => detailResult.value?.summary || {})
const canRetry = computed(() => ['failed', 'done'].includes(selectedTask.value?.status))
const canDelete = computed(() => ['failed', 'done'].includes(selectedTask.value?.status))
/* 从任务记录提取详情展示字段(title/source/size */
const detailComputed = computed(() => {
const task = selectedTask.value
if (!task) return null
const source = task.source || {}
const metadata = source.metadata || {}
return {
title: task.title || source.title || '-',
source: task.source_text || source.source || '-',
size: task.size_bytes != null ? formatSize(task.size_bytes) : formatSize(metadata.original_size_bytes)
}
})
async function loadTasks(showSpinner = false) {
if (showSpinner) isLoading.value = true
try {
const data = await taskList(50)
tasks.value = data.items || []
total.value = data.total ?? tasks.value.length
} catch (err) {
message.error(err?.message || '加载入库任务失败')
} finally {
if (showSpinner) isLoading.value = false
}
}
function startPolling() {
if (timer) return
isPolling.value = true
timer = setInterval(async () => {
await loadTasks(false)
}, 2000)
}
function stopPolling() {
if (timer) {
clearInterval(timer)
timer = null
}
isPolling.value = false
}
function togglePolling() {
if (isPolling.value) stopPolling()
else startPolling()
}
/* 打开/关闭抽屉时启停轮询 */
watch(
() => state.open,
(open) => {
if (open) {
loadTasks(true)
startPolling()
} else {
stopPolling()
selectedTask.value = null
}
},
{ immediate: true }
)
async function openDetail(task) {
selectedTask.value = null
isLoadingDetail.value = true
try {
selectedTask.value = await taskStatus(task.task_id)
} catch (err) {
message.error(err?.message || '加载任务详情失败')
} finally {
isLoadingDetail.value = false
}
}
function backToList() {
selectedTask.value = null
}
async function handleRetry(task) {
const taskId = task?.task_id || selectedTask.value?.task_id
if (!taskId) return
isRetrying.value = true
try {
const data = await retryTask(taskId)
message.success(`已提交新任务:${data.task_id}`)
selectedTask.value = null
await loadTasks(false)
} catch (err) {
message.error(err?.message || '重试失败')
} finally {
isRetrying.value = false
}
}
function handleDelete(task) {
const taskId = task?.task_id || selectedTask.value?.task_id
if (!taskId) return
Modal.confirm({
title: '删除入库任务',
content: `确定删除任务「${taskId}」吗?删除后不可恢复。`,
okText: '删除',
okType: 'danger',
cancelText: '取消',
async onOk() {
isDeleting.value = true
try {
await deleteTask(taskId)
message.success('任务已删除')
selectedTask.value = null
await loadTasks(false)
} catch (err) {
message.error(err?.message || '删除任务失败')
} finally {
isDeleting.value = false
}
}
})
}
onBeforeUnmount(stopPolling)
const columns = [
{ title: '状态', dataIndex: 'status', key: 'status', width: 90 },
{ title: '文件名', dataIndex: 'filename', key: 'filename', ellipsis: true },
{ title: 'task_id', dataIndex: 'task_id', key: 'task_id', width: 200 },
{ title: 'doc_id', dataIndex: 'doc_id', key: 'doc_id', width: 200 },
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at', width: 160 },
{ title: '操作', dataIndex: 'action', key: 'action', width: 130 }
]
</script>
<template>
<a-drawer
:open="state.open"
:title="selectedTask ? '任务详情' : '入库进度'"
placement="right"
width="640"
:destroy-on-close="true"
@close="close"
>
<!-- 列表视图 -->
<template v-if="!selectedTask">
<div class="tasks-drawer__header">
<span class="text-muted"> {{ total }} </span>
<div class="tasks-drawer__actions">
<a-button size="small" :loading="isLoading" @click="loadTasks(true)">
<template #icon><ReloadOutlined /></template>
刷新
</a-button>
<a-button size="small" :type="isPolling ? 'default' : 'primary'" @click="togglePolling">
{{ isPolling ? '停止轮询' : '开始轮询' }}
</a-button>
</div>
</div>
<a-alert
v-if="isPolling"
type="info"
show-icon
message="每 2 秒自动刷新近期入库任务。"
style="margin-bottom: 12px"
/>
<a-table
:columns="columns"
:data-source="tasks"
:pagination="false"
:loading="isLoading"
row-key="task_id"
size="small"
:scroll="{ x: 720 }"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'status'">
<a-tag :color="statusColor(record.status)">{{ statusText(record.status) }}</a-tag>
</template>
<template v-else-if="column.key === 'filename'">
<span :title="record.filename">{{ record.filename || '-' }}</span>
</template>
<template v-else-if="column.key === 'task_id'">
<a-typography-text copyable :style="{ fontSize: '12px' }">{{ record.task_id }}</a-typography-text>
</template>
<template v-else-if="column.key === 'doc_id'">
<a-typography-text
v-if="record.doc_id"
copyable
:style="{ fontSize: '12px' }"
>
{{ record.doc_id }}
</a-typography-text>
<span v-else class="text-muted">-</span>
</template>
<template v-else-if="column.key === 'created_at'">
{{ formatTime(record.created_at) }}
</template>
<template v-else-if="column.key === 'action'">
<a-button type="link" size="small" @click="openDetail(record)">
<template #icon><EyeOutlined /></template>
详情
</a-button>
<a-button
v-if="['failed', 'done'].includes(record.status)"
type="link"
size="small"
@click="handleRetry(record)"
>
<template #icon><UndoOutlined /></template>
重试
</a-button>
<a-button
v-if="['done', 'failed'].includes(record.status)"
type="link"
size="small"
danger
@click="handleDelete(record)"
>
<template #icon><DeleteOutlined /></template>
删除
</a-button>
</template>
</template>
</a-table>
</template>
<!-- 详情视图 -->
<template v-else>
<a-button size="small" type="link" style="margin-bottom: 12px; padding-left: 0" @click="backToList">
<template #icon><ArrowLeftOutlined /></template>
返回列表
</a-button>
<a-spin :spinning="isLoadingDetail">
<template v-if="selectedTask">
<a-descriptions :column="1" size="small" bordered>
<a-descriptions-item label="状态">
<a-tag :color="statusColor(selectedTask.status)">{{ statusText(selectedTask.status) }}</a-tag>
</a-descriptions-item>
<a-descriptions-item label="task_id">{{ selectedTask.task_id || '-' }}</a-descriptions-item>
<a-descriptions-item label="文件名">{{ selectedTask.filename || '-' }}</a-descriptions-item>
<a-descriptions-item label="标题">{{ detailComputed?.title || '-' }}</a-descriptions-item>
<a-descriptions-item label="来源">{{ detailComputed?.source || '-' }}</a-descriptions-item>
<a-descriptions-item label="文件大小">{{ detailComputed?.size || '-' }}</a-descriptions-item>
<a-descriptions-item label="doc_id">{{ selectedTask.doc_id || '-' }}</a-descriptions-item>
<a-descriptions-item label="创建时间">{{ formatTime(selectedTask.created_at) }}</a-descriptions-item>
<a-descriptions-item label="更新时间">{{ formatTime(selectedTask.updated_at) }}</a-descriptions-item>
</a-descriptions>
<!-- 完成详情 -->
<div v-if="isTerminal && selectedTask.status === 'done' && detailResult" class="tasks-drawer__block">
<h4 class="tasks-drawer__block-title">入库结果</h4>
<a-descriptions :column="1" size="small" bordered>
<a-descriptions-item label="document_id">{{ detailResult.document_id || '-' }}</a-descriptions-item>
<a-descriptions-item label="类目">{{ detailResult.category || '-' }}置信度 {{ detailResult.category_confidence ?? '-' }}</a-descriptions-item>
<a-descriptions-item label="标签">
<template v-if="detailResult.tags && detailResult.tags.length">
<a-tag v-for="tag in detailResult.tags" :key="tag">{{ tag }}</a-tag>
</template>
<span v-else class="text-muted">-</span>
</a-descriptions-item>
<a-descriptions-item label="总结层级">{{ detailSummary.level ?? '-' }}</a-descriptions-item>
<a-descriptions-item label="chunks_count">{{ detailResult.chunks_count ?? 0 }}</a-descriptions-item>
<a-descriptions-item v-if="detailResult.deduplicated" label="去重">复用既有文档</a-descriptions-item>
</a-descriptions>
<h4 class="tasks-drawer__block-title">L1 总结</h4>
<pre class="tasks-drawer__pre">{{ detailSummary.l1_summary || '' }}</pre>
</div>
<!-- 失败详情 -->
<div v-if="isTerminal && selectedTask.status === 'failed' && detailError" class="tasks-drawer__block">
<a-alert
type="error"
show-icon
:message="`失败阶段:${detailError.stage || '-'}`"
:description="detailError.message || '-'"
/>
<template v-if="detailError.partial_summary && detailError.partial_summary.l1_summary">
<h4 class="tasks-drawer__block-title">L1 总结部分</h4>
<pre class="tasks-drawer__pre">{{ detailError.partial_summary.l1_summary }}</pre>
</template>
</div>
<div class="tasks-drawer__actions">
<a-button v-if="canRetry" :loading="isRetrying" @click="handleRetry(selectedTask)">
<template #icon><UndoOutlined /></template>
重试
</a-button>
<a-button v-if="canDelete" danger :loading="isDeleting" @click="handleDelete(selectedTask)">
<template #icon><DeleteOutlined /></template>
删除
</a-button>
</div>
</template>
</a-spin>
</template>
</a-drawer>
</template>
<style scoped>
.tasks-drawer__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.tasks-drawer__actions {
display: flex;
align-items: center;
gap: 8px;
}
.tasks-drawer__block {
margin-top: 16px;
}
.tasks-drawer__block-title {
font-size: 13px;
margin: 12px 0 6px;
color: #374151;
}
.tasks-drawer__pre {
background: #f9fafb;
border: 1px solid #e5e7eb;
border-radius: 4px;
padding: 8px;
white-space: pre-wrap;
word-break: break-word;
font-size: 12px;
max-height: 220px;
overflow: auto;
margin: 0;
}
</style>
+5
View File
@@ -15,6 +15,11 @@ dependencies = [
"openai>=1.58.0", "openai>=1.58.0",
"pyjwt>=2.13.0", "pyjwt>=2.13.0",
"bcrypt>=5.0.0", "bcrypt>=5.0.0",
"python-multipart>=0.0.20",
"pypdf>=5.1.0",
"python-docx>=1.1.2",
"pypdfium2>=4.0.0", # PDF 渲染为图片供 OCR 使用
"rapidocr-onnxruntime>=1.3.8", # 扫描件 PDF OCR 降级(首次调用时下载约 25MB 模型)
] ]
[project.optional-dependencies] [project.optional-dependencies]
+144
View File
@@ -0,0 +1,144 @@
#!/usr/bin/env bash
# =============================================================================
# QMDSearch 自动部署脚本(本地 CI/CD 入口)
#
# 设计原则(贴合 NAS 自托管 + 挂载式部署):
# 1. 检测变化 —— 对比 origin/main,区分 backend / frontend / deps 范围
# 2. 校验 —— 本地跑 pytest(CI 门禁),坏代码绝不上线
# 3. 按需构建 —— 仅前端变化时本地预构建(dist 被 gitignoreNAS 端会再构建)
# 4. 提交推送 —— git commit + push origin main,推送后 SSH 到 NAS 执行真正部署
#
# 用法:
# ./scripts/deploy.sh 正常流程:检测 → 校验 → 按需构建 → 提交推送 → SSH 到 NAS 部署
# ./scripts/deploy.sh --dry 零副作用自测:只 检测/校验/构建,不提交不推送
# ./scripts/deploy.sh --no-push 提交但不推送(准备好后手动 push 或再跑一次)
# ./scripts/deploy.sh --no-ssh 推送后不自动 SSH 部署(仅同步代码到 NAS git)
# ./scripts/deploy.sh --skip-tests 跳过 pytest(谨慎)
# ./scripts/deploy.sh --skip-fe 跳过前端构建(即便前端变化)
# ./scripts/deploy.sh --build-fe 强制重新构建前端
# ./scripts/deploy.sh --help
# =============================================================================
set -euo pipefail
REMOTE="origin"
BRANCH="main"
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT"
NO_PUSH=0
DRY=0
NO_SSH=0
SKIP_TESTS=0
SKIP_FE=0
FORCE_FE=0
while [[ $# -gt 0 ]]; do
case "$1" in
--no-push) NO_PUSH=1 ;;
--dry) DRY=1 ;;
--no-ssh) NO_SSH=1 ;;
--skip-tests) SKIP_TESTS=1 ;;
--skip-fe) SKIP_FE=1 ;;
--build-fe) FORCE_FE=1 ;;
--help|-h) sed -n '1,22p' "$0"; exit 0 ;;
*) echo "未知参数: $1"; exit 2 ;;
esac
shift
done
log(){ printf '\033[36m[deploy]\033[0m %s\n' "$*"; }
ok(){ printf '\033[32m[ ok ]\033[0m %s\n' "$*"; }
err(){ printf '\033[31m[err ]\033[0m %s\n' "$*" >&2; }
# ---- 1. 变化检测 --------------------------------------------------------------
log "同步远端引用..."
git fetch --quiet "$REMOTE" "$BRANCH" 2>/dev/null || log "fetch 失败(离线?继续本地流程)"
LOCAL_CHANGES="$(git status --porcelain)"
AHEAD="$(git rev-list --count "$REMOTE/$BRANCH..HEAD" 2>/dev/null || echo 0)"
# 汇总「已提交未推送」+「未提交」的变更文件,去重
CHANGED_FILES="$(
{ git diff --name-only "$REMOTE/$BRANCH" HEAD 2>/dev/null; echo "$LOCAL_CHANGES" | awk '{print $2}'; } \
| grep -v '^$' | sort -u
)"
has(){ echo "$CHANGED_FILES" | grep -qE "$1"; }
BACKEND_CHANGED=0; FRONTEND_CHANGED=0; DEPS_CHANGED=0
if has '^(app/|scripts/|tests/|.*\.py$|Dockerfile|docker-compose\.yml|\.env\.example)'; then BACKEND_CHANGED=1; fi
if has '^frontend/(src/|public/|index\.html|package\.json|vite\.config|package-lock\.json)'; then FRONTEND_CHANGED=1; fi
if has '^(pyproject\.toml|uv\.lock|Dockerfile)'; then DEPS_CHANGED=1; fi
if [[ -z "$CHANGED_FILES" && "$AHEAD" -eq 0 ]]; then
ok "无代码变化,无需部署。"
exit 0
fi
log "变更范围 → backend=$BACKEND_CHANGED frontend=$FRONTEND_CHANGED deps=$DEPS_CHANGED (ahead=$AHEAD)"
# ---- 2. 校验(本地 CI gate---------------------------------------------------
if [[ "$SKIP_TESTS" -eq 0 && "$BACKEND_CHANGED" -eq 1 ]]; then
log "运行后端测试 (uv run pytest)..."
if ! uv run pytest -q --no-header 2>&1 | tail -n 30; then
err "测试失败,已中止部署。请修复后再运行 deploy.sh"
exit 1
fi
ok "后端测试通过"
# 顺带做一次非阻断的静态检查
if command -v uv >/dev/null 2>&1; then
log "运行 ruff 静态检查(不阻断)..."
uv run ruff check app tests 2>&1 | tail -n 15 || true
fi
fi
# ---- 3. 按需构建前端 ----------------------------------------------------------
if [[ ("$FRONTEND_CHANGED" -eq 1 || "$FORCE_FE" -eq 1) && "$SKIP_FE" -eq 0 ]]; then
log "构建前端 (npm ci && npm run build)..."
if ! (cd frontend && npm ci --no-audit --no-fund && npm run build); then
err "前端构建失败,已中止。"
exit 1
fi
ok "前端构建完成 -> frontend/dist"
log "提示: frontend/dist 已被 gitignoreNAS 端 post-receive 会重新构建以生效。"
fi
# ---- 4. 提交 + 推送 -----------------------------------------------------------
if [[ "$DRY" -eq 1 ]]; then
log "(--dry) 零副作用模式:跳过提交与推送。以上为检测结果,确认无误后可去掉 --dry 正式部署。"
exit 0
fi
if [[ -n "$LOCAL_CHANGES" ]]; then
log "暂存并提交本地变化..."
git add -A # .gitignore 已排除 .env / data / node_modules / .workbuddy / .trae 等
SCOPE=""
[[ "$BACKEND_CHANGED" -eq 1 ]] && SCOPE="$SCOPE backend"
[[ "$FRONTEND_CHANGED" -eq 1 ]] && SCOPE="$SCOPE frontend"
[[ "$DEPS_CHANGED" -eq 1 ]] && SCOPE="$SCOPE deps"
MSG="chore(deploy): 自动部署 $(date '+%Y-%m-%d %H:%M')${SCOPE:+ |$SCOPE}"
git commit -q -m "$MSG"
ok "已提交: $MSG"
fi
if [[ "$NO_PUSH" -eq 1 ]]; then
log "(--no-push) 未推送。当前领先远端 $AHEAD 个提交,确认无误后去掉 --no-push 再跑。"
exit 0
fi
export QMDS_SKIP_PREPUSH=1 # 避免与 pre-push hook 重复跑测试
log "推送到 $REMOTE/$BRANCH(触发 NAS 自动部署)..."
if ! git push "$REMOTE" "$BRANCH"; then
err "推送失败:可能远端有更新,请先 git pull 解决冲突后重试。"
exit 1
fi
ok "已推送。代码已同步至 NAS git。"
if [[ "$NO_SSH" -eq 0 ]]; then
log "通过 SSH 在 NAS 上执行部署..."
if bash "$ROOT/scripts/nas-deploy-ssh.sh"; then
ok "NAS 部署完成。"
else
err "NAS 部署脚本返回非 0(详见上方 NAS 输出)。"
exit 1
fi
else
log "(--no-ssh) 已跳过 NAS 端部署。如需部署请运行: ./scripts/nas-deploy-ssh.sh"
fi
+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env bash
# =============================================================================
# 安装本地 git pre-push 门禁
#
# 作用:任何「推送到 origin(NAS)」的操作前,先跑后端测试;若前端有变化,
# 先在本地预构建一次。任一环节失败则阻断推送,确保坏代码绝不上线。
#
# 说明:
# - deploy.sh 推送前会设 QMDS_SKIP_PREPUSH=1,避免与自身校验重复跑。
# - 仅对 origin 远端生效,推其他远端不受影响。
#
# 卸载:rm .git/hooks/pre-push
# =============================================================================
set -e
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
HOOK="$ROOT/.git/hooks/pre-push"
mkdir -p "$(dirname "$HOOK")"
cat > "$HOOK" <<'HOOK_EOF'
#!/usr/bin/env bash
# QMDSearch pre-push 校验门禁(由 scripts/install-hooks.sh 安装)
REMOTE="$1"
# 仅对 NAS 远端 (origin) 做校验;且 deploy.sh 已设 QMDS_SKIP_PREPUSH 时跳过
if [[ "$REMOTE" != "origin" ]]; then exit 0; fi
if [[ -n "${QMDS_SKIP_PREPUSH:-}" ]]; then exit 0; fi
echo "[pre-push] 推送前校验后端测试..."
if ! uv run pytest -q --no-header 2>&1 | tail -n 20; then
echo "[pre-push] 测试失败,已阻断推送。请先修复后再 push。" >&2
exit 1
fi
# 暂存区若含前端变化,本地先预构建一次,避免推到 NAS 才翻车
if git diff --cached --name-only | grep -qE '^frontend/'; then
echo "[pre-push] 检测到前端变化,本地预构建..."
if ! (cd frontend && npm ci --no-audit --no-fund && npm run build); then
echo "[pre-push] 前端构建失败,已阻断推送。" >&2
exit 1
fi
fi
echo "[pre-push] 校验通过,允许推送。"
HOOK_EOF
chmod +x "$HOOK"
echo "已安装 pre-push 门禁 -> $HOOK"
echo "如需卸载: rm $HOOK"
+120
View File
@@ -0,0 +1,120 @@
#!/usr/bin/env bash
# =============================================================================
# QMDSearch NAS 端部署(通过 SSH 直连执行,密钥免密)
#
# 环境约束(已实测):
# - NAS 上有 docker compose v2,但【没有 node/npm】→ 前端必须在本地构建后 rsync 过去
# - compose 的 app 服务只有 image: 无 build: → 依赖变化用 `docker build -t qmdsearch-app:latest .`
# - kp 已在 docker 组,可免 sudo 跑 docker
# - 部署目录 /vol1/qmdsearch 是 git 工作树,以 origin/main 为唯一真相源
#
# 流程:
# 1. SSH 到 NASgit fetch + git reset --hard origin/main(同步受管代码),并算出变更范围
# 2. 本地:若前端变化 → 本地 npm 构建 → rsync frontend/dist 到 NASNAS 无 node
# 3. SSH 到 NAS:依赖变化则 docker build + force-recreate;否则 restart app
# 4. 健康检查 /health;失败回滚到上一稳定提交
#
# 用法:
# ./scripts/nas-deploy-ssh.sh 正式部署
# ./scripts/nas-deploy-ssh.sh --dry-run 只预览将要部署的变更,不重启
# 环境变量:NAS_HOST(默认 nas DEPLOY_DIR(默认 /vol1/qmdsearch APP_PORT(默认 8000
# =============================================================================
set -uo pipefail
DEPLOY_HOST="${NAS_HOST:-nas}"
DEPLOY_DIR="${DEPLOY_DIR:-/vol1/qmdsearch}"
APP_PORT="${APP_PORT:-8000}"
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
DRY_RUN=0
[[ "${1:-}" == "--dry-run" ]] && DRY_RUN=1
# 前置检查
if ! ssh -o BatchMode=yes -o ConnectTimeout=5 "$DEPLOY_HOST" true 2>/dev/null; then
echo "[local] 无法免密 SSH 到 $DEPLOY_HOST,请先配置 SSH 密钥登录。" >&2
exit 1
fi
command -v rsync >/dev/null 2>&1 || { echo "[local] 本机缺少 rsync,请先安装。" >&2; exit 1; }
# ---- Phase 1: NAS 同步受管代码 + 计算变更范围 ----
SCOPE=$(ssh "$DEPLOY_HOST" "bash -s -- '${DEPLOY_DIR}'" <<'REMOTE'
DEPLOY_DIR="$1"
set -uo pipefail
cd "$DEPLOY_DIR" || { echo "NOCD"; exit 1; }
git fetch origin main 2>&1 | tail -n 2
NEW=$(git rev-parse origin/main)
PREV=$(cat .last_deployed_commit 2>/dev/null || echo "NONE")
if [[ "$PREV" == "$NEW" ]]; then echo "UP_TO_DATE"; exit 0; fi
OLD_HEAD=$(git rev-parse HEAD)
git reset --hard origin/main 2>&1 | tail -n 2
BASE="$OLD_HEAD"
[[ "$PREV" != "NONE" ]] && BASE="$PREV"
CHANGED=$(git diff --name-only "$BASE" "$NEW")
FE=0; DEPS=0
echo "$CHANGED" | grep -qE '^frontend/(src/|public/|index\.html|package\.json|vite\.config|package-lock\.json)' && FE=1
echo "$CHANGED" | grep -qE '^(pyproject\.toml|uv\.lock|Dockerfile)' && DEPS=1
echo "SCOPE FE=$FE DEPS=$DEPS PREV=$PREV NEW=$NEW"
REMOTE
)
if echo "$SCOPE" | grep -q "NOCD"; then echo "[nas] 无法进入 $DEPLOY_DIR"; exit 1; fi
if echo "$SCOPE" | grep -q "UP_TO_DATE"; then echo "[nas] 已是最新,无需更新。"; exit 0; fi
FE=$(echo "$SCOPE" | grep -oE 'FE=[01]' | cut -d= -f2)
DEPS=$(echo "$SCOPE" | grep -oE 'DEPS=[01]' | cut -d= -f2)
PREV=$(echo "$SCOPE" | grep -oE 'PREV=[0-9a-f]+' | cut -d= -f2)
NEW=$(echo "$SCOPE" | grep -oE 'NEW=[0-9a-f]+' | cut -d= -f2)
echo "[deploy] 将部署 $PREV -> $NEW (FE=$FE DEPS=$DEPS)"
if [[ "$DRY_RUN" == "1" ]]; then
echo "[deploy][dry-run] 变更文件:"
if [[ "$PREV" != "NONE" ]]; then
ssh "$DEPLOY_HOST" "cd $DEPLOY_DIR && git diff --name-only $PREV $NEW" 2>/dev/null | sed 's/^/ /'
else
echo " (首次部署,将全量同步受管代码)"
fi
exit 0
fi
# ---- Phase 2: 前端本地构建 + rsync 到 NASNAS 无 node----
if [[ "$FE" == "1" ]]; then
echo "[local] 本地构建前端..."
if ! (cd "$ROOT/frontend" && npm ci --no-audit --no-fund && npm run build); then
echo "[local][WARN] 前端构建失败(当前环境可能受限或缺少 node 依赖),跳过前端同步;后端仍会部署,但 frontend/dist 可能为旧版本。待在可正常构建的环境中重跑即补齐。"
else
echo "[local] rsync frontend/dist -> $DEPLOY_HOST:$DEPLOY_DIR/frontend/dist"
if ! rsync -az --delete "$ROOT/frontend/dist/" "$DEPLOY_HOST:$DEPLOY_DIR/frontend/dist/"; then
echo "[local][WARN] rsync 失败,跳过前端同步。"
fi
fi
fi
# ---- Phase 3: NAS 容器部署 + 健康检查 ----
ssh "$DEPLOY_HOST" "bash -s -- '$DEPLOY_DIR' '$APP_PORT' '$DEPS' '$NEW' '$PREV'" <<'REMOTE'
DEPLOY_DIR="$1"; APP_PORT="$2"; DEPS="$3"; NEW="$4"; PREV="$5"
set -uo pipefail
cd "$DEPLOY_DIR"
if [[ "$DEPS" == "1" ]]; then
echo "[nas] 依赖变化,重建镜像: docker build -t qmdsearch-app:latest ."
if ! docker build -t qmdsearch-app:latest . 2>&1 | tail -n 20; then
echo "[nas] 镜像构建失败,回滚到 $PREV"; git checkout -f "$PREV"; exit 1
fi
echo "[nas] 用新镜像重建容器: docker compose up -d --force-recreate app"
docker compose up -d --force-recreate app 2>&1 | tail -n 10
else
echo "[nas] 仅代码/前端变化,重启 app: docker compose restart app"
docker compose restart app 2>&1 | tail -n 5
fi
echo "[nas] 健康检查 http://localhost:$APP_PORT/api/v1/health ..."
for i in $(seq 1 20); do
if curl -fsS "http://localhost:$APP_PORT/api/v1/health" >/dev/null 2>&1; then
echo "$NEW" > .last_deployed_commit
echo "[nas] 健康检查通过 ✓ 部署完成"
exit 0
fi
sleep 3
done
echo "[nas] 健康检查失败,回滚到 $PREV"
git checkout -f "$PREV"
docker compose restart app
exit 1
REMOTE
+107
View File
@@ -0,0 +1,107 @@
#!/usr/bin/env bash
# =============================================================================
# QMDSearch NAS 端自动部署钩子 (post-receive)
#
# 这是真正的「持续部署(CD)」:当本地 deploy.sh 把代码 push 到 NAS 后,
# 此钩子自动完成 checkout → 按需构建 → 重启/重建容器 → 健康检查 → 失败回滚。
#
# 安装方式(二选一):
# A. 作为 bare 仓库的 hook(推荐,全自动):
# # 假设 NAS 上 bare 仓库在 /volume1/git/QMDSearch.git,工作树在 /volume1/docker/QMDSearch
# cp scripts/nas-post-receive /volume1/git/QMDSearch.git/hooks/post-receive
# chmod +x /volume1/git/QMDSearch.git/hooks/post-receive
# # 在该 bare 仓库 config 中设置:
# # [core] bare = true
# # [hooks] 并确保工作树目录已 clone 过一次
# # 首次需手动:git --work-tree=/volume1/docker/QMDSearch --git-dir=/volume1/git/QMDSearch.git checkout -f
#
# B. 作为普通脚本手动触发(无 bare 仓库时):
# cd /volume1/docker/QMDSearch && git pull && sudo bash scripts/nas-post-receive
#
# 前置依赖(NAS 上需具备):git、docker、docker compose、node/npm
# 可调环境变量:DEPLOY_DIR(工作树)、APP_PORT、LOG_FILE
# =============================================================================
set -uo pipefail
DEPLOY_DIR="${DEPLOY_DIR:-/volume1/docker/QMDSearch}" # ← 改成你的 NAS 部署目录
GIT_DIR="$DEPLOY_DIR/.git"
LOG_FILE="${LOG_FILE:-$DEPLOY_DIR/deploy.log}"
APP_PORT="${APP_PORT:-8000}"
PREV_COMMIT_FILE="$DEPLOY_DIR/.last_deployed_commit"
log(){ printf '%s [nas-deploy] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" | tee -a "$LOG_FILE"; }
rollback(){
log "回滚到上一稳定提交: ${PREV:-<无>}"
if [[ -n "${PREV:-}" ]]; then
git --work-tree="$DEPLOY_DIR" --git-dir="$GIT_DIR" checkout -f "$PREV"
(cd "$DEPLOY_DIR" && docker compose restart app) | tee -a "$LOG_FILE"
echo "$PREV" > "$PREV_COMMIT_FILE"
fi
}
# 解析 post-receive 通过 stdin 传入的 <old> <new> <ref>
newrev=""; refname=""
while read -r oldrev newrev refname; do :; done
# 仅处理 main 分支的推送;手动运行时 newrev 为空,回退到 HEAD
if [[ -z "$newrev" ]]; then
newrev="$(git --git-dir="$GIT_DIR" rev-parse HEAD 2>/dev/null || echo "")"
fi
if [[ -n "$refname" && "$refname" != "refs/heads/main" ]]; then
log "忽略非 main 分支推送 ($refname)"
exit 0
fi
log "=== 开始部署 (newrev=${newrev:0:8}) ==="
# 1) 检出到工作树
git --work-tree="$DEPLOY_DIR" --git-dir="$GIT_DIR" checkout -f
cd "$DEPLOY_DIR" || { log "无法进入 $DEPLOY_DIR"; exit 1; }
# 2) 计算变化范围
PREV="$(cat "$PREV_COMMIT_FILE" 2>/dev/null || echo "")"
if [[ -n "$PREV" && -n "$newrev" ]]; then
CHANGED="$(git --git-dir="$GIT_DIR" diff --name-only "$PREV" "$newrev")"
else
CHANGED="$(git --git-dir="$GIT_DIR" show --name-only --format= "$newrev")"
fi
has(){ echo "$CHANGED" | grep -qE "$1"; }
FE_CHANGED=0; DEPS_CHANGED=0
has '^frontend/(src/|public/|index\.html|package\.json|vite\.config|package-lock\.json)' && FE_CHANGED=1
has '^(pyproject\.toml|uv\.lock|Dockerfile)' && DEPS_CHANGED=1
log "变更范围 → frontend=$FE_CHANGED deps=$DEPS_CHANGED"
# 3) 按需构建前端(dist 不入库,必须在此构建)
if [[ "$FE_CHANGED" -eq 1 ]]; then
log "检测前端变化,重新构建..."
if ! (cd frontend && npm ci --no-audit --no-fund && npm run build) 2>&1 | tee -a "$LOG_FILE"; then
log "前端构建失败,中止部署(保留上一版本)"; exit 1
fi
ok(){ :; }; log "前端构建完成"
fi
# 4) 部署容器
if [[ "$DEPS_CHANGED" -eq 1 ]]; then
log "依赖变化,重建镜像并重启: docker compose up -d --build app"
docker compose up -d --build app 2>&1 | tee -a "$LOG_FILE"
else
log "仅代码变化,重启 app 服务: docker compose restart app"
docker compose restart app 2>&1 | tee -a "$LOG_FILE"
fi
# 5) 健康检查(失败自动回滚)
log "健康检查 http://localhost:$APP_PORT/health ..."
for i in $(seq 1 15); do
if curl -fsS "http://localhost:$APP_PORT/health" >/dev/null 2>&1; then
log "健康检查通过 ✓"
[[ -n "$newrev" ]] && echo "$newrev" > "$PREV_COMMIT_FILE"
log "=== 部署完成 ==="
exit 0
fi
sleep 2
done
log "健康检查失败,触发回滚"
rollback
exit 1
+58
View File
@@ -0,0 +1,58 @@
#!/usr/bin/env bash
# =============================================================================
# QMDSearch 文件变化监视器(纯轮询,无需 fswatch / inotifywait
#
# 作用:持续监听仓库代码变化,实现「每次更新代码后自动检测」。
# - 默认模式:检测到变化 → 本地跑测试 + 前端预构建 → 提示你运行 deploy.sh
# - --auto 模式:检测到变化 → 直接调用 ./scripts/deploy.sh 全自动部署
# (注意:--auto 会在保存即提交推送,慎用,建议确认改动完整后再用)
#
# 用法:
# ./scripts/watch.sh 监听并本地校验,提示部署
# ./scripts/watch.sh --auto 监听变化后全自动部署
# ./scripts/watch.sh --interval 5 轮询间隔秒数(默认 3)
# =============================================================================
set -uo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT"
AUTO=0
INTERVAL=3
while [[ $# -gt 0 ]]; do
case "$1" in
--auto) AUTO=1 ;;
--interval) INTERVAL="${2:-3}"; shift ;;
*) echo "未知参数: $1"; exit 2 ;;
esac
shift
done
SNAPSHOT="$(mktemp)"
record(){ git status --porcelain > "$SNAPSHOT"; }
record
if [[ "$AUTO" -eq 1 ]]; then
echo "[watch] 监听 $ROOTAUTO 模式:变化即部署,间隔 ${INTERVAL}sCtrl-C 退出)"
else
echo "[watch] 监听 $ROOT(校验模式:变化即本地校验并提示,间隔 ${INTERVAL}sCtrl-C 退出)"
fi
while true; do
sleep "$INTERVAL"
if ! git status --porcelain | diff -q - "$SNAPSHOT" >/dev/null 2>&1; then
echo "[watch] $(date '+%H:%M:%S') 检测到代码变化"
if [[ "$AUTO" -eq 1 ]]; then
bash "$ROOT/scripts/deploy.sh"
else
echo "[watch] 运行本地校验..."
uv run pytest -q --no-header 2>&1 | tail -n 10 || true
if git status --porcelain | awk '{print $2}' | grep -qE '^frontend/'; then
echo "[watch] 前端变化,本地预构建..."
(cd frontend && npm ci --no-audit --no-fund && npm run build) 2>&1 | tail -n 5 || true
fi
echo "[watch] 校验完成。确认无误后运行: ./scripts/deploy.sh"
fi
record
fi
done
+157
View File
@@ -0,0 +1,157 @@
---
name: qmdsearch-agent
description: 通过 QMDSearch REST API 让 AI Agent 上传文档(文本/文件)并执行分层检索查询。配置服务地址与 Bearer Token 后,即可在对话中入库知识库并检索答案。
metadata: {"clawdbot":{"emoji":"📚"}}
---
# QMDSearch Agent 接入 Skill
本 skill 指导 AI Agent 调用 **QMDSearch**(面向 AI Agent 的分层信息检索服务)的 HTTP API,完成两类核心操作:
1. **上传文档**:把文本或文件送入知识库(异步入库,自动三级总结 + 向量化)。
2. **查询 / 检索**:对知识库做分层语义检索,返回最相关原文 chunk。
## 何时使用
- 用户希望把一份文档 / 笔记 / 网页内容加入知识库 → 调用上传接口。
- 用户就知识库内容提问 → 调用检索接口。
- 用户想确认文档是否入库成功 → 轮询任务状态。
- 需要列出 / 删除知识库文档、查看统计 → 调用对应只读 / 管理接口。
## 前置配置(必须)
启用前先确定:
- `QMDSEARCH_BASE_URL`:服务基地址,例如 `http://localhost:8000` 或 NAS 地址 `http://<nas-ip>:8000`
- `QMDSEARCH_TOKEN`Bearer Token。通过 `POST {BASE}/api/v1/auth/login`(用户名 + 密码)获取;session TTL 12h,失效后重新登录。
> 变更类请求(上传 / 删除 / 重试 / 用户管理)必须携带 `Authorization: Bearer <token>`。
> 查询类只读接口(检索 / 文档列表 / 详情 / 下载 / 类目 / 统计 / 健康 / 任务状态)免登录,无需携带 token。
## 统一约定
- 路径前缀:`{BASE}/api/v1`
- 响应体:`{ "code": 0, "data": {...}, "message": "ok" }``code=0` 成功;`1xxx` 客户端错误;`2xxx` 服务端错误。
- 入库是**异步**的:上传返回 `202 + task_id`,需轮询 `GET /documents/tasks/{task_id}` 直到 `status=done`(或 `failed`)。
## 1. 获取 Token
```bash
curl -X POST {BASE}/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"<password>"}'
# → {"code":0,"data":{"token":"<session-token>","username":"admin","role":"admin","must_change_password":false},"message":"ok"}
```
`must_change_password=true` 时,登录成功但调用其他接口会返回 `1006`,需先 `POST /api/v1/auth/password` 改密。
## 2. 上传文档(文本)
`POST {BASE}/api/v1/documents`JSON body `{text, title?, source?, metadata?}`
```bash
curl -X POST {BASE}/api/v1/documents \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"text":"QMDSearch 支持 L1/L2/L3 分层检索……", "title":"QMDSearch 介绍", "source":"notes"}'
# → 202 {"code":0,"data":{"task_id":"<uuid>","status":"pending"},"message":"ok"}
```
- `text` 必填且非空;`title` / `source` 可选;`metadata``dict[str,str]`
- 相同 `sha256(text)` 会命中**去重**,直接复用旧文档(`data``deduplicated=true`)。
## 3. 上传文件
`POST {BASE}/api/v1/documents/upload``multipart/form-data`
- `file`:必填,支持 `.txt .md .html .htm .pdf .docx`
- `title` / `source`:可选(默认取原文件名)
- `metadata`:可选 JSON 字符串
```bash
curl -X POST {BASE}/api/v1/documents/upload \
-H "Authorization: Bearer <token>" \
-F "file=@document.pdf" \
-F "source=manual"
# → 202 {"code":0,"data":{"task_id":"<uuid>","status":"pending","saved_path":"..."},"message":"ok"}
```
服务端按扩展名提取纯文本(PDF 扫描件自动 OCR 降级),再复用同一入库流水线。
## 4. 轮询入库任务
`GET {BASE}/api/v1/documents/tasks/{task_id}`(无需鉴权):
```bash
curl {BASE}/api/v1/documents/tasks/<task_id>
# done → {"code":0,"data":{"task_id":...,"status":"done","result":{...文档ID/总结/分类...},"created_at":...,"updated_at":...}}
# failed → {"code":0,"data":{"task_id":...,"status":"failed","error":"..."}}
```
建议:提交后每 12s 轮询,直到 `status∈{done,failed}``done``result.document_id` 即入库文档 ID。
## 5. 检索(查询)
`POST {BASE}/api/v1/search`JSON `{query, top_k?, summarize?}`
```bash
curl -X POST {BASE}/api/v1/search \
-H "Content-Type: application/json" \
-d '{"query":"如何配置 Redis 缓存","top_k":5,"summarize":false}'
```
返回 `data``{query, hits[], routed_categories[], fallback, extracted_info, summary?}`
每个 `hit``{text, doc_id, title, section_path, score, doc_summary}`
- `top_k`:返回条数(默认 `RETRIEVAL_FINAL_K`,通常 5)。
- `summarize=true`:额外返回 `summary`(对检索结果做 AI 总结)。
- `fallback=true`:走了全库兜底(未走类目路由),召回较广。
- `extracted_info`:query 解析出的关键词 / 实体 / 意图 / 命中类目。
## 6. 其他管理接口(按需)
| 方法 | 路径 | 说明 | 鉴权 |
|------|------|------|------|
| POST | `/documents` | 文本入库(202 + task_id | Bearer |
| POST | `/documents/upload` | 文件入库(202 + task_id | Bearer |
| POST | `/documents/upload-batch` | 批量文件入库(202,逐文件建任务) | Bearer |
| GET | `/documents/tasks` | 入库任务列表(按时间降序) | Bearer |
| GET | `/documents/tasks/{task_id}` | 任务状态(done / failed | 免登录 |
| POST | `/documents/tasks/{task_id}/retry` | 重试入库任务(返回新 task_id | Bearer |
| DELETE | `/documents/tasks/{task_id}` | 删除入库任务(幂等) | Bearer |
| GET | `/documents?limit=&offset=` | 文档列表(分页游标) | 免登录 |
| GET | `/documents/{doc_id}` | 文档详情(L1 + L2/L3 + chunks 数) | 免登录 |
| GET | `/documents/{doc_id}/file` | 下载原始文件 | 免登录 |
| POST | `/documents/{doc_id}/reingest` | 重新摘要入库(读原文件→删旧→重跑) | Bearer |
| DELETE | `/documents/{doc_id}` | 删除文档(幂等) | Bearer |
| GET | `/knowledge/categories` | 知识分类类目集 | 免登录 |
| GET | `/knowledge/stats` | 统计(四层点数 + 类目分布) | 免登录 |
| GET | `/health` | 健康检查 | 免登录 |
| POST | `/auth/logout` | 退出登录 | Bearer |
| POST | `/auth/password` | 修改自己的密码 | Bearer |
| GET | `/auth/me` | 当前用户信息(脱敏) | Bearer |
| GET | `/auth/users` | 用户列表(脱敏) | Bearer + admin |
| POST | `/auth/users` | 创建用户 | Bearer + admin |
| PATCH | `/auth/users/{username}` | 更新用户角色 / 启用状态 | Bearer + admin |
| POST | `/auth/users/{username}/password` | 重置指定用户密码 | Bearer + admin |
| DELETE | `/auth/users/{username}` | 删除用户 | Bearer + admin |
## 错误码速查
- `1001` 参数 / 格式错误(空文本、不支持的类型、弱密码、超大小)
- `1004` 资源不存在(task / doc 不存在)
- `1005` 未认证或凭证无效 / 账号已禁用
- `1006` 权限不足 / 首次登录须先改密
- `2000` 服务端内部错误(检索 / 入库 / 列表失败)
- `2001` 认证服务暂不可用
## Agent 最佳实践
1. **先登录拿 token**,缓存 12h,失效再用 `/auth/login` 刷新。
2. **上传走异步**:拿到 `task_id` 后轮询,不要在对话里阻塞等待,可告诉用户“正在入库,稍后查询”。
3. **去重友好**:重复提交同文本会自动复用,不必担心重复。
4. **大文件 / 批量**:逐文件上传并各自轮询;注意 `UPLOAD_MAX_SIZE_MB`(默认 20MB)。
5. **检索调参**:召回不足调大 `top_k`;需要摘要设 `summarize=true`;关注 `fallback` 判断是否需要改写 query。
6. **凭证安全**:token 等同会话,不要写入日志或外发。
完整请求 / 响应示例(含 Python 客户端封装)见 `references/api-examples.md`
@@ -0,0 +1,198 @@
# QMDSearch API 示例与 Python 客户端封装
> 基址占位符 `{BASE}` 替换为实际服务地址,例如 `http://localhost:8000` 或 `http://<nas-ip>:8000`。
## 一、curl 示例
### 登录
```bash
curl -s -X POST {BASE}/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"<password>"}'
```
### 文本入库(202 + task_id
```bash
curl -s -X POST {BASE}/api/v1/documents \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"text":"QMDSearch 是面向 AI Agent 的分层信息检索服务……","title":"QMDSearch 介绍","source":"notes"}'
```
### 文件上传(202 + task_id
```bash
curl -s -X POST {BASE}/api/v1/documents/upload \
-H "Authorization: Bearer <token>" \
-F "file=@document.pdf" \
-F "source=manual"
```
### 轮询任务状态
```bash
curl -s {BASE}/api/v1/documents/tasks/<task_id>
```
### 批量文件上传(202,逐文件建任务)
```bash
curl -s -X POST {BASE}/api/v1/documents/upload-batch \
-H "Authorization: Bearer <token>" \
-F "files=@a.pdf" -F "files=@b.md"
```
### 列出入库任务 / 重试 / 删除任务
```bash
curl -s -H "Authorization: Bearer <token>" "{BASE}/api/v1/documents/tasks?limit=20"
curl -s -X POST -H "Authorization: Bearer <token>" {BASE}/api/v1/documents/tasks/<task_id>/retry
curl -s -X DELETE -H "Authorization: Bearer <token>" {BASE}/api/v1/documents/tasks/<task_id>
```
### 重新摘要入库(读原文件 → 删旧 → 重跑)
```bash
curl -s -X POST -H "Authorization: Bearer <token>" {BASE}/api/v1/documents/<doc_id>/reingest
```
### 检索(免登录)
```bash
curl -s -X POST {BASE}/api/v1/search \
-H "Content-Type: application/json" \
-d '{"query":"如何配置 Redis 缓存","top_k":5,"summarize":false}'
```
## 二、Python 客户端封装
可直接在 Agent 工具代码里复用:
```python
import time
import requests
class QMDSearchClient:
"""QMDSearch 最小客户端:登录、入库(文本/文件)、轮询、检索。"""
def __init__(self, base_url: str, username: str, password: str,
poll_interval: float = 1.5, poll_timeout: float = 120.0):
self.base = base_url.rstrip("/")
self.username = username
self.password = password
self.poll_interval = poll_interval
self.poll_timeout = poll_timeout
self.token: str | None = None
# ---- 鉴权 ----
def login(self) -> str:
r = requests.post(
f"{self.base}/api/v1/auth/login",
json={"username": self.username, "password": self.password},
timeout=30,
)
r.raise_for_status()
body = r.json()
if body.get("code") != 0:
raise RuntimeError(f"登录失败: {body}")
self.token = body["data"]["token"]
return self.token
def _headers(self) -> dict:
if not self.token:
self.login()
return {"Authorization": f"Bearer {self.token}"}
def _ok(self, resp: requests.Response):
resp.raise_for_status()
body = resp.json()
if body.get("code") != 0:
raise RuntimeError(f"API 错误 code={body.get('code')} msg={body.get('message')}")
return body["data"]
# ---- 入库 ----
def ingest_text(self, text: str, title: str = "", source: str = "",
metadata: dict | None = None) -> str:
data = {"text": text}
if title:
data["title"] = title
if source:
data["source"] = source
if metadata:
data["metadata"] = {str(k): str(v) for k, v in metadata.items()}
resp = requests.post(f"{self.base}/api/v1/documents",
headers=self._headers(), json=data, timeout=30)
return self._ok(resp)["task_id"]
def upload_file(self, path: str, title: str = "", source: str = "",
metadata: dict | None = None) -> str:
files = {"file": open(path, "rb")}
data = {}
if title:
data["title"] = title
if source:
data["source"] = source
if metadata:
data["metadata"] = str({str(k): str(v) for k, v in metadata.items()})
resp = requests.post(f"{self.base}/api/v1/documents/upload",
headers=self._headers(), files=files, data=data, timeout=60)
return self._ok(resp)["task_id"]
def upload_batch(self, paths: list[str]) -> dict:
"""批量上传多个文件:返回 {tasks:[{filename,task_id}], failed:[{filename,error}]}"""
files = [("files", open(p, "rb")) for p in paths]
resp = requests.post(f"{self.base}/api/v1/documents/upload-batch",
headers=self._headers(), files=files, timeout=60)
return self._ok(resp)
# ---- 轮询 ----
def wait_task(self, task_id: str) -> dict:
deadline = time.time() + self.poll_timeout
while time.time() < deadline:
resp = requests.get(f"{self.base}/api/v1/documents/tasks/{task_id}", timeout=30)
data = self._ok(resp)
if data["status"] == "done":
return data["result"]
if data["status"] == "failed":
raise RuntimeError(f"入库失败: {data.get('error')}")
time.sleep(self.poll_interval)
raise TimeoutError(f"任务 {task_id} 轮询超时")
def ingest_text_wait(self, *args, **kwargs) -> dict:
return self.wait_task(self.ingest_text(*args, **kwargs))
def upload_file_wait(self, *args, **kwargs) -> dict:
return self.wait_task(self.upload_file(*args, **kwargs))
# ---- 检索 ----
def search(self, query: str, top_k: int | None = None,
summarize: bool = False) -> dict:
payload = {"query": query, "summarize": summarize}
if top_k is not None:
payload["top_k"] = top_k
resp = requests.post(f"{self.base}/api/v1/search",
headers=self._headers(), json=payload, timeout=60)
return self._ok(resp)
# 用法
if __name__ == "__main__":
client = QMDSearchClient("http://localhost:8000", "admin", "<password>")
client.login()
# 文本入库并等待完成
result = client.ingest_text_wait("这是一篇关于分层检索的笔记……", title="笔记")
print("document_id =", result["document_id"])
# 检索
hits = client.search("分层检索是什么", top_k=5)["hits"]
for h in hits:
print(f"[{h['score']:.3f}] {h['title']}: {h['text'][:80]}")
```
## 三、响应结构速记
- 文本 / 文件 / 批量入库 `202``data = {task_id, status:"pending"[, saved_path]}`;批量返回 `{tasks, failed}`
- 任务 `done``data.result = {document_id, summary:{l1_summary,l2_outline,l3_content_outline,level}, category, chunks_count, tags, category_confidence, deduplicated}`
- 检索 → `data = {query, hits:[{text,doc_id,title,section_path,score,doc_summary}], routed_categories, fallback, extracted_info, summary?}`
+8 -24
View File
@@ -1,39 +1,23 @@
"""pytest 全局夹具:覆盖 JWT 认证依赖,让现有 API 测试默认以 admin 身份运行 """pytest 全局夹具
业务接口(search/knowledge/settings)仍使用 app.core.auth 的 JWT 依赖, 鉴权采用 app.api.deps 的会话体系(UserStore / SessionStore):
这里通过 autouse 夹具把两个依赖统一替换为返回固定 admin AuthUser 的 lambda - 文档变更类端点(POST /documents、/documents/upload、DELETE /documents/{id})、
使现有 API 测试无需改动即可通过认证。单个测试需要走真实认证逻辑时, /auth/* 与 Settings 变更端点需要登录;
可在测试函数内 pop 掉对应 overrideautouse fixture yield 后会统一 clear。 - 查询类端点(POST /search、GET /knowledge/*、GET /documents*、
GET /documents/{id}/file)免登录。
文档变更类端点(POST /documents、/documents/upload、DELETE /documents/{id} auth_stores / admin_headers 夹具注入内存存储并签发真实 session token
使用 app.api.deps 的会话认证(UserStore/SessionStore),由 auth_stores / 供需要登录的接口测试使用;查询类端点测试直接无 token 调用即可,无需覆盖依赖。
admin_headers 夹具注入内存存储并签发真实 session token。
另外清理 LLM 客户端 / 解析插件 / 去重策略三处进程级缓存,避免跨测试串扰 另外清理 LLM 客户端 / 解析插件 / 去重策略三处进程级缓存,避免跨测试串扰
(不同测试可能注入不同的 redis/ollama 实例,缓存按配置签名而非实例区分)。 (不同测试可能注入不同的 redis/ollama 实例,缓存按配置签名而非实例区分)。
""" """
import asyncio import asyncio
from datetime import UTC, datetime
from typing import Any from typing import Any
import pytest import pytest
from app.core.auth import get_current_user, require_admin
from app.main import app
from app.models.auth import AuthUser
TEST_USER = AuthUser(username="testuser", role="admin", created_at=datetime.now(UTC))
@pytest.fixture(autouse=True)
def override_auth():
"""所有测试默认以 admin 身份运行(旧 JWT 依赖);测试结束清理 dependency_overrides"""
app.dependency_overrides[get_current_user] = lambda: TEST_USER
app.dependency_overrides[require_admin] = lambda: TEST_USER
yield
app.dependency_overrides.clear()
@pytest.fixture @pytest.fixture
def auth_stores(monkeypatch: pytest.MonkeyPatch): def auth_stores(monkeypatch: pytest.MonkeyPatch):
+120 -1
View File
@@ -13,6 +13,8 @@
10. 请求拦截:Authorization Bearer 注入、1005 回登录、1006 错误条 10. 请求拦截:Authorization Bearer 注入、1005 回登录、1006 错误条
11. API 指南区块:导航/section、API_GUIDE 清单与真实路由一致性、试一下面板、 11. API 指南区块:导航/section、API_GUIDE 清单与真实路由一致性、试一下面板、
curl 复制、auth 标注、upload 文件选择、禁止自定义 URL curl 复制、auth 标注、upload 文件选择、禁止自定义 URL
12. 入库进度区块(Task 2):section/nav、表格结构、2s 轮询启停、状态徽章复用、
done/failed 操作按钮、reingest 端点、批量上传 multiple + upload-batch 路径
""" """
import re import re
@@ -39,9 +41,20 @@ def client(monkeypatch: pytest.MonkeyPatch) -> Iterator[TestClient]:
@pytest.fixture @pytest.fixture
def admin_html(client: TestClient) -> str: def admin_html(client: TestClient) -> str:
"""请求 /admin 并返回 HTML 文本(前置断言 200""" """请求 /admin 并返回旧版 admin.html 文本(前置断言 200
当 app/static/admin 符号链接存在时(Vue SPA 部署),/admin 端点会返回 SPA 入口
而非旧版 admin.html。此时直接读取 admin.html 文件内容进行测试。
"""
resp = client.get("/admin") resp = client.get("/admin")
assert resp.status_code == 200 assert resp.status_code == 200
# 检测是否返回了 Vue SPA(无 section-overview 标记)
if "section-overview" not in resp.text:
# SPA 模式:直接读取旧版 admin.html 文件
from pathlib import Path
admin_html_path = Path(__file__).resolve().parent.parent / "app" / "static" / "admin.html"
return admin_html_path.read_text(encoding="utf-8")
return resp.text return resp.text
@@ -86,6 +99,8 @@ def test_admin_page_fetch_paths(admin_html: str) -> None:
"/api/v1/auth/logout", "/api/v1/auth/logout",
"/api/v1/auth/password", "/api/v1/auth/password",
"/api/v1/auth/users", "/api/v1/auth/users",
"/api/v1/documents/upload-batch",
"/api/v1/documents/tasks",
): ):
assert path in admin_html assert path in admin_html
@@ -107,6 +122,13 @@ def test_admin_page_fetch_paths_in_real_routes(admin_html: str) -> None:
"""页面 api() 调用路径均在真实后端路由集合内(含 tasks 与 /api/v1/auth/* 路径)""" """页面 api() 调用路径均在真实后端路由集合内(含 tasks 与 /api/v1/auth/* 路径)"""
route_paths = _collect_route_paths(app.routes) route_paths = _collect_route_paths(app.routes)
assert "/api/v1/documents/tasks/{task_id}" in route_paths assert "/api/v1/documents/tasks/{task_id}" in route_paths
# Task 2 新增端点在真实路由集合内
for new_path in (
"/api/v1/documents/tasks",
"/api/v1/documents/upload-batch",
"/api/v1/documents/{doc_id}/reingest",
):
assert new_path in route_paths, f"后端缺少 Task 2 新增端点: {new_path}"
for auth_path in ( for auth_path in (
"/api/v1/auth/login", "/api/v1/auth/login",
"/api/v1/auth/me", "/api/v1/auth/me",
@@ -140,6 +162,99 @@ def test_admin_page_ingest_polling(admin_html: str) -> None:
assert "disabled" in admin_html assert "disabled" in admin_html
def test_admin_page_progress_section(admin_html: str) -> None:
"""入库进度区块:导航按钮、section、表格结构、刷新按钮、空列表占位"""
# section 与导航按钮(所有登录用户可见)
assert 'id="section-progress"' in admin_html
assert 'id="nav-progress"' in admin_html
assert 'data-target="section-progress"' in admin_html
assert "入库进度" in admin_html
# 刷新按钮与状态统计
assert 'id="btn-refresh-progress"' in admin_html
assert 'id="progress-stats"' in admin_html
# 表格 tbody 与表头列:文件名 / 状态 / 创建时间 / 更新时间 / 操作
assert 'id="progress-tbody"' in admin_html
for col in ("文件名", "状态", "创建时间", "更新时间", "操作"):
assert col in admin_html
# 空列表占位文案
assert "暂无入库任务" in admin_html
def test_admin_page_progress_polling(admin_html: str) -> None:
"""入库进度区块:2s 轮询常量、startProgressPolling/stopProgressPolling 函数、GET /documents/tasks 路径"""
# 进度区块专用轮询常量(2s
assert "PROGRESS_POLL_INTERVAL_MS" in admin_html
assert "2000" in admin_html
# 轮询启停函数
assert "function startProgressPolling" in admin_html
assert "function stopProgressPolling" in admin_html
assert "progressPollTimer" in admin_html
# 数据来源:GET /documents/tasks?limit=50
assert "/api/v1/documents/tasks?limit=50" in admin_html
# loadProgress / renderProgress / buildProgressRow 函数
assert "function loadProgress" in admin_html
assert "function renderProgress" in admin_html
assert "function buildProgressRow" in admin_html
def test_admin_page_progress_polling_lifecycle(admin_html: str) -> None:
"""进度区块轮询生命周期:activateSection 切走暂停、切回恢复;showLogin 停止"""
# activateSection 开头调用 stopProgressPolling(切走即暂停)
assert "stopProgressPolling();" in admin_html
# 切到 section-progress 时恢复轮询
assert 'targetId === "section-progress"' in admin_html
assert 'startProgressPolling()' in admin_html
# showLogin 退出时停止轮询
assert "stopProgressPolling" in admin_html
def test_admin_page_progress_status_badge_reuse(admin_html: str) -> None:
"""进度表格状态徽章复用 INGEST_STATUS_TEXT 中文映射与 status-* 配色"""
# statusBadgeEl 共享构造函数(无 id 可重复使用)
assert "function statusBadgeEl" in admin_html
# makeStatusBadge 复用 statusBadgeEl + 加 id
assert "statusBadgeEl(status)" in admin_html
# 状态徽章 CSS 类复用
for cls in ("status-running", "status-done", "status-failed"):
assert cls in admin_html
def test_admin_page_progress_actions(admin_html: str) -> None:
"""进度表格操作列:done 查看文档+重新入库、failed 重试、进行中无操作;reingest 端点"""
# done 状态操作按钮
assert "查看文档" in admin_html
assert "重新入库" in admin_html
# failed 状态重试按钮
assert "重试" in admin_html
# reingestDocument 函数与 POST /documents/{doc_id}/reingest 端点
assert "function reingestDocument" in admin_html
assert "/reingest" in admin_html
# 查看文档:切到 section-docs 并调用 loadDocDetail
assert 'activateSection("section-docs")' in admin_html
assert "loadDocDetail" in admin_html
# 状态统计文案
assert "进行中" in admin_html
def test_admin_page_batch_upload(admin_html: str) -> None:
"""批量上传:input multiple 属性、upload-batch 端点、批量结果展示、自动切进度区块"""
# file input multiple 属性
assert 'id="upload-file"' in admin_html
assert "multiple" in admin_html
# 批量上传分支:files > 1 时走 upload-batch 端点
assert "/api/v1/documents/upload-batch" in admin_html
assert 'fileInput.files.length > 1' in admin_html
# FormData 用 files 字段逐文件 append
assert 'batchForm.append("files"' in admin_html
# 批量结果展示函数
assert "function renderBatchUploadResult" in admin_html
assert "批量上传完成" in admin_html
# 成功后自动切到入库进度区块
assert 'activateSection("section-progress")' in admin_html
# 单文件上传仍走原 upload 端点(保持既有逻辑)
assert "/api/v1/documents/upload\"" in admin_html or "/api/v1/documents/upload'," in admin_html
def test_admin_page_no_prompt_confirm_calls(admin_html: str) -> None: def test_admin_page_no_prompt_confirm_calls(admin_html: str) -> None:
"""清理:弹窗组件已替换原生 prompt/confirm,全页面无 prompt( 与 confirm( 调用残留""" """清理:弹窗组件已替换原生 prompt/confirm,全页面无 prompt( 与 confirm( 调用残留"""
assert "prompt(" not in admin_html assert "prompt(" not in admin_html
@@ -490,15 +605,19 @@ def test_admin_page_api_guide_paths_in_real_routes(admin_html: str) -> None:
assert (method, path) in route_methods, f"API_GUIDE 端点 {method} {path} 不在后端路由集合内" assert (method, path) in route_methods, f"API_GUIDE 端点 {method} {path} 不在后端路由集合内"
# 全部真实端点覆盖:health/search/documents*/knowledge*/auth* # 全部真实端点覆盖:health/search/documents*/knowledge*/auth*
# Task 2 新增端点:upload-batch / tasks 列表 / reingest
expected = { expected = {
("GET", "/api/v1/health"), ("GET", "/api/v1/health"),
("POST", "/api/v1/search"), ("POST", "/api/v1/search"),
("POST", "/api/v1/documents"), ("POST", "/api/v1/documents"),
("POST", "/api/v1/documents/upload"), ("POST", "/api/v1/documents/upload"),
("POST", "/api/v1/documents/upload-batch"),
("GET", "/api/v1/documents/tasks"),
("GET", "/api/v1/documents/tasks/{task_id}"), ("GET", "/api/v1/documents/tasks/{task_id}"),
("GET", "/api/v1/documents"), ("GET", "/api/v1/documents"),
("GET", "/api/v1/documents/{doc_id}"), ("GET", "/api/v1/documents/{doc_id}"),
("DELETE", "/api/v1/documents/{doc_id}"), ("DELETE", "/api/v1/documents/{doc_id}"),
("POST", "/api/v1/documents/{doc_id}/reingest"),
("GET", "/api/v1/knowledge/categories"), ("GET", "/api/v1/knowledge/categories"),
("GET", "/api/v1/knowledge/stats"), ("GET", "/api/v1/knowledge/stats"),
("POST", "/api/v1/auth/login"), ("POST", "/api/v1/auth/login"),
+335
View File
@@ -0,0 +1,335 @@
"""POST /documents/upload-batch、POST /documents/{id}/reingest、GET /documents/tasks 端点测试
TestClient + FakeManager + FakeQdrant,不真实联网。
"""
from collections.abc import Iterator
from pathlib import Path
from typing import Any
import pytest
from fastapi.testclient import TestClient
from app.api.v1 import document as document_module
from app.main import app
from app.models.document import DocumentInput
from app.services.qdrant import QdrantService
class FakeManager:
"""假入库任务管理器:支持 submit/get/list_tasks"""
def __init__(self, task_id: str = "task-1") -> None:
self.task_id = task_id
self._counter = 0
self.submitted: list[DocumentInput] = []
self._tasks: list[dict[str, Any]] = []
async def submit(self, doc: DocumentInput) -> str:
self.submitted.append(doc)
self._counter += 1
tid = f"{self.task_id}-{self._counter}"
self._tasks.append(
{
"task_id": tid,
"status": "pending",
"filename": doc.metadata.get("original_filename") or doc.title,
"created_at": f"2026-01-0{self._counter}T00:00:00+00:00",
"updated_at": f"2026-01-0{self._counter}T00:00:00+00:00",
"doc_id": None,
}
)
return tid
async def get(self, task_id: str) -> dict[str, Any] | None:
return None
async def list_tasks(self, limit: int = 20) -> list[dict[str, Any]]:
return list(self._tasks[:limit])
class FakeQdrant:
"""假 Qdrant 服务:可控的 get_l1_metadata/delete_by_doc_id"""
def __init__(self, meta: dict[str, str] | None = None) -> None:
self.meta = meta
self.deleted: list[str] = []
async def get_l1_metadata(self, doc_id: str) -> dict[str, str] | None:
return self.meta
async def delete_by_doc_id(self, doc_id: str) -> dict[str, int]:
self.deleted.append(doc_id)
return {"doc_l1": 1}
@pytest.fixture
def client(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, admin_headers: dict[str, str]
) -> Iterator[TestClient]:
async def _noop_ensure_collections(self: QdrantService) -> None:
return None
monkeypatch.setattr(QdrantService, "ensure_collections", _noop_ensure_collections)
monkeypatch.setattr(document_module.settings, "upload_dir", str(tmp_path / "uploads"))
with TestClient(app) as test_client:
test_client.headers.update(admin_headers)
yield test_client
def _inject_manager(monkeypatch: pytest.MonkeyPatch, manager: FakeManager) -> None:
monkeypatch.setattr(document_module, "_get_task_manager", lambda: manager)
def _inject_qdrant(monkeypatch: pytest.MonkeyPatch, qdrant: FakeQdrant) -> None:
monkeypatch.setattr(document_module, "_get_qdrant", lambda: qdrant)
# ----------------------- upload-batch -----------------------
def test_batch_upload_multiple_files_success(
client: TestClient, monkeypatch: pytest.MonkeyPatch
) -> None:
"""多文件全部成功:3 文件 → 3 tasksfailed 为空"""
manager = FakeManager()
_inject_manager(monkeypatch, manager)
resp = client.post(
"/api/v1/documents/upload-batch",
files=[
("files", ("a.txt", b"content a", "text/plain")),
("files", ("b.md", b"# B", "text/markdown")),
("files", ("c.txt", b"content c", "text/plain")),
],
)
assert resp.status_code == 202
data = resp.json()["data"]
assert len(data["tasks"]) == 3
assert data["failed"] == []
assert len(manager.submitted) == 3
filenames = [t["filename"] for t in data["tasks"]]
assert filenames == ["a.txt", "b.md", "c.txt"]
def test_batch_upload_partial_failure(
client: TestClient, monkeypatch: pytest.MonkeyPatch
) -> None:
"""部分失败:1 个不支持扩展名进 failed,其他成功"""
manager = FakeManager()
_inject_manager(monkeypatch, manager)
resp = client.post(
"/api/v1/documents/upload-batch",
files=[
("files", ("ok.txt", b"good", "text/plain")),
("files", ("bad.xlsx", b"binary", "application/octet-stream")),
],
)
assert resp.status_code == 202
data = resp.json()["data"]
assert len(data["tasks"]) == 1
assert data["tasks"][0]["filename"] == "ok.txt"
assert len(data["failed"]) == 1
assert data["failed"][0]["filename"] == "bad.xlsx"
assert "不支持" in data["failed"][0]["error"]
assert len(manager.submitted) == 1
def test_batch_upload_all_fail(
client: TestClient, monkeypatch: pytest.MonkeyPatch
) -> None:
"""全部失败:tasks 空,failed 2 条,未提交任何任务"""
manager = FakeManager()
_inject_manager(monkeypatch, manager)
resp = client.post(
"/api/v1/documents/upload-batch",
files=[
("files", ("a.exe", b"x", "application/octet-stream")),
("files", ("b.bin", b"y", "application/octet-stream")),
],
)
assert resp.status_code == 202
data = resp.json()["data"]
assert data["tasks"] == []
assert len(data["failed"]) == 2
assert len(manager.submitted) == 0
def test_batch_upload_empty_list_returns_1001(
client: TestClient, monkeypatch: pytest.MonkeyPatch
) -> None:
"""空文件列表:1001"""
manager = FakeManager()
_inject_manager(monkeypatch, manager)
resp = client.post("/api/v1/documents/upload-batch", files=[])
body = resp.json()
assert body["code"] == 1001
assert manager.submitted == []
# ----------------------- reingest -----------------------
def test_reingest_success(
client: TestClient, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""有原始文件:202 + task_id,删旧数据后提交新任务"""
upload_dir = tmp_path / "uploads"
upload_dir.mkdir(parents=True, exist_ok=True)
raw = upload_dir / "raw_report.txt"
raw.write_bytes(b"original content")
meta = {
"raw_file_path": str(raw),
"original_filename": "raw_report.txt",
}
qdrant = FakeQdrant(meta=meta)
_inject_qdrant(monkeypatch, qdrant)
manager = FakeManager(task_id="reingest-task")
_inject_manager(monkeypatch, manager)
resp = client.post("/api/v1/documents/some-doc/reingest")
assert resp.status_code == 202
data = resp.json()["data"]
assert data["task_id"] == "reingest-task-1"
assert data["status"] == "pending"
# 旧数据已删
assert qdrant.deleted == ["some-doc"]
# 新任务已提交,文本来自原文件
assert len(manager.submitted) == 1
assert manager.submitted[0].text == "original content"
assert manager.submitted[0].title == "raw_report" # 文件名 stem
def test_reingest_no_raw_file_returns_1001(
client: TestClient, monkeypatch: pytest.MonkeyPatch
) -> None:
"""无原始文件(meta 无 raw_file_path):1001"""
qdrant = FakeQdrant(meta={"some": "metadata"})
_inject_qdrant(monkeypatch, qdrant)
manager = FakeManager()
_inject_manager(monkeypatch, manager)
resp = client.post("/api/v1/documents/doc1/reingest")
body = resp.json()
assert body["code"] == 1001
assert "无原始文件" in body["message"]
assert len(manager.submitted) == 0
def test_reingest_missing_file_returns_1004(
client: TestClient, monkeypatch: pytest.MonkeyPatch
) -> None:
"""文件不存在:1004"""
qdrant = FakeQdrant(
meta={"raw_file_path": "/nonexistent/path.txt", "original_filename": "path.txt"}
)
_inject_qdrant(monkeypatch, qdrant)
manager = FakeManager()
_inject_manager(monkeypatch, manager)
resp = client.post("/api/v1/documents/doc1/reingest")
body = resp.json()
assert body["code"] == 1004
assert len(manager.submitted) == 0
def test_reingest_doc_not_found_returns_1004(
client: TestClient, monkeypatch: pytest.MonkeyPatch
) -> None:
"""文档不存在(meta 为 None):1004"""
qdrant = FakeQdrant(meta=None)
_inject_qdrant(monkeypatch, qdrant)
manager = FakeManager()
_inject_manager(monkeypatch, manager)
resp = client.post("/api/v1/documents/nope/reingest")
body = resp.json()
assert body["code"] == 1004
assert len(manager.submitted) == 0
# ----------------------- tasks 列表 -----------------------
def test_list_ingest_tasks_returns_items(
client: TestClient, monkeypatch: pytest.MonkeyPatch
) -> None:
"""任务列表:返回近期任务,含 items 与 total"""
manager = FakeManager()
_inject_manager(monkeypatch, manager)
manager._tasks = [
{
"task_id": "t1",
"status": "done",
"filename": "a.txt",
"created_at": "2026-01-01T00:00:00+00:00",
"updated_at": "2026-01-01T00:00:00+00:00",
"doc_id": "d1",
},
{
"task_id": "t2",
"status": "failed",
"filename": "b.txt",
"created_at": "2026-01-02T00:00:00+00:00",
"updated_at": "2026-01-02T00:00:00+00:00",
"doc_id": None,
},
]
resp = client.get("/api/v1/documents/tasks")
assert resp.status_code == 200
data = resp.json()["data"]
assert data["total"] == 2
assert len(data["items"]) == 2
assert data["items"][0]["task_id"] == "t1"
assert data["items"][0]["doc_id"] == "d1"
def test_list_ingest_tasks_limit(
client: TestClient, monkeypatch: pytest.MonkeyPatch
) -> None:
"""limit 截断生效"""
manager = FakeManager()
_inject_manager(monkeypatch, manager)
manager._tasks = [
{
"task_id": f"t{i}",
"status": "done",
"filename": "f",
"created_at": "",
"updated_at": "",
"doc_id": None,
}
for i in range(10)
]
resp = client.get("/api/v1/documents/tasks?limit=5")
data = resp.json()["data"]
assert data["total"] == 5
assert len(data["items"]) == 5
def test_list_ingest_tasks_requires_auth(
client: TestClient, monkeypatch: pytest.MonkeyPatch
) -> None:
"""无 token1005 未认证"""
manager = FakeManager()
_inject_manager(monkeypatch, manager)
resp = client.get("/api/v1/documents/tasks", headers={"Authorization": ""})
body = resp.json()
assert body["code"] == 1005
+339
View File
@@ -0,0 +1,339 @@
"""批量上传与入库进度集成验证
在真实内存 Qdrant + FakeOllama + 确定性向量环境下,全链路验证:
- POST /documents/upload-batch 批量上传闭环(全部成功 / 部分失败)
- GET /documents/tasks 任务列表(按 updated_at 降序、每项含 filename/doc_id
- POST /documents/{doc_id}/reingest 重新入库(成功 / 边界 1001/1004
复用 tests/test_e2e_integration.py 的 FakeOllama / DeterministicEmbedding / FakeCache
upload_dir 用 tmp_path 真实落盘,monkeypatch 模块级 _task_manager / _qdrant 单例。
"""
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from qdrant_client import AsyncQdrantClient
from app.api.v1 import document as document_module
from app.config import Settings
from app.core.chunker import Chunker
from app.core.classifier import Classifier
from app.core.ingest_tasks import IngestTaskManager, IngestTaskStatus
from app.core.ingestion import Ingester
from app.core.query_parser import QueryParser
from app.core.retriever import Retriever
from app.core.sparse import SparseEncoder
from app.core.summarizer import Summarizer
from app.main import app
from app.models.knowledge import load_taxonomy
from app.services.qdrant import COLLECTION_CHUNKS, QdrantService
from tests.test_e2e_integration import (
FAKE_L1_SUMMARY,
DeterministicEmbedding,
FakeCache,
FakeOllama,
)
async def _make_env() -> tuple[QdrantService, Ingester, Retriever]:
"""构建集成环境:真实组件 + 内存 Qdrant + FakeOllama + 确定性向量"""
qdrant = QdrantService(client=AsyncQdrantClient(location=":memory:"))
await qdrant.ensure_collections()
ollama = FakeOllama()
taxonomy = load_taxonomy()
embedding = DeterministicEmbedding()
ingester = Ingester(
summarizer=Summarizer(ollama=ollama), # type: ignore[arg-type]
classifier=Classifier(ollama=ollama, taxonomy=taxonomy), # type: ignore[arg-type]
chunker=Chunker(),
embedding=embedding, # type: ignore[arg-type]
sparse=SparseEncoder(),
qdrant=qdrant,
)
retriever = Retriever(
qdrant=qdrant,
query_parser=QueryParser(ollama=ollama, taxonomy=taxonomy, cache=FakeCache()), # type: ignore[arg-type]
embedding=embedding, # type: ignore[arg-type]
sparse_encoder=SparseEncoder(),
)
return qdrant, ingester, retriever
def _patch_app(
monkeypatch: pytest.MonkeyPatch,
manager: IngestTaskManager,
qdrant: QdrantService,
retriever: Retriever,
upload_dir: Path,
) -> None:
"""替换模块级单例:任务管理器 / Qdrant / Retriever / upload_dirlifespan 建集合改空操作"""
async def _noop_ensure_collections(self: QdrantService) -> None:
return None
monkeypatch.setattr(QdrantService, "ensure_collections", _noop_ensure_collections)
monkeypatch.setattr(document_module, "_task_manager", manager)
monkeypatch.setattr(document_module, "_qdrant", qdrant)
monkeypatch.setattr(document_module.settings, "upload_dir", str(upload_dir))
def _md_content(name: str) -> bytes:
"""构造可触发完整三级总结的 .md 文件内容(不同 name 保证文本不同,避免去重)"""
paragraph = f"这是 {name} 章节的正文内容,包含足够信息量用于测试切分与向量化流程。" * 20
text = f"# {name} 安装指南\n{paragraph}\n\n## 环境准备\n{paragraph}\n\n## 安装步骤\n{paragraph}"
return text.encode("utf-8")
class TestBatchUploadIntegration:
"""批量上传闭环:3 文件全成功 → 任务列表 → 文档详情含原始文件信息"""
async def test_batch_upload_full_loop(
self,
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
admin_headers: dict[str, str],
) -> None:
qdrant, ingester, retriever = await _make_env()
manager = IngestTaskManager(ingester, None, Settings())
_patch_app(monkeypatch, manager, qdrant, retriever, tmp_path / "uploads")
with TestClient(app) as client:
# 1. 批量上传 3 个 .md202 + tasks 含 3 个 + failed 空
resp = client.post(
"/api/v1/documents/upload-batch",
files=[
("files", ("a.md", _md_content("Alpha"), "text/markdown")),
("files", ("b.md", _md_content("Beta"), "text/markdown")),
("files", ("c.md", _md_content("Gamma"), "text/markdown")),
],
headers=admin_headers,
)
assert resp.status_code == 202
data = resp.json()["data"]
assert len(data["tasks"]) == 3
assert data["failed"] == []
task_ids = [t["task_id"] for t in data["tasks"]]
filenames = [t["filename"] for t in data["tasks"]]
assert filenames == ["a.md", "b.md", "c.md"]
# 2. 逐个等待终态:全部 done
doc_ids: list[str] = []
for tid in task_ids:
final = await manager.wait_done(tid)
assert final["status"] == IngestTaskStatus.DONE
doc_ids.append(final["result"]["document_id"])
# 3. GET /documents/tasksitems 含 3 项,按 updated_at 降序,每项含 filename/doc_id
resp_list = client.get(
"/api/v1/documents/tasks?limit=10", headers=admin_headers
)
body_list = resp_list.json()
assert body_list["code"] == 0
items = body_list["data"]["items"]
assert body_list["data"]["total"] == 3
assert len(items) == 3
# 按 updated_at 降序
updated = [it["updated_at"] for it in items]
assert updated == sorted(updated, reverse=True)
# 每项含 filename 与 doc_id
for it in items:
assert it["filename"] in {"a.md", "b.md", "c.md"}
assert it["doc_id"] in doc_ids
# 4. GET /documents/{doc_id}file 字段非 null(有原始文件信息)
resp_detail = client.get(f"/api/v1/documents/{doc_ids[0]}")
body_detail = resp_detail.json()
assert body_detail["code"] == 0
file_info = body_detail["data"]["file"]
assert file_info is not None
assert file_info["filename"] == filenames[0]
assert file_info["size_bytes"] > 0
assert file_info["url"] == f"/api/v1/documents/{doc_ids[0]}/file"
class TestBatchUploadPartialFailure:
"""部分失败:1 个 .unsupported 扩展名进 failed,其余 2 个成功"""
async def test_batch_upload_partial_failure(
self,
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
admin_headers: dict[str, str],
) -> None:
qdrant, ingester, retriever = await _make_env()
manager = IngestTaskManager(ingester, None, Settings())
_patch_app(monkeypatch, manager, qdrant, retriever, tmp_path / "uploads")
with TestClient(app) as client:
resp = client.post(
"/api/v1/documents/upload-batch",
files=[
("files", ("ok1.md", _md_content("Ok1"), "text/markdown")),
("files", ("ok2.md", _md_content("Ok2"), "text/markdown")),
("files", ("bad.unsupported", b"whatever", "application/octet-stream")),
],
headers=admin_headers,
)
assert resp.status_code == 202
data = resp.json()["data"]
assert len(data["tasks"]) == 2
assert len(data["failed"]) == 1
assert data["failed"][0]["filename"] == "bad.unsupported"
assert "不支持" in data["failed"][0]["error"]
# 2 个成功任务均可进入终态 done
for t in data["tasks"]:
final = await manager.wait_done(t["task_id"])
assert final["status"] == IngestTaskStatus.DONE
class TestReingestIntegration:
"""重新入库:成功路径 + 边界(无原始文件 / 文档不存在 / 文件已删)"""
async def test_reingest_success(
self,
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
admin_headers: dict[str, str],
) -> None:
qdrant, ingester, retriever = await _make_env()
manager = IngestTaskManager(ingester, None, Settings())
_patch_app(monkeypatch, manager, qdrant, retriever, tmp_path / "uploads")
with TestClient(app) as client:
# 先上传 1 个文件,拿到 doc_id
resp_up = client.post(
"/api/v1/documents/upload-batch",
files=[("files", ("re.md", _md_content("Reingest"), "text/markdown"))],
headers=admin_headers,
)
assert resp_up.status_code == 202
up_data = resp_up.json()["data"]
assert len(up_data["tasks"]) == 1
old_task_id = up_data["tasks"][0]["task_id"]
old_final = await manager.wait_done(old_task_id)
old_doc_id = old_final["result"]["document_id"]
# 重新入库:202 + task_id
resp_re = client.post(
f"/api/v1/documents/{old_doc_id}/reingest", headers=admin_headers
)
assert resp_re.status_code == 202
re_body = resp_re.json()
assert re_body["code"] == 0
new_task_id = re_body["data"]["task_id"]
assert new_task_id
# 等待终态:done
new_final = await manager.wait_done(new_task_id)
assert new_final["status"] == IngestTaskStatus.DONE
new_doc_id = new_final["result"]["document_id"]
# 旧 doc_id 数据已删:GET /documents/{old_doc_id} → 1004
resp_old = client.get(f"/api/v1/documents/{old_doc_id}")
assert resp_old.json()["code"] == 1004
# 新 doc_id 详情:L1 summary 存在,file 非 null(保留原文件信息)
resp_new = client.get(f"/api/v1/documents/{new_doc_id}")
body_new = resp_new.json()
assert body_new["code"] == 0
assert body_new["data"]["l1"]["text"] == FAKE_L1_SUMMARY
assert body_new["data"]["file"] is not None
assert body_new["data"]["file"]["filename"] == "re.md"
# 旧 chunks 已删:chunks 集合中 old_doc_id 点数为 0
old_count = await qdrant.client.count(
collection_name=COLLECTION_CHUNKS,
count_filter=qdrant.build_filter(doc_ids=[old_doc_id]),
exact=True,
)
assert old_count.count == 0
async def test_reingest_text_only_doc_returns_1001(
self,
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
admin_headers: dict[str, str],
) -> None:
"""纯文本入库的 doc_id → reingest → 1001(无原始文件)"""
qdrant, ingester, retriever = await _make_env()
manager = IngestTaskManager(ingester, None, Settings())
_patch_app(monkeypatch, manager, qdrant, retriever, tmp_path / "uploads")
with TestClient(app) as client:
# 纯文本入库(无原始文件落盘记录)
resp_doc = client.post(
"/api/v1/documents",
json={
"text": "# 纯文本\n这是一段纯文本入库的内容,用于测试 reingest 边界。",
"title": "纯文本",
},
headers=admin_headers,
)
assert resp_doc.status_code == 202
task_id = resp_doc.json()["data"]["task_id"]
final = await manager.wait_done(task_id)
doc_id = final["result"]["document_id"]
# reingest → 1001
resp_re = client.post(
f"/api/v1/documents/{doc_id}/reingest", headers=admin_headers
)
body = resp_re.json()
assert body["code"] == 1001
assert "无原始文件" in body["message"]
async def test_reingest_doc_not_found_returns_1004(
self,
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
admin_headers: dict[str, str],
) -> None:
"""不存在的 doc_id → reingest → 1004"""
qdrant, ingester, retriever = await _make_env()
manager = IngestTaskManager(ingester, None, Settings())
_patch_app(monkeypatch, manager, qdrant, retriever, tmp_path / "uploads")
with TestClient(app) as client:
resp = client.post(
"/api/v1/documents/nonexistent-doc/reingest", headers=admin_headers
)
body = resp.json()
assert body["code"] == 1004
async def test_reingest_file_deleted_returns_1004(
self,
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
admin_headers: dict[str, str],
) -> None:
"""文件被删(删 upload_dir 下落盘文件后)→ reingest → 1004"""
qdrant, ingester, retriever = await _make_env()
manager = IngestTaskManager(ingester, None, Settings())
_patch_app(monkeypatch, manager, qdrant, retriever, tmp_path / "uploads")
with TestClient(app) as client:
# 上传 1 个文件
resp_up = client.post(
"/api/v1/documents/upload-batch",
files=[("files", ("del.md", _md_content("Deleted"), "text/markdown"))],
headers=admin_headers,
)
assert resp_up.status_code == 202
tid = resp_up.json()["data"]["tasks"][0]["task_id"]
final = await manager.wait_done(tid)
doc_id = final["result"]["document_id"]
# 删除 upload_dir 下落盘的原始文件
meta = await qdrant.get_l1_metadata(doc_id)
assert meta is not None
raw_path = Path(meta["raw_file_path"])
assert raw_path.is_file()
raw_path.unlink()
# reingest → 1004(文件不存在)
resp_re = client.post(
f"/api/v1/documents/{doc_id}/reingest", headers=admin_headers
)
body = resp_re.json()
assert body["code"] == 1004
+49
View File
@@ -19,6 +19,8 @@ class FakeManager:
self.tasks = tasks or {} self.tasks = tasks or {}
self.task_id = task_id self.task_id = task_id
self.submitted: list[DocumentInput] = [] self.submitted: list[DocumentInput] = []
self.retry_result: str | None = None
self.delete_result: bool = False
async def submit(self, doc: DocumentInput) -> str: async def submit(self, doc: DocumentInput) -> str:
self.submitted.append(doc) self.submitted.append(doc)
@@ -27,6 +29,12 @@ class FakeManager:
async def get(self, task_id: str) -> dict[str, Any] | None: async def get(self, task_id: str) -> dict[str, Any] | None:
return self.tasks.get(task_id) return self.tasks.get(task_id)
async def retry(self, task_id: str) -> str | None:
return self.retry_result
async def delete(self, task_id: str) -> bool:
return self.delete_result
def _task(task_id: str, status: str, **extra: Any) -> dict[str, Any]: def _task(task_id: str, status: str, **extra: Any) -> dict[str, Any]:
"""构造一条任务记录(时间字段为固定 ISO8601 字符串)""" """构造一条任务记录(时间字段为固定 ISO8601 字符串)"""
@@ -159,3 +167,44 @@ def test_post_empty_text_creates_no_task(client: TestClient, monkeypatch: pytest
body = resp.json() body = resp.json()
assert body["code"] == 1001 assert body["code"] == 1001
assert manager.submitted == [] assert manager.submitted == []
def test_retry_task_returns_202(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
"""POST tasks/{id}/retryHTTP 202,返回新 task_id 与 pending 状态"""
manager = FakeManager()
manager.retry_result = "new-task-7"
_inject_manager(monkeypatch, manager)
resp = client.post("/api/v1/documents/tasks/t-failed/retry")
assert resp.status_code == 202
body = resp.json()
assert body["code"] == 0
assert body["data"]["task_id"] == "new-task-7"
assert body["data"]["status"] == "pending"
def test_retry_task_not_found(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
"""POST tasks/{id}/retrymanager 返回 None → code=1004"""
manager = FakeManager()
manager.retry_result = None
_inject_manager(monkeypatch, manager)
resp = client.post("/api/v1/documents/tasks/nope/retry")
body = resp.json()
assert body["code"] == 1004
def test_delete_task(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
"""DELETE tasks/{id}:删除成功 → deleted=true"""
manager = FakeManager()
manager.delete_result = True
_inject_manager(monkeypatch, manager)
resp = client.delete("/api/v1/documents/tasks/t-x")
body = resp.json()
assert body["code"] == 0
assert body["data"]["task_id"] == "t-x"
assert body["data"]["deleted"] is True
+143 -1
View File
@@ -7,8 +7,8 @@ from datetime import datetime
from typing import Any from typing import Any
from app.config import Settings from app.config import Settings
from app.core.dedup import DEDUP_KEY_PREFIX
from app.core.ingest_tasks import ( from app.core.ingest_tasks import (
DEDUP_KEY_PREFIX,
REDIS_KEY_PREFIX, REDIS_KEY_PREFIX,
IngestTaskManager, IngestTaskManager,
IngestTaskStatus, IngestTaskStatus,
@@ -91,6 +91,19 @@ class FakeRedis:
async def get_json(self, key: str) -> dict[str, Any] | None: async def get_json(self, key: str) -> dict[str, Any] | None:
return self.store.get(key) return self.store.get(key)
def _get_client(self) -> Any:
"""返回支持 scan_iter 的假客户端(供 list_tasks 扫描键)"""
store = self.store
class _FakeClient:
async def scan_iter(self, match: str = "*") -> Any:
prefix = match[:-1] if match.endswith("*") else match
for key in list(store.keys()):
if key.startswith(prefix):
yield key
return _FakeClient()
async def test_submit_returns_immediately_and_completes() -> None: async def test_submit_returns_immediately_and_completes() -> None:
"""submit 立即返回;任务后台跑完为 done,结果完整,阶段序列齐全,Redis 镜像同步""" """submit 立即返回;任务后台跑完为 done,结果完整,阶段序列齐全,Redis 镜像同步"""
@@ -317,3 +330,132 @@ async def test_dedup_lookup_failure_falls_back_to_normal_pipeline() -> None:
final = await manager.wait_done(task, timeout=5) final = await manager.wait_done(task, timeout=5)
assert final["status"] == IngestTaskStatus.DONE assert final["status"] == IngestTaskStatus.DONE
assert len(ingester.calls) == 1 assert len(ingester.calls) == 1
async def test_submit_records_filename_from_metadata_or_title() -> None:
"""submitfilename 优先 metadata.original_filename,其次 title"""
ingester = FakeIngester()
manager = IngestTaskManager(ingester, None, Settings())
# 有 original_filename
t1 = await manager.submit(
DocumentInput(text="x", title="t1", metadata={"original_filename": "report.pdf"})
)
await manager.wait_done(t1, timeout=5)
# 无 original_filename,回退 title
t2 = await manager.submit(DocumentInput(text="y", title="my-title"))
await manager.wait_done(t2, timeout=5)
items = await manager.list_tasks(limit=20)
by_id = {it["task_id"]: it for it in items}
assert by_id[t1]["filename"] == "report.pdf"
assert by_id[t2]["filename"] == "my-title"
async def test_list_tasks_merges_memory_and_redis_and_sorts() -> None:
"""list_tasks:合并内存与 Redis 镜像,去重,按 updated_at 降序"""
ingester = FakeIngester()
redis = FakeRedis()
manager = IngestTaskManager(ingester, redis, Settings())
# 内存任务 1
t1 = await manager.submit(DocumentInput(text="内容A", title="t1"))
await manager.wait_done(t1, timeout=5)
# 内存任务 2
t2 = await manager.submit(DocumentInput(text="内容B", title="t2"))
await manager.wait_done(t2, timeout=5)
# Redis 镜像中独有任务(不在内存里,模拟重启后只存在 Redis 的历史记录)
redis.store[f"{REDIS_KEY_PREFIX}redis-only-1"] = {
"task_id": "redis-only-1",
"status": "done",
"filename": "legacy.md",
"created_at": "2020-01-01T00:00:00+00:00",
"updated_at": "2020-01-01T00:00:00+00:00",
"result": {"document_id": "doc-legacy"},
"error": None,
}
items = await manager.list_tasks(limit=20)
# 共 3 条(内存 2 + Redis 独有 1,内存的 2 也已镜像到 Redis 但按 task_id 去重)
assert len(items) == 3
# 按 updated_at 降序:内存任务(当前时间)排在 Redis 旧任务前
ids = [it["task_id"] for it in items]
assert "redis-only-1" in ids
assert ids[-1] == "redis-only-1" # 最旧排最后
# Redis 独有任务提取 doc_id 与 filename
legacy = next(it for it in items if it["task_id"] == "redis-only-1")
assert legacy["filename"] == "legacy.md"
assert legacy["doc_id"] == "doc-legacy"
# 内存 done 任务也提取 doc_id
mem_item = next(it for it in items if it["task_id"] == t1)
assert mem_item["doc_id"] == "doc-1"
async def test_list_tasks_dedups_memory_and_redis_by_task_id() -> None:
"""list_tasks:内存与 Redis 都有的同一 task_id 仅保留内存版本(去重)"""
ingester = FakeIngester()
redis = FakeRedis()
manager = IngestTaskManager(ingester, redis, Settings())
task_id = await manager.submit(DocumentInput(text="唯一", title="t"))
await manager.wait_done(task_id, timeout=5)
# Redis 镜像中给同一 task_id 篡改一个旧 status,验证内存版本胜出
redis.store[f"{REDIS_KEY_PREFIX}{task_id}"]["status"] = "pending"
items = await manager.list_tasks(limit=20)
assert len(items) == 1
assert items[0]["task_id"] == task_id
assert items[0]["status"] == IngestTaskStatus.DONE # 内存版本(done
async def test_list_tasks_respects_limit() -> None:
"""list_taskslimit 截断返回条数"""
ingester = FakeIngester()
manager = IngestTaskManager(ingester, None, Settings())
for i in range(5):
t = await manager.submit(DocumentInput(text=f"c{i}", title=f"t{i}"))
await manager.wait_done(t, timeout=5)
items = await manager.list_tasks(limit=3)
assert len(items) == 3
async def test_list_tasks_without_redis_returns_memory_only() -> None:
"""Redis 不可用:list_tasks 仅返回内存任务"""
ingester = FakeIngester()
manager = IngestTaskManager(ingester, None, Settings())
t1 = await manager.submit(DocumentInput(text="x", title="t1"))
await manager.wait_done(t1, timeout=5)
items = await manager.list_tasks(limit=20)
assert len(items) == 1
assert items[0]["task_id"] == t1
assert items[0]["filename"] == "t1"
assert items[0]["doc_id"] == "doc-1"
async def test_list_tasks_extract_fields_for_in_progress_task() -> None:
"""进行中任务:doc_id 为 Nonedone 时才从 result.document_id 提取)"""
ingester = FakeIngester()
ingester.gate = asyncio.Event() # 阻塞任务使其停留在最后一个阶段
manager = IngestTaskManager(ingester, None, Settings(ingest_max_concurrency=1))
t1 = await manager.submit(
DocumentInput(text="x", title="t1", metadata={"original_filename": "a.txt"})
)
await asyncio.wait_for(ingester.started.wait(), timeout=1)
await asyncio.sleep(0.05)
items = await manager.list_tasks(limit=20)
assert len(items) == 1
item = items[0]
assert item["task_id"] == t1
# 任务已推进到 writing 阶段(gate 阻塞前最后一个 progress_cb
assert item["status"] == IngestTaskStatus.WRITING
assert item["doc_id"] is None # 未完成,doc_id 为 None
assert item["filename"] == "a.txt"
ingester.gate.set()
await manager.wait_done(t1, timeout=5)
Generated
+389
View File
@@ -159,6 +159,14 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/7b/0e/00cddd6b8668884e9c7588ab0eeb73becbd1efa3eaead34397f2e9a8de49/fastapi-0.140.7-py3-none-any.whl", hash = "sha256:960bb9696d8fd19dff488aa4f67f276364542cfcce9f7e68a82fe49dce126626", size = 131085, upload-time = "2026-07-27T17:34:47.036Z" }, { url = "https://files.pythonhosted.org/packages/7b/0e/00cddd6b8668884e9c7588ab0eeb73becbd1efa3eaead34397f2e9a8de49/fastapi-0.140.7-py3-none-any.whl", hash = "sha256:960bb9696d8fd19dff488aa4f67f276364542cfcce9f7e68a82fe49dce126626", size = 131085, upload-time = "2026-07-27T17:34:47.036Z" },
] ]
[[package]]
name = "flatbuffers"
version = "25.12.19"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e8/2d/d2a548598be01649e2d46231d151a6c56d10b964d94043a335ae56ea2d92/flatbuffers-25.12.19-py2.py3-none-any.whl", hash = "sha256:7634f50c427838bb021c2d66a3d1168e9d199b0607e6329399f04846d42e20b4", size = 26661, upload-time = "2025-12-19T23:16:13.622Z" },
]
[[package]] [[package]]
name = "grpcio" name = "grpcio"
version = "1.83.0" version = "1.83.0"
@@ -395,6 +403,86 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/78/f7/18a1afcd64f35314b68c1f23afcd9994d0bc13e65cc77517afff4e83986d/jiter-0.16.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64d613743df53199b1aa256a7d328340da6d7078aac7705a7db9d7a791e9cfd2", size = 343885, upload-time = "2026-06-29T13:05:12.087Z" }, { url = "https://files.pythonhosted.org/packages/78/f7/18a1afcd64f35314b68c1f23afcd9994d0bc13e65cc77517afff4e83986d/jiter-0.16.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64d613743df53199b1aa256a7d328340da6d7078aac7705a7db9d7a791e9cfd2", size = 343885, upload-time = "2026-06-29T13:05:12.087Z" },
] ]
[[package]]
name = "lxml"
version = "6.1.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/05/3b/aab6728cae887456f409b4d75e8a01856e4f04bd510de38052a47768b680/lxml-6.1.1.tar.gz", hash = "sha256:ba96ae44888e0185281e937633a743ea90d5a196c6000f82565ebb0580012d40", size = 4197430, upload-time = "2026-05-18T19:19:06.424Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6a/6e/c4add832b6fc1e887125b96f880d7b9b70aae5248718e046b1704bcac4b9/lxml-6.1.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:104c09bda8d2a562824c0e319d0768ce26a779b7601e0931d33b09b53c392ef7", size = 8570821, upload-time = "2026-05-18T19:17:42.068Z" },
{ url = "https://files.pythonhosted.org/packages/22/00/ff3009c88e65de8011630acf8ab5a09cb2becd2aaf47fba2f3449f6224e9/lxml-6.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:25c6997a9a534e016695a0ba06b2f07945de682731ff01065b6d5a4474179da1", size = 4624252, upload-time = "2026-05-18T19:17:47.897Z" },
{ url = "https://files.pythonhosted.org/packages/42/95/bb63f0fd62e554fe078e1fb3c8fe9083c14ddc7ad7fa178d10e57e071ac7/lxml-6.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c921ba5c51e4e9f63b8b00267d06566e1f63407408a0496da2d1d0bfc819c7fc", size = 4930746, upload-time = "2026-05-18T19:18:29.637Z" },
{ url = "https://files.pythonhosted.org/packages/eb/99/0013e8d9b5960f4f041cf0b73e2f80c23eb5205b1f7bfb20203243651359/lxml-6.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:54a7f95e4de5fb94e2f9f4b9055c6ba33bf3d628fd77a1d647c5923caa2cdcdc", size = 5093723, upload-time = "2026-05-18T19:18:34.168Z" },
{ url = "https://files.pythonhosted.org/packages/29/91/317b332636bfc7bddcff828d41b3307f50043f4b237e40849c333d80fa1a/lxml-6.1.1-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f2ec43df44b1f76249ee0a615334f9b5b060e1c8bd90e706dad2d14d02f383", size = 5005557, upload-time = "2026-05-18T19:18:39.798Z" },
{ url = "https://files.pythonhosted.org/packages/42/2f/cc9bf06afe70f9c9093ae60855d9759da9db601ec4080f7473319666ffd7/lxml-6.1.1-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:70ef8a7e102a1508f8121aae5b0867abd663f72c14f0a9c937e6554cb4587b7b", size = 5631036, upload-time = "2026-05-18T19:18:44.858Z" },
{ url = "https://files.pythonhosted.org/packages/08/f6/af32e23e563971ffb0fb86be52bc5be5c2c118858ffc119bf6a9039b173d/lxml-6.1.1-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ebe6af670449830d6d9b752c256a983291c766a1365ba5d5460048f9e33a7818", size = 5240367, upload-time = "2026-05-18T19:18:49.217Z" },
{ url = "https://files.pythonhosted.org/packages/78/83/8555d40948b09ce86f1bd0c68a7ac31d07b1929f92cc1b074006c97ef2d2/lxml-6.1.1-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:27acc820660aaffa4f7c087f29120e12980f7779d56d8492d263170111284740", size = 5350171, upload-time = "2026-05-18T19:18:52.779Z" },
{ url = "https://files.pythonhosted.org/packages/63/75/5d92da93729b7bad783689e6496049fa40927b45bec7bf183c981de3ca70/lxml-6.1.1-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:1db753c9115ec7100d073b744d17e25e88a8f90f5c39b2f5dd878149af59671f", size = 4694874, upload-time = "2026-05-18T19:18:55.139Z" },
{ url = "https://files.pythonhosted.org/packages/c5/b5/3aad415a9a25b822e783f15deeb4dffccf5113030f1afa2222dd929313d9/lxml-6.1.1-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c4f469aebd783bb741c2ecb2a681008fd26bfe5c16a9a72ed5467f834e810df2", size = 5244492, upload-time = "2026-05-18T19:19:01.28Z" },
{ url = "https://files.pythonhosted.org/packages/f1/a1/5fcf7eb9904b80086aa47dcf0027de07b1bb990afad2e6823144c368ae04/lxml-6.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:766b010012d59470072c1816b5b6c69f1d243e5db36ea5968e94accf430a4635", size = 5048232, upload-time = "2026-05-18T19:18:12.67Z" },
{ url = "https://files.pythonhosted.org/packages/77/74/1f601b63c7a69fcdf10fa9b148c81da8442204194f6c55509cc485c786b9/lxml-6.1.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b8d812c6011c08b8111a15e54dd990b8923692d80adf35488bee34026c35accf", size = 4777023, upload-time = "2026-05-18T19:18:15.928Z" },
{ url = "https://files.pythonhosted.org/packages/a2/b9/7a78f51aec95b1bf780d78e12705a9f6533284f8693dc5c0e6724fa53d3f/lxml-6.1.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:fe0306bd29505a9177aac19f1877174b0e7422c222a59f70b2cd41633448c3dc", size = 5645773, upload-time = "2026-05-18T19:18:23.223Z" },
{ url = "https://files.pythonhosted.org/packages/a5/6e/98a7b7ad54e4e74fa1f20fff776913980619d0ebe5558232d7da6580bdd8/lxml-6.1.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5ba186ad207446c65d3bb3d3e0412b032b1d9f595e59861e2354798c5703d955", size = 5233088, upload-time = "2026-05-18T19:18:31.433Z" },
{ url = "https://files.pythonhosted.org/packages/65/d1/bc0ed2427bf609f2ee10da303a6a226f9c8bce94f945dc29a32ce55de6e4/lxml-6.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:aa366a1e55b8ebfe8ca8ddc3cfe75c8ebade181aeb0f661d0cb05986b647f72a", size = 5260995, upload-time = "2026-05-18T19:18:37.091Z" },
{ url = "https://files.pythonhosted.org/packages/69/8b/6772e1a4b513fc50a8d931f19edde0e13ae6918510a1e13ff67864f3e5ed/lxml-6.1.1-cp312-cp312-win32.whl", hash = "sha256:126c93f7f56f0eda92f6d8c619edc463a4f23d9252f1c9d0405a76f25fa9f11a", size = 3596382, upload-time = "2026-05-18T19:17:18.37Z" },
{ url = "https://files.pythonhosted.org/packages/1b/89/45198e9624762af2dfd2cb8782598477ceb29f6e59caab560388ae1f4ec1/lxml-6.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:26e6eda8d38c1fcab1090dd196ee87cbd13788e531937610e2589085de074e77", size = 3997255, upload-time = "2026-05-18T19:17:56.781Z" },
{ url = "https://files.pythonhosted.org/packages/90/a9/7a54b6834088d9ae528a7b780584ba6a39a9457b0ac330479f20ffbc9449/lxml-6.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:6540377fbd53fe1b629172288c464fb18db11ce1fa7dc15891da10aa9dcc3e7f", size = 3659610, upload-time = "2026-05-19T19:22:50.843Z" },
{ url = "https://files.pythonhosted.org/packages/a5/eb/7e6f37c5584ccbb2ff267f56fd0339016938c1c8684cfefab9b33ffc2f36/lxml-6.1.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:68a9198d0fc122d14bb76837de9aa80cf84caed990b5b237f532ed87d3706736", size = 8559780, upload-time = "2026-05-18T19:17:57.661Z" },
{ url = "https://files.pythonhosted.org/packages/a1/36/587c2521cf23a2cd6c9c22108aa7528f683a1f195ed7ccd23a4b1786ad36/lxml-6.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7d47866cb32fb503450b6edc9df355d10dc49836af2e89901bd6ac6b0896d9d9", size = 4618006, upload-time = "2026-05-18T19:18:04.452Z" },
{ url = "https://files.pythonhosted.org/packages/6e/ca/ab7bfe2bf4c972af5e7878262845ead3a24a929a9b04bc11c7c1ece6c82a/lxml-6.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb7c9811bfaa8b1ed5ed319f5d370dfbcaa59d52ea64be2a5a85e18195930354", size = 4924139, upload-time = "2026-05-18T19:19:04.873Z" },
{ url = "https://files.pythonhosted.org/packages/6b/55/a0c72851dfee5ecc689f949723a73dea457758912542cb955b108eaf0d8f/lxml-6.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:762ff394d5bd56da0cf034a23dcce4e13923f15321a2adfa2ac00201dc6d3fca", size = 5082329, upload-time = "2026-05-18T19:19:09.728Z" },
{ url = "https://files.pythonhosted.org/packages/f0/b6/0608f7d61a3b96cc67e5648a3d906e31a5082093e10e7be65b3886289938/lxml-6.1.1-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a088f287f7d8275a33c07f2cac6c50b9319309a0200a39e7e75d80c707723099", size = 4993564, upload-time = "2026-05-18T19:19:13.608Z" },
{ url = "https://files.pythonhosted.org/packages/4c/66/ae227524b066d29d55bf0b453d93d2d793c40218657d643dcbbca13b8faf/lxml-6.1.1-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e902da4b04e6b52e5893900d4b8ab46068f75f3561f01bf1080957f9fd932ed6", size = 5613467, upload-time = "2026-05-18T19:19:16.228Z" },
{ url = "https://files.pythonhosted.org/packages/a6/76/dbe4a00b50385e40194231dcfe5a12c059de7cf90e89c83407d2b085b719/lxml-6.1.1-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1d4962d4c66bf830a7e59ed6cfc17d148149898a3aefa8ec6e59763e6e3ed085", size = 5228304, upload-time = "2026-05-18T19:19:19.354Z" },
{ url = "https://files.pythonhosted.org/packages/1c/01/00b1b8442ed2041793336868ba0b9ea4b13d7da7c085c6404c207a63bf79/lxml-6.1.1-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:581d4c8ae690a6609e64862dd6b7c2489635c2d13907fc2b20f2bc200ff1d21e", size = 5341607, upload-time = "2026-05-18T19:19:22.297Z" },
{ url = "https://files.pythonhosted.org/packages/63/36/1ad29931e9a4638bb707869f01d423a6c815f82152138d1a40dfcfde2b95/lxml-6.1.1-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:876e1ff5930ed8bf295ec5ef9a8155e9b6b1876bbf1deed8b3a8069311875a8f", size = 4700168, upload-time = "2026-05-18T19:19:25.133Z" },
{ url = "https://files.pythonhosted.org/packages/3c/d1/a9536cecf9be18a0dc72d32bead283a2332d1ffebd2dd3ac70ce444686e5/lxml-6.1.1-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9eb9b5a968f6e0f6d640092a567e14529ff8cea2e29d00da6f78a79fa49f013c", size = 5232487, upload-time = "2026-05-18T19:19:28.603Z" },
{ url = "https://files.pythonhosted.org/packages/0e/77/b4fb1e03bf5d130e879214d3100092e386418807fb74dd0adc4b0a48f351/lxml-6.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:aa49e06d94aba782c6a02eecb7e507969e7e7a41b267f1b359bb35585f295d5b", size = 5044231, upload-time = "2026-05-18T19:18:42.246Z" },
{ url = "https://files.pythonhosted.org/packages/26/4c/d00daeeb0a5530c4028a9232aa1b93db3ef4ed2158c116ea73c79a9765b3/lxml-6.1.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:70cdfd80589d59e43e18005dd7244e8895e93db8ab6a620b7e23df5445a4e3d2", size = 4769450, upload-time = "2026-05-18T19:18:48.013Z" },
{ url = "https://files.pythonhosted.org/packages/ed/6a/715a3a8d156ce42f29cf014706f5410c2ff3b02267774110fc23266409fe/lxml-6.1.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:aad9aa39483ed8ec44d6d2e59e5b98a0d80676ef0d92f44bfc374836111f62f5", size = 5635874, upload-time = "2026-05-18T19:18:51.914Z" },
{ url = "https://files.pythonhosted.org/packages/45/37/0544bc21dde2a88f3a17b504e6fc79c0e01d25a33c2f6079724e9e72b9c7/lxml-6.1.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:d49514be2f28d895c38cf9d2b72d7b9a07d00314519f456c0b50b53cfcf4c785", size = 5223987, upload-time = "2026-05-18T19:18:59.715Z" },
{ url = "https://files.pythonhosted.org/packages/4d/f8/f6a5e8185bcb28c2befae3d31f8e3df3b811cb0f47746517a81279fcafe1/lxml-6.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:47402e62c52ff5988c1e8c6c63177f5708bccf48e366dea4e3dcf1e645e04947", size = 5250276, upload-time = "2026-05-18T19:19:03.834Z" },
{ url = "https://files.pythonhosted.org/packages/c7/f2/1a2b9f1b7a49d45495369be7ef9ad05b262930f2eab3e3145706fca8083f/lxml-6.1.1-cp313-cp313-win32.whl", hash = "sha256:3483644525531e1d5762b0c44a8e18b6efba321b6dcf8a8952de10b037618bca", size = 3596903, upload-time = "2026-05-18T19:17:29.863Z" },
{ url = "https://files.pythonhosted.org/packages/e6/99/f4ffb024f238eec2131aaa09f3278fb6129cf892741bf68e1fc1afb8c100/lxml-6.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:a10bd2fd62e8ce916ececb342f348f190724a098c1faa056fdfb2a22ad5e8660", size = 3995869, upload-time = "2026-05-18T19:18:02.596Z" },
{ url = "https://files.pythonhosted.org/packages/d1/53/70eb8c5c6037f27448f1e3c54ebede9545a801ae63f0a7254afca4fe8e45/lxml-6.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:424aa57aca0897eb922aef34395bd1289b3b6f04e6bae20ea123c0c7e333cffc", size = 3658490, upload-time = "2026-05-19T19:22:53.846Z" },
{ url = "https://files.pythonhosted.org/packages/13/e2/2e325795566de01d0d7c3bb57d3c370616b2d07b01214e84eec5d3b10963/lxml-6.1.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:19b7ab10b210b0b3ad7985d9ac4eb66ab09a90b20fe6e2f7ba55d01a234345d0", size = 8577146, upload-time = "2026-05-18T19:18:17.765Z" },
{ url = "https://files.pythonhosted.org/packages/93/cf/5630b5e4be7d2e6bee8efe83865c925221103cf0221303b104ce134b01e2/lxml-6.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c08e5c694306507275f2290073350c4f32e383db15213b2c69e7ff39c1193840", size = 4623866, upload-time = "2026-05-18T19:18:30.669Z" },
{ url = "https://files.pythonhosted.org/packages/d2/51/3904907c063451cf8d4a5c9fe0cad95fa1f4ec57f4e3884fa0731bd7a305/lxml-6.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:74a9717fd0d82effef5c2854f0d917231d5324b5a3eb7275c43ac9fa32f97a14", size = 4950022, upload-time = "2026-05-18T19:19:31.958Z" },
{ url = "https://files.pythonhosted.org/packages/94/cd/9c7611a51c37a2830928405817cc5d56a97f64fab83cc3f628748b135749/lxml-6.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:efe0374196335f93b53269acd811b944f2e6bdc88e8894f214bd636455484909", size = 5086695, upload-time = "2026-05-18T19:19:34.764Z" },
{ url = "https://files.pythonhosted.org/packages/da/d6/24e3b5906abb0b674ff2ae195bc3ce59708df2bcd17cf17703b2d7dd643a/lxml-6.1.1-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac931cdc9442c1763b8a8f6cd62c0c938737eafc5be75eff88df55fc73bc0d00", size = 5031642, upload-time = "2026-05-18T19:19:37.771Z" },
{ url = "https://files.pythonhosted.org/packages/2d/db/6ec54f99019838bff54785c51da07f189eb4676861c5f2730962b0d8d665/lxml-6.1.1-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:aee395f5d0927f947758b4ec119fd5fc8ec71f07a1c5c52077b30b04c0fa6955", size = 5647338, upload-time = "2026-05-18T19:19:40.553Z" },
{ url = "https://files.pythonhosted.org/packages/42/3d/ef4dcfffd22d27a61805d8ed9f7fb888495bc6aa88648fa07c1eaa5586b6/lxml-6.1.1-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9395002973c827b3ed67db77e6ec09f092919a587022174554096a269378fb13", size = 5239528, upload-time = "2026-05-18T19:19:43.657Z" },
{ url = "https://files.pythonhosted.org/packages/62/bb/37fb3f0dff146bdcfa78eec47879273820b2a0bf350ec236ce14bd0b1c26/lxml-6.1.1-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:73bc2086f141224ebddb7fc5c6a36ca58b31b94b561e1dfe8e073e3270fad1e7", size = 5350730, upload-time = "2026-05-18T19:19:46.307Z" },
{ url = "https://files.pythonhosted.org/packages/90/42/43253f168388df4fae1f38c01df36ddb9bee39e2048167b54cdcbae85ea3/lxml-6.1.1-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:3779def59032b81e44a5f70096ef6bf2082f8d901937dca354474ba09782e245", size = 4697530, upload-time = "2026-05-18T19:19:49.889Z" },
{ url = "https://files.pythonhosted.org/packages/eb/a8/c5a8504f81bbdfc8e7094c2c850cdb4ed6777fc4d5ddd9e5ab819f3b0d54/lxml-6.1.1-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:86c89b9d55ebf820ad7c90bc533410f0d098054f293351f10603c0c46ff598f5", size = 5250670, upload-time = "2026-05-18T19:19:53.199Z" },
{ url = "https://files.pythonhosted.org/packages/77/b7/c7e76ab18744d75e21f320ebf9ff9d1ceae2b54dd431ea5a64caf26c9672/lxml-6.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19607c6bbff2a44cf3fe8250abccd20942d3462473e0a721d01d379ed017e462", size = 5084485, upload-time = "2026-05-18T19:19:08.422Z" },
{ url = "https://files.pythonhosted.org/packages/31/31/b35c53f8ef7b7c31cacd23d3638652fff7bcd1deb6eedb709ab43b685908/lxml-6.1.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:c6ed5141a5c7507cf3ee76bd363b0d6f801e3321adc35b5d825a23115faa5465", size = 4737635, upload-time = "2026-05-18T19:19:12.321Z" },
{ url = "https://files.pythonhosted.org/packages/d9/06/31f23c813a7fe8e0cb1b175e915b08c9bf4e86d225b210feadbdbe519667/lxml-6.1.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:62aeb7e85b5d60320b9d77eef2e773994e2c0ce10121b277e0a19804e1654a5a", size = 5670681, upload-time = "2026-05-18T19:19:15.001Z" },
{ url = "https://files.pythonhosted.org/packages/1a/bc/ce619bccc89b1fd9ad8a8e1330ee3f3beff9f2ff95b712d7bbcdd6e22fc3/lxml-6.1.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:b1b963fd8f5caa68e99dfae060d54de1fe9cba899b8718b44a00cdca53c3e590", size = 5238229, upload-time = "2026-05-18T19:19:18.131Z" },
{ url = "https://files.pythonhosted.org/packages/2f/5d/b329acbbedc0b619ebc2be6cf7ee9ed07e80892c88d4dfd612c33805789a/lxml-6.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:63876be28efefa04a1df615b46770e82042cce445cfdce55160522f57b231ccb", size = 5264191, upload-time = "2026-05-18T19:19:21.118Z" },
{ url = "https://files.pythonhosted.org/packages/d6/85/be36fb1425b30db3c3f9df75fe86343ebffb79e6320bd7f588e25bfeac39/lxml-6.1.1-cp314-cp314-win32.whl", hash = "sha256:7f7a92e8583f06b1fd49d01158143b8461cfcd135dcb10ec807270a3051bd603", size = 3657202, upload-time = "2026-05-18T19:17:39.509Z" },
{ url = "https://files.pythonhosted.org/packages/b8/ce/3cf9a827342269f54d405a6202397de63f07c69cbd6ce7d183a3f0cba1e9/lxml-6.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:b2d444f2e66624d68e9c6b211e28a76e22fff5fcabcfff4deac18b529b7d4137", size = 4064497, upload-time = "2026-05-18T19:18:14.662Z" },
{ url = "https://files.pythonhosted.org/packages/d9/3e/1a957bde8f0760039e627f94699f82caa782c9d838d86c3d28245ee67212/lxml-6.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:3fd9728a2735fda14f4e8235830c86b539e9661e849665bf926d3f867943b4bf", size = 3741991, upload-time = "2026-05-19T19:22:59.111Z" },
{ url = "https://files.pythonhosted.org/packages/78/b2/00ed55b3a2efa4658fb795c38d1090ec9b3e8a6c3683d4441fa517f09c3b/lxml-6.1.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:787b2496d0dbe8cd180984e8d29e3a6f76e7ea34db781cb3bd55e4ba1ef8b4ee", size = 8827545, upload-time = "2026-05-18T19:18:41.193Z" },
{ url = "https://files.pythonhosted.org/packages/c0/73/74573db19baa618d5f266f2407898b087ff6927115b00b71e5fc1b700847/lxml-6.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2c8daa471358dc2d6fcf02165e80ec68f77871a286df95bc5cc3816153b0fd2c", size = 4735736, upload-time = "2026-05-18T19:18:46.761Z" },
{ url = "https://files.pythonhosted.org/packages/16/02/6f7061f4f95f51e545d48e87647c54791d204a4e881be4156e7a26ba5338/lxml-6.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:acd7d70b64c0aae0c7922cca83d288a16f5f6da523637697872253415269baef", size = 4970291, upload-time = "2026-05-18T19:19:56.215Z" },
{ url = "https://files.pythonhosted.org/packages/b0/02/55fc057d8283427dea7d6edb102e7a840239c77a64a983d92f62a304c0e9/lxml-6.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4f0dd2f01f9f8a89f565d000e03abcf0a13d692a346c8d22f628d49af098777a", size = 5102822, upload-time = "2026-05-18T19:19:59.223Z" },
{ url = "https://files.pythonhosted.org/packages/e4/48/8e1cf78d89d66850121d9255a2a24414c98f775da93b90cf976956c24b14/lxml-6.1.1-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b7e8a14c8634bf6f7a568634cb395305a6d964aeb5b7ee32248094bed3a7e2c", size = 5027923, upload-time = "2026-05-18T19:20:01.549Z" },
{ url = "https://files.pythonhosted.org/packages/ed/00/0632a0647612c8af24d26997b3b961397daa9d5b2581444805933629a4cb/lxml-6.1.1-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:86281fbdd6a8162756f8d603f37e3435bfa38043adb79c6dc6a2dfee065e7525", size = 5595843, upload-time = "2026-05-18T19:20:03.93Z" },
{ url = "https://files.pythonhosted.org/packages/bc/86/ab008a7dc360711b66858d61c80a5979a70a09f2aa2b05d9698df80b803d/lxml-6.1.1-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5d7152ec39ca7c402d8fb9bad86140a15b9503bd0c54484e3f1bbe3dd37ceca", size = 5224515, upload-time = "2026-05-18T19:20:06.381Z" },
{ url = "https://files.pythonhosted.org/packages/75/c6/2702ff375e728e34f56d9a45339a9cf7e4427e917f542225242d63a05afa/lxml-6.1.1-cp314-cp314t-manylinux_2_28_i686.whl", hash = "sha256:88d8cb75b9d82858497a5393e3c63cfbf03035225e4b35a49ed7ccb151e4dc0e", size = 5312511, upload-time = "2026-05-18T19:20:09.308Z" },
{ url = "https://files.pythonhosted.org/packages/b7/57/a5807c98f87a86f10ef9ffab35516df7c0f0c4b6d5d33e9f608ab9c04a31/lxml-6.1.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f64ec5397ea6a41fc1b4af0380d79b44a755b5531dcaccd9940fb260dca93038", size = 4639206, upload-time = "2026-05-18T19:20:11.704Z" },
{ url = "https://files.pythonhosted.org/packages/1f/e1/8a0a2c35734812395f4da4eaf33748a7e5705bfb2a58b128da764339d5ec/lxml-6.1.1-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d34bbf07dbc7ca5970671b1512e928991fb5e9d95365636c9b2d8b4f53af405e", size = 5232404, upload-time = "2026-05-18T19:20:14.064Z" },
{ url = "https://files.pythonhosted.org/packages/c2/e2/0e6a4dd5ad84d01d99aa7bae7cfefd4a760a0e0f8176818241de17d9b6c0/lxml-6.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:17e0e18d4ad8adbd0399291bc44845b69d9dd68439a3cdebdf35ff902ec05072", size = 5083769, upload-time = "2026-05-18T19:19:23.758Z" },
{ url = "https://files.pythonhosted.org/packages/a0/7e/161f33d463f6ffc1c7679104b65086dea120080d49dde4d238f015aaee2f/lxml-6.1.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:3ab541146f1f6968c462d6c2ac495148e8cdba2f8347700b2141b6ec5a75bf52", size = 4758936, upload-time = "2026-05-18T19:19:27.256Z" },
{ url = "https://files.pythonhosted.org/packages/f1/fb/2369825e3f6ca99305bf9f7b7085fda91c8b0922a89e54d900974aa3ef85/lxml-6.1.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2a0217714657e023ef4293500f65aa20fce6164c8fd6b08fa5bd4a859fb14b9b", size = 5620296, upload-time = "2026-05-18T19:19:29.993Z" },
{ url = "https://files.pythonhosted.org/packages/30/90/d61e383146f74c5ab683947ea14dc7b82778838ab9b95ea73a23b60d0191/lxml-6.1.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:05a82eb6e1530a64f26225b55cbd178113bd0b5af1c2b625f25e5296742c26d2", size = 5228598, upload-time = "2026-05-18T19:19:33.523Z" },
{ url = "https://files.pythonhosted.org/packages/76/2d/2dafd8149e94b05bb070690efd5bb2680720681e03ff03fc57d2b70a1105/lxml-6.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9e36f163528fc50cbef305f02a5fd66d404edf7049cdaff211dbc2cba5a7013e", size = 5247845, upload-time = "2026-05-18T19:19:36.649Z" },
{ url = "https://files.pythonhosted.org/packages/ce/68/b30e913340c380ddac9580c6e6230991fc37240ec4f64704833e4f3e2769/lxml-6.1.1-cp314-cp314t-win32.whl", hash = "sha256:649dda677cf3bd6ac9ae14007ba0c824ded8ce5808b53fc7431d9140399118c1", size = 3897345, upload-time = "2026-05-18T19:17:33.562Z" },
{ url = "https://files.pythonhosted.org/packages/3c/4e/9eb2af5335545f9fbcd7af57bcf87c6025d31eaa31b14ec184a6c8675328/lxml-6.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:793033d6c5cdf33a573f910d9bea14ef8f5771820411d118da8e1182edb53d5e", size = 4393350, upload-time = "2026-05-18T19:18:10.076Z" },
{ url = "https://files.pythonhosted.org/packages/7f/2c/0f1e93c636720e8a3eb59af2bfda99d98b55891e1c53bc30c2e0e865f01b/lxml-6.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:58bb955caba94e467d2a96da17660d2d704e0675894cba21ab8a775b8621fd1c", size = 3817223, upload-time = "2026-05-19T19:22:56.823Z" },
]
[[package]] [[package]]
name = "numpy" name = "numpy"
version = "2.5.1" version = "2.5.1"
@@ -446,6 +534,38 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a1/5a/4d2b1601df3602dba7a14f3348ba9bfe94a18adb428e693df6154c293831/numpy-2.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:5a6db61f9aaa57e369905c67d852045d3c4f7126405b29d09b19dec118e9c9cb", size = 10697674, upload-time = "2026-07-04T17:07:58.506Z" }, { url = "https://files.pythonhosted.org/packages/a1/5a/4d2b1601df3602dba7a14f3348ba9bfe94a18adb428e693df6154c293831/numpy-2.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:5a6db61f9aaa57e369905c67d852045d3c4f7126405b29d09b19dec118e9c9cb", size = 10697674, upload-time = "2026-07-04T17:07:58.506Z" },
] ]
[[package]]
name = "onnxruntime"
version = "1.28.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "flatbuffers" },
{ name = "numpy" },
{ name = "packaging" },
{ name = "protobuf" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/98/f8/dcbe7700dca82fa540035abd3c868fe5ad0f86af00b9a3db7c2e27d15c7d/onnxruntime-1.28.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:26ff0fdd06efb6c155bae95387a09db1a2be89c7a03e4d0bffd5a171cc2826da", size = 19141362, upload-time = "2026-07-25T01:22:36.965Z" },
{ url = "https://files.pythonhosted.org/packages/28/5b/1d77e62097fdbe07e2dc827f389b1c4c0c275f6fab0369a8f46d2461af27/onnxruntime-1.28.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e81a23df16e7acb9d51b06d30cc098e49315ef9180f97bc2221d167b4b04d9c", size = 17050628, upload-time = "2026-07-25T01:21:40.481Z" },
{ url = "https://files.pythonhosted.org/packages/95/df/5486ab03e9be288d5268867054c8b04bebcf95bfd12e801c05cc67703dab/onnxruntime-1.28.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0a83bdb70d143cede762b677789bf2a7acca54b3fb82565601d5c30695aa933c", size = 19214257, upload-time = "2026-07-25T01:22:01.695Z" },
{ url = "https://files.pythonhosted.org/packages/3e/3b/986ca67c274932ba9ac5332fb10de56f643dfd433c74e33f8ae8f847cf24/onnxruntime-1.28.0-cp312-cp312-win_amd64.whl", hash = "sha256:c35064f9b3c43c81c5d5d282091401d0f1ff22796d93ccade4ea2ece5e137ab8", size = 13755036, upload-time = "2026-07-25T01:22:26.89Z" },
{ url = "https://files.pythonhosted.org/packages/1d/46/059dba81d46c6ba88e0c2d1c64321ac8098847d678423300a183d42ecbd6/onnxruntime-1.28.0-cp312-cp312-win_arm64.whl", hash = "sha256:e02feeb0165c5f13b4cc954738078d59b90128516ac12b671ee24a530242bf02", size = 13454462, upload-time = "2026-07-25T01:22:17.38Z" },
{ url = "https://files.pythonhosted.org/packages/9c/12/3807e2b17d9eb71d3cb78ed2ba76869b05c637c9b9d6112e636098b0c97a/onnxruntime-1.28.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:31410f544674f534c2f27348af52ef81682ca9c8719154bf4d48f0ef23823b1e", size = 19141759, upload-time = "2026-07-25T01:21:53.765Z" },
{ url = "https://files.pythonhosted.org/packages/c0/23/b46045c3bf67a9cf54c12f5df0f018a422c65fbb9d6072b10071bebfaae2/onnxruntime-1.28.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f649dd6f6452d12a8059888aa489fe519e062e18793dac72b9efa0f9fdb64135", size = 17049339, upload-time = "2026-07-25T01:21:43.005Z" },
{ url = "https://files.pythonhosted.org/packages/78/b6/8c5396e7894e77c5a7d1e026f3acb9dd39c4b5644e412e37a0055eaa3bc5/onnxruntime-1.28.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:54fa221d669282bd8f582708ce4c96010a7e9fb0661f9006b37fe2fedafb73fe", size = 19214329, upload-time = "2026-07-25T01:22:04.133Z" },
{ url = "https://files.pythonhosted.org/packages/56/f1/51225c202edba4dfc94e1ea03f3d78f1aaf307da75fd792c0ce1946b2514/onnxruntime-1.28.0-cp313-cp313-win_amd64.whl", hash = "sha256:1a1a19175464665c9b8d50bc916f216cc0b569110045b7bbca8f9f290b186f58", size = 13755033, upload-time = "2026-07-25T01:22:29.302Z" },
{ url = "https://files.pythonhosted.org/packages/f4/db/f59f715edfdd96a051f32b5ef0e680a20a8755d4ecd75f63090e960e347a/onnxruntime-1.28.0-cp313-cp313-win_arm64.whl", hash = "sha256:cfab507abe09d6ffeb817eee07944d452fdc0b00fdcef34cab4db10a45e378c7", size = 13454175, upload-time = "2026-07-25T01:22:19.912Z" },
{ url = "https://files.pythonhosted.org/packages/47/28/810314fa88647af9f4cdaf438a30ad1cfebebb53ded55499232d7a0094e6/onnxruntime-1.28.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac301f53b1930402fc46c368e268acfed02f3207272aaff05070d7e09f96f031", size = 17057307, upload-time = "2026-07-25T01:21:45.492Z" },
{ url = "https://files.pythonhosted.org/packages/3d/cc/9e9f193cc0f29f263a8f09ec08487aed6c96ee856d5fd77da32a425c1949/onnxruntime-1.28.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7f022a1103cae591c75fc4565589a515f2ddd14a6ac8e8a05812dfeda142e28", size = 19222954, upload-time = "2026-07-25T01:22:06.952Z" },
{ url = "https://files.pythonhosted.org/packages/4e/eb/952314c451d9463e5c9aed9978eec76cf32930d407d9ab8700dd0f4ea1ea/onnxruntime-1.28.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:8adff67a3f28257b37cfe945a7e952e4122666aa8c91a0380862e9fd4c2ed19f", size = 19143748, upload-time = "2026-07-25T01:21:56.297Z" },
{ url = "https://files.pythonhosted.org/packages/3f/e9/139180b4dd810329aaa42c238b4e6383c906202d98609ae29d66eb7c32b1/onnxruntime-1.28.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bc2565e487b4896fb988d6383577d875d958e071fc5f6c3550bd5d02ae98264b", size = 17051950, upload-time = "2026-07-25T01:21:48.606Z" },
{ url = "https://files.pythonhosted.org/packages/03/88/9432428273356ad3c8aa01f52c1b3e7f53c4c0192748f41ad983872b436b/onnxruntime-1.28.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6afdc83f1317c136e92fc29f5ee9f058de59d87c0b22cee3fdbfbaa0ccc2098a", size = 19214924, upload-time = "2026-07-25T01:22:09.727Z" },
{ url = "https://files.pythonhosted.org/packages/bb/e2/6feb3a43517aaf2b1bf7e46897ba5eb81a29717f7d7901420614d5ee4653/onnxruntime-1.28.0-cp314-cp314-win_amd64.whl", hash = "sha256:f2a3b9e30ce880d4ca54999cb313569e36da4f62eefe25f87be18f43e9a3a4d5", size = 14093738, upload-time = "2026-07-25T01:22:31.629Z" },
{ url = "https://files.pythonhosted.org/packages/fc/8f/83974a1e201dc2e58e5e7111bcaeb1ca2413e9c41f505d26419ee9e3dddf/onnxruntime-1.28.0-cp314-cp314-win_arm64.whl", hash = "sha256:07fb3cbe990d6bf0ab3c22bfbbfb0e314151266046ea6edb4a07f556b4258c5f", size = 13821117, upload-time = "2026-07-25T01:22:22.387Z" },
{ url = "https://files.pythonhosted.org/packages/0d/83/00e606bc25c756d76a267370c39b7516ad52f9cf134d7ff2bff8b6108bc4/onnxruntime-1.28.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e562d6e36a749f6764481c0ddb0f2af3d0b5a3c164291361d08803c557f369af", size = 17055518, upload-time = "2026-07-25T01:21:51.08Z" },
{ url = "https://files.pythonhosted.org/packages/94/a9/68707e1ce345cbdbcd4df65932ebc82a673e917d63eda0007ebcff948691/onnxruntime-1.28.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f6e92367ddce1e4d33cf295024f40192be6c6171a09208f515ba169ced06c8e", size = 19222976, upload-time = "2026-07-25T01:22:12.474Z" },
]
[[package]] [[package]]
name = "openai" name = "openai"
version = "2.49.0" version = "2.49.0"
@@ -465,6 +585,25 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b1/ca/53357e460a1172e831ecbe43dd0c37342b7211a1eb09f4cf21a412adbbdf/openai-2.49.0-py3-none-any.whl", hash = "sha256:b694201eaa42a1ccf2aa125fe29458150108fb22df1abfb55d7188599da81d8c", size = 1648589, upload-time = "2026-07-27T22:51:38Z" }, { url = "https://files.pythonhosted.org/packages/b1/ca/53357e460a1172e831ecbe43dd0c37342b7211a1eb09f4cf21a412adbbdf/openai-2.49.0-py3-none-any.whl", hash = "sha256:b694201eaa42a1ccf2aa125fe29458150108fb22df1abfb55d7188599da81d8c", size = 1648589, upload-time = "2026-07-27T22:51:38Z" },
] ]
[[package]]
name = "opencv-python"
version = "5.0.0.93"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "numpy" },
]
sdist = { url = "https://files.pythonhosted.org/packages/79/4c/a438d23e09ce2033c09f7b784ad2fbdb0adf529e434101ed28f142226f98/opencv_python-5.0.0.93.tar.gz", hash = "sha256:66aac3e5b5faa48d4025816592f3af19e4bfc2c68dec067bae2dbb4ca10aa9e2", size = 81802749, upload-time = "2026-07-02T06:59:53.815Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9c/75/76f6ade78f6102c61034f828e2a22616708df2c9504bc8d6af9dd8f73dc5/opencv_python-5.0.0.93-cp37-abi3-macosx_13_0_arm64.whl", hash = "sha256:198a75138241810206a17c829dbcc40a7cb1841cda538ca86cbbfc6c7d95f898", size = 48322443, upload-time = "2026-07-02T05:50:25.466Z" },
{ url = "https://files.pythonhosted.org/packages/15/8c/bc1bda6aae69a32e9d84fc34153ba104cd25226861eb4aea33b2cea4860d/opencv_python-5.0.0.93-cp37-abi3-macosx_14_0_x86_64.whl", hash = "sha256:6bbc32f59e1b1a7db7b39c81f63d00625f041d333037fd8702f6da52cc39108b", size = 34782755, upload-time = "2026-07-02T05:51:30.556Z" },
{ url = "https://files.pythonhosted.org/packages/f4/8a/b04776ec45d2dea08a1b176f1829201db3515d4ed16c35f8fcc9fa7beb16/opencv_python-5.0.0.93-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e2b4272e736836f66c2d176e43ab8101f3a00d45654916399f52e150c58981ac", size = 50614064, upload-time = "2026-07-02T06:53:22.604Z" },
{ url = "https://files.pythonhosted.org/packages/95/54/eb47866b94f2b5b42dde17644b78055ef1ee05aae59962c7290e55270803/opencv_python-5.0.0.93-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f8b6d0a212253dd26ad338c812f1f23ca118fdf05a9c8c6b9444f161aa8c5881", size = 71064711, upload-time = "2026-07-02T06:54:13.148Z" },
{ url = "https://files.pythonhosted.org/packages/93/da/962579f1e703cbf8c5422fd1f576467dcb3b5b0b0b81c1471c979764353a/opencv_python-5.0.0.93-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:08d5d91d967b58d6db86073b2ad3eaef88ca4ebdfd45c9059bf59f5ded0c7ad2", size = 49798576, upload-time = "2026-07-02T06:54:33.781Z" },
{ url = "https://files.pythonhosted.org/packages/cf/4c/c73f828fdbcd37eaf21d08fa852544a3ca7c2dbb3ea76873d64f2ea413d1/opencv_python-5.0.0.93-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:c8de2dec111122a02e8beb28e16c31904992dfd6186560b142a92c71403c1039", size = 73783032, upload-time = "2026-07-02T06:55:03.415Z" },
{ url = "https://files.pythonhosted.org/packages/e2/4b/edaf83b996ca5a1a3d8ccad485706b9c6d4742b13b9c4586bf1c1e7d9423/opencv_python-5.0.0.93-cp37-abi3-win32.whl", hash = "sha256:4b4b1a34c79bf8d3738e3cfe9a9e67b51a79663f6b692cbdad8c31f570da4157", size = 35564734, upload-time = "2026-07-02T05:49:57.704Z" },
{ url = "https://files.pythonhosted.org/packages/21/f0/9fa6e85cb10c8eb36a0222d27e50fe381b86ce49a55446bf39f491727564/opencv_python-5.0.0.93-cp37-abi3-win_amd64.whl", hash = "sha256:f90ba04b8f73bc5c3814037699739f0156f597338a98f05956c684e7c3ca10d2", size = 44000345, upload-time = "2026-07-02T05:49:54.971Z" },
]
[[package]] [[package]]
name = "packaging" name = "packaging"
version = "26.2" version = "26.2"
@@ -474,6 +613,77 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
] ]
[[package]]
name = "pillow"
version = "12.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/37/bf/fb3ebff8ddcb76aac5a01389251bbbb9519922a9b520d8247c1ca864a25d/pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965", size = 5345969, upload-time = "2026-07-01T11:54:06.397Z" },
{ url = "https://files.pythonhosted.org/packages/d8/66/9a386a92561f402389a4fc70c18838bf6d35eb5eb5c6850b4b2dc64f5048/pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7", size = 4780323, upload-time = "2026-07-01T11:54:09.351Z" },
{ url = "https://files.pythonhosted.org/packages/25/27/ac8f99618ffd3dde21db0f4d4b1d2ab00c0880595bfd17df103f7f39fd0c/pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9", size = 6266838, upload-time = "2026-07-01T11:54:11.71Z" },
{ url = "https://files.pythonhosted.org/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91", size = 6940830, upload-time = "2026-07-01T11:54:13.732Z" },
{ url = "https://files.pythonhosted.org/packages/eb/51/8b08617af3ad95e33ce6d7dd2c99ed6c8298f7fb131636303956be022e25/pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c", size = 6344383, upload-time = "2026-07-01T11:54:15.756Z" },
{ url = "https://files.pythonhosted.org/packages/1d/72/cf78ac9780bb93c28328f408973845a309d4d145041665f734572ced1b52/pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df", size = 7052934, upload-time = "2026-07-01T11:54:17.721Z" },
{ url = "https://files.pythonhosted.org/packages/20/20/25e0f4dc178a6bc0696793720055519a0de89e7661dae886992decbd2f81/pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f", size = 6472684, upload-time = "2026-07-01T11:54:19.839Z" },
{ url = "https://files.pythonhosted.org/packages/45/89/da2f7971a317f83d807fdd4065c0af40208e59e692cc43d315a71a0e96d1/pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09", size = 7227137, upload-time = "2026-07-01T11:54:22.025Z" },
{ url = "https://files.pythonhosted.org/packages/de/47/4845a0a6c0dbf1db8456bd9fc791f13c5ced7ced20606d08a0aacfd25b49/pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510", size = 2568267, upload-time = "2026-07-01T11:54:24.051Z" },
{ url = "https://files.pythonhosted.org/packages/9d/ac/31fb64e1e7efb5a4b50cd3d92049ba89ac6e4d8d3bb6a74e15048ca3353e/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89", size = 4161684, upload-time = "2026-07-01T11:54:25.934Z" },
{ url = "https://files.pythonhosted.org/packages/87/b4/9805e23d2b4d77842b468513841fda254ee42f0289d25088340e4ff46e2d/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace", size = 4255487, upload-time = "2026-07-01T11:54:27.935Z" },
{ url = "https://files.pythonhosted.org/packages/df/39/ecf519435a200c693fe053a6ee4d835b41cf963a4dfc2551c4e637cb2a71/pillow-12.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec", size = 3696433, upload-time = "2026-07-01T11:54:29.813Z" },
{ url = "https://files.pythonhosted.org/packages/42/92/2fc3ffad878ae8dd5469ec1bc8eb83b71f48e13efdf68f02709003982a32/pillow-12.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66", size = 5345889, upload-time = "2026-07-01T11:54:31.97Z" },
{ url = "https://files.pythonhosted.org/packages/10/76/8803c13605b763d33d156c4678fc77f8443389c0c51c8aef707bb02015f4/pillow-12.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35", size = 4780109, upload-time = "2026-07-01T11:54:34.026Z" },
{ url = "https://files.pythonhosted.org/packages/1f/01/e18aff37cb0b4aac47ac90f016d347a49aca667ef97f190b06ac2aabc928/pillow-12.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65", size = 6263736, upload-time = "2026-07-01T11:54:36.131Z" },
{ url = "https://files.pythonhosted.org/packages/f7/62/de5bdd77d935331f4f802edc11e4d82950f642caad6cb2f949837b8560e2/pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3", size = 6937129, upload-time = "2026-07-01T11:54:38.216Z" },
{ url = "https://files.pythonhosted.org/packages/70/4d/105627a13300c5e0df1d174230b32fd1273062c96f7745fd552b945d1e1d/pillow-12.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a", size = 6339562, upload-time = "2026-07-01T11:54:40.354Z" },
{ url = "https://files.pythonhosted.org/packages/6b/1d/f13de01a553988ab895ba1c722e06cf3144d4f57656fd5b81b6d881f1179/pillow-12.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e", size = 7049439, upload-time = "2026-07-01T11:54:42.489Z" },
{ url = "https://files.pythonhosted.org/packages/c9/f9/066794cca041b969964f779ee5fa66a9498bbf34248ac39c5d7954e4198f/pillow-12.3.0-cp313-cp313-win32.whl", hash = "sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f", size = 6473287, upload-time = "2026-07-01T11:54:44.9Z" },
{ url = "https://files.pythonhosted.org/packages/a6/9b/7a58e61d62be561da3a356fe2384d4059a6345fc130e23ef1c36a5b81d24/pillow-12.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8", size = 7239691, upload-time = "2026-07-01T11:54:47.141Z" },
{ url = "https://files.pythonhosted.org/packages/aa/b0/c4ed4f0ef8f8fa5ee8351537db6650bb8189f7e118842978dd6589065692/pillow-12.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b", size = 2568185, upload-time = "2026-07-01T11:54:49.137Z" },
{ url = "https://files.pythonhosted.org/packages/dc/01/001f65b68192f0228cc1dbbc8d2530ab5d58b61037ba0587f946fea607cd/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330", size = 4161736, upload-time = "2026-07-01T11:54:51.156Z" },
{ url = "https://files.pythonhosted.org/packages/1a/d2/0219746d0fd16fc8a84498e79452375be3797d3ce4044596ce565164b84f/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217", size = 4255435, upload-time = "2026-07-01T11:54:53.414Z" },
{ url = "https://files.pythonhosted.org/packages/c8/02/8d0bc62ef0302318c46ff2a512822d2610e81c7aa46c9b3abe6cbaca5ad0/pillow-12.3.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930", size = 3696262, upload-time = "2026-07-01T11:54:55.739Z" },
{ url = "https://files.pythonhosted.org/packages/85/e2/73c77d218410b14f5f2d565e8a998d5317b7b9c75368d29985139f7a46f0/pillow-12.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8", size = 5350344, upload-time = "2026-07-01T11:54:57.657Z" },
{ url = "https://files.pythonhosted.org/packages/c7/da/32c752228ae345f489e3a42499d817b6c3996da7e8a3bc7a04fc806b243b/pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0", size = 4780131, upload-time = "2026-07-01T11:54:59.713Z" },
{ url = "https://files.pythonhosted.org/packages/b1/9d/8b2c807dbef61a5197c047afe99823787eb66f63daf9fb2432f91d6f0462/pillow-12.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321", size = 6263757, upload-time = "2026-07-01T11:55:01.778Z" },
{ url = "https://files.pythonhosted.org/packages/5c/44/c85361f65dbe00eea8576ee467c768d25129989efb76e94f205e9ca9bb46/pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b", size = 6936962, upload-time = "2026-07-01T11:55:03.93Z" },
{ url = "https://files.pythonhosted.org/packages/18/7e/e483414b35800b86b6f08dbbc7803fb5cd52c4d6f897f47d53ea2c7e6f65/pillow-12.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198", size = 6339171, upload-time = "2026-07-01T11:55:05.989Z" },
{ url = "https://files.pythonhosted.org/packages/f0/f4/68c491844841ede6bed70189546b3ee9731cf9f2cbad396faff5e1ccba45/pillow-12.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130", size = 7048116, upload-time = "2026-07-01T11:55:08.131Z" },
{ url = "https://files.pythonhosted.org/packages/a3/34/77f3f793fed8efc7d243f21b33c5a3f0d1c97ee70346d3db855587e155ff/pillow-12.3.0-cp314-cp314-win32.whl", hash = "sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a", size = 6467209, upload-time = "2026-07-01T11:55:10.408Z" },
{ url = "https://files.pythonhosted.org/packages/f1/e0/492879f69d94f91f60fc8cd05ba03650e9520afebb2fb7aa12777d7c7f38/pillow-12.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d", size = 7237707, upload-time = "2026-07-01T11:55:12.745Z" },
{ url = "https://files.pythonhosted.org/packages/c9/ac/6b11f2875f1c2ac040d84e1bbf9cf22a88038f901ca1037898b280b38365/pillow-12.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838", size = 2565995, upload-time = "2026-07-01T11:55:14.736Z" },
{ url = "https://files.pythonhosted.org/packages/52/69/c2208e56af9bfc1913afb24020297a691eb1d4ef688474c8a04913f65e04/pillow-12.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e", size = 5352503, upload-time = "2026-07-01T11:55:17.076Z" },
{ url = "https://files.pythonhosted.org/packages/07/70/e5686d753e898a45d778ff1718dba8516ead6ab6b95d85fc8c4b70650cf2/pillow-12.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17", size = 4782956, upload-time = "2026-07-01T11:55:19.448Z" },
{ url = "https://files.pythonhosted.org/packages/d5/37/25c6692f06927ee973ff18c8d9ee98ad0b4d84ee67a09610c2dd1447958e/pillow-12.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385", size = 6322855, upload-time = "2026-07-01T11:55:21.613Z" },
{ url = "https://files.pythonhosted.org/packages/cc/91/420637fcb8f1bc11029e403b4538e6694744428d8246118e45719f944556/pillow-12.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c", size = 6989642, upload-time = "2026-07-01T11:55:24.006Z" },
{ url = "https://files.pythonhosted.org/packages/10/08/b94d7811281ccf0d143a1cf768d1c49e1e54af63e7b708ab2ee3eb87face/pillow-12.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d", size = 6391281, upload-time = "2026-07-01T11:55:26.252Z" },
{ url = "https://files.pythonhosted.org/packages/d2/87/24233f785f55474dc02ce3e739c5528a77e3a862e9333d1dd7a25cc31f70/pillow-12.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931", size = 7096716, upload-time = "2026-07-01T11:55:28.318Z" },
{ url = "https://files.pythonhosted.org/packages/23/26/fcb2f6e37175b04f53570b59937867e2b80ee1685e744023153028fc14f9/pillow-12.3.0-cp314-cp314t-win32.whl", hash = "sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7", size = 6474125, upload-time = "2026-07-01T11:55:30.956Z" },
{ url = "https://files.pythonhosted.org/packages/90/de/3634abee5f1c9e13c56787b7d5517b0ba8d6de51700b95578cf338349c9f/pillow-12.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c", size = 7242939, upload-time = "2026-07-01T11:55:34.044Z" },
{ url = "https://files.pythonhosted.org/packages/ce/2a/fd13f8eb24de5714a6eb444a3d67e2842c6c576e159a43793adf23051351/pillow-12.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45", size = 2567506, upload-time = "2026-07-01T11:55:35.988Z" },
{ url = "https://files.pythonhosted.org/packages/5d/dc/8fdce34ec725a33c81c6ba122b904d6b9024e50ea9ac7bede62fab54506c/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139", size = 4162063, upload-time = "2026-07-01T11:55:37.941Z" },
{ url = "https://files.pythonhosted.org/packages/76/66/2044b9a63d3b84ff048228dfcb7cd9bf0df983e8470971bf7d4c57b693de/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402", size = 4255549, upload-time = "2026-07-01T11:55:40.022Z" },
{ url = "https://files.pythonhosted.org/packages/52/7e/1f67e6f4ece6b582ee4b539decbcc9f848dc245a93ed8cd7338bafef72f1/pillow-12.3.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c", size = 3696331, upload-time = "2026-07-01T11:55:41.98Z" },
{ url = "https://files.pythonhosted.org/packages/12/40/d306fc2c8e4d45d7f175c77edca7063be7b86fe7fe6e68f4353bf71d808c/pillow-12.3.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f", size = 5350370, upload-time = "2026-07-01T11:55:44.028Z" },
{ url = "https://files.pythonhosted.org/packages/dd/44/668fb1437e8ce420f62d6106eb66e44a5971602a4d794615bdf79315d82d/pillow-12.3.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701", size = 4780147, upload-time = "2026-07-01T11:55:46.073Z" },
{ url = "https://files.pythonhosted.org/packages/0c/08/93fa2e70e30a2d81547e481b6ee2bb9522117221fb1e0ce4b5df70967677/pillow-12.3.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace", size = 6273659, upload-time = "2026-07-01T11:55:48.264Z" },
{ url = "https://files.pythonhosted.org/packages/f8/6d/043e96ff814fc31a33077e4cba86082167db520c93632afdf2042febbb0c/pillow-12.3.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4", size = 6947439, upload-time = "2026-07-01T11:55:50.503Z" },
{ url = "https://files.pythonhosted.org/packages/af/92/ba71d2ee2ac0edf3fa33bd9d5ee9ee080da70b1766f3ca3934f9938ddac9/pillow-12.3.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39", size = 6353577, upload-time = "2026-07-01T11:55:52.697Z" },
{ url = "https://files.pythonhosted.org/packages/0f/ce/e63064e2122923ff687c8ad792d0d736a7b3920a56a46982e81a7fdd25d6/pillow-12.3.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71", size = 7060394, upload-time = "2026-07-01T11:55:55.149Z" },
{ url = "https://files.pythonhosted.org/packages/54/76/a09cc3ccc8d773a7283d34c38bec1708f9e3cc932093cbc4c5e71ac4060b/pillow-12.3.0-cp315-cp315-win32.whl", hash = "sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827", size = 6467375, upload-time = "2026-07-01T11:55:57.769Z" },
{ url = "https://files.pythonhosted.org/packages/3e/03/1846c49ba3b1d5550392a4bbd06d6fb4578e1cd91a803198b5c90f5f7d53/pillow-12.3.0-cp315-cp315-win_amd64.whl", hash = "sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5", size = 7237048, upload-time = "2026-07-01T11:55:59.975Z" },
{ url = "https://files.pythonhosted.org/packages/fb/bb/89f35dcc79610423f9f195504d7def7f0d1416a711541b42867e25fe3412/pillow-12.3.0-cp315-cp315-win_arm64.whl", hash = "sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658", size = 2566006, upload-time = "2026-07-01T11:56:02.143Z" },
{ url = "https://files.pythonhosted.org/packages/30/88/707027ba09942dfa2c28759b5c222d769290a41c6d20ea60ec250801941f/pillow-12.3.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf", size = 5352509, upload-time = "2026-07-01T11:56:04.2Z" },
{ url = "https://files.pythonhosted.org/packages/b0/6d/00352fa25332c2569cd387851f568cc5a4b75a9adbfb37ac4fbce4c02eec/pillow-12.3.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64", size = 4783167, upload-time = "2026-07-01T11:56:06.631Z" },
{ url = "https://files.pythonhosted.org/packages/13/4f/9e049dfa21af7c22427275720e2490267ba8138120add5c4c574deb69782/pillow-12.3.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e", size = 6329237, upload-time = "2026-07-01T11:56:08.868Z" },
{ url = "https://files.pythonhosted.org/packages/36/16/cf6eeaae8d0fce8dd390a33437cf68c5d5bd73834a2bc6e2f14efda0ab45/pillow-12.3.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777", size = 6997047, upload-time = "2026-07-01T11:56:11.379Z" },
{ url = "https://files.pythonhosted.org/packages/1e/69/dbf769bdd55f48bf5733cac28edc6364ffaa072ec9ba336266e4fe66be55/pillow-12.3.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1", size = 6400440, upload-time = "2026-07-01T11:56:13.908Z" },
{ url = "https://files.pythonhosted.org/packages/a0/e1/ffc9cfc2eea0d178da8018e18e959301ad9d6bc9f3edb7181e748a474b97/pillow-12.3.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9", size = 7105895, upload-time = "2026-07-01T11:56:16.575Z" },
{ url = "https://files.pythonhosted.org/packages/18/f0/a5595c1e8c3ae44b9828cb2f0fa8155e5095ef04d6327b8f61cf44a3df85/pillow-12.3.0-cp315-cp315t-win32.whl", hash = "sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8", size = 6474384, upload-time = "2026-07-01T11:56:18.855Z" },
{ url = "https://files.pythonhosted.org/packages/e4/04/62bcd9f844984c5938d3b05264a61d797a29d3e0812341a8204af70bbdee/pillow-12.3.0-cp315-cp315t-win_amd64.whl", hash = "sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418", size = 7243537, upload-time = "2026-07-01T11:56:21.214Z" },
{ url = "https://files.pythonhosted.org/packages/3d/68/1f3066acedf37673694a7141381d8f811ae97f30d34413d236abe7d489f1/pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59", size = 2567491, upload-time = "2026-07-01T11:56:23.506Z" },
]
[[package]] [[package]]
name = "pluggy" name = "pluggy"
version = "1.6.0" version = "1.6.0"
@@ -510,6 +720,36 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/19/c7/5f7c636ec43e0c545e28d1f1db71990108306f7bdcb89f069ba97e428e7f/protobuf-7.35.1-py3-none-any.whl", hash = "sha256:4bc97768d8fe4ad6743c8a19403e314511ed9f6d13205b687e52421c023ac1b9", size = 171659, upload-time = "2026-06-11T21:55:39.155Z" }, { url = "https://files.pythonhosted.org/packages/19/c7/5f7c636ec43e0c545e28d1f1db71990108306f7bdcb89f069ba97e428e7f/protobuf-7.35.1-py3-none-any.whl", hash = "sha256:4bc97768d8fe4ad6743c8a19403e314511ed9f6d13205b687e52421c023ac1b9", size = 171659, upload-time = "2026-06-11T21:55:39.155Z" },
] ]
[[package]]
name = "pyclipper"
version = "1.4.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f6/21/3c06205bb407e1f79b73b7b4dfb3950bd9537c4f625a68ab5cc41177f5bc/pyclipper-1.4.0.tar.gz", hash = "sha256:9882bd889f27da78add4dd6f881d25697efc740bf840274e749988d25496c8e1", size = 54489, upload-time = "2025-12-01T13:15:35.015Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/90/1b/7a07b68e0842324d46c03e512d8eefa9cb92ba2a792b3b4ebf939dafcac3/pyclipper-1.4.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:222ac96c8b8281b53d695b9c4fedc674f56d6d4320ad23f1bdbd168f4e316140", size = 265676, upload-time = "2025-12-01T13:15:04.15Z" },
{ url = "https://files.pythonhosted.org/packages/6b/dd/8bd622521c05d04963420ae6664093f154343ed044c53ea260a310c8bb4d/pyclipper-1.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f3672dbafbb458f1b96e1ee3e610d174acb5ace5bd2ed5d1252603bb797f2fc6", size = 140458, upload-time = "2025-12-01T13:15:05.76Z" },
{ url = "https://files.pythonhosted.org/packages/7a/06/6e3e241882bf7d6ab23d9c69ba4e85f1ec47397cbbeee948a16cf75e21ed/pyclipper-1.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d1f807e2b4760a8e5c6d6b4e8c1d71ef52b7fe1946ff088f4fa41e16a881a5ca", size = 978235, upload-time = "2025-12-01T13:15:06.993Z" },
{ url = "https://files.pythonhosted.org/packages/cf/f4/3418c1cd5eea640a9fa2501d4bc0b3655fa8d40145d1a4f484b987990a75/pyclipper-1.4.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ce1f83c9a4e10ea3de1959f0ae79e9a5bd41346dff648fee6228ba9eaf8b3872", size = 961388, upload-time = "2025-12-01T13:15:08.467Z" },
{ url = "https://files.pythonhosted.org/packages/ac/94/c85401d24be634af529c962dd5d781f3cb62a67cd769534df2cb3feee97a/pyclipper-1.4.0-cp312-cp312-win32.whl", hash = "sha256:3ef44b64666ebf1cb521a08a60c3e639d21b8c50bfbe846ba7c52a0415e936f4", size = 95169, upload-time = "2025-12-01T13:15:10.098Z" },
{ url = "https://files.pythonhosted.org/packages/97/77/dfea08e3b230b82ee22543c30c35d33d42f846a77f96caf7c504dd54fab1/pyclipper-1.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:d1e5498d883b706a4ce636247f0d830c6eb34a25b843a1b78e2c969754ca9037", size = 104619, upload-time = "2025-12-01T13:15:11.592Z" },
{ url = "https://files.pythonhosted.org/packages/67/d0/cbce7d47de1e6458f66a4d999b091640134deb8f2c7351eab993b70d2e10/pyclipper-1.4.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d49df13cbb2627ccb13a1046f3ea6ebf7177b5504ec61bdef87d6a704046fd6e", size = 264342, upload-time = "2025-12-01T13:15:12.697Z" },
{ url = "https://files.pythonhosted.org/packages/ce/cc/742b9d69d96c58ac156947e1b56d0f81cbacbccf869e2ac7229f2f86dc4e/pyclipper-1.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:37bfec361e174110cdddffd5ecd070a8064015c99383d95eb692c253951eee8a", size = 139839, upload-time = "2025-12-01T13:15:13.911Z" },
{ url = "https://files.pythonhosted.org/packages/db/48/dd301d62c1529efdd721b47b9e5fb52120fcdac5f4d3405cfc0d2f391414/pyclipper-1.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:14c8bdb5a72004b721c4e6f448d2c2262d74a7f0c9e3076aeff41e564a92389f", size = 972142, upload-time = "2025-12-01T13:15:15.477Z" },
{ url = "https://files.pythonhosted.org/packages/07/bf/d493fd1b33bb090fa64e28c1009374d5d72fa705f9331cd56517c35e381e/pyclipper-1.4.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f2a50c22c3a78cb4e48347ecf06930f61ce98cf9252f2e292aa025471e9d75b1", size = 952789, upload-time = "2025-12-01T13:15:17.042Z" },
{ url = "https://files.pythonhosted.org/packages/cf/88/b95ea8ea21ddca34aa14b123226a81526dd2faaa993f9aabd3ed21231604/pyclipper-1.4.0-cp313-cp313-win32.whl", hash = "sha256:c9a3faa416ff536cee93417a72bfb690d9dea136dc39a39dbbe1e5dadf108c9c", size = 94817, upload-time = "2025-12-01T13:15:18.724Z" },
{ url = "https://files.pythonhosted.org/packages/ba/42/0a1920d276a0e1ca21dc0d13ee9e3ba10a9a8aa3abac76cd5e5a9f503306/pyclipper-1.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:d4b2d7c41086f1927d14947c563dfc7beed2f6c0d9af13c42fe3dcdc20d35832", size = 104007, upload-time = "2025-12-01T13:15:19.763Z" },
{ url = "https://files.pythonhosted.org/packages/1a/20/04d58c70f3ccd404f179f8dd81d16722a05a3bf1ab61445ee64e8218c1f8/pyclipper-1.4.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:7c87480fc91a5af4c1ba310bdb7de2f089a3eeef5fe351a3cedc37da1fcced1c", size = 265167, upload-time = "2025-12-01T13:15:20.844Z" },
{ url = "https://files.pythonhosted.org/packages/bd/2e/a570c1abe69b7260ca0caab4236ce6ea3661193ebf8d1bd7f78ccce537a5/pyclipper-1.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:81d8bb2d1fb9d66dc7ea4373b176bb4b02443a7e328b3b603a73faec088b952e", size = 139966, upload-time = "2025-12-01T13:15:22.036Z" },
{ url = "https://files.pythonhosted.org/packages/e8/3b/e0859e54adabdde8a24a29d3f525ebb31c71ddf2e8d93edce83a3c212ffc/pyclipper-1.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:773c0e06b683214dcfc6711be230c83b03cddebe8a57eae053d4603dd63582f9", size = 968216, upload-time = "2025-12-01T13:15:23.18Z" },
{ url = "https://files.pythonhosted.org/packages/f6/6b/e3c4febf0a35ae643ee579b09988dd931602b5bf311020535fd9e5b7e715/pyclipper-1.4.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9bc45f2463d997848450dbed91c950ca37c6cf27f84a49a5cad4affc0b469e39", size = 954198, upload-time = "2025-12-01T13:15:24.522Z" },
{ url = "https://files.pythonhosted.org/packages/fc/74/728efcee02e12acb486ce9d56fa037120c9bf5b77c54bbdbaa441c14a9d9/pyclipper-1.4.0-cp314-cp314-win32.whl", hash = "sha256:0b8c2105b3b3c44dbe1a266f64309407fe30bf372cf39a94dc8aaa97df00da5b", size = 96951, upload-time = "2025-12-01T13:15:25.79Z" },
{ url = "https://files.pythonhosted.org/packages/e3/d7/7f4354e69f10a917e5c7d5d72a499ef2e10945312f5e72c414a0a08d2ae4/pyclipper-1.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:6c317e182590c88ec0194149995e3d71a979cfef3b246383f4e035f9d4a11826", size = 106782, upload-time = "2025-12-01T13:15:26.945Z" },
{ url = "https://files.pythonhosted.org/packages/63/60/fc32c7a3d7f61a970511ec2857ecd09693d8ac80d560ee7b8e67a6d268c9/pyclipper-1.4.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:f160a2c6ba036f7eaf09f1f10f4fbfa734234af9112fb5187877efed78df9303", size = 269880, upload-time = "2025-12-01T13:15:28.117Z" },
{ url = "https://files.pythonhosted.org/packages/49/df/c4a72d3f62f0ba03ec440c4fff56cd2d674a4334d23c5064cbf41c9583f6/pyclipper-1.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:a9f11ad133257c52c40d50de7a0ca3370a0cdd8e3d11eec0604ad3c34ba549e9", size = 141706, upload-time = "2025-12-01T13:15:30.134Z" },
{ url = "https://files.pythonhosted.org/packages/c5/0b/cf55df03e2175e1e2da9db585241401e0bc98f76bee3791bed39d0313449/pyclipper-1.4.0-cp314-cp314t-win32.whl", hash = "sha256:bbc827b77442c99deaeee26e0e7f172355ddb097a5e126aea206d447d3b26286", size = 105308, upload-time = "2025-12-01T13:15:31.225Z" },
{ url = "https://files.pythonhosted.org/packages/8f/dc/53df8b6931d47080b4fe4ee8450d42e660ee1c5c1556c7ab73359182b769/pyclipper-1.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:29dae3e0296dff8502eeb7639fcfee794b0eec8590ba3563aee28db269da6b04", size = 117608, upload-time = "2025-12-01T13:15:32.69Z" },
]
[[package]] [[package]]
name = "pydantic" name = "pydantic"
version = "2.13.4" version = "2.13.4"
@@ -632,6 +872,44 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" },
] ]
[[package]]
name = "pypdf"
version = "6.14.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/03/72/7dfd5ff1c9c37de97a731701f51af091325f123d9d4270361c9c69e4431f/pypdf-6.14.2.tar.gz", hash = "sha256:7873f502fe4385e79539b21d872392dc0c4e3714327c15881cbc7fbfd1f95b25", size = 6491182, upload-time = "2026-06-23T14:18:30.859Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/49/e6/136aa8993a2ae7214e0b0ef2edaa0d2e08d1d4e4982635b08a835ff31ec8/pypdf-6.14.2-py3-none-any.whl", hash = "sha256:3f07891af76dc002657e04993ab9b4de81de29f9013b9761d0b7968bff12e946", size = 349514, upload-time = "2026-06-23T14:18:28.867Z" },
]
[[package]]
name = "pypdfium2"
version = "5.12.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/db/42/0b51bdf50ccf13f3deb3209ca996179a49761dc191748469cf0de55b0055/pypdfium2-5.12.1.tar.gz", hash = "sha256:d0e0648fb2e28f50efcd1ec0a5a18ced9f4d66b2c227fae9b603f0a883b2d13f", size = 274428, upload-time = "2026-07-17T10:01:22.713Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/09/7e/bd8df53b1131c582f6646372047b49162032bd01628d49b9a60cd94d2181/pypdfium2-5.12.1-py3-none-android_23_arm64_v8a.whl", hash = "sha256:05bab9b1ba2de7fc299ae2af25cb9c8a0543bc8bb893e879fe8c9ba8310e9ce4", size = 3392276, upload-time = "2026-07-17T10:00:47.376Z" },
{ url = "https://files.pythonhosted.org/packages/b2/13/a2b71e17b0439d2af78c817a381e3557371c6c56098581da8485aef65ea6/pypdfium2-5.12.1-py3-none-android_23_armeabi_v7a.whl", hash = "sha256:d4ee061e566a6422b660cdddaaa799a2d1cbf2f016921bcaf24d61426d01d942", size = 2848776, upload-time = "2026-07-17T10:00:49.09Z" },
{ url = "https://files.pythonhosted.org/packages/e2/a8/d7a61700db3022792b28bce5264be90a7d2e7104998362af6b93766f50b2/pypdfium2-5.12.1-py3-none-macosx_13_0_arm64.whl", hash = "sha256:66a9ed40d70a5d728cd42148fecb9d7a0917c6161d6bb67c844093a4ed1df089", size = 3480243, upload-time = "2026-07-17T10:00:50.674Z" },
{ url = "https://files.pythonhosted.org/packages/01/2c/d7a38fad74b6da0947cf8763aee0f8e6c9d3c12fc8e137aa615f7f8ae76c/pypdfium2-5.12.1-py3-none-macosx_13_0_x86_64.whl", hash = "sha256:847378a5ab41332998b2621b21bab2e96dc8c3eff36a08bce26695b964163983", size = 3643490, upload-time = "2026-07-17T10:00:52.236Z" },
{ url = "https://files.pythonhosted.org/packages/8f/29/aca739676323558595fcf8cdc8d7939d2b25aaaa6f538e829f1fee938cdd/pypdfium2-5.12.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6eabf028ad8e7bc7811c9acf3a72718c180569b624b844d2c6cc974609784275", size = 3649734, upload-time = "2026-07-17T10:00:53.776Z" },
{ url = "https://files.pythonhosted.org/packages/a4/e0/e4ecc05f4f1a11d11c8d684a24b2fc8be8207f341be985dac301caa4f6aa/pypdfium2-5.12.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7857cfa6642ec5a09db12ff8f5cf6b6494585b5e3a605399fddc4fb862837b63", size = 3380828, upload-time = "2026-07-17T10:00:55.377Z" },
{ url = "https://files.pythonhosted.org/packages/17/1b/c94c9d486791276e736350917a11fe2cf3acba2c6c7f03f9ac0d51f8952a/pypdfium2-5.12.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:05bfa20a08a96584253bbe38b60e13f81a037eac31c5579e607ec1480ad25dbf", size = 3777202, upload-time = "2026-07-17T10:00:57.212Z" },
{ url = "https://files.pythonhosted.org/packages/14/aa/7f81f0c035fc32850dfab9daf78530814f215be226dad7491e3caa0a3e8c/pypdfium2-5.12.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9f059f7bdbdf4352eb83691071096940d769d6ae5930b8734237fdb1bd78fbc2", size = 4186083, upload-time = "2026-07-17T10:00:59.022Z" },
{ url = "https://files.pythonhosted.org/packages/23/16/21420a6f2bc5f981299c336817dd5d72709dad5fda30ac38cbd5f0f7b372/pypdfium2-5.12.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e10cbf41b21233ec5e20adfc170cf60edd77abead86a97dc708fff55a8a886c7", size = 3701734, upload-time = "2026-07-17T10:01:00.952Z" },
{ url = "https://files.pythonhosted.org/packages/36/a1/bb89f49e2b3ea3e945b67859d2ec6e73e722a83c006099c675692641e51d/pypdfium2-5.12.1-py3-none-manylinux_2_27_s390x.manylinux_2_28_s390x.whl", hash = "sha256:07eeebb2784f4cd38d386b924235df43217a397442796673296bb6efbdaad1d0", size = 4030403, upload-time = "2026-07-17T10:01:02.568Z" },
{ url = "https://files.pythonhosted.org/packages/80/a7/cea5eb0c39e9c6fdf9853a3008bf021d08f962228073af90354b60c5ccdc/pypdfium2-5.12.1-py3-none-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5c3e6cbe43581af79526184643920ab03a9401a0c79f2226bea9d4d1e3d34008", size = 3994411, upload-time = "2026-07-17T10:01:04.25Z" },
{ url = "https://files.pythonhosted.org/packages/fc/43/5e470213b27c13b0d94d03d97fc3740507edadea1d1e2ce2049bfbec4aa0/pypdfium2-5.12.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4648f0905441bcb141687ca2263bbf38a1aa056b943eef06019f91cff3e1da4a", size = 4993687, upload-time = "2026-07-17T10:01:05.811Z" },
{ url = "https://files.pythonhosted.org/packages/db/da/af7972ca72f24ed720db6501333fd67bc66aa6aa5a5ec698551bd30ae62e/pypdfium2-5.12.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:bdff622181fab64f32328591c9c8287cdc745c9a1f2afc26ca3feba39e3e6645", size = 4534560, upload-time = "2026-07-17T10:01:07.291Z" },
{ url = "https://files.pythonhosted.org/packages/a9/63/06a7f2cd691f7e336cbc53fd65453fee516042c8ddb016bc50d1cd2bed45/pypdfium2-5.12.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:236dbdc88aa54f14b27937ccb2ebe3dcf08c10dbb8652f432ea982dc9af39732", size = 5237681, upload-time = "2026-07-17T10:01:08.997Z" },
{ url = "https://files.pythonhosted.org/packages/2b/f5/937b080671758ab0b3d3d69b2657006682e4c6a0134b36774be4ed1afcfb/pypdfium2-5.12.1-py3-none-musllinux_1_2_ppc64le.whl", hash = "sha256:9c8856ce7dd77a7827476c7d75afe1197d6cd505f5cb4167b6aacf661f3f8ea5", size = 5143027, upload-time = "2026-07-17T10:01:10.69Z" },
{ url = "https://files.pythonhosted.org/packages/32/02/094632700c24728fa443dc89d9d1c6e4fc05bb00778b22e8482fdb133da0/pypdfium2-5.12.1-py3-none-musllinux_1_2_riscv64.whl", hash = "sha256:5f257bb40fa44ce9ba18d2c919777dbd3f16bf22548b1d68fd56c7c92f1de530", size = 4647048, upload-time = "2026-07-17T10:01:12.559Z" },
{ url = "https://files.pythonhosted.org/packages/fe/d0/12d84bf55a4fcf2c0ed242afc94168933194f672067e1d162aa60a8e4426/pypdfium2-5.12.1-py3-none-musllinux_1_2_s390x.whl", hash = "sha256:974082344172da76a5c3c0782eaedfe6069dbe88db77d8c671ef36b61e9b14e2", size = 5088747, upload-time = "2026-07-17T10:01:14.42Z" },
{ url = "https://files.pythonhosted.org/packages/92/9c/92a460bac1f6cfd6f96251802b3098a804fb251dfe0b5eb004ede958ae0e/pypdfium2-5.12.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:715ae16b34ea1d64884d58800155179ba700e9ea65a2f583b020666acd2bfb12", size = 5049695, upload-time = "2026-07-17T10:01:15.961Z" },
{ url = "https://files.pythonhosted.org/packages/f7/67/53c61d366222550220b42a9212131407f49d3dbcf050178c620bf80fa899/pypdfium2-5.12.1-py3-none-win32.whl", hash = "sha256:e5358d2ce4ebc5c899aab1df9ca5d215357244e9168aa443225d3c1e649c7eac", size = 3725466, upload-time = "2026-07-17T10:01:17.773Z" },
{ url = "https://files.pythonhosted.org/packages/a9/c3/08b62718faf2f6b6aa49207626e3113ff3ec1b3cd076c0ef8fd852f0e57c/pypdfium2-5.12.1-py3-none-win_amd64.whl", hash = "sha256:9609be73a6701a68f29dffe0335f7a2e4b3ba581542ed65d35d49f761a4600ca", size = 3859845, upload-time = "2026-07-17T10:01:19.417Z" },
{ url = "https://files.pythonhosted.org/packages/6c/d5/ad551e55790134c8bc87ea16e0866a3721def0620fbfa19112c8ad4a25a6/pypdfium2-5.12.1-py3-none-win_arm64.whl", hash = "sha256:afc0b7e0c975a429abc75875209ce17b66d749f6ac5cbe8ba72470e83901e304", size = 3674605, upload-time = "2026-07-17T10:01:21.008Z" },
]
[[package]] [[package]]
name = "pytest" name = "pytest"
version = "9.1.1" version = "9.1.1"
@@ -661,6 +939,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" },
] ]
[[package]]
name = "python-docx"
version = "1.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "lxml" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a9/f7/eddfe33871520adab45aaa1a71f0402a2252050c14c7e3009446c8f4701c/python_docx-1.2.0.tar.gz", hash = "sha256:7bc9d7b7d8a69c9c02ca09216118c86552704edc23bac179283f2e38f86220ce", size = 5723256, upload-time = "2025-06-16T20:46:27.921Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d0/00/1e03a4989fa5795da308cd774f05b704ace555a70f9bf9d3be057b680bcf/python_docx-1.2.0-py3-none-any.whl", hash = "sha256:3fd478f3250fbbbfd3b94fe1e985955737c145627498896a8a6bf81f4baf66c7", size = 252987, upload-time = "2025-06-16T20:46:22.506Z" },
]
[[package]] [[package]]
name = "python-dotenv" name = "python-dotenv"
version = "1.2.2" version = "1.2.2"
@@ -670,6 +961,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
] ]
[[package]]
name = "python-multipart"
version = "0.0.32"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" },
]
[[package]] [[package]]
name = "pywin32" name = "pywin32"
version = "312" version = "312"
@@ -765,7 +1065,12 @@ dependencies = [
{ name = "pydantic" }, { name = "pydantic" },
{ name = "pydantic-settings" }, { name = "pydantic-settings" },
{ name = "pyjwt" }, { name = "pyjwt" },
{ name = "pypdf" },
{ name = "pypdfium2" },
{ name = "python-docx" },
{ name = "python-multipart" },
{ name = "qdrant-client" }, { name = "qdrant-client" },
{ name = "rapidocr-onnxruntime" },
{ name = "redis" }, { name = "redis" },
{ name = "structlog" }, { name = "structlog" },
{ name = "uvicorn", extra = ["standard"] }, { name = "uvicorn", extra = ["standard"] },
@@ -787,9 +1092,14 @@ requires-dist = [
{ name = "pydantic", specifier = ">=2.10.0" }, { name = "pydantic", specifier = ">=2.10.0" },
{ name = "pydantic-settings", specifier = ">=2.7.0" }, { name = "pydantic-settings", specifier = ">=2.7.0" },
{ name = "pyjwt", specifier = ">=2.13.0" }, { name = "pyjwt", specifier = ">=2.13.0" },
{ name = "pypdf", specifier = ">=5.1.0" },
{ name = "pypdfium2", specifier = ">=4.0.0" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.3.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.3.0" },
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.24.0" }, { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.24.0" },
{ name = "python-docx", specifier = ">=1.1.2" },
{ name = "python-multipart", specifier = ">=0.0.20" },
{ name = "qdrant-client", specifier = ">=1.12.0" }, { name = "qdrant-client", specifier = ">=1.12.0" },
{ name = "rapidocr-onnxruntime", specifier = ">=1.3.8" },
{ name = "redis", specifier = ">=5.2.0" }, { name = "redis", specifier = ">=5.2.0" },
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.8.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.8.0" },
{ name = "structlog", specifier = ">=24.4.0" }, { name = "structlog", specifier = ">=24.4.0" },
@@ -797,6 +1107,25 @@ requires-dist = [
] ]
provides-extras = ["dev"] provides-extras = ["dev"]
[[package]]
name = "rapidocr-onnxruntime"
version = "1.4.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "numpy" },
{ name = "onnxruntime" },
{ name = "opencv-python" },
{ name = "pillow" },
{ name = "pyclipper" },
{ name = "pyyaml" },
{ name = "shapely" },
{ name = "six" },
{ name = "tqdm" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/ba/12/1e5497183bdbe782dbb91bad1d0d2297dba4d2831b2652657f7517bfc6df/rapidocr_onnxruntime-1.4.4-py3-none-any.whl", hash = "sha256:971d7d5f223a7a808662229df1ef69893809d8457d834e6373d3854bc1782cbf", size = 14915192, upload-time = "2025-01-17T01:48:25.104Z" },
]
[[package]] [[package]]
name = "redis" name = "redis"
version = "8.0.1" version = "8.0.1"
@@ -831,6 +1160,66 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/9a/8415f2657cbe200f41a4531ccededf135505a92d4a012229121f885b26f9/ruff-0.16.0-py3-none-win_arm64.whl", hash = "sha256:14296fedcd2705c77ab8235439278bbb38f285cf7da5528b00b3e330c3d4872d", size = 11273407, upload-time = "2026-07-23T19:11:28.705Z" }, { url = "https://files.pythonhosted.org/packages/cb/9a/8415f2657cbe200f41a4531ccededf135505a92d4a012229121f885b26f9/ruff-0.16.0-py3-none-win_arm64.whl", hash = "sha256:14296fedcd2705c77ab8235439278bbb38f285cf7da5528b00b3e330c3d4872d", size = 11273407, upload-time = "2026-07-23T19:11:28.705Z" },
] ]
[[package]]
name = "shapely"
version = "2.1.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "numpy" },
]
sdist = { url = "https://files.pythonhosted.org/packages/4d/bc/0989043118a27cccb4e906a46b7565ce36ca7b57f5a18b78f4f1b0f72d9d/shapely-2.1.2.tar.gz", hash = "sha256:2ed4ecb28320a433db18a5bf029986aa8afcfd740745e78847e330d5d94922a9", size = 315489, upload-time = "2025-09-24T13:51:41.432Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/24/c0/f3b6453cf2dfa99adc0ba6675f9aaff9e526d2224cbd7ff9c1a879238693/shapely-2.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fe2533caae6a91a543dec62e8360fe86ffcdc42a7c55f9dfd0128a977a896b94", size = 1833550, upload-time = "2025-09-24T13:50:30.019Z" },
{ url = "https://files.pythonhosted.org/packages/86/07/59dee0bc4b913b7ab59ab1086225baca5b8f19865e6101db9ebb7243e132/shapely-2.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ba4d1333cc0bc94381d6d4308d2e4e008e0bd128bdcff5573199742ee3634359", size = 1643556, upload-time = "2025-09-24T13:50:32.291Z" },
{ url = "https://files.pythonhosted.org/packages/26/29/a5397e75b435b9895cd53e165083faed5d12fd9626eadec15a83a2411f0f/shapely-2.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0bd308103340030feef6c111d3eb98d50dc13feea33affc8a6f9fa549e9458a3", size = 2988308, upload-time = "2025-09-24T13:50:33.862Z" },
{ url = "https://files.pythonhosted.org/packages/b9/37/e781683abac55dde9771e086b790e554811a71ed0b2b8a1e789b7430dd44/shapely-2.1.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1e7d4d7ad262a48bb44277ca12c7c78cb1b0f56b32c10734ec9a1d30c0b0c54b", size = 3099844, upload-time = "2025-09-24T13:50:35.459Z" },
{ url = "https://files.pythonhosted.org/packages/d8/f3/9876b64d4a5a321b9dc482c92bb6f061f2fa42131cba643c699f39317cb9/shapely-2.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e9eddfe513096a71896441a7c37db72da0687b34752c4e193577a145c71736fc", size = 3988842, upload-time = "2025-09-24T13:50:37.478Z" },
{ url = "https://files.pythonhosted.org/packages/d1/a0/704c7292f7014c7e74ec84eddb7b109e1fbae74a16deae9c1504b1d15565/shapely-2.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:980c777c612514c0cf99bc8a9de6d286f5e186dcaf9091252fcd444e5638193d", size = 4152714, upload-time = "2025-09-24T13:50:39.9Z" },
{ url = "https://files.pythonhosted.org/packages/53/46/319c9dc788884ad0785242543cdffac0e6530e4d0deb6c4862bc4143dcf3/shapely-2.1.2-cp312-cp312-win32.whl", hash = "sha256:9111274b88e4d7b54a95218e243282709b330ef52b7b86bc6aaf4f805306f454", size = 1542745, upload-time = "2025-09-24T13:50:41.414Z" },
{ url = "https://files.pythonhosted.org/packages/ec/bf/cb6c1c505cb31e818e900b9312d514f381fbfa5c4363edfce0fcc4f8c1a4/shapely-2.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:743044b4cfb34f9a67205cee9279feaf60ba7d02e69febc2afc609047cb49179", size = 1722861, upload-time = "2025-09-24T13:50:43.35Z" },
{ url = "https://files.pythonhosted.org/packages/c3/90/98ef257c23c46425dc4d1d31005ad7c8d649fe423a38b917db02c30f1f5a/shapely-2.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b510dda1a3672d6879beb319bc7c5fd302c6c354584690973c838f46ec3e0fa8", size = 1832644, upload-time = "2025-09-24T13:50:44.886Z" },
{ url = "https://files.pythonhosted.org/packages/6d/ab/0bee5a830d209adcd3a01f2d4b70e587cdd9fd7380d5198c064091005af8/shapely-2.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8cff473e81017594d20ec55d86b54bc635544897e13a7cfc12e36909c5309a2a", size = 1642887, upload-time = "2025-09-24T13:50:46.735Z" },
{ url = "https://files.pythonhosted.org/packages/2d/5e/7d7f54ba960c13302584c73704d8c4d15404a51024631adb60b126a4ae88/shapely-2.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fe7b77dc63d707c09726b7908f575fc04ff1d1ad0f3fb92aec212396bc6cfe5e", size = 2970931, upload-time = "2025-09-24T13:50:48.374Z" },
{ url = "https://files.pythonhosted.org/packages/f2/a2/83fc37e2a58090e3d2ff79175a95493c664bcd0b653dd75cb9134645a4e5/shapely-2.1.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7ed1a5bbfb386ee8332713bf7508bc24e32d24b74fc9a7b9f8529a55db9f4ee6", size = 3082855, upload-time = "2025-09-24T13:50:50.037Z" },
{ url = "https://files.pythonhosted.org/packages/44/2b/578faf235a5b09f16b5f02833c53822294d7f21b242f8e2d0cf03fb64321/shapely-2.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a84e0582858d841d54355246ddfcbd1fce3179f185da7470f41ce39d001ee1af", size = 3979960, upload-time = "2025-09-24T13:50:51.74Z" },
{ url = "https://files.pythonhosted.org/packages/4d/04/167f096386120f692cc4ca02f75a17b961858997a95e67a3cb6a7bbd6b53/shapely-2.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dc3487447a43d42adcdf52d7ac73804f2312cbfa5d433a7d2c506dcab0033dfd", size = 4142851, upload-time = "2025-09-24T13:50:53.49Z" },
{ url = "https://files.pythonhosted.org/packages/48/74/fb402c5a6235d1c65a97348b48cdedb75fb19eca2b1d66d04969fc1c6091/shapely-2.1.2-cp313-cp313-win32.whl", hash = "sha256:9c3a3c648aedc9f99c09263b39f2d8252f199cb3ac154fadc173283d7d111350", size = 1541890, upload-time = "2025-09-24T13:50:55.337Z" },
{ url = "https://files.pythonhosted.org/packages/41/47/3647fe7ad990af60ad98b889657a976042c9988c2807cf322a9d6685f462/shapely-2.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:ca2591bff6645c216695bdf1614fca9c82ea1144d4a7591a466fef64f28f0715", size = 1722151, upload-time = "2025-09-24T13:50:57.153Z" },
{ url = "https://files.pythonhosted.org/packages/3c/49/63953754faa51ffe7d8189bfbe9ca34def29f8c0e34c67cbe2a2795f269d/shapely-2.1.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2d93d23bdd2ed9dc157b46bc2f19b7da143ca8714464249bef6771c679d5ff40", size = 1834130, upload-time = "2025-09-24T13:50:58.49Z" },
{ url = "https://files.pythonhosted.org/packages/7f/ee/dce001c1984052970ff60eb4727164892fb2d08052c575042a47f5a9e88f/shapely-2.1.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:01d0d304b25634d60bd7cf291828119ab55a3bab87dc4af1e44b07fb225f188b", size = 1642802, upload-time = "2025-09-24T13:50:59.871Z" },
{ url = "https://files.pythonhosted.org/packages/da/e7/fc4e9a19929522877fa602f705706b96e78376afb7fad09cad5b9af1553c/shapely-2.1.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8d8382dd120d64b03698b7298b89611a6ea6f55ada9d39942838b79c9bc89801", size = 3018460, upload-time = "2025-09-24T13:51:02.08Z" },
{ url = "https://files.pythonhosted.org/packages/a1/18/7519a25db21847b525696883ddc8e6a0ecaa36159ea88e0fef11466384d0/shapely-2.1.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:19efa3611eef966e776183e338b2d7ea43569ae99ab34f8d17c2c054d3205cc0", size = 3095223, upload-time = "2025-09-24T13:51:04.472Z" },
{ url = "https://files.pythonhosted.org/packages/48/de/b59a620b1f3a129c3fecc2737104a0a7e04e79335bd3b0a1f1609744cf17/shapely-2.1.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:346ec0c1a0fcd32f57f00e4134d1200e14bf3f5ae12af87ba83ca275c502498c", size = 4030760, upload-time = "2025-09-24T13:51:06.455Z" },
{ url = "https://files.pythonhosted.org/packages/96/b3/c6655ee7232b417562bae192ae0d3ceaadb1cc0ffc2088a2ddf415456cc2/shapely-2.1.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6305993a35989391bd3476ee538a5c9a845861462327efe00dd11a5c8c709a99", size = 4170078, upload-time = "2025-09-24T13:51:08.584Z" },
{ url = "https://files.pythonhosted.org/packages/a0/8e/605c76808d73503c9333af8f6cbe7e1354d2d238bda5f88eea36bfe0f42a/shapely-2.1.2-cp313-cp313t-win32.whl", hash = "sha256:c8876673449f3401f278c86eb33224c5764582f72b653a415d0e6672fde887bf", size = 1559178, upload-time = "2025-09-24T13:51:10.73Z" },
{ url = "https://files.pythonhosted.org/packages/36/f7/d317eb232352a1f1444d11002d477e54514a4a6045536d49d0c59783c0da/shapely-2.1.2-cp313-cp313t-win_amd64.whl", hash = "sha256:4a44bc62a10d84c11a7a3d7c1c4fe857f7477c3506e24c9062da0db0ae0c449c", size = 1739756, upload-time = "2025-09-24T13:51:12.105Z" },
{ url = "https://files.pythonhosted.org/packages/fc/c4/3ce4c2d9b6aabd27d26ec988f08cb877ba9e6e96086eff81bfea93e688c7/shapely-2.1.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:9a522f460d28e2bf4e12396240a5fc1518788b2fcd73535166d748399ef0c223", size = 1831290, upload-time = "2025-09-24T13:51:13.56Z" },
{ url = "https://files.pythonhosted.org/packages/17/b9/f6ab8918fc15429f79cb04afa9f9913546212d7fb5e5196132a2af46676b/shapely-2.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1ff629e00818033b8d71139565527ced7d776c269a49bd78c9df84e8f852190c", size = 1641463, upload-time = "2025-09-24T13:51:14.972Z" },
{ url = "https://files.pythonhosted.org/packages/a5/57/91d59ae525ca641e7ac5551c04c9503aee6f29b92b392f31790fcb1a4358/shapely-2.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f67b34271dedc3c653eba4e3d7111aa421d5be9b4c4c7d38d30907f796cb30df", size = 2970145, upload-time = "2025-09-24T13:51:16.961Z" },
{ url = "https://files.pythonhosted.org/packages/8a/cb/4948be52ee1da6927831ab59e10d4c29baa2a714f599f1f0d1bc747f5777/shapely-2.1.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:21952dc00df38a2c28375659b07a3979d22641aeb104751e769c3ee825aadecf", size = 3073806, upload-time = "2025-09-24T13:51:18.712Z" },
{ url = "https://files.pythonhosted.org/packages/03/83/f768a54af775eb41ef2e7bec8a0a0dbe7d2431c3e78c0a8bdba7ab17e446/shapely-2.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1f2f33f486777456586948e333a56ae21f35ae273be99255a191f5c1fa302eb4", size = 3980803, upload-time = "2025-09-24T13:51:20.37Z" },
{ url = "https://files.pythonhosted.org/packages/9f/cb/559c7c195807c91c79d38a1f6901384a2878a76fbdf3f1048893a9b7534d/shapely-2.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cf831a13e0d5a7eb519e96f58ec26e049b1fad411fc6fc23b162a7ce04d9cffc", size = 4133301, upload-time = "2025-09-24T13:51:21.887Z" },
{ url = "https://files.pythonhosted.org/packages/80/cd/60d5ae203241c53ef3abd2ef27c6800e21afd6c94e39db5315ea0cbafb4a/shapely-2.1.2-cp314-cp314-win32.whl", hash = "sha256:61edcd8d0d17dd99075d320a1dd39c0cb9616f7572f10ef91b4b5b00c4aeb566", size = 1583247, upload-time = "2025-09-24T13:51:23.401Z" },
{ url = "https://files.pythonhosted.org/packages/74/d4/135684f342e909330e50d31d441ace06bf83c7dc0777e11043f99167b123/shapely-2.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:a444e7afccdb0999e203b976adb37ea633725333e5b119ad40b1ca291ecf311c", size = 1773019, upload-time = "2025-09-24T13:51:24.873Z" },
{ url = "https://files.pythonhosted.org/packages/a3/05/a44f3f9f695fa3ada22786dc9da33c933da1cbc4bfe876fe3a100bafe263/shapely-2.1.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:5ebe3f84c6112ad3d4632b1fd2290665aa75d4cef5f6c5d77c4c95b324527c6a", size = 1834137, upload-time = "2025-09-24T13:51:26.665Z" },
{ url = "https://files.pythonhosted.org/packages/52/7e/4d57db45bf314573427b0a70dfca15d912d108e6023f623947fa69f39b72/shapely-2.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5860eb9f00a1d49ebb14e881f5caf6c2cf472c7fd38bd7f253bbd34f934eb076", size = 1642884, upload-time = "2025-09-24T13:51:28.029Z" },
{ url = "https://files.pythonhosted.org/packages/5a/27/4e29c0a55d6d14ad7422bf86995d7ff3f54af0eba59617eb95caf84b9680/shapely-2.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b705c99c76695702656327b819c9660768ec33f5ce01fa32b2af62b56ba400a1", size = 3018320, upload-time = "2025-09-24T13:51:29.903Z" },
{ url = "https://files.pythonhosted.org/packages/9f/bb/992e6a3c463f4d29d4cd6ab8963b75b1b1040199edbd72beada4af46bde5/shapely-2.1.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a1fd0ea855b2cf7c9cddaf25543e914dd75af9de08785f20ca3085f2c9ca60b0", size = 3094931, upload-time = "2025-09-24T13:51:32.699Z" },
{ url = "https://files.pythonhosted.org/packages/9c/16/82e65e21070e473f0ed6451224ed9fa0be85033d17e0c6e7213a12f59d12/shapely-2.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:df90e2db118c3671a0754f38e36802db75fe0920d211a27481daf50a711fdf26", size = 4030406, upload-time = "2025-09-24T13:51:34.189Z" },
{ url = "https://files.pythonhosted.org/packages/7c/75/c24ed871c576d7e2b64b04b1fe3d075157f6eb54e59670d3f5ffb36e25c7/shapely-2.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:361b6d45030b4ac64ddd0a26046906c8202eb60d0f9f53085f5179f1d23021a0", size = 4169511, upload-time = "2025-09-24T13:51:36.297Z" },
{ url = "https://files.pythonhosted.org/packages/b1/f7/b3d1d6d18ebf55236eec1c681ce5e665742aab3c0b7b232720a7d43df7b6/shapely-2.1.2-cp314-cp314t-win32.whl", hash = "sha256:b54df60f1fbdecc8ebc2c5b11870461a6417b3d617f555e5033f1505d36e5735", size = 1602607, upload-time = "2025-09-24T13:51:37.757Z" },
{ url = "https://files.pythonhosted.org/packages/9a/f6/f09272a71976dfc138129b8faf435d064a811ae2f708cb147dccdf7aacdb/shapely-2.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:0036ac886e0923417932c2e6369b6c52e38e0ff5d9120b90eef5cd9a5fc5cae9", size = 1796682, upload-time = "2025-09-24T13:51:39.233Z" },
]
[[package]]
name = "six"
version = "1.17.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
]
[[package]] [[package]]
name = "sniffio" name = "sniffio"
version = "1.3.1" version = "1.3.1"