feat: add adversarial round loop with backfill iteration and difficulty report

This commit is contained in:
2026-07-14 16:40:05 -04:00
parent c1565a01c2
commit 73d0bb9190
2 changed files with 305 additions and 1 deletions
+140 -1
View File
@@ -12,6 +12,7 @@ from __future__ import annotations
import enum
import hashlib
import json
import os
from pathlib import Path
from typing import TYPE_CHECKING, Protocol, overload
@@ -602,6 +603,9 @@ def write_final_bank(
终判集合来自 `final_passed_question_ids`(当前 hash+config 下 cheat=passed 且
无 filtered_no_flip 行)。镜像题不在 questions_by_id 中,天然被排除。
原子性:先写同目录 `.tmp`,再 `os.replace` 覆盖目标,保证任何时刻读到的
final JSON 都是完整的(迭代循环每轮全量重写,崩溃可从 verdicts 表重建)。
参数:
out_path: 输出 JSON 路径。
store: verdict 来源。
@@ -616,5 +620,140 @@ def write_final_bank(
records = [
_question_to_record(questions_by_id[qid]) for qid in questions_by_id if qid in passed
]
out_path.write_text(json.dumps(records, ensure_ascii=False, indent=2), encoding="utf-8")
out_path.parent.mkdir(parents=True, exist_ok=True)
tmp = out_path.with_suffix(".tmp")
tmp.write_text(json.dumps(records, ensure_ascii=False, indent=2), encoding="utf-8")
os.replace(str(tmp), str(out_path))
logger.info("final 题库全量重写: {} 题 → {}", len(records), out_path)
return records
def _report_difficulty(store: QuestionGenStore, *, round_no: int, threshold: float) -> float:
"""记录并按阈值告警本轮 agent 正确率(作弊门聚合)。
正确率来自 `cheat_agent_accuracy`stage='cheat' 的 agent_correct 平均),高于
阈值说明 agent 太容易答对 = 出题偏简单,据此告警提示提高难度。
参数:
store: verdict 来源。
round_no: 当前轮次。
threshold: 正确率告警阈值(超过即 WARNING)。
返回:
本轮 agent 正确率。
"""
acc = store.cheat_agent_accuracy(round_no)
logger.info("难度报告 round={}: agent 正确率={:.2%}", round_no, acc)
if acc > threshold:
logger.warning(
"出题太简单: round={} agent 正确率={:.2%} > 阈值 {:.2%}",
round_no,
acc,
threshold,
)
return acc
class BackfillFn(Protocol):
"""补生成回调 — 缺额驱动,返回新增 AR 题。
真实实现由 Task 10 用 run_pipeline_v2 组装(传 initial_used_node_ids /
initial_embed_pool / seq_offset 防与已有题碰撞);单测可注入返回定制新题的假实现。
"""
async def __call__(
self,
deficit: int,
round_no: int,
existing: dict[str, GeneratedQuestion],
) -> list[GeneratedQuestion]:
"""按缺额补生成新题。
参数:
deficit: 目标缺额(target - 当前终判 passed 数)。
round_no: 当前轮次。
existing: 已有全部原题(供实现计算 used_node_ids / seq_offset)。
返回:
新增 AR 题列表(长度应等于 deficit)。
"""
...
async def run_adversarial_rounds(
initial_questions: list[GeneratedQuestion],
*,
agent: AgentRunner,
vlm: VLMProvider,
store: QuestionGenStore,
trees: dict[str, TreeIndex],
config: AdversarialFilterConfig,
final_path: Path,
target: int,
backfill: BackfillFn,
session_id: str,
) -> None:
"""两门 + 补生成迭代主循环,每轮全量重写 final 并做难度报告。
每轮:作弊门 → 翻转门 → 全量重写 `accepted_questions_final.json`(原子)→ 难度报告。
缺额 deficit = target - 终判 passed 数;deficit≤0 或已达轮次上限即停止(不无限循环),
否则调 `backfill` 补生成 deficit 题并只对新题重跑两门(已判题走续跑,不重跑)。
参数:
initial_questions: 首轮 AR 题(来自 accepted_questions.json 过滤)。
agent: 完整 agent 试答端口。
vlm: 镜像题生成 VLM 端口。
store: verdict 持久化。
trees: video_id → 三层树索引(重建镜像素材用)。
config: 过滤配置(轮次上限 / max_steps / 难度阈值)。
final_path: 最终题库 JSON 路径(每轮全量原子重写)。
target: 目标 passed 题数(缺额 = target - passed,锁定为首轮 AR 题数)。
backfill: 补生成回调 (deficit, round_no, existing) → 新增题;Task 10 用
run_pipeline_v2 实现,单测可 mock。
session_id: 遥测会话 ID(派生各门 run_id)。
"""
cfg_fp = agent_config_fingerprint(
skill_mode=agent.skill_mode,
max_steps=config.adversarial_agent_max_steps,
model=agent.model,
)
all_questions: dict[str, GeneratedQuestion] = {
q.question_id: q for q in initial_questions
}
pending = list(initial_questions)
passed_now = 0
for round_no in range(config.adversarial_max_rounds):
survivors = await run_cheater_gate(
pending,
agent=agent,
store=store,
config=config,
round_no=round_no,
run_id=f"{session_id}_cheat_{round_no}",
)
await run_flip_gate(
survivors,
agent=agent,
vlm=vlm,
store=store,
trees=trees,
config=config,
round_no=round_no,
run_id=f"{session_id}_flip_{round_no}",
session_id=session_id,
)
passed_now = len(write_final_bank(final_path, store, all_questions, cfg_fp))
_report_difficulty(
store, round_no=round_no, threshold=config.difficulty_warn_threshold
)
deficit = target - passed_now
if deficit <= 0 or round_no + 1 >= config.adversarial_max_rounds:
break
new_qs = await backfill(deficit, round_no, all_questions)
assert len(new_qs) == deficit, (
f"backfill 应产出 {deficit} 题,实得 {len(new_qs)}(缺额驱动契约)"
)
for q in new_qs:
all_questions[q.question_id] = q
pending = new_qs # 只对新补的题重新过滤(已判题走续跑)
logger.info("对抗过滤结束: final={} 题(target={}", passed_now, target)