架构重构(候选 1-6): - storage/repository.py 按域拆分为包(target/scenario/run/campaign/result) - storage/db.py 按域拆分为包(eval/campaign/file/model_config/intelligent_eval) - intelligent_eval/lifecycle.py 按状态机阶段拆分为包 - services/runs.py 编排逻辑下沉 - Campaigns.tsx 拆分为 campaigns/ 子组件 测试补全(候选 7): 前端(+125 用例,107→232): - utils/ 纯函数:date/campaignTime/ruleLabels/fileTree/fileFormat/colors - stores/tabStore 状态管理 - 核心组件:FormDrawer/PageWrapper/ChatBubble/GeneratedMessages/SectionHeader/StatCard/TurnList - 业务组件:CaseBlock/CaseDetail/RuleOverview/WindowTimeline/RunList/TabBar/CampaignRunTimeline - 文件管理:FileCategoryTree/FileTable - hooks:sessionReducer/useFiles/useRunSession 后端(+38 用例,916→954): - targets API CRUD + 404 路径 - WebSocket 连接管理器 - proxy 头部重写(CSP/X-Frame-Options) - target 仓储 update 方法 - app 健康检查 + SPA 404 - scenarios 模板端点 + 404 - files API 边缘分支(404 场景 + 500 兜底) - files service update_category - 智能评估状态机迁移测试 门禁状态: - 前端:tsc 干净 + 232 passed - 后端:954 passed + ruff 全绿
235 lines
9.3 KiB
Python
235 lines
9.3 KiB
Python
"""Session execution phase: virtual-user sessions, turns, and idle expiry."""
|
||
|
||
from datetime import timedelta
|
||
from typing import Any
|
||
|
||
from sqlmodel import Session, func, select
|
||
|
||
from agenteval.channels.base import ExchangeStatus, SendResult
|
||
from agenteval.channels.factory import ChannelFactory
|
||
from agenteval.config import get_settings
|
||
from agenteval.intelligent_eval.decision_logs import append_decision_log
|
||
from agenteval.intelligent_eval.lifecycle._core import (
|
||
IntelligentEvalChannelError,
|
||
IntelligentEvalNotFoundError,
|
||
IntelligentEvalTransitionError,
|
||
get_or_raise,
|
||
)
|
||
from agenteval.intelligent_eval.models import (
|
||
IntelligentEvalMessage,
|
||
IntelligentEvalSession,
|
||
IntelligentEvalSessionStatus,
|
||
IntelligentEvalStatus,
|
||
)
|
||
from agenteval.intelligent_eval.repository import (
|
||
CompareAndSetStatus,
|
||
IntelligentEvalMessageRepository,
|
||
IntelligentEvalRepository,
|
||
IntelligentEvalSessionRepository,
|
||
)
|
||
from agenteval.storage.db import IntelligentEvalMessageDB, IntelligentEvalSessionDB, utc_now
|
||
from agenteval.storage.repository import TargetRepository
|
||
|
||
|
||
def _get_session_or_raise(repo: IntelligentEvalSessionRepository, session_id: str) -> IntelligentEvalSession:
|
||
obj = repo.get(session_id)
|
||
if obj is None:
|
||
raise IntelligentEvalNotFoundError(f"intelligent eval session {session_id} not found")
|
||
return obj
|
||
|
||
|
||
def _get_owned_session_or_raise(
|
||
repo: IntelligentEvalSessionRepository,
|
||
eval_id: str,
|
||
session_id: str,
|
||
) -> IntelligentEvalSession:
|
||
obj = _get_session_or_raise(repo, session_id)
|
||
if obj.eval_id != eval_id:
|
||
raise IntelligentEvalNotFoundError(f"intelligent eval session {session_id} not found")
|
||
return obj
|
||
|
||
|
||
def open_session(
|
||
session: Session,
|
||
*,
|
||
eval_id: str,
|
||
persona: dict[str, Any],
|
||
goal: str,
|
||
dimension: str | None = None,
|
||
) -> IntelligentEvalSession:
|
||
"""创建虚拟用户会话:仅 executing 状态且未超粗计划预算的评估可创建。"""
|
||
eval_repo = IntelligentEvalRepository(session)
|
||
ev = get_or_raise(eval_repo, eval_id)
|
||
if ev.status != IntelligentEvalStatus.EXECUTING:
|
||
raise IntelligentEvalTransitionError(f"评估不在执行中(当前 {ev.status.value}),无法创建会话")
|
||
# 预算硬闸门(ADR-0011):会话数不得超过粗计划虚拟用户数
|
||
budget = (ev.plan or {}).get("estimated_sessions", 0)
|
||
if isinstance(budget, int) and budget > 0:
|
||
opened = session.exec(
|
||
select(func.count()).select_from(IntelligentEvalSessionDB).where(IntelligentEvalSessionDB.eval_id == ev.id)
|
||
).one()
|
||
if opened >= budget:
|
||
raise IntelligentEvalTransitionError(f"会话数已达粗计划上限({budget} 个虚拟用户),超出预算")
|
||
repo = IntelligentEvalSessionRepository(session)
|
||
status, created = repo._create_if_executing(
|
||
IntelligentEvalSession(
|
||
eval_id=ev.id,
|
||
target_id=ev.target_id,
|
||
persona=persona,
|
||
goal=goal,
|
||
dimension=dimension,
|
||
)
|
||
)
|
||
if status is CompareAndSetStatus.CONFLICT:
|
||
raise IntelligentEvalTransitionError("评估不在执行中,会话创建被拒绝")
|
||
if status is CompareAndSetStatus.NOT_FOUND or created is None:
|
||
raise IntelligentEvalNotFoundError(f"intelligent eval {eval_id} not found")
|
||
return created
|
||
|
||
|
||
async def conduct_turn(session: Session, *, eval_id: str, session_id: str, content: str) -> dict[str, Any]:
|
||
"""一轮完整问答:状态检查 → 通道往返 → 双条消息落库 → 轮次自增。"""
|
||
repo = IntelligentEvalSessionRepository(session)
|
||
obj = _get_owned_session_or_raise(repo, eval_id, session_id)
|
||
if obj.status != IntelligentEvalSessionStatus.RUNNING:
|
||
raise IntelligentEvalTransitionError("会话不在进行中,拒收消息")
|
||
|
||
target = TargetRepository(session).get(obj.target_id)
|
||
if not target:
|
||
raise IntelligentEvalNotFoundError("session target not found")
|
||
|
||
message_repo = IntelligentEvalMessageRepository(session)
|
||
sent_at = utc_now()
|
||
channel = ChannelFactory.create(target)
|
||
|
||
async def record_sent(_send_result: SendResult) -> None:
|
||
message = IntelligentEvalMessage(
|
||
session_id=obj.id,
|
||
role="user",
|
||
content=content,
|
||
created_at=sent_at,
|
||
)
|
||
status = message_repo._create_user_and_increment(message)
|
||
if status is CompareAndSetStatus.NOT_FOUND:
|
||
raise IntelligentEvalNotFoundError(f"intelligent eval session {obj.id} not found")
|
||
if status is CompareAndSetStatus.CONFLICT:
|
||
raise IntelligentEvalTransitionError("会话不在进行中,拒收消息")
|
||
|
||
outcome = await channel.exchange(
|
||
content,
|
||
timeout=get_settings().poll_reply_timeout,
|
||
on_sent=record_sent,
|
||
)
|
||
if outcome.status is ExchangeStatus.SEND_FAILED:
|
||
raise IntelligentEvalChannelError(f"评测对象通道发送失败: {outcome.reason}")
|
||
if outcome.status is ExchangeStatus.POLL_FAILED:
|
||
raise IntelligentEvalChannelError(f"等待评测对象回复失败: {outcome.reason}")
|
||
if outcome.status is ExchangeStatus.REPLY_TIMEOUT:
|
||
raise IntelligentEvalChannelError("等待评测对象回复超时")
|
||
|
||
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._create(
|
||
IntelligentEvalMessage(
|
||
session_id=obj.id,
|
||
role="assistant",
|
||
content=reply_text,
|
||
latency_ms=latency_ms,
|
||
created_at=received_at,
|
||
)
|
||
)
|
||
return {"reply": reply_text, "latency_ms": latency_ms, "turn_count": obj.turn_count + 1}
|
||
|
||
|
||
def close_session(
|
||
session: Session,
|
||
*,
|
||
eval_id: str,
|
||
session_id: str,
|
||
verdict: dict[str, Any],
|
||
) -> IntelligentEvalSession:
|
||
"""关闭会话并记录结论(verdict);仅 running 会话可关闭。"""
|
||
repo = IntelligentEvalSessionRepository(session)
|
||
obj = _get_owned_session_or_raise(repo, eval_id, session_id)
|
||
status, closed = repo._close_if_running(obj.id, verdict)
|
||
if status is CompareAndSetStatus.NOT_FOUND:
|
||
raise IntelligentEvalNotFoundError(f"intelligent eval session {session_id} not found")
|
||
if status is CompareAndSetStatus.CONFLICT:
|
||
raise IntelligentEvalTransitionError("会话不在进行中,无法关闭")
|
||
if closed is None:
|
||
raise RuntimeError(f"session close returned no session: {session_id}")
|
||
return closed
|
||
|
||
|
||
def list_sessions(session: Session, eval_id: str) -> list[IntelligentEvalSession]:
|
||
eval_repo = IntelligentEvalRepository(session)
|
||
get_or_raise(eval_repo, eval_id)
|
||
return IntelligentEvalSessionRepository(session).list_by_eval(eval_id)
|
||
|
||
|
||
def list_messages(session: Session, *, eval_id: str, session_id: str) -> list[IntelligentEvalMessage]:
|
||
_get_owned_session_or_raise(IntelligentEvalSessionRepository(session), eval_id, session_id)
|
||
return IntelligentEvalMessageRepository(session).list_by_session(session_id)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 会话过期 watchdog(ADR-0011):running 会话 60 分钟无新轮次 → expired
|
||
# ---------------------------------------------------------------------------
|
||
|
||
SESSION_IDLE_EXPIRE_MINUTES = 60
|
||
|
||
|
||
def expire_stale_running_sessions(session: Session) -> int:
|
||
"""把「60 分钟无新轮次」的 running 会话置为 expired。
|
||
|
||
worker/agent 中断会让会话永久卡在 running:conduct_turn 拒收非 running
|
||
会话,而 submit_report 又要求全部终态——不过期的话评估永远无法收尾。
|
||
活动判定取会话内最后一条消息的 created_at,无消息则退回会话 created_at。
|
||
expired 是终态(closed_at 一并写入),报告中按不完整证据对待。
|
||
|
||
Returns:
|
||
过期的会话数。
|
||
"""
|
||
now = utc_now()
|
||
# SQLite 读出为 naive datetime,阈值须同为 naive 才能在 Python 侧比较
|
||
threshold = now.replace(tzinfo=None) - timedelta(minutes=SESSION_IDLE_EXPIRE_MINUTES)
|
||
|
||
# 单次查询获取所有 running 会话及其最后消息时间(避免 N+1 查询)
|
||
stmt = (
|
||
select(
|
||
IntelligentEvalSessionDB,
|
||
func.max(IntelligentEvalMessageDB.created_at).label("last_message_at"),
|
||
)
|
||
.outerjoin(
|
||
IntelligentEvalMessageDB,
|
||
IntelligentEvalMessageDB.session_id == IntelligentEvalSessionDB.id,
|
||
)
|
||
.where(IntelligentEvalSessionDB.status == "running")
|
||
.group_by(IntelligentEvalSessionDB.id)
|
||
)
|
||
rows = session.exec(stmt).all()
|
||
|
||
expired = 0
|
||
for session_row, last_message_at in rows:
|
||
last_activity = last_message_at or session_row.created_at
|
||
if last_activity is None or last_activity >= threshold:
|
||
continue
|
||
session_row.status = IntelligentEvalSessionStatus.EXPIRED.value
|
||
session_row.closed_at = now
|
||
expired += 1
|
||
append_decision_log(
|
||
session_row.eval_id,
|
||
"session_expired",
|
||
f"平台兜底:会话 {SESSION_IDLE_EXPIRE_MINUTES} 分钟无新轮次,置为过期(不完整证据)",
|
||
"platform",
|
||
{"platform_supplemented": True, "session_id": session_row.id, "turn_count": session_row.turn_count},
|
||
session,
|
||
)
|
||
|
||
if expired:
|
||
session.commit()
|
||
return expired
|