{
  "generated_at": "2026-09-06T07:14:54+00:00",
  "source_commit": "a3c8b9e",
  "lab": {
    "id": "lab-2-a",
    "title": "lab-2-a · 会自己做视频的助理",
    "tagline": "在你自己的电脑上,指挥你的 coding agent 造出一个能控制 OBS、写口播稿、开提词器、自动剪辑的本地视频助理。",
    "stack": [
      [
        "Agent 本体(两条路线任选)",
        "路线 A · 纯 Python 手写:零框架,http.server 起 localhost 对话窗口,urllib 直发 Anthropic Messages 协议,循环 ~200 行 —— 看清『任何 SDK 都只是这 200 行的包装』。路线 B · Claude Code SDK:pip install claude-agent-sdk(另需 npm i -g @anthropic-ai/claude-code),ClaudeSDKClient 多轮对话 + @tool 装饰器把你的工具挂成进程内 MCP server —— 循环/流式/权限/子代理全由 SDK 承担,你专注工具与验收。两条路线的工具函数本体完全同一份;A 懂机制,B 得杠杆,先 A 后 B 最佳。"
      ],
      [
        "驱动 OBS",
        "obs-websocket 5.x 裸协议(OBS 28+ 内置,端口 4455)。手写 WebSocket 客户端:双层 SHA256 握手(secret=b64(sha256(密码+salt)), auth=b64(sha256(secret+challenge))),op6 请求 / op7 按 id 配对回复 —— 和课上 B12 的 CDP 是同一个形状。传输用 websocket-client,这是唯一必装的 pip 包;刻意不用 obsws-python 现成客户端。"
      ],
      [
        "连接 CapCut",
        "VectCutAPI —— 开源本地服务(localhost:9001),底层用 pyJianYingDraft 直接生成 CapCut 草稿文件写进草稿目录。我们调它的 REST 端点(create_draft / add_video / add_text / add_audio / save_draft)。⚠ 它无论成败都回 HTTP 200,必须查 body.success;保存后还要 os.path.isdir 查草稿目录真出现 —— 验收哲学的活教材。"
      ],
      [
        "剪辑引擎",
        "ffmpeg 全家:cut/concat 流拷贝、ffprobe 真读验收(所有『做完了』的证据)、silencedetect 零 token 找冷场。好看的字幕不走 srt/libass —— Pillow 渲染圆角半透明底条 PNG + overlay 滤镜合成,样式像素级可控。"
      ],
      [
        "转录",
        "whisper(引擎探测:mlx-whisper → faster-whisper → openai-whisper,谁装了用谁),词级时间戳。铁律:模型绝不产时间戳,只报段编号,编号→时间戳由工具查表。"
      ],
      [
        "检索",
        "sklearn TF-IDF(字面/词法)+ fastembed 本地 ONNX embedding(语义,无 key 无 torch)。三路对比实验(retrieval_bench)在你自己的语料上量『该不该上向量』。"
      ]
    ],
    "tasks": [
      {
        "id": "t1",
        "title": "环境就绪",
        "est": "15 分钟",
        "kps": [
          "kp-01"
        ],
        "verify": "local",
        "expect": [
          "python3 preflight.py 全绿(VectCutAPI 一项黄色可接受)",
          "OBS → Tools → WebSocket Server Settings 已勾 Enable,密码已 export OBS_PASSWORD",
          "LLM 凭据已 export(课程网关 token 或 BYO key)"
        ],
        "eval": null,
        "check_cmd": "python3 preflight.py"
      },
      {
        "id": "t2",
        "title": "对话窗口 + agent 循环(路线 A/B 任选)",
        "est": "45 分钟",
        "kps": [
          "kp-01"
        ],
        "verify": "local",
        "expect": [
          "python3 -m agent.server 起在 localhost:8788(8787 常被占,用 PORT env 可调)",
          "网页能对话;每次工具调用和结果在页面上可见(⚙ 调用 / ✓✗ 结果)",
          "工具报错以 is_error 回填给模型,绝不静默"
        ],
        "eval": null,
        "check_cmd": "python3 -m py_compile agent/server.py && curl -sf -o /dev/null -m 3 http://localhost:8788/"
      },
      {
        "id": "t3",
        "title": "OBS 控制:裸协议",
        "est": "60 分钟",
        "kps": [
          "kp-02",
          "kp-03",
          "kp-04"
        ],
        "verify": "cloud",
        "expect": [
          "对话「查一下 OBS 状态」返回真实版本/当前场景/是否在录",
          "「开始录制」「停止录制」真控制 OBS,停录返回 outputPath",
          "「把 XX 摄像头设成源加个亮度滤镜」能布场景(枚举真实设备,不瞎猜 id)"
        ],
        "eval": {
          "scenarios": [
            "t3-obs"
          ],
          "k": 3
        },
        "check_cmd": null
      },
      {
        "id": "t4",
        "title": "剪辑 + 自动检测",
        "est": "45 分钟",
        "kps": [
          "kp-04",
          "kp-05"
        ],
        "verify": "cloud",
        "expect": [
          "对话「把 X 的 5-12 秒切出来并验证时长」→ 切完自动 probe 验证",
          "「把冷场剪掉(不用转录)」→ detect_silences 找静音区间 → cut_spans 出片",
          "所有出片工具返回里带 ffprobe 真读的时长"
        ],
        "eval": {
          "scenarios": [
            "t4-capcut"
          ],
          "k": 3
        },
        "check_cmd": null
      },
      {
        "id": "t5",
        "title": "转录 + 剪掉讲错的",
        "est": "60 分钟",
        "kps": [
          "kp-05"
        ],
        "verify": "local",
        "expect": [
          "对话「把这段视频里讲错的和废话剪掉」→ 转录 → 表格分析删留 → 出干净版",
          "agent 正确处理「讲错后重讲」:留后一次",
          "删减超 40% 时先给用户看清单再动手"
        ],
        "eval": null,
        "check_cmd": "python3 -m py_compile agent/tools/transcribe.py && python3 -c \"import sys; sys.path.insert(0,'.'); from agent.tools import transcribe as t; assert hasattr(t,'transcribe') and hasattr(t,'cut_by_segments')\""
      },
      {
        "id": "t6",
        "title": "资料 → 口播稿 → 提词器",
        "est": "60 分钟",
        "kps": [
          "kp-06",
          "kp-07"
        ],
        "verify": "local",
        "expect": [
          "对话给主题+URL → 抓正文入 corpus/(带来源)→ 检索 → 口播稿每段带 [来源] → 推到 /teleprompter",
          "提词器大字滚动,空格暂停、↑↓调速;agent 对话里也能 start/pause/调速",
          "检索里没有的事实不写进稿子(引用闸)"
        ],
        "eval": null,
        "check_cmd": "python3 -m py_compile agent/tools/research.py && test -f agent/web/teleprompter.html && curl -sf -o /dev/null -m 3 http://localhost:8788/teleprompter"
      },
      {
        "id": "t7",
        "title": "检索实验 + 好看的字幕",
        "est": "60 分钟",
        "kps": [
          "kp-06"
        ],
        "verify": "local",
        "expect": [
          "对话「做检索对比实验」→ agent 自己设计精确词+换说法两类问题 → 三路并排对比表 → 给出该不该上向量的结论(以你的语料为准)",
          "「加上好看的字幕」→ 深色圆角半透明底条+大号白字,agent 自动用剪后视频重新转录拿时间戳",
          "小分辨率视频字幕不溢出(行宽按视频宽度算)"
        ],
        "eval": null,
        "check_cmd": "python3 -m py_compile agent/tools/subtitle_style.py && python3 -c \"import sys; sys.path.insert(0,'.'); from agent.tools import research, subtitle_style; assert hasattr(research,'retrieval_bench') and hasattr(subtitle_style,'burn_pretty_subtitles')\""
      }
    ],
    "learning_objectives": [
      {
        "id": "kp-01",
        "name": "agent 循环一行不多:messages → LLM → tool_use → 执行 → 回填 → 续推"
      },
      {
        "id": "kp-02",
        "name": "协议会换,形状不变:obs-websocket 与 CDP 是同一个形状"
      },
      {
        "id": "kp-03",
        "name": "给 agent 的每个「写」工具,配一个「看清选项」的读工具"
      },
      {
        "id": "kp-04",
        "name": "验收以真读为准:outputPath 只是字符串"
      },
      {
        "id": "kp-05",
        "name": "自动剪辑两层:信号层抓「没说话」,语义层抓「说错话」"
      },
      {
        "id": "kp-06",
        "name": "检索三路在你自己的语料上量过再选"
      },
      {
        "id": "kp-07",
        "name": "「显示但不录制」的正解是不进采集管线"
      },
      {
        "id": "kp-08",
        "name": "指挥 agent 写:期望结果 + 评测矩阵 = 你的坑位地图"
      }
    ],
    "solution_tools": [
      "obs_get_status",
      "obs_list_scenes",
      "obs_set_scene",
      "obs_start_record",
      "obs_stop_record",
      "obs_list_inputs",
      "obs_create_input",
      "obs_get_input_device_options",
      "obs_set_input_settings",
      "obs_list_filters",
      "obs_list_filter_kinds",
      "obs_add_filter",
      "obs_set_filter_settings",
      "probe_video",
      "cut_clip",
      "concat_clips",
      "capcut_create",
      "capcut_add_video",
      "capcut_add_text",
      "capcut_add_audio",
      "capcut_transition_types",
      "transcribe_video",
      "cut_by_segments",
      "make_srt",
      "fetch_article",
      "corpus_list",
      "corpus_search",
      "corpus_grep",
      "corpus_embed_search",
      "retrieval_bench",
      "teleprompter_load",
      "teleprompter_control",
      "detect_silences",
      "cut_spans",
      "burn_pretty_subtitles",
      "burn_subtitles",
      "capcut_save"
    ]
  },
  "recording": {
    "date": "2026-08-31",
    "real_versions": {
      "obs": "OBS 32.2.0",
      "obs_websocket": "obs-websocket 5.7.4",
      "vectcut": "VectCutAPI capcut_server.py :9001"
    },
    "traces": {
      "obs_frames": 217,
      "vectcut_pairs": 7
    },
    "digests": {
      "obs_request_types": 19,
      "obs_event_types": 14,
      "vectcut_endpoints": 6,
      "obs_top_keys": [
        "hello",
        "requests",
        "events"
      ],
      "vectcut_top_keys": [
        "endpoints"
      ]
    },
    "baselines": {
      "obs_main_steps": 19,
      "obs_edge_cases": 2,
      "vectcut_steps": 7
    }
  },
  "mocks": {
    "obs_mock_lines": 268,
    "vectcut_mock_lines": 115,
    "known_divergences": 9,
    "divergence_affecting_asserts": 1,
    "iron_rules": [
      "改 mock 必须过契约测试——mock 任何行为改动,test_contract_*.py 必须保持全绿。",
      "重录 baseline 必须真机——不许手写或编造 baseline/digest 数据充数。"
    ]
  },
  "contract": {
    "obs_steps": 19,
    "vectcut_steps": 7,
    "test_functions": 3
  },
  "evalkit": {
    "files": 35,
    "lines": 3381,
    "assert_types": [
      "obs_state",
      "obs_request_made",
      "vectcut_draft_saved",
      "vectcut_request_made",
      "file_ffprobe",
      "trajectory_tool_called"
    ],
    "plus": "required_order"
  },
  "tests": {
    "collected_total": 115,
    "collected_evalkit": 87,
    "collected_lab": 28,
    "source": "pytest --collect-only -q"
  },
  "scenarios": [
    {
      "file": "t3-obs.json",
      "task": "t3",
      "kps": [
        "kp-02",
        "kp-03",
        "kp-04"
      ],
      "id": "record-cycle",
      "k": 3,
      "timeout_s": 180,
      "instructions": [
        "帮我开始录制",
        "好了,停止录制"
      ],
      "asserts": [
        {
          "type": "obs_request_made",
          "request": "StartRecord",
          "ok": true
        },
        {
          "type": "obs_request_made",
          "request": "StopRecord",
          "ok": true
        },
        {
          "type": "obs_state",
          "path": "recording",
          "equals": false
        },
        {
          "type": "file_ffprobe",
          "min_duration_s": 0.5
        }
      ],
      "required_order": []
    },
    {
      "file": "t3-obs.json",
      "task": "t3",
      "kps": [
        "kp-02",
        "kp-03",
        "kp-04"
      ],
      "id": "enumerate-before-create",
      "k": 3,
      "timeout_s": 240,
      "instructions": [
        "在当前场景里新建一个彩色底色的输入源,颜色随便,名字叫 EvalCam"
      ],
      "asserts": [
        {
          "type": "obs_request_made",
          "request": "GetInputKindList",
          "ok": true
        },
        {
          "type": "obs_request_made",
          "request": "CreateInput",
          "ok": true
        }
      ],
      "required_order": [
        [
          "GetInputKindList",
          "CreateInput"
        ]
      ]
    },
    {
      "file": "t4-capcut.json",
      "task": "t4",
      "kps": [
        "kp-04"
      ],
      "id": "draft-assemble",
      "k": 3,
      "timeout_s": 240,
      "instructions": [
        "用 /tmp/eval-sample.mp4 这个视频建一个 1920x1080 的 CapCut 草稿,加一段 0-1.5 秒的视频和一条字幕『评测』,然后把草稿保存到 {draft_folder} 这个目录"
      ],
      "asserts": [
        {
          "type": "vectcut_draft_saved",
          "min_tracks": 2
        },
        {
          "type": "trajectory_tool_called",
          "tool": "capcut_save"
        },
        {
          "type": "vectcut_request_made",
          "path": "/save_draft",
          "success": true
        }
      ],
      "required_order": []
    }
  ],
  "acceptance": {
    "source": "lab-2-a/eval/CLOUD-ACCEPTANCE.md (2026-09-03/04, production Render worker) + ENGINE-ACCEPTANCE.md",
    "env": "生产 · lab-agent.parallight.ai · Render one-off Job evalkit-worker",
    "model": "claude-haiku-4-5-20251001(学员自己的 token)",
    "matrix": [
      {
        "task": "t3",
        "scenario": "record-cycle",
        "passed": 3,
        "k": 3,
        "submit_to_result_s": 92
      },
      {
        "task": "t3",
        "scenario": "enumerate-before-create",
        "passed": 3,
        "k": 3,
        "submit_to_result_s": 92
      },
      {
        "task": "t4",
        "scenario": "draft-assemble",
        "passed": 3,
        "k": 3,
        "submit_to_result_s": 54
      }
    ],
    "noise_anecdote": "t4 的一次 k=1 冒烟失败(轨迹在 probe_video → capcut_create 后停住),原样重跑即通过——单次轨迹噪声,正是 k=3 采样存在的理由。",
    "cost": [
      {
        "task": "t3",
        "k": 3,
        "usd": 0.2839,
        "llm_calls": 27
      },
      {
        "task": "t4",
        "k": 3,
        "usd": 0.1386,
        "llm_calls": 12
      }
    ],
    "cost_note": "LLM token 成本,不含 Render 容器算力;评测用学员自己的 key,/lab-assistant 用平台服务账号(Bedrock)。",
    "engine_bugs_found_by_real_run": 4,
    "assistant_leak_audit": {
      "solution_lines_ge20": 1485,
      "hint_hits": 0
    },
    "real_eval_duration_min": [
      2,
      6
    ],
    "plugin_version": "0.1.26-phase1"
  },
  "principles": [
    {
      "src": "spec §5.2",
      "text": "终态断言:mock OBS 状态机走到「录制已停止且有产物」、草稿目录真落盘、ffprobe 真读剪辑产物时长——确定性断言随机过程的产物。"
    },
    {
      "src": "spec §5.2",
      "text": "必要工具调用:如「必须先枚举再创建」,断言写在不变量上,不逐步匹配轨迹。"
    },
    {
      "src": "spec §5.3",
      "text": "每场景跑 k 次采样。结果不是单一 pass/fail,而是场景通过矩阵。"
    },
    {
      "src": "spec §5.4",
      "text": "评测报告替代静态 hint,成为学员形成理解的主要来源。"
    },
    {
      "src": "spec §8",
      "text": "mock 从录制的真实流量里提炼行为,不照文档写;同一场景在真环境和 mock 上跑,工具层可见行为必须一致,mock 才算合格。"
    },
    {
      "src": "spec §9",
      "text": "云端评测环境刻意单一平台(标准考场):写死平台特有值的 agent 被评测报告指出——这本身是教学点。"
    },
    {
      "src": "spec §4",
      "text": "成人教育不硬堵,但设一道摩擦。"
    }
  ],
  "caveats": [
    "目前只有 lab-2-a 建了 mock 层与云端评测;其余 lab 用本机自检或沙箱真跑。",
    "录制只在 macOS 做过,Windows 没有 digest;助手的坑位知识以 macOS 验证为准。",
    "/lab-evaluate 与 /lab-assistant 需要付费学员账号。"
  ]
}