Compare commits
9 Commits
6ae91679e2
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 01b8c05984 | |||
| 35b7decd49 | |||
| f0fc20a9b6 | |||
| bdd30f0a88 | |||
| 884cf78033 | |||
| 364fede1b2 | |||
| c5d0f4c63e | |||
| a1b80566bb | |||
| 83eb8d0dd0 |
+2
-2
@@ -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
|
||||
|
||||
|
||||
@@ -9,6 +9,9 @@ build/
|
||||
# Frontend
|
||||
node_modules/
|
||||
|
||||
# 前端构建产物(本地残留的 Vue 构建 / 符号链接,勿提交;线上由 frontend/dist 挂载)
|
||||
app/static/admin
|
||||
|
||||
# Env
|
||||
.env
|
||||
.env.local
|
||||
|
||||
@@ -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 附 result,failed 附 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 token(TTL 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 token(admin/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 token(admin/user 角色均可),其余查询类端点免登录。
|
||||
|
||||
入库任务状态持久化在 Redis(key: `ingest_task:{task_id}`):进行中与 done 保留 24h,failed 保留 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
@@ -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 \
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
| 向量数据库 | Qdrant(Docker) |
|
||||
| 缓存 | Redis(Docker) |
|
||||
| 嵌入模型 | 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
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
# 构造新文档输入(保留原 metadata,title/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_at,done 附 result,failed 附 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),
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 缓存:命中直接返回缓存的响应;未命中走检索流程并回写缓存。
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 附 result,failed 附 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
@@ -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
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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))
|
||||
},
|
||||
|
||||
/**
|
||||
* 登出
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 <token>,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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,807 @@
|
||||
<script setup>
|
||||
import { computed, h, 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
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
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
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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]
|
||||
|
||||
Executable
+144
@@ -0,0 +1,144 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# QMDSearch 自动部署脚本(本地 CI/CD 入口)
|
||||
#
|
||||
# 设计原则(贴合 NAS 自托管 + 挂载式部署):
|
||||
# 1. 检测变化 —— 对比 origin/main,区分 backend / frontend / deps 范围
|
||||
# 2. 校验 —— 本地跑 pytest(CI 门禁),坏代码绝不上线
|
||||
# 3. 按需构建 —— 仅前端变化时本地预构建(dist 被 gitignore,NAS 端会再构建)
|
||||
# 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 已被 gitignore,NAS 端 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
|
||||
Executable
+48
@@ -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"
|
||||
Executable
+120
@@ -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 到 NAS:git fetch + git reset --hard origin/main(同步受管代码),并算出变更范围
|
||||
# 2. 本地:若前端变化 → 本地 npm 构建 → rsync frontend/dist 到 NAS(NAS 无 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 到 NAS(NAS 无 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
|
||||
Executable
+107
@@ -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
|
||||
Executable
+58
@@ -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] 监听 $ROOT(AUTO 模式:变化即部署,间隔 ${INTERVAL}s,Ctrl-C 退出)"
|
||||
else
|
||||
echo "[watch] 监听 $ROOT(校验模式:变化即本地校验并提示,间隔 ${INTERVAL}s,Ctrl-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
|
||||
@@ -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":"..."}}
|
||||
```
|
||||
|
||||
建议:提交后每 1–2s 轮询,直到 `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
@@ -1,39 +1,23 @@
|
||||
"""pytest 全局夹具:覆盖 JWT 认证依赖,让现有 API 测试默认以 admin 身份运行
|
||||
"""pytest 全局夹具
|
||||
|
||||
业务接口(search/knowledge/settings)仍使用 app.core.auth 的 JWT 依赖,
|
||||
这里通过 autouse 夹具把两个依赖统一替换为返回固定 admin AuthUser 的 lambda,
|
||||
使现有 API 测试无需改动即可通过认证。单个测试需要走真实认证逻辑时,
|
||||
可在测试函数内 pop 掉对应 override,autouse 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
@@ -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"),
|
||||
|
||||
@@ -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 tasks,failed 为空"""
|
||||
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:
|
||||
"""无 token:1005 未认证"""
|
||||
manager = FakeManager()
|
||||
_inject_manager(monkeypatch, manager)
|
||||
|
||||
resp = client.get("/api/v1/documents/tasks", headers={"Authorization": ""})
|
||||
|
||||
body = resp.json()
|
||||
assert body["code"] == 1005
|
||||
@@ -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_dir,lifespan 建集合改空操作"""
|
||||
|
||||
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 个 .md:202 + 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/tasks:items 含 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
|
||||
@@ -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}/retry:HTTP 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}/retry:manager 返回 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
@@ -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:
|
||||
"""submit:filename 优先 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_tasks:limit 截断返回条数"""
|
||||
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 为 None(done 时才从 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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user