commit 51dc8dc4f6e462f4dee6dc96360f8c2d4c23a7ae Author: kplam Date: Wed Jul 29 21:24:40 2026 +0800 Initial commit: QMDSearch 分层信息检索服务 - FastAPI + Qdrant + Redis + Ollama 技术栈 - L1→L2→L3→chunk 四层分层检索(dense + sparse RRF 融合) - 文档三级总结与 2.5 级回退 - query 解析路由与分类 - /admin 管理页面 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..78879d5 --- /dev/null +++ b/.env.example @@ -0,0 +1,74 @@ +# =========================================== +# QMDSearch 环境变量配置 +# =========================================== + +# --- 应用 --- +APP_PORT=8000 +LOG_LEVEL=info + +# --- 嵌入模型 --- +# openai | local +EMBEDDING_PROVIDER=openai +OPENAI_API_KEY=sk-xxx +OPENAI_BASE_URL=https://api.openai.com/v1 +EMBEDDING_MODEL=text-embedding-3-small +EMBEDDING_DIMENSION=1536 + +# --- Qdrant --- +QDRANT_PORT=6333 +QDRANT_DASHBOARD_PORT=6334 + +# --- Redis --- +REDIS_PORT=6379 + +# --- Ollama 本地模型(文档三级总结)--- +OLLAMA_PORT=11434 +OLLAMA_MODEL=qwen2.5:1.5b +# 备选模型: qwen2.5:3b(更好的总结质量,需更多内存) +# EMBEDDING_PROVIDER=local 时使用的 Ollama 嵌入模型 +OLLAMA_EMBEDDING_MODEL=bge-m3 + +# --- NAS 持久化 --- +NAS_DATA_DIR=./data + +# --- 检索参数 --- +# L2 语义检索召回数量 +RETRIEVAL_TOP_K=20 +# L3 重排后返回数量 +RETRIEVAL_FINAL_K=5 + +# --- 文档入库参数 --- +# 低于此字符数触发 2.5 级回退 +SUMMARY_MIN_TEXT_LENGTH=500 + +# --- 入库异步任务 --- +# 入库后台任务并发上限 +INGEST_MAX_CONCURRENCY=2 +# 任务状态 Redis 保留秒数(进行中与已完成,默认 24 小时) +INGEST_TASK_TTL_DONE=86400 +# 失败任务状态 Redis 保留秒数(默认 7 天) +INGEST_TASK_TTL_FAILED=604800 + +# --- 知识分类(taxonomy)--- +# taxonomy JSON 文件路径,留空使用内置默认类目集 +TAXONOMY_PATH= +# 分类置信度低于此值归入 uncategorized +CLASSIFY_CONFIDENCE_THRESHOLD=0.6 +# query 路由命中类目数上限,超过走全库兜底 +CLASSIFY_MAX_CATEGORIES=3 + +# --- 分层检索参数 --- +# L1 层候选文档数 +L1_DOC_TOP_N=10 +# L2 层候选 section 数 +L2_SECTION_TOP_N=5 +# L3 层定位数 +L3_TOP_N=10 +# 是否启用稀疏检索 +SPARSE_ENABLED=true +# Redis 缓存秒数 +CACHE_TTL=300 + +# --- 分块参数 --- +# chunk 超长二次切分阈值(字符数) +CHUNK_MAX_CHARS=800 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..056168c --- /dev/null +++ b/.gitignore @@ -0,0 +1,35 @@ +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +dist/ +build/ +.venv/ + +# Env +.env +.env.local + +# Data +data/ + +# IDE +.idea/ +.vscode/ +*.swp + +# OS +.DS_Store +Thumbs.db + +# Docker +docker-compose.override.yml + +# Logs +logs/ +*.log + +# Test +.pytest_cache/ +.coverage +htmlcov/ diff --git a/.trae/specs/add-async-ingest-tasks/checklist.md b/.trae/specs/add-async-ingest-tasks/checklist.md new file mode 100644 index 0000000..cd248ef --- /dev/null +++ b/.trae/specs/add-async-ingest-tasks/checklist.md @@ -0,0 +1,31 @@ +# Checklist + +## 配置与任务管理器 + +- [x] config 含 ingest_max_concurrency(默认 2)/ingest_task_ttl_done(默认 86400)/ingest_task_ttl_failed(默认 604800),.env.example 同步 +- [x] submit 返回 task_id 且状态初始 pending;后台任务按信号量限流(超额保持 pending) +- [x] 状态机按流水线阶段推进:pending→summarizing→classifying→embedding→writing→done +- [x] 成功后 result 为完整 IngestionResult;失败 error 含 stage 与 message,partial_summary(已产出总结)保留 +- [x] 状态写 Redis:done TTL 24h、failed TTL 7 天;Redis 故障降级内存字典且接口不报错 + +## API + +- [x] POST /api/v1/documents 合法请求返回 HTTP 202 + {task_id, status:"pending"},不再同步等待流水线 +- [x] POST 空文本等校验失败仍同步返回 code=1001(不进任务队列) +- [x] GET /api/v1/documents/tasks/{task_id} 返回状态/时间戳;done 附 result;failed 附 error.stage/message +- [x] 查询不存在 task_id 返回 code=1004 +- [x] 既有入库相关测试全部适配新契约且通过 + +## 管理页面 + +- [x] 入库提交后展示 task_id 与状态,2s 轮询直至 done/failed +- [x] done 展示 category/置信度/tags/总结层级/chunks_count;failed 展示 stage 与 message +- [x] 轮询期间禁止重复提交;5 分钟超时停止轮询并提示 +- [x] 页面测试覆盖 tasks 轮询路径与新交互标记 + +## 集成与文档 + +- [x] 内存闭环:提交→轮询 done→GET /documents/{doc_id} 可查→检索命中→删除 全通 +- [x] 失败路径:FakeOllama 抛错 → 任务 failed、stage 正确、failed TTL=7 天 +- [x] CLAUDE.md API 清单反映 POST /documents 202 异步与任务查询端点 +- [x] `uv run pytest` 全部通过;`uv run ruff check app tests scripts` 无错误 diff --git a/.trae/specs/add-async-ingest-tasks/spec.md b/.trae/specs/add-async-ingest-tasks/spec.md new file mode 100644 index 0000000..cb53c7c --- /dev/null +++ b/.trae/specs/add-async-ingest-tasks/spec.md @@ -0,0 +1,72 @@ +# 入库异步任务化 Spec + +## Why + +当前 `POST /api/v1/documents` 同步等待整条入库流水线(总结→分类→切分→向量化→写库),真实 Ollama 冒烟单篇耗时 39s+,长文档分钟级,客户端/网关易超时,批量导入不可用。改为后台任务模式:提交即返回 task_id,状态可查,失败任务长期保留便于归因。 + +## What Changes + +- **BREAKING**:`POST /api/v1/documents` 由同步返回 IngestionResult(200)改为立即返回 `{task_id, status}`(HTTP 202),流水线在后台 asyncio 任务中执行 +- **新增** `GET /api/v1/documents/tasks/{task_id}`:查询任务状态(pending/各阶段/done/failed)、完成后的 IngestionResult、失败时的 stage 与错误信息 +- **新增** `app/core/ingest_tasks.py` IngestTaskManager:提交登记 → 信号量限流并发 → 后台执行 → 状态机推进 → 结果/错误落储 +- **状态存储**:Redis 为主(key `ingest_task:{task_id}`,JSON);done TTL 24h,**failed TTL 7 天**(便于失败归因);Redis 不可用时降级进程内字典(重启丢失,日志告警),服务不因此拒绝入库 +- **管理页**:入库区块改为「提交 → 轮询任务状态 → 展示进度/结果/失败详情」 +- **配置新增**:`ingest_max_concurrency`(默认 2)、`ingest_task_ttl_done`(默认 86400s)、`ingest_task_ttl_failed`(默认 604800s,7 天) +- CLAUDE.md API 清单同步更新(标注 POST /documents 行为变更) + +## Impact + +- Affected specs: 文档入库 API、管理页面、任务状态存储 +- Affected code: + - 修改:[document.py](file:///Users/kplam/coding/QMDSearch/app/api/v1/document.py)(POST 改 202 + 新增任务查询端点)、[config.py](file:///Users/kplam/coding/QMDSearch/app/config.py)、[.env.example](file:///Users/kplam/coding/QMDSearch/.env.example)、[admin.html](file:///Users/kplam/coding/QMDSearch/app/static/admin.html)(入库区块轮询)、[CLAUDE.md](file:///Users/kplam/coding/QMDSearch/CLAUDE.md) + - 新增:`app/core/ingest_tasks.py`、`tests/test_ingest_tasks.py`、`tests/test_ingest_task_api.py` + +## ADDED Requirements + +### Requirement: 异步入库提交 + +系统 SHALL 将 `POST /api/v1/documents` 改为异步任务模式:校验通过后立即返回 HTTP 202 与 `{task_id, status: "pending"}`,入库流水线在后台任务执行;并发后台任务数受 `ingest_max_concurrency` 信号量限制,超限任务保持 pending 排队。请求体与校验规则(text 非空等)与现状一致,校验失败仍同步返回 1001。 + +#### Scenario: 提交即返回 + +- **WHEN** 提交合法文档 +- **THEN** 响应 202 + task_id,无需等待流水线完成;后台任务按并发额度开始执行 + +### Requirement: 任务状态查询 + +系统 SHALL 提供 `GET /api/v1/documents/tasks/{task_id}`:返回 `{task_id, status, created_at, updated_at}`;status 取值 `pending | summarizing | classifying | embedding | writing | done | failed`(与流水线阶段一致推进);done 时附 `result`(完整 IngestionResult:document_id/category/tags/总结层级/chunks_count);failed 时附 `error: {stage, message}`(stage 来自 IngestionError,已产出总结不丢失一并附在 error.partial_summary)。task_id 不存在返回 code=1004。 + +#### Scenario: 生命周期 + +- **WHEN** 提交后轮询 task_id +- **THEN** 依次观察到 pending→阶段状态→done(result 可查,文档已可检索);失败任务观察到 failed + error.stage + +### Requirement: 状态存储与保留 + +任务状态 SHALL 写入 Redis(`ingest_task:{task_id}`,JSON):done TTL=ingest_task_ttl_done(默认 24h),failed TTL=ingest_task_ttl_failed(默认 7 天),进行中状态 TTL 取 done 值。Redis 不可用时降级进程内字典并日志告警,入库与查询照常(重启后历史任务丢失可接受,接口语义不变)。 + +#### Scenario: Redis 宕机降级 + +- **WHEN** Redis 连接失败 +- **THEN** 提交与查询仍正常,仅日志 warning;进程重启后任务状态丢失 + +### Requirement: 管理页入库区块 + +管理页入库表单 SHALL 改为:提交后展示 task_id 与状态进度条/文本,每 2s 轮询任务接口,done 展示分类/标签/总结层级/chunks_count,failed 展示 error.stage 与 message;轮询期间禁止重复提交,超时(5 分钟)停止轮询并提示可稍后手动查询。 + +### Requirement: 并发控制 + +后台入库 SHALL 用 asyncio 信号量限制并发(默认 2),避免多任务同时打满 Ollama/embedding;排队任务状态保持 pending。 + +## MODIFIED Requirements + +### Requirement: POST /api/v1/documents(行为变更) + +原同步返回 IngestionResult 的行为废弃,改为 202 + task_id。所有既有调用方(管理页、既有测试)同步适配;响应体统一响应包装不变(`{code:0, data:{task_id,status}, message:"ok"}`,HTTP 状态码 202)。 + +## REMOVED Requirements + +### Requirement: 同步入库等待 + +**Reason**:长耗时流水线导致客户端超时、无法批量导入。 +**Migration**:调用方改为提交后轮询 `GET /api/v1/documents/tasks/{task_id}`;管理页已内置轮询。 diff --git a/.trae/specs/add-async-ingest-tasks/tasks.md b/.trae/specs/add-async-ingest-tasks/tasks.md new file mode 100644 index 0000000..4870dcc --- /dev/null +++ b/.trae/specs/add-async-ingest-tasks/tasks.md @@ -0,0 +1,18 @@ +# Tasks + +- [x] Task 1: 配置 + IngestTaskManager:config 新增 ingest_max_concurrency/ingest_task_ttl_done/ingest_task_ttl_failed 与 .env.example 同步;新建 app/core/ingest_tasks.py——submit(doc)→task_id、asyncio 信号量限流后台执行、状态机(pending→summarizing→classifying→embedding→writing→done/failed)、Redis 主存储(failed TTL 7 天)+ Redis 故障降级进程内字典、get(task_id) 查询;单测(FakeIngester 成功/失败/IngestionError.stage 透传/partial_summary 保留/并发限流/Redis 降级) + - [x] SubTask 1.1: 配置项 + .env.example + - [x] SubTask 1.2: IngestTaskManager 实现 + 单测 +- [x] Task 2: API 改造:POST /api/v1/documents 改 202 返回 {task_id,status}(校验失败仍同步 1001);新增 GET /api/v1/documents/tasks/{task_id}(不存在 1004);适配既有 test_document_api.py 与 test_e2e_integration.py 中入库断言为新契约;新增 tests/test_ingest_task_api.py +- [x] Task 3: 管理页入库区块改造:提交→展示 task_id 与状态→2s 轮询→done 展示结果/failed 展示 stage+message→轮询期禁重复提交→5 分钟超时提示;更新 test_admin_page.py 断言(含 tasks 轮询路径) +- [x] Task 4: 集成验证 + 文档:内存 Qdrant + FakeOllama 走通 提交→轮询至 done→文档可检索→删除 闭环;失败路径(FakeOllama 抛错)状态 failed 且 stage 正确;Redis 降级路径可用;CLAUDE.md API 清单更新(POST /documents 标注 202 异步、新增任务查询端点);`uv run pytest` 全绿 + ruff 通过 + +# Task Dependencies + +- [Task 2] depends on [Task 1] +- [Task 3] depends on [Task 2] +- [Task 4] depends on [Task 3] + +# Parallelizable + +- 各任务串行依赖,无并行项 diff --git a/.trae/specs/add-hierarchical-rag-pipeline/checklist.md b/.trae/specs/add-hierarchical-rag-pipeline/checklist.md new file mode 100644 index 0000000..7aceda7 --- /dev/null +++ b/.trae/specs/add-hierarchical-rag-pipeline/checklist.md @@ -0,0 +1,45 @@ +# Checklist + +## 配置与模型 + +- [x] config 含 taxonomy 路径、分类置信度阈值、各层 top-k(l1/l2/l3)、sparse 开关、缓存 TTL,.env.example 同步更新 +- [x] taxonomy 默认配置文件存在且含 uncategorized 兜底类,加载校验函数可用 +- [x] CategoryResult(主类+多标签+置信度)、ChunkModel、SearchRequest/SearchResponse/SearchHit 模型齐备且有类型注解 + +## 入库链路 + +- [x] L2 大纲优先使用文档原生标题树,无结构文本回退 LLM 生成,2.5 级回退逻辑不受影响 +- [x] 分类器输出主类+多标签+置信度,低置信文档归入 uncategorized,不再硬编码 default +- [x] chunk 按标题树切分并记录 section_path,超长 section 二次切分,短文本单 chunk +- [x] 入库后 Qdrant 四层集合均有数据:doc_l1/doc_l2/doc_l3/chunks,payload 含 doc_id/category/tags/section_path +- [x] chunks 与 doc_l1 同时携带 dense 与 sparse 向量,sparse 由本地分词+BM25 生成、无外部模型依赖 +- [x] 入库失败时返回明确错误码,已生成总结不丢失(可重试) + +## 检索链路 + +- [x] query 解析以 JSON 约束输出意图类目+置信度+rewrite+关键词,解析失败自动降级全库检索 +- [x] 分类路由:高置信按主类硬过滤,多标签软召回生效,低置信/超类目上限走全库兜底 +- [x] 三级文档检索沿 L1→L2→L3→chunk 逐层收敛,chunk 候选仅来自 L3 命中范围 +- [x] 2.5 级文档 L1 命中后直进 L3/chunk 层 +- [x] L2/L3 空召回时回退上一层范围直搜 chunk,不返回空结果 +- [x] chunk 层 dense+sparse 双路召回经 RRF 融合,返回 final_k 个结果 +- [x] 检索结果 text 字段为原文 chunk,摘要仅以 doc_summary 上下文标注出现 +- [x] Redis 缓存命中时重复 query 不重复调用 Ollama/Qdrant;Redis 宕机检索仍可用 + +## API + +- [x] POST /api/v1/documents 入库成功返回 document_id、分类结果、总结层级 +- [x] POST /api/v1/search 返回统一格式 {"code":0,"data":...,"message":"ok"} +- [x] GET /api/v1/knowledge/categories 返回 taxonomy 类目列表 +- [x] 错误码符合 0=成功、1xxx=客户端错误、2xxx=服务端错误规范 + +## 评测 + +- [x] 回归集样例 ≥5 篇文档,每篇含应检出/不应检出 query 及 golden 标注 +- [x] 评测脚本输出 Entity Recall(L1/L3)、Hallucination Rate、Routing F1、Pruning Loss、Precision@5/Recall@10 +- [x] 报告含平铺 chunk baseline 对比,未达门槛项显式标出 + +## 端到端 + +- [x] docker compose 环境下 入库→检索→评测 全链路跑通(本机以内存 Qdrant 集成测试 + 真实 Ollama 冒烟等效验证,NAS docker 部署待用户侧执行) +- [x] `uv run pytest` 全部通过 diff --git a/.trae/specs/add-hierarchical-rag-pipeline/spec.md b/.trae/specs/add-hierarchical-rag-pipeline/spec.md new file mode 100644 index 0000000..b04cee3 --- /dev/null +++ b/.trae/specs/add-hierarchical-rag-pipeline/spec.md @@ -0,0 +1,144 @@ +# 分层摘要索引 + 分类路由检索全链路 Spec + +## Why + +当前 QMDSearch 仅实现了三级总结器(summarizer),入库链路的分类/向量化/写入为 TODO,检索侧整体缺失,无法兑现 CLAUDE.md 中「面向 AI Agent 的分层信息检索服务」定位。本变更按「分层预摘要索引(Hierarchical Summarization Index)+ 分类路由 + 自顶向下剪枝检索」架构补齐全链路:离线侧把 Agentic RAG 中 router/planner 需要的结构信息预先算好,在线侧用「分类过滤 + 摘要树逐层剪枝 + hybrid 检索」实现大规模知识库下又省又准的检索,并附评测脚本骨架守护摘要质量与剪枝召回。 + +## What Changes + +- **入库链路补全**:taxonomy 驱动的分类判定(主类 + 多标签 + 置信度,`uncategorized` 兜底)、dense + sparse(BM25)双向量生成、Qdrant 四层集合写入(L1 总结 / L2 大纲节点 / L3 内容大纲节点 / 原文 chunk) +- **summarizer 改造**:L2 大纲优先解析文档原生标题树(Markdown 标题/编号标题),无结构文本回退 LLM 生成;新增 chunk 切分器,chunk 关联所属 section 路径 +- **分层检索引擎(新增)**:query 解析(Ollama 小模型 JSON 约束输出:意图分类 + query rewrite + 关键词)→ 分类路由(主类硬过滤 + 多标签软召回 + 低置信全库兜底)→ L1→L2→L3 摘要树逐层剪枝 → chunk 层 dense+sparse hybrid 召回(RRF 融合)→ 重排返回原文 chunk +- **检索边界**:摘要仅用于路由与上下文标注,返回给调用方 Agent 的答案语料只含原文 chunk(防摘要幻觉进入生成) +- **降级路径**:分类低置信跳过路由;任一层召回为空回退上一层范围直搜 chunk;2.5 级文档 L1 命中后直进 L3/chunk 层 +- **API(新增)**:`POST /api/v1/documents` 入库、`POST /api/v1/search` 分层检索、`GET /api/v1/knowledge/categories` 类目查询 +- **Redis 缓存**:query 解析结果与检索结果缓存(短 TTL) +- **评测脚本骨架(新增)**:离线回归集 + Entity Recall / Hallucination Rate / Routing F1 / Pruning Loss / Precision@5 指标,支持与平铺 chunk baseline 对比 + +**存储设计说明**:不按类目建集合,采用全局四层集合 + category payload 索引过滤(Qdrant keyword payload index)。理由:支持多标签软召回与 uncategorized 兜底跨类检索,taxonomy 调整无需迁库;此设计取代 CLAUDE.md 中「按分类映射写入对应集合」的设想(该设想从未实现,非破坏性变更)。 + +**命名对齐**:用户方案中 L0/L1/L2 对应代码库既有命名 L1 总结 / L2 大纲 / L3 内容大纲(+ L2.5 回退),本 spec 沿用代码库命名。 + +## Impact + +- Affected specs: 文档入库(ingestion)、文档三级总结(summarizer)、分层检索(新增)、评测(新增) +- Affected code: + - 修改:[summarizer.py](file:///Users/kplam/coding/QMDSearch/app/core/summarizer.py)、[ingestion.py](file:///Users/kplam/coding/QMDSearch/app/core/ingestion.py)、[document.py](file:///Users/kplam/coding/QMDSearch/app/models/document.py)、[config.py](file:///Users/kplam/coding/QMDSearch/app/config.py)、[main.py](file:///Users/kplam/coding/QMDSearch/app/main.py) + - 新增:`app/core/embeddings.py`、`app/core/sparse.py`、`app/core/chunker.py`、`app/core/classifier.py`、`app/core/retriever.py`、`app/core/ranker.py`、`app/core/query_parser.py`、`app/services/qdrant.py`、`app/services/redis.py`、`app/models/search.py`、`app/models/knowledge.py`、`app/api/v1/document.py`、`app/api/v1/search.py`、`app/api/v1/knowledge.py`、`scripts/eval/`(评测脚本与回归集样例) +- 基础设施:Qdrant 集合初始化(4 集合 + payload 索引 + sparse 配置),无需新增容器 + +## ADDED Requirements + +### Requirement: taxonomy 分类判定 + +系统 SHALL 提供可配置的类目体系(taxonomy),入库时基于 L1 总结对文档分类,输出主类、多标签与置信度;置信度低于阈值时归入 `uncategorized` 兜底类。taxonomy 通过配置文件定义,支持环境变量指定路径,未配置时使用内置默认类目集。 + +#### Scenario: 正常分类 + +- **WHEN** 入库一篇内容明确的文档 +- **THEN** 返回 `main_category`(主类)、`tags`(0~3 个附加标签)、`confidence`(0~1),且主类属于 taxonomy 已定义类目 + +#### Scenario: 低置信兜底 + +- **WHEN** 文档内容跨类目或无法明确归类(分类置信度低于阈值) +- **THEN** `main_category` 为 `uncategorized`,多标签保留候选类目,检索时该文档仅在全库兜底通道与标签软召回中可见 + +### Requirement: 双向量索引(dense + sparse) + +系统 SHALL 为 L1 总结、L2 大纲节点、L3 内容大纲节点、原文 chunk 生成 dense 向量;并至少为原文 chunk 与 L1 总结生成 sparse(BM25)向量。dense 向量提供方可配置(openai / local),sparse 向量使用本地分词 + 特征哈希 BM25 实现,不依赖外部模型下载。 + +#### Scenario: 四层集合写入 + +- **WHEN** 文档完成总结与分类 +- **THEN** L1/L2/L3 各层节点与原文 chunk 分别写入对应集合,每条数据携带 `doc_id`、`category`、`tags`、`section_path`(chunk/L2/L3 层)payload,chunk 同时携带 dense 与 sparse 向量 + +### Requirement: chunk 切分与 section 关联 + +系统 SHALL 将原文按结构(标题树)优先、长度兜底切分为 chunk,每个 chunk 记录所属 section 路径,与 L2/L3 节点可互相定位。 + +#### Scenario: 结构化文档切分 + +- **WHEN** 文档含标题结构 +- **THEN** chunk 按 section 边界切分,`section_path` 与 L2 大纲节点对应;超长 section 内部按长度二次切分 + +### Requirement: query 解析与分类路由 + +系统 SHALL 在检索前用 Ollama 小模型以 JSON 约束输出解析 query:意图命中的类目集合 + 置信度、rewrite 后 query、关键词。解析失败或分类置信度低时 SHALL 跳过路由进入全库检索。 + +#### Scenario: 明确意图路由 + +- **WHEN** query 意图明确命中 1~2 个类目且置信度达标 +- **THEN** 后续检索仅在命中类目 payload 过滤范围内进行 + +#### Scenario: 跨类/模糊兜底 + +- **WHEN** 分类置信度低于阈值或命中类目数超过上限 +- **THEN** 放弃类目过滤,全库检索(不丢召回) + +### Requirement: 自顶向下摘要树剪枝检索 + +系统 SHALL 按 L1→L2→L3→chunk 顺序逐层缩小检索范围:L1 层检索落候选文档(top-N),候选文档内检索 L2 落候选 section,候选 section 内检索 L3 定位 chunk 范围,最终在范围内 hybrid 检索 chunk。2.5 级文档无 L2 层,L1 命中后 SHALL 直进 L3/chunk 层。任一层召回为空时 SHALL 回退到上一层范围直接检索 chunk。 + +#### Scenario: 三级文档逐层命中 + +- **WHEN** query 在路由范围内有匹配文档 +- **THEN** 检索沿 摘要树逐层收敛,最终 chunk 候选仅来自 L3 命中的 section 范围 + +#### Scenario: 剪枝为空回退 + +- **WHEN** L2 或 L3 层在候选范围内召回为空 +- **THEN** 回退到上一层候选文档范围直接 hybrid 检索 chunk,不返回空结果 + +### Requirement: hybrid 召回与重排 + +chunk 层 SHALL 同时执行 dense 与 sparse 检索,使用 RRF 融合排序,按 `retrieval_top_k` 召回、`retrieval_final_k` 返回。 + +#### Scenario: RRF 融合 + +- **WHEN** chunk 层执行检索 +- **THEN** dense 与 sparse 两路结果经 RRF 融合后排序,返回 final_k 个原文 chunk + +### Requirement: 检索结果只含原文 + +检索 API SHALL 仅返回原文 chunk 及引用元数据(doc_id、标题、section_path、score、所属文档 L1 总结作为上下文标注),摘要不作为可答题语料返回字段的主体。 + +#### Scenario: 结果结构 + +- **WHEN** 检索成功 +- **THEN** 每个 hit 含 `text`(原文)、`doc_id`、`title`、`section_path`、`score`、`doc_summary`(L1,仅上下文标注) + +### Requirement: 检索缓存 + +系统 SHALL 使用 Redis 缓存 query 解析结果与检索结果(短 TTL,可配置),缓存键包含 query 文本与路由类目;缓存不可用时不影响主流程。 + +### Requirement: 入库与检索 API + +系统 SHALL 提供 `POST /api/v1/documents`(入库,返回 document_id/分类/总结层级)、`POST /api/v1/search`(分层检索)、`GET /api/v1/knowledge/categories`(taxonomy 类目列表),统一响应格式 `{"code": 0, "data": {...}, "message": "ok"}`,错误码 0=成功、1xxx=客户端错误、2xxx=服务端错误。 + +### Requirement: 评测脚本骨架 + +系统 SHALL 提供离线评测脚本:回归集格式(文档 + golden query 含应检出/不应检出 + golden doc/section/chunk 标注),实现 Entity Recall、Hallucination Rate(LLM-as-judge 反查)、Routing F1、Pruning Loss、Precision@5 / Recall@10 指标,并支持同一 query 集对「平铺 chunk baseline」与本方案做 A/B 对比输出报告。 + +#### Scenario: 摘要质量门禁 + +- **WHEN** 对回归集运行评测脚本 +- **THEN** 输出各指标:L1 Entity Recall ≥ 0.85、L3 ≥ 0.9、Hallucination Rate < 2%、Pruning Loss < 8% 作为参考门槛,不达标项在报告中显式标红 + +## MODIFIED Requirements + +### Requirement: L2 大纲生成(原 LLM 自由生成) + +L2 大纲 SHALL 优先解析文档原生标题树(Markdown `#`/`##`/`###` 及常见编号标题模式),直接以标题层级作为大纲节点;仅当文档无可解析结构时回退为 LLM 生成大纲。L2 限定为「结构导航」,不写入结论性压缩语句,避免与 L1 总结语义重叠。原文无标题结构且为短文本时仍走既有 2.5 级回退。 + +**理由**:用户方案评审结论——LLM 自由生成的三级粒度易出现层级语义重叠、检索两头命中同一段;原生标题树更稳且零成本。 + +### Requirement: 分类判定(原 stub 返回 "default") + +原 `Ingester._classify` 占位实现 SHALL 替换为独立 classifier 模块:基于 L1 总结调用 Ollama 小模型 JSON 约束输出主类 + 多标签 + 置信度,对齐 taxonomy;不再硬编码返回 `default`。 + +## REMOVED Requirements + +### Requirement: 按分类映射写入对应集合(CLAUDE.md 设想) + +**Reason**:固定 taxonomy 的按类目分集合会使新业务线/跨类目问题被路由砍死,且多标签软召回与 uncategorized 兜底无法跨集合实现;taxonomy 变更需迁库。 +**Migration**:从未实现,无需迁移;以全局四层集合 + category payload 索引过滤替代(见存储设计说明)。 diff --git a/.trae/specs/add-hierarchical-rag-pipeline/tasks.md b/.trae/specs/add-hierarchical-rag-pipeline/tasks.md new file mode 100644 index 0000000..86985e9 --- /dev/null +++ b/.trae/specs/add-hierarchical-rag-pipeline/tasks.md @@ -0,0 +1,54 @@ +# Tasks + +- [x] Task 1: 扩展配置与数据模型:config 增加 taxonomy/各层 top-k/sparse 开关/缓存 TTL 等参数;新建 `app/models/knowledge.py`(TaxonomyCategory、CategoryResult)与 `app/models/search.py`(SearchRequest/SearchResponse/SearchHit);`app/models/document.py` 增加 ChunkModel(doc_id、text、section_path、chunk_index) + - [x] SubTask 1.1: config.py 增加 taxonomy_path、classify_confidence_threshold、l1_doc_top_n、l2_section_top_n、l3_top_n、sparse_enabled、cache_ttl 等配置项,同步更新 .env.example + - [x] SubTask 1.2: 新建 taxonomy 默认配置文件(内置通用类目集 + uncategorized),提供加载与校验函数 + - [x] SubTask 1.3: 新建 knowledge.py / search.py 模型,document.py 增加 ChunkModel 与 CategoryResult 引用 +- [x] Task 2: 向量服务:`app/core/embeddings.py`(dense,openai | local 双 provider 统一异步接口,批量编码)+ `app/core/sparse.py`(本地分词 + 特征哈希 BM25 稀疏向量,输出 Qdrant sparse vector 格式,不依赖外部模型) + - [x] SubTask 2.1: EmbeddingService 抽象 + openai provider 实现 + local provider(Ollama embedding 接口)实现 + - [x] SubTask 2.2: SparseEncoder(中文分词 + BM25 权重 + 特征哈希到固定维度),单测验证输出稀疏格式合法 +- [x] Task 3: Qdrant 服务封装 `app/services/qdrant.py`:启动时初始化 4 个集合(doc_l1 / doc_l2 / doc_l3 / chunks,chunks 与 doc_l1 带 sparse 向量配置),建立 category/tags/doc_id/section_path payload 索引;提供 upsert 与按层检索(支持 payload 过滤 + dense/sparse/hybrid 查询)接口 + - [x] SubTask 3.1: 集合初始化与 payload 索引(幂等,应用启动时执行) + - [x] SubTask 3.2: upsert 接口(按层写入,chunk 携带 dense+sparse) + - [x] SubTask 3.3: 分层查询接口(dense 过滤查询 / hybrid 查询) +- [x] Task 4: summarizer 改造 + chunk 切分器:L2 大纲优先解析原生标题树(Markdown 标题 + 编号标题正则),无结构回退现有 LLM 生成;新建 `app/core/chunker.py` 按标题树切分 chunk(超长 section 按长度二次切分,短文本整篇单 chunk),chunk 记录 section_path 与 L2 节点对应关系 + - [x] SubTask 4.1: 标题树解析器(Markdown ATX 标题 + 中文编号标题),输出 section 树 + - [x] SubTask 4.2: summarizer 接入标题树:有结构时 L2 直接用标题树生成大纲文本,无结构走原 prompt;L3 保持不变 + - [x] SubTask 4.3: Chunker 实现与单测(结构化/非结构化/短文本三种输入) +- [x] Task 5: 分类器 + ingestion 全链路补全:`app/core/classifier.py`(基于 L1 总结,Ollama JSON 约束输出主类+多标签+置信度,低置信归 uncategorized);改造 `app/core/ingestion.py` 串起 总结→分类→chunk 切分→双向量化→Qdrant 写入,返回真实 document_id 与集合信息 + - [x] SubTask 5.1: Classifier 实现(taxonomy 注入 prompt,JSON 解析容错,置信度阈值判兜底) + - [x] SubTask 5.2: Ingester 全链路串联(总结→分类→切 chunk→embedding→sparse→upsert 四层) + - [x] SubTask 5.3: 入库失败处理:Qdrant 写入失败不丢已生成总结,返回明确错误码与可重试标识 +- [x] Task 6: 入库与类目 API:`app/api/v1/document.py`(POST /api/v1/documents)、`app/api/v1/knowledge.py`(GET /api/v1/knowledge/categories),统一响应包装与错误码,main.py 注册路由 +- [x] Task 7: query 解析与分类路由:`app/core/query_parser.py`(Ollama 小模型 JSON 约束输出:命中类目+置信度、rewrite query、关键词;解析失败/低置信/命中过多类目→全库兜底) + - [x] SubTask 7.1: QueryParser 实现与 JSON 容错解析 + - [x] SubTask 7.2: 路由决策函数(主类硬过滤 + 多标签软召回合并为 Qdrant payload filter + 兜底判定),单测覆盖三类分支 +- [x] Task 8: 分层检索引擎 + 重排 + 检索 API:`app/core/retriever.py`(L1→L2→L3→chunk 逐层剪枝,2.5 级文档跳过 L2,空召回回退上一层直搜 chunk)+ `app/core/ranker.py`(RRF 融合 + final_k 截断)+ `app/api/v1/search.py`(POST /api/v1/search) + - [x] SubTask 8.1: Retriever 逐层 drill-down 主流程 + - [x] SubTask 8.2: 降级路径(2.5 级跳层、空召回回退、路由兜底直通) + - [x] SubTask 8.3: Ranker RRF 融合与单测 + - [x] SubTask 8.4: 检索 API 与统一响应 +- [x] Task 9: Redis 缓存 `app/services/redis.py`:query 解析结果与检索结果缓存(短 TTL 可配置),缓存键含 query 与路由类目;Redis 不可用时降级直连不报错 +- [x] Task 10: 评测脚本骨架 `scripts/eval/`:回归集 JSON 格式定义与样例(≥5 篇文档、每篇 3~5 条应检出 query + 1~2 条不应检出 query,标注 golden doc/section/chunk);指标实现 Entity Recall、Routing F1、Pruning Loss、Precision@5/Recall@10;Hallucination Rate 用 Ollama LLM-as-judge 反查;支持平铺 chunk baseline 对比并输出 Markdown 报告(含门槛标红:L1 Entity Recall≥0.85、L3≥0.9、幻觉率<2%、Pruning Loss<8%) + - [x] SubTask 10.1: 回归集 schema + 样例数据 + - [x] SubTask 10.2: 摘要质量指标(Entity Recall / Hallucination Rate) + - [x] SubTask 10.3: 检索效用指标(Routing F1 / Pruning Loss / Precision@5)+ baseline 对比报告 +- [x] Task 11: 端到端验证:docker compose 起 Qdrant/Redis/Ollama,走通 入库→检索→评测 全链路;`uv run pytest` 全绿;修复发现的问题 + +# Task Dependencies + +- [Task 2] depends on [Task 1] +- [Task 3] depends on [Task 1] +- [Task 4] depends on [Task 1] +- [Task 5] depends on [Task 2, Task 3, Task 4] +- [Task 6] depends on [Task 5] +- [Task 7] depends on [Task 1] +- [Task 8] depends on [Task 3, Task 7] +- [Task 9] depends on [Task 7, Task 8] +- [Task 10] depends on [Task 5, Task 8] +- [Task 11] depends on [Task 6, Task 9, Task 10] + +# Parallelizable + +- Task 2 / Task 3 / Task 4 / Task 7 互相独立,可并行 +- Task 6 与 Task 8 可并行(依赖均已满足后) diff --git a/.trae/specs/complete-admin-console-and-tests/checklist.md b/.trae/specs/complete-admin-console-and-tests/checklist.md new file mode 100644 index 0000000..952750f --- /dev/null +++ b/.trae/specs/complete-admin-console-and-tests/checklist.md @@ -0,0 +1,41 @@ +# Checklist + +## 文档管理 API + +- [x] GET /api/v1/documents 分页返回 items(doc_id/title/category/tags/summary)与 next_offset,空库返回空列表 +- [x] GET /api/v1/documents/{doc_id} 返回 L1 全部字段 + l2_nodes/l3_nodes + chunks_count +- [x] 不存在 doc_id 详情返回 code=1004 +- [x] DELETE /api/v1/documents/{doc_id} 后四层集合该文档点全部清除,再查详情返回 1004 +- [x] DELETE 不存在 doc_id 幂等成功且 deleted 统计为 0 + +## 统计 API + +- [x] GET /api/v1/knowledge/stats 返回四层集合点数、类目分布、uncategorized_count +- [x] stats 数值与内存 Qdrant 实际写入一致(测试验证) + +## 管理页面 + +- [x] GET /admin 返回 200 HTML +- [x] 页面含 概览/文档管理/入库/检索测试台/类目 五个区块 +- [x] 页面无外部 CDN/外链资源(无 http(s):// src 或 link) +- [x] 所有 fetch 指向 /api/v1/ 路径且与后端路由契约一致(路径、方法、字段名) +- [x] 删除操作有二次确认逻辑(confirm 或等价交互) +- [x] code≠0 时页面有错误提示处理 + +## 单测补全 + +- [x] judge.py:正常幻觉率计算、部分断言不支持、解析失败返回 0.0 +- [x] response.py:ok/error/ApiError 结构与 code +- [x] ollama.py:generate 正常/json_mode payload 含 format:is_available 可达与异常分支 +- [x] run_eval.py:门槛判定(✅/❌)与数值格式化纯函数 + +## 部署与文档 + +- [x] Dockerfile 含 COPY scripts/ scripts/ +- [x] CLAUDE.md 项目结构、API 清单(含 documents 管理端点/stats//admin)与实际一致 + +## 集成 + +- [x] 内存 Qdrant 管理闭环冒烟通过:入库→列表→详情→检索→删除→统计 +- [x] `uv run pytest` 全部通过(含新增测试) +- [x] `uv run ruff check app tests scripts` 无错误 diff --git a/.trae/specs/complete-admin-console-and-tests/spec.md b/.trae/specs/complete-admin-console-and-tests/spec.md new file mode 100644 index 0000000..01ec6c2 --- /dev/null +++ b/.trae/specs/complete-admin-console-and-tests/spec.md @@ -0,0 +1,83 @@ +# 项目完整性补全:管理 API + 管理页面 + 单测补全 Spec + +## Why + +分层 RAG 全链路已验收(149 测试全绿),但系统对外闭环不完整:已入库文档无法查看/管理(无列表/详情/删除 API),运维人员无 UI 可手工验证检索与入库,judge/response/ollama 客户端等模块无单测覆盖,Dockerfile 未打包评测脚本,CLAUDE.md 项目结构已过时。本变更补齐管理闭环与测试覆盖,使系统达到可运维状态。 + +## What Changes + +- **文档管理 API(新增)**:`GET /api/v1/documents`(分页列表,源自 doc_l1 scroll)、`GET /api/v1/documents/{doc_id}`(详情:L1 总结 + L2/L3 节点 + chunk 数)、`DELETE /api/v1/documents/{doc_id}`(按 doc_id 过滤删除四层集合所有点,幂等) +- **统计 API(新增)**:`GET /api/v1/knowledge/stats`(四层集合点数、类目分布、uncategorized 数量) +- **管理页面(新增)**:FastAPI 托管静态单页 `/admin`,原生 JS + fetch 单文件实现(无 Node 构建链、镜像零新增依赖),含 概览 / 文档管理 / 文档入库 / 检索测试台 / 类目列表 五个区块;删除操作前端二次确认 +- **单元测试补全**:scripts/eval/judge.py、app/api/response.py、app/services/ollama.py(HTTP 行为)、scripts/eval/run_eval.py 可测纯函数、新增管理 API 端点测试、/admin 页面存在性测试 +- **完整性修补**:Dockerfile 增加 `COPY scripts/ scripts/`;CLAUDE.md 项目结构与 API 清单更新至当前实现 + +无破坏性变更(现有 API 路径与响应结构不变,仅新增)。 + +## Impact + +- Affected specs: 文档管理(新增)、统计概览(新增)、管理页面(新增)、测试覆盖、部署 +- Affected code: + - 修改:[qdrant.py](file:///Users/kplam/coding/QMDSearch/app/services/qdrant.py)(scroll/delete/count 管理操作)、[main.py](file:///Users/kplam/coding/QMDSearch/app/main.py)(挂载 /admin 静态页)、[document.py](file:///Users/kplam/coding/QMDSearch/app/api/v1/document.py)(新增端点)、[knowledge.py](file:///Users/kplam/coding/QMDSearch/app/api/v1/knowledge.py)(stats)、[Dockerfile](file:///Users/kplam/coding/QMDSearch/Dockerfile)、[CLAUDE.md](file:///Users/kplam/coding/QMDSearch/CLAUDE.md) + - 新增:`app/static/admin.html`、`tests/test_judge.py`、`tests/test_response.py`、`tests/test_ollama_client.py`、`tests/test_run_eval.py`、`tests/test_document_admin_api.py`、`tests/test_admin_page.py` + +## ADDED Requirements + +### Requirement: 文档列表与详情 + +系统 SHALL 提供 `GET /api/v1/documents`:基于 doc_l1 集合 scroll 分页返回文档摘要列表(doc_id、title、category、tags、总结层级推断、L1 总结摘要),支持 `limit`/`offset` 游标分页;并提供 `GET /api/v1/documents/{doc_id}` 返回单文档详情:L1 payload 全部字段、L2/L3 节点列表、chunk 数量。doc_id 不存在时返回 code=1004。 + +#### Scenario: 分页列表 + +- **WHEN** 请求 `GET /api/v1/documents?limit=20` +- **THEN** 返回 code=0,data 含 items(每项 doc_id/title/category/tags/summary)与 next_offset(无更多为 null) + +#### Scenario: 详情与不存在 + +- **WHEN** 请求已入库 doc_id 的详情 +- **THEN** 返回 L1 字段 + l2_nodes/l3_nodes 列表 + chunks_count;请求不存在的 doc_id 返回 code=1004 + +### Requirement: 文档删除 + +系统 SHALL 提供 `DELETE /api/v1/documents/{doc_id}`:按 doc_id payload 过滤删除 doc_l1/doc_l2/doc_l3/chunks 四个集合中的全部点;删除不存在 doc_id 幂等返回成功(deleted_points=0);返回各集合删除点数统计。 + +#### Scenario: 删除已入库文档 + +- **WHEN** 对已入库 doc_id 执行 DELETE +- **THEN** 四层集合该 doc_id 的点全部被清除,再次 GET 详情返回 1004 + +### Requirement: 统计概览 + +系统 SHALL 提供 `GET /api/v1/knowledge/stats`:返回四层集合各自点数、按 category 的文档分布、uncategorized 文档数。统计基于 Qdrant count 与 doc_l1 轻量 scroll 聚合(仅取 category 字段),属管理端低频接口。 + +#### Scenario: 概览数据 + +- **WHEN** 请求 stats +- **THEN** data 含 collections(四层点数)、categories(类目→文档数)、uncategorized_count + +### Requirement: 管理页面 + +系统 SHALL 在 `/admin` 提供静态单页管理界面(单 HTML 文件,内联 CSS/JS,无外部 CDN 依赖),包含五个区块:概览(stats 展示)、文档管理(列表 + 详情查看 + 删除,删除需二次确认)、文档入库(表单提交 text/title/source,展示分类与总结结果)、检索测试台(输入 query 展示 hits/routed_categories/fallback)、类目列表。页面调用同-origin `/api/v1/*`,统一处理 code≠0 的错误提示。 + +#### Scenario: 页面可用性 + +- **WHEN** 浏览器访问 `/admin` +- **THEN** 返回 200 HTML,包含五个功能区块与全部 fetch 调用指向 `/api/v1/` 路径,无外部资源引用 + +### Requirement: 单元测试补全 + +系统 SHALL 为以下模块补齐单测:judge.py(mock Ollama:断言抽取/支持判定/解析失败返回 0.0)、response.py(ok/error/ApiError 结构)、ollama.py(mock httpx:generate/json_mode payload/is_available 可达与异常分支)、run_eval.py 可测纯函数(格式化与门槛判定)、新增管理 API 与 /admin 页面存在性(TestClient 200 + 关键区块标记)。 + +### Requirement: 部署与文档完整性 + +Dockerfile SHALL 打包 scripts/ 目录(镜像内可执行评测);CLAUDE.md SHALL 更新项目结构、API 清单与管理页面说明至当前实现。 + +## MODIFIED Requirements + +### Requirement: QdrantService(新增管理操作) + +QdrantService SHALL 新增:`scroll_l1(limit, offset) -> (items, next_offset)`(仅取列表所需 payload 字段)、`get_doc_detail(doc_id) -> dict | None`(L1 + L2/L3 节点 + chunk 计数)、`delete_by_doc_id(doc_id) -> dict[str, int]`(四集合按过滤删除,返回各集合删除数)、`count(collection) -> int`。均复用现有客户端与集合常量,不改变既有 upsert/查询行为。 + +## REMOVED Requirements + +无。 diff --git a/.trae/specs/complete-admin-console-and-tests/tasks.md b/.trae/specs/complete-admin-console-and-tests/tasks.md new file mode 100644 index 0000000..11b0179 --- /dev/null +++ b/.trae/specs/complete-admin-console-and-tests/tasks.md @@ -0,0 +1,24 @@ +# Tasks + +- [x] Task 1: QdrantService 管理操作扩展:scroll_l1 分页(仅取 doc_id/title/category/tags/text/level 所需字段)、get_doc_detail(L1+L2/L3 节点+chunk 计数)、delete_by_doc_id(四集合过滤删除返回各集合删除数)、count;内存 Qdrant 单测覆盖 + - [x] SubTask 1.1: scroll_l1 与 count 实现 + 单测(分页游标、空集合) + - [x] SubTask 1.2: get_doc_detail 与 delete_by_doc_id 实现 + 单测(存在/不存在/删除后四层清空) +- [x] Task 2: 文档管理 API:`GET /api/v1/documents`(limit/offset 分页)、`GET /api/v1/documents/{doc_id}`(详情,不存在 code=1004)、`DELETE /api/v1/documents/{doc_id}`(幂等,返回各集合删除数);沿用统一响应与懒加载单例模式;TestClient 测试(mock QdrantService) +- [x] Task 3: 统计 API:`GET /api/v1/knowledge/stats`(四层点数 + 类目分布 + uncategorized 数);TestClient 测试 +- [x] Task 4: 管理页面 `app/static/admin.html` 单文件(内联 CSS/JS,零外部依赖)+ main.py 挂载 /admin:概览/文档管理(列表/详情/删除二次确认)/入库表单/检索测试台/类目列表五区块,统一 code≠0 错误提示;TestClient 存在性测试(200 + 五区块标记 + 无 http(s) 外链资源) +- [x] Task 5: 单测补全:tests/test_judge.py(mock Ollama:正常判定/部分断言不支持/解析失败返回 0.0)、tests/test_response.py、tests/test_ollama_client.py(mock httpx:generate/json_mode/is_available 分支)、tests/test_run_eval.py(门槛判定与格式化纯函数) +- [x] Task 6: 部署与文档修补:Dockerfile 增加 `COPY scripts/ scripts/`;CLAUDE.md 更新项目结构(scripts/eval、static)、API 清单(documents 管理端点、stats、/admin)与管理页面使用说明 +- [x] Task 7: 集成验证:`uv run pytest` 全绿 + `uv run ruff check app tests scripts` 通过;内存 Qdrant 走通 入库→列表→详情→检索→删除→统计 管理闭环冒烟;确认页面 fetch 路径与实际 API 契约一致 +- [x] Task 8: 修复验收发现:CLAUDE.md 项目结构中 tests 目录注释「18 个测试文件」与实际 26 个不符,改为不写死数量防止再次漂移 + +# Task Dependencies + +- [Task 2] depends on [Task 1] +- [Task 3] depends on [Task 1] +- [Task 4] depends on [Task 2, Task 3] +- [Task 7] depends on [Task 4, Task 5, Task 6] + +# Parallelizable + +- Task 1 / Task 5 / Task 6 互相独立,可并行 +- Task 2 与 Task 3 可并行(Task 1 完成后) diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..a815d23 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,160 @@ +# QMDSearch - AI Agent 分层信息检索服务 + +## 项目概述 + +QMDSearch 是面向 AI Agent 的分层信息检索服务,支持多层级知识库检索、向量语义搜索和结构化数据查询。基于 Docker 部署在 NAS 上,为 AI Agent 提供高效、精准的信息检索能力。 + +## 技术栈 + +- **语言**: Python 3.12+ +- **Web 框架**: FastAPI +- **向量数据库**: Qdrant (Docker) +- **缓存**: Redis (Docker) +- **关系数据库**: PostgreSQL (Docker, 可选) +- **嵌入模型**: OpenAI / 本地模型 (通过配置切换) +- **本地推理模型**: Ollama (Docker) — 用于文档三级总结,默认 qwen2.5:1.5b +- **部署**: Docker Compose on NAS + +## 项目结构 + +``` +QMDSearch/ +├── app/ # 应用主目录 +│ ├── main.py # FastAPI 入口(含 /admin 管理页面挂载) +│ ├── config.py # 配置管理 +│ ├── api/ # API 路由层 +│ │ ├── response.py # 统一响应格式 (ok/error/ApiError) +│ │ └── v1/ # API v1 版本 +│ │ ├── search.py # 检索接口 +│ │ ├── document.py # 文档入库/管理接口 +│ │ └── knowledge.py # 知识库接口 +│ ├── core/ # 核心业务逻辑 +│ │ ├── retriever.py # 分层检索引擎 (L1→L2→L3→chunk) +│ │ ├── query_parser.py # query 解析与分类路由 +│ │ ├── embeddings.py # 向量嵌入 +│ │ ├── sparse.py # 稀疏向量编码 (BM25 近似) +│ │ ├── ranker.py # RRF 融合与结果截断 +│ │ ├── summarizer.py # 文档三级总结 (Ollama) +│ │ ├── headings.py # 原生标题树解析 +│ │ ├── classifier.py # 文档分类 (主类+标签+置信度) +│ │ ├── chunker.py # 标题树感知 chunk 切分 +│ │ └── ingestion.py # 文档入库 (总结→分类→写入) +│ ├── models/ # 数据模型 +│ │ ├── search.py # 检索请求/响应模型 +│ │ ├── knowledge.py # 知识库/taxonomy 模型 +│ │ └── document.py # 文档/总结模型 +│ ├── services/ # 服务层 (外部交互) +│ │ ├── qdrant.py # Qdrant 客户端 (四层集合) +│ │ ├── redis.py # Redis 缓存客户端 +│ │ └── ollama.py # Ollama 客户端 +│ ├── static/ # 静态资源 +│ │ └── admin.html # 管理页面 (单文件) +│ └── utils/ # 工具函数 +├── scripts/ # 运维与评测脚本 +│ ├── eval/ # 回归评测 (run_eval.py / metrics.py / judge.py / regression_set.json) +│ └── smoke_live.py # 在线冒烟脚本 +├── tests/ # 测试 +├── docker-compose.yml # Docker 编排 +├── Dockerfile # 应用镜像 +├── .env.example # 环境变量模板 +├── pyproject.toml # 项目配置 +└── CLAUDE.md # 本文件 +``` + +## 分层检索架构 + +检索链路:query 解析路由 → L1→L2→L3 摘要树逐层剪枝 → chunk 层 hybrid 检索 (dense + sparse,RRF 融合)。 + +0. **query 解析路由**: 用 Ollama 小模型将 query 解析为结构化结果(rewrite、关键词、命中类目+置信度);高置信且类目数不超上限时按类目过滤,低置信/解析失败/类目过多则全库兜底(不丢召回) +1. **L1 - 文档定位层**: 在文档 L1 总结集合中检索,产出候选文档集合;无命中时直接全库 chunk 兜底(fallback=True) +2. **L2 - 章节大纲层**: 在候选文档内检索 L2 章节大纲,按 section 剪枝定位;未命中的文档(含 2.5 级文档)落入 L3 b 路(仅按 doc 过滤) +3. **L3 - 小节内容层**: 两路查询(a:L2 命中文档按 section_path 过滤;b:其余文档仅按 doc_id 过滤)后 RRF 融合;无命中时 chunk 层回退为 L1 候选文档级检索 +4. **chunk 层**: 在 L3 收窄的范围内对原文 chunk 做 hybrid 检索(dense 向量 + sparse 稀疏向量 RRF 融合),截断后返回最终 Top-K + +## API 设计规范 + +- RESTful 风格,版本化路径: `/api/v1/` +- 请求/响应使用 Pydantic 模型校验 +- 统一响应格式: `{"code": 0, "data": {...}, "message": "ok"}` +- 错误码: 0=成功, 1xxx=客户端错误, 2xxx=服务端错误 + +## API 清单 + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/api/v1/health` | 健康检查 | +| POST | `/api/v1/search` | 分层检索 | +| POST | `/api/v1/documents` | 文档入库(202 异步入库,返回 task_id) | +| GET | `/api/v1/documents/tasks/{task_id}` | 入库任务状态查询(done 附 result,failed 附 error) | +| GET | `/api/v1/knowledge/categories` | 知识分类类目集 | +| GET | `/api/v1/knowledge/stats` | 统计(四层点数 + 类目分布 + uncategorized 数) | +| GET | `/api/v1/documents` | 文档列表(limit/offset 分页) | +| GET | `/api/v1/documents/{doc_id}` | 文档详情 | +| DELETE | `/api/v1/documents/{doc_id}` | 删除文档(幂等) | +| GET | `/admin` | 管理页面 | + +入库任务状态持久化在 Redis(key: `ingest_task:{task_id}`):进行中与 done 保留 24h,failed 保留 7 天;Redis 不可用时降级为纯内存。 + +## 管理页面 + +浏览器访问 `/admin`,单页面含概览、文档管理(列表/详情/删除)、文档入库、检索测试台、类目列表五个区块。 + +## 编码规范 + +- 使用 uv 管理依赖 +- 类型注解必须 (Python 3.12+ 语法) +- 异步优先 (async/await) +- 配置通过环境变量注入,使用 pydantic-settings +- 日志使用 structlog 结构化日志 + +## 文档入库与三级总结 + +文档入库时通过 Ollama 本地小模型对文档内容进行三级总结,然后根据总结进行分类入库。 + +### 总结层级 + +| 层级 | 名称 | 说明 | 存储用途 | +|------|------|------|----------| +| L1 | 总结 | 对整篇文档的一句话高度概括 | 快速分类、知识库路由 | +| L2 | 大纲 | 提取文档主要章节和关键主题(优先使用文档原生标题树,无结构文本回退 LLM 生成) | 检索召回、上下文概览 | +| L3 | 内容大纲 | 每个章节的详细内容摘要 | 精确匹配、深度检索 | + +**2.5 级回退**: 当文档内容不足以支撑三级总结时(如短文本、简单说明),自动降级为二级总结: +- L1: 总结(同上) +- L2.5: 内容大纲(跳过大纲层,直接输出详细摘要) + +### 入库流程 + +``` +文档输入 → 文本提取 → 三级总结(Ollama) → 分类判定(L1总结) → 向量化 → 写入Qdrant + ↓ + 不足三级 → 2.5级回退 +``` + +1. **文本提取**: 从文件中提取纯文本内容 +2. **三级总结**: 调用 Ollama 依次生成 L1/L2/L3 总结;其中 L2 大纲优先使用文档原生标题树(不调 LLM),无结构文本(标题数 < 2)回退 LLM 生成 +3. **分类判定**: 根据 L1 总结将文档分配到 taxonomy 类目,输出主类目 + 附加标签 + 置信度;LLM 输出解析失败或置信度低于阈值时归 uncategorized 兜底(低置信时候选类目名保留进 tags 供软召回) +4. **向量化**: 对原文 chunk 和各级总结分别生成 embedding(dense + sparse) +5. **写入 Qdrant**: 将文档元数据、各级总结、向量写入对应四层集合(L1/L2/L3/chunks) + +### 本地模型 (Ollama) + +- 服务: Ollama 容器,默认模型 `qwen2.5:1.5b`(约 1GB,适合 NAS 低资源环境) +- 备选模型: `qwen2.5:3b`(更好的总结质量,约 2GB) +- 模型通过环境变量 `OLLAMA_MODEL` 配置 +- 首次启动时自动拉取模型,需 NAS 可访问外网 + +## Docker 部署说明 + +- 目标环境: NAS (ARM64/AMD64) +- 镜像: python:3.12-slim +- 持久化: NAS 本地目录挂载 +- 端口: 默认 8000 (API), 6333 (Qdrant), 6379 (Redis), 11434 (Ollama) +- 健康检查: `/api/v1/health` + +## 开发流程 + +1. 修改代码后本地测试: `uv run pytest` +2. 构建镜像: `docker compose build` +3. 启动服务: `docker compose up -d` +4. 查看日志: `docker compose logs -f app` diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..371ba2f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,21 @@ +FROM python:3.12-slim AS base + +WORKDIR /app + +# 安装 uv +COPY --from=ghcr.io/astral-sh/uv:latest /uv /uv/bin/uv + +# 依赖层 +COPY pyproject.toml uv.lock ./ +RUN uv sync --frozen --no-dev + +# 应用层 +COPY app/ app/ +COPY scripts/ scripts/ + +EXPOSE 8000 + +HEALTHCHECK --interval=30s --timeout=5s --retries=3 \ + CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/v1/health')" + +CMD ["uv", "run", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/__init__.py b/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/response.py b/app/api/response.py new file mode 100644 index 0000000..6791e38 --- /dev/null +++ b/app/api/response.py @@ -0,0 +1,26 @@ +"""统一 API 响应包装与业务异常 + +响应格式:{"code": 0, "data": ..., "message": "ok"} +错误码约定:0 成功;1001 请求参数校验失败;2000 服务器内部错误。 +""" + +from typing import Any + + +def ok(data: Any) -> dict[str, Any]: + """成功响应包装""" + return {"code": 0, "data": data, "message": "ok"} + + +def error(code: int, message: str) -> dict[str, Any]: + """错误响应包装""" + return {"code": code, "data": None, "message": message} + + +class ApiError(Exception): + """业务异常:携带错误码与消息,由全局异常处理器转为统一错误响应""" + + def __init__(self, code: int, message: str) -> None: + self.code = code + self.message = message + super().__init__(message) diff --git a/app/api/v1/__init__.py b/app/api/v1/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/v1/document.py b/app/api/v1/document.py new file mode 100644 index 0000000..2f0822a --- /dev/null +++ b/app/api/v1/document.py @@ -0,0 +1,111 @@ +"""文档 API:POST /api/v1/documents 异步入库 + 任务查询 + 文档管理(列表/详情/删除)""" + +from typing import Any + +import structlog +from fastapi import APIRouter, Query +from fastapi.responses import JSONResponse + +from app.api.response import ApiError, ok +from app.config import Settings +from app.core.ingest_tasks import IngestTaskManager +from app.core.ingestion import Ingester +from app.models.document import DocumentInput +from app.services.qdrant import QdrantService +from app.services.redis import RedisCache, get_cache + +logger = structlog.get_logger() + +router = APIRouter(prefix="/api/v1", tags=["document"]) + +# 模块级懒加载单例,避免每请求重建 Ingester 及其下游依赖 +_ingester: Ingester | None = None +_qdrant: QdrantService | None = None +_task_manager: IngestTaskManager | None = None + + +def _get_ingester() -> Ingester: + global _ingester + if _ingester is None: + _ingester = Ingester() + return _ingester + + +def _get_qdrant() -> QdrantService: + global _qdrant + if _qdrant is None: + _qdrant = QdrantService() + return _qdrant + + +def _get_task_manager() -> IngestTaskManager: + """入库任务管理器懒加载单例 + + Redis 沿用全局缓存单例(RedisCache 读写全容错,不可用时镜像写失败仅告警); + 获取缓存实例异常时传 None,退化为纯内存模式。 + """ + global _task_manager + if _task_manager is None: + try: + redis: RedisCache | None = get_cache() + except Exception: + logger.warning("Redis 缓存不可用,入库任务状态仅保留在内存", exc_info=True) + redis = None + _task_manager = IngestTaskManager(ingester=_get_ingester(), redis=redis, settings=Settings()) + return _task_manager + + +@router.post("/documents") +async def ingest_document(doc: DocumentInput) -> JSONResponse: + """文档入库入口:登记异步任务并返回 202 + task_id,入库结果经任务查询端点获取""" + if not doc.text.strip(): + raise ApiError(1001, "文档内容不能为空") + task_id = await _get_task_manager().submit(doc) + return JSONResponse(status_code=202, content=ok({"task_id": task_id, "status": "pending"})) + + +@router.get("/documents/tasks/{task_id}") +async def get_ingest_task(task_id: str) -> dict[str, Any]: + """查询入库任务状态:含 task_id/status/created_at/updated_at,done 附 result,failed 附 error""" + task = await _get_task_manager().get(task_id) + if task is None: + raise ApiError(1004, "任务不存在") + return ok(task) + + +@router.get("/documents") +async def list_documents( + limit: int = Query(default=20, ge=1, le=100), + offset: str | None = None, +) -> dict[str, Any]: + """分页列出文档(L1 摘要),返回 items 与下一页游标 next_offset""" + try: + items, next_offset = await _get_qdrant().scroll_l1(limit=limit, offset=offset) + except Exception as exc: + logger.error("文档列表查询失败", error=str(exc)) + raise ApiError(2000, f"文档列表查询失败: {exc}") from exc + return ok({"items": items, "next_offset": next_offset}) + + +@router.get("/documents/{doc_id}") +async def get_document(doc_id: str) -> dict[str, Any]: + """获取文档详情:L1 记录 + L2/L3 节点 + chunks 数量""" + try: + detail = await _get_qdrant().get_doc_detail(doc_id) + except Exception as exc: + logger.error("文档详情查询失败", doc_id=doc_id, error=str(exc)) + raise ApiError(2000, f"文档详情查询失败: {exc}") from exc + if detail is None: + raise ApiError(1004, "文档不存在") + return ok(detail) + + +@router.delete("/documents/{doc_id}") +async def delete_document(doc_id: str) -> dict[str, Any]: + """删除文档:四层集合中该 doc_id 的所有点;幂等,不存在也返回成功(删除数全 0)""" + try: + deleted = await _get_qdrant().delete_by_doc_id(doc_id) + except Exception as exc: + logger.error("文档删除失败", doc_id=doc_id, error=str(exc)) + raise ApiError(2000, f"文档删除失败: {exc}") from exc + return ok({"doc_id": doc_id, "deleted": deleted, "deleted_total": sum(deleted.values())}) diff --git a/app/api/v1/knowledge.py b/app/api/v1/knowledge.py new file mode 100644 index 0000000..b9611d2 --- /dev/null +++ b/app/api/v1/knowledge.py @@ -0,0 +1,75 @@ +"""知识分类 API:GET /api/v1/knowledge/categories、GET /api/v1/knowledge/stats""" + +from functools import lru_cache +from typing import Any + +import structlog +from fastapi import APIRouter + +from app.api.response import ApiError, ok +from app.config import settings +from app.models.knowledge import UNCATEGORIZED, TaxonomyCategory, load_taxonomy +from app.services.qdrant import ALL_COLLECTIONS, COLLECTION_L1, QdrantService + +logger = structlog.get_logger() + +router = APIRouter(prefix="/api/v1", tags=["knowledge"]) + +# 类目分布统计的分页大小 +_STATS_SCROLL_PAGE_SIZE = 100 + +# 模块级懒加载单例,避免每请求重建 QdrantService +_qdrant: QdrantService | None = None + + +def _get_qdrant() -> QdrantService: + global _qdrant + if _qdrant is None: + _qdrant = QdrantService() + return _qdrant + + +@lru_cache(maxsize=1) +def _get_taxonomy() -> list[TaxonomyCategory]: + """加载并缓存 taxonomy 类目集(进程内只加载一次)""" + return load_taxonomy(settings.taxonomy_path) + + +@router.get("/knowledge/categories") +async def list_categories() -> dict[str, Any]: + """返回完整知识分类类目集""" + categories = _get_taxonomy() + return ok({"categories": [c.model_dump() for c in categories], "count": len(categories)}) + + +@router.get("/knowledge/stats") +async def knowledge_stats() -> dict[str, Any]: + """返回四层集合规模与 L1 类目分布统计""" + service = _get_qdrant() + try: + collections = {collection: await service.count(collection) for collection in ALL_COLLECTIONS} + categories = await _aggregate_l1_categories(service) + except Exception as exc: + logger.error("获取知识库统计失败", error=str(exc)) + raise ApiError(2000, f"获取知识库统计失败: {exc}") from exc + return ok( + { + "collections": collections, + "categories": categories, + "uncategorized_count": categories.get(UNCATEGORIZED, 0), + "documents_total": collections[COLLECTION_L1], + } + ) + + +async def _aggregate_l1_categories(service: QdrantService) -> dict[str, int]: + """分页遍历 L1 文档,按 category 聚合文档数(空类目归入 uncategorized)""" + categories: dict[str, int] = {} + offset: str | None = None + while True: + items, offset = await service.scroll_l1(limit=_STATS_SCROLL_PAGE_SIZE, offset=offset) + for item in items: + category = item.get("category") or UNCATEGORIZED + categories[category] = categories.get(category, 0) + 1 + if offset is None: + return categories diff --git a/app/api/v1/search.py b/app/api/v1/search.py new file mode 100644 index 0000000..e514d96 --- /dev/null +++ b/app/api/v1/search.py @@ -0,0 +1,58 @@ +"""检索 API:POST /api/v1/search""" + +from hashlib import sha256 +from typing import Any + +import structlog +from fastapi import APIRouter + +from app.api.response import ApiError, ok +from app.core.retriever import Retriever +from app.models.search import SearchRequest +from app.services.redis import get_cache + +logger = structlog.get_logger() + +router = APIRouter(prefix="/api/v1", tags=["search"]) + +# 模块级懒加载单例,避免每请求重建 Qdrant/Embedding client +_retriever: Retriever | None = None + + +def _get_retriever() -> Retriever: + global _retriever + if _retriever is None: + _retriever = Retriever() + return _retriever + + +def _cache_key(request: SearchRequest) -> str: + """检索缓存键:query + top_k 的短哈希(top_k 影响结果集,需参与键计算)""" + digest = sha256((request.query + "|" + str(request.top_k)).encode()).hexdigest()[:16] + return f"search:{digest}" + + +@router.post("/search") +async def search(request: SearchRequest) -> dict[str, Any]: + """分层检索入口,返回统一包装的 SearchResponse + + 先查 Redis 缓存:命中直接返回缓存的响应;未命中走检索流程并回写缓存。 + 缓存读写失败均降级为无缓存行为,不影响检索。 + """ + cache_key = _cache_key(request) + cached = await get_cache().get_json(cache_key) + if cached is not None: + logger.info("检索缓存命中", query=request.query, cache_key=cache_key) + return cached + + try: + response = await _get_retriever().search(request) + except ApiError: + raise + except Exception as exc: + logger.exception("检索失败", query=request.query) + raise ApiError(2000, f"检索失败: {exc}") from exc + + result = ok(response.model_dump()) + await get_cache().set_json(cache_key, result) + return result diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..774d7ab --- /dev/null +++ b/app/config.py @@ -0,0 +1,60 @@ +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings): + """应用配置,通过环境变量注入""" + + # 应用 + app_name: str = "QMDSearch" + log_level: str = "info" + + # 嵌入模型 + embedding_provider: str = "openai" # openai | local + openai_api_key: str = "" + openai_base_url: str = "https://api.openai.com/v1" + embedding_model: str = "text-embedding-3-small" + embedding_dimension: int = 1536 + + # Ollama 本地模型(用于文档三级总结) + ollama_base_url: str = "http://localhost:11434" + ollama_model: str = "qwen2.5:1.5b" # 备选: qwen2.5:3b + ollama_embedding_model: str = "bge-m3" # embedding_provider=local 时使用的嵌入模型 + + # Qdrant + qdrant_host: str = "localhost" + qdrant_port: int = 6333 + + # Redis + redis_url: str = "redis://localhost:6379/0" + + # 检索参数 + retrieval_top_k: int = 20 # L2 语义检索召回数 + retrieval_final_k: int = 5 # L3 重排后返回数 + + # 文档入库参数 + summary_min_text_length: int = 500 # 低于此字符数触发 2.5 级回退 + + # 入库异步任务 + ingest_max_concurrency: int = 2 # 入库后台任务并发上限 + ingest_task_ttl_done: int = 86400 # 任务状态 Redis 保留秒数(进行中与已完成,24h) + ingest_task_ttl_failed: int = 604800 # 失败任务状态 Redis 保留秒数(7 天) + + # 知识分类(taxonomy) + taxonomy_path: str = "" # taxonomy JSON 文件路径,为空用内置默认 + classify_confidence_threshold: float = 0.6 # 低于此值归 uncategorized + classify_max_categories: int = 3 # query 路由命中类目数上限,超过走全库兜底 + + # 分层检索参数 + l1_doc_top_n: int = 10 # L1 层候选文档数 + l2_section_top_n: int = 5 # L2 层候选 section 数 + l3_top_n: int = 10 # L3 层定位数 + sparse_enabled: bool = True # 是否启用稀疏检索 + cache_ttl: int = 300 # Redis 缓存秒数 + + # 分块参数 + chunk_max_chars: int = 800 # chunk 超长二次切分阈值 + + model_config = {"env_prefix": "", "case_sensitive": False} + + +settings = Settings() diff --git a/app/core/__init__.py b/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/core/chunker.py b/app/core/chunker.py new file mode 100644 index 0000000..6e0cc9a --- /dev/null +++ b/app/core/chunker.py @@ -0,0 +1,121 @@ +"""文档 chunk 切分器 + +按文档原生标题树切分 chunk: +- 结构化文本:每个标题起点切分 section(标题行到下一个标题前), + 超长 section 按空行段落二次切分,单段落仍超长则硬切 +- 无结构文本:直接按空行段落累加切分 + +每个 chunk 记录 section_path(祖先标题链," / " 连接),与 L2 大纲节点互相定位。 +""" + +import re + +import structlog + +from app.config import settings +from app.core.headings import Heading, parse_headings +from app.models.document import ChunkModel + +logger = structlog.get_logger() + +# 段落分隔:一个或多个空行 +_PARAGRAPH_SPLIT_PATTERN = re.compile(r"\n\s*\n") + + +class Chunker: + """按标题树切分文档 chunk""" + + def __init__(self, max_chars: int = settings.chunk_max_chars) -> None: + self.max_chars = max_chars + + def chunk(self, text: str, doc_id: str) -> list[ChunkModel]: + """将文档文本切分为 chunk 列表 + + Args: + text: 文档纯文本内容 + doc_id: 文档 ID + + Returns: + list[ChunkModel]: 切分结果,chunk_index 从 0 递增 + """ + stripped = text.strip() + if not stripped: + return [] + + # 全文不超长:整篇单 chunk + if len(stripped) <= self.max_chars: + return [ChunkModel(doc_id=doc_id, chunk_index=0, text=stripped)] + + headings = parse_headings(stripped) + chunks: list[ChunkModel] = [] + + if headings: + # 结构化:先按标题切分 section,再按长度二次切分 + for section_text, section_path in self._split_sections(stripped, headings): + for piece in self._split_by_length(section_text): + chunks.append( + ChunkModel(doc_id=doc_id, chunk_index=len(chunks), text=piece, section_path=section_path) + ) + else: + # 无结构:直接按段落累加切分 + for piece in self._split_by_length(stripped): + chunks.append(ChunkModel(doc_id=doc_id, chunk_index=len(chunks), text=piece)) + + logger.info("文档切分完成", doc_id=doc_id, chunks_count=len(chunks), has_headings=bool(headings)) + return chunks + + def _split_sections(self, text: str, headings: list[Heading]) -> list[tuple[str, str]]: + """按标题树切分 section,返回 (section 文本, section_path) 列表 + + 每个标题起点切分一个 section,section 文本含标题行本身; + section_path 为祖先标题链(含自身标题),用 " / " 连接; + 首个标题前的引导正文归入无前缀 section(section_path 为空)。 + """ + lines = text.splitlines() + sections: list[tuple[str, str]] = [] + + # 首个标题前的引导内容 + preamble = "\n".join(lines[: headings[0].line_index]).strip() + if preamble: + sections.append((preamble, "")) + + # 维护祖先标题栈:遇到同级或更高级标题时弹栈 + stack: list[Heading] = [] + for i, heading in enumerate(headings): + while stack and stack[-1].level >= heading.level: + stack.pop() + stack.append(heading) + + end = headings[i + 1].line_index if i + 1 < len(headings) else len(lines) + section_text = "\n".join(lines[heading.line_index : end]).strip() + section_path = " / ".join(h.title for h in stack) + sections.append((section_text, section_path)) + + return sections + + def _split_by_length(self, text: str) -> list[str]: + """按 max_chars 切分文本:先按空行段落累加,单段落超长则硬切""" + if len(text) <= self.max_chars: + return [text] + + pieces: list[str] = [] + current = "" + for paragraph in _PARAGRAPH_SPLIT_PATTERN.split(text): + paragraph = paragraph.strip() + if not paragraph: + continue + candidate = f"{current}\n\n{paragraph}" if current else paragraph + if len(candidate) <= self.max_chars: + current = candidate + continue + if current: + pieces.append(current) + current = "" + # 单段落仍超长:按 max_chars 硬切 + if len(paragraph) > self.max_chars: + pieces.extend(paragraph[i : i + self.max_chars] for i in range(0, len(paragraph), self.max_chars)) + else: + current = paragraph + if current: + pieces.append(current) + return pieces diff --git a/app/core/classifier.py b/app/core/classifier.py new file mode 100644 index 0000000..a15972e --- /dev/null +++ b/app/core/classifier.py @@ -0,0 +1,139 @@ +"""文档分类器 + +入库链路第二步:基于 L1 总结,用 Ollama 小模型将文档判定为 taxonomy 中的 +主类目 + 附加标签: +- LLM 输出解析失败 / 类目名不在 taxonomy → 归 uncategorized(confidence=0.0) +- 置信度低于阈值 → 主类目归 uncategorized,候选类目名保留进 tags(软召回用) +""" + +import json +import re + +import structlog + +from app.config import settings +from app.models.knowledge import UNCATEGORIZED, CategoryResult, TaxonomyCategory, load_taxonomy +from app.services.ollama import OllamaClient + +logger = structlog.get_logger() + +# 从 LLM 输出中提取第一个 {...} JSON 块(贪婪匹配到最后的 },兼容嵌套对象) +_JSON_BLOCK_RE = re.compile(r"\{.*\}", re.DOTALL) + + +def _extract_json(raw: str) -> dict | None: + """从 LLM 输出中提取 JSON 对象 + + 先尝试直接解析;失败则用正则提取第一个 {...} 块再解析。 + 返回 None 表示无法提取出合法的 JSON 对象。 + """ + text = raw.strip() + try: + data = json.loads(text) + return data if isinstance(data, dict) else None + except json.JSONDecodeError: + pass + + match = _JSON_BLOCK_RE.search(text) + if not match: + return None + try: + data = json.loads(match.group(0)) + return data if isinstance(data, dict) else None + except json.JSONDecodeError: + return None + + +class Classifier: + """文档分类器:将 L1 总结判定为 taxonomy 主类目 + 附加标签""" + + def __init__(self, ollama: OllamaClient | None = None, taxonomy: list[TaxonomyCategory] | None = None) -> None: + self.ollama = ollama or OllamaClient() + self.taxonomy = taxonomy if taxonomy is not None else load_taxonomy() + # 合法类目名集合(含 uncategorized) + self._valid_names = {c.name for c in self.taxonomy} + + async def classify(self, l1_summary: str, title: str = "") -> CategoryResult: + """对文档进行分类判定 + + Args: + l1_summary: 文档 L1 总结 + title: 文档标题(可选,辅助判定) + + Returns: + CategoryResult: 主类目 / 附加标签 / 置信度 + """ + prompt = self._build_prompt(l1_summary, title) + raw = await self.ollama.generate(prompt, json_mode=True) + + data = _extract_json(raw) + if data is None: + logger.warning("分类失败:LLM 输出非合法 JSON", title=title, output=raw[:200]) + return CategoryResult(main_category=UNCATEGORIZED, tags=[], confidence=0.0) + + main_category = data.get("main_category") + confidence = data.get("confidence") + if ( + not isinstance(main_category, str) + or main_category not in self._valid_names + or not isinstance(confidence, (int, float)) + or not 0 <= confidence <= 1 + ): + logger.warning( + "分类失败:类目名不在 taxonomy 或 confidence 非法", + title=title, + main_category=main_category, + confidence=confidence, + ) + return CategoryResult(main_category=UNCATEGORIZED, tags=[], confidence=0.0) + + tags = self._clean_tags(data.get("tags"), main_category) + confidence = float(confidence) + + # 低置信度软召回:主类目归 uncategorized,候选类目名保留进 tags + if confidence < settings.classify_confidence_threshold: + logger.info( + "分类置信度低于阈值,归入 uncategorized", + title=title, + candidate=main_category, + confidence=confidence, + threshold=settings.classify_confidence_threshold, + ) + if main_category != UNCATEGORIZED and main_category not in tags: + tags.insert(0, main_category) + return CategoryResult(main_category=UNCATEGORIZED, tags=tags, confidence=confidence) + + return CategoryResult(main_category=main_category, tags=tags, confidence=confidence) + + def _build_prompt(self, l1_summary: str, title: str) -> str: + """构造分类 prompt:列出全部 taxonomy 类目(含 uncategorized 及其用途说明)""" + category_lines = [] + for c in self.taxonomy: + if c.name == UNCATEGORIZED: + category_lines.append(f"- {c.name}: 当文档跨多个类目或无法明确归入其他类目时选择此类目") + else: + category_lines.append(f"- {c.name}: {c.description}") + category_block = "\n".join(category_lines) + + prompt = ( + "你是知识库分类助手。请根据文档标题和总结,判断文档最适合归入以下哪个类目。\n\n" + f"可选类目:\n{category_block}\n\n" + "要求:\n" + "- main_category 只能从上面的类目名中选择,不要输出其他名称;\n" + "- tags 为 0~3 个附加标签(词或短语),且不能包含 main_category 本身;\n" + "- confidence 为 0~1 之间的小数,表示对主类目判断的置信度;\n" + "- 只输出 JSON,不要输出任何其他内容。\n\n" + '输出格式:{"main_category": "类目名", "tags": ["标签"], "confidence": 0.0}\n\n' + ) + if title: + prompt += f"文档标题:{title}\n" + prompt += f"文档总结:{l1_summary}" + return prompt + + @staticmethod + def _clean_tags(raw_tags: object, main_category: str) -> list[str]: + """清洗 LLM 输出的 tags:仅保留非空字符串、剔除主类目名、最多 3 个""" + if not isinstance(raw_tags, list): + return [] + tags = [t for t in raw_tags if isinstance(t, str) and t and t != main_category] + return tags[:3] diff --git a/app/core/embeddings.py b/app/core/embeddings.py new file mode 100644 index 0000000..8c33222 --- /dev/null +++ b/app/core/embeddings.py @@ -0,0 +1,99 @@ +"""Dense 向量嵌入服务 + +提供统一的 EmbeddingService 接口,支持两种 provider: +- openai:OpenAI 兼容 API(AsyncOpenAI) +- local:本地 Ollama /api/embed 接口 +""" + +from typing import Protocol, runtime_checkable + +import httpx +import structlog +from openai import AsyncOpenAI + +from app.config import settings + +logger = structlog.get_logger() + + +@runtime_checkable +class EmbeddingService(Protocol): + """统一嵌入服务接口""" + + async def embed(self, texts: list[str]) -> list[list[float]]: + """批量生成文本向量 + + Args: + texts: 待嵌入文本列表,空列表时直接返回空列表 + + Returns: + 与输入等长的向量列表 + """ + ... + + +def _check_dimension(vectors: list[list[float]], provider: str) -> None: + """返回维度与配置不一致时告警(不抛错),每次调用最多提示一次""" + for vec in vectors: + if len(vec) != settings.embedding_dimension: + logger.warning( + "嵌入向量维度与配置不一致", + provider=provider, + actual=len(vec), + expected=settings.embedding_dimension, + ) + return + + +class OpenAIEmbeddingService: + """OpenAI 兼容 API 嵌入服务""" + + def __init__(self, api_key: str, base_url: str, model: str) -> None: + self._client = AsyncOpenAI(api_key=api_key, base_url=base_url) + self._model = model + + async def embed(self, texts: list[str]) -> list[list[float]]: + if not texts: + return [] + resp = await self._client.embeddings.create(model=self._model, input=texts) + vectors = [item.embedding for item in resp.data] + _check_dimension(vectors, provider="openai") + logger.debug("OpenAI 嵌入完成", model=self._model, count=len(vectors)) + return vectors + + +class LocalEmbeddingService: + """本地 Ollama 嵌入服务(/api/embed)""" + + def __init__(self, base_url: str, model: str, timeout: float = 60.0) -> None: + self.base_url = base_url.rstrip("/") + self.model = model + self.timeout = timeout + + async def embed(self, texts: list[str]) -> list[list[float]]: + if not texts: + return [] + url = f"{self.base_url}/api/embed" + payload = {"model": self.model, "input": texts} + async with httpx.AsyncClient(timeout=self.timeout) as client: + resp = await client.post(url, json=payload) + resp.raise_for_status() + data = resp.json() + vectors: list[list[float]] = data.get("embeddings", []) + _check_dimension(vectors, provider="local") + logger.debug("Ollama 嵌入完成", model=self.model, count=len(vectors)) + return vectors + + +def create_embedding_service() -> EmbeddingService: + """按 settings.embedding_provider 创建嵌入服务实例""" + if settings.embedding_provider == "local": + return LocalEmbeddingService( + base_url=settings.ollama_base_url, + model=settings.ollama_embedding_model, + ) + return OpenAIEmbeddingService( + api_key=settings.openai_api_key, + base_url=settings.openai_base_url, + model=settings.embedding_model, + ) diff --git a/app/core/headings.py b/app/core/headings.py new file mode 100644 index 0000000..8d8e48c --- /dev/null +++ b/app/core/headings.py @@ -0,0 +1,94 @@ +"""标题树解析器 + +从纯文本中解析文档原生标题结构,支持两类模式: +- Markdown ATX 标题(# ~ ######,# 数量即层级) +- 中文编号标题(第X章/节/篇、一、1.1 等编号,行长度不超过 60 字符) + +解析结果用于 L2 大纲生成与 chunk 的 section 切分。 +""" + +import re + +from pydantic import BaseModel, Field + + +class Heading(BaseModel): + """文档标题节点""" + + title: str = Field(description="标题文本") + level: int = Field(description="标题层级,1 为最顶层") + line_index: int = Field(description="标题所在行号(从 0 开始)") + + +# Markdown ATX 标题:1~6 个 # 后跟空白 +_ATX_PATTERN = re.compile(r"^(#{1,6})\s+(.+?)\s*$") + +# 中文篇章节编号:第X章/第X节/第X篇(章/篇=1 级,节=2 级) +_CN_CHAPTER_PATTERN = re.compile(r"^第[一二三四五六七八九十百\d]+([章节篇])") + +# 中文序号:一、二、……,固定 1 级 +_CN_ENUM_PATTERN = re.compile(r"^[一二三四五六七八九十]+、") + +# 数字编号:1. / 1、/ 1.1 / 1.1.1 等,按点分段数定层级 +_NUM_PATTERN = re.compile(r"^(\d+(?:\.\d+)*)[、.\s]") + +# 编号类标题行的最大长度,超过则视为正文 +_MAX_HEADING_LINE_LENGTH = 60 + +# 第X[章节篇] 后缀对应的层级 +_CN_CHAPTER_LEVELS = {"章": 1, "节": 2, "篇": 1} + + +def parse_headings(text: str) -> list[Heading]: + """解析文本中的标题,按行号升序返回 + + Args: + text: 文档纯文本内容 + + Returns: + list[Heading]: 标题列表,无标题时返回空列表 + """ + headings: list[Heading] = [] + for line_index, line in enumerate(text.splitlines()): + heading = _match_heading(line, line_index) + if heading is not None: + headings.append(heading) + return headings + + +def render_outline(headings: list[Heading]) -> str: + """将标题树渲染为大纲文本,每行一个节点,按层级缩进""" + return "\n".join(f"{' ' * (h.level - 1)}- {h.title}" for h in headings) + + +def _match_heading(line: str, line_index: int) -> Heading | None: + """匹配单行是否为标题,是则返回 Heading,否则返回 None""" + stripped = line.strip() + if not stripped: + return None + + # Markdown ATX 标题(无长度限制) + match = _ATX_PATTERN.match(stripped) + if match: + return Heading(title=match.group(2), level=len(match.group(1)), line_index=line_index) + + # 编号类标题有行长度限制,过长视为正文 + if len(stripped) > _MAX_HEADING_LINE_LENGTH: + return None + + # 第X章/节/篇 + match = _CN_CHAPTER_PATTERN.match(stripped) + if match: + return Heading(title=stripped, level=_CN_CHAPTER_LEVELS[match.group(1)], line_index=line_index) + + # 一、二、…… + if _CN_ENUM_PATTERN.match(stripped): + return Heading(title=stripped, level=1, line_index=line_index) + + # 数字编号,层级 = 点分段数(1.=1、1.1=2、1.1.1=3) + match = _NUM_PATTERN.match(stripped) + if match: + level = match.group(1).count(".") + 1 + return Heading(title=stripped, level=level, line_index=line_index) + + return None diff --git a/app/core/ingest_tasks.py b/app/core/ingest_tasks.py new file mode 100644 index 0000000..73a837a --- /dev/null +++ b/app/core/ingest_tasks.py @@ -0,0 +1,172 @@ +"""入库异步任务管理器 + +将文档入库包装为后台异步任务:submit 登记任务并立即返回 task_id, +后台受并发上限控制执行 Ingester.ingest,并按阶段推进任务状态。 + +内存注册表为主(记录 status/created_at/updated_at/result/error), +Redis 为持久镜像(key: ingest_task:{task_id}),每次状态迁移同步写入; +Redis 不可用或写入失败仅记录 warning,不影响任务执行。 +""" + +import asyncio +import uuid +from datetime import UTC, datetime +from enum import StrEnum +from typing import Any + +import structlog + +from app.config import Settings +from app.core.ingestion import Ingester, IngestionError +from app.models.document import DocumentInput +from app.services.redis import RedisCache + +logger = structlog.get_logger() + +# Redis 任务状态 key 前缀 +REDIS_KEY_PREFIX = "ingest_task:" + + +class IngestTaskStatus(StrEnum): + """入库任务状态""" + + PENDING = "pending" # 已登记,排队等待执行 + SUMMARIZING = "summarizing" # 三级总结中 + CLASSIFYING = "classifying" # 分类判定中 + EMBEDDING = "embedding" # 向量化中 + WRITING = "writing" # 写入 Qdrant 中 + DONE = "done" # 入库完成 + FAILED = "failed" # 入库失败 + + +# 终态集合 +TERMINAL_STATUSES: frozenset[str] = frozenset({IngestTaskStatus.DONE, IngestTaskStatus.FAILED}) + + +def _utc_now_iso() -> str: + """当前 UTC 时间的 ISO8601 字符串""" + return datetime.now(UTC).isoformat() + + +class IngestTaskManager: + """入库异步任务管理器:登记、后台执行、状态查询与 Redis 持久镜像""" + + def __init__(self, ingester: Ingester, redis: RedisCache | None, settings: Settings) -> None: + self._ingester = ingester + self._redis = redis + self._settings = settings + self._tasks: dict[str, dict[str, Any]] = {} + self._semaphore = asyncio.Semaphore(settings.ingest_max_concurrency) + # 持有后台任务与镜像任务引用,避免被 GC 提前回收 + self._background_tasks: set[asyncio.Task[None]] = set() + self._mirror_tasks: set[asyncio.Task[None]] = set() + + async def submit(self, doc: DocumentInput) -> str: + """登记入库任务并后台执行,立即返回 task_id""" + task_id = uuid.uuid4().hex + now = _utc_now_iso() + self._tasks[task_id] = { + "task_id": task_id, + "status": IngestTaskStatus.PENDING, + "created_at": now, + "updated_at": now, + "result": None, + "error": None, + } + self._schedule_mirror(task_id) + background = asyncio.create_task(self._run(task_id, doc)) + self._background_tasks.add(background) + background.add_done_callback(self._background_tasks.discard) + logger.info("入库任务已登记", task_id=task_id, title=doc.title) + return task_id + + async def get(self, task_id: str) -> dict[str, Any] | None: + """查询任务状态:先查内存注册表,miss 再查 Redis 镜像,都没有返回 None""" + record = self._tasks.get(task_id) + if record is not None: + return record + if self._redis is None: + return None + try: + return await self._redis.get_json(f"{REDIS_KEY_PREFIX}{task_id}") + except Exception: + logger.warning("入库任务状态读取 Redis 失败,降级为未命中", task_id=task_id, exc_info=True) + return None + + async def wait_done(self, task_id: str, timeout: float = 30.0) -> dict[str, Any]: + """轮询内存注册表直到任务进入终态(done/failed)或超时 + + 进入终态后会等待已调度的 Redis 镜像写完再返回;超时抛 TimeoutError。 + """ + loop = asyncio.get_running_loop() + deadline = loop.time() + timeout + while True: + record = self._tasks.get(task_id) + if record is not None and record["status"] in TERMINAL_STATUSES: + if self._mirror_tasks: + await asyncio.gather(*self._mirror_tasks, return_exceptions=True) + return record + if loop.time() >= deadline: + raise TimeoutError(f"入库任务 {task_id} 在 {timeout}s 内未进入终态") + await asyncio.sleep(0.01) + + async def _run(self, task_id: str, doc: DocumentInput) -> None: + """后台执行入库:并发限流 + 阶段状态推进 + 结果/错误落账""" + async with self._semaphore: + try: + result = await self._ingester.ingest(doc, progress_cb=lambda stage: self._on_progress(task_id, stage)) + except IngestionError as exc: + # 入库已知失败:透传阶段与已产出的部分总结 + self._finish_failed( + task_id, + { + "stage": exc.stage, + "message": str(exc), + "partial_summary": exc.summary.model_dump(mode="json") if exc.summary is not None else None, + }, + ) + except Exception as exc: + self._finish_failed(task_id, {"stage": "unknown", "message": str(exc), "partial_summary": None}) + else: + self._tasks[task_id].update( + status=IngestTaskStatus.DONE, + updated_at=_utc_now_iso(), + result=result.model_dump(mode="json"), + ) + self._schedule_mirror(task_id) + logger.info("入库任务完成", task_id=task_id) + + def _finish_failed(self, task_id: str, error: dict[str, Any]) -> None: + """将任务置为 failed 并记录错误信息""" + self._tasks[task_id].update(status=IngestTaskStatus.FAILED, updated_at=_utc_now_iso(), error=error) + self._schedule_mirror(task_id) + logger.error("入库任务失败", task_id=task_id, stage=error["stage"], error=error["message"]) + + def _on_progress(self, task_id: str, stage: str) -> None: + """Ingester 阶段回调:推进任务状态并同步镜像(同步函数,供 progress_cb 使用)""" + record = self._tasks.get(task_id) + if record is None: + return + record["status"] = stage + record["updated_at"] = _utc_now_iso() + self._schedule_mirror(task_id) + + def _schedule_mirror(self, task_id: str) -> None: + """将当前任务状态快照异步镜像到 Redis(同步上下文也可调用)""" + if self._redis is None: + return + mirror = asyncio.create_task(self._mirror_to_redis(task_id, dict(self._tasks[task_id]))) + self._mirror_tasks.add(mirror) + mirror.add_done_callback(self._mirror_tasks.discard) + + async def _mirror_to_redis(self, task_id: str, snapshot: dict[str, Any]) -> None: + """写入 Redis 镜像:进行中与 done 用 ttl_done,failed 用 ttl_failed;写失败仅告警""" + ttl = ( + self._settings.ingest_task_ttl_failed + if snapshot["status"] == IngestTaskStatus.FAILED + else self._settings.ingest_task_ttl_done + ) + try: + await self._redis.set_json(f"{REDIS_KEY_PREFIX}{task_id}", snapshot, ttl=ttl) + except Exception: + logger.warning("入库任务状态镜像 Redis 失败", task_id=task_id, exc_info=True) diff --git a/app/core/ingestion.py b/app/core/ingestion.py new file mode 100644 index 0000000..ca6a762 --- /dev/null +++ b/app/core/ingestion.py @@ -0,0 +1,306 @@ +"""文档入库模块 + +入库流程:文档输入 → 三级总结(Ollama) → 分类判定(L1总结) → 切分 chunk +→ 构建 L2/L3 大纲节点 → 批量向量化(dense + sparse)→ 写入 Qdrant 四层集合 + +L2/L3 大纲节点的构建策略见 _build_l2_nodes / _build_l3_nodes。 +""" + +import uuid +from collections.abc import Callable +from typing import Any + +import structlog + +from app.config import settings +from app.core.chunker import Chunker +from app.core.classifier import Classifier +from app.core.embeddings import EmbeddingService, create_embedding_service +from app.core.headings import Heading, parse_headings +from app.core.sparse import SparseEncoder +from app.core.summarizer import Summarizer +from app.models.document import ChunkModel, DocumentInput, DocumentSummary, IngestionResult, SummaryLevel +from app.models.knowledge import CategoryResult +from app.services.qdrant import COLLECTION_L2, COLLECTION_L3, QdrantService, SparseVectorTuple + +logger = structlog.get_logger() + +# IngestionError 阶段标识 +STAGE_SUMMARIZE = "summarize" +STAGE_CLASSIFY = "classify" +STAGE_EMBED = "embed" +STAGE_QDRANT = "qdrant" + + +class IngestionError(Exception): + """入库失败异常 + + 携带失败阶段(stage)与已产出的总结(summary,如有), + Qdrant 写入失败时总结不丢,上层可按阶段重试。 + """ + + def __init__(self, stage: str, message: str, summary: DocumentSummary | None = None) -> None: + super().__init__(message) + self.stage = stage + self.summary = summary + + +def _heading_paths(headings: list[Heading]) -> list[tuple[str, str]]: + """按文档顺序计算每个标题的 (标题文本, 祖先标题链含自身),链用 " / " 连接""" + paths: list[tuple[str, str]] = [] + stack: list[Heading] = [] + for heading in headings: + # 遇到同级或更高级标题时弹栈,维护当前祖先链 + while stack and stack[-1].level >= heading.level: + stack.pop() + stack.append(heading) + paths.append((heading.title, " / ".join(h.title for h in stack))) + return paths + + +def _split_l3_blocks(outline: str) -> list[tuple[str, str]]: + """将内容大纲按 "## " 行分块,返回 (块标题, 块文本) 列表 + + 无 "## " 行时整块作为一个节点(块标题为空); + 首个 "## " 之前的引导内容直接忽略。 + """ + stripped = outline.strip() + if not stripped: + return [] + + blocks: list[tuple[str, list[str]]] = [] + for line in stripped.splitlines(): + if line.startswith("## "): + blocks.append((line[3:].strip(), [line])) + elif blocks: + blocks[-1][1].append(line) + if not blocks: + return [("", stripped)] + return [(title, "\n".join(lines).strip()) for title, lines in blocks] + + +class Ingester: + """文档入库器:编排总结、分类、切分、向量化与 Qdrant 写入全链路""" + + def __init__( + self, + summarizer: Summarizer | None = None, + classifier: Classifier | None = None, + chunker: Chunker | None = None, + embedding: EmbeddingService | None = None, + sparse: SparseEncoder | None = None, + qdrant: QdrantService | None = None, + ) -> None: + self.summarizer = summarizer or Summarizer() + self.classifier = classifier or Classifier() + self.chunker = chunker or Chunker() + self.embedding = embedding or create_embedding_service() + self.sparse = sparse or SparseEncoder() + self.qdrant = qdrant or QdrantService() + + async def ingest(self, doc: DocumentInput, progress_cb: Callable[[str], None] | None = None) -> IngestionResult: + """执行文档入库 + + Args: + doc: 文档输入(文本内容 + 元数据) + progress_cb: 可选的阶段进度回调(同步函数),在各阶段边界以 + "summarizing" / "classifying" / "embedding" / "writing" 调用 + + Returns: + IngestionResult: 入库结果 + + Raises: + IngestionError: 任一阶段失败时抛出,携带 stage 与已产出总结 + """ + + def _report(stage: str) -> None: + if progress_cb is not None: + progress_cb(stage) + + logger.info("开始文档入库", title=doc.title, text_length=len(doc.text)) + doc_id = uuid.uuid4().hex + + # 1. 三级总结 + _report("summarizing") + try: + summary = await self.summarizer.summarize(doc.text, title=doc.title) + except Exception as exc: + logger.error("入库失败:三级总结", stage=STAGE_SUMMARIZE, error=str(exc)) + raise IngestionError(STAGE_SUMMARIZE, f"三级总结失败: {exc}") from exc + logger.info("三级总结完成", doc_id=doc_id, level=summary.level.value) + + # 2. 分类判定(基于 L1 总结) + _report("classifying") + try: + category = await self.classifier.classify(summary.l1_summary, title=doc.title) + except Exception as exc: + logger.error("入库失败:分类判定", stage=STAGE_CLASSIFY, error=str(exc)) + raise IngestionError(STAGE_CLASSIFY, f"分类判定失败: {exc}", summary=summary) from exc + logger.info("分类判定完成", doc_id=doc_id, category=category.main_category, confidence=category.confidence) + + # 3. 切分 chunk 并构建 L2/L3 大纲节点((text, section_path) 列表) + chunks = self.chunker.chunk(doc.text, doc_id) + l2_nodes = self._build_l2_nodes(doc.text, summary) + l3_nodes = self._build_l3_nodes(doc.text, summary) + + # 4. 批量 embedding:L1 + L2 + L3 + chunks 一次调用,按序切片取向量 + texts = [ + summary.l1_summary, + *(node_text for node_text, _ in l2_nodes), + *(node_text for node_text, _ in l3_nodes), + *(c.text for c in chunks), + ] + try: + _report("embedding") + vectors = await self.embedding.embed(texts) + except Exception as exc: + logger.error("入库失败:向量化", stage=STAGE_EMBED, error=str(exc)) + raise IngestionError(STAGE_EMBED, f"向量化失败: {exc}", summary=summary) from exc + l1_vector = vectors[0] + l2_vectors = vectors[1 : 1 + len(l2_nodes)] + l3_vectors = vectors[1 + len(l2_nodes) : 1 + len(l2_nodes) + len(l3_nodes)] + chunk_vectors = vectors[1 + len(l2_nodes) + len(l3_nodes) :] + + # 5. sparse 向量(仅 L1 与 chunks 需要) + l1_sparse: SparseVectorTuple | None = None + chunk_sparses: list[SparseVectorTuple | None] = [None] * len(chunks) + if settings.sparse_enabled: + l1_sparse = self.sparse.encode(summary.l1_summary) + chunk_sparses = [self.sparse.encode(c.text) for c in chunks] + + # 6. 写入 Qdrant 四层集合 + _report("writing") + try: + await self._write_qdrant( + doc_id, + doc, + summary, + category, + chunks, + l2_nodes, + l3_nodes, + l1_vector, + l2_vectors, + l3_vectors, + chunk_vectors, + l1_sparse, + chunk_sparses, + ) + except Exception as exc: + logger.error("入库失败:Qdrant 写入", stage=STAGE_QDRANT, doc_id=doc_id, error=str(exc)) + raise IngestionError(STAGE_QDRANT, f"Qdrant 写入失败: {exc}", summary=summary) from exc + + logger.info( + "文档入库完成", + doc_id=doc_id, + chunks_count=len(chunks), + l2_nodes=len(l2_nodes), + l3_nodes=len(l3_nodes), + category=category.main_category, + ) + return IngestionResult( + document_id=doc_id, + summary=summary, + category=category.main_category, + tags=category.tags, + category_confidence=category.confidence, + collection="四层集合", + chunks_count=len(chunks), + ) + + def _build_l2_nodes(self, text: str, summary: DocumentSummary) -> list[tuple[str, str]]: + """构建 L2 大纲节点,返回 (text, section_path) 列表 + + - 有标题结构(L3 级且标题数 >= 2):每个标题一个节点, + text 与 section_path 均为该节点的祖先标题链 + - 否则若 l2_outline 非空(LLM 生成的大纲):按非空行拆节点,section_path 为空 + - 2.5 级文档(l2_outline 为 None):无 L2 节点 + """ + headings = parse_headings(text) + if summary.level == SummaryLevel.L3 and len(headings) >= 2: + return [(path, path) for _, path in _heading_paths(headings)] + if summary.l2_outline: + return [(line.strip(), "") for line in summary.l2_outline.splitlines() if line.strip()] + return [] + + def _build_l3_nodes(self, text: str, summary: DocumentSummary) -> list[tuple[str, str]]: + """构建 L3 内容大纲节点,返回 (text, section_path) 列表 + + 按 "## " 分块(无 "## " 则整块一个节点); + section_path 尽力匹配文档标题链(块标题与文档标题文本精确匹配),匹配不到用 ""。 + """ + path_by_title: dict[str, str] = {} + for title, path in _heading_paths(parse_headings(text)): + path_by_title.setdefault(title, path) + return [ + (block_text, path_by_title.get(block_title, "")) + for block_title, block_text in _split_l3_blocks(summary.l3_content_outline) + ] + + async def _write_qdrant( + self, + doc_id: str, + doc: DocumentInput, + summary: DocumentSummary, + category: CategoryResult, + chunks: list[ChunkModel], + l2_nodes: list[tuple[str, str]], + l3_nodes: list[tuple[str, str]], + l1_vector: list[float], + l2_vectors: list[list[float]], + l3_vectors: list[list[float]], + chunk_vectors: list[list[float]], + l1_sparse: SparseVectorTuple | None, + chunk_sparses: list[SparseVectorTuple | None], + ) -> None: + """将 L1/L2/L3/chunks 四层数据写入 Qdrant(任一失败向上抛出)""" + await self.qdrant.upsert_l1( + doc_id=doc_id, + title=doc.title, + summary=summary.l1_summary, + category=category.main_category, + tags=category.tags, + dense_vector=l1_vector, + sparse_vector=l1_sparse, + ) + + # L2/L3 大纲节点(为空时跳过对应集合的 upsert) + for collection, nodes, vectors in ( + (COLLECTION_L2, l2_nodes, l2_vectors), + (COLLECTION_L3, l3_nodes, l3_vectors), + ): + if not nodes: + continue + await self.qdrant.upsert_nodes( + collection, + [ + { + "doc_id": doc_id, + "section_path": section_path, + "text": node_text, + "category": category.main_category, + "tags": category.tags, + "dense_vector": vector, + } + for (node_text, section_path), vector in zip(nodes, vectors, strict=True) + ], + ) + + if chunks: + # chunk dict 额外携带 doc_summary(= L1 总结),检索侧直接取用,不用回查 L1 + chunk_dicts: list[dict[str, Any]] = [ + { + "doc_id": doc_id, + "chunk_index": chunk.chunk_index, + "text": chunk.text, + "section_path": chunk.section_path, + "title": doc.title, + "category": category.main_category, + "tags": category.tags, + "dense_vector": vector, + "sparse_vector": sparse, + "doc_summary": summary.l1_summary, + } + for chunk, vector, sparse in zip(chunks, chunk_vectors, chunk_sparses, strict=True) + ] + await self.qdrant.upsert_chunks(chunk_dicts) diff --git a/app/core/query_parser.py b/app/core/query_parser.py new file mode 100644 index 0000000..5647ae1 --- /dev/null +++ b/app/core/query_parser.py @@ -0,0 +1,214 @@ +"""query 解析与分类路由模块 + +分层 RAG 在线侧第一步:用 Ollama 小模型将用户 query 解析为结构化 JSON +(命中类目+置信度、rewrite 后 query、关键词),再由纯函数做路由决策: +- 高置信且命中类目数 <= 上限 → 按类目过滤检索 +- 低置信 / 解析失败 / 命中类目过多 → 全库兜底(不丢召回) + +解析(LLM 调用)与决策(纯函数)分离,便于单元测试。 +""" + +import json +import re +from hashlib import sha256 + +import structlog +from pydantic import BaseModel, Field, ValidationError + +from app.config import settings +from app.models.knowledge import UNCATEGORIZED, TaxonomyCategory +from app.services.ollama import OllamaClient +from app.services.redis import RedisCache, get_cache + +logger = structlog.get_logger() + +# 从 LLM 输出中提取第一个 {...} JSON 块(贪婪匹配到最后的 },兼容嵌套对象) +_JSON_BLOCK_RE = re.compile(r"\{.*\}", re.DOTALL) + + +class CategoryHit(BaseModel): + """query 命中的类目及置信度""" + + name: str = Field(description="类目名称,必须来自 taxonomy") + confidence: float = Field(ge=0, le=1, description="命中置信度,范围 [0, 1]") + + +class ParsedQuery(BaseModel): + """query 解析结果""" + + raw_query: str = Field(description="原始 query") + rewrite: str = Field(description="rewrite 后的 query") + keywords: list[str] = Field(default_factory=list, description="提取的关键词") + categories: list[CategoryHit] = Field(default_factory=list, description="命中类目列表") + parse_failed: bool = Field(default=False, description="LLM 输出解析是否失败") + + +class RouteDecision(BaseModel): + """路由决策结果""" + + fallback: bool = Field(description="是否走全库兜底") + filter_categories: list[str] | None = Field(default=None, description="过滤类目名列表,None 表示全库不过滤") + reason: str = Field(description="决策原因:parse_failed | low_confidence | too_many_categories | routed") + parsed: ParsedQuery = Field(description="对应的 query 解析结果") + + +def _extract_json(raw: str) -> dict | None: + """从 LLM 输出中提取 JSON 对象 + + 先尝试直接解析;失败则用正则提取第一个 {...} 块再解析。 + 返回 None 表示无法提取出合法的 JSON 对象。 + """ + text = raw.strip() + try: + data = json.loads(text) + return data if isinstance(data, dict) else None + except json.JSONDecodeError: + pass + + match = _JSON_BLOCK_RE.search(text) + if not match: + return None + try: + data = json.loads(match.group(0)) + return data if isinstance(data, dict) else None + except json.JSONDecodeError: + return None + + +def decide_route(parsed: ParsedQuery, threshold: float, max_categories: int) -> RouteDecision: + """路由决策(纯函数) + + - 解析失败 → 全库兜底(parse_failed) + - 无 confidence >= threshold 的类目 → 全库兜底(low_confidence) + - 命中类目数 > max_categories → 全库兜底(too_many_categories) + - 否则按类目过滤检索,类目按 confidence 降序排列(routed) + """ + if parsed.parse_failed: + return RouteDecision(fallback=True, filter_categories=None, reason="parse_failed", parsed=parsed) + + hits = [c for c in parsed.categories if c.confidence >= threshold] + if not hits: + return RouteDecision(fallback=True, filter_categories=None, reason="low_confidence", parsed=parsed) + + if len(hits) > max_categories: + return RouteDecision(fallback=True, filter_categories=None, reason="too_many_categories", parsed=parsed) + + hits.sort(key=lambda c: c.confidence, reverse=True) + return RouteDecision( + fallback=False, + filter_categories=[c.name for c in hits], + reason="routed", + parsed=parsed, + ) + + +class QueryParser: + """query 解析器:调用 Ollama 小模型将 query 解析为结构化 JSON""" + + def __init__( + self, ollama: OllamaClient, taxonomy: list[TaxonomyCategory], cache: RedisCache | None = None + ) -> None: + self.ollama = ollama + self.taxonomy = taxonomy + # 解析结果缓存,缺省用全局单例;RedisCache 全操作容错,缓存不可用时退化为无缓存行为 + self.cache = cache if cache is not None else get_cache() + # 可作为路由命中类目的名字集合(uncategorized 不可作为路由命中类目) + self._routable_names = {c.name for c in taxonomy if c.name != UNCATEGORIZED} + + async def parse(self, query: str) -> ParsedQuery: + """调用 LLM 将 query 解析为结构化结果 + + 解析失败(输出非 JSON / 必填字段缺失或类型错误)时, + 返回 parse_failed=True 的兜底结果(rewrite 为原 query)。 + """ + prompt = self._build_prompt(query) + raw = await self.ollama.generate(prompt, json_mode=True) + + data = _extract_json(raw) + if data is None: + logger.warning("query 解析失败:LLM 输出非合法 JSON", query=query, output=raw[:200]) + return ParsedQuery(raw_query=query, rewrite=query, parse_failed=True) + + rewrite = data.get("rewrite") + keywords = data.get("keywords") + categories = data.get("categories") + if not isinstance(rewrite, str) or not isinstance(keywords, list) or not isinstance(categories, list): + logger.warning("query 解析失败:JSON 必填字段缺失或类型错误", query=query, output=raw[:200]) + return ParsedQuery(raw_query=query, rewrite=query, parse_failed=True) + + return ParsedQuery( + raw_query=query, + rewrite=rewrite, + keywords=[str(k) for k in keywords], + categories=self._validate_categories(categories, query), + ) + + async def parse_and_route(self, query: str) -> RouteDecision: + """解析 query 并做路由决策(threshold / max_categories 取自 settings) + + parse() 的 LLM 解析结果按 query 哈希缓存;路由决策每次现算(纯函数, + 阈值取最新 settings),缓存数据损坏时回退为重新走 LLM 解析。 + """ + cache_key = f"qparse:{sha256(query.encode()).hexdigest()[:16]}" + parsed = await self._get_cached_parsed(cache_key, query) + if parsed is None: + parsed = await self.parse(query) + await self.cache.set_json(cache_key, parsed.model_dump()) + return decide_route( + parsed, + threshold=settings.classify_confidence_threshold, + max_categories=settings.classify_max_categories, + ) + + async def _get_cached_parsed(self, cache_key: str, query: str) -> ParsedQuery | None: + """读取缓存的解析结果;未命中或缓存数据无法重建时返回 None""" + cached = await self.cache.get_json(cache_key) + if cached is None: + return None + try: + parsed = ParsedQuery.model_validate(cached) + except ValidationError: + logger.warning("query 解析缓存数据损坏,按未命中处理", query=query, cache_key=cache_key) + return None + logger.info("query 解析缓存命中", query=query, cache_key=cache_key) + return parsed + + def _build_prompt(self, query: str) -> str: + """构造解析 prompt:列出 taxonomy 类目名+描述,要求模型只输出 JSON""" + category_lines = [f"- {c.name}: {c.description}" for c in self.taxonomy if c.name != UNCATEGORIZED] + category_block = "\n".join(category_lines) + return ( + "你是搜索查询分析助手。请分析用户 query,完成三件事:\n" + "1. 判断 query 意图命中以下哪些知识类目,并给出每个类目的置信度(0~1 之间的小数);\n" + "2. 将 query 改写为更适合检索的形式;\n" + "3. 提取 query 的关键词。\n\n" + f"可选类目:\n{category_block}\n\n" + "要求:\n" + "- categories 中的 name 只能从上面的类目名中选择,不要输出其他名称;\n" + "- 若没有明显命中的类目,categories 返回空列表;\n" + "- 只输出 JSON,不要输出任何其他内容。\n\n" + '输出格式:{"categories": [{"name": "类目名", "confidence": 0.0}], ' + '"rewrite": "改写后的 query", "keywords": ["关键词"]}\n\n' + f"用户 query:{query}" + ) + + def _validate_categories(self, categories: list, query: str) -> list[CategoryHit]: + """校验并过滤 LLM 输出的类目条目 + + 丢弃:类目名不在 taxonomy 可路由类目中的条目(含 uncategorized)、 + confidence 缺失或越界(不在 [0, 1])的条目。 + """ + hits: list[CategoryHit] = [] + for item in categories: + if not isinstance(item, dict): + continue + name = item.get("name") + confidence = item.get("confidence") + if not isinstance(name, str) or name not in self._routable_names: + logger.warning("丢弃不在 taxonomy 中的类目", query=query, name=name) + continue + if not isinstance(confidence, (int, float)) or not 0 <= confidence <= 1: + logger.warning("丢弃 confidence 越界的类目", query=query, name=name, confidence=confidence) + continue + hits.append(CategoryHit(name=name, confidence=float(confidence))) + return hits diff --git a/app/core/ranker.py b/app/core/ranker.py new file mode 100644 index 0000000..63d5eaf --- /dev/null +++ b/app/core/ranker.py @@ -0,0 +1,24 @@ +"""检索结果重排:RRF 融合与最终截断""" + +from qdrant_client import models + + +def rrf_fuse(result_lists: list[list[models.ScoredPoint]], k: int = 60) -> list[models.ScoredPoint]: + """标准 RRF(Reciprocal Rank Fusion)融合 + + 融合分 = Σ 1/(k + rank)(rank 从 1 开始),按 point id 去重合并, + 返回按融合分降序的列表,score 字段写回融合分。空输入返回 []。 + """ + scores: dict[str | int, float] = {} + points: dict[str | int, models.ScoredPoint] = {} + for results in result_lists: + for rank, point in enumerate(results, start=1): + scores[point.id] = scores.get(point.id, 0.0) + 1.0 / (k + rank) + points.setdefault(point.id, point) + ordered = sorted(points, key=lambda pid: scores[pid], reverse=True) + return [points[pid].model_copy(update={"score": scores[pid]}) for pid in ordered] + + +def finalize(points: list[models.ScoredPoint], final_k: int) -> list[models.ScoredPoint]: + """截断为最终返回的 top final_k""" + return points[:final_k] diff --git a/app/core/retriever.py b/app/core/retriever.py new file mode 100644 index 0000000..58d7e00 --- /dev/null +++ b/app/core/retriever.py @@ -0,0 +1,169 @@ +"""分层检索引擎 + +L1(文档总结)→ L2(章节大纲)→ L3(小节定位)→ chunks(原文)逐层收窄: +- L1 无候选文档:直接全库 chunk 兜底,fallback=True +- L2 无命中:全部候选文档回退到 L3 的 doc 级查询(b 路) +- 2.5 级文档无 L2 节点,天然落入 L3 b 路(仅按 doc 过滤) +- L3 两路(a:L2 命中文档按 section 过滤;b:其余文档仅按 doc 过滤)RRF 融合; + L3 无命中时 chunk 层回退为 L1 候选文档级检索 +""" + +from collections.abc import Iterable + +import structlog +from qdrant_client import models + +from app.config import settings +from app.core.embeddings import EmbeddingService, create_embedding_service +from app.core.query_parser import QueryParser +from app.core.ranker import finalize, rrf_fuse +from app.core.sparse import SparseEncoder +from app.models.knowledge import load_taxonomy +from app.models.search import SearchHit, SearchRequest, SearchResponse +from app.services.ollama import OllamaClient +from app.services.qdrant import ( + COLLECTION_CHUNKS, + COLLECTION_L1, + COLLECTION_L2, + COLLECTION_L3, + SPARSE_COLLECTIONS, + QdrantService, +) + +logger = structlog.get_logger() + + +def _unique(values: Iterable[str | None]) -> list[str]: + """去重保序并丢弃空值""" + return list(dict.fromkeys(v for v in values if v)) + + +class Retriever: + """分层检索引擎,依赖均可注入(默认自建,便于测试替换)""" + + def __init__( + self, + qdrant: QdrantService | None = None, + query_parser: QueryParser | None = None, + embedding: EmbeddingService | None = None, + sparse_encoder: SparseEncoder | None = None, + ) -> None: + self.qdrant = qdrant or QdrantService() + self.query_parser = query_parser or QueryParser( + ollama=OllamaClient(), + taxonomy=load_taxonomy(settings.taxonomy_path), + ) + self.embedding = embedding or create_embedding_service() + self.sparse_encoder = sparse_encoder or SparseEncoder() + + async def search(self, request: SearchRequest) -> SearchResponse: + """分层检索主流程""" + route = await self.query_parser.parse_and_route(request.query) + query_text = route.parsed.rewrite or request.query + dense = (await self.embedding.embed([query_text]))[0] + sparse = self.sparse_encoder.encode(query_text) if settings.sparse_enabled else None + # 路由兜底时不做类目过滤,避免丢召回 + categories = None if route.fallback else route.filter_categories + + # L1:文档级检索,产出候选文档 + l1_filter = QdrantService.build_filter(categories=categories) + l1_hits = await self._search_collection(COLLECTION_L1, dense, sparse, settings.l1_doc_top_n, l1_filter) + logger.info("L1 检索完成", hits=len(l1_hits), categories=categories) + + if not l1_hits: + # L1 无候选文档 → 全库 chunk 兜底 + chunk_hits = await self._search_collection(COLLECTION_CHUNKS, dense, sparse, settings.retrieval_top_k, None) + logger.info("L1 无命中,全库 chunk 兜底", hits=len(chunk_hits)) + return SearchResponse( + query=request.query, + hits=self._to_hits(finalize(chunk_hits, self._final_k(request))), + routed_categories=route.filter_categories or [], + fallback=True, + ) + + doc_ids = _unique((p.payload or {}).get("doc_id") for p in l1_hits) + + # L2:候选文档内检索章节大纲 + l2_filter = QdrantService.build_filter(categories=categories, doc_ids=doc_ids) + l2_hits = await self._search_collection( + COLLECTION_L2, dense, sparse, settings.l2_section_top_n * len(doc_ids), l2_filter + ) + logger.info("L2 检索完成", hits=len(l2_hits)) + l2_doc_ids = _unique((p.payload or {}).get("doc_id") for p in l2_hits) + l2_section_paths = _unique((p.payload or {}).get("section_path") for p in l2_hits) + # 无 L2 命中的文档(2.5 级文档或该 doc 的 L2 未命中)走 L3 b 路 + remaining_doc_ids = [d for d in doc_ids if d not in l2_doc_ids] + + # L3:两路查询后 RRF 融合 + l3_lists: list[list[models.ScoredPoint]] = [] + if l2_doc_ids: + l3_filter_a = QdrantService.build_filter( + categories=categories, doc_ids=l2_doc_ids, section_paths=l2_section_paths + ) + l3_lists.append(await self._search_collection(COLLECTION_L3, dense, sparse, settings.l3_top_n, l3_filter_a)) + if remaining_doc_ids: + l3_filter_b = QdrantService.build_filter(categories=categories, doc_ids=remaining_doc_ids) + l3_lists.append(await self._search_collection(COLLECTION_L3, dense, sparse, settings.l3_top_n, l3_filter_b)) + l3_hits = rrf_fuse(l3_lists) + logger.info("L3 检索完成", hits=len(l3_hits)) + + # chunk 层:L3 有命中按 section 收窄;无命中回退为 L1 候选文档级检索 + if l3_hits: + l3_doc_ids = _unique((p.payload or {}).get("doc_id") for p in l3_hits) + # L3 命中 section_path 全为空(如 2.5 级文档)时仅按 doc 过滤 + l3_section_paths = _unique((p.payload or {}).get("section_path") for p in l3_hits) + chunk_filter = QdrantService.build_filter( + categories=categories, doc_ids=l3_doc_ids, section_paths=l3_section_paths + ) + else: + logger.info("L3 无命中,回退到 L1 候选文档级 chunk 检索") + chunk_filter = QdrantService.build_filter(categories=categories, doc_ids=doc_ids) + + chunk_hits = await self._search_collection( + COLLECTION_CHUNKS, dense, sparse, settings.retrieval_top_k, chunk_filter + ) + logger.info("chunk 检索完成", hits=len(chunk_hits)) + + final_points = finalize(rrf_fuse([chunk_hits]), self._final_k(request)) + return SearchResponse( + query=request.query, + hits=self._to_hits(final_points), + routed_categories=route.filter_categories or [], + fallback=route.fallback, + ) + + async def _search_collection( + self, + collection: str, + dense: list[float], + sparse: tuple[list[int], list[float]] | None, + limit: int, + query_filter: models.Filter | None, + ) -> list[models.ScoredPoint]: + """按集合是否支持 sparse 选择 hybrid 或 dense 检索""" + if sparse is not None and collection in SPARSE_COLLECTIONS: + return await self.qdrant.search_hybrid(collection, dense, sparse, limit, query_filter=query_filter) + return await self.qdrant.search_dense(collection, dense, limit, query_filter=query_filter) + + @staticmethod + def _final_k(request: SearchRequest) -> int: + """最终返回数:请求指定优先,否则用配置默认值""" + return request.top_k or settings.retrieval_final_k + + @staticmethod + def _to_hits(points: list[models.ScoredPoint]) -> list[SearchHit]: + """chunk 点组装为 SearchHit,字段取自 chunk payload(doc_summary 仅上下文标注)""" + hits: list[SearchHit] = [] + for p in points: + payload = p.payload or {} + hits.append( + SearchHit( + text=payload.get("text", ""), + doc_id=payload.get("doc_id", ""), + title=payload.get("title", ""), + section_path=payload.get("section_path") or "", + score=p.score, + doc_summary=payload.get("doc_summary") or "", + ) + ) + return hits diff --git a/app/core/sparse.py b/app/core/sparse.py new file mode 100644 index 0000000..c7b9e39 --- /dev/null +++ b/app/core/sparse.py @@ -0,0 +1,68 @@ +"""BM25 轻量近似稀疏向量编码器 + +零第三方依赖的稀疏编码实现:无全局 IDF 的 BM25 近似(仅用词频 tf 加权), +输出 Qdrant SparseVector 所需的 indices/values 格式。 +后续可替换为 SPLADE / BM42 等更强的稀疏编码器。 +""" + +import hashlib +import math +import re + +# 稀疏向量维度(哈希空间大小) +SPARSE_DIM = 2**18 + +# 连续 CJK 字符段(一-鿿) +_CJK_RE = re.compile(r"[一-鿿]+") +# CJK 段或英文/数字连续段 +_TOKEN_RE = re.compile(r"[一-鿿]+|[a-zA-Z0-9]+") + + +def _tokenize(text: str) -> list[str]: + """分词(纯正则实现) + + - 连续 CJK 字符段:长度 1 时保留 unigram,长度 >= 2 时生成字符 bigram + - 英文/数字连续段:小写化后作为整词 + """ + tokens: list[str] = [] + for match in _TOKEN_RE.finditer(text): + seg = match.group() + if _CJK_RE.fullmatch(seg): + if len(seg) == 1: + tokens.append(seg) + else: + tokens.extend(seg[i : i + 2] for i in range(len(seg) - 1)) + else: + tokens.append(seg.lower()) + return tokens + + +def _hash(token: str) -> int: + """将词哈希到 [0, SPARSE_DIM) 的索引空间""" + digest = hashlib.blake2b(token.encode("utf-8"), digest_size=8).digest() + return int.from_bytes(digest, "big") % SPARSE_DIM + + +class SparseEncoder: + """稀疏向量编码器,输出 Qdrant SparseVector 的 indices/values""" + + def encode(self, text: str) -> tuple[list[int], list[float]]: + """编码单条文本 + + 权重 = 1 + log(tf),词经哈希到 [0, SPARSE_DIM),哈希冲突时权重累加。 + + Returns: + (indices, values):indices 升序且无重复,与 values 等长 + """ + # 按哈希索引累计词频,天然处理哈希冲突 + tf: dict[int, float] = {} + for token in _tokenize(text): + idx = _hash(token) + tf[idx] = tf.get(idx, 0.0) + 1.0 + indices = sorted(tf) + values = [1.0 + math.log(tf[idx]) for idx in indices] + return indices, values + + def encode_batch(self, texts: list[str]) -> list[tuple[list[int], list[float]]]: + """批量编码,与逐条 encode 结果一致""" + return [self.encode(text) for text in texts] diff --git a/app/core/summarizer.py b/app/core/summarizer.py new file mode 100644 index 0000000..909f65a --- /dev/null +++ b/app/core/summarizer.py @@ -0,0 +1,138 @@ +"""文档三级总结模块 + +通过 Ollama 本地小模型对文档进行分级总结: +- L1: 总结(一句话高度概括) +- L2: 大纲(主要章节和关键主题) +- L3: 内容大纲(每个章节的详细内容摘要) + +当文档内容不足以支撑三级总结时,自动降级为 2.5 级(L1 + L2.5 内容大纲)。 +""" + +import structlog + +from app.core.headings import parse_headings, render_outline +from app.models.document import DocumentSummary, SummaryLevel +from app.services.ollama import OllamaClient + +logger = structlog.get_logger() + +# 文本长度阈值(字符数),低于此值触发 2.5 级回退 +MIN_TEXT_LENGTH_FOR_L3 = 500 + + +class Summarizer: + """文档三级总结器""" + + def __init__(self, ollama: OllamaClient | None = None) -> None: + self.ollama = ollama or OllamaClient() + + async def summarize(self, text: str, *, title: str = "") -> DocumentSummary: + """对文档文本进行三级总结 + + Args: + text: 文档纯文本内容 + title: 文档标题(可选,辅助总结) + + Returns: + DocumentSummary: 包含各级总结的结果 + """ + text_length = len(text.strip()) + + # 生成 L1 总结 + l1_summary = await self._generate_l1(text, title) + logger.info("L1 总结完成", title=title, l1=l1_summary[:100]) + + # 判断是否需要 2.5 级回退 + use_fallback = text_length < MIN_TEXT_LENGTH_FOR_L3 + + if use_fallback: + logger.info("文档内容不足,使用 2.5 级回退", text_length=text_length) + # L2.5: 直接生成内容大纲(跳过大纲层) + l2_half = await self._generate_l2_half(text, title, l1_summary) + return DocumentSummary( + l1_summary=l1_summary, + l2_outline=None, + l3_content_outline=l2_half, + level=SummaryLevel.L2_HALF, + ) + + # 生成 L2 大纲:优先使用文档原生标题树(结构导航,不调 LLM), + # 无结构文本(标题数 < 2)回退 LLM 生成 + headings = parse_headings(text) + if len(headings) >= 2: + l2_outline = render_outline(headings) + logger.info("L2 大纲完成", title=title, outline_source="headings", heading_count=len(headings)) + else: + l2_outline = await self._generate_l2(text, title, l1_summary) + logger.info("L2 大纲完成", title=title, outline_source="llm") + + # 生成 L3 内容大纲 + l3_content_outline = await self._generate_l3(text, title, l1_summary, l2_outline) + logger.info("L3 内容大纲完成", title=title) + + return DocumentSummary( + l1_summary=l1_summary, + l2_outline=l2_outline, + l3_content_outline=l3_content_outline, + level=SummaryLevel.L3, + ) + + async def _generate_l1(self, text: str, title: str) -> str: + """生成 L1 总结:一句话高度概括""" + prompt = ( + "请用一句话对以下文档内容进行高度概括,要求简洁精炼," + "突出文档的核心主题和关键信息。\n\n" + ) + if title: + prompt += f"文档标题:{title}\n\n" + prompt += f"文档内容:\n{text}" + + return await self.ollama.generate(prompt) + + async def _generate_l2(self, text: str, title: str, l1_summary: str) -> str: + """生成 L2 大纲:主要章节和关键主题""" + prompt = ( + "请提取以下文档的主要章节结构和关键主题," + "以大纲形式呈现。每个主题用一行表示," + "格式为:序号. 主题名称\n\n" + f"文档总结:{l1_summary}\n\n" + ) + if title: + prompt += f"文档标题:{title}\n\n" + prompt += f"文档内容:\n{text}" + + return await self.ollama.generate(prompt) + + async def _generate_l3( + self, text: str, title: str, l1_summary: str, l2_outline: str + ) -> str: + """生成 L3 内容大纲:每个章节的详细内容摘要""" + prompt = ( + "请对以下文档的每个章节/主题进行详细的内容摘要," + "格式为:\n" + "## 章节名称\n" + "详细摘要内容...\n\n" + f"文档总结:{l1_summary}\n\n" + f"文档大纲:\n{l2_outline}\n\n" + ) + if title: + prompt += f"文档标题:{title}\n\n" + prompt += f"文档内容:\n{text}" + + return await self.ollama.generate(prompt) + + async def _generate_l2_half(self, text: str, title: str, l1_summary: str) -> str: + """生成 L2.5 内容大纲(2.5 级回退) + + 跳过大纲层,直接对短文本生成详细摘要。 + """ + prompt = ( + "请对以下文档内容进行详细摘要," + "涵盖所有关键信息点。以要点形式呈现。\n\n" + f"文档总结:{l1_summary}\n\n" + ) + if title: + prompt += f"文档标题:{title}\n\n" + prompt += f"文档内容:\n{text}" + + return await self.ollama.generate(prompt) diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..7ba8dc0 --- /dev/null +++ b/app/main.py @@ -0,0 +1,79 @@ +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from pathlib import Path + +import structlog +from fastapi import FastAPI, Request +from fastapi.exceptions import RequestValidationError +from fastapi.responses import FileResponse, JSONResponse + +from app.api.response import ApiError, error +from app.api.v1.document import router as document_router +from app.api.v1.knowledge import router as knowledge_router +from app.api.v1.search import router as search_router +from app.config import settings +from app.services.qdrant import QdrantService + +logger = structlog.get_logger() + + +@asynccontextmanager +async def lifespan(_: FastAPI) -> AsyncIterator[None]: + """应用生命周期:启动时初始化 Qdrant 集合 + + 初始化失败仅记录日志、不阻止启动(本地开发可能无 Qdrant)。 + """ + try: + await QdrantService().ensure_collections() + logger.info("Qdrant 集合初始化完成") + except Exception: + logger.error("Qdrant 集合初始化失败,跳过初始化继续启动") + yield + + +app = FastAPI( + title=settings.app_name, + description="AI Agent 分层信息检索服务", + version="0.1.0", + lifespan=lifespan, +) + +app.include_router(search_router) +app.include_router(document_router) +app.include_router(knowledge_router) + + +@app.exception_handler(ApiError) +async def api_error_handler(request: Request, exc: ApiError) -> JSONResponse: + """业务异常 → 统一错误响应(code 取自异常)""" + return JSONResponse(content=error(exc.code, exc.message)) + + +@app.exception_handler(RequestValidationError) +async def validation_error_handler(request: Request, exc: RequestValidationError) -> JSONResponse: + """请求参数校验失败 → code 1001""" + errors = exc.errors() + message = f"请求参数校验失败: {errors[0]['msg']}" if errors else "请求参数校验失败" + return JSONResponse(content=error(1001, message)) + + +@app.exception_handler(Exception) +async def unhandled_error_handler(request: Request, exc: Exception) -> JSONResponse: + """未捕获异常 → code 2000""" + logger.exception("未捕获异常", path=request.url.path) + return JSONResponse(content=error(2000, "服务器内部错误")) + + +@app.get("/api/v1/health") +async def health() -> dict[str, str]: + """健康检查""" + return {"status": "ok"} + + +_ADMIN_HTML = Path(__file__).resolve().parent / "static" / "admin.html" + + +@app.get("/admin", include_in_schema=False) +async def admin_page() -> FileResponse: + """管理后台单页(单文件静态 HTML,零外部依赖)""" + return FileResponse(_ADMIN_HTML, media_type="text/html") diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/models/document.py b/app/models/document.py new file mode 100644 index 0000000..4d18cb6 --- /dev/null +++ b/app/models/document.py @@ -0,0 +1,51 @@ +"""文档和总结相关的数据模型""" + +from enum import StrEnum + +from pydantic import BaseModel, Field + + +class SummaryLevel(StrEnum): + """总结层级""" + + L3 = "L3" # 完整三级:总结 → 大纲 → 内容大纲 + L2_HALF = "L2.5" # 2.5 级回退:总结 → 内容大纲(跳过大纲) + + +class DocumentSummary(BaseModel): + """文档三级总结结果""" + + l1_summary: str = Field(description="L1 总结:一句话高度概括") + l2_outline: str | None = Field(default=None, description="L2 大纲:主要章节和关键主题") + l3_content_outline: str = Field(description="L3/L2.5 内容大纲:详细内容摘要") + level: SummaryLevel = Field(description="实际使用的总结层级") + + +class DocumentInput(BaseModel): + """文档入库输入""" + + text: str = Field(description="文档纯文本内容") + title: str = Field(default="", description="文档标题") + source: str = Field(default="", description="来源标识(文件路径/URL等)") + metadata: dict[str, str] = Field(default_factory=dict, description="附加元数据") + + +class ChunkModel(BaseModel): + """文档分块结果""" + + doc_id: str = Field(description="所属文档 ID") + chunk_index: int = Field(description="chunk 在文档内的序号") + text: str = Field(description="chunk 文本内容") + section_path: str = Field(default="", description="chunk 所在的章节路径") + + +class IngestionResult(BaseModel): + """文档入库结果""" + + document_id: str = Field(description="写入后的文档 ID") + summary: DocumentSummary = Field(description="三级总结结果") + category: str = Field(description="分类标签(主类目)") + collection: str = Field(description="写入的 Qdrant 集合名") + chunks_count: int = Field(default=0, description="写入的 chunk 数量") + tags: list[str] = Field(default_factory=list, description="附加分类标签") + category_confidence: float = Field(default=0.0, description="主类目分类置信度") diff --git a/app/models/knowledge.py b/app/models/knowledge.py new file mode 100644 index 0000000..1bc8ce7 --- /dev/null +++ b/app/models/knowledge.py @@ -0,0 +1,68 @@ +"""知识分类(taxonomy)相关的数据模型与加载逻辑""" + +import json +from pathlib import Path + +import structlog +from pydantic import BaseModel, Field + +logger = structlog.get_logger() + +# 未分类常量:分类置信度不足或无法归类时使用 +UNCATEGORIZED = "uncategorized" + + +class TaxonomyCategory(BaseModel): + """知识分类类目定义""" + + name: str = Field(description="类目名称,全局唯一") + description: str = Field(default="", description="类目描述,用于辅助分类判断") + + +class CategoryResult(BaseModel): + """文档/查询的分类结果""" + + main_category: str = Field(description="主类目名称") + tags: list[str] = Field(default_factory=list, description="附加标签列表") + confidence: float = Field(ge=0, le=1, description="分类置信度,范围 [0, 1]") + + +def _default_taxonomy() -> list[TaxonomyCategory]: + """内置默认类目集(通用企业知识库场景)""" + return [ + TaxonomyCategory(name="技术文档", description="架构设计、API 文档、开发规范、运维手册等技术资料"), + TaxonomyCategory(name="产品手册", description="产品功能介绍、使用说明、版本发布说明"), + TaxonomyCategory(name="运营规范", description="运营流程、活动方案、内容规范、客服话术"), + TaxonomyCategory(name="财务行政", description="财务制度、报销流程、行政通知、办公管理"), + TaxonomyCategory(name="市场资料", description="市场分析、竞品调研、营销素材、品牌规范"), + TaxonomyCategory(name="人事制度", description="招聘、考勤、绩效、培训、员工手册等 HR 制度"), + TaxonomyCategory(name="法律法规", description="合同模板、合规要求、法律条文、知识产权"), + TaxonomyCategory(name=UNCATEGORIZED, description="无法归入其他类目的文档"), + ] + + +def load_taxonomy(path: str = "") -> list[TaxonomyCategory]: + """加载 taxonomy 类目集 + + path 为空时使用内置默认类目集;非空时从 JSON 文件加载, + 文件格式为 [{"name": ..., "description": ...}]。 + 校验类目 name 唯一;若缺少 uncategorized 类目则自动追加。 + """ + if not path: + return _default_taxonomy() + + raw = json.loads(Path(path).read_text(encoding="utf-8")) + categories = [TaxonomyCategory.model_validate(item) for item in raw] + + # 校验 name 唯一 + names = [c.name for c in categories] + if len(names) != len(set(names)): + duplicates = sorted({n for n in names if names.count(n) > 1}) + raise ValueError(f"taxonomy 类目 name 重复: {duplicates}") + + # 必含 uncategorized,缺失则自动追加 + if UNCATEGORIZED not in names: + logger.warning("taxonomy 缺少 uncategorized 类目,已自动追加", path=path) + categories.append(TaxonomyCategory(name=UNCATEGORIZED, description="无法归入其他类目的文档")) + + return categories diff --git a/app/models/search.py b/app/models/search.py new file mode 100644 index 0000000..f132de6 --- /dev/null +++ b/app/models/search.py @@ -0,0 +1,30 @@ +"""检索请求与响应的数据模型""" + +from pydantic import BaseModel, Field + + +class SearchRequest(BaseModel): + """检索请求""" + + query: str = Field(description="查询文本") + top_k: int | None = Field(default=None, description="返回结果数,为空时使用 settings.retrieval_final_k") + + +class SearchHit(BaseModel): + """单条检索命中结果""" + + text: str = Field(description="原文 chunk 内容") + doc_id: str = Field(description="所属文档 ID") + title: str = Field(default="", description="文档标题") + section_path: str = Field(default="", description="chunk 所在的章节路径") + score: float = Field(description="相关性得分") + doc_summary: str = Field(default="", description="L1 文档总结,仅用于上下文标注") + + +class SearchResponse(BaseModel): + """检索响应""" + + query: str = Field(description="原始查询文本") + hits: list[SearchHit] = Field(default_factory=list, description="命中结果列表") + routed_categories: list[str] = Field(default_factory=list, description="query 路由命中的类目") + fallback: bool = Field(default=False, description="是否走了全库兜底路径") diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/services/ollama.py b/app/services/ollama.py new file mode 100644 index 0000000..b42dfeb --- /dev/null +++ b/app/services/ollama.py @@ -0,0 +1,62 @@ +"""Ollama 客户端 + +通过 Ollama HTTP API 调用本地模型进行文本生成。 +""" + +import httpx +import structlog + +from app.config import settings + +logger = structlog.get_logger() + + +class OllamaClient: + """Ollama HTTP API 客户端""" + + def __init__( + self, + base_url: str | None = None, + model: str | None = None, + timeout: float = 120.0, + ) -> None: + self.base_url = (base_url or settings.ollama_base_url).rstrip("/") + self.model = model or settings.ollama_model + self.timeout = timeout + + async def generate(self, prompt: str, json_mode: bool = False) -> str: + """调用 Ollama 生成文本 + + Args: + prompt: 输入提示词 + json_mode: 为 True 时启用 Ollama 原生 JSON 约束输出(payload 加 format: json) + + Returns: + 生成的文本内容 + """ + url = f"{self.base_url}/api/generate" + payload = { + "model": self.model, + "prompt": prompt, + "stream": False, + } + if json_mode: + payload["format"] = "json" + + async with httpx.AsyncClient(timeout=self.timeout) as client: + resp = await client.post(url, json=payload) + resp.raise_for_status() + data = resp.json() + + result = data.get("response", "") + logger.debug("Ollama 生成完成", model=self.model, output_length=len(result)) + return result + + async def is_available(self) -> bool: + """检查 Ollama 服务是否可用""" + try: + async with httpx.AsyncClient(timeout=5.0) as client: + resp = await client.get(f"{self.base_url}/api/tags") + return resp.status_code == 200 + except httpx.HTTPError: + return False diff --git a/app/services/qdrant.py b/app/services/qdrant.py new file mode 100644 index 0000000..83884e7 --- /dev/null +++ b/app/services/qdrant.py @@ -0,0 +1,351 @@ +"""Qdrant 向量数据库服务封装 + +分层摘要索引 RAG 的存储层: +- doc_l1:文档级总结(dense + sparse) +- doc_l2 / doc_l3:大纲节点(dense) +- chunks:原文分块(dense + sparse) + +提供集合初始化(幂等)、按层 upsert、dense / hybrid(RRF 融合)检索接口。 +""" + +import uuid +from typing import Any + +import structlog +from qdrant_client import AsyncQdrantClient, models + +from app.config import settings + +logger = structlog.get_logger() + +# 集合名 +COLLECTION_L1 = "doc_l1" +COLLECTION_L2 = "doc_l2" +COLLECTION_L3 = "doc_l3" +COLLECTION_CHUNKS = "chunks" + +ALL_COLLECTIONS = (COLLECTION_L1, COLLECTION_L2, COLLECTION_L3, COLLECTION_CHUNKS) + +# 命名向量 +VECTOR_DENSE = "dense" +VECTOR_SPARSE = "sparse" + +# 需要 sparse 向量的集合 +SPARSE_COLLECTIONS = (COLLECTION_L1, COLLECTION_CHUNKS) + +# 需要建立 KEYWORD payload 索引的字段 +PAYLOAD_INDEX_FIELDS = ("doc_id", "category", "tags", "section_path") + +# upsert_nodes 集合名 -> point id 层级前缀 +_NODE_ID_PREFIX = {COLLECTION_L2: "l2", COLLECTION_L3: "l3"} + +# 稀疏向量统一表示:(indices, values) +SparseVectorTuple = tuple[list[int], list[float]] + + +def _point_id(key: str) -> str: + """由确定性 key 生成 UUID5 point id,保证重复入库幂等覆盖""" + return str(uuid.uuid5(uuid.NAMESPACE_URL, key)) + + +class QdrantService: + """Qdrant 异步客户端封装""" + + def __init__(self, client: AsyncQdrantClient | None = None) -> None: + # 允许注入自定义 client(测试可用 location=":memory:" 的本地模式) + self._client = client or AsyncQdrantClient(host=settings.qdrant_host, port=settings.qdrant_port) + + @property + def client(self) -> AsyncQdrantClient: + return self._client + + async def ensure_collections(self) -> None: + """初始化 4 个集合与 payload 索引(幂等,已存在则跳过)""" + existing = await self._client.get_collections() + existing_names = {c.name for c in existing.collections} + + for collection in ALL_COLLECTIONS: + if collection in existing_names: + continue + sparse_config = None + if settings.sparse_enabled and collection in SPARSE_COLLECTIONS: + sparse_config = {VECTOR_SPARSE: models.SparseVectorParams(modifier=models.Modifier.IDF)} + dense_params = models.VectorParams(size=settings.embedding_dimension, distance=models.Distance.COSINE) + await self._client.create_collection( + collection_name=collection, + vectors_config={VECTOR_DENSE: dense_params}, + sparse_vectors_config=sparse_config, + ) + logger.info("创建 Qdrant 集合", collection=collection, sparse=sparse_config is not None) + + # payload 索引:先查已有索引,缺失才创建,保证幂等 + for collection in ALL_COLLECTIONS: + info = await self._client.get_collection(collection) + indexed = set(info.payload_schema.keys()) if info.payload_schema else set() + for field in PAYLOAD_INDEX_FIELDS: + if field in indexed: + continue + await self._client.create_payload_index( + collection_name=collection, + field_name=field, + field_schema=models.PayloadSchemaType.KEYWORD, + ) + logger.debug("payload 索引就绪", collection=collection) + + # ---------- upsert ---------- + + async def upsert_l1( + self, + doc_id: str, + title: str, + summary: str, + category: str, + tags: list[str], + dense_vector: list[float], + sparse_vector: SparseVectorTuple | None = None, + ) -> None: + """写入 L1 文档总结,payload 含 doc_id/title/category/tags/text(=summary)""" + vector: dict[str, Any] = {VECTOR_DENSE: dense_vector} + if sparse_vector is not None: + vector[VECTOR_SPARSE] = models.SparseVector(indices=sparse_vector[0], values=sparse_vector[1]) + point = models.PointStruct( + id=_point_id(f"{doc_id}:l1"), + vector=vector, + payload={"doc_id": doc_id, "title": title, "category": category, "tags": tags, "text": summary}, + ) + await self._client.upsert(collection_name=COLLECTION_L1, points=[point]) + + async def upsert_nodes(self, collection: str, nodes: list[dict[str, Any]]) -> None: + """批量写入 L2/L3 大纲节点 + + 每个 node 含 doc_id/section_path/text/category/tags/dense_vector。 + """ + if collection not in _NODE_ID_PREFIX: + raise ValueError(f"upsert_nodes 仅支持 {COLLECTION_L2}/{COLLECTION_L3},收到: {collection}") + prefix = _NODE_ID_PREFIX[collection] + points = [ + models.PointStruct( + id=_point_id(f"{node['doc_id']}:{prefix}:{i}"), + vector={VECTOR_DENSE: node["dense_vector"]}, + payload={ + "doc_id": node["doc_id"], + "section_path": node["section_path"], + "text": node["text"], + "category": node["category"], + "tags": node["tags"], + }, + ) + for i, node in enumerate(nodes) + ] + if points: + await self._client.upsert(collection_name=collection, points=points) + + async def upsert_chunks(self, chunks: list[dict[str, Any]]) -> None: + """批量写入原文 chunk + + 每个 chunk 含 doc_id/chunk_index/text/section_path/title/category/tags/dense_vector, + sparse_vector 可选((indices, values) 形式)。 + """ + points = [] + for chunk in chunks: + vector: dict[str, Any] = {VECTOR_DENSE: chunk["dense_vector"]} + sparse = chunk.get("sparse_vector") + if sparse is not None: + vector[VECTOR_SPARSE] = models.SparseVector(indices=sparse[0], values=sparse[1]) + points.append( + models.PointStruct( + id=_point_id(f"{chunk['doc_id']}:chunk:{chunk['chunk_index']}"), + vector=vector, + payload={ + "doc_id": chunk["doc_id"], + "chunk_index": chunk["chunk_index"], + "text": chunk["text"], + "section_path": chunk["section_path"], + "title": chunk["title"], + "category": chunk["category"], + "tags": chunk["tags"], + # 所属文档 L1 总结,仅作检索结果上下文标注 + "doc_summary": chunk.get("doc_summary", ""), + }, + ) + ) + if points: + await self._client.upsert(collection_name=COLLECTION_CHUNKS, points=points) + + # ---------- 查询 ---------- + + async def search_dense( + self, + collection: str, + vector: list[float], + limit: int, + query_filter: models.Filter | None = None, + ) -> list[models.ScoredPoint]: + """dense 命名向量检索""" + resp = await self._client.query_points( + collection_name=collection, + query=vector, + using=VECTOR_DENSE, + limit=limit, + query_filter=query_filter, + ) + return resp.points + + async def search_hybrid( + self, + collection: str, + dense_vector: list[float], + sparse: SparseVectorTuple, + limit: int, + query_filter: models.Filter | None = None, + ) -> list[models.ScoredPoint]: + """dense + sparse 两路 prefetch,服务端 RRF 融合""" + resp = await self._client.query_points( + collection_name=collection, + prefetch=[ + models.Prefetch(query=dense_vector, using=VECTOR_DENSE, limit=limit, filter=query_filter), + models.Prefetch( + query=models.SparseVector(indices=sparse[0], values=sparse[1]), + using=VECTOR_SPARSE, + limit=limit, + filter=query_filter, + ), + ], + query=models.FusionQuery(fusion=models.Fusion.RRF), + limit=limit, + query_filter=query_filter, + ) + return resp.points + + # ---------- 管理操作 ---------- + + async def count(self, collection: str) -> int: + """精确统计集合中点的总数""" + result = await self._client.count(collection_name=collection, exact=True) + return result.count + + async def scroll_l1(self, limit: int = 20, offset: str | None = None) -> tuple[list[dict[str, Any]], str | None]: + """分页浏览 L1 文档列表(不取向量) + + 返回 (items, next_offset):item 含 doc_id/title/category/tags/summary(=payload text); + next_offset 为下一页游标,无更多数据时为 None。 + """ + records, next_offset = await self._client.scroll( + collection_name=COLLECTION_L1, + limit=limit, + offset=offset, + with_payload=["doc_id", "title", "category", "tags", "text"], + with_vectors=False, + ) + items = [] + for record in records: + payload = record.payload or {} + items.append( + { + "doc_id": payload.get("doc_id", ""), + "title": payload.get("title", ""), + "category": payload.get("category", ""), + "tags": payload.get("tags", []), + "summary": payload.get("text", ""), + } + ) + return items, str(next_offset) if next_offset is not None else None + + async def get_doc_detail(self, doc_id: str) -> dict[str, Any] | None: + """获取文档详情:L1 记录 + L2/L3 全部节点 + chunks 数量,文档不存在返回 None""" + doc_filter = self.build_filter(doc_ids=[doc_id]) + l1_records, _ = await self._client.scroll( + collection_name=COLLECTION_L1, + scroll_filter=doc_filter, + limit=1, + with_payload=True, + with_vectors=False, + ) + if not l1_records: + return None + + l2_nodes = await self._scroll_payloads(COLLECTION_L2, doc_filter) + l3_nodes = await self._scroll_payloads(COLLECTION_L3, doc_filter) + chunks_count = await self._client.count( + collection_name=COLLECTION_CHUNKS, + count_filter=doc_filter, + exact=True, + ) + return { + "l1": l1_records[0].payload or {}, + "l2_nodes": l2_nodes, + "l3_nodes": l3_nodes, + "chunks_count": chunks_count.count, + } + + async def delete_by_doc_id(self, doc_id: str) -> dict[str, int]: + """删除四层集合中该 doc_id 的所有点,返回各集合删除数量(不存在的 doc_id 全 0,幂等)""" + doc_filter = self.build_filter(doc_ids=[doc_id]) + deleted: dict[str, int] = {} + for collection in ALL_COLLECTIONS: + # 先记录删除前数量,再按过滤器删除 + before = await self._client.count(collection_name=collection, count_filter=doc_filter, exact=True) + await self._client.delete( + collection_name=collection, + points_selector=models.FilterSelector(filter=doc_filter), + ) + deleted[collection] = before.count + logger.info("按 doc_id 删除文档数据", doc_id=doc_id, deleted=deleted) + return deleted + + async def _scroll_payloads( + self, + collection: str, + scroll_filter: models.Filter | None, + page_size: int = 256, + ) -> list[dict[str, Any]]: + """循环 scroll 取出所有匹配点的 payload(含 section_path/text 等全字段)""" + payloads: list[dict[str, Any]] = [] + offset = None + while True: + records, next_offset = await self._client.scroll( + collection_name=collection, + scroll_filter=scroll_filter, + limit=page_size, + offset=offset, + with_payload=True, + with_vectors=False, + ) + payloads.extend(record.payload or {} for record in records) + if next_offset is None: + return payloads + offset = next_offset + + # ---------- 过滤构造 ---------- + + @staticmethod + def build_filter( + categories: list[str] | None = None, + doc_ids: list[str] | None = None, + section_paths: list[str] | None = None, + ) -> models.Filter | None: + """构造 payload 过滤器 + + - categories:主类硬过滤 + 多标签软召回(category 或 tags 命中其一即召回,min_should=1) + - doc_ids / section_paths:must 条件 MatchAny + - 全空返回 None + """ + must: list[models.FieldCondition] = [] + if doc_ids: + must.append(models.FieldCondition(key="doc_id", match=models.MatchAny(any=doc_ids))) + if section_paths: + must.append(models.FieldCondition(key="section_path", match=models.MatchAny(any=section_paths))) + + min_should = None + if categories: + min_should = models.MinShould( + conditions=[ + models.FieldCondition(key="category", match=models.MatchAny(any=categories)), + models.FieldCondition(key="tags", match=models.MatchAny(any=categories)), + ], + min_count=1, + ) + + if not must and min_should is None: + return None + return models.Filter(must=must or None, min_should=min_should) diff --git a/app/services/redis.py b/app/services/redis.py new file mode 100644 index 0000000..c0280b1 --- /dev/null +++ b/app/services/redis.py @@ -0,0 +1,75 @@ +"""Redis 缓存服务 + +为检索结果与 query 解析结果提供 JSON 缓存。所有操作容错: +Redis 不可用或缓存数据异常时降级为未命中 / 写入失败,绝不影响主流程。 +""" + +import json +from typing import Any + +import structlog +from redis import asyncio as redis_async + +from app.config import settings + +logger = structlog.get_logger() + + +class RedisCache: + """Redis JSON 缓存客户端(懒连接,全操作容错)""" + + def __init__(self) -> None: + self._client: redis_async.Redis | None = None + + def _get_client(self) -> redis_async.Redis: + """懒创建 Redis 客户端(TCP 连接在首次执行命令时才建立)""" + if self._client is None: + self._client = redis_async.from_url(settings.redis_url, decode_responses=True) + return self._client + + async def get_json(self, key: str) -> dict[str, Any] | None: + """读取缓存并反序列化为 dict + + 任何异常(连接失败 / JSON 解析失败)或值非 dict 时都降级为未命中,返回 None。 + """ + try: + raw = await self._get_client().get(key) + if raw is None: + return None + data = json.loads(raw) + return data if isinstance(data, dict) else None + except Exception: + logger.warning("Redis 读取缓存失败,降级为未命中", key=key, exc_info=True) + return None + + async def set_json(self, key: str, value: dict[str, Any], ttl: int | None = None) -> bool: + """写入缓存(SETEX),ttl 缺省取 settings.cache_ttl + + 异常时记录日志并返回 False,不影响主流程。 + """ + try: + await self._get_client().setex( + key, ttl if ttl is not None else settings.cache_ttl, json.dumps(value, ensure_ascii=False) + ) + return True + except Exception: + logger.warning("Redis 写入缓存失败", key=key, exc_info=True) + return False + + async def close(self) -> None: + """关闭底层连接""" + if self._client is not None: + await self._client.aclose() + self._client = None + + +# 模块级懒加载单例,避免每请求重建客户端 +_cache: RedisCache | None = None + + +def get_cache() -> RedisCache: + """获取全局 RedisCache 单例""" + global _cache + if _cache is None: + _cache = RedisCache() + return _cache diff --git a/app/static/admin.html b/app/static/admin.html new file mode 100644 index 0000000..d41d2d2 --- /dev/null +++ b/app/static/admin.html @@ -0,0 +1,692 @@ + + + + + +知识库管理后台 + + + +
+

知识库管理后台

+ +
+
+
+

概览

+ +
+ +
+
+

类目分布

+
+
+ + + + + + + + +
+ + + + diff --git a/app/utils/__init__.py b/app/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..d4b579f --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,78 @@ +services: + app: + build: . + container_name: qmdsearch-app + restart: unless-stopped + ports: + - "${APP_PORT:-8000}:8000" + environment: + - QDRANT_HOST=qdrant + - QDRANT_PORT=6333 + - REDIS_URL=redis://redis:6379/0 + - OLLAMA_BASE_URL=http://ollama:11434 + env_file: + - .env + depends_on: + qdrant: + condition: service_healthy + redis: + condition: service_healthy + ollama: + condition: service_started + volumes: + - ${NAS_DATA_DIR:-./data}/logs:/app/logs + networks: + - qmdsearch + + qdrant: + image: qdrant/qdrant:latest + container_name: qmdsearch-qdrant + restart: unless-stopped + ports: + - "${QDRANT_PORT:-6333}:6333" + - "${QDRANT_DASHBOARD_PORT:-6334}:6334" + volumes: + - ${NAS_DATA_DIR:-./data}/qdrant:/qdrant/storage + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:6333/healthz"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - qmdsearch + + redis: + image: redis:7-alpine + container_name: qmdsearch-redis + restart: unless-stopped + ports: + - "${REDIS_PORT:-6379}:6379" + volumes: + - ${NAS_DATA_DIR:-./data}/redis:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - qmdsearch + + ollama: + image: ollama/ollama:latest + container_name: qmdsearch-ollama + restart: unless-stopped + ports: + - "${OLLAMA_PORT:-11434}:11434" + volumes: + - ${NAS_DATA_DIR:-./data}/ollama:/root/.ollama + # 首次启动后需手动拉取模型: + # docker exec qmdsearch-ollama ollama pull qwen2.5:1.5b + # 或取消下方 entrypoint 注释以自动拉取(需等待下载完成) + # entrypoint: /bin/bash + # command: -c "ollama serve & sleep 5 && ollama pull qwen2.5:1.5b && wait" + networks: + - qmdsearch + +networks: + qmdsearch: + driver: bridge diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..c4a3074 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,41 @@ +[project] +name = "qmdsearch" +version = "0.1.0" +description = "AI Agent 分层信息检索服务" +requires-python = ">=3.12" +dependencies = [ + "fastapi>=0.115.0", + "uvicorn[standard]>=0.34.0", + "pydantic>=2.10.0", + "pydantic-settings>=2.7.0", + "qdrant-client>=1.12.0", + "redis>=5.2.0", + "httpx>=0.28.0", + "structlog>=24.4.0", + "openai>=1.58.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.3.0", + "pytest-asyncio>=0.24.0", + "ruff>=0.8.0", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["app"] + +[tool.ruff] +target-version = "py312" +line-length = 120 + +[tool.ruff.lint] +select = ["E", "F", "I", "N", "UP", "B"] + +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] diff --git a/scripts/eval/__init__.py b/scripts/eval/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/scripts/eval/judge.py b/scripts/eval/judge.py new file mode 100644 index 0000000..13f4e46 --- /dev/null +++ b/scripts/eval/judge.py @@ -0,0 +1,93 @@ +"""LLM-as-judge 评测指标:基于 Ollama 小模型的幻觉率与类目一致性判定 + +所有判定均要求模型以 JSON 输出(json_mode),解析失败时保守处理并记录日志: +- hallucination_rate 解析失败返回 0.0(不低报问题,避免阻塞评测主流程) +- taxonomy_consistency 解析失败返回 True(不冤枉摘要) +""" + +import json +import re + +import structlog + +from app.services.ollama import OllamaClient + +logger = structlog.get_logger() + +# 每次判定的断言抽取上限,避免长摘要导致判定过慢 +_MAX_CLAIMS = 5 + +_HALLUCINATION_PROMPT = """你是一个事实核查员。请从下面的【摘要】中抽取最多 {max_claims} 条事实性断言, +并逐条判断【原文】是否支持该断言(断言中的数字、名称、条款等关键信息必须与原文一致才算支持)。 + +【原文】 +{source} + +【摘要】 +{summary} + +只输出 JSON,格式:{{"assertions": [{{"claim": "断言内容", "supported": true 或 false}}]}}""" + +_TAXONOMY_PROMPT = """你是一个文档分类审核员。请判断下面的【摘要】所描述的文档是否适合归入类目【{category}】。 + +【摘要】 +{summary} + +只输出 JSON,格式:{{"consistent": true 或 false}}""" + + +def _extract_json(text: str) -> dict | None: + """从模型输出提取首个 JSON 对象(容忍前后噪声),失败返回 None""" + match = re.search(r"\{.*\}", text, re.DOTALL) + if not match: + return None + try: + data = json.loads(match.group()) + except json.JSONDecodeError: + return None + return data if isinstance(data, dict) else None + + +async def hallucination_rate(summary: str, source_text: str, ollama: OllamaClient) -> float: + """幻觉率:摘要中不被原文支持的断言占比 + + 流程:模型抽取断言(最多 _MAX_CLAIMS 条)→ 逐条判原文是否支持 → 返回不支持比例。 + 调用或解析失败保守返回 0.0 并记录日志。 + """ + prompt = _HALLUCINATION_PROMPT.format(max_claims=_MAX_CLAIMS, source=source_text, summary=summary) + try: + raw = await ollama.generate(prompt, json_mode=True) + except Exception as exc: + logger.warning("幻觉率判定调用失败,保守返回 0.0", error=str(exc)) + return 0.0 + + data = _extract_json(raw) + if data is None: + logger.warning("幻觉率判定输出解析失败,保守返回 0.0", raw=raw[:200]) + return 0.0 + + claims = data.get("assertions") + if not isinstance(claims, list): + logger.warning("幻觉率判定输出缺少 assertions 字段,保守返回 0.0", raw=raw[:200]) + return 0.0 + supported_flags = [bool(c["supported"]) for c in claims[:_MAX_CLAIMS] if isinstance(c, dict) and "supported" in c] + if not supported_flags: + return 0.0 + unsupported = sum(1 for supported in supported_flags if not supported) + return unsupported / len(supported_flags) + + +async def taxonomy_consistency(summary: str, category: str, ollama: OllamaClient) -> bool: + """类目一致性:摘要是否支持归入指定类目;调用或解析失败保守返回 True""" + prompt = _TAXONOMY_PROMPT.format(category=category, summary=summary) + try: + raw = await ollama.generate(prompt, json_mode=True) + except Exception as exc: + logger.warning("类目一致性判定调用失败,保守返回 True", error=str(exc)) + return True + + data = _extract_json(raw) + if data is None or "consistent" not in data: + logger.warning("类目一致性判定输出解析失败,保守返回 True", raw=raw[:200]) + return True + return bool(data["consistent"]) diff --git a/scripts/eval/metrics.py b/scripts/eval/metrics.py new file mode 100644 index 0000000..0bc31b4 --- /dev/null +++ b/scripts/eval/metrics.py @@ -0,0 +1,106 @@ +"""离线评测指标:纯函数实现,不依赖 Qdrant / Ollama,可独立单测 + +- entity_recall:实体保留率(摘要质量) +- routing_f1:L1 路由层 micro P/R/F1 +- pruning_loss:上层剪枝损失率 +- precision_at_k / recall_at_k:检索效用 +- aggregate:按 key 汇总均值 +""" + +import re + +# 数字串(含小数/版本号/百分数),如 1536、3.12、95% +_NUMBER_RE = re.compile(r"\d+(?:\.\d+)*%?") +# 型号/版本号模式,如 bge-m3、v1.2.0、FD-07 +_MODEL_RE = re.compile(r"[A-Za-z]+[-_]\w*\d\w*|[A-Za-z]+\d+(?:\.\d+)*") +# 条款号,如 第三条、第 5 章、第十条 +_CLAUSE_RE = re.compile(r"第\s*[一二三四五六七八九十百千万零0-9]+\s*[条款章节项]") +# 英文/大小写混合词(含连字符/点号连接),如 Qdrant、OpenAI、text-embedding-3-small +_EN_WORD_RE = re.compile(r"[A-Za-z][A-Za-z0-9]*(?:[-_.][A-Za-z0-9]+)*") +# 中文书名号内词,如 《部署手册》 +_BOOK_TITLE_RE = re.compile(r"《([^》]+)》") + + +def _extract_entities(text: str) -> set[str]: + """从文本抽取关键实体 token(去重集合)""" + entities: set[str] = set() + entities.update(_NUMBER_RE.findall(text)) + entities.update(_MODEL_RE.findall(text)) + entities.update(m.replace(" ", "") for m in _CLAUSE_RE.findall(text)) + entities.update(_BOOK_TITLE_RE.findall(text)) + # 英文词至少 2 个字符,避免单字母噪声 + entities.update(w for w in _EN_WORD_RE.findall(text) if len(w) >= 2) + return entities + + +def entity_recall(source_text: str, summary: str) -> float: + """实体保留率:原文抽取的关键实体在摘要中保留的比例 + + 原文无可抽取实体时返回 1.0(无实体可丢,视为满分)。 + """ + entities = _extract_entities(source_text) + if not entities: + return 1.0 + kept = sum(1 for entity in entities if entity in summary) + return kept / len(entities) + + +def routing_f1(golden_doc_ids_per_query: list[set[str]], routed_doc_ids_per_query: list[set[str]]) -> dict[str, float]: + """L1 路由层 micro precision / recall / f1 + + 逐 query 累加 tp/fp/fn 后统一计算(micro 平均); + 两条列表必须等长,通常只统计 positive query(negative query 无 golden doc)。 + """ + tp = fp = fn = 0 + for golden, routed in zip(golden_doc_ids_per_query, routed_doc_ids_per_query, strict=True): + tp += len(golden & routed) + fp += len(routed - golden) + fn += len(golden - routed) + precision = tp / (tp + fp) if tp + fp else 0.0 + recall = tp / (tp + fn) if tp + fn else 0.0 + f1 = 2 * precision * recall / (precision + recall) if precision + recall else 0.0 + return {"precision": precision, "recall": recall, "f1": f1} + + +def pruning_loss(cases: list[bool]) -> float: + """剪枝损失率:原文含答案但 L1 未命中 golden doc 的 positive query 占比 + + cases 中每个元素表示一条 positive query 是否被上层剪掉(True = 被剪)。 + """ + if not cases: + return 0.0 + return sum(cases) / len(cases) + + +def precision_at_k(hit_doc_ids: list[str], golden: set[str], k: int) -> float: + """Precision@k:前 k 条命中中 doc_id 属于 golden 的比例(按命中条数计,不去重)""" + if k <= 0: + return 0.0 + top = hit_doc_ids[:k] + if not top: + return 0.0 + hits = sum(1 for doc_id in top if doc_id in golden) + return hits / k + + +def recall_at_k(hit_doc_ids: list[str], golden: set[str], k: int) -> float: + """Recall@k:前 k 条命中覆盖的 golden doc 比例(按 doc 去重);golden 为空返回 0.0""" + if not golden: + return 0.0 + found = {doc_id for doc_id in hit_doc_ids[:k] if doc_id in golden} + return len(found) / len(golden) + + +def aggregate(records: list[dict[str, float]]) -> dict[str, float]: + """按 key 汇总均值:对记录列表中每个指标求算术平均 + + 某 key 只在部分记录中出现时,按出现的记录取均值;空列表返回 {}。 + """ + if not records: + return {} + keys = list(dict.fromkeys(key for record in records for key in record)) + result: dict[str, float] = {} + for key in keys: + values = [record[key] for record in records if key in record] + result[key] = sum(values) / len(values) if values else 0.0 + return result diff --git a/scripts/eval/regression_set.json b/scripts/eval/regression_set.json new file mode 100644 index 0000000..d923b95 --- /dev/null +++ b/scripts/eval/regression_set.json @@ -0,0 +1,73 @@ +{ + "documents": [ + { + "id": "doc001", + "title": "QMDSearch 向量检索服务部署运维手册", + "text": "# 部署概述\nQMDSearch v1.2.0 依赖 Qdrant 1.12、Redis 7.2 与 Ollama 0.5 三个组件,默认端口分别为 6333、6379 与 11434。生产环境推荐使用 docker compose 一键拉起,健康检查路径为 /health,首次启动约需 30 秒完成集合初始化。\n# 关键配置\nembedding_dimension 默认 1536,sparse_enabled 默认开启。按照《运维规范》第三条要求:修改 retrieval_top_k 或 l1_doc_top_n 后必须重启服务,并执行回归冒烟用例。\n# 故障排查\n若 Qdrant 连接超时,先检查容器网络 qmd-net 是否互通;Ollama 拉取模型失败时重试 ollama pull qwen2.5:1.5b;Redis 不可用时会自动降级为直连检索,不影响主流程。", + "golden_category": "技术文档" + }, + { + "id": "doc002", + "title": "Embedding 服务接入指南", + "text": "# 接入方式\nEmbeddingService 统一接口支持 openai 与 local 两种 provider。openai provider 默认模型 text-embedding-3-small,走 OpenAI 兼容 API;local provider 调用 Ollama 的 /api/embed 接口,默认模型 bge-m3,超时 60 秒。\n# 批量调用\nembed(texts) 支持批量编码,传入空列表时直接返回空列表。单次批量建议不超过 64 条文本,超长文本建议先按 800 字符切分。\n# 维度校验\n返回向量维度与 settings.embedding_dimension 不一致时仅记录 warning 不抛错,且每次调用最多提示一次,避免日志刷屏。", + "golden_category": "技术文档" + }, + { + "id": "doc003", + "title": "智能搜索助手 Pro 2.0 产品说明书", + "text": "# 产品简介\n智能搜索助手 Pro 2.0 面向企业知识库场景,单机支持 200 人并发查询,平均响应时间 350 毫秒,P99 延迟不超过 1.2 秒。\n# 核心功能\n产品支持分层摘要检索、类目路由与平铺全文检索三种模式,可按租户切换。第 5 章介绍高级筛选语法,更多示例见《用户操作手册》。\n# 版本记录\n2.0 版新增 RRF 融合排序与 sparse 稀疏检索;1.8 版的旧版筛选语法兼容至 2026 年 12 月 31 日,之后停止维护。", + "golden_category": "产品手册" + }, + { + "id": "doc004", + "title": "云文档协作平台快速上手指南", + "text": "# 快速开始\n注册账号后 3 分钟内即可创建首个知识空间,免费额度包含 5GB 存储与每月 1000 次 API 调用,超出后按 0.01 元每次计费。\n# 协作功能\n平台支持多人实时编辑、行内评论与版本回滚,历史版本默认保留 90 天,企业版可延长至 365 天。详见《协作白皮书》第二章。\n# 移动端\niOS 与 Android 客户端支持离线缓存,单文件上限 200MB,弱网环境下自动启用增量同步。", + "golden_category": "产品手册" + }, + { + "id": "doc005", + "title": "差旅费用报销管理办法", + "text": "# 适用范围\n本办法适用于全体正式员工与实习生,自 2026 年 1 月 1 日起施行,原 2024 版办法同时废止。\n# 报销标准\n一线城市住宿限额每晚 500 元,二线城市 400 元,其他城市 300 元;高铁二等座、飞机经济舱据实报销。第十条规定:超标部分需部门总监书面审批。\n# 报销流程\n发票开具后 30 日内须提交 OA 系统,超过 90 天的票据不予受理。常见问题见《财务报销常见问题》第七条。", + "golden_category": "财务行政" + }, + { + "id": "doc006", + "title": "固定资产采购与领用规定", + "text": "# 采购审批\n单笔金额超过 5000 元的采购须走 OA 审批流,超过 20000 元需分管副总裁签字,紧急采购可先邮件报备后补流程。\n# 领用登记\n固定资产领用后 3 个工作日内在行政系统登记资产编号,编号规则见《资产管理细则》第四条,笔记本等移动设备须加贴防伪标签。\n# 盘点与报废\n每年 12 月进行年度盘点,报废资产须填写 FD-07 表单并附照片存档,残值率按 5% 计提。", + "golden_category": "财务行政" + } + ], + "queries": [ + {"query": "QMDSearch 依赖哪些组件,分别用什么端口?", "type": "positive", "golden_doc_id": "doc001", "golden_section": "部署概述"}, + {"query": "修改 retrieval_top_k 之后需要做什么?", "type": "positive", "golden_doc_id": "doc001", "golden_section": "关键配置"}, + {"query": "Ollama 拉取模型失败应该怎么处理?", "type": "positive", "golden_doc_id": "doc001", "golden_section": "故障排查"}, + {"query": "embedding_dimension 的默认值是多少?", "type": "positive", "golden_doc_id": "doc001", "golden_section": "关键配置"}, + {"query": "公司组织年度体检的医院是哪家?", "type": "negative", "golden_doc_id": null}, + + {"query": "local provider 默认使用哪个嵌入模型?", "type": "positive", "golden_doc_id": "doc002", "golden_section": "接入方式"}, + {"query": "embed 接口传入空列表会返回什么?", "type": "positive", "golden_doc_id": "doc002", "golden_section": "批量调用"}, + {"query": "向量维度和配置不一致时会抛异常吗?", "type": "positive", "golden_doc_id": "doc002", "golden_section": "维度校验"}, + {"query": "如何申请欧洲申根旅游签证?", "type": "negative", "golden_doc_id": null}, + + {"query": "智能搜索助手 Pro 2.0 支持多少并发查询?", "type": "positive", "golden_doc_id": "doc003", "golden_section": "产品简介"}, + {"query": "高级筛选语法在产品说明书的哪一章介绍?", "type": "positive", "golden_doc_id": "doc003", "golden_section": "核心功能"}, + {"query": "旧版筛选语法兼容到什么时候?", "type": "positive", "golden_doc_id": "doc003", "golden_section": "版本记录"}, + {"query": "竞品的按年订阅价格是多少?", "type": "negative", "golden_doc_id": null}, + + {"query": "云文档平台免费额度包含多少存储空间?", "type": "positive", "golden_doc_id": "doc004", "golden_section": "快速开始"}, + {"query": "历史版本默认保留多长时间?", "type": "positive", "golden_doc_id": "doc004", "golden_section": "协作功能"}, + {"query": "移动端单文件上传上限是多少?", "type": "positive", "golden_doc_id": "doc004", "golden_section": "移动端"}, + {"query": "视频会议最多支持多少人同时开启摄像头?", "type": "negative", "golden_doc_id": null}, + + {"query": "一线城市住宿报销限额是多少?", "type": "positive", "golden_doc_id": "doc005", "golden_section": "报销标准"}, + {"query": "发票超过多少天就不能报销了?", "type": "positive", "golden_doc_id": "doc005", "golden_section": "报销流程"}, + {"query": "住宿超标部分需要谁审批?", "type": "positive", "golden_doc_id": "doc005", "golden_section": "报销标准"}, + {"query": "新版差旅报销办法什么时候开始施行?", "type": "positive", "golden_doc_id": "doc005", "golden_section": "适用范围"}, + {"query": "员工结婚礼金的公司福利标准是多少?", "type": "negative", "golden_doc_id": null}, + + {"query": "采购金额超过多少需要副总裁签字?", "type": "positive", "golden_doc_id": "doc006", "golden_section": "采购审批"}, + {"query": "资产编号规则在哪个文件里规定的?", "type": "positive", "golden_doc_id": "doc006", "golden_section": "领用登记"}, + {"query": "报废资产需要填写什么表单?", "type": "positive", "golden_doc_id": "doc006", "golden_section": "盘点与报废"}, + {"query": "办公区绿植养护的排班表在哪里查?", "type": "negative", "golden_doc_id": null} + ] +} diff --git a/scripts/eval/run_eval.py b/scripts/eval/run_eval.py new file mode 100644 index 0000000..206d8aa --- /dev/null +++ b/scripts/eval/run_eval.py @@ -0,0 +1,353 @@ +#!/usr/bin/env python3 +"""分层摘要 RAG 离线评测 harness + +流程:回归集 → 独立 _eval 后缀集合入库(Ingester)→ 摘要质量指标(Entity Recall / +幻觉率 / 类目一致性)→ 检索效用指标(Routing F1 / Pruning Loss / Precision@5 / +Recall@10)→ 平铺 chunks baseline 对比 → Markdown 报告(stdout + report.md)。 + +用法: + uv run python scripts/eval/run_eval.py [--regression PATH] [--keep-data] [--no-judge] +""" + +from __future__ import annotations + +import argparse +import asyncio +import json +import sys +from pathlib import Path + +# 脚本直运行时把项目根加入 sys.path,保证可以 import app 与 scripts.eval +_PROJECT_ROOT = Path(__file__).resolve().parents[2] +if str(_PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(_PROJECT_ROOT)) + +import structlog # noqa: E402 + +from app.config import settings # noqa: E402 +from app.core import ingestion as ingestion_mod # noqa: E402 +from app.core import retriever as retriever_mod # noqa: E402 +from app.core.ingestion import Ingester # noqa: E402 +from app.core.retriever import Retriever # noqa: E402 +from app.models.document import DocumentInput # noqa: E402 +from app.models.search import SearchRequest # noqa: E402 +from app.services import qdrant as qdrant_mod # noqa: E402 +from app.services.ollama import OllamaClient # noqa: E402 +from app.services.qdrant import QdrantService # noqa: E402 +from scripts.eval.judge import hallucination_rate, taxonomy_consistency # noqa: E402 +from scripts.eval.metrics import ( # noqa: E402 + aggregate, + entity_recall, + precision_at_k, + pruning_loss, + recall_at_k, + routing_f1, +) + +logger = structlog.get_logger() + +# 评测集合后缀与默认路径 +EVAL_SUFFIX = "_eval" +_SCRIPT_DIR = Path(__file__).resolve().parent +DEFAULT_REGRESSION = _SCRIPT_DIR / "regression_set.json" +REPORT_PATH = _SCRIPT_DIR / "report.md" + +# 门槛(Spec 规定):低于/高于门槛在报告中标红 +THRESHOLD_L1_ER = 0.85 # L1 Entity Recall ≥ 0.85 +THRESHOLD_L3_ER = 0.9 # L3 Entity Recall ≥ 0.9 +THRESHOLD_HALLUCINATION = 0.02 # 幻觉率 < 2% +THRESHOLD_PRUNING_LOSS = 0.08 # Pruning Loss < 8% + + +def _switch_to_eval_collections() -> list[str]: + """把 app 内模块级集合名常量整体切换为 _eval 后缀的评测集合 + + QdrantService / Retriever / Ingester 均在各自模块命名空间引用了集合名常量, + 评测脚本统一改写这些模块属性实现集合隔离,不改动 app 源码。 + 返回评测集合名列表(用于评测结束后清理)。 + """ + eval_names = { + "COLLECTION_L1": f"{qdrant_mod.COLLECTION_L1}{EVAL_SUFFIX}", + "COLLECTION_L2": f"{qdrant_mod.COLLECTION_L2}{EVAL_SUFFIX}", + "COLLECTION_L3": f"{qdrant_mod.COLLECTION_L3}{EVAL_SUFFIX}", + "COLLECTION_CHUNKS": f"{qdrant_mod.COLLECTION_CHUNKS}{EVAL_SUFFIX}", + } + for module in (qdrant_mod, retriever_mod, ingestion_mod): + for name, value in eval_names.items(): + if hasattr(module, name): + setattr(module, name, value) + qdrant_mod.ALL_COLLECTIONS = tuple(eval_names.values()) + sparse_eval = (eval_names["COLLECTION_L1"], eval_names["COLLECTION_CHUNKS"]) + qdrant_mod.SPARSE_COLLECTIONS = sparse_eval + retriever_mod.SPARSE_COLLECTIONS = sparse_eval + # upsert_nodes 按集合名校验层级前缀,需同步替换 + qdrant_mod._NODE_ID_PREFIX = {eval_names["COLLECTION_L2"]: "l2", eval_names["COLLECTION_L3"]: "l3"} + return list(eval_names.values()) + + +async def _check_services(qdrant: QdrantService, ollama: OllamaClient) -> str | None: + """检查 Qdrant / Ollama 连通性,返回错误消息(None 表示正常)""" + try: + await qdrant.client.get_collections() + except Exception as exc: + return f"无法连接 Qdrant({settings.qdrant_host}:{settings.qdrant_port}):{exc}" + if not await ollama.is_available(): + return f"无法连接 Ollama({settings.ollama_base_url}),请确认服务已启动(入库与评测均依赖 Ollama)" + return None + + +async def _l1_candidate_doc_ids(retriever: Retriever, query: str) -> set[str]: + """轻量复现 Retriever 的 L1 路由层:embed → L1 集合 top-N → 候选 doc_id 集合 + + 用于计算 Pruning Loss 与 Routing F1;不套类目过滤,度量纯 L1 向量召回。 + """ + dense = (await retriever.embedding.embed([query]))[0] + sparse = retriever.sparse_encoder.encode(query) if settings.sparse_enabled else None + # 复用 Retriever 内部检索方法(hybrid/dense 按集合能力自动选择) + hits = await retriever._search_collection(retriever_mod.COLLECTION_L1, dense, sparse, settings.l1_doc_top_n, None) + return {(p.payload or {}).get("doc_id", "") for p in hits} - {""} + + +async def _baseline_doc_ids(retriever: Retriever, query: str) -> list[str]: + """平铺 baseline:直接对 chunks 集合做 hybrid top-k(无路由无剪枝),返回命中 doc_id 列表""" + dense = (await retriever.embedding.embed([query]))[0] + sparse = retriever.sparse_encoder.encode(query) if settings.sparse_enabled else None + points = await retriever._search_collection( + retriever_mod.COLLECTION_CHUNKS, dense, sparse, settings.retrieval_top_k, None + ) + return [(p.payload or {}).get("doc_id", "") for p in points] + + +def _mark(ok: bool) -> str: + """门槛判定标记""" + return "✅" if ok else "❌" + + +def _fmt(value: float | None, percent: bool = False) -> str: + """数值格式化;None 表示未评测(judge 跳过)""" + if value is None: + return "N/A" + return f"{value:.1%}" if percent else f"{value:.4f}" + + +def _build_report( + regression_path: Path, + doc_records: list[dict], + query_summary: dict, + hier_metrics: dict[str, float], + baseline_metrics: dict[str, float], + routing: dict[str, float], + prune_loss: float, + judge_enabled: bool, + kept_data: bool, +) -> str: + """组装 Markdown 评测报告""" + lines: list[str] = [ + "# 分层摘要 RAG 评测报告", + "", + f"- 回归集:`{regression_path}`", + f"- 文档数:{len(doc_records)};query 数:{query_summary['total']}" + f"(positive {query_summary['positive']} / negative {query_summary['negative']})", + f"- LLM judge:{'开启' if judge_enabled else '跳过(--no-judge 或 Ollama 不可用)'}", + f"- 评测集合:`*{EVAL_SUFFIX}`({'保留' if kept_data else '已清理'})", + "", + "## 摘要质量(按文档)", + "", + "| 文档 | 标题 | golden 类目 | 实际类目 | L1 Entity Recall | L3 Entity Recall | 幻觉率 | 类目一致 |", + "| --- | --- | --- | --- | --- | --- | --- | --- |", + ] + for record in doc_records: + consistency = record.get("taxonomy_consistency") + lines.append( + f"| {record['id']} | {record['title']} | {record['golden_category']} | {record['category']} " + f"| {_fmt(record['entity_recall_l1'])} | {_fmt(record['entity_recall_l3'])} " + f"| {_fmt(record.get('hallucination_rate'), percent=True)} " + f"| {('是' if consistency else '否') if consistency is not None else 'N/A'} |" + ) + + # 汇总与门槛判定 + er_l1 = aggregate([{"v": r["entity_recall_l1"]} for r in doc_records]).get("v", 0.0) + er_l3 = aggregate([{"v": r["entity_recall_l3"]} for r in doc_records]).get("v", 0.0) + hall_records = [{"v": r["hallucination_rate"]} for r in doc_records if r.get("hallucination_rate") is not None] + hall = aggregate(hall_records).get("v") if hall_records else None + lines += [ + "", + "## 指标汇总与门槛判定", + "", + "| 指标 | 数值 | 门槛 | 判定 |", + "| --- | --- | --- | --- |", + f"| L1 Entity Recall | {_fmt(er_l1)} | ≥ {THRESHOLD_L1_ER} | {_mark(er_l1 >= THRESHOLD_L1_ER)} |", + f"| L3 Entity Recall | {_fmt(er_l3)} | ≥ {THRESHOLD_L3_ER} | {_mark(er_l3 >= THRESHOLD_L3_ER)} |", + f"| Hallucination Rate | {_fmt(hall, percent=True)} | < {THRESHOLD_HALLUCINATION:.0%} " + f"| {_mark(hall < THRESHOLD_HALLUCINATION) if hall is not None else 'N/A'} |", + f"| Pruning Loss | {_fmt(prune_loss, percent=True)} | < {THRESHOLD_PRUNING_LOSS:.0%} " + f"| {_mark(prune_loss < THRESHOLD_PRUNING_LOSS)} |", + "", + "## 检索效用(hierarchical vs 平铺 baseline)", + "", + "| 指标 | 分层检索 | 平铺 baseline |", + "| --- | --- | --- |", + f"| Precision@5 | {_fmt(hier_metrics.get('precision@5', 0.0))} " + f"| {_fmt(baseline_metrics.get('precision@5', 0.0))} |", + f"| Recall@10 | {_fmt(hier_metrics.get('recall@10', 0.0))} | {_fmt(baseline_metrics.get('recall@10', 0.0))} |", + "", + "### 路由层(L1)", + "", + f"- Routing Precision:{_fmt(routing['precision'])}", + f"- Routing Recall:{_fmt(routing['recall'])}", + f"- Routing F1:{_fmt(routing['f1'])}", + f"- Pruning Loss:{_fmt(prune_loss, percent=True)}({_mark(prune_loss < THRESHOLD_PRUNING_LOSS)})", + "", + "### negative query", + "", + f"- 误中(返回了任意结果):{query_summary['negative_false_alarm']} / {query_summary['negative']}", + "", + ] + return "\n".join(lines) + + +async def run(regression_path: Path, keep_data: bool, judge_enabled: bool) -> int: + """评测主流程,返回进程退出码""" + data = json.loads(regression_path.read_text(encoding="utf-8")) + documents: list[dict] = data["documents"] + queries: list[dict] = data["queries"] + + eval_collections = _switch_to_eval_collections() + qdrant = QdrantService() + ollama = OllamaClient() + + # 连通性检查:失败给出中文提示并以退出码 2 结束 + error = await _check_services(qdrant, ollama) + if error: + print(f"错误:{error}", file=sys.stderr) + return 2 + + await qdrant.ensure_collections() + logger.info("评测集合就绪", collections=eval_collections) + + ingester = Ingester(qdrant=qdrant) + retriever = Retriever(qdrant=qdrant) + + id_map: dict[str, str] = {} # 回归集 doc id -> 实际入库 doc_id + doc_records: list[dict] = [] + hier_records: list[dict[str, float]] = [] + baseline_records: list[dict[str, float]] = [] + pruned_cases: list[bool] = [] + golden_sets: list[set[str]] = [] + routed_sets: list[set[str]] = [] + negative_total = 0 + negative_false_alarm = 0 + + try: + # 1. 逐篇入库并计算摘要质量指标 + for doc in documents: + result = await ingester.ingest(DocumentInput(title=doc["title"], text=doc["text"])) + id_map[doc["id"]] = result.document_id + logger.info("文档入库完成", id=doc["id"], doc_id=result.document_id, category=result.category) + + record: dict = { + "id": doc["id"], + "title": doc["title"], + "golden_category": doc["golden_category"], + "category": result.category, + "entity_recall_l1": entity_recall(doc["text"], result.summary.l1_summary), + "entity_recall_l3": entity_recall(doc["text"], result.summary.l3_content_outline), + "hallucination_rate": None, + "taxonomy_consistency": None, + } + if judge_enabled: + record["hallucination_rate"] = await hallucination_rate(result.summary.l1_summary, doc["text"], ollama) + record["taxonomy_consistency"] = await taxonomy_consistency( + result.summary.l1_summary, result.category, ollama + ) + doc_records.append(record) + + # 2. 逐 query 计算检索效用指标(分层检索 + 轻量 L1 路由层 + 平铺 baseline) + for query in queries: + query_text = query["query"] + golden_ids = {id_map[query["golden_doc_id"]]} if query.get("golden_doc_id") else set() + + response = await retriever.search(SearchRequest(query=query_text)) + hit_doc_ids = [hit.doc_id for hit in response.hits] + l1_candidates = await _l1_candidate_doc_ids(retriever, query_text) + + if query["type"] == "positive" and golden_ids: + pruned_cases.append(not golden_ids & l1_candidates) + golden_sets.append(golden_ids) + routed_sets.append(l1_candidates) + hier_records.append( + { + "precision@5": precision_at_k(hit_doc_ids, golden_ids, 5), + "recall@10": recall_at_k(hit_doc_ids, golden_ids, 10), + } + ) + baseline_ids = await _baseline_doc_ids(retriever, query_text) + baseline_records.append( + { + "precision@5": precision_at_k(baseline_ids, golden_ids, 5), + "recall@10": recall_at_k(baseline_ids, golden_ids, 10), + } + ) + else: + # negative query:期望无结果,任何返回均计为误中 + negative_total += 1 + if hit_doc_ids: + negative_false_alarm += 1 + finally: + # 评测集合清理(--keep-data 时保留) + if keep_data: + logger.info("--keep-data 生效,保留评测集合", collections=eval_collections) + else: + for collection in eval_collections: + try: + await qdrant.client.delete_collection(collection) + except Exception as exc: + logger.warning("评测集合清理失败", collection=collection, error=str(exc)) + logger.info("评测集合已清理", collections=eval_collections) + + # 3. 汇总并输出报告 + prune_loss = pruning_loss(pruned_cases) + routing = routing_f1(golden_sets, routed_sets) + hier_metrics = aggregate(hier_records) + baseline_metrics = aggregate(baseline_records) + query_summary = { + "total": len(queries), + "positive": len(queries) - negative_total, + "negative": negative_total, + "negative_false_alarm": negative_false_alarm, + } + report = _build_report( + regression_path, + doc_records, + query_summary, + hier_metrics, + baseline_metrics, + routing, + prune_loss, + judge_enabled, + keep_data, + ) + print(report) + REPORT_PATH.write_text(report + "\n", encoding="utf-8") + logger.info("评测报告已写入", path=str(REPORT_PATH)) + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser( + description="分层摘要 RAG 离线评测:回归集入库 → 摘要质量/检索效用指标 → 平铺 baseline 对比 → Markdown 报告", + ) + parser.add_argument( + "--regression", + type=Path, + default=DEFAULT_REGRESSION, + help=f"回归集 JSON 路径(默认 {DEFAULT_REGRESSION})", + ) + parser.add_argument("--keep-data", action="store_true", help="评测结束后保留 _eval 集合(默认删除)") + parser.add_argument("--no-judge", action="store_true", help="跳过 LLM-as-judge 指标(幻觉率 / 类目一致性)") + args = parser.parse_args() + return asyncio.run(run(args.regression.resolve(), args.keep_data, not args.no_judge)) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/smoke_live.py b/scripts/smoke_live.py new file mode 100644 index 0000000..c6e9d98 --- /dev/null +++ b/scripts/smoke_live.py @@ -0,0 +1,165 @@ +"""真实 Ollama 现场冒烟脚本 + +用法:uv run python scripts/smoke_live.py + +链路:内存 Qdrant + 真实本地 Ollama(deepseek-r1:8b)+ 确定性哈希向量(无语义,仅打通流程)。 +读取回归集第一篇文档走完整 ingest → search 流程,打印各阶段结果、原始 LLM 输出与耗时。 + +deepseek-r1 是推理模型,/api/generate 的 response 字段可能带 思考内容; +classifier / query_parser 内建 JSON 容错,若解析失败会走兜底路径——均为合理的现场观察。 +""" + +import asyncio +import json +import random +import sys +import time +from hashlib import sha256 +from pathlib import Path + +# 脚本直接运行时需要把项目根目录加入 sys.path +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from qdrant_client import AsyncQdrantClient # noqa: E402 + +from app.config import settings # noqa: E402 +from app.core.chunker import Chunker # noqa: E402 +from app.core.classifier import Classifier # noqa: E402 +from app.core.ingestion import Ingester # noqa: E402 +from app.core.query_parser import QueryParser # noqa: E402 +from app.core.retriever import Retriever # noqa: E402 +from app.core.sparse import SparseEncoder # noqa: E402 +from app.core.summarizer import Summarizer # noqa: E402 +from app.models.document import DocumentInput # noqa: E402 +from app.models.knowledge import load_taxonomy # noqa: E402 +from app.models.search import SearchRequest # noqa: E402 +from app.services.ollama import OllamaClient # noqa: E402 +from app.services.qdrant import QdrantService # noqa: E402 + +OLLAMA_MODEL = "deepseek-r1:8b" +OLLAMA_TIMEOUT = 600.0 + + +class DeterministicEmbedding: + """稳定哈希伪向量:同文本恒同向量,维度等于 settings.embedding_dimension(无语义)""" + + async def embed(self, texts: list[str]) -> list[list[float]]: + vectors: list[list[float]] = [] + for text in texts: + seed = int.from_bytes(sha256(text.encode("utf-8")).digest()[:8], "big") + rng = random.Random(seed) + vectors.append([rng.random() for _ in range(settings.embedding_dimension)]) + return vectors + + +class RecordingOllama: + """包装真实 OllamaClient,记录每次调用的原始输出供现场观察""" + + def __init__(self, inner: OllamaClient) -> None: + self.inner = inner + self.records: list[tuple[str, str]] = [] # (调用用途, 原始输出) + + async def generate(self, prompt: str, json_mode: bool = False) -> str: + kind = _prompt_kind(prompt) + raw = await self.inner.generate(prompt, json_mode=json_mode) + self.records.append((kind, raw)) + return raw + + +def _prompt_kind(prompt: str) -> str: + """按 prompt 特征串标注调用用途""" + if "你是知识库分类助手" in prompt: + return "文档分类" + if "你是搜索查询分析助手" in prompt: + return "query 解析" + if "请用一句话对以下文档内容进行高度概括" in prompt: + return "L1 总结" + if "请提取以下文档的主要章节结构" in prompt: + return "L2 大纲" + if "请对以下文档的每个章节/主题进行详细的内容摘要" in prompt: + return "L3 内容大纲" + if "请对以下文档内容进行详细摘要" in prompt: + return "L2.5 摘要" + return "未知" + + +def _fmt(seconds: float) -> str: + return f"{seconds:.1f}s" + + +async def main() -> int: + started = time.perf_counter() + + # 0. 环境检查 + ollama_inner = OllamaClient(base_url="http://localhost:11434", model=OLLAMA_MODEL, timeout=OLLAMA_TIMEOUT) + if not await ollama_inner.is_available(): + print("Ollama 不可用(http://localhost:11434),请先启动 Ollama 服务") + return 1 + print(f"[环境] Ollama 可用,模型 {OLLAMA_MODEL};Qdrant 使用 :memory: 本地模式") + + # 1. 读取回归集第一篇文档与一条相关 query + regression_path = Path(__file__).resolve().parent / "eval" / "regression_set.json" + regression = json.loads(regression_path.read_text(encoding="utf-8")) + doc_data = regression["documents"][0] + query = next(q["query"] for q in regression["queries"] if q.get("golden_doc_id") == doc_data["id"]) + doc = DocumentInput(text=doc_data["text"], title=doc_data["title"]) + print(f"[数据] 文档《{doc.title}》({len(doc.text)} 字符);query:{query}") + + # 2. 组装真实链路(仅 embedding 为确定性伪向量) + qdrant = QdrantService(client=AsyncQdrantClient(location=":memory:")) + await qdrant.ensure_collections() + ollama = RecordingOllama(ollama_inner) + taxonomy = load_taxonomy() + embedding = DeterministicEmbedding() + ingester = Ingester( + summarizer=Summarizer(ollama=ollama), # type: ignore[arg-type] + classifier=Classifier(ollama=ollama, taxonomy=taxonomy), # type: ignore[arg-type] + chunker=Chunker(), + embedding=embedding, + sparse=SparseEncoder(), + qdrant=qdrant, + ) + retriever = Retriever( + qdrant=qdrant, + query_parser=QueryParser(ollama=ollama, taxonomy=taxonomy), # type: ignore[arg-type] + embedding=embedding, + sparse_encoder=SparseEncoder(), + ) + + # 3. 入库 + t0 = time.perf_counter() + result = await ingester.ingest(doc) + t_ingest = time.perf_counter() - t0 + print(f"\n[入库] 耗时 {_fmt(t_ingest)}") + print(f" 总结层级: {result.summary.level.value}") + print(f" L1 总结: {result.summary.l1_summary[:100]}") + print(f" L2 大纲: {(result.summary.l2_outline or '(None,2.5 级回退)')[:100]}") + print(f" 分类: {result.category} (置信度 {result.category_confidence:.2f}),tags={result.tags}") + print(f" chunks 数: {result.chunks_count}") + + # 4. 检索 + t0 = time.perf_counter() + response = await retriever.search(SearchRequest(query=query)) + t_search = time.perf_counter() - t0 + print(f"\n[检索] 耗时 {_fmt(t_search)}") + print(f" routed_categories={response.routed_categories},fallback={response.fallback}") + print(f" hits 数: {len(response.hits)}") + if response.hits: + hit = response.hits[0] + print(f" 首条 hit: section_path={hit.section_path!r},score={hit.score:.4f}") + print(f" 首条 hit.doc_summary 前 50 字: {hit.doc_summary[:50]!r}") + print(f" 首条 hit.text 前 50 字: {hit.text[:50]!r}") + + # 5. r1 模型原始输出观察(thinking / JSON 表现) + print("\n[Ollama 原始输出摘录](r1 推理模型的 JSON 表现现场观察)") + for kind, raw in ollama.records: + excerpt = raw.strip().replace("\n", " ")[:200] + has_think = "" in raw + print(f" - {kind}: 长度 {len(raw)},含 ={has_think},输出摘录: {excerpt!r}") + + print(f"\n[总耗时] {_fmt(time.perf_counter() - started)}") + return 0 + + +if __name__ == "__main__": + sys.exit(asyncio.run(main())) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_admin_integration.py b/tests/test_admin_integration.py new file mode 100644 index 0000000..014890b --- /dev/null +++ b/tests/test_admin_integration.py @@ -0,0 +1,329 @@ +"""管理闭环集成测试(Spec Task 7:集成验证) + +两部分覆盖,均不修改 app/ 与 scripts/ 实现代码: + +1. TestAdminClosedLoop:真实内存 Qdrant(location=":memory:")+ TestClient 走完整管理闭环 + 列表 → 详情 → stats → 删除 → 删除后校验 → 幂等再删。 + document.py 与 knowledge.py 的 _get_qdrant 单例均 monkeypatch 指向同一内存服务, + 数据直接经 QdrantService.upsert_* 写入(L1/L2/L3/chunks 四层齐全)。 + +2. TestAdminPageContract:读取 app/static/admin.html 文本做静态断言, + 校验页面 fetch 路径全部落在后端真实路由集合内、页面引用的响应字段名抽样 + 存在于后端模型/路由代码中,防止前后端契约漂移。 +""" + +import re +from pathlib import Path +from typing import Any + +import pytest +from fastapi.routing import APIRoute +from fastapi.testclient import TestClient +from qdrant_client import AsyncQdrantClient + +from app.api.v1 import document as document_module +from app.api.v1 import knowledge as knowledge_module +from app.config import settings +from app.main import app +from app.services.qdrant import ( + COLLECTION_CHUNKS, + COLLECTION_L1, + COLLECTION_L2, + COLLECTION_L3, + QdrantService, +) + +DIM = settings.embedding_dimension + +# 文档 A:类目「技术文档」,L2 x2 / L3 x3 / chunks x4 +DOC_A = "doc-a-tech" +DOC_A_TITLE = "安装指南" +DOC_A_SUMMARY = "安装指南的一句话总结" +DOC_A_CATEGORY = "技术文档" +DOC_A_TAGS = ["安装", "运维"] +DOC_A_L2_PATHS = ["安装指南", "安装指南 / 环境准备"] +DOC_A_L3_PATHS = ["安装指南", "安装指南 / 环境准备", "安装指南 / 安装步骤"] +DOC_A_CHUNKS = 4 + +# 文档 B:类目「uncategorized」,L2 x1 / L3 x2 / chunks x3 +DOC_B = "doc-b-misc" +DOC_B_TITLE = "随手记" +DOC_B_SUMMARY = "无法归类的随手记录" +DOC_B_CATEGORY = "uncategorized" +DOC_B_L2_PATHS = ["随手记"] +DOC_B_L3_PATHS = ["随手记", "随手记 / 杂项"] +DOC_B_CHUNKS = 3 + +# admin.html 与项目根路径 +_PROJECT_ROOT = Path(__file__).resolve().parent.parent +_ADMIN_HTML_PATH = _PROJECT_ROOT / "app" / "static" / "admin.html" + +# 页面引用的响应字段抽样 -> 应包含该字段的后端模型/路由文件(相对项目根) +_CONTRACT_FIELD_FILES = { + "routed_categories": "app/models/search.py", + "fallback": "app/models/search.py", + "chunks_count": "app/models/document.py", + "deleted_total": "app/api/v1/document.py", + "next_offset": "app/api/v1/document.py", + "uncategorized_count": "app/api/v1/knowledge.py", + "documents_total": "app/api/v1/knowledge.py", +} + + +def _backend_paths() -> set[str]: + """展平 app.routes,收集后端真实路由路径 + + 兼容两种挂载形态:APIRoute 直接平铺,以及 include_router 产生的 + 嵌套代理对象(其路由在 original_router.routes 上)。 + """ + paths: set[str] = set() + stack: list[Any] = list(app.routes) + while stack: + route = stack.pop() + if isinstance(route, APIRoute): + paths.add(route.path) + continue + stack.extend(getattr(route, "routes", []) or []) + inner = getattr(route, "original_router", None) + if inner is not None: + stack.extend(getattr(inner, "routes", [])) + return paths + + +def _dense(seed: float) -> list[float]: + """构造确定性 dense 向量:前 4 维取特征值,便于区分不同点""" + vec = [0.0] * DIM + vec[0] = seed + vec[1] = 1.0 - seed + vec[2] = seed * 0.5 + vec[3] = 0.1 + return vec + + +def _node(doc_id: str, section_path: str, category: str, tags: list[str], seed: float) -> dict[str, Any]: + """构造一个 L2/L3 大纲节点""" + return { + "doc_id": doc_id, + "section_path": section_path, + "text": f"{section_path} 的节点内容", + "category": category, + "tags": tags, + "dense_vector": _dense(seed), + } + + +def _chunk(doc_id: str, index: int, title: str, category: str, tags: list[str]) -> dict[str, Any]: + """构造一个原文 chunk""" + return { + "doc_id": doc_id, + "chunk_index": index, + "text": f"{doc_id} 的第 {index} 个 chunk", + "section_path": f"section-{index}", + "title": title, + "category": category, + "tags": tags, + "dense_vector": _dense(0.5 + index * 0.05), + } + + +async def _seed_documents(service: QdrantService) -> None: + """写入两篇四层结构完整的文档:A「技术文档」、B「uncategorized」""" + await service.upsert_l1( + doc_id=DOC_A, + title=DOC_A_TITLE, + summary=DOC_A_SUMMARY, + category=DOC_A_CATEGORY, + tags=DOC_A_TAGS, + dense_vector=_dense(0.9), + ) + await service.upsert_nodes( + COLLECTION_L2, [_node(DOC_A, path, DOC_A_CATEGORY, DOC_A_TAGS, 0.8) for path in DOC_A_L2_PATHS] + ) + await service.upsert_nodes( + COLLECTION_L3, [_node(DOC_A, path, DOC_A_CATEGORY, DOC_A_TAGS, 0.7) for path in DOC_A_L3_PATHS] + ) + await service.upsert_chunks( + [_chunk(DOC_A, i, DOC_A_TITLE, DOC_A_CATEGORY, DOC_A_TAGS) for i in range(DOC_A_CHUNKS)] + ) + + await service.upsert_l1( + doc_id=DOC_B, + title=DOC_B_TITLE, + summary=DOC_B_SUMMARY, + category=DOC_B_CATEGORY, + tags=[], + dense_vector=_dense(0.6), + ) + await service.upsert_nodes( + COLLECTION_L2, [_node(DOC_B, path, DOC_B_CATEGORY, [], 0.55) for path in DOC_B_L2_PATHS] + ) + await service.upsert_nodes( + COLLECTION_L3, [_node(DOC_B, path, DOC_B_CATEGORY, [], 0.5) for path in DOC_B_L3_PATHS] + ) + await service.upsert_chunks([_chunk(DOC_B, i, DOC_B_TITLE, DOC_B_CATEGORY, []) for i in range(DOC_B_CHUNKS)]) + + +class TestAdminClosedLoop: + """文档管理 API + stats 的完整闭环(真实内存 Qdrant + TestClient)""" + + async def test_document_admin_closed_loop(self, monkeypatch: pytest.MonkeyPatch) -> None: + service = QdrantService(client=AsyncQdrantClient(location=":memory:")) + await service.ensure_collections() + await _seed_documents(service) + + # lifespan 的集合初始化替换为空操作;两个路由模块的 _get_qdrant 指向同一内存服务 + async def _noop_ensure_collections(self: QdrantService) -> None: + return None + + monkeypatch.setattr(QdrantService, "ensure_collections", _noop_ensure_collections) + monkeypatch.setattr(document_module, "_get_qdrant", lambda: service) + monkeypatch.setattr(knowledge_module, "_get_qdrant", lambda: service) + + with TestClient(app) as client: + # 1. 列表:items 数=2,字段完整 + resp = client.get("/api/v1/documents") + assert resp.status_code == 200 + body = resp.json() + assert body["code"] == 0 + data = body["data"] + assert set(data.keys()) == {"items", "next_offset"} + items = data["items"] + assert len(items) == 2 + assert data["next_offset"] is None + by_id = {item["doc_id"]: item for item in items} + assert set(by_id) == {DOC_A, DOC_B} + item_a = by_id[DOC_A] + assert set(item_a.keys()) == {"doc_id", "title", "category", "tags", "summary"} + assert item_a["title"] == DOC_A_TITLE + assert item_a["category"] == DOC_A_CATEGORY + assert item_a["tags"] == DOC_A_TAGS + assert item_a["summary"] == DOC_A_SUMMARY + item_b = by_id[DOC_B] + assert item_b["category"] == DOC_B_CATEGORY + assert item_b["tags"] == [] + + # 2. 详情:l1/l2_nodes/l3_nodes/chunks_count 与写入一致 + resp = client.get(f"/api/v1/documents/{DOC_A}") + assert resp.status_code == 200 + data = resp.json()["data"] + assert set(data.keys()) == {"l1", "l2_nodes", "l3_nodes", "chunks_count"} + l1 = data["l1"] + assert l1["doc_id"] == DOC_A + assert l1["title"] == DOC_A_TITLE + assert l1["text"] == DOC_A_SUMMARY + assert l1["category"] == DOC_A_CATEGORY + assert l1["tags"] == DOC_A_TAGS + assert len(data["l2_nodes"]) == len(DOC_A_L2_PATHS) + assert {n["section_path"] for n in data["l2_nodes"]} == set(DOC_A_L2_PATHS) + assert len(data["l3_nodes"]) == len(DOC_A_L3_PATHS) + assert {n["section_path"] for n in data["l3_nodes"]} == set(DOC_A_L3_PATHS) + assert data["chunks_count"] == DOC_A_CHUNKS + + # 3. stats:documents_total=2、类目分布与四层点数正确 + resp = client.get("/api/v1/knowledge/stats") + assert resp.status_code == 200 + data = resp.json()["data"] + assert set(data.keys()) == {"collections", "categories", "uncategorized_count", "documents_total"} + assert data["documents_total"] == 2 + assert data["categories"] == {DOC_A_CATEGORY: 1, DOC_B_CATEGORY: 1} + assert data["uncategorized_count"] == 1 + assert data["collections"] == { + COLLECTION_L1: 2, + COLLECTION_L2: len(DOC_A_L2_PATHS) + len(DOC_B_L2_PATHS), + COLLECTION_L3: len(DOC_A_L3_PATHS) + len(DOC_B_L3_PATHS), + COLLECTION_CHUNKS: DOC_A_CHUNKS + DOC_B_CHUNKS, + } + + # 4. 删除文档 A:deleted_total > 0,各集合删除数与写入一致 + resp = client.delete(f"/api/v1/documents/{DOC_A}") + assert resp.status_code == 200 + body = resp.json() + assert body["code"] == 0 + data = body["data"] + assert set(data.keys()) == {"doc_id", "deleted", "deleted_total"} + assert data["doc_id"] == DOC_A + assert data["deleted"] == { + COLLECTION_L1: 1, + COLLECTION_L2: len(DOC_A_L2_PATHS), + COLLECTION_L3: len(DOC_A_L3_PATHS), + COLLECTION_CHUNKS: DOC_A_CHUNKS, + } + assert data["deleted_total"] == 1 + len(DOC_A_L2_PATHS) + len(DOC_A_L3_PATHS) + DOC_A_CHUNKS > 0 + + # 5. 删除后校验:详情 1004、stats 只剩 1 篇、文档 B 不受影响 + resp = client.get(f"/api/v1/documents/{DOC_A}") + body = resp.json() + assert body["code"] == 1004 + assert body["data"] is None + + resp = client.get("/api/v1/knowledge/stats") + data = resp.json()["data"] + assert data["documents_total"] == 1 + assert data["categories"] == {DOC_B_CATEGORY: 1} + assert data["uncategorized_count"] == 1 + assert data["collections"] == { + COLLECTION_L1: 1, + COLLECTION_L2: len(DOC_B_L2_PATHS), + COLLECTION_L3: len(DOC_B_L3_PATHS), + COLLECTION_CHUNKS: DOC_B_CHUNKS, + } + + resp = client.get(f"/api/v1/documents/{DOC_B}") + body = resp.json() + assert body["code"] == 0 + data = body["data"] + assert data["l1"]["doc_id"] == DOC_B + assert len(data["l2_nodes"]) == len(DOC_B_L2_PATHS) + assert len(data["l3_nodes"]) == len(DOC_B_L3_PATHS) + assert data["chunks_count"] == DOC_B_CHUNKS + + resp = client.get("/api/v1/documents") + items = resp.json()["data"]["items"] + assert [item["doc_id"] for item in items] == [DOC_B] + + # 6. 幂等再删:code=0 且 deleted_total=0,各集合删除数全 0 + resp = client.delete(f"/api/v1/documents/{DOC_A}") + body = resp.json() + assert body["code"] == 0 + data = body["data"] + all_collections = (COLLECTION_L1, COLLECTION_L2, COLLECTION_L3, COLLECTION_CHUNKS) + assert data["deleted"] == {name: 0 for name in all_collections} + assert data["deleted_total"] == 0 + + +class TestAdminPageContract: + """admin.html 与后端 API 的契约一致性(静态断言,防契约漂移)""" + + def test_fetch_paths_in_backend_routes(self) -> None: + """HTML 中所有 /api/v1/ URL 字面量都在后端真实路由集合内""" + html = _ADMIN_HTML_PATH.read_text(encoding="utf-8") + # 提取所有以 /api/v1/ 开头的字符串字面量(覆盖 fetch()/api() 调用与路径变量) + literals = set(re.findall(r"""["'](/api/v1/[^"']*)["']""", html)) + + # 页面至少应覆盖这 5 条契约路径(缺失说明提取失效或页面能力回退) + expected = { + "/api/v1/search", + "/api/v1/documents", + "/api/v1/documents/", # 详情/删除:拼接 doc_id 的前缀形式 + "/api/v1/knowledge/stats", + "/api/v1/knowledge/categories", + } + assert expected <= literals + + backend_paths = _backend_paths() + for literal in sorted(literals): + path = literal.split("?", 1)[0] # 去掉查询串 + if path in backend_paths: + continue + # 以 / 结尾的前缀(如 /api/v1/documents/)应对应带路径参数的路由 + if path.endswith("/") and any(p.startswith(path) for p in backend_paths): + continue + pytest.fail(f"页面 fetch 路径不在后端路由集合内: {literal}") + + @pytest.mark.parametrize(("field", "source_file"), sorted(_CONTRACT_FIELD_FILES.items())) + def test_response_fields_in_backend_code(self, field: str, source_file: str) -> None: + """页面引用的响应字段名:既在 HTML 中出现,也在后端模型/路由代码中存在""" + html = _ADMIN_HTML_PATH.read_text(encoding="utf-8") + assert field in html, f"契约字段未在页面中引用: {field}" + source = (_PROJECT_ROOT / source_file).read_text(encoding="utf-8") + assert field in source, f"契约字段未在后端代码中定义: {field} ({source_file})" diff --git a/tests/test_admin_page.py b/tests/test_admin_page.py new file mode 100644 index 0000000..71fb2a6 --- /dev/null +++ b/tests/test_admin_page.py @@ -0,0 +1,125 @@ +"""管理页面挂载测试(GET /admin) + +覆盖: +1. 路由存在性:200 + content-type 为 text/html +2. 五区块可识别标记(文案与 section id) +3. 零外部依赖:无 http(s) 外链资源、无 CDN 引用 +4. fetch 调用路径与后端 API 契约一致 +5. 删除操作的 confirm() 二次确认逻辑 +""" + +import re +from collections.abc import Iterator + +import pytest +from fastapi.testclient import TestClient + +from app.main import app +from app.services.qdrant import QdrantService + + +@pytest.fixture +def client(monkeypatch: pytest.MonkeyPatch) -> Iterator[TestClient]: + """TestClient,lifespan 中的 Qdrant 集合初始化替换为空操作""" + + async def _noop_ensure_collections(self: QdrantService) -> None: + return None + + monkeypatch.setattr(QdrantService, "ensure_collections", _noop_ensure_collections) + with TestClient(app) as test_client: + yield test_client + + +@pytest.fixture +def admin_html(client: TestClient) -> str: + """请求 /admin 并返回 HTML 文本(前置断言 200)""" + resp = client.get("/admin") + assert resp.status_code == 200 + return resp.text + + +def test_admin_page_ok(client: TestClient) -> None: + """GET /admin → 200,content-type 含 text/html""" + resp = client.get("/admin") + + assert resp.status_code == 200 + assert "text/html" in resp.headers["content-type"] + + +def test_admin_page_has_five_sections(admin_html: str) -> None: + """HTML 含五个区块的文案与对应 section id""" + for section_id in ( + "section-overview", + "section-docs", + "section-ingest", + "section-search", + "section-categories", + ): + assert section_id in admin_html + for label in ("概览", "文档管理", "文档入库", "检索测试台", "类目列表"): + assert label in admin_html + + +def test_admin_page_no_external_resources(admin_html: str) -> None: + """零外部依赖:无 src/href http(s) 外链,无 CDN 引用""" + assert re.search(r'src=["\']https?://', admin_html) is None + assert re.search(r'href=["\']https?://', admin_html) is None + assert re.search(r"//cdn", admin_html) is None + + +def test_admin_page_fetch_paths(admin_html: str) -> None: + """fetch 调用路径与后端 API 契约一致""" + for path in ( + "/api/v1/search", + "/api/v1/documents", + "/api/v1/knowledge/stats", + "/api/v1/knowledge/categories", + ): + assert path in admin_html + + +def _collect_route_paths(routes: list) -> set[str]: + """递归收集路由路径(FastAPI 0.140+ include_router 包装为 _IncludedRouter)""" + paths: set[str] = set() + for route in routes: + path = getattr(route, "path", None) + if path: + paths.add(path) + sub_router = getattr(route, "original_router", None) + if sub_router is not None: + paths |= _collect_route_paths(sub_router.routes) + return paths + + +def test_admin_page_fetch_paths_in_real_routes(admin_html: str) -> None: + """页面 api() 调用路径均在真实后端路由集合内(含新增的 tasks 路径)""" + route_paths = _collect_route_paths(app.routes) + assert "/api/v1/documents/tasks/{task_id}" in route_paths + + prefixes = set(re.findall(r'api\("([^"?]+)', admin_html)) + assert prefixes, "页面应包含 api() 调用" + for prefix in prefixes: + assert any(path == prefix or path.startswith(prefix) for path in route_paths), ( + f"页面 fetch 路径 {prefix} 不在后端路由集合内" + ) + + +def test_admin_page_ingest_polling(admin_html: str) -> None: + """入库区块:异步任务轮询逻辑标记""" + # 轮询任务状态端点 + assert "/api/v1/documents/tasks/" in admin_html + # 状态中文映射 + for text in ("排队中", "总结中", "分类中", "向量化中", "写入中", "完成", "失败"): + assert text in admin_html + # 提交确认与超时提示 + assert "任务已提交" in admin_html + assert "任务仍在进行,可稍后凭 task_id 查询" in admin_html + # 5 分钟超时:150 次 × 2s + assert "150" in admin_html + # 防重复提交:轮询中禁用提交按钮 + assert "disabled" in admin_html + + +def test_admin_page_has_confirm(admin_html: str) -> None: + """删除操作包含 confirm() 二次确认逻辑""" + assert "confirm(" in admin_html diff --git a/tests/test_chunker.py b/tests/test_chunker.py new file mode 100644 index 0000000..8d610cf --- /dev/null +++ b/tests/test_chunker.py @@ -0,0 +1,130 @@ +"""Chunker 切分器的单元测试""" + +from app.core.chunker import Chunker + + +class TestStructuredChunking: + """结构化文档按标题树切分""" + + def test_split_by_sections_and_section_path(self): + """按 section 切分,section_path 为祖先标题链""" + text = ( + "# 安装指南\n这是简介内容,介绍安装流程。\n\n" + "## 环境准备\n准备 Python 环境与依赖。\n\n" + "## 安装步骤\n执行安装命令完成部署。" + ) + chunks = Chunker(max_chars=30).chunk(text, "doc-1") + + assert [c.chunk_index for c in chunks] == [0, 1, 2] + assert [c.section_path for c in chunks] == ["安装指南", "安装指南 / 环境准备", "安装指南 / 安装步骤"] + # chunk 文本含所属标题行本身 + assert chunks[0].text.startswith("# 安装指南") + assert chunks[1].text.startswith("## 环境准备") + assert chunks[2].text.startswith("## 安装步骤") + assert all(c.doc_id == "doc-1" for c in chunks) + + def test_preamble_before_first_heading(self): + """首个标题前的引导正文归入空 section_path 的 chunk""" + text = "无标题的引导内容,描述文档主题。\n# 第一章\n章节正文内容。" + chunks = Chunker(max_chars=20).chunk(text, "doc-1") + + assert len(chunks) == 2 + assert chunks[0].section_path == "" + assert chunks[0].text == "无标题的引导内容,描述文档主题。" + assert chunks[1].section_path == "第一章" + + def test_sibling_sections_reset_path(self): + """同级标题弹栈,section_path 不含上一分支""" + text = "# 甲\n内容甲。\n## 甲一\n内容甲一。\n# 乙\n内容乙。" + chunks = Chunker(max_chars=20).chunk(text, "doc-1") + + assert [c.section_path for c in chunks] == ["甲", "甲 / 甲一", "乙"] + + +class TestLongSectionSplitting: + """超长 section 二次切分""" + + def test_long_section_split_by_paragraphs(self): + """超长 section 按空行段落累加切分,同 section 的 chunk 共享 section_path""" + para1 = "第一段内容," * 6 # 36 字符 + para2 = "第二段内容," * 6 + text = f"# 大章节\n{para1}\n\n{para2}" + chunks = Chunker(max_chars=60).chunk(text, "doc-1") + + assert len(chunks) == 2 + assert [c.chunk_index for c in chunks] == [0, 1] + assert all(c.section_path == "大章节" for c in chunks) + assert all(len(c.text) <= 60 for c in chunks) + assert chunks[0].text.startswith("# 大章节") + assert para1 in chunks[0].text + assert para2 in chunks[1].text + + def test_long_paragraph_hard_split(self): + """单段落仍超长时按 max_chars 硬切""" + long_para = "长" * 150 + text = f"# 章节\n{long_para}" + chunks = Chunker(max_chars=50).chunk(text, "doc-1") + + # section 文本为 "# 章节\n" + 150 字 = 156 字符,硬切为 4 段 + assert len(chunks) == 4 + assert [c.chunk_index for c in chunks] == [0, 1, 2, 3] + assert all(len(c.text) <= 50 for c in chunks) + assert all(c.section_path == "章节" for c in chunks) + assert chunks[0].text.startswith("# 章节") + + +class TestPlainTextChunking: + """无结构文本按段落切分""" + + def test_split_by_paragraphs(self): + """按空行段落累加切分,section_path 为空""" + para = "段落内容," * 5 # 25 字符 + text = f"{para}\n\n{para}\n\n{para}" + chunks = Chunker(max_chars=55).chunk(text, "doc-1") + + # 两段累加 52 字符 ≤ 55,再加一段超限,故切为 2 块 + assert len(chunks) == 2 + assert [c.chunk_index for c in chunks] == [0, 1] + assert all(c.section_path == "" for c in chunks) + assert all(len(c.text) <= 55 for c in chunks) + + def test_long_plain_text_hard_split(self): + """无结构单段落超长时硬切""" + text = "字" * 120 + chunks = Chunker(max_chars=50).chunk(text, "doc-1") + + assert len(chunks) == 3 + assert all(len(c.text) <= 50 for c in chunks) + assert all(c.section_path == "" for c in chunks) + + +class TestShortAndEmptyText: + """短文本与空文本""" + + def test_short_text_single_chunk(self): + """全文不超过 max_chars 时整篇单 chunk""" + chunks = Chunker(max_chars=100).chunk("# 标题\n短文本内容", "doc-1") + + assert len(chunks) == 1 + assert chunks[0].chunk_index == 0 + assert chunks[0].text == "# 标题\n短文本内容" + assert chunks[0].section_path == "" + + def test_empty_text_returns_empty(self): + assert Chunker(max_chars=100).chunk("", "doc-1") == [] + assert Chunker(max_chars=100).chunk(" \n ", "doc-1") == [] + + +class TestChunkIndexContinuity: + """chunk_index 跨 section 连续递增""" + + def test_indices_continuous_across_sections(self): + para = "内容段落," * 6 # 30 字符 + text = f"# 第一章\n{para}\n\n{para}\n# 第二章\n{para}\n\n{para}" + chunks = Chunker(max_chars=50).chunk(text, "doc-1") + + # 每个 section(标题行 + 两段)超 50,各切为 2 块,共 4 块 + assert len(chunks) == 4 + assert [c.chunk_index for c in chunks] == [0, 1, 2, 3] + assert chunks[0].section_path == chunks[1].section_path == "第一章" + assert chunks[2].section_path == chunks[3].section_path == "第二章" diff --git a/tests/test_classifier.py b/tests/test_classifier.py new file mode 100644 index 0000000..4c87356 --- /dev/null +++ b/tests/test_classifier.py @@ -0,0 +1,133 @@ +"""Classifier 文档分类的单元测试(mock OllamaClient,不真实联网)""" + +import json + +from app.core.classifier import Classifier +from app.models.knowledge import UNCATEGORIZED, TaxonomyCategory + + +def _taxonomy() -> list[TaxonomyCategory]: + """测试用 taxonomy 类目集""" + return [ + TaxonomyCategory(name="技术文档", description="架构设计、API 文档、开发规范等技术资料"), + TaxonomyCategory(name="产品手册", description="产品功能介绍、使用说明"), + TaxonomyCategory(name="财务行政", description="财务制度、报销流程、行政通知"), + TaxonomyCategory(name=UNCATEGORIZED, description="无法归入其他类目的文档"), + ] + + +class FakeOllama: + """返回固定响应的假 OllamaClient,记录调用参数""" + + def __init__(self, response: str) -> None: + self.response = response + self.calls: list[dict] = [] + + async def generate(self, prompt: str, json_mode: bool = False) -> str: + self.calls.append({"prompt": prompt, "json_mode": json_mode}) + return self.response + + +def _make_classifier(response: str) -> Classifier: + return Classifier(ollama=FakeOllama(response), taxonomy=_taxonomy()) # type: ignore[arg-type] + + +class TestClassify: + """Classifier.classify 的正常解析与容错""" + + async def test_classify_valid_json(self): + """正常 JSON 响应 → 正确解析主类目/tags/confidence + + tags 清洗规则:剔除主类目名、最多保留 3 个。 + """ + response = json.dumps( + { + "main_category": "技术文档", + # "技术文档" 与主类目重复应被剔除;超出 3 个应被截断 + "tags": ["架构", "技术文档", "API", "部署", "运维"], + "confidence": 0.9, + }, + ensure_ascii=False, + ) + classifier = _make_classifier(response) + + result = await classifier.classify("本文介绍系统架构设计。", title="架构文档") + + assert result.main_category == "技术文档" + assert result.tags == ["架构", "API", "部署"] + assert result.confidence == 0.9 + + async def test_classify_uses_json_mode_and_prompt_contains_taxonomy(self): + """以 json_mode 调用 LLM,prompt 包含全部类目名与 uncategorized 用途说明""" + classifier = _make_classifier('{"main_category": "财务行政", "tags": [], "confidence": 0.8}') + + result = await classifier.classify("报销流程说明", title="") + + assert result.main_category == "财务行政" + ollama = classifier.ollama + assert ollama.calls[0]["json_mode"] is True + prompt = ollama.calls[0]["prompt"] + assert "技术文档" in prompt and "产品手册" in prompt and "财务行政" in prompt + assert UNCATEGORIZED in prompt + assert "跨多个类目" in prompt + assert "报销流程说明" in prompt + + async def test_classify_json_with_surrounding_noise(self): + """响应带前后多余文本 → 正则提取 {...} 块成功""" + response = ( + "好的,分类结果如下:\n" + '{"main_category": "产品手册", "tags": ["使用说明"], "confidence": 0.75}\n' + "以上是分类结果。" + ) + classifier = _make_classifier(response) + + result = await classifier.classify("产品功能使用说明") + + assert result.main_category == "产品手册" + assert result.tags == ["使用说明"] + assert result.confidence == 0.75 + + async def test_classify_non_json_falls_back_to_uncategorized(self): + """完全非 JSON 响应 → 归 uncategorized,tags 为空,confidence=0.0""" + classifier = _make_classifier("抱歉,我无法完成分类。") + + result = await classifier.classify("一段总结") + + assert result.main_category == UNCATEGORIZED + assert result.tags == [] + assert result.confidence == 0.0 + + async def test_classify_unknown_category_falls_back(self): + """类目名不在 taxonomy → 归 uncategorized,confidence=0.0""" + classifier = _make_classifier('{"main_category": "不存在的类目", "tags": ["x"], "confidence": 0.9}') + + result = await classifier.classify("一段总结") + + assert result.main_category == UNCATEGORIZED + assert result.tags == [] + assert result.confidence == 0.0 + + async def test_classify_low_confidence_soft_recall(self): + """置信度低于阈值(默认 0.6)→ 主类目归 uncategorized,候选类目名保留进 tags,confidence 保留原值""" + response = json.dumps( + {"main_category": "产品手册", "tags": ["手册"], "confidence": 0.4}, + ensure_ascii=False, + ) + classifier = _make_classifier(response) + + result = await classifier.classify("介于产品和运营之间的内容") + + assert result.main_category == UNCATEGORIZED + # 候选类目名插入 tags 首位,供检索侧软召回 + assert result.tags == ["产品手册", "手册"] + assert result.confidence == 0.4 + + async def test_classify_low_confidence_uncategorized_not_duplicated_in_tags(self): + """低置信且候选本身为 uncategorized → 不把 uncategorized 塞进 tags""" + classifier = _make_classifier('{"main_category": "uncategorized", "tags": [], "confidence": 0.3}') + + result = await classifier.classify("杂项内容") + + assert result.main_category == UNCATEGORIZED + assert result.tags == [] + assert result.confidence == 0.3 diff --git a/tests/test_document_admin_api.py b/tests/test_document_admin_api.py new file mode 100644 index 0000000..347de67 --- /dev/null +++ b/tests/test_document_admin_api.py @@ -0,0 +1,171 @@ +"""文档管理 API 测试(QdrantService 为 mock,不真实联网)""" + +from collections.abc import Iterator +from typing import Any + +import pytest +from fastapi.testclient import TestClient + +from app.api.v1 import document as document_module +from app.main import app +from app.services.qdrant import QdrantService + + +class FakeQdrant: + """假 QdrantService:返回固定数据或抛出固定异常""" + + def __init__( + self, + scroll_result: tuple[list[dict[str, Any]], str | None] | None = None, + detail: dict[str, Any] | None = None, + deleted: dict[str, int] | None = None, + error: Exception | None = None, + ) -> None: + self.scroll_result = scroll_result if scroll_result is not None else ([], None) + self.detail = detail + self.deleted = deleted if deleted is not None else {} + self.error = error + + async def scroll_l1(self, limit: int = 20, offset: str | None = None) -> tuple[list[dict[str, Any]], str | None]: + if self.error is not None: + raise self.error + return self.scroll_result + + async def get_doc_detail(self, doc_id: str) -> dict[str, Any] | None: + if self.error is not None: + raise self.error + return self.detail + + async def delete_by_doc_id(self, doc_id: str) -> dict[str, int]: + if self.error is not None: + raise self.error + return self.deleted + + +@pytest.fixture +def client(monkeypatch: pytest.MonkeyPatch) -> Iterator[TestClient]: + """TestClient,lifespan 中的 Qdrant 集合初始化替换为空操作""" + + async def _noop_ensure_collections(self: QdrantService) -> None: + return None + + monkeypatch.setattr(QdrantService, "ensure_collections", _noop_ensure_collections) + with TestClient(app) as test_client: + yield test_client + + +def _install_fake(monkeypatch: pytest.MonkeyPatch, fake: FakeQdrant) -> None: + """将 _get_qdrant 单例替换为假服务""" + monkeypatch.setattr(document_module, "_get_qdrant", lambda: fake) + + +def _make_item(doc_id: str = "doc-1") -> dict[str, Any]: + """构造固定的 L1 列表项""" + return { + "doc_id": doc_id, + "title": "标题", + "category": "技术文档", + "tags": ["API"], + "summary": "一句话总结", + } + + +def test_list_documents_success(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: + """列表:正常返回 items 与 next_offset""" + fake = FakeQdrant(scroll_result=([_make_item()], "cursor-2")) + _install_fake(monkeypatch, fake) + + resp = client.get("/api/v1/documents", params={"limit": 10, "offset": "cursor-1"}) + + assert resp.status_code == 200 + body = resp.json() + assert body["code"] == 0 + data = body["data"] + assert data["items"] == [_make_item()] + assert data["next_offset"] == "cursor-2" + + +def test_list_documents_empty(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: + """列表:空库返回 items=[]、next_offset=None""" + fake = FakeQdrant(scroll_result=([], None)) + _install_fake(monkeypatch, fake) + + resp = client.get("/api/v1/documents") + + body = resp.json() + assert body["code"] == 0 + assert body["data"] == {"items": [], "next_offset": None} + + +def test_get_document_detail(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: + """详情:存在返回 l1/l2_nodes/l3_nodes/chunks_count""" + detail = { + "l1": {"doc_id": "doc-1", "title": "标题", "text": "一句话总结"}, + "l2_nodes": [{"doc_id": "doc-1", "section_path": "1", "text": "大纲节点"}], + "l3_nodes": [], + "chunks_count": 3, + } + fake = FakeQdrant(detail=detail) + _install_fake(monkeypatch, fake) + + resp = client.get("/api/v1/documents/doc-1") + + body = resp.json() + assert body["code"] == 0 + assert body["data"] == detail + + +def test_get_document_not_found(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: + """详情:不存在返回 code=1004""" + fake = FakeQdrant(detail=None) + _install_fake(monkeypatch, fake) + + resp = client.get("/api/v1/documents/missing") + + body = resp.json() + assert body["code"] == 1004 + assert body["message"] == "文档不存在" + assert body["data"] is None + + +def test_delete_document_success(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: + """删除:返回各集合删除数与 deleted_total""" + deleted = {"doc_l1": 1, "doc_l2": 2, "doc_l3": 4, "chunks": 6} + fake = FakeQdrant(deleted=deleted) + _install_fake(monkeypatch, fake) + + resp = client.delete("/api/v1/documents/doc-1") + + body = resp.json() + assert body["code"] == 0 + data = body["data"] + assert data["doc_id"] == "doc-1" + assert data["deleted"] == deleted + assert data["deleted_total"] == 13 + + +def test_delete_document_not_found_idempotent(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: + """删除:不存在 doc_id 仍 code=0,各集合删除数全 0、deleted_total=0(幂等)""" + deleted = {"doc_l1": 0, "doc_l2": 0, "doc_l3": 0, "chunks": 0} + fake = FakeQdrant(deleted=deleted) + _install_fake(monkeypatch, fake) + + resp = client.delete("/api/v1/documents/missing") + + body = resp.json() + assert body["code"] == 0 + data = body["data"] + assert data["deleted"] == deleted + assert data["deleted_total"] == 0 + + +def test_list_documents_service_error(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: + """列表:scroll 抛错返回 code=2000""" + fake = FakeQdrant(error=RuntimeError("boom")) + _install_fake(monkeypatch, fake) + + resp = client.get("/api/v1/documents") + + body = resp.json() + assert body["code"] == 2000 + assert body["data"] is None diff --git a/tests/test_document_api.py b/tests/test_document_api.py new file mode 100644 index 0000000..2760d79 --- /dev/null +++ b/tests/test_document_api.py @@ -0,0 +1,74 @@ +"""文档入库 API 测试(任务管理器为 FakeManager,不真实联网)""" + +from collections.abc import Iterator + +import pytest +from fastapi.testclient import TestClient + +from app.api.v1 import document as document_module +from app.main import app +from app.models.document import DocumentInput +from app.services.qdrant import QdrantService + + +class FakeManager: + """假入库任务管理器:记录 submit 调用并返回固定 task_id""" + + def __init__(self, task_id: str = "task-1") -> None: + self.task_id = task_id + self.submitted: list[DocumentInput] = [] + + async def submit(self, doc: DocumentInput) -> str: + self.submitted.append(doc) + return self.task_id + + +@pytest.fixture +def client(monkeypatch: pytest.MonkeyPatch) -> Iterator[TestClient]: + """TestClient,lifespan 中的 Qdrant 集合初始化替换为空操作""" + + async def _noop_ensure_collections(self: QdrantService) -> None: + return None + + monkeypatch.setattr(QdrantService, "ensure_collections", _noop_ensure_collections) + with TestClient(app) as test_client: + yield test_client + + +def test_ingest_submit_accepted(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: + """正常提交:HTTP 202,code=0,data 含 task_id 与 status=pending,文档已登记给管理器""" + fake = FakeManager(task_id="task-abc") + monkeypatch.setattr(document_module, "_get_task_manager", lambda: fake) + + resp = client.post("/api/v1/documents", json={"text": "正文内容", "title": "标题"}) + + assert resp.status_code == 202 + body = resp.json() + assert body["code"] == 0 + data = body["data"] + assert data["task_id"] == "task-abc" + assert data["status"] == "pending" + assert [d.title for d in fake.submitted] == ["标题"] + + +def test_ingest_empty_text(client: TestClient) -> None: + """空 text:路由内校验,返回 code=1001""" + resp = client.post("/api/v1/documents", json={"text": ""}) + + body = resp.json() + assert body["code"] == 1001 + assert body["message"] == "文档内容不能为空" + assert body["data"] is None + + +def test_ingest_no_sync_ingestion_error(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: + """任务模式下同步路径不再返回 2000:提交即 202,入库失败体现在任务状态中""" + fake = FakeManager() + monkeypatch.setattr(document_module, "_get_task_manager", lambda: fake) + + resp = client.post("/api/v1/documents", json={"text": "正文内容"}) + + assert resp.status_code == 202 + body = resp.json() + assert body["code"] == 0 + assert body["data"]["status"] == "pending" diff --git a/tests/test_e2e_integration.py b/tests/test_e2e_integration.py new file mode 100644 index 0000000..4afe1b9 --- /dev/null +++ b/tests/test_e2e_integration.py @@ -0,0 +1,328 @@ +"""端到端集成测试 + +真实 Ingester + Retriever + 内存 Qdrant(location=":memory:")串联分层 RAG 全链路, +仅替换两个外部边界: +- FakeOllama:按 prompt 内容返回确定性的 L1/L2/L3 总结、分类 JSON、query 解析 JSON +- DeterministicEmbedding:稳定哈希生成固定维度向量(无语义,仅保证流程可跑) + +本机无需 Docker / Ollama / Redis,CI 可直接运行。 +""" + +import json +import random +import uuid +from dataclasses import dataclass +from hashlib import sha256 +from typing import Any + +import pytest +from fastapi.testclient import TestClient +from qdrant_client import AsyncQdrantClient + +from app.api.v1 import document as document_module +from app.api.v1 import search as search_module +from app.config import Settings, settings +from app.core.chunker import Chunker +from app.core.classifier import Classifier +from app.core.ingest_tasks import IngestTaskManager, IngestTaskStatus +from app.core.ingestion import Ingester +from app.core.query_parser import QueryParser +from app.core.retriever import Retriever +from app.core.sparse import SparseEncoder +from app.core.summarizer import Summarizer +from app.main import app +from app.models.document import DocumentInput +from app.models.knowledge import load_taxonomy +from app.models.search import SearchRequest +from app.services.qdrant import ( + ALL_COLLECTIONS, + COLLECTION_CHUNKS, + COLLECTION_L1, + COLLECTION_L2, + COLLECTION_L3, + QdrantService, +) + +# FakeOllama 的确定性输出 +FAKE_L1_SUMMARY = "本文介绍安装指南、环境准备与安装步骤,是一篇集成测试文档。" +FAKE_L3_OUTLINE = ( + "## 安装指南\n安装指南的整体流程说明。\n" + "## 环境准备\n环境准备的依赖与注意事项。\n" + "## 安装步骤\n安装步骤的命令与验证方法。" +) +FAKE_L2_HALF = "要点一:短文档的核心通知内容。\n要点二:需要关注的事项。" +FAKE_CATEGORY = "技术文档" +FAKE_TAGS = ["安装", "运维"] + + +class FakeOllama: + """按 prompt 内容返回确定性结果的假 OllamaClient + + 覆盖 Summarizer / Classifier / QueryParser 三类调用方; + 分类与 query 解析的置信度可配置,用于构造路由兜底场景。 + """ + + def __init__(self, classify_confidence: float = 0.9, query_confidence: float = 0.9) -> None: + self.classify_confidence = classify_confidence + self.query_confidence = query_confidence + + async def generate(self, prompt: str, json_mode: bool = False) -> str: + if "你是知识库分类助手" in prompt: + return json.dumps( + {"main_category": FAKE_CATEGORY, "tags": FAKE_TAGS, "confidence": self.classify_confidence}, + ensure_ascii=False, + ) + if "你是搜索查询分析助手" in prompt: + return json.dumps( + { + "categories": [{"name": FAKE_CATEGORY, "confidence": self.query_confidence}], + "rewrite": "安装指南 环境准备 安装步骤", + "keywords": ["安装", "环境准备"], + }, + ensure_ascii=False, + ) + if "请用一句话对以下文档内容进行高度概括" in prompt: + return FAKE_L1_SUMMARY + if "请提取以下文档的主要章节结构" in prompt: + return "1. 主题一\n2. 主题二" + if "请对以下文档的每个章节/主题进行详细的内容摘要" in prompt: + return FAKE_L3_OUTLINE + if "请对以下文档内容进行详细摘要" in prompt: + return FAKE_L2_HALF + raise AssertionError(f"FakeOllama 收到未识别的 prompt: {prompt[:100]}") + + +class DeterministicEmbedding: + """稳定哈希伪向量:同文本恒同向量,维度等于 settings.embedding_dimension""" + + async def embed(self, texts: list[str]) -> list[list[float]]: + vectors: list[list[float]] = [] + for text in texts: + seed = int.from_bytes(sha256(text.encode("utf-8")).digest()[:8], "big") + rng = random.Random(seed) + vectors.append([rng.random() for _ in range(settings.embedding_dimension)]) + return vectors + + +class FakeCache: + """无缓存行为的假 RedisCache(get 恒未命中,set 恒成功)""" + + async def get_json(self, key: str) -> dict[str, Any] | None: + return None + + async def set_json(self, key: str, value: dict[str, Any], ttl: int | None = None) -> bool: + return True + + +@dataclass +class _Env: + """一套共享内存 Qdrant 的真实 Ingester + Retriever 环境""" + + qdrant: QdrantService + ollama: FakeOllama + ingester: Ingester + retriever: Retriever + + +async def _make_env(classify_confidence: float = 0.9, query_confidence: float = 0.9) -> _Env: + """构建集成环境:真实组件 + 内存 Qdrant + FakeOllama + 确定性向量""" + qdrant = QdrantService(client=AsyncQdrantClient(location=":memory:")) + await qdrant.ensure_collections() + ollama = FakeOllama(classify_confidence=classify_confidence, query_confidence=query_confidence) + taxonomy = load_taxonomy() + embedding = DeterministicEmbedding() + ingester = Ingester( + summarizer=Summarizer(ollama=ollama), # type: ignore[arg-type] + classifier=Classifier(ollama=ollama, taxonomy=taxonomy), # type: ignore[arg-type] + chunker=Chunker(), + embedding=embedding, + sparse=SparseEncoder(), + qdrant=qdrant, + ) + retriever = Retriever( + qdrant=qdrant, + query_parser=QueryParser(ollama=ollama, taxonomy=taxonomy, cache=FakeCache()), # type: ignore[arg-type] + embedding=embedding, + sparse_encoder=SparseEncoder(), + ) + return _Env(qdrant=qdrant, ollama=ollama, ingester=ingester, retriever=retriever) + + +def _structured_doc() -> DocumentInput: + """带 Markdown 标题结构的中文长文档(>500 字符,切出 3 个 section chunk)""" + paragraph = "这是章节正文内容,包含足够多的信息量,用于测试切分与向量化流程。" * 20 + text = f"# 安装指南\n{paragraph}\n\n## 环境准备\n{paragraph}\n\n## 安装步骤\n{paragraph}" + return DocumentInput(text=text, title="安装文档") + + +def _short_doc() -> DocumentInput: + """短文档(< summary_min_text_length),触发 2.5 级回退""" + return DocumentInput(text="# 维护通知\n明天凌晨系统维护,请提前保存工作。", title="维护通知") + + +async def _counts(qdrant: QdrantService) -> dict[str, int]: + """四个集合的点数""" + return {name: (await qdrant.client.count(collection_name=name)).count for name in ALL_COLLECTIONS} + + +class TestIngestIntegration: + """入库链路:四层集合点数与 chunk payload 完整性""" + + async def test_ingest_structured_document(self): + """结构化长文档 → L1=1、L2=标题数、L3/chunks ≥1,chunk payload 字段齐全""" + env = await _make_env() + doc = _structured_doc() + + result = await env.ingester.ingest(doc) + + # 结果透传 + assert result.document_id + assert result.category == FAKE_CATEGORY + assert result.tags == FAKE_TAGS + assert result.category_confidence == 0.9 + assert result.summary.l1_summary == FAKE_L1_SUMMARY + assert result.chunks_count >= 1 + + counts = await _counts(env.qdrant) + assert counts[COLLECTION_L1] == 1 + assert counts[COLLECTION_L2] == 3 # 标题数:安装指南/环境准备/安装步骤 + assert counts[COLLECTION_L3] >= 1 + assert counts[COLLECTION_CHUNKS] == result.chunks_count >= 1 + + # chunk payload:doc_summary/category/tags/section_path 齐全且与分类结果一致 + points, _ = await env.qdrant.client.scroll( + collection_name=COLLECTION_CHUNKS, limit=100, with_payload=True + ) + assert len(points) == result.chunks_count + expected_paths = {"安装指南", "安装指南 / 环境准备", "安装指南 / 安装步骤"} + for point in points: + payload = point.payload or {} + assert payload["doc_id"] == result.document_id + assert payload["doc_summary"] == FAKE_L1_SUMMARY + assert payload["category"] == FAKE_CATEGORY + assert payload["tags"] == FAKE_TAGS + assert payload["section_path"] in expected_paths + assert payload["text"] in doc.text + + async def test_ingest_short_document_l2_half(self): + """短文档 → 2.5 级:doc_l2 无该 doc 节点,其余层正常写入""" + env = await _make_env() + + result = await env.ingester.ingest(_short_doc()) + + assert result.summary.l2_outline is None + counts = await _counts(env.qdrant) + assert counts[COLLECTION_L1] == 1 + assert counts[COLLECTION_L2] == 0 # 2.5 级文档无 L2 大纲节点 + assert counts[COLLECTION_L3] >= 1 + assert counts[COLLECTION_CHUNKS] >= 1 + + async def test_reingest_idempotent(self, monkeypatch: pytest.MonkeyPatch): + """同 doc_id 重复入库:幂等覆盖不报错,各层点数不翻倍""" + env = await _make_env() + doc = _structured_doc() + # 固定 doc_id,两次入库写入同一组确定性 point id(uuid5 覆盖) + fixed_uuid = uuid.UUID("12345678-1234-5678-1234-567812345678") + monkeypatch.setattr("app.core.ingestion.uuid.uuid4", lambda: fixed_uuid) + + first = await env.ingester.ingest(doc) + counts_after_first = await _counts(env.qdrant) + second = await env.ingester.ingest(doc) + counts_after_second = await _counts(env.qdrant) + + assert first.document_id == second.document_id == fixed_uuid.hex + assert counts_after_second == counts_after_first + assert counts_after_second[COLLECTION_L1] == 1 + assert counts_after_second[COLLECTION_L2] == 3 + + +class TestSearchIntegration: + """检索链路:正常路由 / 路由兜底 / 空库""" + + async def test_search_normal_query(self): + """正常 query:hits 非空,text 为原文片段,doc_summary 非空,routed_categories 来自分类""" + env = await _make_env() + doc = _structured_doc() + await env.ingester.ingest(doc) + + resp = await env.retriever.search(SearchRequest(query="安装步骤有哪些注意事项?")) + + assert resp.fallback is False + assert resp.routed_categories == [FAKE_CATEGORY] + assert resp.hits + hit = resp.hits[0] + assert hit.text in doc.text + assert hit.doc_summary == FAKE_L1_SUMMARY + assert hit.score > 0 + + async def test_search_route_fallback(self): + """query 解析低置信 → 路由兜底:fallback=True 且仍有 hits""" + env = await _make_env(query_confidence=0.3) + doc = _structured_doc() + await env.ingester.ingest(doc) + + resp = await env.retriever.search(SearchRequest(query="随便问点什么")) + + assert resp.fallback is True + assert resp.routed_categories == [] + assert resp.hits + assert resp.hits[0].text in doc.text + + async def test_search_empty_db(self): + """空库 search:hits 为空、不报错、fallback=True""" + env = await _make_env() + + resp = await env.retriever.search(SearchRequest(query="空库查询")) + + assert resp.hits == [] + assert resp.fallback is True + + +class TestApiIntegration: + """API 层冒烟:documents → search → knowledge/categories,统一响应格式 code=0""" + + async def test_api_smoke(self, monkeypatch: pytest.MonkeyPatch): + env = await _make_env() + doc = _structured_doc() + # 入库走异步任务:测试自建纯内存任务管理器(内存 Qdrant + FakeOllama 的 Ingester) + manager = IngestTaskManager(env.ingester, None, Settings()) + + # lifespan 的 ensure_collections 替换为空操作(真实 Qdrant 不可达时也能启动) + async def _noop_ensure_collections(self: QdrantService) -> None: + return None + + monkeypatch.setattr(QdrantService, "ensure_collections", _noop_ensure_collections) + # 模块级单例替换为集成环境实例 + monkeypatch.setattr(document_module, "_task_manager", manager) + monkeypatch.setattr(search_module, "_retriever", env.retriever) + monkeypatch.setattr(search_module, "get_cache", lambda: FakeCache()) + + with TestClient(app) as client: + # 入库:202 拿 task_id,等待任务终态后断言入库结果 + resp_doc = client.post("/api/v1/documents", json={"text": doc.text, "title": doc.title}) + assert resp_doc.status_code == 202 + body_doc = resp_doc.json() + assert body_doc["code"] == 0 + assert body_doc["data"]["status"] == "pending" + task_id = body_doc["data"]["task_id"] + assert task_id + + final = await manager.wait_done(task_id) + assert final["status"] == IngestTaskStatus.DONE + result = final["result"] + assert result["document_id"] + assert result["category"] == FAKE_CATEGORY + assert result["chunks_count"] >= 1 + + # 检索 + resp_search = client.post("/api/v1/search", json={"query": "安装步骤有哪些注意事项?"}) + body_search = resp_search.json() + assert body_search["code"] == 0 + assert body_search["data"]["hits"] + assert body_search["data"]["routed_categories"] == [FAKE_CATEGORY] + + # 知识分类 + resp_categories = client.get("/api/v1/knowledge/categories") + body_categories = resp_categories.json() + assert body_categories["code"] == 0 + assert body_categories["data"]["count"] > 0 diff --git a/tests/test_embeddings.py b/tests/test_embeddings.py new file mode 100644 index 0000000..b14de15 --- /dev/null +++ b/tests/test_embeddings.py @@ -0,0 +1,150 @@ +"""嵌入服务单元测试(mock httpx/openai,不发起真实网络请求)""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import httpx + +from app.config import settings +from app.core.embeddings import ( + EmbeddingService, + LocalEmbeddingService, + OpenAIEmbeddingService, + create_embedding_service, +) + + +def _fake_openai_response(dim: int, n: int) -> MagicMock: + """构造 OpenAI embeddings.create 的假响应""" + resp = MagicMock() + resp.data = [MagicMock(embedding=[0.1 * (i + 1)] * dim) for i in range(n)] + return resp + + +class _FakeResponse: + """httpx.Response 替代品""" + + def __init__(self, data: dict) -> None: + self._data = data + + def raise_for_status(self) -> None: + pass + + def json(self) -> dict: + return self._data + + +class _FakeAsyncClient: + """httpx.AsyncClient 替代品,记录请求参数""" + + def __init__(self, data: dict, captured: dict, **kwargs) -> None: + self._data = data + self._captured = captured + captured["timeout"] = kwargs.get("timeout") + + async def __aenter__(self) -> "_FakeAsyncClient": + return self + + async def __aexit__(self, *args) -> bool: + return False + + async def post(self, url: str, json: dict | None = None) -> _FakeResponse: + self._captured["url"] = url + self._captured["json"] = json + return _FakeResponse(self._data) + + +class TestOpenAIEmbeddingService: + """OpenAI provider 行为""" + + async def test_embed_batch(self): + """批量嵌入:模型与输入透传,返回与输入等长的向量""" + with patch("app.core.embeddings.AsyncOpenAI") as mock_cls: + client = mock_cls.return_value + client.embeddings.create = AsyncMock(return_value=_fake_openai_response(dim=1536, n=2)) + service = OpenAIEmbeddingService(api_key="k", base_url="http://x/v1", model="m") + vectors = await service.embed(["你好", "world"]) + + assert len(vectors) == 2 + assert all(len(v) == 1536 for v in vectors) + client.embeddings.create.assert_awaited_once_with(model="m", input=["你好", "world"]) + + async def test_embed_empty(self): + """空列表输入直接返回空列表,不调用 API""" + with patch("app.core.embeddings.AsyncOpenAI") as mock_cls: + client = mock_cls.return_value + client.embeddings.create = AsyncMock() + service = OpenAIEmbeddingService(api_key="k", base_url="http://x/v1", model="m") + assert await service.embed([]) == [] + client.embeddings.create.assert_not_called() + + async def test_dimension_mismatch_warns(self): + """返回维度与配置不一致时记录 warning,不抛错""" + with ( + patch("app.core.embeddings.AsyncOpenAI") as mock_cls, + patch("app.core.embeddings.logger") as mock_logger, + ): + client = mock_cls.return_value + client.embeddings.create = AsyncMock(return_value=_fake_openai_response(dim=8, n=1)) + service = OpenAIEmbeddingService(api_key="k", base_url="http://x/v1", model="m") + vectors = await service.embed(["a"]) + + assert len(vectors[0]) == 8 + mock_logger.warning.assert_called_once() + + +class TestLocalEmbeddingService: + """Ollama local provider 行为""" + + async def test_embed_batch(self, monkeypatch): + """批量嵌入:POST /api/embed,payload 与 URL 正确,timeout 60s""" + captured: dict = {} + data = {"embeddings": [[0.1, 0.2], [0.3, 0.4]]} + monkeypatch.setattr(httpx, "AsyncClient", lambda **kw: _FakeAsyncClient(data, captured, **kw)) + + service = LocalEmbeddingService(base_url="http://localhost:11434/", model="bge-m3") + vectors = await service.embed(["你好", "world"]) + + assert vectors == [[0.1, 0.2], [0.3, 0.4]] + assert captured["url"] == "http://localhost:11434/api/embed" + assert captured["json"] == {"model": "bge-m3", "input": ["你好", "world"]} + assert captured["timeout"] == 60.0 + + async def test_embed_empty(self, monkeypatch): + """空列表输入直接返回空列表,不发起请求""" + called = [] + monkeypatch.setattr(httpx, "AsyncClient", lambda **kw: called.append(kw)) + + service = LocalEmbeddingService(base_url="http://localhost:11434", model="bge-m3") + assert await service.embed([]) == [] + assert called == [] + + async def test_dimension_mismatch_warns(self, monkeypatch): + """返回维度与配置不一致时记录 warning,不抛错""" + data = {"embeddings": [[0.1] * 8]} + monkeypatch.setattr(httpx, "AsyncClient", lambda **kw: _FakeAsyncClient(data, {}, **kw)) + + with patch("app.core.embeddings.logger") as mock_logger: + service = LocalEmbeddingService(base_url="http://localhost:11434", model="bge-m3") + vectors = await service.embed(["a"]) + + assert len(vectors[0]) == 8 + mock_logger.warning.assert_called_once() + + +class TestCreateEmbeddingService: + """工厂函数选择逻辑""" + + def test_provider_openai(self, monkeypatch): + monkeypatch.setattr(settings, "embedding_provider", "openai") + monkeypatch.setattr(settings, "openai_api_key", "test-key") + service = create_embedding_service() + assert isinstance(service, OpenAIEmbeddingService) + assert isinstance(service, EmbeddingService) + + def test_provider_local(self, monkeypatch): + monkeypatch.setattr(settings, "embedding_provider", "local") + monkeypatch.setattr(settings, "ollama_embedding_model", "bge-m3") + service = create_embedding_service() + assert isinstance(service, LocalEmbeddingService) + assert service.model == "bge-m3" + assert isinstance(service, EmbeddingService) diff --git a/tests/test_eval_metrics.py b/tests/test_eval_metrics.py new file mode 100644 index 0000000..dd6f4fa --- /dev/null +++ b/tests/test_eval_metrics.py @@ -0,0 +1,94 @@ +"""评测指标纯函数单元测试(不依赖 Qdrant / Ollama)""" + +import pytest + +from scripts.eval.metrics import ( + aggregate, + entity_recall, + precision_at_k, + pruning_loss, + recall_at_k, + routing_f1, +) + + +class TestEntityRecall: + def test_all_entities_kept(self) -> None: + source = "使用 Qdrant 存储 1536 维向量,模型 bge-m3,见《部署手册》第三条。" + summary = "基于 Qdrant 与 bge-m3 的 1536 维向量检索,详见《部署手册》第三条。" + assert entity_recall(source, summary) == pytest.approx(1.0) + + def test_partial_entities_kept(self) -> None: + source = "支持 text-embedding-3-small 和 bge-m3 两个模型。" + summary = "支持 bge-m3 模型。" + score = entity_recall(source, summary) + assert 0.0 < score < 1.0 + + def test_no_entities_returns_full_score(self) -> None: + assert entity_recall("这是一段没有任何实体的中文普通句子。", "很短") == 1.0 + + def test_empty_summary_drops_all(self) -> None: + source = "版本 v1.2.0 修复了 3 个问题。" + assert entity_recall(source, "") == pytest.approx(0.0) + + +class TestRoutingF1: + def test_perfect_routing(self) -> None: + golden = [{"d1"}, {"d2"}] + routed = [{"d1"}, {"d2"}] + result = routing_f1(golden, routed) + assert result == {"precision": 1.0, "recall": 1.0, "f1": 1.0} + + def test_micro_average(self) -> None: + # query1: tp=1 fp=1;query2: tp=0 fn=1 → micro p=1/2 r=1/2 f1=1/2 + golden = [{"d1"}, {"d2"}] + routed = [{"d1", "d3"}, set()] + result = routing_f1(golden, routed) + assert result["precision"] == pytest.approx(0.5) + assert result["recall"] == pytest.approx(0.5) + assert result["f1"] == pytest.approx(0.5) + + def test_empty_routed(self) -> None: + result = routing_f1([{"d1"}], [set()]) + assert result["precision"] == 0.0 + assert result["recall"] == 0.0 + + +class TestPruningLoss: + def test_mixed_cases(self) -> None: + assert pruning_loss([False, True, False, False]) == pytest.approx(0.25) + + def test_empty(self) -> None: + assert pruning_loss([]) == 0.0 + + +class TestPrecisionRecallAtK: + def test_precision_at_k(self) -> None: + hits = ["d1", "d2", "d3", "d4", "d5"] + assert precision_at_k(hits, {"d1", "d3"}, 5) == pytest.approx(0.4) + + def test_precision_at_k_empty_hits(self) -> None: + assert precision_at_k([], {"d1"}, 5) == 0.0 + + def test_recall_at_k_dedup(self) -> None: + hits = ["d1", "d1", "d2"] + assert recall_at_k(hits, {"d1", "d2", "d3"}, 3) == pytest.approx(2 / 3) + + def test_recall_at_k_empty_golden(self) -> None: + assert recall_at_k(["d1"], set(), 5) == 0.0 + + +class TestAggregate: + def test_mean_per_key(self) -> None: + records = [{"a": 1.0, "b": 0.5}, {"a": 0.0, "b": 1.0}] + result = aggregate(records) + assert result["a"] == pytest.approx(0.5) + assert result["b"] == pytest.approx(0.75) + + def test_partial_keys(self) -> None: + result = aggregate([{"a": 1.0}, {"a": 0.0, "b": 1.0}]) + assert result["a"] == pytest.approx(0.5) + assert result["b"] == pytest.approx(1.0) + + def test_empty(self) -> None: + assert aggregate([]) == {} diff --git a/tests/test_headings.py b/tests/test_headings.py new file mode 100644 index 0000000..8607502 --- /dev/null +++ b/tests/test_headings.py @@ -0,0 +1,104 @@ +"""标题树解析器的单元测试""" + +from app.core.headings import parse_headings, render_outline + + +class TestMarkdownHeadings: + """Markdown ATX 标题解析""" + + def test_multi_level(self): + """多级 Markdown 标题,层级为 # 数量""" + text = "# 一级标题\n正文内容\n## 二级标题\n更多内容\n### 三级标题\n" + headings = parse_headings(text) + assert [(h.title, h.level, h.line_index) for h in headings] == [ + ("一级标题", 1, 0), + ("二级标题", 2, 2), + ("三级标题", 3, 4), + ] + + def test_hash_without_space_not_heading(self): + """# 后无空白不是标题""" + assert parse_headings("#不是标题") == [] + + def test_up_to_six_levels(self): + """最多支持 6 级标题""" + text = "###### 六级标题\n####### 七级不算\n" + headings = parse_headings(text) + assert len(headings) == 1 + assert headings[0].level == 6 + + +class TestChineseChapterHeadings: + """中文章/节/篇编号标题解析""" + + def test_chapter_and_section(self): + """章=1 级,节=2 级""" + text = "第一章 总则\n正文\n第一节 一般规定\n" + headings = parse_headings(text) + assert [(h.title, h.level) for h in headings] == [("第一章 总则", 1), ("第一节 一般规定", 2)] + + def test_pian_is_level_one(self): + """篇=1 级,支持阿拉伯数字编号""" + text = "第一篇 概述\n第2章 背景\n" + headings = parse_headings(text) + assert [(h.title, h.level) for h in headings] == [("第一篇 概述", 1), ("第2章 背景", 1)] + + def test_chinese_enum_is_level_one(self): + """一、二、…… 固定 1 级""" + text = "一、项目背景\n二、建设目标\n" + headings = parse_headings(text) + assert [(h.title, h.level) for h in headings] == [("一、项目背景", 1), ("二、建设目标", 1)] + + def test_overlong_line_not_heading(self): + """编号行超过 60 字符视为正文""" + text = "第一章 " + "很长的标题" * 12 + "\n" + assert parse_headings(text) == [] + + +class TestNumericHeadings: + """数字编号标题解析""" + + def test_dot_segments_determine_level(self): + """按点分段数定层级:1.=1、1.1=2、1.1.1=3""" + text = "1. 概述\n1.1 背景\n1.1.1 细节\n" + headings = parse_headings(text) + assert [(h.title, h.level) for h in headings] == [ + ("1. 概述", 1), + ("1.1 背景", 2), + ("1.1.1 细节", 3), + ] + + def test_dunhao_separator(self): + """顿号分隔的数字编号为 1 级""" + headings = parse_headings("1、基本要求\n2、总体架构\n") + assert [(h.title, h.level) for h in headings] == [("1、基本要求", 1), ("2、总体架构", 1)] + + +class TestMixedAndPlain: + """混合模式与无结构文本""" + + def test_no_headings_returns_empty(self): + """无标题文本返回空列表""" + text = "第一段正文内容。\n\n第二段正文内容。\n" + assert parse_headings(text) == [] + + def test_empty_text(self): + assert parse_headings("") == [] + + def test_mixed_patterns(self): + """Markdown 与中文编号混合时均能识别""" + text = "# Markdown 标题\n正文\n第一章 中文标题\n1.1 数字标题\n" + headings = parse_headings(text) + assert [(h.title, h.level) for h in headings] == [ + ("Markdown 标题", 1), + ("第一章 中文标题", 1), + ("1.1 数字标题", 2), + ] + + +class TestRenderOutline: + """标题树渲染为大纲文本""" + + def test_indent_by_level(self): + headings = parse_headings("# 安装指南\n## 环境准备\n## 安装步骤\n### 验证\n") + assert render_outline(headings) == "- 安装指南\n - 环境准备\n - 安装步骤\n - 验证" diff --git a/tests/test_ingest_async_integration.py b/tests/test_ingest_async_integration.py new file mode 100644 index 0000000..59bb8d1 --- /dev/null +++ b/tests/test_ingest_async_integration.py @@ -0,0 +1,262 @@ +"""异步入库集成验证 + +在真实内存 Qdrant + FakeOllama 环境下验证异步入库全链路闭环: +POST 202 拿 task_id → wait_done 终态 → 任务查询 → 文档详情 → 检索命中 → 删除; +并覆盖两条边界路径: +- 失败路径:分类阶段抛错 → failed + error.stage/partial_summary,Redis 失败镜像 TTL=7 天 +- Redis 降级路径:无 Redis / Redis 全异常时任务照常完成、查询正常 +""" + +from collections.abc import Callable +from typing import Any + +import pytest +from fastapi.testclient import TestClient +from qdrant_client import AsyncQdrantClient + +from app.api.v1 import document as document_module +from app.api.v1 import search as search_module +from app.config import Settings +from app.core.chunker import Chunker +from app.core.classifier import Classifier +from app.core.ingest_tasks import IngestTaskManager, IngestTaskStatus +from app.core.ingestion import Ingester +from app.core.query_parser import QueryParser +from app.core.retriever import Retriever +from app.core.sparse import SparseEncoder +from app.core.summarizer import Summarizer +from app.main import app +from app.models.document import DocumentInput, IngestionResult +from app.models.knowledge import load_taxonomy +from app.services.qdrant import QdrantService +from tests.test_e2e_integration import ( + FAKE_CATEGORY, + FAKE_L1_SUMMARY, + DeterministicEmbedding, + FakeCache, + FakeOllama, +) + + +class ClassifyFailOllama(FakeOllama): + """分类阶段抛错的 FakeOllama(总结等其余行为与正常版一致)""" + + async def generate(self, prompt: str, json_mode: bool = False) -> str: + if "你是知识库分类助手" in prompt: + raise RuntimeError("模拟分类模型不可用") + return await super().generate(prompt, json_mode=json_mode) + + +class RecordingRedis: + """记录 set_json 调用的假 Redis(get_json 恒未命中)""" + + def __init__(self) -> None: + self.set_calls: list[tuple[str, dict[str, Any], int | None]] = [] + + async def get_json(self, key: str) -> dict[str, Any] | None: + return None + + async def set_json(self, key: str, value: dict[str, Any], ttl: int | None = None) -> bool: + self.set_calls.append((key, value, ttl)) + return True + + +class BrokenRedis: + """全部方法抛异常的假 Redis,验证任务管理器的容错降级""" + + async def get_json(self, key: str) -> dict[str, Any] | None: + raise ConnectionError("模拟 Redis 不可用") + + async def set_json(self, key: str, value: dict[str, Any], ttl: int | None = None) -> bool: + raise ConnectionError("模拟 Redis 不可用") + + +class RecordingIngester: + """包装真实 Ingester,记录 progress_cb 回调的阶段序列(接口与 Ingester 一致)""" + + def __init__(self, ingester: Ingester) -> None: + self._ingester = ingester + self.stages: list[str] = [] + + async def ingest( + self, doc: DocumentInput, progress_cb: Callable[[str], None] | None = None + ) -> IngestionResult: + def _cb(stage: str) -> None: + self.stages.append(stage) + if progress_cb is not None: + progress_cb(stage) + + return await self._ingester.ingest(doc, progress_cb=_cb) + + +async def _make_env(ollama: FakeOllama) -> tuple[QdrantService, Ingester, Retriever]: + """构建集成环境:真实组件 + 内存 Qdrant + 指定 FakeOllama + 确定性向量""" + qdrant = QdrantService(client=AsyncQdrantClient(location=":memory:")) + await qdrant.ensure_collections() + taxonomy = load_taxonomy() + embedding = DeterministicEmbedding() + ingester = Ingester( + summarizer=Summarizer(ollama=ollama), # type: ignore[arg-type] + classifier=Classifier(ollama=ollama, taxonomy=taxonomy), # type: ignore[arg-type] + chunker=Chunker(), + embedding=embedding, # type: ignore[arg-type] + sparse=SparseEncoder(), + qdrant=qdrant, + ) + retriever = Retriever( + qdrant=qdrant, + query_parser=QueryParser(ollama=ollama, taxonomy=taxonomy, cache=FakeCache()), # type: ignore[arg-type] + embedding=embedding, # type: ignore[arg-type] + sparse_encoder=SparseEncoder(), + ) + return qdrant, ingester, retriever + + +def _structured_doc() -> DocumentInput: + """带 Markdown 标题结构的中文长文档(>500 字符,走完整三级总结)""" + paragraph = "这是章节正文内容,包含足够多的信息量,用于测试切分与向量化流程。" * 20 + text = f"# 安装指南\n{paragraph}\n\n## 环境准备\n{paragraph}\n\n## 安装步骤\n{paragraph}" + return DocumentInput(text=text, title="安装文档") + + +def _patch_app( + monkeypatch: pytest.MonkeyPatch, manager: IngestTaskManager, qdrant: QdrantService, retriever: Retriever +) -> None: + """替换模块级单例:任务管理器 / Qdrant / Retriever,lifespan 建集合改空操作""" + + async def _noop_ensure_collections(self: QdrantService) -> None: + return None + + monkeypatch.setattr(QdrantService, "ensure_collections", _noop_ensure_collections) + monkeypatch.setattr(document_module, "_task_manager", manager) + monkeypatch.setattr(document_module, "_qdrant", qdrant) + monkeypatch.setattr(search_module, "_retriever", retriever) + monkeypatch.setattr(search_module, "get_cache", lambda: FakeCache()) + + +class TestIngestAsyncFullLoop: + """全链路闭环:异步入库 → 任务查询 → 文档管理 → 检索 → 删除""" + + async def test_full_loop(self, monkeypatch: pytest.MonkeyPatch): + qdrant, ingester, retriever = await _make_env(FakeOllama()) + recording = RecordingIngester(ingester) + manager = IngestTaskManager(recording, None, Settings()) # type: ignore[arg-type] + _patch_app(monkeypatch, manager, qdrant, retriever) + doc = _structured_doc() + + with TestClient(app) as client: + # 1. 提交入库:202 + task_id + resp_post = client.post("/api/v1/documents", json={"text": doc.text, "title": doc.title}) + assert resp_post.status_code == 202 + body_post = resp_post.json() + assert body_post["code"] == 0 + assert body_post["data"]["status"] == "pending" + task_id = body_post["data"]["task_id"] + assert task_id + + # 2. 等待终态:done + 结果完整 + 阶段序列完整 + final = await manager.wait_done(task_id) + assert final["status"] == IngestTaskStatus.DONE + result = final["result"] + assert result["document_id"] + assert result["category"] == FAKE_CATEGORY + assert result["chunks_count"] >= 1 + assert recording.stages == ["summarizing", "classifying", "embedding", "writing"] + document_id = result["document_id"] + + # 3. 任务状态查询:code=0、done、含 result + resp_task = client.get(f"/api/v1/documents/tasks/{task_id}") + body_task = resp_task.json() + assert body_task["code"] == 0 + assert body_task["data"]["status"] == "done" + assert body_task["data"]["result"]["document_id"] == document_id + + # 4. 文档详情:入库完成后即可管理 + resp_detail = client.get(f"/api/v1/documents/{document_id}") + body_detail = resp_detail.json() + assert body_detail["code"] == 0 + + # 5. 检索:相关 query 命中该文档 + resp_search = client.post("/api/v1/search", json={"query": "安装步骤有哪些注意事项?"}) + body_search = resp_search.json() + assert body_search["code"] == 0 + hits = body_search["data"]["hits"] + assert hits + assert any(hit["doc_id"] == document_id for hit in hits) + + # 6. 删除:四层集合中该文档全部清除 + resp_delete = client.delete(f"/api/v1/documents/{document_id}") + body_delete = resp_delete.json() + assert body_delete["code"] == 0 + assert body_delete["data"]["deleted_total"] > 0 + + +class TestIngestAsyncFailure: + """失败路径:分类阶段抛错 → failed 状态、错误透传与 Redis 失败镜像 TTL""" + + async def test_classify_failure(self, monkeypatch: pytest.MonkeyPatch): + qdrant, ingester, retriever = await _make_env(ClassifyFailOllama()) + redis = RecordingRedis() + manager = IngestTaskManager(ingester, redis, Settings()) # type: ignore[arg-type] + _patch_app(monkeypatch, manager, qdrant, retriever) + + # 直接经 manager 提交(与 API 提交同路径),在测试事件循环内等待终态与镜像写完 + task_id = await manager.submit(_structured_doc()) + final = await manager.wait_done(task_id) + + # 终态 failed:阶段与已产出总结透传 + assert final["status"] == IngestTaskStatus.FAILED + error = final["error"] + assert error["stage"] == "classify" + assert error["partial_summary"] + assert error["partial_summary"]["l1_summary"] == FAKE_L1_SUMMARY + + # Redis 镜像:failed 快照使用失败 TTL(7 天),其余快照用 done TTL(24h) + failed_mirrors = [c for c in redis.set_calls if c[1]["status"] == IngestTaskStatus.FAILED] + assert failed_mirrors + assert all(ttl == 604800 for _, _, ttl in failed_mirrors) + non_failed = [c for c in redis.set_calls if c[1]["status"] != IngestTaskStatus.FAILED] + assert non_failed + assert all(ttl == 86400 for _, _, ttl in non_failed) + + # API 查询:GET tasks/{task_id} 返回同样的失败信息 + with TestClient(app) as client: + resp_task = client.get(f"/api/v1/documents/tasks/{task_id}") + body_task = resp_task.json() + assert body_task["code"] == 0 + assert body_task["data"]["status"] == "failed" + assert body_task["data"]["error"]["stage"] == "classify" + assert body_task["data"]["error"]["partial_summary"] + assert body_task["data"]["error"]["partial_summary"]["l1_summary"] == FAKE_L1_SUMMARY + + +class TestIngestAsyncRedisDegraded: + """Redis 降级路径:无 Redis 或 Redis 全异常时任务照常完成""" + + async def test_redis_none(self): + """redis=None:纯内存模式,提交→done→查询全流程正常""" + _, ingester, _ = await _make_env(FakeOllama()) + manager = IngestTaskManager(ingester, None, Settings()) + + task_id = await manager.submit(_structured_doc()) + final = await manager.wait_done(task_id) + + assert final["status"] == IngestTaskStatus.DONE + assert final["result"]["document_id"] + record = await manager.get(task_id) + assert record is not None + assert record["status"] == IngestTaskStatus.DONE + + async def test_redis_broken(self): + """Redis 读写全抛异常:镜像/读取容错降级,任务流程与查询不受影响""" + _, ingester, _ = await _make_env(FakeOllama()) + manager = IngestTaskManager(ingester, BrokenRedis(), Settings()) # type: ignore[arg-type] + + task_id = await manager.submit(_structured_doc()) + final = await manager.wait_done(task_id) + + assert final["status"] == IngestTaskStatus.DONE + assert final["result"]["document_id"] + record = await manager.get(task_id) + assert record is not None + assert record["status"] == IngestTaskStatus.DONE diff --git a/tests/test_ingest_task_api.py b/tests/test_ingest_task_api.py new file mode 100644 index 0000000..98626ad --- /dev/null +++ b/tests/test_ingest_task_api.py @@ -0,0 +1,160 @@ +"""入库异步任务 API 测试(TestClient + FakeManager,不真实联网)""" + +from collections.abc import Iterator +from typing import Any + +import pytest +from fastapi.testclient import TestClient + +from app.api.v1 import document as document_module +from app.main import app +from app.models.document import DocumentInput +from app.services.qdrant import QdrantService + + +class FakeManager: + """假入库任务管理器:记录 submit 调用,按 task_id 返回预置任务""" + + def __init__(self, tasks: dict[str, dict[str, Any]] | None = None, task_id: str = "task-1") -> None: + self.tasks = tasks or {} + self.task_id = task_id + self.submitted: list[DocumentInput] = [] + + async def submit(self, doc: DocumentInput) -> str: + self.submitted.append(doc) + return self.task_id + + async def get(self, task_id: str) -> dict[str, Any] | None: + return self.tasks.get(task_id) + + +def _task(task_id: str, status: str, **extra: Any) -> dict[str, Any]: + """构造一条任务记录(时间字段为固定 ISO8601 字符串)""" + record: dict[str, Any] = { + "task_id": task_id, + "status": status, + "created_at": "2026-07-29T08:00:00+00:00", + "updated_at": "2026-07-29T08:00:01+00:00", + "result": None, + "error": None, + } + record.update(extra) + return record + + +@pytest.fixture +def client(monkeypatch: pytest.MonkeyPatch) -> Iterator[TestClient]: + """TestClient,lifespan 中的 Qdrant 集合初始化替换为空操作""" + + async def _noop_ensure_collections(self: QdrantService) -> None: + return None + + monkeypatch.setattr(QdrantService, "ensure_collections", _noop_ensure_collections) + with TestClient(app) as test_client: + yield test_client + + +def _inject_manager(monkeypatch: pytest.MonkeyPatch, manager: FakeManager) -> None: + """将 FakeManager 注入路由的 _get_task_manager""" + monkeypatch.setattr(document_module, "_get_task_manager", lambda: manager) + + +def test_post_documents_returns_202(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: + """POST /documents:HTTP 202,data.task_id 非空且 status=pending,文档已提交给管理器""" + manager = FakeManager(task_id="abc123") + _inject_manager(monkeypatch, manager) + + resp = client.post("/api/v1/documents", json={"text": "正文内容", "title": "标题"}) + + assert resp.status_code == 202 + body = resp.json() + assert body["code"] == 0 + assert body["data"]["task_id"] == "abc123" + assert body["data"]["status"] == "pending" + assert len(manager.submitted) == 1 + + +def test_get_task_pending(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: + """GET tasks/{id}:进行中任务 → code=0,含 status/created_at/updated_at""" + manager = FakeManager(tasks={"t-pending": _task("t-pending", "summarizing")}) + _inject_manager(monkeypatch, manager) + + resp = client.get("/api/v1/documents/tasks/t-pending") + + body = resp.json() + assert body["code"] == 0 + data = body["data"] + assert data["task_id"] == "t-pending" + assert data["status"] == "summarizing" + assert data["created_at"] + assert data["updated_at"] + assert data["result"] is None + assert data["error"] is None + + +def test_get_task_done(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: + """GET tasks/{id}:done 任务 → data.result 含 document_id""" + result = { + "document_id": "doc-1", + "summary": {"l1_summary": "一句话", "l2_outline": None, "l3_content_outline": "大纲", "level": "L3"}, + "category": "技术文档", + "collection": "四层集合", + "chunks_count": 3, + "tags": ["API"], + "category_confidence": 0.9, + } + manager = FakeManager(tasks={"t-done": _task("t-done", "done", result=result)}) + _inject_manager(monkeypatch, manager) + + resp = client.get("/api/v1/documents/tasks/t-done") + + body = resp.json() + assert body["code"] == 0 + data = body["data"] + assert data["status"] == "done" + assert data["result"]["document_id"] == "doc-1" + assert data["result"]["chunks_count"] == 3 + assert data["error"] is None + + +def test_get_task_failed(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: + """GET tasks/{id}:failed 任务 → data.error 含 stage/message/partial_summary""" + partial = {"l1_summary": "L1", "l2_outline": None, "l3_content_outline": "L3", "level": "L3"} + error = {"stage": "embed", "message": "向量化失败: boom", "partial_summary": partial} + manager = FakeManager(tasks={"t-failed": _task("t-failed", "failed", error=error)}) + _inject_manager(monkeypatch, manager) + + resp = client.get("/api/v1/documents/tasks/t-failed") + + body = resp.json() + assert body["code"] == 0 + data = body["data"] + assert data["status"] == "failed" + assert data["error"]["stage"] == "embed" + assert "boom" in data["error"]["message"] + assert data["error"]["partial_summary"] == partial + assert data["result"] is None + + +def test_get_task_not_found(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: + """GET tasks/{id}:不存在的 task_id → code=1004""" + _inject_manager(monkeypatch, FakeManager()) + + resp = client.get("/api/v1/documents/tasks/不存在") + + body = resp.json() + assert body["code"] == 1004 + assert body["message"] == "任务不存在" + assert body["data"] is None + + +def test_post_empty_text_creates_no_task(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: + """POST 空文本:code=1001,且不产生任务(submit 未被调用)""" + manager = FakeManager() + _inject_manager(monkeypatch, manager) + + resp = client.post("/api/v1/documents", json={"text": " "}) + + body = resp.json() + assert body["code"] == 1001 + assert manager.submitted == [] diff --git a/tests/test_ingest_tasks.py b/tests/test_ingest_tasks.py new file mode 100644 index 0000000..e4aa30a --- /dev/null +++ b/tests/test_ingest_tasks.py @@ -0,0 +1,200 @@ +"""IngestTaskManager 单元测试(FakeIngester/FakeRedis,不真实联网)""" + +import asyncio +from collections.abc import Callable +from datetime import datetime +from typing import Any + +from app.config import Settings +from app.core.ingest_tasks import REDIS_KEY_PREFIX, IngestTaskManager, IngestTaskStatus +from app.core.ingestion import IngestionError +from app.models.document import DocumentInput, DocumentSummary, IngestionResult, SummaryLevel + + +def make_summary() -> DocumentSummary: + """构造固定的三级总结""" + return DocumentSummary( + l1_summary="一句话总结", l2_outline=None, l3_content_outline="内容大纲", level=SummaryLevel.L3 + ) + + +def make_result(doc_id: str = "doc-1") -> IngestionResult: + """构造固定的入库结果""" + return IngestionResult( + document_id=doc_id, + summary=make_summary(), + category="tech", + collection="四层集合", + chunks_count=2, + tags=["t"], + category_confidence=0.9, + ) + + +class FakeIngester: + """假入库器:上报固定阶段序列;可配置抛错或用闸门阻塞以验证并发限流""" + + def __init__(self, error: Exception | None = None) -> None: + self.error = error + self.stages: list[str] = [] + self.calls: list[DocumentInput] = [] + self.gate: asyncio.Event | None = None + self.started = asyncio.Event() + + async def ingest(self, doc: DocumentInput, progress_cb: Callable[[str], None] | None = None) -> IngestionResult: + self.calls.append(doc) + self.started.set() + if progress_cb is not None: + for stage in ("summarizing", "classifying", "embedding", "writing"): + progress_cb(stage) + self.stages.append(stage) + if self.gate is not None: + await self.gate.wait() + if self.error is not None: + raise self.error + return make_result() + + +class FakeRedis: + """内存版 Redis:记录全部写入(含 TTL),可配置写入抛错模拟故障降级""" + + def __init__(self, fail_writes: bool = False) -> None: + self.fail_writes = fail_writes + self.writes: list[tuple[str, dict[str, Any], int | None]] = [] + self.store: dict[str, dict[str, Any]] = {} + + async def set_json(self, key: str, value: dict[str, Any], ttl: int | None = None) -> bool: + self.writes.append((key, value, ttl)) + if self.fail_writes: + raise RuntimeError("redis down") + self.store[key] = value + return True + + async def get_json(self, key: str) -> dict[str, Any] | None: + return self.store.get(key) + + +async def test_submit_returns_immediately_and_completes() -> None: + """submit 立即返回;任务后台跑完为 done,结果完整,阶段序列齐全,Redis 镜像同步""" + ingester = FakeIngester() + redis = FakeRedis() + manager = IngestTaskManager(ingester, redis, Settings()) + + task_id = await manager.submit(DocumentInput(text="正文", title="标题")) + assert isinstance(task_id, str) and len(task_id) == 32 + + # submit 后立即可查:状态在合法集合内(pending 或已进入某阶段) + record = await manager.get(task_id) + assert record is not None + assert record["status"] in set(IngestTaskStatus) + + final = await manager.wait_done(task_id, timeout=5) + assert final["status"] == IngestTaskStatus.DONE + assert final["result"] == make_result().model_dump(mode="json") + assert final["error"] is None + assert ingester.stages == ["summarizing", "classifying", "embedding", "writing"] + # 时间字段为 ISO8601 字符串 + datetime.fromisoformat(final["created_at"]) + datetime.fromisoformat(final["updated_at"]) + # Redis 镜像已写入终态 + mirrored = await redis.get_json(f"{REDIS_KEY_PREFIX}{task_id}") + assert mirrored is not None + assert mirrored["status"] == IngestTaskStatus.DONE + + +async def test_ingestion_error_marks_failed_with_stage_and_partial_summary() -> None: + """IngestionError:任务 failed,error.stage 透传,partial_summary 保留""" + summary = DocumentSummary(l1_summary="L1", l2_outline=None, l3_content_outline="L3", level=SummaryLevel.L3) + ingester = FakeIngester(error=IngestionError("classify", "分类判定失败: boom", summary=summary)) + manager = IngestTaskManager(ingester, FakeRedis(), Settings()) + + task_id = await manager.submit(DocumentInput(text="正文")) + final = await manager.wait_done(task_id, timeout=5) + assert final["status"] == IngestTaskStatus.FAILED + assert final["result"] is None + assert final["error"]["stage"] == "classify" + assert "boom" in final["error"]["message"] + assert final["error"]["partial_summary"] == summary.model_dump(mode="json") + + +async def test_unexpected_error_marks_failed_with_unknown_stage() -> None: + """普通异常:任务 failed,error.stage 为 unknown""" + ingester = FakeIngester(error=RuntimeError("炸了")) + manager = IngestTaskManager(ingester, None, Settings()) + + task_id = await manager.submit(DocumentInput(text="正文")) + final = await manager.wait_done(task_id, timeout=5) + assert final["status"] == IngestTaskStatus.FAILED + assert final["error"]["stage"] == "unknown" + assert "炸了" in final["error"]["message"] + + +async def test_concurrency_limited_by_semaphore() -> None: + """并发上限 1 时两个任务串行:第一个放行前第二个不得进入执行""" + ingester = FakeIngester() + ingester.gate = asyncio.Event() # 首次调用阻塞,验证第二个任务在排队 + manager = IngestTaskManager(ingester, None, Settings(ingest_max_concurrency=1)) + + task1 = await manager.submit(DocumentInput(text="a", title="t1")) + task2 = await manager.submit(DocumentInput(text="b", title="t2")) + await asyncio.wait_for(ingester.started.wait(), timeout=1) + await asyncio.sleep(0.05) # 给第二个任务调度机会 + assert len(ingester.calls) == 1 + queued = await manager.get(task2) + assert queued is not None + assert queued["status"] == IngestTaskStatus.PENDING + + ingester.gate.set() + final1 = await manager.wait_done(task1, timeout=5) + final2 = await manager.wait_done(task2, timeout=5) + assert final1["status"] == IngestTaskStatus.DONE + assert final2["status"] == IngestTaskStatus.DONE + assert [doc.title for doc in ingester.calls] == ["t1", "t2"] + + +async def test_redis_write_failure_does_not_break_task() -> None: + """Redis 写失败仅告警:任务仍正常跑完为 done""" + redis = FakeRedis(fail_writes=True) + manager = IngestTaskManager(FakeIngester(), redis, Settings()) + + task_id = await manager.submit(DocumentInput(text="正文")) + final = await manager.wait_done(task_id, timeout=5) + assert final["status"] == IngestTaskStatus.DONE + assert final["result"] is not None + assert redis.writes # 确实尝试过写 Redis + + +async def test_redis_mirror_ttl_done_and_failed() -> None: + """Redis 镜像 TTL:进行中与 done 用 ttl_done,failed 用 ttl_failed""" + settings = Settings(ingest_task_ttl_done=86400, ingest_task_ttl_failed=604800) + + redis = FakeRedis() + manager = IngestTaskManager(FakeIngester(), redis, settings) + done_id = await manager.submit(DocumentInput(text="x")) + await manager.wait_done(done_id, timeout=5) + done_writes = [(v, ttl) for key, v, ttl in redis.writes if key.endswith(done_id)] + assert done_writes + assert all(ttl == 86400 for _, ttl in done_writes) + assert done_writes[-1][0]["status"] == IngestTaskStatus.DONE + + redis2 = FakeRedis() + failing_manager = IngestTaskManager(FakeIngester(error=RuntimeError("boom")), redis2, settings) + failed_id = await failing_manager.submit(DocumentInput(text="y")) + await failing_manager.wait_done(failed_id, timeout=5) + failed_writes = [(v, ttl) for key, v, ttl in redis2.writes if key.endswith(failed_id)] + assert failed_writes + assert failed_writes[-1][0]["status"] == IngestTaskStatus.FAILED + assert failed_writes[-1][1] == 604800 + + +async def test_get_falls_back_to_redis_then_none() -> None: + """get:内存 miss 时回查 Redis;两者都没有返回 None""" + redis = FakeRedis() + manager = IngestTaskManager(FakeIngester(), redis, Settings()) + + assert await manager.get("不存在") is None + + redis.store[f"{REDIS_KEY_PREFIX}abc"] = {"task_id": "abc", "status": "done"} + record = await manager.get("abc") + assert record is not None + assert record["status"] == "done" diff --git a/tests/test_ingestion.py b/tests/test_ingestion.py new file mode 100644 index 0000000..30dc96c --- /dev/null +++ b/tests/test_ingestion.py @@ -0,0 +1,273 @@ +"""Ingester 全链路单元测试(Summarizer/Classifier/Embedding/Qdrant 均为假实现,不真实联网)""" + +from typing import Any + +import pytest + +from app.core.chunker import Chunker +from app.core.ingestion import Ingester, IngestionError +from app.core.sparse import SparseEncoder +from app.models.document import DocumentInput, DocumentSummary, SummaryLevel +from app.models.knowledge import CategoryResult +from app.services.qdrant import COLLECTION_L2, COLLECTION_L3 + + +class FakeSummarizer: + """返回固定总结结果的假 Summarizer""" + + def __init__(self, summary: DocumentSummary) -> None: + self.summary = summary + self.calls: list[tuple[str, str]] = [] + + async def summarize(self, text: str, *, title: str = "") -> DocumentSummary: + self.calls.append((text, title)) + return self.summary + + +class FakeClassifier: + """返回固定分类结果的假 Classifier""" + + def __init__(self, result: CategoryResult) -> None: + self.result = result + self.calls: list[tuple[str, str]] = [] + + async def classify(self, l1_summary: str, title: str = "") -> CategoryResult: + self.calls.append((l1_summary, title)) + return self.result + + +class FakeEmbedding: + """按输入数量返回伪向量的假 EmbeddingService,记录每次调用的文本""" + + def __init__(self) -> None: + self.calls: list[list[str]] = [] + + async def embed(self, texts: list[str]) -> list[list[float]]: + self.calls.append(list(texts)) + return [[float(i), 1.0] for i in range(len(texts))] + + +class FakeQdrant: + """内存版 QdrantService,记录各层 upsert 调用;可配置在某一层抛错""" + + def __init__(self, fail_on: str = "") -> None: + self.fail_on = fail_on + self.l1_calls: list[dict[str, Any]] = [] + self.nodes_calls: list[tuple[str, list[dict[str, Any]]]] = [] + self.chunks_calls: list[list[dict[str, Any]]] = [] + + async def upsert_l1( + self, + doc_id: str, + title: str, + summary: str, + category: str, + tags: list[str], + dense_vector: list[float], + sparse_vector: Any = None, + ) -> None: + if self.fail_on == "l1": + raise RuntimeError("qdrant down") + self.l1_calls.append( + { + "doc_id": doc_id, + "title": title, + "summary": summary, + "category": category, + "tags": tags, + "dense_vector": dense_vector, + "sparse_vector": sparse_vector, + } + ) + + async def upsert_nodes(self, collection: str, nodes: list[dict[str, Any]]) -> None: + if self.fail_on == collection: + raise RuntimeError("qdrant down") + self.nodes_calls.append((collection, nodes)) + + async def upsert_chunks(self, chunks: list[dict[str, Any]]) -> None: + if self.fail_on == "chunks": + raise RuntimeError("qdrant down") + self.chunks_calls.append(chunks) + + +def _make_ingester( + summary: DocumentSummary, + category: CategoryResult, + qdrant: FakeQdrant, + embedding: FakeEmbedding | None = None, +) -> Ingester: + return Ingester( + summarizer=FakeSummarizer(summary), # type: ignore[arg-type] + classifier=FakeClassifier(category), # type: ignore[arg-type] + chunker=Chunker(), + embedding=embedding or FakeEmbedding(), # type: ignore[arg-type] + sparse=SparseEncoder(), + qdrant=qdrant, # type: ignore[arg-type] + ) + + +def _structured_doc() -> DocumentInput: + """带标题结构的长文档(切出多个 chunk,L2 走标题树)""" + paragraph = "这是章节正文内容,包含足够多的信息量,用于测试切分与向量化流程。" * 10 + text = f"# 安装指南\n{paragraph}\n\n## 环境准备\n{paragraph}\n\n## 安装步骤\n{paragraph}" + return DocumentInput(text=text, title="安装文档") + + +def _structured_summary() -> DocumentSummary: + return DocumentSummary( + l1_summary="本文介绍软件的安装流程。", + l2_outline="- 安装指南\n - 环境准备\n - 安装步骤", + l3_content_outline=( + "## 安装指南\n整体安装流程说明。\n## 环境准备\n准备依赖环境。\n## 安装步骤\n执行安装命令。" + ), + level=SummaryLevel.L3, + ) + + +def _category() -> CategoryResult: + return CategoryResult(main_category="技术文档", tags=["安装", "运维"], confidence=0.9) + + +class TestStructuredDocument: + """结构化长文档:四层集合 upsert 均被调用,结果字段透传""" + + async def test_full_pipeline(self): + doc = _structured_doc() + summary = _structured_summary() + qdrant = FakeQdrant() + embedding = FakeEmbedding() + ingester = _make_ingester(summary, _category(), qdrant, embedding) + + result = await ingester.ingest(doc) + + # 结果字段透传 + assert result.document_id + assert result.summary is summary + assert result.category == "技术文档" + assert result.tags == ["安装", "运维"] + assert result.category_confidence == 0.9 + + # chunk 数与 Chunker 直出一致 + expected_chunks = Chunker().chunk(doc.text, "expected") + assert result.chunks_count == len(expected_chunks) > 1 + + # 批量 embedding 恰好一次:L1 + L2 节点 + L3 节点 + chunks + assert len(embedding.calls) == 1 + l2_calls = [nodes for c, nodes in qdrant.nodes_calls if c == COLLECTION_L2] + l3_calls = [nodes for c, nodes in qdrant.nodes_calls if c == COLLECTION_L3] + assert len(embedding.calls[0]) == 1 + len(l2_calls[0]) + len(l3_calls[0]) + result.chunks_count + + # L1:category/tags 透传,sparse 已启用 + assert len(qdrant.l1_calls) == 1 + l1 = qdrant.l1_calls[0] + assert l1["doc_id"] == result.document_id + assert l1["title"] == "安装文档" + assert l1["summary"] == summary.l1_summary + assert l1["category"] == "技术文档" + assert l1["tags"] == ["安装", "运维"] + assert l1["sparse_vector"] is not None + + # L2:每个标题一个节点,text 与 section_path 均为祖先标题链 + assert len(l2_calls) == 1 + l2_nodes = l2_calls[0] + assert len(l2_nodes) == 3 + assert [n["section_path"] for n in l2_nodes] == [ + "安装指南", + "安装指南 / 环境准备", + "安装指南 / 安装步骤", + ] + assert all(n["text"] == n["section_path"] for n in l2_nodes) + assert all(n["category"] == "技术文档" and n["tags"] == ["安装", "运维"] for n in l2_nodes) + + # L3:按 "## " 分块,section_path 精确匹配到标题链 + assert len(l3_calls) == 1 + l3_nodes = l3_calls[0] + assert len(l3_nodes) == 3 + assert [n["section_path"] for n in l3_nodes] == [ + "安装指南", + "安装指南 / 环境准备", + "安装指南 / 安装步骤", + ] + assert l3_nodes[0]["text"].startswith("## 安装指南") + + # chunks:携带 doc_summary 与 sparse 向量 + assert len(qdrant.chunks_calls) == 1 + chunk_dicts = qdrant.chunks_calls[0] + assert len(chunk_dicts) == result.chunks_count + assert all(c["doc_summary"] == summary.l1_summary for c in chunk_dicts) + assert all(c["sparse_vector"] is not None for c in chunk_dicts) + assert all(c["category"] == "技术文档" for c in chunk_dicts) + assert [c["chunk_index"] for c in chunk_dicts] == list(range(result.chunks_count)) + + +class TestFallbackDocuments: + """2.5 级文档与无结构文档的 L2/L3 节点构建""" + + async def test_l2_half_document_skips_l2_upsert(self): + """2.5 级文档(l2_outline=None)→ 不写 L2 集合,L3 整块一个节点""" + summary = DocumentSummary( + l1_summary="一条简短通知。", + l2_outline=None, + l3_content_outline="要点一:明天放假。\n要点二:注意安全。", + level=SummaryLevel.L2_HALF, + ) + doc = DocumentInput(text="简短通知正文,无标题结构。", title="通知") + qdrant = FakeQdrant() + ingester = _make_ingester(summary, _category(), qdrant) + + result = await ingester.ingest(doc) + + collections = [c for c, _ in qdrant.nodes_calls] + assert COLLECTION_L2 not in collections + # L3 内容大纲无 "## " → 整块一个节点,section_path 为空 + l3_calls = [nodes for c, nodes in qdrant.nodes_calls if c == COLLECTION_L3] + assert len(l3_calls) == 1 + assert len(l3_calls[0]) == 1 + assert l3_calls[0][0]["section_path"] == "" + assert l3_calls[0][0]["text"] == summary.l3_content_outline + # 其余层级正常写入 + assert len(qdrant.l1_calls) == 1 + assert result.chunks_count == len(qdrant.chunks_calls[0]) + + async def test_l2_from_llm_outline_lines(self): + """无标题结构的 L3 级文档 → L2 节点来自 LLM 大纲行,section_path 为空""" + summary = DocumentSummary( + l1_summary="本文介绍两个主题。", + l2_outline="1. 主题一\n2. 主题二", + l3_content_outline="详细摘要内容,无分块标题。", + level=SummaryLevel.L3, + ) + text = "这是一段没有标题结构的正文内容," * 40 + doc = DocumentInput(text=text, title="") + qdrant = FakeQdrant() + ingester = _make_ingester(summary, _category(), qdrant) + + await ingester.ingest(doc) + + l2_calls = [nodes for c, nodes in qdrant.nodes_calls if c == COLLECTION_L2] + assert len(l2_calls) == 1 + l2_nodes = l2_calls[0] + assert [n["text"] for n in l2_nodes] == ["1. 主题一", "2. 主题二"] + assert all(n["section_path"] == "" for n in l2_nodes) + + +class TestQdrantFailure: + """Qdrant 写入失败:抛 IngestionError,stage=qdrant,总结不丢可重试""" + + async def test_chunks_upsert_failure(self): + doc = _structured_doc() + summary = _structured_summary() + qdrant = FakeQdrant(fail_on="chunks") + ingester = _make_ingester(summary, _category(), qdrant) + + with pytest.raises(IngestionError) as exc_info: + await ingester.ingest(doc) + + err = exc_info.value + assert err.stage == "qdrant" + assert err.summary is summary + # L1/L2/L3 已写入,失败发生在 chunks 层 + assert len(qdrant.l1_calls) == 1 + assert {c for c, _ in qdrant.nodes_calls} == {COLLECTION_L2, COLLECTION_L3} + assert qdrant.chunks_calls == [] diff --git a/tests/test_judge.py b/tests/test_judge.py new file mode 100644 index 0000000..16338dd --- /dev/null +++ b/tests/test_judge.py @@ -0,0 +1,99 @@ +"""LLM-as-judge 评测指标单元测试(FakeOllama 替身,不依赖真实模型)""" + +import pytest + +from scripts.eval.judge import hallucination_rate, taxonomy_consistency + + +class FakeOllama: + """记录 prompt 并按序返回预设响应(或抛出预设异常)的 OllamaClient 替身""" + + def __init__(self, responses: list[str] | None = None, error: Exception | None = None) -> None: + self.prompts: list[str] = [] + self.json_modes: list[bool] = [] + self._responses = list(responses or []) + self._error = error + + async def generate(self, prompt: str, json_mode: bool = False) -> str: + self.prompts.append(prompt) + self.json_modes.append(json_mode) + if self._error is not None: + raise self._error + return self._responses.pop(0) + + +def _assertions_json(*supported: bool) -> str: + """构造 hallucination 判定的 JSON 输出,每个 bool 对应一条断言的 supported""" + claims = ", ".join(f'{{"claim": "断言{i}", "supported": {str(flag).lower()}}}' for i, flag in enumerate(supported)) + return f'{{"assertions": [{claims}]}}' + + +class TestHallucinationRate: + async def test_all_supported_returns_zero(self) -> None: + ollama = FakeOllama([_assertions_json(True, True, True)]) + assert await hallucination_rate("摘要", "原文", ollama) == 0.0 # type: ignore[arg-type] + + async def test_prompt_contains_source_and_summary(self) -> None: + ollama = FakeOllama([_assertions_json(True)]) + await hallucination_rate("这是摘要内容", "这是原文内容", ollama) # type: ignore[arg-type] + assert len(ollama.prompts) == 1 + assert "这是摘要内容" in ollama.prompts[0] + assert "这是原文内容" in ollama.prompts[0] + assert ollama.json_modes == [True] + + async def test_two_of_five_unsupported(self) -> None: + ollama = FakeOllama([_assertions_json(True, False, True, False, True)]) + rate = await hallucination_rate("摘要", "原文", ollama) # type: ignore[arg-type] + assert rate == pytest.approx(0.4) + + async def test_non_json_output_returns_zero(self) -> None: + ollama = FakeOllama(["我无法完成这个判定任务。"]) + assert await hallucination_rate("摘要", "原文", ollama) == 0.0 # type: ignore[arg-type] + + async def test_missing_assertions_field_returns_zero(self) -> None: + ollama = FakeOllama(['{"result": "ok"}']) + assert await hallucination_rate("摘要", "原文", ollama) == 0.0 # type: ignore[arg-type] + + async def test_generate_error_returns_zero(self) -> None: + ollama = FakeOllama(error=RuntimeError("Ollama 不可用")) + assert await hallucination_rate("摘要", "原文", ollama) == 0.0 # type: ignore[arg-type] + + async def test_noisy_output_json_extracted(self) -> None: + # 模型输出前后带噪声时仍能提取首个 JSON 对象 + ollama = FakeOllama([f"先分析一下。\n{_assertions_json(True, False)}\n以上。"]) + rate = await hallucination_rate("摘要", "原文", ollama) # type: ignore[arg-type] + assert rate == pytest.approx(0.5) + + async def test_claims_truncated_to_max(self) -> None: + # 超过 _MAX_CLAIMS(5) 的断言不计入:前 5 条全支持,第 6/7 条不支持 → 0.0 + ollama = FakeOllama([_assertions_json(True, True, True, True, True, False, False)]) + assert await hallucination_rate("摘要", "原文", ollama) == 0.0 # type: ignore[arg-type] + + +class TestTaxonomyConsistency: + async def test_consistent_true(self) -> None: + ollama = FakeOllama(['{"consistent": true}']) + assert await taxonomy_consistency("摘要", "制度", ollama) is True # type: ignore[arg-type] + + async def test_consistent_false(self) -> None: + ollama = FakeOllama(['{"consistent": false}']) + assert await taxonomy_consistency("摘要", "制度", ollama) is False # type: ignore[arg-type] + + async def test_prompt_contains_category_and_summary(self) -> None: + ollama = FakeOllama(['{"consistent": true}']) + await taxonomy_consistency("这是摘要内容", "人事制度", ollama) # type: ignore[arg-type] + assert "人事制度" in ollama.prompts[0] + assert "这是摘要内容" in ollama.prompts[0] + assert ollama.json_modes == [True] + + async def test_non_json_output_returns_default_true(self) -> None: + ollama = FakeOllama(["无法判定"]) + assert await taxonomy_consistency("摘要", "制度", ollama) is True # type: ignore[arg-type] + + async def test_missing_consistent_field_returns_default_true(self) -> None: + ollama = FakeOllama(['{"ok": 1}']) + assert await taxonomy_consistency("摘要", "制度", ollama) is True # type: ignore[arg-type] + + async def test_generate_error_returns_default_true(self) -> None: + ollama = FakeOllama(error=RuntimeError("Ollama 不可用")) + assert await taxonomy_consistency("摘要", "制度", ollama) is True # type: ignore[arg-type] diff --git a/tests/test_knowledge_api.py b/tests/test_knowledge_api.py new file mode 100644 index 0000000..8aa6180 --- /dev/null +++ b/tests/test_knowledge_api.py @@ -0,0 +1,38 @@ +"""知识分类 API 测试(Qdrant 为 mock,不真实联网)""" + +from collections.abc import Iterator + +import pytest +from fastapi.testclient import TestClient + +from app.main import app +from app.models.knowledge import UNCATEGORIZED, load_taxonomy +from app.services.qdrant import QdrantService + + +@pytest.fixture +def client(monkeypatch: pytest.MonkeyPatch) -> Iterator[TestClient]: + """TestClient,lifespan 中的 Qdrant 集合初始化替换为空操作""" + + async def _noop_ensure_collections(self: QdrantService) -> None: + return None + + monkeypatch.setattr(QdrantService, "ensure_collections", _noop_ensure_collections) + with TestClient(app) as test_client: + yield test_client + + +def test_list_categories(client: TestClient) -> None: + """categories 返回完整 taxonomy,且含 uncategorized""" + resp = client.get("/api/v1/knowledge/categories") + + assert resp.status_code == 200 + body = resp.json() + assert body["code"] == 0 + + expected = load_taxonomy() + data = body["data"] + assert data["count"] == len(expected) + names = [c["name"] for c in data["categories"]] + assert names == [c.name for c in expected] + assert UNCATEGORIZED in names diff --git a/tests/test_knowledge_stats_api.py b/tests/test_knowledge_stats_api.py new file mode 100644 index 0000000..f45cb79 --- /dev/null +++ b/tests/test_knowledge_stats_api.py @@ -0,0 +1,175 @@ +"""知识统计 API 测试(GET /api/v1/knowledge/stats) + +两层覆盖: +1. TestClient + monkeypatch 注入假 Qdrant 服务:响应结构与异常路径 +2. 真实内存 Qdrant(location=":memory:")集成:stats 数值与实际写入一致 +""" + +from collections.abc import Iterator +from typing import Any + +import pytest +from fastapi.testclient import TestClient +from qdrant_client import AsyncQdrantClient + +from app.api.v1 import knowledge as knowledge_module +from app.config import settings +from app.main import app +from app.services.qdrant import ( + COLLECTION_CHUNKS, + COLLECTION_L1, + COLLECTION_L2, + COLLECTION_L3, + QdrantService, +) + +DIM = settings.embedding_dimension + + +def _dense(seed: float) -> list[float]: + """构造确定性 dense 向量""" + vec = [0.0] * DIM + vec[0] = seed + vec[1] = 1.0 - seed + vec[2] = seed * 0.5 + vec[3] = 0.1 + return vec + + +@pytest.fixture +def client(monkeypatch: pytest.MonkeyPatch) -> Iterator[TestClient]: + """TestClient,lifespan 中的 Qdrant 集合初始化替换为空操作""" + + async def _noop_ensure_collections(self: QdrantService) -> None: + return None + + monkeypatch.setattr(QdrantService, "ensure_collections", _noop_ensure_collections) + with TestClient(app) as test_client: + yield test_client + + +class _FakeQdrantService: + """假 Qdrant 服务:固定集合计数,scroll_l1 分 2 页返回类目""" + + COUNTS = {COLLECTION_L1: 3, COLLECTION_L2: 6, COLLECTION_L3: 4, COLLECTION_CHUNKS: 9} + PAGES = [ + ([{"category": "技术文档"}, {"category": "技术文档"}], "cursor-1"), + ([{"category": "uncategorized"}], None), + ] + + async def count(self, collection: str) -> int: + return self.COUNTS[collection] + + async def scroll_l1( + self, limit: int = 100, offset: str | None = None + ) -> tuple[list[dict[str, Any]], str | None]: + assert limit == 100 + return self.PAGES[0] if offset is None else self.PAGES[1] + + +class _FailingQdrantService: + """count 即抛异常的假服务,用于验证错误包装""" + + async def count(self, collection: str) -> int: + raise RuntimeError("qdrant 连接失败") + + async def scroll_l1( + self, limit: int = 100, offset: str | None = None + ) -> tuple[list[dict[str, Any]], str | None]: + raise RuntimeError("qdrant 连接失败") + + +def test_stats_ok(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: + """正常返回:四字段结构完整,数值来自假服务,类目跨页聚合""" + monkeypatch.setattr(knowledge_module, "_get_qdrant", lambda: _FakeQdrantService()) + + resp = client.get("/api/v1/knowledge/stats") + + assert resp.status_code == 200 + body = resp.json() + assert body["code"] == 0 + data = body["data"] + assert set(data.keys()) == {"collections", "categories", "uncategorized_count", "documents_total"} + assert data["collections"] == {"doc_l1": 3, "doc_l2": 6, "doc_l3": 4, "chunks": 9} + assert data["categories"] == {"技术文档": 2, "uncategorized": 1} + assert data["uncategorized_count"] == 1 + assert data["documents_total"] == 3 + + +def test_stats_qdrant_error(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: + """Qdrant 异常 → code 2000 统一错误响应""" + monkeypatch.setattr(knowledge_module, "_get_qdrant", lambda: _FailingQdrantService()) + + resp = client.get("/api/v1/knowledge/stats") + + assert resp.status_code == 200 + body = resp.json() + assert body["code"] == 2000 + assert body["data"] is None + assert "获取知识库统计失败" in body["message"] + + +@pytest.fixture +async def memory_service() -> QdrantService: + """真实内存 Qdrant 服务,集合并写入 3 篇文档与若干 chunk""" + service = QdrantService(client=AsyncQdrantClient(location=":memory:")) + await service.ensure_collections() + + # 3 篇 L1:2 篇同类目 + 1 篇 uncategorized + await service.upsert_l1( + doc_id="doc-a", + title="标题A", + summary="总结A", + category="技术文档", + tags=["api"], + dense_vector=_dense(0.9), + ) + await service.upsert_l1( + doc_id="doc-b", + title="标题B", + summary="总结B", + category="技术文档", + tags=["sdk"], + dense_vector=_dense(0.8), + ) + await service.upsert_l1( + doc_id="doc-c", + title="标题C", + summary="总结C", + category="uncategorized", + tags=[], + dense_vector=_dense(0.7), + ) + # 5 个 chunk(doc-a 3 个、doc-c 2 个) + chunks = [ + { + "doc_id": doc_id, + "chunk_index": i, + "text": f"chunk{i}-{doc_id}", + "section_path": "1", + "title": f"标题-{doc_id}", + "category": category, + "tags": [], + "dense_vector": _dense(0.5 + i * 0.1), + } + for doc_id, category, n in (("doc-a", "技术文档", 3), ("doc-c", "uncategorized", 2)) + for i in range(n) + ] + await service.upsert_chunks(chunks) + return service + + +async def test_stats_with_real_memory_qdrant( + memory_service: QdrantService, monkeypatch: pytest.MonkeyPatch +) -> None: + """stats 数值与真实内存 Qdrant 写入一致(直接调路由处理函数)""" + monkeypatch.setattr(knowledge_module, "_get_qdrant", lambda: memory_service) + + body = await knowledge_module.knowledge_stats() + + assert body["code"] == 0 + data = body["data"] + assert data["collections"] == {"doc_l1": 3, "doc_l2": 0, "doc_l3": 0, "chunks": 5} + assert data["documents_total"] == 3 + assert data["categories"] == {"技术文档": 2, "uncategorized": 1} + assert data["uncategorized_count"] == 1 diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 0000000..e7b0b5a --- /dev/null +++ b/tests/test_models.py @@ -0,0 +1,134 @@ +"""数据模型与 taxonomy 加载的单元测试""" + +import json + +import pytest +from pydantic import ValidationError + +from app.models.document import ChunkModel +from app.models.knowledge import ( + UNCATEGORIZED, + CategoryResult, + TaxonomyCategory, + load_taxonomy, +) +from app.models.search import SearchHit, SearchRequest, SearchResponse + + +class TestLoadTaxonomy: + """taxonomy 加载与校验""" + + def test_default_taxonomy(self): + """空路径时使用内置默认类目集""" + taxonomy = load_taxonomy("") + assert len(taxonomy) >= 6 + names = [c.name for c in taxonomy] + assert UNCATEGORIZED in names + assert len(names) == len(set(names)) + assert all(isinstance(c, TaxonomyCategory) for c in taxonomy) + + def test_load_from_json_file(self, tmp_path): + """从 JSON 文件加载自定义类目集""" + data = [ + {"name": "技术", "description": "技术类文档"}, + {"name": UNCATEGORIZED, "description": "未分类"}, + ] + file = tmp_path / "taxonomy.json" + file.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8") + + taxonomy = load_taxonomy(str(file)) + assert [c.name for c in taxonomy] == ["技术", UNCATEGORIZED] + assert taxonomy[0].description == "技术类文档" + + def test_append_uncategorized_when_missing(self, tmp_path): + """JSON 文件缺少 uncategorized 时自动追加""" + data = [{"name": "技术"}, {"name": "产品"}] + file = tmp_path / "taxonomy.json" + file.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8") + + taxonomy = load_taxonomy(str(file)) + names = [c.name for c in taxonomy] + assert names == ["技术", "产品", UNCATEGORIZED] + + def test_duplicate_name_raises(self, tmp_path): + """类目 name 重复时报错""" + data = [{"name": "技术"}, {"name": "技术"}] + file = tmp_path / "taxonomy.json" + file.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8") + + with pytest.raises(ValueError, match="重复"): + load_taxonomy(str(file)) + + +class TestCategoryResult: + """CategoryResult 字段校验""" + + def test_defaults(self): + result = CategoryResult(main_category="技术文档", confidence=0.9) + assert result.tags == [] + assert result.confidence == 0.9 + + def test_confidence_out_of_range(self): + """confidence 越界(>1 或 <0)时校验失败""" + with pytest.raises(ValidationError): + CategoryResult(main_category="技术文档", confidence=1.5) + with pytest.raises(ValidationError): + CategoryResult(main_category="技术文档", confidence=-0.1) + + def test_confidence_boundary_values(self): + """confidence 边界值 0 和 1 合法""" + assert CategoryResult(main_category="a", confidence=0).confidence == 0 + assert CategoryResult(main_category="a", confidence=1).confidence == 1 + + +class TestSearchModels: + """检索模型字段校验""" + + def test_search_hit(self): + hit = SearchHit(text="内容", doc_id="doc-1", score=0.85) + assert hit.title == "" + assert hit.section_path == "" + assert hit.doc_summary == "" + + def test_search_hit_full_fields(self): + hit = SearchHit( + text="内容", + doc_id="doc-1", + title="标题", + section_path="第一章/第一节", + score=0.85, + doc_summary="一句话总结", + ) + assert hit.section_path == "第一章/第一节" + + def test_search_hit_missing_required(self): + """缺少必填字段时校验失败""" + with pytest.raises(ValidationError): + SearchHit(text="内容", doc_id="doc-1") # type: ignore[call-arg] + + def test_search_request_default_top_k(self): + req = SearchRequest(query="如何报销") + assert req.top_k is None + + def test_search_response_defaults(self): + resp = SearchResponse(query="q", hits=[]) + assert resp.routed_categories == [] + assert resp.fallback is False + + +class TestChunkModel: + """ChunkModel 字段校验""" + + def test_defaults(self): + chunk = ChunkModel(doc_id="doc-1", chunk_index=0, text="第一段") + assert chunk.section_path == "" + + def test_full_fields(self): + chunk = ChunkModel(doc_id="doc-1", chunk_index=2, text="第三段", section_path="第二章") + assert chunk.chunk_index == 2 + assert chunk.section_path == "第二章" + + def test_missing_required(self): + """缺少必填字段时校验失败""" + with pytest.raises(ValidationError): + ChunkModel(doc_id="doc-1", chunk_index=0) # type: ignore[call-arg] diff --git a/tests/test_ollama_client.py b/tests/test_ollama_client.py new file mode 100644 index 0000000..fdf4ef4 --- /dev/null +++ b/tests/test_ollama_client.py @@ -0,0 +1,126 @@ +"""OllamaClient 单元测试(mock httpx,不发起真实网络请求)""" + +from typing import Any + +import httpx +import pytest + +from app.services.ollama import OllamaClient + + +def _make_response(status_code: int, data: dict | None = None) -> httpx.Response: + """构造带 request 上下文的 httpx.Response(raise_for_status 依赖 request)""" + request = httpx.Request("POST", "http://localhost:11434/api/generate") + if data is None: + return httpx.Response(status_code, request=request) + return httpx.Response(status_code, json=data, request=request) + + +class _FakeAsyncClient: + """httpx.AsyncClient 替代品:记录请求参数,返回预设响应或抛出预设异常""" + + def __init__( + self, + captured: dict, + response: httpx.Response | None = None, + error: Exception | None = None, + **kwargs: Any, + ) -> None: + self._captured = captured + self._response = response + self._error = error + captured["timeout"] = kwargs.get("timeout") + + async def __aenter__(self) -> "_FakeAsyncClient": + return self + + async def __aexit__(self, *args: object) -> bool: + return False + + async def post(self, url: str, json: dict | None = None) -> httpx.Response: + self._captured["url"] = url + self._captured["json"] = json + if self._error is not None: + raise self._error + assert self._response is not None + return self._response + + async def get(self, url: str) -> httpx.Response: + self._captured["url"] = url + if self._error is not None: + raise self._error + assert self._response is not None + return self._response + + +class TestGenerate: + async def test_returns_response_field(self, monkeypatch: pytest.MonkeyPatch) -> None: + captured: dict = {} + monkeypatch.setattr( + httpx, + "AsyncClient", + lambda **kw: _FakeAsyncClient(captured, response=_make_response(200, {"response": "生成结果"}), **kw), + ) + client = OllamaClient(base_url="http://localhost:11434/", model="qwen2.5:1.5b") + + result = await client.generate("你好") + + assert result == "生成结果" + # base_url 尾部斜杠被去除 + assert captured["url"] == "http://localhost:11434/api/generate" + assert captured["json"] == {"model": "qwen2.5:1.5b", "prompt": "你好", "stream": False} + assert "format" not in captured["json"] + assert captured["timeout"] == 120.0 + + async def test_json_mode_adds_format(self, monkeypatch: pytest.MonkeyPatch) -> None: + captured: dict = {} + monkeypatch.setattr( + httpx, + "AsyncClient", + lambda **kw: _FakeAsyncClient(captured, response=_make_response(200, {"response": "{}"}), **kw), + ) + client = OllamaClient(base_url="http://localhost:11434", model="qwen2.5:1.5b") + + await client.generate("你好", json_mode=True) + + assert captured["json"]["format"] == "json" + + async def test_http_error_status_raises(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + httpx, "AsyncClient", lambda **kw: _FakeAsyncClient({}, response=_make_response(500), **kw) + ) + client = OllamaClient(base_url="http://localhost:11434", model="qwen2.5:1.5b") + + with pytest.raises(httpx.HTTPStatusError): + await client.generate("你好") + + +class TestIsAvailable: + async def test_200_returns_true(self, monkeypatch: pytest.MonkeyPatch) -> None: + captured: dict = {} + monkeypatch.setattr( + httpx, + "AsyncClient", + lambda **kw: _FakeAsyncClient(captured, response=_make_response(200, {"models": []}), **kw), + ) + client = OllamaClient(base_url="http://localhost:11434", model="qwen2.5:1.5b") + + assert await client.is_available() is True + assert captured["url"] == "http://localhost:11434/api/tags" + assert captured["timeout"] == 5.0 + + async def test_non_200_returns_false(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + httpx, "AsyncClient", lambda **kw: _FakeAsyncClient({}, response=_make_response(503), **kw) + ) + client = OllamaClient(base_url="http://localhost:11434", model="qwen2.5:1.5b") + + assert await client.is_available() is False + + async def test_connect_error_returns_false(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + httpx, "AsyncClient", lambda **kw: _FakeAsyncClient({}, error=httpx.ConnectError("连接被拒绝"), **kw) + ) + client = OllamaClient(base_url="http://localhost:11434", model="qwen2.5:1.5b") + + assert await client.is_available() is False diff --git a/tests/test_qdrant.py b/tests/test_qdrant.py new file mode 100644 index 0000000..f66a847 --- /dev/null +++ b/tests/test_qdrant.py @@ -0,0 +1,236 @@ +"""QdrantService 测试 + +使用 AsyncQdrantClient(location=":memory:") 本地模式,无需 Docker。 +""" + +import pytest +from qdrant_client import AsyncQdrantClient + +from app.config import settings +from app.services.qdrant import ( + ALL_COLLECTIONS, + COLLECTION_CHUNKS, + COLLECTION_L1, + COLLECTION_L2, + COLLECTION_L3, + QdrantService, +) + +DIM = settings.embedding_dimension + + +def _dense(seed: float) -> list[float]: + """构造确定性 dense 向量:前 4 维取特征值,便于区分不同文档""" + vec = [0.0] * DIM + vec[0] = seed + vec[1] = 1.0 - seed + vec[2] = seed * 0.5 + vec[3] = 0.1 + return vec + + +@pytest.fixture +async def service() -> QdrantService: + svc = QdrantService(client=AsyncQdrantClient(location=":memory:")) + await svc.ensure_collections() + return svc + + +async def test_ensure_collections_idempotent(service: QdrantService) -> None: + """重复调用 ensure_collections 不报错,且 4 个集合均存在""" + await service.ensure_collections() # fixture 中已调一次,这里第二次 + collections = await service.client.get_collections() + names = {c.name for c in collections.collections} + assert set(ALL_COLLECTIONS) <= names + + # L1 与 chunks 应配置 sparse 命名向量 + for name in (COLLECTION_L1, COLLECTION_CHUNKS): + info = await service.client.get_collection(name) + assert info.config.params.sparse_vectors is not None + assert "sparse" in info.config.params.sparse_vectors + + +async def test_upsert_l1_and_filter_by_doc_id(service: QdrantService) -> None: + await service.upsert_l1( + doc_id="doc-1", + title="文档一", + summary="这是文档一的总结", + category="tech", + tags=["ai", "rag"], + dense_vector=_dense(0.9), + sparse_vector=([1, 2, 3], [0.5, 0.3, 0.2]), + ) + # 重复写入(幂等覆盖)不报错 + await service.upsert_l1( + doc_id="doc-1", + title="文档一", + summary="这是文档一的总结(更新)", + category="tech", + tags=["ai", "rag"], + dense_vector=_dense(0.9), + ) + + results = await service.search_dense( + COLLECTION_L1, + _dense(0.9), + limit=5, + query_filter=QdrantService.build_filter(doc_ids=["doc-1"]), + ) + assert len(results) == 1 + assert results[0].payload is not None + assert results[0].payload["doc_id"] == "doc-1" + assert results[0].payload["text"] == "这是文档一的总结(更新)" + + +async def test_upsert_nodes(service: QdrantService) -> None: + nodes = [ + { + "doc_id": "doc-2", + "section_path": "1", + "text": "第一章大纲", + "category": "tech", + "tags": ["db"], + "dense_vector": _dense(0.8), + }, + { + "doc_id": "doc-2", + "section_path": "2", + "text": "第二章大纲", + "category": "tech", + "tags": ["db"], + "dense_vector": _dense(0.7), + }, + ] + await service.upsert_nodes(COLLECTION_L2, nodes) + await service.upsert_nodes(COLLECTION_L3, nodes) + + for collection in (COLLECTION_L2, COLLECTION_L3): + results = await service.search_dense( + collection, + _dense(0.8), + limit=10, + query_filter=QdrantService.build_filter(doc_ids=["doc-2"]), + ) + assert len(results) == 2 + paths = {r.payload["section_path"] for r in results if r.payload} + assert paths == {"1", "2"} + + # 非法集合应抛 ValueError + with pytest.raises(ValueError): + await service.upsert_nodes(COLLECTION_L1, nodes) + + +async def test_upsert_chunks(service: QdrantService) -> None: + chunks = [ + { + "doc_id": "doc-3", + "chunk_index": 0, + "text": "第一段原文", + "section_path": "1", + "title": "文档三", + "category": "finance", + "tags": ["stock"], + "dense_vector": _dense(0.6), + "sparse_vector": ([10, 20], [1.0, 0.8]), + }, + { + "doc_id": "doc-3", + "chunk_index": 1, + "text": "第二段原文", + "section_path": "2", + "title": "文档三", + "category": "finance", + "tags": ["stock"], + "dense_vector": _dense(0.4), + }, + ] + await service.upsert_chunks(chunks) + + results = await service.search_dense( + COLLECTION_CHUNKS, + _dense(0.6), + limit=10, + query_filter=QdrantService.build_filter(doc_ids=["doc-3"]), + ) + assert len(results) == 2 + indices = {r.payload["chunk_index"] for r in results if r.payload} + assert indices == {0, 1} + + +def test_build_filter_empty() -> None: + assert QdrantService.build_filter() is None + assert QdrantService.build_filter(categories=[], doc_ids=[], section_paths=[]) is None + + +async def test_build_filter_categories(service: QdrantService) -> None: + """categories 过滤:主类命中与标签命中的文档都能召回,无关类目被排除""" + await service.upsert_l1("doc-cat", "主类命中", "总结", category="tech", tags=["x"], dense_vector=_dense(0.9)) + await service.upsert_l1( + "doc-tag", "标签命中", "总结", category="life", tags=["tech", "y"], dense_vector=_dense(0.8) + ) + await service.upsert_l1("doc-none", "无关文档", "总结", category="finance", tags=["z"], dense_vector=_dense(0.7)) + + query_filter = QdrantService.build_filter(categories=["tech"]) + assert query_filter is not None + results = await service.search_dense(COLLECTION_L1, _dense(0.9), limit=10, query_filter=query_filter) + doc_ids = {r.payload["doc_id"] for r in results if r.payload} + assert doc_ids == {"doc-cat", "doc-tag"} + + +async def test_build_filter_doc_ids(service: QdrantService) -> None: + await service.upsert_l1("doc-a", "A", "总结A", category="t", tags=[], dense_vector=_dense(0.9)) + await service.upsert_l1("doc-b", "B", "总结B", category="t", tags=[], dense_vector=_dense(0.8)) + + results = await service.search_dense( + COLLECTION_L1, + _dense(0.9), + limit=10, + query_filter=QdrantService.build_filter(doc_ids=["doc-b"]), + ) + assert len(results) == 1 + assert results[0].payload is not None + assert results[0].payload["doc_id"] == "doc-b" + + +async def test_search_hybrid_rrf(service: QdrantService) -> None: + """hybrid 查询:dense + sparse 两路 prefetch 走服务端 RRF 融合,应正常返回结果""" + await service.upsert_l1( + doc_id="doc-h1", + title="混合一", + summary="混合检索文档一", + category="tech", + tags=["ai"], + dense_vector=_dense(0.9), + sparse_vector=([100, 200], [1.0, 0.5]), + ) + await service.upsert_l1( + doc_id="doc-h2", + title="混合二", + summary="混合检索文档二", + category="tech", + tags=["ai"], + dense_vector=_dense(0.3), + sparse_vector=([100, 300], [0.9, 0.7]), + ) + + results = await service.search_hybrid( + COLLECTION_L1, + dense_vector=_dense(0.9), + sparse=([100, 200], [1.0, 0.5]), + limit=5, + ) + assert len(results) >= 1 + doc_ids = {r.payload["doc_id"] for r in results if r.payload} + assert "doc-h1" in doc_ids + + # hybrid 带过滤也应正常工作 + filtered = await service.search_hybrid( + COLLECTION_L1, + dense_vector=_dense(0.9), + sparse=([100], [1.0]), + limit=5, + query_filter=QdrantService.build_filter(doc_ids=["doc-h2"]), + ) + assert len(filtered) == 1 + assert filtered[0].payload is not None + assert filtered[0].payload["doc_id"] == "doc-h2" diff --git a/tests/test_qdrant_admin.py b/tests/test_qdrant_admin.py new file mode 100644 index 0000000..f9b753f --- /dev/null +++ b/tests/test_qdrant_admin.py @@ -0,0 +1,197 @@ +"""QdrantService 管理操作测试 + +使用 AsyncQdrantClient(location=":memory:") 本地模式,无需 Docker。 +""" + +import pytest +from qdrant_client import AsyncQdrantClient + +from app.config import settings +from app.services.qdrant import ( + ALL_COLLECTIONS, + COLLECTION_CHUNKS, + COLLECTION_L1, + COLLECTION_L2, + COLLECTION_L3, + QdrantService, +) + +DIM = settings.embedding_dimension + + +def _dense(seed: float) -> list[float]: + """构造确定性 dense 向量:前 4 维取特征值,便于区分不同文档""" + vec = [0.0] * DIM + vec[0] = seed + vec[1] = 1.0 - seed + vec[2] = seed * 0.5 + vec[3] = 0.1 + return vec + + +@pytest.fixture +async def service() -> QdrantService: + svc = QdrantService(client=AsyncQdrantClient(location=":memory:")) + await svc.ensure_collections() + return svc + + +async def _seed_doc(service: QdrantService, doc_id: str, chunk_count: int = 2) -> None: + """写入一篇完整文档:L1 一条 + L2/L3 各 2 节点 + chunk_count 个 chunk""" + await service.upsert_l1( + doc_id=doc_id, + title=f"标题-{doc_id}", + summary=f"总结-{doc_id}", + category="tech", + tags=["t"], + dense_vector=_dense(0.9), + ) + nodes = [ + { + "doc_id": doc_id, + "section_path": str(i), + "text": f"节点{i}-{doc_id}", + "category": "tech", + "tags": ["t"], + "dense_vector": _dense(0.8 - i * 0.1), + } + for i in range(1, 3) + ] + await service.upsert_nodes(COLLECTION_L2, nodes) + await service.upsert_nodes(COLLECTION_L3, nodes) + chunks = [ + { + "doc_id": doc_id, + "chunk_index": i, + "text": f"chunk{i}-{doc_id}", + "section_path": "1", + "title": f"标题-{doc_id}", + "category": "tech", + "tags": ["t"], + "dense_vector": _dense(0.5 + i * 0.1), + } + for i in range(chunk_count) + ] + await service.upsert_chunks(chunks) + + +async def test_count_empty_and_after_upsert(service: QdrantService) -> None: + for collection in ALL_COLLECTIONS: + assert await service.count(collection) == 0 + + await _seed_doc(service, "doc-count", chunk_count=3) + assert await service.count(COLLECTION_L1) == 1 + assert await service.count(COLLECTION_L2) == 2 + assert await service.count(COLLECTION_L3) == 2 + assert await service.count(COLLECTION_CHUNKS) == 3 + + +async def test_scroll_l1_empty(service: QdrantService) -> None: + items, next_offset = await service.scroll_l1() + assert items == [] + assert next_offset is None + + +async def test_scroll_l1_pagination(service: QdrantService) -> None: + """写入 3 篇文档,limit=2 翻页应取全且无重复""" + for i in range(3): + await service.upsert_l1( + doc_id=f"doc-{i}", + title=f"标题{i}", + summary=f"总结{i}", + category="tech", + tags=["x"], + dense_vector=_dense(0.5 + i * 0.1), + ) + + seen: list[dict] = [] + offset: str | None = None + pages = 0 + while True: + items, offset = await service.scroll_l1(limit=2, offset=offset) + seen.extend(items) + pages += 1 + if offset is None: + break + assert pages <= 3 # 防止游标异常导致死循环 + assert pages == 2 + + assert len(seen) == 3 + doc_ids = [item["doc_id"] for item in seen] + assert len(set(doc_ids)) == 3 # 无重复 + assert set(doc_ids) == {"doc-0", "doc-1", "doc-2"} + + # item 字段完整,summary 映射自 payload text + for item in seen: + assert set(item.keys()) == {"doc_id", "title", "category", "tags", "summary"} + i = int(item["doc_id"].rsplit("-", 1)[1]) + assert item["title"] == f"标题{i}" + assert item["summary"] == f"总结{i}" + assert item["category"] == "tech" + assert item["tags"] == ["x"] + + +async def test_get_doc_detail_not_found(service: QdrantService) -> None: + assert await service.get_doc_detail("doc-missing") is None + + +async def test_get_doc_detail(service: QdrantService) -> None: + await _seed_doc(service, "doc-detail", chunk_count=3) + # 干扰数据:不应混入结果 + await _seed_doc(service, "doc-other", chunk_count=1) + + detail = await service.get_doc_detail("doc-detail") + assert detail is not None + + l1 = detail["l1"] + assert l1["doc_id"] == "doc-detail" + assert l1["title"] == "标题-doc-detail" + assert l1["text"] == "总结-doc-detail" + assert l1["category"] == "tech" + + assert len(detail["l2_nodes"]) == 2 + assert len(detail["l3_nodes"]) == 2 + for nodes in (detail["l2_nodes"], detail["l3_nodes"]): + assert {n["section_path"] for n in nodes} == {"1", "2"} + for node in nodes: + assert node["doc_id"] == "doc-detail" + assert "text" in node + + assert detail["chunks_count"] == 3 + + +async def test_delete_by_doc_id(service: QdrantService) -> None: + await _seed_doc(service, "doc-del", chunk_count=2) + await _seed_doc(service, "doc-keep", chunk_count=1) + + deleted = await service.delete_by_doc_id("doc-del") + assert deleted == { + COLLECTION_L1: 1, + COLLECTION_L2: 2, + COLLECTION_L3: 2, + COLLECTION_CHUNKS: 2, + } + + # 四层该 doc 的点全部清空 + assert await service.get_doc_detail("doc-del") is None + doc_filter = QdrantService.build_filter(doc_ids=["doc-del"]) + for collection in ALL_COLLECTIONS: + result = await service.client.count(collection, count_filter=doc_filter, exact=True) + assert result.count == 0 + + # 不影响其他 doc 的数据 + keep_detail = await service.get_doc_detail("doc-keep") + assert keep_detail is not None + assert keep_detail["chunks_count"] == 1 + assert len(keep_detail["l2_nodes"]) == 2 + + +async def test_delete_by_doc_id_nonexistent(service: QdrantService) -> None: + """删除不存在的 doc_id:各集合返回 0,已有数据不受影响(幂等)""" + await _seed_doc(service, "doc-alive", chunk_count=1) + + deleted = await service.delete_by_doc_id("doc-missing") + assert deleted == {name: 0 for name in ALL_COLLECTIONS} + + assert await service.count(COLLECTION_L1) == 1 + assert await service.count(COLLECTION_CHUNKS) == 1 diff --git a/tests/test_query_parser.py b/tests/test_query_parser.py new file mode 100644 index 0000000..6c7876e --- /dev/null +++ b/tests/test_query_parser.py @@ -0,0 +1,238 @@ +"""query 解析与分类路由的单元测试(mock OllamaClient,不真实联网)""" + +import json + +from app.core.query_parser import CategoryHit, ParsedQuery, QueryParser, decide_route +from app.models.knowledge import UNCATEGORIZED, TaxonomyCategory + + +def _taxonomy() -> list[TaxonomyCategory]: + """测试用 taxonomy 类目集""" + return [ + TaxonomyCategory(name="技术文档", description="架构设计、API 文档、开发规范等技术资料"), + TaxonomyCategory(name="产品手册", description="产品功能介绍、使用说明"), + TaxonomyCategory(name="财务行政", description="财务制度、报销流程、行政通知"), + TaxonomyCategory(name=UNCATEGORIZED, description="无法归入其他类目的文档"), + ] + + +class FakeOllama: + """返回固定响应的假 OllamaClient,记录调用参数""" + + def __init__(self, response: str) -> None: + self.response = response + self.calls: list[dict] = [] + + async def generate(self, prompt: str, json_mode: bool = False) -> str: + self.calls.append({"prompt": prompt, "json_mode": json_mode}) + return self.response + + +def _make_parser(response: str) -> QueryParser: + return QueryParser(ollama=FakeOllama(response), taxonomy=_taxonomy()) # type: ignore[arg-type] + + +class TestQueryParserParse: + """QueryParser.parse 的 JSON 解析与容错""" + + async def test_parse_valid_json(self): + """正常 JSON 响应 → 正确解析出 categories/rewrite/keywords""" + response = json.dumps( + { + "categories": [{"name": "技术文档", "confidence": 0.9}], + "rewrite": "如何设计系统架构", + "keywords": ["架构", "设计"], + }, + ensure_ascii=False, + ) + parser = _make_parser(response) + + parsed = await parser.parse("怎么做架构设计") + + assert parsed.parse_failed is False + assert parsed.raw_query == "怎么做架构设计" + assert parsed.rewrite == "如何设计系统架构" + assert parsed.keywords == ["架构", "设计"] + assert len(parsed.categories) == 1 + assert parsed.categories[0].name == "技术文档" + assert parsed.categories[0].confidence == 0.9 + + async def test_parse_uses_json_mode_and_prompt_contains_taxonomy(self): + """以 json_mode 调用 LLM,且 prompt 中包含 taxonomy 类目名与描述""" + parser = _make_parser('{"categories": [], "rewrite": "q", "keywords": []}') + + await parser.parse("报销流程是什么") + + ollama = parser.ollama + assert ollama.calls[0]["json_mode"] is True + prompt = ollama.calls[0]["prompt"] + assert "技术文档" in prompt and "架构设计" in prompt + assert "财务行政" in prompt and "报销流程" in prompt + assert "报销流程是什么" in prompt + + async def test_parse_json_with_surrounding_text(self): + """响应带前后多余文本 → 正则提取第一个 {...} 块成功""" + response = ( + "好的,分析结果如下:\n" + '{"categories": [{"name": "财务行政", "confidence": 0.8}], "rewrite": "报销流程", "keywords": ["报销"]}\n' + "以上就是分析结果。" + ) + parser = _make_parser(response) + + parsed = await parser.parse("怎么报销") + + assert parsed.parse_failed is False + assert parsed.rewrite == "报销流程" + assert parsed.keywords == ["报销"] + assert [c.name for c in parsed.categories] == ["财务行政"] + + async def test_parse_non_json_response(self): + """完全非 JSON 响应 → parse_failed=True,rewrite 回退为原 query""" + parser = _make_parser("抱歉,我无法理解这个问题。") + + parsed = await parser.parse("blah blah") + + assert parsed.parse_failed is True + assert parsed.rewrite == "blah blah" + assert parsed.keywords == [] + assert parsed.categories == [] + + async def test_parse_missing_fields(self): + """JSON 合法但必填字段缺失 → parse_failed=True""" + parser = _make_parser('{"rewrite": "只有 rewrite"}') + + parsed = await parser.parse("q") + + assert parsed.parse_failed is True + assert parsed.rewrite == "q" + assert parsed.categories == [] + + async def test_unknown_category_dropped(self): + """未知类目名(含 uncategorized)被丢弃,合法类目保留""" + response = json.dumps( + { + "categories": [ + {"name": "不存在的类目", "confidence": 0.9}, + {"name": UNCATEGORIZED, "confidence": 0.8}, + {"name": "产品手册", "confidence": 0.7}, + ], + "rewrite": "r", + "keywords": [], + }, + ensure_ascii=False, + ) + parser = _make_parser(response) + + parsed = await parser.parse("q") + + assert parsed.parse_failed is False + assert [c.name for c in parsed.categories] == ["产品手册"] + + async def test_out_of_range_confidence_dropped(self): + """confidence 越界(>1 或 <0)的条目被丢弃""" + response = json.dumps( + { + "categories": [ + {"name": "技术文档", "confidence": 1.5}, + {"name": "产品手册", "confidence": -0.2}, + {"name": "财务行政", "confidence": 0.6}, + ], + "rewrite": "r", + "keywords": [], + }, + ensure_ascii=False, + ) + parser = _make_parser(response) + + parsed = await parser.parse("q") + + assert [c.name for c in parsed.categories] == ["财务行政"] + + +class TestDecideRoute: + """decide_route 纯函数的四个分支""" + + def _parsed(self, categories: list[CategoryHit], parse_failed: bool = False) -> ParsedQuery: + return ParsedQuery(raw_query="q", rewrite="q", categories=categories, parse_failed=parse_failed) + + def test_parse_failed_fallback(self): + """解析失败 → 全库兜底,reason=parse_failed""" + decision = decide_route(self._parsed([], parse_failed=True), threshold=0.6, max_categories=3) + + assert decision.fallback is True + assert decision.filter_categories is None + assert decision.reason == "parse_failed" + + def test_low_confidence_fallback(self): + """所有类目 confidence 低于阈值 → 全库兜底,reason=low_confidence""" + categories = [CategoryHit(name="技术文档", confidence=0.5), CategoryHit(name="产品手册", confidence=0.3)] + decision = decide_route(self._parsed(categories), threshold=0.6, max_categories=3) + + assert decision.fallback is True + assert decision.filter_categories is None + assert decision.reason == "low_confidence" + + def test_empty_categories_fallback(self): + """无命中类目 → 全库兜底,reason=low_confidence""" + decision = decide_route(self._parsed([]), threshold=0.6, max_categories=3) + + assert decision.fallback is True + assert decision.filter_categories is None + assert decision.reason == "low_confidence" + + def test_too_many_categories_fallback(self): + """过阈值类目数超过上限 → 全库兜底,reason=too_many_categories""" + categories = [ + CategoryHit(name="技术文档", confidence=0.9), + CategoryHit(name="产品手册", confidence=0.8), + CategoryHit(name="财务行政", confidence=0.7), + ] + decision = decide_route(self._parsed(categories), threshold=0.6, max_categories=2) + + assert decision.fallback is True + assert decision.filter_categories is None + assert decision.reason == "too_many_categories" + + def test_routed_sorted_by_confidence_desc(self): + """正常路由 → 按 confidence 降序输出过滤类目,低于阈值的类目被剔除""" + categories = [ + CategoryHit(name="产品手册", confidence=0.7), + CategoryHit(name="财务行政", confidence=0.5), # 低于阈值,应被剔除 + CategoryHit(name="技术文档", confidence=0.9), + ] + decision = decide_route(self._parsed(categories), threshold=0.6, max_categories=3) + + assert decision.fallback is False + assert decision.filter_categories == ["技术文档", "产品手册"] + assert decision.reason == "routed" + + +class TestParseAndRoute: + """parse_and_route 便捷方法(threshold/max_categories 取自 settings,默认 0.6/3)""" + + async def test_parse_and_route_routed(self): + response = json.dumps( + { + "categories": [{"name": "技术文档", "confidence": 0.9}], + "rewrite": "r", + "keywords": ["k"], + }, + ensure_ascii=False, + ) + parser = _make_parser(response) + + decision = await parser.parse_and_route("架构设计文档在哪") + + assert decision.fallback is False + assert decision.filter_categories == ["技术文档"] + assert decision.reason == "routed" + assert decision.parsed.raw_query == "架构设计文档在哪" + + async def test_parse_and_route_fallback_on_parse_failure(self): + parser = _make_parser("不是 JSON") + + decision = await parser.parse_and_route("q") + + assert decision.fallback is True + assert decision.filter_categories is None + assert decision.reason == "parse_failed" diff --git a/tests/test_ranker.py b/tests/test_ranker.py new file mode 100644 index 0000000..50c74f9 --- /dev/null +++ b/tests/test_ranker.py @@ -0,0 +1,79 @@ +"""RRF 融合与截断的单元测试""" + +import pytest +from qdrant_client import models + +from app.core.ranker import finalize, rrf_fuse + + +def _point(pid: str, score: float = 1.0) -> models.ScoredPoint: + return models.ScoredPoint(id=pid, version=0, score=score, payload={}, vector=None) + + +class TestRrfFuse: + """rrf_fuse 的排序 / 去重 / 空输入 / k 参数行为""" + + def test_empty_input(self): + """空列表输入返回 []""" + assert rrf_fuse([]) == [] + assert rrf_fuse([[], []]) == [] + + def test_single_list_fused_scores(self): + """单路结果按 rank 计算 1/(k+rank) 融合分并降序返回""" + fused = rrf_fuse([[_point("a"), _point("b"), _point("c")]], k=60) + assert [p.id for p in fused] == ["a", "b", "c"] + assert fused[0].score == pytest.approx(1 / 61) + assert fused[1].score == pytest.approx(1 / 62) + assert fused[2].score == pytest.approx(1 / 63) + + def test_dedup_merges_scores(self): + """同一 point id 出现在多路结果中,只保留一条且分数累加""" + list1 = [_point("a"), _point("b")] + list2 = [_point("b"), _point("c")] + fused = rrf_fuse([list1, list2], k=60) + # b 在两路中分别 rank2/rank1,融合分最高;a 与 c 各 1/61 + assert [p.id for p in fused] == ["b", "a", "c"] + assert fused[0].score == pytest.approx(1 / 62 + 1 / 61) + assert fused[1].score == pytest.approx(1 / 61) + assert fused[2].score == pytest.approx(1 / 62) + + def test_k_affects_ranking(self): + """k 参数改变融合权重,可改变最终排序 + + A 排名 [1, 10],B 排名 [5, 5]: + - k=60 时 B 总分更高(两路均衡占优) + - k=1 时 A 总分更高(头部 rank 权重被放大) + """ + l1 = [_point("a"), *[_point(f"n{i}") for i in range(3)], _point("b"), *[_point(f"m{i}") for i in range(5)]] + l2 = [*[_point(f"x{i}") for i in range(4)], _point("b"), *[_point(f"y{i}") for i in range(4)], _point("a")] + # l1:a rank1,b rank5;l2:b rank5,a rank10 + + fused_k60 = rrf_fuse([l1, l2], k=60) + assert fused_k60[0].id == "b" + + fused_k1 = rrf_fuse([l1, l2], k=1) + assert fused_k1[0].id == "a" + + def test_result_is_new_objects(self): + """返回的是写回融合分的新对象,不修改原 point""" + original = _point("a", score=0.99) + fused = rrf_fuse([[original]], k=60) + assert fused[0].score != 0.99 + assert original.score == 0.99 + + +class TestFinalize: + """finalize 截断行为""" + + def test_truncate(self): + points = [_point(str(i)) for i in range(10)] + result = finalize(points, 5) + assert [p.id for p in result] == ["0", "1", "2", "3", "4"] + + def test_truncate_larger_than_input(self): + """final_k 大于列表长度时返回全部""" + points = [_point("a"), _point("b")] + assert finalize(points, 5) == points + + def test_empty(self): + assert finalize([], 5) == [] diff --git a/tests/test_redis_cache.py b/tests/test_redis_cache.py new file mode 100644 index 0000000..5d9b9c2 --- /dev/null +++ b/tests/test_redis_cache.py @@ -0,0 +1,247 @@ +"""Redis 缓存与缓存集成测试(AsyncMock redis 客户端,不连真实 Redis)""" + +import json +from hashlib import sha256 +from typing import Any +from unittest.mock import AsyncMock + +import pytest +from fastapi.testclient import TestClient + +from app.config import settings +from app.core.query_parser import CategoryHit, ParsedQuery, QueryParser +from app.main import app +from app.models.knowledge import UNCATEGORIZED, TaxonomyCategory +from app.models.search import SearchRequest, SearchResponse +from app.services.redis import RedisCache + + +def _cache_with_client(client: AsyncMock) -> RedisCache: + """构造注入 mock 客户端的 RedisCache(绕过真实 Redis 连接)""" + cache = RedisCache() + cache._client = client + return cache + + +class _MemoryCache: + """内存版假缓存,接口与 RedisCache 一致""" + + def __init__(self) -> None: + self.store: dict[str, dict[str, Any]] = {} + + async def get_json(self, key: str) -> dict[str, Any] | None: + return self.store.get(key) + + async def set_json(self, key: str, value: dict[str, Any], ttl: int | None = None) -> bool: + self.store[key] = value + return True + + async def close(self) -> None: + pass + + +class TestRedisCacheGetJson: + """RedisCache.get_json:命中 / 未命中 / 数据异常 / 连接异常""" + + async def test_hit(self): + client = AsyncMock() + client.get.return_value = json.dumps({"code": 0, "data": {"x": 1}}, ensure_ascii=False) + cache = _cache_with_client(client) + + assert await cache.get_json("k") == {"code": 0, "data": {"x": 1}} + client.get.assert_awaited_once_with("k") + + async def test_miss(self): + client = AsyncMock() + client.get.return_value = None + + assert await _cache_with_client(client).get_json("k") is None + + async def test_invalid_json_returns_none(self): + client = AsyncMock() + client.get.return_value = "not-json{" + + assert await _cache_with_client(client).get_json("k") is None + + async def test_non_dict_json_returns_none(self): + client = AsyncMock() + client.get.return_value = json.dumps([1, 2, 3]) + + assert await _cache_with_client(client).get_json("k") is None + + async def test_error_returns_none(self): + client = AsyncMock() + client.get.side_effect = ConnectionError("redis down") + + assert await _cache_with_client(client).get_json("k") is None + + +class TestRedisCacheSetJson: + """RedisCache.set_json:默认 TTL / 自定义 TTL / 异常容错""" + + async def test_ok_uses_default_ttl(self): + client = AsyncMock() + cache = _cache_with_client(client) + + assert await cache.set_json("k", {"a": 1}) is True + client.setex.assert_awaited_once_with("k", settings.cache_ttl, json.dumps({"a": 1}, ensure_ascii=False)) + + async def test_ok_custom_ttl(self): + client = AsyncMock() + cache = _cache_with_client(client) + + assert await cache.set_json("k", {"a": 1}, ttl=10) is True + client.setex.assert_awaited_once_with("k", 10, json.dumps({"a": 1}, ensure_ascii=False)) + + async def test_error_returns_false(self): + client = AsyncMock() + client.setex.side_effect = ConnectionError("redis down") + + assert await _cache_with_client(client).set_json("k", {"a": 1}) is False + + +class _CountingRetriever: + """记录 search 调用次数的假 Retriever""" + + def __init__(self, response: SearchResponse) -> None: + self.response = response + self.calls = 0 + + async def search(self, request: SearchRequest) -> SearchResponse: + self.calls += 1 + return self.response + + +def _search_cache_key(query: str, top_k: int | None = None) -> str: + """与路由侧一致的检索缓存键""" + return f"search:{sha256((query + '|' + str(top_k)).encode()).hexdigest()[:16]}" + + +class TestSearchApiCache: + """检索 API 缓存层:命中短路 Retriever,Redis 异常不影响检索""" + + def test_second_request_hits_cache(self, monkeypatch: pytest.MonkeyPatch): + """第一次调 Retriever 并回写缓存,第二次缓存命中不再调用""" + retriever = _CountingRetriever(SearchResponse(query="q")) + cache = _MemoryCache() + monkeypatch.setattr("app.api.v1.search._retriever", retriever) + monkeypatch.setattr("app.api.v1.search.get_cache", lambda: cache) + + client = TestClient(app) + body1 = client.post("/api/v1/search", json={"query": "q"}).json() + body2 = client.post("/api/v1/search", json={"query": "q"}).json() + + assert retriever.calls == 1 + assert body1 == body2 + assert body2["code"] == 0 + assert _search_cache_key("q") in cache.store + + def test_different_top_k_uses_different_key(self, monkeypatch: pytest.MonkeyPatch): + """top_k 参与缓存键计算:同 query 不同 top_k 不共享缓存""" + retriever = _CountingRetriever(SearchResponse(query="q")) + cache = _MemoryCache() + monkeypatch.setattr("app.api.v1.search._retriever", retriever) + monkeypatch.setattr("app.api.v1.search.get_cache", lambda: cache) + + client = TestClient(app) + client.post("/api/v1/search", json={"query": "q"}) + client.post("/api/v1/search", json={"query": "q", "top_k": 3}) + + assert retriever.calls == 2 + assert _search_cache_key("q") in cache.store + assert _search_cache_key("q", 3) in cache.store + + def test_redis_error_still_returns_result(self, monkeypatch: pytest.MonkeyPatch): + """Redis 读写全部抛异常 → 降级为无缓存,检索正常返回""" + broken_client = AsyncMock() + broken_client.get.side_effect = ConnectionError("redis down") + broken_client.setex.side_effect = ConnectionError("redis down") + retriever = _CountingRetriever(SearchResponse(query="q")) + monkeypatch.setattr("app.api.v1.search._retriever", retriever) + monkeypatch.setattr("app.api.v1.search.get_cache", lambda: _cache_with_client(broken_client)) + + client = TestClient(app) + resp = client.post("/api/v1/search", json={"query": "q"}) + + assert resp.status_code == 200 + body = resp.json() + assert body["code"] == 0 + assert body["data"]["query"] == "q" + assert retriever.calls == 1 + + +def _taxonomy() -> list[TaxonomyCategory]: + return [ + TaxonomyCategory(name="技术文档", description="技术资料"), + TaxonomyCategory(name=UNCATEGORIZED, description="未分类"), + ] + + +class _FakeOllama: + """返回固定响应的假 OllamaClient,记录调用次数""" + + def __init__(self, response: str) -> None: + self.response = response + self.calls: list[str] = [] + + async def generate(self, prompt: str, json_mode: bool = False) -> str: + self.calls.append(prompt) + return self.response + + +def _qparse_cache_key(query: str) -> str: + """与 QueryParser 一致的解析缓存键""" + return f"qparse:{sha256(query.encode()).hexdigest()[:16]}" + + +class TestQueryParserCache: + """QueryParser 解析缓存:命中跳过 LLM,异常回退 LLM""" + + async def test_cache_hit_skips_ollama(self): + """缓存命中时直接用缓存重建 ParsedQuery,不调用 Ollama;decide_route 仍现算""" + parsed = ParsedQuery( + raw_query="q", + rewrite="缓存里的 rewrite", + keywords=["k"], + categories=[CategoryHit(name="技术文档", confidence=0.9)], + ) + cache = _MemoryCache() + cache.store[_qparse_cache_key("q")] = parsed.model_dump() + ollama = _FakeOllama("不应被调用") + parser = QueryParser(ollama=ollama, taxonomy=_taxonomy(), cache=cache) # type: ignore[arg-type] + + decision = await parser.parse_and_route("q") + + assert ollama.calls == [] + assert decision.parsed.rewrite == "缓存里的 rewrite" + assert decision.fallback is False + assert decision.reason == "routed" + assert decision.filter_categories == ["技术文档"] + + async def test_llm_result_written_to_cache(self): + """首次走 LLM 并回写缓存,第二次同 query 命中缓存不再调 LLM""" + ollama = _FakeOllama('{"categories": [], "rewrite": "r", "keywords": []}') + cache = _MemoryCache() + parser = QueryParser(ollama=ollama, taxonomy=_taxonomy(), cache=cache) # type: ignore[arg-type] + + await parser.parse_and_route("q") + await parser.parse_and_route("q") + + assert len(ollama.calls) == 1 + assert _qparse_cache_key("q") in cache.store + + async def test_cache_error_falls_back_to_llm(self): + """Redis 读写全部抛异常 → 降级为无缓存,正常走 LLM 解析""" + broken_client = AsyncMock() + broken_client.get.side_effect = ConnectionError("redis down") + broken_client.setex.side_effect = ConnectionError("redis down") + ollama = _FakeOllama('{"categories": [], "rewrite": "r", "keywords": []}') + parser = QueryParser( + ollama=ollama, taxonomy=_taxonomy(), cache=_cache_with_client(broken_client) # type: ignore[arg-type] + ) + + decision = await parser.parse_and_route("q") + + assert len(ollama.calls) == 1 + assert decision.parsed.rewrite == "r" + assert decision.reason == "low_confidence" diff --git a/tests/test_response.py b/tests/test_response.py new file mode 100644 index 0000000..9e47a21 --- /dev/null +++ b/tests/test_response.py @@ -0,0 +1,42 @@ +"""统一 API 响应包装与业务异常单元测试""" + +import pytest + +from app.api.response import ApiError, error, ok + + +class TestOk: + def test_ok_structure(self) -> None: + data = {"items": [1, 2, 3]} + assert ok(data) == {"code": 0, "data": {"items": [1, 2, 3]}, "message": "ok"} + + def test_ok_none_data(self) -> None: + assert ok(None) == {"code": 0, "data": None, "message": "ok"} + + +class TestError: + def test_error_structure(self) -> None: + assert error(1004, "文档不存在") == {"code": 1004, "data": None, "message": "文档不存在"} + + def test_error_code_passthrough(self) -> None: + result = error(2000, "服务器内部错误") + assert result["code"] == 2000 + assert result["message"] == "服务器内部错误" + + +class TestApiError: + def test_attributes(self) -> None: + exc = ApiError(1004, "文档不存在") + assert exc.code == 1004 + assert exc.message == "文档不存在" + + def test_is_exception_with_message(self) -> None: + exc = ApiError(1004, "文档不存在") + assert isinstance(exc, Exception) + assert str(exc) == "文档不存在" + + def test_raise_and_catch(self) -> None: + with pytest.raises(ApiError) as exc_info: + raise ApiError(1001, "请求参数校验失败") + assert exc_info.value.code == 1001 + assert exc_info.value.message == "请求参数校验失败" diff --git a/tests/test_retriever.py b/tests/test_retriever.py new file mode 100644 index 0000000..20966a0 --- /dev/null +++ b/tests/test_retriever.py @@ -0,0 +1,350 @@ +"""分层检索引擎与检索 API 的单元测试(全部 mock,不联网、不起 Docker)""" + +from typing import Any + +import pytest +from fastapi.testclient import TestClient +from qdrant_client import models + +from app.config import settings +from app.core.query_parser import ParsedQuery, RouteDecision +from app.core.retriever import Retriever +from app.core.sparse import SparseEncoder +from app.main import app +from app.models.search import SearchHit, SearchRequest, SearchResponse +from app.services.qdrant import COLLECTION_CHUNKS, COLLECTION_L1, COLLECTION_L2, COLLECTION_L3 + + +def _point( + pid: str, doc_id: str = "", section_path: str | None = None, score: float = 1.0, **extra: Any +) -> models.ScoredPoint: + """构造测试用 ScoredPoint,payload 模拟 chunk/节点结构""" + payload: dict[str, Any] = {"doc_id": doc_id, "text": f"text-{pid}", "title": f"title-{doc_id}", **extra} + if section_path is not None: + payload["section_path"] = section_path + return models.ScoredPoint(id=pid, version=0, score=score, payload=payload, vector=None) + + +class FakeQdrant: + """按集合 + 调用顺序返回预置结果的假 QdrantService,记录每次调用的参数""" + + def __init__(self, results: dict[str, list[list[models.ScoredPoint]]]) -> None: + self._results = results + self.calls: list[dict[str, Any]] = [] + + def _next(self, collection: str) -> list[models.ScoredPoint]: + queue = self._results.get(collection, []) + return queue.pop(0) if queue else [] + + async def search_dense( + self, collection: str, vector: list[float], limit: int, query_filter: models.Filter | None = None + ) -> list[models.ScoredPoint]: + self.calls.append({"collection": collection, "method": "dense", "limit": limit, "filter": query_filter}) + return self._next(collection) + + async def search_hybrid( + self, + collection: str, + dense_vector: list[float], + sparse: tuple[list[int], list[float]], + limit: int, + query_filter: models.Filter | None = None, + ) -> list[models.ScoredPoint]: + self.calls.append({"collection": collection, "method": "hybrid", "limit": limit, "filter": query_filter}) + return self._next(collection) + + +class FakeParser: + """返回固定路由决策的假 QueryParser""" + + def __init__(self, route: RouteDecision) -> None: + self.route = route + + async def parse_and_route(self, query: str) -> RouteDecision: + return self.route + + +class FakeEmbedding: + """返回固定向量的假 EmbeddingService""" + + async def embed(self, texts: list[str]) -> list[list[float]]: + return [[0.1, 0.2, 0.3] for _ in texts] + + +def _route( + fallback: bool = False, categories: tuple[str, ...] = ("技术文档",), rewrite: str = "rewrite query" +) -> RouteDecision: + return RouteDecision( + fallback=fallback, + filter_categories=None if fallback else list(categories), + reason="low_confidence" if fallback else "routed", + parsed=ParsedQuery(raw_query="q", rewrite=rewrite), + ) + + +def _make_retriever(qdrant: FakeQdrant, route: RouteDecision) -> Retriever: + return Retriever( + qdrant=qdrant, # type: ignore[arg-type] + query_parser=FakeParser(route), # type: ignore[arg-type] + embedding=FakeEmbedding(), + sparse_encoder=SparseEncoder(), + ) + + +def _calls(qdrant: FakeQdrant, collection: str) -> list[dict[str, Any]]: + return [c for c in qdrant.calls if c["collection"] == collection] + + +def _must_match_any(flt: models.Filter | None, key: str) -> list[str] | None: + """提取 must 中指定 key 的 MatchAny 值,不存在返回 None""" + if flt is None: + return None + for cond in flt.must or []: + if cond.key == key: + return list(cond.match.any) + return None + + +def _filter_categories(flt: models.Filter | None) -> list[str] | None: + """提取 min_should 中 category 条件的 MatchAny 值,不存在返回 None""" + if flt is None or flt.min_should is None: + return None + for cond in flt.min_should.conditions: + if cond.key == "category": + return list(cond.match.any) + return None + + +class TestRetriever: + """分层检索流程:各层 filter 参数与回退路径""" + + async def test_full_pipeline(self): + """正常三级逐层:L1 候选 → L2 收窄 → L3 两路(含 2.5 级文档 b 路)→ chunk""" + qdrant = FakeQdrant( + { + COLLECTION_L1: [[_point("l1a", "d1"), _point("l1b", "d2")]], + COLLECTION_L2: [[_point("l2a", "d1", "章节A")]], + COLLECTION_L3: [ + [_point("l3a", "d1", "章节A")], # a 路:L2 命中文档 + [_point("l3b", "d2", "")], # b 路:2.5 级文档(无 L2 节点) + ], + COLLECTION_CHUNKS: [ + [ + _point("c1", "d1", "章节A", doc_summary="总结1"), + _point("c2", "d2", "", doc_summary="总结2"), + ] + ], + } + ) + retriever = _make_retriever(qdrant, _route()) + + resp = await retriever.search(SearchRequest(query="测试查询")) + + # L1:categories 过滤、无 doc 限制,limit=l1_doc_top_n + l1_call = _calls(qdrant, COLLECTION_L1)[0] + assert l1_call["limit"] == settings.l1_doc_top_n + assert _filter_categories(l1_call["filter"]) == ["技术文档"] + assert _must_match_any(l1_call["filter"], "doc_id") is None + + # L2:doc_ids 为 L1 候选(保序),limit=l2_section_top_n*候选数 + l2_call = _calls(qdrant, COLLECTION_L2)[0] + assert l2_call["method"] == "dense" + assert l2_call["limit"] == settings.l2_section_top_n * 2 + assert _must_match_any(l2_call["filter"], "doc_id") == ["d1", "d2"] + assert _filter_categories(l2_call["filter"]) == ["技术文档"] + + # L3 两路:a 路 d1 + section「章节A」;b 路 d2(2.5 级文档)仅 doc 过滤 + l3_calls = _calls(qdrant, COLLECTION_L3) + assert len(l3_calls) == 2 + assert all(c["limit"] == settings.l3_top_n for c in l3_calls) + assert _must_match_any(l3_calls[0]["filter"], "doc_id") == ["d1"] + assert _must_match_any(l3_calls[0]["filter"], "section_path") == ["章节A"] + assert _must_match_any(l3_calls[1]["filter"], "doc_id") == ["d2"] + assert _must_match_any(l3_calls[1]["filter"], "section_path") is None + + # chunk:doc_ids 为 L3 命中文档,section_paths 仅非空值 + chunk_call = _calls(qdrant, COLLECTION_CHUNKS)[0] + assert chunk_call["limit"] == settings.retrieval_top_k + assert sorted(_must_match_any(chunk_call["filter"], "doc_id") or []) == ["d1", "d2"] + assert _must_match_any(chunk_call["filter"], "section_path") == ["章节A"] + + # 响应组装 + assert resp.query == "测试查询" + assert resp.fallback is False + assert resp.routed_categories == ["技术文档"] + assert [h.doc_id for h in resp.hits] == ["d1", "d2"] + assert resp.hits[0].text == "text-c1" + assert resp.hits[0].title == "title-d1" + assert resp.hits[0].section_path == "章节A" + assert resp.hits[0].doc_summary == "总结1" + assert resp.hits[0].score > 0 + + async def test_l2_empty_all_docs_go_l3_b_path(self): + """L2 整体无命中:全部候选文档回退为 L3 单路 doc 级查询""" + qdrant = FakeQdrant( + { + COLLECTION_L1: [[_point("l1a", "d1"), _point("l1b", "d2")]], + COLLECTION_L2: [[]], + COLLECTION_L3: [[_point("l3a", "d1", "章节A")]], + COLLECTION_CHUNKS: [[_point("c1", "d1", "章节A")]], + } + ) + retriever = _make_retriever(qdrant, _route()) + + resp = await retriever.search(SearchRequest(query="测试查询")) + + l3_calls = _calls(qdrant, COLLECTION_L3) + assert len(l3_calls) == 1 + assert _must_match_any(l3_calls[0]["filter"], "doc_id") == ["d1", "d2"] + assert _must_match_any(l3_calls[0]["filter"], "section_path") is None + assert [h.doc_id for h in resp.hits] == ["d1"] + + async def test_l3_empty_fallback_doc_level_chunks(self): + """L3 两路均无命中:chunk 层回退为 L1 候选文档级检索(无 section 过滤)""" + qdrant = FakeQdrant( + { + COLLECTION_L1: [[_point("l1a", "d1"), _point("l1b", "d2")]], + COLLECTION_L2: [[_point("l2a", "d1", "章节A")]], + COLLECTION_L3: [[], []], + COLLECTION_CHUNKS: [[_point("c1", "d2", "")]], + } + ) + retriever = _make_retriever(qdrant, _route()) + + resp = await retriever.search(SearchRequest(query="测试查询")) + + chunk_call = _calls(qdrant, COLLECTION_CHUNKS)[0] + assert _must_match_any(chunk_call["filter"], "doc_id") == ["d1", "d2"] + assert _must_match_any(chunk_call["filter"], "section_path") is None + assert [h.doc_id for h in resp.hits] == ["d2"] + + async def test_l1_empty_global_chunk_fallback(self): + """L1 无候选文档:直接全库 chunk 兜底(无 filter),fallback=True""" + qdrant = FakeQdrant( + { + COLLECTION_L1: [[]], + COLLECTION_CHUNKS: [[_point("c1", "d9", "章节X", doc_summary="总结9")]], + } + ) + retriever = _make_retriever(qdrant, _route()) + + resp = await retriever.search(SearchRequest(query="测试查询")) + + # 不再触发 L2/L3 查询 + assert _calls(qdrant, COLLECTION_L2) == [] + assert _calls(qdrant, COLLECTION_L3) == [] + # 全库 chunk 检索:无 filter + chunk_calls = _calls(qdrant, COLLECTION_CHUNKS) + assert len(chunk_calls) == 1 + assert chunk_calls[0]["filter"] is None + assert chunk_calls[0]["limit"] == settings.retrieval_top_k + assert resp.fallback is True + assert [h.doc_id for h in resp.hits] == ["d9"] + + async def test_route_fallback_no_category_filter(self): + """路由兜底时 categories=None,各层均不做类目过滤""" + qdrant = FakeQdrant( + { + COLLECTION_L1: [[_point("l1a", "d1")]], + COLLECTION_L2: [[]], + COLLECTION_L3: [[_point("l3a", "d1", "")]], + COLLECTION_CHUNKS: [[_point("c1", "d1", "")]], + } + ) + retriever = _make_retriever(qdrant, _route(fallback=True)) + + resp = await retriever.search(SearchRequest(query="测试查询")) + + # L1:categories 与 doc_ids 均空 → filter 为 None + assert _calls(qdrant, COLLECTION_L1)[0]["filter"] is None + # L2/L3/chunk:仅有 doc 级 must 条件,无类目 min_should + for collection in (COLLECTION_L2, COLLECTION_L3, COLLECTION_CHUNKS): + for call in _calls(qdrant, collection): + assert _filter_categories(call["filter"]) is None + assert resp.fallback is True + assert resp.routed_categories == [] + + async def test_top_k_override(self): + """request.top_k 优先于 settings.retrieval_final_k""" + qdrant = FakeQdrant( + { + COLLECTION_L1: [[_point("l1a", "d1")]], + COLLECTION_L2: [[]], + COLLECTION_L3: [[_point("l3a", "d1", "")]], + COLLECTION_CHUNKS: [[_point(f"c{i}", "d1", "") for i in range(5)]], + } + ) + retriever = _make_retriever(qdrant, _route()) + + resp = await retriever.search(SearchRequest(query="测试查询", top_k=2)) + assert len(resp.hits) == 2 + + +class _FakeRetriever: + """API 测试用假 Retriever:返回固定响应或抛异常""" + + def __init__(self, response: SearchResponse | None = None, exc: Exception | None = None) -> None: + self.response = response + self.exc = exc + + async def search(self, request: SearchRequest) -> SearchResponse: + if self.exc is not None: + raise self.exc + assert self.response is not None + return self.response + + +class TestSearchApi: + """检索 API 统一响应包装""" + + def test_search_ok(self, monkeypatch: pytest.MonkeyPatch): + """正常检索 → {"code": 0, "data": ..., "message": "ok"}""" + response = SearchResponse( + query="q", + hits=[SearchHit(text="t", doc_id="d1", title="标题", section_path="", score=0.5, doc_summary="s")], + routed_categories=["技术文档"], + fallback=False, + ) + monkeypatch.setattr("app.api.v1.search._retriever", _FakeRetriever(response=response)) + + client = TestClient(app) + resp = client.post("/api/v1/search", json={"query": "q"}) + + assert resp.status_code == 200 + body = resp.json() + assert body["code"] == 0 + assert body["message"] == "ok" + assert body["data"]["query"] == "q" + assert body["data"]["hits"][0]["doc_id"] == "d1" + assert body["data"]["routed_categories"] == ["技术文档"] + assert body["data"]["fallback"] is False + + def test_search_error(self, monkeypatch: pytest.MonkeyPatch): + """检索内部异常 → code 2000""" + monkeypatch.setattr("app.api.v1.search._retriever", _FakeRetriever(exc=RuntimeError("boom"))) + + client = TestClient(app) + resp = client.post("/api/v1/search", json={"query": "q"}) + + assert resp.status_code == 200 + body = resp.json() + assert body["code"] == 2000 + assert body["data"] is None + assert "boom" in body["message"] + + def test_validation_error(self): + """缺少必填 query 字段 → code 1001""" + client = TestClient(app) + resp = client.post("/api/v1/search", json={}) + + assert resp.status_code == 200 + body = resp.json() + assert body["code"] == 1001 + assert body["data"] is None + + def test_health_kept(self): + """现有健康检查接口不受影响""" + client = TestClient(app) + resp = client.get("/api/v1/health") + assert resp.status_code == 200 + assert resp.json() == {"status": "ok"} diff --git a/tests/test_run_eval.py b/tests/test_run_eval.py new file mode 100644 index 0000000..78eed70 --- /dev/null +++ b/tests/test_run_eval.py @@ -0,0 +1,131 @@ +"""run_eval 门槛常量与报告组装纯函数单元测试""" + +from pathlib import Path +from typing import Any + +from scripts.eval.run_eval import ( + THRESHOLD_HALLUCINATION, + THRESHOLD_L1_ER, + THRESHOLD_L3_ER, + THRESHOLD_PRUNING_LOSS, + _build_report, + _fmt, + _mark, +) + + +class TestThresholds: + """Spec 规定的门槛值:L1 ER ≥ 0.85 / L3 ER ≥ 0.9 / 幻觉率 < 2% / Pruning Loss < 8%""" + + def test_l1_entity_recall_threshold(self) -> None: + assert THRESHOLD_L1_ER == 0.85 + + def test_l3_entity_recall_threshold(self) -> None: + assert THRESHOLD_L3_ER == 0.9 + + def test_hallucination_threshold(self) -> None: + assert THRESHOLD_HALLUCINATION == 0.02 + + def test_pruning_loss_threshold(self) -> None: + assert THRESHOLD_PRUNING_LOSS == 0.08 + + +class TestMark: + def test_pass(self) -> None: + assert _mark(True) == "✅" + + def test_fail(self) -> None: + assert _mark(False) == "❌" + + +class TestFmt: + def test_none_returns_na(self) -> None: + assert _fmt(None) == "N/A" + assert _fmt(None, percent=True) == "N/A" + + def test_decimal_format(self) -> None: + assert _fmt(0.85) == "0.8500" + + def test_percent_format(self) -> None: + assert _fmt(0.1234, percent=True) == "12.3%" + assert _fmt(0.02, percent=True) == "2.0%" + + +def _doc_record(**overrides: Any) -> dict: + record: dict[str, Any] = { + "id": "d1", + "title": "考勤制度", + "golden_category": "制度", + "category": "制度", + "entity_recall_l1": 0.9, + "entity_recall_l3": 0.95, + "hallucination_rate": 0.01, + "taxonomy_consistency": True, + } + record.update(overrides) + return record + + +def _report_kwargs(**overrides: Any) -> dict: + kwargs: dict[str, Any] = { + "regression_path": Path("regression_set.json"), + "doc_records": [_doc_record()], + "query_summary": {"total": 4, "positive": 3, "negative": 1, "negative_false_alarm": 0}, + "hier_metrics": {"precision@5": 0.6, "recall@10": 0.8}, + "baseline_metrics": {"precision@5": 0.4, "recall@10": 0.7}, + "routing": {"precision": 0.75, "recall": 0.8, "f1": 0.77}, + "prune_loss": 0.05, + "judge_enabled": True, + "kept_data": False, + } + kwargs.update(overrides) + return kwargs + + +class TestBuildReport: + def test_baseline_comparison_columns(self) -> None: + report = _build_report(**_report_kwargs()) + assert "| 指标 | 分层检索 | 平铺 baseline |" in report + assert "| Precision@5 | 0.6000 | 0.4000 |" in report + assert "| Recall@10 | 0.8000 | 0.7000 |" in report + + def test_doc_table_row(self) -> None: + report = _build_report(**_report_kwargs()) + assert "| d1 | 考勤制度 | 制度 | 制度 | 0.9000 | 0.9500 | 1.0% | 是 |" in report + + def test_threshold_pass_marks(self) -> None: + report = _build_report(**_report_kwargs()) + assert "| L1 Entity Recall | 0.9000 | ≥ 0.85 | ✅ |" in report + assert "| L3 Entity Recall | 0.9500 | ≥ 0.9 | ✅ |" in report + assert "| Hallucination Rate | 1.0% | < 2% | ✅ |" in report + assert "| Pruning Loss | 5.0% | < 8% | ✅ |" in report + + def test_threshold_fail_marks(self) -> None: + report = _build_report( + **_report_kwargs( + doc_records=[ + _doc_record( + entity_recall_l1=0.8, + entity_recall_l3=0.7, + hallucination_rate=0.05, + taxonomy_consistency=False, + ) + ], + prune_loss=0.2, + ) + ) + assert "| L1 Entity Recall | 0.8000 | ≥ 0.85 | ❌ |" in report + assert "| L3 Entity Recall | 0.7000 | ≥ 0.9 | ❌ |" in report + assert "| Hallucination Rate | 5.0% | < 2% | ❌ |" in report + assert "| Pruning Loss | 20.0% | < 8% | ❌ |" in report + assert " | 否 |" in report + + def test_judge_skipped_renders_na(self) -> None: + report = _build_report( + **_report_kwargs( + doc_records=[_doc_record(hallucination_rate=None, taxonomy_consistency=None)], + judge_enabled=False, + ) + ) + assert "N/A" in report + assert "跳过" in report diff --git a/tests/test_sparse.py b/tests/test_sparse.py new file mode 100644 index 0000000..148b024 --- /dev/null +++ b/tests/test_sparse.py @@ -0,0 +1,57 @@ +"""稀疏向量编码器单元测试""" + +from app.core.sparse import SPARSE_DIM, SparseEncoder + + +class TestSparseEncoder: + """SparseEncoder.encode / encode_batch 行为""" + + def setup_method(self): + self.encoder = SparseEncoder() + + def test_deterministic(self): + """同一文本多次编码结果完全一致""" + text = "人工智能 artificial intelligence 2024" + assert self.encoder.encode(text) == self.encoder.encode(text) + + def test_indices_sorted_unique(self): + """indices 升序且无重复,与 values 等长,且都在 [0, SPARSE_DIM) 内""" + indices, values = self.encoder.encode("向量检索与关键词检索相结合 hybrid search 123") + assert indices == sorted(indices) + assert len(indices) == len(set(indices)) + assert len(indices) == len(values) + assert all(0 <= idx < SPARSE_DIM for idx in indices) + assert all(v > 0 for v in values) + + def test_mixed_text_non_empty(self): + """中英文混合文本产生非空向量""" + indices, values = self.encoder.encode("使用 Qdrant 存储向量") + assert len(indices) > 0 + assert len(values) > 0 + + def test_empty_string(self): + """空字符串返回空向量""" + assert self.encoder.encode("") == ([], []) + + def test_different_texts_differ(self): + """不同文本产生不同向量""" + assert self.encoder.encode("机器学习") != self.encoder.encode("深度学习") + + def test_english_case_insensitive(self): + """英文词小写化:大小写不同编码结果一致""" + assert self.encoder.encode("Hello") == self.encoder.encode("hello") + + def test_tf_weighting(self): + """词频越高权重越大(1 + log(tf))""" + once = dict(zip(*self.encoder.encode("apple"), strict=True)) + twice = dict(zip(*self.encoder.encode("apple apple"), strict=True)) + assert once.keys() == twice.keys() + for idx in once: + assert twice[idx] > once[idx] + + def test_batch_matches_single(self): + """encode_batch 与逐条 encode 结果一致""" + texts = ["人工智能", "vector search", ""] + batch = self.encoder.encode_batch(texts) + singles = [self.encoder.encode(t) for t in texts] + assert batch == singles diff --git a/tests/test_summarizer.py b/tests/test_summarizer.py new file mode 100644 index 0000000..3a52414 --- /dev/null +++ b/tests/test_summarizer.py @@ -0,0 +1,75 @@ +"""Summarizer 标题树接入的单元测试""" + +from app.core.summarizer import Summarizer +from app.models.document import SummaryLevel + + +class FakeOllama: + """记录调用次数的 OllamaClient 替身""" + + def __init__(self) -> None: + self.prompts: list[str] = [] + + async def generate(self, prompt: str) -> str: + self.prompts.append(prompt) + return "LLM 生成结果" + + +def _structured_text() -> str: + """构造带标题结构且长度 ≥ 500 的文档(不触发 2.5 回退)""" + paragraph = "这是章节正文内容,包含足够多的信息量。" * 10 + return f"# 安装指南\n{paragraph}\n## 环境准备\n{paragraph}\n## 安装步骤\n{paragraph}" + + +class TestStructuredDocument: + """结构化文档:L2 直接使用标题树,不调 LLM""" + + async def test_l2_from_headings_skips_llm(self): + ollama = FakeOllama() + summarizer = Summarizer(ollama=ollama) # type: ignore[arg-type] + + summary = await summarizer.summarize(_structured_text(), title="安装文档") + + assert summary.level == SummaryLevel.L3 + # L1 + L3 两次调用,跳过了 L2 的 LLM 调用 + assert len(ollama.prompts) == 2 + # 大纲由标题树渲染,包含标题文本与层级缩进 + assert summary.l2_outline is not None + assert "- 安装指南" in summary.l2_outline + assert " - 环境准备" in summary.l2_outline + assert " - 安装步骤" in summary.l2_outline + assert summary.l2_outline != "LLM 生成结果" + # L3 的 prompt 中注入了标题树大纲 + assert "安装指南" in ollama.prompts[1] + + +class TestPlainDocument: + """无结构文档:L2 回退 LLM 生成""" + + async def test_l2_falls_back_to_llm(self): + ollama = FakeOllama() + summarizer = Summarizer(ollama=ollama) # type: ignore[arg-type] + text = "这是一段没有标题结构的正文内容," * 40 # 640 字符,不触发 2.5 回退 + + summary = await summarizer.summarize(text, title="") + + assert summary.level == SummaryLevel.L3 + # L1 + L2 + L3 三次调用 + assert len(ollama.prompts) == 3 + assert summary.l2_outline == "LLM 生成结果" + + +class TestFallbackUnaffected: + """2.5 级回退逻辑不受标题树改造影响""" + + async def test_short_structured_text_still_fallback(self): + ollama = FakeOllama() + summarizer = Summarizer(ollama=ollama) # type: ignore[arg-type] + text = "# 标题一\n简短内容。\n# 标题二\n简短内容。" + + summary = await summarizer.summarize(text, title="") + + assert summary.level == SummaryLevel.L2_HALF + assert summary.l2_outline is None + # L1 + L2.5 两次调用 + assert len(ollama.prompts) == 2 diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..cb0b461 --- /dev/null +++ b/uv.lock @@ -0,0 +1,1044 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version == '3.13.*'", + "python_full_version < '3.13'", +] + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, +] + +[[package]] +name = "anyio" +version = "4.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, +] + +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + +[[package]] +name = "click" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "distro" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" } +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 = "fastapi" +version = "0.140.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e1/33/e0dfa29ccce4eb8c9a073f9e557b0d6bacbb3aa32e7ad595f678de4d036a/fastapi-0.140.7.tar.gz", hash = "sha256:09a640af2d29006345e1f28e4f031fa60f89b1a75d29f26070f3afa677d66cce", size = 422051, upload-time = "2026-07-27T17:34:45.908Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/0e/00cddd6b8668884e9c7588ab0eeb73becbd1efa3eaead34397f2e9a8de49/fastapi-0.140.7-py3-none-any.whl", hash = "sha256:960bb9696d8fd19dff488aa4f67f276364542cfcce9f7e68a82fe49dce126626", size = 131085, upload-time = "2026-07-27T17:34:47.036Z" }, +] + +[[package]] +name = "grpcio" +version = "1.83.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0c/98/304898ac4e04e2d5e4e4c2eadc178b1f2a16d5f4bc2f91306c87d64680b9/grpcio-1.83.0.tar.gz", hash = "sha256:7674587248fbbb2ac6e4eecf83a8a0f3d91a928f941de571acfd3a2f007fbc24", size = 13428824, upload-time = "2026-07-23T15:20:37.759Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/2b/51e32514a4e9b715375c99721aadff0f24164cc2049b8269eda4de82a814/grpcio-1.83.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:28f6c35ac8fcf10e4594f138e468f194360089dde40d126a7033e863fc479930", size = 6303167, upload-time = "2026-07-23T15:19:33.78Z" }, + { url = "https://files.pythonhosted.org/packages/39/33/b5b50fc2c6fbe350e04814047bb2d409feec7b36ef8b170254c050e06bc0/grpcio-1.83.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:33898e6a28e4ae598f1577cb1c4fec2a15c033d0ec52b9b45a09610dd045b9da", size = 12160538, upload-time = "2026-07-23T15:19:35.958Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5f/734e72e7b9f79bcf0b2c270b8d3bca0e4ebb97a27a50d06240b145f6d41e/grpcio-1.83.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6fb8a1dd0c6f0f931e69e9d0dc6d1c406ed2a44fa963414eafba07b7fb685d16", size = 6869310, upload-time = "2026-07-23T15:19:38.607Z" }, + { url = "https://files.pythonhosted.org/packages/a4/17/a1735f215b2a5cd43c38b79eac072ad197e61be9829905b6b29550abd0db/grpcio-1.83.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:2b5e75c34842cd9c1b95285ca395c6a569664b81e3ffa6b714125922942abaaf", size = 7613472, upload-time = "2026-07-23T15:19:40.645Z" }, + { url = "https://files.pythonhosted.org/packages/b2/78/c9e81f806ac704b6b145cb01628db398985b1f8dfdc10e23b55fb0902b3d/grpcio-1.83.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeb339838db07600481ef869507279b75326c75eac6d10f7afa62a0da1d2bcdd", size = 7040616, upload-time = "2026-07-23T15:19:42.349Z" }, + { url = "https://files.pythonhosted.org/packages/9a/ba/94cd5af859876049d340480acbb61a959096c84b567f215534faa78d0424/grpcio-1.83.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f47d62808b4c0a97b78bff88a6d4ca283a2a492b9a04a87d814af95ca3b9c19c", size = 7570491, upload-time = "2026-07-23T15:19:44.357Z" }, + { url = "https://files.pythonhosted.org/packages/3e/15/108d30d5a5c964312ae8b9cb0e8cc5b3c1cc68d8f757cca52b3565534d26/grpcio-1.83.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62003babc444a606dcd1f009cd16391ce23669ae4ad6ec267a873da7937a69f5", size = 8605036, upload-time = "2026-07-23T15:19:46.454Z" }, + { url = "https://files.pythonhosted.org/packages/ea/23/3828ae13c3db8233d123ad612747665817b952d8a954f32390230b582336/grpcio-1.83.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1aa567f8c3f19850ffd5d2858c9a8ea7c80f0db6c01186b71eb31e923ec984f5", size = 7981587, upload-time = "2026-07-23T15:19:48.913Z" }, + { url = "https://files.pythonhosted.org/packages/17/5b/77af31228f55f55a2a5112bb0077ad0a1c4d23dbb0c2853a62475bbdcc14/grpcio-1.83.0-cp312-cp312-win32.whl", hash = "sha256:cb2906c61db4f9c64cc360054b5df70eeb81846228e9e56a4944bd415a63dadc", size = 4394004, upload-time = "2026-07-23T15:19:50.618Z" }, + { url = "https://files.pythonhosted.org/packages/c0/da/f706e39550e7a3732ce2b9c5926107a93d74a802775b19b642a6df27dc96/grpcio-1.83.0-cp312-cp312-win_amd64.whl", hash = "sha256:1c699bbb20f143c8f2bff219de578aa2dc1f919399d67dc702b038b986ee62df", size = 5158525, upload-time = "2026-07-23T15:19:52.246Z" }, + { url = "https://files.pythonhosted.org/packages/56/eb/135daaa713f32d33b8f99b4153b3f8dc3b2a124996ac15581bf9ebdad3c3/grpcio-1.83.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:6662f3b1e07cc7493d437351860dc867bddc6a93c83ecf33bbfdaf0c217ab2d0", size = 6304480, upload-time = "2026-07-23T15:19:53.962Z" }, + { url = "https://files.pythonhosted.org/packages/8f/a1/121806ce69f23138dabe06aa595b0e5f1ae051a37e4c1954eed7d692c800/grpcio-1.83.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:74fe6f9e8a35c7dbf32255ee154d15e3e5338a81ed39173d079d594d2e544cd1", size = 12154419, upload-time = "2026-07-23T15:19:56.3Z" }, + { url = "https://files.pythonhosted.org/packages/b0/e8/d0389e09cd6b4c4d3089b92967ae4e3ffd64795bd349bf2f85cd6656d3da/grpcio-1.83.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:10b3fa0475eb572c9a81a6fe37fa16a9c500c0c91cfc148cac15692b7e3c2867", size = 6873200, upload-time = "2026-07-23T15:19:58.701Z" }, + { url = "https://files.pythonhosted.org/packages/f8/51/f464c1d211fa50d5adbabe1b2e519948d99c13757052bfc9ea7afa28e284/grpcio-1.83.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:5f20a988480b0f28207f057f7f7ae1313393c3cef0adcfeae8248f9947eaf881", size = 7618811, upload-time = "2026-07-23T15:20:00.733Z" }, + { url = "https://files.pythonhosted.org/packages/e8/c0/539fe0832f2dd6500a28f5263071623fb34e8d4867aec632ccf81bd21156/grpcio-1.83.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7bd82671b39065ba18cd536e9cd45b27ff649053f81ddd2c6a966d595067080f", size = 7042310, upload-time = "2026-07-23T15:20:02.675Z" }, + { url = "https://files.pythonhosted.org/packages/8c/ca/ccf617d37ffa72567fa8e005ec7090c99da922799be2fb9847c8b21ca18c/grpcio-1.83.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc60215b5cb9fc8ca72942c498b551ac2305bd08f6ef8d4e3f0d21b64fbecd61", size = 7575412, upload-time = "2026-07-23T15:20:04.712Z" }, + { url = "https://files.pythonhosted.org/packages/eb/b9/fd8d5245f823a8e0fd35d90e20ea3aa4acd47f8d5318fa8df307df52dec6/grpcio-1.83.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:f1c3e5689d4b90987b1d72022bcfe866a9a3dc66197484cf856d96b6150e7f45", size = 8604248, upload-time = "2026-07-23T15:20:06.77Z" }, + { url = "https://files.pythonhosted.org/packages/14/1e/f37632fc11db72dfa4bba86c3a43e54358e53030df111ecae5e91a733ad6/grpcio-1.83.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a21cb4eeeba124443f399be2e8b624943cde864dcbe588cb42e5c483a52a906c", size = 7977458, upload-time = "2026-07-23T15:20:09.109Z" }, + { url = "https://files.pythonhosted.org/packages/93/b6/d70b69ae5c0cfc341b9ba474980e4ed99cbf05c0e4a14e9eee8cb73db0a5/grpcio-1.83.0-cp313-cp313-win32.whl", hash = "sha256:8fe04f1050a59f875601eb55d42b4f66946fe89817f967e34db1462ccd07dadf", size = 4393993, upload-time = "2026-07-23T15:20:11.017Z" }, + { url = "https://files.pythonhosted.org/packages/0f/13/45d4cccb555cf4c476226979bf3d2fd0b0254216f7564c3a053e35117efc/grpcio-1.83.0-cp313-cp313-win_amd64.whl", hash = "sha256:6e01ecd9d8ef280abe1365138a4dc318f9a5287f4cb1b41d07816f796653f735", size = 5159650, upload-time = "2026-07-23T15:20:12.979Z" }, + { url = "https://files.pythonhosted.org/packages/9c/60/f2cca8147ea213d3e43ae9158d03ad04e020fdf32ff027253e1fe93f921d/grpcio-1.83.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:3f351629f6ae16ecc0ec3553e586a6763ffd9f6114044286d0cbec3e09241bfa", size = 6305607, upload-time = "2026-07-23T15:20:15.353Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ab/d3874931d123a95e83a3ebf8aa04537988fb62425cedb8bf3cefc5ad41b2/grpcio-1.83.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d05ff664100d429335b93c91b8b34ddf9e94a112205e7fa06dede309e44a4e4c", size = 12166617, upload-time = "2026-07-23T15:20:17.435Z" }, + { url = "https://files.pythonhosted.org/packages/92/ff/6f18f9426b69306f4e00a9add3b0ee2748da8aad53836ef80cab0d62d04f/grpcio-1.83.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7936f2a56cf04f6514705c0fedf400971de01b6aa1719327e4718f410a765e2b", size = 6880213, upload-time = "2026-07-23T15:20:19.98Z" }, + { url = "https://files.pythonhosted.org/packages/70/21/706d1147c6b93b98f179240c13991fbcc56880eba0c868abb1ad40d8a0a6/grpcio-1.83.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b0a0be840e51b6b7ee9df9269770faf77bdf4b771053c257c21d12bad607714c", size = 7618335, upload-time = "2026-07-23T15:20:22.161Z" }, + { url = "https://files.pythonhosted.org/packages/74/04/1a8443c889115ec9e213a213e86bc93a71ee9088027e5befa09aaa0edd9d/grpcio-1.83.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:009667eaf3dcd5224c713589cdc98e7ca4ed0ff0b61132c6b276e930eb83a2df", size = 7043416, upload-time = "2026-07-23T15:20:24.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/c6/94e0fee5b12bc1da1370185b680988db6f739d19b42d9959db01a7ea50bf/grpcio-1.83.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bb669918fd88936b15599caff4160a77ab74bdeb25f2231f6e45b61282d6107b", size = 7583253, upload-time = "2026-07-23T15:20:26.313Z" }, + { url = "https://files.pythonhosted.org/packages/a0/97/de1ccb671fb85575bc5192faedf9ecdbdf5b390d2e6584dcf552bcbd370e/grpcio-1.83.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:c19b454d3d3f28db81f2c7c4dbaee96e7f6fd149721733ffe79d6bc530f17404", size = 8605102, upload-time = "2026-07-23T15:20:28.437Z" }, + { url = "https://files.pythonhosted.org/packages/17/0f/0e0ec749a7034ffcbaa050e39779872950ead90c22e7e0116be3f28b2b46/grpcio-1.83.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:61007cd08640abc5c54547ee32505474c482cd733a53cb87551ea81faa6350af", size = 7979826, upload-time = "2026-07-23T15:20:31.182Z" }, + { url = "https://files.pythonhosted.org/packages/83/fa/c3fda157287f64bc65acee6c5aa90c41acf9e0d3a8e69a265eecff6d00a1/grpcio-1.83.0-cp314-cp314-win32.whl", hash = "sha256:32e11c37f5285b0c6fa3042c05fe06903696689749833fc64e67dec71b9bbe33", size = 4471765, upload-time = "2026-07-23T15:20:33.195Z" }, + { url = "https://files.pythonhosted.org/packages/a1/00/b1b26431c9d54eee11724fd6e5585473a2ed47fbc1fb95e5204906a642ce/grpcio-1.83.0-cp314-cp314-win_amd64.whl", hash = "sha256:2bb48cb5e6dd005ca12b89ce4b6ac0b48ff3112c747542ee7986ef611a8ca6d9", size = 5298932, upload-time = "2026-07-23T15:20:35.48Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "h2" +version = "4.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "hpack" }, + { name = "hyperframe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/30/d4/a7d6fb3f58be99d65cbf2d3f766896217a2921d0f3ab10711c45dc1519ee/h2-4.4.0.tar.gz", hash = "sha256:46b551bdcdc7e83cf5c04d0bf93badb8a939bd2287d9fee1abb23a445b9e0580", size = 2156691, upload-time = "2026-07-23T19:14:19.442Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f6/df/5b14a118322d6097cb9bb30ec6bacad268e546a8ecfcb1f6d0de618dac2f/h2-4.4.0-py3-none-any.whl", hash = "sha256:6acffe1aeab79098d7eb0f8385c1add11f2c7a94815f6fa2b7060eeddee3d87c", size = 62368, upload-time = "2026-07-23T19:14:16.143Z" }, +] + +[[package]] +name = "hpack" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/26/5b/fcabf6028144a8723726318b07a32c2f3314acdff6265743cf08a344b18e/hpack-4.2.0.tar.gz", hash = "sha256:0895cfa3b5531fc65fe439c05eb65144f123bf7a394fcaa56aa423548d8e45c0", size = 51300, upload-time = "2026-06-23T18:34:46.667Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/b4/4a9fcfb2aef6ba44d9073ecd301443aa00b3dac95de5619f2a7de7ec8a91/hpack-4.2.0-py3-none-any.whl", hash = "sha256:858ac0b02280fa582b5080d68db0899c62a80375e0e5413a74970c5e518b6986", size = 34246, upload-time = "2026-06-23T18:34:45.472Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httptools" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/e5/d471fcb0e14523fe1c3f4ba58ca52480e7bd70ad7109a3846bc75892f7fb/httptools-0.8.0.tar.gz", hash = "sha256:6b2a32f18d97e16e90827d7a819ffa8dbd8cc245fc4e1fa9d1095b54ef4bd999", size = 271342, upload-time = "2026-05-25T22:17:48.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/88/1d21a36da8f5cb0fa49eafd4b169eba5608d57e75bbcf61845cbc6243216/httptools-0.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:880490234c10f70a9830743097e8958d6e4b9f5a0ffc24515023afeef984054d", size = 208247, upload-time = "2026-05-25T22:17:07.843Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/cc4feea2945cb3051038f090c9b36bd5b8a9d7f5a894a506a8983e33fd1c/httptools-0.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5931891fb7b441b8a3853cf1b85c82c903defce084dd5f6771ca46e31bf862c5", size = 113064, upload-time = "2026-05-25T22:17:09.136Z" }, + { url = "https://files.pythonhosted.org/packages/e3/a6/febbb8b8db0f58b38e44ad6cb946e6a255ae49b55f2e8543408fb7501ccd/httptools-0.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b15fc622b0f869d19207c4089a501d9bcc63ca5e071ffdd2f03f922df882dcb2", size = 523851, upload-time = "2026-05-25T22:17:10.106Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e4/f90a0df0b83beff265b7e3b65f2a4cefd95792d4be0ac3e16049f2acd3c2/httptools-0.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:425f83884fd6343828d8c565f046cb72b6d19063f6924093e11bcd8e1548cd09", size = 518842, upload-time = "2026-05-25T22:17:11.218Z" }, + { url = "https://files.pythonhosted.org/packages/9e/2d/0c9ac76dd2c893841fbf6498d6acec4f2442e1b7067f6e3e316a80e494e8/httptools-0.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ef7c3c97f4311c7be57e2986629df89d49cb434dbff78eafcd48c2bff986b15a", size = 501238, upload-time = "2026-05-25T22:17:12.728Z" }, + { url = "https://files.pythonhosted.org/packages/ca/42/906adc91ae3a5fa9c59c0a2f21c139725bd7e5b41ae6acd485cd14123ebf/httptools-0.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a1afd7c9fbff0d9f5d489c4ce2768bd09c84a46ddefc7161e6aa82ae35c85745", size = 509567, upload-time = "2026-05-25T22:17:13.842Z" }, + { url = "https://files.pythonhosted.org/packages/05/0b/4240efeb672751ee5b9b380cb0e3fdc050bc05f68adc7a8aefc4fcd9a69a/httptools-0.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:cd96f29b4bab1d42fa6e3d008711c75e0f79e94e06827330160e3a304227f150", size = 90918, upload-time = "2026-05-25T22:17:15.155Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e5/8cfcabc5546e8022f168be28bcdaa128a240a0befdd03b59d558b4f18bd6/httptools-0.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:614ceea8ea606848bece2338ac03b3ce5324bcb4be8dc7d377ed708012fa4db8", size = 205148, upload-time = "2026-05-25T22:17:16.333Z" }, + { url = "https://files.pythonhosted.org/packages/2a/0e/0fb14848c19a686c8062ff9067c1a48793e3224b47bc5b201535b6036fce/httptools-0.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2d689918c15a013c65ef52d9fd495d766893ab831a2c8d89f2ac5940a5df847c", size = 111368, upload-time = "2026-05-25T22:17:17.586Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/46f1cecf06b9bbde8e4b8c88034ac7908989e5ff7a3a388ef38392949c1f/httptools-0.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:eb3028cca2fc0a6d720e52ef61d8ebb62fcbfeb1de56874546d858d3f25a26b7", size = 486447, upload-time = "2026-05-25T22:17:18.564Z" }, + { url = "https://files.pythonhosted.org/packages/77/00/258bfc0837221f81d9725c45f9b948a6a6b2994a147a4fb66e85100c668f/httptools-0.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:88bdd940f2b5d487b4d032c6afa5489a7dc4694410d43de3c38c4fb3af0dc45d", size = 482448, upload-time = "2026-05-25T22:17:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/04/ab/d1cef3b5523f4d272a70f42a776c3169a2dddfe3a54de4b2ce4a36341528/httptools-0.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6a43c9dd399758ccc0531acb0a3c4a6c299ee893ee9400e9c893b7bdcfae0681", size = 464460, upload-time = "2026-05-25T22:17:20.882Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/5d1d072442277bb2b3434e0e60690b8e8c23840ef7de8b6ea54040a536d3/httptools-0.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0770728beb05094c809b98e814edff5fef69d26ad7d21185f2f6d5884a0ba683", size = 471312, upload-time = "2026-05-25T22:17:22.085Z" }, + { url = "https://files.pythonhosted.org/packages/0d/66/b96623b27e51a68199ef4efdda0613cced9233fe3062ac74e50749c5ad37/httptools-0.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:7685df791fad561384bfb139e77fde27a1ffd93134e016f95a0db424ffbf77b1", size = 90117, upload-time = "2026-05-25T22:17:23.074Z" }, + { url = "https://files.pythonhosted.org/packages/1a/12/fa3fbf5f9517b273edea2dc982aa82a8c634091e67c590792b729017bc6f/httptools-0.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:de242a49b5d18e0a8776e654e9f6bf6d89f3875a5c35b425a0e7ce940feb3fd6", size = 206183, upload-time = "2026-05-25T22:17:24.004Z" }, + { url = "https://files.pythonhosted.org/packages/30/fc/5e7c4cb443370f2090a3aba0453a07384d29ff66b7435bb90e77e1037599/httptools-0.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:159e9ab5f701ccd42e555a12f1ad8ff69702910fc1c996cf2bb66e5fcb7a231b", size = 112079, upload-time = "2026-05-25T22:17:25.216Z" }, + { url = "https://files.pythonhosted.org/packages/ba/53/771bd891eb0f236f32145d6a1775777ec85745f3cc983a1f23d1a3b8ddfe/httptools-0.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c4a9f1707e4823d54dfec6c33fa3697d302aed536ed352a7ebb5a061ddb869d0", size = 481596, upload-time = "2026-05-25T22:17:26.186Z" }, + { url = "https://files.pythonhosted.org/packages/62/42/94e15bc68ce3d423243c45d7f1b0c7561f13844f97dc52ae23182fb65628/httptools-0.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d76ad7b951387e3632c8716a9bb03ac5b45c5f16119aa409db0459520887944e", size = 480865, upload-time = "2026-05-25T22:17:27.542Z" }, + { url = "https://files.pythonhosted.org/packages/1c/7c/fe2980fc03723272e30f135b62360b075f513dfe7cc73aef36c7f04012bd/httptools-0.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a3b7387147361c3fd47a0bde763c5c91b5b4cd4dc9989b8ece84ff436c99843b", size = 463189, upload-time = "2026-05-25T22:17:28.546Z" }, + { url = "https://files.pythonhosted.org/packages/15/1b/47fc5fff68acd1bfa20b4734059c9a06cadb88119dcd5258b5b0d21d91c8/httptools-0.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f256d6ce930c52ca1cb2a960b7da03548c454e7d28b06059ad41bfe789036ce0", size = 466610, upload-time = "2026-05-25T22:17:29.816Z" }, + { url = "https://files.pythonhosted.org/packages/60/bd/07b13c93ffd9bec9546e0d43f8e19378dd696dbd278511406bc07371ef1f/httptools-0.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:19d1ee275bb59ba2643ba9a3a1e51cc0c788caf2b8df506368e03f56fdd08527", size = 92705, upload-time = "2026-05-25T22:17:31.133Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c4/121648f68ce066d7bd762d6b6d97e620847642d38d54f3d90ff11d947629/httptools-0.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:de1ed58a974e75d56560acc7e7fed01a454994429456f65209789992e41f2568", size = 215023, upload-time = "2026-05-25T22:17:32.401Z" }, + { url = "https://files.pythonhosted.org/packages/b9/b0/312a062ae741ae3e8baa8c8bf20be81b2e67337b259ab4349bebc7b6142e/httptools-0.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e93c227b595c6926c1acee96891dd9da4be338cfbe82e5cd3bb9d8dd7dc4ac0b", size = 117405, upload-time = "2026-05-25T22:17:33.742Z" }, + { url = "https://files.pythonhosted.org/packages/fc/37/fccd705f795386bb05bf413012fecff2a33e5aa8c2f069096de3e9fd8702/httptools-0.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2a021c3a8e65cc125390d72f59b968afca3bdcaff25bd67965e0a055a14946ca", size = 558497, upload-time = "2026-05-25T22:17:34.732Z" }, + { url = "https://files.pythonhosted.org/packages/bd/39/f172e8003576de35f5ba77ff417cf0e34429d35dc014deef15afa337a72c/httptools-0.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48774d39cbb70e2b1f71f88852a3087ae1d3a1eb80482bb48c13067ab080c14f", size = 571585, upload-time = "2026-05-25T22:17:35.813Z" }, + { url = "https://files.pythonhosted.org/packages/3e/b9/f5564760af99f3dbbf3f9104dc00e5da27e96cf433c6bdcf77617f70bf3f/httptools-0.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:88eead8ec8680a9f146c655bc88445a325bd7921cfd8194c7337e9467282427d", size = 543297, upload-time = "2026-05-25T22:17:37.08Z" }, + { url = "https://files.pythonhosted.org/packages/99/67/8d9f2c313618e161b82f3873188e7196126da1d6e29688df40eb3997c77a/httptools-0.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2c032fa028f46871ec7e1fc59fc15e8023eab3e6bbe6ece786a1611719a5d081", size = 539535, upload-time = "2026-05-25T22:17:38.032Z" }, + { url = "https://files.pythonhosted.org/packages/48/63/b906c01e53f50d432c0defe43ce52764a111dc1bdd028bafbeb54dcfd008/httptools-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:384c17174464c8e873398b7af24f0b1f44d992c820328413951a625323155d77", size = 108209, upload-time = "2026-05-25T22:17:39.473Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[package.optional-dependencies] +http2 = [ + { name = "h2" }, +] + +[[package]] +name = "hyperframe" +version = "6.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/02/e7/94f8232d4a74cc99514c13a9f995811485a6903d48e5d952771ef6322e30/hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08", size = 26566, upload-time = "2025-01-22T21:41:49.302Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/30/47d0bf6072f7252e6521f3447ccfa40b421b6824517f82854703d0f5a98b/hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5", size = 13007, upload-time = "2025-01-22T21:41:47.295Z" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "jiter" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/1f/10936e16d8860c70698a1aa939a46aa0224813b782bce4e000e637da0b2d/jiter-0.16.0.tar.gz", hash = "sha256:7b24c3492c5f4f84a37946ad9cf504910cf6a782d6a4e0689b6673c5894b4a1c", size = 176431, upload-time = "2026-06-29T13:05:13.657Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/2b/52ace16ed031354f0539749a49e4bf33797d82bea5137910835fa4b09793/jiter-0.16.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:67c3bc1760f8c99d805dcab4e644027142a53b1d5d861f18780ebdbd5d40b72a", size = 306943, upload-time = "2026-06-29T13:03:14.035Z" }, + { url = "https://files.pythonhosted.org/packages/94/2e/34957c2c1b661c252ba9bcc60ae0bddc27e0f7202c6073326a13c5390eec/jiter-0.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5af7780e4a26bd7d0d989592bf9ef12ebf806b74ab709223ecca37c749872ea9", size = 307779, upload-time = "2026-06-29T13:03:15.418Z" }, + { url = "https://files.pythonhosted.org/packages/88/6c/59bd309cab4460c54cf1079f3eb7fe7af6a4c895c5c957a53378693bad2b/jiter-0.16.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d5bf78d0e05e45cfdd66558893938d59afe3d1b1a824a202039b20e607d25a72", size = 335826, upload-time = "2026-06-29T13:03:17.11Z" }, + { url = "https://files.pythonhosted.org/packages/3b/8c/f5ef7b65f0df47afa16596969defb281ebb86e96df346d62be6fd853d620/jiter-0.16.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f4444a83f946605990c98f625cdd3d2725bfb818158760c5748c653170a20e0e", size = 362573, upload-time = "2026-06-29T13:03:18.781Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0b/ace4354da061ee38844a0c27dc2c21eecd27aea119e8da324bea987522d0/jiter-0.16.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3a23f0e4f957e1be65752d2dfac9a5a06b1917af8dc85deb639c3b9d02e31290", size = 457979, upload-time = "2026-06-29T13:03:20.293Z" }, + { url = "https://files.pythonhosted.org/packages/55/40/c0253d3772eb9dcd8e6606ee9b2d53ec8e5b814589c47f140aa585f21eaa/jiter-0.16.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c22a488f7b9218e245a0025a9ba6b100e2e54700831cf4cf16833a27fba3ad01", size = 372302, upload-time = "2026-06-29T13:03:21.739Z" }, + { url = "https://files.pythonhosted.org/packages/a8/d2/4839422241aa12860ce597b20068727094ba0bc480723c74924ca5bad483/jiter-0.16.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:46add52f4ad47a08bfb1219f3e673da972191489a33016edefdb5ea55bfa8c48", size = 343805, upload-time = "2026-06-29T13:03:23.384Z" }, + { url = "https://files.pythonhosted.org/packages/e2/59/e196888a05befdda7dbe299b722d56f2f6eec65402bc34c0a3306d595feb/jiter-0.16.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:9c8a956fd72c2cf1e730d01ea080341f13aa0a97a4a33b51abebe725b7ae9ca9", size = 351107, upload-time = "2026-06-29T13:03:24.815Z" }, + { url = "https://files.pythonhosted.org/packages/ec/74/4cd9e0fca65232136400354b630fbfcd2de634e22ccbb96567725981b548/jiter-0.16.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:561926e0573ffe4a32498420a76d64b16c513e1ab413b9d28158a8764ac701e5", size = 388441, upload-time = "2026-06-29T13:03:26.266Z" }, + { url = "https://files.pythonhosted.org/packages/d9/8c/554691e48bc711299c0a293dd8a6179e24b2d66a54dc295421fcf64569c0/jiter-0.16.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:44d019fa8cdaf89bf29c71b39e3712143fdd0ac76725c6ef954f9957a5ea8730", size = 516354, upload-time = "2026-06-29T13:03:28.02Z" }, + { url = "https://files.pythonhosted.org/packages/a4/cb/01e9d69dc2cc6759d4f91e230b34489c4fdb2518992650633f9e20bece89/jiter-0.16.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:0df91907609837f33341b8e6fe73b95991fdaa57caf1a0fbd343dffe826f386f", size = 547880, upload-time = "2026-06-29T13:03:29.534Z" }, + { url = "https://files.pythonhosted.org/packages/79/70/2953195f1c6ad00f49fa67e13df7e60acb3dd4f387101bc15abccddd905e/jiter-0.16.0-cp312-cp312-win32.whl", hash = "sha256:51d7b836acb0108d7c77df1742332cac2a1fa04a74d6dacec46e7091f0e91274", size = 203473, upload-time = "2026-06-29T13:03:31.025Z" }, + { url = "https://files.pythonhosted.org/packages/2d/05/2909a8b10699a4d560f8c502b6b2c5f3991b682b1922c1eedda242b225bd/jiter-0.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:1878349266f8ee36ecb1375cc5ba2f115f35fd9f0a1a4119e725e379126647f7", size = 196905, upload-time = "2026-06-29T13:03:32.472Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a9/6b82bb1c8d7790d602489b967b982a909e5d092875a6c2ade96444c8dfc5/jiter-0.16.0-cp312-cp312-win_arm64.whl", hash = "sha256:2ed5738ae4af18271a51a528b8811b0cbfa4a1858de9d83359e4169855d6a331", size = 190618, upload-time = "2026-06-29T13:03:34.672Z" }, + { url = "https://files.pythonhosted.org/packages/91/c0/555fc60473d30d66894ba825e63615e3be7524fac23858356afa7a38906c/jiter-0.16.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:41977aa5654023948c2dae2a81cbf9c43343954bef1cd59a154dd15a4d84c195", size = 306203, upload-time = "2026-06-29T13:03:36.243Z" }, + { url = "https://files.pythonhosted.org/packages/d0/2b/c3eaf16f5d7c9bad66ea32f40a95bd169b29a91217fcc7f081375157e99c/jiter-0.16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d28bb3c26762358dadf3e5bf0bccd29ae987d65e6988d2e6f49829c76b003c09", size = 306489, upload-time = "2026-06-29T13:03:37.846Z" }, + { url = "https://files.pythonhosted.org/packages/96/3f/02fdfc6705cad96127d883af5c34e4867f554f29ec7705ec1a46156400a9/jiter-0.16.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0542a7189c26920778658fc8fcf2af8bae05bae9924577f71804acef37996536", size = 335453, upload-time = "2026-06-29T13:03:39.221Z" }, + { url = "https://files.pythonhosted.org/packages/b2/a6/e4bda5920d4b0d7c5dfb7174ce4a6b2e4d3e11c9162c452ef0eab4cdbdbd/jiter-0.16.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8fb8de1e23a0cb2a7f53c335049c7b72b6db41aa6227cdcc0972a1de5cb39450", size = 361625, upload-time = "2026-06-29T13:03:40.597Z" }, + { url = "https://files.pythonhosted.org/packages/b7/97/4e6b59b2c6e55cbb3e183595f81ad65dcfb21c915fee5e19e335df21bc55/jiter-0.16.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b72d0b2990ca754a9102779ac98d8597b7cb31678958562214a007f909eab78e", size = 456958, upload-time = "2026-06-29T13:03:42.074Z" }, + { url = "https://files.pythonhosted.org/packages/15/e0/97e9557686d2f94f4b93786eccb7eed28e9228ad132ea8237f44727314a7/jiter-0.16.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d5f91b1c27fc22a57993d5a5cb8a627cb8ed4b10502716fac1ffbfe1d19d84e8", size = 372017, upload-time = "2026-06-29T13:03:43.658Z" }, + { url = "https://files.pythonhosted.org/packages/0f/94/db768b6938e0df35c86beeba3dfbbb025c9ee5c19e1aa271f2396e50864d/jiter-0.16.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c682bea068a90b764577bdb78a60a4c1d1606daf9cd4c893832a37c7cc9d9026", size = 343320, upload-time = "2026-06-29T13:03:45.226Z" }, + { url = "https://files.pythonhosted.org/packages/c1/d6/5a59d938244a30735fe62d9433fd325f9021ea29d89780ea4596ea93bc89/jiter-0.16.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:8d031aabecc4f1b6276adfb42e3aabb77c89d468bf616600e8d3a11328929053", size = 350520, upload-time = "2026-06-29T13:03:46.671Z" }, + { url = "https://files.pythonhosted.org/packages/67/f8/c4a857f49c9af125f6bbcac7e3eee7f7978ed89682833062e2dbf62576b1/jiter-0.16.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:eab2cd170150e70153de16896a1774e3a1dca80154c56b54d7a812c479a7165e", size = 387550, upload-time = "2026-06-29T13:03:48.361Z" }, + { url = "https://files.pythonhosted.org/packages/8b/d6/5fbc2f7d6b67b754caa61a993a2e626e815dec47ffc2f9e35f01adfebec7/jiter-0.16.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:6edb63a46e65a82c26800a868e49b2cac30dd5a4218b88d74bc2c848c8ad60bb", size = 515424, upload-time = "2026-06-29T13:03:49.881Z" }, + { url = "https://files.pythonhosted.org/packages/ed/54/284f0164b64a5fed915fea6ba7e9ba9b3d8d37c67d59cf2e3bb99d45cdfe/jiter-0.16.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:659039cc50b5addcc35fcc87ae2c1833b7c0a8e5326ef631a75e4478447bcf84", size = 546981, upload-time = "2026-06-29T13:03:51.363Z" }, + { url = "https://files.pythonhosted.org/packages/13/c5/2a467585a576594384e1d2c43e1224deaafc085f24e243529cf98beef8e1/jiter-0.16.0-cp313-cp313-win32.whl", hash = "sha256:c9c53be232c2e206ef9cdbad81a48bfa74c3d3f08bcf8124630a8a748aad993e", size = 202853, upload-time = "2026-06-29T13:03:53.015Z" }, + { url = "https://files.pythonhosted.org/packages/88/6a/de61d04b9eec69c71719968d2f716532a3bc121170c44a39e14979c6be81/jiter-0.16.0-cp313-cp313-win_amd64.whl", hash = "sha256:baad945ed47f163ad833314f8e3288c396118934f94e7bbb9e243ce4b341a4fd", size = 196160, upload-time = "2026-06-29T13:03:54.447Z" }, + { url = "https://files.pythonhosted.org/packages/19/4b/b390ed59bafb3f31d008d1218578f10327714484b334439947f7e5b11e7f/jiter-0.16.0-cp313-cp313-win_arm64.whl", hash = "sha256:3c1fd2dbe1b0af19e987f03fe66c5f5bd105a2229c1aff4ab14890b24f41d21a", size = 189862, upload-time = "2026-06-29T13:03:55.754Z" }, + { url = "https://files.pythonhosted.org/packages/a7/89/bc4f1b57d5da938fd344a466396541e586d161320d70bffd929aaafcd8f4/jiter-0.16.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b2c61484666ad42726029af0c00ef4541f0f3b5cdc550221f56c2343208018ee", size = 308239, upload-time = "2026-06-29T13:03:57.205Z" }, + { url = "https://files.pythonhosted.org/packages/65/7a/c415453e5213001bf3b411ff65dec3d303b0e76a4a2cfea9768cd4960994/jiter-0.16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:63efadc657488f45db1c676d81e704cac2abf3fdb892def1faea61db053127e2", size = 308928, upload-time = "2026-06-29T13:03:58.643Z" }, + { url = "https://files.pythonhosted.org/packages/11/fc/1f4fb7ebf9a724c7741994f4aae18fba1e2f3133df14521a79194952c34a/jiter-0.16.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cf0d73f50e7b6935677854f6e8e31d499ca7064dd24734f703e060f5b237d883", size = 336998, upload-time = "2026-06-29T13:04:00.071Z" }, + { url = "https://files.pythonhosted.org/packages/a0/8d/72cadaac05ccfa7cc3a0a2232862e6c72443ca40cf300ba8b57f9f18b69b/jiter-0.16.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bf3ea07d9bc8e7d03a9fbc051295462e6dbc295b894fd72457c3136e3e43d898", size = 362112, upload-time = "2026-06-29T13:04:01.52Z" }, + { url = "https://files.pythonhosted.org/packages/58/4a/c4b0d5f651fda90a24ffce9f8d56cde462a2e09d31ae3de3c68cef34c04e/jiter-0.16.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:26798522707abb47d767db536e4148ceac1b14446bf028ee85e579a2e043cfe5", size = 459807, upload-time = "2026-06-29T13:04:03.214Z" }, + { url = "https://files.pythonhosted.org/packages/80/58/ef77879ea9aa56b50824edc5a445e226422c7a8d211f3fd2a56bcb9493cf/jiter-0.16.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bc837c1b9631be10abfe0191537fe8009838204cec7e44827401ace390ddb567", size = 373181, upload-time = "2026-06-29T13:04:04.629Z" }, + { url = "https://files.pythonhosted.org/packages/49/2e/ffbc3f254e4d8a66da3062c624a7df4b7c2b2cf9e1fe43cf394b3e104041/jiter-0.16.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49060fd70737fad59d33ba9dcc0d83247dc9e77187de26053a19c16c9f32bd69", size = 344927, upload-time = "2026-06-29T13:04:06.067Z" }, + { url = "https://files.pythonhosted.org/packages/9a/f6/0be5dc6d64a89f80aa8fec984f94dedb2973e251edcae55841d60786d578/jiter-0.16.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:adbb8edeadd431bc4477879d5d371ece7cb1334486584e0f252656dd7ffada29", size = 352754, upload-time = "2026-06-29T13:04:07.477Z" }, + { url = "https://files.pythonhosted.org/packages/da/6e/7d31243b3b91cd261dd19e9d3557fc3251a80883d3d8049c86174e7ab7af/jiter-0.16.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:31aaee5b80f672c1dc21272bcfb9cbdcfc1ea04ff50f00ed5af500b80c44fa93", size = 390553, upload-time = "2026-06-29T13:04:08.92Z" }, + { url = "https://files.pythonhosted.org/packages/25/33/51ae371fde3c88897520f62b4d5f8b27ad7103e2bb10812ff52195609853/jiter-0.16.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:6722bcef4ffc86c835574b1b2fac6b33b9fb4a889c781e67950e891591f3c55a", size = 516900, upload-time = "2026-06-29T13:04:10.407Z" }, + { url = "https://files.pythonhosted.org/packages/a0/45/6449b3d123ea439ba79507c657288f461d55049e7bcbdc2cf8eb8210f491/jiter-0.16.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:5ab4f50ff971b611d656554ea10b75f80097392c827bc32923c6eeb6386c8b00", size = 548754, upload-time = "2026-06-29T13:04:12.046Z" }, + { url = "https://files.pythonhosted.org/packages/9b/e7/fd2fb11ae3e2649333da3aa170d04d7b3000bbdc3b270f6513382fdf4e04/jiter-0.16.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:710cc51d4ebdcd3c1f70b232c1db1ea1344a075770422bbd4bede5708335acbe", size = 122381, upload-time = "2026-06-29T13:04:13.413Z" }, + { url = "https://files.pythonhosted.org/packages/26/80/f0b147a62c315a164ed2168908286ca302310824c218d3aae52b06c0c9a9/jiter-0.16.0-cp314-cp314-win32.whl", hash = "sha256:57b37fc887a32d44798e4d8ebfa7c9683ff3da1d5bf38f08d1bb3573ccb39106", size = 204578, upload-time = "2026-06-29T13:04:14.813Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e6/4758a14304b4523a6f5adb2419340086aa3593bd4327c2b25b5948a90548/jiter-0.16.0-cp314-cp314-win_amd64.whl", hash = "sha256:cbd18dd5e2df96b580487b5745adf57ef64ad89ba2d9662fc3c19386acce7db8", size = 198154, upload-time = "2026-06-29T13:04:16.272Z" }, + { url = "https://files.pythonhosted.org/packages/26/be/41fa54a2e7ea41d6c99f1dc5b1f0fd4cb474680304b5d268dd518e81da3a/jiter-0.16.0-cp314-cp314-win_arm64.whl", hash = "sha256:a32d2027a9fa67f109ff245a3252ece3ccc32cc56703e1deab6cc846a59e0585", size = 191458, upload-time = "2026-06-29T13:04:17.707Z" }, + { url = "https://files.pythonhosted.org/packages/81/6b/59127338b86d9fe4d99418f5a15118bea778103ee0fe9d9dd7e0af174e95/jiter-0.16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2577196f4474ef3fc4779a088a23b0897bbf86f9ea3679c372d45b8383b43207", size = 316739, upload-time = "2026-06-29T13:04:19.663Z" }, + { url = "https://files.pythonhosted.org/packages/2d/95/49461034d5388196d3dabf98748935f017b7785d8f3f5349f834bcc4ed0d/jiter-0.16.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:616e89e008a93c01104161c75b4988e58716b01d62307ebfe161e52a56d2a818", size = 340911, upload-time = "2026-06-29T13:04:21.257Z" }, + { url = "https://files.pythonhosted.org/packages/cd/97/a4369f2fb82cb3dda13b98622f31249b2e014b223fe64ee534413ad72294/jiter-0.16.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e2e9efbe042210df657bade597f66d6d75723e3d8f45a12ea6d8167ff8bbce3", size = 361747, upload-time = "2026-06-29T13:04:22.677Z" }, + { url = "https://files.pythonhosted.org/packages/28/51/49b6ed456261646e1906016a6760367a28aacd3c24805e4e5fe64116c1db/jiter-0.16.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3f4d9e473a5ce7d27fef8b848df4dc16e283893d3f53b4a585e72c9595f3c284", size = 460225, upload-time = "2026-06-29T13:04:24.441Z" }, + { url = "https://files.pythonhosted.org/packages/33/b5/5689aff4f66c5b60be63106e591dbfcba2190df97d2c9c7cf052361ddb98/jiter-0.16.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8d30a4a1c87713060c8d1cc59a7b6c8fb6b8ef0a6900368014c76c87922a2929", size = 373169, upload-time = "2026-06-29T13:04:25.884Z" }, + { url = "https://files.pythonhosted.org/packages/a2/96/3ae1b85ee0d6d6cab254fb7f8da018272b932bbf2d69b07e98aa2a96c746/jiter-0.16.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bae96332410f866e5900d809298b1ed82735932986c672495f9701daacd80620", size = 350332, upload-time = "2026-06-29T13:04:27.302Z" }, + { url = "https://files.pythonhosted.org/packages/15/32/c99d7bafd78986556c95bf60ce84c6cc98786eac56066c12d7f828bb6747/jiter-0.16.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:da3d7ec75dc83bb18bca888b5edfae0656a26849056c59e05a7728badd17e7af", size = 353377, upload-time = "2026-06-29T13:04:28.731Z" }, + { url = "https://files.pythonhosted.org/packages/0e/4b/f99a8e571287c3dec766bcc18528bbe8e8fb5365522ab5e6d64c93e87066/jiter-0.16.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ee6162b77d49a9939229df666dfa8af3e656b6701b54c4c84966d740e189264e", size = 387746, upload-time = "2026-06-29T13:04:30.319Z" }, + { url = "https://files.pythonhosted.org/packages/75/69/c78a5b3f71040e34eb5917df26fb7ae9a2174cad1ccbf277512507c53a6e/jiter-0.16.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:63ffdbdae7d4499f4cda14eadc12ddcabef0fc0c081191bdc2247489cb698077", size = 517292, upload-time = "2026-06-29T13:04:31.709Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f7/095b38eda4c70d03651c403f29a5590f16d12ddc5d544aac9f9cddf72277/jiter-0.16.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a111256a7193bea0759267b10385e5870949c239ed7b6ddbaaf57573edb38734", size = 549259, upload-time = "2026-06-29T13:04:33.721Z" }, + { url = "https://files.pythonhosted.org/packages/2e/c5/6a0207d90e5f656d95af98ebd0934f382d37674416f215aeda2ff8063e51/jiter-0.16.0-cp314-cp314t-win32.whl", hash = "sha256:de5ba8763e56b793561f43bed197c9ea55776daa5e9a6b91eed68a909bc9cdbf", size = 206523, upload-time = "2026-06-29T13:04:35.068Z" }, + { url = "https://files.pythonhosted.org/packages/a5/31/c757d5f30a8980fd945ce7b98be10be9e4ff59c7c42f5fd86804c2e87db8/jiter-0.16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b8a3f9a6008048fe9def7bf465180564a6e458047d2ce499149cfbe73c3ae9db", size = 200366, upload-time = "2026-06-29T13:04:36.61Z" }, + { url = "https://files.pythonhosted.org/packages/7c/a2/d88de6d313d734a544a7901353ad5db67cb38dcfcd91713b7979dafc345d/jiter-0.16.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0fa25b09b13075c46f5bc174f2690525a925a4fc2f7c82969a2bbabff22386ce", size = 190516, upload-time = "2026-06-29T13:04:38.004Z" }, + { url = "https://files.pythonhosted.org/packages/98/ab/664fd8c4be028b2bedd3d2ff08769c4ede23d0dbc87a77c62384a0515b5d/jiter-0.16.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:f17d61a28b4b3e0e3e2ba98490c70501403b4d196f78732439160e7fd3678127", size = 303106, upload-time = "2026-06-29T13:05:07.118Z" }, + { url = "https://files.pythonhosted.org/packages/1a/07/421f1d5b65493a76e16027b848aba6a7d28073ae75944fa4289cc914d39f/jiter-0.16.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:96e38eea538c8ddf853a35727c7be0741c76c13f04148ac5c116222f50ece3b3", size = 304658, upload-time = "2026-06-29T13:05:08.708Z" }, + { url = "https://files.pythonhosted.org/packages/0a/db/bba1155f01a01c3c37a89425d571da751bbedf5c54247b831a04cb971798/jiter-0.16.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d284fb8d94d5855d60c44fefcab4bf966f1da6fada73992b01f6f0c9bc0c6702", size = 339719, upload-time = "2026-06-29T13:05:10.41Z" }, + { url = "https://files.pythonhosted.org/packages/78/f7/18a1afcd64f35314b68c1f23afcd9994d0bc13e65cc77517afff4e83986d/jiter-0.16.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64d613743df53199b1aa256a7d328340da6d7078aac7705a7db9d7a791e9cfd2", size = 343885, upload-time = "2026-06-29T13:05:12.087Z" }, +] + +[[package]] +name = "numpy" +version = "2.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/fd/89965aa4ac08c74998539fcbf24fa3540f3e15237fbeb6bcf9c908f4aade/numpy-2.5.1.tar.gz", hash = "sha256:a48a113e6afea91f5608793bafa7ef2ad481fefbda87ec5069f483de61cb9fa3", size = 20755553, upload-time = "2026-07-04T17:08:00.933Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/7b/14687aa674250e5e546f616f486b0d56d3631cd5b2415739141ce40bdcea/numpy-2.5.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2c889b56fe48b1018f764b0eec8df59ab654e9148aa91faa12596043500de277", size = 16801574, upload-time = "2026-07-04T17:06:12.423Z" }, + { url = "https://files.pythonhosted.org/packages/e1/19/cc5bb2a3f2913d27d6dbb2c78d25921fabaedc6741d4a5a615a11f3c5bf3/numpy-2.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ab451b59c5643c570974c43aef780703ef1d3b4965d2be07afd530615a9358d1", size = 11772250, upload-time = "2026-07-04T17:06:15.726Z" }, + { url = "https://files.pythonhosted.org/packages/42/77/fdf34a71dd30f54979b18603bee915e0aaf825b07afe79acd60b04b691e2/numpy-2.5.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:78798bd5b9ad744056af8efa90e3b9ddaa53272a0848a483084a1cc0a13b2dc0", size = 5331516, upload-time = "2026-07-04T17:06:17.913Z" }, + { url = "https://files.pythonhosted.org/packages/ce/e2/eb7efa015b4cce41e2517bf182a7fce0d7d5b9d9ed76a29bfa0f4fe4505c/numpy-2.5.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:2ae0ca40bcb22d6ba59c1dfd5446f49940b0f2d821fde133f10dda11f816b84e", size = 6664863, upload-time = "2026-07-04T17:06:20.02Z" }, + { url = "https://files.pythonhosted.org/packages/a9/4b/a2b32dd94ee9ffbeecb28152240042a3949db33b1c834d44090b80e1b3b8/numpy-2.5.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:61ac47e772e6b8ea489e1d2f441a34c5c3ac17327e7ce294cbdf535795ad4e75", size = 15167977, upload-time = "2026-07-04T17:06:21.621Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a9/6e73d68500f80773f65f0654ea932019d6694329a0eb0ed0533de38df376/numpy-2.5.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:59fda5e192b570217ec2580c96f00e9a7e12ef6866a900eb089b62c1a32545ca", size = 16672469, upload-time = "2026-07-04T17:06:24.064Z" }, + { url = "https://files.pythonhosted.org/packages/24/7d/ad3e59015135f5261c95fd4cafeff159c955febd83a99a1d9250c4233815/numpy-2.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f7119ebff1a9829e9f431a4f9d28e703023bb6b9fe7c8f724467dbfc27c94ab3", size = 16527531, upload-time = "2026-07-04T17:06:26.69Z" }, + { url = "https://files.pythonhosted.org/packages/83/d0/a39b2fbcde9cb17a1dac678f254b33a6336298af9df338824c685425d5e8/numpy-2.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e824c2acf8862052246be5a44c15da1777940c60d010dd2aab897824d9c430f9", size = 18431940, upload-time = "2026-07-04T17:06:29.521Z" }, + { url = "https://files.pythonhosted.org/packages/04/12/cff070947791c1ed425ff76413189adbdc2fbe215eba7ce7fa454a03c7f8/numpy-2.5.1-cp312-cp312-win32.whl", hash = "sha256:08d60c810432eb83360958dea0999ac4cfb94531ea8efcbf0b7f277c2068aeb2", size = 6066764, upload-time = "2026-07-04T17:06:32.571Z" }, + { url = "https://files.pythonhosted.org/packages/65/66/53f31807a48a750f9d748da273bc3fcedd12b27ff1f3e373bfec55ef2dc0/numpy-2.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:f7d60026c0bdb1380e83bfa7a0419c4577ee4b9a08880afcb6dadeb74c649fa2", size = 12430966, upload-time = "2026-07-04T17:06:34.926Z" }, + { url = "https://files.pythonhosted.org/packages/2b/2a/d1a88066b1c14186f5d3c0d18c94f17b064511982bab0578d49ee9d43c29/numpy-2.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:17a25e09640602e10bc8de0e6fa2b3fd68eedd84ba6d7842dc8f32f9ab87bd0b", size = 10350488, upload-time = "2026-07-04T17:06:37.785Z" }, + { url = "https://files.pythonhosted.org/packages/eb/07/ec2a3f0c91761581d4b7104a740791800025983f9a4dc4e73f91a99aeac4/numpy-2.5.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0bfebd8695f9863592fe744be833a258120b14a9f39da255e8aa8fade2c0ddd1", size = 16796419, upload-time = "2026-07-04T17:06:40.37Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ab/ddb499fc4f8780354395face5b65c7fd107bcd6e1d667a5f07d046956f6f/numpy-2.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:30b44a6b53a7ae63c54c089a8726e5563ed302716c5b7ccc85afade40b0e7ff6", size = 11765832, upload-time = "2026-07-04T17:06:42.768Z" }, + { url = "https://files.pythonhosted.org/packages/88/b3/3c28c558a09fc72100c646dac6d2fce8e834c471b0edca01a29996706117/numpy-2.5.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:6165343f81b56ef8f514f396989e529b61d9dc709b99421b07e9f3e698e2287d", size = 5325143, upload-time = "2026-07-04T17:06:45.466Z" }, + { url = "https://files.pythonhosted.org/packages/5e/0e/ce19b985bb15c596f4f05954e76cccc77c845083b3b8f938a6c68e523128/numpy-2.5.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4939237038ada79308dda3204ac6462df056b5672b2e25db1149cf873668b3e1", size = 6659749, upload-time = "2026-07-04T17:06:47.288Z" }, + { url = "https://files.pythonhosted.org/packages/2e/20/1ee6614d64332a1bba6411f38e68cb79eec1b2459e20a623777c5c5492a2/numpy-2.5.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c6759f538fb912fc46de0a6b1758ccf7b57bc7c7ebebc23974fdac3de8db0cd", size = 15164716, upload-time = "2026-07-04T17:06:49.494Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a7/2bcd3fdbb87804755c35b729bf8709d62025c5f4cfd7d5b2415997097515/numpy-2.5.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9726558e8db4a5bf7929a70ae50f63abda4daf0efe810e3bfbab95976f75fc1a", size = 16661440, upload-time = "2026-07-04T17:06:52.061Z" }, + { url = "https://files.pythonhosted.org/packages/fc/d7/a41e3310c886fe457d36e670bbf24fae411aca8a7b6ad92a32afd924077c/numpy-2.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3935f3b419b244a02732676fa5317a9193cc596a4c0646db07e5b421229ac9f7", size = 16526305, upload-time = "2026-07-04T17:06:54.605Z" }, + { url = "https://files.pythonhosted.org/packages/53/75/4333a9a707c1edd3a4e1a0c58eca52c0f31e55089fa80db02b5565b24df7/numpy-2.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dc932a65ded7ce9013d120845a2514dcccb1a67bfc8deb8d37633762951904a6", size = 18423008, upload-time = "2026-07-04T17:06:57.54Z" }, + { url = "https://files.pythonhosted.org/packages/ee/90/e314a32b1c11a2ffe818ddad3a57b50b4b6e1b6c487192eb50cdef0415d0/numpy-2.5.1-cp313-cp313-win32.whl", hash = "sha256:4b4ff1608417eb7a59da7b967bbb798cacfe071d2caf526a24281cd562072ed9", size = 6063885, upload-time = "2026-07-04T17:07:00.14Z" }, + { url = "https://files.pythonhosted.org/packages/10/70/800b3fca480af32df9e8ea9f3d4a0c8feb4b32d7f195d174eabbda4829ad/numpy-2.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:6c3fe51bc6a16453d452997053454f309e8e0ed7b42d6b361ce4ac8c32913d74", size = 12425674, upload-time = "2026-07-04T17:07:02.387Z" }, + { url = "https://files.pythonhosted.org/packages/8b/0b/196350c122f50f6ca56846f2d71efd5e0d24b7b2e07355e019b2e2c7a11e/numpy-2.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:f7feb014281029e628ba2d5a007407443b06e418b6fe451d1e2adcbc8eba0107", size = 10350256, upload-time = "2026-07-04T17:07:04.878Z" }, + { url = "https://files.pythonhosted.org/packages/db/f4/731b6085a83faf6ca843394cbd5e217280c214399f7e8b21b9f552af0ae2/numpy-2.5.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7c786fe9a5bbe360022e584c5a34cf6b54265c71bd7ec8ac3d8fec38968071f8", size = 16795063, upload-time = "2026-07-04T17:07:07.374Z" }, + { url = "https://files.pythonhosted.org/packages/bf/64/0e215f2048dd11a55bb989ed41b3585ef57452404e638d703a211a3e4157/numpy-2.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:32985c896d897419ef8da6917872d80b78ad0ea26d85b23245c7366ffde76d75", size = 11776652, upload-time = "2026-07-04T17:07:09.907Z" }, + { url = "https://files.pythonhosted.org/packages/b5/59/2b844c7a6e9deff69b404a66221e1542937734f65d5e6e39411876053862/numpy-2.5.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:efd736408cc97c79b9e6917338dfc8f06013b2274f992e96b1d9a81a71e2a2c2", size = 5335944, upload-time = "2026-07-04T17:07:12.227Z" }, + { url = "https://files.pythonhosted.org/packages/86/51/9bf7cb2cabcebc9e017e4ec7e6322b378317a542c08b4cb68479c1efc716/numpy-2.5.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:ab84dc6b074fa881cae55bea94cc4f68e285181ba7f32497bf7dee6b1496165b", size = 6656266, upload-time = "2026-07-04T17:07:14.368Z" }, + { url = "https://files.pythonhosted.org/packages/83/3e/fb7615b211b82a32f44d5180a6d421b61f84d4fadd578b48ba4ac34e189f/numpy-2.5.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caf3e317d33d60c37986b452613f4ab51246d0691350c03d0cb4a898627f4a95", size = 15179720, upload-time = "2026-07-04T17:07:16.272Z" }, + { url = "https://files.pythonhosted.org/packages/41/5f/0f992cb24560673496c5d68de61913b57166ce530ffda07c1f280e0cc464/numpy-2.5.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:54ad769f17bc2d833b620851989f62054fb9ab93c969d9e1dc3c8e3d56beea21", size = 16664835, upload-time = "2026-07-04T17:07:19.021Z" }, + { url = "https://files.pythonhosted.org/packages/a2/2f/97d6475ee91afe2587797d09446f9d3e475ad4cb681662d824809327b75a/numpy-2.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c12afb53450fa976d4c681c50a7423729a4c51c0465ed9f32b8a9cabbc472373", size = 16539135, upload-time = "2026-07-04T17:07:22.015Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5b/4db81e4ba0be7e2776b1de68c82aa862c7f8ec27e1b4927d4ae075e20678/numpy-2.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e8c11c405efc5ff6816d5983c96cdfa215bab3428961243af3ff59b228490438", size = 18426684, upload-time = "2026-07-04T17:07:24.941Z" }, + { url = "https://files.pythonhosted.org/packages/1f/64/c0ba2d90724d450279a7df8f32057241070250a26a7e2b5337d77347f481/numpy-2.5.1-cp314-cp314-win32.whl", hash = "sha256:f2479a47f8d5932d1718168a681ad6e536a9df484c83cfcf9de365e164537ace", size = 6116103, upload-time = "2026-07-04T17:07:27.622Z" }, + { url = "https://files.pythonhosted.org/packages/c1/1a/837f9ed7405adcd7a40538792eb169eddd8fa5630c16a1ef49dae71a30f4/numpy-2.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:24d0eb82c0541d3415a33425db64ae439dffccd7b4dbcb30e7c35120205c506a", size = 12562177, upload-time = "2026-07-04T17:07:29.887Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/49707938b6dd0a78a9178dd93227dc89e4c11af47f5c798d70366e8d0483/numpy-2.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:5a4c988b38d261deeeaad9954e3deb091ad905c94e8bb6708654ef1d97f286b0", size = 10627739, upload-time = "2026-07-04T17:07:32.568Z" }, + { url = "https://files.pythonhosted.org/packages/a6/c7/bb4b882cfe7f299cbc8b66e42e7dd78cf9d14e40f9469fc5e3db7e15b3bd/numpy-2.5.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a33276be12fa045805f477f22482088b66bb758ffbe89a9d21457de863a32e22", size = 11894709, upload-time = "2026-07-04T17:07:34.941Z" }, + { url = "https://files.pythonhosted.org/packages/40/3f/5af7f4a7f6224aef48017aa82bb6174c7a659d724be0c75017b7e64a55b4/numpy-2.5.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:f089d7b00756190aacf1f5d34bdf38c3c430ac82b4f868f8cede73380460fce7", size = 5453810, upload-time = "2026-07-04T17:07:37.495Z" }, + { url = "https://files.pythonhosted.org/packages/20/c9/3474309bc94d634d3f9c3eddf03250ecb8c22cd948ef16fef69a77cc5d7b/numpy-2.5.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:09e9bfd8d2cf479c7d174804fb3811c53a8e9f20a37444008606b57d6b7a826d", size = 6761189, upload-time = "2026-07-04T17:07:39.563Z" }, + { url = "https://files.pythonhosted.org/packages/90/8a/558ae39fdd55d7e7f7fef9a84a6e964ac6b23edbd2a07e52bb084500507d/numpy-2.5.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e68d8dd1e7eba712948f2053a29ec86917bc70ba1358df869d9f06649ef9cf09", size = 15225039, upload-time = "2026-07-04T17:07:41.682Z" }, + { url = "https://files.pythonhosted.org/packages/63/27/ca7392b2d030277bdf0273e7d23255b3ee57d57a7c170a6f4fb3981e1e5d/numpy-2.5.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:99d5095fa265a0c4152e7bb12759e14381ef5496152f1ce58f44bdf55c44beb4", size = 16701306, upload-time = "2026-07-04T17:07:44.611Z" }, + { url = "https://files.pythonhosted.org/packages/02/42/03d53ae7996c44d4374a8262e9dc41671fd56cbb98f7d47ef85cf5da4c6b/numpy-2.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ab87a91b3cc3382b8956095bd8f95e00cf679bb81554339be1a2ba404a1473c1", size = 16589955, upload-time = "2026-07-04T17:07:47.694Z" }, + { url = "https://files.pythonhosted.org/packages/7b/15/6c1784ae469640e65db111e9a34b3d0f14d91e8a38b9ce34810ced370dbb/numpy-2.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:224ca51130ef7da85bea2191625181cb4f337f9cb64b471f10c1a12aa8b60077", size = 18464252, upload-time = "2026-07-04T17:07:50.684Z" }, + { url = "https://files.pythonhosted.org/packages/94/a8/f98e50356cf167df656c526c2dfeec2d7dde182f2a3da4b458a5938e2776/numpy-2.5.1-cp314-cp314t-win32.whl", hash = "sha256:6eab239876581b2b3c5a242281b6007bbdbcd1c7085d7709bb57c5929b11e6bf", size = 6263298, upload-time = "2026-07-04T17:07:53.445Z" }, + { url = "https://files.pythonhosted.org/packages/72/ac/96ae880cdecad0b3275d9359fcec72667b49a4863c9f12942e43679dda02/numpy-2.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:83ce9c80d5b521b0d77ddcbe5447c218d247929b6cc056ca5351342accfff0af", size = 12748623, upload-time = "2026-07-04T17:07:55.384Z" }, + { url = "https://files.pythonhosted.org/packages/a1/5a/4d2b1601df3602dba7a14f3348ba9bfe94a18adb428e693df6154c293831/numpy-2.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:5a6db61f9aaa57e369905c67d852045d3c4f7126405b29d09b19dec118e9c9cb", size = 10697674, upload-time = "2026-07-04T17:07:58.506Z" }, +] + +[[package]] +name = "openai" +version = "2.49.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "httpx" }, + { name = "jiter" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "tqdm" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d3/9a/275bee8349b766906741223b98c668fcd4e17e1982a87597778743b4156f/openai-2.49.0.tar.gz", hash = "sha256:80f934333b5b83cef2fde9af7151dacaa72e150f43f92b7675f7647ca6157f48", size = 1080973, upload-time = "2026-07-27T22:51:40.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b1/ca/53357e460a1172e831ecbe43dd0c37342b7211a1eb09f4cf21a412adbbdf/openai-2.49.0-py3-none-any.whl", hash = "sha256:b694201eaa42a1ccf2aa125fe29458150108fb22df1abfb55d7188599da81d8c", size = 1648589, upload-time = "2026-07-27T22:51:38Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "portalocker" +version = "3.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pywin32", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/77/65b857a69ed876e1951e88aaba60f5ce6120c33703f7cb61a3c894b8c1b6/portalocker-3.2.0.tar.gz", hash = "sha256:1f3002956a54a8c3730586c5c77bf18fae4149e07eaf1c29fc3faf4d5a3f89ac", size = 95644, upload-time = "2025-06-14T13:20:40.03Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/a6/38c8e2f318bf67d338f4d629e93b0b4b9af331f455f0390ea8ce4a099b26/portalocker-3.2.0-py3-none-any.whl", hash = "sha256:3cdc5f565312224bc570c49337bd21428bba0ef363bbcf58b9ef4a9f11779968", size = 22424, upload-time = "2025-06-14T13:20:38.083Z" }, +] + +[[package]] +name = "protobuf" +version = "7.35.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/da/01/9ef0afd7999eb9badb3a768b4aedd78c86d4c65cfaf1958ab276199e76b4/protobuf-7.35.1.tar.gz", hash = "sha256:ce115a26fe0c39a2c29973d914d327e516a6455464489fe3cd1e51a1b354f81a", size = 458717, upload-time = "2026-06-11T21:55:40.257Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/03/8aeeb7458d22546bf64b5250ca1daeb5ff757d900e8e4a7476c6f0db843e/protobuf-7.35.1-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:24f857477359a85c0c235261b8ba905fd51b2562f4a64ca1df5473f29850cbf6", size = 433226, upload-time = "2026-06-11T21:55:31.719Z" }, + { url = "https://files.pythonhosted.org/packages/37/4b/dfb89eb0e652a1ff073c39a59fb5e3a83cfe9b57a2c83fa6d78270101767/protobuf-7.35.1-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:11d6b0ec246892d85215b0a13ca6e0233cf5284b68f0ac02646427f4ff88a799", size = 328847, upload-time = "2026-06-11T21:55:34.035Z" }, + { url = "https://files.pythonhosted.org/packages/0f/58/dc12f2cd484951524af6e3382c785869b9b3fb5e52ee95ae23add53ee8f9/protobuf-7.35.1-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:b73f9489a4b8b1c9cb1f8ed951c736392592edb24b9d6819f36d2e10b171d5b4", size = 344030, upload-time = "2026-06-11T21:55:34.941Z" }, + { url = "https://files.pythonhosted.org/packages/e4/be/5b3cfe508bfab6761414ff944e3366eb13be4fd71efcd69450f89ba39f43/protobuf-7.35.1-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:74758715c53d7158fb76caf4f0cfdacc5329a4b1bb994f865d6cf302d413a1c4", size = 327130, upload-time = "2026-06-11T21:55:35.921Z" }, + { url = "https://files.pythonhosted.org/packages/d8/bc/6d6c7ba8709c85f8f2c390b2b118d6fb08a783676a572271851bf45a7d22/protobuf-7.35.1-cp310-abi3-win32.whl", hash = "sha256:353652e4efd0bca5b5fc2656abf8307ef351f0cf938c9eba09f0e09c20a25c30", size = 428945, upload-time = "2026-06-11T21:55:37.034Z" }, + { url = "https://files.pythonhosted.org/packages/0a/19/8d0cb6f20a1ef7b18f1c8986ad5783f22f84cce39c6ce9a6e645ea55192e/protobuf-7.35.1-cp310-abi3-win_amd64.whl", hash = "sha256:230a75ddfc2de4806e56696ce9640c1cdfdb6543b7cfce98d42a4c0a0e7bdb87", size = 439996, upload-time = "2026-06-11T21:55:38.123Z" }, + { url = "https://files.pythonhosted.org/packages/19/c7/5f7c636ec43e0c545e28d1f1db71990108306f7bdcb89f069ba97e428e7f/protobuf-7.35.1-py3-none-any.whl", hash = "sha256:4bc97768d8fe4ad6743c8a19403e314511ed9f6d13205b687e52421c023ac1b9", size = 171659, upload-time = "2026-06-11T21:55:39.155Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5c/b5/8f48e906c3e0205276e8bd8cb7512217a87b2685304d64be27cad5b3019f/pydantic_settings-2.14.2.tar.gz", hash = "sha256:c19dd64b19097f1de80184f0cc7b0272a13ae6e170cbf240a3e27e381ed14a5f", size = 237700, upload-time = "2026-06-19T13:44:56.324Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/c1/6e422f34e569cf8e18df68d1939c81c099d2b61e4f7d9621c8a77560799c/pydantic_settings-2.14.2-py3-none-any.whl", hash = "sha256:a20c97b37910b6550d5ea50fbcc2d4187defe58cd57070b73863d069419c9440", size = 61715, upload-time = "2026-06-19T13:44:55.02Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "pywin32" +version = "312" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/ff/32aa7d2ed0ab12b323aaa64f9b75e6ad4f8fd09f9ccfc28c79414d46838d/pywin32-312-cp312-cp312-win32.whl", hash = "sha256:dab4f65ac9c4e48400a2a0530c46c3c579cd5905ecd11b80692373915269208b", size = 6371877, upload-time = "2026-06-04T07:49:28.836Z" }, + { url = "https://files.pythonhosted.org/packages/03/d9/77040d3b43df3f3be32ea289433d660d2727f5ba327bc73be835127d9d60/pywin32-312-cp312-cp312-win_amd64.whl", hash = "sha256:b457f6d628a47e8a7346ce22acb7e1a46a4a78b52e1d17e1af56871bd19a93bc", size = 6914841, upload-time = "2026-06-04T07:49:31.85Z" }, + { url = "https://files.pythonhosted.org/packages/e3/cc/7b1ec671775756020a0ee7f4feeaf3c568f0ab86bd3900088cf986937a92/pywin32-312-cp312-cp312-win_arm64.whl", hash = "sha256:6017c58e12f6809fbb0555b75df144c2922a9ffd18e4b9b5afa863b6c1a9d950", size = 6727901, upload-time = "2026-06-04T07:49:34.244Z" }, + { url = "https://files.pythonhosted.org/packages/2d/41/12fbfd7f36ed2146d8bc9de96c2741296bf0d490b98508496cff322e274c/pywin32-312-cp313-cp313-win32.whl", hash = "sha256:7a27df850933d16a8eabfbaeb73d52b273e2da667f80d70b01a89d1f6828d02c", size = 6370184, upload-time = "2026-06-04T07:49:36.253Z" }, + { url = "https://files.pythonhosted.org/packages/ba/db/36a78e3403099d31d9746d13fdcde5accc43c1155f375a34d15983a479a7/pywin32-312-cp313-cp313-win_amd64.whl", hash = "sha256:c53e878d15a1c44788082bfe712a905433473aa38f86375b7cf8b45e3acbaaf9", size = 6914298, upload-time = "2026-06-04T07:49:38.876Z" }, + { url = "https://files.pythonhosted.org/packages/84/37/c1697194092b76de9ed47ca124323f02c57ffc8a45c06f88a3d5acaf01eb/pywin32-312-cp313-cp313-win_arm64.whl", hash = "sha256:59aba5d5940842075343a5ddc6b11f1cdf0d1567fe745290359dfbcc7c2eb831", size = 6727640, upload-time = "2026-06-04T07:49:41.083Z" }, + { url = "https://files.pythonhosted.org/packages/fc/2b/1f3cded5822fd49c02f40544cbb5f58c7cfd6b1694869fd476cb6170ee97/pywin32-312-cp314-cp314-win32.whl", hash = "sha256:a77a90fbb6881238d2ca9c6fd797b25817f3768fe78d214a90137ff055a75f5b", size = 6468928, upload-time = "2026-06-04T07:49:43.188Z" }, + { url = "https://files.pythonhosted.org/packages/21/82/3bf86d2e2808902013132e1ce905a7da0da53790f3836c64bf44d55e24f3/pywin32-312-cp314-cp314-win_amd64.whl", hash = "sha256:a4dd3a848290ef724347b19f301045831d8e802fa4464f491b98b1e0a081432e", size = 7024157, upload-time = "2026-06-04T07:49:45.34Z" }, + { url = "https://files.pythonhosted.org/packages/a4/0e/73f6d6800b4f27655abd9e9f6aaeaefcddb2b946e4674efa2bab184a7f7b/pywin32-312-cp314-cp314-win_arm64.whl", hash = "sha256:9fce94568364e0155e6dfb781ac5d95903be8baf28670632beab1b523f300daa", size = 6839598, upload-time = "2026-06-04T07:49:47.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/61/caa39686032d2ebdd04ff0ab5cbe163126c0066d98e00c9018646e42393b/pywin32-312-cp315-cp315-win32.whl", hash = "sha256:5c1fbe4a937a73ae9297384a3da38518cbc694c68ad8a809b2e19acd350f03ed", size = 6471159, upload-time = "2026-06-04T07:49:50.035Z" }, + { url = "https://files.pythonhosted.org/packages/0f/cd/7e1de64a4a6f69c04214169657ccab0d93a670ea50e35eb8f489d7378249/pywin32-312-cp315-cp315-win_amd64.whl", hash = "sha256:c2f03a0f73f804a13c2735b99392b0cd426bb4f2c4d0178e5ac966a0f21618d5", size = 7025293, upload-time = "2026-06-04T07:49:54.857Z" }, + { url = "https://files.pythonhosted.org/packages/23/ed/4532e9388e65fa16b46776ef47ad631a64eda1631884488af707666350ed/pywin32-312-cp315-cp315-win_arm64.whl", hash = "sha256:a8597d28f267b39074aef51fa593530082b39cbe5a074226096857b1fed2dfb9", size = 6840337, upload-time = "2026-06-04T07:49:57.531Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "qdrant-client" +version = "1.18.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "grpcio" }, + { name = "httpx", extra = ["http2"] }, + { name = "numpy" }, + { name = "portalocker" }, + { name = "protobuf" }, + { name = "pydantic" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/65/45/5b1bdd15a3c7730eefb9c113600829e20d689b82b5a23f9e07d107094004/qdrant_client-1.18.0.tar.gz", hash = "sha256:52e8ece1a7d40519801bf0b70713bfa0f6b7ae28c7275bbe0b0286fbed7f6db4", size = 352580, upload-time = "2026-05-11T14:12:38.702Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d6/10/c437bd2ac41ef30d3019063e6ce537dc111e9214473b337ee88f7fa6359a/qdrant_client-1.18.0-py3-none-any.whl", hash = "sha256:093aa8cf8a420ee3ad2a68b007e1378d7992b2600e0b53c193fc172674f659cd", size = 398126, upload-time = "2026-05-11T14:12:36.998Z" }, +] + +[[package]] +name = "qmdsearch" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "fastapi" }, + { name = "httpx" }, + { name = "openai" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "qdrant-client" }, + { name = "redis" }, + { name = "structlog" }, + { name = "uvicorn", extra = ["standard"] }, +] + +[package.optional-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "fastapi", specifier = ">=0.115.0" }, + { name = "httpx", specifier = ">=0.28.0" }, + { name = "openai", specifier = ">=1.58.0" }, + { name = "pydantic", specifier = ">=2.10.0" }, + { name = "pydantic-settings", specifier = ">=2.7.0" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.3.0" }, + { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.24.0" }, + { name = "qdrant-client", specifier = ">=1.12.0" }, + { name = "redis", specifier = ">=5.2.0" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.8.0" }, + { name = "structlog", specifier = ">=24.4.0" }, + { name = "uvicorn", extras = ["standard"], specifier = ">=0.34.0" }, +] +provides-extras = ["dev"] + +[[package]] +name = "redis" +version = "8.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/c3/928b290c2c0ca99ab96eea5b4ff8f30be8112b075301a7d3ba214a3c8c12/redis-8.0.1.tar.gz", hash = "sha256:afc5a7a2f5a084f5b1880dec548dd45be17db7e43c82a30d84f952aefb05cfb0", size = 5114170, upload-time = "2026-06-23T14:52:37.728Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/0a/c2345ebf1ebe70840ce3f6c6ee612f8fa749cfbd1b03069c53bf0c62aaad/redis-8.0.1-py3-none-any.whl", hash = "sha256:47daa35a058c23468d6437f17a8c76882cb316b838ef763036af99b96cedd743", size = 502406, upload-time = "2026-06-23T14:52:36.137Z" }, +] + +[[package]] +name = "ruff" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4d/94/1e5e4967626faf12fa56999cd6222dff6992ceb086ad7945756baf70c7a7/ruff-0.16.0.tar.gz", hash = "sha256:e460aafd5495ec89efaa6ced2e4a9a581116451e1c88b9d37ef497e0f8e93982", size = 4790557, upload-time = "2026-07-23T19:11:30.981Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/81/1c8818fee7ce1a04cd7d1b3172e0a8f8e4f1dc4feb7fc390e16daa8af323/ruff-0.16.0-py3-none-linux_armv6l.whl", hash = "sha256:e5115729eb08c585e5121978ba5d5b60caeae394ce21b9fb5e6cd33a1c6c9b1e", size = 10754633, upload-time = "2026-07-23T19:10:46.415Z" }, + { url = "https://files.pythonhosted.org/packages/23/df/beaf59c09d68db84304d555f188b276a77132a5d5b0b67a5c762aa143628/ruff-0.16.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3c954b1d580bfa035b41654f7858cc7e71d5fc3ac5b723dd62bd9133830ed522", size = 10969164, upload-time = "2026-07-23T19:10:50.271Z" }, + { url = "https://files.pythonhosted.org/packages/42/ce/741cd197496a1abbf51352710fd15ed995d2a2be87189c1da26a450d6e83/ruff-0.16.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e01c21d10eb1b29f47b7454e1f4056db9a3f0260c646aa88457c610291db9f81", size = 10488846, upload-time = "2026-07-23T19:10:52.639Z" }, + { url = "https://files.pythonhosted.org/packages/52/2a/a2db8e88cade358f5cdcb05674a917751074109315d014eb6352d9a893f7/ruff-0.16.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e364e5ed22ed8dc05082fd78e35308618260907ac2d3c1d637b2e682415b6c9", size = 10889729, upload-time = "2026-07-23T19:10:54.89Z" }, + { url = "https://files.pythonhosted.org/packages/42/65/62a771694ebd63029dc953e27dbad40e1588bd4860ff9fe881018fddaa49/ruff-0.16.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d327b8fc113a1d4421a04f3839d3752057c8dd1ee320223a6f3f52d04ada462a", size = 10568275, upload-time = "2026-07-23T19:10:56.993Z" }, + { url = "https://files.pythonhosted.org/packages/3f/e2/ced249fe8af5f086c5c58cc21cc3356d50f32f7401c5df87050c999620a7/ruff-0.16.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a9b50c55e263103586b3dcf5f73d479eb8cb5fdb6098fec59a62891dab653717", size = 11385112, upload-time = "2026-07-23T19:10:59.615Z" }, + { url = "https://files.pythonhosted.org/packages/87/0b/05154977a8fd69eeb6c103271f55403bfd8711f5c0f8ed07489d95a504e7/ruff-0.16.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0ff4a79ce3ec0172f3241943835de1c4cb4e2dcd07f0f8c2d02603dbbbee4b17", size = 12207008, upload-time = "2026-07-23T19:11:02.154Z" }, + { url = "https://files.pythonhosted.org/packages/fb/29/98225831a3a1eab0e02f4acc6ca6559a98611dcc68b6965ff4b7234627c1/ruff-0.16.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e95c448fca1fb2a18372a9440926c5a6ee789639bb975c72e7ae6d0b04218ab4", size = 11650842, upload-time = "2026-07-23T19:11:04.557Z" }, + { url = "https://files.pythonhosted.org/packages/91/66/6bd3cf90500653d55dc0ffc8507aa8300bd49d0214b2e8cb4d3fef2943ba/ruff-0.16.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f11a8d11010301d0a398a2fdef67691feca7294da6aef55e2150e8fa2cd520b", size = 11400718, upload-time = "2026-07-23T19:11:09.233Z" }, + { url = "https://files.pythonhosted.org/packages/8e/a2/a54eb4eae05d66364050a5d3b8a9c5ef88196531b3cbe7109d873f87f819/ruff-0.16.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:48044c678e9cb8698246c99b14aaccfa6601dea7379eb48a6f8f73f7a6d86cd0", size = 11426177, upload-time = "2026-07-23T19:11:11.994Z" }, + { url = "https://files.pythonhosted.org/packages/1a/be/16e3eea4b2a478a496919f5e36f17c4559e54620bd3bbac5d6affa068006/ruff-0.16.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:7aa0959bad8eb8bef50340154fc9b58678dae31fa4293afa38b44b6e552c0213", size = 10856126, upload-time = "2026-07-23T19:11:14.221Z" }, + { url = "https://files.pythonhosted.org/packages/a2/84/252eb8b868a16eec7257c14f504f77537e734b2d69c762e639e588e304a3/ruff-0.16.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:28ea2b7df8ebf7f9da6b7d47b230ab48f387c0a29be3b474c4d0740e197bb9af", size = 10571208, upload-time = "2026-07-23T19:11:16.378Z" }, + { url = "https://files.pythonhosted.org/packages/21/09/817a482f542f7570cbb4554b26e896610c7114f539b1d9e2d2145bf6bef6/ruff-0.16.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:33a3dfac8c35f81498dea9181bccc2f4c4bc8f1521a1dd9406e77643e0f0fb09", size = 11063329, upload-time = "2026-07-23T19:11:19.173Z" }, + { url = "https://files.pythonhosted.org/packages/2e/23/9403c180ca1cb9b1f7335f5c3e5305c09d49ea5b345196682a36028bde4a/ruff-0.16.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a5237a0bda500d30d81b8e07a6973a5cbc772864cbf746ae2f4e8a2e01c9f4ed", size = 11489751, upload-time = "2026-07-23T19:11:21.74Z" }, + { url = "https://files.pythonhosted.org/packages/b2/1d/1b2ef7bcde851c78d7f17f1cca13fd6dc695fc4b3d6197941e72cae5b132/ruff-0.16.0-py3-none-win32.whl", hash = "sha256:7fab76fa065c873f41ff744347c6e77bcc3dfec4bcc754dc26b63d23c0f7f5fb", size = 10785885, upload-time = "2026-07-23T19:11:23.947Z" }, + { url = "https://files.pythonhosted.org/packages/b2/a3/d5e4ef7a56be3f928ffb90b94c25ba7d3cb9c7fe0736aeaaedf361770712/ruff-0.16.0-py3-none-win_amd64.whl", hash = "sha256:429c117f022bf481fabd9d551e7a3952b24c65e6ef44337ea09d90bebef14472", size = 11923141, upload-time = "2026-07-23T19:11:26.409Z" }, + { url = "https://files.pythonhosted.org/packages/cb/9a/8415f2657cbe200f41a4531ccededf135505a92d4a012229121f885b26f9/ruff-0.16.0-py3-none-win_arm64.whl", hash = "sha256:14296fedcd2705c77ab8235439278bbb38f285cf7da5528b00b3e330c3d4872d", size = 11273407, upload-time = "2026-07-23T19:11:28.705Z" }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + +[[package]] +name = "starlette" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" }, +] + +[[package]] +name = "structlog" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/89/b4a0bcfdf4f71a3dea31379f095929613d7e4528a0996bca6aa964cd0dca/structlog-26.1.0.tar.gz", hash = "sha256:f63a716cbd1b1291cf7661de7794b455acfa4c43c5bcf1630e6ad5ddc1adb3b7", size = 1459881, upload-time = "2026-06-06T07:33:39.348Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/18/489c97b834dfff9cf2fc2507cede4bcd4b11e67f84bc462acd1992496f86/structlog-26.1.0-py3-none-any.whl", hash = "sha256:e081a26d6c373e6d201eca24eede26d8ffab07f88f477822e679183428d3d91e", size = 73764, upload-time = "2026-06-06T07:33:38.046Z" }, +] + +[[package]] +name = "tqdm" +version = "4.70.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/21/3b/6c24bec5be5e743ffd99576daa5cc077722fc7d5bbc00bd133fa0c698dc6/tqdm-4.70.0.tar.gz", hash = "sha256:55b0b0dbd97462d06ebee91e4dac24ed4d4702be82b24f07e6c1d27e08cea220", size = 795438, upload-time = "2026-07-27T11:33:15.271Z" } +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 = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.51.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a2/65/b7c6c443ccc58678c91e1e973bbe2a878591538655d6e1d47f24ba1c51f3/uvicorn-0.51.0.tar.gz", hash = "sha256:f6f4b69b657c312f516dd2d268ab9ae6f254b11e4bac504f37b2ab58b24dd0b0", size = 94412, upload-time = "2026-07-08T10:59:05.962Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/ec/dbb7e5a6b91f86bfb9eb7d2988a2730907b6a729875b949c7f022e8b88fa/uvicorn-0.51.0-py3-none-any.whl", hash = "sha256:5d38af6cd620f2ae3849fb44fd4879e0890aa1febe8d47eb355fb45d93fe6a5b", size = 73219, upload-time = "2026-07-08T10:59:04.44Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "httptools" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, + { name = "watchfiles" }, + { name = "websockets" }, +] + +[[package]] +name = "uvloop" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" }, + { url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" }, + { url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" }, + { url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" }, + { url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" }, + { url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" }, + { url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" }, + { url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" }, + { url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" }, + { url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" }, + { url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" }, + { url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" }, + { url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" }, + { url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" }, + { url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" }, + { url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" }, + { url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" }, + { url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" }, + { url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" }, + { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" }, +] + +[[package]] +name = "watchfiles" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/41/5e1a4bb12aac5f1493fa1bdc11154eca3b258ca4eba65d39c473fe19d8e9/watchfiles-1.2.0.tar.gz", hash = "sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838", size = 108252, upload-time = "2026-05-18T04:32:04.251Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/2f/e42c992d2afda3108ea1c02acecc991b9f31d05c14adc2a7cee9ee211fc4/watchfiles-1.2.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:bc13eb17538be00c874699dc0abe4ee2bc8d50bb1166a6b9e175ef3fd7eb8f26", size = 400115, upload-time = "2026-05-18T04:32:02.06Z" }, + { url = "https://files.pythonhosted.org/packages/5f/8f/6af2ea19065c91d8b0ea3516fdfc8c0d349f407e8e9fbf4e5a17360de8ad/watchfiles-1.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2d95ddc1eb6914154253d239089900813f6a767e174b8e6a50e7fdacb7e4236c", size = 393659, upload-time = "2026-05-18T04:30:50.951Z" }, + { url = "https://files.pythonhosted.org/packages/13/01/b32a967c56fb3e3e5be3db52c3d3b87fa4513aa367d8ed1ad96d42952e5f/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f70d8b291ef6e88d19b1f297a6905ddb978888d9272b0d05e6f53309856bcfc", size = 453207, upload-time = "2026-05-18T04:31:04.231Z" }, + { url = "https://files.pythonhosted.org/packages/04/98/97557a812180338cb1abd32e1cffcc4588f59b5f23e0cb006b2ba95ba64a/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56d8641cf834c2836922899105bd3ce3d0dfc69291d52edf0b4d0436829b34c0", size = 459273, upload-time = "2026-05-18T04:31:50.377Z" }, + { url = "https://files.pythonhosted.org/packages/e8/a8/b4b08dcb7653b8087c6586f7ce649505900e866bbcfe40dc9587af02e686/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2581a94056e55d7d0a31a823ea92bf73749c489ca2285bfdc0fbe6b2bb49d50c", size = 489927, upload-time = "2026-05-18T04:31:42.485Z" }, + { url = "https://files.pythonhosted.org/packages/50/94/3dceea03545d2e5ddfd839f0ddd5e1cecbf1697b5a428d5ba11cef6af95d/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:41bc1199f7523b3f82843c88cbb979180c949caef0342cf90968f178e5d49b01", size = 570476, upload-time = "2026-05-18T04:31:03.071Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f2/d39a5450c3532092b91f81d274360e613c2371bc874a89c7a1a3c5e8d138/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7571e4464cb6e434958f867f7f730b8ab0b75e3f8e5eac0499168486ab3c33a8", size = 465650, upload-time = "2026-05-18T04:30:12.701Z" }, + { url = "https://files.pythonhosted.org/packages/22/24/ed72f68cbc1333ca9b9f2200aa048bb6658ae41709bc1caad4310f4bdffd/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e53a384f76b631c3ae5334ce6a52f0baa3a911eb94a4eac7f160079868b716d5", size = 456398, upload-time = "2026-05-18T04:30:13.784Z" }, + { url = "https://files.pythonhosted.org/packages/0d/64/982ef4a4e5bab5b6e5b6becc8cd5e732f6130a78b855f0abec6439a9a135/watchfiles-1.2.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:d20029a60a71a052a24c4db7673bc4de39ab89adbaccbfb5d67987c5d73f424d", size = 465140, upload-time = "2026-05-18T04:31:52.111Z" }, + { url = "https://files.pythonhosted.org/packages/a0/0c/95282abf4ed680b6096010bcfc30c5fa7a041fc5aa5a2ad17a2cc6c75bba/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:2cb93af48550faf1cea04c303107c8b75833de7013e57ce27d3b8d21d8d0f58c", size = 630259, upload-time = "2026-05-18T04:31:25.676Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/607c1de1530c4bdcf2cf1d1ecc2505ddba5d96bd43ba9f2b0e79876f850f/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2995c176de7692b86a2e4c58d9ec718f753150a979cb4a754e2b4ffa38e70906", size = 659859, upload-time = "2026-05-18T04:30:24.333Z" }, + { url = "https://files.pythonhosted.org/packages/fa/08/d9e2e0f9e8e6791d33aefc694ad7eefa7f901f63caff84a81ded38692f9c/watchfiles-1.2.0-cp312-cp312-win32.whl", hash = "sha256:7a2cffd17d27d2ecbb310c2b1d8174f222a5495b1a721894afa88ec11e25b898", size = 275480, upload-time = "2026-05-18T04:30:31.307Z" }, + { url = "https://files.pythonhosted.org/packages/1c/e6/9d42569c0102645cc8cea5d8c7d8a1e9d4ada2cb7f05f75e554b8aa2202a/watchfiles-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:f155b3a1b2a5fc89cdc70d47ee5d54e3b75e88efa34982028a35daef9ba00379", size = 288718, upload-time = "2026-05-18T04:32:10.745Z" }, + { url = "https://files.pythonhosted.org/packages/0a/26/88e0dc6ee3898169d7fa22bb6a69cabf2502d2ee25cb8c876d1262d204f8/watchfiles-1.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:8fa585ede612ee9f9e91b18bebf9ba11b9ae29a4e3a0d0cf6fca3e382133f0d5", size = 281026, upload-time = "2026-05-18T04:30:22.23Z" }, + { url = "https://files.pythonhosted.org/packages/d1/4d/70a7feced9f87e2ff26dba42667290f41694fc64646c67261fbb8cab5d5c/watchfiles-1.2.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:01ea8d66f0693b9b60a6541c8d10263091ca9a9060d242f3c1f3143f9aad2c98", size = 399730, upload-time = "2026-05-18T04:31:38.162Z" }, + { url = "https://files.pythonhosted.org/packages/31/3a/0da302f2307aee316922806ebd5726c542cbd787c938271cf14a074c7daf/watchfiles-1.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7ba0480b9a74af058f43b337e937a451e109295c420916d68ad24e3dc02f5e44", size = 392842, upload-time = "2026-05-18T04:30:27.051Z" }, + { url = "https://files.pythonhosted.org/packages/db/ef/d5bdb705c224dbc256aa0c1ec47bf4e61ec52558f2afb44a71a1fe4d7015/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f34e26a19f91f710c08e0183429f0d1d15df734e6bc78c31e77b9ea9c433658", size = 452989, upload-time = "2026-05-18T04:31:11.945Z" }, + { url = "https://files.pythonhosted.org/packages/71/29/5495f2c1661949ef7a35e4d71111d129cfe7606414a26887a919d0a55406/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b4e77f6a55f858504069abd35d336a637555c09bca453dde1ee1e5ada8a6a1fb", size = 458978, upload-time = "2026-05-18T04:30:52.606Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8c/7f9c07c433811c2fffd93e13fdfb7135de9aab5f2ae41be08960fa0047dc/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0cb4d80e212f116474a545c21c912b445f16bb0cef9e6a73a498164223e14e2f", size = 490248, upload-time = "2026-05-18T04:31:36.003Z" }, + { url = "https://files.pythonhosted.org/packages/3c/11/d93632febc52fbc21be90231bb7c17fd5387f46c9076fd40a5f9c2ae6910/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b974946a10af379d425e2eef5b62f5c6ebeaccf91d45eaad6f5b27ecd4f91aa0", size = 571847, upload-time = "2026-05-18T04:31:10.862Z" }, + { url = "https://files.pythonhosted.org/packages/55/b4/383173e73aabb07ad1d9c7aa859d95437ac46a6d6a1e11005facda0c9d19/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86bc13c25a8d1fcd70b51d0ce7c9b65e90de5666fcbfd3e34957cc73ee19aeb5", size = 465974, upload-time = "2026-05-18T04:30:17.006Z" }, + { url = "https://files.pythonhosted.org/packages/a7/6c/89b1a230a78f57c52dd8893adb1f92f94411721b6ec12596c56d98c74356/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca148d73dea36c9763aaa351e4d7a51780ec1584217c45276f4fe8239c768b71", size = 454782, upload-time = "2026-05-18T04:30:35.656Z" }, + { url = "https://files.pythonhosted.org/packages/24/62/1732118367cfff0a9fce3bf62ff4bfded09ef5df21d9d446b858b3f70a96/watchfiles-1.2.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:c525543d91961c6955b2636b308569e84a1d1c5f5f2932041ab9ef46422f43e3", size = 465182, upload-time = "2026-05-18T04:30:20.846Z" }, + { url = "https://files.pythonhosted.org/packages/28/96/716f7e5f51339bf22963f3345f9f27d7f3b30e2eadc597e257c881dd3c53/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:a204794696ffb8f9b10fba6f7cb5216d42f3b2b71860ccac6b6e42f5f10973b0", size = 629841, upload-time = "2026-05-18T04:31:05.397Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fe/c40783950fd771ccf66ab3ec2722d188a9af1c7f96c6e811f36e40c6e03f/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:10d86db20695afe7997ac9e1717637d6714a8d0220458c33f3d2061f54cec427", size = 658028, upload-time = "2026-05-18T04:31:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/71/72/4508db1856d1d87fcbb3b63f4839bab1b5682cb0e8d224d122263c09654a/watchfiles-1.2.0-cp313-cp313-win32.whl", hash = "sha256:eb283ee99e21ad6443c8cdb06ac5b34b1308c329cbdf03fa02b445363714c799", size = 275183, upload-time = "2026-05-18T04:30:59.57Z" }, + { url = "https://files.pythonhosted.org/packages/f9/36/14b76ca57652e5cc5fd1c11f32a261292c08a0d19a00351013c2549cbfb2/watchfiles-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:a0f27f01bee51861392bb6b7c4fdb290b27d1eb194e9e28788d68102a0e898d9", size = 288059, upload-time = "2026-05-18T04:32:07.937Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8d/0a85e395398d8d20fadfe5c5d32c726eee17a519e78fb356f2cf7531bffe/watchfiles-1.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:3651aa7058595e9cfb75d35dd5ada2bf9f48a5b8a0f3562821d3e210c507e077", size = 280186, upload-time = "2026-05-18T04:31:54.484Z" }, + { url = "https://files.pythonhosted.org/packages/37/68/36db056f1fdcc5f07302f56e631774d6835bcd6fa3ace402304621d5f9e5/watchfiles-1.2.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:faea288b6f0ab1902ef08f4ca6de005dccf856c4e0c4f21b8c5fce02d90a1b08", size = 399031, upload-time = "2026-05-18T04:30:44.576Z" }, + { url = "https://files.pythonhosted.org/packages/c1/64/01a9d6f66a82a5c101ce939274106cc72759d62427e153f01edd2b9f87c2/watchfiles-1.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:01859b11fd9fbca670f4d5da00fbac282cfea9bd67a2125d8b2833a3b5617ea9", size = 391205, upload-time = "2026-05-18T04:30:25.413Z" }, + { url = "https://files.pythonhosted.org/packages/84/2c/0a44fe058cb4bb7b8ede6b6670698bbb7c0400740e378d00022189b7b31d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fff610d7bb2256a317bb1e96f0d7862c7aa8076733ee5df0fd41bbe76a24a4f4", size = 451892, upload-time = "2026-05-18T04:32:14.005Z" }, + { url = "https://files.pythonhosted.org/packages/67/a1/351e0d56cd35e6488b5c8b4fb11a809a5bc923e8fe8fed9faf8920be0c89/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b141a4891c995a039cd89e9a49e62df1dc8a559a5d1a6e4c7106d16c12777a55", size = 458867, upload-time = "2026-05-18T04:31:22.279Z" }, + { url = "https://files.pythonhosted.org/packages/d5/7d/9d09605187f1b838998624049fcf8bf47b73c1a3b76901fcac1782f62277/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f22943b7770483f6ea0721c6b11d022947a98eb0acae14694de034f4d0d38925", size = 490217, upload-time = "2026-05-18T04:31:43.657Z" }, + { url = "https://files.pythonhosted.org/packages/60/5d/a17a16eccb182f04188cd308ec24b1a71a9b5c4e7098269cf35d9fa56d02/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1bc6195825b7dcd217968bb1f801a60fd4c16e8eeab5bedc7fe917d7d5995ab4", size = 571458, upload-time = "2026-05-18T04:32:11.875Z" }, + { url = "https://files.pythonhosted.org/packages/d3/3d/4dd457062083ab1938e5dfd45032eb425cee2ac817287ca8ff4356183e5d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4a4b147f5dca2a5d325a06a832fb43f345751adfbc63204aec30e0d9ca965a2", size = 464707, upload-time = "2026-05-18T04:30:43.492Z" }, + { url = "https://files.pythonhosted.org/packages/c6/71/ea8c57b128f5383de74d0c7d2d9c57ad7c9a65a930c451bd25d524b295b7/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4543579a9bdb0c9560039b4ffddbdb39545707659fbc430ce4c10f3f68d557f9", size = 454663, upload-time = "2026-05-18T04:30:16.061Z" }, + { url = "https://files.pythonhosted.org/packages/53/fd/2e812bf938406d7db351f0703ddd3fc6c061cf30d96153a77bc79a943a44/watchfiles-1.2.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:20aa0e708b920bde876a4aa82dc7dd6ebea228a63a67cda6632c2fc87b787efa", size = 463537, upload-time = "2026-05-18T04:31:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/86/56/d17a7f1dd1bc3035f1072694a551301272f1739c2d8e319c927cb9e29b38/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:d413349d565dab74297f2a63e84a097936be69bf8f3b3801f27f380e32040f44", size = 629194, upload-time = "2026-05-18T04:31:14.141Z" }, + { url = "https://files.pythonhosted.org/packages/be/06/f1ff66bf5cae50aa4062779a0ecd0bbaf15e466195719074078947d9a17d/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f28b2725eb8cce327b9b3ab02415c853011dc55c95832fe90de6bc56f5315f72", size = 656194, upload-time = "2026-05-18T04:31:47.14Z" }, + { url = "https://files.pythonhosted.org/packages/e7/54/a9c7ea9a82a4ac65e7004c0a03920b5cdd2f9c3b678757d9cd425aa51d53/watchfiles-1.2.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b8c8358484d5fa12ef34f05b7f4168eaf1932f408725ff6d023c33ec17bd79d4", size = 400205, upload-time = "2026-05-18T04:32:05.153Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5d/c9ab3534374a4a67450696905d6ef16a04405448b8dc52bd752ae50423d4/watchfiles-1.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f04b092229ad2c50126dd3c922c8822e51e605993764a33058d4a791ab42281", size = 392508, upload-time = "2026-05-18T04:30:54.849Z" }, + { url = "https://files.pythonhosted.org/packages/26/ca/1ad30103535cf0cecd7b993e8d50edc5351b1820e38f2d22e3df58962feb/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a7ce236284f002a156f70add88efe5c70879cccbb658be0822c54b1306fc09d", size = 452448, upload-time = "2026-05-18T04:30:53.727Z" }, + { url = "https://files.pythonhosted.org/packages/37/a1/ceee2cdf2afbd715fa07758d39c9859513eae411b23196f7fd039e5feedd/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b9909cc2b48468b575eefa944919e1fe8a36c5849d5c7c168f80a8c1db69398e", size = 459605, upload-time = "2026-05-18T04:30:23.312Z" }, + { url = "https://files.pythonhosted.org/packages/e8/f6/421e30fd1cb3907a84ed92ab3f1983e37ba2dca015e9a894a048418417a2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a37faaed405c67e28e6be45a1fa4f206ef5a2860f27c237db9fa30704c38242", size = 490757, upload-time = "2026-05-18T04:30:47.358Z" }, + { url = "https://files.pythonhosted.org/packages/41/b0/55ed1b97ed08be7bba6f9a541cac15f2a858e1d74d2b07b6da70a82aab00/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9649193aa27bd9ff2e80ff29bfaa93085496c7a3a377592823cc58b77ee88add", size = 568672, upload-time = "2026-05-18T04:30:38.915Z" }, + { url = "https://files.pythonhosted.org/packages/d1/cf/d8ae8a80dd7bafab395ea7681c10237311bbf34d37704a8c744e7cf31fc7/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e4ff8e37f99cf1da89e255e07c9c4b37c214038c4283707bdec308cb1b0ea1f", size = 464197, upload-time = "2026-05-18T04:30:09.914Z" }, + { url = "https://files.pythonhosted.org/packages/7c/8a/3076c496ca8dafe0e8cd03fcebdfc47be4b1174b4e5b24ff6e396e6b3af2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:054dc20fd2e3132b4c3883b4a00d72fd6e1f56fdaf89fccd12e8057d74cd74d7", size = 453181, upload-time = "2026-05-18T04:30:14.829Z" }, + { url = "https://files.pythonhosted.org/packages/e5/10/9745e17c98e7b8a86454df0a3c7b5686bd650383f1e9f26e4ebcbd6cc0c0/watchfiles-1.2.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e140ed30ebde76796b686e67c182cff10ea2fbab186fafd1560f74bb5a473a6e", size = 465109, upload-time = "2026-05-18T04:30:28.123Z" }, + { url = "https://files.pythonhosted.org/packages/8f/95/8ef4a95481d3e0cb52d62a06fa6e972e81424be2d9698b91a2fecca9904c/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:bb7e52ecf68ba46d22df23467b87cffeb2146908aa523ebfe803019618cfda06", size = 630653, upload-time = "2026-05-18T04:31:49.304Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e4/3b3bf36b0f829b50c6ebcb8d031583863c59f923d6a6af3d485e470d0fac/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:23282a321c8baf9b3a3c4afff673f9fe65eb7fdc2338d765ccad9d3d1916a5ba", size = 657838, upload-time = "2026-05-18T04:31:06.497Z" }, + { url = "https://files.pythonhosted.org/packages/21/b1/6cbbb50c1f3002ab568777d44aa21206dfb8807a840990c4037523b51812/watchfiles-1.2.0-cp314-cp314-win32.whl", hash = "sha256:c0db965c5f79aa49fe672d297cf1febc5ad149b658594944f49a54a2b96270a7", size = 275108, upload-time = "2026-05-18T04:30:06.891Z" }, + { url = "https://files.pythonhosted.org/packages/92/45/190ce6db8dcb4536682cf75d3889ff1a27182a58cb519d343cb6d9ea63d8/watchfiles-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:71283b39fd17e5408eb123bd37aeecfd9d54c81fc184421943208aadb879d103", size = 288441, upload-time = "2026-05-18T04:32:12.901Z" }, + { url = "https://files.pythonhosted.org/packages/74/0d/3eae1c2313ab08378431d907c3f8095ecca00f3eda33111cf4f0f2591799/watchfiles-1.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:c5c19526f4e54a00f2666a6c0e9e40d582c09e865055ea7378bf0009aab857b3", size = 280684, upload-time = "2026-05-18T04:31:26.902Z" }, + { url = "https://files.pythonhosted.org/packages/b1/75/fb64e6c25d6b5ca636d03df34ffb1c6e9873303e76d27967e045f8df088f/watchfiles-1.2.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d73a585accffa5ae39c17264c36ec3166d2fad7000c780f5ef83b2722afb9dd2", size = 398857, upload-time = "2026-05-18T04:32:17.108Z" }, + { url = "https://files.pythonhosted.org/packages/73/4e/9f7adf01754cbf81843722ccfec169d8f26c69778281a302855cecd2ee08/watchfiles-1.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ae99b14c5f21e026e0e9d96f40e07d8570ebee6cafd9d8fc318354606daa7a28", size = 392413, upload-time = "2026-05-18T04:31:07.911Z" }, + { url = "https://files.pythonhosted.org/packages/47/c8/bec626bcc2d69f44b9acb24ce7d60ed7b16b73628eea747fcbd169d8edda/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4429f3b105524a10b72c3a819b091c495d2811d419c1e1e8df773a5a5974f831", size = 452409, upload-time = "2026-05-18T04:31:20.142Z" }, + { url = "https://files.pythonhosted.org/packages/00/b7/b6362068e81e7c556d155a34c35d40ac3ef42d747b06d7f6e5bf58e359c2/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:43d818978d06062d9b22c4fab2ebe44cf5213d42dc8e62bda8c2760cfa2eeb33", size = 458827, upload-time = "2026-05-18T04:32:06.219Z" }, + { url = "https://files.pythonhosted.org/packages/67/f8/9a813fa42afb1e0b4625e75f0479826644d3ee8dc287e093799bc01f390c/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b9f732dc58b2dbe69e464ccf8fff7a03b0dd0be439da4c0720d3558527d3d6b4", size = 490104, upload-time = "2026-05-18T04:31:56.034Z" }, + { url = "https://files.pythonhosted.org/packages/2f/bf/27dfb6094ca4c9aad21298b5525b6c53cb36121ee454331d05161e58d130/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f200104103feb097de4cab8fe4f5dd18a2026934c7dea98c55a2f5fd6d5a33b", size = 571360, upload-time = "2026-05-18T04:31:57.133Z" }, + { url = "https://files.pythonhosted.org/packages/fb/39/44a096d67270ea93df91d33877dbe91fbda3aa4f8ec2edf799d93eda8736/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:63ac26eefbf4af1741247d6fb68b11c49a25b2f7413fbd318a83a12aaa9cf666", size = 464644, upload-time = "2026-05-18T04:30:57.33Z" }, + { url = "https://files.pythonhosted.org/packages/0e/80/c7472203bad6268e3ef1ad260739704847898938ad7ea8b63a5131f46b50/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c4997d4e4a55f0d02b6cde327322daf3a0400e5df6c6b15948994bf72497925", size = 454771, upload-time = "2026-05-18T04:30:48.736Z" }, + { url = "https://files.pythonhosted.org/packages/51/cf/3b10b268b4b7f0fc26e9debb5eef1998b515887840f444cd3ec80c688755/watchfiles-1.2.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:4c887eba18b7945ac73067a8b4a66f21cd46c2539b2bc68588f7be6c7eb6d26b", size = 463494, upload-time = "2026-05-18T04:31:33.826Z" }, + { url = "https://files.pythonhosted.org/packages/3d/3e/a4302545cd589262a0dc7d140e86f7688eba3f9c72776c27f7e23b8864c4/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3416ff151bb6b5a8d8d11664974fbef4d9305b9b2957839ab5a270468fd8df30", size = 629383, upload-time = "2026-05-18T04:31:15.596Z" }, + { url = "https://files.pythonhosted.org/packages/db/99/d5649df0a9a410d45b7c882304d0b790903ac9b6e8f2cfd12114e0c6b9f2/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:0e831a271c035d89789cffc386b6aa1375f39f1cd25eb7ca0997e4970d152fc5", size = 656093, upload-time = "2026-05-18T04:31:58.707Z" }, + { url = "https://files.pythonhosted.org/packages/92/b9/362702539275019a54dd2e94511b31a9b89c5f9e6a21966de7eb692549fc/watchfiles-1.2.0-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:37a6721cdf3f65dbb13aa9503510ccb4451603ac837e44d265d7992a597e1374", size = 400109, upload-time = "2026-05-18T04:31:16.879Z" }, + { url = "https://files.pythonhosted.org/packages/8f/75/71d5ba62db781e5587bded1d944c675374bc4aa37ff33d5018d98e8b6538/watchfiles-1.2.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2b37d10b5a63bd4d87e18472d80fa525bd670586fae62e5dd580452764879b65", size = 392167, upload-time = "2026-05-18T04:31:28.058Z" }, + { url = "https://files.pythonhosted.org/packages/3c/01/c66dd95d0423fe30d31820e2d1d5bda773764131bbb6ac0cb1cf303ac328/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a105bc2283f67e8fbec74253ec2d94925de92ed72c0393f1206bf326b7b7b69", size = 452372, upload-time = "2026-05-18T04:31:00.836Z" }, + { url = "https://files.pythonhosted.org/packages/91/15/2fe99557e72f85627c6a8eed50d889e8d101623e060a22ad75b875cb932d/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5327989a465505f05cfe06f04fa9d0c2fd5432bb243e10e6f012b1bdca3c8579", size = 459596, upload-time = "2026-05-18T04:31:34.96Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/d4acfa0023367428ed48351b3b9b267893037b6cadae55620c61c24bcfd4/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ecb47f183a8025b2aa18b546725c3657e542112ae9c0613a2af79b4fa8d04ad7", size = 490869, upload-time = "2026-05-18T04:31:59.923Z" }, + { url = "https://files.pythonhosted.org/packages/a4/5f/3164cbdce06c9fb95c4f7b9e2f9760b5e2797af43a9ecc317ef42a23a278/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8520a4ab0e37f770afc34459c4f8f7019e153f9124dc101c15538365875d1ab2", size = 571641, upload-time = "2026-05-18T04:32:00.948Z" }, + { url = "https://files.pythonhosted.org/packages/41/e6/85d3731c55e65cd7690f3f803d24c139588aaf863e4bf2148fe7a7fa1a19/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:71cd71740ed2c15211ebb237ced4e39a1cdf6f80566e5fe95428da1626f4fde6", size = 464444, upload-time = "2026-05-18T04:30:34.298Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7d/562641012b8b09872742c3b8adf9629ec479fd78f8d68ae4a0c13da8add6/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f88af53d6ddaf72179ef613ddc905e6f4785f712b49b80b3bef9f3525e6194b4", size = 453593, upload-time = "2026-05-18T04:31:23.464Z" }, + { url = "https://files.pythonhosted.org/packages/56/fe/cb8ef3d6f929d14158fdaaad9925985b7310abc9384dcd4d82dd0016fb59/watchfiles-1.2.0-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:cee9d5efd929efdac5f7e58f72b3376f676b64050a91c5b99a7094c5b2317488", size = 465096, upload-time = "2026-05-18T04:31:30.384Z" }, + { url = "https://files.pythonhosted.org/packages/25/91/80908e835e100527a9267147b08c0eee1fa6ab0ffec15edc04d1d44885f7/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:b718bf356bbc15e559bd8ef41782b573b8ae0e3f177ab244b440568d7ea02cfb", size = 630638, upload-time = "2026-05-18T04:30:49.89Z" }, + { url = "https://files.pythonhosted.org/packages/46/4b/95ab2f256bb4af3cb2eb23b9317bda984ee6e0f11733a5c004a6c95b06e3/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:922c0e019fe68b3ae392965a766b02a71ba1168c932cebc3733cd52c5fe5b377", size = 657684, upload-time = "2026-05-18T04:31:32.027Z" }, +] + +[[package]] +name = "websockets" +version = "16.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/f7/bc3a25c5ec26ce62ce487690becc2f3710bbc7b33338f005ad390db0b986/websockets-16.1.1.tar.gz", hash = "sha256:db234eda965dcce15df96bb9709f587cd87d4d52aaf0e80e2f34ec04c7670c57", size = 182204, upload-time = "2026-07-17T22:51:05.858Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/9d/681cda21c9eee743203a6cb79b9d3d05adad9aa60ec660c6c9bf4dd619ca/websockets-16.1.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:cc97814dfb786a83b6e2dc2e79351e1b83e6d715647d6887fcabd83026417a00", size = 179600, upload-time = "2026-07-17T22:49:13.92Z" }, + { url = "https://files.pythonhosted.org/packages/fb/8d/6195a88b45e8d2a8f745fc2046e36f885a3c9763e6767d2c46229bf9510c/websockets-16.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e047dc87ef7ca50f4d309bf775ad4a71711c58556d75d7bd0604b2317f43e94b", size = 177272, upload-time = "2026-07-17T22:49:15.453Z" }, + { url = "https://files.pythonhosted.org/packages/73/e3/fe2d498c64dea0095c9a9f9a351af4cd6eef31b618395582bc1f38ba45ff/websockets-16.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:01fbdcbac298efe19360b94bc0039c8f746f0220ba570f327577bfee81059175", size = 177542, upload-time = "2026-07-17T22:49:16.875Z" }, + { url = "https://files.pythonhosted.org/packages/fe/ed/f1831681fce0e3242346e5458486003c5f124ed69e5e0b847fd029db4973/websockets-16.1.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0f62863e8a00a6d33c3d6566ec0b89f23787b747ffe0c3bc71ec0e76b82c94b1", size = 187137, upload-time = "2026-07-17T22:49:18.323Z" }, + { url = "https://files.pythonhosted.org/packages/6f/79/4ff9dcc1bb46f6b4c536936dde1fd60f9b564f3304307274db97f4c9496d/websockets-16.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8087e82f842609734c9b5a1330464f8e94e346ba0e18c832c08bafa4b0d63c15", size = 188374, upload-time = "2026-07-17T22:49:19.65Z" }, + { url = "https://files.pythonhosted.org/packages/62/c3/5c49b6efb36cab733d23773f6de575e1dba65736ead17d5d2b2a1daef779/websockets-16.1.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2bb5d041a8307d2e18782e7ce777f6fdb1e8c2f5d09291484b18c294b789d9aa", size = 191155, upload-time = "2026-07-17T22:49:21.331Z" }, + { url = "https://files.pythonhosted.org/packages/6e/f6/56ccceda3a4838d18f1d40821480da4775397e8b1eecf4031e20c50e2e90/websockets-16.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1db4de4a0e95673f7545d393c49eeb0c2f18ac1ef93073218c79d5cdb2ee75ab", size = 189011, upload-time = "2026-07-17T22:49:22.889Z" }, + { url = "https://files.pythonhosted.org/packages/86/d6/ad5286241a2bce1107e2798d3bfbd62cf79aee167bdb654f8cb1e9dbf949/websockets-16.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f17dbe07eb3ea7f99e4df9b7e0efefe80fbf30d37a8cc4d561a0aed310bc8847", size = 187766, upload-time = "2026-07-17T22:49:24.339Z" }, + { url = "https://files.pythonhosted.org/packages/bc/67/d65c970b7e347fdca69479beb7811c2060529956730a7a4e3ae7c66b0e31/websockets-16.1.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4b57693728576d84ede0a77987ab16881b783d2cd9f1dc180a8fbbc3f79c4428", size = 185173, upload-time = "2026-07-17T22:49:25.743Z" }, + { url = "https://files.pythonhosted.org/packages/1d/5b/14af3cd4ee69d8ea9baca58f3dc3cfb1ba78332a347fd478cb096549d60e/websockets-16.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2a636ff1e7a5c4edf71ef0e79adae7f25dba93b4fcbe3dc958733477ffeb0eaf", size = 187809, upload-time = "2026-07-17T22:49:27.147Z" }, + { url = "https://files.pythonhosted.org/packages/7b/11/be301710d70de97e3e7b3586e6d492c9c06d6a61bf1c2202c36cf0c75607/websockets-16.1.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:d6bec75c290fe484a8ba4cacdf838501e17c06ecfbbf31eede81a9e431bd7751", size = 186412, upload-time = "2026-07-17T22:49:28.611Z" }, + { url = "https://files.pythonhosted.org/packages/db/07/fe1435bf6fe738a3d3b54dbe0c18dabf12cba4d909ac8b58b539ce27c1f4/websockets-16.1.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:54509b8e92fee4453e152b7558ddef37ce9705a044922f2095a6105e3f80c96f", size = 188290, upload-time = "2026-07-17T22:49:29.965Z" }, + { url = "https://files.pythonhosted.org/packages/8a/0a/81f394aff8efcbb01208c1ced77df0a3c7fcce584a88c7273663697946c2/websockets-16.1.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:f0aa4aad3b1b69ad3fd85a0fd0952ec64331c762bd77ec51cc814170873890b2", size = 185844, upload-time = "2026-07-17T22:49:31.447Z" }, + { url = "https://files.pythonhosted.org/packages/39/5c/dd485b995473f415510251fe9bd708f2d24458f439fce958daf8d66dc7c6/websockets-16.1.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:42290eb6db4ccaca7012656738214f8514082fb6fa40cdeb61bb9a471b52e383", size = 186823, upload-time = "2026-07-17T22:49:33.104Z" }, + { url = "https://files.pythonhosted.org/packages/9d/0b/f78de76ff446f1e66af12b43c48a35f31744de93cfdec2f4ea67d5d7bbf1/websockets-16.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:53260c8930da5771cec89439bff99c20c8cb03ddb9588b980697355a83cd4bd3", size = 187102, upload-time = "2026-07-17T22:49:34.616Z" }, + { url = "https://files.pythonhosted.org/packages/37/a1/4cf892007778eaf84ad162bfc98046e0ed89b63ac55949e3236626b2a23f/websockets-16.1.1-cp312-cp312-win32.whl", hash = "sha256:1d27fa8462ad6a1cb36206a3d0640b2333340def181fae11ed7f9adeaa5c0747", size = 179943, upload-time = "2026-07-17T22:49:36.213Z" }, + { url = "https://files.pythonhosted.org/packages/d9/de/6abe251d28c3a3f217096575400b27750b18e0b1d2fff3a2a239960fea07/websockets-16.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:b436f6ec4fc3a6b4237c84d3f83170ed2b40bb584222f0ac47a0c8a5921980c7", size = 180243, upload-time = "2026-07-17T22:49:37.626Z" }, + { url = "https://files.pythonhosted.org/packages/ce/fd/6ec6c6d2850aea25b1b2aa9901a016980bb87d01e89b3eb00470b1b5d471/websockets-16.1.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ab59169ace05dcb49a1d4118f0bde139557adf45091bd85747e36bf5de984dd1", size = 179587, upload-time = "2026-07-17T22:49:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/5f/d8/1d299d2dd34087db39831a34cc645ef8a6f89d78efada6983093513cd81c/websockets-16.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5e3b7d601f6f84156b08cc4a5e541c2b50ad7b36cfc302b657a12477c904a5df", size = 177272, upload-time = "2026-07-17T22:49:40.293Z" }, + { url = "https://files.pythonhosted.org/packages/3d/86/0a70d3ae2f0f2256bb41302d9804dbca65d4360281e7feb3e1f94102ac46/websockets-16.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cd2ca96a082a36964aca83e992f72abeb61b7306c1a6cba4c7d06a7b93750cac", size = 177530, upload-time = "2026-07-17T22:49:41.786Z" }, + { url = "https://files.pythonhosted.org/packages/b5/c2/c676c69444d9db448b3f0a55a98dcc534affce0bce961d9d2f0b8499b10a/websockets-16.1.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f5d497865f05bb222cab7016c6034542e84e5f29f49c6fd3f4939cda7197b5b8", size = 187197, upload-time = "2026-07-17T22:49:43.658Z" }, + { url = "https://files.pythonhosted.org/packages/0b/13/88137fbaf726ebe29d62c1117fa11fa2bbb6209dc79d4ad738efbe36a2aa/websockets-16.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bae954c382e013d5ea5b190d2830526bfa45ad121c326da0049b8c769f185db6", size = 188433, upload-time = "2026-07-17T22:49:45.147Z" }, + { url = "https://files.pythonhosted.org/packages/01/6d/46c2f2ce6751cb26f39293e1ecbf8544cb01321397cd476c2756b98c216d/websockets-16.1.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e09f753a169951eb4f28c2c774f71069304f66e7277e0f5a2892423599cfa854", size = 189868, upload-time = "2026-07-17T22:49:46.581Z" }, + { url = "https://files.pythonhosted.org/packages/29/2b/170a9e8097636cfde4dc3c592b6e00b18a44a2f5407606d96ca542dd5838/websockets-16.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:024193f8551a2b0eafbdd160911012c4e6c228c28430c84433253299a9e42d6a", size = 189059, upload-time = "2026-07-17T22:49:47.972Z" }, + { url = "https://files.pythonhosted.org/packages/a7/48/f0d4ebc9ab4b473b8861b9e20fdb663d515d42f7befdf62cdb60fee7a1ec/websockets-16.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:aabe464bfd13bd25f4821faf111da6fefdc389f870265a53105580e45b0a2e49", size = 187814, upload-time = "2026-07-17T22:49:49.344Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ba/39a41d3ae8e72696a9492581900611c5a91e2b07563b0bcd2523adea9854/websockets-16.1.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a28fcbc9b6baf54a2e23f8655f308e4ccc6afdd7266f8fe7954f320dcda0f785", size = 185229, upload-time = "2026-07-17T22:49:50.787Z" }, + { url = "https://files.pythonhosted.org/packages/3c/36/ac15b604f850d1907f0a85ed721cefe47cd45034b3620069b829746cccbe/websockets-16.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:79eace538c6a97e96d0d03d4f9d314f9677f5ed85a8a984992ffd90b13cb8a56", size = 187874, upload-time = "2026-07-17T22:49:52.228Z" }, + { url = "https://files.pythonhosted.org/packages/a8/f3/3fbd5d71d59299c3770faa5884d4f45070236ca5a35ab3a61830812c409a/websockets-16.1.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:496af849a472b531f758dbd4d61338f5000538cb1a7b3d20d9d32a264517f509", size = 186469, upload-time = "2026-07-17T22:49:53.776Z" }, + { url = "https://files.pythonhosted.org/packages/b4/fc/dd90349bba58af2a53ef2ddd9c32716c81eb6d59a0687939fff561860878/websockets-16.1.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5283810d2646741a0d8da2aa733d6aefa0545809afccb2a5d105a26bc45125f1", size = 188347, upload-time = "2026-07-17T22:49:55.202Z" }, + { url = "https://files.pythonhosted.org/packages/4c/f3/f73ba86427682da59b78c11d77ba56d5b801c32e84afe79b274bbd6a9bb2/websockets-16.1.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:4e3b680b1e0a27457e727a0d572fd81dffa87b6dbf8b228ab57da64f7d85aead", size = 185903, upload-time = "2026-07-17T22:49:56.75Z" }, + { url = "https://files.pythonhosted.org/packages/34/7c/f95eb20e80104173b3a0a092291f89ea4047ef6e608e0a57ca06eb14eecb/websockets-16.1.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:69159730a823dde3ea8d08783e8d47ef135a6d7e8d44eb127e32b321c9db8e3e", size = 186855, upload-time = "2026-07-17T22:49:58.467Z" }, + { url = "https://files.pythonhosted.org/packages/b0/35/dd875b3e050ff232d60fa377707f890e369f74d134f1be32e8f68879747c/websockets-16.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ed5bb271084b46530ee2ddc0410537a9961152c5ccba2fc98c5276d992ccba87", size = 187140, upload-time = "2026-07-17T22:50:00.016Z" }, + { url = "https://files.pythonhosted.org/packages/e8/dc/5cbfcb41824502f6af93b8f3943a4d06c67c23c7d2e31eb18748c4a5b2a7/websockets-16.1.1-cp313-cp313-win32.whl", hash = "sha256:cfb70b4eb56cac4da0a83588f3ad50d46beb0690391082f3d4e2d488c70b68ea", size = 179928, upload-time = "2026-07-17T22:50:01.685Z" }, + { url = "https://files.pythonhosted.org/packages/b0/c1/71e5deb5b7f8f226997ab64908c184ac3105c0155ce2d486f318e5dd08a8/websockets-16.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:d9531d9cbeac99af6f038fb1bc351403531f7d634a2c2e10e2f7c854c6ed5b68", size = 180242, upload-time = "2026-07-17T22:50:03.117Z" }, + { url = "https://files.pythonhosted.org/packages/73/a2/ba78a164eeea4620df4a4df4bd2ed6017438c4655cc0f36f2c0bc0432355/websockets-16.1.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:443aefe96b7fdb132e2a70806cca1f2af49bb3f28e47abcd7c2e9dcf4d8fa1b8", size = 179635, upload-time = "2026-07-17T22:50:05.001Z" }, + { url = "https://files.pythonhosted.org/packages/b9/08/d26d7a7628cd4ac34cbbdb63ac80914ca842ed8e42938c40a53567806df3/websockets-16.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6456ff333092d509127d75a638cb411afae8ff17f092635015d1902efec8a293", size = 177320, upload-time = "2026-07-17T22:50:06.427Z" }, + { url = "https://files.pythonhosted.org/packages/0f/45/ebec83e6269536aa5932533c67b0af5c781f3e73fdbcd68672dcf43f4f44/websockets-16.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fce6c48559c86d1ac3632ccb1bebc7d5442fbe79bd9bb0e40379ee54be2a4051", size = 177544, upload-time = "2026-07-17T22:50:07.834Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d5/abc614d2297f6c1c3e01e61260364457a47c25cc1cf6a879038902bc6aa8/websockets-16.1.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:92b820d345f7a3fc7b8163949ee92df910f290c3fc517b3d5301c78065adafe1", size = 187270, upload-time = "2026-07-17T22:50:09.275Z" }, + { url = "https://files.pythonhosted.org/packages/52/71/4c99af3b87dff1b2927981f6876607d4acb45338c665242168d3982f7758/websockets-16.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a606d9c24035242a3e256e9d5b77ed9cd6bccfcb7cf993e5ca3c0f6f68fb6a7", size = 188509, upload-time = "2026-07-17T22:50:10.722Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b4/5c8ca14b0df7eb84ed0524165c5359150210140817a3312aee57bf62a1cf/websockets-16.1.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:414e596c75f74e0994084694189d7dc9229fb278e33064d6784b73ffbba3ca31", size = 189882, upload-time = "2026-07-17T22:50:12.293Z" }, + { url = "https://files.pythonhosted.org/packages/25/c1/bedfba9e70557129cb8083748d167bdcc01483dedf0f0df143676df05cbe/websockets-16.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:536676848fc5961aca9d20389951f59169508f765637a172403dc5434d722fa0", size = 189114, upload-time = "2026-07-17T22:50:13.789Z" }, + { url = "https://files.pythonhosted.org/packages/df/09/aa835b2787835aebd839114be5de51b797cb480b63ba42b26d34dfe147cb/websockets-16.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:97fd3a0e8b53efa41970ac1dff3d8cf0d2884cadeb4caaf95db7ad1526926ee3", size = 187861, upload-time = "2026-07-17T22:50:15.179Z" }, + { url = "https://files.pythonhosted.org/packages/20/26/f6408330694dbc9830857d9d23bc14ac4f6875127a480cfdda8d5ca21198/websockets-16.1.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7b1b19636af86a3c7995d4d028dbe376f39b4bf31541146f9c123582a6c94562", size = 185286, upload-time = "2026-07-17T22:50:16.741Z" }, + { url = "https://files.pythonhosted.org/packages/17/9a/e0675e70dd8a80762cf35bb18799d3f290a4890ffe6439bc51d222796083/websockets-16.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:41c8e77f17294c0ac18008a7309b99b34ee72247ef10b6dff4c3f8b5ac29896b", size = 187935, upload-time = "2026-07-17T22:50:18.213Z" }, + { url = "https://files.pythonhosted.org/packages/33/c1/3234cfb86afde01b81e9bddcc6e534c440975d60a13991259e833069ab3e/websockets-16.1.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:9f63bcef7f4b02b06b35fc01c93b96c43b5e88e1e8868676caacf493d5a31f3a", size = 186444, upload-time = "2026-07-17T22:50:19.67Z" }, + { url = "https://files.pythonhosted.org/packages/89/87/9c15206e1d778923d8daa9657de07aa62ea815e13448319c98458c37b281/websockets-16.1.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dab9eb87869da2d6ed3af3f3adf28414baae6ec9d4df355ffc18889132f3436c", size = 188409, upload-time = "2026-07-17T22:50:21.28Z" }, + { url = "https://files.pythonhosted.org/packages/f2/00/cf5de5c67676de2d3eef8b2a518f168f6796595447a5b7161ba0d012915c/websockets-16.1.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:43e3a9fdd7cbf7ba6040c31fae0faf84ca1474fef777c4e37912f1540f854499", size = 185958, upload-time = "2026-07-17T22:50:22.719Z" }, + { url = "https://files.pythonhosted.org/packages/62/c0/731b6ddede2e4136912ec4cff2cffbda35af73546be4762c3d7bd3bd79af/websockets-16.1.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:056ae37939ed7e9974f364f5864e76e49182622d8f9751ac1903c0d09b013985", size = 186911, upload-time = "2026-07-17T22:50:24.108Z" }, + { url = "https://files.pythonhosted.org/packages/8c/7f/39c634472c4469a24a7c09cecddffb08fac6d0e74f73881a94ee8a40a196/websockets-16.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a0eadbbf2c30f01efa58e1f110eb6fa293261f6b0b1aa38f7f48707107690af9", size = 187204, upload-time = "2026-07-17T22:50:25.548Z" }, + { url = "https://files.pythonhosted.org/packages/26/89/9667c256c256dafcc62d21328ce7a40067da857969b68ee9af375b0aaf72/websockets-16.1.1-cp314-cp314-win32.whl", hash = "sha256:195c978b065fa40910582464f99d6b15c8b314c68e0546549a55ed83f4735328", size = 179603, upload-time = "2026-07-17T22:50:27.086Z" }, + { url = "https://files.pythonhosted.org/packages/bd/dd/1c099d6c0fc5deb6b46ccdbb6981fdb4b12c917869cb3952408409dc18db/websockets-16.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:4e8d01cc3bcae7bbf8167f944aeafefed590fae5693552bba9794a9df68371cc", size = 179948, upload-time = "2026-07-17T22:50:28.521Z" }, + { url = "https://files.pythonhosted.org/packages/35/25/9956b2d5e0529d5d23924f21bba1440d4c5c88a562e4f08550871ffa97a7/websockets-16.1.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0ffd3031ea8bda8d61762e84220186105ba3b748b3c8da2ae4f7816fac03e573", size = 179963, upload-time = "2026-07-17T22:50:29.982Z" }, + { url = "https://files.pythonhosted.org/packages/17/06/55ffc976c488b6aee9ea05761ff7c4e88e7c1fd82818c8ca7b556ad2f90c/websockets-16.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:84a2cef8deffbd9ab8ee0ea546a2a6a7030c28f44e6cdd4547dbfeb489eb8999", size = 177497, upload-time = "2026-07-17T22:50:31.396Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e8/f7dac2e980bacc92bdc26cebae4ae4d50cae5380732c50980598fc0bbae4/websockets-16.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3df13f73af9b3b38ab1195eb299ecb67a4330c911c97ae04043ff74085728abe", size = 177698, upload-time = "2026-07-17T22:50:32.829Z" }, + { url = "https://files.pythonhosted.org/packages/b2/39/26762f734113e22da2b942c3aca85798e0c0405d64c256549540ff31e5a1/websockets-16.1.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:23253dd5bcae3f9aaee0a1d30967a8dbd52e5d3cff93a2e5b84df57b77d4750d", size = 187561, upload-time = "2026-07-17T22:50:34.24Z" }, + { url = "https://files.pythonhosted.org/packages/11/94/c3f330851806b9b02138b774d593478323e73c99238681b4b93efe64e02d/websockets-16.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c1c5705e314449e3308872fe084b8571ce078ee4fc55a98a769bdefe5917392", size = 188732, upload-time = "2026-07-17T22:50:36.088Z" }, + { url = "https://files.pythonhosted.org/packages/d1/f2/eb2c450f052de334ae33cf200ece6e87b0e14d186807074e4eb1cd2cdea2/websockets-16.1.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:69e52d175a0a7d1e13b4b67ad41c560b7d98e8c6f6126eb0bda496c784faf8c7", size = 190872, upload-time = "2026-07-17T22:50:38.008Z" }, + { url = "https://files.pythonhosted.org/packages/70/31/2ac8cecf3a74f7fed9132129fc3d90b3998a1554570c11a69b2a8c20332d/websockets-16.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1f79c89b5eb034d1722938a891916582f8f7f503f58ca22518a63c3f2cd18499", size = 189305, upload-time = "2026-07-17T22:50:39.53Z" }, + { url = "https://files.pythonhosted.org/packages/6a/cf/8ab19650d3c0d4562c92e70ab47c257c4aa5c6a713ed87fe63766b31fefc/websockets-16.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:39f2a024af5c345ffe8fcf1ee18c049c024c94df393bb09b044a6917c77bde43", size = 188033, upload-time = "2026-07-17T22:50:40.912Z" }, + { url = "https://files.pythonhosted.org/packages/66/d7/a49a38a6127a4acb134fb1912b215d900cc657605cff32445bf519f3acc4/websockets-16.1.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:952303a7318d4cbe1011400839bb2051c9f84fa0a35923267f5daba34b15d458", size = 185748, upload-time = "2026-07-17T22:50:42.559Z" }, + { url = "https://files.pythonhosted.org/packages/95/3e/ad1fa40388c7f2e0bb2c7930d0090b6c5498594bd1cdaec18864df3d9e97/websockets-16.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:249116b4a76063d930a46391ad56e135c286e4562a18309029fc2c73f4ed4c62", size = 188285, upload-time = "2026-07-17T22:50:43.974Z" }, + { url = "https://files.pythonhosted.org/packages/35/b8/d5db28ca264b9104f82196f92dc8843e35fd391f763d42e4ad358f5bc97e/websockets-16.1.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:61922544a0587a13fd3f53e4c0e5e606510c7b0d9d22c8444e5fae22a06b38cb", size = 186777, upload-time = "2026-07-17T22:50:45.474Z" }, + { url = "https://files.pythonhosted.org/packages/42/9c/726cb39d0cc43ae848dce4aa2acb04eecc6738b1264ec6d700bf6bcfb9f8/websockets-16.1.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:46dcaa042cd1de6c59e7d9269fa63ff7572b6df40510600b678f0826b3c7af51", size = 188682, upload-time = "2026-07-17T22:50:46.973Z" }, + { url = "https://files.pythonhosted.org/packages/be/c7/1168704de8c2dd483edabe4a22cbe4465dd8be8dd95561d214f9fe092871/websockets-16.1.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:38565aca3e01ea8734e578fb2118dade0ecb0250533f29e22b8d1a7a196cf4d0", size = 186377, upload-time = "2026-07-17T22:50:48.413Z" }, + { url = "https://files.pythonhosted.org/packages/ca/40/f9ff2d630ffce4e7dfea0b2288e1caf9ebbf9ff8a9ec9396136ce8b94935/websockets-16.1.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:42f599f4d48c7e1a3338fdaac3acd075be3b3cf02d4b274f3bf2767aedd3d217", size = 187148, upload-time = "2026-07-17T22:50:49.845Z" }, + { url = "https://files.pythonhosted.org/packages/b5/71/e177c8299f78d7cbe2d14df228643c10c70c0e86e108e092056bbcc16e46/websockets-16.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dcc04fedf83effaeb9cce98abc9469bb1b42ef85f03e01c8c1f4438ef7555737", size = 187578, upload-time = "2026-07-17T22:50:51.619Z" }, + { url = "https://files.pythonhosted.org/packages/49/b2/b6987faf330f5af5c787a2610124c2e8403d51724f9001ec4fff6311fe7a/websockets-16.1.1-cp314-cp314t-win32.whl", hash = "sha256:8483c2096363120eea8b07c06ae7304d520f686665fffd4811fad423930a65d7", size = 179729, upload-time = "2026-07-17T22:50:53.269Z" }, + { url = "https://files.pythonhosted.org/packages/a2/6e/fbac6ed878dd362fbad7d415fa4f84d38e3e33fed8cde45c64e783acf826/websockets-16.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:bcce07e23e5769375158f5efdcdafa8d5cd014b93c6683865b840ed65b96f231", size = 180072, upload-time = "2026-07-17T22:50:54.969Z" }, + { url = "https://files.pythonhosted.org/packages/be/4d/2d0d67834092e354d2b0498f014a41249a89556bc406cf86f3e1557bb463/websockets-16.1.1-py3-none-any.whl", hash = "sha256:6abbd3e82c731c8e531714466acd5d87b5e88ac3243465337ba71d68e23ae7e3", size = 173814, upload-time = "2026-07-17T22:51:04.184Z" }, +]