"""Tests for S2 new rules: json_schema, safety, semantic_similarity, and rule combination logic (all/any/weighted).""" from unittest.mock import AsyncMock, MagicMock, patch import pytest from agenteval.evaluation.engine import EvalEngine from agenteval.evaluation.rules.json_schema import JsonSchemaRule, _get_path from agenteval.evaluation.rules.safety import SafetyRule from agenteval.evaluation.rules.semantic import SemanticSimilarityRule, _cosine from agenteval.models import ( Case, CaseType, EvalRuleConfig, EvalTarget, PlatformType, RuleLogic, RunStatus, Scenario, TargetStatus, Turn, ) from tests.unit.mock_channel import MockChannel # ── helpers ────────────────────────────────────────────────────────────── def _turn(reply_text: str, latency_ms: int = 100) -> Turn: return Turn( id="t1", run_id="r1", case_id="c1", round_index=1, reply={"msgBody": {"content": reply_text}}, latency_ms=latency_ms, ) def _case( *, rules: list[dict] | None = None, rule_logic: RuleLogic = RuleLogic.ALL, rule_pass_threshold: float = 0.6, ) -> Case: eval_rules = [EvalRuleConfig(**r) for r in (rules or [])] return Case( id="c1", type=CaseType.SINGLE, messages=["hi"], eval_rules=eval_rules, rule_logic=rule_logic, rule_pass_threshold=rule_pass_threshold, ) # ── _get_path ───────────────────────────────────────────────────────────── def test_json_schema_get_path_nested(): assert _get_path({"a": {"b": 1}}, "a.b") == (True, 1) def test_json_schema_get_path_missing(): assert _get_path({"a": 1}, "a.b") == (False, None) # ── JsonSchemaRule ──────────────────────────────────────────────────────── async def test_json_schema_valid_json_no_constraints(): rule = JsonSchemaRule({}) result = await rule.evaluate(_case(), [_turn('{"key": "value"}')]) assert result.passed is True async def test_json_schema_invalid_json_strict(): rule = JsonSchemaRule({"strict_json": True}) result = await rule.evaluate(_case(), [_turn("not json at all")]) assert result.passed is False assert "合法 JSON" in result.reason async def test_json_schema_invalid_json_nonstrict(): rule = JsonSchemaRule({"strict_json": False}) result = await rule.evaluate(_case(), [_turn("not json")]) assert result.passed is True async def test_json_schema_required_key_present(): rule = JsonSchemaRule({"required_keys": ["status", "data.id"]}) result = await rule.evaluate(_case(), [_turn('{"status": "ok", "data": {"id": 42}}')]) assert result.passed is True async def test_json_schema_required_key_missing(): rule = JsonSchemaRule({"required_keys": ["missing_key"]}) result = await rule.evaluate(_case(), [_turn('{"status": "ok"}')]) assert result.passed is False assert "missing_key" in result.reason async def test_json_schema_forbidden_key_present(): rule = JsonSchemaRule({"forbidden_keys": ["error"]}) result = await rule.evaluate(_case(), [_turn('{"status": "ok", "error": "oops"}')]) assert result.passed is False assert "error" in result.reason async def test_json_schema_type_check_pass(): rule = JsonSchemaRule({"key_types": {"count": "int", "name": "str"}}) result = await rule.evaluate(_case(), [_turn('{"count": 5, "name": "hello"}')]) assert result.passed is True async def test_json_schema_type_check_fail(): rule = JsonSchemaRule({"key_types": {"count": "int"}}) result = await rule.evaluate(_case(), [_turn('{"count": "five"}')]) assert result.passed is False assert "count" in result.reason async def test_json_schema_strips_markdown_fence(): rule = JsonSchemaRule({"required_keys": ["id"]}) reply = '```json\n{"id": 123}\n```' result = await rule.evaluate(_case(), [_turn(reply)]) assert result.passed is True async def test_json_schema_empty_dialog(): rule = JsonSchemaRule({}) result = await rule.evaluate(_case(), []) assert result.passed is False # ── SafetyRule ──────────────────────────────────────────────────────────── async def test_safety_clean_reply(): rule = SafetyRule({"blacklist": ["kill", "harm"]}) result = await rule.evaluate(_case(), [_turn("This is a helpful response.")]) assert result.passed is True async def test_safety_blacklist_hit(): rule = SafetyRule({"blacklist": ["kill"]}) result = await rule.evaluate(_case(), [_turn("I will kill you!")]) assert result.passed is False assert "kill" in result.reason async def test_safety_blacklist_case_insensitive(): rule = SafetyRule({"blacklist": ["HARM"]}) result = await rule.evaluate(_case(), [_turn("this causes harm")]) assert result.passed is False async def test_safety_no_blacklist(): rule = SafetyRule({}) result = await rule.evaluate(_case(), [_turn("any text")]) assert result.passed is True async def test_safety_moderation_api_flagged(): rule = SafetyRule({ "use_moderation_api": True, "api_url": "http://mock/v1/moderations", "api_key": "test", }) mock_response = MagicMock() mock_response.raise_for_status = MagicMock() mock_response.json = MagicMock(return_value={ "results": [{"flagged": True, "categories": {"hate": True, "violence": False}}] }) with patch("agenteval.evaluation.rules.safety.httpx.AsyncClient") as MockClient: instance = MockClient.return_value.__aenter__.return_value instance.post = AsyncMock(return_value=mock_response) result = await rule.evaluate(_case(), [_turn("hateful content here")]) assert result.passed is False assert "hate" in result.reason async def test_safety_moderation_api_unavailable_degrades(): rule = SafetyRule({ "use_moderation_api": True, "api_url": "http://unavailable/v1/moderations", }) with patch("agenteval.evaluation.rules.safety.httpx.AsyncClient") as MockClient: instance = MockClient.return_value.__aenter__.return_value instance.post = AsyncMock(side_effect=Exception("connection refused")) result = await rule.evaluate(_case(), [_turn("normal text")]) assert result.passed is True assert "降级" in result.reason async def test_safety_empty_dialog(): rule = SafetyRule({}) result = await rule.evaluate(_case(), []) assert result.passed is False # ── SemanticSimilarityRule ──────────────────────────────────────────────── def test_cosine_identical(): v = [1.0, 0.0, 0.0] assert _cosine(v, v) == pytest.approx(1.0) def test_cosine_orthogonal(): assert _cosine([1.0, 0.0], [0.0, 1.0]) == pytest.approx(0.0) def test_cosine_zero_vector(): assert _cosine([0.0, 0.0], [1.0, 0.0]) == 0.0 async def test_semantic_missing_api_url(): rule = SemanticSimilarityRule({"reference": "hello world"}) result = await rule.evaluate(_case(), [_turn("hello")]) assert result.passed is False assert "api_url" in result.reason async def test_semantic_missing_reference(): rule = SemanticSimilarityRule({"api_url": "http://mock/embed"}) result = await rule.evaluate(_case(), [_turn("hello")]) assert result.passed is False assert "reference" in result.reason async def test_semantic_high_similarity_passes(): rule = SemanticSimilarityRule({ "api_url": "http://mock/embed", "reference": "hello world", "min_score": 0.8, }) vec = [1.0, 0.0, 0.0] mock_resp = MagicMock() mock_resp.raise_for_status = MagicMock() mock_resp.json = MagicMock(return_value={"data": [{"embedding": vec}]}) with patch("agenteval.evaluation.rules.semantic.httpx.AsyncClient") as MockClient: instance = MockClient.return_value.__aenter__.return_value instance.post = AsyncMock(return_value=mock_resp) result = await rule.evaluate(_case(), [_turn("hello world")]) assert result.passed is True assert result.score == pytest.approx(1.0) async def test_semantic_low_similarity_fails(): rule = SemanticSimilarityRule({ "api_url": "http://mock/embed", "reference": "hello world", "min_score": 0.9, }) call_count = {"n": 0} async def mock_post(url, **kwargs): mock_resp = MagicMock() mock_resp.raise_for_status = MagicMock() # First call (reply): orthogonal to reference vecs = [[1.0, 0.0], [0.0, 1.0]] mock_resp.json = MagicMock(return_value={"data": [{"embedding": vecs[call_count["n"]]}]}) call_count["n"] += 1 return mock_resp with patch("agenteval.evaluation.rules.semantic.httpx.AsyncClient") as MockClient: instance = MockClient.return_value.__aenter__.return_value instance.post = AsyncMock(side_effect=mock_post) result = await rule.evaluate(_case(), [_turn("completely different")]) assert result.passed is False async def test_semantic_api_error_fails_gracefully(): rule = SemanticSimilarityRule({ "api_url": "http://mock/embed", "reference": "ref", }) with patch("agenteval.evaluation.rules.semantic.httpx.AsyncClient") as MockClient: instance = MockClient.return_value.__aenter__.return_value instance.post = AsyncMock(side_effect=Exception("timeout")) result = await rule.evaluate(_case(), [_turn("reply")]) assert result.passed is False assert "embedding 调用失败" in result.reason async def test_semantic_empty_dialog(): rule = SemanticSimilarityRule({"api_url": "http://x", "reference": "ref"}) result = await rule.evaluate(_case(), []) assert result.passed is False # ── Rule combination logic (engine integration) ─────────────────────────── def _make_target() -> EvalTarget: return EvalTarget( id="t-1", name="t", platform=PlatformType.AI_DIGITAL_EMPLOYEE, channel_type=__import__("agenteval.models", fromlist=["ChannelType"]).ChannelType.TUTU_API, channel_config={"base_url": "http://x", "token": "x", "tenant": "t", "chat_channel_id": "c", "chat_contact_id": "u"}, status=TargetStatus.ACTIVE, ) def _build_engine(scenario, session) -> EvalEngine: engine = EvalEngine(target=_make_target(), scenario=scenario, session=session) engine.channel = MockChannel() return engine async def test_rule_logic_all_passes_when_all_pass(db_session): scenario = Scenario(id="s1", name="s", cases=[Case( id="c1", type=CaseType.SINGLE, messages=["hi"], eval_rules=[ EvalRuleConfig(type="keyword_match", params={"keywords": ["echo"]}), # MockChannel replies "echo: q-1" EvalRuleConfig(type="response_time", params={"max_ms": 99999}), ], rule_logic=RuleLogic.ALL, )]) engine = _build_engine(scenario, db_session) run = await engine.run() assert run.status == RunStatus.COMPLETED assert run.summary.passed_cases == 1 async def test_rule_logic_all_fails_when_one_fails(db_session): scenario = Scenario(id="s1", name="s", cases=[Case( id="c1", type=CaseType.SINGLE, messages=["hi"], eval_rules=[ EvalRuleConfig(type="keyword_match", params={"keywords": ["echo"]}), # passes EvalRuleConfig(type="keyword_match", params={"keywords": ["__IMPOSSIBLE__"]}), # fails ], rule_logic=RuleLogic.ALL, )]) engine = _build_engine(scenario, db_session) run = await engine.run() assert run.status == RunStatus.COMPLETED assert run.summary.failed_cases == 1 async def test_rule_logic_any_passes_when_one_passes(db_session): scenario = Scenario(id="s1", name="s", cases=[Case( id="c1", type=CaseType.SINGLE, messages=["hi"], eval_rules=[ EvalRuleConfig(type="keyword_match", params={"keywords": ["echo"]}), # passes EvalRuleConfig(type="keyword_match", params={"keywords": ["__IMPOSSIBLE__"]}), # fails ], rule_logic=RuleLogic.ANY, )]) engine = _build_engine(scenario, db_session) run = await engine.run() assert run.status == RunStatus.COMPLETED assert run.summary.passed_cases == 1 async def test_rule_logic_weighted_passes_above_threshold(db_session): scenario = Scenario(id="s1", name="s", cases=[Case( id="c1", type=CaseType.SINGLE, messages=["hi"], eval_rules=[ EvalRuleConfig(type="keyword_match", params={"keywords": ["echo"]}, weight=0.8), # passes EvalRuleConfig(type="keyword_match", params={"keywords": ["__IMPOSSIBLE__"]}, weight=0.2), # fails ], rule_logic=RuleLogic.WEIGHTED, rule_pass_threshold=0.6, # weighted score = 0.8/(0.8+0.2)=0.8 >= 0.6 → pass )]) engine = _build_engine(scenario, db_session) run = await engine.run() assert run.status == RunStatus.COMPLETED assert run.summary.passed_cases == 1 async def test_rule_logic_weighted_fails_below_threshold(db_session): scenario = Scenario(id="s1", name="s", cases=[Case( id="c1", type=CaseType.SINGLE, messages=["hi"], eval_rules=[ EvalRuleConfig(type="keyword_match", params={"keywords": ["echo"]}, weight=0.2), # passes EvalRuleConfig(type="keyword_match", params={"keywords": ["__IMPOSSIBLE__"]}, weight=0.8), # fails ], rule_logic=RuleLogic.WEIGHTED, rule_pass_threshold=0.6, # weighted score = 0.2/(0.2+0.8)=0.2 < 0.6 → fail )]) engine = _build_engine(scenario, db_session) run = await engine.run() assert run.status == RunStatus.COMPLETED assert run.summary.failed_cases == 1