from __future__ import annotations from types import SimpleNamespace import pytest import pytest_asyncio from sqlalchemy import JSON from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine from app.models.base import Base from app.models.integration import Integration from app.services.adaptive_rag_service.mixin_answer_retrieval import AdaptiveRAGAnswerRetrievalMixin from app.services.adaptive_rag_service.mixin_deep_guard_rules import AdaptiveRAGDeepGuardRulesMixin from app.services.adaptive_rag_service.shared_part_02 import RetrievedItem from app.services.external_query_service.adapters import MCPExternalAdapter from app.services.external_query_service.gateway import ExternalQueryGateway from app.services.external_query_service.registry import ExternalProviderRegistry from app.services.external_query_service.schemas import ExternalAdapterResult, ExternalEvidenceItem, ExternalQueryPlan from app.services.mcp_config import McpConfigService from app.services.mcp_config.runtime import DuckDuckGoSearchRuntime, MicrosoftLearnMCPRuntime def _use_sqlite_json_types(*tables) -> None: for table in tables: for column in table.columns: if column.type.__class__.__name__ in {"JSONB", "ARRAY", "Vector"}: column.type = JSON() @pytest_asyncio.fixture async def db_session(): engine = create_async_engine("sqlite+aiosqlite:///:memory:") tables = [Integration.__table__] _use_sqlite_json_types(*tables) async with engine.begin() as conn: await conn.run_sync(lambda sync_conn: Base.metadata.create_all(sync_conn, tables=tables)) maker = async_sessionmaker(engine, expire_on_commit=False) async with maker() as session: yield session await engine.dispose() async def _seed_backend_direct_server(db_session) -> None: service = McpConfigService(db_session, tenant_id=1) await service.create_server( name="microsoft-learn", display_name="Microsoft Learn MCP", template_id="microsoft_learn", params={}, ) await db_session.commit() async def _seed_duckduckgo_backend_direct_server(db_session) -> None: service = McpConfigService(db_session, tenant_id=1) await service.create_server( name="duckduckgo", display_name="DuckDuckGo MCP", template_id="duckduckgo", params={}, ) await db_session.commit() @pytest.mark.asyncio async def test_mcp_config_service_search_external_uses_backend_direct_server(db_session, monkeypatch) -> None: await _seed_backend_direct_server(db_session) async def _fake_search(self, *, query: str, result_limit: int = 3): # noqa: ANN001 _ = (self, query, result_limit) return [ ExternalEvidenceItem( source_type="mcp_search", source_label="Microsoft Learn MCP", title="Azure OpenAI quickstart", target_url="https://learn.microsoft.com/azure/ai-foundry/openai/chatgpt-quickstart", snippet="Official Microsoft quickstart.", retrieved_at="2026-04-15T12:30:00+08:00", freshness_score=0.9, authority_score=0.98, grounded_fields=["microsoft_docs_search"], raw_metadata={"provider": "microsoft_learn_mcp"}, ) ] monkeypatch.setattr(MicrosoftLearnMCPRuntime, "search", _fake_search) evidence = await McpConfigService(db_session, tenant_id=1).search_external( query="azure openai quickstart", result_limit=3, query_kind="model", ) assert len(evidence) == 1 assert evidence[0].source_type == "mcp_search" assert evidence[0].target_url == "https://learn.microsoft.com/azure/ai-foundry/openai/chatgpt-quickstart" @pytest.mark.asyncio async def test_mcp_config_service_search_external_uses_duckduckgo_backend_direct_for_generic_web( db_session, monkeypatch, ) -> None: await _seed_duckduckgo_backend_direct_server(db_session) async def _fake_search(self, *, query: str, result_limit: int = 3, query_kind: str = "unknown"): # noqa: ANN001 _ = (self, result_limit) assert query == "OpenAI latest news" assert query_kind == "generic_web" return [ ExternalEvidenceItem( source_type="mcp_search", source_label="DuckDuckGo Search", title="OpenAI ships new release", target_url="https://news.example/openai-release", snippet="Latest OpenAI release details.", retrieved_at="2026-04-16T20:10:00+08:00", freshness_score=0.8, authority_score=0.62, grounded_fields=["ddg-text-search"], raw_metadata={"provider": "duckduckgo_backend_direct"}, ) ] monkeypatch.setattr(DuckDuckGoSearchRuntime, "search", _fake_search) evidence = await McpConfigService(db_session, tenant_id=1).search_external( query="OpenAI latest news", result_limit=3, query_kind="generic_web", ) assert len(evidence) == 1 assert evidence[0].source_label == "DuckDuckGo Search" assert evidence[0].target_url == "https://news.example/openai-release" @pytest.mark.asyncio async def test_external_query_gateway_prefers_backend_direct_mcp_when_available(db_session, monkeypatch) -> None: await _seed_backend_direct_server(db_session) async def _fake_search_external(self, *, query: str, result_limit: int = 3, query_kind: str = "unknown"): # noqa: ANN001 _ = (self, query, result_limit, query_kind) return [ ExternalEvidenceItem( source_type="mcp_search", source_label="Microsoft Learn MCP", title="Azure OpenAI quickstart", target_url="https://learn.microsoft.com/azure/ai-foundry/openai/chatgpt-quickstart", snippet="Official Microsoft quickstart.", retrieved_at="2026-04-15T12:30:00+08:00", freshness_score=0.9, authority_score=0.98, grounded_fields=["microsoft_docs_search"], raw_metadata={"provider": "microsoft_learn_mcp"}, ) ] monkeypatch.setattr(McpConfigService, "search_external", _fake_search_external) class _FakeService(AdaptiveRAGAnswerRetrievalMixin, AdaptiveRAGDeepGuardRulesMixin): def __init__(self, db): self.db = db self.tenant_id = 1 self.llm = None service = _FakeService(db_session) rows, meta = await service._execute_external_query_gateway( question="Azure OpenAI quickstart", query_semantics={}, result_limit=3, max_pages=1, ) assert len(rows) == 1 assert isinstance(rows[0], RetrievedItem) assert rows[0].source == "mcp_search" assert rows[0].metadata["external_adapter"] == "mcp" assert meta["adapter"] == "mcp" @pytest.mark.asyncio async def test_external_query_gateway_returns_external_context_packet_in_meta() -> None: class _FakeService(AdaptiveRAGAnswerRetrievalMixin, AdaptiveRAGDeepGuardRulesMixin): def __init__(self) -> None: self.db = object() self.tenant_id = 1 self.llm = None async def _execute_external_mcp_search(self, plan): # noqa: ANN001 _ = plan return { "adapter_name": "mcp", "status": "unavailable", "evidence": [], "meta": {"reason": "skip_mcp"}, } def _build_live_external_search_queries(self, question: str) -> list[str]: return [question] async def _search_live_external_results(self, *, query: str, max_results: int = 5): # noqa: ANN001 _ = (query, max_results) return [{"url": "https://weather.example/zhanjiang-5day", "title": "湛江未来五天天气"}] async def _fetch_url_text(self, url: str) -> str: assert url == "https://weather.example/zhanjiang-5day" return "湛江未来5天有阵雨转多云,最高温33度,最低温26度。" def _chunk_text(self, text: str, *, chunk_size: int = 900, overlap: int = 120) -> list[str]: _ = (chunk_size, overlap) return [text] def _score_live_external_search_result(self, *, question: str, title: str, url: str) -> float: _ = (question, title, url) return 0.92 def _score_live_external_chunk(self, *, question: str, title: str, content: str, url: str) -> float: _ = (question, title, url) return 0.94 if "湛江未来5天" in content else 0.1 service = _FakeService() rows, meta = await service._execute_external_query_gateway( question="湛江市未来五天的天气", query_semantics={ "query_profile": {"needs_freshness": True}, "object_mentions": ["湛江市"], }, result_limit=3, max_pages=1, ) assert len(rows) == 1 packet = meta.get("external_context_packet") assert isinstance(packet, dict) assert packet["question"] == "湛江市未来五天的天气" assert packet["plan"]["answer_contract"] == "summary_with_sources" assert packet["verified_evidence"] assert packet["adapter_attempts"] @pytest.mark.asyncio async def test_external_query_gateway_consumes_active_external_query_slot_only_for_external_route(db_session) -> None: db_session.add( Integration( tenant_id=1, type="ai_capability_slots", config={ "active_version": "slot-v-external", "active": { "slots": { "external_query": { "primary_provider_id": "live_search", "fallback_provider_ids": ["mcp"], "verifier_required": True, "source_filtering_required": True, "failure_policy": "explicit_fail", } } }, "draft": {"slots": {}}, }, is_active=True, ) ) await db_session.commit() class _FakeService(AdaptiveRAGAnswerRetrievalMixin, AdaptiveRAGDeepGuardRulesMixin): def __init__(self, db): self.db = db self.tenant_id = 1 self.llm = None def _build_live_external_search_queries(self, question: str) -> list[str]: return [question] async def _search_live_external_results(self, *, query: str, max_results: int = 5): # noqa: ANN001 _ = (query, max_results) return [{"url": "https://news.example/openai-release", "title": "OpenAI latest release"}] async def _fetch_url_text(self, url: str) -> str: _ = url return "OpenAI latest release introduces public API updates." def _chunk_text(self, text: str, *, chunk_size: int = 900, overlap: int = 120) -> list[str]: _ = (chunk_size, overlap) return [text] def _score_live_external_search_result(self, *, question: str, title: str, url: str) -> float: _ = (question, title, url) return 0.92 def _score_live_external_chunk(self, *, question: str, title: str, content: str, url: str) -> float: _ = (question, title, url) return 0.94 if "OpenAI" in content else 0.1 service = _FakeService(db_session) external_rows, external_meta = await service._execute_external_query_gateway( question="OpenAI latest release", query_semantics={"route_mode": "external"}, result_limit=3, max_pages=1, ) assert len(external_rows) == 1 assert external_meta["slot_key"] == "external_query" assert external_meta["slot_version"] == "slot-v-external" assert external_meta["slot_runtime_source"] == "active_slot" assert external_meta["adapter"] == "live_search" internal_rows, internal_meta = await service._execute_external_query_gateway( question="OpenAI latest release", query_semantics={"route_mode": "internal"}, result_limit=3, max_pages=1, ) assert len(internal_rows) == 1 assert "slot_key" not in internal_meta @pytest.mark.asyncio async def test_external_query_gateway_falls_back_when_mcp_evidence_is_off_topic() -> None: plan = ExternalQueryPlan( plan_version="v1", query_kind="weather", answer_contract="summary_with_sources", freshness_required=True, topic="广州未来5天天气", subject_entities=["广州"], location_hint="广州", time_range_hint="未来5天", preferred_domains=[], search_queries=["广州 未来5天天气"], result_limit=3, requires_multi_source=False, clarify_on_ambiguity=False, ) class _Planner: async def build_plan(self, *, question: str, query_semantics: dict | None = None): _ = (question, query_semantics) return plan, {"planner": "test"} class _MCPAdapter: adapter_name = "mcp" async def execute(self, current_plan: ExternalQueryPlan) -> ExternalAdapterResult: _ = current_plan return ExternalAdapterResult( adapter_name="mcp", status="success", evidence=[ ExternalEvidenceItem( source_type="mcp_search", source_label="Microsoft Learn MCP", title="Azure Maps Weather services", target_url="https://learn.microsoft.com/azure/azure-maps/weather-services-concepts", snippet="Learn how Azure Maps Weather APIs expose forecast data models and request options.", retrieved_at="2026-04-15T12:30:00+08:00", freshness_score=0.82, authority_score=0.98, grounded_fields=["microsoft_docs_search"], raw_metadata={"provider": "microsoft_learn_mcp"}, ) ], meta={"provider": "mcp"}, ) class _LiveSearchAdapter: adapter_name = "live_search" async def execute(self, current_plan: ExternalQueryPlan) -> ExternalAdapterResult: _ = current_plan return ExternalAdapterResult( adapter_name="live_search", status="success", evidence=[ ExternalEvidenceItem( source_type="url", source_label="Weather Example", title="广州未来5天天气预报", target_url="https://weather.example/guangzhou-5day", snippet="广州未来5天有阵雨转多云,最高温31度,最低温24度。", retrieved_at="2026-04-15T12:35:00+08:00", freshness_score=0.91, authority_score=0.76, grounded_fields=["forecast"], raw_metadata={"provider": "live_search"}, ) ], meta={"provider": "live_search"}, ) execution = await ExternalQueryGateway( planner=_Planner(), registry=ExternalProviderRegistry(adapters=[_MCPAdapter(), _LiveSearchAdapter()]), ).execute( question="帮我看看未来5天的广州天气如何", query_semantics={"query_profile": {"needs_freshness": True}}, ) assert execution.adapter_name == "live_search" assert execution.meta["fallback_used"] is True assert execution.verifier["ok"] is True @pytest.mark.asyncio async def test_mcp_adapter_fetches_target_page_content_after_search_result() -> None: plan = ExternalQueryPlan( plan_version="v1", query_kind="weather", answer_contract="summary_with_sources", freshness_required=True, topic="湛江市未来五天天气", subject_entities=["湛江市"], location_hint="湛江市", time_range_hint="未来5天", preferred_domains=["weather.example"], search_queries=["湛江市未来五天天气预报"], result_limit=3, requires_multi_source=False, clarify_on_ambiguity=False, ) class _Planner: async def build_plan(self, *, question: str, query_semantics: dict | None = None): _ = (question, query_semantics) return plan, {"planner": "test"} async def _search_callable(current_plan: ExternalQueryPlan) -> ExternalAdapterResult: _ = current_plan return ExternalAdapterResult( adapter_name="mcp", status="success", evidence=[ ExternalEvidenceItem( source_type="mcp_search", source_label="Weather MCP", title="Forecast detail page", target_url="https://weather.example/forecast/zhanjiang-five-day", snippet="Landing page", retrieved_at="2026-04-16T01:05:00+08:00", freshness_score=0.6, authority_score=0.86, grounded_fields=["mcp_search"], raw_metadata={"provider": "weather_mcp"}, ) ], meta={"provider": "weather_mcp"}, ) async def _fetch_text(url: str) -> str: assert url == "https://weather.example/forecast/zhanjiang-five-day" return "页面说明。湛江未来5天有阵雨转多云,最高温33度,最低温26度。末尾。" def _chunk_text(text: str, *, chunk_size: int = 900, overlap: int = 120) -> list[str]: _ = (text, chunk_size, overlap) return ["页面说明。", "湛江未来5天有阵雨转多云,最高温33度,最低温26度。"] def _score_chunk(*, question: str, title: str, content: str, url: str) -> float: _ = (question, title, url) return 0.94 if "湛江未来5天" in content else 0.08 execution = await ExternalQueryGateway( planner=_Planner(), registry=ExternalProviderRegistry( adapters=[ MCPExternalAdapter( search_callable=_search_callable, fetch_text_callable=_fetch_text, chunk_text_callable=_chunk_text, score_chunk_callable=_score_chunk, max_pages=1, ) ] ), ).execute( question="湛江市未来五天的天气", query_semantics={"query_profile": {"needs_freshness": True}}, ) assert execution.adapter_name == "mcp" assert execution.verifier["ok"] is True assert execution.evidence[0].snippet == "湛江未来5天有阵雨转多云,最高温33度,最低温26度。" assert execution.evidence[0].raw_metadata["search_snippet"] == "Landing page" assert "page_content_snippet" in execution.evidence[0].grounded_fields @pytest.mark.asyncio async def test_external_query_gateway_accepts_bundle_with_normalized_anchor_match() -> None: plan = ExternalQueryPlan( plan_version="v1", query_kind="weather", answer_contract="summary_with_sources", freshness_required=True, topic="湛江市未来五天天气", subject_entities=["湛江市"], location_hint="湛江市", time_range_hint="未来5天", preferred_domains=["weather.com.cn", "nmc.cn"], search_queries=["湛江市未来五天天气预报"], result_limit=3, requires_multi_source=False, clarify_on_ambiguity=False, ) class _Planner: async def build_plan(self, *, question: str, query_semantics: dict | None = None): _ = (question, query_semantics) return plan, {"planner": "test"} class _LiveSearchAdapter: adapter_name = "live_search" async def execute(self, current_plan: ExternalQueryPlan) -> ExternalAdapterResult: _ = current_plan return ExternalAdapterResult( adapter_name="live_search", status="success", evidence=[ ExternalEvidenceItem( source_type="url", source_label="中国天气网", title="湛江天气预报,湛江7天天气预报,湛江15天天气预报,湛江天气查询", target_url="https://www.weather.com.cn/weather/101281001.shtml", snippet="湛江未来5天有阵雨转多云,最高温33度,最低温26度。", retrieved_at="2026-04-16T00:30:00+08:00", freshness_score=0.92, authority_score=0.95, grounded_fields=["forecast"], raw_metadata={"provider": "live_search"}, ) ], meta={"provider": "live_search"}, ) execution = await ExternalQueryGateway( planner=_Planner(), registry=ExternalProviderRegistry(adapters=[_LiveSearchAdapter()]), ).execute( question="湛江市未来五天的天气", query_semantics={"query_profile": {"needs_freshness": True}}, ) assert execution.adapter_name == "live_search" assert execution.verifier["ok"] is True @pytest.mark.asyncio async def test_external_query_gateway_prefers_weather_capable_adapter_before_doc_mcp() -> None: plan = ExternalQueryPlan( plan_version="v1", query_kind="weather", answer_contract="summary_with_sources", freshness_required=True, topic="湛江市未来五天天气", subject_entities=["湛江市"], location_hint="湛江市", time_range_hint="未来5天", preferred_domains=["weather.com.cn", "nmc.cn"], search_queries=["湛江市未来五天天气预报"], result_limit=3, requires_multi_source=False, clarify_on_ambiguity=False, ) call_order: list[str] = [] class _Planner: async def build_plan(self, *, question: str, query_semantics: dict | None = None): _ = (question, query_semantics) return plan, {"planner": "test"} class _MCPAdapter: adapter_name = "mcp" supported_query_kinds = {"model", "company", "generic_web", "unknown"} async def execute(self, current_plan: ExternalQueryPlan) -> ExternalAdapterResult: _ = current_plan call_order.append("mcp") return ExternalAdapterResult( adapter_name="mcp", status="success", evidence=[ ExternalEvidenceItem( source_type="mcp_search", source_label="Microsoft Learn MCP", title="Azure Maps Weather service coverage", target_url="https://learn.microsoft.com/azure/azure-maps/weather-coverage", snippet="Weather service coverage.", retrieved_at="2026-04-16T00:30:00+08:00", freshness_score=0.82, authority_score=0.98, grounded_fields=["microsoft_docs_search"], raw_metadata={"provider": "mcp"}, ) ], meta={"provider": "mcp"}, ) class _LiveSearchAdapter: adapter_name = "live_search" supported_query_kinds = {"weather", "news", "market", "generic_web", "unknown"} async def execute(self, current_plan: ExternalQueryPlan) -> ExternalAdapterResult: _ = current_plan call_order.append("live_search") return ExternalAdapterResult( adapter_name="live_search", status="success", evidence=[ ExternalEvidenceItem( source_type="url", source_label="中国天气网", title="湛江天气预报,湛江7天天气预报,湛江15天天气预报,湛江天气查询", target_url="https://www.weather.com.cn/weather/101281001.shtml", snippet="湛江未来5天有阵雨转多云,最高温33度,最低温26度。", retrieved_at="2026-04-16T00:30:00+08:00", freshness_score=0.92, authority_score=0.95, grounded_fields=["forecast"], raw_metadata={"provider": "live_search"}, ) ], meta={"provider": "live_search"}, ) execution = await ExternalQueryGateway( planner=_Planner(), registry=ExternalProviderRegistry(adapters=[_MCPAdapter(), _LiveSearchAdapter()]), ).execute( question="湛江市未来五天的天气", query_semantics={"query_profile": {"needs_freshness": True}}, ) assert call_order == ["live_search"] assert execution.adapter_name == "live_search" assert execution.verifier["ok"] is True