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