ai-robot-core/ai-service/tests/test_metadata_governance_in...

676 lines
22 KiB
Python
Raw Normal View History

"""
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"