afa77173e3
- generator_v2: _load_prompt_template takes template_name str instead of QuestionFamilySpec; _build_v2_prompt takes prompt_template + strategy_name + sub_pattern_instruction; generate_one_v2 takes discrete params (prompt_template, strategy_name, skill_target, sub_pattern_instruction) - gates: _gate_leak_test and run_gates take leak_probe_template str instead of QuestionFamilySpec - run_store: add sub_pattern column to DDL + idempotent migration; record_item accepts optional sub_pattern param - Remove QuestionFamilySpec imports from generator_v2 and gates modules - Update test call sites accordingly Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
335 lines
11 KiB
Python
335 lines
11 KiB
Python
"""轻量四门质量检查单元测试。"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from typing import Any
|
|
|
|
import pytest
|
|
|
|
from app.question_gen.gates import (
|
|
CandidateQuestion,
|
|
GateVerdict,
|
|
_gate_blind_answer,
|
|
_gate_key_verify,
|
|
_gate_leak_test,
|
|
_gate_multi_true,
|
|
run_gates,
|
|
)
|
|
from app.question_gen.postprocess import PostprocessResult
|
|
from app.tree.index import (
|
|
IndexMeta,
|
|
L1Card,
|
|
L1Node,
|
|
L2Card,
|
|
L2Node,
|
|
L3Card,
|
|
L3Node,
|
|
TreeIndex,
|
|
)
|
|
from core.types import LLMResponse
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fixtures
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _make_llm_response(verdict: str, reason: str) -> LLMResponse:
|
|
"""构造一个 LLM 返回值,content 为标准 JSON 格式。"""
|
|
return LLMResponse(
|
|
content=json.dumps({"verdict": verdict, "reason": reason}),
|
|
thinking="",
|
|
model="mock-model",
|
|
provider="mock",
|
|
prompt_tokens=10,
|
|
completion_tokens=5,
|
|
latency_ms=100,
|
|
ttft_ms=None,
|
|
max_inter_token_ms=None,
|
|
cache_hit=False,
|
|
call_id="mock-call-001",
|
|
)
|
|
|
|
|
|
class MockLLM:
|
|
"""可配置的 LLM mock — 按调用顺序返回预设响应。"""
|
|
|
|
def __init__(self, responses: list[LLMResponse]) -> None:
|
|
self._responses = list(responses)
|
|
self._call_count = 0
|
|
self.calls: list[dict[str, Any]] = []
|
|
|
|
async def chat(
|
|
self,
|
|
messages: list[dict[str, Any]],
|
|
*,
|
|
session_id: str | None = None,
|
|
parent_call_id: str | None = None,
|
|
) -> LLMResponse:
|
|
"""记录调用并返回预设响应。"""
|
|
self.calls.append({"messages": messages, "session_id": session_id})
|
|
idx = self._call_count
|
|
self._call_count += 1
|
|
if idx < len(self._responses):
|
|
return self._responses[idx]
|
|
return _make_llm_response("pass", "default")
|
|
|
|
async def chat_with_images(
|
|
self,
|
|
messages: list[dict[str, Any]],
|
|
images: list[Any],
|
|
*,
|
|
session_id: str | None = None,
|
|
parent_call_id: str | None = None,
|
|
) -> LLMResponse:
|
|
"""记录 VLM 调用并返回预设响应(与 chat 共享计数器)。"""
|
|
self.calls.append({"messages": messages, "images": images, "session_id": session_id})
|
|
idx = self._call_count
|
|
self._call_count += 1
|
|
if idx < len(self._responses):
|
|
return self._responses[idx]
|
|
return _make_llm_response("pass", "default")
|
|
|
|
|
|
@pytest.fixture()
|
|
def mock_llm_pass() -> MockLLM:
|
|
"""返回始终 PASS 的 mock LLM。"""
|
|
return MockLLM([_make_llm_response("pass", "evidence found")])
|
|
|
|
|
|
@pytest.fixture()
|
|
def mock_llm_fail() -> MockLLM:
|
|
"""返回始终 FAIL 的 mock LLM。"""
|
|
return MockLLM([_make_llm_response("fail", "no evidence")])
|
|
|
|
|
|
def _make_candidate() -> CandidateQuestion:
|
|
"""构造测试用候选题目。"""
|
|
return CandidateQuestion(
|
|
question_id="q-001",
|
|
video_id="v-001",
|
|
task_type="Action Reasoning",
|
|
skill_target="M1",
|
|
question="What does the person do after picking up the book?",
|
|
options=(
|
|
"A. Reads it",
|
|
"B. Puts it back",
|
|
"C. Throws it away",
|
|
"D. Gives it to someone",
|
|
),
|
|
answer="A",
|
|
source_nodes=("L2_001", "L3_001"),
|
|
difficulty="medium",
|
|
)
|
|
|
|
|
|
def _make_tree() -> TreeIndex:
|
|
"""构造最小测试树。"""
|
|
l3_card = L3Card(
|
|
frame_summary="Person picks up a book from the shelf and starts reading it.",
|
|
visible_entities=["person", "book", "shelf"],
|
|
ongoing_actions=["picking up book", "reading"],
|
|
visible_text=[],
|
|
spatial_layout="person in center, shelf on left",
|
|
visual_attributes={},
|
|
subtitle="He picks up the book and reads.",
|
|
)
|
|
l3 = L3Node(id="L3_001", card=l3_card)
|
|
|
|
l2_card = L2Card(
|
|
event_description="A person picks up a book from the shelf and begins reading it attentively.",
|
|
entities=["person", "book"],
|
|
actions=["pick up", "read"],
|
|
action_subjects=["person"],
|
|
visible_text=[],
|
|
spatial_relations="person near shelf",
|
|
state_changes="book moves from shelf to hands",
|
|
subtitle="He picks up the book and reads.",
|
|
)
|
|
l2 = L2Node(id="L2_001", card=l2_card, children=[l3])
|
|
|
|
l1_card = L1Card(
|
|
scene_summary="Library scene with a person browsing and reading books.",
|
|
main_setting="library",
|
|
key_entities=["person", "books", "shelf"],
|
|
main_actions=["browsing", "reading"],
|
|
topic_keywords=["library", "reading"],
|
|
visible_text=[],
|
|
temporal_flow="enter → browse → pick up → read",
|
|
)
|
|
l1 = L1Node(id="L1_001", card=l1_card, children=[l2])
|
|
|
|
meta = IndexMeta(source_path="test.mp4", modality="video")
|
|
return TreeIndex(metadata=meta, roots=[l1])
|
|
|
|
|
|
def _make_postprocess(verbatim_ratio: float = 0.1) -> PostprocessResult:
|
|
"""构造测试用后处理结果。"""
|
|
return PostprocessResult(
|
|
options=(
|
|
"A. Reads it",
|
|
"B. Puts it back",
|
|
"C. Throws it away",
|
|
"D. Gives it to someone",
|
|
),
|
|
answer="A",
|
|
referent_violations=[],
|
|
verbatim_ratio=verbatim_ratio,
|
|
has_time_anchor=False,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TestGateKeyVerify
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestGateKeyVerify:
|
|
"""key_verify 门:验证答案在来源素材中有证据支撑。"""
|
|
|
|
@pytest.mark.asyncio()
|
|
async def test_pass_evidence(self, mock_llm_pass: MockLLM) -> None:
|
|
"""LLM 确认有证据 → PASS。"""
|
|
candidate = _make_candidate()
|
|
tree = _make_tree()
|
|
result = await _gate_key_verify(candidate, tree, mock_llm_pass, session_id="test-session")
|
|
assert result.verdict == GateVerdict.PASS
|
|
assert mock_llm_pass.calls # 确认调用了 LLM
|
|
|
|
@pytest.mark.asyncio()
|
|
async def test_fail_no_evidence(self, mock_llm_fail: MockLLM) -> None:
|
|
"""LLM 判断无证据 → FAIL。"""
|
|
candidate = _make_candidate()
|
|
tree = _make_tree()
|
|
result = await _gate_key_verify(candidate, tree, mock_llm_fail, session_id="test-session")
|
|
assert result.verdict == GateVerdict.FAIL
|
|
assert "no evidence" in result.reason
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TestGateBlindAnswer
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestGateBlindAnswer:
|
|
"""blind_answer 门:无上下文答对 → FAIL(题目太简单/泄漏)。"""
|
|
|
|
@pytest.mark.asyncio()
|
|
async def test_pass_wrong(self) -> None:
|
|
"""LLM 在无上下文时答错 → PASS(题目确实需要视频信息)。"""
|
|
# LLM 返回 "pass" 表示它无法正确回答
|
|
llm = MockLLM([_make_llm_response("pass", "cannot determine without context")])
|
|
candidate = _make_candidate()
|
|
result = await _gate_blind_answer(candidate, llm, session_id="test-session")
|
|
assert result.verdict == GateVerdict.PASS
|
|
|
|
@pytest.mark.asyncio()
|
|
async def test_fail_correct(self) -> None:
|
|
"""LLM 在无上下文时答对 → FAIL(题目泄漏)。"""
|
|
llm = MockLLM([_make_llm_response("fail", "answer is obvious from options")])
|
|
candidate = _make_candidate()
|
|
result = await _gate_blind_answer(candidate, llm, session_id="test-session")
|
|
assert result.verdict == GateVerdict.FAIL
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TestGateMultiTrue
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestGateMultiTrue:
|
|
"""multi_true 门:多选项正确 → FAIL。"""
|
|
|
|
@pytest.mark.asyncio()
|
|
async def test_pass_single(self) -> None:
|
|
"""只有一个正确选项 → PASS。"""
|
|
llm = MockLLM([_make_llm_response("pass", "only one correct answer")])
|
|
candidate = _make_candidate()
|
|
tree = _make_tree()
|
|
result = await _gate_multi_true(candidate, tree, llm, session_id="test-session")
|
|
assert result.verdict == GateVerdict.PASS
|
|
|
|
@pytest.mark.asyncio()
|
|
async def test_fail_multi(self) -> None:
|
|
"""多个选项可被视为正确 → FAIL。"""
|
|
llm = MockLLM([_make_llm_response("fail", "options A and B are both plausible")])
|
|
candidate = _make_candidate()
|
|
tree = _make_tree()
|
|
result = await _gate_multi_true(candidate, tree, llm, session_id="test-session")
|
|
assert result.verdict == GateVerdict.FAIL
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TestGateLeakTest
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestGateLeakTest:
|
|
"""leak_test 门:按家族模板执行泄漏探测。"""
|
|
|
|
@pytest.mark.asyncio()
|
|
async def test_per_strategy_template(self) -> None:
|
|
"""使用策略特定的 probe_template 调用 LLM。"""
|
|
llm = MockLLM([_make_llm_response("pass", "no shortcut detected")])
|
|
candidate = _make_candidate()
|
|
result = await _gate_leak_test(
|
|
candidate, "gate_leak_retrieval.md", llm, session_id="test-session"
|
|
)
|
|
assert result.verdict == GateVerdict.PASS
|
|
# 验证 session_id 被正确传递
|
|
assert llm.calls[0]["session_id"] == "test-session"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TestRunGates
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestRunGates:
|
|
"""run_gates 编排:并发四门 + verbatim 短路。"""
|
|
|
|
@pytest.mark.asyncio()
|
|
async def test_all_pass(self) -> None:
|
|
"""四门全 PASS → GateReport.passed=True。"""
|
|
llm = MockLLM([_make_llm_response("pass", f"gate {i} ok") for i in range(4)])
|
|
candidate = _make_candidate()
|
|
tree = _make_tree()
|
|
postprocess = _make_postprocess(verbatim_ratio=0.1)
|
|
|
|
report = await run_gates(
|
|
candidate=candidate,
|
|
tree=tree,
|
|
llm=llm,
|
|
leak_probe_template="gate_leak_retrieval.md",
|
|
postprocess=postprocess,
|
|
session_id="test-session",
|
|
)
|
|
assert report.passed is True
|
|
assert report.reject_reason is None
|
|
|
|
@pytest.mark.asyncio()
|
|
async def test_high_verbatim_shortcircuits(self) -> None:
|
|
"""verbatim_ratio > 0.5 → key_verify 直接 FAIL,其余 SKIP,不调用 LLM。"""
|
|
llm = MockLLM([_make_llm_response("pass", "should not be called")])
|
|
candidate = _make_candidate()
|
|
tree = _make_tree()
|
|
postprocess = _make_postprocess(verbatim_ratio=0.8)
|
|
|
|
report = await run_gates(
|
|
candidate=candidate,
|
|
tree=tree,
|
|
llm=llm,
|
|
leak_probe_template="gate_leak_retrieval.md",
|
|
postprocess=postprocess,
|
|
session_id="test-session",
|
|
)
|
|
assert report.passed is False
|
|
assert report.key_verify.verdict == GateVerdict.FAIL
|
|
assert "verbatim" in report.key_verify.reason.lower()
|
|
# 其余三门应为 SKIP
|
|
assert report.blind_answer.verdict == GateVerdict.SKIP
|
|
assert report.multi_true.verdict == GateVerdict.SKIP
|
|
assert report.leak_test.verdict == GateVerdict.SKIP
|
|
# LLM 不应被调用
|
|
assert len(llm.calls) == 0
|