refactor(exploration): move ledger and state machine into domain modules
Some checks failed
CI / test (push) Failing after 33s
Some checks failed
CI / test (push) Failing after 33s
架构保养候选 3:探索生命周期的账本规则与状态机从 HTTP 层落入 exploration/lifecycle.py(open/conduct/close)与 patrol.py(巡检读模型), 违规改用类型化领域异常(NotFound/Guardrail/Channel),router 瘦回纯 HTTP 翻译(404/409/502 映射),领域层不再依赖 fastapi,可脱离 TestClient 直测(新增 12 个单元测试)。
This commit is contained in:
parent
38849d46f1
commit
df76edcf55
26
backend/agenteval/exploration/errors.py
Normal file
26
backend/agenteval/exploration/errors.py
Normal file
@ -0,0 +1,26 @@
|
||||
"""Domain errors for exploratory evaluation (不依赖 fastapi).
|
||||
|
||||
领域违规用类型化异常表达,router 统一映射 HTTP 状态码:
|
||||
NotFound → 404,Guardrail → 409(reason 即给常驻代理的反馈),
|
||||
Channel → 502。
|
||||
"""
|
||||
|
||||
|
||||
class ExplorationNotFoundError(Exception):
|
||||
"""探索域实体(活动 / 评测对象 / 会话)不存在。"""
|
||||
|
||||
|
||||
class ExplorationGuardrailError(Exception):
|
||||
"""平台账本规则违规(状态机 / 预算),拒绝本身即反馈。"""
|
||||
|
||||
def __init__(self, reason: str):
|
||||
self.reason = reason
|
||||
super().__init__(reason)
|
||||
|
||||
|
||||
class ExplorationChannelError(Exception):
|
||||
"""评测对象通道收发失败。"""
|
||||
|
||||
def __init__(self, reason: str):
|
||||
self.reason = reason
|
||||
super().__init__(reason)
|
||||
187
backend/agenteval/exploration/lifecycle.py
Normal file
187
backend/agenteval/exploration/lifecycle.py
Normal file
@ -0,0 +1,187 @@
|
||||
"""Exploration session lifecycle (探索会话生命周期).
|
||||
|
||||
会话三态的领域操作:open_session / conduct_turn / close_session。
|
||||
平台账本(预算、状态机、轮次记账)全部在此执行,违规抛类型化领域
|
||||
异常(errors.py),由 router 映射 HTTP 状态码;通道收发沿用
|
||||
ChannelFactory 既有接缝。
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Any, Optional
|
||||
|
||||
from sqlmodel import Session
|
||||
|
||||
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.repository import (
|
||||
CampaignRepository,
|
||||
ExplorationMessageRepository,
|
||||
ExplorationSessionRepository,
|
||||
TargetRepository,
|
||||
)
|
||||
|
||||
|
||||
def coerce_reply_text(content: Any) -> str:
|
||||
"""Flatten a reply payload to text; tutu returns msgBody as a parsed object,
|
||||
and str(dict) would leak a Python repr into the view."""
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
if isinstance(content, dict):
|
||||
for key in ("content", "text", "message"):
|
||||
value = content.get(key)
|
||||
if isinstance(value, str) and value:
|
||||
return value
|
||||
if content is None:
|
||||
return ""
|
||||
return json.dumps(content, ensure_ascii=False)
|
||||
|
||||
|
||||
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")
|
||||
|
||||
channel = ChannelFactory.create(target)
|
||||
sent_at = utc_now()
|
||||
try:
|
||||
send_result = await channel.send(content)
|
||||
except Exception as exc: # channel adapters raise transport-specific errors
|
||||
raise ExplorationChannelError(f"评测对象通道发送失败: {exc}") from exc
|
||||
if not send_result.ok:
|
||||
raise ExplorationChannelError(f"评测对象通道发送失败: {send_result.error}")
|
||||
|
||||
message_repo = ExplorationMessageRepository(db_session)
|
||||
round_index = session_obj.turn_count + 1
|
||||
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)
|
||||
|
||||
try:
|
||||
reply = await channel.poll_reply(
|
||||
send_result.question_msg_id,
|
||||
timeout=get_settings().poll_reply_timeout,
|
||||
)
|
||||
except Exception as exc:
|
||||
raise ExplorationChannelError(f"等待评测对象回复失败: {exc}") from exc
|
||||
if reply is None:
|
||||
raise ExplorationChannelError("等待评测对象回复超时")
|
||||
|
||||
received_at = utc_now()
|
||||
latency_ms = int((received_at - sent_at).total_seconds() * 1000)
|
||||
reply_text = coerce_reply_text(reply.content)
|
||||
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
|
||||
100
backend/agenteval/exploration/patrol.py
Normal file
100
backend/agenteval/exploration/patrol.py
Normal file
@ -0,0 +1,100 @@
|
||||
"""Exploration patrol (探索巡检).
|
||||
|
||||
常驻智能体的无状态巡检:筛出参与探索的正式线活动(running、
|
||||
time_scale == 1、有种子集),汇报水位以来的新增结果与剩余探索预算,
|
||||
构建完响应后推进水位,保证下次只报增量。
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlmodel import Session
|
||||
|
||||
from agenteval.evaluation.report import generate_campaign_report
|
||||
from agenteval.exploration.models import resolve_budget
|
||||
from agenteval.models import Campaign, CampaignStatus, EvalRun
|
||||
from agenteval.storage.db import as_utc, iso_utc, utc_now
|
||||
from agenteval.storage.repository import (
|
||||
CampaignRepository,
|
||||
ExplorationSessionRepository,
|
||||
RunRepository,
|
||||
ScenarioRepository,
|
||||
TargetRepository,
|
||||
)
|
||||
|
||||
|
||||
def _new_runs_since(runs: list[EvalRun], watermark: datetime | None) -> list[EvalRun]:
|
||||
fresh = []
|
||||
for run in runs:
|
||||
if run.completed_at is None:
|
||||
continue
|
||||
if watermark is not None and as_utc(run.completed_at) <= watermark:
|
||||
continue
|
||||
fresh.append(run)
|
||||
return fresh
|
||||
|
||||
|
||||
def patrol_report(db_session: Session) -> dict[str, Any]:
|
||||
"""一次巡检的完整读模型:patrolled_at + 逐活动条目列表。
|
||||
|
||||
水位取构建响应之后的时刻:查询与持久化之间完成的结果不会在下次重复上报。
|
||||
"""
|
||||
campaign_repo = CampaignRepository(db_session)
|
||||
run_repo = RunRepository(db_session)
|
||||
exploration_repo = ExplorationSessionRepository(db_session)
|
||||
scenario_names = ScenarioRepository(db_session).name_map()
|
||||
target_names = {t.id: t.name for t in TargetRepository(db_session).list_all()}
|
||||
|
||||
patrolled_at = utc_now()
|
||||
entries: list[dict[str, Any]] = []
|
||||
patrolled_campaigns: list[Campaign] = []
|
||||
for campaign in campaign_repo.list_all():
|
||||
if campaign.status != CampaignStatus.RUNNING:
|
||||
continue
|
||||
if campaign.time_scale != 1:
|
||||
continue
|
||||
if campaign.exploration_seeds is None:
|
||||
continue
|
||||
|
||||
watermark = as_utc(campaign.last_patrolled_at) if campaign.last_patrolled_at else None
|
||||
fresh = _new_runs_since(run_repo.list_by_campaign(campaign.id), watermark)
|
||||
new_results = None
|
||||
if fresh:
|
||||
report = generate_campaign_report(campaign, fresh, scenario_names=scenario_names)
|
||||
new_results = {
|
||||
"summary": report["summary"],
|
||||
"capability_summary": report["capability_summary"],
|
||||
}
|
||||
|
||||
budget = resolve_budget(campaign)
|
||||
sessions = exploration_repo.list_by_campaign(campaign.id)
|
||||
seconds_since_last_session = None
|
||||
if sessions:
|
||||
latest = max(as_utc(s.created_at) for s in sessions if s.created_at)
|
||||
seconds_since_last_session = int((patrolled_at - latest).total_seconds())
|
||||
|
||||
entries.append(
|
||||
{
|
||||
"campaign_id": campaign.id,
|
||||
"campaign_name": campaign.name,
|
||||
"target_id": campaign.target_id,
|
||||
"target_name": target_names.get(campaign.target_id),
|
||||
"last_patrolled_at": iso_utc(campaign.last_patrolled_at),
|
||||
"new_results": new_results,
|
||||
"budget": {
|
||||
"max_sessions": budget.max_sessions,
|
||||
"sessions_used": len(sessions),
|
||||
"remaining_sessions": max(0, budget.max_sessions - len(sessions)),
|
||||
"max_turns": budget.max_turns,
|
||||
"min_interval_seconds": budget.min_interval_seconds,
|
||||
"seconds_since_last_session": seconds_since_last_session,
|
||||
},
|
||||
}
|
||||
)
|
||||
patrolled_campaigns.append(campaign)
|
||||
|
||||
watermark_at = utc_now()
|
||||
for campaign in patrolled_campaigns:
|
||||
campaign_repo.touch_patrol_watermark(campaign.id, watermark_at)
|
||||
|
||||
return {"patrolled_at": iso_utc(watermark_at), "campaigns": entries}
|
||||
@ -1,45 +1,28 @@
|
||||
"""API routes for exploratory evaluation sessions (探索式评测, v0.9).
|
||||
|
||||
The virtual user (OpenClaw) drives these sessions through plain HTTP: create a
|
||||
running session against a campaign, converse with the target through its real
|
||||
channel, then close with a structured self-reported experience record.
|
||||
|
||||
Guardrails are a platform ledger — enforced here on every call, never trusted
|
||||
to client self-discipline. Budget overruns and state violations are rejected
|
||||
with 409 plus a readable reason, so the rejection itself is feedback to the
|
||||
resident agent.
|
||||
领域逻辑(账本规则、状态机、巡检)在 exploration/lifecycle.py 与
|
||||
patrol.py;本层只做 HTTP 翻译:解析请求、调用领域操作、把领域异常
|
||||
映射为状态码(NotFound→404、Guardrail→409、Channel→502)。预算违规
|
||||
与状态违规的拒绝文案由领域层给出,拒绝本身就是对常驻智能体的反馈。
|
||||
"""
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlmodel import Session
|
||||
|
||||
from agenteval.channels.factory import ChannelFactory
|
||||
from agenteval.config import get_settings
|
||||
from agenteval.evaluation.report import generate_campaign_report
|
||||
from agenteval.exploration.judge import start_judge_review
|
||||
from agenteval.exploration.models import (
|
||||
ExplorationBudget,
|
||||
ExplorationMessage,
|
||||
ExplorationSession,
|
||||
ExplorationSessionStatus,
|
||||
ExplorationTrigger,
|
||||
normalize_experience,
|
||||
resolve_budget,
|
||||
from agenteval.exploration import lifecycle, patrol
|
||||
from agenteval.exploration.errors import (
|
||||
ExplorationChannelError,
|
||||
ExplorationGuardrailError,
|
||||
ExplorationNotFoundError,
|
||||
)
|
||||
from agenteval.models import Campaign, CampaignStatus, EvalRun
|
||||
from agenteval.storage.db import as_utc, iso_utc, utc_now
|
||||
from agenteval.exploration.models import ExplorationTrigger
|
||||
from agenteval.storage.repository import (
|
||||
CampaignRepository,
|
||||
ExplorationMessageRepository,
|
||||
ExplorationSessionRepository,
|
||||
RunRepository,
|
||||
ScenarioRepository,
|
||||
TargetRepository,
|
||||
)
|
||||
from agenteval.web.deps import get_db
|
||||
|
||||
@ -62,132 +45,18 @@ class CloseSessionRequest(BaseModel):
|
||||
experience: dict[str, Any]
|
||||
|
||||
|
||||
def _coerce_reply_text(content: Any) -> str:
|
||||
"""Flatten a reply payload to text; tutu returns msgBody as a parsed object,
|
||||
and str(dict) would leak a Python repr into the view."""
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
if isinstance(content, dict):
|
||||
for key in ("content", "text", "message"):
|
||||
value = content.get(key)
|
||||
if isinstance(value, str) and value:
|
||||
return value
|
||||
if content is None:
|
||||
return ""
|
||||
return json.dumps(content, ensure_ascii=False)
|
||||
|
||||
|
||||
def _check_creation_guardrails(
|
||||
campaign: Campaign,
|
||||
triggered_by: ExplorationTrigger,
|
||||
budget: ExplorationBudget,
|
||||
repo: ExplorationSessionRepository,
|
||||
) -> None:
|
||||
if campaign.status != CampaignStatus.RUNNING:
|
||||
raise HTTPException(status_code=409, detail="活动不在进行中,无法创建探索会话")
|
||||
if campaign.time_scale != 1 and triggered_by != ExplorationTrigger.MANUAL:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="加速调试线仅允许手动创建探索会话(时间压缩与拟真相冲突)",
|
||||
)
|
||||
sessions = repo.list_by_campaign(campaign.id)
|
||||
if len(sessions) >= budget.max_sessions:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=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 HTTPException(
|
||||
status_code=409,
|
||||
detail=f"相邻探索会话间隔不足:最小间隔 {wait_minutes} 分钟,请稍后再试",
|
||||
)
|
||||
|
||||
|
||||
def _new_runs_since(runs: list[EvalRun], watermark: datetime | None) -> list[EvalRun]:
|
||||
fresh = []
|
||||
for run in runs:
|
||||
if run.completed_at is None:
|
||||
continue
|
||||
if watermark is not None and as_utc(run.completed_at) <= watermark:
|
||||
continue
|
||||
fresh.append(run)
|
||||
return fresh
|
||||
def _translate(exc: Exception) -> HTTPException:
|
||||
if isinstance(exc, ExplorationNotFoundError):
|
||||
return HTTPException(status_code=404, detail=str(exc))
|
||||
if isinstance(exc, ExplorationGuardrailError):
|
||||
return HTTPException(status_code=409, detail=exc.reason)
|
||||
return HTTPException(status_code=502, detail=exc.reason)
|
||||
|
||||
|
||||
@router.get("/patrol")
|
||||
async def patrol(session: Session = Depends(get_db)) -> dict:
|
||||
"""Stateless patrol for the resident agent.
|
||||
|
||||
Reports every running production-line (time_scale == 1) campaign that
|
||||
participates in exploration (has a seed set), with new results since the
|
||||
last watermark and the remaining exploration budget. Advances each
|
||||
patrolled campaign's watermark after building the response, so the next
|
||||
call only reports increments.
|
||||
"""
|
||||
campaign_repo = CampaignRepository(session)
|
||||
run_repo = RunRepository(session)
|
||||
exploration_repo = ExplorationSessionRepository(session)
|
||||
scenario_names = ScenarioRepository(session).name_map()
|
||||
target_names = {t.id: t.name for t in TargetRepository(session).list_all()}
|
||||
|
||||
patrolled_at = utc_now()
|
||||
entries: list[dict[str, Any]] = []
|
||||
patrolled_campaigns: list[Campaign] = []
|
||||
for campaign in campaign_repo.list_all():
|
||||
if campaign.status != CampaignStatus.RUNNING:
|
||||
continue
|
||||
if campaign.time_scale != 1:
|
||||
continue
|
||||
if campaign.exploration_seeds is None:
|
||||
continue
|
||||
|
||||
watermark = as_utc(campaign.last_patrolled_at) if campaign.last_patrolled_at else None
|
||||
fresh = _new_runs_since(run_repo.list_by_campaign(campaign.id), watermark)
|
||||
new_results = None
|
||||
if fresh:
|
||||
report = generate_campaign_report(campaign, fresh, scenario_names=scenario_names)
|
||||
new_results = {
|
||||
"summary": report["summary"],
|
||||
"capability_summary": report["capability_summary"],
|
||||
}
|
||||
|
||||
budget = resolve_budget(campaign)
|
||||
sessions = exploration_repo.list_by_campaign(campaign.id)
|
||||
seconds_since_last_session = None
|
||||
if sessions:
|
||||
latest = max(as_utc(s.created_at) for s in sessions if s.created_at)
|
||||
seconds_since_last_session = int((patrolled_at - latest).total_seconds())
|
||||
|
||||
entries.append(
|
||||
{
|
||||
"campaign_id": campaign.id,
|
||||
"campaign_name": campaign.name,
|
||||
"target_id": campaign.target_id,
|
||||
"target_name": target_names.get(campaign.target_id),
|
||||
"last_patrolled_at": iso_utc(campaign.last_patrolled_at),
|
||||
"new_results": new_results,
|
||||
"budget": {
|
||||
"max_sessions": budget.max_sessions,
|
||||
"sessions_used": len(sessions),
|
||||
"remaining_sessions": max(0, budget.max_sessions - len(sessions)),
|
||||
"max_turns": budget.max_turns,
|
||||
"min_interval_seconds": budget.min_interval_seconds,
|
||||
"seconds_since_last_session": seconds_since_last_session,
|
||||
},
|
||||
}
|
||||
)
|
||||
patrolled_campaigns.append(campaign)
|
||||
|
||||
# 水位取构建响应之后的时刻:查询与持久化之间完成的结果不会在下次重复上报。
|
||||
watermark_at = utc_now()
|
||||
for campaign in patrolled_campaigns:
|
||||
campaign_repo.touch_patrol_watermark(campaign.id, watermark_at)
|
||||
|
||||
return {"patrolled_at": iso_utc(watermark_at), "campaigns": entries}
|
||||
async def patrol_endpoint(session: Session = Depends(get_db)) -> dict:
|
||||
"""Stateless patrol for the resident agent — see exploration/patrol.py."""
|
||||
return patrol.patrol_report(session)
|
||||
|
||||
|
||||
@router.post("/sessions")
|
||||
@ -195,25 +64,18 @@ async def create_session(
|
||||
request: CreateSessionRequest,
|
||||
session: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
campaign = CampaignRepository(session).get(request.campaign_id)
|
||||
if not campaign:
|
||||
raise HTTPException(status_code=404, detail="campaign not found")
|
||||
if not TargetRepository(session).get(campaign.target_id):
|
||||
raise HTTPException(status_code=404, detail="campaign target not found")
|
||||
|
||||
budget = resolve_budget(campaign)
|
||||
repo = ExplorationSessionRepository(session)
|
||||
_check_creation_guardrails(campaign, request.triggered_by, budget, repo)
|
||||
|
||||
session_obj = ExplorationSession(
|
||||
campaign_id=campaign.id,
|
||||
target_id=campaign.target_id,
|
||||
persona=request.persona,
|
||||
goal=request.goal,
|
||||
seed_ref=request.seed_ref,
|
||||
triggered_by=request.triggered_by,
|
||||
)
|
||||
return repo.create(session_obj).model_dump(mode="json")
|
||||
try:
|
||||
session_obj = lifecycle.open_session(
|
||||
session,
|
||||
campaign_id=request.campaign_id,
|
||||
persona=request.persona,
|
||||
goal=request.goal,
|
||||
seed_ref=request.seed_ref,
|
||||
triggered_by=request.triggered_by,
|
||||
)
|
||||
except (ExplorationNotFoundError, ExplorationGuardrailError) as exc:
|
||||
raise _translate(exc) from exc
|
||||
return session_obj.model_dump(mode="json")
|
||||
|
||||
|
||||
@router.get("/campaigns/{campaign_id}/sessions")
|
||||
@ -244,72 +106,10 @@ async def send_session_message(
|
||||
request: SendMessageRequest,
|
||||
session: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
repo = ExplorationSessionRepository(session)
|
||||
session_obj = repo.get(session_id)
|
||||
if not session_obj:
|
||||
raise HTTPException(status_code=404, detail="exploration session not found")
|
||||
if session_obj.status != ExplorationSessionStatus.RUNNING:
|
||||
raise HTTPException(status_code=409, detail="探索会话不在进行中,拒收消息")
|
||||
|
||||
campaign = CampaignRepository(session).get(session_obj.campaign_id)
|
||||
budget = resolve_budget(campaign) if campaign else ExplorationBudget()
|
||||
if session_obj.turn_count >= budget.max_turns:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=f"会话轮数超出预算:单会话最多 {budget.max_turns} 轮",
|
||||
)
|
||||
|
||||
target = TargetRepository(session).get(session_obj.target_id)
|
||||
if not target:
|
||||
raise HTTPException(status_code=404, detail="session target not found")
|
||||
|
||||
channel = ChannelFactory.create(target)
|
||||
sent_at = utc_now()
|
||||
try:
|
||||
send_result = await channel.send(request.content)
|
||||
except Exception as exc: # channel adapters raise transport-specific errors
|
||||
raise HTTPException(status_code=502, detail=f"评测对象通道发送失败: {exc}")
|
||||
if not send_result.ok:
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail=f"评测对象通道发送失败: {send_result.error}",
|
||||
)
|
||||
|
||||
# 消息已送达即消耗一轮预算(平台账本):先落用户消息,再等回复。
|
||||
message_repo = ExplorationMessageRepository(session)
|
||||
round_index = session_obj.turn_count + 1
|
||||
message_repo.save_message(
|
||||
ExplorationMessage(
|
||||
session_id=session_obj.id, round_index=round_index, role="user", content=request.content, created_at=sent_at
|
||||
)
|
||||
)
|
||||
session_obj.turn_count = round_index
|
||||
repo.update(session_obj)
|
||||
|
||||
try:
|
||||
reply = await channel.poll_reply(
|
||||
send_result.question_msg_id,
|
||||
timeout=get_settings().poll_reply_timeout,
|
||||
)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=502, detail=f"等待评测对象回复失败: {exc}")
|
||||
if reply is None:
|
||||
raise HTTPException(status_code=502, detail="等待评测对象回复超时")
|
||||
|
||||
received_at = utc_now()
|
||||
latency_ms = int((received_at - sent_at).total_seconds() * 1000)
|
||||
reply_text = _coerce_reply_text(reply.content)
|
||||
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}
|
||||
return await lifecycle.conduct_turn(session, session_id=session_id, content=request.content)
|
||||
except (ExplorationNotFoundError, ExplorationGuardrailError, ExplorationChannelError) as exc:
|
||||
raise _translate(exc) from exc
|
||||
|
||||
|
||||
@router.post("/sessions/{session_id}/close")
|
||||
@ -318,16 +118,8 @@ async def close_session(
|
||||
request: CloseSessionRequest,
|
||||
session: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
repo = ExplorationSessionRepository(session)
|
||||
session_obj = repo.get(session_id)
|
||||
if not session_obj:
|
||||
raise HTTPException(status_code=404, detail="exploration session not found")
|
||||
if session_obj.status != ExplorationSessionStatus.RUNNING:
|
||||
raise HTTPException(status_code=409, detail="探索会话不在进行中,无法关闭")
|
||||
|
||||
session_obj.experience = normalize_experience(request.experience)
|
||||
session_obj.status = ExplorationSessionStatus.COMPLETED
|
||||
session_obj.closed_at = utc_now()
|
||||
updated = repo.update(session_obj)
|
||||
start_judge_review(session_obj.id)
|
||||
try:
|
||||
updated = lifecycle.close_session(session, session_id=session_id, experience=request.experience)
|
||||
except (ExplorationNotFoundError, ExplorationGuardrailError) as exc:
|
||||
raise _translate(exc) from exc
|
||||
return updated.model_dump(mode="json")
|
||||
|
||||
@ -55,9 +55,9 @@ def seeded_db(db_session, monkeypatch):
|
||||
app.dependency_overrides[get_db] = _test_get_db
|
||||
|
||||
# 隔离后台 judge 复核任务:API 测试不真正派发后台任务
|
||||
from agenteval.web.routers import exploration as exploration_router
|
||||
from agenteval.exploration import lifecycle as exploration_lifecycle
|
||||
|
||||
monkeypatch.setattr(exploration_router, "start_judge_review", lambda session_id: None)
|
||||
monkeypatch.setattr(exploration_lifecycle, "start_judge_review", lambda session_id: None)
|
||||
|
||||
target = EvalTarget(
|
||||
id="t-1",
|
||||
@ -75,15 +75,15 @@ def seeded_db(db_session, monkeypatch):
|
||||
|
||||
|
||||
def _stub_channel_factory(monkeypatch, channel) -> None:
|
||||
"""Point the exploration router's ChannelFactory at a test channel."""
|
||||
from agenteval.web.routers import exploration as exploration_module
|
||||
"""Point the exploration lifecycle's ChannelFactory at a test channel."""
|
||||
from agenteval.exploration import lifecycle as lifecycle_module
|
||||
|
||||
class _StubFactory:
|
||||
@staticmethod
|
||||
def create(target):
|
||||
return channel
|
||||
|
||||
monkeypatch.setattr(exploration_module, "ChannelFactory", _StubFactory)
|
||||
monkeypatch.setattr(lifecycle_module, "ChannelFactory", _StubFactory)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
@ -322,7 +322,7 @@ async def test_poll_timeout_consumes_turn_budget(seeded_db, monkeypatch, client)
|
||||
"""消息已送达但等不到回复:账本仍计一轮(超时不可绕过轮数预算)。"""
|
||||
from types import SimpleNamespace
|
||||
|
||||
from agenteval.web.routers import exploration as exploration_module
|
||||
from agenteval.exploration import lifecycle as exploration_module
|
||||
|
||||
from tests.unit.mock_channel import MockChannel
|
||||
|
||||
@ -368,10 +368,10 @@ async def test_close_twice_rejected(seeded_db, mock_channel, client):
|
||||
|
||||
|
||||
async def test_close_triggers_judge_review(seeded_db, mock_channel, client, monkeypatch):
|
||||
from agenteval.web.routers import exploration as exploration_router
|
||||
from agenteval.exploration import lifecycle as exploration_lifecycle
|
||||
|
||||
started: list[str] = []
|
||||
monkeypatch.setattr(exploration_router, "start_judge_review", started.append)
|
||||
monkeypatch.setattr(exploration_lifecycle, "start_judge_review", started.append)
|
||||
|
||||
session_id = (await _create_session(client)).json()["id"]
|
||||
resp = await client.post(
|
||||
|
||||
@ -48,10 +48,10 @@ def seeded_db(db_session, monkeypatch):
|
||||
target + scenario needed by both the loop path and the cancel path."""
|
||||
from agenteval.channels import factory as factory_module
|
||||
from agenteval.evaluation import engine as engine_module
|
||||
from agenteval.exploration import lifecycle as exploration_lifecycle
|
||||
from agenteval.storage import db as db_module
|
||||
from agenteval.storage import repository as repo_module
|
||||
from agenteval.web import app as app_module
|
||||
from agenteval.web.routers import exploration as exploration_router
|
||||
|
||||
monkeypatch.setattr(app_module, "init_db", lambda: None)
|
||||
|
||||
@ -68,7 +68,7 @@ def seeded_db(db_session, monkeypatch):
|
||||
|
||||
channel = MockChannel(reply_delay=0.0)
|
||||
monkeypatch.setattr(factory_module.ChannelFactory, "create", lambda target: channel)
|
||||
monkeypatch.setattr(exploration_router, "start_judge_review", lambda session_id: None)
|
||||
monkeypatch.setattr(exploration_lifecycle, "start_judge_review", lambda session_id: None)
|
||||
|
||||
from agenteval.web.deps import get_db
|
||||
|
||||
|
||||
218
tests/unit/test_exploration_lifecycle.py
Normal file
218
tests/unit/test_exploration_lifecycle.py
Normal file
@ -0,0 +1,218 @@
|
||||
"""探索生命周期领域模块直测(架构保养候选 3)。
|
||||
|
||||
账本规则与状态机从 HTTP 层落入 exploration/lifecycle.py 与 patrol.py,
|
||||
用类型化领域异常(NotFound / Guardrail / Channel)表达违规,
|
||||
脱离 TestClient 即可单测。
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from agenteval.exploration import lifecycle, patrol
|
||||
from agenteval.exploration.errors import ExplorationGuardrailError, ExplorationNotFoundError
|
||||
from agenteval.exploration.models import ExplorationSessionStatus, ExplorationTrigger
|
||||
from agenteval.models import (
|
||||
Campaign,
|
||||
CampaignPlanEntry,
|
||||
CampaignStatus,
|
||||
EvalRun,
|
||||
EvalTarget,
|
||||
ExplorationBudgetConfig,
|
||||
ExplorationSeeds,
|
||||
RunStatus,
|
||||
)
|
||||
from agenteval.storage.repository import (
|
||||
CampaignRepository,
|
||||
ExplorationSessionRepository,
|
||||
RunRepository,
|
||||
TargetRepository,
|
||||
)
|
||||
from sqlmodel import Session, SQLModel, create_engine
|
||||
|
||||
T0 = datetime(2026, 1, 1, 0, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def db_session(tmp_path):
|
||||
from agenteval.storage.db import ( # noqa: F401
|
||||
CampaignDB,
|
||||
EvalResultDB,
|
||||
EvalRunDB,
|
||||
EvalTargetDB,
|
||||
ExplorationMessageDB,
|
||||
ExplorationSessionDB,
|
||||
FileCategoryDB,
|
||||
FileRecordDB,
|
||||
ScenarioDB,
|
||||
TurnDB,
|
||||
)
|
||||
engine = create_engine(
|
||||
f"sqlite:///{tmp_path / 'lifecycle.db'}",
|
||||
connect_args={"check_same_thread": False},
|
||||
)
|
||||
SQLModel.metadata.create_all(engine)
|
||||
session = Session(engine)
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
session.close()
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def _seed_campaign(
|
||||
db_session,
|
||||
*,
|
||||
status=CampaignStatus.RUNNING,
|
||||
time_scale=1.0,
|
||||
budget=None,
|
||||
seeds=None,
|
||||
) -> Campaign:
|
||||
target = TargetRepository(db_session).create(EvalTarget(id=f"t-{uuid4().hex[:8]}", name="数字员工"))
|
||||
campaign = CampaignRepository(db_session).create(Campaign(
|
||||
name="cycle", target_id=target.id, window_seconds=3600, time_scale=time_scale,
|
||||
plan=[CampaignPlanEntry(scenario_id="s-a", offset_seconds=0, count=1)],
|
||||
status=status, started_at=T0,
|
||||
exploration_budget=budget, exploration_seeds=seeds,
|
||||
))
|
||||
return campaign
|
||||
|
||||
|
||||
def _open(db_session, campaign, triggered_by=ExplorationTrigger.AUTO, goal="查询账单"):
|
||||
return lifecycle.open_session(
|
||||
db_session,
|
||||
campaign_id=campaign.id,
|
||||
persona={"name": "急性子用户"},
|
||||
goal=goal,
|
||||
triggered_by=triggered_by,
|
||||
)
|
||||
|
||||
|
||||
# ── 账本四规则 ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_open_rejects_non_running_campaign(db_session):
|
||||
campaign = _seed_campaign(db_session, status=CampaignStatus.PLANNED)
|
||||
with pytest.raises(ExplorationGuardrailError, match="活动不在进行中"):
|
||||
_open(db_session, campaign)
|
||||
|
||||
|
||||
def test_open_rejects_auto_on_accelerated_line_but_allows_manual(db_session):
|
||||
campaign = _seed_campaign(db_session, time_scale=10.0)
|
||||
with pytest.raises(ExplorationGuardrailError, match="加速调试线仅允许手动"):
|
||||
_open(db_session, campaign)
|
||||
session_obj = _open(db_session, campaign, triggered_by=ExplorationTrigger.MANUAL)
|
||||
assert session_obj.status == ExplorationSessionStatus.RUNNING
|
||||
|
||||
|
||||
def test_open_enforces_max_sessions_budget(db_session):
|
||||
campaign = _seed_campaign(db_session, budget=ExplorationBudgetConfig(max_sessions=1))
|
||||
_open(db_session, campaign, triggered_by=ExplorationTrigger.MANUAL)
|
||||
with pytest.raises(ExplorationGuardrailError, match="探索会话数超出预算"):
|
||||
_open(db_session, campaign, triggered_by=ExplorationTrigger.MANUAL)
|
||||
|
||||
|
||||
def test_open_enforces_min_interval(db_session):
|
||||
campaign = _seed_campaign(db_session) # 默认间隔 30 分钟
|
||||
_open(db_session, campaign)
|
||||
with pytest.raises(ExplorationGuardrailError, match="相邻探索会话间隔不足"):
|
||||
_open(db_session, campaign)
|
||||
|
||||
|
||||
def test_open_missing_campaign_or_target_raises_not_found(db_session):
|
||||
with pytest.raises(ExplorationNotFoundError):
|
||||
lifecycle.open_session(
|
||||
db_session, campaign_id="missing", persona={}, goal="x",
|
||||
)
|
||||
campaign = _seed_campaign(db_session)
|
||||
campaign.target_id = "missing-target"
|
||||
CampaignRepository(db_session).update(campaign)
|
||||
with pytest.raises(ExplorationNotFoundError):
|
||||
lifecycle.open_session(
|
||||
db_session, campaign_id=campaign.id, persona={}, goal="x",
|
||||
)
|
||||
|
||||
|
||||
# ── 关闭状态机 ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_close_completes_session_and_normalizes_experience(db_session, monkeypatch):
|
||||
judged: list[str] = []
|
||||
monkeypatch.setattr(lifecycle, "start_judge_review", lambda sid: judged.append(sid))
|
||||
campaign = _seed_campaign(db_session)
|
||||
session_obj = _open(db_session, campaign)
|
||||
|
||||
updated = lifecycle.close_session(
|
||||
db_session, session_id=session_obj.id,
|
||||
experience={"goal_achieved": True, "blockers": ["入口难找"], "emotion": "weird"},
|
||||
)
|
||||
assert updated.status == ExplorationSessionStatus.COMPLETED
|
||||
assert updated.closed_at is not None
|
||||
assert updated.experience["emotion"] == "neutral" # 非法情绪归一
|
||||
assert updated.experience["blockers"] == ["入口难找"]
|
||||
assert judged == [session_obj.id]
|
||||
|
||||
|
||||
def test_close_rejects_non_running_session(db_session, monkeypatch):
|
||||
monkeypatch.setattr(lifecycle, "start_judge_review", lambda sid: None)
|
||||
campaign = _seed_campaign(db_session)
|
||||
session_obj = _open(db_session, campaign)
|
||||
lifecycle.close_session(db_session, session_id=session_obj.id, experience={"goal_achieved": False})
|
||||
with pytest.raises(ExplorationGuardrailError, match="不在进行中"):
|
||||
lifecycle.close_session(db_session, session_id=session_obj.id, experience={"goal_achieved": False})
|
||||
|
||||
|
||||
def test_close_missing_session_raises_not_found(db_session):
|
||||
with pytest.raises(ExplorationNotFoundError):
|
||||
lifecycle.close_session(db_session, session_id="missing", experience={})
|
||||
|
||||
|
||||
# ── 对话轮账本(拒收路径;通道往返由集成测试覆盖)────────────────────────
|
||||
|
||||
|
||||
async def test_turn_rejects_non_running_session(db_session):
|
||||
campaign = _seed_campaign(db_session)
|
||||
session_obj = _open(db_session, campaign)
|
||||
lifecycle.close_session(db_session, session_id=session_obj.id, experience={"goal_achieved": True})
|
||||
with pytest.raises(ExplorationGuardrailError, match="拒收消息"):
|
||||
await lifecycle.conduct_turn(db_session, session_id=session_obj.id, content="你好")
|
||||
|
||||
|
||||
async def test_turn_enforces_turn_budget(db_session):
|
||||
campaign = _seed_campaign(db_session, budget=ExplorationBudgetConfig(max_turns=1, min_interval_seconds=1))
|
||||
session_obj = _open(db_session, campaign)
|
||||
repo = ExplorationSessionRepository(db_session)
|
||||
session_obj.turn_count = 1
|
||||
repo.update(session_obj)
|
||||
with pytest.raises(ExplorationGuardrailError, match="会话轮数超出预算"):
|
||||
await lifecycle.conduct_turn(db_session, session_id=session_obj.id, content="你好")
|
||||
|
||||
|
||||
async def test_turn_missing_session_raises_not_found(db_session):
|
||||
with pytest.raises(ExplorationNotFoundError):
|
||||
await lifecycle.conduct_turn(db_session, session_id="missing", content="你好")
|
||||
|
||||
|
||||
# ── 巡检:筛选 + 增量 + 水位 ──────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_patrol_filters_and_advances_watermark(db_session):
|
||||
eligible = _seed_campaign(db_session, seeds=ExplorationSeeds(personas=["急性子用户"], goals=["查账单"]))
|
||||
# 不符合巡检条件:加速线 / 无种子集
|
||||
_seed_campaign(db_session, time_scale=10.0, seeds=ExplorationSeeds(personas=["慢用户"], goals=["y"]))
|
||||
RunRepository(db_session).create(EvalRun(
|
||||
target_id="t-1", scenario_id="s-a", campaign_id=eligible.id,
|
||||
status=RunStatus.COMPLETED, started_at=T0, completed_at=T0 + timedelta(seconds=10),
|
||||
summary={"total_cases": 1, "passed_cases": 1, "pass_rate": 1.0, "avg_latency_ms": 100},
|
||||
))
|
||||
|
||||
first = patrol.patrol_report(db_session)
|
||||
assert [e["campaign_id"] for e in first["campaigns"]] == [eligible.id]
|
||||
assert first["campaigns"][0]["new_results"] is not None
|
||||
|
||||
# 水位推进后无增量;且只动水位列(状态不被覆写)
|
||||
second = patrol.patrol_report(db_session)
|
||||
assert second["campaigns"][0]["new_results"] is None
|
||||
fresh = CampaignRepository(db_session).get(eligible.id)
|
||||
assert fresh.last_patrolled_at is not None
|
||||
assert fresh.status == CampaignStatus.RUNNING
|
||||
Loading…
Reference in New Issue
Block a user