From 5ecbac620c80501f7701f10de4a8c5700d816665 Mon Sep 17 00:00:00 2001 From: iomgaa Date: Wed, 15 Jul 2026 09:33:40 -0400 Subject: [PATCH] feat: add v3 question-gen observation tables to run_store --- app/question_gen/run_store.py | 403 +++++++++++++++++++++++++ tests/unit/test_run_store_v3_tables.py | 367 ++++++++++++++++++++++ 2 files changed, 770 insertions(+) create mode 100644 tests/unit/test_run_store_v3_tables.py diff --git a/app/question_gen/run_store.py b/app/question_gen/run_store.py index c778c6e..9473d1f 100644 --- a/app/question_gen/run_store.py +++ b/app/question_gen/run_store.py @@ -162,6 +162,102 @@ _DDL_VERDICTS_INDEXES = [ ] +# --------------------------------------------------------------------------- +# v3 出题领域观测表 DDL(facts/unit_verdict/collapse_metrics/quarantine/resume_state) +# +# 时间戳边界(Codex I-4):v3 新表的 ts 列一律由 insert 方法外部传入,DDL 中不设 +# DEFAULT (datetime('now'))、方法体不调 datetime.now(),以保证观测幂等可复现—— +# 断点续跑重放时不因进程内时钟漂移产生脏数据。resume_state 无 ts 列(schema 未列)。 +# --------------------------------------------------------------------------- + +_DDL_FACTS = """ +CREATE TABLE IF NOT EXISTS facts ( + fact_id TEXT PRIMARY KEY, + video_id TEXT, + segment_id TEXT, + subject TEXT, + action TEXT, + object TEXT, + frame_ids TEXT, + polarity TEXT, + fact_type TEXT, + difficulty_tier INTEGER, + verifier_refs TEXT, + cross_agree INTEGER, + negative_at_target TEXT, + session_id TEXT, + ts TEXT +); +""" + +_DDL_UNIT_VERDICT = """ +CREATE TABLE IF NOT EXISTS unit_verdict ( + unit_id TEXT NOT NULL, + pair_id TEXT, + sub_pattern TEXT, + stage INTEGER NOT NULL, + verdict TEXT, + reason TEXT, + metric_value REAL, + model TEXT, + session_id TEXT, + ts TEXT, + PRIMARY KEY (unit_id, stage) +); +""" + +_DDL_COLLAPSE_METRICS = """ +CREATE TABLE IF NOT EXISTS collapse_metrics ( + pair_id TEXT PRIMARY KEY, + text_only_acc REAL, + single_frame_acc REAL, + placebo_drop REAL, + majority_vote_hit REAL, + slot_chi2 REAL, + distractor_min_dist REAL, + multiformat_consistency REAL, + subtitle_answerability REAL, + ts TEXT +); +""" + +_DDL_QUARANTINE = """ +CREATE TABLE IF NOT EXISTS quarantine ( + content_fingerprint TEXT PRIMARY KEY, + sub_pattern TEXT, + quarantine_reason TEXT, + round_no INTEGER, + ts TEXT +); +""" + +_DDL_RESUME_STATE = """ +CREATE TABLE IF NOT EXISTS resume_state ( + unit_id TEXT PRIMARY KEY, + status TEXT, + config_fingerprint TEXT, + seq_offset INTEGER +); +""" + +_DDL_V3_TABLES = [ + _DDL_FACTS, + _DDL_UNIT_VERDICT, + _DDL_COLLAPSE_METRICS, + _DDL_QUARANTINE, + _DDL_RESUME_STATE, +] + +_DDL_V3_INDEXES = [ + "CREATE INDEX IF NOT EXISTS idx_facts_video ON facts(video_id);", + "CREATE INDEX IF NOT EXISTS idx_facts_session ON facts(session_id);", + "CREATE INDEX IF NOT EXISTS idx_uv_unit ON unit_verdict(unit_id);", + "CREATE INDEX IF NOT EXISTS idx_uv_sub_pattern ON unit_verdict(sub_pattern);", + "CREATE INDEX IF NOT EXISTS idx_quar_sub_pattern ON quarantine(sub_pattern);", + "CREATE INDEX IF NOT EXISTS idx_resume_status ON resume_state(status);", +] + + # --------------------------------------------------------------------------- # Store 实现 # --------------------------------------------------------------------------- @@ -201,6 +297,10 @@ class QuestionGenStore: self._conn.execute(_DDL_VERDICTS) for idx_sql in _DDL_VERDICTS_INDEXES: self._conn.execute(idx_sql) + for ddl in _DDL_V3_TABLES: + self._conn.execute(ddl) + for idx_sql in _DDL_V3_INDEXES: + self._conn.execute(idx_sql) self._conn.commit() # 幂等迁移:为已有表加 sub_pattern / selector_scores 列 @@ -648,6 +748,309 @@ class QuestionGenStore: return {row[0]: "accepted" for row in rows} + # ----------------------------------------------------------------------- + # v3 出题领域观测写入(ts 一律外部传入,禁进程内 now,保幂等可复现) + # ----------------------------------------------------------------------- + + def insert_fact( + self, + *, + fact_id: str, + video_id: str, + segment_id: str, + subject: str, + action: str, + object: str, # noqa: A002 — 与设计列名 object 一致,仅 kwargs 传入无遮蔽风险 + frame_ids: str, + polarity: str, + fact_type: str, + difficulty_tier: int, + verifier_refs: str, + cross_agree: int, + negative_at_target: str, + session_id: str, + ts: str, + ) -> None: + """写入一条帧感知抽取 Fact(facts 表,主键 fact_id)。 + + Parameters + ---------- + fact_id : str + Fact 唯一标识(UUID)。 + video_id, segment_id : str + 溯源:所属视频与片段。 + subject, action, object : str + 结构化绑定三元组。 + frame_ids : str + 感知所用帧 ID 的 JSON 数组字符串。 + polarity : str + 事实极性(真/假)。 + fact_type : str + 事实类型(binding/state/manner/order/evidence)。 + difficulty_tier : int + 感知难度分层。 + verifier_refs : str + 双 VLM(qwen/MiniMax)各自裁决的 JSON 字符串。 + cross_agree : int + 双 VLM 是否一致(0/1)。 + negative_at_target : str + 目标点为假的核实结果。 + session_id : str + epoch/step 关联 ID。 + ts : str + 观测时间戳 ISO 字符串,由调用方外部传入(禁进程内 now)。 + """ + self._conn.execute( + """ + INSERT INTO facts + (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) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + 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, + ), + ) + self._conn.commit() + logger.debug("facts 已写入: fact_id={}", fact_id) + + def insert_unit_verdict( + self, + *, + unit_id: str, + pair_id: str | None, + sub_pattern: str, + stage: int, + verdict: str, + reason: str, + metric_value: float | None, + model: str | None, + session_id: str, + ts: str, + ) -> None: + """写入六层验证某一层的裁决(unit_verdict 表,主键 (unit_id, stage))。 + + 同一 (unit_id, stage) 重跑时以 upsert 覆盖旧行——每 unit 每层仅保留最新一条, + 便于断点续跑重放而不残留过期裁决。 + + Parameters + ---------- + unit_id : str + pair/single 单元标识。 + pair_id : str | None + 所属 pair 关联标识(single 单元可为 None)。 + sub_pattern : str + 6 子模式之一。 + stage : int + 验证层号(1-6)。 + verdict : str + 该层裁决(pass/fail/abstain)。 + reason : str + 拒因描述。 + metric_value : float | None + 该层量化值(无量化值时为 None)。 + model : str | None + 裁判模型名(无模型判定时为 None)。 + session_id : str + epoch/step 关联 ID。 + ts : str + 观测时间戳 ISO 字符串,由调用方外部传入(禁进程内 now)。 + """ + self._conn.execute( + """ + INSERT INTO unit_verdict + (unit_id, pair_id, sub_pattern, stage, verdict, reason, + metric_value, model, session_id, ts) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(unit_id, stage) DO UPDATE SET + pair_id=excluded.pair_id, + sub_pattern=excluded.sub_pattern, + verdict=excluded.verdict, + reason=excluded.reason, + metric_value=excluded.metric_value, + model=excluded.model, + session_id=excluded.session_id, + ts=excluded.ts + """, + ( + unit_id, + pair_id, + sub_pattern, + stage, + verdict, + reason, + metric_value, + model, + session_id, + ts, + ), + ) + self._conn.commit() + logger.debug("unit_verdict 已写入: unit_id={}, stage={}", unit_id, stage) + + def insert_collapse_metrics( + self, + *, + pair_id: str, + text_only_acc: float, + single_frame_acc: float, + placebo_drop: float, + majority_vote_hit: float, + slot_chi2: float, + distractor_min_dist: float, + multiformat_consistency: float, + subtitle_answerability: float, + ts: str, + ) -> None: + """写入配对坍缩度量(collapse_metrics 表,主键 pair_id)。 + + 同一 pair_id 重算时以 upsert 覆盖旧行——每 pair 仅保留最新一组度量。 + + Parameters + ---------- + pair_id : str + 配对唯一标识。 + text_only_acc, single_frame_acc, placebo_drop : float + 模型探针类度量。 + majority_vote_hit, slot_chi2, distractor_min_dist : float + 纯结构类度量。 + multiformat_consistency, subtitle_answerability : float + 探针类度量。 + ts : str + 观测时间戳 ISO 字符串,由调用方外部传入(禁进程内 now)。 + """ + self._conn.execute( + """ + INSERT INTO collapse_metrics + (pair_id, text_only_acc, single_frame_acc, placebo_drop, + majority_vote_hit, slot_chi2, distractor_min_dist, + multiformat_consistency, subtitle_answerability, ts) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(pair_id) DO UPDATE SET + text_only_acc=excluded.text_only_acc, + single_frame_acc=excluded.single_frame_acc, + placebo_drop=excluded.placebo_drop, + majority_vote_hit=excluded.majority_vote_hit, + slot_chi2=excluded.slot_chi2, + distractor_min_dist=excluded.distractor_min_dist, + multiformat_consistency=excluded.multiformat_consistency, + subtitle_answerability=excluded.subtitle_answerability, + ts=excluded.ts + """, + ( + pair_id, + text_only_acc, + single_frame_acc, + placebo_drop, + majority_vote_hit, + slot_chi2, + distractor_min_dist, + multiformat_consistency, + subtitle_answerability, + ts, + ), + ) + self._conn.commit() + logger.debug("collapse_metrics 已写入: pair_id={}", pair_id) + + def quarantine( + self, + *, + content_fingerprint: str, + sub_pattern: str, + quarantine_reason: str, + round_no: int, + ts: str, + ) -> None: + """将失败题的内容指纹写入隔离区黑名单(quarantine 表,主键 content_fingerprint)。 + + 同一 content_fingerprint 重复调用以 upsert 覆盖——保证语义相同题面只占一行, + 补构造前查此表当黑名单去重。 + + Parameters + ---------- + content_fingerprint : str + 题面语义内容指纹(去重键)。 + sub_pattern : str + 题目所属子模式。 + quarantine_reason : str + 隔离原因。 + round_no : int + 隔离发生的过滤轮次。 + ts : str + 观测时间戳 ISO 字符串,由调用方外部传入(禁进程内 now)。 + """ + self._conn.execute( + """ + INSERT INTO quarantine + (content_fingerprint, sub_pattern, quarantine_reason, round_no, ts) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(content_fingerprint) DO UPDATE SET + sub_pattern=excluded.sub_pattern, + quarantine_reason=excluded.quarantine_reason, + round_no=excluded.round_no, + ts=excluded.ts + """, + (content_fingerprint, sub_pattern, quarantine_reason, round_no, ts), + ) + self._conn.commit() + logger.debug("quarantine 已写入: fingerprint={}", content_fingerprint) + + def upsert_resume_state( + self, + *, + unit_id: str, + status: str, + config_fingerprint: str, + seq_offset: int, + ) -> None: + """写入/更新断点续跑状态(resume_state 表,主键 unit_id)。 + + 同一 unit_id 覆盖更新——每单元仅保留最新续跑状态。config_fingerprint 变化时 + 由调用方据此判定旧进度作废并重跑。本表无 ts 列(schema 未定义),但方法体仍禁 + 进程内 now,保持 v3 观测一致的幂等可复现语义。 + + Parameters + ---------- + unit_id : str + 单元唯一标识。 + status : str + 续跑状态(pending/accepted/rejected)。 + config_fingerprint : str + 求解器/裁判 config 指纹,变更时旧进度作废。 + seq_offset : int + 补构造续编偏移,防止编号相撞。 + """ + self._conn.execute( + """ + INSERT INTO resume_state + (unit_id, status, config_fingerprint, seq_offset) + VALUES (?, ?, ?, ?) + ON CONFLICT(unit_id) DO UPDATE SET + status=excluded.status, + config_fingerprint=excluded.config_fingerprint, + seq_offset=excluded.seq_offset + """, + (unit_id, status, config_fingerprint, seq_offset), + ) + self._conn.commit() + logger.debug("resume_state 已写入: unit_id={}, status={}", unit_id, status) + def close(self) -> None: """关闭数据库连接。""" self._conn.close() diff --git a/tests/unit/test_run_store_v3_tables.py b/tests/unit/test_run_store_v3_tables.py new file mode 100644 index 0000000..2e57375 --- /dev/null +++ b/tests/unit/test_run_store_v3_tables.py @@ -0,0 +1,367 @@ +"""v3 出题领域观测表落库测试 — facts/unit_verdict/collapse_metrics/quarantine/resume_state。 + +验证 5 张 v3 表的 insert 方法落库+读回、幂等 upsert(同指纹/同 unit_id 不产生重复行)、 +以及 ts 由外部传入(禁进程内 now,保幂等可复现)。同时确认 v2 旧表未受本 Task 影响。 +""" + +from __future__ import annotations + +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, + ) + store.insert_unit_verdict( + verdict="pass", + reason="second", + metric_value=0.9, + model="m2", + ts="2026-07-15T11:20:00+00:00", + **common, + ) + rows = store._conn.execute( + "SELECT verdict, reason, metric_value FROM unit_verdict WHERE unit_id='unit-3' AND stage=2" + ).fetchall() + assert len(rows) == 1 + assert rows[0] == ("pass", "second", 0.9) + + +# --------------------------------------------------------------------------- +# 表 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", + ) + 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="2026-07-15T12:30:00+00:00", + ) + rows = store._conn.execute( + "SELECT text_only_acc, slot_chi2 FROM collapse_metrics WHERE pair_id='pair-2'" + ).fetchall() + assert len(rows) == 1 + assert rows[0] == (0.05, 9.0) + + +# --------------------------------------------------------------------------- +# 表 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) + + +# --------------------------------------------------------------------------- +# 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