diff --git a/apps/web/package-lock.json b/apps/web/package-lock.json
index ba825f7..9ac3f20 100644
--- a/apps/web/package-lock.json
+++ b/apps/web/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "ultimate-rag-web",
- "version": "1.0.0",
+ "version": "2.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ultimate-rag-web",
- "version": "1.0.0",
+ "version": "2.0.0",
"dependencies": {
"@ai-sdk/react": "^4.0.86",
"@radix-ui/react-avatar": "^1.2.6",
diff --git a/apps/web/package.json b/apps/web/package.json
index bc4c24b..20daa38 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -1,6 +1,6 @@
{
"name": "ultimate-rag-web",
- "version": "1.0.0",
+ "version": "2.0.0",
"private": true,
"scripts": {
"dev": "next dev",
diff --git a/docker-compose.yml b/docker-compose.yml
index cb964ea..9bb2d1a 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,3 +1,4 @@
+# Compose 项目名保持稳定,V1 升级 V2 时继续复用已有事实数据卷。
name: ultimate-rag-v1
services:
@@ -100,6 +101,8 @@ services:
EMBEDDING_MODEL: ${EMBEDDING_MODEL:-text-embedding-v4}
EMBEDDING_DIMENSION: ${EMBEDDING_DIMENSION:-1024}
LLM_MODEL: ${LLM_MODEL:-qwen-plus}
+ OCR_MODEL: ${OCR_MODEL:-qwen-vl-ocr-latest}
+ OCR_MAX_IMAGE_BYTES: ${OCR_MAX_IMAGE_BYTES:-6291456}
CORS_ORIGINS: http://localhost:3000,http://192.168.3.19:3000
ports:
- "8000:8000"
diff --git a/docs/4.v2_implementation.md b/docs/4.v2_implementation.md
new file mode 100644
index 0000000..3fe499b
--- /dev/null
+++ b/docs/4.v2_implementation.md
@@ -0,0 +1,296 @@
+# UltimateRAG V2 实现说明
+
+## 1. 文档目的
+
+本文说明 V2 Document Intelligence 如何在 V1 Naive RAG 闭环上增加多格式解析能力,同时保持
+RAG Core 与原始文件格式解耦。产品范围以 `1.product_description.md` 为准,架构边界以
+`2.technical_architecture.md` 为准。
+
+V2 的核心交付不是“安装更多文件解析库”,而是建立一条稳定边界:
+
+```text
+不可信原始文件
+ ↓
+Parser Registry
+ ↓
+DocumentParser
+ ↓
+ParsedDocument / Block / SourceLocator
+ ↓
+StructureAwareChunker
+ ↓
+V1 RAG Core(Embed → Index → Retrieve → Generate)
+```
+
+新增一种文档类型只需实现 `DocumentParser` 并注册,不需要修改切块、Embedding、Milvus、检索、
+上下文构造或生成流程。
+
+## 2. V2 支持范围
+
+| 类型 | 扩展名 | Parser | 主要定位信息 |
+|---|---|---|---|
+| Markdown | `.md`、`.markdown` | `MarkdownParser` | `heading_path` |
+| PDF | `.pdf` | `PDFParser` | `page`,预留 `bbox` |
+| Word | `.docx` | `WordParser` | `heading_path` |
+| Excel | `.xlsx` | `ExcelParser` | `sheet`、`cell_range` |
+| PowerPoint | `.pptx` | `PowerPointParser` | `slide`、`heading_path` |
+| HTML | `.html`、`.htm` | `HtmlParser` | `heading_path` |
+| 图片 | PNG/JPEG/WEBP/TIFF/BMP | `ImageOCRParser` | 当前为文档级定位 |
+
+V2 只支持现代 OOXML,不支持 `.doc`、`.xls`、`.ppt`、带宏 Office 文件,也不解析 Office 内嵌
+图片或图表数据模型。它不包含网页爬取、OCR Worker、异步任务队列、Reranker、ACL 或 Agent。
+
+## 3. 统一文档模型
+
+每个 Parser 必须输出 UltimateRAG 自有模型,第三方库对象不能越过 Parser 边界。
+
+### BlockType
+
+V2 使用以下语义类型:
+
+```text
+HEADING / TEXT / TABLE / IMAGE / CODE / LIST / QUOTE
+```
+
+类型用于表达原始结构,不决定具体厂商协议。比如 OCR 页面使用 `IMAGE`,其 `content` 仍是可
+Embedding 的识别文本;HTML `` 使用 `CODE`;Office 表格统一转换为 Markdown 表格文本。
+
+### SourceLocator
+
+`SourceLocator` 是跨格式来源位置:
+
+```json
+{
+ "heading_path": ["RAG", "Indexing"],
+ "page": 12,
+ "bbox": null,
+ "sheet": null,
+ "cell_range": null,
+ "slide": null
+}
+```
+
+字段均可选,因为不同格式的定位能力不同。Locator 会沿以下链路传播:
+
+```text
+Block
+ → Chunk
+ → PostgreSQL chunk_metadata.source_locator
+ → Milvus JSON
+ → RetrievalResult
+ → ContextBuilder / Citation
+ → FastAPI / Web
+```
+
+`heading_path` 继续作为 Chunk 的兼容字段保留,避免破坏 V1 API 和数据库结构。
+
+## 4. Parser Registry 与输入边界
+
+`IngestionService` 不再硬编码 Markdown 扩展名。上传流程在访问 MinIO 前完成:
+
+1. 文件名 basename 净化
+2. 空内容与 10 MB 总上传上限检查
+3. MIME 主类型归一化
+4. `ParserRegistry.resolve()` 同时检查扩展名和 MIME
+5. 解析器继续检查真实编码、容器或文件结构
+
+扩展名和 MIME 都来自不可信客户端,因此它们只负责 Parser 路由,不能替代内容校验。例如图片
+由 Pillow 解码并确认真实格式,OOXML 会检查 ZIP Central Directory,HTML/Markdown 必须能按
+UTF-8 解码。
+
+只有找到 Parser 的文件才会进入 MinIO 和 PostgreSQL,未知类型不会留下孤儿对象。Parser 实际
+处理失败后,原文件与文档事实保留,状态进入 `FAILED`,便于排查和未来重试。
+
+## 5. PDF 原生文本与扫描件
+
+`PDFParser` 使用 PDFium 逐页处理:
+
+```text
+Open PDF
+ ↓
+Extract native page text
+ ├── 字符数达到阈值 → TEXT Block
+ └── 字符数低于阈值 → Render JPEG → OCR → IMAGE Block
+```
+
+这种按页判定支持“前几页原生文本、后几页扫描图”的混合 PDF。原生页不会调用付费 OCR;扫描页
+只渲染当前页,识别后立即转换为带一基页码的 Block。
+
+相关配置:
+
+```dotenv
+PDF_NATIVE_TEXT_THRESHOLD=20
+PDF_RENDER_SCALE=1.5
+```
+
+V2 限制单份 PDF 最多 500 页。加密、损坏、空页 PDF 会返回明确输入错误。当前只保存页码,
+`bbox` 已进入统一模型,留给未来版面分析 Parser 使用。
+
+## 6. 百炼 OCR 适配器
+
+`OCRClient` 是领域端口,`BailianOCRClient` 是阿里云百炼 OpenAI-Compatible 适配器。Image 与 PDF
+Parser 只依赖端口,因此单元测试不访问模型服务,未来替换 OCR 厂商也不改变解析流程。
+
+适配器把验证后的图片编码为 Data URL,并要求模型:
+
+- 按自然阅读顺序提取全部可见文字
+- 保留标题、段落和列表
+- 表格输出 Markdown 表格
+- 不解释、不总结、不编造不可见内容
+
+配置:
+
+```dotenv
+OCR_MODEL=qwen-vl-ocr-latest
+OCR_MAX_IMAGE_BYTES=6291456
+```
+
+OCR 单图默认限制 6 MB,为百炼 Base64 图片边界留出余量。空图、非图片 MIME、超限输入和空模型
+响应都会显式失败。模型 API Key 只从 `.env` 注入,不写入日志、文档或仓库。
+
+## 7. Office Parser
+
+### DOCX
+
+`WordParser` 使用 python-docx 按正文顺序读取段落和表格:
+
+- Heading Style 更新标题路径
+- List Style 映射为 `LIST`
+- 普通段落映射为 `TEXT`
+- 表格映射为 Markdown `TABLE`
+
+Word 文档没有可靠的静态页码模型,因此 V2 使用标题路径定位。
+
+### XLSX
+
+`ExcelParser` 使用 openpyxl `read_only` 模式读取公式或单元格值,每 100 行形成一个表格 Block,
+并记录工作表与范围,例如:
+
+```text
+工作表 Metrics · 区域 A1:D100
+```
+
+单工作表最大允许 200,000 个声明范围单元格,Workbook 最多 100 个 Sheet。限制用于阻止异常文件
+消耗无界内存与 CPU,不代表生产业务推荐达到上限。
+
+### PPTX
+
+`PowerPointParser` 按幻灯片和 Shape 顺序提取标题、文本框和表格,每个 Block 保存一基 slide
+序号。V2 不对幻灯片内嵌图片再次 OCR。
+
+### OOXML 安全检查
+
+DOCX/XLSX/PPTX 本质都是 ZIP。交给第三方库前统一检查:
+
+- 最多 10,000 个内部条目
+- 解压后总量不超过 100 MB
+- 大条目压缩比不超过 200
+
+该检查降低 ZIP Bomb 风险,同时保留 API 的 10 MB 压缩文件上传上限。
+
+## 8. HTML 与图片
+
+`HtmlParser` 只处理上传的静态 HTML,不发起外部网络请求,不加载图片/iframe,也不执行脚本。
+`script`、`style`、`noscript` 和 `template` 会在提取前删除;标题、段落、列表、引用、代码与表格
+按 DOM 顺序进入统一 Block。V2 因此没有 SSRF 或网页爬取授权范围。
+
+`ImageOCRParser` 支持 PNG、JPEG、WEBP、TIFF 和 BMP。Pillow 会验证实际编码,扩展名伪装的图片
+会在访问百炼前失败。OCR 输出为空时不创建空 Chunk。
+
+## 9. 格式无关切块
+
+V1 的 `StructureAwareMarkdownChunker` 已泛化为 `StructureAwareChunker`,旧类名作为兼容别名保留。
+切块仍优先按自然结构聚合,再对超长内容做字符窗口切分;关键变化是来源边界比较完整 Locator:
+
+```text
+heading_path / page / sheet / cell_range / slide
+```
+
+即使两个 PDF 页面标题相同,页码变化也会先提交当前 Section,避免一个 Chunk 混合多个来源位置。
+代码块继续恢复围栏,标题路径继续写入 Chunk 文本以提供局部语义。
+
+## 10. 持久化与 V1 兼容
+
+V2 没有新增业务表或修改 PostgreSQL Schema:
+
+- 强类型 Locator 保存在现有 `chunks.chunk_metadata["source_locator"]`
+- Milvus 复用现有 JSON 类型 `heading_path` 字段保存 Locator 字典
+- 读取端兼容 V1 的标题路径数组和 V2 Locator 字典
+
+这样从 V1 升级 V2 不需要应用启动时隐式改表或删除已有向量集合。PostgreSQL + MinIO 仍是事实
+来源,Milvus 仍是可以重建的派生索引。
+
+## 11. API 与前端
+
+上传端点不变:
+
+```text
+POST /api/knowledge-bases/{knowledge_base_id}/documents
+```
+
+前端文件选择器展示全部 V2 扩展名。文档列表显示实际 `parser_name` 和处理状态。Retrieval 与 Chat
+响应新增可选 `locator`,同时保留 `heading_path`:
+
+```json
+{
+ "filename": "metrics.xlsx",
+ "heading_path": [],
+ "locator": {
+ "heading_path": [],
+ "page": null,
+ "bbox": null,
+ "sheet": "Metrics",
+ "cell_range": "A1:B20",
+ "slide": null
+ }
+}
+```
+
+证据面板会根据格式显示章节、页码、工作表范围或幻灯片。`192.168.3.19:3000` 与
+`localhost:3000` 都在默认 CORS 白名单中;未配置 `NEXT_PUBLIC_API_URL` 时前端继续使用当前浏览器
+主机的 8000 端口。
+
+## 12. 测试与验收
+
+单元测试在内存中动态构造 HTML、DOCX、XLSX、PPTX、PNG 和扫描 PDF,覆盖:
+
+- Parser 类型选择和输入拒绝发生在 MinIO 写入前
+- 每种格式生成统一 Block
+- 标题、页码、Sheet/Range 和 Slide 来源位置
+- HTML 不索引脚本
+- 图片真实格式校验
+- 扫描 PDF 渲染与 OCR 路径
+- 跨页 Block 不被错误合并
+- 百炼 OCR Data URL 与字节上限
+
+本地验证:
+
+```bash
+uv run pytest
+uv run ruff check .
+uv run mypy
+
+cd apps/web
+npm run lint
+npm run build
+```
+
+Docker 全栈与真实百炼验收:
+
+```bash
+docker compose up -d --build
+uv run python scripts/smoke_v2.py --api-url http://localhost:8000
+```
+
+Smoke Test 会创建临时知识库,动态生成全部支持格式,验证每份文档 `READY` 和 Parser 名称,再验证
+带 Locator 的检索与流式 Citation,最后清理临时知识库。
+
+## 13. 已知限制与后续版本边界
+
+- V2 摄取仍为同步请求,不适合超长文件或大规模并发;异步任务属于 V4
+- OCR 依赖外部百炼服务,网络或额度故障会形成可追踪 `FAILED`
+- V2 不解析 Office 内嵌图片、图表、批注、修订和宏
+- HTML 不抓取远程资源
+- PDF 仅保存页级定位,复杂 Layout 与 BBox 属于更高级 Document Intelligence
+- Retrieval 仍是 V1 Dense Search;Hybrid、Reranker、Query Rewrite 属于 V3
+- 认证、ACL、多租户隔离和审计属于 V4,当前不应直接暴露到公网
diff --git a/pyproject.toml b/pyproject.toml
index 7e6efb3..b15c80d 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,12 +1,13 @@
[project]
name = "ultimate-rag"
-version = "1.0.0"
+version = "2.0.0"
description = "An evolvable enterprise-grade RAG platform"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"alembic>=1.16,<2",
"asyncpg>=0.30,<1",
+ "beautifulsoup4>=4.13,<5",
"fastapi>=0.116,<1",
# OpenAI SDK 通过 HTTPX 自动读取 HTTP_PROXY/HTTPS_PROXY/ALL_PROXY。
# 本地开发常使用 SOCKS 代理访问百炼,因此运行时必须包含 socksio 可选传输依赖。
@@ -14,9 +15,14 @@ dependencies = [
"markdown-it-py>=4,<5",
"minio>=7.2,<8",
"openai>=1.99,<3",
+ "openpyxl>=3.1,<4",
+ "pillow>=11,<13",
"pydantic-settings>=2.10,<3",
"pymilvus>=2.5,<3",
+ "pypdfium2>=4.30,<5",
+ "python-docx>=1.2,<2",
"python-multipart>=0.0.20,<1",
+ "python-pptx>=1.0,<2",
"sqlalchemy[asyncio]>=2.0.41,<3",
"uvicorn[standard]>=0.35,<1",
]
@@ -27,6 +33,7 @@ dev = [
"pytest>=8.4,<9",
"pytest-asyncio>=1.1,<2",
"ruff>=0.12,<1",
+ "types-openpyxl>=3.1,<4",
]
[build-system]
diff --git a/scripts/smoke_v2.py b/scripts/smoke_v2.py
new file mode 100644
index 0000000..ccebc51
--- /dev/null
+++ b/scripts/smoke_v2.py
@@ -0,0 +1,276 @@
+"""对已启动的 UltimateRAG 执行一次可回收的 V2 全格式真实闭环验收。
+
+脚本通过公开 API 动态生成并上传 Markdown、HTML、DOCX、XLSX、PPTX、PNG 和扫描 PDF,验证
+Parser、READY、来源定位、Milvus 检索与百炼流式问答,最后清理临时知识库。
+
+执行会真实调用百炼 Embedding、OCR 和 LLM,并产生少量模型用量:
+``uv run python scripts/smoke_v2.py --api-url http://localhost:8000``
+"""
+
+import argparse
+import json
+from collections.abc import Iterator
+from dataclasses import dataclass
+from io import BytesIO
+from typing import Any
+from uuid import uuid4
+
+import httpx
+from docx import Document as WordDocument
+from openpyxl import Workbook
+from PIL import Image, ImageDraw, ImageFont
+from pptx import Presentation
+from pptx.util import Inches
+
+
+@dataclass(frozen=True, slots=True)
+class SmokeDocument:
+ """一份待上传的内存文档及其期望 Parser。"""
+
+ filename: str
+ mime_type: str
+ content: bytes
+ parser_name: str
+
+
+def parse_arguments() -> argparse.Namespace:
+ """解析 API 地址和验证问题。"""
+
+ parser = argparse.ArgumentParser(description="Run the UltimateRAG V2 smoke test")
+ parser.add_argument("--api-url", default="http://localhost:8000")
+ parser.add_argument("--question", default="What is the UltimateRAG V2 smoke marker?")
+ return parser.parse_args()
+
+
+def require_success(response: httpx.Response, operation: str) -> None:
+ """把非成功响应转换为带有限正文的验收错误。"""
+
+ if response.is_success:
+ return
+ raise RuntimeError(f"{operation} failed: HTTP {response.status_code}: {response.text[:1000]}")
+
+
+def ui_stream_events(response: httpx.Response) -> Iterator[dict[str, Any]]:
+ """从 AI SDK SSE 中解析 JSON Event。"""
+
+ for line in response.iter_lines():
+ if not line or not line.startswith("data: "):
+ continue
+ payload = line.removeprefix("data: ")
+ if payload == "[DONE]":
+ return
+ event = json.loads(payload)
+ if not isinstance(event, dict):
+ raise RuntimeError("stream event must be a JSON object")
+ yield event
+
+
+def build_documents() -> list[SmokeDocument]:
+ """在内存中生成全部 V2 格式,避免仓库长期保存二进制 Fixture。"""
+
+ marker = "UltimateRAG V2 smoke marker is 8291."
+ documents = [
+ SmokeDocument(
+ "smoke.md",
+ "text/markdown",
+ f"# V2 Smoke\n\n{marker}".encode(),
+ "markdown",
+ ),
+ SmokeDocument(
+ "smoke.html",
+ "text/html",
+ f"V2 Smoke
{marker}
".encode(),
+ "html",
+ ),
+ ]
+
+ word = WordDocument()
+ word.add_heading("V2 Smoke", level=1)
+ word.add_paragraph(marker)
+ word_buffer = BytesIO()
+ word.save(word_buffer)
+ documents.append(
+ SmokeDocument(
+ "smoke.docx",
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
+ word_buffer.getvalue(),
+ "docx",
+ )
+ )
+
+ workbook = Workbook()
+ sheet = workbook.active
+ sheet.title = "Smoke"
+ sheet.append(["Name", "Value"])
+ sheet.append(["UltimateRAG V2 smoke marker", 8291])
+ excel_buffer = BytesIO()
+ workbook.save(excel_buffer)
+ documents.append(
+ SmokeDocument(
+ "smoke.xlsx",
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ excel_buffer.getvalue(),
+ "xlsx",
+ )
+ )
+
+ presentation = Presentation()
+ slide = presentation.slides.add_slide(presentation.slide_layouts[1])
+ slide.shapes.title.text = "V2 Smoke"
+ text_box = slide.shapes.add_textbox(Inches(1), Inches(2), Inches(7), Inches(1))
+ text_box.text = marker
+ ppt_buffer = BytesIO()
+ presentation.save(ppt_buffer)
+ documents.append(
+ SmokeDocument(
+ "smoke.pptx",
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation",
+ ppt_buffer.getvalue(),
+ "pptx",
+ )
+ )
+
+ # 大号高对比英文让 OCR Smoke Test 对本地字体环境不敏感;同一图像同时用于图片和扫描 PDF。
+ image = Image.new("RGB", (1400, 320), "white")
+ font = ImageFont.load_default(size=52)
+ ImageDraw.Draw(image).text((40, 110), marker, fill="black", font=font)
+ png_buffer = BytesIO()
+ image.save(png_buffer, format="PNG")
+ documents.append(SmokeDocument("smoke.png", "image/png", png_buffer.getvalue(), "image-ocr"))
+
+ pdf_buffer = BytesIO()
+ image.save(pdf_buffer, format="PDF", resolution=150)
+ documents.append(SmokeDocument("smoke.pdf", "application/pdf", pdf_buffer.getvalue(), "pdf"))
+ return documents
+
+
+def _verify_locator(parser_name: str, payload: dict[str, Any]) -> None:
+ """验证格式特有 Locator 已从向量检索返回,而不只在 Parser 内短暂存在。"""
+
+ locator = payload.get("locator")
+ if not isinstance(locator, dict):
+ raise RuntimeError(f"retrieval result has no locator: {payload}")
+ if parser_name == "pdf" and locator.get("page") is None:
+ raise RuntimeError(f"PDF retrieval result has no page: {payload}")
+ if parser_name == "xlsx" and not locator.get("sheet"):
+ raise RuntimeError(f"XLSX retrieval result has no sheet: {payload}")
+ if parser_name == "pptx" and locator.get("slide") is None:
+ raise RuntimeError(f"PPTX retrieval result has no slide: {payload}")
+
+
+def run_smoke_test(api_url: str, question: str) -> None:
+ """执行 Create → Upload All → Retrieve → Stream Chat → Delete 完整验收。"""
+
+ knowledge_base_id: str | None = None
+ primary_error: Exception | None = None
+ cleanup_error: str | None = None
+ documents = build_documents()
+ with httpx.Client(base_url=api_url.rstrip("/"), timeout=300.0, trust_env=False) as client:
+ try:
+ health = client.get("/api/health")
+ require_success(health, "health check")
+ create = client.post(
+ "/api/knowledge-bases",
+ json={
+ "name": f"V2 Smoke {uuid4().hex[:8]}",
+ "description": "由 scripts/smoke_v2.py 创建,可安全清理。",
+ },
+ )
+ require_success(create, "create knowledge base")
+ knowledge_base_id = str(create.json()["id"])
+ print(f"[1/5] Knowledge base created: {knowledge_base_id}")
+
+ uploaded: dict[str, str] = {}
+ for document in documents:
+ response = client.post(
+ f"/api/knowledge-bases/{knowledge_base_id}/documents",
+ files={
+ "file": (document.filename, document.content, document.mime_type),
+ },
+ )
+ require_success(response, f"upload {document.filename}")
+ payload = response.json()
+ if payload.get("status") != "READY":
+ raise RuntimeError(f"{document.filename} did not reach READY: {payload}")
+ if payload.get("parser_name") != document.parser_name:
+ raise RuntimeError(
+ f"{document.filename} selected {payload.get('parser_name')}, "
+ f"expected {document.parser_name}"
+ )
+ uploaded[str(payload["id"])] = document.parser_name
+ print(f"[2/5] All {len(documents)} V2 formats reached READY")
+
+ retrieval = client.post(
+ "/api/retrieval/search",
+ json={"knowledge_base_id": knowledge_base_id, "query": question, "top_k": 20},
+ )
+ require_success(retrieval, "dense retrieval")
+ results = retrieval.json()
+ if not results:
+ raise RuntimeError("retrieval returned no chunks")
+ located_formats: set[str] = set()
+ for result in results:
+ parser_name = uploaded.get(str(result.get("document_id")))
+ if parser_name in {"pdf", "xlsx", "pptx"}:
+ _verify_locator(parser_name, result)
+ located_formats.add(parser_name)
+ if located_formats != {"pdf", "xlsx", "pptx"}:
+ raise RuntimeError(
+ "top-20 retrieval did not return every locator format: "
+ f"found={sorted(located_formats)}"
+ )
+ print(f"[3/5] Retrieval returned {len(results)} chunks with V2 locators")
+
+ with client.stream(
+ "POST",
+ "/api/chat/stream",
+ json={"knowledge_base_id": knowledge_base_id, "question": question, "top_k": 5},
+ ) as stream:
+ require_success(stream, "stream chat")
+ events = list(ui_stream_events(stream))
+ event_types = [str(event.get("type")) for event in events]
+ answer = "".join(
+ str(event.get("delta", "")) for event in events if event.get("type") == "text-delta"
+ )
+ evidence = [event for event in events if event.get("type") == "data-retrieval"]
+ if not answer.strip() or not evidence or "finish" not in event_types:
+ raise RuntimeError(f"invalid chat stream: events={event_types}, answer={answer!r}")
+ evidence_data = evidence[0].get("data")
+ if not isinstance(evidence_data, dict) or not evidence_data.get("citations"):
+ raise RuntimeError("stream retrieval data contains no citations")
+ print(f"[4/5] Stream answer and citations verified ({len(answer)} chars)")
+ except Exception as exc:
+ primary_error = exc
+ finally:
+ if knowledge_base_id is not None:
+ try:
+ cleanup = client.delete(f"/api/knowledge-bases/{knowledge_base_id}")
+ except httpx.HTTPError as exc:
+ cleanup_error = f"knowledge_base_id={knowledge_base_id}, cleanup error: {exc}"
+ else:
+ if cleanup.is_success:
+ print("[5/5] Temporary knowledge base deleted")
+ else:
+ cleanup_error = (
+ f"knowledge_base_id={knowledge_base_id}, cleanup HTTP "
+ f"{cleanup.status_code}: {cleanup.text[:500]}"
+ )
+
+ if primary_error is not None:
+ if cleanup_error:
+ primary_error.add_note(f"Additional cleanup failure: {cleanup_error}")
+ raise primary_error
+ if cleanup_error:
+ raise RuntimeError(cleanup_error)
+
+
+def main() -> None:
+ """运行命令行验收并输出明确发布信号。"""
+
+ arguments = parse_arguments()
+ run_smoke_test(arguments.api_url, arguments.question)
+ print("UltimateRAG V2 smoke test passed.")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/src/ultimate_rag/__init__.py b/src/ultimate_rag/__init__.py
index 43ddb6b..4a44cd4 100644
--- a/src/ultimate_rag/__init__.py
+++ b/src/ultimate_rag/__init__.py
@@ -1,3 +1,3 @@
-"""UltimateRAG V1 package."""
+"""UltimateRAG V2 Document Intelligence package."""
-__version__ = "1.0.0"
+__version__ = "2.0.0"
diff --git a/src/ultimate_rag/application/context.py b/src/ultimate_rag/application/context.py
index 3c11777..2cdbeaa 100644
--- a/src/ultimate_rag/application/context.py
+++ b/src/ultimate_rag/application/context.py
@@ -48,11 +48,12 @@ def build(self, results: list[RetrievalResult]) -> str:
for index, result in enumerate(results, start=1):
# 每个 Section 都携带用户可读文件名和标题路径。没有标题时使用明确占位文本,
# 避免空标签让模型和用户误以为来源元数据在格式化时丢失。
- heading = " > ".join(result.heading_path) or "未命名章节"
+ locator = result.locator.display() if result.locator else ""
+ heading = locator or " > ".join(result.heading_path) or "未提供原文定位"
section = (
f"[来源 {index}]\n"
f"文档:{result.filename}\n"
- f"章节:{heading}\n"
+ f"位置:{heading}\n"
f"内容:\n{result.content}"
)
diff --git a/src/ultimate_rag/application/services.py b/src/ultimate_rag/application/services.py
index 6f5913b..f1983fd 100644
--- a/src/ultimate_rag/application/services.py
+++ b/src/ultimate_rag/application/services.py
@@ -1,11 +1,11 @@
-"""V1 文档生命周期、检索与生成应用服务。
+"""V2 文档生命周期、检索与生成应用服务。
模块职责:
以普通 Python Service 显式编排两条 RAG 主链路:
``Upload → Parse → Chunk → Embed → Index`` 与 ``Query → Retrieve → Generate``。
架构边界:
- 本模块只依赖领域模型、领域端口和 Repository,不实现 Markdown 语法、Embedding 协议、
+ 本模块只依赖领域模型、领域端口和 Repository,不实现具体文件语法、Embedding 协议、
Milvus SDK 或 HTTP 路由。基础设施异常在这里转换为用户可理解的业务失败状态。
设计背景:
@@ -42,7 +42,7 @@
class IngestionService:
- """编排 Markdown 从上传字节到可检索向量的同步摄取流程。
+ """编排多格式文档从上传字节到可检索向量的同步摄取流程。
本类位于 Application 层,负责输入边界、阶段顺序、文档状态和失败语义;Parser、Chunker、
Embedder 与 VectorStore 的具体算法或外部协议由注入的端口实现,不在本类中处理。
@@ -51,16 +51,6 @@ class IngestionService:
只有 PostgreSQL Chunk 与 Milvus 向量均成功写入后,文档才进入 ``READY``。
"""
- # 浏览器和操作系统对 Markdown 的声明不统一,允许常见文本类型与通用二进制回退值。
- MARKDOWN_MIME_TYPES = frozenset(
- {
- "text/markdown",
- "text/plain",
- "application/x-markdown",
- "application/octet-stream",
- }
- )
-
def __init__(
self,
*,
@@ -88,13 +78,13 @@ async def ingest(
mime_type: str,
content: bytes,
) -> Document:
- """校验、保存并同步处理一份 Markdown 文档。
+ """校验、保存并同步处理一份 V2 支持的文档。
Args:
knowledge_base_id: 文档所属知识库 ID。
filename: 浏览器上传的展示文件名;只取 basename,不用于构造本地路径。
- mime_type: 客户端声明的 MIME 类型,缺失时回退为 ``text/markdown``。
- content: 原始文件字节,大小和 UTF-8 编码会在管线中验证。
+ mime_type: 客户端声明的 MIME 类型,缺失时回退为通用二进制类型。
+ content: 原始文件字节;Parser 会继续验证实际格式和内容。
Returns:
已完成处理且状态为 ``READY`` 的文档。
@@ -117,8 +107,8 @@ async def ingest(
# 真正的 Object Key 会使用系统 UUID 构造,因此同名上传不会覆盖,也不能路径穿越。
safe_filename = PurePath(filename).name
extension = PurePath(safe_filename).suffix.lower()
- if extension not in {".md", ".markdown"}:
- raise InvalidDocumentError("V1 仅支持 .md 或 .markdown 文件")
+ if not safe_filename or not extension:
+ raise InvalidDocumentError("上传文件必须包含安全的文件名和扩展名")
if not content:
raise InvalidDocumentError("上传文件不能为空")
if len(content) > self._max_upload_bytes:
@@ -126,11 +116,8 @@ async def ingest(
# MIME 可能包含 ``charset`` 参数,比较前先归一化主类型。MIME 仍只是客户端声明,
# 所以后续 Parser 必须继续验证 UTF-8 解码和实际文本内容,不能把它当作可信证据。
- normalized_mime_type = (
- mime_type.split(";", maxsplit=1)[0].strip().lower() or "text/markdown"
- )
- if normalized_mime_type not in self.MARKDOWN_MIME_TYPES:
- raise InvalidDocumentError(f"不支持的 Markdown MIME 类型:{normalized_mime_type}")
+ normalized_mime_type = mime_type.split(";", maxsplit=1)[0].strip().lower()
+ normalized_mime_type = normalized_mime_type or "application/octet-stream"
# 阶段 2:为原始文件生成稳定的系统定位信息,然后先保存文件、再创建文档事实。
# Object Key 使用知识库 ID 与文档 UUID 隔离对象;SHA-256 记录上传内容指纹,
@@ -139,6 +126,12 @@ async def ingest(
object_key = f"{knowledge_base_id}/{document_id}/source{extension}"
sha256 = hashlib.sha256(content).hexdigest()
+ # 在保存原文件前让 Registry 同时检查扩展名和 MIME,未知格式不会产生 MinIO 孤儿对象。
+ # Parser 后续仍需检查真实文件签名/结构,因为 MIME 与扩展名都来自不可信客户端。
+ self._parser_registry.resolve(
+ DocumentSource(document_id, safe_filename, normalized_mime_type, content)
+ )
+
# 原文件先于解析和索引持久化。进入处理阶段后即使失败,也不要求用户重新上传,
# 并且可以使用 MinIO 中的事实数据重新构建 PostgreSQL Chunk 与 Milvus 派生索引。
await self._storage.put(object_key, content, normalized_mime_type)
@@ -221,7 +214,17 @@ async def _process(self, document: Document, content: bytes) -> None:
# Chunk 保持不可变;使用 dataclasses.replace 只添加展示用文件名。Milvus 检索命中后
# 可以直接构造 Citation,避免为了每个 Hit 再查询一次 PostgreSQL 形成 N+1。
- chunks = [replace(chunk, metadata={"filename": document.filename}) for chunk in chunks]
+ chunks = [
+ replace(
+ chunk,
+ metadata={
+ **chunk.metadata,
+ "filename": document.filename,
+ "source_locator": chunk.locator.to_metadata() if chunk.locator else {},
+ },
+ )
+ for chunk in chunks
+ ]
# 阶段 3 — Embed:应用层一次提交全部文本,Adapter 再按供应商 Batch 上限有界分批。
# 这样业务流程不依赖百炼限制,也避免每个 Chunk 单独发一次网络请求。
@@ -390,6 +393,7 @@ async def _prepare_generation(
filename=result.filename,
chunk_id=result.chunk_id,
heading_path=result.heading_path,
+ locator=result.locator,
)
for result in results
]
diff --git a/src/ultimate_rag/chunkers/__init__.py b/src/ultimate_rag/chunkers/__init__.py
index cc330dd..f988c0f 100644
--- a/src/ultimate_rag/chunkers/__init__.py
+++ b/src/ultimate_rag/chunkers/__init__.py
@@ -1,5 +1,5 @@
"""文档切块策略公开入口。"""
-from ultimate_rag.chunkers.markdown import StructureAwareMarkdownChunker
+from ultimate_rag.chunkers.markdown import StructureAwareChunker, StructureAwareMarkdownChunker
-__all__ = ["StructureAwareMarkdownChunker"]
+__all__ = ["StructureAwareChunker", "StructureAwareMarkdownChunker"]
diff --git a/src/ultimate_rag/chunkers/markdown.py b/src/ultimate_rag/chunkers/markdown.py
index 47395d9..1238ed5 100644
--- a/src/ultimate_rag/chunkers/markdown.py
+++ b/src/ultimate_rag/chunkers/markdown.py
@@ -23,11 +23,11 @@
import re
from uuid import NAMESPACE_URL, uuid5
-from ultimate_rag.domain.models import BlockType, Chunk, ParsedDocument
+from ultimate_rag.domain.models import BlockType, Chunk, ParsedDocument, SourceLocator
-class StructureAwareMarkdownChunker:
- """按章节结构和字符预算生成可追溯、可重复计算的 Chunk。
+class StructureAwareChunker:
+ """按来源位置、章节结构和字符预算生成可追溯的 Chunk。
本类是无状态切块策略;构造后只保存字符预算。相同 ``document_id``、Block 顺序和内容
会生成相同 Chunk ID,以支持摄取重试时的数据库替换与向量 Upsert。
@@ -58,25 +58,25 @@ async def split(self, document: ParsedDocument, knowledge_base_id: str) -> list[
# 阶段 1 — Build Sections:先把连续正文 Block 聚合到各自的标题路径下。
# 标题路径与正文一起保存,章节稍后即使拆成多个 Chunk,Citation 仍能定位原始章节。
- sections: list[tuple[tuple[str, ...], str]] = []
- current_path: tuple[str, ...] = ()
+ sections: list[tuple[SourceLocator, str]] = []
+ current_locator = SourceLocator()
current_parts: list[str] = []
for block in document.blocks:
- block_path = block.locator.heading_path if block.locator else ()
+ block_locator = block.locator or SourceLocator()
if block.type == BlockType.HEADING:
# Heading Block 标记上一章节结束。标题文字已经存在于 heading_path,
# 因此不把它重复追加到正文;最终由 _with_heading() 统一添加一次完整路径。
- self._flush_section(sections, current_path, current_parts)
- current_path = block_path
+ self._flush_section(sections, current_locator, current_parts)
+ current_locator = block_locator
current_parts = []
continue
- if block_path != current_path and current_parts:
- # Markdown Parser 通常会先产生 Heading Block;这个保护分支也支持未来 Parser
- # 直接改变 SourceLocator,避免不同来源路径的正文被错误合并为同一章节。
- self._flush_section(sections, current_path, current_parts)
+ if block_locator != current_locator and current_parts:
+ # V2 的页码、工作表、单元格范围或幻灯片变化都必须形成新的来源区间;
+ # 不能只比较标题路径,否则 PDF 相邻页面会被合并并丢失精确引用位置。
+ self._flush_section(sections, current_locator, current_parts)
current_parts = []
- current_path = block_path
+ current_locator = block_locator
# CODE Block 只保存代码正文,这里恢复通用围栏,让 Embedding 输入仍能区分代码
# 与普通段落。V1 没有在领域模型中保存原始语言标记,因此不会尝试伪造语言名称。
@@ -85,12 +85,13 @@ async def split(self, document: ParsedDocument, knowledge_base_id: str) -> list[
current_parts.append(f"{prefix}{block.content}{suffix}")
# 最后一个章节后面没有新的 Heading 触发 Flush,循环结束时必须显式提交。
- self._flush_section(sections, current_path, current_parts)
+ self._flush_section(sections, current_locator, current_parts)
# 阶段 2 — Split Sections:短章节保持完整,只有超预算章节才按段落和窗口继续切分。
# 标题路径会写入每一段最终文本,使向量自身也携带局部结构语义。
chunks: list[Chunk] = []
- for heading_path, section in sections:
+ for locator, section in sections:
+ heading_path = locator.heading_path
for piece in self._split_text(section):
content = self._with_heading(heading_path, piece)
index = len(chunks)
@@ -113,20 +114,21 @@ async def split(self, document: ParsedDocument, knowledge_base_id: str) -> list[
content=content,
heading_path=heading_path,
token_count=self._estimate_tokens(content),
+ locator=locator,
)
)
return chunks
@staticmethod
def _flush_section(
- sections: list[tuple[tuple[str, ...], str]],
- heading_path: tuple[str, ...],
+ sections: list[tuple[SourceLocator, str]],
+ locator: SourceLocator,
parts: list[str],
) -> None:
"""把当前非空章节规范化后追加到待切分列表。"""
content = "\n\n".join(part.strip() for part in parts if part.strip()).strip()
if content:
- sections.append((heading_path, content))
+ sections.append((locator, content))
def _split_text(self, text: str) -> list[str]:
"""优先把完整段落装入字符窗口,只对超长段落使用带 Overlap 的硬切分。
@@ -192,3 +194,7 @@ def _estimate_tokens(text: str) -> int:
other_words = len(re.findall(r"[A-Za-z0-9_]+", text))
punctuation = len(re.findall(r"[^\w\s\u4e00-\u9fff]", text))
return chinese_chars + other_words + punctuation // 2
+
+
+# 兼容 V1 的公开类名,已有调用方无需迁移;新代码使用格式无关名称表达真实职责。
+StructureAwareMarkdownChunker = StructureAwareChunker
diff --git a/src/ultimate_rag/config.py b/src/ultimate_rag/config.py
index 3cae493..54b9d4c 100644
--- a/src/ultimate_rag/config.py
+++ b/src/ultimate_rag/config.py
@@ -11,7 +11,7 @@
class Settings(BaseSettings):
- """UltimateRAG V1 的集中配置模型。
+ """UltimateRAG V2 的集中配置模型。
默认值面向本地 Docker Compose 开发环境;密钥必须由环境变量或未提交的 ``.env`` 提供。
"""
@@ -28,7 +28,7 @@ class Settings(BaseSettings):
log_level: str = "INFO"
# NoDecode 把原始字符串交给下方校验器,避免 Settings 在校验前强制按 JSON 解码。
cors_origins: Annotated[list[str], NoDecode] = Field(
- default_factory=lambda: ["http://localhost:3000"]
+ default_factory=lambda: ["http://localhost:3000", "http://192.168.3.19:3000"]
)
database_url: str = "postgresql+asyncpg://ultimate_rag:ultimate_rag@localhost:5432/ultimate_rag"
@@ -49,6 +49,9 @@ class Settings(BaseSettings):
embedding_dimension: int = 1024
embedding_batch_size: int = 10
llm_model: str = "qwen-plus"
+ ocr_model: str = "qwen-vl-ocr-latest"
+ # 百炼 Base64 OCR 接口要求原图小于 7 MB;默认留出编码和服务端校验余量。
+ ocr_max_image_bytes: int = 6 * 1024 * 1024
model_timeout_seconds: float = 60.0
max_upload_bytes: int = 10 * 1024 * 1024
@@ -56,6 +59,8 @@ class Settings(BaseSettings):
chunk_overlap_chars: int = 160
retrieval_top_k: int = 5
context_max_chars: int = 12000
+ pdf_native_text_threshold: int = 20
+ pdf_render_scale: float = 1.5
@field_validator("cors_origins", mode="before")
@classmethod
diff --git a/src/ultimate_rag/domain/models.py b/src/ultimate_rag/domain/models.py
index ba1833a..52c6220 100644
--- a/src/ultimate_rag/domain/models.py
+++ b/src/ultimate_rag/domain/models.py
@@ -4,6 +4,7 @@
或模型厂商 SDK。
"""
+from collections.abc import Mapping
from dataclasses import dataclass, field
from datetime import datetime
from enum import StrEnum
@@ -25,20 +26,95 @@ class DocumentStatus(StrEnum):
class BlockType(StrEnum):
- """统一文档模型中 V1 已识别的语义块类型。"""
+ """统一文档模型中可由不同 Parser 产生的语义块类型。"""
HEADING = "HEADING"
TEXT = "TEXT"
CODE = "CODE"
LIST = "LIST"
QUOTE = "QUOTE"
+ TABLE = "TABLE"
+ IMAGE = "IMAGE"
@dataclass(frozen=True, slots=True)
class SourceLocator:
- """内容在原文中的可追溯位置;V1 使用 Markdown 标题路径定位。"""
+ """跨文档格式的来源位置,供 Chunk、检索结果和 Citation 统一复用。
+
+ 字段均为可选,因为不同格式能提供的定位精度不同:Markdown/HTML 使用标题路径,PDF
+ 使用页码与可选边界框,Excel 使用 Sheet 与单元格范围,PowerPoint 使用幻灯片序号。
+ """
heading_path: tuple[str, ...] = ()
+ page: int | None = None
+ bbox: tuple[float, float, float, float] | None = None
+ sheet: str | None = None
+ cell_range: str | None = None
+ slide: int | None = None
+
+ def to_metadata(self) -> dict[str, JsonValue]:
+ """转换为 PostgreSQL JSONB、Milvus JSON 和 API 都可接受的稳定字典。"""
+
+ value: dict[str, JsonValue] = {"heading_path": list(self.heading_path)}
+ if self.page is not None:
+ value["page"] = self.page
+ if self.bbox is not None:
+ value["bbox"] = list(self.bbox)
+ if self.sheet is not None:
+ value["sheet"] = self.sheet
+ if self.cell_range is not None:
+ value["cell_range"] = self.cell_range
+ if self.slide is not None:
+ value["slide"] = self.slide
+ return value
+
+ @classmethod
+ def from_metadata(cls, value: Mapping[str, object] | None) -> "SourceLocator":
+ """兼容缺失字段并从持久化 JSON 恢复强类型定位信息。"""
+
+ if not value:
+ return cls()
+ raw_bbox = value.get("bbox")
+ bbox = None
+ if isinstance(raw_bbox, list) and len(raw_bbox) == 4:
+ bbox = (
+ float(raw_bbox[0]),
+ float(raw_bbox[1]),
+ float(raw_bbox[2]),
+ float(raw_bbox[3]),
+ )
+ raw_heading = value.get("heading_path")
+ heading_path = (
+ tuple(str(item) for item in raw_heading) if isinstance(raw_heading, list) else ()
+ )
+ raw_page = value.get("page")
+ page = int(raw_page) if isinstance(raw_page, (str, int, float)) else None
+ raw_slide = value.get("slide")
+ slide = int(raw_slide) if isinstance(raw_slide, (str, int, float)) else None
+ return cls(
+ heading_path=heading_path,
+ page=page,
+ bbox=bbox,
+ sheet=str(value["sheet"]) if value.get("sheet") is not None else None,
+ cell_range=(str(value["cell_range"]) if value.get("cell_range") is not None else None),
+ slide=slide,
+ )
+
+ def display(self) -> str:
+ """生成面向 Prompt 和前端的紧凑定位文本。"""
+
+ parts: list[str] = []
+ if self.heading_path:
+ parts.append(" / ".join(self.heading_path))
+ if self.page is not None:
+ parts.append(f"第 {self.page} 页")
+ if self.sheet:
+ parts.append(f"工作表 {self.sheet}")
+ if self.cell_range:
+ parts.append(f"区域 {self.cell_range}")
+ if self.slide is not None:
+ parts.append(f"第 {self.slide} 张幻灯片")
+ return " · ".join(parts) or "未提供原文定位"
@dataclass(frozen=True, slots=True)
@@ -82,6 +158,7 @@ class Chunk:
content: str
heading_path: tuple[str, ...]
token_count: int
+ locator: SourceLocator | None = None
metadata: dict[str, JsonValue] = field(default_factory=dict)
@@ -104,6 +181,7 @@ class RetrievalResult:
content: str
heading_path: tuple[str, ...]
score: float
+ locator: SourceLocator | None = None
@dataclass(frozen=True, slots=True)
@@ -114,6 +192,7 @@ class Citation:
filename: str
chunk_id: str
heading_path: tuple[str, ...]
+ locator: SourceLocator | None = None
@dataclass(frozen=True, slots=True)
diff --git a/src/ultimate_rag/domain/ports.py b/src/ultimate_rag/domain/ports.py
index 22c75d9..74f53f2 100644
--- a/src/ultimate_rag/domain/ports.py
+++ b/src/ultimate_rag/domain/ports.py
@@ -1,6 +1,6 @@
"""核心可替换能力的最小端口协议。
-协议由应用层依赖、外围适配器实现;这里只描述当前 V1 真实需要的行为,不预设未来插件运行时。
+协议由应用层依赖、外围适配器实现;这里只描述当前 V2 真实需要的行为,不预设未来插件运行时。
"""
from collections.abc import AsyncIterator, Sequence
@@ -113,3 +113,11 @@ def stream(self, system_prompt: str, user_prompt: str) -> AsyncIterator[str]:
不需要知道 OpenAI-Compatible SDK 的 Chunk 结构,也不会用假逐字动画掩盖模型延迟。
"""
...
+
+
+class OCRClient(Protocol):
+ """把图片字节转换为纯文本的视觉 OCR 边界。"""
+
+ async def extract_text(self, image: bytes, mime_type: str) -> str:
+ """识别单张可信格式图片,返回保持阅读顺序的非空文本。"""
+ ...
diff --git a/src/ultimate_rag/infrastructure/database/repository.py b/src/ultimate_rag/infrastructure/database/repository.py
index 002fd64..9fd2251 100644
--- a/src/ultimate_rag/infrastructure/database/repository.py
+++ b/src/ultimate_rag/infrastructure/database/repository.py
@@ -20,7 +20,7 @@
class Repository:
- """封装 V1 知识库、文档和 Chunk 的数据库读写。"""
+ """封装知识库、文档和 Chunk 的数据库事实读写。"""
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
"""接收可复用 Session 工厂,每个公共操作自行定义短事务。"""
diff --git a/src/ultimate_rag/infrastructure/storage/minio.py b/src/ultimate_rag/infrastructure/storage/minio.py
index 9f5af14..ff1f22e 100644
--- a/src/ultimate_rag/infrastructure/storage/minio.py
+++ b/src/ultimate_rag/infrastructure/storage/minio.py
@@ -40,7 +40,7 @@ def ensure() -> None:
await asyncio.to_thread(ensure)
async def put(self, object_key: str, content: bytes, content_type: str) -> None:
- """上传内存中的 V1 文档内容,并保存标准 MIME 类型。"""
+ """上传内存中的原始文档内容,并保存标准 MIME 类型。"""
await asyncio.to_thread(
self._client.put_object,
self._bucket,
diff --git a/src/ultimate_rag/ocr/__init__.py b/src/ultimate_rag/ocr/__init__.py
new file mode 100644
index 0000000..e3bd132
--- /dev/null
+++ b/src/ultimate_rag/ocr/__init__.py
@@ -0,0 +1,5 @@
+"""图片文字识别适配器公开入口。"""
+
+from ultimate_rag.ocr.bailian import BailianOCRClient
+
+__all__ = ["BailianOCRClient"]
diff --git a/src/ultimate_rag/ocr/bailian.py b/src/ultimate_rag/ocr/bailian.py
new file mode 100644
index 0000000..d9e7d0c
--- /dev/null
+++ b/src/ultimate_rag/ocr/bailian.py
@@ -0,0 +1,91 @@
+"""阿里云百炼 Qwen-OCR 适配器。
+
+模块职责:
+ 把图片字节编码为 Data URL,通过百炼 OpenAI-Compatible Chat Completions API 提取文本。
+
+架构边界:
+ 本模块只实现 ``OCRClient`` 外部协议,不判断文件扩展名、不渲染 PDF,也不创建领域 Block。
+ 图片验证、页码定位和 Parser 选择分别由 Image/PDF Parser 与 Parser Registry 负责。
+
+外部约束:
+ 百炼 OpenAI 兼容接口的 Base64 图片原文件必须小于 7 MB。调用使用配置提供的有界超时,
+ 不做无限重试;空响应和超限输入都显式失败,避免把空 OCR 文本继续送入 Embedding。
+"""
+
+import base64
+from typing import cast
+
+from openai import AsyncOpenAI
+from openai.types.chat import ChatCompletionMessageParam
+
+
+class BailianOCRClient:
+ """使用 Qwen-OCR 把单张图片识别为保持阅读顺序的纯文本。"""
+
+ _PROMPT = (
+ "请按自然阅读顺序提取图片中的全部可见文字。保留标题、段落、列表和表格的行列关系,"
+ "表格使用 Markdown 表格表示。只输出识别结果,不要解释、总结或编造不可见内容。"
+ )
+
+ def __init__(
+ self,
+ *,
+ api_key: str,
+ base_url: str,
+ model: str,
+ max_image_bytes: int,
+ timeout: float,
+ ) -> None:
+ """创建可复用客户端并固定 OCR 模型与单图字节上限。"""
+
+ self._client = AsyncOpenAI(api_key=api_key, base_url=base_url, timeout=timeout)
+ self._model = model
+ self._max_image_bytes = max_image_bytes
+
+ async def extract_text(self, image: bytes, mime_type: str) -> str:
+ """识别图片并拒绝空内容或超过百炼 Base64 接口限制的输入。
+
+ Args:
+ image: 已经由 Parser 验证过格式的图片原始字节。
+ mime_type: Data URL 使用的标准图片 MIME 类型。
+
+ Returns:
+ 去除首尾空白但保留内部换行的 OCR 文本。
+
+ Raises:
+ ValueError: 图片为空、MIME 非图片或超过配置上限。
+ RuntimeError: 模型没有返回可索引文字。
+ """
+
+ if not image:
+ raise ValueError("OCR 图片不能为空")
+ if not mime_type.startswith("image/"):
+ raise ValueError(f"OCR 只接受图片 MIME:{mime_type}")
+ if len(image) > self._max_image_bytes:
+ limit_mb = self._max_image_bytes // (1024 * 1024)
+ raise ValueError(f"OCR 图片不能超过 {limit_mb} MB")
+
+ data_url = f"data:{mime_type};base64,{base64.b64encode(image).decode('ascii')}"
+ # OpenAI SDK 的 TypedDict 联合类型无法从嵌套字面量稳定推导;cast 只隔离 SDK 类型,
+ # 请求结构仍严格遵循百炼 image_url + text 的 OpenAI-Compatible 协议。
+ messages = cast(
+ list[ChatCompletionMessageParam],
+ [
+ {
+ "role": "user",
+ "content": [
+ {"type": "image_url", "image_url": {"url": data_url}},
+ {"type": "text", "text": self._PROMPT},
+ ],
+ }
+ ],
+ )
+ response = await self._client.chat.completions.create(
+ model=self._model,
+ messages=messages,
+ temperature=0,
+ )
+ content = response.choices[0].message.content
+ if not content or not content.strip():
+ raise RuntimeError("OCR model returned empty text")
+ return content.strip()
diff --git a/src/ultimate_rag/parsers/__init__.py b/src/ultimate_rag/parsers/__init__.py
index 56e06f9..e150dc5 100644
--- a/src/ultimate_rag/parsers/__init__.py
+++ b/src/ultimate_rag/parsers/__init__.py
@@ -1,6 +1,19 @@
"""文档解析器与解析器注册表公开入口。"""
+from ultimate_rag.parsers.html import HtmlParser
+from ultimate_rag.parsers.image import ImageOCRParser
from ultimate_rag.parsers.markdown import MarkdownParser
+from ultimate_rag.parsers.office import ExcelParser, PowerPointParser, WordParser
+from ultimate_rag.parsers.pdf import PDFParser
from ultimate_rag.parsers.registry import ParserRegistry
-__all__ = ["MarkdownParser", "ParserRegistry"]
+__all__ = [
+ "ExcelParser",
+ "HtmlParser",
+ "ImageOCRParser",
+ "MarkdownParser",
+ "ParserRegistry",
+ "PDFParser",
+ "PowerPointParser",
+ "WordParser",
+]
diff --git a/src/ultimate_rag/parsers/_shared.py b/src/ultimate_rag/parsers/_shared.py
new file mode 100644
index 0000000..009b38e
--- /dev/null
+++ b/src/ultimate_rag/parsers/_shared.py
@@ -0,0 +1,100 @@
+"""多格式 Parser 共用的安全校验与领域映射小工具。"""
+
+from collections.abc import Sequence
+from io import BytesIO
+from pathlib import PurePosixPath
+from uuid import NAMESPACE_URL, uuid5
+from zipfile import BadZipFile, ZipFile
+
+from ultimate_rag.domain.exceptions import InvalidDocumentError
+from ultimate_rag.domain.models import Block, BlockType, DocumentSource, SourceLocator
+
+
+def source_extension(source: DocumentSource) -> str:
+ """统一按 POSIX 分隔符读取已净化文件名扩展名。"""
+
+ return PurePosixPath(source.filename.replace("\\", "/")).suffix.lower()
+
+
+def source_mime(source: DocumentSource) -> str:
+ """移除 MIME 参数并转换为小写,兼容浏览器附带 charset。"""
+
+ return source.mime_type.split(";", maxsplit=1)[0].strip().lower()
+
+
+def supports_source(
+ source: DocumentSource,
+ extensions: frozenset[str],
+ mime_types: frozenset[str],
+) -> bool:
+ """同时校验扩展名与 MIME,通用二进制 MIME 交给 Parser 检查实际结构。"""
+
+ return source_extension(source) in extensions and source_mime(source) in (
+ mime_types | {"application/octet-stream"}
+ )
+
+
+def stable_block(
+ document_id: str,
+ index: int,
+ block_type: BlockType,
+ content: str,
+ locator: SourceLocator,
+) -> Block:
+ """使用文档、顺序、类型和内容生成可重复的 Block ID。"""
+
+ block_id = str(
+ uuid5(NAMESPACE_URL, f"{document_id}:block:{index}:{block_type.value}:{content}")
+ )
+ return Block(id=block_id, type=block_type, content=content, locator=locator)
+
+
+def table_to_markdown(rows: Sequence[Sequence[object]]) -> str:
+ """把二维单元格值转换为结构稳定、适合 Embedding 的 Markdown 表格。"""
+
+ normalized = [
+ [
+ str(value).strip().replace("|", "\\|").replace("\n", " ") if value is not None else ""
+ for value in row
+ ]
+ for row in rows
+ ]
+ normalized = [row for row in normalized if any(cell for cell in row)]
+ if not normalized:
+ return ""
+ width = max(len(row) for row in normalized)
+ padded = [row + [""] * (width - len(row)) for row in normalized]
+ header = padded[0]
+ separator = ["---"] * width
+ body = padded[1:]
+ lines = [f"| {' | '.join(header)} |", f"| {' | '.join(separator)} |"]
+ lines.extend(f"| {' | '.join(row)} |" for row in body)
+ return "\n".join(lines)
+
+
+def validate_ooxml_archive(
+ content: bytes,
+ *,
+ max_entries: int = 10_000,
+ max_uncompressed_bytes: int = 100 * 1024 * 1024,
+ max_compression_ratio: int = 200,
+) -> None:
+ """在 Office 库解压前拒绝损坏文件和明显 ZIP Bomb。
+
+ OOXML 本质是 ZIP。仅限制上传压缩包大小不足以限制解压后的内存与 CPU,因此同时约束
+ 条目数量、总解压体积和单条目压缩比。校验只读取 Central Directory,不提取文件。
+ """
+
+ try:
+ with ZipFile(BytesIO(content)) as archive:
+ entries = archive.infolist()
+ except BadZipFile as exc:
+ raise InvalidDocumentError("Office 文件不是有效的 OOXML 压缩包") from exc
+ if len(entries) > max_entries:
+ raise InvalidDocumentError("Office 文件包含过多内部条目")
+ if sum(entry.file_size for entry in entries) > max_uncompressed_bytes:
+ raise InvalidDocumentError("Office 文件解压后内容过大")
+ for entry in entries:
+ compressed = max(entry.compress_size, 1)
+ if entry.file_size > 1024 * 1024 and entry.file_size / compressed > max_compression_ratio:
+ raise InvalidDocumentError("Office 文件包含异常高压缩比条目")
diff --git a/src/ultimate_rag/parsers/html.py b/src/ultimate_rag/parsers/html.py
new file mode 100644
index 0000000..ba06954
--- /dev/null
+++ b/src/ultimate_rag/parsers/html.py
@@ -0,0 +1,139 @@
+"""HTML 到统一文档模型的 Parser。
+
+模块职责:
+ 清理脚本与样式内容,按照 DOM 文档顺序提取标题、正文、列表、引用、代码和表格,并把
+ HTML 结构立即转换为 UltimateRAG 自有的 Block 与 SourceLocator。
+
+安全边界:
+ 本 Parser 只解析上传的静态字节,不访问外部 URL、不执行 JavaScript,也不加载 CSS、图片
+ 或 iframe。V2 不实现网页爬取,避免 SSRF 与不受控网络访问进入文档摄取链路。
+"""
+
+import asyncio
+
+from bs4 import BeautifulSoup, Tag
+
+from ultimate_rag.domain.exceptions import InvalidDocumentError
+from ultimate_rag.domain.models import (
+ Block,
+ BlockType,
+ DocumentSource,
+ ParsedDocument,
+ SourceLocator,
+)
+from ultimate_rag.parsers._shared import stable_block, supports_source, table_to_markdown
+
+
+class HtmlParser:
+ """把静态 HTML 的可见主要内容转换为有序语义 Block。"""
+
+ name = "html"
+ version = "2.0"
+ _EXTENSIONS = frozenset({".html", ".htm"})
+ _MIME_TYPES = frozenset({"text/html", "application/xhtml+xml"})
+ _CONTENT_TAGS = frozenset(
+ {"h1", "h2", "h3", "h4", "h5", "h6", "p", "li", "blockquote", "pre", "table"}
+ )
+
+ def supports(self, source: DocumentSource) -> bool:
+ """同时要求 HTML 扩展名以及 HTML 或通用二进制 MIME。"""
+
+ return supports_source(source, self._EXTENSIONS, self._MIME_TYPES)
+
+ async def parse(self, source: DocumentSource) -> ParsedDocument:
+ """在线程中解析 HTML,避免较大 DOM 构建阻塞事件循环。"""
+
+ return await asyncio.to_thread(self._parse_sync, source)
+
+ def _parse_sync(self, source: DocumentSource) -> ParsedDocument:
+ """清理不可见节点,并保持主要内容在 DOM 中的原始顺序。"""
+
+ try:
+ text = source.content.decode("utf-8-sig")
+ except UnicodeDecodeError as exc:
+ raise InvalidDocumentError("HTML 文件必须使用 UTF-8 编码") from exc
+ if not text.strip():
+ raise InvalidDocumentError("HTML 文件不能为空")
+
+ try:
+ soup = BeautifulSoup(text, "html.parser")
+ for element in soup.find_all(["script", "style", "noscript", "template"]):
+ element.decompose()
+ blocks = self._extract_blocks(source.document_id, soup)
+ except InvalidDocumentError:
+ raise
+ except Exception as exc:
+ raise InvalidDocumentError("HTML 文件损坏或无法解析") from exc
+ if not blocks:
+ raise InvalidDocumentError("HTML 文件没有可索引的可见文本")
+ return ParsedDocument(
+ document_id=source.document_id,
+ blocks=tuple(blocks),
+ metadata={"parser": self.name, "parser_version": self.version},
+ )
+
+ def _extract_blocks(self, document_id: str, soup: BeautifulSoup) -> list[Block]:
+ """提取顶层内容标签,防止同一段嵌套文本被父子标签重复索引。"""
+
+ blocks: list[Block] = []
+ heading_path: list[str] = []
+ for element in soup.find_all(self._CONTENT_TAGS):
+ if not isinstance(element, Tag) or self._has_content_ancestor(element):
+ continue
+ tag_name = element.name.lower()
+ if tag_name == "table":
+ content = self._table_content(element)
+ block_type = BlockType.TABLE
+ else:
+ separator = "\n" if tag_name == "pre" else " "
+ content = element.get_text(separator=separator, strip=True)
+ block_type = self._block_type(tag_name)
+ if not content:
+ continue
+ if tag_name.startswith("h") and len(tag_name) == 2 and tag_name[1].isdigit():
+ level = int(tag_name[1])
+ heading_path = heading_path[: level - 1] + [content]
+ blocks.append(
+ stable_block(
+ document_id,
+ len(blocks),
+ block_type,
+ content,
+ SourceLocator(heading_path=tuple(heading_path)),
+ )
+ )
+ return blocks
+
+ def _has_content_ancestor(self, element: Tag) -> bool:
+ """判断元素是否已包含在另一个将被整体提取的内容节点中。"""
+
+ parent = element.parent
+ while isinstance(parent, Tag):
+ if parent.name.lower() in self._CONTENT_TAGS:
+ return True
+ parent = parent.parent
+ return False
+
+ @staticmethod
+ def _table_content(table: Tag) -> str:
+ """把 HTML 表格按行列转换为稳定 Markdown 表格。"""
+
+ rows = [
+ [cell.get_text(" ", strip=True) for cell in row.find_all(["th", "td"])]
+ for row in table.find_all("tr")
+ ]
+ return table_to_markdown(rows)
+
+ @staticmethod
+ def _block_type(tag_name: str) -> BlockType:
+ """把少量明确 HTML 标签映射为统一 BlockType。"""
+
+ if tag_name.startswith("h"):
+ return BlockType.HEADING
+ if tag_name == "li":
+ return BlockType.LIST
+ if tag_name == "blockquote":
+ return BlockType.QUOTE
+ if tag_name == "pre":
+ return BlockType.CODE
+ return BlockType.TEXT
diff --git a/src/ultimate_rag/parsers/image.py b/src/ultimate_rag/parsers/image.py
new file mode 100644
index 0000000..7852c35
--- /dev/null
+++ b/src/ultimate_rag/parsers/image.py
@@ -0,0 +1,94 @@
+"""常见图片到统一文档模型的 OCR Parser。
+
+模块职责:
+ 使用 Pillow 校验真实图片格式,再调用可替换的 OCRClient 提取文字并生成 Image Block。
+
+架构边界:
+ Parser 不依赖具体模型厂商。百炼调用封装在基础设施适配器中,测试可注入内存 OCR Stub;
+ 图片没有可识别文字时明确失败,不生成无法检索的空 Chunk。
+"""
+
+import asyncio
+from io import BytesIO
+
+from PIL import Image, UnidentifiedImageError
+
+from ultimate_rag.domain.exceptions import InvalidDocumentError
+from ultimate_rag.domain.models import BlockType, DocumentSource, ParsedDocument, SourceLocator
+from ultimate_rag.domain.ports import OCRClient
+from ultimate_rag.parsers._shared import source_extension, source_mime, stable_block
+
+
+class ImageOCRParser:
+ """验证 PNG/JPEG/WEBP/TIFF/BMP 后通过 OCR 提取可检索文字。"""
+
+ name = "image-ocr"
+ version = "2.0"
+ _EXTENSION_FORMATS = {
+ ".png": frozenset({"PNG"}),
+ ".jpg": frozenset({"JPEG"}),
+ ".jpeg": frozenset({"JPEG"}),
+ ".webp": frozenset({"WEBP"}),
+ ".tif": frozenset({"TIFF"}),
+ ".tiff": frozenset({"TIFF"}),
+ ".bmp": frozenset({"BMP"}),
+ }
+ _FORMAT_MIME = {
+ "PNG": "image/png",
+ "JPEG": "image/jpeg",
+ "WEBP": "image/webp",
+ "TIFF": "image/tiff",
+ "BMP": "image/bmp",
+ }
+
+ def __init__(self, ocr_client: OCRClient) -> None:
+ """注入厂商无关 OCR 协议,使解析逻辑可独立测试和替换。"""
+
+ self._ocr_client = ocr_client
+
+ def supports(self, source: DocumentSource) -> bool:
+ """按受支持扩展名与 image MIME 选择图片 Parser。"""
+
+ extension = source_extension(source)
+ mime_type = source_mime(source)
+ return extension in self._EXTENSION_FORMATS and (
+ mime_type.startswith("image/") or mime_type == "application/octet-stream"
+ )
+
+ async def parse(self, source: DocumentSource) -> ParsedDocument:
+ """先验证实际编码格式,再把图片提交 OCR 并构造统一结果。"""
+
+ image_format = await asyncio.to_thread(self._validated_format, source)
+ mime_type = self._FORMAT_MIME[image_format]
+ try:
+ content = (await self._ocr_client.extract_text(source.content, mime_type)).strip()
+ except (ValueError, RuntimeError) as exc:
+ raise InvalidDocumentError(f"图片 OCR 失败:{exc}") from exc
+ if not content:
+ raise InvalidDocumentError("图片没有可索引的识别文字")
+ block = stable_block(
+ source.document_id,
+ 0,
+ BlockType.IMAGE,
+ content,
+ SourceLocator(),
+ )
+ return ParsedDocument(
+ document_id=source.document_id,
+ blocks=(block,),
+ metadata={"parser": self.name, "parser_version": self.version},
+ )
+
+ def _validated_format(self, source: DocumentSource) -> str:
+ """使用图片解码器校验内容,并拒绝扩展名与真实编码不一致。"""
+
+ extension = source_extension(source)
+ try:
+ with Image.open(BytesIO(source.content)) as image:
+ image_format = (image.format or "").upper()
+ image.verify()
+ except (UnidentifiedImageError, OSError, ValueError) as exc:
+ raise InvalidDocumentError("图片文件损坏或格式不受支持") from exc
+ if image_format not in self._EXTENSION_FORMATS[extension]:
+ raise InvalidDocumentError("图片扩展名与实际编码格式不一致")
+ return image_format
diff --git a/src/ultimate_rag/parsers/markdown.py b/src/ultimate_rag/parsers/markdown.py
index 3d950a1..f5cd68d 100644
--- a/src/ultimate_rag/parsers/markdown.py
+++ b/src/ultimate_rag/parsers/markdown.py
@@ -19,7 +19,6 @@
V1 只保留标题、普通文本和代码块,不保留链接目标、图片、表格结构或代码语言标记。
"""
-from pathlib import PurePath
from uuid import NAMESPACE_URL, uuid5
from markdown_it import MarkdownIt
@@ -33,6 +32,7 @@
ParsedDocument,
SourceLocator,
)
+from ultimate_rag.parsers._shared import supports_source
class MarkdownParser:
@@ -43,15 +43,17 @@ class MarkdownParser:
"""
name = "markdown"
- version = "1.0"
+ version = "2.0"
+ _EXTENSIONS = frozenset({".md", ".markdown"})
+ _MIME_TYPES = frozenset({"text/markdown", "text/plain", "application/x-markdown"})
def __init__(self) -> None:
"""创建启用 CommonMark 规则的无状态 Token 解析器。"""
self._markdown = MarkdownIt("commonmark")
def supports(self, source: DocumentSource) -> bool:
- """V1 按安全归一化后的文件扩展名识别 Markdown。"""
- return PurePath(source.filename).suffix.lower() in {".md", ".markdown"}
+ """同时校验 Markdown 扩展名与常见 MIME,通用二进制由内容校验兜底。"""
+ return supports_source(source, self._EXTENSIONS, self._MIME_TYPES)
async def parse(self, source: DocumentSource) -> ParsedDocument:
"""校验并解析 Markdown 字节,返回带章节定位的统一文档。
diff --git a/src/ultimate_rag/parsers/office.py b/src/ultimate_rag/parsers/office.py
new file mode 100644
index 0000000..561a182
--- /dev/null
+++ b/src/ultimate_rag/parsers/office.py
@@ -0,0 +1,253 @@
+"""DOCX、XLSX 与 PPTX 到统一文档模型的 Office Parser。
+
+模块职责:
+ 使用各格式成熟库提取标题、正文、列表与表格,立即映射为 UltimateRAG 的 Block 和
+ SourceLocator。所有同步 ZIP/XML 解析在线程中执行,避免阻塞 FastAPI Event Loop。
+
+安全边界:
+ 三种 OOXML 格式在交给第三方库前统一检查 ZIP 条目数、解压总量和异常压缩比,降低压缩炸弹
+ 风险。V2 只支持现代 OOXML,不支持带宏格式或旧版 `.doc`、`.xls`、`.ppt`。
+
+已知限制:
+ V2 提取 Office 原生文字与表格,不对文档内嵌图片再次 OCR,不计算 Word 真实页码,也不解析
+ 图表背后的数据模型;这些内容不会阻止同一文档中的可见文字进入 RAG。
+"""
+
+import asyncio
+from io import BytesIO
+
+from docx import Document as OpenDocument
+from docx.table import Table as WordTable
+from docx.text.paragraph import Paragraph
+from openpyxl import load_workbook
+from pptx import Presentation
+
+from ultimate_rag.domain.exceptions import InvalidDocumentError
+from ultimate_rag.domain.models import (
+ Block,
+ BlockType,
+ DocumentSource,
+ ParsedDocument,
+ SourceLocator,
+)
+from ultimate_rag.parsers._shared import (
+ stable_block,
+ supports_source,
+ table_to_markdown,
+ validate_ooxml_archive,
+)
+
+
+class WordParser:
+ """把 DOCX 段落、标题、列表和表格转换为有序 Block。"""
+
+ name = "docx"
+ version = "2.0"
+ _EXTENSIONS = frozenset({".docx"})
+ _MIME_TYPES = frozenset(
+ {"application/vnd.openxmlformats-officedocument.wordprocessingml.document"}
+ )
+
+ def supports(self, source: DocumentSource) -> bool:
+ """按 DOCX 扩展名和标准 MIME 识别来源。"""
+
+ return supports_source(source, self._EXTENSIONS, self._MIME_TYPES)
+
+ async def parse(self, source: DocumentSource) -> ParsedDocument:
+ """在线程中解析 DOCX,保留标题路径和表格结构。"""
+
+ validate_ooxml_archive(source.content)
+ return await asyncio.to_thread(self._parse_sync, source)
+
+ def _parse_sync(self, source: DocumentSource) -> ParsedDocument:
+ """执行 python-docx 同步解析并映射领域模型。"""
+
+ try:
+ document = OpenDocument(BytesIO(source.content))
+ blocks: list[Block] = []
+ heading_path: list[str] = []
+ for element in document.iter_inner_content():
+ if isinstance(element, Paragraph):
+ content = element.text.strip()
+ if not content:
+ continue
+ style_name = element.style.name if element.style is not None else ""
+ block_type = BlockType.TEXT
+ if style_name.startswith("Heading"):
+ level_text = style_name.removeprefix("Heading").strip()
+ level = int(level_text) if level_text.isdigit() else 1
+ heading_path = heading_path[: level - 1] + [content]
+ block_type = BlockType.HEADING
+ elif style_name.startswith("List"):
+ block_type = BlockType.LIST
+ locator = SourceLocator(heading_path=tuple(heading_path))
+ blocks.append(
+ stable_block(source.document_id, len(blocks), block_type, content, locator)
+ )
+ elif isinstance(element, WordTable):
+ content = table_to_markdown(
+ [[cell.text for cell in row.cells] for row in element.rows]
+ )
+ if content:
+ locator = SourceLocator(heading_path=tuple(heading_path))
+ blocks.append(
+ stable_block(
+ source.document_id,
+ len(blocks),
+ BlockType.TABLE,
+ content,
+ locator,
+ )
+ )
+ except InvalidDocumentError:
+ raise
+ except Exception as exc:
+ raise InvalidDocumentError("DOCX 文件损坏或无法解析") from exc
+ return _parsed(source, self.name, self.version, blocks)
+
+
+class ExcelParser:
+ """把 XLSX 工作表按有限行窗口转换为 Markdown Table Block。"""
+
+ name = "xlsx"
+ version = "2.0"
+ _EXTENSIONS = frozenset({".xlsx"})
+ _MIME_TYPES = frozenset({"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"})
+ _MAX_SHEETS = 100
+ _MAX_CELLS_PER_SHEET = 200_000
+ _ROWS_PER_BLOCK = 100
+
+ def supports(self, source: DocumentSource) -> bool:
+ """按 XLSX 扩展名和标准 MIME 识别来源。"""
+
+ return supports_source(source, self._EXTENSIONS, self._MIME_TYPES)
+
+ async def parse(self, source: DocumentSource) -> ParsedDocument:
+ """在线程中以 read-only 模式读取工作表,避免复制完整 Workbook。"""
+
+ validate_ooxml_archive(source.content)
+ return await asyncio.to_thread(self._parse_sync, source)
+
+ def _parse_sync(self, source: DocumentSource) -> ParsedDocument:
+ """执行 openpyxl 同步读取,并给每个表格窗口记录 Sheet 与 Range。"""
+
+ try:
+ workbook = load_workbook(BytesIO(source.content), read_only=True, data_only=False)
+ if len(workbook.worksheets) > self._MAX_SHEETS:
+ raise InvalidDocumentError("XLSX 工作表数量超过限制")
+ blocks: list[Block] = []
+ for worksheet in workbook.worksheets:
+ if worksheet.max_row * worksheet.max_column > self._MAX_CELLS_PER_SHEET:
+ raise InvalidDocumentError(f"XLSX 工作表 {worksheet.title} 单元格范围过大")
+ rows = list(worksheet.iter_rows(values_only=True))
+ for start in range(0, len(rows), self._ROWS_PER_BLOCK):
+ window = rows[start : start + self._ROWS_PER_BLOCK]
+ content = table_to_markdown(window)
+ if not content:
+ continue
+ end_row = min(start + len(window), worksheet.max_row)
+ end_cell = worksheet.cell(end_row, worksheet.max_column).coordinate
+ cell_range = f"A{start + 1}:{end_cell}"
+ locator = SourceLocator(sheet=worksheet.title, cell_range=cell_range)
+ blocks.append(
+ stable_block(
+ source.document_id,
+ len(blocks),
+ BlockType.TABLE,
+ content,
+ locator,
+ )
+ )
+ workbook.close()
+ except InvalidDocumentError:
+ raise
+ except Exception as exc:
+ raise InvalidDocumentError("XLSX 文件损坏或无法解析") from exc
+ return _parsed(source, self.name, self.version, blocks)
+
+
+class PowerPointParser:
+ """把 PPTX 每张幻灯片中的标题、文本框和表格转换为 Block。"""
+
+ name = "pptx"
+ version = "2.0"
+ _EXTENSIONS = frozenset({".pptx"})
+ _MIME_TYPES = frozenset(
+ {"application/vnd.openxmlformats-officedocument.presentationml.presentation"}
+ )
+
+ def supports(self, source: DocumentSource) -> bool:
+ """按 PPTX 扩展名和标准 MIME 识别来源。"""
+
+ return supports_source(source, self._EXTENSIONS, self._MIME_TYPES)
+
+ async def parse(self, source: DocumentSource) -> ParsedDocument:
+ """在线程中解析 PPTX,并记录一基幻灯片序号。"""
+
+ validate_ooxml_archive(source.content)
+ return await asyncio.to_thread(self._parse_sync, source)
+
+ def _parse_sync(self, source: DocumentSource) -> ParsedDocument:
+ """执行 python-pptx 同步解析并保持 Shape 原始顺序。"""
+
+ try:
+ presentation = Presentation(BytesIO(source.content))
+ blocks: list[Block] = []
+ for slide_number, slide in enumerate(presentation.slides, start=1):
+ title_shape = slide.shapes.title
+ title = title_shape.text.strip() if title_shape is not None else ""
+ heading_path = (title,) if title else ()
+ if title:
+ blocks.append(
+ stable_block(
+ source.document_id,
+ len(blocks),
+ BlockType.HEADING,
+ title,
+ SourceLocator(heading_path=heading_path, slide=slide_number),
+ )
+ )
+ for shape in slide.shapes:
+ if shape is title_shape:
+ continue
+ locator = SourceLocator(heading_path=heading_path, slide=slide_number)
+ if shape.has_table:
+ content = table_to_markdown(
+ [[cell.text for cell in row.cells] for row in shape.table.rows]
+ )
+ block_type = BlockType.TABLE
+ elif shape.has_text_frame:
+ content = shape.text.strip()
+ block_type = BlockType.TEXT
+ else:
+ continue
+ if content:
+ blocks.append(
+ stable_block(
+ source.document_id,
+ len(blocks),
+ block_type,
+ content,
+ locator,
+ )
+ )
+ except Exception as exc:
+ raise InvalidDocumentError("PPTX 文件损坏或无法解析") from exc
+ return _parsed(source, self.name, self.version, blocks)
+
+
+def _parsed(
+ source: DocumentSource,
+ parser_name: str,
+ parser_version: str,
+ blocks: list[Block],
+) -> ParsedDocument:
+ """统一拒绝无文本 Office 文件并构造 Parser 元数据。"""
+
+ if not blocks:
+ raise InvalidDocumentError(f"{source.filename} 没有可索引的文本或表格")
+ return ParsedDocument(
+ document_id=source.document_id,
+ blocks=tuple(blocks),
+ metadata={"parser": parser_name, "parser_version": parser_version},
+ )
diff --git a/src/ultimate_rag/parsers/pdf.py b/src/ultimate_rag/parsers/pdf.py
new file mode 100644
index 0000000..8dbead2
--- /dev/null
+++ b/src/ultimate_rag/parsers/pdf.py
@@ -0,0 +1,144 @@
+"""原生 PDF 与扫描 PDF 到统一文档模型的 Parser。
+
+模块职责:
+ 使用 PDFium 提取每页文本;文字量低于阈值的页面渲染为 JPEG 后交给 OCRClient。每个 Block
+ 都保留一基页码,使检索结果与 Citation 可以回溯原 PDF。
+
+设计取舍:
+ V2 采用“按页自动判定原生文本或 OCR”的直接流程,支持混合型 PDF,也避免为扫描文件引入
+ 独立任务队列。PDFium 的同步解析和渲染在线程中执行;OCR 网络调用保持异步且逐页有界。
+"""
+
+import asyncio
+from dataclasses import dataclass
+from io import BytesIO
+
+import pypdfium2 as pdfium # type: ignore[import-untyped]
+
+from ultimate_rag.domain.exceptions import InvalidDocumentError
+from ultimate_rag.domain.models import (
+ Block,
+ BlockType,
+ DocumentSource,
+ ParsedDocument,
+ SourceLocator,
+)
+from ultimate_rag.domain.ports import OCRClient
+from ultimate_rag.parsers._shared import stable_block, supports_source
+
+
+@dataclass(frozen=True, slots=True)
+class _PDFPage:
+ """线程边界间传递的单页提取结果,不泄漏 PDFium 资源句柄。"""
+
+ number: int
+ text: str
+ rendered_image: bytes | None
+
+
+class PDFParser:
+ """优先提取原生文本,并对低文字量页面自动执行 OCR。"""
+
+ name = "pdf"
+ version = "2.0"
+ _EXTENSIONS = frozenset({".pdf"})
+ _MIME_TYPES = frozenset({"application/pdf"})
+ _MAX_PAGES = 500
+
+ def __init__(
+ self,
+ ocr_client: OCRClient,
+ *,
+ native_text_threshold: int = 20,
+ render_scale: float = 1.5,
+ ) -> None:
+ """配置扫描页判定阈值与 OCR 渲染倍率。"""
+
+ if native_text_threshold < 0:
+ raise ValueError("PDF native text threshold cannot be negative")
+ if not 0.5 <= render_scale <= 3.0:
+ raise ValueError("PDF render scale must be between 0.5 and 3.0")
+ self._ocr_client = ocr_client
+ self._native_text_threshold = native_text_threshold
+ self._render_scale = render_scale
+
+ def supports(self, source: DocumentSource) -> bool:
+ """同时要求 PDF 扩展名以及 PDF 或通用二进制 MIME。"""
+
+ return supports_source(source, self._EXTENSIONS, self._MIME_TYPES)
+
+ async def parse(self, source: DocumentSource) -> ParsedDocument:
+ """提取或 OCR 每一页,并拒绝完全没有可索引文本的 PDF。"""
+
+ pages = await asyncio.to_thread(self._extract_pages, source.content)
+ blocks: list[Block] = []
+ ocr_page_count = 0
+ for page in pages:
+ content = page.text
+ block_type = BlockType.TEXT
+ if page.rendered_image is not None:
+ try:
+ content = await self._ocr_client.extract_text(page.rendered_image, "image/jpeg")
+ except (ValueError, RuntimeError) as exc:
+ raise InvalidDocumentError(f"PDF 第 {page.number} 页 OCR 失败:{exc}") from exc
+ block_type = BlockType.IMAGE
+ ocr_page_count += 1
+ content = content.strip()
+ if not content:
+ continue
+ blocks.append(
+ stable_block(
+ source.document_id,
+ len(blocks),
+ block_type,
+ content,
+ SourceLocator(page=page.number),
+ )
+ )
+ if not blocks:
+ raise InvalidDocumentError("PDF 没有可索引的文本或 OCR 结果")
+ return ParsedDocument(
+ document_id=source.document_id,
+ blocks=tuple(blocks),
+ metadata={
+ "parser": self.name,
+ "parser_version": self.version,
+ "page_count": len(pages),
+ "ocr_page_count": ocr_page_count,
+ },
+ )
+
+ def _extract_pages(self, content: bytes) -> list[_PDFPage]:
+ """同步打开 PDF,提取文本,并只渲染需要 OCR 的页面。"""
+
+ try:
+ document = pdfium.PdfDocument(content)
+ page_count = len(document)
+ if page_count == 0:
+ raise InvalidDocumentError("PDF 文件没有页面")
+ if page_count > self._MAX_PAGES:
+ raise InvalidDocumentError(f"PDF 页数不能超过 {self._MAX_PAGES} 页")
+ pages: list[_PDFPage] = []
+ for page_index in range(page_count):
+ page = document[page_index]
+ text_page = page.get_textpage()
+ # bounded API 与整页默认边界等价,且避免 PDFium 对 get_text_range 默认参数
+ # 的弃用警告;两者都按页面文字对象的自然顺序返回 Unicode 文本。
+ text = text_page.get_text_bounded().strip()
+ text_page.close()
+ rendered = None
+ if len(text) < self._native_text_threshold:
+ bitmap = page.render(scale=self._render_scale)
+ image = bitmap.to_pil().convert("RGB")
+ buffer = BytesIO()
+ image.save(buffer, format="JPEG", quality=85, optimize=True)
+ rendered = buffer.getvalue()
+ bitmap.close()
+ page.close()
+ pages.append(_PDFPage(page_index + 1, text, rendered))
+ document.close()
+ return pages
+ except InvalidDocumentError:
+ raise
+ except Exception as exc:
+ raise InvalidDocumentError("PDF 文件损坏、加密或无法解析") from exc
diff --git a/src/ultimate_rag/parsers/registry.py b/src/ultimate_rag/parsers/registry.py
index 77da415..706564b 100644
--- a/src/ultimate_rag/parsers/registry.py
+++ b/src/ultimate_rag/parsers/registry.py
@@ -1,6 +1,6 @@
"""解析器注册与解析器选择。
-V1 使用显式内存注册即可满足单进程需求;本模块不实现包发现或远程插件加载。
+V2 使用显式内存注册即可满足单进程需求;本模块不实现包发现或远程插件加载。
"""
from ultimate_rag.domain.exceptions import UnsupportedDocumentTypeError
diff --git a/src/ultimate_rag/vectorstores/milvus.py b/src/ultimate_rag/vectorstores/milvus.py
index d2fd6c4..aa5e509 100644
--- a/src/ultimate_rag/vectorstores/milvus.py
+++ b/src/ultimate_rag/vectorstores/milvus.py
@@ -27,7 +27,7 @@
from pymilvus import DataType, MilvusClient # type: ignore[import-untyped]
-from ultimate_rag.domain.models import EmbeddedChunk, RetrievalResult
+from ultimate_rag.domain.models import EmbeddedChunk, RetrievalResult, SourceLocator
class MilvusVectorStore:
@@ -117,7 +117,13 @@ async def upsert(self, chunks: Sequence[EmbeddedChunk]) -> None:
"chunk_id": item.chunk.id,
"filename": str(item.chunk.metadata.get("filename", "")),
"content": item.chunk.content,
- "heading_path": list(item.chunk.heading_path),
+ # 复用 V1 已有 JSON 字段保存 V2 SourceLocator,避免在应用启动时隐式迁移
+ # Milvus Schema。读取端兼容旧版 list,现有 V1 索引可以原地继续检索。
+ "heading_path": (
+ item.chunk.locator.to_metadata()
+ if item.chunk.locator
+ else {"heading_path": list(item.chunk.heading_path)}
+ ),
"embedding": list(item.embedding),
}
for item in chunks
@@ -196,12 +202,19 @@ def _retrieval_result(hit: dict[str, Any]) -> RetrievalResult:
# ``output_fields`` 位于 entity,COSINE 分数位于 Hit 顶层 distance。
# heading_path 兼容缺失字段或 JSON null,并在领域边界冻结为 Tuple。
entity = hit["entity"]
+ raw_locator = entity.get("heading_path")
+ if isinstance(raw_locator, dict):
+ locator = SourceLocator.from_metadata(raw_locator)
+ else:
+ # V1 行只保存标题数组;转换为统一 Locator 后无需重建已有 Collection。
+ locator = SourceLocator(heading_path=tuple(str(item) for item in (raw_locator or [])))
return RetrievalResult(
chunk_id=str(entity["chunk_id"]),
knowledge_base_id=str(entity["knowledge_base_id"]),
document_id=str(entity["document_id"]),
filename=str(entity["filename"]),
content=str(entity["content"]),
- heading_path=tuple(entity.get("heading_path") or []),
+ heading_path=locator.heading_path,
score=float(hit["distance"]),
+ locator=locator,
)
diff --git a/tests/unit/test_bailian_ocr.py b/tests/unit/test_bailian_ocr.py
new file mode 100644
index 0000000..6922ecf
--- /dev/null
+++ b/tests/unit/test_bailian_ocr.py
@@ -0,0 +1,66 @@
+"""验证百炼 OCR 适配器的 Data URL 请求与输入边界。"""
+
+from types import SimpleNamespace
+from typing import cast
+
+import pytest
+
+from ultimate_rag.ocr import BailianOCRClient
+
+
+class FakeCompletionsAPI:
+ """记录 Chat Completions 请求并返回固定 OCR 文本。"""
+
+ def __init__(self) -> None:
+ """初始化空请求记录。"""
+
+ self.requests: list[dict[str, object]] = []
+
+ async def create(self, **kwargs: object) -> SimpleNamespace:
+ """保存请求并模拟 OpenAI-Compatible 响应结构。"""
+
+ self.requests.append(kwargs)
+ return SimpleNamespace(
+ choices=[SimpleNamespace(message=SimpleNamespace(content=" OCR text "))]
+ )
+
+
+@pytest.mark.asyncio
+async def test_ocr_uses_base64_data_url_and_strips_response() -> None:
+ """图片字节应编码为正确 MIME 的 Data URL,响应首尾空白应被移除。"""
+
+ client = BailianOCRClient(
+ api_key="test",
+ base_url="https://example.test/v1",
+ model="qwen-vl-ocr-latest",
+ max_image_bytes=100,
+ timeout=1,
+ )
+ api = FakeCompletionsAPI()
+ client._client = SimpleNamespace( # type: ignore[assignment]
+ chat=SimpleNamespace(completions=api)
+ )
+
+ result = await client.extract_text(b"png", "image/png")
+
+ assert result == "OCR text"
+ messages = cast(list[dict[str, object]], api.requests[0]["messages"])
+ content = cast(list[dict[str, object]], messages[0]["content"])
+ image_url = cast(dict[str, str], content[0]["image_url"])
+ assert image_url["url"].startswith("data:image/png;base64,")
+
+
+@pytest.mark.asyncio
+async def test_ocr_rejects_image_over_configured_limit() -> None:
+ """超限图片必须在访问模型服务前失败。"""
+
+ client = BailianOCRClient(
+ api_key="test",
+ base_url="https://example.test/v1",
+ model="qwen-vl-ocr-latest",
+ max_image_bytes=2,
+ timeout=1,
+ )
+
+ with pytest.raises(ValueError, match="不能超过"):
+ await client.extract_text(b"123", "image/png")
diff --git a/tests/unit/test_chunker.py b/tests/unit/test_chunker.py
index d0c39c9..45ed043 100644
--- a/tests/unit/test_chunker.py
+++ b/tests/unit/test_chunker.py
@@ -5,7 +5,13 @@
import pytest
from ultimate_rag.chunkers import StructureAwareMarkdownChunker
-from ultimate_rag.domain.models import DocumentSource
+from ultimate_rag.domain.models import (
+ Block,
+ BlockType,
+ DocumentSource,
+ ParsedDocument,
+ SourceLocator,
+)
from ultimate_rag.parsers import MarkdownParser
FIXTURE_CONTENT = Path("tests/fixtures/rag.md").read_bytes()
@@ -42,3 +48,20 @@ async def test_chunker_splits_oversized_paragraph() -> None:
assert len(chunks) > 1
assert all(len(chunk.content) <= 220 for chunk in chunks)
+
+
+@pytest.mark.asyncio
+async def test_chunker_keeps_pdf_pages_as_separate_source_locations() -> None:
+ """标题相同但页码不同的 Block 不能合并,否则 Citation 会丢失精确页码。"""
+
+ parsed = ParsedDocument(
+ document_id="pdf-1",
+ blocks=(
+ Block("b1", BlockType.TEXT, "第一页内容", SourceLocator(page=1)),
+ Block("b2", BlockType.TEXT, "第二页内容", SourceLocator(page=2)),
+ ),
+ )
+
+ chunks = await StructureAwareMarkdownChunker(200, 20).split(parsed, "kb-1")
+
+ assert [chunk.locator.page if chunk.locator else None for chunk in chunks] == [1, 2]
diff --git a/tests/unit/test_ingestion_service.py b/tests/unit/test_ingestion_service.py
index 3270644..cf16f14 100644
--- a/tests/unit/test_ingestion_service.py
+++ b/tests/unit/test_ingestion_service.py
@@ -13,8 +13,8 @@
@pytest.mark.asyncio
-async def test_ingestion_rejects_non_markdown_mime_before_storage() -> None:
- """明显非文本 MIME 即使使用 .md 后缀,也不应写入 MinIO。"""
+async def test_ingestion_rejects_extension_mime_mismatch_before_storage() -> None:
+ """扩展名与 MIME 没有任何 Parser 同时支持时,不应写入 MinIO。"""
repository = AsyncMock()
repository.get_knowledge_base.return_value = object()
@@ -29,7 +29,7 @@ async def test_ingestion_rejects_non_markdown_mime_before_storage() -> None:
max_upload_bytes=1024,
)
- with pytest.raises(InvalidDocumentError, match="MIME"):
+ with pytest.raises(InvalidDocumentError, match="不支持的文档类型"):
await service.ingest(
knowledge_base_id="kb-1",
filename="disguised.md",
diff --git a/tests/unit/test_milvus_vector_store.py b/tests/unit/test_milvus_vector_store.py
index 3b3ea28..1bfc19e 100644
--- a/tests/unit/test_milvus_vector_store.py
+++ b/tests/unit/test_milvus_vector_store.py
@@ -64,3 +64,25 @@ def test_delete_flushes_after_data_change() -> None:
("delete", 'knowledge_chunks:document_id == "doc-1"'),
("flush", "knowledge_chunks"),
]
+
+
+def test_retrieval_result_reads_v2_locator_and_v1_heading_path() -> None:
+ """同一 JSON 字段必须兼容 V2 Locator 字典与升级前的 V1 标题数组。"""
+
+ common = {
+ "chunk_id": "chunk-1",
+ "knowledge_base_id": "kb-1",
+ "document_id": "doc-1",
+ "filename": "source.pdf",
+ "content": "content",
+ }
+ v2 = MilvusVectorStore._retrieval_result(
+ {"distance": 0.9, "entity": {**common, "heading_path": {"page": 3}}}
+ )
+ v1 = MilvusVectorStore._retrieval_result(
+ {"distance": 0.8, "entity": {**common, "heading_path": ["RAG", "Index"]}}
+ )
+
+ assert v2.locator is not None and v2.locator.page == 3
+ assert v1.heading_path == ("RAG", "Index")
+ assert v1.locator is not None and v1.locator.heading_path == ("RAG", "Index")
diff --git a/tests/unit/test_v2_parsers.py b/tests/unit/test_v2_parsers.py
new file mode 100644
index 0000000..4cf4841
--- /dev/null
+++ b/tests/unit/test_v2_parsers.py
@@ -0,0 +1,232 @@
+"""验证 V2 多格式 Parser 都映射到同一领域模型并保留格式特有位置。"""
+
+from io import BytesIO
+
+import pytest
+from docx import Document as WordDocument
+from openpyxl import Workbook
+from PIL import Image
+from pptx import Presentation
+from pptx.util import Inches
+
+from ultimate_rag.domain.models import BlockType, DocumentSource
+from ultimate_rag.parsers import (
+ ExcelParser,
+ HtmlParser,
+ ImageOCRParser,
+ PDFParser,
+ PowerPointParser,
+ WordParser,
+)
+
+
+class StubOCRClient:
+ """返回固定识别文本并记录图片 MIME,避免单元测试访问百炼。"""
+
+ def __init__(self, text: str = "识别出的文字") -> None:
+ """保存固定结果和空调用记录。"""
+
+ self.text = text
+ self.mime_types: list[str] = []
+
+ async def extract_text(self, image: bytes, mime_type: str) -> str:
+ """验证 Parser 确实提交了非空图片,再返回确定性结果。"""
+
+ assert image
+ self.mime_types.append(mime_type)
+ return self.text
+
+
+def _source(filename: str, mime_type: str, content: bytes) -> DocumentSource:
+ """用统一文档 ID 构造 Parser 单元测试输入。"""
+
+ return DocumentSource("doc-v2", filename, mime_type, content)
+
+
+def _native_text_pdf(text: str) -> bytes:
+ """构造一个最小但带真实文字对象的 PDF,用于覆盖无 OCR 原生路径。"""
+
+ stream = f"BT /F1 24 Tf 72 720 Td ({text}) Tj ET".encode("ascii")
+ objects = [
+ b"<< /Type /Catalog /Pages 2 0 R >>",
+ b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
+ (
+ b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] "
+ b"/Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>"
+ ),
+ b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
+ f"<< /Length {len(stream)} >>\nstream\n".encode() + stream + b"\nendstream",
+ ]
+ content = bytearray(b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n")
+ offsets = [0]
+ for number, value in enumerate(objects, start=1):
+ offsets.append(len(content))
+ content.extend(f"{number} 0 obj\n".encode() + value + b"\nendobj\n")
+ xref_offset = len(content)
+ content.extend(f"xref\n0 {len(objects) + 1}\n0000000000 65535 f \n".encode())
+ for offset in offsets[1:]:
+ content.extend(f"{offset:010d} 00000 n \n".encode())
+ content.extend(
+ (
+ f"trailer\n<< /Size {len(objects) + 1} /Root 1 0 R >>\n"
+ f"startxref\n{xref_offset}\n%%EOF\n"
+ ).encode()
+ )
+ return bytes(content)
+
+
+@pytest.mark.asyncio
+async def test_html_parser_extracts_visible_structure_and_ignores_script() -> None:
+ """HTML 标题、正文和表格应按 DOM 顺序进入领域 Block,脚本不得进入索引。"""
+
+ content = b"""
+ RAG Guide
Visible paragraph.
+
+ """
+
+ parsed = await HtmlParser().parse(_source("guide.html", "text/html", content))
+
+ assert [block.type for block in parsed.blocks] == [
+ BlockType.HEADING,
+ BlockType.TEXT,
+ BlockType.TABLE,
+ ]
+ assert parsed.blocks[1].locator is not None
+ assert parsed.blocks[1].locator.heading_path == ("RAG Guide",)
+ assert all("ignore this command" not in block.content for block in parsed.blocks)
+
+
+@pytest.mark.asyncio
+async def test_word_parser_preserves_heading_and_table() -> None:
+ """DOCX 标题路径、普通段落与表格应转换为统一 Block。"""
+
+ document = WordDocument()
+ document.add_heading("Enterprise RAG", level=1)
+ document.add_paragraph("A maintainable pipeline.")
+ table = document.add_table(rows=2, cols=2)
+ table.cell(0, 0).text = "Stage"
+ table.cell(0, 1).text = "State"
+ table.cell(1, 0).text = "Index"
+ table.cell(1, 1).text = "Ready"
+ buffer = BytesIO()
+ document.save(buffer)
+
+ parsed = await WordParser().parse(
+ _source(
+ "guide.docx",
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
+ buffer.getvalue(),
+ )
+ )
+
+ assert any(block.type == BlockType.TABLE for block in parsed.blocks)
+ paragraph = next(block for block in parsed.blocks if block.type == BlockType.TEXT)
+ assert paragraph.locator is not None
+ assert paragraph.locator.heading_path == ("Enterprise RAG",)
+
+
+@pytest.mark.asyncio
+async def test_excel_parser_preserves_sheet_and_cell_range() -> None:
+ """XLSX 每个表格窗口必须携带工作表名称与单元格范围。"""
+
+ workbook = Workbook()
+ sheet = workbook.active
+ sheet.title = "Metrics"
+ sheet.append(["Metric", "Value"])
+ sheet.append(["Recall", 0.92])
+ buffer = BytesIO()
+ workbook.save(buffer)
+
+ parsed = await ExcelParser().parse(
+ _source(
+ "metrics.xlsx",
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ buffer.getvalue(),
+ )
+ )
+
+ assert len(parsed.blocks) == 1
+ locator = parsed.blocks[0].locator
+ assert locator is not None
+ assert locator.sheet == "Metrics"
+ assert locator.cell_range == "A1:B2"
+ assert "Recall" in parsed.blocks[0].content
+
+
+@pytest.mark.asyncio
+async def test_powerpoint_parser_preserves_slide_number() -> None:
+ """PPTX 标题和正文应保留一基幻灯片序号。"""
+
+ presentation = Presentation()
+ slide = presentation.slides.add_slide(presentation.slide_layouts[1])
+ slide.shapes.title.text = "Architecture"
+ text_box = slide.shapes.add_textbox(Inches(1), Inches(2), Inches(5), Inches(1))
+ text_box.text = "Parse then chunk."
+ buffer = BytesIO()
+ presentation.save(buffer)
+
+ parsed = await PowerPointParser().parse(
+ _source(
+ "architecture.pptx",
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation",
+ buffer.getvalue(),
+ )
+ )
+
+ assert parsed.blocks
+ assert all(block.locator and block.locator.slide == 1 for block in parsed.blocks)
+
+
+@pytest.mark.asyncio
+async def test_image_parser_validates_format_before_ocr() -> None:
+ """图片 Parser 应向 OCR 提交由真实编码推导的 MIME,而不是盲信客户端。"""
+
+ buffer = BytesIO()
+ Image.new("RGB", (80, 40), "white").save(buffer, format="PNG")
+ ocr = StubOCRClient()
+
+ parsed = await ImageOCRParser(ocr).parse(
+ _source("scan.png", "application/octet-stream", buffer.getvalue())
+ )
+
+ assert parsed.blocks[0].type == BlockType.IMAGE
+ assert parsed.blocks[0].content == "识别出的文字"
+ assert ocr.mime_types == ["image/png"]
+
+
+@pytest.mark.asyncio
+async def test_pdf_parser_ocr_scan_page_and_keeps_page_number() -> None:
+ """无文本扫描 PDF 应渲染页面执行 OCR,并把一基页码带到 Block。"""
+
+ buffer = BytesIO()
+ Image.new("RGB", (240, 120), "white").save(buffer, format="PDF")
+ ocr = StubOCRClient("扫描页内容")
+
+ parsed = await PDFParser(ocr).parse(_source("scan.pdf", "application/pdf", buffer.getvalue()))
+
+ assert parsed.blocks[0].type == BlockType.IMAGE
+ assert parsed.blocks[0].locator is not None
+ assert parsed.blocks[0].locator.page == 1
+ assert parsed.metadata["ocr_page_count"] == 1
+ assert ocr.mime_types == ["image/jpeg"]
+
+
+@pytest.mark.asyncio
+async def test_pdf_parser_uses_native_text_without_ocr() -> None:
+ """文字型 PDF 达到阈值时必须直接提取,避免不必要的付费 OCR。"""
+
+ ocr = StubOCRClient()
+
+ parsed = await PDFParser(ocr, native_text_threshold=10).parse(
+ _source(
+ "native.pdf",
+ "application/pdf",
+ _native_text_pdf("UltimateRAG native PDF text"),
+ )
+ )
+
+ assert parsed.blocks[0].type == BlockType.TEXT
+ assert "UltimateRAG native PDF text" in parsed.blocks[0].content
+ assert parsed.blocks[0].locator is not None and parsed.blocks[0].locator.page == 1
+ assert parsed.metadata["ocr_page_count"] == 0
+ assert ocr.mime_types == []
diff --git a/uv.lock b/uv.lock
index 8646387..8103f10 100644
--- a/uv.lock
+++ b/uv.lock
@@ -153,6 +153,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/3c/d7/8fb3044eaef08a310acfe23dae9a8e2e07d305edc29a53497e52bc76eca7/asyncpg-0.31.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bd4107bb7cdd0e9e65fae66a62afd3a249663b844fa34d479f6d5b3bef9c04c3", size = 706062, upload-time = "2025-11-24T23:26:44.086Z" },
]
+[[package]]
+name = "beautifulsoup4"
+version = "4.15.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "soupsieve" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/43/65/318323f98dbee45d42dff61d8f047181bc6f2268a9068cfad035a46be5af/beautifulsoup4-4.15.0.tar.gz", hash = "sha256:288e3ca7d54b06f2ac191970bc275c1939cb46d450b255bf6718b04aa37ab4f7", size = 632571, upload-time = "2026-06-07T16:44:20.453Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/88/c6/92fcd42f1ba33e1184263f25bfabf3d27c383410470f169e4b8163bf9c17/beautifulsoup4-4.15.0-py3-none-any.whl", hash = "sha256:d6f88de62e1d4e38ecb1077eb9724cd0eff29d2a08ca16a401e9b9e93f117cf9", size = 109924, upload-time = "2026-06-07T16:44:21.566Z" },
+]
+
[[package]]
name = "cachetools"
version = "7.1.7"
@@ -414,6 +427,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" },
]
+[[package]]
+name = "et-xmlfile"
+version = "2.0.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d3/38/af70d7ab1ae9d4da450eeec1fa3918940a5fafb9055e934af8d6eb0c2313/et_xmlfile-2.0.0.tar.gz", hash = "sha256:dab3f4764309081ce75662649be815c4c9081e88f0837825f90fd28317d4da54", size = 17234, upload-time = "2024-10-25T17:25:40.039Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c1/8b/5fe2cc11fee489817272089c4203e679c63b570a5aaeb18d852ae3cbba6a/et_xmlfile-2.0.0-py3-none-any.whl", hash = "sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa", size = 18059, upload-time = "2024-10-25T17:25:39.051Z" },
+]
+
[[package]]
name = "fastapi"
version = "0.141.1"
@@ -803,6 +825,116 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/38/a6/800800bfed7b1fb10fc3f3d557785c3854e80d3f7a9800d784b176a1fc2d/librt-0.15.0-cp315-cp315t-win_arm64.whl", hash = "sha256:84d244b00604d17df3fc7736c327892d6bba66181254aa4087be807b6c342bdc", size = 110700, upload-time = "2026-08-07T10:49:15.499Z" },
]
+[[package]]
+name = "lxml"
+version = "6.1.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/ad/a9/970b8fa0ecc4fbf1dfaed0d89bbc1fc1421b25ec26a2038c91e872dc6c8e/lxml-6.1.2.tar.gz", hash = "sha256:1055241852f2b02068af4a625a5d32c087db193c12251928af2562ecd2239f18", size = 4210626, upload-time = "2026-08-19T04:58:15.341Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ee/a4/55eb54507073089ab27743c5da2113c84f0d0b1715b33175fdd943c9652d/lxml-6.1.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:7d506bdba580ecb1a6ad2e2b5c49445e66d3e1f95894885739094393a1aad237", size = 8602111, upload-time = "2026-08-19T04:58:28.017Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/bf/6332f45d78da385bb01d5cac3fe4acda19f025d1307cbc7ad538355fecbb/lxml-6.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:12acd337d2821cb8b9247dfe4b7aa2f2769a3df5ae8511b7e550df42b8f4d3c3", size = 4638376, upload-time = "2026-08-19T04:58:41.181Z" },
+ { url = "https://files.pythonhosted.org/packages/68/e0/21fba0fe74d417fbe976903ae6bc77e92cdce01aae7b636abd87756f4588/lxml-6.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5078ff51e6316c0f75ea8127c2cd24374747fb351f62fb93d1761f8ae5a04a40", size = 4939689, upload-time = "2026-08-19T04:58:48.526Z" },
+ { url = "https://files.pythonhosted.org/packages/de/e5/ce3e885264fdd0bdcb6b49c1ea1842f94281b39e4ff956099e8d57532c60/lxml-6.1.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9477e14217c212e6023c994a71a1a349db19b0e10fd5bf189666b281ae63b1fd", size = 5105185, upload-time = "2026-08-19T04:59:15.533Z" },
+ { url = "https://files.pythonhosted.org/packages/e6/b6/990a8446c488c70fa25681e150de94b7bf2eaaf387e374d195ab3c8faafb/lxml-6.1.2-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:261d98065326676d7253882db0198d0aa06748d7ee0443367acf10b148273f99", size = 5011863, upload-time = "2026-08-19T04:59:50.58Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/6a/f70f41363dae27e3bfd6224b128f5ba150874bd32ca4938552930ffa33b0/lxml-6.1.2-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0666943ee1576fa890a6dc6316ef42e8241b5dd56f67bc5475acb2ac298c6ca9", size = 5638234, upload-time = "2026-08-19T05:00:00.802Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/e0/a65b64f34d556925faef2c4f14167d58c571bc15a3e1f2bba71138830562/lxml-6.1.2-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:04cf9e3f4ee9cab9d9ba05401bef8668840fa9620fcd4d8e85a2d2fd0b0fa960", size = 5244532, upload-time = "2026-08-19T05:00:07.516Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/a9/471552e015e954fc9d960aa27c3d67ebf489683d03f033399a790417c67c/lxml-6.1.2-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:9429d2371d406344ed1da5b5686d9412e74137c07b0171278368ff704f470ed5", size = 5358194, upload-time = "2026-08-19T05:00:22.747Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/0f/bc6248fbec2cc416f102b1267f1567e07510f6fa909bbe8cd2a22d6fb78e/lxml-6.1.2-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:eff128ffdc093cc6317955934ad9751105d37ed8dbca3ff4ccd751af6be37185", size = 4704432, upload-time = "2026-08-19T05:00:51.115Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/3f/cec859f50e63f1fa338fab43d2362d7543e1237f2475960d8ab0769de0eb/lxml-6.1.2-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ba58574d710b82ead7cbedea01cac3e110bc3ef82d4731519b74a2c11f7cf5e9", size = 5255038, upload-time = "2026-08-19T05:00:58.895Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/d9/2ced0cf2967115f92a1b8b3ae6bd18763abc3ebef88c98cf25145fda396c/lxml-6.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:52f6d4dff133c9778a24e9a2cfc1608930b15869866171aacc5131b5a418a003", size = 5054481, upload-time = "2026-08-19T05:01:10.096Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/f5/4f07386d3c88673daeec3b8cc09a2a4d39fa01c1fc49009791b0746d97fa/lxml-6.1.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:8807998c1023d1e9d60e02500f90e85a0752dbc0b670989806bba87b82dd5b42", size = 4785535, upload-time = "2026-08-19T05:01:18.909Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/5a/f4fe3ecbc189f48fba2547c5db5c940a10151d3e86b856a60a533a77e816/lxml-6.1.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:2170d0a280c877b6e2dc6738217db947be35dd8cf09ca458b355aa1bab2a9e70", size = 5655337, upload-time = "2026-08-19T05:01:41.324Z" },
+ { url = "https://files.pythonhosted.org/packages/92/c4/f586aa1bf27bfbace2dfdbb704da5c52f0bdece8ee440c8fb4946c940b2e/lxml-6.1.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c67f3c1278f942e97d8665c2a690324aaea5137de16f056583a21f0ac706177f", size = 5245778, upload-time = "2026-08-19T05:01:45.227Z" },
+ { url = "https://files.pythonhosted.org/packages/18/a1/677494bbaef4d6db5e4633af817414f478865850b55c03ae4bf70fa7b8ca/lxml-6.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:093fbf547d0f3ca02705381f795a050fbb58988be4aac7f79f99f280c4082313", size = 5267274, upload-time = "2026-08-19T05:01:57.687Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/71/b71425b8764d4cb7c92eb970483be7d5610dce2a6316242b5aaae7d260be/lxml-6.1.2-cp312-cp312-win32.whl", hash = "sha256:be365ce8d2d411cf2fb573747684b4fd470fa6224e0094d9d5a21155acc369d3", size = 3602563, upload-time = "2026-08-19T05:02:01.837Z" },
+ { url = "https://files.pythonhosted.org/packages/1b/fb/909584e16d2148c1a252cc2c32dd99fe0e2682459c586d3d7a192e74a0ae/lxml-6.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:b97153ca609b434b712ddfb92cd6af101a7045a7724c542258bd4727a344472f", size = 4005965, upload-time = "2026-08-19T05:02:07.157Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/8d/41207c9212caad0b52749e34739fb9bfab67486729f52a8fe9bd9266fee6/lxml-6.1.2-cp312-cp312-win_arm64.whl", hash = "sha256:7feb72424f19a893ae4f3373c7aae821b1aacb6076b708915c651f0683a97c49", size = 3666641, upload-time = "2026-08-19T05:02:11.3Z" },
+ { url = "https://files.pythonhosted.org/packages/61/2a/e9651f47a31a60b5cae031abc23391ed9aa30c8fc07571d1a38f58d6d770/lxml-6.1.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:351318f5c0eb7fcab5b4fdb507c6f88fb2c4b5e67784c7e5911448c91fffb5d4", size = 8590165, upload-time = "2026-08-19T04:58:40.489Z" },
+ { url = "https://files.pythonhosted.org/packages/61/87/a8098abaf35118767d1703b84c98940a5d833064e0eca39a00ecfe9840ab/lxml-6.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c0edde95e4b4278dcc0175eda06dc8aa2631ad9f83ae5dbdbc4f0925e200b0b0", size = 4632474, upload-time = "2026-08-19T04:58:47.465Z" },
+ { url = "https://files.pythonhosted.org/packages/93/cc/fe74d1def7f4fb967c4a825608a074d4dbdbb871b0d6bd59c6ed07d67868/lxml-6.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a8326e24ae6c3a6bfb03fa8b4793f9a5d804c125228aa067f652b0428e31b87c", size = 4936196, upload-time = "2026-08-19T04:59:03.477Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/ad/b96e6ca926e26726a99aa643602aac7411ecc1731ddb1b25af8cc57edfcd/lxml-6.1.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7c534ed898413f439b048130011e99a4245ee13d62d431f6b4f7f2484d02a93a", size = 5093290, upload-time = "2026-08-19T04:59:17.498Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/84/616f5d3b7cd086fcfba3e5add6fccda67f976c1c753ae9ed7bbd317cb9be/lxml-6.1.2-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e37fe49fe2d5aa40a2cb1cc8176673ad7de0d124e6f4a509d9318f5979c7871", size = 4998767, upload-time = "2026-08-19T04:59:28.385Z" },
+ { url = "https://files.pythonhosted.org/packages/80/88/d5b453a8d083483c9442ad7f5ac5c560796022eb5c80d60b65d75e449236/lxml-6.1.2-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9b52ea73a37fc64aa3357ff8607801d46dd170506d3cf8253a91a1d91639d4f9", size = 5626717, upload-time = "2026-08-19T04:59:40.045Z" },
+ { url = "https://files.pythonhosted.org/packages/71/45/31e5aa4d4bae024908ba1d03480c7425cf027a28b7e5c88d1b7202bd80cc/lxml-6.1.2-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8b9a92652e75e7731309ea51db5dee892eef414ce70a6ec3441e5d36bf5189f", size = 5232330, upload-time = "2026-08-19T04:59:46.175Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/5a/2627912420df8b2d31ba3014da5539f15ec85add01d42048864ffefda516/lxml-6.1.2-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:9088da25ecd609965f838d89fda0465a905b48f4dd90331db9845518f2177372", size = 5347054, upload-time = "2026-08-19T04:59:52.762Z" },
+ { url = "https://files.pythonhosted.org/packages/16/86/54ac0f529b22a8f12313726dd49e12961bb46471d9028cc28d2a29408f0b/lxml-6.1.2-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:0349321a0537d4fdbebb2af06dd1b64676132c72e2ae250de8cdb58f8c43019c", size = 4707275, upload-time = "2026-08-19T05:00:04.836Z" },
+ { url = "https://files.pythonhosted.org/packages/3a/42/ffcdc6e4519be90df907cdae7e88409efb25d823ae4de8846f737dae1884/lxml-6.1.2-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b20440e578d269c5e8a722ab602ddd0f0cedb8b080006b3f936da9991a593d3b", size = 5240071, upload-time = "2026-08-19T05:00:19.604Z" },
+ { url = "https://files.pythonhosted.org/packages/68/49/5b1d7ab35f013f1127ec48f3108319f58b65b00d5cb26f215adbe86eadfb/lxml-6.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7766e525282dd38fd89567311323e441996eb958e8e816d16b38f782e3aecd2a", size = 5050356, upload-time = "2026-08-19T05:00:27.968Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/57/1cf049d054189b55c8fe8012269234f6602256949b69cd3ba80608a88219/lxml-6.1.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:9221442682c27417f10fe11184ea4cce174b25ab52465570b1f3ee3f85f320fa", size = 4780394, upload-time = "2026-08-19T05:00:39.047Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/ad/064488a8fa60e639fd773e421a18bf17541d02a95fbf36238ad7c65f69d4/lxml-6.1.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:75530642d8471327e691ab9b0513a5f9c77f38871014ceda40f51bb51765c0a1", size = 5645854, upload-time = "2026-08-19T05:03:42.697Z" },
+ { url = "https://files.pythonhosted.org/packages/85/bb/120e56f3cf1c149bb3b014278fb86d0a6dd552403981081f0ee0a0a57be7/lxml-6.1.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:678e35f1cbca98f55107511ee21a60568535c950f3c2371819bd64504c980d20", size = 5231132, upload-time = "2026-08-19T05:03:45.466Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/2c/7d49aab893c128671a3276580074cce4c002896145b8dd2893da79633bca/lxml-6.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5c2bae42b3a09f977330a08f4a8fe72aec58c4bdb89069d3fe7272a71d885881", size = 5256076, upload-time = "2026-08-19T05:03:48.092Z" },
+ { url = "https://files.pythonhosted.org/packages/72/28/ddea3aa1fa9acfd384fe34d4a2a93eecc07541dd2d922fa9b140c60d8014/lxml-6.1.2-cp313-cp313-win32.whl", hash = "sha256:5848f3de6a8de8a93cff9f068134393ff5fa69ac2a04399f7d49cd67c61c348c", size = 3602177, upload-time = "2026-08-19T05:03:50.571Z" },
+ { url = "https://files.pythonhosted.org/packages/1b/7a/96bac167538748cae2544335855f812fa33e49a9a67bc8b8520dcbd592bd/lxml-6.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:6cb0c87421946030b92b558be416852780a912454e3dcba0998e4497c9c588d5", size = 4004117, upload-time = "2026-08-19T05:03:53.074Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/24/9498fa3c84135956e5ef55ea4d8bd11e999e381f7f210fb6f8c6a980ef03/lxml-6.1.2-cp313-cp313-win_arm64.whl", hash = "sha256:648861c19b775b89ebefa14586f85090b10163367476d77f242c4131c835ce73", size = 3665412, upload-time = "2026-08-19T05:03:55.621Z" },
+ { url = "https://files.pythonhosted.org/packages/27/b5/728b0578791b397ace8d1b101c8b3fe10f36043542f7bb85f82d8bdc3f50/lxml-6.1.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:d50a44113fe6800dcc8a859332b823a4735b1e6ae1b0063882e4cca569ec3e29", size = 8609651, upload-time = "2026-08-19T04:58:42.42Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/6b/49209fa6225c15c48a30061f03d3aba75e3c19634813b88bf83b88c525ed/lxml-6.1.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fa813b0247d0543a563b993ac3dba6168eef59e3a61448432cf5453300c2412b", size = 4639588, upload-time = "2026-08-19T04:59:01.501Z" },
+ { url = "https://files.pythonhosted.org/packages/20/86/80bae4e8bc2eed9d6f017701a3d86fdea56936218efa738911d0b76aa7f4/lxml-6.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d858e718b94033ab4b67e4a58fe3114c65bae01ae2314a62fb39ae8897ed4324", size = 4964846, upload-time = "2026-08-19T04:59:08.59Z" },
+ { url = "https://files.pythonhosted.org/packages/70/ce/4782caee7a22959c1ac67cb46495e03912c22a4ba7d20c163496a519e815/lxml-6.1.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e3b666f57a5d81562f38c766c762416b0f6eb58a00590546911514b48412abd", size = 5099288, upload-time = "2026-08-19T04:59:18.649Z" },
+ { url = "https://files.pythonhosted.org/packages/32/21/f120967cc43b54e05512dff0c39726b832c836195d30f41f88733ef36ac8/lxml-6.1.2-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:26ff164c6629e5c4d11c9e55d5ea3d6eed0be2a420eee1f55cbce6e2c23e231a", size = 5036837, upload-time = "2026-08-19T04:59:47.217Z" },
+ { url = "https://files.pythonhosted.org/packages/61/ba/8005e9f47598e3ec5c18312c77f94e889580027616678848405c6aeba5de/lxml-6.1.2-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:962c12b51d0b164f12569af225dea57568477e24a845b96eaccbef6c07e4cc03", size = 5658569, upload-time = "2026-08-19T04:59:54.078Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/ba/add33b3c7ce51462cf7a4637bcfec2eaa258364d6015b989dd7d1216e6a6/lxml-6.1.2-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47e367dfe341521426692819803e260d0673899c0ff611f14af978d725e2c999", size = 5246003, upload-time = "2026-08-19T04:59:59.764Z" },
+ { url = "https://files.pythonhosted.org/packages/05/b3/a43012748fb861c914c5eac1c1a3bad44282e767499cd02280d4d1edf092/lxml-6.1.2-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:92c2b366028ac01e90399e6d17734ce6e4f4aeddd8ba75fbaf80ea11d6c6d645", size = 5354047, upload-time = "2026-08-19T05:00:21.657Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/cb/813021d9a445713b8d758b9e5eae2ed392cd598d9f119d9b053b37c2ab93/lxml-6.1.2-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:7e81fc065ede5d58dd0bf0912025aee1bd04c52c2affd61fdb93226a97ce2fc6", size = 4704382, upload-time = "2026-08-19T05:00:47.067Z" },
+ { url = "https://files.pythonhosted.org/packages/17/c9/1155299f4577bebf3c280497534a73e4b8ad8cab3b96074731ad10949d4e/lxml-6.1.2-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:633ac039cb32366dd5935868e041e385875c017b8cd54ea56aeee3fe29ca5935", size = 5258530, upload-time = "2026-08-19T05:01:14.893Z" },
+ { url = "https://files.pythonhosted.org/packages/25/6e/d76e58384b378b877e140e25b9a9835da00035f81ff70cbe943a3749bf27/lxml-6.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f3194777c0d05945ac91d8594be25d2679d1d826e01e1fc90bae568ff3a547b", size = 5089919, upload-time = "2026-08-19T05:01:33.602Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/b7/898013c0f8891481d0624ab3bd5dd8c8ff827232dfee2a5d1f8bf970a7cc/lxml-6.1.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:1133bd969f2bfcc6b0c0cf7cdf5f2631e62b23fa2471ee8bd44f6ab73554ee9a", size = 4741972, upload-time = "2026-08-19T05:01:38.18Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/47/efb53c4d7b655831c03317a450d9da439b0829c61f34d9d4fe7c863445d6/lxml-6.1.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:1edca8f4a92b94e873093df959f141d388f2141fcad0c47598442fb4730ef57a", size = 5683241, upload-time = "2026-08-19T05:02:00.731Z" },
+ { url = "https://files.pythonhosted.org/packages/da/0a/0ff36a584cbba14a71326ee8a5300694400f0b97927d1f90a87d95b17d4a/lxml-6.1.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:8512b3775d68994dd1d6d533161e0a214f2ad9c634659d34a99c98e86c6c3d68", size = 5245892, upload-time = "2026-08-19T05:02:06.108Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/9e/303717a1aa56d4bd775c91936717d3c9e8d999a8e8b68b00979c4c1f93d0/lxml-6.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5005c0c9e4d749a76a2ff8bd5918a8bb248df8e08e73a55654b9f79c9cd1e2b", size = 5269528, upload-time = "2026-08-19T05:02:09.883Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/c2/2ae7cb97089eb86bf0689516db3cf280a007b6145853d2a0235a1f01683d/lxml-6.1.2-cp314-cp314-win32.whl", hash = "sha256:e17e2c30e27f56da5551e7a425888b45f013e940b99ab07d125a1c33f77a4605", size = 3662743, upload-time = "2026-08-19T05:03:02.513Z" },
+ { url = "https://files.pythonhosted.org/packages/77/13/a3d483230a09201e211ceb1aa208b1374d27d23b8b180d74dba14b30f6b3/lxml-6.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:87e9673cd8a3445024fe38e7f91b55fa3428437eec9b7a7ff7d81979520c0d2d", size = 4073942, upload-time = "2026-08-19T05:03:04.864Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/f1/c1445d4b6ad7c51e39d4e2ebbf015a4880f5b297a4ab0e77e4d0e5b70110/lxml-6.1.2-cp314-cp314-win_arm64.whl", hash = "sha256:878e7c8ada8f92c52f13f35a2ab98ef0adf7fd0211d164fc2af589e4c3cfed63", size = 3749235, upload-time = "2026-08-19T05:03:07.239Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/eb/598c76f4ce19a67c635e86a46d880cc854f308f39a6f1fdf13bbb01813ec/lxml-6.1.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:94162456ed0a64fb1c06915df5bd06af4675ae3966d6048fcb73b0906e0e0222", size = 8860315, upload-time = "2026-08-19T05:02:14.39Z" },
+ { url = "https://files.pythonhosted.org/packages/da/c7/1f9fac7b566a86ad0da13dcc0259164266469c0ad86744c740ccd5c2a081/lxml-6.1.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:4b0fa7109b1d0bc1747d8241a0853e135eefb1c978685241b544c46937383efd", size = 4755176, upload-time = "2026-08-19T05:02:18.705Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/1b/cfda9307388d496e7eeb7493d9455896b8137ed95f51f3d6ae6ddcc14a47/lxml-6.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:604f4778632588d7c000e7e19430639dc12fca58b5b6e99edffba7631725ef0e", size = 4979444, upload-time = "2026-08-19T05:02:21.262Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/71/f732c8919c45b7f29acf443288c6e90036877a67bfeeb1acceb0fffa011b/lxml-6.1.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5a096d6a5f96b776a5b020cb45c17c545effd2a3b6639e6fa97bc95537600923", size = 5115887, upload-time = "2026-08-19T05:02:23.62Z" },
+ { url = "https://files.pythonhosted.org/packages/30/00/121d52b944f41e33ea86c62875f902d24982842dc7231ab154ac5a6c6593/lxml-6.1.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6454d184d556eaf4cb3d6f69e405d21602d6fdcf08b8d57796824275986c6595", size = 5032418, upload-time = "2026-08-19T05:02:26.114Z" },
+ { url = "https://files.pythonhosted.org/packages/70/19/cadb73c7fe48c7563dc8ab62ea53d5b920c8911bfb808507a6daa82e78d2/lxml-6.1.2-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8b68f2548259bb04e0b3d5df0c397abe8b0080f5e1ffe4019fb7a8bf01a9339e", size = 5603304, upload-time = "2026-08-19T05:02:28.694Z" },
+ { url = "https://files.pythonhosted.org/packages/13/32/9de126a14d5a5db8c371c5ec869178417db226707b62a47273a95ae6df7f/lxml-6.1.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c9cc4b6532abe154dbdebb42aaba8d52c852919591e45067f5b7d46a0405e88", size = 5228938, upload-time = "2026-08-19T05:02:30.99Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/9b/22dd9e843629ed04652591fb220eb2bf2394d97be3be377d60d8083405d7/lxml-6.1.2-cp314-cp314t-manylinux_2_28_i686.whl", hash = "sha256:57188e441ab24f906bd5a5c14eb55363ab51aa6c0de549f3dd320043721cc118", size = 5317790, upload-time = "2026-08-19T05:02:33.301Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/2c/b12a1dc121f81c280635c721c7bcaa341441fcbe37397f60b8915048aece/lxml-6.1.2-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d0bfd719c254bbe60ea022cff0e6ffb799a6fa7d4d72852cebe0257957b32d68", size = 4646468, upload-time = "2026-08-19T05:02:35.504Z" },
+ { url = "https://files.pythonhosted.org/packages/57/41/fd87a41edc531e7969c25ab1d6b52b5b041eb108b88f6394d6afb4374396/lxml-6.1.2-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:be6f87cd224254a8f81324e34cc655508b83f1d70458a1a39857ad2aa9925852", size = 5240607, upload-time = "2026-08-19T05:02:37.805Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/30/713ba813b6e6673c6dc34733746516017efcd17949b767b154cc50bccf20/lxml-6.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:074a88f70a7360a4a0c5be5d898062cd26f898c25b459efb1bdd43ae700c5a1a", size = 5086495, upload-time = "2026-08-19T05:02:40.099Z" },
+ { url = "https://files.pythonhosted.org/packages/33/f8/6532ce0fecd9c326d06b08274ee075cc28dbc9f5e9285355db8504689114/lxml-6.1.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:9031f5f01452681abf39fdd65f84a70cb01a7572a1bbf570042e826b1232d07b", size = 4758801, upload-time = "2026-08-19T05:02:45.434Z" },
+ { url = "https://files.pythonhosted.org/packages/74/b6/5a1f7833ebaa0dd33c28f6f9755ec6ff3891bf63f097634b44e6da1bb65e/lxml-6.1.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:cfeac14425fc7a6fca7864b774d4ee63547926158f4a18c67d77b2c9a948acf1", size = 5626977, upload-time = "2026-08-19T05:02:48.092Z" },
+ { url = "https://files.pythonhosted.org/packages/e6/20/6ae0fc1b45e20877cdcfb1168ceeaf9abb0fba5ed36bd639a260e7b2101e/lxml-6.1.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8ec111ff8067325f85c08aa9c2b26179ec0537bb89c003fde31127139f85f82d", size = 5235036, upload-time = "2026-08-19T05:02:50.726Z" },
+ { url = "https://files.pythonhosted.org/packages/47/b4/2bc7b37fbb990ccfb7d30393660741592177224a94e07d842c8da70638e8/lxml-6.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:48e912f37c99a297175ba955f55a47c0e1c834b506ef162e52a6e4fe276e6e45", size = 5252270, upload-time = "2026-08-19T05:02:53.454Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/0b/07fb8e1dee29a78e2c5fa5c6c914218be76a6406baff27907429566e90ec/lxml-6.1.2-cp314-cp314t-win32.whl", hash = "sha256:7c444c3a6e8e75334879980eed96568f0e12064c8b1913424eac1805e976736b", size = 3902666, upload-time = "2026-08-19T05:02:55.607Z" },
+ { url = "https://files.pythonhosted.org/packages/58/ab/3371527bd9820aae6f511697c93032ed197b0d8dab0f17818f18d3099637/lxml-6.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:7f35ba7667004ecdafebbe08da7c9fa06ee6195275bb7ef7a29ee1901e69519c", size = 4401011, upload-time = "2026-08-19T05:02:57.899Z" },
+ { url = "https://files.pythonhosted.org/packages/e6/bb/e6de9b2546a4e6df4fb52fb18921906a8b7a041aba06570995759a4d6d8b/lxml-6.1.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d117f39b28ab8a330a74abdbe61c2255b51973b238db25fd6c2448de1eb2a02d", size = 3823384, upload-time = "2026-08-19T05:03:00.371Z" },
+ { url = "https://files.pythonhosted.org/packages/0e/83/7ff98683e14a148191278728d11ba782c3d5137886d49fd95ab4036efa1b/lxml-6.1.2-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:1e3c67b817867c484794d7fe0d73045d7d0c67460c78a0a1249a9e92266e6a0e", size = 8609183, upload-time = "2026-08-19T04:58:32.19Z" },
+ { url = "https://files.pythonhosted.org/packages/24/39/c39f05e8240e98009dd3d4ceb248319d0f36467babc5f90a909ed0c5b68a/lxml-6.1.2-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:d3e97ac4353cca3fbbfa829bc0c6a913771573d1c6d46932d4335c46f2b7796a", size = 4639898, upload-time = "2026-08-19T04:58:39.017Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/bf/25e26b089510940a0777ab334357874569255e50930224c8159cd649e754/lxml-6.1.2-cp315-cp315-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:827438bf6c8292d22a409bb7990d7cffce410f33e7664e46ca74d2ecc26975ef", size = 5037527, upload-time = "2026-08-19T04:58:46.224Z" },
+ { url = "https://files.pythonhosted.org/packages/65/6d/aed3a58a3d662f7367a537fabe8c549f1446dbd043719e0ae8cd53f47819/lxml-6.1.2-cp315-cp315-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c470d192e27f97842a068cf12a1c1296b20ca716c56a9249715c6654bc192d19", size = 5661918, upload-time = "2026-08-19T04:59:02.534Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/ca/706d32b6957c0c2e005a9833e8fc528449196b38d5cfcf9e0fd86a96fb00/lxml-6.1.2-cp315-cp315-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef0b8ba6e13597f681b2b4924ca9c4e8c88420bf0e21d9a9006c757f2fc39d1f", size = 5249359, upload-time = "2026-08-19T05:04:01.956Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/e9/445ff43f56fcffa06f6f3a7189920c216f3eacef68ef834d4111cdbd86ba/lxml-6.1.2-cp315-cp315-manylinux_2_31_armv7l.whl", hash = "sha256:65c32ddc5d0750129c7b119fb57d48192b76d334c21e6b690d19dfb06b34af79", size = 4704548, upload-time = "2026-08-19T05:04:04.57Z" },
+ { url = "https://files.pythonhosted.org/packages/69/78/20b8b7e79a1b1d9cd4465c332d62962858562b446692f16a27068fa54b85/lxml-6.1.2-cp315-cp315-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0aa07065497f191ad26c4b587ce5dbb5a7105285a3789aafd0661750e8bac537", size = 5261170, upload-time = "2026-08-19T05:04:07.336Z" },
+ { url = "https://files.pythonhosted.org/packages/54/ca/84a0e1148bf511e12e0d99732a4e136a3bf1b91622f0a1b197796e2ff984/lxml-6.1.2-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:cde6b8db7d2e5135129eb5e74b7b44dd2053aa767cd5023541fccedddc262453", size = 5090576, upload-time = "2026-08-19T05:04:09.968Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/f8/1ef6fc7070bed8753315f2e4ea66bc0d37620e1444d014db7f0267b8faaf/lxml-6.1.2-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:b28842b30c4bc2e6afe137d98a5d2071a62589471e76d053bea55b0e53298af9", size = 4744614, upload-time = "2026-08-19T05:04:12.717Z" },
+ { url = "https://files.pythonhosted.org/packages/87/f6/3a4824cd1c1b81d996d2d75bbd176ba13fbe9b5d89489290d93ff9558486/lxml-6.1.2-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:11f529062255209a421ae4de5b1bb36b2f0a2e1a700745e675a4bf4084d13c00", size = 5685792, upload-time = "2026-08-19T05:04:15.367Z" },
+ { url = "https://files.pythonhosted.org/packages/64/9a/f133bf16a67149e00ca5d8a8f1ae662c30a86c303aa242693b67f8e19856/lxml-6.1.2-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:f8b89b3be75a37509602b03f9cfa1a28298d4eed4625748148307aeb907901b7", size = 5248972, upload-time = "2026-08-19T05:04:18.491Z" },
+ { url = "https://files.pythonhosted.org/packages/50/63/273e7e8a73a5d183d8552dfdaa131dfda0292ddab7bcddc5a66a0ae525d8/lxml-6.1.2-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:1a2331da06dd55a8184985306eb2afd72d708283ce7e85d67bba77317b785060", size = 5271809, upload-time = "2026-08-19T05:04:21.448Z" },
+ { url = "https://files.pythonhosted.org/packages/49/eb/614117c36a28909e79ff7cdec87008f0bd996478f35cf72309189cf398b1/lxml-6.1.2-cp315-cp315-win32.whl", hash = "sha256:442766b326d9892585a64e8c6c4b5ab81d0e6c0538c9f0fc11a84dc101a5d97f", size = 3662854, upload-time = "2026-08-19T05:05:07.141Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/e9/06aee6107cf8e7b870f10f82539f366cba10dc6053144cca80e838caf8c8/lxml-6.1.2-cp315-cp315-win_amd64.whl", hash = "sha256:a7fd1dd6faa3df9dcd8f1765237362cd885ca62cdf77a7c5f5ea383ae5b6048b", size = 4074590, upload-time = "2026-08-19T05:05:09.697Z" },
+ { url = "https://files.pythonhosted.org/packages/84/bf/dad9b6baf9b26d79584834e15cef2a5dd0a13c7b1df08831e8f18244b494/lxml-6.1.2-cp315-cp315-win_arm64.whl", hash = "sha256:054175250531a5fb102d485743ff16412279c93add12385b3b1c3d7b16d8deaa", size = 3749336, upload-time = "2026-08-19T05:05:12.334Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/9d/cd0c43d45e2eb52df7735c6558f24054ca633499191899b0cb9040fbbc3c/lxml-6.1.2-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:84a2a46b93b789d8acb44cfcb3d967ce9dbe29884ddb93fbb1a33f0e0c8fcd86", size = 8857688, upload-time = "2026-08-19T05:04:24.648Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/26/27093dc1a9edbdd8a54652f237a387f7e63ec0192efe708bc2576d8a1383/lxml-6.1.2-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:4aced3284e0353c798b060fe2c175eb81410e99b9a7e2ae6951be5333732b111", size = 4754422, upload-time = "2026-08-19T05:04:27.645Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/ee/502f7c93507f57eb496744a64da8f4ca86855cf88e48d14584342f1bfd92/lxml-6.1.2-cp315-cp315t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:47c92dc5167de16e27ace8332454f12ba172dcab04f7a78a9eae14e2e41b6a41", size = 5033396, upload-time = "2026-08-19T05:04:30.054Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/72/c4cbbe72f951650f2afe43a70e51687e111d82b9bec46e3310ea76419d46/lxml-6.1.2-cp315-cp315t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:40366c23a938008a3bedfcfd80709b3a857c188b4d710b083e978ef5d2c1c715", size = 5615298, upload-time = "2026-08-19T05:04:32.752Z" },
+ { url = "https://files.pythonhosted.org/packages/14/83/a3df966d6d7b6513e9dfb6fbfb041c0619642170359c1b36ab20a83e59eb/lxml-6.1.2-cp315-cp315t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c4c6dc1b2485aaa4adfb6ed754f90dddcb2b96a66bbebc9e1ac242b5ce5e818", size = 5236282, upload-time = "2026-08-19T05:04:35.762Z" },
+ { url = "https://files.pythonhosted.org/packages/4f/85/8692ec8173c9f8d295735b9bf410d202317e7b3ed11141e80a30f421f409/lxml-6.1.2-cp315-cp315t-manylinux_2_31_armv7l.whl", hash = "sha256:3a698fad6f122a9b3e2dc2fb598c1de7329c74a67c7a334c9109a440de2508e5", size = 4650647, upload-time = "2026-08-19T05:04:38.396Z" },
+ { url = "https://files.pythonhosted.org/packages/11/e7/dbe3cece28a5bf82997a091d9dbb0fc49e725a5fa86550897ee2cf6412e6/lxml-6.1.2-cp315-cp315t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:14879fa5eb2b793c040bbfcb62011aa3015c65d6c9875e063ea98ce2029d51fb", size = 5243387, upload-time = "2026-08-19T05:04:41.247Z" },
+ { url = "https://files.pythonhosted.org/packages/99/a9/81a2d27640db0d27200b2f32339a54e74c36d58feb5ad528b87d52a59ecc/lxml-6.1.2-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:b631174cd2e4d9f8a94ef17f911c6ded10ede93b5e7860dee7bbf85961d321e9", size = 5092624, upload-time = "2026-08-19T05:04:43.919Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/f4/0b0304c70c087f618d95b0306738b070bd556afd09c2c92589b78dbe5eb0/lxml-6.1.2-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:ceafa5e0536c62a5cd9f65327fa0b57d6f0b0e3435daf2c98a78d0dde7ecbae1", size = 4758742, upload-time = "2026-08-19T05:04:46.615Z" },
+ { url = "https://files.pythonhosted.org/packages/a2/ec/f9fc45f1d01b632b673e11880e75292dff9953db9f426d1a38201b8eb5f5/lxml-6.1.2-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:7c482e87cc86bed78a50462560675bc2c348ef72c47596f9b933346d5a8e920e", size = 5649540, upload-time = "2026-08-19T05:04:49.777Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/0b/d65e0458c2bcce0df68d5cc29ad0006e76446f02d9e50caf188fd1fb8bae/lxml-6.1.2-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:b1c0d2dde8a50520efc51644587f0fc4810e3af7d3e029d7af0be93bf39e2b5c", size = 5234869, upload-time = "2026-08-19T05:04:52.972Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/62/1fee828238badd3bfe9544f5cc9ce6ded421ef38e9634030445dedd78b36/lxml-6.1.2-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:dd7ea3fa47154b9fff90591b961e41b3718bd7fcd5bc2d9bb47e9845c8ace088", size = 5259992, upload-time = "2026-08-19T05:04:56.028Z" },
+ { url = "https://files.pythonhosted.org/packages/20/18/35fb14dd6baccbffa6daeb2369802f04a94e3f73db3c7bb405dbab009729/lxml-6.1.2-cp315-cp315t-win32.whl", hash = "sha256:87534cec6ea325435e4adf2326b0cf3110eee9a47abf73652eb155db639c08c6", size = 3901151, upload-time = "2026-08-19T05:04:58.671Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/b6/07530896ca062bc3d2f09d5cb8a48e799c05b12c496205db03159ba13b6c/lxml-6.1.2-cp315-cp315t-win_amd64.whl", hash = "sha256:4e220a9c297e5d36895d489a08c9a3f1f6193b6414e702c5fb751e4a3767f8d0", size = 4395355, upload-time = "2026-08-19T05:05:01.651Z" },
+ { url = "https://files.pythonhosted.org/packages/31/a8/237d8de1d77085cfd41d0c6049a044d8d01886f3afb7f1eda2f43d900a96/lxml-6.1.2-cp315-cp315t-win_arm64.whl", hash = "sha256:f16a407766bac51c65d605b06d900821751a79aa20e12185f273f14a17180e7b", size = 3822823, upload-time = "2026-08-19T05:05:04.63Z" },
+]
+
[[package]]
name = "mako"
version = "1.4.1"
@@ -1059,6 +1191,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/64/a8/bb76c7356de8ad57f59d5ff993d434df0607f07f08bcc9c9a5c275e399c0/openai-2.54.0-py3-none-any.whl", hash = "sha256:89089789197ccdb87f173a03145ed1598d00795220c93e96cf712b1cbf5e5f2b", size = 1660351, upload-time = "2026-08-11T18:46:56.684Z" },
]
+[[package]]
+name = "openpyxl"
+version = "3.1.5"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "et-xmlfile" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/3d/f9/88d94a75de065ea32619465d2f77b29a0469500e99012523b91cc4141cd1/openpyxl-3.1.5.tar.gz", hash = "sha256:cf0e3cf56142039133628b5acffe8ef0c12bc902d2aadd3e0fe5878dc08d1050", size = 186464, upload-time = "2024-06-28T14:03:44.161Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c0/da/977ded879c29cbd04de313843e76868e6e13408a94ed6b987245dc7c8506/openpyxl-3.1.5-py2.py3-none-any.whl", hash = "sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2", size = 250910, upload-time = "2024-06-28T14:03:41.161Z" },
+]
+
[[package]]
name = "orjson"
version = "3.12.0"
@@ -1175,6 +1319,77 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" },
]
+[[package]]
+name = "pillow"
+version = "12.3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/37/bf/fb3ebff8ddcb76aac5a01389251bbbb9519922a9b520d8247c1ca864a25d/pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965", size = 5345969, upload-time = "2026-07-01T11:54:06.397Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/66/9a386a92561f402389a4fc70c18838bf6d35eb5eb5c6850b4b2dc64f5048/pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7", size = 4780323, upload-time = "2026-07-01T11:54:09.351Z" },
+ { url = "https://files.pythonhosted.org/packages/25/27/ac8f99618ffd3dde21db0f4d4b1d2ab00c0880595bfd17df103f7f39fd0c/pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9", size = 6266838, upload-time = "2026-07-01T11:54:11.71Z" },
+ { url = "https://files.pythonhosted.org/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91", size = 6940830, upload-time = "2026-07-01T11:54:13.732Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/51/8b08617af3ad95e33ce6d7dd2c99ed6c8298f7fb131636303956be022e25/pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c", size = 6344383, upload-time = "2026-07-01T11:54:15.756Z" },
+ { url = "https://files.pythonhosted.org/packages/1d/72/cf78ac9780bb93c28328f408973845a309d4d145041665f734572ced1b52/pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df", size = 7052934, upload-time = "2026-07-01T11:54:17.721Z" },
+ { url = "https://files.pythonhosted.org/packages/20/20/25e0f4dc178a6bc0696793720055519a0de89e7661dae886992decbd2f81/pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f", size = 6472684, upload-time = "2026-07-01T11:54:19.839Z" },
+ { url = "https://files.pythonhosted.org/packages/45/89/da2f7971a317f83d807fdd4065c0af40208e59e692cc43d315a71a0e96d1/pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09", size = 7227137, upload-time = "2026-07-01T11:54:22.025Z" },
+ { url = "https://files.pythonhosted.org/packages/de/47/4845a0a6c0dbf1db8456bd9fc791f13c5ced7ced20606d08a0aacfd25b49/pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510", size = 2568267, upload-time = "2026-07-01T11:54:24.051Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/ac/31fb64e1e7efb5a4b50cd3d92049ba89ac6e4d8d3bb6a74e15048ca3353e/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89", size = 4161684, upload-time = "2026-07-01T11:54:25.934Z" },
+ { url = "https://files.pythonhosted.org/packages/87/b4/9805e23d2b4d77842b468513841fda254ee42f0289d25088340e4ff46e2d/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace", size = 4255487, upload-time = "2026-07-01T11:54:27.935Z" },
+ { url = "https://files.pythonhosted.org/packages/df/39/ecf519435a200c693fe053a6ee4d835b41cf963a4dfc2551c4e637cb2a71/pillow-12.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec", size = 3696433, upload-time = "2026-07-01T11:54:29.813Z" },
+ { url = "https://files.pythonhosted.org/packages/42/92/2fc3ffad878ae8dd5469ec1bc8eb83b71f48e13efdf68f02709003982a32/pillow-12.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66", size = 5345889, upload-time = "2026-07-01T11:54:31.97Z" },
+ { url = "https://files.pythonhosted.org/packages/10/76/8803c13605b763d33d156c4678fc77f8443389c0c51c8aef707bb02015f4/pillow-12.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35", size = 4780109, upload-time = "2026-07-01T11:54:34.026Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/01/e18aff37cb0b4aac47ac90f016d347a49aca667ef97f190b06ac2aabc928/pillow-12.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65", size = 6263736, upload-time = "2026-07-01T11:54:36.131Z" },
+ { url = "https://files.pythonhosted.org/packages/f7/62/de5bdd77d935331f4f802edc11e4d82950f642caad6cb2f949837b8560e2/pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3", size = 6937129, upload-time = "2026-07-01T11:54:38.216Z" },
+ { url = "https://files.pythonhosted.org/packages/70/4d/105627a13300c5e0df1d174230b32fd1273062c96f7745fd552b945d1e1d/pillow-12.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a", size = 6339562, upload-time = "2026-07-01T11:54:40.354Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/1d/f13de01a553988ab895ba1c722e06cf3144d4f57656fd5b81b6d881f1179/pillow-12.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e", size = 7049439, upload-time = "2026-07-01T11:54:42.489Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/f9/066794cca041b969964f779ee5fa66a9498bbf34248ac39c5d7954e4198f/pillow-12.3.0-cp313-cp313-win32.whl", hash = "sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f", size = 6473287, upload-time = "2026-07-01T11:54:44.9Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/9b/7a58e61d62be561da3a356fe2384d4059a6345fc130e23ef1c36a5b81d24/pillow-12.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8", size = 7239691, upload-time = "2026-07-01T11:54:47.141Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/b0/c4ed4f0ef8f8fa5ee8351537db6650bb8189f7e118842978dd6589065692/pillow-12.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b", size = 2568185, upload-time = "2026-07-01T11:54:49.137Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/01/001f65b68192f0228cc1dbbc8d2530ab5d58b61037ba0587f946fea607cd/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330", size = 4161736, upload-time = "2026-07-01T11:54:51.156Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/d2/0219746d0fd16fc8a84498e79452375be3797d3ce4044596ce565164b84f/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217", size = 4255435, upload-time = "2026-07-01T11:54:53.414Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/02/8d0bc62ef0302318c46ff2a512822d2610e81c7aa46c9b3abe6cbaca5ad0/pillow-12.3.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930", size = 3696262, upload-time = "2026-07-01T11:54:55.739Z" },
+ { url = "https://files.pythonhosted.org/packages/85/e2/73c77d218410b14f5f2d565e8a998d5317b7b9c75368d29985139f7a46f0/pillow-12.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8", size = 5350344, upload-time = "2026-07-01T11:54:57.657Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/da/32c752228ae345f489e3a42499d817b6c3996da7e8a3bc7a04fc806b243b/pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0", size = 4780131, upload-time = "2026-07-01T11:54:59.713Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/9d/8b2c807dbef61a5197c047afe99823787eb66f63daf9fb2432f91d6f0462/pillow-12.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321", size = 6263757, upload-time = "2026-07-01T11:55:01.778Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/44/c85361f65dbe00eea8576ee467c768d25129989efb76e94f205e9ca9bb46/pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b", size = 6936962, upload-time = "2026-07-01T11:55:03.93Z" },
+ { url = "https://files.pythonhosted.org/packages/18/7e/e483414b35800b86b6f08dbbc7803fb5cd52c4d6f897f47d53ea2c7e6f65/pillow-12.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198", size = 6339171, upload-time = "2026-07-01T11:55:05.989Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/f4/68c491844841ede6bed70189546b3ee9731cf9f2cbad396faff5e1ccba45/pillow-12.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130", size = 7048116, upload-time = "2026-07-01T11:55:08.131Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/34/77f3f793fed8efc7d243f21b33c5a3f0d1c97ee70346d3db855587e155ff/pillow-12.3.0-cp314-cp314-win32.whl", hash = "sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a", size = 6467209, upload-time = "2026-07-01T11:55:10.408Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/e0/492879f69d94f91f60fc8cd05ba03650e9520afebb2fb7aa12777d7c7f38/pillow-12.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d", size = 7237707, upload-time = "2026-07-01T11:55:12.745Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/ac/6b11f2875f1c2ac040d84e1bbf9cf22a88038f901ca1037898b280b38365/pillow-12.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838", size = 2565995, upload-time = "2026-07-01T11:55:14.736Z" },
+ { url = "https://files.pythonhosted.org/packages/52/69/c2208e56af9bfc1913afb24020297a691eb1d4ef688474c8a04913f65e04/pillow-12.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e", size = 5352503, upload-time = "2026-07-01T11:55:17.076Z" },
+ { url = "https://files.pythonhosted.org/packages/07/70/e5686d753e898a45d778ff1718dba8516ead6ab6b95d85fc8c4b70650cf2/pillow-12.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17", size = 4782956, upload-time = "2026-07-01T11:55:19.448Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/37/25c6692f06927ee973ff18c8d9ee98ad0b4d84ee67a09610c2dd1447958e/pillow-12.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385", size = 6322855, upload-time = "2026-07-01T11:55:21.613Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/91/420637fcb8f1bc11029e403b4538e6694744428d8246118e45719f944556/pillow-12.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c", size = 6989642, upload-time = "2026-07-01T11:55:24.006Z" },
+ { url = "https://files.pythonhosted.org/packages/10/08/b94d7811281ccf0d143a1cf768d1c49e1e54af63e7b708ab2ee3eb87face/pillow-12.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d", size = 6391281, upload-time = "2026-07-01T11:55:26.252Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/87/24233f785f55474dc02ce3e739c5528a77e3a862e9333d1dd7a25cc31f70/pillow-12.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931", size = 7096716, upload-time = "2026-07-01T11:55:28.318Z" },
+ { url = "https://files.pythonhosted.org/packages/23/26/fcb2f6e37175b04f53570b59937867e2b80ee1685e744023153028fc14f9/pillow-12.3.0-cp314-cp314t-win32.whl", hash = "sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7", size = 6474125, upload-time = "2026-07-01T11:55:30.956Z" },
+ { url = "https://files.pythonhosted.org/packages/90/de/3634abee5f1c9e13c56787b7d5517b0ba8d6de51700b95578cf338349c9f/pillow-12.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c", size = 7242939, upload-time = "2026-07-01T11:55:34.044Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/2a/fd13f8eb24de5714a6eb444a3d67e2842c6c576e159a43793adf23051351/pillow-12.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45", size = 2567506, upload-time = "2026-07-01T11:55:35.988Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/dc/8fdce34ec725a33c81c6ba122b904d6b9024e50ea9ac7bede62fab54506c/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139", size = 4162063, upload-time = "2026-07-01T11:55:37.941Z" },
+ { url = "https://files.pythonhosted.org/packages/76/66/2044b9a63d3b84ff048228dfcb7cd9bf0df983e8470971bf7d4c57b693de/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402", size = 4255549, upload-time = "2026-07-01T11:55:40.022Z" },
+ { url = "https://files.pythonhosted.org/packages/52/7e/1f67e6f4ece6b582ee4b539decbcc9f848dc245a93ed8cd7338bafef72f1/pillow-12.3.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c", size = 3696331, upload-time = "2026-07-01T11:55:41.98Z" },
+ { url = "https://files.pythonhosted.org/packages/12/40/d306fc2c8e4d45d7f175c77edca7063be7b86fe7fe6e68f4353bf71d808c/pillow-12.3.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f", size = 5350370, upload-time = "2026-07-01T11:55:44.028Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/44/668fb1437e8ce420f62d6106eb66e44a5971602a4d794615bdf79315d82d/pillow-12.3.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701", size = 4780147, upload-time = "2026-07-01T11:55:46.073Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/08/93fa2e70e30a2d81547e481b6ee2bb9522117221fb1e0ce4b5df70967677/pillow-12.3.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace", size = 6273659, upload-time = "2026-07-01T11:55:48.264Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/6d/043e96ff814fc31a33077e4cba86082167db520c93632afdf2042febbb0c/pillow-12.3.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4", size = 6947439, upload-time = "2026-07-01T11:55:50.503Z" },
+ { url = "https://files.pythonhosted.org/packages/af/92/ba71d2ee2ac0edf3fa33bd9d5ee9ee080da70b1766f3ca3934f9938ddac9/pillow-12.3.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39", size = 6353577, upload-time = "2026-07-01T11:55:52.697Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/ce/e63064e2122923ff687c8ad792d0d736a7b3920a56a46982e81a7fdd25d6/pillow-12.3.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71", size = 7060394, upload-time = "2026-07-01T11:55:55.149Z" },
+ { url = "https://files.pythonhosted.org/packages/54/76/a09cc3ccc8d773a7283d34c38bec1708f9e3cc932093cbc4c5e71ac4060b/pillow-12.3.0-cp315-cp315-win32.whl", hash = "sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827", size = 6467375, upload-time = "2026-07-01T11:55:57.769Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/03/1846c49ba3b1d5550392a4bbd06d6fb4578e1cd91a803198b5c90f5f7d53/pillow-12.3.0-cp315-cp315-win_amd64.whl", hash = "sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5", size = 7237048, upload-time = "2026-07-01T11:55:59.975Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/bb/89f35dcc79610423f9f195504d7def7f0d1416a711541b42867e25fe3412/pillow-12.3.0-cp315-cp315-win_arm64.whl", hash = "sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658", size = 2566006, upload-time = "2026-07-01T11:56:02.143Z" },
+ { url = "https://files.pythonhosted.org/packages/30/88/707027ba09942dfa2c28759b5c222d769290a41c6d20ea60ec250801941f/pillow-12.3.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf", size = 5352509, upload-time = "2026-07-01T11:56:04.2Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/6d/00352fa25332c2569cd387851f568cc5a4b75a9adbfb37ac4fbce4c02eec/pillow-12.3.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64", size = 4783167, upload-time = "2026-07-01T11:56:06.631Z" },
+ { url = "https://files.pythonhosted.org/packages/13/4f/9e049dfa21af7c22427275720e2490267ba8138120add5c4c574deb69782/pillow-12.3.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e", size = 6329237, upload-time = "2026-07-01T11:56:08.868Z" },
+ { url = "https://files.pythonhosted.org/packages/36/16/cf6eeaae8d0fce8dd390a33437cf68c5d5bd73834a2bc6e2f14efda0ab45/pillow-12.3.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777", size = 6997047, upload-time = "2026-07-01T11:56:11.379Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/69/dbf769bdd55f48bf5733cac28edc6364ffaa072ec9ba336266e4fe66be55/pillow-12.3.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1", size = 6400440, upload-time = "2026-07-01T11:56:13.908Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/e1/ffc9cfc2eea0d178da8018e18e959301ad9d6bc9f3edb7181e748a474b97/pillow-12.3.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9", size = 7105895, upload-time = "2026-07-01T11:56:16.575Z" },
+ { url = "https://files.pythonhosted.org/packages/18/f0/a5595c1e8c3ae44b9828cb2f0fa8155e5095ef04d6327b8f61cf44a3df85/pillow-12.3.0-cp315-cp315t-win32.whl", hash = "sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8", size = 6474384, upload-time = "2026-07-01T11:56:18.855Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/04/62bcd9f844984c5938d3b05264a61d797a29d3e0812341a8204af70bbdee/pillow-12.3.0-cp315-cp315t-win_amd64.whl", hash = "sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418", size = 7243537, upload-time = "2026-07-01T11:56:21.214Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/68/1f3066acedf37673694a7141381d8f811ae97f30d34413d236abe7d489f1/pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59", size = 2567491, upload-time = "2026-07-01T11:56:23.506Z" },
+]
+
[[package]]
name = "pluggy"
version = "1.6.0"
@@ -1369,6 +1584,26 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/42/a6/9e2824138ffe1b2932c24f836638dabff5c6fbb5a9afcc1ba5ac5cdc39d8/pymilvus-2.6.17-py3-none-any.whl", hash = "sha256:583c680bea60b5944762ae81dd577169efe3b895b579d78e60bab05c087c4f89", size = 342148, upload-time = "2026-07-17T06:23:31.188Z" },
]
+[[package]]
+name = "pypdfium2"
+version = "4.30.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/a1/14/838b3ba247a0ba92e4df5d23f2bea9478edcfd72b78a39d6ca36ccd84ad2/pypdfium2-4.30.0.tar.gz", hash = "sha256:48b5b7e5566665bc1015b9d69c1ebabe21f6aee468b509531c3c8318eeee2e16", size = 140239, upload-time = "2024-05-09T18:33:17.552Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c7/9a/c8ff5cc352c1b60b0b97642ae734f51edbab6e28b45b4fcdfe5306ee3c83/pypdfium2-4.30.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:b33ceded0b6ff5b2b93bc1fe0ad4b71aa6b7e7bd5875f1ca0cdfb6ba6ac01aab", size = 2837254, upload-time = "2024-05-09T18:32:48.653Z" },
+ { url = "https://files.pythonhosted.org/packages/21/8b/27d4d5409f3c76b985f4ee4afe147b606594411e15ac4dc1c3363c9a9810/pypdfium2-4.30.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:4e55689f4b06e2d2406203e771f78789bd4f190731b5d57383d05cf611d829de", size = 2707624, upload-time = "2024-05-09T18:32:51.458Z" },
+ { url = "https://files.pythonhosted.org/packages/11/63/28a73ca17c24b41a205d658e177d68e198d7dde65a8c99c821d231b6ee3d/pypdfium2-4.30.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e6e50f5ce7f65a40a33d7c9edc39f23140c57e37144c2d6d9e9262a2a854854", size = 2793126, upload-time = "2024-05-09T18:32:53.581Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/96/53b3ebf0955edbd02ac6da16a818ecc65c939e98fdeb4e0958362bd385c8/pypdfium2-4.30.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3d0dd3ecaffd0b6dbda3da663220e705cb563918249bda26058c6036752ba3a2", size = 2591077, upload-time = "2024-05-09T18:32:55.99Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/ee/0394e56e7cab8b5b21f744d988400948ef71a9a892cbeb0b200d324ab2c7/pypdfium2-4.30.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cc3bf29b0db8c76cdfaac1ec1cde8edf211a7de7390fbf8934ad2aa9b4d6dfad", size = 2864431, upload-time = "2024-05-09T18:32:57.911Z" },
+ { url = "https://files.pythonhosted.org/packages/65/cd/3f1edf20a0ef4a212a5e20a5900e64942c5a374473671ac0780eaa08ea80/pypdfium2-4.30.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f1f78d2189e0ddf9ac2b7a9b9bd4f0c66f54d1389ff6c17e9fd9dc034d06eb3f", size = 2812008, upload-time = "2024-05-09T18:32:59.886Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/91/2d517db61845698f41a2a974de90762e50faeb529201c6b3574935969045/pypdfium2-4.30.0-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:5eda3641a2da7a7a0b2f4dbd71d706401a656fea521b6b6faa0675b15d31a163", size = 6181543, upload-time = "2024-05-09T18:33:02.597Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/c4/ed1315143a7a84b2c7616569dfb472473968d628f17c231c39e29ae9d780/pypdfium2-4.30.0-py3-none-musllinux_1_1_i686.whl", hash = "sha256:0dfa61421b5eb68e1188b0b2231e7ba35735aef2d867d86e48ee6cab6975195e", size = 6175911, upload-time = "2024-05-09T18:33:05.376Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/c4/9e62d03f414e0e3051c56d5943c3bf42aa9608ede4e19dc96438364e9e03/pypdfium2-4.30.0-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:f33bd79e7a09d5f7acca3b0b69ff6c8a488869a7fab48fdf400fec6e20b9c8be", size = 6267430, upload-time = "2024-05-09T18:33:08.067Z" },
+ { url = "https://files.pythonhosted.org/packages/90/47/eda4904f715fb98561e34012826e883816945934a851745570521ec89520/pypdfium2-4.30.0-py3-none-win32.whl", hash = "sha256:ee2410f15d576d976c2ab2558c93d392a25fb9f6635e8dd0a8a3a5241b275e0e", size = 2775951, upload-time = "2024-05-09T18:33:10.567Z" },
+ { url = "https://files.pythonhosted.org/packages/25/bd/56d9ec6b9f0fc4e0d95288759f3179f0fcd34b1a1526b75673d2f6d5196f/pypdfium2-4.30.0-py3-none-win_amd64.whl", hash = "sha256:90dbb2ac07be53219f56be09961eb95cf2473f834d01a42d901d13ccfad64b4c", size = 2892098, upload-time = "2024-05-09T18:33:13.107Z" },
+ { url = "https://files.pythonhosted.org/packages/be/7a/097801205b991bc3115e8af1edb850d30aeaf0118520b016354cf5ccd3f6/pypdfium2-4.30.0-py3-none-win_arm64.whl", hash = "sha256:119b2969a6d6b1e8d55e99caaf05290294f2d0fe49c12a3f17102d01c441bd29", size = 2752118, upload-time = "2024-05-09T18:33:15.489Z" },
+]
+
[[package]]
name = "pytest"
version = "8.4.2"
@@ -1410,6 +1645,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
]
+[[package]]
+name = "python-docx"
+version = "1.2.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "lxml" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/a9/f7/eddfe33871520adab45aaa1a71f0402a2252050c14c7e3009446c8f4701c/python_docx-1.2.0.tar.gz", hash = "sha256:7bc9d7b7d8a69c9c02ca09216118c86552704edc23bac179283f2e38f86220ce", size = 5723256, upload-time = "2025-06-16T20:46:27.921Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d0/00/1e03a4989fa5795da308cd774f05b704ace555a70f9bf9d3be057b680bcf/python_docx-1.2.0-py3-none-any.whl", hash = "sha256:3fd478f3250fbbbfd3b94fe1e985955737c145627498896a8a6bf81f4baf66c7", size = 252987, upload-time = "2025-06-16T20:46:22.506Z" },
+]
+
[[package]]
name = "python-dotenv"
version = "1.2.3"
@@ -1428,6 +1676,21 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" },
]
+[[package]]
+name = "python-pptx"
+version = "1.0.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "lxml" },
+ { name = "pillow" },
+ { name = "typing-extensions" },
+ { name = "xlsxwriter" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/52/a9/0c0db8d37b2b8a645666f7fd8accea4c6224e013c42b1d5c17c93590cd06/python_pptx-1.0.2.tar.gz", hash = "sha256:479a8af0eaf0f0d76b6f00b0887732874ad2e3188230315290cd1f9dd9cc7095", size = 10109297, upload-time = "2024-08-07T17:33:37.772Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d9/4f/00be2196329ebbff56ce564aa94efb0fbc828d00de250b1980de1a34ab49/python_pptx-1.0.2-py3-none-any.whl", hash = "sha256:160838e0b8565a8b1f67947675886e9fea18aa5e795db7ae531606d68e785cba", size = 472788, upload-time = "2024-08-07T17:33:28.192Z" },
+]
+
[[package]]
name = "pyyaml"
version = "6.0.3"
@@ -1541,6 +1804,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/37/c3/6eeb6034408dac0fa653d126c9204ade96b819c936e136c5e8a6897eee9c/socksio-1.0.0-py3-none-any.whl", hash = "sha256:95dc1f15f9b34e8d7b16f06d74b8ccf48f609af32ab33c608d08761c5dcbb1f3", size = 12763, upload-time = "2020-04-17T15:50:31.878Z" },
]
+[[package]]
+name = "soupsieve"
+version = "2.9.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/69/99/a6ca3beb3ccacb41fb3321d8a60e5566f9e6467601ef8eba6a17e1b89778/soupsieve-2.9.2.tar.gz", hash = "sha256:4a55d8cf158a9c2e587fa4922f1bbb91d68ac829e2d6f25403a85747c71daf74", size = 122445, upload-time = "2026-08-07T00:57:24.801Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/eb/dc/ad025c1ee131eba60c69f4dd5779b18fcf1e6b21a343e2162a84d5d133c7/soupsieve-2.9.2-py3-none-any.whl", hash = "sha256:8089a26fd974ca7a1f30276d3d8492ab266ab15af581642dfe8aa162e0c1c823", size = 37370, upload-time = "2026-08-07T00:57:23.524Z" },
+]
+
[[package]]
name = "sqlalchemy"
version = "2.0.52"
@@ -1606,6 +1878,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f9/1c/01bfd571a64e7f270e6bab5e33777debe0edc56759233ce84f27dec92d14/tqdm-4.70.0-py3-none-any.whl", hash = "sha256:7f585706bfddbdebf89daac705b2dfcc16890130727d3197ca62c732b4310953", size = 80184, upload-time = "2026-07-27T11:33:13.167Z" },
]
+[[package]]
+name = "types-openpyxl"
+version = "3.1.5.20260827"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/8e/6b/ce650ce7754a2bce3ca1dfbce7f7441df092e2dc6047d00e04f840c2b56e/types_openpyxl-3.1.5.20260827.tar.gz", hash = "sha256:be8b605fb99cfd7d5f5576d4a508e8ec44be2dd15b85157c559080de6384be34", size = 101985, upload-time = "2026-08-27T12:06:18.927Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/12/23/9708c0895d237205ab2b31c06f97d72294f69789ff9923ff7f4aa2126d6b/types_openpyxl-3.1.5.20260827-py3-none-any.whl", hash = "sha256:94e176d871d12e3cbc34f8fb03dc14db2a4245a6690791daf16fc7b08fd67869", size = 165885, upload-time = "2026-08-27T12:06:17.88Z" },
+]
+
[[package]]
name = "typing-extensions"
version = "4.16.0"
@@ -1638,19 +1919,25 @@ wheels = [
[[package]]
name = "ultimate-rag"
-version = "1.0.0"
+version = "2.0.0"
source = { editable = "." }
dependencies = [
{ name = "alembic" },
{ name = "asyncpg" },
+ { name = "beautifulsoup4" },
{ name = "fastapi" },
{ name = "httpx", extra = ["socks"] },
{ name = "markdown-it-py" },
{ name = "minio" },
{ name = "openai" },
+ { name = "openpyxl" },
+ { name = "pillow" },
{ name = "pydantic-settings" },
{ name = "pymilvus" },
+ { name = "pypdfium2" },
+ { name = "python-docx" },
{ name = "python-multipart" },
+ { name = "python-pptx" },
{ name = "sqlalchemy", extra = ["asyncio"] },
{ name = "uvicorn", extra = ["standard"] },
]
@@ -1661,20 +1948,27 @@ dev = [
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "ruff" },
+ { name = "types-openpyxl" },
]
[package.metadata]
requires-dist = [
{ name = "alembic", specifier = ">=1.16,<2" },
{ name = "asyncpg", specifier = ">=0.30,<1" },
+ { name = "beautifulsoup4", specifier = ">=4.13,<5" },
{ name = "fastapi", specifier = ">=0.116,<1" },
{ name = "httpx", extras = ["socks"], specifier = ">=0.28,<1" },
{ name = "markdown-it-py", specifier = ">=4,<5" },
{ name = "minio", specifier = ">=7.2,<8" },
{ name = "openai", specifier = ">=1.99,<3" },
+ { name = "openpyxl", specifier = ">=3.1,<4" },
+ { name = "pillow", specifier = ">=11,<13" },
{ name = "pydantic-settings", specifier = ">=2.10,<3" },
{ name = "pymilvus", specifier = ">=2.5,<3" },
+ { name = "pypdfium2", specifier = ">=4.30,<5" },
+ { name = "python-docx", specifier = ">=1.2,<2" },
{ name = "python-multipart", specifier = ">=0.0.20,<1" },
+ { name = "python-pptx", specifier = ">=1.0,<2" },
{ name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0.41,<3" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.35,<1" },
]
@@ -1685,6 +1979,7 @@ dev = [
{ name = "pytest", specifier = ">=8.4,<9" },
{ name = "pytest-asyncio", specifier = ">=1.1,<2" },
{ name = "ruff", specifier = ">=0.12,<1" },
+ { name = "types-openpyxl", specifier = ">=3.1,<4" },
]
[[package]]
@@ -1965,3 +2260,12 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ce/3c/8b9a225b523f06a9389be81f1b0ab07c49bec6014742e6aa359c1f920f1f/websockets-17.1-cp315-cp315t-win_arm64.whl", hash = "sha256:e552e0037230ac16e5f568de7012041344d1b18c9feed30ec2891b8eba55af81", size = 217372, upload-time = "2026-08-26T14:58:22.807Z" },
{ url = "https://files.pythonhosted.org/packages/41/63/23572870e01836a98346075b9e17a8bc24a6ddd9800a3204ceee58677f3c/websockets-17.1-py3-none-any.whl", hash = "sha256:f221081107b8c48184d99f7019604486376e7ef826037e70aad6b02540732c23", size = 211134, upload-time = "2026-08-26T17:25:31.397Z" },
]
+
+[[package]]
+name = "xlsxwriter"
+version = "3.2.9"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/46/2c/c06ef49dc36e7954e55b802a8b231770d286a9758b3d936bd1e04ce5ba88/xlsxwriter-3.2.9.tar.gz", hash = "sha256:254b1c37a368c444eac6e2f867405cc9e461b0ed97a3233b2ac1e574efb4140c", size = 215940, upload-time = "2025-09-16T00:16:21.63Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/3a/0c/3662f4a66880196a590b202f0db82d919dd2f89e99a27fadef91c4a33d41/xlsxwriter-3.2.9-py3-none-any.whl", hash = "sha256:9a5db42bc5dff014806c58a20b9eae7322a134abb6fce3c92c181bfb275ec5b3", size = 175315, upload-time = "2025-09-16T00:16:20.108Z" },
+]
From e7e68cd0aa667cae498cd68b8937b20758167a55 Mon Sep 17 00:00:00 2001
From: leonyangdev <2443992009@qq.com>
Date: Sat, 29 Aug 2026 17:39:00 +0800
Subject: [PATCH 2/6] =?UTF-8?q?feat(document-ingestion):=20=E5=AE=9E?=
=?UTF-8?q?=E7=8E=B0V2=E5=BC=82=E6=AD=A5=E5=8F=AF=E9=9D=A0=E6=96=87?=
=?UTF-8?q?=E6=A1=A3=E6=91=84=E5=8F=96=E7=AE=A1=E7=BA=BF?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
本次提交实现V2版本的异步文档智能摄取完整能力:
- 新增PostgreSQL持久化任务队列,支持多Worker并行领取、租约回收与有限重试,解决同步上传超时与API进程资源占用问题
- 新增Alembic数据库迁移脚本,初始化异步摄取任务表并迁移旧版未完成任务
- 引入Docling本地PDF版面与表格分析,支持分栏阅读顺序、表格结构还原与元素BBox定位,减少对外OCR调用成本
- 新增百炼视觉理解适配器,提取图表、架构图等视觉内容的语义文本
- 调整API上传接口返回202 Accepted,文件可靠落库后立即返回,不再同步等待完整处理流程
- 新增后台Worker进程,异步执行解析、切块、向量化与索引链路
- 重构切块器为Token感知的结构感知切块,优化表格、代码等特殊内容的切分规则
- 更新前端文档列表支持自动轮询处理状态,优化上传与管理体验
- 更新依赖配置与环境变量示例,新增Docling、tiktoken等必要依赖与配置项
- 更新Docker Compose配置,新增后台Worker服务与持久化缓存卷
- 补充完整单元测试、冒烟测试脚本与架构文档,新增ADR-001记录决策背景与权衡
---
.env.example | 26 +-
Dockerfile.api | 5 +
README.md | 70 +-
alembic/versions/0002_v2_async_ingestion.py | 79 +
apps/api/app.py | 125 +-
apps/api/routes.py | 6 +-
apps/web/app/knowledge-bases/[id]/page.tsx | 53 +-
docker-compose.yml | 41 +-
docs/1.product_description.md | 3 +
docs/2.technical_architecture.md | 8 +-
docs/4.v2_implementation.md | 116 +-
.../ADR-001-v2-async-document-intelligence.md | 142 ++
pyproject.toml | 15 +
scripts/smoke_v1.py | 28 +-
scripts/smoke_v2.py | 55 +-
src/ultimate_rag/application/__init__.py | 2 +
src/ultimate_rag/application/services.py | 169 +-
src/ultimate_rag/chunkers/markdown.py | 479 +++--
src/ultimate_rag/config.py | 40 +-
src/ultimate_rag/domain/exceptions.py | 6 +
src/ultimate_rag/domain/models.py | 32 +-
src/ultimate_rag/domain/ports.py | 8 +
.../infrastructure/database/models.py | 33 +-
.../infrastructure/database/repository.py | 256 ++-
src/ultimate_rag/parsers/_shared.py | 13 +-
src/ultimate_rag/parsers/pdf.py | 505 ++++-
src/ultimate_rag/runtime.py | 151 ++
src/ultimate_rag/vision/__init__.py | 5 +
src/ultimate_rag/vision/bailian.py | 77 +
src/ultimate_rag/worker.py | 231 +++
tests/unit/test_chunker.py | 48 +-
tests/unit/test_document_lifecycle_service.py | 9 +-
tests/unit/test_ingestion_service.py | 6 +-
tests/unit/test_v2_parsers.py | 114 +-
tests/unit/test_worker.py | 83 +
uv.lock | 1662 +++++++++++++++--
36 files changed, 4117 insertions(+), 584 deletions(-)
create mode 100644 alembic/versions/0002_v2_async_ingestion.py
create mode 100644 docs/adr/ADR-001-v2-async-document-intelligence.md
create mode 100644 src/ultimate_rag/runtime.py
create mode 100644 src/ultimate_rag/vision/__init__.py
create mode 100644 src/ultimate_rag/vision/bailian.py
create mode 100644 src/ultimate_rag/worker.py
create mode 100644 tests/unit/test_worker.py
diff --git a/.env.example b/.env.example
index 82ca1cc..996f354 100644
--- a/.env.example
+++ b/.env.example
@@ -4,8 +4,32 @@ DASHSCOPE_API_KEY=replace-me
EMBEDDING_MODEL=text-embedding-v4
EMBEDDING_DIMENSION=1024
LLM_MODEL=qwen-plus
-OCR_MODEL=qwen-vl-ocr-latest
+OCR_MODEL=qwen3.5-ocr
OCR_MAX_IMAGE_BYTES=6291456
+VISION_MODEL=qwen3-vl-flash
+VISION_MAX_IMAGE_BYTES=6291456
+
+# Background ingestion and token-aware chunking
+INGESTION_JOB_MAX_ATTEMPTS=3
+WORKER_POLL_INTERVAL_SECONDS=1
+WORKER_LEASE_SECONDS=900
+WORKER_HEARTBEAT_SECONDS=30
+WORKER_RETRY_DELAY_SECONDS=10
+CHUNK_MAX_TOKENS=512
+CHUNK_OVERLAP_TOKENS=64
+CHUNK_TOKENIZER=cl100k_base
+
+# Local Docling PDF layout/table inference; artifacts path is optional.
+PDF_NATIVE_TEXT_THRESHOLD=20
+PDF_RENDER_SCALE=2.0
+PDF_VISION_CONCURRENCY=2
+PDF_MAX_PICTURES=20
+PDF_MIN_PICTURE_PIXELS=10000
+DOCLING_DEVICE=cpu
+DOCLING_NUM_THREADS=4
+DOCLING_TIMEOUT_SECONDS=600
+DOCLING_IMAGES_SCALE=2.0
+# DOCLING_ARTIFACTS_PATH=/models/docling
# Local Docker Compose defaults
DATABASE_URL=postgresql+asyncpg://ultimate_rag:ultimate_rag@localhost:5432/ultimate_rag
diff --git a/Dockerfile.api b/Dockerfile.api
index c2216b1..b3892df 100644
--- a/Dockerfile.api
+++ b/Dockerfile.api
@@ -4,6 +4,11 @@ FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim
WORKDIR /app
ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy PATH="/app/.venv/bin:$PATH"
+# Docling 的 OpenCV 运行时需要基础图形/线程动态库;它们只用于后台 PDF 推理,不启动 GUI。
+RUN apt-get update \
+ && apt-get install -y --no-install-recommends libgl1 libglib2.0-0 \
+ && rm -rf /var/lib/apt/lists/*
+
COPY pyproject.toml uv.lock README.md ./
COPY src ./src
COPY apps/api ./apps/api
diff --git a/README.md b/README.md
index ebbde25..52f42a0 100644
--- a/README.md
+++ b/README.md
@@ -10,14 +10,15 @@
1. 创建知识库
2. 上传 Markdown、PDF、DOCX、XLSX、PPTX、HTML 或常见图片
-3. 自动识别 PDF 原生文本页与扫描页,并使用阿里云百炼 Qwen-OCR 处理扫描内容
-4. 查看文档从 `PENDING` 到 `READY` 的处理结果和实际 Parser
-5. 使用 Milvus Dense Retrieval 独立调试召回内容和分数
-6. 使用阿里云百炼模型进行知识库问答
-7. 查看答案引用的章节、PDF 页码、Excel 区域或 PPT 幻灯片
-8. 删除文档或知识库,并同步清理三类存储
+3. 上传在文件与任务可靠落库后立即返回,由独立 Worker 后台处理
+4. 使用本地 Docling 恢复 PDF 分栏顺序、标题、表格、图片区域和 BBox,扫描页使用百炼 OCR
+5. 前端自动刷新文档从 `PENDING` 到 `READY/FAILED` 的状态和实际 Parser
+6. 使用 Milvus Dense Retrieval 独立调试召回内容和分数
+7. 使用阿里云百炼模型进行知识库问答
+8. 查看答案引用的章节、PDF 页码/BBox、Excel 区域或 PPT 幻灯片
+9. 删除文档或知识库,并同步清理三类存储
-V2 明确不包含混合检索、Reranker、Agent、ACL、异步任务和 RAGOps;这些属于后续版本。
+V2 明确不包含混合检索、Reranker、Agent、ACL、DLQ 控制台和 RAGOps;这些属于后续版本。
## 架构
@@ -26,17 +27,19 @@ Next.js Web
│
▼
FastAPI Interface
- │
- ▼
-Application Services
- ├── Ingestion: Parse → Chunk → Embed → Index
- └── RAG: Query Embed → Retrieve → Context → Generate → Citation
+ ├── Upload → MinIO + PostgreSQL Job → 202
+ └── RAG Application → Retrieve → Context → Generate → Citation
+
+PostgreSQL Job
+ ↓
+Background Worker → Parse → Chunk → Embed → Index
│
▼
Domain Ports
├── DocumentParser → Markdown / PDF / Office / HTML / Image OCR
- ├── OCRClient → BailianOCRClient
- ├── Chunker → StructureAwareChunker
+ ├── OCRClient → BailianOCRClient(扫描页/图片文字)
+ ├── VisionClient → BailianVisionClient(图表/架构图语义)
+ ├── Chunker → StructureAwareChunker(结构 + Token + 类型)
├── Embedder → BailianEmbedder
├── VectorStore → MilvusVectorStore
├── ObjectStorage → MinioObjectStorage
@@ -53,6 +56,7 @@ Domain Ports
- MinIO 保存所有原始文件,且对象键由系统生成
- Milvus 只保存可重建向量索引,不作为业务事实数据源
- 文档仅在 Parse、Chunk、Embedding、Index 全部成功后进入 `READY`
+- Worker 使用 PostgreSQL 租约、心跳和有限重试,进程重启不会丢失上传任务
- 知识库内容按不可信输入处理,不能覆盖系统 Prompt
详细设计见 [V2 实现说明](docs/4.v2_implementation.md),V1 的基础闭环见
@@ -61,12 +65,14 @@ Domain Ports
## 技术栈
- Python 3.12、FastAPI、Pydantic v2
+- Docling Layout/TableFormer、PDFium、tiktoken
- SQLAlchemy 2、Alembic、PostgreSQL 16
- MinIO、Milvus 2.5、Attu
- 阿里云百炼 OpenAI 兼容 API
- Embedding 默认 `text-embedding-v4`,1024 维
- LLM 默认 `qwen-plus`
- - OCR 默认 `qwen-vl-ocr-latest`
+ - OCR 默认 `qwen3.5-ocr`
+ - PDF 图片理解默认 `qwen3-vl-flash`
- Next.js 16、React 19、TypeScript、Tailwind CSS 4、shadcn/ui、AI SDK
- uv、pytest、Ruff、Mypy
@@ -84,8 +90,10 @@ DASHSCOPE_API_KEY=你的API-Key
EMBEDDING_MODEL=text-embedding-v4
EMBEDDING_DIMENSION=1024
LLM_MODEL=qwen-plus
-OCR_MODEL=qwen-vl-ocr-latest
+OCR_MODEL=qwen3.5-ocr
OCR_MAX_IMAGE_BYTES=6291456
+VISION_MODEL=qwen3-vl-flash
+VISION_MAX_IMAGE_BYTES=6291456
# 可选;留空时浏览器自动访问当前页面主机的 8000 端口
NEXT_PUBLIC_API_URL=
@@ -105,8 +113,21 @@ docker compose up -d --build
```bash
docker compose ps
docker compose logs -f api
+docker compose logs -f worker
```
+第一次处理文字型 PDF 时,Worker 会把 Docling Layout/TableFormer 模型下载到持久化
+`docling_cache` Volume。希望在离线验收前预热模型时可执行:
+
+```bash
+docker compose run --rm worker docling-tools models download
+```
+
+扫描 PDF 不依赖 Docling OCR,而是按页调用 `.env` 中的百炼 OCR;文字型 PDF 的版面与表格推理
+在 Worker 本地完成。默认锁文件从 PyTorch 官方 CPU Index 安装 `torch/torchvision`,避免本地 Docker
+镜像误装数 GB CUDA 依赖。GPU 部署应维护独立的 CUDA 镜像/锁定策略,而不是直接修改运行时设备名。
+生产环境应为 Worker 单独配置 CPU/内存与副本数。
+
### 3. 打开服务
| 服务 | 地址 | 用途 |
@@ -203,12 +224,16 @@ POST /api/chat/stream
## 文档处理状态
+上传接口返回 `202 Accepted` 和 `PENDING` 文档,不等待解析。Worker 使用以下状态推进:
+
```text
PENDING → PARSING → CHUNKING → EMBEDDING → INDEXING → READY
- └→ FAILED(任一处理阶段失败)
+ ↑
+ └── 临时故障有限重试 任一终态错误 → FAILED
```
-失败文档保留原文件与错误状态,方便定位问题和未来重建。V2 仍是同步管线,因此上传请求会等待处理完成。
+前端仅在存在非终态文档时每两秒自动刷新。失败文档保留原文件、Chunk 事实与可操作错误;Milvus
+半成品会清理,检索还会按 PostgreSQL `READY` 状态二次过滤。
## 验证
@@ -240,8 +265,8 @@ V1 脚本保留用于回归。V2 全格式验收使用:
uv run python scripts/smoke_v2.py --api-url http://localhost:8000
```
-V2 脚本会动态生成并上传全部支持格式,验证 Parser、`READY`、带来源位置的检索、流式答案和
-Citation,最后删除临时知识库及其跨存储资源。
+V2 脚本会动态生成全部支持格式,先验证上传立即返回 `202/PENDING`,再轮询 Worker 到 `READY`,
+最后验证来源位置、检索、流式答案和 Citation,并删除临时知识库及其跨存储资源。
## 目录
@@ -251,6 +276,9 @@ apps/api/ FastAPI 应用
src/ultimate_rag/domain/ 领域模型与端口
src/ultimate_rag/application/ 显式业务工作流
src/ultimate_rag/parsers/ Markdown / PDF / Office / HTML / Image 解析与注册表
+src/ultimate_rag/worker.py PostgreSQL 持久化任务 Worker
+src/ultimate_rag/runtime.py API/Worker 共用依赖装配
+src/ultimate_rag/vision/ 百炼图片语义理解适配器
src/ultimate_rag/ocr/ 百炼 OCR 适配器
src/ultimate_rag/chunkers/ 结构感知切块
src/ultimate_rag/embeddings/ 百炼向量适配器
@@ -266,7 +294,7 @@ docs/ 产品、架构与实现文档
## 安全提醒
- 上传文件最大 10 MB;Markdown/HTML 必须使用 UTF-8,Office 会检查 ZIP Bomb 风险
-- 图片提交 OCR 前会验证真实编码;PDF 最多 500 页,扫描页按页调用 OCR
+- 图片提交模型前会验证/压缩;PDF 最多 500 页,扫描页按页 OCR,附图数量和并发均有界
- 用户文件名不参与本地路径或对象键构造
- `.env`、API Key 和生产凭据禁止提交
- 默认 Docker 密码只适合本地开发
diff --git a/alembic/versions/0002_v2_async_ingestion.py b/alembic/versions/0002_v2_async_ingestion.py
new file mode 100644
index 0000000..5975278
--- /dev/null
+++ b/alembic/versions/0002_v2_async_ingestion.py
@@ -0,0 +1,79 @@
+"""Add the durable V2 background-ingestion queue.
+
+Revision ID: 0002_v2_async_ingestion
+Revises: 0001_v1_schema
+"""
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+
+from alembic import op
+
+revision: str = "0002_v2_async_ingestion"
+down_revision: str | None = "0001_v1_schema"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+ """创建持久化任务表,并恢复旧版本中断后遗留的非终态文档。"""
+
+ op.create_table(
+ "ingestion_jobs",
+ sa.Column("id", sa.String(length=36), nullable=False),
+ sa.Column("document_id", sa.String(length=36), nullable=False),
+ sa.Column("status", sa.String(length=20), nullable=False),
+ sa.Column("attempts", sa.Integer(), nullable=False),
+ sa.Column("max_attempts", sa.Integer(), nullable=False),
+ sa.Column("available_at", sa.DateTime(timezone=True), nullable=False),
+ sa.Column("locked_at", sa.DateTime(timezone=True), nullable=True),
+ sa.Column("worker_id", sa.String(length=200), nullable=True),
+ sa.Column("error_message", sa.Text(), nullable=True),
+ sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
+ sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
+ sa.ForeignKeyConstraint(["document_id"], ["documents.id"], ondelete="CASCADE"),
+ sa.PrimaryKeyConstraint("id"),
+ sa.UniqueConstraint("document_id"),
+ )
+ op.create_index(
+ "ix_ingestion_jobs_claim",
+ "ingestion_jobs",
+ ["status", "available_at"],
+ )
+
+ # V1/V2 旧同步进程若在处理中退出,文档可能永久停在 PARSING 等状态。任务 ID 直接复用
+ # document_id,既无需数据库扩展生成 UUID,也能保证这次迁移可重复理解和追踪。
+ op.execute(
+ sa.text(
+ """
+ INSERT INTO ingestion_jobs (
+ id, document_id, status, attempts, max_attempts,
+ available_at, locked_at, worker_id, error_message, created_at, updated_at
+ )
+ SELECT
+ id, id, 'PENDING', 0, 3,
+ CURRENT_TIMESTAMP, NULL, NULL, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
+ FROM documents
+ WHERE status IN ('PENDING', 'PARSING', 'CHUNKING', 'EMBEDDING', 'INDEXING')
+ """
+ )
+ )
+ op.execute(
+ sa.text(
+ """
+ UPDATE documents
+ SET status = 'PENDING',
+ error_message = '服务升级后已恢复后台处理任务',
+ updated_at = CURRENT_TIMESTAMP
+ WHERE status IN ('PARSING', 'CHUNKING', 'EMBEDDING', 'INDEXING')
+ """
+ )
+ )
+
+
+def downgrade() -> None:
+ """删除任务表;文档与 Chunk 事实保持不变。"""
+
+ op.drop_index("ix_ingestion_jobs_claim", table_name="ingestion_jobs")
+ op.drop_table("ingestion_jobs")
diff --git a/apps/api/app.py b/apps/api/app.py
index 3960e0c..f3a86e9 100644
--- a/apps/api/app.py
+++ b/apps/api/app.py
@@ -34,33 +34,18 @@
from ultimate_rag.application import (
ContextBuilder,
DocumentLifecycleService,
- IngestionService,
RAGService,
RetrievalService,
)
-from ultimate_rag.chunkers import StructureAwareChunker
from ultimate_rag.config import get_settings
from ultimate_rag.domain.exceptions import (
+ DocumentBusyError,
InvalidDocumentError,
ResourceNotFoundError,
UltimateRAGError,
)
-from ultimate_rag.embeddings import BailianEmbedder
from ultimate_rag.generation import BailianLLMClient
-from ultimate_rag.infrastructure.database import create_database
-from ultimate_rag.infrastructure.storage import MinioObjectStorage
-from ultimate_rag.ocr import BailianOCRClient
-from ultimate_rag.parsers import (
- ExcelParser,
- HtmlParser,
- ImageOCRParser,
- MarkdownParser,
- ParserRegistry,
- PDFParser,
- PowerPointParser,
- WordParser,
-)
-from ultimate_rag.vectorstores import MilvusVectorStore
+from ultimate_rag.runtime import create_processing_runtime
settings = get_settings()
logging.basicConfig(
@@ -90,106 +75,47 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
并在正常关闭阶段释放 SQLAlchemy Engine 的连接池。
"""
- # 阶段 1:创建事实数据库和原文件存储适配器。
- # create_database 只构造 Engine、Session Factory 和 Repository,不在启动时隐式建表;
- # PostgreSQL Schema 必须事先通过 Alembic 升级,避免应用启动悄悄修改生产数据库。
- engine, repository = create_database(settings.database_url)
- storage = MinioObjectStorage(
- settings.minio_endpoint,
- settings.minio_access_key,
- settings.minio_secret_key,
- settings.minio_bucket,
- settings.minio_secure,
- )
-
- # 阶段 2:创建会被所有请求复用的模型与向量存储客户端。
- # Embedder 与 Milvus 必须使用同一向量维度,否则 Embedding 即使生成成功也无法写入
- # Collection;两者都读取同一个 Settings 字段,避免分别配置后产生隐蔽的不一致。
- embedder = BailianEmbedder(
- api_key=settings.dashscope_api_key,
- base_url=settings.dashscope_base_url,
- model=settings.embedding_model,
- dimension=settings.embedding_dimension,
- batch_size=settings.embedding_batch_size,
- timeout=settings.model_timeout_seconds,
- )
- vector_store = MilvusVectorStore(
- uri=settings.milvus_uri,
- token=settings.milvus_token,
- collection=settings.milvus_collection,
- dimension=settings.embedding_dimension,
- )
+ # API 与 Worker 从同一 Composition Root 装配 Parser、Chunker 和事实存储,防止上传校验
+ # 支持某格式、后台进程却没有对应 Parser。API 只使用其中轻量的提交服务,不执行解析。
+ runtime = create_processing_runtime(settings)
- # LLM、Embedder 与 OCR 共享百炼 Endpoint 和 API Key,但模型职责完全分离。
+ # LLM 只属于 HTTP 问答进程;后台 Worker 不需要创建生成模型客户端。
llm = BailianLLMClient(
api_key=settings.dashscope_api_key,
base_url=settings.dashscope_base_url,
model=settings.llm_model,
timeout=settings.model_timeout_seconds,
)
- ocr = BailianOCRClient(
- api_key=settings.dashscope_api_key,
- base_url=settings.dashscope_base_url,
- model=settings.ocr_model,
- max_image_bytes=settings.ocr_max_image_bytes,
- timeout=settings.model_timeout_seconds,
- )
-
- # 阶段 3:装配不直接拥有外部资源的领域策略和应用服务。
- # Registry 隔离源格式与 Parser 选择,Chunker 负责统一 ParsedDocument 之后的切块;
- # RetrievalService 复用同一个 Embedder,保证查询向量与文档向量处于相同向量空间。
- registry = ParserRegistry(
- [
- MarkdownParser(),
- WordParser(),
- ExcelParser(),
- PowerPointParser(),
- HtmlParser(),
- PDFParser(
- ocr,
- native_text_threshold=settings.pdf_native_text_threshold,
- render_scale=settings.pdf_render_scale,
- ),
- ImageOCRParser(ocr),
- ]
- )
- chunker = StructureAwareChunker(settings.chunk_max_chars, settings.chunk_overlap_chars)
- retrieval = RetrievalService(embedder, vector_store)
+ retrieval = RetrievalService(runtime.embedder, runtime.vector_store, runtime.repository)
# 阶段 4:把已经装配好的对象集中放入进程级 Container。
# Route 只从 app.state 取应用服务,不自行读取配置或创建客户端,从而保持 HTTP 层轻量,
# 也确保摄取、检索和删除流程使用的是同一组 Repository、Storage 与 VectorStore 实例。
app.state.container = Container(
- engine=engine,
+ engine=runtime.engine,
max_upload_bytes=settings.max_upload_bytes,
- repository=repository,
- ingestion=IngestionService(
- repository=repository,
- storage=storage,
- parser_registry=registry,
- chunker=chunker,
- embedder=embedder,
- vector_store=vector_store,
- max_upload_bytes=settings.max_upload_bytes,
- ),
+ repository=runtime.repository,
+ ingestion=runtime.ingestion,
retrieval=retrieval,
rag=RAGService(retrieval, ContextBuilder(settings.context_max_chars), llm),
- lifecycle=DocumentLifecycleService(repository, storage, vector_store),
+ lifecycle=DocumentLifecycleService(
+ runtime.repository,
+ runtime.storage,
+ runtime.vector_store,
+ ),
)
# 阶段 5:在开放 HTTP 服务之前完成外部资源的幂等准备。
# Bucket/Collection 不存在时创建,存在时复用;任一步抛出异常都会阻止执行 yield,
# FastAPI 因而不会在依赖不可用或向量 Schema 未准备好时对外宣称启动成功。
- await storage.ensure_bucket()
- await vector_store.ensure_collection()
-
- # 生命周期分界点:yield 之前属于启动阶段,yield 期间由 FastAPI 处理请求,
- # 恢复执行后进入关闭阶段。所有 Route 此时都可以读取上方写入的 Container。
- yield
+ try:
+ await runtime.initialize()
- # 阶段 6:正常关闭时释放 SQLAlchemy Engine 管理的连接池。
- # 当前其他 Adapter 没有统一的 close() 端口,因此这里只清理明确由本模块持有的异步资源。
- await engine.dispose()
+ # 生命周期分界点:yield 之前属于启动阶段,yield 期间由 FastAPI 处理请求。
+ yield
+ finally:
+ # 初始化中途失败也必须释放已经创建的数据库连接池。
+ await runtime.close()
app = FastAPI(title=settings.app_name, version="2.0.0", lifespan=lifespan)
@@ -218,6 +144,13 @@ async def invalid_document_handler(_request: Request, exc: InvalidDocumentError)
return JSONResponse(status_code=status.HTTP_400_BAD_REQUEST, content={"detail": str(exc)})
+@app.exception_handler(DocumentBusyError)
+async def document_busy_handler(_request: Request, exc: DocumentBusyError) -> JSONResponse:
+ """处理中的文档存在并发写入风险,使用 409 提示客户端稍后重试。"""
+
+ return JSONResponse(status_code=status.HTTP_409_CONFLICT, content={"detail": str(exc)})
+
+
@app.exception_handler(UltimateRAGError)
async def application_error_handler(_request: Request, exc: UltimateRAGError) -> JSONResponse:
"""把已知处理故障转换为 502,且不向客户端暴露 Stack Trace。"""
diff --git a/apps/api/routes.py b/apps/api/routes.py
index 1e09868..3a5a257 100644
--- a/apps/api/routes.py
+++ b/apps/api/routes.py
@@ -126,17 +126,17 @@ async def delete_knowledge_base(knowledge_base_id: str, request: Request) -> Res
@router.post(
"/knowledge-bases/{knowledge_base_id}/documents",
response_model=DocumentResponse,
- status_code=status.HTTP_201_CREATED,
+ status_code=status.HTTP_202_ACCEPTED,
)
async def upload_document(
knowledge_base_id: str,
request: Request,
file: Annotated[UploadFile, File()],
) -> DocumentResponse:
- """上传并同步完成多格式文档的解析、切块、向量化和索引。"""
+ """可靠保存文件并提交后台任务;不等待解析、模型调用或索引完成。"""
dependencies = container(request)
content = await _read_bounded_upload(file, dependencies.max_upload_bytes)
- value = await dependencies.ingestion.ingest(
+ value = await dependencies.ingestion.submit(
knowledge_base_id,
file.filename or "document.bin",
file.content_type or "application/octet-stream",
diff --git a/apps/web/app/knowledge-bases/[id]/page.tsx b/apps/web/app/knowledge-bases/[id]/page.tsx
index 6c93623..7abaca2 100644
--- a/apps/web/app/knowledge-bases/[id]/page.tsx
+++ b/apps/web/app/knowledge-bases/[id]/page.tsx
@@ -23,13 +23,15 @@ import { Separator } from "@/components/ui/separator";
const STATUS_PRESENTATION: Record = {
READY: { label: "可检索", className: "border-emerald-200 bg-emerald-50 text-emerald-700" },
FAILED: { label: "失败", className: "border-red-200 bg-red-50 text-red-700" },
- PENDING: { label: "等待", className: "border-slate-200 bg-slate-50 text-slate-600" },
+ PENDING: { label: "排队中", className: "border-slate-200 bg-slate-50 text-slate-600" },
PARSING: { label: "解析中", className: "border-amber-200 bg-amber-50 text-amber-700" },
CHUNKING: { label: "切分中", className: "border-amber-200 bg-amber-50 text-amber-700" },
EMBEDDING: { label: "向量化", className: "border-amber-200 bg-amber-50 text-amber-700" },
INDEXING: { label: "索引中", className: "border-amber-200 bg-amber-50 text-amber-700" },
};
+const TERMINAL_DOCUMENT_STATUSES = new Set(["READY", "FAILED"]);
+
/**
* 知识库工作台 —— 管理单个知识库的文档事实。
*
@@ -50,9 +52,12 @@ export default function KnowledgeBaseWorkspacePage() {
const [knowledgeBase, setKnowledgeBase] = useState(null);
const [documents, setDocuments] = useState([]);
const [pageError, setPageError] = useState("");
- const [isDocumentWorking, setIsDocumentWorking] = useState(false);
+ const [isUploading, setIsUploading] = useState(false);
const readyCount = documents.filter((document) => document.status === "READY").length;
+ const hasProcessingDocuments = documents.some(
+ (document) => !TERMINAL_DOCUMENT_STATUSES.has(document.status),
+ );
/**
* 并行刷新知识库元数据与文档状态。
@@ -92,28 +97,51 @@ export default function KnowledgeBaseWorkspacePage() {
};
}, [id]);
- /** 上传 V2 支持的文档;同步 API 返回时已完成 Parse → Chunk → Embed → Index。 */
+ /**
+ * 只在存在非终态文档时轮询。页面离开或全部完成后立即清除定时器,避免空知识库也持续
+ * 请求;两秒间隔兼顾状态可见性与数据库读取压力,不要求用户手动刷新页面。
+ */
+ useEffect(() => {
+ if (!hasProcessingDocuments) return;
+ let isActive = true;
+ const timer = window.setInterval(() => {
+ void api(`/api/knowledge-bases/${id}/documents`)
+ .then((docs) => {
+ if (isActive) setDocuments(docs);
+ })
+ .catch((value: unknown) => {
+ if (isActive) setPageError(value instanceof Error ? value.message : "文档状态刷新失败");
+ });
+ }, 2000);
+ return () => {
+ isActive = false;
+ window.clearInterval(timer);
+ };
+ }, [hasProcessingDocuments, id]);
+
+ /** 上传只等待可靠保存和任务入队;解析、向量化和索引由后台 Worker 继续执行。 */
async function upload(event: FormEvent) {
event.preventDefault();
const form = event.currentTarget;
const input = form.elements.namedItem("document") as HTMLInputElement;
if (!input.files?.[0]) return;
- setIsDocumentWorking(true);
+ setIsUploading(true);
setPageError("");
const data = new FormData();
data.append("file", input.files[0]);
try {
- await api(`/api/knowledge-bases/${id}/documents`, {
+ const accepted = await api(`/api/knowledge-bases/${id}/documents`, {
method: "POST",
body: data,
});
+ // 202 响应已经包含 PENDING 文档,直接加入列表即可启动上方轮询,无需等待一次额外 GET。
+ setDocuments((current) => [accepted, ...current.filter((item) => item.id !== accepted.id)]);
form.reset();
} catch (value) {
setPageError(value instanceof Error ? value.message : "文档上传失败");
} finally {
- await load();
- setIsDocumentWorking(false);
+ setIsUploading(false);
}
}
@@ -195,17 +223,17 @@ export default function KnowledgeBaseWorkspacePage() {