好的,我已经根据我们最新的讨论、Deep Research 报告的结论以及你验证 PWC API 的结果,对说明文档进行了修订和扩展,形成了 v1.1 版本。
主要更新包括:
- 版本号更新: 标题和结论中更新为 v1.1。
- 强化测试策略 (第 11 节): 这是最重要的修订部分,融入了大量实践经验和最佳实践,特别是:
- 明确了测试组织和命名规范。
- 严禁全局状态污染,强制使用
app.state。 - 提供了更详细、更准确的 Mocking 最佳实践,包括
AsyncMock、精确patch路径、模拟类实例化、spec/autospec的重要性。 - 再次强调了 API 测试中服务级别依赖覆盖的核心策略及其原因。
- 完全重写了
lifespan函数的测试方法,禁止使用TestClient测试内部逻辑,并提供了直接测试上下文管理器的详细步骤(包括成功和失败场景,以及如何正确断言)。 - 新增了测试配置 (
config.py) 的详细方法,包括如何使用monkeypatch模拟环境、Mockload_dotenv以及使用importlib.reload。 - 强调了数据库集成测试的隔离性。
- 强化依赖注入与状态管理 (第 7.1 节):
- 更清晰地阐述了
lifespan管理底层资源并存入app.state,而依赖项通过request.app.state访问这些资源(如果需要)。 - 再次强调了严禁使用全局变量。
- 更清晰地阐述了
- 后台任务依赖 (第 10 节): 明确后台任务需要独立初始化依赖。
- 细化目录结构说明 (第 4 节): 增加了对
tests/目录结构和命名规范的强调。 - 细化代码示例: 在第 7 节和第 11 节更新了代码示例,使其更清晰、更符合最佳实践(例如明确使用
Optional,Mock 类实例化示例)。 - 整体一致性: 在文档各处(引言、架构决策、结论)都融入了从实践和测试中获得的经验教训。
虽然本文档主要关注后端架构、规范和测试,但修订后的原则(尤其是测试策略)为实现你最新确定的数据收集策略(HF -> ArXiv -> PWC via ArXiv ID)提供了更坚实的基础和保障。
修改后的完整说明文档如下:
1. 引言与指导原则
本文档详细阐述了 AIGraphX v1.1 的简化架构与设计。鉴于先前开发经验中暴露出的过早复杂化和脆弱抽象等问题(正如"软件开发焦虑"分析中所讨论的),本版本优先考虑以下原则:
- 简洁至上 ("大道至简"): 从满足核心需求的最简可行方案着手。避免引入 MVP (最小可行产品) 阶段非必需的功能和抽象。
- 聚焦核心价值: 优先实现与收集、存储、关联和搜索 AI 模型/论文信息直接相关的功能。
- 迭代开发: 构建坚实的基础,仅在有明确需求和已验证价值的情况下才逐步引入复杂性。
- 可测试性: 设计易于测试的组件,具有清晰的依赖关系和最小化的副作用。这是后续演进和重构的基石。
- 显式接口与一致性: 严格定义并强制执行 API、函数签名、数据模型和错误处理的规范,以防止集成问题。这是减少开发中"小摩擦"的关键。
- 统一实现模式 (!!!): 尤其在依赖注入管理和API 测试策略上,必须采用统一、明确的最佳实践模式,避免多套逻辑并存(例如,依赖提供函数来源不一致、测试覆盖方法混乱、全局状态污染)带来的难以调试的问题。(这是从实际测试、重构和问题排查经验中得到的关键教训)。
- 务实的技术选型: 使用成熟、标准且适合任务的技术,避免不必要的"技术堆砌"或"简历驱动开发"。
- 安全优先: 妥善管理敏感信息(如 API 密钥),绝不硬编码或提交到版本控制系统。
- 状态管理: 严禁使用全局变量管理应用状态,必须使用
app.state结合lifespan进行管理。
(开发流程建议) 强烈推荐开发者在实施本项目时,遵循 Deep Research 报告第 10 节所推荐的个人开发者迭代工作流程,该流程整合了敏捷、精益、迭代思想,并强调了测试、重构和反思,有助于确保项目按本文档的原则和规范稳步推进。
2. 核心需求 (MVP 范围)
初始版本将专注于构建一个能够实现以下功能的系统:
- 数据采集: 从 两个主要数据源(Papers with Code, Hugging Face Hub)收集 AI 模型和论文的基本元数据及关系。
- 核心存储:
- 使用 PostgreSQL 存储结构化元数据。
- 使用 Neo4j 存储关键关系 (例如,模型-论文,模型-任务,论文-代码库等)。
- 使用 Faiss (或类似库) 存储文本嵌入。
- 基础搜索: 关键词搜索 (PG) 和语义相似度搜索 (Faiss)。
- API 访问: 提供稳定的 RESTful API 用于搜索和检索。
3. 简化架构
采用 模块化单体 后端服务,结合独立的 数据处理脚本/任务。
graph TD
subgraph "外部数据源"
PWC[Papers with Code API]
HF[Hugging Face API]
ARXIV[ArXiv API] # 新增 ArXiv API 作为关键桥梁
GITHUB[GitHub API] # 新增 GitHub API 获取星标
end
subgraph "后台处理"
# 更新数据采集逻辑描述
DC[数据采集脚本 (HF驱动)] -- 使用 API Keys --> HF
DC -- 提取 ArXiv ID --> ARXIV # HF->ArXiv
DC -- 使用 ArXiv ID 查询 --> PWC # ArXiv->PWC
DC -- 获取代码库 URL --> GITHUB # PWC->GitHub
DC -- 写入 --> PG[(PostgreSQL)]
DSync[数据同步任务/Worker] -- 读取 --> PG
DSync -- 更新 --> NJ[(Neo4j)]
DSync -- 更新 --> FA[(Faiss 索引)]
end
subgraph "后端服务 (FastAPI 应用)"
API[RESTful API 端点<br>(/search, /models/{id}, ...)] --> SS[搜索服务]
SS --> PR[PostgreSQL 仓库]
SS --> NR[Neo4j 仓库]
SS --> FR[Faiss 仓库]
API --> MS[元数据服务 (可选/内部)]
MS --> PR
MS --> NR
end
subgraph "数据库"
PG -- 被使用 --> PR & DSync & DC
NJ -- 被使用 --> NR & DSync
FA -- 被使用 --> FR & DSync
end
User[用户/客户端应用] --> API
style PG fill:#D6EAF8,stroke:#333,stroke-width:2px
style NJ fill:#D5F5E3,stroke:#333,stroke-width:2px
style FA fill:#FDEDEC,stroke:#333,stroke-width:2px
style API fill:#FEF9E7,stroke:#333,stroke-width:2px
style DC fill:#E8DAEF,stroke:#333,stroke-width:1px
style DSync fill:#E8DAEF,stroke:#333,stroke-width:1px
style ARXIV fill:#FADBD8,stroke:#333,stroke-width:1px
style GITHUB fill:#D6DBDF,stroke:#333,stroke-width:1px
(架构图已更新,反映了 ArXiv 和 GitHub API 在数据采集中作为关键辅助角色的引入,并调整了数据流描述)
关键架构决策与简化措施:
- 单一后端服务: FastAPI 应用,内部模块化分层。
- 直接数据库访问: 通过专用仓库模块 (Repository)。
- 异步后台任务/独立脚本: 解耦数据处理与 API 响应。数据采集脚本现在以 Hugging Face 数据为驱动。
- 最终一致性 (明确接受): PG 为主源,异步更新 Neo4j/Faiss。v1.1 无复杂跨库一致性机制。
- 无服务发现/注册。
- 简单缓存 (按需): 直接使用 Redis 客户端。无通用缓存抽象层。
- 基础健康检查:
/health端点。无复杂聚合报告。 - 配置管理: 环境变量 +
.env文件。(详见第 8 节) - 状态管理: 严禁使用全局变量管理应用状态,必须使用
app.state结合lifespan。(详见 7.1 和 11)
4. 目录结构 (简化且聚焦)
AIGraphX/
├── aigraphx/ # 后端服务主 Python 包 (!!! 包名固定, 用于内部导入 !!!)
│ ├── __init__.py
│ ├── main.py # FastAPI 应用实例与启动逻辑
│ ├── core/ # 核心工具、配置加载、数据库客户端设置
│ │ ├── __init__.py
│ │ ├── config.py # 配置加载 (环境变量, 文件)
│ │ └── db.py # 数据库客户端初始化 (PG, Neo4j, Faiss), lifespan 管理
│ ├── models/ # 用于 API 请求/响应 的 Pydantic 模型 (!!! 严格用于 API 边界, 命名需清晰 !!!)
│ │ ├── __init__.py # (允许导出模型类)
│ │ ├── common.py # 例如分页请求/响应模型 (文件名示例)
│ │ ├── search.py # 搜索相关的请求/响应模型
│ │ └── graph.py # 图/节点/关系相关的请求/响应模型
│ ├── schemas/ # (可选) 代表内部数据结构的 Pydantic 模型
│ │ └── ... # (如果内部结构与 API 不同, 命名同样需清晰)
│ ├── api/ # FastAPI 路由与端点
│ │ ├── __init__.py
│ │ └── v1/ # API 版本 v1 (!!! 路径固定 !!!)
│ │ ├── __init__.py
│ │ ├── endpoints/ # API 端点实现 (!!! 文件名对应资源或功能 !!!)
│ │ │ ├── __init__.py
│ │ │ ├── search.py # 对应 /search 端点
│ │ │ └── graph.py # 对应 /models, /papers 等图相关端点
│ │ └── dependencies.py # API 依赖项提供函数 (!!! 关键, 见 7.1 !!!)
│ ├── services/ # 服务层 (业务逻辑) (!!! 类名/函数名需明确体现业务 !!!)
│ │ ├── __init__.py
│ │ ├── search_service.py
│ │ └── graph_service.py
│ ├── repositories/ # 仓库层 (数据访问) (!!! 类名/方法名需明确对应数据库及操作 !!!)
│ │ ├── __init__.py
│ │ ├── postgres_repo.py # 例如 class PostgresRepository: def get_model_by_id(...)
│ │ ├── neo4j_repo.py # 例如 class Neo4jRepository: def find_related_papers(...)
│ │ └── faiss_repo.py # 例如 class FaissRepository: def search_similar_vectors(...)
│ ├── vectorization/ # 文本向量化逻辑
│ │ ├── __init__.py
│ │ └── embedder.py # 例如 class TextEmbedder: def embed(...)
│ ├── tasks/ # 后台任务定义 (!!! 函数名需清晰描述任务 !!!)
│ │ ├── __init__.py
│ │ └── data_sync.py # 例如 def sync_pg_to_neo4j_task(...)
│ └── utils/ # 真正通用的、无依赖的、简单的辅助函数 (!!! 极度谨慎添加, 保持简洁 !!!)
│ ├── __init__.py
│ └── formatting.py # (文件名示例)
├── scripts/ # 独立脚本 (!!! 文件名需清晰描述脚本功能 !!!)
│ └── collect_data.py # (文件名示例, 整合 HF, ArXiv, PWC, GitHub 数据采集逻辑)
│ └── ...
├── tests/ # 测试 (!!! 结构必须镜像 aigraphx, 命名需遵循 test_*.py !!!)
│ ├── conftest.py
│ ├── core/
│ │ ├── test_config.py # (文件名示例)
│ │ └── test_db.py # (文件名示例, 测试 lifespan 等)
│ ├── models/ # (如果模型有复杂验证逻辑需要测试)
│ ├── api/
│ │ └── v1/
│ │ └── endpoints/
│ │ ├── test_search_api.py # (文件名示例, 对应 search.py)
│ │ └── test_graph_api.py # (文件名示例, 对应 graph.py)
│ ├── services/
│ │ ├── test_search_service.py
│ │ └── test_graph_service.py
│ ├── repositories/ # (可能更多是集成测试)
│ │ ├── test_postgres_repo.py # (如果需要单元测试或特定集成测试)
│ │ ├── test_neo4j_repo.py
│ │ └── test_faiss_repo.py
│ └── tasks/
│ └── test_data_sync.py
├── docs/ # 项目文档
├── .env.example # 环境变量示例 (!!! 重要 !!!)
├── environment.yml # Conda 环境定义文件 (优先) + Pip 依赖
├── Dockerfile # 后端服务 Dockerfile
├── docker-compose.yml # 本地开发 Docker Compose
└── pyproject.toml # 项目元数据, 工具配置 (ruff, mypy, pytest, black)
(注: tests/ 目录结构必须严格镜像 aigraphx/,且测试文件名必须以 test_ 开头。这是 pytest 发现测试的基础,也是避免之前遇到的测试文件组织混乱问题的关键。)
5. 关键模块职责与接口
aigraphx/core: 应用配置、数据库客户端/连接池生命周期管理 (lifespan)。aigraphx/models: 严格用于 API 边界的 Pydantic 模型,负责请求/响应的结构定义和验证。aigraphx/schemas: (可选) 内部使用的 Pydantic 模型。aigraphx/api: FastAPI 路由、端点保持轻薄,调用services。依赖项通过dependencies.py注入。aigraphx/services: 业务逻辑层,编排repositories调用。aigraphx/repositories: 数据访问层,封装所有直接数据库交互。方法名应清晰反映操作。aigraphx/vectorization: 文本嵌入逻辑。aigraphx/tasks: 后台任务函数定义(例如数据同步)。scripts/: 独立的、核心的数据采集和处理脚本。
6. 严格的 API 设计与接口规范 (!!!关键!!!)
- RESTful 原则: 遵循标准。
- 一致的命名:
- API 路径:
/v1/models,/v1/search(小写,复数)。必须使用此风格。 - JSON 键 / 查询参数:
snake_case(例如,model_id,query_text)。必须使用此风格。
- API 路径:
- 标准 HTTP 状态码: 恰当使用。
- 一致的错误响应格式: 所有错误 (4xx, 5xx) 必须 返回 JSON:
{"detail": "...", "error_code": "...", "context": {...}}。必须使用 FastAPI 自定义异常处理器统一实现。 - Pydantic 用于所有 API 输入/输出: 请求体验证,响应体使用
response_model。必须严格执行。 - 版本控制: 路径包含版本号
/v1/。必须使用。
7. 严格的函数/方法签名规范 (!!!关键!!!)
为最大限度减少因接口不匹配、导入错误等问题导致的调试痛苦,必须严格遵守以下规范:
- 强制类型提示: 所有函数和方法签名(包括
__init__)的参数和返回值 必须 使用 Python 类型提示。这为静态分析和 AI 理解提供了基础。# 服务层示例 - 强制类型提示 from aigraphx.schemas.graph import ModelDetailSchema # 显式导入内部 Schema from aigraphx.repositories.postgres_repo import PostgresRepository # 显式导入仓库类 from typing import Optional # 明确使用 Optional 或 | None async def get_model_details(model_id: str, pg_repo: PostgresRepository) -> Optional[ModelDetailSchema]: """获取指定模型的详细信息。 Args: model_id (str): 模型的唯一标识符。 pg_repo (PostgresRepository): Postgres 仓库的实例。 Returns: Optional[ModelDetailSchema]: 模型详情 Schema 或 None。 """ # ... 实现 ... pass
- 强制 PEP 8 命名规范:
- 模块名:
lower_case_with_underscores(例如postgres_repo.py)。必须遵守。 - 包名:
lower_case_with_underscores(例如aigraphx,services)。必须遵守。 - 类名:
PascalCase(例如PostgresRepository,ModelDetailSchema)。必须遵守。 - 函数/方法名:
lower_case_with_underscores(例如get_model_details,search_similar_vectors)。必须遵守。 - 变量名:
lower_case_with_underscores。必须遵守。 - 常量名:
ALL_CAPS_WITH_UNDERSCORES(例如DEFAULT_TIMEOUT = 30)。必须遵守。 - 一致性是关键: 清晰、一致的命名是防止混淆和错误的基础。严禁使用无意义或过于简短的名称。
- 模块名:
- 静态分析 (
mypy): 必须 配置mypy(pyproject.toml) 并作为 CI 流水线的一部分运行,强制检查类型提示的正确性。类型错误必须视为构建失败。 - 强制一致的文档字符串 (Docstrings): 所有公共模块、类、函数和方法 必须 包含符合 PEP 257 规范的 Docstring,清晰解释其目的、参数 (
Args:), 返回值 (Returns:), 及可能抛出的异常 (Raises:)。使用统一格式(例如 Google 风格)。这是给人类和 AI 理解代码意图的重要信息。from typing import Optional from aigraphx.schemas.graph import ModelDetailSchema from aigraphx.repositories.postgres_repo import PostgresRepository # from aigraphx.core.exceptions import DatabaseConnectionError # 假设定义了自定义异常 async def get_model_details(model_id: str, pg_repo: PostgresRepository) -> Optional[ModelDetailSchema]: """获取指定模型的详细信息。 严格遵循类型提示和命名规范。 Args: model_id (str): 模型的唯一标识符。 pg_repo (PostgresRepository): Postgres 仓库的实例。 Returns: Optional[ModelDetailSchema]: 如果找到模型,则返回包含详情的 Schema 对象, 否则返回 None。 Raises: DatabaseConnectionError: 如果连接数据库失败 (示例异常)。 """ # ... 实现 ... pass
- 显式导入: 优先使用绝对导入(从
aigraphx根目录开始)。例如from aigraphx.services import search_service。避免容易出错的相对导入 (from . import ...),除非在包内结构非常稳定且必要。严禁使用from module import *。
7.1 依赖注入 (DI) 策略 (!!!关键!!!)
(在原第 7 节"严格的函数/方法签名规范"之后新增此节)
为了确保依赖关系清晰、可管理且高度可测试,本项目规定以下依赖注入策略:
-
集中管理依赖提供者:
- 所有 FastAPI 端点和服务所需的核心依赖项(如数据库仓库
PostgresRepository,Neo4jRepository,FaissRepository;文本嵌入器TextEmbedder;以及各服务SearchService,GraphService等)的获取逻辑,必须统一由aigraphx/api/v1/dependencies.py模块中的依赖提供函数(例如get_postgres_repository,get_search_service)负责。(注意目录结构中已更新此文件位置) - 严禁在项目的其他地方(尤其是
aigraphx/core/db.py或其他非dependencies.py的模块)定义并使用替代的或重复的依赖提供逻辑。
- 所有 FastAPI 端点和服务所需的核心依赖项(如数据库仓库
-
依赖提供者实现原则:
dependencies.py中的依赖提供函数应设计为尽可能独立。- 它们应直接使用从
aigraphx/core/config.py加载的配置(如数据库 URL、模型名称等)来创建仓库、嵌入器或服务实例(如果这些实例本身不需要共享资源)。 - 如果依赖项(如 Repository)需要访问由
lifespan管理的底层共享资源(如连接池),则依赖提供函数必须通过注入request: Request参数来访问request.app.state以获取这些共享资源,并将它们传递给依赖项的构造函数。严禁依赖提供函数本身去访问全局状态或直接进行复杂的资源初始化。# aigraphx/api/v1/dependencies.py 示例 from fastapi import Depends, Request from psycopg import AsyncConnectionPool # 假设类型 from aigraphx.repositories.postgres_repo import PostgresRepository from aigraphx.core.config import settings # 获取其他配置 async def get_postgres_pool(request: Request) -> AsyncConnectionPool: # 假设 lifespan 将 pool 存储在 app.state.pg_pool pool = getattr(request.app.state, "pg_pool", None) if pool is None: # Handle error: pool not initialized raise RuntimeError("Postgres pool not available in app state.") return pool async def get_postgres_repository( pool: AsyncConnectionPool = Depends(get_postgres_pool) ) -> PostgresRepository: # Repository 接收共享的 pool 进行初始化 return PostgresRepository(pool=pool, some_config=settings.SOME_PG_CONFIG)
- 可以使用
@functools.lru_cache()装饰器来缓存那些无状态且不依赖共享资源的依赖提供函数的返回值,实现简单的单例效果。 lifespan的职责:aigraphx/core/db.py中的lifespan管理器应专注于管理底层共享资源(如psycopg_pool的异步连接池、neo4j.AsyncDriver实例)的生命周期(启动时创建/连接,关闭时清理/断开),并将这些底层资源存储在app.state中 (例如app.state.pg_pool = ...)。- 状态管理: 再次强调,严禁使用可变的全局变量来存储应用状态 (如之前错误的
app_state全局字典)。必须通过 FastAPI 的app.state属性进行管理。
-
明确导入和使用:
- 所有 API 端点函数(位于
aigraphx/api/v1/endpoints/下)以及需要其他依赖的服务,在使用Depends()时,必须导入并使用定义在aigraphx/api/v1/dependencies.py中的对应依赖提供函数。
- 所有 API 端点函数(位于
-
目的: 这种集中、独立、明确的依赖注入策略,极大地简化了依赖关系的管理,最重要的是,它使得在测试环境中通过覆盖
dependencies.py中的函数来注入 Mock 对象或测试专用实例变得简单而可靠。
8. 配置管理
- 本地开发: 使用根目录下的
.env文件存储敏感信息。 - .gitignore: 必须包含
.env。 - .env.example: 必须提供,列出所有必需的环境变量及其格式。
- 代码加载: 使用
python-dotenv在应用启动时加载.env(aigraphx/core/config.py中)。 - 代码访问: 通过
os.getenv("VARIABLE_NAME", default_value)获取,并进行适当的类型转换和存在性检查。
9. !!! 重要:API 密钥与敏感信息管理 !!!
- 风险: 敏感信息硬编码或提交到 Git 会导致严重安全问题,且可能永久留在历史记录中。
- 解决方案: 必须遵循第 8 节的配置管理方法(环境变量 +
.env+.gitignore)。
10. 后台任务与独立脚本
- 数据采集脚本 (
scripts/collect_data.py):- 核心逻辑: 实现基于 HF 模型驱动的数据采集流程:HF -> ArXiv -> PWC -> GitHub。
- 独立性: 此脚本应作为独立的 Python 程序运行。
- 依赖管理: 必须独立加载配置 (使用
dotenv和config.py的逻辑,但独立于 FastAPI 应用) 并独立初始化其所需的 API 客户端 (HF, ArXiv, PWC, GitHub) 和数据库仓库实例。严禁依赖 FastAPI 应用实例或其lifespan。 - 错误处理与重试: 实现健壮的错误处理和重试机制 (如
tenacity) 来应对各个 API 的调用失败。 - 检查点: 实现检查点机制,记录已成功处理的 HF 模型 ID 或数量,以便中断和恢复。
- 后台任务 (
aigraphx/tasks/):- 用于定义需要与后端服务(可能通过消息队列如 Celery)集成的异步任务,例如数据同步 (
sync_pg_to_neo4j_task)。 - 同样需要独立管理依赖,不依赖 FastAPI 应用实例。
- 用于定义需要与后端服务(可能通过消息队列如 Celery)集成的异步任务,例如数据同步 (
11. 测试策略 (!!! 核心修订与实践强化 !!!)
-
测试是基石: 完善的测试是保证代码质量、支持安全地进行持续重构(演进式设计的关键实践)的基石。鉴于实际开发和测试中遇到的挑战(如全局状态污染、lifespan 测试困难、配置加载干扰等),现明确并强化以下核心测试策略:
-
测试组织与命名:
- 测试文件必须放在
tests/目录下,并严格镜像aigraphx/的包/模块结构。例如,aigraphx/core/db.py的测试应放在tests/core/test_db.py。避免将不同模块的测试混入错误的文件(如__init__.py或其他模块的测试文件)。 - 测试文件和测试函数(或方法)必须以
test_开头,以便pytest自动发现。
- 测试文件必须放在
-
禁止全局状态污染:
- 严禁在应用代码(尤其是
aigraphx/core/下的模块)中使用可变的全局变量来存储应用状态或共享资源。 - 所有需要在应用生命周期内共享的资源(如数据库连接池、驱动程序实例)必须通过 FastAPI 的
app.state进行管理,并在aigraphx/core/db.py的lifespan函数中进行初始化和清理。这确保了状态与应用实例绑定,避免了测试间的状态泄漏。
- 严禁在应用代码(尤其是
-
Mocking 最佳实践:
- Mock 直接依赖: 单元测试时,仅 Mock 被测单元的直接依赖项。例如,测试 Service 时 Mock Repository,测试 Repository 时(如果进行单元测试)Mock 底层驱动调用。
- 精确 Patch 路径: 使用
@patch或patch上下文管理器时,确保提供的路径 ('aigraphx.module.ClassName') 是正确的、被调用方实际导入和使用的路径。 - 正确使用 AsyncMock: 对于异步函数或方法,使用
unittest.mock.AsyncMock。确保await了 Mock 的调用,并使用assert_awaited_once_with等进行断言。 - Mocking 类实例化: 当需要 Mock 一个类的实例化过程(例如,
pool = AsyncConnectionPool(...))时,应使用patch("path.to.AsyncConnectionPool", new_callable=MagicMock)来 Mock 类本身。然后,必须将其return_value设置为一个配置好的 Mock 实例,例如:这确保了代码中获取到的是预期的 Mock 实例,而不是 Mock 类本身或其他意外对象。from unittest.mock import patch, MagicMock, AsyncMock from psycopg import AsyncConnectionPool # 假设导入 # ... # 创建一个符合接口规范的 Mock 实例 mock_pool_instance = AsyncMock(spec=AsyncConnectionPool) # Mock 实例上的方法,例如 close mock_pool_instance.close = AsyncMock() # ... 可能还需要 mock 其他需要的方法 ... # 使用 patch Mock 类 with patch("aigraphx.core.db.AsyncConnectionPool", new_callable=MagicMock) as mock_pool_class: # 将类的 return_value 设置为我们准备好的实例 mock_pool_class.return_value = mock_pool_instance # --- 在这里执行需要 Mock Pool 的代码 --- # 例如,调用初始化连接池的函数 # await initialize_db_pool(mock_app) # --- 代码结束 --- # 可以断言类被调用以创建实例 mock_pool_class.assert_called_once() # 可以在这里断言传递给构造函数的参数 (如果需要) # mock_pool_class.assert_called_once_with(conninfo="...", ...) # 在 with 块外,可以断言实例上的方法被调用 (如果相关逻辑执行了清理) # 例如,在测试 lifespan 的 __aexit__ 后: # mock_pool_instance.close.assert_awaited_once()
- 使用
spec/autospec: 创建 Mock 对象 (MagicMock,AsyncMock) 时,强烈推荐使用spec=ClassName或autospec=True。这会使 Mock 对象具有与原始类/对象相同的接口(方法和属性),如果尝试调用不存在的方法或属性,会直接抛出AttributeError,有助于在测试早期捕捉接口不匹配的错误。 - 复杂 Mock 场景: 如果 Mock 逻辑变得过于复杂,考虑是否可以通过依赖注入提供一个真实的、配置用于测试的“伪”实现 (Fake object),或者是否需要进行更高层次的集成测试。
-
API 端点测试 (主要集成测试方法):
- 测试工具: 使用
pytest结合 FastAPI 的TestClient。 - 测试范围: 重点测试
tests/api/v1/endpoints/下的 API 端点。 - 核心策略: 服务级别依赖覆盖:
- 在每个测试函数内部,使用
client.app.dependency_overrides字典(通常结合with语句或try...finally块来确保恢复)来直接覆盖该 API 端点所依赖的核心服务获取函数(例如,覆盖deps.get_search_service或deps.get_graph_service,其中deps指向aigraphx.api.v1.dependencies)。 - 被覆盖的函数应返回一个预先配置好的 Mock 对象 (
unittest.mock.AsyncMock或MagicMock),推荐使用spec。 - 在 API 调用之前,配置这个 Mock Service 实例的方法(如
.perform_semantic_search)的return_value或side_effect来模拟不同的业务场景。 - 原因: 实践证明,这种在测试函数内部直接覆盖服务层依赖的方式,比全局覆盖或
@patch装饰器更为清晰、直接、隔离性好且不易出错,能有效避免依赖覆盖失效或测试间干扰的问题。 - 测试焦点: 验证 API 端点的请求处理、参数解析、对(被 Mock 的)服务的调用(包括参数)、对服务返回结果的处理、以及最终生成的 HTTP 响应(状态码、响应体)是否符合预期。使用 Mock 的
assert_called_once_with/assert_awaited_once_with来验证服务调用。
- 在每个测试函数内部,使用
conftest.py职责:- 提供配置好的
TestClient实例 (clientfixture)。该实例应基于一个测试专用的、包含了所有被测 API 路由的FastAPI应用实例 (test_appfixture) 创建。 test_appfixture 不应包含全局性的app.dependency_overrides设置。conftest.py可以定义可复用的 Mock 对象 fixture(例如@pytest.fixture def mock_search_service(): return AsyncMock(spec=SearchService)),供测试函数按需注入。
- 提供配置好的
- 测试工具: 使用
-
单元测试:
- 重点测试 Services (
aigraphx/services/) 中的业务逻辑、Repositories (aigraphx/repositories/) 中复杂的查询或数据处理逻辑(如果未使用真实数据库进行集成测试)、Vectorization (aigraphx/vectorization/) 逻辑、以及utils和tasks中的独立函数。 - 遵循上述 Mocking 最佳实践。
- 重点测试 Services (
-
集成测试 (数据库交互):
- 对于需要验证服务层与真实数据库(或 Faiss 索引)交互逻辑的场景,应编写单独的集成测试(可以放在不同的文件或用
@pytest.mark.integration标记)。 - 在这些测试中,使用与 API 测试类似的依赖覆盖方法,但覆盖的是仓库层的依赖获取函数(例如
deps.get_postgres_repository)。 - 被覆盖的函数应返回连接到测试数据库(使用
.env或环境变量中定义的TEST_DATABASE_URL等)的真实仓库实例。 - 需要确保测试数据库的 Schema 是最新的,并考虑使用 fixture 或 setup/teardown 逻辑(例如
pytest-asyncio的event_loopfixture 结合事务)来准备测试数据和清理数据库状态,保证测试隔离性。
- 对于需要验证服务层与真实数据库(或 Faiss 索引)交互逻辑的场景,应编写单独的集成测试(可以放在不同的文件或用
-
测试
lifespan函数:- 禁止使用
TestClient测试lifespan函数本身的内部逻辑、资源初始化顺序或异常处理。TestClient会隐藏启动阶段 (__aenter__) 的异常,使其难以断言。 - 推荐方法: 直接测试
lifespan异步上下文管理器。- 创建一个 Mock 应用对象,关键是要有一个
state属性 (可以使用unittest.mock.MagicMock或 FastAPI 的State类实例):# tests/core/test_db.py (或类似文件) import pytest from fastapi import FastAPI from fastapi.datastructures import State from unittest.mock import MagicMock, AsyncMock, patch from contextlib import asynccontextmanager # 如果 lifespan 是函数 from aigraphx.core.db import lifespan # 导入你的 lifespan from psycopg import AsyncConnectionPool # 假设导入 from neo4j import AsyncDriver # 假设导入 # 假设 lifespan 函数定义类似: # @asynccontextmanager # async def lifespan(app: FastAPI): # # ... connect pg ... # app.state.pg_pool = await AsyncConnectionPool(...) # # ... connect neo4j ... # app.state.neo4j_driver = AsyncGraphDatabase.driver(...) # try: # yield # finally: # # ... close pg ... # if hasattr(app.state, 'pg_pool') and app.state.pg_pool: # await app.state.pg_pool.close() # # ... close neo4j ... # if hasattr(app.state, 'neo4j_driver') and app.state.neo4j_driver: # await app.state.neo4j_driver.close() @pytest.mark.asyncio async def test_lifespan_success(): """测试 lifespan 成功初始化和清理资源""" mock_app = MagicMock(spec=FastAPI) mock_app.state = State() # 必须有 state 属性 # Mock 资源初始化过程 (使用正确的 patch 路径!) mock_pg_pool_instance = AsyncMock(spec=AsyncConnectionPool) mock_pg_pool_instance.close = AsyncMock() mock_neo4j_driver_instance = AsyncMock(spec=AsyncDriver) mock_neo4j_driver_instance.close = AsyncMock() # 注意 patch 的 target 是 lifespan 函数内部实际调用的类/函数 with patch("aigraphx.core.db.AsyncConnectionPool", return_value=mock_pg_pool_instance) as mock_pg_pool_class, \ patch("aigraphx.core.db.AsyncGraphDatabase.driver", return_value=mock_neo4j_driver_instance) as mock_neo4j_driver_class: # 直接执行 lifespan 上下文 async with lifespan(mock_app): # 在 __aenter__ 执行后 (async with 块内) 断言 mock_pg_pool_class.assert_called_once() # 验证 Pool 类是否被调用创建实例 mock_neo4j_driver_class.assert_called_once() # 验证 Driver 是否被调用创建实例 assert mock_app.state.pg_pool is mock_pg_pool_instance assert mock_app.state.neo4j_driver is mock_neo4j_driver_instance # 可以在这里断言 Pool/Driver 的构造参数 (如果需要) # mock_pg_pool_class.assert_called_once_with(conninfo=...) # 在 __aexit__ 执行后 (async with 块结束) 断言清理 mock_pg_pool_instance.close.assert_awaited_once() mock_neo4j_driver_instance.close.assert_awaited_once() @pytest.mark.asyncio async def test_lifespan_startup_failure(): """测试 lifespan 在初始化某个资源时失败""" mock_app = MagicMock(spec=FastAPI) mock_app.state = State() mock_pg_pool_instance = AsyncMock(spec=AsyncConnectionPool) mock_pg_pool_instance.close = AsyncMock() mock_neo4j_driver_instance = AsyncMock(spec=AsyncDriver) mock_neo4j_driver_instance.close = AsyncMock() # 模拟 Neo4j 连接失败 neo4j_connection_error = ConnectionError("Failed to connect to Neo4j") with patch("aigraphx.core.db.AsyncConnectionPool", return_value=mock_pg_pool_instance) as mock_pg_pool_class, \ patch("aigraphx.core.db.AsyncGraphDatabase.driver", side_effect=neo4j_connection_error) as mock_neo4j_driver_class: # 使用 pytest.raises 断言异常 with pytest.raises(ConnectionError, match="Failed to connect to Neo4j"): # 手动驱动上下文的 __aenter__ 部分 ctx = lifespan(mock_app) await ctx.__aenter__() # 这里会抛出异常 # 在 __aenter__ 失败后断言 mock_pg_pool_class.assert_called_once() # PG 连接成功 assert mock_app.state.pg_pool is mock_pg_pool_instance # PG 资源已设置 mock_neo4j_driver_class.assert_called_once() # Neo4j 连接尝试了 assert not hasattr(mock_app.state, 'neo4j_driver') # Neo4j 资源未设置 # !!! 关键断言:清理逻辑是否被正确跳过或执行 !!! # 在 __aenter__ 失败时,__aexit__ 不会被框架自动调用并传入异常信息 # 因此,lifespan 函数内部 finally 块中的清理逻辑应该不会被触发 # (除非你在 lifespan 的 __aenter__ 内部手动 try...except...finally 并调用清理) # 假设标准的 finally 清理逻辑: mock_pg_pool_instance.close.assert_not_awaited() mock_neo4j_driver_instance.close.assert_not_awaited()
- 使用
async with lifespan(mock_app):或手动驱动上下文生命周期 (ctx = lifespan(mock_app); await ctx.__aenter__() ... await ctx.__aexit__(None, None, None))。 - 成功场景: 使用
patchMock 掉lifespan内部调用的资源初始化函数 (如AsyncConnectionPool,AsyncDriver)。在__aenter__执行后(或async with块内),断言这些 Mock 被正确调用,并且mock_app.state上设置了正确的属性(通常是 Mock 实例)。在__aexit__执行后(或async with块结束时),断言资源的清理方法(如mock_pool_instance.close()) 被调用。 - 启动失败场景: Mock 某个资源初始化函数,使其抛出预期的异常。使用
pytest.raises(ExpectedError, match=...)包裹await ctx.__aenter__()(其中ctx = lifespan(mock_app))。断言抛出了正确的异常。关键: 验证失败点之后的资源初始化 Mock 未被调用。并且,由于标准的异步上下文协议,如果__aenter__失败,__aexit__通常不会被框架调用来执行清理,因此断言所有在finally块中的清理 Mock(如close())也未被调用。切勿在测试代码中手动调用失败上下文的__aexit__。
- 创建一个 Mock 应用对象,关键是要有一个
- 禁止使用
-
测试配置 (
config.py):- 由于配置模块 (
aigraphx/core/config.py) 通常在导入时就会执行顶层的os.getenv调用,为了确保测试的隔离性和可重复性(不受实际.env文件或外部环境变量影响),必须 Mock 环境。 - 推荐方法:
- 使用
pytest的monkeypatchfixture 在测试函数或 fixture 内部 Mockos.getenv。提供一个自定义实现,使其从受测试控制的字典中读取值,对于未定义的变量返回None或默认值。# tests/core/test_config.py import os import pytest from unittest.mock import patch import importlib from aigraphx.core import config # 导入你的配置模块 @pytest.fixture def mock_env(monkeypatch): """Fixture to mock environment variables for config testing.""" test_env_vars = { "POSTGRES_USER": "test_user", "POSTGRES_PASSWORD": "test_password", "POSTGRES_DB": "test_db", "POSTGRES_HOST": "test_host", "POSTGRES_PORT": "5432", "NEO4J_URI": "neo4j://test_neo4j:7687", "NEO4J_USERNAME": "test_neo_user", "NEO4J_PASSWORD": "test_neo_pass", # ... include other variables defined in your Settings model ... # Ensure TEST_DATABASE_URL is also mocked if used in tests "TEST_POSTGRES_URI": "postgresql+asyncpg://test_user:test_password@test_host:5432/test_db_test" } # Mock os.getenv def mock_getenv(key, default=None): # Simulate environment lookup return test_env_vars.get(key, default) monkeypatch.setattr(os, 'getenv', mock_getenv) # Mock load_dotenv to prevent reading actual .env file during test # Patch the *specific location* where load_dotenv is called in config.py with patch("aigraphx.core.config.load_dotenv") as mock_load_dotenv: # You might not need a return value unless your code checks it mock_load_dotenv.return_value = True yield # Allow the test using this fixture to run def test_settings_load_from_mock_env(mock_env): # Use the fixture """Test that Settings model loads correctly from mocked environment.""" # !!! Crucial: Reload the config module to apply the mocked os.getenv !!! importlib.reload(config) # Now assert the values loaded into the settings object # Use the actual attribute names defined in your Settings Pydantic model assert config.settings.POSTGRES_USER == "test_user" assert config.settings.POSTGRES_PASSWORD == "test_password" assert config.settings.POSTGRES_DB == "test_db" assert config.settings.POSTGRES_HOST == "test_host" assert config.settings.POSTGRES_PORT == 5432 # Assuming it's parsed as int assert config.settings.NEO4J_URI == "neo4j://test_neo4j:7687" assert config.settings.NEO4J_USERNAME == "test_neo_user" assert config.settings.NEO4J_PASSWORD == "test_neo_pass" # Construct expected DATABASE_URL and assert expected_db_url = f"postgresql+asyncpg://test_user:test_password@test_host:5432/test_db" assert str(config.settings.DATABASE_URL) == expected_db_url # Assert TEST_DATABASE_URL if it's part of your settings assert str(config.settings.TEST_DATABASE_URL) == "postgresql+asyncpg://test_user:test_password@test_host:5432/test_db_test" # Test a missing variable behaves as expected (returns None or default) assert config.settings.OPTIONAL_SETTING is None # Assuming it has no default in mock_env
- 必须同时使用
monkeypatch或@patchMock 掉aigraphx.core.config.load_dotenv,使其在测试期间不执行,彻底阻止读取实际的.env文件。 - 在应用了 Mock 之后,必须使用
importlib.reload(config)来强制重新加载配置模块,这样它才会使用我们 Mock 后的os.getenv来读取配置。
- 使用
- 断言准确: 确保测试中的断言与
config.py中定义的实际配置属性名(例如,Pydantic 模型Settings中的字段名)完全匹配。
- 由于配置模块 (
-
及时清理: 定期审查并清理旧的、放错位置的、重复的或不再使用的测试文件和代码,避免混淆和维护负担。
12. 未来增强功能 (MVP 之后)
明确推迟以下复杂功能,严格按需评估后才考虑引入:
多源采集与冲突解决、高级图分析/可视化、分布式事件总线、服务发现、集中配置、复杂缓存层、高级监控/追踪、高可用设置、Vault、复杂跨库一致性机制、正式 common 库。
13. 部署注意事项
- 绝不部署
.env文件。 - 在部署环境通过平台的环境变量管理机制设置真实配置。
- 部署时应考虑如何一致地创建 Conda 环境 (例如,导出
environment.yml或使用conda pack) 或构建 Docker 镜像。
14. 结论
这份优化后的详细设计文档 (v1.1) 为构建 AIGraphX 提供了极其明确、规范且安全的指导。通过聚焦核心功能、采用务实架构、强制接口/编码/安全规范,并融入了实际测试过程中的经验教训,旨在最大限度地减少开发痛点,创建一个健壮、可测试、可维护的系统。项目的成功不仅依赖于简洁的设计,更依赖于一致的实现模式和经过实践验证、不断完善的测试策略。严格遵循本文档(尤其是关于依赖注入、状态管理、Mocking、lifespan 测试和配置测试的强化规范)对于避免混乱、提高开发效率和保证代码质量至关重要。强烈建议开发者将本文档视为项目开发的"契约",并严格遵循其中定义的结构、规范和原则,特别是关于命名、类型提示、API 设计、敏感信息处理以及至关重要的测试实践部分。 同时,结合 Deep Research 报告中推荐的迭代工作流程,将有助于确保项目健康、高效地推进。