Files
Video-Tree-TRM5/app/harness/workspace.py
T
iomgaa d349fe1148 feat(harness): workspace.py — Workspace lifecycle + VersionedSkillStore/PromptStore
- ResolvedPaths frozen dataclass: store_dir, videos_dir, questions_dir,
  skills_dir, prompts_dir, workspace_dir, db_path, analyses_dir, runs_dir
- init_workspace: create ws + copy seed weights from store
- init_workspace_from_seed: create from seed with fail-fast questions check
- load_manifest / resolve_paths: manifest I/O + path resolution
  (skills/prompts resolve to workspace, videos/questions to store)
- update_manifest: key whitelist validation
- record_run: idempotent history append + per-video wiki dirs
- read_best / update_best: best pointer independent of current
- list_video_ids: videos with tree.json
- archive_workspace: move to .archive/<name>-<ts>
- VersionedSkillStore: implements core/evolution/protocols.py::SkillStore
- VersionedPromptStore: implements core/evolution/protocols.py::PromptStore
- 21 tests all passing (incl. Protocol compliance checks)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-07 12:41:59 -04:00

479 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Workspace 生命周期管理 + manifest 读写 + Protocol 实现。
Workspace 是一次实验的独立工作区,通过 manifest.json 引用 Store 中的
特定版本资源并记录实验过程。Skills/Prompts 权重拷入 workspace 本地,
训练产物只进 workspace 不污染 Store。
VersionedSkillStore / VersionedPromptStore 实现 core/evolution/protocols.py
中定义的只读端口,供 core/ 层以 Protocol 方式读取技能和提示词。
"""
from __future__ import annotations
import json
import os
import shutil
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import TYPE_CHECKING
from loguru import logger
from app.harness.store import read_seed
if TYPE_CHECKING:
from pathlib import Path
@dataclass(frozen=True)
class ResolvedPaths:
"""manifest 解析后的绝对路径集合。
属性:
store_dir: Store 根目录绝对路径。
videos_dir: 视频数据目录。
questions_dir: 当前引用的题目目录。
skills_dir: 当前引用的 Skill 版本目录(workspace 内)。
prompts_dir: 当前引用的 Prompt 版本目录(workspace 内)。
workspace_dir: Workspace 根目录。
db_path: harness.db 路径。
analyses_dir: 分析报告目录。
runs_dir: 运行临时状态目录。
"""
store_dir: Path
videos_dir: Path
questions_dir: Path
skills_dir: Path
prompts_dir: Path
workspace_dir: Path
db_path: Path
analyses_dir: Path
runs_dir: Path
# ---------------------------------------------------------------------------
# 内部工具
# ---------------------------------------------------------------------------
_MANIFEST_CURRENT_KEYS = {"videos", "questions", "skills", "prompts"}
def _now_iso() -> str:
"""返回当前 UTC 时间的 ISO 格式字符串。"""
return datetime.now(UTC).isoformat()
# ---------------------------------------------------------------------------
# Workspace 核心函数
# ---------------------------------------------------------------------------
def _scaffold_workspace(
workspace_dir: Path,
store_dir: Path,
questions: str,
skills_version: str,
prompts_version: str,
) -> None:
"""写 manifest + 建 analyses/runs 目录(不拷权重;权重由调用方按来源拷入)。
参数:
workspace_dir: 目标 workspace(由调用方保证不存在)。
store_dir: Store 根目录。
questions: 题目相对路径,如 ``'benchmarks/Video-MME'``。
skills_version: manifest.current.skills 初始版本号。
prompts_version: manifest.current.prompts 初始版本号。
关键实现:
不依赖任何外部资源源(store 中的 skills/prompts 是否存在不在此校验),
因此可被 init_workspace 与种子初始化复用;store 引用以相对路径写入 manifest。
"""
workspace_dir.mkdir(parents=True)
(workspace_dir / "analyses").mkdir()
(workspace_dir / "runs").mkdir()
store_abs = store_dir.resolve()
store_rel = os.path.relpath(store_abs, workspace_dir.resolve())
manifest = {
"name": workspace_dir.name,
"created_at": _now_iso(),
"store": store_rel,
"current": {
"videos": "videos",
"questions": f"questions/{questions}",
"skills": f"skills/{skills_version}",
"prompts": f"prompts/{prompts_version}",
},
"history": [],
}
(workspace_dir / "manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2))
def init_workspace(
workspace_dir: Path,
store_dir: Path,
questions: str,
skills_version: str,
prompts_version: str,
) -> None:
"""创建 Workspace 目录并写入初始 manifest.json,拷贝种子权重。
参数:
workspace_dir: Workspace 目标路径(不得已存在)。
store_dir: Store 根目录。
questions: 题目在 questions/ 下的相对路径,如 ``"benchmarks/Video-MME"``。
skills_version: Skills 版本号,如 ``"v1"``。
prompts_version: Prompts 版本号,如 ``"v1"``。
异常:
FileExistsError: Workspace 目录已存在。
FileNotFoundError: 引用的资源在 Store 中不存在。
"""
if workspace_dir.exists():
raise FileExistsError(f"Workspace 已存在: {workspace_dir}")
store_abs = store_dir.resolve()
refs = {
"skills": f"skills/{skills_version}",
"prompts": f"prompts/{prompts_version}",
"questions": f"questions/{questions}",
}
for label, rel in refs.items():
full = store_abs / rel
if not full.is_dir():
raise FileNotFoundError(f"Store 中不存在 {label}: {full}")
_scaffold_workspace(workspace_dir, store_dir, questions, skills_version, prompts_version)
# 拷种子权重进 workspacev2+ 训练产物只进 workspace,不污染 store
shutil.copytree(store_abs / refs["skills"], workspace_dir / refs["skills"])
shutil.copytree(store_abs / refs["prompts"], workspace_dir / refs["prompts"])
logger.info("Workspace 初始化完成: {}", workspace_dir)
def init_workspace_from_seed(
workspace_dir: Path,
store_dir: Path,
seed_name: str,
questions: str,
) -> str:
"""从种子全新建 workspace:拷权重 -> v1、baseline.db -> harness.db、读 baseline_run_id。
参数:
workspace_dir: 目标 workspace(不得已存在)。
store_dir: Store 根目录。
seed_name: 种子名(store/seeds 下)。
questions: 题目相对路径,如 ``'benchmarks/Video-MME'``。
返回:
baseline_run_id(供 build_pools 使用)。
异常:
FileExistsError: workspace 已存在。
FileNotFoundError: 种子不存在(由 read_seed 抛出),或 questions ref 目录不存在。
关键实现:
破坏性/创建操作前先校验 questions ref 存在:fresh 路径在 runner 侧已先
归档旧 ws,若到 build_pools 才发现 questions 缺失则旧 ws 已被毁;
故在此尽早报错(fail-fast),让新 ws 在创建前失败。
"""
if workspace_dir.exists():
raise FileExistsError(f"Workspace 已存在: {workspace_dir}")
# 校验种子存在 + 取 baseline_run_id
meta = read_seed(store_dir, seed_name)
# fail-fast:校验 questions ref 存在
questions_ref = store_dir / "questions" / questions
if not questions_ref.is_dir():
raise FileNotFoundError(f"questions ref 目录不存在: {questions_ref}")
seed_dir = store_dir / "seeds" / seed_name
_scaffold_workspace(workspace_dir, store_dir, questions, "v1", "v1")
shutil.copytree(seed_dir / "skills", workspace_dir / "skills" / "v1")
shutil.copytree(seed_dir / "prompts", workspace_dir / "prompts" / "v1")
shutil.copy2(seed_dir / "baseline.db", workspace_dir / "harness.db")
logger.info("Workspace 从种子 '{}' 初始化完成: {}", seed_name, workspace_dir)
return meta["baseline_run_id"]
def load_manifest(workspace_dir: Path) -> dict:
"""读取并返回 workspace 的 manifest.json。
参数:
workspace_dir: Workspace 根目录。
返回:
manifest 字典。
异常:
FileNotFoundError: manifest.json 不存在。
"""
manifest_path = workspace_dir / "manifest.json"
if not manifest_path.exists():
raise FileNotFoundError(f"manifest.json 不存在: {manifest_path}")
return json.loads(manifest_path.read_text())
def resolve_paths(workspace_dir: Path) -> ResolvedPaths:
"""读取 manifest,解析 current 中所有资源的绝对路径。
skills_dir/prompts_dir 解析到 workspace(非 store),
videos_dir/questions_dir 解析到 store。
参数:
workspace_dir: Workspace 根目录。
返回:
ResolvedPaths 实例,包含所有资源的绝对路径。
"""
manifest = load_manifest(workspace_dir)
ws_abs = workspace_dir.resolve()
store_abs = (ws_abs / manifest["store"]).resolve()
current = manifest["current"]
return ResolvedPaths(
store_dir=store_abs,
videos_dir=store_abs / current["videos"],
questions_dir=store_abs / current["questions"],
skills_dir=ws_abs / current["skills"],
prompts_dir=ws_abs / current["prompts"],
workspace_dir=ws_abs,
db_path=ws_abs / "harness.db",
analyses_dir=ws_abs / "analyses",
runs_dir=ws_abs / "runs",
)
def list_video_ids(workspace_dir: Path) -> list[str]:
"""列出 workspace 引用的所有视频 ID(含 tree.json 的子目录名)。
参数:
workspace_dir: Workspace 根目录。
返回:
排序后的视频 ID 列表。
"""
paths = resolve_paths(workspace_dir)
video_ids = []
for entry in paths.videos_dir.iterdir():
if entry.is_dir() and (entry / "tree.json").exists():
video_ids.append(entry.name)
return sorted(video_ids)
def update_manifest(workspace_dir: Path, **version_updates: str) -> None:
"""更新 manifest 的 current 字段。
参数:
workspace_dir: Workspace 根目录。
**version_updates: 要更新的字段及其新值,如 ``skills="skills/v2"``。
异常:
KeyError: 更新的字段不在 current 允许的 key 白名单中。
"""
invalid = set(version_updates) - _MANIFEST_CURRENT_KEYS
if invalid:
raise KeyError(f"无效的 manifest current 字段: {invalid}")
manifest = load_manifest(workspace_dir)
manifest["current"].update(version_updates)
(workspace_dir / "manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2))
def record_run(workspace_dir: Path, run_id: str) -> Path:
"""将 current 版本快照追加到 manifest history,创建 run 目录和 per-video wiki 目录。
幂等:同 run_id 不重复追加 history(长跑中断后重启 / held-out 复用 run_id 时)。
参数:
workspace_dir: Workspace 根目录。
run_id: 本次运行的唯一标识,如 ``"run_001"``。
返回:
创建的 run 目录路径。
"""
manifest = load_manifest(workspace_dir)
current = manifest["current"]
# 幂等:同 run_id 不重复追加 history
if not any(h["run_id"] == run_id for h in manifest["history"]):
manifest["history"].append(
{
"run_id": run_id,
"started_at": _now_iso(),
"skills": current["skills"],
"prompts": current["prompts"],
"questions": current["questions"],
}
)
(workspace_dir / "manifest.json").write_text(
json.dumps(manifest, ensure_ascii=False, indent=2)
)
run_dir = workspace_dir / "runs" / run_id
# exist_ok:同 run_id 重跑时 run 目录已存在不应崩溃
run_dir.mkdir(parents=True, exist_ok=True)
for video_id in list_video_ids(workspace_dir):
(run_dir / video_id / "wiki").mkdir(parents=True, exist_ok=True)
logger.debug("Run 已记录: {}", run_id)
return run_dir
def read_best(workspace_dir: Path) -> dict | None:
"""读取 manifest 的 best 指针,未设置时返回 None。
参数:
workspace_dir: Workspace 根目录。
返回:
best 字典(skills/prompts/val_acc/run_id/epoch),未设置时 None。
"""
return load_manifest(workspace_dir).get("best")
def update_best(
workspace_dir: Path,
skills: str,
prompts: str,
val_acc: float,
run_id: str,
epoch: int,
) -> None:
"""写入 manifest 的 best 指针(历史最优版本快照,与 current 平级)。
best 独立于 current——更新 best 不影响 current。
参数:
workspace_dir: Workspace 根目录。
skills: 最优 skills 版本完整 ref,如 ``'skills/v2'``。
prompts: 最优 prompts 版本完整 ref,如 ``'prompts/v2'``。
val_acc: 该版本验证集准确率。
run_id: 该版本验证 run_id。
epoch: 达成该最优的轮次。
"""
manifest = load_manifest(workspace_dir)
manifest["best"] = {
"skills": skills,
"prompts": prompts,
"val_acc": val_acc,
"run_id": run_id,
"epoch": epoch,
}
(workspace_dir / "manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2))
logger.info("Best 已更新: val_acc={}, run={}, epoch={}", val_acc, run_id, epoch)
def archive_workspace(workspace_dir: Path) -> Path:
"""把 workspace 整体移动到同级 .archive/<name>-<ts>,返回归档路径。
参数:
workspace_dir: 要归档的 Workspace 根目录。
返回:
归档后的目标路径。
异常:
FileNotFoundError: workspace 不存在。
"""
if not workspace_dir.exists():
raise FileNotFoundError(f"workspace 不存在: {workspace_dir}")
archive_root = workspace_dir.parent / ".archive"
archive_root.mkdir(exist_ok=True)
ts = datetime.now(UTC).strftime("%Y%m%d-%H%M%S")
target = archive_root / f"{workspace_dir.name}-{ts}"
shutil.move(str(workspace_dir), str(target))
logger.info("Workspace 已归档: {} -> {}", workspace_dir, target)
return target
# ---------------------------------------------------------------------------
# Protocol 实现:VersionedSkillStore / VersionedPromptStore
# ---------------------------------------------------------------------------
class VersionedSkillStore:
"""版本化技能读取端口实现。
满足 ``core/evolution/protocols.py::SkillStore`` Protocol。
从指定的 skills 版本目录读取 ``.md`` 文件。
参数:
skills_dir: skills 版本目录绝对路径(如 ``workspace/skills/v1``)。
"""
def __init__(self, skills_dir: Path) -> None:
if not skills_dir.is_dir():
raise FileNotFoundError(f"Skills 目录不存在: {skills_dir}")
self._dir = skills_dir
def read_skill(self, filename: str) -> str:
"""读取指定 skill 文件的全文内容。
参数:
filename: skill 文件名,如 ``'temporal-reasoning.md'``。
返回:
文件全文内容。
异常:
FileNotFoundError: 文件不存在。
"""
path = self._dir / filename
if not path.exists():
raise FileNotFoundError(f"Skill 文件不存在: {path}")
return path.read_text()
def list_skill_files(self) -> list[str]:
"""列出当前版本所有 skill 文件名。
返回:
文件名列表(排序)。
"""
return sorted(entry.name for entry in self._dir.iterdir() if entry.is_file())
class VersionedPromptStore:
"""版本化提示词读取端口实现。
满足 ``core/evolution/protocols.py::PromptStore`` Protocol。
从指定的 prompts 版本目录读取 ``.md`` 文件。
参数:
prompts_dir: prompts 版本目录绝对路径(如 ``workspace/prompts/v1``)。
"""
def __init__(self, prompts_dir: Path) -> None:
if not prompts_dir.is_dir():
raise FileNotFoundError(f"Prompts 目录不存在: {prompts_dir}")
self._dir = prompts_dir
def read_prompt(self, filename: str) -> str:
"""读取指定 prompt 文件的全文内容。
参数:
filename: prompt 文件名,如 ``'system.md'``。
返回:
文件全文内容。
异常:
FileNotFoundError: 文件不存在。
"""
path = self._dir / filename
if not path.exists():
raise FileNotFoundError(f"Prompt 文件不存在: {path}")
return path.read_text()
def list_prompt_files(self) -> list[str]:
"""列出当前版本所有 prompt 文件名。
返回:
文件名列表(排序)。
"""
return sorted(entry.name for entry in self._dir.iterdir() if entry.is_file())