AgentEvalTool/backend/agenteval/intelligent_eval/lifecycle/session_execution.py
sinohqb 3705945a7d test: 完整测试覆盖补全(+163 用例)
架构重构(候选 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 全绿
2026-08-24 15:56:09 +08:00

235 lines
9.3 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.

"""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)
# ---------------------------------------------------------------------------
# 会话过期 watchdogADR-0011running 会话 60 分钟无新轮次 → expired
# ---------------------------------------------------------------------------
SESSION_IDLE_EXPIRE_MINUTES = 60
def expire_stale_running_sessions(session: Session) -> int:
"""把「60 分钟无新轮次」的 running 会话置为 expired。
worker/agent 中断会让会话永久卡在 runningconduct_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