qwen-sci 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of qwen-sci might be problematic. Click here for more details.

Files changed (626) hide show
  1. qwen_sci-0.2.0.dist-info/METADATA +492 -0
  2. qwen_sci-0.2.0.dist-info/RECORD +626 -0
  3. qwen_sci-0.2.0.dist-info/WHEEL +4 -0
  4. qwen_sci-0.2.0.dist-info/entry_points.txt +6 -0
  5. qwen_sci-0.2.0.dist-info/licenses/LICENSE +21 -0
  6. src/__init__.py +1 -0
  7. src/__main__.py +5 -0
  8. src/agents/__init__.py +1 -0
  9. src/agents/blog_agent/README.md +299 -0
  10. src/agents/blog_agent/__init__.py +0 -0
  11. src/agents/blog_agent/agent/__init__.py +0 -0
  12. src/agents/blog_agent/agent/llm_config.py +109 -0
  13. src/agents/blog_agent/agent/new_base_agent.py +388 -0
  14. src/agents/blog_agent/scripts/run.py +497 -0
  15. src/agents/blog_agent/skills/blog-analyze/SKILL.md +376 -0
  16. src/agents/blog_agent/skills/blog-refine/SKILL.md +246 -0
  17. src/agents/blog_agent/skills/blog-writer/SKILL.md +218 -0
  18. src/agents/blog_agent/skills/workspace-navigator/SKILL.md +147 -0
  19. src/agents/blog_agent/tools/count_words_tool.py +161 -0
  20. src/agents/blog_agent/tools/download_paper_pdf_tool.py +158 -0
  21. src/agents/blog_agent/tools/gengraph.py +389 -0
  22. src/agents/blog_agent/tools/illustrate.py +116 -0
  23. src/agents/blog_agent/tools/ocr.py +217 -0
  24. src/agents/blog_agent/tools/search_core_tool.py +159 -0
  25. src/agents/blog_agent/tools/search_paper_abstract_tool.py +164 -0
  26. src/agents/blog_agent/utils/deeperaser.pth +0 -0
  27. src/agents/blog_agent/utils/deeperaser.py +381 -0
  28. src/agents/blog_agent/utils/graph.db +0 -0
  29. src/agents/blog_agent/utils/retrieval.py +278 -0
  30. src/agents/blog_agent/utils/search_core.py +159 -0
  31. src/agents/blog_agent/utils/semantic_scholar.py +669 -0
  32. src/agents/experiment_agent/README.md +93 -0
  33. src/agents/experiment_agent/__init__.py +0 -0
  34. src/agents/experiment_agent/agents/__init__.py +4 -0
  35. src/agents/experiment_agent/agents/base/__init__.py +11 -0
  36. src/agents/experiment_agent/agents/base/agent.py +204 -0
  37. src/agents/experiment_agent/agents/code/__init__.py +21 -0
  38. src/agents/experiment_agent/agents/code/entry.py +11 -0
  39. src/agents/experiment_agent/agents/code/planner.py +599 -0
  40. src/agents/experiment_agent/agents/code/reviewer.py +128 -0
  41. src/agents/experiment_agent/agents/code/worker.py +78 -0
  42. src/agents/experiment_agent/agents/finalization/__init__.py +6 -0
  43. src/agents/experiment_agent/agents/finalization/entry.py +350 -0
  44. src/agents/experiment_agent/agents/master/__init__.py +3 -0
  45. src/agents/experiment_agent/agents/master/entry.py +424 -0
  46. src/agents/experiment_agent/agents/prepare/__init__.py +39 -0
  47. src/agents/experiment_agent/agents/prepare/entry.py +7 -0
  48. src/agents/experiment_agent/agents/prepare/planner.py +497 -0
  49. src/agents/experiment_agent/agents/prepare/reviewer.py +99 -0
  50. src/agents/experiment_agent/agents/prepare/worker.py +101 -0
  51. src/agents/experiment_agent/agents/science/__init__.py +21 -0
  52. src/agents/experiment_agent/agents/science/entry.py +11 -0
  53. src/agents/experiment_agent/agents/science/planner.py +639 -0
  54. src/agents/experiment_agent/agents/science/reviewer.py +124 -0
  55. src/agents/experiment_agent/agents/science/worker.py +34 -0
  56. src/agents/experiment_agent/config.py +937 -0
  57. src/agents/experiment_agent/main.py +378 -0
  58. src/agents/experiment_agent/runtime/__init__.py +88 -0
  59. src/agents/experiment_agent/runtime/ablation_results.py +444 -0
  60. src/agents/experiment_agent/runtime/artifacts.py +2481 -0
  61. src/agents/experiment_agent/runtime/code_review_context.py +473 -0
  62. src/agents/experiment_agent/runtime/contracts.py +1346 -0
  63. src/agents/experiment_agent/runtime/finalization_hooks.py +607 -0
  64. src/agents/experiment_agent/runtime/idea_components.py +102 -0
  65. src/agents/experiment_agent/runtime/manifests.py +192 -0
  66. src/agents/experiment_agent/runtime/openharness_runner.py +1091 -0
  67. src/agents/experiment_agent/runtime/openharness_vendor.py +58 -0
  68. src/agents/experiment_agent/runtime/phase_contracts.py +201 -0
  69. src/agents/experiment_agent/runtime/phase_runner.py +2574 -0
  70. src/agents/experiment_agent/runtime/prepare_contracts.py +614 -0
  71. src/agents/experiment_agent/runtime/project_integrity.py +409 -0
  72. src/agents/experiment_agent/runtime/report_layout.py +194 -0
  73. src/agents/experiment_agent/runtime/self_contained.py +179 -0
  74. src/agents/experiment_agent/skills/__init__.py +45 -0
  75. src/agents/experiment_agent/skills/benchmark-discovery/SKILL.md +33 -0
  76. src/agents/experiment_agent/skills/bounded-tool-use/SKILL.md +31 -0
  77. src/agents/experiment_agent/skills/code-enablement/SKILL.md +59 -0
  78. src/agents/experiment_agent/skills/code-planning/SKILL.md +55 -0
  79. src/agents/experiment_agent/skills/component-coverage/SKILL.md +26 -0
  80. src/agents/experiment_agent/skills/environment-setup/SKILL.md +23 -0
  81. src/agents/experiment_agent/skills/prepare-planning/SKILL.md +85 -0
  82. src/agents/experiment_agent/skills/resource-acquisition/SKILL.md +69 -0
  83. src/agents/experiment_agent/skills/science-execution/SKILL.md +46 -0
  84. src/agents/experiment_agent/skills/science-planning/SKILL.md +35 -0
  85. src/agents/experiment_agent/telemetry/__init__.py +161 -0
  86. src/agents/experiment_agent/tools/__init__.py +6 -0
  87. src/agents/experiment_design_agent/__init__.py +268 -0
  88. src/agents/experiment_design_agent/artifacts.py +747 -0
  89. src/agents/experiment_design_agent/cache.py +319 -0
  90. src/agents/experiment_design_agent/completeness.py +130 -0
  91. src/agents/experiment_design_agent/contracts.py +771 -0
  92. src/agents/experiment_design_agent/counterexample_analyzer.py +187 -0
  93. src/agents/experiment_design_agent/design_evidence_paper_screener.py +470 -0
  94. src/agents/experiment_design_agent/discipline_catalog.py +178 -0
  95. src/agents/experiment_design_agent/evidence_cards.py +338 -0
  96. src/agents/experiment_design_agent/evidence_planner.py +452 -0
  97. src/agents/experiment_design_agent/formal_reasoning_planner.py +884 -0
  98. src/agents/experiment_design_agent/fulltext_acquisition.py +863 -0
  99. src/agents/experiment_design_agent/idea_adapter.py +100 -0
  100. src/agents/experiment_design_agent/idea_intake.py +87 -0
  101. src/agents/experiment_design_agent/llm_json.py +328 -0
  102. src/agents/experiment_design_agent/orchestrator.py +1220 -0
  103. src/agents/experiment_design_agent/reasoning_context.py +183 -0
  104. src/agents/experiment_design_agent/reasoning_validation.py +546 -0
  105. src/agents/experiment_design_agent/run.py +481 -0
  106. src/agents/experiment_design_agent/run_logging.py +292 -0
  107. src/agents/experiment_design_agent/scope_gate.py +198 -0
  108. src/agents/experiment_design_agent/study_type_composer.py +1360 -0
  109. src/agents/experiment_design_agent/survey_evidence.py +1381 -0
  110. src/agents/experiment_design_agent/template_router.py +195 -0
  111. src/agents/experiment_design_agent/variable_claim_extractor.py +93 -0
  112. src/agents/idea_agent/__init__.py +1 -0
  113. src/agents/idea_agent/agent/__init__.py +6 -0
  114. src/agents/idea_agent/agent/artifacts.py +308 -0
  115. src/agents/idea_agent/agent/base.py +122 -0
  116. src/agents/idea_agent/agent/ligagent.py +488 -0
  117. src/agents/idea_agent/agent/mcts.py +2060 -0
  118. src/agents/idea_agent/agent/prompts/__init__.py +48 -0
  119. src/agents/idea_agent/agent/prompts/advanced_analysis.py +235 -0
  120. src/agents/idea_agent/agent/prompts/algorithm_alignment.py +55 -0
  121. src/agents/idea_agent/agent/prompts/algorithm_structuring.py +48 -0
  122. src/agents/idea_agent/agent/prompts/component_extraction.py +62 -0
  123. src/agents/idea_agent/agent/prompts/component_novelty_evaluation.py +54 -0
  124. src/agents/idea_agent/agent/prompts/experiment_findings_extraction.py +39 -0
  125. src/agents/idea_agent/agent/prompts/idea_fusion.py +295 -0
  126. src/agents/idea_agent/agent/prompts/idea_introduction.py +30 -0
  127. src/agents/idea_agent/agent/prompts/idea_result_alignment.py +39 -0
  128. src/agents/idea_agent/agent/prompts/input_interpreter.py +52 -0
  129. src/agents/idea_agent/agent/prompts/keynote_ops.py +78 -0
  130. src/agents/idea_agent/agent/prompts/mcts_evaluation.py +235 -0
  131. src/agents/idea_agent/agent/prompts/mcts_generation.py +140 -0
  132. src/agents/idea_agent/agent/prompts/mechanism_commit_query.py +99 -0
  133. src/agents/idea_agent/agent/prompts/memory_selection.py +0 -0
  134. src/agents/idea_agent/agent/prompts/prompt_modes.py +16 -0
  135. src/agents/idea_agent/agent/prompts/rag_query.py +34 -0
  136. src/agents/idea_agent/agent/prompts/re_analysis_replan.py +48 -0
  137. src/agents/idea_agent/agent/prompts/reference_grounding.py +34 -0
  138. src/agents/idea_agent/agent/prompts/root_domain_classification.py +32 -0
  139. src/agents/idea_agent/agent/prompts/scientific_debate.py +88 -0
  140. src/agents/idea_agent/agent/prompts/scientific_materialization.py +45 -0
  141. src/agents/idea_agent/agent/prompts/skill_instantiation.py +193 -0
  142. src/agents/idea_agent/agent/prompts/theory_transfer_query.py +93 -0
  143. src/agents/idea_agent/agent/prompts/topic_background.py +15 -0
  144. src/agents/idea_agent/agent/skills/DEFAULT_SKILL_TEMPLATES.json +263 -0
  145. src/agents/idea_agent/agent/skills/edit_operator_skills/alternative-path-contrast/SKILL.md +40 -0
  146. src/agents/idea_agent/agent/skills/edit_operator_skills/alternative-path-contrast/references/contrast_patterns.md +22 -0
  147. src/agents/idea_agent/agent/skills/edit_operator_skills/feedback-closed-loop/SKILL.md +40 -0
  148. src/agents/idea_agent/agent/skills/edit_operator_skills/feedback-closed-loop/references/mechanism_cards.md +24 -0
  149. src/agents/idea_agent/agent/skills/edit_operator_skills/hierarchical-decomposition/SKILL.md +39 -0
  150. src/agents/idea_agent/agent/skills/edit_operator_skills/hierarchical-decomposition/references/hierarchy_patterns.md +22 -0
  151. src/agents/idea_agent/agent/skills/edit_operator_skills/mechanism-commit-innovation/SKILL.md +39 -0
  152. src/agents/idea_agent/agent/skills/edit_operator_skills/mechanism-commit-innovation/references/mechanism_commit_patterns.md +24 -0
  153. src/agents/idea_agent/agent/skills/edit_operator_skills/multi-scale-coordinator/SKILL.md +39 -0
  154. src/agents/idea_agent/agent/skills/edit_operator_skills/multi-scale-coordinator/references/coordination_patterns.md +22 -0
  155. src/agents/idea_agent/agent/skills/edit_operator_skills/speculative-execution-with-repair/SKILL.md +41 -0
  156. src/agents/idea_agent/agent/skills/edit_operator_skills/speculative-execution-with-repair/references/speculation_patterns.md +22 -0
  157. src/agents/idea_agent/agent/skills/edit_operator_skills/surgical-modularity/SKILL.md +39 -0
  158. src/agents/idea_agent/agent/skills/edit_operator_skills/surgical-modularity/references/modularity_patterns.md +22 -0
  159. src/agents/idea_agent/agent/skills/edit_operator_skills/theory-transfer-injection/SKILL.md +40 -0
  160. src/agents/idea_agent/agent/skills/edit_operator_skills/theory-transfer-injection/references/transfer_patterns.md +24 -0
  161. src/agents/idea_agent/example/idea_result.json +900 -0
  162. src/agents/idea_agent/run.py +276 -0
  163. src/agents/idea_agent/tests/test_advanced_analysis_contract.py +90 -0
  164. src/agents/idea_agent/tests/test_gap_hypothesis_bridge.py +166 -0
  165. src/agents/idea_agent/tests/test_idea_debate.py +480 -0
  166. src/agents/idea_agent/tests/test_idea_result_v5.py +99 -0
  167. src/agents/idea_agent/tests/test_idea_synthesis.py +92 -0
  168. src/agents/idea_agent/tests/test_mature_idea_sources.py +27 -0
  169. src/agents/idea_agent/tests/test_multimodal_idea_integration.py +386 -0
  170. src/agents/idea_agent/tests/test_profile_materialization_and_scoring.py +219 -0
  171. src/agents/idea_agent/tests/test_profile_native_skill_rendering.py +122 -0
  172. src/agents/idea_agent/tests/test_result_persistence.py +183 -0
  173. src/agents/idea_agent/tests/test_scientific_intervention_profiles.py +241 -0
  174. src/agents/idea_agent/tests/test_scientific_rubric_and_prompts.py +559 -0
  175. src/agents/idea_agent/tests/test_strict_json_object_responses.py +48 -0
  176. src/agents/idea_agent/utils/core/__init__.py +1 -0
  177. src/agents/idea_agent/utils/core/ablation_inputs.py +60 -0
  178. src/agents/idea_agent/utils/core/chat_errors.py +95 -0
  179. src/agents/idea_agent/utils/core/chat_router.py +130 -0
  180. src/agents/idea_agent/utils/core/chat_transport.py +188 -0
  181. src/agents/idea_agent/utils/core/config_loader.py +166 -0
  182. src/agents/idea_agent/utils/core/json_utils.py +28 -0
  183. src/agents/idea_agent/utils/core/logger.py +287 -0
  184. src/agents/idea_agent/utils/core/progress.py +58 -0
  185. src/agents/idea_agent/utils/core/response_parsing.py +57 -0
  186. src/agents/idea_agent/utils/core/run_inputs.py +198 -0
  187. src/agents/idea_agent/utils/mcts/__init__.py +1 -0
  188. src/agents/idea_agent/utils/mcts/component_novelty.py +342 -0
  189. src/agents/idea_agent/utils/mcts/defect_registry.py +302 -0
  190. src/agents/idea_agent/utils/mcts/idea_routes.py +61 -0
  191. src/agents/idea_agent/utils/mcts/idea_taste_presets.py +312 -0
  192. src/agents/idea_agent/utils/mcts/mcts_helpers.py +738 -0
  193. src/agents/idea_agent/utils/mcts/mcts_runtime.py +3000 -0
  194. src/agents/idea_agent/utils/mcts/scientific_intervention_ontology.py +1561 -0
  195. src/agents/idea_agent/utils/mcts/scientific_rubric.py +211 -0
  196. src/agents/idea_agent/utils/mcts/skill_parsing.py +92 -0
  197. src/agents/idea_agent/utils/papers/__init__.py +1 -0
  198. src/agents/idea_agent/utils/papers/paper_graph_vector_store.py +499 -0
  199. src/agents/idea_agent/utils/papers/paper_repository.py +372 -0
  200. src/agents/idea_agent/utils/prompting/__init__.py +1 -0
  201. src/agents/idea_agent/utils/prompting/prompt_views.py +637 -0
  202. src/agents/idea_agent/utils/workflow/__init__.py +1 -0
  203. src/agents/idea_agent/utils/workflow/advanced_analysis_contract.py +244 -0
  204. src/agents/idea_agent/utils/workflow/gap_hypothesis_bridge.py +461 -0
  205. src/agents/idea_agent/utils/workflow/idea_contract.py +426 -0
  206. src/agents/idea_agent/utils/workflow/idea_debate.py +1238 -0
  207. src/agents/idea_agent/utils/workflow/idea_diversity.py +163 -0
  208. src/agents/idea_agent/utils/workflow/idea_fusion.py +889 -0
  209. src/agents/idea_agent/utils/workflow/idea_helpers.py +478 -0
  210. src/agents/idea_agent/utils/workflow/idea_portfolio.py +422 -0
  211. src/agents/idea_agent/utils/workflow/idea_synthesis.py +482 -0
  212. src/agents/idea_agent/utils/workflow/ligagent_flow.py +581 -0
  213. src/agents/idea_agent/utils/workflow/ligagent_handlers.py +1836 -0
  214. src/agents/idea_agent/utils/workflow/ligagent_helpers.py +1331 -0
  215. src/agents/idea_agent/utils/workflow/ligagent_utils.py +322 -0
  216. src/agents/idea_agent/utils/workflow/mature_idea_sources.py +648 -0
  217. src/agents/idea_agent/utils/workflow/multimodal_data_anchoring.py +578 -0
  218. src/agents/idea_agent/utils/workflow/result_persistence.py +774 -0
  219. src/agents/idea_agent/utils/workflow/stage_contract.py +76 -0
  220. src/agents/idea_agent/utils/workflow/workflow_runtime.py +288 -0
  221. src/agents/research_plan_author/__init__.py +50 -0
  222. src/agents/research_plan_author/artifacts.py +270 -0
  223. src/agents/research_plan_author/authoring_blueprint.py +1246 -0
  224. src/agents/research_plan_author/bibtex_renderer.py +252 -0
  225. src/agents/research_plan_author/contract_repair.py +131 -0
  226. src/agents/research_plan_author/contracts.py +745 -0
  227. src/agents/research_plan_author/cross_section_editor.py +232 -0
  228. src/agents/research_plan_author/document_quality.py +987 -0
  229. src/agents/research_plan_author/idea_evolution.py +223 -0
  230. src/agents/research_plan_author/input_loader.py +76 -0
  231. src/agents/research_plan_author/latex_compiler.py +260 -0
  232. src/agents/research_plan_author/latex_safety.py +159 -0
  233. src/agents/research_plan_author/llm_json.py +74 -0
  234. src/agents/research_plan_author/markdown_renderer.py +275 -0
  235. src/agents/research_plan_author/pdf_validator.py +149 -0
  236. src/agents/research_plan_author/render.py +166 -0
  237. src/agents/research_plan_author/render_artifacts.py +187 -0
  238. src/agents/research_plan_author/run.py +765 -0
  239. src/agents/research_plan_author/run_logging.py +209 -0
  240. src/agents/research_plan_author/section_cache.py +184 -0
  241. src/agents/research_plan_author/section_composer.py +1764 -0
  242. src/agents/research_plan_author/section_router.py +209 -0
  243. src/agents/research_plan_author/semantic_validator.py +254 -0
  244. src/agents/research_plan_author/source_bundle.py +46 -0
  245. src/agents/research_plan_author/source_registry.py +208 -0
  246. src/agents/research_plan_author/survey_source_loader.py +137 -0
  247. src/agents/research_plan_author/template_adapter.py +173 -0
  248. src/agents/research_plan_author/template_profile.py +204 -0
  249. src/agents/research_plan_author/tex_renderer.py +496 -0
  250. src/agents/research_plan_author/theory_presentation.py +134 -0
  251. src/agents/research_plan_author/theory_spine.py +703 -0
  252. src/agents/survey_agent/Dockerfile +54 -0
  253. src/agents/survey_agent/README.md +350 -0
  254. src/agents/survey_agent/config/deep_survey.yaml +217 -0
  255. src/agents/survey_agent/config/deep_survey_batch.yaml +176 -0
  256. src/agents/survey_agent/config/deep_survey_fast.yaml +183 -0
  257. src/agents/survey_agent/config/evaluate_baseline.yaml +197 -0
  258. src/agents/survey_agent/config/outcomeRAG.yaml +159 -0
  259. src/agents/survey_agent/env_report.md +348 -0
  260. src/agents/survey_agent/modules/__init__.py +0 -0
  261. src/agents/survey_agent/modules/code_collector.py +2194 -0
  262. src/agents/survey_agent/modules/code_report_generator.py +1282 -0
  263. src/agents/survey_agent/modules/data_manager.py +1713 -0
  264. src/agents/survey_agent/modules/database.py +167 -0
  265. src/agents/survey_agent/modules/fulltext_download_cache.py +462 -0
  266. src/agents/survey_agent/modules/fulltext_http.py +134 -0
  267. src/agents/survey_agent/modules/fulltext_resolution.py +749 -0
  268. src/agents/survey_agent/modules/judge.py +434 -0
  269. src/agents/survey_agent/modules/outcome_RAG.py +236 -0
  270. src/agents/survey_agent/modules/paper_graph_retriever.py +1075 -0
  271. src/agents/survey_agent/modules/pe.py +1729 -0
  272. src/agents/survey_agent/modules/pseudo_creator.py +782 -0
  273. src/agents/survey_agent/modules/pseudo_reviser.py +537 -0
  274. src/agents/survey_agent/modules/refine_agent.py +1952 -0
  275. src/agents/survey_agent/modules/survey_generator.py +5691 -0
  276. src/agents/survey_agent/modules/survey_visualizer.py +1817 -0
  277. src/agents/survey_agent/modules/visual_prompts.py +327 -0
  278. src/agents/survey_agent/modules/work_analyzer.py +2750 -0
  279. src/agents/survey_agent/modules/work_collector.py +5649 -0
  280. src/agents/survey_agent/scripts/baseline_evaluation.py +411 -0
  281. src/agents/survey_agent/scripts/run_deep_survey.py +224 -0
  282. src/agents/survey_agent/scripts/run_deep_survey_adapter.py +407 -0
  283. src/agents/survey_agent/scripts/run_deep_survey_adapter_with_input.py +409 -0
  284. src/agents/survey_agent/scripts/run_deep_survey_batch.py +228 -0
  285. src/agents/survey_agent/scripts/run_deep_survey_batch_arg.py +243 -0
  286. src/agents/survey_agent/scripts/run_deep_survey_batch_settings.py +218 -0
  287. src/agents/survey_agent/scripts/run_keynotes_gen.py +128 -0
  288. src/agents/survey_agent/scripts/run_work_clusters.py +140 -0
  289. src/agents/survey_agent/utils/__init__.py +0 -0
  290. src/agents/survey_agent/utils/ablation_utils.py +317 -0
  291. src/agents/survey_agent/utils/api_call.py +2518 -0
  292. src/agents/survey_agent/utils/chat_utils.py +82 -0
  293. src/agents/survey_agent/utils/config_utils.py +70 -0
  294. src/agents/survey_agent/utils/convert_to_md.py +29 -0
  295. src/agents/survey_agent/utils/err_info.py +24 -0
  296. src/agents/survey_agent/utils/file_utils.py +176 -0
  297. src/agents/survey_agent/utils/gpu_utils.py +145 -0
  298. src/agents/survey_agent/utils/graph_visualizer.py +279 -0
  299. src/agents/survey_agent/utils/html_utils.py +181 -0
  300. src/agents/survey_agent/utils/latex_generator_intra_cluster.py +529 -0
  301. src/agents/survey_agent/utils/latex_generator_relation_table.py +193 -0
  302. src/agents/survey_agent/utils/mineru_section_packer.py +334 -0
  303. src/agents/survey_agent/utils/mineru_utils.py +396 -0
  304. src/agents/survey_agent/utils/paper.py +144 -0
  305. src/agents/survey_agent/utils/read_yaml.py +15 -0
  306. src/agents/survey_agent/utils/repo_utils.py +167 -0
  307. src/agents/survey_agent/utils/rich_logger.py +77 -0
  308. src/agents/survey_agent/utils/step2v2.py +874 -0
  309. src/agents/survey_agent/utils/step2v2_extractor.py +856 -0
  310. src/agents/survey_agent/utils/topic_survey_storage.py +309 -0
  311. src/agents/survey_agent/utils/utils.py +118 -0
  312. src/agents/survey_agent/utils/work.py +2 -0
  313. src/cli.py +1611 -0
  314. src/config/__init__.py +213 -0
  315. src/config/default.yaml +1122 -0
  316. src/harness/LICENSE +21 -0
  317. src/harness/VENDORED.md +14 -0
  318. src/harness/src/__init__.py +0 -0
  319. src/harness/src/openharness/__init__.py +0 -0
  320. src/harness/src/openharness/__main__.py +6 -0
  321. src/harness/src/openharness/api/__init__.py +21 -0
  322. src/harness/src/openharness/api/client.py +281 -0
  323. src/harness/src/openharness/api/codex_client.py +407 -0
  324. src/harness/src/openharness/api/copilot_auth.py +240 -0
  325. src/harness/src/openharness/api/copilot_client.py +134 -0
  326. src/harness/src/openharness/api/errors.py +19 -0
  327. src/harness/src/openharness/api/openai_client.py +577 -0
  328. src/harness/src/openharness/api/provider.py +186 -0
  329. src/harness/src/openharness/api/registry.py +437 -0
  330. src/harness/src/openharness/api/usage.py +17 -0
  331. src/harness/src/openharness/auth/__init__.py +29 -0
  332. src/harness/src/openharness/auth/external.py +610 -0
  333. src/harness/src/openharness/auth/flows.py +189 -0
  334. src/harness/src/openharness/auth/manager.py +482 -0
  335. src/harness/src/openharness/auth/storage.py +269 -0
  336. src/harness/src/openharness/autopilot/__init__.py +23 -0
  337. src/harness/src/openharness/autopilot/service.py +2239 -0
  338. src/harness/src/openharness/autopilot/types.py +91 -0
  339. src/harness/src/openharness/bridge/__init__.py +20 -0
  340. src/harness/src/openharness/bridge/manager.py +106 -0
  341. src/harness/src/openharness/bridge/session_runner.py +46 -0
  342. src/harness/src/openharness/bridge/types.py +37 -0
  343. src/harness/src/openharness/bridge/work_secret.py +41 -0
  344. src/harness/src/openharness/channels/UPSTREAM +6 -0
  345. src/harness/src/openharness/channels/__init__.py +22 -0
  346. src/harness/src/openharness/channels/adapter.py +131 -0
  347. src/harness/src/openharness/channels/bus/__init__.py +6 -0
  348. src/harness/src/openharness/channels/bus/events.py +38 -0
  349. src/harness/src/openharness/channels/bus/queue.py +44 -0
  350. src/harness/src/openharness/channels/impl/__init__.py +6 -0
  351. src/harness/src/openharness/channels/impl/base.py +141 -0
  352. src/harness/src/openharness/channels/impl/dingtalk.py +445 -0
  353. src/harness/src/openharness/channels/impl/discord.py +311 -0
  354. src/harness/src/openharness/channels/impl/email.py +410 -0
  355. src/harness/src/openharness/channels/impl/feishu.py +1342 -0
  356. src/harness/src/openharness/channels/impl/manager.py +257 -0
  357. src/harness/src/openharness/channels/impl/matrix.py +700 -0
  358. src/harness/src/openharness/channels/impl/mochat.py +897 -0
  359. src/harness/src/openharness/channels/impl/qq.py +141 -0
  360. src/harness/src/openharness/channels/impl/slack.py +284 -0
  361. src/harness/src/openharness/channels/impl/telegram.py +525 -0
  362. src/harness/src/openharness/channels/impl/whatsapp.py +159 -0
  363. src/harness/src/openharness/cli.py +2551 -0
  364. src/harness/src/openharness/commands/__init__.py +21 -0
  365. src/harness/src/openharness/commands/registry.py +2783 -0
  366. src/harness/src/openharness/config/__init__.py +34 -0
  367. src/harness/src/openharness/config/paths.py +160 -0
  368. src/harness/src/openharness/config/schema.py +119 -0
  369. src/harness/src/openharness/config/settings.py +1104 -0
  370. src/harness/src/openharness/coordinator/__init__.py +12 -0
  371. src/harness/src/openharness/coordinator/agent_definitions.py +975 -0
  372. src/harness/src/openharness/coordinator/coordinator_mode.py +520 -0
  373. src/harness/src/openharness/engine/__init__.py +80 -0
  374. src/harness/src/openharness/engine/cost_tracker.py +24 -0
  375. src/harness/src/openharness/engine/messages.py +222 -0
  376. src/harness/src/openharness/engine/query.py +1188 -0
  377. src/harness/src/openharness/engine/query_engine.py +313 -0
  378. src/harness/src/openharness/engine/stream_events.py +90 -0
  379. src/harness/src/openharness/hooks/__init__.py +50 -0
  380. src/harness/src/openharness/hooks/events.py +20 -0
  381. src/harness/src/openharness/hooks/executor.py +242 -0
  382. src/harness/src/openharness/hooks/hot_reload.py +31 -0
  383. src/harness/src/openharness/hooks/loader.py +68 -0
  384. src/harness/src/openharness/hooks/schemas.py +66 -0
  385. src/harness/src/openharness/hooks/types.py +38 -0
  386. src/harness/src/openharness/keybindings/__init__.py +14 -0
  387. src/harness/src/openharness/keybindings/default_bindings.py +11 -0
  388. src/harness/src/openharness/keybindings/loader.py +22 -0
  389. src/harness/src/openharness/keybindings/parser.py +18 -0
  390. src/harness/src/openharness/keybindings/resolver.py +13 -0
  391. src/harness/src/openharness/mcp/__init__.py +79 -0
  392. src/harness/src/openharness/mcp/client.py +298 -0
  393. src/harness/src/openharness/mcp/config.py +16 -0
  394. src/harness/src/openharness/mcp/types.py +76 -0
  395. src/harness/src/openharness/memory/__init__.py +25 -0
  396. src/harness/src/openharness/memory/agent.py +91 -0
  397. src/harness/src/openharness/memory/manager.py +183 -0
  398. src/harness/src/openharness/memory/memdir.py +52 -0
  399. src/harness/src/openharness/memory/migrate.py +137 -0
  400. src/harness/src/openharness/memory/paths.py +22 -0
  401. src/harness/src/openharness/memory/relevance.py +145 -0
  402. src/harness/src/openharness/memory/scan.py +116 -0
  403. src/harness/src/openharness/memory/schema.py +443 -0
  404. src/harness/src/openharness/memory/search.py +71 -0
  405. src/harness/src/openharness/memory/team.py +90 -0
  406. src/harness/src/openharness/memory/types.py +33 -0
  407. src/harness/src/openharness/memory/usage.py +153 -0
  408. src/harness/src/openharness/output_styles/__init__.py +5 -0
  409. src/harness/src/openharness/output_styles/loader.py +42 -0
  410. src/harness/src/openharness/permissions/__init__.py +26 -0
  411. src/harness/src/openharness/permissions/checker.py +200 -0
  412. src/harness/src/openharness/permissions/modes.py +13 -0
  413. src/harness/src/openharness/personalization/__init__.py +6 -0
  414. src/harness/src/openharness/personalization/extractor.py +138 -0
  415. src/harness/src/openharness/personalization/rules.py +64 -0
  416. src/harness/src/openharness/personalization/session_hook.py +65 -0
  417. src/harness/src/openharness/platforms.py +86 -0
  418. src/harness/src/openharness/plugins/__init__.py +53 -0
  419. src/harness/src/openharness/plugins/bundled/__init__.py +0 -0
  420. src/harness/src/openharness/plugins/installer.py +39 -0
  421. src/harness/src/openharness/plugins/loader.py +730 -0
  422. src/harness/src/openharness/plugins/schemas.py +24 -0
  423. src/harness/src/openharness/plugins/types.py +59 -0
  424. src/harness/src/openharness/prompts/__init__.py +14 -0
  425. src/harness/src/openharness/prompts/claudemd.py +48 -0
  426. src/harness/src/openharness/prompts/context.py +187 -0
  427. src/harness/src/openharness/prompts/environment.py +135 -0
  428. src/harness/src/openharness/prompts/system_prompt.py +109 -0
  429. src/harness/src/openharness/sandbox/Dockerfile +6 -0
  430. src/harness/src/openharness/sandbox/__init__.py +33 -0
  431. src/harness/src/openharness/sandbox/adapter.py +148 -0
  432. src/harness/src/openharness/sandbox/docker_backend.py +232 -0
  433. src/harness/src/openharness/sandbox/docker_image.py +103 -0
  434. src/harness/src/openharness/sandbox/path_validator.py +37 -0
  435. src/harness/src/openharness/sandbox/session.py +63 -0
  436. src/harness/src/openharness/services/__init__.py +30 -0
  437. src/harness/src/openharness/services/autodream/__init__.py +34 -0
  438. src/harness/src/openharness/services/autodream/backup.py +104 -0
  439. src/harness/src/openharness/services/autodream/lock.py +138 -0
  440. src/harness/src/openharness/services/autodream/prompt.py +128 -0
  441. src/harness/src/openharness/services/autodream/service.py +313 -0
  442. src/harness/src/openharness/services/compact/__init__.py +1725 -0
  443. src/harness/src/openharness/services/cron.py +137 -0
  444. src/harness/src/openharness/services/cron_scheduler.py +595 -0
  445. src/harness/src/openharness/services/lsp/__init__.py +216 -0
  446. src/harness/src/openharness/services/memory_extract/__init__.py +261 -0
  447. src/harness/src/openharness/services/oauth/__init__.py +0 -0
  448. src/harness/src/openharness/services/session_backend.py +97 -0
  449. src/harness/src/openharness/services/session_memory/__init__.py +139 -0
  450. src/harness/src/openharness/services/session_storage.py +230 -0
  451. src/harness/src/openharness/services/token_estimation.py +15 -0
  452. src/harness/src/openharness/services/tool_outputs.py +55 -0
  453. src/harness/src/openharness/skills/__init__.py +44 -0
  454. src/harness/src/openharness/skills/_frontmatter.py +97 -0
  455. src/harness/src/openharness/skills/bundled/__init__.py +75 -0
  456. src/harness/src/openharness/skills/bundled/content/commit.md +25 -0
  457. src/harness/src/openharness/skills/bundled/content/debug.md +25 -0
  458. src/harness/src/openharness/skills/bundled/content/diagnose.md +35 -0
  459. src/harness/src/openharness/skills/bundled/content/plan.md +39 -0
  460. src/harness/src/openharness/skills/bundled/content/review.md +33 -0
  461. src/harness/src/openharness/skills/bundled/content/simplify.md +26 -0
  462. src/harness/src/openharness/skills/bundled/content/skill-creator.md +175 -0
  463. src/harness/src/openharness/skills/bundled/content/test.md +38 -0
  464. src/harness/src/openharness/skills/loader.py +229 -0
  465. src/harness/src/openharness/skills/registry.py +29 -0
  466. src/harness/src/openharness/skills/types.py +24 -0
  467. src/harness/src/openharness/state/__init__.py +6 -0
  468. src/harness/src/openharness/state/app_state.py +30 -0
  469. src/harness/src/openharness/state/store.py +40 -0
  470. src/harness/src/openharness/swarm/__init__.py +70 -0
  471. src/harness/src/openharness/swarm/in_process.py +693 -0
  472. src/harness/src/openharness/swarm/lockfile.py +24 -0
  473. src/harness/src/openharness/swarm/mailbox.py +522 -0
  474. src/harness/src/openharness/swarm/permission_sync.py +1168 -0
  475. src/harness/src/openharness/swarm/registry.py +410 -0
  476. src/harness/src/openharness/swarm/spawn_utils.py +229 -0
  477. src/harness/src/openharness/swarm/subprocess_backend.py +171 -0
  478. src/harness/src/openharness/swarm/team_lifecycle.py +910 -0
  479. src/harness/src/openharness/swarm/types.py +398 -0
  480. src/harness/src/openharness/swarm/worktree.py +315 -0
  481. src/harness/src/openharness/tasks/__init__.py +18 -0
  482. src/harness/src/openharness/tasks/local_agent_task.py +28 -0
  483. src/harness/src/openharness/tasks/local_shell_task.py +17 -0
  484. src/harness/src/openharness/tasks/manager.py +475 -0
  485. src/harness/src/openharness/tasks/stop_task.py +11 -0
  486. src/harness/src/openharness/tasks/types.py +32 -0
  487. src/harness/src/openharness/themes/__init__.py +21 -0
  488. src/harness/src/openharness/themes/builtin.py +89 -0
  489. src/harness/src/openharness/themes/loader.py +55 -0
  490. src/harness/src/openharness/themes/schema.py +54 -0
  491. src/harness/src/openharness/tools/__init__.py +107 -0
  492. src/harness/src/openharness/tools/agent_tool.py +141 -0
  493. src/harness/src/openharness/tools/ask_user_question_tool.py +46 -0
  494. src/harness/src/openharness/tools/base.py +80 -0
  495. src/harness/src/openharness/tools/bash_tool.py +259 -0
  496. src/harness/src/openharness/tools/brief_tool.py +33 -0
  497. src/harness/src/openharness/tools/config_tool.py +54 -0
  498. src/harness/src/openharness/tools/cron_create_tool.py +105 -0
  499. src/harness/src/openharness/tools/cron_delete_tool.py +32 -0
  500. src/harness/src/openharness/tools/cron_list_tool.py +69 -0
  501. src/harness/src/openharness/tools/cron_toggle_tool.py +37 -0
  502. src/harness/src/openharness/tools/enter_plan_mode_tool.py +28 -0
  503. src/harness/src/openharness/tools/enter_worktree_tool.py +80 -0
  504. src/harness/src/openharness/tools/exit_plan_mode_tool.py +28 -0
  505. src/harness/src/openharness/tools/exit_worktree_tool.py +42 -0
  506. src/harness/src/openharness/tools/file_edit_tool.py +95 -0
  507. src/harness/src/openharness/tools/file_read_tool.py +72 -0
  508. src/harness/src/openharness/tools/file_write_tool.py +87 -0
  509. src/harness/src/openharness/tools/glob_tool.py +175 -0
  510. src/harness/src/openharness/tools/grep_tool.py +374 -0
  511. src/harness/src/openharness/tools/image_generation_tool.py +447 -0
  512. src/harness/src/openharness/tools/image_to_text_tool.py +237 -0
  513. src/harness/src/openharness/tools/list_mcp_resources_tool.py +36 -0
  514. src/harness/src/openharness/tools/lsp_tool.py +154 -0
  515. src/harness/src/openharness/tools/mcp_auth_tool.py +71 -0
  516. src/harness/src/openharness/tools/mcp_tool.py +72 -0
  517. src/harness/src/openharness/tools/notebook_edit_tool.py +97 -0
  518. src/harness/src/openharness/tools/read_mcp_resource_tool.py +38 -0
  519. src/harness/src/openharness/tools/remote_trigger_tool.py +74 -0
  520. src/harness/src/openharness/tools/send_message_tool.py +58 -0
  521. src/harness/src/openharness/tools/skill_tool.py +43 -0
  522. src/harness/src/openharness/tools/sleep_tool.py +32 -0
  523. src/harness/src/openharness/tools/task_create_tool.py +56 -0
  524. src/harness/src/openharness/tools/task_get_tool.py +33 -0
  525. src/harness/src/openharness/tools/task_list_tool.py +35 -0
  526. src/harness/src/openharness/tools/task_output_tool.py +35 -0
  527. src/harness/src/openharness/tools/task_stop_tool.py +30 -0
  528. src/harness/src/openharness/tools/task_update_tool.py +50 -0
  529. src/harness/src/openharness/tools/team_create_tool.py +31 -0
  530. src/harness/src/openharness/tools/team_delete_tool.py +30 -0
  531. src/harness/src/openharness/tools/todo_write_tool.py +46 -0
  532. src/harness/src/openharness/tools/tool_search_tool.py +38 -0
  533. src/harness/src/openharness/tools/web_fetch_tool.py +117 -0
  534. src/harness/src/openharness/tools/web_search_tool.py +119 -0
  535. src/harness/src/openharness/ui/__init__.py +5 -0
  536. src/harness/src/openharness/ui/app.py +320 -0
  537. src/harness/src/openharness/ui/backend_host.py +941 -0
  538. src/harness/src/openharness/ui/coordinator_drain.py +198 -0
  539. src/harness/src/openharness/ui/input.py +32 -0
  540. src/harness/src/openharness/ui/output.py +265 -0
  541. src/harness/src/openharness/ui/permission_dialog.py +14 -0
  542. src/harness/src/openharness/ui/protocol.py +247 -0
  543. src/harness/src/openharness/ui/react_launcher.py +179 -0
  544. src/harness/src/openharness/ui/runtime.py +799 -0
  545. src/harness/src/openharness/ui/textual_app.py +495 -0
  546. src/harness/src/openharness/utils/__init__.py +0 -0
  547. src/harness/src/openharness/utils/file_lock.py +86 -0
  548. src/harness/src/openharness/utils/fs.py +98 -0
  549. src/harness/src/openharness/utils/helpers.py +77 -0
  550. src/harness/src/openharness/utils/network_guard.py +340 -0
  551. src/harness/src/openharness/utils/shell.py +147 -0
  552. src/harness/src/openharness/vim/__init__.py +5 -0
  553. src/harness/src/openharness/vim/transitions.py +8 -0
  554. src/harness/src/openharness/voice/__init__.py +7 -0
  555. src/harness/src/openharness/voice/keyterms.py +10 -0
  556. src/harness/src/openharness/voice/stream_stt.py +8 -0
  557. src/harness/src/openharness/voice/voice_mode.py +44 -0
  558. src/llm/__init__.py +29 -0
  559. src/llm/image_generation.py +402 -0
  560. src/llm/provider_registry.py +419 -0
  561. src/llm/runtime_env.py +64 -0
  562. src/llm/structured_output.py +131 -0
  563. src/llm/vision.py +198 -0
  564. src/memory/__init__.py +2 -0
  565. src/memory/api/__init__.py +1 -0
  566. src/memory/api/base_memory_system_api.py +98 -0
  567. src/memory/api/base_symbolic_memory_system_api.py +190 -0
  568. src/memory/api/base_vector_memory_system_api.py +116 -0
  569. src/memory/api/faiss_memory_system_api.py +228 -0
  570. src/memory/api/slot_process_api.py +827 -0
  571. src/memory/api/symbolic_memory_system_api.py +572 -0
  572. src/memory/memory_system/__init__.py +48 -0
  573. src/memory/memory_system/component_taxonomy.py +430 -0
  574. src/memory/memory_system/llm.py +437 -0
  575. src/memory/memory_system/models.py +168 -0
  576. src/memory/memory_system/user_prompt.py +418 -0
  577. src/memory/memory_system/utils.py +282 -0
  578. src/memory/memory_system/vectorstore.py +311 -0
  579. src/memory/memory_system/working_slot.py +99 -0
  580. src/pipeline/__init__.py +129 -0
  581. src/pipeline/contracts.py +531 -0
  582. src/pipeline/discipline_taxonomy.py +916 -0
  583. src/pipeline/evidence_coverage_ledger.py +1150 -0
  584. src/pipeline/evidence_refinement.py +620 -0
  585. src/pipeline/experiment_to_symbolic.py +137 -0
  586. src/pipeline/multimodal_evidence/__init__.py +28 -0
  587. src/pipeline/multimodal_evidence/capabilities.py +107 -0
  588. src/pipeline/multimodal_evidence/claims.py +135 -0
  589. src/pipeline/multimodal_evidence/contract.py +291 -0
  590. src/pipeline/multimodal_evidence/data_sh_compiler.py +505 -0
  591. src/pipeline/multimodal_evidence/handoff_binding.py +135 -0
  592. src/pipeline/multimodal_evidence/manifest.py +254 -0
  593. src/pipeline/multimodal_evidence/native_analysis.py +479 -0
  594. src/pipeline/multimodal_evidence/perception.py +277 -0
  595. src/pipeline/multimodal_evidence/query_binding.py +65 -0
  596. src/pipeline/multimodal_evidence/rendering.py +120 -0
  597. src/pipeline/multimodal_evidence/safety.py +42 -0
  598. src/pipeline/multimodal_evidence/sampling.py +69 -0
  599. src/pipeline/multimodal_evidence/service.py +148 -0
  600. src/pipeline/multimodal_evidence/survey_integration.py +460 -0
  601. src/pipeline/paper_identity.py +52 -0
  602. src/pipeline/research_design_inventory.py +338 -0
  603. src/pipeline/research_domain_resolution.py +669 -0
  604. src/pipeline/research_identity.py +548 -0
  605. src/pipeline/research_question_contract.py +646 -0
  606. src/pipeline/retrieval_lanes.py +889 -0
  607. src/pipeline/run_loop.py +862 -0
  608. src/pipeline/science_events.py +30 -0
  609. src/pipeline/science_manifests.py +602 -0
  610. src/pipeline/science_run.py +918 -0
  611. src/pipeline/science_stages.py +709 -0
  612. src/pipeline/science_workflow.py +807 -0
  613. src/pipeline/sh_cluster_projection.py +306 -0
  614. src/pipeline/sh_graph_provenance.py +648 -0
  615. src/pipeline/subhypothesis_decomposition.py +392 -0
  616. src/pipeline/subhypothesis_evidence.py +856 -0
  617. src/pipeline/survey_evidence_plan.py +858 -0
  618. src/pipeline/survey_gap_adjudication.py +647 -0
  619. src/pipeline/survey_gap_candidates.py +840 -0
  620. src/pipeline/survey_gap_ledger.py +683 -0
  621. src/pipeline/survey_gap_triage.py +401 -0
  622. src/pipeline/survey_handoff_persistence.py +481 -0
  623. src/pipeline/survey_handoff_projection.py +457 -0
  624. src/pipeline/survey_idea_handoff.py +1112 -0
  625. src/pipeline/survey_idea_loader.py +289 -0
  626. src/research_run_ids.py +11 -0
@@ -0,0 +1,1713 @@
1
+ """
2
+ DataManager: 负责数据读写、缓存管理和API调用
3
+ 从WorkCollector中分离出来,供其他模块(如PaperGraphRetriever)复用
4
+ """
5
+ from typing import Any, Dict, List, Mapping, Optional
6
+ import os
7
+ import sys
8
+ import json
9
+ from urllib.parse import urlsplit, urlunsplit
10
+ sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
11
+ from utils.api_call import ArxivAPI, OpenAlexAPI, SemanticScholarAPI, UnpaywallAPI
12
+ from utils.rich_logger import get_logger
13
+ from utils.mineru_utils import parse_doc
14
+ from utils.utils import get_hash, is_valid_pdf
15
+ from modules.fulltext_resolution import (
16
+ resolve_declared_pdf_links,
17
+ resolve_fulltext_candidates,
18
+ )
19
+ from modules.fulltext_download_cache import FulltextDownloadCoordinator
20
+ from modules.fulltext_http import FulltextHttpClient
21
+ import requests
22
+ from contextlib import closing
23
+ import diskcache as dc
24
+ import torch
25
+ import gc
26
+ import time
27
+ from sentence_transformers import SentenceTransformer, util
28
+ from concurrent.futures import ThreadPoolExecutor, as_completed
29
+ import threading
30
+ from utils.gpu_utils import load_sentence_transformer_auto
31
+
32
+
33
+ class DataManager:
34
+ """数据管理类:负责paper的下载、解析、缓存和API查询"""
35
+
36
+ def __init__(self, config):
37
+ self.config = config
38
+ self.cache_path = self.config.BasicInfo.cache_path
39
+ self.logger = get_logger("DataManager")
40
+
41
+ # 初始化APIs
42
+ self.openalex_api = OpenAlexAPI(config)
43
+ self.semantic_scholar_api = SemanticScholarAPI(config)
44
+ self.arxiv_api = ArxivAPI(config)
45
+ self.unpaywall_api = UnpaywallAPI(config)
46
+ self.fulltext_download_coordinator = FulltextDownloadCoordinator(
47
+ self.cache_path,
48
+ self.config.ModuleInfo.WorkCollector,
49
+ logger=self.logger,
50
+ )
51
+ self.fulltext_http_client = FulltextHttpClient(
52
+ self.config.ModuleInfo.WorkCollector,
53
+ contact_email=getattr(self.unpaywall_api, "email", ""),
54
+ logger=self.logger,
55
+ )
56
+ self._fulltext_artifact_lock = threading.RLock()
57
+
58
+ # 初始化缓存
59
+ self.paper_abstract_cache = dc.Cache(
60
+ os.path.join(self.cache_path, "paper_abstracts")
61
+ )
62
+
63
+ # title to paper_id lookup cache (for filter_papers_local_paper_graph)
64
+ self.title_lookup_cache = dc.Cache(
65
+ os.path.join(self.cache_path, "title_lookup_cache")
66
+ )
67
+
68
+ # embedding model (lazy loading)
69
+ self.embedding_model = None
70
+ self._model_device = None
71
+
72
+ def _get_embedding_model(self):
73
+ """Lazy load and cache the embedding model."""
74
+ if self.embedding_model is not None:
75
+ return self.embedding_model
76
+
77
+ model_name = self.config.ModuleInfo.WorkCollector.sentence_transformer_model
78
+ try:
79
+ self.embedding_model, self._model_device = load_sentence_transformer_auto(
80
+ model_name,
81
+ logger=self.logger,
82
+ )
83
+ except Exception as e:
84
+ if "out of memory" in str(e).lower():
85
+ self.logger.error("Out of memory error detected. Using CPU instead.")
86
+ try:
87
+ torch.cuda.empty_cache()
88
+ gc.collect()
89
+ except Exception:
90
+ self.logger.warning("Failed to clear GPU cache.")
91
+ pass
92
+ self.embedding_model, self._model_device = load_sentence_transformer_auto(
93
+ model_name,
94
+ logger=self.logger,
95
+ )
96
+ else:
97
+ try:
98
+ self.embedding_model, self._model_device = load_sentence_transformer_auto(
99
+ model_name,
100
+ logger=self.logger,
101
+ )
102
+ except Exception as e2:
103
+ self.logger.error(f"Failed to load SentenceTransformer model: {e2}")
104
+ raise e2
105
+
106
+ return self.embedding_model
107
+
108
+ @staticmethod
109
+ def _resolve_paper_reference_id(paper_ref: Any) -> str:
110
+ """Normalize a paper lookup result into the cache/download paper id."""
111
+ if isinstance(paper_ref, str):
112
+ return paper_ref.strip()
113
+
114
+ if not isinstance(paper_ref, dict):
115
+ return ""
116
+
117
+ openalex_id = OpenAlexAPI.normalize_work_id(
118
+ paper_ref.get("openalex_id") or paper_ref.get("id")
119
+ )
120
+ if openalex_id:
121
+ return openalex_id
122
+
123
+ external_ids = paper_ref.get("externalIds")
124
+ if isinstance(external_ids, dict):
125
+ for key in ("ArXiv", "arXiv", "ARXIV"):
126
+ value = external_ids.get(key)
127
+ if value is not None and str(value).strip():
128
+ return str(value).strip()
129
+
130
+ for key in ("paperId", "paper_id", "id", "corpusId"):
131
+ value = paper_ref.get(key)
132
+ if value is not None and str(value).strip():
133
+ return str(value).strip()
134
+
135
+ return ""
136
+
137
+ def is_valid_abstract(self, abstract: str) -> bool:
138
+ if not isinstance(abstract, str):
139
+ return False
140
+ if not abstract or abstract.strip() == "" or abstract == "abstract not found" or len(abstract) < 50:
141
+ return False
142
+ if len(abstract) < 300 and len(abstract) > 50:
143
+ self.logger.warning(f"abstract too short, deug for safety: {abstract}")
144
+ return True
145
+
146
+ def add_papers_abstracts_in_cache(self, papers: List[str], retry: int = 1):
147
+ """获取并缓存paper的摘要信息"""
148
+ err_papers = []
149
+ for pid in papers:
150
+ hash_id = get_hash(pid)
151
+ # already cached
152
+ if hash_id in self.paper_abstract_cache:
153
+ if self.is_valid_abstract(self.paper_abstract_cache[hash_id]["abstract"]):
154
+ continue
155
+
156
+ abstract = ""
157
+ title = ""
158
+
159
+ # fetch minimal metadata including abstract
160
+ for attempt in range(retry):
161
+ paper = None
162
+ if OpenAlexAPI.is_openalex_work_id(pid):
163
+ try:
164
+ paper = self.openalex_api.get_paper_details(pid)
165
+ except Exception as e:
166
+ self.logger.warning(
167
+ f"Error fetching paper {pid} details from OpenAlex: {e}. "
168
+ f"Retrying {attempt + 1}/{retry}..."
169
+ )
170
+ paper = None
171
+ else:
172
+ query_id = f"ARXIV:{pid}" if "." in pid else pid
173
+ try:
174
+ if "." in pid and "v" in pid:
175
+ raise ValueError(
176
+ f"arxiv id with version that semantic scholar cannot read: {pid}"
177
+ )
178
+ paper = self.semantic_scholar_api.get_paper_details(
179
+ query_id, fields="abstract,title,externalIds"
180
+ )
181
+ abstract = paper.get("abstract", "") or ""
182
+ if not abstract:
183
+ raise ValueError(
184
+ f"Failed to get abstract for {query_id} in semantic scholar, turn to arxiv"
185
+ )
186
+ except Exception as e:
187
+ self.logger.warning(
188
+ f"Error fetching paper {pid} details from Semantic Scholar: {e}. "
189
+ f"Retrying {attempt + 1}/{retry}..."
190
+ )
191
+ paper = None
192
+ if not paper and "." in pid:
193
+ try:
194
+ self.logger.info(f"Trying arXiv API for paper {pid} as fallback.")
195
+ paper = self.arxiv_api.get_paper_details(pid)
196
+ except Exception as e:
197
+ self.logger.warning(
198
+ f"Error fetching from arXiv for {pid}: {e}. "
199
+ f"Retrying {attempt + 1}/{retry}..."
200
+ )
201
+ paper = None
202
+
203
+ if not paper:
204
+ continue
205
+
206
+ abstract = paper.get("abstract", "") or ""
207
+ title = paper.get("title", "") or ""
208
+
209
+ if not self.is_valid_abstract(abstract):
210
+ self.logger.warning(f"No abstract found for {pid}. or abstract too short, len: {len(abstract)}.")
211
+ err_papers.append(pid)
212
+ continue
213
+
214
+ if self.config.BasicInfo.debug:
215
+ self.logger.info(f"Add abstract for {pid}: {len(abstract)} chars.")
216
+ break
217
+
218
+ if self.is_valid_abstract(abstract):
219
+ self.paper_abstract_cache[hash_id] = {
220
+ "paper_id": pid,
221
+ "abstract": abstract,
222
+ "title": title,
223
+ }
224
+
225
+ if self.config.BasicInfo.debug:
226
+ self.logger.info(f"Cached abstract for {pid}: {len(abstract)} chars.")
227
+ else:
228
+ err_papers.append(pid)
229
+
230
+ return err_papers
231
+
232
+ def get_paper_title_abstract(self, paper_id: str, retry: int = 1):
233
+ """获取paper的title和abstract"""
234
+ hash_id = get_hash(paper_id)
235
+ # already cached
236
+ if hash_id in self.paper_abstract_cache and self.is_valid_abstract(self.paper_abstract_cache[hash_id]['abstract']):
237
+ # if self.config.BasicInfo.debug:
238
+ # self.logger.info(f"Cache hit for paper {paper_id} abstract, len: {len(self.paper_abstract_cache[hash_id]['abstract'])}")
239
+ return self.paper_abstract_cache[hash_id]['title'], self.paper_abstract_cache[hash_id]['abstract']
240
+
241
+ self.add_papers_abstracts_in_cache([paper_id], retry=retry)
242
+ if hash_id in self.paper_abstract_cache and self.is_valid_abstract(self.paper_abstract_cache[hash_id]['abstract']):
243
+ if self.config.BasicInfo.debug:
244
+ self.logger.info(f"Fetched and cached abstract for paper {paper_id}, len: {len(self.paper_abstract_cache[hash_id]['abstract'])}")
245
+ return self.paper_abstract_cache[hash_id]['title'], self.paper_abstract_cache[hash_id]['abstract']
246
+ else:
247
+ raise ValueError(f"Failed to get valid abstract for paper ID {paper_id} after {retry} retries.")
248
+
249
+ def get_paper_title(self, paper_id: str, retry: int = 3):
250
+ """获取paper的title"""
251
+ hash_id = get_hash(paper_id)
252
+ # already cached
253
+ if hash_id in self.paper_abstract_cache:
254
+ # if self.config.BasicInfo.debug:
255
+ # self.logger.info(f"Cache hit for paper {paper_id} title, title: {self.paper_abstract_cache[hash_id]['title']}")
256
+ return self.paper_abstract_cache[hash_id]['title']
257
+
258
+ self.add_papers_abstracts_in_cache([paper_id], retry=retry)
259
+ if hash_id in self.paper_abstract_cache:
260
+ if self.config.BasicInfo.debug:
261
+ self.logger.info(f"Fetched and cached title for paper {paper_id}, len: {len(self.paper_abstract_cache[hash_id]['title'])}")
262
+ return self.paper_abstract_cache[hash_id]['title']
263
+ else:
264
+ raise ValueError(f"Failed to get valid abstract for paper ID {paper_id} after {retry} retries.")
265
+
266
+ @staticmethod
267
+ def _deduplicate_urls(urls):
268
+ seen = set()
269
+ unique_urls = []
270
+ for url in urls:
271
+ normalized = str(url or "").strip()
272
+ if normalized and normalized not in seen:
273
+ seen.add(normalized)
274
+ unique_urls.append(normalized)
275
+ return unique_urls
276
+
277
+ def _fulltext_setting(self, name, default):
278
+ module_info = getattr(getattr(self, "config", None), "ModuleInfo", None)
279
+ work_collector = getattr(module_info, "WorkCollector", None)
280
+ return getattr(work_collector, name, default)
281
+
282
+ @staticmethod
283
+ def _fulltext_enabled(value):
284
+ return str(value).strip().casefold() not in {"0", "false", "no", "off"}
285
+
286
+ @staticmethod
287
+ def _safe_provenance_url(url):
288
+ """Keep URL provenance useful without persisting signed query parameters."""
289
+ raw = str(url or "").strip()
290
+ if not raw:
291
+ return ""
292
+ parsed = urlsplit(raw)
293
+ if parsed.scheme.lower() not in {"http", "https"} or not parsed.netloc:
294
+ return ""
295
+ return urlunsplit((parsed.scheme, parsed.netloc, parsed.path, "", ""))
296
+
297
+ def _fulltext_provenance_path(self, paper_id):
298
+ return os.path.join(
299
+ self.cache_path,
300
+ "fulltext_provenance",
301
+ f"{paper_id}.json",
302
+ )
303
+
304
+ def _write_fulltext_provenance(self, paper_id, payload):
305
+ """Atomically persist non-sensitive acquisition provenance for one paper."""
306
+ if not self._fulltext_enabled(self._fulltext_setting("fulltext_provenance_enabled", True)):
307
+ return
308
+ path = self._fulltext_provenance_path(paper_id)
309
+ directory = os.path.dirname(path)
310
+ os.makedirs(directory, exist_ok=True)
311
+ temporary_path = f"{path}.tmp"
312
+ try:
313
+ with open(temporary_path, "w", encoding="utf-8") as handle:
314
+ json.dump(payload, handle, ensure_ascii=False, indent=2, sort_keys=True)
315
+ os.replace(temporary_path, path)
316
+ except OSError as exc:
317
+ self.logger.warning(
318
+ "Could not write full-text provenance for %s: %s", paper_id, exc
319
+ )
320
+ try:
321
+ if os.path.exists(temporary_path):
322
+ os.remove(temporary_path)
323
+ except OSError:
324
+ pass
325
+
326
+ @staticmethod
327
+ def _fulltext_summary_from_provenance(paper_id, provenance, provenance_path):
328
+ """Produce acquisition metadata without making a scientific claim.
329
+
330
+ A downloaded PDF makes a paper available for subsequent parsing. It is
331
+ deliberately insufficient to enable direct writing: that also requires
332
+ evidence localization and the existing writing-consumer checks.
333
+ """
334
+
335
+ outcome = dict(provenance.get("outcome") or {})
336
+ status = str(outcome.get("status") or "fulltext_fetch_failed")
337
+ resolution = dict(provenance.get("resolution") or {})
338
+ return {
339
+ "schema_version": "fulltext_acquisition_summary_v1",
340
+ "paper_id": str(paper_id or ""),
341
+ "status": status,
342
+ "fulltext_available": status in {"downloaded", "cache_hit"},
343
+ # Full-text acquisition is a prerequisite, never an automatic
344
+ # upgrade to direct scientific evidence or a writing assertion.
345
+ "writing_direct_evidence_allowed": False,
346
+ "candidate_count": int(resolution.get("candidate_count") or 0),
347
+ "attempt_count": len(provenance.get("attempts") or []),
348
+ "selected_source": str(outcome.get("selected_source") or ""),
349
+ "provenance_path": str(provenance_path or ""),
350
+ }
351
+
352
+ @classmethod
353
+ def _attach_fulltext_summary_to_paper_records(
354
+ cls,
355
+ value,
356
+ paper_id,
357
+ summary,
358
+ _visited=None,
359
+ ):
360
+ """Attach an acquisition summary without touching semantic fields."""
361
+
362
+ visited = _visited if isinstance(_visited, set) else set()
363
+ if isinstance(value, list):
364
+ if id(value) in visited:
365
+ return
366
+ visited.add(id(value))
367
+ for item in value:
368
+ cls._attach_fulltext_summary_to_paper_records(
369
+ item, paper_id, summary, visited
370
+ )
371
+ return
372
+ if not isinstance(value, dict):
373
+ return
374
+ if id(value) in visited:
375
+ return
376
+ visited.add(id(value))
377
+ identifiers = {
378
+ str(value.get(key) or "").strip()
379
+ for key in ("paperId", "paper_id", "openalex_id", "id")
380
+ }
381
+ external_ids = value.get("externalIds")
382
+ if isinstance(external_ids, dict):
383
+ identifiers.update(
384
+ str(external_ids.get(key) or "").strip()
385
+ for key in ("OpenAlex", "ArXiv", "arXiv")
386
+ )
387
+ if str(paper_id or "") in identifiers:
388
+ value["fulltext_acquisition"] = dict(summary)
389
+ for key, child in value.items():
390
+ if key in {"fulltext_acquisition", "fulltext_acquisition_by_paper"}:
391
+ continue
392
+ if isinstance(child, (dict, list)):
393
+ cls._attach_fulltext_summary_to_paper_records(
394
+ child, paper_id, summary, visited
395
+ )
396
+
397
+ def _persist_sh_graph_fulltext_summary(self, graph_artifact):
398
+ basic_info = self.config.BasicInfo
399
+ artifact_path = str(
400
+ getattr(basic_info, "sh_graph_provenance_artifact_path", "") or ""
401
+ ).strip()
402
+ if not artifact_path:
403
+ base_dir = str(getattr(basic_info, "base_dir", "") or "").strip()
404
+ if base_dir:
405
+ artifact_path = os.path.join(base_dir, "sh_graph_provenance.json")
406
+ if not artifact_path:
407
+ return
408
+ directory = os.path.dirname(artifact_path)
409
+ temporary_path = f"{artifact_path}.tmp"
410
+ try:
411
+ os.makedirs(directory, exist_ok=True)
412
+ with open(temporary_path, "w", encoding="utf-8") as handle:
413
+ json.dump(graph_artifact, handle, ensure_ascii=False, indent=2)
414
+ os.replace(temporary_path, artifact_path)
415
+ except OSError as exc:
416
+ self.logger.warning("Could not persist SH graph full-text summary: %s", exc)
417
+ self._remove_incomplete_pdf(temporary_path)
418
+
419
+ def _publish_fulltext_acquisition_summary(self, paper_id, provenance):
420
+ """Write acquisition state to artifacts while preserving SH semantics.
421
+
422
+ This method only adds a separate `fulltext_acquisition` namespace. It
423
+ must not clear or reinterpret `sh_semantic_assessments`, `sh_matches`,
424
+ seed tiers, graph modes, or direct-evidence annotations.
425
+ """
426
+
427
+ if not self._fulltext_enabled(self._fulltext_setting("fulltext_provenance_enabled", True)):
428
+ return
429
+ summary = self._fulltext_summary_from_provenance(
430
+ paper_id,
431
+ provenance,
432
+ self._fulltext_provenance_path(paper_id),
433
+ )
434
+ with self._fulltext_artifact_lock:
435
+ basic_info = self.config.BasicInfo
436
+ fulltext_by_paper = getattr(basic_info, "fulltext_acquisition_by_paper", {})
437
+ fulltext_by_paper = (
438
+ dict(fulltext_by_paper) if isinstance(fulltext_by_paper, Mapping) else {}
439
+ )
440
+ fulltext_by_paper[str(paper_id)] = dict(summary)
441
+ try:
442
+ basic_info.fulltext_acquisition_by_paper = fulltext_by_paper
443
+ except Exception:
444
+ pass
445
+
446
+ sh_artifact = getattr(basic_info, "subhypothesis_retrieval", None)
447
+ if isinstance(sh_artifact, dict):
448
+ sh_artifact.setdefault("fulltext_acquisition_by_paper", {})[str(paper_id)] = dict(summary)
449
+ self._attach_fulltext_summary_to_paper_records(sh_artifact, paper_id, summary)
450
+
451
+ graph_artifact = getattr(basic_info, "sh_graph_provenance", None)
452
+ if isinstance(graph_artifact, dict):
453
+ graph_artifact.setdefault("fulltext_acquisition_by_paper", {})[str(paper_id)] = dict(summary)
454
+ self._persist_sh_graph_fulltext_summary(graph_artifact)
455
+
456
+ @classmethod
457
+ def _safe_candidate_for_provenance(cls, candidate):
458
+ if not isinstance(candidate, dict):
459
+ return {}
460
+ safe = {
461
+ key: value
462
+ for key, value in candidate.items()
463
+ if key
464
+ in {
465
+ "kind",
466
+ "source",
467
+ "sources",
468
+ "priority",
469
+ "version",
470
+ "license",
471
+ "host_type",
472
+ "evidence",
473
+ "metadata_field",
474
+ "oa_evidence",
475
+ "doi",
476
+ "status",
477
+ }
478
+ and value not in (None, "", [], {})
479
+ }
480
+ safe["url"] = cls._safe_provenance_url(candidate.get("url"))
481
+ return safe
482
+
483
+ @classmethod
484
+ def _download_status_for_http_status(cls, status_code):
485
+ if status_code == 404:
486
+ return "not_found"
487
+ if status_code == 401:
488
+ return "authentication_required"
489
+ if status_code == 403:
490
+ return "access_denied"
491
+ if status_code == 429:
492
+ return "rate_limited"
493
+ if isinstance(status_code, int) and status_code >= 500:
494
+ return "transient_failure"
495
+ return "http_error"
496
+
497
+ @staticmethod
498
+ def _remove_incomplete_pdf(filename):
499
+ for path in (filename, f"{filename}.part"):
500
+ try:
501
+ if os.path.exists(path):
502
+ os.remove(path)
503
+ except OSError:
504
+ pass
505
+
506
+ @staticmethod
507
+ def _paper_doi(paper):
508
+ if not isinstance(paper, dict):
509
+ return ""
510
+ external_ids = paper.get("externalIds")
511
+ if isinstance(external_ids, dict):
512
+ doi = external_ids.get("DOI") or external_ids.get("doi")
513
+ if str(doi or "").strip():
514
+ return UnpaywallAPI.normalize_doi(doi)
515
+ return UnpaywallAPI.normalize_doi(paper.get("doi"))
516
+
517
+ @staticmethod
518
+ def _paper_download_id(paper):
519
+ if isinstance(paper, str):
520
+ return paper.strip()
521
+ if not isinstance(paper, dict):
522
+ return ""
523
+ if str(paper.get("api_platform") or "").lower() == "openalex":
524
+ openalex_id = OpenAlexAPI.normalize_work_id(
525
+ paper.get("openalex_id") or paper.get("paperId")
526
+ )
527
+ if openalex_id:
528
+ return openalex_id
529
+ external_ids = paper.get("externalIds")
530
+ if isinstance(external_ids, dict):
531
+ arxiv_id = external_ids.get("ArXiv") or external_ids.get("arXiv")
532
+ if str(arxiv_id or "").strip():
533
+ return str(arxiv_id).strip()
534
+ return str(paper.get("paperId") or paper.get("paper_id") or "").strip()
535
+
536
+ def _has_pdf_download_candidate(self, paper):
537
+ if isinstance(paper, str):
538
+ return "." in paper or OpenAlexAPI.is_openalex_work_id(paper)
539
+ if not isinstance(paper, dict):
540
+ return False
541
+ external_ids = paper.get("externalIds")
542
+ if isinstance(external_ids, dict) and any(
543
+ str(external_ids.get(key) or "").strip() for key in ("ArXiv", "arXiv")
544
+ ):
545
+ return True
546
+ open_access_pdf = paper.get("openAccessPdf")
547
+ if isinstance(open_access_pdf, dict) and str(open_access_pdf.get("url") or "").strip():
548
+ return True
549
+ openalex_oa_locations = paper.get("openalex_oa_locations")
550
+ if isinstance(openalex_oa_locations, (list, tuple)) and any(
551
+ isinstance(location, Mapping)
552
+ and any(
553
+ str(location.get(key) or "").strip()
554
+ for key in ("pdf_url", "url_for_pdf", "landing_page_url", "url")
555
+ )
556
+ for location in openalex_oa_locations
557
+ ):
558
+ return True
559
+ if any(
560
+ str(paper.get(key) or "").strip()
561
+ for key in (
562
+ "repository_pdf",
563
+ "repository_url",
564
+ "accepted_manuscript_url",
565
+ "author_manuscript_url",
566
+ "open_repository_url",
567
+ "pmcid",
568
+ )
569
+ ):
570
+ return True
571
+ if isinstance(external_ids, dict) and any(
572
+ str(external_ids.get(key) or "").strip()
573
+ for key in ("PMCID", "pmcid", "PMC")
574
+ ):
575
+ return True
576
+ generic_direct_fields = (
577
+ "pdf_url",
578
+ "url_for_pdf",
579
+ "full_text_pdf_url",
580
+ "fulltext_pdf_url",
581
+ "oa_pdf_url",
582
+ )
583
+ if any(str(paper.get(key) or "").strip() for key in generic_direct_fields):
584
+ return True
585
+ for key in ("full_text_url", "fulltext_url"):
586
+ value = str(paper.get(key) or "").strip()
587
+ if urlsplit(value).path.lower().endswith(".pdf"):
588
+ return True
589
+ # Let resolution record a generic landing URL as disabled when it lacks
590
+ # OA evidence; the resolver itself will not make an HTTP request in
591
+ # that case. This keeps the provenance explainable instead of
592
+ # silently discarding incoming metadata.
593
+ if any(
594
+ str(paper.get(key) or "").strip()
595
+ for key in (
596
+ "full_text_url",
597
+ "fulltext_url",
598
+ "landing_page_url",
599
+ "url_for_landing_page",
600
+ )
601
+ ):
602
+ return True
603
+ for container_key in ("fulltext", "full_text"):
604
+ container = paper.get(container_key)
605
+ if isinstance(container, Mapping) and any(
606
+ str(container.get(key) or "").strip()
607
+ for key in (
608
+ "pdf_url",
609
+ "url_for_pdf",
610
+ "full_text_pdf_url",
611
+ "fulltext_pdf_url",
612
+ "oa_pdf_url",
613
+ "full_text_url",
614
+ "fulltext_url",
615
+ "landing_page_url",
616
+ "url_for_landing_page",
617
+ )
618
+ ):
619
+ return True
620
+ return bool(self._paper_doi(paper) and self.unpaywall_api.enabled)
621
+
622
+ def _prepare_download_info(self, paper, reference_graph=None):
623
+ """Build ordered, provenance-rich legal full-text candidates for one paper."""
624
+ del reference_graph
625
+ if isinstance(paper, str) and OpenAlexAPI.is_openalex_work_id(paper):
626
+ resolved_paper = self.openalex_api.get_paper_details(paper)
627
+ if not resolved_paper:
628
+ return None
629
+ return self._prepare_download_info(resolved_paper)
630
+
631
+ resolution = {}
632
+ download_candidates = []
633
+ paper_id = ""
634
+ paper_title = ""
635
+
636
+ if isinstance(paper, dict):
637
+ paper_id = self._paper_download_id(paper)
638
+ if not paper_id:
639
+ return None
640
+ resolution = resolve_fulltext_candidates(
641
+ paper,
642
+ unpaywall_api=(
643
+ self.unpaywall_api
644
+ if self._fulltext_enabled(
645
+ self._fulltext_setting("fulltext_resolution_enabled", True)
646
+ )
647
+ else None
648
+ ),
649
+ include_all_unpaywall_locations=self._fulltext_enabled(
650
+ self._fulltext_setting(
651
+ "fulltext_unpaywall_all_locations_enabled", True
652
+ )
653
+ ),
654
+ include_generic_metadata_urls=self._fulltext_enabled(
655
+ self._fulltext_setting("fulltext_generic_metadata_urls_enabled", True)
656
+ ),
657
+ include_doi_landing_fallback=self._fulltext_enabled(
658
+ self._fulltext_setting("fulltext_doi_landing_fallback_enabled", True)
659
+ ),
660
+ )
661
+ download_candidates = list(resolution.get("candidates") or [])
662
+ paper_title = str(paper.get("title") or paper_id)
663
+ elif isinstance(paper, str) and "." in paper:
664
+ paper_id = paper.strip()
665
+ download_candidates.extend(
666
+ [
667
+ {
668
+ "url": f"https://arxiv.org/pdf/{paper_id}.pdf",
669
+ "kind": "pdf",
670
+ "source": "identifier.arxiv",
671
+ "priority": 100,
672
+ "version": "preprint",
673
+ },
674
+ {
675
+ "url": f"https://export.arxiv.org/pdf/{paper_id}.pdf",
676
+ "kind": "pdf",
677
+ "source": "identifier.arxiv_mirror",
678
+ "priority": 101,
679
+ "version": "preprint",
680
+ },
681
+ ]
682
+ )
683
+ resolution = {
684
+ "schema_version": "fulltext_resolution_v2",
685
+ "paper_id": paper_id,
686
+ "doi": "",
687
+ "candidates": list(download_candidates),
688
+ "deferred_candidates": [],
689
+ "disabled_candidates": [],
690
+ "unpaywall": {"status": "not_requested"},
691
+ }
692
+ paper_title = paper_id
693
+ else:
694
+ return None
695
+
696
+ pdf_path = os.path.join(
697
+ self.cache_path,
698
+ "pdf_papers",
699
+ paper_id,
700
+ f"{paper_id}.pdf",
701
+ )
702
+ return (paper_id, download_candidates, pdf_path, paper_title, resolution)
703
+
704
+ def _record_no_fulltext_candidate(self, paper, resolution=None):
705
+ paper_id = self._paper_download_id(paper)
706
+ if not paper_id:
707
+ return
708
+ resolution = dict(resolution or {})
709
+ provenance = {
710
+ "schema_version": "fulltext_acquisition_v1",
711
+ "paper_id": paper_id,
712
+ "doi": self._paper_doi(paper) if isinstance(paper, dict) else "",
713
+ "resolution": {
714
+ "unpaywall": dict(
715
+ resolution.get("unpaywall") or {"status": "not_requested"}
716
+ ),
717
+ "candidate_count": 0,
718
+ "candidates": [],
719
+ "access_context_generation": self.fulltext_download_coordinator.access_context_generation,
720
+ },
721
+ "attempts": [],
722
+ "outcome": {"status": "no_open_access_candidate", "attempted_candidate_count": 0},
723
+ }
724
+ self._write_fulltext_provenance(paper_id, provenance)
725
+ self._publish_fulltext_acquisition_summary(paper_id, provenance)
726
+
727
+ def _download_pdf_candidate(self, candidate, *, pdf_path, paper_title, index, total):
728
+ """Acquire one PDF route under URL/host coordination and validate it."""
729
+
730
+ url = str(candidate.get("url") or "")
731
+ source = str(candidate.get("source") or "")
732
+
733
+ def perform_network_download():
734
+ attempt = self._download_pdf_with_resume(
735
+ url=url,
736
+ filename=pdf_path,
737
+ title=f"{paper_title} ({index}/{total})",
738
+ is_arxiv=source.startswith("identifier.arxiv"),
739
+ return_attempt=True,
740
+ )
741
+ if attempt.get("downloaded") and not is_valid_pdf(pdf_path):
742
+ attempt["downloaded"] = False
743
+ attempt["status"] = "non_pdf"
744
+ self._remove_incomplete_pdf(pdf_path)
745
+ return attempt
746
+
747
+ return self.fulltext_download_coordinator.execute(
748
+ url=url,
749
+ kind="pdf",
750
+ destination_path=pdf_path,
751
+ validator=is_valid_pdf,
752
+ operation=perform_network_download,
753
+ )
754
+
755
+ def _resolve_landing_candidate(self, candidate):
756
+ """Resolve a declared OA landing page through the same URL/host guard."""
757
+
758
+ def resolve_landing():
759
+ return resolve_declared_pdf_links(
760
+ candidate,
761
+ http_client=getattr(self, "fulltext_http_client", None),
762
+ timeout_seconds=float(
763
+ self._fulltext_setting("fulltext_landing_page_timeout_seconds", 20)
764
+ ),
765
+ max_html_bytes=int(
766
+ self._fulltext_setting("fulltext_landing_page_max_bytes", 1_500_000)
767
+ ),
768
+ max_pdf_links=int(
769
+ self._fulltext_setting("fulltext_max_declared_pdf_links_per_landing_page", 8)
770
+ ),
771
+ max_redirects=int(
772
+ self._fulltext_setting("fulltext_landing_page_max_redirects", 5)
773
+ ),
774
+ )
775
+
776
+ return self.fulltext_download_coordinator.execute(
777
+ url=str(candidate.get("url") or ""),
778
+ kind="landing_page",
779
+ operation=resolve_landing,
780
+ is_success=lambda result: str(result.get("status") or "")
781
+ in {"resolved_to_pdf", "pdf_links_found"},
782
+ )
783
+
784
+ def _has_parsed_markdown(self, paper_id: str) -> bool:
785
+ markdown_path = os.path.join(
786
+ self.cache_path,
787
+ "parsed_papers",
788
+ str(paper_id),
789
+ "auto",
790
+ f"{paper_id}.md",
791
+ )
792
+ return os.path.exists(markdown_path)
793
+
794
+ def _download_single_paper(self, paper, index, total, reference_graph=None):
795
+ """下载单个paper,返回(paper_id, pdf_path)或(None, None)"""
796
+ # 准备下载信息
797
+ info = self._prepare_download_info(paper, reference_graph)
798
+ if info is None:
799
+ self._record_no_fulltext_candidate(paper)
800
+ return (None, None)
801
+
802
+ paper_id, download_candidates, pdf_path, paper_title, resolution = info
803
+ provenance = {
804
+ "schema_version": "fulltext_acquisition_v1",
805
+ "paper_id": paper_id,
806
+ "doi": str(resolution.get("doi") or ""),
807
+ "resolution": {
808
+ "unpaywall": dict(resolution.get("unpaywall") or {}),
809
+ "candidate_count": len(download_candidates),
810
+ "candidates": [
811
+ self._safe_candidate_for_provenance(candidate)
812
+ for candidate in download_candidates
813
+ if isinstance(candidate, dict)
814
+ ],
815
+ "deferred_candidates": [
816
+ self._safe_candidate_for_provenance(candidate)
817
+ for candidate in resolution.get("deferred_candidates") or []
818
+ if isinstance(candidate, dict)
819
+ ],
820
+ "disabled_candidates": [
821
+ self._safe_candidate_for_provenance(candidate)
822
+ for candidate in resolution.get("disabled_candidates") or []
823
+ if isinstance(candidate, dict)
824
+ ],
825
+ "access_context_generation": self.fulltext_download_coordinator.access_context_generation,
826
+ },
827
+ "attempts": [],
828
+ "outcome": {},
829
+ }
830
+
831
+ # 检查缓存
832
+ if (
833
+ not self.config.ModuleInfo.WorkCollector.download_safe_mode
834
+ and os.path.exists(pdf_path)
835
+ ):
836
+ if is_valid_pdf(pdf_path):
837
+ if self.config.BasicInfo.debug:
838
+ self.logger.info(f"Cache Hit! Existing PDF at {pdf_path} is valid, skipping download.")
839
+ provenance["outcome"] = {
840
+ "status": "cache_hit",
841
+ "pdf_path": pdf_path,
842
+ }
843
+ self._write_fulltext_provenance(paper_id, provenance)
844
+ self._publish_fulltext_acquisition_summary(paper_id, provenance)
845
+ return (paper_id, pdf_path)
846
+ else:
847
+ try:
848
+ os.remove(pdf_path)
849
+ except OSError as e:
850
+ self.logger.error(f"Failed to delete invalid PDF {pdf_path}: {e}")
851
+ self.logger.warning(f"Existing PDF at {pdf_path} is invalid, re-downloading.")
852
+
853
+ os.makedirs(os.path.dirname(pdf_path), exist_ok=True)
854
+
855
+ # A candidate describes one alternative legal acquisition route. A
856
+ # failure must not change the paper's SH/project relevance decision.
857
+ downloaded = False
858
+ selected_candidate = {}
859
+ candidate_queue = list(download_candidates)
860
+ candidate_index = 0
861
+ max_candidates = max(
862
+ 1,
863
+ int(self._fulltext_setting("fulltext_max_candidates_per_paper", 12) or 12),
864
+ )
865
+ while candidate_index < len(candidate_queue) and candidate_index < max_candidates:
866
+ candidate = candidate_queue[candidate_index]
867
+ candidate_index += 1
868
+ if not isinstance(candidate, dict):
869
+ continue
870
+ source = str(candidate.get("source") or "unknown")
871
+ kind = str(candidate.get("kind") or "pdf")
872
+ download_url = str(candidate.get("url") or "")
873
+ if not download_url:
874
+ continue
875
+ if kind == "landing_page":
876
+ landing = self._resolve_landing_candidate(candidate)
877
+ landing_attempt = {
878
+ **self._safe_candidate_for_provenance(candidate),
879
+ "status": str(landing.get("status") or "fetch_failed"),
880
+ "http_status": landing.get("http_status"),
881
+ "final_url": self._safe_provenance_url(landing.get("final_url")),
882
+ "requested_host": str(urlsplit(download_url).netloc or "").lower(),
883
+ "final_host": str(
884
+ urlsplit(str(landing.get("final_url") or "")).netloc or ""
885
+ ).lower(),
886
+ "content_type": str(landing.get("content_type") or ""),
887
+ "elapsed_seconds": landing.get("elapsed_seconds"),
888
+ "redirect_count": landing.get("redirect_count"),
889
+ "declared_pdf_count": len(landing.get("pdf_candidates") or []),
890
+ }
891
+ provenance["attempts"].append(landing_attempt)
892
+ if source == "doi.landing_fallback":
893
+ self.logger.info(
894
+ "Full-text DOI fallback paper=%s doi=%s status=%s http_status=%s "
895
+ "redirect_count=%s declared_pdf_count=%s.",
896
+ paper_id,
897
+ provenance.get("doi") or "",
898
+ landing_attempt["status"],
899
+ landing_attempt["http_status"],
900
+ landing_attempt["redirect_count"],
901
+ landing_attempt["declared_pdf_count"],
902
+ )
903
+ else:
904
+ self.logger.info(
905
+ "Full-text landing resolution paper=%s source=%s status=%s "
906
+ "http_status=%s declared_pdf_count=%s.",
907
+ paper_id,
908
+ source,
909
+ landing_attempt["status"],
910
+ landing_attempt["http_status"],
911
+ landing_attempt["declared_pdf_count"],
912
+ )
913
+ declared = [
914
+ item
915
+ for item in landing.get("pdf_candidates") or []
916
+ if isinstance(item, dict)
917
+ ]
918
+ max_declared_pdf_attempts = max(
919
+ 1,
920
+ int(
921
+ self._fulltext_setting(
922
+ "fulltext_max_doi_landing_recovery_pdf_attempts", 4
923
+ )
924
+ or 4
925
+ ),
926
+ )
927
+ candidate_queue[candidate_index:candidate_index] = declared[
928
+ :max_declared_pdf_attempts
929
+ ]
930
+ continue
931
+
932
+ attempt = self._download_pdf_candidate(
933
+ candidate,
934
+ pdf_path=pdf_path,
935
+ paper_title=paper_title,
936
+ index=index,
937
+ total=total,
938
+ )
939
+ attempt_record = {
940
+ **self._safe_candidate_for_provenance(candidate),
941
+ **{
942
+ key: value
943
+ for key, value in attempt.items()
944
+ if key not in {"requested_url", "final_url"}
945
+ },
946
+ "final_url": self._safe_provenance_url(attempt.get("final_url")),
947
+ "requested_host": str(urlsplit(download_url).netloc or "").lower(),
948
+ "final_host": str(
949
+ urlsplit(str(attempt.get("final_url") or "")).netloc or ""
950
+ ).lower(),
951
+ }
952
+ downloaded = bool(attempt.get("downloaded"))
953
+ if downloaded and not is_valid_pdf(pdf_path):
954
+ attempt_record["status"] = "non_pdf"
955
+ downloaded = False
956
+ self.fulltext_download_coordinator.invalidate_url(download_url)
957
+ provenance["attempts"].append(attempt_record)
958
+ if not downloaded:
959
+ self.logger.warning(
960
+ "Full-text attempt failed paper=%s source=%s status=%s "
961
+ "http_status=%s content_type=%s; trying next candidate if available.",
962
+ paper_id,
963
+ source,
964
+ attempt_record.get("status"),
965
+ attempt_record.get("http_status"),
966
+ attempt_record.get("content_type"),
967
+ )
968
+ self._remove_incomplete_pdf(pdf_path)
969
+ continue
970
+ selected_candidate = candidate
971
+ break
972
+
973
+ if not downloaded or not is_valid_pdf(pdf_path):
974
+ statuses = {
975
+ str(attempt.get("status") or "")
976
+ for attempt in provenance["attempts"]
977
+ if isinstance(attempt, dict)
978
+ }
979
+ outcome_status = (
980
+ "authentication_required"
981
+ if "authentication_required" in statuses
982
+ else "open_access_access_denied"
983
+ if "access_denied" in statuses
984
+ else "rate_limited"
985
+ if "rate_limited" in statuses
986
+ else "no_open_access_candidate"
987
+ if not provenance["attempts"]
988
+ else "fulltext_fetch_failed"
989
+ )
990
+ provenance["outcome"] = {
991
+ "status": outcome_status,
992
+ "attempted_candidate_count": len(provenance["attempts"]),
993
+ }
994
+ self._write_fulltext_provenance(paper_id, provenance)
995
+ self._publish_fulltext_acquisition_summary(paper_id, provenance)
996
+ self.logger.warning(
997
+ "Full-text resolution failed paper=%s doi=%s outcome=%s attempts=%s.",
998
+ paper_id,
999
+ provenance.get("doi") or "",
1000
+ outcome_status,
1001
+ len(provenance["attempts"]),
1002
+ )
1003
+ self._remove_incomplete_pdf(pdf_path)
1004
+ return (None, None)
1005
+ provenance["outcome"] = {
1006
+ "status": "downloaded",
1007
+ "selected_source": str(selected_candidate.get("source") or ""),
1008
+ "selected_url": self._safe_provenance_url(selected_candidate.get("url")),
1009
+ "pdf_path": pdf_path,
1010
+ }
1011
+ self._write_fulltext_provenance(paper_id, provenance)
1012
+ self._publish_fulltext_acquisition_summary(paper_id, provenance)
1013
+ self.logger.info(
1014
+ "Full-text resolution completed paper=%s doi=%s source=%s attempts=%s.",
1015
+ paper_id,
1016
+ provenance.get("doi") or "",
1017
+ provenance["outcome"]["selected_source"],
1018
+ len(provenance["attempts"]),
1019
+ )
1020
+ return (paper_id, pdf_path)
1021
+
1022
+ def _download_papers_serial(self, papers: list, limit: int = -1):
1023
+ """串行下载papers"""
1024
+ valid_paper_ids = []
1025
+ valid_paper_paths = []
1026
+ valid_paper_paths_ids = []
1027
+
1028
+ reference_graph = self._get_reference_graph()
1029
+
1030
+ index = 0
1031
+ total = min(len(papers), limit) if limit > 0 else len(papers)
1032
+
1033
+ while index < len(papers) and (limit < 0 or len(valid_paper_ids) < limit):
1034
+ paper = papers[index]
1035
+ index += 1
1036
+
1037
+ # 处理abstract_when_full_text_fail的情况
1038
+ if isinstance(paper, dict):
1039
+ paper_id = self._paper_download_id(paper)
1040
+ if not self._has_pdf_download_candidate(paper):
1041
+ self._record_no_fulltext_candidate(paper)
1042
+ if self.config.ModuleInfo.WorkAnalyzer.abstract_when_full_text_fail:
1043
+ self.logger.info(f"No full text PDF found for paper {paper_id}, skipping download but keeping abstract.")
1044
+ err = self.add_papers_abstracts_in_cache([paper_id])
1045
+ if not err:
1046
+ valid_paper_ids.append(paper_id)
1047
+ continue
1048
+ else:
1049
+ continue
1050
+
1051
+ paper_id, pdf_path = self._download_single_paper(paper, index, total, reference_graph)
1052
+
1053
+ if paper_id and pdf_path:
1054
+ valid_paper_ids.append(paper_id)
1055
+ if not self._has_parsed_markdown(paper_id):
1056
+ valid_paper_paths.append(pdf_path)
1057
+ valid_paper_paths_ids.append(paper_id)
1058
+
1059
+ return valid_paper_ids, valid_paper_paths, valid_paper_paths_ids
1060
+
1061
+ def _download_papers_parallel(self, papers: list, limit: int = -1):
1062
+ """并行下载papers"""
1063
+ valid_paper_ids = []
1064
+ valid_paper_paths = []
1065
+ valid_paper_paths_ids = []
1066
+
1067
+ reference_graph = self._get_reference_graph()
1068
+
1069
+ # 过滤需要下载的papers
1070
+ download_tasks = []
1071
+ limit = limit if limit > 0 else len(papers)
1072
+
1073
+ for idx, paper in enumerate(papers[:limit]):
1074
+ # 处理特殊情况
1075
+ if isinstance(paper, dict):
1076
+ paper_id = self._paper_download_id(paper)
1077
+ if not self._has_pdf_download_candidate(paper):
1078
+ self._record_no_fulltext_candidate(paper)
1079
+ if self.config.ModuleInfo.WorkAnalyzer.abstract_when_full_text_fail:
1080
+ self.logger.info(f"No full text PDF found for paper {paper_id}, skipping download but keeping abstract.")
1081
+ err = self.add_papers_abstracts_in_cache([paper_id])
1082
+ if not err:
1083
+ valid_paper_ids.append(paper_id)
1084
+ continue
1085
+ else:
1086
+ continue
1087
+ elif isinstance(paper, str) and not self._has_pdf_download_candidate(paper):
1088
+ continue
1089
+
1090
+ download_tasks.append((idx + 1, paper))
1091
+
1092
+ if not download_tasks:
1093
+ return valid_paper_ids, valid_paper_paths, valid_paper_paths_ids
1094
+
1095
+ total = len(download_tasks)
1096
+ max_workers = getattr(self.config.ModuleInfo.WorkCollector, 'download_parallel_workers', 4)
1097
+
1098
+ self.logger.info(f"Starting parallel download with {max_workers} workers for {len(download_tasks)} papers...")
1099
+
1100
+ with ThreadPoolExecutor(max_workers=max_workers) as executor:
1101
+ futures = {
1102
+ executor.submit(self._download_single_paper, paper, idx, total, reference_graph): idx
1103
+ for idx, paper in download_tasks
1104
+ }
1105
+
1106
+ for future in as_completed(futures):
1107
+ try:
1108
+ paper_id, pdf_path = future.result()
1109
+ if paper_id and pdf_path:
1110
+ valid_paper_ids.append(paper_id)
1111
+ if not self._has_parsed_markdown(paper_id):
1112
+ valid_paper_paths.append(pdf_path)
1113
+ valid_paper_paths_ids.append(paper_id)
1114
+ except Exception as e:
1115
+ self.logger.error(f"Error in parallel download: {e}")
1116
+
1117
+ return valid_paper_ids, valid_paper_paths, valid_paper_paths_ids
1118
+
1119
+ def _download_pdf_with_resume(self, url: str, filename: str, title: str, is_arxiv: bool = False, chunk_size: int = 1024 * 1024, return_attempt: bool = False):
1120
+ """下载PDF文件,支持断点续传"""
1121
+ started_at = time.monotonic()
1122
+ attempt = {
1123
+ "downloaded": False,
1124
+ "status": "fetch_failed",
1125
+ "requested_url": str(url or ""),
1126
+ "final_url": str(url or ""),
1127
+ "http_status": None,
1128
+ "content_type": "",
1129
+ "bytes_written": 0,
1130
+ "requested_host": str(urlsplit(str(url or "")).netloc or "").lower(),
1131
+ "final_host": str(urlsplit(str(url or "")).netloc or "").lower(),
1132
+ }
1133
+
1134
+ def finish(downloaded):
1135
+ attempt["downloaded"] = bool(downloaded)
1136
+ attempt["elapsed_seconds"] = round(time.monotonic() - started_at, 3)
1137
+ return attempt if return_attempt else bool(downloaded)
1138
+
1139
+ partial_filename = f"{filename}.part"
1140
+ temp_size = os.path.getsize(partial_filename) if os.path.exists(partial_filename) else 0
1141
+
1142
+ headers = {"Range": f"bytes={temp_size}-"}
1143
+ if is_arxiv:
1144
+ headers = {
1145
+ "User-Agent": "Xcientist/0.8 (academic PDF downloader)",
1146
+ "Range": f"bytes={temp_size}-"
1147
+ }
1148
+
1149
+ if self.config.BasicInfo.debug:
1150
+ self.logger.info(
1151
+ "[%s] Resuming download from byte %s from %s",
1152
+ title,
1153
+ temp_size,
1154
+ self._safe_provenance_url(url),
1155
+ )
1156
+
1157
+ http_client = getattr(self, "fulltext_http_client", None)
1158
+ request_get = http_client.get if http_client is not None else requests.get
1159
+ request_timeout = None if http_client is not None else self.config.ModuleInfo.WorkCollector.download_timeout
1160
+ try:
1161
+ with closing(
1162
+ request_get(
1163
+ url,
1164
+ headers=headers,
1165
+ stream=True,
1166
+ timeout=request_timeout,
1167
+ allow_redirects=True,
1168
+ )
1169
+ ) as resp:
1170
+ attempt["http_status"] = int(resp.status_code)
1171
+ attempt["final_url"] = str(getattr(resp, "url", "") or url)
1172
+ attempt["final_host"] = str(
1173
+ urlsplit(attempt["final_url"]).netloc or ""
1174
+ ).lower()
1175
+ attempt["content_type"] = str(resp.headers.get("Content-Type") or "").lower()
1176
+
1177
+ if resp.status_code not in (200, 206, 416):
1178
+ attempt["status"] = self._download_status_for_http_status(resp.status_code)
1179
+ self.logger.error(f"[{title}] Could not download file: {resp.status_code}")
1180
+ return finish(False)
1181
+
1182
+ if resp.status_code == 416:
1183
+ if not os.path.exists(partial_filename):
1184
+ attempt["status"] = "range_not_satisfiable"
1185
+ return finish(False)
1186
+ try:
1187
+ os.replace(partial_filename, filename)
1188
+ except OSError:
1189
+ attempt["status"] = "finalize_failed"
1190
+ return finish(False)
1191
+ self.logger.info(f"[{title}] File already fully downloaded.")
1192
+ attempt["status"] = "downloaded_response"
1193
+ return finish(True)
1194
+ if resp.status_code == 200 and temp_size:
1195
+ # Servers are allowed to ignore Range. In that case the
1196
+ # body is a complete representation, so appending it to a
1197
+ # partial file would corrupt the PDF.
1198
+ self.logger.info(
1199
+ "[%s] Server ignored Range; restarting the PDF download from byte 0.",
1200
+ title,
1201
+ )
1202
+ temp_size = 0
1203
+ if resp.status_code == 206:
1204
+ content_range = str(resp.headers.get("Content-Range") or "")
1205
+ try:
1206
+ range_start = int(content_range.split(" ", 1)[1].split("-", 1)[0])
1207
+ except (IndexError, ValueError):
1208
+ attempt["status"] = "invalid_range_response"
1209
+ return finish(False)
1210
+ if range_start != temp_size:
1211
+ attempt["status"] = "invalid_range_response"
1212
+ return finish(False)
1213
+ self.logger.info(f"[{title}] Resuming download from byte {temp_size}")
1214
+
1215
+ total_size = None
1216
+ if "Content-Range" in resp.headers:
1217
+ total_size = int(resp.headers["Content-Range"].split("/")[-1])
1218
+ elif "Content-Length" in resp.headers:
1219
+ total_size = int(resp.headers["Content-Length"]) + temp_size
1220
+
1221
+ if total_size:
1222
+ self.logger.info(f"[{title}] Total file size: {total_size} bytes")
1223
+ else:
1224
+ self.logger.warning(f"[{title}] Total file size unknown. Progress may not be shown.")
1225
+
1226
+ max_pdf_bytes = max(
1227
+ 1,
1228
+ int(self._fulltext_setting("fulltext_pdf_max_bytes", 50_000_000) or 50_000_000),
1229
+ )
1230
+ if total_size is not None and total_size > max_pdf_bytes:
1231
+ attempt["status"] = "too_large"
1232
+ self.logger.warning(
1233
+ "[%s] Full-text response exceeds configured PDF limit: %s > %s bytes.",
1234
+ title,
1235
+ total_size,
1236
+ max_pdf_bytes,
1237
+ )
1238
+ return finish(False)
1239
+
1240
+ write_mode = "ab" if resp.status_code == 206 and temp_size else "wb"
1241
+ with open(partial_filename, write_mode) as f:
1242
+ downloaded = temp_size
1243
+
1244
+ last_time = time.time()
1245
+ last_downloaded = downloaded
1246
+
1247
+ for chunk in resp.iter_content(chunk_size=chunk_size):
1248
+ if chunk:
1249
+ if downloaded + len(chunk) > max_pdf_bytes:
1250
+ attempt["status"] = "too_large"
1251
+ self.logger.warning(
1252
+ "[%s] Full-text response exceeded configured PDF limit of %s bytes.",
1253
+ title,
1254
+ max_pdf_bytes,
1255
+ )
1256
+ return finish(False)
1257
+ f.write(chunk)
1258
+ downloaded += len(chunk)
1259
+ attempt["bytes_written"] += len(chunk)
1260
+
1261
+ now = time.time()
1262
+ elapsed = now - last_time
1263
+ if elapsed > 0:
1264
+ delta_bytes = downloaded - last_downloaded
1265
+ speed_mb_s = delta_bytes / elapsed / 1024 / 1024
1266
+ else:
1267
+ speed_mb_s = 0.0
1268
+
1269
+ last_time = now
1270
+ last_downloaded = downloaded
1271
+
1272
+ if total_size:
1273
+ percent = downloaded * 100 / total_size
1274
+ self.logger.info(
1275
+ f"[{title}] {percent:.2f}% "
1276
+ f"({downloaded}/{total_size} bytes) "
1277
+ f"Speed: {speed_mb_s:.2f} MB/s"
1278
+ )
1279
+ else:
1280
+ self.logger.info(
1281
+ f"[{title}] Downloaded {downloaded} bytes "
1282
+ f"Speed: {speed_mb_s:.2f} MB/s"
1283
+ )
1284
+
1285
+ except requests.Timeout as e:
1286
+ attempt["status"] = "timeout"
1287
+ self.logger.error("[%s] Download timed out (%s).", title, type(e).__name__)
1288
+ return finish(False)
1289
+ except requests.RequestException as e:
1290
+ attempt["status"] = "network_error"
1291
+ self.logger.error("[%s] Download request failed (%s).", title, type(e).__name__)
1292
+ return finish(False)
1293
+ except Exception as e:
1294
+ attempt["status"] = "fetch_failed"
1295
+ self.logger.error("[%s] Download failed (%s).", title, type(e).__name__)
1296
+ return finish(False)
1297
+
1298
+ try:
1299
+ os.replace(partial_filename, filename)
1300
+ except OSError:
1301
+ attempt["status"] = "finalize_failed"
1302
+ return finish(False)
1303
+ self.logger.info(f"[{title}] Download completed → {filename}")
1304
+ attempt["status"] = "downloaded_response"
1305
+ return finish(True)
1306
+
1307
+ def download_and_parse_papers(self, papers: list, limit: int = -1):
1308
+ """下载并解析papers"""
1309
+ # 检查是否启用并行下载
1310
+ use_parallel = getattr(self.config.ModuleInfo.WorkCollector, 'download_in_parallel', False)
1311
+
1312
+ if use_parallel:
1313
+ self.logger.info("Using parallel download mode")
1314
+ valid_paper_ids, valid_paper_paths, valid_paper_paths_ids = self._download_papers_parallel(papers, limit)
1315
+ else:
1316
+ self.logger.info("Using serial download mode")
1317
+ valid_paper_ids, valid_paper_paths, valid_paper_paths_ids = self._download_papers_serial(papers, limit)
1318
+
1319
+ # step 2: parse the downloaded PDFs in bounded batches to limit memory use
1320
+ self.logger.info(f"Parsing {len(valid_paper_paths)} downloaded papers...")
1321
+ if valid_paper_paths:
1322
+ parse_batch_size = getattr(
1323
+ self.config.ModuleInfo.WorkCollector,
1324
+ "pdf_parse_batch_size",
1325
+ 1,
1326
+ )
1327
+ try:
1328
+ parse_batch_size = min(4, max(1, int(parse_batch_size)))
1329
+ except (TypeError, ValueError):
1330
+ parse_batch_size = 1
1331
+
1332
+ parsed_output_dir = os.path.join(self.cache_path, "parsed_papers")
1333
+ failed_paper_ids = set()
1334
+
1335
+ for batch_start in range(0, len(valid_paper_paths), parse_batch_size):
1336
+ batch_paths = valid_paper_paths[batch_start : batch_start + parse_batch_size]
1337
+ batch_ids = valid_paper_paths_ids[batch_start : batch_start + parse_batch_size]
1338
+ batch_number = batch_start // parse_batch_size + 1
1339
+ batch_total = (
1340
+ len(valid_paper_paths) + parse_batch_size - 1
1341
+ ) // parse_batch_size
1342
+ self.logger.info(
1343
+ f"Parsing PDF batch {batch_number}/{batch_total} "
1344
+ f"({len(batch_paths)} papers)..."
1345
+ )
1346
+
1347
+ parse_doc(batch_paths, output_dir=parsed_output_dir, lang="en")
1348
+
1349
+ missing_paths = []
1350
+ missing_ids = []
1351
+ for paper_path, paper_id in zip(batch_paths, batch_ids):
1352
+ if not self._has_parsed_markdown(paper_id):
1353
+ missing_paths.append(paper_path)
1354
+ missing_ids.append(paper_id)
1355
+
1356
+ for paper_path, paper_id in zip(missing_paths, missing_ids):
1357
+ self.logger.warning(
1358
+ f"Batch parsing did not produce Markdown for {paper_id}; "
1359
+ "retrying this paper individually."
1360
+ )
1361
+ parse_doc([paper_path], output_dir=parsed_output_dir, lang="en")
1362
+ if not self._has_parsed_markdown(paper_id):
1363
+ failed_paper_ids.add(paper_id)
1364
+ self.logger.error(f"Failed to parse paper at {paper_path}")
1365
+
1366
+ gc.collect()
1367
+ if torch.cuda.is_available():
1368
+ torch.cuda.empty_cache()
1369
+
1370
+ if failed_paper_ids:
1371
+ valid_paper_ids = [
1372
+ paper_id for paper_id in valid_paper_ids if paper_id not in failed_paper_ids
1373
+ ]
1374
+
1375
+ return valid_paper_ids
1376
+
1377
+ def get_paper_raw_markdown(self, paper_id: Any) -> str:
1378
+ resolved_paper_id = self._resolve_paper_reference_id(paper_id)
1379
+ if not resolved_paper_id:
1380
+ raise ValueError("Paper id is empty or none when get_paper_raw_markdown")
1381
+ md_path = os.path.join(
1382
+ f"{self.cache_path}/parsed_papers",
1383
+ resolved_paper_id,
1384
+ "auto",
1385
+ f"{resolved_paper_id}.md",
1386
+ )
1387
+ if not os.path.exists(md_path):
1388
+ if self.config.BasicInfo.debug:
1389
+ self.logger.info(
1390
+ f"Paper {resolved_paper_id} markdown not found in cache, re-downloading and parsing..."
1391
+ )
1392
+ try:
1393
+ self.download_and_parse_papers([paper_id if isinstance(paper_id, dict) else resolved_paper_id])
1394
+ except Exception as e:
1395
+ self.logger.error(f"Failed to parse paper {resolved_paper_id}: {e}")
1396
+ raise e
1397
+ if not os.path.exists(md_path):
1398
+ self.logger.warning(f"Markdown still missing after parse: {md_path}")
1399
+ raise ValueError("Markdown file missing in getting paper markdown")
1400
+ with open(md_path, "r", encoding="utf-8") as fr:
1401
+ paper_markdown_text = fr.read()
1402
+ return paper_markdown_text
1403
+
1404
+ def get_paper_with_title_semantic(self, title: str):
1405
+ """通过Semantic Scholar API根据title搜索paper"""
1406
+ self.logger.info(f"Searching for paper with title: {title}")
1407
+ fields = "title,externalIds,openAccessPdf,abstract,authors,year,venue"
1408
+ search_results = []
1409
+
1410
+ try:
1411
+ response = self.semantic_scholar_api.search_papers(query=title, fields=fields)
1412
+ if response and response.get("data"):
1413
+ search_results.extend(response["data"][:3])
1414
+ self.logger.info(f"Found {len(response['data'])} papers from Semantic Scholar")
1415
+ except Exception as e:
1416
+ self.logger.warning(f"Error searching Semantic Scholar for '{title}': {e}")
1417
+
1418
+ if not search_results:
1419
+ self.logger.warning(f"No papers found for title: {title}")
1420
+ return None
1421
+
1422
+ return self._select_best_paper_by_similarity(title, search_results)
1423
+
1424
+ def get_paper_with_title_openalex(self, title: str):
1425
+ """Search OpenAlex first because it is the primary Survey discovery provider."""
1426
+ self.logger.info(f"Searching for paper with title through OpenAlex: {title}")
1427
+ try:
1428
+ search_results = self.openalex_api.search_papers(title, per_page=5)
1429
+ if search_results:
1430
+ self.logger.info(f"Found {len(search_results)} papers from OpenAlex for title: {title}")
1431
+ return self._select_best_paper_by_similarity(title, search_results)
1432
+ except Exception as e:
1433
+ self.logger.warning(f"Error searching OpenAlex for '{title}': {e}")
1434
+ self.logger.warning(f"No papers found through OpenAlex for title: {title}")
1435
+ return None
1436
+
1437
+ def get_paper_with_title_arxiv(self, title: str):
1438
+ """通过ArXiv API根据title搜索paper"""
1439
+ self.logger.info(f"Searching for paper with title: {title}")
1440
+
1441
+ try:
1442
+ search_results = self.arxiv_api.search_papers_by_title(title)
1443
+ if search_results:
1444
+ self.logger.info(f"Found {len(search_results)} papers from arXiv for title: {title}")
1445
+ return self._select_best_paper_by_similarity(title, search_results)
1446
+ except Exception as e:
1447
+ self.logger.warning(f"Error searching arXiv for '{title}': {e}")
1448
+
1449
+ self.logger.warning(f"No papers found for title: {title}")
1450
+ return None
1451
+
1452
+ def get_paper_with_title(self, title: str):
1453
+ """Search OpenAlex first, then use Semantic Scholar and arXiv as fallbacks."""
1454
+ normalized = title.strip().lower()
1455
+ if normalized in self.title_lookup_cache:
1456
+ cached = self.title_lookup_cache[normalized]
1457
+ self.logger.info(f"Cache hit for title: {title[:50]}...")
1458
+ if cached["found"]:
1459
+ return cached["paper_info"]
1460
+
1461
+ result = self.get_paper_with_title_openalex(title)
1462
+ if result:
1463
+ result["api_platform"] = "openalex"
1464
+ if not result:
1465
+ self.logger.info("OpenAlex did not resolve the title; trying Semantic Scholar.")
1466
+ result = self.get_paper_with_title_semantic(title)
1467
+ if result:
1468
+ result["api_platform"] = "semantic"
1469
+ if not result:
1470
+ self.logger.info("Semantic Scholar did not resolve the title; trying arXiv.")
1471
+ result = self.get_paper_with_title_arxiv(title)
1472
+ if result:
1473
+ result["api_platform"] = "arxiv"
1474
+
1475
+ if result:
1476
+ self.title_lookup_cache[normalized] = {"found": True, "paper_info": result}
1477
+ return result
1478
+
1479
+ def get_paper_with_title_batch(self, titles: List[str]):
1480
+ """
1481
+ 批量根据title搜索paper,优先使用Semantic Scholar,失败则用ArXiv
1482
+ 统一计算embedding和相似度,显著提升性能
1483
+
1484
+ 使用 title_lookup_cache 缓存查询结果,避免重复API调用。
1485
+ 缓存格式:title (lowercase, stripped) -> {"found": bool, "paper_info": dict or None}
1486
+
1487
+ Args:
1488
+ titles: 论文标题列表
1489
+
1490
+ Returns:
1491
+ Dict[str, dict]: title -> paper_info 的映射,未找到的title对应None
1492
+ """
1493
+ if not titles:
1494
+ return {}
1495
+
1496
+ self.logger.info(f"Batch searching for {len(titles)} papers...")
1497
+
1498
+ # Normalize titles for cache lookup
1499
+ normalized_titles = {title: title.strip().lower() for title in titles}
1500
+
1501
+ # Separate titles into cache hits and misses
1502
+ titles_to_query = []
1503
+ results = {}
1504
+ cache_misses = []
1505
+
1506
+ for title in titles:
1507
+ normalized = normalized_titles[title]
1508
+ if normalized in self.title_lookup_cache:
1509
+ cached = self.title_lookup_cache[normalized]
1510
+ self.logger.info(f"Cache hit for title: {title[:50]}...")
1511
+ if cached["found"]:
1512
+ results[title] = cached["paper_info"]
1513
+ # else:
1514
+ # results[title] = None
1515
+ else:
1516
+ titles_to_query.append(title)
1517
+ cache_misses.append(normalized)
1518
+
1519
+ self.logger.info(f"Cache hits: {len(titles) - len(titles_to_query)}, Cache misses: {len(titles_to_query)}")
1520
+
1521
+ if not titles_to_query:
1522
+ return results
1523
+
1524
+ # OpenAlex is the primary discovery provider. Semantic Scholar is only
1525
+ # queried for titles that OpenAlex did not resolve.
1526
+ openalex_results = {} # title -> list of search results
1527
+ for title in titles_to_query:
1528
+ try:
1529
+ openalex_results[title] = self.openalex_api.search_papers(title, per_page=5)[:5]
1530
+ except Exception as e:
1531
+ self.logger.warning(f"Error searching OpenAlex for '{title}': {e}")
1532
+ openalex_results[title] = []
1533
+
1534
+ semantic_results = {} # title -> list of search results
1535
+ for title in titles_to_query:
1536
+ if openalex_results.get(title):
1537
+ semantic_results[title] = []
1538
+ continue
1539
+ try:
1540
+ fields = "title,externalIds,openAccessPdf,abstract,authors,year,venue"
1541
+ response = self.semantic_scholar_api.search_papers(query=title, fields=fields)
1542
+ if response and response.get("data"):
1543
+ semantic_results[title] = response["data"][:5]
1544
+ else:
1545
+ semantic_results[title] = []
1546
+ except Exception as e:
1547
+ self.logger.warning(f"Error searching Semantic Scholar for '{title}': {e}")
1548
+ semantic_results[title] = []
1549
+
1550
+ # 批量搜索ArXiv作为fallback
1551
+ arxiv_results = {} # title -> list of search results
1552
+
1553
+ for title in titles_to_query:
1554
+ if openalex_results.get(title) or semantic_results.get(title):
1555
+ arxiv_results[title] = []
1556
+ continue
1557
+ try:
1558
+ search_results = self.arxiv_api.search_papers_by_title(title)
1559
+ arxiv_results[title] = search_results[:3] if search_results else []
1560
+ except Exception as e:
1561
+ self.logger.warning(f"Error searching arXiv for '{title}': {e}")
1562
+ arxiv_results[title] = []
1563
+
1564
+ # 批量计算embedding和相似度
1565
+ model = self._get_embedding_model()
1566
+
1567
+ # 收集所有需要计算相似度的title
1568
+ all_titles_to_encode = []
1569
+ title_to_results = {} # query_title -> [(paper_info, source), ...]
1570
+
1571
+ for title in titles_to_query:
1572
+ title_to_results[title] = []
1573
+
1574
+ for paper in openalex_results.get(title, [])[:3]:
1575
+ title_to_results[title].append((paper, "openalex"))
1576
+
1577
+ for paper in semantic_results.get(title, [])[:3]:
1578
+ title_to_results[title].append((paper, "semantic"))
1579
+
1580
+ for paper in arxiv_results.get(title, [])[:2]:
1581
+ title_to_results[title].append((paper, "arxiv"))
1582
+
1583
+ # 收集所有需要encode的title
1584
+ all_titles_to_encode.append(title)
1585
+ for paper, _ in title_to_results[title]:
1586
+ all_titles_to_encode.append(paper.get("title", ""))
1587
+
1588
+ # 批量编码所有title
1589
+ self.logger.info(f"Batch encoding {len(all_titles_to_encode)} titles...")
1590
+ all_embeddings = model.encode(
1591
+ all_titles_to_encode,
1592
+ convert_to_tensor=True,
1593
+ batch_size=32,
1594
+ show_progress_bar=True
1595
+ )
1596
+
1597
+ # 解析embeddings
1598
+ embeddings_dict = {}
1599
+ idx = 0
1600
+ for title in all_titles_to_encode:
1601
+ embeddings_dict[title] = all_embeddings[idx]
1602
+ idx += 1
1603
+
1604
+ # 批量计算相似度并选择最佳匹配,同时更新缓存
1605
+ for title in titles_to_query:
1606
+ normalized = normalized_titles[title]
1607
+ query_embedding = embeddings_dict[title]
1608
+ best_paper = None
1609
+ best_similarity = 0.0
1610
+ best_source = None
1611
+
1612
+ for paper, source in title_to_results[title]:
1613
+ paper_title = paper.get("title", "")
1614
+ if not paper_title or paper_title not in embeddings_dict:
1615
+ continue
1616
+
1617
+ paper_embedding = embeddings_dict[paper_title]
1618
+ similarity = util.pytorch_cos_sim(query_embedding, paper_embedding).item()
1619
+
1620
+ if similarity > best_similarity:
1621
+ best_similarity = similarity
1622
+ best_paper = paper
1623
+ best_source = source
1624
+
1625
+ if similarity > 0.95:
1626
+ self.logger.info(f"Paper: {paper_title[:50]}... Similarity: {similarity:.4f}")
1627
+
1628
+ if similarity == 1.0:
1629
+ break
1630
+
1631
+ if best_paper and best_similarity > 0.95:
1632
+ self.logger.info(f"Found matching paper for '{title}' with similarity {best_similarity:.4f}: {best_paper.get('title', 'N/A')}")
1633
+ best_paper["api_platform"] = best_source
1634
+ results[title] = best_paper
1635
+ # Cache the found result
1636
+ self.title_lookup_cache[normalized] = {"found": True, "paper_info": best_paper}
1637
+ else:
1638
+ self.logger.warning(f"No paper found with similarity > 0.95 for title: {title}")
1639
+ results[title] = None
1640
+ # Cache the "not found" result to avoid repeated lookups
1641
+ # self.title_lookup_cache[normalized] = {"found": False, "paper_info": None}
1642
+
1643
+ return results
1644
+
1645
+ def _select_best_paper_by_similarity(self, query_title: str, search_results: List[dict]):
1646
+ """根据embedding相似度选择最佳匹配的paper"""
1647
+ model = self._get_embedding_model()
1648
+ query_embedding = model.encode(query_title, convert_to_tensor=True)
1649
+
1650
+ best_paper = None
1651
+ best_similarity = 0.0
1652
+
1653
+ for paper in search_results[:3]:
1654
+ paper_title = paper.get("title", "")
1655
+ if not paper_title:
1656
+ continue
1657
+
1658
+ paper_embedding = model.encode(paper_title, convert_to_tensor=True)
1659
+ similarity = util.pytorch_cos_sim(query_embedding, paper_embedding).item()
1660
+ self.logger.info(f"Paper: {paper_title[:50]}... Similarity: {similarity:.4f}")
1661
+
1662
+ if similarity > 0.95 and similarity > best_similarity:
1663
+ best_similarity = similarity
1664
+ best_paper = paper
1665
+ if similarity == 1.0:
1666
+ best_paper = paper
1667
+ break
1668
+
1669
+ if best_paper:
1670
+ self.logger.info(f"Found matching paper with similarity {best_similarity:.4f}: {best_paper.get('title', 'N/A')}")
1671
+ return best_paper
1672
+ else:
1673
+ self.logger.warning(f"No paper found with similarity > 0.95 for title: {query_title}")
1674
+ return None
1675
+
1676
+ def _get_reference_graph(self):
1677
+ """获取reference graph(如果存在)"""
1678
+ import pickle
1679
+ ref_graph_path = os.path.join(self.cache_path, "reference_graph.pkl")
1680
+ if os.path.exists(ref_graph_path):
1681
+ try:
1682
+ with open(ref_graph_path, "rb") as reader:
1683
+ reference_graph = pickle.load(reader)
1684
+ except Exception as exc:
1685
+ self.logger.warning(f"Unable to load reference graph cache: {exc}")
1686
+ return None
1687
+ expected_schema = int(
1688
+ getattr(
1689
+ self.config.APIInfo,
1690
+ "openalex_graph_cache_schema_version",
1691
+ 2,
1692
+ )
1693
+ or 2
1694
+ )
1695
+ graph_metadata = getattr(reference_graph, "graph", {})
1696
+ if (
1697
+ graph_metadata.get("provider") == "openalex"
1698
+ and graph_metadata.get("schema_version") == expected_schema
1699
+ ):
1700
+ return reference_graph
1701
+ return None
1702
+
1703
+ def clear_title_lookup_cache(self):
1704
+ """清除title lookup缓存"""
1705
+ self.title_lookup_cache.clear()
1706
+ self.logger.info("Title lookup cache cleared.")
1707
+
1708
+ def get_title_lookup_cache_stats(self):
1709
+ """获取title lookup缓存的统计信息"""
1710
+ return {
1711
+ "size": len(self.title_lookup_cache),
1712
+ "cache_path": os.path.join(self.cache_path, "title_lookup_cache")
1713
+ }