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