## 新规则(共 6 种,增加 3 种) ### semantic_similarity - 调用 OpenAI 兼容 embedding API(asyncio.gather 并发两路请求) - 余弦相似度与 reference 比对,min_score 可配置(默认 0.7) - API 异常时明确返回失败原因,不隐藏错误 ### json_schema - 验证回复是否为合法 JSON(支持 markdown 代码块剥离) - required_keys / forbidden_keys / key_types 三维校验 - dot-path 支持嵌套字段("data.id") - strict_json=false 模式非阻断校验 ### safety - 双层检测:关键词黑名单(零延迟)+ 可选 moderation API - API 不可用时自动降级黑名单,不中止评测 - 支持自定义 flagged_categories ## 规则组合逻辑(rule_logic + rule_pass_threshold) - models.py: EvalRuleConfig 增加 weight 字段;Case 增加 rule_logic / rule_pass_threshold - models.py: 新增 RuleLogic 枚举(all / any / weighted) - engine._save_rule_results: 按 rule_logic 决定 case 通过/失败 - ALL:全部通过才通过(原有行为,向下兼容) - ANY:至少一条通过即通过 - WEIGHTED:加权平均分 >= rule_pass_threshold ## 测试(43 → 76,新增 33) - test_s2_rules_and_logic.py:3 个新规则的 pass/fail/边界/API 降级 + 5 个组合逻辑集成测试 Co-Authored-By: Claude <noreply@anthropic.com>
121 lines
4.4 KiB
Python
121 lines
4.4 KiB
Python
"""JSON schema validation evaluation rule.
|
|
|
|
Validates that the agent reply is valid JSON and optionally conforms to
|
|
a specified structural schema (key presence, types, nested paths).
|
|
|
|
Configuration params:
|
|
required_keys list of dot-path keys that must exist (e.g. ["data.id", "status"])
|
|
forbidden_keys list of dot-path keys that must NOT exist
|
|
key_types dict mapping dot-path key → expected type name
|
|
("str", "int", "float", "bool", "list", "dict", "null")
|
|
e.g. {"status": "str", "count": "int"}
|
|
strict_json if true (default), fail if reply is not parseable JSON
|
|
|
|
All params are optional; with no params the rule only checks valid JSON.
|
|
"""
|
|
|
|
import json
|
|
from typing import Any
|
|
|
|
from agenteval.evaluation.rules.base import EvalRule, RuleResult, register_rule
|
|
from agenteval.models import Case, Turn
|
|
from agenteval.utils.llm import extract_reply_text
|
|
|
|
_TYPE_MAP: dict[str, type] = {
|
|
"str": str,
|
|
"int": int,
|
|
"float": float,
|
|
"bool": bool,
|
|
"list": list,
|
|
"dict": dict,
|
|
"null": type(None),
|
|
}
|
|
|
|
|
|
def _get_path(data: Any, path: str) -> tuple[bool, Any]:
|
|
"""Return (found, value) for a dot-separated path."""
|
|
parts = path.split(".")
|
|
current = data
|
|
for part in parts:
|
|
if isinstance(current, dict):
|
|
if part not in current:
|
|
return False, None
|
|
current = current[part]
|
|
elif isinstance(current, list) and part.isdigit():
|
|
idx = int(part)
|
|
if idx >= len(current):
|
|
return False, None
|
|
current = current[idx]
|
|
else:
|
|
return False, None
|
|
return True, current
|
|
|
|
|
|
@register_rule
|
|
class JsonSchemaRule(EvalRule):
|
|
"""Validate that the reply is valid JSON and matches a structural schema."""
|
|
|
|
name = "json_schema"
|
|
|
|
async def evaluate(self, case: Case, dialog: list[Turn]) -> RuleResult:
|
|
if not dialog:
|
|
return RuleResult(passed=False, reason="无回复记录")
|
|
|
|
reply_text = extract_reply_text(dialog[-1].reply).strip()
|
|
strict_json: bool = self.params.get("strict_json", True)
|
|
required_keys: list[str] = self.params.get("required_keys", [])
|
|
forbidden_keys: list[str] = self.params.get("forbidden_keys", [])
|
|
key_types: dict[str, str] = self.params.get("key_types", {})
|
|
|
|
# Try to find JSON in the reply (may be wrapped in markdown code block)
|
|
data: Any = None
|
|
try:
|
|
# Strip markdown code fences if present
|
|
text = reply_text
|
|
if text.startswith("```"):
|
|
lines = text.split("\n")
|
|
text = "\n".join(lines[1:-1] if lines[-1].strip() == "```" else lines[1:])
|
|
data = json.loads(text)
|
|
except (json.JSONDecodeError, ValueError):
|
|
if strict_json:
|
|
return RuleResult(passed=False, reason="回复不是合法 JSON")
|
|
# Non-strict: proceed with None data, required_keys will catch it
|
|
|
|
errors: list[str] = []
|
|
|
|
if data is not None:
|
|
# Check required keys
|
|
for key in required_keys:
|
|
found, _ = _get_path(data, key)
|
|
if not found:
|
|
errors.append(f"缺少字段: {key}")
|
|
|
|
# Check forbidden keys
|
|
for key in forbidden_keys:
|
|
found, _ = _get_path(data, key)
|
|
if found:
|
|
errors.append(f"存在禁止字段: {key}")
|
|
|
|
# Check key types
|
|
for key, expected_type_name in key_types.items():
|
|
found, value = _get_path(data, key)
|
|
if not found:
|
|
errors.append(f"类型检查字段缺失: {key}")
|
|
continue
|
|
expected_type = _TYPE_MAP.get(expected_type_name)
|
|
if expected_type is None:
|
|
continue # unknown type name — skip
|
|
if not isinstance(value, expected_type):
|
|
actual = type(value).__name__
|
|
errors.append(f"{key} 类型错误: 期望 {expected_type_name},实际 {actual}")
|
|
|
|
if errors:
|
|
return RuleResult(passed=False, reason="; ".join(errors))
|
|
|
|
total_checks = len(required_keys) + len(forbidden_keys) + len(key_types)
|
|
return RuleResult(
|
|
passed=True,
|
|
score=1.0,
|
|
reason=f"JSON 结构校验通过({total_checks} 项检查)" if total_checks else "合法 JSON",
|
|
)
|