qwen-sci 0.2.2__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.
Files changed (549) hide show
  1. qwen_sci-0.2.2.dist-info/METADATA +492 -0
  2. qwen_sci-0.2.2.dist-info/RECORD +549 -0
  3. qwen_sci-0.2.2.dist-info/WHEEL +4 -0
  4. qwen_sci-0.2.2.dist-info/entry_points.txt +6 -0
  5. qwen_sci-0.2.2.dist-info/licenses/LICENSE +21 -0
  6. src/1.txt +1 -0
  7. src/__init__.py +1 -0
  8. src/__main__.py +5 -0
  9. src/agents/__init__.py +1 -0
  10. src/agents/experiment_design_agent/__init__.py +268 -0
  11. src/agents/experiment_design_agent/artifacts.py +747 -0
  12. src/agents/experiment_design_agent/cache.py +319 -0
  13. src/agents/experiment_design_agent/completeness.py +130 -0
  14. src/agents/experiment_design_agent/contracts.py +771 -0
  15. src/agents/experiment_design_agent/counterexample_analyzer.py +187 -0
  16. src/agents/experiment_design_agent/design_evidence_paper_screener.py +470 -0
  17. src/agents/experiment_design_agent/discipline_catalog.py +178 -0
  18. src/agents/experiment_design_agent/evidence_cards.py +338 -0
  19. src/agents/experiment_design_agent/evidence_planner.py +452 -0
  20. src/agents/experiment_design_agent/formal_reasoning_planner.py +884 -0
  21. src/agents/experiment_design_agent/fulltext_acquisition.py +863 -0
  22. src/agents/experiment_design_agent/idea_adapter.py +100 -0
  23. src/agents/experiment_design_agent/idea_intake.py +87 -0
  24. src/agents/experiment_design_agent/llm_json.py +328 -0
  25. src/agents/experiment_design_agent/orchestrator.py +1220 -0
  26. src/agents/experiment_design_agent/reasoning_context.py +183 -0
  27. src/agents/experiment_design_agent/reasoning_validation.py +546 -0
  28. src/agents/experiment_design_agent/run.py +481 -0
  29. src/agents/experiment_design_agent/run_logging.py +292 -0
  30. src/agents/experiment_design_agent/scope_gate.py +198 -0
  31. src/agents/experiment_design_agent/study_type_composer.py +1360 -0
  32. src/agents/experiment_design_agent/survey_evidence.py +1381 -0
  33. src/agents/experiment_design_agent/template_router.py +195 -0
  34. src/agents/experiment_design_agent/variable_claim_extractor.py +93 -0
  35. src/agents/idea_agent/__init__.py +1 -0
  36. src/agents/idea_agent/agent/__init__.py +6 -0
  37. src/agents/idea_agent/agent/artifacts.py +308 -0
  38. src/agents/idea_agent/agent/base.py +122 -0
  39. src/agents/idea_agent/agent/ligagent.py +488 -0
  40. src/agents/idea_agent/agent/mcts.py +2060 -0
  41. src/agents/idea_agent/agent/prompts/__init__.py +48 -0
  42. src/agents/idea_agent/agent/prompts/advanced_analysis.py +235 -0
  43. src/agents/idea_agent/agent/prompts/algorithm_alignment.py +55 -0
  44. src/agents/idea_agent/agent/prompts/algorithm_structuring.py +48 -0
  45. src/agents/idea_agent/agent/prompts/component_extraction.py +62 -0
  46. src/agents/idea_agent/agent/prompts/component_novelty_evaluation.py +54 -0
  47. src/agents/idea_agent/agent/prompts/experiment_findings_extraction.py +39 -0
  48. src/agents/idea_agent/agent/prompts/idea_fusion.py +295 -0
  49. src/agents/idea_agent/agent/prompts/idea_introduction.py +30 -0
  50. src/agents/idea_agent/agent/prompts/idea_result_alignment.py +39 -0
  51. src/agents/idea_agent/agent/prompts/input_interpreter.py +52 -0
  52. src/agents/idea_agent/agent/prompts/keynote_ops.py +78 -0
  53. src/agents/idea_agent/agent/prompts/mcts_evaluation.py +235 -0
  54. src/agents/idea_agent/agent/prompts/mcts_generation.py +140 -0
  55. src/agents/idea_agent/agent/prompts/mechanism_commit_query.py +99 -0
  56. src/agents/idea_agent/agent/prompts/memory_selection.py +0 -0
  57. src/agents/idea_agent/agent/prompts/prompt_modes.py +16 -0
  58. src/agents/idea_agent/agent/prompts/rag_query.py +34 -0
  59. src/agents/idea_agent/agent/prompts/re_analysis_replan.py +48 -0
  60. src/agents/idea_agent/agent/prompts/reference_grounding.py +34 -0
  61. src/agents/idea_agent/agent/prompts/root_domain_classification.py +32 -0
  62. src/agents/idea_agent/agent/prompts/scientific_debate.py +88 -0
  63. src/agents/idea_agent/agent/prompts/scientific_materialization.py +45 -0
  64. src/agents/idea_agent/agent/prompts/skill_instantiation.py +193 -0
  65. src/agents/idea_agent/agent/prompts/theory_transfer_query.py +93 -0
  66. src/agents/idea_agent/agent/prompts/topic_background.py +15 -0
  67. src/agents/idea_agent/agent/skills/DEFAULT_SKILL_TEMPLATES.json +263 -0
  68. src/agents/idea_agent/agent/skills/edit_operator_skills/alternative-path-contrast/SKILL.md +40 -0
  69. src/agents/idea_agent/agent/skills/edit_operator_skills/alternative-path-contrast/references/contrast_patterns.md +22 -0
  70. src/agents/idea_agent/agent/skills/edit_operator_skills/feedback-closed-loop/SKILL.md +40 -0
  71. src/agents/idea_agent/agent/skills/edit_operator_skills/feedback-closed-loop/references/mechanism_cards.md +24 -0
  72. src/agents/idea_agent/agent/skills/edit_operator_skills/hierarchical-decomposition/SKILL.md +39 -0
  73. src/agents/idea_agent/agent/skills/edit_operator_skills/hierarchical-decomposition/references/hierarchy_patterns.md +22 -0
  74. src/agents/idea_agent/agent/skills/edit_operator_skills/mechanism-commit-innovation/SKILL.md +39 -0
  75. src/agents/idea_agent/agent/skills/edit_operator_skills/mechanism-commit-innovation/references/mechanism_commit_patterns.md +24 -0
  76. src/agents/idea_agent/agent/skills/edit_operator_skills/multi-scale-coordinator/SKILL.md +39 -0
  77. src/agents/idea_agent/agent/skills/edit_operator_skills/multi-scale-coordinator/references/coordination_patterns.md +22 -0
  78. src/agents/idea_agent/agent/skills/edit_operator_skills/speculative-execution-with-repair/SKILL.md +41 -0
  79. src/agents/idea_agent/agent/skills/edit_operator_skills/speculative-execution-with-repair/references/speculation_patterns.md +22 -0
  80. src/agents/idea_agent/agent/skills/edit_operator_skills/surgical-modularity/SKILL.md +39 -0
  81. src/agents/idea_agent/agent/skills/edit_operator_skills/surgical-modularity/references/modularity_patterns.md +22 -0
  82. src/agents/idea_agent/agent/skills/edit_operator_skills/theory-transfer-injection/SKILL.md +40 -0
  83. src/agents/idea_agent/agent/skills/edit_operator_skills/theory-transfer-injection/references/transfer_patterns.md +24 -0
  84. src/agents/idea_agent/example/idea_result.json +900 -0
  85. src/agents/idea_agent/run.py +276 -0
  86. src/agents/idea_agent/tests/test_advanced_analysis_contract.py +90 -0
  87. src/agents/idea_agent/tests/test_gap_hypothesis_bridge.py +166 -0
  88. src/agents/idea_agent/tests/test_idea_debate.py +480 -0
  89. src/agents/idea_agent/tests/test_idea_result_v5.py +99 -0
  90. src/agents/idea_agent/tests/test_idea_synthesis.py +92 -0
  91. src/agents/idea_agent/tests/test_mature_idea_sources.py +27 -0
  92. src/agents/idea_agent/tests/test_multimodal_idea_integration.py +386 -0
  93. src/agents/idea_agent/tests/test_profile_materialization_and_scoring.py +219 -0
  94. src/agents/idea_agent/tests/test_profile_native_skill_rendering.py +122 -0
  95. src/agents/idea_agent/tests/test_result_persistence.py +183 -0
  96. src/agents/idea_agent/tests/test_scientific_intervention_profiles.py +241 -0
  97. src/agents/idea_agent/tests/test_scientific_rubric_and_prompts.py +559 -0
  98. src/agents/idea_agent/tests/test_strict_json_object_responses.py +48 -0
  99. src/agents/idea_agent/utils/core/__init__.py +1 -0
  100. src/agents/idea_agent/utils/core/ablation_inputs.py +60 -0
  101. src/agents/idea_agent/utils/core/chat_errors.py +95 -0
  102. src/agents/idea_agent/utils/core/chat_router.py +130 -0
  103. src/agents/idea_agent/utils/core/chat_transport.py +188 -0
  104. src/agents/idea_agent/utils/core/config_loader.py +166 -0
  105. src/agents/idea_agent/utils/core/json_utils.py +28 -0
  106. src/agents/idea_agent/utils/core/logger.py +287 -0
  107. src/agents/idea_agent/utils/core/progress.py +58 -0
  108. src/agents/idea_agent/utils/core/response_parsing.py +57 -0
  109. src/agents/idea_agent/utils/core/run_inputs.py +198 -0
  110. src/agents/idea_agent/utils/mcts/__init__.py +1 -0
  111. src/agents/idea_agent/utils/mcts/component_novelty.py +342 -0
  112. src/agents/idea_agent/utils/mcts/defect_registry.py +302 -0
  113. src/agents/idea_agent/utils/mcts/idea_routes.py +61 -0
  114. src/agents/idea_agent/utils/mcts/idea_taste_presets.py +312 -0
  115. src/agents/idea_agent/utils/mcts/mcts_helpers.py +738 -0
  116. src/agents/idea_agent/utils/mcts/mcts_runtime.py +3000 -0
  117. src/agents/idea_agent/utils/mcts/scientific_intervention_ontology.py +1561 -0
  118. src/agents/idea_agent/utils/mcts/scientific_rubric.py +211 -0
  119. src/agents/idea_agent/utils/mcts/skill_parsing.py +92 -0
  120. src/agents/idea_agent/utils/papers/__init__.py +1 -0
  121. src/agents/idea_agent/utils/papers/paper_graph_vector_store.py +499 -0
  122. src/agents/idea_agent/utils/papers/paper_repository.py +372 -0
  123. src/agents/idea_agent/utils/prompting/__init__.py +1 -0
  124. src/agents/idea_agent/utils/prompting/prompt_views.py +637 -0
  125. src/agents/idea_agent/utils/workflow/__init__.py +1 -0
  126. src/agents/idea_agent/utils/workflow/advanced_analysis_contract.py +244 -0
  127. src/agents/idea_agent/utils/workflow/gap_hypothesis_bridge.py +461 -0
  128. src/agents/idea_agent/utils/workflow/idea_contract.py +426 -0
  129. src/agents/idea_agent/utils/workflow/idea_debate.py +1238 -0
  130. src/agents/idea_agent/utils/workflow/idea_diversity.py +163 -0
  131. src/agents/idea_agent/utils/workflow/idea_fusion.py +889 -0
  132. src/agents/idea_agent/utils/workflow/idea_helpers.py +478 -0
  133. src/agents/idea_agent/utils/workflow/idea_portfolio.py +422 -0
  134. src/agents/idea_agent/utils/workflow/idea_synthesis.py +482 -0
  135. src/agents/idea_agent/utils/workflow/ligagent_flow.py +581 -0
  136. src/agents/idea_agent/utils/workflow/ligagent_handlers.py +1836 -0
  137. src/agents/idea_agent/utils/workflow/ligagent_helpers.py +1331 -0
  138. src/agents/idea_agent/utils/workflow/ligagent_utils.py +322 -0
  139. src/agents/idea_agent/utils/workflow/mature_idea_sources.py +648 -0
  140. src/agents/idea_agent/utils/workflow/multimodal_data_anchoring.py +578 -0
  141. src/agents/idea_agent/utils/workflow/result_persistence.py +774 -0
  142. src/agents/idea_agent/utils/workflow/stage_contract.py +76 -0
  143. src/agents/idea_agent/utils/workflow/workflow_runtime.py +288 -0
  144. src/agents/research_plan_author/__init__.py +50 -0
  145. src/agents/research_plan_author/artifacts.py +270 -0
  146. src/agents/research_plan_author/authoring_blueprint.py +1246 -0
  147. src/agents/research_plan_author/bibtex_renderer.py +252 -0
  148. src/agents/research_plan_author/contract_repair.py +131 -0
  149. src/agents/research_plan_author/contracts.py +745 -0
  150. src/agents/research_plan_author/cross_section_editor.py +232 -0
  151. src/agents/research_plan_author/document_quality.py +987 -0
  152. src/agents/research_plan_author/idea_evolution.py +223 -0
  153. src/agents/research_plan_author/input_loader.py +76 -0
  154. src/agents/research_plan_author/latex_compiler.py +260 -0
  155. src/agents/research_plan_author/latex_safety.py +159 -0
  156. src/agents/research_plan_author/llm_json.py +74 -0
  157. src/agents/research_plan_author/markdown_renderer.py +275 -0
  158. src/agents/research_plan_author/pdf_validator.py +149 -0
  159. src/agents/research_plan_author/render.py +166 -0
  160. src/agents/research_plan_author/render_artifacts.py +187 -0
  161. src/agents/research_plan_author/run.py +765 -0
  162. src/agents/research_plan_author/run_logging.py +209 -0
  163. src/agents/research_plan_author/section_cache.py +184 -0
  164. src/agents/research_plan_author/section_composer.py +1764 -0
  165. src/agents/research_plan_author/section_router.py +209 -0
  166. src/agents/research_plan_author/semantic_validator.py +254 -0
  167. src/agents/research_plan_author/source_bundle.py +46 -0
  168. src/agents/research_plan_author/source_registry.py +208 -0
  169. src/agents/research_plan_author/survey_source_loader.py +137 -0
  170. src/agents/research_plan_author/template_adapter.py +173 -0
  171. src/agents/research_plan_author/template_profile.py +204 -0
  172. src/agents/research_plan_author/tex_renderer.py +496 -0
  173. src/agents/research_plan_author/theory_presentation.py +134 -0
  174. src/agents/research_plan_author/theory_spine.py +703 -0
  175. src/agents/survey_agent/Dockerfile +54 -0
  176. src/agents/survey_agent/README.md +350 -0
  177. src/agents/survey_agent/config/deep_survey.yaml +217 -0
  178. src/agents/survey_agent/config/deep_survey_batch.yaml +176 -0
  179. src/agents/survey_agent/config/deep_survey_fast.yaml +183 -0
  180. src/agents/survey_agent/config/evaluate_baseline.yaml +197 -0
  181. src/agents/survey_agent/config/outcomeRAG.yaml +159 -0
  182. src/agents/survey_agent/env_report.md +348 -0
  183. src/agents/survey_agent/modules/__init__.py +0 -0
  184. src/agents/survey_agent/modules/code_collector.py +2194 -0
  185. src/agents/survey_agent/modules/code_report_generator.py +1282 -0
  186. src/agents/survey_agent/modules/data_manager.py +1713 -0
  187. src/agents/survey_agent/modules/database.py +167 -0
  188. src/agents/survey_agent/modules/fulltext_download_cache.py +462 -0
  189. src/agents/survey_agent/modules/fulltext_http.py +134 -0
  190. src/agents/survey_agent/modules/fulltext_resolution.py +749 -0
  191. src/agents/survey_agent/modules/judge.py +434 -0
  192. src/agents/survey_agent/modules/outcome_RAG.py +236 -0
  193. src/agents/survey_agent/modules/paper_graph_retriever.py +1075 -0
  194. src/agents/survey_agent/modules/pe.py +1729 -0
  195. src/agents/survey_agent/modules/pseudo_creator.py +782 -0
  196. src/agents/survey_agent/modules/pseudo_reviser.py +537 -0
  197. src/agents/survey_agent/modules/refine_agent.py +1952 -0
  198. src/agents/survey_agent/modules/survey_generator.py +5691 -0
  199. src/agents/survey_agent/modules/survey_visualizer.py +1817 -0
  200. src/agents/survey_agent/modules/visual_prompts.py +327 -0
  201. src/agents/survey_agent/modules/work_analyzer.py +2750 -0
  202. src/agents/survey_agent/modules/work_collector.py +5649 -0
  203. src/agents/survey_agent/scripts/baseline_evaluation.py +411 -0
  204. src/agents/survey_agent/scripts/run_deep_survey.py +224 -0
  205. src/agents/survey_agent/scripts/run_deep_survey_adapter.py +407 -0
  206. src/agents/survey_agent/scripts/run_deep_survey_adapter_with_input.py +409 -0
  207. src/agents/survey_agent/scripts/run_deep_survey_batch.py +228 -0
  208. src/agents/survey_agent/scripts/run_deep_survey_batch_arg.py +243 -0
  209. src/agents/survey_agent/scripts/run_deep_survey_batch_settings.py +218 -0
  210. src/agents/survey_agent/scripts/run_keynotes_gen.py +128 -0
  211. src/agents/survey_agent/scripts/run_work_clusters.py +140 -0
  212. src/agents/survey_agent/utils/__init__.py +0 -0
  213. src/agents/survey_agent/utils/ablation_utils.py +317 -0
  214. src/agents/survey_agent/utils/api_call.py +2518 -0
  215. src/agents/survey_agent/utils/chat_utils.py +82 -0
  216. src/agents/survey_agent/utils/config_utils.py +70 -0
  217. src/agents/survey_agent/utils/convert_to_md.py +29 -0
  218. src/agents/survey_agent/utils/err_info.py +24 -0
  219. src/agents/survey_agent/utils/file_utils.py +176 -0
  220. src/agents/survey_agent/utils/gpu_utils.py +145 -0
  221. src/agents/survey_agent/utils/graph_visualizer.py +279 -0
  222. src/agents/survey_agent/utils/html_utils.py +181 -0
  223. src/agents/survey_agent/utils/latex_generator_intra_cluster.py +529 -0
  224. src/agents/survey_agent/utils/latex_generator_relation_table.py +193 -0
  225. src/agents/survey_agent/utils/mineru_section_packer.py +334 -0
  226. src/agents/survey_agent/utils/mineru_utils.py +396 -0
  227. src/agents/survey_agent/utils/paper.py +144 -0
  228. src/agents/survey_agent/utils/read_yaml.py +15 -0
  229. src/agents/survey_agent/utils/repo_utils.py +167 -0
  230. src/agents/survey_agent/utils/rich_logger.py +77 -0
  231. src/agents/survey_agent/utils/step2v2.py +874 -0
  232. src/agents/survey_agent/utils/step2v2_extractor.py +856 -0
  233. src/agents/survey_agent/utils/topic_survey_storage.py +309 -0
  234. src/agents/survey_agent/utils/utils.py +118 -0
  235. src/agents/survey_agent/utils/work.py +2 -0
  236. src/cli.py +1611 -0
  237. src/config/__init__.py +213 -0
  238. src/config/default.yaml +1122 -0
  239. src/harness/LICENSE +21 -0
  240. src/harness/VENDORED.md +14 -0
  241. src/harness/src/__init__.py +0 -0
  242. src/harness/src/openharness/__init__.py +0 -0
  243. src/harness/src/openharness/__main__.py +6 -0
  244. src/harness/src/openharness/api/__init__.py +21 -0
  245. src/harness/src/openharness/api/client.py +281 -0
  246. src/harness/src/openharness/api/codex_client.py +407 -0
  247. src/harness/src/openharness/api/copilot_auth.py +240 -0
  248. src/harness/src/openharness/api/copilot_client.py +134 -0
  249. src/harness/src/openharness/api/errors.py +19 -0
  250. src/harness/src/openharness/api/openai_client.py +577 -0
  251. src/harness/src/openharness/api/provider.py +186 -0
  252. src/harness/src/openharness/api/registry.py +437 -0
  253. src/harness/src/openharness/api/usage.py +17 -0
  254. src/harness/src/openharness/auth/__init__.py +29 -0
  255. src/harness/src/openharness/auth/external.py +610 -0
  256. src/harness/src/openharness/auth/flows.py +189 -0
  257. src/harness/src/openharness/auth/manager.py +482 -0
  258. src/harness/src/openharness/auth/storage.py +269 -0
  259. src/harness/src/openharness/autopilot/__init__.py +23 -0
  260. src/harness/src/openharness/autopilot/service.py +2239 -0
  261. src/harness/src/openharness/autopilot/types.py +91 -0
  262. src/harness/src/openharness/bridge/__init__.py +20 -0
  263. src/harness/src/openharness/bridge/manager.py +106 -0
  264. src/harness/src/openharness/bridge/session_runner.py +46 -0
  265. src/harness/src/openharness/bridge/types.py +37 -0
  266. src/harness/src/openharness/bridge/work_secret.py +41 -0
  267. src/harness/src/openharness/channels/UPSTREAM +6 -0
  268. src/harness/src/openharness/channels/__init__.py +22 -0
  269. src/harness/src/openharness/channels/adapter.py +131 -0
  270. src/harness/src/openharness/channels/bus/__init__.py +6 -0
  271. src/harness/src/openharness/channels/bus/events.py +38 -0
  272. src/harness/src/openharness/channels/bus/queue.py +44 -0
  273. src/harness/src/openharness/channels/impl/__init__.py +6 -0
  274. src/harness/src/openharness/channels/impl/base.py +141 -0
  275. src/harness/src/openharness/channels/impl/dingtalk.py +445 -0
  276. src/harness/src/openharness/channels/impl/discord.py +311 -0
  277. src/harness/src/openharness/channels/impl/email.py +410 -0
  278. src/harness/src/openharness/channels/impl/feishu.py +1342 -0
  279. src/harness/src/openharness/channels/impl/manager.py +257 -0
  280. src/harness/src/openharness/channels/impl/matrix.py +700 -0
  281. src/harness/src/openharness/channels/impl/mochat.py +897 -0
  282. src/harness/src/openharness/channels/impl/qq.py +141 -0
  283. src/harness/src/openharness/channels/impl/slack.py +284 -0
  284. src/harness/src/openharness/channels/impl/telegram.py +525 -0
  285. src/harness/src/openharness/channels/impl/whatsapp.py +159 -0
  286. src/harness/src/openharness/cli.py +2551 -0
  287. src/harness/src/openharness/commands/__init__.py +21 -0
  288. src/harness/src/openharness/commands/registry.py +2783 -0
  289. src/harness/src/openharness/config/__init__.py +34 -0
  290. src/harness/src/openharness/config/paths.py +160 -0
  291. src/harness/src/openharness/config/schema.py +119 -0
  292. src/harness/src/openharness/config/settings.py +1104 -0
  293. src/harness/src/openharness/coordinator/__init__.py +12 -0
  294. src/harness/src/openharness/coordinator/agent_definitions.py +975 -0
  295. src/harness/src/openharness/coordinator/coordinator_mode.py +520 -0
  296. src/harness/src/openharness/engine/__init__.py +80 -0
  297. src/harness/src/openharness/engine/cost_tracker.py +24 -0
  298. src/harness/src/openharness/engine/messages.py +222 -0
  299. src/harness/src/openharness/engine/query.py +1188 -0
  300. src/harness/src/openharness/engine/query_engine.py +313 -0
  301. src/harness/src/openharness/engine/stream_events.py +90 -0
  302. src/harness/src/openharness/hooks/__init__.py +50 -0
  303. src/harness/src/openharness/hooks/events.py +20 -0
  304. src/harness/src/openharness/hooks/executor.py +242 -0
  305. src/harness/src/openharness/hooks/hot_reload.py +31 -0
  306. src/harness/src/openharness/hooks/loader.py +68 -0
  307. src/harness/src/openharness/hooks/schemas.py +66 -0
  308. src/harness/src/openharness/hooks/types.py +38 -0
  309. src/harness/src/openharness/keybindings/__init__.py +14 -0
  310. src/harness/src/openharness/keybindings/default_bindings.py +11 -0
  311. src/harness/src/openharness/keybindings/loader.py +22 -0
  312. src/harness/src/openharness/keybindings/parser.py +18 -0
  313. src/harness/src/openharness/keybindings/resolver.py +13 -0
  314. src/harness/src/openharness/mcp/__init__.py +79 -0
  315. src/harness/src/openharness/mcp/client.py +298 -0
  316. src/harness/src/openharness/mcp/config.py +16 -0
  317. src/harness/src/openharness/mcp/types.py +76 -0
  318. src/harness/src/openharness/memory/__init__.py +25 -0
  319. src/harness/src/openharness/memory/agent.py +91 -0
  320. src/harness/src/openharness/memory/manager.py +183 -0
  321. src/harness/src/openharness/memory/memdir.py +52 -0
  322. src/harness/src/openharness/memory/migrate.py +137 -0
  323. src/harness/src/openharness/memory/paths.py +22 -0
  324. src/harness/src/openharness/memory/relevance.py +145 -0
  325. src/harness/src/openharness/memory/scan.py +116 -0
  326. src/harness/src/openharness/memory/schema.py +443 -0
  327. src/harness/src/openharness/memory/search.py +71 -0
  328. src/harness/src/openharness/memory/team.py +90 -0
  329. src/harness/src/openharness/memory/types.py +33 -0
  330. src/harness/src/openharness/memory/usage.py +153 -0
  331. src/harness/src/openharness/output_styles/__init__.py +5 -0
  332. src/harness/src/openharness/output_styles/loader.py +42 -0
  333. src/harness/src/openharness/permissions/__init__.py +26 -0
  334. src/harness/src/openharness/permissions/checker.py +200 -0
  335. src/harness/src/openharness/permissions/modes.py +13 -0
  336. src/harness/src/openharness/personalization/__init__.py +6 -0
  337. src/harness/src/openharness/personalization/extractor.py +138 -0
  338. src/harness/src/openharness/personalization/rules.py +64 -0
  339. src/harness/src/openharness/personalization/session_hook.py +65 -0
  340. src/harness/src/openharness/platforms.py +86 -0
  341. src/harness/src/openharness/plugins/__init__.py +53 -0
  342. src/harness/src/openharness/plugins/bundled/__init__.py +0 -0
  343. src/harness/src/openharness/plugins/installer.py +39 -0
  344. src/harness/src/openharness/plugins/loader.py +730 -0
  345. src/harness/src/openharness/plugins/schemas.py +24 -0
  346. src/harness/src/openharness/plugins/types.py +59 -0
  347. src/harness/src/openharness/prompts/__init__.py +14 -0
  348. src/harness/src/openharness/prompts/claudemd.py +48 -0
  349. src/harness/src/openharness/prompts/context.py +187 -0
  350. src/harness/src/openharness/prompts/environment.py +135 -0
  351. src/harness/src/openharness/prompts/system_prompt.py +109 -0
  352. src/harness/src/openharness/sandbox/Dockerfile +6 -0
  353. src/harness/src/openharness/sandbox/__init__.py +33 -0
  354. src/harness/src/openharness/sandbox/adapter.py +148 -0
  355. src/harness/src/openharness/sandbox/docker_backend.py +232 -0
  356. src/harness/src/openharness/sandbox/docker_image.py +103 -0
  357. src/harness/src/openharness/sandbox/path_validator.py +37 -0
  358. src/harness/src/openharness/sandbox/session.py +63 -0
  359. src/harness/src/openharness/services/__init__.py +30 -0
  360. src/harness/src/openharness/services/autodream/__init__.py +34 -0
  361. src/harness/src/openharness/services/autodream/backup.py +104 -0
  362. src/harness/src/openharness/services/autodream/lock.py +138 -0
  363. src/harness/src/openharness/services/autodream/prompt.py +128 -0
  364. src/harness/src/openharness/services/autodream/service.py +313 -0
  365. src/harness/src/openharness/services/compact/__init__.py +1725 -0
  366. src/harness/src/openharness/services/cron.py +137 -0
  367. src/harness/src/openharness/services/cron_scheduler.py +595 -0
  368. src/harness/src/openharness/services/lsp/__init__.py +216 -0
  369. src/harness/src/openharness/services/memory_extract/__init__.py +261 -0
  370. src/harness/src/openharness/services/oauth/__init__.py +0 -0
  371. src/harness/src/openharness/services/session_backend.py +97 -0
  372. src/harness/src/openharness/services/session_memory/__init__.py +139 -0
  373. src/harness/src/openharness/services/session_storage.py +230 -0
  374. src/harness/src/openharness/services/token_estimation.py +15 -0
  375. src/harness/src/openharness/services/tool_outputs.py +55 -0
  376. src/harness/src/openharness/skills/__init__.py +44 -0
  377. src/harness/src/openharness/skills/_frontmatter.py +97 -0
  378. src/harness/src/openharness/skills/bundled/__init__.py +75 -0
  379. src/harness/src/openharness/skills/bundled/content/commit.md +25 -0
  380. src/harness/src/openharness/skills/bundled/content/debug.md +25 -0
  381. src/harness/src/openharness/skills/bundled/content/diagnose.md +35 -0
  382. src/harness/src/openharness/skills/bundled/content/plan.md +39 -0
  383. src/harness/src/openharness/skills/bundled/content/review.md +33 -0
  384. src/harness/src/openharness/skills/bundled/content/simplify.md +26 -0
  385. src/harness/src/openharness/skills/bundled/content/skill-creator.md +175 -0
  386. src/harness/src/openharness/skills/bundled/content/test.md +38 -0
  387. src/harness/src/openharness/skills/loader.py +229 -0
  388. src/harness/src/openharness/skills/registry.py +29 -0
  389. src/harness/src/openharness/skills/types.py +24 -0
  390. src/harness/src/openharness/state/__init__.py +6 -0
  391. src/harness/src/openharness/state/app_state.py +30 -0
  392. src/harness/src/openharness/state/store.py +40 -0
  393. src/harness/src/openharness/swarm/__init__.py +70 -0
  394. src/harness/src/openharness/swarm/in_process.py +693 -0
  395. src/harness/src/openharness/swarm/lockfile.py +24 -0
  396. src/harness/src/openharness/swarm/mailbox.py +522 -0
  397. src/harness/src/openharness/swarm/permission_sync.py +1168 -0
  398. src/harness/src/openharness/swarm/registry.py +410 -0
  399. src/harness/src/openharness/swarm/spawn_utils.py +229 -0
  400. src/harness/src/openharness/swarm/subprocess_backend.py +171 -0
  401. src/harness/src/openharness/swarm/team_lifecycle.py +910 -0
  402. src/harness/src/openharness/swarm/types.py +398 -0
  403. src/harness/src/openharness/swarm/worktree.py +315 -0
  404. src/harness/src/openharness/tasks/__init__.py +18 -0
  405. src/harness/src/openharness/tasks/local_agent_task.py +28 -0
  406. src/harness/src/openharness/tasks/local_shell_task.py +17 -0
  407. src/harness/src/openharness/tasks/manager.py +475 -0
  408. src/harness/src/openharness/tasks/stop_task.py +11 -0
  409. src/harness/src/openharness/tasks/types.py +32 -0
  410. src/harness/src/openharness/themes/__init__.py +21 -0
  411. src/harness/src/openharness/themes/builtin.py +89 -0
  412. src/harness/src/openharness/themes/loader.py +55 -0
  413. src/harness/src/openharness/themes/schema.py +54 -0
  414. src/harness/src/openharness/tools/__init__.py +107 -0
  415. src/harness/src/openharness/tools/agent_tool.py +141 -0
  416. src/harness/src/openharness/tools/ask_user_question_tool.py +46 -0
  417. src/harness/src/openharness/tools/base.py +80 -0
  418. src/harness/src/openharness/tools/bash_tool.py +259 -0
  419. src/harness/src/openharness/tools/brief_tool.py +33 -0
  420. src/harness/src/openharness/tools/config_tool.py +54 -0
  421. src/harness/src/openharness/tools/cron_create_tool.py +105 -0
  422. src/harness/src/openharness/tools/cron_delete_tool.py +32 -0
  423. src/harness/src/openharness/tools/cron_list_tool.py +69 -0
  424. src/harness/src/openharness/tools/cron_toggle_tool.py +37 -0
  425. src/harness/src/openharness/tools/enter_plan_mode_tool.py +28 -0
  426. src/harness/src/openharness/tools/enter_worktree_tool.py +80 -0
  427. src/harness/src/openharness/tools/exit_plan_mode_tool.py +28 -0
  428. src/harness/src/openharness/tools/exit_worktree_tool.py +42 -0
  429. src/harness/src/openharness/tools/file_edit_tool.py +95 -0
  430. src/harness/src/openharness/tools/file_read_tool.py +72 -0
  431. src/harness/src/openharness/tools/file_write_tool.py +87 -0
  432. src/harness/src/openharness/tools/glob_tool.py +175 -0
  433. src/harness/src/openharness/tools/grep_tool.py +374 -0
  434. src/harness/src/openharness/tools/image_generation_tool.py +447 -0
  435. src/harness/src/openharness/tools/image_to_text_tool.py +237 -0
  436. src/harness/src/openharness/tools/list_mcp_resources_tool.py +36 -0
  437. src/harness/src/openharness/tools/lsp_tool.py +154 -0
  438. src/harness/src/openharness/tools/mcp_auth_tool.py +71 -0
  439. src/harness/src/openharness/tools/mcp_tool.py +72 -0
  440. src/harness/src/openharness/tools/notebook_edit_tool.py +97 -0
  441. src/harness/src/openharness/tools/read_mcp_resource_tool.py +38 -0
  442. src/harness/src/openharness/tools/remote_trigger_tool.py +74 -0
  443. src/harness/src/openharness/tools/send_message_tool.py +58 -0
  444. src/harness/src/openharness/tools/skill_tool.py +43 -0
  445. src/harness/src/openharness/tools/sleep_tool.py +32 -0
  446. src/harness/src/openharness/tools/task_create_tool.py +56 -0
  447. src/harness/src/openharness/tools/task_get_tool.py +33 -0
  448. src/harness/src/openharness/tools/task_list_tool.py +35 -0
  449. src/harness/src/openharness/tools/task_output_tool.py +35 -0
  450. src/harness/src/openharness/tools/task_stop_tool.py +30 -0
  451. src/harness/src/openharness/tools/task_update_tool.py +50 -0
  452. src/harness/src/openharness/tools/team_create_tool.py +31 -0
  453. src/harness/src/openharness/tools/team_delete_tool.py +30 -0
  454. src/harness/src/openharness/tools/todo_write_tool.py +46 -0
  455. src/harness/src/openharness/tools/tool_search_tool.py +38 -0
  456. src/harness/src/openharness/tools/web_fetch_tool.py +117 -0
  457. src/harness/src/openharness/tools/web_search_tool.py +119 -0
  458. src/harness/src/openharness/ui/__init__.py +5 -0
  459. src/harness/src/openharness/ui/app.py +320 -0
  460. src/harness/src/openharness/ui/backend_host.py +941 -0
  461. src/harness/src/openharness/ui/coordinator_drain.py +198 -0
  462. src/harness/src/openharness/ui/input.py +32 -0
  463. src/harness/src/openharness/ui/output.py +265 -0
  464. src/harness/src/openharness/ui/permission_dialog.py +14 -0
  465. src/harness/src/openharness/ui/protocol.py +247 -0
  466. src/harness/src/openharness/ui/react_launcher.py +179 -0
  467. src/harness/src/openharness/ui/runtime.py +799 -0
  468. src/harness/src/openharness/ui/textual_app.py +495 -0
  469. src/harness/src/openharness/utils/__init__.py +0 -0
  470. src/harness/src/openharness/utils/file_lock.py +86 -0
  471. src/harness/src/openharness/utils/fs.py +98 -0
  472. src/harness/src/openharness/utils/helpers.py +77 -0
  473. src/harness/src/openharness/utils/network_guard.py +340 -0
  474. src/harness/src/openharness/utils/shell.py +147 -0
  475. src/harness/src/openharness/vim/__init__.py +5 -0
  476. src/harness/src/openharness/vim/transitions.py +8 -0
  477. src/harness/src/openharness/voice/__init__.py +7 -0
  478. src/harness/src/openharness/voice/keyterms.py +10 -0
  479. src/harness/src/openharness/voice/stream_stt.py +8 -0
  480. src/harness/src/openharness/voice/voice_mode.py +44 -0
  481. src/llm/__init__.py +29 -0
  482. src/llm/image_generation.py +402 -0
  483. src/llm/provider_registry.py +419 -0
  484. src/llm/runtime_env.py +64 -0
  485. src/llm/structured_output.py +131 -0
  486. src/llm/vision.py +198 -0
  487. src/memory/__init__.py +2 -0
  488. src/memory/api/__init__.py +1 -0
  489. src/memory/api/base_memory_system_api.py +98 -0
  490. src/memory/api/base_symbolic_memory_system_api.py +190 -0
  491. src/memory/api/base_vector_memory_system_api.py +116 -0
  492. src/memory/api/faiss_memory_system_api.py +228 -0
  493. src/memory/api/slot_process_api.py +827 -0
  494. src/memory/api/symbolic_memory_system_api.py +572 -0
  495. src/memory/memory_system/__init__.py +48 -0
  496. src/memory/memory_system/component_taxonomy.py +430 -0
  497. src/memory/memory_system/llm.py +437 -0
  498. src/memory/memory_system/models.py +168 -0
  499. src/memory/memory_system/user_prompt.py +418 -0
  500. src/memory/memory_system/utils.py +282 -0
  501. src/memory/memory_system/vectorstore.py +311 -0
  502. src/memory/memory_system/working_slot.py +99 -0
  503. src/pipeline/__init__.py +129 -0
  504. src/pipeline/contracts.py +531 -0
  505. src/pipeline/discipline_taxonomy.py +916 -0
  506. src/pipeline/evidence_coverage_ledger.py +1150 -0
  507. src/pipeline/evidence_refinement.py +620 -0
  508. src/pipeline/experiment_to_symbolic.py +137 -0
  509. src/pipeline/multimodal_evidence/__init__.py +28 -0
  510. src/pipeline/multimodal_evidence/capabilities.py +107 -0
  511. src/pipeline/multimodal_evidence/claims.py +135 -0
  512. src/pipeline/multimodal_evidence/contract.py +291 -0
  513. src/pipeline/multimodal_evidence/data_sh_compiler.py +505 -0
  514. src/pipeline/multimodal_evidence/handoff_binding.py +135 -0
  515. src/pipeline/multimodal_evidence/manifest.py +254 -0
  516. src/pipeline/multimodal_evidence/native_analysis.py +479 -0
  517. src/pipeline/multimodal_evidence/perception.py +277 -0
  518. src/pipeline/multimodal_evidence/query_binding.py +65 -0
  519. src/pipeline/multimodal_evidence/rendering.py +120 -0
  520. src/pipeline/multimodal_evidence/safety.py +42 -0
  521. src/pipeline/multimodal_evidence/sampling.py +69 -0
  522. src/pipeline/multimodal_evidence/service.py +148 -0
  523. src/pipeline/multimodal_evidence/survey_integration.py +460 -0
  524. src/pipeline/paper_identity.py +52 -0
  525. src/pipeline/research_design_inventory.py +338 -0
  526. src/pipeline/research_domain_resolution.py +669 -0
  527. src/pipeline/research_identity.py +548 -0
  528. src/pipeline/research_question_contract.py +646 -0
  529. src/pipeline/retrieval_lanes.py +889 -0
  530. src/pipeline/run_loop.py +862 -0
  531. src/pipeline/science_events.py +30 -0
  532. src/pipeline/science_manifests.py +602 -0
  533. src/pipeline/science_run.py +918 -0
  534. src/pipeline/science_stages.py +709 -0
  535. src/pipeline/science_workflow.py +807 -0
  536. src/pipeline/sh_cluster_projection.py +306 -0
  537. src/pipeline/sh_graph_provenance.py +648 -0
  538. src/pipeline/subhypothesis_decomposition.py +392 -0
  539. src/pipeline/subhypothesis_evidence.py +856 -0
  540. src/pipeline/survey_evidence_plan.py +858 -0
  541. src/pipeline/survey_gap_adjudication.py +647 -0
  542. src/pipeline/survey_gap_candidates.py +840 -0
  543. src/pipeline/survey_gap_ledger.py +683 -0
  544. src/pipeline/survey_gap_triage.py +401 -0
  545. src/pipeline/survey_handoff_persistence.py +481 -0
  546. src/pipeline/survey_handoff_projection.py +457 -0
  547. src/pipeline/survey_idea_handoff.py +1112 -0
  548. src/pipeline/survey_idea_loader.py +289 -0
  549. src/research_run_ids.py +11 -0
@@ -0,0 +1,749 @@
1
+ """Legal open-access full-text candidate resolution.
2
+
3
+ This module deliberately plans bounded, provenance-rich download candidates; it
4
+ does not decide whether a paper is relevant to a project or sub-hypothesis, and
5
+ it does not attempt to bypass publisher access controls.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from html.parser import HTMLParser
11
+ from typing import Any, Iterable, Mapping
12
+ from urllib.parse import quote, urljoin, urlsplit, urlunsplit
13
+ import time
14
+
15
+ import requests
16
+
17
+
18
+ OA_PRIORITY_UNPAYWALL_BEST = 0
19
+ OA_PRIORITY_UNPAYWALL_OTHER = 10
20
+ OA_PRIORITY_OPENALEX_BEST = 30
21
+ OA_PRIORITY_OPENALEX_OTHER = 40
22
+ OA_PRIORITY_IDENTIFIER_REPOSITORY = 100
23
+ OA_PRIORITY_METADATA_REPOSITORY = 120
24
+ OA_PRIORITY_GENERIC_METADATA = 130
25
+ OA_PRIORITY_PROVIDER_URL = 140
26
+ # DOI recovery runs immediately after Unpaywall candidates (0/1/10/11),
27
+ # before generic metadata and provider routes. It remains deliberately
28
+ # conditional on an OA declaration.
29
+ OA_PRIORITY_DOI_LANDING = 20
30
+
31
+
32
+ _GENERIC_METADATA_URL_FIELDS = (
33
+ # A source that explicitly calls a value a PDF is safe to try as a direct
34
+ # candidate. The downloader still verifies its bytes before retaining it.
35
+ ("pdf_url", "metadata.pdf_url", "pdf"),
36
+ ("url_for_pdf", "metadata.url_for_pdf", "pdf"),
37
+ ("full_text_pdf_url", "metadata.full_text_pdf_url", "pdf"),
38
+ ("fulltext_pdf_url", "metadata.fulltext_pdf_url", "pdf"),
39
+ ("oa_pdf_url", "metadata.oa_pdf_url", "pdf"),
40
+ # These URLs are common interchange fields. A URL with a PDF-shaped path
41
+ # is a direct candidate; a non-PDF landing page must also carry an explicit
42
+ # OA signal before the resolver is allowed to fetch and inspect it.
43
+ ("full_text_url", "metadata.full_text_url", "auto"),
44
+ ("fulltext_url", "metadata.fulltext_url", "auto"),
45
+ ("full_text", "metadata.full_text", "auto"),
46
+ ("landing_page_url", "metadata.landing_page_url", "landing_page"),
47
+ ("url_for_landing_page", "metadata.url_for_landing_page", "landing_page"),
48
+ )
49
+
50
+
51
+ def normalize_http_url(value: Any) -> str:
52
+ """Return an HTTP(S) URL without a fragment, or an empty string.
53
+
54
+ HTTP scheme and host are case-insensitive. A repository path and query
55
+ string are not: notably, signed URL parameters commonly encode case.
56
+ """
57
+ raw = str(value or "").strip()
58
+ if not raw:
59
+ return ""
60
+ parsed = urlsplit(raw)
61
+ if parsed.scheme.lower() not in {"http", "https"} or not parsed.netloc:
62
+ return ""
63
+ return urlunsplit(
64
+ (parsed.scheme.lower(), parsed.netloc.lower(), parsed.path, parsed.query, "")
65
+ )
66
+
67
+
68
+ def _external_ids(paper: Mapping[str, Any]) -> Mapping[str, Any]:
69
+ external_ids = paper.get("externalIds") or paper.get("external_ids")
70
+ return external_ids if isinstance(external_ids, Mapping) else {}
71
+
72
+
73
+ def _paper_doi(paper: Mapping[str, Any], unpaywall_api: Any) -> str:
74
+ external_ids = _external_ids(paper)
75
+ value = (
76
+ external_ids.get("DOI")
77
+ or external_ids.get("doi")
78
+ or paper.get("doi")
79
+ or ""
80
+ )
81
+ normalize_doi = getattr(unpaywall_api, "normalize_doi", None)
82
+ return str(normalize_doi(value) if callable(normalize_doi) else value or "").strip()
83
+
84
+
85
+ def _is_truthy_oa_value(value: Any) -> bool:
86
+ if isinstance(value, bool):
87
+ return value
88
+ return str(value or "").strip().casefold() in {"1", "true", "yes", "open", "oa"}
89
+
90
+
91
+ def _explicit_oa_evidence(paper: Mapping[str, Any]) -> str:
92
+ """Return a non-secret OA declaration suitable for limited landing recovery.
93
+
94
+ A generic landing-page URL alone is deliberately insufficient. That
95
+ avoids treating a publisher's ordinary metadata URL as permission to crawl
96
+ its subscription route. The accepted forms are provider-neutral metadata
97
+ fields used by common scholarly APIs.
98
+ """
99
+ for key in ("is_oa", "isOpenAccess", "open_access", "openAccess"):
100
+ value = paper.get(key)
101
+ if _is_truthy_oa_value(value):
102
+ return f"metadata.{key}"
103
+ if isinstance(value, Mapping):
104
+ if _is_truthy_oa_value(value.get("is_oa")) or _is_truthy_oa_value(
105
+ value.get("isOpenAccess")
106
+ ):
107
+ return f"metadata.{key}.is_oa"
108
+ oa_status = str(value.get("oa_status") or value.get("status") or "").strip()
109
+ if oa_status.casefold() in {"gold", "green", "hybrid", "bronze", "open"}:
110
+ return f"metadata.{key}.oa_status"
111
+ return ""
112
+
113
+
114
+ def _generic_metadata_values(
115
+ paper: Mapping[str, Any],
116
+ ) -> Iterable[tuple[str, str, str, str]]:
117
+ """Yield standardized generic full-text fields from top-level metadata.
118
+
119
+ The pipeline deliberately does not scan arbitrary nested dictionaries for
120
+ strings resembling URLs. That would make an unrelated publisher URL look
121
+ like a declared full-text route. Only documented interchange field names
122
+ are accepted, first at the top level and then in ``fulltext``/``full_text``
123
+ containers used by external collectors.
124
+ """
125
+ containers: list[tuple[str, Mapping[str, Any]]] = [("", paper)]
126
+ for container_name in ("fulltext", "full_text"):
127
+ container = paper.get(container_name)
128
+ if isinstance(container, Mapping):
129
+ containers.append((f"{container_name}.", container))
130
+ for prefix, container in containers:
131
+ for key, source, route_type in _GENERIC_METADATA_URL_FIELDS:
132
+ value = str(container.get(key) or "").strip()
133
+ if value:
134
+ # The field name is separately persisted so provenance can
135
+ # explain how this route entered the candidate queue.
136
+ yield f"{prefix}{key}", source, route_type, value
137
+
138
+
139
+ def _add_candidate(
140
+ candidates_by_url: dict[str, dict[str, Any]],
141
+ candidate: Mapping[str, Any],
142
+ ) -> None:
143
+ normalized_url = normalize_http_url(candidate.get("url"))
144
+ if not normalized_url:
145
+ return
146
+ normalized = dict(candidate)
147
+ normalized["url"] = normalized_url
148
+ normalized["kind"] = "landing_page" if normalized.get("kind") == "landing_page" else "pdf"
149
+ # A DOI resolver URL is a metadata/redirect route, never the PDF bytes
150
+ # themselves. Treat a provider-mislabelled DOI URL as a bounded landing
151
+ # page so it is not requested with a PDF Range header.
152
+ parsed = urlsplit(normalized_url)
153
+ if normalized["kind"] == "pdf" and parsed.netloc.casefold() in {"doi.org", "dx.doi.org"}:
154
+ normalized["kind"] = "landing_page"
155
+ normalized["priority"] = int(normalized.get("priority") or 0)
156
+ normalized["source"] = str(normalized.get("source") or "unknown")
157
+ # `normalize_http_url()` lower-cases only scheme/host. Never lowercase the
158
+ # entire URL here: paths and signed query parameters may be case-sensitive.
159
+ key = normalized_url
160
+ existing = candidates_by_url.get(key)
161
+ if existing is None:
162
+ normalized["sources"] = [normalized["source"]]
163
+ candidates_by_url[key] = normalized
164
+ return
165
+
166
+ existing_sources = list(existing.get("sources") or [existing.get("source")])
167
+ if normalized["source"] not in existing_sources:
168
+ existing_sources.append(normalized["source"])
169
+ existing["sources"] = existing_sources
170
+ if (normalized["priority"], normalized["kind"] != "pdf") < (
171
+ int(existing.get("priority") or 0),
172
+ existing.get("kind") != "pdf",
173
+ ):
174
+ existing.update(
175
+ {
176
+ key: value
177
+ for key, value in normalized.items()
178
+ if value not in (None, "", [], {})
179
+ }
180
+ )
181
+ existing["sources"] = existing_sources
182
+
183
+
184
+ def resolve_fulltext_candidates(
185
+ paper: Mapping[str, Any],
186
+ *,
187
+ unpaywall_api: Any,
188
+ include_all_unpaywall_locations: bool = True,
189
+ include_generic_metadata_urls: bool = True,
190
+ include_doi_landing_fallback: bool = True,
191
+ ) -> dict[str, Any]:
192
+ """Build ordered, legal OA candidates without fetching PDF bytes.
193
+
194
+ Candidate sources are alternative acquisition routes. They are not a
195
+ conjunctive relevance rule and a failed candidate must never change the
196
+ paper's project/SH semantic assessment.
197
+ """
198
+ paper = paper if isinstance(paper, Mapping) else {}
199
+ doi = _paper_doi(paper, unpaywall_api)
200
+ candidates_by_url: dict[str, dict[str, Any]] = {}
201
+ disabled_candidates: list[dict[str, Any]] = []
202
+ unpaywall_status = "not_requested"
203
+ unpaywall_candidate_count = 0
204
+
205
+ if doi and unpaywall_api is not None:
206
+ try:
207
+ get_candidates = getattr(unpaywall_api, "get_oa_candidates", None)
208
+ if callable(get_candidates):
209
+ unpaywall_candidates = list(get_candidates(doi) or [])
210
+ else:
211
+ legacy_url = str(unpaywall_api.get_oa_pdf_url(doi) or "")
212
+ unpaywall_candidates = (
213
+ [
214
+ {
215
+ "url": legacy_url,
216
+ "kind": "pdf",
217
+ "source": "unpaywall.legacy_best_pdf",
218
+ "priority": OA_PRIORITY_UNPAYWALL_BEST,
219
+ }
220
+ ]
221
+ if legacy_url
222
+ else []
223
+ )
224
+ unpaywall_status = "resolved" if unpaywall_candidates else "no_open_access_location"
225
+ unpaywall_candidate_count = sum(
226
+ 1
227
+ for candidate in unpaywall_candidates
228
+ if str(candidate.get("source") or "").startswith("unpaywall.")
229
+ )
230
+ if not include_all_unpaywall_locations:
231
+ unpaywall_candidates = [
232
+ candidate
233
+ for candidate in unpaywall_candidates
234
+ if str(candidate.get("source") or "")
235
+ in {"unpaywall.best_oa_location", "unpaywall.legacy_best_pdf"}
236
+ ]
237
+ for candidate in unpaywall_candidates:
238
+ if isinstance(candidate, Mapping):
239
+ _add_candidate(candidates_by_url, candidate)
240
+ except Exception as exc: # provider failures should not block other OA sources
241
+ unpaywall_status = "lookup_failed"
242
+ unpaywall_error = type(exc).__name__
243
+ else:
244
+ unpaywall_error = ""
245
+ else:
246
+ unpaywall_error = ""
247
+
248
+ openalex_locations = paper.get("openalex_oa_locations")
249
+ if isinstance(openalex_locations, Iterable) and not isinstance(
250
+ openalex_locations, (str, bytes, Mapping)
251
+ ):
252
+ for location in openalex_locations:
253
+ if not isinstance(location, Mapping):
254
+ continue
255
+ source = str(location.get("source") or "openalex.oa_locations")
256
+ default_priority = (
257
+ OA_PRIORITY_OPENALEX_BEST
258
+ if source == "openalex.best_oa_location"
259
+ else OA_PRIORITY_OPENALEX_OTHER
260
+ )
261
+ try:
262
+ priority = int(location.get("priority", default_priority) or default_priority)
263
+ except (TypeError, ValueError):
264
+ priority = default_priority
265
+ common = {
266
+ "source": source,
267
+ "priority": priority,
268
+ "version": str(location.get("version") or ""),
269
+ "license": str(location.get("license") or ""),
270
+ "host_type": str(location.get("host_type") or ""),
271
+ "evidence": str(location.get("evidence") or "openalex.oa_location"),
272
+ "oa_evidence": "openalex.oa_location",
273
+ }
274
+ pdf_url = location.get("pdf_url") or location.get("url_for_pdf")
275
+ if pdf_url:
276
+ _add_candidate(
277
+ candidates_by_url,
278
+ {**common, "url": pdf_url, "kind": "pdf"},
279
+ )
280
+ landing_url = location.get("landing_page_url") or location.get("url")
281
+ if landing_url and str(landing_url).strip() != str(pdf_url or "").strip():
282
+ _add_candidate(
283
+ candidates_by_url,
284
+ {**common, "url": landing_url, "kind": "landing_page"},
285
+ )
286
+
287
+ external_ids = _external_ids(paper)
288
+ arxiv_id = str(external_ids.get("ArXiv") or external_ids.get("arXiv") or "").strip()
289
+ if arxiv_id:
290
+ _add_candidate(
291
+ candidates_by_url,
292
+ {
293
+ "url": f"https://arxiv.org/pdf/{arxiv_id}.pdf",
294
+ "kind": "pdf",
295
+ "source": "identifier.arxiv",
296
+ "priority": OA_PRIORITY_IDENTIFIER_REPOSITORY,
297
+ "version": "preprint",
298
+ },
299
+ )
300
+ _add_candidate(
301
+ candidates_by_url,
302
+ {
303
+ "url": f"https://export.arxiv.org/pdf/{arxiv_id}.pdf",
304
+ "kind": "pdf",
305
+ "source": "identifier.arxiv_mirror",
306
+ "priority": OA_PRIORITY_IDENTIFIER_REPOSITORY + 1,
307
+ "version": "preprint",
308
+ },
309
+ )
310
+
311
+ pmcid = str(
312
+ paper.get("pmcid")
313
+ or external_ids.get("PMCID")
314
+ or external_ids.get("pmcid")
315
+ or external_ids.get("PMC")
316
+ or ""
317
+ ).strip()
318
+ if pmcid:
319
+ canonical_pmcid = pmcid.upper()
320
+ if not canonical_pmcid.startswith("PMC"):
321
+ canonical_pmcid = f"PMC{canonical_pmcid}"
322
+ _add_candidate(
323
+ candidates_by_url,
324
+ {
325
+ "url": f"https://pmc.ncbi.nlm.nih.gov/articles/{canonical_pmcid}/pdf/",
326
+ "kind": "pdf",
327
+ "source": "identifier.pmc",
328
+ "priority": OA_PRIORITY_IDENTIFIER_REPOSITORY,
329
+ "version": "open_access_repository",
330
+ },
331
+ )
332
+
333
+ for key, source in (
334
+ ("repository_pdf", "metadata.repository"),
335
+ ("repository_url", "metadata.repository"),
336
+ ("accepted_manuscript_url", "metadata.accepted_manuscript"),
337
+ ("author_manuscript_url", "metadata.author_manuscript"),
338
+ ("open_repository_url", "metadata.open_repository"),
339
+ ):
340
+ value = paper.get(key)
341
+ url = normalize_http_url(value)
342
+ if not url:
343
+ continue
344
+ _add_candidate(
345
+ candidates_by_url,
346
+ {
347
+ "url": url,
348
+ "kind": "pdf" if urlsplit(url).path.lower().endswith(".pdf") else "landing_page",
349
+ "source": source,
350
+ "priority": OA_PRIORITY_METADATA_REPOSITORY,
351
+ },
352
+ )
353
+
354
+ generic_metadata_oa_evidence = _explicit_oa_evidence(paper)
355
+ if include_generic_metadata_urls:
356
+ for field_name, source, route_type, raw_url in _generic_metadata_values(paper):
357
+ url = normalize_http_url(raw_url)
358
+ if not url:
359
+ continue
360
+ path_looks_like_pdf = urlsplit(url).path.lower().endswith(".pdf")
361
+ is_direct_pdf = route_type == "pdf" or (
362
+ route_type == "auto" and path_looks_like_pdf
363
+ )
364
+ if is_direct_pdf:
365
+ _add_candidate(
366
+ candidates_by_url,
367
+ {
368
+ "url": url,
369
+ "kind": "pdf",
370
+ "source": source,
371
+ "priority": OA_PRIORITY_GENERIC_METADATA,
372
+ "metadata_field": field_name,
373
+ "oa_evidence": generic_metadata_oa_evidence or "declared_pdf_url",
374
+ },
375
+ )
376
+ continue
377
+ if generic_metadata_oa_evidence:
378
+ _add_candidate(
379
+ candidates_by_url,
380
+ {
381
+ "url": url,
382
+ "kind": "landing_page",
383
+ "source": source,
384
+ "priority": OA_PRIORITY_GENERIC_METADATA + 1,
385
+ "metadata_field": field_name,
386
+ "oa_evidence": generic_metadata_oa_evidence,
387
+ },
388
+ )
389
+ continue
390
+ # Preserve the reason why a declared URL was not fetched. It is
391
+ # valuable provenance, and avoids quietly turning generic
392
+ # publisher metadata into an unrestricted landing-page crawler.
393
+ disabled_candidates.append(
394
+ {
395
+ "url": url,
396
+ "kind": "landing_page",
397
+ "source": source,
398
+ "priority": OA_PRIORITY_GENERIC_METADATA + 1,
399
+ "metadata_field": field_name,
400
+ "status": "landing_recovery_requires_explicit_oa",
401
+ }
402
+ )
403
+
404
+ open_access_pdf = paper.get("openAccessPdf")
405
+ if isinstance(open_access_pdf, Mapping):
406
+ _add_candidate(
407
+ candidates_by_url,
408
+ {
409
+ "url": open_access_pdf.get("url"),
410
+ "kind": "pdf",
411
+ "source": "provider.open_access_pdf",
412
+ "priority": OA_PRIORITY_PROVIDER_URL,
413
+ },
414
+ )
415
+
416
+ # A DOI resolver is a bounded public metadata lookup, not a method for
417
+ # probing subscription routes. It is scheduled immediately after the
418
+ # Unpaywall routes, then before generic/provider metadata alternatives.
419
+ # It is activated only with a concrete OA declaration: either Unpaywall
420
+ # returned an OA location or incoming metadata explicitly said the work is
421
+ # OA.
422
+ doi_oa_evidence = (
423
+ "unpaywall.oa_location"
424
+ if unpaywall_status == "resolved"
425
+ else generic_metadata_oa_evidence
426
+ )
427
+ if doi and include_doi_landing_fallback and doi_oa_evidence:
428
+ _add_candidate(
429
+ candidates_by_url,
430
+ {
431
+ "url": f"https://doi.org/{quote(doi, safe='/')}",
432
+ "kind": "landing_page",
433
+ "source": "doi.landing_fallback",
434
+ "priority": OA_PRIORITY_DOI_LANDING,
435
+ "doi": doi,
436
+ "oa_evidence": doi_oa_evidence,
437
+ },
438
+ )
439
+ candidates = sorted(
440
+ candidates_by_url.values(),
441
+ key=lambda candidate: (
442
+ int(candidate.get("priority") or 0),
443
+ 0 if candidate.get("kind") == "pdf" else 1,
444
+ str(candidate.get("url") or ""),
445
+ ),
446
+ )
447
+ return {
448
+ "schema_version": "fulltext_resolution_v2",
449
+ "paper_id": str(paper.get("paperId") or paper.get("paper_id") or ""),
450
+ "doi": doi,
451
+ "candidates": candidates,
452
+ "deferred_candidates": [],
453
+ "disabled_candidates": disabled_candidates,
454
+ "unpaywall": {
455
+ "status": unpaywall_status,
456
+ "candidate_count": unpaywall_candidate_count,
457
+ **({"error": unpaywall_error} if unpaywall_error else {}),
458
+ },
459
+ }
460
+
461
+
462
+ class _DeclaredPdfLinkParser(HTMLParser):
463
+ """Extract bounded, article-shaped PDF links from an already approved OA page."""
464
+
465
+ def __init__(self) -> None:
466
+ super().__init__(convert_charrefs=True)
467
+ self.pdf_links: list[str] = []
468
+
469
+ def handle_starttag(self, tag: str, attrs: Iterable[tuple[str, str | None]]) -> None:
470
+ attributes = {str(key).lower(): str(value or "") for key, value in attrs}
471
+ tag = tag.lower()
472
+ if tag == "meta":
473
+ meta_name = (
474
+ attributes.get("name", "")
475
+ or attributes.get("property", "")
476
+ or attributes.get("itemprop", "")
477
+ ).lower()
478
+ if meta_name in {
479
+ "citation_pdf_url",
480
+ "eprints.document_url",
481
+ "bepress_citation_pdf_url",
482
+ }:
483
+ self.pdf_links.append(attributes.get("content", ""))
484
+ return
485
+ if tag == "link" and "application/pdf" in attributes.get("type", "").lower():
486
+ self.pdf_links.append(attributes.get("href", ""))
487
+ return
488
+ if tag == "a":
489
+ href = attributes.get("href", "")
490
+ parsed = urlsplit(href)
491
+ # Repository pages often expose a generic PDF anchor. A
492
+ # supplementary PDF, however, is not a substitute for the article.
493
+ # Keep the fallback conservative and never promote it to full text.
494
+ link_context = " ".join(
495
+ (
496
+ href,
497
+ attributes.get("title", ""),
498
+ attributes.get("aria-label", ""),
499
+ attributes.get("class", ""),
500
+ )
501
+ ).lower()
502
+ is_supplement = any(
503
+ marker in link_context
504
+ for marker in ("supplement", "supporting-information", "supporting_information")
505
+ )
506
+ is_pdf_path = parsed.path.lower().endswith(".pdf")
507
+ is_pdf_type = "application/pdf" in attributes.get("type", "").lower()
508
+ is_article_download = (
509
+ "download" in attributes
510
+ and any(marker in link_context for marker in ("pdf", "manuscript", "fulltext", "full-text"))
511
+ )
512
+ if not is_supplement and (is_pdf_path or is_pdf_type or is_article_download):
513
+ self.pdf_links.append(href)
514
+
515
+
516
+ def _landing_recovery_permitted(candidate: Mapping[str, Any]) -> bool:
517
+ """Allow recovery only from explicit, lawful OA/repository routes."""
518
+
519
+ if str(candidate.get("kind") or "") != "landing_page":
520
+ return False
521
+ source = str(candidate.get("source") or "").casefold()
522
+ # DOI resolution is deliberately more restrictive than a landing page
523
+ # already supplied by Unpaywall: the caller must have retained a concrete
524
+ # OA declaration in the candidate provenance.
525
+ if source == "doi.landing_fallback":
526
+ return bool(str(candidate.get("oa_evidence") or "").strip())
527
+ if source.startswith(("metadata.full_text", "metadata.fulltext", "metadata.landing_page", "metadata.url_for_landing_page")):
528
+ return bool(str(candidate.get("oa_evidence") or "").strip())
529
+ allowed_prefixes = (
530
+ "unpaywall.",
531
+ "identifier.arxiv",
532
+ "identifier.pmc",
533
+ "metadata.repository",
534
+ "metadata.accepted_manuscript",
535
+ "metadata.author_manuscript",
536
+ "metadata.open_repository",
537
+ # OpenAlex has explicitly marked this URL as an OA full-text route.
538
+ "provider.open_access_pdf",
539
+ "openalex.",
540
+ )
541
+ return source.startswith(allowed_prefixes)
542
+
543
+
544
+ def _bounded_landing_get(
545
+ url: str,
546
+ *,
547
+ headers: Mapping[str, str],
548
+ timeout_seconds: float,
549
+ max_redirects: int,
550
+ http_client: Any = None,
551
+ ) -> tuple[Any | None, dict[str, Any]]:
552
+ """GET a public landing page with an explicit, small redirect budget.
553
+
554
+ Requests' default redirect policy is intentionally not used here. A DOI
555
+ can redirect through several providers; constraining hops keeps a fallback
556
+ from becoming an unbounded route explorer and records the actual terminal
557
+ URL for provenance.
558
+ """
559
+ current_url = url
560
+ redirects = 0
561
+ while True:
562
+ try:
563
+ request_get = http_client.get if http_client is not None else requests.get
564
+ response = request_get(
565
+ current_url,
566
+ headers=dict(headers),
567
+ stream=True,
568
+ timeout=max(1.0, min(float(timeout_seconds), 30.0)),
569
+ allow_redirects=False,
570
+ )
571
+ except requests.Timeout as exc:
572
+ return None, {
573
+ "status": "timeout",
574
+ "http_status": None,
575
+ "final_url": current_url,
576
+ "error": str(exc)[:300],
577
+ "redirect_count": redirects,
578
+ }
579
+ except requests.RequestException as exc:
580
+ return None, {
581
+ "status": "network_error",
582
+ "http_status": None,
583
+ "final_url": current_url,
584
+ "error": str(exc)[:300],
585
+ "redirect_count": redirects,
586
+ }
587
+
588
+ status_code = int(getattr(response, "status_code", 0) or 0)
589
+ if status_code not in {301, 302, 303, 307, 308}:
590
+ return response, {
591
+ "status": "response",
592
+ "http_status": status_code,
593
+ "final_url": normalize_http_url(getattr(response, "url", "") or current_url),
594
+ "redirect_count": redirects,
595
+ }
596
+
597
+ location = str(getattr(response, "headers", {}).get("Location") or "").strip()
598
+ next_url = normalize_http_url(urljoin(current_url, location)) if location else ""
599
+ response.close()
600
+ if not next_url:
601
+ return None, {
602
+ "status": "redirect_without_location",
603
+ "http_status": status_code,
604
+ "final_url": current_url,
605
+ "redirect_count": redirects,
606
+ }
607
+ if redirects >= max(0, int(max_redirects)):
608
+ return None, {
609
+ "status": "redirect_limit_exceeded",
610
+ "http_status": status_code,
611
+ "final_url": current_url,
612
+ "redirect_count": redirects,
613
+ }
614
+ current_url = next_url
615
+ redirects += 1
616
+
617
+
618
+ def resolve_declared_pdf_links(
619
+ candidate: Mapping[str, Any],
620
+ *,
621
+ timeout_seconds: float = 12.0,
622
+ max_html_bytes: int = 1_500_000,
623
+ max_pdf_links: int = 8,
624
+ max_redirects: int = 3,
625
+ http_client: Any = None,
626
+ ) -> dict[str, Any]:
627
+ """Resolve one declared OA/repository landing page to bounded PDF links."""
628
+ if not _landing_recovery_permitted(candidate):
629
+ return {"status": "landing_recovery_not_permitted", "pdf_candidates": []}
630
+ url = normalize_http_url(candidate.get("url"))
631
+ if not url:
632
+ return {"status": "invalid_url", "pdf_candidates": []}
633
+ headers = {
634
+ "Accept": "text/html,application/xhtml+xml,application/pdf;q=0.8,*/*;q=0.2",
635
+ }
636
+ if http_client is None:
637
+ headers["User-Agent"] = "Xcientist/0.8 (academic OA full-text resolver)"
638
+ started_at = time.monotonic()
639
+ response, request_result = _bounded_landing_get(
640
+ url,
641
+ headers=headers,
642
+ timeout_seconds=timeout_seconds,
643
+ max_redirects=max_redirects,
644
+ http_client=http_client,
645
+ )
646
+ if response is None:
647
+ return {
648
+ **request_result,
649
+ "elapsed_seconds": round(time.monotonic() - started_at, 3),
650
+ "pdf_candidates": [],
651
+ }
652
+
653
+ final_url = str(request_result.get("final_url") or url)
654
+ status_code = int(request_result.get("http_status") or 0)
655
+ content_type = str(getattr(response, "headers", {}).get("Content-Type") or "").lower()
656
+ elapsed_seconds = round(time.monotonic() - started_at, 3)
657
+ if status_code == 404:
658
+ response.close()
659
+ return {
660
+ "status": "not_found",
661
+ "http_status": status_code,
662
+ "final_url": final_url,
663
+ "content_type": content_type,
664
+ "elapsed_seconds": elapsed_seconds,
665
+ "redirect_count": request_result.get("redirect_count", 0),
666
+ "pdf_candidates": [],
667
+ }
668
+ if status_code in {401, 403}:
669
+ response.close()
670
+ return {
671
+ "status": "access_denied",
672
+ "http_status": status_code,
673
+ "final_url": final_url,
674
+ "content_type": content_type,
675
+ "elapsed_seconds": elapsed_seconds,
676
+ "redirect_count": request_result.get("redirect_count", 0),
677
+ "pdf_candidates": [],
678
+ }
679
+ if status_code < 200 or status_code >= 300:
680
+ response.close()
681
+ return {
682
+ "status": "http_error",
683
+ "http_status": status_code,
684
+ "final_url": final_url,
685
+ "content_type": content_type,
686
+ "elapsed_seconds": elapsed_seconds,
687
+ "redirect_count": request_result.get("redirect_count", 0),
688
+ "pdf_candidates": [],
689
+ }
690
+
691
+ data = bytearray()
692
+ try:
693
+ for chunk in response.iter_content(chunk_size=64 * 1024):
694
+ if not chunk:
695
+ continue
696
+ data.extend(chunk)
697
+ if len(data) > max_html_bytes:
698
+ return {
699
+ "status": "landing_page_too_large",
700
+ "http_status": status_code,
701
+ "final_url": final_url,
702
+ "content_type": content_type,
703
+ "elapsed_seconds": elapsed_seconds,
704
+ "redirect_count": request_result.get("redirect_count", 0),
705
+ "pdf_candidates": [],
706
+ }
707
+ finally:
708
+ response.close()
709
+
710
+ raw = bytes(data)
711
+ if "application/pdf" in content_type or raw.lstrip().startswith(b"%PDF-"):
712
+ pdf_urls = [final_url]
713
+ status = "resolved_to_pdf"
714
+ else:
715
+ parser = _DeclaredPdfLinkParser()
716
+ parser.feed(raw.decode("utf-8", errors="replace"))
717
+ pdf_urls = []
718
+ seen: set[str] = set()
719
+ for href in parser.pdf_links:
720
+ resolved = normalize_http_url(urljoin(final_url, href))
721
+ if resolved and resolved not in seen:
722
+ seen.add(resolved)
723
+ pdf_urls.append(resolved)
724
+ if len(pdf_urls) >= max_pdf_links:
725
+ break
726
+ status = "pdf_links_found" if pdf_urls else "no_declared_pdf"
727
+
728
+ parent_source = str(candidate.get("source") or "landing_page")
729
+ priority = int(candidate.get("priority") or 0)
730
+ return {
731
+ "status": status,
732
+ "http_status": status_code,
733
+ "final_url": final_url,
734
+ "content_type": content_type,
735
+ "elapsed_seconds": elapsed_seconds,
736
+ "redirect_count": request_result.get("redirect_count", 0),
737
+ "pdf_candidates": [
738
+ {
739
+ "url": pdf_url,
740
+ "kind": "pdf",
741
+ "source": f"{parent_source}.declared_pdf",
742
+ "priority": priority,
743
+ "version": candidate.get("version", ""),
744
+ "license": candidate.get("license", ""),
745
+ "host_type": candidate.get("host_type", ""),
746
+ }
747
+ for pdf_url in pdf_urls
748
+ ],
749
+ }