Files
Video-Tree-TRM5/research-wiki/plans/2026-07-14-grounded-question-gen-phaseA-plan.md
T

1466 lines
57 KiB
Markdown
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.
# Grounded Question-Gen Phase A Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 把 Action Recognition 干扰项从"VLM 主观写得像"下沉到机制层——候选池 + VLM 视觉打分 selector 按视觉可信度区间选出 grounded near-miss 干扰项,消灭 Easy-Options Bias(负空间干扰项→排除法秒杀);同时修复 gate tree 错配 bug、落实 sub_pattern 持久化(Phase B 硬前提)。
**Architecture:** 路径隔离靠 `uses_grounded_selector` 策略属性——AR=True 走 selector 两步出题(先用现有 `generate_one_v2` 拿到"正解",再用新模块 `distractor_selector` 生成候选池 + 视觉打分选 3 个干扰项重组四选项);11 个非 AR 题型=False 走原路径,字节级行为不变。公共层只做纯 bug 修复、数据字段透传、门控 rubric 松绑。
**Tech Stack:** Python 3.11、asyncio、VLMProvider`chat_with_images`)、sqlite3(幂等 ALTER TABLE)、json_repair、pytest。全部命令在 conda 环境 `Video-Tree-TRM` 内执行。
---
## 前置约定(所有任务通用)
- **环境**:每条 Python/pytest/ruff 命令前缀 `conda run -n Video-Tree-TRM`。示例:`conda run -n Video-Tree-TRM pytest tests/unit/test_x.py -v`
- **路径隔离铁律**:除"公共纯 bug/纯数据/门控 rubric"外,任何行为变更只能发生在 AR 路径(`uses_grounded_selector=True` 分支)。每个任务末尾的回归步骤必须证明 11 个非 AR 题型行为不变。
- **提交**:每个 Task 末尾 commit,走 `commit` skill 的消息规范(英文、imperative、`<type>: <desc>`**禁止任何 AI 署名**)。
- **设计来源**`research-wiki/designs/2026-07-14-grounded-question-gen-phaseA-design.md`
---
## Task 1: 修复 gate tree 错配 bug(公共,纯 bug
`_process_one_slot` retry 换视频后,`current_tree` 已切换到新视频,但第 6 门 `run_gates` 仍传旧 `tree`,导致门控用错树验证("无 source material"假拒绝)。
**Files:**
- Modify: `app/question_gen/pipeline_v2.py:471``run_gates(candidate=candidate, tree=tree, ...)``tree=current_tree`
- Test: `tests/unit/test_pipeline_v2_tree_fix.py`(新建)
- [ ] **Step 1: 写失败测试**
新建 `tests/unit/test_pipeline_v2_tree_fix.py`:断言源码中 `run_gates` 调用使用 `current_tree` 而非 `tree`AST/正则守卫测试,锁死回归)。
```python
"""守卫 gate tree 错配 bugrun_gates 必须用 current_tree(换视频后的当前树)。"""
import ast
from pathlib import Path
_PIPELINE = Path(__file__).resolve().parents[2] / "app" / "question_gen" / "pipeline_v2.py"
def _find_run_gates_tree_arg() -> str:
"""解析 pipeline_v2.py,返回 run_gates 调用中 tree= 关键字实参的变量名。"""
tree_src = ast.parse(_PIPELINE.read_text(encoding="utf-8"))
for node in ast.walk(tree_src):
if isinstance(node, ast.Call):
func = node.func
name = getattr(func, "id", None) or getattr(func, "attr", None)
if name == "run_gates":
for kw in node.keywords:
if kw.arg == "tree":
assert isinstance(kw.value, ast.Name)
return kw.value.id
raise AssertionError("未找到 run_gates 的 tree= 关键字实参")
def test_run_gates_uses_current_tree():
assert _find_run_gates_tree_arg() == "current_tree"
```
- [ ] **Step 2: 跑测试确认失败**
Run: `conda run -n Video-Tree-TRM pytest tests/unit/test_pipeline_v2_tree_fix.py -v`
Expected: FAIL(当前实参为 `tree`
- [ ] **Step 3: 改代码**
`app/question_gen/pipeline_v2.py` Phase 6 的 `run_gates` 调用(约 471 行),把 `tree=tree` 改为 `tree=current_tree`
```python
report = await run_gates(
candidate=candidate,
tree=current_tree,
llm=llm,
leak_probe_template=strategy.leak_probe_template,
postprocess=pp,
vlm=vlm,
session_id=session_id,
)
```
- [ ] **Step 4: 跑测试确认通过**
Run: `conda run -n Video-Tree-TRM pytest tests/unit/test_pipeline_v2_tree_fix.py -v`
Expected: PASS
- [ ] **Step 5: 提交**
```bash
git add app/question_gen/pipeline_v2.py tests/unit/test_pipeline_v2_tree_fix.py
git commit -m "fix: run_gates must use current_tree after video resample"
```
---
## Task 2: sub_pattern 字段透传 + 持久化(公共,纯数据 / Phase B 硬前提)
`sub_pattern` 目前只在 `_process_one_slot` 选出并写入 `store.record_item`,未随 `GeneratedQuestion` 传出,`accepted_questions.json` 也不含该字段。Phase B 按题的 `sub_pattern``supports_flip`,缺则无法工作。
**Files:**
- Modify: `core/types.py``GeneratedQuestion``sub_pattern: str | None`
- Modify: `app/question_gen/pipeline_v2.py``_to_generated_question``sub_pattern` 形参;`_process_one_slot` 传入 `sub_pattern.name`
- Modify: `tools/generate_questions.py``_on_accept``_append_to_json``sub_pattern`
- Test: `tests/unit/test_generated_question_sub_pattern.py`(新建)
- [ ] **Step 1: 写失败测试**
新建 `tests/unit/test_generated_question_sub_pattern.py`
```python
"""GeneratedQuestion.sub_pattern 字段 + _to_generated_question 透传。"""
from app.question_gen.generator_v2 import CandidateQuestion
from app.question_gen.pipeline_v2 import _to_generated_question
from core.types import GeneratedQuestion
def _candidate() -> CandidateQuestion:
return CandidateQuestion(
question_id="v1_Action Recognition_0001",
video_id="v1",
task_type="Action Recognition",
skill_target="M1_AR",
question="厨师最终采用了哪种烹饪方式?",
options=("A. 蒸", "B. 炒", "C. 煮", "D. 炸"),
answer="A",
source_nodes=("n1", "n2"),
difficulty="hard",
)
def test_generated_question_has_sub_pattern_default_none():
q = GeneratedQuestion(
question_id="q1", video_id="v1", task_type="Action Recognition",
question="?", options=("A. x",), answer="A",
source_nodes=("n1",), difficulty="easy",
)
assert q.sub_pattern is None
def test_to_generated_question_threads_sub_pattern():
q = _to_generated_question(
_candidate(), family="ACTION_RECOGNITION",
sub_pattern="premature_evidence_anchoring",
)
assert q.sub_pattern == "premature_evidence_anchoring"
def test_to_generated_question_sub_pattern_defaults_none():
q = _to_generated_question(_candidate(), family="RETRIEVAL")
assert q.sub_pattern is None
```
- [ ] **Step 2: 跑测试确认失败**
Run: `conda run -n Video-Tree-TRM pytest tests/unit/test_generated_question_sub_pattern.py -v`
Expected: FAIL`GeneratedQuestion``sub_pattern``_to_generated_question` 无该形参)
- [ ] **Step 3: 改 `core/types.py`**
`GeneratedQuestion` 末尾新增字段(保持 frozen dataclass,带默认值以兼容既有构造点):
```python
family: str | None = field(default=None)
skill_target: str | None = field(default=None)
difficulty_steps: int | None = field(default=None)
sub_pattern: str | None = field(default=None)
```
同步在 docstring 属性列表补一行:`sub_pattern: 出题子模式标识(AR 特化策略使用,None 表示无)。`
- [ ] **Step 4: 改 `_to_generated_question`**
`app/question_gen/pipeline_v2.py`,函数签名加 keyword-only 形参并透传:
```python
def _to_generated_question(
candidate: CandidateQuestion,
*,
family: str,
options: tuple[str, ...] | None = None,
answer: str | None = None,
sub_pattern: str | None = None,
) -> GeneratedQuestion:
"""... (在 docstring 参数区补 sub_pattern 说明) ..."""
return GeneratedQuestion(
question_id=candidate.question_id,
video_id=candidate.video_id,
task_type=candidate.task_type,
question=candidate.question,
options=options if options is not None else candidate.options,
answer=answer if answer is not None else candidate.answer,
source_nodes=candidate.source_nodes,
difficulty=candidate.difficulty,
family=family,
skill_target=candidate.skill_target,
difficulty_steps=None,
sub_pattern=sub_pattern,
)
```
- [ ] **Step 5: 在 `_process_one_slot` 接受点传入 sub_pattern**
`app/question_gen/pipeline_v2.py` Phase 8 接受构造处(约 529 行):
```python
result = _to_generated_question(
candidate,
family=strategy.strategy_name,
options=pp.options,
answer=pp.answer,
sub_pattern=sub_pattern.name if sub_pattern else None,
)
```
- [ ] **Step 6: 跑单元测试确认通过**
Run: `conda run -n Video-Tree-TRM pytest tests/unit/test_generated_question_sub_pattern.py -v`
Expected: PASS
- [ ] **Step 7: 写共享序列化测试**
`_append_to_json``_on_accept` 是两条独立写路径(后者才写 `accepted_questions.json`),各自维护一份 entry dict——易漏改一处而测试不红。抽共享函数 `_question_to_entry(q) -> dict` 供两处复用,直接测它保证两条路径都含 `sub_pattern`
```python
def test_question_to_entry_includes_sub_pattern():
from tools.generate_questions import _question_to_entry
q = GeneratedQuestion(
question_id="v1_Action Recognition_0001", video_id="v1",
task_type="Action Recognition", question="?",
options=("A. 蒸", "B. 炒", "C. 煮", "D. 炸"), answer="A",
source_nodes=("n1",), difficulty="hard",
family="ACTION_RECOGNITION", skill_target="M1_AR",
sub_pattern="temporal_reasoning_failure",
)
entry = _question_to_entry(q)
assert entry["sub_pattern"] == "temporal_reasoning_failure"
assert entry["question_id"] == "v1_Action Recognition_0001"
assert entry["options"] == ["A. 蒸", "B. 炒", "C. 煮", "D. 炸"]
def test_append_to_json_writes_sub_pattern(tmp_path):
from tools.generate_questions import _append_to_json
q = GeneratedQuestion(
question_id="v1_Action Recognition_0001", video_id="v1",
task_type="Action Recognition", question="?",
options=("A. 蒸", "B. 炒", "C. 煮", "D. 炸"), answer="A",
source_nodes=("n1",), difficulty="hard",
family="ACTION_RECOGNITION", skill_target="M1_AR",
sub_pattern="temporal_reasoning_failure",
)
_append_to_json(tmp_path, q)
import json
data = json.loads((tmp_path / "v1.json").read_text(encoding="utf-8"))
assert data[0]["sub_pattern"] == "temporal_reasoning_failure"
```
- [ ] **Step 8: 跑测试确认失败**
Run: `conda run -n Video-Tree-TRM pytest tests/unit/test_generated_question_sub_pattern.py -k "entry or append" -v`
Expected: FAIL`_question_to_entry` 不存在)
- [ ] **Step 9: 抽共享 `_question_to_entry` + 两处复用**
`tools/generate_questions.py`,在 `_append_to_json` 之前加共享函数:
```python
def _question_to_entry(question: GeneratedQuestion) -> dict:
"""将题目序列化为 JSON entry_append_to_json 与 _on_accept 共用)。"""
return {
"question_id": question.question_id,
"video_id": question.video_id,
"task_type": question.task_type,
"question": question.question,
"options": list(question.options),
"answer": question.answer,
"source_nodes": list(question.source_nodes),
"difficulty": question.difficulty,
"family": question.family,
"skill_target": question.skill_target,
"sub_pattern": question.sub_pattern,
}
```
`_append_to_json``entry = {...}` 整体替换为 `entry = _question_to_entry(question)``_on_accept``existing.append({...})` 整体替换为 `existing.append(_question_to_entry(q))`
> 注:`_append_to_json` 原 entry 不含 `video_id` 键(按 video 分文件),改用共享函数后会多出 `video_id` 键——无害(下游按需取键),且与 `accepted_questions.json` 格式统一。若下游有严格 schema 校验,保留两函数但都调用 `_question_to_entry` 后 `entry.pop("video_id", None)`;实现时确认下游读取无强约束即可直接统一。
- [ ] **Step 10: 跑测试确认通过**
Run: `conda run -n Video-Tree-TRM pytest tests/unit/test_generated_question_sub_pattern.py -v`
Expected: PASS3+1 全绿)
- [ ] **Step 11: 提交**
```bash
git add core/types.py app/question_gen/pipeline_v2.py tools/generate_questions.py tests/unit/test_generated_question_sub_pattern.py
git commit -m "feat: thread and persist sub_pattern into accepted questions"
```
---
## Task 3: `uses_grounded_selector` 策略开关(路径隔离核心)
**Files:**
- Modify: `app/question_gen/strategy.py`Protocol 加 property`BaseTaskTypeStrategy` 默认 `False`
- Modify: `app/question_gen/strategy_action_recognition.py``ActionRecognitionStrategy` 覆盖为 `True`
- Test: `tests/unit/test_strategy_grounded_flag.py`(新建)
- [ ] **Step 1: 写失败测试**
```python
"""uses_grounded_selector 分流:仅 AR=True,其余 11 类=False。"""
from app.question_gen.strategy import get_strategy
_NON_AR = [
"Action Reasoning", "Attribute Perception", "Counting Problem",
"Information Synopsis", "Object Recognition", "Object Reasoning",
"OCR Problems", "Spatial Perception", "Spatial Reasoning",
"Temporal Perception", "Temporal Reasoning",
]
def test_action_recognition_uses_grounded_selector():
assert get_strategy("Action Recognition").uses_grounded_selector is True
def test_non_ar_do_not_use_grounded_selector():
for tt in _NON_AR:
assert get_strategy(tt).uses_grounded_selector is False, tt
```
- [ ] **Step 2: 跑测试确认失败**
Run: `conda run -n Video-Tree-TRM pytest tests/unit/test_strategy_grounded_flag.py -v`
Expected: FAIL`AttributeError: 'uses_grounded_selector'`
- [ ] **Step 3: 改 Protocol + Base**
`app/question_gen/strategy.py`,在 `TaskTypeStrategy` Protocol 里(`leak_probe_template` 之后)加:
```python
@property
def uses_grounded_selector(self) -> bool: ...
```
`BaseTaskTypeStrategy` 里加(返回 False):
```python
@property
def uses_grounded_selector(self) -> bool:
"""默认不启用 grounded selector11 类题型走原路径)。"""
return False
```
- [ ] **Step 4: 改 ActionRecognitionStrategy**
`app/question_gen/strategy_action_recognition.py`,在类里加:
```python
@property
def uses_grounded_selector(self) -> bool:
"""AR 启用候选池 + VLM 视觉打分 selector。"""
return True
```
- [ ] **Step 5: 跑测试确认通过**
Run: `conda run -n Video-Tree-TRM pytest tests/unit/test_strategy_grounded_flag.py -v`
Expected: PASS
- [ ] **Step 6: 提交**
```bash
git add app/question_gen/strategy.py app/question_gen/strategy_action_recognition.py tests/unit/test_strategy_grounded_flag.py
git commit -m "feat: add uses_grounded_selector strategy switch (AR only)"
```
---
## Task 4: `selector_scores` 观测列 + `update_selector_scores`(公共,纯数据)
**Files:**
- Modify: `app/question_gen/run_store.py``_DDL_ITEMS` 加列 + 幂等 ALTER TABLE + 新方法)
- Modify: `research-wiki/schemas/question-gen-items.md`(登记 `selector_scores` 列 + JSON 结构)
- Test: `tests/unit/test_run_store_selector_scores.py`(新建)
- [ ] **Step 1: 写失败测试**
```python
"""selector_scores 列幂等迁移 + update_selector_scores 写入。"""
import json
from app.question_gen.run_store import QuestionGenStore
def _store(tmp_path):
return QuestionGenStore(tmp_path / "q.db")
def test_selector_scores_column_exists(tmp_path):
store = _store(tmp_path)
cols = {r[1] for r in store._conn.execute("PRAGMA table_info(question_gen_items)")}
assert "selector_scores" in cols
store.close()
def test_update_selector_scores_writes_json(tmp_path):
store = _store(tmp_path)
store.record_run_start("run1", "sha", "{}")
store.record_item(
item_id="it1", run_id="run1", slot_id="s1", video_id="v1",
family="ACTION_RECOGNITION", task_type="Action Recognition",
skill_target="M1_AR", attempt=1, question_text="?",
sub_pattern="temporal_reasoning_failure",
)
payload = {"correct_score": 0.8, "chosen": [0.7, 0.6, 0.55],
"pool_size": 24, "anneal_rounds": 0, "hard_fail": False}
store.update_selector_scores("it1", json.dumps(payload))
row = store._conn.execute(
"SELECT selector_scores FROM question_gen_items WHERE item_id='it1'"
).fetchone()
assert json.loads(row[0])["correct_score"] == 0.8
store.close()
def test_update_selector_scores_unknown_item_raises(tmp_path):
store = _store(tmp_path)
try:
store.update_selector_scores("missing", "{}")
raise AssertionError("应抛 ValueError")
except ValueError:
pass
store.close()
```
- [ ] **Step 2: 跑测试确认失败**
Run: `conda run -n Video-Tree-TRM pytest tests/unit/test_run_store_selector_scores.py -v`
Expected: FAIL
- [ ] **Step 3: 改 DDL + 幂等迁移**
`app/question_gen/run_store.py``_DDL_ITEMS``difficulty_steps INTEGER,` 后加 `selector_scores TEXT,`(新建库直接带列)。`_init_schema` 里,仿照 `sub_pattern` 的幂等迁移追加:
```python
cols = {r[1] for r in self._conn.execute("PRAGMA table_info(question_gen_items)")}
if "sub_pattern" not in cols:
self._conn.execute("ALTER TABLE question_gen_items ADD COLUMN sub_pattern TEXT")
self._conn.commit()
if "selector_scores" not in cols:
self._conn.execute("ALTER TABLE question_gen_items ADD COLUMN selector_scores TEXT")
self._conn.commit()
```
- [ ] **Step 4: 加 `update_selector_scores` 方法**
`update_difficulty` 之后加:
```python
def update_selector_scores(self, item_id: str, selector_scores_json: str) -> None:
"""写入 grounded selector 打分观测(JSON 字符串)。
Parameters
----------
item_id : str
题目唯一 ID。
selector_scores_json : str
观测 JSONcorrect_score / chosen / pool_size / anneal_rounds / hard_fail。
Raises
------
ValueError
item_id 不存在时抛出。
"""
cursor = self._conn.execute(
"UPDATE question_gen_items SET selector_scores=? WHERE item_id=?",
(selector_scores_json, item_id),
)
self._conn.commit()
if cursor.rowcount == 0:
raise ValueError(f"item_id 不存在: {item_id}")
```
- [ ] **Step 5: 跑测试确认通过**
Run: `conda run -n Video-Tree-TRM pytest tests/unit/test_run_store_selector_scores.py -v`
Expected: PASS
- [ ] **Step 6: 登记 schema 文档**
`research-wiki/schemas/question-gen-items.md` 的列清单补一行 `selector_scores TEXT`,并说明其 JSON 结构:`{correct_score: float, chosen: float[], pool_size: int, anneal_rounds: int, delta_high_final: float, hard_fail: bool}`。若文档用表格,追加一行;保持与既有 `sub_pattern` 条目同风格。
- [ ] **Step 7: 提交**
```bash
git add app/question_gen/run_store.py research-wiki/schemas/question-gen-items.md tests/unit/test_run_store_selector_scores.py
git commit -m "feat: add selector_scores observation column to question_gen_items"
```
---
## Task 5: `distractor_selector.py` — 候选池 + VLM 视觉打分 + 区间选择 + 退火
模块核心。纯逻辑(区间选择)单测;VLM 调用用 mock 集成测。新增两个版本化 prompt。
**Files:**
- Create: `app/question_gen/distractor_selector.py`
- Create: `store/prompts/question_gen/ar_distractor_pool.md`
- Create: `store/prompts/question_gen/ar_distractor_score.md`
- Test: `tests/unit/test_distractor_selector.py`(新建)
### 5.1 纯逻辑:区间选择
- [ ] **Step 1: 写失败测试(区间选择)**
```python
"""distractor_selector 区间选择纯逻辑。"""
from app.question_gen.distractor_selector import _select_in_interval
def test_select_three_in_interval_by_highest_score():
# correct=0.90, 区间 = [0.90-0.35, 0.90-0.05] = [0.55, 0.85]
cands = ["a", "b", "c", "d", "e"]
scores = [0.84, 0.70, 0.60, 0.50, 0.88] # e=0.88 太接近(>0.85)剔除, d=0.50 太低剔除
chosen = _select_in_interval(0.90, cands, scores, delta_low=0.05, delta_high=0.35)
assert chosen == ["a", "b", "c"] # 落区间的按分数降序取 3(最难)
def test_select_returns_none_when_fewer_than_three():
cands = ["a", "b"]
scores = [0.80, 0.70]
assert _select_in_interval(0.90, cands, scores, 0.05, 0.35) is None
def test_select_excludes_out_of_band():
cands = ["hi", "lo", "ok1", "ok2", "ok3"]
scores = [0.89, 0.10, 0.80, 0.75, 0.70] # hi>上界, lo<下界
chosen = _select_in_interval(0.90, cands, scores, 0.05, 0.35)
assert chosen == ["ok1", "ok2", "ok3"]
```
- [ ] **Step 2: 跑测试确认失败**
Run: `conda run -n Video-Tree-TRM pytest tests/unit/test_distractor_selector.py -v`
Expected: FAIL(模块不存在)
- [ ] **Step 3: 建模块骨架 + 区间选择纯函数**
新建 `app/question_gen/distractor_selector.py`
```python
"""Grounded 干扰项 selector — 候选池 + VLM 视觉打分 + 区间选择(仅 AR 路径)。
把干扰项从"VLM 主观写得像"下沉到机制层:VLM 生成 N 个候选干扰项,再对
候选 + 正解逐一打"视觉可信度"分,按 [正解分-δ_high, 正解分-δ_low] 区间
选 3 个 grounded near-miss,从机制上消灭 Easy-Options Bias。
设计: research-wiki/designs/2026-07-14-grounded-question-gen-phaseA-design.md §3
"""
from __future__ import annotations
import json
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING
from json_repair import repair_json
from loguru import logger
if TYPE_CHECKING:
from app.question_gen.sampler_v2 import MaterialContext
from core.protocols import VLMProvider
_PROMPTS_DIR = Path(__file__).resolve().parent.parent.parent / "store" / "prompts" / "question_gen"
@dataclass(frozen=True)
class SelectorConfig:
"""selector 科研参数。
属性:
candidate_pool_size: 首轮候选干扰项数 N。
delta_low: 干扰项视觉分与正解的最小差(上界,太近=真歧义)。
delta_high: 干扰项视觉分与正解的最大差(下界,太低=负空间)。
max_delta_relax: δ_high 放宽次数上限(退火)。
delta_relax_step: 每次放宽 δ_high 的增量。
"""
candidate_pool_size: int
delta_low: float
delta_high: float
max_delta_relax: int = 2
delta_relax_step: float = 0.1
@dataclass(frozen=True)
class SelectorOutcome:
"""selector 产出。observation 始终存在(含 hard-fail),供 run_store 落库。
属性:
observation: 打分观测 dictcorrect_score/chosen/pool_size/anneal_rounds/hard_fail)。
options: 重组四选项(A=正解),hard-fail 时为 None。
answer: 正解字母(恒 "A"),hard-fail 时为 None。
"""
observation: dict
options: tuple[str, ...] | None = None
answer: str | None = None
@property
def hard_fail(self) -> bool:
"""是否硬失败(凑不齐 3 个 grounded 干扰项)。"""
return self.options is None
def _select_in_interval(
correct_score: float,
candidates: list[str],
candidate_scores: list[float],
delta_low: float,
delta_high: float,
) -> list[str] | None:
"""从候选中选 3 个视觉分落 [correct-δ_high, correct-δ_low] 区间的干扰项。
落区间者按分数降序取前 3(分数越高越接近正解=越难)。不足 3 个返回 None。
参数:
correct_score: 正解视觉可信度分。
candidates: 候选干扰项文本列表。
candidate_scores: 与 candidates 对齐的视觉分列表。
delta_low: 最小差(上界 = correct - delta_low)。
delta_high: 最大差(下界 = correct - delta_high)。
返回:
选中的 3 个候选文本(降序)或 None(不足 3 个)。
"""
upper = correct_score - delta_low
lower = correct_score - delta_high
eligible = [
(c, s)
for c, s in zip(candidates, candidate_scores, strict=True)
if lower <= s <= upper
]
if len(eligible) < 3:
return None
eligible.sort(key=lambda cs: cs[1], reverse=True)
return [c for c, _ in eligible[:3]]
```
- [ ] **Step 4: 跑区间选择测试确认通过**
Run: `conda run -n Video-Tree-TRM pytest tests/unit/test_distractor_selector.py -v`
Expected: PASS3 个区间选择测试)
### 5.2 版本化 prompt
- [ ] **Step 5: 建候选池 prompt**
新建 `store/prompts/question_gen/ar_distractor_pool.md`
```markdown
You generate hard-negative distractor options for a video Action Recognition
multiple-choice question.
## Given
- The question, the correct answer, subtitle context, and video frames.
## Rules
- Produce distractors that are **grounded near-misses**: each MUST describe an
action/entity that genuinely appears in the video, differing from the correct
answer in exactly ONE dimension (timing, subject, manner, or object).
- NEVER invent events absent from the video ("negative space"). A distractor
that names something not shown is a failure.
- Each distractor must be a plausible answer to the question for someone who
only skimmed the video.
- Keep each distractor parallel in structure and length to the correct answer.
## Output
Respond with ONLY a JSON object:
```json
{"distractors": ["...", "...", "..."]}
```
Return exactly N distractors (N is given in the request). No option-letter
prefixes, just the raw text.
```
- [ ] **Step 6: 建打分 prompt**
新建 `store/prompts/question_gen/ar_distractor_score.md`
```markdown
You are a strict visual grader for a video Action Recognition question.
## Given
- The question, video frames, and a numbered list of candidate answer texts
(the first is the true answer; the rest are distractor candidates — but you
are NOT told which is which).
## Task
For EACH candidate, judge how visually credible it is as an answer given ONLY
the frames — i.e. how strongly the frames could be read as supporting it.
Score in [0.0, 1.0]: 1.0 = frames strongly depict this; 0.0 = frames show no
trace of it (pure negative space).
Judge visual groundedness ONLY. Do NOT reward the option for being the
"correct" answer — a good distractor is visually credible yet wrong.
## Output
Respond with ONLY a JSON object mapping 1-based index to score, same order as
input:
```json
{"scores": [0.9, 0.7, 0.6, 0.3, 0.85]}
```
Return exactly as many scores as candidates, in order.
```
### 5.3 VLM 编排 + 退火
- [ ] **Step 7: 写失败测试(编排,mock VLM**
在 `tests/unit/test_distractor_selector.py` 追加:
```python
import pytest
from core.types import LLMResponse
class _FakeVLM:
"""按队列返回预设响应的 mock VLM。"""
def __init__(self, responses: list[str]):
self._responses = list(responses)
self.calls = 0
async def chat_with_images(self, messages, images, *, session_id=None, parent_call_id=None):
self.calls += 1
content = self._responses.pop(0)
return LLMResponse(
content=content, thinking="", model="fake", provider="fake",
prompt_tokens=0, completion_tokens=0, latency_ms=0,
ttft_ms=None, max_inter_token_ms=None, cache_hit=False, call_id="c",
)
class _Material:
subtitle_sentences = ["厨师先炒后蒸"]
frame_paths = ["/f1.jpg", "/f2.jpg"]
cross_l2_texts: list = []
source_nodes = ("n1",)
@pytest.mark.asyncio
async def test_build_grounded_options_happy_path():
from app.question_gen.distractor_selector import (
SelectorConfig, build_grounded_options,
)
pool = '{"distractors": ["炒", "煮", "炸", "烤"]}'
scores = '{"scores": [0.90, 0.80, 0.70, 0.60, 0.20]}' # 正解0.90; 炒0.80 煮0.70 炸0.60 落区间, 烤0.20 剔除
vlm = _FakeVLM([pool, scores])
cfg = SelectorConfig(candidate_pool_size=4, delta_low=0.05, delta_high=0.35)
out = await build_grounded_options(
vlm=vlm, question="厨师最终用哪种方式?", correct_text="蒸",
material=_Material(), config=cfg, session_id="s",
)
assert out.hard_fail is False
assert out.answer == "A"
assert out.options[0] == "A. 蒸"
assert {o[3:] for o in out.options[1:]} == {"炒", "煮", "炸"}
assert out.observation["hard_fail"] is False
@pytest.mark.asyncio
async def test_build_grounded_options_hard_fail_keeps_observation():
from app.question_gen.distractor_selector import (
SelectorConfig, build_grounded_options,
)
# 所有候选都在负空间(分数极低),退火后仍不足 3 个 → hard_fail。
# VLM 只被调 2 次(首轮 pool+score+ 1 次退火 pool + 1 次退火 score = 4 次;
# δ_high 放宽轮次是纯重选,不调 VLM。退火 pool 打分含正解,共 4 个分数。
pool = '{"distractors": ["x", "y", "z"]}'
scores = '{"scores": [0.90, 0.05, 0.04, 0.03]}'
pool2 = '{"distractors": ["p", "q", "r"]}'
scores2 = '{"scores": [0.90, 0.05, 0.04, 0.03]}'
vlm = _FakeVLM([pool, scores, pool2, scores2])
cfg = SelectorConfig(candidate_pool_size=3, delta_low=0.05, delta_high=0.35)
out = await build_grounded_options(
vlm=vlm, question="?", correct_text="蒸",
material=_Material(), config=cfg, session_id="s",
)
assert out.hard_fail is True
assert out.options is None
assert out.observation["hard_fail"] is True
assert out.observation["pool_size"] == 6 # 首轮 3 + 退火追加 3
```
- [ ] **Step 8: 跑测试确认失败**
Run: `conda run -n Video-Tree-TRM pytest tests/unit/test_distractor_selector.py -v`
Expected: FAIL`build_grounded_options` 未实现)
- [ ] **Step 9: 实现 pool/score/编排**
`distractor_selector.py` 追加。先补 prompt 加载与解析辅助,再 `build_grounded_options`
```python
def _load_prompt(name: str) -> str:
path = _PROMPTS_DIR / name
if not path.exists():
msg = f"Prompt 模板不存在: {path}"
raise FileNotFoundError(msg)
return path.read_text(encoding="utf-8")
def _material_context_block(question: str, correct_text: str, material: MaterialContext) -> str:
parts = [f"## Question\n{question}", f"## Correct Answer\n{correct_text}"]
if material.subtitle_sentences:
parts.append("## Subtitles")
parts.extend(f" - {s}" for s in material.subtitle_sentences)
if getattr(material, "cross_l2_texts", None):
parts.append("## Cross-Segment Context")
parts.extend(f" - {t}" for t in material.cross_l2_texts)
return "\n".join(parts)
def _parse_json_object(raw: str) -> dict:
content = raw.strip()
if "```" in content:
for part in content.split("```"):
stripped = part.strip()
if stripped.startswith("json"):
stripped = stripped[4:].strip()
if stripped.startswith("{"):
content = stripped
break
data = json.loads(repair_json(content, return_objects=False))
if not isinstance(data, dict):
msg = f"selector 响应顶层非 JSON 对象: {type(data).__name__}"
raise ValueError(msg)
return data
async def _generate_pool(
vlm: VLMProvider, question: str, correct_text: str,
material: MaterialContext, n: int, *, session_id: str,
) -> list[str]:
"""VLM 生成 n 个候选干扰项文本。"""
system = _load_prompt("ar_distractor_pool.md")
user = _material_context_block(question, correct_text, material) + f"\n## N\nGenerate exactly {n} distractors."
messages = [{"role": "system", "content": system}, {"role": "user", "content": user}]
resp = await vlm.chat_with_images(messages, list(material.frame_paths), session_id=session_id)
data = _parse_json_object(resp.content)
raw = data.get("distractors", [])
if not isinstance(raw, list):
return []
# 防御:去空、去重、剔除与正解字面相同者
seen: set[str] = set()
out: list[str] = []
for item in raw:
text = str(item).strip()
if not text or text == correct_text.strip() or text in seen:
continue
seen.add(text)
out.append(text)
return out
async def _score_options(
vlm: VLMProvider, question: str, options: list[str],
material: MaterialContext, *, session_id: str,
) -> list[float]:
"""VLM 对 options(首个为正解)逐一打视觉可信度分 [0,1],返回对齐分数列表。"""
system = _load_prompt("ar_distractor_score.md")
numbered = "\n".join(f"{i}. {opt}" for i, opt in enumerate(options, 1))
user = f"## Question\n{question}\n\n## Candidates\n{numbered}"
messages = [{"role": "system", "content": system}, {"role": "user", "content": user}]
resp = await vlm.chat_with_images(messages, list(material.frame_paths), session_id=session_id)
data = _parse_json_object(resp.content)
scores_raw = data.get("scores", [])
if not isinstance(scores_raw, list) or len(scores_raw) != len(options):
msg = f"打分数量({len(scores_raw) if isinstance(scores_raw, list) else 'NA'}) != 选项数({len(options)})"
raise ValueError(msg)
return [max(0.0, min(1.0, float(s))) for s in scores_raw]
async def build_grounded_options(
vlm: VLMProvider,
question: str,
correct_text: str,
material: MaterialContext,
config: SelectorConfig,
*,
session_id: str,
) -> SelectorOutcome:
"""生成候选池 → 视觉打分 → 区间选 3 干扰项 → 重组四选项。
退火(凑不齐 3 个时按序):① 追加 N 个候选使池达 2N 再打分;② 逐步放宽
δ_high(纯重选,不再调 VLM);③ 仍不足则 hard_fail(调用方走重出)。
参数:
vlm: VLM 端口。
question: 题干。
correct_text: 正解文本(无字母前缀)。
material: 采样素材(提供 frame_paths / subtitles)。
config: selector 科研参数。
session_id: 遥测会话 ID。
返回:
SelectorOutcome。成功时 options=A 正解+3 grounded 干扰项;hard_fail
时 options=None,但 observation 始终存在供落库。
"""
candidates = await _generate_pool(
vlm, question, correct_text, material, config.candidate_pool_size, session_id=session_id
)
# options[0] 恒为正解
scored = await _score_options(vlm, question, [correct_text, *candidates], material, session_id=session_id)
correct_score, cand_scores = scored[0], scored[1:]
anneal_rounds = 0
chosen = _select_in_interval(
correct_score, candidates, cand_scores, config.delta_low, config.delta_high
)
# 退火 1: 追加 N 个候选使池达 2N(仅对新增候选打分,正解分保持首轮值)
if chosen is None:
anneal_rounds += 1
more = await _generate_pool(
vlm, question, correct_text, material, config.candidate_pool_size, session_id=session_id
)
more = [m for m in more if m not in candidates]
if more:
more_scores = await _score_options(
vlm, question, [correct_text, *more], material, session_id=session_id
)
candidates = candidates + more
cand_scores = cand_scores + more_scores[1:]
chosen = _select_in_interval(
correct_score, candidates, cand_scores, config.delta_low, config.delta_high
)
# 退火 2: 放宽 δ_high(下界下移,纳入更低分候选),δ_low 不动
relax = 0
delta_high = config.delta_high
while chosen is None and relax < config.max_delta_relax:
relax += 1
anneal_rounds += 1
delta_high = delta_high + config.delta_relax_step
chosen = _select_in_interval(
correct_score, candidates, cand_scores, config.delta_low, delta_high
)
hard_fail = chosen is None
observation = {
"correct_score": correct_score,
"chosen": [
cand_scores[candidates.index(c)] for c in (chosen or [])
],
"pool_size": len(candidates),
"anneal_rounds": anneal_rounds,
"delta_high_final": delta_high,
"hard_fail": hard_fail,
}
if hard_fail:
logger.warning(
"grounded selector 硬失败: correct={:.3f}, pool={}, anneal={}",
correct_score, len(candidates), anneal_rounds,
)
# observation 仍返回,供 pipeline 落 selector_scores(设计 §3.3 退化观测)
return SelectorOutcome(observation=observation)
options = (
f"A. {correct_text}",
f"B. {chosen[0]}",
f"C. {chosen[1]}",
f"D. {chosen[2]}",
)
return SelectorOutcome(observation=observation, options=options, answer="A")
```
- [ ] **Step 10: 跑全模块测试确认通过**
Run: `conda run -n Video-Tree-TRM pytest tests/unit/test_distractor_selector.py -v`
Expected: PASS(区间选择 3 + 编排 2
- [ ] **Step 11: 提交**
```bash
git add app/question_gen/distractor_selector.py store/prompts/question_gen/ar_distractor_pool.md store/prompts/question_gen/ar_distractor_score.md tests/unit/test_distractor_selector.py
git commit -m "feat: add grounded distractor selector with visual scoring"
```
---
## Task 6: selector 接入 `_process_one_slot` + 配置参数(AR 路径)
把 selector 织入 AR 出题:`generate_one_v2` 拿正解 → 提取正解文本 → `build_grounded_options` 重组四选项 → 失败则重出。配置走 `PipelineConfig` + YAML。
**Files:**
- Modify: `app/question_gen/pipeline_v2.py``PipelineConfig` 加 selector 字段;`load_pipeline_config` 读 YAML`_process_one_slot` 织入)
- Modify: `config/question_gen_ar30.yaml`(补 selector 参数)
- Test: `tests/unit/test_pipeline_selector_wiring.py`(新建)
- [ ] **Step 1: 加 PipelineConfig 字段 + 加载**
`app/question_gen/pipeline_v2.py``PipelineConfig` 末尾加(带默认,兼容既有 YAML):
```python
seed: int
output_dir: Path
candidate_pool_size: int = 24
selector_delta_low: float = 0.05
selector_delta_high: float = 0.35
```
`load_pipeline_config``return PipelineConfig(...)` 补三行(`.get` 读,缺省用设计默认):
```python
seed=int(section["seed"]),
output_dir=Path(section["output_dir"]),
candidate_pool_size=int(section.get("candidate_pool_size", 24)),
selector_delta_low=float(section.get("selector_delta_low", 0.05)),
selector_delta_high=float(section.get("selector_delta_high", 0.35)),
```
**同步修 CLI seed override**`tools/generate_questions.py:900``--seed` 覆盖手工重建 `PipelineConfig`,只复制旧字段会把 selector 三参重置为默认。补三行:
```python
config = PipelineConfig(
per_type=config.per_type,
retry_limit=config.retry_limit,
heavy_sample_rate=config.heavy_sample_rate,
dedup_threshold=config.dedup_threshold,
concurrency=config.concurrency,
seed=args.seed,
output_dir=config.output_dir,
candidate_pool_size=config.candidate_pool_size,
selector_delta_low=config.selector_delta_low,
selector_delta_high=config.selector_delta_high,
)
```
> 更稳健的等价写法是 `dataclasses.replace(config, seed=args.seed)`;若采用请在文件顶部 `import dataclasses` 或 `from dataclasses import replace`。二选一即可,实现时保持一致。
- [ ] **Step 2: 写失败测试(正解文本提取 + 织入分流)**
新建 `tests/unit/test_pipeline_selector_wiring.py`。先测纯辅助 `_extract_correct_text`
```python
"""selector 织入辅助:正解文本提取 + 分流。"""
from app.question_gen.pipeline_v2 import _extract_correct_text
def test_extract_correct_text_strips_prefix():
options = ("A. 蒸", "B. 炒", "C. 煮", "D. 炸")
assert _extract_correct_text(options, "C") == "煮"
def test_extract_correct_text_handles_lowercase_answer():
options = ("A. run", "B. walk", "C. jump", "D. sit")
assert _extract_correct_text(options, "b") == "walk"
```
- [ ] **Step 3: 跑测试确认失败**
Run: `conda run -n Video-Tree-TRM pytest tests/unit/test_pipeline_selector_wiring.py -v`
Expected: FAIL`_extract_correct_text` 不存在)
- [ ] **Step 4: 加 `_extract_correct_text` 辅助**
`app/question_gen/pipeline_v2.py``_to_generated_question` 附近):
```python
def _extract_correct_text(options: tuple[str, ...], answer: str) -> str:
"""从四选项中取正解文本(去掉 "X. " 字母前缀)。
参数:
options: 选项元组,格式 ("A. ...", "B. ...", ...)。
answer: 正解字母(大小写不敏感)。
返回:
正解选项去前缀后的文本。
"""
idx = ord(answer.strip().upper()) - ord("A")
if not 0 <= idx < len(options):
msg = f"answer '{answer}' 超出选项范围 (n={len(options)})"
raise ValueError(msg)
opt = options[idx]
prefix = f"{answer.strip().upper()}. "
return opt[len(prefix):] if opt.startswith(prefix) else opt
```
- [ ] **Step 5: 织入 selector 到 `_process_one_slot`**
在 Phase 2`generate_one_v2` 得到 `candidate`)与 Phase 3`record_item`)之间不变;在 Phase 3 之后、Phase 4postprocess)之前,插入 grounded 分支。用 `strategy.uses_grounded_selector` 分流;失败走 `continue`。注意:selector 成功后需用重组选项**替换 candidate 的 options/answer** 再进 postprocess。
```python
# Phase 3.5: grounded selector(仅 AR 路径)
if strategy.uses_grounded_selector:
from app.question_gen.distractor_selector import (
SelectorConfig,
build_grounded_options,
)
correct_text = _extract_correct_text(candidate.options, candidate.answer)
selector_cfg = SelectorConfig(
candidate_pool_size=config.candidate_pool_size,
delta_low=config.selector_delta_low,
delta_high=config.selector_delta_high,
)
try:
outcome = await build_grounded_options(
vlm=vlm,
question=candidate.question,
correct_text=correct_text,
material=material,
config=selector_cfg,
session_id=session_id,
)
except (ValueError, FileNotFoundError) as e:
logger.warning("slot {} selector 异常 (attempt {}): {}", slot.slot_id, attempt, e)
prev_reason = f"selector_error: {e}"
continue
# observation 始终落库(含 hard-fail),供 EOB 退化观测与调参
store.update_selector_scores(
item_id, json.dumps(outcome.observation, ensure_ascii=False)
)
if outcome.hard_fail:
prev_reason = "grounded 干扰项不足(selector 硬失败)"
store.mark_item_rejected(item_id, prev_reason)
logger.info("slot {} selector 硬失败 (attempt {})", slot.slot_id, attempt)
continue
# 用 grounded 四选项替换候选(frozen → 构造新实例)
candidate = _replace_candidate_options(candidate, outcome.options, outcome.answer)
```
在文件顶部 `import` 区补 `import json`(若未导入)。并加辅助:
```python
def _replace_candidate_options(
candidate: CandidateQuestion, options: tuple[str, ...], answer: str
) -> CandidateQuestion:
"""用 selector 重组的选项/答案替换候选(CandidateQuestion frozen)。"""
return CandidateQuestion(
question_id=candidate.question_id,
video_id=candidate.video_id,
task_type=candidate.task_type,
skill_target=candidate.skill_target,
question=candidate.question,
options=options,
answer=answer,
source_nodes=candidate.source_nodes,
difficulty=candidate.difficulty,
subtitle_sentences=candidate.subtitle_sentences,
frame_paths=candidate.frame_paths,
)
```
- [ ] **Step 6: 写织入集成测试(mock VLM 分流)**
`tests/unit/test_pipeline_selector_wiring.py` 追加一个断言:非 AR 题型不触发 selector(源码守卫——`uses_grounded_selector` 分支只在 True 时进入)。用轻量单测覆盖 `_replace_candidate_options`
```python
def test_replace_candidate_options():
from app.question_gen.generator_v2 import CandidateQuestion
from app.question_gen.pipeline_v2 import _replace_candidate_options
c = CandidateQuestion(
question_id="q", video_id="v", task_type="Action Recognition",
skill_target="M1_AR", question="?",
options=("A. a", "B. b", "C. c", "D. d"), answer="A",
source_nodes=("n1",), difficulty="hard",
)
new = _replace_candidate_options(c, ("A. 蒸", "B. 炒", "C. 煮", "D. 炸"), "A")
assert new.options == ("A. 蒸", "B. 炒", "C. 煮", "D. 炸")
assert new.question == "?" # 其余字段不变
assert new.source_nodes == ("n1",)
```
- [ ] **Step 7: 跑测试确认通过**
Run: `conda run -n Video-Tree-TRM pytest tests/unit/test_pipeline_selector_wiring.py -v`
Expected: PASS
- [ ] **Step 8: 补 YAML 配置**
`config/question_gen_ar30.yaml``question_gen_v2` 区段补三行(值用设计默认):
```yaml
candidate_pool_size: 24
selector_delta_low: 0.05
selector_delta_high: 0.35
```
- [ ] **Step 9: 更新 AR 集成测试 MockVLMselector 启用后必须能应答 pool/score**
`tests/integration/test_pipeline_v2.py``MockVLM.chat_with_images` 现只区分门控(含 "verdict")与生成。selector 启用后 AR 路径会额外发 pool 请求(system prompt 含 "distractor")和 score 请求(含 "grader" / "scores")。若不识别,pool 会解析成候选 JSON → `_generate_pool` 得空列表 → hard-fail → AR slot 全拒,破坏既有断言。改 `chat_with_images` 顶部按 system prompt 关键词分流:
```python
async def chat_with_images(self, messages, images, *, session_id=None, parent_call_id=None):
prompt_text = str(messages)
system_text = messages[0].get("content", "") if messages else ""
if "verdict" in prompt_text.lower():
return _make_llm_response(self._gate_response)
if "distractor" in system_text.lower() and "grader" not in system_text.lower():
# 候选池请求:返回 4 个 grounded 干扰项
return _make_llm_response('{"distractors": ["蒸", "煮", "炸", "烤"]}')
if "grader" in system_text.lower():
# 打分请求:正解高分、3 个落区间、1 个负空间
return _make_llm_response('{"scores": [0.90, 0.80, 0.70, 0.60, 0.20]}')
idx = min(self._gen_count, len(self._responses) - 1)
self._gen_count += 1
return _make_llm_response(self._responses[idx])
```
> 打分响应长度需匹配"正解 + 候选数"。若某测试自定义候选池大小,须相应调整该 mock(打分列表长度 = pool 返回的干扰项数 + 1)。默认候选 JSON 4 个 → 打分 5 个,与上面一致。
- [ ] **Step 10: 全量出题相关单测 + AR 集成回归**
Run: `conda run -n Video-Tree-TRM pytest tests/integration/test_pipeline_v2.py tests/unit/test_generate_questions.py tests/unit/test_families.py tests/unit/test_gates.py -v`
Expected: PASSAR 集成经 selector 仍通过;非 AR 路径不受影响)
- [ ] **Step 11: 提交**
```bash
git add app/question_gen/pipeline_v2.py config/question_gen_ar30.yaml tools/generate_questions.py tests/unit/test_pipeline_selector_wiring.py tests/integration/test_pipeline_v2.py
git commit -m "feat: wire grounded selector into AR slot processing"
```
---
## Task 7: 单维反事实约束(仅 ARSubPattern 内容)
改 6 个 SubPattern 的 `instruction` + `distractor_rules`,硬约束"干扰项必须是视频中真实发生、仅在单一维度(时点/主体/方式/对象)与正解不同,严禁缺席事件"。纯 prompt 内容,行为由 selector 保障,此处强化生成端引导。
**Files:**
- Modify: `app/question_gen/strategy_action_recognition.py`6 个 SubPattern 的 `distractor_rules`
- Test: `tests/unit/test_ar_sub_pattern_counterfactual.py`(新建)
- [ ] **Step 1: 写失败测试**
```python
"""AR 6 个 SubPattern 的 distractor_rules 含单维反事实约束关键词。"""
from app.question_gen.strategy_action_recognition import AR_SUB_PATTERNS
_REQUIRED = ["真实", "单一维度"] # 每个 distractor_rules 都需强调 grounded + 单维
def test_all_sub_patterns_enforce_single_dimension_counterfactual():
for sp in AR_SUB_PATTERNS:
rules = sp.distractor_rules
assert "真实" in rules, sp.name # 干扰项须真实发生
assert ("单一维度" in rules or "只在" in rules or "仅在" in rules), sp.name
def test_sub_pattern_count_unchanged():
assert len(AR_SUB_PATTERNS) == 6
```
- [ ] **Step 2: 跑测试确认失败**
Run: `conda run -n Video-Tree-TRM pytest tests/unit/test_ar_sub_pattern_counterfactual.py -v`
Expected: FAIL
- [ ] **Step 3: 改 6 个 SubPattern 的 distractor_rules**
`app/question_gen/strategy_action_recognition.py`,逐一替换(按设计 §3.4 的反事实维度表)。示例——`_TEMPORAL_REASONING_FAILURE`(事件顺序维):
```python
distractor_rules=(
"干扰项必须是视频中真实发生的事件,仅在【事件时序/顺序】这一单一维度上与正解不同——"
"即同一组真实事件的错误排列或错误的第 N 次定位。"
"严禁使用视频中未出现的缺席事件作为干扰项。"
),
```
`_PREMATURE_EVIDENCE_ANCHORING`(时点维):
```python
distractor_rules=(
"将视频前段真实出现的局部匹配动作设为强干扰项——它真实发生,"
"仅在【时点】这一单一维度上与正解不同(前段 vs 最终结论段)。"
"其余干扰项亦须是视频中真实发生的动作,严禁缺席事件。"
),
```
`_SEMANTIC_RIGIDITY`(表述维):
```python
distractor_rules=(
"干扰项须基于视频真实内容,仅在【表述/语义】这一单一维度上做文章:"
"保留一个复用视频原始字幕字面、但在题干限定下语义为假的选项作为陷阱,"
"其余选项描述真实动作的不同同义表述。严禁凭空编造缺席动作。"
),
```
`_FINE_GRAINED_VISUAL_ACTION`(方式维):
```python
distractor_rules=(
"四个选项须是【同一大类动作】的不同执行方式,全部为视频中真实可见的做法,"
"仅在【执行方式】这一单一维度上不同(如顺/逆时针、扳手/螺丝刀)。"
"严禁使用明显不相关或视频中未出现的动作作为干扰项。"
),
```
`_CROSS_SEGMENT_ENTITY_TRACKING`(主体维):
```python
distractor_rules=(
"干扰项须是视频中【另一真实实体】在相应片段真实做过的动作,"
"仅在【动作主体】这一单一维度上与正解不同;或构造只覆盖部分片段的真实子集。"
"严禁编造任何实体未做过的缺席动作。"
),
```
`_EVIDENCE_GAP_CONFABULATION`(因果完整性维):
```python
distractor_rules=(
"正解仅陈述视频中可观测的事实或诚实承认证据不足;"
"干扰项在【因果完整性】这一单一维度上越界——补上一段视频未展示的因果链,"
"但其前提元素仍取自视频真实内容(诱导 Agent 顺势编造),而非完全凭空的缺席事件。"
),
```
同时在每个 SubPattern 的 `instruction` 末尾(可选)加一句"干扰项遵循单维反事实、不得缺席"的提醒——但测试只校验 `distractor_rules`,此步以 `distractor_rules` 为准。
- [ ] **Step 4: 跑测试确认通过**
Run: `conda run -n Video-Tree-TRM pytest tests/unit/test_ar_sub_pattern_counterfactual.py -v`
Expected: PASS
- [ ] **Step 5: 回归 AR 策略既有测试**
Run: `conda run -n Video-Tree-TRM pytest tests/unit/ -k "action or strategy or families" -v`
Expected: PASS
- [ ] **Step 6: 提交**
```bash
git add app/question_gen/strategy_action_recognition.py tests/unit/test_ar_sub_pattern_counterfactual.py
git commit -m "feat: enforce single-dimension counterfactual in AR distractor rules"
```
---
## Task 8: multi_true 门 rubric 松绑(公共,12 类统一)
放行"错误选项有局部真实证据、但在题干限定(同主体/时点/方式/对象)下为假"的近似干扰项——否则 grounded 干扰项会被 multi_true 误毙。
> **这是本计划唯一一处授权的公共路径行为变更**(用户在 brainstorming 明确答复"全局松绑",见设计 §3.5)。它作用于全部 12 题型的 multi_true 门。**无回归破坏风险**`_gate_multi_true``app/question_gen/gates.py:363`)加载 prompt 后调 `llm.chat``tests/unit/test_gates.py` 用 mock LLM 返回固定 verdict、不校验 prompt 内容,故 rubric 文案变更不会使既有非 AR 门控测试变红。
**Files:**
- Modify: `store/prompts/question_gen/gate_multi_true.md`
- Test: `tests/unit/test_gate_multi_true_rubric.py`(新建)
- [ ] **Step 1: 写失败测试**
```python
"""multi_true rubric 已松绑为题干限定判据。"""
from pathlib import Path
_PROMPT = Path(__file__).resolve().parents[2] / "store" / "prompts" / "question_gen" / "gate_multi_true.md"
def test_rubric_uses_qualifier_constraint():
text = _PROMPT.read_text(encoding="utf-8")
# 新 rubric 必须提到"题干限定下同时为真才 fail"qualifier / under the question's constraints
assert "under the question" in text.lower() or "qualifier" in text.lower()
# 必须显式放行"有局部真实证据但在限定下为假"的干扰项
assert "partial" in text.lower() or "locally" in text.lower()
```
- [ ] **Step 2: 跑测试确认失败**
Run: `conda run -n Video-Tree-TRM pytest tests/unit/test_gate_multi_true_rubric.py -v`
Expected: FAIL
- [ ] **Step 3: 改 rubric**
`store/prompts/question_gen/gate_multi_true.md``## Instructions` 段替换为:
```markdown
## Instructions
1. Read the source material and the question carefully. Note the question's
explicit qualifiers (subject, timing, manner, object).
2. For each option, assess whether it is **fully correct under the question's
qualifiers** — not merely whether it has some partial or local support in
the source.
3. A good hard-negative distractor MAY have partial/local evidence in the video
yet be FALSE under the question's constraints. Such an option is NOT a
second correct answer — do NOT fail the question for it.
4. Verdict "fail" ONLY IF two or more options are each fully correct under the
question's qualifiers (a genuine ambiguity).
5. Otherwise verdict "pass".
```
- [ ] **Step 4: 跑测试确认通过**
Run: `conda run -n Video-Tree-TRM pytest tests/unit/test_gate_multi_true_rubric.py -v`
Expected: PASS
- [ ] **Step 5: 回归 gate 测试**
Run: `conda run -n Video-Tree-TRM pytest tests/unit/test_gates.py -v`
Expected: PASSmock LLMrubric 文案变更不影响判定断言)
- [ ] **Step 6: 提交**
```bash
git add store/prompts/question_gen/gate_multi_true.md tests/unit/test_gate_multi_true_rubric.py
git commit -m "feat: loosen multi_true gate to qualifier-scoped correctness"
```
---
## Task 9: 全量回归 + lint + wiki 收口
**Files:**
- 无新代码;运行验证 + wiki 登记。
- [ ] **Step 1: 全量测试**
Run: `conda run -n Video-Tree-TRM pytest tests/ -q`
Expected: 全绿(含既有 1200+ 用例,证明 11 非 AR 题型行为不变)。若有红,回到对应 Task 修复。
- [ ] **Step 2: lint**
Run: `conda run -n Video-Tree-TRM ruff check app/ core/ --fix && conda run -n Video-Tree-TRM ruff format app/ core/`
Expected: 无剩余错误。
- [ ] **Step 3: wiki 登记 plan 实体**
```bash
conda run -n Video-Tree-TRM python3 .claude/tools/research_wiki.py add_entity research-wiki/ --type plan --id grounded-question-gen-phaseA --title "Grounded Question-Gen Phase A"
conda run -n Video-Tree-TRM python3 .claude/tools/research_wiki.py add_edge research-wiki/ --from "plan:grounded-question-gen-phaseA" --to "design:grounded-question-gen-phaseA" --type implements --evidence "Phase A 实现计划"
conda run -n Video-Tree-TRM python3 .claude/tools/research_wiki.py rebuild_index research-wiki/
```
- [ ] **Step 4: 提交**
```bash
git add research-wiki/
git commit -m "docs: register Phase A plan in research wiki"
```
---
## Self-Review 与保真校验
**核心算法保真**:本计划改动局限于出题(question_gen)的候选池/打分/门控 rubric/数据透传,**不涉及** `research-wiki/ARCHITECTURE.md §6` 的 12 项核心算法(建树 4 项 + 训练 8 项)。出题四门 gate 非核心算法清单成员。**保真校验不适用**。
**Spec 覆盖**L0 tree bug→Task1uses_grounded_selector→Task3;候选池+VLM打分+区间+退火→Task5;单维反事实→Task7multi_true松绑→Task8sub_pattern持久化→Task2selector观测列→Task4;接入+配置→Task6;回归→Task9。设计 §7 三个配置参数→Task6 Step1/8。
**路径隔离**Task3 建开关,Task6 用开关分流,Task5 模块只被 AR 分支调用;Task1/2/4/8 为公共纯 bug/数据/rubric。每个改行为的 Task 都含非 AR 回归步骤。