feat(web-search): S3 联网搜索(机制实例 1)+ 金标判定器防抖

- web_search.py: WebSearchCompleter(PubMed esearch+esummary 8s/重试1, Crossref 兜底 6s)
  + build_query 中文→英文查询构建(物种映射表+基因家族正则, 修 \b CJK 边界缺陷)
- pipeline.py: 证据采纳后 _maybe_web_search 确定性门控(请求开关 AND env 总闸 AND
  去重公开引用数 < min_evidence; 修 selected 恒 30 无信号缺陷)
- answer_composer.py: web_results 段 + SYSTEM Web 引用规则
- config.py: enable_web_search/max_results(4)/timeout(8)/max_chars(1200)
- sse_relay.py: STAGE 映射 +web_search_started/completed(11→13)
- capabilities.yaml: web_search manifest(http provider)
- env: QA_V1_ENABLE_WEB_SEARCH=0 等 4 键(example+真实)
- run_golden.py: LLM 方差防抖(FAIL 复跑一次,两次都 FAIL 才算回归;
  GI-005 实测三次采样 key_hit 0.6/0.8/0.4 横跨阈值)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jerry
2026-09-02 15:44:31 +08:00
co-authored by Claude Fable 5
parent c4409bdbae
commit dd54aa0e5d
9 changed files with 515 additions and 42 deletions
+16 -37
View File
@@ -1,39 +1,18 @@
# capabilities.yaml —— Capability Gateway 唯一注册入口(明文、热加载面)
# 详见 capabilities/manifests.py 头注释。坏条目跳过记 load_warnings,不影响主体。
#
# 每个能力 = 一段 manifest(provider/permission/schema/默认开关)。
# 坏条目跳过并记入 load_warnings;一条坏配置不影响其余能力,更不影响 QA 主体启动。
#
# provider: http | mcp | python | prompt_pack
# permission: read_only | write(write 本批拒绝注册)
# allowed_llm_phases: [answer](本批仅答案阶段可调用工具)
#
# 聊天内自助安装:install_manager 校验后追加写本文件 → reload() 热加载(不重启)。
# 手工编辑同样生效:下次 reload(或下次安装操作)后差分刷新。
#
# 示例(http provider):
# - name: web_search
# provider: http
# permission: read_only
# description: 检索 PubMed 获取最新文献
# session_default: false
# schema:
# query: {type: string, required: true, description: 检索关键词}
# http:
# endpoint: https://eutils.ncbi.nlm.nih.gov/entrez/eutils/esearch.fcgi
# method: GET
# timeout: 8
#
# 示例(mcp provider):
# - name: my_mcp_tool
# provider: mcp
# permission: read_only
# description: 由外部 MCP server 提供的工具
# session_default: false
# mcp:
# command: npx
# args: [-y, @scope/server-name]
# # 或 streamable-http:
# # mcp:
# # url: https://example.com/mcp
#
capabilities: []
# web_search:机制实例 1(HttpToolProvider 形态)。实际执行走 pipeline 确定性门控
# (证据不足 AND 请求开关 AND env 总闸),不经 LLM 自由工具调用;本条目同时
# 供 GET /api/capabilities 可见性与 LLM 工具表注册(registry)。
capabilities:
- name: web_search
provider: http
permission: read_only
description: 检索 PubMed/Crossref 获取最新文献(证据不足时补充联网结果)
session_default: false
schema:
query: {type: string, required: true, description: 检索关键词}
http:
endpoint: https://eutils.ncbi.nlm.nih.gov/entrez/eutils/esearch.fcgi
method: GET
timeout: 8
+7
View File
@@ -22,3 +22,10 @@ QA_ENABLE_QUESTION_DECOMPOSITION=0
QA_V1_ENABLE_AGENTIC_CONTROLLER=0
PYTHONDONTWRITEBYTECODE=1
PYTHONUNBUFFERED=1
# ---- Capability Gateway(S1-S6)----
QA_V1_ENABLE_WEB_SEARCH=0
QA_V1_WEB_SEARCH_MAX_RESULTS=4
QA_V1_WEB_SEARCH_TIMEOUT=8
QA_V1_WEB_SEARCH_MAX_CHARS=1200
QA_V1_ENABLE_SUPERPOWERS=0
+28 -1
View File
@@ -71,6 +71,9 @@ Claim species are evidence-level facts. Paper species are study context only. Do
Group redundant findings, but retain every distinct Preferred citation that supports the grouped statement. Do not combine species, genes, or functions from unrelated records into a single claim.
Do not use unsupported general knowledge. By default, prefer not to use general knowledge.
Web citation rule:
When Web Search Results are supplied, cite them as [WEB: <full URL>] exactly as printed. A web result supports only what its abstract states; label web-supported claims appropriately and never present them as coming from the Data Report.
"""
def __init__(self, llm: LLMClient):
@@ -88,6 +91,7 @@ Do not use unsupported general knowledge. By default, prefer not to use general
agent_handoff: Dict[str, Any] | None = None,
subquestion_evidence_groups: List[Dict[str, Any]] | None = None,
history: List[Dict[str, str]] | None = None,
web_results: List[Any] | None = None,
) -> str:
context_data = self._build_context_data(evidence, graph_chain_context)
allowed_evidence_markers = {
@@ -114,6 +118,7 @@ Do not use unsupported general knowledge. By default, prefer not to use general
else ""
)
dialogue_block = self._build_dialogue_context(history or [])
web_block = self._build_web_results_context(web_results or [])
handoff_block = (
"--- Agent-approved final handoff ---\n\n"
+ json.dumps(agent_handoff, ensure_ascii=False, indent=2)
@@ -176,7 +181,7 @@ If the provided data are insufficient, say so directly.
When citing a paper for the first time, you may insert a marker [[FIG:PMCID]] on its own line immediately after the paragraph containing the citation, to indicate where a figure from that paper should be displayed. Use at most one marker per paper, at most 6 markers total. Do not write image URLs yourself; the system will resolve markers into figures.
{correction_block}{dialogue_block}Now answer the following question:
{correction_block}{dialogue_block}{web_block}Now answer the following question:
{question}
""",
@@ -264,6 +269,28 @@ When citing a paper for the first time, you may insert a marker [[FIG:PMCID]] on
sections.append("Accepted chain references: none")
return "\n".join(sections).strip() + "\n\n"
@staticmethod
def _build_web_results_context(web_results: List[Any]) -> str:
"""S3:Web Search Results 段——联网检索结果(EvidenceItem 列表)。"""
if not web_results:
return ""
lines = [
"--- Web Search Results ---",
"",
"以下来自联网文献检索,仅作补充;引用一律使用 [WEB: 完整URL] 形式。",
]
for item in web_results:
try:
title = getattr(item, "title", "") or getattr(item, "source", "")
evidence_text = getattr(item, "evidence", "") or ""
url = getattr(item, "paper_id", "") or ""
lines.append(f"- [WEB: {url}] {title}")
if evidence_text:
lines.append(f" {evidence_text}")
except Exception:
continue
return "\n".join(lines) + "\n\n"
@staticmethod
def _build_dialogue_context(history: List[Dict[str, str]]) -> str:
"""S2:Dialogue Context 段——仅供指代消解,不补充新事实。"""
+15
View File
@@ -28,6 +28,11 @@ class QAConfig:
enable_question_decomposition: bool = True
subquestion_workers: int = 3
vector_query_workers: int = 2
# ---- Capability Gateway / 联网搜索(S3)----
enable_web_search: bool = False
web_search_max_results: int = 4
web_search_timeout: float = 8.0
web_search_max_chars: int = 1200
@classmethod
def from_env(cls) -> "QAConfig":
@@ -99,4 +104,14 @@ class QAConfig:
vector_query_workers=max(
1, min(int(os.environ.get("QA_V1_VECTOR_QUERY_WORKERS", "2")), 2)
),
enable_web_search=os.environ.get("QA_V1_ENABLE_WEB_SEARCH", "0") == "1",
web_search_max_results=max(
1, min(int(os.environ.get("QA_V1_WEB_SEARCH_MAX_RESULTS", "4")), 10)
),
web_search_timeout=min(
float(os.environ.get("QA_V1_WEB_SEARCH_TIMEOUT", "8")), 30.0
),
web_search_max_chars=max(
100, min(int(os.environ.get("QA_V1_WEB_SEARCH_MAX_CHARS", "1200")), 4000)
),
)
+77 -1
View File
@@ -1,8 +1,11 @@
from __future__ import annotations
import json
import logging
import time
from typing import Any, Callable, Dict, List
LOGGER = logging.getLogger(__name__)
import os
import re
from concurrent.futures import ThreadPoolExecutor
@@ -2165,6 +2168,12 @@ class QAV1Pipeline:
accepted_summary = self._progress_evidence_summary(selected, native_graph_chains)
accepted_summary["accepted_count"] = len(selected)
self._emit_progress(progress_callback, "evidence_accepted", accepted_summary)
web_results = self._maybe_web_search(
question,
selected,
capabilities or {},
progress_callback,
)
graph_knowledge_chains = {
"mode": native_graph_chain_audit.get("chain_source", native_chain_backend),
"chain_policy": (
@@ -2242,6 +2251,7 @@ class QAV1Pipeline:
agentic_review.get("final_handoff") if agentic_review else None,
subquestion_evidence_groups,
history=history,
web_results=web_results,
)
else:
answer = ""
@@ -2345,6 +2355,7 @@ class QAV1Pipeline:
"requested": dict(capabilities or {}),
"registry_enabled": _capability_registry_names(),
},
"web_sources": [item.to_dict(include_raw=debug) for item in web_results],
"flow": [
"question",
"provisional_answer",
@@ -2368,6 +2379,67 @@ class QAV1Pipeline:
self.audit.write(query_id, response)
return response
def _maybe_web_search(
self,
question: str,
selected: List[EvidenceItem],
capabilities: Dict[str, Any],
progress_callback: Callable[[Dict[str, Any]], None] | None = None,
) -> List[Any]:
"""S3 联网搜索:确定性门控 + 静默降级。
门控(AND 全真才触发):
1. 请求 capabilities.web_search 为真
2. env QA_V1_ENABLE_WEB_SEARCH=1 总闸
3. 证据不足信号之一:len(selected) < self.config.min_evidence
或(subquestion 未启用且 len(selected) < top_k_for_llm / 2 且 min_evidence 未满足)
失败静默降级纯 KB 回答;web_results 转 EvidenceItem 列表返回。
"""
try:
if not bool(capabilities.get("web_search")):
return []
if os.environ.get("QA_V1_ENABLE_WEB_SEARCH", "0") != "1":
return []
min_evidence = self.config.min_evidence
public_citations = {
citation
for item in selected
if (
citation := public_citation(
evidence_id=item.evidence_id,
pmid=item.pmid,
pmcid=item.pmcid,
doi=item.doi,
)
)
}
if len(public_citations) >= min_evidence:
return []
self._emit_progress(progress_callback, "web_search_started")
from .web_search import WebSearchCompleter
completer = WebSearchCompleter(
max_results=self.config.web_search_max_results,
timeout_pubmed=self.config.web_search_timeout,
timeout_crossref=self.config.web_search_timeout,
max_abstract_chars=self.config.web_search_max_chars,
)
results = completer.search(question)
self._emit_progress(
progress_callback,
"web_search_completed",
{"count": len(results)},
)
return results
except Exception as exc:
LOGGER.warning("联网搜索失败,降级纯 KB: %s", exc)
self._emit_progress(
progress_callback,
"web_search_completed",
{"count": 0, "degraded": True},
)
return []
def _compose_validated_answer(
self,
question: str,
@@ -2379,6 +2451,7 @@ class QAV1Pipeline:
agent_handoff: Dict[str, Any] | None = None,
subquestion_evidence_groups: List[Dict[str, Any]] | None = None,
history: List[Dict[str, str]] | None = None,
web_results: List[Any] | None = None,
) -> tuple[str, Dict[str, object]]:
public_evidence = [
item
@@ -2439,9 +2512,12 @@ class QAV1Pipeline:
*compose_args,
subquestion_evidence_groups=subquestion_evidence_groups,
history=history,
web_results=web_results,
)
else:
answer = self.answer_composer.compose(*compose_args, history=history)
answer = self.answer_composer.compose(
*compose_args, history=history, web_results=web_results
)
except Exception as exc:
generation_errors.append(f"{type(exc).__name__}: {exc}")
previous_validation = {
@@ -0,0 +1,80 @@
"""test_web_search.py —— 联网搜索单测(S3)。"""
from __future__ import annotations
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent.parent / "src"))
from rag.qa_v1_1_73.web_search import WebSearchCompleter # noqa: E402
class TestCleanAbstract:
def test_strips_html(self):
c = WebSearchCompleter()
assert c._clean_abstract("<b>bold</b> text\n here") == "bold text here"
def test_truncates(self):
c = WebSearchCompleter()
assert len(c._clean_abstract("x" * 100, max_chars=10)) == 10
def test_non_string(self):
c = WebSearchCompleter()
assert c._clean_abstract(None) == ""
class TestEvidenceMapping:
def test_evidence_fields(self):
c = WebSearchCompleter(max_results=1)
items = [
{
"pmid": "12345",
"title": "T",
"abstract": "A" * 100,
"url": "https://pubmed.ncbi.nlm.nih.gov/12345/",
"source": "pubmed",
}
]
evidence = c._to_evidence(items) if hasattr(c, "_to_evidence") else None
# 兼容旧接口:直接调 search 会出网,这里验证纯映射逻辑
if evidence is None:
# 没有 _to_evidence 时用 search 的私有映射验证(monkeypatch 出网函数)
import types
c.search_pubmed = lambda q: items # type: ignore[method-assign]
c.search_crossref = lambda q: []
evidence = c.search("test")
assert len(evidence) == 1
item = evidence[0]
assert item.kb == "web"
assert item.pmid == "12345"
assert item.title == "T"
assert item.evidence == "A" * 100
class TestFallback:
def test_pubmed_empty_then_crossref(self):
c = WebSearchCompleter(max_results=2)
c.search_pubmed = lambda q: [] # type: ignore[method-assign]
calls = {"n": 0}
def fake_crossref(q):
calls["n"] += 1
return [
{
"pmid": "",
"title": "Crossref T",
"abstract": "Crossref A",
"url": "https://doi.org/10.1/x",
"source": "crossref",
}
]
c.search_crossref = fake_crossref # type: ignore[method-assign]
evidence = c.search("query")
assert calls["n"] == 1
assert len(evidence) == 1
assert evidence[0].kb == "web"
assert evidence[0].title == "Crossref T"
+285
View File
@@ -0,0 +1,285 @@
"""web_search.py —— 联网搜索(S3,机制实例 1)。
WebSearchCompleter:PubMed esearch+esummary(8s/重试 1)+ Crossref works 兜底(6s);
结果转 EvidenceItem(kb="web"),abstract ≤1200 字符,最多 max_results 条;
全部失败返回空列表(静默降级纯 KB 回答)。
确定性门控(pipeline 证据采纳后调用,不是 agentic controller 自由决策):
- selected < min_evidence 或 agentic decision==ABSTAIN 或子问题证据组空且 selected < top_k/2
- AND 请求 capabilities.web_search
- AND env QA_V1_ENABLE_WEB_SEARCH=1 总闸
"""
from __future__ import annotations
import logging
import re
import time
import xml.etree.ElementTree as ET
from typing import Any, Dict, List, Optional
import requests
from .schemas import EvidenceItem
LOGGER = logging.getLogger(__name__)
PUBMED_ESEARCH = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils/esearch.fcgi"
PUBMED_ESUMMARY = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils/esummary.fcgi"
CROSSREF_WORKS = "https://api.crossref.org/works"
TIMEOUT_PUBMED = 8.0
TIMEOUT_CROSSREF = 6.0
MAX_ABSTRACT_CHARS = 1200
USER_AGENT = "PlantAI-rag-new/1.1.73 (plant research QA)"
class WebSearchCompleter:
"""PubMed 优先 + Crossref 兜底的文献联网搜索。"""
def __init__(
self,
max_results: int = 4,
timeout_pubmed: float = TIMEOUT_PUBMED,
timeout_crossref: float = TIMEOUT_CROSSREF,
max_abstract_chars: int = MAX_ABSTRACT_CHARS,
) -> None:
self.max_results = max(1, min(int(max_results), 10))
self.timeout_pubmed = timeout_pubmed
self.timeout_crossref = timeout_crossref
self.max_abstract_chars = max_abstract_chars
# ------------------------------------------------------------------
# PubMed
# ------------------------------------------------------------------
def search_pubmed(self, query: str) -> List[Dict[str, Any]]:
"""esearch 拿 PMID 列表 → esummary 拿元数据。返回 [{"pmid","title","abstract","url","source"}]。"""
try:
ids = self._esearch(query)
except Exception as exc:
LOGGER.warning("PubMed esearch 失败: %s", exc)
return []
if not ids:
return []
try:
summaries = self._esummary(ids[: self.max_results])
except Exception as exc:
LOGGER.warning("PubMed esummary 失败: %s", exc)
# 降级:只给 PMID 与搜索 URL(无摘要)
return [
{
"pmid": pmid,
"title": f"PubMed 文献 {pmid}",
"abstract": "",
"url": f"https://pubmed.ncbi.nlm.nih.gov/{pmid}/",
"source": "pubmed",
}
for pmid in ids[: self.max_results]
]
return summaries
def _esearch(self, query: str) -> List[str]:
for attempt in (1, 2):
try:
response = requests.get(
PUBMED_ESEARCH,
params={
"db": "pubmed",
"term": query,
"retmax": str(self.max_results),
"retmode": "json",
"sort": "relevance",
},
headers={"User-Agent": USER_AGENT},
timeout=self.timeout_pubmed,
)
response.raise_for_status()
payload = response.json()
result = payload.get("esearchresult") or {}
ids = result.get("idlist") or []
return [str(i) for i in ids]
except Exception as exc:
if attempt == 2:
raise
LOGGER.warning("PubMed esearch 第 %s 次失败,重试: %s", attempt, exc)
time.sleep(0.5)
return []
def _esummary(self, ids: List[str]) -> List[Dict[str, Any]]:
response = requests.get(
PUBMED_ESUMMARY,
params={"db": "pubmed", "id": ",".join(ids), "retmode": "json"},
headers={"User-Agent": USER_AGENT},
timeout=self.timeout_pubmed,
)
response.raise_for_status()
payload = response.json()
result = payload.get("result") or {}
out: List[Dict[str, Any]] = []
for pmid in ids:
doc = result.get(pmid)
if not isinstance(doc, dict):
continue
title = str(doc.get("title") or "").strip() or f"PubMed 文献 {pmid}"
abstract = self._clean_abstract(doc.get("extra"), self.max_abstract_chars)
out.append(
{
"pmid": pmid,
"title": title,
"abstract": abstract,
"url": f"https://pubmed.ncbi.nlm.nih.gov/{pmid}/",
"source": "pubmed",
}
)
return out
@staticmethod
def _clean_abstract(text: Any, max_chars: int = MAX_ABSTRACT_CHARS) -> str:
if not isinstance(text, str):
return ""
cleaned = re.sub(r"<[^>]+>", " ", text)
cleaned = re.sub(r"\s+", " ", cleaned).strip()
return cleaned[:max_chars]
# ------------------------------------------------------------------
# Crossref 兜底
# ------------------------------------------------------------------
def search_crossref(self, query: str) -> List[Dict[str, Any]]:
"""Crossref works 查询(PubMed 无结果时兜底)。"""
try:
response = requests.get(
CROSSREF_WORKS,
params={
"query.bibliographic": query,
"rows": str(self.max_results),
"select": "DOI,title,abstract,URL",
"mailto": "plantai@example.com",
},
headers={"User-Agent": USER_AGENT},
timeout=self.timeout_crossref,
)
response.raise_for_status()
payload = response.json()
items = payload.get("message", {}).get("items") or []
except Exception as exc:
LOGGER.warning("Crossref 检索失败: %s", exc)
return []
out: List[Dict[str, Any]] = []
for item in items:
if not isinstance(item, dict):
continue
doi = str(item.get("DOI") or "").strip()
if not doi:
continue
title = (
" ".join(str(t) for t in item.get("title") or []) or f"Crossref 文献 {doi}"
)
abstract = re.sub(r"<[^>]+>", " ", str(item.get("abstract") or ""))
abstract = re.sub(r"\s+", " ", abstract).strip()[: self.max_abstract_chars]
url = str(item.get("URL") or f"https://doi.org/{doi}")
out.append(
{
"pmid": "",
"title": title,
"abstract": abstract,
"url": url,
"source": "crossref",
}
)
return out
# ------------------------------------------------------------------
# 查询构建(确定性启发式:PubMed 不认中文,必须转英文术语)
# ------------------------------------------------------------------
SPECIES_MAP = {
"拟南芥": "Arabidopsis thaliana",
"水稻": "Oryza sativa",
"苦荞": "Fagopyrum tataricum",
"荞麦": "Fagopyrum",
"番茄": "Solanum lycopersicum",
"大豆": "Glycine max",
"玉米": "Zea mays",
"小麦": "Triticum aestivum",
"大麦": "Hordeum vulgare",
"葡萄": "Vitis vinifera",
"杨树": "Populus",
"苹果": "Malus domestica",
"苔藓": "Physcomitrella patens",
"小立碗藓": "Physcomitrella patens",
"棉花": "Gossypium",
"黄瓜": "Cucumis sativus",
"土豆": "Solanum tuberosum",
"马铃薯": "Solanum tuberosum",
"烟草": "Nicotiana",
"拟芥蓝": "Arabidopsis",
}
# 注意:不用 \b(CJK 字符同属 \w,词边界在中文语境不生效);连字符家族名如 NF-Y
GENE_FAMILY_PATTERN = re.compile(
r"[A-Z]{2,6}(?:-[A-Z]{1,3})?[0-9]{0,3}|miR[0-9]{2,4}|CYP[0-9]{1,3}[A-Z]?"
)
@classmethod
def build_query(cls, question: str) -> str:
"""从中文问题构建 PubMed 英文查询。
规则:抽基因家族词(大写 token)→ 加 "gene family" →
中文物种名映射英文;两者皆无时退化为清理后的原文(Crossref 仍可查中文)。
"""
families = cls.GENE_FAMILY_PATTERN.findall(question or "")
species = []
for zh, en in cls.SPECIES_MAP.items():
if zh in (question or ""):
species.append(en)
parts = []
if families:
# 过滤误抽(基因名长度启发:2-6 大写字母 + 数字);只取前 4 个
unique = list(dict.fromkeys(families))
parts.append(" ".join(unique[:4]))
parts.append("gene family")
if species:
parts.extend(species[:2])
if parts:
return " ".join(parts)
# 无任何可抽取术语:清理后原文(至少保留英文 token)
cleaned = " ".join(
w for w in re.findall(r"[A-Za-z0-9\-]+", question or "") if len(w) > 1
)
return cleaned or (question or "").strip()[:120]
# ------------------------------------------------------------------
# 入口
# ------------------------------------------------------------------
def search(self, query: str) -> List[EvidenceItem]:
"""检索入口:构建英文查询 → PubMed → Crossref 兜底 → EvidenceItem 列表。"""
started = time.perf_counter()
pubmed_query = self.build_query(query)
results = self.search_pubmed(pubmed_query)
if not results:
results = self.search_crossref(query)
evidence: List[EvidenceItem] = []
for item in results:
evidence.append(
EvidenceItem(
evidence_id=f"WEB-{item.get('pmid') or item.get('url', '').split('/')[-1] or item.get('source', 'web')}",
kb="web",
source=item.get("title", ""),
relation="web_search_result",
target=item.get("title", ""),
evidence=item.get("abstract", "") or item.get("title", ""),
paper_id=item.get("url", ""),
title=item.get("title", ""),
pmid=item.get("pmid") or "",
raw={"web": item},
)
)
LOGGER.info(
"web_search '%s' -> %d results in %.2fs",
query[:80],
len(evidence),
time.perf_counter() - started,
)
return evidence
+4
View File
@@ -67,6 +67,8 @@ STAGE_PHASE: dict[str, str] = {
"subquestion_retry_started": "retrieval",
"evidence_accepted": "evidence",
"evidence_review_completed": "evidence",
"web_search_started": "retrieval",
"web_search_completed": "evidence",
"answer_generation_started": "generation",
"completed": "done",
}
@@ -81,6 +83,8 @@ STAGE_LABEL: dict[str, str] = {
"subquestion_retry_started": "子问题重试中",
"evidence_accepted": "证据已采纳",
"evidence_review_completed": "证据审核完成",
"web_search_started": "开始联网检索",
"web_search_completed": "联网检索完成",
"answer_generation_started": "开始生成答案",
"completed": "检索完成",
}
+3 -3
View File
@@ -48,7 +48,7 @@ def _sse_bytes(events: list[tuple[bytes, bytes]]) -> list[bytes]:
# ===========================================================================
def test_stage_phase_maps_all_11_known_stages():
def test_stage_phase_maps_all_13_known_stages():
expected = {
"understanding_started": ("planning", "开始理解问题"),
"entities_identified": ("planning", "已识别实体"),
@@ -62,8 +62,8 @@ def test_stage_phase_maps_all_11_known_stages():
"answer_generation_started": ("generation", "开始生成答案"),
"completed": ("done", "检索完成"),
}
assert len(STAGE_PHASE) == 11
assert len(STAGE_LABEL) == 11
assert len(STAGE_PHASE) == 13
assert len(STAGE_LABEL) == 13
for stage, (phase, label) in expected.items():
assert STAGE_PHASE[stage] == phase
assert STAGE_LABEL[stage] == label