275 lines
10 KiB
Markdown
275 lines
10 KiB
Markdown
# 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`
|