Compare commits

..

8 Commits

Author SHA1 Message Date
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 4485 additions and 1189 deletions
+2 -2
View File
@@ -25,8 +25,8 @@ REDIS_PORT=6379
# --- Ollama 本地模型(文档三级总结)---
OLLAMA_PORT=11434
OLLAMA_MODEL=qwen2.5:1.5b
# 备选模型: qwen2.5:3b(更好的总结质量,需更多内存)
OLLAMA_MODEL=qwen3:1.7b
# 备选模型: qwen3:4b(更好的总结质量,需更多内存)
# EMBEDDING_PROVIDER=local 时使用的 Ollama 嵌入模型
OLLAMA_EMBEDDING_MODEL=bge-m3
+3
View File
@@ -9,6 +9,9 @@ build/
# Frontend
node_modules/
# 前端构建产物(本地残留的 Vue 构建 / 符号链接,勿提交;线上由 frontend/dist 挂载)
app/static/admin
# Env
.env
.env.local
+8 -5
View File
@@ -12,7 +12,7 @@ QMDSearch 是面向 AI Agent 的分层信息检索服务,支持多层级知识
- **缓存**: Redis (Docker)
- **关系数据库**: PostgreSQL (Docker, 可选)
- **嵌入模型**: OpenAI / 本地模型 (通过配置切换)
- **本地推理模型**: Ollama (Docker) — 用于文档三级总结,默认 qwen2.5:1.5b
- **本地推理模型**: Ollama (Docker) — 用于文档三级总结,默认 qwen3:1.7b
- **部署**: Docker Compose on NAS
## 项目结构
@@ -90,12 +90,15 @@ QMDSearch/
| POST | `/api/v1/search` | 分层检索 | 免登录 |
| 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-batch` | 批量文件上传入库(多文件,202 异步) | Bearer |
| GET | `/api/v1/documents/tasks` | 入库任务列表(近期,按时间降序) | Bearer |
| GET | `/api/v1/documents/tasks/{task_id}` | 入库任务状态查询(done 附 resultfailed 附 error | 免登录 |
| GET | `/api/v1/knowledge/categories` | 知识分类类目集 | 免登录 |
| GET | `/api/v1/knowledge/stats` | 统计(四层点数 + 类目分布 + uncategorized 数) | 免登录 |
| GET | `/api/v1/documents` | 文档列表(limit/offset 分页) | 免登录 |
| GET | `/api/v1/documents/{doc_id}` | 文档详情 | 免登录 |
| GET | `/api/v1/documents/{doc_id}/file` | 下载关联的原始文件(免登录) | 免登录 |
| POST | `/api/v1/documents/{doc_id}/reingest` | 重新摘要入库(读原文件→删旧→重跑流水线) | Bearer |
| DELETE | `/api/v1/documents/{doc_id}` | 删除文档(幂等) | Bearer |
| POST | `/api/v1/auth/login` | 用户名密码登录,签发 session tokenTTL 12h | 免登录 |
| POST | `/api/v1/auth/logout` | 退出登录(删除当前 session | Bearer |
@@ -108,13 +111,13 @@ QMDSearch/
| DELETE | `/api/v1/auth/users/{username}` | 删除用户(清除其 session;禁删自己/最后一个 admin | Bearer + 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 不可用时降级为纯内存。
## 管理页面
浏览器访问 `/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 容器,默认模型 `qwen2.5:1.5b`(约 1GB,适合 NAS 低资源环境)
- 备选模型: `qwen2.5:3b`(更好的总结质量,约 2GB
- 服务: Ollama 容器,默认模型 `qwen3:1.7b`(约 1.3GB,适合 NAS 低资源环境)
- 备选模型: `qwen3:4b`(更好的总结质量,约 2.5GB
- 模型通过环境变量 `OLLAMA_MODEL` 配置
- 首次启动时自动拉取模型,需 NAS 可访问外网
+8 -18
View File
@@ -1,17 +1,10 @@
# ---------- Stage 1: 前端构建 ----------
FROM node:20-slim AS frontend-builder
WORKDIR /frontend
# 依赖层(利用 Docker 层缓存)
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci
# 源码 + 构建
COPY frontend/ ./
RUN npm run build
# ---------- Stage 2: Python 后端 ----------
# ---------- Python 后端运行时镜像 ----------
# 部署模型说明:
# 前端采用「挂载式」部署——docker-compose 将宿主机的 ./frontend/dist
# 挂到容器的 /app/app/static/admin,运行时直接覆盖镜像内同名目录。
# 因此镜像【不再构建前端】,只负责 Python 运行时 + 依赖;前端由本地
# `npm run build` 后 rsync 到 NAS,或挂载既有 dist。
# 仅当 pyproject.toml / uv.lock 变化时才需 `docker build -t qmdsearch-app .` 一次。
FROM python:3.12-slim AS base
WORKDIR /app
@@ -19,7 +12,7 @@ WORKDIR /app
# 安装 uv(放到 PATH 中,供 uv sync 与 CMD 的 uv run 使用)
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
# 依赖层
# 依赖层(利用 Docker 层缓存)
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-dev
@@ -27,9 +20,6 @@ RUN uv sync --frozen --no-dev
COPY app/ app/
COPY scripts/ scripts/
# 前端构建产物 → 后端静态目录(SPA 由 /admin 路由服务)
COPY --from=frontend-builder /frontend/dist /app/app/static/admin
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
+3 -3
View File
@@ -22,7 +22,7 @@
| 向量数据库 | QdrantDocker |
| 缓存 | RedisDocker |
| 嵌入模型 | OpenAI / Ollama 本地模型(可切换) |
| 本地推理 | Ollama + qwen2.5:1.5b(文档总结) |
| 本地推理 | Ollama + qwen3:1.7b(文档总结) |
| OCR | rapidocr-onnxruntime + pypdfium2(扫描件 PDF |
| 部署 | Docker Compose on NAS |
@@ -123,7 +123,7 @@ docker compose up -d
### 4. 拉取 Ollama 模型(首次启动后)
```bash
docker exec qmdsearch-ollama ollama pull qwen2.5:1.5b
docker exec qmdsearch-ollama ollama pull qwen3:1.7b
```
### 5. 验证服务
@@ -232,7 +232,7 @@ curl -X POST http://localhost:8000/api/v1/documents/upload \
| `EMBEDDING_PROVIDER` | 嵌入模型提供商(openai / local | `openai` |
| `OPENAI_API_KEY` | OpenAI API Key | - |
| `EMBEDDING_MODEL` | 嵌入模型名称 | `text-embedding-3-small` |
| `OLLAMA_MODEL` | Ollama 本地模型 | `qwen2.5:1.5b` |
| `OLLAMA_MODEL` | Ollama 本地模型 | `qwen3:1.7b` |
| `RETRIEVAL_TOP_K` | 检索召回数 | `20` |
| `RETRIEVAL_FINAL_K` | 最终返回数 | `5` |
| `INGEST_MAX_CONCURRENCY` | 入库并发上限 | `2` |
+12 -2
View File
@@ -8,6 +8,7 @@ UserStore/SessionStore 为模块级懒加载单例:Redis 客户端创建失败
import structlog
from fastapi import Depends, Header
from redis import asyncio as redis_async
from redis import Redis as RedisSync
from app.api.response import ApiError
from app.config import settings
@@ -22,11 +23,20 @@ _session_store: SessionStore | None = 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:
# 同步 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)
except Exception:
logger.warning("Redis 客户端创建失败,认证存储降级为内存模式", exc_info=True)
logger.warning("Redis 连接失败,认证存储降级为内存模式", exc_info=True)
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}")
async def get_ingest_task(task_id: str) -> dict[str, Any]:
"""查询入库任务状态:含 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)
@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")
async def list_documents(
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
import structlog
from fastapi import APIRouter, Depends
from fastapi import APIRouter
from app.api.response import ApiError, ok
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.services.qdrant import ALL_COLLECTIONS, COLLECTION_L1, QdrantService
@@ -37,14 +36,14 @@ def _get_taxonomy() -> list[TaxonomyCategory]:
@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()
return ok({"categories": [c.model_dump() for c in categories], "count": len(categories)})
@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 类目分布统计"""
service = _get_qdrant()
try:
+2 -3
View File
@@ -4,10 +4,9 @@ from hashlib import sha256
from typing import Any
import structlog
from fastapi import APIRouter, Depends
from fastapi import APIRouter
from app.api.response import ApiError, ok
from app.core.auth import AuthUser, get_current_user
from app.core.retriever import Retriever
from app.models.search import SearchRequest
from app.services.redis import get_cache
@@ -34,7 +33,7 @@ def _cache_key(request: SearchRequest) -> str:
@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
先查 Redis 缓存:命中直接返回缓存的响应;未命中走检索流程并回写缓存。
+6 -5
View File
@@ -11,7 +11,8 @@ from fastapi import APIRouter, Depends
from pydantic import BaseModel
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.file_parser import (
list_docx_plugins,
@@ -46,7 +47,7 @@ class SettingsUpdateRequest(BaseModel):
@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(任何登录用户可读)"""
cfg = get_runtime_settings()
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")
async def update_settings(
body: SettingsUpdateRequest,
user: AuthUser = Depends(require_admin),
user: UserRecord = Depends(require_admin),
) -> dict[str, Any]:
"""部分更新 RuntimeSettings(仅 admin
@@ -82,7 +83,7 @@ async def update_settings(
@router.get("/settings/schema")
async def get_settings_schema(
user: AuthUser = Depends(get_current_user),
user: UserRecord = Depends(get_current_user),
) -> dict[str, Any]:
"""返回可选插件与策略列表(前端 Settings 页渲染选项用)"""
return ok(
@@ -98,7 +99,7 @@ async def get_settings_schema(
@router.post("/settings/reset")
async def reset_settings(
user: AuthUser = Depends(require_admin),
user: UserRecord = Depends(require_admin),
) -> dict[str, Any]:
"""重置 RuntimeSettings 为默认值(仅 admin),同时清缓存"""
try:
+1 -1
View File
@@ -18,7 +18,7 @@ class Settings(BaseSettings):
# Ollama 本地模型(用于文档三级总结)
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 时使用的嵌入模型
# 重排模型(cross-encoder,经 Ollama /api/rerank,对 chunk 候选做最终精排)
+120 -1
View File
@@ -20,7 +20,7 @@ from typing import Any
import structlog
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.models.document import DocumentInput
from app.services.redis import RedisCache
@@ -79,6 +79,8 @@ class IngestTaskManager:
"""
task_id = uuid.uuid4().hex
now = _utc_now_iso()
filename = self._extract_filename(doc)
source = self._extract_source(doc)
dedup = get_dedup_strategy(self._redis)
# 1. 去重命中:直接置 done,复用旧结果,不调 _run
@@ -93,6 +95,8 @@ class IngestTaskManager:
"updated_at": now,
"result": result_dict,
"error": None,
"filename": filename,
"source": source,
}
self._schedule_mirror(task_id)
logger.info(
@@ -110,6 +114,8 @@ class IngestTaskManager:
"updated_at": now,
"result": None,
"error": None,
"filename": filename,
"source": source,
}
self._schedule_mirror(task_id)
background = asyncio.create_task(self._run(task_id, doc, dedup))
@@ -135,6 +141,119 @@ class IngestTaskManager:
)
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]:
"""轮询内存注册表直到任务进入终态(done/failed)或超时
+25 -8
View File
@@ -6,7 +6,6 @@ import structlog
from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
from redis import asyncio as redis_async
from app.api.response import ApiError, error
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.settings import router as settings_router
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
logger = structlog.get_logger()
@@ -33,13 +32,13 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]:
except Exception:
logger.error("Qdrant 集合初始化失败,跳过初始化继续启动")
try:
# Redis 客户端创建失败(如 URL 非法)时传 None,UserStore 降级为内存模式
redis_client = redis_async.from_url(settings.redis_url, decode_responses=True)
except Exception:
redis_client = None
try:
# 复用 deps 的 UserStore 单例,确保 lifespan 创建的 admin 与 API 请求用的是同一实例
# _get_user_store 内部会检测 Redis 可达性,不可达时降级为内存模式
from app.api.deps import _get_user_store
user_store = _get_user_store()
# 空库引导默认管理员;明文密码由 bootstrap_admin 内部 warning 打印一次
await bootstrap_admin(UserStore(redis_client), logger)
await bootstrap_admin(user_store, logger)
except Exception:
logger.warning("默认管理员初始化失败,跳过", exc_info=True)
try:
@@ -144,3 +143,21 @@ async def admin_spa(rest: str):
return FileResponse(file_path)
# SPA 客户端路由兜底
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-docs">文档管理</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-categories">类目列表</button>
<button type="button" data-target="section-api-guide">API 指南</button>
@@ -340,8 +341,8 @@
<h3 style="font-size:14px; margin-top:24px;">或上传文件</h3>
<form id="upload-form">
<div class="field">
<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>
<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" multiple required>
</div>
<div class="field">
<label for="upload-title">标题(可选,默认取文件名)</label>
@@ -356,6 +357,21 @@
<div class="result-box hidden" id="ingest-result"></div>
</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">
<h2>检索测试台</h2>
<div class="error-bar hidden" id="error-search"></div>
@@ -505,6 +521,7 @@ function showLogin() {
document.getElementById("password-overlay").classList.add("hidden");
document.getElementById("user-area").classList.add("hidden");
loadedOnce = {};
stopProgressPolling();
unmountUsersSection();
}
@@ -858,6 +875,8 @@ function truncate(text, maxLen) {
var loadedOnce = {};
function activateSection(targetId) {
/* 切走前停止进度区块轮询(切到其他区块即暂停) */
stopProgressPolling();
var sections = document.querySelectorAll("main section");
sections.forEach(function (sec) {
sec.classList.toggle("hidden", sec.id !== targetId);
@@ -875,6 +894,8 @@ function activateSection(targetId) {
if (targetId === "section-api-guide") { renderApiGuide(); }
if (targetId === "section-profile") { loadProfile(); }
}
/* 切到入库进度区块时恢复轮询 */
if (targetId === "section-progress") { startProgressPolling(); }
}
document.getElementById("nav").addEventListener("click", function (event) {
@@ -1158,12 +1179,17 @@ function restoreIngestButton() {
}
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";
if (status === "done") { cls = "status-done"; }
if (status === "failed") { cls = "status-failed"; }
var badge = el("span", INGEST_STATUS_TEXT[status] || status, "status-badge " + cls);
badge.id = "ingest-status-badge";
return badge;
return el("span", INGEST_STATUS_TEXT[status] || status, "status-badge " + cls);
}
function updateIngestStatus(status) {
@@ -1290,26 +1316,178 @@ document.getElementById("upload-form").addEventListener("submit", function (even
var submitBtn = document.getElementById("btn-upload-submit");
submitBtn.disabled = true;
submitBtn.textContent = "上传中…";
var formData = new FormData();
formData.append("file", fileInput.files[0]);
var title = document.getElementById("upload-title").value;
var source = document.getElementById("upload-source").value;
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 = "上传入库";
});
if (fileInput.files.length > 1) {
/* 批量上传:FormData 用 files 字段逐文件 append,走 upload-batch 端点 */
var batchForm = new FormData();
for (var i = 0; i < fileInput.files.length; i++) {
batchForm.append("files", fileInput.files[i]);
}
api("/api/v1/documents/upload-batch", {
method: "POST",
body: batchForm
}).then(function (data) {
renderBatchUploadResult(data);
/* 自动切到入库进度区块查看进度 */
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. 检索测试台 ---------- */
document.getElementById("search-form").addEventListener("submit", function (event) {
@@ -1768,7 +1946,15 @@ var API_GUIDE = [
{ name: "title", desc: "可选标题,默认取文件名去扩展", 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",
query: [],
pathParams: [{ name: "task_id", desc: "入库任务 ID", def: "" }],
@@ -1787,6 +1973,10 @@ var API_GUIDE = [
query: [],
pathParams: [{ name: "doc_id", desc: "文档 ID", def: "" }],
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",
query: [], pathParams: [], form: [], body: null, upload: false },
{ method: "GET", path: "/api/v1/knowledge/stats", desc: "统计(四层点数 + 类目分布 + uncategorized 数)", auth: "none",
@@ -1841,7 +2031,8 @@ function buildCurl(ep) {
parts.push('-H "Authorization: Bearer <TOKEN>"');
}
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) {
parts.push('-H "Content-Type: application/json"');
parts.push("-d '" + JSON.stringify(ep.body) + "'");
@@ -1964,7 +2155,11 @@ function sendApiTry(idx, panel) {
}
/* FormData 由浏览器自动生成 multipart 边界,不设置 Content-Type 头 */
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");
for (var k = 0; k < formInputs.length; k++) {
var fVal = formInputs[k].value.trim();
@@ -2044,11 +2239,12 @@ function renderApiItem(ep, idx) {
if (ep.upload) {
renderTryInputs(panel, "form", ep.form);
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");
fileInput.type = "file";
fileInput.className = "try-file";
fileInput.accept = ".txt,.md,.html,.htm,.pdf,.docx";
if (ep.uploadMultiple) { fileInput.setAttribute("multiple", ""); }
fileField.appendChild(fileInput);
panel.appendChild(fileField);
}
Binary file not shown.
+2 -2
View File
@@ -96,7 +96,7 @@ services:
- OLLAMA_MAX_LOADED_MODELS=3
- OLLAMA_NUM_THREADS=16
- OLLAMA_KV_CACHE_TYPE=q8_0
# 首次启动自动拉取所需模型(qwen2.5:1.5b 总结 + bge-m3 嵌入 + qwen3-reranker 重排),
# 首次启动自动拉取所需模型(qwen3:1.7b 总结 + bge-m3 嵌入 + qwen3-reranker 重排),
# 下载完成后转交常驻 ollama serve。已存在时仅做健康检查。
entrypoint: /bin/bash
command:
@@ -105,7 +105,7 @@ services:
ollama serve &
SERVE_PID=$$!
sleep 6
ollama pull qwen2.5:1.5b
ollama pull qwen3:1.7b
ollama pull bge-m3
ollama pull qwen3-reranker:0.6b
wait $$SERVE_PID
+12
View File
@@ -63,3 +63,15 @@ export function resetPassword(username, newPassword) {
export function deleteUser(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
@@ -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
}
}
}
+130 -11
View File
@@ -1,13 +1,10 @@
<script setup>
import { computed, h, ref } from 'vue'
import { computed, h, reactive, ref, watch } from 'vue'
import { useRoute, useRouter, RouterView } from 'vue-router'
import { storeToRefs } from 'pinia'
import { Modal } from 'ant-design-vue'
import { Modal, message } from 'ant-design-vue'
import {
DashboardOutlined,
FileTextOutlined,
UploadOutlined,
SearchOutlined,
AppstoreOutlined,
SettingOutlined,
MenuFoldOutlined,
@@ -15,14 +12,20 @@ import {
LogoutOutlined,
DatabaseOutlined,
ApiOutlined,
TeamOutlined
TeamOutlined,
ClockCircleOutlined
} from '@ant-design/icons-vue'
import { useAuthStore } from '@/stores/useAuthStore'
import { changeMyPassword } from '@/api/auth'
import { callApi } from '@/api/client'
import TasksDrawer from '@/views/TasksDrawer.vue'
import { useTasksDrawer } from '@/composables/useTasksDrawer'
const route = useRoute()
const router = useRouter()
const authStore = useAuthStore()
const { user, displayName } = storeToRefs(authStore)
const tasksDrawer = useTasksDrawer()
const collapsed = ref(false)
@@ -34,11 +37,9 @@ const openKeys = ref(['main'])
const menuItems = computed(() => {
const items = [
{ key: 'overview', icon: () => h(DashboardOutlined), label: '概览' },
{ key: 'documents', icon: () => h(FileTextOutlined), label: '文档管理' },
{ key: 'ingest', icon: () => h(UploadOutlined), label: '文档入库' },
{ key: 'search', icon: () => h(SearchOutlined), label: '检索测试台' },
{ key: 'categories', icon: () => h(AppstoreOutlined), label: '类目列表' },
{ key: 'overview', icon: () => h(DashboardOutlined), label: '概览与检索' },
{ key: 'library', icon: () => h(AppstoreOutlined), label: '知识库' },
{ key: 'tasks', icon: () => h(ClockCircleOutlined), label: '入库进度' },
{ key: 'settings', icon: () => h(SettingOutlined), label: '设置' },
{ key: 'api-docs', icon: () => h(ApiOutlined), label: 'API 说明' }
]
@@ -49,6 +50,10 @@ const menuItems = computed(() => {
})
function handleMenuClick({ key }) {
if (key === 'tasks') {
tasksDrawer.open()
return
}
if (key && key !== route.name) {
router.push({ name: key })
}
@@ -66,6 +71,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>
<template>
@@ -127,6 +194,58 @@ function handleLogout() {
QMDSearch Admin · 当前用户{{ user?.username || '-' }}
</a-layout-footer>
</a-layout>
<!-- 入库进度右侧 Drawer -->
<TasksDrawer />
<!-- 强制改密弹窗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>
</template>
+5 -23
View File
@@ -18,31 +18,13 @@ const routes = [
path: 'overview',
name: 'overview',
component: () => import('@/views/Overview.vue'),
meta: { title: '概览', requiresAuth: true }
meta: { title: '概览与检索', requiresAuth: true }
},
{
path: 'documents',
name: 'documents',
component: () => import('@/views/Documents.vue'),
meta: { title: '文档管理', requiresAuth: true }
},
{
path: 'ingest',
name: 'ingest',
component: () => import('@/views/Ingest.vue'),
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: 'library',
name: 'library',
component: () => import('@/views/Library.vue'),
meta: { title: '知识库', requiresAuth: true }
},
{
path: 'settings',
+26 -5
View File
@@ -39,13 +39,27 @@ export const useAuthStore = defineStore('auth', {
actions: {
/**
* 登录成功后保存 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) {
this.token = data.access_token
this.user = data.user
localStorage.setItem(TOKEN_STORAGE_KEY, data.access_token)
localStorage.setItem(USER_STORAGE_KEY, JSON.stringify(data.user))
const token = data?.access_token ?? data?.token
const user =
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) */
@@ -56,6 +70,13 @@ export const useAuthStore = defineStore('auth', {
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
}
/**
* 格式化字节数为人类可读大小
* @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
+27 -1
View File
@@ -1,6 +1,6 @@
<script setup>
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 文档」章节
const apiList = [
@@ -29,6 +29,8 @@ const methodColor = {
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 \\
-H "Content-Type: application/json" \\
-d '{"username": "admin", "password": "your-password"}'`
@@ -59,6 +61,23 @@ const uploadExample = `curl -X POST ${baseUrl.value}/api/v1/documents/upload \\
message="需要鉴权的接口请在请求头携带 Authorization: Bearer &lt;token&gt;token 通过 /auth/login 获取。"
/>
<!-- 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">
<h3 class="section-subtitle">
@@ -152,6 +171,13 @@ const uploadExample = `curl -X POST ${baseUrl.value}/api/v1/documents/upload \\
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 {
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>
-352
View File
@@ -1,352 +0,0 @@
<script setup>
import { computed, reactive, ref } from 'vue'
import { message } from 'ant-design-vue'
import { ingest as ingestDocument, upload as uploadDocument } from '@/api/documents'
import { useIngestPolling } from '@/composables/useIngestPolling'
import {
INGEST_STATUS_TEXT,
INGEST_STATUS_COLOR
} from '@/constants/ingest'
const { state: pollState, startPolling, stopPolling } = useIngestPolling()
const activeTab = ref('text')
const isSubmittingText = ref(false)
const isSubmittingFile = ref(false)
const textFormRef = ref(null)
const textForm = reactive({
title: '',
source: '',
text: ''
})
const textRules = {
title: [{ required: true, message: '请输入标题', trigger: 'blur' }],
text: [{ required: true, message: '请输入正文', trigger: 'blur' }]
}
const fileForm = reactive({
title: '',
source: ''
})
const fileList = ref([])
const rawFile = ref(null)
const ACCEPTED_EXTENSIONS = '.txt,.md,.html,.htm,.pdf,.docx'
const statusBadgeColor = computed(() => {
return INGEST_STATUS_COLOR[pollState.status] || 'default'
})
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 errorData = computed(() => pollState.task?.error || pollState.error || null)
const summaryData = computed(() => resultData.value?.summary || {})
const partialSummary = computed(() => errorData.value?.partial_summary || null)
function handleFileChange(file) {
// a-upload before-upload 返回 false 表示不自动上传;保存原始 File 供 FormData 使用
rawFile.value = file
fileList.value = [file]
return false
}
function handleFileRemove() {
rawFile.value = null
fileList.value = []
}
async function handleSubmitText() {
try {
await textFormRef.value.validate()
} catch {
return
}
isSubmittingText.value = true
try {
const payload = {
title: textForm.title,
text: textForm.text
}
if (textForm.source) {
payload.source = textForm.source
}
const data = await ingestDocument(payload)
message.success('任务已提交')
startPolling(data.task_id)
} catch (err) {
message.error(err?.message || '提交入库失败')
} finally {
isSubmittingText.value = false
}
}
async function handleSubmitFile() {
if (!rawFile.value) {
message.warning('请选择文件')
return
}
isSubmittingFile.value = true
try {
const formData = new FormData()
formData.append('file', rawFile.value)
if (fileForm.title) {
formData.append('title', fileForm.title)
}
if (fileForm.source) {
formData.append('source', fileForm.source)
}
const data = await uploadDocument(formData)
message.success('任务已提交')
startPolling(data.task_id)
} catch (err) {
message.error(err?.message || '上传入库失败')
} finally {
isSubmittingFile.value = false
}
}
function handleCancelPolling() {
stopPolling()
message.info('已停止轮询')
}
</script>
<template>
<div class="ingest page-section">
<div class="ingest__header">
<h2 class="ingest__title">文档入库</h2>
</div>
<a-tabs v-model:activeKey="activeTab">
<a-tab-pane key="text" tab="文本入库">
<a-form
ref="textFormRef"
:model="textForm"
:rules="textRules"
layout="vertical"
>
<a-form-item label="标题" name="title">
<a-input v-model:value="textForm.title" placeholder="请输入标题" allow-clear />
</a-form-item>
<a-form-item label="来源" name="source">
<a-input
v-model:value="textForm.source"
placeholder="例如:manual / web / file"
allow-clear
/>
</a-form-item>
<a-form-item label="正文" name="text">
<a-textarea
v-model:value="textForm.text"
placeholder="请输入文档正文"
:auto-size="{ minRows: 8, maxRows: 18 }"
/>
</a-form-item>
<a-form-item>
<a-button
type="primary"
:loading="isSubmittingText || pollState.isPolling"
@click="handleSubmitText"
>
提交入库
</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-tabs>
<div v-if="pollState.taskId" class="ingest__result">
<div class="ingest__result-header">
<span>任务已提交{{ pollState.taskId }}</span>
<a-tag :color="statusBadgeColor">{{ statusBadgeText }}</a-tag>
<a-button
v-if="pollState.isPolling"
type="link"
size="small"
@click="handleCancelPolling"
>
停止轮询
</a-button>
</div>
<a-alert
v-if="pollState.isTimeout"
class="ingest__alert"
type="warning"
show-icon
:message="`任务仍在进行,可稍后凭 task_id 查询:${pollState.taskId}`"
/>
<a-alert
v-if="pollState.error && !pollState.task"
class="ingest__alert"
type="error"
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>
<pre class="ingest__pre">{{ partialSummary.l1_summary }}</pre>
</template>
</div>
</div>
</div>
</template>
<style scoped>
.ingest__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.ingest__title {
font-size: 16px;
margin: 0;
}
.ingest__result {
margin-top: 16px;
border: 1px solid #e5e7eb;
border-radius: 6px;
background: #fafafa;
padding: 12px 16px;
}
.ingest__result-header {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
font-size: 13px;
margin-bottom: 8px;
}
.ingest__alert {
margin: 8px 0;
}
.ingest__done,
.ingest__failed {
margin-top: 8px;
}
.ingest__section-title {
font-size: 14px;
margin: 12px 0 8px;
color: #374151;
}
.ingest__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;
}
</style>
+815
View File
@@ -0,0 +1,815 @@
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { message, Modal } from 'ant-design-vue'
import {
FolderOutlined,
FileTextOutlined,
UploadOutlined,
ReloadOutlined,
SearchOutlined,
ClockCircleOutlined,
TagsOutlined
} from '@ant-design/icons-vue'
import {
categories as fetchCategories,
stats as fetchStats
} from '@/api/knowledge'
import {
list as fetchDocuments,
detail as fetchDocumentDetail,
remove as deleteDocument,
reingest as reingestDocument,
ingest as ingestDocument,
upload as uploadDocument,
uploadBatch
} from '@/api/documents'
import { useIngestPolling } from '@/composables/useIngestPolling'
import { useTasksDrawer } from '@/composables/useTasksDrawer'
import { INGEST_STATUS_TEXT, INGEST_STATUS_COLOR } from '@/constants/ingest'
const tasksDrawer = useTasksDrawer()
/* ---------- 树形目录 ---------- */
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
}))
}
}
function h(type) {
return { render: () => _h(type) }
}
function _h(type) {
return type
}
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 docDetailVisible = ref(false)
const docDetailData = ref(null)
const isLoadingDetail = ref(false)
const isDeleting = ref(false)
const isReingesting = ref(false)
function openDocDetail(doc) {
docDetailVisible.value = true
docDetailData.value = null
loadDocDetail(doc.doc_id)
}
function closeDocDetail() {
docDetailVisible.value = false
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
}
}
})
}
/* ---------- 入库抽屉 ---------- */
const ingestVisible = ref(false)
const activeTab = ref('text')
const { state: pollState, startPolling, stopPolling } = useIngestPolling()
const textFormRef = ref(null)
const textForm = reactive({ title: '', source: '', text: '' })
const textRules = {
title: [{ 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({ title: '', source: '' })
const fileList = ref([])
const rawFile = ref(null)
const batchResult = ref(null)
const ACCEPTED_EXTENSIONS = '.txt,.md,.html,.htm,.pdf,.docx'
const statusBadgeColor = computed(() => 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 resultData = computed(() => pollState.task?.result || null)
const errorData = computed(() => pollState.task?.error || pollState.error || null)
const summaryData = computed(() => resultData.value?.summary || {})
const partialSummary = computed(() => errorData.value?.partial_summary || null)
function openIngest() {
ingestVisible.value = true
}
function handleFileChange(file) {
rawFile.value = file
fileList.value = [file]
return false
}
function handleFileRemove() {
rawFile.value = null
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() {
try {
await textFormRef.value.validate()
} catch {
return
}
isSubmittingText.value = true
try {
const payload = { title: textForm.title, text: textForm.text }
if (textForm.source) payload.source = textForm.source
const data = await ingestDocument(payload)
message.success('任务已提交')
startPolling(data.task_id)
} catch (err) {
message.error(err?.message || '提交入库失败')
} finally {
isSubmittingText.value = false
}
}
async function handleSubmitFile() {
if (!rawFile.value) {
message.warning('请选择文件')
return
}
isSubmittingFile.value = true
try {
const formData = new FormData()
formData.append('file', rawFile.value)
if (fileForm.title) formData.append('title', fileForm.title)
if (fileForm.source) formData.append('source', fileForm.source)
const data = await uploadDocument(formData)
message.success('任务已提交')
startPolling(data.task_id)
} catch (err) {
message.error(err?.message || '上传入库失败')
} finally {
isSubmittingFile.value = false
}
}
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)
}
} catch (err) {
message.error(err?.message || '批量上传失败')
} finally {
isSubmittingBatch.value = false
}
}
function handleCancelPolling() {
stopPolling()
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' }
]
</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 @click="tasksDrawer.open()">
<template #icon><ClockCircleOutlined /></template>
入库进度
</a-button>
<a-button type="primary" @click="openIngest">
<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="!docDetailVisible" class="library__placeholder text-muted">
从左侧选择类目或文档查看详情
</div>
</div>
</div>
<!-- 文档详情抽屉 -->
<a-drawer
:open="docDetailVisible"
title="文档详情"
placement="right"
width="620"
:destroy-on-close="true"
@close="closeDocDetail"
>
<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>
<div class="library__detail-actions">
<a-button size="small" :loading="isReingesting" @click="handleReingest(docDetailData.l1)">
重新摘要
</a-button>
<a-button size="small" danger :loading="isDeleting" @click="handleDelete(docDetailData.l1)">
删除
</a-button>
</div>
<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>
</a-drawer>
<!-- 入库抽屉 -->
<a-drawer
:open="ingestVisible"
title="文档入库"
placement="right"
width="560"
:destroy-on-close="true"
@close="() => (ingestVisible = false)"
>
<a-tabs v-model:activeKey="activeTab">
<a-tab-pane key="text" tab="文本入库">
<a-form ref="textFormRef" :model="textForm" :rules="textRules" layout="vertical">
<a-form-item label="标题" name="title">
<a-input v-model:value="textForm.title" placeholder="请输入标题" allow-clear />
</a-form-item>
<a-form-item label="来源" name="source">
<a-input v-model:value="textForm.source" placeholder="例如:manual / web / file" allow-clear />
</a-form-item>
<a-form-item label="正文" name="text">
<a-textarea v-model:value="textForm.text" placeholder="请输入文档正文" :auto-size="{ minRows: 8, maxRows: 18 }" />
</a-form-item>
<a-form-item>
<a-button type="primary" :loading="isSubmittingText || pollState.isPolling" @click="handleSubmitText">
提交入库
</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="library__batch">
<a-alert
v-if="batchResult.failed && batchResult.failed.length"
class="library__alert"
type="error"
show-icon
:message="`${batchResult.failed.length} 个文件上传失败`"
/>
<a-table
v-if="batchResult.tasks && batchResult.tasks.length"
:data-source="batchResult.tasks"
:columns="batchColumns"
:pagination="false"
size="small"
row-key="task_id"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'task_id'">
<a-typography-text copyable :style="{ fontSize: '12px' }">{{ record.task_id }}</a-typography-text>
</template>
</template>
</a-table>
<a-table
v-if="batchResult.failed && batchResult.failed.length"
:data-source="batchResult.failed"
:columns="failedColumns"
:pagination="false"
size="small"
row-key="filename"
style="margin-top: 12px"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'error'">
<span class="text-break">{{ record.error }}</span>
</template>
</template>
</a-table>
</div>
</a-tab-pane>
</a-tabs>
<div v-if="pollState.taskId" class="library__result">
<div class="library__result-header">
<span>任务已提交{{ pollState.taskId }}</span>
<a-tag :color="statusBadgeColor">{{ statusBadgeText }}</a-tag>
<a-button v-if="pollState.isPolling" type="link" size="small" @click="handleCancelPolling">停止轮询</a-button>
</div>
<a-alert
v-if="pollState.isTimeout"
class="library__alert"
type="warning"
show-icon
:message="`任务仍在进行,可稍后凭 task_id 查询:${pollState.taskId}`"
/>
<div v-if="isTerminal && pollState.status === 'done' && resultData" class="library__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="chunks_count">{{ resultData.chunks_count ?? 0 }}</a-descriptions-item>
</a-descriptions>
<h3 class="library__section-title">L1 总结</h3>
<pre class="library__pre">{{ summaryData.l1_summary || '' }}</pre>
</div>
<div v-if="isTerminal && pollState.status === 'failed'" class="library__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="library__section-title">L1 总结</h3>
<pre class="library__pre">{{ partialSummary.l1_summary }}</pre>
</template>
</div>
</div>
</a-drawer>
</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;
margin: 12px 0;
}
.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;
}
.library__result {
margin-top: 16px;
border: 1px solid #e5e7eb;
border-radius: 6px;
background: #fafafa;
padding: 12px 16px;
}
.library__result-header {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
font-size: 13px;
margin-bottom: 8px;
}
.library__alert {
margin: 8px 0;
}
.library__batch {
margin-top: 8px;
}
@media (max-width: 768px) {
.library__body {
flex-direction: column;
}
.library__tree {
width: 100%;
}
}
</style>
+230 -71
View File
@@ -1,5 +1,5 @@
<script setup>
import { computed, onMounted, ref } from 'vue'
import { computed, onMounted, reactive, ref } from 'vue'
import { message } from 'ant-design-vue'
import {
DatabaseOutlined,
@@ -9,69 +9,82 @@ import {
FileTextOutlined,
QuestionCircleOutlined,
ReloadOutlined,
AppstoreOutlined
AppstoreOutlined,
SearchOutlined
} from '@ant-design/icons-vue'
import { stats as fetchStats } from '@/api/knowledge'
import { search as searchApi } from '@/api/search'
/* ---------- 检索 ---------- */
const isSearching = ref(false)
const resultData = ref(null)
const searchFormRef = ref(null)
const searchForm = reactive({
query: '',
top_k: 5,
summarize: false
})
const searchRules = {
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(', ')
}
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 isLoading = ref(false)
const statsData = ref(null)
const cardMeta = [
{
key: 'doc_l1',
label: 'L1 文档总结',
icon: DatabaseOutlined,
color: '#1677ff',
bg: 'rgba(22, 119, 255, 0.12)'
},
{
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)'
}
{ key: 'doc_l1', label: 'L1 文档总结', icon: DatabaseOutlined, color: '#1677ff', bg: 'rgba(22, 119, 255, 0.12)' },
{ 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 collections = statsData.value?.collections || {}
return cardMeta.map((m) => {
let value = 0
if (m.key === '__documents_total') {
value = statsData.value?.documents_total ?? 0
} else if (m.key === '__uncategorized') {
value = statsData.value?.uncategorized_count ?? 0
} else {
value = collections[m.key] ?? 0
}
if (m.key === '__documents_total') value = statsData.value?.documents_total ?? 0
else if (m.key === '__uncategorized') value = statsData.value?.uncategorized_count ?? 0
else value = collections[m.key] ?? 0
return { ...m, value }
})
})
@@ -126,26 +139,108 @@ onMounted(() => {
<div class="overview page-section">
<div class="overview__header">
<h2 class="page-title">概览</h2>
<a-space>
<a-button :loading="isLoading" @click="loadStats">
<template #icon><ReloadOutlined /></template>
刷新
</a-button>
</a-space>
<a-button :loading="isLoading" @click="loadStats">
<template #icon><ReloadOutlined /></template>
刷新
</a-button>
</div>
<!-- 检索测试 -->
<div class="overview__search">
<h3 class="overview__subtitle">
<SearchOutlined />
<span>检索</span>
</h3>
<a-form
ref="searchFormRef"
:model="searchForm"
:rules="searchRules"
layout="inline"
>
<a-form-item name="query" style="flex: 1; min-width: 260px">
<a-input
v-model:value="searchForm.query"
placeholder="请输入查询语句"
allow-clear
@pressEnter="handleSearch"
/>
</a-form-item>
<a-form-item name="top_k">
<a-input-number
v-model:value="searchForm.top_k"
:min="1"
:max="50"
style="width: 120px"
/>
</a-form-item>
<a-form-item name="summarize">
<a-checkbox v-model:checked="searchForm.summarize">AI 总结</a-checkbox>
</a-form-item>
<a-form-item>
<a-button type="primary" :loading="isSearching" @click="handleSearch">
检索
</a-button>
</a-form-item>
</a-form>
<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>
</div>
<!-- 概览统计 -->
<a-spin :spinning="isLoading">
<div class="overview__cards">
<div
v-for="card in cards"
:key="card.key"
class="overview__card"
>
<div v-for="card in cards" :key="card.key" class="overview__card">
<div class="overview__card-body">
<div
class="overview__card-icon"
:style="{ background: card.bg, color: card.color }"
>
<div class="overview__card-icon" :style="{ background: card.bg, color: card.color }">
<component :is="card.icon" />
</div>
<div class="overview__card-info">
@@ -166,9 +261,7 @@ onMounted(() => {
{{ categoryBars.length }} 个类目
</span>
</div>
<div v-if="categoryBars.length === 0" class="overview__empty text-muted">
暂无数据
</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"
@@ -200,6 +293,72 @@ onMounted(() => {
margin-bottom: 16px;
}
.overview__search {
background: #fff;
border: 1px solid #f0f0f0;
border-radius: 8px;
padding: 16px;
margin-bottom: 24px;
}
.overview__result {
margin-top: 16px;
}
.overview__alert {
margin: 12px 0;
}
.overview__summary {
background: #f0fdf4;
border: 1px solid #bbf7d0;
border-radius: 6px;
padding: 12px 14px;
margin: 12px 0;
font-size: 13px;
}
.overview__summary-title {
font-weight: 600;
margin-bottom: 6px;
color: #15803d;
}
.overview__extracted {
background: #f0f9ff;
border: 1px solid #bae6fd;
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;
color: #6b7280;
margin-bottom: 6px;
word-break: break-all;
}
.overview__hit-snippet {
font-size: 13px;
color: #1f2937;
}
.overview__cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
@@ -272,7 +431,7 @@ onMounted(() => {
font-size: 14px;
font-weight: 600;
color: #374151;
margin: 0;
margin: 0 0 12px;
display: flex;
align-items: center;
gap: 6px;
@@ -346,4 +505,4 @@ onMounted(() => {
width: 120px;
}
}
</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-input
v-model:value="form.models[key].model"
placeholder="例如 qwen2.5:1.5b"
placeholder="例如 qwen3:1.7b"
allow-clear
/>
</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",
"pyjwt>=2.13.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]
+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
+145
View File
@@ -0,0 +1,145 @@
---
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,失效后重新登录。
> 所有变更类请求(上传 / 删除)与检索请求都必须在 Header 携带 `Authorization: Bearer <token>`。
> 当前代码实现中,查询类只读接口(文档列表 / 详情 / 类目 / 统计 / 健康)同样要求 Bearer(与登录态绑定),请始终携带 token 以避免 `1005` 未认证。
## 统一约定
- 路径前缀:`{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":"<JWT>","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 "Authorization: Bearer <token>" \
-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 |
| POST | `/documents/upload` | 文件入库(202 + task_id |
| GET | `/documents/tasks/{task_id}` | 任务状态(done / failed |
| GET | `/documents?limit=&offset=` | 文档列表(分页游标) |
| GET | `/documents/{doc_id}` | 文档详情(L1 + L2/L3 + chunks 数) |
| GET | `/documents/{doc_id}/file` | 下载原始文件 |
| DELETE | `/documents/{doc_id}` | 删除文档(幂等) |
| GET | `/knowledge/categories` | 知识分类类目集 |
| GET | `/knowledge/stats` | 统计(四层点数 + 类目分布) |
| GET | `/health` | 健康检查 |
## 错误码速查
- `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,170 @@
# 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>
```
### 检索
```bash
curl -s -X POST {BASE}/api/v1/search \
-H "Authorization: Bearer <token>" \
-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 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]}`
- 任务 `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 依赖,
这里通过 autouse 夹具把两个依赖统一替换为返回固定 admin AuthUser 的 lambda
使现有 API 测试无需改动即可通过认证。单个测试需要走真实认证逻辑时,
可在测试函数内 pop 掉对应 overrideautouse fixture yield 后会统一 clear。
鉴权采用 app.api.deps 的会话体系(UserStore / SessionStore):
- 文档变更类端点(POST /documents、/documents/upload、DELETE /documents/{id})、
/auth/* 与 Settings 变更端点需要登录;
- 查询类端点(POST /search、GET /knowledge/*、GET /documents*、
GET /documents/{id}/file)免登录。
文档变更类端点(POST /documents、/documents/upload、DELETE /documents/{id}
使用 app.api.deps 的会话认证(UserStore/SessionStore),由 auth_stores /
admin_headers 夹具注入内存存储并签发真实 session token。
auth_stores / admin_headers 夹具注入内存存储并签发真实 session token
供需要登录的接口测试使用;查询类端点测试直接无 token 调用即可,无需覆盖依赖。
另外清理 LLM 客户端 / 解析插件 / 去重策略三处进程级缓存,避免跨测试串扰
(不同测试可能注入不同的 redis/ollama 实例,缓存按配置签名而非实例区分)。
"""
import asyncio
from datetime import UTC, datetime
from typing import Any
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
def auth_stores(monkeypatch: pytest.MonkeyPatch):
+120 -1
View File
@@ -13,6 +13,8 @@
10. 请求拦截:Authorization Bearer 注入、1005 回登录、1006 错误条
11. API 指南区块:导航/section、API_GUIDE 清单与真实路由一致性、试一下面板、
curl 复制、auth 标注、upload 文件选择、禁止自定义 URL
12. 入库进度区块(Task 2):section/nav、表格结构、2s 轮询启停、状态徽章复用、
done/failed 操作按钮、reingest 端点、批量上传 multiple + upload-batch 路径
"""
import re
@@ -39,9 +41,20 @@ def client(monkeypatch: pytest.MonkeyPatch) -> Iterator[TestClient]:
@pytest.fixture
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")
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
@@ -86,6 +99,8 @@ def test_admin_page_fetch_paths(admin_html: str) -> None:
"/api/v1/auth/logout",
"/api/v1/auth/password",
"/api/v1/auth/users",
"/api/v1/documents/upload-batch",
"/api/v1/documents/tasks",
):
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/* 路径)"""
route_paths = _collect_route_paths(app.routes)
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 (
"/api/v1/auth/login",
"/api/v1/auth/me",
@@ -140,6 +162,99 @@ def test_admin_page_ingest_polling(admin_html: str) -> None:
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:
"""清理:弹窗组件已替换原生 prompt/confirm,全页面无 prompt( 与 confirm( 调用残留"""
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} 不在后端路由集合内"
# 全部真实端点覆盖:health/search/documents*/knowledge*/auth*
# Task 2 新增端点:upload-batch / tasks 列表 / reingest
expected = {
("GET", "/api/v1/health"),
("POST", "/api/v1/search"),
("POST", "/api/v1/documents"),
("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"),
("GET", "/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/stats"),
("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.task_id = task_id
self.submitted: list[DocumentInput] = []
self.retry_result: str | None = None
self.delete_result: bool = False
async def submit(self, doc: DocumentInput) -> str:
self.submitted.append(doc)
@@ -27,6 +29,12 @@ class FakeManager:
async def get(self, task_id: str) -> dict[str, Any] | None:
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]:
"""构造一条任务记录(时间字段为固定 ISO8601 字符串)"""
@@ -159,3 +167,44 @@ def test_post_empty_text_creates_no_task(client: TestClient, monkeypatch: pytest
body = resp.json()
assert body["code"] == 1001
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 app.config import Settings
from app.core.dedup import DEDUP_KEY_PREFIX
from app.core.ingest_tasks import (
DEDUP_KEY_PREFIX,
REDIS_KEY_PREFIX,
IngestTaskManager,
IngestTaskStatus,
@@ -91,6 +91,19 @@ class FakeRedis:
async def get_json(self, key: str) -> dict[str, Any] | None:
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:
"""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)
assert final["status"] == IngestTaskStatus.DONE
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" },
]
[[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]]
name = "grpcio"
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" },
]
[[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]]
name = "numpy"
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" },
]
[[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]]
name = "openai"
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" },
]
[[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]]
name = "packaging"
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" },
]
[[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]]
name = "pluggy"
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" },
]
[[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]]
name = "pydantic"
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" },
]
[[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]]
name = "pytest"
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" },
]
[[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]]
name = "python-dotenv"
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" },
]
[[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]]
name = "pywin32"
version = "312"
@@ -765,7 +1065,12 @@ dependencies = [
{ name = "pydantic" },
{ name = "pydantic-settings" },
{ name = "pyjwt" },
{ name = "pypdf" },
{ name = "pypdfium2" },
{ name = "python-docx" },
{ name = "python-multipart" },
{ name = "qdrant-client" },
{ name = "rapidocr-onnxruntime" },
{ name = "redis" },
{ name = "structlog" },
{ name = "uvicorn", extra = ["standard"] },
@@ -787,9 +1092,14 @@ requires-dist = [
{ name = "pydantic", specifier = ">=2.10.0" },
{ name = "pydantic-settings", specifier = ">=2.7.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-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 = "rapidocr-onnxruntime", specifier = ">=1.3.8" },
{ name = "redis", specifier = ">=5.2.0" },
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.8.0" },
{ name = "structlog", specifier = ">=24.4.0" },
@@ -797,6 +1107,25 @@ requires-dist = [
]
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]]
name = "redis"
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" },
]
[[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]]
name = "sniffio"
version = "1.3.1"