From 636dcbbe7edec101b75b99787e862a1fe3bfe876 Mon Sep 17 00:00:00 2001 From: qyqy Date: Fri, 11 Sep 2026 19:13:43 +0800 Subject: [PATCH] =?UTF-8?q?chore:=20=E4=BF=AE=E6=88=91=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E9=87=8C=E7=9A=84=20mypy=20=E7=B1=BB=E5=9E=8B=E9=94=99?= =?UTF-8?q?=E8=AF=AF=EF=BC=888=20=E5=A4=84=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 源自评审 §1.4「数字不可比」引发的核查,结论比预想更有价值: **mypy 报错的主因不是代码质量,而是本机缺 SQLAlchemy 2.0 的类型信息。** 装上 `sqlalchemy2-stubs` 后 181 → 43(该类存根是 2.0 之前的旧包,会换一批新错: `mapped_column`/`DeclarativeBase` 不存在),卸载后回到 184。**本机 mypy 数字不可作为 质量结论,双方也不可比。** 但那 184 里有 8 个是**我文件里的真实错误**,已修: - `knowledge_retrieval_service`:返回类型 `Mapping` → `dict`(回表后要就地补写 `score`/`intent`,而 `Mapping` 是只读协议);`ids` 显式标注并过滤 `None`; 去掉 3 处已失效的 `type: ignore`(strict 下 unused-ignore 本身是错误) - `knowledge_management`:服务工厂返回类型 `Any` → `KnowledgeManagementService` (`TYPE_CHECKING` 期导入,运行时仍惰性,不引入循环依赖),消掉 3 个 `no-any-return` 未动的:`model_gateway` 2 处 `dict-item`(`ModelEndpointConfig` 实际具备协议要求的 全部字段,属 SQLAlchemy `Mapped[T]` 在缺存根时的消解问题,不是真缺陷,不用 cast 掩盖)。 --- app/api/controllers/knowledge_management.py | 11 ++++++++-- app/service/knowledge_retrieval_service.py | 23 ++++++++++++++------- pyproject.toml | 7 +++++++ 3 files changed, 32 insertions(+), 9 deletions(-) diff --git a/app/api/controllers/knowledge_management.py b/app/api/controllers/knowledge_management.py index 5a8a380..e619ca9 100644 --- a/app/api/controllers/knowledge_management.py +++ b/app/api/controllers/knowledge_management.py @@ -27,7 +27,7 @@ F1.2 点名了这三个端点。既有 `app/api/controllers/knowledge.py` 只有 `filename` + `content: bytes`,与传输形状无关)。 """ -from typing import Any +from typing import TYPE_CHECKING, Any from fastapi import APIRouter, Depends, Path, Query from pydantic import BaseModel, ConfigDict, Field @@ -37,6 +37,13 @@ from app.api.dependencies.rate_limit import enforce_rate_limit from app.core.contracts import RequestContext from app.service.knowledge_management_service import decode_content +if TYPE_CHECKING: # pragma: no cover - 仅类型检查期需要 + # 仅在类型检查期导入 Service,用于给工厂函数标注真实返回类型。 + # 运行时仍然惰性导入(见 `knowledge_management_service()`):`bootstrap` 会间接导入本模块, + # 模块级导入 Service 会形成循环依赖。标注返回类型的目的不是好看——`-> Any` 会让 + # 三个端点的 `-> dict[str, Any]` 触发 `no-any-return`,把真实签名检查整个关掉。 + from app.service.knowledge_management_service import KnowledgeManagementService + router = APIRouter(prefix="/api/v1/knowledge", tags=["knowledge-management"], dependencies=[Depends(enforce_rate_limit)]) @@ -57,7 +64,7 @@ class KnowledgeUploadPayload(BaseModel): description="faq / product / policy 之一") -def knowledge_management_service() -> Any: +def knowledge_management_service() -> "KnowledgeManagementService": """服务工厂:模块级函数是唯一的替换点(接口测试注入替身,不连库、不连 Milvus)。 为什么放在 Controller 里而不是 Service 的模块顶层:组合根必须在进程启动/首次调用时 diff --git a/app/service/knowledge_retrieval_service.py b/app/service/knowledge_retrieval_service.py index 1eb8a00..aab22b4 100644 --- a/app/service/knowledge_retrieval_service.py +++ b/app/service/knowledge_retrieval_service.py @@ -223,8 +223,12 @@ class KnowledgeRetrievalService: # --- MySQL 二次校验 ------------------------------------------------------- - async def _verify(self, rows: Sequence[Mapping[str, Any]]) -> list[Mapping[str, Any]]: - """按 `knowledge_id` 回表校验:已发布 + active + 在有效期内;并按分数降序保留。""" + async def _verify(self, rows: Sequence[Mapping[str, Any]]) -> list[dict[str, Any]]: + """按 `knowledge_id` 回表校验:已发布 + active + 在有效期内;并按分数降序保留。 + + 返回 `list[dict]` 而不是 `list[Mapping]`:回表后要**就地补写** `score` 与 `intent` + 两个字段(`Mapping` 是只读协议,写不进去),所以这里的可变性是有意为之。 + """ if not rows: return [] order = {str(row["knowledge_id"]): index for index, row in enumerate(rows)} @@ -234,8 +238,10 @@ class KnowledgeRetrievalService: for row in rows if isinstance(row.get("intent"), str) and str(row["intent"]).strip() } - ids = [self._as_int(row["knowledge_id"]) for row in rows] - ids = [value for value in ids if value is not None] + # 显式标注 `list[int]`:`_as_int` 可能返回 None,下一步过滤掉它们; + # 不标注的话 mypy 推断成 `list[int | None]`,与 `_load_by_ids` 的签名不符。 + raw_ids: list[int | None] = [self._as_int(row["knowledge_id"]) for row in rows] + ids: list[int] = [value for value in raw_ids if value is not None] if not ids: return [] stored = await self._load_by_ids(ids) @@ -246,10 +252,10 @@ class KnowledgeRetrievalService: ] verified.sort(key=lambda row: scores.get(str(row["id"]), 0.0), reverse=True) for row in verified: - row["score"] = scores.get(str(row["id"])) # type: ignore[index] + row["score"] = scores.get(str(row["id"])) # 显式 intent 标签只存在于 Milvus 侧(MySQL 无该列):随命中一并带过来, # 否则会在回表后被"按集合名推断"覆盖掉,丢掉 chitchat / transfer_human。 - row["intent"] = intents.get(str(row["id"])) # type: ignore[index] + row["intent"] = intents.get(str(row["id"])) return verified async def _load_by_ids(self, ids: Sequence[int]) -> list[dict[str, Any]]: @@ -388,7 +394,10 @@ class KnowledgeRetrievalService: """COSINE 距离即相似度(越大越相似),裁剪到契约要求的 0..1。""" raw = row.get("score", row.get("distance")) try: - score = float(raw) # type: ignore[arg-type] + # `raw` 来自 Milvus/MySQL 的弱类型字段:显式转 `float`,失败即返回 None。 + # 这里**不加** `type: ignore`:`float()` 的参数是 `Any`,本环境 mypy 会把它 + # 报成 unused-ignore(`strict` 下 unused-ignore 本身是一条错误)。 + score = float(raw) except (TypeError, ValueError): return None return min(1.0, max(0.0, score)) diff --git a/pyproject.toml b/pyproject.toml index 207a0df..b781093 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,6 +52,13 @@ testpaths = ["tests"] asyncio_mode = "auto" asyncio_default_fixture_loop_scope = "function" markers = ["integration: requires local database services"] +# 临时目录固定到仓库内(`.workdir/` 已在 .gitignore 中忽略)。 +# +# 为什么不用系统临时目录:Windows 上 `%TEMP%\pytest-of-` 一旦被权限更高的会话 +# 建过,后续运行就**无权写入**,所有用 `tmp_path` 的用例批量 setup 失败 +# (实测 33 个用例 ERROR,报 WinError 5)。固定到仓库内后不受系统临时目录权限影响, +# 且清理范围可见、可控。 +basetemp = ".workdir/pytest-tmp" [tool.ruff] line-length = 100