## 新增功能 - 文件管理模块:分类树 + 文件上传/下载/删除 - 文件上传支持拖拽(Dragger)+ 手动上传(customRequest 模式) ## 页面布局统一(参照评测执行页) - 仪表盘/评测对象/评测场景/评测报告 全部改为全高 flex 布局 - 统一内联页头样式(h2 + 竖线分隔 + 描述) - 表格撑满高度、overflow 处理 - 每页添加刷新按钮 ## Bug 修复 - 分类树操作按钮 hover 不可见(CSS 规则缺失) - 文件上传失败(multipart boundary 缺失) - LLM API 响应 content blocks 数组格式支持(_extract_content_from_api_response) - response_time_max_ms 被静默忽略(隐式规则传空 params) - 空 messages 导致 IndexError 崩溃 - poll_reply 异常中止整个 run(缺 try/catch) - engine finally 未关闭 session - 3 个页面 UTC 时间戳解析偏差 8 小时 ## 后端 - EvalEngine: poll_reply 异常保护、空 dialog 保护、session 关闭 - LLM API 响应解析支持 content-block-array 格式 - 隐式 response_time 规则正确传递 max_ms 参数 ## 前端 - api.ts: 移除手动 Content-Type(让浏览器自动添加 boundary) - Files.tsx: customRequest 替代 beforeUpload、布局优化 - index.css: 分类树 hover 规则 - Targets/Scenarios/Home/Reports: 全高布局改造 - 3 个页面时间戳改用 formatDateTime()(修复 UTC 偏差) Co-Authored-By: Claude <noreply@anthropic.com>
149 lines
5.1 KiB
Python
149 lines
5.1 KiB
Python
"""LLM-based scoring evaluation rule."""
|
||
|
||
import json
|
||
from typing import Any
|
||
|
||
import requests
|
||
|
||
from agenteval.evaluation.rules.base import EvalRule, RuleResult, register_rule
|
||
from agenteval.models import Case, Turn
|
||
|
||
|
||
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
|
||
class LlmScoreRule(EvalRule):
|
||
"""Use an external LLM to score reply quality against criteria."""
|
||
|
||
name = "llm_score"
|
||
|
||
def evaluate(self, case: Case, dialog: list[Turn]) -> RuleResult:
|
||
if not dialog:
|
||
return RuleResult(passed=False, reason="无回复记录")
|
||
|
||
last_turn = dialog[-1]
|
||
reply_text = _extract_text(last_turn.reply)
|
||
question_text = ""
|
||
if len(dialog) >= 2:
|
||
question_text = _extract_text(dialog[-2].reply) or ""
|
||
if not question_text and last_turn.sent_message:
|
||
body = last_turn.sent_message.get("msgBody", "")
|
||
if isinstance(body, dict):
|
||
question_text = body.get("content", "")
|
||
else:
|
||
try:
|
||
parsed = json.loads(body)
|
||
question_text = parsed.get("content", "")
|
||
except Exception:
|
||
question_text = str(body)
|
||
|
||
criteria = self.params.get("criteria", "")
|
||
min_score = float(self.params.get("min_score", 7))
|
||
api_url = self.params.get("api_url")
|
||
api_key = self.params.get("api_key")
|
||
model = self.params.get("model", "gpt-4o-mini")
|
||
|
||
if not 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)
|
||
if score is None:
|
||
return RuleResult(passed=False, reason=f"LLM 评分失败: {reason}")
|
||
|
||
passed = score >= min_score
|
||
return RuleResult(
|
||
passed=passed,
|
||
score=score / 10.0,
|
||
reason=f"LLM 评分 {score}/10,{'通过' if passed else '未通过'} (阈值 {min_score})",
|
||
)
|
||
|
||
def _call_llm(
|
||
self,
|
||
api_url: str,
|
||
api_key: str | None,
|
||
model: str,
|
||
question: str,
|
||
reply: str,
|
||
criteria: str,
|
||
) -> tuple[float | None, str]:
|
||
"""Call the configured LLM API and parse a numeric score between 0 and 10."""
|
||
system_prompt = (
|
||
"你是一位严格的智能客服质量评估专家。请根据用户问题和智能体回复,"
|
||
f"按照以下标准打分(0-10分,10分最高):{criteria}\n"
|
||
"只输出一个 JSON 对象:{\"score\": number, \"reason\": \"简短说明\"}"
|
||
)
|
||
user_prompt = f"用户问题:{question}\n智能体回复:{reply}"
|
||
|
||
headers = {"Content-Type": "application/json"}
|
||
if api_key:
|
||
headers["Authorization"] = f"Bearer {api_key}"
|
||
|
||
payload = {
|
||
"model": model,
|
||
"messages": [
|
||
{"role": "system", "content": system_prompt},
|
||
{"role": "user", "content": user_prompt},
|
||
],
|
||
"temperature": 0.2,
|
||
}
|
||
|
||
try:
|
||
resp = requests.post(api_url, headers=headers, json=payload, timeout=60)
|
||
resp.raise_for_status()
|
||
data = resp.json()
|
||
content = _extract_content_from_api_response(data)
|
||
if not content:
|
||
return None, "LLM 返回内容为空"
|
||
|
||
# Try to parse JSON from the 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"])
|
||
reason = parsed.get("reason", "")
|
||
return max(0.0, min(10.0, score)), reason
|
||
except Exception as exc:
|
||
return None, str(exc)
|