Files
iomgaa 7abe92eb1c fix(question_gen): check_verbatim covers question_text + add missing blacklist patterns
- check_verbatim now computes n-gram overlap for BOTH question_text and
  correct_option vs source texts, returning max(question_ratio, option_ratio).
  Extracted _ngram_overlap_ratio helper for reuse.

- Added 4 missing blacklist patterns: 'this segment', 'this frame',
  'the current frame', 'frame summary'.

- Added 5 new test cases covering the above changes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-11 23:23:37 -04:00

377 lines
14 KiB
Python

"""postprocess 模块单元测试 — 选项洗牌、指代黑名单、逐字重复率、时间锚点、素材禁区、run_postprocess 编排。"""
from __future__ import annotations
import random
from app.question_gen.postprocess import (
PostprocessResult,
check_forbidden_material,
check_referent_blacklist,
check_verbatim,
has_time_anchor,
run_postprocess,
shuffle_options,
)
# ---------------------------------------------------------------------------
# TestShuffleOptions
# ---------------------------------------------------------------------------
class TestShuffleOptions:
"""选项洗牌确定性与答案重映射测试。"""
def test_deterministic_with_seed(self) -> None:
"""相同种子产生相同置换结果。"""
options = ("A. 苹果", "B. 香蕉", "C. 橙子", "D. 葡萄")
answer = "B"
rng1 = random.Random(42)
rng2 = random.Random(42)
result1 = shuffle_options(options, answer, rng1)
result2 = shuffle_options(options, answer, rng2)
assert result1 == result2
def test_answer_remapped_correctly(self) -> None:
"""洗牌后 answer 字母始终指向原正确文本。"""
options = ("A. 苹果", "B. 香蕉", "C. 橙子", "D. 葡萄")
answer = "B" # 正确文本是 "香蕉"
# 用多个不同种子确保覆盖不同排列
for seed in range(100):
rng = random.Random(seed)
new_options, new_answer = shuffle_options(options, answer, rng)
# 答案字母对应的选项文本必须包含 "香蕉"
answer_idx = ord(new_answer) - ord("A")
assert "香蕉" in new_options[answer_idx], (
f"seed={seed}: 新答案 {new_answer} 对应 '{new_options[answer_idx]}',不含 '香蕉'"
)
def test_all_options_preserved(self) -> None:
"""洗牌后选项内容集合不变(仅字母前缀重映射)。"""
options = ("A. 苹果", "B. 香蕉", "C. 橙子", "D. 葡萄")
answer = "A"
rng = random.Random(7)
new_options, _ = shuffle_options(options, answer, rng)
# 提取纯文本(去除 "X. " 前缀)
original_texts = {opt[3:] for opt in options}
shuffled_texts = {opt[3:] for opt in new_options}
assert original_texts == shuffled_texts
def test_output_letters_sequential(self) -> None:
"""洗牌后选项前缀始终为 A/B/C/D 顺序。"""
options = ("A. one", "B. two", "C. three", "D. four")
answer = "C"
rng = random.Random(99)
new_options, _ = shuffle_options(options, answer, rng)
for i, opt in enumerate(new_options):
expected_prefix = f"{chr(ord('A') + i)}. "
assert opt.startswith(expected_prefix), (
f"选项 {i} 应以 '{expected_prefix}' 开头,实际为 '{opt[:3]}'"
)
# ---------------------------------------------------------------------------
# TestReferentBlacklist
# ---------------------------------------------------------------------------
class TestReferentBlacklist:
"""指代黑名单检测测试。"""
def test_clean_passes(self) -> None:
"""无自指代语言的正常题目返回空列表。"""
q = "What is the main character doing in the kitchen?"
violations = check_referent_blacklist(q)
assert violations == []
def test_this_clip_caught(self) -> None:
"""'this clip' 被检测为违规。"""
q = "What happens in this clip after the man enters?"
violations = check_referent_blacklist(q)
assert len(violations) >= 1
assert any("this clip" in v.lower() for v in violations)
def test_the_video_caught(self) -> None:
"""'the video' 被检测为违规。"""
q = "In the video, what color is the car?"
violations = check_referent_blacklist(q)
assert len(violations) >= 1
def test_chinese_referent_caught(self) -> None:
"""中文自指代 '这段视频' 被检测。"""
q = "这段视频中男人在做什么?"
violations = check_referent_blacklist(q)
assert len(violations) >= 1
def test_this_scene_caught(self) -> None:
"""'this scene' 被检测为违规。"""
q = "What is shown in this scene before the explosion?"
violations = check_referent_blacklist(q)
assert len(violations) >= 1
def test_case_insensitive(self) -> None:
"""大小写不影响检测。"""
q = "What does THIS CLIP show at the end?"
violations = check_referent_blacklist(q)
assert len(violations) >= 1
def test_this_segment_caught(self) -> None:
"""'this segment' 被检测为违规。"""
q = "What action is performed in this segment?"
violations = check_referent_blacklist(q)
assert len(violations) >= 1
assert any("this segment" in v.lower() for v in violations)
def test_this_frame_caught(self) -> None:
"""'this frame' 被检测为违规。"""
q = "What object is visible in this frame?"
violations = check_referent_blacklist(q)
assert len(violations) >= 1
def test_current_frame_caught(self) -> None:
"""'the current frame' 被检测为违规。"""
q = "Based on the current frame, what is happening?"
violations = check_referent_blacklist(q)
assert len(violations) >= 1
def test_frame_summary_caught(self) -> None:
"""'frame summary' 被检测为违规。"""
q = "According to the frame summary, what is the main event?"
violations = check_referent_blacklist(q)
assert len(violations) >= 1
# ---------------------------------------------------------------------------
# TestVerbatim
# ---------------------------------------------------------------------------
class TestVerbatim:
"""逐字重复率检测测试。"""
def test_zero_overlap(self) -> None:
"""正确选项与来源文本完全无重叠时返回 0.0。"""
ratio = check_verbatim(
question_text="What color is the sky?",
correct_option="A. The sky is painted in bright orange",
source_texts=["A man walks into a store and buys groceries for dinner."],
window=6,
)
assert ratio == 0.0
def test_full_copy_returns_one(self) -> None:
"""正确选项完全来自来源文本时返回 1.0。"""
source = "The cat sat on the mat near the window looking outside"
option = "A. The cat sat on the mat near the window looking outside"
ratio = check_verbatim(
question_text="What did the animal do?",
correct_option=option,
source_texts=[source],
window=6,
)
assert ratio == 1.0
def test_partial(self) -> None:
"""部分重叠返回 0-1 之间的值。"""
source = "The large brown dog jumped over the lazy red fox quickly"
# 选项中 6 词窗口 "large brown dog jumped over the" 出现在 source 中
option = "A. A large brown dog jumped over the small fence"
ratio = check_verbatim(
question_text="What happened?",
correct_option=option,
source_texts=[source],
window=6,
)
assert 0.0 < ratio < 1.0
def test_empty_source_returns_zero(self) -> None:
"""来源文本为空列表时返回 0.0。"""
ratio = check_verbatim(
question_text="What is this?",
correct_option="A. Something interesting",
source_texts=[],
window=6,
)
assert ratio == 0.0
def test_short_option_below_window(self) -> None:
"""选项词数不足窗口大小时仍正常工作(返回 0.0)。"""
ratio = check_verbatim(
question_text="What?",
correct_option="A. Hello world",
source_texts=["Some very long source text that goes on and on"],
window=6,
)
assert ratio == 0.0
def test_question_text_overlap_detected(self) -> None:
"""题目文本从来源复制但选项原创时,仍检测到高重叠率。"""
source = "The man walked slowly through the crowded market buying fresh vegetables"
# 题目直接复制来源的大部分内容
question = "The man walked slowly through the crowded market buying what?"
# 选项完全原创
option = "A. Fresh organic produce from the local farm stand"
ratio = check_verbatim(
question_text=question,
correct_option=option,
source_texts=[source],
window=6,
)
assert ratio > 0.0, "题目文本的逐字重叠应被检测到"
# ---------------------------------------------------------------------------
# TestTimeAnchor
# ---------------------------------------------------------------------------
class TestTimeAnchor:
"""时间锚点检测测试。"""
def test_timestamp(self) -> None:
"""包含 '01:30' 格式时间戳 → True。"""
assert has_time_anchor("What happens at 01:30 in the recording?") is True
def test_no_anchor(self) -> None:
"""无任何时间锚点 → False。"""
assert has_time_anchor("What is the man doing in the park?") is False
def test_beginning_phrase(self) -> None:
"""'at the beginning' → True。"""
assert has_time_anchor("At the beginning, what does the woman say?") is True
def test_chinese_beginning(self) -> None:
"""'开头' → True。"""
assert has_time_anchor("视频开头出现了什么?") is True
def test_chinese_ending(self) -> None:
"""'结尾' → True。"""
assert has_time_anchor("在视频结尾发生了什么?") is True
def test_minute_second_format(self) -> None:
"""'12:45' 格式 → True。"""
assert has_time_anchor("At 12:45, who appears on screen?") is True
# ---------------------------------------------------------------------------
# TestCheckForbiddenMaterial
# ---------------------------------------------------------------------------
class TestCheckForbiddenMaterial:
"""素材禁区检测测试。"""
def test_clean_material_passes(self) -> None:
"""正常素材无违规。"""
source = "一个男人走进厨房,开始准备晚餐,切菜并烧水。"
violations = check_forbidden_material(source, "Action Recognition")
assert violations == []
def test_t1_instant_action_detected(self) -> None:
"""T1 瞬时动作模式被检测。"""
source = "球员瞬间射门得分。一闪而过的画面。"
violations = check_forbidden_material(source, "Action Recognition")
assert len(violations) >= 1
def test_t1_scoreboard_temporal_detected(self) -> None:
"""T1 记分牌时序模式被检测。"""
source = "比分从2:1变为3:1,记分牌显示更新。"
violations = check_forbidden_material(source, "Counting Problem")
assert len(violations) >= 1
def test_t7_option_repetition_detected(self) -> None:
"""T7 选项重复模式被检测。"""
source = "选项A和选项B完全相同。重复选项无法区分。"
violations = check_forbidden_material(source, "Object Recognition")
assert len(violations) >= 1
def test_t7_counting_ambiguity_detected(self) -> None:
"""T7 计数边界口径含糊被检测。"""
source = "大约有三到五个人,数量不确定,难以精确计数。"
violations = check_forbidden_material(source, "Counting Problem")
assert len(violations) >= 1
# ---------------------------------------------------------------------------
# TestRunPostprocess
# ---------------------------------------------------------------------------
class TestRunPostprocess:
"""run_postprocess 编排测试。"""
def test_returns_postprocess_result(self) -> None:
"""返回类型正确。"""
result = run_postprocess(
question_text="What is the main character doing?",
options=("A. Running", "B. Swimming", "C. Reading", "D. Sleeping"),
answer="A",
source_texts=["The protagonist goes for a morning jog in the park."],
rng=random.Random(42),
)
assert isinstance(result, PostprocessResult)
def test_shuffle_applied(self) -> None:
"""选项顺序被打乱(至少在多次运行中出现过不同排列)。"""
options = ("A. Alpha", "B. Beta", "C. Gamma", "D. Delta")
answer = "A"
seen_orders: set[tuple[str, ...]] = set()
for seed in range(50):
result = run_postprocess(
question_text="Pick one",
options=options,
answer=answer,
source_texts=["Unrelated source text about something else entirely."],
rng=random.Random(seed),
)
seen_orders.add(result.options)
# 50 个不同种子应产生多于 1 种排列
assert len(seen_orders) > 1
def test_referent_violations_populated(self) -> None:
"""有指代违规时正确填入结果。"""
result = run_postprocess(
question_text="What happens in this clip?",
options=("A. One", "B. Two", "C. Three", "D. Four"),
answer="B",
source_texts=["Some text here."],
rng=random.Random(0),
)
assert len(result.referent_violations) >= 1
def test_verbatim_ratio_range(self) -> None:
"""verbatim_ratio 始终在 [0.0, 1.0] 范围内。"""
result = run_postprocess(
question_text="What is shown?",
options=(
"A. A cat sits on the mat",
"B. A dog runs",
"C. A bird flies",
"D. A fish swims",
),
answer="A",
source_texts=["A cat sits on the mat by the window."],
rng=random.Random(1),
)
assert 0.0 <= result.verbatim_ratio <= 1.0
def test_has_time_anchor_field(self) -> None:
"""时间锚点字段正确填写。"""
result = run_postprocess(
question_text="What happens at 02:30?",
options=("A. X", "B. Y", "C. Z", "D. W"),
answer="C",
source_texts=["Some source text."],
rng=random.Random(5),
)
assert result.has_time_anchor is True