52 KiB
审计问题修复计划 — 全部 35 项
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: 修复 2026-07-10 审计报告中的全部 35 个问题,涵盖 CRITICAL(1) + HIGH(13) + MEDIUM(11) + LOW(10)
Architecture: 分 4 批执行:CRITICAL 立即修复 → HIGH 安全/功能/代码质量/测试补充 → MEDIUM 架构改进 → LOW 文档和工具链收尾。每批 TDD 先行,所有新增或修改的逻辑都必须有对应测试,每批结束后跑全量测试确保无回归。
Tech Stack: Python 3.13, FastAPI, ChromaDB, pytest, Typer
批次总览
| 批次 | 问题数 | 预计时间 | 说明 |
|---|---|---|---|
| Batch 1 | 1 | 10 min | CRITICAL: 重复入库 |
| Batch 2 | 13 | 3-4 hrs | HIGH: 安全/功能/代码质量/测试 |
| Batch 3 | 11 | 2 hrs | MEDIUM: 架构改进/去重/边界测试 |
| Batch 4 | 10 | 1 hr | LOW: 文档/工具链/编码 |
Batch 1 — CRITICAL(1 个问题)
Task 1: 修复 ingest_obsidian.py 顶层 .md 文件重复入库
Files:
-
Modify:
scripts/ingest_obsidian.py:66-77 -
Step 1: 删除重复扫描代码
删除第二轮 for target_info in targets: 循环(第 66-77 行),rglob("*.md") 已递归覆盖所有文件。
修改前(第 66-77 行):
# 顶层 .md 文件
for target_info in targets:
d = target_info[1]
p = Path(d)
if not p.exists():
continue
for f in p.glob("*.md"):
sz = f.stat().st_size
if sz > MAX_SIZE:
skipped.append((f.name, sz))
else:
files.append(("顶层/" + p.name, str(f)))
修改后(直接删除整段,rglob 在上一轮已覆盖):
# rglob("*.md") 已包括顶层文件,无需单独扫描
- Step 2: 运行现有测试确认无回归
uv run pytest tests/ -v
预期:90 passed
- Step 3: 提交
git add scripts/ingest_obsidian.py
git commit -m "fix: 修复 ingest_obsidian 顶层 .md 重复入库导致 GPU 浪费"
Batch 2 — HIGH(13 个问题)
Task 2: CORS 配置冲突修复 (HIGH-1)
Files:
-
Modify:
src/server/app.py:66-72 -
Step 1: 修改 CORS 配置
# 第 66-72 行,改为:
app.add_middleware(
CORSMiddleware,
allow_origins=os.environ.get("CORS_ORIGINS", "http://localhost:3000").split(","),
allow_credentials=False, # 默认关闭,与 allow_origins="*" 不兼容
allow_methods=["GET", "POST", "DELETE", "OPTIONS"],
allow_headers=["Content-Type", "Authorization", "X-API-Key"],
)
- Step 2: 运行测试
uv run pytest tests/test_api.py -v
预期:14 passed
- Step 3: 提交
git add src/server/app.py
git commit -m "fix: 修复 CORS allow_credentials 与 allow_origins=* 冲突"
Task 3: search_documents 端点添加异常处理 (HIGH-2)
Files:
-
Modify:
src/server/app.py:147-155 -
Step 1: 添加 try/except
将 search_documents 函数体包裹在异常处理中:
@app.post("/api/v1/search")
def search_documents(
req: SearchRequest,
state: AppState = Depends(get_state),
_: bool = Depends(verify_api_key),
):
try:
searcher = state.get_searcher(req.collection)
results = searcher.search(req.query, top_k=req.top_k)
return {"results": results, "collection": searcher.collection_name}
except HTTPException:
raise
except Exception:
logger.exception("检索失败")
raise HTTPException(status_code=500, detail="服务器内部错误")
- Step 2: 写测试验证异常不泄露 traceback
在 tests/test_api.py 中添加:
def test_search_internal_error_does_not_leak_traceback(client, monkeypatch):
"""search 内部错误不泄露 traceback 信息."""
monkeypatch.setenv("MD_VECTOR_API_KEY", "test-key")
# 模拟嵌入器抛异常
with monkeypatch.context() as m:
m.setattr(
"src.core.embedder.LocalEmbedder.embed",
lambda self, texts: (_ for _ in ()).throw(RuntimeError("GPU OOM")),
)
response = client.post(
"/api/v1/search",
json={"query": "test", "top_k": 3},
headers={"X-API-Key": "test-key"},
)
assert response.status_code == 500
data = response.json()
assert "detail" in data
# 不应泄露内部错误信息
assert "GPU OOM" not in str(data)
assert "RuntimeError" not in str(data)
assert "traceback" not in str(data).lower()
- Step 3: 运行测试
uv run pytest tests/test_api.py::test_search_internal_error_does_not_leak_traceback -v
预期:PASS
- Step 4: 提交
git add src/server/app.py tests/test_api.py
git commit -m "fix: search_documents 端点添加异常处理防止 traceback 泄露"
Task 4: delete_document collection 参数添加输入校验 (HIGH-3)
Files:
-
Modify:
src/server/app.py:159-163 -
Step 1: 添加 Query 校验
# 第 159-163 行,添加 Query 参数校验:
@app.delete("/api/v1/documents/{file_name}")
def delete_document(
file_name: str,
state: AppState = Depends(get_state),
_: bool = Depends(verify_api_key),
collection: str | None = Query(
default=None, max_length=128, pattern=r"^[a-zA-Z0-9_-]+$",
),
):
需要在文件顶部添加 Query 的导入(FastAPI 已导入,但 Query 不在当前 import 中):
在 src/server/app.py 第 8 行修改导入:
from fastapi import FastAPI, HTTPException, Depends, Request, Query
- Step 2: 写测试验证非法 collection 名被拒绝
在 tests/test_api.py 中添加:
def test_delete_document_rejects_invalid_collection_name(client):
"""非法 collection 名被拒绝 (含特殊字符)."""
response = client.delete(
"/api/v1/documents/test.md?collection=bad;drop--table",
headers={"X-API-Key": "test-key"},
)
assert response.status_code == 422 # Pydantic validation error
- Step 3: 运行测试
uv run pytest tests/test_api.py::test_delete_document_rejects_invalid_collection_name -v
预期:PASS
- Step 4: 提交
git add src/server/app.py tests/test_api.py
git commit -m "fix: delete_document 的 collection 参数添加正则校验"
Task 5: 统一 CLI 与 API 的路径安全检查 (HIGH-4)
Files:
-
Modify:
src/core/security.py(新增is_path_within_workspace) -
Modify:
src/server/app.py:119-130(改用新函数) -
Modify:
src/cli/main.py:85,120(改用新函数) -
Create:
tests/test_security.py补充测试 -
Step 1: 在 security.py 中添加
is_path_within_workspace
# 在 is_safe_path 函数后面添加(第 28 行之后):
def is_path_within_workspace(path_str: str) -> bool:
"""检查路径是否在当前工作目录内(防路径穿越 + 目录绑定).
同时检查:
1. 路径不含 .. 穿越组件且非绝对路径
2. resolve 后的路径位于当前工作目录内
Args:
path_str: 用户提供的路径字符串
Returns:
路径安全且在工作目录内时返回 True
"""
if not is_safe_path(path_str):
return False
from pathlib import Path
path = Path(path_str).resolve()
cwd = Path.cwd().resolve()
try:
common = Path(os.path.commonpath([str(path), str(cwd)]))
except ValueError:
return False
return common == cwd
- Step 2: 写测试
在 tests/test_security.py 中添加:
from src.core.security import is_path_within_workspace
class TestIsPathWithinWorkspace:
"""工作目录绑定检查."""
def test_simple_safe_path(self):
"""当前目录下的普通路径安全."""
assert is_path_within_workspace("test.md") is True
assert is_path_within_workspace("subdir/test.md") is True
def test_parent_traversal_rejected(self):
"""父目录穿越被拒绝."""
assert is_path_within_workspace("../outside.md") is False
def test_absolute_path_rejected(self):
"""绝对路径被拒绝."""
assert is_path_within_workspace("/etc/passwd") is False
def test_empty_string_rejected(self):
"""空字符串."""
assert is_path_within_workspace("") is True # 空路径 resolve 后等于 cwd
def test_dot_dot_in_middle(self):
"""路径中间的 .. 被拒绝."""
assert is_path_within_workspace("foo/../bar.md") is False
- Step 3: 修改 app.py 使用新函数
# 第 117-133 行,替换路径检查逻辑:
@app.post("/api/v1/ingest")
def ingest_document(...):
ingestor = state.get_ingestor(req.collection)
try:
if req.file_path:
if not is_path_within_workspace(req.file_path): # 改这里
raise HTTPException(status_code=400, detail="不允许的路径")
path = Path(req.file_path).resolve()
# 不再需要 app.py 内部的 commonpath 检查
...
同时需要更新 app.py 顶部的 import:
from src.core.security import is_path_within_workspace # 替换 is_safe_path
- Step 4: 修改 cli/main.py 使用新函数
# 第 20 行,修改 import:
from src.core.security import is_path_within_workspace
# 第 85 行:
if not is_path_within_workspace(fp): # 替换 is_safe_path
typer.echo(f"[SKIP] 不安全的路径: {fp}", err=True)
continue
# 第 93 行:
if not is_path_within_workspace(m): # 替换 is_safe_path
typer.echo(f"[SKIP] 不安全的路径: {m}", err=True)
continue
# 第 120 行:
if not is_path_within_workspace(dir_path): # 替换 is_safe_path
typer.echo(f"错误: 不安全的路径 — {dir_path}", err=True)
raise typer.Exit(code=1)
- Step 5: 运行全量测试
uv run pytest tests/ -v
预期:全部通过
- Step 6: 提交
git add src/core/security.py src/server/app.py src/cli/main.py tests/test_security.py
git commit -m "fix: 统一 CLI/API 路径安全检查为 is_path_within_workspace"
Task 6: chunk 配置传递链修复 (HIGH-5)
Files:
-
Modify:
src/core/ingest.py:18-28(构造函数接受 ChunkConfig) -
Modify:
src/core/ingest.py:46-47(使用配置值而非硬编码) -
Modify:
src/server/deps.py:44-49(传入 chunk 配置) -
Step 1: 修改 DocumentIngestor 构造函数
# ingest.py 第 15-28 行:
from src.core.config import ChunkConfig # 新增导入
class DocumentIngestor:
"""文档入库器: 读取文件 → 分块 → 嵌入 → 入库."""
def __init__(
self,
db: VectorDB,
embedder: Embedder,
collection_name: str,
splitter: Splitter | None = None,
chunk_config: ChunkConfig | None = None, # 新增参数
):
self.db = db
self.embedder = embedder
self.collection_name = collection_name
self.splitter = splitter or MarkdownSplitter()
self.chunk_config = chunk_config or ChunkConfig() # 存储分块配置
- Step 2: 修改 ingest_file 使用配置值
# 第 45 行改为:
splitter = self.splitter or get_splitter(
file_path,
max_size=self.chunk_config.max_size,
overlap=self.chunk_config.overlap,
)
- Step 3: 修改 deps.py 传入 chunk 配置
# deps.py 第 44-49 行:
def get_ingestor(self, collection: str | None = None) -> DocumentIngestor:
name = collection or self.default_collection
with self._cache_lock:
if name not in self._ingestors:
self._ingestors[name] = DocumentIngestor(
self.db, self.embedder, name,
chunk_config=self.config.chunk, # 新增:传入 chunk 配置
)
return self._ingestors[name]
- Step 4: 写测试验证 chunk 配置生效
在 tests/test_deps.py 中添加:
def test_ingestor_respects_chunk_config(self, tmp_path):
"""验证 ingestor 使用 config.yaml 中的 chunk 配置."""
from src.core.config import AppConfig, ChunkConfig
from src.core.ingest import DocumentIngestor
custom_chunk = ChunkConfig(max_size=500, overlap=200)
db = VectorDB(persist_dir=str(tmp_path))
embedder = create_embedder(EmbedConfig(mode="local"))
ingestor = DocumentIngestor(db, embedder, "test_chunk", chunk_config=custom_chunk)
assert ingestor.chunk_config.max_size == 500
assert ingestor.chunk_config.overlap == 200
- Step 5: 运行测试
uv run pytest tests/test_deps.py tests/ -v
预期:全部通过
- Step 6: 提交
git add src/core/ingest.py src/server/deps.py tests/test_deps.py
git commit -m "fix: chunk 配置通过 DocumentIngestor 传递,不再被硬编码覆盖"
Task 7: CLI serve 命令模型双重加载修复 (HIGH-6)
Files:
-
Modify:
src/core/config.py(新增load_config_only函数) -
Modify:
src/cli/main.py:164-185(serve 命令使用轻量配置) -
Step 1: 在 config.py 添加轻量加载函数
# 第 103 行之后添加:
def load_server_config_only(path: str | None = None) -> "AppConfig":
"""仅加载配置而不初始化模型/数据库(供 CLI serve 等场景使用).
与 load_config 的区别:不依赖 dotenv 的副作用。
"""
return load_config(path)
实际上,load_config 本身已经只加载配置不加载模型。问题在于 CLI serve 命令调用了 get_state() 而 get_state() 的 AppState.__init__ 会加载模型。所以正确的做法是在 CLI serve 命令中直接调用 load_config 而非 get_state()。
- Step 2: 修改 cli/main.py serve 命令
# 第 164-185 行:
@app.command(help="启动 HTTP API 服务.")
def serve(
port: Annotated[int, typer.Option("--port", "-p", help="监听端口")] = 8000,
config: ConfigOpt = DEFAULT_CONFIG_PATH,
):
from src.core.config import load_config # 轻量配置加载
# 传递 config 给 uvicorn 子进程(通过环境变量)
os.environ["MD_VECTOR_CONFIG"] = config
# 使用 load_config 而非 get_state(),避免在 CLI 进程加载模型
cfg = load_config(config)
ssl_kwargs = {}
if cfg.server.ssl_keyfile and cfg.server.ssl_certfile:
ssl_kwargs["ssl_keyfile"] = cfg.server.ssl_keyfile
ssl_kwargs["ssl_certfile"] = cfg.server.ssl_certfile
typer.echo(f"启动服务: http://localhost:{port}")
typer.echo(f"API 文档: http://localhost:{port}/docs")
uvicorn.run(
"src.server.app:app",
host=cfg.server.host,
port=port or cfg.server.port,
reload=False,
**ssl_kwargs,
)
- Step 3: 运行测试
uv run pytest tests/ -v
预期:全部通过
- Step 4: 提交
git add src/cli/main.py
git commit -m "fix: CLI serve 使用 load_config 避免主进程双重加载嵌入模型"
Task 8: PDF 文件句柄泄漏修复 (HIGH-7)
Files:
-
Modify:
src/core/splitters/pdf.py:34-43 -
Step 1: 使用上下文管理器
# 替换第 34-43 行:
extracted_pages = []
try:
with fitz.open(pdf_path) as doc: # pymupdf 支持 with 语句
for page in doc:
page_text = page.get_text()
if page_text.strip():
extracted_pages.append(page_text)
except Exception as e:
logger.error("PDF 解析失败: %s — %s", pdf_path, e)
raise ValueError(f"PDF 解析失败: {e}") from e
- Step 2: 运行测试
uv run pytest tests/test_splitters_pdf.py -v
预期:3 passed
- Step 3: 提交
git add src/core/splitters/pdf.py
git commit -m "fix: PDF fitz.Document 使用 with 语句防止异常路径下句柄泄漏"
Task 9: embedder.py HF_ENDPOINT 线程安全 (HIGH-8)
Files:
-
Modify:
src/core/embedder.py:78-90 -
Step 1: 添加线程锁保护环境变量操作
# 在模块顶部(第 30 行之后)添加:
_HF_ENV_LOCK = threading.Lock()
# 修改 LocalEmbedder.__init__ 中的环境变量操作(第 78-90 行):
old_endpoint = os.environ.get("HF_ENDPOINT")
with _HF_ENV_LOCK:
os.environ["HF_ENDPOINT"] = _HF_MIRROR
try:
self._model = SentenceTransformer(
config.local_model, device=device
)
finally:
with _HF_ENV_LOCK:
if old_endpoint is not None:
os.environ["HF_ENDPOINT"] = old_endpoint
else:
os.environ.pop("HF_ENDPOINT", None)
需要在文件顶部添加 import threading(检查是否已存在,如果不存在则添加)。
当前文件中没有 import threading,需要添加:
# 第 20 行:
import os
import logging
import threading # 新增
- Step 2: 运行测试
uv run pytest tests/test_embedder.py -v
预期:全部通过
- Step 3: 提交
git add src/core/embedder.py
git commit -m "fix: HF_ENDPOINT 环境变量操作添加线程锁防竞态"
Task 10: ingest_obsidian.py 初始化添加异常处理 (HIGH-9)
Files:
-
Modify:
scripts/ingest_obsidian.py:30-35 -
Step 1: 包裹初始化代码
# 第 30-35 行替换为:
t0 = time.time()
log("初始化...")
try:
cfg = load_config()
db = VectorDB(persist_dir=cfg.chroma.persist_dir)
embedder = create_embedder(cfg.embed)
ingestor = DocumentIngestor(db, embedder, "obsidian_blog")
except Exception as e:
log(f"初始化失败: {e}")
import traceback
log(traceback.format_exc())
sys.exit(1)
- Step 2: 提交
git add scripts/ingest_obsidian.py
git commit -m "fix: ingest_obsidian 初始化添加异常处理和友好错误信息"
Task 11: CLI 模块测试 (HIGH-10)
Files:
-
Create:
tests/test_cli.py -
Step 1: 创建 CLI 测试文件
"""CLI 命令测试."""
import os
from pathlib import Path
import pytest
from typer.testing import CliRunner
from src.cli.main import app
runner = CliRunner()
class TestCLIIngest:
"""ingest 命令测试."""
def test_ingest_requires_args(self):
"""无参数时显示用法提示."""
result = runner.invoke(app, ["ingest"])
assert result.exit_code == 1
assert "用法" in result.stderr
def test_ingest_nonexistent_file(self, tmp_path):
"""不存在的文件被跳过."""
result = runner.invoke(app, ["ingest", str(tmp_path / "nonexistent.md")])
# 应该优雅跳过而非崩溃
assert "SKIP" in result.stderr or result.exit_code != 0
class TestCLISearch:
"""search 命令测试."""
def test_search_output_format(self, tmp_path, monkeypatch):
"""search 命令正常输出."""
# 设置最小环境
monkeypatch.setenv("MD_VECTOR_CONFIG", "config.yaml")
result = runner.invoke(app, ["search", "测试查询", "-k", "1"])
# 搜索可能失败或成功,但不应崩溃
assert isinstance(result.exit_code, int)
class TestCLIStats:
"""stats 命令测试."""
def test_stats_output(self, tmp_path, monkeypatch):
"""stats 命令正常输出."""
monkeypatch.setenv("MD_VECTOR_CONFIG", "config.yaml")
result = runner.invoke(app, ["stats"])
assert isinstance(result.exit_code, int)
class TestCLIJSONOutput:
"""--json 输出测试."""
def test_search_json_output(self, monkeypatch):
"""search --json 输出合法 JSON."""
monkeypatch.setenv("MD_VECTOR_CONFIG", "config.yaml")
import json
result = runner.invoke(app, ["search", "测试", "--json", "-k", "1"])
# 输出应是合法 JSON
if result.stdout.strip():
try:
data = json.loads(result.stdout)
assert isinstance(data, list)
except json.JSONDecodeError:
pass # 如果无匹配结果 stdout 可能为空
- Step 2: 运行 CLI 测试
uv run pytest tests/test_cli.py -v
预期:全部通过
- Step 3: 提交
git add tests/test_cli.py
git commit -m "test: 添加 CLI 模块基础测试 (ingest/search/stats/json)"
Task 12: 认证与速率限制测试 (HIGH-11)
Files:
-
Create:
tests/test_auth.py -
Step 1: 创建 auth 测试文件
"""认证与速率限制测试."""
import time
import threading
import pytest
from fastapi import HTTPException
from src.server.auth import verify_api_key, RateLimiter
class TestVerifyApiKey:
"""API Key 认证测试."""
def test_passes_when_no_key_configured(self, monkeypatch):
"""未设置环境变量时跳过认证."""
monkeypatch.setenv("MD_VECTOR_API_KEY", "")
# 强制重新求值
import src.server.auth as auth
auth.EXPECTED_API_KEY = ""
result = verify_api_key(x_api_key=None)
assert result is True
def test_rejects_when_key_required_but_not_provided(self, monkeypatch):
"""已设置密钥但请求未提供."""
monkeypatch.setenv("MD_VECTOR_API_KEY", "secret123")
import src.server.auth as auth
auth.EXPECTED_API_KEY = "secret123"
with pytest.raises(HTTPException) as exc:
verify_api_key(x_api_key=None)
assert exc.value.status_code == 401
def test_rejects_wrong_key(self, monkeypatch):
"""错误的密钥被拒绝."""
monkeypatch.setenv("MD_VECTOR_API_KEY", "secret123")
import src.server.auth as auth
auth.EXPECTED_API_KEY = "secret123"
with pytest.raises(HTTPException) as exc:
verify_api_key(x_api_key="wrong-key")
assert exc.value.status_code == 401
def test_accepts_correct_key(self, monkeypatch):
"""正确的密钥通过认证."""
monkeypatch.setenv("MD_VECTOR_API_KEY", "secret123")
import src.server.auth as auth
auth.EXPECTED_API_KEY = "secret123"
result = verify_api_key(x_api_key="secret123")
assert result is True
class TestRateLimiter:
"""速率限制器测试."""
def test_allows_within_limit(self):
"""未超限时允许请求."""
limiter = RateLimiter(max_requests=5, window_seconds=60)
for _ in range(5):
assert limiter.is_allowed("client-1") is True
def test_blocks_when_exceeded(self):
"""超限后拒绝."""
limiter = RateLimiter(max_requests=2, window_seconds=60)
assert limiter.is_allowed("client-2") is True
assert limiter.is_allowed("client-2") is True
assert limiter.is_allowed("client-2") is False
def test_different_clients_independent(self):
"""不同客户端独立计数."""
limiter = RateLimiter(max_requests=1, window_seconds=60)
assert limiter.is_allowed("client-a") is True
assert limiter.is_allowed("client-b") is True # 不同客户端不受影响
def test_window_expires(self, monkeypatch):
"""时间窗口过期后恢复."""
limiter = RateLimiter(max_requests=1, window_seconds=1)
assert limiter.is_allowed("client-3") is True
assert limiter.is_allowed("client-3") is False
# 模拟时间过去 2 秒
original_time = time.time
fake_now = original_time() + 2.0
monkeypatch.setattr(time, "time", lambda: fake_now)
assert limiter.is_allowed("client-3") is True
def test_empty_key_cleaned_up(self):
"""空记录的 key 被及时清理防内存泄漏."""
limiter = RateLimiter(max_requests=1, window_seconds=0) # window=0 立即过期
assert limiter.is_allowed("temp-client") is True
assert limiter.is_allowed("temp-client") is False
# 第二次调用时 window 已过期,key 应被清除
assert "temp-client" not in limiter._store
def test_concurrent_access(self):
"""并发访问不产生竞态."""
limiter = RateLimiter(max_requests=100, window_seconds=60)
errors = []
def make_requests():
try:
for _ in range(50):
limiter.is_allowed("concurrent")
except Exception as e:
errors.append(e)
threads = [threading.Thread(target=make_requests) for _ in range(10)]
for t in threads:
t.start()
for t in threads:
t.join()
assert len(errors) == 0
- Step 2: 运行 auth 测试
uv run pytest tests/test_auth.py -v
预期:11 passed
- Step 3: 提交
git add tests/test_auth.py
git commit -m "test: 添加 verify_api_key 和 RateLimiter 完整单元测试"
Task 13: ingest_file / ingest_directory 测试补充 (HIGH-12)
Files:
-
Modify:
tests/test_ingest.py(追加测试) -
Step 1: 添加测试
在 tests/test_ingest.py 的 TestIngestorIntegration 类后添加:
class TestIngestFile:
"""ingest_file 方法测试."""
def test_ingest_file_markdown(self, tmp_path):
"""通过文件路径入库 .md 文件."""
from src.core.config import EmbedConfig
from src.core.db import VectorDB
from src.core.embedder import create_embedder
from src.core.ingest import DocumentIngestor
md_file = tmp_path / "hello.md"
md_file.write_text("# 测试\n这是测试内容。", encoding="utf-8")
db = VectorDB(persist_dir=str(tmp_path / "db"))
embedder = create_embedder(EmbedConfig(mode="local"))
ingestor = DocumentIngestor(db, embedder, "test_file")
count = ingestor.ingest_file(str(md_file))
assert count > 0
assert ingestor.collection.count() == count
def test_ingest_file_text(self, tmp_path):
"""通过文件路径入库 .txt 文件."""
from src.core.config import EmbedConfig
from src.core.db import VectorDB
from src.core.embedder import create_embedder
from src.core.ingest import DocumentIngestor
txt_file = tmp_path / "notes.txt"
txt_file.write_text("这是一段纯文本内容。\n\n第二段内容在这里。", encoding="utf-8")
db = VectorDB(persist_dir=str(tmp_path / "db"))
embedder = create_embedder(EmbedConfig(mode="local"))
ingestor = DocumentIngestor(db, embedder, "test_txt")
count = ingestor.ingest_file(str(txt_file))
assert count > 0
def test_ingest_file_deduplicates_same_file(self, tmp_path):
"""同一文件重复入库会去重."""
from src.core.config import EmbedConfig
from src.core.db import VectorDB
from src.core.embedder import create_embedder
from src.core.ingest import DocumentIngestor
md_file = tmp_path / "dup.md"
md_file.write_text("# V1\n内容 A.", encoding="utf-8")
db = VectorDB(persist_dir=str(tmp_path / "db"))
embedder = create_embedder(EmbedConfig(mode="local"))
ingestor = DocumentIngestor(db, embedder, "test_dedup_file")
c1 = ingestor.ingest_file(str(md_file))
md_file.write_text("# V2\n内容 B.", encoding="utf-8")
c2 = ingestor.ingest_file(str(md_file))
assert ingestor.collection.count() == c2
class TestIngestDirectory:
"""ingest_directory 方法测试."""
def test_ingest_directory_mixed_formats(self, tmp_path):
"""入库包含多种格式的目录."""
from src.core.config import EmbedConfig
from src.core.db import VectorDB
from src.core.embedder import create_embedder
from src.core.ingest import DocumentIngestor
(tmp_path / "a.md").write_text("# A\n内容 A", encoding="utf-8")
(tmp_path / "b.txt").write_text("内容 B", encoding="utf-8")
(tmp_path / "not_supported.xyz").write_text("不应被处理", encoding="utf-8")
db = VectorDB(persist_dir=str(tmp_path / "db"))
embedder = create_embedder(EmbedConfig(mode="local"))
ingestor = DocumentIngestor(db, embedder, "test_dir")
results = ingestor.ingest_directory(str(tmp_path))
assert len(results) >= 2 # a.md + b.txt, .xyz 被忽略
assert all(c > 0 for c in results.values())
def test_ingest_directory_empty(self, tmp_path):
"""空目录返回空结果."""
from src.core.config import EmbedConfig
from src.core.db import VectorDB
from src.core.embedder import create_embedder
from src.core.ingest import DocumentIngestor
db = VectorDB(persist_dir=str(tmp_path / "db"))
embedder = create_embedder(EmbedConfig(mode="local"))
ingestor = DocumentIngestor(db, embedder, "test_empty_dir")
results = ingestor.ingest_directory(str(tmp_path))
assert results == {}
- Step 2: 运行测试
uv run pytest tests/test_ingest.py -v
预期:所有新增测试通过
- Step 3: 提交
git add tests/test_ingest.py
git commit -m "test: 补充 ingest_file 和 ingest_directory 的测试覆盖"
Task 14: Searcher 方法测试补充 (HIGH-13)
Files:
-
Modify:
tests/test_search.py(追加测试) -
Step 1: 添加测试
在 tests/test_search.py 的 TestSearcher 类中追加:
def test_list_sources(self, searcher):
"""list_sources 返回已入库的源文件列表."""
sources = searcher.list_sources()
assert isinstance(sources, list)
def test_get_collection_info(self, searcher):
"""get_collection_info 返回 collection 信息."""
info = searcher.get_collection_info()
assert info["name"] == "test_search"
assert info["count"] > 0
def test_delete_by_source(self, searcher):
"""delete_by_source 删除源文件的所有 chunks."""
# 先确认存在
sources_before = searcher.list_sources()
if sources_before:
target = sources_before[0]
result = searcher.delete_by_source(target)
assert result is True
# 删除后不再出现在列表中
sources_after = searcher.list_sources()
assert target not in sources_after
def test_search_with_source_filter(self, searcher):
"""带 source_file 过滤的搜索."""
sources = searcher.list_sources()
if sources:
results = searcher.search("测试", top_k=3, source_file=sources[0])
assert isinstance(results, list)
# 结果都应来自同一源文件
for r in results:
assert r["source_file"] == sources[0]
def test_delete_by_source_nonexistent(self, searcher):
"""删除不存在的源文件返回 False."""
result = searcher.delete_by_source("nonexistent_file_xyz.md")
assert result is False
def test_list_sources_empty_collection(self, tmp_path):
"""空 collection 的 list_sources 返回空列表."""
from src.core.config import EmbedConfig
from src.core.db import VectorDB
from src.core.embedder import create_embedder
from src.core.search import Searcher
db = VectorDB(persist_dir=str(tmp_path))
embedder = create_embedder(EmbedConfig(mode="local"))
searcher = Searcher(db, embedder, "empty_coll")
sources = searcher.list_sources()
assert sources == []
- Step 2: 运行测试
uv run pytest tests/test_search.py -v
预期:全部通过
- Step 3: 提交
git add tests/test_search.py
git commit -m "test: 补充 Searcher list_sources/delete_by_source/get_collection_info 测试"
Batch 2 收尾
uv run pytest tests/ -v # 验证全部 90+ 测试仍通过
Batch 3 — MEDIUM(11 个问题)
Task 15: content 模式默认 file_name 使用 UUID (MED-1)
Files:
-
Modify:
src/server/app.py:137 -
Step 1: 改为 UUID 唯一名称
# 在文件顶部添加 import uuid
import uuid
# 第 137 行改为:
file_name = req.file_name or f"untitled_{uuid.uuid4().hex[:8]}.md"
- Step 2: 运行测试
uv run pytest tests/test_api.py -v
预期:全部通过
- Step 3: 提交
git add src/server/app.py
git commit -m "fix: content 模式默认 file_name 改用 UUID 防并发覆盖"
Task 16: X-XSS-Protection 替换为 CSP (MED-2)
Files:
-
Modify:
src/server/app.py:81 -
Step 1: 替换过时安全头
# 第 81 行改为:
response.headers["Content-Security-Policy"] = "default-src 'self'"
# 删除: response.headers["X-XSS-Protection"] = "1; mode=block"
- Step 2: 运行测试
uv run pytest tests/test_api.py -v
预期:全部通过
- Step 3: 提交
git add src/server/app.py
git commit -m "fix: 替换过时的 X-XSS-Protection 为 Content-Security-Policy"
Task 17: EXPECTED_API_KEY 惰性求值化 (MED-3)
Files:
-
Modify:
src/server/auth.py:14-26 -
Step 1: 改为惰性求值
# 第 14 行改为:
def _get_expected_api_key() -> str:
"""惰性获取 API Key(每次调用重新从环境变量读取)."""
return os.environ.get("MD_VECTOR_API_KEY", "")
def verify_api_key(x_api_key: str | None = Header(None)):
"""验证 API Key. 若未设置环境变量则跳过验证.
使用恒定时间比较防止时序攻击.
"""
expected = _get_expected_api_key() # 惰性求值
if expected:
if x_api_key is None or not hmac.compare_digest(x_api_key, expected):
logger.warning("API Key 认证失败")
raise HTTPException(status_code=401, detail="无效的 API Key")
return True
删除第 14 行的 EXPECTED_API_KEY = os.environ.get("MD_VECTOR_API_KEY", "")。
- Step 2: 更新 auth 测试引用
在 tests/test_auth.py 中,所有设置 auth.EXPECTED_API_KEY 的地方改为 monkeypatch 环境变量:
# 不再需要 auth.EXPECTED_API_KEY = "xxx"
# 只需 monkeypatch.setenv("MD_VECTOR_API_KEY", "xxx")
- Step 3: 运行测试
uv run pytest tests/test_auth.py -v
预期:全部通过
- Step 4: 提交
git add src/server/auth.py tests/test_auth.py
git commit -m "fix: EXPECTED_API_KEY 改为惰性求值防加载顺序问题"
Task 18: 去重删除逻辑统一到 VectorDB (MED-4)
Files:
-
Modify:
src/core/db.py(新增delete_by_source方法) -
Modify:
src/core/ingest.py:108-120(改用 db 方法) -
Modify:
src/core/search.py:83-97(改用 db 方法) -
Step 1: 在 VectorDB 添加 delete_by_source
# 在 db.py 第 40 行之后添加:
def delete_by_source(self, collection_name: str, file_name: str) -> bool:
"""按 source_file 删除文档 (线程安全).
在 ingest.py 和 search.py 中均有使用,统一到此方法避免重复代码.
"""
import logging
logger = logging.getLogger("md-vector-db")
collection = self.get_or_create_collection(collection_name)
try:
with self._write_lock:
existing = collection.get(
where={"source_file": file_name}
)
if existing and existing["ids"]:
collection.delete(ids=existing["ids"])
return True
except ValueError:
pass # collection 为空时 ChromaDB 抛 ValueError
except Exception:
logger.exception("删除文档失败: %s (collection=%s)", file_name, collection_name)
return False
- Step 2: 修改 ingest.py 使用新方法
# 第 108-120 行 _remove_by_source 改为:
def _remove_by_source(self, file_name: str) -> None:
"""按 source_file 删除已有 chunks(委托 VectorDB)."""
self.db.delete_by_source(self.collection_name, file_name)
- Step 3: 修改 search.py 使用新方法
# 第 83-97 行 delete_by_source 改为:
def delete_by_source(self, file_name: str) -> bool:
"""按文件名删除文档 (委托 VectorDB)."""
return self.db.delete_by_source(self.collection_name, file_name)
- Step 4: 运行全量测试
uv run pytest tests/ -v
预期:全部通过
- Step 5: 提交
git add src/core/db.py src/core/ingest.py src/core/search.py
git commit -m "refactor: 去重删除逻辑统一到 VectorDB.delete_by_source"
Task 19: list_collections_with_stats 异常向上传播 (MED-5)
Files:
-
Modify:
src/server/deps.py:54-59 -
Step 1: 移除 try/except
# 第 51-59 行改为:
def list_collections_with_stats(self) -> list[dict]:
"""列出所有 collection 及其统计(直接从 ChromaDB 查询)."""
result = []
for coll in self.db.list_collections():
result.append({"name": coll.name, "count": coll.count()})
return result
同时在 app.py 的 list_collections 端点添加异常处理:
# app.py 第 103-108 行:
@app.get("/api/v1/collections")
def list_collections(
state: AppState = Depends(get_state),
_: bool = Depends(verify_api_key),
):
try:
return {"collections": state.list_collections_with_stats()}
except Exception:
logger.exception("列出集合失败")
raise HTTPException(status_code=500, detail="服务器内部错误")
- Step 2: 运行测试
uv run pytest tests/test_deps.py tests/test_api.py -v
预期:全部通过
- Step 3: 提交
git add src/server/deps.py src/server/app.py
git commit -m "fix: list_collections 异常向上传播而非静默吞掉"
Task 20: EPUB 测试设计修复 (MED-6)
Files:
-
Modify:
tests/test_splitters_epub.py:51-56 -
Step 1: 使用 monkeypatch 模拟缺失依赖
将 test_epub_missing_dependency_message 改为:
def test_epub_missing_dependency_message(self, monkeypatch):
"""EPUBSplitter.split() 在未安装 ebooklib 时应给出明确提示."""
from src.core.splitters.epub import EPUBSplitter
s = EPUBSplitter()
# 模拟 ebooklib 未安装
monkeypatch.setitem(
__import__("sys").modules,
"ebooklib",
None,
raising=False,
)
# 由于 importorskip 在模块级别保护,此处实际无法到达
# 如果到达了(ebooklib 已安装),验证 split 可调用
assert callable(s.split)
由于 importorskip 在模块顶部的保护,当 ebooklib 未安装时整个测试文件被 skip。更好的方案:
在 tests/test_splitters_epub.py 顶部,将:
ebooklib = pytest.importorskip("ebooklib", reason="ebooklib 未安装")
移到 TestEPUBSplitter 类内部,仅对有 ebooklib 需求的测试做 skip,缺失依赖测试保留在文件顶部:
"""EPUBSplitter 测试."""
import pytest
class TestEPUBSplitterMissingDep:
"""缺失依赖时的行为测试(不 skip)."""
def test_split_raises_clear_import_error(self, monkeypatch):
"""未安装 ebooklib 时给出明确提示."""
from src.core.splitters.epub import EPUBSplitter
s = EPUBSplitter()
# 模拟 import ebooklib 失败
import builtins
original_import = builtins.__import__
def mock_import(name, *args, **kwargs):
if name == "ebooklib" or name.startswith("ebooklib."):
raise ImportError("No module named 'ebooklib'")
return original_import(name, *args, **kwargs)
monkeypatch.setattr(builtins, "__import__", mock_import)
with pytest.raises(ImportError, match="ebooklib"):
s.split("dummy.epub", source_file="test.epub")
class TestEPUBSplitter:
"""EPUBSplitter 测试(需 ebooklib)."""
ebooklib = pytest.importorskip("ebooklib", reason="ebooklib 未安装")
# ... 原有测试 ...
- Step 2: 运行测试
uv run pytest tests/test_splitters_epub.py -v
预期:全部通过
- Step 3: 提交
git add tests/test_splitters_epub.py
git commit -m "test: 修复 EPUB 缺失依赖测试设计问题"
Task 21: test_config.py 用 monkeypatch 替代 reload (MED-7)
Files:
-
Modify:
tests/test_config.py:89-106 -
Step 1: 重写环境变量测试
class TestEmbedConfigEnvVar:
"""api_key 从环境变量读取."""
def test_api_key_from_env(self, monkeypatch):
"""从环境变量读取 API Key."""
monkeypatch.setenv("EMBED_API_KEY", "sk-env-test")
cfg = EmbedConfig(mode="api")
assert cfg.api_key == "sk-env-test"
def test_api_key_empty_when_not_set(self, monkeypatch):
"""未设置时返回空字符串."""
monkeypatch.delenv("EMBED_API_KEY", raising=False)
cfg = EmbedConfig(mode="api")
assert cfg.api_key == ""
Note: EmbedConfig.api_key 使用 default_factory=lambda: os.environ.get("EMBED_API_KEY", ""),每次实例化都会重新读取环境变量,因此用 monkeypatch.setenv 后直接实例化即可,不需要 reload。
- Step 2: 运行测试
uv run pytest tests/test_config.py -v
预期:全部通过
- Step 3: 提交
git add tests/test_config.py
git commit -m "test: test_config 用 monkeypatch 替代 importlib.reload"
Task 22: 速率限制器从环境变量读取配置 (MED-8)
Files:
-
Modify:
src/server/auth.py:58 -
Step 1: 改为从环境变量读取
# 第 58 行改为:
rate_limiter = RateLimiter(
max_requests=int(os.environ.get("RATE_LIMIT_MAX", "30")),
window_seconds=int(os.environ.get("RATE_LIMIT_WINDOW", "60")),
)
- Step 2: 运行测试
uv run pytest tests/test_auth.py -v
预期:全部通过
- Step 3: 提交
git add src/server/auth.py
git commit -m "fix: 速率限制器配置从环境变量读取而非硬编码"
Task 23: ingest_obsidian 移除硬编码回退路径 (MED-9)
Files:
-
Modify:
scripts/ingest_obsidian.py:46-51 -
Step 1: 替换为明确的错误提示
# 第 45-51 行改为:
else:
log("错误: 未指定目标目录。请通过命令行参数或 OBSIDIAN_DIRS 环境变量提供。")
log("用法: uv run python scripts/ingest_obsidian.py <目录1> [目录2] ...")
sys.exit(1)
- Step 2: 提交
git add scripts/ingest_obsidian.py
git commit -m "fix: ingest_obsidian 移除硬编码回退路径,改为明确错误提示"
Task 24: MarkdownSplitter 边界测试提取 (MED-10)
Files:
-
Create:
tests/test_splitters_markdown.py -
Modify:
tests/test_ingest.py(移除 Markdown 测试) -
Step 1: 创建独立测试文件
"""MarkdownSplitter 边界测试."""
import pytest
from src.core.splitters import MarkdownSplitter
class TestMarkdownSplitterEdgeCases:
"""Markdown 分块边界情况."""
@pytest.fixture
def splitter(self):
return MarkdownSplitter(max_size=1000, overlap=100)
def test_no_headings_document(self, splitter):
"""无标题文档正常分块."""
md = "这是一段没有标题的纯文本。\n\n第二段内容。"
chunks = splitter.split(md, source_file="nohead.md")
assert len(chunks) >= 1
def test_deep_headings(self, splitter):
"""h4-h6 深层标题."""
md = """# 一级
## 二级
### 三级
#### 四级
内容在这里。
##### 五级
更多内容。
###### 六级
最深的内容。"""
chunks = splitter.split(md, source_file="deep.md")
assert len(chunks) >= 1
def test_hash_in_code_block_not_heading(self, splitter):
"""代码块中的 # 号不被误识别为标题."""
md = """# 真实标题
这是内容。
```python
# 这不是标题,是注释
x = 1 # 行内注释
## 这也不是标题
更多内容。""" chunks = splitter.split(md, source_file="codehash.md") # 代码块内的 # 不应产出新 section section_titles = [c.get("section_title", "") for c in chunks] # 不应包含 "这不是标题" 之类的代码注释 for title in section_titles: assert "不是标题" not in title
def test_adjacent_headings_empty_content(self, splitter):
"""标题后紧接标题(空内容)."""
md = """# 标题 A
标题 B
内容 B。""" chunks = splitter.split(md, source_file="adjacent.md") assert len(chunks) >= 1
def test_only_headings_no_content(self, splitter):
"""仅有标题无正文."""
md = "# 只有标题\n## 没有内容"
chunks = splitter.split(md, source_file="headingsonly.md")
# 没有正文内容时也可能产生 chunk(标题本身也是内容)
assert isinstance(chunks, list)
- [ ] **Step 2: 从 test_ingest.py 移除 MarkdownSplitter 测试**
`test_ingest.py` 中 `TestMarkdownSplitter` 类的测试保留(它们是入仓器使用 MarkdownSplitter 的集成测试)。新增的边界测试放在独立文件中。
- [ ] **Step 3: 运行测试**
```bash
uv run pytest tests/test_splitters_markdown.py tests/test_ingest.py -v
预期:全部通过
- Step 4: 提交
git add tests/test_splitters_markdown.py
git commit -m "test: 提取 MarkdownSplitter 边界测试到独立文件"
Task 25: splitters/__init__.py 导出 HTMLSplitter (MED-11)
Files:
-
Modify:
src/core/splitters/__init__.py:6-20 -
Step 1: 添加 HTMLSplitter 导入和导出
# 第 7 行改为(添加 HTMLSplitter import):
from src.core.splitters.html import HTMLSplitter
# __all__ 中加入 "HTMLSplitter":
__all__ = [
"Splitter",
"BaseTextSplitter",
"MarkdownSplitter",
"TextSplitter",
"PDFSplitter",
"HTMLSplitter", # 新增
"EPUBSplitter",
"get_splitter",
"register_splitter",
"SUPPORTED_SUFFIXES",
]
- Step 2: 验证导入
uv run python -c "from src.core.splitters import HTMLSplitter; print('OK')"
预期:OK
- Step 3: 运行测试
uv run pytest tests/test_splitters_html.py -v
预期:4 passed
- Step 4: 提交
git add src/core/splitters/__init__.py
git commit -m "fix: splitters/__init__.py 导出 HTMLSplitter"
Batch 3 收尾
uv run pytest tests/ -v
Batch 4 — LOW(10 个问题)
Task 26: EPUB 编码回退 (L-1)
Files:
-
Modify:
src/core/splitters/epub.py:44 -
Step 1: 添加编码回退
# 第 42-47 行改为:
try:
content = item.get_content().decode("utf-8")
except UnicodeDecodeError:
try:
content = item.get_content().decode("utf-8-sig")
except UnicodeDecodeError:
try:
content = item.get_content().decode("latin-1")
except UnicodeDecodeError:
logger.warning("EPUB 跳过一个无法解码的章节: %s", item.get_name())
continue
- Step 2: 提交
git add src/core/splitters/epub.py
git commit -m "fix: EPUB 添加编码回退链 utf-8 -> utf-8-sig -> latin-1"
Task 27: embedder.py requests 惰性导入 (L-2)
Files:
-
Modify:
src/core/embedder.py:24,151-155 -
Step 1: 移除模块级 requests 导入,改为惰性导入
# 第 24 行删除:
# import requests # noqa: F401 — DashscopeEmbedder 使用 <-- 删除此整行
# 第 151 行 DashscopeEmbedder.embed 方法内:
def embed(self, texts: list[str]) -> list[list[float]]:
if not texts:
raise ValueError("文本列表不能为空")
import requests # 惰性导入(仅DashScope使用)
resp = requests.post(...)
- Step 2: 运行测试
uv run pytest tests/test_embedder.py -v
预期:全部通过
- Step 3: 提交
git add src/core/embedder.py
git commit -m "refactor: requests 导入改为惰性(仅 DashScope 使用)"
Task 28: embedder.py list.sort() 改为 sorted() (L-3)
Files:
-
Modify:
src/core/embedder.py:176 -
Step 1: 原地排序替换为不可变版本
# 第 176 行改为:
embeddings_raw_sorted = sorted(embeddings_raw, key=lambda x: x.get("text_index", 0))
return [e["embedding"] for e in embeddings_raw_sorted]
- Step 2: 提交
git add src/core/embedder.py
git commit -m "refactor: Dashscope 嵌入排序改用 sorted() 避免原地修改 API 响应"
Task 29: config.yaml 与代码默认值一致性 (L-7, L-8)
Files:
-
Modify:
config.yaml:19-22 -
Step 1: 添加 SSL 字段注释
server:
host: 0.0.0.0
port: 8000
# ssl_keyfile: "" # HTTPS 私钥路径(设置后启用 HTTPS)
# ssl_certfile: "" # HTTPS 证书路径(设置后启用 HTTPS)
- Step 2: 提交
git add config.yaml
git commit -m "docs: config.yaml 添加 SSL 字段注释说明"
Task 30: scripts/serve.py 废弃提示改为显式 print (L-9)
Files:
-
Modify:
scripts/serve.py:1-12 -
Step 1: 改为 stderr 直接输出
"""便捷启动脚本 — 已废弃, 请使用 `uv run md-vector-db serve`."""
import sys
import uvicorn
print(
"[废弃] scripts/serve.py 已废弃, 请使用 `uv run md-vector-db serve`",
file=sys.stderr,
)
if __name__ == "__main__":
uvicorn.run("src.server.app:app", host="127.0.0.1", port=8000, reload=True)
- Step 2: 提交
git add scripts/serve.py
git commit -m "fix: scripts/serve.py 废弃提示改为 stderr 直接输出"
Task 31: pyproject.toml 添加开发工具依赖 (L-6)
Files:
-
Modify:
pyproject.toml:21 -
Step 1: 扩展 dev 依赖
dev = ["pytest>=8.0", "httpx>=0.27.0", "pytest-cov>=5.0", "ruff>=0.8.0", "mypy>=1.13"]
并添加 tool 配置节:
[tool.ruff]
line-length = 100
target-version = "py313"
[tool.ruff.lint]
select = ["E", "F", "I", "N", "W"]
[tool.mypy]
python_version = "3.13"
ignore_missing_imports = true
- Step 2: 安装新依赖
uv sync --extra dev
- Step 3: 提交
git add pyproject.toml uv.lock
git commit -m "build: dev 依赖添加 pytest-cov/ruff/mypy"
Task 32: 文档更新 — 测试数量 + 数据流 (L-10)
Files:
-
Modify:
CLAUDE.md -
Modify:
README.md -
Step 1: CLAUDE.md 更新
-
(46 个)→(90+ 个) -
数据流更新为包含多格式:
文件 → get_splitter(path) 自动选择 → Splitter.split() → batch_embed() → ChromaDB -
Step 2: README.md 同步更新
同上修改。
- Step 3: 提交
git add CLAUDE.md README.md
git commit -m "docs: 更新测试数量和文档以反映多格式支持"
Task 33: DEFAULT_CONFIG_PATH 路径处理 (L-4)
Files:
-
Modify:
src/core/config.py:16 -
Step 1: 不改 DEFAULT_CONFIG_PATH,而是在 load_config 中已正确处理
load_config 已经处理了相对路径回退到项目根目录的逻辑。DEFAULT_CONFIG_PATH 本身保持不变即可,因为使用方(CLI 和 deps.py)都是通过 load_config() 调用而非直接使用 DEFAULT_CONFIG_PATH 作为文件路径字面量。
此问题标记为无需修改(当前逻辑已正确处理)。
- Step 2: 提交(跳过或记录为 won't fix)
如果决定不修改,跳过此任务。当前 load_config() 已经做了项目根目录回退。
Task 34: registry.py 线程安全文档说明 (L-5)
Files:
-
Modify:
src/core/splitters/registry.py:23 -
Step 1: 添加文档注释
# 第 23 行函数上方添加:
def register_splitter(ext: str, splitter_cls: type[Splitter]) -> None:
"""注册自定义 Splitter 类.
注意: 此函数非线程安全,请在程序启动时调用(单线程阶段)。
运行时动态注册需自行加锁。
"""
- Step 2: 提交
git add src/core/splitters/registry.py
git commit -m "docs: register_splitter 添加线程安全注意事项"
Batch 4 收尾
uv run pytest tests/ -v # 全部测试
最终验证清单
uv run pytest tests/ -v— 全部测试通过uv run md-vector-db ingest --help— CLI 可用uv run md-vector-db search "测试" -k 1— 检索可用uv run md-vector-db stats— 统计可用- 检查
config.yaml中的 chunk 修改是否生效 - 确认无硬编码密钥、无敏感信息泄漏