"""确定性后处理层 — 零 LLM 的选项洗牌、指代黑名单、逐字重复率、时间锚点、素材禁区检测。 所有函数均为纯函数(给定输入必定产出相同输出), 用于 pipeline 出题后、门控前的确定性质量检查。 """ from __future__ import annotations import re from dataclasses import dataclass from typing import TYPE_CHECKING from loguru import logger if TYPE_CHECKING: import random # --------------------------------------------------------------------------- # 数据结构 # --------------------------------------------------------------------------- @dataclass(frozen=True) class PostprocessResult: """后处理结果汇总。 属性: options: 洗牌后的选项元组。 answer: 重映射后的答案字母。 referent_violations: 指代黑名单违规描述列表。 verbatim_ratio: 正确选项与来源素材的逐字重复率 [0.0, 1.0]。 has_time_anchor: 题目是否包含时间锚点。 """ options: tuple[str, ...] answer: str referent_violations: list[str] verbatim_ratio: float has_time_anchor: bool # --------------------------------------------------------------------------- # 指代黑名单(预编译正则) # --------------------------------------------------------------------------- _BLACKLIST_PATTERNS: list[tuple[re.Pattern[str], str]] = [ (re.compile(r"this\s+clip", re.IGNORECASE), "this clip"), (re.compile(r"the\s+video", re.IGNORECASE), "the video"), (re.compile(r"this\s+video", re.IGNORECASE), "this video"), (re.compile(r"this\s+scene", re.IGNORECASE), "this scene"), (re.compile(r"the\s+clip", re.IGNORECASE), "the clip"), (re.compile(r"this\s+footage", re.IGNORECASE), "this footage"), (re.compile(r"the\s+footage", re.IGNORECASE), "the footage"), (re.compile(r"上面的片段", re.IGNORECASE), "上面的片段"), (re.compile(r"这段视频", re.IGNORECASE), "这段视频"), (re.compile(r"该视频", re.IGNORECASE), "该视频"), (re.compile(r"这个片段", re.IGNORECASE), "这个片段"), (re.compile(r"视频中", re.IGNORECASE), "视频中"), ] # --------------------------------------------------------------------------- # 时间锚点正则与短语 # --------------------------------------------------------------------------- _TIMESTAMP_PATTERN = re.compile(r"\d{1,2}:\d{2}") _TIME_ANCHOR_PHRASES: list[re.Pattern[str]] = [ re.compile(r"at\s+the\s+beginning", re.IGNORECASE), re.compile(r"at\s+the\s+end", re.IGNORECASE), re.compile(r"in\s+the\s+beginning", re.IGNORECASE), re.compile(r"at\s+the\s+start", re.IGNORECASE), re.compile(r"开头", re.IGNORECASE), re.compile(r"结尾", re.IGNORECASE), re.compile(r"末尾", re.IGNORECASE), re.compile(r"片头", re.IGNORECASE), re.compile(r"片尾", re.IGNORECASE), ] # --------------------------------------------------------------------------- # T1 / T7 素材禁区正则 # --------------------------------------------------------------------------- # T1: 瞬时动作 _T1_INSTANT_ACTION_PATTERNS: list[re.Pattern[str]] = [ re.compile(r"瞬间|一闪而过|转瞬即逝|一瞬间|刹那", re.IGNORECASE), re.compile(r"flash|instant|split\s*second|blink", re.IGNORECASE), ] # T1: 记分牌时序 _T1_SCOREBOARD_PATTERNS: list[re.Pattern[str]] = [ re.compile(r"记分牌|比分.*变|比分.*更新|得分.*变化", re.IGNORECASE), re.compile(r"scoreboard|score\s*(changed|updated|went)", re.IGNORECASE), ] # T1: 无对白因果 _T1_NO_DIALOGUE_CAUSAL_PATTERNS: list[re.Pattern[str]] = [ re.compile(r"无对白.*因果|因果.*无法.*判断", re.IGNORECASE), re.compile(r"no\s+dialogue.*caus|cannot.*determin.*caus", re.IGNORECASE), ] # T7: 选项重复 _T7_OPTION_REPETITION_PATTERNS: list[re.Pattern[str]] = [ re.compile(r"重复选项|选项.*相同|选项.*重复|完全相同", re.IGNORECASE), re.compile(r"duplicate\s+option|identical\s+option|same\s+option", re.IGNORECASE), ] # T7: 计数边界口径含糊 _T7_COUNTING_AMBIGUITY_PATTERNS: list[re.Pattern[str]] = [ re.compile(r"大约.*数量|数量不确定|难以.*计数|不确定.*几|约.*个", re.IGNORECASE), re.compile(r"approximate.*count|uncertain.*number|hard\s+to\s+count", re.IGNORECASE), ] # --------------------------------------------------------------------------- # 公开函数 # --------------------------------------------------------------------------- def shuffle_options( options: tuple[str, ...], answer: str, rng: random.Random, ) -> tuple[tuple[str, ...], str]: """确定性洗牌选项并重映射答案字母。 通过 index permutation 打乱选项顺序,为每个选项重新分配 A/B/C/D 前缀, 并将答案字母映射到正确选项的新位置。 参数: options: 原始选项元组,格式为 ("A. text1", "B. text2", ...)。 answer: 原始答案字母("A"/"B"/"C"/"D")。 rng: 可控随机数生成器(保证确定性)。 返回: (新选项元组, 新答案字母) — 选项文本不变,仅前缀和顺序改变。 """ # Phase 1: 提取纯文本(去掉 "X. " 前缀) texts = [opt[3:] for opt in options] # Phase 2: 确定原正确选项的文本 correct_idx = ord(answer) - ord("A") correct_text = texts[correct_idx] # Phase 3: 生成随机排列 indices = list(range(len(texts))) rng.shuffle(indices) # Phase 4: 按排列重组,分配新前缀 new_options: list[str] = [] new_answer = "" for new_pos, old_idx in enumerate(indices): letter = chr(ord("A") + new_pos) new_options.append(f"{letter}. {texts[old_idx]}") if texts[old_idx] == correct_text: new_answer = letter return tuple(new_options), new_answer def check_referent_blacklist(question_text: str) -> list[str]: """检测题目文本中的自指代语言。 在 benchmark 题目中,自指代(如 "this clip"、"这段视频")会泄露视频上下文, 使题目脱离视频后无法独立理解。 参数: question_text: 题目文本。 返回: 违规描述列表,空列表表示通过。 """ violations: list[str] = [] for pattern, label in _BLACKLIST_PATTERNS: if pattern.search(question_text): violations.append(f"检测到自指代: '{label}'") return violations def check_verbatim( question_text: str, correct_option: str, source_texts: list[str], window: int = 6, ) -> float: """计算正确选项与来源素材的逐字重复率。 使用滑动窗口 n-gram 集合交集方法:从选项文本提取所有 n-gram, 与来源文本的 n-gram 集合求交集,计算重叠比例。 参数: question_text: 题目文本(当前未使用,预留接口)。 correct_option: 正确选项文本(含 "X. " 前缀)。 source_texts: 来源素材文本列表。 window: n-gram 窗口大小。 返回: 重复率 [0.0, 1.0]。0.0 表示无重叠,1.0 表示完全复制。 """ # Phase 1: 提取选项纯文本(去掉可能的 "X. " 前缀) option_text = correct_option if len(option_text) >= 3 and option_text[1] == "." and option_text[2] == " ": option_text = option_text[3:] # Phase 2: 分词(简单空格分词,转小写) option_words = option_text.lower().split() # Phase 3: 选项词数不足窗口大小则无法构成 n-gram if len(option_words) < window: return 0.0 # Phase 4: 构造选项的 n-gram 集合 option_ngrams: set[tuple[str, ...]] = set() for i in range(len(option_words) - window + 1): option_ngrams.add(tuple(option_words[i : i + window])) if not option_ngrams: return 0.0 # Phase 5: 构造来源文本的 n-gram 集合 source_ngrams: set[tuple[str, ...]] = set() for source in source_texts: words = source.lower().split() for i in range(len(words) - window + 1): source_ngrams.add(tuple(words[i : i + window])) if not source_ngrams: return 0.0 # Phase 6: 计算交集比例 overlap = option_ngrams & source_ngrams return len(overlap) / len(option_ngrams) def has_time_anchor(question_text: str) -> bool: """检测题目中是否包含时间锚点。 时间锚点包括:数字时间戳(如 "01:30")和时间短语(如 "at the beginning"、"开头")。 参数: question_text: 题目文本。 返回: True 表示包含时间锚点。 """ # Phase 1: 检查数字时间戳 if _TIMESTAMP_PATTERN.search(question_text): return True # Phase 2: 检查时间短语 return any(pattern.search(question_text) for pattern in _TIME_ANCHOR_PHRASES) def check_forbidden_material(source_nodes_text: str, task_type: str) -> list[str]: """出题禁区:检测 T1 类素材和 T7 噪声模式。 T1 类素材(不适合出题的内容): - 瞬时动作:画面一闪而过,无法稳定观察 - 记分牌时序:依赖数字变化的时序信息 - 无对白因果:缺乏语言线索的因果推理 T7 噪声模式(选项质量问题): - 选项重复:多个选项表述相同 - 计数边界口径含糊:数量描述不确定 参数: source_nodes_text: 来源节点的拼接文本。 task_type: 题型名称(用于上下文感知检测)。 返回: 违规描述列表,空列表表示通过。 """ violations: list[str] = [] # Phase 1: T1 瞬时动作检测 for pattern in _T1_INSTANT_ACTION_PATTERNS: if pattern.search(source_nodes_text): violations.append("T1 违规: 素材包含瞬时动作描述,不适合出题") break # Phase 2: T1 记分牌时序检测 for pattern in _T1_SCOREBOARD_PATTERNS: if pattern.search(source_nodes_text): violations.append("T1 违规: 素材包含记分牌时序信息,不适合出题") break # Phase 3: T1 无对白因果检测 for pattern in _T1_NO_DIALOGUE_CAUSAL_PATTERNS: if pattern.search(source_nodes_text): violations.append("T1 违规: 素材缺乏对白因果线索,不适合出题") break # Phase 4: T7 选项重复检测 for pattern in _T7_OPTION_REPETITION_PATTERNS: if pattern.search(source_nodes_text): violations.append("T7 违规: 素材暗示可能产生重复选项") break # Phase 5: T7 计数边界口径含糊检测 for pattern in _T7_COUNTING_AMBIGUITY_PATTERNS: if pattern.search(source_nodes_text): violations.append("T7 违规: 素材包含计数边界含糊描述") break return violations def run_postprocess( question_text: str, options: tuple[str, ...], answer: str, source_texts: list[str], rng: random.Random, ) -> PostprocessResult: """编排全部后处理检查,返回汇总结果。 执行流程: 1. 选项洗牌(确定性) 2. 指代黑名单检测 3. 逐字重复率计算(基于洗牌后的正确选项) 4. 时间锚点检测 参数: question_text: 题目文本。 options: 原始选项元组("A. text", "B. text", ...)。 answer: 原始答案字母。 source_texts: 来源素材文本列表。 rng: 可控随机数生成器。 返回: PostprocessResult 汇总实例。 """ # Phase 1: 选项洗牌 shuffled_options, new_answer = shuffle_options(options, answer, rng) # Phase 2: 指代黑名单 referent_violations = check_referent_blacklist(question_text) # Phase 3: 逐字重复率(用洗牌后正确选项的文本) correct_idx = ord(new_answer) - ord("A") correct_option_text = shuffled_options[correct_idx] verbatim_ratio = check_verbatim(question_text, correct_option_text, source_texts) # Phase 4: 时间锚点 time_anchor = has_time_anchor(question_text) logger.debug( "后处理完成: referent_violations={}, verbatim_ratio={:.3f}, has_time_anchor={}", len(referent_violations), verbatim_ratio, time_anchor, ) return PostprocessResult( options=shuffled_options, answer=new_answer, referent_violations=referent_violations, verbatim_ratio=verbatim_ratio, has_time_anchor=time_anchor, )