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,2750 @@
1
+ from typing import List
2
+ import json
3
+ import os
4
+ import pickle
5
+ from pathlib import Path
6
+ from typing import Any, Dict, List
7
+ from collections.abc import Mapping, Sequence
8
+ import networkx as nx
9
+ from utils.api_call import ArxivAPI, ChatAgent, OpenAlexAPI, SemanticScholarAPI
10
+ from utils.utils import get_hash, extract_json
11
+ import diskcache as dc
12
+ import time
13
+ from modules.pe import (
14
+ PAPER_DEEP_READING,
15
+ PAPER_SECTION_READING,
16
+ PAPER_KEYNOTE_SYNTHESIS,
17
+ PAPER_KEYNOTE_COMPACTION,
18
+ PAPER_CLUSTERING,
19
+ PROPOSE_QUESTIONS_FOR_CLUSTER,
20
+ ANSWER_QUESTION_FOR_PAPERS,
21
+ INTER_CLUSTER_ANALYSIS,
22
+ PAPER_CLUSTERING_CREATING,
23
+ PAPER_CLUSTERING_ASSIGNING,
24
+ ERROR_FEEDBACK_PROMPT,
25
+ PAPER_RELATIONSHIP_ANALYSIS,
26
+ CLUSTER_TABLE_GENERATION
27
+ )
28
+ from modules.paper_graph_retriever import PaperGraphRetriever
29
+ from utils.mineru_section_packer import (
30
+ DEFAULT_EXCLUDED_SECTION_PATTERNS,
31
+ derive_effective_body_budget,
32
+ pack_mineru_markdown_by_complete_sections,
33
+ render_packet_outline,
34
+ )
35
+ from src.pipeline.evidence_coverage_ledger import (
36
+ EVIDENCE_COVERAGE_LEDGER_SCHEMA_VERSION,
37
+ )
38
+ from src.pipeline.sh_cluster_projection import (
39
+ SH_CLUSTER_COVERAGE_SCHEMA_VERSION,
40
+ build_cluster_sh_coverage_projection,
41
+ )
42
+ from src.pipeline.sh_graph_provenance import SH_GRAPH_PROVENANCE_SCHEMA_VERSION
43
+ from src.pipeline.paper_identity import canonical_paper_id
44
+ import hdbscan
45
+ from sentence_transformers import SentenceTransformer
46
+ from utils.rich_logger import get_logger
47
+ import math
48
+ import requests
49
+ import xml.etree.ElementTree as ET
50
+ import copy
51
+ import re
52
+
53
+
54
+ # A full-text packet can cost several minutes at the configured 512k limit.
55
+ # One malformed/empty response is a paper-level readability failure, not a
56
+ # reason to spend another complete long-context request on the same packet.
57
+ SECTION_PACKET_MAX_INVALID_RESPONSES = 1
58
+ PAPER_KEYNOTE_FAILURE_SCHEMA_VERSION = "paper_keynote_failure_v1"
59
+ # These are intentionally paper-specific exceptions. Their full-text payloads
60
+ # make deep-reading requests disproportionately expensive, so retain them
61
+ # through abstracts without submitting those requests.
62
+ FULLTEXT_READING_BYPASS_PAPER_IDS = frozenset({"W2074305322", "W4225610241"})
63
+ COMPLETE_SECTION_READING_SOURCES = frozenset(
64
+ {"complete_sections_single_packet", "complete_section_packet_synthesis"}
65
+ )
66
+
67
+
68
+ class WorkAnalyzer:
69
+ def __init__(self, config, work_collector, paper_graph_retriever = None):
70
+ self.config = config
71
+ self.chat_agent = ChatAgent(config)
72
+ self.semantic_scholar_api = SemanticScholarAPI(config)
73
+ self.arxiv_api = ArxivAPI(config)
74
+ self.logger = get_logger("WorkAnalyzer")
75
+ self.work_collector = work_collector
76
+ self.openalex_api = getattr(
77
+ getattr(work_collector, "data_manager", None), "openalex_api", None
78
+ )
79
+ if self.openalex_api is None:
80
+ self.openalex_api = OpenAlexAPI(config)
81
+ self.relation_analysis_graph = None
82
+ self.relation_analysis_table = None
83
+ self.sh_cluster_coverage_artifact = {}
84
+ # These structures intentionally live only for the lifetime of one
85
+ # WorkAnalyzer / survey run. They prevent a known-unrecoverable paper
86
+ # from repeatedly calling metadata APIs or long-context LLM requests.
87
+ self.paper_keynote_failure_records = {}
88
+ self._paper_keynote_negative_cache = {}
89
+ self._section_keynote_states = {}
90
+
91
+ self.cluster_fast_mode = self.config.ModuleInfo.WorkAnalyzer.cluster_assign_fast_mode
92
+ self.use_graph_keynotes = self.config.ModuleInfo.WorkAnalyzer.use_local_paper_graph_keynotes
93
+ self.use_ds_keynotes_when_graph_fail = self.config.ModuleInfo.WorkAnalyzer.use_ds_keynotes_when_graph_fail
94
+ if self.use_graph_keynotes:
95
+ if not paper_graph_retriever:
96
+ self.paper_graph_retriever = PaperGraphRetriever(config)
97
+ else:
98
+ self.paper_graph_retriever = paper_graph_retriever
99
+
100
+ self.max_problems_per_cluster_proposed = 5
101
+ self.max_words_num_in_inter_cluster_analysis = 800
102
+ self.max_words_num_in_intra_cluster_answer = 200
103
+
104
+ self.cache_path = self.config.BasicInfo.cache_path
105
+ os.makedirs(self.cache_path, exist_ok=True)
106
+ self.reference_graph = self._load_openalex_reference_graph()
107
+
108
+ # cache for paper keynotes
109
+ self.paper_keynote_cache = dc.Cache(
110
+ os.path.join(self.cache_path, "paper_keynotes")
111
+ )
112
+
113
+ # caches for clustering and relationship graph/table
114
+ self.cluster_cache = dc.Cache(
115
+ os.path.join(self.cache_path, "cluster_cache")
116
+ )
117
+ self.relation_graph_cache = dc.Cache(
118
+ os.path.join(self.cache_path, "relation_graph_cache")
119
+ )
120
+
121
+ self.paper_abstract_cache = self.work_collector.paper_abstract_cache
122
+
123
+ def _load_openalex_reference_graph(self):
124
+ reference_graph = getattr(self.work_collector, "reference_graph", None)
125
+ if reference_graph is None:
126
+ ref_graph_path = os.path.join(self.cache_path, "reference_graph.pkl")
127
+ if not os.path.exists(ref_graph_path):
128
+ return None
129
+ try:
130
+ with open(ref_graph_path, "rb") as reader:
131
+ reference_graph = pickle.load(reader)
132
+ except Exception as exc:
133
+ self.logger.warning(f"Unable to load reference graph cache: {exc}")
134
+ return None
135
+
136
+ expected_schema = int(
137
+ getattr(
138
+ self.config.APIInfo,
139
+ "openalex_graph_cache_schema_version",
140
+ 2,
141
+ )
142
+ or 2
143
+ )
144
+ graph_metadata = getattr(reference_graph, "graph", {})
145
+ if (
146
+ graph_metadata.get("provider") != "openalex"
147
+ or graph_metadata.get("schema_version") != expected_schema
148
+ ):
149
+ return None
150
+ return reference_graph
151
+
152
+ def read_papers_and_write_keynotes(
153
+ self,
154
+ papers: List[str],
155
+ retry: int = 1,
156
+ ds_keynotes_fallback: bool = False,
157
+ force_complete_section_paper_ids: Sequence[str] | None = None,
158
+ ):
159
+
160
+ if self.config.ModuleInfo.WorkAnalyzer.abstract_only_mode:
161
+ self.logger.info("Abstract-only mode enabled, fetching abstracts instead of deep reading and get keynotes.")
162
+ return self.work_collector.add_papers_abstracts_in_cache(papers, retry=retry)
163
+
164
+ # Keep alternate launchers on the same safe upgrade path as the deep
165
+ # survey launcher. Retries receive the resolved list explicitly, so
166
+ # this bounded selector is evaluated only once for a read operation.
167
+ if force_complete_section_paper_ids is None and retry == 1:
168
+ upgrade_plan = self.prepare_complete_section_upgrade_candidates(papers)
169
+ force_complete_section_paper_ids = list(
170
+ upgrade_plan.get("force_complete_section_paper_ids", [])
171
+ if isinstance(upgrade_plan, Mapping)
172
+ else []
173
+ )
174
+
175
+ if retry > self.config.ModuleInfo.WorkAnalyzer.paper_reading_max_retry:
176
+ self.logger.error("Exceeded maximum retries for reading papers.")
177
+ self.logger.error(f"Papers failed to read: {papers}")
178
+ return papers
179
+
180
+ requested_papers = list(dict.fromkeys(papers))
181
+ forced_complete_section_ids = {
182
+ canonical_paper_id(paper_id)
183
+ for paper_id in force_complete_section_paper_ids or []
184
+ if canonical_paper_id(paper_id)
185
+ }
186
+ bypassed_papers = [
187
+ paper_id
188
+ for paper_id in requested_papers
189
+ if canonical_paper_id(paper_id) in FULLTEXT_READING_BYPASS_PAPER_IDS
190
+ ]
191
+ for paper_id in bypassed_papers:
192
+ # Do this before the graph/full-text branches so this exact paper
193
+ # can never create a long-context deep-reading request.
194
+ self.logger.info(
195
+ "Skipping deep reading for explicit abstract-only exception: %s",
196
+ paper_id,
197
+ )
198
+ self._try_abstract_keynote_fallback(
199
+ pid=paper_id,
200
+ hash_id=get_hash(paper_id),
201
+ reason="explicit deep-reading bypass for oversized full text",
202
+ failure_code="explicit_fulltext_reading_bypass",
203
+ )
204
+
205
+ negative_cache = self._paper_keynote_negative_failures()
206
+ permanently_failed = [
207
+ paper_id for paper_id in requested_papers if paper_id in negative_cache
208
+ ]
209
+ papers = [
210
+ paper_id
211
+ for paper_id in requested_papers
212
+ if (
213
+ canonical_paper_id(paper_id) not in FULLTEXT_READING_BYPASS_PAPER_IDS
214
+ and paper_id not in negative_cache
215
+ )
216
+ ]
217
+ if not papers:
218
+ # A previous attempt in this run already established that neither
219
+ # full text nor the configured abstract fallback can serve these
220
+ # papers. Do not hit OpenAlex or the LLM again.
221
+ return permanently_failed
222
+
223
+ # 这里retry进来会有问题
224
+ if self.config.ModuleInfo.WorkAnalyzer.use_local_paper_graph_keynotes and not ds_keynotes_fallback:
225
+ # extract information for baselines and empty node and write back to graph
226
+ self.logger.info(f"getting keynotes in graph...")
227
+ error_ids = self.paper_graph_retriever.read_papers_and_write_keynotes(papers)
228
+ if self.use_ds_keynotes_when_graph_fail and error_ids:
229
+ self.logger.info(f"some paper not in graph, number: {len(error_ids)}, use previous methods")
230
+ error_ids = self.read_papers_and_write_keynotes(
231
+ papers = error_ids,
232
+ retry = retry,
233
+ ds_keynotes_fallback = True,
234
+ force_complete_section_paper_ids=force_complete_section_paper_ids,
235
+ )
236
+ return list(dict.fromkeys([*permanently_failed, *(error_ids or [])]))
237
+
238
+ tasks = []
239
+ section_packet_states = self._section_packet_states()
240
+ # Permanent failures are returned to the caller, but they must never
241
+ # be placed back into an active LLM retry wave merely because another
242
+ # paper in the same batch is transiently failing.
243
+ err_papers = []
244
+ work_config = self.config.ModuleInfo.WorkAnalyzer
245
+ section_packing_enabled = bool(
246
+ getattr(work_config, "fulltext_section_packing_enabled", True)
247
+ )
248
+ section_max_output_tokens = int(
249
+ getattr(work_config, "fulltext_section_max_output_tokens", 16000) or 16000
250
+ )
251
+ section_workers = int(
252
+ getattr(work_config, "fulltext_section_batch_worker", 1) or 1
253
+ )
254
+ section_max_in_flight_tokens = int(
255
+ getattr(
256
+ work_config,
257
+ "fulltext_section_max_in_flight_tokens",
258
+ 800_000,
259
+ )
260
+ or 800_000
261
+ )
262
+ json_response_format = self._json_object_response_format()
263
+ if section_packing_enabled:
264
+ configured_body_limit = int(
265
+ getattr(work_config, "fulltext_section_max_tokens", 512000) or 512000
266
+ )
267
+ prompt_reserve = int(
268
+ getattr(work_config, "fulltext_section_prompt_reserve_tokens", 24000)
269
+ or 24000
270
+ )
271
+ try:
272
+ section_body_budget = derive_effective_body_budget(
273
+ configured_max_body_tokens=configured_body_limit,
274
+ context_window_tokens=int(self.config.APIInfo.llm_max_context_length),
275
+ max_output_tokens=section_max_output_tokens,
276
+ prompt_reserve_tokens=prompt_reserve,
277
+ )
278
+ except ValueError as exc:
279
+ self.logger.error("Invalid full-text section budget: %s", exc)
280
+ return papers
281
+
282
+ for pid in papers:
283
+ try:
284
+ hash_id = get_hash(pid)
285
+ # A legacy keynote is still useful for ordinary RAG, but it
286
+ # cannot prove that the paper was read section-completely.
287
+ # The upgrade selector passes only current-budget
288
+ # citation-expanded candidates here, so bypass their stale
289
+ # cache entry once and overwrite it with an auditable
290
+ # complete-section keynote on success.
291
+ if (
292
+ hash_id in self.paper_keynote_cache
293
+ and canonical_paper_id(pid) not in forced_complete_section_ids
294
+ ):
295
+ continue
296
+
297
+ existing_state = section_packet_states.get(pid)
298
+ if existing_state is not None:
299
+ if existing_state.get("resolved"):
300
+ continue
301
+ if existing_state.get("terminal_failure"):
302
+ # The fallback is finalized after this request wave;
303
+ # never re-create or re-send a terminal packet.
304
+ continue
305
+ tasks.extend(
306
+ task
307
+ for packet_index, task in existing_state["packet_tasks"].items()
308
+ if packet_index not in existing_state["section_responses"]
309
+ )
310
+ continue
311
+ try:
312
+ paper_markdown_text = self.work_collector.get_paper_raw_markdown(pid)
313
+
314
+ except Exception as e:
315
+ self.logger.error(f"Failed to get content for paper ID: {pid}: {e} in PAPER COMPREHENDING. Skipping this paper or use abstract based on config.")
316
+ if self.config.ModuleInfo.WorkAnalyzer.abstract_when_full_text_fail:
317
+ if self._try_abstract_keynote_fallback(
318
+ pid=pid,
319
+ hash_id=hash_id,
320
+ reason=f"full-text retrieval failed: {e}",
321
+ failure_code="fulltext_retrieval_failed",
322
+ ):
323
+ continue
324
+ else:
325
+ self._record_paper_keynote_failure(
326
+ pid,
327
+ code="fulltext_retrieval_failed",
328
+ reason=f"full-text retrieval failed and abstract fallback is disabled: {e}",
329
+ permanent=True,
330
+ fallback_status="disabled",
331
+ )
332
+ err_papers.append(pid)
333
+ continue
334
+
335
+ if not section_packing_enabled:
336
+ max_ctx = self.config.APIInfo.llm_max_context_length
337
+ overhead = work_config.llm_max_context_overhead_length_in_paper_reading
338
+ allowed = max_ctx - overhead
339
+ paper_markdown_text = self.chat_agent.truncate_text(
340
+ pid, paper_markdown_text, allowed
341
+ )
342
+ tasks.append(
343
+ {
344
+ "kind": "paper",
345
+ "paper_id": pid,
346
+ "hash_id": hash_id,
347
+ "prompt": PAPER_DEEP_READING.format(
348
+ paper_markdown_text=paper_markdown_text
349
+ ),
350
+ }
351
+ )
352
+ continue
353
+
354
+ packing, paper_tasks, unsafe_section = self._build_safe_fulltext_tasks(
355
+ pid=pid,
356
+ hash_id=hash_id,
357
+ paper_markdown_text=paper_markdown_text,
358
+ body_budget=section_body_budget,
359
+ max_input_tokens=(
360
+ int(self.config.APIInfo.llm_max_context_length)
361
+ - section_max_output_tokens
362
+ ),
363
+ )
364
+ if not paper_tasks:
365
+ if self._fallback_from_unreadable_fulltext(
366
+ pid=pid,
367
+ hash_id=hash_id,
368
+ packing_status=(
369
+ packing.status
370
+ if packing.status in {"no_sections", "unsplittable_section"}
371
+ else "prompt_budget"
372
+ ),
373
+ section=unsafe_section or packing.unsplittable_section,
374
+ ):
375
+ continue
376
+ err_papers.append(pid)
377
+ continue
378
+
379
+ if packing.status == "single_packet":
380
+ tasks.extend(paper_tasks)
381
+ continue
382
+
383
+ section_packet_states[pid] = {
384
+ "hash_id": hash_id,
385
+ "packing": packing,
386
+ "packet_tasks": {
387
+ task["packet_index"]: task for task in paper_tasks
388
+ },
389
+ "section_responses": {},
390
+ "non_json_attempts": {},
391
+ "invalid_response_attempts": {},
392
+ "terminal_failure": None,
393
+ "resolved": False,
394
+ }
395
+ tasks.extend(paper_tasks)
396
+ except Exception as e:
397
+ self.logger.error(f"Error preparing paper {pid} for reading: {e} in PAPER COMPREHENDING")
398
+ err_papers.append(pid)
399
+
400
+ prompts = [task["prompt"] for task in tasks]
401
+ if not prompts and not err_papers:
402
+ return permanently_failed # remaining papers were already processed
403
+ elif not prompts:
404
+ self.logger.error("Fail to get raw markdown .No prompt generated. Exit")
405
+ return list(dict.fromkeys([*permanently_failed, *err_papers]))
406
+
407
+ try:
408
+ responses = self.chat_agent.batch_remote_chat(
409
+ prompts,
410
+ temperature=self.config.ModuleInfo.WorkAnalyzer.paper_reading_temperature,
411
+ desc="Reading papers",
412
+ workers=section_workers if section_packing_enabled else None,
413
+ strict_input_budget=section_packing_enabled,
414
+ max_output_tokens=section_max_output_tokens,
415
+ response_format=json_response_format,
416
+ max_in_flight_tokens=(
417
+ section_max_in_flight_tokens if section_packing_enabled else None
418
+ ),
419
+ )
420
+ if not responses:
421
+ self.logger.error("No responses received from LLM during paper reading.")
422
+ raise ValueError("No responses received from LLM during paper reading.")
423
+ except Exception as e:
424
+ self.logger.error(f'Error: {e} , during comprehending papers in getting response from LLM, retrying all...')
425
+ return self.read_papers_and_write_keynotes(
426
+ papers,
427
+ retry=retry + 1,
428
+ ds_keynotes_fallback=ds_keynotes_fallback,
429
+ force_complete_section_paper_ids=force_complete_section_paper_ids,
430
+ )
431
+
432
+ for task, response in zip(tasks, responses):
433
+ if task["kind"] == "section":
434
+ if not self._consume_section_packet_response(
435
+ task,
436
+ response,
437
+ section_packet_states,
438
+ ):
439
+ err_papers.append(task["paper_id"])
440
+ continue
441
+ try:
442
+ if not response:
443
+ self.logger.warning(
444
+ "Response is empty or None for paper %s. Marking as error.",
445
+ task["paper_id"],
446
+ )
447
+ err_papers.append(task["paper_id"])
448
+ continue
449
+ if len(response.strip()) < 10:
450
+ self.logger.warning(
451
+ "Extracted keynote too short for paper %s. Marking as error.",
452
+ task["paper_id"],
453
+ )
454
+ err_papers.append(task["paper_id"])
455
+ continue
456
+
457
+ self._cache_paper_keynote(
458
+ task["paper_id"],
459
+ task["hash_id"],
460
+ extract_json(response),
461
+ fulltext_reading_source=(
462
+ "complete_sections_single_packet"
463
+ if "packet_index" in task
464
+ else "legacy_or_unknown"
465
+ ),
466
+ )
467
+ except Exception as e:
468
+ self.logger.warning(
469
+ "Error processing LLM response for paper %s: %s. First 500 chars: %s",
470
+ task["paper_id"],
471
+ e,
472
+ response[:500] if response else "",
473
+ )
474
+ err_papers.append(task["paper_id"])
475
+
476
+ for pid in papers:
477
+ state = section_packet_states.get(pid)
478
+ if state is None or state.get("resolved"):
479
+ continue
480
+ packing = state["packing"]
481
+ section_responses = state["section_responses"]
482
+ terminal_failure = state.get("terminal_failure")
483
+ if terminal_failure is not None:
484
+ if self._fallback_after_terminal_section_failure(pid, state):
485
+ state["resolved"] = True
486
+ err_papers = [paper_id for paper_id in err_papers if paper_id != pid]
487
+ else:
488
+ err_papers.append(pid)
489
+ continue
490
+ if len(section_responses) != len(packing.packets):
491
+ self.logger.warning(
492
+ "Waiting to retry only missing complete-section notes for paper %s; "
493
+ "expected %s, got %s.",
494
+ pid,
495
+ len(packing.packets),
496
+ len(section_responses),
497
+ )
498
+ err_papers.append(pid)
499
+ continue
500
+ try:
501
+ keynote = self._synthesize_complete_section_notes(
502
+ paper_title=packing.paper_title or pid,
503
+ section_notes=[
504
+ section_responses[index]
505
+ for index in range(len(packing.packets))
506
+ ],
507
+ temperature=self.config.ModuleInfo.WorkAnalyzer.paper_reading_temperature,
508
+ max_output_tokens=section_max_output_tokens,
509
+ workers=section_workers,
510
+ max_in_flight_tokens=section_max_in_flight_tokens,
511
+ )
512
+ except Exception as exc:
513
+ failure_reason = f"complete-section keynote synthesis failed: {exc}"
514
+ self.logger.warning(
515
+ "Failed to synthesize complete-section notes for paper %s: %s",
516
+ pid,
517
+ exc,
518
+ )
519
+ self._record_paper_keynote_failure(
520
+ pid,
521
+ code="paper_keynote_synthesis_failed",
522
+ reason=failure_reason,
523
+ fallback_status="pending",
524
+ )
525
+ if self._try_abstract_keynote_fallback(
526
+ pid=pid,
527
+ hash_id=state["hash_id"],
528
+ reason=failure_reason,
529
+ failure_code="paper_keynote_synthesis_failed",
530
+ ):
531
+ state["resolved"] = True
532
+ err_papers = [paper_id for paper_id in err_papers if paper_id != pid]
533
+ else:
534
+ err_papers.append(pid)
535
+ continue
536
+ self._cache_paper_keynote(
537
+ pid,
538
+ state["hash_id"],
539
+ keynote,
540
+ fulltext_reading_source="complete_section_packet_synthesis",
541
+ )
542
+ state["resolved"] = True
543
+
544
+ if err_papers:
545
+ retry_papers = list(dict.fromkeys(err_papers))
546
+ negative_cache = self._paper_keynote_negative_failures()
547
+ terminal_papers = [
548
+ paper_id for paper_id in retry_papers if paper_id in negative_cache
549
+ ]
550
+ retryable_papers = [
551
+ paper_id for paper_id in retry_papers if paper_id not in negative_cache
552
+ ]
553
+ if not retryable_papers:
554
+ return list(
555
+ dict.fromkeys([*permanently_failed, *terminal_papers])
556
+ )
557
+ self.logger.info(
558
+ "Retrying %s readable paper(s) due to previous errors in "
559
+ "keynotes generation; excluding %s terminal failure(s).",
560
+ len(retryable_papers),
561
+ len(terminal_papers),
562
+ )
563
+ retried_failures = self.read_papers_and_write_keynotes(
564
+ retryable_papers,
565
+ retry=retry + 1,
566
+ ds_keynotes_fallback=ds_keynotes_fallback,
567
+ force_complete_section_paper_ids=force_complete_section_paper_ids,
568
+ )
569
+ return list(
570
+ dict.fromkeys(
571
+ [*permanently_failed, *terminal_papers, *(retried_failures or [])]
572
+ )
573
+ )
574
+
575
+ return permanently_failed
576
+
577
+ def prepare_complete_section_upgrade_candidates(
578
+ self,
579
+ selected_fulltext_expanded_paper_ids: Sequence[str],
580
+ ) -> dict[str, Any]:
581
+ """Choose legacy keynotes that must be reread before SH promotion.
582
+
583
+ The collector first constrains the scope to the current run's global
584
+ full-text selection and the SH-level promotion budget. This method
585
+ then applies the local cache and parsed-Markdown predicates. It never
586
+ downloads, parses, or expands new papers.
587
+ """
588
+
589
+ collector = getattr(self, "work_collector", None)
590
+ select_candidates = getattr(
591
+ collector, "select_complete_section_upgrade_candidates", None
592
+ )
593
+ if not callable(select_candidates):
594
+ return {
595
+ "enabled": False,
596
+ "skipped_reason": "collector_has_no_complete_section_upgrade_selector",
597
+ "force_complete_section_paper_ids": [],
598
+ }
599
+ if bool(
600
+ getattr(
601
+ getattr(getattr(self, "config", None), "ModuleInfo", None),
602
+ "WorkAnalyzer",
603
+ None,
604
+ )
605
+ and getattr(self.config.ModuleInfo.WorkAnalyzer, "abstract_only_mode", False)
606
+ ):
607
+ return {
608
+ "enabled": False,
609
+ "skipped_reason": "abstract_only_mode",
610
+ "force_complete_section_paper_ids": [],
611
+ }
612
+ if not bool(
613
+ getattr(self.config.ModuleInfo.WorkAnalyzer, "fulltext_section_packing_enabled", True)
614
+ ):
615
+ return {
616
+ "enabled": False,
617
+ "skipped_reason": "complete_section_packing_disabled",
618
+ "force_complete_section_paper_ids": [],
619
+ }
620
+
621
+ selection = select_candidates(selected_fulltext_expanded_paper_ids)
622
+ selection = dict(selection) if isinstance(selection, Mapping) else {}
623
+ candidate_pairs = selection.get("candidate_pairs") or []
624
+ forced_ids: list[str] = []
625
+ skipped = {
626
+ "already_complete_section_read": 0,
627
+ "missing_parsed_markdown": 0,
628
+ "explicit_fulltext_bypass": 0,
629
+ "terminal_read_failure": 0,
630
+ }
631
+ negative_by_identity = {
632
+ canonical_paper_id(paper_id)
633
+ for paper_id in self._paper_keynote_negative_failures()
634
+ if canonical_paper_id(paper_id)
635
+ }
636
+ data_manager = getattr(collector, "data_manager", None)
637
+ has_parsed_markdown = getattr(data_manager, "_has_parsed_markdown", None)
638
+
639
+ for raw_pair in candidate_pairs:
640
+ pair = dict(raw_pair) if isinstance(raw_pair, Mapping) else {}
641
+ paper_id = str(pair.get("paper_id") or "").strip()
642
+ identifier = canonical_paper_id(pair.get("canonical_paper_id") or paper_id)
643
+ if not paper_id or not identifier:
644
+ continue
645
+ if identifier in FULLTEXT_READING_BYPASS_PAPER_IDS:
646
+ skipped["explicit_fulltext_bypass"] += 1
647
+ continue
648
+ if identifier in negative_by_identity:
649
+ skipped["terminal_read_failure"] += 1
650
+ continue
651
+ try:
652
+ cached = self.paper_keynote_cache.get(get_hash(paper_id))
653
+ except (AttributeError, OSError, ValueError):
654
+ cached = None
655
+ cached = dict(cached) if isinstance(cached, Mapping) else {}
656
+ if str(cached.get("fulltext_reading_source") or "") in COMPLETE_SECTION_READING_SOURCES:
657
+ skipped["already_complete_section_read"] += 1
658
+ continue
659
+
660
+ parsed_available = False
661
+ if callable(has_parsed_markdown):
662
+ try:
663
+ parsed_available = bool(has_parsed_markdown(paper_id))
664
+ except (AttributeError, OSError, TypeError, ValueError):
665
+ parsed_available = False
666
+ if not parsed_available:
667
+ skipped["missing_parsed_markdown"] += 1
668
+ continue
669
+ if paper_id not in forced_ids:
670
+ forced_ids.append(paper_id)
671
+
672
+ selection["force_complete_section_paper_ids"] = forced_ids
673
+ selection["upgrade_input_skipped"] = skipped
674
+ self.logger.info(
675
+ "Selected %s current-budget citation-expanded paper(s) for complete-section "
676
+ "keynote upgrade across %s candidate/SH pair(s); already-complete=%s, "
677
+ "missing-markdown=%s, bypassed=%s, terminal=%s.",
678
+ len(forced_ids),
679
+ len(candidate_pairs),
680
+ skipped["already_complete_section_read"],
681
+ skipped["missing_parsed_markdown"],
682
+ skipped["explicit_fulltext_bypass"],
683
+ skipped["terminal_read_failure"],
684
+ )
685
+ return selection
686
+
687
+ def promote_complete_section_read_graph_candidates(
688
+ self,
689
+ papers: Sequence[str],
690
+ ) -> dict[str, Any]:
691
+ """Pass only proven complete-section keynotes to the SH promotion gate.
692
+
693
+ Abstract fallbacks, legacy/truncated reading cache entries, and failed
694
+ papers remain available to the existing non-evidentiary paths but are
695
+ intentionally absent here. The collector then performs an independent
696
+ SH-slot assessment before allowing any citation-expanded paper into the
697
+ writing evidence plan.
698
+ """
699
+
700
+ collector = getattr(self, "work_collector", None)
701
+ promote = getattr(
702
+ collector, "promote_complete_section_read_graph_candidates", None
703
+ )
704
+ if not callable(promote):
705
+ return {
706
+ "enabled": False,
707
+ "skipped_reason": "collector_has_no_fulltext_promotion_channel",
708
+ }
709
+ fallback_by_identity = {
710
+ canonical_paper_id(paper_id): dict(record)
711
+ for paper_id, record in self._paper_keynote_failure_records().items()
712
+ if isinstance(record, Mapping) and canonical_paper_id(paper_id)
713
+ }
714
+ complete_keynotes: dict[str, Any] = {}
715
+ skipped = {
716
+ "abstract_fallback": 0,
717
+ "not_complete_section_read": 0,
718
+ "missing_keynote": 0,
719
+ }
720
+ for raw_paper_id in dict.fromkeys(str(paper_id) for paper_id in papers):
721
+ identifier = canonical_paper_id(raw_paper_id)
722
+ if not identifier:
723
+ continue
724
+ failure = fallback_by_identity.get(identifier, {})
725
+ if str(failure.get("fallback_status") or "") == "abstract_used":
726
+ skipped["abstract_fallback"] += 1
727
+ continue
728
+ try:
729
+ cached = self.paper_keynote_cache.get(get_hash(raw_paper_id))
730
+ except (AttributeError, OSError, ValueError):
731
+ cached = None
732
+ cached = dict(cached) if isinstance(cached, Mapping) else {}
733
+ keynote = cached.get("keynote")
734
+ if keynote in (None, "", [], {}):
735
+ skipped["missing_keynote"] += 1
736
+ continue
737
+ if str(cached.get("fulltext_reading_source") or "") not in COMPLETE_SECTION_READING_SOURCES:
738
+ skipped["not_complete_section_read"] += 1
739
+ continue
740
+ complete_keynotes.setdefault(identifier, keynote)
741
+
742
+ result = promote(complete_keynotes)
743
+ result = dict(result) if isinstance(result, Mapping) else {"result": result}
744
+ result["input_complete_section_keynotes"] = len(complete_keynotes)
745
+ result["input_skipped"] = skipped
746
+ self.logger.info(
747
+ "Prepared %s complete-section keynotes for citation-expanded-paper "
748
+ "promotion; skipped abstract=%s, unproven-reading=%s, missing=%s.",
749
+ len(complete_keynotes),
750
+ skipped["abstract_fallback"],
751
+ skipped["not_complete_section_read"],
752
+ skipped["missing_keynote"],
753
+ )
754
+ return result
755
+
756
+ def _paper_keynote_negative_failures(self) -> dict[str, dict[str, Any]]:
757
+ cache = getattr(self, "_paper_keynote_negative_cache", None)
758
+ if not isinstance(cache, dict):
759
+ cache = {}
760
+ self._paper_keynote_negative_cache = cache
761
+ return cache
762
+
763
+ def _section_packet_states(self) -> dict[str, dict[str, Any]]:
764
+ states = getattr(self, "_section_keynote_states", None)
765
+ if not isinstance(states, dict):
766
+ states = {}
767
+ self._section_keynote_states = states
768
+ return states
769
+
770
+ def _paper_keynote_failure_records(self) -> dict[str, dict[str, Any]]:
771
+ records = getattr(self, "paper_keynote_failure_records", None)
772
+ if not isinstance(records, dict):
773
+ records = {}
774
+ self.paper_keynote_failure_records = records
775
+ return records
776
+
777
+ def _json_object_response_format(self) -> str | None:
778
+ """Request JSON mode only when the selected provider declares support."""
779
+ supports_format = getattr(self.chat_agent, "supports_response_format", None)
780
+ if not callable(supports_format):
781
+ return None
782
+ try:
783
+ return "json_object" if supports_format("json_object") else None
784
+ except Exception as exc:
785
+ self.logger.warning(
786
+ "Unable to determine JSON-object support for keynote reading: %s",
787
+ exc,
788
+ )
789
+ return None
790
+
791
+ def _record_paper_keynote_failure(
792
+ self,
793
+ pid: str,
794
+ *,
795
+ code: str,
796
+ reason: str,
797
+ permanent: bool = False,
798
+ fallback_status: str = "not_attempted",
799
+ **details: Any,
800
+ ) -> dict[str, Any]:
801
+ """Record a structured, in-run keynote failure without changing evidence roles."""
802
+ paper_id = str(pid or "").strip()
803
+ records = self._paper_keynote_failure_records()
804
+ record = dict(records.get(paper_id) or {})
805
+ record.update(
806
+ {
807
+ "schema_version": PAPER_KEYNOTE_FAILURE_SCHEMA_VERSION,
808
+ "paper_id": paper_id,
809
+ "status": "permanent_failure" if permanent else "fallback_used",
810
+ "code": str(code or "unknown_keynote_failure"),
811
+ "reason": str(reason or ""),
812
+ "fallback_status": str(fallback_status or "not_attempted"),
813
+ }
814
+ )
815
+ record.update(details)
816
+ records[paper_id] = record
817
+ if permanent:
818
+ self._paper_keynote_negative_failures()[paper_id] = dict(record)
819
+
820
+ basic_info = getattr(getattr(self, "config", None), "BasicInfo", None)
821
+ if basic_info is not None:
822
+ try:
823
+ basic_info.paper_keynote_failures = dict(records)
824
+ except Exception:
825
+ pass
826
+ return record
827
+
828
+ @staticmethod
829
+ def _validate_section_note_payload(payload: Any) -> dict[str, Any]:
830
+ """Validate and normalize the fixed JSON contract for one section packet."""
831
+ if not isinstance(payload, Mapping):
832
+ raise ValueError("Section note must be a JSON object.")
833
+ required_keys = {
834
+ "source_sections",
835
+ "claims",
836
+ "methods",
837
+ "results",
838
+ "limitations",
839
+ "unknowns",
840
+ }
841
+ missing_keys = sorted(required_keys - set(payload))
842
+ if missing_keys:
843
+ raise ValueError(
844
+ "Section note is missing required fields: " + ", ".join(missing_keys)
845
+ )
846
+
847
+ def text_list(name: str) -> list[str]:
848
+ values = payload.get(name)
849
+ if not isinstance(values, list):
850
+ raise ValueError(f"Section note '{name}' must be a JSON list.")
851
+ normalized = []
852
+ for value in values:
853
+ if not isinstance(value, str):
854
+ raise ValueError(f"Section note '{name}' values must be strings.")
855
+ text = value.strip()
856
+ if text:
857
+ normalized.append(text)
858
+ return normalized
859
+
860
+ source_sections = text_list("source_sections")
861
+ if not source_sections:
862
+ raise ValueError("Section note must name at least one source section.")
863
+ claims = payload.get("claims")
864
+ if not isinstance(claims, list):
865
+ raise ValueError("Section note 'claims' must be a JSON list.")
866
+ normalized_claims = []
867
+ for index, claim in enumerate(claims):
868
+ if not isinstance(claim, Mapping):
869
+ raise ValueError(f"Section note claim {index} must be a JSON object.")
870
+ normalized_claim = {}
871
+ for key in ("claim", "evidence", "source_section"):
872
+ value = claim.get(key)
873
+ if not isinstance(value, str) or not value.strip():
874
+ raise ValueError(
875
+ f"Section note claim {index} requires a non-empty '{key}' string."
876
+ )
877
+ normalized_claim[key] = value.strip()
878
+ normalized_claims.append(normalized_claim)
879
+
880
+ return {
881
+ "source_sections": source_sections,
882
+ "claims": normalized_claims,
883
+ "methods": text_list("methods"),
884
+ "results": text_list("results"),
885
+ "limitations": text_list("limitations"),
886
+ "unknowns": text_list("unknowns"),
887
+ }
888
+
889
+ @staticmethod
890
+ def _extract_section_note_json_object(response: str) -> dict[str, Any]:
891
+ """Parse only a top-level JSON object for the fixed section schema.
892
+
893
+ The generic extractor accepts a JSON list and, after a truncated object,
894
+ may accidentally recover a nested ``source_sections`` or ``claims``
895
+ list. That made a visibly object-shaped response look like a list in
896
+ validation logs. A section note has an object-only schema, so never
897
+ treat a nested list as a viable recovery candidate.
898
+ """
899
+
900
+ text = re.sub(r"```[\w]*", "", str(response or "")).replace("```", "").strip()
901
+ if not text:
902
+ raise ValueError("Empty section-note response.")
903
+ decoder = json.JSONDecoder()
904
+ for start, character in enumerate(text):
905
+ if character != "{":
906
+ continue
907
+ candidate = text[start:]
908
+ try:
909
+ payload, _end = decoder.raw_decode(candidate)
910
+ except json.JSONDecodeError:
911
+ continue
912
+ if isinstance(payload, dict):
913
+ return payload
914
+ raise ValueError("No complete JSON object found for section note.")
915
+
916
+ def _consume_section_packet_response(
917
+ self,
918
+ task: Mapping[str, Any],
919
+ response: Any,
920
+ section_packet_states: Mapping[str, dict[str, Any]],
921
+ ) -> bool:
922
+ """Store one valid note or immediately terminally fail the paper packet."""
923
+ paper_id = str(task["paper_id"])
924
+ packet_index = int(task["packet_index"])
925
+ state = section_packet_states[paper_id]
926
+ try:
927
+ if not isinstance(response, str) or len(response.strip()) < 10:
928
+ raise ValueError("Empty or too-short section-note response.")
929
+ parsed = self._extract_section_note_json_object(response)
930
+ state["section_responses"][packet_index] = self._validate_section_note_payload(
931
+ parsed
932
+ )
933
+ return True
934
+ except Exception as exc:
935
+ message = str(exc)
936
+ is_non_json = (
937
+ "No JSON found" in message
938
+ or "No complete JSON object found" in message
939
+ )
940
+ attempt_bucket = (
941
+ state["non_json_attempts"]
942
+ if is_non_json
943
+ else state["invalid_response_attempts"]
944
+ )
945
+ attempts = int(attempt_bucket.get(packet_index, 0)) + 1
946
+ attempt_bucket[packet_index] = attempts
947
+ packet = state["packing"].packets[packet_index]
948
+ failure_code = (
949
+ "section_response_not_json"
950
+ if is_non_json
951
+ else "section_response_invalid_schema"
952
+ )
953
+ reason = (
954
+ f"section packet {packet_index + 1}/{len(state['packing'].packets)} "
955
+ f"({', '.join(packet.headings)}) failed validation: {message}"
956
+ )
957
+ state["terminal_failure"] = {
958
+ "code": failure_code,
959
+ "reason": reason,
960
+ "packet_index": packet_index,
961
+ "packet_headings": list(packet.headings),
962
+ "attempts": attempts,
963
+ }
964
+ self._record_paper_keynote_failure(
965
+ paper_id,
966
+ code=failure_code,
967
+ reason=reason,
968
+ fallback_status="pending",
969
+ section_packet_index=packet_index,
970
+ section_headings=list(packet.headings),
971
+ invalid_response_attempts=attempts,
972
+ )
973
+ self.logger.warning(
974
+ "Stopping unreadable section packet %s for paper %s after its first "
975
+ "invalid response; no full-text retry will be sent. Using the "
976
+ "configured paper fallback instead. Reason: %s. First 500 chars: %s",
977
+ packet_index + 1,
978
+ paper_id,
979
+ message,
980
+ response[:500] if isinstance(response, str) else "",
981
+ )
982
+ return False
983
+
984
+ def _try_abstract_keynote_fallback(
985
+ self,
986
+ *,
987
+ pid: str,
988
+ hash_id: str,
989
+ reason: str,
990
+ failure_code: str,
991
+ ) -> bool:
992
+ """Try one abstract fallback and negative-cache a confirmed unusable paper."""
993
+ try:
994
+ errors = self.work_collector.add_papers_abstracts_in_cache([pid])
995
+ except Exception as exc:
996
+ self._record_paper_keynote_failure(
997
+ pid,
998
+ code=failure_code,
999
+ reason=f"{reason}; abstract fallback request failed: {exc}",
1000
+ permanent=True,
1001
+ fallback_status="abstract_request_failed",
1002
+ )
1003
+ self.logger.warning("Abstract fallback failed for paper %s: %s", pid, exc)
1004
+ return False
1005
+ if hash_id in self.paper_abstract_cache and not errors:
1006
+ self._record_paper_keynote_failure(
1007
+ pid,
1008
+ code=failure_code,
1009
+ reason=reason,
1010
+ fallback_status="abstract_used",
1011
+ )
1012
+ self.logger.info("Using abstract fallback for paper %s because %s.", pid, reason)
1013
+ return True
1014
+ self._record_paper_keynote_failure(
1015
+ pid,
1016
+ code=failure_code,
1017
+ reason=reason,
1018
+ permanent=True,
1019
+ fallback_status="abstract_unavailable",
1020
+ )
1021
+ self.logger.warning("Abstract fallback produced no usable keynote for paper %s.", pid)
1022
+ return False
1023
+
1024
+ def _fallback_after_terminal_section_failure(
1025
+ self,
1026
+ pid: str,
1027
+ state: Mapping[str, Any],
1028
+ ) -> bool:
1029
+ failure = dict(state.get("terminal_failure") or {})
1030
+ return self._try_abstract_keynote_fallback(
1031
+ pid=pid,
1032
+ hash_id=str(state["hash_id"]),
1033
+ reason=str(failure.get("reason") or "section packet validation failed"),
1034
+ failure_code=str(failure.get("code") or "section_response_invalid"),
1035
+ )
1036
+
1037
+ def _build_safe_fulltext_tasks(
1038
+ self,
1039
+ *,
1040
+ pid,
1041
+ hash_id,
1042
+ paper_markdown_text,
1043
+ body_budget: int,
1044
+ max_input_tokens: int,
1045
+ ):
1046
+ """Create only complete-section prompts that fit their final envelopes.
1047
+
1048
+ ``fulltext_section_prompt_reserve_tokens`` keeps ordinary requests well
1049
+ below the context limit, but the final prompt also contains headings and
1050
+ instructions. This method measures that exact final prompt. If a
1051
+ multi-section packet overflows, it lowers the raw-body budget and packs
1052
+ again, thereby moving only whole ``##`` sections to the next packet.
1053
+ It never fixes an overflow by slicing raw Markdown.
1054
+ """
1055
+ dynamic_body_budget = int(body_budget)
1056
+ if max_input_tokens <= 0:
1057
+ raise ValueError("The full-text request has no input-token budget.")
1058
+
1059
+ for _ in range(128):
1060
+ packing = pack_mineru_markdown_by_complete_sections(
1061
+ paper_markdown_text,
1062
+ max_body_tokens=dynamic_body_budget,
1063
+ count_tokens=self.chat_agent.estimate_tokens,
1064
+ excluded_heading_patterns=DEFAULT_EXCLUDED_SECTION_PATTERNS,
1065
+ )
1066
+ if packing.status in {"no_sections", "unsplittable_section"}:
1067
+ return packing, [], packing.unsplittable_section
1068
+
1069
+ tasks = []
1070
+ if packing.status == "single_packet":
1071
+ packet = packing.packets[0]
1072
+ tasks.append(
1073
+ {
1074
+ "kind": "paper",
1075
+ "paper_id": pid,
1076
+ "hash_id": hash_id,
1077
+ "packet_index": packet.index,
1078
+ "prompt": PAPER_DEEP_READING.format(
1079
+ paper_markdown_text=packet.markdown
1080
+ ),
1081
+ }
1082
+ )
1083
+ else:
1084
+ for packet in packing.packets:
1085
+ included, omitted = render_packet_outline(packing, packet)
1086
+ tasks.append(
1087
+ {
1088
+ "kind": "section",
1089
+ "paper_id": pid,
1090
+ "hash_id": hash_id,
1091
+ "packet_index": packet.index,
1092
+ "prompt": PAPER_SECTION_READING.format(
1093
+ paper_title=packing.paper_title or pid,
1094
+ packet_index=packet.index + 1,
1095
+ packet_count=len(packing.packets),
1096
+ included_headings=included,
1097
+ omitted_headings=omitted,
1098
+ packet_markdown=packet.markdown,
1099
+ ),
1100
+ }
1101
+ )
1102
+
1103
+ overflowing = []
1104
+ for task in tasks:
1105
+ prompt_token_count = int(self.chat_agent.estimate_tokens(task["prompt"]))
1106
+ if prompt_token_count > max_input_tokens:
1107
+ overflowing.append((task, prompt_token_count))
1108
+ if not overflowing:
1109
+ return packing, tasks, None
1110
+
1111
+ task, prompt_token_count = overflowing[0]
1112
+ packet = packing.packets[task["packet_index"]]
1113
+ if len(packet.section_indices) <= 1:
1114
+ section = next(
1115
+ (
1116
+ item
1117
+ for item in packing.sections
1118
+ if item.index == packet.section_indices[0]
1119
+ ),
1120
+ None,
1121
+ )
1122
+ self.logger.warning(
1123
+ "Complete section for paper %s cannot fit its final prompt: "
1124
+ "%s > %s input tokens.",
1125
+ pid,
1126
+ prompt_token_count,
1127
+ max_input_tokens,
1128
+ )
1129
+ return packing, [], section
1130
+
1131
+ overflow_tokens = prompt_token_count - max_input_tokens
1132
+ next_body_budget = min(
1133
+ dynamic_body_budget - 1,
1134
+ packet.body_token_count - max(1, overflow_tokens),
1135
+ )
1136
+ if next_body_budget <= 0 or next_body_budget >= dynamic_body_budget:
1137
+ self.logger.warning(
1138
+ "Unable to repack complete sections for paper %s within the "
1139
+ "final prompt budget.",
1140
+ pid,
1141
+ )
1142
+ return packing, [], None
1143
+ dynamic_body_budget = next_body_budget
1144
+
1145
+ self.logger.warning(
1146
+ "Stopped complete-section repacking for paper %s after 128 attempts.",
1147
+ pid,
1148
+ )
1149
+ return packing, [], None
1150
+
1151
+ def _synthesize_complete_section_notes(
1152
+ self,
1153
+ *,
1154
+ paper_title: str,
1155
+ section_notes: list[Any],
1156
+ temperature: float,
1157
+ max_output_tokens: int,
1158
+ workers: int,
1159
+ max_in_flight_tokens: int | None = None,
1160
+ ):
1161
+ """Synthesize notes hierarchically, enforcing the final prompt limit.
1162
+
1163
+ The raw paper text was already read as whole sections. At this stage
1164
+ the values are structured LLM notes, so intermediate summaries can be
1165
+ recursively merged without ever cutting a source chapter. Each level
1166
+ is exactly token-counted before it is sent.
1167
+ """
1168
+ max_input_tokens = (
1169
+ int(self.config.APIInfo.llm_max_context_length) - int(max_output_tokens)
1170
+ )
1171
+ if max_in_flight_tokens is None:
1172
+ work_config = getattr(
1173
+ getattr(self.config, "ModuleInfo", None), "WorkAnalyzer", None
1174
+ )
1175
+ max_in_flight_tokens = int(
1176
+ getattr(
1177
+ work_config,
1178
+ "fulltext_section_max_in_flight_tokens",
1179
+ 800_000,
1180
+ )
1181
+ or 800_000
1182
+ )
1183
+ if max_input_tokens <= 0:
1184
+ raise ValueError("The keynote synthesis request has no input-token budget.")
1185
+
1186
+ current_notes = list(section_notes)
1187
+ if not current_notes:
1188
+ raise ValueError("No complete-section notes are available to synthesize.")
1189
+ json_response_format = self._json_object_response_format()
1190
+
1191
+ for level in range(1, 9):
1192
+ final_prompt = self._format_keynote_synthesis_prompt(
1193
+ paper_title, current_notes
1194
+ )
1195
+ if int(self.chat_agent.estimate_tokens(final_prompt)) <= max_input_tokens:
1196
+ responses = self.chat_agent.batch_remote_chat(
1197
+ [final_prompt],
1198
+ temperature=temperature,
1199
+ desc="Synthesizing whole-paper keynote",
1200
+ workers=workers,
1201
+ strict_input_budget=True,
1202
+ max_output_tokens=max_output_tokens,
1203
+ response_format=json_response_format,
1204
+ max_in_flight_tokens=max_in_flight_tokens,
1205
+ )
1206
+ if len(responses) != 1 or not responses[0] or len(responses[0].strip()) < 10:
1207
+ raise ValueError("empty final whole-paper synthesis response")
1208
+ return extract_json(responses[0])
1209
+
1210
+ note_groups = self._partition_notes_for_compaction(
1211
+ paper_title=paper_title,
1212
+ notes=current_notes,
1213
+ max_input_tokens=max_input_tokens,
1214
+ )
1215
+ prompts = [
1216
+ self._format_keynote_compaction_prompt(
1217
+ paper_title=paper_title,
1218
+ notes=note_group,
1219
+ )
1220
+ for note_group in note_groups
1221
+ ]
1222
+ responses = self.chat_agent.batch_remote_chat(
1223
+ prompts,
1224
+ temperature=temperature,
1225
+ desc=f"Compacting complete-section notes (level {level})",
1226
+ workers=workers,
1227
+ strict_input_budget=True,
1228
+ max_output_tokens=max_output_tokens,
1229
+ response_format=json_response_format,
1230
+ max_in_flight_tokens=max_in_flight_tokens,
1231
+ )
1232
+ if len(responses) != len(prompts):
1233
+ raise ValueError("The synthesis response count did not match its requests.")
1234
+
1235
+ summarized_notes = []
1236
+ for response in responses:
1237
+ if not response or len(response.strip()) < 10:
1238
+ raise ValueError("empty synthesis response")
1239
+ summarized_notes.append(extract_json(response))
1240
+
1241
+ current_notes = summarized_notes
1242
+
1243
+ raise ValueError(
1244
+ "Complete-section notes could not be compacted into one budgeted keynote "
1245
+ "within eight hierarchy levels."
1246
+ )
1247
+
1248
+ def _partition_notes_for_compaction(
1249
+ self,
1250
+ *,
1251
+ paper_title: str,
1252
+ notes: list[Any],
1253
+ max_input_tokens: int,
1254
+ ) -> list[list[Any]]:
1255
+ """Greedily group notes only when an exact compaction prompt fits."""
1256
+ groups: list[list[Any]] = []
1257
+ current_group: list[Any] = []
1258
+ for note in notes:
1259
+ candidate = [*current_group, note]
1260
+ candidate_prompt = self._format_keynote_compaction_prompt(
1261
+ paper_title=paper_title,
1262
+ notes=candidate,
1263
+ )
1264
+ if int(self.chat_agent.estimate_tokens(candidate_prompt)) <= max_input_tokens:
1265
+ current_group = candidate
1266
+ continue
1267
+ if not current_group:
1268
+ raise ValueError(
1269
+ "One complete-section note exceeds the final keynote prompt budget."
1270
+ )
1271
+ groups.append(current_group)
1272
+ current_group = [note]
1273
+ single_note_prompt = self._format_keynote_compaction_prompt(
1274
+ paper_title=paper_title,
1275
+ notes=current_group,
1276
+ )
1277
+ if int(self.chat_agent.estimate_tokens(single_note_prompt)) > max_input_tokens:
1278
+ raise ValueError(
1279
+ "One complete-section note exceeds the final keynote prompt budget."
1280
+ )
1281
+ if current_group:
1282
+ groups.append(current_group)
1283
+ return groups
1284
+
1285
+ @staticmethod
1286
+ def _format_keynote_synthesis_prompt(paper_title: str, notes: list[Any]) -> str:
1287
+ return PAPER_KEYNOTE_SYNTHESIS.format(
1288
+ paper_title=paper_title,
1289
+ section_notes_json=json.dumps(notes, ensure_ascii=False),
1290
+ )
1291
+
1292
+ @staticmethod
1293
+ def _format_keynote_compaction_prompt(
1294
+ *,
1295
+ paper_title: str,
1296
+ notes: list[Any],
1297
+ ) -> str:
1298
+ return PAPER_KEYNOTE_COMPACTION.format(
1299
+ paper_title=paper_title,
1300
+ section_notes_json=json.dumps(notes, ensure_ascii=False),
1301
+ )
1302
+
1303
+ def _fallback_from_unreadable_fulltext(self, *, pid, hash_id, packing_status, section):
1304
+ """Use the configured abstract fallback without ever sending a partial chapter."""
1305
+ policy = str(
1306
+ getattr(
1307
+ self.config.ModuleInfo.WorkAnalyzer,
1308
+ "oversized_unsplittable_section_policy",
1309
+ "abstract",
1310
+ )
1311
+ or "abstract"
1312
+ ).lower()
1313
+ if packing_status == "unsplittable_section" and section is not None:
1314
+ reason = (
1315
+ f"complete section '{section.heading}' is {section.token_count} tokens "
1316
+ "and cannot be split safely"
1317
+ )
1318
+ elif packing_status == "prompt_budget" and section is not None:
1319
+ reason = (
1320
+ f"complete section '{section.heading}' cannot fit the final LLM "
1321
+ "prompt envelope without splitting"
1322
+ )
1323
+ elif packing_status == "prompt_budget":
1324
+ reason = "complete-section packets cannot fit the final LLM prompt envelope"
1325
+ else:
1326
+ reason = "MinerU Markdown has no safe ## body-section boundaries"
1327
+ if policy != "abstract" or not self.config.ModuleInfo.WorkAnalyzer.abstract_when_full_text_fail:
1328
+ self.logger.error("Cannot read paper %s without partial text: %s", pid, reason)
1329
+ self._record_paper_keynote_failure(
1330
+ pid,
1331
+ code="fulltext_packet_unsafe",
1332
+ reason=reason,
1333
+ permanent=True,
1334
+ fallback_status="disabled",
1335
+ )
1336
+ return False
1337
+ return self._try_abstract_keynote_fallback(
1338
+ pid=pid,
1339
+ hash_id=hash_id,
1340
+ reason=reason,
1341
+ failure_code="fulltext_packet_unsafe",
1342
+ )
1343
+
1344
+ def _cache_paper_keynote(
1345
+ self,
1346
+ pid,
1347
+ hash_id,
1348
+ keynote,
1349
+ *,
1350
+ fulltext_reading_source: str = "legacy_or_unknown",
1351
+ ):
1352
+ self.paper_keynote_cache[hash_id] = {
1353
+ "paper_id": pid,
1354
+ "keynote": keynote,
1355
+ # The promotion channel may use only notes whose source proves
1356
+ # that every original Markdown body section was read intact.
1357
+ # Old cache entries have no such provenance and remain usable for
1358
+ # ordinary survey RAG, but cannot silently become SH evidence.
1359
+ "fulltext_reading_source": str(fulltext_reading_source or ""),
1360
+ }
1361
+ if self.config.BasicInfo.debug:
1362
+ self.logger.info(f"paper ID {pid} keynote: {keynote}")
1363
+
1364
+ def generate_mla(self, paper_id: str):
1365
+ self.reference_graph = self._load_openalex_reference_graph()
1366
+ if (
1367
+ self.reference_graph is not None
1368
+ and paper_id in self.reference_graph
1369
+ and "authors" in self.reference_graph.nodes[paper_id]
1370
+ and "year" in self.reference_graph.nodes[paper_id]
1371
+ and "venue" in self.reference_graph.nodes[paper_id]
1372
+ and "title" in self.reference_graph.nodes[paper_id]
1373
+ ):
1374
+ authors = self.reference_graph.nodes[paper_id].get("authors", [])
1375
+ title = self.reference_graph.nodes[paper_id].get("title", "")
1376
+ venue = self.reference_graph.nodes[paper_id].get("venue", "")
1377
+ year = self.reference_graph.nodes[paper_id].get("year", "")
1378
+ else:
1379
+ paper = None
1380
+ if OpenAlexAPI.is_openalex_work_id(paper_id):
1381
+ try:
1382
+ paper = self.openalex_api.get_paper_details(paper_id)
1383
+ except Exception as exc:
1384
+ self.logger.warning(f"OpenAlex API failed for {paper_id}: {exc}")
1385
+ paper = None
1386
+ elif "." in paper_id: # arXiv IDs typically contain dots, e.g., "1706.03762"
1387
+ try:
1388
+ paper = self.arxiv_api.get_paper_details(paper_id)
1389
+ except Exception as e:
1390
+ self.logger.warning(f"arXiv API failed for {paper_id}: {e}. Trying Semantic Scholar.")
1391
+ try:
1392
+ query_id = "ARXIV:" + paper_id
1393
+ paper = self.semantic_scholar_api.get_paper_details(
1394
+ query_id, fields="title,year,venue,authors"
1395
+ )
1396
+ except Exception as e:
1397
+ self.logger.error(f"Semantic Scholar also failed for arXiv paper {paper_id}: {e}")
1398
+ paper = None
1399
+ else:
1400
+ # For non-arXiv papers, try Semantic Scholar directly
1401
+ try:
1402
+ paper = self.semantic_scholar_api.get_paper_details(
1403
+ paper_id, fields="title,year,venue,authors"
1404
+ )
1405
+ except Exception as e:
1406
+ self.logger.error(f"Semantic Scholar failed for paper {paper_id}: {e}")
1407
+ paper = None
1408
+
1409
+ if not paper:
1410
+ self.logger.warning(f"Warning: Unable to fetch details for paper ID {paper_id}")
1411
+ if getattr(self.config, 'AblationInfo', None) and getattr(self.config.AblationInfo, 'survey_generator_disabled', False):
1412
+ self.logger.warning(f"Ablation mode: Returning paper_id as title for {paper_id}")
1413
+ return f'Unknown Author. "{paper_id}" *Unknown*, Unknown'
1414
+ raise ValueError("Unable to fetch paper details in mla generation")
1415
+
1416
+ authors = paper.get("authors", [])
1417
+ title = paper.get("title", "")
1418
+ venue = paper.get("venue", "")
1419
+ year = paper.get("year", "")
1420
+
1421
+ if authors and isinstance(authors[0], dict):
1422
+ authors = [author.get("name", "") for author in authors]
1423
+
1424
+ if len(authors) == 0:
1425
+ author_str = ""
1426
+ elif len(authors) == 1:
1427
+ author_str = authors[0]
1428
+ elif len(authors) == 2:
1429
+ author_str = f"{authors[0]} and {authors[1]}"
1430
+ else:
1431
+ author_str = ", ".join(authors[:-1]) + ", and " + authors[-1]
1432
+
1433
+ citation = f'{author_str}. "{title}." *{venue}*, {year}'
1434
+ citation += "."
1435
+ return citation
1436
+
1437
+ def get_paper_keynote(self, paper_id: str, ds_keynote_fallback: bool = False):
1438
+ """Get the keynote of a paper by its ID."""
1439
+ hash_id = get_hash(paper_id)
1440
+
1441
+ if canonical_paper_id(paper_id) in FULLTEXT_READING_BYPASS_PAPER_IDS:
1442
+ # Keep the read-pipeline exception effective for all downstream
1443
+ # consumers, including callers that request a keynote directly.
1444
+ self.logger.info(
1445
+ "Using abstract keynote for explicit deep-reading exception: %s",
1446
+ paper_id,
1447
+ )
1448
+ try:
1449
+ _, abstract = self.work_collector.get_paper_title_abstract(paper_id)
1450
+ except Exception as exc:
1451
+ raise ValueError(
1452
+ f"Failed to get abstract for paper ID {paper_id}: {exc}"
1453
+ ) from exc
1454
+ return abstract
1455
+
1456
+ if self.use_graph_keynotes and not ds_keynote_fallback:
1457
+ results, errors = self.paper_graph_retriever.get_paper_keynote([paper_id])
1458
+ if errors or None in results:
1459
+ if self.use_ds_keynotes_when_graph_fail:
1460
+ return self.get_paper_keynote(paper_id, True)
1461
+ else:
1462
+ raise ValueError(f"Failed to get keynote for paper ID {paper_id} in paper graph")
1463
+ return results
1464
+
1465
+ if self.config.ModuleInfo.WorkAnalyzer.abstract_only_mode:
1466
+ try:
1467
+ title, abstract = self.work_collector.get_paper_title_abstract(paper_id)
1468
+ except Exception as e:
1469
+ raise ValueError(f"Failed to get abstract for paper ID {paper_id}: {e}")
1470
+ return abstract
1471
+
1472
+ elif not self.config.ModuleInfo.WorkAnalyzer.abstract_only_mode or ds_keynote_fallback:
1473
+ err = []
1474
+ if hash_id not in self.paper_keynote_cache or not self.config.ModuleInfo.WorkAnalyzer.cache_enabled:
1475
+ err = self.read_papers_and_write_keynotes([paper_id], ds_keynote_fallback)
1476
+
1477
+ if hash_id not in self.paper_keynote_cache or len(err) > 0:
1478
+ if self.config.ModuleInfo.WorkAnalyzer.abstract_when_full_text_fail:
1479
+ self.logger.info(f"Using abstract for paper ID: {paper_id} as keynote due to full text failure.")
1480
+ try:
1481
+ title, abstract = self.work_collector.get_paper_title_abstract(paper_id)
1482
+ except Exception as e:
1483
+ raise ValueError(f"Failed to get abstract for paper ID {paper_id}: {e}")
1484
+ return abstract
1485
+
1486
+ keynote_data = self.paper_keynote_cache[hash_id]["keynote"]
1487
+ return keynote_data
1488
+
1489
+ def cluster_papers(self, papers: List[str]) -> List[Dict]:
1490
+ # Every survey-producing launcher reaches clustering after deep
1491
+ # reading. Keep the citation-expanded promotion gate here rather
1492
+ # than in one launcher so batch and adapter entry points cannot omit
1493
+ # it. The gate is a safe no-op for runs without SH provenance or
1494
+ # complete-section keynotes.
1495
+ try:
1496
+ promotion_result = self.promote_complete_section_read_graph_candidates(
1497
+ papers
1498
+ )
1499
+ if promotion_result.get("promoted_pairs"):
1500
+ self.logger.info(
1501
+ "Promoted %s complete-section citation-expanded paper/SH pair(s) "
1502
+ "before clustering.",
1503
+ promotion_result["promoted_pairs"],
1504
+ )
1505
+ except Exception as exc:
1506
+ # Promotion is an additive writing path. A temporary assessment
1507
+ # failure must not erase independently admitted seed evidence or
1508
+ # prevent clustering; the unpromoted graph candidates remain
1509
+ # forbidden by the evidence plan.
1510
+ self.logger.warning(
1511
+ "Citation-expanded-paper promotion failed; proceeding without "
1512
+ "new expanded-paper evidence: %s",
1513
+ exc,
1514
+ )
1515
+ cached = self._load_cached_clusters(papers)
1516
+ if cached is not None:
1517
+ self.logger.info("Using cached clustering result.")
1518
+ return self._project_clusters_with_sh_coverage(cached)
1519
+
1520
+ if self.config.ModuleInfo.WorkAnalyzer.clustering_in_steps:
1521
+ clusters = self.cluster_papers_in_steps(papers)
1522
+ else:
1523
+ clusters = self.cluster_papers_1_step(papers)
1524
+
1525
+ clusters = self._project_clusters_with_sh_coverage(clusters)
1526
+ self._store_cached_clusters(papers, clusters)
1527
+ return clusters
1528
+
1529
+ def _sh_cluster_projection_inputs(self) -> tuple[dict[str, Any], dict[str, Any], str] | None:
1530
+ """Load current-project SH artifacts; reject stale cross-project joins."""
1531
+
1532
+ provenance = getattr(self.work_collector, "sh_graph_provenance_artifact", {})
1533
+ retrieval = getattr(self.work_collector, "subhypothesis_retrieval_artifact", {})
1534
+ basic_info = getattr(self.config, "BasicInfo", None)
1535
+ if (not isinstance(provenance, Mapping) or not provenance) and basic_info is not None:
1536
+ provenance = getattr(basic_info, "sh_graph_provenance", {})
1537
+ if (not isinstance(retrieval, Mapping) or not retrieval) and basic_info is not None:
1538
+ retrieval = getattr(basic_info, "subhypothesis_retrieval", {})
1539
+ if not provenance and not retrieval:
1540
+ return None
1541
+ if not isinstance(provenance, Mapping) or not isinstance(retrieval, Mapping):
1542
+ raise ValueError("SH cluster projection requires both provenance and retrieval artifacts.")
1543
+ if provenance.get("schema_version") != SH_GRAPH_PROVENANCE_SCHEMA_VERSION:
1544
+ raise ValueError("SH cluster projection received an unsupported provenance artifact.")
1545
+ final_ledger = retrieval.get("evidence_coverage_ledger_final")
1546
+ plan_context = (
1547
+ retrieval.get("plan", {}).get("project_context", {})
1548
+ if isinstance(retrieval.get("plan"), Mapping)
1549
+ else {}
1550
+ )
1551
+ fingerprint = str(provenance.get("project_context_fingerprint") or "")
1552
+ if not fingerprint or plan_context.get("project_context_fingerprint") != fingerprint:
1553
+ raise ValueError("SH provenance and retrieval artifacts belong to different project contexts.")
1554
+ if not isinstance(final_ledger, Mapping):
1555
+ raise ValueError("SH cluster projection requires the final evidence coverage ledger.")
1556
+ if final_ledger.get("schema_version") != EVIDENCE_COVERAGE_LEDGER_SCHEMA_VERSION:
1557
+ raise ValueError("SH cluster projection requires an evidence_coverage_ledger_v1 ledger.")
1558
+ if not isinstance(final_ledger.get("subhypotheses"), Sequence) or isinstance(
1559
+ final_ledger.get("subhypotheses"), (str, bytes)
1560
+ ):
1561
+ raise ValueError("SH coverage ledger must provide a subhypotheses sequence.")
1562
+ return dict(provenance), dict(final_ledger), fingerprint
1563
+
1564
+ def _project_clusters_with_sh_coverage(self, clusters: List[Dict]) -> List[Dict]:
1565
+ """Add SH evidence/background/gap views without changing global clusters."""
1566
+
1567
+ inputs = self._sh_cluster_projection_inputs()
1568
+ if inputs is None:
1569
+ return clusters
1570
+ provenance, final_ledger, fingerprint = inputs
1571
+ projected, artifact = build_cluster_sh_coverage_projection(
1572
+ clusters,
1573
+ provenance_artifact=provenance,
1574
+ coverage_ledger=final_ledger,
1575
+ graph=getattr(self.work_collector, "reference_graph", None),
1576
+ project_context_fingerprint=fingerprint,
1577
+ )
1578
+ self._store_sh_cluster_coverage_artifact(artifact)
1579
+ primary_links = sum(
1580
+ len(item.get("primary_subhypothesis_ids") or [])
1581
+ for item in artifact.get("clusters", [])
1582
+ if isinstance(item, Mapping)
1583
+ )
1584
+ self.logger.info(
1585
+ "Projected SH cluster coverage: clusters=%s direct_SH_links=%s unrepresented_SH=%s.",
1586
+ len(projected),
1587
+ primary_links,
1588
+ "|".join(artifact.get("unrepresented_subhypothesis_ids") or []) or "none",
1589
+ )
1590
+ return projected
1591
+
1592
+ def _store_sh_cluster_coverage_artifact(self, artifact: Mapping[str, Any]) -> None:
1593
+ if artifact.get("schema_version") != SH_CLUSTER_COVERAGE_SCHEMA_VERSION:
1594
+ raise ValueError("Refusing to persist an unsupported SH cluster coverage artifact.")
1595
+ self.sh_cluster_coverage_artifact = dict(artifact)
1596
+ try:
1597
+ self.work_collector.sh_cluster_coverage_artifact = self.sh_cluster_coverage_artifact
1598
+ except Exception:
1599
+ pass
1600
+ basic_info = getattr(self.config, "BasicInfo", None)
1601
+ if basic_info is None:
1602
+ return
1603
+ try:
1604
+ basic_info.sh_cluster_coverage = self.sh_cluster_coverage_artifact
1605
+ except Exception:
1606
+ pass
1607
+ base_dir = str(getattr(basic_info, "base_dir", "") or "").strip()
1608
+ if not base_dir:
1609
+ return
1610
+ artifact_path = Path(base_dir) / "sh_cluster_coverage.json"
1611
+ artifact_path.parent.mkdir(parents=True, exist_ok=True)
1612
+ artifact_path.write_text(
1613
+ json.dumps(self.sh_cluster_coverage_artifact, ensure_ascii=False, indent=2),
1614
+ encoding="utf-8",
1615
+ )
1616
+ try:
1617
+ basic_info.sh_cluster_coverage_artifact_path = str(artifact_path)
1618
+ except Exception:
1619
+ pass
1620
+
1621
+ def cluster_papers_1_step(self, papers: List[str]) -> List[Dict]:
1622
+ clusters = []
1623
+ num_batches = math.ceil(
1624
+ len(papers) / self.config.ModuleInfo.WorkAnalyzer.clustering_batch_size
1625
+ )
1626
+
1627
+ i = 0
1628
+ if self.config.BasicInfo.debug:
1629
+ self.logger.info(f'complete paper list for clustering: {papers}')
1630
+ while i < num_batches:
1631
+ valid = False
1632
+ for retry_time in range(
1633
+ self.config.ModuleInfo.WorkAnalyzer.paper_clustering_max_retry
1634
+ ):
1635
+ try:
1636
+ self.logger.info(f"Clustering batch {i+1}/{num_batches}...")
1637
+ batch = papers[
1638
+ i
1639
+ * self.config.ModuleInfo.WorkAnalyzer.clustering_batch_size : (
1640
+ i + 1
1641
+ )
1642
+ * self.config.ModuleInfo.WorkAnalyzer.clustering_batch_size
1643
+ ]
1644
+ paper_keynotes = ""
1645
+ for pid in batch:
1646
+ keynote_json = self.get_paper_keynote(pid)
1647
+ paper_keynotes += (
1648
+ f"Paper ID: {pid}\nKeynote: {keynote_json}\n\n"
1649
+ )
1650
+
1651
+ # self.logger.info(f"Clustering {len(batch)} papers...")
1652
+ # self.logger.info(f'Clustering prompt: {PAPER_CLUSTERING.format(existing_clusters_json=clusters,new_batch_json=paper_keynotes,)}')
1653
+ new_clusters = extract_json(
1654
+ self.chat_agent.remote_chat(
1655
+ PAPER_CLUSTERING.format(
1656
+ existing_clusters_json=clusters,
1657
+ new_batch_json=paper_keynotes,
1658
+ ),
1659
+ temperature=self.config.ModuleInfo.WorkAnalyzer.clustering_temperature,
1660
+ )
1661
+ )
1662
+ self.validate_clusters(new_clusters, papers) #YZY MODIFY: from clusters
1663
+ valid = True
1664
+ break
1665
+ except Exception as e:
1666
+ self.logger.warning(f"Error during clustering batch {i+1}: {e}. Retrying for {retry_time + 1}...")
1667
+ if not valid:
1668
+ raise ValueError("Clustering failed after maximum retries.")
1669
+ clusters = new_clusters
1670
+ if self.config.BasicInfo.debug:
1671
+ self.logger.info(f"CLUSTER after batch {i+1}: {new_clusters}")
1672
+ i += 1
1673
+ if self.config.BasicInfo.debug:
1674
+ self.logger.info(f"Final CLUSTER result: {clusters}")
1675
+ return clusters
1676
+
1677
+ def cluster_papers_in_steps(self, papers: List[str]) -> List[Dict]:
1678
+ clusters = self._create_clusters(papers)
1679
+ clusters = self._assign_papers_to_clusters(papers, clusters)
1680
+ return clusters
1681
+
1682
+ def _create_clusters(self, papers: List[str]) -> List[Dict]:
1683
+ clusters = []
1684
+ num_batches = math.ceil(
1685
+ len(papers) / self.config.ModuleInfo.WorkAnalyzer.clustering_batch_size_in_creation
1686
+ )
1687
+
1688
+ # step 1: create clusters
1689
+ i = 0
1690
+ while i < num_batches:
1691
+ valid = False
1692
+ for retry_time in range(
1693
+ self.config.ModuleInfo.WorkAnalyzer.paper_clustering_creation_max_retry
1694
+ ):
1695
+ try:
1696
+ self.logger.info(f"Clustering batch {i+1}/{num_batches}...")
1697
+ batch = papers[
1698
+ i
1699
+ * self.config.ModuleInfo.WorkAnalyzer.clustering_batch_size_in_creation : (
1700
+ i + 1
1701
+ )
1702
+ * self.config.ModuleInfo.WorkAnalyzer.clustering_batch_size_in_creation
1703
+ ]
1704
+ if self.config.BasicInfo.debug:
1705
+ self.logger.info(f'complete paper list for clustering: {papers}')
1706
+ paper_keynotes = ""
1707
+ for pid in batch:
1708
+ keynote_json = self.get_paper_keynote(pid)
1709
+ paper_keynotes += (
1710
+ f"Paper ID: {pid}\nKeynote: {keynote_json}\n\n"
1711
+ )
1712
+
1713
+ new_clusters = extract_json(
1714
+ self.chat_agent.remote_chat(
1715
+ PAPER_CLUSTERING_CREATING.format(
1716
+ existing_clusters_json=clusters,
1717
+ new_batch_json=paper_keynotes,
1718
+ ),
1719
+ temperature=self.config.ModuleInfo.WorkAnalyzer.clustering_temperature,
1720
+ )
1721
+ )
1722
+ valid = True
1723
+ break
1724
+ except Exception as e:
1725
+ self.logger.warning(f"Error during clustering batch creation {i+1}: {e}. Retrying for {retry_time + 1}...")
1726
+ if not valid:
1727
+ raise ValueError("Clustering failed after maximum retries.")
1728
+ clusters = new_clusters
1729
+ if self.config.BasicInfo.debug:
1730
+ self.logger.info(f"CLUSTER after batch {i+1}: {new_clusters}")
1731
+ i += 1
1732
+ if self.config.BasicInfo.debug:
1733
+ self.logger.info(f"Final CLUSTER result: {clusters}")
1734
+ return clusters
1735
+
1736
+ def _validate_assignment(self, result, info_dict):
1737
+ # self.logger.info("outer validating....")
1738
+ error_conservatism_mode = info_dict.get("error_conservatism_mode", False)
1739
+ max_retry = info_dict.get("max_retry", 5)
1740
+ batches = info_dict.get("batches", [])
1741
+ papers = info_dict.get("papers", [])
1742
+ # Fixed: was "cluster" in info_dict but "clusters" in access - now using "cluster" which is what we pass
1743
+ clusters = info_dict.get("cluster", {})
1744
+ idx = info_dict.get("idx")
1745
+ retry_time = info_dict.get("retry_time", 0)
1746
+
1747
+
1748
+ omit_error = not error_conservatism_mode and retry_time >= max_retry - 1
1749
+ batch = batches[idx] if idx < len(batches) else []
1750
+ try:
1751
+ new_papers_dict = extract_json(result)
1752
+ if self._validate_new_papers_to_clusters(clusters, new_papers_dict, papers, batch, omit_error):
1753
+ return True, result
1754
+ except Exception as e:
1755
+ if omit_error:
1756
+ self.logger.warning(f"error in cluster assign: {e}, omit")
1757
+ # Fixed: return (val, result) format for retry
1758
+ return True, result
1759
+ else:
1760
+ self.logger.error(f"error in cluster assign: {e}")
1761
+ return False, result
1762
+ return True, result
1763
+
1764
+ def _assign_papers_to_clusters(self, papers: List[str], clusters: List[dict]) -> List[Dict]:
1765
+ # step2: assign papers to clusters
1766
+ num_batches = math.ceil(
1767
+ len(papers) / self.config.ModuleInfo.WorkAnalyzer.clustering_batch_size_in_assignment
1768
+ )
1769
+
1770
+ cluster_name_dict = {cluster["cluster_name"]: cluster for cluster in clusters}
1771
+ for cluster in cluster_name_dict.values():
1772
+ cluster["papers"] = []
1773
+
1774
+ new_clusters = copy.deepcopy(cluster_name_dict)
1775
+
1776
+ prompts = []
1777
+ batches = []
1778
+ # step 2: assign papers to clusters
1779
+ i = 0
1780
+ while i < num_batches:
1781
+ valid = False
1782
+ err_info = ""
1783
+ for retry_time in range(
1784
+ self.config.ModuleInfo.WorkAnalyzer.paper_clustering_assignment_max_retry
1785
+ ):
1786
+ try:
1787
+ self.logger.info(f"Clustering batch {i+1}/{num_batches}...")
1788
+ batch = papers[
1789
+ i
1790
+ * self.config.ModuleInfo.WorkAnalyzer.clustering_batch_size_in_assignment : (
1791
+ i + 1
1792
+ )
1793
+ * self.config.ModuleInfo.WorkAnalyzer.clustering_batch_size_in_assignment
1794
+ ]
1795
+ if self.config.BasicInfo.debug:
1796
+ self.logger.info(f'complete paper list for clustering: {papers}')
1797
+ paper_keynotes = ""
1798
+ for pid in batch:
1799
+ keynote_json = self.get_paper_keynote(pid)
1800
+ paper_keynotes += (
1801
+ f"Paper ID: {pid}\nKeynote: {keynote_json}\n\n"
1802
+ )
1803
+
1804
+ assign_prompt = PAPER_CLUSTERING_ASSIGNING.format(
1805
+ clusters_json=cluster_name_dict,
1806
+ batch_json=paper_keynotes,
1807
+ ) + (ERROR_FEEDBACK_PROMPT.format(info = err_info) if err_info else "")
1808
+
1809
+
1810
+ ### for fast mode, use batch chat out of he loop
1811
+ if self.cluster_fast_mode:
1812
+ self.logger.info(f"[CLUSTER FAST MODE DEBUG] use fast cluster")
1813
+ valid = True
1814
+ batches.append(batch)
1815
+ prompts.append(assign_prompt)
1816
+ break
1817
+
1818
+ new_papers_dict = extract_json(
1819
+ self.chat_agent.remote_chat(
1820
+ assign_prompt,
1821
+ temperature=self.config.ModuleInfo.WorkAnalyzer.clustering_temperature,
1822
+ )
1823
+ )
1824
+ if not self.config.BasicInfo.error_conservatism_mode and retry_time == self.config.ModuleInfo.WorkAnalyzer.paper_clustering_assignment_max_retry - 1:
1825
+ self.logger.warning(f"max retry reached:{retry_time+1}/{self.config.ModuleInfo.WorkAnalyzer.paper_clustering_assignment_max_retry}, assigning papers directly to clusters and returning in PAPER CLUSTER PAPER ASSIGNING.")
1826
+ new_clusters = self._validate_and_assign_new_papers_to_clusters(new_clusters, new_papers_dict, papers, batch, True)
1827
+
1828
+ else:
1829
+ new_clusters = self._validate_and_assign_new_papers_to_clusters(new_clusters, new_papers_dict, papers, batch, False)
1830
+ valid = True
1831
+ break
1832
+ except Exception as e:
1833
+ self.logger.warning(f"Error during clustering batch assignment {i+1}: {e}. Retrying for {retry_time + 1}...")
1834
+ err_info += f"Error during clustering batch {i+1}: {e}. \n"
1835
+ if not valid:
1836
+ raise ValueError("Clustering failed after maximum retries.")
1837
+ if self.config.BasicInfo.debug and not self.cluster_fast_mode:
1838
+ self.logger.info(f"CLUSTER after batch {i+1}: {new_clusters}")
1839
+ i += 1
1840
+
1841
+ if self.cluster_fast_mode:
1842
+ info_dict = {
1843
+ "error_conservatism_mode": self.config.BasicInfo.error_conservatism_mode,
1844
+ "max_retry": self.config.ModuleInfo.WorkAnalyzer.paper_clustering_assignment_max_retry,
1845
+ "batches": batches,
1846
+ "papers": papers,
1847
+ "cluster": cluster_name_dict
1848
+ }
1849
+
1850
+ results = self.chat_agent.batch_remote_chat_with_retry(prompts = prompts,
1851
+ validate_fn=self._validate_assignment,
1852
+ max_retry = self.config.ModuleInfo.WorkAnalyzer.paper_clustering_assignment_max_retry,
1853
+ desc = "assigning resultd in fast mode",
1854
+ temperature = self.config.ModuleInfo.WorkAnalyzer.clustering_temperature,
1855
+ info_dict = info_dict,
1856
+ )
1857
+
1858
+ for idx, result in enumerate(results):
1859
+ # self.logger.info(f"assigning result {idx}...")
1860
+ batch = batches[idx]
1861
+ new_papers_dict = extract_json(result)
1862
+ new_clusters = self._validate_and_assign_new_papers_to_clusters(new_clusters, new_papers_dict, papers, batch, True)
1863
+
1864
+ if self.config.BasicInfo.debug:
1865
+ self.logger.info(f"Final CLUSTER result: {new_clusters}")
1866
+
1867
+ new_clusters = list(new_clusters.values())
1868
+ return new_clusters
1869
+
1870
+ def _validate_and_assign_new_papers_to_clusters(self, clusters: Dict[str, dict], new_papers: List[dict], valid_papers: List[str], necessary_papers: List[str], omit_err: bool = False) -> Dict[str, dict]:
1871
+ valid_papers = set(valid_papers)
1872
+
1873
+ for paper in new_papers:
1874
+ if "clusters" not in paper or paper.get("clusters") is None:
1875
+ if omit_err:
1876
+ self.logger.warning("Omitting this paper due to incomplete information in PAPER CLUSTER PAPER ASSIGNING: lack key clusters")
1877
+ continue
1878
+ raise ValueError("Incomplete paper information in PAPER CLUSTER PAPER ASSIGNING: lack key clusters")
1879
+
1880
+ assigned_clusters = paper.get("clusters")
1881
+ for assigned_cluster in assigned_clusters:
1882
+ if assigned_cluster in clusters:
1883
+
1884
+ if paper["id"] is None or paper["title"] is None or paper["tldr"] is None:
1885
+ self.logger.error(f"Paper information incomplete for paper in cluster assignment: {paper}")
1886
+ if omit_err:
1887
+ self.logger.warning("Omitting this paper due to incomplete information in PAPER CLUSTER PAPER ASSIGNING.")
1888
+ continue
1889
+ raise ValueError("Incomplete paper information in PAPER CLUSTER PAPER ASSIGNING.")
1890
+
1891
+ if paper["id"] not in valid_papers:
1892
+ self.logger.error(f"Paper ID {paper['id']} not in original paper list during cluster assignment.")
1893
+ if omit_err:
1894
+ self.logger.warning("Omitting this paper due to incomplete information in PAPER CLUSTER PAPER ASSIGNING.")
1895
+ continue
1896
+ raise ValueError("Invalid paper ID in PAPER CLUSTER PAPER ASSIGNING.")
1897
+
1898
+ clusters[assigned_cluster]["papers"].append({
1899
+ "id": paper["id"],
1900
+ "title": paper["title"],
1901
+ "tldr": paper["tldr"]
1902
+ })
1903
+ else:
1904
+ self.logger.error(f"Assigned cluster {assigned_cluster} not found in existing clusters.")
1905
+ if omit_err:
1906
+ self.logger.warning(f"Omitting this fake cluster name:{assigned_cluster} due to incomplete information in PAPER CLUSTER PAPER ASSIGNING.")
1907
+ continue
1908
+ raise ValueError("Invalid clustering assignment in PAPER CLUSTER PAPER ASSIGNING.")
1909
+
1910
+ assigned_paper_ids = set(paper["id"] for paper in new_papers)
1911
+ unassigned = set(necessary_papers) - assigned_paper_ids
1912
+
1913
+ if unassigned:
1914
+ self.logger.error(f"Papers in batch not assigned num: {len(unassigned)}")
1915
+ if not omit_err:
1916
+ raise ValueError("Missing papers in batch assignment.")
1917
+ else:
1918
+ self.logger.warning(f"Omitting this not assigned papers:{len(unassigned)} due to incomplete information in PAPER CLUSTER PAPER ASSIGNING.")
1919
+ return clusters
1920
+
1921
+ def _validate_new_papers_to_clusters(self, clusters: Dict[str, dict], new_papers: List[dict], valid_papers: List[str], necessary_papers: List[str], omit_err: bool = False) -> Dict[str, dict]:
1922
+ valid_papers = set(valid_papers)
1923
+ # self.logger.info("inner validating....")
1924
+ returned_papers = []
1925
+
1926
+ for paper in new_papers:
1927
+
1928
+ if "clusters" not in paper or paper.get("clusters") is None:
1929
+ self.logger.error("ERROR: incomplete information in PAPER CLUSTER PAPER ASSIGNING: lack key clusters or clusters empty")
1930
+ # self.logger.info(f"[CLUSTER FAST MODE DEBUG ERROR RESPONSE]: {new_papers}")
1931
+ if omit_err:
1932
+ self.logger.warning("Omitting this paper due to incomplete information in PAPER CLUSTER PAPER ASSIGNING: lack key clusters or clusters empty")
1933
+ continue
1934
+ raise ValueError("Incomplete paper information in PAPER CLUSTER PAPER ASSIGNING: lack key clusters")
1935
+
1936
+ assigned_clusters = paper.get("clusters")
1937
+
1938
+ for assigned_cluster in assigned_clusters:
1939
+ if assigned_cluster in clusters:
1940
+
1941
+ if paper["id"] is None or paper["title"] is None or paper["tldr"] is None:
1942
+ self.logger.error(f"Paper information incomplete for paper in cluster assignment: {paper}")
1943
+ # self.logger.info(f"[CLUSTER FAST MODE DEBUG ERROR RESPONSE]: {new_papers}")
1944
+ if omit_err:
1945
+ self.logger.warning("Omitting this paper due to incomplete information in PAPER CLUSTER PAPER ASSIGNING.")
1946
+ continue
1947
+ raise ValueError("Incomplete paper information in PAPER CLUSTER PAPER ASSIGNING.")
1948
+
1949
+ if paper["id"] not in valid_papers:
1950
+ self.logger.error(f"Paper ID {paper['id']} not in original paper list during cluster assignment.")
1951
+ # self.logger.info(f"[CLUSTER FAST MODE DEBUG ERROR RESPONSE]: {new_papers}")
1952
+ if omit_err:
1953
+ self.logger.warning("Omitting this paper due to incomplete information in PAPER CLUSTER PAPER ASSIGNING.")
1954
+ continue
1955
+ raise ValueError("Invalid paper ID in PAPER CLUSTER PAPER ASSIGNING.")
1956
+ else:
1957
+ self.logger.error(f"Assigned cluster {assigned_cluster} not found in existing clusters.")
1958
+ # self.logger.info(f"[CLUSTER FAST MODE DEBUG ERROR RESPONSE]: {new_papers}")
1959
+ if omit_err:
1960
+ self.logger.warning(f"Omitting this fake cluster name:{assigned_cluster} due to incomplete information in PAPER CLUSTER PAPER ASSIGNING.")
1961
+ continue
1962
+ raise ValueError("Invalid clustering assignment in PAPER CLUSTER PAPER ASSIGNING.")
1963
+
1964
+ assigned_paper_ids = set(paper["id"] for paper in new_papers)
1965
+ unassigned = set(necessary_papers) - assigned_paper_ids
1966
+
1967
+ if unassigned:
1968
+ self.logger.error(f"Papers in batch not assigned num: {len(unassigned)}")
1969
+ # self.logger.info(f"[CLUSTER FAST MODE DEBUG ERROR RESPONSE]: {new_papers}")
1970
+ if not omit_err:
1971
+ raise ValueError("Missing papers in batch assignment.")
1972
+ else:
1973
+ self.logger.warning(f"Omitting this not assigned papers:{len(unassigned)} due to incomplete information in PAPER CLUSTER PAPER ASSIGNING.")
1974
+ self.logger.info("inner validate finish")
1975
+ return True
1976
+
1977
+
1978
+ def log_clusters(self, clusters: List[Dict]):
1979
+ paper_id_set = set()
1980
+ log_str = "Clustering Results:\n"
1981
+ for i, cluster in enumerate(clusters):
1982
+ log_str += f"Cluster {i+1}:\n"
1983
+ log_str += f" Cluster Name: {cluster['cluster_name']}\n"
1984
+ log_str += f" Summary: {cluster['summary']}\n"
1985
+ log_str += f" Papers:\n"
1986
+ for paper in cluster["papers"]:
1987
+ log_str += f" - ID: {paper['id']}\n"
1988
+ log_str += f" Title: {paper['title']}\n"
1989
+ log_str += f" TL;DR: {paper['tldr']}\n"
1990
+ paper_id_set.add(paper["id"])
1991
+ log_str += "\n"
1992
+ log_str += "-" * 40 + "\n\n"
1993
+ self.logger.info(log_str)
1994
+ self.logger.info(f"Total unique papers in clusters: {len(paper_id_set)}")
1995
+ if self.config.BasicInfo.debug:
1996
+ self.logger.info(f"all ref set: {paper_id_set}")
1997
+
1998
+ def validate_clusters(self, clusters: List[Dict], papers: List[str]) -> List[Dict]:
1999
+ paper_id_set = set(papers)
2000
+ for cluster in clusters:
2001
+ for paper in cluster["papers"]:
2002
+ if paper["id"] not in paper_id_set:
2003
+ self.logger.warning(
2004
+ f"Paper ID {paper['id']} in cluster {cluster['cluster_name']} not in original paper list."
2005
+ )
2006
+ raise ValueError("Invalid clustering result.")
2007
+
2008
+ def prepare_prompt_for_proposing_questions_for_cluster(self, cluster: List[str]):
2009
+ cluster_content = f"Paper keynotes:\n"
2010
+ for paper in cluster["papers"]:
2011
+ pid = paper["id"]
2012
+ keynote_json = self.get_paper_keynote(pid)
2013
+ cluster_content += f"Paper ID: {pid}\nKeynote: {keynote_json}\n\n"
2014
+ return PROPOSE_QUESTIONS_FOR_CLUSTER.format(cluster_content=cluster_content)
2015
+
2016
+ def prepare_prompt_for_answering_questions_for_cluster(self, questions: List[Dict]):
2017
+ prompts = []
2018
+ for q in questions:
2019
+ question_text = q["question"]
2020
+ related_papers = q["related_papers"]
2021
+ related_papers_content = ""
2022
+ for pid in related_papers:
2023
+ keynote_json = self.get_paper_keynote(pid)
2024
+ related_papers_content += (
2025
+ f"Paper ID: {pid}\nKeynote: {keynote_json}\n\n"
2026
+ )
2027
+ prompt = ANSWER_QUESTION_FOR_PAPERS.format(
2028
+ question=question_text, related_papers_content=related_papers_content
2029
+ )
2030
+ prompts.append(prompt)
2031
+ return prompts
2032
+
2033
+ def intra_cluster_analysis(self, clusters: List[Dict], retry=1):
2034
+ try:
2035
+ # step 1: propose questions for each cluster
2036
+ prompts = []
2037
+ for cluster in clusters:
2038
+ prompt = self.prepare_prompt_for_proposing_questions_for_cluster(
2039
+ cluster
2040
+ )
2041
+ prompts.append(prompt)
2042
+
2043
+ # use fixed-length list to keep correspondence turn with clusters
2044
+ questions = [None] * len(clusters)
2045
+ step_1_clusters = clusters # to avoid changing clusters during retry
2046
+
2047
+ # turn mapping for retry
2048
+ indices = list(range(len(clusters)))
2049
+
2050
+ # start question proposing with retry
2051
+ for _ in range(
2052
+ self.config.ModuleInfo.WorkAnalyzer.intra_clustering_probelm_proposing_max_retry
2053
+ ):
2054
+ valid = True
2055
+ try:
2056
+ responses = self.chat_agent.batch_remote_chat(
2057
+ prompts,
2058
+ temperature=self.config.ModuleInfo.WorkAnalyzer.propose_question_temperature,
2059
+ desc="Proposing questions for clusters",
2060
+ )
2061
+ except Exception as e:
2062
+ self.logger.error(f'Error: {e} , during proposing questions for clusters in getting response from LLM, retrying all...')
2063
+ continue
2064
+
2065
+ # self.logger.info(f"intra cluster analysis responses:\nTYPE:{type(responses)} | {responses}")
2066
+
2067
+ err_prompts = []
2068
+ err_clusters = []
2069
+ err_indices = []
2070
+ err_infos = []
2071
+
2072
+ # use current batch indices to map results back to original positions
2073
+ for i, response in enumerate(responses):
2074
+ original_index = indices[i]
2075
+
2076
+ try:
2077
+ cur_questions = extract_json(response)
2078
+ except Exception as e:
2079
+ self.logger.warning(f"Error extracting questions for cluster {original_index+1}: {e}. Retrying...")
2080
+ err_prompts.append(prompts[i])
2081
+ err_clusters.append(step_1_clusters[i])
2082
+ err_indices.append(original_index)
2083
+ err_infos.append(f"Error extracting questions from your answer for cluster {original_index+1}: {e}. \n")
2084
+ valid = False
2085
+ continue
2086
+
2087
+ omit_error = not self.config.BasicInfo.error_conservatism_mode and (_ + 1 == self.config.ModuleInfo.WorkAnalyzer.intra_clustering_probelm_proposing_max_retry)
2088
+
2089
+ response_valid, invalid_pid = self.validate_questions(cur_questions, step_1_clusters[i], omit_err=omit_error)
2090
+ if response_valid:
2091
+ questions[original_index] = cur_questions[:self.max_problems_per_cluster_proposed]
2092
+ else:
2093
+ valid = False
2094
+ self.logger.warning(f"Invalid questions proposed for cluster {original_index+1}. Retrying...")
2095
+
2096
+ err_prompts.append(prompts[i])
2097
+ err_clusters.append(step_1_clusters[i])
2098
+ err_indices.append(original_index)
2099
+ err_infos.append(f"Question related papers ID {invalid_pid} not valid. Make sure only cite provided paper.\n ")
2100
+
2101
+ if valid: # no error, break
2102
+ break
2103
+
2104
+ # next retry only for error cases
2105
+ prompts = err_prompts
2106
+ step_1_clusters = err_clusters
2107
+ indices = err_indices
2108
+
2109
+ for i in range(len(prompts)):
2110
+ prompts[i] += ERROR_FEEDBACK_PROMPT.format(info = err_infos[i])
2111
+
2112
+ # if None occurs in questions after retries, raise error
2113
+ if any(q is None for q in questions):
2114
+ raise ValueError("Invalid question related papers after maximum retries.")
2115
+
2116
+ # step 2: answer questions for each cluster
2117
+ prompts = self.prepare_prompt_for_answering_questions_for_cluster(
2118
+ [q for cluster_questions in questions for q in cluster_questions]
2119
+ )
2120
+ answers = self.chat_agent.batch_remote_chat(
2121
+ prompts,
2122
+ temperature=self.config.ModuleInfo.WorkAnalyzer.propose_question_temperature,
2123
+ desc="Answering questions for clusters",
2124
+ )
2125
+
2126
+ # step 3: organize the Q&A
2127
+ for cluster_questions in questions:
2128
+ for q in cluster_questions:
2129
+ q["answer"] = self._truncate_by_words(answers.pop(0), self.max_words_num_in_intra_cluster_answer)
2130
+
2131
+ return questions
2132
+ except Exception as e:
2133
+ if (
2134
+ retry
2135
+ > self.config.ModuleInfo.WorkAnalyzer.intra_cluster_analysis_max_retry
2136
+ ):
2137
+ raise e
2138
+ return self.intra_cluster_analysis(clusters, retry=retry + 1)
2139
+
2140
+ def validate_questions(self, questions: List[Dict], cluster: Dict, omit_err: bool = False):
2141
+ valid_paper_ids = {paper["id"] for paper in cluster["papers"]}
2142
+ err_id = []
2143
+ for q in questions:
2144
+ for pid in q["related_papers"]:
2145
+ if pid not in valid_paper_ids:
2146
+ self.logger.warning(
2147
+ f"Paper ID {pid} in question '{q['question']}' not in cluster papers."
2148
+ )
2149
+ if not omit_err:
2150
+ return False, pid
2151
+ else:
2152
+ self.logger.warning(f"Omitting this invalid paper ID:{pid} in question due to omit_err mode.")
2153
+ err_id.append(pid)
2154
+ if omit_err and err_id:
2155
+ q["related_papers"] = [pid for pid in q["related_papers"] if pid not in err_id]
2156
+
2157
+ return True, ""
2158
+
2159
+ def log_intra_cluster_analysis(self, analysis_results: List[List[Dict]]):
2160
+ log_str = "Intra-Cluster Analysis Results:\n"
2161
+ for i, cluster_results in enumerate(analysis_results):
2162
+ log_str += f"Cluster {i+1}:\n"
2163
+ for qa in cluster_results:
2164
+ log_str += f" Question: {qa['question']}\n"
2165
+ log_str += f" Related Papers: {', '.join(qa['related_papers'])}\n"
2166
+ log_str += f" Answer: {qa['answer']}\n"
2167
+ log_str += "\n"
2168
+ log_str += "-" * 40 + "\n\n"
2169
+ self.logger.info(log_str)
2170
+
2171
+ def _truncate_by_words(self, text: str, max_words: int, suffix: str = "(too long, truncated)...") -> str:
2172
+ """Truncate text to max_words, adding suffix if truncated."""
2173
+ if not text:
2174
+ return text
2175
+ words = text.split()
2176
+ if len(words) <= max_words:
2177
+ return text
2178
+ truncated = " ".join(words[:max_words - len(suffix.split())])
2179
+ return truncated + " " + suffix
2180
+
2181
+ def inter_cluster_analysis(self, intra_analysis_results: List[List[Dict]]):
2182
+ cluster_analysis_content = ""
2183
+ for i, cluster_results in enumerate(intra_analysis_results):
2184
+ cluster_analysis_content += f"Group {i+1} Analysis:\n"
2185
+ for j, qa in enumerate(cluster_results):
2186
+ cluster_analysis_content += f"Question {j+1}: {qa['question']}\n"
2187
+ cluster_analysis_content += f"Answer: {j+1}: {qa['answer']}\n\n"
2188
+ cluster_analysis_content += "-" * 3 + "\n\n"
2189
+
2190
+ prompt = INTER_CLUSTER_ANALYSIS.format(
2191
+ cluster_analysis_content=cluster_analysis_content
2192
+ )
2193
+ response = self.chat_agent.remote_chat(
2194
+ prompt,
2195
+ temperature=self.config.ModuleInfo.WorkAnalyzer.intra_cluster_analysis_temperature,
2196
+ )
2197
+
2198
+ # Apply word limit if configured
2199
+ max_words = self.max_words_num_in_inter_cluster_analysis
2200
+ response = self._truncate_by_words(response, max_words)
2201
+
2202
+ return response
2203
+
2204
+ def log_inter_cluster_analysis(self, analysis_results):
2205
+ log_str = "Inter-Cluster Analysis Results:\n"
2206
+ log_str += analysis_results + "\n"
2207
+ self.logger.info(log_str)
2208
+
2209
+ # ---------------------------- Relationship analysis ----------------------------
2210
+ def build_relationship_graphs(self, clusters: List[Dict]):
2211
+ """
2212
+ For each cluster, construct a directed graph over its papers and enrich edges with
2213
+ relation type/analysis from the LLM.
2214
+
2215
+ Returns a dict: {cluster_name: nx.DiGraph}, where each edge has attributes
2216
+ {'type': str, 'analysis': str, 'raw': str}.
2217
+ """
2218
+ ref_graph: nx.DiGraph = self.work_collector.reference_graph
2219
+ if ref_graph is None:
2220
+ raise ValueError("Reference graph is not initialized; cannot build relationship graphs.")
2221
+
2222
+ cached = self._load_cached_relation_graph(clusters)
2223
+ if cached is not None:
2224
+ self.logger.info("Using cached relationship graphs.")
2225
+ self.relation_analysis_graph = cached
2226
+ return cached
2227
+
2228
+ results = {}
2229
+ pending_tasks = [] # collect all edge prompts for batched processing
2230
+ for cluster in clusters:
2231
+ cluster_name = cluster.get("cluster_name", "unknown_cluster")
2232
+ g = nx.DiGraph()
2233
+
2234
+ # Collect node IDs in this cluster
2235
+ paper_ids = [p.get("id") for p in cluster.get("papers", []) if p.get("id")]
2236
+ g.add_nodes_from(
2237
+ (
2238
+ paper_id,
2239
+ dict(ref_graph.nodes[paper_id]) if paper_id in ref_graph else {},
2240
+ )
2241
+ for paper_id in paper_ids
2242
+ )
2243
+
2244
+ for src in paper_ids:
2245
+ for dst in paper_ids:
2246
+ if src == dst:
2247
+ continue
2248
+ if not ref_graph.has_edge(src, dst):
2249
+ continue
2250
+ if self.config.BasicInfo.debug:
2251
+ self.logger.info(f"Analyzing relationship for edge {src}->{dst} in cluster {cluster_name}.")
2252
+ # try:
2253
+ src_title = ref_graph.nodes.get(src, {}).get("title", "")
2254
+ dst_title = ref_graph.nodes.get(dst, {}).get("title", "")
2255
+ src_keynote = self.get_paper_keynote(src)
2256
+ dst_keynote = self.get_paper_keynote(dst)
2257
+
2258
+ prompt = PAPER_RELATIONSHIP_ANALYSIS.format(
2259
+ src_title=src_title,
2260
+ dist_title=dst_title,
2261
+ src_keynote=src_keynote,
2262
+ dist_keynote=dst_keynote,
2263
+ )
2264
+ pending_tasks.append({
2265
+ "cluster_name": cluster_name,
2266
+ "graph": g,
2267
+ "src": src,
2268
+ "dst": dst,
2269
+ "prompt": prompt,
2270
+ })
2271
+ # except Exception as e:
2272
+ # self.logger.warning(f"Relation analysis failed for edge {src}->{dst}: {e}")
2273
+ # continue
2274
+
2275
+ results[cluster_name] = g
2276
+
2277
+ # Batch call LLM with retry
2278
+ temperature = getattr(
2279
+ self.config.ModuleInfo.WorkAnalyzer,
2280
+ "paper_relationship_temperature",
2281
+ 0.3,
2282
+ )
2283
+ max_retry = getattr(
2284
+ self.config.ModuleInfo.WorkAnalyzer,
2285
+ "paper_relationship_max_retry",
2286
+ 3,
2287
+ )
2288
+
2289
+ tasks = pending_tasks
2290
+ for attempt in range(1, max_retry + 1):
2291
+ if not tasks:
2292
+ break
2293
+ prompts = [t["prompt"] for t in tasks]
2294
+ try:
2295
+ responses = self.chat_agent.batch_remote_chat(
2296
+ prompts,
2297
+ temperature=temperature,
2298
+ desc=f"Relationship analysis attempt {attempt}/{max_retry}",
2299
+ )
2300
+ except Exception as e:
2301
+ self.logger.warning(f"batch_remote_chat failed on attempt {attempt}: {e}")
2302
+ continue
2303
+
2304
+ next_tasks = []
2305
+ for task, resp in zip(tasks, responses or []):
2306
+ if resp is None:
2307
+ next_tasks.append(task)
2308
+ continue
2309
+
2310
+ relation_type = "unspecified"
2311
+ analysis = resp
2312
+ try:
2313
+ parsed = extract_json(resp)
2314
+ if isinstance(parsed, dict):
2315
+ relation_type = parsed.get("type") or parsed.get("relation") or relation_type
2316
+ analysis = parsed.get("analysis") or parsed.get("description") or analysis
2317
+ except Exception:
2318
+ pass
2319
+
2320
+ try:
2321
+ task["graph"].add_edge(
2322
+ task["src"], task["dst"], type=relation_type, analysis=analysis, raw=resp
2323
+ )
2324
+ if self.config.BasicInfo.debug:
2325
+ self.logger.info(f"GRAPH_DEBUG: Added edge {task['src']}->{task['dst']} with type '{relation_type}' in cluster {task['cluster_name']}.")
2326
+ except Exception as e:
2327
+ self.logger.warning(f"Failed to add edge {task['src']}->{task['dst']}: {e}")
2328
+ next_tasks.append(task)
2329
+
2330
+ tasks = next_tasks
2331
+
2332
+ # Drop edges that still failed after max retries
2333
+ for t in tasks:
2334
+ self.logger.warning(
2335
+ f"Dropping edge {t['src']}->{t['dst']} in cluster {t['cluster_name']} after {max_retry} retries."
2336
+ )
2337
+ self.relation_analysis_graph = results
2338
+ self._store_cached_relation_graph(clusters, results)
2339
+ self.logger.info(f"=====GRAPH_DEBUG=====")
2340
+ self.logger.info(f"{results}")
2341
+ self.logger.info(f"=====GRAPH END=====")
2342
+ return results
2343
+
2344
+ def _get_relation_analysis_graph(self, relationship_graphs: Dict[str, nx.DiGraph] = None):
2345
+ """
2346
+ Convert relationship graphs to triples for downstream prompts.
2347
+
2348
+ Returns a dict: {cluster_name: [ (src, relation, dst, analysis) ]}
2349
+ """
2350
+ if relationship_graphs is None:
2351
+ if self.relation_analysis_graph is None:
2352
+ raise ValueError("Relationship graphs not provided and not built yet.")
2353
+ relationship_graphs = self.relation_analysis_graph
2354
+ triples = {}
2355
+ for cluster_name, g in relationship_graphs.items():
2356
+ cluster_triples = []
2357
+ for u, v, data in g.edges(data=True):
2358
+ cluster_triples.append(
2359
+ (
2360
+ u,
2361
+ data.get("type", "unspecified"),
2362
+ v,
2363
+ data.get("analysis", ""),
2364
+ )
2365
+ )
2366
+ triples[cluster_name] = cluster_triples
2367
+ return triples
2368
+
2369
+ def format_analysis_graph(self, relationship_graphs: Dict[str, nx.DiGraph] = None) -> str:
2370
+ """
2371
+ Call get_relation_analysis and format as a readable string per cluster.
2372
+
2373
+ Output example:
2374
+ Cluster: C1
2375
+ - A --uses--> B | Because ...
2376
+ - ...
2377
+ """
2378
+ triples = self._get_relation_analysis_graph(relationship_graphs)
2379
+ parts: list[str] = []
2380
+ for cluster_name, items in triples.items():
2381
+ parts.append(f"Cluster: {cluster_name}")
2382
+ if not items:
2383
+ parts.append(" (no relations)")
2384
+ continue
2385
+ for src, rel, dst, analysis in items:
2386
+ rel_clean = rel or "unspecified"
2387
+ analysis_clean = analysis or ""
2388
+ parts.append(f"- {src} --{rel_clean}--> {dst} | {analysis_clean}")
2389
+ return "\n".join(parts)
2390
+
2391
+ # ---------------------------- Cluster table generation ----------------------------
2392
+ @staticmethod
2393
+ def _normalize_cluster_table_payload(parsed: Any) -> Dict[str, Any]:
2394
+ """Return the one relation-table schema consumed by downstream writers.
2395
+
2396
+ The requested LLM schema is an object with ``comparison_dimensions``
2397
+ and ``table_data``. Some providers nevertheless return a bare list of
2398
+ rows. That common, information-preserving variant is normalized here;
2399
+ any other malformed structure is rejected so the batch retry mechanism
2400
+ can request a new response instead of letting a list reach
2401
+ ``format_analysis_table``.
2402
+ """
2403
+ if isinstance(parsed, Mapping):
2404
+ dimensions = parsed.get("comparison_dimensions")
2405
+ table_data = parsed.get("table_data")
2406
+ if dimensions is None and table_data is None and len(parsed) == 1:
2407
+ # Compatibility with providers that wrap the intended object
2408
+ # in one named field, while still requiring the inner schema.
2409
+ only_value = next(iter(parsed.values()))
2410
+ if isinstance(only_value, Mapping):
2411
+ dimensions = only_value.get("comparison_dimensions")
2412
+ table_data = only_value.get("table_data")
2413
+ elif isinstance(parsed, list):
2414
+ # A top-level list is recoverable only when every item is a table
2415
+ # row; dimensions can be derived from its explicit ``columns``
2416
+ # mapping or from direct row fields.
2417
+ dimensions = None
2418
+ table_data = parsed
2419
+ else:
2420
+ raise ValueError(
2421
+ "Cluster table JSON must be an object or a list of row objects; "
2422
+ f"received {type(parsed).__name__}."
2423
+ )
2424
+
2425
+ if not isinstance(table_data, list) or not table_data:
2426
+ raise ValueError("Cluster table 'table_data' must be a non-empty JSON list.")
2427
+ if not all(isinstance(row, Mapping) for row in table_data):
2428
+ raise ValueError("Every cluster-table row must be a JSON object.")
2429
+
2430
+ normalized_dimensions: list[str] = []
2431
+ if dimensions is not None:
2432
+ if not isinstance(dimensions, list):
2433
+ raise ValueError("Cluster table 'comparison_dimensions' must be a JSON list.")
2434
+ for dimension in dimensions:
2435
+ text = str(dimension or "").strip()
2436
+ if not text:
2437
+ raise ValueError("Cluster table dimensions must be non-empty strings.")
2438
+ if text not in normalized_dimensions:
2439
+ normalized_dimensions.append(text)
2440
+
2441
+ direct_row_reserved_keys = {"paper_id", "paper_title", "title", "columns"}
2442
+ for row in table_data:
2443
+ columns = row.get("columns")
2444
+ if columns is not None and not isinstance(columns, Mapping):
2445
+ raise ValueError("Each cluster-table row's 'columns' field must be an object.")
2446
+ candidate_columns = columns if isinstance(columns, Mapping) else {
2447
+ key: value
2448
+ for key, value in row.items()
2449
+ if key not in direct_row_reserved_keys
2450
+ }
2451
+ for dimension in candidate_columns:
2452
+ text = str(dimension or "").strip()
2453
+ if text and text not in normalized_dimensions:
2454
+ normalized_dimensions.append(text)
2455
+
2456
+ if not normalized_dimensions:
2457
+ raise ValueError(
2458
+ "Cluster table must provide at least one comparison dimension or row column."
2459
+ )
2460
+
2461
+ normalized_rows = []
2462
+ for row in table_data:
2463
+ raw_columns = row.get("columns")
2464
+ if isinstance(raw_columns, Mapping):
2465
+ column_source = raw_columns
2466
+ else:
2467
+ column_source = {
2468
+ key: value
2469
+ for key, value in row.items()
2470
+ if key not in direct_row_reserved_keys
2471
+ }
2472
+ paper_id = str(row.get("paper_id") or "").strip()
2473
+ paper_title = str(
2474
+ row.get("paper_title") or row.get("title") or paper_id or "Untitled paper"
2475
+ ).strip()
2476
+ normalized_rows.append(
2477
+ {
2478
+ "paper_id": paper_id,
2479
+ "paper_title": paper_title,
2480
+ "columns": {
2481
+ dimension: (
2482
+ "N/A"
2483
+ if column_source.get(dimension) is None
2484
+ else str(column_source.get(dimension)).strip() or "N/A"
2485
+ )
2486
+ for dimension in normalized_dimensions
2487
+ },
2488
+ }
2489
+ )
2490
+
2491
+ return {
2492
+ "comparison_dimensions": normalized_dimensions,
2493
+ "table_data": normalized_rows,
2494
+ }
2495
+
2496
+ @classmethod
2497
+ def _validate_cluster_table(cls, result: str, info_dict: dict) -> tuple[bool, dict]:
2498
+ """
2499
+ Validation function for cluster table generation.
2500
+ Extracts and validates JSON from the LLM response.
2501
+
2502
+ Returns:
2503
+ (True, parsed_json) if successful
2504
+ Raises:
2505
+ ValueError if parsing fails
2506
+ """
2507
+ try:
2508
+ parsed = extract_json(result)
2509
+ return True, cls._normalize_cluster_table_payload(parsed)
2510
+ except Exception as e:
2511
+ raise ValueError(f"Invalid cluster table response: {e}")
2512
+
2513
+ def generate_cluster_tables(self, clusters: List[Dict]):
2514
+ """
2515
+ Build comparison tables for all clusters using CLUSTER_TABLE_GENERATION in batch.
2516
+
2517
+ clusters: list of cluster dicts with keys 'cluster_name', 'papers' (list of dict with id/title),
2518
+ and optionally 'summary'.
2519
+
2520
+ Returns a dict {cluster_name: table_json} parsed from the LLM responses.
2521
+ Raises if any cluster still fails after retries.
2522
+ """
2523
+ pending_tasks = []
2524
+
2525
+ for cluster in clusters:
2526
+ cluster_name = cluster.get("cluster_name", "unknown_cluster")
2527
+ description = cluster.get("summary", "")
2528
+
2529
+ paper_blocks = []
2530
+ for p in cluster.get("papers", []):
2531
+ pid = p.get("id") or ""
2532
+ title = p.get("title") or ""
2533
+ try:
2534
+ keynote = self.get_paper_keynote(pid)
2535
+ except Exception as e:
2536
+ self.logger.warning(f"Failed to get keynote for paper {pid}: {e}. Using empty keynote.")
2537
+ keynote = ""
2538
+ paper_blocks.append(
2539
+ f"Paper ID: {pid}\nPaper Title: {title}\nKeynote: {keynote}\n"
2540
+ )
2541
+
2542
+ paper_content = "\n".join(paper_blocks)
2543
+ prompt = CLUSTER_TABLE_GENERATION.format(
2544
+ cluster_name=cluster_name,
2545
+ cluster_description=description,
2546
+ paper_content=paper_content,
2547
+ )
2548
+
2549
+ pending_tasks.append({
2550
+ "cluster_name": cluster_name,
2551
+ "prompt": prompt,
2552
+ })
2553
+
2554
+ max_retry = getattr(self.config.ModuleInfo.WorkAnalyzer, "cluster_table_max_retry", 3)
2555
+ temperature = getattr(self.config.ModuleInfo.WorkAnalyzer, "cluster_table_temperature", 0.3)
2556
+ # The table validator is deliberately strict about row objects. Ask
2557
+ # capable providers for an actual JSON object so the retry path is
2558
+ # reserved for schema/content failures rather than avoidable Markdown
2559
+ # table formatting drift.
2560
+ json_response_format = self._json_object_response_format()
2561
+
2562
+ prompts = [t["prompt"] for t in pending_tasks]
2563
+ cluster_names = [t["cluster_name"] for t in pending_tasks]
2564
+
2565
+ # Use batch_remote_chat_with_retry for cleaner retry logic
2566
+ parsed_results = self.chat_agent.batch_remote_chat_with_retry(
2567
+ prompts=prompts,
2568
+ validate_fn=self._validate_cluster_table,
2569
+ max_retry=max_retry,
2570
+ desc="Cluster table generation",
2571
+ temperature=temperature,
2572
+ future_timeout=300.0,
2573
+ response_format=json_response_format,
2574
+ )
2575
+
2576
+ # Map results back to cluster names
2577
+ results = {
2578
+ cluster_name: parsed_result
2579
+ for cluster_name, parsed_result in zip(cluster_names, parsed_results)
2580
+ }
2581
+
2582
+ self.relation_analysis_table = results
2583
+ return results
2584
+
2585
+ def format_analysis_table(self, cluster_tables: Dict[str, Dict] = None):
2586
+ """Render all cluster tables into a markdown string."""
2587
+ if cluster_tables is None:
2588
+ if self.relation_analysis_table is None:
2589
+ raise ValueError("Cluster tables not provided and not generated yet.")
2590
+ cluster_tables = self.relation_analysis_table
2591
+ parts = []
2592
+ for cluster_name, table_json in cluster_tables.items():
2593
+ table_json = self._normalize_cluster_table_payload(table_json)
2594
+ headers = ["Title"] + table_json.get("comparison_dimensions", [])
2595
+ md_table = "| " + " | ".join(headers) + " |\n"
2596
+ md_table += "| " + " | ".join(["---"] * len(headers)) + " |\n"
2597
+
2598
+ for row in table_json.get("table_data", []):
2599
+ cols = [row.get("paper_title", "")]
2600
+ for dim in table_json.get("comparison_dimensions", []):
2601
+ cols.append(row.get("columns", {}).get(dim, "N/A"))
2602
+ md_table += "| " + " | ".join(cols) + " |\n"
2603
+
2604
+ parts.append(f"Cluster: {cluster_name}\n{md_table}")
2605
+
2606
+ return "\n".join(parts)
2607
+
2608
+ # ---------------------------- Cache helpers ----------------------------
2609
+ def _cache_task_key(self) -> str:
2610
+ topic = getattr(self.config.BasicInfo, "topic", "")
2611
+ cache_token = getattr(self.config.BasicInfo, "cache_token", "")
2612
+ return get_hash(f"{topic}|{cache_token}|{self.cache_path}")
2613
+
2614
+ def _sh_projection_cache_token(self) -> str:
2615
+ inputs = self._sh_cluster_projection_inputs()
2616
+ if inputs is None:
2617
+ return "no_sh_projection"
2618
+ provenance, ledger, fingerprint = inputs
2619
+ reports = []
2620
+ for report in ledger.get("subhypotheses", []):
2621
+ if not isinstance(report, Mapping):
2622
+ continue
2623
+ reports.append(
2624
+ {
2625
+ "sub_hypothesis_id": report.get("sub_hypothesis_id", ""),
2626
+ "required_slots": report.get("required_slots", []),
2627
+ "covered_slots": report.get("covered_slots", []),
2628
+ "background_only_slots": report.get("background_only_slots", []),
2629
+ "missing_slots": report.get("missing_slots", []),
2630
+ "conclusion_admissibility": report.get(
2631
+ "conclusion_admissibility", {}
2632
+ ),
2633
+ }
2634
+ )
2635
+ return get_hash(
2636
+ json.dumps(
2637
+ {
2638
+ "schema_version": SH_CLUSTER_COVERAGE_SCHEMA_VERSION,
2639
+ "project_id": provenance.get("project_id", ""),
2640
+ "project_context_fingerprint": fingerprint,
2641
+ "ledger_reports": reports,
2642
+ "paper_annotations": {
2643
+ str(paper_id): records
2644
+ for paper_id, records in sorted(
2645
+ (provenance.get("paper_annotations") or {}).items()
2646
+ )
2647
+ },
2648
+ "graph_expansion_records": sorted(
2649
+ provenance.get("graph_expansion_records") or [],
2650
+ key=lambda record: json.dumps(
2651
+ record,
2652
+ ensure_ascii=False,
2653
+ sort_keys=True,
2654
+ ),
2655
+ ),
2656
+ },
2657
+ ensure_ascii=False,
2658
+ sort_keys=True,
2659
+ )
2660
+ )
2661
+
2662
+ def _cluster_cache_key(self, papers: List[str]) -> str:
2663
+ sorted_ids = sorted(papers)
2664
+ return get_hash(
2665
+ f"clusters_v3|{self._cache_task_key()}|{self._sh_projection_cache_token()}|"
2666
+ f"{'|'.join(sorted_ids)}"
2667
+ )
2668
+
2669
+ def _relation_graph_cache_key(self, clusters: List[Dict]) -> str:
2670
+ cluster_repr = []
2671
+ for c in clusters:
2672
+ name = c.get("cluster_name", "")
2673
+ ids = sorted([p.get("id", "") for p in c.get("papers", [])])
2674
+ cluster_repr.append(f"{name}:{'|'.join(ids)}")
2675
+ return get_hash(
2676
+ f"relation_graph_v3|{self._cache_task_key()}|{self._sh_projection_cache_token()}|"
2677
+ f"{'||'.join(sorted(cluster_repr))}"
2678
+ )
2679
+
2680
+ def _prune_cache(self, cache: dc.Cache, max_entries: int):
2681
+ if max_entries is None or max_entries <= 0:
2682
+ return
2683
+ try:
2684
+ # exclude internal keys starting with '__'
2685
+ data_keys = [k for k in cache.iterkeys() if not (isinstance(k, str) and k.startswith("__"))]
2686
+ if len(data_keys) <= max_entries:
2687
+ return
2688
+ keyed = []
2689
+ for k in data_keys:
2690
+ try:
2691
+ meta = cache.get(k, {}) or {}
2692
+ ts = meta.get("ts", 0)
2693
+ except Exception:
2694
+ ts = 0
2695
+ keyed.append((ts, k))
2696
+ keyed.sort()
2697
+ for _, k in keyed[: max(0, len(data_keys) - max_entries)]:
2698
+ try:
2699
+ del cache[k]
2700
+ except Exception:
2701
+ self.logger.warning(f"Failed to evict cache entry {k}")
2702
+ except Exception as e:
2703
+ self.logger.warning(f"Cache pruning skipped due to error: {e}")
2704
+
2705
+ def _load_cached_clusters(self, papers: List[str]):
2706
+ if not getattr(self.config.ModuleInfo.WorkAnalyzer, "cluster_cache_enabled", True):
2707
+ return None
2708
+ key = self._cluster_cache_key(papers)
2709
+ if key in self.cluster_cache:
2710
+ try:
2711
+ payload = self.cluster_cache[key]
2712
+ return payload.get("data")
2713
+ except Exception as e:
2714
+ self.logger.warning(f"Failed to load cached clusters: {e}")
2715
+ return None
2716
+
2717
+ def _store_cached_clusters(self, papers: List[str], clusters: List[Dict]):
2718
+ if not getattr(self.config.ModuleInfo.WorkAnalyzer, "cluster_cache_enabled", True):
2719
+ return
2720
+ key = self._cluster_cache_key(papers)
2721
+ self.cluster_cache[key] = {"data": clusters, "ts": time.time()}
2722
+ max_entries = getattr(self.config.ModuleInfo.WorkAnalyzer, "cluster_cache_max_entries", 5)
2723
+ self._prune_cache(self.cluster_cache, max_entries)
2724
+
2725
+ def _load_cached_relation_graph(self, clusters: List[Dict]):
2726
+ if not getattr(self.config.ModuleInfo.WorkAnalyzer, "relation_graph_cache_enabled", True):
2727
+ return None
2728
+ key = self._relation_graph_cache_key(clusters)
2729
+ if key in self.relation_graph_cache:
2730
+ try:
2731
+ payload = self.relation_graph_cache[key]
2732
+ return payload.get("data")
2733
+ except Exception as e:
2734
+ self.logger.warning(f"Failed to load cached relationship graph: {e}")
2735
+ return None
2736
+
2737
+ def _store_cached_relation_graph(self, clusters: List[Dict], graphs: Dict[str, nx.DiGraph]):
2738
+ if not getattr(self.config.ModuleInfo.WorkAnalyzer, "relation_graph_cache_enabled", True):
2739
+ return
2740
+ key = self._relation_graph_cache_key(clusters)
2741
+ self.relation_graph_cache[key] = {"data": graphs, "ts": time.time()}
2742
+ max_entries = getattr(self.config.ModuleInfo.WorkAnalyzer, "relation_graph_cache_max_entries", 5)
2743
+ self._prune_cache(self.relation_graph_cache, max_entries)
2744
+
2745
+
2746
+ if __name__ == "__main__":
2747
+ root_ids = [
2748
+ # "arXiv:1706.03762", # Transformer
2749
+ "fa72afa9b2cbc8f0d7b05d52548906610ffbb9c5"
2750
+ ]