446 lines
14 KiB
Python
446 lines
14 KiB
Python
"""v3 出题领域观测表落库测试 — facts/unit_verdict/collapse_metrics/quarantine/resume_state。
|
||
|
||
验证 5 张 v3 表的 insert 方法落库+读回、幂等 upsert(同指纹/同 unit_id 不产生重复行)、
|
||
以及 ts 由外部传入(禁进程内 now,保幂等可复现)。同时确认 v2 旧表未受本 Task 影响。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import sqlite3
|
||
from typing import TYPE_CHECKING
|
||
|
||
import pytest
|
||
|
||
from app.question_gen.run_store import QuestionGenStore, RunStats
|
||
|
||
if TYPE_CHECKING:
|
||
from pathlib import Path
|
||
|
||
|
||
@pytest.fixture
|
||
def store(tmp_path: Path) -> QuestionGenStore:
|
||
"""基于真临时 SQLite 文件构造 QuestionGenStore。"""
|
||
db_path = tmp_path / "qgen_v3.db"
|
||
s = QuestionGenStore(db_path)
|
||
yield s
|
||
s.close()
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 表 1:facts
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_insert_fact_roundtrip(store: QuestionGenStore) -> None:
|
||
"""insert_fact 落库后可读回全部列,ts 为外部传入值。"""
|
||
ts = "2026-07-15T10:00:00+00:00"
|
||
store.insert_fact(
|
||
fact_id="fact-1",
|
||
video_id="vid-1",
|
||
segment_id="seg-1",
|
||
subject="man",
|
||
action="pour",
|
||
object="water",
|
||
frame_ids="[10, 20, 30]",
|
||
polarity="真",
|
||
fact_type="binding",
|
||
difficulty_tier=2,
|
||
verifier_refs='{"qwen": "pass", "minimax": "pass"}',
|
||
cross_agree=1,
|
||
negative_at_target="not_false",
|
||
session_id="sess-1",
|
||
ts=ts,
|
||
)
|
||
row = store._conn.execute(
|
||
"SELECT fact_id, video_id, segment_id, subject, action, object, frame_ids, "
|
||
"polarity, fact_type, difficulty_tier, verifier_refs, cross_agree, "
|
||
"negative_at_target, session_id, ts FROM facts WHERE fact_id='fact-1'"
|
||
).fetchone()
|
||
assert row == (
|
||
"fact-1",
|
||
"vid-1",
|
||
"seg-1",
|
||
"man",
|
||
"pour",
|
||
"water",
|
||
"[10, 20, 30]",
|
||
"真",
|
||
"binding",
|
||
2,
|
||
'{"qwen": "pass", "minimax": "pass"}',
|
||
1,
|
||
"not_false",
|
||
"sess-1",
|
||
ts,
|
||
)
|
||
|
||
|
||
def test_insert_fact_ts_is_external_not_now(store: QuestionGenStore) -> None:
|
||
"""ts 如实存储外部传入的历史时间戳,不被进程内 now 覆盖。"""
|
||
old_ts = "2020-01-01T00:00:00+00:00"
|
||
store.insert_fact(
|
||
fact_id="fact-old",
|
||
video_id="v",
|
||
segment_id="s",
|
||
subject="a",
|
||
action="b",
|
||
object="c",
|
||
frame_ids="[]",
|
||
polarity="真",
|
||
fact_type="state",
|
||
difficulty_tier=1,
|
||
verifier_refs="{}",
|
||
cross_agree=0,
|
||
negative_at_target="x",
|
||
session_id="sess",
|
||
ts=old_ts,
|
||
)
|
||
stored = store._conn.execute("SELECT ts FROM facts WHERE fact_id='fact-old'").fetchone()[0]
|
||
assert stored == old_ts
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 表 2:unit_verdict
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_insert_unit_verdict_roundtrip(store: QuestionGenStore) -> None:
|
||
"""insert_unit_verdict 落库后可读回,含可空 pair_id/metric_value/model。"""
|
||
ts = "2026-07-15T11:00:00+00:00"
|
||
store.insert_unit_verdict(
|
||
unit_id="unit-1",
|
||
pair_id="pair-1",
|
||
sub_pattern="binding_swap",
|
||
stage=3,
|
||
verdict="pass",
|
||
reason="ok",
|
||
metric_value=0.87,
|
||
model="qwen-vl",
|
||
session_id="sess-1",
|
||
ts=ts,
|
||
)
|
||
row = store._conn.execute(
|
||
"SELECT unit_id, pair_id, sub_pattern, stage, verdict, reason, metric_value, "
|
||
"model, session_id, ts FROM unit_verdict WHERE unit_id='unit-1' AND stage=3"
|
||
).fetchone()
|
||
assert row == (
|
||
"unit-1",
|
||
"pair-1",
|
||
"binding_swap",
|
||
3,
|
||
"pass",
|
||
"ok",
|
||
0.87,
|
||
"qwen-vl",
|
||
"sess-1",
|
||
ts,
|
||
)
|
||
|
||
|
||
def test_insert_unit_verdict_nullable_fields(store: QuestionGenStore) -> None:
|
||
"""可空列(pair_id/metric_value/model)支持 None 落库。"""
|
||
store.insert_unit_verdict(
|
||
unit_id="unit-2",
|
||
pair_id=None,
|
||
sub_pattern="order",
|
||
stage=1,
|
||
verdict="abstain",
|
||
reason="no judge",
|
||
metric_value=None,
|
||
model=None,
|
||
session_id="sess-1",
|
||
ts="2026-07-15T11:05:00+00:00",
|
||
)
|
||
row = store._conn.execute(
|
||
"SELECT pair_id, metric_value, model FROM unit_verdict WHERE unit_id='unit-2'"
|
||
).fetchone()
|
||
assert row == (None, None, None)
|
||
|
||
|
||
def test_insert_unit_verdict_rerun_same_stage_overwrites(store: QuestionGenStore) -> None:
|
||
"""同 (unit_id, stage) 重跑覆盖更新,行数保持 1。"""
|
||
common = {
|
||
"unit_id": "unit-3",
|
||
"pair_id": "pair-3",
|
||
"sub_pattern": "manner",
|
||
"stage": 2,
|
||
"session_id": "sess-1",
|
||
}
|
||
store.insert_unit_verdict(
|
||
verdict="fail",
|
||
reason="first",
|
||
metric_value=0.1,
|
||
model="m1",
|
||
ts="2026-07-15T11:10:00+00:00",
|
||
**common,
|
||
)
|
||
new_ts = "2026-07-15T11:20:00+00:00"
|
||
store.insert_unit_verdict(
|
||
verdict="pass",
|
||
reason="second",
|
||
metric_value=0.9,
|
||
model="m2",
|
||
ts=new_ts,
|
||
**common,
|
||
)
|
||
rows = store._conn.execute(
|
||
"SELECT verdict, reason, metric_value, ts "
|
||
"FROM unit_verdict WHERE unit_id='unit-3' AND stage=2"
|
||
).fetchall()
|
||
assert len(rows) == 1
|
||
# ts 随整行覆盖为第二次(新)ts,锁死 DO UPDATE SET ts=excluded.ts 语义。
|
||
assert rows[0] == ("pass", "second", 0.9, new_ts)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 表 3:collapse_metrics
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_insert_collapse_metrics_roundtrip(store: QuestionGenStore) -> None:
|
||
"""insert_collapse_metrics 落库后可读回全部度量列。"""
|
||
ts = "2026-07-15T12:00:00+00:00"
|
||
store.insert_collapse_metrics(
|
||
pair_id="pair-1",
|
||
text_only_acc=0.3,
|
||
single_frame_acc=0.4,
|
||
placebo_drop=0.1,
|
||
majority_vote_hit=0.25,
|
||
slot_chi2=1.5,
|
||
distractor_min_dist=0.6,
|
||
multiformat_consistency=0.8,
|
||
subtitle_answerability=0.2,
|
||
ts=ts,
|
||
)
|
||
row = store._conn.execute(
|
||
"SELECT pair_id, text_only_acc, single_frame_acc, placebo_drop, majority_vote_hit, "
|
||
"slot_chi2, distractor_min_dist, multiformat_consistency, subtitle_answerability, ts "
|
||
"FROM collapse_metrics WHERE pair_id='pair-1'"
|
||
).fetchone()
|
||
assert row == (
|
||
"pair-1",
|
||
0.3,
|
||
0.4,
|
||
0.1,
|
||
0.25,
|
||
1.5,
|
||
0.6,
|
||
0.8,
|
||
0.2,
|
||
ts,
|
||
)
|
||
|
||
|
||
def test_insert_collapse_metrics_recompute_upserts(store: QuestionGenStore) -> None:
|
||
"""同 pair_id 重算覆盖更新,行数保持 1。"""
|
||
store.insert_collapse_metrics(
|
||
pair_id="pair-2",
|
||
text_only_acc=0.3,
|
||
single_frame_acc=0.4,
|
||
placebo_drop=0.1,
|
||
majority_vote_hit=0.25,
|
||
slot_chi2=1.0,
|
||
distractor_min_dist=0.5,
|
||
multiformat_consistency=0.7,
|
||
subtitle_answerability=0.1,
|
||
ts="2026-07-15T12:00:00+00:00",
|
||
)
|
||
new_ts = "2026-07-15T12:30:00+00:00"
|
||
store.insert_collapse_metrics(
|
||
pair_id="pair-2",
|
||
text_only_acc=0.05,
|
||
single_frame_acc=0.05,
|
||
placebo_drop=0.5,
|
||
majority_vote_hit=0.25,
|
||
slot_chi2=9.0,
|
||
distractor_min_dist=0.9,
|
||
multiformat_consistency=0.95,
|
||
subtitle_answerability=0.0,
|
||
ts=new_ts,
|
||
)
|
||
rows = store._conn.execute(
|
||
"SELECT text_only_acc, slot_chi2, ts FROM collapse_metrics WHERE pair_id='pair-2'"
|
||
).fetchall()
|
||
assert len(rows) == 1
|
||
# ts 随整行覆盖为第二次(新)ts,锁死 DO UPDATE SET ts=excluded.ts 语义。
|
||
assert rows[0] == (0.05, 9.0, new_ts)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 表 4:quarantine
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_quarantine_roundtrip(store: QuestionGenStore) -> None:
|
||
"""quarantine 落库后可读回,ts 为外部传入值。"""
|
||
ts = "2026-07-15T13:00:00+00:00"
|
||
store.quarantine(
|
||
content_fingerprint="fp-1",
|
||
sub_pattern="binding_swap",
|
||
quarantine_reason="double_true",
|
||
round_no=1,
|
||
ts=ts,
|
||
)
|
||
row = store._conn.execute(
|
||
"SELECT content_fingerprint, sub_pattern, quarantine_reason, round_no, ts "
|
||
"FROM quarantine WHERE content_fingerprint='fp-1'"
|
||
).fetchone()
|
||
assert row == ("fp-1", "binding_swap", "double_true", 1, ts)
|
||
|
||
|
||
def test_quarantine_same_fingerprint_dedup(store: QuestionGenStore) -> None:
|
||
"""同 content_fingerprint 重复调用不产生重复行(去重幂等),且内容被更新。"""
|
||
store.quarantine(
|
||
content_fingerprint="fp-dup",
|
||
sub_pattern="order",
|
||
quarantine_reason="reason_a",
|
||
round_no=1,
|
||
ts="2026-07-15T13:00:00+00:00",
|
||
)
|
||
store.quarantine(
|
||
content_fingerprint="fp-dup",
|
||
sub_pattern="order",
|
||
quarantine_reason="reason_b",
|
||
round_no=2,
|
||
ts="2026-07-15T13:30:00+00:00",
|
||
)
|
||
rows = store._conn.execute(
|
||
"SELECT quarantine_reason, round_no FROM quarantine WHERE content_fingerprint='fp-dup'"
|
||
).fetchall()
|
||
assert len(rows) == 1
|
||
assert rows[0] == ("reason_b", 2)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 表 5:resume_state
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_upsert_resume_state_roundtrip(store: QuestionGenStore) -> None:
|
||
"""upsert_resume_state 落库后可读回全部列。"""
|
||
store.upsert_resume_state(
|
||
unit_id="unit-1",
|
||
status="pending",
|
||
config_fingerprint="cfg-abc",
|
||
seq_offset=0,
|
||
)
|
||
row = store._conn.execute(
|
||
"SELECT unit_id, status, config_fingerprint, seq_offset "
|
||
"FROM resume_state WHERE unit_id='unit-1'"
|
||
).fetchone()
|
||
assert row == ("unit-1", "pending", "cfg-abc", 0)
|
||
|
||
|
||
def test_upsert_resume_state_same_unit_overwrites(store: QuestionGenStore) -> None:
|
||
"""同 unit_id 覆盖更新 status/config/seq_offset,行数保持 1。"""
|
||
store.upsert_resume_state(
|
||
unit_id="unit-2",
|
||
status="pending",
|
||
config_fingerprint="cfg-1",
|
||
seq_offset=0,
|
||
)
|
||
store.upsert_resume_state(
|
||
unit_id="unit-2",
|
||
status="accepted",
|
||
config_fingerprint="cfg-2",
|
||
seq_offset=5,
|
||
)
|
||
rows = store._conn.execute(
|
||
"SELECT status, config_fingerprint, seq_offset FROM resume_state WHERE unit_id='unit-2'"
|
||
).fetchall()
|
||
assert len(rows) == 1
|
||
assert rows[0] == ("accepted", "cfg-2", 5)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# CHECK 约束 fail-loud(非法枚举写入直接报错,符合 §4.2/P5)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_unit_verdict_invalid_stage_raises(store: QuestionGenStore) -> None:
|
||
"""非法 stage(超出 1-6)触发 CHECK 约束,写入直接 IntegrityError。"""
|
||
with pytest.raises(sqlite3.IntegrityError):
|
||
store.insert_unit_verdict(
|
||
unit_id="unit-bad",
|
||
pair_id=None,
|
||
sub_pattern="order",
|
||
stage=7,
|
||
verdict="pass",
|
||
reason="r",
|
||
metric_value=None,
|
||
model=None,
|
||
session_id="sess-1",
|
||
ts="2026-07-15T11:00:00+00:00",
|
||
)
|
||
|
||
|
||
def test_unit_verdict_invalid_verdict_raises(store: QuestionGenStore) -> None:
|
||
"""非法 verdict(不在 pass/fail/abstain)触发 CHECK 约束,写入直接 IntegrityError。"""
|
||
with pytest.raises(sqlite3.IntegrityError):
|
||
store.insert_unit_verdict(
|
||
unit_id="unit-bad2",
|
||
pair_id=None,
|
||
sub_pattern="order",
|
||
stage=1,
|
||
verdict="maybe",
|
||
reason="r",
|
||
metric_value=None,
|
||
model=None,
|
||
session_id="sess-1",
|
||
ts="2026-07-15T11:00:00+00:00",
|
||
)
|
||
|
||
|
||
def test_facts_invalid_cross_agree_raises(store: QuestionGenStore) -> None:
|
||
"""非法 cross_agree(非 0/1)触发 CHECK 约束,写入直接 IntegrityError。"""
|
||
with pytest.raises(sqlite3.IntegrityError):
|
||
store.insert_fact(
|
||
fact_id="fact-bad",
|
||
video_id="v",
|
||
segment_id="s",
|
||
subject="a",
|
||
action="b",
|
||
object="c",
|
||
frame_ids="[]",
|
||
polarity="真",
|
||
fact_type="state",
|
||
difficulty_tier=1,
|
||
verifier_refs="{}",
|
||
cross_agree=2,
|
||
negative_at_target="x",
|
||
session_id="sess",
|
||
ts="2026-07-15T11:00:00+00:00",
|
||
)
|
||
|
||
|
||
def test_resume_state_invalid_status_raises(store: QuestionGenStore) -> None:
|
||
"""非法 status(不在 pending/accepted/rejected)触发 CHECK 约束,写入直接 IntegrityError。"""
|
||
with pytest.raises(sqlite3.IntegrityError):
|
||
store.upsert_resume_state(
|
||
unit_id="unit-bad",
|
||
status="unknown",
|
||
config_fingerprint="cfg",
|
||
seq_offset=0,
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# v2 旧表未受影响
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_v2_tables_unaffected(store: QuestionGenStore) -> None:
|
||
"""v3 建表后 v2 旧表仍存在且旧方法仍可用。"""
|
||
store.record_run_start(run_id="run-1", git_sha="deadbeef", config_snapshot="{}")
|
||
store.record_run_end(
|
||
run_id="run-1",
|
||
status="completed",
|
||
stats=RunStats(total_slots=10, accepted=8, rejected=2, heavy_sampled=1),
|
||
)
|
||
stats = store.get_run_stats("run-1")
|
||
assert stats.accepted == 8
|
||
tables = {
|
||
r[0] for r in store._conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
|
||
}
|
||
assert {"question_gen_runs", "question_gen_items", "adversarial_verdicts"} <= tables
|