feat: add baseline diagnosis signal store

This commit is contained in:
2026-07-15 12:01:30 -04:00
parent 01f2e7c7b9
commit 6b85fcbe1c
4 changed files with 317 additions and 1 deletions
+154
View File
@@ -0,0 +1,154 @@
"""SqliteDiagnosisSignalStorebaseline 逐题诊断信号的 SQLite 持久化适配器。
实现 core/evolution/protocols.py::DiagnosisSignalStore 端口。信号行以
(question_id, baseline_run_id, diag_fingerprint) 为主键,INSERT OR REPLACE
保证逐题幂等 upsert;bool 字段以 0/1 存储,可空字段以 NULL 存储。表建在
harness.db,供离线诊断编排写入、视频级切分选择器读取。
"""
from __future__ import annotations
import sqlite3
from pathlib import Path
from core.evolution.types import DiagnosisSignalRow
# 表列顺序即 DiagnosisSignalRow 字段顺序(question_id..session_id),
# upsert 写入与 load 还原共用,避免手写列名两处漂移。
_COLUMNS: tuple[str, ...] = (
"question_id",
"video_id",
"baseline_run_id",
"diag_fingerprint",
"task_type",
"error_type",
"cause_category",
"tier",
"evolution_target",
"degraded",
"infra",
"session_id",
)
class SqliteDiagnosisSignalStore:
"""逐题诊断信号的 SQLite 存储实现。
构造时按需建表(CREATE TABLE IF NOT EXISTS),主键
(question_id, baseline_run_id, diag_fingerprint) 保证同键覆盖。
每次写操作单事务 commit,保证原子落盘与断点续跑。
参数:
db_path: SQLite 数据库文件路径(通常为 harness.db)。
"""
def __init__(self, db_path: str) -> None:
Path(db_path).parent.mkdir(parents=True, exist_ok=True)
self._conn = sqlite3.connect(db_path)
self._conn.row_factory = sqlite3.Row
self._init_table()
def _init_table(self) -> None:
"""创建 baseline_diagnosis 表(若不存在)。"""
self._conn.execute(
"""
CREATE TABLE IF NOT EXISTS baseline_diagnosis (
question_id TEXT NOT NULL,
video_id TEXT NOT NULL,
baseline_run_id TEXT NOT NULL,
diag_fingerprint TEXT NOT NULL,
task_type TEXT NOT NULL,
error_type TEXT,
cause_category TEXT,
tier TEXT NOT NULL,
evolution_target TEXT,
degraded INTEGER NOT NULL,
infra INTEGER NOT NULL,
session_id TEXT,
PRIMARY KEY (question_id, baseline_run_id, diag_fingerprint)
)
"""
)
self._conn.commit()
def upsert(self, row: DiagnosisSignalRow) -> None:
"""写入或覆盖单题诊断信号(按主键幂等,单事务)。
参数:
row: 待持久化的诊断信号行。bool 字段转 0/1None 存 NULL。
关键实现:
用 INSERT OR REPLACE 按主键覆盖,避免重复行;commit 保证原子。
"""
placeholders = ", ".join("?" for _ in _COLUMNS)
col_names = ", ".join(_COLUMNS)
values = (
row.question_id,
row.video_id,
row.baseline_run_id,
row.diag_fingerprint,
row.task_type,
row.error_type,
row.cause_category,
row.tier,
row.evolution_target,
int(row.degraded),
int(row.infra),
row.session_id,
)
self._conn.execute(
f"INSERT OR REPLACE INTO baseline_diagnosis ({col_names}) VALUES ({placeholders})",
values,
)
self._conn.commit()
def done_question_ids(self, baseline_run_id: str, diag_fingerprint: str) -> set[str]:
"""查询指定 run 与诊断指纹下已完成的 question_id 集合。
参数:
baseline_run_id: baseline run 标识。
diag_fingerprint: 诊断口径指纹。
返回:
已落盘信号的 question_id 集合;无匹配时为空集,供断点续跑跳过。
"""
cursor = self._conn.execute(
"SELECT DISTINCT question_id FROM baseline_diagnosis"
" WHERE baseline_run_id = ? AND diag_fingerprint = ?",
(baseline_run_id, diag_fingerprint),
)
return {r["question_id"] for r in cursor.fetchall()}
def load(self, baseline_run_id: str, diag_fingerprint: str) -> list[DiagnosisSignalRow]:
"""加载指定 run 与诊断指纹下的全部诊断信号行。
参数:
baseline_run_id: baseline run 标识。
diag_fingerprint: 诊断口径指纹。
返回:
还原后的 DiagnosisSignalRow 列表(0/1→boolNULL→None)。
"""
col_names = ", ".join(_COLUMNS)
cursor = self._conn.execute(
f"SELECT {col_names} FROM baseline_diagnosis"
" WHERE baseline_run_id = ? AND diag_fingerprint = ?",
(baseline_run_id, diag_fingerprint),
)
return [
DiagnosisSignalRow(
question_id=r["question_id"],
video_id=r["video_id"],
baseline_run_id=r["baseline_run_id"],
diag_fingerprint=r["diag_fingerprint"],
task_type=r["task_type"],
error_type=r["error_type"],
cause_category=r["cause_category"],
tier=r["tier"],
evolution_target=r["evolution_target"],
degraded=bool(r["degraded"]),
infra=bool(r["infra"]),
session_id=r["session_id"],
)
for r in cursor.fetchall()
]
+45 -1
View File
@@ -7,7 +7,10 @@ SkillStore / PromptStore 为同步(文件读取量小且快),RunLog 为异
from __future__ import annotations from __future__ import annotations
from typing import Any, Protocol, runtime_checkable from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
if TYPE_CHECKING:
from core.evolution.types import DiagnosisSignalRow
@runtime_checkable @runtime_checkable
@@ -104,3 +107,44 @@ class RunLog(Protocol):
轨迹记录字典列表。 轨迹记录字典列表。
""" """
... ...
@runtime_checkable
class DiagnosisSignalStore(Protocol):
"""逐题诊断信号存储端口。
隔离 SQLite 实现细节,app/core 不写裸 SQL。逐题 upsert 落盘、
支持断点续跑(done_question_ids 查已完成集合)。
"""
def upsert(self, row: DiagnosisSignalRow) -> None:
"""写入或覆盖单题诊断信号(按主键幂等)。
参数:
row: 待持久化的诊断信号行。
"""
...
def done_question_ids(self, baseline_run_id: str, diag_fingerprint: str) -> set[str]:
"""查询指定 run 与诊断指纹下已完成的 question_id 集合。
参数:
baseline_run_id: baseline run 标识。
diag_fingerprint: 诊断口径指纹。
返回:
已落盘信号的 question_id 集合,用于断点续跑跳过。
"""
...
def load(self, baseline_run_id: str, diag_fingerprint: str) -> list[DiagnosisSignalRow]:
"""加载指定 run 与诊断指纹下的全部诊断信号行。
参数:
baseline_run_id: baseline run 标识。
diag_fingerprint: 诊断口径指纹。
返回:
还原后的 DiagnosisSignalRow 列表。
"""
...
+38
View File
@@ -300,6 +300,44 @@ class DiagnosisResult:
degraded_question_ids: list[str] = field(default_factory=list) degraded_question_ids: list[str] = field(default_factory=list)
@dataclass(frozen=True)
class DiagnosisSignalRow:
"""单题诊断信号行,即 baseline run 逐题诊断的持久化单元。
供后续视频级切分选择器消费;由 (question_id, baseline_run_id,
diag_fingerprint) 唯一确定,逐题 upsert 支持断点续跑。
属性:
question_id: 题目唯一标识。
video_id: 对应视频唯一标识。
baseline_run_id: 产出该信号的 baseline run 标识。
diag_fingerprint: 诊断口径指纹,隔离不同诊断配置的信号。
task_type: 题目任务类型。
error_type: 错误类别(extraction/search/reasoning/mixed);
T0/uncertain 行为 None。
cause_category: 病因类别(defect/lapse);不适用为 None。
tier: 诊断分层(T0/T1/T2/uncertain)。
evolution_target: 进化目标(tool/skill/system);
error_type 为 None 时亦为 None。
degraded: 是否为降级信号(judge 解析失败时生成)。
infra: 是否为 INFRA 护栏排除行。
session_id: 关联的会话标识;不适用为 None。
"""
question_id: str
video_id: str
baseline_run_id: str
diag_fingerprint: str
task_type: str
error_type: str | None
cause_category: str | None
tier: str
evolution_target: str | None
degraded: bool
infra: bool
session_id: str | None
# ========================================================================= # =========================================================================
# 3. 进化类型 # 3. 进化类型
# ========================================================================= # =========================================================================
@@ -0,0 +1,80 @@
"""SqliteDiagnosisSignalStore 单元测试。
验证逐题诊断信号的 upsert 幂等、断点续跑(done_question_ids)、
load 往返还原,以及 INFRA 行的可空字段处理。
"""
from adapters.baseline_diagnosis_store import DiagnosisSignalRow, SqliteDiagnosisSignalStore
def _store(tmp_path):
"""构造指向临时 harness.db 的信号存储。"""
return SqliteDiagnosisSignalStore(str(tmp_path / "h.db"))
def test_upsert_and_get_done_ids(tmp_path):
s = _store(tmp_path)
row = DiagnosisSignalRow(
question_id="q1",
video_id="v1",
baseline_run_id="r",
diag_fingerprint="fp",
task_type="Counting",
error_type="search_failure",
cause_category="defect",
tier="T2",
evolution_target="skill",
degraded=False,
infra=False,
session_id="s",
)
s.upsert(row)
assert s.done_question_ids("r", "fp") == {"q1"}
s.upsert(row) # 同键覆盖,不重复
assert s.done_question_ids("r", "fp") == {"q1"}
assert s.done_question_ids("r", "other_fp") == set() # 不同 fingerprint 隔离
def test_load_rows_roundtrip(tmp_path):
s = _store(tmp_path)
s.upsert(
DiagnosisSignalRow(
"q2",
"v2",
"r",
"fp",
"OCR Problems",
"extraction_failure",
"lapse",
"T1",
"tool",
False,
False,
"s",
)
)
rows = s.load("r", "fp")
assert len(rows) == 1 and rows[0].tier == "T1" and rows[0].evolution_target == "tool"
def test_null_fields_for_infra_row(tmp_path):
s = _store(tmp_path)
# T0 INFRA 行:error_type/cause_category/evolution_target 为 None
s.upsert(
DiagnosisSignalRow(
"q3",
"v3",
"r",
"fp",
"Counting",
None,
None,
"T0",
None,
degraded=False,
infra=True,
session_id=None,
)
)
rows = s.load("r", "fp")
assert rows[0].error_type is None and rows[0].evolution_target is None and rows[0].infra is True