AgentEvalTool/backend/agenteval/intelligent_eval/read_model.py
sinohqb 71543f042a refactor(intelligent-eval): 可见性接缝收敛(Phase 1)
将「已删即 404」语义收进 IntelligentEvalRepository 单一接缝,消除三处独立裁决;
任务监控开始隐藏已删评估的任务(本 Phase 唯一刻意行为变化)。

- repository.py 新增 visible() 谓词与 require_live_eval() 服务接缝;
  六处裸谓词统一走它,get()/get_including_deleted() 语义不变。
- decision_logs.py 删除本地 _require_eval,三处调用迁至 repository 接缝;
  count_decisions 由 len(.all()) 改为 func.count。
- task_queue.py list_tasks 与 stats 过滤已删评估的任务(行为变化)。
- web/routers/intelligent_evals.py: _require_eval_exists → _require_live_eval,
  把 LookupError 翻译为 404;expired 会话 Markdown 标注下沉至
  read_model.report_markdown_by_eval;配置快照 11 字段序列化收至
  config_snapshot.snapshot_to_dict 单一出口。
- AGENTS.md 登记可见性纪律(已知陷阱 #6)。
- 补 characterization 测试锁定四处契约;更新 task_queue 测试以使用
  真实 eval_id(可见性过滤后字面 eval_id 不再可见)。
2026-08-24 05:47:00 +08:00

365 lines
13 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.

"""Read projections for intelligent evaluations.
The lifecycle module owns writes and state transitions. This module is the
read seam used by HTTP adapters: it turns domain entities into stable,
purpose-specific projections and keeps session-count/detail assembly out of
routers. Message bodies are intentionally not part of the detail projection;
transcripts remain available through the dedicated messages endpoint.
"""
from collections.abc import Sequence
from datetime import datetime, timedelta
from typing import Any
from pydantic import BaseModel, Field
from sqlmodel import Session
from agenteval.intelligent_eval.domain import parse_time_slot
from agenteval.intelligent_eval.models import (
IntelligentEval,
IntelligentEvalMessage,
IntelligentEvalSession,
IntelligentEvalSessionStatus,
IntelligentEvalStatus,
)
from agenteval.intelligent_eval.report import render_report_markdown
from agenteval.intelligent_eval.repository import (
IntelligentEvalMessageRepository,
IntelligentEvalRepository,
IntelligentEvalSessionRepository,
)
from agenteval.storage.db import as_utc, utc_now
class IntelligentEvalProjection(BaseModel):
"""Fields shared by list and detail representations."""
id: str
name: str
target_id: str
status: IntelligentEvalStatus
goal: str
seeds: dict[str, Any] = Field(default_factory=dict)
intent: str
role_description: str
plan: dict[str, Any] | None = None
plan_feedback: str | None = None
time_window_hours: int
report: dict[str, Any] | None = None
created_at: datetime | None = None
updated_at: datetime | None = None
started_at: datetime | None = None
completed_at: datetime | None = None
class IntelligentEvalListItem(IntelligentEvalProjection):
"""Compact projection used by collection and mutation responses."""
session_count: int = 0
completed_sessions: int = 0
class IntelligentEvalSessionSummary(BaseModel):
"""Session metadata shown in an evaluation detail view.
This deliberately contains no chat messages. Callers that need a
transcript must use ``GET /{eval_id}/sessions/{session_id}/messages``.
"""
id: str
eval_id: str
target_id: str
persona: dict[str, Any] = Field(default_factory=dict)
goal: str
dimension: str | None = None
status: IntelligentEvalSessionStatus
verdict: dict[str, Any] | None = None
turn_count: int = 0
created_at: datetime | None = None
closed_at: datetime | None = None
class IntelligentEvalDetail(IntelligentEvalListItem):
"""Full evaluation projection, including session metadata only."""
sessions: list[IntelligentEvalSessionSummary] = Field(default_factory=list)
def _require_id(eval_obj: IntelligentEval) -> str:
if eval_obj.id is None:
raise ValueError("intelligent evaluation projection requires an id")
return eval_obj.id
def _base_fields(eval_obj: IntelligentEval) -> dict[str, Any]:
"""Copy domain data into the stable projection field set."""
return {
"id": _require_id(eval_obj),
"name": eval_obj.name,
"target_id": eval_obj.target_id,
"status": eval_obj.status,
"goal": eval_obj.goal,
"seeds": eval_obj.seeds,
"intent": eval_obj.intent,
"role_description": eval_obj.role_description,
"plan": eval_obj.plan,
"plan_feedback": eval_obj.plan_feedback,
"time_window_hours": eval_obj.time_window_hours,
"report": eval_obj.report,
"created_at": eval_obj.created_at,
"updated_at": eval_obj.updated_at,
"started_at": eval_obj.started_at,
"completed_at": eval_obj.completed_at,
}
def _session_summary(session_obj: IntelligentEvalSession) -> IntelligentEvalSessionSummary:
if session_obj.id is None:
raise ValueError("intelligent evaluation session projection requires an id")
return IntelligentEvalSessionSummary.model_validate(session_obj.model_dump())
def project_list_item(
eval_obj: IntelligentEval,
sessions: Sequence[IntelligentEvalSession],
) -> IntelligentEvalListItem:
"""Build a list projection from an evaluation and its session metadata."""
return IntelligentEvalListItem(
**_base_fields(eval_obj),
session_count=len(sessions),
completed_sessions=sum(1 for item in sessions if item.status is IntelligentEvalSessionStatus.COMPLETED),
)
def project_detail(
eval_obj: IntelligentEval,
sessions: Sequence[IntelligentEvalSession],
) -> IntelligentEvalDetail:
"""Build a detail projection without embedding transcript messages."""
item = project_list_item(eval_obj, sessions)
return IntelligentEvalDetail(
**item.model_dump(),
sessions=[_session_summary(item) for item in sessions],
)
# ---------------------------------------------------------------------------
# Execution progress (执行过程视图)
# ---------------------------------------------------------------------------
class ExecutionSlotProgress(BaseModel):
"""Plan-vs-actual progress for one time-distribution slot."""
time_slot: str
planned: int = 0
created: int = 0
completed: int = 0
is_current: bool = False
is_past: bool = False
class ExecutionProgress(BaseModel):
"""Server-authoritative "where is it stuck and why" projection.
current_stage uses the four-step axis: planning / approval / executing /
done. cancelled/failed never appear as a stage; they surface as
abnormal_outcome with the stage marking the node where the eval stopped.
"""
current_stage: str
abnormal_outcome: str | None = None
blocker: str | None = None
next_action: str | None = None
slots: list[ExecutionSlotProgress] = Field(default_factory=list)
def _naive(moment: datetime) -> datetime:
return as_utc(moment).replace(tzinfo=None)
def _derive_stage(eval_obj: IntelligentEval) -> tuple[str, str | None]:
"""Map the 7-state machine onto the 4-step axis; abnormal outcomes mark the node."""
status = eval_obj.status
if status in (IntelligentEvalStatus.DRAFT, IntelligentEvalStatus.PLANNING):
return "planning", None
if status is IntelligentEvalStatus.PENDING_APPROVAL:
return "approval", None
if status is IntelligentEvalStatus.EXECUTING:
return "executing", None
if status is IntelligentEvalStatus.COMPLETED:
return "done", None
abnormal = status.value # cancelled / failed
if eval_obj.plan is None:
return "planning", abnormal
if eval_obj.started_at is None:
return "approval", abnormal
return "executing", abnormal
def _project_slots(
eval_obj: IntelligentEval,
sessions: Sequence[IntelligentEvalSession],
now: datetime,
) -> list[ExecutionSlotProgress]:
"""Bucket sessions into plan slots by created_at, mirroring domain.py 口径."""
if not eval_obj.plan or eval_obj.started_at is None:
return []
time_distribution = eval_obj.plan.get("time_distribution", [])
if not isinstance(time_distribution, list):
return []
started = _naive(eval_obj.started_at)
offset_hours = (_naive(now) - started).total_seconds() / 3600
slots: list[ExecutionSlotProgress] = []
for slot in time_distribution:
parsed = parse_time_slot(str(slot.get("time_slot", "")))
if parsed is None:
continue
start_hour, end_hour = parsed
window_start = started + timedelta(hours=start_hour)
window_end = started + timedelta(hours=end_hour)
in_window = [
item
for item in sessions
if item.created_at is not None and window_start <= _naive(item.created_at) < window_end
]
slots.append(
ExecutionSlotProgress(
time_slot=str(slot.get("time_slot", "")),
planned=int(slot.get("sessions", 0) or 0),
created=len(in_window),
completed=sum(1 for item in in_window if item.status is IntelligentEvalSessionStatus.COMPLETED),
is_current=start_hour <= offset_hours < end_hour,
is_past=offset_hours >= end_hour,
)
)
return slots
def project_execution_progress(
eval_obj: IntelligentEval,
sessions: Sequence[IntelligentEvalSession],
now: datetime | None = None,
) -> ExecutionProgress:
"""Derive stage, blocker and next action entirely server-side."""
moment = now or utc_now()
stage, abnormal = _derive_stage(eval_obj)
slots = _project_slots(eval_obj, sessions, moment)
blocker: str | None = None
next_action: str | None = None
if abnormal == "cancelled":
blocker = "评估已取消"
elif abnormal == "failed":
blocker = f"评估已失败:{eval_obj.plan_feedback}" if eval_obj.plan_feedback else "评估已失败"
elif stage == "planning":
next_action = (
"等待 OpenClaw 规划师提交粗计划"
if eval_obj.status is IntelligentEvalStatus.PLANNING
else "提交评估后进入 AI 规划"
)
elif stage == "approval":
blocker = "等待人工审批粗计划"
next_action = "审批通过后开始执行"
elif stage == "executing":
due_planned = sum(item.planned for item in slots if item.is_past or item.is_current)
deficit = max(0, due_planned - sum(item.created for item in slots))
running = sum(1 for item in sessions if item.status is IntelligentEvalSessionStatus.RUNNING)
if deficit > 0:
next_action = f"等待平台触发 worker 补足当前欠账 {deficit} 个会话"
elif running > 0:
next_action = f"等待 {running} 个进行中的会话完成"
elif any(not item.is_past for item in slots):
next_action = "当前时段计划已完成,等待下一时段到期"
else:
next_action = "等待 analyst 汇总评估报告"
return ExecutionProgress(
current_stage=stage,
abnormal_outcome=abnormal,
blocker=blocker,
next_action=next_action,
slots=slots,
)
class IntelligentEvalReadModel:
"""Read interface for stable intelligent-evaluation projections.
The current implementation uses the existing session repository. Query
batching can be added behind this seam without changing Router callers.
"""
def __init__(self, session: Session):
self._evals = IntelligentEvalRepository(session)
self._sessions = IntelligentEvalSessionRepository(session)
self._messages = IntelligentEvalMessageRepository(session)
def list_item(self, eval_obj: IntelligentEval) -> IntelligentEvalListItem:
return project_list_item(eval_obj, self._sessions.list_by_eval(_require_id(eval_obj)))
def list_items(self, evals: Sequence[IntelligentEval]) -> list[IntelligentEvalListItem]:
"""Project a collection after one batched session lookup."""
sessions_by_eval = self._sessions.list_by_evals([_require_id(item) for item in evals])
return [project_list_item(item, sessions_by_eval[item.id]) for item in evals if item.id is not None]
def detail_by_id(self, eval_id: str) -> IntelligentEvalDetail | None:
"""Read one evaluation and its summaries with one snapshot query."""
snapshot = self._evals.get_with_sessions(eval_id)
return project_detail(*snapshot) if snapshot is not None else None
def execution_progress_by_id(self, eval_id: str) -> ExecutionProgress | None:
"""Project the execution-process view (stage / blocker / slots)."""
snapshot = self._evals.get_with_sessions(eval_id)
return project_execution_progress(*snapshot) if snapshot is not None else None
def sessions_by_eval(self, eval_id: str) -> list[IntelligentEvalSession] | None:
"""Return session summaries, or ``None`` when the parent is unknown."""
if self._evals.get(eval_id) is None:
return None
return self._sessions.list_by_eval(eval_id)
def messages_by_session(self, eval_id: str, session_id: str) -> list[IntelligentEvalMessage] | None:
"""Read a transcript only when the session belongs to the evaluation."""
if self._evals.get(eval_id) is None:
return None
session_obj = self._sessions.get(session_id)
if session_obj is None or session_obj.eval_id != eval_id:
return None
return self._messages.list_by_session(session_id)
def report_by_eval(self, eval_id: str) -> tuple[str, dict[str, Any]] | None:
"""Return the report payload with its evaluation name for renderers."""
eval_obj = self._evals.get(eval_id)
if eval_obj is None or eval_obj.report is None:
return None
return eval_obj.name, eval_obj.report
def report_markdown_by_eval(self, eval_id: str) -> str | None:
"""报告的 Markdown 投影(含 expired 会话的不完整证据标注ADR-0011"""
report = self.report_by_eval(eval_id)
if report is None:
return None
name, payload = report
markdown = render_report_markdown(payload, name=name, eval_id=eval_id)
expired = [s for s in self._sessions.list_by_eval(eval_id) if s.status is IntelligentEvalSessionStatus.EXPIRED]
if expired:
lines = ["", "## 不完整证据会话", ""]
for s in expired:
lines.append(f"- 会话 `{s.id}`{s.turn_count}60 分钟无新轮次过期,证据不完整")
markdown = markdown.rstrip() + "\n" + "\n".join(lines) + "\n"
return markdown