# External Query Gateway Design ## Goal 为 non-plan `query.answer` 的外部实时查询建立一条独立、受控、可审计的执行主链:`Query Route Gateway` 只负责判到 `route_mode=external`,后续统一进入 `External Query Gateway`,由大模型协助产出结构化查询计划,再交给受控 adapter 拉取外部证据,最后由 verifier 校验来源合同与 freshness 合同后才能出站。 ## Why Now 当前外部查询已经能在路由层识别 freshness/public 问题,但执行层还没有真正独立出来。 现在的实际链路是: 1. `Query Route Gateway` 通过 `query_profile.needs_freshness + referenced_objects` 判定 `route_mode=external` 2. `AdaptiveRAGAnswerRetrievalMixin._maybe_fetch_live_external_freshness_context()` 直接调用 `_retrieve_live_external_context()` 3. `_retrieve_live_external_context()` 再走 `_search_live_external_results()`,本质是 DuckDuckGo HTML 搜索 + 抓网页正文 + chunk 打分 4. 若没有可靠 `url/http(s)` 证据,就返回 External Freshness Guard 这条链的问题不是“少了天气 provider”,而是“没有通用外部执行层”。 另外,当前来源过滤还有一处明确 bug: - `_filter_sources_for_route()` 会在 external 过滤结果为空时 `return filtered or list(sources or [])` - 这意味着外部回答如果没筛出 `url/system`,会把原始 `feishu/doc/pmflow` sources 兜回去 - 所以你截图里的 `外部实时查询` 下出现 `来源摘要:Feishu history` 不是展示偶发,而是现有来源合同的 fail-open 缺口 ## Confirmed Baseline - 不做天气专用 provider,不按单个场景堆专用接入。 - 外部查询的第一实现是通用 `External Query Gateway`,`MCP` 作为这层的第一个 adapter。 - `Query Route Gateway` 继续保留;external 判定后不再直接调用 DuckDuckGo HTML 抓取。 - 大模型只负责把问题整理成 `ExternalQueryPlan`,不直接自由回答外部事实。 - 外部回答必须只引用 adapter 返回的外部证据;内部历史、项目图谱、Feishu history、PMFlow records 不得再混进外部来源摘要。 - 现有 DuckDuckGo HTML 抓取降级为 bounded fallback,不再是主执行层。 ## Scope ### In Scope - `route_mode=external` 的 non-plan `query.answer` - 结构化 `ExternalQueryPlan` - `External Query Gateway` 与 adapter 合同 - `MCP` 作为第一外部 adapter - 外部来源合同、freshness 合同、answer verifier - 现有 DuckDuckGo HTML 检索的降级收口 ### Out Of Scope - internal DSL / semantic facts 主链 - requirement-plan / followup / draft confirm - 针对天气、股价、体育等逐类 provider 拆分 - 自由 browser agent、多步网页操作 agent - 自动把外部事实反写入内部库 ## Current State ### 1. 路由层已经能判 external `backend/app/services/adaptive_rag_service/mixin_query_route_gateway.py` - `_EXTERNAL_ROUTE_OBJECTS` 已包含 `public_model / public_company / weather / market_data / sports_event / news_topic` - `_run_external_route_probe()` 在 `needs_freshness` 或命中 external object 时会先尝试 live external retrieval ### 2. 执行层仍然是 HTML 搜索抓取 `backend/app/services/adaptive_rag_service/mixin_conversation_utils_a_p1.py` - `_search_live_external_results()` 直接请求 `https://html.duckduckgo.com/html/?q=...` - `_retrieve_live_external_context()` 基于搜索结果抓取网页正文、切块、打分后返回 `RetrievedItem` ### 3. freshness guard 只看是否拿到外部 grounding `backend/app/services/adaptive_rag_service/mixin_deep_guard_rules.py` - `_has_external_grounding_sources()` 目前只把 `url/http(s)` 视为外部 grounding - 没拿到这类证据,就返回“当前未接入实时外部数据...” ### 4. 来源过滤当前是 fail-open `backend/app/services/adaptive_rag_service/mixin_query_route_gateway.py` - `_EXTERNAL_ALLOWED_SOURCES = {"url", "system"}` - 但 `_filter_sources_for_route()` 在过滤后为空时会退回原始 sources - 这会让 `Feishu history`、`doc`、`pmflow` 在 external 回答里重新出现 ## Design Decision 外部实时查询主链改成: 1. `Query Route Gateway` 2. `External Query Planner` 3. `External Query Gateway` 4. `External Provider Registry` 5. `MCPExternalAdapter` 6. `bounded fallback live search` 7. `External Answer Verifier` 8. `reply_contract renderer` 这里真正让大模型参与的是 `External Query Planner`,不是让模型自由联网,也不是让模型自由写查询脚本。 ## ExternalQueryPlan Contract `ExternalQueryPlan` 建议统一为单一结构化对象: | 字段 | 说明 | | --- | --- | | `plan_version` | 版本号 | | `query_kind` | `weather / news / company / model / market / generic_web / unknown` | | `answer_contract` | `direct_fact / summary_with_sources / compare_with_sources / clarify` | | `freshness_required` | 是否要求最新信息 | | `topic` | 用户真正要查的主题 | | `subject_entities[]` | 人、公司、产品、地点等显式对象 | | `location_hint` | 如“广州” | | `time_range_hint` | 如“未来5天”“截至今天” | | `preferred_domains[]` | 官方站点或高可信站点偏好 | | `search_queries[]` | adapter 可直接执行的查询短语 | | `result_limit` | 证据数量上限 | | `requires_multi_source` | 是否要求多来源交叉 | | `clarify_on_ambiguity` | 歧义时是否先澄清 | 示例:用户问“帮我看看未来 5 天的广州天气如何”。 ```json { "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天天气", "广州 天气 5日预报" ], "result_limit": 3, "requires_multi_source": false, "clarify_on_ambiguity": false } ``` ## Large-Model Assist Boundary 这条链路里大模型只做三件事: 1. 从自然语言问题里提炼 `topic / entities / time_range / location / answer_contract` 2. 归一化成受控 `ExternalQueryPlan` 3. 在证据已拿到后做 grounded answer composition 大模型不能做的事: - 不能直接决定“我觉得这个网站可信就算” - 不能在没证据时直接口头给外部结论 - 不能绕过 plan 直接调用任意工具 - 不能把内部历史当外部来源 ## External Provider Registry 建议新增统一 registry: - `MCPExternalAdapter` - `LiveSearchFallbackAdapter` 选择规则: 1. 优先走 `MCPExternalAdapter` 2. `MCP` 不可用、超时或返回空证据时,再走 bounded fallback 3. fallback 只负责补救,不负责承担主链语义 这里不做 `WeatherProvider / StockProvider / NewsProvider` 这种逐类 provider 拆分;`query_kind` 只是 plan 信号,不是 provider 数量膨胀入口。 ## MCPExternalAdapter Contract 输入:`ExternalQueryPlan` 输出:`ExternalEvidenceBundle` | 字段 | 说明 | | --- | --- | | `source_type` | 固定外部类型,如 `url` / `mcp_search` | | `source_label` | 给用户展示的来源名 | | `title` | 外部结果标题 | | `target_url` | 原始 URL | | `snippet` | 可引用摘要 | | `retrieved_at` | 抓取时间 | | `freshness_score` | 时效性评分 | | `authority_score` | 来源可信度评分 | | `grounded_fields` | 当前证据支持了哪些字段 | | `raw_metadata` | adapter 私有元信息 | 上层只认 `ExternalEvidenceBundle`,不认 MCP 的私有响应格式。 ## Mandatory Execution Flow external 问答统一走: 1. `Query Route Gateway` 产出 `route_mode=external` 2. `External Query Planner` 生成 `ExternalQueryPlan` 3. `External Query Gateway` 校验 plan schema、补充默认 limit/timeout 4. `External Provider Registry` 选择 adapter 5. `MCPExternalAdapter` 执行并返回 `ExternalEvidenceBundle` 6. 若证据不足,再走 `LiveSearchFallbackAdapter` 7. `External Answer Verifier` 校验来源与 freshness 合同 8. `reply_contract` 渲染 `外部实时查询:` ## Source Contract 外部回答必须遵守下面四条: 1. `sources` 只允许 external adapter 返回的 `url/http(s)` 证据进入来源摘要 2. external 过滤必须 fail-closed,过滤空了就返回空,不得把原始 `feishu/doc/pmflow` sources 放回去 3. 外部回答正文不得引用内部历史作为“来源依据” 4. `来源摘要` 与 answer grounding 必须来自同一次 external execution 这意味着本轮必须把 `_filter_sources_for_route()` 从 fail-open 改成 fail-closed。 ## Failure Semantics - planner 结构化失败:显式 `clarify_or_fail` - MCP 超时或不可用:进入 bounded fallback - fallback 仍无外部 grounding:返回 freshness guard - verifier 发现来源合同违背:拒绝出站 - 只有拿到外部 evidence bundle,才允许产出 grounded external answer ## Rollout ### Phase A 先确认并写入基线文档,不动运行时代码。 ### Phase B 落地 `ExternalQueryPlan + External Query Gateway + MCPExternalAdapter`,把 DuckDuckGo HTML 抓取降为 fallback。 ### Phase C 补 `external answer verifier + fail-closed source contract`,止住 `Feishu history` 串源。 ## Planned File Changes ### 新增模块 - `backend/app/services/external_query_service/__init__.py` - `backend/app/services/external_query_service/schemas.py` - `backend/app/services/external_query_service/gateway.py` - `backend/app/services/external_query_service/planner.py` - `backend/app/services/external_query_service/registry.py` - `backend/app/services/external_query_service/adapters/mcp.py` - `backend/app/services/external_query_service/adapters/live_search.py` ### 运行时接入 - `backend/app/config/ai_prompts.py` - `backend/app/config/ai_prompts_catalog/` - `backend/app/services/adaptive_rag_service/mixin_answer_retrieval.py` - `backend/app/services/adaptive_rag_service/mixin_query_route_gateway.py` - `backend/app/services/adaptive_rag_service/mixin_answer_payload.py` - `backend/app/services/adaptive_rag_service/mixin_deep_guard_rules.py` - `backend/app/services/adaptive_rag_service/mixin_conversation_utils_a_p1.py` ### 回归测试 - `backend/tests/test_external_query_gateway.py` - `backend/tests/test_external_query_planner.py` - `backend/tests/test_external_query_source_contract.py` - `backend/tests/test_adaptive_rag_external_freshness_contract.py` ### 文档基线 - `AGENTS.md` - `docs/architecture/RUNTIME_GUARDRAILS.md` - `docs/architecture/ARCHITECTURE_INVENTORY.md` - `docs/superpowers/specs/2026-04-15-external-query-gateway-design.md`