feat: 新增多格式文件上传入库与认证体系
- 新增 JWT 认证模块,支持登录/注册/用户管理 - 新增文件上传接口,支持 .txt/.md/.html/.pdf/.docx 等格式解析入库 - 新增检索结果 AI 总结功能 - 新增文本去重缓存机制 - 新增全局认证夹具简化测试 - 新增配置项与环境变量支持 - 完善文档与测试覆盖
This commit is contained in:
@@ -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/.md(UTF-8 解码 errors=replace)
|
||||||
|
- [x] 支持 .html/.htm(html.parser 剥离 script/style 后提取可见文本)
|
||||||
|
- [x] 支持 .pdf(pypdf 逐页 extract_text 拼接)
|
||||||
|
- [x] 支持 .docx(python-docx 段落文本拼接)
|
||||||
|
- [x] 未识别扩展名抛 `ValueError("不支持的文件类型: {ext}")`
|
||||||
|
- [x] 解析异常包装为 `ValueError("文件解析失败: {detail}")` 并保留原异常链
|
||||||
|
- [x] `tests/test_file_parser.py` 13 例覆盖各格式 + 异常路径
|
||||||
|
|
||||||
|
## 文件上传入库端点
|
||||||
|
|
||||||
|
- [x] `POST /api/v1/documents/upload` 端点存在,接收 multipart/form-data(file + 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`
|
||||||
|
|
||||||
|
## 管理后台 UI(Task 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-data(file + 可选 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** 返回 202,data.task_id 非空、data.status=="pending"、data.saved_path 指向已存在文件;任务管理器收到 DocumentInput,text 与文件解码一致、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** 返回 202,saved_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 input(accept=".txt,.md,.html,.htm,.pdf,.docx")+ 可选 title + 可选 source + 提交按钮 `#btn-upload-submit`
|
||||||
|
- [x] SubTask 5.2: 新增 `#upload-form` submit 监听:构造 FormData(file/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 5(admin.html UI)依赖 Task 3 已完成(端点已存在)✓
|
||||||
|
- Task 6 独立
|
||||||
|
- Task 7 依赖 Task 4/5/6 全部完成
|
||||||
@@ -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 附 result,failed 附 error) |
|
| GET | `/api/v1/documents/tasks/{task_id}` | 入库任务状态查询(done 附 result,failed 附 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 供软召回)
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"""认证 API:POST /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
@@ -1,13 +1,19 @@
|
|||||||
"""文档 API:POST /api/v1/documents 异步入库 + 任务查询 + 文档管理(列表/详情/删除)"""
|
"""文档 API:POST /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_at,done 附 result,failed 附 error"""
|
"""查询入库任务状态:含 task_id/status/created_at/updated_at,done 附 result,failed 附 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,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:
|
||||||
|
|||||||
@@ -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 缓存:命中直接返回缓存的响应;未命中走检索流程并回写缓存。
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,213 @@
|
|||||||
|
"""JWT 认证核心:密码哈希、token 签发/验签、用户存储、FastAPI 鉴权依赖
|
||||||
|
|
||||||
|
用户持久化在 Redis(key: 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 secret(settings 为空时自动生成,仅开发用)
|
||||||
|
_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
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
"""多格式文件文本提取:按扩展名分发到对应解析器
|
||||||
|
|
||||||
|
支持的扩展名:
|
||||||
|
- .txt / .md:UTF-8 解码(errors="replace" 兜底)
|
||||||
|
- .html / .htm:标准库 html.parser 剥离标签提取可见文本
|
||||||
|
- .pdf:pypdf 逐页 extract_text 拼接;文本层为空(扫描件/图片型)时
|
||||||
|
自动降级为 OCR(pypdfium2 渲染 + rapidocr-onnxruntime 识别)
|
||||||
|
- .docx:python-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())
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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="登录用户信息")
|
||||||
@@ -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)")
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 掉对应 override,autouse 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()
|
||||||
@@ -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_admin:admin 通过、非 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
|
||||||
@@ -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=1001,message 含'大小上限',未提交任务"""
|
||||||
|
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=1001,message 含'无法从文件提取文本',未提交任务"""
|
||||||
|
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:
|
||||||
|
"""损坏的 PDF:code=1001,message 含'文件解析失败',未提交任务"""
|
||||||
|
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"
|
||||||
@@ -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:
|
||||||
|
"""不支持的扩展名抛 ValueError,message 含扩展名"""
|
||||||
|
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 抛 ValueError,message 含'文件解析失败'"""
|
||||||
|
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 & Jerry</p></body></html>"
|
||||||
|
result = parse_file("page.html", html)
|
||||||
|
assert "Tom & Jerry" in result
|
||||||
|
assert "&" 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
|
||||||
@@ -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_id,deduplicated=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
|
||||||
|
|||||||
@@ -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 纯函数的四个分支"""
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
"""ResultSummarizer 单元测试(FakeOllama,不依赖真实 Ollama)
|
||||||
|
|
||||||
|
覆盖:
|
||||||
|
- summarize 正常:调用 ollama,prompt 含 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):
|
||||||
|
"""正常总结:调用 ollama,prompt 含 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
|
||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user