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

676 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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