feat: 新增多格式文件上传入库与认证体系

- 新增 JWT 认证模块,支持登录/注册/用户管理
- 新增文件上传接口,支持 .txt/.md/.html/.pdf/.docx 等格式解析入库
- 新增检索结果 AI 总结功能
- 新增文本去重缓存机制
- 新增全局认证夹具简化测试
- 新增配置项与环境变量支持
- 完善文档与测试覆盖
This commit is contained in:
2026-07-30 10:30:15 +08:00
parent 51dc8dc4f6
commit dce9e31bde
31 changed files with 3021 additions and 45 deletions
+26
View File
@@ -72,3 +72,29 @@ CACHE_TTL=300
# --- 分块参数 --- # --- 分块参数 ---
# chunk 超长二次切分阈值(字符数) # chunk 超长二次切分阈值(字符数)
CHUNK_MAX_CHARS=800 CHUNK_MAX_CHARS=800
# --- 认证(JWT---
# JWT 签名密钥,生产环境必须设置为足够长的随机串;留空则启动时自动生成(仅开发用)
JWT_SECRET_KEY=
# JWT 签名算法
JWT_ALGORITHM=HS256
# token 有效期(分钟),默认 24 小时
JWT_EXPIRE_MINUTES=1440
# 是否开放 POST /api/v1/auth/register 注册接口
AUTH_REGISTER_ENABLED=true
# 启动时自动创建的默认管理员用户名
DEFAULT_ADMIN_USERNAME=admin
# 默认管理员密码,留空则不创建默认管理员
DEFAULT_ADMIN_PASSWORD=
# --- 检索结果 AI 总结 ---
# 参与总结的最大 hit 条数(控制 prompt 长度)
RESULT_SUMMARY_MAX_HITS=5
# --- 文件上传 ---
# 原始文件保存目录(相对路径以工作目录为基)
UPLOAD_DIR=./uploads
# 单文件大小上限(MB
UPLOAD_MAX_SIZE_MB=20
# 允许上传的扩展名(逗号分隔,含点号,全小写)
UPLOAD_ALLOWED_EXTENSIONS=.txt,.md,.html,.htm,.pdf,.docx
@@ -0,0 +1,72 @@
# Checklist
## 多格式文件文本提取
- [x] `app/core/file_parser.py` 存在并实现 `parse_file(filename, content)``supported_extensions()`
- [x] 支持 .txt/.mdUTF-8 解码 errors=replace
- [x] 支持 .html/.htmhtml.parser 剥离 script/style 后提取可见文本)
- [x] 支持 .pdfpypdf 逐页 extract_text 拼接)
- [x] 支持 .docxpython-docx 段落文本拼接)
- [x] 未识别扩展名抛 `ValueError("不支持的文件类型: {ext}")`
- [x] 解析异常包装为 `ValueError("文件解析失败: {detail}")` 并保留原异常链
- [x] `tests/test_file_parser.py` 13 例覆盖各格式 + 异常路径
## 文件上传入库端点
- [x] `POST /api/v1/documents/upload` 端点存在,接收 multipart/form-datafile + title/source/metadata
- [x] 扩展名校验基于 `settings.upload_allowed_extensions`,失败抛 code=1001 含扩展名
- [x] 大小校验基于 `settings.upload_max_size_mb`,失败抛 code=1001 含「大小上限」
- [x] 调用 `parse_file` 提取文本,失败抛 code=1001
- [x] 提取文本为空抛 code=1001 含「无法从文件提取文本」
- [x] 落盘到 `settings.upload_dir`,失败仅 warning 不阻塞入库
- [x] 落盘元数据 raw_file_path/original_filename/original_size_bytes 写入 DocumentInput.metadata
- [x] 用户传入 metadata JSON 解析合并(落盘元数据优先级更高,用 setdefault
- [x] 默认 title 取文件名 stem,默认 source 取 `file:{原文件名}`
- [x] 复用现有 IngestTaskManager.submit 提交异步入库流水线
- [x] 返回 202 + `{code:0, data:{task_id, status:"pending", saved_path}}`
- [x] `tests/test_document_upload_api.py` 10 例覆盖合法上传/不支持扩展名/超大/空文本/损坏 PDF/落盘失败降级/metadata JSON/默认 title/source
## 配置项
- [x] `app/config.py` Settings 新增 `upload_dir: str = "./uploads"`
- [x] `app/config.py` Settings 新增 `upload_max_size_mb: int = 20`
- [x] `app/config.py` Settings 新增 `upload_allowed_extensions: str = ".txt,.md,.html,.htm,.pdf,.docx"`
## 依赖声明
- [x] `pyproject.toml` dependencies 含 `python-multipart>=0.0.20`
- [x] `pyproject.toml` dependencies 含 `pypdf>=5.1.0`
- [x] `pyproject.toml` dependencies 含 `python-docx>=1.1.2`
## 应用启动初始化(Task 4.1
- [x] `app/main.py` lifespan 在 `ensure_default_admin` 之后调用 `Path(settings.upload_dir).mkdir(parents=True, exist_ok=True)`
- [x] mkdir 失败仅 warning 不阻塞启动
## 环境变量模板(Task 4.2
- [x] `.env.example` 末尾含 `--- 文件上传 ---` 段落
- [x]`UPLOAD_DIR=./uploads`
- [x]`UPLOAD_MAX_SIZE_MB=20`
- [x]`UPLOAD_ALLOWED_EXTENSIONS=.txt,.md,.html,.htm,.pdf,.docx`
## 管理后台 UITask 5
- [x] `admin.html` `#section-ingest` 区块含「文件上传」子表单
- [x] file input 含 `accept=".txt,.md,.html,.htm,.pdf,.docx"`
- [x] 可选 title 输入框
- [x] 可选 source 输入框
- [x] 提交按钮 id=`btn-upload-submit`
- [x] submit 监听构造 FormData 调用 `/api/v1/documents/upload`
- [x] 复用 `renderIngestHeader` + `startIngestPolling` 展示任务进度
- [x] 提交期间禁用按钮并显示「上传中…」
- [x] 完成(成功/失败)后恢复按钮文案为「上传入库」
## 文档同步(Task 6
- [x] `CLAUDE.md` API 清单含 `POST /api/v1/documents/upload`
- [x] `CLAUDE.md` 「文档入库与三级总结」节简述文件上传通道
## 全量回归(Task 7
- [x] `uv run pytest` 全绿(289 passed,含新增 23 测试 + 既有 266 测试套件)
@@ -0,0 +1,90 @@
# 多格式文件上传入库 Spec
## Why
QMDSearch 现有入库接口 `POST /api/v1/documents` 仅接受 JSON 文本字段,用户必须先将文件内容贴成 text 才能入库;对 .md/.html/.pdf/.docx 等常见知识载体缺少直接通道。引入文件上传端点可减少入库摩擦、保留原始文件元数据、让管理后台一站式完成「上传→解析→入库→轮询任务」全流程。
## What Changes
- 新增 `app/core/file_parser.py`:按扩展名分发解析器(.txt/.md/.html/.htm/.pdf/.docx),统一异常包装。
- 新增 `POST /api/v1/documents/upload` 端点:multipart/form-data 接收文件 + 可选 title/source/metadata,校验扩展名/大小→提取文本→落盘→复用现有 IngestTaskManager 提交异步入库流水线,返回 202 + task_id + saved_path。
- 配置项:`upload_dir` / `upload_max_size_mb` / `upload_allowed_extensions` 注入到 `Settings`
- 应用启动 lifespan 中 `mkdir -p upload_dir`(失败仅 warning 不阻塞启动)。
- 依赖:`pyproject.toml` 增加 `python-multipart` / `pypdf` / `python-docx`
- 环境变量模板 `.env.example` 增加 `--- 文件上传 ---` 段落。
- 管理后台 `admin.html` 在入库区块新增「文件上传」子表单:file input + 可选 title/source + 提交按钮,构造 FormData 调用 upload 端点,复用 `ingestPollTimer` 轮询 UI;提交期间禁用按钮。
- `CLAUDE.md` API 清单加入 `POST /api/v1/documents/upload`,并在「文档入库与三级总结」节简述文件上传通道。
- 单元测试:`tests/test_file_parser.py`13 例覆盖各格式与异常路径)、`tests/test_document_upload_api.py`(10 例覆盖合法上传/不支持扩展名/超大/空文本/损坏 PDF/落盘失败降级)。
## Impact
- Affected specs: 无(首次新增能力,不修改既有 spec)
- Affected code:
- 新增:`app/core/file_parser.py``tests/test_file_parser.py``tests/test_document_upload_api.py`
- 修改:`app/api/v1/document.py``app/config.py``app/main.py``app/static/admin.html``pyproject.toml``.env.example``CLAUDE.md`
## ADDED Requirements
### Requirement: 多格式文件文本提取
系统 SHALL 提供 `parse_file(filename, content)` 函数,按扩展名分发到对应解析器:.txt/.md 走 UTF-8 解码(errors=replace 兜底);.html/.htm 走标准库 html.parser 剥离 script/style 后提取可见文本;.pdf 走 pypdf 逐页 extract_text 拼接;.docx 走 python-docx 段落文本拼接(不含表格/页眉页脚)。未识别扩展名 SHALL 抛 `ValueError("不支持的文件类型: {ext}")`;解析异常 SHALL 包装为 `ValueError("文件解析失败: {detail}")` 并保留原异常链。
#### Scenario: 支持的扩展名提取成功
- **WHEN** 调用 `parse_file("notes.md", b"# Hello\n\nWorld")`
- **THEN** 返回字符串 `"# Hello\n\nWorld"`
#### Scenario: 不支持的扩展名
- **WHEN** 调用 `parse_file("data.xlsx", b"binary")`
- **THEN** 抛 `ValueError`message 含 "不支持的文件类型: .xlsx"
#### Scenario: 损坏 PDF
- **WHEN** 调用 `parse_file("bad.pdf", b"not a real pdf")`
- **THEN** 抛 `ValueError`message 含 "文件解析失败"
### Requirement: 文件上传入库端点
系统 SHALL 提供 `POST /api/v1/documents/upload` 端点,接收 multipart/form-datafile + 可选 title/source/metadata JSON 字符串),返回 `202` + `{code:0, data:{task_id, status:"pending", saved_path}}`。端点流程 SHALL 依次:扩展名校验(基于 `settings.upload_allowed_extensions`)→ 大小校验(`settings.upload_max_size_mb`)→ `parse_file` 提取文本→ 落盘到 `settings.upload_dir`(失败仅 warning 不阻塞)→ 合并用户 metadata(落盘元数据 raw_file_path/original_filename/original_size_bytes 优先级更高)→ 默认 title 取文件名 stem、默认 source 取 `file:{原文件名}` → 提交 IngestTaskManager → 返回 202。
#### Scenario: 合法 .md 上传
- **WHEN** POST /api/v1/documents/upload 携带 file=notes.md(内容 "# Hello\n\nWorld")、title="我的笔记"、source="manual"
- **THEN** 返回 202data.task_id 非空、data.status=="pending"、data.saved_path 指向已存在文件;任务管理器收到 DocumentInputtext 与文件解码一致、metadata.original_filename=="notes.md"
#### Scenario: 不支持扩展名
- **WHEN** POST 上传 data.xlsx
- **THEN** 返回 code=1001、message 含 ".xlsx",任务管理器未收到任何提交
#### Scenario: 超大文件
- **WHEN** upload_max_size_mb=1 且上传 2MB txt
- **THEN** 返回 code=1001、message 含 "大小上限"
#### Scenario: 解析后为空文本
- **WHEN** 上传 blank.txt 内容仅空白
- **THEN** 返回 code=1001、message 含 "无法从文件提取文本"
#### Scenario: 落盘失败降级
- **WHEN** upload_dir 指向已存在的文件(mkdir 失败)
- **THEN** 返回 202saved_path==""DocumentInput.metadata 不含 raw_file_path/original_filename
### Requirement: 应用启动初始化 upload_dir
应用 lifespan SHALL 在 `ensure_default_admin` 之后尝试 `Path(settings.upload_dir).mkdir(parents=True, exist_ok=True)`,失败仅 warning 不阻塞启动。
### Requirement: 管理后台文件上传 UI
`admin.html` 入库区块 SHALL 在现有「文本入库」表单旁新增「文件上传」子表单,包含:file input(accept 当前支持的扩展名)+ 可选标题输入框 + 可选来源输入框 + 提交按钮。提交时构造 FormData 调用 `/api/v1/documents/upload`,复用现有 `ingestPollTimer` 与 ingest-result 渲染逻辑展示任务进度与最终结果。提交期间 SHALL 禁用提交按钮并显示「上传中…」文案。
#### Scenario: 用户上传文件
- **WHEN** 用户选择 notes.md 文件、填标题、点提交
- **THEN** 按钮禁用显示「上传中…」,FormData 含 file/title,请求返回 202 后开始轮询 task 状态直到 done/failed/timeout
### Requirement: 配置项与环境变量
`Settings` SHALL 新增三项配置:`upload_dir: str = "./uploads"``upload_max_size_mb: int = 20``upload_allowed_extensions: str = ".txt,.md,.html,.htm,.pdf,.docx"``.env.example` SHALL 增加 `--- 文件上传 ---` 段落包含对应环境变量 `UPLOAD_DIR` / `UPLOAD_MAX_SIZE_MB` / `UPLOAD_ALLOWED_EXTENSIONS`
### Requirement: 依赖声明
`pyproject.toml` dependencies SHALL 增加:`python-multipart>=0.0.20`FastAPI 文件上传依赖)、`pypdf>=5.1.0`PDF 解析)、`python-docx>=1.1.2`DOCX 解析)。
### Requirement: 文档同步
`CLAUDE.md` API 清单 SHALL 加入 `POST /api/v1/documents/upload` 行;「文档入库与三级总结」节 SHALL 简述文件上传通道(multipart 入口→parse_file 提取→复用现有 IngestTaskManager)。
@@ -0,0 +1,37 @@
# Tasks
## 已完成(前期工作)
- [x] Task 1: 新增 `app/core/file_parser.py` 多格式文本提取
- [x] SubTask 1.1: 实现 _parse_text/_parse_html/_parse_pdf/_parse_docx 解析器与 _PARSERS 注册表
- [x] SubTask 1.2: 实现 `parse_file(filename, content)` 入口与异常包装(不支持/解析失败)
- [x] SubTask 1.3: 实现 `supported_extensions()` 辅助函数
- [x] Task 2: 单元测试 `tests/test_file_parser.py`13 例覆盖各格式 + 异常路径)
- [x] Task 3: 新增 `POST /api/v1/documents/upload` 端点 + 测试
- [x] SubTask 3.1: 在 `app/api/v1/document.py` 增加 upload 端点(扩展名/大小/解析/落盘/metadata/默认 title-source/提交 IngestTaskManager
- [x] SubTask 3.2: 增加 `_allowed_extensions()` 辅助函数
- [x] SubTask 3.3: 在 `app/config.py` 增加 `upload_dir` / `upload_max_size_mb` / `upload_allowed_extensions`
- [x] SubTask 3.4: `tests/test_document_upload_api.py` 10 例测试(合法上传/不支持扩展名/超大/空文本/损坏 PDF/落盘失败降级/metadata JSON/默认 title/source
- [x] SubTask 3.5: `pyproject.toml` dependencies 增加 `python-multipart>=0.0.20` / `pypdf>=5.1.0` / `python-docx>=1.1.2`
## 剩余任务
- [x] Task 4: 应用启动初始化 + 环境变量模板
- [x] SubTask 4.1: 在 `app/main.py` lifespan 的 `ensure_default_admin` 之后增加 `Path(settings.upload_dir).mkdir(parents=True, exist_ok=True)`,异常仅 warning 不阻塞启动
- [x] SubTask 4.2: 在 `.env.example` 末尾增加 `--- 文件上传 ---` 段落,包含 `UPLOAD_DIR` / `UPLOAD_MAX_SIZE_MB` / `UPLOAD_ALLOWED_EXTENSIONS` 三项
- [x] Task 5: 管理后台 `admin.html` 文件上传 UI
- [x] SubTask 5.1: 在 `#section-ingest` 现有 `#ingest-form` 之后新增「文件上传」子表单,含 file inputaccept=".txt,.md,.html,.htm,.pdf,.docx"+ 可选 title + 可选 source + 提交按钮 `#btn-upload-submit`
- [x] SubTask 5.2: 新增 `#upload-form` submit 监听:构造 FormDatafile/title/source)→ 调用 `/api/v1/documents/upload` → 复用 `renderIngestHeader` + `startIngestPolling` 展示任务进度
- [x] SubTask 5.3: 提交期间禁用 `#btn-upload-submit` 显示「上传中…」,请求完成(成功/失败)后恢复按钮文案
- [x] Task 6: 文档同步 `CLAUDE.md`
- [x] SubTask 6.1: API 清单表格在 `POST /api/v1/documents` 行之后增加 `POST /api/v1/documents/upload` 行(说明:multipart 文件上传入库,202 异步)
- [x] SubTask 6.2: 「文档入库与三级总结」节增加简述:文件上传通道(multipart 入口→parse_file 提取→复用现有 IngestTaskManager→同一任务查询端点)
- [x] Task 7: 全量 pytest 验证回归
- [x] SubTask 7.1: 执行 `uv run pytest` 全绿(289 passed,含新增 23 测试 + 既有 266 测试套件)
# Task Dependencies
- Task 4 独立于 Task 5/6,可并行
- Task 5admin.html UI)依赖 Task 3 已完成(端点已存在)✓
- Task 6 独立
- Task 7 依赖 Task 4/5/6 全部完成
+3
View File
@@ -85,6 +85,7 @@ QMDSearch/
| GET | `/api/v1/health` | 健康检查 | | GET | `/api/v1/health` | 健康检查 |
| POST | `/api/v1/search` | 分层检索 | | POST | `/api/v1/search` | 分层检索 |
| POST | `/api/v1/documents` | 文档入库(202 异步入库,返回 task_id | | POST | `/api/v1/documents` | 文档入库(202 异步入库,返回 task_id |
| POST | `/api/v1/documents/upload` | multipart 文件上传入库(202 异步,支持 .txt/.md/.html/.htm/.pdf/.docx |
| GET | `/api/v1/documents/tasks/{task_id}` | 入库任务状态查询(done 附 resultfailed 附 error | | GET | `/api/v1/documents/tasks/{task_id}` | 入库任务状态查询(done 附 resultfailed 附 error |
| GET | `/api/v1/knowledge/categories` | 知识分类类目集 | | GET | `/api/v1/knowledge/categories` | 知识分类类目集 |
| GET | `/api/v1/knowledge/stats` | 统计(四层点数 + 类目分布 + uncategorized 数) | | GET | `/api/v1/knowledge/stats` | 统计(四层点数 + 类目分布 + uncategorized 数) |
@@ -131,6 +132,8 @@ QMDSearch/
不足三级 → 2.5级回退 不足三级 → 2.5级回退
``` ```
**文件上传通道**: 除 JSON 文本入库外,`POST /api/v1/documents/upload` 提供 multipart 文件上传入口,支持 .txt/.md/.html/.htm/.pdf/.docx;服务端调用 `app/core/file_parser.py` 按扩展名提取纯文本后,复用上述同一入库流水线;原始文件落盘到 `settings.upload_dir`(落盘失败仅告警不阻塞入库),任务状态同样经 `GET /api/v1/documents/tasks/{task_id}` 查询。
1. **文本提取**: 从文件中提取纯文本内容 1. **文本提取**: 从文件中提取纯文本内容
2. **三级总结**: 调用 Ollama 依次生成 L1/L2/L3 总结;其中 L2 大纲优先使用文档原生标题树(不调 LLM),无结构文本(标题数 < 2)回退 LLM 生成 2. **三级总结**: 调用 Ollama 依次生成 L1/L2/L3 总结;其中 L2 大纲优先使用文档原生标题树(不调 LLM),无结构文本(标题数 < 2)回退 LLM 生成
3. **分类判定**: 根据 L1 总结将文档分配到 taxonomy 类目,输出主类目 + 附加标签 + 置信度;LLM 输出解析失败或置信度低于阈值时归 uncategorized 兜底(低置信时候选类目名保留进 tags 供软召回) 3. **分类判定**: 根据 L1 总结将文档分配到 taxonomy 类目,输出主类目 + 附加标签 + 置信度;LLM 输出解析失败或置信度低于阈值时归 uncategorized 兜底(低置信时候选类目名保留进 tags 供软召回)
+62
View File
@@ -0,0 +1,62 @@
"""认证 APIPOST /auth/login、POST /auth/register、GET /auth/me"""
from typing import Any
import structlog
from fastapi import APIRouter, Depends
from app.api.response import ApiError, ok
from app.config import settings
from app.core.auth import (
ERR_REGISTER_DISABLED,
UserStore,
create_access_token,
get_current_user,
get_user_store,
)
from app.models.auth import (
AuthUser,
LoginRequest,
RegisterRequest,
TokenResponse,
)
logger = structlog.get_logger()
router = APIRouter(prefix="/api/v1", tags=["auth"])
def _build_token_response(user, store: UserStore) -> dict[str, Any]:
"""签发 token 并构造统一响应 data"""
token, expires_in = create_access_token(user.username, user.role)
auth_user = AuthUser(username=user.username, role=user.role, created_at=user.created_at)
resp = TokenResponse(access_token=token, expires_in=expires_in, user=auth_user)
return resp.model_dump(mode="json")
@router.post("/auth/login")
async def login(req: LoginRequest) -> dict[str, Any]:
"""用户名密码登录,返回 JWT access_token"""
store = get_user_store()
user = await store.authenticate(req.username, req.password)
logger.info("用户登录成功", username=user.username)
return ok(_build_token_response(user, store))
@router.post("/auth/register")
async def register(req: RegisterRequest) -> dict[str, Any]:
"""注册新用户(role=user),注册后自动签发 token
受 settings.auth_register_enabled 控制,关闭时返回 ERR_REGISTER_DISABLED。
"""
if not settings.auth_register_enabled:
raise ApiError(ERR_REGISTER_DISABLED, "注册已关闭")
store = get_user_store()
user = await store.create(req.username, req.password, role="user")
return ok(_build_token_response(user, store))
@router.get("/auth/me")
async def me(user: AuthUser = Depends(get_current_user)) -> dict[str, Any]:
"""返回当前登录用户信息"""
return ok(user.model_dump(mode="json"))
+103 -7
View File
@@ -1,13 +1,19 @@
"""文档 APIPOST /api/v1/documents 异步入库 + 任务查询 + 文档管理(列表/详情/删除)""" """文档 APIPOST /api/v1/documents 异步入库 + 任务查询 + 文档管理(列表/详情/删除)+ 文件上传入库"""
import json
import uuid
from datetime import UTC, datetime
from pathlib import Path
from typing import Any from typing import Any
import structlog import structlog
from fastapi import APIRouter, Query from fastapi import APIRouter, Depends, File, Form, Query, UploadFile
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from app.api.response import ApiError, ok from app.api.response import ApiError, ok
from app.config import Settings from app.config import Settings, settings
from app.core.auth import AuthUser, get_current_user, require_admin
from app.core.file_parser import parse_file
from app.core.ingest_tasks import IngestTaskManager from app.core.ingest_tasks import IngestTaskManager
from app.core.ingestion import Ingester from app.core.ingestion import Ingester
from app.models.document import DocumentInput from app.models.document import DocumentInput
@@ -55,8 +61,13 @@ def _get_task_manager() -> IngestTaskManager:
return _task_manager return _task_manager
def _allowed_extensions() -> set[str]:
"""解析 settings.upload_allowed_extensions 逗号分隔字符串为扩展名集合(全小写、含点号)"""
return {ext.strip().lower() for ext in settings.upload_allowed_extensions.split(",") if ext.strip()}
@router.post("/documents") @router.post("/documents")
async def ingest_document(doc: DocumentInput) -> JSONResponse: async def ingest_document(doc: DocumentInput, user: AuthUser = Depends(get_current_user)) -> JSONResponse:
"""文档入库入口:登记异步任务并返回 202 + task_id,入库结果经任务查询端点获取""" """文档入库入口:登记异步任务并返回 202 + task_id,入库结果经任务查询端点获取"""
if not doc.text.strip(): if not doc.text.strip():
raise ApiError(1001, "文档内容不能为空") raise ApiError(1001, "文档内容不能为空")
@@ -64,8 +75,92 @@ async def ingest_document(doc: DocumentInput) -> JSONResponse:
return JSONResponse(status_code=202, content=ok({"task_id": task_id, "status": "pending"})) return JSONResponse(status_code=202, content=ok({"task_id": task_id, "status": "pending"}))
@router.post("/documents/upload")
async def upload_document(
file: UploadFile = File(..., description="上传的文件(.txt/.md/.html/.htm/.pdf/.docx"),
title: str = Form(default="", description="可选标题,默认取原文件名去扩展"),
source: str = Form(default="", description="可选来源标识,默认 file:{原文件名}"),
metadata: str = Form(default="", description='可选元数据 JSON 字符串,如 \'{"author":"x"}\''),
user: AuthUser = Depends(get_current_user),
) -> JSONResponse:
"""文件上传入库入口:校验 → 提取文本 → 落盘 → 提交异步入库流水线
与 POST /documents 共用同一 IngestTaskManager;任务状态经
GET /api/v1/documents/tasks/{task_id} 查询。
"""
original_filename = file.filename or "unnamed"
ext = Path(original_filename).suffix.lower()
# 1. 扩展名校验
allowed = _allowed_extensions()
if ext not in allowed:
raise ApiError(1001, f"不支持的文件类型: {ext or '(无扩展名)'}")
# 2. 读取字节并校验大小
content = await file.read()
max_bytes = settings.upload_max_size_mb * 1024 * 1024
if len(content) > max_bytes:
raise ApiError(1001, f"文件超过大小上限: {settings.upload_max_size_mb}MB")
# 3. 提取文本
try:
text = parse_file(original_filename, content)
except ValueError as exc:
raise ApiError(1001, str(exc)) from exc
if not text.strip():
raise ApiError(1001, "无法从文件提取文本")
# 4. 落盘(按 YYYY/MM 日期分片;失败仅 warning,不阻塞入库)
doc_id = uuid.uuid4().hex
saved_path = ""
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)
saved_path = str(target)
metadata_dict.update(
{
"raw_file_path": saved_path,
"original_filename": original_filename,
"original_size_bytes": str(len(content)),
}
)
logger.info("上传文件已落盘", doc_id=doc_id, saved_path=saved_path, size=len(content))
except Exception:
logger.warning("上传文件落盘失败,仅做文本入库", doc_id=doc_id, filename=original_filename, exc_info=True)
# 5. 合并用户传入的 metadata(落盘元数据优先级更高,不与用户键冲突)
if metadata.strip():
try:
user_meta = json.loads(metadata)
if isinstance(user_meta, dict):
for k, v in user_meta.items():
metadata_dict.setdefault(str(k), str(v))
except (json.JSONDecodeError, TypeError):
logger.warning("metadata 不是合法 JSON,已忽略", raw=metadata)
# 6. 默认 title / source
if not title.strip():
title = Path(original_filename).stem
if not source.strip():
source = f"file:{original_filename}"
# 7. 提交入库流水线
doc_input = DocumentInput(text=text, title=title, source=source, metadata=metadata_dict)
task_id = await _get_task_manager().submit(doc_input)
return JSONResponse(
status_code=202,
content=ok({"task_id": task_id, "status": "pending", "saved_path": saved_path}),
)
@router.get("/documents/tasks/{task_id}") @router.get("/documents/tasks/{task_id}")
async def get_ingest_task(task_id: str) -> dict[str, Any]: async def get_ingest_task(task_id: str, user: AuthUser = Depends(get_current_user)) -> dict[str, Any]:
"""查询入库任务状态:含 task_id/status/created_at/updated_atdone 附 resultfailed 附 error""" """查询入库任务状态:含 task_id/status/created_at/updated_atdone 附 resultfailed 附 error"""
task = await _get_task_manager().get(task_id) task = await _get_task_manager().get(task_id)
if task is None: if task is None:
@@ -77,6 +172,7 @@ async def get_ingest_task(task_id: str) -> dict[str, Any]:
async def list_documents( async def list_documents(
limit: int = Query(default=20, ge=1, le=100), limit: int = Query(default=20, ge=1, le=100),
offset: str | None = None, offset: str | None = None,
user: AuthUser = Depends(get_current_user),
) -> dict[str, Any]: ) -> dict[str, Any]:
"""分页列出文档(L1 摘要),返回 items 与下一页游标 next_offset""" """分页列出文档(L1 摘要),返回 items 与下一页游标 next_offset"""
try: try:
@@ -88,7 +184,7 @@ async def list_documents(
@router.get("/documents/{doc_id}") @router.get("/documents/{doc_id}")
async def get_document(doc_id: str) -> dict[str, Any]: async def get_document(doc_id: str, user: AuthUser = Depends(get_current_user)) -> dict[str, Any]:
"""获取文档详情:L1 记录 + L2/L3 节点 + chunks 数量""" """获取文档详情:L1 记录 + L2/L3 节点 + chunks 数量"""
try: try:
detail = await _get_qdrant().get_doc_detail(doc_id) detail = await _get_qdrant().get_doc_detail(doc_id)
@@ -101,7 +197,7 @@ async def get_document(doc_id: str) -> dict[str, Any]:
@router.delete("/documents/{doc_id}") @router.delete("/documents/{doc_id}")
async def delete_document(doc_id: str) -> dict[str, Any]: async def delete_document(doc_id: str, user: AuthUser = Depends(require_admin)) -> dict[str, Any]:
"""删除文档:四层集合中该 doc_id 的所有点;幂等,不存在也返回成功(删除数全 0)""" """删除文档:四层集合中该 doc_id 的所有点;幂等,不存在也返回成功(删除数全 0)"""
try: try:
deleted = await _get_qdrant().delete_by_doc_id(doc_id) deleted = await _get_qdrant().delete_by_doc_id(doc_id)
+4 -3
View File
@@ -4,10 +4,11 @@ from functools import lru_cache
from typing import Any from typing import Any
import structlog import structlog
from fastapi import APIRouter from fastapi import APIRouter, Depends
from app.api.response import ApiError, ok from app.api.response import ApiError, ok
from app.config import settings from app.config import settings
from app.core.auth import AuthUser, get_current_user
from app.models.knowledge import UNCATEGORIZED, TaxonomyCategory, load_taxonomy from app.models.knowledge import UNCATEGORIZED, TaxonomyCategory, load_taxonomy
from app.services.qdrant import ALL_COLLECTIONS, COLLECTION_L1, QdrantService from app.services.qdrant import ALL_COLLECTIONS, COLLECTION_L1, QdrantService
@@ -36,14 +37,14 @@ def _get_taxonomy() -> list[TaxonomyCategory]:
@router.get("/knowledge/categories") @router.get("/knowledge/categories")
async def list_categories() -> dict[str, Any]: async def list_categories(user: AuthUser = Depends(get_current_user)) -> dict[str, Any]:
"""返回完整知识分类类目集""" """返回完整知识分类类目集"""
categories = _get_taxonomy() categories = _get_taxonomy()
return ok({"categories": [c.model_dump() for c in categories], "count": len(categories)}) return ok({"categories": [c.model_dump() for c in categories], "count": len(categories)})
@router.get("/knowledge/stats") @router.get("/knowledge/stats")
async def knowledge_stats() -> dict[str, Any]: async def knowledge_stats(user: AuthUser = Depends(get_current_user)) -> dict[str, Any]:
"""返回四层集合规模与 L1 类目分布统计""" """返回四层集合规模与 L1 类目分布统计"""
service = _get_qdrant() service = _get_qdrant()
try: try:
+5 -4
View File
@@ -4,9 +4,10 @@ from hashlib import sha256
from typing import Any from typing import Any
import structlog import structlog
from fastapi import APIRouter from fastapi import APIRouter, Depends
from app.api.response import ApiError, ok from app.api.response import ApiError, ok
from app.core.auth import AuthUser, get_current_user
from app.core.retriever import Retriever from app.core.retriever import Retriever
from app.models.search import SearchRequest from app.models.search import SearchRequest
from app.services.redis import get_cache from app.services.redis import get_cache
@@ -27,13 +28,13 @@ def _get_retriever() -> Retriever:
def _cache_key(request: SearchRequest) -> str: def _cache_key(request: SearchRequest) -> str:
"""检索缓存键:query + top_k 的短哈希(top_k 影响结果集,需参与键计算)""" """检索缓存键:query + top_k + summarize 的短哈希(三者均影响结果集,需参与键计算)"""
digest = sha256((request.query + "|" + str(request.top_k)).encode()).hexdigest()[:16] digest = sha256((request.query + "|" + str(request.top_k) + "|" + str(request.summarize)).encode()).hexdigest()[:16]
return f"search:{digest}" return f"search:{digest}"
@router.post("/search") @router.post("/search")
async def search(request: SearchRequest) -> dict[str, Any]: async def search(request: SearchRequest, user: AuthUser = Depends(get_current_user)) -> dict[str, Any]:
"""分层检索入口,返回统一包装的 SearchResponse """分层检索入口,返回统一包装的 SearchResponse
先查 Redis 缓存:命中直接返回缓存的响应;未命中走检索流程并回写缓存。 先查 Redis 缓存:命中直接返回缓存的响应;未命中走检索流程并回写缓存。
+21
View File
@@ -54,6 +54,27 @@ class Settings(BaseSettings):
# 分块参数 # 分块参数
chunk_max_chars: int = 800 # chunk 超长二次切分阈值 chunk_max_chars: int = 800 # chunk 超长二次切分阈值
# 认证(JWT
jwt_secret_key: str = "" # JWT 签名密钥,为空时启动自动生成(仅开发,生产必填)
jwt_algorithm: str = "HS256"
jwt_expire_minutes: int = 1440 # token 有效期(分钟),默认 24 小时
auth_register_enabled: bool = True # 是否开放 POST /auth/register
default_admin_username: str = "admin" # 启动时自动创建的默认管理员用户名
default_admin_password: str = "" # 默认管理员密码,为空则不创建默认管理员
# 文件上传
upload_dir: str = "./uploads" # 原始文件保存目录(相对路径以工作目录为基)
upload_max_size_mb: int = 20 # 单文件大小上限(MB
upload_allowed_extensions: str = ".txt,.md,.html,.htm,.pdf,.docx" # 允许上传的扩展名(逗号分隔)
# PDF OCR(图片型/扫描件降级,pypdf extract_text 为空时触发)
pdf_ocr_enabled: bool = True # 是否启用 OCR 降级(关闭则扫描件按"无法提取文本"拒绝入库)
pdf_ocr_max_pages: int = 30 # 单文件 OCR 页数上限,超过仅前 N 页
pdf_ocr_dpi: int = 200 # 渲染 DPI(越高越准但越慢,72~300 合理)
# 检索结果 AI 总结
result_summary_max_hits: int = 5 # 参与总结的最大 hit 条数(控制 prompt 长度)
model_config = {"env_prefix": "", "case_sensitive": False} model_config = {"env_prefix": "", "case_sensitive": False}
+213
View File
@@ -0,0 +1,213 @@
"""JWT 认证核心:密码哈希、token 签发/验签、用户存储、FastAPI 鉴权依赖
用户持久化在 Rediskey: auth:user:{username})。登录/注册需 Redis 可用;
鉴权(get_current_user)以 JWT 自包含信息为主,Redis 不可用时降级可用。
"""
from datetime import UTC, datetime, timedelta
import bcrypt
import jwt
import structlog
from fastapi import Depends
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from redis import asyncio as redis_async
from app.api.response import ApiError
from app.config import settings
from app.models.auth import AuthUser, StoredUser
logger = structlog.get_logger()
# 错误码
ERR_UNAUTHORIZED = 1003 # 未认证(需要登录)
ERR_TOKEN_INVALID = 1005 # token 无效或已过期
ERR_FORBIDDEN = 1006 # 权限不足
ERR_USER_EXISTS = 1007 # 用户名已存在
ERR_BAD_CREDENTIALS = 1008 # 用户名或密码错误
ERR_REGISTER_DISABLED = 1009 # 注册已关闭
# Bearer token 提取器(auto_error=False,统一由 ApiError 处理)
_bearer = HTTPBearer(auto_error=False)
# 模块级 JWT secretsettings 为空时自动生成,仅开发用)
_auto_secret: str | None = None
# 模块级用户存储单例
_user_store: "UserStore | None" = None
def _get_secret() -> str:
"""获取 JWT 签名密钥,settings 为空时生成一次性随机密钥(仅开发)"""
global _auto_secret
if settings.jwt_secret_key:
return settings.jwt_secret_key
if _auto_secret is None:
import secrets
_auto_secret = secrets.token_urlsafe(48)
logger.warning(
"JWT_SECRET_KEY 未配置,已自动生成随机密钥(仅开发用,重启后旧 token 失效)"
)
return _auto_secret
def hash_password(password: str) -> str:
"""密码 bcrypt 哈希(返回 utf-8 字符串形式的哈希)"""
return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
def verify_password(password: str, hashed: str) -> bool:
"""校验明文密码与哈希是否匹配,异常或不匹配均返回 False"""
try:
return bcrypt.checkpw(password.encode("utf-8"), hashed.encode("utf-8"))
except Exception:
return False
def create_access_token(username: str, role: str) -> tuple[str, int]:
"""签发 JWT,返回 (token, expires_in_seconds)"""
now = datetime.now(UTC)
expire = now + timedelta(minutes=settings.jwt_expire_minutes)
payload = {
"sub": username,
"role": role,
"iat": int(now.timestamp()),
"exp": int(expire.timestamp()),
}
token = jwt.encode(payload, _get_secret(), algorithm=settings.jwt_algorithm)
return token, settings.jwt_expire_minutes * 60
def decode_token(token: str) -> dict:
"""验签并返回 payload,失败抛 ApiError(ERR_TOKEN_INVALID)"""
try:
return jwt.decode(token, _get_secret(), algorithms=[settings.jwt_algorithm])
except jwt.ExpiredSignatureError as exc:
raise ApiError(ERR_TOKEN_INVALID, "token 已过期") from exc
except jwt.InvalidTokenError as exc:
raise ApiError(ERR_TOKEN_INVALID, "token 无效") from exc
class UserStore:
"""Redis 用户存储"""
def __init__(self, redis_url: str | None = None) -> None:
self._redis_url = redis_url or settings.redis_url
self._client: redis_async.Redis | None = None
def _get_client(self) -> redis_async.Redis:
if self._client is None:
self._client = redis_async.from_url(self._redis_url, decode_responses=True)
return self._client
@staticmethod
def _key(username: str) -> str:
return f"auth:user:{username}"
async def get(self, username: str) -> StoredUser | None:
"""读取用户,Redis 异常或数据损坏均返回 None"""
try:
raw = await self._get_client().get(self._key(username))
except Exception:
logger.error("Redis 读取用户失败", username=username, exc_info=True)
return None
if raw is None:
return None
try:
return StoredUser.model_validate_json(raw)
except Exception:
logger.error("用户数据损坏", username=username, exc_info=True)
return None
async def exists(self, username: str) -> bool:
try:
return bool(await self._get_client().exists(self._key(username)))
except Exception:
logger.error("Redis 检查用户存在性失败", username=username, exc_info=True)
return False
async def create(
self, username: str, password: str, role: str = "user"
) -> StoredUser:
"""创建用户,已存在抛 ApiError(ERR_USER_EXISTS)Redis 写失败抛 ApiError(2000)"""
if await self.exists(username):
raise ApiError(ERR_USER_EXISTS, f"用户名已存在: {username}")
user = StoredUser(
username=username,
role=role,
created_at=datetime.now(UTC),
hashed_password=hash_password(password),
)
try:
await self._get_client().set(self._key(username), user.model_dump_json())
except Exception as exc:
logger.error("Redis 写入用户失败", username=username, exc_info=True)
raise ApiError(2000, f"用户创建失败: {exc}") from exc
logger.info("用户创建成功", username=username, role=role)
return user
async def authenticate(self, username: str, password: str) -> StoredUser:
"""验证用户名密码,失败抛 ApiError(ERR_BAD_CREDENTIALS)"""
user = await self.get(username)
if user is None or not verify_password(password, user.hashed_password):
raise ApiError(ERR_BAD_CREDENTIALS, "用户名或密码错误")
return user
def get_user_store() -> UserStore:
"""全局 UserStore 单例"""
global _user_store
if _user_store is None:
_user_store = UserStore()
return _user_store
async def ensure_default_admin() -> None:
"""启动时按配置创建默认管理员(密码为空则跳过)"""
pwd = settings.default_admin_password
if not pwd:
return
store = get_user_store()
try:
if await store.exists(settings.default_admin_username):
return
await store.create(settings.default_admin_username, pwd, role="admin")
logger.info("默认管理员已创建", username=settings.default_admin_username)
except ApiError:
raise
except Exception:
logger.warning("默认管理员创建失败,跳过", exc_info=True)
async def get_current_user(
credentials: HTTPAuthorizationCredentials | None = Depends(_bearer),
) -> AuthUser:
"""FastAPI 鉴权依赖:解析 Bearer token,返回 AuthUser
优先查 Redis 获取最新用户信息(支持删除/改角色生效);
Redis 不可用或用户未命中时降级为 JWT payload 构造 AuthUser,保证鉴权可用。
"""
if credentials is None or (credentials.scheme or "").lower() != "bearer":
raise ApiError(ERR_UNAUTHORIZED, "未提供认证凭证,请先登录")
payload = decode_token(credentials.credentials)
username = payload.get("sub")
role = payload.get("role", "user")
if not username:
raise ApiError(ERR_TOKEN_INVALID, "token 缺少用户标识")
stored = await get_user_store().get(username)
if stored is not None:
return AuthUser(
username=stored.username, role=stored.role, created_at=stored.created_at
)
# Redis 不可用或用户不存在(可能已删除):降级用 JWT payload
logger.warning("用户存储查询未命中,降级使用 JWT payload", username=username)
return AuthUser(username=username, role=role, created_at=datetime.now(UTC))
async def require_admin(user: AuthUser = Depends(get_current_user)) -> AuthUser:
"""要求管理员角色"""
if user.role != "admin":
raise ApiError(ERR_FORBIDDEN, "需要管理员权限")
return user
+204
View File
@@ -0,0 +1,204 @@
"""多格式文件文本提取:按扩展名分发到对应解析器
支持的扩展名:
- .txt / .mdUTF-8 解码(errors="replace" 兜底)
- .html / .htm:标准库 html.parser 剥离标签提取可见文本
- .pdfpypdf 逐页 extract_text 拼接;文本层为空(扫描件/图片型)时
自动降级为 OCRpypdfium2 渲染 + rapidocr-onnxruntime 识别)
- .docxpython-docx 段落文本拼接(不含表格/页眉页脚)
未识别扩展名抛 ValueError("不支持的文件类型: {ext}")
解析异常统一包装为 ValueError("文件解析失败: {detail}"),原异常链式保留。
"""
from __future__ import annotations
import io
from collections.abc import Callable
from html.parser import HTMLParser
from pathlib import Path
from typing import Any
import structlog
from app.config import settings
logger = structlog.get_logger()
# 模块级懒加载 OCR 引擎单例(首次调用时初始化,避免无扫描件场景白白下载模型)
_ocr_engine: Any | None = None
_ocr_unavailable: bool = False # 标记 OCR 依赖不可用,后续直接跳过避免重复尝试
class _VisibleTextExtractor(HTMLParser):
"""HTMLParser 子类:累积可见文本,跳过 script/style 内容"""
def __init__(self) -> None:
super().__init__(convert_charrefs=True)
self._parts: list[str] = []
self._skip_depth = 0 # 在 script/style 标签内时 > 0
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
if tag.lower() in {"script", "style"}:
self._skip_depth += 1
def handle_endtag(self, tag: str) -> None:
if tag.lower() in {"script", "style"} and self._skip_depth > 0:
self._skip_depth -= 1
def handle_data(self, data: str) -> None:
if self._skip_depth == 0:
self._parts.append(data)
def get_text(self) -> str:
# 块级标签间用空格连接,再折叠多余空白
text = " ".join(self._parts)
return " ".join(text.split())
def _decode_html(content: bytes) -> str:
"""HTML 内容解码:优先 utf-8(带 BOM),失败回退 latin-1"""
try:
return content.decode("utf-8-sig")
except UnicodeDecodeError:
return content.decode("latin-1", errors="replace")
def _parse_text(content: bytes) -> str:
"""UTF-8 解码(errors=replace 兜底),保留原字符"""
return content.decode("utf-8", errors="replace")
def _parse_html(content: bytes) -> str:
"""HTML 剥离标签,保留可见文本"""
parser = _VisibleTextExtractor()
parser.feed(_decode_html(content))
parser.close()
return parser.get_text()
def _parse_pdf(content: bytes) -> str:
"""PDF 解析:优先 pypdf extract_text;文本层为空(扫描件)时降级 OCR
OCR 流程:pypdfium2 渲染每页为 PIL Image → rapidocr-onnxruntime 识别 →
拼接每页识别出的文本。受 settings.pdf_ocr_* 控制:开关、最大页数、DPI。
OCR 依赖未安装或运行异常时降级返回空字符串(由上游 upload 端点拒绝入库)。
"""
from pypdf import PdfReader
reader = PdfReader(io.BytesIO(content))
parts: list[str] = []
for page in reader.pages:
text = page.extract_text() or ""
if text:
parts.append(text)
text_layer = "\n".join(parts).strip()
# 文本层非空:直接返回
if text_layer:
return text_layer
# 文本层为空 → 尝试 OCR 降级
if not settings.pdf_ocr_enabled:
return ""
ocr_text = _ocr_pdf(content, max_pages=settings.pdf_ocr_max_pages, dpi=settings.pdf_ocr_dpi)
return ocr_text
def _ocr_pdf(content: bytes, max_pages: int, dpi: int) -> str:
"""对扫描件 PDF 跑 OCR:渲染每页 → 识别 → 拼接
返回空字符串的场景:依赖未安装 / 渲染或识别异常 / 无识别结果。
任何异常仅告警不抛出,由上游按"无法提取文本"处理。
"""
global _ocr_engine, _ocr_unavailable
if _ocr_unavailable:
return ""
# 1. 懒加载 OCR 引擎
if _ocr_engine is None:
try:
from rapidocr_onnxruntime import RapidOCR
_ocr_engine = RapidOCR()
logger.info("PDF OCR 引擎已初始化", dpi=dpi)
except Exception:
_ocr_unavailable = True
logger.warning("OCR 依赖不可用,扫描件 PDF 将无法提取文本", exc_info=True)
return ""
# 2. 渲染并识别
try:
import pypdfium2 as pdfium
scale = max(1.0, dpi / 72.0)
pdf = pdfium.PdfDocument(io.BytesIO(content))
total = min(len(pdf), max(1, max_pages))
page_texts: list[str] = []
for i in range(total):
page = pdf[i]
pil_image = page.render(scale=scale).to_pil()
result, _ = _ocr_engine(pil_image)
if result:
# result: [[box, text, score], ...],按行拼接
lines = [item[1] for item in result if item and len(item) >= 2 and item[1]]
if lines:
page_texts.append("\n".join(lines))
pdf.close()
return "\n".join(page_texts).strip()
except Exception as exc:
logger.warning("PDF OCR 失败,降级返回空文本", error=str(exc), exc_info=True)
return ""
def _parse_docx(content: bytes) -> str:
"""DOCX 段落文本拼接(不含表格/页眉页脚)"""
from docx import Document # type: ignore[import-untyped]
document = Document(io.BytesIO(content))
parts = [p.text for p in document.paragraphs if p.text and p.text.strip()]
return "\n".join(parts).strip()
# 扩展名 → 解析函数映射(启动时构建,避免每次请求重复构造)
_PARSERS: dict[str, Callable[[bytes], str]] = {
".txt": _parse_text,
".md": _parse_text,
".html": _parse_html,
".htm": _parse_html,
".pdf": _parse_pdf,
".docx": _parse_docx,
}
def parse_file(filename: str, content: bytes) -> str:
"""按扩展名分发解析器提取文本
Args:
filename: 文件名(用于判定扩展名)
content: 文件二进制内容
Returns:
提取的纯文本
Raises:
ValueError: 未识别扩展名 → "不支持的文件类型: {ext}"
解析失败 → "文件解析失败: {detail}"(保留原异常链)
"""
ext = Path(filename).suffix.lower()
parser = _PARSERS.get(ext)
if parser is None:
raise ValueError(f"不支持的文件类型: {ext or '(无扩展名)'}")
try:
return parser(content)
except ValueError:
raise
except Exception as exc:
raise ValueError(f"文件解析失败: {exc}") from exc
def supported_extensions() -> set[str]:
"""返回当前支持的扩展名集合(含点号,全小写)"""
return set(_PARSERS.keys())
+63 -5
View File
@@ -9,6 +9,7 @@ Redis 不可用或写入失败仅记录 warning,不影响任务执行。
""" """
import asyncio import asyncio
import hashlib
import uuid import uuid
from datetime import UTC, datetime from datetime import UTC, datetime
from enum import StrEnum from enum import StrEnum
@@ -25,6 +26,8 @@ logger = structlog.get_logger()
# Redis 任务状态 key 前缀 # Redis 任务状态 key 前缀
REDIS_KEY_PREFIX = "ingest_task:" REDIS_KEY_PREFIX = "ingest_task:"
# Redis 文本去重 key 前缀(value 为已入库文档的 IngestionResult JSON
DEDUP_KEY_PREFIX = "dedup:sha256:"
class IngestTaskStatus(StrEnum): class IngestTaskStatus(StrEnum):
@@ -62,9 +65,39 @@ class IngestTaskManager:
self._mirror_tasks: set[asyncio.Task[None]] = set() self._mirror_tasks: set[asyncio.Task[None]] = set()
async def submit(self, doc: DocumentInput) -> str: async def submit(self, doc: DocumentInput) -> str:
"""登记入库任务并后台执行,立即返回 task_id""" """登记入库任务并后台执行,立即返回 task_id
文本去重:基于 doc.text 的 sha256 在 Redis 中查重;命中则直接复用旧
IngestionResult(仅置 deduplicated=True),不重跑流水线;未命中走原
异步入库流程,完成后写入去重记录供后续命中复用。Redis 不可用时跳过
去重,按原流程执行,不影响主流程。
"""
task_id = uuid.uuid4().hex task_id = uuid.uuid4().hex
now = _utc_now_iso() now = _utc_now_iso()
text_hash = hashlib.sha256(doc.text.encode("utf-8")).hexdigest()
# 1. 去重命中:直接置 done,复用旧结果,不调 _run
dedup_record = await self._lookup_dedup(text_hash)
if dedup_record is not None:
result_dict = dict(dedup_record)
result_dict["deduplicated"] = True
self._tasks[task_id] = {
"task_id": task_id,
"status": IngestTaskStatus.DONE,
"created_at": now,
"updated_at": now,
"result": result_dict,
"error": None,
}
self._schedule_mirror(task_id)
logger.info(
"入库任务命中去重,复用既有文档",
task_id=task_id,
document_id=result_dict.get("document_id"),
)
return task_id
# 2. 未命中:登记 pending 并后台跑流水线
self._tasks[task_id] = { self._tasks[task_id] = {
"task_id": task_id, "task_id": task_id,
"status": IngestTaskStatus.PENDING, "status": IngestTaskStatus.PENDING,
@@ -74,7 +107,7 @@ class IngestTaskManager:
"error": None, "error": None,
} }
self._schedule_mirror(task_id) self._schedule_mirror(task_id)
background = asyncio.create_task(self._run(task_id, doc)) background = asyncio.create_task(self._run(task_id, doc, text_hash))
self._background_tasks.add(background) self._background_tasks.add(background)
background.add_done_callback(self._background_tasks.discard) background.add_done_callback(self._background_tasks.discard)
logger.info("入库任务已登记", task_id=task_id, title=doc.title) logger.info("入库任务已登记", task_id=task_id, title=doc.title)
@@ -110,8 +143,8 @@ class IngestTaskManager:
raise TimeoutError(f"入库任务 {task_id}{timeout}s 内未进入终态") raise TimeoutError(f"入库任务 {task_id}{timeout}s 内未进入终态")
await asyncio.sleep(0.01) await asyncio.sleep(0.01)
async def _run(self, task_id: str, doc: DocumentInput) -> None: async def _run(self, task_id: str, doc: DocumentInput, text_hash: str) -> None:
"""后台执行入库:并发限流 + 阶段状态推进 + 结果/错误落账""" """后台执行入库:并发限流 + 阶段状态推进 + 结果/错误落账 + 去重记录写入"""
async with self._semaphore: async with self._semaphore:
try: try:
result = await self._ingester.ingest(doc, progress_cb=lambda stage: self._on_progress(task_id, stage)) result = await self._ingester.ingest(doc, progress_cb=lambda stage: self._on_progress(task_id, stage))
@@ -128,14 +161,39 @@ class IngestTaskManager:
except Exception as exc: except Exception as exc:
self._finish_failed(task_id, {"stage": "unknown", "message": str(exc), "partial_summary": None}) self._finish_failed(task_id, {"stage": "unknown", "message": str(exc), "partial_summary": None})
else: else:
result_dict = result.model_dump(mode="json")
self._tasks[task_id].update( self._tasks[task_id].update(
status=IngestTaskStatus.DONE, status=IngestTaskStatus.DONE,
updated_at=_utc_now_iso(), updated_at=_utc_now_iso(),
result=result.model_dump(mode="json"), result=result_dict,
) )
self._schedule_mirror(task_id) self._schedule_mirror(task_id)
await self._record_dedup(text_hash, result_dict)
logger.info("入库任务完成", task_id=task_id) logger.info("入库任务完成", task_id=task_id)
async def _lookup_dedup(self, text_hash: str) -> dict[str, Any] | None:
"""查询文本去重记录;Redis 不可用或异常时降级为未命中"""
if self._redis is None:
return None
try:
return await self._redis.get_json(f"{DEDUP_KEY_PREFIX}{text_hash}")
except Exception:
logger.warning("去重记录查询失败,降级为未命中", text_hash=text_hash, exc_info=True)
return None
async def _record_dedup(self, text_hash: str, result_dict: dict[str, Any]) -> None:
"""写入文本去重记录(含完整 IngestionResult),供后续命中复用;失败仅告警"""
if self._redis is None:
return
try:
await self._redis.set_json(
f"{DEDUP_KEY_PREFIX}{text_hash}",
result_dict,
ttl=self._settings.ingest_task_ttl_done,
)
except Exception:
logger.warning("去重记录写入失败", text_hash=text_hash, exc_info=True)
def _finish_failed(self, task_id: str, error: dict[str, Any]) -> None: def _finish_failed(self, task_id: str, error: dict[str, Any]) -> None:
"""将任务置为 failed 并记录错误信息""" """将任务置为 failed 并记录错误信息"""
self._tasks[task_id].update(status=IngestTaskStatus.FAILED, updated_at=_utc_now_iso(), error=error) self._tasks[task_id].update(status=IngestTaskStatus.FAILED, updated_at=_utc_now_iso(), error=error)
+28 -3
View File
@@ -40,6 +40,9 @@ class ParsedQuery(BaseModel):
rewrite: str = Field(description="rewrite 后的 query") rewrite: str = Field(description="rewrite 后的 query")
keywords: list[str] = Field(default_factory=list, description="提取的关键词") keywords: list[str] = Field(default_factory=list, description="提取的关键词")
categories: list[CategoryHit] = Field(default_factory=list, description="命中类目列表") categories: list[CategoryHit] = Field(default_factory=list, description="命中类目列表")
entities: list[str] = Field(default_factory=list, description="提取的实体(人名/产品/技术等)")
intent: str = Field(default="", description="查询意图分类(如 查询/对比/操作/定义/排查)")
time_range: str = Field(default="", description="时间范围(如 '2023年''最近一个月'),空表示无")
parse_failed: bool = Field(default=False, description="LLM 输出解析是否失败") parse_failed: bool = Field(default=False, description="LLM 输出解析是否失败")
@@ -136,11 +139,28 @@ class QueryParser:
logger.warning("query 解析失败:JSON 必填字段缺失或类型错误", query=query, output=raw[:200]) logger.warning("query 解析失败:JSON 必填字段缺失或类型错误", query=query, output=raw[:200])
return ParsedQuery(raw_query=query, rewrite=query, parse_failed=True) return ParsedQuery(raw_query=query, rewrite=query, parse_failed=True)
# 扩展字段:实体/意图/时间范围(缺失或类型错误时降级为默认值,不触发 parse_failed
entities = data.get("entities", [])
if not isinstance(entities, list):
logger.warning("query 解析 entities 非列表,置空", query=query)
entities = []
intent = data.get("intent", "")
if not isinstance(intent, str):
logger.warning("query 解析 intent 非字符串,置空", query=query)
intent = ""
time_range = data.get("time_range", "")
if not isinstance(time_range, str):
logger.warning("query 解析 time_range 非字符串,置空", query=query)
time_range = ""
return ParsedQuery( return ParsedQuery(
raw_query=query, raw_query=query,
rewrite=rewrite, rewrite=rewrite,
keywords=[str(k) for k in keywords], keywords=[str(k) for k in keywords],
categories=self._validate_categories(categories, query), categories=self._validate_categories(categories, query),
entities=[str(e) for e in entities],
intent=intent,
time_range=time_range,
) )
async def parse_and_route(self, query: str) -> RouteDecision: async def parse_and_route(self, query: str) -> RouteDecision:
@@ -178,17 +198,22 @@ class QueryParser:
category_lines = [f"- {c.name}: {c.description}" for c in self.taxonomy if c.name != UNCATEGORIZED] category_lines = [f"- {c.name}: {c.description}" for c in self.taxonomy if c.name != UNCATEGORIZED]
category_block = "\n".join(category_lines) category_block = "\n".join(category_lines)
return ( return (
"你是搜索查询分析助手。请分析用户 query,完成三件事\n" "你是搜索查询分析助手。请分析用户 query,完成以下事项\n"
"1. 判断 query 意图命中以下哪些知识类目,并给出每个类目的置信度(0~1 之间的小数);\n" "1. 判断 query 意图命中以下哪些知识类目,并给出每个类目的置信度(0~1 之间的小数);\n"
"2. 将 query 改写为更适合检索的形式;\n" "2. 将 query 改写为更适合检索的形式;\n"
"3. 提取 query 的关键词\n\n" "3. 提取 query 的关键词\n"
"4. 提取 query 中的实体(人名、产品名、技术名词、组织机构等);\n"
"5. 判断 query 的查询意图,从 [查询, 对比, 操作, 定义, 排查] 中选最接近的一个;\n"
"6. 提取 query 中的时间范围(如 '2023年''上个月''Q1'),无则留空字符串。\n\n"
f"可选类目:\n{category_block}\n\n" f"可选类目:\n{category_block}\n\n"
"要求:\n" "要求:\n"
"- categories 中的 name 只能从上面的类目名中选择,不要输出其他名称;\n" "- categories 中的 name 只能从上面的类目名中选择,不要输出其他名称;\n"
"- 若没有明显命中的类目,categories 返回空列表;\n" "- 若没有明显命中的类目,categories 返回空列表;\n"
"- entities 没有则返回空数组;\n"
"- 只输出 JSON,不要输出任何其他内容。\n\n" "- 只输出 JSON,不要输出任何其他内容。\n\n"
'输出格式:{"categories": [{"name": "类目名", "confidence": 0.0}], ' '输出格式:{"categories": [{"name": "类目名", "confidence": 0.0}], '
'"rewrite": "改写后的 query", "keywords": ["关键词"]}\n\n' '"rewrite": "改写后的 query", "keywords": ["关键词"], '
'"entities": ["实体"], "intent": "查询", "time_range": ""}\n\n'
f"用户 query{query}" f"用户 query{query}"
) )
+66
View File
@@ -0,0 +1,66 @@
"""检索结果 AI 总结
对检索返回的 chunk 命中结果,调用 Ollama 本地模型生成一段针对用户 query 的总结回答。
仅基于检索结果内容,不编造未提及的信息。
"""
import structlog
from app.config import settings
from app.models.search import SearchHit
from app.services.ollama import OllamaClient
logger = structlog.get_logger()
class ResultSummarizer:
"""检索结果总结器"""
def __init__(self, ollama: OllamaClient | None = None) -> None:
self.ollama = ollama or OllamaClient()
async def summarize(self, query: str, hits: list[SearchHit]) -> str:
"""对检索结果生成针对 query 的总结
取前 settings.result_summary_max_hits 条命中拼接为上下文,
无命中时返回空字符串(不调 LLM)。
"""
if not hits:
return ""
max_hits = settings.result_summary_max_hits
selected = hits[:max_hits]
context = self._build_context(selected)
prompt = self._build_prompt(query, context)
try:
summary = await self.ollama.generate(prompt)
except Exception:
logger.warning("检索结果总结生成失败,返回空字符串", exc_info=True)
return ""
logger.info("检索结果总结完成", query=query, hits_count=len(selected), summary_len=len(summary))
return summary.strip()
@staticmethod
def _build_context(hits: list[SearchHit]) -> str:
"""拼接命中结果为带编号的上下文"""
blocks: list[str] = []
for i, hit in enumerate(hits, start=1):
header_parts = [f"[{i}]"]
if hit.title:
header_parts.append(hit.title)
if hit.section_path:
header_parts.append(hit.section_path)
header = " / ".join(header_parts)
blocks.append(f"{header}\n{hit.text}")
return "\n\n---\n\n".join(blocks)
@staticmethod
def _build_prompt(query: str, context: str) -> str:
return (
"请根据以下检索结果,针对用户问题生成一段简洁的总结回答。\n"
"要求:\n"
"- 综合多条结果信息,不要简单逐条罗列;\n"
"- 只基于检索结果内容,不编造未提及的信息;\n"
"- 用中文回答,简洁明了。\n\n"
f"用户问题:{query}\n\n"
f"检索结果:\n{context}"
)
+39 -14
View File
@@ -15,11 +15,12 @@ from qdrant_client import models
from app.config import settings from app.config import settings
from app.core.embeddings import EmbeddingService, create_embedding_service from app.core.embeddings import EmbeddingService, create_embedding_service
from app.core.query_parser import QueryParser from app.core.query_parser import QueryParser, RouteDecision
from app.core.ranker import finalize, rrf_fuse from app.core.ranker import finalize, rrf_fuse
from app.core.result_summarizer import ResultSummarizer
from app.core.sparse import SparseEncoder from app.core.sparse import SparseEncoder
from app.models.knowledge import load_taxonomy from app.models.knowledge import load_taxonomy
from app.models.search import SearchHit, SearchRequest, SearchResponse from app.models.search import ExtractedInfo, SearchHit, SearchRequest, SearchResponse
from app.services.ollama import OllamaClient from app.services.ollama import OllamaClient
from app.services.qdrant import ( from app.services.qdrant import (
COLLECTION_CHUNKS, COLLECTION_CHUNKS,
@@ -47,6 +48,7 @@ class Retriever:
query_parser: QueryParser | None = None, query_parser: QueryParser | None = None,
embedding: EmbeddingService | None = None, embedding: EmbeddingService | None = None,
sparse_encoder: SparseEncoder | None = None, sparse_encoder: SparseEncoder | None = None,
result_summarizer: ResultSummarizer | None = None,
) -> None: ) -> None:
self.qdrant = qdrant or QdrantService() self.qdrant = qdrant or QdrantService()
self.query_parser = query_parser or QueryParser( self.query_parser = query_parser or QueryParser(
@@ -55,6 +57,7 @@ class Retriever:
) )
self.embedding = embedding or create_embedding_service() self.embedding = embedding or create_embedding_service()
self.sparse_encoder = sparse_encoder or SparseEncoder() self.sparse_encoder = sparse_encoder or SparseEncoder()
self.result_summarizer = result_summarizer or ResultSummarizer()
async def search(self, request: SearchRequest) -> SearchResponse: async def search(self, request: SearchRequest) -> SearchResponse:
"""分层检索主流程""" """分层检索主流程"""
@@ -74,12 +77,8 @@ class Retriever:
# L1 无候选文档 → 全库 chunk 兜底 # L1 无候选文档 → 全库 chunk 兜底
chunk_hits = await self._search_collection(COLLECTION_CHUNKS, dense, sparse, settings.retrieval_top_k, None) chunk_hits = await self._search_collection(COLLECTION_CHUNKS, dense, sparse, settings.retrieval_top_k, None)
logger.info("L1 无命中,全库 chunk 兜底", hits=len(chunk_hits)) logger.info("L1 无命中,全库 chunk 兜底", hits=len(chunk_hits))
return SearchResponse( hits = self._to_hits(finalize(chunk_hits, self._final_k(request)))
query=request.query, return await self._build_response(request, route, hits, fallback=True)
hits=self._to_hits(finalize(chunk_hits, self._final_k(request))),
routed_categories=route.filter_categories or [],
fallback=True,
)
doc_ids = _unique((p.payload or {}).get("doc_id") for p in l1_hits) doc_ids = _unique((p.payload or {}).get("doc_id") for p in l1_hits)
@@ -125,12 +124,8 @@ class Retriever:
logger.info("chunk 检索完成", hits=len(chunk_hits)) logger.info("chunk 检索完成", hits=len(chunk_hits))
final_points = finalize(rrf_fuse([chunk_hits]), self._final_k(request)) final_points = finalize(rrf_fuse([chunk_hits]), self._final_k(request))
return SearchResponse( hits = self._to_hits(final_points)
query=request.query, return await self._build_response(request, route, hits, fallback=route.fallback)
hits=self._to_hits(final_points),
routed_categories=route.filter_categories or [],
fallback=route.fallback,
)
async def _search_collection( async def _search_collection(
self, self,
@@ -167,3 +162,33 @@ class Retriever:
) )
) )
return hits return hits
async def _build_response(
self,
request: SearchRequest,
route: RouteDecision,
hits: list[SearchHit],
*,
fallback: bool,
) -> SearchResponse:
"""构造最终响应:组装 AI 提取信息,按需生成结果总结"""
parsed = route.parsed
extracted = ExtractedInfo(
rewrite=parsed.rewrite,
keywords=parsed.keywords,
entities=parsed.entities,
intent=parsed.intent,
time_range=parsed.time_range,
categories=[c.name for c in parsed.categories],
)
summary: str | None = None
if request.summarize:
summary = await self.result_summarizer.summarize(request.query, hits)
return SearchResponse(
query=request.query,
hits=hits,
routed_categories=route.filter_categories or [],
fallback=fallback,
extracted_info=extracted,
summary=summary,
)
+14 -2
View File
@@ -8,10 +8,12 @@ from fastapi.exceptions import RequestValidationError
from fastapi.responses import FileResponse, JSONResponse from fastapi.responses import FileResponse, JSONResponse
from app.api.response import ApiError, error from app.api.response import ApiError, error
from app.api.v1.auth import router as auth_router
from app.api.v1.document import router as document_router from app.api.v1.document import router as document_router
from app.api.v1.knowledge import router as knowledge_router from app.api.v1.knowledge import router as knowledge_router
from app.api.v1.search import router as search_router from app.api.v1.search import router as search_router
from app.config import settings from app.config import settings
from app.core.auth import ensure_default_admin
from app.services.qdrant import QdrantService from app.services.qdrant import QdrantService
logger = structlog.get_logger() logger = structlog.get_logger()
@@ -19,15 +21,24 @@ logger = structlog.get_logger()
@asynccontextmanager @asynccontextmanager
async def lifespan(_: FastAPI) -> AsyncIterator[None]: async def lifespan(_: FastAPI) -> AsyncIterator[None]:
"""应用生命周期:启动时初始化 Qdrant 集合 """应用生命周期:启动时初始化 Qdrant 集合与默认管理员
初始化失败仅记录日志、不阻止启动(本地开发可能无 Qdrant)。 初始化失败仅记录日志、不阻止启动(本地开发可能无 Qdrant/Redis)。
""" """
try: try:
await QdrantService().ensure_collections() await QdrantService().ensure_collections()
logger.info("Qdrant 集合初始化完成") logger.info("Qdrant 集合初始化完成")
except Exception: except Exception:
logger.error("Qdrant 集合初始化失败,跳过初始化继续启动") logger.error("Qdrant 集合初始化失败,跳过初始化继续启动")
try:
await ensure_default_admin()
except Exception:
logger.warning("默认管理员初始化失败,跳过", exc_info=True)
try:
Path(settings.upload_dir).mkdir(parents=True, exist_ok=True)
logger.info("上传目录已就绪", upload_dir=settings.upload_dir)
except Exception:
logger.warning("上传目录初始化失败,文件落盘将按需创建", exc_info=True)
yield yield
@@ -38,6 +49,7 @@ app = FastAPI(
lifespan=lifespan, lifespan=lifespan,
) )
app.include_router(auth_router)
app.include_router(search_router) app.include_router(search_router)
app.include_router(document_router) app.include_router(document_router)
app.include_router(knowledge_router) app.include_router(knowledge_router)
+42
View File
@@ -0,0 +1,42 @@
"""认证相关数据模型"""
from datetime import datetime
from pydantic import BaseModel, Field
class LoginRequest(BaseModel):
"""登录请求"""
username: str = Field(description="用户名")
password: str = Field(description="明文密码")
class RegisterRequest(BaseModel):
"""注册请求"""
username: str = Field(min_length=3, max_length=32, description="用户名(3-32 字符)")
password: str = Field(min_length=6, max_length=128, description="密码(6-128 字符)")
class AuthUser(BaseModel):
"""对外暴露的用户信息(不含密码)"""
username: str = Field(description="用户名")
role: str = Field(default="user", description="角色:admin | user")
created_at: datetime = Field(description="创建时间")
class StoredUser(AuthUser):
"""存储层用户:含密码哈希,仅内部使用,不对外暴露"""
hashed_password: str = Field(description="bcrypt 密码哈希")
class TokenResponse(BaseModel):
"""登录成功返回的 token 信息"""
access_token: str = Field(description="JWT access token")
token_type: str = Field(default="bearer", description="token 类型")
expires_in: int = Field(description="token 有效期(秒)")
user: AuthUser = Field(description="登录用户信息")
+1
View File
@@ -49,3 +49,4 @@ class IngestionResult(BaseModel):
chunks_count: int = Field(default=0, description="写入的 chunk 数量") chunks_count: int = Field(default=0, description="写入的 chunk 数量")
tags: list[str] = Field(default_factory=list, description="附加分类标签") tags: list[str] = Field(default_factory=list, description="附加分类标签")
category_confidence: float = Field(default=0.0, description="主类目分类置信度") category_confidence: float = Field(default=0.0, description="主类目分类置信度")
deduplicated: bool = Field(default=False, description="是否命中去重复用旧文档(True 时 document_id 为既有文档 ID")
+14
View File
@@ -8,6 +8,7 @@ class SearchRequest(BaseModel):
query: str = Field(description="查询文本") query: str = Field(description="查询文本")
top_k: int | None = Field(default=None, description="返回结果数,为空时使用 settings.retrieval_final_k") top_k: int | None = Field(default=None, description="返回结果数,为空时使用 settings.retrieval_final_k")
summarize: bool = Field(default=False, description="是否对检索结果生成 AI 总结")
class SearchHit(BaseModel): class SearchHit(BaseModel):
@@ -21,6 +22,17 @@ class SearchHit(BaseModel):
doc_summary: str = Field(default="", description="L1 文档总结,仅用于上下文标注") doc_summary: str = Field(default="", description="L1 文档总结,仅用于上下文标注")
class ExtractedInfo(BaseModel):
"""AI 从 query 中提取的关键信息"""
rewrite: str = Field(default="", description="改写后的 query")
keywords: list[str] = Field(default_factory=list, description="关键词")
entities: list[str] = Field(default_factory=list, description="实体(人名/产品/技术等)")
intent: str = Field(default="", description="查询意图分类")
time_range: str = Field(default="", description="时间范围,空表示无")
categories: list[str] = Field(default_factory=list, description="命中类目名(含未达阈值的候选)")
class SearchResponse(BaseModel): class SearchResponse(BaseModel):
"""检索响应""" """检索响应"""
@@ -28,3 +40,5 @@ class SearchResponse(BaseModel):
hits: list[SearchHit] = Field(default_factory=list, description="命中结果列表") hits: list[SearchHit] = Field(default_factory=list, description="命中结果列表")
routed_categories: list[str] = Field(default_factory=list, description="query 路由命中的类目") routed_categories: list[str] = Field(default_factory=list, description="query 路由命中的类目")
fallback: bool = Field(default=False, description="是否走了全库兜底路径") fallback: bool = Field(default=False, description="是否走了全库兜底路径")
extracted_info: ExtractedInfo | None = Field(default=None, description="AI 提取的 query 关键信息")
summary: str | None = Field(default=None, description="检索结果 AI 总结,仅 summarize=true 时返回")
+199 -3
View File
@@ -115,11 +115,56 @@
.status-running { background: #eff6ff; color: #1d4ed8; border-color: #93c5fd; } .status-running { background: #eff6ff; color: #1d4ed8; border-color: #93c5fd; }
.status-done { background: #f0fdf4; color: #15803d; border-color: #86efac; } .status-done { background: #f0fdf4; color: #15803d; border-color: #86efac; }
.status-failed { background: #fef2f2; color: #b91c1c; border-color: #fca5a5; } .status-failed { background: #fef2f2; color: #b91c1c; border-color: #fca5a5; }
header { position: relative; }
.user-area { position: absolute; top: 14px; right: 24px; display: flex; align-items: center; gap: 8px; }
.user-area .action { padding: 4px 10px; }
.login-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.45);
display: flex; align-items: center; justify-content: center; z-index: 100;
}
.login-box {
background: #fff; border-radius: 8px; padding: 24px 28px; width: 320px;
box-shadow: 0 6px 20px rgba(0,0,0,0.25);
}
.login-box h2 { font-size: 16px; margin: 0 0 16px; }
.extracted-info {
background: #f0f9ff; border: 1px solid #bae6fd; border-radius: 6px;
padding: 10px 12px; margin-bottom: 12px; font-size: 13px;
}
.extracted-info .ei-title { font-weight: 600; margin-bottom: 6px; color: #0369a1; }
.extracted-info .ei-row { margin-bottom: 3px; }
.extracted-info .ei-k { color: #6b7280; margin-right: 4px; }
.summary-box {
background: #f0fdf4; border: 1px solid #bbf7d0; border-radius: 6px;
padding: 12px 14px; margin-bottom: 12px; font-size: 13px; white-space: pre-wrap;
}
.summary-box .sum-title { font-weight: 600; margin-bottom: 6px; color: #15803d; }
</style> </style>
</head> </head>
<body> <body>
<div id="login-overlay" class="login-overlay">
<div class="login-box">
<h2>登录知识库后台</h2>
<div class="error-bar hidden" id="error-login"></div>
<form id="login-form">
<div class="field">
<label for="login-username">用户名</label>
<input type="text" id="login-username" name="username" required autocomplete="username">
</div>
<div class="field">
<label for="login-password">密码</label>
<input type="password" id="login-password" name="password" required autocomplete="current-password">
</div>
<button type="submit" class="primary">登录</button>
</form>
</div>
</div>
<header> <header>
<h1>知识库管理后台</h1> <h1>知识库管理后台</h1>
<div id="user-area" class="user-area hidden">
<span class="muted" id="user-name"></span>
<button type="button" class="action" id="btn-logout">登出</button>
</div>
<nav id="nav"> <nav id="nav">
<button type="button" data-target="section-overview" class="active">概览</button> <button type="button" data-target="section-overview" class="active">概览</button>
<button type="button" data-target="section-docs">文档管理</button> <button type="button" data-target="section-docs">文档管理</button>
@@ -174,6 +219,22 @@
</div> </div>
<button type="submit" class="primary" id="btn-ingest-submit">提交入库</button> <button type="submit" class="primary" id="btn-ingest-submit">提交入库</button>
</form> </form>
<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>
</div>
<div class="field">
<label for="upload-title">标题(可选,默认取文件名)</label>
<input type="text" id="upload-title" name="title" placeholder="留空则使用文件名去扩展">
</div>
<div class="field">
<label for="upload-source">来源(可选,默认 file:原文件名)</label>
<input type="text" id="upload-source" name="source" placeholder="例如:manual / web">
</div>
<button type="submit" class="primary" id="btn-upload-submit">上传入库</button>
</form>
<div class="result-box hidden" id="ingest-result"></div> <div class="result-box hidden" id="ingest-result"></div>
</section> </section>
@@ -189,6 +250,9 @@
<label for="search-topk">top_k</label> <label for="search-topk">top_k</label>
<input type="number" id="search-topk" name="top_k" value="5" min="1" max="50"> <input type="number" id="search-topk" name="top_k" value="5" min="1" max="50">
</div> </div>
<div class="field">
<label><input type="checkbox" id="search-summarize" name="summarize"> 对结果生成 AI 总结</label>
</div>
<button type="submit" class="primary" id="btn-search-submit">检索</button> <button type="submit" class="primary" id="btn-search-submit">检索</button>
</form> </form>
<div class="result-box hidden" id="search-result"></div> <div class="result-box hidden" id="search-result"></div>
@@ -235,14 +299,76 @@ function hideError(boxId) {
document.getElementById(boxId).classList.add("hidden"); document.getElementById(boxId).classList.add("hidden");
} }
/* 统一 API 封装:code !== 0 抛错,网络错误同样捕获 */ /* ---------- 认证 token 管理 ---------- */
function getToken() { return localStorage.getItem("qmd_token") || ""; }
function setToken(t) { localStorage.setItem("qmd_token", t); }
function clearToken() { localStorage.removeItem("qmd_token"); }
function showLogin() {
document.getElementById("login-overlay").classList.remove("hidden");
document.getElementById("user-area").classList.add("hidden");
loadedOnce = {};
}
function afterLogin(user) {
document.getElementById("login-overlay").classList.add("hidden");
document.getElementById("user-area").classList.remove("hidden");
document.getElementById("user-name").textContent = user.username + " (" + user.role + ")";
loadedOnce = {};
activateSection("section-overview");
}
document.getElementById("login-form").addEventListener("submit", function (event) {
event.preventDefault();
hideError("error-login");
var payload = {
username: document.getElementById("login-username").value,
password: document.getElementById("login-password").value
};
fetch("/api/v1/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
}).then(function (resp) { return resp.json(); }).then(function (body) {
if (body.code !== 0) {
showError("error-login", { code: body.code, message: body.message });
return;
}
setToken(body.data.access_token);
afterLogin(body.data.user);
}).catch(function (err) {
showError("error-login", { code: "NETWORK", message: "登录请求失败: " + (err && err.message ? err.message : err) });
});
});
document.getElementById("btn-logout").addEventListener("click", function () {
clearToken();
showLogin();
});
/* 统一 API 封装:自动注入 token,code !== 0 抛错,未认证跳登录 */
function api(path, options) { function api(path, options) {
options = options || {};
options.headers = Object.assign({}, options.headers || {});
var token = getToken();
if (token && !options.headers["Authorization"]) {
options.headers["Authorization"] = "Bearer " + token;
}
return fetch(path, options).then(function (resp) { return fetch(path, options).then(function (resp) {
if (resp.status === 401) {
clearToken();
showLogin();
throw { code: 1003, message: "未认证或登录已过期,请重新登录" };
}
return resp.json().catch(function () { return resp.json().catch(function () {
throw { code: "HTTP " + resp.status, message: "响应解析失败" }; throw { code: "HTTP " + resp.status, message: "响应解析失败" };
}); });
}).then(function (body) { }).then(function (body) {
if (body.code !== 0) { if (body.code !== 0) {
if (body.code === 1003 || body.code === 1005) {
clearToken();
showLogin();
}
throw { code: body.code, message: body.message }; throw { code: body.code, message: body.message };
} }
return body.data; return body.data;
@@ -612,6 +738,38 @@ document.getElementById("ingest-form").addEventListener("submit", function (even
}); });
}); });
document.getElementById("upload-form").addEventListener("submit", function (event) {
event.preventDefault();
hideError("error-ingest");
stopIngestPolling();
var fileInput = document.getElementById("upload-file");
if (!fileInput.files || fileInput.files.length === 0) {
showError("error-ingest", { code: "VALIDATION", message: "请选择文件" });
return;
}
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 = "上传入库";
});
});
/* ---------- 4. 检索测试台 ---------- */ /* ---------- 4. 检索测试台 ---------- */
document.getElementById("search-form").addEventListener("submit", function (event) { document.getElementById("search-form").addEventListener("submit", function (event) {
@@ -621,7 +779,8 @@ document.getElementById("search-form").addEventListener("submit", function (even
submitBtn.disabled = true; submitBtn.disabled = true;
var payload = { var payload = {
query: document.getElementById("search-query").value, query: document.getElementById("search-query").value,
top_k: parseInt(document.getElementById("search-topk").value, 10) || 5 top_k: parseInt(document.getElementById("search-topk").value, 10) || 5,
summarize: document.getElementById("search-summarize").checked
}; };
api("/api/v1/search", { api("/api/v1/search", {
method: "POST", method: "POST",
@@ -636,6 +795,13 @@ document.getElementById("search-form").addEventListener("submit", function (even
}); });
}); });
function eiRow(k, v) {
var row = el("div", null, "ei-row");
row.appendChild(el("span", k + ":", "ei-k"));
row.appendChild(el("span", v || "(无)"));
return row;
}
function renderSearchResult(data) { function renderSearchResult(data) {
var box = document.getElementById("search-result"); var box = document.getElementById("search-result");
clearChildren(box); clearChildren(box);
@@ -650,6 +816,26 @@ function renderSearchResult(data) {
box.appendChild(el("div", "fallback:本次检索触发了回退策略(路由类目无命中,已降级全局检索)", "fallback-flag")); box.appendChild(el("div", "fallback:本次检索触发了回退策略(路由类目无命中,已降级全局检索)", "fallback-flag"));
} }
if (data.summary) {
var sumBox = el("div", null, "summary-box");
sumBox.appendChild(el("div", "AI 总结", "sum-title"));
sumBox.appendChild(el("div", data.summary));
box.appendChild(sumBox);
}
var ei = data.extracted_info;
if (ei) {
var eiBox = el("div", null, "extracted-info");
eiBox.appendChild(el("div", "AI 提取的关键信息", "ei-title"));
eiBox.appendChild(eiRow("改写", ei.rewrite));
eiBox.appendChild(eiRow("关键词", (ei.keywords || []).join(", ")));
eiBox.appendChild(eiRow("实体", (ei.entities || []).join(", ")));
eiBox.appendChild(eiRow("意图", ei.intent));
eiBox.appendChild(eiRow("时间范围", ei.time_range));
eiBox.appendChild(eiRow("命中类目", (ei.categories || []).join(", ")));
box.appendChild(eiBox);
}
var hits = data.hits || []; var hits = data.hits || [];
box.appendChild(el("div", "命中 " + hits.length + " 条", "muted")); box.appendChild(el("div", "命中 " + hits.length + " 条", "muted"));
hits.forEach(function (hit) { hits.forEach(function (hit) {
@@ -686,7 +872,17 @@ function loadCategories() {
/* ---------- 初始化 ---------- */ /* ---------- 初始化 ---------- */
activateSection("section-overview"); (function init() {
if (!getToken()) {
showLogin();
return;
}
api("/api/v1/auth/me").then(function (user) {
afterLogin(user);
}).catch(function () {
showLogin();
});
})();
</script> </script>
</body> </body>
</html> </html>
+8
View File
@@ -13,6 +13,13 @@ dependencies = [
"httpx>=0.28.0", "httpx>=0.28.0",
"structlog>=24.4.0", "structlog>=24.4.0",
"openai>=1.58.0", "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] [project.optional-dependencies]
@@ -35,6 +42,7 @@ line-length = 120
[tool.ruff.lint] [tool.ruff.lint]
select = ["E", "F", "I", "N", "UP", "B"] select = ["E", "F", "I", "N", "UP", "B"]
ignore = ["B008"] # FastAPI Depends() 在默认参数是标准用法,B008 为误报
[tool.pytest.ini_options] [tool.pytest.ini_options]
asyncio_mode = "auto" asyncio_mode = "auto"
+27
View File
@@ -0,0 +1,27 @@
"""pytest 全局夹具:覆盖 JWT 认证依赖,让现有 API 测试默认以 admin 身份运行
业务接口(search/document/knowledge)已加 Depends(get_current_user)、
DELETE /documents 加了 Depends(require_admin)。这里通过 autouse 夹具把两个依赖
统一替换为返回固定 admin AuthUser 的 lambda,使现有 API 测试无需改动即可通过认证。
单个测试需要走真实认证逻辑时(如 tests/test_auth.py),可在测试函数内
pop 掉对应 overrideautouse fixture yield 后会统一 clear。
"""
from datetime import UTC, datetime
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 身份运行;测试结束清理 dependency_overrides"""
app.dependency_overrides[get_current_user] = lambda: TEST_USER
app.dependency_overrides[require_admin] = lambda: TEST_USER
yield
app.dependency_overrides.clear()
+293
View File
@@ -0,0 +1,293 @@
"""认证 API 与核心函数的单元测试(mock UserStore,不依赖真实 Redis
覆盖:
- POST /auth/login:成功 / 密码错误 / 用户不存在
- POST /auth/register:成功 / 用户已存在 / 注册关闭
- GET /auth/me:有效 token / 无 token / 无效 token(需走真实 get_current_user
- require_admin:非 admin 抛 FORBIDDEN(直接测函数)
- create_access_token + decode_token 往返一致
- hash_password + verify_password 正确 / 错误
UserStore 的 authenticate/create 在 FakeUserStore 中 mock,避免依赖真实 Redis。
"""
from datetime import UTC, datetime
import pytest
from fastapi.testclient import TestClient
from app.api.response import ApiError
from app.api.v1 import auth as auth_module
from app.config import settings
from app.core import auth as auth_core
from app.core.auth import (
ERR_BAD_CREDENTIALS,
ERR_FORBIDDEN,
ERR_REGISTER_DISABLED,
ERR_TOKEN_INVALID,
ERR_UNAUTHORIZED,
ERR_USER_EXISTS,
create_access_token,
decode_token,
get_current_user,
hash_password,
require_admin,
verify_password,
)
from app.main import app
from app.models.auth import AuthUser, StoredUser
def _now() -> datetime:
return datetime.now(UTC)
class FakeUserStore:
"""假 UserStore:按预置数据返回结果或抛 ApiError,记录调用
authenticate/create 使用预置 hashed_password,不调用真实 hash_password。
"""
def __init__(
self,
user: StoredUser | None = None,
exists: bool = False,
create_error: ApiError | None = None,
auth_fail: bool = False,
) -> None:
self.user = user
self._exists = exists
self.create_error = create_error
self.auth_fail = auth_fail
self.create_calls: list[tuple[str, str, str]] = []
async def get(self, username: str) -> StoredUser | None:
if self.user is not None and self.user.username == username:
return self.user
return None
async def exists(self, username: str) -> bool:
return self._exists
async def create(self, username: str, password: str, role: str = "user") -> StoredUser:
if self.create_error is not None:
raise self.create_error
user = StoredUser(
username=username,
role=role,
created_at=_now(),
hashed_password="fake-hash",
)
self.create_calls.append((username, password, role))
return user
async def authenticate(self, username: str, password: str) -> StoredUser:
if self.auth_fail or self.user is None or self.user.username != username:
raise ApiError(ERR_BAD_CREDENTIALS, "用户名或密码错误")
return self.user
class NullUserStore:
"""恒返回 None 的空存储:用于 /auth/me 测试,让 get_current_user 降级用 JWT payload"""
async def get(self, username: str) -> StoredUser | None:
return None
async def exists(self, username: str) -> bool:
return False
def _install_store(monkeypatch: pytest.MonkeyPatch, store: FakeUserStore) -> None:
"""将 auth 路由模块的 get_user_store 替换为假存储"""
monkeypatch.setattr(auth_module, "get_user_store", lambda: store)
def _make_stored_user(username: str = "alice", role: str = "user") -> StoredUser:
return StoredUser(username=username, role=role, created_at=_now(), hashed_password="fake-hash")
class TestAuthLoginApi:
"""POST /auth/login"""
def test_login_success(self, monkeypatch: pytest.MonkeyPatch):
user = _make_stored_user(username="alice", role="admin")
_install_store(monkeypatch, FakeUserStore(user=user))
client = TestClient(app)
resp = client.post("/api/v1/auth/login", json={"username": "alice", "password": "secret123"})
assert resp.status_code == 200
body = resp.json()
assert body["code"] == 0
data = body["data"]
assert data["access_token"]
assert data["token_type"] == "bearer"
assert data["expires_in"] == settings.jwt_expire_minutes * 60
assert data["user"]["username"] == "alice"
assert data["user"]["role"] == "admin"
assert "created_at" in data["user"]
def test_login_wrong_password(self, monkeypatch: pytest.MonkeyPatch):
user = _make_stored_user(username="alice")
# authenticate 失败(密码不匹配)
_install_store(monkeypatch, FakeUserStore(user=user, auth_fail=True))
client = TestClient(app)
resp = client.post("/api/v1/auth/login", json={"username": "alice", "password": "WRONG"})
body = resp.json()
assert body["code"] == ERR_BAD_CREDENTIALS
assert body["data"] is None
def test_login_user_not_found(self, monkeypatch: pytest.MonkeyPatch):
_install_store(monkeypatch, FakeUserStore(user=None))
client = TestClient(app)
resp = client.post("/api/v1/auth/login", json={"username": "nobody", "password": "whatever"})
body = resp.json()
assert body["code"] == ERR_BAD_CREDENTIALS
class TestAuthRegisterApi:
"""POST /auth/register"""
def test_register_success(self, monkeypatch: pytest.MonkeyPatch):
store = FakeUserStore(user=None, exists=False)
_install_store(monkeypatch, store)
client = TestClient(app)
resp = client.post(
"/api/v1/auth/register", json={"username": "newbie", "password": "passwd123"}
)
assert resp.status_code == 200
body = resp.json()
assert body["code"] == 0
data = body["data"]
assert data["access_token"]
assert data["user"]["username"] == "newbie"
assert data["user"]["role"] == "user"
assert len(store.create_calls) == 1
assert store.create_calls[0][0] == "newbie"
assert store.create_calls[0][2] == "user"
def test_register_user_exists(self, monkeypatch: pytest.MonkeyPatch):
store = FakeUserStore(
exists=True,
create_error=ApiError(ERR_USER_EXISTS, "用户名已存在: newbie"),
)
_install_store(monkeypatch, store)
client = TestClient(app)
resp = client.post(
"/api/v1/auth/register", json={"username": "newbie", "password": "passwd123"}
)
body = resp.json()
assert body["code"] == ERR_USER_EXISTS
assert body["data"] is None
def test_register_disabled(self, monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(settings, "auth_register_enabled", False)
_install_store(monkeypatch, FakeUserStore())
client = TestClient(app)
resp = client.post(
"/api/v1/auth/register", json={"username": "newbie", "password": "passwd123"}
)
body = resp.json()
assert body["code"] == ERR_REGISTER_DISABLED
assert body["data"] is None
class TestAuthMeApi:
"""GET /auth/me:需走真实 get_current_user,测试内清除 override"""
def test_me_with_valid_token(self, monkeypatch: pytest.MonkeyPatch):
# 清除 override,让 get_current_user 走真实认证
app.dependency_overrides.pop(get_current_user, None)
# mock get_user_store 返回空存储(模拟 Redis 无该用户,降级用 JWT payload
monkeypatch.setattr(auth_core, "get_user_store", lambda: NullUserStore())
token, _ = create_access_token("alice", "admin")
client = TestClient(app)
resp = client.get("/api/v1/auth/me", headers={"Authorization": f"Bearer {token}"})
assert resp.status_code == 200
body = resp.json()
assert body["code"] == 0
assert body["data"]["username"] == "alice"
assert body["data"]["role"] == "admin"
def test_me_without_token(self, monkeypatch: pytest.MonkeyPatch):
app.dependency_overrides.pop(get_current_user, None)
monkeypatch.setattr(auth_core, "get_user_store", lambda: NullUserStore())
client = TestClient(app)
resp = client.get("/api/v1/auth/me")
body = resp.json()
assert body["code"] == ERR_UNAUTHORIZED
assert body["data"] is None
def test_me_with_invalid_token(self, monkeypatch: pytest.MonkeyPatch):
app.dependency_overrides.pop(get_current_user, None)
monkeypatch.setattr(auth_core, "get_user_store", lambda: NullUserStore())
client = TestClient(app)
resp = client.get("/api/v1/auth/me", headers={"Authorization": "Bearer not-a-jwt"})
body = resp.json()
assert body["code"] == ERR_TOKEN_INVALID
assert body["data"] is None
class TestRequireAdmin:
"""require_adminadmin 通过、非 admin 抛 FORBIDDEN(直接测函数,不经 API)"""
async def test_admin_passes(self):
admin = AuthUser(username="alice", role="admin", created_at=_now())
result = await require_admin(user=admin)
assert result.role == "admin"
async def test_non_admin_raises_forbidden(self):
normal = AuthUser(username="bob", role="user", created_at=_now())
with pytest.raises(ApiError) as exc:
await require_admin(user=normal)
assert exc.value.code == ERR_FORBIDDEN
class TestAuthCoreFunctions:
"""核心函数:token 往返 / 密码哈希"""
def test_create_and_decode_token_roundtrip(self):
token, expires_in = create_access_token("alice", "admin")
assert expires_in == settings.jwt_expire_minutes * 60
payload = decode_token(token)
assert payload["sub"] == "alice"
assert payload["role"] == "admin"
assert "iat" in payload and "exp" in payload
def test_decode_invalid_token_raises(self):
with pytest.raises(ApiError) as exc:
decode_token("not-a-jwt")
assert exc.value.code == ERR_TOKEN_INVALID
def test_hash_password_not_plaintext(self):
hashed = hash_password("my-secret")
assert hashed != "my-secret"
assert hashed.startswith("$2") # bcrypt 哈希前缀
def test_verify_password_correct(self):
hashed = hash_password("my-secret")
assert verify_password("my-secret", hashed) is True
def test_verify_password_wrong(self):
hashed = hash_password("my-secret")
assert verify_password("wrong-password", hashed) is False
def test_verify_password_garbage_hash_returns_false(self):
# 非法 hash 会被 bcrypt 拒绝,verify_password 捕获异常返回 False
assert verify_password("any", "not-a-valid-hash") is False
+313
View File
@@ -0,0 +1,313 @@
"""POST /api/v1/documents/upload 端点测试(TestClient + FakeManager,不真实联网)"""
import io
import re
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:
def __init__(self, task_id: str = "task-upload-1") -> None:
self.task_id = task_id
self.submitted: list[DocumentInput] = []
async def submit(self, doc: DocumentInput) -> str:
self.submitted.append(doc)
return self.task_id
async def get(self, task_id: str) -> dict[str, Any] | None:
return None
@pytest.fixture
def client(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> 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:
yield test_client
def _inject_manager(monkeypatch: pytest.MonkeyPatch, manager: FakeManager) -> None:
monkeypatch.setattr(document_module, "_get_task_manager", lambda: manager)
def _make_minimal_pdf(text: str = "Hello PDF World") -> bytes:
content_stream = f"BT /F1 24 Tf 100 700 Td ({text}) Tj ET".encode("latin-1")
content_obj = b"<< /Length " + str(len(content_stream)).encode() + b" >>\nstream\n" + content_stream + b"\nendstream"
return (
b"%PDF-1.0\n"
b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n"
b"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n"
b"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] "
b"/Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>\nendobj\n"
b"4 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>\nendobj\n"
b"5 0 obj\n" + content_obj + b"\nendobj\n"
b"xref\n0 6\n"
b"0000000000 65535 f\n"
b"0000000010 00000 n\n"
b"0000000059 00000 n\n"
b"0000000115 00000 n\n"
b"0000000241 00000 n\n"
b"0000000316 00000 n\n"
b"trailer\n<< /Size 6 /Root 1 0 R >>\nstartxref\n414\n%%EOF\n"
)
def _make_docx(text_lines: list[str]) -> bytes:
from docx import Document # type: ignore[import-untyped]
document = Document()
for line in text_lines:
document.add_paragraph(line)
buf = io.BytesIO()
document.save(buf)
return buf.getvalue()
def test_upload_md_returns_202_and_saves_file(
client: TestClient, monkeypatch: pytest.MonkeyPatch
) -> None:
"""md 上传:202 + 落盘 + metadata 含原文件信息"""
manager = FakeManager(task_id="abc-upload")
_inject_manager(monkeypatch, manager)
content = "# Hello\n\nThis is a markdown file.".encode("utf-8")
resp = client.post(
"/api/v1/documents/upload",
files={"file": ("notes.md", content, "text/markdown")},
data={"title": "我的笔记", "source": "manual"},
)
assert resp.status_code == 202
body = resp.json()
assert body["code"] == 0
data = body["data"]
assert data["task_id"] == "abc-upload"
assert data["status"] == "pending"
saved_path = data["saved_path"]
assert saved_path
assert Path(saved_path).read_bytes() == content
assert len(manager.submitted) == 1
doc = manager.submitted[0]
assert doc.text == content.decode("utf-8")
assert doc.title == "我的笔记"
assert doc.source == "manual"
assert doc.metadata["original_filename"] == "notes.md"
assert doc.metadata["original_size_bytes"] == str(len(content))
assert doc.metadata["raw_file_path"] == saved_path
def test_upload_txt_default_title_and_source(
client: TestClient, monkeypatch: pytest.MonkeyPatch
) -> None:
"""txt 上传未传 title/source:默认取文件名 stem 与 file:{原文件名}"""
manager = FakeManager()
_inject_manager(monkeypatch, manager)
resp = client.post(
"/api/v1/documents/upload",
files={"file": ("readme.txt", b"plain text body", "text/plain")},
)
assert resp.status_code == 202
assert len(manager.submitted) == 1
doc = manager.submitted[0]
assert doc.title == "readme"
assert doc.source == "file:readme.txt"
def test_upload_pdf_extracts_text_and_returns_202(
client: TestClient, monkeypatch: pytest.MonkeyPatch
) -> None:
"""pdf 上传:提取文本并返回 202"""
manager = FakeManager()
_inject_manager(monkeypatch, manager)
content = _make_minimal_pdf("Hello PDF World")
resp = client.post(
"/api/v1/documents/upload",
files={"file": ("doc.pdf", content, "application/pdf")},
)
assert resp.status_code == 202
body = resp.json()
assert body["code"] == 0
assert body["data"]["saved_path"]
assert len(manager.submitted) == 1
assert "Hello PDF World" in manager.submitted[0].text
def test_upload_docx_extracts_text(
client: TestClient, monkeypatch: pytest.MonkeyPatch
) -> None:
"""docx 上传:提取段落文本"""
manager = FakeManager()
_inject_manager(monkeypatch, manager)
content = _make_docx(["第一段落", "第二段落"])
resp = client.post(
"/api/v1/documents/upload",
files={"file": ("doc.docx", content, "application/vnd.openxmlformats-officedocument.wordprocessingml.document")},
)
assert resp.status_code == 202
assert len(manager.submitted) == 1
text = manager.submitted[0].text
assert "第一段落" in text
assert "第二段落" in text
def test_upload_metadata_json_is_parsed(
client: TestClient, monkeypatch: pytest.MonkeyPatch
) -> None:
"""metadata JSON 字符串被解析并入 metadata 字典"""
manager = FakeManager()
_inject_manager(monkeypatch, manager)
resp = client.post(
"/api/v1/documents/upload",
files={"file": ("x.txt", b"content", "text/plain")},
data={"metadata": '{"author": "alice", "team": "backend"}'},
)
assert resp.status_code == 202
assert len(manager.submitted) == 1
doc = manager.submitted[0]
assert doc.metadata["author"] == "alice"
assert doc.metadata["team"] == "backend"
assert "raw_file_path" in doc.metadata
assert doc.metadata["original_filename"] == "x.txt"
def test_upload_rejects_unsupported_extension(
client: TestClient, monkeypatch: pytest.MonkeyPatch
) -> None:
"""不支持的扩展名:code=1001,message 含扩展名,未提交任务"""
manager = FakeManager()
_inject_manager(monkeypatch, manager)
resp = client.post(
"/api/v1/documents/upload",
files={"file": ("data.xlsx", b"binary content", "application/octet-stream")},
)
body = resp.json()
assert body["code"] == 1001
assert ".xlsx" in body["message"]
assert manager.submitted == []
def test_upload_rejects_oversized_file(
client: TestClient, monkeypatch: pytest.MonkeyPatch
) -> None:
"""超过大小上限:code=1001message 含'大小上限',未提交任务"""
manager = FakeManager()
_inject_manager(monkeypatch, manager)
monkeypatch.setattr(document_module.settings, "upload_max_size_mb", 1)
big_content = b"x" * (2 * 1024 * 1024)
resp = client.post(
"/api/v1/documents/upload",
files={"file": ("big.txt", big_content, "text/plain")},
)
body = resp.json()
assert body["code"] == 1001
assert "大小上限" in body["message"]
assert manager.submitted == []
def test_upload_rejects_empty_text_after_parse(
client: TestClient, monkeypatch: pytest.MonkeyPatch
) -> None:
"""解析后文本为空:code=1001message 含'无法从文件提取文本',未提交任务"""
manager = FakeManager()
_inject_manager(monkeypatch, manager)
resp = client.post(
"/api/v1/documents/upload",
files={"file": ("blank.txt", b" \n\t ", "text/plain")},
)
body = resp.json()
assert body["code"] == 1001
assert "无法从文件提取文本" in body["message"]
assert manager.submitted == []
def test_upload_rejects_corrupted_pdf(
client: TestClient, monkeypatch: pytest.MonkeyPatch
) -> None:
"""损坏的 PDFcode=1001message 含'文件解析失败',未提交任务"""
manager = FakeManager()
_inject_manager(monkeypatch, manager)
resp = client.post(
"/api/v1/documents/upload",
files={"file": ("bad.pdf", b"not a real pdf", "application/pdf")},
)
body = resp.json()
assert body["code"] == 1001
assert "文件解析失败" in body["message"]
assert manager.submitted == []
def test_upload_falls_back_when_save_fails(
client: TestClient, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""落盘失败时降级:仍 202 提交入库,但 metadata 不含落盘信息"""
manager = FakeManager(task_id="fallback-task")
_inject_manager(monkeypatch, manager)
blocker = tmp_path / "blocker_file"
blocker.write_bytes(b"x")
monkeypatch.setattr(document_module.settings, "upload_dir", str(blocker))
resp = client.post(
"/api/v1/documents/upload",
files={"file": ("notes.txt", b"hello world", "text/plain")},
)
assert resp.status_code == 202
body = resp.json()
assert body["data"]["saved_path"] == ""
assert len(manager.submitted) == 1
doc = manager.submitted[0]
assert "raw_file_path" not in doc.metadata
assert "original_filename" not in doc.metadata
def test_upload_saved_path_uses_date_shard(
client: TestClient, monkeypatch: pytest.MonkeyPatch
) -> None:
"""落盘路径使用 YYYY/MM 日期分片子目录"""
manager = FakeManager()
_inject_manager(monkeypatch, manager)
resp = client.post(
"/api/v1/documents/upload",
files={"file": ("shard.txt", b"shard content", "text/plain")},
)
assert resp.status_code == 202
saved_path = resp.json()["data"]["saved_path"]
# 路径形如 .../uploads/YYYY/MM/{32位hex doc_id}_shard.txt
norm = saved_path.replace("\\", "/")
assert re.search(r"/\d{4}/\d{2}/[0-9a-f]{32}_shard\.txt$", norm), saved_path
# 文件确已落盘到分片目录
assert Path(saved_path).read_bytes() == b"shard content"
+312
View File
@@ -0,0 +1,312 @@
"""file_parser 单元测试:覆盖 txt/md/html/pdf/docx + 损坏文件 + 不支持扩展名 + PDF OCR 降级"""
import io
import sys
from typing import Any
import pytest
from app.config import settings
from app.core import file_parser as fp_module
from app.core.file_parser import parse_file, supported_extensions
def _make_minimal_pdf(text: str = "Hello PDF World") -> bytes:
"""构造一个含一页文本的最小 PDF(pypdf 可读出文本)"""
content_stream = f"BT /F1 24 Tf 100 700 Td ({text}) Tj ET".encode("latin-1")
content_obj = b"<< /Length " + str(len(content_stream)).encode() + b" >>\nstream\n" + content_stream + b"\nendstream"
return (
b"%PDF-1.0\n"
b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n"
b"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n"
b"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] "
b"/Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>\nendobj\n"
b"4 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>\nendobj\n"
b"5 0 obj\n" + content_obj + b"\nendobj\n"
b"xref\n0 6\n"
b"0000000000 65535 f\n"
b"0000000010 00000 n\n"
b"0000000059 00000 n\n"
b"0000000115 00000 n\n"
b"0000000241 00000 n\n"
b"0000000316 00000 n\n"
b"trailer\n<< /Size 6 /Root 1 0 R >>\nstartxref\n414\n%%EOF\n"
)
def _make_docx(text_lines: list[str]) -> bytes:
from docx import Document # type: ignore[import-untyped]
document = Document()
for line in text_lines:
document.add_paragraph(line)
buf = io.BytesIO()
document.save(buf)
return buf.getvalue()
def test_parse_txt_returns_decoded_text() -> None:
"""txt:UTF-8 解码(含中文),无效字节 errors=replace 不抛错"""
text = "你好世界 hello"
assert parse_file("note.txt", text.encode("utf-8")) == text
# 无效 UTF-8 字节不抛错(errors=replace 兜底)
result = parse_file("bad.txt", b"\xff\xfe\x00invalid")
assert isinstance(result, str)
def test_parse_md_returns_decoded_text() -> None:
"""md:与 txt 走同一解析器"""
text = "# 标题\n\n正文内容"
assert parse_file("note.md", text.encode("utf-8")) == text
def test_parse_html_strips_tags() -> None:
"""html:剥离 script/style 与所有标签,仅保留可见文本"""
html = b"<html><body><h1>Hello</h1><p>World</p><script>x=1</script><style>p{}</style></body></html>"
result = parse_file("page.html", html)
assert "Hello" in result
assert "World" in result
assert "<" not in result
assert ">" not in result
assert "x=1" not in result
assert "p{}" not in result
def test_parse_htm_same_as_html() -> None:
"""htm:与 html 走同一解析器"""
html = b"<html><body><p>same content</p></body></html>"
assert parse_file("page.htm", html) == parse_file("page.html", html)
def test_parse_pdf_extracts_text() -> None:
"""pdf:从最小 PDF 中提取文本"""
pdf_bytes = _make_minimal_pdf("Hello PDF World")
result = parse_file("doc.pdf", pdf_bytes)
assert "Hello PDF World" in result
def test_parse_docx_extracts_paragraphs() -> None:
"""docx:提取段落文本"""
docx_bytes = _make_docx(["第一段落", "第二段落"])
result = parse_file("doc.docx", docx_bytes)
assert "第一段落" in result
assert "第二段落" in result
def test_parse_file_unsupported_extension_raises() -> None:
"""不支持的扩展名抛 ValueErrormessage 含扩展名"""
with pytest.raises(ValueError, match=r"不支持的文件类型: \.xlsx"):
parse_file("data.xlsx", b"binary")
def test_parse_file_no_extension_raises() -> None:
"""无扩展名抛 ValueError"""
with pytest.raises(ValueError, match=r"不支持的文件类型"):
parse_file("noext", b"text")
def test_parse_file_corrupted_pdf_raises() -> None:
"""损坏的 PDF 抛 ValueErrormessage 含'文件解析失败'"""
with pytest.raises(ValueError, match=r"文件解析失败"):
parse_file("bad.pdf", b"not a real pdf")
def test_parse_file_empty_html_returns_empty_string() -> None:
"""空 HTML 返回空字符串"""
assert parse_file("empty.html", b"<html></html>") == ""
def test_parse_file_html_with_script_style_excluded() -> None:
"""HTML 中 script/style 内容被排除"""
html = b"<html><body><p>visible</p><script>alert(1)</script><style>body{}</style></body></html>"
result = parse_file("page.html", html)
assert "visible" in result
assert "alert" not in result
assert "body{}" not in result
def test_parse_file_html_with_entities_decoded() -> None:
"""HTML 实体被解码"""
html = b"<html><body><p>Tom &amp; Jerry</p></body></html>"
result = parse_file("page.html", html)
assert "Tom & Jerry" in result
assert "&amp;" not in result
def test_supported_extensions_contains_expected_set() -> None:
"""supported_extensions 返回包含全部六种扩展名的集合"""
exts = supported_extensions()
assert {".txt", ".md", ".html", ".htm", ".pdf", ".docx"} <= exts
# --------------------------------------------------------------------------- #
# PDF OCR 降级路径测试(mock pypdf / pypdfium2 / rapidocr_onnxruntime,不真实下载模型)
# --------------------------------------------------------------------------- #
@pytest.fixture(autouse=True)
def _reset_ocr_state() -> Any:
"""每个 OCR 测试前后重置模块级 OCR 引擎状态,避免相互污染"""
saved_engine = fp_module._ocr_engine
saved_unavailable = fp_module._ocr_unavailable
yield
fp_module._ocr_engine = saved_engine
fp_module._ocr_unavailable = saved_unavailable
class _FakeTextPage:
"""pypdf PageObject 替身:返回固定文本"""
def __init__(self, text: str) -> None:
self._text = text
def extract_text(self) -> str:
return self._text
class _FakePdfReader:
"""pypdf.PdfReader 替身:构造时不解析,按预设页文本返回"""
def __init__(self, stream: Any) -> None:
self.pages = [_FakeTextPage(""), _FakeTextPage("")]
class _FakePilImage:
pass
class _FakeRenderResult:
def to_pil(self) -> _FakePilImage:
return _FakePilImage()
class _FakePdfiumPage:
def render(self, scale: float) -> _FakeRenderResult:
return _FakeRenderResult()
class _FakePdfDocument:
"""pypdfium2.PdfDocument 替身"""
def __init__(self, stream: Any) -> None:
self._n_pages = 2
def __len__(self) -> int:
return self._n_pages
def __getitem__(self, i: int) -> _FakePdfiumPage:
return _FakePdfiumPage()
def close(self) -> None:
pass
class _FakeOcrEngine:
"""rapidocr RapidOCR 替身:每次返回固定识别结果"""
def __init__(self) -> None:
self.call_count = 0
def __call__(self, image: Any) -> tuple[list[list[Any]], float]:
self.call_count += 1
# 返回 [[box, text, score], ...] 结构
return [[[0, 0], f"OCR文本第{self.call_count}", 0.95]], 0.1
def _patch_pdf_ocr_deps(monkeypatch: pytest.MonkeyPatch) -> None:
"""把 _parse_pdf/_ocr_pdf 内部用到的 pypdf / pypdfium2 / rapidocr_onnxruntime 全部替换"""
monkeypatch.setattr("pypdf.PdfReader", _FakePdfReader)
monkeypatch.setattr("pypdfium2.PdfDocument", _FakePdfDocument)
fake_module = type(sys)("rapidocr_onnxruntime")
fake_module.RapidOCR = _FakeOcrEngine
monkeypatch.setitem(sys.modules, "rapidocr_onnxruntime", fake_module)
def test_parse_pdf_ocr_fallback_when_text_layer_empty(monkeypatch: pytest.MonkeyPatch) -> None:
"""扫描件 PDF(文本层全空)触发 OCR 降级,返回识别文本"""
_patch_pdf_ocr_deps(monkeypatch)
monkeypatch.setattr(settings, "pdf_ocr_enabled", True)
monkeypatch.setattr(settings, "pdf_ocr_max_pages", 30)
monkeypatch.setattr(settings, "pdf_ocr_dpi", 200)
result = parse_file("scan.pdf", b"fake pdf bytes")
# 两页都跑了 OCR,每页返回一段文本
assert "OCR文本第1页" in result
assert "OCR文本第2页" in result
def test_parse_pdf_ocr_skipped_when_disabled(monkeypatch: pytest.MonkeyPatch) -> None:
"""pdf_ocr_enabled=False:文本层为空时直接返回空,不调 OCR"""
_patch_pdf_ocr_deps(monkeypatch)
monkeypatch.setattr(settings, "pdf_ocr_enabled", False)
result = parse_file("scan.pdf", b"fake pdf bytes")
assert result == ""
def test_parse_pdf_ocr_respects_max_pages(monkeypatch: pytest.MonkeyPatch) -> None:
"""max_pages=1:只 OCR 第一页,第二页跳过"""
_patch_pdf_ocr_deps(monkeypatch)
monkeypatch.setattr(settings, "pdf_ocr_enabled", True)
monkeypatch.setattr(settings, "pdf_ocr_max_pages", 1)
monkeypatch.setattr(settings, "pdf_ocr_dpi", 200)
result = parse_file("scan.pdf", b"fake pdf bytes")
assert "OCR文本第1页" in result
assert "OCR文本第2页" not in result
def test_parse_pdf_ocr_returns_empty_when_dependency_unavailable(monkeypatch: pytest.MonkeyPatch) -> None:
"""rapidocr 导入失败:降级返回空文本,且把 _ocr_unavailable 置 True 避免重试"""
monkeypatch.setattr("pypdf.PdfReader", _FakePdfReader)
# 故意让 rapidocr_onnxruntime 提供一个非类的 RapidOCR,构造时抛错
fake_module = type(sys)("rapidocr_onnxruntime")
def _boom(*args: Any, **kwargs: Any) -> None:
raise RuntimeError("model missing")
fake_module.RapidOCR = _boom # type: ignore[attr-defined]
monkeypatch.setitem(sys.modules, "rapidocr_onnxruntime", fake_module)
monkeypatch.setattr(settings, "pdf_ocr_enabled", True)
assert parse_file("scan.pdf", b"fake pdf bytes") == ""
assert fp_module._ocr_unavailable is True
def test_parse_pdf_ocr_runtime_exception_falls_back_to_empty(monkeypatch: pytest.MonkeyPatch) -> None:
"""OCR 运行时抛错:仅告警,降级返回空文本(不抛出 ValueError)"""
monkeypatch.setattr("pypdf.PdfReader", _FakePdfReader)
class _ExplodingPdfDocument:
def __init__(self, stream: Any) -> None:
raise RuntimeError("pdfium render failed")
monkeypatch.setattr("pypdfium2.PdfDocument", _ExplodingPdfDocument)
fake_module = type(sys)("rapidocr_onnxruntime")
fake_module.RapidOCR = _FakeOcrEngine
monkeypatch.setitem(sys.modules, "rapidocr_onnxruntime", fake_module)
monkeypatch.setattr(settings, "pdf_ocr_enabled", True)
assert parse_file("scan.pdf", b"fake pdf bytes") == ""
def test_parse_pdf_text_layer_present_skips_ocr(monkeypatch: pytest.MonkeyPatch) -> None:
"""文本层非空:直接返回文本,OCR 引擎不会被实例化"""
call_count = 0
class _CountingReader:
def __init__(self, stream: Any) -> None:
self.pages = [_FakeTextPage("这是文本层的内容")]
monkeypatch.setattr("pypdf.PdfReader", _CountingReader)
# 即便 OCR 依赖故意坏掉,也不应被调用
bad_module = type(sys)("rapidocr_onnxruntime")
bad_module.RapidOCR = lambda *a, **k: (_ for _ in ()).throw(RuntimeError("不应被调用")) # type: ignore[attr-defined]
monkeypatch.setitem(sys.modules, "rapidocr_onnxruntime", bad_module)
monkeypatch.setattr(settings, "pdf_ocr_enabled", True)
result = parse_file("text.pdf", b"fake pdf bytes")
assert result == "这是文本层的内容"
assert fp_module._ocr_engine is None
+90 -1
View File
@@ -1,12 +1,13 @@
"""IngestTaskManager 单元测试(FakeIngester/FakeRedis,不真实联网)""" """IngestTaskManager 单元测试(FakeIngester/FakeRedis,不真实联网)"""
import asyncio import asyncio
import hashlib
from collections.abc import Callable from collections.abc import Callable
from datetime import datetime from datetime import datetime
from typing import Any from typing import Any
from app.config import Settings from app.config import Settings
from app.core.ingest_tasks import REDIS_KEY_PREFIX, IngestTaskManager, IngestTaskStatus from app.core.ingest_tasks import DEDUP_KEY_PREFIX, REDIS_KEY_PREFIX, IngestTaskManager, IngestTaskStatus
from app.core.ingestion import IngestionError from app.core.ingestion import IngestionError
from app.models.document import DocumentInput, DocumentSummary, IngestionResult, SummaryLevel from app.models.document import DocumentInput, DocumentSummary, IngestionResult, SummaryLevel
@@ -198,3 +199,91 @@ async def test_get_falls_back_to_redis_then_none() -> None:
record = await manager.get("abc") record = await manager.get("abc")
assert record is not None assert record is not None
assert record["status"] == "done" assert record["status"] == "done"
def _text_hash(text: str) -> str:
return hashlib.sha256(text.encode("utf-8")).hexdigest()
async def test_dedup_hit_reuses_old_doc_id_and_skips_pipeline() -> None:
"""去重命中:相同 text 第二次提交直接 done,复用旧 doc_iddeduplicated=True,不调 ingester"""
ingester = FakeIngester()
redis = FakeRedis()
manager = IngestTaskManager(ingester, redis, Settings())
# 首次提交:正常跑流水线
text = "重复内容"
task1 = await manager.submit(DocumentInput(text=text, title="t1"))
final1 = await manager.wait_done(task1, timeout=5)
assert final1["status"] == IngestTaskStatus.DONE
assert final1["result"]["document_id"] == "doc-1"
assert final1["result"]["deduplicated"] is False
assert len(ingester.calls) == 1
# dedup 记录已写入 Redis
stored = await redis.get_json(f"{DEDUP_KEY_PREFIX}{_text_hash(text)}")
assert stored is not None
assert stored["document_id"] == "doc-1"
# 第二次提交相同 text:直接 done,不重跑流水线
task2 = await manager.submit(DocumentInput(text=text, title="t2"))
# wait_done 会先 await 所有 fire-and-forget 镜像任务再返回,确保 Redis 已写
record2 = await manager.wait_done(task2, timeout=5)
assert record2["status"] == IngestTaskStatus.DONE
assert record2["result"]["document_id"] == "doc-1"
assert record2["result"]["deduplicated"] is True
# ingester 没被再次调用
assert len(ingester.calls) == 1
# 镜像已写入 Redis
mirrored = await redis.get_json(f"{REDIS_KEY_PREFIX}{task2}")
assert mirrored is not None
assert mirrored["status"] == IngestTaskStatus.DONE
assert mirrored["result"]["deduplicated"] is True
async def test_dedup_miss_when_text_differs() -> None:
"""去重未命中:不同 text 走原 _run,完成后写入对应 dedup key"""
ingester = FakeIngester()
redis = FakeRedis()
manager = IngestTaskManager(ingester, redis, Settings())
task1 = await manager.submit(DocumentInput(text="内容A", title="t1"))
await manager.wait_done(task1, timeout=5)
task2 = await manager.submit(DocumentInput(text="内容B", title="t2"))
await manager.wait_done(task2, timeout=5)
assert await redis.get_json(f"{DEDUP_KEY_PREFIX}{_text_hash('内容A')}") is not None
assert await redis.get_json(f"{DEDUP_KEY_PREFIX}{_text_hash('内容B')}") is not None
assert len(ingester.calls) == 2
async def test_dedup_skipped_when_redis_unavailable() -> None:
"""Redis 不可用:跳过去重,相同 text 仍走完整流水线"""
ingester = FakeIngester()
manager = IngestTaskManager(ingester, None, Settings())
task1 = await manager.submit(DocumentInput(text="内容", title="t1"))
final1 = await manager.wait_done(task1, timeout=5)
assert final1["result"]["deduplicated"] is False
task2 = await manager.submit(DocumentInput(text="内容", title="t2"))
final2 = await manager.wait_done(task2, timeout=5)
assert final2["status"] == IngestTaskStatus.DONE
assert final2["result"]["deduplicated"] is False
assert len(ingester.calls) == 2
async def test_dedup_lookup_failure_falls_back_to_normal_pipeline() -> None:
"""Redis get_json 抛错:去重查询降级为未命中,走原流水线"""
class _ExplodingRedis(FakeRedis):
async def get_json(self, key: str) -> dict[str, Any] | None:
raise RuntimeError("redis down")
ingester = FakeIngester()
redis = _ExplodingRedis()
manager = IngestTaskManager(ingester, redis, Settings())
task = await manager.submit(DocumentInput(text="x", title="t"))
final = await manager.wait_done(task, timeout=5)
assert final["status"] == IngestTaskStatus.DONE
assert len(ingester.calls) == 1
+63
View File
@@ -148,6 +148,69 @@ class TestQueryParserParse:
assert [c.name for c in parsed.categories] == ["财务行政"] assert [c.name for c in parsed.categories] == ["财务行政"]
async def test_parse_new_fields_populated(self):
"""JSON 含 entities/intent/time_range → 正确填充字段"""
response = json.dumps(
{
"categories": [{"name": "技术文档", "confidence": 0.9}],
"rewrite": "如何设计微服务架构",
"keywords": ["架构"],
"entities": ["微服务", "Kubernetes"],
"intent": "操作",
"time_range": "2023年",
},
ensure_ascii=False,
)
parser = _make_parser(response)
parsed = await parser.parse("怎么做微服务部署")
assert parsed.parse_failed is False
assert parsed.entities == ["微服务", "Kubernetes"]
assert parsed.intent == "操作"
assert parsed.time_range == "2023年"
async def test_parse_new_fields_missing_defaults(self):
"""JSON 缺 entities/intent/time_range → 降级为默认值,parse_failed=False(向后兼容)"""
response = json.dumps(
{
"categories": [{"name": "技术文档", "confidence": 0.9}],
"rewrite": "如何设计架构",
"keywords": ["架构"],
},
ensure_ascii=False,
)
parser = _make_parser(response)
parsed = await parser.parse("怎么做架构设计")
assert parsed.parse_failed is False
assert parsed.entities == []
assert parsed.intent == ""
assert parsed.time_range == ""
async def test_parse_new_fields_wrong_type_defaults(self):
"""entities 非列表 / intent 非字符串 / time_range 非字符串 → 置空,parse_failed=False(容错)"""
response = json.dumps(
{
"categories": [{"name": "技术文档", "confidence": 0.9}],
"rewrite": "如何设计架构",
"keywords": ["架构"],
"entities": "微服务", # 非列表
"intent": 123, # 非字符串
"time_range": ["2023"], # 非字符串
},
ensure_ascii=False,
)
parser = _make_parser(response)
parsed = await parser.parse("怎么做架构设计")
assert parsed.parse_failed is False
assert parsed.entities == []
assert parsed.intent == ""
assert parsed.time_range == ""
class TestDecideRoute: class TestDecideRoute:
"""decide_route 纯函数的四个分支""" """decide_route 纯函数的四个分支"""
+3 -3
View File
@@ -112,9 +112,9 @@ class _CountingRetriever:
return self.response return self.response
def _search_cache_key(query: str, top_k: int | None = None) -> str: def _search_cache_key(query: str, top_k: int | None = None, summarize: bool = False) -> str:
"""与路由侧一致的检索缓存键""" """与路由侧一致的检索缓存键query + top_k + summarize 三者均参与键计算)"""
return f"search:{sha256((query + '|' + str(top_k)).encode()).hexdigest()[:16]}" return f"search:{sha256((query + '|' + str(top_k) + '|' + str(summarize)).encode()).hexdigest()[:16]}"
class TestSearchApiCache: class TestSearchApiCache:
+138
View File
@@ -0,0 +1,138 @@
"""ResultSummarizer 单元测试(FakeOllama,不依赖真实 Ollama
覆盖:
- summarize 正常:调用 ollamaprompt 含 query 与 hits 文本,返回 strip 后的 summary
- summarize 无 hits:返回空串且不调 ollama
- summarize ollama 异常:返回空串
- _build_context 拼接格式:编号 / title / section_path / 文本
- 取前 settings.result_summary_max_hits 条:超量 hits 只取前 N 条进入 prompt
"""
import pytest
from app.config import settings
from app.core.result_summarizer import ResultSummarizer
from app.models.search import SearchHit
class FakeOllama:
"""记录调用并返回固定响应的假 OllamaClient"""
def __init__(self, response: str = "这是总结") -> None:
self.response = response
self.calls: list[dict] = []
async def generate(self, prompt: str, json_mode: bool = False) -> str:
self.calls.append({"prompt": prompt, "json_mode": json_mode})
return self.response
class FailingOllama:
"""generate 抛异常的假 OllamaClient,用于测试容错降级"""
async def generate(self, prompt: str, json_mode: bool = False) -> str:
raise RuntimeError("ollama 不可用")
def _hit(idx: int, title: str = "", section_path: str = "") -> SearchHit:
"""构造测试用 SearchHit"""
return SearchHit(
text=f"文本内容-{idx}",
doc_id=f"doc-{idx}",
title=title or f"标题-{idx}",
section_path=section_path,
score=0.1 * idx,
)
class TestSummarize:
"""ResultSummarizer.summarize 主流程"""
async def test_summarize_normal(self):
"""正常总结:调用 ollamaprompt 含 query 与 hits 文本,返回 strip 后的 summary"""
ollama = FakeOllama(response=" 这是 AI 总结 ")
summarizer = ResultSummarizer(ollama=ollama) # type: ignore[arg-type]
hits = [_hit(1, "文档A", "章节A"), _hit(2, "文档B", "章节B")]
result = await summarizer.summarize("安装步骤是什么", hits)
assert result == "这是 AI 总结"
assert len(ollama.calls) == 1
prompt = ollama.calls[0]["prompt"]
assert "安装步骤是什么" in prompt
assert "文本内容-1" in prompt
assert "文本内容-2" in prompt
assert "文档A" in prompt
assert "文档B" in prompt
assert "章节A" in prompt
assert "章节B" in prompt
async def test_summarize_empty_hits_returns_empty(self):
"""无 hits:返回空串且不调用 ollama"""
ollama = FakeOllama()
summarizer = ResultSummarizer(ollama=ollama) # type: ignore[arg-type]
result = await summarizer.summarize("任何问题", [])
assert result == ""
assert ollama.calls == []
async def test_summarize_ollama_error_returns_empty(self):
"""ollama.generate 抛异常:返回空串(容错降级)"""
summarizer = ResultSummarizer(ollama=FailingOllama()) # type: ignore[arg-type]
hits = [_hit(1)]
result = await summarizer.summarize("问题", hits)
assert result == ""
class TestBuildContext:
"""_build_context 拼接格式:编号 / title / section_path / 文本"""
def test_build_context_format(self):
hits = [
_hit(1, "文档A", "第一章"),
_hit(2, "文档B", "第二章"),
]
context = ResultSummarizer._build_context(hits)
# 每条带编号、title、section_path
assert "[1] / 文档A / 第一章" in context
assert "[2] / 文档B / 第二章" in context
# 文本内容拼接
assert "文本内容-1" in context
assert "文本内容-2" in context
# 分隔符
assert "---" in context
def test_build_context_empty_title_and_section(self):
"""title 与 section_path 为空时头部仅保留编号"""
hit = SearchHit(text="纯文本", doc_id="d1", title="", section_path="", score=1.0)
context = ResultSummarizer._build_context([hit])
assert context.strip().startswith("[1]")
assert "纯文本" in context
class TestMaxHitsLimit:
"""取前 settings.result_summary_max_hits 条命中"""
async def test_only_first_n_hits_in_prompt(self, monkeypatch: pytest.MonkeyPatch):
"""构造超过 max_hits 的 hits,验证 prompt 只含前 N 条文本"""
max_hits = 3
monkeypatch.setattr(settings, "result_summary_max_hits", max_hits)
ollama = FakeOllama(response="总结")
summarizer = ResultSummarizer(ollama=ollama) # type: ignore[arg-type]
# 构造 max_hits + 5 条 hits
hits = [_hit(i) for i in range(max_hits + 5)]
await summarizer.summarize("问题", hits)
assert len(ollama.calls) == 1
prompt = ollama.calls[0]["prompt"]
# 前 max_hits 条文本出现在 prompt 中
for i in range(max_hits):
assert f"文本内容-{i}" in prompt
# 超出的文本不出现在 prompt 中
for i in range(max_hits, max_hits + 5):
assert f"文本内容-{i}" not in prompt
Generated
+468
View File
@@ -38,6 +38,72 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" },
] ]
[[package]]
name = "bcrypt"
version = "5.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d4/36/3329e2518d70ad8e2e5817d5a4cac6bba05a47767ec416c7d020a965f408/bcrypt-5.0.0.tar.gz", hash = "sha256:f748f7c2d6fd375cc93d3fba7ef4a9e3a092421b8dbf34d8d4dc06be9492dfdd", size = 25386, upload-time = "2025-09-25T19:50:47.829Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/13/85/3e65e01985fddf25b64ca67275bb5bdb4040bd1a53b66d355c6c37c8a680/bcrypt-5.0.0-cp313-cp313t-macosx_10_12_universal2.whl", hash = "sha256:f3c08197f3039bec79cee59a606d62b96b16669cff3949f21e74796b6e3cd2be", size = 481806, upload-time = "2025-09-25T19:49:05.102Z" },
{ url = "https://files.pythonhosted.org/packages/44/dc/01eb79f12b177017a726cbf78330eb0eb442fae0e7b3dfd84ea2849552f3/bcrypt-5.0.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:200af71bc25f22006f4069060c88ed36f8aa4ff7f53e67ff04d2ab3f1e79a5b2", size = 268626, upload-time = "2025-09-25T19:49:06.723Z" },
{ url = "https://files.pythonhosted.org/packages/8c/cf/e82388ad5959c40d6afd94fb4743cc077129d45b952d46bdc3180310e2df/bcrypt-5.0.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:baade0a5657654c2984468efb7d6c110db87ea63ef5a4b54732e7e337253e44f", size = 271853, upload-time = "2025-09-25T19:49:08.028Z" },
{ url = "https://files.pythonhosted.org/packages/ec/86/7134b9dae7cf0efa85671651341f6afa695857fae172615e960fb6a466fa/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:c58b56cdfb03202b3bcc9fd8daee8e8e9b6d7e3163aa97c631dfcfcc24d36c86", size = 269793, upload-time = "2025-09-25T19:49:09.727Z" },
{ url = "https://files.pythonhosted.org/packages/cc/82/6296688ac1b9e503d034e7d0614d56e80c5d1a08402ff856a4549cb59207/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4bfd2a34de661f34d0bda43c3e4e79df586e4716ef401fe31ea39d69d581ef23", size = 289930, upload-time = "2025-09-25T19:49:11.204Z" },
{ url = "https://files.pythonhosted.org/packages/d1/18/884a44aa47f2a3b88dd09bc05a1e40b57878ecd111d17e5bba6f09f8bb77/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:ed2e1365e31fc73f1825fa830f1c8f8917ca1b3ca6185773b349c20fd606cec2", size = 272194, upload-time = "2025-09-25T19:49:12.524Z" },
{ url = "https://files.pythonhosted.org/packages/0e/8f/371a3ab33c6982070b674f1788e05b656cfbf5685894acbfef0c65483a59/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_aarch64.whl", hash = "sha256:83e787d7a84dbbfba6f250dd7a5efd689e935f03dd83b0f919d39349e1f23f83", size = 269381, upload-time = "2025-09-25T19:49:14.308Z" },
{ url = "https://files.pythonhosted.org/packages/b1/34/7e4e6abb7a8778db6422e88b1f06eb07c47682313997ee8a8f9352e5a6f1/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_x86_64.whl", hash = "sha256:137c5156524328a24b9fac1cb5db0ba618bc97d11970b39184c1d87dc4bf1746", size = 271750, upload-time = "2025-09-25T19:49:15.584Z" },
{ url = "https://files.pythonhosted.org/packages/c0/1b/54f416be2499bd72123c70d98d36c6cd61a4e33d9b89562c22481c81bb30/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:38cac74101777a6a7d3b3e3cfefa57089b5ada650dce2baf0cbdd9d65db22a9e", size = 303757, upload-time = "2025-09-25T19:49:17.244Z" },
{ url = "https://files.pythonhosted.org/packages/13/62/062c24c7bcf9d2826a1a843d0d605c65a755bc98002923d01fd61270705a/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:d8d65b564ec849643d9f7ea05c6d9f0cd7ca23bdd4ac0c2dbef1104ab504543d", size = 306740, upload-time = "2025-09-25T19:49:18.693Z" },
{ url = "https://files.pythonhosted.org/packages/d5/c8/1fdbfc8c0f20875b6b4020f3c7dc447b8de60aa0be5faaf009d24242aec9/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:741449132f64b3524e95cd30e5cd3343006ce146088f074f31ab26b94e6c75ba", size = 334197, upload-time = "2025-09-25T19:49:20.523Z" },
{ url = "https://files.pythonhosted.org/packages/a6/c1/8b84545382d75bef226fbc6588af0f7b7d095f7cd6a670b42a86243183cd/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:212139484ab3207b1f0c00633d3be92fef3c5f0af17cad155679d03ff2ee1e41", size = 352974, upload-time = "2025-09-25T19:49:22.254Z" },
{ url = "https://files.pythonhosted.org/packages/10/a6/ffb49d4254ed085e62e3e5dd05982b4393e32fe1e49bb1130186617c29cd/bcrypt-5.0.0-cp313-cp313t-win32.whl", hash = "sha256:9d52ed507c2488eddd6a95bccee4e808d3234fa78dd370e24bac65a21212b861", size = 148498, upload-time = "2025-09-25T19:49:24.134Z" },
{ url = "https://files.pythonhosted.org/packages/48/a9/259559edc85258b6d5fc5471a62a3299a6aa37a6611a169756bf4689323c/bcrypt-5.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f6984a24db30548fd39a44360532898c33528b74aedf81c26cf29c51ee47057e", size = 145853, upload-time = "2025-09-25T19:49:25.702Z" },
{ url = "https://files.pythonhosted.org/packages/2d/df/9714173403c7e8b245acf8e4be8876aac64a209d1b392af457c79e60492e/bcrypt-5.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9fffdb387abe6aa775af36ef16f55e318dcda4194ddbf82007a6f21da29de8f5", size = 139626, upload-time = "2025-09-25T19:49:26.928Z" },
{ url = "https://files.pythonhosted.org/packages/f8/14/c18006f91816606a4abe294ccc5d1e6f0e42304df5a33710e9e8e95416e1/bcrypt-5.0.0-cp314-cp314t-macosx_10_12_universal2.whl", hash = "sha256:4870a52610537037adb382444fefd3706d96d663ac44cbb2f37e3919dca3d7ef", size = 481862, upload-time = "2025-09-25T19:49:28.365Z" },
{ url = "https://files.pythonhosted.org/packages/67/49/dd074d831f00e589537e07a0725cf0e220d1f0d5d8e85ad5bbff251c45aa/bcrypt-5.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48f753100931605686f74e27a7b49238122aa761a9aefe9373265b8b7aa43ea4", size = 268544, upload-time = "2025-09-25T19:49:30.39Z" },
{ url = "https://files.pythonhosted.org/packages/f5/91/50ccba088b8c474545b034a1424d05195d9fcbaaf802ab8bfe2be5a4e0d7/bcrypt-5.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f70aadb7a809305226daedf75d90379c397b094755a710d7014b8b117df1ebbf", size = 271787, upload-time = "2025-09-25T19:49:32.144Z" },
{ url = "https://files.pythonhosted.org/packages/aa/e7/d7dba133e02abcda3b52087a7eea8c0d4f64d3e593b4fffc10c31b7061f3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:744d3c6b164caa658adcb72cb8cc9ad9b4b75c7db507ab4bc2480474a51989da", size = 269753, upload-time = "2025-09-25T19:49:33.885Z" },
{ url = "https://files.pythonhosted.org/packages/33/fc/5b145673c4b8d01018307b5c2c1fc87a6f5a436f0ad56607aee389de8ee3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a28bc05039bdf3289d757f49d616ab3efe8cf40d8e8001ccdd621cd4f98f4fc9", size = 289587, upload-time = "2025-09-25T19:49:35.144Z" },
{ url = "https://files.pythonhosted.org/packages/27/d7/1ff22703ec6d4f90e62f1a5654b8867ef96bafb8e8102c2288333e1a6ca6/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:7f277a4b3390ab4bebe597800a90da0edae882c6196d3038a73adf446c4f969f", size = 272178, upload-time = "2025-09-25T19:49:36.793Z" },
{ url = "https://files.pythonhosted.org/packages/c8/88/815b6d558a1e4d40ece04a2f84865b0fef233513bd85fd0e40c294272d62/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:79cfa161eda8d2ddf29acad370356b47f02387153b11d46042e93a0a95127493", size = 269295, upload-time = "2025-09-25T19:49:38.164Z" },
{ url = "https://files.pythonhosted.org/packages/51/8c/e0db387c79ab4931fc89827d37608c31cc57b6edc08ccd2386139028dc0d/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a5393eae5722bcef046a990b84dff02b954904c36a194f6cfc817d7dca6c6f0b", size = 271700, upload-time = "2025-09-25T19:49:39.917Z" },
{ url = "https://files.pythonhosted.org/packages/06/83/1570edddd150f572dbe9fc00f6203a89fc7d4226821f67328a85c330f239/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f4c94dec1b5ab5d522750cb059bb9409ea8872d4494fd152b53cca99f1ddd8c", size = 334034, upload-time = "2025-09-25T19:49:41.227Z" },
{ url = "https://files.pythonhosted.org/packages/c9/f2/ea64e51a65e56ae7a8a4ec236c2bfbdd4b23008abd50ac33fbb2d1d15424/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0cae4cb350934dfd74c020525eeae0a5f79257e8a201c0c176f4b84fdbf2a4b4", size = 352766, upload-time = "2025-09-25T19:49:43.08Z" },
{ url = "https://files.pythonhosted.org/packages/d7/d4/1a388d21ee66876f27d1a1f41287897d0c0f1712ef97d395d708ba93004c/bcrypt-5.0.0-cp314-cp314t-win32.whl", hash = "sha256:b17366316c654e1ad0306a6858e189fc835eca39f7eb2cafd6aaca8ce0c40a2e", size = 152449, upload-time = "2025-09-25T19:49:44.971Z" },
{ url = "https://files.pythonhosted.org/packages/3f/61/3291c2243ae0229e5bca5d19f4032cecad5dfb05a2557169d3a69dc0ba91/bcrypt-5.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:92864f54fb48b4c718fc92a32825d0e42265a627f956bc0361fe869f1adc3e7d", size = 149310, upload-time = "2025-09-25T19:49:46.162Z" },
{ url = "https://files.pythonhosted.org/packages/3e/89/4b01c52ae0c1a681d4021e5dd3e45b111a8fb47254a274fa9a378d8d834b/bcrypt-5.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dd19cf5184a90c873009244586396a6a884d591a5323f0e8a5922560718d4993", size = 143761, upload-time = "2025-09-25T19:49:47.345Z" },
{ url = "https://files.pythonhosted.org/packages/84/29/6237f151fbfe295fe3e074ecc6d44228faa1e842a81f6d34a02937ee1736/bcrypt-5.0.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:fc746432b951e92b58317af8e0ca746efe93e66555f1b40888865ef5bf56446b", size = 494553, upload-time = "2025-09-25T19:49:49.006Z" },
{ url = "https://files.pythonhosted.org/packages/45/b6/4c1205dde5e464ea3bd88e8742e19f899c16fa8916fb8510a851fae985b5/bcrypt-5.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c2388ca94ffee269b6038d48747f4ce8df0ffbea43f31abfa18ac72f0218effb", size = 275009, upload-time = "2025-09-25T19:49:50.581Z" },
{ url = "https://files.pythonhosted.org/packages/3b/71/427945e6ead72ccffe77894b2655b695ccf14ae1866cd977e185d606dd2f/bcrypt-5.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:560ddb6ec730386e7b3b26b8b4c88197aaed924430e7b74666a586ac997249ef", size = 278029, upload-time = "2025-09-25T19:49:52.533Z" },
{ url = "https://files.pythonhosted.org/packages/17/72/c344825e3b83c5389a369c8a8e58ffe1480b8a699f46c127c34580c4666b/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d79e5c65dcc9af213594d6f7f1fa2c98ad3fc10431e7aa53c176b441943efbdd", size = 275907, upload-time = "2025-09-25T19:49:54.709Z" },
{ url = "https://files.pythonhosted.org/packages/0b/7e/d4e47d2df1641a36d1212e5c0514f5291e1a956a7749f1e595c07a972038/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2b732e7d388fa22d48920baa267ba5d97cca38070b69c0e2d37087b381c681fd", size = 296500, upload-time = "2025-09-25T19:49:56.013Z" },
{ url = "https://files.pythonhosted.org/packages/0f/c3/0ae57a68be2039287ec28bc463b82e4b8dc23f9d12c0be331f4782e19108/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0c8e093ea2532601a6f686edbc2c6b2ec24131ff5c52f7610dd64fa4553b5464", size = 278412, upload-time = "2025-09-25T19:49:57.356Z" },
{ url = "https://files.pythonhosted.org/packages/45/2b/77424511adb11e6a99e3a00dcc7745034bee89036ad7d7e255a7e47be7d8/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5b1589f4839a0899c146e8892efe320c0fa096568abd9b95593efac50a87cb75", size = 275486, upload-time = "2025-09-25T19:49:59.116Z" },
{ url = "https://files.pythonhosted.org/packages/43/0a/405c753f6158e0f3f14b00b462d8bca31296f7ecfc8fc8bc7919c0c7d73a/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:89042e61b5e808b67daf24a434d89bab164d4de1746b37a8d173b6b14f3db9ff", size = 277940, upload-time = "2025-09-25T19:50:00.869Z" },
{ url = "https://files.pythonhosted.org/packages/62/83/b3efc285d4aadc1fa83db385ec64dcfa1707e890eb42f03b127d66ac1b7b/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:e3cf5b2560c7b5a142286f69bde914494b6d8f901aaa71e453078388a50881c4", size = 310776, upload-time = "2025-09-25T19:50:02.393Z" },
{ url = "https://files.pythonhosted.org/packages/95/7d/47ee337dacecde6d234890fe929936cb03ebc4c3a7460854bbd9c97780b8/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:f632fd56fc4e61564f78b46a2269153122db34988e78b6be8b32d28507b7eaeb", size = 312922, upload-time = "2025-09-25T19:50:04.232Z" },
{ url = "https://files.pythonhosted.org/packages/d6/3a/43d494dfb728f55f4e1cf8fd435d50c16a2d75493225b54c8d06122523c6/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:801cad5ccb6b87d1b430f183269b94c24f248dddbbc5c1f78b6ed231743e001c", size = 341367, upload-time = "2025-09-25T19:50:05.559Z" },
{ url = "https://files.pythonhosted.org/packages/55/ab/a0727a4547e383e2e22a630e0f908113db37904f58719dc48d4622139b5c/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3cf67a804fc66fc217e6914a5635000259fbbbb12e78a99488e4d5ba445a71eb", size = 359187, upload-time = "2025-09-25T19:50:06.916Z" },
{ url = "https://files.pythonhosted.org/packages/1b/bb/461f352fdca663524b4643d8b09e8435b4990f17fbf4fea6bc2a90aa0cc7/bcrypt-5.0.0-cp38-abi3-win32.whl", hash = "sha256:3abeb543874b2c0524ff40c57a4e14e5d3a66ff33fb423529c88f180fd756538", size = 153752, upload-time = "2025-09-25T19:50:08.515Z" },
{ url = "https://files.pythonhosted.org/packages/41/aa/4190e60921927b7056820291f56fc57d00d04757c8b316b2d3c0d1d6da2c/bcrypt-5.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:35a77ec55b541e5e583eb3436ffbbf53b0ffa1fa16ca6782279daf95d146dcd9", size = 150881, upload-time = "2025-09-25T19:50:09.742Z" },
{ url = "https://files.pythonhosted.org/packages/54/12/cd77221719d0b39ac0b55dbd39358db1cd1246e0282e104366ebbfb8266a/bcrypt-5.0.0-cp38-abi3-win_arm64.whl", hash = "sha256:cde08734f12c6a4e28dc6755cd11d3bdfea608d93d958fffbe95a7026ebe4980", size = 144931, upload-time = "2025-09-25T19:50:11.016Z" },
{ url = "https://files.pythonhosted.org/packages/5d/ba/2af136406e1c3839aea9ecadc2f6be2bcd1eff255bd451dd39bcf302c47a/bcrypt-5.0.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0c418ca99fd47e9c59a301744d63328f17798b5947b0f791e9af3c1c499c2d0a", size = 495313, upload-time = "2025-09-25T19:50:12.309Z" },
{ url = "https://files.pythonhosted.org/packages/ac/ee/2f4985dbad090ace5ad1f7dd8ff94477fe089b5fab2040bd784a3d5f187b/bcrypt-5.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddb4e1500f6efdd402218ffe34d040a1196c072e07929b9820f363a1fd1f4191", size = 275290, upload-time = "2025-09-25T19:50:13.673Z" },
{ url = "https://files.pythonhosted.org/packages/e4/6e/b77ade812672d15cf50842e167eead80ac3514f3beacac8902915417f8b7/bcrypt-5.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7aeef54b60ceddb6f30ee3db090351ecf0d40ec6e2abf41430997407a46d2254", size = 278253, upload-time = "2025-09-25T19:50:15.089Z" },
{ url = "https://files.pythonhosted.org/packages/36/c4/ed00ed32f1040f7990dac7115f82273e3c03da1e1a1587a778d8cea496d8/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f0ce778135f60799d89c9693b9b398819d15f1921ba15fe719acb3178215a7db", size = 276084, upload-time = "2025-09-25T19:50:16.699Z" },
{ url = "https://files.pythonhosted.org/packages/e7/c4/fa6e16145e145e87f1fa351bbd54b429354fd72145cd3d4e0c5157cf4c70/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a71f70ee269671460b37a449f5ff26982a6f2ba493b3eabdd687b4bf35f875ac", size = 297185, upload-time = "2025-09-25T19:50:18.525Z" },
{ url = "https://files.pythonhosted.org/packages/24/b4/11f8a31d8b67cca3371e046db49baa7c0594d71eb40ac8121e2fc0888db0/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f8429e1c410b4073944f03bd778a9e066e7fad723564a52ff91841d278dfc822", size = 278656, upload-time = "2025-09-25T19:50:19.809Z" },
{ url = "https://files.pythonhosted.org/packages/ac/31/79f11865f8078e192847d2cb526e3fa27c200933c982c5b2869720fa5fce/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:edfcdcedd0d0f05850c52ba3127b1fce70b9f89e0fe5ff16517df7e81fa3cbb8", size = 275662, upload-time = "2025-09-25T19:50:21.567Z" },
{ url = "https://files.pythonhosted.org/packages/d4/8d/5e43d9584b3b3591a6f9b68f755a4da879a59712981ef5ad2a0ac1379f7a/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:611f0a17aa4a25a69362dcc299fda5c8a3d4f160e2abb3831041feb77393a14a", size = 278240, upload-time = "2025-09-25T19:50:23.305Z" },
{ url = "https://files.pythonhosted.org/packages/89/48/44590e3fc158620f680a978aafe8f87a4c4320da81ed11552f0323aa9a57/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:db99dca3b1fdc3db87d7c57eac0c82281242d1eabf19dcb8a6b10eb29a2e72d1", size = 311152, upload-time = "2025-09-25T19:50:24.597Z" },
{ url = "https://files.pythonhosted.org/packages/5f/85/e4fbfc46f14f47b0d20493669a625da5827d07e8a88ee460af6cd9768b44/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:5feebf85a9cefda32966d8171f5db7e3ba964b77fdfe31919622256f80f9cf42", size = 313284, upload-time = "2025-09-25T19:50:26.268Z" },
{ url = "https://files.pythonhosted.org/packages/25/ae/479f81d3f4594456a01ea2f05b132a519eff9ab5768a70430fa1132384b1/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3ca8a166b1140436e058298a34d88032ab62f15aae1c598580333dc21d27ef10", size = 341643, upload-time = "2025-09-25T19:50:28.02Z" },
{ url = "https://files.pythonhosted.org/packages/df/d2/36a086dee1473b14276cd6ea7f61aef3b2648710b5d7f1c9e032c29b859f/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:61afc381250c3182d9078551e3ac3a41da14154fbff647ddf52a769f588c4172", size = 359698, upload-time = "2025-09-25T19:50:31.347Z" },
{ url = "https://files.pythonhosted.org/packages/c0/f6/688d2cd64bfd0b14d805ddb8a565e11ca1fb0fd6817175d58b10052b6d88/bcrypt-5.0.0-cp39-abi3-win32.whl", hash = "sha256:64d7ce196203e468c457c37ec22390f1a61c85c6f0b8160fd752940ccfb3a683", size = 153725, upload-time = "2025-09-25T19:50:34.384Z" },
{ url = "https://files.pythonhosted.org/packages/9f/b9/9d9a641194a730bda138b3dfe53f584d61c58cd5230e37566e83ec2ffa0d/bcrypt-5.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:64ee8434b0da054d830fa8e89e1c8bf30061d539044a39524ff7dec90481e5c2", size = 150912, upload-time = "2025-09-25T19:50:35.69Z" },
{ url = "https://files.pythonhosted.org/packages/27/44/d2ef5e87509158ad2187f4dd0852df80695bb1ee0cfe0a684727b01a69e0/bcrypt-5.0.0-cp39-abi3-win_arm64.whl", hash = "sha256:f2347d3534e76bf50bca5500989d6c1d05ed64b440408057a37673282c654927", size = 144953, upload-time = "2025-09-25T19:50:37.32Z" },
]
[[package]] [[package]]
name = "certifi" name = "certifi"
version = "2026.7.22" version = "2026.7.22"
@@ -93,6 +159,14 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/7b/0e/00cddd6b8668884e9c7588ab0eeb73becbd1efa3eaead34397f2e9a8de49/fastapi-0.140.7-py3-none-any.whl", hash = "sha256:960bb9696d8fd19dff488aa4f67f276364542cfcce9f7e68a82fe49dce126626", size = 131085, upload-time = "2026-07-27T17:34:47.036Z" }, { url = "https://files.pythonhosted.org/packages/7b/0e/00cddd6b8668884e9c7588ab0eeb73becbd1efa3eaead34397f2e9a8de49/fastapi-0.140.7-py3-none-any.whl", hash = "sha256:960bb9696d8fd19dff488aa4f67f276364542cfcce9f7e68a82fe49dce126626", size = 131085, upload-time = "2026-07-27T17:34:47.036Z" },
] ]
[[package]]
name = "flatbuffers"
version = "25.12.19"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e8/2d/d2a548598be01649e2d46231d151a6c56d10b964d94043a335ae56ea2d92/flatbuffers-25.12.19-py2.py3-none-any.whl", hash = "sha256:7634f50c427838bb021c2d66a3d1168e9d199b0607e6329399f04846d42e20b4", size = 26661, upload-time = "2025-12-19T23:16:13.622Z" },
]
[[package]] [[package]]
name = "grpcio" name = "grpcio"
version = "1.83.0" version = "1.83.0"
@@ -329,6 +403,86 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/78/f7/18a1afcd64f35314b68c1f23afcd9994d0bc13e65cc77517afff4e83986d/jiter-0.16.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64d613743df53199b1aa256a7d328340da6d7078aac7705a7db9d7a791e9cfd2", size = 343885, upload-time = "2026-06-29T13:05:12.087Z" }, { url = "https://files.pythonhosted.org/packages/78/f7/18a1afcd64f35314b68c1f23afcd9994d0bc13e65cc77517afff4e83986d/jiter-0.16.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64d613743df53199b1aa256a7d328340da6d7078aac7705a7db9d7a791e9cfd2", size = 343885, upload-time = "2026-06-29T13:05:12.087Z" },
] ]
[[package]]
name = "lxml"
version = "6.1.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/05/3b/aab6728cae887456f409b4d75e8a01856e4f04bd510de38052a47768b680/lxml-6.1.1.tar.gz", hash = "sha256:ba96ae44888e0185281e937633a743ea90d5a196c6000f82565ebb0580012d40", size = 4197430, upload-time = "2026-05-18T19:19:06.424Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6a/6e/c4add832b6fc1e887125b96f880d7b9b70aae5248718e046b1704bcac4b9/lxml-6.1.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:104c09bda8d2a562824c0e319d0768ce26a779b7601e0931d33b09b53c392ef7", size = 8570821, upload-time = "2026-05-18T19:17:42.068Z" },
{ url = "https://files.pythonhosted.org/packages/22/00/ff3009c88e65de8011630acf8ab5a09cb2becd2aaf47fba2f3449f6224e9/lxml-6.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:25c6997a9a534e016695a0ba06b2f07945de682731ff01065b6d5a4474179da1", size = 4624252, upload-time = "2026-05-18T19:17:47.897Z" },
{ url = "https://files.pythonhosted.org/packages/42/95/bb63f0fd62e554fe078e1fb3c8fe9083c14ddc7ad7fa178d10e57e071ac7/lxml-6.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c921ba5c51e4e9f63b8b00267d06566e1f63407408a0496da2d1d0bfc819c7fc", size = 4930746, upload-time = "2026-05-18T19:18:29.637Z" },
{ url = "https://files.pythonhosted.org/packages/eb/99/0013e8d9b5960f4f041cf0b73e2f80c23eb5205b1f7bfb20203243651359/lxml-6.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:54a7f95e4de5fb94e2f9f4b9055c6ba33bf3d628fd77a1d647c5923caa2cdcdc", size = 5093723, upload-time = "2026-05-18T19:18:34.168Z" },
{ url = "https://files.pythonhosted.org/packages/29/91/317b332636bfc7bddcff828d41b3307f50043f4b237e40849c333d80fa1a/lxml-6.1.1-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f2ec43df44b1f76249ee0a615334f9b5b060e1c8bd90e706dad2d14d02f383", size = 5005557, upload-time = "2026-05-18T19:18:39.798Z" },
{ url = "https://files.pythonhosted.org/packages/42/2f/cc9bf06afe70f9c9093ae60855d9759da9db601ec4080f7473319666ffd7/lxml-6.1.1-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:70ef8a7e102a1508f8121aae5b0867abd663f72c14f0a9c937e6554cb4587b7b", size = 5631036, upload-time = "2026-05-18T19:18:44.858Z" },
{ url = "https://files.pythonhosted.org/packages/08/f6/af32e23e563971ffb0fb86be52bc5be5c2c118858ffc119bf6a9039b173d/lxml-6.1.1-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ebe6af670449830d6d9b752c256a983291c766a1365ba5d5460048f9e33a7818", size = 5240367, upload-time = "2026-05-18T19:18:49.217Z" },
{ url = "https://files.pythonhosted.org/packages/78/83/8555d40948b09ce86f1bd0c68a7ac31d07b1929f92cc1b074006c97ef2d2/lxml-6.1.1-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:27acc820660aaffa4f7c087f29120e12980f7779d56d8492d263170111284740", size = 5350171, upload-time = "2026-05-18T19:18:52.779Z" },
{ url = "https://files.pythonhosted.org/packages/63/75/5d92da93729b7bad783689e6496049fa40927b45bec7bf183c981de3ca70/lxml-6.1.1-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:1db753c9115ec7100d073b744d17e25e88a8f90f5c39b2f5dd878149af59671f", size = 4694874, upload-time = "2026-05-18T19:18:55.139Z" },
{ url = "https://files.pythonhosted.org/packages/c5/b5/3aad415a9a25b822e783f15deeb4dffccf5113030f1afa2222dd929313d9/lxml-6.1.1-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c4f469aebd783bb741c2ecb2a681008fd26bfe5c16a9a72ed5467f834e810df2", size = 5244492, upload-time = "2026-05-18T19:19:01.28Z" },
{ url = "https://files.pythonhosted.org/packages/f1/a1/5fcf7eb9904b80086aa47dcf0027de07b1bb990afad2e6823144c368ae04/lxml-6.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:766b010012d59470072c1816b5b6c69f1d243e5db36ea5968e94accf430a4635", size = 5048232, upload-time = "2026-05-18T19:18:12.67Z" },
{ url = "https://files.pythonhosted.org/packages/77/74/1f601b63c7a69fcdf10fa9b148c81da8442204194f6c55509cc485c786b9/lxml-6.1.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b8d812c6011c08b8111a15e54dd990b8923692d80adf35488bee34026c35accf", size = 4777023, upload-time = "2026-05-18T19:18:15.928Z" },
{ url = "https://files.pythonhosted.org/packages/a2/b9/7a78f51aec95b1bf780d78e12705a9f6533284f8693dc5c0e6724fa53d3f/lxml-6.1.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:fe0306bd29505a9177aac19f1877174b0e7422c222a59f70b2cd41633448c3dc", size = 5645773, upload-time = "2026-05-18T19:18:23.223Z" },
{ url = "https://files.pythonhosted.org/packages/a5/6e/98a7b7ad54e4e74fa1f20fff776913980619d0ebe5558232d7da6580bdd8/lxml-6.1.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5ba186ad207446c65d3bb3d3e0412b032b1d9f595e59861e2354798c5703d955", size = 5233088, upload-time = "2026-05-18T19:18:31.433Z" },
{ url = "https://files.pythonhosted.org/packages/65/d1/bc0ed2427bf609f2ee10da303a6a226f9c8bce94f945dc29a32ce55de6e4/lxml-6.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:aa366a1e55b8ebfe8ca8ddc3cfe75c8ebade181aeb0f661d0cb05986b647f72a", size = 5260995, upload-time = "2026-05-18T19:18:37.091Z" },
{ url = "https://files.pythonhosted.org/packages/69/8b/6772e1a4b513fc50a8d931f19edde0e13ae6918510a1e13ff67864f3e5ed/lxml-6.1.1-cp312-cp312-win32.whl", hash = "sha256:126c93f7f56f0eda92f6d8c619edc463a4f23d9252f1c9d0405a76f25fa9f11a", size = 3596382, upload-time = "2026-05-18T19:17:18.37Z" },
{ url = "https://files.pythonhosted.org/packages/1b/89/45198e9624762af2dfd2cb8782598477ceb29f6e59caab560388ae1f4ec1/lxml-6.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:26e6eda8d38c1fcab1090dd196ee87cbd13788e531937610e2589085de074e77", size = 3997255, upload-time = "2026-05-18T19:17:56.781Z" },
{ url = "https://files.pythonhosted.org/packages/90/a9/7a54b6834088d9ae528a7b780584ba6a39a9457b0ac330479f20ffbc9449/lxml-6.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:6540377fbd53fe1b629172288c464fb18db11ce1fa7dc15891da10aa9dcc3e7f", size = 3659610, upload-time = "2026-05-19T19:22:50.843Z" },
{ url = "https://files.pythonhosted.org/packages/a5/eb/7e6f37c5584ccbb2ff267f56fd0339016938c1c8684cfefab9b33ffc2f36/lxml-6.1.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:68a9198d0fc122d14bb76837de9aa80cf84caed990b5b237f532ed87d3706736", size = 8559780, upload-time = "2026-05-18T19:17:57.661Z" },
{ url = "https://files.pythonhosted.org/packages/a1/36/587c2521cf23a2cd6c9c22108aa7528f683a1f195ed7ccd23a4b1786ad36/lxml-6.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7d47866cb32fb503450b6edc9df355d10dc49836af2e89901bd6ac6b0896d9d9", size = 4618006, upload-time = "2026-05-18T19:18:04.452Z" },
{ url = "https://files.pythonhosted.org/packages/6e/ca/ab7bfe2bf4c972af5e7878262845ead3a24a929a9b04bc11c7c1ece6c82a/lxml-6.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb7c9811bfaa8b1ed5ed319f5d370dfbcaa59d52ea64be2a5a85e18195930354", size = 4924139, upload-time = "2026-05-18T19:19:04.873Z" },
{ url = "https://files.pythonhosted.org/packages/6b/55/a0c72851dfee5ecc689f949723a73dea457758912542cb955b108eaf0d8f/lxml-6.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:762ff394d5bd56da0cf034a23dcce4e13923f15321a2adfa2ac00201dc6d3fca", size = 5082329, upload-time = "2026-05-18T19:19:09.728Z" },
{ url = "https://files.pythonhosted.org/packages/f0/b6/0608f7d61a3b96cc67e5648a3d906e31a5082093e10e7be65b3886289938/lxml-6.1.1-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a088f287f7d8275a33c07f2cac6c50b9319309a0200a39e7e75d80c707723099", size = 4993564, upload-time = "2026-05-18T19:19:13.608Z" },
{ url = "https://files.pythonhosted.org/packages/4c/66/ae227524b066d29d55bf0b453d93d2d793c40218657d643dcbbca13b8faf/lxml-6.1.1-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e902da4b04e6b52e5893900d4b8ab46068f75f3561f01bf1080957f9fd932ed6", size = 5613467, upload-time = "2026-05-18T19:19:16.228Z" },
{ url = "https://files.pythonhosted.org/packages/a6/76/dbe4a00b50385e40194231dcfe5a12c059de7cf90e89c83407d2b085b719/lxml-6.1.1-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1d4962d4c66bf830a7e59ed6cfc17d148149898a3aefa8ec6e59763e6e3ed085", size = 5228304, upload-time = "2026-05-18T19:19:19.354Z" },
{ url = "https://files.pythonhosted.org/packages/1c/01/00b1b8442ed2041793336868ba0b9ea4b13d7da7c085c6404c207a63bf79/lxml-6.1.1-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:581d4c8ae690a6609e64862dd6b7c2489635c2d13907fc2b20f2bc200ff1d21e", size = 5341607, upload-time = "2026-05-18T19:19:22.297Z" },
{ url = "https://files.pythonhosted.org/packages/63/36/1ad29931e9a4638bb707869f01d423a6c815f82152138d1a40dfcfde2b95/lxml-6.1.1-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:876e1ff5930ed8bf295ec5ef9a8155e9b6b1876bbf1deed8b3a8069311875a8f", size = 4700168, upload-time = "2026-05-18T19:19:25.133Z" },
{ url = "https://files.pythonhosted.org/packages/3c/d1/a9536cecf9be18a0dc72d32bead283a2332d1ffebd2dd3ac70ce444686e5/lxml-6.1.1-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9eb9b5a968f6e0f6d640092a567e14529ff8cea2e29d00da6f78a79fa49f013c", size = 5232487, upload-time = "2026-05-18T19:19:28.603Z" },
{ url = "https://files.pythonhosted.org/packages/0e/77/b4fb1e03bf5d130e879214d3100092e386418807fb74dd0adc4b0a48f351/lxml-6.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:aa49e06d94aba782c6a02eecb7e507969e7e7a41b267f1b359bb35585f295d5b", size = 5044231, upload-time = "2026-05-18T19:18:42.246Z" },
{ url = "https://files.pythonhosted.org/packages/26/4c/d00daeeb0a5530c4028a9232aa1b93db3ef4ed2158c116ea73c79a9765b3/lxml-6.1.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:70cdfd80589d59e43e18005dd7244e8895e93db8ab6a620b7e23df5445a4e3d2", size = 4769450, upload-time = "2026-05-18T19:18:48.013Z" },
{ url = "https://files.pythonhosted.org/packages/ed/6a/715a3a8d156ce42f29cf014706f5410c2ff3b02267774110fc23266409fe/lxml-6.1.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:aad9aa39483ed8ec44d6d2e59e5b98a0d80676ef0d92f44bfc374836111f62f5", size = 5635874, upload-time = "2026-05-18T19:18:51.914Z" },
{ url = "https://files.pythonhosted.org/packages/45/37/0544bc21dde2a88f3a17b504e6fc79c0e01d25a33c2f6079724e9e72b9c7/lxml-6.1.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:d49514be2f28d895c38cf9d2b72d7b9a07d00314519f456c0b50b53cfcf4c785", size = 5223987, upload-time = "2026-05-18T19:18:59.715Z" },
{ url = "https://files.pythonhosted.org/packages/4d/f8/f6a5e8185bcb28c2befae3d31f8e3df3b811cb0f47746517a81279fcafe1/lxml-6.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:47402e62c52ff5988c1e8c6c63177f5708bccf48e366dea4e3dcf1e645e04947", size = 5250276, upload-time = "2026-05-18T19:19:03.834Z" },
{ url = "https://files.pythonhosted.org/packages/c7/f2/1a2b9f1b7a49d45495369be7ef9ad05b262930f2eab3e3145706fca8083f/lxml-6.1.1-cp313-cp313-win32.whl", hash = "sha256:3483644525531e1d5762b0c44a8e18b6efba321b6dcf8a8952de10b037618bca", size = 3596903, upload-time = "2026-05-18T19:17:29.863Z" },
{ url = "https://files.pythonhosted.org/packages/e6/99/f4ffb024f238eec2131aaa09f3278fb6129cf892741bf68e1fc1afb8c100/lxml-6.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:a10bd2fd62e8ce916ececb342f348f190724a098c1faa056fdfb2a22ad5e8660", size = 3995869, upload-time = "2026-05-18T19:18:02.596Z" },
{ url = "https://files.pythonhosted.org/packages/d1/53/70eb8c5c6037f27448f1e3c54ebede9545a801ae63f0a7254afca4fe8e45/lxml-6.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:424aa57aca0897eb922aef34395bd1289b3b6f04e6bae20ea123c0c7e333cffc", size = 3658490, upload-time = "2026-05-19T19:22:53.846Z" },
{ url = "https://files.pythonhosted.org/packages/13/e2/2e325795566de01d0d7c3bb57d3c370616b2d07b01214e84eec5d3b10963/lxml-6.1.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:19b7ab10b210b0b3ad7985d9ac4eb66ab09a90b20fe6e2f7ba55d01a234345d0", size = 8577146, upload-time = "2026-05-18T19:18:17.765Z" },
{ url = "https://files.pythonhosted.org/packages/93/cf/5630b5e4be7d2e6bee8efe83865c925221103cf0221303b104ce134b01e2/lxml-6.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c08e5c694306507275f2290073350c4f32e383db15213b2c69e7ff39c1193840", size = 4623866, upload-time = "2026-05-18T19:18:30.669Z" },
{ url = "https://files.pythonhosted.org/packages/d2/51/3904907c063451cf8d4a5c9fe0cad95fa1f4ec57f4e3884fa0731bd7a305/lxml-6.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:74a9717fd0d82effef5c2854f0d917231d5324b5a3eb7275c43ac9fa32f97a14", size = 4950022, upload-time = "2026-05-18T19:19:31.958Z" },
{ url = "https://files.pythonhosted.org/packages/94/cd/9c7611a51c37a2830928405817cc5d56a97f64fab83cc3f628748b135749/lxml-6.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:efe0374196335f93b53269acd811b944f2e6bdc88e8894f214bd636455484909", size = 5086695, upload-time = "2026-05-18T19:19:34.764Z" },
{ url = "https://files.pythonhosted.org/packages/da/d6/24e3b5906abb0b674ff2ae195bc3ce59708df2bcd17cf17703b2d7dd643a/lxml-6.1.1-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac931cdc9442c1763b8a8f6cd62c0c938737eafc5be75eff88df55fc73bc0d00", size = 5031642, upload-time = "2026-05-18T19:19:37.771Z" },
{ url = "https://files.pythonhosted.org/packages/2d/db/6ec54f99019838bff54785c51da07f189eb4676861c5f2730962b0d8d665/lxml-6.1.1-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:aee395f5d0927f947758b4ec119fd5fc8ec71f07a1c5c52077b30b04c0fa6955", size = 5647338, upload-time = "2026-05-18T19:19:40.553Z" },
{ url = "https://files.pythonhosted.org/packages/42/3d/ef4dcfffd22d27a61805d8ed9f7fb888495bc6aa88648fa07c1eaa5586b6/lxml-6.1.1-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9395002973c827b3ed67db77e6ec09f092919a587022174554096a269378fb13", size = 5239528, upload-time = "2026-05-18T19:19:43.657Z" },
{ url = "https://files.pythonhosted.org/packages/62/bb/37fb3f0dff146bdcfa78eec47879273820b2a0bf350ec236ce14bd0b1c26/lxml-6.1.1-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:73bc2086f141224ebddb7fc5c6a36ca58b31b94b561e1dfe8e073e3270fad1e7", size = 5350730, upload-time = "2026-05-18T19:19:46.307Z" },
{ url = "https://files.pythonhosted.org/packages/90/42/43253f168388df4fae1f38c01df36ddb9bee39e2048167b54cdcbae85ea3/lxml-6.1.1-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:3779def59032b81e44a5f70096ef6bf2082f8d901937dca354474ba09782e245", size = 4697530, upload-time = "2026-05-18T19:19:49.889Z" },
{ url = "https://files.pythonhosted.org/packages/eb/a8/c5a8504f81bbdfc8e7094c2c850cdb4ed6777fc4d5ddd9e5ab819f3b0d54/lxml-6.1.1-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:86c89b9d55ebf820ad7c90bc533410f0d098054f293351f10603c0c46ff598f5", size = 5250670, upload-time = "2026-05-18T19:19:53.199Z" },
{ url = "https://files.pythonhosted.org/packages/77/b7/c7e76ab18744d75e21f320ebf9ff9d1ceae2b54dd431ea5a64caf26c9672/lxml-6.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19607c6bbff2a44cf3fe8250abccd20942d3462473e0a721d01d379ed017e462", size = 5084485, upload-time = "2026-05-18T19:19:08.422Z" },
{ url = "https://files.pythonhosted.org/packages/31/31/b35c53f8ef7b7c31cacd23d3638652fff7bcd1deb6eedb709ab43b685908/lxml-6.1.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:c6ed5141a5c7507cf3ee76bd363b0d6f801e3321adc35b5d825a23115faa5465", size = 4737635, upload-time = "2026-05-18T19:19:12.321Z" },
{ url = "https://files.pythonhosted.org/packages/d9/06/31f23c813a7fe8e0cb1b175e915b08c9bf4e86d225b210feadbdbe519667/lxml-6.1.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:62aeb7e85b5d60320b9d77eef2e773994e2c0ce10121b277e0a19804e1654a5a", size = 5670681, upload-time = "2026-05-18T19:19:15.001Z" },
{ url = "https://files.pythonhosted.org/packages/1a/bc/ce619bccc89b1fd9ad8a8e1330ee3f3beff9f2ff95b712d7bbcdd6e22fc3/lxml-6.1.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:b1b963fd8f5caa68e99dfae060d54de1fe9cba899b8718b44a00cdca53c3e590", size = 5238229, upload-time = "2026-05-18T19:19:18.131Z" },
{ url = "https://files.pythonhosted.org/packages/2f/5d/b329acbbedc0b619ebc2be6cf7ee9ed07e80892c88d4dfd612c33805789a/lxml-6.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:63876be28efefa04a1df615b46770e82042cce445cfdce55160522f57b231ccb", size = 5264191, upload-time = "2026-05-18T19:19:21.118Z" },
{ url = "https://files.pythonhosted.org/packages/d6/85/be36fb1425b30db3c3f9df75fe86343ebffb79e6320bd7f588e25bfeac39/lxml-6.1.1-cp314-cp314-win32.whl", hash = "sha256:7f7a92e8583f06b1fd49d01158143b8461cfcd135dcb10ec807270a3051bd603", size = 3657202, upload-time = "2026-05-18T19:17:39.509Z" },
{ url = "https://files.pythonhosted.org/packages/b8/ce/3cf9a827342269f54d405a6202397de63f07c69cbd6ce7d183a3f0cba1e9/lxml-6.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:b2d444f2e66624d68e9c6b211e28a76e22fff5fcabcfff4deac18b529b7d4137", size = 4064497, upload-time = "2026-05-18T19:18:14.662Z" },
{ url = "https://files.pythonhosted.org/packages/d9/3e/1a957bde8f0760039e627f94699f82caa782c9d838d86c3d28245ee67212/lxml-6.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:3fd9728a2735fda14f4e8235830c86b539e9661e849665bf926d3f867943b4bf", size = 3741991, upload-time = "2026-05-19T19:22:59.111Z" },
{ url = "https://files.pythonhosted.org/packages/78/b2/00ed55b3a2efa4658fb795c38d1090ec9b3e8a6c3683d4441fa517f09c3b/lxml-6.1.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:787b2496d0dbe8cd180984e8d29e3a6f76e7ea34db781cb3bd55e4ba1ef8b4ee", size = 8827545, upload-time = "2026-05-18T19:18:41.193Z" },
{ url = "https://files.pythonhosted.org/packages/c0/73/74573db19baa618d5f266f2407898b087ff6927115b00b71e5fc1b700847/lxml-6.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2c8daa471358dc2d6fcf02165e80ec68f77871a286df95bc5cc3816153b0fd2c", size = 4735736, upload-time = "2026-05-18T19:18:46.761Z" },
{ url = "https://files.pythonhosted.org/packages/16/02/6f7061f4f95f51e545d48e87647c54791d204a4e881be4156e7a26ba5338/lxml-6.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:acd7d70b64c0aae0c7922cca83d288a16f5f6da523637697872253415269baef", size = 4970291, upload-time = "2026-05-18T19:19:56.215Z" },
{ url = "https://files.pythonhosted.org/packages/b0/02/55fc057d8283427dea7d6edb102e7a840239c77a64a983d92f62a304c0e9/lxml-6.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4f0dd2f01f9f8a89f565d000e03abcf0a13d692a346c8d22f628d49af098777a", size = 5102822, upload-time = "2026-05-18T19:19:59.223Z" },
{ url = "https://files.pythonhosted.org/packages/e4/48/8e1cf78d89d66850121d9255a2a24414c98f775da93b90cf976956c24b14/lxml-6.1.1-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b7e8a14c8634bf6f7a568634cb395305a6d964aeb5b7ee32248094bed3a7e2c", size = 5027923, upload-time = "2026-05-18T19:20:01.549Z" },
{ url = "https://files.pythonhosted.org/packages/ed/00/0632a0647612c8af24d26997b3b961397daa9d5b2581444805933629a4cb/lxml-6.1.1-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:86281fbdd6a8162756f8d603f37e3435bfa38043adb79c6dc6a2dfee065e7525", size = 5595843, upload-time = "2026-05-18T19:20:03.93Z" },
{ url = "https://files.pythonhosted.org/packages/bc/86/ab008a7dc360711b66858d61c80a5979a70a09f2aa2b05d9698df80b803d/lxml-6.1.1-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5d7152ec39ca7c402d8fb9bad86140a15b9503bd0c54484e3f1bbe3dd37ceca", size = 5224515, upload-time = "2026-05-18T19:20:06.381Z" },
{ url = "https://files.pythonhosted.org/packages/75/c6/2702ff375e728e34f56d9a45339a9cf7e4427e917f542225242d63a05afa/lxml-6.1.1-cp314-cp314t-manylinux_2_28_i686.whl", hash = "sha256:88d8cb75b9d82858497a5393e3c63cfbf03035225e4b35a49ed7ccb151e4dc0e", size = 5312511, upload-time = "2026-05-18T19:20:09.308Z" },
{ url = "https://files.pythonhosted.org/packages/b7/57/a5807c98f87a86f10ef9ffab35516df7c0f0c4b6d5d33e9f608ab9c04a31/lxml-6.1.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f64ec5397ea6a41fc1b4af0380d79b44a755b5531dcaccd9940fb260dca93038", size = 4639206, upload-time = "2026-05-18T19:20:11.704Z" },
{ url = "https://files.pythonhosted.org/packages/1f/e1/8a0a2c35734812395f4da4eaf33748a7e5705bfb2a58b128da764339d5ec/lxml-6.1.1-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d34bbf07dbc7ca5970671b1512e928991fb5e9d95365636c9b2d8b4f53af405e", size = 5232404, upload-time = "2026-05-18T19:20:14.064Z" },
{ url = "https://files.pythonhosted.org/packages/c2/e2/0e6a4dd5ad84d01d99aa7bae7cfefd4a760a0e0f8176818241de17d9b6c0/lxml-6.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:17e0e18d4ad8adbd0399291bc44845b69d9dd68439a3cdebdf35ff902ec05072", size = 5083769, upload-time = "2026-05-18T19:19:23.758Z" },
{ url = "https://files.pythonhosted.org/packages/a0/7e/161f33d463f6ffc1c7679104b65086dea120080d49dde4d238f015aaee2f/lxml-6.1.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:3ab541146f1f6968c462d6c2ac495148e8cdba2f8347700b2141b6ec5a75bf52", size = 4758936, upload-time = "2026-05-18T19:19:27.256Z" },
{ url = "https://files.pythonhosted.org/packages/f1/fb/2369825e3f6ca99305bf9f7b7085fda91c8b0922a89e54d900974aa3ef85/lxml-6.1.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2a0217714657e023ef4293500f65aa20fce6164c8fd6b08fa5bd4a859fb14b9b", size = 5620296, upload-time = "2026-05-18T19:19:29.993Z" },
{ url = "https://files.pythonhosted.org/packages/30/90/d61e383146f74c5ab683947ea14dc7b82778838ab9b95ea73a23b60d0191/lxml-6.1.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:05a82eb6e1530a64f26225b55cbd178113bd0b5af1c2b625f25e5296742c26d2", size = 5228598, upload-time = "2026-05-18T19:19:33.523Z" },
{ url = "https://files.pythonhosted.org/packages/76/2d/2dafd8149e94b05bb070690efd5bb2680720681e03ff03fc57d2b70a1105/lxml-6.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9e36f163528fc50cbef305f02a5fd66d404edf7049cdaff211dbc2cba5a7013e", size = 5247845, upload-time = "2026-05-18T19:19:36.649Z" },
{ url = "https://files.pythonhosted.org/packages/ce/68/b30e913340c380ddac9580c6e6230991fc37240ec4f64704833e4f3e2769/lxml-6.1.1-cp314-cp314t-win32.whl", hash = "sha256:649dda677cf3bd6ac9ae14007ba0c824ded8ce5808b53fc7431d9140399118c1", size = 3897345, upload-time = "2026-05-18T19:17:33.562Z" },
{ url = "https://files.pythonhosted.org/packages/3c/4e/9eb2af5335545f9fbcd7af57bcf87c6025d31eaa31b14ec184a6c8675328/lxml-6.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:793033d6c5cdf33a573f910d9bea14ef8f5771820411d118da8e1182edb53d5e", size = 4393350, upload-time = "2026-05-18T19:18:10.076Z" },
{ url = "https://files.pythonhosted.org/packages/7f/2c/0f1e93c636720e8a3eb59af2bfda99d98b55891e1c53bc30c2e0e865f01b/lxml-6.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:58bb955caba94e467d2a96da17660d2d704e0675894cba21ab8a775b8621fd1c", size = 3817223, upload-time = "2026-05-19T19:22:56.823Z" },
]
[[package]] [[package]]
name = "numpy" name = "numpy"
version = "2.5.1" version = "2.5.1"
@@ -380,6 +534,38 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a1/5a/4d2b1601df3602dba7a14f3348ba9bfe94a18adb428e693df6154c293831/numpy-2.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:5a6db61f9aaa57e369905c67d852045d3c4f7126405b29d09b19dec118e9c9cb", size = 10697674, upload-time = "2026-07-04T17:07:58.506Z" }, { url = "https://files.pythonhosted.org/packages/a1/5a/4d2b1601df3602dba7a14f3348ba9bfe94a18adb428e693df6154c293831/numpy-2.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:5a6db61f9aaa57e369905c67d852045d3c4f7126405b29d09b19dec118e9c9cb", size = 10697674, upload-time = "2026-07-04T17:07:58.506Z" },
] ]
[[package]]
name = "onnxruntime"
version = "1.28.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "flatbuffers" },
{ name = "numpy" },
{ name = "packaging" },
{ name = "protobuf" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/98/f8/dcbe7700dca82fa540035abd3c868fe5ad0f86af00b9a3db7c2e27d15c7d/onnxruntime-1.28.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:26ff0fdd06efb6c155bae95387a09db1a2be89c7a03e4d0bffd5a171cc2826da", size = 19141362, upload-time = "2026-07-25T01:22:36.965Z" },
{ url = "https://files.pythonhosted.org/packages/28/5b/1d77e62097fdbe07e2dc827f389b1c4c0c275f6fab0369a8f46d2461af27/onnxruntime-1.28.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e81a23df16e7acb9d51b06d30cc098e49315ef9180f97bc2221d167b4b04d9c", size = 17050628, upload-time = "2026-07-25T01:21:40.481Z" },
{ url = "https://files.pythonhosted.org/packages/95/df/5486ab03e9be288d5268867054c8b04bebcf95bfd12e801c05cc67703dab/onnxruntime-1.28.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0a83bdb70d143cede762b677789bf2a7acca54b3fb82565601d5c30695aa933c", size = 19214257, upload-time = "2026-07-25T01:22:01.695Z" },
{ url = "https://files.pythonhosted.org/packages/3e/3b/986ca67c274932ba9ac5332fb10de56f643dfd433c74e33f8ae8f847cf24/onnxruntime-1.28.0-cp312-cp312-win_amd64.whl", hash = "sha256:c35064f9b3c43c81c5d5d282091401d0f1ff22796d93ccade4ea2ece5e137ab8", size = 13755036, upload-time = "2026-07-25T01:22:26.89Z" },
{ url = "https://files.pythonhosted.org/packages/1d/46/059dba81d46c6ba88e0c2d1c64321ac8098847d678423300a183d42ecbd6/onnxruntime-1.28.0-cp312-cp312-win_arm64.whl", hash = "sha256:e02feeb0165c5f13b4cc954738078d59b90128516ac12b671ee24a530242bf02", size = 13454462, upload-time = "2026-07-25T01:22:17.38Z" },
{ url = "https://files.pythonhosted.org/packages/9c/12/3807e2b17d9eb71d3cb78ed2ba76869b05c637c9b9d6112e636098b0c97a/onnxruntime-1.28.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:31410f544674f534c2f27348af52ef81682ca9c8719154bf4d48f0ef23823b1e", size = 19141759, upload-time = "2026-07-25T01:21:53.765Z" },
{ url = "https://files.pythonhosted.org/packages/c0/23/b46045c3bf67a9cf54c12f5df0f018a422c65fbb9d6072b10071bebfaae2/onnxruntime-1.28.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f649dd6f6452d12a8059888aa489fe519e062e18793dac72b9efa0f9fdb64135", size = 17049339, upload-time = "2026-07-25T01:21:43.005Z" },
{ url = "https://files.pythonhosted.org/packages/78/b6/8c5396e7894e77c5a7d1e026f3acb9dd39c4b5644e412e37a0055eaa3bc5/onnxruntime-1.28.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:54fa221d669282bd8f582708ce4c96010a7e9fb0661f9006b37fe2fedafb73fe", size = 19214329, upload-time = "2026-07-25T01:22:04.133Z" },
{ url = "https://files.pythonhosted.org/packages/56/f1/51225c202edba4dfc94e1ea03f3d78f1aaf307da75fd792c0ce1946b2514/onnxruntime-1.28.0-cp313-cp313-win_amd64.whl", hash = "sha256:1a1a19175464665c9b8d50bc916f216cc0b569110045b7bbca8f9f290b186f58", size = 13755033, upload-time = "2026-07-25T01:22:29.302Z" },
{ url = "https://files.pythonhosted.org/packages/f4/db/f59f715edfdd96a051f32b5ef0e680a20a8755d4ecd75f63090e960e347a/onnxruntime-1.28.0-cp313-cp313-win_arm64.whl", hash = "sha256:cfab507abe09d6ffeb817eee07944d452fdc0b00fdcef34cab4db10a45e378c7", size = 13454175, upload-time = "2026-07-25T01:22:19.912Z" },
{ url = "https://files.pythonhosted.org/packages/47/28/810314fa88647af9f4cdaf438a30ad1cfebebb53ded55499232d7a0094e6/onnxruntime-1.28.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac301f53b1930402fc46c368e268acfed02f3207272aaff05070d7e09f96f031", size = 17057307, upload-time = "2026-07-25T01:21:45.492Z" },
{ url = "https://files.pythonhosted.org/packages/3d/cc/9e9f193cc0f29f263a8f09ec08487aed6c96ee856d5fd77da32a425c1949/onnxruntime-1.28.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7f022a1103cae591c75fc4565589a515f2ddd14a6ac8e8a05812dfeda142e28", size = 19222954, upload-time = "2026-07-25T01:22:06.952Z" },
{ url = "https://files.pythonhosted.org/packages/4e/eb/952314c451d9463e5c9aed9978eec76cf32930d407d9ab8700dd0f4ea1ea/onnxruntime-1.28.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:8adff67a3f28257b37cfe945a7e952e4122666aa8c91a0380862e9fd4c2ed19f", size = 19143748, upload-time = "2026-07-25T01:21:56.297Z" },
{ url = "https://files.pythonhosted.org/packages/3f/e9/139180b4dd810329aaa42c238b4e6383c906202d98609ae29d66eb7c32b1/onnxruntime-1.28.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bc2565e487b4896fb988d6383577d875d958e071fc5f6c3550bd5d02ae98264b", size = 17051950, upload-time = "2026-07-25T01:21:48.606Z" },
{ url = "https://files.pythonhosted.org/packages/03/88/9432428273356ad3c8aa01f52c1b3e7f53c4c0192748f41ad983872b436b/onnxruntime-1.28.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6afdc83f1317c136e92fc29f5ee9f058de59d87c0b22cee3fdbfbaa0ccc2098a", size = 19214924, upload-time = "2026-07-25T01:22:09.727Z" },
{ url = "https://files.pythonhosted.org/packages/bb/e2/6feb3a43517aaf2b1bf7e46897ba5eb81a29717f7d7901420614d5ee4653/onnxruntime-1.28.0-cp314-cp314-win_amd64.whl", hash = "sha256:f2a3b9e30ce880d4ca54999cb313569e36da4f62eefe25f87be18f43e9a3a4d5", size = 14093738, upload-time = "2026-07-25T01:22:31.629Z" },
{ url = "https://files.pythonhosted.org/packages/fc/8f/83974a1e201dc2e58e5e7111bcaeb1ca2413e9c41f505d26419ee9e3dddf/onnxruntime-1.28.0-cp314-cp314-win_arm64.whl", hash = "sha256:07fb3cbe990d6bf0ab3c22bfbbfb0e314151266046ea6edb4a07f556b4258c5f", size = 13821117, upload-time = "2026-07-25T01:22:22.387Z" },
{ url = "https://files.pythonhosted.org/packages/0d/83/00e606bc25c756d76a267370c39b7516ad52f9cf134d7ff2bff8b6108bc4/onnxruntime-1.28.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e562d6e36a749f6764481c0ddb0f2af3d0b5a3c164291361d08803c557f369af", size = 17055518, upload-time = "2026-07-25T01:21:51.08Z" },
{ url = "https://files.pythonhosted.org/packages/94/a9/68707e1ce345cbdbcd4df65932ebc82a673e917d63eda0007ebcff948691/onnxruntime-1.28.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f6e92367ddce1e4d33cf295024f40192be6c6171a09208f515ba169ced06c8e", size = 19222976, upload-time = "2026-07-25T01:22:12.474Z" },
]
[[package]] [[package]]
name = "openai" name = "openai"
version = "2.49.0" version = "2.49.0"
@@ -399,6 +585,25 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b1/ca/53357e460a1172e831ecbe43dd0c37342b7211a1eb09f4cf21a412adbbdf/openai-2.49.0-py3-none-any.whl", hash = "sha256:b694201eaa42a1ccf2aa125fe29458150108fb22df1abfb55d7188599da81d8c", size = 1648589, upload-time = "2026-07-27T22:51:38Z" }, { url = "https://files.pythonhosted.org/packages/b1/ca/53357e460a1172e831ecbe43dd0c37342b7211a1eb09f4cf21a412adbbdf/openai-2.49.0-py3-none-any.whl", hash = "sha256:b694201eaa42a1ccf2aa125fe29458150108fb22df1abfb55d7188599da81d8c", size = 1648589, upload-time = "2026-07-27T22:51:38Z" },
] ]
[[package]]
name = "opencv-python"
version = "5.0.0.93"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "numpy" },
]
sdist = { url = "https://files.pythonhosted.org/packages/79/4c/a438d23e09ce2033c09f7b784ad2fbdb0adf529e434101ed28f142226f98/opencv_python-5.0.0.93.tar.gz", hash = "sha256:66aac3e5b5faa48d4025816592f3af19e4bfc2c68dec067bae2dbb4ca10aa9e2", size = 81802749, upload-time = "2026-07-02T06:59:53.815Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9c/75/76f6ade78f6102c61034f828e2a22616708df2c9504bc8d6af9dd8f73dc5/opencv_python-5.0.0.93-cp37-abi3-macosx_13_0_arm64.whl", hash = "sha256:198a75138241810206a17c829dbcc40a7cb1841cda538ca86cbbfc6c7d95f898", size = 48322443, upload-time = "2026-07-02T05:50:25.466Z" },
{ url = "https://files.pythonhosted.org/packages/15/8c/bc1bda6aae69a32e9d84fc34153ba104cd25226861eb4aea33b2cea4860d/opencv_python-5.0.0.93-cp37-abi3-macosx_14_0_x86_64.whl", hash = "sha256:6bbc32f59e1b1a7db7b39c81f63d00625f041d333037fd8702f6da52cc39108b", size = 34782755, upload-time = "2026-07-02T05:51:30.556Z" },
{ url = "https://files.pythonhosted.org/packages/f4/8a/b04776ec45d2dea08a1b176f1829201db3515d4ed16c35f8fcc9fa7beb16/opencv_python-5.0.0.93-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e2b4272e736836f66c2d176e43ab8101f3a00d45654916399f52e150c58981ac", size = 50614064, upload-time = "2026-07-02T06:53:22.604Z" },
{ url = "https://files.pythonhosted.org/packages/95/54/eb47866b94f2b5b42dde17644b78055ef1ee05aae59962c7290e55270803/opencv_python-5.0.0.93-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f8b6d0a212253dd26ad338c812f1f23ca118fdf05a9c8c6b9444f161aa8c5881", size = 71064711, upload-time = "2026-07-02T06:54:13.148Z" },
{ url = "https://files.pythonhosted.org/packages/93/da/962579f1e703cbf8c5422fd1f576467dcb3b5b0b0b81c1471c979764353a/opencv_python-5.0.0.93-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:08d5d91d967b58d6db86073b2ad3eaef88ca4ebdfd45c9059bf59f5ded0c7ad2", size = 49798576, upload-time = "2026-07-02T06:54:33.781Z" },
{ url = "https://files.pythonhosted.org/packages/cf/4c/c73f828fdbcd37eaf21d08fa852544a3ca7c2dbb3ea76873d64f2ea413d1/opencv_python-5.0.0.93-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:c8de2dec111122a02e8beb28e16c31904992dfd6186560b142a92c71403c1039", size = 73783032, upload-time = "2026-07-02T06:55:03.415Z" },
{ url = "https://files.pythonhosted.org/packages/e2/4b/edaf83b996ca5a1a3d8ccad485706b9c6d4742b13b9c4586bf1c1e7d9423/opencv_python-5.0.0.93-cp37-abi3-win32.whl", hash = "sha256:4b4b1a34c79bf8d3738e3cfe9a9e67b51a79663f6b692cbdad8c31f570da4157", size = 35564734, upload-time = "2026-07-02T05:49:57.704Z" },
{ url = "https://files.pythonhosted.org/packages/21/f0/9fa6e85cb10c8eb36a0222d27e50fe381b86ce49a55446bf39f491727564/opencv_python-5.0.0.93-cp37-abi3-win_amd64.whl", hash = "sha256:f90ba04b8f73bc5c3814037699739f0156f597338a98f05956c684e7c3ca10d2", size = 44000345, upload-time = "2026-07-02T05:49:54.971Z" },
]
[[package]] [[package]]
name = "packaging" name = "packaging"
version = "26.2" version = "26.2"
@@ -408,6 +613,77 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
] ]
[[package]]
name = "pillow"
version = "12.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/37/bf/fb3ebff8ddcb76aac5a01389251bbbb9519922a9b520d8247c1ca864a25d/pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965", size = 5345969, upload-time = "2026-07-01T11:54:06.397Z" },
{ url = "https://files.pythonhosted.org/packages/d8/66/9a386a92561f402389a4fc70c18838bf6d35eb5eb5c6850b4b2dc64f5048/pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7", size = 4780323, upload-time = "2026-07-01T11:54:09.351Z" },
{ url = "https://files.pythonhosted.org/packages/25/27/ac8f99618ffd3dde21db0f4d4b1d2ab00c0880595bfd17df103f7f39fd0c/pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9", size = 6266838, upload-time = "2026-07-01T11:54:11.71Z" },
{ url = "https://files.pythonhosted.org/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91", size = 6940830, upload-time = "2026-07-01T11:54:13.732Z" },
{ url = "https://files.pythonhosted.org/packages/eb/51/8b08617af3ad95e33ce6d7dd2c99ed6c8298f7fb131636303956be022e25/pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c", size = 6344383, upload-time = "2026-07-01T11:54:15.756Z" },
{ url = "https://files.pythonhosted.org/packages/1d/72/cf78ac9780bb93c28328f408973845a309d4d145041665f734572ced1b52/pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df", size = 7052934, upload-time = "2026-07-01T11:54:17.721Z" },
{ url = "https://files.pythonhosted.org/packages/20/20/25e0f4dc178a6bc0696793720055519a0de89e7661dae886992decbd2f81/pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f", size = 6472684, upload-time = "2026-07-01T11:54:19.839Z" },
{ url = "https://files.pythonhosted.org/packages/45/89/da2f7971a317f83d807fdd4065c0af40208e59e692cc43d315a71a0e96d1/pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09", size = 7227137, upload-time = "2026-07-01T11:54:22.025Z" },
{ url = "https://files.pythonhosted.org/packages/de/47/4845a0a6c0dbf1db8456bd9fc791f13c5ced7ced20606d08a0aacfd25b49/pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510", size = 2568267, upload-time = "2026-07-01T11:54:24.051Z" },
{ url = "https://files.pythonhosted.org/packages/9d/ac/31fb64e1e7efb5a4b50cd3d92049ba89ac6e4d8d3bb6a74e15048ca3353e/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89", size = 4161684, upload-time = "2026-07-01T11:54:25.934Z" },
{ url = "https://files.pythonhosted.org/packages/87/b4/9805e23d2b4d77842b468513841fda254ee42f0289d25088340e4ff46e2d/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace", size = 4255487, upload-time = "2026-07-01T11:54:27.935Z" },
{ url = "https://files.pythonhosted.org/packages/df/39/ecf519435a200c693fe053a6ee4d835b41cf963a4dfc2551c4e637cb2a71/pillow-12.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec", size = 3696433, upload-time = "2026-07-01T11:54:29.813Z" },
{ url = "https://files.pythonhosted.org/packages/42/92/2fc3ffad878ae8dd5469ec1bc8eb83b71f48e13efdf68f02709003982a32/pillow-12.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66", size = 5345889, upload-time = "2026-07-01T11:54:31.97Z" },
{ url = "https://files.pythonhosted.org/packages/10/76/8803c13605b763d33d156c4678fc77f8443389c0c51c8aef707bb02015f4/pillow-12.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35", size = 4780109, upload-time = "2026-07-01T11:54:34.026Z" },
{ url = "https://files.pythonhosted.org/packages/1f/01/e18aff37cb0b4aac47ac90f016d347a49aca667ef97f190b06ac2aabc928/pillow-12.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65", size = 6263736, upload-time = "2026-07-01T11:54:36.131Z" },
{ url = "https://files.pythonhosted.org/packages/f7/62/de5bdd77d935331f4f802edc11e4d82950f642caad6cb2f949837b8560e2/pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3", size = 6937129, upload-time = "2026-07-01T11:54:38.216Z" },
{ url = "https://files.pythonhosted.org/packages/70/4d/105627a13300c5e0df1d174230b32fd1273062c96f7745fd552b945d1e1d/pillow-12.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a", size = 6339562, upload-time = "2026-07-01T11:54:40.354Z" },
{ url = "https://files.pythonhosted.org/packages/6b/1d/f13de01a553988ab895ba1c722e06cf3144d4f57656fd5b81b6d881f1179/pillow-12.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e", size = 7049439, upload-time = "2026-07-01T11:54:42.489Z" },
{ url = "https://files.pythonhosted.org/packages/c9/f9/066794cca041b969964f779ee5fa66a9498bbf34248ac39c5d7954e4198f/pillow-12.3.0-cp313-cp313-win32.whl", hash = "sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f", size = 6473287, upload-time = "2026-07-01T11:54:44.9Z" },
{ url = "https://files.pythonhosted.org/packages/a6/9b/7a58e61d62be561da3a356fe2384d4059a6345fc130e23ef1c36a5b81d24/pillow-12.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8", size = 7239691, upload-time = "2026-07-01T11:54:47.141Z" },
{ url = "https://files.pythonhosted.org/packages/aa/b0/c4ed4f0ef8f8fa5ee8351537db6650bb8189f7e118842978dd6589065692/pillow-12.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b", size = 2568185, upload-time = "2026-07-01T11:54:49.137Z" },
{ url = "https://files.pythonhosted.org/packages/dc/01/001f65b68192f0228cc1dbbc8d2530ab5d58b61037ba0587f946fea607cd/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330", size = 4161736, upload-time = "2026-07-01T11:54:51.156Z" },
{ url = "https://files.pythonhosted.org/packages/1a/d2/0219746d0fd16fc8a84498e79452375be3797d3ce4044596ce565164b84f/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217", size = 4255435, upload-time = "2026-07-01T11:54:53.414Z" },
{ url = "https://files.pythonhosted.org/packages/c8/02/8d0bc62ef0302318c46ff2a512822d2610e81c7aa46c9b3abe6cbaca5ad0/pillow-12.3.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930", size = 3696262, upload-time = "2026-07-01T11:54:55.739Z" },
{ url = "https://files.pythonhosted.org/packages/85/e2/73c77d218410b14f5f2d565e8a998d5317b7b9c75368d29985139f7a46f0/pillow-12.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8", size = 5350344, upload-time = "2026-07-01T11:54:57.657Z" },
{ url = "https://files.pythonhosted.org/packages/c7/da/32c752228ae345f489e3a42499d817b6c3996da7e8a3bc7a04fc806b243b/pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0", size = 4780131, upload-time = "2026-07-01T11:54:59.713Z" },
{ url = "https://files.pythonhosted.org/packages/b1/9d/8b2c807dbef61a5197c047afe99823787eb66f63daf9fb2432f91d6f0462/pillow-12.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321", size = 6263757, upload-time = "2026-07-01T11:55:01.778Z" },
{ url = "https://files.pythonhosted.org/packages/5c/44/c85361f65dbe00eea8576ee467c768d25129989efb76e94f205e9ca9bb46/pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b", size = 6936962, upload-time = "2026-07-01T11:55:03.93Z" },
{ url = "https://files.pythonhosted.org/packages/18/7e/e483414b35800b86b6f08dbbc7803fb5cd52c4d6f897f47d53ea2c7e6f65/pillow-12.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198", size = 6339171, upload-time = "2026-07-01T11:55:05.989Z" },
{ url = "https://files.pythonhosted.org/packages/f0/f4/68c491844841ede6bed70189546b3ee9731cf9f2cbad396faff5e1ccba45/pillow-12.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130", size = 7048116, upload-time = "2026-07-01T11:55:08.131Z" },
{ url = "https://files.pythonhosted.org/packages/a3/34/77f3f793fed8efc7d243f21b33c5a3f0d1c97ee70346d3db855587e155ff/pillow-12.3.0-cp314-cp314-win32.whl", hash = "sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a", size = 6467209, upload-time = "2026-07-01T11:55:10.408Z" },
{ url = "https://files.pythonhosted.org/packages/f1/e0/492879f69d94f91f60fc8cd05ba03650e9520afebb2fb7aa12777d7c7f38/pillow-12.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d", size = 7237707, upload-time = "2026-07-01T11:55:12.745Z" },
{ url = "https://files.pythonhosted.org/packages/c9/ac/6b11f2875f1c2ac040d84e1bbf9cf22a88038f901ca1037898b280b38365/pillow-12.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838", size = 2565995, upload-time = "2026-07-01T11:55:14.736Z" },
{ url = "https://files.pythonhosted.org/packages/52/69/c2208e56af9bfc1913afb24020297a691eb1d4ef688474c8a04913f65e04/pillow-12.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e", size = 5352503, upload-time = "2026-07-01T11:55:17.076Z" },
{ url = "https://files.pythonhosted.org/packages/07/70/e5686d753e898a45d778ff1718dba8516ead6ab6b95d85fc8c4b70650cf2/pillow-12.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17", size = 4782956, upload-time = "2026-07-01T11:55:19.448Z" },
{ url = "https://files.pythonhosted.org/packages/d5/37/25c6692f06927ee973ff18c8d9ee98ad0b4d84ee67a09610c2dd1447958e/pillow-12.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385", size = 6322855, upload-time = "2026-07-01T11:55:21.613Z" },
{ url = "https://files.pythonhosted.org/packages/cc/91/420637fcb8f1bc11029e403b4538e6694744428d8246118e45719f944556/pillow-12.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c", size = 6989642, upload-time = "2026-07-01T11:55:24.006Z" },
{ url = "https://files.pythonhosted.org/packages/10/08/b94d7811281ccf0d143a1cf768d1c49e1e54af63e7b708ab2ee3eb87face/pillow-12.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d", size = 6391281, upload-time = "2026-07-01T11:55:26.252Z" },
{ url = "https://files.pythonhosted.org/packages/d2/87/24233f785f55474dc02ce3e739c5528a77e3a862e9333d1dd7a25cc31f70/pillow-12.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931", size = 7096716, upload-time = "2026-07-01T11:55:28.318Z" },
{ url = "https://files.pythonhosted.org/packages/23/26/fcb2f6e37175b04f53570b59937867e2b80ee1685e744023153028fc14f9/pillow-12.3.0-cp314-cp314t-win32.whl", hash = "sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7", size = 6474125, upload-time = "2026-07-01T11:55:30.956Z" },
{ url = "https://files.pythonhosted.org/packages/90/de/3634abee5f1c9e13c56787b7d5517b0ba8d6de51700b95578cf338349c9f/pillow-12.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c", size = 7242939, upload-time = "2026-07-01T11:55:34.044Z" },
{ url = "https://files.pythonhosted.org/packages/ce/2a/fd13f8eb24de5714a6eb444a3d67e2842c6c576e159a43793adf23051351/pillow-12.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45", size = 2567506, upload-time = "2026-07-01T11:55:35.988Z" },
{ url = "https://files.pythonhosted.org/packages/5d/dc/8fdce34ec725a33c81c6ba122b904d6b9024e50ea9ac7bede62fab54506c/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139", size = 4162063, upload-time = "2026-07-01T11:55:37.941Z" },
{ url = "https://files.pythonhosted.org/packages/76/66/2044b9a63d3b84ff048228dfcb7cd9bf0df983e8470971bf7d4c57b693de/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402", size = 4255549, upload-time = "2026-07-01T11:55:40.022Z" },
{ url = "https://files.pythonhosted.org/packages/52/7e/1f67e6f4ece6b582ee4b539decbcc9f848dc245a93ed8cd7338bafef72f1/pillow-12.3.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c", size = 3696331, upload-time = "2026-07-01T11:55:41.98Z" },
{ url = "https://files.pythonhosted.org/packages/12/40/d306fc2c8e4d45d7f175c77edca7063be7b86fe7fe6e68f4353bf71d808c/pillow-12.3.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f", size = 5350370, upload-time = "2026-07-01T11:55:44.028Z" },
{ url = "https://files.pythonhosted.org/packages/dd/44/668fb1437e8ce420f62d6106eb66e44a5971602a4d794615bdf79315d82d/pillow-12.3.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701", size = 4780147, upload-time = "2026-07-01T11:55:46.073Z" },
{ url = "https://files.pythonhosted.org/packages/0c/08/93fa2e70e30a2d81547e481b6ee2bb9522117221fb1e0ce4b5df70967677/pillow-12.3.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace", size = 6273659, upload-time = "2026-07-01T11:55:48.264Z" },
{ url = "https://files.pythonhosted.org/packages/f8/6d/043e96ff814fc31a33077e4cba86082167db520c93632afdf2042febbb0c/pillow-12.3.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4", size = 6947439, upload-time = "2026-07-01T11:55:50.503Z" },
{ url = "https://files.pythonhosted.org/packages/af/92/ba71d2ee2ac0edf3fa33bd9d5ee9ee080da70b1766f3ca3934f9938ddac9/pillow-12.3.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39", size = 6353577, upload-time = "2026-07-01T11:55:52.697Z" },
{ url = "https://files.pythonhosted.org/packages/0f/ce/e63064e2122923ff687c8ad792d0d736a7b3920a56a46982e81a7fdd25d6/pillow-12.3.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71", size = 7060394, upload-time = "2026-07-01T11:55:55.149Z" },
{ url = "https://files.pythonhosted.org/packages/54/76/a09cc3ccc8d773a7283d34c38bec1708f9e3cc932093cbc4c5e71ac4060b/pillow-12.3.0-cp315-cp315-win32.whl", hash = "sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827", size = 6467375, upload-time = "2026-07-01T11:55:57.769Z" },
{ url = "https://files.pythonhosted.org/packages/3e/03/1846c49ba3b1d5550392a4bbd06d6fb4578e1cd91a803198b5c90f5f7d53/pillow-12.3.0-cp315-cp315-win_amd64.whl", hash = "sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5", size = 7237048, upload-time = "2026-07-01T11:55:59.975Z" },
{ url = "https://files.pythonhosted.org/packages/fb/bb/89f35dcc79610423f9f195504d7def7f0d1416a711541b42867e25fe3412/pillow-12.3.0-cp315-cp315-win_arm64.whl", hash = "sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658", size = 2566006, upload-time = "2026-07-01T11:56:02.143Z" },
{ url = "https://files.pythonhosted.org/packages/30/88/707027ba09942dfa2c28759b5c222d769290a41c6d20ea60ec250801941f/pillow-12.3.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf", size = 5352509, upload-time = "2026-07-01T11:56:04.2Z" },
{ url = "https://files.pythonhosted.org/packages/b0/6d/00352fa25332c2569cd387851f568cc5a4b75a9adbfb37ac4fbce4c02eec/pillow-12.3.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64", size = 4783167, upload-time = "2026-07-01T11:56:06.631Z" },
{ url = "https://files.pythonhosted.org/packages/13/4f/9e049dfa21af7c22427275720e2490267ba8138120add5c4c574deb69782/pillow-12.3.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e", size = 6329237, upload-time = "2026-07-01T11:56:08.868Z" },
{ url = "https://files.pythonhosted.org/packages/36/16/cf6eeaae8d0fce8dd390a33437cf68c5d5bd73834a2bc6e2f14efda0ab45/pillow-12.3.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777", size = 6997047, upload-time = "2026-07-01T11:56:11.379Z" },
{ url = "https://files.pythonhosted.org/packages/1e/69/dbf769bdd55f48bf5733cac28edc6364ffaa072ec9ba336266e4fe66be55/pillow-12.3.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1", size = 6400440, upload-time = "2026-07-01T11:56:13.908Z" },
{ url = "https://files.pythonhosted.org/packages/a0/e1/ffc9cfc2eea0d178da8018e18e959301ad9d6bc9f3edb7181e748a474b97/pillow-12.3.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9", size = 7105895, upload-time = "2026-07-01T11:56:16.575Z" },
{ url = "https://files.pythonhosted.org/packages/18/f0/a5595c1e8c3ae44b9828cb2f0fa8155e5095ef04d6327b8f61cf44a3df85/pillow-12.3.0-cp315-cp315t-win32.whl", hash = "sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8", size = 6474384, upload-time = "2026-07-01T11:56:18.855Z" },
{ url = "https://files.pythonhosted.org/packages/e4/04/62bcd9f844984c5938d3b05264a61d797a29d3e0812341a8204af70bbdee/pillow-12.3.0-cp315-cp315t-win_amd64.whl", hash = "sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418", size = 7243537, upload-time = "2026-07-01T11:56:21.214Z" },
{ url = "https://files.pythonhosted.org/packages/3d/68/1f3066acedf37673694a7141381d8f811ae97f30d34413d236abe7d489f1/pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59", size = 2567491, upload-time = "2026-07-01T11:56:23.506Z" },
]
[[package]] [[package]]
name = "pluggy" name = "pluggy"
version = "1.6.0" version = "1.6.0"
@@ -444,6 +720,36 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/19/c7/5f7c636ec43e0c545e28d1f1db71990108306f7bdcb89f069ba97e428e7f/protobuf-7.35.1-py3-none-any.whl", hash = "sha256:4bc97768d8fe4ad6743c8a19403e314511ed9f6d13205b687e52421c023ac1b9", size = 171659, upload-time = "2026-06-11T21:55:39.155Z" }, { url = "https://files.pythonhosted.org/packages/19/c7/5f7c636ec43e0c545e28d1f1db71990108306f7bdcb89f069ba97e428e7f/protobuf-7.35.1-py3-none-any.whl", hash = "sha256:4bc97768d8fe4ad6743c8a19403e314511ed9f6d13205b687e52421c023ac1b9", size = 171659, upload-time = "2026-06-11T21:55:39.155Z" },
] ]
[[package]]
name = "pyclipper"
version = "1.4.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f6/21/3c06205bb407e1f79b73b7b4dfb3950bd9537c4f625a68ab5cc41177f5bc/pyclipper-1.4.0.tar.gz", hash = "sha256:9882bd889f27da78add4dd6f881d25697efc740bf840274e749988d25496c8e1", size = 54489, upload-time = "2025-12-01T13:15:35.015Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/90/1b/7a07b68e0842324d46c03e512d8eefa9cb92ba2a792b3b4ebf939dafcac3/pyclipper-1.4.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:222ac96c8b8281b53d695b9c4fedc674f56d6d4320ad23f1bdbd168f4e316140", size = 265676, upload-time = "2025-12-01T13:15:04.15Z" },
{ url = "https://files.pythonhosted.org/packages/6b/dd/8bd622521c05d04963420ae6664093f154343ed044c53ea260a310c8bb4d/pyclipper-1.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f3672dbafbb458f1b96e1ee3e610d174acb5ace5bd2ed5d1252603bb797f2fc6", size = 140458, upload-time = "2025-12-01T13:15:05.76Z" },
{ url = "https://files.pythonhosted.org/packages/7a/06/6e3e241882bf7d6ab23d9c69ba4e85f1ec47397cbbeee948a16cf75e21ed/pyclipper-1.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d1f807e2b4760a8e5c6d6b4e8c1d71ef52b7fe1946ff088f4fa41e16a881a5ca", size = 978235, upload-time = "2025-12-01T13:15:06.993Z" },
{ url = "https://files.pythonhosted.org/packages/cf/f4/3418c1cd5eea640a9fa2501d4bc0b3655fa8d40145d1a4f484b987990a75/pyclipper-1.4.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ce1f83c9a4e10ea3de1959f0ae79e9a5bd41346dff648fee6228ba9eaf8b3872", size = 961388, upload-time = "2025-12-01T13:15:08.467Z" },
{ url = "https://files.pythonhosted.org/packages/ac/94/c85401d24be634af529c962dd5d781f3cb62a67cd769534df2cb3feee97a/pyclipper-1.4.0-cp312-cp312-win32.whl", hash = "sha256:3ef44b64666ebf1cb521a08a60c3e639d21b8c50bfbe846ba7c52a0415e936f4", size = 95169, upload-time = "2025-12-01T13:15:10.098Z" },
{ url = "https://files.pythonhosted.org/packages/97/77/dfea08e3b230b82ee22543c30c35d33d42f846a77f96caf7c504dd54fab1/pyclipper-1.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:d1e5498d883b706a4ce636247f0d830c6eb34a25b843a1b78e2c969754ca9037", size = 104619, upload-time = "2025-12-01T13:15:11.592Z" },
{ url = "https://files.pythonhosted.org/packages/67/d0/cbce7d47de1e6458f66a4d999b091640134deb8f2c7351eab993b70d2e10/pyclipper-1.4.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d49df13cbb2627ccb13a1046f3ea6ebf7177b5504ec61bdef87d6a704046fd6e", size = 264342, upload-time = "2025-12-01T13:15:12.697Z" },
{ url = "https://files.pythonhosted.org/packages/ce/cc/742b9d69d96c58ac156947e1b56d0f81cbacbccf869e2ac7229f2f86dc4e/pyclipper-1.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:37bfec361e174110cdddffd5ecd070a8064015c99383d95eb692c253951eee8a", size = 139839, upload-time = "2025-12-01T13:15:13.911Z" },
{ url = "https://files.pythonhosted.org/packages/db/48/dd301d62c1529efdd721b47b9e5fb52120fcdac5f4d3405cfc0d2f391414/pyclipper-1.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:14c8bdb5a72004b721c4e6f448d2c2262d74a7f0c9e3076aeff41e564a92389f", size = 972142, upload-time = "2025-12-01T13:15:15.477Z" },
{ url = "https://files.pythonhosted.org/packages/07/bf/d493fd1b33bb090fa64e28c1009374d5d72fa705f9331cd56517c35e381e/pyclipper-1.4.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f2a50c22c3a78cb4e48347ecf06930f61ce98cf9252f2e292aa025471e9d75b1", size = 952789, upload-time = "2025-12-01T13:15:17.042Z" },
{ url = "https://files.pythonhosted.org/packages/cf/88/b95ea8ea21ddca34aa14b123226a81526dd2faaa993f9aabd3ed21231604/pyclipper-1.4.0-cp313-cp313-win32.whl", hash = "sha256:c9a3faa416ff536cee93417a72bfb690d9dea136dc39a39dbbe1e5dadf108c9c", size = 94817, upload-time = "2025-12-01T13:15:18.724Z" },
{ url = "https://files.pythonhosted.org/packages/ba/42/0a1920d276a0e1ca21dc0d13ee9e3ba10a9a8aa3abac76cd5e5a9f503306/pyclipper-1.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:d4b2d7c41086f1927d14947c563dfc7beed2f6c0d9af13c42fe3dcdc20d35832", size = 104007, upload-time = "2025-12-01T13:15:19.763Z" },
{ url = "https://files.pythonhosted.org/packages/1a/20/04d58c70f3ccd404f179f8dd81d16722a05a3bf1ab61445ee64e8218c1f8/pyclipper-1.4.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:7c87480fc91a5af4c1ba310bdb7de2f089a3eeef5fe351a3cedc37da1fcced1c", size = 265167, upload-time = "2025-12-01T13:15:20.844Z" },
{ url = "https://files.pythonhosted.org/packages/bd/2e/a570c1abe69b7260ca0caab4236ce6ea3661193ebf8d1bd7f78ccce537a5/pyclipper-1.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:81d8bb2d1fb9d66dc7ea4373b176bb4b02443a7e328b3b603a73faec088b952e", size = 139966, upload-time = "2025-12-01T13:15:22.036Z" },
{ url = "https://files.pythonhosted.org/packages/e8/3b/e0859e54adabdde8a24a29d3f525ebb31c71ddf2e8d93edce83a3c212ffc/pyclipper-1.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:773c0e06b683214dcfc6711be230c83b03cddebe8a57eae053d4603dd63582f9", size = 968216, upload-time = "2025-12-01T13:15:23.18Z" },
{ url = "https://files.pythonhosted.org/packages/f6/6b/e3c4febf0a35ae643ee579b09988dd931602b5bf311020535fd9e5b7e715/pyclipper-1.4.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9bc45f2463d997848450dbed91c950ca37c6cf27f84a49a5cad4affc0b469e39", size = 954198, upload-time = "2025-12-01T13:15:24.522Z" },
{ url = "https://files.pythonhosted.org/packages/fc/74/728efcee02e12acb486ce9d56fa037120c9bf5b77c54bbdbaa441c14a9d9/pyclipper-1.4.0-cp314-cp314-win32.whl", hash = "sha256:0b8c2105b3b3c44dbe1a266f64309407fe30bf372cf39a94dc8aaa97df00da5b", size = 96951, upload-time = "2025-12-01T13:15:25.79Z" },
{ url = "https://files.pythonhosted.org/packages/e3/d7/7f4354e69f10a917e5c7d5d72a499ef2e10945312f5e72c414a0a08d2ae4/pyclipper-1.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:6c317e182590c88ec0194149995e3d71a979cfef3b246383f4e035f9d4a11826", size = 106782, upload-time = "2025-12-01T13:15:26.945Z" },
{ url = "https://files.pythonhosted.org/packages/63/60/fc32c7a3d7f61a970511ec2857ecd09693d8ac80d560ee7b8e67a6d268c9/pyclipper-1.4.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:f160a2c6ba036f7eaf09f1f10f4fbfa734234af9112fb5187877efed78df9303", size = 269880, upload-time = "2025-12-01T13:15:28.117Z" },
{ url = "https://files.pythonhosted.org/packages/49/df/c4a72d3f62f0ba03ec440c4fff56cd2d674a4334d23c5064cbf41c9583f6/pyclipper-1.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:a9f11ad133257c52c40d50de7a0ca3370a0cdd8e3d11eec0604ad3c34ba549e9", size = 141706, upload-time = "2025-12-01T13:15:30.134Z" },
{ url = "https://files.pythonhosted.org/packages/c5/0b/cf55df03e2175e1e2da9db585241401e0bc98f76bee3791bed39d0313449/pyclipper-1.4.0-cp314-cp314t-win32.whl", hash = "sha256:bbc827b77442c99deaeee26e0e7f172355ddb097a5e126aea206d447d3b26286", size = 105308, upload-time = "2025-12-01T13:15:31.225Z" },
{ url = "https://files.pythonhosted.org/packages/8f/dc/53df8b6931d47080b4fe4ee8450d42e660ee1c5c1556c7ab73359182b769/pyclipper-1.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:29dae3e0296dff8502eeb7639fcfee794b0eec8590ba3563aee28db269da6b04", size = 117608, upload-time = "2025-12-01T13:15:32.69Z" },
]
[[package]] [[package]]
name = "pydantic" name = "pydantic"
version = "2.13.4" version = "2.13.4"
@@ -557,6 +863,53 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
] ]
[[package]]
name = "pyjwt"
version = "2.13.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" }
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]] [[package]]
name = "pytest" name = "pytest"
version = "9.1.1" version = "9.1.1"
@@ -586,6 +939,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" },
] ]
[[package]]
name = "python-docx"
version = "1.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "lxml" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a9/f7/eddfe33871520adab45aaa1a71f0402a2252050c14c7e3009446c8f4701c/python_docx-1.2.0.tar.gz", hash = "sha256:7bc9d7b7d8a69c9c02ca09216118c86552704edc23bac179283f2e38f86220ce", size = 5723256, upload-time = "2025-06-16T20:46:27.921Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d0/00/1e03a4989fa5795da308cd774f05b704ace555a70f9bf9d3be057b680bcf/python_docx-1.2.0-py3-none-any.whl", hash = "sha256:3fd478f3250fbbbfd3b94fe1e985955737c145627498896a8a6bf81f4baf66c7", size = 252987, upload-time = "2025-06-16T20:46:22.506Z" },
]
[[package]] [[package]]
name = "python-dotenv" name = "python-dotenv"
version = "1.2.2" version = "1.2.2"
@@ -595,6 +961,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
] ]
[[package]]
name = "python-multipart"
version = "0.0.32"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" },
]
[[package]] [[package]]
name = "pywin32" name = "pywin32"
version = "312" version = "312"
@@ -683,12 +1058,19 @@ name = "qmdsearch"
version = "0.1.0" version = "0.1.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "bcrypt" },
{ name = "fastapi" }, { name = "fastapi" },
{ name = "httpx" }, { name = "httpx" },
{ name = "openai" }, { name = "openai" },
{ name = "pydantic" }, { name = "pydantic" },
{ name = "pydantic-settings" }, { name = "pydantic-settings" },
{ name = "pyjwt" },
{ name = "pypdf" },
{ name = "pypdfium2" },
{ name = "python-docx" },
{ name = "python-multipart" },
{ name = "qdrant-client" }, { name = "qdrant-client" },
{ name = "rapidocr-onnxruntime" },
{ name = "redis" }, { name = "redis" },
{ name = "structlog" }, { name = "structlog" },
{ name = "uvicorn", extra = ["standard"] }, { name = "uvicorn", extra = ["standard"] },
@@ -703,14 +1085,21 @@ dev = [
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "bcrypt", specifier = ">=5.0.0" },
{ name = "fastapi", specifier = ">=0.115.0" }, { name = "fastapi", specifier = ">=0.115.0" },
{ name = "httpx", specifier = ">=0.28.0" }, { name = "httpx", specifier = ">=0.28.0" },
{ name = "openai", specifier = ">=1.58.0" }, { name = "openai", specifier = ">=1.58.0" },
{ name = "pydantic", specifier = ">=2.10.0" }, { name = "pydantic", specifier = ">=2.10.0" },
{ name = "pydantic-settings", specifier = ">=2.7.0" }, { name = "pydantic-settings", specifier = ">=2.7.0" },
{ name = "pyjwt", specifier = ">=2.13.0" },
{ name = "pypdf", specifier = ">=5.1.0" },
{ name = "pypdfium2", specifier = ">=4.0.0" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.3.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.3.0" },
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.24.0" }, { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.24.0" },
{ name = "python-docx", specifier = ">=1.1.2" },
{ name = "python-multipart", specifier = ">=0.0.20" },
{ name = "qdrant-client", specifier = ">=1.12.0" }, { name = "qdrant-client", specifier = ">=1.12.0" },
{ name = "rapidocr-onnxruntime", specifier = ">=1.3.8" },
{ name = "redis", specifier = ">=5.2.0" }, { name = "redis", specifier = ">=5.2.0" },
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.8.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.8.0" },
{ name = "structlog", specifier = ">=24.4.0" }, { name = "structlog", specifier = ">=24.4.0" },
@@ -718,6 +1107,25 @@ requires-dist = [
] ]
provides-extras = ["dev"] provides-extras = ["dev"]
[[package]]
name = "rapidocr-onnxruntime"
version = "1.4.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "numpy" },
{ name = "onnxruntime" },
{ name = "opencv-python" },
{ name = "pillow" },
{ name = "pyclipper" },
{ name = "pyyaml" },
{ name = "shapely" },
{ name = "six" },
{ name = "tqdm" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/ba/12/1e5497183bdbe782dbb91bad1d0d2297dba4d2831b2652657f7517bfc6df/rapidocr_onnxruntime-1.4.4-py3-none-any.whl", hash = "sha256:971d7d5f223a7a808662229df1ef69893809d8457d834e6373d3854bc1782cbf", size = 14915192, upload-time = "2025-01-17T01:48:25.104Z" },
]
[[package]] [[package]]
name = "redis" name = "redis"
version = "8.0.1" version = "8.0.1"
@@ -752,6 +1160,66 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/9a/8415f2657cbe200f41a4531ccededf135505a92d4a012229121f885b26f9/ruff-0.16.0-py3-none-win_arm64.whl", hash = "sha256:14296fedcd2705c77ab8235439278bbb38f285cf7da5528b00b3e330c3d4872d", size = 11273407, upload-time = "2026-07-23T19:11:28.705Z" }, { url = "https://files.pythonhosted.org/packages/cb/9a/8415f2657cbe200f41a4531ccededf135505a92d4a012229121f885b26f9/ruff-0.16.0-py3-none-win_arm64.whl", hash = "sha256:14296fedcd2705c77ab8235439278bbb38f285cf7da5528b00b3e330c3d4872d", size = 11273407, upload-time = "2026-07-23T19:11:28.705Z" },
] ]
[[package]]
name = "shapely"
version = "2.1.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "numpy" },
]
sdist = { url = "https://files.pythonhosted.org/packages/4d/bc/0989043118a27cccb4e906a46b7565ce36ca7b57f5a18b78f4f1b0f72d9d/shapely-2.1.2.tar.gz", hash = "sha256:2ed4ecb28320a433db18a5bf029986aa8afcfd740745e78847e330d5d94922a9", size = 315489, upload-time = "2025-09-24T13:51:41.432Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/24/c0/f3b6453cf2dfa99adc0ba6675f9aaff9e526d2224cbd7ff9c1a879238693/shapely-2.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fe2533caae6a91a543dec62e8360fe86ffcdc42a7c55f9dfd0128a977a896b94", size = 1833550, upload-time = "2025-09-24T13:50:30.019Z" },
{ url = "https://files.pythonhosted.org/packages/86/07/59dee0bc4b913b7ab59ab1086225baca5b8f19865e6101db9ebb7243e132/shapely-2.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ba4d1333cc0bc94381d6d4308d2e4e008e0bd128bdcff5573199742ee3634359", size = 1643556, upload-time = "2025-09-24T13:50:32.291Z" },
{ url = "https://files.pythonhosted.org/packages/26/29/a5397e75b435b9895cd53e165083faed5d12fd9626eadec15a83a2411f0f/shapely-2.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0bd308103340030feef6c111d3eb98d50dc13feea33affc8a6f9fa549e9458a3", size = 2988308, upload-time = "2025-09-24T13:50:33.862Z" },
{ url = "https://files.pythonhosted.org/packages/b9/37/e781683abac55dde9771e086b790e554811a71ed0b2b8a1e789b7430dd44/shapely-2.1.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1e7d4d7ad262a48bb44277ca12c7c78cb1b0f56b32c10734ec9a1d30c0b0c54b", size = 3099844, upload-time = "2025-09-24T13:50:35.459Z" },
{ url = "https://files.pythonhosted.org/packages/d8/f3/9876b64d4a5a321b9dc482c92bb6f061f2fa42131cba643c699f39317cb9/shapely-2.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e9eddfe513096a71896441a7c37db72da0687b34752c4e193577a145c71736fc", size = 3988842, upload-time = "2025-09-24T13:50:37.478Z" },
{ url = "https://files.pythonhosted.org/packages/d1/a0/704c7292f7014c7e74ec84eddb7b109e1fbae74a16deae9c1504b1d15565/shapely-2.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:980c777c612514c0cf99bc8a9de6d286f5e186dcaf9091252fcd444e5638193d", size = 4152714, upload-time = "2025-09-24T13:50:39.9Z" },
{ url = "https://files.pythonhosted.org/packages/53/46/319c9dc788884ad0785242543cdffac0e6530e4d0deb6c4862bc4143dcf3/shapely-2.1.2-cp312-cp312-win32.whl", hash = "sha256:9111274b88e4d7b54a95218e243282709b330ef52b7b86bc6aaf4f805306f454", size = 1542745, upload-time = "2025-09-24T13:50:41.414Z" },
{ url = "https://files.pythonhosted.org/packages/ec/bf/cb6c1c505cb31e818e900b9312d514f381fbfa5c4363edfce0fcc4f8c1a4/shapely-2.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:743044b4cfb34f9a67205cee9279feaf60ba7d02e69febc2afc609047cb49179", size = 1722861, upload-time = "2025-09-24T13:50:43.35Z" },
{ url = "https://files.pythonhosted.org/packages/c3/90/98ef257c23c46425dc4d1d31005ad7c8d649fe423a38b917db02c30f1f5a/shapely-2.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b510dda1a3672d6879beb319bc7c5fd302c6c354584690973c838f46ec3e0fa8", size = 1832644, upload-time = "2025-09-24T13:50:44.886Z" },
{ url = "https://files.pythonhosted.org/packages/6d/ab/0bee5a830d209adcd3a01f2d4b70e587cdd9fd7380d5198c064091005af8/shapely-2.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8cff473e81017594d20ec55d86b54bc635544897e13a7cfc12e36909c5309a2a", size = 1642887, upload-time = "2025-09-24T13:50:46.735Z" },
{ url = "https://files.pythonhosted.org/packages/2d/5e/7d7f54ba960c13302584c73704d8c4d15404a51024631adb60b126a4ae88/shapely-2.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fe7b77dc63d707c09726b7908f575fc04ff1d1ad0f3fb92aec212396bc6cfe5e", size = 2970931, upload-time = "2025-09-24T13:50:48.374Z" },
{ url = "https://files.pythonhosted.org/packages/f2/a2/83fc37e2a58090e3d2ff79175a95493c664bcd0b653dd75cb9134645a4e5/shapely-2.1.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7ed1a5bbfb386ee8332713bf7508bc24e32d24b74fc9a7b9f8529a55db9f4ee6", size = 3082855, upload-time = "2025-09-24T13:50:50.037Z" },
{ url = "https://files.pythonhosted.org/packages/44/2b/578faf235a5b09f16b5f02833c53822294d7f21b242f8e2d0cf03fb64321/shapely-2.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a84e0582858d841d54355246ddfcbd1fce3179f185da7470f41ce39d001ee1af", size = 3979960, upload-time = "2025-09-24T13:50:51.74Z" },
{ url = "https://files.pythonhosted.org/packages/4d/04/167f096386120f692cc4ca02f75a17b961858997a95e67a3cb6a7bbd6b53/shapely-2.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dc3487447a43d42adcdf52d7ac73804f2312cbfa5d433a7d2c506dcab0033dfd", size = 4142851, upload-time = "2025-09-24T13:50:53.49Z" },
{ url = "https://files.pythonhosted.org/packages/48/74/fb402c5a6235d1c65a97348b48cdedb75fb19eca2b1d66d04969fc1c6091/shapely-2.1.2-cp313-cp313-win32.whl", hash = "sha256:9c3a3c648aedc9f99c09263b39f2d8252f199cb3ac154fadc173283d7d111350", size = 1541890, upload-time = "2025-09-24T13:50:55.337Z" },
{ url = "https://files.pythonhosted.org/packages/41/47/3647fe7ad990af60ad98b889657a976042c9988c2807cf322a9d6685f462/shapely-2.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:ca2591bff6645c216695bdf1614fca9c82ea1144d4a7591a466fef64f28f0715", size = 1722151, upload-time = "2025-09-24T13:50:57.153Z" },
{ url = "https://files.pythonhosted.org/packages/3c/49/63953754faa51ffe7d8189bfbe9ca34def29f8c0e34c67cbe2a2795f269d/shapely-2.1.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2d93d23bdd2ed9dc157b46bc2f19b7da143ca8714464249bef6771c679d5ff40", size = 1834130, upload-time = "2025-09-24T13:50:58.49Z" },
{ url = "https://files.pythonhosted.org/packages/7f/ee/dce001c1984052970ff60eb4727164892fb2d08052c575042a47f5a9e88f/shapely-2.1.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:01d0d304b25634d60bd7cf291828119ab55a3bab87dc4af1e44b07fb225f188b", size = 1642802, upload-time = "2025-09-24T13:50:59.871Z" },
{ url = "https://files.pythonhosted.org/packages/da/e7/fc4e9a19929522877fa602f705706b96e78376afb7fad09cad5b9af1553c/shapely-2.1.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8d8382dd120d64b03698b7298b89611a6ea6f55ada9d39942838b79c9bc89801", size = 3018460, upload-time = "2025-09-24T13:51:02.08Z" },
{ url = "https://files.pythonhosted.org/packages/a1/18/7519a25db21847b525696883ddc8e6a0ecaa36159ea88e0fef11466384d0/shapely-2.1.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:19efa3611eef966e776183e338b2d7ea43569ae99ab34f8d17c2c054d3205cc0", size = 3095223, upload-time = "2025-09-24T13:51:04.472Z" },
{ url = "https://files.pythonhosted.org/packages/48/de/b59a620b1f3a129c3fecc2737104a0a7e04e79335bd3b0a1f1609744cf17/shapely-2.1.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:346ec0c1a0fcd32f57f00e4134d1200e14bf3f5ae12af87ba83ca275c502498c", size = 4030760, upload-time = "2025-09-24T13:51:06.455Z" },
{ url = "https://files.pythonhosted.org/packages/96/b3/c6655ee7232b417562bae192ae0d3ceaadb1cc0ffc2088a2ddf415456cc2/shapely-2.1.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6305993a35989391bd3476ee538a5c9a845861462327efe00dd11a5c8c709a99", size = 4170078, upload-time = "2025-09-24T13:51:08.584Z" },
{ url = "https://files.pythonhosted.org/packages/a0/8e/605c76808d73503c9333af8f6cbe7e1354d2d238bda5f88eea36bfe0f42a/shapely-2.1.2-cp313-cp313t-win32.whl", hash = "sha256:c8876673449f3401f278c86eb33224c5764582f72b653a415d0e6672fde887bf", size = 1559178, upload-time = "2025-09-24T13:51:10.73Z" },
{ url = "https://files.pythonhosted.org/packages/36/f7/d317eb232352a1f1444d11002d477e54514a4a6045536d49d0c59783c0da/shapely-2.1.2-cp313-cp313t-win_amd64.whl", hash = "sha256:4a44bc62a10d84c11a7a3d7c1c4fe857f7477c3506e24c9062da0db0ae0c449c", size = 1739756, upload-time = "2025-09-24T13:51:12.105Z" },
{ url = "https://files.pythonhosted.org/packages/fc/c4/3ce4c2d9b6aabd27d26ec988f08cb877ba9e6e96086eff81bfea93e688c7/shapely-2.1.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:9a522f460d28e2bf4e12396240a5fc1518788b2fcd73535166d748399ef0c223", size = 1831290, upload-time = "2025-09-24T13:51:13.56Z" },
{ url = "https://files.pythonhosted.org/packages/17/b9/f6ab8918fc15429f79cb04afa9f9913546212d7fb5e5196132a2af46676b/shapely-2.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1ff629e00818033b8d71139565527ced7d776c269a49bd78c9df84e8f852190c", size = 1641463, upload-time = "2025-09-24T13:51:14.972Z" },
{ url = "https://files.pythonhosted.org/packages/a5/57/91d59ae525ca641e7ac5551c04c9503aee6f29b92b392f31790fcb1a4358/shapely-2.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f67b34271dedc3c653eba4e3d7111aa421d5be9b4c4c7d38d30907f796cb30df", size = 2970145, upload-time = "2025-09-24T13:51:16.961Z" },
{ url = "https://files.pythonhosted.org/packages/8a/cb/4948be52ee1da6927831ab59e10d4c29baa2a714f599f1f0d1bc747f5777/shapely-2.1.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:21952dc00df38a2c28375659b07a3979d22641aeb104751e769c3ee825aadecf", size = 3073806, upload-time = "2025-09-24T13:51:18.712Z" },
{ url = "https://files.pythonhosted.org/packages/03/83/f768a54af775eb41ef2e7bec8a0a0dbe7d2431c3e78c0a8bdba7ab17e446/shapely-2.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1f2f33f486777456586948e333a56ae21f35ae273be99255a191f5c1fa302eb4", size = 3980803, upload-time = "2025-09-24T13:51:20.37Z" },
{ url = "https://files.pythonhosted.org/packages/9f/cb/559c7c195807c91c79d38a1f6901384a2878a76fbdf3f1048893a9b7534d/shapely-2.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cf831a13e0d5a7eb519e96f58ec26e049b1fad411fc6fc23b162a7ce04d9cffc", size = 4133301, upload-time = "2025-09-24T13:51:21.887Z" },
{ url = "https://files.pythonhosted.org/packages/80/cd/60d5ae203241c53ef3abd2ef27c6800e21afd6c94e39db5315ea0cbafb4a/shapely-2.1.2-cp314-cp314-win32.whl", hash = "sha256:61edcd8d0d17dd99075d320a1dd39c0cb9616f7572f10ef91b4b5b00c4aeb566", size = 1583247, upload-time = "2025-09-24T13:51:23.401Z" },
{ url = "https://files.pythonhosted.org/packages/74/d4/135684f342e909330e50d31d441ace06bf83c7dc0777e11043f99167b123/shapely-2.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:a444e7afccdb0999e203b976adb37ea633725333e5b119ad40b1ca291ecf311c", size = 1773019, upload-time = "2025-09-24T13:51:24.873Z" },
{ url = "https://files.pythonhosted.org/packages/a3/05/a44f3f9f695fa3ada22786dc9da33c933da1cbc4bfe876fe3a100bafe263/shapely-2.1.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:5ebe3f84c6112ad3d4632b1fd2290665aa75d4cef5f6c5d77c4c95b324527c6a", size = 1834137, upload-time = "2025-09-24T13:51:26.665Z" },
{ url = "https://files.pythonhosted.org/packages/52/7e/4d57db45bf314573427b0a70dfca15d912d108e6023f623947fa69f39b72/shapely-2.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5860eb9f00a1d49ebb14e881f5caf6c2cf472c7fd38bd7f253bbd34f934eb076", size = 1642884, upload-time = "2025-09-24T13:51:28.029Z" },
{ url = "https://files.pythonhosted.org/packages/5a/27/4e29c0a55d6d14ad7422bf86995d7ff3f54af0eba59617eb95caf84b9680/shapely-2.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b705c99c76695702656327b819c9660768ec33f5ce01fa32b2af62b56ba400a1", size = 3018320, upload-time = "2025-09-24T13:51:29.903Z" },
{ url = "https://files.pythonhosted.org/packages/9f/bb/992e6a3c463f4d29d4cd6ab8963b75b1b1040199edbd72beada4af46bde5/shapely-2.1.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a1fd0ea855b2cf7c9cddaf25543e914dd75af9de08785f20ca3085f2c9ca60b0", size = 3094931, upload-time = "2025-09-24T13:51:32.699Z" },
{ url = "https://files.pythonhosted.org/packages/9c/16/82e65e21070e473f0ed6451224ed9fa0be85033d17e0c6e7213a12f59d12/shapely-2.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:df90e2db118c3671a0754f38e36802db75fe0920d211a27481daf50a711fdf26", size = 4030406, upload-time = "2025-09-24T13:51:34.189Z" },
{ url = "https://files.pythonhosted.org/packages/7c/75/c24ed871c576d7e2b64b04b1fe3d075157f6eb54e59670d3f5ffb36e25c7/shapely-2.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:361b6d45030b4ac64ddd0a26046906c8202eb60d0f9f53085f5179f1d23021a0", size = 4169511, upload-time = "2025-09-24T13:51:36.297Z" },
{ url = "https://files.pythonhosted.org/packages/b1/f7/b3d1d6d18ebf55236eec1c681ce5e665742aab3c0b7b232720a7d43df7b6/shapely-2.1.2-cp314-cp314t-win32.whl", hash = "sha256:b54df60f1fbdecc8ebc2c5b11870461a6417b3d617f555e5033f1505d36e5735", size = 1602607, upload-time = "2025-09-24T13:51:37.757Z" },
{ url = "https://files.pythonhosted.org/packages/9a/f6/f09272a71976dfc138129b8faf435d064a811ae2f708cb147dccdf7aacdb/shapely-2.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:0036ac886e0923417932c2e6369b6c52e38e0ff5d9120b90eef5cd9a5fc5cae9", size = 1796682, upload-time = "2025-09-24T13:51:39.233Z" },
]
[[package]]
name = "six"
version = "1.17.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
]
[[package]] [[package]]
name = "sniffio" name = "sniffio"
version = "1.3.1" version = "1.3.1"