docs: revise Phase A plan per Codex review (C1-C2,I1-I5,M1)
This commit is contained in:
@@ -219,11 +219,27 @@ def _to_generated_question(
|
||||
Run: `conda run -n Video-Tree-TRM pytest tests/unit/test_generated_question_sub_pattern.py -v`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 7: 写 JSON 持久化测试**
|
||||
- [ ] **Step 7: 写共享序列化测试**
|
||||
|
||||
在同文件追加,验证 `_on_accept` 与 `_append_to_json` 写出 `sub_pattern` 键。因 `_on_accept` 是闭包,测试改为直接测公共写函数 `_append_to_json`(同格式):
|
||||
`_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(
|
||||
@@ -242,16 +258,19 @@ def test_append_to_json_writes_sub_pattern(tmp_path):
|
||||
|
||||
- [ ] **Step 8: 跑测试确认失败**
|
||||
|
||||
Run: `conda run -n Video-Tree-TRM pytest tests/unit/test_generated_question_sub_pattern.py::test_append_to_json_writes_sub_pattern -v`
|
||||
Expected: FAIL(`_append_to_json` 的 entry dict 无 `sub_pattern`)
|
||||
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: 改 `tools/generate_questions.py` 两处写 dict**
|
||||
- [ ] **Step 9: 抽共享 `_question_to_entry` + 两处复用**
|
||||
|
||||
`_append_to_json` 的 `entry` dict(约 344 行)与 `_on_accept` 内的 append dict(约 1123 行),各补一行 `"sub_pattern": q.sub_pattern,`(放在 `"skill_target"` 之后)。示例(`_append_to_json`):
|
||||
`tools/generate_questions.py`,在 `_append_to_json` 之前加共享函数:
|
||||
|
||||
```python
|
||||
entry = {
|
||||
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),
|
||||
@@ -264,7 +283,9 @@ Expected: FAIL(`_append_to_json` 的 entry dict 无 `sub_pattern`)
|
||||
}
|
||||
```
|
||||
|
||||
`_on_accept` 内同理,在 `"skill_target": q.skill_target,` 后加 `"sub_pattern": q.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: 跑测试确认通过**
|
||||
|
||||
@@ -363,6 +384,7 @@ git commit -m "feat: add uses_grounded_selector strategy switch (AR only)"
|
||||
|
||||
**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: 写失败测试**
|
||||
@@ -467,10 +489,14 @@ Expected: FAIL
|
||||
Run: `conda run -n Video-Tree-TRM pytest tests/unit/test_run_store_selector_scores.py -v`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 6: 提交**
|
||||
- [ ] **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 tests/unit/test_run_store_selector_scores.py
|
||||
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"
|
||||
```
|
||||
|
||||
@@ -574,17 +600,22 @@ class SelectorConfig:
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SelectorOutcome:
|
||||
"""selector 产出。
|
||||
"""selector 产出。observation 始终存在(含 hard-fail),供 run_store 落库。
|
||||
|
||||
属性:
|
||||
options: 重组后的四选项(A=正解),格式 ("A. ...", "B. ...", ...)。
|
||||
answer: 正解字母(恒为 "A",后续 postprocess 洗牌)。
|
||||
observation: 打分观测 dict(落 run_store selector_scores)。
|
||||
observation: 打分观测 dict(correct_score/chosen/pool_size/anneal_rounds/hard_fail)。
|
||||
options: 重组四选项(A=正解),hard-fail 时为 None。
|
||||
answer: 正解字母(恒 "A"),hard-fail 时为 None。
|
||||
"""
|
||||
|
||||
options: tuple[str, ...]
|
||||
answer: str
|
||||
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(
|
||||
@@ -736,7 +767,7 @@ async def test_build_grounded_options_happy_path():
|
||||
vlm=vlm, question="厨师最终用哪种方式?", correct_text="蒸",
|
||||
material=_Material(), config=cfg, session_id="s",
|
||||
)
|
||||
assert out is not None
|
||||
assert out.hard_fail is False
|
||||
assert out.answer == "A"
|
||||
assert out.options[0] == "A. 蒸"
|
||||
assert {o[3:] for o in out.options[1:]} == {"炒", "煮", "炸"}
|
||||
@@ -744,22 +775,27 @@ async def test_build_grounded_options_happy_path():
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_grounded_options_hard_fail_returns_none():
|
||||
async def test_build_grounded_options_hard_fail_keeps_observation():
|
||||
from app.question_gen.distractor_selector import (
|
||||
SelectorConfig, build_grounded_options,
|
||||
)
|
||||
# 所有候选都在负空间(分数极低),退火后仍不足 3 个 → None
|
||||
# 所有候选都在负空间(分数极低),退火后仍不足 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, 0.05, 0.04, 0.03]}'
|
||||
vlm = _FakeVLM([pool, scores, pool2, scores2, pool2, scores2])
|
||||
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 is None
|
||||
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: 跑测试确认失败**
|
||||
@@ -859,11 +895,11 @@ async def build_grounded_options(
|
||||
config: SelectorConfig,
|
||||
*,
|
||||
session_id: str,
|
||||
) -> SelectorOutcome | None:
|
||||
) -> SelectorOutcome:
|
||||
"""生成候选池 → 视觉打分 → 区间选 3 干扰项 → 重组四选项。
|
||||
|
||||
退火(凑不齐 3 个时按序):① 扩池 N→2N 再打分;② 逐步放宽 δ_high;
|
||||
③ 仍不足返回 None(调用方走重出)。
|
||||
退火(凑不齐 3 个时按序):① 追加 N 个候选使池达 2N 再打分;② 逐步放宽
|
||||
δ_high(纯重选,不再调 VLM);③ 仍不足则 hard_fail(调用方走重出)。
|
||||
|
||||
参数:
|
||||
vlm: VLM 端口。
|
||||
@@ -874,7 +910,8 @@ async def build_grounded_options(
|
||||
session_id: 遥测会话 ID。
|
||||
|
||||
返回:
|
||||
SelectorOutcome(A=正解 + 3 grounded 干扰项)或 None(硬失败)。
|
||||
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
|
||||
@@ -888,7 +925,7 @@ async def build_grounded_options(
|
||||
correct_score, candidates, cand_scores, config.delta_low, config.delta_high
|
||||
)
|
||||
|
||||
# 退火 1: 扩池一轮 N→2N
|
||||
# 退火 1: 追加 N 个候选使池达 2N(仅对新增候选打分,正解分保持首轮值)
|
||||
if chosen is None:
|
||||
anneal_rounds += 1
|
||||
more = await _generate_pool(
|
||||
@@ -932,7 +969,8 @@ async def build_grounded_options(
|
||||
"grounded selector 硬失败: correct={:.3f}, pool={}, anneal={}",
|
||||
correct_score, len(candidates), anneal_rounds,
|
||||
)
|
||||
return None
|
||||
# observation 仍返回,供 pipeline 落 selector_scores(设计 §3.3 退化观测)
|
||||
return SelectorOutcome(observation=observation)
|
||||
|
||||
options = (
|
||||
f"A. {correct_text}",
|
||||
@@ -940,7 +978,7 @@ async def build_grounded_options(
|
||||
f"C. {chosen[1]}",
|
||||
f"D. {chosen[2]}",
|
||||
)
|
||||
return SelectorOutcome(options=options, answer="A", observation=observation)
|
||||
return SelectorOutcome(observation=observation, options=options, answer="A")
|
||||
```
|
||||
|
||||
- [ ] **Step 10: 跑全模块测试确认通过**
|
||||
@@ -988,6 +1026,25 @@ git commit -m "feat: add grounded distractor selector with visual scoring"
|
||||
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`:
|
||||
@@ -1069,13 +1126,17 @@ def _extract_correct_text(options: tuple[str, ...], answer: str) -> str:
|
||||
prev_reason = f"selector_error: {e}"
|
||||
continue
|
||||
|
||||
if outcome is None:
|
||||
# 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
|
||||
|
||||
store.update_selector_scores(item_id, json.dumps(outcome.observation, ensure_ascii=False))
|
||||
# 用 grounded 四选项替换候选(frozen → 构造新实例)
|
||||
candidate = _replace_candidate_options(candidate, outcome.options, outcome.answer)
|
||||
```
|
||||
@@ -1137,15 +1198,38 @@ Expected: PASS
|
||||
selector_delta_high: 0.35
|
||||
```
|
||||
|
||||
- [ ] **Step 9: 全量出题相关单测回归**
|
||||
- [ ] **Step 9: 更新 AR 集成测试 MockVLM(selector 启用后必须能应答 pool/score)**
|
||||
|
||||
Run: `conda run -n Video-Tree-TRM pytest tests/unit/test_generate_questions.py tests/unit/test_families.py tests/unit/test_gate.py -v`
|
||||
Expected: PASS(非 AR 路径不受影响)
|
||||
`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 关键词分流:
|
||||
|
||||
- [ ] **Step 10: 提交**
|
||||
```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: PASS(AR 集成经 selector 仍通过;非 AR 路径不受影响)
|
||||
|
||||
- [ ] **Step 11: 提交**
|
||||
|
||||
```bash
|
||||
git add app/question_gen/pipeline_v2.py config/question_gen_ar30.yaml tests/unit/test_pipeline_selector_wiring.py
|
||||
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"
|
||||
```
|
||||
|
||||
@@ -1272,6 +1356,8 @@ git commit -m "feat: enforce single-dimension counterfactual in AR distractor ru
|
||||
|
||||
放行"错误选项有局部真实证据、但在题干限定(同主体/时点/方式/对象)下为假"的近似干扰项——否则 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`(新建)
|
||||
@@ -1326,8 +1412,8 @@ Expected: PASS
|
||||
|
||||
- [ ] **Step 5: 回归 gate 测试**
|
||||
|
||||
Run: `conda run -n Video-Tree-TRM pytest tests/unit/test_gate.py -v`
|
||||
Expected: PASS
|
||||
Run: `conda run -n Video-Tree-TRM pytest tests/unit/test_gates.py -v`
|
||||
Expected: PASS(mock LLM,rubric 文案变更不影响判定断言)
|
||||
|
||||
- [ ] **Step 6: 提交**
|
||||
|
||||
|
||||
Reference in New Issue
Block a user