"""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) # 拷种子权重进 workspace:v2+ 训练产物只进 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/-,返回归档路径。 参数: 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())