AgentEvalTool/backend/agenteval/evaluation/rules/fluency.py
sinohqb 21cc6ed407
All checks were successful
CI / test (pull_request) Successful in 4m11s
feat: 用户体验指标(放弃率+流畅度评估)
新增用户体验跟踪能力。

- RunSummary 新增 abandoned_cases 和 abandonment_rate 字段
- 新增 fluency 规则:LLM 评估对话流畅度和自然性(0-10 分)
- 流畅度评估考虑:自然性、重复性、连贯性、响应质量
- 新增 10 项单元测试(676 tests passed)

注:放弃率的实际计算逻辑需要在引擎中集成,本 PR 提供数据模型和规则基础设施。

Closes #26
2026-08-25 16:36:28 +08:00

170 lines
6.3 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.

"""Fluency assessment rule using LLM to evaluate conversation naturalness."""
import json
from agenteval.evaluation.rules.base import EvalRule, RuleResult, register_rule
from agenteval.models import Case, Turn
from agenteval.utils.llm import extract_reply_text
@register_rule
class FluencyRule(EvalRule):
"""Use LLM to evaluate conversation fluency and naturalness.
Evaluates:
- Naturalness: Does the conversation flow naturally?
- Repetition: Are there unnecessary repetitions?
- Coherence: Is the conversation coherent and logical?
Configuration params:
min_score: Minimum fluency score to pass (0-10, default: 7)
criteria: Custom evaluation criteria (optional)
"""
name = "fluency"
async def evaluate(self, case: Case, dialog: list[Turn]) -> RuleResult:
if not dialog:
return RuleResult(passed=False, reason="无回复记录")
# Build conversation context
conversation_parts = []
for turn in dialog:
# Extract sent message
sent_text = ""
if turn.sent_message:
sent_text = self._extract_message_text(turn.sent_message)
# Extract reply
reply_text = extract_reply_text(turn.reply) if turn.reply else ""
if sent_text:
conversation_parts.append(f"用户: {sent_text}")
if reply_text:
conversation_parts.append(f"助手: {reply_text}")
if not conversation_parts:
return RuleResult(passed=False, reason="无法提取对话内容")
conversation_text = "\n".join(conversation_parts)
# Get evaluation criteria
min_score = float(self.params.get("min_score", 7))
custom_criteria = self.params.get("criteria", "")
# Call LLM for fluency assessment
if self.model_config and self.gateway:
score, reason = await self._evaluate_fluency(conversation_text, custom_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="流畅度评估规则未绑定评估模型")
score, reason = await self._call_llm(api_url, api_key, model, conversation_text, custom_criteria)
if score is None:
return RuleResult(passed=False, reason=f"流畅度评估失败: {reason}")
passed = score >= min_score
verdict = "通过" if passed else "未通过"
return RuleResult(
passed=passed,
score=score / 10.0,
reason=f"流畅度评分 {score}/10{verdict} (阈值 {min_score}){reason}",
)
def _extract_message_text(self, sent_message: dict) -> str:
"""Extract text from sent_message dict."""
body = sent_message.get("msgBody", "")
if isinstance(body, dict):
return body.get("content", "")
try:
return json.loads(body).get("content", "")
except Exception:
return str(body)
async def _evaluate_fluency(self, conversation: str, custom_criteria: str) -> tuple[float | None, str]:
"""Evaluate conversation fluency using the gateway."""
system_prompt, user_prompt = self._build_prompts(conversation, custom_criteria)
try:
content = await self.gateway.chat(
self.model_config,
[
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
],
temperature=0.2,
)
return self._parse_score(content)
except Exception as exc:
return None, str(exc)
async def _call_llm(
self,
api_url: str,
api_key: str | None,
model: str,
conversation: str,
custom_criteria: str,
) -> tuple[float | None, str]:
"""Call LLM API directly for fluency assessment."""
import httpx
system_prompt, user_prompt = self._build_prompts(conversation, custom_criteria)
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:
async with httpx.AsyncClient(timeout=60) as client:
resp = await client.post(api_url, headers=headers, json=payload)
resp.raise_for_status()
from agenteval.utils.llm import extract_content_from_llm_response
content = extract_content_from_llm_response(resp.json())
if not content:
return None, "LLM 返回内容为空"
return self._parse_score(content)
except Exception as exc:
return None, str(exc)
@staticmethod
def _build_prompts(conversation: str, custom_criteria: str) -> tuple[str, str]:
"""Build system and user prompts for fluency evaluation."""
default_criteria = """评估对话的流畅度和自然性,考虑以下方面:
1. 自然性:对话是否流畅自然,像真人对话?
2. 重复性:是否有不必要的重复或冗余?
3. 连贯性:对话是否逻辑连贯,上下文一致?
4. 响应质量:助手的回复是否恰当、有帮助?"""
criteria = custom_criteria if custom_criteria else default_criteria
system_prompt = (
"你是一位对话质量评估专家。请评估以下对话的流畅度和自然性。\n"
f"评估标准:{criteria}\n"
"打分范围0-10分10分最高\n"
'只输出一个 JSON 对象:{"score": number, "reason": "简短说明"}'
)
user_prompt = f"对话内容:\n{conversation}"
return system_prompt, user_prompt
@staticmethod
def _parse_score(content: str) -> tuple[float, str]:
"""Parse LLM response to extract score and reason."""
from agenteval.utils.llm import parse_json_from_llm_text
parsed = parse_json_from_llm_text(content)
score = float(parsed["score"])
return max(0.0, min(10.0, score)), parsed.get("reason", "")