AgentEvalTool/backend/agenteval/evaluation/rules/llm_score.py
sinohqb a77cd83e6a v0.2.0-dev: 文件管理 + 页面布局统一 + 6 个 bug 修复
## 新增功能
- 文件管理模块:分类树 + 文件上传/下载/删除
- 文件上传支持拖拽(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>
2026-07-16 15:25:22 +08:00

149 lines
5.1 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.

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