""" Tests for clarification mechanism. [AC-CLARIFY] 澄清机制测试 """ import pytest from unittest.mock import MagicMock, patch from app.services.intent.clarification import ( ClarificationEngine, ClarifyMetrics, ClarifyReason, ClarifySessionManager, ClarifyState, HybridIntentResult, IntentCandidate, T_HIGH, T_LOW, MAX_CLARIFY_RETRY, get_clarify_metrics, ) class TestClarifyMetrics: def test_singleton_pattern(self): m1 = ClarifyMetrics() m2 = ClarifyMetrics() assert m1 is m2 def test_record_clarify_trigger(self): metrics = ClarifyMetrics() metrics.reset() metrics.record_clarify_trigger() metrics.record_clarify_trigger() metrics.record_clarify_trigger() counts = metrics.get_metrics() assert counts["clarify_trigger_rate"] == 3 def test_record_clarify_converge(self): metrics = ClarifyMetrics() metrics.reset() metrics.record_clarify_converge() metrics.record_clarify_converge() counts = metrics.get_metrics() assert counts["clarify_converge_rate"] == 2 def test_record_misroute(self): metrics = ClarifyMetrics() metrics.reset() metrics.record_misroute() counts = metrics.get_metrics() assert counts["misroute_rate"] == 1 def test_get_rates(self): metrics = ClarifyMetrics() metrics.reset() metrics.record_clarify_trigger() metrics.record_clarify_converge() metrics.record_misroute() rates = metrics.get_rates(100) assert rates["clarify_trigger_rate"] == 0.01 assert rates["clarify_converge_rate"] == 1.0 assert rates["misroute_rate"] == 0.01 def test_get_rates_zero_requests(self): metrics = ClarifyMetrics() metrics.reset() rates = metrics.get_rates(0) assert rates["clarify_trigger_rate"] == 0.0 assert rates["clarify_converge_rate"] == 0.0 assert rates["misroute_rate"] == 0.0 def test_reset(self): metrics = ClarifyMetrics() metrics.record_clarify_trigger() metrics.record_clarify_converge() metrics.record_misroute() metrics.reset() counts = metrics.get_metrics() assert counts["clarify_trigger_rate"] == 0 assert counts["clarify_converge_rate"] == 0 assert counts["misroute_rate"] == 0 class TestIntentCandidate: def test_to_dict(self): candidate = IntentCandidate( intent_id="intent-1", intent_name="退货意图", confidence=0.85, response_type="flow", target_kb_ids=["kb-1"], flow_id="flow-1", fixed_reply=None, transfer_message=None, ) result = candidate.to_dict() assert result["intent_id"] == "intent-1" assert result["intent_name"] == "退货意图" assert result["confidence"] == 0.85 assert result["response_type"] == "flow" assert result["target_kb_ids"] == ["kb-1"] assert result["flow_id"] == "flow-1" class TestHybridIntentResult: def test_to_dict(self): candidate = IntentCandidate( intent_id="intent-1", intent_name="退货意图", confidence=0.85, ) result = HybridIntentResult( intent=candidate, confidence=0.85, candidates=[candidate], need_clarify=False, clarify_reason=None, missing_slots=[], ) d = result.to_dict() assert d["intent"]["intent_id"] == "intent-1" assert d["confidence"] == 0.85 assert len(d["candidates"]) == 1 assert d["need_clarify"] is False def test_from_fusion_result(self): mock_fusion = MagicMock() mock_fusion.final_intent = MagicMock() mock_fusion.final_intent.id = "intent-1" mock_fusion.final_intent.name = "退货意图" mock_fusion.final_intent.response_type = "flow" mock_fusion.final_intent.target_kb_ids = ["kb-1"] mock_fusion.final_intent.flow_id = None mock_fusion.final_intent.fixed_reply = None mock_fusion.final_intent.transfer_message = None mock_fusion.final_confidence = 0.85 mock_fusion.need_clarify = False mock_fusion.decision_reason = "rule_high_confidence" mock_fusion.clarify_candidates = [] result = HybridIntentResult.from_fusion_result(mock_fusion) assert result.intent is not None assert result.intent.intent_id == "intent-1" assert result.confidence == 0.85 assert result.need_clarify is False def test_from_fusion_result_with_clarify(self): mock_fusion = MagicMock() mock_fusion.final_intent = None mock_fusion.final_confidence = 0.5 mock_fusion.need_clarify = True mock_fusion.decision_reason = "multi_intent" candidate1 = MagicMock() candidate1.id = "intent-1" candidate1.name = "退货意图" candidate1.response_type = "flow" candidate1.target_kb_ids = None candidate1.flow_id = None candidate1.fixed_reply = None candidate1.transfer_message = None candidate2 = MagicMock() candidate2.id = "intent-2" candidate2.name = "换货意图" candidate2.response_type = "flow" candidate2.target_kb_ids = None candidate2.flow_id = None candidate2.fixed_reply = None candidate2.transfer_message = None mock_fusion.clarify_candidates = [candidate1, candidate2] result = HybridIntentResult.from_fusion_result(mock_fusion) assert result.need_clarify is True assert result.clarify_reason == ClarifyReason.MULTI_INTENT assert len(result.candidates) == 2 class TestClarifyState: def test_to_dict(self): candidate = IntentCandidate( intent_id="intent-1", intent_name="退货意图", confidence=0.5, ) state = ClarifyState( reason=ClarifyReason.INTENT_AMBIGUITY, asked_slot=None, retry_count=1, candidates=[candidate], asked_intent_ids=["intent-1"], ) d = state.to_dict() assert d["reason"] == "intent_ambiguity" assert d["retry_count"] == 1 assert len(d["candidates"]) == 1 def test_increment_retry(self): state = ClarifyState(reason=ClarifyReason.LOW_CONFIDENCE) state.increment_retry() assert state.retry_count == 1 state.increment_retry() assert state.retry_count == 2 def test_is_max_retry(self): state = ClarifyState(reason=ClarifyReason.LOW_CONFIDENCE) assert not state.is_max_retry() state.retry_count = MAX_CLARIFY_RETRY assert state.is_max_retry() class TestClarificationEngine: def test_compute_confidence_rule_only(self): engine = ClarificationEngine() confidence = engine.compute_confidence( rule_score=1.0, semantic_score=0.0, llm_score=0.0, w_rule=1.0, w_semantic=0.0, w_llm=0.0, ) assert confidence == 1.0 def test_compute_confidence_semantic_only(self): engine = ClarificationEngine() confidence = engine.compute_confidence( rule_score=0.0, semantic_score=0.8, llm_score=0.0, w_rule=0.3, w_semantic=0.5, w_llm=0.2, ) # With weights w_rule=0.3, w_semantic=0.5, w_llm=0.2 and scores # rule=0.0, semantic=0.8, llm=0.0: # confidence = (0.0*0.3 + 0.8*0.5 + 0.0*0.2) / (0.3+0.5+0.2) = 0.4/1.0 = 0.4 assert confidence == 0.4 def test_compute_confidence_weighted(self): engine = ClarificationEngine() confidence = engine.compute_confidence( rule_score=1.0, semantic_score=0.8, llm_score=0.9, w_rule=0.5, w_semantic=0.3, w_llm=0.2, ) expected = (1.0 * 0.5 + 0.8 * 0.3 + 0.9 * 0.2) / 1.0 assert abs(confidence - expected) < 0.001 def test_check_hard_block_low_confidence(self): engine = ClarificationEngine() result = HybridIntentResult( intent=None, confidence=0.5, candidates=[], ) is_blocked, reason = engine.check_hard_block(result) assert is_blocked is True assert reason == ClarifyReason.LOW_CONFIDENCE def test_check_hard_block_high_confidence(self): engine = ClarificationEngine() result = HybridIntentResult( intent=IntentCandidate( intent_id="intent-1", intent_name="退货意图", confidence=0.85, ), confidence=0.85, candidates=[], ) is_blocked, reason = engine.check_hard_block(result) assert is_blocked is False assert reason is None def test_check_hard_block_missing_slots(self): engine = ClarificationEngine() result = HybridIntentResult( intent=IntentCandidate( intent_id="intent-1", intent_name="退货意图", confidence=0.85, ), confidence=0.85, candidates=[], ) is_blocked, reason = engine.check_hard_block( result, required_slots=["order_id", "product_id"], filled_slots={"order_id": "123"}, ) assert is_blocked is True assert reason == ClarifyReason.MISSING_SLOT def test_should_trigger_clarify_below_t_low(self): engine = ClarificationEngine() get_clarify_metrics().reset() result = HybridIntentResult( intent=None, confidence=0.3, candidates=[], ) should_clarify, state = engine.should_trigger_clarify(result) assert should_clarify is True assert state is not None assert state.reason == ClarifyReason.LOW_CONFIDENCE def test_should_trigger_clarify_gray_zone(self): engine = ClarificationEngine() get_clarify_metrics().reset() candidate = IntentCandidate( intent_id="intent-1", intent_name="退货意图", confidence=0.5, ) result = HybridIntentResult( intent=candidate, confidence=0.5, candidates=[candidate], need_clarify=True, clarify_reason=ClarifyReason.INTENT_AMBIGUITY, ) should_clarify, state = engine.should_trigger_clarify(result) assert should_clarify is True assert state is not None assert state.reason == ClarifyReason.INTENT_AMBIGUITY def test_should_trigger_clarify_above_t_high(self): engine = ClarificationEngine() get_clarify_metrics().reset() candidate = IntentCandidate( intent_id="intent-1", intent_name="退货意图", confidence=0.85, ) result = HybridIntentResult( intent=candidate, confidence=0.85, candidates=[candidate], ) should_clarify, state = engine.should_trigger_clarify(result) assert should_clarify is False assert state is None def test_generate_clarify_prompt_missing_slot(self): engine = ClarificationEngine() state = ClarifyState( reason=ClarifyReason.MISSING_SLOT, asked_slot="order_id", ) prompt = engine.generate_clarify_prompt(state) assert "order_id" in prompt or "相关信息" in prompt def test_generate_clarify_prompt_low_confidence(self): engine = ClarificationEngine() state = ClarifyState(reason=ClarifyReason.LOW_CONFIDENCE) prompt = engine.generate_clarify_prompt(state) assert "理解" in prompt or "详细" in prompt def test_generate_clarify_prompt_multi_intent(self): engine = ClarificationEngine() candidates = [ IntentCandidate(intent_id="1", intent_name="退货", confidence=0.5), IntentCandidate(intent_id="2", intent_name="换货", confidence=0.4), ] state = ClarifyState( reason=ClarifyReason.MULTI_INTENT, candidates=candidates, ) prompt = engine.generate_clarify_prompt(state) assert "退货" in prompt assert "换货" in prompt def test_process_clarify_response_max_retry(self): engine = ClarificationEngine() get_clarify_metrics().reset() state = ClarifyState( reason=ClarifyReason.LOW_CONFIDENCE, retry_count=MAX_CLARIFY_RETRY, ) result = engine.process_clarify_response("用户回复", state) assert result.intent is None assert result.confidence == 0.0 assert result.need_clarify is False def test_process_clarify_response_missing_slot(self): engine = ClarificationEngine() get_clarify_metrics().reset() candidate = IntentCandidate( intent_id="intent-1", intent_name="退货意图", confidence=0.8, ) state = ClarifyState( reason=ClarifyReason.MISSING_SLOT, asked_slot="order_id", candidates=[candidate], ) result = engine.process_clarify_response("订单号是123", state) assert result.intent is not None assert result.need_clarify is False def test_get_metrics(self): engine = ClarificationEngine() get_clarify_metrics().reset() engine._metrics.record_clarify_trigger() engine._metrics.record_clarify_converge() metrics = engine.get_metrics() assert metrics["clarify_trigger_rate"] == 1 assert metrics["clarify_converge_rate"] == 1 class TestClarifySessionManager: def test_set_and_get_session(self): ClarifySessionManager.clear_session("test-session") state = ClarifyState(reason=ClarifyReason.LOW_CONFIDENCE) ClarifySessionManager.set_session("test-session", state) retrieved = ClarifySessionManager.get_session("test-session") assert retrieved is not None assert retrieved.reason == ClarifyReason.LOW_CONFIDENCE def test_clear_session(self): ClarifySessionManager.set_session( "test-session", ClarifyState(reason=ClarifyReason.LOW_CONFIDENCE), ) ClarifySessionManager.clear_session("test-session") retrieved = ClarifySessionManager.get_session("test-session") assert retrieved is None def test_has_active_clarify(self): ClarifySessionManager.clear_session("test-session") assert not ClarifySessionManager.has_active_clarify("test-session") state = ClarifyState(reason=ClarifyReason.LOW_CONFIDENCE) ClarifySessionManager.set_session("test-session", state) assert ClarifySessionManager.has_active_clarify("test-session") state.retry_count = MAX_CLARIFY_RETRY assert not ClarifySessionManager.has_active_clarify("test-session") class TestThresholds: def test_t_high_value(self): assert T_HIGH == 0.75 def test_t_low_value(self): assert T_LOW == 0.45 def test_t_high_greater_than_t_low(self): assert T_HIGH > T_LOW def test_max_retry_value(self): assert MAX_CLARIFY_RETRY == 3