feat(harness): store.py — Store 版本操作 + Seed 管理
从 TRM4 core/workspace.py 拆出 Store + Seed 相关函数: - _parse_version / list_versions / next_version / advance_version - _write_meta / init_store - init_seed / list_seeds / read_seed - extract_run_db(保留原始 CREATE 语句重建主键约束) - promote_to_seed(强校验版本一致 + 非 NULL + finally 清理) 26 个测试全部通过,radon 复杂度 A (2.83)。 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,378 @@
|
|||||||
|
"""Store 版本操作 + Seed 管理。
|
||||||
|
|
||||||
|
Store 存储版本化资源(视频、题目、Skill、Prompt),
|
||||||
|
通过版本号(v1, v2, ...)管理资源的演化历史。
|
||||||
|
Seed 是可复现的实验起点,包含权重快照 + baseline 数据库。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import sqlite3
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def _now_iso() -> str:
|
||||||
|
"""返回当前 UTC 时间的 ISO 格式字符串。"""
|
||||||
|
return datetime.now(UTC).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_version(name: str) -> int:
|
||||||
|
"""解析版本目录名 ``v\\d+`` 为整数。
|
||||||
|
|
||||||
|
参数:
|
||||||
|
name: 版本目录名,如 ``"v1"``、``"v10"``。
|
||||||
|
|
||||||
|
返回:
|
||||||
|
版本号整数。
|
||||||
|
|
||||||
|
异常:
|
||||||
|
ValueError: 版本目录名格式不合法(不匹配 ``v\\d+``)。
|
||||||
|
"""
|
||||||
|
match = re.match(r"v(\d+)$", name)
|
||||||
|
if not match:
|
||||||
|
raise ValueError(f"无效版本号: {name}")
|
||||||
|
return int(match.group(1))
|
||||||
|
|
||||||
|
|
||||||
|
def list_versions(store_dir: Path, resource_type: str) -> list[str]:
|
||||||
|
"""列出 Store 中某类资源的所有版本号,按数字值排序。
|
||||||
|
|
||||||
|
按数字排序保证 v10 排在 v2 后面(而非字典序 v10 < v2)。
|
||||||
|
|
||||||
|
参数:
|
||||||
|
store_dir: Store 根目录。
|
||||||
|
resource_type: 资源类型路径,如 ``"skills"``、``"questions/generated"``。
|
||||||
|
|
||||||
|
返回:
|
||||||
|
排序后的版本号列表,如 ``["v1", "v2", "v10"]``。
|
||||||
|
"""
|
||||||
|
resource_dir = store_dir / resource_type
|
||||||
|
if not resource_dir.is_dir():
|
||||||
|
return []
|
||||||
|
versions = []
|
||||||
|
for entry in resource_dir.iterdir():
|
||||||
|
if entry.is_dir() and re.match(r"v\d+$", entry.name):
|
||||||
|
versions.append(entry.name)
|
||||||
|
return sorted(versions, key=_parse_version)
|
||||||
|
|
||||||
|
|
||||||
|
def next_version(store_dir: Path, resource_type: str) -> str:
|
||||||
|
"""返回某类资源的下一个可用版本号。
|
||||||
|
|
||||||
|
参数:
|
||||||
|
store_dir: Store 根目录。
|
||||||
|
resource_type: 资源类型路径。
|
||||||
|
|
||||||
|
返回:
|
||||||
|
下一个版本号字符串,如 ``"v3"``。
|
||||||
|
"""
|
||||||
|
versions = list_versions(store_dir, resource_type)
|
||||||
|
if not versions:
|
||||||
|
return "v1"
|
||||||
|
latest = _parse_version(versions[-1])
|
||||||
|
return f"v{latest + 1}"
|
||||||
|
|
||||||
|
|
||||||
|
def _write_meta(target_dir: Path, version: str, source: str, **extra: str | None) -> None:
|
||||||
|
"""写入版本元数据文件 ``meta.json``。
|
||||||
|
|
||||||
|
参数:
|
||||||
|
target_dir: 版本目录。
|
||||||
|
version: 版本号。
|
||||||
|
source: 来源标识(``"manual"`` / ``"evolution"`` / ``"auto-gen"``)。
|
||||||
|
**extra: 额外字段(parent, trigger_run, trigger_workspace, description)。
|
||||||
|
"""
|
||||||
|
meta = {
|
||||||
|
"version": version,
|
||||||
|
"created_at": _now_iso(),
|
||||||
|
"parent": extra.get("parent"),
|
||||||
|
"source": source,
|
||||||
|
"trigger_run": extra.get("trigger_run"),
|
||||||
|
"trigger_workspace": extra.get("trigger_workspace"),
|
||||||
|
"description": extra.get("description", ""),
|
||||||
|
}
|
||||||
|
(target_dir / "meta.json").write_text(json.dumps(meta, ensure_ascii=False, indent=2))
|
||||||
|
|
||||||
|
|
||||||
|
def advance_version(
|
||||||
|
store_dir: Path,
|
||||||
|
resource_type: str,
|
||||||
|
source_dir: Path,
|
||||||
|
meta: dict,
|
||||||
|
) -> str:
|
||||||
|
"""将 source_dir 的内容写入 Store 的下一个版本目录,写入 meta.json。
|
||||||
|
|
||||||
|
参数:
|
||||||
|
store_dir: Store 根目录。
|
||||||
|
resource_type: 资源类型路径,如 ``"skills"``、``"questions/generated"``。
|
||||||
|
source_dir: 包含新版本资源文件的源目录。
|
||||||
|
meta: 元数据字典,至少包含 ``source`` 字段。
|
||||||
|
|
||||||
|
返回:
|
||||||
|
新版本号字符串,如 ``"v2"``。
|
||||||
|
"""
|
||||||
|
version = next_version(store_dir, resource_type)
|
||||||
|
target = store_dir / resource_type / version
|
||||||
|
shutil.copytree(source_dir, target)
|
||||||
|
_write_meta(
|
||||||
|
target,
|
||||||
|
version,
|
||||||
|
meta.get("source", "manual"),
|
||||||
|
parent=meta.get("parent"),
|
||||||
|
trigger_run=meta.get("trigger_run"),
|
||||||
|
trigger_workspace=meta.get("trigger_workspace"),
|
||||||
|
description=meta.get("description", ""),
|
||||||
|
)
|
||||||
|
logger.info("Store 版本推进: {}/{}", resource_type, version)
|
||||||
|
return version
|
||||||
|
|
||||||
|
|
||||||
|
def init_store(
|
||||||
|
store_dir: Path,
|
||||||
|
videos_source: Path,
|
||||||
|
skills_dir: Path,
|
||||||
|
prompts_dir: Path,
|
||||||
|
) -> None:
|
||||||
|
"""初始化 Store:拷贝视频数据,创建 skills/v1、prompts/v1 和 questions 目录。
|
||||||
|
|
||||||
|
参数:
|
||||||
|
store_dir: Store 目标路径(不得已存在)。
|
||||||
|
videos_source: 视频数据源目录。
|
||||||
|
skills_dir: 初始 Skill 文件目录。
|
||||||
|
prompts_dir: 初始 Prompt 文件目录。
|
||||||
|
|
||||||
|
异常:
|
||||||
|
FileExistsError: Store 目录已存在。
|
||||||
|
"""
|
||||||
|
if store_dir.exists():
|
||||||
|
raise FileExistsError(f"Store 已存在: {store_dir}")
|
||||||
|
store_dir.mkdir(parents=True)
|
||||||
|
shutil.copytree(videos_source, store_dir / "videos")
|
||||||
|
(store_dir / "questions" / "benchmarks").mkdir(parents=True)
|
||||||
|
(store_dir / "questions" / "generated").mkdir(parents=True)
|
||||||
|
shutil.copytree(skills_dir, store_dir / "skills" / "v1")
|
||||||
|
_write_meta(
|
||||||
|
store_dir / "skills" / "v1",
|
||||||
|
"v1",
|
||||||
|
"manual",
|
||||||
|
description="手工创建的初始版本",
|
||||||
|
)
|
||||||
|
shutil.copytree(prompts_dir, store_dir / "prompts" / "v1")
|
||||||
|
_write_meta(
|
||||||
|
store_dir / "prompts" / "v1",
|
||||||
|
"v1",
|
||||||
|
"manual",
|
||||||
|
description="手工创建的初始版本",
|
||||||
|
)
|
||||||
|
logger.info("Store 初始化完成: {}", store_dir)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 种子库(Seed)函数
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def init_seed(
|
||||||
|
store_dir: Path,
|
||||||
|
name: str,
|
||||||
|
skills_dir: Path,
|
||||||
|
prompts_dir: Path,
|
||||||
|
baseline_db: Path,
|
||||||
|
baseline_run_id: str,
|
||||||
|
parent: str | None,
|
||||||
|
description: str,
|
||||||
|
) -> Path:
|
||||||
|
"""在 store/seeds/<name> 写一个种子:权重 + baseline.db + seed.json。
|
||||||
|
|
||||||
|
参数:
|
||||||
|
store_dir: Store 根目录。
|
||||||
|
name: 种子名(如 ``'initial'``、``'from-evolve-v20'``)。
|
||||||
|
skills_dir: 该版本 Skill 权重源目录。
|
||||||
|
prompts_dir: 该版本 Prompt 权重源目录。
|
||||||
|
baseline_db: 该版本全量记录 db(含 _runs + predictions 行)。
|
||||||
|
baseline_run_id: 全量记录的 run_id,fresh 时注入 build_pools。
|
||||||
|
parent: 来源(initial 为 None)。
|
||||||
|
description: 人类可读说明。
|
||||||
|
|
||||||
|
返回:
|
||||||
|
种子目录路径。
|
||||||
|
|
||||||
|
异常:
|
||||||
|
FileExistsError: 同名种子已存在(不覆盖)。
|
||||||
|
"""
|
||||||
|
seed_dir = store_dir / "seeds" / name
|
||||||
|
if seed_dir.exists():
|
||||||
|
raise FileExistsError(f"种子已存在,不覆盖: {seed_dir}")
|
||||||
|
seed_dir.mkdir(parents=True)
|
||||||
|
shutil.copytree(skills_dir, seed_dir / "skills")
|
||||||
|
shutil.copytree(prompts_dir, seed_dir / "prompts")
|
||||||
|
shutil.copy2(baseline_db, seed_dir / "baseline.db")
|
||||||
|
(seed_dir / "seed.json").write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"baseline_run_id": baseline_run_id,
|
||||||
|
"parent": parent,
|
||||||
|
"created_at": _now_iso(),
|
||||||
|
"description": description,
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
indent=2,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
logger.info("种子创建完成: {}", seed_dir)
|
||||||
|
return seed_dir
|
||||||
|
|
||||||
|
|
||||||
|
def list_seeds(store_dir: Path) -> list[str]:
|
||||||
|
"""列出 store/seeds 下所有种子名(按名排序)。
|
||||||
|
|
||||||
|
参数:
|
||||||
|
store_dir: Store 根目录。
|
||||||
|
|
||||||
|
返回:
|
||||||
|
种子名列表(仅含 seed.json 存在的目录),按名排序。
|
||||||
|
"""
|
||||||
|
seeds_root = store_dir / "seeds"
|
||||||
|
if not seeds_root.is_dir():
|
||||||
|
return []
|
||||||
|
return sorted(e.name for e in seeds_root.iterdir() if (e / "seed.json").exists())
|
||||||
|
|
||||||
|
|
||||||
|
def read_seed(store_dir: Path, name: str) -> dict:
|
||||||
|
"""读取种子 seed.json;不存在则报错。
|
||||||
|
|
||||||
|
参数:
|
||||||
|
store_dir: Store 根目录。
|
||||||
|
name: 种子名。
|
||||||
|
|
||||||
|
返回:
|
||||||
|
seed.json 解析后的字典。
|
||||||
|
|
||||||
|
异常:
|
||||||
|
FileNotFoundError: 该种子不存在。
|
||||||
|
"""
|
||||||
|
seed_json = store_dir / "seeds" / name / "seed.json"
|
||||||
|
if not seed_json.exists():
|
||||||
|
raise FileNotFoundError(f"种子不存在: {name}({seed_json})")
|
||||||
|
return json.loads(seed_json.read_text())
|
||||||
|
|
||||||
|
|
||||||
|
def extract_run_db(src_db: Path, dst_db: Path, run_id: str) -> None:
|
||||||
|
"""从 src_db 抽出某 run_id 的 _runs + predictions 行,写一个最小 db(种子 baseline.db)。
|
||||||
|
|
||||||
|
用源表的**原始 CREATE 语句**重建目标表,保留主键/列类型/约束——
|
||||||
|
``_runs.run_id TEXT PRIMARY KEY`` 是 HarnessLog ``INSERT OR IGNORE`` 去重的依据,
|
||||||
|
若 seed db 丢主键则续训/fresh-bootstrap 的去重失效。
|
||||||
|
|
||||||
|
参数:
|
||||||
|
src_db: 源 harness.db。
|
||||||
|
dst_db: 目标 db(不得已存在)。
|
||||||
|
run_id: 要抽取的 run。
|
||||||
|
|
||||||
|
异常:
|
||||||
|
RuntimeError: 源中无该表或无该 run 的行。
|
||||||
|
"""
|
||||||
|
src = sqlite3.connect(src_db)
|
||||||
|
dst = sqlite3.connect(dst_db)
|
||||||
|
try:
|
||||||
|
for table in ("_runs", "predictions"):
|
||||||
|
create_sql = src.execute(
|
||||||
|
"SELECT sql FROM sqlite_master WHERE type='table' AND name=?",
|
||||||
|
(table,),
|
||||||
|
).fetchone()
|
||||||
|
if create_sql is None or create_sql[0] is None:
|
||||||
|
raise RuntimeError(f"源 db 无表 {table}")
|
||||||
|
dst.execute(create_sql[0])
|
||||||
|
cols = [r[1] for r in src.execute(f"PRAGMA table_info({table})")]
|
||||||
|
col_sql = ", ".join(cols)
|
||||||
|
rows = src.execute(
|
||||||
|
f"SELECT {col_sql} FROM {table} WHERE run_id=?", (run_id,)
|
||||||
|
).fetchall()
|
||||||
|
if not rows:
|
||||||
|
raise RuntimeError(f"{table} 中无 run_id={run_id} 的行")
|
||||||
|
ph = ", ".join("?" * len(cols))
|
||||||
|
dst.executemany(f"INSERT INTO {table} ({col_sql}) VALUES ({ph})", rows)
|
||||||
|
dst.commit()
|
||||||
|
finally:
|
||||||
|
dst.close()
|
||||||
|
src.close()
|
||||||
|
|
||||||
|
|
||||||
|
def promote_to_seed(
|
||||||
|
workspace_dir: Path,
|
||||||
|
store_dir: Path,
|
||||||
|
version: str,
|
||||||
|
eval_run_id: str,
|
||||||
|
name: str,
|
||||||
|
description: str,
|
||||||
|
) -> Path:
|
||||||
|
"""把 workspace 的指定版本 + 配套 prompts + 指定 eval run 全量记录固化成新种子。
|
||||||
|
|
||||||
|
强校验 eval_run_id 对应的 _runs 行中 skills_version 必须与 version 一致,
|
||||||
|
且 skills_version/prompts_version 均不得为 NULL。
|
||||||
|
|
||||||
|
参数:
|
||||||
|
workspace_dir: 来源 workspace。
|
||||||
|
store_dir: Store 根目录。
|
||||||
|
version: skills 版本号。
|
||||||
|
eval_run_id: canonical eval run(其 _runs 行提供配套 prompts 版本与全量记录)。
|
||||||
|
name: 新种子名(冲突报错不覆盖)。
|
||||||
|
description: 说明。
|
||||||
|
|
||||||
|
返回:
|
||||||
|
新种子目录。
|
||||||
|
|
||||||
|
异常:
|
||||||
|
ValueError: eval_run_id 不存在,或其 skills_version 与 version 不符,或版本为 NULL。
|
||||||
|
FileExistsError: 同名种子已存在(由 init_seed 抛出)。
|
||||||
|
"""
|
||||||
|
con = sqlite3.connect(workspace_dir / "harness.db")
|
||||||
|
con.row_factory = sqlite3.Row
|
||||||
|
try:
|
||||||
|
row = con.execute(
|
||||||
|
"SELECT skills_version, prompts_version FROM _runs WHERE run_id=?",
|
||||||
|
(eval_run_id,),
|
||||||
|
).fetchone()
|
||||||
|
finally:
|
||||||
|
con.close()
|
||||||
|
|
||||||
|
if row is None:
|
||||||
|
raise ValueError(f"eval run 不存在: {eval_run_id}")
|
||||||
|
|
||||||
|
skills_v, prompts_v = row["skills_version"], row["prompts_version"]
|
||||||
|
|
||||||
|
# 强校验——eval run 的版本必须与 --version 一致,且不得为 NULL
|
||||||
|
if skills_v is None or prompts_v is None:
|
||||||
|
raise ValueError(f"eval run {eval_run_id} 的 _runs 版本对为 NULL(未回填?),无法 promote")
|
||||||
|
if skills_v != version:
|
||||||
|
raise ValueError(f"eval run {eval_run_id} 的版本 {skills_v} 与 --version {version} 不符")
|
||||||
|
|
||||||
|
tmp_db = workspace_dir / "_promote_tmp.db"
|
||||||
|
if tmp_db.exists():
|
||||||
|
tmp_db.unlink()
|
||||||
|
extract_run_db(workspace_dir / "harness.db", tmp_db, eval_run_id)
|
||||||
|
try:
|
||||||
|
seed_dir = init_seed(
|
||||||
|
store_dir,
|
||||||
|
name,
|
||||||
|
workspace_dir / "skills" / skills_v,
|
||||||
|
workspace_dir / "prompts" / prompts_v,
|
||||||
|
tmp_db,
|
||||||
|
baseline_run_id=eval_run_id,
|
||||||
|
parent=f"{workspace_dir.name}:{version}",
|
||||||
|
description=description,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
tmp_db.unlink()
|
||||||
|
|
||||||
|
logger.info("Promote 完成: {} -> {}", workspace_dir.name, seed_dir)
|
||||||
|
return seed_dir
|
||||||
@@ -0,0 +1,460 @@
|
|||||||
|
"""Store 版本操作 + Seed 管理的单元测试。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.harness.store import (
|
||||||
|
_parse_version,
|
||||||
|
_write_meta,
|
||||||
|
advance_version,
|
||||||
|
extract_run_db,
|
||||||
|
init_seed,
|
||||||
|
init_store,
|
||||||
|
list_seeds,
|
||||||
|
list_versions,
|
||||||
|
next_version,
|
||||||
|
promote_to_seed,
|
||||||
|
read_seed,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _parse_version
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseVersion:
|
||||||
|
"""_parse_version 解析 v\\d+ 格式版本号。"""
|
||||||
|
|
||||||
|
def test_parse_version_normal(self) -> None:
|
||||||
|
assert _parse_version("v1") == 1
|
||||||
|
assert _parse_version("v10") == 10
|
||||||
|
assert _parse_version("v999") == 999
|
||||||
|
|
||||||
|
def test_parse_version_invalid(self) -> None:
|
||||||
|
with pytest.raises(ValueError, match="无效版本号"):
|
||||||
|
_parse_version("abc")
|
||||||
|
with pytest.raises(ValueError, match="无效版本号"):
|
||||||
|
_parse_version("v")
|
||||||
|
with pytest.raises(ValueError, match="无效版本号"):
|
||||||
|
_parse_version("v1.0")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# list_versions — 数字排序
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestListVersions:
|
||||||
|
"""list_versions 按数字排序,v10 排在 v2 后。"""
|
||||||
|
|
||||||
|
def test_list_versions_numeric_sort(self, tmp_path: "Path") -> None:
|
||||||
|
"""v10 必须排在 v2 后面(非字典序)。"""
|
||||||
|
store = tmp_path / "store"
|
||||||
|
resource = store / "skills"
|
||||||
|
resource.mkdir(parents=True)
|
||||||
|
for v in ("v1", "v10", "v2", "v20", "v3"):
|
||||||
|
(resource / v).mkdir()
|
||||||
|
result = list_versions(store, "skills")
|
||||||
|
assert result == ["v1", "v2", "v3", "v10", "v20"]
|
||||||
|
|
||||||
|
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:
|
||||||
|
"""非 v\\d+ 格式的目录被忽略。"""
|
||||||
|
store = tmp_path / "store"
|
||||||
|
resource = store / "skills"
|
||||||
|
resource.mkdir(parents=True)
|
||||||
|
(resource / "v1").mkdir()
|
||||||
|
(resource / "backup").mkdir()
|
||||||
|
(resource / ".hidden").mkdir()
|
||||||
|
assert list_versions(store, "skills") == ["v1"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# next_version
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestNextVersion:
|
||||||
|
"""next_version 返回下一个可用版本号。"""
|
||||||
|
|
||||||
|
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:
|
||||||
|
store = tmp_path / "store"
|
||||||
|
resource = store / "skills"
|
||||||
|
resource.mkdir(parents=True)
|
||||||
|
(resource / "v1").mkdir()
|
||||||
|
(resource / "v2").mkdir()
|
||||||
|
assert next_version(store, "skills") == "v3"
|
||||||
|
|
||||||
|
def test_next_version_with_gap(self, tmp_path: "Path") -> None:
|
||||||
|
"""v1 和 v10 之间有 gap,next 应为 v11。"""
|
||||||
|
store = tmp_path / "store"
|
||||||
|
resource = store / "skills"
|
||||||
|
resource.mkdir(parents=True)
|
||||||
|
(resource / "v1").mkdir()
|
||||||
|
(resource / "v10").mkdir()
|
||||||
|
assert next_version(store, "skills") == "v11"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# advance_version
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestAdvanceVersion:
|
||||||
|
"""advance_version copytree + _write_meta。"""
|
||||||
|
|
||||||
|
def test_advance_version(self, tmp_path: "Path") -> None:
|
||||||
|
store = tmp_path / "store"
|
||||||
|
resource = store / "skills"
|
||||||
|
resource.mkdir(parents=True)
|
||||||
|
(resource / "v1").mkdir()
|
||||||
|
|
||||||
|
source = tmp_path / "new_skills"
|
||||||
|
source.mkdir()
|
||||||
|
(source / "skill_a.md").write_text("内容A")
|
||||||
|
|
||||||
|
version = advance_version(
|
||||||
|
store,
|
||||||
|
"skills",
|
||||||
|
source,
|
||||||
|
{"source": "evolution", "description": "进化产出"},
|
||||||
|
)
|
||||||
|
assert version == "v2"
|
||||||
|
assert (resource / "v2" / "skill_a.md").read_text() == "内容A"
|
||||||
|
|
||||||
|
meta = json.loads((resource / "v2" / "meta.json").read_text())
|
||||||
|
assert meta["version"] == "v2"
|
||||||
|
assert meta["source"] == "evolution"
|
||||||
|
assert meta["description"] == "进化产出"
|
||||||
|
assert "created_at" in meta
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# init_store
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestInitStore:
|
||||||
|
"""init_store 初始化 Store 目录结构。"""
|
||||||
|
|
||||||
|
def test_init_store(self, tmp_path: "Path") -> None:
|
||||||
|
videos = tmp_path / "videos_src"
|
||||||
|
videos.mkdir()
|
||||||
|
(videos / "v001").mkdir()
|
||||||
|
(videos / "v001" / "tree.json").write_text("{}")
|
||||||
|
|
||||||
|
skills = tmp_path / "skills_src"
|
||||||
|
skills.mkdir()
|
||||||
|
(skills / "search.md").write_text("skill")
|
||||||
|
|
||||||
|
prompts = tmp_path / "prompts_src"
|
||||||
|
prompts.mkdir()
|
||||||
|
(prompts / "system.md").write_text("prompt")
|
||||||
|
|
||||||
|
store = tmp_path / "store"
|
||||||
|
init_store(store, videos, skills, prompts)
|
||||||
|
|
||||||
|
assert (store / "videos" / "v001" / "tree.json").exists()
|
||||||
|
assert (store / "questions" / "benchmarks").is_dir()
|
||||||
|
assert (store / "questions" / "generated").is_dir()
|
||||||
|
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()
|
||||||
|
)
|
||||||
|
assert skills_meta["version"] == "v1"
|
||||||
|
assert skills_meta["source"] == "manual"
|
||||||
|
|
||||||
|
def test_init_store_exists_raises(self, tmp_path: "Path") -> None:
|
||||||
|
store = tmp_path / "store"
|
||||||
|
store.mkdir()
|
||||||
|
with pytest.raises(FileExistsError, match="Store 已存在"):
|
||||||
|
init_store(store, tmp_path, tmp_path, tmp_path)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Seed 相关
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _make_seed_fixtures(tmp_path):
|
||||||
|
"""创建 seed 测试所需的公共 fixture。"""
|
||||||
|
store = tmp_path / "store"
|
||||||
|
store.mkdir(parents=True)
|
||||||
|
|
||||||
|
skills_dir = tmp_path / "sk"
|
||||||
|
skills_dir.mkdir()
|
||||||
|
(skills_dir / "search.md").write_text("skill")
|
||||||
|
|
||||||
|
prompts_dir = tmp_path / "pr"
|
||||||
|
prompts_dir.mkdir()
|
||||||
|
(prompts_dir / "system.md").write_text("prompt")
|
||||||
|
|
||||||
|
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("INSERT INTO _runs VALUES ('r1', 'done')")
|
||||||
|
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()
|
||||||
|
|
||||||
|
return store, skills_dir, prompts_dir, baseline_db
|
||||||
|
|
||||||
|
|
||||||
|
class TestInitSeed:
|
||||||
|
"""init_seed 创建种子目录。"""
|
||||||
|
|
||||||
|
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,
|
||||||
|
"initial",
|
||||||
|
skills_dir,
|
||||||
|
prompts_dir,
|
||||||
|
baseline_db,
|
||||||
|
"r1",
|
||||||
|
None,
|
||||||
|
"初始种子",
|
||||||
|
)
|
||||||
|
assert seed_dir == store / "seeds" / "initial"
|
||||||
|
assert (seed_dir / "skills" / "search.md").exists()
|
||||||
|
assert (seed_dir / "prompts" / "system.md").exists()
|
||||||
|
assert (seed_dir / "baseline.db").exists()
|
||||||
|
|
||||||
|
meta = json.loads((seed_dir / "seed.json").read_text())
|
||||||
|
assert meta["baseline_run_id"] == "r1"
|
||||||
|
assert meta["parent"] is None
|
||||||
|
assert meta["description"] == "初始种子"
|
||||||
|
assert "created_at" in meta
|
||||||
|
|
||||||
|
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"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestListSeeds:
|
||||||
|
"""list_seeds 列出所有种子。"""
|
||||||
|
|
||||||
|
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:
|
||||||
|
store = tmp_path / "store"
|
||||||
|
assert list_seeds(store) == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestReadSeed:
|
||||||
|
"""read_seed 读取 seed.json。"""
|
||||||
|
|
||||||
|
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:
|
||||||
|
store = tmp_path / "store"
|
||||||
|
store.mkdir()
|
||||||
|
with pytest.raises(FileNotFoundError, match="种子不存在"):
|
||||||
|
read_seed(store, "no_such")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# extract_run_db
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestExtractRunDb:
|
||||||
|
"""extract_run_db 抽取指定 run 的行并保留 PK。"""
|
||||||
|
|
||||||
|
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("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("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:
|
||||||
|
"""原始 CREATE 保留主键约束。"""
|
||||||
|
src = tmp_path / "src.db"
|
||||||
|
dst = tmp_path / "dst.db"
|
||||||
|
self._make_src_db(src)
|
||||||
|
extract_run_db(src, dst, "r1")
|
||||||
|
|
||||||
|
conn = sqlite3.connect(dst)
|
||||||
|
# 验证 _runs 表有 PK
|
||||||
|
create_sql = conn.execute(
|
||||||
|
"SELECT sql FROM sqlite_master WHERE type='table' AND name='_runs'"
|
||||||
|
).fetchone()[0]
|
||||||
|
assert "PRIMARY KEY" in create_sql
|
||||||
|
|
||||||
|
# 验证只有 r1 的行
|
||||||
|
runs = conn.execute("SELECT * FROM _runs").fetchall()
|
||||||
|
assert len(runs) == 1
|
||||||
|
assert runs[0][0] == "r1"
|
||||||
|
|
||||||
|
preds = conn.execute("SELECT * FROM predictions").fetchall()
|
||||||
|
assert len(preds) == 2
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def test_extract_run_db_missing_table(self, tmp_path: "Path") -> None:
|
||||||
|
"""源 db 无目标表时报错。"""
|
||||||
|
src = tmp_path / "src.db"
|
||||||
|
dst = tmp_path / "dst.db"
|
||||||
|
conn = sqlite3.connect(src)
|
||||||
|
conn.execute("CREATE TABLE other (id TEXT)")
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
with pytest.raises(RuntimeError, match="源 db 无表"):
|
||||||
|
extract_run_db(src, dst, "r1")
|
||||||
|
|
||||||
|
def test_extract_run_db_no_rows(self, tmp_path: "Path") -> None:
|
||||||
|
"""目标 run_id 不存在时报错。"""
|
||||||
|
src = tmp_path / "src.db"
|
||||||
|
dst = tmp_path / "dst.db"
|
||||||
|
self._make_src_db(src)
|
||||||
|
with pytest.raises(RuntimeError, match="无 run_id="):
|
||||||
|
extract_run_db(src, dst, "nonexistent")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# promote_to_seed
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _make_promote_fixtures(tmp_path):
|
||||||
|
"""创建 promote_to_seed 测试所需的 workspace + store。"""
|
||||||
|
ws = tmp_path / "ws"
|
||||||
|
ws.mkdir()
|
||||||
|
store = tmp_path / "store"
|
||||||
|
store.mkdir()
|
||||||
|
|
||||||
|
# workspace 内的 skills/prompts 版本目录
|
||||||
|
(ws / "skills" / "v2").mkdir(parents=True)
|
||||||
|
(ws / "skills" / "v2" / "skill.md").write_text("evolved")
|
||||||
|
(ws / "prompts" / "v2").mkdir(parents=True)
|
||||||
|
(ws / "prompts" / "v2" / "system.md").write_text("prompt v2")
|
||||||
|
|
||||||
|
# workspace harness.db
|
||||||
|
db_path = ws / "harness.db"
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE _runs (
|
||||||
|
run_id TEXT PRIMARY KEY,
|
||||||
|
skills_version TEXT,
|
||||||
|
prompts_version TEXT
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO _runs VALUES ('eval_001', 'v2', 'v2')"
|
||||||
|
)
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE predictions (
|
||||||
|
run_id TEXT, question_id TEXT, answer TEXT
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
conn.execute("INSERT INTO predictions VALUES ('eval_001', 'q1', 'A')")
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
return ws, store
|
||||||
|
|
||||||
|
|
||||||
|
class TestPromoteToSeed:
|
||||||
|
"""promote_to_seed 固化 workspace 版本为种子。"""
|
||||||
|
|
||||||
|
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"
|
||||||
|
assert (seed_dir / "skills" / "skill.md").exists()
|
||||||
|
assert (seed_dir / "prompts" / "system.md").exists()
|
||||||
|
assert (seed_dir / "baseline.db").exists()
|
||||||
|
meta = json.loads((seed_dir / "seed.json").read_text())
|
||||||
|
assert meta["baseline_run_id"] == "eval_001"
|
||||||
|
assert meta["parent"] == "ws:v2"
|
||||||
|
|
||||||
|
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:
|
||||||
|
"""eval run 的版本为 NULL 时报错。"""
|
||||||
|
ws = tmp_path / "ws2"
|
||||||
|
ws.mkdir()
|
||||||
|
store = tmp_path / "store2"
|
||||||
|
store.mkdir()
|
||||||
|
db_path = ws / "harness.db"
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE _runs (
|
||||||
|
run_id TEXT PRIMARY KEY,
|
||||||
|
skills_version TEXT,
|
||||||
|
prompts_version TEXT
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
conn.execute("INSERT INTO _runs VALUES ('eval_null', NULL, NULL)")
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
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:
|
||||||
|
"""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:
|
||||||
|
"""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:
|
||||||
|
"""即使 init_seed 失败(同名种子),临时 db 也应被清理。"""
|
||||||
|
ws, store = _make_promote_fixtures(tmp_path)
|
||||||
|
promote_to_seed(ws, store, "v2", "eval_001", "first", "first time")
|
||||||
|
with pytest.raises(FileExistsError):
|
||||||
|
promote_to_seed(ws, store, "v2", "eval_001", "first", "second time")
|
||||||
|
assert not (ws / "_promote_tmp.db").exists()
|
||||||
Reference in New Issue
Block a user