---
id: adversarial-question-gen-phaseB
title: 出题质量提升 Phase B — Agent 对抗过滤（独立后置过滤层）
type: design
created: 2026-07-14
status: draft
---

# Phase B：Agent 对抗过滤（独立后置过滤层）

## 1. 目标与范围

在 Phase A 的 grounded 单题基础上，加两个"用完整 inference agent 揪残余 shortcut"的机制：**作弊者门**（AFLite 式——agent 能秒杀的题剔除）和**配对翻转门**（agent 答案必须随问题翻转，否则揪出偏好蒙答）。

**关键架构决策**：Phase B 是 Phase A 产物 `accepted_questions.json` 之上的**独立后置过滤层**，**不改** Phase A 的逐题 accepted 语义、`on_accept` 逐题持久化、`record_item`/`update_gates` 状态机、`load_progress` 断点续跑。Phase B 有自己的过滤状态表，与 Phase A 的 `final_status` 正交。这个选择有明确代价（§2 权衡），但换来对现有状态机的零改动。

**前置依赖（硬依赖，缺则 Phase B 无法判 flip）**：Phase A（[2026-07-14-grounded-question-gen-phaseA-design.md]）已落地，且 **`accepted_questions.json` 必须写入 `sub_pattern` 字段**——Phase B 按题的 `sub_pattern` 查其 `supports_flip`/`flip_axis`（Codex 指出当前 `_on_accept` 未写该字段，Phase A 的"sub_pattern 持久化"必须落实，否则 Phase B 无法区分哪些题走翻转门）。

**问题来源**：[2026-07-14-hard-distractor-literature.md]（AFLite、TempCompass QuadAcc、DMC3 before/after 问题翻转）。

### 路径归属

| 改动 | 代码位置 | 生效范围 |
|------|---------|---------|
| 作弊者门 + 配对翻转门 | 新建 `adversarial_filter.py`（独立过滤层） | **filter 层自己的配置 `filter_task_types`（默认 `[Action Recognition]`）选择过滤哪些题型**——不塞进 Phase A strategy 主流程，保持概念隔离 |
| supports_flip 声明 | `SubPattern` 数据类加 `supports_flip`/`flip_axis` | 仅声明的 SubPattern（当前只 AR 的 temporal/cross_segment） |
| 过滤状态表 | `run_store.py` 新增 `adversarial_verdicts` 表 | 公共（新表，不动现有 `question_gen_items`） |

## 2. 两种架构对比与权衡

Codex 审查了"改内部状态机"方案（把 accepted 时机推迟到 agent 门后），指出三个 Critical：`record_item` 只存 question_text 无法从 DB 重建完整 `GeneratedQuestion`；改 accepted 时机与 `update_gates`/`on_accept`/`load_progress` 三处状态机强冲突；`load_progress` 不返回 pending 无恢复入口。

这三个 Critical **不是靠打补丁修**，而是靠**换架构从根上消除**——这正是本设计选独立后置过滤层的理由：

| Critical（改内部状态机方案的问题） | 独立后置层如何从根消除 |
|-----------------------------------|----------------------|
| DB 只存 question_text，无法重建 GeneratedQuestion | Phase B 从 `accepted_questions.json` 重建（question/options/answer/source_nodes/video_id 字段完整），**根本不碰 DB 重建这条路** |
| 改 accepted 时机与三处状态机冲突 | Phase B **不改 accepted 时机**——Phase A 照常逐题 accepted，Phase B 在其产物之上过滤，冲突的前提（改时机）不存在 |
| load_progress 不返回 pending | Phase B **不引入 pending 状态**，用独立 `adversarial_verdicts` 表管自己的过滤进度，不依赖 load_progress |

**但独立后置层有它自己的代价（诚实标注）**：

| 代价 | 说明 | 缓解 |
|------|------|------|
| **两份 JSON** | `accepted_questions.json`（便宜门过，Phase A 原始产物）不再是最终题库，最终是 `accepted_questions_final.json`（agent 门过）。使用者（训练/评估脚本）若读错会用到未过 agent 门的题 | 明确命名 + 文档标注训练脚本必须读 `_final`。**不覆盖写回**——覆盖写回会与补生成的 `on_accept` 追加产生时序打架，故保留两份、各有明确语义。这是本架构无法消除的真实代价 |
| **补生成多 run** | 被过滤题需再跑 Phase A 补缺额，多次出题 run | 迭代上限 `adversarial_max_rounds` 兜底 |
| **agent + 镜像成本翻倍** | 每题一次 agent 试答，supports_flip 的题额外一次镜像生成 + 一对试答 | 批次后置只对便宜门过的题跑，不浪费在中途废题 |

净判断：独立后置层用"两份 JSON + 补生成成本"换"现有状态机零改动 + 从完整 JSON 重建"，符合"避免过度修改引入 bug"的约束。

## 3. 架构与数据流

```mermaid
flowchart TD
    A[Phase A 产物 accepted_questions.json<br/>N 道便宜门过的 AR 题] --> B[adversarial_filter 后置过滤]
    B --> C[作弊者门: 完整 agent 标准设定试答每题]
    C -->|agent 答对=太简单| D[verdict=filtered_too_easy]
    C -->|agent 答错=有效| E{supports_flip?}
    E -->|否| H[verdict=passed]
    E -->|是| F[现场生成镜像题 + agent 试答一对]
    F -->|P/Q 答案不同=随问题翻转| H
    F -->|P/Q 答案相同=没翻转/瞎选| G[verdict=filtered_no_flip]
    D --> I[统计缺额]
    G --> I
    H --> J[写 final: accepted_questions_final.json]
    I -->|缺额>0 且 轮次<上限| K[调 run_pipeline_v2 补生成<br/>避开已用节点/已过滤题]
    K --> A
    I -->|够数或达轮次上限| L[难度报告 + 告警]
```

## 4. 组件设计

### 4.1 作弊者门（`adversarial_filter.py`）

对 `accepted_questions.json` 中每道 AR 题，用完整 inference agent（**标准设定**，看完整题；agent 配置 skill_mode/max_steps 从训练配置继承）试答：
- agent 答对 → `verdict=filtered_too_easy`（EOB/排除法残余，剔除）
- agent 答错 → 进入配对翻转门

`adversarial_verdicts` 表（schema 补齐以支撑严格续跑）：

| 列 | 说明 |
|----|------|
| `question_id` | 题标识（跨轮唯一，见 §4.3 ID 方案） |
| `round` | 过滤轮次 |
| `stage` | `cheat`（作弊门）\| `flip_original` \| `flip_mirror`（区分同一 pair 的三次 agent 试答） |
| `question_hash` | 原题 payload（question+options+answer）hash，防 JSON 变动后误用旧 verdict |
| `agent_prediction` | agent 答案 |
| `agent_correct` | 是否答对（作弊门用） |
| `verdict` | `passed` \| `filtered_too_easy` \| `filtered_no_flip` \| `flip_skipped` |
| `pair_id` | 关联原题与镜像题 |
| `agent_config` | agent 配置指纹（skill_mode/max_steps/model），固化以保证续跑一致 |

续跑：重启时按 `(question_id, question_hash, stage)` 查已完成的试答，未完成的重跑；`agent_config` 变化则该题全部 verdict 作废重判。每题试答结果**立即落表**（崩溃不丢）。

### 4.2 配对翻转门（现场生成镜像，仅 supports_flip 的 SubPattern）

**声明粒度是 SubPattern 级（不是 strategy 级）**：`SubPattern` 数据类加 `supports_flip: bool` + `flip_axis: str | None`（"before/after" | "first/last"）。因为 AR 内部 6 个 SubPattern 只有部分支持翻转：temporal_reasoning_failure=before/after、cross_segment_entity_tracking=first/last；其余 4 个=None。Phase B 按题的 `sub_pattern`（从 JSON 读，见 §依赖）查该 SubPattern 的 flip_axis。

**判定语义（修正前版逻辑洞）**：配对翻转**不要求答对，只看答案是否随问题翻转**。前版"两题都答对才 passed"与作弊门"agent 答错才进入"直接矛盾（同一 agent 对原题先错后对判定不稳定）。修正为：

| agent 对 (P, Q) 的答案 | 含义 | verdict |
|----------------------|------|---------|
| 两题给**不同**答案 | 答案随问题翻转 → agent 在尝试理解视频（即使都错）| **passed**（有效难题） |
| 两题给**相同**答案 | 问题明明相反却不翻转 → agent 没看视频瞎选、题诱导瞎选（有偏置）| **filtered_no_flip**（剔除） |

**流程（完全在 Phase B 内，Phase A 不动）**：
1. 从原题 `source_nodes` 重建素材（Phase B 已持有树）；
2. 现场调 VLM 生成镜像题（同素材、翻转 flip_axis：`"X 之前"`→`"X 之后"`），正解天然相反；
3. 原题 P 的 agent 预测**复用作弊门那次结果**（不重跑，避免同一 agent 非确定性引入噪声）；仅新跑镜像题 Q；同一 `pair_id`。
4. 按上表判定（比较 P/Q 所选是否指向同一语义内容，不判对错）。
5. 镜像生成失败（造不出合法 before/after 对）→ `flip_skipped`，该题只经作弊门判定，不误杀。

**判定工程化细则（消除判定噪声）**：
- **按 canonical 语义比较，不比字母**：镜像题选项会重洗牌，A/B/C/D 字母无语义；比较 agent 所选**选项规范化文本**（canonical option）是否指向同一内容。
- **无效答案保守处理**：P/Q 任一为拒答/非法答案 → 不判 passed，记 `flip_skipped`（退回只经作弊门），避免"P 拒答、Q 随机给答"被误判为翻转。
- **镜像正解字面校验**：镜像生成后校验 `canonical_correct(P) != canonical_correct(Q)`（before/after 不总保证正解不同，如列表答案、"无法确定"类）；相同则造不出有效镜像对 → `flip_skipped`。

镜像题仅用于翻转检验，**不进最终题库**。

**门顺序（逻辑自洽）**：① 作弊门用完整 agent 试答原题，答对=太简单→剔除；② 存活的"agent 答错"题进翻转门，看 P/Q 答案是否翻转。两门都用完整 agent，串联无矛盾：答对的在作弊门被剔除，答错的在翻转门按"答案是否翻转"区分"有效难题"vs"诱导瞎选的偏置题"。

### 4.3 补生成与迭代

缺额 = 目标数 − passed 数。缺额 >0 且轮次 < `adversarial_max_rounds` 时，调 `run_pipeline_v2`（Phase A）补生成。

**需要的接口改动（明确，非"显式传入即可"一笔带过）**：`run_pipeline_v2` 现每 run 新建空 `embed_pool`/`used_node_ids`（`pipeline_v2.py:793` 附近），补生成需新增两个可选参数：
- `initial_used_node_ids: set[str] | None` — 传入已用节点，补生成避开（sampler 的 used_node_ids 初值）；
- `initial_embed_pool: list[np.ndarray] | None` — 传入已接受题的 embedding，补生成对它们去重。
不传时行为与现状完全一致（默认 None → 空初始化），11 题型不受影响。

**question_id 跨轮防撞**：现 `question_id = f"{video_id}_{task_type}_{seq:04d}"`（`generator_v2.py:340`）。补生成新 run 复用 seq 会撞 ID。方案：补生成 run 传入 `seq_offset`（= 上一轮最大 seq），新题从 offset 续编，保证 `accepted_questions.json` 与 `adversarial_verdicts` 中 ID 全局唯一。

**两份 JSON 时序（消除歧义）**：
- `accepted_questions.json`：Phase A 各 run（含补生成）的 `on_accept` **滚动追加**（保持 Phase A 语义）。
- `accepted_questions_final.json`：Phase B 每轮结束**全量重写**（tmp+os.replace 原子写），内容 = 当前所有 `verdict=passed` 的题。非增量追加——避免断点在"verdict 已写、final 未重写"之间时状态不一致；final 可随时从 verdicts 表全量重建。

### 4.4 难度报告（`run_store.py`）

- 每轮记录 agent 正确率（=作弊门答对率，越低越难）到 `adversarial_verdicts` 聚合。
- 批次 agent 正确率 > `difficulty_warn_threshold`（默认 0.85）→ `logger.warning` 告警"出题太简单"。
- 难度用独立字段（agent_correct 聚合），**不复用** `difficulty_steps`（那是 heavy_check 步数）。

## 5. Prior-Version Audit（Phase A 后的管线行为）

| Phase A 后行为 | Phase B 处置 |
|---------------|-------------|
| on_accept 逐题追加 JSON（便宜门过即 accepted） | **保留** — Phase B 不改，只在其产物之上过滤 |
| record_item/update_gates 状态机（final_status） | **保留** — Phase B 用独立 `adversarial_verdicts` 表 |
| load_progress 断点续跑 | **保留** — Phase B 有独立过滤进度（未试答题续跑） |
| run_pipeline_v2 逐 slot 出题 | **保留** — 补生成是标准 Phase A run |
| embed_pool/used_node_ids 每 run 独立初始化 | **保留** — 补生成新 run 不继承，显式传避开节点 |
| 四门 gate | **保留** — 作弊门/翻转门是 JSON 产物之后的独立层 |

未发现隐式删除。Phase B 是 additive 层，Phase A 状态机零改动。

## 6. 非功能性需求

| 维度 | 设计 |
|------|------|
| **持久化** | 每题 agent 试答结果按 `(question_id, question_hash, stage)` 立即写 `adversarial_verdicts` 表；final JSON 每轮全量重写（tmp+os.replace）。原题 GeneratedQuestion 从 Phase A 的 JSON 完整重建，无需 DB 重建 |
| **幂等性** | agent 试答非确定性（LLM 固有），但过滤决策幂等（同 verdict 不重复处理）；`question_hash` 防 JSON 变动误用旧 verdict；补生成用递增 seed + seq_offset 可复现 |
| **断点续跑** | `adversarial_verdicts` 按 `(question_id, question_hash, stage)` 记录已完成试答，重启只重跑未完成的；`agent_config` 变则该题 verdict 作废重判。补生成复用 Phase A progress |
| **原子性** | 每题 verdict 单条 DB 写；final JSON 全量 tmp+os.replace 原子重写（非追加，可随时从 verdicts 表重建） |

## 7. 行为保真检查清单

| # | 行为 | 状态 |
|---|------|------|
| 1 | Phase A 出题 + accepted 语义 + on_accept | 保留（零改动） |
| 2 | 四门 gate / 断点续跑 / record_item 状态机 | 保留 |
| 3 | 11 个非 AR 题型 | 保留（不在 `filter_task_types` 中，不触发过滤） |
| 4 | AR 最终题库 | **有意变更** — 经 agent 对抗过滤 + 翻转检验 |

## 8. 配置参数（YAML）

| 参数 | 默认 | 说明 |
|------|------|------|
| `filter_task_types` | `[Action Recognition]` | 后置过滤作用的题型（filter 层配置，非 strategy） |
| `adversarial_max_rounds` | 5 | 后置过滤迭代上限 |
| `adversarial_agent_max_steps` | 40 | agent 试答步数上限 |
| `difficulty_warn_threshold` | 0.85 | 批次 agent 正确率告警阈值 |

## 9. 测试策略

- 单元：翻转判定（构造 agent 对 P/Q 的答案 → 验证"答案不同=passed / 答案相同=filtered_no_flip"，不判对错）；作弊门 verdict（agent 答对=filtered_too_easy）；迭代缺额计算 + 轮次上限 + seq_offset 防撞 ID。
- 集成：AR 后置过滤端到端（mock agent + mock 镜像 VLM），验证 `accepted_questions.json` → 过滤 → `accepted_questions_final.json`；断点续跑（中断后从 adversarial_verdicts 未试答题继续，已判的不重跑）。
- 回归：11 个非 AR 题型不触发过滤（不在 `filter_task_types` 中，现有测试全绿）。

## 10. 待实现时确认的细节

- 补生成"避开已用 source_nodes"的粒度：整题级去重（已用节点集合传入 sampler 的 used_node_ids）已足够，无需节点级黑名单持久化跨 run（每轮显式传入即可）。
- 镜像题 VLM 生成失败（无法造出合法 before/after 对）时：该题跳过翻转门、只经作弊门判定（记 flip_skipped，不算 filtered），避免因镜像生成失败误杀有效题。
