合并两个不可分割的深化: 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 精神,等真实需求出现再议)。
177 lines
6.9 KiB
Python
177 lines
6.9 KiB
Python
"""Exploration session lifecycle (探索会话生命周期).
|
||
|
||
会话三态的领域操作:open_session / conduct_turn / close_session。
|
||
平台账本(预算、状态机、轮次记账)全部在此执行,违规抛类型化领域
|
||
异常(errors.py),由 router 映射 HTTP 状态码;通道收发沿用
|
||
ChannelFactory 既有接缝。
|
||
"""
|
||
|
||
from typing import Any, Optional
|
||
|
||
from sqlmodel import Session
|
||
|
||
from agenteval.channels.base import ExchangeStatus, SendResult
|
||
from agenteval.channels.factory import ChannelFactory
|
||
from agenteval.config import get_settings
|
||
from agenteval.exploration.errors import (
|
||
ExplorationChannelError,
|
||
ExplorationGuardrailError,
|
||
ExplorationNotFoundError,
|
||
)
|
||
from agenteval.exploration.judge import start_judge_review
|
||
from agenteval.exploration.models import (
|
||
ExplorationBudget,
|
||
ExplorationMessage,
|
||
ExplorationSession,
|
||
ExplorationSessionStatus,
|
||
ExplorationTrigger,
|
||
normalize_experience,
|
||
resolve_budget,
|
||
)
|
||
from agenteval.models import CampaignStatus
|
||
from agenteval.storage.db import as_utc, utc_now
|
||
from agenteval.storage.exploration_repository import ExplorationMessageRepository, ExplorationSessionRepository
|
||
from agenteval.storage.repository import (
|
||
CampaignRepository,
|
||
TargetRepository,
|
||
)
|
||
|
||
|
||
def _check_creation_guardrails(
|
||
campaign,
|
||
triggered_by: ExplorationTrigger,
|
||
budget: ExplorationBudget,
|
||
repo: ExplorationSessionRepository,
|
||
) -> None:
|
||
if campaign.status != CampaignStatus.RUNNING:
|
||
raise ExplorationGuardrailError("活动不在进行中,无法创建探索会话")
|
||
if campaign.time_scale != 1 and triggered_by != ExplorationTrigger.MANUAL:
|
||
raise ExplorationGuardrailError("加速调试线仅允许手动创建探索会话(时间压缩与拟真相冲突)")
|
||
sessions = repo.list_by_campaign(campaign.id)
|
||
if len(sessions) >= budget.max_sessions:
|
||
raise ExplorationGuardrailError(f"探索会话数超出预算:本活动窗口最多 {budget.max_sessions} 个会话")
|
||
if sessions:
|
||
latest = max(s.created_at for s in sessions if s.created_at)
|
||
elapsed = (utc_now() - as_utc(latest)).total_seconds()
|
||
if elapsed < budget.min_interval_seconds:
|
||
wait_minutes = budget.min_interval_seconds // 60
|
||
raise ExplorationGuardrailError(f"相邻探索会话间隔不足:最小间隔 {wait_minutes} 分钟,请稍后再试")
|
||
|
||
|
||
def open_session(
|
||
db_session: Session,
|
||
*,
|
||
campaign_id: str,
|
||
persona: dict[str, Any],
|
||
goal: str,
|
||
seed_ref: Optional[dict[str, Any]] = None,
|
||
triggered_by: ExplorationTrigger = ExplorationTrigger.AUTO,
|
||
) -> ExplorationSession:
|
||
"""按账本规则开一个 running 会话;违规抛领域异常。"""
|
||
campaign = CampaignRepository(db_session).get(campaign_id)
|
||
if not campaign:
|
||
raise ExplorationNotFoundError("campaign not found")
|
||
if not TargetRepository(db_session).get(campaign.target_id):
|
||
raise ExplorationNotFoundError("campaign target not found")
|
||
|
||
budget = resolve_budget(campaign)
|
||
repo = ExplorationSessionRepository(db_session)
|
||
_check_creation_guardrails(campaign, triggered_by, budget, repo)
|
||
|
||
return repo.create(
|
||
ExplorationSession(
|
||
campaign_id=campaign.id,
|
||
target_id=campaign.target_id,
|
||
persona=persona,
|
||
goal=goal,
|
||
seed_ref=seed_ref,
|
||
triggered_by=triggered_by,
|
||
)
|
||
)
|
||
|
||
|
||
async def conduct_turn(db_session: Session, *, session_id: str, content: str) -> dict[str, Any]:
|
||
"""一轮完整问答:状态机拒收 → 轮次预算记账 → 通道往返 → 双条落库。
|
||
|
||
消息已送达即消耗一轮预算(平台账本):先落用户消息,再等回复。
|
||
"""
|
||
repo = ExplorationSessionRepository(db_session)
|
||
session_obj = repo.get(session_id)
|
||
if not session_obj:
|
||
raise ExplorationNotFoundError("exploration session not found")
|
||
if session_obj.status != ExplorationSessionStatus.RUNNING:
|
||
raise ExplorationGuardrailError("探索会话不在进行中,拒收消息")
|
||
|
||
campaign = CampaignRepository(db_session).get(session_obj.campaign_id)
|
||
budget = resolve_budget(campaign) if campaign else ExplorationBudget()
|
||
if session_obj.turn_count >= budget.max_turns:
|
||
raise ExplorationGuardrailError(f"会话轮数超出预算:单会话最多 {budget.max_turns} 轮")
|
||
|
||
target = TargetRepository(db_session).get(session_obj.target_id)
|
||
if not target:
|
||
raise ExplorationNotFoundError("session target not found")
|
||
|
||
message_repo = ExplorationMessageRepository(db_session)
|
||
round_index = session_obj.turn_count + 1
|
||
sent_at = utc_now()
|
||
channel = ChannelFactory.create(target)
|
||
|
||
async def record_sent(_send_result: SendResult) -> None:
|
||
message_repo.save_message(
|
||
ExplorationMessage(
|
||
session_id=session_obj.id,
|
||
round_index=round_index,
|
||
role="user",
|
||
content=content,
|
||
created_at=sent_at,
|
||
)
|
||
)
|
||
session_obj.turn_count = round_index
|
||
repo.update(session_obj)
|
||
|
||
outcome = await channel.exchange(
|
||
content,
|
||
timeout=get_settings().poll_reply_timeout,
|
||
on_sent=record_sent,
|
||
)
|
||
if outcome.status is ExchangeStatus.SEND_FAILED:
|
||
raise ExplorationChannelError(f"评测对象通道发送失败: {outcome.reason}")
|
||
if outcome.status is ExchangeStatus.POLL_FAILED:
|
||
raise ExplorationChannelError(f"等待评测对象回复失败: {outcome.reason}")
|
||
if outcome.status is ExchangeStatus.REPLY_TIMEOUT:
|
||
raise ExplorationChannelError("等待评测对象回复超时")
|
||
|
||
received_at = utc_now()
|
||
latency_ms = (
|
||
outcome.latency_ms if outcome.latency_ms is not None else int((received_at - sent_at).total_seconds() * 1000)
|
||
)
|
||
reply_text = outcome.reply_text or ""
|
||
message_repo.save_message(
|
||
ExplorationMessage(
|
||
session_id=session_obj.id,
|
||
round_index=round_index,
|
||
role="assistant",
|
||
content=reply_text,
|
||
latency_ms=latency_ms,
|
||
created_at=received_at,
|
||
)
|
||
)
|
||
return {"reply": reply_text, "latency_ms": latency_ms, "turn_count": round_index}
|
||
|
||
|
||
def close_session(db_session: Session, *, session_id: str, experience: dict[str, Any]) -> ExplorationSession:
|
||
"""关闭会话:体验记录归一化 → 状态迁移 → 触发 judge 抽样复核。"""
|
||
repo = ExplorationSessionRepository(db_session)
|
||
session_obj = repo.get(session_id)
|
||
if not session_obj:
|
||
raise ExplorationNotFoundError("exploration session not found")
|
||
if session_obj.status != ExplorationSessionStatus.RUNNING:
|
||
raise ExplorationGuardrailError("探索会话不在进行中,无法关闭")
|
||
|
||
session_obj.experience = normalize_experience(experience)
|
||
session_obj.status = ExplorationSessionStatus.COMPLETED
|
||
session_obj.closed_at = utc_now()
|
||
updated = repo.update(session_obj)
|
||
start_judge_review(session_obj.id)
|
||
return updated
|