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.
- qwen_sci-0.2.0.dist-info/METADATA +492 -0
- qwen_sci-0.2.0.dist-info/RECORD +626 -0
- qwen_sci-0.2.0.dist-info/WHEEL +4 -0
- qwen_sci-0.2.0.dist-info/entry_points.txt +6 -0
- qwen_sci-0.2.0.dist-info/licenses/LICENSE +21 -0
- src/__init__.py +1 -0
- src/__main__.py +5 -0
- src/agents/__init__.py +1 -0
- src/agents/blog_agent/README.md +299 -0
- src/agents/blog_agent/__init__.py +0 -0
- src/agents/blog_agent/agent/__init__.py +0 -0
- src/agents/blog_agent/agent/llm_config.py +109 -0
- src/agents/blog_agent/agent/new_base_agent.py +388 -0
- src/agents/blog_agent/scripts/run.py +497 -0
- src/agents/blog_agent/skills/blog-analyze/SKILL.md +376 -0
- src/agents/blog_agent/skills/blog-refine/SKILL.md +246 -0
- src/agents/blog_agent/skills/blog-writer/SKILL.md +218 -0
- src/agents/blog_agent/skills/workspace-navigator/SKILL.md +147 -0
- src/agents/blog_agent/tools/count_words_tool.py +161 -0
- src/agents/blog_agent/tools/download_paper_pdf_tool.py +158 -0
- src/agents/blog_agent/tools/gengraph.py +389 -0
- src/agents/blog_agent/tools/illustrate.py +116 -0
- src/agents/blog_agent/tools/ocr.py +217 -0
- src/agents/blog_agent/tools/search_core_tool.py +159 -0
- src/agents/blog_agent/tools/search_paper_abstract_tool.py +164 -0
- src/agents/blog_agent/utils/deeperaser.pth +0 -0
- src/agents/blog_agent/utils/deeperaser.py +381 -0
- src/agents/blog_agent/utils/graph.db +0 -0
- src/agents/blog_agent/utils/retrieval.py +278 -0
- src/agents/blog_agent/utils/search_core.py +159 -0
- src/agents/blog_agent/utils/semantic_scholar.py +669 -0
- src/agents/experiment_agent/README.md +93 -0
- src/agents/experiment_agent/__init__.py +0 -0
- src/agents/experiment_agent/agents/__init__.py +4 -0
- src/agents/experiment_agent/agents/base/__init__.py +11 -0
- src/agents/experiment_agent/agents/base/agent.py +204 -0
- src/agents/experiment_agent/agents/code/__init__.py +21 -0
- src/agents/experiment_agent/agents/code/entry.py +11 -0
- src/agents/experiment_agent/agents/code/planner.py +599 -0
- src/agents/experiment_agent/agents/code/reviewer.py +128 -0
- src/agents/experiment_agent/agents/code/worker.py +78 -0
- src/agents/experiment_agent/agents/finalization/__init__.py +6 -0
- src/agents/experiment_agent/agents/finalization/entry.py +350 -0
- src/agents/experiment_agent/agents/master/__init__.py +3 -0
- src/agents/experiment_agent/agents/master/entry.py +424 -0
- src/agents/experiment_agent/agents/prepare/__init__.py +39 -0
- src/agents/experiment_agent/agents/prepare/entry.py +7 -0
- src/agents/experiment_agent/agents/prepare/planner.py +497 -0
- src/agents/experiment_agent/agents/prepare/reviewer.py +99 -0
- src/agents/experiment_agent/agents/prepare/worker.py +101 -0
- src/agents/experiment_agent/agents/science/__init__.py +21 -0
- src/agents/experiment_agent/agents/science/entry.py +11 -0
- src/agents/experiment_agent/agents/science/planner.py +639 -0
- src/agents/experiment_agent/agents/science/reviewer.py +124 -0
- src/agents/experiment_agent/agents/science/worker.py +34 -0
- src/agents/experiment_agent/config.py +937 -0
- src/agents/experiment_agent/main.py +378 -0
- src/agents/experiment_agent/runtime/__init__.py +88 -0
- src/agents/experiment_agent/runtime/ablation_results.py +444 -0
- src/agents/experiment_agent/runtime/artifacts.py +2481 -0
- src/agents/experiment_agent/runtime/code_review_context.py +473 -0
- src/agents/experiment_agent/runtime/contracts.py +1346 -0
- src/agents/experiment_agent/runtime/finalization_hooks.py +607 -0
- src/agents/experiment_agent/runtime/idea_components.py +102 -0
- src/agents/experiment_agent/runtime/manifests.py +192 -0
- src/agents/experiment_agent/runtime/openharness_runner.py +1091 -0
- src/agents/experiment_agent/runtime/openharness_vendor.py +58 -0
- src/agents/experiment_agent/runtime/phase_contracts.py +201 -0
- src/agents/experiment_agent/runtime/phase_runner.py +2574 -0
- src/agents/experiment_agent/runtime/prepare_contracts.py +614 -0
- src/agents/experiment_agent/runtime/project_integrity.py +409 -0
- src/agents/experiment_agent/runtime/report_layout.py +194 -0
- src/agents/experiment_agent/runtime/self_contained.py +179 -0
- src/agents/experiment_agent/skills/__init__.py +45 -0
- src/agents/experiment_agent/skills/benchmark-discovery/SKILL.md +33 -0
- src/agents/experiment_agent/skills/bounded-tool-use/SKILL.md +31 -0
- src/agents/experiment_agent/skills/code-enablement/SKILL.md +59 -0
- src/agents/experiment_agent/skills/code-planning/SKILL.md +55 -0
- src/agents/experiment_agent/skills/component-coverage/SKILL.md +26 -0
- src/agents/experiment_agent/skills/environment-setup/SKILL.md +23 -0
- src/agents/experiment_agent/skills/prepare-planning/SKILL.md +85 -0
- src/agents/experiment_agent/skills/resource-acquisition/SKILL.md +69 -0
- src/agents/experiment_agent/skills/science-execution/SKILL.md +46 -0
- src/agents/experiment_agent/skills/science-planning/SKILL.md +35 -0
- src/agents/experiment_agent/telemetry/__init__.py +161 -0
- src/agents/experiment_agent/tools/__init__.py +6 -0
- src/agents/experiment_design_agent/__init__.py +268 -0
- src/agents/experiment_design_agent/artifacts.py +747 -0
- src/agents/experiment_design_agent/cache.py +319 -0
- src/agents/experiment_design_agent/completeness.py +130 -0
- src/agents/experiment_design_agent/contracts.py +771 -0
- src/agents/experiment_design_agent/counterexample_analyzer.py +187 -0
- src/agents/experiment_design_agent/design_evidence_paper_screener.py +470 -0
- src/agents/experiment_design_agent/discipline_catalog.py +178 -0
- src/agents/experiment_design_agent/evidence_cards.py +338 -0
- src/agents/experiment_design_agent/evidence_planner.py +452 -0
- src/agents/experiment_design_agent/formal_reasoning_planner.py +884 -0
- src/agents/experiment_design_agent/fulltext_acquisition.py +863 -0
- src/agents/experiment_design_agent/idea_adapter.py +100 -0
- src/agents/experiment_design_agent/idea_intake.py +87 -0
- src/agents/experiment_design_agent/llm_json.py +328 -0
- src/agents/experiment_design_agent/orchestrator.py +1220 -0
- src/agents/experiment_design_agent/reasoning_context.py +183 -0
- src/agents/experiment_design_agent/reasoning_validation.py +546 -0
- src/agents/experiment_design_agent/run.py +481 -0
- src/agents/experiment_design_agent/run_logging.py +292 -0
- src/agents/experiment_design_agent/scope_gate.py +198 -0
- src/agents/experiment_design_agent/study_type_composer.py +1360 -0
- src/agents/experiment_design_agent/survey_evidence.py +1381 -0
- src/agents/experiment_design_agent/template_router.py +195 -0
- src/agents/experiment_design_agent/variable_claim_extractor.py +93 -0
- src/agents/idea_agent/__init__.py +1 -0
- src/agents/idea_agent/agent/__init__.py +6 -0
- src/agents/idea_agent/agent/artifacts.py +308 -0
- src/agents/idea_agent/agent/base.py +122 -0
- src/agents/idea_agent/agent/ligagent.py +488 -0
- src/agents/idea_agent/agent/mcts.py +2060 -0
- src/agents/idea_agent/agent/prompts/__init__.py +48 -0
- src/agents/idea_agent/agent/prompts/advanced_analysis.py +235 -0
- src/agents/idea_agent/agent/prompts/algorithm_alignment.py +55 -0
- src/agents/idea_agent/agent/prompts/algorithm_structuring.py +48 -0
- src/agents/idea_agent/agent/prompts/component_extraction.py +62 -0
- src/agents/idea_agent/agent/prompts/component_novelty_evaluation.py +54 -0
- src/agents/idea_agent/agent/prompts/experiment_findings_extraction.py +39 -0
- src/agents/idea_agent/agent/prompts/idea_fusion.py +295 -0
- src/agents/idea_agent/agent/prompts/idea_introduction.py +30 -0
- src/agents/idea_agent/agent/prompts/idea_result_alignment.py +39 -0
- src/agents/idea_agent/agent/prompts/input_interpreter.py +52 -0
- src/agents/idea_agent/agent/prompts/keynote_ops.py +78 -0
- src/agents/idea_agent/agent/prompts/mcts_evaluation.py +235 -0
- src/agents/idea_agent/agent/prompts/mcts_generation.py +140 -0
- src/agents/idea_agent/agent/prompts/mechanism_commit_query.py +99 -0
- src/agents/idea_agent/agent/prompts/memory_selection.py +0 -0
- src/agents/idea_agent/agent/prompts/prompt_modes.py +16 -0
- src/agents/idea_agent/agent/prompts/rag_query.py +34 -0
- src/agents/idea_agent/agent/prompts/re_analysis_replan.py +48 -0
- src/agents/idea_agent/agent/prompts/reference_grounding.py +34 -0
- src/agents/idea_agent/agent/prompts/root_domain_classification.py +32 -0
- src/agents/idea_agent/agent/prompts/scientific_debate.py +88 -0
- src/agents/idea_agent/agent/prompts/scientific_materialization.py +45 -0
- src/agents/idea_agent/agent/prompts/skill_instantiation.py +193 -0
- src/agents/idea_agent/agent/prompts/theory_transfer_query.py +93 -0
- src/agents/idea_agent/agent/prompts/topic_background.py +15 -0
- src/agents/idea_agent/agent/skills/DEFAULT_SKILL_TEMPLATES.json +263 -0
- src/agents/idea_agent/agent/skills/edit_operator_skills/alternative-path-contrast/SKILL.md +40 -0
- src/agents/idea_agent/agent/skills/edit_operator_skills/alternative-path-contrast/references/contrast_patterns.md +22 -0
- src/agents/idea_agent/agent/skills/edit_operator_skills/feedback-closed-loop/SKILL.md +40 -0
- src/agents/idea_agent/agent/skills/edit_operator_skills/feedback-closed-loop/references/mechanism_cards.md +24 -0
- src/agents/idea_agent/agent/skills/edit_operator_skills/hierarchical-decomposition/SKILL.md +39 -0
- src/agents/idea_agent/agent/skills/edit_operator_skills/hierarchical-decomposition/references/hierarchy_patterns.md +22 -0
- src/agents/idea_agent/agent/skills/edit_operator_skills/mechanism-commit-innovation/SKILL.md +39 -0
- src/agents/idea_agent/agent/skills/edit_operator_skills/mechanism-commit-innovation/references/mechanism_commit_patterns.md +24 -0
- src/agents/idea_agent/agent/skills/edit_operator_skills/multi-scale-coordinator/SKILL.md +39 -0
- src/agents/idea_agent/agent/skills/edit_operator_skills/multi-scale-coordinator/references/coordination_patterns.md +22 -0
- src/agents/idea_agent/agent/skills/edit_operator_skills/speculative-execution-with-repair/SKILL.md +41 -0
- src/agents/idea_agent/agent/skills/edit_operator_skills/speculative-execution-with-repair/references/speculation_patterns.md +22 -0
- src/agents/idea_agent/agent/skills/edit_operator_skills/surgical-modularity/SKILL.md +39 -0
- src/agents/idea_agent/agent/skills/edit_operator_skills/surgical-modularity/references/modularity_patterns.md +22 -0
- src/agents/idea_agent/agent/skills/edit_operator_skills/theory-transfer-injection/SKILL.md +40 -0
- src/agents/idea_agent/agent/skills/edit_operator_skills/theory-transfer-injection/references/transfer_patterns.md +24 -0
- src/agents/idea_agent/example/idea_result.json +900 -0
- src/agents/idea_agent/run.py +276 -0
- src/agents/idea_agent/tests/test_advanced_analysis_contract.py +90 -0
- src/agents/idea_agent/tests/test_gap_hypothesis_bridge.py +166 -0
- src/agents/idea_agent/tests/test_idea_debate.py +480 -0
- src/agents/idea_agent/tests/test_idea_result_v5.py +99 -0
- src/agents/idea_agent/tests/test_idea_synthesis.py +92 -0
- src/agents/idea_agent/tests/test_mature_idea_sources.py +27 -0
- src/agents/idea_agent/tests/test_multimodal_idea_integration.py +386 -0
- src/agents/idea_agent/tests/test_profile_materialization_and_scoring.py +219 -0
- src/agents/idea_agent/tests/test_profile_native_skill_rendering.py +122 -0
- src/agents/idea_agent/tests/test_result_persistence.py +183 -0
- src/agents/idea_agent/tests/test_scientific_intervention_profiles.py +241 -0
- src/agents/idea_agent/tests/test_scientific_rubric_and_prompts.py +559 -0
- src/agents/idea_agent/tests/test_strict_json_object_responses.py +48 -0
- src/agents/idea_agent/utils/core/__init__.py +1 -0
- src/agents/idea_agent/utils/core/ablation_inputs.py +60 -0
- src/agents/idea_agent/utils/core/chat_errors.py +95 -0
- src/agents/idea_agent/utils/core/chat_router.py +130 -0
- src/agents/idea_agent/utils/core/chat_transport.py +188 -0
- src/agents/idea_agent/utils/core/config_loader.py +166 -0
- src/agents/idea_agent/utils/core/json_utils.py +28 -0
- src/agents/idea_agent/utils/core/logger.py +287 -0
- src/agents/idea_agent/utils/core/progress.py +58 -0
- src/agents/idea_agent/utils/core/response_parsing.py +57 -0
- src/agents/idea_agent/utils/core/run_inputs.py +198 -0
- src/agents/idea_agent/utils/mcts/__init__.py +1 -0
- src/agents/idea_agent/utils/mcts/component_novelty.py +342 -0
- src/agents/idea_agent/utils/mcts/defect_registry.py +302 -0
- src/agents/idea_agent/utils/mcts/idea_routes.py +61 -0
- src/agents/idea_agent/utils/mcts/idea_taste_presets.py +312 -0
- src/agents/idea_agent/utils/mcts/mcts_helpers.py +738 -0
- src/agents/idea_agent/utils/mcts/mcts_runtime.py +3000 -0
- src/agents/idea_agent/utils/mcts/scientific_intervention_ontology.py +1561 -0
- src/agents/idea_agent/utils/mcts/scientific_rubric.py +211 -0
- src/agents/idea_agent/utils/mcts/skill_parsing.py +92 -0
- src/agents/idea_agent/utils/papers/__init__.py +1 -0
- src/agents/idea_agent/utils/papers/paper_graph_vector_store.py +499 -0
- src/agents/idea_agent/utils/papers/paper_repository.py +372 -0
- src/agents/idea_agent/utils/prompting/__init__.py +1 -0
- src/agents/idea_agent/utils/prompting/prompt_views.py +637 -0
- src/agents/idea_agent/utils/workflow/__init__.py +1 -0
- src/agents/idea_agent/utils/workflow/advanced_analysis_contract.py +244 -0
- src/agents/idea_agent/utils/workflow/gap_hypothesis_bridge.py +461 -0
- src/agents/idea_agent/utils/workflow/idea_contract.py +426 -0
- src/agents/idea_agent/utils/workflow/idea_debate.py +1238 -0
- src/agents/idea_agent/utils/workflow/idea_diversity.py +163 -0
- src/agents/idea_agent/utils/workflow/idea_fusion.py +889 -0
- src/agents/idea_agent/utils/workflow/idea_helpers.py +478 -0
- src/agents/idea_agent/utils/workflow/idea_portfolio.py +422 -0
- src/agents/idea_agent/utils/workflow/idea_synthesis.py +482 -0
- src/agents/idea_agent/utils/workflow/ligagent_flow.py +581 -0
- src/agents/idea_agent/utils/workflow/ligagent_handlers.py +1836 -0
- src/agents/idea_agent/utils/workflow/ligagent_helpers.py +1331 -0
- src/agents/idea_agent/utils/workflow/ligagent_utils.py +322 -0
- src/agents/idea_agent/utils/workflow/mature_idea_sources.py +648 -0
- src/agents/idea_agent/utils/workflow/multimodal_data_anchoring.py +578 -0
- src/agents/idea_agent/utils/workflow/result_persistence.py +774 -0
- src/agents/idea_agent/utils/workflow/stage_contract.py +76 -0
- src/agents/idea_agent/utils/workflow/workflow_runtime.py +288 -0
- src/agents/research_plan_author/__init__.py +50 -0
- src/agents/research_plan_author/artifacts.py +270 -0
- src/agents/research_plan_author/authoring_blueprint.py +1246 -0
- src/agents/research_plan_author/bibtex_renderer.py +252 -0
- src/agents/research_plan_author/contract_repair.py +131 -0
- src/agents/research_plan_author/contracts.py +745 -0
- src/agents/research_plan_author/cross_section_editor.py +232 -0
- src/agents/research_plan_author/document_quality.py +987 -0
- src/agents/research_plan_author/idea_evolution.py +223 -0
- src/agents/research_plan_author/input_loader.py +76 -0
- src/agents/research_plan_author/latex_compiler.py +260 -0
- src/agents/research_plan_author/latex_safety.py +159 -0
- src/agents/research_plan_author/llm_json.py +74 -0
- src/agents/research_plan_author/markdown_renderer.py +275 -0
- src/agents/research_plan_author/pdf_validator.py +149 -0
- src/agents/research_plan_author/render.py +166 -0
- src/agents/research_plan_author/render_artifacts.py +187 -0
- src/agents/research_plan_author/run.py +765 -0
- src/agents/research_plan_author/run_logging.py +209 -0
- src/agents/research_plan_author/section_cache.py +184 -0
- src/agents/research_plan_author/section_composer.py +1764 -0
- src/agents/research_plan_author/section_router.py +209 -0
- src/agents/research_plan_author/semantic_validator.py +254 -0
- src/agents/research_plan_author/source_bundle.py +46 -0
- src/agents/research_plan_author/source_registry.py +208 -0
- src/agents/research_plan_author/survey_source_loader.py +137 -0
- src/agents/research_plan_author/template_adapter.py +173 -0
- src/agents/research_plan_author/template_profile.py +204 -0
- src/agents/research_plan_author/tex_renderer.py +496 -0
- src/agents/research_plan_author/theory_presentation.py +134 -0
- src/agents/research_plan_author/theory_spine.py +703 -0
- src/agents/survey_agent/Dockerfile +54 -0
- src/agents/survey_agent/README.md +350 -0
- src/agents/survey_agent/config/deep_survey.yaml +217 -0
- src/agents/survey_agent/config/deep_survey_batch.yaml +176 -0
- src/agents/survey_agent/config/deep_survey_fast.yaml +183 -0
- src/agents/survey_agent/config/evaluate_baseline.yaml +197 -0
- src/agents/survey_agent/config/outcomeRAG.yaml +159 -0
- src/agents/survey_agent/env_report.md +348 -0
- src/agents/survey_agent/modules/__init__.py +0 -0
- src/agents/survey_agent/modules/code_collector.py +2194 -0
- src/agents/survey_agent/modules/code_report_generator.py +1282 -0
- src/agents/survey_agent/modules/data_manager.py +1713 -0
- src/agents/survey_agent/modules/database.py +167 -0
- src/agents/survey_agent/modules/fulltext_download_cache.py +462 -0
- src/agents/survey_agent/modules/fulltext_http.py +134 -0
- src/agents/survey_agent/modules/fulltext_resolution.py +749 -0
- src/agents/survey_agent/modules/judge.py +434 -0
- src/agents/survey_agent/modules/outcome_RAG.py +236 -0
- src/agents/survey_agent/modules/paper_graph_retriever.py +1075 -0
- src/agents/survey_agent/modules/pe.py +1729 -0
- src/agents/survey_agent/modules/pseudo_creator.py +782 -0
- src/agents/survey_agent/modules/pseudo_reviser.py +537 -0
- src/agents/survey_agent/modules/refine_agent.py +1952 -0
- src/agents/survey_agent/modules/survey_generator.py +5691 -0
- src/agents/survey_agent/modules/survey_visualizer.py +1817 -0
- src/agents/survey_agent/modules/visual_prompts.py +327 -0
- src/agents/survey_agent/modules/work_analyzer.py +2750 -0
- src/agents/survey_agent/modules/work_collector.py +5649 -0
- src/agents/survey_agent/scripts/baseline_evaluation.py +411 -0
- src/agents/survey_agent/scripts/run_deep_survey.py +224 -0
- src/agents/survey_agent/scripts/run_deep_survey_adapter.py +407 -0
- src/agents/survey_agent/scripts/run_deep_survey_adapter_with_input.py +409 -0
- src/agents/survey_agent/scripts/run_deep_survey_batch.py +228 -0
- src/agents/survey_agent/scripts/run_deep_survey_batch_arg.py +243 -0
- src/agents/survey_agent/scripts/run_deep_survey_batch_settings.py +218 -0
- src/agents/survey_agent/scripts/run_keynotes_gen.py +128 -0
- src/agents/survey_agent/scripts/run_work_clusters.py +140 -0
- src/agents/survey_agent/utils/__init__.py +0 -0
- src/agents/survey_agent/utils/ablation_utils.py +317 -0
- src/agents/survey_agent/utils/api_call.py +2518 -0
- src/agents/survey_agent/utils/chat_utils.py +82 -0
- src/agents/survey_agent/utils/config_utils.py +70 -0
- src/agents/survey_agent/utils/convert_to_md.py +29 -0
- src/agents/survey_agent/utils/err_info.py +24 -0
- src/agents/survey_agent/utils/file_utils.py +176 -0
- src/agents/survey_agent/utils/gpu_utils.py +145 -0
- src/agents/survey_agent/utils/graph_visualizer.py +279 -0
- src/agents/survey_agent/utils/html_utils.py +181 -0
- src/agents/survey_agent/utils/latex_generator_intra_cluster.py +529 -0
- src/agents/survey_agent/utils/latex_generator_relation_table.py +193 -0
- src/agents/survey_agent/utils/mineru_section_packer.py +334 -0
- src/agents/survey_agent/utils/mineru_utils.py +396 -0
- src/agents/survey_agent/utils/paper.py +144 -0
- src/agents/survey_agent/utils/read_yaml.py +15 -0
- src/agents/survey_agent/utils/repo_utils.py +167 -0
- src/agents/survey_agent/utils/rich_logger.py +77 -0
- src/agents/survey_agent/utils/step2v2.py +874 -0
- src/agents/survey_agent/utils/step2v2_extractor.py +856 -0
- src/agents/survey_agent/utils/topic_survey_storage.py +309 -0
- src/agents/survey_agent/utils/utils.py +118 -0
- src/agents/survey_agent/utils/work.py +2 -0
- src/cli.py +1611 -0
- src/config/__init__.py +213 -0
- src/config/default.yaml +1122 -0
- src/harness/LICENSE +21 -0
- src/harness/VENDORED.md +14 -0
- src/harness/src/__init__.py +0 -0
- src/harness/src/openharness/__init__.py +0 -0
- src/harness/src/openharness/__main__.py +6 -0
- src/harness/src/openharness/api/__init__.py +21 -0
- src/harness/src/openharness/api/client.py +281 -0
- src/harness/src/openharness/api/codex_client.py +407 -0
- src/harness/src/openharness/api/copilot_auth.py +240 -0
- src/harness/src/openharness/api/copilot_client.py +134 -0
- src/harness/src/openharness/api/errors.py +19 -0
- src/harness/src/openharness/api/openai_client.py +577 -0
- src/harness/src/openharness/api/provider.py +186 -0
- src/harness/src/openharness/api/registry.py +437 -0
- src/harness/src/openharness/api/usage.py +17 -0
- src/harness/src/openharness/auth/__init__.py +29 -0
- src/harness/src/openharness/auth/external.py +610 -0
- src/harness/src/openharness/auth/flows.py +189 -0
- src/harness/src/openharness/auth/manager.py +482 -0
- src/harness/src/openharness/auth/storage.py +269 -0
- src/harness/src/openharness/autopilot/__init__.py +23 -0
- src/harness/src/openharness/autopilot/service.py +2239 -0
- src/harness/src/openharness/autopilot/types.py +91 -0
- src/harness/src/openharness/bridge/__init__.py +20 -0
- src/harness/src/openharness/bridge/manager.py +106 -0
- src/harness/src/openharness/bridge/session_runner.py +46 -0
- src/harness/src/openharness/bridge/types.py +37 -0
- src/harness/src/openharness/bridge/work_secret.py +41 -0
- src/harness/src/openharness/channels/UPSTREAM +6 -0
- src/harness/src/openharness/channels/__init__.py +22 -0
- src/harness/src/openharness/channels/adapter.py +131 -0
- src/harness/src/openharness/channels/bus/__init__.py +6 -0
- src/harness/src/openharness/channels/bus/events.py +38 -0
- src/harness/src/openharness/channels/bus/queue.py +44 -0
- src/harness/src/openharness/channels/impl/__init__.py +6 -0
- src/harness/src/openharness/channels/impl/base.py +141 -0
- src/harness/src/openharness/channels/impl/dingtalk.py +445 -0
- src/harness/src/openharness/channels/impl/discord.py +311 -0
- src/harness/src/openharness/channels/impl/email.py +410 -0
- src/harness/src/openharness/channels/impl/feishu.py +1342 -0
- src/harness/src/openharness/channels/impl/manager.py +257 -0
- src/harness/src/openharness/channels/impl/matrix.py +700 -0
- src/harness/src/openharness/channels/impl/mochat.py +897 -0
- src/harness/src/openharness/channels/impl/qq.py +141 -0
- src/harness/src/openharness/channels/impl/slack.py +284 -0
- src/harness/src/openharness/channels/impl/telegram.py +525 -0
- src/harness/src/openharness/channels/impl/whatsapp.py +159 -0
- src/harness/src/openharness/cli.py +2551 -0
- src/harness/src/openharness/commands/__init__.py +21 -0
- src/harness/src/openharness/commands/registry.py +2783 -0
- src/harness/src/openharness/config/__init__.py +34 -0
- src/harness/src/openharness/config/paths.py +160 -0
- src/harness/src/openharness/config/schema.py +119 -0
- src/harness/src/openharness/config/settings.py +1104 -0
- src/harness/src/openharness/coordinator/__init__.py +12 -0
- src/harness/src/openharness/coordinator/agent_definitions.py +975 -0
- src/harness/src/openharness/coordinator/coordinator_mode.py +520 -0
- src/harness/src/openharness/engine/__init__.py +80 -0
- src/harness/src/openharness/engine/cost_tracker.py +24 -0
- src/harness/src/openharness/engine/messages.py +222 -0
- src/harness/src/openharness/engine/query.py +1188 -0
- src/harness/src/openharness/engine/query_engine.py +313 -0
- src/harness/src/openharness/engine/stream_events.py +90 -0
- src/harness/src/openharness/hooks/__init__.py +50 -0
- src/harness/src/openharness/hooks/events.py +20 -0
- src/harness/src/openharness/hooks/executor.py +242 -0
- src/harness/src/openharness/hooks/hot_reload.py +31 -0
- src/harness/src/openharness/hooks/loader.py +68 -0
- src/harness/src/openharness/hooks/schemas.py +66 -0
- src/harness/src/openharness/hooks/types.py +38 -0
- src/harness/src/openharness/keybindings/__init__.py +14 -0
- src/harness/src/openharness/keybindings/default_bindings.py +11 -0
- src/harness/src/openharness/keybindings/loader.py +22 -0
- src/harness/src/openharness/keybindings/parser.py +18 -0
- src/harness/src/openharness/keybindings/resolver.py +13 -0
- src/harness/src/openharness/mcp/__init__.py +79 -0
- src/harness/src/openharness/mcp/client.py +298 -0
- src/harness/src/openharness/mcp/config.py +16 -0
- src/harness/src/openharness/mcp/types.py +76 -0
- src/harness/src/openharness/memory/__init__.py +25 -0
- src/harness/src/openharness/memory/agent.py +91 -0
- src/harness/src/openharness/memory/manager.py +183 -0
- src/harness/src/openharness/memory/memdir.py +52 -0
- src/harness/src/openharness/memory/migrate.py +137 -0
- src/harness/src/openharness/memory/paths.py +22 -0
- src/harness/src/openharness/memory/relevance.py +145 -0
- src/harness/src/openharness/memory/scan.py +116 -0
- src/harness/src/openharness/memory/schema.py +443 -0
- src/harness/src/openharness/memory/search.py +71 -0
- src/harness/src/openharness/memory/team.py +90 -0
- src/harness/src/openharness/memory/types.py +33 -0
- src/harness/src/openharness/memory/usage.py +153 -0
- src/harness/src/openharness/output_styles/__init__.py +5 -0
- src/harness/src/openharness/output_styles/loader.py +42 -0
- src/harness/src/openharness/permissions/__init__.py +26 -0
- src/harness/src/openharness/permissions/checker.py +200 -0
- src/harness/src/openharness/permissions/modes.py +13 -0
- src/harness/src/openharness/personalization/__init__.py +6 -0
- src/harness/src/openharness/personalization/extractor.py +138 -0
- src/harness/src/openharness/personalization/rules.py +64 -0
- src/harness/src/openharness/personalization/session_hook.py +65 -0
- src/harness/src/openharness/platforms.py +86 -0
- src/harness/src/openharness/plugins/__init__.py +53 -0
- src/harness/src/openharness/plugins/bundled/__init__.py +0 -0
- src/harness/src/openharness/plugins/installer.py +39 -0
- src/harness/src/openharness/plugins/loader.py +730 -0
- src/harness/src/openharness/plugins/schemas.py +24 -0
- src/harness/src/openharness/plugins/types.py +59 -0
- src/harness/src/openharness/prompts/__init__.py +14 -0
- src/harness/src/openharness/prompts/claudemd.py +48 -0
- src/harness/src/openharness/prompts/context.py +187 -0
- src/harness/src/openharness/prompts/environment.py +135 -0
- src/harness/src/openharness/prompts/system_prompt.py +109 -0
- src/harness/src/openharness/sandbox/Dockerfile +6 -0
- src/harness/src/openharness/sandbox/__init__.py +33 -0
- src/harness/src/openharness/sandbox/adapter.py +148 -0
- src/harness/src/openharness/sandbox/docker_backend.py +232 -0
- src/harness/src/openharness/sandbox/docker_image.py +103 -0
- src/harness/src/openharness/sandbox/path_validator.py +37 -0
- src/harness/src/openharness/sandbox/session.py +63 -0
- src/harness/src/openharness/services/__init__.py +30 -0
- src/harness/src/openharness/services/autodream/__init__.py +34 -0
- src/harness/src/openharness/services/autodream/backup.py +104 -0
- src/harness/src/openharness/services/autodream/lock.py +138 -0
- src/harness/src/openharness/services/autodream/prompt.py +128 -0
- src/harness/src/openharness/services/autodream/service.py +313 -0
- src/harness/src/openharness/services/compact/__init__.py +1725 -0
- src/harness/src/openharness/services/cron.py +137 -0
- src/harness/src/openharness/services/cron_scheduler.py +595 -0
- src/harness/src/openharness/services/lsp/__init__.py +216 -0
- src/harness/src/openharness/services/memory_extract/__init__.py +261 -0
- src/harness/src/openharness/services/oauth/__init__.py +0 -0
- src/harness/src/openharness/services/session_backend.py +97 -0
- src/harness/src/openharness/services/session_memory/__init__.py +139 -0
- src/harness/src/openharness/services/session_storage.py +230 -0
- src/harness/src/openharness/services/token_estimation.py +15 -0
- src/harness/src/openharness/services/tool_outputs.py +55 -0
- src/harness/src/openharness/skills/__init__.py +44 -0
- src/harness/src/openharness/skills/_frontmatter.py +97 -0
- src/harness/src/openharness/skills/bundled/__init__.py +75 -0
- src/harness/src/openharness/skills/bundled/content/commit.md +25 -0
- src/harness/src/openharness/skills/bundled/content/debug.md +25 -0
- src/harness/src/openharness/skills/bundled/content/diagnose.md +35 -0
- src/harness/src/openharness/skills/bundled/content/plan.md +39 -0
- src/harness/src/openharness/skills/bundled/content/review.md +33 -0
- src/harness/src/openharness/skills/bundled/content/simplify.md +26 -0
- src/harness/src/openharness/skills/bundled/content/skill-creator.md +175 -0
- src/harness/src/openharness/skills/bundled/content/test.md +38 -0
- src/harness/src/openharness/skills/loader.py +229 -0
- src/harness/src/openharness/skills/registry.py +29 -0
- src/harness/src/openharness/skills/types.py +24 -0
- src/harness/src/openharness/state/__init__.py +6 -0
- src/harness/src/openharness/state/app_state.py +30 -0
- src/harness/src/openharness/state/store.py +40 -0
- src/harness/src/openharness/swarm/__init__.py +70 -0
- src/harness/src/openharness/swarm/in_process.py +693 -0
- src/harness/src/openharness/swarm/lockfile.py +24 -0
- src/harness/src/openharness/swarm/mailbox.py +522 -0
- src/harness/src/openharness/swarm/permission_sync.py +1168 -0
- src/harness/src/openharness/swarm/registry.py +410 -0
- src/harness/src/openharness/swarm/spawn_utils.py +229 -0
- src/harness/src/openharness/swarm/subprocess_backend.py +171 -0
- src/harness/src/openharness/swarm/team_lifecycle.py +910 -0
- src/harness/src/openharness/swarm/types.py +398 -0
- src/harness/src/openharness/swarm/worktree.py +315 -0
- src/harness/src/openharness/tasks/__init__.py +18 -0
- src/harness/src/openharness/tasks/local_agent_task.py +28 -0
- src/harness/src/openharness/tasks/local_shell_task.py +17 -0
- src/harness/src/openharness/tasks/manager.py +475 -0
- src/harness/src/openharness/tasks/stop_task.py +11 -0
- src/harness/src/openharness/tasks/types.py +32 -0
- src/harness/src/openharness/themes/__init__.py +21 -0
- src/harness/src/openharness/themes/builtin.py +89 -0
- src/harness/src/openharness/themes/loader.py +55 -0
- src/harness/src/openharness/themes/schema.py +54 -0
- src/harness/src/openharness/tools/__init__.py +107 -0
- src/harness/src/openharness/tools/agent_tool.py +141 -0
- src/harness/src/openharness/tools/ask_user_question_tool.py +46 -0
- src/harness/src/openharness/tools/base.py +80 -0
- src/harness/src/openharness/tools/bash_tool.py +259 -0
- src/harness/src/openharness/tools/brief_tool.py +33 -0
- src/harness/src/openharness/tools/config_tool.py +54 -0
- src/harness/src/openharness/tools/cron_create_tool.py +105 -0
- src/harness/src/openharness/tools/cron_delete_tool.py +32 -0
- src/harness/src/openharness/tools/cron_list_tool.py +69 -0
- src/harness/src/openharness/tools/cron_toggle_tool.py +37 -0
- src/harness/src/openharness/tools/enter_plan_mode_tool.py +28 -0
- src/harness/src/openharness/tools/enter_worktree_tool.py +80 -0
- src/harness/src/openharness/tools/exit_plan_mode_tool.py +28 -0
- src/harness/src/openharness/tools/exit_worktree_tool.py +42 -0
- src/harness/src/openharness/tools/file_edit_tool.py +95 -0
- src/harness/src/openharness/tools/file_read_tool.py +72 -0
- src/harness/src/openharness/tools/file_write_tool.py +87 -0
- src/harness/src/openharness/tools/glob_tool.py +175 -0
- src/harness/src/openharness/tools/grep_tool.py +374 -0
- src/harness/src/openharness/tools/image_generation_tool.py +447 -0
- src/harness/src/openharness/tools/image_to_text_tool.py +237 -0
- src/harness/src/openharness/tools/list_mcp_resources_tool.py +36 -0
- src/harness/src/openharness/tools/lsp_tool.py +154 -0
- src/harness/src/openharness/tools/mcp_auth_tool.py +71 -0
- src/harness/src/openharness/tools/mcp_tool.py +72 -0
- src/harness/src/openharness/tools/notebook_edit_tool.py +97 -0
- src/harness/src/openharness/tools/read_mcp_resource_tool.py +38 -0
- src/harness/src/openharness/tools/remote_trigger_tool.py +74 -0
- src/harness/src/openharness/tools/send_message_tool.py +58 -0
- src/harness/src/openharness/tools/skill_tool.py +43 -0
- src/harness/src/openharness/tools/sleep_tool.py +32 -0
- src/harness/src/openharness/tools/task_create_tool.py +56 -0
- src/harness/src/openharness/tools/task_get_tool.py +33 -0
- src/harness/src/openharness/tools/task_list_tool.py +35 -0
- src/harness/src/openharness/tools/task_output_tool.py +35 -0
- src/harness/src/openharness/tools/task_stop_tool.py +30 -0
- src/harness/src/openharness/tools/task_update_tool.py +50 -0
- src/harness/src/openharness/tools/team_create_tool.py +31 -0
- src/harness/src/openharness/tools/team_delete_tool.py +30 -0
- src/harness/src/openharness/tools/todo_write_tool.py +46 -0
- src/harness/src/openharness/tools/tool_search_tool.py +38 -0
- src/harness/src/openharness/tools/web_fetch_tool.py +117 -0
- src/harness/src/openharness/tools/web_search_tool.py +119 -0
- src/harness/src/openharness/ui/__init__.py +5 -0
- src/harness/src/openharness/ui/app.py +320 -0
- src/harness/src/openharness/ui/backend_host.py +941 -0
- src/harness/src/openharness/ui/coordinator_drain.py +198 -0
- src/harness/src/openharness/ui/input.py +32 -0
- src/harness/src/openharness/ui/output.py +265 -0
- src/harness/src/openharness/ui/permission_dialog.py +14 -0
- src/harness/src/openharness/ui/protocol.py +247 -0
- src/harness/src/openharness/ui/react_launcher.py +179 -0
- src/harness/src/openharness/ui/runtime.py +799 -0
- src/harness/src/openharness/ui/textual_app.py +495 -0
- src/harness/src/openharness/utils/__init__.py +0 -0
- src/harness/src/openharness/utils/file_lock.py +86 -0
- src/harness/src/openharness/utils/fs.py +98 -0
- src/harness/src/openharness/utils/helpers.py +77 -0
- src/harness/src/openharness/utils/network_guard.py +340 -0
- src/harness/src/openharness/utils/shell.py +147 -0
- src/harness/src/openharness/vim/__init__.py +5 -0
- src/harness/src/openharness/vim/transitions.py +8 -0
- src/harness/src/openharness/voice/__init__.py +7 -0
- src/harness/src/openharness/voice/keyterms.py +10 -0
- src/harness/src/openharness/voice/stream_stt.py +8 -0
- src/harness/src/openharness/voice/voice_mode.py +44 -0
- src/llm/__init__.py +29 -0
- src/llm/image_generation.py +402 -0
- src/llm/provider_registry.py +419 -0
- src/llm/runtime_env.py +64 -0
- src/llm/structured_output.py +131 -0
- src/llm/vision.py +198 -0
- src/memory/__init__.py +2 -0
- src/memory/api/__init__.py +1 -0
- src/memory/api/base_memory_system_api.py +98 -0
- src/memory/api/base_symbolic_memory_system_api.py +190 -0
- src/memory/api/base_vector_memory_system_api.py +116 -0
- src/memory/api/faiss_memory_system_api.py +228 -0
- src/memory/api/slot_process_api.py +827 -0
- src/memory/api/symbolic_memory_system_api.py +572 -0
- src/memory/memory_system/__init__.py +48 -0
- src/memory/memory_system/component_taxonomy.py +430 -0
- src/memory/memory_system/llm.py +437 -0
- src/memory/memory_system/models.py +168 -0
- src/memory/memory_system/user_prompt.py +418 -0
- src/memory/memory_system/utils.py +282 -0
- src/memory/memory_system/vectorstore.py +311 -0
- src/memory/memory_system/working_slot.py +99 -0
- src/pipeline/__init__.py +129 -0
- src/pipeline/contracts.py +531 -0
- src/pipeline/discipline_taxonomy.py +916 -0
- src/pipeline/evidence_coverage_ledger.py +1150 -0
- src/pipeline/evidence_refinement.py +620 -0
- src/pipeline/experiment_to_symbolic.py +137 -0
- src/pipeline/multimodal_evidence/__init__.py +28 -0
- src/pipeline/multimodal_evidence/capabilities.py +107 -0
- src/pipeline/multimodal_evidence/claims.py +135 -0
- src/pipeline/multimodal_evidence/contract.py +291 -0
- src/pipeline/multimodal_evidence/data_sh_compiler.py +505 -0
- src/pipeline/multimodal_evidence/handoff_binding.py +135 -0
- src/pipeline/multimodal_evidence/manifest.py +254 -0
- src/pipeline/multimodal_evidence/native_analysis.py +479 -0
- src/pipeline/multimodal_evidence/perception.py +277 -0
- src/pipeline/multimodal_evidence/query_binding.py +65 -0
- src/pipeline/multimodal_evidence/rendering.py +120 -0
- src/pipeline/multimodal_evidence/safety.py +42 -0
- src/pipeline/multimodal_evidence/sampling.py +69 -0
- src/pipeline/multimodal_evidence/service.py +148 -0
- src/pipeline/multimodal_evidence/survey_integration.py +460 -0
- src/pipeline/paper_identity.py +52 -0
- src/pipeline/research_design_inventory.py +338 -0
- src/pipeline/research_domain_resolution.py +669 -0
- src/pipeline/research_identity.py +548 -0
- src/pipeline/research_question_contract.py +646 -0
- src/pipeline/retrieval_lanes.py +889 -0
- src/pipeline/run_loop.py +862 -0
- src/pipeline/science_events.py +30 -0
- src/pipeline/science_manifests.py +602 -0
- src/pipeline/science_run.py +918 -0
- src/pipeline/science_stages.py +709 -0
- src/pipeline/science_workflow.py +807 -0
- src/pipeline/sh_cluster_projection.py +306 -0
- src/pipeline/sh_graph_provenance.py +648 -0
- src/pipeline/subhypothesis_decomposition.py +392 -0
- src/pipeline/subhypothesis_evidence.py +856 -0
- src/pipeline/survey_evidence_plan.py +858 -0
- src/pipeline/survey_gap_adjudication.py +647 -0
- src/pipeline/survey_gap_candidates.py +840 -0
- src/pipeline/survey_gap_ledger.py +683 -0
- src/pipeline/survey_gap_triage.py +401 -0
- src/pipeline/survey_handoff_persistence.py +481 -0
- src/pipeline/survey_handoff_projection.py +457 -0
- src/pipeline/survey_idea_handoff.py +1112 -0
- src/pipeline/survey_idea_loader.py +289 -0
- 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
|
+
}
|