chore: snapshot in-progress question-gen work before preflight fixes

This commit is contained in:
2026-07-16 04:12:21 -04:00
parent 11a5545f57
commit a4c429b247
39 changed files with 738 additions and 283 deletions
+3
View File
@@ -1,4 +1,5 @@
"""core/agent/protocols.py 单元测试。"""
from __future__ import annotations
from typing import Any
@@ -18,9 +19,11 @@ class _FakeDispatcher:
def test_fake_dispatcher_satisfies_protocol() -> None:
assert isinstance(_FakeDispatcher(), ToolDispatcher)
def test_plain_object_not_dispatcher() -> None:
assert not isinstance(object(), ToolDispatcher)
def test_hookspec_can_register() -> None:
pm = pluggy.PluginManager("agent_loop")
pm.add_hookspecs(AgentLoopSpec)
+10 -3
View File
@@ -1,4 +1,5 @@
"""core/agent/types.py 单元测试。"""
from __future__ import annotations
from core.agent.types import LoopResult, Step
@@ -31,12 +32,18 @@ class TestLoopResult:
def test_with_steps(self) -> None:
step = Step(
thought="t", reflect={}, plan={},
thought="t",
reflect={},
plan={},
tool_call={"tool": "t", "args": {}},
tool_output="o", raw_content="r", call_id="c",
tool_output="o",
raw_content="r",
call_id="c",
)
lr = LoopResult(
result={"answer": "42"}, steps=[step], steps_used=1,
result={"answer": "42"},
steps=[step],
steps_used=1,
token_usage={"prompt_tokens": 100, "completion_tokens": 50},
stop_reason="finished",
)
+48 -15
View File
@@ -1,11 +1,10 @@
"""core/protocols.py 单元测试 — 验证 Protocol 可 runtime_checkable。"""
from __future__ import annotations
from pathlib import Path
from typing import Any
import pytest
from core.protocols import LLMProvider, TelemetryRecorder, VLMProvider
from core.types import LLMResponse
@@ -19,9 +18,17 @@ class _FakeLLM:
parent_call_id: str | None = None,
) -> LLMResponse:
return LLMResponse(
content="ok", thinking="", model="m", provider="p",
prompt_tokens=1, completion_tokens=1, latency_ms=1,
ttft_ms=None, max_inter_token_ms=None, cache_hit=False, call_id="c",
content="ok",
thinking="",
model="m",
provider="p",
prompt_tokens=1,
completion_tokens=1,
latency_ms=1,
ttft_ms=None,
max_inter_token_ms=None,
cache_hit=False,
call_id="c",
)
@@ -35,19 +42,39 @@ class _FakeVLM:
parent_call_id: str | None = None,
) -> LLMResponse:
return LLMResponse(
content="ok", thinking="", model="m", provider="p",
prompt_tokens=1, completion_tokens=1, latency_ms=1,
ttft_ms=None, max_inter_token_ms=None, cache_hit=False, call_id="c",
content="ok",
thinking="",
model="m",
provider="p",
prompt_tokens=1,
completion_tokens=1,
latency_ms=1,
ttft_ms=None,
max_inter_token_ms=None,
cache_hit=False,
call_id="c",
)
class _FakeTelemetry:
async def record_llm_call(
self, *, call_id: str, parent_call_id: str | None, session_id: str | None,
model_name: str, provider: str, messages: str, response: str, thinking: str,
prompt_tokens: int, completion_tokens: int, latency_ms: int,
ttft_ms: float | None, max_inter_token_ms: float | None,
cache_hit: bool, error: str | None,
self,
*,
call_id: str,
parent_call_id: str | None,
session_id: str | None,
model_name: str,
provider: str,
messages: str,
response: str,
thinking: str,
prompt_tokens: int,
completion_tokens: int,
latency_ms: int,
ttft_ms: float | None,
max_inter_token_ms: float | None,
cache_hit: bool,
error: str | None,
) -> None:
pass
@@ -55,12 +82,15 @@ class _FakeTelemetry:
def test_fake_llm_satisfies_protocol() -> None:
assert isinstance(_FakeLLM(), LLMProvider)
def test_fake_vlm_satisfies_protocol() -> None:
assert isinstance(_FakeVLM(), VLMProvider)
def test_fake_telemetry_satisfies_protocol() -> None:
assert isinstance(_FakeTelemetry(), TelemetryRecorder)
def test_plain_object_does_not_satisfy() -> None:
assert not isinstance(object(), LLMProvider)
assert not isinstance(object(), VLMProvider)
@@ -80,8 +110,11 @@ class TestPoolStrategyProtocol:
class FakeStrategy:
def build(self, questions, correctness, config):
return Pools(
diagnosis=[], validation=[], test=[],
baseline_run_id="", baseline_val_accuracy=0.0,
diagnosis=[],
validation=[],
test=[],
baseline_run_id="",
baseline_val_accuracy=0.0,
)
def build_incremental(self, new_task_types, questions, correctness, config):
+9 -9
View File
@@ -14,21 +14,21 @@ from app.question_gen.families import (
get_family_for_slot,
)
# 12 种任务类型(来自 harness config
# Video-MME 12 种任务类型
ALL_TASK_TYPES: frozenset[str] = frozenset(
[
"Action Recognition",
"Action Reasoning",
"Action Prediction",
"Action Sequence",
"Attribute Perception",
"Counting Problem",
"Information Synopsis",
"Object Recognition",
"Object Reasoning",
"Object Interaction",
"Scene Understanding",
"Event Reasoning",
"Causal Reasoning",
"Temporal Reasoning",
"OCR Problems",
"Spatial Perception",
"Spatial Reasoning",
"Temporal Perception",
"Temporal Reasoning",
]
)
@@ -163,7 +163,7 @@ class TestGetFamilyForSlot:
def test_no_legal_family_raises(self) -> None:
"""所有 family 权重为 0 时合法族为空,应抛出 ValueError。"""
rng = random.Random(0)
# Spatial Reasoning 合法族: RETRIEVAL, REASONING, VISUAL, SPATIAL
# Spatial Reasoning 合法族: SPATIAL
# 如果 ratios 中只含不合法的族名,应抛错
with pytest.raises(ValueError, match="合法"):
get_family_for_slot(
+1 -3
View File
@@ -4,12 +4,10 @@
的核心路径与边界条件。
"""
import math
import pytest
from core.evolution.gate import compute_e_value, gate_decision, probation_verdict
from core.evolution.types import GateParams, GateVerdict
from core.evolution.types import GateParams
_PARAMS = GateParams(
e_confirm=20.0,
-1
View File
@@ -11,7 +11,6 @@ import sys
from pathlib import Path
import numpy as np
import pytest
# 确保项目根目录在 sys.path 中
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
+4 -12
View File
@@ -112,9 +112,7 @@ class TestHarnessLog:
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
row = conn.execute(
"SELECT status FROM _runs WHERE run_id = ?", (run_id,)
).fetchone()
row = conn.execute("SELECT status FROM _runs WHERE run_id = ?", (run_id,)).fetchone()
conn.close()
assert row["status"] == "failed"
@@ -130,9 +128,7 @@ class TestHarnessLog:
log.insert("t", {"x": 42})
conn = sqlite3.connect(db_path)
count = conn.execute(
"SELECT COUNT(*) FROM _runs WHERE run_id = ?", (run_id,)
).fetchone()[0]
count = conn.execute("SELECT COUNT(*) FROM _runs WHERE run_id = ?", (run_id,)).fetchone()[0]
conn.close()
assert count == 1, "ON CONFLICT DO UPDATE 应保证 _runs 只有一行"
@@ -151,9 +147,7 @@ class TestHarnessLog:
with HarnessLog(db_path, run_id) as log:
log.create_table("batch", {"epoch": "INTEGER", "loss": "REAL"})
log.insert_many("batch", records)
rows = log.query(
"SELECT * FROM batch WHERE run_id = ? ORDER BY epoch", (run_id,)
)
rows = log.query("SELECT * FROM batch WHERE run_id = ? ORDER BY epoch", (run_id,))
assert len(rows) == 5
assert [r["epoch"] for r in rows] == [0, 1, 2, 3, 4]
@@ -163,9 +157,7 @@ class TestHarnessLog:
with HarnessLog(db_path, run_id) as log:
log.log_event("train_start", {"epoch": 1, "lr": 0.001})
log.log_event("train_end", {"epoch": 1, "loss": 0.42})
rows = log.query(
"SELECT * FROM _events WHERE run_id = ? ORDER BY id", (run_id,)
)
rows = log.query("SELECT * FROM _events WHERE run_id = ? ORDER BY id", (run_id,))
assert len(rows) == 2
assert rows[0]["event_type"] == "train_start"
+31 -48
View File
@@ -9,7 +9,6 @@ import pytest
from app.harness.store import (
_parse_version,
_write_meta,
advance_version,
extract_run_db,
init_seed,
@@ -21,7 +20,6 @@ from app.harness.store import (
read_seed,
)
# ---------------------------------------------------------------------------
# _parse_version
# ---------------------------------------------------------------------------
@@ -52,7 +50,7 @@ class TestParseVersion:
class TestListVersions:
"""list_versions 按数字排序,v10 排在 v2 后。"""
def test_list_versions_numeric_sort(self, tmp_path: "Path") -> None:
def test_list_versions_numeric_sort(self, tmp_path: Path) -> None:
"""v10 必须排在 v2 后面(非字典序)。"""
store = tmp_path / "store"
resource = store / "skills"
@@ -62,11 +60,11 @@ class TestListVersions:
result = list_versions(store, "skills")
assert result == ["v1", "v2", "v3", "v10", "v20"]
def test_list_versions_empty(self, tmp_path: "Path") -> None:
def test_list_versions_empty(self, tmp_path: Path) -> None:
store = tmp_path / "store"
assert list_versions(store, "skills") == []
def test_list_versions_ignores_non_version_dirs(self, tmp_path: "Path") -> None:
def test_list_versions_ignores_non_version_dirs(self, tmp_path: Path) -> None:
"""非 v\\d+ 格式的目录被忽略。"""
store = tmp_path / "store"
resource = store / "skills"
@@ -85,11 +83,11 @@ class TestListVersions:
class TestNextVersion:
"""next_version 返回下一个可用版本号。"""
def test_next_version_empty(self, tmp_path: "Path") -> None:
def test_next_version_empty(self, tmp_path: Path) -> None:
store = tmp_path / "store"
assert next_version(store, "skills") == "v1"
def test_next_version_after_existing(self, tmp_path: "Path") -> None:
def test_next_version_after_existing(self, tmp_path: Path) -> None:
store = tmp_path / "store"
resource = store / "skills"
resource.mkdir(parents=True)
@@ -97,7 +95,7 @@ class TestNextVersion:
(resource / "v2").mkdir()
assert next_version(store, "skills") == "v3"
def test_next_version_with_gap(self, tmp_path: "Path") -> None:
def test_next_version_with_gap(self, tmp_path: Path) -> None:
"""v1 和 v10 之间有 gapnext 应为 v11。"""
store = tmp_path / "store"
resource = store / "skills"
@@ -115,7 +113,7 @@ class TestNextVersion:
class TestAdvanceVersion:
"""advance_version copytree + _write_meta。"""
def test_advance_version(self, tmp_path: "Path") -> None:
def test_advance_version(self, tmp_path: Path) -> None:
store = tmp_path / "store"
resource = store / "skills"
resource.mkdir(parents=True)
@@ -149,7 +147,7 @@ class TestAdvanceVersion:
class TestInitStore:
"""init_store 初始化 Store 目录结构。"""
def test_init_store(self, tmp_path: "Path") -> None:
def test_init_store(self, tmp_path: Path) -> None:
videos = tmp_path / "videos_src"
videos.mkdir()
(videos / "v001").mkdir()
@@ -172,13 +170,11 @@ class TestInitStore:
assert (store / "skills" / "v1" / "search.md").read_text() == "skill"
assert (store / "prompts" / "v1" / "system.md").read_text() == "prompt"
skills_meta = json.loads(
(store / "skills" / "v1" / "meta.json").read_text()
)
skills_meta = json.loads((store / "skills" / "v1" / "meta.json").read_text())
assert skills_meta["version"] == "v1"
assert skills_meta["source"] == "manual"
def test_init_store_exists_raises(self, tmp_path: "Path") -> None:
def test_init_store_exists_raises(self, tmp_path: Path) -> None:
store = tmp_path / "store"
store.mkdir()
with pytest.raises(FileExistsError, match="Store 已存在"):
@@ -205,13 +201,9 @@ def _make_seed_fixtures(tmp_path):
baseline_db = tmp_path / "base.db"
conn = sqlite3.connect(baseline_db)
conn.execute(
"CREATE TABLE _runs (run_id TEXT PRIMARY KEY, status TEXT)"
)
conn.execute("CREATE TABLE _runs (run_id TEXT PRIMARY KEY, status TEXT)")
conn.execute("INSERT INTO _runs VALUES ('r1', 'done')")
conn.execute(
"CREATE TABLE predictions (run_id TEXT, question_id TEXT, answer TEXT)"
)
conn.execute("CREATE TABLE predictions (run_id TEXT, question_id TEXT, answer TEXT)")
conn.execute("INSERT INTO predictions VALUES ('r1', 'q1', 'A')")
conn.commit()
conn.close()
@@ -222,7 +214,7 @@ def _make_seed_fixtures(tmp_path):
class TestInitSeed:
"""init_seed 创建种子目录。"""
def test_init_seed(self, tmp_path: "Path") -> None:
def test_init_seed(self, tmp_path: Path) -> None:
store, skills_dir, prompts_dir, baseline_db = _make_seed_fixtures(tmp_path)
seed_dir = init_seed(
store,
@@ -245,25 +237,23 @@ class TestInitSeed:
assert meta["description"] == "初始种子"
assert "created_at" in meta
def test_init_seed_exists_raises(self, tmp_path: "Path") -> None:
def test_init_seed_exists_raises(self, tmp_path: Path) -> None:
store, skills_dir, prompts_dir, baseline_db = _make_seed_fixtures(tmp_path)
init_seed(store, "dup", skills_dir, prompts_dir, baseline_db, "r1", None, "first")
with pytest.raises(FileExistsError, match="种子已存在"):
init_seed(
store, "dup", skills_dir, prompts_dir, baseline_db, "r1", None, "second"
)
init_seed(store, "dup", skills_dir, prompts_dir, baseline_db, "r1", None, "second")
class TestListSeeds:
"""list_seeds 列出所有种子。"""
def test_list_seeds(self, tmp_path: "Path") -> None:
def test_list_seeds(self, tmp_path: Path) -> None:
store, skills_dir, prompts_dir, baseline_db = _make_seed_fixtures(tmp_path)
init_seed(store, "beta", skills_dir, prompts_dir, baseline_db, "r1", None, "b")
init_seed(store, "alpha", skills_dir, prompts_dir, baseline_db, "r1", None, "a")
assert list_seeds(store) == ["alpha", "beta"]
def test_list_seeds_empty(self, tmp_path: "Path") -> None:
def test_list_seeds_empty(self, tmp_path: Path) -> None:
store = tmp_path / "store"
assert list_seeds(store) == []
@@ -271,14 +261,14 @@ class TestListSeeds:
class TestReadSeed:
"""read_seed 读取 seed.json。"""
def test_read_seed(self, tmp_path: "Path") -> None:
def test_read_seed(self, tmp_path: Path) -> None:
store, skills_dir, prompts_dir, baseline_db = _make_seed_fixtures(tmp_path)
init_seed(store, "s1", skills_dir, prompts_dir, baseline_db, "r1", None, "desc")
meta = read_seed(store, "s1")
assert meta["baseline_run_id"] == "r1"
assert meta["description"] == "desc"
def test_read_seed_not_found(self, tmp_path: "Path") -> None:
def test_read_seed_not_found(self, tmp_path: Path) -> None:
store = tmp_path / "store"
store.mkdir()
with pytest.raises(FileNotFoundError, match="种子不存在"):
@@ -296,22 +286,17 @@ class TestExtractRunDb:
def _make_src_db(self, path):
"""创建带 _runs + predictions 表的源 db。"""
conn = sqlite3.connect(path)
conn.execute(
"CREATE TABLE _runs (run_id TEXT PRIMARY KEY, status TEXT)"
)
conn.execute("CREATE TABLE _runs (run_id TEXT PRIMARY KEY, status TEXT)")
conn.execute("INSERT INTO _runs VALUES ('r1', 'done')")
conn.execute("INSERT INTO _runs VALUES ('r2', 'done')")
conn.execute(
"CREATE TABLE predictions "
"(run_id TEXT, question_id TEXT, answer TEXT)"
)
conn.execute("CREATE TABLE predictions (run_id TEXT, question_id TEXT, answer TEXT)")
conn.execute("INSERT INTO predictions VALUES ('r1', 'q1', 'A')")
conn.execute("INSERT INTO predictions VALUES ('r1', 'q2', 'B')")
conn.execute("INSERT INTO predictions VALUES ('r2', 'q1', 'C')")
conn.commit()
conn.close()
def test_extract_run_db_preserves_pk(self, tmp_path: "Path") -> None:
def test_extract_run_db_preserves_pk(self, tmp_path: Path) -> None:
"""原始 CREATE 保留主键约束。"""
src = tmp_path / "src.db"
dst = tmp_path / "dst.db"
@@ -334,7 +319,7 @@ class TestExtractRunDb:
assert len(preds) == 2
conn.close()
def test_extract_run_db_missing_table(self, tmp_path: "Path") -> None:
def test_extract_run_db_missing_table(self, tmp_path: Path) -> None:
"""源 db 无目标表时报错。"""
src = tmp_path / "src.db"
dst = tmp_path / "dst.db"
@@ -345,7 +330,7 @@ class TestExtractRunDb:
with pytest.raises(RuntimeError, match="源 db 无表"):
extract_run_db(src, dst, "r1")
def test_extract_run_db_no_rows(self, tmp_path: "Path") -> None:
def test_extract_run_db_no_rows(self, tmp_path: Path) -> None:
"""目标 run_id 不存在时报错。"""
src = tmp_path / "src.db"
dst = tmp_path / "dst.db"
@@ -382,9 +367,7 @@ def _make_promote_fixtures(tmp_path):
prompts_version TEXT
)
""")
conn.execute(
"INSERT INTO _runs VALUES ('eval_001', 'v2', 'v2')"
)
conn.execute("INSERT INTO _runs VALUES ('eval_001', 'v2', 'v2')")
conn.execute("""
CREATE TABLE predictions (
run_id TEXT, question_id TEXT, answer TEXT
@@ -400,7 +383,7 @@ def _make_promote_fixtures(tmp_path):
class TestPromoteToSeed:
"""promote_to_seed 固化 workspace 版本为种子。"""
def test_promote_to_seed_success(self, tmp_path: "Path") -> None:
def test_promote_to_seed_success(self, tmp_path: Path) -> None:
ws, store = _make_promote_fixtures(tmp_path)
seed_dir = promote_to_seed(ws, store, "v2", "eval_001", "evolved-seed", "good")
assert seed_dir == store / "seeds" / "evolved-seed"
@@ -411,13 +394,13 @@ class TestPromoteToSeed:
assert meta["baseline_run_id"] == "eval_001"
assert meta["parent"] == "ws:v2"
def test_promote_to_seed_version_mismatch(self, tmp_path: "Path") -> None:
def test_promote_to_seed_version_mismatch(self, tmp_path: Path) -> None:
"""eval run 的 skills_version 与 --version 不符时报错。"""
ws, store = _make_promote_fixtures(tmp_path)
with pytest.raises(ValueError, match="版本.*不符"):
promote_to_seed(ws, store, "v3", "eval_001", "bad", "mismatch")
def test_promote_to_seed_null_version(self, tmp_path: "Path") -> None:
def test_promote_to_seed_null_version(self, tmp_path: Path) -> None:
"""eval run 的版本为 NULL 时报错。"""
ws = tmp_path / "ws2"
ws.mkdir()
@@ -438,20 +421,20 @@ class TestPromoteToSeed:
with pytest.raises(ValueError, match="NULL"):
promote_to_seed(ws, store, "v1", "eval_null", "bad", "null ver")
def test_promote_to_seed_run_not_found(self, tmp_path: "Path") -> None:
def test_promote_to_seed_run_not_found(self, tmp_path: Path) -> None:
"""eval run 不存在时报错。"""
ws, store = _make_promote_fixtures(tmp_path)
with pytest.raises(ValueError, match="eval run 不存在"):
promote_to_seed(ws, store, "v1", "nonexistent", "bad", "no run")
def test_promote_cleanup_tmp_db(self, tmp_path: "Path") -> None:
def test_promote_cleanup_tmp_db(self, tmp_path: Path) -> None:
"""finally 清理临时 db 文件。"""
ws, store = _make_promote_fixtures(tmp_path)
promote_to_seed(ws, store, "v2", "eval_001", "clean-test", "cleanup")
# 临时 db 应已清理
assert not (ws / "_promote_tmp.db").exists()
def test_promote_cleanup_tmp_db_on_error(self, tmp_path: "Path") -> None:
def test_promote_cleanup_tmp_db_on_error(self, tmp_path: Path) -> None:
"""即使 init_seed 失败(同名种子),临时 db 也应被清理。"""
ws, store = _make_promote_fixtures(tmp_path)
promote_to_seed(ws, store, "v2", "eval_001", "first", "first time")
+3 -9
View File
@@ -259,9 +259,7 @@ class TestApplyPatch:
def test_insert_after_protected_skip(self) -> None:
content = "# Title\n\nFROZEN BLOCK\n\nrest"
edits = [{"op": "insert_after", "target": "FROZEN BLOCK", "content": "nope"}]
out, reports = apply_patch_with_report(
content, edits, protected_spans=["FROZEN BLOCK"]
)
out, reports = apply_patch_with_report(content, edits, protected_spans=["FROZEN BLOCK"])
assert out == content
assert reports[0]["status"] == "skipped_protected"
@@ -276,9 +274,7 @@ class TestApplyPatch:
def test_replace_protected_skip(self) -> None:
content = "# Title\n\nprotected\n\nrest"
edits = [{"op": "replace", "target": "protected", "content": "nope"}]
out, reports = apply_patch_with_report(
content, edits, protected_spans=["protected"]
)
out, reports = apply_patch_with_report(content, edits, protected_spans=["protected"])
assert "protected" in out
assert reports[0]["status"] == "skipped_protected"
@@ -335,9 +331,7 @@ class TestApplyPatch:
{"op": "append", "target": "", "content": "prefix text"},
{"op": "replace", "target": protected, "content": "nope"},
]
out, reports = apply_patch_with_report(
content, edits, protected_spans=[protected]
)
out, reports = apply_patch_with_report(content, edits, protected_spans=[protected])
# append 在 FREEZE 之前插入,坐标右移后 replace 仍能检测冻结区
assert reports[1]["status"] == "skipped_protected"
+1 -3
View File
@@ -61,9 +61,7 @@ async def test_cache_miss_returns_none(fake_redis: object) -> None:
@pytest.mark.asyncio
async def test_cache_roundtrip(
fake_redis: object, sample_response: LLMResponse
) -> None:
async def test_cache_roundtrip(fake_redis: object, sample_response: LLMResponse) -> None:
"""set 后 get 应返回相同内容。"""
cache = RedisResponseCache(redis=fake_redis, ttl_s=300)
await cache.set("gpt-4o", MESSAGES, sample_response)
+1 -1
View File
@@ -14,7 +14,7 @@ from app.tree.index import (
L3Node,
TreeIndex,
)
from app.tree.repair.detector import NodeIssue, detect_issues
from app.tree.repair.detector import detect_issues
if TYPE_CHECKING:
from pathlib import Path
+8
View File
@@ -1,4 +1,5 @@
"""修复管线断点续跑 progress 管理测试。"""
from __future__ import annotations
import asyncio
@@ -10,6 +11,7 @@ import pytest
def test_load_progress_missing_file(tmp_path):
"""progress 文件不存在时返回空集合。"""
from tools.repair_trees import load_progress
result = load_progress(tmp_path / "nonexistent.json")
assert result == set()
@@ -17,6 +19,7 @@ def test_load_progress_missing_file(tmp_path):
def test_load_progress_valid_file(tmp_path):
"""正常读取已有 progress 文件。"""
from tools.repair_trees import load_progress
path = tmp_path / "progress.json"
path.write_text(json.dumps({"finished_video_ids": ["vid_a", "vid_b"]}))
result = load_progress(path)
@@ -26,6 +29,7 @@ def test_load_progress_valid_file(tmp_path):
def test_load_progress_corrupted_file(tmp_path):
"""损坏的 JSON 文件返回空集合(不抛异常)。"""
from tools.repair_trees import load_progress
path = tmp_path / "progress.json"
path.write_text("{invalid json")
result = load_progress(path)
@@ -36,6 +40,7 @@ def test_load_progress_corrupted_file(tmp_path):
async def test_save_progress_atomic(tmp_path):
"""save_progress 原子写入,并发调用不丢失更新。"""
from tools.repair_trees import save_progress
path = tmp_path / "progress.json"
lock = asyncio.Lock()
await save_progress(path, lock, "vid_a")
@@ -48,6 +53,7 @@ async def test_save_progress_atomic(tmp_path):
async def test_save_progress_concurrent(tmp_path):
"""16 路并发 save_progress 不丢失更新。"""
from tools.repair_trees import save_progress
path = tmp_path / "progress.json"
lock = asyncio.Lock()
tasks = [save_progress(path, lock, f"vid_{i}") for i in range(16)]
@@ -59,6 +65,7 @@ async def test_save_progress_concurrent(tmp_path):
def test_should_skip_finished():
"""已在 finished 集合中的视频应跳过。"""
from tools.repair_trees import should_skip_video
finished = {"vid_a", "vid_b"}
assert should_skip_video("vid_a", finished, reaggregate_all=False) is True
assert should_skip_video("vid_c", finished, reaggregate_all=False) is False
@@ -67,5 +74,6 @@ def test_should_skip_finished():
def test_should_skip_reaggregate_all_forces_rerun():
"""--reaggregate-all 标志强制不跳过。"""
from tools.repair_trees import should_skip_video
finished = {"vid_a"}
assert should_skip_video("vid_a", finished, reaggregate_all=True) is False
+1 -1
View File
@@ -138,7 +138,7 @@ class TestQuestionGenStore:
slot_id="slot-0",
video_id="v002",
family="reasoning",
task_type="Action Sequence",
task_type="Action Reasoning",
skill_target="M2",
attempt=1,
question_text="为什么这样做?",
+35 -54
View File
@@ -219,11 +219,7 @@ class TestCheckAnchors:
def test_no_info_statement_not_counted(self) -> None:
"""声明句"未包含…相关…信息"不计入 n_assertions。"""
anchor_map = {"s1": "行1"}
summary = (
"[相关信息]\n"
"- 该节点未包含与问题直接相关的信息\n"
"- 关键发现(s1)"
)
summary = "[相关信息]\n- 该节点未包含与问题直接相关的信息\n- 关键发现(s1)"
_, stats = check_anchors(summary, anchor_map)
assert stats["n_assertions"] == 1 # 声明句不计
assert stats["n_anchored"] == 1
@@ -271,9 +267,7 @@ class TestAssembleAnchoredOutput:
"""ids_expand 模式:保留行号 + 附加引文段。"""
anchor_map = {"s1": "第一行内容", "s2": "第二行内容"}
summary = "关键发现(s1,s2)"
result, stats = assemble_anchored_output(
summary, anchor_map, "ids_expand"
)
result, stats = assemble_anchored_output(summary, anchor_map, "ids_expand")
assert "(s1,s2)" in result
assert "[引文]" in result
assert 's1: "第一行内容"' in result
@@ -284,9 +278,7 @@ class TestAssembleAnchoredOutput:
"""expand_only 模式:剥除行号 + 附加引文段。"""
anchor_map = {"s1": "第一行内容"}
summary = "关键发现(s1)"
result, stats = assemble_anchored_output(
summary, anchor_map, "expand_only"
)
result, stats = assemble_anchored_output(summary, anchor_map, "expand_only")
assert "(s1)" not in result
assert "[引文]" in result
assert 's1: "第一行内容"' in result
@@ -297,21 +289,15 @@ class TestAssembleAnchoredOutput:
anchor_map = {f"s{i}": f"{i}" for i in range(1, 10)}
refs = ",".join(f"s{i}" for i in range(1, 10))
summary = f"发现({refs})"
result, stats = assemble_anchored_output(
summary, anchor_map, "ids_expand"
)
result, stats = assemble_anchored_output(summary, anchor_map, "ids_expand")
assert stats["n_expanded"] == 5
def test_max_chars_cap(self) -> None:
"""总字符超过 800 时截断。"""
anchor_map = {
f"s{i}": "A" * 300 for i in range(1, 6)
}
anchor_map = {f"s{i}": "A" * 300 for i in range(1, 6)}
refs = ",".join(f"s{i}" for i in range(1, 6))
summary = f"发现({refs})"
result, stats = assemble_anchored_output(
summary, anchor_map, "ids_expand"
)
result, stats = assemble_anchored_output(summary, anchor_map, "ids_expand")
# 300 字符原文 + 前缀 ≈ 310+ 每条,800 / 310 ≈ 2 条
assert stats["n_expanded"] < 5
@@ -319,9 +305,7 @@ class TestAssembleAnchoredOutput:
"""单行超 200 字符截断并标记 n_trunc。"""
anchor_map = {"s1": "A" * 250}
summary = "发现(s1)"
result, stats = assemble_anchored_output(
summary, anchor_map, "ids_expand"
)
result, stats = assemble_anchored_output(summary, anchor_map, "ids_expand")
assert stats["n_trunc"] == 1
assert "" in result
@@ -388,10 +372,12 @@ class TestSummarizeNode:
async def test_anchor_mode(self, prompts_dir: Path) -> None:
"""锚模式:check_anchors + assemble。"""
anchor_map = {"s1": "第一行", "s2": "第二行"}
llm = FakeLLMProvider([
"[相关信息]\n- 关键发现(s1)\n- 补充(s2)",
"核实通过",
])
llm = FakeLLMProvider(
[
"[相关信息]\n- 关键发现(s1)\n- 补充(s2)",
"核实通过",
]
)
result = await summarize_node(
llm,
"带行号的内容",
@@ -409,10 +395,12 @@ class TestSummarizeNode:
"""锚模式 stats_sink 回调接收完整统计。"""
anchor_map = {"s1": "第一行"}
collected: list[dict] = []
llm = FakeLLMProvider([
"[相关信息]\n- 关键发现(s1)",
"核实通过",
])
llm = FakeLLMProvider(
[
"[相关信息]\n- 关键发现(s1)",
"核实通过",
]
)
await summarize_node(
llm,
"内容",
@@ -471,9 +459,7 @@ class TestSummarizeChildren:
{"id": "n2", "time_range": (30.0, 60.0), "summary": "中间"},
]
llm = FakeLLMProvider(["相关性标注结果", "核实通过"])
result = await summarize_children(
llm, children_info, "问题", prompts_dir
)
result = await summarize_children(llm, children_info, "问题", prompts_dir)
assert "相关性标注结果" in result
assert "[核实] 核实通过" in result
@@ -484,25 +470,19 @@ class TestSummarizeChildren:
{"id": "n1", "time_range": (0.0, 30.0), "summary": "开头"},
]
llm = FailingLLMProvider("网络错误")
result = await summarize_children(
llm, children_info, "问题", prompts_dir
)
result = await summarize_children(llm, children_info, "问题", prompts_dir)
assert "n1" in result
assert "0-30s" in result
assert "开头" in result
@pytest.mark.asyncio()
async def test_verify_failure_returns_extract_only(
self, prompts_dir: Path
) -> None:
async def test_verify_failure_returns_extract_only(self, prompts_dir: Path) -> None:
"""核实轮失败仍返回提取结果。"""
children_info = [
{"id": "n1", "time_range": (0.0, 30.0), "summary": "开头"},
]
llm = FailOnNthLLMProvider(["标注结果"], fail_on=2)
result = await summarize_children(
llm, children_info, "问题", prompts_dir
)
result = await summarize_children(llm, children_info, "问题", prompts_dir)
assert "标注结果" in result
@@ -513,19 +493,22 @@ class TestSummarizeNodesBatch:
async def test_batch_normal(self, prompts_dir: Path) -> None:
"""并发三个节点,结果顺序与输入一致。"""
# 每个节点需要 2 轮 LLM 调用(提取 + 核实)
llm = FakeLLMProvider([
"摘要A", "核实A",
"摘要B", "核实B",
"摘要C", "核实C",
])
llm = FakeLLMProvider(
[
"摘要A",
"核实A",
"摘要B",
"核实B",
"摘要C",
"核实C",
]
)
items = [
("n1", "内容1", "extra1"),
("n2", "内容2", "extra2"),
("n3", "内容3", "extra3"),
]
results = await summarize_nodes_batch(
llm, items, "问题", prompts_dir
)
results = await summarize_nodes_batch(llm, items, "问题", prompts_dir)
assert len(results) == 3
assert results[0][0] == "n1"
assert results[1][0] == "n2"
@@ -538,7 +521,5 @@ class TestSummarizeNodesBatch:
async def test_batch_empty(self, prompts_dir: Path) -> None:
"""空列表返回空结果。"""
llm = FakeLLMProvider([])
results = await summarize_nodes_batch(
llm, [], "问题", prompts_dir
)
results = await summarize_nodes_batch(llm, [], "问题", prompts_dir)
assert results == []
+11 -33
View File
@@ -126,9 +126,7 @@ def prompts_dir(tmp_path: Path) -> Path:
class TestObserveFrameNormal:
"""两轮正常执行(verify=True)。"""
def test_two_round_normal(
self, frame_files: list[Path], prompts_dir: Path
) -> None:
def test_two_round_normal(self, frame_files: list[Path], prompts_dir: Path) -> None:
from app.search.vision import observe_frame
vlm = FakeVLMProvider(responses=["raw evidence", "verified ok"])
@@ -158,9 +156,7 @@ class TestObserveFrameNormal:
class TestObserveFrameExtractOnly:
"""verify=False 仅执行提取轮。"""
def test_extract_only(
self, frame_files: list[Path], prompts_dir: Path
) -> None:
def test_extract_only(self, frame_files: list[Path], prompts_dir: Path) -> None:
from app.search.vision import observe_frame
vlm = FakeVLMProvider(responses=["only extract"])
@@ -183,9 +179,7 @@ class TestObserveFrameExtractOnly:
class TestObserveFrameOCRInjection:
"""OCR 注入:文本非空时并置于问题前。"""
def test_ocr_injected(
self, frame_files: list[Path], prompts_dir: Path
) -> None:
def test_ocr_injected(self, frame_files: list[Path], prompts_dir: Path) -> None:
from app.search.vision import observe_frame
vlm = FakeVLMProvider(responses=["evidence with ocr"])
@@ -216,9 +210,7 @@ class TestObserveFrameOCRInjection:
class TestObserveFrameOCRFailDegrades:
"""OCR 转录抛出异常时降级:不注入 OCR、ocr_failed=1。"""
def test_ocr_failure_degrades(
self, frame_files: list[Path], prompts_dir: Path
) -> None:
def test_ocr_failure_degrades(self, frame_files: list[Path], prompts_dir: Path) -> None:
from app.search.vision import observe_frame
vlm = FakeVLMProvider(responses=["evidence no ocr"])
@@ -245,9 +237,7 @@ class TestObserveFrameOCRFailDegrades:
class TestObserveFrameOCRNone:
"""ocr=None 时不执行转录。"""
def test_ocr_none(
self, frame_files: list[Path], prompts_dir: Path
) -> None:
def test_ocr_none(self, frame_files: list[Path], prompts_dir: Path) -> None:
from app.search.vision import observe_frame
vlm = FakeVLMProvider(responses=["no ocr"])
@@ -274,9 +264,7 @@ class TestObserveFrameOCRNone:
class TestObserveFrameVLMExtractFailure:
"""VLM 提取轮失败 → 返回 [VL错误]。"""
def test_vlm_extract_failure(
self, frame_files: list[Path], prompts_dir: Path
) -> None:
def test_vlm_extract_failure(self, frame_files: list[Path], prompts_dir: Path) -> None:
from app.search.vision import observe_frame
vlm = FakeVLMProvider(raises=[RuntimeError("VLM timeout")])
@@ -302,9 +290,7 @@ class TestObserveFrameVLMExtractFailure:
class TestObserveFrameVLMVerifyFailureDegrades:
"""VLM 验证轮失败 → 降级:保留提取结果 + [验证] 跳过。"""
def test_vlm_verify_failure_degrades(
self, frame_files: list[Path], prompts_dir: Path
) -> None:
def test_vlm_verify_failure_degrades(self, frame_files: list[Path], prompts_dir: Path) -> None:
from app.search.vision import observe_frame
vlm = FakeVLMProvider(
@@ -359,9 +345,7 @@ class TestObserveFrameFileMissing:
class TestObserveFrameStatsKeys:
"""stats 包含全部五个预期键。"""
def test_stats_keys_complete(
self, frame_files: list[Path], prompts_dir: Path
) -> None:
def test_stats_keys_complete(self, frame_files: list[Path], prompts_dir: Path) -> None:
from app.search.vision import observe_frame
vlm = FakeVLMProvider(responses=["evidence"])
@@ -386,9 +370,7 @@ class TestObserveFrameStatsKeys:
class TestObserveFrameDiscrepancyAndAbstain:
"""VLM 返回含 '分歧''[证据不存在]' 时对应 stats 标记。"""
def test_discrepancy_flag(
self, frame_files: list[Path], prompts_dir: Path
) -> None:
def test_discrepancy_flag(self, frame_files: list[Path], prompts_dir: Path) -> None:
from app.search.vision import observe_frame
vlm = FakeVLMProvider(responses=["发现分歧:OCR 与画面不一致"])
@@ -408,9 +390,7 @@ class TestObserveFrameDiscrepancyAndAbstain:
assert collected[0]["discrepancy"] == 1
def test_abstain_flag(
self, frame_files: list[Path], prompts_dir: Path
) -> None:
def test_abstain_flag(self, frame_files: list[Path], prompts_dir: Path) -> None:
from app.search.vision import observe_frame
vlm = FakeVLMProvider(responses=["[证据不存在] 无法判断"])
@@ -434,9 +414,7 @@ class TestObserveFrameDiscrepancyAndAbstain:
class TestObserveFrameTelemetryPassthrough:
"""session_id 和 parent_call_id 透传到 VLM 调用。"""
def test_telemetry_passthrough(
self, frame_files: list[Path], prompts_dir: Path
) -> None:
def test_telemetry_passthrough(self, frame_files: list[Path], prompts_dir: Path) -> None:
from app.search.vision import observe_frame
vlm = FakeVLMProvider(responses=["evidence"])
+3
View File
@@ -4,16 +4,19 @@
def test_core_importable():
"""core 包可导入。"""
import core
assert core is not None
def test_app_importable():
"""app 包可导入。"""
import app
assert app is not None
def test_adapters_importable():
"""adapters 包可导入。"""
import adapters
assert adapters is not None
+10 -3
View File
@@ -1,4 +1,5 @@
"""adapters/telemetry.py 单元测试 — SQLiteTelemetryRecorder。"""
from __future__ import annotations
import sqlite3
@@ -9,11 +10,11 @@ import pytest
from adapters.telemetry import SQLiteTelemetryRecorder
from core.protocols import TelemetryRecorder
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture()
def db_path(tmp_path):
"""返回临时数据库路径。"""
@@ -51,6 +52,7 @@ def _make_call_kwargs(*, cache_hit: bool = False, error: str | None = None):
# Tests
# ---------------------------------------------------------------------------
def test_satisfies_protocol(recorder):
"""SQLiteTelemetryRecorder 满足 TelemetryRecorder Protocol。"""
assert isinstance(recorder, TelemetryRecorder)
@@ -86,7 +88,9 @@ async def test_record_with_error(recorder, db_path):
conn = sqlite3.connect(str(db_path))
conn.row_factory = sqlite3.Row
row = conn.execute("SELECT error FROM llm_calls WHERE call_id = ?", (kwargs["call_id"],)).fetchone()
row = conn.execute(
"SELECT error FROM llm_calls WHERE call_id = ?", (kwargs["call_id"],)
).fetchone()
conn.close()
assert row["error"] == "RateLimitError: 429"
@@ -100,7 +104,9 @@ async def test_record_cache_hit(recorder, db_path):
conn = sqlite3.connect(str(db_path))
conn.row_factory = sqlite3.Row
row = conn.execute("SELECT cache_hit FROM llm_calls WHERE call_id = ?", (kwargs["call_id"],)).fetchone()
row = conn.execute(
"SELECT cache_hit FROM llm_calls WHERE call_id = ?", (kwargs["call_id"],)
).fetchone()
conn.close()
assert row["cache_hit"] == 1
@@ -130,6 +136,7 @@ async def test_db_error_does_not_propagate(tmp_path):
async def test_concurrent_writes_no_lock_error(recorder, db_path):
"""16 路并发 record_llm_call 应全部成功,无 database is locked 错误。"""
import asyncio
tasks = []
for _ in range(16):
kwargs = _make_call_kwargs()