AgentEvalTool/backend/agenteval/storage/repository.py
sinohqb 7eae6de52d refactor(evaluation/storage): 结算统一与 repository 拆分(Phase 2 + 3)
合并两个不可分割的深化:

Phase 2 — 智能作业结算统一(ADR-0012)
- intelligence_jobs.execute(job_kind, campaign_id, ...) 作为结算的
  唯一实现:建行 → 认领 → 校验 → generating → 落账,一处编排、
  一处截断(500 字符)。两个 executor 退化为 ensure_queued /
  validate / work_fn 三个小 adapter。
- analysis.validate_analysis_request() 共享校验入口(活动终态 →
  模型),路由捕获映射 400、executor 捕获落 failed 行,与
  validate_comparison_request 先例同构。
- campaign_runner._auto_start_analysis 的跳过守卫收敛至
  auto_intelligence_eligible 单一判断点。
- comparison.py 删除零调用的 build_comparison_payload;
  load_comparison_view 投影归位至 campaign_read_model。
- 新增 characterization 测试(认领竞争、重复触发、截断、恢复上限)。

Phase 3 — storage/repository.py 拆分
- AsyncJobRepository 及两个子类迁至
  storage/async_job_repository.py(Phase 2 的 intelligence_jobs
  与 comparison 必须 import 自该路径,故与 Phase 2 同 commit)。
- ExplorationSession / ExplorationMessage 迁至
  storage/exploration_repository.py;repository.py 由 1180 行降至
  约 814 行,grep 确认无残留符号。
- exploration 子模块与路由 import 全部更新;测试 import 跟随。

刻意不做:CAS 共享原语、app.py 五 registry 关停顺序归一
(ADR-0006 精神,等真实需求出现再议)。
2026-08-24 05:50:27 +08:00

815 lines
30 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Repository layer for database access."""
from dataclasses import dataclass
from datetime import datetime
from enum import Enum
from typing import Generic, Optional, TypeVar
from sqlalchemy import insert as sql_insert
from sqlalchemy import literal
from sqlalchemy import update as sql_update
from sqlalchemy.exc import IntegrityError
from sqlmodel import Session, select
from agenteval.exploration.models import ExplorationSessionStatus
from agenteval.models import (
Campaign,
CampaignStatus,
CampaignSummary,
Case,
EvalResult,
EvalRun,
EvalTarget,
RunStatus,
RunTrigger,
Scenario,
)
from agenteval.services.model_configs import ModelConfigService
from agenteval.storage.db import (
CampaignDB,
EvalResultDB,
EvalRunDB,
EvalTargetDB,
ExplorationSessionDB,
ScenarioDB,
TurnDB,
get_session,
new_uuid,
utc_now,
)
from agenteval.storage.model_config_repository import ScenarioModelBindingRepository
M = TypeVar("M") # domain model
DB = TypeVar("DB") # persisted table row
class BaseRepository(Generic[M, DB]):
"""Shared CRUD skeleton for id-keyed entity repositories.
Subclasses declare the table (``_table``) and the ``list_all`` ordering
column name (``_order_by``, newest-first), and implement the ``_to_db`` /
``_from_db`` converter pair. The converters are instance methods so a
subclass whose ``_from_db`` needs cross-table reads (e.g. Scenario's model
bindings) can reach ``self.session``. Entities with bespoke create/update
(binding validation, versioning) override just those methods.
"""
_table: type
_order_by: str
def __init__(self, session: Optional[Session] = None):
self.session = session or get_session()
def _to_db(self, obj: M) -> DB:
raise NotImplementedError
def _from_db(self, db: DB) -> M:
raise NotImplementedError
def list_all(self) -> list[M]:
column = getattr(self._table, self._order_by)
statement = select(self._table).order_by(column.desc())
return [self._from_db(r) for r in self.session.exec(statement).all()]
def get(self, entity_id: str) -> Optional[M]:
db = self.session.get(self._table, entity_id)
return self._from_db(db) if db else None
def create(self, obj: M) -> M:
db = self._to_db(obj)
self.session.add(db)
self.session.commit()
self.session.refresh(db)
return self._from_db(db)
def delete(self, entity_id: str) -> bool:
db = self.session.get(self._table, entity_id)
if not db:
return False
self.session.delete(db)
self.session.commit()
return True
def _result_to_db(result: EvalResult) -> EvalResultDB:
return EvalResultDB(
id=result.id,
run_id=result.run_id,
case_id=result.case_id,
turn_id=result.turn_id,
rule_type=result.rule_type,
passed=result.passed,
score=result.score,
reason=result.reason,
)
def _result_from_db(db: EvalResultDB) -> EvalResult:
return EvalResult(
id=db.id,
run_id=db.run_id,
case_id=db.case_id,
turn_id=db.turn_id,
rule_type=db.rule_type,
passed=db.passed,
score=db.score,
reason=db.reason,
)
class TargetRepository(BaseRepository[EvalTarget, EvalTargetDB]):
"""Repository for evaluation targets."""
_table = EvalTargetDB
_order_by = "created_at"
def _to_db(self, target: EvalTarget) -> EvalTargetDB:
db = EvalTargetDB(
id=target.id,
name=target.name,
description=target.description,
platform=target.platform.value,
channel_type=target.channel_type.value,
status=target.status.value,
created_at=target.created_at,
updated_at=target.updated_at or utc_now(),
)
db.set_config(target.channel_config)
return db
def _from_db(self, db: EvalTargetDB) -> EvalTarget:
return EvalTarget(
id=db.id,
name=db.name,
description=db.description,
platform=db.platform,
channel_type=db.channel_type,
channel_config=db.get_config(),
status=db.status,
created_at=db.created_at,
updated_at=db.updated_at,
)
def update(self, target: EvalTarget) -> Optional[EvalTarget]:
existing = self.session.get(EvalTargetDB, target.id)
if not existing:
return None
existing.name = target.name
existing.description = target.description
existing.platform = target.platform.value
existing.channel_type = target.channel_type.value
existing.status = target.status.value
existing.set_config(target.channel_config)
existing.updated_at = utc_now()
self.session.add(existing)
self.session.commit()
self.session.refresh(existing)
return self._from_db(existing)
class ScenarioRepository(BaseRepository[Scenario, ScenarioDB]):
"""Repository for evaluation scenarios."""
_table = ScenarioDB
_order_by = "created_at"
def _to_db(self, scenario: Scenario) -> ScenarioDB:
db = ScenarioDB(
id=scenario.id,
name=scenario.name,
description=scenario.description,
created_at=scenario.created_at,
updated_at=scenario.updated_at or utc_now(),
)
db.set_tags(scenario.tags)
# mode="json" 与 update() 的考纲比较保持同一序列化形态,避免假升版
db.set_cases([case.model_dump(mode="json") for case in scenario.cases])
db.set_llm_config(scenario.llm_config)
return db
def _from_db(self, db: ScenarioDB) -> Scenario:
bindings = ScenarioModelBindingRepository(self.session).get_for_scenario(db.id or "")
return Scenario(
id=db.id,
name=db.name,
description=db.description,
tags=db.get_tags(),
cases=[Case(**case) for case in db.get_cases()],
model_bindings=bindings,
llm_config=db.get_llm_config(),
version=db.version or 1,
created_at=db.created_at,
updated_at=db.updated_at,
)
def create(self, scenario: Scenario) -> Scenario:
db = self._to_db(scenario)
bindings = {purpose.value: config_id for purpose, config_id in scenario.model_bindings.items()}
try:
ModelConfigService(self.session).validate_bindings(bindings)
self.session.add(db)
self.session.flush()
ScenarioModelBindingRepository(self.session).replace_for_scenario(db.id or "", bindings)
self.session.commit()
self.session.refresh(db)
except Exception:
self.session.rollback()
raise
return self._from_db(db)
def update(self, scenario: Scenario) -> Optional[Scenario]:
existing = self.session.get(ScenarioDB, scenario.id)
if not existing:
return None
bindings = {purpose.value: config_id for purpose, config_id in scenario.model_bindings.items()}
try:
ModelConfigService(self.session).validate_bindings(bindings)
# 考纲字段cases / model_bindings / llm_config变更才升版ADR-0001
# 版本由系统维护,忽略 scenario.version 的外部传入值。
new_cases = [case.model_dump(mode="json") for case in scenario.cases]
old_bindings = ScenarioModelBindingRepository(self.session).get_for_scenario(existing.id or "")
syllabus_changed = (
existing.get_cases() != new_cases
or existing.get_llm_config() != scenario.llm_config
or old_bindings != bindings
)
if syllabus_changed:
existing.version = (existing.version or 1) + 1
existing.name = scenario.name
existing.description = scenario.description
existing.set_tags(scenario.tags)
existing.set_cases(new_cases)
existing.set_llm_config(scenario.llm_config)
existing.updated_at = utc_now()
self.session.add(existing)
ScenarioModelBindingRepository(self.session).replace_for_scenario(existing.id or "", bindings)
self.session.commit()
self.session.refresh(existing)
except Exception:
self.session.rollback()
raise
return self._from_db(existing)
def delete(self, scenario_id: str) -> bool:
db = self.session.get(ScenarioDB, scenario_id)
if not db:
return False
try:
ScenarioModelBindingRepository(self.session).delete_for_scenario(scenario_id)
self.session.delete(db)
self.session.commit()
except Exception:
self.session.rollback()
raise
return True
def name_map(self) -> dict[str, str]:
"""scenario_id → 名称映射:报告 / 时间线 / 列表等读路径共用的场景名取法。"""
return {sid: name for sid, name in self.session.exec(select(ScenarioDB.id, ScenarioDB.name)).all()}
class CampaignRunClaimStatus(str, Enum):
"""Outcome of a durable Campaign child-Run claim."""
CLAIMED = "claimed"
EXISTING = "existing"
NOT_FOUND = "not_found"
CONFLICT = "conflict"
@dataclass(frozen=True)
class CampaignRunClaimResult:
"""Typed result for the idempotent child-Run claim seam."""
status: CampaignRunClaimStatus
run: Optional[EvalRun] = None
@property
def accepted(self) -> bool:
return self.status in (CampaignRunClaimStatus.CLAIMED, CampaignRunClaimStatus.EXISTING)
@property
def created(self) -> bool:
return self.status is CampaignRunClaimStatus.CLAIMED
class RunRepository(BaseRepository[EvalRun, EvalRunDB]):
"""Repository for evaluation runs."""
_table = EvalRunDB
_order_by = "started_at"
def claim_campaign_run(
self,
*,
campaign_id: str,
scenario_id: str,
scenario_version: int,
plan_index: int,
occurrence_index: int,
) -> "CampaignRunClaimResult":
"""Claim one durable child Run occurrence while its Campaign is running.
The conditional ``INSERT ... SELECT`` makes the Campaign status check
part of the write. The composite unique index turns retries or a
competing scheduler into an ``EXISTING`` result instead of a second
pending Run. No external messages are sent by this operation.
"""
if plan_index < 0 or occurrence_index < 0:
raise ValueError("plan_index and occurrence_index must be non-negative")
run_id = new_uuid()
now = utc_now()
statement = sql_insert(EvalRunDB).from_select(
[
"id",
"target_id",
"scenario_id",
"scenario_version",
"campaign_id",
"campaign_plan_index",
"campaign_occurrence_index",
"status",
"triggered_by",
"started_at",
],
select(
literal(run_id),
CampaignDB.target_id,
literal(scenario_id),
literal(scenario_version),
CampaignDB.id,
literal(plan_index),
literal(occurrence_index),
literal(RunStatus.PENDING.value),
literal(RunTrigger.CAMPAIGN.value),
literal(now),
).where(
CampaignDB.id == campaign_id,
CampaignDB.status == CampaignStatus.RUNNING.value,
CampaignDB.target_id.is_not(None),
),
)
try:
result = self.session.exec(statement)
if result.rowcount == 1:
self.session.commit()
db = self.session.get(EvalRunDB, run_id)
return CampaignRunClaimResult(CampaignRunClaimStatus.CLAIMED, self._from_db(db))
self.session.rollback()
except IntegrityError:
self.session.rollback()
except Exception:
self.session.rollback()
raise
existing = self._get_campaign_identity(campaign_id, plan_index, occurrence_index)
campaign = self.session.get(CampaignDB, campaign_id)
if campaign is None:
return CampaignRunClaimResult(CampaignRunClaimStatus.NOT_FOUND)
if campaign.status != CampaignStatus.RUNNING.value:
return CampaignRunClaimResult(CampaignRunClaimStatus.CONFLICT)
if existing is not None:
return CampaignRunClaimResult(CampaignRunClaimStatus.EXISTING, self._from_db(existing))
return CampaignRunClaimResult(CampaignRunClaimStatus.CONFLICT)
def _get_campaign_identity(self, campaign_id: str, plan_index: int, occurrence_index: int) -> Optional[EvalRunDB]:
statement = select(EvalRunDB).where(
EvalRunDB.campaign_id == campaign_id,
EvalRunDB.campaign_plan_index == plan_index,
EvalRunDB.campaign_occurrence_index == occurrence_index,
)
return self.session.exec(statement).first()
def _to_db(self, run: EvalRun) -> EvalRunDB:
db = EvalRunDB(
id=run.id,
target_id=run.target_id,
scenario_id=run.scenario_id,
scenario_version=run.scenario_version,
campaign_id=run.campaign_id,
campaign_plan_index=run.campaign_plan_index,
campaign_occurrence_index=run.campaign_occurrence_index,
status=run.status.value,
triggered_by=run.triggered_by.value,
started_at=run.started_at,
completed_at=run.completed_at,
)
if run.summary is not None:
db.set_summary(run.summary.model_dump(mode="json"))
return db
def _from_db(self, db: EvalRunDB) -> EvalRun:
return EvalRun(
id=db.id,
target_id=db.target_id,
scenario_id=db.scenario_id,
scenario_version=db.scenario_version or 1,
campaign_id=db.campaign_id,
campaign_plan_index=db.campaign_plan_index,
campaign_occurrence_index=db.campaign_occurrence_index,
status=db.status,
triggered_by=db.triggered_by or "manual",
started_at=db.started_at,
completed_at=db.completed_at,
summary=db.get_summary(),
)
def list_by_campaign(self, campaign_id: str) -> list[EvalRun]:
statement = select(EvalRunDB).where(EvalRunDB.campaign_id == campaign_id).order_by(EvalRunDB.started_at)
return [self._from_db(r) for r in self.session.exec(statement).all()]
def list_by_campaigns(self, campaign_ids: list[str]) -> dict[str, list[EvalRun]]:
"""Load child Runs for multiple Campaigns in one query."""
grouped = {campaign_id: [] for campaign_id in campaign_ids}
if not campaign_ids:
return grouped
statement = (
select(EvalRunDB)
.where(EvalRunDB.campaign_id.in_(campaign_ids)) # type: ignore[union-attr]
.order_by(EvalRunDB.started_at)
)
for row in self.session.exec(statement).all():
if row.campaign_id in grouped:
grouped[row.campaign_id].append(self._from_db(row))
return grouped
def mark_orphans_failed(self) -> int:
"""Fail process-orphaned Runs while preserving recoverable child claims.
Every RUNNING Run may already have sent messages and must not replay.
A PENDING Run is preserved only when it has a complete Campaign plan
identity and its parent Campaign is still running; the Campaign loop
can safely resume those claims before any external send occurred.
"""
statement = select(EvalRunDB).where(EvalRunDB.status.in_(["running", "pending"])) # type: ignore[attr-defined]
candidates = self.session.exec(statement).all()
failed: list[EvalRunDB] = []
for db in candidates:
if db.status == RunStatus.PENDING.value and self._is_recoverable_campaign_pending(db):
continue
self._set_interrupted(db)
failed.append(db)
if failed:
self.session.commit()
return len(failed)
def _is_recoverable_campaign_pending(self, db: EvalRunDB) -> bool:
if db.campaign_id is None or db.campaign_plan_index is None or db.campaign_occurrence_index is None:
return False
campaign = self.session.get(CampaignDB, db.campaign_id)
return campaign is not None and campaign.status == CampaignStatus.RUNNING.value
def _set_interrupted(self, db: EvalRunDB) -> None:
db.status = RunStatus.FAILED.value
db.completed_at = db.completed_at or utc_now()
summary = db.get_summary() or {}
summary["error"] = {"code": "interrupted", "message": "服务重启导致评测中断"}
db.set_summary(summary)
self.session.add(db)
def list_pending_campaign_children(self, campaign_id: str) -> list[EvalRun]:
"""Return durable pending claims in deterministic plan order."""
statement = (
select(EvalRunDB)
.where(
EvalRunDB.campaign_id == campaign_id,
EvalRunDB.campaign_plan_index.is_not(None),
EvalRunDB.campaign_occurrence_index.is_not(None),
EvalRunDB.status == RunStatus.PENDING.value,
)
.order_by(EvalRunDB.campaign_plan_index, EvalRunDB.campaign_occurrence_index)
)
return [self._from_db(db) for db in self.session.exec(statement).all()]
def mark_campaign_running_interrupted(self, campaign_id: str) -> list[str]:
"""Fail running child Runs from a previous process without replaying."""
statement = select(EvalRunDB).where(
EvalRunDB.campaign_id == campaign_id,
EvalRunDB.campaign_plan_index.is_not(None),
EvalRunDB.campaign_occurrence_index.is_not(None),
EvalRunDB.status == RunStatus.RUNNING.value,
)
rows = list(self.session.exec(statement).all())
for db in rows:
self._set_interrupted(db)
if rows:
self.session.commit()
return [db.id or "" for db in rows]
def mark_pending_campaign_child_interrupted(self, run_id: str) -> Optional[EvalRun]:
"""Fail one still-pending child claim that cannot be safely recovered."""
db = self.session.get(EvalRunDB, run_id)
if db is None or db.status != RunStatus.PENDING.value:
return self._from_db(db) if db is not None else None
self._set_interrupted(db)
self.session.commit()
self.session.refresh(db)
return self._from_db(db)
def update(self, run: EvalRun) -> Optional[EvalRun]:
existing = self.session.get(EvalRunDB, run.id)
if not existing:
return None
existing.target_id = run.target_id
existing.scenario_id = run.scenario_id
existing.scenario_version = run.scenario_version
existing.campaign_id = run.campaign_id
existing.campaign_plan_index = run.campaign_plan_index
existing.campaign_occurrence_index = run.campaign_occurrence_index
existing.status = run.status.value
existing.triggered_by = run.triggered_by.value
existing.completed_at = run.completed_at
if run.summary is not None:
existing.set_summary(run.summary.model_dump(mode="json"))
self.session.add(existing)
self.session.commit()
self.session.refresh(existing)
return self._from_db(existing)
def get_turns(self, run_id: str) -> list[TurnDB]:
statement = select(TurnDB).where(TurnDB.run_id == run_id).order_by(TurnDB.sent_at)
return list(self.session.exec(statement).all())
def get_results(self, run_id: str) -> list[EvalResult]:
statement = select(EvalResultDB).where(EvalResultDB.run_id == run_id)
return [_result_from_db(r) for r in self.session.exec(statement).all()]
class CampaignWriteStatus(str, Enum):
"""Outcome of a conditional Campaign lifecycle write."""
APPLIED = "applied"
NOT_FOUND = "not_found"
CONFLICT = "conflict"
@dataclass(frozen=True)
class CampaignWriteResult:
"""Typed result returned by Campaign compare-and-set operations."""
status: CampaignWriteStatus
campaign: Optional[Campaign] = None
@property
def applied(self) -> bool:
return self.status is CampaignWriteStatus.APPLIED
class CampaignRepository(BaseRepository[Campaign, CampaignDB]):
"""Repository for evaluation campaigns (评估活动)."""
_table = CampaignDB
_order_by = "created_at"
def _to_db(self, campaign: Campaign) -> CampaignDB:
db = CampaignDB(
id=campaign.id,
name=campaign.name,
target_id=campaign.target_id,
window_seconds=campaign.window_seconds,
time_scale=campaign.time_scale,
status=campaign.status.value,
started_at=campaign.started_at,
completed_at=campaign.completed_at,
created_at=campaign.created_at,
analysis_model_config_id=campaign.analysis_model_config_id,
last_patrolled_at=campaign.last_patrolled_at,
)
db.set_plan([entry.model_dump(mode="json") for entry in campaign.plan])
if campaign.summary:
db.set_summary(campaign.summary.model_dump(mode="json"))
if campaign.exploration_seeds is not None:
db.set_exploration_seeds(campaign.exploration_seeds.model_dump(mode="json"))
if campaign.exploration_budget is not None:
db.set_exploration_budget(campaign.exploration_budget.model_dump(mode="json"))
return db
def _from_db(self, db: CampaignDB) -> Campaign:
return Campaign(
id=db.id,
name=db.name,
target_id=db.target_id,
window_seconds=db.window_seconds,
time_scale=db.time_scale,
plan=db.get_plan(),
status=db.status,
started_at=db.started_at,
completed_at=db.completed_at,
created_at=db.created_at,
summary=db.get_summary(),
analysis_model_config_id=db.analysis_model_config_id,
exploration_seeds=db.get_exploration_seeds(),
exploration_budget=db.get_exploration_budget(),
last_patrolled_at=db.last_patrolled_at,
)
def update(self, campaign: Campaign) -> Optional[Campaign]:
existing = self.session.get(CampaignDB, campaign.id)
if not existing:
return None
existing.name = campaign.name
existing.target_id = campaign.target_id
existing.window_seconds = campaign.window_seconds
existing.time_scale = campaign.time_scale
existing.set_plan([entry.model_dump(mode="json") for entry in campaign.plan])
existing.status = campaign.status.value
existing.started_at = campaign.started_at
existing.completed_at = campaign.completed_at
existing.analysis_model_config_id = campaign.analysis_model_config_id
existing.last_patrolled_at = campaign.last_patrolled_at
if campaign.summary is not None:
existing.set_summary(campaign.summary.model_dump(mode="json"))
if campaign.exploration_seeds is not None:
existing.set_exploration_seeds(campaign.exploration_seeds.model_dump(mode="json"))
if campaign.exploration_budget is not None:
existing.set_exploration_budget(campaign.exploration_budget.model_dump(mode="json"))
self.session.add(existing)
self.session.commit()
self.session.refresh(existing)
return self._from_db(existing)
def touch_patrol_watermark(self, campaign_id: str, at: datetime) -> None:
"""窄口径原子更新:只写巡检水位,不覆写并发的 status / summary 变更。"""
db = self.session.get(CampaignDB, campaign_id)
if not db:
return
db.last_patrolled_at = at
self.session.add(db)
self.session.commit()
self.session.refresh(db)
def mark_cancelled(self, campaign_id: str, at: datetime) -> Optional[Campaign]:
"""窄口径终态迁移:只写 status + completed_at不抹掉水位与调度进度。"""
db = self.session.get(CampaignDB, campaign_id)
if not db:
return None
db.status = CampaignStatus.CANCELLED.value
db.completed_at = at
self.session.add(db)
self.session.commit()
self.session.refresh(db)
return self._from_db(db)
def _compare_and_set_status(
self,
campaign_id: str,
*,
expected_statuses: tuple[CampaignStatus, ...],
target_status: CampaignStatus,
at: datetime,
settle_exploration: bool = False,
) -> CampaignWriteResult:
"""Apply one conditional Campaign transition and optional settlement.
Final Campaign status and expiration of running exploration sessions
share one transaction. A settlement failure therefore rolls the
Campaign back to its prior runnable state.
"""
values: dict[str, object] = {"status": target_status.value}
if target_status is CampaignStatus.RUNNING:
values["started_at"] = at
if target_status in (CampaignStatus.CANCELLED, CampaignStatus.COMPLETED, CampaignStatus.FAILED):
values["completed_at"] = at
statement = (
sql_update(CampaignDB)
.where(
CampaignDB.id == campaign_id,
CampaignDB.status.in_([status.value for status in expected_statuses]),
)
.values(**values)
)
try:
result = self.session.exec(statement)
if result.rowcount != 1:
self.session.rollback()
db = self.session.get(CampaignDB, campaign_id)
status = CampaignWriteStatus.NOT_FOUND if db is None else CampaignWriteStatus.CONFLICT
return CampaignWriteResult(status=status, campaign=self._from_db(db) if db else None)
if settle_exploration:
self.session.exec(
sql_update(ExplorationSessionDB)
.where(
ExplorationSessionDB.campaign_id == campaign_id,
ExplorationSessionDB.status == ExplorationSessionStatus.RUNNING.value,
)
.values(status=ExplorationSessionStatus.EXPIRED.value, closed_at=at)
)
self.session.commit()
except Exception:
self.session.rollback()
raise
self.session.expire_all()
db = self.session.get(CampaignDB, campaign_id)
return CampaignWriteResult(
status=CampaignWriteStatus.APPLIED,
campaign=self._from_db(db) if db else None,
)
def cancel_if_active(self, campaign_id: str, at: datetime) -> CampaignWriteResult:
"""Atomically cancel and expire running exploration sessions."""
return self._compare_and_set_status(
campaign_id,
expected_statuses=(CampaignStatus.PLANNED, CampaignStatus.RUNNING),
target_status=CampaignStatus.CANCELLED,
at=at,
settle_exploration=True,
)
def complete_if_running(self, campaign_id: str, at: datetime) -> CampaignWriteResult:
"""Atomically complete and expire running exploration sessions."""
return self._compare_and_set_status(
campaign_id,
expected_statuses=(CampaignStatus.RUNNING,),
target_status=CampaignStatus.COMPLETED,
at=at,
settle_exploration=True,
)
def start_if_planned(self, campaign_id: str, at: datetime) -> CampaignWriteResult:
"""Atomically stamp a planned Campaign as running."""
return self._compare_and_set_status(
campaign_id,
expected_statuses=(CampaignStatus.PLANNED,),
target_status=CampaignStatus.RUNNING,
at=at,
)
def save_scheduler_state(self, campaign_id: str, summary: CampaignSummary) -> None:
"""窄口径调度进度持久化:只写 summary 列,不覆写并发的状态 / 水位变更。"""
db = self.session.get(CampaignDB, campaign_id)
if not db:
return
db.set_summary(summary.model_dump(mode="json"))
self.session.add(db)
self.session.commit()
self.session.refresh(db)
class ResultRepository:
"""Repository for evaluation results."""
def __init__(self, session: Optional[Session] = None):
self.session = session or get_session()
def save_turn(self, turn) -> TurnDB:
db = TurnDB(
id=turn.id,
run_id=turn.run_id,
case_id=turn.case_id,
round_index=turn.round_index,
question_msg_id=turn.question_msg_id,
sent_at=turn.sent_at,
received_at=turn.received_at,
latency_ms=turn.latency_ms,
)
db.set_sent_message(turn.sent_message)
db.set_reply(turn.reply)
self.session.add(db)
self.session.commit()
self.session.refresh(db)
return db
def update_turn_exchange(
self,
turn_id: str,
*,
question_msg_id: Optional[str],
reply: Optional[dict],
received_at: Optional[datetime],
latency_ms: Optional[int],
) -> Optional[TurnDB]:
"""Attach exchange facts to an already-persisted Turn.
This is deliberately narrower than updating a whole Turn: the sent
message, case identity, ordering and send timestamp remain untouched.
A caller can therefore commit the sent fact before polling and safely
complete the same ledger row after a reply, timeout or poll failure.
"""
db = self.session.get(TurnDB, turn_id)
if db is None:
return None
db.question_msg_id = question_msg_id
db.set_reply(reply)
db.received_at = received_at
db.latency_ms = latency_ms
self.session.add(db)
self.session.commit()
self.session.refresh(db)
return db
def save_result(self, result: EvalResult) -> EvalResult:
db = _result_to_db(result)
self.session.add(db)
self.session.commit()
self.session.refresh(db)
return _result_from_db(db)