AgentEvalTool/backend/agenteval/intelligent_eval/domain.py
sinohqb ca208232c7 perf(db): WAL 模式 + 性能索引 + N+1 查询消除
- SQLite 启用 WAL,允许读写并发
- 新增 6 个索引(eval_runs.status/campaign_id、eval_results.run_id、
  turns.run_id、intelligent_evals.status、task_queue.assigned_at)
- 幂等 Alembic 迁移(列/索引存在性检查)
- domain.py 计数改 func.count 聚合,get_attention_reason 单次加载 sessions
- scenario list_all 批量加载 bindings(1+N → 2 查询)
- mark_orphans_failed 批量加载 campaigns(N → 1 IN 查询)
2026-08-24 23:17:51 +08:00

184 lines
6.8 KiB
Python
Raw Permalink 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.

"""Shared domain logic for intelligent-eval scheduling, priority, attention.
Centralises the time-slot / deficit / severity calculations that previously
lived in `task_queue` and `decision` (P1 deepening, S1). Both modules now
delegate here so a single source of truth governs "8-10h"-style semantics.
"""
from datetime import datetime, timedelta
from typing import Optional
from sqlmodel import Session, func, select
from agenteval.storage.db import (
IntelligentEvalDB,
IntelligentEvalSessionDB,
as_utc,
utc_now,
)
# ---------------------------------------------------------------------------
# Time-slot parsing
# ---------------------------------------------------------------------------
def parse_time_slot(time_slot: str) -> Optional[tuple[float, float]]:
"""Parse a slot into (start_hours, end_hours), both in hours.
Supports hour-level ("8-10h" -> (8, 10)) and minute-level ("0-20min" ->
(0, 1/3)) slots. planner 对短窗口(如 1h会用分钟级时段长窗口用
小时级统一换算成小时float供上层判断。Returns None on bad format.
"""
try:
raw = time_slot.strip().lower()
factor = 1.0
if raw.endswith("min"):
raw = raw[:-3]
factor = 1.0 / 60
elif raw.endswith("h"):
raw = raw[:-1]
factor = 1.0
parts = raw.split("-")
if len(parts) != 2:
return None
return (int(parts[0]) * factor, int(parts[1]) * factor)
except (ValueError, AttributeError):
return None
def is_slot_due(slot: dict, current_offset: timedelta) -> bool:
"""True if the slot's start hour has been reached."""
parsed = parse_time_slot(slot.get("time_slot", ""))
if parsed is None:
return False
start_hour, _ = parsed
return current_offset >= timedelta(hours=start_hour)
def get_current_slot(time_distribution: list[dict], current_offset: timedelta) -> Optional[dict]:
"""Return the slot dict whose [start, end) contains the current offset."""
current_hours = current_offset.total_seconds() / 3600
for slot in time_distribution:
parsed = parse_time_slot(slot.get("time_slot", ""))
if parsed is None:
continue
start_hour, end_hour = parsed
if start_hour <= current_hours < end_hour:
return slot
return None
# ---------------------------------------------------------------------------
# Session accounting
# ---------------------------------------------------------------------------
def count_sessions_in_slot(
eval_id: str, slot: dict, eval_started_at: datetime, session: Session
) -> int:
"""Count sessions of this eval whose created_at falls in the slot window."""
parsed = parse_time_slot(slot.get("time_slot", ""))
if parsed is None:
return 0
start_hour, end_hour = parsed
slot_start = (as_utc(eval_started_at) + timedelta(hours=start_hour)).replace(tzinfo=None)
slot_end = (as_utc(eval_started_at) + timedelta(hours=end_hour)).replace(tzinfo=None)
return session.exec(
select(func.count(IntelligentEvalSessionDB.id)).where(
IntelligentEvalSessionDB.eval_id == eval_id,
IntelligentEvalSessionDB.created_at >= slot_start,
IntelligentEvalSessionDB.created_at < slot_end,
)
).one()
def count_total_sessions(eval_id: str, session: Session) -> int:
return session.exec(
select(func.count(IntelligentEvalSessionDB.id)).where(
IntelligentEvalSessionDB.eval_id == eval_id
)
).one()
def calculate_session_deficit(eval_db: IntelligentEvalDB, session: Session) -> int:
"""Total sessions that should exist by now minus what actually exists."""
if not eval_db.plan:
return 0
plan = eval_db.get_plan()
time_distribution = plan.get("time_distribution", [])
current_offset = (
utc_now() - as_utc(eval_db.started_at) if eval_db.started_at else timedelta(0)
)
should_have = sum(
slot.get("sessions", 0) for slot in time_distribution if is_slot_due(slot, current_offset)
)
return max(0, should_have - count_total_sessions(eval_db.id, session))
# ---------------------------------------------------------------------------
# Priority
# ---------------------------------------------------------------------------
def calculate_priority(eval_db: IntelligentEvalDB, session: Session) -> int:
"""Smaller value = higher priority. Base 100 minus slot-due, deficit, wait."""
priority = 100
if eval_db.plan and eval_db.started_at:
plan = eval_db.get_plan()
current_offset = utc_now() - as_utc(eval_db.started_at)
if any(is_slot_due(s, current_offset) for s in plan.get("time_distribution", [])):
priority -= 50
priority -= calculate_session_deficit(eval_db, session) * 10
if eval_db.started_at:
wait_min = (utc_now() - as_utc(eval_db.started_at)).total_seconds() / 60
priority -= min(int(wait_min / 10), 20)
return max(priority, 1)
# ---------------------------------------------------------------------------
# Attention / severity
# ---------------------------------------------------------------------------
def get_attention_reason(eval_db: IntelligentEvalDB, session: Session) -> Optional[str]:
"""Returns 'slot_due' / 'all_sessions_completed' / None."""
if not eval_db.plan or not eval_db.started_at:
return None
plan = eval_db.get_plan()
time_distribution = plan.get("time_distribution", [])
current_offset = utc_now() - as_utc(eval_db.started_at)
# Load sessions once and reuse for both checks
sessions = session.exec(
select(IntelligentEvalSessionDB).where(IntelligentEvalSessionDB.eval_id == eval_db.id)
).all()
# Check slot due with deficit
if any(is_slot_due(s, current_offset) for s in time_distribution):
should_have = sum(
slot.get("sessions", 0) for slot in time_distribution if is_slot_due(slot, current_offset)
)
actual = len(sessions)
if should_have > actual:
return "slot_due"
# ADR-0011expired/failed 同为会话终态——存在过期会话时也必须触发 analyst
# 否则评估永远等不到"全部完成"而卡在 executing
if sessions and all(s.status in ("completed", "failed", "expired") for s in sessions):
if len(sessions) >= plan.get("estimated_sessions", 0):
return "all_sessions_completed"
return None
def has_high_severity_issues(eval_id: str, session: Session) -> bool:
completed = session.exec(
select(IntelligentEvalSessionDB).where(
IntelligentEvalSessionDB.eval_id == eval_id,
IntelligentEvalSessionDB.status == "completed",
)
).all()
for s in completed:
verdict = s.get_verdict()
if verdict and verdict.get("severity") == "high":
return True
return False