feat(question_gen): is_duplicate + generate_one — 去重判定与单题生成编排

- is_duplicate: 余弦相似度去重,空池短路
- generate_one: 异步重试循环,不含去重(由调用方汇总点原子执行)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-09 05:32:39 -04:00
parent 90f17e330e
commit 5aa7cc48c5
2 changed files with 255 additions and 1 deletions
+121 -1
View File
@@ -1,11 +1,13 @@
"""synthesizer 模块单元测试 — AnchorContext + 题型映射常量 + sample_anchor + prompt 构造 + VLM 解析。"""
"""synthesizer 模块单元测试 — AnchorContext + 题型映射常量 + sample_anchor + prompt 构造 + VLM 解析 + 去重 + 单题生成"""
from __future__ import annotations
import dataclasses
import random
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock
import numpy as np
import pytest
from app.question_gen.synthesizer import (
@@ -13,6 +15,8 @@ from app.question_gen.synthesizer import (
AnchorContext,
TaskTypeSpec,
build_generation_prompt,
generate_one,
is_duplicate,
parse_vlm_response,
sample_anchor,
)
@@ -379,3 +383,119 @@ class TestParseVlmResponse:
raw = '{"question": "Q?", "options": ["A. 1", "B. 2", "C. 3", "D. 4"], "answer": "C"}'
result = parse_vlm_response(raw, "video_abc", "Action Reasoning", 42)
assert result["question_id"] == "gen-video_abc-042"
# ---------------------------------------------------------------------------
# is_duplicate 测试
# ---------------------------------------------------------------------------
class TestIsDuplicate:
"""is_duplicate embedding 去重判定测试。"""
@staticmethod
def _fake_embed(texts: str | list[str]) -> np.ndarray:
"""确定性 + L2 归一化的 fake embedding。"""
if isinstance(texts, str):
texts = [texts]
vecs = []
for t in texts:
rs = np.random.RandomState(hash(t) % 2**31)
v = rs.randn(4).astype(np.float32)
v /= np.linalg.norm(v)
vecs.append(v)
return np.array(vecs, dtype=np.float32)
def test_empty_pool_never_duplicate(self) -> None:
"""空池始终返回 False。"""
pool = np.zeros((0, 4), dtype=np.float32)
assert is_duplicate("anything", pool, self._fake_embed, 0.85) is False
def test_identical_text_is_duplicate(self) -> None:
"""相同文本的 embedding 与自身余弦相似度为 1,必定判重。"""
text = "What is happening in the video?"
emb = self._fake_embed(text)
pool = emb.copy()
assert is_duplicate(text, pool, self._fake_embed, 0.85) is True
def test_different_text_not_duplicate(self) -> None:
"""极高阈值下,不同文本不判重。"""
pool_texts = ["aaa", "bbb", "ccc", "ddd", "eee"]
pool = self._fake_embed(pool_texts)
assert is_duplicate("completely unique text xyz", pool, self._fake_embed, 0.99) is False
# ---------------------------------------------------------------------------
# generate_one 测试
# ---------------------------------------------------------------------------
class TestGenerateOne:
"""generate_one 单题异步生成测试。"""
@staticmethod
def _load_test_tree() -> tuple[TreeIndex, str]:
"""加载真实测试树。"""
videos_dir = Path("store/videos")
first_vid = sorted(videos_dir.iterdir())[0]
return TreeIndex.load_json(str(first_vid / "tree.json")), first_vid.name
@pytest.mark.asyncio
async def test_success_path(self) -> None:
"""mock VLM 返回合法 JSON,应成功生成 GeneratedQuestion。"""
vlm = AsyncMock()
vlm.chat_with_images.return_value = MagicMock(
content='{"question":"Q?","options":["A. 1","B. 2","C. 3","D. 4"],"answer":"A"}',
)
def embed_fn(t: str | list[str]) -> np.ndarray:
shape = (1, 4) if isinstance(t, str) else (len(t), 4)
return np.zeros(shape, dtype=np.float32)
tree, vid = self._load_test_tree()
result = await generate_one(
vlm=vlm,
embed_fn=embed_fn,
tree=tree,
video_id=vid,
task_type="Object Recognition",
seq=1,
exemplars=[],
used_node_ids=set(),
max_retries=3,
similarity_threshold=0.85,
rng=random.Random(42),
session_id="test",
)
assert result is not None
assert result.question_id == f"gen-{vid}-001"
assert result.task_type == "Object Recognition"
assert result.source_nodes # non-empty
assert result.difficulty == "medium"
@pytest.mark.asyncio
async def test_all_retries_exhausted_returns_none(self) -> None:
"""VLM 始终返回无效 JSON,耗尽重试后返回 None。"""
vlm = AsyncMock()
vlm.chat_with_images.return_value = MagicMock(content="invalid")
def embed_fn(t: str | list[str]) -> np.ndarray:
return np.zeros((1, 4), dtype=np.float32)
tree, vid = self._load_test_tree()
result = await generate_one(
vlm=vlm,
embed_fn=embed_fn,
tree=tree,
video_id=vid,
task_type="Object Recognition",
seq=1,
exemplars=[],
used_node_ids=set(),
max_retries=2,
similarity_threshold=0.85,
rng=random.Random(42),
session_id="test",
)
assert result is None
assert vlm.chat_with_images.call_count == 2