""" Integration tests for Metadata Governance runtime pipeline. [AC-IDSMETA-18~20] Test routing -> filtering -> retrieval -> fallback chain. Test Matrix: - AC-IDSMETA-18: Intent routing with target KB selection - AC-IDSMETA-19: Metadata filter injection in RAG retrieval - AC-IDSMETA-20: Fallback strategy with structured reason codes """ import pytest from unittest.mock import AsyncMock, MagicMock, patch from typing import Any from dataclasses import dataclass, field @dataclass class MockIntentRule: """Mock IntentRule for testing.""" id: str name: str response_type: str target_kb_ids: list[str] | None = None keywords: list[str] | None = None patterns: list[str] | None = None priority: int = 0 is_enabled: bool = True fixed_reply: str | None = None flow_id: str | None = None transfer_message: str | None = None @dataclass class MockIntentMatchResult: """Mock IntentMatchResult for testing.""" rule: MockIntentRule match_type: str matched: str def to_dict(self) -> dict[str, Any]: return { "rule_id": str(self.rule.id), "rule_name": self.rule.name, "match_type": self.match_type, "matched": self.matched, "response_type": self.rule.response_type, "target_kb_ids": self.rule.target_kb_ids or [], } @dataclass class MockRetrievalHit: """Mock RetrievalHit for testing.""" text: str score: float source: str metadata: dict[str, Any] = field(default_factory=dict) @dataclass class MockRetrievalResult: """Mock RetrievalResult for testing.""" hits: list[MockRetrievalHit] diagnostics: dict[str, Any] = field(default_factory=dict) @property def hit_count(self) -> int: return len(self.hits) @property def max_score(self) -> float: return max((h.score for h in self.hits), default=0.0) @property def is_empty(self) -> bool: return len(self.hits) == 0 class MockIntentRouter: """Mock IntentRouter for testing.""" def match(self, message: str, rules: list[MockIntentRule]) -> MockIntentMatchResult | None: message_lower = message.lower() sorted_rules = sorted(rules, key=lambda r: r.priority, reverse=True) for rule in sorted_rules: if not rule.is_enabled: continue if rule.keywords: for keyword in rule.keywords: if keyword.lower() in message_lower: return MockIntentMatchResult( rule=rule, match_type="keyword", matched=keyword, ) if rule.patterns: import re for pattern in rule.patterns: if re.search(pattern, message, re.IGNORECASE): return MockIntentMatchResult( rule=rule, match_type="regex", matched=pattern, ) return None class MockRetriever: """Mock Retriever with metadata filtering support.""" def __init__(self, hits: list[MockRetrievalHit] | None = None): self._hits = hits or [] self._last_filter: dict[str, Any] | None = None self._last_target_kb_ids: list[str] | None = None async def retrieve( self, tenant_id: str, query: str, target_kb_ids: list[str] | None = None, metadata_filter: dict[str, Any] | None = None, ) -> MockRetrievalResult: self._last_filter = metadata_filter self._last_target_kb_ids = target_kb_ids filtered_hits = [] for hit in self._hits: if metadata_filter: match = True for key, value in metadata_filter.items(): if hit.metadata.get(key) != value: match = False break if not match: continue if target_kb_ids: if hit.metadata.get("kb_id") not in target_kb_ids: continue filtered_hits.append(hit) return MockRetrievalResult( hits=filtered_hits, diagnostics={ "filter_applied": metadata_filter is not None, "target_kb_ids": target_kb_ids, }, ) class FallbackStrategy: """ [AC-IDSMETA-20] Fallback strategy with structured reason codes. """ REASON_CODES = { "NO_INTENT_MATCH": "intent_not_matched", "NO_RETRIEVAL_HITS": "retrieval_empty", "LOW_CONFIDENCE": "confidence_below_threshold", "KB_UNAVAILABLE": "knowledge_base_unavailable", "METADATA_FILTER_TOO_STRICT": "filter_excluded_all", } def execute( self, reason: str, fallback_kb_id: str | None = None, fallback_message: str | None = None, ) -> dict[str, Any]: reason_code = self.REASON_CODES.get(reason, "unknown") result = { "fallback_triggered": True, "reason_code": reason_code, "fallback_type": None, "fallback_content": None, } if fallback_kb_id: result["fallback_type"] = "kb" result["fallback_kb_id"] = fallback_kb_id elif fallback_message: result["fallback_type"] = "fixed" result["fallback_content"] = fallback_message else: result["fallback_type"] = "default" result["fallback_content"] = "抱歉,我暂时无法回答您的问题,请稍后重试或联系人工客服。" return result class TestIntentRouting: """ [AC-IDSMETA-18] Test intent routing with target KB selection. """ def setup_method(self): self.router = MockIntentRouter() def test_keyword_match_routes_to_rag(self): """Intent with response_type=rag should route to RAG with target KBs.""" rules = [ MockIntentRule( id="rule-1", name="退货咨询", response_type="rag", target_kb_ids=["kb-return", "kb-policy"], keywords=["退货", "退款"], priority=10, ) ] result = self.router.match("我想退货怎么办", rules) assert result is not None assert result.rule.response_type == "rag" assert result.rule.target_kb_ids == ["kb-return", "kb-policy"] assert result.match_type == "keyword" def test_regex_match_routes_to_flow(self): """Intent with response_type=flow should start script flow.""" rules = [ MockIntentRule( id="rule-2", name="订单查询", response_type="flow", flow_id="flow-order-query", patterns=[r"订单.*查询", r"查询.*订单"], priority=5, ) ] result = self.router.match("帮我查询订单状态", rules) assert result is not None assert result.rule.response_type == "flow" assert result.rule.flow_id == "flow-order-query" assert result.match_type == "regex" def test_fixed_reply_intent(self): """Intent with response_type=fixed should return fixed reply.""" rules = [ MockIntentRule( id="rule-3", name="问候", response_type="fixed", fixed_reply="您好,请问有什么可以帮您?", keywords=["你好", "您好"], priority=1, ) ] result = self.router.match("你好", rules) assert result is not None assert result.rule.response_type == "fixed" assert result.rule.fixed_reply == "您好,请问有什么可以帮您?" def test_transfer_intent(self): """Intent with response_type=transfer should trigger transfer.""" rules = [ MockIntentRule( id="rule-4", name="人工服务", response_type="transfer", transfer_message="正在为您转接人工客服...", keywords=["人工", "转人工"], priority=100, ) ] result = self.router.match("我要转人工", rules) assert result is not None assert result.rule.response_type == "transfer" assert result.rule.transfer_message == "正在为您转接人工客服..." def test_priority_ordering(self): """Higher priority rules should be matched first.""" rules = [ MockIntentRule( id="rule-low", name="通用问候", response_type="fixed", fixed_reply="通用问候回复", keywords=["你好"], priority=1, ), MockIntentRule( id="rule-high", name="VIP问候", response_type="fixed", fixed_reply="VIP问候回复", keywords=["你好"], priority=10, ), ] result = self.router.match("你好", rules) assert result is not None assert result.rule.id == "rule-high" assert result.rule.fixed_reply == "VIP问候回复" def test_no_match_returns_none(self): """No matching intent should return None.""" rules = [ MockIntentRule( id="rule-1", name="退货", response_type="rag", keywords=["退货"], priority=10, ) ] result = self.router.match("今天天气怎么样", rules) assert result is None class TestMetadataFilterInjection: """ [AC-IDSMETA-19] Test metadata filter injection in RAG retrieval. """ @pytest.mark.asyncio async def test_filter_injection_with_grade_subject_scene(self): """RAG retrieval should inject grade/subject/scene metadata filters.""" retriever = MockRetriever(hits=[ MockRetrievalHit( text="初一数学知识点", score=0.9, source="kb", metadata={"grade": "初一", "subject": "数学", "scene": "课后辅导", "kb_id": "kb-1"}, ), MockRetrievalHit( text="初二物理知识点", score=0.85, source="kb", metadata={"grade": "初二", "subject": "物理", "scene": "课后辅导", "kb_id": "kb-1"}, ), ]) metadata_filter = { "grade": "初一", "subject": "数学", "scene": "课后辅导", } result = await retriever.retrieve( tenant_id="tenant-1", query="数学知识点", metadata_filter=metadata_filter, ) assert retriever._last_filter == metadata_filter assert result.hit_count == 1 assert result.hits[0].metadata["grade"] == "初一" @pytest.mark.asyncio async def test_target_kb_ids_filtering(self): """RAG retrieval should filter by target KB IDs from intent.""" retriever = MockRetriever(hits=[ MockRetrievalHit( text="退货政策", score=0.9, source="kb", metadata={"kb_id": "kb-return"}, ), MockRetrievalHit( text="产品介绍", score=0.85, source="kb", metadata={"kb_id": "kb-product"}, ), ]) result = await retriever.retrieve( tenant_id="tenant-1", query="退货", target_kb_ids=["kb-return"], ) assert retriever._last_target_kb_ids == ["kb-return"] assert result.hit_count == 1 assert result.hits[0].metadata["kb_id"] == "kb-return" @pytest.mark.asyncio async def test_combined_filters(self): """RAG retrieval should combine target KB and metadata filters.""" retriever = MockRetriever(hits=[ MockRetrievalHit( text="初一数学教材", score=0.9, source="kb", metadata={"grade": "初一", "subject": "数学", "kb_id": "kb-edu"}, ), MockRetrievalHit( text="初二数学教材", score=0.85, source="kb", metadata={"grade": "初二", "subject": "数学", "kb_id": "kb-edu"}, ), MockRetrievalHit( text="初一数学练习", score=0.8, source="kb", metadata={"grade": "初一", "subject": "数学", "kb_id": "kb-exercise"}, ), ]) result = await retriever.retrieve( tenant_id="tenant-1", query="数学", target_kb_ids=["kb-edu"], metadata_filter={"grade": "初一"}, ) assert result.hit_count == 1 assert result.hits[0].metadata["grade"] == "初一" assert result.hits[0].metadata["kb_id"] == "kb-edu" class TestFallbackStrategy: """ [AC-IDSMETA-20] Test fallback strategy with structured reason codes. """ def setup_method(self): self.fallback = FallbackStrategy() def test_no_intent_match_fallback(self): """No intent match should trigger fallback with reason code.""" result = self.fallback.execute( reason="NO_INTENT_MATCH", fallback_message="抱歉,我不太理解您的问题,请换种方式描述。", ) assert result["fallback_triggered"] is True assert result["reason_code"] == "intent_not_matched" assert result["fallback_type"] == "fixed" assert "不太理解" in result["fallback_content"] def test_no_retrieval_hits_fallback(self): """No retrieval hits should trigger fallback with reason code.""" result = self.fallback.execute( reason="NO_RETRIEVAL_HITS", fallback_kb_id="kb-general", ) assert result["fallback_triggered"] is True assert result["reason_code"] == "retrieval_empty" assert result["fallback_type"] == "kb" assert result["fallback_kb_id"] == "kb-general" def test_low_confidence_fallback(self): """Low confidence should trigger fallback with reason code.""" result = self.fallback.execute( reason="LOW_CONFIDENCE", fallback_message="我对这个回答不太确定,建议您咨询人工客服。", ) assert result["fallback_triggered"] is True assert result["reason_code"] == "confidence_below_threshold" assert result["fallback_type"] == "fixed" def test_metadata_filter_too_strict_fallback(self): """Too strict metadata filter should trigger fallback.""" result = self.fallback.execute( reason="METADATA_FILTER_TOO_STRICT", fallback_message="没有找到符合条件的答案,请尝试调整筛选条件。", ) assert result["fallback_triggered"] is True assert result["reason_code"] == "filter_excluded_all" def test_default_fallback(self): """Default fallback should be used when no specific fallback provided.""" result = self.fallback.execute(reason="NO_RETRIEVAL_HITS") assert result["fallback_triggered"] is True assert result["fallback_type"] == "default" assert "人工客服" in result["fallback_content"] class TestRoutingFilterRetrievalFallbackChain: """ [AC-IDSMETA-18, AC-IDSMETA-19, AC-IDSMETA-20] Test complete chain. """ @pytest.mark.asyncio async def test_full_chain_with_intent_match_and_retrieval(self): """Full chain: intent match -> metadata filter -> retrieval -> response.""" router = MockIntentRouter() retriever = MockRetriever(hits=[ MockRetrievalHit( text="退货需在7天内,商品未拆封", score=0.9, source="kb", metadata={"kb_id": "kb-return"}, ), ]) fallback = FallbackStrategy() rules = [ MockIntentRule( id="rule-1", name="退货", response_type="rag", target_kb_ids=["kb-return"], keywords=["退货"], priority=10, ) ] user_message = "我想退货" intent_result = router.match(user_message, rules) assert intent_result is not None assert intent_result.rule.response_type == "rag" retrieval_result = await retriever.retrieve( tenant_id="tenant-1", query=user_message, target_kb_ids=intent_result.rule.target_kb_ids, ) assert retrieval_result.hit_count > 0 assert not retrieval_result.is_empty @pytest.mark.asyncio async def test_full_chain_no_intent_match_fallback(self): """Full chain: no intent match -> fallback.""" router = MockIntentRouter() fallback = FallbackStrategy() rules = [ MockIntentRule( id="rule-1", name="退货", response_type="rag", keywords=["退货"], priority=10, ) ] user_message = "今天天气怎么样" intent_result = router.match(user_message, rules) assert intent_result is None fallback_result = fallback.execute( reason="NO_INTENT_MATCH", fallback_message="抱歉,我无法回答这个问题。", ) assert fallback_result["fallback_triggered"] is True assert fallback_result["reason_code"] == "intent_not_matched" @pytest.mark.asyncio async def test_full_chain_no_retrieval_hits_fallback(self): """Full chain: intent match -> no retrieval hits -> fallback.""" router = MockIntentRouter() retriever = MockRetriever(hits=[]) fallback = FallbackStrategy() rules = [ MockIntentRule( id="rule-1", name="退货", response_type="rag", target_kb_ids=["kb-return"], keywords=["退货"], priority=10, ) ] user_message = "退货流程是什么" intent_result = router.match(user_message, rules) assert intent_result is not None retrieval_result = await retriever.retrieve( tenant_id="tenant-1", query=user_message, target_kb_ids=intent_result.rule.target_kb_ids, ) assert retrieval_result.is_empty fallback_result = fallback.execute( reason="NO_RETRIEVAL_HITS", fallback_kb_id="kb-general", ) assert fallback_result["fallback_triggered"] is True assert fallback_result["reason_code"] == "retrieval_empty" @pytest.mark.asyncio async def test_full_chain_with_metadata_filter(self): """Full chain with metadata filter injection.""" router = MockIntentRouter() retriever = MockRetriever(hits=[ MockRetrievalHit( text="初一数学课程大纲", score=0.9, source="kb", metadata={"grade": "初一", "subject": "数学", "scene": "咨询", "kb_id": "kb-edu"}, ), MockRetrievalHit( text="初二数学课程大纲", score=0.85, source="kb", metadata={"grade": "初二", "subject": "数学", "scene": "咨询", "kb_id": "kb-edu"}, ), ]) fallback = FallbackStrategy() rules = [ MockIntentRule( id="rule-1", name="课程咨询", response_type="rag", target_kb_ids=["kb-edu"], keywords=["课程", "大纲"], priority=10, ) ] user_message = "初一数学课程大纲" session_metadata = {"grade": "初一", "subject": "数学", "scene": "咨询"} intent_result = router.match(user_message, rules) retrieval_result = await retriever.retrieve( tenant_id="tenant-1", query=user_message, target_kb_ids=intent_result.rule.target_kb_ids if intent_result else None, metadata_filter=session_metadata, ) assert retrieval_result.hit_count == 1 assert retrieval_result.hits[0].metadata["grade"] == "初一" class TestReasonCodeStructure: """ [AC-IDSMETA-20] Test structured reason codes for fallback. """ def test_reason_code_format(self): """Reason codes should follow snake_case format.""" fallback = FallbackStrategy() for reason_key, expected_code in FallbackStrategy.REASON_CODES.items(): result = fallback.execute(reason=reason_key) assert result["reason_code"] == expected_code assert "_" in expected_code or expected_code.islower() def test_reason_code_in_diagnostics(self): """Reason code should be included in diagnostics.""" fallback = FallbackStrategy() result = fallback.execute(reason="NO_RETRIEVAL_HITS") assert "reason_code" in result assert result["reason_code"] == "retrieval_empty" def test_unknown_reason_code(self): """Unknown reason should return 'unknown' code.""" fallback = FallbackStrategy() result = fallback.execute(reason="UNKNOWN_REASON") assert result["reason_code"] == "unknown"