v0.3-s1: 规则层异步化 + 工具函数去重 + HTTP 通道
## 核心变更
### 规则层全面异步化(DEBT-1)
- EvalRule.evaluate() 签名改为 async def,全量同步改造(无兼容层)
- LlmScoreRule._call_llm: requests.post → httpx.AsyncClient,彻底消除事件循环阻塞
- engine._save_rule_results: rule.evaluate() → await rule.evaluate()
### 工具函数去重(DEBT-2)
- 新建 agenteval/utils/llm.py,统一三个函数:
- extract_reply_text (原 5 处重复)
- extract_content_from_llm_response (原 2 处重复)
- parse_json_from_llm_text (统一 LLM 输出 JSON 解析)
- engine.py / llm_score.py / runs.py / report.py 全部切换到 utils.llm
### HTTP 通用通道(S1-3)
- 新建 channels/http.py (HttpChannel)
- 配置化 send_url / reply_url 模板 ({message}, {msg_id} 占位)
- dot-path 提取 msg_id 和 reply_text
- 可选 reply_ready_path 就绪标志
- 长连接 AsyncClient 复用
- ChannelFactory 注册 ChannelType.HTTP → HttpChannel
### 测试
- 新增 tests/unit/test_http_channel_and_rules.py (19 个测试)
- _get_path / health_check / send / poll_reply / 超时 / 就绪标志 / async 规则评估
- 测试总数:24 → 43,全部通过
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
a77cd83e6a
commit
12481cd1b8
@ -1,6 +1,7 @@
|
|||||||
"""Factory for creating message channels from target configuration."""
|
"""Factory for creating message channels from target configuration."""
|
||||||
|
|
||||||
from agenteval.channels.base import EvalChannel
|
from agenteval.channels.base import EvalChannel
|
||||||
|
from agenteval.channels.http import HttpChannel
|
||||||
from agenteval.channels.tutu import TutuApiChannel
|
from agenteval.channels.tutu import TutuApiChannel
|
||||||
from agenteval.models import ChannelType, EvalTarget
|
from agenteval.models import ChannelType, EvalTarget
|
||||||
|
|
||||||
@ -10,6 +11,7 @@ class ChannelFactory:
|
|||||||
|
|
||||||
_mapping: dict[ChannelType, type[EvalChannel]] = {
|
_mapping: dict[ChannelType, type[EvalChannel]] = {
|
||||||
ChannelType.TUTU_API: TutuApiChannel,
|
ChannelType.TUTU_API: TutuApiChannel,
|
||||||
|
ChannelType.HTTP: HttpChannel,
|
||||||
}
|
}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|||||||
132
backend/agenteval/channels/http.py
Normal file
132
backend/agenteval/channels/http.py
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
"""Generic HTTP evaluation channel.
|
||||||
|
|
||||||
|
Supports any REST API that follows a configurable request/response template.
|
||||||
|
Configuration keys (all under channel_config):
|
||||||
|
|
||||||
|
Required:
|
||||||
|
send_url URL to POST the message to (supports {message} template var)
|
||||||
|
reply_url URL to GET the reply from (supports {msg_id} template var)
|
||||||
|
|
||||||
|
Optional:
|
||||||
|
health_url URL for health check GET (defaults to send_url)
|
||||||
|
headers dict of extra request headers (e.g. Authorization)
|
||||||
|
send_body JSON body template for POST; {message} is substituted
|
||||||
|
Default: {"text": "{message}"}
|
||||||
|
msg_id_path dot-separated path to extract msg_id from send response
|
||||||
|
Default: "id"
|
||||||
|
reply_path dot-separated path to extract reply text from reply response
|
||||||
|
Default: "text"
|
||||||
|
reply_ready_path dot-separated path to a boolean indicating reply is ready
|
||||||
|
Default: (treat any 200 as ready)
|
||||||
|
poll_interval seconds between polls (default 1.0)
|
||||||
|
timeout seconds before poll gives up (default 30.0)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from agenteval.channels.base import ChannelHealth, EvalChannel, Reply, SendResult
|
||||||
|
|
||||||
|
|
||||||
|
def _get_path(data: Any, path: str) -> Any:
|
||||||
|
"""Traverse a dot-separated key path into a nested dict/list."""
|
||||||
|
parts = path.split(".")
|
||||||
|
current = data
|
||||||
|
for part in parts:
|
||||||
|
if current is None:
|
||||||
|
return None
|
||||||
|
if isinstance(current, dict):
|
||||||
|
current = current.get(part)
|
||||||
|
elif isinstance(current, list) and part.isdigit():
|
||||||
|
idx = int(part)
|
||||||
|
current = current[idx] if idx < len(current) else None
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
return current
|
||||||
|
|
||||||
|
|
||||||
|
class HttpChannel(EvalChannel):
|
||||||
|
"""Generic HTTP channel: POST to send, GET/POST to poll for reply."""
|
||||||
|
|
||||||
|
def __init__(self, config: dict[str, Any]):
|
||||||
|
self.send_url: str = config["send_url"]
|
||||||
|
self.reply_url: str = config.get("reply_url", "")
|
||||||
|
self.health_url: str = config.get("health_url", self.send_url)
|
||||||
|
self.headers: dict[str, str] = config.get("headers", {})
|
||||||
|
self.send_body_template: str = config.get("send_body", '{"text": "{message}"}')
|
||||||
|
self.msg_id_path: str = config.get("msg_id_path", "id")
|
||||||
|
self.reply_path: str = config.get("reply_path", "text")
|
||||||
|
self.reply_ready_path: Optional[str] = config.get("reply_ready_path")
|
||||||
|
self._poll_interval: float = float(config.get("poll_interval", 1.0))
|
||||||
|
|
||||||
|
self._client = httpx.AsyncClient(headers=self.headers, timeout=30)
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
await self._client.aclose()
|
||||||
|
|
||||||
|
async def health_check(self) -> ChannelHealth:
|
||||||
|
try:
|
||||||
|
resp = await self._client.get(self.health_url)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return ChannelHealth(ok=True, message=f"HTTP {resp.status_code}")
|
||||||
|
except Exception as exc:
|
||||||
|
return ChannelHealth(ok=False, message=str(exc))
|
||||||
|
|
||||||
|
async def send(self, content: str, **kwargs: Any) -> SendResult:
|
||||||
|
body_str = self.send_body_template.replace("{message}", content)
|
||||||
|
try:
|
||||||
|
body = json.loads(body_str)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
body = {"text": content}
|
||||||
|
|
||||||
|
url = self.send_url.replace("{message}", content)
|
||||||
|
try:
|
||||||
|
resp = await self._client.post(url, json=body)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
msg_id = str(_get_path(data, self.msg_id_path) or uuid.uuid4())
|
||||||
|
return SendResult(ok=True, question_msg_id=msg_id, raw_response=data)
|
||||||
|
except Exception as exc:
|
||||||
|
return SendResult(ok=False, error=str(exc))
|
||||||
|
|
||||||
|
async def poll_reply(
|
||||||
|
self,
|
||||||
|
question_msg_id: str,
|
||||||
|
timeout: float = 30.0,
|
||||||
|
poll_interval: float = 1.0,
|
||||||
|
) -> Optional[Reply]:
|
||||||
|
interval = poll_interval or self._poll_interval
|
||||||
|
deadline = asyncio.get_event_loop().time() + timeout
|
||||||
|
url = self.reply_url.replace("{msg_id}", question_msg_id)
|
||||||
|
|
||||||
|
while asyncio.get_event_loop().time() < deadline:
|
||||||
|
try:
|
||||||
|
resp = await self._client.get(url)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
|
||||||
|
# Check if reply is ready (optional readiness flag)
|
||||||
|
if self.reply_ready_path:
|
||||||
|
ready = _get_path(data, self.reply_ready_path)
|
||||||
|
if not ready:
|
||||||
|
await asyncio.sleep(interval)
|
||||||
|
continue
|
||||||
|
|
||||||
|
reply_text = _get_path(data, self.reply_path)
|
||||||
|
if reply_text is not None:
|
||||||
|
raw = {"text": str(reply_text), "_raw": data}
|
||||||
|
return Reply(
|
||||||
|
question_msg_id=question_msg_id,
|
||||||
|
content=str(reply_text),
|
||||||
|
raw_message=raw,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
await asyncio.sleep(interval)
|
||||||
|
|
||||||
|
return None
|
||||||
@ -16,7 +16,6 @@ from typing import Optional
|
|||||||
from pydantic import Field, model_validator
|
from pydantic import Field, model_validator
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
ROOT_DIR = Path(__file__).resolve().parent.parent.parent.parent
|
ROOT_DIR = Path(__file__).resolve().parent.parent.parent.parent
|
||||||
ENV_FILE = ROOT_DIR / ".env"
|
ENV_FILE = ROOT_DIR / ".env"
|
||||||
|
|
||||||
|
|||||||
@ -6,10 +6,9 @@ via an ``asyncio.Event`` cancel token.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
|
||||||
import uuid
|
import uuid
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass
|
||||||
from typing import Any, Awaitable, Callable, Optional
|
from typing import Any, Callable, Optional
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
@ -19,7 +18,7 @@ from agenteval.evaluation.rules import get_rule
|
|||||||
from agenteval.models import Case, CaseType, EvalResult, EvalRun, EvalTarget, RunStatus, Scenario, Turn
|
from agenteval.models import Case, CaseType, EvalResult, EvalRun, EvalTarget, RunStatus, Scenario, Turn
|
||||||
from agenteval.storage.db import get_session, utc_now
|
from agenteval.storage.db import get_session, utc_now
|
||||||
from agenteval.storage.repository import ResultRepository, RunRepository
|
from agenteval.storage.repository import ResultRepository, RunRepository
|
||||||
|
from agenteval.utils.llm import extract_content_from_llm_response, extract_reply_text, parse_json_from_llm_text
|
||||||
|
|
||||||
# Progress callbacks may be sync or async; the engine awaits the result if
|
# Progress callbacks may be sync or async; the engine awaits the result if
|
||||||
# it is a coroutine, otherwise treats it as a plain function.
|
# it is a coroutine, otherwise treats it as a plain function.
|
||||||
@ -45,48 +44,6 @@ def _build_send_message(content: str) -> dict[str, Any]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _extract_content_from_api_response(data: dict) -> str:
|
|
||||||
"""Extract text content from an LLM API response.
|
|
||||||
|
|
||||||
Handles both OpenAI format (choices[0].message.content as string)
|
|
||||||
and content-block-array format used by Anthropic-compatible APIs
|
|
||||||
(choices[0].message.content as list of {type, text/text} blocks).
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
content = data["choices"][0]["message"]["content"]
|
|
||||||
except (KeyError, IndexError, TypeError):
|
|
||||||
return ""
|
|
||||||
if isinstance(content, str):
|
|
||||||
return content
|
|
||||||
if isinstance(content, list):
|
|
||||||
parts = []
|
|
||||||
for block in content:
|
|
||||||
if not isinstance(block, dict):
|
|
||||||
continue
|
|
||||||
# Anthropic format: {"type": "text", "text": "..."}
|
|
||||||
# Some APIs may use "content" key instead of "text"
|
|
||||||
if block.get("type") == "text":
|
|
||||||
parts.append(block.get("text") or block.get("content") or "")
|
|
||||||
else:
|
|
||||||
# Non-text blocks (tool_use, image, etc.) — skip
|
|
||||||
pass
|
|
||||||
return "\n".join(parts)
|
|
||||||
return str(content)
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_reply_text(reply: Any) -> str:
|
|
||||||
if reply is None:
|
|
||||||
return ""
|
|
||||||
if isinstance(reply, str):
|
|
||||||
return reply
|
|
||||||
if isinstance(reply, dict):
|
|
||||||
body = reply.get("msgBody") or reply.get("content", "")
|
|
||||||
if isinstance(body, dict):
|
|
||||||
return body.get("content", "")
|
|
||||||
return str(body)
|
|
||||||
return str(reply)
|
|
||||||
|
|
||||||
|
|
||||||
class EvalEngine:
|
class EvalEngine:
|
||||||
"""Execute evaluation scenarios against targets.
|
"""Execute evaluation scenarios against targets.
|
||||||
|
|
||||||
@ -151,22 +108,37 @@ class EvalEngine:
|
|||||||
|
|
||||||
for idx, case in enumerate(self.scenario.cases, start=1):
|
for idx, case in enumerate(self.scenario.cases, start=1):
|
||||||
self._check_cancel()
|
self._check_cancel()
|
||||||
await self._emit(progress_callback, "case_start", {
|
await self._emit(
|
||||||
"index": idx, "total": total_cases, "case_id": case.id,
|
progress_callback,
|
||||||
})
|
"case_start",
|
||||||
|
{
|
||||||
|
"index": idx,
|
||||||
|
"total": total_cases,
|
||||||
|
"case_id": case.id,
|
||||||
|
},
|
||||||
|
)
|
||||||
async with self._case_semaphore:
|
async with self._case_semaphore:
|
||||||
case_passed, rule_pass, rule_total = await self._run_case(
|
case_passed, rule_pass, rule_total = await self._run_case(
|
||||||
run, case, progress_callback,
|
run,
|
||||||
|
case,
|
||||||
|
progress_callback,
|
||||||
)
|
)
|
||||||
if case_passed:
|
if case_passed:
|
||||||
passed_cases += 1
|
passed_cases += 1
|
||||||
else:
|
else:
|
||||||
failed_cases += 1
|
failed_cases += 1
|
||||||
await self._emit(progress_callback, "case_end", {
|
await self._emit(
|
||||||
"index": idx, "total": total_cases, "case_id": case.id,
|
progress_callback,
|
||||||
"passed": case_passed,
|
"case_end",
|
||||||
"rule_pass_count": rule_pass, "rule_total": rule_total,
|
{
|
||||||
})
|
"index": idx,
|
||||||
|
"total": total_cases,
|
||||||
|
"case_id": case.id,
|
||||||
|
"passed": case_passed,
|
||||||
|
"rule_pass_count": rule_pass,
|
||||||
|
"rule_total": rule_total,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
results = self.run_repo.get_results(run.id)
|
results = self.run_repo.get_results(run.id)
|
||||||
total_rules = len(results)
|
total_rules = len(results)
|
||||||
@ -183,27 +155,42 @@ class EvalEngine:
|
|||||||
run.status = RunStatus.COMPLETED
|
run.status = RunStatus.COMPLETED
|
||||||
run.completed_at = utc_now()
|
run.completed_at = utc_now()
|
||||||
run.summary = summary
|
run.summary = summary
|
||||||
await self._emit(progress_callback, "run_completed", {
|
await self._emit(
|
||||||
"status": "completed", "summary": summary,
|
progress_callback,
|
||||||
})
|
"run_completed",
|
||||||
|
{
|
||||||
|
"status": "completed",
|
||||||
|
"summary": summary,
|
||||||
|
},
|
||||||
|
)
|
||||||
except CancelledError:
|
except CancelledError:
|
||||||
run.status = RunStatus.FAILED
|
run.status = RunStatus.FAILED
|
||||||
run.completed_at = utc_now()
|
run.completed_at = utc_now()
|
||||||
run.summary = {
|
run.summary = {
|
||||||
"error": {"code": "cancelled_by_user", "message": "评测已手动停止"},
|
"error": {"code": "cancelled_by_user", "message": "评测已手动停止"},
|
||||||
}
|
}
|
||||||
await self._emit(progress_callback, "run_completed", {
|
await self._emit(
|
||||||
"status": "failed", "reason": "cancelled",
|
progress_callback,
|
||||||
"error": {"code": "cancelled_by_user", "message": "评测已手动停止"},
|
"run_completed",
|
||||||
})
|
{
|
||||||
|
"status": "failed",
|
||||||
|
"reason": "cancelled",
|
||||||
|
"error": {"code": "cancelled_by_user", "message": "评测已手动停止"},
|
||||||
|
},
|
||||||
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
run.status = RunStatus.FAILED
|
run.status = RunStatus.FAILED
|
||||||
run.completed_at = utc_now()
|
run.completed_at = utc_now()
|
||||||
run.summary = {"error": str(exc)}
|
run.summary = {"error": str(exc)}
|
||||||
await self._emit(progress_callback, "error", {"error": str(exc)})
|
await self._emit(progress_callback, "error", {"error": str(exc)})
|
||||||
await self._emit(progress_callback, "run_completed", {
|
await self._emit(
|
||||||
"status": "failed", "error": str(exc),
|
progress_callback,
|
||||||
})
|
"run_completed",
|
||||||
|
{
|
||||||
|
"status": "failed",
|
||||||
|
"error": str(exc),
|
||||||
|
},
|
||||||
|
)
|
||||||
raise
|
raise
|
||||||
finally:
|
finally:
|
||||||
run = self.run_repo.update(run) or run
|
run = self.run_repo.update(run) or run
|
||||||
@ -235,9 +222,14 @@ class EvalEngine:
|
|||||||
if case.type == CaseType.DYNAMIC:
|
if case.type == CaseType.DYNAMIC:
|
||||||
generated = await self._generate_messages(case, progress_callback)
|
generated = await self._generate_messages(case, progress_callback)
|
||||||
if not generated:
|
if not generated:
|
||||||
await self._emit(progress_callback, "error", {
|
await self._emit(
|
||||||
"error": "LLM 未能生成测试消息", "case_id": case.id,
|
progress_callback,
|
||||||
})
|
"error",
|
||||||
|
{
|
||||||
|
"error": "LLM 未能生成测试消息",
|
||||||
|
"case_id": case.id,
|
||||||
|
},
|
||||||
|
)
|
||||||
return False, 0, 0
|
return False, 0, 0
|
||||||
case = case.model_copy(update={"messages": generated})
|
case = case.model_copy(update={"messages": generated})
|
||||||
|
|
||||||
@ -245,24 +237,39 @@ class EvalEngine:
|
|||||||
|
|
||||||
for round_index, message in enumerate(case.messages, start=1):
|
for round_index, message in enumerate(case.messages, start=1):
|
||||||
self._check_cancel()
|
self._check_cancel()
|
||||||
await self._emit(progress_callback, "turn_start", {
|
await self._emit(
|
||||||
"run_id": run.id, "case_id": case.id,
|
progress_callback,
|
||||||
"round": round_index, "message": message,
|
"turn_start",
|
||||||
})
|
{
|
||||||
|
"run_id": run.id,
|
||||||
|
"case_id": case.id,
|
||||||
|
"round": round_index,
|
||||||
|
"message": message,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
sent_at = utc_now()
|
sent_at = utc_now()
|
||||||
send_result = await self.channel.send(message)
|
send_result = await self.channel.send(message)
|
||||||
if not send_result.ok:
|
if not send_result.ok:
|
||||||
turn = Turn(
|
turn = Turn(
|
||||||
id=str(uuid.uuid4()),
|
id=str(uuid.uuid4()),
|
||||||
run_id=run.id, case_id=case.id, round_index=round_index,
|
run_id=run.id,
|
||||||
sent_message=_build_send_message(message), sent_at=sent_at,
|
case_id=case.id,
|
||||||
|
round_index=round_index,
|
||||||
|
sent_message=_build_send_message(message),
|
||||||
|
sent_at=sent_at,
|
||||||
)
|
)
|
||||||
self.result_repo.save_turn(turn)
|
self.result_repo.save_turn(turn)
|
||||||
await self._save_rule_results(run, case, turn, [], progress_callback)
|
await self._save_rule_results(run, case, turn, [], progress_callback)
|
||||||
await self._emit(progress_callback, "turn_error", {
|
await self._emit(
|
||||||
"case_id": case.id, "round": round_index, "error": send_result.error,
|
progress_callback,
|
||||||
})
|
"turn_error",
|
||||||
|
{
|
||||||
|
"case_id": case.id,
|
||||||
|
"round": round_index,
|
||||||
|
"error": send_result.error,
|
||||||
|
},
|
||||||
|
)
|
||||||
return False, 0, 0
|
return False, 0, 0
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@ -274,16 +281,24 @@ class EvalEngine:
|
|||||||
received_at = utc_now()
|
received_at = utc_now()
|
||||||
turn = Turn(
|
turn = Turn(
|
||||||
id=str(uuid.uuid4()),
|
id=str(uuid.uuid4()),
|
||||||
run_id=run.id, case_id=case.id, round_index=round_index,
|
run_id=run.id,
|
||||||
sent_message=_build_send_message(message), sent_at=sent_at,
|
case_id=case.id,
|
||||||
|
round_index=round_index,
|
||||||
|
sent_message=_build_send_message(message),
|
||||||
|
sent_at=sent_at,
|
||||||
question_msg_id=send_result.question_msg_id,
|
question_msg_id=send_result.question_msg_id,
|
||||||
received_at=received_at,
|
received_at=received_at,
|
||||||
)
|
)
|
||||||
self.result_repo.save_turn(turn)
|
self.result_repo.save_turn(turn)
|
||||||
await self._emit(progress_callback, "turn_error", {
|
await self._emit(
|
||||||
"case_id": case.id, "round": round_index,
|
progress_callback,
|
||||||
"error": f"poll_reply 异常: {poll_exc}",
|
"turn_error",
|
||||||
})
|
{
|
||||||
|
"case_id": case.id,
|
||||||
|
"round": round_index,
|
||||||
|
"error": f"poll_reply 异常: {poll_exc}",
|
||||||
|
},
|
||||||
|
)
|
||||||
return False, 0, 0
|
return False, 0, 0
|
||||||
|
|
||||||
received_at = utc_now()
|
received_at = utc_now()
|
||||||
@ -293,20 +308,30 @@ class EvalEngine:
|
|||||||
|
|
||||||
turn = Turn(
|
turn = Turn(
|
||||||
id=str(uuid.uuid4()),
|
id=str(uuid.uuid4()),
|
||||||
run_id=run.id, case_id=case.id, round_index=round_index,
|
run_id=run.id,
|
||||||
sent_message=_build_send_message(message), sent_at=sent_at,
|
case_id=case.id,
|
||||||
|
round_index=round_index,
|
||||||
|
sent_message=_build_send_message(message),
|
||||||
|
sent_at=sent_at,
|
||||||
question_msg_id=send_result.question_msg_id,
|
question_msg_id=send_result.question_msg_id,
|
||||||
reply=reply.raw_message if reply else None,
|
reply=reply.raw_message if reply else None,
|
||||||
received_at=received_at, latency_ms=latency_ms,
|
received_at=received_at,
|
||||||
|
latency_ms=latency_ms,
|
||||||
)
|
)
|
||||||
self.result_repo.save_turn(turn)
|
self.result_repo.save_turn(turn)
|
||||||
dialog.append(turn)
|
dialog.append(turn)
|
||||||
|
|
||||||
await self._emit(progress_callback, "turn_end", {
|
await self._emit(
|
||||||
"run_id": run.id, "case_id": case.id, "round": round_index,
|
progress_callback,
|
||||||
"latency_ms": latency_ms,
|
"turn_end",
|
||||||
"reply_text": _extract_reply_text(reply.raw_message if reply else None),
|
{
|
||||||
})
|
"run_id": run.id,
|
||||||
|
"case_id": case.id,
|
||||||
|
"round": round_index,
|
||||||
|
"latency_ms": latency_ms,
|
||||||
|
"reply_text": extract_reply_text(reply.raw_message if reply else None),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
if not dialog:
|
if not dialog:
|
||||||
return False, 0, 0
|
return False, 0, 0
|
||||||
@ -329,9 +354,14 @@ class EvalEngine:
|
|||||||
# If no explicit rules, derive implicit rules from expectations.
|
# If no explicit rules, derive implicit rules from expectations.
|
||||||
if not rules_config:
|
if not rules_config:
|
||||||
if case.expectations.response_time_max_ms:
|
if case.expectations.response_time_max_ms:
|
||||||
rules_config.append(EvalRuleConfig(type="response_time", params={
|
rules_config.append(
|
||||||
"max_ms": case.expectations.response_time_max_ms,
|
EvalRuleConfig(
|
||||||
}))
|
type="response_time",
|
||||||
|
params={
|
||||||
|
"max_ms": case.expectations.response_time_max_ms,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
if case.expectations.keywords_include or case.expectations.keywords_exclude:
|
if case.expectations.keywords_include or case.expectations.keywords_exclude:
|
||||||
rules_config.append(
|
rules_config.append(
|
||||||
EvalRuleConfig(
|
EvalRuleConfig(
|
||||||
@ -348,12 +378,16 @@ class EvalEngine:
|
|||||||
total_count = 0
|
total_count = 0
|
||||||
for rule_config in rules_config:
|
for rule_config in rules_config:
|
||||||
rule = get_rule(rule_config.type, rule_config.params)
|
rule = get_rule(rule_config.type, rule_config.params)
|
||||||
result = rule.evaluate(case, dialog)
|
result = await rule.evaluate(case, dialog)
|
||||||
eval_result = EvalResult(
|
eval_result = EvalResult(
|
||||||
id=str(uuid.uuid4()),
|
id=str(uuid.uuid4()),
|
||||||
run_id=run.id, case_id=case.id, turn_id=turn.id or "",
|
run_id=run.id,
|
||||||
|
case_id=case.id,
|
||||||
|
turn_id=turn.id or "",
|
||||||
rule_type=rule_config.type,
|
rule_type=rule_config.type,
|
||||||
passed=result.passed, score=result.score, reason=result.reason,
|
passed=result.passed,
|
||||||
|
score=result.score,
|
||||||
|
reason=result.reason,
|
||||||
)
|
)
|
||||||
self.result_repo.save_result(eval_result)
|
self.result_repo.save_result(eval_result)
|
||||||
total_count += 1
|
total_count += 1
|
||||||
@ -361,23 +395,36 @@ class EvalEngine:
|
|||||||
passed_count += 1
|
passed_count += 1
|
||||||
else:
|
else:
|
||||||
all_passed = False
|
all_passed = False
|
||||||
await self._emit(progress_callback, "rule_result", {
|
await self._emit(
|
||||||
"run_id": run.id, "case_id": case.id,
|
progress_callback,
|
||||||
"rule_type": rule_config.type,
|
"rule_result",
|
||||||
"passed": result.passed, "score": result.score, "reason": result.reason,
|
{
|
||||||
})
|
"run_id": run.id,
|
||||||
|
"case_id": case.id,
|
||||||
|
"rule_type": rule_config.type,
|
||||||
|
"passed": result.passed,
|
||||||
|
"score": result.score,
|
||||||
|
"reason": result.reason,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
return all_passed, passed_count, total_count
|
return all_passed, passed_count, total_count
|
||||||
|
|
||||||
async def _generate_messages(
|
async def _generate_messages(
|
||||||
self, case: Case, progress_callback: Optional[ProgressCallback],
|
self,
|
||||||
|
case: Case,
|
||||||
|
progress_callback: Optional[ProgressCallback],
|
||||||
) -> list[str]:
|
) -> list[str]:
|
||||||
"""Use LLM to generate test messages for dynamic cases."""
|
"""Use LLM to generate test messages for dynamic cases."""
|
||||||
llm_config = self.scenario.llm_config
|
llm_config = self.scenario.llm_config
|
||||||
if not llm_config:
|
if not llm_config:
|
||||||
await self._emit(progress_callback, "error", {
|
await self._emit(
|
||||||
"error": "动态用例需要配置 llm_config",
|
progress_callback,
|
||||||
})
|
"error",
|
||||||
|
{
|
||||||
|
"error": "动态用例需要配置 llm_config",
|
||||||
|
},
|
||||||
|
)
|
||||||
return []
|
return []
|
||||||
|
|
||||||
api_url = llm_config.get("api_url")
|
api_url = llm_config.get("api_url")
|
||||||
@ -415,25 +462,28 @@ class EvalEngine:
|
|||||||
async with httpx.AsyncClient(timeout=self.timeout_config.llm_generate) as client:
|
async with httpx.AsyncClient(timeout=self.timeout_config.llm_generate) as client:
|
||||||
resp = await client.post(api_url, headers=headers, json=payload)
|
resp = await client.post(api_url, headers=headers, json=payload)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
data = resp.json()
|
content = extract_content_from_llm_response(resp.json())
|
||||||
content = _extract_content_from_api_response(data)
|
|
||||||
if not content:
|
if not content:
|
||||||
await self._emit(progress_callback, "error", {
|
await self._emit(
|
||||||
"error": "LLM 返回内容为空或无法解析",
|
progress_callback,
|
||||||
})
|
"error",
|
||||||
|
{
|
||||||
|
"error": "LLM 返回内容为空或无法解析",
|
||||||
|
},
|
||||||
|
)
|
||||||
return []
|
return []
|
||||||
|
|
||||||
try:
|
try:
|
||||||
parsed = json.loads(content)
|
parsed = parse_json_from_llm_text(content)
|
||||||
except json.JSONDecodeError:
|
except (ValueError, Exception) as parse_exc:
|
||||||
start = content.find("[")
|
await self._emit(
|
||||||
end = content.rfind("]")
|
progress_callback,
|
||||||
if start == -1 or end == -1:
|
"error",
|
||||||
await self._emit(progress_callback, "error", {
|
{
|
||||||
"error": f"LLM 返回无法解析为数组: {content[:200]}",
|
"error": f"LLM 返回无法解析为数组: {parse_exc}",
|
||||||
})
|
},
|
||||||
return []
|
)
|
||||||
parsed = json.loads(content[start:end + 1])
|
return []
|
||||||
|
|
||||||
if not isinstance(parsed, list):
|
if not isinstance(parsed, list):
|
||||||
await self._emit(progress_callback, "error", {"error": "LLM 返回的不是数组"})
|
await self._emit(progress_callback, "error", {"error": "LLM 返回的不是数组"})
|
||||||
@ -444,9 +494,14 @@ class EvalEngine:
|
|||||||
await self._emit(progress_callback, "error", {"error": "LLM 返回的消息为空"})
|
await self._emit(progress_callback, "error", {"error": "LLM 返回的消息为空"})
|
||||||
return []
|
return []
|
||||||
|
|
||||||
await self._emit(progress_callback, "messages_generated", {
|
await self._emit(
|
||||||
"case_id": case.id, "messages": messages,
|
progress_callback,
|
||||||
})
|
"messages_generated",
|
||||||
|
{
|
||||||
|
"case_id": case.id,
|
||||||
|
"messages": messages,
|
||||||
|
},
|
||||||
|
)
|
||||||
return messages
|
return messages
|
||||||
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@ -460,7 +515,10 @@ class EvalEngine:
|
|||||||
raise CancelledError("run cancelled")
|
raise CancelledError("run cancelled")
|
||||||
|
|
||||||
async def _emit(
|
async def _emit(
|
||||||
self, callback: Optional[ProgressCallback], event: str, data: dict[str, Any],
|
self,
|
||||||
|
callback: Optional[ProgressCallback],
|
||||||
|
event: str,
|
||||||
|
data: dict[str, Any],
|
||||||
) -> None:
|
) -> None:
|
||||||
if not callback:
|
if not callback:
|
||||||
return
|
return
|
||||||
|
|||||||
@ -9,6 +9,7 @@ from jinja2 import Template
|
|||||||
|
|
||||||
from agenteval.storage.db import DATA_DIR
|
from agenteval.storage.db import DATA_DIR
|
||||||
from agenteval.storage.repository import RunRepository, ScenarioRepository, TargetRepository
|
from agenteval.storage.repository import RunRepository, ScenarioRepository, TargetRepository
|
||||||
|
from agenteval.utils.llm import extract_reply_text
|
||||||
|
|
||||||
HTML_TEMPLATE = """<!DOCTYPE html>
|
HTML_TEMPLATE = """<!DOCTYPE html>
|
||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
@ -88,16 +89,7 @@ HTML_TEMPLATE = """<!DOCTYPE html>
|
|||||||
|
|
||||||
|
|
||||||
def _extract_text(data: Any) -> str:
|
def _extract_text(data: Any) -> str:
|
||||||
if data is None:
|
return extract_reply_text(data)
|
||||||
return ""
|
|
||||||
if isinstance(data, str):
|
|
||||||
return data
|
|
||||||
if isinstance(data, dict):
|
|
||||||
body = data.get("msgBody") or data.get("content", "")
|
|
||||||
if isinstance(body, dict):
|
|
||||||
return body.get("content", "")
|
|
||||||
return str(body)
|
|
||||||
return str(data)
|
|
||||||
|
|
||||||
|
|
||||||
def generate_report(run_id: str, session=None) -> dict[str, Any]:
|
def generate_report(run_id: str, session=None) -> dict[str, Any]:
|
||||||
|
|||||||
@ -17,7 +17,7 @@ class RuleResult:
|
|||||||
|
|
||||||
|
|
||||||
class EvalRule(ABC):
|
class EvalRule(ABC):
|
||||||
"""Abstract evaluation rule."""
|
"""Abstract evaluation rule. All implementations must be async."""
|
||||||
|
|
||||||
name: str = ""
|
name: str = ""
|
||||||
|
|
||||||
@ -25,7 +25,7 @@ class EvalRule(ABC):
|
|||||||
self.params = params
|
self.params = params
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def evaluate(self, case: Case, dialog: list[Turn]) -> RuleResult:
|
async def evaluate(self, case: Case, dialog: list[Turn]) -> RuleResult:
|
||||||
"""Evaluate the dialog against the case expectation."""
|
"""Evaluate the dialog against the case expectation."""
|
||||||
...
|
...
|
||||||
|
|
||||||
|
|||||||
@ -2,21 +2,7 @@
|
|||||||
|
|
||||||
from agenteval.evaluation.rules.base import EvalRule, RuleResult, register_rule
|
from agenteval.evaluation.rules.base import EvalRule, RuleResult, register_rule
|
||||||
from agenteval.models import Case, Turn
|
from agenteval.models import Case, Turn
|
||||||
|
from agenteval.utils.llm import extract_reply_text
|
||||||
|
|
||||||
def _extract_text(reply) -> str:
|
|
||||||
"""Extract plain text from a reply object for matching."""
|
|
||||||
if reply is None:
|
|
||||||
return ""
|
|
||||||
if isinstance(reply, str):
|
|
||||||
return reply
|
|
||||||
if isinstance(reply, dict):
|
|
||||||
# Tutu-api message structure: msgBody.content
|
|
||||||
body = reply.get("msgBody") or reply.get("content", "")
|
|
||||||
if isinstance(body, dict):
|
|
||||||
return body.get("content", "")
|
|
||||||
return str(body)
|
|
||||||
return str(reply)
|
|
||||||
|
|
||||||
|
|
||||||
@register_rule
|
@register_rule
|
||||||
@ -25,13 +11,12 @@ class KeywordMatchRule(EvalRule):
|
|||||||
|
|
||||||
name = "keyword_match"
|
name = "keyword_match"
|
||||||
|
|
||||||
def evaluate(self, case: Case, dialog: list[Turn]) -> RuleResult:
|
async def evaluate(self, case: Case, dialog: list[Turn]) -> RuleResult:
|
||||||
if not dialog:
|
if not dialog:
|
||||||
return RuleResult(passed=False, reason="无回复记录")
|
return RuleResult(passed=False, reason="无回复记录")
|
||||||
|
|
||||||
last_turn = dialog[-1]
|
last_turn = dialog[-1]
|
||||||
reply = last_turn.reply
|
text = extract_reply_text(last_turn.reply).lower()
|
||||||
text = _extract_text(reply).lower()
|
|
||||||
|
|
||||||
params = self.params
|
params = self.params
|
||||||
include = [k.lower() for k in params.get("keywords", [])]
|
include = [k.lower() for k in params.get("keywords", [])]
|
||||||
|
|||||||
@ -1,49 +1,12 @@
|
|||||||
"""LLM-based scoring evaluation rule."""
|
"""LLM-based scoring evaluation rule."""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import requests
|
import httpx
|
||||||
|
|
||||||
from agenteval.evaluation.rules.base import EvalRule, RuleResult, register_rule
|
from agenteval.evaluation.rules.base import EvalRule, RuleResult, register_rule
|
||||||
from agenteval.models import Case, Turn
|
from agenteval.models import Case, Turn
|
||||||
|
from agenteval.utils.llm import extract_content_from_llm_response, extract_reply_text, parse_json_from_llm_text
|
||||||
|
|
||||||
def _extract_text(reply: Any) -> str:
|
|
||||||
if reply is None:
|
|
||||||
return ""
|
|
||||||
if isinstance(reply, str):
|
|
||||||
return reply
|
|
||||||
if isinstance(reply, dict):
|
|
||||||
body = reply.get("msgBody") or reply.get("content", "")
|
|
||||||
if isinstance(body, dict):
|
|
||||||
return body.get("content", "")
|
|
||||||
return str(body)
|
|
||||||
return str(reply)
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_content_from_api_response(data: dict) -> str:
|
|
||||||
"""Extract text content from an LLM API response.
|
|
||||||
|
|
||||||
Handles both OpenAI format (choices[0].message.content as string)
|
|
||||||
and content-block-array format used by Anthropic-compatible APIs
|
|
||||||
(choices[0].message.content as list of {type, text/text} blocks).
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
content = data["choices"][0]["message"]["content"]
|
|
||||||
except (KeyError, IndexError, TypeError):
|
|
||||||
return ""
|
|
||||||
if isinstance(content, str):
|
|
||||||
return content
|
|
||||||
if isinstance(content, list):
|
|
||||||
parts = []
|
|
||||||
for block in content:
|
|
||||||
if not isinstance(block, dict):
|
|
||||||
continue
|
|
||||||
if block.get("type") == "text":
|
|
||||||
parts.append(block.get("text") or block.get("content") or "")
|
|
||||||
return "\n".join(parts)
|
|
||||||
return str(content)
|
|
||||||
|
|
||||||
|
|
||||||
@register_rule
|
@register_rule
|
||||||
@ -52,23 +15,23 @@ class LlmScoreRule(EvalRule):
|
|||||||
|
|
||||||
name = "llm_score"
|
name = "llm_score"
|
||||||
|
|
||||||
def evaluate(self, case: Case, dialog: list[Turn]) -> RuleResult:
|
async def evaluate(self, case: Case, dialog: list[Turn]) -> RuleResult:
|
||||||
if not dialog:
|
if not dialog:
|
||||||
return RuleResult(passed=False, reason="无回复记录")
|
return RuleResult(passed=False, reason="无回复记录")
|
||||||
|
|
||||||
last_turn = dialog[-1]
|
last_turn = dialog[-1]
|
||||||
reply_text = _extract_text(last_turn.reply)
|
reply_text = extract_reply_text(last_turn.reply)
|
||||||
|
|
||||||
question_text = ""
|
question_text = ""
|
||||||
if len(dialog) >= 2:
|
if len(dialog) >= 2:
|
||||||
question_text = _extract_text(dialog[-2].reply) or ""
|
question_text = extract_reply_text(dialog[-2].reply) or ""
|
||||||
if not question_text and last_turn.sent_message:
|
if not question_text and last_turn.sent_message:
|
||||||
body = last_turn.sent_message.get("msgBody", "")
|
body = last_turn.sent_message.get("msgBody", "")
|
||||||
if isinstance(body, dict):
|
if isinstance(body, dict):
|
||||||
question_text = body.get("content", "")
|
question_text = body.get("content", "")
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
parsed = json.loads(body)
|
question_text = json.loads(body).get("content", "")
|
||||||
question_text = parsed.get("content", "")
|
|
||||||
except Exception:
|
except Exception:
|
||||||
question_text = str(body)
|
question_text = str(body)
|
||||||
|
|
||||||
@ -81,7 +44,7 @@ class LlmScoreRule(EvalRule):
|
|||||||
if not api_url:
|
if not api_url:
|
||||||
return RuleResult(passed=False, reason="LLM 评分规则未配置 api_url")
|
return RuleResult(passed=False, reason="LLM 评分规则未配置 api_url")
|
||||||
|
|
||||||
score, reason = self._call_llm(api_url, api_key, model, question_text, reply_text, criteria)
|
score, reason = await self._call_llm(api_url, api_key, model, question_text, reply_text, criteria)
|
||||||
if score is None:
|
if score is None:
|
||||||
return RuleResult(passed=False, reason=f"LLM 评分失败: {reason}")
|
return RuleResult(passed=False, reason=f"LLM 评分失败: {reason}")
|
||||||
|
|
||||||
@ -92,7 +55,7 @@ class LlmScoreRule(EvalRule):
|
|||||||
reason=f"LLM 评分 {score}/10,{'通过' if passed else '未通过'} (阈值 {min_score})",
|
reason=f"LLM 评分 {score}/10,{'通过' if passed else '未通过'} (阈值 {min_score})",
|
||||||
)
|
)
|
||||||
|
|
||||||
def _call_llm(
|
async def _call_llm(
|
||||||
self,
|
self,
|
||||||
api_url: str,
|
api_url: str,
|
||||||
api_key: str | None,
|
api_key: str | None,
|
||||||
@ -105,7 +68,7 @@ class LlmScoreRule(EvalRule):
|
|||||||
system_prompt = (
|
system_prompt = (
|
||||||
"你是一位严格的智能客服质量评估专家。请根据用户问题和智能体回复,"
|
"你是一位严格的智能客服质量评估专家。请根据用户问题和智能体回复,"
|
||||||
f"按照以下标准打分(0-10分,10分最高):{criteria}\n"
|
f"按照以下标准打分(0-10分,10分最高):{criteria}\n"
|
||||||
"只输出一个 JSON 对象:{\"score\": number, \"reason\": \"简短说明\"}"
|
'只输出一个 JSON 对象:{"score": number, "reason": "简短说明"}'
|
||||||
)
|
)
|
||||||
user_prompt = f"用户问题:{question}\n智能体回复:{reply}"
|
user_prompt = f"用户问题:{question}\n智能体回复:{reply}"
|
||||||
|
|
||||||
@ -123,24 +86,14 @@ class LlmScoreRule(EvalRule):
|
|||||||
}
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
resp = requests.post(api_url, headers=headers, json=payload, timeout=60)
|
async with httpx.AsyncClient(timeout=60) as client:
|
||||||
|
resp = await client.post(api_url, headers=headers, json=payload)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
data = resp.json()
|
content = extract_content_from_llm_response(resp.json())
|
||||||
content = _extract_content_from_api_response(data)
|
|
||||||
if not content:
|
if not content:
|
||||||
return None, "LLM 返回内容为空"
|
return None, "LLM 返回内容为空"
|
||||||
|
|
||||||
# Try to parse JSON from the content
|
parsed = parse_json_from_llm_text(content)
|
||||||
try:
|
|
||||||
parsed = json.loads(content)
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
# Fallback: extract JSON substring
|
|
||||||
start = content.find("{")
|
|
||||||
end = content.rfind("}")
|
|
||||||
if start == -1 or end == -1:
|
|
||||||
return None, "LLM 返回格式无法解析"
|
|
||||||
parsed = json.loads(content[start : end + 1])
|
|
||||||
|
|
||||||
score = float(parsed["score"])
|
score = float(parsed["score"])
|
||||||
reason = parsed.get("reason", "")
|
reason = parsed.get("reason", "")
|
||||||
return max(0.0, min(10.0, score)), reason
|
return max(0.0, min(10.0, score)), reason
|
||||||
|
|||||||
@ -10,7 +10,7 @@ class ResponseTimeRule(EvalRule):
|
|||||||
|
|
||||||
name = "response_time"
|
name = "response_time"
|
||||||
|
|
||||||
def evaluate(self, case: Case, dialog: list[Turn]) -> RuleResult:
|
async def evaluate(self, case: Case, dialog: list[Turn]) -> RuleResult:
|
||||||
if not dialog:
|
if not dialog:
|
||||||
return RuleResult(passed=False, reason="无回复记录")
|
return RuleResult(passed=False, reason="无回复记录")
|
||||||
|
|
||||||
|
|||||||
@ -3,7 +3,6 @@
|
|||||||
import json
|
import json
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
import os
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
|||||||
@ -6,9 +6,9 @@ from typing import Optional
|
|||||||
from sqlmodel import Session, select
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
from agenteval.storage.db import (
|
from agenteval.storage.db import (
|
||||||
|
FILES_DIR,
|
||||||
FileCategoryDB,
|
FileCategoryDB,
|
||||||
FileRecordDB,
|
FileRecordDB,
|
||||||
FILES_DIR,
|
|
||||||
get_session,
|
get_session,
|
||||||
new_uuid,
|
new_uuid,
|
||||||
utc_now,
|
utc_now,
|
||||||
@ -183,4 +183,4 @@ class FileRecordRepository:
|
|||||||
return
|
return
|
||||||
file_path = FILES_DIR / record.storage_name
|
file_path = FILES_DIR / record.storage_name
|
||||||
if file_path.exists():
|
if file_path.exists():
|
||||||
os.remove(file_path)
|
os.remove(file_path)
|
||||||
|
|||||||
0
backend/agenteval/utils/__init__.py
Normal file
0
backend/agenteval/utils/__init__.py
Normal file
72
backend/agenteval/utils/llm.py
Normal file
72
backend/agenteval/utils/llm.py
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
"""Shared utilities for LLM API interaction and message extraction.
|
||||||
|
|
||||||
|
Consolidates the duplicated _extract_text / _extract_reply_text pattern
|
||||||
|
(previously repeated in 5 places) and _extract_content_from_api_response
|
||||||
|
(previously duplicated in 2 places).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
def extract_reply_text(reply: Any) -> str:
|
||||||
|
"""Extract plain text from a tutu-api reply object.
|
||||||
|
|
||||||
|
Handles: None | str | dict with msgBody.content or content key.
|
||||||
|
"""
|
||||||
|
if reply is None:
|
||||||
|
return ""
|
||||||
|
if isinstance(reply, str):
|
||||||
|
return reply
|
||||||
|
if isinstance(reply, dict):
|
||||||
|
body = reply.get("msgBody") or reply.get("content", "")
|
||||||
|
if isinstance(body, dict):
|
||||||
|
return body.get("content", "")
|
||||||
|
return str(body)
|
||||||
|
return str(reply)
|
||||||
|
|
||||||
|
|
||||||
|
def extract_content_from_llm_response(data: dict) -> str:
|
||||||
|
"""Extract text content from an LLM API response dict.
|
||||||
|
|
||||||
|
Handles:
|
||||||
|
- OpenAI format: choices[0].message.content as a plain string
|
||||||
|
- Anthropic-compatible format: choices[0].message.content as a list of
|
||||||
|
content blocks {type: "text", text: "..."} (non-text blocks are skipped)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
content = data["choices"][0]["message"]["content"]
|
||||||
|
except (KeyError, IndexError, TypeError):
|
||||||
|
return ""
|
||||||
|
if isinstance(content, str):
|
||||||
|
return content
|
||||||
|
if isinstance(content, list):
|
||||||
|
parts = []
|
||||||
|
for block in content:
|
||||||
|
if not isinstance(block, dict):
|
||||||
|
continue
|
||||||
|
if block.get("type") == "text":
|
||||||
|
parts.append(block.get("text") or block.get("content") or "")
|
||||||
|
return "\n".join(parts)
|
||||||
|
return str(content)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_json_from_llm_text(content: str) -> Any:
|
||||||
|
"""Parse JSON from LLM output, falling back to bracket-delimited substring.
|
||||||
|
|
||||||
|
Returns parsed JSON object, or raises json.JSONDecodeError if unparseable.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
|
||||||
|
try:
|
||||||
|
return json.loads(content)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Fallback: find the outermost JSON object or array
|
||||||
|
for open_char, close_char in [("{", "}"), ("[", "]")]:
|
||||||
|
start = content.find(open_char)
|
||||||
|
end = content.rfind(close_char)
|
||||||
|
if start != -1 and end != -1 and end > start:
|
||||||
|
return json.loads(content[start : end + 1])
|
||||||
|
|
||||||
|
raise ValueError(f"No JSON found in LLM output: {content[:200]}")
|
||||||
@ -70,6 +70,7 @@ _default_dist = Path(__file__).resolve().parent.parent.parent.parent / "frontend
|
|||||||
WEB_DIST = Path(settings.frontend_dist_path) if settings.frontend_dist_path else _default_dist
|
WEB_DIST = Path(settings.frontend_dist_path) if settings.frontend_dist_path else _default_dist
|
||||||
if WEB_DIST.exists():
|
if WEB_DIST.exists():
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
app.mount("/assets", StaticFiles(directory=WEB_DIST / "assets"), name="assets")
|
app.mount("/assets", StaticFiles(directory=WEB_DIST / "assets"), name="assets")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -7,7 +7,6 @@ longer re-declare these helpers themselves.
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from fastapi import Depends, Header, HTTPException, status
|
from fastapi import Depends, Header, HTTPException, status
|
||||||
from sqlmodel import Session
|
|
||||||
|
|
||||||
from agenteval.config import get_settings
|
from agenteval.config import get_settings
|
||||||
from agenteval.storage.db import get_session
|
from agenteval.storage.db import get_session
|
||||||
|
|||||||
@ -10,7 +10,7 @@ from pydantic import BaseModel
|
|||||||
from sqlmodel import Session
|
from sqlmodel import Session
|
||||||
|
|
||||||
from agenteval.config import get_settings
|
from agenteval.config import get_settings
|
||||||
from agenteval.storage.db import FILES_DIR, FileRecordDB
|
from agenteval.storage.db import FILES_DIR
|
||||||
from agenteval.storage.file_repository import FileCategoryRepository, FileRecordRepository
|
from agenteval.storage.file_repository import FileCategoryRepository, FileRecordRepository
|
||||||
from agenteval.web.deps import get_db
|
from agenteval.web.deps import get_db
|
||||||
|
|
||||||
@ -200,4 +200,4 @@ def download_file(file_id: str, session: Session = Depends(get_db)):
|
|||||||
def delete_file(file_id: str, session: Session = Depends(get_db)) -> dict:
|
def delete_file(file_id: str, session: Session = Depends(get_db)) -> dict:
|
||||||
if not FileRecordRepository(session).delete(file_id):
|
if not FileRecordRepository(session).delete(file_id):
|
||||||
raise HTTPException(status_code=404, detail="文件不存在")
|
raise HTTPException(status_code=404, detail="文件不存在")
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|||||||
@ -11,6 +11,7 @@ from agenteval.evaluation.engine import EvalEngine
|
|||||||
from agenteval.models import EvalRun, RunStatus
|
from agenteval.models import EvalRun, RunStatus
|
||||||
from agenteval.storage.db import get_session
|
from agenteval.storage.db import get_session
|
||||||
from agenteval.storage.repository import RunRepository, ScenarioRepository, TargetRepository
|
from agenteval.storage.repository import RunRepository, ScenarioRepository, TargetRepository
|
||||||
|
from agenteval.utils.llm import extract_reply_text
|
||||||
from agenteval.web.deps import get_db
|
from agenteval.web.deps import get_db
|
||||||
from agenteval.web.websocket import ws_manager
|
from agenteval.web.websocket import ws_manager
|
||||||
|
|
||||||
@ -42,7 +43,9 @@ async def _run_evaluation(run_id: str, target_id: str, scenario_id: str) -> None
|
|||||||
return
|
return
|
||||||
|
|
||||||
engine = EvalEngine(
|
engine = EvalEngine(
|
||||||
target=target, scenario=scenario, session=session,
|
target=target,
|
||||||
|
scenario=scenario,
|
||||||
|
session=session,
|
||||||
cancel_token=cancel_token,
|
cancel_token=cancel_token,
|
||||||
)
|
)
|
||||||
await engine.run(
|
await engine.run(
|
||||||
@ -135,7 +138,7 @@ async def get_run_logs(run_id: str, session: Session = Depends(get_db)) -> dict:
|
|||||||
"round_index": t.round_index,
|
"round_index": t.round_index,
|
||||||
"latency_ms": t.latency_ms,
|
"latency_ms": t.latency_ms,
|
||||||
"sent_text": t.get_sent_message().get("msgBody", {}).get("content", ""),
|
"sent_text": t.get_sent_message().get("msgBody", {}).get("content", ""),
|
||||||
"reply_text": _extract_reply_text(t.get_reply()),
|
"reply_text": extract_reply_text(t.get_reply()),
|
||||||
"sent_at": t.sent_at.isoformat() if t.sent_at else None,
|
"sent_at": t.sent_at.isoformat() if t.sent_at else None,
|
||||||
"received_at": t.received_at.isoformat() if t.received_at else None,
|
"received_at": t.received_at.isoformat() if t.received_at else None,
|
||||||
}
|
}
|
||||||
@ -169,22 +172,7 @@ async def get_run_logs(run_id: str, session: Session = Depends(get_db)) -> dict:
|
|||||||
"response_time_max_ms": case.expectations.response_time_max_ms,
|
"response_time_max_ms": case.expectations.response_time_max_ms,
|
||||||
"coherence_min_score": case.expectations.coherence_min_score,
|
"coherence_min_score": case.expectations.coherence_min_score,
|
||||||
},
|
},
|
||||||
"eval_rules": [
|
"eval_rules": [{"type": r.type, "params": dict(r.params)} for r in case.eval_rules],
|
||||||
{"type": r.type, "params": dict(r.params)} for r in case.eval_rules
|
|
||||||
],
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {"turns": turns_data, "results": results_data, "scenario_snapshot": scenario_snapshot}
|
return {"turns": turns_data, "results": results_data, "scenario_snapshot": scenario_snapshot}
|
||||||
|
|
||||||
|
|
||||||
def _extract_reply_text(reply) -> str:
|
|
||||||
if reply is None:
|
|
||||||
return ""
|
|
||||||
if isinstance(reply, str):
|
|
||||||
return reply
|
|
||||||
if isinstance(reply, dict):
|
|
||||||
body = reply.get("msgBody") or reply.get("content", "")
|
|
||||||
if isinstance(body, dict):
|
|
||||||
return body.get("content", "")
|
|
||||||
return str(body)
|
|
||||||
return str(reply)
|
|
||||||
|
|||||||
@ -20,7 +20,9 @@ def _progress(event: str, data: dict[str, Any]) -> None:
|
|||||||
console.print(f" 第 {data['round']} 轮回复,耗时 {data['latency_ms']}ms")
|
console.print(f" 第 {data['round']} 轮回复,耗时 {data['latency_ms']}ms")
|
||||||
elif event == "rule_result":
|
elif event == "rule_result":
|
||||||
style = "green" if data["passed"] else "red"
|
style = "green" if data["passed"] else "red"
|
||||||
console.print(f" [{data['rule_type']}] {'通过' if data['passed'] else '失败'} - {data['reason']}", style=style)
|
console.print(
|
||||||
|
f" [{data['rule_type']}] {'通过' if data['passed'] else '失败'} - {data['reason']}", style=style
|
||||||
|
)
|
||||||
elif event == "error":
|
elif event == "error":
|
||||||
console.print(f"执行异常: {data['error']}", style="red")
|
console.print(f"执行异常: {data['error']}", style="red")
|
||||||
|
|
||||||
|
|||||||
195
docs/plan-v0.3.md
Normal file
195
docs/plan-v0.3.md
Normal file
@ -0,0 +1,195 @@
|
|||||||
|
# AgentEvalTool v0.3 「拓」迭代规划
|
||||||
|
|
||||||
|
**版本**: v0.3.0(规划)
|
||||||
|
**制定日期**: 2026-07-16
|
||||||
|
**基线**: v0.2.0-dev(已部署 t480,含文件管理 + 布局统一 + 6 bug 修复)
|
||||||
|
**代号**: 「拓」(Extensibility milestone)
|
||||||
|
**状态**: 待评审
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、版本定位
|
||||||
|
|
||||||
|
v0.3 是「先稳后拓」战略的第二步。v0.2 已让核心闭环具备生产可用性,v0.3 的使命是:
|
||||||
|
|
||||||
|
> 把「单通道 + 3 规则」的稳定闭环,扩展成「多通道 + 可组合规则」的评估平台,
|
||||||
|
> 并打通 OpenClaw 双向集成,让评估能力可被 AI 助手自主编排。
|
||||||
|
|
||||||
|
```
|
||||||
|
v0.1 (07-09) MVP 闭环
|
||||||
|
v1.1 (07-10) Ant Design 前端重构
|
||||||
|
v0.2 (07-14) 「稳」async + 安全 + 测试 + 部署
|
||||||
|
└─ (07-16) 文件管理 + 布局统一 + bug 修复
|
||||||
|
v0.3 (规划) 「拓」← 本文档
|
||||||
|
└─ 多通道 · 规则扩展 · OpenClaw 深度集成 · 报告升级
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、现状评估
|
||||||
|
|
||||||
|
### 2.1 已具备的能力
|
||||||
|
|
||||||
|
| 层面 | 现状 |
|
||||||
|
|---|---|
|
||||||
|
| 引擎 | async EvalEngine,真取消,可配置超时,并发信号量 |
|
||||||
|
| 通道 | 仅 `tutu-api`(工厂已预留 register 扩展点) |
|
||||||
|
| 规则 | 3 种:keyword_match / response_time / llm_score |
|
||||||
|
| 报告 | JSON + HTML |
|
||||||
|
| 存储 | SQLite + Alembic + 级联删除 + 文件管理 |
|
||||||
|
| 安全 | X-API-Key + .env + CORS 收紧 |
|
||||||
|
| 测试 | 24 个后端测试,57% 覆盖率 |
|
||||||
|
| 部署 | 一键脚本 + 镜像版本 tag + /api/health 校验 |
|
||||||
|
|
||||||
|
### 2.2 技术债务盘点(v0.3 前置或并行处理)
|
||||||
|
|
||||||
|
| 编号 | 债务 | 严重度 | 影响 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **DEBT-1** | **规则评估层同步阻塞**:`EvalRule.evaluate()` 是 `def` 而非 `async def`;`llm_score` 用同步 `requests.post(timeout=60)` | 🔴 架构级 | 阻塞事件循环,与 v0.2 async 化目标矛盾;**是 P0 规则扩展的前置阻塞项** |
|
||||||
|
| DEBT-2 | 后端 `_extract_text`/`_extract_reply_text` 重复 5 处,`_extract_content_from_api_response` 重复 2 处 | 🟡 | 一处修复遗漏另一处即产生 bug |
|
||||||
|
| DEBT-3 | 前端 `PageWrapper` 已成死代码,6 页面重复布局 JSX;`statusColor` 多页重复 | 🟡 | 改布局需改 6 处 |
|
||||||
|
| DEBT-4 | 测试盲区:rules 覆盖率 15-30%,文件管理零测试,`_generate_messages` 无测试 | 🟡 | 规则/文件功能回归无保护 |
|
||||||
|
| DEBT-5 | WebSocket 断线无自动重连;前端 bundle ~2.8MB | 🟢 | 体验问题 |
|
||||||
|
|
||||||
|
> **关键判断**:DEBT-1 必须在 P0 规则扩展之前解决。semantic_similarity(调 embedding API)、safety(调 moderation API)本质都是网络 IO 规则,若继续在同步 `evaluate()` 上叠加,每条规则都会阻塞整个事件循环,v0.2 的异步化收益被规则层抵消。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、v0.3 范围
|
||||||
|
|
||||||
|
### 3.1 做什么(In Scope)
|
||||||
|
|
||||||
|
#### P0 — 核心能力(必须交付)
|
||||||
|
|
||||||
|
**P0-0 规则评估层异步化**(前置技术改造)
|
||||||
|
- `EvalRule.evaluate()` → `async def evaluate()`
|
||||||
|
- `engine._save_rule_results` 相应 `await rule.evaluate(...)`
|
||||||
|
- `llm_score._call_llm`:`requests` → `httpx.AsyncClient`
|
||||||
|
- 涉及文件:`evaluation/rules/base.py`、`keyword.py`、`response_time.py`、`llm_score.py`、`evaluation/engine.py`、`tests/unit/test_engine.py`
|
||||||
|
- 验收:4 个规则全部 async;`pytest` 全绿;并发评测时 LLM 评分不阻塞 WebSocket 推送
|
||||||
|
|
||||||
|
**P0-1 多通道插件化**
|
||||||
|
- 新增 `HttpChannel`(通用 HTTP 请求/响应通道,配置化 request 模板 + response JSONPath 提取)
|
||||||
|
- 新增 `OpenClawChannel`(直接对接 OpenClaw WS,复用现有 proxy 认证逻辑)
|
||||||
|
- `ChannelFactory` 补齐 `HTTP` / `OPENCLAW` 映射
|
||||||
|
- (可选)entry_points 插件发现 —— 见决策点 D3
|
||||||
|
- 涉及文件:`channels/http.py`(新)、`channels/openclaw.py`(新)、`channels/factory.py`、`models.py`(ChannelType 已有枚举)
|
||||||
|
- 验收:可创建 HTTP 通道类型的评测对象并跑通一次评测;连通性测试可用
|
||||||
|
|
||||||
|
**P0-2 规则扩展 + 组合逻辑**
|
||||||
|
- 新增规则:
|
||||||
|
- `semantic_similarity`(回复与参考答案的语义相似度,embedding + 余弦)
|
||||||
|
- `json_schema`(回复 JSON 结构校验)
|
||||||
|
- `safety`(敏感内容检测,可接 moderation API 或关键词黑名单降级)
|
||||||
|
- 组合逻辑:Case 支持 `rule_logic: "all" | "any" | "weighted"`,weighted 支持每规则权重 + 阈值
|
||||||
|
- 涉及文件:`rules/semantic.py`(新)、`rules/json_schema.py`(新)、`rules/safety.py`(新)、`rules/__init__.py`、`models.py`(Case 加 rule_logic 字段)、`engine.py`(组合判定逻辑)、Alembic 迁移
|
||||||
|
- 验收:3 个新规则注册可用;weighted 组合的 pass_rate 计算正确;前端场景编辑器模板包含新规则示例
|
||||||
|
|
||||||
|
#### P1 — 重要能力
|
||||||
|
|
||||||
|
**P1-1 OpenClaw 深度集成(双向)**
|
||||||
|
- OpenClaw skill 可触发评测(现有 CLI 调用链打通 + 结构化返回)
|
||||||
|
- 评测完成 webhook 通知(`POST` 到配置的回调地址,附报告摘要)
|
||||||
|
- 涉及文件:`plugins/openclaw/agenteval_skill.py`、`web/routers/runs.py`(webhook hook)、`config/settings.py`(webhook 配置)
|
||||||
|
- 验收:OpenClaw 对话触发评测后能收到完成通知
|
||||||
|
|
||||||
|
**P1-2 报告能力升级**
|
||||||
|
- 对比报告:选两次 run,side-by-side 展示规则/用例差异
|
||||||
|
- Markdown 导出(补充现有 JSON/HTML)
|
||||||
|
- 报告模板外置(Jinja2 模板从代码抽到 `templates/` 目录)
|
||||||
|
- 涉及文件:`web/routers/reports.py`、`evaluation/report.py`、`templates/`(新)、前端 `pages/Reports.tsx`
|
||||||
|
- 验收:对比视图可用;Markdown 导出格式正确
|
||||||
|
|
||||||
|
**P1-3 测试补全**(还 DEBT-4)
|
||||||
|
- rules 单测:每个规则(含 3 个新规则)覆盖 pass/fail/边界
|
||||||
|
- 文件管理测试:上传/下载/分类级联删除
|
||||||
|
- 前端引入 vitest:`sessionReducer` 单测(v0.2 已抽纯函数,就等测试)
|
||||||
|
- 验收:后端覆盖率 → 70%+;前端有首批 reducer 测试
|
||||||
|
|
||||||
|
#### P2 — 体验优化
|
||||||
|
|
||||||
|
**P2-1 场景能力增强**
|
||||||
|
- 内置场景模板库(单轮问答 / 多轮对话 / 压力测试 / 动态生成)
|
||||||
|
- 参数化场景(变量占位 + 批量实例化)
|
||||||
|
|
||||||
|
**P2-2 前端体验 + 债务清理**(还 DEBT-3 / DEBT-5)
|
||||||
|
- WebSocket 自动重连(指数退避)
|
||||||
|
- `PageWrapper` 复用改造 或 删除;`statusColor` 抽到 tokens
|
||||||
|
- bundle 优化:路由级懒加载
|
||||||
|
|
||||||
|
### 3.2 不做什么(Out of Scope)
|
||||||
|
|
||||||
|
明确排除,维持「个人/小团队」定位,避免过度设计:
|
||||||
|
|
||||||
|
- ❌ 多租户 / RBAC 权限系统
|
||||||
|
- ❌ PostgreSQL / 分布式数据库(SQLite 足够)
|
||||||
|
- ❌ Celery / RQ 任务队列(async + asyncio.Task 足够)
|
||||||
|
- ❌ 移动端适配
|
||||||
|
- ❌ entry_points 全动态插件市场(见决策点 D3,倾向轻量注册)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、迭代节奏
|
||||||
|
|
||||||
|
按「先还债、再扩展、后体验」组织为 4 个 Sprint。工作量按人天(PD)估算,基于项目快速迭代节奏。
|
||||||
|
|
||||||
|
| Sprint | 主题 | 工作项 | 估算 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **S1** | 还债 + 通道 | P0-0 规则异步化 + P0-1 多通道 + DEBT-2 后端工具函数合并 | 4-5 PD |
|
||||||
|
| **S2** | 规则扩展 | P0-2 新规则 + 组合逻辑 + P1-3 规则测试同步 | 5-6 PD |
|
||||||
|
| **S3** | 集成 + 报告 | P1-1 OpenClaw 双向 + P1-2 报告升级 | 4-5 PD |
|
||||||
|
| **S4** | 体验 + 收尾 | P2-1 场景模板 + P2-2 前端债务清理 + 文件管理测试 | 3-4 PD |
|
||||||
|
|
||||||
|
**合计约 16-20 PD(≈ 3-4 周)**,与原 release notes 的 4-5 周估算基本吻合(本计划把还债前置,S1 会略慢)。
|
||||||
|
|
||||||
|
**里程碑验收点**:
|
||||||
|
- M1(S1 末):HTTP 通道跑通一次评测,规则层全 async,pytest 全绿
|
||||||
|
- M2(S2 末):5 种规则 + weighted 组合可用,后端覆盖率 65%+
|
||||||
|
- M3(S3 末):OpenClaw 对话触发评测 + 收到 webhook,对比报告可用
|
||||||
|
- M4(S4 末):场景模板库 + WebSocket 重连,v0.3 部署 t480
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、需要拍板的决策点
|
||||||
|
|
||||||
|
以下决策会显著影响实现,建议在 S1 启动前确认:
|
||||||
|
|
||||||
|
| 编号 | 决策 | 选项 | 倾向 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **D1** | semantic_similarity 的 embedding 来源 | A) 外部 API(豆包/OpenAI embedding,成本+网络依赖) B) 本地 sentence-transformers(部署重,+模型体积) | **A**:与现有 llm_score 一致的外部 API 模式,部署轻 |
|
||||||
|
| **D2** | safety 规则实现深度 | A) 接 moderation API B) 关键词黑名单 C) 两者,API 不可用时降级黑名单 | **C**:默认黑名单保证可用,API 可选增强 |
|
||||||
|
| **D3** | 通道/规则插件机制 | A) entry_points 动态发现 B) 现有工厂 register + 装饰器注册 | **B**:小团队无需插件市场,现有机制已够,避免过度设计 |
|
||||||
|
| **D4** | 规则异步化是否 breaking | 改 `evaluate` 签名会影响所有规则 | 全量改造(4 规则少,一次到位),无需兼容层 |
|
||||||
|
| **D5** | v0.3 是否顺带升级前端数据层(TanStack Query) | A) 引入 B) 维持 useState + 手动 load | **B**:当前页面数据量小,S4 仅做重连 + 懒加载,Query 留 v0.4 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、风险
|
||||||
|
|
||||||
|
| 风险 | 影响 | 缓解 |
|
||||||
|
|---|---|---|
|
||||||
|
| 规则异步化牵连 engine + 全部规则 + 测试 | S1 工期 | 规则数量少(4 个),且有 24 个测试兜底,一次改到位 |
|
||||||
|
| semantic/safety 依赖外部 API 稳定性 | 评测可靠性 | 规则内 try/except,API 失败降级为「规则跳过 + 明确 reason」,不中止 run |
|
||||||
|
| OpenClaw webhook 依赖外部可达性 | P1-1 集成 | webhook 失败不影响评测结果,仅记录日志 |
|
||||||
|
| 组合逻辑 weighted 的 pass_rate 语义变化 | 报告一致性 | 明确定义:weighted 下 case 通过 = 加权分 ≥ 阈值;文档 + 测试锁定 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、验收标准(v0.3 Definition of Done)
|
||||||
|
|
||||||
|
1. 可创建 tutu-api / HTTP / OpenClaw 三类通道的评测对象并各跑通一次评测
|
||||||
|
2. 5 种规则(3 旧 + semantic/json_schema/safety 选交付)可用,支持 all/any/weighted 组合
|
||||||
|
3. 规则评估层全 async,并发评测时 LLM/embedding 调用不阻塞 WebSocket 推送
|
||||||
|
4. OpenClaw 对话可触发评测并收到完成通知
|
||||||
|
5. 报告支持 JSON/HTML/Markdown 导出 + 两次 run 对比
|
||||||
|
6. 后端测试覆盖率 ≥ 70%,前端有首批 reducer 测试
|
||||||
|
7. 后端工具函数去重(DEBT-2),前端布局债务清理(DEBT-3)
|
||||||
|
8. v0.3 部署 t480,`/api/health` 显示 0.3.0 版本
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、下一步
|
||||||
|
|
||||||
|
1. 评审本计划,确认第五节 5 个决策点
|
||||||
|
2. 确认后按 Sprint 拆分为可执行 issue(每项含涉及文件 + 验收)
|
||||||
|
3. 从 S1(规则异步化 + 多通道)启动
|
||||||
213
tests/unit/test_http_channel_and_rules.py
Normal file
213
tests/unit/test_http_channel_and_rules.py
Normal file
@ -0,0 +1,213 @@
|
|||||||
|
"""Unit tests for HttpChannel and async rule evaluation."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from agenteval.channels.http import HttpChannel, _get_path
|
||||||
|
from agenteval.channels.base import SendResult
|
||||||
|
from agenteval.evaluation.rules.keyword import KeywordMatchRule
|
||||||
|
from agenteval.evaluation.rules.response_time import ResponseTimeRule
|
||||||
|
from agenteval.models import Case, CaseType, Expectation, Turn
|
||||||
|
|
||||||
|
|
||||||
|
# ── _get_path helper ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_get_path_simple():
|
||||||
|
assert _get_path({"id": "abc"}, "id") == "abc"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_path_nested():
|
||||||
|
assert _get_path({"a": {"b": {"c": 42}}}, "a.b.c") == 42
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_path_missing():
|
||||||
|
assert _get_path({"a": 1}, "a.b") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_path_none_data():
|
||||||
|
assert _get_path(None, "x") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_path_list_index():
|
||||||
|
assert _get_path({"items": ["x", "y"]}, "items.1") == "y"
|
||||||
|
|
||||||
|
|
||||||
|
# ── HttpChannel ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _make_channel(**extra) -> HttpChannel:
|
||||||
|
config = {
|
||||||
|
"send_url": "http://mock/send",
|
||||||
|
"reply_url": "http://mock/reply/{msg_id}",
|
||||||
|
"health_url": "http://mock/health",
|
||||||
|
**extra,
|
||||||
|
}
|
||||||
|
return HttpChannel(config)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_http_health_check_ok():
|
||||||
|
ch = _make_channel()
|
||||||
|
mock_resp = MagicMock()
|
||||||
|
mock_resp.status_code = 200
|
||||||
|
mock_resp.raise_for_status = MagicMock()
|
||||||
|
with patch.object(ch._client, "get", new=AsyncMock(return_value=mock_resp)):
|
||||||
|
result = await ch.health_check()
|
||||||
|
assert result.ok is True
|
||||||
|
assert "200" in result.message
|
||||||
|
|
||||||
|
|
||||||
|
async def test_http_health_check_fail():
|
||||||
|
ch = _make_channel()
|
||||||
|
with patch.object(ch._client, "get", new=AsyncMock(side_effect=Exception("conn refused"))):
|
||||||
|
result = await ch.health_check()
|
||||||
|
assert result.ok is False
|
||||||
|
assert "conn refused" in result.message
|
||||||
|
|
||||||
|
|
||||||
|
async def test_http_send_extracts_msg_id():
|
||||||
|
ch = _make_channel(msg_id_path="data.id")
|
||||||
|
mock_resp = MagicMock()
|
||||||
|
mock_resp.raise_for_status = MagicMock()
|
||||||
|
mock_resp.json = MagicMock(return_value={"data": {"id": "msg-42"}})
|
||||||
|
with patch.object(ch._client, "post", new=AsyncMock(return_value=mock_resp)):
|
||||||
|
result = await ch.send("hello")
|
||||||
|
assert result.ok is True
|
||||||
|
assert result.question_msg_id == "msg-42"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_http_send_failure():
|
||||||
|
ch = _make_channel()
|
||||||
|
with patch.object(ch._client, "post", new=AsyncMock(side_effect=Exception("timeout"))):
|
||||||
|
result = await ch.send("hi")
|
||||||
|
assert result.ok is False
|
||||||
|
assert "timeout" in result.error
|
||||||
|
|
||||||
|
|
||||||
|
async def test_http_poll_reply_found():
|
||||||
|
ch = _make_channel(reply_path="answer")
|
||||||
|
call_count = {"n": 0}
|
||||||
|
|
||||||
|
async def mock_get(url, **kwargs):
|
||||||
|
call_count["n"] += 1
|
||||||
|
mock_resp = MagicMock()
|
||||||
|
mock_resp.raise_for_status = MagicMock()
|
||||||
|
mock_resp.json = MagicMock(return_value={"answer": "Hello world"})
|
||||||
|
return mock_resp
|
||||||
|
|
||||||
|
with patch.object(ch._client, "get", new=mock_get):
|
||||||
|
reply = await ch.poll_reply("msg-1", timeout=5.0)
|
||||||
|
assert reply is not None
|
||||||
|
assert reply.content == "Hello world"
|
||||||
|
assert reply.question_msg_id == "msg-1"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_http_poll_reply_timeout():
|
||||||
|
ch = _make_channel(reply_path="missing_field", poll_interval=0.05)
|
||||||
|
|
||||||
|
async def mock_get(url, **kwargs):
|
||||||
|
mock_resp = MagicMock()
|
||||||
|
mock_resp.raise_for_status = MagicMock()
|
||||||
|
mock_resp.json = MagicMock(return_value={}) # field not present
|
||||||
|
return mock_resp
|
||||||
|
|
||||||
|
with patch.object(ch._client, "get", new=mock_get):
|
||||||
|
reply = await ch.poll_reply("msg-1", timeout=0.15, poll_interval=0.05)
|
||||||
|
assert reply is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_http_poll_reply_readiness_flag():
|
||||||
|
ch = _make_channel(reply_path="text", reply_ready_path="ready")
|
||||||
|
responses = [
|
||||||
|
{"ready": False, "text": "not ready"},
|
||||||
|
{"ready": True, "text": "final answer"},
|
||||||
|
]
|
||||||
|
call_idx = {"n": 0}
|
||||||
|
|
||||||
|
async def mock_get(url, **kwargs):
|
||||||
|
mock_resp = MagicMock()
|
||||||
|
mock_resp.raise_for_status = MagicMock()
|
||||||
|
mock_resp.json = MagicMock(return_value=responses[min(call_idx["n"], 1)])
|
||||||
|
call_idx["n"] += 1
|
||||||
|
return mock_resp
|
||||||
|
|
||||||
|
with patch.object(ch._client, "get", new=mock_get):
|
||||||
|
reply = await ch.poll_reply("msg-1", timeout=5.0, poll_interval=0.05)
|
||||||
|
assert reply is not None
|
||||||
|
assert reply.content == "final answer"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Async rule evaluation ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _make_turn(reply_text: str, latency_ms: int = 100) -> Turn:
|
||||||
|
return Turn(
|
||||||
|
id="t1", run_id="r1", case_id="c1", round_index=1,
|
||||||
|
reply={"msgBody": {"content": reply_text}},
|
||||||
|
latency_ms=latency_ms,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_case(*, keywords: list[str] | None = None, max_ms: int | None = None) -> Case:
|
||||||
|
return Case(
|
||||||
|
id="c1", type=CaseType.SINGLE, messages=["hi"],
|
||||||
|
expectations=Expectation(
|
||||||
|
keywords_include=keywords or [],
|
||||||
|
response_time_max_ms=max_ms,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_keyword_rule_is_async_and_passes():
|
||||||
|
rule = KeywordMatchRule({"keywords": ["hello", "world"]})
|
||||||
|
turn = _make_turn("hello world here")
|
||||||
|
result = await rule.evaluate(_make_case(), [turn])
|
||||||
|
assert result.passed is True
|
||||||
|
assert result.score == 1.0
|
||||||
|
|
||||||
|
|
||||||
|
async def test_keyword_rule_fails_missing_keyword():
|
||||||
|
rule = KeywordMatchRule({"keywords": ["missing"]})
|
||||||
|
turn = _make_turn("some other text")
|
||||||
|
result = await rule.evaluate(_make_case(), [turn])
|
||||||
|
assert result.passed is False
|
||||||
|
assert "missing" in result.reason
|
||||||
|
|
||||||
|
|
||||||
|
async def test_keyword_rule_fails_excluded_keyword():
|
||||||
|
rule = KeywordMatchRule({"exclude_keywords": ["banned"]})
|
||||||
|
turn = _make_turn("this is banned content")
|
||||||
|
result = await rule.evaluate(_make_case(), [turn])
|
||||||
|
assert result.passed is False
|
||||||
|
|
||||||
|
|
||||||
|
async def test_response_time_rule_is_async_and_passes():
|
||||||
|
rule = ResponseTimeRule({"max_ms": 500})
|
||||||
|
turn = _make_turn("ok", latency_ms=200)
|
||||||
|
result = await rule.evaluate(_make_case(), [turn])
|
||||||
|
assert result.passed is True
|
||||||
|
|
||||||
|
|
||||||
|
async def test_response_time_rule_fails_over_threshold():
|
||||||
|
rule = ResponseTimeRule({"max_ms": 100})
|
||||||
|
turn = _make_turn("slow response", latency_ms=5000)
|
||||||
|
result = await rule.evaluate(_make_case(), [turn])
|
||||||
|
assert result.passed is False
|
||||||
|
assert "5000ms" in result.reason
|
||||||
|
|
||||||
|
|
||||||
|
async def test_response_time_uses_expectation_fallback():
|
||||||
|
rule = ResponseTimeRule({}) # no max_ms in params
|
||||||
|
case = _make_case(max_ms=200)
|
||||||
|
turn = _make_turn("ok", latency_ms=100)
|
||||||
|
result = await rule.evaluate(case, [turn])
|
||||||
|
assert result.passed is True
|
||||||
|
|
||||||
|
|
||||||
|
async def test_rules_empty_dialog_fail():
|
||||||
|
keyword_rule = KeywordMatchRule({"keywords": ["x"]})
|
||||||
|
rt_rule = ResponseTimeRule({"max_ms": 1000})
|
||||||
|
case = _make_case()
|
||||||
|
for rule in [keyword_rule, rt_rule]:
|
||||||
|
result = await rule.evaluate(case, [])
|
||||||
|
assert result.passed is False
|
||||||
|
assert "无回复" in result.reason
|
||||||
Loading…
Reference in New Issue
Block a user