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,1817 @@
1
+ """Evidence-bounded visual enhancement for completed survey manuscripts.
2
+
3
+ The module is intentionally a post-save, fail-open companion: it never edits the
4
+ canonical ``survey.md`` and a provider or quality-control failure merely omits a
5
+ figure from ``survey_visual.md``. Figures are planned from exact section
6
+ paragraphs, saved beside ``survey.md``, and recorded in an auditable manifest.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import io
12
+ import json
13
+ import re
14
+ import textwrap
15
+ import unicodedata
16
+ from collections import Counter
17
+ from dataclasses import asdict, dataclass, field
18
+ from pathlib import Path
19
+ from typing import Any, Callable, Mapping, Sequence
20
+
21
+ from PIL import Image, ImageColor, ImageDraw, ImageFont
22
+
23
+ from src.config import load_config
24
+ from src.llm.image_generation import (
25
+ MODEL_ADAPTERS,
26
+ DashScopeImageClient,
27
+ resolve_image_generation_settings,
28
+ )
29
+ from src.llm.vision import QwenVisionClient, resolve_vision_settings
30
+ from src.pipeline.paper_identity import canonical_paper_id
31
+
32
+ from modules.visual_prompts import (
33
+ ARTICLE_STYLE_PLANNER,
34
+ FIGURE_TYPE_LAYOUT_INSTRUCTIONS,
35
+ IMAGE_PROMPT,
36
+ VISUAL_BRIEF_BUILDER,
37
+ VISUAL_BRIEF_REPAIR,
38
+ VISUAL_CANDIDATE_PLANNER,
39
+ VISION_QC_PROMPT,
40
+ json_schema_text,
41
+ )
42
+ from utils.utils import extract_json
43
+
44
+
45
+ _CJK_PATTERN = re.compile(r"[\u3400-\u9fff\uf900-\ufaff]")
46
+ _NON_LATIN_SCRIPT_PATTERN = re.compile(
47
+ r"[\u0400-\u052f\u0530-\u058f\u0590-\u08ff\u0900-\u1fff\u3040-\u30ff\uac00-\ud7af]"
48
+ )
49
+ _HEX_PATTERN = re.compile(r"^#[0-9A-Fa-f]{6}$")
50
+ _PAPER_ID_PATTERN = re.compile(r"\bW\d{4,}\b")
51
+ _NUMBERED_CITATION_PATTERN = re.compile(r"\[(\d+)\]")
52
+ _HEADING_PATTERN = re.compile(r"^(#{1,6})\s+(.+?)\s*$")
53
+ _REFERENCE_MARKER_PATTERN = re.compile(
54
+ r"^(?:references?|bibliography|reference\s+list)\s*:?\s*$",
55
+ re.IGNORECASE,
56
+ )
57
+
58
+ DEFAULT_FIGURE_TYPES = (
59
+ "overview_framework",
60
+ "mechanism",
61
+ "causal_pathway",
62
+ "evidence_to_inference",
63
+ "method_comparison",
64
+ "multiscale_synthesis",
65
+ "conceptual_workflow",
66
+ "research_landscape",
67
+ "future_roadmap",
68
+ )
69
+
70
+ DEFAULT_STYLE = {
71
+ "visual_language_en": (
72
+ "Refined editorial scientific figures with clean vector-like forms, "
73
+ "subtle scientific rendering, ample negative space, and print-ready hierarchy."
74
+ ),
75
+ "palette": {
76
+ "background": "#F6F4EF",
77
+ "ink": "#26313A",
78
+ "primary": "#326B7C",
79
+ "secondary": "#5D7188",
80
+ "accent": "#C77A37",
81
+ "uncertainty": "#9AA2A8",
82
+ },
83
+ }
84
+
85
+ DEFAULT_OVERLAY_LABELS = (
86
+ "Evidence-supported relationship",
87
+ "Stated uncertainty",
88
+ )
89
+
90
+
91
+ @dataclass(frozen=True)
92
+ class MarkdownParagraph:
93
+ text: str
94
+ start: int
95
+ end: int
96
+
97
+
98
+ @dataclass(frozen=True)
99
+ class MarkdownSection:
100
+ index: int
101
+ title: str
102
+ start: int
103
+ end: int
104
+ paragraphs: tuple[MarkdownParagraph, ...]
105
+
106
+
107
+ @dataclass(frozen=True)
108
+ class VisualCandidate:
109
+ section_index: int
110
+ figure_type: str
111
+ source_paragraph_indices: tuple[int, ...]
112
+ insert_after_paragraph: int
113
+ main_message_en: str
114
+ composition_en: str
115
+ importance: int
116
+ entities_en: tuple[str, ...] = ()
117
+
118
+
119
+ @dataclass(frozen=True)
120
+ class VisualEvidencePath:
121
+ sub_hypothesis_id: str
122
+ slot_name: str
123
+ paper_id: str
124
+ support_kind: str
125
+
126
+
127
+ @dataclass(frozen=True)
128
+ class VisualRelation:
129
+ relation_en: str
130
+ source_paragraph_index: int
131
+ source_quote: str
132
+ support_kind: str
133
+ evidence_paper_ids: tuple[str, ...]
134
+ evidence_paths: tuple[VisualEvidencePath, ...]
135
+
136
+
137
+ @dataclass(frozen=True)
138
+ class VisualBrief:
139
+ figure_id: str
140
+ figure_number: int
141
+ figure_type: str
142
+ section_index: int
143
+ section_title: str
144
+ source_paragraph_indices: tuple[int, ...]
145
+ insert_after_paragraph: int
146
+ main_message_en: str
147
+ relations: tuple[VisualRelation, ...]
148
+ entities_en: tuple[str, ...]
149
+ uncertainties_en: tuple[str, ...]
150
+ composition_en: str
151
+ allowed_overlay_labels_en: tuple[str, ...]
152
+ caption_en: str
153
+ alt_text_en: str
154
+ source_paper_ids: tuple[str, ...]
155
+
156
+
157
+ @dataclass(frozen=True)
158
+ class VisualStyleProfile:
159
+ visual_language_en: str
160
+ palette: dict[str, str]
161
+ source: str = "llm"
162
+
163
+
164
+ @dataclass
165
+ class FigureAsset:
166
+ brief: VisualBrief
167
+ file_name: str
168
+ prompt: str
169
+ model: str
170
+ provider: str
171
+ quality_status: str
172
+ qc_notes: list[str] = field(default_factory=list)
173
+ revised_prompt: str = ""
174
+ generation_attempts: int = 1
175
+
176
+
177
+ def _read(value: Any, name: str, default: Any = None) -> Any:
178
+ """Read a dict, OmegaConf node, or namespace with one tolerant helper."""
179
+
180
+ if isinstance(value, Mapping):
181
+ return value.get(name, default)
182
+ getter = getattr(value, "get", None)
183
+ if callable(getter):
184
+ try:
185
+ return getter(name, default)
186
+ except (AttributeError, TypeError):
187
+ pass
188
+ return getattr(value, name, default)
189
+
190
+
191
+ def _as_mapping(value: Any) -> dict[str, Any]:
192
+ if isinstance(value, Mapping):
193
+ return {str(key): item for key, item in value.items()}
194
+ items = getattr(value, "items", None)
195
+ if callable(items):
196
+ try:
197
+ return {str(key): item for key, item in items()}
198
+ except TypeError:
199
+ return {}
200
+ return {}
201
+
202
+
203
+ def _clean_text(value: Any, *, limit: int = 4000) -> str:
204
+ text = re.sub(r"\s+", " ", str(value or "")).strip()
205
+ return text[:limit].strip()
206
+
207
+
208
+ def _english_text(value: Any, *, limit: int = 4000) -> str:
209
+ """Return reader-facing English-only text, or an empty string if invalid."""
210
+
211
+ text = _clean_text(value, limit=limit)
212
+ if not text or _CJK_PATTERN.search(text) or _NON_LATIN_SCRIPT_PATTERN.search(text):
213
+ return ""
214
+ # This is intentionally a conservative script-level guard, not a language
215
+ # detector: prompt and QC still request English, while this rejects the
216
+ # common non-English scripts before any reader-facing Markdown is written.
217
+ return text if re.search(r"[A-Za-z]", text) else ""
218
+
219
+
220
+ def _english_strings(value: Any, *, maximum: int) -> tuple[str, ...]:
221
+ values = value if isinstance(value, Sequence) and not isinstance(value, (str, bytes)) else [value]
222
+ cleaned: list[str] = []
223
+ for item in values:
224
+ text = _english_text(item, limit=300)
225
+ if text and text not in cleaned:
226
+ cleaned.append(text)
227
+ if len(cleaned) >= maximum:
228
+ break
229
+ return tuple(cleaned)
230
+
231
+
232
+ def _safe_slug(value: str, *, fallback: str) -> str:
233
+ slug = re.sub(r"[^a-z0-9]+", "_", str(value or "").lower()).strip("_")
234
+ return slug[:48] or fallback
235
+
236
+
237
+ def _reference_ids(references: Any) -> tuple[str, ...]:
238
+ """Extract canonical identifiers without exposing full citations to prompts."""
239
+
240
+ if references is None:
241
+ return ()
242
+ if isinstance(references, Mapping):
243
+ references = references.get("references", references.get("items", references))
244
+ if isinstance(references, (str, bytes)) or not isinstance(references, Sequence):
245
+ references = [references]
246
+ identifiers: list[str] = []
247
+ for reference in references:
248
+ if isinstance(reference, Mapping):
249
+ value = next(
250
+ (
251
+ reference.get(key)
252
+ for key in ("paper_id", "paperId", "openalex_id", "work_id", "id")
253
+ if reference.get(key)
254
+ ),
255
+ "",
256
+ )
257
+ else:
258
+ value = reference
259
+ identifier = canonical_paper_id(_clean_text(value, limit=160))
260
+ if identifier and identifier not in identifiers:
261
+ identifiers.append(identifier)
262
+ return tuple(identifiers)
263
+
264
+
265
+ def _numbered_reference_ids(text: str, reference_ids: Sequence[str]) -> tuple[str, ...]:
266
+ """Resolve reader-visible ``[n]`` citations against the supplied list."""
267
+
268
+ resolved: list[str] = []
269
+ for raw_index in _NUMBERED_CITATION_PATTERN.findall(text):
270
+ index = int(raw_index) - 1
271
+ if 0 <= index < len(reference_ids) and reference_ids[index] not in resolved:
272
+ resolved.append(reference_ids[index])
273
+ return tuple(resolved)
274
+
275
+
276
+ class SurveyVisualizer:
277
+ """Plan, generate, audit, and insert optional figure companions for a survey."""
278
+
279
+ def __init__(
280
+ self,
281
+ config: Any,
282
+ chat_agent: Any,
283
+ logger: Any,
284
+ *,
285
+ image_client_factory: Callable[..., Any] = DashScopeImageClient,
286
+ vision_client_factory: Callable[..., Any] = QwenVisionClient,
287
+ project_config: Any = None,
288
+ ) -> None:
289
+ self.config = config
290
+ self.chat_agent = chat_agent
291
+ self.logger = logger
292
+ self.settings = _read(_read(config, "ModuleInfo", {}), "SurveyVisualization", {})
293
+ self.image_client_factory = image_client_factory
294
+ self.vision_client_factory = vision_client_factory
295
+ self.project_config = project_config or load_config()
296
+ self._brief_rejections: list[dict[str, Any]] = []
297
+
298
+ @property
299
+ def enabled(self) -> bool:
300
+ return bool(_read(self.settings, "enabled", False))
301
+
302
+ def run(
303
+ self,
304
+ final_survey: str,
305
+ *,
306
+ survey_path: str | Path,
307
+ references: Sequence[Any] | Mapping[str, Any] | None = None,
308
+ evidence_plan: Mapping[str, Any] | None = None,
309
+ outline: Mapping[str, Any] | None = None,
310
+ claim_traceability: Mapping[str, Any] | None = None,
311
+ ) -> dict[str, Any]:
312
+ """Write optional visual artifacts; caller owns the fail-open boundary."""
313
+
314
+ if not self.enabled:
315
+ return {"status": "disabled", "figure_count": 0}
316
+
317
+ self._brief_rejections = []
318
+ survey_path = Path(survey_path)
319
+ output_dir = survey_path.parent
320
+ reference_ids = _reference_ids(references)
321
+ outline = _as_mapping(outline)
322
+ evidence_plan = _as_mapping(evidence_plan)
323
+ claim_traceability = _as_mapping(claim_traceability)
324
+ sections, survey_title = self._parse_sections(final_survey)
325
+ if not sections:
326
+ self.logger.warning("Survey visualisation skipped: no writable Markdown sections found.")
327
+ return self._write_companion(
328
+ final_survey, survey_path, [], VisualStyleProfile(
329
+ visual_language_en=str(DEFAULT_STYLE["visual_language_en"]),
330
+ palette=dict(DEFAULT_STYLE["palette"]), source="fallback_no_sections",
331
+ ), status="skipped_no_sections", reason="no writable Markdown sections", outline=outline,
332
+ evidence_plan=evidence_plan, reference_ids=reference_ids,
333
+ )
334
+
335
+ candidates = self._select_candidates(sections, survey_title, outline)
336
+ if not candidates:
337
+ self.logger.warning("Survey visualisation skipped: no suitable visual candidates were selected.")
338
+ return self._write_companion(
339
+ final_survey, survey_path, [], VisualStyleProfile(
340
+ visual_language_en=str(DEFAULT_STYLE["visual_language_en"]),
341
+ palette=dict(DEFAULT_STYLE["palette"]), source="fallback_no_candidates",
342
+ ), status="skipped_no_candidates", reason="no suitable visual candidates", outline=outline,
343
+ evidence_plan=evidence_plan, reference_ids=reference_ids,
344
+ )
345
+
346
+ briefs = self._build_briefs(
347
+ candidates,
348
+ sections,
349
+ survey_title,
350
+ evidence_plan,
351
+ outline,
352
+ claim_traceability,
353
+ reference_ids,
354
+ )
355
+ if not briefs:
356
+ self.logger.warning("Survey visualisation skipped: no evidence-bounded visual brief was accepted.")
357
+ return self._write_companion(
358
+ final_survey, survey_path, [], VisualStyleProfile(
359
+ visual_language_en=str(DEFAULT_STYLE["visual_language_en"]),
360
+ palette=dict(DEFAULT_STYLE["palette"]), source="fallback_no_briefs",
361
+ ), status="skipped_no_briefs", reason="no evidence-bounded visual brief", outline=outline,
362
+ evidence_plan=evidence_plan, reference_ids=reference_ids,
363
+ )
364
+
365
+ style = self._build_style_profile(survey_title, briefs)
366
+ assets: list[FigureAsset] = []
367
+ skipped_figures: list[dict[str, str]] = []
368
+ for brief in briefs:
369
+ try:
370
+ asset = self._render_figure(brief, style, output_dir)
371
+ except Exception as exc: # Per-figure failure is explicitly non-blocking.
372
+ self.logger.warning(
373
+ "Survey visualisation skipped figure %s after provider/QC failure: %s",
374
+ brief.figure_id,
375
+ exc,
376
+ )
377
+ skipped_figures.append(
378
+ {
379
+ "figure_id": brief.figure_id,
380
+ "reason": str(exc),
381
+ }
382
+ )
383
+ continue
384
+ assets.append(asset)
385
+
386
+ if not assets:
387
+ return self._write_companion(
388
+ final_survey, survey_path, [], style, status="completed_without_figures",
389
+ reason="all generated candidates were skipped", outline=outline, skipped_figures=skipped_figures,
390
+ evidence_plan=evidence_plan, reference_ids=reference_ids,
391
+ )
392
+
393
+ result = self._write_companion(
394
+ self._insert_markdown(final_survey, sections, assets), survey_path, assets, style,
395
+ status="completed", reason="", outline=outline, evidence_plan=evidence_plan,
396
+ skipped_figures=skipped_figures, reference_ids=reference_ids,
397
+ )
398
+ self.logger.info(
399
+ "Survey visualisation completed: %s figure(s), Markdown=%s",
400
+ len(assets),
401
+ result["survey_visual_path"],
402
+ )
403
+ return result
404
+
405
+ def _write_companion(
406
+ self,
407
+ visual_markdown: str,
408
+ survey_path: Path,
409
+ assets: Sequence[FigureAsset],
410
+ style: VisualStyleProfile,
411
+ *,
412
+ status: str,
413
+ reason: str,
414
+ outline: Mapping[str, Any],
415
+ evidence_plan: Mapping[str, Any],
416
+ reference_ids: Sequence[str] = (),
417
+ skipped_figures: Sequence[Mapping[str, str]] = (),
418
+ ) -> dict[str, Any]:
419
+ """Always materialise the stable companion interface, including zero-figure runs."""
420
+
421
+ output_dir = survey_path.parent
422
+ image_location = str(
423
+ _read(self.settings, "image_output_location", "survey_output_directory")
424
+ or "survey_output_directory"
425
+ )
426
+ if image_location != "survey_output_directory":
427
+ self.logger.warning(
428
+ "Survey visualisation ignores image_output_location=%s; figures must remain beside survey.md.",
429
+ image_location,
430
+ )
431
+ output_mode = str(_read(self.settings, "output_mode", "companion_markdown") or "companion_markdown")
432
+ write_back = bool(_read(self.settings, "write_back_to_survey_md", False))
433
+ if output_mode not in {"companion_markdown", "inline_markdown"}:
434
+ self.logger.warning(
435
+ "Unknown survey visual output_mode=%s; using companion_markdown.",
436
+ output_mode,
437
+ )
438
+ if output_mode == "inline_markdown":
439
+ write_back = True
440
+ visual_path = survey_path if write_back else output_dir / "survey_visual.md"
441
+ visual_path.write_text(visual_markdown, encoding="utf-8")
442
+ manifest_path = output_dir / "survey_visual_manifest.json"
443
+ manifest = self._build_manifest(
444
+ survey_path=survey_path,
445
+ visual_path=visual_path,
446
+ style=style,
447
+ assets=assets,
448
+ status=status,
449
+ reason=reason,
450
+ outline=outline,
451
+ evidence_plan=evidence_plan,
452
+ reference_ids=reference_ids,
453
+ rejected_figures=self._brief_rejections,
454
+ skipped_figures=skipped_figures,
455
+ )
456
+ manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
457
+ return {
458
+ "status": status,
459
+ "reason": reason,
460
+ "figure_count": len(assets),
461
+ "survey_visual_path": str(visual_path),
462
+ "manifest_path": str(manifest_path),
463
+ "style_profile": asdict(style),
464
+ }
465
+
466
+ def _parse_sections(self, survey: str) -> tuple[list[MarkdownSection], str]:
467
+ title = "Survey"
468
+ lines = survey.splitlines(keepends=True)
469
+ offsets: list[int] = []
470
+ offset = 0
471
+ for line in lines:
472
+ offsets.append(offset)
473
+ offset += len(line)
474
+
475
+ headings: list[tuple[int, int, str]] = []
476
+ for index, line in enumerate(lines):
477
+ match = _HEADING_PATTERN.match(line.strip())
478
+ if not match:
479
+ continue
480
+ level = len(match.group(1))
481
+ heading = _clean_text(match.group(2), limit=300)
482
+ if level == 1 and title == "Survey":
483
+ title = heading or title
484
+ if level == 2:
485
+ headings.append((index, level, heading))
486
+
487
+ sections: list[MarkdownSection] = []
488
+ for section_index, (line_index, _level, heading) in enumerate(headings, start=1):
489
+ if heading.lower() in {"references", "bibliography", "reference list"}:
490
+ break
491
+ next_line_index = (
492
+ headings[section_index][0]
493
+ if section_index < len(headings)
494
+ else len(lines)
495
+ )
496
+ paragraphs = self._paragraphs_from_lines(
497
+ lines,
498
+ offsets,
499
+ line_index + 1,
500
+ next_line_index,
501
+ )
502
+ if paragraphs:
503
+ sections.append(
504
+ MarkdownSection(
505
+ index=section_index,
506
+ title=heading or f"Section {section_index}",
507
+ start=offsets[line_index],
508
+ end=offsets[next_line_index] if next_line_index < len(offsets) else len(survey),
509
+ paragraphs=tuple(paragraphs),
510
+ )
511
+ )
512
+ return sections, title
513
+
514
+ @staticmethod
515
+ def _paragraphs_from_lines(
516
+ lines: Sequence[str],
517
+ offsets: Sequence[int],
518
+ start: int,
519
+ end: int,
520
+ ) -> list[MarkdownParagraph]:
521
+ paragraphs: list[MarkdownParagraph] = []
522
+ buffered: list[str] = []
523
+ paragraph_start: int | None = None
524
+ paragraph_end: int | None = None
525
+
526
+ def flush() -> None:
527
+ nonlocal buffered, paragraph_start, paragraph_end
528
+ text = _clean_text(" ".join(buffered), limit=12000)
529
+ if text and paragraph_start is not None and paragraph_end is not None:
530
+ if not text.startswith("[[SH_CLAIM_TRACE") and not text.startswith("[[/SH_CLAIM_TRACE"):
531
+ paragraphs.append(MarkdownParagraph(text=text, start=paragraph_start, end=paragraph_end))
532
+ buffered = []
533
+ paragraph_start = None
534
+ paragraph_end = None
535
+
536
+ for line_index in range(start, end):
537
+ raw = lines[line_index]
538
+ stripped = raw.strip()
539
+ if not stripped:
540
+ flush()
541
+ continue
542
+ if _REFERENCE_MARKER_PATTERN.match(stripped):
543
+ flush()
544
+ break
545
+ if _HEADING_PATTERN.match(stripped):
546
+ flush()
547
+ continue
548
+ if paragraph_start is None:
549
+ paragraph_start = offsets[line_index]
550
+ buffered.append(stripped)
551
+ paragraph_end = offsets[line_index] + len(raw.rstrip("\r\n"))
552
+ flush()
553
+ return paragraphs
554
+
555
+ def _select_candidates(
556
+ self,
557
+ sections: Sequence[MarkdownSection],
558
+ survey_title: str,
559
+ outline: Mapping[str, Any],
560
+ ) -> list[VisualCandidate]:
561
+ allowed_types = tuple(
562
+ str(item).strip()
563
+ for item in (_read(self.settings, "allowed_figure_types", DEFAULT_FIGURE_TYPES) or DEFAULT_FIGURE_TYPES)
564
+ if str(item).strip() in DEFAULT_FIGURE_TYPES
565
+ ) or DEFAULT_FIGURE_TYPES
566
+ min_figures = max(1, int(_read(self.settings, "min_figures", 3) or 3))
567
+ max_figures = max(1, int(_read(self.settings, "max_figures", 5) or 5))
568
+ min_figures = min(min_figures, max_figures)
569
+ max_context_per_section = max(800, int(_read(self.settings, "planner_section_max_chars", 6000) or 6000))
570
+ section_texts: list[str] = []
571
+ for section in sections:
572
+ paragraphs = "\n".join(
573
+ f"[{index}] {paragraph.text}"
574
+ for index, paragraph in enumerate(section.paragraphs, start=1)
575
+ )
576
+ section_texts.append(
577
+ f"SECTION {section.index}: {section.title}\n{paragraphs[:max_context_per_section]}"
578
+ )
579
+
580
+ schema = {
581
+ "type": "object",
582
+ "properties": {
583
+ "candidates": {
584
+ "type": "array",
585
+ "items": {
586
+ "type": "object",
587
+ "properties": {
588
+ "section_index": {"type": "integer"},
589
+ "figure_type": {"type": "string"},
590
+ "source_paragraph_indices": {"type": "array", "items": {"type": "integer"}},
591
+ "insert_after_paragraph": {"type": "integer"},
592
+ "main_message_en": {"type": "string"},
593
+ "composition_en": {"type": "string"},
594
+ "entities_en": {"type": "array", "items": {"type": "string"}},
595
+ "importance": {"type": "integer"},
596
+ },
597
+ "required": [
598
+ "section_index", "figure_type", "source_paragraph_indices",
599
+ "insert_after_paragraph", "main_message_en", "composition_en", "importance",
600
+ ],
601
+ "additionalProperties": False,
602
+ },
603
+ }
604
+ },
605
+ "required": ["candidates"],
606
+ "additionalProperties": False,
607
+ }
608
+ prompt = VISUAL_CANDIDATE_PLANNER.format(
609
+ allowed_figure_types=", ".join(allowed_types),
610
+ survey_title=survey_title,
611
+ outline_context=self._outline_context(outline),
612
+ sections="\n\n".join(section_texts),
613
+ numeric_chart_policy=(
614
+ "Programmatic numeric charts are enabled only when structured data are supplied."
615
+ if bool(_read(self.settings, "allow_generated_numeric_charts", False))
616
+ else "Generated numeric charts are disabled; select only conceptual figures."
617
+ ),
618
+ min_figures=min_figures,
619
+ max_figures=max_figures,
620
+ schema=json_schema_text(schema),
621
+ )
622
+ try:
623
+ payload = self._request_json(prompt, max_output_tokens=3000)
624
+ except Exception as exc:
625
+ self.logger.warning("Survey visual candidate planner failed: %s", exc)
626
+ return self._fallback_candidates(sections, allowed_types, max_figures)
627
+
628
+ section_map = {section.index: section for section in sections}
629
+ accepted: list[VisualCandidate] = []
630
+ seen_section_types: set[tuple[int, str]] = set()
631
+ raw_candidates = _read(payload, "candidates", [])
632
+ if not isinstance(raw_candidates, Sequence) or isinstance(raw_candidates, (str, bytes)):
633
+ raw_candidates = []
634
+ for candidate_number, raw in enumerate(raw_candidates, start=1):
635
+ item = _as_mapping(raw)
636
+ raw_section_index = item.get("section_index")
637
+ raw_figure_type = _clean_text(item.get("figure_type"), limit=120)
638
+ candidate_id = f"candidate_{candidate_number:02d}_{_safe_slug(raw_figure_type, fallback='unknown')}"
639
+
640
+ def reject_candidate(reason: str) -> None:
641
+ rejection = {
642
+ "figure_id": candidate_id,
643
+ "section_index": raw_section_index,
644
+ "figure_type": raw_figure_type,
645
+ "reasons": [reason],
646
+ "repair_attempted": False,
647
+ "stage": "candidate_selection",
648
+ }
649
+ self._brief_rejections.append(rejection)
650
+ self.logger.warning(
651
+ "Survey visual candidate rejected (%s): %s",
652
+ candidate_id,
653
+ reason,
654
+ )
655
+
656
+ try:
657
+ section_index = int(item.get("section_index"))
658
+ section = section_map[section_index]
659
+ figure_type = str(item.get("figure_type") or "").strip()
660
+ paragraph_indices = tuple(sorted({int(index) for index in item.get("source_paragraph_indices", [])}))
661
+ insert_after = int(item.get("insert_after_paragraph"))
662
+ importance = max(1, min(5, int(item.get("importance", 3))))
663
+ except (KeyError, TypeError, ValueError):
664
+ reject_candidate("invalid section_index, figure_type, paragraph indices, or insert_after_paragraph")
665
+ continue
666
+ if figure_type not in allowed_types or not paragraph_indices:
667
+ reject_candidate("figure_type is not allowed or source_paragraph_indices is empty")
668
+ continue
669
+ candidate_entities = _english_strings(item.get("entities_en"), maximum=8)
670
+ if len(paragraph_indices) < 2 and len(candidate_entities) < 3:
671
+ # A single paragraph is acceptable only when the planner
672
+ # identifies enough distinct entities to justify integration.
673
+ reject_candidate(
674
+ "candidate requires at least two source paragraphs or three English entities"
675
+ )
676
+ continue
677
+ if any(index < 1 or index > len(section.paragraphs) for index in paragraph_indices):
678
+ reject_candidate("source_paragraph_indices contains an out-of-range paragraph")
679
+ continue
680
+ if insert_after < 1 or insert_after > len(section.paragraphs):
681
+ reject_candidate("insert_after_paragraph is outside the selected section")
682
+ continue
683
+ main_message = _english_text(item.get("main_message_en"), limit=700)
684
+ composition = _english_text(item.get("composition_en"), limit=700)
685
+ entities = candidate_entities
686
+ if not main_message or not composition:
687
+ reject_candidate("main_message_en or composition_en is missing or non-English")
688
+ continue
689
+ key = (section_index, figure_type)
690
+ if key in seen_section_types:
691
+ reject_candidate("duplicate section_index and figure_type")
692
+ continue
693
+ seen_section_types.add(key)
694
+ accepted.append(
695
+ VisualCandidate(
696
+ section_index=section_index,
697
+ figure_type=figure_type,
698
+ source_paragraph_indices=paragraph_indices,
699
+ insert_after_paragraph=insert_after,
700
+ main_message_en=main_message,
701
+ composition_en=composition,
702
+ importance=importance,
703
+ entities_en=entities,
704
+ )
705
+ )
706
+
707
+ accepted.sort(key=lambda candidate: (-candidate.importance, candidate.section_index))
708
+ return accepted[:max_figures]
709
+
710
+ @staticmethod
711
+ def _fallback_candidates(
712
+ sections: Sequence[MarkdownSection],
713
+ allowed_types: Sequence[str],
714
+ max_figures: int,
715
+ ) -> list[VisualCandidate]:
716
+ """A modest fail-open fallback when the visual editor response is unavailable."""
717
+
718
+ candidates: list[VisualCandidate] = []
719
+ if sections and "overview_framework" in allowed_types:
720
+ section = sections[0]
721
+ source_indices = tuple(range(1, min(3, len(section.paragraphs)) + 1))
722
+ if source_indices:
723
+ candidates.append(
724
+ VisualCandidate(
725
+ section_index=section.index,
726
+ figure_type="overview_framework",
727
+ source_paragraph_indices=source_indices,
728
+ insert_after_paragraph=source_indices[-1],
729
+ main_message_en="A conceptual overview of the relationships introduced in this section.",
730
+ composition_en="Use a layered overview with a clear reading direction and reserved negative space.",
731
+ importance=3,
732
+ )
733
+ )
734
+ return candidates[:max_figures]
735
+
736
+ def _build_briefs(
737
+ self,
738
+ candidates: Sequence[VisualCandidate],
739
+ sections: Sequence[MarkdownSection],
740
+ survey_title: str,
741
+ evidence_plan: Mapping[str, Any],
742
+ outline: Mapping[str, Any],
743
+ claim_traceability: Mapping[str, Any],
744
+ reference_ids: Sequence[str] = (),
745
+ ) -> list[VisualBrief]:
746
+ section_map = {section.index: section for section in sections}
747
+ evidence_context = self._evidence_context(
748
+ evidence_plan,
749
+ include_gaps=bool(_read(self.settings, "show_explicit_evidence_gaps", True)),
750
+ )
751
+ briefs: list[VisualBrief] = []
752
+ for figure_number, candidate in enumerate(candidates, start=1):
753
+ section = section_map[candidate.section_index]
754
+ source_paragraphs = [section.paragraphs[index - 1].text for index in candidate.source_paragraph_indices]
755
+ schema = {
756
+ "type": "object",
757
+ "properties": {
758
+ "main_message_en": {"type": "string"},
759
+ "relations": {
760
+ "type": "array",
761
+ "items": {
762
+ "type": "object",
763
+ "properties": {
764
+ "relation_en": {"type": "string"},
765
+ "source_paragraph_index": {"type": "integer"},
766
+ "source_quote": {"type": "string"},
767
+ },
768
+ "required": ["relation_en", "source_paragraph_index", "source_quote"],
769
+ "additionalProperties": False,
770
+ },
771
+ },
772
+ "entities_en": {"type": "array", "items": {"type": "string"}},
773
+ "uncertainties_en": {"type": "array", "items": {"type": "string"}},
774
+ "composition_en": {"type": "string"},
775
+ "allowed_overlay_labels_en": {"type": "array", "items": {"type": "string"}},
776
+ "caption_en": {"type": "string"},
777
+ "alt_text_en": {"type": "string"},
778
+ },
779
+ "required": [
780
+ "main_message_en", "relations", "entities_en", "uncertainties_en",
781
+ "composition_en", "allowed_overlay_labels_en", "caption_en", "alt_text_en",
782
+ ],
783
+ "additionalProperties": False,
784
+ }
785
+ paragraph_block = "\n\n".join(
786
+ f"Paragraph {index}: {paragraph}"
787
+ for index, paragraph in zip(candidate.source_paragraph_indices, source_paragraphs)
788
+ )
789
+ prompt = VISUAL_BRIEF_BUILDER.format(
790
+ survey_title=survey_title,
791
+ section_title=section.title,
792
+ figure_type=candidate.figure_type,
793
+ proposed_message=candidate.main_message_en,
794
+ outline_context=self._outline_context(outline, section_title=section.title),
795
+ source_paragraphs=paragraph_block,
796
+ evidence_context=evidence_context,
797
+ schema=json_schema_text(schema),
798
+ )
799
+ payload: Any = {}
800
+ rejection_reasons: tuple[str, ...] = ()
801
+ initial_failure = ""
802
+ try:
803
+ payload = self._request_json(prompt, max_output_tokens=2400)
804
+ brief, rejection_reasons = self._brief_from_payload_with_reasons(
805
+ payload,
806
+ candidate,
807
+ section,
808
+ figure_number,
809
+ source_paragraphs,
810
+ evidence_plan,
811
+ outline,
812
+ claim_traceability,
813
+ reference_ids,
814
+ )
815
+ except Exception as exc:
816
+ brief = None
817
+ initial_failure = f"initial brief request/parse failed: {type(exc).__name__}: {exc}"
818
+ rejection_reasons = (initial_failure,)
819
+
820
+ repair_attempted = False
821
+ if brief is None and bool(_read(self.settings, "visual_brief_repair_enabled", True)):
822
+ repair_attempted = True
823
+ try:
824
+ repaired_payload = self._repair_visual_brief_payload(
825
+ survey_title=survey_title,
826
+ section=section,
827
+ candidate=candidate,
828
+ paragraph_block=paragraph_block,
829
+ evidence_context=evidence_context,
830
+ original_payload=payload,
831
+ rejection_reasons=rejection_reasons,
832
+ schema=schema,
833
+ )
834
+ brief, repair_reasons = self._brief_from_payload_with_reasons(
835
+ repaired_payload,
836
+ candidate,
837
+ section,
838
+ figure_number,
839
+ source_paragraphs,
840
+ evidence_plan,
841
+ outline,
842
+ claim_traceability,
843
+ reference_ids,
844
+ )
845
+ if brief is not None:
846
+ self.logger.info(
847
+ "Survey visual brief repaired for section %s after format validation.",
848
+ section.index,
849
+ )
850
+ else:
851
+ rejection_reasons = rejection_reasons + tuple(
852
+ f"repair: {reason}" for reason in repair_reasons
853
+ )
854
+ except Exception as exc:
855
+ rejection_reasons = rejection_reasons + (
856
+ f"repair request/parse failed: {type(exc).__name__}: {exc}",
857
+ )
858
+
859
+ if brief is not None:
860
+ briefs.append(brief)
861
+ continue
862
+
863
+ rejection = {
864
+ "figure_id": f"fig_{figure_number:02d}_{_safe_slug(candidate.figure_type, fallback='conceptual_figure')}",
865
+ "section_index": candidate.section_index,
866
+ "figure_type": candidate.figure_type,
867
+ "reasons": list(dict.fromkeys(rejection_reasons)) or ["brief rejected without a recorded reason"],
868
+ "repair_attempted": repair_attempted,
869
+ "stage": "brief_validation",
870
+ }
871
+ self._brief_rejections.append(rejection)
872
+ self.logger.warning(
873
+ "Survey visual brief rejected for section %s (%s): %s",
874
+ section.index,
875
+ rejection["figure_id"],
876
+ "; ".join(rejection["reasons"]),
877
+ )
878
+ return briefs
879
+
880
+ def _repair_visual_brief_payload(
881
+ self,
882
+ *,
883
+ survey_title: str,
884
+ section: MarkdownSection,
885
+ candidate: VisualCandidate,
886
+ paragraph_block: str,
887
+ evidence_context: str,
888
+ original_payload: Any,
889
+ rejection_reasons: Sequence[str],
890
+ schema: Mapping[str, Any],
891
+ ) -> Any:
892
+ prompt = VISUAL_BRIEF_REPAIR.format(
893
+ survey_title=survey_title,
894
+ section_title=section.title,
895
+ figure_type=candidate.figure_type,
896
+ source_paragraphs=paragraph_block,
897
+ evidence_context=evidence_context,
898
+ original_payload=json.dumps(original_payload, ensure_ascii=False, default=str)[:12000],
899
+ rejection_reasons="\n".join(f"- {reason}" for reason in rejection_reasons) or "- no valid payload was returned",
900
+ schema=json_schema_text(schema),
901
+ )
902
+ return self._request_json(
903
+ prompt,
904
+ max_output_tokens=max(1200, int(_read(self.settings, "visual_brief_repair_max_output_tokens", 2400) or 2400)),
905
+ )
906
+
907
+ def _brief_from_payload(
908
+ self,
909
+ payload: Any,
910
+ candidate: VisualCandidate,
911
+ section: MarkdownSection,
912
+ figure_number: int,
913
+ source_paragraphs: Sequence[str],
914
+ evidence_plan: Mapping[str, Any],
915
+ outline: Mapping[str, Any],
916
+ claim_traceability: Mapping[str, Any],
917
+ reference_ids: Sequence[str] = (),
918
+ ) -> VisualBrief | None:
919
+ brief, _reasons = self._brief_from_payload_with_reasons(
920
+ payload,
921
+ candidate,
922
+ section,
923
+ figure_number,
924
+ source_paragraphs,
925
+ evidence_plan,
926
+ outline,
927
+ claim_traceability,
928
+ reference_ids,
929
+ )
930
+ return brief
931
+
932
+ def _brief_from_payload_with_reasons(
933
+ self,
934
+ payload: Any,
935
+ candidate: VisualCandidate,
936
+ section: MarkdownSection,
937
+ figure_number: int,
938
+ source_paragraphs: Sequence[str],
939
+ evidence_plan: Mapping[str, Any],
940
+ outline: Mapping[str, Any],
941
+ claim_traceability: Mapping[str, Any],
942
+ reference_ids: Sequence[str] = (),
943
+ ) -> tuple[VisualBrief | None, tuple[str, ...]]:
944
+ item = _as_mapping(payload)
945
+ main_message = _english_text(item.get("main_message_en"), limit=700) or _english_text(
946
+ getattr(candidate, "main_message_en", ""),
947
+ limit=700,
948
+ )
949
+ composition = _english_text(item.get("composition_en"), limit=700) or _english_text(
950
+ candidate.composition_en,
951
+ limit=700,
952
+ )
953
+ caption = _english_text(item.get("caption_en"), limit=900)
954
+ alt_text = _english_text(item.get("alt_text_en"), limit=700)
955
+ relation_rejection_reasons: list[str] = []
956
+ relations = self._validated_relations(
957
+ item.get("relations", []),
958
+ candidate,
959
+ section,
960
+ evidence_plan,
961
+ outline,
962
+ claim_traceability,
963
+ reference_ids,
964
+ rejection_reasons=relation_rejection_reasons,
965
+ )
966
+ entities = _english_strings(item.get("entities_en"), maximum=8)
967
+ if not entities:
968
+ entities = _english_strings(
969
+ getattr(candidate, "entities_en", ()),
970
+ maximum=8,
971
+ )
972
+ if not entities:
973
+ entities = _english_strings(
974
+ (
975
+ section.title,
976
+ str(getattr(candidate, "figure_type", "")).replace("_", " "),
977
+ "Evidence-supported relationship",
978
+ ),
979
+ maximum=8,
980
+ )
981
+ uncertainties = _english_strings(item.get("uncertainties_en"), maximum=5)
982
+ labels = _english_strings(item.get("allowed_overlay_labels_en"), maximum=6)
983
+ rejection_reasons: list[str] = []
984
+ if not main_message:
985
+ rejection_reasons.append("missing or non-English main_message_en")
986
+ if not composition:
987
+ rejection_reasons.append("missing or non-English composition_en")
988
+ if not relations:
989
+ rejection_reasons.append("no evidence-bounded relation survived validation")
990
+ if relation_rejection_reasons:
991
+ counts = Counter(relation_rejection_reasons)
992
+ detail = "; ".join(
993
+ f"{reason} ({count})" if count > 1 else reason
994
+ for reason, count in counts.items()
995
+ )
996
+ rejection_reasons.append(f"relation validation details: {detail}")
997
+ if not entities:
998
+ rejection_reasons.append("missing or non-English entities_en")
999
+ if rejection_reasons:
1000
+ return None, tuple(rejection_reasons)
1001
+
1002
+ caption = caption or "Conceptual synthesis of the supported survey relationships."
1003
+ alt_text = alt_text or (
1004
+ "Conceptual diagram of the evidence-supported relationships discussed in this section."
1005
+ )
1006
+ labels = labels or DEFAULT_OVERLAY_LABELS
1007
+ caption = self._normalise_caption(caption, figure_number)
1008
+ source_ids = tuple(sorted({
1009
+ paper_id
1010
+ for relation in relations
1011
+ for paper_id in relation.evidence_paper_ids
1012
+ } | {
1013
+ paper_id for text in source_paragraphs for paper_id in _PAPER_ID_PATTERN.findall(text)
1014
+ }))
1015
+ figure_id = f"fig_{figure_number:02d}_{_safe_slug(candidate.figure_type, fallback='conceptual_figure')}"
1016
+ return VisualBrief(
1017
+ figure_id=figure_id,
1018
+ figure_number=figure_number,
1019
+ figure_type=candidate.figure_type,
1020
+ section_index=section.index,
1021
+ section_title=section.title,
1022
+ source_paragraph_indices=candidate.source_paragraph_indices,
1023
+ insert_after_paragraph=candidate.insert_after_paragraph,
1024
+ main_message_en=main_message,
1025
+ relations=relations,
1026
+ entities_en=entities,
1027
+ uncertainties_en=uncertainties,
1028
+ composition_en=composition,
1029
+ allowed_overlay_labels_en=labels,
1030
+ caption_en=caption,
1031
+ alt_text_en=alt_text,
1032
+ source_paper_ids=source_ids,
1033
+ ), ()
1034
+
1035
+ @staticmethod
1036
+ def _normalise_caption(caption: str, figure_number: int) -> str:
1037
+ text = _clean_text(caption, limit=900)
1038
+ text = re.sub(r"^figure\s+\d+\s*[|:.\-]?\s*", "", text, flags=re.IGNORECASE).strip()
1039
+ if not text:
1040
+ text = "Conceptual synthesis of the supported survey relationships."
1041
+ if not text.endswith((".", "!", "?")):
1042
+ text += "."
1043
+ evidence_note = (
1044
+ "This schematic is a conceptual synthesis of the cited survey evidence "
1045
+ "and introduces no new empirical data."
1046
+ )
1047
+ if evidence_note.casefold() not in text.casefold():
1048
+ text = f"{text} {evidence_note}"
1049
+ return f"Figure {figure_number} | {text}"
1050
+
1051
+ def _build_style_profile(
1052
+ self,
1053
+ survey_title: str,
1054
+ briefs: Sequence[VisualBrief],
1055
+ ) -> VisualStyleProfile:
1056
+ if not bool(_read(self.settings, "generate_article_style_profile", True)):
1057
+ return VisualStyleProfile(
1058
+ visual_language_en=str(DEFAULT_STYLE["visual_language_en"]),
1059
+ palette=dict(DEFAULT_STYLE["palette"]),
1060
+ source="fallback_disabled",
1061
+ )
1062
+ schema = {
1063
+ "type": "object",
1064
+ "properties": {
1065
+ "visual_language_en": {"type": "string"},
1066
+ "palette": {
1067
+ "type": "object",
1068
+ "properties": {
1069
+ role: {"type": "string"}
1070
+ for role in ("background", "ink", "primary", "secondary", "accent", "uncertainty")
1071
+ },
1072
+ "required": ["background", "ink", "primary", "secondary", "accent", "uncertainty"],
1073
+ "additionalProperties": False,
1074
+ },
1075
+ },
1076
+ "required": ["visual_language_en", "palette"],
1077
+ "additionalProperties": False,
1078
+ }
1079
+ summaries = "\n".join(
1080
+ f"- {brief.figure_id}: {brief.figure_type}; {brief.main_message_en}"
1081
+ for brief in briefs
1082
+ )
1083
+ prompt = ARTICLE_STYLE_PLANNER.format(
1084
+ survey_title=survey_title,
1085
+ figure_summaries=summaries,
1086
+ schema=json_schema_text(schema),
1087
+ )
1088
+ try:
1089
+ payload = self._request_json(prompt, max_output_tokens=1600)
1090
+ raw_palette = _as_mapping(_read(payload, "palette", {}))
1091
+ palette = {role: str(raw_palette.get(role) or "").strip() for role in DEFAULT_STYLE["palette"]}
1092
+ visual_language = _english_text(_read(payload, "visual_language_en"), limit=900)
1093
+ if visual_language and all(_HEX_PATTERN.match(color) for color in palette.values()):
1094
+ return VisualStyleProfile(
1095
+ visual_language_en=visual_language,
1096
+ palette=palette,
1097
+ source="llm",
1098
+ )
1099
+ raise ValueError("style response lacks English text or a complete valid hex palette")
1100
+ except Exception as exc:
1101
+ self.logger.warning("Survey visual style planner failed; using neutral fallback palette: %s", exc)
1102
+ return VisualStyleProfile(
1103
+ visual_language_en=str(DEFAULT_STYLE["visual_language_en"]),
1104
+ palette=dict(DEFAULT_STYLE["palette"]),
1105
+ source="fallback",
1106
+ )
1107
+
1108
+ def _render_figure(
1109
+ self,
1110
+ brief: VisualBrief,
1111
+ style: VisualStyleProfile,
1112
+ output_dir: Path,
1113
+ ) -> FigureAsset:
1114
+ prompt = self._image_prompt(brief, style)
1115
+ role = str(_read(self.settings, "final_image_role", "academic_figure") or "academic_figure")
1116
+ image_settings = resolve_image_generation_settings(self.project_config, role=role)
1117
+ image_client = self.image_client_factory(
1118
+ api_key=image_settings["api_key"],
1119
+ base_url=image_settings["base_url"],
1120
+ timeout=image_settings["timeout"],
1121
+ poll_interval=image_settings["poll_interval"],
1122
+ config=self.project_config,
1123
+ )
1124
+ candidate_count = max(1, min(4, int(_read(self.settings, "candidates_per_figure", 2) or 2)))
1125
+ size = self._image_size_for_model(image_settings["model"])
1126
+ regenerate_for_palette = bool(
1127
+ _read(self.settings, "regenerate_on_palette_mismatch", True)
1128
+ )
1129
+ max_regenerations = max(
1130
+ 0,
1131
+ min(3, int(_read(self.settings, "max_regeneration_attempts", 1) or 0)),
1132
+ )
1133
+ max_attempts = 1 + (max_regenerations if regenerate_for_palette else 0)
1134
+ accumulated_notes: list[str] = []
1135
+
1136
+ for attempt in range(1, max_attempts + 1):
1137
+ response = image_client.generate(
1138
+ prompt=prompt,
1139
+ model=image_settings["model"],
1140
+ n=candidate_count,
1141
+ size=size,
1142
+ output_format="png",
1143
+ )
1144
+ image_bytes, quality_status, qc_notes, palette_mismatch = self._select_image_candidate(
1145
+ response.images,
1146
+ brief,
1147
+ style,
1148
+ )
1149
+ accumulated_notes.extend(f"generation {attempt}: {note}" for note in qc_notes)
1150
+ if image_bytes is not None:
1151
+ if bool(_read(self.settings, "append_english_label_strip", True)):
1152
+ image_bytes = self._append_label_strip(image_bytes, brief, style)
1153
+ file_name = self._image_file_name(brief)
1154
+ (output_dir / file_name).write_bytes(image_bytes)
1155
+ return FigureAsset(
1156
+ brief=brief,
1157
+ file_name=file_name,
1158
+ prompt=prompt,
1159
+ model=str(response.model),
1160
+ provider=str(response.provider),
1161
+ quality_status=quality_status,
1162
+ qc_notes=accumulated_notes,
1163
+ revised_prompt=str(response.revised_prompt or ""),
1164
+ generation_attempts=attempt,
1165
+ )
1166
+ if not palette_mismatch or attempt == max_attempts:
1167
+ break
1168
+ self.logger.warning(
1169
+ "Survey visualisation regenerating figure %s after palette mismatch (%s/%s).",
1170
+ brief.figure_id,
1171
+ attempt,
1172
+ max_attempts,
1173
+ )
1174
+
1175
+ raise RuntimeError(
1176
+ "All generated image candidates were rejected by visual QC. "
1177
+ + " ".join(accumulated_notes)
1178
+ )
1179
+
1180
+ def _image_size_for_model(self, model: str) -> str:
1181
+ """Keep a high-resolution default without breaking non-4K Qwen roles."""
1182
+
1183
+ requested = str(_read(self.settings, "final_image_size", "3072x2304") or "3072x2304")
1184
+ try:
1185
+ width, height = (int(part) for part in requested.lower().replace("*", "x").split("x", 1))
1186
+ except (TypeError, ValueError):
1187
+ return requested
1188
+ if max(width, height) <= 2048:
1189
+ return requested
1190
+ configured_models = _as_mapping(_read(self.project_config, "image_generation", {})).get("models", {})
1191
+ configured_model = _as_mapping(_as_mapping(configured_models).get(model, {}))
1192
+ adapter = MODEL_ADAPTERS.get(str(model))
1193
+ supports_4k = bool(
1194
+ configured_model.get(
1195
+ "supports_4k",
1196
+ getattr(adapter, "supports_4k", False),
1197
+ )
1198
+ )
1199
+ if supports_4k:
1200
+ return requested
1201
+ compatible_size = str(
1202
+ _read(self.settings, "compatible_image_size", "2048x1536") or "2048x1536"
1203
+ )
1204
+ self.logger.info(
1205
+ "Survey visualisation model %s does not declare 4K support; using %s instead of %s.",
1206
+ model,
1207
+ compatible_size,
1208
+ requested,
1209
+ )
1210
+ return compatible_size
1211
+
1212
+ def _image_file_name(self, brief: VisualBrief) -> str:
1213
+ """Resolve a safe, same-directory PNG filename from the configured template."""
1214
+
1215
+ template = str(
1216
+ _read(self.settings, "image_filename_template", "fig_{index:02d}_{slug}.png")
1217
+ or "fig_{index:02d}_{slug}.png"
1218
+ )
1219
+ try:
1220
+ file_name = template.format(
1221
+ index=brief.figure_number,
1222
+ slug=_safe_slug(brief.figure_type, fallback="conceptual_figure"),
1223
+ )
1224
+ except (KeyError, ValueError):
1225
+ file_name = f"{brief.figure_id}.png"
1226
+ # The visual companion contract intentionally forbids subdirectories.
1227
+ file_name = Path(file_name.replace("\\", "/")).name
1228
+ return file_name if file_name.lower().endswith(".png") else f"{file_name}.png"
1229
+
1230
+ def _image_prompt(self, brief: VisualBrief, style: VisualStyleProfile) -> str:
1231
+ palette = style.palette
1232
+ return IMAGE_PROMPT.format(
1233
+ figure_type=brief.figure_type.replace("_", " "),
1234
+ main_message=brief.main_message_en,
1235
+ relations="\n".join(f"- {relation.relation_en}" for relation in brief.relations),
1236
+ relation_guidance=(
1237
+ "Use solid prominent paths for directly supported relationships. "
1238
+ "Use thin, dashed, or muted paths for qualified contributions. "
1239
+ "Use translucent or restrained elements for background context. "
1240
+ "Never render evidence classifications, support metadata, paper IDs, "
1241
+ "or internal audit labels as visible text."
1242
+ ),
1243
+ entities=", ".join(brief.entities_en),
1244
+ exact_visible_labels=(
1245
+ json.dumps(list(brief.allowed_overlay_labels_en), ensure_ascii=False)
1246
+ if brief.allowed_overlay_labels_en
1247
+ else "None supplied; do not render any reader-facing labels."
1248
+ ),
1249
+ uncertainties=(
1250
+ "\n".join(f"- {item}" for item in brief.uncertainties_en)
1251
+ if bool(_read(self.settings, "show_explicit_evidence_gaps", True))
1252
+ else "Do not render uncertainty annotations."
1253
+ ) or "None stated in the supplied section.",
1254
+ composition=brief.composition_en,
1255
+ figure_type_layout=FIGURE_TYPE_LAYOUT_INSTRUCTIONS.get(
1256
+ brief.figure_type,
1257
+ "Use a clear conceptual composition based only on the supplied relationships.",
1258
+ ),
1259
+ visual_language=style.visual_language_en,
1260
+ **palette,
1261
+ )
1262
+
1263
+ def _select_image_candidate(
1264
+ self,
1265
+ images: Sequence[bytes],
1266
+ brief: VisualBrief,
1267
+ style: VisualStyleProfile,
1268
+ ) -> tuple[bytes | None, str, list[str], bool]:
1269
+ if not images:
1270
+ return None, "rejected", ["Image provider returned no candidates."], False
1271
+ if not bool(_read(self.settings, "visual_qc_enabled", True)):
1272
+ return images[0], "not_reviewed", [], False
1273
+ advisory_notes: list[str] = []
1274
+ palette_mismatch = False
1275
+ for index, image in enumerate(images, start=1):
1276
+ accepted, notes, reviewed, palette_matches = self._run_visual_qc(image, brief, style)
1277
+ advisory_notes.extend(f"candidate {index}: {note}" for note in notes)
1278
+ if reviewed and not palette_matches:
1279
+ palette_mismatch = True
1280
+ if accepted:
1281
+ return image, "accepted" if reviewed else "qc_unavailable", advisory_notes, False
1282
+ return None, "rejected", advisory_notes or ["Visual QC rejected all candidates."], palette_mismatch
1283
+
1284
+ def _run_visual_qc(
1285
+ self,
1286
+ image_bytes: bytes,
1287
+ brief: VisualBrief,
1288
+ style: VisualStyleProfile,
1289
+ ) -> tuple[bool, list[str], bool, bool]:
1290
+ schema = {
1291
+ "type": "object",
1292
+ "properties": {
1293
+ "accept": {"type": "boolean"},
1294
+ "major_issues": {"type": "array", "items": {"type": "string"}},
1295
+ "palette_match": {"type": "boolean"},
1296
+ "reader_facing_text_is_english": {"type": "boolean"},
1297
+ },
1298
+ "required": ["accept", "major_issues", "palette_match", "reader_facing_text_is_english"],
1299
+ "additionalProperties": False,
1300
+ }
1301
+ compact_brief = {
1302
+ "figure_type": brief.figure_type,
1303
+ "main_message_en": brief.main_message_en,
1304
+ "relations": [
1305
+ {
1306
+ "relation_en": relation.relation_en,
1307
+ "source_paragraph_index": relation.source_paragraph_index,
1308
+ }
1309
+ for relation in brief.relations
1310
+ ],
1311
+ "uncertainties_en": brief.uncertainties_en,
1312
+ }
1313
+ prompt = VISION_QC_PROMPT.format(
1314
+ brief=json.dumps(compact_brief, ensure_ascii=False),
1315
+ palette=json.dumps(style.palette, sort_keys=True),
1316
+ schema=json_schema_text(schema),
1317
+ )
1318
+ try:
1319
+ settings = resolve_vision_settings(self.project_config)
1320
+ client = self.vision_client_factory(
1321
+ model=settings["model"],
1322
+ provider=settings["provider"],
1323
+ api_key=settings["api_key"],
1324
+ base_url=settings["base_url"],
1325
+ timeout=settings["timeout"],
1326
+ config=self.project_config,
1327
+ )
1328
+ raw = client.describe(
1329
+ image_bytes,
1330
+ prompt=prompt,
1331
+ max_tokens=min(1600, int(settings["max_tokens"])),
1332
+ response_format={"type": "json_object"},
1333
+ )
1334
+ result = _as_mapping(extract_json(raw))
1335
+ notes = _english_strings(result.get("major_issues", []), maximum=8)
1336
+ palette_matches = bool(result.get("palette_match", False))
1337
+ accepted = bool(result.get("accept", False)) and palette_matches
1338
+ if not palette_matches:
1339
+ notes = notes + ("The candidate does not match the locked article palette.",)
1340
+ if not bool(result.get("reader_facing_text_is_english", True)):
1341
+ accepted = False
1342
+ notes = notes + ("The candidate contains non-English reader-facing text.",)
1343
+ return accepted, list(notes), True, palette_matches
1344
+ except Exception as exc:
1345
+ # QC has no authority to turn an optional enhancement into a pipeline failure.
1346
+ return True, [f"Visual QC unavailable; accepted fail-open ({exc})."], False, True
1347
+
1348
+ @staticmethod
1349
+ def _append_label_strip(
1350
+ image_bytes: bytes,
1351
+ brief: VisualBrief,
1352
+ style: VisualStyleProfile,
1353
+ ) -> bytes:
1354
+ """Optionally add a compact English-only concept strip beneath a generated image."""
1355
+
1356
+ labels = brief.allowed_overlay_labels_en[:4]
1357
+ if not labels:
1358
+ return image_bytes
1359
+ with Image.open(io.BytesIO(image_bytes)) as image:
1360
+ canvas = image.convert("RGB")
1361
+ footer_height = max(90, int(canvas.height * 0.11))
1362
+ output = Image.new("RGB", (canvas.width, canvas.height + footer_height), ImageColor.getrgb(style.palette["background"]))
1363
+ output.paste(canvas, (0, 0))
1364
+ draw = ImageDraw.Draw(output)
1365
+ try:
1366
+ font = ImageFont.truetype("DejaVuSans.ttf", max(16, footer_height // 6))
1367
+ except OSError:
1368
+ font = ImageFont.load_default()
1369
+ text = " · ".join(labels)
1370
+ draw.text(
1371
+ (max(24, canvas.width // 40), canvas.height + footer_height // 3),
1372
+ text,
1373
+ fill=ImageColor.getrgb(style.palette["ink"]),
1374
+ font=font,
1375
+ )
1376
+ buffer = io.BytesIO()
1377
+ output.save(buffer, format="PNG")
1378
+ return buffer.getvalue()
1379
+
1380
+ @staticmethod
1381
+ def _outline_context(
1382
+ outline: Mapping[str, Any],
1383
+ *,
1384
+ section_title: str = "",
1385
+ ) -> str:
1386
+ """Provide the approved outline as a bounded planning constraint, not evidence."""
1387
+
1388
+ outline_map = _as_mapping(outline)
1389
+ sections = outline_map.get("sections", [])
1390
+ if not isinstance(sections, Sequence) or isinstance(sections, (str, bytes)):
1391
+ return "No approved outline artifact is available. Use the supplied manuscript section only."
1392
+ requested = _clean_text(section_title, limit=300).casefold()
1393
+ summaries: list[str] = []
1394
+ for raw_section in sections:
1395
+ item = _as_mapping(raw_section)
1396
+ title = _clean_text(item.get("title"), limit=300)
1397
+ if requested and title.casefold() != requested:
1398
+ continue
1399
+ description = _clean_text(item.get("description"), limit=900)
1400
+ subsections = [
1401
+ _clean_text(_as_mapping(raw_subsection).get("title"), limit=220)
1402
+ for raw_subsection in item.get("subsections", [])
1403
+ if _clean_text(_as_mapping(raw_subsection).get("title"), limit=220)
1404
+ ]
1405
+ paper_ids = SurveyVisualizer._outline_paper_ids_from_section(item)
1406
+ summaries.append(
1407
+ f"Section: {title or 'Untitled'}; purpose: {description or 'not specified'}; "
1408
+ f"subsections: {', '.join(subsections) or 'none'}; "
1409
+ f"assigned paper IDs: {', '.join(paper_ids) or 'none'}"
1410
+ )
1411
+ if requested:
1412
+ break
1413
+ if not summaries:
1414
+ return "No matching approved outline section is available. Use the supplied manuscript section only."
1415
+ return "\n".join(summaries)[:5000]
1416
+
1417
+ @staticmethod
1418
+ def _outline_paper_ids_for_section(
1419
+ outline: Mapping[str, Any],
1420
+ section_title: str,
1421
+ ) -> tuple[str, ...]:
1422
+ requested = _clean_text(section_title, limit=300).casefold()
1423
+ for raw_section in _as_mapping(outline).get("sections", []):
1424
+ section = _as_mapping(raw_section)
1425
+ if _clean_text(section.get("title"), limit=300).casefold() == requested:
1426
+ return SurveyVisualizer._outline_paper_ids_from_section(section)
1427
+ return ()
1428
+
1429
+ @staticmethod
1430
+ def _outline_paper_ids_from_section(section: Mapping[str, Any]) -> tuple[str, ...]:
1431
+ paper_ids: list[str] = []
1432
+ for raw_unit in [section, *section.get("subsections", [])]:
1433
+ unit = _as_mapping(raw_unit)
1434
+ for raw_paper_id in unit.get("papers_to_use", []):
1435
+ paper_id = _clean_text(raw_paper_id, limit=100)
1436
+ if paper_id and paper_id not in paper_ids:
1437
+ paper_ids.append(paper_id)
1438
+ return tuple(paper_ids)
1439
+
1440
+ def _validated_relations(
1441
+ self,
1442
+ raw_relations: Any,
1443
+ candidate: VisualCandidate,
1444
+ section: MarkdownSection,
1445
+ evidence_plan: Mapping[str, Any],
1446
+ outline: Mapping[str, Any],
1447
+ claim_traceability: Mapping[str, Any],
1448
+ reference_ids: Sequence[str] = (),
1449
+ rejection_reasons: list[str] | None = None,
1450
+ ) -> tuple[VisualRelation, ...]:
1451
+ """Accept only relations anchored by an exact source quote and derive their role."""
1452
+
1453
+ def reject(reason: str) -> None:
1454
+ if rejection_reasons is not None:
1455
+ rejection_reasons.append(reason)
1456
+
1457
+ if not isinstance(raw_relations, Sequence) or isinstance(raw_relations, (str, bytes)):
1458
+ reject("relations is not an array")
1459
+ return ()
1460
+ source_index_set = set(candidate.source_paragraph_indices)
1461
+ result: list[VisualRelation] = []
1462
+ seen: set[tuple[str, int]] = set()
1463
+ for raw_relation in raw_relations[:8]:
1464
+ item = _as_mapping(raw_relation)
1465
+ relation_en = _english_text(item.get("relation_en"), limit=600)
1466
+ source_quote = _clean_text(
1467
+ item.get("source_quote", item.get("source_quote_en", "")),
1468
+ limit=1000,
1469
+ )
1470
+ try:
1471
+ paragraph_index = int(item.get("source_paragraph_index"))
1472
+ except (TypeError, ValueError):
1473
+ reject("invalid source_paragraph_index")
1474
+ continue
1475
+ if not relation_en:
1476
+ reject("relation_en is missing or non-English")
1477
+ if not source_quote:
1478
+ reject("source_quote is missing")
1479
+ if paragraph_index not in source_index_set:
1480
+ reject(f"source_paragraph_index {paragraph_index} is outside candidate paragraphs")
1481
+ if not relation_en or not source_quote or paragraph_index not in source_index_set:
1482
+ continue
1483
+ paragraph = section.paragraphs[paragraph_index - 1].text
1484
+ if not self._quote_is_grounded(source_quote, paragraph):
1485
+ reject(f"source_quote is not grounded in paragraph {paragraph_index}")
1486
+ continue
1487
+ key = (relation_en.casefold(), paragraph_index)
1488
+ if key in seen:
1489
+ continue
1490
+ seen.add(key)
1491
+ visible_paper_ids = tuple(sorted(set(_PAPER_ID_PATTERN.findall(paragraph))))
1492
+ numbered_paper_ids = _numbered_reference_ids(paragraph, reference_ids)
1493
+ numbered_indices = [int(index) for index in _NUMBERED_CITATION_PATTERN.findall(paragraph)]
1494
+ if reference_ids and numbered_indices and any(
1495
+ index < 1 or index > len(reference_ids) for index in numbered_indices
1496
+ ):
1497
+ # Do not let an out-of-range reader-visible citation fall back
1498
+ # to an unrelated outline paper during visual auditing.
1499
+ reject(f"numbered citation is outside supplied references in paragraph {paragraph_index}")
1500
+ continue
1501
+ audited_paper_ids = visible_paper_ids or numbered_paper_ids
1502
+ if reference_ids and visible_paper_ids:
1503
+ reference_id_set = set(reference_ids)
1504
+ audited_paper_ids = tuple(
1505
+ paper_id for paper_id in visible_paper_ids if paper_id in reference_id_set
1506
+ )
1507
+ if len(audited_paper_ids) != len(visible_paper_ids):
1508
+ # A citation visible in the survey but absent from the
1509
+ # independently supplied references is not allowed to
1510
+ # authorize a visual evidence path.
1511
+ reject(f"visible paper ID is absent from supplied references in paragraph {paragraph_index}")
1512
+ continue
1513
+ outline_paper_ids = self._outline_paper_ids_for_section(outline, section.title)
1514
+ evidence_paths = self._derive_evidence_paths(
1515
+ source_text=paragraph,
1516
+ source_quote=source_quote,
1517
+ paper_ids=audited_paper_ids or outline_paper_ids,
1518
+ evidence_plan=evidence_plan,
1519
+ claim_traceability=claim_traceability,
1520
+ )
1521
+ requires_anchor = bool(_read(self.settings, "require_evidence_anchor", True))
1522
+ allow_unsupported = bool(_read(self.settings, "allow_unsupported_claims", False))
1523
+ if not evidence_paths and (requires_anchor or not allow_unsupported):
1524
+ reject(f"no evidence path for grounded relation in paragraph {paragraph_index}")
1525
+ continue
1526
+ support_kind = self._conservative_support_kind(
1527
+ {path.support_kind for path in evidence_paths}
1528
+ ) if evidence_paths else "BACKGROUND_CONTEXT"
1529
+ evidence_paper_ids = tuple(sorted({path.paper_id for path in evidence_paths if path.paper_id})) or audited_paper_ids
1530
+ result.append(
1531
+ VisualRelation(
1532
+ relation_en=relation_en,
1533
+ source_paragraph_index=paragraph_index,
1534
+ source_quote=source_quote,
1535
+ support_kind=support_kind,
1536
+ evidence_paper_ids=evidence_paper_ids,
1537
+ evidence_paths=evidence_paths,
1538
+ )
1539
+ )
1540
+ if len(result) >= 6:
1541
+ break
1542
+ return tuple(result)
1543
+
1544
+ @staticmethod
1545
+ def _normalise_for_grounding(value: Any) -> str:
1546
+ text = unicodedata.normalize("NFKC", str(value or ""))
1547
+ text = text.translate(str.maketrans({
1548
+ "\u2018": "'",
1549
+ "\u2019": "'",
1550
+ "\u201c": '"',
1551
+ "\u201d": '"',
1552
+ "\u2013": "-",
1553
+ "\u2014": "-",
1554
+ "\u2212": "-",
1555
+ }))
1556
+ return re.sub(r"\s+", " ", text).strip().casefold()
1557
+
1558
+ @staticmethod
1559
+ def _grounding_sentences(value: Any) -> tuple[str, ...]:
1560
+ text = SurveyVisualizer._normalise_for_grounding(value)
1561
+ return tuple(
1562
+ sentence.strip()
1563
+ for sentence in re.split(r"(?<=[.!?。!?;;])\s*", text)
1564
+ if sentence.strip()
1565
+ )
1566
+
1567
+ @staticmethod
1568
+ def _quote_is_grounded(source_quote: str, paragraph: str) -> bool:
1569
+ """Match exact text, or a whole source sentence after punctuation normalization."""
1570
+
1571
+ quote = SurveyVisualizer._normalise_for_grounding(source_quote)
1572
+ source = SurveyVisualizer._normalise_for_grounding(paragraph)
1573
+ if not quote or not source:
1574
+ return False
1575
+ if quote in source:
1576
+ return True
1577
+
1578
+ for quote_sentence in SurveyVisualizer._grounding_sentences(source_quote):
1579
+ compact_sentence = re.sub(r"[^\w\s]", "", quote_sentence)
1580
+ if not compact_sentence:
1581
+ continue
1582
+ for source_sentence in SurveyVisualizer._grounding_sentences(paragraph):
1583
+ compact_source_sentence = re.sub(r"[^\w\s]", "", source_sentence)
1584
+ if compact_sentence == compact_source_sentence:
1585
+ return True
1586
+ return False
1587
+
1588
+ def _derive_evidence_paths(
1589
+ self,
1590
+ *,
1591
+ source_text: str,
1592
+ source_quote: str,
1593
+ paper_ids: Sequence[str],
1594
+ evidence_plan: Mapping[str, Any],
1595
+ claim_traceability: Mapping[str, Any],
1596
+ ) -> tuple[VisualEvidencePath, ...]:
1597
+ """Resolve exact SH/slot/paper paths before a relation reaches image prompting."""
1598
+
1599
+ trace_paths = self._trace_evidence_paths(source_text, source_quote, claim_traceability)
1600
+ if trace_paths:
1601
+ return trace_paths
1602
+ plan_paths = self._evidence_paths_by_paper(evidence_plan)
1603
+ return self._dedupe_evidence_paths(
1604
+ path
1605
+ for paper_id in paper_ids
1606
+ for path in plan_paths.get(paper_id, ())
1607
+ )
1608
+
1609
+ @staticmethod
1610
+ def _conservative_support_kind(support_kinds: Sequence[str] | set[str]) -> str:
1611
+ kinds = {str(kind or "").strip() for kind in support_kinds}
1612
+ if kinds == {"DIRECT_LEDGER_EVIDENCE"}:
1613
+ return "DIRECT_LEDGER_EVIDENCE"
1614
+ if "QUALIFIED_SH_CONTRIBUTION" in kinds:
1615
+ return "QUALIFIED_SH_CONTRIBUTION"
1616
+ return "BACKGROUND_CONTEXT"
1617
+
1618
+ @staticmethod
1619
+ def _trace_evidence_paths(
1620
+ source_text: str,
1621
+ source_quote: str,
1622
+ claim_traceability: Mapping[str, Any],
1623
+ ) -> tuple[VisualEvidencePath, ...]:
1624
+ source_normalized = SurveyVisualizer._normalise_for_grounding(source_text)
1625
+ quote_normalized = SurveyVisualizer._normalise_for_grounding(source_quote)
1626
+ paths: list[VisualEvidencePath] = []
1627
+ claims = _as_mapping(claim_traceability).get("claims", [])
1628
+ if not isinstance(claims, Sequence) or isinstance(claims, (str, bytes)):
1629
+ return ()
1630
+ for raw_claim in claims:
1631
+ claim = _as_mapping(raw_claim)
1632
+ claim_text = SurveyVisualizer._normalise_for_grounding(claim.get("claim_text"))
1633
+ if not claim_text or (claim_text not in source_normalized and claim_text not in quote_normalized):
1634
+ continue
1635
+ for raw_path in claim.get("evidence_paths", []):
1636
+ item = _as_mapping(raw_path)
1637
+ support_kind = _clean_text(item.get("support_kind"), limit=100)
1638
+ if support_kind not in {
1639
+ "DIRECT_LEDGER_EVIDENCE",
1640
+ "QUALIFIED_SH_CONTRIBUTION",
1641
+ "BACKGROUND_CONTEXT",
1642
+ }:
1643
+ continue
1644
+ paths.append(
1645
+ VisualEvidencePath(
1646
+ sub_hypothesis_id=_clean_text(item.get("sub_hypothesis_id"), limit=100),
1647
+ slot_name=_clean_text(item.get("slot_name"), limit=160),
1648
+ paper_id=canonical_paper_id(_clean_text(item.get("paper_id"), limit=100)),
1649
+ support_kind=support_kind,
1650
+ )
1651
+ )
1652
+ return SurveyVisualizer._dedupe_evidence_paths(paths)
1653
+
1654
+ @staticmethod
1655
+ def _evidence_paths_by_paper(
1656
+ evidence_plan: Mapping[str, Any],
1657
+ ) -> dict[str, tuple[VisualEvidencePath, ...]]:
1658
+ paths: dict[str, list[VisualEvidencePath]] = {}
1659
+
1660
+ def register(ids: Any, role: str, *, sh_id: str, slot_name: str) -> None:
1661
+ for paper_id in ids if isinstance(ids, Sequence) and not isinstance(ids, (str, bytes)) else [ids]:
1662
+ canonical = canonical_paper_id(_clean_text(paper_id, limit=100))
1663
+ if canonical:
1664
+ paths.setdefault(canonical, []).append(
1665
+ VisualEvidencePath(
1666
+ sub_hypothesis_id=sh_id,
1667
+ slot_name=slot_name,
1668
+ paper_id=canonical,
1669
+ support_kind=role,
1670
+ )
1671
+ )
1672
+
1673
+ for raw_entry in _as_mapping(evidence_plan).get("subhypotheses", []):
1674
+ entry = _as_mapping(raw_entry)
1675
+ sh_id = _clean_text(entry.get("sub_hypothesis_id"), limit=100)
1676
+ register(entry.get("evidence_paper_ids", []), "DIRECT_LEDGER_EVIDENCE", sh_id=sh_id, slot_name="")
1677
+ register(entry.get("qualified_paper_ids", []), "QUALIFIED_SH_CONTRIBUTION", sh_id=sh_id, slot_name="")
1678
+ register(entry.get("context_paper_ids", []), "BACKGROUND_CONTEXT", sh_id=sh_id, slot_name="")
1679
+ for slot_name, raw_slot in _as_mapping(entry.get("slot_support", {})).items():
1680
+ slot = _as_mapping(raw_slot)
1681
+ register(slot.get("evidence_paper_ids", []), "DIRECT_LEDGER_EVIDENCE", sh_id=sh_id, slot_name=str(slot_name))
1682
+ register(slot.get("qualified_paper_ids", []), "QUALIFIED_SH_CONTRIBUTION", sh_id=sh_id, slot_name=str(slot_name))
1683
+ register(slot.get("background_paper_ids", []), "BACKGROUND_CONTEXT", sh_id=sh_id, slot_name=str(slot_name))
1684
+ return {paper_id: SurveyVisualizer._dedupe_evidence_paths(entries) for paper_id, entries in paths.items()}
1685
+
1686
+ @staticmethod
1687
+ def _dedupe_evidence_paths(paths: Sequence[VisualEvidencePath] | Any) -> tuple[VisualEvidencePath, ...]:
1688
+ unique: list[VisualEvidencePath] = []
1689
+ seen: set[tuple[str, str, str, str]] = set()
1690
+ for path in paths:
1691
+ if not isinstance(path, VisualEvidencePath):
1692
+ continue
1693
+ key = (path.sub_hypothesis_id, path.slot_name, path.paper_id, path.support_kind)
1694
+ if key not in seen:
1695
+ seen.add(key)
1696
+ unique.append(path)
1697
+ return tuple(unique)
1698
+
1699
+ @staticmethod
1700
+ def _evidence_context(
1701
+ evidence_plan: Mapping[str, Any],
1702
+ *,
1703
+ include_gaps: bool,
1704
+ ) -> str:
1705
+ plan = _as_mapping(evidence_plan)
1706
+ entries = plan.get("subhypotheses", [])
1707
+ summaries: list[str] = []
1708
+ if isinstance(entries, Sequence) and not isinstance(entries, (str, bytes)):
1709
+ for entry in entries[:20]:
1710
+ item = _as_mapping(entry)
1711
+ sh_id = _clean_text(item.get("sub_hypothesis_id"), limit=80)
1712
+ mode = _clean_text(item.get("allowed_writing_mode"), limit=100)
1713
+ missing = ", ".join(_clean_text(slot, limit=100) for slot in item.get("missing_slots", [])[:6])
1714
+ summaries.append(
1715
+ f"{sh_id or 'SH'}: allowed mode={mode or 'unspecified'}; "
1716
+ f"explicit gaps={missing or 'none recorded' if include_gaps else 'suppressed'}"
1717
+ )
1718
+ base = (
1719
+ "Only the supplied section paragraphs provide visualisable scientific facts. "
1720
+ "Direct evidence may use solid primary pathways; qualified, background, and gap "
1721
+ "content must remain visually qualified."
1722
+ )
1723
+ return base + ("\n" + "\n".join(summaries) if summaries else "")
1724
+
1725
+ def _request_json(self, prompt: str, *, max_output_tokens: int) -> Any:
1726
+ raw = self.chat_agent.remote_chat(
1727
+ prompt,
1728
+ temperature=float(_read(self.settings, "planner_temperature", 0.2) or 0.2),
1729
+ max_output_tokens=max_output_tokens,
1730
+ response_format="json_object",
1731
+ )
1732
+ return extract_json(raw)
1733
+
1734
+ @staticmethod
1735
+ def _insert_markdown(
1736
+ survey: str,
1737
+ sections: Sequence[MarkdownSection],
1738
+ assets: Sequence[FigureAsset],
1739
+ ) -> str:
1740
+ section_map = {section.index: section for section in sections}
1741
+ inserts: list[tuple[int, str]] = []
1742
+ for asset in assets:
1743
+ brief = asset.brief
1744
+ section = section_map[brief.section_index]
1745
+ paragraph = section.paragraphs[brief.insert_after_paragraph - 1]
1746
+ block = (
1747
+ f"\n\n<!-- FIGURE: {brief.figure_id}; section={brief.section_index}; "
1748
+ f"paragraphs={','.join(str(index) for index in brief.source_paragraph_indices)} -->\n\n"
1749
+ f"![{brief.alt_text_en}]({asset.file_name})\n\n"
1750
+ f"*{brief.caption_en}*\n"
1751
+ )
1752
+ inserts.append((paragraph.end, block))
1753
+ result = survey
1754
+ for position, block in sorted(inserts, key=lambda item: item[0], reverse=True):
1755
+ result = result[:position] + block + result[position:]
1756
+ return result
1757
+
1758
+ @staticmethod
1759
+ def _build_manifest(
1760
+ *,
1761
+ survey_path: Path,
1762
+ visual_path: Path,
1763
+ style: VisualStyleProfile,
1764
+ assets: Sequence[FigureAsset],
1765
+ status: str,
1766
+ reason: str,
1767
+ outline: Mapping[str, Any],
1768
+ evidence_plan: Mapping[str, Any],
1769
+ skipped_figures: Sequence[Mapping[str, str]],
1770
+ reference_ids: Sequence[str] = (),
1771
+ rejected_figures: Sequence[Mapping[str, Any]] = (),
1772
+ ) -> dict[str, Any]:
1773
+ return {
1774
+ "schema_version": "survey_visual_manifest_v1",
1775
+ "status": status,
1776
+ "reason": reason,
1777
+ "source_survey": survey_path.name,
1778
+ "visual_survey": visual_path.name,
1779
+ "image_directory": ".",
1780
+ "reader_facing_language": "en",
1781
+ "captions_language": "en",
1782
+ "alt_text_language": "en",
1783
+ "outline_used": bool(_as_mapping(outline)),
1784
+ "references_received": bool(reference_ids),
1785
+ "reference_ids": list(reference_ids),
1786
+ "reference_count": len(reference_ids),
1787
+ "evidence_plan_schema_version": _clean_text(
1788
+ _as_mapping(evidence_plan).get("schema_version"), limit=120
1789
+ ),
1790
+ "style_profile": asdict(style),
1791
+ "rejected_figures": [dict(item) for item in rejected_figures],
1792
+ "skipped_figures": [dict(item) for item in skipped_figures],
1793
+ "figures": [
1794
+ {
1795
+ "figure_id": asset.brief.figure_id,
1796
+ "file": asset.file_name,
1797
+ "figure_type": asset.brief.figure_type,
1798
+ "source_section_index": asset.brief.section_index,
1799
+ "source_section_title": asset.brief.section_title,
1800
+ "source_paragraph_indices": list(asset.brief.source_paragraph_indices),
1801
+ "insert_after_paragraph": asset.brief.insert_after_paragraph,
1802
+ "source_paper_ids": list(asset.brief.source_paper_ids),
1803
+ "relations": [asdict(relation) for relation in asset.brief.relations],
1804
+ "caption_en": asset.brief.caption_en,
1805
+ "alt_text_en": asset.brief.alt_text_en,
1806
+ "allowed_overlay_labels_en": list(asset.brief.allowed_overlay_labels_en),
1807
+ "model": asset.model,
1808
+ "provider": asset.provider,
1809
+ "quality_status": asset.quality_status,
1810
+ "generation_attempts": asset.generation_attempts,
1811
+ "qc_notes": asset.qc_notes,
1812
+ "revised_prompt": asset.revised_prompt,
1813
+ "prompt": asset.prompt,
1814
+ }
1815
+ for asset in assets
1816
+ ],
1817
+ }