AgentEvalTool/backend/agenteval/evaluation/rules/llm_score.py
sinohqb 2f09ee2bfc
All checks were successful
CI / test (pull_request) Successful in 3m58s
refactor(v1.3.1): Phase 3 报告横幅、评分逻辑收敛与成本闭环
- 报告渲染 Go/No-Go 上线评估横幅(HTML 彩色 banner + Markdown 引用块)
- 抽取 scored_llm 共享模块:llm_score / fluency 直连调用与评分解析收敛
- 网关新增 chat_with_usage / embed_with_usage,规则按次归集 llm_usage
- 引擎分岗位用量归集(judge/generator/embedding/moderation)写入
  RunSummary.eval_usage_by_purpose,并发下不做总量差值
- cost_tracking 重构:data/model_pricing.json 覆盖 + 默认计价表,
  删除从未有数据支撑的 Turn 维度成本函数(偏差说明见 PR)
- 报告 summary 增加 eval_cost 分岗位成本段并在 Markdown 渲染
2026-08-26 01:59:20 +08:00

156 lines
6.2 KiB
Python
Raw Permalink 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 asyncio
import json
from agenteval.evaluation.rules.base import EvalRule, RuleResult, register_rule
from agenteval.evaluation.rules.scored_llm import call_scored_llm, parse_scored_content
from agenteval.models import Case, Turn
from agenteval.utils.llm import extract_reply_text
@register_rule
class LlmScoreRule(EvalRule):
"""Use an external LLM to score reply quality against criteria."""
name = "llm_score"
async def evaluate(self, case: Case, dialog: list[Turn]) -> RuleResult:
if not dialog:
return RuleResult(passed=False, reason="无回复记录")
last_turn = dialog[-1]
reply_text = extract_reply_text(last_turn.reply)
question_text = ""
if last_turn.sent_message:
body = last_turn.sent_message.get("msgBody", "")
if isinstance(body, dict):
question_text = body.get("content", "")
else:
try:
question_text = json.loads(body).get("content", "")
except Exception:
question_text = str(body)
dimensions = self.params.get("dimensions")
if dimensions:
return await self._evaluate_dimensions(question_text, reply_text, dimensions)
criteria = self.params.get("criteria", "")
min_score = float(self.params.get("min_score", 7))
if self.model_config and self.gateway:
score, reason = await self._call_gateway(question_text, reply_text, criteria)
else:
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 = await 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
verdict = "通过" if passed else "未通过"
detail = f"{reason}" if reason else ""
return RuleResult(
passed=passed,
score=score / 10.0,
reason=f"LLM 评分 {score}/10{verdict} (阈值 {min_score}){detail}",
)
_MAX_CONCURRENT_DIMENSIONS = 5
async def _evaluate_dimensions(
self, question: str, reply: str, dimensions: list[dict]
) -> RuleResult:
semaphore = asyncio.Semaphore(self._MAX_CONCURRENT_DIMENSIONS)
async def bounded(dim: dict) -> tuple[float | None, str]:
async with semaphore:
return await self._evaluate_one_dimension(question, reply, dim)
results = await asyncio.gather(*(bounded(dim) for dim in dimensions))
dimension_scores = {}
dimension_reasons = []
all_passed = True
for dim, (score, reason) in zip(dimensions, results):
dim_name = dim.get("name", "unknown")
min_score = float(dim.get("min_score", 7))
if score is None:
all_passed = False
dimension_scores[dim_name] = None
dimension_reasons.append(f"{dim_name}: 评分失败 ({reason})")
else:
passed = score >= min_score
if not passed:
all_passed = False
dimension_scores[dim_name] = score
dimension_reasons.append(f"{dim_name}: {score}/10")
valid_scores = [s for s in dimension_scores.values() if s is not None]
avg_score = sum(valid_scores) / len(valid_scores) if valid_scores else 0
verdict = "通过" if all_passed else "未通过"
reasons_str = "".join(dimension_reasons)
return RuleResult(
passed=all_passed,
score=avg_score / 10.0,
reason=f"多维度 LLM 评分 {avg_score:.1f}/10{verdict}{reasons_str}",
details={"dimensions": dimension_scores},
)
async def _evaluate_one_dimension(
self, question: str, reply: str, dimension: dict
) -> tuple[float | None, str]:
criteria = dimension.get("criteria", "")
if self.model_config and self.gateway:
return await self._call_gateway(question, reply, criteria)
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 None, "未绑定评估模型"
return await self._call_llm(api_url, api_key, model, question, reply, criteria)
async def _call_gateway(self, question: str, reply: str, criteria: str) -> tuple[float | None, str]:
system_prompt, user_prompt = self._prompts(question, reply, criteria)
try:
content, usage = await self.gateway.chat_with_usage(
self.model_config,
[
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
],
temperature=0.2,
)
self._record_llm_usage(usage)
return parse_scored_content(content)
except Exception as exc:
return None, str(exc)
@staticmethod
def _prompts(question: str, reply: str, criteria: str) -> tuple[str, str]:
system_prompt = (
"你是一位严格的智能客服质量评估专家。请根据用户问题和智能体回复,"
f"按照以下标准打分0-10分10分最高{criteria}\n"
'只输出一个 JSON 对象:{"score": number, "reason": "简短说明"}'
)
return system_prompt, f"用户问题:{question}\n智能体回复:{reply}"
async 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, user_prompt = self._prompts(question, reply, criteria)
return await call_scored_llm(api_url, api_key, model, system_prompt, user_prompt)