AgentEvalTool/backend/agenteval/channels/http.py
sinohqb 12481cd1b8 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>
2026-07-17 10:52:32 +08:00

133 lines
4.9 KiB
Python

"""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