feat: 支持 PubMed/arXiv 学术搜索、全文获取与引用审计 - #90
Conversation
|
head_sha: 变更摘要本 PR 为系统新增了 PubMed 和 arXiv 学术搜索能力,支持从文献检索到全文获取的完整链路,并建立了贯穿"返回→进入→选中→引用"四个阶段的全文审计机制。核心改动包括:新增 主要改动
|
|
head_sha: 代码审查Now I've completed the review. Let me provide the closing summary. 审查结论已逐一审查全部 8 个变更文件,每个文件的审查结果如下:
按优先级统计: P0: 0 | P1: 0 | P2: 1 | P3: 1 整体风险判断: 中等偏低。P2 问题涉及去重逻辑中元数据完整性,在 Tavily 结果成为代表文档的特定场景下会被触发,可能导致下游审计数据不一致。P3 问题为纯粹的死代码,不影响运行行为。其余变更(全文获取、重试逻辑、日期过滤、审计链路接入)实现质量良好,无安全或并发方面的明显缺陷。 Now I have a complete picture. Let me compile the final review. 审查总结变更文件审查结果
按优先级汇总
整体风险评估中低风险。核心的重试、限速、全文获取和审计链路设计合理,边界处理较完善。主要问题是 审查总结已审查全部 3 个变更文件:
按优先级统计
总体风险评估:低风险本次变更全部为测试代码新增/修改,覆盖了 PubMed/arXiv 学术搜索、全文获取、请求重试与限流、以及学术全文审计链路的完整功能验证。测试设计严谨:正确使用
💬 仅评论 |
|
|
| caption = self._joined_text(element.find("caption")) | ||
| value = " ".join(part for part in (label, caption) if part) | ||
| if value: | ||
| parts.append(value) |
There was a problem hiding this comment.
head_sha: 95fe3191f7e96ea7020969181c13fcd2f929c195
🟡 Medium Priority
changed line: pubmed.py 中 _parse_pmc_xml 的 for element in body.iter(): 循环(第 388 行)。body.iter() 遍历 <body> 的所有后代节点,包括嵌套在 <td> 内部的 <p> 元素。当处理 <tr> 时,_joined_text(cell) 通过 "".join(element.itertext()) 递归提取 <td> 的全部文本(含嵌套 <p>),形成表格行文本(如 "Value 1 | Value 2")。随后 body.iter() 继续遍历到嵌套的 <p>,其标签匹配 "p",再次被提取为独立段落。虽然后续 dict.fromkeys(parts) 可消除完全相同的字符串,但当 <td> 包含混合内容(如 <td>Label: <p>text</p></td>)时,表格行文本为 "Label: text",独立 <p> 文本为 "text",二者不同,无法去重,导致内容重复浪费 LLM 上下文窗口。
failure mode: 对于包含表格的 PMC 全文,解析输出的 full_text 中同一内容出现两次(一次在表格行中,一次作为独立段落),降低全文质量,影响后续写作模型的理解准确性。
suggested fix: 将 body.iter() 改为仅遍历 <body> 的直接子元素,对 <sec> 递归处理其直接子元素;或在遍历时跟踪当前元素是否位于已处理的容器(<table-wrap>、<fig>、<tr>)内部,若是则跳过。
建议:改为仅遍历 body 的直接子元素,对 section/table-wrap/fig 等容器分别处理其内容,避免深度递归遍历导致表格内嵌元素被重复提取。
There was a problem hiding this comment.
head_sha: 71e8b5a39b665d8bee999312ea1221671602d823
已在 71e8b5a 修复。解析 table-wrap 和 fig 时会记录其结构化文本子节点,后续遍历不再单独提取其中的
,避免 caption 和表格单元格内容重复;表格行内容仍正常保留。已补充表格、图片 caption 和嵌套单元格段落的回归测试。
| "url": "https://export.arxiv.org/abs/hep-th/9901001", | ||
| "evidence_content_type": "full_text", | ||
| "evidence_content_chars": 100, | ||
| } |
There was a problem hiding this comment.
head_sha: 95fe3191f7e96ea7020969181c13fcd2f929c195
🟡 Medium Priority
4 个测试中对 emit_cited_academic_full_text_events 的第三参数使用了两种不一致的格式:
- 第 159 行(
test_cited_helper_matches_pubmed_document_to_pmc_citation):传入[pubmed],即List[Dict](pubmed本身是 dict,[pubmed]是一个扁平的单文档列表)。 - 第 179 行(
test_cited_helper_matches_arxiv_url_variants):传入[arxiv],同样是List[Dict]。 - 第 201 行(
test_cited_helper_matches_legacy_arxiv_url_variants):传入[document],同样是List[Dict]。
这两种格式语义不同:List[List[Dict]] 按引用分组文档,List[Dict] 是扁平文档列表。至少有一组测试的调用方式与被测函数的真实签名不符。如果函数实际期望 List[List[Dict]],则第 159/179/201 行的测试在对 dict 进行迭代时可能迭代到 dict 的 key("academic_source"、"url" 等字符串),导致引用匹配逻辑静默跳过,测试成为误通过(false positive);反之若函数期望 List[Dict],则第 130 行的 [[pubmed], [arxiv]] 会使函数拿到 list 而非 dict,同样可能静默失败。
建议:确认 emit_cited_academic_full_text_events 的实际签名:若期望 List[List[Dict]],则将第 159/179/201 行的 [doc] 改为 [[doc]];若期望 List[Dict],则将第 130 行的 [[pubmed], [arxiv]] 改为 [pubmed, arxiv]。统一后所有测试使用相同格式。
There was a problem hiding this comment.
head_sha: 71e8b5a39b665d8bee999312ea1221671602d823
经核对,此处无需修改。生产数据 all_classified_contents 可以是 List[List[Dict]],综合测试已经使用 [[pubmed], [arxiv]] 覆盖该结构。_iter_documents() 会递归展开 Dict、List[Dict]、List[List[Dict]] 和 tuple。其他测试使用 [document] 是为了单独验证 PMC/arXiv URL 身份匹配;两种输入最终产生相同的文档序列,不存在误通过。
| "full_text_truncated", | ||
| ): | ||
| if full_text_key in discarded: | ||
| representative[full_text_key] = discarded[full_text_key] |
There was a problem hiding this comment.
head_sha: 95fe3191f7e96ea7020969181c13fcd2f929c195
🟡 Medium Priority
changed line 275-284 → 去重时若 discarded(被淘汰文档)有全文而 representative(代表文档)仅有摘要,代码将 evidence_content_type(设为 "full_text")与 evidence_content_chars(全文字符数)从 discarded 拷贝到 representative。但 representative 的 original_content 仍保留原有摘要文本,导致元数据与正文不一致。
受影响的行为/契约:下游消费者若通过 evidence_content_type == "full_text" 判断是否为全文,或以 evidence_content_chars 估算内容长度,将获得错误信息——元数据声称"全文"但 original_content 实际存放的是摘要。
触发条件:去重时同一 URL 下 Tavily/web 结果被选为代表文档,而 arXiv/PubMed 的全文结果被淘汰。
建议:方案 A:拷贝 evidence_content_type/evidence_content_chars 的同时也拷贝 original_content。方案 B:不拷贝 evidence_content_type 和 evidence_content_chars,仅保留 academic_source/doi/pmcid 等来源溯源字段,避免元数据与正文不一致。推荐方案 B(仅保留溯源信息,不改变内容类型元数据),更贴合 PR 中"保留学术来源及全文元数据"的意图且不会引入不一致。
There was a problem hiding this comment.
head_sha: 71e8b5a39b665d8bee999312ea1221671602d823
已在 71e8b5a 修复。摘要文档成为代表文档时,只继承 academic_source、academic_source_id、DOI、PMCID 等学术溯源字段,不再继承全文类型、字符数、格式和全文 URL。只有代表文档与被淘汰文档本身都是全文时,才补齐缺失的全文元数据。已补充回归测试。
| search_results=[], | ||
| error=f"Error when run web search {resolved_name}: {e}") | ||
| error=f"Error when run web search {resolved_name}: {e}", | ||
| retryable=True) |
There was a problem hiding this comment.
head_sha: 95fe3191f7e96ea7020969181c13fcd2f929c195
🟡 Medium Priority
changed line: web_search.py:208 — 在 except Exception 分支中新增 retryable=True。
affected behavior: run_web_search 捕获来自下游 wrapper(如 PubMedSearchAPIWrapper、ArxivSearchAPIWrapper)的所有异常并统一标记为可重试。wrapper 内部通过 async_request_with_retry 已对可重试状态码(429/5xx)做了内部重试,非瞬态错误(如 400/401/403)会立即向上抛出 httpx.HTTPStatusError。该异常被此处的 except Exception 捕获后也获得 retryable=True。
failure mode: info_collector 层的 _direct_search_with_retry 根据 retryable 字段决定是否重试整个搜索(见 info_collector.py:551: tool_result_raw.get("retryable", True) is not False)。当 wrapper 因 400 等非瞬态错误失败后,info_collector 会再次重试,浪费 API 配额并延迟错误暴露。虽然修改前缺少 retryable 字段时默认也是 True(行为等价),但本 PR 将该字段显式化,却未根据异常类型区分重试策略,与 PR 描述中"普通非临时 4xx 错误不重试"的意图不一致。
suggested fix: 在 except Exception 分支中根据异常类型设置 retryable:仅对连接错误、超时或 5xx/429 响应标记为 retryable=True,其余设为 retryable=False。可复用 common.py 中已有的 is_transient_connection_error 和 http_status_code 辅助函数。
建议:在 except 分支中区分瞬态/非瞬态异常,对非瞬态错误设置 retryable=False。可导入 common.py 的 is_transient_connection_error 和 http_status_code 辅助判断。
| retryable=True) | |
| return dict(search_engine=resolved_name, | |
| search_results=[], | |
| error=f"Error when run web search {resolved_name}: {e}", | |
| retryable=is_transient_connection_error(e) or http_status_code(e) in RETRYABLE_HTTP_STATUSES) |
There was a problem hiding this comment.
head_sha: 71e8b5a39b665d8bee999312ea1221671602d823
已在 71e8b5a 修复。run_web_search 复用现有的 is_transient_connection_error、http_status_code 和 RETRYABLE_HTTP_STATUSES 分类异常:连接错误、超时、429 和可重试 5xx 返回 retryable=True;400/401/403 及未知异常返回 retryable=False。已增加 400、429、503、连接错误和未知异常测试。
8c68522 to
ebd4e57
Compare
| @@ -0,0 +1,66 @@ | |||
| # Academic search full-text enrichment | |||
There was a problem hiding this comment.
head_sha: ebd4e5757d56802968d27a87d74a82db1dcec283
和代码仓其他 feature 文档统一,改中文
There was a problem hiding this comment.
head_sha: a17193b124dcbe221bb9d7f5923947be340752d8
已修改。academic-search-full-text.md 已整体改为中文,并删除了本次不进入正式代码的实验审计相关描述。文档现在只说明学术搜索、官方全文获取、通用全文字段协议、配置开关及功能边界。
| - 存在时间范围时,初始 query 与 supervisor 补搜 query 由 LLM 自然表达该范围;每条 query 最多 5 个主题关键词, | ||
| 时间短语不计入该限制。`source_date` 表示资料发表时间,`content_date` 表示事实或数据时间。 | ||
| - 时间约束采用召回优先的 best-effort:只删除 Tavily 返回的可确认发表日期且明确越界的文档,日期未知、其他 web 引擎和 local 文档继续进入证据。 | ||
| - 时间约束采用召回优先的 best-effort:删除 Tavily、PubMed 和 arXiv 返回的可确认发表日期且明确越界的文档,日期未知、其他 web 引擎和 local 文档继续进入证据。 |
There was a problem hiding this comment.
head_sha: ebd4e5757d56802968d27a87d74a82db1dcec283
PubMed 和 arXiv 的时间约束不在本次修改范围内,去除
There was a problem hiding this comment.
head_sha: a17193b124dcbe221bb9d7f5923947be340752d8
已修改。已删除 PubMed/arXiv 日期过滤及相关日期解析逻辑,info-collector-subgraph.md 也恢复为当前实际边界:只有 Tavily wrapper 提供可确认的 source_date/source_date_type=published,Collector 才执行发表日期过滤。PubMed/arXiv 返回的书目日期不进入该过滤协议。
| current_index, current_doc = current | ||
| if _representative_sort_key((index, doc)) > _representative_sort_key(current): | ||
| grouped[key] = (index, doc) | ||
| representative, discarded = doc, current_doc |
There was a problem hiding this comment.
head_sha: ebd4e5757d56802968d27a87d74a82db1dcec283
为什么需要修改 doc_prefilter?从下面的逻辑看这个文件的修改是为了审计仍能识别该文档是 PubMed/arXiv 全文,不需要有这个识别项
There was a problem hiding this comment.
head_sha: ecc8715fb660d72dec50a85ff2d693b9c19683f6
已修改。实验审计不进入正式代码,因此已撤销 doc_prefilter.py 中为 PubMed/arXiv 保留代表文档及合并审计元数据的专用逻辑。
| f"selected_content len: {len(classified_content)}" | ||
| ) | ||
| classified_content = current_inputs.get("classified_content", []) | ||
| emit_selected_academic_full_text_events(logger, classified_content) |
There was a problem hiding this comment.
head_sha: ebd4e5757d56802968d27a87d74a82db1dcec283
审计只是为了预实验你是否有真实调用学术论文,事实上这个审计函数没有上正式代码仓的必要,审计相关内容需要去除,其他文件也看下
There was a problem hiding this comment.
head_sha: ecc8715fb660d72dec50a85ff2d693b9c19683f6
已修改。已移除 returned → entered → selected → cited 审计链路、审计工具及对应测试。
| content = str(full_text if use_full_text else record.get("content") or "")[ | ||
| :MAX_COLLECTOR_DOC_CONTENT_LENGTH | ||
| ] | ||
| evidence_content_type = "full_text" if use_full_text else "abstract" |
There was a problem hiding this comment.
head_sha: ebd4e5757d56802968d27a87d74a82db1dcec283
开启网页正文增强后,PubMed/arXiv 的 HTTP URL 也会进入候选列表。apply_enrichment_to_doc 会替换 original_content 和 source_id,却不更新 evidence_content_type、evidence_content_chars 及全文来源字段,导致摘要或网页正文被错误标记为 full_text,并被审计为已使用官方全文。
需要修改一下网页正文增强的逻辑,排除掉你的垂域搜索的 URL
There was a problem hiding this comment.
head_sha: ecc8715fb660d72dec50a85ff2d693b9c19683f6
已修改。通用网页增强模块没有硬编码 PubMed/arXiv 域名。学术 wrapper 通过通用字段 skip_webpage_enrichment=True 声明结果已经由上游处理;Collector 和 Evidence 仅传递严格布尔值 True,网页增强候选构建遇到该标记时直接跳过,从而避免再次抓取页面并覆盖官方全文字段。
|
|
||
| logger = logging.getLogger(__name__) | ||
|
|
||
| SCHOLARLY_MONTH_NAMES = { |
There was a problem hiding this comment.
head_sha: ebd4e5757d56802968d27a87d74a82db1dcec283
这个应该只服务于PubMed?从职责划分上不适合放在这里,从长期维护看,会让通用 Collector 逐渐耦合 PubMed/arXiv 的细节
There was a problem hiding this comment.
head_sha: ecc8715fb660d72dec50a85ff2d693b9c19683f6
已修改。Collector 已不再判断 PubMed/arXiv 来源,而是消费通用全文协议。仅当 full_text_status 为 available、unavailable 或 failed 时传递全文字段;available 但正文为空时降级为 unavailable;full_text_truncated 和 skip_webpage_enrichment 只接受真正的布尔值 True。后续其他全文提供者遵守同一协议即可接入,无需修改 Collector。
| def apply_full_text_extension_config(wrapper: Any, extension: dict | None) -> None: | ||
| ext = extension or {} | ||
| if "scholarly_fetch_full_text" in ext: | ||
| wrapper.fetch_full_text = bool(ext["scholarly_fetch_full_text"]) |
There was a problem hiding this comment.
head_sha: ebd4e5757d56802968d27a87d74a82db1dcec283
不要用 bool() 解析字符串开关
extension 是开放字典,配置可能传入字符串 false;但 Python 中 bool('false') 为 True,因此用户关闭全文抓取的配置会被忽略。请校验布尔类型,或显式解析 true/false 字符串。
There was a problem hiding this comment.
head_sha: ecc8715fb660d72dec50a85ff2d693b9c19683f6
已修改。增加了严格布尔配置解析,只接受原生 bool,或忽略大小写和首尾空格的 "true"/"false"。其他值会抛出 ValueError,不会再通过 bool("false") 得到错误结果。
| academic_config["search_engine_name"] = engine_name | ||
| academic_config["search_url"] = "" | ||
| academic_config["search_api_key"] = bytearray() | ||
| academic_config["max_web_search_results"] = 1 |
There was a problem hiding this comment.
head_sha: ebd4e5757d56802968d27a87d74a82db1dcec283
这个配置原因是为什么
There was a problem hiding this comment.
head_sha: ecc8715fb660d72dec50a85ff2d693b9c19683f6
已补充注释。学术结果还可能继续抓取官方全文,单条结果的网络和解析成本明显高于普通网页搜索,因此注册学术 wrapper 时不继承主搜索引擎较大的结果数,而是显式限制为 1。wrapper 仍允许调用方显式覆盖该配置。
|
|
||
| from __future__ import annotations | ||
|
|
||
| import asyncio |
There was a problem hiding this comment.
head_sha: ebd4e5757d56802968d27a87d74a82db1dcec283
这两个学术搜索引擎修改后,是否测试过这两个搜索工具的耗时,评论区贴一下
There was a problem hiding this comment.
head_sha: ecc8715fb660d72dec50a85ff2d693b9c19683f6
分别完成一次启用学术搜索的 PubMed 和 arXiv E2E 实验。PubMed 实验共调用 PubMed 37 次,平均单次观测延迟 12.91 秒、中位数 10.02 秒、最长 34.50 秒;arXiv 实验,共调用 arXiv 105 次,平均单次观测延迟 38.52 秒、中位数 32.34 秒、最长 188.64 秒。实验表明学术搜索及官方全文处理成本明显高于普通搜索,因此学术 wrapper 默认每个 query 只返回 1 条结果,避免进一步扩大请求和全文解析成本。
| from __future__ import annotations | ||
|
|
||
| import asyncio | ||
| import logging |
There was a problem hiding this comment.
head_sha: ebd4e5757d56802968d27a87d74a82db1dcec283
SEG 有提到,垂域搜索加个配置开关,默认关闭。
There was a problem hiding this comment.
head_sha: ecc8715fb660d72dec50a85ff2d693b9c19683f6
已修改。新增 web_search_engine_config.extension.scholarly_search_enabled,默认值为 false。关闭时不会实例化 PubMed/arXiv wrapper,同时会清理 query 级学术路由;只有显式启用后才注册学术引擎并允许路由。配置行为、严格布尔解析及启停路由均已补充文档和测试。
| except Exception as exc: | ||
| row.update(_full_text_fields()) | ||
| row["full_text_status"] = "failed" | ||
| logger.warning("Unable to enrich PubMed result %s from PMC: %s", row.get("source_id"), exc) |
There was a problem hiding this comment.
head_sha: ebd4e5757d56802968d27a87d74a82db1dcec283
看下错误日志的情况,是否出现大量 warning 日志
There was a problem hiding this comment.
head_sha: ecc8715fb660d72dec50a85ff2d693b9c19683f6
已检查 PubMed 日志。PubMed 实验共调用 37 次,Unable to enrich PubMed result ... from PMC warning 为 0,没有发现该日志大量输出。当前每个 query 最多增强 1 条学术结果,因此单次调用不会批量产生 warning。
|
|
||
| @staticmethod | ||
| def _exact_pmid(query: str) -> str: | ||
| match = re.fullmatch(r"\s*(?:PMID\s*:\s*)?(\d+)\s*", str(query or ""), re.IGNORECASE) |
There was a problem hiding this comment.
head_sha: ebd4e5757d56802968d27a87d74a82db1dcec283
pubmed.py::_exact_pmid 用 re.fullmatch(r"\s*(?:PMID\s*:\s*)?(\d+)\s*") 拦截查询。"2024"(纯年份)、数值型数据集 ID 等查询会被当作 PMID 直接走 efetch,跳过 esearch。
There was a problem hiding this comment.
head_sha: ecc8715fb660d72dec50a85ff2d693b9c19683f6
已修改。现在只有显式格式 PMID: 38132429 才会直接进入 EFetch。2024 或其他普通纯数字查询会继续走 ESearch,避免把年份等通用查询误判为 PMID。同步和异步路径均使用相同判断,并有回归测试覆盖。
Paired: GitHub #90 ↔ GitCode !353
变更概述
本 PR 增加 PubMed 和 arXiv 学术搜索能力,并完善学术全文在信息收集、写作选择和最终引用过程中的审计链路。
主要改动
Retry-Afterreturnedenteredselectedcitedconversation_id严格隔离稳定性与兼容性
4xx错误不重试upstream/devWhat type of PR is this?
/kind
Self-checklist:(请自检,在[ ]内打上x,我们将检视你的完成情况,否则会导致pr无法合入)
Linked Closing Issues: