diff --git a/backend/pyproject.toml b/backend/pyproject.toml index f3ce3440..7dd9f9ed 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -35,6 +35,7 @@ dependencies = [ "duckdb>=1.4.4", "bibtexparser>=1.4.0", "rapidfuzz>=3.5.0", + "rank-bm25>=0.2.2", "beautifulsoup4>=4.12.3", ] diff --git a/backend/src/gateway/contracts/__init__.py b/backend/src/gateway/contracts/__init__.py index f3c9837c..23e91824 100644 --- a/backend/src/gateway/contracts/__init__.py +++ b/backend/src/gateway/contracts/__init__.py @@ -8,6 +8,7 @@ IdeaGenError, IdeaGenRunEvent, IdeaGenRunCreate, + IdeaGenPresetDefinition, IdeaGenRunEventType, IdeaGenRunResponse, IdeaGenRunStatus, @@ -63,6 +64,7 @@ "IdeaGenError", "IdeaGenRunEvent", "IdeaGenRunCreate", + "IdeaGenPresetDefinition", "IdeaGenRunEventType", "IdeaGenRunResponse", "IdeaGenRunStatus", diff --git a/backend/src/gateway/contracts/idea_gen.py b/backend/src/gateway/contracts/idea_gen.py index 9606e0d0..20c81f87 100644 --- a/backend/src/gateway/contracts/idea_gen.py +++ b/backend/src/gateway/contracts/idea_gen.py @@ -7,6 +7,7 @@ from typing import Literal from pydantic import BaseModel, Field, field_validator, model_validator +from src.modules.idea_gen.config_presets import PresetMode IDEA_GEN_DEFAULT_TARGET_VENUES = ("NIPS", "ICML", "ICLR") DEFAULT_MAX_CRITIC_ITERATIONS = 2 @@ -63,6 +64,7 @@ class IdeaGenRunCreate(BaseModel): """Request model for creating a new Idea Gen run.""" target: str = Field(..., min_length=10, max_length=1000) + preset_mode: PresetMode = "custom" academic_domain: str = Field(default="AI/ML", min_length=1, max_length=200) target_venues: list[str] = Field( default_factory=lambda: list(IDEA_GEN_DEFAULT_TARGET_VENUES) @@ -124,6 +126,16 @@ def _strip_answer(cls, value: str) -> str: return stripped +class IdeaGenPresetDefinition(BaseModel): + """Preset metadata exposed to the frontend.""" + + mode: PresetMode + label: str = Field(..., min_length=1, max_length=100) + description: str = Field(..., min_length=1, max_length=300) + default_max_iterations: int = Field(..., ge=1) + max_critic_iterations: int = Field(..., ge=1) + + class CriticFeedbackSimple(BaseModel): """Current critic feedback shape persisted by the workflow.""" diff --git a/backend/src/gateway/routers/idea_gen.py b/backend/src/gateway/routers/idea_gen.py index fe5942a6..b8fca5c9 100644 --- a/backend/src/gateway/routers/idea_gen.py +++ b/backend/src/gateway/routers/idea_gen.py @@ -24,14 +24,26 @@ IdeaGenRunEvent, IdeaGenRunEventType, IdeaGenRunCreate, + IdeaGenPresetDefinition, IdeaGenRunResponse, create_initial_idea_gen_run, idea_gen_run_dir, idea_gen_run_events_path, idea_gen_run_metadata_path, ) -from src.modules.idea_gen.config import get_workflow_config +from src.modules.idea_gen.config import get_workflow_config, runtime_config_scope +from src.modules.idea_gen.config_presets import ( + build_runtime_config_bundle, + list_preset_descriptors, +) from src.modules.idea_gen.utils import IdeaResearchExecutor, NodeExecutor +from src.modules.idea_gen.utils.state_utils import ( + get_clarification_question, + get_compressed_papers, + get_research_runs, + get_supervisor_action, + needs_clarification, +) from src.modules.idea_gen.workflow import ( IdeaGenInput, IdeaGenState, @@ -69,6 +81,51 @@ def _utc_now() -> datetime: return datetime.now(timezone.utc) +def _config_overrides_from_input(input_payload: IdeaGenRunCreate) -> dict[str, dict]: + """Convert flat API fields into preset override sections.""" + + workflow_config: dict[str, object] = {} + paper_config: dict[str, object] = {} + + if input_payload.max_concurrent_research_units is not None: + workflow_config["max_concurrent_research_units"] = ( + input_payload.max_concurrent_research_units + ) + if input_payload.search_results_per_topic is not None: + workflow_config["search_results_per_topic"] = input_payload.search_results_per_topic + if input_payload.max_critic_iterations is not None: + workflow_config["max_critic_iterations"] = input_payload.max_critic_iterations + if input_payload.critic_quality_threshold is not None: + workflow_config["critic_quality_threshold"] = input_payload.critic_quality_threshold + + if input_payload.enable_scoring is not None: + paper_config["enable_scoring"] = input_payload.enable_scoring + if input_payload.score_threshold is not None: + paper_config["score_threshold"] = input_payload.score_threshold + if input_payload.top_k_papers is not None: + paper_config["top_k_papers"] = input_payload.top_k_papers + if input_payload.enable_pdf_parsing is not None: + paper_config["enable_pdf_parsing"] = input_payload.enable_pdf_parsing + if input_payload.enable_summarization is not None: + paper_config["enable_summarization"] = input_payload.enable_summarization + if input_payload.enable_paper_cache is not None: + paper_config["enable_paper_cache"] = input_payload.enable_paper_cache + + overrides: dict[str, dict] = {} + if workflow_config: + overrides["workflow_config"] = workflow_config + if paper_config: + overrides["paper_config"] = paper_config + return overrides + + +def _build_runtime_bundle(input_payload: IdeaGenRunCreate): + return build_runtime_config_bundle( + input_payload.preset_mode, + _config_overrides_from_input(input_payload), + ) + + class IdeaGenRunManager: """Persisted run manager for Idea Gen v1 polling APIs.""" @@ -120,10 +177,11 @@ def create_run( background_tasks: BackgroundTasks, ) -> IdeaGenRunResponse: run_id = str(uuid.uuid4()) + runtime_bundle = _build_runtime_bundle(input_payload) run = create_initial_idea_gen_run( run_id=run_id, input_payload=input_payload, - max_critic_iterations=self._max_critic_iterations(), + max_critic_iterations=runtime_bundle.workflow.max_critic_iterations, ) self._save_run(run) self._append_event( @@ -259,156 +317,155 @@ def _execute_run( ) -> None: current_node: str | None = None try: - state = IdeaGenState( - input=IdeaGenInput(**execution_input.model_dump()), - thread_id=run_id, - messages=messages, - ) - self._append_event( - run_id, - event_type="run_started", - status_value="running", - message="Background execution started", - payload={"input_target": display_input.target}, - ) - clarify_executor = NodeExecutor("clarify") - brief_executor = NodeExecutor("brief") - query_generation_executor = NodeExecutor("query_generation") - supervisor_executor = NodeExecutor("supervisor") - research_executor = IdeaResearchExecutor(node_executor=NodeExecutor("research_dispatch")) - compress_executor = NodeExecutor("compress") - report_executor = NodeExecutor("report") - - def run_node(node_name: str, fn): - nonlocal state, current_node - self._abort_if_deleted(run_id) - current_node = node_name - self._save_run( - self._build_run_from_state( - run_id=run_id, - input_payload=display_input, - state=state, - status_value="running", - current_node=node_name, - ) + runtime_bundle = _build_runtime_bundle(execution_input) + with runtime_config_scope(runtime_bundle): + state = IdeaGenState( + input=IdeaGenInput(**execution_input.model_dump()), + thread_id=run_id, + messages=messages, ) self._append_event( run_id, - event_type="node_started", + event_type="run_started", status_value="running", - node=node_name, - message=f"Node {node_name} started", - payload=self._event_payload_from_state(state), + message="Background execution started", + payload={"input_target": display_input.target}, ) - state = fn(state) - self._abort_if_deleted(run_id) - interim_status = ( - "clarification_required" - if node_name == "clarify" and state.temp_data.get("need_clarification") - else "running" - ) - self._save_run( - self._build_run_from_state( - run_id=run_id, - input_payload=display_input, - state=state, - status_value=interim_status, - current_node=node_name if interim_status == "running" else None, + clarify_executor = NodeExecutor("clarify") + brief_executor = NodeExecutor("brief") + query_generation_executor = NodeExecutor("query_generation") + supervisor_executor = NodeExecutor("supervisor") + research_executor = IdeaResearchExecutor(node_executor=NodeExecutor("research_dispatch")) + compress_executor = NodeExecutor("compress") + report_executor = NodeExecutor("report") + + def run_node(node_name: str, fn): + nonlocal state, current_node + self._abort_if_deleted(run_id) + current_node = node_name + self._save_run( + self._build_run_from_state( + run_id=run_id, + input_payload=display_input, + state=state, + status_value="running", + current_node=node_name, + ) ) - ) - self._append_event( - run_id, - event_type=( + self._append_event( + run_id, + event_type="node_started", + status_value="running", + node=node_name, + message=f"Node {node_name} started", + payload=self._event_payload_from_state(state), + ) + state = fn(state) + self._abort_if_deleted(run_id) + interim_status = ( "clarification_required" - if interim_status == "clarification_required" - else "node_completed" - ), - status_value=interim_status, - node=node_name, - message=( - "Run requires clarification before continuing" - if interim_status == "clarification_required" - else f"Node {node_name} completed" - ), - payload={ - **self._event_payload_from_state(state), - **( - { - "clarification_question": state.temp_data.get( - "clarification_question", - "", - ) - } + if node_name == "clarify" and needs_clarification(state) + else "running" + ) + self._save_run( + self._build_run_from_state( + run_id=run_id, + input_payload=display_input, + state=state, + status_value=interim_status, + current_node=node_name if interim_status == "running" else None, + ) + ) + self._append_event( + run_id, + event_type=( + "clarification_required" + if interim_status == "clarification_required" + else "node_completed" + ), + status_value=interim_status, + node=node_name, + message=( + "Run requires clarification before continuing" if interim_status == "clarification_required" - else {} + else f"Node {node_name} completed" ), - }, + payload={ + **self._event_payload_from_state(state), + **( + { + "clarification_question": get_clarification_question(state) + } + if interim_status == "clarification_required" + else {} + ), + }, + ) + + run_node("start", start_node) + run_node("clarify", lambda current: clarify_node(current, clarify_executor)) + if needs_clarification(state): + self._save_run( + self._build_run_from_state( + run_id=run_id, + input_payload=display_input, + state=state, + status_value="clarification_required", + current_node=None, + ) + ) + return + + run_node("brief", lambda current: brief_node(current, brief_executor)) + run_node( + "query_generation", + lambda current: query_generation_node(current, query_generation_executor), ) - run_node("start", start_node) - run_node("clarify", lambda current: clarify_node(current, clarify_executor)) - if state.temp_data.get("need_clarification"): + while True: + run_node("supervisor", lambda current: supervisor_node(current, supervisor_executor)) + supervisor_action = get_supervisor_action(state, "RESEARCH_COMPLETE") + + if supervisor_action == "THINK": + continue + + if supervisor_action == "CONDUCT_RESEARCH": + run_node( + "query_generation", + lambda current: query_generation_node(current, query_generation_executor), + ) + run_node( + "research_dispatch", + lambda current: research_dispatch_node(current, research_executor), + ) + run_node("compress", lambda current: compress_node(current, compress_executor)) + continue + + run_node("critic", critic_node) + if state.temp_data.get("critic_action") == "ITERATE": + continue + + break + + run_node("report", lambda current: report_node(current, report_executor)) + run_node("finalize", finalize_node) self._save_run( self._build_run_from_state( run_id=run_id, input_payload=display_input, state=state, - status_value="clarification_required", - current_node=None, + status_value="completed", + current_node="finalize", ) ) - return - - run_node("brief", lambda current: brief_node(current, brief_executor)) - run_node( - "query_generation", - lambda current: query_generation_node(current, query_generation_executor), - ) - - while True: - run_node("supervisor", lambda current: supervisor_node(current, supervisor_executor)) - supervisor_action = state.temp_data.get("supervisor_action", "RESEARCH_COMPLETE") - - if supervisor_action == "THINK": - continue - - if supervisor_action == "CONDUCT_RESEARCH": - run_node( - "query_generation", - lambda current: query_generation_node(current, query_generation_executor), - ) - run_node( - "research_dispatch", - lambda current: research_dispatch_node(current, research_executor), - ) - run_node("compress", lambda current: compress_node(current, compress_executor)) - continue - - run_node("critic", critic_node) - if state.temp_data.get("critic_action") == "ITERATE": - continue - - break - - run_node("report", lambda current: report_node(current, report_executor)) - run_node("finalize", finalize_node) - self._save_run( - self._build_run_from_state( - run_id=run_id, - input_payload=display_input, - state=state, + self._append_event( + run_id, + event_type="run_completed", status_value="completed", - current_node="finalize", + node="finalize", + message="Run completed successfully", + payload=self._event_payload_from_state(state), ) - ) - self._append_event( - run_id, - event_type="run_completed", - status_value="completed", - node="finalize", - message="Run completed successfully", - payload=self._event_payload_from_state(state), - ) except _RunDeletedError: logger.info("Idea Gen run %s was deleted while executing; stopping background task", run_id) except Exception as exc: @@ -475,7 +532,7 @@ def _build_run_from_state( "input": input_payload, "artifacts": self._list_artifacts(run_id), "clarification_question": ( - state.temp_data.get("clarification_question") + get_clarification_question(state) if state and status_value == "clarification_required" else None ), @@ -484,14 +541,14 @@ def _build_run_from_state( return previous.model_copy(update=update) def _papers_count(self, run_id: str, state: IdeaGenState | None) -> int: - from_state = len({paper.get("paper_id") for paper in (state.temp_data.get("compressed_papers", []) if state else []) if paper.get("paper_id")}) + from_state = len({paper.get("paper_id") for paper in (get_compressed_papers(state) if state else []) if paper.get("paper_id")}) from_disk = len(list((self._run_dir(run_id) / "papers").glob("*.md"))) manifest_count = int(((state.temp_data.get("storage_manifest") if state else None) or {}).get("papers_count", 0)) return max(from_state, from_disk, manifest_count) def _compressed_papers_count(self, run_id: str, state: IdeaGenState | None) -> int: from_state = 0 - for paper in (state.temp_data.get("compressed_papers", []) if state else []): + for paper in (get_compressed_papers(state) if state else []): if any( paper.get(key) for key in ("core_summary", "technical_depth", "empirical_support") @@ -503,7 +560,7 @@ def _compressed_papers_count(self, run_id: str, state: IdeaGenState | None) -> i def _search_results_count(self, run_id: str, state: IdeaGenState | None) -> int: from_runs = sum( len(run.get("results") or []) - for run in ((state.temp_data.get("research_runs") or []) if state else []) + for run in (get_research_runs(state) if state else []) if isinstance(run, dict) ) from_disk = len(list((self._run_dir(run_id) / "raw" / "search").glob("*.json"))) @@ -682,6 +739,18 @@ async def list_runs() -> list[IdeaGenRunResponse]: return run_manager.list_runs() +@router.get( + "/presets", + response_model=list[IdeaGenPresetDefinition], + summary="List Idea Gen preset metadata", +) +async def list_presets() -> list[IdeaGenPresetDefinition]: + return [ + IdeaGenPresetDefinition.model_validate(item) + for item in list_preset_descriptors() + ] + + @router.get( "/{run_id}", response_model=IdeaGenRunResponse, diff --git a/backend/src/modules/draft_gen/utils/latex_validator.py b/backend/src/modules/draft_gen/utils/latex_validator.py index 37dda36a..76d37f31 100644 --- a/backend/src/modules/draft_gen/utils/latex_validator.py +++ b/backend/src/modules/draft_gen/utils/latex_validator.py @@ -1,7 +1,5 @@ """LaTeX validator.""" -import re - class LatexValidator: """Validate LaTeX structure.""" diff --git a/backend/src/modules/draft_gen/workflow.py b/backend/src/modules/draft_gen/workflow.py index 894701b7..e8b96766 100644 --- a/backend/src/modules/draft_gen/workflow.py +++ b/backend/src/modules/draft_gen/workflow.py @@ -3,7 +3,6 @@ import logging from datetime import datetime from pathlib import Path -from typing import Any from src.modules.draft_gen.schemas import ( DraftGenState, PaperOutline, SectionOutline, SectionProse, ReferenceEntry diff --git a/backend/src/modules/exp_gen/skills_config.py b/backend/src/modules/exp_gen/skills_config.py index 13486aae..d6e5141c 100644 --- a/backend/src/modules/exp_gen/skills_config.py +++ b/backend/src/modules/exp_gen/skills_config.py @@ -1,7 +1,6 @@ """Skills configuration for exp_gen module.""" from dataclasses import dataclass from pathlib import Path -from types import MappingProxyType @dataclass diff --git a/backend/src/modules/exp_gen/utils/run_context.py b/backend/src/modules/exp_gen/utils/run_context.py index c8a407fe..7ed806c0 100644 --- a/backend/src/modules/exp_gen/utils/run_context.py +++ b/backend/src/modules/exp_gen/utils/run_context.py @@ -2,7 +2,6 @@ from datetime import datetime, timezone from pathlib import Path -from typing import Any from uuid import uuid4 from src.modules.exp_gen.schemas import ExpGenState diff --git a/backend/src/modules/exp_gen/utils/runtime_planner.py b/backend/src/modules/exp_gen/utils/runtime_planner.py index 1623a470..a9a9143c 100644 --- a/backend/src/modules/exp_gen/utils/runtime_planner.py +++ b/backend/src/modules/exp_gen/utils/runtime_planner.py @@ -1,7 +1,5 @@ """Runtime project-plan and per-task contract helpers for exp_gen.""" -from typing import Any - from src.modules.exp_gen.schemas import ( ArtifactContract, ExpGenState, @@ -12,7 +10,6 @@ ) from .dataset_manager import _get_dataset_preparation -from .plan_builder import _coerce_project_plan from .storage_manager import _load_iteration_checkpoint_payload def _build_runtime_project_plan(state: ExpGenState) -> ProjectPlan: diff --git a/backend/src/modules/exp_gen/workflow.py b/backend/src/modules/exp_gen/workflow.py index d43b6e14..4719cdd7 100644 --- a/backend/src/modules/exp_gen/workflow.py +++ b/backend/src/modules/exp_gen/workflow.py @@ -29,7 +29,6 @@ QuickEvalResult, ReferenceSelectionOutput, TaskMetadataOutput, - TaskListOutput, ) from src.modules.exp_gen.utils import generation_manifest from src.modules.exp_gen.utils import plan_builder diff --git a/backend/src/modules/idea_gen/config.py b/backend/src/modules/idea_gen/config.py index 7d0befdb..eadff883 100644 --- a/backend/src/modules/idea_gen/config.py +++ b/backend/src/modules/idea_gen/config.py @@ -1,6 +1,8 @@ """Configuration for deep_research workflow nodes and runtime limits.""" import os -from dataclasses import dataclass, field +from contextlib import contextmanager +from contextvars import ContextVar +from dataclasses import dataclass, field, replace from pathlib import Path from langchain_openai import ChatOpenAI @@ -30,6 +32,59 @@ def _default_embedding_model() -> str: return os.getenv("IDEA_GEN_EMBEDDING_MODEL") or os.getenv("DF_EMBEDDING_MODEL") or "text-embedding-3-small" +def _default_evidence_lane_hybrid_weights() -> dict[str, dict[str, float]]: + return { + "inspiration": {"embedding": 0.75, "bm25": 0.25}, + "novelty": {"embedding": 0.35, "bm25": 0.65}, + "contradiction": {"embedding": 0.60, "bm25": 0.40}, + "execution": {"embedding": 0.30, "bm25": 0.70}, + } + + +def _default_evidence_lane_targets() -> dict[str, int]: + return { + "inspiration": 1, + "novelty": 2, + "contradiction": 1, + "execution": 1, + } + + +def _default_evidence_lane_completeness_weights() -> dict[str, float]: + return { + "inspiration": 0.15, + "novelty": 0.40, + "contradiction": 0.25, + "execution": 0.20, + } + + +def _default_evidence_lane_quality_thresholds() -> dict[str, float]: + return { + "inspiration": 0.55, + "novelty": 0.65, + "contradiction": 0.65, + "execution": 0.65, + } + + +def _default_report_idea_detail_lane_limits() -> dict[str, int]: + return { + "novelty": 3, + "contradiction": 3, + "inspiration": 3, + "execution": 2, + } + + +def _default_report_section_min_citations() -> dict[str, int]: + return { + "limitations": 2, + "idea_detail": 2, + "comparison": 2, + } + + @dataclass class NodeLLMConfig: """LLM configuration for a single node.""" @@ -63,7 +118,13 @@ class WorkflowRuntimeConfig: """Non-LLM runtime configuration for deep_research workflow.""" default_max_iterations: int = 8 + global_max_iterations: int = 10 max_concurrent_research_units: int = 8 + search_max_concurrency: int = 4 + web_fetch_max_concurrency: int = 3 + llm_max_concurrency: int = 4 + max_consecutive_thinks: int = 2 + max_total_thinks_per_phase: int = 3 search_results_per_topic: int = 20 researcher_simple_search_calls_max: int = 3 researcher_complex_search_calls_max: int = 5 @@ -72,6 +133,50 @@ class WorkflowRuntimeConfig: critic_quality_threshold: float = 7.5 +@dataclass +class SeedConfig: + """Seed discovery configuration.""" + + target_seed_count: int = 2 + min_distinct_seed_directions_for_completion: int = 2 + json_repair_max_rounds: int = 3 + max_papers_for_seed_discovery: int = 8 # Top-k papers to use for seed generation + max_summary_chars_per_paper: int = 3000 # Max chars per paper summary + + +@dataclass +class EvidenceConfig: + """Evidence retrieval and readiness configuration.""" + + top_k: int = 3 + search_top_k: int = 8 + max_parallel_seed_workers: int = 4 + rrf_k: int = 60 + ready_unique_papers_threshold: int = 5 + ready_completeness_threshold: float = 0.75 + lane_targets: dict[str, int] = field(default_factory=_default_evidence_lane_targets) + lane_hybrid_weights: dict[str, dict[str, float]] = field(default_factory=_default_evidence_lane_hybrid_weights) + lane_completeness_weights: dict[str, float] = field(default_factory=_default_evidence_lane_completeness_weights) + lane_quality_thresholds: dict[str, float] = field(default_factory=_default_evidence_lane_quality_thresholds) + required_lanes: list[str] = field(default_factory=lambda: ["novelty", "contradiction", "execution"]) + + +@dataclass +class ReportConfig: + """Section-wise report generation configuration.""" + + idea_detail_reference_pool_size: int = 10 + report_search_top_k: int = 12 + idea_detail_prior_work_limit: int = 4 + idea_detail_contrast_limit: int = 3 + idea_detail_inspiration_limit: int = 2 + idea_detail_execution_limit: int = 1 + idea_detail_lane_limits: dict[str, int] = field(default_factory=_default_report_idea_detail_lane_limits) + ideas_overview_citations_per_idea: int = 1 + section_min_citations: dict[str, int] = field(default_factory=_default_report_section_min_citations) + section_retry_max_rounds: int = 1 + + @dataclass class PaperProcessingConfig: """Paper processing runtime configuration.""" @@ -111,6 +216,7 @@ class PaperProcessingConfig: mineru_api_token: str | None = field(default_factory=lambda: os.getenv("MINERU_API_TOKEN")) mineru_api_endpoint: str = "https://mineru.net/api/v4/extract/task" enable_embedding_rerank: bool = True + pdf_parse_max_concurrency: int = 4 embedding_model: str = field(default_factory=_default_embedding_model) candidate_pool_size: int = 20 top_k_for_pdf: int = 5 @@ -153,6 +259,18 @@ class CompressionConfig: empirical_support_max_chars: int = 6000 +@dataclass(frozen=True) +class IdeaGenRuntimeConfigBundle: + """Per-run runtime config bundle used to isolate preset overrides.""" + + workflow: WorkflowRuntimeConfig + seed: SeedConfig + evidence: EvidenceConfig + report: ReportConfig + paper: PaperProcessingConfig + critic: CriticConfig + + NODE_CONFIGS = { "clarify": NodeLLMConfig( model="gpt-4o-mini", @@ -174,6 +292,36 @@ class CompressionConfig: temperature=0.1, max_tokens=16000, ), + "supervisor_validator": NodeLLMConfig( + model="gpt-4o-mini", + temperature=0.1, + max_tokens=8000, + ), + "seed_discovery": NodeLLMConfig( + model="gpt-4o-mini", + temperature=0.1, + max_tokens=12000, + ), + "seed_discovery_validator": NodeLLMConfig( + model="gpt-4o-mini", + temperature=0.1, + max_tokens=8000, + ), + "evidence_orchestrator": NodeLLMConfig( + model="gpt-4o-mini", + temperature=0.1, + max_tokens=12000, + ), + "hypothesis": NodeLLMConfig( + model="gpt-4o-mini", + temperature=0.1, + max_tokens=12000, + ), + "hypothesis_validator": NodeLLMConfig( + model="gpt-4o-mini", + temperature=0.1, + max_tokens=8000, + ), "compress": NodeLLMConfig( model="gpt-4o-mini", temperature=0.1, @@ -217,10 +365,67 @@ class CompressionConfig: } WORKFLOW_CONFIG = WorkflowRuntimeConfig() +SEED_CONFIG = SeedConfig() +EVIDENCE_CONFIG = EvidenceConfig() +REPORT_CONFIG = ReportConfig() PAPER_PROCESSING_CONFIG = PaperProcessingConfig() CRITIC_CONFIG = CriticConfig() COMPRESSION_CONFIG = CompressionConfig() +_RUNTIME_CONFIG_OVERRIDE: ContextVar[IdeaGenRuntimeConfigBundle | None] = ContextVar( + "idea_gen_runtime_config_override", + default=None, +) + + +def get_runtime_config_bundle() -> IdeaGenRuntimeConfigBundle: + """Return the active runtime config bundle for the current execution context.""" + + override = _RUNTIME_CONFIG_OVERRIDE.get() + if override is not None: + return override + + return IdeaGenRuntimeConfigBundle( + workflow=WORKFLOW_CONFIG, + seed=SEED_CONFIG, + evidence=EVIDENCE_CONFIG, + report=REPORT_CONFIG, + paper=PAPER_PROCESSING_CONFIG, + critic=CRITIC_CONFIG, + ) + + +def create_runtime_config_bundle( + *, + workflow_config: dict | None = None, + seed_config: dict | None = None, + evidence_config: dict | None = None, + report_config: dict | None = None, + paper_config: dict | None = None, + critic_config: dict | None = None, +) -> IdeaGenRuntimeConfigBundle: + """Create a per-run runtime config bundle by overriding the global defaults.""" + + return IdeaGenRuntimeConfigBundle( + workflow=replace(WORKFLOW_CONFIG, **(workflow_config or {})), + seed=replace(SEED_CONFIG, **(seed_config or {})), + evidence=replace(EVIDENCE_CONFIG, **(evidence_config or {})), + report=replace(REPORT_CONFIG, **(report_config or {})), + paper=replace(PAPER_PROCESSING_CONFIG, **(paper_config or {})), + critic=replace(CRITIC_CONFIG, **(critic_config or {})), + ) + + +@contextmanager +def runtime_config_scope(bundle: IdeaGenRuntimeConfigBundle): + """Temporarily apply a per-run runtime config bundle.""" + + token = _RUNTIME_CONFIG_OVERRIDE.set(bundle) + try: + yield + finally: + _RUNTIME_CONFIG_OVERRIDE.reset(token) + def get_node_config(node_name: str) -> NodeLLMConfig: """Return the configured LLM settings for a node.""" @@ -237,19 +442,37 @@ def get_node_tool_config(node_name: str) -> NodeToolConfig: def get_workflow_config() -> WorkflowRuntimeConfig: """Return the configured runtime limits for the workflow.""" - return WORKFLOW_CONFIG + return get_runtime_config_bundle().workflow + + +def get_seed_config() -> SeedConfig: + """Return the configured seed-discovery settings.""" + + return get_runtime_config_bundle().seed + + +def get_evidence_config() -> EvidenceConfig: + """Return the configured evidence retrieval and readiness settings.""" + + return get_runtime_config_bundle().evidence + + +def get_report_config() -> ReportConfig: + """Return the configured section-wise report generation settings.""" + + return get_runtime_config_bundle().report def get_paper_processing_config() -> PaperProcessingConfig: """Return the configured paper processing settings.""" - return PAPER_PROCESSING_CONFIG + return get_runtime_config_bundle().paper def get_critic_config() -> CriticConfig: """Return the configured critic settings.""" - return CRITIC_CONFIG + return get_runtime_config_bundle().critic def get_compression_config() -> CompressionConfig: diff --git a/backend/src/modules/idea_gen/config_presets.py b/backend/src/modules/idea_gen/config_presets.py new file mode 100644 index 00000000..0230694f --- /dev/null +++ b/backend/src/modules/idea_gen/config_presets.py @@ -0,0 +1,513 @@ +"""Configuration presets for idea_gen workflow. + +提供预设配置模式,简化用户配置。用户只需选择预设模式,系统自动应用最佳参数组合。 +""" +from dataclasses import dataclass +from typing import Any +from typing import Literal + +from src.modules.idea_gen.config import ( + WorkflowRuntimeConfig, + SeedConfig, + EvidenceConfig, + ReportConfig, + PaperProcessingConfig, + CriticConfig, + IdeaGenRuntimeConfigBundle, + create_runtime_config_bundle, + get_workflow_config, +) + + +PresetMode = Literal["fast", "balanced", "quality", "custom"] + + +@dataclass +class WorkflowPreset: + """工作流预设配置。 + + 预设模式说明: + - fast: 快速模式,适合测试和快速迭代(5-10 分钟) + - balanced: 平衡模式,质量和速度兼顾(10-20 分钟) + - quality: 高质量模式,追求最佳结果(20-40 分钟) + - custom: 自定义模式,用户完全控制所有参数 + """ + + mode: PresetMode = "balanced" + + # 可选的自定义覆盖参数 + workflow_config: WorkflowRuntimeConfig | None = None + seed_config: SeedConfig | None = None + evidence_config: EvidenceConfig | None = None + report_config: ReportConfig | None = None + paper_config: PaperProcessingConfig | None = None + critic_config: CriticConfig | None = None + + +# ============ 预设配置定义 ============ + +FAST_PRESET = { + "workflow_config": { + "default_max_iterations": 2, + "global_max_iterations": 4, + "max_concurrent_research_units": 2, + "max_consecutive_thinks": 2, + "max_total_thinks_per_phase": 2, + "search_results_per_topic": 5, + "max_critic_iterations": 1, + "critic_quality_threshold": 6.0, + }, + "seed_config": { + "target_seed_count": 2, + "min_distinct_seed_directions_for_completion": 2, + }, + "evidence_config": { + "top_k": 2, + "search_top_k": 10, + "max_parallel_seed_workers": 4, + "rrf_k": 60, + "ready_unique_papers_threshold": 5, + "ready_completeness_threshold": 0.70, + "lane_targets": { + "inspiration": 1, + "novelty": 2, + "contradiction": 1, + "execution": 1, + }, + }, + "report_config": { + "idea_detail_reference_pool_size": 10, + "report_search_top_k": 10, + "idea_detail_prior_work_limit": 2, + "idea_detail_contrast_limit": 2, + "idea_detail_inspiration_limit": 1, + "idea_detail_execution_limit": 1, + "idea_detail_lane_limits": { + "novelty": 3, + "contradiction": 2, + "inspiration": 2, + "execution": 1, + }, + "section_retry_max_rounds": 1, + }, + "paper_config": { + "enable_scoring": False, + "enable_two_tier_storage": True, + "enable_pdf_parsing": True, + "enable_embedding_rerank": True, + "enable_paper_pool": True, + "candidate_pool_size": 15, + "top_k_for_pdf": 8, + "rerank_pool_size": 8, + "pool_relevance_threshold": 7.0, + "pool_top_k_for_expansion": 5, + "pool_max_citations_per_paper": 3, + "pool_max_final_papers": 15, + "max_papers_to_compress": 15, + }, + "critic_config": { + "model_names": ["gpt-4o-mini"], + "temperature": 0.3, + "max_tokens": 12000, + "timeout_seconds": 300, + }, +} + + +BALANCED_PRESET = { + "workflow_config": { + "default_max_iterations": 4, + "global_max_iterations": 10, + "max_concurrent_research_units": 3, + "max_consecutive_thinks": 2, + "max_total_thinks_per_phase": 3, + "search_results_per_topic": 10, + "max_critic_iterations": 2, + "critic_quality_threshold": 6.5, + }, + "seed_config": { + "target_seed_count": 2, + "min_distinct_seed_directions_for_completion": 2, + }, + "evidence_config": { + "top_k": 3, + "search_top_k": 20, + "max_parallel_seed_workers": 4, + "rrf_k": 60, + "ready_unique_papers_threshold": 8, + "ready_completeness_threshold": 0.75, + "lane_targets": { + "inspiration": 2, + "novelty": 3, + "contradiction": 2, + "execution": 2, + }, + }, + "report_config": { + "idea_detail_reference_pool_size": 20, + "report_search_top_k": 20, + "idea_detail_prior_work_limit": 3, + "idea_detail_contrast_limit": 2, + "idea_detail_inspiration_limit": 2, + "idea_detail_execution_limit": 1, + "idea_detail_lane_limits": { + "novelty": 4, + "contradiction": 3, + "inspiration": 3, + "execution": 2, + }, + "section_retry_max_rounds": 1, + }, + "paper_config": { + "enable_scoring": False, + "enable_two_tier_storage": True, + "enable_pdf_parsing": True, + "enable_embedding_rerank": True, + "enable_paper_pool": True, + "candidate_pool_size": 30, + "top_k_for_pdf": 15, + "rerank_pool_size": 15, + "pool_relevance_threshold": 7.5, + "pool_top_k_for_expansion": 8, + "pool_max_citations_per_paper": 5, + "pool_max_final_papers": 20, + "max_papers_to_compress": 20, + }, + "critic_config": { + "model_names": ["gpt-4o", "gemini-3-flash-preview"], + "temperature": 0.3, + "max_tokens": 15000, + "timeout_seconds": 360, + }, +} + + +QUALITY_PRESET = { + "workflow_config": { + "default_max_iterations": 6, + "global_max_iterations": 15, + "max_concurrent_research_units": 5, + "max_consecutive_thinks": 2, + "max_total_thinks_per_phase": 4, + "search_results_per_topic": 15, + "max_critic_iterations": 3, + "critic_quality_threshold": 7.0, + }, + "seed_config": { + "target_seed_count": 3, + "min_distinct_seed_directions_for_completion": 2, + }, + "evidence_config": { + "top_k": 4, + "search_top_k": 30, + "max_parallel_seed_workers": 4, + "rrf_k": 60, + "ready_unique_papers_threshold": 12, + "ready_completeness_threshold": 0.75, + "lane_targets": { + "inspiration": 3, + "novelty": 4, + "contradiction": 3, + "execution": 2, + }, + }, + "report_config": { + "idea_detail_reference_pool_size": 30, + "report_search_top_k": 30, + "idea_detail_prior_work_limit": 4, + "idea_detail_contrast_limit": 3, + "idea_detail_inspiration_limit": 3, + "idea_detail_execution_limit": 2, + "idea_detail_lane_limits": { + "novelty": 5, + "contradiction": 4, + "inspiration": 4, + "execution": 3, + }, + "section_retry_max_rounds": 2, + }, + "paper_config": { + "enable_scoring": True, + "enable_two_tier_storage": True, + "enable_pdf_parsing": True, + "enable_embedding_rerank": True, + "enable_paper_pool": True, + "candidate_pool_size": 50, + "top_k_for_pdf": 25, + "rerank_pool_size": 25, + "pool_relevance_threshold": 7.5, + "pool_top_k_for_expansion": 15, + "pool_max_citations_per_paper": 8, + "pool_max_final_papers": 30, + "max_papers_to_compress": 30, + }, + "critic_config": { + "model_names": ["gpt-5.4", "gemini-3-pro-preview"], + "temperature": 0.3, + "max_tokens": 16000, + "timeout_seconds": 480, + }, +} + + +PRESET_REGISTRY = { + "fast": FAST_PRESET, + "balanced": BALANCED_PRESET, + "quality": QUALITY_PRESET, +} + +PRESET_LABELS = { + "fast": "快速 Fast", + "balanced": "平衡 Balanced", + "quality": "高质量 Quality", + "custom": "自定义 Custom", +} + +PRESET_DESCRIPTIONS = { + "fast": "低成本快速探索,适合测试、原型验证和短迭代。", + "balanced": "默认生产预设,在质量、成本和耗时之间保持平衡。", + "quality": "更高预算换取更深研究与更强 critic 配置。", + "custom": "基于系统默认配置手动覆盖参数,不套用固定预设。", +} + + +def get_preset_config(mode: PresetMode) -> dict: + """获取预设配置。 + + Args: + mode: 预设模式 (fast/balanced/quality/custom) + + Returns: + 预设配置字典 + + Raises: + ValueError: 如果模式不存在 + """ + if mode == "custom": + return {} + + if mode not in PRESET_REGISTRY: + raise ValueError( + f"Unknown preset mode: {mode}. " + f"Available modes: {list(PRESET_REGISTRY.keys())}" + ) + + return PRESET_REGISTRY[mode] + + +def apply_preset( + mode: PresetMode, + overrides: dict | None = None, +) -> dict: + """应用预设配置,支持部分覆盖。 + + Args: + mode: 预设模式 + overrides: 可选的覆盖参数,格式: + { + "workflow_config": {"max_iterations": 5}, + "paper_config": {"top_k_for_pdf": 20}, + } + + Returns: + 合并后的完整配置 + + Example: + >>> config = apply_preset("balanced", { + ... "workflow_config": {"max_iterations": 5}, + ... "paper_config": {"enable_scoring": True}, + ... }) + """ + if mode == "custom": + return overrides or {} + + preset = get_preset_config(mode) + + if not overrides: + return preset + + # 深度合并覆盖参数 + result = {} + for key, value in preset.items(): + if key in overrides and isinstance(value, dict): + result[key] = {**value, **overrides[key]} + else: + result[key] = overrides.get(key, value) + + # 添加 preset 中没有的覆盖参数 + for key, value in overrides.items(): + if key not in result: + result[key] = value + + return result + + +def build_runtime_config_bundle( + mode: PresetMode, + overrides: dict | None = None, +) -> IdeaGenRuntimeConfigBundle: + """Build a per-run runtime config bundle from a preset and optional overrides.""" + + resolved = apply_preset(mode, overrides) + return create_runtime_config_bundle( + workflow_config=resolved.get("workflow_config"), + seed_config=resolved.get("seed_config"), + evidence_config=resolved.get("evidence_config"), + report_config=resolved.get("report_config"), + paper_config=resolved.get("paper_config"), + critic_config=resolved.get("critic_config"), + ) + + +def get_preset_descriptor(mode: PresetMode) -> dict[str, Any]: + """Return frontend-friendly metadata for a preset mode.""" + + if mode == "custom": + workflow_config = get_workflow_config() + return { + "mode": mode, + "label": PRESET_LABELS[mode], + "description": PRESET_DESCRIPTIONS[mode], + "default_max_iterations": workflow_config.default_max_iterations, + "max_critic_iterations": workflow_config.max_critic_iterations, + } + + preset = get_preset_config(mode) + workflow_config = preset["workflow_config"] + return { + "mode": mode, + "label": PRESET_LABELS[mode], + "description": PRESET_DESCRIPTIONS[mode], + "default_max_iterations": workflow_config["default_max_iterations"], + "max_critic_iterations": workflow_config["max_critic_iterations"], + } + + +def list_preset_descriptors() -> list[dict[str, Any]]: + """Return all preset descriptors in UI display order.""" + + return [ + get_preset_descriptor("fast"), + get_preset_descriptor("balanced"), + get_preset_descriptor("quality"), + get_preset_descriptor("custom"), + ] + + +def get_preset_summary(mode: PresetMode) -> str: + """获取预设配置的摘要说明。 + + Args: + mode: 预设模式 + + Returns: + 配置摘要字符串 + """ + summaries = { + "fast": """ +快速模式 (Fast Mode) +================== +适用场景:测试、快速迭代、原型验证 +预计耗时:5-10 分钟 +成本:低 + +关键参数: +- 迭代次数:2 次/阶段 +- PDF 下载:8 篇 +- Critic 模型:1 个(gpt-4o-mini) +- 论文评分:关闭 +- 证据通道:每通道 1-2 篇 + +优点:快速反馈,成本低 +缺点:结果质量一般,适合初步探索 + """, + + "balanced": """ +平衡模式 (Balanced Mode) +======================= +适用场景:日常研究、标准工作流、生产环境 +预计耗时:10-20 分钟 +成本:中等 + +关键参数: +- 迭代次数:4 次/阶段 +- PDF 下载:15 篇 +- Critic 模型:2 个(gpt-4o-mini + claude-3-5-sonnet) +- 论文评分:关闭 +- 证据通道:每通道 2-3 篇 + +优点:质量和速度平衡,性价比高 +缺点:在极端场景下可能不够深入 + """, + + "quality": """ +高质量模式 (Quality Mode) +======================== +适用场景:重要研究、论文投稿、深度分析 +预计耗时:20-40 分钟 +成本:高 + +关键参数: +- 迭代次数:6 次/阶段 +- PDF 下载:25 篇 +- Critic 模型:3 个(gpt-5.4 + gemini-3-pro + claude-opus-4-6) +- 论文评分:启用 +- 证据通道:每通道 2-4 篇 + +优点:最高质量,全面深入 +缺点:耗时长,成本高 + """, + + "custom": """ +自定义模式 (Custom Mode) +======================= +适用场景:特殊需求、精细调优 +预计耗时:取决于配置 +成本:取决于配置 + +说明: +用户完全控制所有参数,需要手动配置每个模块。 +建议先从预设模式开始,再根据需求微调。 + """, + } + + return summaries.get(mode, "Unknown mode") + + +def print_preset_comparison(): + """打印所有预设模式的对比表格。""" + print("\n预设模式对比") + print("=" * 80) + print(f"{'参数':<30} {'Fast':<15} {'Balanced':<15} {'Quality':<15}") + print("-" * 80) + + comparisons = [ + ("预计耗时", "5-10 分钟", "10-20 分钟", "20-40 分钟"), + ("成本", "低", "中", "高"), + ("迭代次数/阶段", "2", "4", "6"), + ("全局预算", "4", "10", "15"), + ("PDF 下载数", "8", "15", "25"), + ("Critic 模型数", "1", "2", "3"), + ("论文评分", "关闭", "关闭", "启用"), + ("证据通道论文数", "5 篇", "9 篇", "12 篇"), + ("报告引用数", "8 篇", "12 篇", "16 篇"), + ] + + for param, fast, balanced, quality in comparisons: + print(f"{param:<30} {fast:<15} {balanced:<15} {quality:<15}") + + print("=" * 80) + print("\n推荐:") + print("- 测试/快速迭代 → fast") + print("- 日常研究/生产 → balanced") + print("- 重要研究/投稿 → quality") + print() + + +if __name__ == "__main__": + # 打印对比表格 + print_preset_comparison() + + # 打印每个模式的详细说明 + for mode in ["fast", "balanced", "quality"]: + print(get_preset_summary(mode)) diff --git a/backend/src/modules/idea_gen/nodes/__init__.py b/backend/src/modules/idea_gen/nodes/__init__.py new file mode 100644 index 00000000..9fb59331 --- /dev/null +++ b/backend/src/modules/idea_gen/nodes/__init__.py @@ -0,0 +1,43 @@ +"""Workflow nodes for idea_gen.""" + +from .bootstrap import start_node, clarify_node, brief_node, query_generation_node +from .compress import compress_node +from .critic import critic_node +from .evidence import evidence_orchestrator_node, hypothesis_node +from .finalize import finalize_node +from .paper_pool import paper_pool_process_node +from .report import report_node +from .research import research_dispatch_node +from .routing import ( + route_clarify, + route_compress, + route_critic, + route_query_generation, + route_supervisor, +) +from .seed import seed_discovery_node, seed_discovery_validate_node +from .supervisor import supervisor_node, supervisor_validate_node + +__all__ = [ + "start_node", + "clarify_node", + "brief_node", + "query_generation_node", + "supervisor_node", + "supervisor_validate_node", + "research_dispatch_node", + "seed_discovery_node", + "seed_discovery_validate_node", + "evidence_orchestrator_node", + "hypothesis_node", + "paper_pool_process_node", + "compress_node", + "critic_node", + "report_node", + "finalize_node", + "route_clarify", + "route_query_generation", + "route_compress", + "route_supervisor", + "route_critic", +] diff --git a/backend/src/modules/idea_gen/nodes/bootstrap.py b/backend/src/modules/idea_gen/nodes/bootstrap.py new file mode 100644 index 00000000..db874c2b --- /dev/null +++ b/backend/src/modules/idea_gen/nodes/bootstrap.py @@ -0,0 +1,342 @@ +"""Bootstrap and query-generation nodes.""" + +from __future__ import annotations + +from datetime import datetime + +from src.modules.idea_gen.config import get_workflow_config +from src.modules.idea_gen.prompts import ( + build_brief_prompt, + build_clarify_prompt, + build_query_generation_prompt, +) +from src.modules.idea_gen.state import IdeaGenState +from src.modules.idea_gen.utils import NodeExecutor, query_utils +from src.modules.idea_gen.utils import workflow_helpers +from src.modules.idea_gen.utils.error_utils import handle_node_error +from src.modules.idea_gen.utils.query_planning_utils import ( + _needs_web_query, + _plan_to_queries, + _query_generation_mode, + _summarize_query_feedback_for_prompt, +) +from src.modules.idea_gen.utils.query_state_manager import QueryStateManager +from src.modules.idea_gen.utils.state_utils import ( + current_research_phase, + get_temp_value, + phase_completion_action, + reset_supervisor_think_counters, + set_temp_value, + sync_query_state, +) +from src.modules.idea_gen.utils.supervisor_utils import tasks_to_topics +from src.utils.structured_output import parse_structured_output + + +def start_node(state: IdeaGenState) -> IdeaGenState: + """Initialize workflow state.""" + print("\n[NODE] start - Initializing workflow...") + workflow_config = get_workflow_config() + state.final_report = "" + state.research_runs = [] + state.iteration_count = 0 + state.broad_iteration_count = 0 + state.evidence_iteration_count = 0 + state.unit_count = 0 + state.global_max_iterations = max(1, workflow_config.global_max_iterations) + set_temp_value(state, "workflow_date", datetime.now().strftime("%Y-%m-%d")) + set_temp_value(state, "need_clarification", False) + set_temp_value(state, "clarification_question", "") + set_temp_value(state, "research_phase", "broad") + set_temp_value(state, "supervisor_tasks", []) + set_temp_value(state, "supervisor_topics", []) + set_temp_value(state, "supervisor_action", "") + set_temp_value(state, "supervisor_validation_status", "") + set_temp_value(state, "supervisor_validation_rounds", 0) + set_temp_value(state, "seed_validation_status", "") + set_temp_value(state, "seed_validation_rounds", 0) + set_temp_value(state, "final_papers_for_compress", []) + set_temp_value(state, "compressed_papers", []) + set_temp_value(state, "evidence_status", {}) + set_temp_value(state, "critic_action", "") + reset_supervisor_think_counters(state, "broad") + sync_query_state(state) + QueryStateManager.ensure_history(state) + return state + + +def clarify_node(state: IdeaGenState, executor: NodeExecutor | None = None) -> IdeaGenState: + """Check whether clarification is required.""" + print("\n[NODE] clarify - Checking if clarification needed...") + + if not state.input.allow_clarification: + state.temp.need_clarification = False + state.temp.clarification_question = "" + print("[NODE] clarify - Skipped (not allowed)") + return state + + messages_str = "\n".join(f"{m.get('role', 'user')}: {m.get('content', '')}" for m in state.messages) if state.messages else f"user: {state.input.target}" + + system, user = build_clarify_prompt( + messages_str, + date=get_temp_value(state, "workflow_date"), + ) + executor = executor or NodeExecutor("clarify") + response = executor.invoke_text(system, user) + + try: + data = parse_structured_output(response) + need_clarification = data.get("need_clarification", False) + state.temp.need_clarification = bool(need_clarification) + state.temp.clarification_question = "" + if need_clarification: + state.temp.clarification_question = data.get("question", "") + print(f"[NODE] clarify - Need clarification: {data.get('question', '')[:100]}") + else: + print("[NODE] clarify - No clarification needed") + except Exception as exc: + state.temp.need_clarification = False + state.temp.clarification_question = "" + print(f"[NODE] clarify - Error: {exc}, proceeding without clarification") + + return state + + +def brief_node(state: IdeaGenState, executor: NodeExecutor | None = None) -> IdeaGenState: + """Generate research brief.""" + print("\n[NODE] brief - Generating research brief...") + + messages_str = "\n".join(f"{m.get('role', 'user')}: {m.get('content', '')}" for m in state.messages) if state.messages else f"user: {state.input.target}" + system, user = build_brief_prompt( + messages_str, + date=get_temp_value(state, "workflow_date"), + language=state.input.language, + academic_domain=state.input.academic_domain, + target_venues=state.input.target_venues, + ) + executor = executor or NodeExecutor("brief") + response = executor.invoke_text(system, user) + + try: + data = parse_structured_output(response) + state.research_brief = data.get("research_brief", state.input.target) + except Exception: + state.research_brief = state.input.target + + state.paper_queries = [] + state.web_queries = [] + + print(f"[NODE] brief - Done: {state.research_brief[:100]}...") + workflow_helpers._save_artifact(state, "research_brief.md", state.research_brief) + workflow_helpers._save_node_state(state, "brief") + return state + + +def query_generation_node(state: IdeaGenState, executor: NodeExecutor | None = None) -> IdeaGenState: + """Generate or rewrite query plans.""" + mode = _query_generation_mode(state) + query_state = sync_query_state(state) + QueryStateManager.ensure_history(state) + previous_active_paper_queries = list(query_state.get("active_paper_queries", []) or state.paper_queries) + previous_active_web_queries = list(query_state.get("active_web_queries", []) or state.web_queries) + print(f"\n[NODE] query_generation - Generating search queries ({mode})...") + + if mode == "reuse_initial": + state.paper_queries = list(previous_active_paper_queries) + state.web_queries = list(previous_active_web_queries) + print( + f"[NODE] query_generation - Reusing initial active queries: " + f"{len(state.paper_queries)} paper, {len(state.web_queries)} web" + ) + workflow_helpers._save_node_state(state, "query_generation") + return state + + query_feedback = get_temp_value(state, "query_feedback", {}) or {} + latest_supervisor = state.supervisor_messages[-1] if state.supervisor_messages else {} + supervisor_tasks = list(get_temp_value(state, "supervisor_tasks", []) or []) + system, user = build_query_generation_prompt( + target=state.input.target, + research_brief=state.research_brief, + date=get_temp_value(state, "workflow_date"), + academic_domain=state.input.academic_domain, + target_venues=state.input.target_venues, + mode=mode, + current_active_queries={ + "paper_queries": list(query_state.get("active_paper_queries", []) or state.paper_queries), + "web_queries": list(query_state.get("active_web_queries", []) or state.web_queries), + "paper_query_plan": list(query_state.get("active_paper_query_plan", []) or []), + "web_query_plan": list(query_state.get("active_web_query_plan", []) or []), + }, + supervisor_reflection=latest_supervisor.get("reflection", ""), + supervisor_topics=list(get_temp_value(state, "supervisor_topics", []) or []), + supervisor_tasks=supervisor_tasks, + query_feedback=_summarize_query_feedback_for_prompt(query_feedback), + query_history=state.temp_data.get("query_history") or {}, + ) + executor = executor or NodeExecutor("query_generation") + try: + response = executor.invoke_text(system, user) + except Exception as exc: + fatal = mode == "initial" + handle_node_error( + state, + node="query_generation", + error=exc, + recoverable=not fatal, + message=f"query generation failed in {mode} mode", + fatal=fatal, + ) + response = "" + + rewrite_strategy = "initial" if mode == "initial" else "retain" + rewrite_reason = "initial query generation from target + brief" + next_paper_plan: list[dict] = [] + next_web_plan: list[dict] = [] + need_more_queries = True + missing_aspects = list(query_feedback.get("missing_aspects", []) or []) + + try: + data = parse_structured_output(response) + need_more_queries = bool(data.get("need_more_queries", True)) + response_missing = list(data.get("missing_aspects", []) or []) + if response_missing: + missing_aspects = [item for item in (query_utils.normalize_query_text(v) for v in response_missing) if item] + next_paper_plan = query_utils.coerce_plan( + data.get("paper_queries"), + channel="paper", + limit=6, + default_source="model", + fallback_role="candidate", + ) + next_web_plan = query_utils.coerce_plan( + data.get("web_queries"), + channel="web", + limit=6, + default_source="model", + fallback_role="candidate", + ) + rewrite_strategy = query_utils.normalize_query_text(data.get("rewrite_strategy", rewrite_strategy)).lower() or rewrite_strategy + rewrite_reason = query_utils.normalize_query_text(data.get("reason", rewrite_reason)) or rewrite_reason + except Exception: + next_paper_plan = [] + next_web_plan = [] + need_more_queries = mode == "initial" + + if mode == "initial": + next_paper_plan = query_utils.dedupe_and_filter_plan( + next_paper_plan, + target=state.input.target, + research_brief=state.research_brief, + channel="paper", + limit=3, + ) + if len(next_paper_plan) < 3: + for candidate in query_utils.default_initial_queries(state.input.target, state.research_brief): + if len(next_paper_plan) >= 3: + break + next_paper_plan = query_utils.dedupe_and_filter_plan( + [*next_paper_plan, candidate], + target=state.input.target, + research_brief=state.research_brief, + channel="paper", + limit=3, + ) + next_web_plan = [] + else: + if rewrite_strategy == "stop" or not need_more_queries: + next_paper_plan = [] + next_web_plan = [] + else: + next_paper_plan = query_utils.dedupe_and_filter_plan( + next_paper_plan, + target=state.input.target, + research_brief=state.research_brief, + channel="paper", + limit=2, + ) + next_web_plan = query_utils.dedupe_and_filter_plan( + next_web_plan, + target=state.input.target, + research_brief=state.research_brief, + channel="web", + limit=1, + ) + gap_inputs = missing_aspects or tasks_to_topics(supervisor_tasks) or list(get_temp_value(state, "supervisor_topics", []) or []) + if not next_paper_plan and gap_inputs: + next_paper_plan = query_utils.default_iterative_queries( + target=state.input.target, + research_brief=state.research_brief, + missing_aspects=gap_inputs, + channel="paper", + ) + if not next_web_plan and gap_inputs and _needs_web_query(gap_inputs): + next_web_plan = query_utils.default_iterative_queries( + target=state.input.target, + research_brief=state.research_brief, + missing_aspects=gap_inputs, + channel="web", + ) + + next_paper_queries = _plan_to_queries(next_paper_plan, limit=3) + next_web_queries = _plan_to_queries(next_web_plan, limit=3) + dropped_queries = ( + query_utils.compute_dropped(previous_active_paper_queries, next_paper_queries) + + query_utils.compute_dropped(previous_active_web_queries, next_web_queries) + ) if mode != "initial" else [] + + state.paper_queries = next_paper_queries + state.web_queries = next_web_queries + + if mode == "initial": + query_state["seed_paper_queries"] = list(next_paper_queries) + query_state["seed_web_queries"] = list(next_web_queries) + query_state["seed_paper_query_plan"] = list(next_paper_plan) + query_state["seed_web_query_plan"] = list(next_web_plan) + + query_state["active_paper_queries"] = list(next_paper_queries) + query_state["active_web_queries"] = list(next_web_queries) + query_state["active_paper_query_plan"] = list(next_paper_plan) + query_state["active_web_query_plan"] = list(next_web_plan) + query_state["last_rewrite_strategy"] = rewrite_strategy + query_state["coverage_summary"] = dict(query_feedback.get("coverage_summary", {}) or {}) + query_state["missing_aspects"] = list(missing_aspects) + + entry = { + "iteration": state.iteration_count, + "mode": mode, + "need_more_queries": need_more_queries, + "paper_queries": list(next_paper_queries), + "web_queries": list(next_web_queries), + "paper_query_plan": list(next_paper_plan), + "web_query_plan": list(next_web_plan), + "missing_aspects": list(missing_aspects), + "strategy": rewrite_strategy, + "reason": rewrite_reason, + "dropped_queries": dropped_queries, + } + QueryStateManager.record_entry(state, entry) + set_temp_value(state, "query_state", query_state) + + if mode != "initial" and not state.paper_queries and not state.web_queries and (rewrite_strategy == "stop" or not need_more_queries): + completion_action = phase_completion_action(current_research_phase(state)) + set_temp_value(state, "supervisor_action", completion_action) + set_temp_value(state, "supervisor_tasks", []) + set_temp_value(state, "supervisor_topics", []) + + if mode == "initial" and not state.paper_queries: + error = ValueError("initial query generation produced no usable paper queries") + handle_node_error( + state, + node="query_generation", + error=error, + recoverable=False, + message=str(error), + fatal=True, + ) + + print( + f"[NODE] query_generation - Done ({rewrite_strategy}): " + f"{len(state.paper_queries)} paper queries, {len(state.web_queries)} web queries" + ) + workflow_helpers._save_node_state(state, "query_generation") + return state diff --git a/backend/src/modules/idea_gen/nodes/compress.py b/backend/src/modules/idea_gen/nodes/compress.py new file mode 100644 index 00000000..81c74439 --- /dev/null +++ b/backend/src/modules/idea_gen/nodes/compress.py @@ -0,0 +1,12 @@ +"""Compression node.""" + +from __future__ import annotations + +from src.modules.idea_gen.services import CompressionService +from src.modules.idea_gen.state import IdeaGenState +from src.modules.idea_gen.utils import NodeExecutor + + +def compress_node(state: IdeaGenState, executor: NodeExecutor | None = None) -> IdeaGenState: + """Compress selected papers.""" + return CompressionService().run(state, executor=executor) diff --git a/backend/src/modules/idea_gen/nodes/critic.py b/backend/src/modules/idea_gen/nodes/critic.py new file mode 100644 index 00000000..805948ee --- /dev/null +++ b/backend/src/modules/idea_gen/nodes/critic.py @@ -0,0 +1,139 @@ +"""Critic node.""" + +from __future__ import annotations + +import asyncio + +from src.modules.idea_gen.config import get_critic_config, get_workflow_config +from src.modules.idea_gen.prompts import build_critic_prompt +from src.modules.idea_gen.state import IdeaGenState +from src.modules.idea_gen.utils.critic_executor import MultiModelCriticExecutor, aggregate_critiques +from src.modules.idea_gen.utils.workflow_helpers import _save_node_state +from src.modules.idea_gen.utils.state_utils import set_temp_value + + +def _finalize_critic_state( + state: IdeaGenState, + *, + action: str, + status: str, + ran: bool, + successful_models: int = 0, + avg_score: float | None = None, +) -> IdeaGenState: + """Persist critic execution metadata on state.""" + set_temp_value(state, "critic_action", action) + state.temp_data["critic_action"] = action + state.temp_data["critic_status"] = status + state.temp_data["critic_ran"] = ran + state.temp_data["critic_successful_models"] = successful_models + if avg_score is None: + state.temp_data.pop("critic_avg_score", None) + else: + state.temp_data["critic_avg_score"] = avg_score + _save_node_state(state, f"critic_iter{state.critic_iteration_count}") + return state + + +def critic_node(state: IdeaGenState) -> IdeaGenState: + """Run multi-model critique and decide iterate vs accept.""" + print(f"\n[NODE] critic - Iteration {state.critic_iteration_count}") + + critic_config = get_critic_config() + workflow_config = get_workflow_config() + + configured_models = [ + model_name + for model_name in critic_config.model_names + if model_name and model_name.strip() + ] + state.temp_data["critic_requested_models"] = configured_models + if not configured_models: + print("[NODE] critic - Skipped (no models configured)") + return _finalize_critic_state( + state, + action="ACCEPT", + status="skipped_no_models", + ran=False, + ) + + effective_min_successful_models = min( + max(1, critic_config.min_successful_models), + len(configured_models), + ) + + if state.critic_iteration_count >= workflow_config.max_critic_iterations: + print("[NODE] critic - ACCEPT (max iterations)") + return _finalize_critic_state( + state, + action="ACCEPT", + status="accepted_max_iterations", + ran=False, + ) + + system, user = build_critic_prompt( + research_brief=state.research_brief, + notes=state.notes, + previous_feedback=state.critic_feedback, + ) + + executor = MultiModelCriticExecutor() + model_results = asyncio.run(executor.evaluate_parallel(system, user)) + + if not model_results: + print("[NODE] critic - ACCEPT (no results)") + return _finalize_critic_state( + state, + action="ACCEPT", + status="accepted_no_results", + ran=True, + successful_models=0, + ) + + if len(model_results) < effective_min_successful_models: + print( + "[NODE] critic - ACCEPT " + f"(insufficient successful models: {len(model_results)}/{effective_min_successful_models})" + ) + return _finalize_critic_state( + state, + action="ACCEPT", + status="accepted_insufficient_models", + ran=True, + successful_models=len(model_results), + ) + + aggregated = aggregate_critiques(model_results) + avg_score = sum(aggregated.consensus_scores.values()) / len(aggregated.consensus_scores) + + if avg_score >= workflow_config.critic_quality_threshold: + state.critic_feedback = None + print(f"[NODE] critic - ACCEPT (score: {avg_score:.1f})") + return _finalize_critic_state( + state, + action="ACCEPT", + status="accepted_threshold", + ran=True, + successful_models=len(model_results), + avg_score=avg_score, + ) + + blocking = [ + f"{dimension}: {score:.1f}/10" + for dimension, score in aggregated.consensus_scores.items() + if score < workflow_config.critic_quality_threshold + ] + state.critic_feedback = { + "blocking_issues": ", ".join(blocking), + "suggestions": aggregated.summary, + } + state.critic_iteration_count += 1 + print(f"[NODE] critic - ITERATE (score: {avg_score:.1f})") + return _finalize_critic_state( + state, + action="ITERATE", + status="iterate_threshold", + ran=True, + successful_models=len(model_results), + avg_score=avg_score, + ) diff --git a/backend/src/modules/idea_gen/nodes/evidence.py b/backend/src/modules/idea_gen/nodes/evidence.py new file mode 100644 index 00000000..c1dfb661 --- /dev/null +++ b/backend/src/modules/idea_gen/nodes/evidence.py @@ -0,0 +1,245 @@ +"""Evidence and hypothesis nodes.""" + +from __future__ import annotations + +import json +from concurrent.futures import as_completed +from typing import Any + +from src.modules.idea_gen.config import create_node_model, get_evidence_config +from src.modules.idea_gen.prompts import build_hypothesis_prompt, build_hypothesis_validation_prompt +from src.modules.idea_gen.schemas import EvidenceBundle, Hypothesis +from src.modules.idea_gen.state import IdeaGenState +from src.modules.idea_gen.utils import NodeExecutor +from src.modules.idea_gen.utils import workflow_helpers +from src.modules.idea_gen.utils.embedding_store import EmbeddingStore +from src.modules.idea_gen.utils.evidence_retriever import ( + RetrieverConfig, + build_evidence_bundle_with_diagnostics, +) +from src.modules.idea_gen.utils.evidence_utils import extract_allowed_paper_keys_from_bundle +from src.modules.idea_gen.utils.error_utils import handle_node_error +from src.modules.idea_gen.utils.state_utils import get_final_papers_for_compress, get_temp_value, set_temp_value +from src.modules.idea_gen.utils.thread_pool_manager import ThreadPoolManager +from src.utils.structured_output import parse_structured_output + + +def evidence_orchestrator_node(state: IdeaGenState) -> IdeaGenState: + """Build EvidenceBundle for each seed via 4-lane parallel retrieval.""" + import asyncio + + def _run_coroutine(coro: Any) -> Any: + loop = asyncio.new_event_loop() + try: + asyncio.set_event_loop(loop) + return loop.run_until_complete(coro) + finally: + asyncio.set_event_loop(None) + loop.close() + + def _clone_embedding_store(source: EmbeddingStore) -> EmbeddingStore: + cloned = EmbeddingStore() + cloned._paper_keys = list(source._paper_keys) + cloned._docs = list(source._docs) + cloned._bm25 = source._bm25 + cloned._chunk_keys = list(source._chunk_keys) + cloned._chunk_texts = list(source._chunk_texts) + cloned._chunk_vectors = source._chunk_vectors + return cloned + + print("\n[NODE] evidence_orchestrator - Building evidence bundles...") + + if not state.seeds: + print("[NODE] evidence_orchestrator - No seeds, skipping") + return state + + output_dir = workflow_helpers._get_output_dir(state) + papers_dir = output_dir / "papers" + selected_papers = get_final_papers_for_compress(state) + pool_papers = selected_papers or (state.paper_pool.get_all_papers() if state.paper_pool else []) + + paper_docs: dict[str, str] = {} + for paper in pool_papers: + key = paper.get("paper_key", "") + if not key: + continue + md_file = papers_dir / f"{paper.get('paper_id', key)}.md" + if md_file.exists(): + paper_docs[key] = md_file.read_text(encoding="utf-8") + else: + paper_docs[key] = f"{paper.get('title', '')} {paper.get('abstract', '')}" + + store = EmbeddingStore() + store.build( + [ + { + "paper_key": paper.get("paper_key", ""), + "title": paper.get("title", ""), + "abstract": paper.get("abstract", ""), + } + for paper in pool_papers + if paper.get("paper_key") + ] + ) + try: + _run_coroutine(store.build_embeddings()) + except Exception as exc: + handle_node_error( + state, + node="evidence_orchestrator", + error=exc, + recoverable=True, + message="embedding build failed for evidence retrieval; falling back to lexical support", + ) + print(f"[NODE] evidence_orchestrator - Embedding build failed: {exc}") + + evidence_config = get_evidence_config() + cfg = RetrieverConfig( + top_k=evidence_config.top_k, + search_top_k=evidence_config.search_top_k, + rrf_k=evidence_config.rrf_k, + ready_unique_papers_threshold=evidence_config.ready_unique_papers_threshold, + ready_completeness_threshold=evidence_config.ready_completeness_threshold, + lane_targets=dict(evidence_config.lane_targets), + lane_hybrid_weights={lane: dict(weights) for lane, weights in evidence_config.lane_hybrid_weights.items()}, + lane_completeness_weights=dict(evidence_config.lane_completeness_weights), + lane_quality_thresholds=dict(evidence_config.lane_quality_thresholds), + required_lanes=tuple(evidence_config.required_lanes), + ) + + def _build_for_seed(seed: Any) -> tuple[Any, dict[str, Any]]: + seed_store = _clone_embedding_store(store) + llm = create_node_model("evidence_orchestrator") + return _run_coroutine( + build_evidence_bundle_with_diagnostics( + seed, + seed_store, + paper_docs, + llm, + cfg, + ) + ) + + bundle_results: list[tuple[Any, dict[str, Any]] | None] = [None] * len(state.seeds) + pool = ThreadPoolManager.get_compute_pool() + future_to_index = { + pool.submit(_build_for_seed, seed): index + for index, seed in enumerate(state.seeds) + } + for future in as_completed(future_to_index): + index = future_to_index[future] + seed = state.seeds[index] + try: + bundle_results[index] = future.result() + except Exception as exc: + handle_node_error( + state, + node="evidence_orchestrator", + error=exc, + recoverable=True, + message=f"evidence bundle build failed for seed {seed.seed_id}", + ) + bundle_results[index] = ( + EvidenceBundle(seed_id=seed.seed_id), + { + "seed_id": seed.seed_id, + "lane_diagnostics": {}, + "evidence_status": { + "seed_id": seed.seed_id, + "ready_for_hypothesis": False, + "blocking_lanes": ["novelty", "contradiction", "execution"], + "evidence_gaps": ["evidence bundle construction failed"], + }, + }, + ) + + bundles = [bundle for bundle, _diag in bundle_results if bundle is not None] + diagnostics = [diag for _bundle, diag in bundle_results if diag is not None] + seed_statuses = [diag.get("evidence_status", {}) for diag in diagnostics] + phase_ready = bool(seed_statuses) and all(item.get("ready_for_hypothesis", False) for item in seed_statuses) + + state.evidence_bundles = list(bundles) + set_temp_value( + state, + "evidence_status", + { + "phase_ready": phase_ready, + "seed_statuses": seed_statuses, + }, + ) + state.temp_data["evidence_lane_diagnostics"] = diagnostics + workflow_helpers._save_artifact( + state, + "evidence_status.json", + json.dumps(get_temp_value(state, "evidence_status", {}), ensure_ascii=False, indent=2), + ) + workflow_helpers._save_artifact( + state, + "evidence_lane_diagnostics.json", + json.dumps(diagnostics, ensure_ascii=False, indent=2), + ) + print(f"[NODE] evidence_orchestrator - Built {len(state.evidence_bundles)} bundles") + return state + + +def hypothesis_node( + state: IdeaGenState, + executor: NodeExecutor | None = None, + validator_executor: NodeExecutor | None = None, +) -> IdeaGenState: + """Build Hypothesis for each seed from its EvidenceBundle.""" + print("\n[NODE] hypothesis - Building hypotheses...") + executor = executor or NodeExecutor("hypothesis") + validator_executor = validator_executor or NodeExecutor("hypothesis_validator") + max_rounds = 3 + + bundle_map = {bundle.seed_id: bundle for bundle in state.evidence_bundles} + hypotheses: list[Hypothesis] = [] + for seed in state.seeds: + bundle = bundle_map.get(seed.seed_id) + if not bundle: + continue + system, user = build_hypothesis_prompt( + seed_json=seed.model_dump_json(indent=2), + evidence_bundle_json=bundle.model_dump_json(indent=2), + ) + allowed_paper_keys = extract_allowed_paper_keys_from_bundle(bundle) + try: + response = executor.invoke_messages([ + {"role": "system", "content": system}, + {"role": "user", "content": user}, + ]) + candidate = NodeExecutor.get_text(response) + workflow_helpers._save_artifact(state, f"hypothesis_raw_{seed.seed_id}.txt", candidate) + + last_error: Exception | None = None + for attempt in range(max_rounds + 1): + try: + data = parse_structured_output(candidate, dict) + data["evidence_trace"] = bundle.model_dump() + hypotheses.append(Hypothesis(**data)) + break + except Exception as exc: + last_error = exc + if attempt >= max_rounds: + raise + repair_system, repair_user = build_hypothesis_validation_prompt( + raw_hypothesis_output=candidate, + seed_json=seed.model_dump_json(indent=2), + evidence_bundle_json=bundle.model_dump_json(indent=2), + allowed_paper_keys=allowed_paper_keys, + ) + candidate = validator_executor.invoke_text(repair_system, repair_user) + workflow_helpers._save_artifact( + state, + f"hypothesis_validation_{seed.seed_id}_attempt_{attempt + 1}.txt", + candidate, + ) + if last_error is not None and hypotheses and hypotheses[-1].seed_id == seed.seed_id: + print(f"[NODE] hypothesis - Repaired hypothesis for seed {seed.seed_id}") + except Exception as exc: + print(f"[NODE] hypothesis - Error for seed {seed.seed_id}: {exc}") + + state.hypotheses = hypotheses + print(f"[NODE] hypothesis - Built {len(state.hypotheses)} hypotheses") + return state diff --git a/backend/src/modules/idea_gen/nodes/finalize.py b/backend/src/modules/idea_gen/nodes/finalize.py new file mode 100644 index 00000000..311eb008 --- /dev/null +++ b/backend/src/modules/idea_gen/nodes/finalize.py @@ -0,0 +1,12 @@ +"""Finalize node.""" + +from __future__ import annotations + +from src.modules.idea_gen.state import IdeaGenState + + +def finalize_node(state: IdeaGenState) -> IdeaGenState: + """Finish the workflow and print a compact summary.""" + print("\n[NODE] finalize - Workflow complete") + print(f"[SUMMARY] Iterations: {state.iteration_count}, Units: {state.unit_count}") + return state diff --git a/backend/src/modules/idea_gen/nodes/paper_pool.py b/backend/src/modules/idea_gen/nodes/paper_pool.py new file mode 100644 index 00000000..cc114eea --- /dev/null +++ b/backend/src/modules/idea_gen/nodes/paper_pool.py @@ -0,0 +1,12 @@ +"""Paper pool node.""" + +from __future__ import annotations + +from src.modules.idea_gen.services import PaperPoolService +from src.modules.idea_gen.state import IdeaGenState +from src.modules.idea_gen.utils import NodeExecutor + + +def paper_pool_process_node(state: IdeaGenState, executor: NodeExecutor | None = None) -> IdeaGenState: + """Process paper pool.""" + return PaperPoolService().process(state, executor=executor) diff --git a/backend/src/modules/idea_gen/nodes/report.py b/backend/src/modules/idea_gen/nodes/report.py new file mode 100644 index 00000000..938f11b4 --- /dev/null +++ b/backend/src/modules/idea_gen/nodes/report.py @@ -0,0 +1,214 @@ +"""Report node.""" + +from __future__ import annotations + +import json +import time +from typing import Any + +from src.modules.idea_gen.config import get_report_config +from src.modules.idea_gen.prompts import build_report_prompt, build_report_section_prompt +from src.modules.idea_gen.state import IdeaGenState +from src.modules.idea_gen.utils import NodeExecutor +from src.modules.idea_gen.utils import workflow_helpers +from src.modules.idea_gen.utils.report_builder import ( + build_citation_retry_feedback, + build_fixed_report_outline, + build_report_retrieval_context, + build_section_payload, + build_source_catalog, + lint_section_citations, + renumber_citations, + render_outline_markdown, + render_sources_section, + sanitize_section_markdown, +) +from src.modules.idea_gen.utils.report_source_handler import ReportSourceHandler +from src.modules.idea_gen.utils.state_utils import get_temp_value, set_final_report + + +def _messages_text(state: IdeaGenState) -> str: + if state.messages: + return "\n".join( + f"{message.get('role', 'user')}: {message.get('content', '')}" + for message in state.messages + ) + return f"user: {state.input.target}" + + +def report_node(state: IdeaGenState, executor: NodeExecutor | None = None) -> IdeaGenState: + """Generate the final report.""" + node_start = time.time() + print("\n[NODE] report - Generating final report...") + + messages_str = _messages_text(state) + executor = executor or NodeExecutor("report") + report_config = get_report_config() + workflow_date = get_temp_value(state, "workflow_date") + + hypotheses_json = "" + if state.hypotheses: + hypotheses_json = json.dumps( + [hypothesis.model_dump(exclude={"evidence_trace"}) for hypothesis in state.hypotheses], + ensure_ascii=False, + indent=2, + ) + + if not state.hypotheses: + system = build_report_prompt( + research_brief=state.research_brief, + messages=messages_str, + findings=state.notes, + date=workflow_date, + language=state.input.language, + target_venues=state.input.target_venues, + hypotheses_json=hypotheses_json, + ) + user = f"Research brief:\n{state.research_brief}\n\nNotes:\n{state.notes}" + response = executor.invoke_text(system, user) + response = ReportSourceHandler.repair_sources(response, state) + set_final_report(state, response) + workflow_helpers._save_artifact(state, "final_report.md", response) + workflow_helpers._save_node_state(state, "report") + total_ms = round((time.time() - node_start) * 1000, 2) + state.temp_data["report_timing"] = {"total_ms": total_ms} + print(f"[NODE] report - report.total_ms={total_ms:.2f}") + print(f"[NODE] report - Done, {len(response)} chars") + return state + + source_catalog = build_source_catalog(state) + report_retrieval_context = build_report_retrieval_context(state, source_catalog) + outline = build_fixed_report_outline(state) + outline_markdown = render_outline_markdown(outline) + sources_heading = next( + ( + section.get("heading", "## Sources") + for section in outline + if section.get("section_id") == "sources" + ), + "## Sources", + ) + + state.temp_data["report_outline"] = outline + state.temp_data["report_source_catalog"] = list(source_catalog.values()) + workflow_helpers._save_artifact(state, "report_outline.md", outline_markdown) + workflow_helpers._save_artifact( + state, + "report_source_catalog.json", + json.dumps(list(source_catalog.values()), ensure_ascii=False, indent=2), + ) + + report_sections = [ + section for section in outline + if section.get("section_id") != "sources" + ] + generated_sections: list[str] = [] + section_payloads: list[dict[str, Any]] = [] + section_lint_results: list[dict[str, Any]] = [] + for index, section in enumerate(report_sections, start=1): + section_payload = build_section_payload( + state, + section, + source_catalog, + retrieval_context=report_retrieval_context, + ) + section_payloads.append(section_payload) + print( + f"[REPORT] Section {index}/{len(report_sections)} - " + f"{section.get('heading', section.get('section_id', 'unknown'))}" + ) + system, user = build_report_section_prompt( + research_brief=state.research_brief, + section_id=section.get("section_id", ""), + section_heading=section.get("heading", ""), + section_goal=section.get("goal", ""), + section_instruction=section.get("instruction", ""), + outline_markdown=outline_markdown, + section_context_json=json.dumps(section_payload, ensure_ascii=False, indent=2), + allowed_sources_json=json.dumps( + section_payload.get("sources", []), + ensure_ascii=False, + indent=2, + ), + date=workflow_date, + language=state.input.language, + target_venues=state.input.target_venues, + ) + raw_section = executor.invoke_text(system, user) + normalized_section = sanitize_section_markdown( + raw_section, + section.get("heading", "## Section"), + ) + lint_result = lint_section_citations(section, section_payload, normalized_section) + + retry_attempts = 0 + max_retry_rounds = max(0, int(report_config.section_retry_max_rounds)) + while not lint_result["valid"] and retry_attempts < max_retry_rounds: + retry_attempts += 1 + print( + f"[REPORT] Citation lint retry for {section.get('section_id', 'unknown')}: " + + "; ".join(lint_result.get("errors", [])) + ) + retry_feedback = build_citation_retry_feedback(lint_result, section_payload) + retry_user = f"{user}\n\n\n{retry_feedback}\n" + retry_raw_section = executor.invoke_text(system, retry_user) + retry_normalized_section = sanitize_section_markdown( + retry_raw_section, + section.get("heading", "## Section"), + ) + retry_lint_result = lint_section_citations( + section, + section_payload, + retry_normalized_section, + ) + retry_lint_result["retried"] = True + retry_lint_result["retry_round"] = retry_attempts + retry_lint_result["first_pass_errors"] = lint_result.get( + "first_pass_errors", + lint_result.get("errors", []), + ) + if retry_lint_result["valid"] or len(retry_lint_result.get("valid_keys", [])) >= len( + lint_result.get("valid_keys", []) + ): + normalized_section = retry_normalized_section + lint_result = retry_lint_result + else: + lint_result["retried"] = True + lint_result["retry_round"] = retry_attempts + lint_result["retry_errors"] = retry_lint_result.get("errors", []) + + section_lint_results.append(lint_result) + generated_sections.append(normalized_section) + workflow_helpers._save_artifact( + state, + f"report_section_{index:02d}_{section.get('section_id', f'section_{index}')}.md", + normalized_section, + ) + + state.temp_data["report_section_inputs"] = section_payloads + state.temp_data["report_section_lint"] = section_lint_results + workflow_helpers._save_artifact( + state, + "report_section_lint.json", + json.dumps(section_lint_results, ensure_ascii=False, indent=2), + ) + report_body = "\n\n".join(generated_sections).strip() + renumbered_body, used_source_ids = renumber_citations(report_body, source_catalog) + sources_section = render_sources_section( + used_source_ids, + source_catalog, + heading=sources_heading, + ) + response = f"{renumbered_body}\n\n{sources_section}".strip() + + state.temp_data["report_used_source_ids"] = used_source_ids + state.temp_data["report_unknown_source_ids"] = [] + set_final_report(state, response) + workflow_helpers._save_artifact(state, "final_report.md", response) + workflow_helpers._save_node_state(state, "report") + + total_ms = round((time.time() - node_start) * 1000, 2) + state.temp_data["report_timing"] = {"total_ms": total_ms} + print(f"[NODE] report - report.total_ms={total_ms:.2f}") + print(f"[NODE] report - Done, {len(response)} chars") + return state diff --git a/backend/src/modules/idea_gen/nodes/research.py b/backend/src/modules/idea_gen/nodes/research.py new file mode 100644 index 00000000..7cad0be3 --- /dev/null +++ b/backend/src/modules/idea_gen/nodes/research.py @@ -0,0 +1,12 @@ +"""Research dispatch node.""" + +from __future__ import annotations + +from src.modules.idea_gen.services import ResearchService +from src.modules.idea_gen.state import IdeaGenState +from src.modules.idea_gen.utils import IdeaResearchExecutor + + +def research_dispatch_node(state: IdeaGenState, executor: IdeaResearchExecutor | None = None) -> IdeaGenState: + """Execute research dispatch.""" + return ResearchService().run(state, executor=executor) diff --git a/backend/src/modules/idea_gen/nodes/routing.py b/backend/src/modules/idea_gen/nodes/routing.py new file mode 100644 index 00000000..0d52f80c --- /dev/null +++ b/backend/src/modules/idea_gen/nodes/routing.py @@ -0,0 +1,84 @@ +"""Routing helpers for the idea_gen workflow graph.""" + +from __future__ import annotations + +from src.modules.idea_gen.config import get_workflow_config +from src.modules.idea_gen.state import IdeaGenState +from src.modules.idea_gen.utils.state_utils import ( + current_research_phase, + get_supervisor_action, + get_temp_value, + needs_clarification, + phase_completion_action, +) + + +def global_budget_exhausted(state: IdeaGenState) -> bool: + """Return whether the global workflow budget has been exhausted.""" + configured_limit = state.global_max_iterations or get_workflow_config().global_max_iterations + return state.iteration_count >= max(1, configured_limit) + + +def budget_exhausted_target(state: IdeaGenState) -> str: + """Choose the next safe terminal path when the global budget is exhausted.""" + if not state.seeds: + return "seed_discovery" + if not state.hypotheses and state.evidence_bundles: + return "hypothesis" + return "compress" + + +def route_clarify(state: IdeaGenState): + """Stop for clarification or continue to brief generation.""" + from src.modules.idea_gen.workflow import END + + return END if needs_clarification(state) else "brief" + + +def route_query_generation(state: IdeaGenState) -> str: + """Move to research when queries exist, otherwise hand control to supervisor.""" + if state.paper_queries or state.web_queries: + return "research_dispatch" + action = get_supervisor_action(state, "").strip().upper() + if action in {"GENERATE_SEEDS", "BUILD_HYPOTHESIS"}: + return route_supervisor(state) + return "supervisor" + + +def route_compress(state: IdeaGenState) -> str: + """Branch from compression into seed discovery or critic review.""" + phase = current_research_phase(state) + if phase == "broad" and not state.seeds: + return "seed_discovery" + return "critic" + + +def route_supervisor(state: IdeaGenState) -> str: + """Resolve supervisor action into the next node name.""" + phase = current_research_phase(state) + default_action = phase_completion_action(phase) + action = str(get_supervisor_action(state, default_action) or default_action).strip().upper() + routing = { + "THINK": "supervisor", + "CONDUCT_RESEARCH": "query_generation", + "GENERATE_SEEDS": "paper_pool_process", + "BUILD_HYPOTHESIS": "hypothesis", + } + + if global_budget_exhausted(state) and action in {"THINK", "CONDUCT_RESEARCH"}: + return budget_exhausted_target(state) + + if action in routing: + return routing[action] + + print(f"[ROUTE] supervisor - Unknown action '{action}', falling back to completion path") + return routing.get(default_action, budget_exhausted_target(state)) + + +def route_critic(state: IdeaGenState) -> str: + """Loop back for another iteration or continue to report.""" + if str(get_temp_value(state, "critic_action", "") or "").upper() == "ITERATE": + if global_budget_exhausted(state): + return "report" + return "supervisor" + return "report" diff --git a/backend/src/modules/idea_gen/nodes/seed.py b/backend/src/modules/idea_gen/nodes/seed.py new file mode 100644 index 00000000..6f91a97f --- /dev/null +++ b/backend/src/modules/idea_gen/nodes/seed.py @@ -0,0 +1,28 @@ +"""Seed nodes.""" + +from __future__ import annotations + +from src.modules.idea_gen.services import SeedService +from src.modules.idea_gen.state import IdeaGenState +from src.modules.idea_gen.utils import NodeExecutor + + +def seed_discovery_node( + state: IdeaGenState, + executor: NodeExecutor | None = None, + *, + validate_immediately: bool = True, + validator_executor: NodeExecutor | None = None, +) -> IdeaGenState: + """Generate candidate seeds.""" + return SeedService().discover( + state, + executor=executor, + validate_immediately=validate_immediately, + validator_executor=validator_executor, + ) + + +def seed_discovery_validate_node(state: IdeaGenState, executor: NodeExecutor | None = None) -> IdeaGenState: + """Validate candidate seeds.""" + return SeedService().validate(state, executor=executor) diff --git a/backend/src/modules/idea_gen/nodes/supervisor.py b/backend/src/modules/idea_gen/nodes/supervisor.py new file mode 100644 index 00000000..c46e62a9 --- /dev/null +++ b/backend/src/modules/idea_gen/nodes/supervisor.py @@ -0,0 +1,235 @@ +"""Supervisor nodes.""" + +from __future__ import annotations + +import json + +from src.modules.idea_gen.config import get_workflow_config +from src.modules.idea_gen.prompts import ( + build_supervisor_prompt, + build_supervisor_validation_prompt, +) +from src.modules.idea_gen.schemas import SupervisorDecision +from src.modules.idea_gen.state import IdeaGenState +from src.modules.idea_gen.utils import NodeExecutor +from src.modules.idea_gen.utils import workflow_helpers +from src.modules.idea_gen.utils.state_utils import ( + current_research_phase, + ensure_supervisor_think_phase, + get_phase_iteration_count, + get_temp_value, + phase_completion_action, + set_temp_value, +) +from src.modules.idea_gen.nodes.routing import global_budget_exhausted +from src.modules.idea_gen.utils.supervisor_utils import ( + apply_supervisor_decision, + compact_evidence_status_for_supervisor, + collect_phase_followup_tasks, + fallback_supervisor_decision, + tasks_to_topics, +) +from src.modules.idea_gen.utils.query_planning_utils import _summarize_query_feedback_for_prompt +from src.utils.structured_output import parse_structured_output + + +def supervisor_node( + state: IdeaGenState, + executor: NodeExecutor | None = None, + *, + validate_immediately: bool = True, + validator_executor: NodeExecutor | None = None, +) -> IdeaGenState: + """Choose the next workflow action.""" + print(f"\n[NODE] supervisor - Iteration {state.iteration_count}") + workflow_config = get_workflow_config() + max_topics = workflow_config.max_concurrent_research_units + state.temp_data.pop("supervisor_raw_response", None) + + phase = current_research_phase(state) + ensure_supervisor_think_phase(state, phase) + phase_iteration_count = get_phase_iteration_count(state, phase) + if global_budget_exhausted(state): + fallback = phase_completion_action(phase) + set_temp_value(state, "supervisor_action", fallback) + set_temp_value(state, "supervisor_tasks", []) + set_temp_value(state, "supervisor_topics", []) + print( + f"[NODE] supervisor - {fallback} " + f"(global budget reached: {state.iteration_count}/{state.global_max_iterations}, phase={phase})" + ) + workflow_helpers._save_node_state(state, f"supervisor_iter{state.iteration_count}") + return state + + if phase_iteration_count >= state.input.max_iterations: + fallback = phase_completion_action(phase) + set_temp_value(state, "supervisor_action", fallback) + set_temp_value(state, "supervisor_tasks", []) + set_temp_value(state, "supervisor_topics", []) + print( + f"[NODE] supervisor - {fallback} " + f"(phase budget reached: {phase_iteration_count}/{state.input.max_iterations}, phase={phase})" + ) + workflow_helpers._save_node_state(state, f"supervisor_iter{state.iteration_count}") + return state + + max_consecutive_thinks = max(1, int(workflow_config.max_consecutive_thinks)) + max_total_thinks = max(1, int(workflow_config.max_total_thinks_per_phase)) + last_action = str(get_temp_value(state, "supervisor_action", "") or "") + if last_action == "THINK": + consecutive_thinks = int(get_temp_value(state, "consecutive_thinks", 0) or 0) + total_thinks = int(get_temp_value(state, "total_thinks", 0) or 0) + if consecutive_thinks >= max_consecutive_thinks or total_thinks >= max_total_thinks: + fallback_tasks = collect_phase_followup_tasks(state, max_topics, phase) + fallback_action = "CONDUCT_RESEARCH" if fallback_tasks else phase_completion_action(phase) + set_temp_value(state, "supervisor_action", fallback_action) + set_temp_value( + state, + "supervisor_tasks", + fallback_tasks if fallback_action == "CONDUCT_RESEARCH" else [], + ) + set_temp_value( + state, + "supervisor_topics", + tasks_to_topics(fallback_tasks) if fallback_action == "CONDUCT_RESEARCH" else [], + ) + print( + f"[NODE] supervisor - {fallback_action} " + f"(THINK guard hit: consecutive={consecutive_thinks}/{max_consecutive_thinks}, " + f"total={total_thinks}/{max_total_thinks})" + ) + workflow_helpers._save_node_state(state, f"supervisor_iter{state.iteration_count}") + return state + + last_action = str(get_temp_value(state, "supervisor_action", "") or "") + last_reflection = state.supervisor_messages[-1].get("reflection", "") if state.supervisor_messages else "" + + system = build_supervisor_prompt( + date=get_temp_value(state, "workflow_date"), + max_researcher_iterations=state.input.max_iterations, + max_concurrent_research_units=max_topics, + last_action=last_action, + last_reflection=last_reflection, + research_phase=phase, + current_phase_iteration=phase_iteration_count, + ) + user = ( + f"Research brief:\n{state.research_brief}\n\n" + f"Notes:\n{state.notes}\n\n" + f"Iteration: {state.iteration_count}\n" + f"Phase Iteration: {phase_iteration_count}" + ) + query_state = get_temp_value(state, "query_state", {}) or {} + if query_state.get("active_paper_queries") or query_state.get("active_web_queries"): + user += ( + "\n\nCurrent Active Queries:\n" + f"- Paper: {json.dumps(query_state.get('active_paper_queries', []), ensure_ascii=False)}\n" + f"- Web: {json.dumps(query_state.get('active_web_queries', []), ensure_ascii=False)}" + ) + query_feedback = get_temp_value(state, "query_feedback", {}) or {} + if query_feedback: + user += ( + "\n\nRecent Query Feedback Summary:\n" + f"{json.dumps(_summarize_query_feedback_for_prompt(query_feedback), ensure_ascii=False, indent=2)}" + ) + if state.critic_feedback: + user += ( + f"\n\n[CRITIC FEEDBACK]\n{state.critic_feedback.get('blocking_issues', '')}\n" + f"{state.critic_feedback.get('suggestions', '')}" + ) + if phase == "evidence": + compact_status = compact_evidence_status_for_supervisor(state) + if compact_status: + user += ( + "\n\n\n" + f"{json.dumps(compact_status, ensure_ascii=False, indent=2)}\n" + "" + ) + executor = executor or NodeExecutor("supervisor") + try: + response = executor.invoke_text(system, user) + state.temp_data["supervisor_raw_response"] = response + workflow_helpers._save_artifact(state, f"supervisor_raw_iter{state.iteration_count}.txt", response) + print("[NODE] supervisor - Candidate decision generated") + if validate_immediately: + return supervisor_validate_node(state, executor=validator_executor) + except Exception as exc: + fallback = fallback_supervisor_decision( + state, + phase=phase, + phase_iteration_count=phase_iteration_count, + max_topics=max_topics, + error=exc, + node="supervisor", + message_template="supervisor decision fallback to {fallback}", + ) + print(f"[NODE] supervisor - Error: {exc}, fallback to {fallback}") + workflow_helpers._save_node_state(state, f"supervisor_iter{state.iteration_count}") + return state + + +def supervisor_validate_node(state: IdeaGenState, executor: NodeExecutor | None = None) -> IdeaGenState: + """Validate or repair supervisor output into strict JSON action.""" + print(f"\n[NODE] supervisor_validate - Iteration {state.iteration_count}") + workflow_config = get_workflow_config() + max_topics = workflow_config.max_concurrent_research_units + phase = current_research_phase(state) + phase_iteration_count = get_phase_iteration_count(state, phase) + candidate = str(state.temp_data.get("supervisor_raw_response", "") or "") + + if not candidate: + print("[NODE] supervisor_validate - No candidate output, using precomputed action") + workflow_helpers._save_node_state(state, f"supervisor_iter{state.iteration_count}") + return state + + executor = executor or NodeExecutor("supervisor_validator") + max_rounds = 3 + last_error: Exception | None = None + for attempt in range(max_rounds + 1): + try: + data = parse_structured_output(candidate, dict) + decision = SupervisorDecision(**data) + action, cleaned_tasks = apply_supervisor_decision( + state, + decision=decision, + phase=phase, + phase_iteration_count=phase_iteration_count, + max_topics=max_topics, + ) + state.temp.supervisor_validation_status = "validated" if attempt == 0 else "repaired" + state.temp.supervisor_validation_rounds = attempt + state.temp_data.pop("supervisor_raw_response", None) + print(f"[NODE] supervisor_validate - {action}, Tasks: {len(cleaned_tasks)}") + workflow_helpers._save_node_state(state, f"supervisor_iter{state.iteration_count}") + return state + except Exception as exc: + last_error = exc + if attempt >= max_rounds: + break + system, user = build_supervisor_validation_prompt( + raw_supervisor_output=candidate, + research_phase=phase, + max_concurrent_research_units=max_topics, + ) + candidate = executor.invoke_text(system, user) + workflow_helpers._save_artifact( + state, + f"supervisor_validation_attempt_{state.iteration_count}_{attempt + 1}.txt", + candidate, + ) + + if last_error is not None: + fallback = fallback_supervisor_decision( + state, + phase=phase, + phase_iteration_count=phase_iteration_count, + max_topics=max_topics, + error=last_error, + node="supervisor_validate", + message_template="supervisor validation fallback to {fallback}", + ) + state.temp.supervisor_validation_status = "failed" + state.temp_data.pop("supervisor_raw_response", None) + print(f"[NODE] supervisor_validate - Error: {last_error}, fallback to {fallback}") + workflow_helpers._save_node_state(state, f"supervisor_iter{state.iteration_count}") + return state diff --git a/backend/src/modules/idea_gen/prompts.py b/backend/src/modules/idea_gen/prompts.py index d1842871..3a8e10d7 100644 --- a/backend/src/modules/idea_gen/prompts.py +++ b/backend/src/modules/idea_gen/prompts.py @@ -2,7 +2,7 @@ from datetime import datetime -from src.modules.idea_gen.config import get_workflow_config +from src.modules.idea_gen.config import get_seed_config, get_workflow_config def build_clarify_prompt(messages: str, date: str | None = None) -> tuple[str, str]: @@ -154,6 +154,7 @@ def build_query_generation_prompt( current_active_queries: dict[str, list[str]] | None = None, supervisor_reflection: str = "", supervisor_topics: list[str] | None = None, + supervisor_tasks: list[dict[str, str]] | None = None, query_feedback: dict | None = None, query_history: dict | None = None, ) -> tuple[str, str]: @@ -164,6 +165,7 @@ def build_query_generation_prompt( venues_str = ", ".join(target_venues) if target_venues else "NIPS, ICML, ICLR" current_active_queries = current_active_queries or {} supervisor_topics = supervisor_topics or [] + supervisor_tasks = supervisor_tasks or [] query_feedback = query_feedback or {} query_history = query_history or {} @@ -216,10 +218,10 @@ def build_query_generation_prompt( {{ "need_more_queries": true, "paper_queries": [ - {{"query": "query1", "intent": "method_gap", "source": "model"}} + {{"query": "query1", "intent": "method_gap", "source": "model", "seed_id": "seed_1", "lane": "novelty", "rationale": "why this query is needed"}} ], "web_queries": [ - {{"query": "query2", "intent": "benchmark_evaluation", "source": "model"}} + {{"query": "query2", "intent": "benchmark_evaluation", "source": "model", "seed_id": "seed_1", "lane": "execution", "rationale": "why this query is needed"}} ], "missing_aspects": ["method evidence", "benchmark evidence"], "rewrite_strategy": "retain|tighten|retarget|stop", @@ -230,6 +232,7 @@ def build_query_generation_prompt( - `paper_queries` and `web_queries` may contain either strings or objects with `query`, `intent`, `source` - Prefer structured objects over plain strings +- When Supervisor structured tasks are provided, preserve the relevant `seed_id`, `lane`, and task rationale in generated query objects whenever possible - `missing_aspects` should be a short list of evidence gaps - Never return null or rename keys - Do not include explanations, bullets, or markdown outside the JSON @@ -250,6 +253,9 @@ def build_query_generation_prompt( Supervisor research intents: {supervisor_topics} +Supervisor structured tasks: +{supervisor_tasks} + Recent query feedback: {query_feedback} @@ -324,12 +330,15 @@ def build_supervisor_prompt( max_concurrent_research_units: int | None = None, last_action: str = "", last_reflection: str = "", + research_phase: str = "broad", + current_phase_iteration: int = 0, ) -> str: """Build academic supervisor prompt.""" if not date: date = datetime.now().strftime("%Y-%m-%d") workflow_config = get_workflow_config() + seed_config = get_seed_config() if max_researcher_iterations is None: max_researcher_iterations = workflow_config.default_max_iterations if max_concurrent_research_units is None: @@ -339,7 +348,60 @@ def build_supervisor_prompt( if last_action: context_info = f"\n\nLast action: {last_action}\nLast reflection: {last_reflection}\n\n" + if research_phase == "broad": + phase_label = "broad (seed discovery)" + valid_actions_str = f"""\ +- `THINK`: Reasoning phase - analyze what's missing, NO tasks output +- `CONDUCT_RESEARCH`: Action phase - provide 1-{max_concurrent_research_units} concrete research tasks +- `GENERATE_SEEDS`: Broad research is sufficient - generate seed pool from paper pool""" + phase_rules = f"""\ +- Use `GENERATE_SEEDS` when you have enough papers to identify {seed_config.min_distinct_seed_directions_for_completion}+ distinct research directions +- Do NOT use `RESEARCH_COMPLETE` or `BUILD_HYPOTHESIS` in this phase""" + output_contract = """\ +{ + "action": "THINK" | "CONDUCT_RESEARCH" | "GENERATE_SEEDS", + "reflection": "short reason for decision", + "tasks": [ + {"topic": "topic 1", "reason": "why this search is needed", "seed_id": "", "lane": ""} + ] +}""" + example_complete = """\ +Seed generation ready: +{ + "action": "GENERATE_SEEDS", + "reflection": "Paper pool covers problem space, key gaps, and cross-domain mechanisms. Ready to distill seeds.", + "tasks": [] +}""" + else: # evidence + phase_label = "evidence (hypothesis building)" + valid_actions_str = f"""\ +- `THINK`: Reasoning phase - analyze evidence gaps, NO tasks output +- `CONDUCT_RESEARCH`: Action phase - provide 1-{max_concurrent_research_units} supplementary research tasks +- `BUILD_HYPOTHESIS`: Evidence bundles are complete - build hypotheses""" + phase_rules = f"""\ +- Use `BUILD_HYPOTHESIS` when evidence bundles have completeness ≥ 0.8 or gaps are acceptable +- When an Evidence Status summary is provided, treat it as the primary readiness signal; only use `BUILD_HYPOTHESIS` when all seeds are marked `ready_for_hypothesis=true` +- If any seed has non-empty `blocking_lanes` or any lane has `quality_ok=false`, that seed is not ready yet +- Do NOT use `GENERATE_SEEDS` or `RESEARCH_COMPLETE` in this phase""" + output_contract = """\ +{ + "action": "THINK" | "CONDUCT_RESEARCH" | "BUILD_HYPOTHESIS", + "reflection": "short reason for decision", + "tasks": [ + {"topic": "topic 1", "reason": "why this search is needed", "seed_id": "seed_1", "lane": "novelty"} + ] +}""" + example_complete = """\ +Hypothesis ready: +{ + "action": "BUILD_HYPOTHESIS", + "reflection": "Evidence bundles cover inspiration, novelty, contradiction, and execution lanes. Ready to build hypotheses.", + "tasks": [] +}""" + return f"""You are the supervisor for an academic idea exploration workflow. Today's date is {date}. +Current research phase: **{phase_label}** +Current phase iteration count: {current_phase_iteration}/{max_researcher_iterations} {context_info} Decide the next workflow action based on accumulated research notes. Return a JSON decision for the orchestrator. @@ -351,38 +413,33 @@ def build_supervisor_prompt( **Phase 1: THINK (Reasoning)** - Analyze current research progress - Identify gaps, contradictions, or missing evidence -- Output: reflection only, NO topics +- Output: reflection only, NO tasks - Use when you need to reason about what's missing before deciding concrete actions **Phase 2: CONDUCT_RESEARCH (Action)** -- Based on previous THINK reflection, generate specific research topics -- Output: concrete topics to investigate (1-{max_concurrent_research_units} topics) -- Each topic must be searchable and concise (max 100 chars) - -**Phase 3: RESEARCH_COMPLETE** -- When research satisfies the completeness checklist -- Output: final reflection, NO topics +- Based on previous THINK reflection, generate specific research tasks +- Output: concrete tasks to investigate (1-{max_concurrent_research_units} tasks) +- Each task must contain a searchable `topic` field (max 100 chars) CRITICAL: -- THINK action MUST have empty topics list -- CONDUCT_RESEARCH action MUST have non-empty topics list -- After THINK, you will be called again to output CONDUCT_RESEARCH or RESEARCH_COMPLETE +- THINK action MUST have empty tasks list +- CONDUCT_RESEARCH action MUST have non-empty tasks list +- After THINK, you will be called again to output CONDUCT_RESEARCH or the phase-completion action - Research brief (academic problem statement) - Accumulated notes from completed research - Current iteration count +- Current phase iteration count -- `THINK`: Reasoning phase - analyze what's missing, NO topics output -- `CONDUCT_RESEARCH`: Action phase - provide 1-{max_concurrent_research_units} concrete research topics -- `RESEARCH_COMPLETE`: Notes sufficient for high-quality idea memo +{valid_actions_str} -Before marking RESEARCH_COMPLETE, verify notes cover: +Before marking phase complete, verify notes cover: **Problem Understanding**: - [ ] Problem significance clearly established @@ -416,30 +473,27 @@ def build_supervisor_prompt( - Follow ReAct pattern: THINK (reason) → CONDUCT_RESEARCH (act) -- Use THINK when you need to analyze gaps before deciding concrete topics -- Use CONDUCT_RESEARCH with at most {max_concurrent_research_units} topics per turn -- Each topic must be a concise research direction (max 100 chars) -- DO NOT output full research brief or markdown sections as topics -- Topics should be searchable phrases, not structured documents +- Use THINK when you need to analyze gaps before deciding concrete tasks +- Use CONDUCT_RESEARCH with at most {max_concurrent_research_units} tasks per turn +- Each task must contain a concise searchable `topic` (max 100 chars) +- Each task must include a short `reason` +- In evidence phase, each task should include `seed_id` and `lane` whenever the gap is tied to a specific hypothesis candidate +- DO NOT output full research brief or markdown sections as task topics +- Task topics should be searchable phrases, not structured documents - Example good topic: "agent memory management long-context LLM" - Example bad topic: "## Problem Statement\n- The core research..." - Prefer targeted literature queries over broad searches -- Use `RESEARCH_COMPLETE` when checklist is satisfied -- If iteration count ≥ {max_researcher_iterations}, prefer `RESEARCH_COMPLETE` unless critical gap exists -- CRITICAL: THINK must have empty topics, CONDUCT_RESEARCH must have non-empty topics -- If `action` is `RESEARCH_COMPLETE`, `topics` must be empty list -- Generate NEW topics that explore DIFFERENT aspects than previous iterations +- If current phase iteration count ≥ {max_researcher_iterations}, prefer phase-completion action unless critical gap exists +- CRITICAL: THINK must have empty tasks, CONDUCT_RESEARCH must have non-empty tasks +- Generate NEW task topics that explore DIFFERENT aspects than previous iterations - Avoid repeating queries that have already been searched - Focus on filling specific knowledge gaps identified in current notes +{phase_rules} Return ONLY valid JSON: -{{ - "action": "THINK" | "CONDUCT_RESEARCH" | "RESEARCH_COMPLETE", - "reflection": "short reason for decision", - "topics": ["topic 1", "topic 2"] -}} +{output_contract} @@ -447,25 +501,20 @@ def build_supervisor_prompt( {{ "action": "THINK", "reflection": "Notes lack recent work on long-context agent memory and theoretical foundations. Need to identify specific gaps before searching.", - "topics": [] + "tasks": [] }} Action phase (after THINK): {{ "action": "CONDUCT_RESEARCH", "reflection": "Targeting recent papers and theoretical frameworks based on identified gaps.", - "topics": [ - "Survey recent papers (2023-2026) on memory management in long-context LLM agents, focusing on NIPS/ICML/ICLR publications", - "Investigate theoretical frameworks for agent memory: formal definitions, complexity analysis, and fundamental limitations" + "tasks": [ + {{"topic": "memory management long-context llm agents recent work", "reason": "Need recent directly relevant papers to map the main landscape.", "seed_id": "", "lane": ""}}, + {{"topic": "agent memory formal definitions limitations", "reason": "Need theoretical foundations and failure boundaries.", "seed_id": "", "lane": ""}} ] }} -Research complete: -{{ - "action": "RESEARCH_COMPLETE", - "reflection": "Notes cover problem significance, recent literature, identified gaps, and potential hypotheses. Ready for idea memo.", - "topics": [] -}} +{example_complete} """ def build_researcher_prompt(date: str | None = None, search_tools_description: str = "", mcp_prompt: str = "") -> str: @@ -523,105 +572,14 @@ def build_researcher_prompt(date: str | None = None, search_tools_description: s """ -def build_compress_prompt(date: str | None = None, language: str = "zh") -> str: - """Build academic synthesis prompt.""" - if not date: - date = datetime.now().strftime("%Y-%m-%d") - - lang_instruction = f"\n\nIMPORTANT: Write synthesis in {language}." if language != "zh" else "" - - return f"""You are synthesizing academic research findings for idea exploration. Today's date is {date}.{lang_instruction} - - -Transform raw research notes into structured academic synthesis organized by papers and themes. - - - - -### 1. Paper Cards -For each key paper, create a card: -``` -**[Paper Title]** (Author et al., Venue Year) -- **Core Contribution**: One-sentence summary -- **Methodology**: Key technical approach -- **Key Results**: Main findings or claims -- **Limitations**: What it doesn't address -- **Relevance**: Why it matters for our research question -- **Citation**: [1] Full citation with URL -``` - -### 2. Thematic Organization -Group papers by research direction or methodology: -- **Theme A**: [List of papers addressing this theme] -- **Theme B**: [List of papers addressing this theme] - -### 3. Methodological Landscape -Compare approaches: -- **Approach X**: Papers using this, shared assumptions, trade-offs -- **Approach Y**: Papers using this, shared assumptions, trade-offs - -### 4. Evidence Quality Tags -Tag each claim: -- **[STRONG]**: Peer-reviewed, replicated, widely cited -- **[MODERATE]**: Peer-reviewed, single study -- **[WEAK]**: Preprint, blog, unverified - -### 5. Identified Gaps -Explicitly list: -- What existing methods cannot do -- Contradictions or debates in literature -- Underexplored directions -- Open research questions - -### 6. Temporal Trends -- Recent developments (last 1-2 years) -- Emerging directions -- Declining approaches - - - - -1. Preserve ALL citations - every paper must be numbered and listed -2. Organize by theme/method, NOT chronologically -3. Synthesize rather than quote verbatim - extract key insights -4. Maintain academic rigor - distinguish facts from speculation -5. Focus on IDEA-relevant information - skip experiment details, code, datasets -6. Number citations sequentially: [1], [2], [3]... - - - -## Paper Cards -[Individual paper summaries] - -## Thematic Organization -[Papers grouped by theme] - -## Methodological Landscape -[Approach comparisons] - -## Identified Gaps -[Explicit gap list] - -## Temporal Trends -[Recent developments] - -### Sources -[1] Paper Title: URL -[2] Paper Title: URL -... - - -CRITICAL: This synthesis will feed into idea memo generation. Preserve all information relevant to problem understanding, gap analysis, and hypothesis formation. -""" - - def build_report_prompt( research_brief: str, messages: str, findings: str, date: str | None = None, language: str = "zh", - target_venues: list[str] | None = None + target_venues: list[str] | None = None, + hypotheses_json: str = "", ) -> str: """Build academic idea memo prompt.""" if not date: @@ -630,19 +588,29 @@ def build_report_prompt( venues_str = ", ".join(target_venues) if target_venues else "NIPS, ICML, ICLR" lang_instruction = f"\n\nCRITICAL: Write entire memo in {language}." if language != "zh" else "" - return f"""Generate an academic idea exploration memo based on completed research. + hypotheses_block = "" + if hypotheses_json: + hypotheses_block = f"\n\n\n{hypotheses_json}\n" + + hypotheses_instruction = "" + if hypotheses_json: + hypotheses_instruction = ( + "\n\nIMPORTANT: The block contains structured research hypotheses " + "generated from evidence. The final report MUST be centered around these hypotheses. " + "Each hypothesis should map to a concrete Idea in Section 3. Use the hypothesis fields " + "(core_claim, novelty_delta, assumptions, risks, minimal_validation, falsification_signal) " + "to populate the corresponding report sections." + ) + + return f"""Generate an academic idea exploration memo based on completed research.{hypotheses_instruction} {research_brief} - -{messages} - - {findings} - +{hypotheses_block} Today's date is {date}. Target venues: {venues_str}{lang_instruction} @@ -719,6 +687,7 @@ def build_report_prompt( 9. Use evidence quality tags: [STRONG], [MODERATE], [WEAK] 10. Cite all papers: [1], [2], [3]... 11. Write in {language} +12. If are provided, each hypothesis maps to one Idea in Section 3; use hypothesis fields directly (core_claim → 核心洞察, novelty_delta → 与现有工作的本质区别, assumptions/risks → 可行性分析, minimal_validation/falsification_signal → 实施路径) @@ -731,73 +700,89 @@ def build_report_prompt( """ -compress_research_simple_human_message = """All above messages are about research conducted by an AI Researcher. Please clean up these findings. - -DO NOT summarize the information. I want the raw information returned, just in a cleaner format. Make sure all relevant information is preserved - you can rewrite findings verbatim.""" - - -def build_summarize_webpage_prompt(webpage_content: str, date: str | None = None) -> str: - """Build webpage summarization prompt.""" +def build_report_section_prompt( + research_brief: str, + section_id: str, + section_heading: str, + section_goal: str, + section_instruction: str, + outline_markdown: str, + section_context_json: str, + allowed_sources_json: str, + date: str | None = None, + language: str = "zh", + target_venues: list[str] | None = None, +) -> tuple[str, str]: + """Build prompt for generating one report section at a time.""" if not date: date = datetime.now().strftime("%Y-%m-%d") - return f"""You are tasked with summarizing the raw content of a webpage retrieved from a web search. Your goal is to create a summary that preserves the most important information from the original web page. This summary will be used by a downstream research agent, so it's crucial to maintain the key details without losing essential information. - -Here is the raw content of the webpage: - - -{webpage_content} - + venues_str = ", ".join(target_venues) if target_venues else "NIPS, ICML, ICLR" + idea_detail_structure = "" + if section_id.startswith("idea_detail_"): + idea_detail_structure = """ +13. Use exactly these markdown subheadings in this order: +### 核心假设与目标缺口 +### 最相关现有工作与关键差异 +### 方案机制 +### 最小验证路径 +### 主要风险与失败信号 +14. If Section Context JSON contains retrieval_expansion_work, treat those sources as supplementary background only; do not let them redefine the anchor hypothesis or the core mechanism""" -Please follow these guidelines to create your summary: + system = f"""You are writing one section of an academic idea exploration memo. -1. Identify and preserve the main topic or purpose of the webpage. -2. Retain key facts, statistics, and data points that are central to the content's message. -3. Keep important quotes from credible sources or experts. -4. Maintain the chronological order of events if the content is time-sensitive or historical. -5. Preserve any lists or step-by-step instructions if present. -6. Include relevant dates, names, and locations that are crucial to understanding the content. -7. Summarize lengthy explanations while keeping the core message intact. +Write ONLY the requested section in {language}. Do not write the full report. Do not add a Sources section. -When handling different types of content: +Target venues: {venues_str} +Today's date: {date} + +Requirements: +1. Start with exactly this heading: {section_heading} +2. Stay strictly within the requested section scope +3. Use only the provided section context and allowed sources +4. If you cite a source, use inline markers in this exact format: [@source_key] + - CORRECT: [@paper:abc123][@paper:def456][@paper:ghi789] + - WRONG: [@paper:abc123; @paper:def456; @paper:ghi789] + - Each citation must be in its own separate brackets +5. Never invent citations, URLs, papers, webpages, blogs, or project pages +6. Do not cite a source unless the surrounding sentence actually relies on it +7. Synthesize evidence; do not dump raw notes or quote long passages +8. Keep the writing concrete, technical, and academically rigorous +9. Distinguish evidence-backed claims from speculation +10. Do not include markdown code fences +11. If Section Context JSON contains idea_cards, write exactly one idea item per card, preserve the given order, and do not introduce any extra idea/framework/benchmark beyond those cards +12. If Section Context JSON contains exactly one hypothesis, that hypothesis is the anchor of the section; supporting sources may only ground prior work, contrast, or validation, and must not replace the main proposal with another paper's agenda{idea_detail_structure}""" + + user = f""" +{research_brief} + -- For news articles: Focus on the who, what, when, where, why, and how. -- For scientific content: Preserve methodology, results, and conclusions. -- For opinion pieces: Maintain the main arguments and supporting points. -- For product pages: Keep key features, specifications, and unique selling points. + +{outline_markdown} + -Your summary should be significantly shorter than the original content but comprehensive enough to stand alone as a source of information. Aim for about 25-30 percent of the original length, unless the content is already concise. + +Heading: {section_heading} +Goal: {section_goal} +Instruction: {section_instruction} + -Present your summary in the following format: +
+{section_context_json} +
-``` -{{{{ - "summary": "Your summary here, structured with appropriate paragraphs or bullet points as needed", - "key_excerpts": "First important quote or excerpt, Second important quote or excerpt, Third important quote or excerpt, ...Add more excerpts as needed, up to a maximum of 5" -}}}} -``` + +{allowed_sources_json} + -Here are two examples of good summaries: +Return only the markdown for {section_heading}.""" -Example 1 (for a news article): -```json -{{{{ - "summary": "On July 15, 2023, NASA successfully launched the Artemis II mission from Kennedy Space Center. This marks the first crewed mission to the Moon since Apollo 17 in 1972. The four-person crew, led by Commander Jane Smith, will orbit the Moon for 10 days before returning to Earth. This mission is a crucial step in NASA's plans to establish a permanent human presence on the Moon by 2030.", - "key_excerpts": "Artemis II represents a new era in space exploration, said NASA Administrator John Doe. The mission will test critical systems for future long-duration stays on the Moon, explained Lead Engineer Sarah Johnson. We're not just going back to the Moon, we're going forward to the Moon, Commander Jane Smith stated during the pre-launch press conference." -}}}} -``` + return system, user -Example 2 (for a scientific article): -```json -{{{{ - "summary": "A new study published in Nature Climate Change reveals that global sea levels are rising faster than previously thought. Researchers analyzed satellite data from 1993 to 2022 and found that the rate of sea-level rise has accelerated by 0.08 mm/year² over the past three decades. This acceleration is primarily attributed to melting ice sheets in Greenland and Antarctica. The study projects that if current trends continue, global sea levels could rise by up to 2 meters by 2100, posing significant risks to coastal communities worldwide.", - "key_excerpts": "Our findings indicate a clear acceleration in sea-level rise, which has significant implications for coastal planning and adaptation strategies, lead author Dr. Emily Brown stated. The rate of ice sheet melt in Greenland and Antarctica has tripled since the 1990s, the study reports. Without immediate and substantial reductions in greenhouse gas emissions, we are looking at potentially catastrophic sea-level rise by the end of this century, warned co-author Professor Michael Green." -}}}} -``` -Remember, your goal is to create a summary that can be easily understood and utilized by a downstream research agent while preserving the most critical information from the original webpage. +compress_research_simple_human_message = """All above messages are about research conducted by an AI Researcher. Please clean up these findings. -Today's date is {date}.""" +DO NOT summarize the information. I want the raw information returned, just in a cleaner format. Make sure all relevant information is preserved - you can rewrite findings verbatim.""" def build_score_prompt(title: str, abstract: str) -> tuple[str, str]: @@ -1022,3 +1007,285 @@ def build_compress_empirical_support_prompt(language: str = "zh") -> str: - Max ~1500 words {lang}""" + + +import json + + +def build_seed_discovery_prompt( + paper_summaries: str, + research_brief: str, + n_seeds: int = 2, + paper_catalog: list[dict[str, str]] | None = None, +) -> tuple[str, str]: + """Prompt for seed_discovery_node: paper pool → Seed list.""" + catalog_lines = "" + if paper_catalog: + rendered = [ + f'- "{item.get("paper_key", "").strip()}": {item.get("title", "").strip()}' + for item in paper_catalog + if str(item.get("paper_key", "")).strip() and str(item.get("title", "")).strip() + ] + if rendered: + catalog_lines = "Available paper keys:\n" + "\n".join(rendered) + + system = f"""CRITICAL INSTRUCTION: You MUST output ONLY a valid JSON array. No other text is allowed. + +You are a JSON generator for research seed extraction. Your task: analyze the paper summaries and research brief, then output EXACTLY {n_seeds} research seeds as a JSON array. + +MANDATORY OUTPUT FORMAT - Your response must start with '[' and end with ']': +[ + {{ + "seed_id": "seed_1", + "target_gap": "specific gap in current research", + "mechanism": "concrete mechanism borrowed from papers", + "scenario": "target application scenario", + "expected_gain": "measurable expected improvement", + "source_paper_keys": ["paper_key_1", "paper_key_2"] + }} +] + +Example (for reference only): +Input: research topic is long-context QA, papers discuss retrieval noise and hierarchical compression +Output: +[ + {{ + "seed_id": "seed_1", + "target_gap": "Current long-context QA systems do not adapt retrieval depth to evidence dispersion.", + "mechanism": "Use hierarchical compression to decide when to expand or prune retrieved long units.", + "scenario": "Multi-document question answering with dispersed evidence.", + "expected_gain": "Higher answer accuracy at lower context cost.", + "source_paper_keys": ["paper_a", "paper_b"] + }}, + {{ + "seed_id": "seed_2", + "target_gap": "Long-context readers lack an explicit memory state for previously retrieved evidence.", + "mechanism": "Persist structured evidence slots across retrieval rounds.", + "scenario": "Agentic long-context workflows with iterative search.", + "expected_gain": "Less redundant retrieval and better multi-hop reasoning.", + "source_paper_keys": ["paper_a"] + }} +] + +STRICT RULES: +- Output EXACTLY {n_seeds} seeds +- Each seed must be distinct (different gaps or mechanisms) +- source_paper_keys must reference actual paper keys from the provided catalog +- Be specific and falsifiable, not vague +- NO markdown fences (no ```) +- NO headings or explanations +- NO prose or commentary +- NO text before '[' or after ']' + +VALIDATION CHECKLIST before you respond: +✓ Does your response start with '[' ? +✓ Does your response end with ']' ? +✓ Is it valid JSON? +✓ Does it contain exactly {n_seeds} seed objects? +✓ Are all required fields present in each seed? + +START YOUR RESPONSE WITH '[' NOW.""" + + user = f"""Research brief: +{research_brief} + +{catalog_lines} + +Paper pool summaries: +{paper_summaries} + +Remember: Output ONLY the JSON array. Start with '[' and end with ']'. No other text.""" + + return system, user + + +def build_seed_validation_prompt( + raw_seed_output: str, + research_brief: str, + paper_catalog: list[dict[str, str]] | None = None, + n_seeds: int = 2, +) -> tuple[str, str]: + """Prompt for seed_discovery validation/repair.""" + catalog_json = "[]" + if paper_catalog: + catalog_json = json.dumps(paper_catalog, ensure_ascii=False, indent=2) + + system = f"""CRITICAL: You are a JSON repair tool. The candidate output is INVALID. + +Your ONLY task: Convert the candidate output into a valid JSON array of {n_seeds} research seeds. + +MANDATORY OUTPUT FORMAT - Start with '[' and end with ']': +[ + {{ + "seed_id": "seed_1", + "target_gap": "specific research gap", + "mechanism": "concrete mechanism", + "scenario": "target scenario", + "expected_gain": "expected improvement", + "source_paper_keys": ["key1", "key2"] + }} +] + +REPAIR STRATEGY: +1. If candidate contains JSON array → extract and validate it +2. If candidate contains prose/markdown → extract seed ideas and convert to JSON +3. If candidate is completely invalid → synthesize {n_seeds} seeds from research brief + +STRICT OUTPUT RULES: +- Output EXACTLY {n_seeds} seed objects +- NO markdown fences (no ```) +- NO explanations or commentary +- NO text before '[' or after ']' +- source_paper_keys must use keys from the available paper keys list +- Preserve substantive meaning from candidate when possible + +VALIDATION CHECKLIST: +✓ Response starts with '[' ? +✓ Response ends with ']' ? +✓ Valid JSON syntax? +✓ Exactly {n_seeds} seeds? +✓ All required fields present? + +START YOUR RESPONSE WITH '[' NOW.""" + + user = f"""Research brief: +{research_brief} + +Available paper keys: +{catalog_json} + +Candidate seed output (INVALID - needs repair): +{raw_seed_output} + +Output the repaired JSON array now. Start with '[' and end with ']'.""" + + return system, user + + +def build_supervisor_validation_prompt( + raw_supervisor_output: str, + research_phase: str, + max_concurrent_research_units: int, +) -> tuple[str, str]: + """Prompt for supervisor output validation/repair.""" + if research_phase == "evidence": + action_contract = '"THINK" | "CONDUCT_RESEARCH" | "BUILD_HYPOTHESIS"' + task_shape = '{"topic": "topic 1", "reason": "why this search is needed", "seed_id": "seed_1", "lane": "novelty"}' + else: + action_contract = '"THINK" | "CONDUCT_RESEARCH" | "GENERATE_SEEDS"' + task_shape = '{"topic": "topic 1", "reason": "why this search is needed", "seed_id": "", "lane": ""}' + + system = f"""You are a strict JSON validator and repairer for supervisor decisions. + +Convert the candidate supervisor output into valid JSON. + +Return ONLY valid JSON: +{{ + "action": {action_contract}, + "reflection": "short reason for decision", + "tasks": [ + {task_shape} + ] +}} + +Rules: +- Output only JSON, no prose or markdown +- Provide at most {max_concurrent_research_units} tasks +- If action is THINK, tasks must be empty +- Preserve the intent of the candidate output instead of inventing a new plan""" + + user = f"""Current phase: {research_phase} + +Candidate supervisor output to validate/repair: +{raw_supervisor_output}""" + + return system, user + + +def build_hypothesis_validation_prompt( + raw_hypothesis_output: str, + seed_json: str, + evidence_bundle_json: str, + allowed_paper_keys: list[str] | None = None, +) -> tuple[str, str]: + """Prompt for hypothesis output validation/repair.""" + allowed_keys_json = json.dumps(list(allowed_paper_keys or []), ensure_ascii=False) + + system = """You are a strict JSON validator and repairer for research hypotheses. + +Convert the candidate hypothesis output into valid JSON. + +Return ONLY valid JSON: +{ + "hypothesis_id": "hyp_", + "seed_id": "...", + "title": "...", + "core_claim": "one sentence falsifiable claim", + "target_gap": "...", + "nearest_prior_work": ["paper_key_1", "paper_key_2"], + "novelty_delta": "what this adds beyond nearest prior work", + "assumptions": ["assumption 1", "assumption 2"], + "risks": ["risk 1", "risk 2"], + "minimal_validation": "smallest experiment that could confirm/refute", + "falsification_signal": "concrete observable that would falsify the claim", + "expected_upside": "...", + "expected_cost": "..." +} + +Rules: +- Output only JSON, no prose or markdown +- Preserve the substantive claim of the candidate output +- nearest_prior_work must be selected from the allowed paper keys when possible +- core_claim must be a single falsifiable sentence""" + + user = f"""Seed: +{seed_json} + +Evidence bundle: +{evidence_bundle_json} + +Allowed prior-work paper keys: +{allowed_keys_json} + +Candidate hypothesis output to validate/repair: +{raw_hypothesis_output}""" + + return system, user + + +def build_hypothesis_prompt( + seed_json: str, + evidence_bundle_json: str, +) -> tuple[str, str]: + """Prompt for hypothesis_node: Seed + EvidenceBundle → Hypothesis.""" + system = """You are a research hypothesis architect. Given a research seed and its evidence bundle, construct a rigorous, falsifiable hypothesis. + +Return ONLY valid JSON: +{ + "hypothesis_id": "hyp_", + "seed_id": "...", + "title": "...", + "core_claim": "one sentence falsifiable claim", + "target_gap": "...", + "nearest_prior_work": ["paper_key_1", "paper_key_2"], + "novelty_delta": "what this adds beyond nearest prior work", + "assumptions": ["assumption 1", "assumption 2"], + "risks": ["risk 1", "risk 2"], + "minimal_validation": "smallest experiment that could confirm/refute", + "falsification_signal": "concrete observable that would falsify the claim", + "expected_upside": "...", + "expected_cost": "..." +} + +Rules: +- core_claim must be a single falsifiable sentence +- nearest_prior_work must reference paper keys from the evidence bundle +- minimal_validation must be concrete and achievable""" + + user = f"""Seed: +{seed_json} + +Evidence bundle: +{evidence_bundle_json}""" + + return system, user diff --git a/backend/src/modules/idea_gen/schemas.py b/backend/src/modules/idea_gen/schemas.py index 26081d86..b12a47ea 100644 --- a/backend/src/modules/idea_gen/schemas.py +++ b/backend/src/modules/idea_gen/schemas.py @@ -4,57 +4,67 @@ from pydantic import BaseModel, Field, model_validator -class ClarifyDecision(BaseModel): - """Clarification decision output.""" - need_clarification: bool - question: str = "" - verification: str = "" +class SupervisorTask(BaseModel): + """Structured supervisor follow-up task.""" + topic: str + reason: str = "" + seed_id: str = "" + lane: str = "" class SupervisorDecision(BaseModel): """Supervisor decision output.""" - action: Literal["THINK", "CONDUCT_RESEARCH", "RESEARCH_COMPLETE"] + action: Literal["THINK", "CONDUCT_RESEARCH", "RESEARCH_COMPLETE", "GENERATE_SEEDS", "BUILD_HYPOTHESIS"] reflection: str + tasks: list[SupervisorTask] = Field(default_factory=list) topics: list[str] = Field(default_factory=list) @model_validator(mode='after') def validate_action_topics(self): - if self.action == "THINK" and self.topics: - raise ValueError("THINK action must not have topics") - if self.action == "CONDUCT_RESEARCH" and not self.topics: - raise ValueError("CONDUCT_RESEARCH action must have topics") + if not self.tasks and self.topics: + self.tasks = [SupervisorTask(topic=topic) for topic in self.topics if str(topic).strip()] + if self.tasks and not self.topics: + self.topics = [task.topic for task in self.tasks if task.topic] + if self.action == "THINK" and (self.topics or self.tasks): + raise ValueError("THINK action must not have tasks") + if self.action == "CONDUCT_RESEARCH" and not (self.topics or self.tasks): + raise ValueError("CONDUCT_RESEARCH action must have tasks") return self -class ResearcherDecision(BaseModel): - """Researcher decision output.""" - action: Literal["SEARCH", "THINK", "FINISH"] - queries: list[str] = Field(default_factory=list) - provider: str = "tavily" - reflection: str = "" - - -class QueryPlanItem(BaseModel): - """Structured query plan item used by iterative query generation.""" - - query: str - role: str = "candidate" - channel: Literal["paper", "web"] = "paper" - source: str = "model" - intent: str = "" - rationale: str = "" - score: float | None = None - - -class QueryRoleFeedback(BaseModel): - """Role-level feedback summary for iterative planning.""" - - role: str - channel: Literal["paper", "web"] - query: str = "" - status: str = "missing" - recommendation: str = "review" - evidence: dict[str, Any] = Field(default_factory=dict) +class NodeError(BaseModel): + """Structured workflow error record.""" + + node: str + error_type: str + message: str + timestamp: str + recoverable: bool + + +class WorkflowTempState(BaseModel): + """Incrementally typed temporary workflow state.""" + + workflow_date: str = "" + need_clarification: bool = False + clarification_question: str = "" + research_phase: str = "broad" + supervisor_action: str = "" + supervisor_tasks: list[dict[str, Any]] = Field(default_factory=list) + supervisor_topics: list[str] = Field(default_factory=list) + supervisor_validation_status: str = "" + supervisor_validation_rounds: int = 0 + consecutive_thinks: int = 0 + total_thinks: int = 0 + think_phase: str = "broad" + query_state: dict[str, Any] = Field(default_factory=dict) + query_feedback: dict[str, Any] = Field(default_factory=dict) + seed_validation_status: str = "" + seed_validation_rounds: int = 0 + evidence_status: dict[str, Any] = Field(default_factory=dict) + final_papers_for_compress: list[dict[str, Any]] = Field(default_factory=list) + compressed_papers: list[dict[str, Any]] = Field(default_factory=list) + critic_action: str = "" class PaperCandidate(BaseModel): @@ -177,3 +187,56 @@ class ResearchStorageManifest(BaseModel): papers: list[CompressedPaperSummary] = Field(default_factory=list) search_results: list[CompressedSearchResult] = Field(default_factory=list) created_at: datetime + + +# --------------------------------------------------------------------------- +# Seed → Evidence → Hypothesis pipeline schemas +# --------------------------------------------------------------------------- + +class Seed(BaseModel): + """A research seed distilled from the broad paper pool.""" + seed_id: str + target_gap: str + mechanism: str + scenario: str + expected_gain: str + source_paper_keys: list[str] = Field(default_factory=list) + + +class EvidenceItem(BaseModel): + """Single evidence entry retrieved for a seed.""" + paper_key: str + title: str + excerpt: str + relevance: str + lane_fit: Literal["strong", "partial", "weak"] = "partial" + evidence_signals: list[str] = Field(default_factory=list) + + +class EvidenceBundle(BaseModel): + """All evidence collected for one seed across 4 retrieval lanes.""" + seed_id: str + inspiration: list[EvidenceItem] = Field(default_factory=list) + novelty: list[EvidenceItem] = Field(default_factory=list) + contradiction: list[EvidenceItem] = Field(default_factory=list) + execution: list[EvidenceItem] = Field(default_factory=list) + evidence_gaps: list[str] = Field(default_factory=list) + completeness: float = 0.0 + + +class Hypothesis(BaseModel): + """A falsifiable research hypothesis grounded in evidence.""" + hypothesis_id: str + seed_id: str + title: str + core_claim: str + target_gap: str + nearest_prior_work: list[str] = Field(default_factory=list) + novelty_delta: str + assumptions: list[str] = Field(default_factory=list) + risks: list[str] = Field(default_factory=list) + minimal_validation: str + falsification_signal: str + expected_upside: str + expected_cost: str + evidence_trace: EvidenceBundle diff --git a/backend/src/modules/idea_gen/services/__init__.py b/backend/src/modules/idea_gen/services/__init__.py new file mode 100644 index 00000000..4332c8b4 --- /dev/null +++ b/backend/src/modules/idea_gen/services/__init__.py @@ -0,0 +1,13 @@ +"""Service layer for heavy idea_gen workflow nodes.""" + +from .compression_service import CompressionService +from .paper_pool_service import PaperPoolService +from .research_service import ResearchService +from .seed_service import SeedService + +__all__ = [ + "CompressionService", + "PaperPoolService", + "ResearchService", + "SeedService", +] diff --git a/backend/src/modules/idea_gen/services/compression_service.py b/backend/src/modules/idea_gen/services/compression_service.py new file mode 100644 index 00000000..ae3140e1 --- /dev/null +++ b/backend/src/modules/idea_gen/services/compression_service.py @@ -0,0 +1,256 @@ +"""Service layer for paper compression.""" + +from __future__ import annotations + +import time +from concurrent.futures import as_completed +from threading import Semaphore +from typing import Any + +from src.modules.idea_gen.config import get_paper_processing_config +from src.modules.idea_gen.utils.error_utils import handle_node_error +from src.modules.idea_gen.utils.node_executor import NodeExecutor +from src.modules.idea_gen.utils import workflow_helpers +from src.modules.idea_gen.utils.paper_cache_store import PaperCacheStore +from src.modules.idea_gen.utils.state_utils import ( + get_compressed_papers, + get_final_papers_for_compress, + set_compressed_papers, +) +from src.modules.idea_gen.utils.storage_manager import StorageManager +from src.modules.idea_gen.utils.thread_pool_manager import ThreadPoolManager +from src.modules.idea_gen.utils.compression_utils import ( + _compress_single_paper, + _get_or_parse_paper_for_compress, +) + + +class CompressionService: + """Encapsulate paper download, parsing, and compression.""" + + def _download_and_parse_papers( + self, + state: Any, + *, + final_papers: list[dict[str, Any]], + papers_dir: Any, + storage: StorageManager | None, + cache_store: PaperCacheStore | None, + paper_config: Any, + ) -> list[dict[str, Any]]: + from src.modules.idea_gen import tools as idea_gen_tools + + if not final_papers: + return [] + + print(f"[COMPRESS] Downloading {len(final_papers)} final selected papers...") + mineru_semaphore = Semaphore(paper_config.mineru_max_concurrency) + parsed_papers: list[tuple[int, dict[str, Any]]] = [] + pool = ThreadPoolManager.get_pdf_parse_pool() + + future_to_idx = { + pool.submit( + _get_or_parse_paper_for_compress, + paper, + index, + papers_dir=papers_dir, + storage=storage, + cache_store=cache_store, + parse_paper_to_markdown_with_parser=idea_gen_tools.parse_paper_to_markdown_with_parser, + paper_config=paper_config, + mineru_semaphore=mineru_semaphore, + ): index + for index, paper in enumerate(final_papers) + } + for future in as_completed(future_to_idx): + index = future_to_idx[future] + try: + result = future.result(timeout=paper_config.pdf_timeout) + parsed_papers.append((index, result)) + status = "(cache)" if result["cache_hit"] else "(partial)" if result["is_partial"] else "" + print(f"[COMPRESS] ✓ {result['title'][:50]} {status}") + except Exception as exc: + handle_node_error( + state, + node="compress", + error=exc, + recoverable=True, + message=f"paper parse failed for {final_papers[index].get('title', 'unknown')}", + ) + print(f"[COMPRESS] ✗ {final_papers[index].get('title', 'unknown')[:50]}: {exc}") + + parsed_papers.sort(key=lambda item: item[0]) + return [item[1] for item in parsed_papers] + + def _compress_parsed_papers( + self, + state: Any, + *, + parsed_papers: list[dict[str, Any]], + papers_dir: Any, + cache_store: PaperCacheStore | None, + paper_config: Any, + language: str, + executor: NodeExecutor, + storage: StorageManager | None, + ) -> tuple[list[dict[str, Any]], str]: + if not parsed_papers: + return [], "" + + strategy = paper_config.compression_strategy + strategy_key = f"{strategy}|{language}" + print(f"[COMPRESS] Using strategy: {strategy}") + compressed_text = f"\n\n[Papers - {strategy.upper()} Compressed]\n\n" + papers_to_compress = list(enumerate(parsed_papers, 1)) + paper_results: dict[int, dict[str, Any]] = {} + pool = ThreadPoolManager.get_compute_pool() + + future_to_position = { + pool.submit( + _compress_single_paper, + position, + paper, + papers_dir=papers_dir, + cache_store=cache_store, + strategy=strategy, + strategy_key=strategy_key, + paper_config=paper_config, + language=language, + executor=executor, + storage=storage, + ): position + for position, paper in papers_to_compress + } + for future in as_completed(future_to_position): + position = future_to_position[future] + try: + paper_results[position] = future.result() + except Exception as exc: + paper = parsed_papers[position - 1] + handle_node_error( + state, + node="compress", + error=exc, + recoverable=True, + message=f"paper compression failed for {paper.get('paper_id', 'unknown')}", + ) + print(f"[COMPRESS] Failed for {paper['paper_id']}: {exc}") + paper_results[position] = { + "position": position, + "paper": paper, + "text": f"## Paper {position}: {paper['title']}\n\n(compression failed: {exc})\n\n", + "log": None, + } + + completed_papers: list[dict[str, Any]] = [] + compression_cache_hits = 0 + for position, _paper in papers_to_compress: + result = paper_results[position] + completed_papers.append(result["paper"]) + compressed_text += result["text"] + if result["paper"].get("compression_cache_hit"): + compression_cache_hits += 1 + if result["log"]: + print(result["log"]) + + state.temp_data["compression_cache_summary"] = { + "strategy_key": strategy_key, + "hits": compression_cache_hits, + "misses": len(completed_papers) - compression_cache_hits, + "total": len(completed_papers), + } + if storage: + storage.save_manifest(run_id=state.thread_id or "default") + state.temp_data["storage_manifest"] = { + "papers_count": len(storage.papers), + "search_results_count": len(storage.search_results), + } + return completed_papers, compressed_text + + @staticmethod + def _assemble_notes_payload( + state: Any, + *, + compressed_papers_text: str, + paper_config: Any, + ) -> None: + if paper_config.enable_paper_pool: + final_papers = get_final_papers_for_compress(state) + selection_info = dict(state.temp_data.get("paper_pool_selection") or {}) + summary_lines = ["[Final Paper Pool Summary]"] + summary_lines.append(f"- Scored papers: {selection_info.get('scored_count', 0)}") + summary_lines.append(f"- Passed threshold: {selection_info.get('threshold_pass_count', 0)}") + summary_lines.append(f"- Selected for download: {selection_info.get('selected_for_download_count', 0)}") + for paper in final_papers: + summary_lines.append( + f"- #{paper.get('final_selection_rank', '?')} {paper.get('title', '')} " + f"(score={paper.get('relevance_score', 0.0):.2f}, " + f"citation_expanded={bool(paper.get('from_citation_expansion'))}, " + f"query_topic={paper.get('query_topic', '') or '-'})" + ) + state.notes = "\n".join(summary_lines) + if compressed_papers_text: + state.notes += "\n\n" + compressed_papers_text + state.raw_notes = "" + return + + state.notes += "\n\n" + compressed_papers_text if state.notes else compressed_papers_text + state.notes += "\n\n" + state.raw_notes if state.raw_notes else "" + state.raw_notes = "" + + def run(self, state: Any, executor: NodeExecutor | None = None) -> Any: + node_start = time.time() + print("\n[NODE] compress - Section-based compression...") + + output_dir = workflow_helpers._get_output_dir(state) + papers_dir = output_dir / "papers" + papers_dir.mkdir(parents=True, exist_ok=True) + executor = executor or NodeExecutor("compress") + language = state.input.language + paper_config = get_paper_processing_config() + storage = StorageManager(output_dir) if paper_config.enable_two_tier_storage else None + cache_store = workflow_helpers._create_paper_cache_store() + if cache_store: + state.temp_data["paper_cache_db_path"] = str(cache_store.db_path) + + final_papers = get_final_papers_for_compress(state) + parsed_papers = ( + self._download_and_parse_papers( + state, + final_papers=final_papers, + papers_dir=papers_dir, + storage=storage, + cache_store=cache_store, + paper_config=paper_config, + ) + if paper_config.enable_paper_pool and final_papers + else get_compressed_papers(state) + ) + set_compressed_papers(state, parsed_papers) + + completed_papers, compressed_papers_text = self._compress_parsed_papers( + state, + parsed_papers=parsed_papers, + papers_dir=papers_dir, + cache_store=cache_store, + paper_config=paper_config, + language=language, + executor=executor, + storage=storage, + ) + set_compressed_papers(state, completed_papers) + self._assemble_notes_payload( + state, + compressed_papers_text=compressed_papers_text, + paper_config=paper_config, + ) + + if compressed_papers_text: + workflow_helpers._save_artifact(state, "compressed_notes.md", compressed_papers_text) + + total_ms = round((time.time() - node_start) * 1000, 2) + state.temp_data["compress_timing"] = {"total_ms": total_ms} + print(f"[NODE] compress - compress.total_ms={total_ms:.2f}") + print(f"[NODE] compress - Done, total notes: {len(state.notes)} chars") + workflow_helpers._save_node_state(state, "compress") + return state diff --git a/backend/src/modules/idea_gen/services/paper_pool_service.py b/backend/src/modules/idea_gen/services/paper_pool_service.py new file mode 100644 index 00000000..bb4d7efc --- /dev/null +++ b/backend/src/modules/idea_gen/services/paper_pool_service.py @@ -0,0 +1,315 @@ +"""Service layer for paper pool processing.""" + +from __future__ import annotations + +import json +import time +from typing import Any + +from src.modules.idea_gen.config import get_paper_processing_config +from src.modules.idea_gen.utils.citation_expander import CitationExpander +from src.modules.idea_gen.utils.node_executor import NodeExecutor +from src.modules.idea_gen.utils.paper_relevance_scorer import PaperRelevanceScorer +from src.modules.idea_gen.utils import workflow_helpers +from src.modules.idea_gen.utils.paper_utils import ( + _complete_final_paper_sources, + _paper_ranking_sort_key, + _resolve_download_source_url, + _resolve_public_paper_url, +) +from src.modules.idea_gen.utils.state_utils import ( + get_research_runs, + get_temp_value, + set_final_papers_for_compress, + sync_query_feedback, +) +from src.modules.idea_gen.utils.paper_cache_store import build_paper_key_from_record + + +class PaperPoolService: + """Encapsulate scoring, citation expansion, and final paper selection.""" + + def process(self, state: Any, executor: NodeExecutor | None = None) -> Any: + node_start = time.time() + print("\n[NODE] paper_pool_process - Processing paper pool...") + paper_config = get_paper_processing_config() + if not paper_config.enable_paper_pool or not state.paper_pool: + print("[NODE] paper_pool_process - Skipped") + return state + + paper_pool_manager = state.paper_pool + all_papers = paper_pool_manager.get_all_papers() + print(f"[NODE] paper_pool_process - Pool has {len(all_papers)} papers") + if not all_papers: + return state + + workflow_helpers._save_artifact( + state, + "paper_pool_pre_scoring.json", + json.dumps(all_papers, ensure_ascii=False, indent=2), + ) + + executor = executor or NodeExecutor("compress") + scorer = PaperRelevanceScorer(executor, threshold=paper_config.pool_relevance_threshold) + + scoring_start = time.time() + print(f"[NODE] paper_pool_process - Scoring {len(all_papers)} papers...") + scored_papers = scorer.score_papers(all_papers, state.input.target, state.research_brief) + + scored_keys = {paper.get("paper_key") or build_paper_key_from_record(paper) for paper in scored_papers} + for paper in all_papers: + paper_key = paper.get("paper_key") or build_paper_key_from_record(paper) + if paper_key not in scored_keys: + paper["relevance_score"] = paper.get("relevance_score", 0.0) + scored_papers.append(paper) + + scored_papers.sort(key=_paper_ranking_sort_key) + scoring_time = time.time() - scoring_start + + state.temp_data["paper_pool_scores"] = [scorer.build_score_snapshot(paper) for paper in scored_papers] + workflow_helpers._save_artifact( + state, + "paper_pool_scores.json", + json.dumps(state.temp_data["paper_pool_scores"], ensure_ascii=False, indent=2), + ) + + print(f"[NODE] paper_pool_process - Scored {len(scored_papers)} papers in {scoring_time:.1f}s") + print( + "[NODE] paper_pool_process - Score range: " + f"{scored_papers[0].get('relevance_score', 0):.1f} - {scored_papers[-1].get('relevance_score', 0):.1f}" + ) + + citation_start = time.time() + top_k_papers = scored_papers[:paper_config.pool_top_k_for_expansion] + prepared_citations: list[dict[str, Any]] = [] + if top_k_papers: + workflow_helpers._save_artifact( + state, + "paper_pool_expansion_seeds.json", + json.dumps(top_k_papers, ensure_ascii=False, indent=2), + ) + print(f"[NODE] paper_pool_process - Expanding citations from top {len(top_k_papers)} papers...") + for index, paper in enumerate(top_k_papers, 1): + identifiers = paper.get("identifiers") or {} + print( + f" [{index}] {paper.get('title', '')[:60]}... " + f"(score: {paper.get('relevance_score', 0):.1f}, " + f"openalex_id: {paper.get('openalex_id', '') or identifiers.get('openalex', '') or '-'}, " + f"arxiv_id: {paper.get('arxivId', '') or identifiers.get('arxiv', '') or '-'})" + ) + + expander = CitationExpander(max_citations_per_paper=paper_config.pool_max_citations_per_paper) + citations = expander.expand_from_paper_list(top_k_papers, max_papers=paper_config.pool_top_k_for_expansion) + citation_debug = dict(expander.last_run_stats or {}) + state.temp_data["citation_expansion_debug"] = citation_debug + citation_fetch_time = time.time() - citation_start + print(f"[NODE] paper_pool_process - Fetched {len(citations)} citations in {citation_fetch_time:.1f}s") + if citation_debug: + print( + "[NODE] paper_pool_process - Citation expansion summary: " + f"requested_papers={citation_debug.get('requested_papers', 0)}, " + f"raw_refs={citation_debug.get('total_raw_references', 0)}, " + f"unique_refs={citation_debug.get('total_unique_references', 0)}, " + f"duplicates={citation_debug.get('total_duplicate_references', 0)}" + ) + for paper_debug in citation_debug.get("papers", []): + attempts_summary = ", ".join( + f"{attempt.get('strategy', '?')}:{attempt.get('status', 'unknown')}" + + ( + f"({attempt.get('reference_count', 0)})" + if attempt.get("status") in {"success", "empty"} + else "" + ) + for attempt in paper_debug.get("lookup_attempts", []) + ) + print( + " [CITATION SUMMARY] " + f"[{paper_debug.get('index', '?')}] status={paper_debug.get('status', 'unknown')} " + f"reason={paper_debug.get('reason', '') or '-'} " + f"strategy={paper_debug.get('selected_strategy', '') or '-'} " + f"openalex_id={paper_debug.get('openalex_id', '') or '-'} " + f"arxiv_id={paper_debug.get('arxiv_id', '') or '-'} " + f"raw={paper_debug.get('raw_reference_count', 0)} " + f"added={paper_debug.get('added_reference_count', 0)} " + f"duplicates={paper_debug.get('duplicate_reference_count', 0)} " + f"attempts={attempts_summary or '-'} " + f"title={paper_debug.get('title', '')}" + ) + + if citations: + added_citations = 0 + for citation in citations: + prepared = dict(citation) + public_source_url = _resolve_public_paper_url(prepared) + download_source_url = _resolve_download_source_url(prepared) + if public_source_url: + if not prepared.get("source_url"): + prepared["source_url"] = public_source_url + if not prepared.get("url"): + prepared["url"] = public_source_url + if download_source_url: + prepared["download_source_url"] = download_source_url + prepared["from_citation_expansion"] = True + if paper_pool_manager.add_paper(prepared): + canonical_key = build_paper_key_from_record(prepared) + canonical_record = paper_pool_manager.get_paper_by_key(canonical_key) + if canonical_record is not None: + prepared_citations.append(canonical_record) + added_citations += 1 + + state.temp_data["paper_pool_citation_additions"] = { + "fetched_count": len(citations), + "added_count": added_citations, + "pool_size": paper_pool_manager.get_count(), + } + workflow_helpers._save_artifact( + state, + "paper_pool_expanded_citations.json", + json.dumps(prepared_citations, ensure_ascii=False, indent=2), + ) + print( + f"[NODE] paper_pool_process - Added {added_citations}/{len(citations)} expanded citations to pool " + f"(total: {paper_pool_manager.get_count()})" + ) + + if prepared_citations: + citation_score_start = time.time() + print(f"[NODE] paper_pool_process - Scoring {len(prepared_citations)} citations...") + citation_scored = scorer.score_papers(prepared_citations, state.input.target, state.research_brief) + + for citation in citation_scored: + paper_key = citation.get("paper_key") or build_paper_key_from_record(citation) + pool_paper = paper_pool_manager.get_paper_by_key(paper_key) + if pool_paper: + pool_paper["relevance_score"] = citation.get("relevance_score", 0.0) + + scored_papers = paper_pool_manager.get_all_papers() + scored_papers.sort(key=_paper_ranking_sort_key) + citation_score_time = time.time() - citation_score_start + print(f"[NODE] paper_pool_process - Citation scoring complete in {citation_score_time:.1f}s") + + filtered_papers = [ + paper + for paper in scored_papers + if paper.get("relevance_score", 0.0) >= paper_config.pool_relevance_threshold + ] + filtered_papers.sort(key=_paper_ranking_sort_key) + final_papers = [] + source_completion_debug = [] + dropped_papers = [] + for rank, paper in enumerate(filtered_papers[:paper_config.pool_max_final_papers], 1): + prepared_paper = dict(paper) + prepared_paper["final_selected_for_pdf"] = True + prepared_paper["final_selection_rank"] = rank + completed_paper, completion_debug = _complete_final_paper_sources(prepared_paper) + source_completion_debug.append(completion_debug) + if completion_debug.get("status") == "failed": + dropped_papers.append( + { + "paper_key": completed_paper.get("paper_key", ""), + "title": completed_paper.get("title", ""), + "reason": completed_paper.get("source_completion_failure_reason", "unknown"), + "stage": "source_completion", + } + ) + continue + final_papers.append(completed_paper) + + final_selected_keys = {paper.get("paper_key") for paper in final_papers if paper.get("paper_key")} + selection_rank_by_key = { + str(paper.get("paper_key")): paper.get("final_selection_rank") + for paper in final_papers + if paper.get("paper_key") + } + for pool_paper in paper_pool_manager.get_all_papers(): + paper_key = pool_paper.get("paper_key") or build_paper_key_from_record(pool_paper) + if paper_key: + pool_paper["paper_key"] = paper_key + pool_paper["final_selected_for_pdf"] = bool(paper_key in final_selected_keys) + pool_paper["final_selection_rank"] = selection_rank_by_key.get(str(paper_key or "")) + + for run in get_research_runs(state): + for result in run.get("results", []) or []: + if not isinstance(result, dict): + continue + paper_key = result.get("paper_key") or build_paper_key_from_record(result) + if paper_key: + result["paper_key"] = paper_key + result["final_selected_for_pdf"] = bool(paper_key in final_selected_keys) + + sync_query_feedback(state, workflow_helpers._build_query_feedback_v1(state)) + workflow_helpers._save_artifact( + state, + "query_feedback_final.json", + json.dumps(get_temp_value(state, "query_feedback", {}), ensure_ascii=False, indent=2), + ) + + selection_status = "selected" if final_papers else "no_papers_passed_threshold" + if filtered_papers and not final_papers: + selection_status = "all_filtered_by_source_completion" + if not filtered_papers: + dropped_papers.append( + { + "paper_key": "", + "title": "", + "reason": "no_papers_passed_relevance_threshold", + "stage": "threshold", + } + ) + + state.temp_data["paper_pool_selection"] = { + "status": selection_status, + "threshold": paper_config.pool_relevance_threshold, + "scored_count": len(scored_papers), + "threshold_pass_count": len(filtered_papers), + "selected_for_download_count": len(final_papers), + "dropped_count": len(dropped_papers), + } + state.temp_data["final_selected_paper_keys"] = list(final_selected_keys) + state.temp_data["paper_pool_dropped_papers"] = dropped_papers + ranked_pool_papers = sorted(paper_pool_manager.get_all_papers(), key=_paper_ranking_sort_key) + state.temp_data["paper_pool_post_expansion_ranking"] = [ + { + **scorer.build_score_snapshot(paper), + "final_selected_for_pdf": bool(paper.get("paper_key") in final_selected_keys), + "final_selection_rank": paper.get("final_selection_rank"), + } + for paper in ranked_pool_papers + ] + + total_time = time.time() - node_start + state.temp_data["paper_pool_process_timing"] = { + "total_ms": round(total_time * 1000, 2), + } + print( + f"[NODE] paper_pool_process - {len(filtered_papers)} papers passed threshold " + f"{paper_config.pool_relevance_threshold}" + ) + print(f"[NODE] paper_pool_process - Selected {len(final_papers)} final papers") + print(f"[NODE] paper_pool_process - paper_pool_process.total_ms={total_time * 1000:.2f}") + + set_final_papers_for_compress(state, final_papers) + state.temp_data["paper_pool_source_completion"] = source_completion_debug + workflow_helpers._save_artifact( + state, + "paper_pool_post_expansion_ranking.json", + json.dumps(state.temp_data["paper_pool_post_expansion_ranking"], ensure_ascii=False, indent=2), + ) + workflow_helpers._save_artifact( + state, + "paper_pool_source_completion.json", + json.dumps(source_completion_debug, ensure_ascii=False, indent=2), + ) + workflow_helpers._save_artifact( + state, + "paper_pool_final_selection.json", + json.dumps(final_papers, ensure_ascii=False, indent=2), + ) + if dropped_papers: + workflow_helpers._save_artifact( + state, + "paper_pool_dropped_papers.json", + json.dumps(dropped_papers, ensure_ascii=False, indent=2), + ) + workflow_helpers._save_node_state(state, "paper_pool_process") + return state diff --git a/backend/src/modules/idea_gen/services/research_service.py b/backend/src/modules/idea_gen/services/research_service.py new file mode 100644 index 00000000..f5478f5d --- /dev/null +++ b/backend/src/modules/idea_gen/services/research_service.py @@ -0,0 +1,687 @@ +"""Service layer for research dispatch execution.""" + +from __future__ import annotations + +import json +import time +from concurrent.futures import ThreadPoolExecutor, as_completed +from dataclasses import dataclass, field +from typing import Any + +from src.modules.idea_gen.config import ( + get_node_tool_config, + get_paper_processing_config, + get_workflow_config, +) +from src.modules.idea_gen.schemas import PaperCandidate +from src.modules.idea_gen.utils import IdeaResearchExecutor +from src.modules.idea_gen.utils.paper_cache_store import ( + PaperCacheStore, + build_paper_key_from_record, +) +from src.modules.idea_gen.utils.paper_pipeline import PaperPipeline +from src.modules.idea_gen.utils.paper_pool import PaperPool +from src.modules.idea_gen.utils.paper_utils import ( + _resolve_download_source_url, + _resolve_public_paper_url, +) +from src.modules.idea_gen.utils.query_planning_utils import _query_role_lookup +from src.modules.idea_gen.utils.query_state_manager import QueryStateManager +from src.modules.idea_gen.utils.result_formatter import ResultFormatter +from src.modules.idea_gen.utils import workflow_helpers +from src.modules.idea_gen.utils.state_utils import ( + append_research_run, + current_research_phase, + ensure_research_runs, + get_temp_value, + increment_phase_iteration_count, + sync_query_feedback, +) +from src.modules.idea_gen.utils.storage_manager import StorageManager +from src.modules.idea_gen.utils.normalizer_utils import ( + _normalize_query_key, +) +from src.modules.idea_gen.utils.thread_pool_manager import ThreadPoolManager +from src.modules.idea_gen.utils import query_utils +from src.modules.idea_gen.utils.supervisor_utils import tasks_to_topics + + +def _elapsed_ms(start_time: float) -> float: + return round((time.time() - start_time) * 1000, 2) + + +@dataclass +class TopicProcessResult: + index: int + topic: str + query_type: str + providers: list[str] + provider_attempts: list[dict[str, Any]] + provider_used: str + results: list[dict[str, Any]] + pool_candidate_results: list[dict[str, Any]] + notes: str + enrichment_summary: dict[str, Any] + rerank_summary: dict[str, Any] + metadata_providers: list[str] + extra_debug: dict[str, Any] = field(default_factory=dict) + + +class ResearchService: + """Encapsulate research dispatch execution and side effects.""" + + @staticmethod + def _build_topic_plan(state: Any, tool_config: Any) -> tuple[ + list[dict[str, Any]], + list[str], + list[str], + dict[str, Any], + dict[str, Any], + ]: + query_state = QueryStateManager.ensure_state(state) + active_paper_plan = list(query_state.get("active_paper_query_plan", []) or []) + active_web_plan = list(query_state.get("active_web_query_plan", []) or []) + paper_role_lookup = _query_role_lookup(active_paper_plan) + web_role_lookup = _query_role_lookup(active_web_plan) + + compiled_query_state = get_temp_value(state, "query_state", {}) + has_explicit_query_state = isinstance(compiled_query_state, dict) + + if state.paper_queries or state.web_queries or has_explicit_query_state: + paper_queries = state.paper_queries or [] + web_queries = state.web_queries or [] + print( + f"\n[NODE] research_dispatch - Iteration {state.iteration_count}: " + f"{len(paper_queries)} paper + {len(web_queries)} web queries" + ) + topics: list[dict[str, Any]] = [] + for query in paper_queries: + topics.append({"query": query, "type": "paper", "providers": tool_config.paper_providers}) + for query in web_queries: + topics.append({"query": query, "type": "web", "providers": tool_config.web_providers}) + return topics, paper_queries, web_queries, paper_role_lookup, web_role_lookup + + supervisor_tasks = list(get_temp_value(state, "supervisor_tasks", []) or []) + supervisor_topics = get_temp_value(state, "supervisor_topics", []) or [] + paper_queries = tasks_to_topics(supervisor_tasks) or list(supervisor_topics) + print( + f"\n[NODE] research_dispatch - Iteration {state.iteration_count}: " + f"{len(paper_queries)} supervisor tasks/topics" + ) + + if supervisor_tasks: + topics = [] + for task in supervisor_tasks: + topics.append( + { + "query": task.get("topic", ""), + "type": "paper", + "providers": tool_config.paper_providers, + "plan_item": query_utils.build_plan_item( + task.get("topic", ""), + channel="paper", + role="candidate", + source="supervisor", + intent=task.get("lane", "") or "supervisor_followup", + rationale=task.get("reason", ""), + seed_id=task.get("seed_id", ""), + lane=task.get("lane", ""), + ), + } + ) + else: + topics = [ + { + "query": topic, + "type": "paper", + "providers": tool_config.paper_providers, + } + for topic in supervisor_topics + ] + + return topics, paper_queries, [], paper_role_lookup, web_role_lookup + + @staticmethod + def _dedupe_results(results: list[dict[str, Any]], max_results: int) -> list[dict[str, Any]]: + seen = set() + deduped_results: list[dict[str, Any]] = [] + for result in results: + key = result.get("url") or result.get("title") + if key and key not in seen: + seen.add(key) + deduped_results.append(result) + if len(deduped_results) >= max_results: + break + return deduped_results + + @staticmethod + def _build_notes(topic: str, query_type: str, results: list[dict[str, Any]]) -> str: + notes = f"## Research: {topic}\n\n" + for index, result in enumerate(results, 1): + notes += f"{index}. **{result.get('title', '')}**\n" + notes += f" Source: {result.get('url', '')} [{result.get('source_provider', 'unknown')}]\n" + for metadata_line in ResultFormatter.build_metadata_lines(result): + notes += f" {metadata_line}\n" + if query_type == "web" and result.get("web_fetch_method"): + notes += f" Web Fetch: {result.get('web_fetch_method')}\n" + snippet = result.get("content") or result.get("snippet", "") + notes += f" {snippet[:200]}...\n\n" + excerpts = result.get("key_excerpts") or [] + if query_type == "web" and excerpts: + for excerpt in excerpts[:2]: + notes += f" Evidence: {excerpt}\n" + notes += "\n" + return notes + + @staticmethod + def _fetch_web_result(result: dict[str, Any]) -> dict[str, Any]: + from src.modules.idea_gen import tools as idea_gen_tools + + enriched_result = dict(result) + url = enriched_result.get("url", "") + if not isinstance(url, str) or not url.startswith(("http://", "https://")): + return enriched_result + + try: + markdown_content, fetch_method = idea_gen_tools.fetch_webpage_as_markdown(url) + web_summary = idea_gen_tools.summarize_webpage_markdown(markdown_content) + enriched_result["web_fetch_method"] = fetch_method + enriched_result["web_markdown_chars"] = len(markdown_content) + enriched_result["content"] = web_summary.get("summary", "") + enriched_result["key_excerpts"] = list(web_summary.get("key_excerpts", []) or []) + if enriched_result.get("content"): + enriched_result["snippet"] = enriched_result["content"][:500] + except Exception as exc: + enriched_result["web_fetch_error"] = str(exc) + + return enriched_result + + def _process_topic( + self, + *, + index: int, + topic_info: dict[str, Any], + state: Any, + workflow_config: Any, + paper_config: Any, + cache_store: PaperCacheStore | None, + paper_queries: list[str], + metadata_providers: list[str], + ) -> TopicProcessResult: + from src.modules.idea_gen import tools as idea_gen_tools + from src.modules.idea_gen import workflow as workflow_module + + topic_start = time.time() + topic = topic_info["query"] + query_type = topic_info["type"] + providers = list(topic_info["providers"] or []) + max_results = ( + paper_config.candidate_pool_size + if query_type == "paper" and paper_config.enable_embedding_rerank + else workflow_config.search_results_per_topic + ) + + search_start = time.time() + all_results: list[dict[str, Any]] = [] + provider_attempts: list[dict[str, Any]] = [] + selected_provider = "" + for provider in providers: + print( + f"[NODE] research_dispatch - Topic {index + 1}: " + f"[{query_type}] {topic[:50]}... -> {provider}" + ) + try: + results = idea_gen_tools.search_sync(topic, provider, max_results) + provider_attempts.append( + { + "provider": provider, + "status": "ok", + "result_count": len(results), + } + ) + for result in results: + result["query_type"] = query_type + result["source_provider"] = provider + if results: + all_results = results + selected_provider = provider + if len(providers) > 1: + print( + "[NODE] research_dispatch - " + f"Selected provider {provider} with {len(results)} results" + ) + break + except Exception as exc: + provider_attempts.append( + { + "provider": provider, + "status": "error", + "result_count": 0, + "error": str(exc), + } + ) + print(f"[NODE] research_dispatch - Error with {provider}: {exc}") + results = self._dedupe_results(all_results, max_results) + search_ms = _elapsed_ms(search_start) + + enrichment_start = time.time() + enrichment_summary = {"provider": None, "matched": 0, "attempted": 0, "cache_hits": 0} + if query_type == "paper" and results and "openalex" in metadata_providers: + try: + openalex_enricher = workflow_module.OpenAlexEnricher() + results_with_keys = [] + paper_keys = [] + for result in results: + result_with_key = dict(result) + paper_key = build_paper_key_from_record(result_with_key) + result_with_key["paper_key"] = paper_key + results_with_keys.append(result_with_key) + paper_keys.append(paper_key) + + cached_metadata_map = cache_store.get_cached_metadata_batch(paper_keys) if cache_store else {} + + cached_results: list[dict[str, Any]] = [] + results_to_enrich: list[dict[str, Any]] = [] + for result_with_key in results_with_keys: + paper_key = result_with_key["paper_key"] + cached_metadata = cached_metadata_map.get(paper_key) + if cached_metadata is not None: + cached_results.append( + PaperCacheStore.apply_metadata_payload(result_with_key, cached_metadata) + ) + else: + results_to_enrich.append(result_with_key) + + enriched_results = openalex_enricher.enrich_many(results_to_enrich) if results_to_enrich else [] + if cache_store: + for enriched_result in enriched_results: + cache_payload = PaperCacheStore.build_metadata_payload(enriched_result) + if cache_payload: + cache_store.upsert_metadata( + paper_key=enriched_result.get("paper_key") + or build_paper_key_from_record(enriched_result), + title=enriched_result.get("title", ""), + source_url=enriched_result.get("url", ""), + metadata=cache_payload, + ) + + combined_results = cached_results + enriched_results + result_lookup = { + (result.get("paper_key") or build_paper_key_from_record(result)): result + for result in combined_results + } + ordered_results: list[dict[str, Any]] = [] + for result in results: + paper_key = build_paper_key_from_record(result) + ordered_results.append(result_lookup.get(paper_key, result)) + results = ordered_results + enrichment_summary = { + "provider": "openalex", + "attempted": len(results), + "matched": sum(1 for result in results if result.get("openalex_match_found")), + "cache_hits": len(cached_results), + } + print( + "[NODE] research_dispatch - " + f"OpenAlex enrichment matched {enrichment_summary['matched']}/" + f"{enrichment_summary['attempted']} papers " + f"(cache hits: {enrichment_summary['cache_hits']})" + ) + except Exception as exc: + print(f"[NODE] research_dispatch - OpenAlex enrichment failed: {exc}") + openalex_enrich_ms = _elapsed_ms(enrichment_start) + + web_fetch_start = time.time() + if query_type == "web" and results: + pool = ThreadPoolManager.get_web_fetch_pool() + future_to_index = { + pool.submit(self._fetch_web_result, result): result_index + for result_index, result in enumerate(results) + } + fetched_results: dict[int, dict[str, Any]] = {} + for future in as_completed(future_to_index): + fetched_results[future_to_index[future]] = future.result() + results = [fetched_results[idx] for idx in range(len(results))] + web_fetch_ms = _elapsed_ms(web_fetch_start) + + rerank_start = time.time() + rerank_summary: dict[str, Any] = {} + pool_candidate_results = list(results) + if query_type == "paper" and results and paper_config.enable_embedding_rerank: + paper_reranker = workflow_module.PaperReranker(config=paper_config) + rerank_output = paper_reranker.rerank( + results=results, + original_target=state.input.target, + research_brief=state.research_brief, + paper_queries=paper_queries, + target_venues=state.input.target_venues, + ) + results = rerank_output.ranked_results + pool_candidate_results = list(results) + rerank_summary = rerank_output.summary + print( + "[NODE] research_dispatch - " + f"Paper rerank {rerank_summary.get('status', 'unknown')}: " + f"{rerank_summary.get('selected_count', 0)}/" + f"{rerank_summary.get('candidate_count', len(results))} selected for pool prioritization" + ) + elif query_type == "paper" and paper_config.enable_embedding_rerank: + rerank_summary = {"status": "skipped_no_results", "selected_count": 0, "candidate_count": 0} + rerank_ms = _elapsed_ms(rerank_start) + + notes = self._build_notes(topic, query_type, results) + topic_ms = _elapsed_ms(topic_start) + timings_ms = { + "topic_ms": topic_ms, + "search_ms": search_ms, + "openalex_enrich_ms": openalex_enrich_ms, + "web_fetch_ms": web_fetch_ms, + "rerank_ms": rerank_ms, + } + print( + "[NODE] research_dispatch - " + f"topic={topic[:60]!r} total_ms={topic_ms:.2f} " + f"search_ms={search_ms:.2f} enrich_ms={openalex_enrich_ms:.2f} " + f"web_fetch_ms={web_fetch_ms:.2f} rerank_ms={rerank_ms:.2f}" + ) + + return TopicProcessResult( + index=index, + topic=topic, + query_type=query_type, + providers=providers, + provider_attempts=provider_attempts, + provider_used=selected_provider, + results=results, + pool_candidate_results=pool_candidate_results, + notes=notes, + enrichment_summary=enrichment_summary, + rerank_summary=rerank_summary, + metadata_providers=metadata_providers if query_type == "paper" else [], + extra_debug={ + "timings_ms": timings_ms, + "max_results": max_results, + }, + ) + + def _merge_topic_result( + self, + state: Any, + *, + topic_result: TopicProcessResult, + plan_item: dict[str, Any], + pending_raw_notes: str, + storage: StorageManager | None, + paper_config: Any, + current_iteration: int, + current_phase: str, + all_paper_titles: set[str], + cache_store: PaperCacheStore | None, + ) -> str: + state.unit_count += 1 + append_research_run( + state, + { + "iteration": current_iteration, + "phase": current_phase, + "topic": topic_result.topic, + "query_type": topic_result.query_type, + "query_role": plan_item.get("role", ""), + "query_source": plan_item.get("source", ""), + "query_intent": plan_item.get("intent", ""), + "query_seed_id": plan_item.get("seed_id", ""), + "query_lane": plan_item.get("lane", ""), + "query_rationale": plan_item.get("rationale", ""), + "providers": topic_result.providers, + "provider_attempts": topic_result.provider_attempts, + "provider_used": topic_result.provider_used, + "metadata_providers": topic_result.metadata_providers, + "metadata_enrichment": ( + topic_result.enrichment_summary if topic_result.query_type == "paper" else {} + ), + "paper_rerank": topic_result.rerank_summary if topic_result.query_type == "paper" else {}, + "results": topic_result.results, + "timings_ms": dict(topic_result.extra_debug.get("timings_ms") or {}), + "tool_calls": [], + }, + ) + + notes = topic_result.notes + if storage and topic_result.results: + search_ref = storage.write_search_raw(topic_result.topic, topic_result.results) + compressed_search = storage.compress_search(topic_result.topic, search_ref, topic_result.results) + notes = f"## Research: {topic_result.topic}\n\n" + for finding in compressed_search.key_findings[:3]: + notes += f"- {finding}\n" + metadata_highlights = ResultFormatter.build_highlights(topic_result.results) + if metadata_highlights: + notes += "\n" + metadata_highlights + notes += "\n" + + if ( + paper_config.enable_paper_pool + and state.paper_pool + and topic_result.pool_candidate_results + and topic_result.query_type == "paper" + ): + added = 0 + for result in topic_result.pool_candidate_results: + candidate = dict(result) + candidate["query_topic"] = topic_result.topic + candidate["query_intent"] = plan_item.get("intent", "") + candidate["query_seed_id"] = plan_item.get("seed_id", "") + candidate["query_lane"] = plan_item.get("lane", "") + candidate["query_rationale"] = plan_item.get("rationale", "") + candidate["from_citation_expansion"] = False + public_source_url = _resolve_public_paper_url(candidate) + download_source_url = _resolve_download_source_url(candidate) + if public_source_url: + if not candidate.get("source_url"): + candidate["source_url"] = public_source_url + if not candidate.get("url"): + candidate["url"] = public_source_url + if download_source_url: + candidate["download_source_url"] = download_source_url + if state.paper_pool.add_paper(candidate): + added += 1 + normalized_title = str(candidate.get("title", "") or "").lower().strip() + if normalized_title: + all_paper_titles.add(normalized_title) + + state.temp_data.setdefault("paper_pool_collection_runs", []).append( + { + "iteration": current_iteration, + "phase": current_phase, + "topic": topic_result.topic, + "query_seed_id": plan_item.get("seed_id", ""), + "query_lane": plan_item.get("lane", ""), + "candidate_count": len(topic_result.pool_candidate_results), + "added_count": added, + "pool_size": state.paper_pool.get_count(), + } + ) + print( + f"[PAPER POOL] Added {added}/{len(topic_result.pool_candidate_results)} papers to pool " + f"(total: {state.paper_pool.get_count()})" + ) + + if paper_config.enable_scoring and topic_result.results: + try: + candidates = [ + PaperCandidate( + paper_id=f"{r.get('retrieval_source') or r.get('source_provider') or r.get('source') or 'paper'}_{i}", + source=r.get("source_provider") or r.get("source") or "unknown", + external_id=r.get("external_id") + or ResultFormatter.extract_arxiv_id(r.get("url", "")) + or r.get("openalex_id", ""), + title=r.get("title", ""), + abstract=r.get("snippet", ""), + authors=ResultFormatter.normalize_authors(r.get("authors")), + url=r.get("url", ""), + snippet=r.get("snippet", ""), + retrieval_source=r.get("retrieval_source"), + metadata_sources=list(r.get("metadata_sources") or []), + identifiers=dict(r.get("identifiers") or {}), + venue=r.get("venue"), + publication_year=r.get("publication_year"), + cited_by_count=r.get("cited_by_count"), + primary_topic=r.get("primary_topic"), + topics=list(r.get("topics") or []), + institutions=list(r.get("institutions") or []), + oa_url=r.get("oa_url"), + pdf_url=r.get("pdf_url"), + metadata=dict(r.get("metadata") or {}), + ) + for i, r in enumerate(topic_result.results) + if r.get("title") + ] + + if candidates: + pipeline = PaperPipeline(cache_store=cache_store) + enriched = pipeline.process_candidates(candidates) + state.temp_data.setdefault("enriched_papers", []).extend( + [candidate.model_dump() for candidate in enriched] + ) + except Exception: + pass + + pending_raw_notes += notes + state.researcher_messages.append( + {"unit_count": state.unit_count, "topic": topic_result.topic, "notes": notes} + ) + return pending_raw_notes + + def run( + self, + state: Any, + executor: IdeaResearchExecutor | None = None, + ) -> Any: + node_start = time.time() + _ = executor # Legacy signature retained for compatibility. + tool_config = get_node_tool_config("research_dispatch") + workflow_config = get_workflow_config() + paper_config = get_paper_processing_config() + pending_raw_notes = "" + ensure_research_runs(state) + current_iteration = state.iteration_count + current_phase = current_research_phase(state) + output_dir = workflow_helpers._get_output_dir(state) + storage = StorageManager(output_dir) if paper_config.enable_two_tier_storage else None + cache_store = workflow_helpers._create_paper_cache_store() + if cache_store: + state.temp_data["paper_cache_db_path"] = str(cache_store.db_path) + paper_metadata_providers = list(getattr(tool_config, "paper_metadata_providers", []) or []) + + if paper_config.enable_paper_pool and state.paper_pool is None: + state.paper_pool = PaperPool() + print("[NODE] research_dispatch - Initialized paper pool") + + all_paper_titles = state.temp_data.setdefault("all_paper_titles", set()) + ( + topics, + paper_queries, + _web_queries, + paper_role_lookup, + web_role_lookup, + ) = self._build_topic_plan(state, tool_config) + + topic_results: list[TopicProcessResult] = [] + max_topic_workers = max(1, int(workflow_config.search_max_concurrency)) + if topics: + if len(topics) == 1: + topic_results.append( + self._process_topic( + index=0, + topic_info=topics[0], + state=state, + workflow_config=workflow_config, + paper_config=paper_config, + cache_store=cache_store, + paper_queries=paper_queries, + metadata_providers=paper_metadata_providers, + ) + ) + else: + with ThreadPoolExecutor( + max_workers=min(max_topic_workers, len(topics)), + thread_name_prefix="idea-research-topic", + ) as topic_pool: + future_to_index = { + topic_pool.submit( + self._process_topic, + index=index, + topic_info=topic_info, + state=state, + workflow_config=workflow_config, + paper_config=paper_config, + cache_store=cache_store, + paper_queries=paper_queries, + metadata_providers=paper_metadata_providers, + ): index + for index, topic_info in enumerate(topics) + } + for future in as_completed(future_to_index): + topic_results.append(future.result()) + + topic_results.sort(key=lambda item: item.index) + + topic_timings = [] + for topic_info, topic_result in zip(topics, topic_results): + role_lookup = paper_role_lookup if topic_result.query_type == "paper" else web_role_lookup + plan_item = role_lookup.get(_normalize_query_key(topic_result.topic), {}) or dict( + topic_info.get("plan_item") or {} + ) + pending_raw_notes = self._merge_topic_result( + state, + topic_result=topic_result, + plan_item=plan_item, + pending_raw_notes=pending_raw_notes, + storage=storage, + paper_config=paper_config, + current_iteration=current_iteration, + current_phase=current_phase, + all_paper_titles=all_paper_titles, + cache_store=cache_store, + ) + topic_timings.append( + { + "topic": topic_result.topic, + "query_type": topic_result.query_type, + **dict(topic_result.extra_debug.get("timings_ms") or {}), + } + ) + + if storage: + storage.save_manifest(run_id=state.thread_id or "default") + state.temp_data["storage_manifest"] = { + "papers_count": len(storage.papers), + "search_results_count": len(storage.search_results), + } + + total_ms = _elapsed_ms(node_start) + state.temp_data["research_dispatch_timings"] = { + "iteration": current_iteration, + "topic_count": len(topics), + "search_max_concurrency": max_topic_workers, + "total_ms": total_ms, + "topics": topic_timings, + } + print(f"[NODE] research_dispatch - research_dispatch.total_ms={total_ms:.2f}") + workflow_helpers._save_artifact( + state, + f"research_dispatch_timings_iter{current_iteration}.json", + json.dumps(state.temp_data["research_dispatch_timings"], ensure_ascii=False, indent=2), + ) + + state.raw_notes = pending_raw_notes + sync_query_feedback(state, workflow_helpers._build_query_feedback_v1(state)) + workflow_helpers._save_artifact( + state, + f"query_feedback_iter{current_iteration}.json", + json.dumps(get_temp_value(state, "query_feedback", {}), ensure_ascii=False, indent=2), + ) + state.iteration_count += 1 + increment_phase_iteration_count(state, current_phase) + print(f"[NODE] research_dispatch - Completed {len(topics)} units") + workflow_helpers._save_node_state(state, f"research_dispatch_iter{state.iteration_count - 1}") + return state diff --git a/backend/src/modules/idea_gen/services/seed_service.py b/backend/src/modules/idea_gen/services/seed_service.py new file mode 100644 index 00000000..f53be0be --- /dev/null +++ b/backend/src/modules/idea_gen/services/seed_service.py @@ -0,0 +1,227 @@ +"""Service layer for seed discovery and validation.""" + +from __future__ import annotations + +from typing import Any + +from src.modules.idea_gen.config import get_seed_config +from src.modules.idea_gen.prompts import ( + build_seed_discovery_prompt, + build_seed_validation_prompt, +) +from src.modules.idea_gen.schemas import Seed +from src.modules.idea_gen.utils.error_utils import handle_node_error +from src.modules.idea_gen.utils.node_executor import NodeExecutor +from src.modules.idea_gen.utils import workflow_helpers +from src.modules.idea_gen.utils.state_utils import ( + get_compressed_papers, + get_final_papers_for_compress, + set_temp_value, + reset_supervisor_think_counters, +) +from src.utils.structured_output import parse_structured_output + + +class SeedService: + """Encapsulate seed discovery and validation flows.""" + + @staticmethod + def _build_paper_catalog(state: Any) -> list[dict[str, str]]: + catalog: list[dict[str, str]] = [] + seen: set[str] = set() + for paper in get_final_papers_for_compress(state): + paper_key = str(paper.get("paper_key", "") or "").strip() + title = str(paper.get("title", "") or "").strip() + if not paper_key or not title or paper_key in seen: + continue + seen.add(paper_key) + catalog.append({"paper_key": paper_key, "title": title}) + return catalog + + @staticmethod + def _select_top_papers_for_seed_discovery( + compressed_papers: list[dict[str, Any]], + max_papers: int, + ) -> list[dict[str, Any]]: + if not compressed_papers: + return [] + + sorted_papers = sorted( + compressed_papers, + key=lambda paper: ( + -float(paper.get("relevance_score", 0.0) or 0.0), + int(paper.get("final_selection_rank") or 999), + str(paper.get("title", "") or "").lower(), + ), + ) + return sorted_papers[:max_papers] + + @staticmethod + def _build_focused_paper_summaries( + selected_papers: list[dict[str, Any]], + max_chars_per_paper: int, + ) -> str: + summaries: list[str] = [] + + for idx, paper in enumerate(selected_papers, 1): + title = paper.get("title", "Unknown") + core_summary = paper.get("core_summary", "") + technical_depth = paper.get("technical_depth", "") + empirical_support = paper.get("empirical_support", "") + paper_text = f"## Paper {idx}: {title}\n\n" + + if core_summary: + paper_text += f"### 核心摘要\n{core_summary[:max_chars_per_paper // 3]}\n\n" + if technical_depth: + paper_text += f"### 技术细节\n{technical_depth[:max_chars_per_paper // 3]}\n\n" + if empirical_support: + paper_text += f"### 实验验证\n{empirical_support[:max_chars_per_paper // 3]}\n\n" + + summaries.append(paper_text) + + return "\n".join(summaries) + + def discover( + self, + state: Any, + executor: NodeExecutor | None = None, + *, + validate_immediately: bool = True, + validator_executor: NodeExecutor | None = None, + ) -> Any: + print("\n[NODE] seed_discovery - Generating seeds...") + executor = executor or NodeExecutor("seed_discovery") + seed_config = get_seed_config() + paper_catalog = self._build_paper_catalog(state) + + compressed_papers = get_compressed_papers(state) + max_papers = seed_config.max_papers_for_seed_discovery + max_chars_per_paper = seed_config.max_summary_chars_per_paper + selected_papers: list[dict[str, Any]] = [] + + if compressed_papers: + selected_papers = self._select_top_papers_for_seed_discovery(compressed_papers, max_papers) + paper_summaries = self._build_focused_paper_summaries(selected_papers, max_chars_per_paper) + print( + f"[NODE] seed_discovery - Selected top {len(selected_papers)}/{len(compressed_papers)} papers for seed generation" + ) + print( + f"[NODE] seed_discovery - Paper summaries: {len(paper_summaries)} chars " + f"(limit: {max_papers * max_chars_per_paper})" + ) + else: + paper_summaries = state.notes + print(f"[NODE] seed_discovery - Using state.notes: {len(paper_summaries)} chars") + + system, user = build_seed_discovery_prompt( + paper_summaries=paper_summaries, + research_brief=state.research_brief, + n_seeds=seed_config.target_seed_count, + paper_catalog=paper_catalog, + ) + response = "" + try: + print(f"[NODE] seed_discovery - Invoking LLM with {len(paper_summaries)} chars input...") + response = executor.invoke_text(system, user) + print(f"[NODE] seed_discovery - LLM returned {len(response)} chars") + + if not response or not response.strip(): + error_msg = ( + f"LLM returned empty response (input: {len(paper_summaries)} chars, " + f"{len(paper_catalog)} papers)" + ) + print(f"[NODE] seed_discovery - ERROR: {error_msg}") + raise ValueError(error_msg) + + state.temp_data["seed_discovery_raw_response"] = response + state.temp_data["seed_discovery_paper_catalog"] = paper_catalog + state.temp_data["seed_discovery_selected_papers"] = ( + [paper.get("paper_id") for paper in selected_papers] if compressed_papers else [] + ) + state.temp_data["seed_discovery_input_chars"] = len(paper_summaries) + state.temp_data["seed_discovery_output_chars"] = len(response) + + workflow_helpers._save_artifact(state, "seed_discovery_raw_response.txt", response) + + print("[NODE] seed_discovery - Candidate output generated and validated") + if validate_immediately: + return self.validate(state, executor=validator_executor) + except Exception as exc: + handle_node_error( + state, + node="seed_discovery", + error=exc, + recoverable=True, + message=f"seed discovery generation failed: {str(exc)}", + ) + print(f"[NODE] seed_discovery - Error: {exc}") + print( + f"[NODE] seed_discovery - Debug: input_chars={len(paper_summaries)}, " + f"response_chars={len(response)}" + ) + + return state + + def validate(self, state: Any, executor: NodeExecutor | None = None) -> Any: + print("\n[NODE] seed_discovery_validate - Validating seed output...") + executor = executor or NodeExecutor("seed_discovery_validator") + seed_config = get_seed_config() + max_rounds = max(0, int(seed_config.json_repair_max_rounds)) + paper_catalog = list(state.temp_data.get("seed_discovery_paper_catalog", []) or []) + candidate = str(state.temp_data.get("seed_discovery_raw_response", "") or "") + + if not candidate: + print("[NODE] seed_discovery_validate - No candidate output, skipping") + return state + + workflow_helpers._save_artifact(state, "seed_discovery_raw_response.txt", candidate) + state.temp_data["seed_validation_attempts"] = [] + + last_error: Exception | None = None + for attempt in range(max_rounds + 1): + try: + data = parse_structured_output(candidate, list) + if not isinstance(data, list): + raise ValueError(f"Expected list, got {type(data).__name__}") + state.seeds = [Seed(**item) for item in data] + set_temp_value(state, "seed_validation_status", "validated" if attempt == 0 else "repaired") + set_temp_value(state, "seed_validation_rounds", attempt) + set_temp_value(state, "research_phase", "evidence") + reset_supervisor_think_counters(state, "evidence") + print( + f"[NODE] seed_discovery_validate - {'Validated' if attempt == 0 else 'Repaired'} " + f"{len(state.seeds)} seeds in round {attempt}" + ) + return state + except Exception as exc: + last_error = exc + attempt_record = {"attempt": attempt, "status": "parse_failed", "error": str(exc)} + state.temp_data["seed_validation_attempts"].append(attempt_record) + if attempt >= max_rounds: + break + + system, user = build_seed_validation_prompt( + raw_seed_output=candidate, + research_brief=state.research_brief, + paper_catalog=paper_catalog, + n_seeds=seed_config.target_seed_count, + ) + candidate = executor.invoke_text(system, user) + workflow_helpers._save_artifact( + state, + f"seed_discovery_validation_attempt_{attempt + 1}.txt", + candidate, + ) + + if last_error is not None: + handle_node_error( + state, + node="seed_discovery_validate", + error=last_error, + recoverable=True, + message="seed discovery validation failed", + ) + set_temp_value(state, "seed_validation_status", "failed") + print(f"[NODE] seed_discovery_validate - Error: {last_error}") + + return state diff --git a/backend/src/modules/idea_gen/state.py b/backend/src/modules/idea_gen/state.py new file mode 100644 index 00000000..6a984698 --- /dev/null +++ b/backend/src/modules/idea_gen/state.py @@ -0,0 +1,54 @@ +"""State models for idea_gen workflow.""" + +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field + +from src.modules.idea_gen.config import get_workflow_config +from src.modules.idea_gen.schemas import NodeError, WorkflowTempState + + +class IdeaGenInput(BaseModel): + """Academic idea research input.""" + + target: str + academic_domain: str = "AI/ML" + target_venues: list[str] = Field(default_factory=lambda: ["NIPS", "ICML", "ICLR"]) + language: str = "zh" + allow_clarification: bool = True + max_iterations: int = Field(default_factory=lambda: get_workflow_config().default_max_iterations) + + +class IdeaGenState(BaseModel): + """Mutable workflow state.""" + + model_config = ConfigDict(arbitrary_types_allowed=True) + + input: IdeaGenInput + thread_id: str = "" + research_brief: str = "" + paper_queries: list[str] = Field(default_factory=list) + web_queries: list[str] = Field(default_factory=list) + messages: list[Any] = Field(default_factory=list) + supervisor_messages: list[dict] = Field(default_factory=list) + researcher_messages: list[dict] = Field(default_factory=list) + notes: str = "" + raw_notes: str = "" + iteration_count: int = 0 + broad_iteration_count: int = 0 + evidence_iteration_count: int = 0 + unit_count: int = 0 + critic_iteration_count: int = 0 + global_max_iterations: int = 0 + critic_feedback: dict[str, Any] | None = None + paper_pool: Any = None + temp: WorkflowTempState = Field(default_factory=WorkflowTempState) + temp_data: dict[str, Any] = Field(default_factory=dict) + node_errors: list[NodeError] = Field(default_factory=list) + research_runs: list[dict[str, Any]] = Field(default_factory=list) + final_report: str = "" + seeds: list[Any] = Field(default_factory=list) + evidence_bundles: list[Any] = Field(default_factory=list) + hypotheses: list[Any] = Field(default_factory=list) diff --git a/backend/src/modules/idea_gen/tools.py b/backend/src/modules/idea_gen/tools.py index d1899d0e..74b01280 100644 --- a/backend/src/modules/idea_gen/tools.py +++ b/backend/src/modules/idea_gen/tools.py @@ -1,13 +1,12 @@ """Search tools for idea generation.""" import asyncio -import concurrent.futures import re +import threading +import time from typing import List, Dict, Any -from pathlib import Path + import httpx import os -import time -import threading _arxiv_rate_limit_lock = threading.Lock() @@ -295,23 +294,18 @@ async def search(query: str, provider: str = "tavily", max_results: int = 5) -> def search_sync(query: str, provider: str = "tavily", max_results: int = 5) -> List[Dict[str, Any]]: """Synchronous wrapper for search. Returns list of results.""" - with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool: - future = pool.submit(asyncio.run, search(query, provider, max_results)) - result = future.result() - # Extract results list from dict - return result.get("results", []) if isinstance(result, dict) else [] - - -def download_and_parse_pdf(url: str, output_path: Path) -> str: - """Download PDF and parse to markdown (compatibility wrapper).""" try: - content = parse_paper_to_markdown(url) - output_path.write_text(content, encoding="utf-8") - return content - except PaperParseError as e: - error_msg = f"# Error parsing PDF\n\nURL: {url}\nError: {str(e)}" - output_path.write_text(error_msg, encoding="utf-8") - return error_msg + asyncio.get_running_loop() + except RuntimeError: + result = asyncio.run(search(query, provider, max_results)) + else: + from src.modules.idea_gen.utils.thread_pool_manager import ThreadPoolManager + + result = ThreadPoolManager.submit_io_task( + asyncio.run, + search(query, provider, max_results), + ).result() + return result.get("results", []) if isinstance(result, dict) else [] class PaperParseError(Exception): diff --git a/backend/src/modules/idea_gen/utils/artifact_utils.py b/backend/src/modules/idea_gen/utils/artifact_utils.py new file mode 100644 index 00000000..dd04c8cc --- /dev/null +++ b/backend/src/modules/idea_gen/utils/artifact_utils.py @@ -0,0 +1,62 @@ +"""Artifact and state snapshot helpers for idea_gen workflow.""" + +from __future__ import annotations + +import json +from datetime import datetime +from pathlib import Path +from typing import Any + + +def get_output_dir(state: Any) -> Path: + """Return the output directory for a workflow run.""" + base = Path(__file__).resolve().parents[4] / "output" / "idea_gen" + return base / state.thread_id if state.thread_id else base / "default" + + +def save_artifact(state: Any, filename: str, content: str) -> None: + """Save a workflow artifact file.""" + output_dir = get_output_dir(state) + output_dir.mkdir(parents=True, exist_ok=True) + output_path = output_dir / filename + output_path.write_text(content, encoding="utf-8") + print(f"[ARTIFACT] Saved to {output_path}") + + +def save_node_state(state: Any, node_name: str) -> None: + """Persist a compact node state snapshot.""" + output_dir = get_output_dir(state) + output_dir.mkdir(parents=True, exist_ok=True) + + def serialize_value(value: Any): + if isinstance(value, datetime): + return value.isoformat() + if hasattr(value, "model_dump"): + return serialize_value(value.model_dump()) + if isinstance(value, set): + return list(value) + if isinstance(value, dict): + return {key: serialize_value(item) for key, item in value.items()} + if isinstance(value, list): + return [serialize_value(item) for item in value] + return value + + state_data = { + "node": node_name, + "iteration_count": state.iteration_count, + "unit_count": state.unit_count, + "research_brief": state.research_brief, + "paper_queries": state.paper_queries, + "web_queries": state.web_queries, + "notes": state.notes, + "messages": state.messages[-5:] if state.messages else [], + "temp": serialize_value(getattr(state, "temp", None)), + "temp_data": {key: serialize_value(value) for key, value in state.temp_data.items()}, + "node_errors": serialize_value(getattr(state, "node_errors", [])), + "research_runs_count": len(getattr(state, "research_runs", []) or []), + "final_report_chars": len(getattr(state, "final_report", "") or ""), + } + + state_path = output_dir / f"node_{node_name}_state.json" + state_path.write_text(json.dumps(state_data, ensure_ascii=False, indent=2), encoding="utf-8") + print(f"[STATE] Saved {node_name} state to {state_path}") diff --git a/backend/src/modules/idea_gen/utils/cache_utils.py b/backend/src/modules/idea_gen/utils/cache_utils.py new file mode 100644 index 00000000..8941152a --- /dev/null +++ b/backend/src/modules/idea_gen/utils/cache_utils.py @@ -0,0 +1,13 @@ +"""Grouped cache helpers for idea_gen workflow.""" + +from __future__ import annotations + +from src.modules.idea_gen.utils.paper_cache_store import PaperCacheStore + + +def _create_paper_cache_store() -> PaperCacheStore | None: + """Create the shared paper cache store for the current process.""" + return PaperCacheStore.create_default() + + +__all__ = ["_create_paper_cache_store"] diff --git a/backend/src/modules/idea_gen/utils/citation_expander.py b/backend/src/modules/idea_gen/utils/citation_expander.py index 861d408f..00fb5bf9 100644 --- a/backend/src/modules/idea_gen/utils/citation_expander.py +++ b/backend/src/modules/idea_gen/utils/citation_expander.py @@ -5,6 +5,12 @@ import requests +from src.modules.idea_gen.utils.paper_normalization import ( + extract_openalex_authors, + extract_openalex_pdf_url, + normalize_arxiv_id, +) + class CitationExpander: """Fetch paper citations with OpenAlex-first fallback.""" @@ -71,20 +77,6 @@ def _request_json( raise ValueError(f"{context_label} returned non-dict JSON payload") return payload - @staticmethod - def normalize_arxiv_id(arxiv_id: str) -> str: - """Normalize arXiv ID.""" - value = arxiv_id.strip() - if value.startswith("http"): - value = value.rstrip("/").split("/")[-1] - if value.startswith("arXiv:"): - value = value.split("arXiv:", 1)[1] - if value.endswith(".pdf"): - value = value[:-4] - if "v" in value and value.split("v")[-1].isdigit(): - value = value.rsplit("v", 1)[0] - return value - @staticmethod def normalize_openalex_id(work_id: str) -> str: """Normalize OpenAlex work ID.""" @@ -97,12 +89,12 @@ def normalize_openalex_id(work_id: str) -> str: @staticmethod def _build_arxiv_abs_url(arxiv_id: str) -> str: - normalized = CitationExpander.normalize_arxiv_id(arxiv_id) + normalized = normalize_arxiv_id(arxiv_id) return f"https://arxiv.org/abs/{normalized}" if normalized else "" @staticmethod def _build_arxiv_pdf_url(arxiv_id: str) -> str: - normalized = CitationExpander.normalize_arxiv_id(arxiv_id) + normalized = normalize_arxiv_id(arxiv_id) return f"https://arxiv.org/pdf/{normalized}.pdf" if normalized else "" @staticmethod @@ -115,38 +107,6 @@ def _build_semantic_scholar_url(paper_id: str) -> str: normalized = str(paper_id or "").strip() return f"https://www.semanticscholar.org/paper/{normalized}" if normalized else "" - @staticmethod - def _extract_openalex_pdf_url(work: dict[str, Any]) -> str: - primary_location = work.get("primary_location") or {} - pdf_url = primary_location.get("pdf_url") or "" - if pdf_url: - return pdf_url - - best_oa_location = work.get("best_oa_location") or {} - pdf_url = best_oa_location.get("pdf_url") or "" - if pdf_url: - return pdf_url - - for location in work.get("locations", []) or []: - if not isinstance(location, dict): - continue - pdf_url = location.get("pdf_url") or "" - if pdf_url: - return pdf_url - return "" - - @staticmethod - def _extract_openalex_authors(work: dict[str, Any]) -> list[str]: - authors: list[str] = [] - for authorship in work.get("authorships", []) or []: - if not isinstance(authorship, dict): - continue - author = authorship.get("author") or {} - name = author.get("display_name") or "" - if name: - authors.append(name) - return authors - def _extract_arxiv_id(self, paper: dict[str, Any]) -> str: identifiers = paper.get("identifiers") or {} candidates = [ @@ -155,10 +115,10 @@ def _extract_arxiv_id(self, paper: dict[str, Any]) -> str: ] for candidate in candidates: if candidate: - return self.normalize_arxiv_id(candidate) + return normalize_arxiv_id(candidate) url = str(paper.get("url", "") or "") if "arxiv.org" in url: - return self.normalize_arxiv_id(url) + return normalize_arxiv_id(url) return "" def _extract_openalex_id(self, paper: dict[str, Any]) -> str: @@ -176,7 +136,7 @@ def fetch_openalex_payload_for_work(self, work_id: str) -> dict[str, Any]: def fetch_openalex_payload_for_arxiv(self, arxiv_id: str) -> dict[str, Any]: """Fetch OpenAlex work payload by arXiv ID.""" - normalized_arxiv_id = self.normalize_arxiv_id(arxiv_id) + normalized_arxiv_id = normalize_arxiv_id(arxiv_id) search_attempts = [ ("locations.landing_page_url", self._build_arxiv_abs_url(normalized_arxiv_id)), ] @@ -247,7 +207,7 @@ def _fetch_referenced_works(self, referenced_works: list[str], context_label: st def fetch_semantic_payload_for_arxiv(self, arxiv_id: str) -> dict[str, Any]: """Fetch Semantic Scholar payload by arXiv ID.""" - normalized_arxiv_id = self.normalize_arxiv_id(arxiv_id) + normalized_arxiv_id = normalize_arxiv_id(arxiv_id) headers = {"x-api-key": self.semantic_scholar_api_key} if self.semantic_scholar_api_key else None payload = self._request_json( self.SEMANTIC_SCHOLAR_ARXIV_URL.format(arxiv_id=normalized_arxiv_id), @@ -255,7 +215,7 @@ def fetch_semantic_payload_for_arxiv(self, arxiv_id: str) -> dict[str, Any]: headers=headers, context_label=f"GET semantic arxiv {normalized_arxiv_id}", ) - returned_id = self.normalize_arxiv_id((payload.get("externalIds") or {}).get("ArXiv", "")) + returned_id = normalize_arxiv_id((payload.get("externalIds") or {}).get("ArXiv", "")) if returned_id and returned_id != normalized_arxiv_id: raise ValueError( f"Semantic Scholar returned mismatched arXiv ID '{returned_id}' for '{normalized_arxiv_id}'" @@ -264,7 +224,7 @@ def fetch_semantic_payload_for_arxiv(self, arxiv_id: str) -> dict[str, Any]: def expand_citations_from_arxiv(self, arxiv_id: str) -> list[dict[str, Any]]: """Expand citations from arXiv paper.""" - arxiv_id = self.normalize_arxiv_id(arxiv_id) + arxiv_id = normalize_arxiv_id(arxiv_id) self._log(f"Resolving arXiv paper via OpenAlex: arxiv_id={arxiv_id}") payload = self.fetch_openalex_payload_for_arxiv(arxiv_id) referenced_works = payload.get("referenced_works", []) or [] @@ -283,7 +243,7 @@ def expand_citations_from_openalex(self, work_id: str) -> list[dict[str, Any]]: def expand_citations_from_semantic(self, arxiv_id: str) -> list[dict[str, Any]]: """Expand citations from Semantic Scholar by arXiv ID.""" - normalized_arxiv_id = self.normalize_arxiv_id(arxiv_id) + normalized_arxiv_id = normalize_arxiv_id(arxiv_id) self._log(f"Resolving arXiv paper via Semantic Scholar: arxiv_id={normalized_arxiv_id}") payload = self.fetch_semantic_payload_for_arxiv(normalized_arxiv_id) references = payload.get("references", []) or [] @@ -317,11 +277,11 @@ def _format_reference(self, ref_data: dict[str, Any]) -> dict[str, Any]: arxiv_id = "" ids = ref_data.get("ids", {}) if ids and ids.get("arxiv"): - arxiv_id = self.normalize_arxiv_id(ids["arxiv"].split("/")[-1]) + arxiv_id = normalize_arxiv_id(ids["arxiv"].split("/")[-1]) openalex_id = self.normalize_openalex_id(ref_data.get("id", "")) doi = (ref_data.get("doi") or ids.get("doi") or "").strip() oa_url = ((ref_data.get("open_access") or {}).get("oa_url") or "").strip() - pdf_url = self._extract_openalex_pdf_url(ref_data) + pdf_url = extract_openalex_pdf_url(ref_data) if not pdf_url and arxiv_id: pdf_url = self._build_arxiv_pdf_url(arxiv_id) identifiers: dict[str, str] = {} @@ -343,7 +303,7 @@ def _format_reference(self, ref_data: dict[str, Any]) -> dict[str, Any]: "arxivId": arxiv_id, "title": ref_data.get("title", ""), "snippet": ref_data.get("abstract", ""), - "authors": self._extract_openalex_authors(ref_data), + "authors": extract_openalex_authors(ref_data), "url": canonical_url, "source_url": canonical_url, "download_source_url": pdf_url, @@ -365,7 +325,7 @@ def _format_reference(self, ref_data: dict[str, Any]) -> dict[str, Any]: def _format_semantic_reference(self, ref_data: dict[str, Any]) -> dict[str, Any]: """Format Semantic Scholar reference.""" external_ids = ref_data.get("externalIds") or {} - arxiv_id = self.normalize_arxiv_id(external_ids.get("ArXiv", "")) if external_ids.get("ArXiv") else "" + arxiv_id = normalize_arxiv_id(external_ids.get("ArXiv", "")) if external_ids.get("ArXiv") else "" paper_id = ref_data.get("paperId", "") or arxiv_id doi = (external_ids.get("DOI") or "").strip() identifiers: dict[str, str] = {} diff --git a/backend/src/modules/idea_gen/utils/compression_utils.py b/backend/src/modules/idea_gen/utils/compression_utils.py new file mode 100644 index 00000000..c41815c8 --- /dev/null +++ b/backend/src/modules/idea_gen/utils/compression_utils.py @@ -0,0 +1,347 @@ +"""Grouped compression helpers for idea_gen workflow.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from src.modules.idea_gen.utils.paper_cache_store import build_paper_key +from src.modules.idea_gen.utils.paper_utils import ( + _resolve_download_source_url, + _resolve_public_paper_url, +) + + +def _parse_with_semaphore( + parse_paper_to_markdown_with_parser: Any, + paper_config: Any, + mineru_semaphore: Any, + url: str, +) -> tuple[str, str]: + """Throttle MinerU parsing while leaving other parsers unconstrained.""" + if paper_config.pdf_parsers and paper_config.pdf_parsers[0] == "mineru": + with mineru_semaphore: + return parse_paper_to_markdown_with_parser(url) + return parse_paper_to_markdown_with_parser(url) + + +def _parse_with_fallback( + parse_paper_to_markdown_with_parser: Any, + paper_config: Any, + mineru_semaphore: Any, + url: str, +) -> tuple[str, bool, str]: + """Fallback to partial parsing when the primary parse stalls.""" + import concurrent.futures + + with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor_inner: + future = executor_inner.submit( + _parse_with_semaphore, + parse_paper_to_markdown_with_parser, + paper_config, + mineru_semaphore, + url, + ) + try: + content, content_parser = future.result(timeout=30) + return content, False, content_parser + except concurrent.futures.TimeoutError: + content, content_parser = parse_paper_to_markdown_with_parser(url, max_pages=8) + return content, True, content_parser + + +def _get_or_parse_paper_for_compress( + paper: dict[str, Any], + idx: int, + *, + papers_dir: Path, + storage: Any, + cache_store: Any, + parse_paper_to_markdown_with_parser: Any, + paper_config: Any, + mineru_semaphore: Any, +) -> dict[str, Any]: + """Resolve one final paper into parsed markdown for downstream compression.""" + import hashlib + + source_url = str(paper.get("source_url") or _resolve_public_paper_url(paper) or "").strip() + download_source_url = str(paper.get("download_source_url") or _resolve_download_source_url(paper) or "").strip() + title = paper.get("title", "unknown") + canonical_source_url = source_url or download_source_url + if not download_source_url: + raise ValueError(f"No downloadable source found for paper: {title}") + + generated_paper_key = build_paper_key(title, canonical_source_url) + cached = cache_store.get_by_key_or_url(generated_paper_key, canonical_source_url) if cache_store else None + if not cached and cache_store and download_source_url != canonical_source_url: + cached = cache_store.get_by_source_url(download_source_url) + + if cached and cached.get("content_md"): + content = cached["content_md"] + is_partial = bool(cached.get("is_partial")) + content_parser = cached.get("content_parser") or "cache" + paper_key = cached.get("paper_key") or generated_paper_key + cache_hit = True + else: + content, is_partial, content_parser = _parse_with_fallback( + parse_paper_to_markdown_with_parser, + paper_config, + mineru_semaphore, + download_source_url, + ) + paper_key = generated_paper_key + cache_hit = False + if cache_store: + cache_store.upsert_parsed_content( + paper_key=paper_key, + title=title, + source_url=canonical_source_url, + content_md=content, + content_hash=hashlib.sha256(content.encode("utf-8")).hexdigest(), + content_parser=content_parser, + is_partial=is_partial, + ) + + safe_title = title[:50].replace("/", "_").replace(" ", "_") + paper_id = f"final_paper_{idx + 1}_{safe_title}" + paper_file = papers_dir / f"{paper_id}.md" + paper_file.write_text(content, encoding="utf-8") + + raw_ref = None + if storage and content: + paper_ref = storage.write_paper_raw(paper_id, content) + raw_ref = paper_ref.model_dump() + + return { + "paper_id": paper_id, + "paper_key": paper_key, + "source_url": canonical_source_url, + "download_source_url": download_source_url, + "title": title, + "core_summary": "", + "technical_depth": "", + "empirical_support": "", + "content_parser": content_parser, + "is_partial": is_partial, + "raw_ref": raw_ref, + "cache_hit": cache_hit, + "url": paper.get("url", ""), + "arxivId": paper.get("arxivId", ""), + "openalex_id": paper.get("openalex_id", ""), + "doi": paper.get("doi", ""), + "oa_url": paper.get("oa_url", ""), + "pdf_url": paper.get("pdf_url", ""), + "identifiers": dict(paper.get("identifiers") or {}), + "retrieval_source": paper.get("retrieval_source", ""), + "source_provider": paper.get("source_provider", ""), + "source": paper.get("source", ""), + "query_topic": paper.get("query_topic", ""), + "query_intent": paper.get("query_intent", ""), + "from_citation_expansion": bool(paper.get("from_citation_expansion")), + "parent_paper_id": paper.get("parent_paper_id", ""), + "relevance_score": paper.get("relevance_score", 0.0), + "final_selection_rank": paper.get("final_selection_rank"), + "source_completion_status": paper.get("source_completion_status", ""), + "source_completion_strategy": paper.get("source_completion_strategy", ""), + "source_completion_failure_reason": paper.get("source_completion_failure_reason", ""), + } + + +def _compress_section_content( + executor: Any, + prompt_builder: Any, + language: str, + section_content: str, +) -> str: + """Compress one section group with the shared node executor.""" + prompt = prompt_builder(language) + return executor.invoke_text(prompt, section_content).strip() + + +def _compress_single_paper( + position: int, + paper: dict[str, Any], + *, + papers_dir: Path, + cache_store: Any, + strategy: str, + strategy_key: str, + paper_config: Any, + language: str, + executor: Any, + storage: Any, +) -> dict[str, Any]: + """Compress one parsed paper into the three structured note fields.""" + from concurrent.futures import ThreadPoolExecutor, as_completed + + from langchain_core.messages import HumanMessage, SystemMessage + + from src.modules.idea_gen.prompts import ( + build_compress_core_summary_prompt, + build_compress_empirical_support_prompt, + build_compress_technical_depth_prompt, + build_paper_compress_prompt, + ) + from src.modules.idea_gen.schemas import ArtifactRef + from src.modules.idea_gen.utils.paper_compressor import CompressedPaper, _group_sections, _parse_sections + from src.utils.structured_output import parse_structured_output + + paper_id = paper["paper_id"] + paper_file = papers_dir / f"{paper_id}.md" + paper_key = paper.get("paper_key") or build_paper_key(paper["title"], paper.get("source_url", "")) + cached = cache_store.get_by_paper_key(paper_key) if cache_store else None + compression_errors: list[str] = [] + compression_cache_hit = bool( + cached and cached.get("compressed_content") and cached.get("compress_strategy") == strategy_key + ) + + if compression_cache_hit: + cached_payload = CompressedPaper.deserialize(cached["compressed_content"]) + core_compressed = cached_payload.core_summary + tech_compressed = cached_payload.technical_depth + emp_compressed = cached_payload.empirical_support + else: + if not paper_file.exists(): + return { + "position": position, + "paper": paper, + "text": f"{position}. {paper['title']} (file not found)\n\n", + "log": None, + } + + full_content = paper_file.read_text(encoding="utf-8") + + if strategy == "section_group": + sections = _parse_sections(full_content) + groups = _group_sections(sections) + group_specs = [ + ("core_summary", "### 核心摘要", groups.get("core_summary", []), build_compress_core_summary_prompt), + ("technical_depth", "### 技术细节", groups.get("technical_depth", []), build_compress_technical_depth_prompt), + ("empirical_support", "### 实验验证", groups.get("empirical_support", []), build_compress_empirical_support_prompt), + ] + compressed_fields = {name: "" for name, _, _, _ in group_specs} + active_specs = [ + (name, heading, "\n\n".join(section["content"] for section in group_sections), prompt_builder) + for name, heading, group_sections, prompt_builder in group_specs + if group_sections + ] + + with ThreadPoolExecutor(max_workers=max(1, len(active_specs))) as section_pool: + future_to_spec = { + section_pool.submit( + _compress_section_content, + executor, + prompt_builder, + language, + group_content, + ): (name, heading) + for name, heading, group_content, prompt_builder in active_specs + } + for future in as_completed(future_to_spec): + name, heading = future_to_spec[future] + try: + compressed_fields[name] = future.result() + except Exception as exc: + compression_errors.append(f"{heading} failed: {exc}") + + core_compressed = compressed_fields["core_summary"] + tech_compressed = compressed_fields["technical_depth"] + emp_compressed = compressed_fields["empirical_support"] + elif strategy == "truncate": + truncated = full_content[:paper_config.truncate_max_chars] + system, user = build_paper_compress_prompt(paper["title"], truncated, language) + response = executor.invoke_messages( + [SystemMessage(content=system), HumanMessage(content=user)] + ) + data = parse_structured_output(response.content) + core_compressed = data.get("core_summary", "") + tech_compressed = data.get("technical_depth", "") + emp_compressed = data.get("empirical_support", "") + else: + raise ValueError(f"Unknown compression strategy: {strategy}") + + if cache_store: + cache_store.update_compressed_content( + paper_key=paper_key, + compressed_content=CompressedPaper( + core_compressed, + tech_compressed, + emp_compressed, + ).serialize(), + compress_strategy=strategy_key, + ) + + completed_paper = { + "paper_id": paper_id, + "paper_key": paper_key, + "source_url": paper.get("source_url", ""), + "download_source_url": paper.get("download_source_url", ""), + "url": paper.get("url", ""), + "title": paper["title"], + "core_summary": core_compressed, + "technical_depth": tech_compressed, + "empirical_support": emp_compressed, + "content_parser": paper.get("content_parser", ""), + "is_partial": paper.get("is_partial", False), + "compression_cache_hit": compression_cache_hit, + "raw_ref": paper.get("raw_ref"), + "arxivId": paper.get("arxivId", ""), + "openalex_id": paper.get("openalex_id", ""), + "doi": paper.get("doi", ""), + "oa_url": paper.get("oa_url", ""), + "pdf_url": paper.get("pdf_url", ""), + "identifiers": dict(paper.get("identifiers") or {}), + "retrieval_source": paper.get("retrieval_source", ""), + "source_provider": paper.get("source_provider", ""), + "source": paper.get("source", ""), + "query_topic": paper.get("query_topic", ""), + "query_intent": paper.get("query_intent", ""), + "from_citation_expansion": bool(paper.get("from_citation_expansion")), + "parent_paper_id": paper.get("parent_paper_id", ""), + "relevance_score": paper.get("relevance_score", 0.0), + "final_selection_rank": paper.get("final_selection_rank"), + "source_completion_status": paper.get("source_completion_status", ""), + "source_completion_strategy": paper.get("source_completion_strategy", ""), + "source_completion_failure_reason": paper.get("source_completion_failure_reason", ""), + } + paper_text = f"## Paper {position}: {paper['title']}\n\n" + raw_ref = paper.get("raw_ref") + + if storage and raw_ref: + storage.compress_paper( + paper_id, + paper["title"], + raw_ref=raw_ref if hasattr(raw_ref, "artifact_type") else ArtifactRef.model_validate(raw_ref), + core_summary=core_compressed, + technical_depth=tech_compressed, + empirical_support=emp_compressed, + ) + + if core_compressed: + paper_text += f"### 核心摘要\n{core_compressed}\n\n" + if tech_compressed: + paper_text += f"### 技术细节\n{tech_compressed}\n\n" + if emp_compressed: + paper_text += f"### 实验验证\n{emp_compressed}\n\n" + for error in compression_errors: + paper_text += f"({error})\n\n" + + return { + "position": position, + "paper": completed_paper, + "text": paper_text, + "log": ( + f"[COMPRESS] {paper_id}: cache hit for {strategy_key}" + if compression_cache_hit + else f"[COMPRESS] {paper_id}: {strategy} compression done" + ), + } + + +__all__ = [ + "_parse_with_semaphore", + "_parse_with_fallback", + "_get_or_parse_paper_for_compress", + "_compress_section_content", + "_compress_single_paper", +] diff --git a/backend/src/modules/idea_gen/utils/critic_executor.py b/backend/src/modules/idea_gen/utils/critic_executor.py index 62cdbc00..93bbfd00 100644 --- a/backend/src/modules/idea_gen/utils/critic_executor.py +++ b/backend/src/modules/idea_gen/utils/critic_executor.py @@ -1,5 +1,6 @@ """Multi-model critic executor for idea_gen.""" import asyncio +import json import logging import time @@ -30,19 +31,24 @@ async def evaluate_parallel(self, system: str, user: str) -> list[ModelCritiqueR async def _evaluate_single(self, model_name: str, system: str, user: str) -> ModelCritiqueResult: """Evaluate with a single model.""" - import json from src.modules.idea_gen.schemas import IdeaCritique start = time.time() model = create_critic_model(model_name) - response = model.invoke([{"role": "system", "content": system}, {"role": "user", "content": user}]) + messages = [{"role": "system", "content": system}, {"role": "user", "content": user}] + + ainvoke = getattr(model, "ainvoke", None) + if callable(ainvoke): + response = await ainvoke(messages) + else: + response = await asyncio.to_thread(model.invoke, messages) try: data = json.loads(response.content) critiques = [IdeaCritique(**c) for c in data.get("critiques", [])] overall_assessment = data.get("overall_assessment", "") - confidence = data.get("confidence", 0.8) - except: + confidence = float(data.get("confidence", 0.8) or 0.8) + except Exception: critiques = [] overall_assessment = response.content confidence = 0.8 diff --git a/backend/src/modules/idea_gen/utils/critic_state_utils.py b/backend/src/modules/idea_gen/utils/critic_state_utils.py new file mode 100644 index 00000000..03dcffb6 --- /dev/null +++ b/backend/src/modules/idea_gen/utils/critic_state_utils.py @@ -0,0 +1,36 @@ +"""Critic state persistence helpers.""" + +from __future__ import annotations + +from typing import Any + +from src.modules.idea_gen.utils.state_utils import set_temp_value + + +def _finalize_critic_state( + state: Any, + *, + action: str, + status: str, + ran: bool, + successful_models: int = 0, + avg_score: float | None = None, +): + """Persist critic execution metadata on state.""" + from src.modules.idea_gen.utils.workflow_helpers import _save_node_state + + if hasattr(state, "temp") and hasattr(state.temp, "critic_action"): + state.temp.critic_action = action + set_temp_value(state, "critic_action", action) + state.temp_data["critic_status"] = status + state.temp_data["critic_ran"] = ran + state.temp_data["critic_successful_models"] = successful_models + if avg_score is None: + state.temp_data.pop("critic_avg_score", None) + else: + state.temp_data["critic_avg_score"] = avg_score + _save_node_state(state, f"critic_iter{state.critic_iteration_count}") + return state + + +__all__ = ["_finalize_critic_state"] diff --git a/backend/src/modules/idea_gen/utils/embedding_store.py b/backend/src/modules/idea_gen/utils/embedding_store.py new file mode 100644 index 00000000..9d5a5a1b --- /dev/null +++ b/backend/src/modules/idea_gen/utils/embedding_store.py @@ -0,0 +1,176 @@ +"""BM25 + embedding retrieval store for paper pool.""" +import os +import logging +import re +from typing import Any + +import numpy as np +import tiktoken +from openai import AsyncOpenAI +from rank_bm25 import BM25Okapi + +logger = logging.getLogger(__name__) + +CHUNK_SIZE = 512 # tokens +CHUNK_OVERLAP = 64 # tokens +EMBED_MODEL = "text-embedding-ada-002" +_enc = tiktoken.get_encoding("cl100k_base") + + +def _token_split(text: str) -> list[str]: + """Split a long text by tokens with overlap.""" + tokens = _enc.encode(text) + chunks = [] + start = 0 + while start < len(tokens): + end = min(start + CHUNK_SIZE, len(tokens)) + chunks.append(_enc.decode(tokens[start:end])) + if end == len(tokens): + break + start += CHUNK_SIZE - CHUNK_OVERLAP + return chunks + + +def _chunk_markdown(text: str) -> list[str]: + """Chunk markdown paper text by section → paragraph, with 1-paragraph overlap.""" + # Split into sections on top-level headings (# but not ##) + section_pattern = re.compile(r"(?=^# )", re.MULTILINE) + sections = section_pattern.split(text) + sections = [s.strip() for s in sections if s.strip()] + + # Fallback: no markdown structure → treat as plain text + if not sections: + return _token_split(text) or [text] + + chunks: list[str] = [] + for section in sections: + paragraphs = [p.strip() for p in re.split(r"\n{2,}", section) if p.strip()] + # Extract section heading (first line starting with #) + heading = "" + content_paras = [] + for p in paragraphs: + if p.startswith("#") and not heading: + heading = p + else: + content_paras.append(p) + + prev: str | None = None + for i, para in enumerate(content_paras): + # Prepend heading to first paragraph of each section + base = f"{heading}\n\n{para}" if (heading and i == 0) else para + token_count = len(_enc.encode(base)) + if token_count > CHUNK_SIZE: + chunks.extend(_token_split(base)) + prev = para + else: + chunk = f"{prev}\n\n{base}" if prev else base + chunks.append(chunk) + prev = para + + return chunks or [text] + + +def _chunk_text(text: str) -> list[str]: + """Choose chunking strategy based on content.""" + if re.search(r"^# ", text, re.MULTILINE): + return _chunk_markdown(text) + # Plain text (title + abstract only): single chunk + return [text] + + + +class EmbeddingStore: + """Holds BM25 index and embedding vectors for a paper pool.""" + + def __init__(self) -> None: + self._paper_keys: list[str] = [] # parallel to _docs + self._docs: list[str] = [] # title + abstract per paper + self._bm25: BM25Okapi | None = None + + # chunk-level data for embedding search + self._chunk_keys: list[str] = [] # paper_key for each chunk + self._chunk_texts: list[str] = [] + self._chunk_vectors: np.ndarray | None = None # (N, D) + + self._client: AsyncOpenAI | None = None + + def _get_client(self) -> AsyncOpenAI: + if self._client is None: + self._client = AsyncOpenAI( + api_key=os.environ["DF_API_KEY"], + base_url=os.environ.get("DF_API_URL"), + ) + return self._client + + def build(self, papers: list[dict[str, Any]]) -> None: + """Build BM25 index from papers (each must have paper_key, title, abstract).""" + self._paper_keys = [] + self._docs = [] + tokenized = [] + for p in papers: + key = p["paper_key"] + doc = f"{p.get('title', '')} {p.get('abstract', '')}" + self._paper_keys.append(key) + self._docs.append(doc) + tokenized.append(doc.lower().split()) + + for chunk in _chunk_text(doc): + self._chunk_keys.append(key) + self._chunk_texts.append(chunk) + + self._bm25 = BM25Okapi(tokenized) + logger.info("BM25 index built: %d papers, %d chunks", len(papers), len(self._chunk_texts)) + + async def build_embeddings(self) -> None: + """Embed all chunks via DF_API_URL (call after build()).""" + if not self._chunk_texts: + return + client = self._get_client() + resp = await client.embeddings.create(model=EMBED_MODEL, input=self._chunk_texts) + vecs = [d.embedding for d in resp.data] + self._chunk_vectors = np.array(vecs, dtype=np.float32) + # L2-normalize for cosine via dot product + norms = np.linalg.norm(self._chunk_vectors, axis=1, keepdims=True) + self._chunk_vectors /= np.where(norms == 0, 1, norms) + logger.info("Embeddings built: %d chunks", len(self._chunk_texts)) + + def bm25_search(self, query: str, top_k: int = 3) -> list[tuple[str, float]]: + """Return [(paper_key, score)] sorted by BM25 score.""" + if self._bm25 is None: + return [] + scores = self._bm25.get_scores(query.lower().split()) + ranked = sorted( + zip(self._paper_keys, scores.tolist()), + key=lambda x: x[1], + reverse=True, + ) + seen: set[str] = set() + results = [] + for key, score in ranked: + if key not in seen: + seen.add(key) + results.append((key, score)) + if len(results) >= top_k: + break + return results + + async def embedding_search(self, query: str, top_k: int = 3) -> list[tuple[str, float]]: + """Return [(paper_key, cosine_sim)] sorted by similarity.""" + if self._chunk_vectors is None: + return [] + client = self._get_client() + resp = await client.embeddings.create(model=EMBED_MODEL, input=[query]) + qvec = np.array(resp.data[0].embedding, dtype=np.float32) + norm = np.linalg.norm(qvec) + if norm > 0: + qvec /= norm + sims = self._chunk_vectors @ qvec # (N,) + + # aggregate to paper level: max-pool + paper_sim: dict[str, float] = {} + for key, sim in zip(self._chunk_keys, sims.tolist()): + if key not in paper_sim or sim > paper_sim[key]: + paper_sim[key] = sim + + ranked = sorted(paper_sim.items(), key=lambda x: x[1], reverse=True) + return ranked[:top_k] diff --git a/backend/src/modules/idea_gen/utils/error_utils.py b/backend/src/modules/idea_gen/utils/error_utils.py new file mode 100644 index 00000000..597bd015 --- /dev/null +++ b/backend/src/modules/idea_gen/utils/error_utils.py @@ -0,0 +1,52 @@ +"""Shared error helpers for idea_gen workflow.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any + +from src.modules.idea_gen.schemas import NodeError + + +def append_node_error( + state: Any, + *, + node: str, + error: Exception, + recoverable: bool, + message: str | None = None, +) -> NodeError: + """Record a structured workflow error on state.""" + error_entry = NodeError( + node=node, + error_type=type(error).__name__, + message=(message or str(error) or type(error).__name__).strip(), + timestamp=datetime.now().isoformat(), + recoverable=recoverable, + ) + state.node_errors.append(error_entry) + temp_data = getattr(state, "temp_data", None) + if isinstance(temp_data, dict): + temp_data.setdefault("node_errors", []).append(error_entry.model_dump()) + return error_entry + + +def handle_node_error( + state: Any, + *, + node: str, + error: Exception, + recoverable: bool, + message: str | None = None, + fatal: bool = False, +) -> None: + """Record an error and optionally raise a fatal runtime error.""" + entry = append_node_error( + state, + node=node, + error=error, + recoverable=recoverable, + message=message, + ) + if fatal: + raise RuntimeError(entry.message) from error diff --git a/backend/src/modules/idea_gen/utils/evidence_retriever.py b/backend/src/modules/idea_gen/utils/evidence_retriever.py new file mode 100644 index 00000000..560e6cee --- /dev/null +++ b/backend/src/modules/idea_gen/utils/evidence_retriever.py @@ -0,0 +1,776 @@ +"""4-lane evidence retriever for a single Seed.""" +from __future__ import annotations + +import json +import logging +import re +from dataclasses import dataclass, field +from typing import Any, Literal + +from langchain_openai import ChatOpenAI + +from src.modules.idea_gen.config import get_evidence_config +from src.modules.idea_gen.schemas import EvidenceBundle, EvidenceItem, Seed +from src.modules.idea_gen.utils.embedding_store import EmbeddingStore + +logger = logging.getLogger(__name__) + +Lane = Literal["inspiration", "novelty", "contradiction", "execution"] +LANE_ORDER: list[Lane] = ["inspiration", "novelty", "contradiction", "execution"] + +_LANE_PROMPTS: dict[Lane, str] = { + "inspiration": ( + "You are searching for cross-domain analogies and distant mechanisms that could inspire " + "a novel approach for the given research seed. Focus on papers from different fields that " + "share structural similarity with the target gap." + ), + "novelty": ( + "You are identifying the nearest prior work to the given research seed to assess novelty delta. " + "Focus on the most recent and directly competing papers." + ), + "contradiction": ( + "You are collecting failure cases, limitations, and counter-evidence related to the given " + "research seed. Focus on papers that report negative results, known bottlenecks, or refutations." + ), + "execution": ( + "You are gathering benchmarks, datasets, repositories, and experimental protocols relevant " + "to validating the given research seed. Focus on evaluation infrastructure." + ), +} + +_LANE_QUERY_HINTS: dict[Lane, str] = { + "inspiration": "Look for transferable mechanisms, analogies, and structurally similar solutions, even if the domain differs.", + "novelty": "Look for nearest prior work, direct competitors, and papers that would be the most natural reviewer references.", + "contradiction": "Look for limitations, failure modes, bottlenecks, counter-evidence, and papers that weaken the seed's assumptions.", + "execution": "Look for benchmarks, datasets, tasks, protocols, repos, and evaluation setups needed to validate the seed.", +} + +_LANE_SIGNAL_VOCAB: dict[Lane, tuple[str, ...]] = { + "inspiration": ( + "theoretical_framework", + "cognitive_analogy", + "cross_domain_mechanism", + "memory_process_model", + ), + "novelty": ( + "nearest_prior", + "direct_competitor", + "recent_method", + "strong_baseline", + ), + "contradiction": ( + "limitation", + "failure_mode", + "negative_result", + "bottleneck", + "counter_evidence", + ), + "execution": ( + "benchmark", + "dataset", + "evaluation_protocol", + "metric", + "repo", + ), +} + +_LANE_QUALITY_WEIGHTS: dict[Lane, dict[str, float]] = { + "inspiration": {"count": 0.25, "fit": 0.35, "signal": 0.25, "support": 0.15}, + "novelty": {"count": 0.20, "fit": 0.35, "signal": 0.20, "support": 0.25}, + "contradiction": {"count": 0.20, "fit": 0.30, "signal": 0.35, "support": 0.15}, + "execution": {"count": 0.20, "fit": 0.25, "signal": 0.40, "support": 0.15}, +} + +_LANE_TEXT_GUARDS: dict[Lane, tuple[str, ...]] = { + "inspiration": ("framework", "theor", "cognitive", "analogy", "mechanism"), + "novelty": ("prior", "competitor", "baseline", "recent", "state-of-the-art"), + "contradiction": ("limit", "failure", "bottleneck", "challenge", "weakness", "counter"), + "execution": ("benchmark", "dataset", "protocol", "evaluation", "evaluate", "metric", "repo"), +} + +_FIT_SCORES = { + "strong": 1.0, + "partial": 0.6, + "weak": 0.2, +} + +_LANE_QUERY_SYSTEM = """You are generating retrieval queries for four evidence lanes around a research seed. + +Return ONLY valid JSON: +{ + "inspiration": { + "dense_query": "...", + "sparse_queries": ["...", "...", "..."] + }, + "novelty": { + "dense_query": "...", + "sparse_queries": ["...", "...", "..."] + }, + "contradiction": { + "dense_query": "...", + "sparse_queries": ["...", "...", "..."] + }, + "execution": { + "dense_query": "...", + "sparse_queries": ["...", "...", "..."] + } +} + +Rules: +- Tailor each lane to its retrieval objective instead of reusing one generic query. +- dense_query should be a natural-language search intent for semantic retrieval. +- sparse_queries should be concise keyword-style search phrases for lexical retrieval. +- Keep sparse_queries to 2-3 items per lane. +- Ground every query in the seed's target_gap, mechanism, and scenario. +- novelty should emphasize nearest prior work / direct competitors. +- contradiction should emphasize limitations / failures / bottlenecks / counter-evidence. +- execution should emphasize benchmark / dataset / protocol / repo / evaluation setup. +- inspiration may be broader and more mechanism-oriented than the other lanes.""" + +_EXTRACT_SYSTEM = """Given a research seed and a retrieved paper, extract a concise EvidenceItem. +Return ONLY valid JSON: +{ + "paper_key": "", + "title": "", + "excerpt": "<1-2 sentence excerpt most relevant to the seed and lane objective>", + "relevance": "<one sentence explaining why this paper is relevant for this lane>", + "lane_fit": "strong|partial|weak", + "evidence_signals": ["signal_1", "signal_2"] +} + +Rules: +- `lane_fit` should be `strong` only when the paper directly answers the lane objective. +- `lane_fit` should be `partial` when the paper is relevant but indirect. +- `lane_fit` should be `weak` when the paper is mostly tangential. +- `evidence_signals` must use only the lane-specific allowed labels provided by the user message. +- Return an empty `evidence_signals` list if no allowed label is clearly supported by the paper text.""" + + +@dataclass +class LaneQueryPack: + dense_query: str = "" + sparse_queries: list[str] = field(default_factory=list) + + +@dataclass +class RetrieverConfig: + top_k: int = field(default_factory=lambda: get_evidence_config().top_k) + search_top_k: int = field(default_factory=lambda: get_evidence_config().search_top_k) + rrf_k: int = field(default_factory=lambda: get_evidence_config().rrf_k) + ready_unique_papers_threshold: int = field( + default_factory=lambda: get_evidence_config().ready_unique_papers_threshold + ) + ready_completeness_threshold: float = field( + default_factory=lambda: get_evidence_config().ready_completeness_threshold + ) + lane_targets: dict[Lane, int] = field( + default_factory=lambda: dict(get_evidence_config().lane_targets) + ) + lane_hybrid_weights: dict[Lane, dict[str, float]] = field( + default_factory=lambda: { + lane: dict(weights) + for lane, weights in get_evidence_config().lane_hybrid_weights.items() + } + ) + lane_completeness_weights: dict[Lane, float] = field( + default_factory=lambda: dict(get_evidence_config().lane_completeness_weights) + ) + lane_quality_thresholds: dict[Lane, float] = field( + default_factory=lambda: dict(get_evidence_config().lane_quality_thresholds) + ) + required_lanes: tuple[Lane, ...] = field( + default_factory=lambda: tuple(get_evidence_config().required_lanes) + ) + + +def _normalize_space(text: str) -> str: + return re.sub(r"\s+", " ", str(text or "")).strip() + + +def _truncate_topic(text: str, limit: int = 100) -> str: + normalized = _normalize_space(text) + return normalized[:limit] + + +def _extract_json_object(text: str) -> dict[str, Any]: + match = re.search(r"\{[\s\S]*\}", str(text or "")) + if not match: + raise ValueError("No JSON object found in model response") + return json.loads(match.group(0)) + + +def _normalize_signal(signal: str) -> str: + normalized = re.sub(r"[^a-z0-9]+", "_", str(signal or "").lower()).strip("_") + return normalized + + +def _coerce_lane_fit(value: Any) -> str: + normalized = _normalize_space(value).lower() + if normalized in _FIT_SCORES: + return normalized + return "partial" + + +def _coerce_evidence_signals(raw: Any, lane: Lane) -> list[str]: + allowed = set(_LANE_SIGNAL_VOCAB[lane]) + coerced: list[str] = [] + for item in list(raw or []): + normalized = _normalize_signal(item) + if normalized and normalized in allowed and normalized not in coerced: + coerced.append(normalized) + return coerced + + +def _default_lane_query_packs(seed: Seed) -> dict[Lane, LaneQueryPack]: + gap = _normalize_space(seed.target_gap) + mechanism = _normalize_space(seed.mechanism) + scenario = _normalize_space(seed.scenario) + + return { + "inspiration": LaneQueryPack( + dense_query=( + f"Find structurally similar mechanisms and cross-domain analogies for {gap} " + f"using {mechanism} in {scenario}" + ).strip(), + sparse_queries=[ + _truncate_topic(f"{gap} analogous mechanism"), + _truncate_topic(f"{mechanism} cross-domain inspiration"), + _truncate_topic(f"{scenario} memory adaptation"), + ], + ), + "novelty": LaneQueryPack( + dense_query=( + f"Find the nearest prior work and direct competitors for {mechanism} addressing {gap} in {scenario}" + ).strip(), + sparse_queries=[ + _truncate_topic(f"{gap} {mechanism} prior work"), + _truncate_topic(f"{scenario} {mechanism} direct competitor"), + _truncate_topic(f"{gap} nearest prior work"), + ], + ), + "contradiction": LaneQueryPack( + dense_query=( + f"Find limitations, failure modes, and counter-evidence for approaches related to {mechanism} " + f"for {gap} in {scenario}" + ).strip(), + sparse_queries=[ + _truncate_topic(f"{gap} limitation failure"), + _truncate_topic(f"{mechanism} bottleneck counter evidence"), + _truncate_topic(f"{scenario} memory failure mode"), + ], + ), + "execution": LaneQueryPack( + dense_query=( + f"Find benchmarks, datasets, repos, and evaluation protocols for validating {mechanism} " + f"on {gap} in {scenario}" + ).strip(), + sparse_queries=[ + _truncate_topic(f"{gap} benchmark dataset"), + _truncate_topic(f"{scenario} evaluation protocol"), + _truncate_topic(f"{mechanism} repo benchmark"), + ], + ), + } + + +def _coerce_lane_query_pack(raw: Any, fallback: LaneQueryPack) -> LaneQueryPack: + if not isinstance(raw, dict): + return fallback + dense_query = _normalize_space(raw.get("dense_query", "")) or fallback.dense_query + sparse_queries = [ + _truncate_topic(item) + for item in list(raw.get("sparse_queries", []) or []) + if _normalize_space(item) + ][:3] + if not sparse_queries: + sparse_queries = list(fallback.sparse_queries) + return LaneQueryPack(dense_query=dense_query, sparse_queries=sparse_queries) + + +async def build_lane_query_packs(seed: Seed, llm: ChatOpenAI | None = None) -> dict[Lane, LaneQueryPack]: + fallback = _default_lane_query_packs(seed) + if llm is None: + return fallback + + user = f"""Seed: +- target_gap: {seed.target_gap} +- mechanism: {seed.mechanism} +- scenario: {seed.scenario} +- expected_gain: {seed.expected_gain} + +Lane objectives: +- inspiration: {_LANE_QUERY_HINTS['inspiration']} +- novelty: {_LANE_QUERY_HINTS['novelty']} +- contradiction: {_LANE_QUERY_HINTS['contradiction']} +- execution: {_LANE_QUERY_HINTS['execution']}""" + + try: + resp = await llm.ainvoke( + [ + {"role": "system", "content": _LANE_QUERY_SYSTEM}, + {"role": "user", "content": user}, + ] + ) + raw = resp.content if hasattr(resp, "content") else str(resp) + data = _extract_json_object(raw) + return { + lane: _coerce_lane_query_pack(data.get(lane), fallback[lane]) + for lane in LANE_ORDER + } + except Exception as exc: + logger.warning("lane query generation failed for %s: %s", seed.seed_id, exc) + return fallback + + +def _weighted_rrf( + weighted_lists: list[tuple[float, list[tuple[str, float]]]], + rrf_k: int, +) -> list[tuple[str, float]]: + scores: dict[str, float] = {} + for weight, hits in weighted_lists: + seen_in_list: set[str] = set() + for rank, (paper_key, _raw_score) in enumerate(hits, start=1): + if not paper_key or paper_key in seen_in_list: + continue + seen_in_list.add(paper_key) + scores[paper_key] = scores.get(paper_key, 0.0) + (weight / (rrf_k + rank)) + return sorted(scores.items(), key=lambda item: item[1], reverse=True) + + +async def _hybrid_retrieve_lane( + seed: Seed, + lane: Lane, + lane_queries: LaneQueryPack, + store: EmbeddingStore, + cfg: RetrieverConfig, +) -> tuple[list[tuple[str, float]], dict[str, Any]]: + weights = cfg.lane_hybrid_weights[lane] + dense_query = lane_queries.dense_query + sparse_queries = list(lane_queries.sparse_queries or []) + if not sparse_queries and dense_query: + sparse_queries = [_truncate_topic(dense_query)] + + embedding_hits: list[tuple[str, float]] = [] + if dense_query: + embedding_hits = await store.embedding_search(dense_query, top_k=cfg.search_top_k) + + sparse_bm25_hits: list[tuple[str, list[tuple[str, float]]]] = [ + (query, store.bm25_search(query, top_k=cfg.search_top_k)) + for query in sparse_queries + if query + ] + bm25_hits = _weighted_rrf( + [(1.0, hits) for _, hits in sparse_bm25_hits], + cfg.rrf_k, + ) + hybrid_hits = _weighted_rrf( + [ + (weights["embedding"], embedding_hits), + (weights["bm25"], bm25_hits), + ], + cfg.rrf_k, + )[: cfg.top_k] + embedding_keys = {paper_key for paper_key, _score in embedding_hits} + bm25_keys = {paper_key for paper_key, _score in bm25_hits} + selected_hit_support = [] + for paper_key, score in hybrid_hits: + in_embedding = paper_key in embedding_keys + in_bm25 = paper_key in bm25_keys + if in_embedding and in_bm25: + support_origin = "both" + elif in_embedding: + support_origin = "embedding" + elif in_bm25: + support_origin = "bm25" + else: + support_origin = "unknown" + selected_hit_support.append( + { + "paper_key": paper_key, + "score": round(score, 6), + "support_origin": support_origin, + } + ) + + diagnostics = { + "seed_id": seed.seed_id, + "lane": lane, + "dense_query": dense_query, + "sparse_queries": sparse_queries, + "weights": weights, + "embedding_hits": [ + {"paper_key": paper_key, "score": round(score, 6)} + for paper_key, score in embedding_hits[: cfg.top_k] + ], + "bm25_queries": [ + { + "query": query, + "hits": [ + {"paper_key": paper_key, "score": round(score, 6)} + for paper_key, score in hits[: min(cfg.top_k, 5)] + ], + } + for query, hits in sparse_bm25_hits + ], + "hybrid_hits": [ + {"paper_key": paper_key, "score": round(score, 6)} + for paper_key, score in hybrid_hits + ], + "selected_hit_support": selected_hit_support, + } + return hybrid_hits, diagnostics + + +async def _extract_evidence_item( + llm: ChatOpenAI, + seed: Seed, + paper_key: str, + paper_doc: str, + lane: Lane, + lane_queries: LaneQueryPack, +) -> EvidenceItem | None: + allowed_signals = list(_LANE_SIGNAL_VOCAB[lane]) + prompt = ( + f"Lane: {lane}\n" + f"Lane objective: {_LANE_QUERY_HINTS[lane]}\n" + f"Allowed evidence signals: {json.dumps(allowed_signals, ensure_ascii=False)}\n" + f"Seed target_gap: {seed.target_gap}\n" + f"Seed mechanism: {seed.mechanism}\n" + f"Seed scenario: {seed.scenario}\n" + f"Dense query: {lane_queries.dense_query}\n" + f"Sparse queries: {json.dumps(lane_queries.sparse_queries, ensure_ascii=False)}\n\n" + f"Paper key: {paper_key}\n" + f"Paper content:\n{paper_doc[:1600]}" + ) + try: + resp = await llm.ainvoke( + [ + {"role": "system", "content": f"{_LANE_PROMPTS[lane]}\n\n{_EXTRACT_SYSTEM}"}, + {"role": "user", "content": prompt}, + ] + ) + raw = resp.content if hasattr(resp, "content") else str(resp) + data = _extract_json_object(raw) + data["lane_fit"] = _coerce_lane_fit(data.get("lane_fit")) + data["evidence_signals"] = _coerce_evidence_signals(data.get("evidence_signals"), lane) + return EvidenceItem(**data) + except Exception as exc: + logger.warning("evidence extraction failed for %s/%s: %s", lane, paper_key, exc) + return None + + +async def retrieve_lane( + seed: Seed, + lane: Lane, + lane_queries: LaneQueryPack, + store: EmbeddingStore, + paper_docs: dict[str, str], + llm: ChatOpenAI, + cfg: RetrieverConfig = RetrieverConfig(), +) -> tuple[list[EvidenceItem], dict[str, Any]]: + """Retrieve top-k papers for one lane and extract EvidenceItems.""" + hits, diagnostics = await _hybrid_retrieve_lane(seed, lane, lane_queries, store, cfg) + + items: list[EvidenceItem] = [] + for paper_key, _score in hits: + doc = paper_docs.get(paper_key, "") + item = await _extract_evidence_item(llm, seed, paper_key, doc, lane, lane_queries) + if item: + items.append(item) + + diagnostics["selected_paper_keys"] = [paper_key for paper_key, _score in hits] + diagnostics["selected_hit_count"] = len(hits) + diagnostics["extracted_count"] = len(items) + return items, diagnostics + + +def _lane_gap_message(lane: Lane, count: int, target: int, diagnostics: dict[str, Any]) -> str | None: + if count >= target: + return None + dense_query = diagnostics.get("dense_query", "") + return f"{lane} lane only has {count}/{target} supporting items; query focus: {dense_query}" + + +def _support_counts(diagnostics: dict[str, Any]) -> dict[str, int]: + counts = {"both": 0, "embedding": 0, "bm25": 0, "unknown": 0} + for item in list(diagnostics.get("selected_hit_support", []) or []): + origin = _normalize_space(item.get("support_origin", "")).lower() + if origin not in counts: + origin = "unknown" + counts[origin] += 1 + return counts + + +def _lane_quality_assessment( + lane: Lane, + items: list[EvidenceItem], + diagnostics: dict[str, Any], + target: int, + cfg: RetrieverConfig, +) -> dict[str, Any]: + support_counts = _support_counts(diagnostics) + fit_values = sorted((_FIT_SCORES.get(item.lane_fit, 0.6) for item in items), reverse=True) + top_fit_values = fit_values[:target] if target > 0 else fit_values + fit_score = 0.0 + if target > 0: + fit_score = min(sum(top_fit_values) / target, 1.0) + + signal_hits = [ + item for item in items + if any(signal in _LANE_SIGNAL_VOCAB[lane] for signal in item.evidence_signals) + ] + text_guard_hits = [ + item + for item in items + if any( + keyword in ( + f"{item.title} {item.excerpt} {item.relevance}" + ).lower() + for keyword in _LANE_TEXT_GUARDS[lane] + ) + ] + signal_score = min(len(signal_hits) / max(target, 1), 1.0) + count_score = min(len(items) / max(target, 1), 1.0) + + modality_mix = support_counts["both"] > 0 or ( + support_counts["embedding"] > 0 and support_counts["bm25"] > 0 + ) + if support_counts["both"] > 0: + support_score = 1.0 + elif modality_mix: + support_score = 0.7 + elif sum(support_counts.values()) > 0: + support_score = 0.35 + else: + support_score = 0.0 + + weights = _LANE_QUALITY_WEIGHTS[lane] + quality_score = ( + weights["count"] * count_score + + weights["fit"] * fit_score + + weights["signal"] * signal_score + + weights["support"] * support_score + ) + + usable_fit_count = sum(1 for item in items if item.lane_fit in {"strong", "partial"}) + strong_fit_count = sum(1 for item in items if item.lane_fit == "strong") + issues: list[str] = [] + if len(items) < target: + issues.append(f"only {len(items)}/{target} extracted items") + if usable_fit_count < target: + issues.append(f"only {usable_fit_count}/{target} items are lane-fit") + + if lane == "novelty": + if len(signal_hits) < 1: + issues.append("missing nearest-prior / direct-competitor signals") + if len(text_guard_hits) < 1: + issues.append("novelty evidence lacks explicit prior-work / competitor wording") + if not modality_mix: + issues.append("retrieval support does not agree across embedding and BM25") + elif lane == "contradiction": + if len(signal_hits) < 1: + issues.append("missing explicit limitation / failure / bottleneck evidence") + if len(text_guard_hits) < 1: + issues.append("contradiction evidence lacks explicit limitation / failure wording") + if not modality_mix: + issues.append("contradiction evidence is supported by only one retrieval modality") + elif lane == "execution": + if len(signal_hits) < 1: + issues.append("missing benchmark / dataset / protocol / repo evidence") + if len(text_guard_hits) < 1: + issues.append("execution evidence lacks explicit benchmark / dataset / protocol wording") + if not modality_mix: + issues.append("execution evidence is supported by only one retrieval modality") + else: + if len(signal_hits) < 1: + issues.append("missing clear theoretical / analogy signal") + + quality_ok = ( + len(items) >= target + and usable_fit_count >= target + and quality_score >= cfg.lane_quality_thresholds[lane] + ) + if lane in cfg.required_lanes and len(signal_hits) < 1: + quality_ok = False + if lane in cfg.required_lanes and len(text_guard_hits) < 1: + quality_ok = False + if lane in cfg.required_lanes and not modality_mix: + quality_ok = False + + return { + "score": round(quality_score, 3), + "quality_ok": quality_ok, + "item_count": len(items), + "usable_fit_count": usable_fit_count, + "strong_fit_count": strong_fit_count, + "signal_hit_count": len(signal_hits), + "text_guard_hit_count": len(text_guard_hits), + "support_counts": support_counts, + "issues": issues[:3], + } + + +def build_evidence_status( + seed: Seed, + bundle: EvidenceBundle, + lane_query_packs: dict[Lane, LaneQueryPack], + lane_diagnostics: dict[Lane, dict[str, Any]], + cfg: RetrieverConfig, +) -> dict[str, Any]: + lane_items = { + "inspiration": list(bundle.inspiration), + "novelty": list(bundle.novelty), + "contradiction": list(bundle.contradiction), + "execution": list(bundle.execution), + } + lane_counts = { + lane: len(items) + for lane, items in lane_items.items() + } + unique_paper_count = len( + { + item.paper_key + for items in lane_items.values() + for item in items + if item.paper_key + } + ) + lane_quality = { + lane: _lane_quality_assessment( + lane, + lane_items[lane], + lane_diagnostics.get(lane, {}), + cfg.lane_targets[lane], + cfg, + ) + for lane in LANE_ORDER + } + lane_scores = { + lane: lane_quality[lane]["score"] + for lane in LANE_ORDER + } + weighted_completeness = sum( + cfg.lane_completeness_weights[lane] * lane_scores[lane] + for lane in LANE_ORDER + ) + blocking_lanes = [ + lane + for lane in cfg.required_lanes + if not lane_quality[lane]["quality_ok"] + ] + evidence_gaps: list[str] = [] + for lane in LANE_ORDER: + gap = _lane_gap_message( + lane, + lane_counts[lane], + cfg.lane_targets[lane], + lane_diagnostics.get(lane, {}), + ) + if gap: + evidence_gaps.append(gap) + for issue in lane_quality[lane]["issues"]: + evidence_gaps.append(f"{lane} lane: {issue}") + if unique_paper_count < cfg.ready_unique_papers_threshold: + evidence_gaps.append( + "bundle-level diversity is low: " + f"only {unique_paper_count}/{cfg.ready_unique_papers_threshold} unique papers across lanes" + ) + + required_ready = all( + lane_quality[lane]["quality_ok"] + for lane in cfg.required_lanes + ) + ready_for_hypothesis = ( + required_ready + and unique_paper_count >= cfg.ready_unique_papers_threshold + and weighted_completeness >= cfg.ready_completeness_threshold + ) + + suggested_topics: list[str] = [] + for lane in LANE_ORDER: + if lane_quality[lane]["quality_ok"]: + continue + lane_pack = lane_query_packs.get(lane) or LaneQueryPack() + for query in list(lane_pack.sparse_queries) + [lane_pack.dense_query]: + topic = _truncate_topic(query) + if topic and topic not in suggested_topics: + suggested_topics.append(topic) + break + if unique_paper_count < cfg.ready_unique_papers_threshold: + for lane in ("novelty", "contradiction", "execution"): + lane_pack = lane_query_packs.get(lane) or LaneQueryPack() + for query in [lane_pack.dense_query, *list(lane_pack.sparse_queries)]: + topic = _truncate_topic(query) + if topic and topic not in suggested_topics: + suggested_topics.append(topic) + if len(suggested_topics) >= 4: + break + if len(suggested_topics) >= 4: + break + + status = { + "seed_id": seed.seed_id, + "target_gap": seed.target_gap, + "mechanism": seed.mechanism, + "scenario": seed.scenario, + "lane_counts": lane_counts, + "lane_targets": dict(cfg.lane_targets), + "unique_paper_count": unique_paper_count, + "weighted_completeness": round(weighted_completeness, 3), + "ready_for_hypothesis": ready_for_hypothesis, + "blocking_lanes": blocking_lanes, + "evidence_gaps": evidence_gaps[:8], + "suggested_topics": suggested_topics[:4], + "lane_quality": lane_quality, + "lane_queries": { + lane: { + "dense_query": lane_query_packs[lane].dense_query, + "sparse_queries": list(lane_query_packs[lane].sparse_queries), + } + for lane in LANE_ORDER + }, + "lane_diagnostics": lane_diagnostics, + } + return status + + +async def build_evidence_bundle_with_diagnostics( + seed: Seed, + store: EmbeddingStore, + paper_docs: dict[str, str], + llm: ChatOpenAI | None = None, + cfg: RetrieverConfig = RetrieverConfig(), +) -> tuple[EvidenceBundle, dict[str, Any]]: + """Run all 4 lanes in parallel and assemble an EvidenceBundle plus diagnostics.""" + import asyncio + + lane_query_packs = await build_lane_query_packs(seed, llm) + results = await asyncio.gather( + *[ + retrieve_lane(seed, lane, lane_query_packs[lane], store, paper_docs, llm, cfg) + for lane in LANE_ORDER + ] + ) + + lane_items = {lane: items for lane, (items, _diag) in zip(LANE_ORDER, results)} + lane_diagnostics = {lane: diag for lane, (_items, diag) in zip(LANE_ORDER, results)} + + bundle = EvidenceBundle( + seed_id=seed.seed_id, + inspiration=lane_items["inspiration"], + novelty=lane_items["novelty"], + contradiction=lane_items["contradiction"], + execution=lane_items["execution"], + ) + status = build_evidence_status(seed, bundle, lane_query_packs, lane_diagnostics, cfg) + bundle.evidence_gaps = list(status["evidence_gaps"]) + bundle.completeness = float(status["weighted_completeness"]) + + diagnostics = { + "seed_id": seed.seed_id, + "lane_queries": status["lane_queries"], + "lane_diagnostics": lane_diagnostics, + "evidence_status": status, + } + return bundle, diagnostics + diff --git a/backend/src/modules/idea_gen/utils/evidence_utils.py b/backend/src/modules/idea_gen/utils/evidence_utils.py new file mode 100644 index 00000000..e610701b --- /dev/null +++ b/backend/src/modules/idea_gen/utils/evidence_utils.py @@ -0,0 +1,16 @@ +"""Evidence and hypothesis helper functions.""" + +from __future__ import annotations + +from typing import Any + + +def extract_allowed_paper_keys_from_bundle(bundle: Any) -> list[str]: + """Extract unique paper keys from an evidence bundle.""" + allowed_keys: list[str] = [] + for lane_name in ("inspiration", "novelty", "contradiction", "execution"): + for item in getattr(bundle, lane_name, []) or []: + paper_key = str(getattr(item, "paper_key", "") or "").strip() + if paper_key and paper_key not in allowed_keys: + allowed_keys.append(paper_key) + return allowed_keys diff --git a/backend/src/modules/idea_gen/utils/llm_concurrency_limiter.py b/backend/src/modules/idea_gen/utils/llm_concurrency_limiter.py new file mode 100644 index 00000000..f50948f3 --- /dev/null +++ b/backend/src/modules/idea_gen/utils/llm_concurrency_limiter.py @@ -0,0 +1,37 @@ +"""Global LLM concurrency limiter for idea_gen.""" + +from __future__ import annotations + +from contextlib import contextmanager +from threading import Lock, Semaphore +from typing import Iterator + +from src.modules.idea_gen.config import get_workflow_config + + +class LLMConcurrencyLimiter: + """Process-wide semaphore wrapper for explicit LLM concurrency control.""" + + _lock = Lock() + _semaphore: Semaphore | None = None + _limit: int | None = None + + @classmethod + def _get_semaphore(cls) -> Semaphore: + limit = max(1, int(get_workflow_config().llm_max_concurrency)) + with cls._lock: + if cls._semaphore is None or cls._limit != limit: + cls._semaphore = Semaphore(limit) + cls._limit = limit + return cls._semaphore + + @classmethod + @contextmanager + def acquire(cls) -> Iterator[None]: + semaphore = cls._get_semaphore() + semaphore.acquire() + try: + yield + finally: + semaphore.release() + diff --git a/backend/src/modules/idea_gen/utils/node_executor.py b/backend/src/modules/idea_gen/utils/node_executor.py index 624dd2ae..e0e551b2 100644 --- a/backend/src/modules/idea_gen/utils/node_executor.py +++ b/backend/src/modules/idea_gen/utils/node_executor.py @@ -8,6 +8,7 @@ from src.models.factory import create_chat_model from src.modules.idea_gen.config import create_node_model +from src.modules.idea_gen.utils.llm_concurrency_limiter import LLMConcurrencyLimiter @dataclass @@ -50,7 +51,8 @@ def _bind_tools(self, model: Any, tools: list[BaseTool] | None): def invoke_messages(self, messages: list[Any], tools: list[BaseTool] | None = None) -> AIMessage: model = self._bind_tools(self._create_model(), tools) - response = model.invoke(messages) + with LLMConcurrencyLimiter.acquire(): + response = model.invoke(messages) if isinstance(response, AIMessage): return response return AIMessage(content=getattr(response, "content", str(response))) diff --git a/backend/src/modules/idea_gen/utils/normalizer_utils.py b/backend/src/modules/idea_gen/utils/normalizer_utils.py new file mode 100644 index 00000000..d5ff9141 --- /dev/null +++ b/backend/src/modules/idea_gen/utils/normalizer_utils.py @@ -0,0 +1,100 @@ +"""Grouped normalization helpers for idea_gen workflow.""" + +from __future__ import annotations + +import re +from typing import Any +from urllib.parse import urlparse + +from src.modules.idea_gen.utils.paper_cache_store import normalize_url + + +def _normalize_query_text(query: Any) -> str: + """Normalize a generated query to a clean single-line string.""" + if not isinstance(query, str): + return "" + return re.sub(r"\s+", " ", query.replace("\n", " ")).strip() + + +def _normalize_query_key(query: str) -> str: + """Build a stable dedup key for queries.""" + return " ".join(re.findall(r"[a-z0-9]+", (query or "").lower())) + + +def _normalize_arxiv_identifier(value: str) -> str: + """Normalize arXiv IDs so they can be reused across pipeline stages.""" + normalized = str(value or "").strip() + if not normalized: + return "" + if normalized.startswith("http"): + normalized = normalized.rstrip("/").split("/")[-1] + if normalized.startswith("arXiv:"): + normalized = normalized.split("arXiv:", 1)[1] + if normalized.endswith(".pdf"): + normalized = normalized[:-4] + if "v" in normalized and normalized.rsplit("v", 1)[-1].isdigit(): + normalized = normalized.rsplit("v", 1)[0] + return normalized + + +def _normalize_openalex_identifier(value: str) -> str: + """Normalize OpenAlex work IDs.""" + normalized = str(value or "").strip() + if not normalized: + return "" + if "/" in normalized: + normalized = normalized.rstrip("/").split("/")[-1] + return normalized + + +def _normalize_direct_download_url(candidate_url: str) -> str: + """Normalize only URLs that are likely directly downloadable paper sources.""" + normalized_candidate = normalize_url(str(candidate_url or "").strip()) + if not normalized_candidate: + return "" + + parsed = urlparse(normalized_candidate) + scheme = (parsed.scheme or "").lower() + host = (parsed.netloc or "").lower() + path = parsed.path or "" + lower_path = path.lower() + lowered = normalized_candidate.lower() + + if scheme not in {"http", "https"}: + return "" + + if "arxiv.org" in host: + arxiv_id = _normalize_arxiv_identifier(normalized_candidate) + return f"https://arxiv.org/pdf/{arxiv_id}.pdf" if arxiv_id else "" + + if lower_path.endswith(".pdf"): + return normalized_candidate + + if "content.openalex.org" in host: + pdf_path = path if lower_path.endswith(".pdf") else f"{path.rstrip('/')}.pdf" + return parsed._replace(path=pdf_path).geturl() + + if "/pdf/" in lower_path or re.search(r"(^|[/?=&._-])pdf([/?=&._-]|$)", lowered): + return normalized_candidate + + return "" + + +def _normalize_relevance_score(score: Any) -> float: + """Normalize 0-10 or 0-1 scores into a 0-1 range for feedback heuristics.""" + if not isinstance(score, (int, float)): + return 0.0 + numeric = float(score) + if numeric <= 1.0: + return max(0.0, min(1.0, numeric)) + return max(0.0, min(1.0, numeric / 10.0)) + + +__all__ = [ + "_normalize_query_text", + "_normalize_query_key", + "_normalize_arxiv_identifier", + "_normalize_openalex_identifier", + "_normalize_direct_download_url", + "_normalize_relevance_score", +] diff --git a/backend/src/modules/idea_gen/utils/openalex_enricher.py b/backend/src/modules/idea_gen/utils/openalex_enricher.py index 220f7f6a..681b819d 100644 --- a/backend/src/modules/idea_gen/utils/openalex_enricher.py +++ b/backend/src/modules/idea_gen/utils/openalex_enricher.py @@ -9,29 +9,20 @@ import httpx - -_ARXIV_RE = re.compile(r"arxiv\.org/(?:abs|pdf)/([^/?#]+)", re.IGNORECASE) -_WHITESPACE_RE = re.compile(r"\s+") - - -def _normalize_text(value: str) -> str: - normalized = _WHITESPACE_RE.sub(" ", value or "").strip().lower() - return re.sub(r"[^\w\s]", "", normalized) - - -def _extract_arxiv_id(url: str) -> str: - match = _ARXIV_RE.search(url or "") - if not match: - return "" - identifier = match.group(1).strip() - if identifier.endswith(".pdf"): - identifier = identifier[:-4] - identifier = re.sub(r"v\d+$", "", identifier, flags=re.IGNORECASE) - return identifier +from src.modules.idea_gen.utils.paper_normalization import ( + extract_arxiv_id_from_url, + extract_author_names_lowercase, + extract_openalex_institutions, + extract_openalex_pdf_url, + extract_openalex_venue, + normalize_text, + parse_year, + reconstruct_abstract_from_inverted_index, +) def _extract_result_arxiv_id(result: dict[str, Any]) -> str: - arxiv_id = _extract_arxiv_id(result.get("url", "")) + arxiv_id = extract_arxiv_id_from_url(result.get("url", "")) if arxiv_id: return arxiv_id @@ -44,7 +35,7 @@ def _extract_result_arxiv_id(result: dict[str, Any]) -> str: for candidate in candidates: if not candidate: continue - normalized = _extract_arxiv_id(str(candidate)) or str(candidate).strip() + normalized = extract_arxiv_id_from_url(str(candidate)) or str(candidate).strip() normalized = re.sub(r"^arxiv:", "", normalized, flags=re.IGNORECASE) normalized = re.sub(r"v\d+$", "", normalized, flags=re.IGNORECASE) if normalized: @@ -52,105 +43,6 @@ def _extract_result_arxiv_id(result: dict[str, Any]) -> str: return "" -def _parse_year(value: Any) -> int | None: - if isinstance(value, int): - return value - if isinstance(value, str) and len(value) >= 4 and value[:4].isdigit(): - return int(value[:4]) - return None - - -def _extract_author_names(value: Any) -> list[str]: - if isinstance(value, str): - return [part.strip().lower() for part in value.split(",") if part.strip()] - if isinstance(value, list): - names: list[str] = [] - for item in value: - if isinstance(item, dict): - name = item.get("name") or item.get("display_name") or "" - if name: - names.append(str(name).strip().lower()) - elif item: - names.append(str(item).strip().lower()) - return names - return [] - - -def _reconstruct_abstract(inverted_index: Any) -> str: - if not isinstance(inverted_index, dict): - return "" - - tokens: list[tuple[int, str]] = [] - for token, positions in inverted_index.items(): - if not isinstance(token, str) or not isinstance(positions, list): - continue - for position in positions: - if isinstance(position, int): - tokens.append((position, token)) - - if not tokens: - return "" - - tokens.sort(key=lambda item: item[0]) - words = [token for _, token in tokens] - return " ".join(words) - - -def _extract_venue(work: dict[str, Any]) -> str: - primary_location = work.get("primary_location") or {} - source = primary_location.get("source") or {} - venue = source.get("display_name") or "" - if venue: - return venue - - for location in work.get("locations", []) or []: - if not isinstance(location, dict): - continue - source = location.get("source") or {} - venue = source.get("display_name") or "" - if venue: - return venue - return "" - - -def _extract_pdf_url(work: dict[str, Any]) -> str: - primary_location = work.get("primary_location") or {} - pdf_url = primary_location.get("pdf_url") or "" - if pdf_url: - return pdf_url - - best_oa_location = work.get("best_oa_location") or {} - pdf_url = best_oa_location.get("pdf_url") or "" - if pdf_url: - return pdf_url - - for location in work.get("locations", []) or []: - if not isinstance(location, dict): - continue - pdf_url = location.get("pdf_url") or "" - if pdf_url: - return pdf_url - return "" - - -def _extract_institutions(work: dict[str, Any]) -> list[str]: - institutions: list[str] = [] - seen: set[str] = set() - for authorship in work.get("authorships", []) or []: - if not isinstance(authorship, dict): - continue - for institution in authorship.get("institutions", []) or []: - if not isinstance(institution, dict): - continue - name = institution.get("display_name") or "" - normalized = name.strip() - if not normalized or normalized in seen: - continue - seen.add(normalized) - institutions.append(normalized) - return institutions - - def _extract_topics(work: dict[str, Any]) -> list[str]: topics: list[str] = [] for topic in work.get("topics", []) or []: @@ -176,7 +68,7 @@ def _candidate_has_matching_arxiv_id(work: dict[str, Any], arxiv_id: str) -> boo continue landing_page_url = location.get("landing_page_url") or "" pdf_url = location.get("pdf_url") or "" - if _extract_arxiv_id(landing_page_url) == arxiv_id or _extract_arxiv_id(pdf_url) == arxiv_id: + if extract_arxiv_id_from_url(landing_page_url) == arxiv_id or extract_arxiv_id_from_url(pdf_url) == arxiv_id: return True return False @@ -194,7 +86,7 @@ def _score_candidate( reasons: list[str] = [] work_title = (work.get("title") or work.get("display_name") or "").strip() - work_title_normalized = _normalize_text(work_title) + work_title_normalized = normalize_text(work_title, remove_punctuation=True, lowercase=True) title_similarity = SequenceMatcher(None, normalized_title, work_title_normalized).ratio() score += title_similarity * 6.0 if title_similarity >= 0.98: @@ -206,7 +98,7 @@ def _score_candidate( score += 4.0 reasons.append("arxiv_location") - work_year = _parse_year(work.get("publication_year") or work.get("publication_date")) + work_year = parse_year(work.get("publication_year") or work.get("publication_date")) if published_year and work_year: year_delta = abs(work_year - published_year) if year_delta == 0: @@ -219,7 +111,7 @@ def _score_candidate( score -= min(1.5, year_delta * 0.3) if author_names: - work_authors = _extract_author_names( + work_authors = extract_author_names_lowercase( [ (authorship.get("author") or {}).get("display_name") for authorship in work.get("authorships", []) or [] @@ -291,9 +183,9 @@ def enrich_result(self, result: dict[str, Any]) -> dict[str, Any]: if not arxiv_id: return enriched - published_year = _parse_year(result.get("published")) - author_names = _extract_author_names(result.get("authors")) - normalized_title = _normalize_text(title) + published_year = parse_year(result.get("published")) + author_names = extract_author_names_lowercase(result.get("authors")) + normalized_title = normalize_text(title, remove_punctuation=True, lowercase=True) params = { "search": title, @@ -333,12 +225,12 @@ def enrich_result(self, result: dict[str, Any]) -> dict[str, Any]: return enriched openalex_id = (best_work.get("id") or "").rstrip("/").split("/")[-1] - abstract_text = _reconstruct_abstract(best_work.get("abstract_inverted_index")) - venue = _extract_venue(best_work) + abstract_text = reconstruct_abstract_from_inverted_index(best_work.get("abstract_inverted_index")) + venue = extract_openalex_venue(best_work) topics = _extract_topics(best_work) primary_topic = ((best_work.get("primary_topic") or {}).get("display_name") or "") oa_url = ((best_work.get("open_access") or {}).get("oa_url") or "") - pdf_url = _extract_pdf_url(best_work) + pdf_url = extract_openalex_pdf_url(best_work) metadata_sources = list(dict.fromkeys([*(enriched.get("metadata_sources") or []), "openalex"])) identifiers = dict(enriched.get("identifiers") or {}) @@ -357,11 +249,11 @@ def enrich_result(self, result: dict[str, Any]) -> dict[str, Any]: "openalex_id": openalex_id, "doi": doi, "venue": venue, - "publication_year": _parse_year(best_work.get("publication_year")), + "publication_year": parse_year(best_work.get("publication_year")), "cited_by_count": best_work.get("cited_by_count"), "topics": topics, "primary_topic": primary_topic, - "institutions": _extract_institutions(best_work), + "institutions": extract_openalex_institutions(best_work), "oa_url": oa_url, "pdf_url": pdf_url, "openalex_abstract": abstract_text, diff --git a/backend/src/modules/idea_gen/utils/paper_cache_store.py b/backend/src/modules/idea_gen/utils/paper_cache_store.py index 05862093..7f36b416 100644 --- a/backend/src/modules/idea_gen/utils/paper_cache_store.py +++ b/backend/src/modules/idea_gen/utils/paper_cache_store.py @@ -77,6 +77,15 @@ def normalize_url(url: str) -> str: return urlunsplit(normalized_parts) +def _decode_metadata_json(metadata_json: str | None) -> dict | None: + if not metadata_json: + return None + try: + return json.loads(metadata_json) + except json.JSONDecodeError: + return None + + def _hash_text(value: str) -> str: return hashlib.sha256(value.encode("utf-8")).hexdigest() @@ -505,14 +514,102 @@ def update_compressed_content( def get_cached_metadata(self, paper_key: str, source_url: str = "") -> dict | None: cached = self.get_by_key_or_url(paper_key, source_url) - if cached is None or not cached.get("metadata_json"): + if cached is None: return None + return _decode_metadata_json(cached.get("metadata_json")) - try: - return json.loads(cached["metadata_json"]) - except json.JSONDecodeError: + def get_cached_metadata_batch(self, paper_keys: list[str]) -> dict[str, dict]: + """Batch retrieve cached metadata for multiple papers. + + Args: + paper_keys: List of paper keys to query + + Returns: + Dictionary mapping paper_key to metadata dict + """ + if not paper_keys: + return {} + + result = {} + with self._connect() as conn: + # SQLite has a limit on the number of variables (default 999) + # Split into chunks if needed + chunk_size = 500 + for i in range(0, len(paper_keys), chunk_size): + chunk = paper_keys[i:i + chunk_size] + placeholders = ','.join('?' * len(chunk)) + rows = conn.execute( + f""" + SELECT paper_key, metadata_json + FROM paper_cache + WHERE paper_key IN ({placeholders}) + AND metadata_json IS NOT NULL + """, + chunk, + ).fetchall() + + for row in rows: + metadata = _decode_metadata_json(row["metadata_json"]) + if metadata is not None: + result[row["paper_key"]] = metadata + + return result + + def get_cached_metadata_by_openalex_id(self, openalex_id: str) -> dict | None: + normalized = _normalize_openalex_id(openalex_id) + if not normalized: + return None + + cached = self.get_by_paper_key(f"openalex:{normalized}") + metadata = _decode_metadata_json(cached.get("metadata_json")) if cached else None + if metadata is not None: + return metadata + + with self._connect() as conn: + row = conn.execute( + """ + SELECT metadata_json + FROM paper_cache + WHERE metadata_json IS NOT NULL + AND ( + json_extract(metadata_json, '$.openalex_id') = ? + OR json_extract(metadata_json, '$.identifiers.openalex') = ? + ) + ORDER BY metadata_updated_at DESC, updated_at DESC + LIMIT 1 + """, + (normalized, normalized), + ).fetchone() + return _decode_metadata_json(row["metadata_json"]) if row else None + + def get_cached_metadata_by_semantic_scholar_id(self, paper_id: str) -> dict | None: + normalized = str(paper_id or "").strip() + if not normalized: return None + cached = self.get_by_paper_key(f"s2:{normalized}") + metadata = _decode_metadata_json(cached.get("metadata_json")) if cached else None + if metadata is not None: + return metadata + + with self._connect() as conn: + row = conn.execute( + """ + SELECT metadata_json + FROM paper_cache + WHERE metadata_json IS NOT NULL + AND ( + json_extract(metadata_json, '$.paper_id') = ? + OR json_extract(metadata_json, '$.identifiers.semantic_scholar') = ? + OR json_extract(metadata_json, '$.identifiers.s2') = ? + ) + ORDER BY metadata_updated_at DESC, updated_at DESC + LIMIT 1 + """, + (normalized, normalized, normalized), + ).fetchone() + return _decode_metadata_json(row["metadata_json"]) if row else None + def upsert_metadata( self, *, @@ -579,6 +676,7 @@ def build_metadata_payload(result: dict) -> dict: """Extract metadata fields for cache persistence.""" fields = [ "retrieval_source", "metadata_sources", "identifiers", "openalex_id", + "paper_id", "arxivId", "doi", "venue", "publication_year", "cited_by_count", "topics", "primary_topic", "institutions", "oa_url", "pdf_url", "openalex_abstract", "openalex_match_found", "openalex_match_score", @@ -596,6 +694,19 @@ def apply_metadata_payload(result: dict, payload: dict) -> dict: merged.update(payload or {}) return merged + @staticmethod + def extract_access_paths(payload: dict | None) -> dict[str, str]: + """Extract source-completion-relevant access paths from a metadata payload.""" + payload = payload or {} + extracted: dict[str, str] = {} + for key in ("openalex_id", "paper_id", "arxivId", "doi", "oa_url", "pdf_url", "source_url"): + value = str(payload.get(key) or "").strip() + if value: + extracted[key] = value + if extracted.get("doi"): + extracted["doi"] = normalize_doi(extracted["doi"]) + return extracted + @staticmethod def create_default() -> "PaperCacheStore | None": """Create default cache store instance.""" diff --git a/backend/src/modules/idea_gen/utils/paper_normalization.py b/backend/src/modules/idea_gen/utils/paper_normalization.py new file mode 100644 index 00000000..af9f0792 --- /dev/null +++ b/backend/src/modules/idea_gen/utils/paper_normalization.py @@ -0,0 +1,321 @@ +"""Paper metadata normalization utilities - single source of truth. + +This module consolidates all text, ID, and author normalization logic +to eliminate duplication across the idea_gen module. +""" +import re +from typing import Any + +# ============ Compiled Patterns ============ + +_WHITESPACE_RE = re.compile(r"\s+") +_ARXIV_RE = re.compile(r"arxiv\.org/(?:abs|pdf)/([^/?#]+)", re.IGNORECASE) + + +# ============ Text Normalization ============ + +def normalize_text(value: str, *, remove_punctuation: bool = False, lowercase: bool = False) -> str: + """Normalize whitespace in text. + + Args: + value: Input text + remove_punctuation: If True, remove all non-alphanumeric chars + lowercase: If True, convert to lowercase + + Returns: + Normalized text + """ + normalized = _WHITESPACE_RE.sub(" ", value or "").strip() + if lowercase: + normalized = normalized.lower() + if remove_punctuation: + normalized = re.sub(r"[^\w\s]", "", normalized) + return normalized + + +# ============ ArXiv ID Normalization ============ + +def extract_arxiv_id_from_url(url: str) -> str: + """Extract canonical arXiv ID from URL. + + Examples: + "https://arxiv.org/abs/2301.12345v2" -> "2301.12345" + "https://arxiv.org/pdf/2301.12345.pdf" -> "2301.12345" + + Args: + url: URL string potentially containing arXiv ID + + Returns: + Canonical arXiv ID without version suffix, or empty string + """ + match = _ARXIV_RE.search(url or "") + if not match: + return "" + identifier = match.group(1).strip() + if identifier.endswith(".pdf"): + identifier = identifier[:-4] + # Remove version suffix + identifier = re.sub(r"v\d+$", "", identifier, flags=re.IGNORECASE) + return identifier + + +def normalize_arxiv_id(arxiv_id: str) -> str: + """Normalize arXiv ID from various formats. + + Examples: + "arXiv:2301.12345v2" -> "2301.12345" + "https://arxiv.org/abs/2301.12345" -> "2301.12345" + "2301.12345.pdf" -> "2301.12345" + + Args: + arxiv_id: arXiv identifier in any common format + + Returns: + Canonical arXiv ID without version suffix + """ + value = str(arxiv_id or "").strip() + if not value: + return "" + + # Try URL extraction first + if "arxiv.org" in value.lower(): + return extract_arxiv_id_from_url(value) + + # Handle URL path format + if "/" in value: + value = value.rstrip("/").split("/")[-1] + + # Remove common prefixes + if value.lower().startswith("arxiv:"): + value = value.split(":", 1)[1] + + # Remove .pdf suffix + if value.endswith(".pdf"): + value = value[:-4] + + # Remove version suffix + if "v" in value and value.split("v")[-1].isdigit(): + value = value.rsplit("v", 1)[0] + + return value.strip() + + +# ============ Author Normalization ============ + +def normalize_authors(authors: Any, *, max_count: int | None = None, lowercase: bool = False) -> list[str]: + """Normalize author data from various formats. + + Handles: + - List of dicts: [{"name": "..."}, {"display_name": "..."}] + - List of strings: ["Author 1", "Author 2"] + - Comma-separated string: "Author 1, Author 2" + + Args: + authors: Author data in various formats + max_count: Maximum number of authors to return (None = all) + lowercase: If True, convert names to lowercase + + Returns: + List of normalized author names + """ + normalized: list[str] = [] + + if isinstance(authors, list): + for author in authors: + if isinstance(author, dict): + # Handle OpenAlex/S2 dict formats + name = ( + author.get("name") + or author.get("display_name") + or "" + ) + else: + name = str(author or "") + + name = normalize_text(name, lowercase=lowercase) + if name: + normalized.append(name) + + elif isinstance(authors, str): + # Handle comma-separated string + parts = [normalize_text(part, lowercase=lowercase) for part in authors.split(",")] + normalized = [part for part in parts if part] + + if max_count is not None: + normalized = normalized[:max_count] + + return normalized + + +def extract_author_names_lowercase(authors: Any) -> list[str]: + """Extract author names in lowercase format (legacy compatibility). + + This is a convenience wrapper for backward compatibility with + openalex_enricher's original _extract_author_names behavior. + + Args: + authors: Author data (string or list) + + Returns: + List of lowercase author names + """ + return normalize_authors(authors, lowercase=True) + + +# ============ OpenAlex Specific Extractors ============ + +def extract_openalex_pdf_url(work: dict[str, Any]) -> str: + """Extract PDF URL from OpenAlex work object. + + Priority: + 1. primary_location.pdf_url + 2. best_oa_location.pdf_url + 3. First location with pdf_url + + Args: + work: OpenAlex work dictionary + + Returns: + PDF URL string, or empty string if not found + """ + # Try primary location + primary_location = work.get("primary_location") or {} + pdf_url = primary_location.get("pdf_url") or "" + if pdf_url: + return pdf_url + + # Try best OA location + best_oa_location = work.get("best_oa_location") or {} + pdf_url = best_oa_location.get("pdf_url") or "" + if pdf_url: + return pdf_url + + # Try all locations + for location in work.get("locations", []) or []: + if not isinstance(location, dict): + continue + pdf_url = location.get("pdf_url") or "" + if pdf_url: + return pdf_url + + return "" + + +def extract_openalex_authors(work: dict[str, Any]) -> list[str]: + """Extract author names from OpenAlex work object. + + Args: + work: OpenAlex work dictionary + + Returns: + List of author display names + """ + authors: list[str] = [] + for authorship in work.get("authorships", []) or []: + if not isinstance(authorship, dict): + continue + author = authorship.get("author") or {} + name = author.get("display_name") or "" + if name: + authors.append(name) + return authors + + +def extract_openalex_venue(work: dict[str, Any]) -> str: + """Extract venue/journal name from OpenAlex work object. + + Args: + work: OpenAlex work dictionary + + Returns: + Venue display name, or empty string if not found + """ + # Try primary location + primary_location = work.get("primary_location") or {} + source = primary_location.get("source") or {} + venue = source.get("display_name") or "" + if venue: + return venue + + # Try all locations + for location in work.get("locations", []) or []: + if not isinstance(location, dict): + continue + source = location.get("source") or {} + venue = source.get("display_name") or "" + if venue: + return venue + + return "" + + +def extract_openalex_institutions(work: dict[str, Any]) -> list[str]: + """Extract institution names from OpenAlex work object. + + Args: + work: OpenAlex work dictionary + + Returns: + List of unique institution display names + """ + institutions: list[str] = [] + seen: set[str] = set() + for authorship in work.get("authorships", []) or []: + if not isinstance(authorship, dict): + continue + for institution in authorship.get("institutions", []) or []: + if not isinstance(institution, dict): + continue + name = institution.get("display_name") or "" + normalized = name.strip() + if not normalized or normalized in seen: + continue + seen.add(normalized) + institutions.append(normalized) + return institutions + + +def reconstruct_abstract_from_inverted_index(inverted_index: Any) -> str: + """Reconstruct abstract text from OpenAlex inverted index format. + + Args: + inverted_index: OpenAlex inverted index dict {word: [positions]} + + Returns: + Reconstructed abstract text + """ + if not isinstance(inverted_index, dict): + return "" + + tokens: list[tuple[int, str]] = [] + for token, positions in inverted_index.items(): + if not isinstance(token, str) or not isinstance(positions, list): + continue + for position in positions: + if isinstance(position, int): + tokens.append((position, token)) + + if not tokens: + return "" + + tokens.sort(key=lambda item: item[0]) + words = [token for _, token in tokens] + return " ".join(words) + + +# ============ Year Parsing ============ + +def parse_year(value: Any) -> int | None: + """Parse publication year from various formats. + + Args: + value: Year as int, string, or date string + + Returns: + Year as integer, or None if unparseable + """ + if isinstance(value, int): + return value + if isinstance(value, str) and len(value) >= 4 and value[:4].isdigit(): + return int(value[:4]) + return None diff --git a/backend/src/modules/idea_gen/utils/paper_pipeline.py b/backend/src/modules/idea_gen/utils/paper_pipeline.py index 675065a8..f3d4cbe7 100644 --- a/backend/src/modules/idea_gen/utils/paper_pipeline.py +++ b/backend/src/modules/idea_gen/utils/paper_pipeline.py @@ -1,60 +1,155 @@ """Paper processing pipeline orchestrator.""" +import logging +from concurrent.futures import as_completed + from src.modules.idea_gen.schemas import PaperCandidate, PaperEnrichmentResult from src.modules.idea_gen.config import get_paper_processing_config from src.modules.idea_gen.utils.paper_scorer import PaperScorer from src.modules.idea_gen.utils.pdf_processor import PDFProcessor from src.modules.idea_gen.utils.paper_summarizer import PaperSummarizer from src.modules.idea_gen.utils.paper_tagger import PaperTagger +from src.modules.idea_gen.utils.paper_cache_store import PaperCacheStore +from src.modules.idea_gen.utils.thread_pool_manager import ThreadPoolManager + +logger = logging.getLogger(__name__) class PaperPipeline: - """Orchestrate paper enrichment stages.""" + """Orchestrate paper enrichment stages with parallel processing.""" - def __init__(self): + def __init__(self, cache_store: PaperCacheStore | None = None): + """Initialize pipeline with optional cache store. + + Args: + cache_store: Optional PaperCacheStore instance for PDF caching + """ self.config = get_paper_processing_config() + self.cache_store = cache_store or PaperCacheStore.create_default() self.scorer = PaperScorer() if self.config.enable_scoring else None - self.pdf_processor = PDFProcessor() if self.config.enable_pdf_parsing else None + self.pdf_processor = PDFProcessor(cache_store=self.cache_store) if self.config.enable_pdf_parsing else None self.summarizer = PaperSummarizer() if self.config.enable_summarization else None self.tagger = PaperTagger() if self.config.enable_tagging else None def process_candidates(self, candidates: list[PaperCandidate]) -> list[PaperEnrichmentResult]: - """Process paper candidates through enrichment pipeline.""" + """Process paper candidates through enrichment pipeline with parallel execution. + + Parallelizes: + - LLM scoring (if enabled) + - PDF download and parsing + + Args: + candidates: List of paper candidates to process + + Returns: + List of enriched paper results + """ if not candidates: return [] - results = [] + # Step 1: Parallel scoring + scored_candidates = self._parallel_score(candidates) + + # Step 2: Sort and select top candidates + scored_candidates.sort(key=lambda x: x[1].score if x[1] else 0, reverse=True) + top_candidates = scored_candidates[:self.config.top_k_papers] + + # Step 3: Parallel PDF processing + results = self._parallel_pdf_process(top_candidates) + + return results + + def _parallel_score(self, candidates: list[PaperCandidate]) -> list[tuple[PaperCandidate, any]]: + """Score candidates in parallel using global thread pool. + + Args: + candidates: List of candidates to score + + Returns: + List of (candidate, score) tuples + """ + if not self.scorer: + return [(c, None) for c in candidates] + scored_candidates = [] + pool = ThreadPoolManager.get_compute_pool() + + future_to_candidate = { + pool.submit(self._score_single, candidate): candidate + for candidate in candidates + } - for candidate in candidates: + for future in as_completed(future_to_candidate): + candidate = future_to_candidate[future] try: - if self.scorer: - score = self.scorer.score_paper(candidate) - scored_candidates.append((candidate, score)) - else: - scored_candidates.append((candidate, None)) - except Exception: + score = future.result() + scored_candidates.append((candidate, score)) + except Exception as e: + logger.error(f"[SCORE ERROR] {candidate.title[:50]}: {e}") scored_candidates.append((candidate, None)) - scored_candidates.sort(key=lambda x: x[1].score if x[1] else 0, reverse=True) - top_candidates = scored_candidates[:self.config.top_k_papers] + return scored_candidates - for candidate, score in top_candidates: - result = PaperEnrichmentResult(candidate=candidate, score=score) + def _score_single(self, candidate: PaperCandidate): + """Score a single candidate (thread-safe wrapper).""" + return self.scorer.score_paper(candidate) - if score and score.score >= self.config.score_threshold: - try: - if self.pdf_processor and candidate.source == "arxiv": - pdf_text = self.pdf_processor.download_and_parse(candidate.external_id) - result.pdf_path = f"papers/{candidate.external_id}.pdf" + def _parallel_pdf_process(self, top_candidates: list[tuple[PaperCandidate, any]]) -> list[PaperEnrichmentResult]: + """Process PDFs in parallel using global thread pool. - if self.summarizer and pdf_text: - result.summary = self.summarizer.summarize_paper(pdf_text) + Args: + top_candidates: List of (candidate, score) tuples to process - if self.tagger and result.summary: - result.tags = self.tagger.generate_tags(result.summary) - except Exception: - pass + Returns: + List of enriched results + """ + if not self.pdf_processor: + # No PDF processing - return results directly + return [ + PaperEnrichmentResult(candidate=candidate, score=score) + for candidate, score in top_candidates + ] - results.append(result) + results = [] + pool = ThreadPoolManager.get_pdf_parse_pool() + + future_to_data = { + pool.submit(self._process_single_pdf, candidate, score): (candidate, score) + for candidate, score in top_candidates + } + + for future in as_completed(future_to_data): + candidate, score = future_to_data[future] + try: + result = future.result() + results.append(result) + except Exception as e: + logger.error(f"[PDF PROCESS ERROR] {candidate.title[:50]}: {e}") + # Fallback: return result without PDF + results.append(PaperEnrichmentResult(candidate=candidate, score=score)) return results + + def _process_single_pdf(self, candidate: PaperCandidate, score) -> PaperEnrichmentResult: + """Process a single PDF (thread-safe wrapper). + + Args: + candidate: Paper candidate + score: Scoring result + + Returns: + Enriched result + """ + result = PaperEnrichmentResult(candidate=candidate, score=score) + + if score and score.score >= self.config.score_threshold: + if self.pdf_processor and candidate.source == "arxiv": + pdf_text = self.pdf_processor.download_and_parse(candidate.external_id) + result.pdf_path = f"papers/{candidate.external_id}.pdf" + + if self.summarizer and pdf_text: + result.summary = self.summarizer.summarize_paper(pdf_text) + + if self.tagger and result.summary: + result.tags = self.tagger.generate_tags(result.summary) + + return result diff --git a/backend/src/modules/idea_gen/utils/paper_relevance_scorer.py b/backend/src/modules/idea_gen/utils/paper_relevance_scorer.py index 1c684989..b0609f28 100644 --- a/backend/src/modules/idea_gen/utils/paper_relevance_scorer.py +++ b/backend/src/modules/idea_gen/utils/paper_relevance_scorer.py @@ -1,7 +1,12 @@ """Paper relevance scoring utility.""" +import logging +from concurrent.futures import as_completed from typing import Any from src.utils.structured_output import parse_structured_output +from src.modules.idea_gen.utils.thread_pool_manager import ThreadPoolManager + +logger = logging.getLogger(__name__) class PaperRelevanceScorer: @@ -22,18 +27,64 @@ def __init__(self, executor, threshold: float = 6.0): self.threshold = threshold def score_papers(self, papers: list[dict[str, Any]], user_query: str, research_brief: str = "") -> list[dict[str, Any]]: - """Score and filter papers by relevance.""" - scored = [] - for paper in papers: - score = self._score_single(paper, user_query, research_brief) - paper["relevance_score"] = score - if score >= self.threshold: - scored.append(paper) + """Score and filter papers by relevance with parallel execution. + + Args: + papers: List of paper dictionaries to score + user_query: User's research query + research_brief: Optional research brief for context + + Returns: + Filtered and sorted list of papers with relevance scores + """ + scored = self._parallel_score(papers, user_query, research_brief) scored.sort(key=lambda p: p.get("relevance_score", 0.0), reverse=True) return scored + def _parallel_score(self, papers: list[dict[str, Any]], user_query: str, research_brief: str) -> list[dict[str, Any]]: + """Score papers in parallel using global thread pool. + + Args: + papers: Papers to score + user_query: User's research query + research_brief: Research brief for context + + Returns: + List of papers with scores (only those above threshold) + """ + scored = [] + pool = ThreadPoolManager.get_compute_pool() + + future_to_paper = { + pool.submit(self._score_single, paper, user_query, research_brief): paper + for paper in papers + } + + for future in as_completed(future_to_paper): + paper = future_to_paper[future] + try: + score = future.result() + paper["relevance_score"] = score + if score >= self.threshold: + scored.append(paper) + except Exception as e: + title = str(paper.get("title", ""))[:50] + logger.error(f"[SCORE ERROR] {title}: {e}") + paper["relevance_score"] = 0.0 + + return scored + def _score_single(self, paper: dict[str, Any], user_query: str, research_brief: str = "") -> float: - """Score a single paper.""" + """Score a single paper. + + Args: + paper: Paper dictionary + user_query: User's research query + research_brief: Research brief for context + + Returns: + Relevance score (0.0-10.0) + """ title = str(paper.get("title", "") or "") abstract = self._extract_abstract(paper) try: @@ -46,7 +97,7 @@ def _score_single(self, paper: dict[str, Any], user_query: str, research_brief: paper["relevance_score_model"] = details.get("model_score") return score except Exception as e: - print(f"[SCORE ERROR] {title[:50]}: {e}") + logger.error(f"[SCORE ERROR] {title[:50]}: {e}") paper["relevance_evaluation"] = { "final_score": 0.0, "error": str(e), diff --git a/backend/src/modules/idea_gen/utils/paper_reranker.py b/backend/src/modules/idea_gen/utils/paper_reranker.py index 36f7a0b1..c6a1e4b1 100644 --- a/backend/src/modules/idea_gen/utils/paper_reranker.py +++ b/backend/src/modules/idea_gen/utils/paper_reranker.py @@ -10,6 +10,7 @@ from langchain_openai import OpenAIEmbeddings from src.modules.idea_gen.config import PaperProcessingConfig, get_paper_processing_config +from src.modules.idea_gen.utils.paper_normalization import normalize_text class EmbeddingsLike(Protocol): @@ -28,10 +29,6 @@ class PaperRerankOutput: summary: dict[str, Any] -def _normalize_text(value: str) -> str: - return re.sub(r"\s+", " ", (value or "").strip()) - - def _tokenize(value: str) -> set[str]: stopwords = { "the", "and", "for", "with", "that", "this", "from", "into", "using", "use", @@ -140,9 +137,9 @@ def _should_use_openalex_abstract(self, result: dict[str, Any]) -> bool: return False def _build_paper_text(self, result: dict[str, Any]) -> tuple[str, str]: - title = _normalize_text(result.get("title", "")) - snippet = _normalize_text(result.get("snippet") or result.get("content") or "") - openalex_abstract = _normalize_text(result.get("openalex_abstract", "")) + title = normalize_text(result.get("title", "")) + snippet = normalize_text(result.get("snippet") or result.get("content") or "") + openalex_abstract = normalize_text(result.get("openalex_abstract", "")) abstract_parts: list[str] = [] source = "snippet" @@ -279,7 +276,7 @@ def rerank( summary={"status": "disabled", "selected_count": len(selected)}, ) - queries = [query for query in paper_queries if _normalize_text(query)] + queries = [query for query in paper_queries if normalize_text(query)] query_count = len(queries) prepared_results: list[dict[str, Any]] = [] @@ -289,14 +286,14 @@ def rerank( paper_text, paper_text_source = self._build_paper_text(prepared) prepared["paper_text"] = paper_text prepared["paper_text_source"] = paper_text_source - prepared["abstract_or_snippet"] = _normalize_text(prepared.get("snippet") or prepared.get("content") or "") + prepared["abstract_or_snippet"] = normalize_text(prepared.get("snippet") or prepared.get("content") or "") prepared_results.append(prepared) - texts.append(paper_text or _normalize_text(prepared.get("title", ""))) + texts.append(paper_text or normalize_text(prepared.get("title", ""))) query_texts = [ - _normalize_text(research_brief), - _normalize_text(original_target), - *[_normalize_text(query) for query in queries], + normalize_text(research_brief), + normalize_text(original_target), + *[normalize_text(query) for query in queries], ] try: embeddings = self._get_embeddings() diff --git a/backend/src/modules/idea_gen/utils/paper_utils.py b/backend/src/modules/idea_gen/utils/paper_utils.py new file mode 100644 index 00000000..3de279df --- /dev/null +++ b/backend/src/modules/idea_gen/utils/paper_utils.py @@ -0,0 +1,498 @@ +"""Grouped paper/source helpers for idea_gen workflow.""" + +from __future__ import annotations + +from typing import Any +from urllib.parse import urlparse + +from src.modules.idea_gen.utils.cache_utils import _create_paper_cache_store +from src.modules.idea_gen.utils.normalizer_utils import ( + _normalize_arxiv_identifier, + _normalize_direct_download_url, + _normalize_openalex_identifier, +) +from src.modules.idea_gen.utils.paper_cache_store import PaperCacheStore, normalize_doi + + +def _extract_domain(url: str) -> str: + """Extract a normalized domain from a result URL.""" + try: + netloc = urlparse(url or "").netloc.lower() + except Exception: + return "" + return netloc[4:] if netloc.startswith("www.") else netloc + + +def _build_arxiv_abs_url(arxiv_id: str) -> str: + normalized = _normalize_arxiv_identifier(arxiv_id) + return f"https://arxiv.org/abs/{normalized}" if normalized else "" + + +def _build_arxiv_pdf_url(arxiv_id: str) -> str: + normalized = _normalize_arxiv_identifier(arxiv_id) + return f"https://arxiv.org/pdf/{normalized}.pdf" if normalized else "" + + +def _build_openalex_work_url(openalex_id: str) -> str: + normalized = _normalize_openalex_identifier(openalex_id) + return f"https://openalex.org/{normalized}" if normalized else "" + + +def _build_semantic_scholar_url(paper_id: str) -> str: + normalized = str(paper_id or "").strip() + return f"https://www.semanticscholar.org/paper/{normalized}" if normalized else "" + + +def _build_doi_url(doi: str) -> str: + normalized = normalize_doi(doi) + return f"https://doi.org/{normalized}" if normalized else "" + + +def _resolve_public_paper_url(paper: dict[str, Any]) -> str: + """Resolve the best public-facing URL for a paper.""" + existing = str(paper.get("source_url") or paper.get("url") or "").strip() + if existing: + return existing + + identifiers = paper.get("identifiers") or {} + arxiv_id = _normalize_arxiv_identifier(paper.get("arxivId") or identifiers.get("arxiv", "")) + if arxiv_id: + return _build_arxiv_abs_url(arxiv_id) + + openalex_id = _normalize_openalex_identifier( + paper.get("openalex_id") or identifiers.get("openalex", "") or paper.get("paper_id", "") + ) + if openalex_id.startswith("W"): + return _build_openalex_work_url(openalex_id) + + semantic_paper_id = str(paper.get("paper_id") or "").strip() + if paper.get("source") == "semantic_scholar_citation" and semantic_paper_id: + return _build_semantic_scholar_url(semantic_paper_id) + + oa_url = str(paper.get("oa_url") or "").strip() + if oa_url: + return oa_url + + pdf_url = str(paper.get("pdf_url") or "").strip() + if pdf_url: + return pdf_url + + return "" + + +def _resolve_download_source_url(paper: dict[str, Any]) -> str: + """Resolve the source that should be used for downloading/parsing a paper.""" + explicit_source = _normalize_direct_download_url(str(paper.get("download_source_url") or "").strip()) + if explicit_source: + return explicit_source + + pdf_url = _normalize_direct_download_url(str(paper.get("pdf_url") or "").strip()) + if pdf_url: + return pdf_url + + identifiers = paper.get("identifiers") or {} + arxiv_id = _normalize_arxiv_identifier(paper.get("arxivId") or identifiers.get("arxiv", "")) + if arxiv_id: + return _build_arxiv_pdf_url(arxiv_id) + + oa_url = _normalize_direct_download_url(str(paper.get("oa_url") or "").strip()) + if oa_url: + return oa_url + + public_url = _normalize_direct_download_url(str(paper.get("url") or paper.get("source_url") or "").strip()) + if public_url: + return public_url + + return "" + + +def _lookup_openalex_access_paths( + openalex_id: str, + *, + cache_store: PaperCacheStore | None = None, +) -> dict[str, str]: + """Resolve public and downloadable URLs from OpenAlex metadata.""" + normalized = _normalize_openalex_identifier(openalex_id) + if not normalized.startswith("W"): + return {} + + cache_store = cache_store or _create_paper_cache_store() + if cache_store: + cached_payload = cache_store.get_cached_metadata_by_openalex_id(normalized) + cached_paths = PaperCacheStore.extract_access_paths(cached_payload) + if cached_paths.get("oa_url") or cached_paths.get("pdf_url") or cached_paths.get("source_url"): + cached_paths.setdefault("openalex_id", normalized) + return cached_paths + + try: + import httpx + + response = httpx.get(f"https://api.openalex.org/works/{normalized}", timeout=20.0) + response.raise_for_status() + payload = response.json() + except Exception: + return {} + + open_access = payload.get("open_access") or {} + primary_location = payload.get("primary_location") or {} + ids = payload.get("ids") or {} + + doi = str(payload.get("doi") or ids.get("doi") or "").strip() + doi_url = _build_doi_url(doi) + oa_url = str(open_access.get("oa_url") or "").strip() + pdf_url = _normalize_direct_download_url(str(primary_location.get("pdf_url") or "").strip()) + if not pdf_url: + pdf_url = _normalize_direct_download_url(oa_url) + public_url = str(payload.get("id") or ids.get("openalex") or "").strip() or _build_openalex_work_url(normalized) + if public_url and not public_url.startswith("http"): + public_url = _build_openalex_work_url(public_url) + + resolved = { + "openalex_id": normalized, + "doi": normalize_doi(doi), + "doi_url": doi_url, + "oa_url": oa_url, + "pdf_url": pdf_url, + "source_url": public_url, + } + if cache_store: + existing_payload = cache_store.get_cached_metadata_by_openalex_id(normalized) or {} + cache_store.upsert_metadata( + paper_key=f"openalex:{normalized}", + title=normalized, + source_url=resolved.get("source_url", ""), + metadata={**existing_payload, **resolved}, + ) + return resolved + + +def _lookup_semantic_scholar_access_paths( + paper_id: str, + *, + cache_store: PaperCacheStore | None = None, +) -> dict[str, str]: + """Resolve public and downloadable URLs from Semantic Scholar metadata.""" + normalized = str(paper_id or "").strip() + if not normalized: + return {} + + cache_store = cache_store or _create_paper_cache_store() + if cache_store: + cached_payload = cache_store.get_cached_metadata_by_semantic_scholar_id(normalized) + cached_paths = PaperCacheStore.extract_access_paths(cached_payload) + if cached_paths.get("arxivId") or cached_paths.get("doi") or cached_paths.get("pdf_url") or cached_paths.get("source_url"): + cached_paths.setdefault("paper_id", normalized) + if cached_paths.get("doi"): + cached_paths["doi_url"] = _build_doi_url(cached_paths["doi"]) + return cached_paths + + try: + import httpx + + response = httpx.get( + f"https://api.semanticscholar.org/graph/v1/paper/{normalized}", + params={"fields": "openAccessPdf,url,externalIds"}, + timeout=20.0, + ) + response.raise_for_status() + payload = response.json() + except Exception: + payload = {} + + external_ids = payload.get("externalIds") or {} + arxiv_id = _normalize_arxiv_identifier(external_ids.get("ArXiv", "")) + doi = normalize_doi(str(external_ids.get("DOI") or "")) + pdf_url = _normalize_direct_download_url(str((payload.get("openAccessPdf") or {}).get("url") or "").strip()) + if not pdf_url and arxiv_id: + pdf_url = _build_arxiv_pdf_url(arxiv_id) + + public_url = str(payload.get("url") or "").strip() or _build_semantic_scholar_url(normalized) + resolved = { + "paper_id": normalized, + "arxivId": arxiv_id, + "doi": doi, + "doi_url": _build_doi_url(doi), + "pdf_url": pdf_url, + "source_url": public_url, + } + if cache_store: + existing_payload = cache_store.get_cached_metadata_by_semantic_scholar_id(normalized) or {} + metadata = { + **existing_payload, + **resolved, + } + identifiers = dict(metadata.get("identifiers") or {}) + identifiers["semantic_scholar"] = normalized + if arxiv_id: + identifiers["arxiv"] = arxiv_id + if doi: + identifiers["doi"] = doi + metadata["identifiers"] = identifiers + cache_store.upsert_metadata( + paper_key=f"s2:{normalized}", + title=normalized, + source_url=resolved.get("source_url", ""), + metadata=metadata, + ) + return resolved + + +def _paper_ranking_sort_key(paper: dict[str, Any]) -> tuple[float, float, int, str]: + """Stable deterministic ranking key for paper-pool ordering.""" + relevance_score = float(paper.get("relevance_score", 0.0) or 0.0) + rerank_score = float(paper.get("rerank_score") or paper.get("final_score") or 0.0) + cited_by_count = int(paper.get("cited_by_count") or paper.get("citationCount") or 0) + title = str(paper.get("title", "") or "").strip().lower() + return (-relevance_score, -rerank_score, -cited_by_count, title) + + +def _complete_final_paper_sources(paper: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any]]: + """Resolve and persist source metadata on a final selected paper record.""" + completed = dict(paper) + debug = { + "paper_key": completed.get("paper_key", ""), + "title": completed.get("title", ""), + "attempts": [], + "status": "pending", + "selected_strategy": "", + "download_source_url": "", + "source_url": "", + "failure_reason": "", + } + + def _record_attempt(strategy: str, status: str, value: str = "", reason: str = "") -> None: + debug["attempts"].append( + { + "strategy": strategy, + "status": status, + "value": value, + "reason": reason, + } + ) + + source_url = str(completed.get("source_url") or _resolve_public_paper_url(completed) or "").strip() + if source_url: + completed["source_url"] = source_url + completed["url"] = str(completed.get("url") or source_url).strip() + + download_source_url = "" + selected_strategy = "" + + explicit_download_raw = str(completed.get("download_source_url") or "").strip() + explicit_download = _normalize_direct_download_url(explicit_download_raw) + if explicit_download: + download_source_url = explicit_download + selected_strategy = "explicit_download_source_url" + _record_attempt(selected_strategy, "success", download_source_url) + elif explicit_download_raw: + _record_attempt( + "explicit_download_source_url", + "invalid", + explicit_download_raw, + reason="not_direct_downloadable", + ) + else: + _record_attempt("explicit_download_source_url", "missing") + + if not download_source_url: + pdf_url_raw = str(completed.get("pdf_url") or "").strip() + pdf_url = _normalize_direct_download_url(pdf_url_raw) + if pdf_url: + download_source_url = pdf_url + selected_strategy = "explicit_pdf_url" + _record_attempt(selected_strategy, "success", pdf_url) + elif pdf_url_raw: + _record_attempt("explicit_pdf_url", "invalid", pdf_url_raw, reason="not_direct_downloadable") + else: + _record_attempt("explicit_pdf_url", "missing") + + if not download_source_url: + identifiers = completed.get("identifiers") or {} + arxiv_id = _normalize_arxiv_identifier(completed.get("arxivId") or identifiers.get("arxiv", "")) + if arxiv_id: + download_source_url = _build_arxiv_pdf_url(arxiv_id) + completed["arxivId"] = arxiv_id + selected_strategy = "arxiv_pdf" + _record_attempt(selected_strategy, "success", download_source_url) + else: + _record_attempt("arxiv_pdf", "missing") + + if not download_source_url: + identifiers = completed.get("identifiers") or {} + openalex_id = _normalize_openalex_identifier( + completed.get("openalex_id") or identifiers.get("openalex", "") or completed.get("paper_id", "") + ) + if openalex_id.startswith("W"): + openalex_paths = _lookup_openalex_access_paths(openalex_id) + if openalex_paths.get("source_url"): + if not completed.get("source_url"): + completed["source_url"] = openalex_paths["source_url"] + if not completed.get("url"): + completed["url"] = openalex_paths["source_url"] + if openalex_paths.get("oa_url"): + completed["oa_url"] = openalex_paths["oa_url"] + if openalex_paths.get("pdf_url"): + completed["pdf_url"] = openalex_paths["pdf_url"] + download_source_url = openalex_paths["pdf_url"] + selected_strategy = "openalex_lookup_pdf" + _record_attempt(selected_strategy, "success", download_source_url) + else: + openalex_oa_download = _normalize_direct_download_url(openalex_paths.get("oa_url", "")) + if openalex_oa_download: + download_source_url = openalex_oa_download + completed["pdf_url"] = str(completed.get("pdf_url") or openalex_oa_download) + selected_strategy = "openalex_lookup_oa" + _record_attempt(selected_strategy, "success", download_source_url) + elif openalex_paths.get("oa_url"): + _record_attempt( + "openalex_lookup", + "empty", + openalex_paths["oa_url"], + reason="oa_url_not_direct_downloadable", + ) + else: + _record_attempt("openalex_lookup", "empty", reason="no_oa_or_pdf_url") + if openalex_paths.get("doi") and not completed.get("doi"): + completed["doi"] = openalex_paths["doi"] + identifiers["doi"] = openalex_paths["doi"] + completed["openalex_id"] = openalex_id + identifiers["openalex"] = openalex_id + completed["identifiers"] = identifiers + else: + _record_attempt("openalex_lookup", "missing") + + if not download_source_url: + doi = normalize_doi(str(completed.get("doi") or (completed.get("identifiers") or {}).get("doi") or "")) + if doi: + doi_url = _build_doi_url(doi) + completed["doi"] = doi + identifiers = dict(completed.get("identifiers") or {}) + identifiers["doi"] = doi + completed["identifiers"] = identifiers + if not completed.get("source_url"): + completed["source_url"] = doi_url + if not completed.get("url"): + completed["url"] = doi_url + doi_download_url = _normalize_direct_download_url(doi_url) + if doi_download_url: + download_source_url = doi_download_url + selected_strategy = "doi_resolution" + _record_attempt(selected_strategy, "success", doi_download_url) + else: + _record_attempt("doi_resolution", "empty", doi_url, reason="landing_page_only") + else: + _record_attempt("doi_resolution", "missing") + + if not download_source_url: + semantic_paper_id = str(completed.get("paper_id") or "").strip() + source_name = " ".join( + str(completed.get(key) or "") + for key in ("source", "source_provider", "retrieval_source") + ).lower() + if semantic_paper_id or "semantic" in source_name: + semantic_paths = _lookup_semantic_scholar_access_paths(semantic_paper_id) + if semantic_paths.get("source_url"): + if not completed.get("source_url"): + completed["source_url"] = semantic_paths["source_url"] + if not completed.get("url"): + completed["url"] = semantic_paths["source_url"] + if semantic_paths.get("pdf_url"): + completed["pdf_url"] = semantic_paths["pdf_url"] + download_source_url = semantic_paths["pdf_url"] + selected_strategy = "semantic_scholar_lookup_pdf" + _record_attempt(selected_strategy, "success", download_source_url) + if semantic_paths.get("arxivId") and not completed.get("arxivId"): + completed["arxivId"] = semantic_paths["arxivId"] + identifiers = dict(completed.get("identifiers") or {}) + identifiers["arxiv"] = semantic_paths["arxivId"] + completed["identifiers"] = identifiers + if semantic_paths.get("doi") and not completed.get("doi"): + completed["doi"] = semantic_paths["doi"] + identifiers = dict(completed.get("identifiers") or {}) + identifiers["doi"] = semantic_paths["doi"] + completed["identifiers"] = identifiers + if not download_source_url and semantic_paths.get("arxivId"): + download_source_url = _build_arxiv_pdf_url(semantic_paths["arxivId"]) + completed["pdf_url"] = str(completed.get("pdf_url") or download_source_url) + selected_strategy = "semantic_scholar_lookup_arxiv_pdf" + _record_attempt(selected_strategy, "success", download_source_url) + elif semantic_paths.get("source_url"): + _record_attempt( + "semantic_scholar_lookup", + "empty", + semantic_paths["source_url"], + reason="no_open_access_pdf", + ) + else: + _record_attempt("semantic_scholar_lookup", "empty", reason="no_metadata") + else: + _record_attempt("semantic_scholar_lookup", "missing") + + if not download_source_url: + public_url = str(completed.get("source_url") or completed.get("url") or "").strip() + public_download_url = _normalize_direct_download_url(public_url) + if public_download_url: + download_source_url = public_download_url + selected_strategy = "public_source_fallback" + _record_attempt(selected_strategy, "success", public_download_url) + elif public_url: + _record_attempt("public_source_fallback", "empty", public_url, reason="landing_page_only") + else: + _record_attempt("public_source_fallback", "missing") + + completed["download_source_url"] = download_source_url + completed["source_url"] = str(completed.get("source_url") or completed.get("url") or "").strip() + completed["url"] = str(completed.get("url") or completed.get("source_url") or "").strip() + completed["source_completion_strategy"] = selected_strategy + if download_source_url: + completed["source_completion_status"] = "resolved" + completed["source_completion_failure_reason"] = "" + debug["status"] = "resolved" + debug["selected_strategy"] = selected_strategy + debug["download_source_url"] = download_source_url + debug["source_url"] = completed.get("source_url", "") + else: + failure_reason = "no_downloadable_source_after_completion" + completed["source_completion_status"] = "failed" + completed["source_completion_failure_reason"] = failure_reason + debug["status"] = "failed" + debug["failure_reason"] = failure_reason + debug["source_url"] = completed.get("source_url", "") + + return completed, debug + + +def _is_selected_for_pdf(result: dict[str, Any]) -> bool: + """Return whether a result ultimately contributed to PDF parsing.""" + return bool( + result.get("final_selected_for_pdf") + or result.get("global_selected_for_pdf") + or result.get("rerank_selected_for_pdf") + ) + + +def _label_relevance(score: float) -> str: + """Bucket a numeric relevance score into a coarse label.""" + if score >= 0.6: + return "high" + if score >= 0.42: + return "medium" + return "low" + + +__all__ = [ + "_extract_domain", + "_build_arxiv_abs_url", + "_build_arxiv_pdf_url", + "_build_openalex_work_url", + "_build_semantic_scholar_url", + "_build_doi_url", + "_resolve_public_paper_url", + "_resolve_download_source_url", + "_lookup_openalex_access_paths", + "_lookup_semantic_scholar_access_paths", + "_paper_ranking_sort_key", + "_complete_final_paper_sources", + "_is_selected_for_pdf", + "_label_relevance", +] diff --git a/backend/src/modules/idea_gen/utils/pdf_processor.py b/backend/src/modules/idea_gen/utils/pdf_processor.py index b5588449..717c31f5 100644 --- a/backend/src/modules/idea_gen/utils/pdf_processor.py +++ b/backend/src/modules/idea_gen/utils/pdf_processor.py @@ -1,32 +1,87 @@ """PDF processing for ArXiv papers.""" +import hashlib +import logging import tempfile from pathlib import Path import urllib.request +from src.modules.idea_gen.utils.paper_cache_store import PaperCacheStore + +logger = logging.getLogger(__name__) + class PDFProcessor: - """Download and parse ArXiv PDFs.""" + """Download and parse ArXiv PDFs with cache integration.""" + + def __init__(self, cache_store: PaperCacheStore | None = None): + """Initialize PDF processor with optional cache store. + + Args: + cache_store: Optional PaperCacheStore instance. If None, creates default. + """ + self.cache_store = cache_store or PaperCacheStore.create_default() def download_and_parse(self, arxiv_id: str) -> str: - """Download and extract text from ArXiv PDF.""" + """Download and extract text from ArXiv PDF with cache support. + + Args: + arxiv_id: ArXiv identifier (e.g., "2301.12345") + + Returns: + Extracted PDF text, or empty string on failure + """ try: import fitz except ImportError: + logger.warning("PyMuPDF (fitz) not available, cannot parse PDFs") return "" - pdf_url = f"https://arxiv.org/pdf/{arxiv_id}.pdf" + # Build cache key + paper_key = f"arxiv:{arxiv_id}" - with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as tmp: - urllib.request.urlretrieve(pdf_url, tmp.name) - pdf_path = tmp.name + # Check cache first + if self.cache_store: + cached = self.cache_store.get_by_paper_key(paper_key) + if cached and cached.get("content_md"): + logger.info(f"[PDF CACHE HIT] {arxiv_id}") + return cached["content_md"] + + # Cache miss - download and parse + logger.info(f"[PDF DOWNLOAD] {arxiv_id}") + pdf_url = f"https://arxiv.org/pdf/{arxiv_id}.pdf" try: - doc = fitz.open(pdf_path) - text_parts = [] - for page_num, page in enumerate(doc, start=1): - text = page.get_text() - text_parts.append(f"[Page {page_num}]\n{text}") - doc.close() - return "\n\n".join(text_parts) - finally: - Path(pdf_path).unlink(missing_ok=True) + with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as tmp: + urllib.request.urlretrieve(pdf_url, tmp.name) + pdf_path = tmp.name + + try: + doc = fitz.open(pdf_path) + text_parts = [] + for page_num, page in enumerate(doc, start=1): + text = page.get_text() + text_parts.append(f"[Page {page_num}]\n{text}") + doc.close() + full_text = "\n\n".join(text_parts) + + # Store in cache + if self.cache_store and full_text: + content_hash = hashlib.sha256(full_text.encode("utf-8")).hexdigest() + self.cache_store.upsert_parsed_content( + paper_key=paper_key, + title=arxiv_id, + source_url=pdf_url, + content_md=full_text, + content_hash=content_hash, + content_parser="pymupdf", + is_partial=False, + ) + logger.info(f"[PDF CACHED] {arxiv_id}") + + return full_text + finally: + Path(pdf_path).unlink(missing_ok=True) + + except Exception as e: + logger.error(f"[PDF ERROR] {arxiv_id}: {e}") + return "" diff --git a/backend/src/modules/idea_gen/utils/query_planning_utils.py b/backend/src/modules/idea_gen/utils/query_planning_utils.py new file mode 100644 index 00000000..801a9777 --- /dev/null +++ b/backend/src/modules/idea_gen/utils/query_planning_utils.py @@ -0,0 +1,574 @@ +"""Grouped query-planning and query-feedback helpers for idea_gen workflow.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any + +from src.modules.idea_gen.config import get_paper_processing_config +from src.modules.idea_gen.utils import query_utils +from src.modules.idea_gen.utils.domain_classifier import DomainClassifier +from src.modules.idea_gen.utils.drift_detector import DriftDetector +from src.modules.idea_gen.utils.normalizer_utils import ( + _normalize_query_key, + _normalize_query_text, + _normalize_relevance_score, +) +from src.modules.idea_gen.utils.paper_cache_store import build_paper_key_from_record +from src.modules.idea_gen.utils.paper_utils import ( + _extract_domain, + _is_selected_for_pdf, + _label_relevance, +) +from src.modules.idea_gen.utils.query_state_manager import QueryStateManager +from src.modules.idea_gen.utils.state_utils import ( + get_final_papers_for_compress, + get_research_runs, +) + + +def _plan_to_queries(plan: list[dict[str, Any]], *, limit: int = 3) -> list[str]: + """Convert a structured query plan into plain query strings.""" + return query_utils.plan_to_queries(plan, limit=limit) + + +def _query_role_lookup(plan: list[dict[str, Any]]) -> dict[str, dict[str, Any]]: + """Build normalized query -> plan item lookup.""" + lookup: dict[str, dict[str, Any]] = {} + for item in plan or []: + if not isinstance(item, dict): + continue + query = _normalize_query_text(item.get("query", "")) + key = _normalize_query_key(query) + if key and key not in lookup: + lookup[key] = item + return lookup + + +def _build_role_feedback_entry( + role: str, + channel: str, + plan_item: dict[str, Any] | None, + feedback_entry: dict[str, Any] | None, +) -> dict[str, Any]: + """Summarize role-specific execution feedback for planning.""" + if not plan_item: + return { + "role": role, + "channel": channel, + "query": "", + "status": "missing", + "recommendation": "generate", + "evidence": {}, + } + + feedback_entry = feedback_entry or {} + status = "missing" + recommendation = "review" + if channel == "paper": + judgement = feedback_entry.get("judgement", "") + selected_ratio = feedback_entry.get("selected_ratio", 0.0) + if judgement == "retain": + if role == "anchor": + status = "stable" + recommendation = "retain" + elif role == "exploit": + status = "promising" + recommendation = "retain" + else: + status = "covered" + recommendation = "retain" + elif judgement == "tighten": + status = "weak_signal" + recommendation = "tighten" + elif judgement == "drop": + status = "failed" + recommendation = "replace" + elif selected_ratio: + status = "weak_signal" + recommendation = "tighten" + else: + judgement = feedback_entry.get("judgement", "") + trusted_ratio = feedback_entry.get("trusted_domain_ratio", 0.0) + if judgement == "retain" and trusted_ratio >= 0.25: + status = "useful" + recommendation = "retain" + elif judgement == "retarget_sources": + status = "noisy" + recommendation = "retarget_sources" + elif judgement == "tighten": + status = "weak_signal" + recommendation = "tighten" + + return { + "role": role, + "channel": channel, + "query": plan_item.get("query", ""), + "status": status, + "recommendation": recommendation, + "evidence": feedback_entry, + } + + +def _infer_coverage_summary(latest_runs: list[dict[str, Any]]) -> dict[str, bool]: + """Infer coarse evidence coverage from latest selected paper/web results.""" + selected_text_parts: list[str] = [] + recent_year_found = False + for run in latest_runs: + for result in run.get("results", []) or []: + if not _is_selected_for_pdf(result) and run.get("query_type") != "web": + continue + selected_text_parts.extend( + [ + str(result.get("title", "")), + str(result.get("snippet") or result.get("content") or ""), + str(result.get("primary_topic", "")), + " ".join(result.get("topics") or []), + ] + ) + year = result.get("publication_year") + if isinstance(year, int) and year >= datetime.now().year - 1: + recent_year_found = True + + combined = " ".join(selected_text_parts).lower() + return { + "method_evidence": any( + token in combined + for token in ( + "method", + "architecture", + "framework", + "retrieval", + "episodic", + "semantic", + "memory management", + "cache", + "mechanism", + ) + ), + "benchmark_evidence": any( + token in combined + for token in ( + "benchmark", + "dataset", + "evaluation", + "experiment", + "performance", + "leaderboard", + "task", + "metrics", + ) + ), + "limitation_evidence": any( + token in combined + for token in ( + "limitation", + "failure", + "bottleneck", + "challenge", + "weakness", + "constraint", + "error", + ) + ), + "recent_work_evidence": recent_year_found, + } + + +def _missing_aspects_from_coverage( + coverage_summary: dict[str, bool], + query_feedback: list[dict[str, Any]], + web_feedback: list[dict[str, Any]], +) -> list[str]: + """Translate coarse coverage booleans into actionable missing aspects.""" + missing: list[str] = [] + if not coverage_summary.get("method_evidence"): + missing.append("method evidence for the core task") + if not coverage_summary.get("benchmark_evidence"): + missing.append("benchmark, evaluation, or dataset evidence") + if not coverage_summary.get("limitation_evidence"): + missing.append("limitations, bottlenecks, or failure modes") + if not coverage_summary.get("recent_work_evidence"): + missing.append("recent work coverage") + + if any(item.get("social_noise_ratio", 0.0) >= 0.5 for item in web_feedback): + missing.append("trusted web sources for survey or benchmark coverage") + + if any(item.get("selected_count", 0) == 0 for item in query_feedback): + missing.append("higher precision paper retrieval for the core target") + + deduped: list[str] = [] + seen: set[str] = set() + for aspect in missing: + key = aspect.strip().lower() + if key and key not in seen: + seen.add(key) + deduped.append(aspect) + return deduped + + +def _infer_coverage_summary_from_selected_papers(papers: list[dict[str, Any]]) -> dict[str, bool]: + """Reuse coverage heuristics over the final selected paper set.""" + synthetic_run = { + "query_type": "paper", + "results": [{**paper, "final_selected_for_pdf": True} for paper in papers], + } + return _infer_coverage_summary([synthetic_run]) + + +def _needs_web_query(aspects: list[str]) -> bool: + """Return whether the current missing aspects call for a web query.""" + tokens = " ".join(aspects).lower() + return any( + token in tokens + for token in ("survey", "benchmark", "dataset", "leaderboard", "recent work", "trusted web") + ) + + +def _build_query_feedback_v1(state: Any) -> dict[str, Any]: + """Build the Phase 1 minimal query feedback summary from completed research runs.""" + runs = list(get_research_runs(state) or []) + query_state = QueryStateManager.ensure_state(state) + query_state.get("active_paper_query_plan", []) or query_utils.coerce_plan( + query_state.get("active_paper_queries", []), + channel="paper", + limit=3, + default_source="state", + ) + query_state.get("active_web_query_plan", []) or query_utils.coerce_plan( + query_state.get("active_web_queries", []), + channel="web", + limit=3, + default_source="state", + ) + if not runs: + return { + "paper_query_feedback": [], + "web_query_feedback": [], + "global_summary": { + "paper_total_selected": 0, + "paper_high_confidence_selected": 0, + "paper_drift_ratio": 0.0, + "web_noise_ratio": 0.0, + "coverage_summary": {}, + "missing_aspects": [], + "current_iteration_goal": "collect_core_evidence", + }, + "coverage_summary": {}, + "missing_aspects": [], + } + + latest_iteration = max(int(run.get("iteration", 0) or 0) for run in runs) + latest_runs = [run for run in runs if int(run.get("iteration", 0) or 0) == latest_iteration] + final_selected_keys = set(state.temp_data.get("final_selected_paper_keys", []) or []) + paper_pool_feedback_mode = bool(get_paper_processing_config().enable_paper_pool and final_selected_keys) + final_papers = list(get_final_papers_for_compress(state) or []) + final_papers_by_key = { + str(paper.get("paper_key") or ""): paper + for paper in final_papers + if paper.get("paper_key") + } + feedback_runs = runs if paper_pool_feedback_mode else latest_runs + + paper_feedback: list[dict[str, Any]] = [] + web_feedback: list[dict[str, Any]] = [] + + for run in feedback_runs: + query = _normalize_query_text(run.get("topic", "")) + query_type = run.get("query_type") + results = list(run.get("results", []) or []) + + if query_type == "paper": + candidate_count = len(results) + if paper_pool_feedback_mode: + selected_keys_for_query: set[str] = set() + selected_scores: list[float] = [] + rejected_results: list[dict[str, Any]] = [] + for result in results: + if not isinstance(result, dict): + continue + paper_key = result.get("paper_key") or build_paper_key_from_record(result) + if paper_key in final_papers_by_key: + if paper_key not in selected_keys_for_query: + selected_keys_for_query.add(paper_key) + selected_scores.append( + float(final_papers_by_key[paper_key].get("relevance_score", 0.0) or 0.0) + ) + else: + rejected_results.append(result) + + selected_count = len(selected_keys_for_query) + selected_ratio = round(selected_count / candidate_count, 3) if candidate_count else 0.0 + normalized_scores = [_normalize_relevance_score(score) for score in selected_scores] + avg_final_score = ( + round(sum(normalized_scores) / len(normalized_scores), 3) + if normalized_scores else 0.0 + ) + top_final_score = round(max(normalized_scores), 3) if normalized_scores else 0.0 + relevance = _label_relevance(max(avg_final_score, top_final_score)) + new_pdf_count = selected_count + + if candidate_count == 0: + drift_risk = "high" + should_retry = True + retry_reason = "no candidates retrieved" + drift_signals = [] + elif selected_count == 0: + drift_risk = "high" + should_retry = True + retry_reason = "no final paper from this query survived unified pool selection" + drift_signals = DriftDetector.extract_signals(rejected_results or results) + else: + if selected_ratio < 0.15 and avg_final_score < 0.5: + drift_risk = "high" + elif selected_ratio < 0.35 or avg_final_score < 0.65: + drift_risk = "medium" + else: + drift_risk = "low" + should_retry = bool(drift_risk == "high" or avg_final_score < 0.45) + retry_reason = "" + if avg_final_score < 0.45: + retry_reason = "selected papers are only weakly aligned after unified pool scoring" + elif drift_risk == "high": + retry_reason = "query returned mostly off-target papers for the final pool" + drift_signals = [] + + novelty_gain = "high" if selected_count >= 2 else "medium" if selected_count == 1 else "low" + evidence_gain = ( + "high" if selected_count >= 2 or top_final_score >= 0.75 + else "medium" if selected_count == 1 else "low" + ) + paper_feedback.append( + { + "query": query, + "source": run.get("query_source", ""), + "intent": run.get("query_intent", ""), + "candidate_count": candidate_count, + "selected_count": selected_count, + "selected_ratio": selected_ratio, + "avg_final_score": avg_final_score, + "top_final_score": top_final_score, + "new_pdf_count": new_pdf_count, + "relevance": relevance, + "drift_risk": drift_risk, + "novelty_gain": novelty_gain, + "evidence_gain": evidence_gain, + "should_retry": should_retry, + "retry_reason": retry_reason, + "drift_signals": drift_signals, + "feedback_mode": "final_paper_pool", + } + ) + continue + + selected_count = sum(1 for result in results if _is_selected_for_pdf(result)) + scores = [ + float(result.get("final_score")) + for result in results + if isinstance(result.get("final_score"), (int, float)) + ] + parse_metadata = list(run.get("paper_parse_metadata", []) or []) + new_pdf_count = sum(1 for item in parse_metadata if item.get("success") and not item.get("cache_hit")) + selected_ratio = round(selected_count / candidate_count, 3) if candidate_count else 0.0 + avg_final_score = round(sum(scores) / len(scores), 3) if scores else 0.0 + drift_signals = DriftDetector.extract_signals(results) + relevance = _label_relevance(max(avg_final_score, max(scores) if scores else 0.0)) + drift_risk = "high" if drift_signals or selected_ratio < 0.2 else "medium" if selected_ratio < 0.4 else "low" + novelty_gain = "high" if new_pdf_count >= 2 else "medium" if new_pdf_count == 1 else "low" + evidence_gain = ( + "high" if selected_count >= 2 or (scores and max(scores) >= 0.6) + else "medium" if selected_count == 1 else "low" + ) + should_retry = bool( + candidate_count == 0 or selected_count == 0 or avg_final_score < 0.4 or drift_risk == "high" + ) + retry_reason = "" + if candidate_count == 0: + retry_reason = "no candidates retrieved" + elif selected_count == 0: + retry_reason = "no globally selected evidence" + elif drift_risk == "high": + retry_reason = "results drifted from the core task" + elif avg_final_score < 0.4: + retry_reason = "retrieved papers are weakly aligned" + paper_feedback.append( + { + "query": query, + "source": run.get("query_source", ""), + "intent": run.get("query_intent", ""), + "candidate_count": candidate_count, + "selected_count": selected_count, + "selected_ratio": selected_ratio, + "avg_final_score": avg_final_score, + "top_final_score": round(max(scores), 3) if scores else 0.0, + "new_pdf_count": new_pdf_count, + "relevance": relevance, + "drift_risk": drift_risk, + "novelty_gain": novelty_gain, + "evidence_gain": evidence_gain, + "should_retry": should_retry, + "retry_reason": retry_reason, + "drift_signals": drift_signals, + } + ) + continue + + if query_type == "web": + domains = [_extract_domain(result.get("url", "")) for result in results if result.get("url")] + quality_buckets = [DomainClassifier.classify(domain) for domain in domains if domain] + trusted_count = sum(1 for bucket in quality_buckets if bucket == "trusted") + social_count = sum(1 for bucket in quality_buckets if bucket == "social") + aggregator_count = sum(1 for bucket in quality_buckets if bucket == "aggregator") + result_count = len(results) + trusted_ratio = round(trusted_count / result_count, 3) if result_count else 0.0 + social_ratio = round(social_count / result_count, 3) if result_count else 0.0 + aggregator_ratio = round(aggregator_count / result_count, 3) if result_count else 0.0 + relevance = "high" if trusted_ratio >= 0.5 else "medium" if trusted_ratio >= 0.25 else "low" + drift_risk = "high" if social_ratio >= 0.5 else "medium" if trusted_ratio < 0.25 else "low" + should_retry = bool(result_count == 0 or social_ratio >= 0.5 or trusted_ratio < 0.25) + retry_reason = "" + if result_count == 0: + retry_reason = "no web results retrieved" + elif social_ratio >= 0.5: + retry_reason = "web results are dominated by social or noisy sources" + elif trusted_ratio < 0.25: + retry_reason = "insufficient trusted source coverage" + web_feedback.append( + { + "query": query, + "source": run.get("query_source", ""), + "intent": run.get("query_intent", ""), + "result_count": result_count, + "trusted_domain_ratio": trusted_ratio, + "social_noise_ratio": social_ratio, + "aggregator_ratio": aggregator_ratio, + "relevance": relevance, + "drift_risk": drift_risk, + "novelty_gain": "low", + "evidence_gain": ( + "high" if trusted_ratio >= 0.5 else "medium" if trusted_ratio >= 0.25 else "low" + ), + "should_retry": should_retry, + "retry_reason": retry_reason, + "bad_domains": sorted( + {domain for domain in domains if DomainClassifier.classify(domain) == "social"} + ), + "good_domains": sorted( + {domain for domain in domains if DomainClassifier.classify(domain) == "trusted"} + ), + } + ) + + paper_drift_ratio = round( + sum(1 for entry in paper_feedback if entry.get("drift_risk") == "high") / max(1, len(paper_feedback)), + 3, + ) + web_noise_ratio = round( + sum(entry.get("social_noise_ratio", 0.0) for entry in web_feedback) / max(1, len(web_feedback)), + 3, + ) + if paper_pool_feedback_mode and final_papers: + coverage_summary = _infer_coverage_summary_from_selected_papers(final_papers) + else: + coverage_summary = _infer_coverage_summary(feedback_runs) + missing_aspects = _missing_aspects_from_coverage(coverage_summary, paper_feedback, web_feedback) + + if not missing_aspects and paper_feedback and paper_drift_ratio <= 0.33: + current_iteration_goal = "sufficient_coverage" + elif any("benchmark" in item.lower() or "dataset" in item.lower() for item in missing_aspects): + current_iteration_goal = "fill_evaluation_gap" + elif any( + "limitation" in item.lower() or "failure" in item.lower() or "bottleneck" in item.lower() + for item in missing_aspects + ): + current_iteration_goal = "fill_limitation_gap" + else: + current_iteration_goal = "collect_core_evidence" + + query_state["coverage_summary"] = coverage_summary + query_state["missing_aspects"] = list(missing_aspects) + + return { + "built_from_iteration": latest_iteration, + "paper_query_feedback": paper_feedback, + "web_query_feedback": web_feedback, + "global_summary": { + "paper_total_selected": ( + len(final_papers_by_key) + if paper_pool_feedback_mode + else sum(entry.get("selected_count", 0) for entry in paper_feedback) + ), + "paper_high_confidence_selected": ( + sum( + 1 for paper in final_papers + if _normalize_relevance_score(paper.get("relevance_score", 0.0)) >= 0.6 + ) + if paper_pool_feedback_mode + else sum(1 for entry in paper_feedback if entry.get("top_final_score", 0.0) >= 0.6) + ), + "paper_drift_ratio": paper_drift_ratio, + "web_noise_ratio": web_noise_ratio, + "coverage_summary": coverage_summary, + "missing_aspects": missing_aspects, + "current_iteration_goal": current_iteration_goal, + }, + "coverage_summary": coverage_summary, + "missing_aspects": missing_aspects, + } + + +def _summarize_query_feedback_for_prompt(query_feedback: dict[str, Any]) -> dict[str, Any]: + """Reduce feedback payload size before injecting it into prompts.""" + if not query_feedback: + return {} + + return { + "built_from_iteration": query_feedback.get("built_from_iteration"), + "paper_query_feedback": [ + { + "query": item.get("query", ""), + "selected_count": item.get("selected_count", 0), + "avg_final_score": item.get("avg_final_score", 0.0), + "relevance": item.get("relevance", "low"), + "drift_risk": item.get("drift_risk", "high"), + "should_retry": item.get("should_retry", False), + } + for item in (query_feedback.get("paper_query_feedback") or [])[:3] + ], + "web_query_feedback": [ + { + "query": item.get("query", ""), + "trusted_domain_ratio": item.get("trusted_domain_ratio", 0.0), + "social_noise_ratio": item.get("social_noise_ratio", 0.0), + "should_retry": item.get("should_retry", False), + } + for item in (query_feedback.get("web_query_feedback") or [])[:3] + ], + "global_summary": query_feedback.get("global_summary", {}), + "coverage_summary": query_feedback.get("coverage_summary", {}), + "missing_aspects": list(query_feedback.get("missing_aspects", []) or [])[:4], + } + + +def _query_generation_mode(state: Any) -> str: + """Choose query generation mode.""" + query_history = state.temp_data.get("query_history") or {} + if not query_history.get("seed"): + return "initial" + if state.iteration_count == 0 and not state.temp_data.get("query_feedback"): + return "reuse_initial" + return "iterative" + + +__all__ = [ + "_plan_to_queries", + "_query_role_lookup", + "_build_role_feedback_entry", + "_infer_coverage_summary", + "_missing_aspects_from_coverage", + "_infer_coverage_summary_from_selected_papers", + "_needs_web_query", + "_build_query_feedback_v1", + "_summarize_query_feedback_for_prompt", + "_query_generation_mode", +] diff --git a/backend/src/modules/idea_gen/utils/query_state_manager.py b/backend/src/modules/idea_gen/utils/query_state_manager.py index 8eb1d903..30a23221 100644 --- a/backend/src/modules/idea_gen/utils/query_state_manager.py +++ b/backend/src/modules/idea_gen/utils/query_state_manager.py @@ -4,7 +4,7 @@ from typing import Any, TYPE_CHECKING if TYPE_CHECKING: - from src.modules.idea_gen.workflow import IdeaGenState + from src.modules.idea_gen.state import IdeaGenState class QueryStateManager: @@ -53,7 +53,7 @@ def record_entry(state: "IdeaGenState", entry: dict[str, Any]) -> None: @staticmethod def append_artifact(state: "IdeaGenState", entry: dict[str, Any]) -> None: """Append to query_history_full.jsonl.""" - from src.modules.idea_gen.workflow import _get_output_dir + from src.modules.idea_gen.utils.workflow_helpers import _get_output_dir output_dir = Path(_get_output_dir(state)) artifact_path = output_dir / "query_history_full.jsonl" diff --git a/backend/src/modules/idea_gen/utils/query_utils.py b/backend/src/modules/idea_gen/utils/query_utils.py index 6f3586e6..82a33fe8 100644 --- a/backend/src/modules/idea_gen/utils/query_utils.py +++ b/backend/src/modules/idea_gen/utils/query_utils.py @@ -5,6 +5,8 @@ from collections import Counter from typing import Any +from src.modules.idea_gen.utils.paper_normalization import normalize_text + QUERY_NOISE_TOKENS = { "neurips", "nips", "icml", "iclr", "acl", "emnlp", "aaai", "cvpr", "site", "arxiv", "openreview", "openreviewnet", "aclweb", "org", "cc", @@ -138,7 +140,7 @@ def _score(token: str) -> int: def _normalize_query_text(query: str) -> str: """Normalize query text.""" - return re.sub(r"\s+", " ", str(query or "").replace("\n", " ")).strip() + return normalize_text(str(query or "").replace("\n", " ")) def _normalize_query_key(query: str) -> str: @@ -302,6 +304,8 @@ def dedupe_and_filter_plan( source=item.get("source", "model"), intent=item.get("intent", ""), rationale=item.get("rationale", ""), + seed_id=item.get("seed_id", ""), + lane=item.get("lane", ""), score=item.get("score"), ) key = _normalize_query_key(rebuilt["query"]) @@ -374,6 +378,8 @@ def build_plan_item( source: str = "model", intent: str = "", rationale: str = "", + seed_id: str = "", + lane: str = "", score: float | None = None, ) -> dict[str, Any]: """Build a normalized query plan item.""" @@ -386,6 +392,8 @@ def build_plan_item( "source": _normalize_query_text(source) or "model", "intent": _normalize_query_text(intent) or infer_query_intent(normalized_query, channel=channel, role=normalized_role), "rationale": _normalize_query_text(rationale), + "seed_id": _normalize_query_text(seed_id), + "lane": _normalize_query_text(lane).lower(), "score": score if isinstance(score, (int, float)) else None, } @@ -432,6 +440,8 @@ def coerce_plan( source=item.get("source", default_source), intent=item.get("intent", ""), rationale=item.get("rationale", ""), + seed_id=item.get("seed_id", ""), + lane=item.get("lane", ""), score=item.get("score"), ) else: @@ -702,6 +712,8 @@ def repair_iterative_paper_plan( source=candidate.get("source", "feedback"), intent=candidate.get("intent", "validated_subdirection"), rationale=candidate.get("rationale", ""), + seed_id=candidate.get("seed_id", ""), + lane=candidate.get("lane", ""), score=candidate.get("score"), ) break @@ -750,6 +762,8 @@ def repair_iterative_paper_plan( source=candidate.get("source", "gap"), intent=candidate.get("intent", "uncovered_gap"), rationale=candidate.get("rationale", ""), + seed_id=candidate.get("seed_id", ""), + lane=candidate.get("lane", ""), score=candidate.get("score"), ) break @@ -774,6 +788,8 @@ def repair_iterative_paper_plan( source=candidate.get("source", "model"), intent=candidate.get("intent", ""), rationale=candidate.get("rationale", ""), + seed_id=candidate.get("seed_id", ""), + lane=candidate.get("lane", ""), score=candidate.get("score"), ), ) @@ -816,6 +832,8 @@ def repair_iterative_web_plan( source=item.get("source", "model"), intent=item.get("intent", ""), rationale=item.get("rationale", ""), + seed_id=item.get("seed_id", ""), + lane=item.get("lane", ""), score=item.get("score"), ), ) diff --git a/backend/src/modules/idea_gen/utils/report_builder.py b/backend/src/modules/idea_gen/utils/report_builder.py new file mode 100644 index 00000000..e862c95b --- /dev/null +++ b/backend/src/modules/idea_gen/utils/report_builder.py @@ -0,0 +1,1077 @@ +"""Helpers for section-wise report generation and source tracking.""" +from __future__ import annotations + +import asyncio +import hashlib +import re +from typing import Any +from urllib.parse import urlparse + +from src.modules.idea_gen.config import get_report_config +from src.modules.idea_gen.utils.paper_normalization import normalize_authors, normalize_text +from src.modules.idea_gen.utils.report_source_handler import ( + ReportSourceHandler, + normalize_report_url, +) +from src.modules.idea_gen.utils.state_utils import ( + get_compressed_papers, + get_final_papers_for_compress, + get_research_runs, +) + +_SOURCE_CITATION_PATTERN = re.compile(r"(?:\[@([A-Za-z0-9:._-]+)\]|【@([A-Za-z0-9:._-]+)】)") +_IDEA_DETAIL_SUBHEADINGS = [ + "### 核心假设与目标缺口", + "### 最相关现有工作与关键差异", + "### 方案机制", + "### 最小验证路径", + "### 主要风险与失败信号", +] + + +def _normalize_space(text: str) -> str: + return normalize_text(text) + + +def _stable_key(prefix: str, raw: str) -> str: + digest = hashlib.sha1(str(raw or "").encode("utf-8")).hexdigest()[:12] + return f"{prefix}:{digest}" + + +def _infer_source_kind(url: str, payload: dict[str, Any]) -> str: + if payload.get("paper_key") or payload.get("doi") or payload.get("arxivId") or payload.get("openalex_id"): + return "paper" + if payload.get("publication_year") or payload.get("venue") or payload.get("authors"): + return "paper" + + domain = urlparse(url).netloc.lower() + if any(token in domain for token in ("github.com", "gitlab.com", "huggingface.co")): + return "project" + if any(token in domain for token in ("medium.com", "substack.com", "blog", "dev.to", "ghost.io")): + return "blog" + return "web" + + +def _source_key_for_payload(title: str, url: str, payload: dict[str, Any]) -> str: + paper_key = _normalize_space(payload.get("paper_key", "")) + if paper_key: + return _stable_key("paper", paper_key) + if url: + return _stable_key(_infer_source_kind(url, payload), url) + return _stable_key("source", title) + + +def _coerce_authors(payload: dict[str, Any]) -> list[str]: + return normalize_authors(payload.get("authors"), max_count=4) + + +def _source_summary(payload: dict[str, Any], kind: str) -> str: + if kind == "paper": + parts = [ + _normalize_space(payload.get("core_summary", "")), + _normalize_space(payload.get("technical_depth", "")), + _normalize_space(payload.get("empirical_support", "")), + _normalize_space(payload.get("snippet", "")), + _normalize_space(payload.get("abstract", "")), + _normalize_space(payload.get("content", "")), + ] + else: + excerpt = " ".join(_normalize_space(item) for item in (payload.get("key_excerpts") or [])[:2]) + parts = [ + _normalize_space(payload.get("content", "")), + _normalize_space(payload.get("snippet", "")), + excerpt, + ] + + for part in parts: + if part: + return part[:600] + return "" + + +def _source_entry_from_payload(payload: dict[str, Any]) -> dict[str, Any] | None: + title = _normalize_space(payload.get("title", "")) + if not title: + return None + + best_url, score = ReportSourceHandler.choose_best_url(payload) + url = normalize_report_url(best_url or str(payload.get("url") or payload.get("source_url") or "")) + if not url: + return None + + kind = _infer_source_kind(url, payload) + source_key = _source_key_for_payload(title, url, payload) + domain = urlparse(url).netloc.lower() + summary = _source_summary(payload, kind) + return { + "source_key": source_key, + "paper_key": _normalize_space(payload.get("paper_key", "")), + "title": title, + "url": url, + "kind": kind, + "authors": _coerce_authors(payload), + "venue": _normalize_space(payload.get("venue", "")), + "year": payload.get("publication_year") or payload.get("year"), + "summary": summary, + "key_excerpts": [ + _normalize_space(item) for item in (payload.get("key_excerpts") or [])[:2] if _normalize_space(item) + ], + "query_topic": _normalize_space(payload.get("query_topic", "")), + "query_intent": _normalize_space(payload.get("query_intent", "")), + "provider": _normalize_space( + payload.get("source_provider") or payload.get("retrieval_source") or payload.get("source", "") + ), + "domain": domain, + "score": int(score) + (2 if kind == "paper" and summary else 0), + } + + +def build_source_catalog(state: Any) -> dict[str, dict[str, Any]]: + """Build a canonical source catalog keyed by report citation key.""" + catalog: dict[str, dict[str, Any]] = {} + + def _upsert(payload: dict[str, Any]) -> None: + entry = _source_entry_from_payload(payload) + if entry is None: + return + source_key = entry["source_key"] + existing = catalog.get(source_key) + if existing is None or entry["score"] > existing["score"] or len(entry["summary"]) > len(existing["summary"]): + catalog[source_key] = entry + + for paper in get_final_papers_for_compress(state): + if isinstance(paper, dict): + _upsert(paper) + + for paper in get_compressed_papers(state): + if isinstance(paper, dict): + _upsert(paper) + + for run in get_research_runs(state): + for result in run.get("results", []) or []: + if isinstance(result, dict): + _upsert(result) + + for enriched in state.temp_data.get("enriched_papers", []) or []: + if not isinstance(enriched, dict): + continue + candidate = enriched.get("candidate") + if isinstance(candidate, dict): + _upsert(candidate) + + return catalog + + +def build_fixed_report_outline(state: Any) -> list[dict[str, Any]]: + """Build the fixed report outline agreed for multi-idea reports.""" + hypotheses = list(state.hypotheses or []) + outline: list[dict[str, Any]] = [ + { + "section_id": "problem_definition", + "heading": "## 1. 问题定义与研究目标", + "goal": "Define the shared research problem and why it matters now.", + "instruction": "用 2-3 段交代共同问题、研究目标、问题重要性,并明确多个候选 idea 针对的是同一类核心缺口。", + "hypothesis_ids": [hyp.hypothesis_id for hyp in hypotheses], + }, + { + "section_id": "limitations", + "heading": "## 2. 现有工作的代表路径与主要不足", + "goal": "Summarize representative prior-work paths and pinpoint concrete gaps or failure modes.", + "instruction": "按代表路径组织现有工作,突出其主要假设、失效边界与尚未覆盖的缺口,避免把所有论文逐条罗列。", + "hypothesis_ids": [hyp.hypothesis_id for hyp in hypotheses], + }, + { + "section_id": "ideas_overview", + "heading": "## 3. Ideas 总览", + "goal": "Present all candidate ideas side by side with concise positioning.", + "instruction": "并列概述所有 ideas,每个 idea 用简短小段说明核心主张、适用场景、预期收益与风险侧重。", + "hypothesis_ids": [hyp.hypothesis_id for hyp in hypotheses], + }, + ] + + base_index = 4 + for offset, hypothesis in enumerate(hypotheses): + label = chr(ord("A") + offset) + outline.append( + { + "section_id": f"idea_detail_{hypothesis.hypothesis_id}", + "heading": f"## {base_index + offset}. Idea {label} 详细设计", + "goal": f"Develop Idea {label} in detail, including mechanism, validation, and risks.", + "instruction": ( + "围绕该 idea 展开:核心假设、要解决的具体缺口、与最相关现有工作的差异、" + "方案设计与关键机制、最小可验证路径、主要风险与失败信号。" + "使用固定小标题:核心假设与目标缺口 / 最相关现有工作与关键差异 / 方案机制 / 最小验证路径 / 主要风险与失败信号。" + ), + "hypothesis_ids": [hypothesis.hypothesis_id], + "idea_label": label, + } + ) + + comparison_index = base_index + len(hypotheses) + outline.extend( + [ + { + "section_id": "comparison", + "heading": f"## {comparison_index}. Ideas 横向比较与适用边界", + "goal": "Compare the ideas, clarify tradeoffs, and define where each idea is strongest or weakest.", + "instruction": "横向比较 ideas 的创新性、可行性、风险、资源需求与适用边界,强调何时应优先选择哪一个。", + "hypothesis_ids": [hyp.hypothesis_id for hyp in hypotheses], + }, + { + "section_id": "venue_fit", + "heading": f"## {comparison_index + 1}. 会议契合度与下一步建议", + "goal": "Assess venue fit, novelty bar, reviewer concerns, and next actions.", + "instruction": "结合目标会议的 novelty bar、主题偏好和潜在 reviewer concern,给出务实的下一步推进建议。", + "hypothesis_ids": [hyp.hypothesis_id for hyp in hypotheses], + }, + { + "section_id": "sources", + "heading": f"## {comparison_index + 2}. Sources", + "goal": "Programmatically generated from sources cited in the body.", + "instruction": "Generated by code.", + "hypothesis_ids": [], + }, + ] + ) + return outline + + +def render_outline_markdown(outline: list[dict[str, Any]]) -> str: + """Render the fixed outline into markdown for artifacts/debugging.""" + lines = ["# Report Outline", ""] + for section in outline: + lines.append(f"- {section['heading']} ") + lines.append(f" Goal: {section['goal']}") + return "\n".join(lines).strip() + + +def _hypothesis_map(state: Any) -> dict[str, Any]: + return { + hyp.hypothesis_id: hyp + for hyp in list(state.hypotheses or []) + if getattr(hyp, "hypothesis_id", "") + } + + +def _seed_map(state: Any) -> dict[str, Any]: + return { + seed.seed_id: seed + for seed in list(state.seeds or []) + if getattr(seed, "seed_id", "") + } + + +def _paper_pool_map(state: Any) -> dict[str, dict[str, Any]]: + papers = list(get_compressed_papers(state) or []) or list(get_final_papers_for_compress(state) or []) + return { + str(paper.get("paper_key") or ""): paper + for paper in papers + if isinstance(paper, dict) and paper.get("paper_key") + } + + +def _general_web_source_keys(state: Any, source_catalog: dict[str, dict[str, Any]]) -> list[str]: + keys: list[str] = [] + seen: set[str] = set() + for run in get_research_runs(state): + if run.get("query_type") != "web": + continue + for result in run.get("results", []) or []: + if not isinstance(result, dict): + continue + entry = _source_entry_from_payload(result) + if entry is None: + continue + key = entry["source_key"] + if key in source_catalog and key not in seen: + seen.add(key) + keys.append(key) + if len(keys) >= 4: + return keys + return keys + + +def _ordered_unique(items: list[str]) -> list[str]: + seen: set[str] = set() + ordered: list[str] = [] + for item in items: + if item and item not in seen: + seen.add(item) + ordered.append(item) + return ordered + + +def _ordered_unique_by_title(items: list[str], source_catalog: dict[str, dict[str, Any]]) -> list[str]: + seen_keys: set[str] = set() + seen_titles: set[str] = set() + ordered: list[str] = [] + for item in items: + if not item or item in seen_keys: + continue + title = _normalize_space(source_catalog.get(item, {}).get("title", "")).lower() + title_key = title or item + if title_key in seen_titles: + continue + seen_keys.add(item) + seen_titles.add(title_key) + ordered.append(item) + return ordered + + +def _catalog_by_paper_key(source_catalog: dict[str, dict[str, Any]]) -> dict[str, str]: + return { + str(entry.get("paper_key") or ""): source_key + for source_key, entry in source_catalog.items() + if entry.get("paper_key") + } + + +def _resolve_catalog_source_key( + raw_key: str, + source_catalog: dict[str, dict[str, Any]], + catalog_by_paper_key: dict[str, str] | None = None, +) -> str | None: + key = str(raw_key or "") + if not key: + return None + if key in source_catalog: + return key + if catalog_by_paper_key is None: + catalog_by_paper_key = _catalog_by_paper_key(source_catalog) + return catalog_by_paper_key.get(key) + + +def _seed_source_keys(seed: Any, source_catalog: dict[str, dict[str, Any]]) -> list[str]: + if seed is None: + return [] + catalog_by_paper_key = _catalog_by_paper_key(source_catalog) + keys: list[str] = [] + for raw_key in getattr(seed, "source_paper_keys", []) or []: + resolved = _resolve_catalog_source_key(str(raw_key), source_catalog, catalog_by_paper_key) + if resolved: + keys.append(resolved) + return _ordered_unique(keys) + + +def _nearest_prior_source_keys(hypothesis: Any, seed: Any, source_catalog: dict[str, dict[str, Any]]) -> list[str]: + catalog_by_paper_key = _catalog_by_paper_key(source_catalog) + keys: list[str] = [] + for raw_key in getattr(hypothesis, "nearest_prior_work", []) or []: + resolved = _resolve_catalog_source_key(str(raw_key), source_catalog, catalog_by_paper_key) + if resolved: + keys.append(resolved) + keys.extend(_seed_source_keys(seed, source_catalog)) + return _ordered_unique(keys) + + +def _report_search_query(hypothesis: Any, seed: Any | None = None) -> str: + fields = [ + getattr(hypothesis, "title", ""), + getattr(hypothesis, "core_claim", ""), + getattr(hypothesis, "target_gap", ""), + getattr(hypothesis, "novelty_delta", ""), + getattr(seed, "mechanism", "") if seed else "", + getattr(seed, "scenario", "") if seed else "", + ] + return " ".join(_normalize_space(field) for field in fields if _normalize_space(field)) + + +def build_report_retrieval_context( + state: Any, + source_catalog: dict[str, dict[str, Any]], +) -> dict[str, Any]: + paper_pool = _paper_pool_map(state) + paper_key_to_source_key = _catalog_by_paper_key(source_catalog) + context: dict[str, Any] = { + "store": None, + "embedding_ready": False, + "embedding_cache": {}, + "paper_key_to_source_key": paper_key_to_source_key, + } + + if not paper_pool: + return context + + papers_for_index: list[dict[str, Any]] = [] + for paper_key, paper in paper_pool.items(): + if paper_key not in paper_key_to_source_key: + continue + title = _normalize_space(paper.get("title", "")) + abstract = _normalize_space( + paper.get("abstract", "") + or paper.get("content", "") + or paper.get("core_summary", "") + or paper.get("snippet", "") + ) + if not title and not abstract: + continue + papers_for_index.append( + { + "paper_key": paper_key, + "title": title, + "abstract": abstract, + } + ) + + if not papers_for_index: + return context + + try: + from src.modules.idea_gen.utils.embedding_store import EmbeddingStore + + store = EmbeddingStore() + store.build(papers_for_index) + context["store"] = store + try: + asyncio.run(store.build_embeddings()) + context["embedding_ready"] = True + except Exception: + context["embedding_ready"] = False + return context + except Exception: + return context + + +def _evidence_lane_brief_items( + hypothesis: Any, + lane: str, + source_catalog: dict[str, dict[str, Any]], +) -> list[dict[str, Any]]: + evidence_trace = getattr(hypothesis, "evidence_trace", None) + if evidence_trace is None: + return [] + + catalog_by_paper_key = _catalog_by_paper_key(source_catalog) + items: list[dict[str, Any]] = [] + seen: set[str] = set() + for item in getattr(evidence_trace, lane, []) or []: + source_key = _resolve_catalog_source_key( + str(getattr(item, "paper_key", "") or ""), + source_catalog, + catalog_by_paper_key, + ) + if not source_key or source_key in seen or source_key not in source_catalog: + continue + seen.add(source_key) + items.append( + { + "source_key": source_key, + "title": getattr(item, "title", ""), + "excerpt": getattr(item, "excerpt", ""), + "relevance": getattr(item, "relevance", ""), + } + ) + return items + + +def _evidence_lane_source_keys( + hypothesis: Any, + source_catalog: dict[str, dict[str, Any]], + lanes: tuple[str, ...], + limit: int, + exclude: list[str] | None = None, +) -> list[str]: + excluded = set(exclude or []) + keys: list[str] = [] + for lane in lanes: + for item in _evidence_lane_brief_items(hypothesis, lane, source_catalog): + source_key = str(item.get("source_key", "") or "") + if not source_key or source_key in excluded or source_key in keys: + continue + keys.append(source_key) + if len(keys) >= limit: + return keys + return keys + + +def _search_hits_to_source_keys( + hits: list[tuple[str, float]], + source_catalog: dict[str, dict[str, Any]], + paper_key_to_source_key: dict[str, str], + exclude: list[str] | None = None, +) -> list[str]: + excluded = set(exclude or []) + keys: list[str] = [] + for paper_key, _score in hits: + source_key = paper_key_to_source_key.get(str(paper_key or "")) + if not source_key or source_key in excluded: + continue + entry = source_catalog.get(source_key) + if not entry or entry.get("kind") != "paper": + continue + keys.append(source_key) + return _ordered_unique_by_title(keys, source_catalog) + + +def _interleave_source_lists(lists: list[list[str]], source_catalog: dict[str, dict[str, Any]]) -> list[str]: + merged: list[str] = [] + max_len = max((len(items) for items in lists), default=0) + for index in range(max_len): + for items in lists: + if index < len(items): + merged.append(items[index]) + return _ordered_unique_by_title(merged, source_catalog) + + +def _hybrid_report_search_source_keys( + hypothesis: Any, + seed: Any, + source_catalog: dict[str, dict[str, Any]], + retrieval_context: dict[str, Any] | None, + limit: int, + exclude: list[str] | None = None, +) -> list[str]: + if not retrieval_context or not retrieval_context.get("store"): + return [] + + store = retrieval_context["store"] + paper_key_to_source_key = retrieval_context.get("paper_key_to_source_key", {}) or {} + query = _report_search_query(hypothesis, seed) + if not query: + return [] + + excluded = list(exclude or []) + report_config = get_report_config() + search_top_k = max(report_config.report_search_top_k, limit * 2) + bm25_hits = store.bm25_search(query, top_k=search_top_k) + bm25_keys = _search_hits_to_source_keys( + bm25_hits, + source_catalog, + paper_key_to_source_key, + exclude=excluded, + ) + + embedding_keys: list[str] = [] + if retrieval_context.get("embedding_ready"): + cache_key = f"{query}::topk={search_top_k}" + cache = retrieval_context.setdefault("embedding_cache", {}) + if cache_key not in cache: + try: + embedding_hits = asyncio.run( + store.embedding_search(query, top_k=search_top_k) + ) + except Exception: + embedding_hits = [] + cache[cache_key] = _search_hits_to_source_keys( + embedding_hits, + source_catalog, + paper_key_to_source_key, + exclude=excluded, + ) + embedding_keys = list(cache.get(cache_key, []) or []) + + return _interleave_source_lists([embedding_keys, bm25_keys], source_catalog)[:limit] + + +def _hypothesis_source_keys(hypothesis: Any, seed: Any, source_catalog: dict[str, dict[str, Any]]) -> list[str]: + keys: list[str] = [] + keys.extend(_nearest_prior_source_keys(hypothesis, seed, source_catalog)) + + evidence_trace = getattr(hypothesis, "evidence_trace", None) + if evidence_trace is not None: + for lane in ("inspiration", "novelty", "contradiction", "execution"): + keys.extend(_evidence_lane_source_keys(hypothesis, source_catalog, (lane,), limit=3, exclude=keys)) + + return _ordered_unique(keys) + + +def _source_brief(source_key: str, entry: dict[str, Any]) -> dict[str, Any]: + return { + "source_key": source_key, + "kind": entry.get("kind", ""), + "title": entry.get("title", ""), + "url": entry.get("url", ""), + "authors": list(entry.get("authors", []) or []), + "venue": entry.get("venue", ""), + "year": entry.get("year"), + "summary": entry.get("summary", ""), + "key_excerpts": list(entry.get("key_excerpts", []) or []), + "query_topic": entry.get("query_topic", ""), + "query_intent": entry.get("query_intent", ""), + "domain": entry.get("domain", ""), + } + + +def _hypothesis_brief(hypothesis: Any, seed: Any | None = None) -> dict[str, Any]: + return { + "hypothesis_id": hypothesis.hypothesis_id, + "seed_id": hypothesis.seed_id, + "title": hypothesis.title, + "core_claim": hypothesis.core_claim, + "target_gap": hypothesis.target_gap, + "novelty_delta": hypothesis.novelty_delta, + "assumptions": list(hypothesis.assumptions or []), + "risks": list(hypothesis.risks or []), + "minimal_validation": hypothesis.minimal_validation, + "falsification_signal": hypothesis.falsification_signal, + "expected_upside": hypothesis.expected_upside, + "expected_cost": hypothesis.expected_cost, + "mechanism": getattr(seed, "mechanism", "") if seed else "", + "scenario": getattr(seed, "scenario", "") if seed else "", + "expected_gain": getattr(seed, "expected_gain", "") if seed else "", + } + + +def _evidence_brief( + hypothesis: Any, + source_catalog: dict[str, dict[str, Any]] | None = None, + lane_limits: dict[str, int] | None = None, + allowed_source_keys: list[str] | None = None, +) -> dict[str, Any]: + evidence_trace = getattr(hypothesis, "evidence_trace", None) + if evidence_trace is None: + return {} + + bundle: dict[str, Any] = { + "seed_id": getattr(evidence_trace, "seed_id", ""), + "completeness": getattr(evidence_trace, "completeness", 0.0), + "evidence_gaps": list(getattr(evidence_trace, "evidence_gaps", []) or []), + } + for lane in ("inspiration", "novelty", "contradiction", "execution"): + if source_catalog is None: + bundle[lane] = [ + { + "paper_key": getattr(item, "paper_key", ""), + "title": getattr(item, "title", ""), + "excerpt": getattr(item, "excerpt", ""), + "relevance": getattr(item, "relevance", ""), + } + for item in (getattr(evidence_trace, lane, []) or [])[:3] + ] + continue + + lane_items = _evidence_lane_brief_items(hypothesis, lane, source_catalog) + if allowed_source_keys is not None: + allowed = set(allowed_source_keys) + lane_items = [item for item in lane_items if item.get("source_key") in allowed] + lane_limit = (lane_limits or {}).get(lane, 0) + if lane_limit > 0: + lane_items = lane_items[:lane_limit] + if lane_items: + bundle[lane] = lane_items + return bundle + + +def _representative_source_keys_for_hypothesis( + hypothesis: Any, + seed: Any, + source_catalog: dict[str, dict[str, Any]], +) -> list[str]: + candidates = _ordered_unique_by_title( + _nearest_prior_source_keys(hypothesis, seed, source_catalog), + source_catalog, + ) + if candidates: + return candidates[:1] + + for lanes in (("novelty", "contradiction"), ("inspiration",), ("execution",)): + lane_keys = _ordered_unique_by_title( + _evidence_lane_source_keys(hypothesis, source_catalog, lanes, limit=4), + source_catalog, + ) + if lane_keys: + return lane_keys[:1] + return [] + + +def _idea_detail_source_keys( + hypothesis: Any, + seed: Any, + source_catalog: dict[str, dict[str, Any]], + retrieval_context: dict[str, Any] | None = None, +) -> dict[str, list[str]]: + report_config = get_report_config() + prior_work = _ordered_unique_by_title( + _nearest_prior_source_keys(hypothesis, seed, source_catalog), + source_catalog, + )[: report_config.idea_detail_prior_work_limit] + contrast = _ordered_unique_by_title( + _evidence_lane_source_keys( + hypothesis, + source_catalog, + ("novelty", "contradiction"), + limit=max( + report_config.idea_detail_prior_work_limit, + report_config.idea_detail_contrast_limit, + ), + exclude=prior_work, + ), + source_catalog, + )[: report_config.idea_detail_contrast_limit] + inspiration = _ordered_unique_by_title( + _evidence_lane_source_keys( + hypothesis, + source_catalog, + ("inspiration",), + limit=max(1, report_config.idea_detail_inspiration_limit), + exclude=prior_work + contrast, + ), + source_catalog, + )[: report_config.idea_detail_inspiration_limit] + execution = _ordered_unique_by_title( + _evidence_lane_source_keys( + hypothesis, + source_catalog, + ("execution",), + limit=max(1, report_config.idea_detail_execution_limit), + exclude=prior_work + contrast + inspiration, + ), + source_catalog, + )[: report_config.idea_detail_execution_limit] + + owned_keys = _ordered_unique_by_title( + prior_work + contrast + inspiration + execution, + source_catalog, + ) + retrieval_expansion = _hybrid_report_search_source_keys( + hypothesis, + seed, + source_catalog, + retrieval_context, + limit=max(0, report_config.idea_detail_reference_pool_size - len(owned_keys)), + exclude=owned_keys, + ) + ranked_selected = _ordered_unique_by_title( + owned_keys + retrieval_expansion, + source_catalog, + )[: report_config.idea_detail_reference_pool_size] + if not prior_work and ranked_selected: + prior_work = [ranked_selected[0]] + + return { + "prior_work": prior_work, + "contrast": contrast, + "inspiration": [source_key for source_key in inspiration if source_key in ranked_selected], + "execution": [source_key for source_key in execution if source_key in ranked_selected], + "retrieval_expansion": [source_key for source_key in retrieval_expansion if source_key in ranked_selected], + "selected": ranked_selected, + } + + +def required_section_subheadings(section_id: str) -> list[str]: + if section_id.startswith("idea_detail_"): + return list(_IDEA_DETAIL_SUBHEADINGS) + return [] + + +def build_section_payload( + state: Any, + section: dict[str, Any], + source_catalog: dict[str, dict[str, Any]], + retrieval_context: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Prepare section-scoped evidence and source bundle.""" + hypotheses = _hypothesis_map(state) + seeds = _seed_map(state) + web_source_keys = _general_web_source_keys(state, source_catalog) + + section_hypotheses = [ + hypotheses[hypothesis_id] + for hypothesis_id in section.get("hypothesis_ids", []) + if hypothesis_id in hypotheses + ] + + briefs: list[dict[str, Any]] = [] + source_keys: list[str] = [] + idea_cards: list[dict[str, Any]] = [] + detail_support: dict[str, Any] | None = None + evidence_bundle: dict[str, Any] | None = None + for hypothesis in section_hypotheses: + seed = seeds.get(hypothesis.seed_id) + brief = _hypothesis_brief(hypothesis, seed) + briefs.append(brief) + + if section["section_id"] == "ideas_overview": + representative_keys = _representative_source_keys_for_hypothesis(hypothesis, seed, source_catalog) + source_keys.extend(representative_keys) + representative_source = None + if representative_keys and representative_keys[0] in source_catalog: + representative_source = _source_brief(representative_keys[0], source_catalog[representative_keys[0]]) + idea_cards.append( + { + "hypothesis_id": brief["hypothesis_id"], + "title": brief["title"], + "core_claim": brief["core_claim"], + "target_gap": brief["target_gap"], + "expected_upside": brief["expected_upside"], + "main_risks": brief["risks"][:2], + "representative_source": representative_source, + } + ) + continue + + if section["section_id"].startswith("idea_detail_"): + plan = _idea_detail_source_keys(hypothesis, seed, source_catalog, retrieval_context=retrieval_context) + source_keys.extend(plan["selected"]) + detail_support = { + "anchor_hypothesis_id": brief["hypothesis_id"], + "reference_pool_size": len(plan["selected"]), + "primary_prior_work": [ + _source_brief(source_key, source_catalog[source_key]) + for source_key in plan["prior_work"] + if source_key in source_catalog + ], + "supporting_contrast_work": [ + _source_brief(source_key, source_catalog[source_key]) + for source_key in plan["contrast"] + if source_key in source_catalog + ], + "supporting_inspiration_work": [ + _source_brief(source_key, source_catalog[source_key]) + for source_key in plan["inspiration"] + if source_key in source_catalog + ], + "validation_or_execution_work": [ + _source_brief(source_key, source_catalog[source_key]) + for source_key in plan["execution"] + if source_key in source_catalog + ], + "retrieval_expansion_work": [ + _source_brief(source_key, source_catalog[source_key]) + for source_key in plan["retrieval_expansion"] + if source_key in source_catalog + ], + } + evidence_bundle = _evidence_brief( + hypothesis, + source_catalog=source_catalog, + lane_limits=dict(get_report_config().idea_detail_lane_limits), + allowed_source_keys=plan["selected"], + ) + continue + + source_keys.extend(_hypothesis_source_keys(hypothesis, seed, source_catalog)) + + if section["section_id"] == "problem_definition": + source_keys = source_keys[:3] + web_source_keys[:2] + elif section["section_id"] == "limitations": + source_keys = source_keys[:6] + elif section["section_id"] == "ideas_overview": + source_keys = _ordered_unique(source_keys) + elif section["section_id"].startswith("idea_detail_"): + source_keys = _ordered_unique(source_keys) + elif section["section_id"] == "comparison": + source_keys = source_keys[:4] + elif section["section_id"] == "venue_fit": + source_keys = source_keys[:2] + web_source_keys[:2] + + payload: dict[str, Any] = { + "section_id": section["section_id"], + "heading": section["heading"], + "goal": section["goal"], + "research_brief": state.research_brief, + "target_venues": list(state.input.target_venues), + "hypotheses": briefs, + "sources": [ + _source_brief(source_key, source_catalog[source_key]) + for source_key in _ordered_unique(source_keys) + if source_key in source_catalog + ], + } + + if section["section_id"] == "ideas_overview": + payload["expected_idea_count"] = len(idea_cards) + payload["idea_cards"] = idea_cards + elif section["section_id"].startswith("idea_detail_"): + if detail_support: + payload["idea_support"] = detail_support + if evidence_bundle: + payload["evidence_bundle"] = evidence_bundle + elif len(section_hypotheses) == 1: + hypothesis = section_hypotheses[0] + payload["evidence_bundle"] = _evidence_brief(hypothesis) + elif section_hypotheses: + payload["comparison_points"] = [ + { + "hypothesis_id": brief["hypothesis_id"], + "title": brief["title"], + "core_claim": brief["core_claim"], + "target_gap": brief["target_gap"], + "novelty_delta": brief["novelty_delta"], + "risks": brief["risks"], + "expected_upside": brief["expected_upside"], + "expected_cost": brief["expected_cost"], + } + for brief in briefs + ] + + return payload + + +def sanitize_section_markdown(text: str, heading: str) -> str: + """Remove accidental Sources sections and normalize heading.""" + stripped = str(text or "").strip() + if not stripped: + return heading + + stripped = re.sub( + r"【@([A-Za-z0-9:._-]+)】", + lambda match: f"[@{match.group(1)}]", + stripped, + ) + + lines = stripped.splitlines() + if not lines or _normalize_space(lines[0]) != _normalize_space(heading): + stripped = f"{heading}\n\n{stripped}" + + stripped = re.sub(r"\n#{2,6}\s+Sources\s*[\s\S]*$", "", stripped, flags=re.IGNORECASE).strip() + return stripped + + +def _minimum_citations(section_id: str, payload: dict[str, Any]) -> int: + report_config = get_report_config() + allowed_count = len(payload.get("sources", []) or []) + hypothesis_count = len(payload.get("hypotheses", []) or []) + section_min = report_config.section_min_citations + + if section_id == "limitations": + return min(section_min.get("limitations", 2), allowed_count) + if section_id == "ideas_overview": + per_idea = max(1, report_config.ideas_overview_citations_per_idea) + return min(max(per_idea, hypothesis_count * per_idea), allowed_count) + if section_id.startswith("idea_detail_"): + return min(section_min.get("idea_detail", 2), allowed_count) + if section_id == "comparison": + return min(section_min.get("comparison", 2), allowed_count) + return 0 + + +def lint_section_citations(section: dict[str, Any], payload: dict[str, Any], text: str) -> dict[str, Any]: + """Validate whether a section cites allowed sources with enough coverage and respects required structure.""" + found_keys = [ + first or second + for first, second in _SOURCE_CITATION_PATTERN.findall(str(text or "")) + if first or second + ] + allowed_keys = { + str(item.get("source_key", "") or "") + for item in (payload.get("sources", []) or []) + if item.get("source_key") + } + unique_found_keys = _ordered_unique(found_keys) + valid_keys = [key for key in unique_found_keys if key in allowed_keys] + unknown_keys = [key for key in unique_found_keys if key not in allowed_keys] + section_id = section.get("section_id", "") + min_required = _minimum_citations(section_id, payload) + missing_count = max(0, min_required - len(valid_keys)) + required_subheadings = required_section_subheadings(section_id) + normalized_text = _normalize_space(text) + missing_subheadings = [ + subtitle + for subtitle in required_subheadings + if _normalize_space(subtitle) not in normalized_text + ] + + errors: list[str] = [] + if unknown_keys: + errors.append( + "Found citations not present in the allowed source list: " + + ", ".join(f"[@{key}]" for key in unknown_keys) + ) + if missing_count > 0: + errors.append( + f"This section needs at least {min_required} valid citations but only has {len(valid_keys)}." + ) + if missing_subheadings: + errors.append( + "Missing required subheadings: " + + ", ".join(missing_subheadings) + ) + + return { + "section_id": section_id, + "heading": section.get("heading", ""), + "valid": not errors, + "min_required": min_required, + "valid_keys": valid_keys, + "unknown_keys": unknown_keys, + "required_subheadings": required_subheadings, + "missing_subheadings": missing_subheadings, + "errors": errors, + } + + +def build_citation_retry_feedback(lint_result: dict[str, Any], payload: dict[str, Any]) -> str: + """Build a compact retry note for a section that failed validation.""" + allowed_sources = payload.get("sources", []) or [] + lines = [ + "The previous draft failed section validation.", + "Please rewrite the same section and fix these issues:", + ] + lines.extend(f"- {error}" for error in lint_result.get("errors", [])) + if lint_result.get("required_subheadings"): + lines.append("Required subheadings for this section:") + lines.extend(f"- {item}" for item in lint_result.get("required_subheadings", [])) + if allowed_sources: + lines.append("Allowed source keys for this section:") + lines.extend( + f"- [@{item.get('source_key', '')}] {item.get('title', '')}" + for item in allowed_sources + if item.get("source_key") + ) + lines.append("Keep the same section heading, preserve the required subheadings, and cite only with [@source_key] markers.") + return "\n".join(lines) + + +def renumber_citations(report_body: str, source_catalog: dict[str, dict[str, Any]]) -> tuple[str, list[str]]: + """Convert [@source_key] markers into sequential numeric citations.""" + used_keys: list[str] = [] + citation_numbers: dict[str, int] = {} + + def _replace(match: re.Match[str]) -> str: + source_key = match.group(1) or match.group(2) + if source_key not in source_catalog: + return "" + if source_key not in citation_numbers: + citation_numbers[source_key] = len(citation_numbers) + 1 + used_keys.append(source_key) + return f"[{citation_numbers[source_key]}]" + + # First, expand malformed multi-citation patterns like [@key1; @key2; @key3] + # into separate citations [@key1][@key2][@key3] + def _expand_multi_citations(match: re.Match[str]) -> str: + content = match.group(1) + # Split by semicolon and extract individual keys + parts = [part.strip() for part in content.split(";")] + expanded = [] + for part in parts: + # Remove leading @ if present + key = part.lstrip("@").strip() + if key: + expanded.append(f"[@{key}]") + return "".join(expanded) + + # Pattern to match malformed multi-citations: [@key1; @key2; @key3] + multi_citation_pattern = re.compile(r"\[@([^]]+;[^]]+)\]") + text = multi_citation_pattern.sub(_expand_multi_citations, report_body) + + # Now apply the standard single-citation replacement + text = _SOURCE_CITATION_PATTERN.sub(_replace, text) + text = re.sub(r"[ \t]+\n", "\n", text) + text = re.sub(r"\n{3,}", "\n\n", text).strip() + return text, used_keys + + +def render_sources_section(used_keys: list[str], source_catalog: dict[str, dict[str, Any]], heading: str = "## Sources") -> str: + """Render a canonical Sources section from cited source keys only.""" + lines = [heading, ""] + for index, source_key in enumerate(used_keys, start=1): + entry = source_catalog.get(source_key) + if not entry: + continue + meta_parts: list[str] = [] + authors = list(entry.get("authors", []) or []) + if authors: + meta_parts.append(", ".join(authors)) + if entry.get("venue"): + meta_parts.append(str(entry["venue"])) + if entry.get("year"): + meta_parts.append(str(entry["year"])) + if not meta_parts and entry.get("kind") != "paper": + meta_parts.append(entry.get("kind", "web")) + + meta_segment = f" | {' / '.join(meta_parts)}" if meta_parts else "" + lines.append(f"[{index}] {entry.get('title', '')}{meta_segment}: {entry.get('url', '')}") + if len(lines) == 2: + lines.append("No sources cited in the report body.") + return "\n".join(lines).strip() diff --git a/backend/src/modules/idea_gen/utils/report_source_handler.py b/backend/src/modules/idea_gen/utils/report_source_handler.py index aaa810c3..ff3324f0 100644 --- a/backend/src/modules/idea_gen/utils/report_source_handler.py +++ b/backend/src/modules/idea_gen/utils/report_source_handler.py @@ -1,12 +1,22 @@ """Report source URL handling and repair.""" +import hashlib import re from typing import Any +from urllib.parse import urlsplit + +from src.modules.idea_gen.utils.paper_cache_store import build_paper_key_from_record, normalize_url +from src.modules.idea_gen.utils.paper_normalization import normalize_authors, normalize_text +from src.modules.idea_gen.utils.state_utils import ( + get_compressed_papers, + get_final_papers_for_compress, + get_research_runs, +) def normalize_source_title(title: str) -> str: """Normalize a rendered source title for matching.""" cleaned = re.sub(r"\([^)]*\)\s*$", "", (title or "").strip()) - cleaned = re.sub(r"\s+", " ", cleaned) + cleaned = normalize_text(cleaned) return re.sub(r"[^a-z0-9]+", " ", cleaned.lower()).strip() @@ -50,6 +60,90 @@ def is_placeholder_report_url(url: str) -> bool: class ReportSourceHandler: """Handles report source URL selection and repair.""" + @staticmethod + def _normalize_authors(authors: Any) -> list[str]: + return normalize_authors(authors) + + @staticmethod + def _detect_source_type(payload: dict[str, Any], url: str) -> str: + query_type = str(payload.get("query_type") or "").strip().lower() + if query_type == "web": + domain = urlsplit(url).netloc.lower() + if any(host in domain for host in ("github.com", "gitlab.com", "huggingface.co")): + return "project" + if any(host in domain for host in ("medium.com", "substack.com", "dev.to")): + return "blog" + return "web" + + if ( + payload.get("paper_key") + or payload.get("doi") + or payload.get("arxivId") + or payload.get("openalex_id") + or payload.get("venue") + or payload.get("publication_year") + or payload.get("authors") + ): + return "paper" + + domain = urlsplit(url).netloc.lower() + if any(host in domain for host in ("github.com", "gitlab.com", "huggingface.co")): + return "project" + return "web" + + @staticmethod + def _choose_title(payload: dict[str, Any], url: str) -> str: + title = str(payload.get("title") or "").strip() + if title: + return title + return url or "Untitled source" + + @staticmethod + def _choose_summary(payload: dict[str, Any]) -> str: + for key in ("core_summary", "content", "snippet", "technical_depth", "empirical_support", "abstract"): + value = str(payload.get(key) or "").strip() + if value: + return value + return "" + + @staticmethod + def _build_identity(payload: dict[str, Any], source_type: str, title: str, url: str) -> str: + paper_key = str(payload.get("paper_key") or "").strip() + if source_type == "paper": + if not paper_key: + paper_key = build_paper_key_from_record(payload) + if paper_key: + return paper_key + + normalized_source_url = normalize_url(url) + if normalized_source_url: + digest = hashlib.sha256(normalized_source_url.encode("utf-8")).hexdigest()[:16] + return f"url:{digest}" + + title_key = normalize_source_title(title) + if title_key: + digest = hashlib.sha256(title_key.encode("utf-8")).hexdigest()[:16] + return f"title:{digest}" + + return "" + + @staticmethod + def _merge_entry(existing: dict[str, Any], incoming: dict[str, Any]) -> dict[str, Any]: + merged = dict(existing) + if incoming.get("priority", 0) > existing.get("priority", 0): + merged["url"] = incoming.get("url", merged.get("url", "")) + for key in ("title", "paper_key", "venue", "year", "query_topic", "source_type"): + if not merged.get(key) and incoming.get(key): + merged[key] = incoming[key] + if not merged.get("authors") and incoming.get("authors"): + merged["authors"] = incoming["authors"] + if len(str(incoming.get("summary") or "")) > len(str(merged.get("summary") or "")): + merged["summary"] = incoming.get("summary", "") + merged["priority"] = max(existing.get("priority", 0), incoming.get("priority", 0)) + merged["normalized_title"] = incoming.get("normalized_title") or existing.get("normalized_title", "") + merged["normalized_url"] = incoming.get("normalized_url") or existing.get("normalized_url", "") + return merged + @staticmethod def render_sources_lines(catalog: dict[str, dict[str, Any]]) -> list[str]: """Render a canonical Sources section from the resolved source catalog.""" @@ -101,15 +195,15 @@ def _upsert(title: str, payload: dict[str, Any]) -> None: "score": score, } - for paper in state.temp_data.get("final_papers_for_compress", []) or []: + for paper in get_final_papers_for_compress(state): if isinstance(paper, dict): _upsert(paper.get("title", ""), paper) - for paper in state.temp_data.get("compressed_papers", []) or []: + for paper in get_compressed_papers(state): if isinstance(paper, dict): _upsert(paper.get("title", ""), paper) - for run in state.temp_data.get("research_runs", []) or []: + for run in get_research_runs(state): for result in run.get("results", []) or []: if isinstance(result, dict): _upsert(result.get("title", ""), result) @@ -123,6 +217,215 @@ def _upsert(title: str, payload: dict[str, Any]) -> None: return catalog + @staticmethod + def build_source_catalog(state: Any) -> dict[str, dict[str, Any]]: + """Build a source catalog keyed by internal source ids for report generation.""" + catalog_by_identity: dict[str, dict[str, Any]] = {} + + def _upsert(payload: dict[str, Any], *, priority: int) -> None: + if not isinstance(payload, dict): + return + best_url, _ = ReportSourceHandler.choose_best_url(payload) + if not best_url: + best_url = normalize_report_url(str(payload.get("url") or "")) + if not best_url: + return + + title = ReportSourceHandler._choose_title(payload, best_url) + source_type = ReportSourceHandler._detect_source_type(payload, best_url) + identity = ReportSourceHandler._build_identity(payload, source_type, title, best_url) + if not identity: + return + paper_key = "" + if source_type == "paper": + paper_key = str(payload.get("paper_key") or build_paper_key_from_record(payload) or "").strip() + + entry = { + "title": title, + "url": best_url, + "paper_key": paper_key, + "authors": ReportSourceHandler._normalize_authors(payload.get("authors")), + "venue": str(payload.get("venue") or "").strip(), + "year": payload.get("publication_year") or payload.get("year"), + "summary": ReportSourceHandler._choose_summary(payload), + "priority": priority, + "query_topic": str(payload.get("query_topic") or "").strip(), + "source_type": source_type, + "normalized_title": normalize_source_title(title), + "normalized_url": normalize_url(best_url), + } + + existing = catalog_by_identity.get(identity) + if existing is None: + catalog_by_identity[identity] = entry + else: + catalog_by_identity[identity] = ReportSourceHandler._merge_entry(existing, entry) + + paper_pool = getattr(state, "paper_pool", None) + if paper_pool is not None and hasattr(paper_pool, "get_all_papers"): + try: + for paper in paper_pool.get_all_papers() or []: + _upsert(dict(paper), priority=40) + except Exception: + pass + + for paper in get_final_papers_for_compress(state): + if isinstance(paper, dict): + _upsert(paper, priority=100) + + for paper in get_compressed_papers(state): + if isinstance(paper, dict): + _upsert(paper, priority=95) + + for run in get_research_runs(state): + priority = 70 if run.get("query_type") == "paper" else 60 + for result in run.get("results", []) or []: + if isinstance(result, dict): + _upsert(result, priority=priority) + + for enriched in state.temp_data.get("enriched_papers", []) or []: + if not isinstance(enriched, dict): + continue + candidate = enriched.get("candidate") + if isinstance(candidate, dict): + _upsert(candidate, priority=50) + + ordered_items = sorted( + catalog_by_identity.values(), + key=lambda item: ( + 0 if item.get("source_type") == "paper" else 1, + -(int(item.get("priority", 0) or 0)), + str(item.get("title") or "").lower(), + ), + ) + + catalog: dict[str, dict[str, Any]] = {} + for index, item in enumerate(ordered_items, start=1): + source_id = f"S{index}" + catalog[source_id] = { + "source_id": source_id, + **item, + } + return catalog + + @staticmethod + def lookup_source_id( + catalog: dict[str, dict[str, Any]], + *, + paper_key: str = "", + url: str = "", + title: str = "", + ) -> str | None: + """Find source id by paper key, URL, or title.""" + normalized_paper_key = str(paper_key or "").strip() + normalized_url = normalize_url(url) if url else "" + normalized_title = normalize_source_title(title) if title else "" + + for source_id, item in catalog.items(): + if normalized_paper_key and item.get("paper_key") == normalized_paper_key: + return source_id + if normalized_url and item.get("normalized_url") == normalized_url: + return source_id + if normalized_title and item.get("normalized_title") == normalized_title: + return source_id + return None + + @staticmethod + def render_source_line(index: int, item: dict[str, Any]) -> str: + """Render one cited source line.""" + title = str(item.get("title") or "").strip() + url = normalize_report_url(str(item.get("url") or "")) + if not title or not url: + return "" + + metadata_parts: list[str] = [] + authors = list(item.get("authors", []) or []) + if authors: + shown = ", ".join(authors[:3]) + if len(authors) > 3: + shown += " et al." + metadata_parts.append(shown) + + venue = str(item.get("venue") or "").strip() + if venue: + metadata_parts.append(venue) + + year = item.get("year") + if year: + metadata_parts.append(str(year)) + + if not metadata_parts and item.get("source_type") != "paper": + metadata_parts.append(str(item.get("source_type") or "web")) + + metadata_suffix = f" | {' / '.join(metadata_parts)}" if metadata_parts else "" + return f"[{index}] {title}{metadata_suffix}: {url}" + + @staticmethod + def replace_inline_citations( + report: str, + catalog: dict[str, dict[str, Any]], + ) -> tuple[str, list[str], list[str]]: + """Replace internal source markers with numbered citations.""" + citation_pattern = re.compile(r"\[@(S\d+)\]") + used_source_ids: list[str] = [] + unknown_source_ids: list[str] = [] + + def _replace(match: re.Match[str]) -> str: + source_id = match.group(1) + if source_id not in catalog: + if source_id not in unknown_source_ids: + unknown_source_ids.append(source_id) + return "" + if source_id not in used_source_ids: + used_source_ids.append(source_id) + return f"[{used_source_ids.index(source_id) + 1}]" + + rendered = citation_pattern.sub(_replace, report) + rendered = re.sub(r"[ \t]+\n", "\n", rendered) + rendered = re.sub(r"\n{3,}", "\n\n", rendered).strip() + return rendered, used_source_ids, unknown_source_ids + + @staticmethod + def render_sources_section( + used_source_ids: list[str], + catalog: dict[str, dict[str, Any]], + *, + heading: str = "## Sources", + ) -> str: + """Render the final Sources section from actually used source ids.""" + lines = [heading, ""] + for index, source_id in enumerate(used_source_ids, start=1): + entry = catalog.get(source_id) + if not entry: + continue + rendered_line = ReportSourceHandler.render_source_line(index, entry) + if rendered_line: + lines.append(rendered_line) + if len(lines) == 2: + lines.append("No sources cited in the report body.") + return "\n".join(lines).strip() + + @staticmethod + def finalize_report( + report_body: str, + catalog: dict[str, dict[str, Any]], + *, + sources_heading: str = "## Sources", + ) -> tuple[str, list[str], list[str]]: + """Finalize inline citations and append Sources based on actual usage.""" + rendered_body, used_source_ids, unknown_source_ids = ReportSourceHandler.replace_inline_citations( + report_body, + catalog, + ) + sources_section = ReportSourceHandler.render_sources_section( + used_source_ids, + catalog, + heading=sources_heading, + ) + if sources_section: + rendered_body = f"{rendered_body}\n\n{sources_section}" + return rendered_body.strip(), used_source_ids, unknown_source_ids + @staticmethod def repair_sources(report: str, state: Any) -> str: """Repair the final Sources section using canonical URLs from state. diff --git a/backend/src/modules/idea_gen/utils/result_formatter.py b/backend/src/modules/idea_gen/utils/result_formatter.py index f550c1da..ce6262f3 100644 --- a/backend/src/modules/idea_gen/utils/result_formatter.py +++ b/backend/src/modules/idea_gen/utils/result_formatter.py @@ -1,7 +1,11 @@ """Result metadata formatting utilities for idea generation workflow.""" -import re from typing import Any +from src.modules.idea_gen.utils.paper_normalization import ( + extract_arxiv_id_from_url, + normalize_authors, +) + class ResultFormatter: """Format research result metadata for prompts.""" @@ -9,33 +13,13 @@ class ResultFormatter: @staticmethod def extract_arxiv_id(url: str) -> str: """Extract canonical arXiv identifier from a result URL.""" - match = re.search(r"arxiv\.org/(?:abs|pdf)/([^/?#]+)", url or "", re.IGNORECASE) - if not match: - return "" - identifier = match.group(1).strip() - if identifier.endswith(".pdf"): - identifier = identifier[:-4] - identifier = re.sub(r"v\d+$", "", identifier, flags=re.IGNORECASE) - return identifier + return extract_arxiv_id_from_url(url) @staticmethod def normalize_authors(authors: Any) -> list[dict[str, str]]: """Normalize mixed author result shapes to PaperCandidate format.""" - if isinstance(authors, list): - normalized: list[dict[str, str]] = [] - for author in authors: - if isinstance(author, dict): - name = author.get("name") or author.get("display_name") or "" - if name: - normalized.append({"name": str(name)}) - elif author: - normalized.append({"name": str(author)}) - return normalized - - if isinstance(authors, str): - return [{"name": part.strip()} for part in authors.split(",") if part.strip()] - - return [] + author_names = normalize_authors(authors) + return [{"name": name} for name in author_names] @staticmethod def build_metadata_lines(result: dict[str, Any]) -> list[str]: diff --git a/backend/src/modules/idea_gen/utils/state_utils.py b/backend/src/modules/idea_gen/utils/state_utils.py new file mode 100644 index 00000000..71259603 --- /dev/null +++ b/backend/src/modules/idea_gen/utils/state_utils.py @@ -0,0 +1,170 @@ +"""Shared state access helpers for idea_gen workflow.""" + +from __future__ import annotations + +from copy import deepcopy +from typing import Any + +from src.modules.idea_gen.schemas import WorkflowTempState +from src.modules.idea_gen.utils.query_state_manager import QueryStateManager + + +def temp_field_default(key: str) -> Any: + """Return the declared default for a typed temp field.""" + field_info = WorkflowTempState.model_fields.get(key) + if field_info is None: + return None + if field_info.default_factory is not None: + return field_info.default_factory() + return field_info.default + + +def get_temp_value(state: Any, key: str, default: Any = None) -> Any: + """Read typed temp state first, then fall back to legacy temp_data.""" + if hasattr(state, "temp") and hasattr(state.temp, key): + typed_value = getattr(state.temp, key) + legacy_has_value = key in getattr(state, "temp_data", {}) + if not legacy_has_value: + return typed_value + legacy_value = state.temp_data.get(key, default) + typed_default = temp_field_default(key) + if typed_value != typed_default or legacy_value == typed_default: + return typed_value + return legacy_value + return getattr(state, "temp_data", {}).get(key, default) + + +def set_temp_value(state: Any, key: str, value: Any) -> Any: + """Write runtime fields to typed temp state and mirror them into temp_data for compatibility.""" + copied = deepcopy(value) + if hasattr(state, "temp") and hasattr(state.temp, key): + setattr(state.temp, key, deepcopy(copied)) + state.temp_data[key] = deepcopy(copied) + return copied + + +def sync_query_state(state: Any) -> dict[str, Any]: + """Refresh query_state from QueryStateManager into typed temp state.""" + query_state = QueryStateManager.ensure_state(state) + set_temp_value(state, "query_state", query_state) + return query_state + + +def sync_query_feedback(state: Any, query_feedback: dict[str, Any]) -> None: + """Persist query feedback into typed temp state.""" + set_temp_value(state, "query_feedback", query_feedback) + + +def reset_supervisor_think_counters(state: Any, phase: str) -> None: + """Reset supervisor think counters for the given phase.""" + set_temp_value(state, "consecutive_thinks", 0) + set_temp_value(state, "total_thinks", 0) + set_temp_value(state, "think_phase", phase) + + +def ensure_supervisor_think_phase(state: Any, phase: str) -> None: + """Ensure think counters align with the active phase.""" + if get_temp_value(state, "think_phase", "broad") != phase: + reset_supervisor_think_counters(state, phase) + + +def current_research_phase(state: Any) -> str: + """Return the normalized research phase.""" + phase = str(get_temp_value(state, "research_phase", "broad") or "broad").strip().lower() + return phase if phase in {"broad", "evidence"} else "broad" + + +def phase_completion_action(phase: str) -> str: + """Return the default completion action for a phase.""" + return "BUILD_HYPOTHESIS" if phase == "evidence" else "GENERATE_SEEDS" + + +def get_phase_iteration_count(state: Any, phase: str | None = None) -> int: + """Return the iteration count for the given phase.""" + resolved_phase = phase or current_research_phase(state) + return state.evidence_iteration_count if resolved_phase == "evidence" else state.broad_iteration_count + + +def increment_phase_iteration_count(state: Any, phase: str | None = None) -> None: + """Increment the iteration counter for the given phase.""" + resolved_phase = phase or current_research_phase(state) + if resolved_phase == "evidence": + state.evidence_iteration_count += 1 + else: + state.broad_iteration_count += 1 + + +def needs_clarification(state: Any) -> bool: + """Return whether the workflow is waiting for clarification.""" + return bool(get_temp_value(state, "need_clarification", False)) + + +def get_clarification_question(state: Any) -> str: + """Return the clarification question if present.""" + return str(get_temp_value(state, "clarification_question", "") or "") + + +def get_supervisor_action(state: Any, default: str = "") -> str: + """Return the current supervisor action.""" + return str(get_temp_value(state, "supervisor_action", default) or default) + + +def get_final_papers_for_compress(state: Any) -> list[dict[str, Any]]: + """Return the final paper selection for compression.""" + return list(get_temp_value(state, "final_papers_for_compress", []) or []) + + +def set_final_papers_for_compress(state: Any, papers: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Persist the final paper selection into typed temp state.""" + return set_temp_value(state, "final_papers_for_compress", papers) + + +def get_compressed_papers(state: Any) -> list[dict[str, Any]]: + """Return compressed paper payloads.""" + return list(get_temp_value(state, "compressed_papers", []) or []) + + +def set_compressed_papers(state: Any, papers: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Persist compressed paper payloads into typed temp state.""" + return set_temp_value(state, "compressed_papers", papers) + + +def ensure_research_runs(state: Any) -> list[dict[str, Any]]: + """Ensure the explicit research_runs field exists and is initialized.""" + runs = getattr(state, "research_runs", None) + legacy_runs = deepcopy(getattr(state, "temp_data", {}).get("research_runs", []) or []) + if isinstance(runs, list) and (runs or not legacy_runs): + return runs + state.research_runs = legacy_runs + return state.research_runs + + +def get_research_runs(state: Any) -> list[dict[str, Any]]: + """Return research run records with legacy fallback.""" + runs = getattr(state, "research_runs", None) + if isinstance(runs, list) and runs: + return runs + if isinstance(runs, list): + legacy_runs = getattr(state, "temp_data", {}).get("research_runs", []) + if not legacy_runs: + return runs + return ensure_research_runs(state) + + +def append_research_run(state: Any, run: dict[str, Any]) -> None: + """Append a research run record to explicit state storage.""" + ensure_research_runs(state).append(deepcopy(run)) + + +def get_final_report(state: Any) -> str: + """Return the final report text with legacy fallback.""" + report = getattr(state, "final_report", "") + if report: + return str(report) + return str(getattr(state, "temp_data", {}).get("final_report", "") or "") + + +def set_final_report(state: Any, report: str) -> str: + """Persist the final report on explicit state.""" + state.final_report = str(report or "") + return state.final_report diff --git a/backend/src/modules/idea_gen/utils/supervisor_utils.py b/backend/src/modules/idea_gen/utils/supervisor_utils.py new file mode 100644 index 00000000..f19d605a --- /dev/null +++ b/backend/src/modules/idea_gen/utils/supervisor_utils.py @@ -0,0 +1,327 @@ +"""Supervisor and evidence-phase helper functions.""" + +from __future__ import annotations + +from typing import Any + +from src.modules.idea_gen.schemas import SupervisorDecision +from src.modules.idea_gen.utils.error_utils import handle_node_error +from src.modules.idea_gen.utils.normalizer_utils import ( + _normalize_query_key, + _normalize_query_text, +) +from src.modules.idea_gen.utils.state_utils import ( + get_temp_value, + phase_completion_action, + set_temp_value, +) + +EVIDENCE_LANES = {"inspiration", "novelty", "contradiction", "execution"} + + +def normalize_supervisor_task( + task_like: Any, + *, + default_reason: str = "", + default_seed_id: str = "", + default_lane: str = "", + phase: str = "broad", +) -> dict[str, str] | None: + """Normalize a supervisor follow-up task.""" + if isinstance(task_like, str): + topic = task_like + reason = default_reason + seed_id = default_seed_id + lane = default_lane + elif isinstance(task_like, dict): + topic = task_like.get("topic") or task_like.get("query") or task_like.get("text") or "" + reason = task_like.get("reason") or task_like.get("rationale") or default_reason + seed_id = task_like.get("seed_id") or default_seed_id + lane = task_like.get("lane") or default_lane + else: + return None + + normalized_topic = _normalize_query_text(topic) + if not normalized_topic: + return None + normalized_reason = _normalize_query_text(reason) + normalized_seed_id = _normalize_query_text(seed_id) + normalized_lane = _normalize_query_text(lane).lower() + if normalized_lane not in EVIDENCE_LANES: + normalized_lane = "" + if phase != "evidence": + normalized_seed_id = "" + normalized_lane = "" + return { + "topic": normalized_topic[:100], + "reason": normalized_reason, + "seed_id": normalized_seed_id, + "lane": normalized_lane, + } + + +def dedupe_supervisor_tasks(tasks: list[dict[str, str]], max_tasks: int) -> list[dict[str, str]]: + """Deduplicate supervisor tasks while preserving order.""" + deduped: list[dict[str, str]] = [] + seen: set[tuple[str, str, str]] = set() + for task in tasks: + normalized = normalize_supervisor_task( + task, + phase="evidence" if task.get("seed_id") or task.get("lane") else "broad", + ) + if not normalized: + continue + key = ( + normalized.get("seed_id", ""), + normalized.get("lane", ""), + _normalize_query_key(normalized.get("topic", "")), + ) + if not key[2] or key in seen: + continue + seen.add(key) + deduped.append(normalized) + if len(deduped) >= max_tasks: + break + return deduped + + +def tasks_to_topics(tasks: list[dict[str, str]]) -> list[str]: + """Project supervisor tasks to distinct query topics.""" + topics: list[str] = [] + for task in tasks: + topic = _normalize_query_text(task.get("topic", "")) + if topic and topic not in topics: + topics.append(topic) + return topics + + +def evidence_phase_status(state: Any) -> dict[str, Any]: + """Return evidence status in normalized dict form.""" + raw = get_temp_value(state, "evidence_status", {}) or {} + if isinstance(raw, dict): + return raw + return {"phase_ready": False, "seed_statuses": []} + + +def compact_evidence_status_for_supervisor(state: Any) -> list[dict[str, Any]]: + """Build a compact evidence summary for supervisor prompting.""" + status = evidence_phase_status(state) + compact: list[dict[str, Any]] = [] + for item in status.get("seed_statuses", []) or []: + raw_lane_quality = item.get("lane_quality", {}) or {} + compact.append( + { + "seed_id": item.get("seed_id", ""), + "lane_counts": item.get("lane_counts", {}), + "lane_targets": item.get("lane_targets", {}), + "unique_paper_count": item.get("unique_paper_count", 0), + "weighted_completeness": item.get("weighted_completeness", 0.0), + "ready_for_hypothesis": item.get("ready_for_hypothesis", False), + "blocking_lanes": list(item.get("blocking_lanes", []) or []), + "evidence_gaps": list(item.get("evidence_gaps", []) or []), + "suggested_topics": list(item.get("suggested_topics", []) or [])[:2], + "lane_quality": { + lane: { + "score": quality.get("score", 0.0), + "quality_ok": quality.get("quality_ok", False), + "issues": list(quality.get("issues", []) or [])[:2], + } + for lane, quality in raw_lane_quality.items() + }, + } + ) + return compact + + +def collect_broad_followup_tasks(state: Any, max_tasks: int) -> list[dict[str, str]]: + """Collect broad-phase gap-filling tasks.""" + query_feedback = get_temp_value(state, "query_feedback", {}) or {} + query_state = get_temp_value(state, "query_state", {}) or {} + missing_aspects = list(query_feedback.get("missing_aspects") or []) or list(query_state.get("missing_aspects") or []) + tasks: list[dict[str, str]] = [] + for aspect in missing_aspects: + normalized = normalize_supervisor_task( + {"topic": aspect, "reason": f"Fill broad-phase gap: {aspect}"}, + phase="broad", + ) + if normalized: + tasks.append(normalized) + return dedupe_supervisor_tasks(tasks, max_tasks) + + +def collect_evidence_followup_tasks(state: Any, max_tasks: int) -> list[dict[str, str]]: + """Collect evidence-phase follow-up tasks from blocked lanes.""" + status = evidence_phase_status(state) + tasks: list[dict[str, str]] = [] + for item in status.get("seed_statuses", []) or []: + if item.get("ready_for_hypothesis", False): + continue + seed_id = _normalize_query_text(item.get("seed_id", "")) + lane_quality = item.get("lane_quality", {}) or {} + lane_queries = item.get("lane_queries", {}) or {} + blocking_lanes = list(item.get("blocking_lanes", []) or []) + if not blocking_lanes: + blocking_lanes = [ + lane for lane, quality in lane_quality.items() + if not bool((quality or {}).get("quality_ok", False)) + ] + for lane in blocking_lanes: + lane_payload = lane_queries.get(lane, {}) or {} + candidate_queries = list(lane_payload.get("sparse_queries", []) or []) + candidate_queries.append(lane_payload.get("dense_query", "")) + topic = next((_normalize_query_text(query) for query in candidate_queries if _normalize_query_text(query)), "") + issues = list((lane_quality.get(lane) or {}).get("issues", []) or []) + reason = issues[0] if issues else "" + if not reason: + for gap in list(item.get("evidence_gaps", []) or []): + if lane in str(gap).lower(): + reason = str(gap) + break + normalized = normalize_supervisor_task( + {"topic": topic, "reason": reason, "seed_id": seed_id, "lane": lane}, + default_seed_id=seed_id, + default_lane=lane, + phase="evidence", + ) + if normalized: + tasks.append(normalized) + if len(tasks) >= max_tasks: + break + + if len(tasks) < max_tasks: + fallback_reason = next(iter(item.get("evidence_gaps", []) or []), "") + for topic in list(item.get("suggested_topics", []) or []): + normalized = normalize_supervisor_task( + {"topic": topic, "reason": fallback_reason, "seed_id": seed_id}, + default_seed_id=seed_id, + phase="evidence", + ) + if normalized: + tasks.append(normalized) + if len(tasks) >= max_tasks: + break + if len(tasks) >= max_tasks: + break + return dedupe_supervisor_tasks(tasks, max_tasks) + + +def collect_phase_followup_tasks(state: Any, max_tasks: int, phase: str | None = None) -> list[dict[str, str]]: + """Collect follow-up tasks for the current phase.""" + resolved_phase = phase or str(get_temp_value(state, "research_phase", "broad") or "broad") + if resolved_phase == "evidence": + return collect_evidence_followup_tasks(state, max_tasks) + return collect_broad_followup_tasks(state, max_tasks) + + +def fallback_supervisor_decision( + state: Any, + *, + phase: str, + phase_iteration_count: int, + max_topics: int, + error: Exception, + node: str, + message_template: str, +) -> str: + """Apply fallback supervisor action when model output fails.""" + fallback_tasks = collect_phase_followup_tasks(state, max_topics, phase) + + if phase == "broad" and phase_iteration_count == 0 and not fallback_tasks: + query_state = get_temp_value(state, "query_state", {}) or {} + active_paper_queries = query_state.get("active_paper_queries", []) or [] + active_web_queries = query_state.get("active_web_queries", []) or [] + if active_paper_queries or active_web_queries: + for query in (active_paper_queries + active_web_queries)[:max_topics]: + normalized = normalize_supervisor_task( + {"topic": query, "reason": "Initial broad research"}, + phase="broad", + ) + if normalized: + fallback_tasks.append(normalized) + print(f"[NODE] supervisor - Generated {len(fallback_tasks)} initial research tasks from active queries") + + fallback = "CONDUCT_RESEARCH" if fallback_tasks else phase_completion_action(phase) + handle_node_error( + state, + node=node, + error=error, + recoverable=True, + message=message_template.format(fallback=fallback), + ) + set_temp_value(state, "supervisor_action", fallback) + set_temp_value( + state, + "supervisor_tasks", + fallback_tasks[:max_topics] if fallback == "CONDUCT_RESEARCH" else [], + ) + set_temp_value( + state, + "supervisor_topics", + tasks_to_topics(fallback_tasks[:max_topics]) if fallback == "CONDUCT_RESEARCH" else [], + ) + return fallback + + +def apply_supervisor_decision( + state: Any, + *, + decision: SupervisorDecision, + phase: str, + phase_iteration_count: int, + max_topics: int, +) -> tuple[str, list[dict[str, str]]]: + """Apply validated supervisor decision to workflow state.""" + action = decision.action + raw_tasks = [ + task.model_dump() if hasattr(task, "model_dump") else dict(task) + for task in list(decision.tasks)[:max_topics] + ] + + if action == "RESEARCH_COMPLETE": + action = phase_completion_action(phase) + print(f"[NODE] supervisor - Redirected RESEARCH_COMPLETE -> {action} (phase={phase})") + + cleaned_tasks: list[dict[str, str]] = [] + for task in raw_tasks: + normalized = normalize_supervisor_task( + task, + default_reason=decision.reflection, + phase=phase, + ) + if normalized: + cleaned_tasks.append(normalized) + cleaned_tasks = dedupe_supervisor_tasks(cleaned_tasks, max_topics) + cleaned_topics = tasks_to_topics(cleaned_tasks) + + if phase == "evidence" and action == "BUILD_HYPOTHESIS": + status = evidence_phase_status(state) + if not status.get("phase_ready", False): + fallback_tasks = collect_phase_followup_tasks(state, max_topics, phase) + action = "CONDUCT_RESEARCH" if fallback_tasks else "THINK" + cleaned_tasks = fallback_tasks if fallback_tasks else [] + cleaned_topics = tasks_to_topics(cleaned_tasks) + print(f"[NODE] supervisor - Overrode BUILD_HYPOTHESIS -> {action} (evidence status not ready)") + + if action == "THINK": + cleaned_tasks = [] + cleaned_topics = [] + + set_temp_value(state, "supervisor_action", action) + set_temp_value(state, "supervisor_tasks", cleaned_tasks) + set_temp_value(state, "supervisor_topics", cleaned_topics) + state.supervisor_messages.append( + { + "action": action, + "reflection": decision.reflection, + "tasks": cleaned_tasks, + "topics": cleaned_topics, + "phase": phase, + "phase_iteration": phase_iteration_count, + } + ) + if action == "THINK": + set_temp_value(state, "consecutive_thinks", int(get_temp_value(state, "consecutive_thinks", 0) or 0) + 1) + set_temp_value(state, "total_thinks", int(get_temp_value(state, "total_thinks", 0) or 0) + 1) + else: + set_temp_value(state, "consecutive_thinks", 0) + return action, cleaned_tasks diff --git a/backend/src/modules/idea_gen/utils/thread_pool_manager.py b/backend/src/modules/idea_gen/utils/thread_pool_manager.py new file mode 100644 index 00000000..7d814f50 --- /dev/null +++ b/backend/src/modules/idea_gen/utils/thread_pool_manager.py @@ -0,0 +1,125 @@ +"""Global thread pool manager for workflow operations.""" + +from __future__ import annotations + +import atexit +import logging +import threading +from concurrent.futures import ThreadPoolExecutor +from contextvars import copy_context +from typing import Any, Callable + +from src.modules.idea_gen.config import get_paper_processing_config, get_workflow_config + +logger = logging.getLogger(__name__) + + +class ThreadPoolManager: + """Singleton thread pool manager with explicit pool ownership by workload type.""" + + _instance = None + _lock = threading.Lock() + _initialized = False + _pools: dict[str, ThreadPoolExecutor] = {} + _pool_sizes: dict[str, int] = {} + + def __new__(cls): + if cls._instance is None: + with cls._lock: + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + def __init__(self): + if not ThreadPoolManager._initialized: + with ThreadPoolManager._lock: + if not ThreadPoolManager._initialized: + ThreadPoolManager._initialized = True + atexit.register(self._cleanup_on_exit) + + @classmethod + def _cleanup_on_exit(cls): + try: + cls.shutdown(wait=True) + except Exception as exc: + logger.error("Error during thread pool cleanup: %s", exc) + + @classmethod + def _desired_sizes(cls) -> dict[str, int]: + workflow_config = get_workflow_config() + paper_config = get_paper_processing_config() + llm_limit = max(1, int(workflow_config.llm_max_concurrency)) + pdf_limit = max( + 1, + int( + getattr(paper_config, "pdf_parse_max_concurrency", 0) + or getattr(paper_config, "pdf_parse_max_workers", 0) + or 1 + ), + ) + web_limit = max(1, int(workflow_config.web_fetch_max_concurrency)) + io_limit = max(workflow_config.search_max_concurrency, web_limit, pdf_limit, 4) + compute_limit = max(llm_limit, 4) + return { + "io": io_limit, + "compute": compute_limit, + "web_fetch": web_limit, + "pdf_parse": pdf_limit, + } + + @classmethod + def _get_pool(cls, name: str, *, prefix: str) -> ThreadPoolExecutor: + cls() + desired_size = cls._desired_sizes()[name] + with cls._lock: + existing = cls._pools.get(name) + existing_size = cls._pool_sizes.get(name) + if existing is not None and existing_size == desired_size: + return existing + if existing is not None: + existing.shutdown(wait=True) + pool = ThreadPoolExecutor(max_workers=desired_size, thread_name_prefix=prefix) + cls._pools[name] = pool + cls._pool_sizes[name] = desired_size + logger.info("Created %s thread pool with %s workers", name, desired_size) + return pool + + @classmethod + def get_io_pool(cls) -> ThreadPoolExecutor: + """Get the general IO pool used by legacy async wrappers.""" + return cls._get_pool("io", prefix="workflow-io") + + @classmethod + def get_compute_pool(cls) -> ThreadPoolExecutor: + """Get the compute/LLM task pool.""" + return cls._get_pool("compute", prefix="workflow-compute") + + @classmethod + def get_web_fetch_pool(cls) -> ThreadPoolExecutor: + """Get the pool dedicated to webpage fetch/summarization work.""" + return cls._get_pool("web_fetch", prefix="workflow-web") + + @classmethod + def get_pdf_parse_pool(cls) -> ThreadPoolExecutor: + """Get the pool dedicated to PDF parsing work.""" + return cls._get_pool("pdf_parse", prefix="workflow-pdf") + + @classmethod + def shutdown(cls, wait: bool = True): + with cls._lock: + for name, pool in list(cls._pools.items()): + pool.shutdown(wait=wait) + logger.info("Shutdown %s thread pool", name) + cls._pools = {} + cls._pool_sizes = {} + cls._initialized = False + + @classmethod + def submit_io_task(cls, fn: Callable, *args, **kwargs) -> Any: + context = copy_context() + return cls.get_io_pool().submit(context.run, fn, *args, **kwargs) + + @classmethod + def submit_compute_task(cls, fn: Callable, *args, **kwargs) -> Any: + context = copy_context() + return cls.get_compute_pool().submit(context.run, fn, *args, **kwargs) diff --git a/backend/src/modules/idea_gen/utils/workflow_helpers.py b/backend/src/modules/idea_gen/utils/workflow_helpers.py index 19d189cb..c7296390 100644 --- a/backend/src/modules/idea_gen/utils/workflow_helpers.py +++ b/backend/src/modules/idea_gen/utils/workflow_helpers.py @@ -1,36 +1,68 @@ -"""Helper functions extracted from idea_gen.workflow.""" +"""Compatibility facade for legacy workflow helper imports.""" from __future__ import annotations import json -import re from datetime import datetime from pathlib import Path from typing import Any -from urllib.parse import urlparse -from src.modules.idea_gen.config import get_paper_processing_config -from src.modules.idea_gen.utils import query_utils -from src.modules.idea_gen.utils.domain_classifier import DomainClassifier -from src.modules.idea_gen.utils.drift_detector import DriftDetector -from src.modules.idea_gen.utils.paper_cache_store import ( - PaperCacheStore, - build_paper_key, - build_paper_key_from_record, - normalize_doi, - normalize_url, +from src.modules.idea_gen.utils.artifact_utils import get_output_dir as _artifact_get_output_dir +from src.modules.idea_gen.utils.cache_utils import _create_paper_cache_store +from src.modules.idea_gen.utils.compression_utils import ( + _compress_section_content, + _compress_single_paper, + _get_or_parse_paper_for_compress, + _parse_with_fallback, + _parse_with_semaphore, ) -from src.modules.idea_gen.utils.query_state_manager import QueryStateManager +from src.modules.idea_gen.utils.critic_state_utils import _finalize_critic_state +from src.modules.idea_gen.utils.normalizer_utils import ( + _normalize_arxiv_identifier, + _normalize_direct_download_url, + _normalize_openalex_identifier, + _normalize_query_key, + _normalize_query_text, + _normalize_relevance_score, +) +from src.modules.idea_gen.utils.paper_utils import ( + _build_arxiv_abs_url, + _build_arxiv_pdf_url, + _build_doi_url, + _build_openalex_work_url, + _build_semantic_scholar_url, + _complete_final_paper_sources, + _extract_domain, + _is_selected_for_pdf, + _label_relevance, + _lookup_openalex_access_paths, + _lookup_semantic_scholar_access_paths, + _paper_ranking_sort_key, + _resolve_download_source_url, + _resolve_public_paper_url, +) +from src.modules.idea_gen.utils.query_planning_utils import ( + _build_query_feedback_v1, + _build_role_feedback_entry, + _infer_coverage_summary, + _infer_coverage_summary_from_selected_papers, + _missing_aspects_from_coverage, + _needs_web_query, + _plan_to_queries, + _query_generation_mode, + _query_role_lookup, + _summarize_query_feedback_for_prompt, +) +from src.modules.idea_gen.utils.state_utils import get_final_report, get_research_runs def _get_output_dir(state: Any) -> Path: - """Get output directory for this run.""" - base = Path(__file__).resolve().parents[4] / "output" / "idea_gen" - return base / state.thread_id if state.thread_id else base / "default" + """Compatibility wrapper for artifact path lookup.""" + return _artifact_get_output_dir(state) def _save_artifact(state: Any, filename: str, content: str): - """Save artifact to output directory.""" + """Compatibility wrapper that keeps monkeypatch-friendly output-dir lookup.""" output_dir = _get_output_dir(state) output_dir.mkdir(parents=True, exist_ok=True) output_path = output_dir / filename @@ -39,20 +71,22 @@ def _save_artifact(state: Any, filename: str, content: str): def _save_node_state(state: Any, node_name: str): - """Save node state and output to JSON.""" + """Compatibility wrapper that keeps monkeypatch-friendly output-dir lookup.""" output_dir = _get_output_dir(state) output_dir.mkdir(parents=True, exist_ok=True) - def serialize_value(v): - if isinstance(v, datetime): - return v.isoformat() - if isinstance(v, set): - return list(v) - if isinstance(v, dict): - return {k: serialize_value(val) for k, val in v.items()} - if isinstance(v, list): - return [serialize_value(item) for item in v] - return v + def serialize_value(value: Any): + if isinstance(value, datetime): + return value.isoformat() + if hasattr(value, "model_dump"): + return serialize_value(value.model_dump()) + if isinstance(value, set): + return list(value) + if isinstance(value, dict): + return {key: serialize_value(item) for key, item in value.items()} + if isinstance(value, list): + return [serialize_value(item) for item in value] + return value state_data = { "node": node_name, @@ -63,7 +97,11 @@ def serialize_value(v): "web_queries": state.web_queries, "notes": state.notes, "messages": state.messages[-5:] if state.messages else [], - "temp_data": {k: serialize_value(v) for k, v in state.temp_data.items() if k != "final_report"}, + "temp": serialize_value(getattr(state, "temp", None)), + "temp_data": {key: serialize_value(value) for key, value in state.temp_data.items()}, + "node_errors": serialize_value(getattr(state, "node_errors", [])), + "research_runs_count": len(get_research_runs(state)), + "final_report_chars": len(get_final_report(state)), } state_path = output_dir / f"node_{node_name}_state.json" @@ -71,1341 +109,45 @@ def serialize_value(v): print(f"[STATE] Saved {node_name} state to {state_path}") -def _normalize_query_text(query: Any) -> str: - """Normalize a generated query to a clean single-line string.""" - if not isinstance(query, str): - return "" - return re.sub(r"\s+", " ", query.replace("\n", " ")).strip() - - -def _normalize_query_key(query: str) -> str: - """Build a stable dedup key for queries.""" - return " ".join(re.findall(r"[a-z0-9]+", (query or "").lower())) - - -def _extract_domain(url: str) -> str: - """Extract a normalized domain from a result URL.""" - try: - netloc = urlparse(url or "").netloc.lower() - except Exception: - return "" - return netloc[4:] if netloc.startswith("www.") else netloc - - -def _create_paper_cache_store() -> PaperCacheStore | None: - """Create the shared paper cache store for the current process.""" - return PaperCacheStore.create_default() - - -def _plan_to_queries(plan: list[dict[str, Any]], *, limit: int = 3) -> list[str]: - """Convert a structured query plan into plain query strings.""" - return query_utils.plan_to_queries(plan, limit=limit) - - -def _query_role_lookup(plan: list[dict[str, Any]]) -> dict[str, dict[str, Any]]: - """Build normalized query -> plan item lookup.""" - lookup: dict[str, dict[str, Any]] = {} - for item in plan or []: - if not isinstance(item, dict): - continue - query = _normalize_query_text(item.get("query", "")) - key = _normalize_query_key(query) - if key and key not in lookup: - lookup[key] = item - return lookup - - -def _build_role_feedback_entry( - role: str, - channel: str, - plan_item: dict[str, Any] | None, - feedback_entry: dict[str, Any] | None, -) -> dict[str, Any]: - """Summarize role-specific execution feedback for planning.""" - if not plan_item: - return { - "role": role, - "channel": channel, - "query": "", - "status": "missing", - "recommendation": "generate", - "evidence": {}, - } - - feedback_entry = feedback_entry or {} - status = "missing" - recommendation = "review" - if channel == "paper": - judgement = feedback_entry.get("judgement", "") - selected_ratio = feedback_entry.get("selected_ratio", 0.0) - if judgement == "retain": - if role == "anchor": - status = "stable" - recommendation = "retain" - elif role == "exploit": - status = "promising" - recommendation = "retain" - else: - status = "covered" - recommendation = "retain" - elif judgement == "tighten": - status = "weak_signal" - recommendation = "tighten" - elif judgement == "drop": - status = "failed" - recommendation = "replace" - elif selected_ratio: - status = "weak_signal" - recommendation = "tighten" - else: - judgement = feedback_entry.get("judgement", "") - trusted_ratio = feedback_entry.get("trusted_domain_ratio", 0.0) - if judgement == "retain" and trusted_ratio >= 0.25: - status = "useful" - recommendation = "retain" - elif judgement == "retarget_sources": - status = "noisy" - recommendation = "retarget_sources" - elif judgement == "tighten": - status = "weak_signal" - recommendation = "tighten" - - return { - "role": role, - "channel": channel, - "query": plan_item.get("query", ""), - "status": status, - "recommendation": recommendation, - "evidence": feedback_entry, - } - - -def _is_selected_for_pdf(result: dict[str, Any]) -> bool: - """Return whether a result ultimately contributed to PDF parsing.""" - return bool( - result.get("final_selected_for_pdf") - or result.get("global_selected_for_pdf") - or result.get("rerank_selected_for_pdf") - ) - - -def _label_relevance(score: float) -> str: - """Bucket a numeric relevance score into a coarse label.""" - if score >= 0.6: - return "high" - if score >= 0.42: - return "medium" - return "low" - - -def _infer_coverage_summary(latest_runs: list[dict[str, Any]]) -> dict[str, bool]: - """Infer coarse evidence coverage from latest selected paper/web results.""" - selected_text_parts: list[str] = [] - recent_year_found = False - for run in latest_runs: - for result in run.get("results", []) or []: - if not _is_selected_for_pdf(result) and run.get("query_type") != "web": - continue - selected_text_parts.extend( - [ - str(result.get("title", "")), - str(result.get("snippet") or result.get("content") or ""), - str(result.get("primary_topic", "")), - " ".join(result.get("topics") or []), - ] - ) - year = result.get("publication_year") - if isinstance(year, int) and year >= datetime.now().year - 1: - recent_year_found = True - - combined = " ".join(selected_text_parts).lower() - return { - "method_evidence": any( - token in combined - for token in ( - "method", - "architecture", - "framework", - "retrieval", - "episodic", - "semantic", - "memory management", - "cache", - "mechanism", - ) - ), - "benchmark_evidence": any( - token in combined - for token in ( - "benchmark", - "dataset", - "evaluation", - "experiment", - "performance", - "leaderboard", - "task", - "metrics", - ) - ), - "limitation_evidence": any( - token in combined - for token in ( - "limitation", - "failure", - "bottleneck", - "challenge", - "weakness", - "constraint", - "error", - ) - ), - "recent_work_evidence": recent_year_found, - } - - -def _missing_aspects_from_coverage( - coverage_summary: dict[str, bool], - query_feedback: list[dict[str, Any]], - web_feedback: list[dict[str, Any]], -) -> list[str]: - """Translate coarse coverage booleans into actionable missing aspects.""" - missing: list[str] = [] - if not coverage_summary.get("method_evidence"): - missing.append("method evidence for the core task") - if not coverage_summary.get("benchmark_evidence"): - missing.append("benchmark, evaluation, or dataset evidence") - if not coverage_summary.get("limitation_evidence"): - missing.append("limitations, bottlenecks, or failure modes") - if not coverage_summary.get("recent_work_evidence"): - missing.append("recent work coverage") - - if any(item.get("social_noise_ratio", 0.0) >= 0.5 for item in web_feedback): - missing.append("trusted web sources for survey or benchmark coverage") - - if any(item.get("selected_count", 0) == 0 for item in query_feedback): - missing.append("higher precision paper retrieval for the core target") - - deduped: list[str] = [] - seen: set[str] = set() - for aspect in missing: - key = aspect.strip().lower() - if key and key not in seen: - seen.add(key) - deduped.append(aspect) - return deduped - - -def _infer_coverage_summary_from_selected_papers(papers: list[dict[str, Any]]) -> dict[str, bool]: - """Reuse coverage heuristics over the final selected paper set.""" - synthetic_run = { - "query_type": "paper", - "results": [{**paper, "final_selected_for_pdf": True} for paper in papers], - } - return _infer_coverage_summary([synthetic_run]) - - -def _needs_web_query(aspects: list[str]) -> bool: - """Return whether the current missing aspects call for a web query.""" - tokens = " ".join(aspects).lower() - return any(token in tokens for token in ("survey", "benchmark", "dataset", "leaderboard", "recent work", "trusted web")) - - -def _normalize_arxiv_identifier(value: str) -> str: - """Normalize arXiv IDs so they can be reused across pipeline stages.""" - normalized = str(value or "").strip() - if not normalized: - return "" - if normalized.startswith("http"): - normalized = normalized.rstrip("/").split("/")[-1] - if normalized.startswith("arXiv:"): - normalized = normalized.split("arXiv:", 1)[1] - if normalized.endswith(".pdf"): - normalized = normalized[:-4] - if "v" in normalized and normalized.rsplit("v", 1)[-1].isdigit(): - normalized = normalized.rsplit("v", 1)[0] - return normalized - - -def _normalize_openalex_identifier(value: str) -> str: - """Normalize OpenAlex work IDs.""" - normalized = str(value or "").strip() - if not normalized: - return "" - if "/" in normalized: - normalized = normalized.rstrip("/").split("/")[-1] - return normalized - - -def _build_arxiv_abs_url(arxiv_id: str) -> str: - normalized = _normalize_arxiv_identifier(arxiv_id) - return f"https://arxiv.org/abs/{normalized}" if normalized else "" - - -def _build_arxiv_pdf_url(arxiv_id: str) -> str: - normalized = _normalize_arxiv_identifier(arxiv_id) - return f"https://arxiv.org/pdf/{normalized}.pdf" if normalized else "" - - -def _build_openalex_work_url(openalex_id: str) -> str: - normalized = _normalize_openalex_identifier(openalex_id) - return f"https://openalex.org/{normalized}" if normalized else "" - - -def _build_semantic_scholar_url(paper_id: str) -> str: - normalized = str(paper_id or "").strip() - return f"https://www.semanticscholar.org/paper/{normalized}" if normalized else "" - - -def _build_doi_url(doi: str) -> str: - normalized = normalize_doi(doi) - return f"https://doi.org/{normalized}" if normalized else "" - - -def _resolve_public_paper_url(paper: dict[str, Any]) -> str: - """Resolve the best public-facing URL for a paper.""" - existing = str(paper.get("source_url") or paper.get("url") or "").strip() - if existing: - return existing - - identifiers = paper.get("identifiers") or {} - arxiv_id = _normalize_arxiv_identifier(paper.get("arxivId") or identifiers.get("arxiv", "")) - if arxiv_id: - return _build_arxiv_abs_url(arxiv_id) - - openalex_id = _normalize_openalex_identifier( - paper.get("openalex_id") or identifiers.get("openalex", "") or paper.get("paper_id", "") - ) - if openalex_id.startswith("W"): - return _build_openalex_work_url(openalex_id) - - semantic_paper_id = str(paper.get("paper_id") or "").strip() - if paper.get("source") == "semantic_scholar_citation" and semantic_paper_id: - return _build_semantic_scholar_url(semantic_paper_id) - - oa_url = str(paper.get("oa_url") or "").strip() - if oa_url: - return oa_url - - pdf_url = str(paper.get("pdf_url") or "").strip() - if pdf_url: - return pdf_url - - return "" - - -def _normalize_direct_download_url(candidate_url: str) -> str: - """Normalize only URLs that are likely directly downloadable paper sources.""" - normalized_candidate = normalize_url(str(candidate_url or "").strip()) - if not normalized_candidate: - return "" - - parsed = urlparse(normalized_candidate) - scheme = (parsed.scheme or "").lower() - host = (parsed.netloc or "").lower() - path = parsed.path or "" - lower_path = path.lower() - lowered = normalized_candidate.lower() - - if scheme not in {"http", "https"}: - return "" - - if "arxiv.org" in host: - arxiv_id = _normalize_arxiv_identifier(normalized_candidate) - return _build_arxiv_pdf_url(arxiv_id) if arxiv_id else "" - - if lower_path.endswith(".pdf"): - return normalized_candidate - - if "content.openalex.org" in host: - pdf_path = path if lower_path.endswith(".pdf") else f"{path.rstrip('/')}.pdf" - return parsed._replace(path=pdf_path).geturl() - - if "/pdf/" in lower_path or re.search(r"(^|[/?=&._-])pdf([/?=&._-]|$)", lowered): - return normalized_candidate - - return "" - - -def _resolve_download_source_url(paper: dict[str, Any]) -> str: - """Resolve the source that should be used for downloading/parsing a paper.""" - explicit_source = _normalize_direct_download_url(str(paper.get("download_source_url") or "").strip()) - if explicit_source: - return explicit_source - - pdf_url = _normalize_direct_download_url(str(paper.get("pdf_url") or "").strip()) - if pdf_url: - return pdf_url - - identifiers = paper.get("identifiers") or {} - arxiv_id = _normalize_arxiv_identifier(paper.get("arxivId") or identifiers.get("arxiv", "")) - if arxiv_id: - return _build_arxiv_pdf_url(arxiv_id) - - oa_url = _normalize_direct_download_url(str(paper.get("oa_url") or "").strip()) - if oa_url: - return oa_url - - public_url = _normalize_direct_download_url(str(paper.get("url") or paper.get("source_url") or "").strip()) - if public_url: - return public_url - - return "" - - -def _lookup_openalex_access_paths(openalex_id: str) -> dict[str, str]: - """Resolve public and downloadable URLs from OpenAlex metadata.""" - normalized = _normalize_openalex_identifier(openalex_id) - if not normalized.startswith("W"): - return {} - - try: - import httpx - - response = httpx.get(f"https://api.openalex.org/works/{normalized}", timeout=20.0) - response.raise_for_status() - payload = response.json() - except Exception: - return {} - - open_access = payload.get("open_access") or {} - primary_location = payload.get("primary_location") or {} - ids = payload.get("ids") or {} - - doi = str(payload.get("doi") or ids.get("doi") or "").strip() - doi_url = _build_doi_url(doi) - oa_url = str(open_access.get("oa_url") or "").strip() - pdf_url = _normalize_direct_download_url(str(primary_location.get("pdf_url") or "").strip()) - if not pdf_url: - pdf_url = _normalize_direct_download_url(oa_url) - public_url = str(payload.get("id") or ids.get("openalex") or "").strip() or _build_openalex_work_url(normalized) - if public_url and not public_url.startswith("http"): - public_url = _build_openalex_work_url(public_url) - - return { - "openalex_id": normalized, - "doi": normalize_doi(doi), - "doi_url": doi_url, - "oa_url": oa_url, - "pdf_url": pdf_url, - "source_url": public_url, - } - - -def _lookup_semantic_scholar_access_paths(paper_id: str) -> dict[str, str]: - """Resolve public and downloadable URLs from Semantic Scholar metadata.""" - normalized = str(paper_id or "").strip() - if not normalized: - return {} - - try: - import httpx - - response = httpx.get( - f"https://api.semanticscholar.org/graph/v1/paper/{normalized}", - params={"fields": "openAccessPdf,url,externalIds"}, - timeout=20.0, - ) - response.raise_for_status() - payload = response.json() - except Exception: - payload = {} - - external_ids = payload.get("externalIds") or {} - arxiv_id = _normalize_arxiv_identifier(external_ids.get("ArXiv", "")) - doi = normalize_doi(str(external_ids.get("DOI") or "")) - pdf_url = _normalize_direct_download_url(str((payload.get("openAccessPdf") or {}).get("url") or "").strip()) - if not pdf_url and arxiv_id: - pdf_url = _build_arxiv_pdf_url(arxiv_id) - - public_url = str(payload.get("url") or "").strip() or _build_semantic_scholar_url(normalized) - return { - "paper_id": normalized, - "arxivId": arxiv_id, - "doi": doi, - "doi_url": _build_doi_url(doi), - "pdf_url": pdf_url, - "source_url": public_url, - } - - -def _normalize_relevance_score(score: Any) -> float: - """Normalize 0-10 or 0-1 scores into a 0-1 range for feedback heuristics.""" - if not isinstance(score, (int, float)): - return 0.0 - numeric = float(score) - if numeric <= 1.0: - return max(0.0, min(1.0, numeric)) - return max(0.0, min(1.0, numeric / 10.0)) - - -def _paper_ranking_sort_key(paper: dict[str, Any]) -> tuple[float, float, int, str]: - """Stable deterministic ranking key for paper-pool ordering.""" - relevance_score = float(paper.get("relevance_score", 0.0) or 0.0) - rerank_score = float(paper.get("rerank_score") or paper.get("final_score") or 0.0) - cited_by_count = int(paper.get("cited_by_count") or paper.get("citationCount") or 0) - title = str(paper.get("title", "") or "").strip().lower() - return (-relevance_score, -rerank_score, -cited_by_count, title) - - -def _complete_final_paper_sources(paper: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any]]: - """Resolve and persist source metadata on a final selected paper record.""" - completed = dict(paper) - debug = { - "paper_key": completed.get("paper_key", ""), - "title": completed.get("title", ""), - "attempts": [], - "status": "pending", - "selected_strategy": "", - "download_source_url": "", - "source_url": "", - "failure_reason": "", - } - - def _record_attempt(strategy: str, status: str, value: str = "", reason: str = "") -> None: - debug["attempts"].append( - { - "strategy": strategy, - "status": status, - "value": value, - "reason": reason, - } - ) - - source_url = str(completed.get("source_url") or _resolve_public_paper_url(completed) or "").strip() - if source_url: - completed["source_url"] = source_url - completed["url"] = str(completed.get("url") or source_url).strip() - - download_source_url = "" - selected_strategy = "" - - explicit_download_raw = str(completed.get("download_source_url") or "").strip() - explicit_download = _normalize_direct_download_url(explicit_download_raw) - if explicit_download: - download_source_url = explicit_download - selected_strategy = "explicit_download_source_url" - _record_attempt(selected_strategy, "success", download_source_url) - elif explicit_download_raw: - _record_attempt( - "explicit_download_source_url", - "invalid", - explicit_download_raw, - reason="not_direct_downloadable", - ) - else: - _record_attempt("explicit_download_source_url", "missing") - - if not download_source_url: - pdf_url_raw = str(completed.get("pdf_url") or "").strip() - pdf_url = _normalize_direct_download_url(pdf_url_raw) - if pdf_url: - download_source_url = pdf_url - selected_strategy = "explicit_pdf_url" - _record_attempt(selected_strategy, "success", pdf_url) - elif pdf_url_raw: - _record_attempt("explicit_pdf_url", "invalid", pdf_url_raw, reason="not_direct_downloadable") - else: - _record_attempt("explicit_pdf_url", "missing") - - if not download_source_url: - identifiers = completed.get("identifiers") or {} - arxiv_id = _normalize_arxiv_identifier(completed.get("arxivId") or identifiers.get("arxiv", "")) - if arxiv_id: - download_source_url = _build_arxiv_pdf_url(arxiv_id) - completed["arxivId"] = arxiv_id - selected_strategy = "arxiv_pdf" - _record_attempt(selected_strategy, "success", download_source_url) - else: - _record_attempt("arxiv_pdf", "missing") - - if not download_source_url: - identifiers = completed.get("identifiers") or {} - openalex_id = _normalize_openalex_identifier( - completed.get("openalex_id") or identifiers.get("openalex", "") or completed.get("paper_id", "") - ) - if openalex_id.startswith("W"): - openalex_paths = _lookup_openalex_access_paths(openalex_id) - if openalex_paths.get("source_url"): - if not completed.get("source_url"): - completed["source_url"] = openalex_paths["source_url"] - if not completed.get("url"): - completed["url"] = openalex_paths["source_url"] - if openalex_paths.get("oa_url"): - completed["oa_url"] = openalex_paths["oa_url"] - if openalex_paths.get("pdf_url"): - completed["pdf_url"] = openalex_paths["pdf_url"] - download_source_url = openalex_paths["pdf_url"] - selected_strategy = "openalex_lookup_pdf" - _record_attempt(selected_strategy, "success", download_source_url) - else: - openalex_oa_download = _normalize_direct_download_url(openalex_paths.get("oa_url", "")) - if openalex_oa_download: - download_source_url = openalex_oa_download - completed["pdf_url"] = str(completed.get("pdf_url") or openalex_oa_download) - selected_strategy = "openalex_lookup_oa" - _record_attempt(selected_strategy, "success", download_source_url) - elif openalex_paths.get("oa_url"): - _record_attempt( - "openalex_lookup", - "empty", - openalex_paths["oa_url"], - reason="oa_url_not_direct_downloadable", - ) - else: - _record_attempt("openalex_lookup", "empty", reason="no_oa_or_pdf_url") - if openalex_paths.get("doi") and not completed.get("doi"): - completed["doi"] = openalex_paths["doi"] - identifiers["doi"] = openalex_paths["doi"] - completed["openalex_id"] = openalex_id - identifiers["openalex"] = openalex_id - completed["identifiers"] = identifiers - else: - _record_attempt("openalex_lookup", "missing") - - if not download_source_url: - doi = normalize_doi(str(completed.get("doi") or (completed.get("identifiers") or {}).get("doi") or "")) - if doi: - doi_url = _build_doi_url(doi) - completed["doi"] = doi - identifiers = dict(completed.get("identifiers") or {}) - identifiers["doi"] = doi - completed["identifiers"] = identifiers - if not completed.get("source_url"): - completed["source_url"] = doi_url - if not completed.get("url"): - completed["url"] = doi_url - doi_download_url = _normalize_direct_download_url(doi_url) - if doi_download_url: - download_source_url = doi_download_url - selected_strategy = "doi_resolution" - _record_attempt(selected_strategy, "success", doi_download_url) - else: - _record_attempt("doi_resolution", "empty", doi_url, reason="landing_page_only") - else: - _record_attempt("doi_resolution", "missing") - - if not download_source_url: - semantic_paper_id = str(completed.get("paper_id") or "").strip() - source_name = " ".join( - str(completed.get(key) or "") - for key in ("source", "source_provider", "retrieval_source") - ).lower() - if semantic_paper_id or "semantic" in source_name: - semantic_paths = _lookup_semantic_scholar_access_paths(semantic_paper_id) - if semantic_paths.get("source_url"): - if not completed.get("source_url"): - completed["source_url"] = semantic_paths["source_url"] - if not completed.get("url"): - completed["url"] = semantic_paths["source_url"] - if semantic_paths.get("pdf_url"): - completed["pdf_url"] = semantic_paths["pdf_url"] - download_source_url = semantic_paths["pdf_url"] - selected_strategy = "semantic_scholar_lookup_pdf" - _record_attempt(selected_strategy, "success", download_source_url) - if semantic_paths.get("arxivId") and not completed.get("arxivId"): - completed["arxivId"] = semantic_paths["arxivId"] - identifiers = dict(completed.get("identifiers") or {}) - identifiers["arxiv"] = semantic_paths["arxivId"] - completed["identifiers"] = identifiers - if semantic_paths.get("doi") and not completed.get("doi"): - completed["doi"] = semantic_paths["doi"] - identifiers = dict(completed.get("identifiers") or {}) - identifiers["doi"] = semantic_paths["doi"] - completed["identifiers"] = identifiers - if not download_source_url and semantic_paths.get("arxivId"): - download_source_url = _build_arxiv_pdf_url(semantic_paths["arxivId"]) - completed["pdf_url"] = str(completed.get("pdf_url") or download_source_url) - selected_strategy = "semantic_scholar_lookup_arxiv_pdf" - _record_attempt(selected_strategy, "success", download_source_url) - elif semantic_paths.get("source_url"): - _record_attempt( - "semantic_scholar_lookup", - "empty", - semantic_paths["source_url"], - reason="no_open_access_pdf", - ) - else: - _record_attempt("semantic_scholar_lookup", "empty", reason="no_metadata") - else: - _record_attempt("semantic_scholar_lookup", "missing") - - if not download_source_url: - public_url = str(completed.get("source_url") or completed.get("url") or "").strip() - public_download_url = _normalize_direct_download_url(public_url) - if public_download_url: - download_source_url = public_download_url - selected_strategy = "public_source_fallback" - _record_attempt(selected_strategy, "success", public_download_url) - elif public_url: - _record_attempt("public_source_fallback", "empty", public_url, reason="landing_page_only") - else: - _record_attempt("public_source_fallback", "missing") - - completed["download_source_url"] = download_source_url - completed["source_url"] = str(completed.get("source_url") or completed.get("url") or "").strip() - completed["url"] = str(completed.get("url") or completed.get("source_url") or "").strip() - completed["source_completion_strategy"] = selected_strategy - if download_source_url: - completed["source_completion_status"] = "resolved" - completed["source_completion_failure_reason"] = "" - debug["status"] = "resolved" - debug["selected_strategy"] = selected_strategy - debug["download_source_url"] = download_source_url - debug["source_url"] = completed.get("source_url", "") - else: - failure_reason = "no_downloadable_source_after_completion" - completed["source_completion_status"] = "failed" - completed["source_completion_failure_reason"] = failure_reason - debug["status"] = "failed" - debug["failure_reason"] = failure_reason - debug["source_url"] = completed.get("source_url", "") - - return completed, debug - - -def _build_query_feedback_v1(state: Any) -> dict[str, Any]: - """Build the Phase 1 minimal query feedback summary from completed research runs.""" - runs = list(state.temp_data.get("research_runs", []) or []) - query_state = QueryStateManager.ensure_state(state) - query_state.get("active_paper_query_plan", []) or query_utils.coerce_plan( - query_state.get("active_paper_queries", []), channel="paper", limit=3, default_source="state" - ) - query_state.get("active_web_query_plan", []) or query_utils.coerce_plan( - query_state.get("active_web_queries", []), channel="web", limit=3, default_source="state" - ) - if not runs: - return { - "paper_query_feedback": [], - "web_query_feedback": [], - "global_summary": { - "paper_total_selected": 0, - "paper_high_confidence_selected": 0, - "paper_drift_ratio": 0.0, - "web_noise_ratio": 0.0, - "coverage_summary": {}, - "missing_aspects": [], - "current_iteration_goal": "collect_core_evidence", - }, - "coverage_summary": {}, - "missing_aspects": [], - } - - latest_iteration = max(int(run.get("iteration", 0) or 0) for run in runs) - latest_runs = [run for run in runs if int(run.get("iteration", 0) or 0) == latest_iteration] - final_selected_keys = set(state.temp_data.get("final_selected_paper_keys", []) or []) - paper_pool_feedback_mode = bool(get_paper_processing_config().enable_paper_pool and final_selected_keys) - final_papers = list(state.temp_data.get("final_papers_for_compress", []) or []) - final_papers_by_key = { - str(paper.get("paper_key") or ""): paper - for paper in final_papers - if paper.get("paper_key") - } - feedback_runs = runs if paper_pool_feedback_mode else latest_runs - - paper_feedback: list[dict[str, Any]] = [] - web_feedback: list[dict[str, Any]] = [] - - for run in feedback_runs: - query = _normalize_query_text(run.get("topic", "")) - query_type = run.get("query_type") - results = list(run.get("results", []) or []) - - if query_type == "paper": - candidate_count = len(results) - if paper_pool_feedback_mode: - selected_keys_for_query: set[str] = set() - selected_scores: list[float] = [] - rejected_results: list[dict[str, Any]] = [] - for result in results: - if not isinstance(result, dict): - continue - paper_key = result.get("paper_key") or build_paper_key_from_record(result) - if paper_key in final_papers_by_key: - if paper_key not in selected_keys_for_query: - selected_keys_for_query.add(paper_key) - selected_scores.append(float(final_papers_by_key[paper_key].get("relevance_score", 0.0) or 0.0)) - else: - rejected_results.append(result) - - selected_count = len(selected_keys_for_query) - selected_ratio = round(selected_count / candidate_count, 3) if candidate_count else 0.0 - normalized_scores = [_normalize_relevance_score(score) for score in selected_scores] - avg_final_score = round(sum(normalized_scores) / len(normalized_scores), 3) if normalized_scores else 0.0 - top_final_score = round(max(normalized_scores), 3) if normalized_scores else 0.0 - relevance = _label_relevance(max(avg_final_score, top_final_score)) - new_pdf_count = selected_count - - if candidate_count == 0: - drift_risk = "high" - should_retry = True - retry_reason = "no candidates retrieved" - drift_signals = [] - elif selected_count == 0: - drift_risk = "high" - should_retry = True - retry_reason = "no final paper from this query survived unified pool selection" - drift_signals = DriftDetector.extract_signals(rejected_results or results) - else: - if selected_ratio < 0.15 and avg_final_score < 0.5: - drift_risk = "high" - elif selected_ratio < 0.35 or avg_final_score < 0.65: - drift_risk = "medium" - else: - drift_risk = "low" - should_retry = bool(drift_risk == "high" or avg_final_score < 0.45) - retry_reason = "" - if avg_final_score < 0.45: - retry_reason = "selected papers are only weakly aligned after unified pool scoring" - elif drift_risk == "high": - retry_reason = "query returned mostly off-target papers for the final pool" - drift_signals = [] - - novelty_gain = "high" if selected_count >= 2 else "medium" if selected_count == 1 else "low" - evidence_gain = "high" if selected_count >= 2 or top_final_score >= 0.75 else "medium" if selected_count == 1 else "low" - paper_feedback.append( - { - "query": query, - "source": run.get("query_source", ""), - "intent": run.get("query_intent", ""), - "candidate_count": candidate_count, - "selected_count": selected_count, - "selected_ratio": selected_ratio, - "avg_final_score": avg_final_score, - "top_final_score": top_final_score, - "new_pdf_count": new_pdf_count, - "relevance": relevance, - "drift_risk": drift_risk, - "novelty_gain": novelty_gain, - "evidence_gain": evidence_gain, - "should_retry": should_retry, - "retry_reason": retry_reason, - "drift_signals": drift_signals, - "feedback_mode": "final_paper_pool", - } - ) - continue - - selected_count = sum(1 for result in results if _is_selected_for_pdf(result)) - scores = [ - float(result.get("final_score")) - for result in results - if isinstance(result.get("final_score"), (int, float)) - ] - parse_metadata = list(run.get("paper_parse_metadata", []) or []) - new_pdf_count = sum(1 for item in parse_metadata if item.get("success") and not item.get("cache_hit")) - selected_ratio = round(selected_count / candidate_count, 3) if candidate_count else 0.0 - avg_final_score = round(sum(scores) / len(scores), 3) if scores else 0.0 - drift_signals = DriftDetector.extract_signals(results) - relevance = _label_relevance(max(avg_final_score, max(scores) if scores else 0.0)) - drift_risk = "high" if drift_signals or selected_ratio < 0.2 else "medium" if selected_ratio < 0.4 else "low" - novelty_gain = "high" if new_pdf_count >= 2 else "medium" if new_pdf_count == 1 else "low" - evidence_gain = "high" if selected_count >= 2 or (scores and max(scores) >= 0.6) else "medium" if selected_count == 1 else "low" - should_retry = bool(candidate_count == 0 or selected_count == 0 or avg_final_score < 0.4 or drift_risk == "high") - retry_reason = "" - if candidate_count == 0: - retry_reason = "no candidates retrieved" - elif selected_count == 0: - retry_reason = "no globally selected evidence" - elif drift_risk == "high": - retry_reason = "results drifted from the core task" - elif avg_final_score < 0.4: - retry_reason = "retrieved papers are weakly aligned" - paper_feedback.append( - { - "query": query, - "source": run.get("query_source", ""), - "intent": run.get("query_intent", ""), - "candidate_count": candidate_count, - "selected_count": selected_count, - "selected_ratio": selected_ratio, - "avg_final_score": avg_final_score, - "top_final_score": round(max(scores), 3) if scores else 0.0, - "new_pdf_count": new_pdf_count, - "relevance": relevance, - "drift_risk": drift_risk, - "novelty_gain": novelty_gain, - "evidence_gain": evidence_gain, - "should_retry": should_retry, - "retry_reason": retry_reason, - "drift_signals": drift_signals, - } - ) - continue - - if query_type == "web": - domains = [_extract_domain(result.get("url", "")) for result in results if result.get("url")] - quality_buckets = [DomainClassifier.classify(domain) for domain in domains if domain] - trusted_count = sum(1 for bucket in quality_buckets if bucket == "trusted") - social_count = sum(1 for bucket in quality_buckets if bucket == "social") - aggregator_count = sum(1 for bucket in quality_buckets if bucket == "aggregator") - result_count = len(results) - trusted_ratio = round(trusted_count / result_count, 3) if result_count else 0.0 - social_ratio = round(social_count / result_count, 3) if result_count else 0.0 - aggregator_ratio = round(aggregator_count / result_count, 3) if result_count else 0.0 - relevance = "high" if trusted_ratio >= 0.5 else "medium" if trusted_ratio >= 0.25 else "low" - drift_risk = "high" if social_ratio >= 0.5 else "medium" if trusted_ratio < 0.25 else "low" - should_retry = bool(result_count == 0 or social_ratio >= 0.5 or trusted_ratio < 0.25) - retry_reason = "" - if result_count == 0: - retry_reason = "no web results retrieved" - elif social_ratio >= 0.5: - retry_reason = "web results are dominated by social or noisy sources" - elif trusted_ratio < 0.25: - retry_reason = "insufficient trusted source coverage" - web_feedback.append( - { - "query": query, - "source": run.get("query_source", ""), - "intent": run.get("query_intent", ""), - "result_count": result_count, - "trusted_domain_ratio": trusted_ratio, - "social_noise_ratio": social_ratio, - "aggregator_ratio": aggregator_ratio, - "relevance": relevance, - "drift_risk": drift_risk, - "novelty_gain": "low", - "evidence_gain": "high" if trusted_ratio >= 0.5 else "medium" if trusted_ratio >= 0.25 else "low", - "should_retry": should_retry, - "retry_reason": retry_reason, - "bad_domains": sorted({domain for domain in domains if DomainClassifier.classify(domain) == "social"}), - "good_domains": sorted({domain for domain in domains if DomainClassifier.classify(domain) == "trusted"}), - } - ) - - paper_drift_ratio = round( - sum(1 for entry in paper_feedback if entry.get("drift_risk") == "high") / max(1, len(paper_feedback)), - 3, - ) - web_noise_ratio = round( - sum(entry.get("social_noise_ratio", 0.0) for entry in web_feedback) / max(1, len(web_feedback)), - 3, - ) - if paper_pool_feedback_mode and final_papers: - coverage_summary = _infer_coverage_summary_from_selected_papers(final_papers) - else: - coverage_summary = _infer_coverage_summary(feedback_runs) - missing_aspects = _missing_aspects_from_coverage(coverage_summary, paper_feedback, web_feedback) - - if not missing_aspects and paper_feedback and paper_drift_ratio <= 0.33: - current_iteration_goal = "sufficient_coverage" - elif any("benchmark" in item.lower() or "dataset" in item.lower() for item in missing_aspects): - current_iteration_goal = "fill_evaluation_gap" - elif any("limitation" in item.lower() or "failure" in item.lower() or "bottleneck" in item.lower() for item in missing_aspects): - current_iteration_goal = "fill_limitation_gap" - else: - current_iteration_goal = "collect_core_evidence" - - query_state["coverage_summary"] = coverage_summary - query_state["missing_aspects"] = list(missing_aspects) - - return { - "built_from_iteration": latest_iteration, - "paper_query_feedback": paper_feedback, - "web_query_feedback": web_feedback, - "global_summary": { - "paper_total_selected": len(final_papers_by_key) if paper_pool_feedback_mode else sum(entry.get("selected_count", 0) for entry in paper_feedback), - "paper_high_confidence_selected": ( - sum(1 for paper in final_papers if _normalize_relevance_score(paper.get("relevance_score", 0.0)) >= 0.6) - if paper_pool_feedback_mode - else sum(1 for entry in paper_feedback if entry.get("top_final_score", 0.0) >= 0.6) - ), - "paper_drift_ratio": paper_drift_ratio, - "web_noise_ratio": web_noise_ratio, - "coverage_summary": coverage_summary, - "missing_aspects": missing_aspects, - "current_iteration_goal": current_iteration_goal, - }, - "coverage_summary": coverage_summary, - "missing_aspects": missing_aspects, - } - - -def _summarize_query_feedback_for_prompt(query_feedback: dict[str, Any]) -> dict[str, Any]: - """Reduce feedback payload size before injecting it into prompts.""" - if not query_feedback: - return {} - - return { - "built_from_iteration": query_feedback.get("built_from_iteration"), - "paper_query_feedback": [ - { - "query": item.get("query", ""), - "selected_count": item.get("selected_count", 0), - "avg_final_score": item.get("avg_final_score", 0.0), - "relevance": item.get("relevance", "low"), - "drift_risk": item.get("drift_risk", "high"), - "should_retry": item.get("should_retry", False), - } - for item in (query_feedback.get("paper_query_feedback") or [])[:3] - ], - "web_query_feedback": [ - { - "query": item.get("query", ""), - "trusted_domain_ratio": item.get("trusted_domain_ratio", 0.0), - "social_noise_ratio": item.get("social_noise_ratio", 0.0), - "should_retry": item.get("should_retry", False), - } - for item in (query_feedback.get("web_query_feedback") or [])[:3] - ], - "global_summary": query_feedback.get("global_summary", {}), - "coverage_summary": query_feedback.get("coverage_summary", {}), - "missing_aspects": list(query_feedback.get("missing_aspects", []) or [])[:4], - } - - -def _query_generation_mode(state: Any) -> str: - """Choose the Phase 1 query-generation mode.""" - query_history = state.temp_data.get("query_history") or {} - if not query_history.get("seed"): - return "initial" - if state.iteration_count == 0 and not state.temp_data.get("query_feedback"): - return "reuse_initial" - return "iterative" - - -def _parse_with_semaphore( - parse_paper_to_markdown_with_parser: Any, - paper_config: Any, - mineru_semaphore: Any, - url: str, -) -> tuple[str, str]: - """Throttle MinerU parsing while leaving other parsers unconstrained.""" - if paper_config.pdf_parsers and paper_config.pdf_parsers[0] == "mineru": - with mineru_semaphore: - return parse_paper_to_markdown_with_parser(url) - return parse_paper_to_markdown_with_parser(url) - - -def _parse_with_fallback( - parse_paper_to_markdown_with_parser: Any, - paper_config: Any, - mineru_semaphore: Any, - url: str, -) -> tuple[str, bool, str]: - """Fallback to partial parsing when the primary parse stalls.""" - import concurrent.futures - - with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor_inner: - future = executor_inner.submit( - _parse_with_semaphore, - parse_paper_to_markdown_with_parser, - paper_config, - mineru_semaphore, - url, - ) - try: - content, content_parser = future.result(timeout=30) - return content, False, content_parser - except concurrent.futures.TimeoutError: - content, content_parser = parse_paper_to_markdown_with_parser(url, max_pages=8) - return content, True, content_parser - - -def _get_or_parse_paper_for_compress( - paper: dict[str, Any], - idx: int, - *, - papers_dir: Path, - storage: Any, - cache_store: Any, - parse_paper_to_markdown_with_parser: Any, - paper_config: Any, - mineru_semaphore: Any, -) -> dict[str, Any]: - """Resolve one final paper into parsed markdown for downstream compression.""" - import hashlib - - source_url = str(paper.get("source_url") or _resolve_public_paper_url(paper) or "").strip() - download_source_url = str(paper.get("download_source_url") or _resolve_download_source_url(paper) or "").strip() - title = paper.get("title", "unknown") - canonical_source_url = source_url or download_source_url - if not download_source_url: - raise ValueError(f"No downloadable source found for paper: {title}") - - generated_paper_key = build_paper_key(title, canonical_source_url) - cached = cache_store.get_by_key_or_url(generated_paper_key, canonical_source_url) if cache_store else None - if not cached and cache_store and download_source_url != canonical_source_url: - cached = cache_store.get_by_source_url(download_source_url) - - if cached and cached.get("content_md"): - content = cached["content_md"] - is_partial = bool(cached.get("is_partial")) - content_parser = cached.get("content_parser") or "cache" - paper_key = cached.get("paper_key") or generated_paper_key - cache_hit = True - else: - content, is_partial, content_parser = _parse_with_fallback( - parse_paper_to_markdown_with_parser, - paper_config, - mineru_semaphore, - download_source_url, - ) - paper_key = generated_paper_key - cache_hit = False - if cache_store: - cache_store.upsert_parsed_content( - paper_key=paper_key, - title=title, - source_url=canonical_source_url, - content_md=content, - content_hash=hashlib.sha256(content.encode("utf-8")).hexdigest(), - content_parser=content_parser, - is_partial=is_partial, - ) - - safe_title = title[:50].replace("/", "_").replace(" ", "_") - paper_id = f"final_paper_{idx+1}_{safe_title}" - paper_file = papers_dir / f"{paper_id}.md" - paper_file.write_text(content, encoding="utf-8") - - raw_ref = None - if storage and content: - paper_ref = storage.write_paper_raw(paper_id, content) - raw_ref = paper_ref.model_dump() - - return { - "paper_id": paper_id, - "paper_key": paper_key, - "source_url": canonical_source_url, - "download_source_url": download_source_url, - "title": title, - "core_summary": "", - "technical_depth": "", - "empirical_support": "", - "content_parser": content_parser, - "is_partial": is_partial, - "raw_ref": raw_ref, - "cache_hit": cache_hit, - "url": paper.get("url", ""), - "arxivId": paper.get("arxivId", ""), - "openalex_id": paper.get("openalex_id", ""), - "doi": paper.get("doi", ""), - "oa_url": paper.get("oa_url", ""), - "pdf_url": paper.get("pdf_url", ""), - "identifiers": dict(paper.get("identifiers") or {}), - "retrieval_source": paper.get("retrieval_source", ""), - "source_provider": paper.get("source_provider", ""), - "source": paper.get("source", ""), - "query_topic": paper.get("query_topic", ""), - "query_intent": paper.get("query_intent", ""), - "from_citation_expansion": bool(paper.get("from_citation_expansion")), - "parent_paper_id": paper.get("parent_paper_id", ""), - "relevance_score": paper.get("relevance_score", 0.0), - "final_selection_rank": paper.get("final_selection_rank"), - "source_completion_status": paper.get("source_completion_status", ""), - "source_completion_strategy": paper.get("source_completion_strategy", ""), - "source_completion_failure_reason": paper.get("source_completion_failure_reason", ""), - } - - -def _compress_section_content( - executor: Any, - prompt_builder: Any, - language: str, - section_content: str, -) -> str: - """Compress one section group with the shared node executor.""" - prompt = prompt_builder(language) - return executor.invoke_text(prompt, section_content).strip() - - -def _compress_single_paper( - position: int, - paper: dict[str, Any], - *, - papers_dir: Path, - cache_store: Any, - strategy: str, - strategy_key: str, - paper_config: Any, - language: str, - executor: Any, - storage: Any, -) -> dict[str, Any]: - """Compress one parsed paper into the three structured note fields.""" - from concurrent.futures import ThreadPoolExecutor, as_completed - - from langchain_core.messages import HumanMessage, SystemMessage - - from src.modules.idea_gen.prompts import ( - build_compress_core_summary_prompt, - build_compress_empirical_support_prompt, - build_compress_technical_depth_prompt, - build_paper_compress_prompt, - ) - from src.modules.idea_gen.schemas import ArtifactRef - from src.modules.idea_gen.utils.paper_compressor import CompressedPaper, _group_sections, _parse_sections - from src.utils.structured_output import parse_structured_output - - paper_id = paper["paper_id"] - paper_file = papers_dir / f"{paper_id}.md" - paper_key = paper.get("paper_key") or build_paper_key(paper["title"], paper.get("source_url", "")) - cached = cache_store.get_by_paper_key(paper_key) if cache_store else None - compression_errors: list[str] = [] - compression_cache_hit = bool( - cached and cached.get("compressed_content") and cached.get("compress_strategy") == strategy_key - ) - - if compression_cache_hit: - cached_payload = CompressedPaper.deserialize(cached["compressed_content"]) - core_compressed = cached_payload.core_summary - tech_compressed = cached_payload.technical_depth - emp_compressed = cached_payload.empirical_support - else: - if not paper_file.exists(): - return { - "position": position, - "paper": paper, - "text": f"{position}. {paper['title']} (file not found)\n\n", - "log": None, - } - - full_content = paper_file.read_text(encoding="utf-8") - - if strategy == "section_group": - sections = _parse_sections(full_content) - groups = _group_sections(sections) - group_specs = [ - ("core_summary", "### 核心摘要", groups.get("core_summary", []), build_compress_core_summary_prompt), - ("technical_depth", "### 技术细节", groups.get("technical_depth", []), build_compress_technical_depth_prompt), - ("empirical_support", "### 实验验证", groups.get("empirical_support", []), build_compress_empirical_support_prompt), - ] - compressed_fields = {name: "" for name, _, _, _ in group_specs} - active_specs = [ - (name, heading, "\n\n".join(section["content"] for section in group_sections), prompt_builder) - for name, heading, group_sections, prompt_builder in group_specs - if group_sections - ] - - with ThreadPoolExecutor(max_workers=max(1, len(active_specs))) as section_pool: - future_to_spec = { - section_pool.submit( - _compress_section_content, - executor, - prompt_builder, - language, - group_content, - ): (name, heading) - for name, heading, group_content, prompt_builder in active_specs - } - for future in as_completed(future_to_spec): - name, heading = future_to_spec[future] - try: - compressed_fields[name] = future.result() - except Exception as exc: - compression_errors.append(f"{heading} failed: {exc}") - - core_compressed = compressed_fields["core_summary"] - tech_compressed = compressed_fields["technical_depth"] - emp_compressed = compressed_fields["empirical_support"] - elif strategy == "truncate": - truncated = full_content[:paper_config.truncate_max_chars] - system, user = build_paper_compress_prompt(paper["title"], truncated, language) - response = executor.invoke_messages( - [SystemMessage(content=system), HumanMessage(content=user)] - ) - data = parse_structured_output(response.content) - core_compressed = data.get("core_summary", "") - tech_compressed = data.get("technical_depth", "") - emp_compressed = data.get("empirical_support", "") - else: - raise ValueError(f"Unknown compression strategy: {strategy}") - - if cache_store: - cache_store.update_compressed_content( - paper_key=paper_key, - compressed_content=CompressedPaper( - core_compressed, - tech_compressed, - emp_compressed, - ).serialize(), - compress_strategy=strategy_key, - ) - - completed_paper = { - "paper_id": paper_id, - "paper_key": paper_key, - "source_url": paper.get("source_url", ""), - "download_source_url": paper.get("download_source_url", ""), - "url": paper.get("url", ""), - "title": paper["title"], - "core_summary": core_compressed, - "technical_depth": tech_compressed, - "empirical_support": emp_compressed, - "content_parser": paper.get("content_parser", ""), - "is_partial": paper.get("is_partial", False), - "compression_cache_hit": compression_cache_hit, - "raw_ref": paper.get("raw_ref"), - "arxivId": paper.get("arxivId", ""), - "openalex_id": paper.get("openalex_id", ""), - "doi": paper.get("doi", ""), - "oa_url": paper.get("oa_url", ""), - "pdf_url": paper.get("pdf_url", ""), - "identifiers": dict(paper.get("identifiers") or {}), - "retrieval_source": paper.get("retrieval_source", ""), - "source_provider": paper.get("source_provider", ""), - "source": paper.get("source", ""), - "query_topic": paper.get("query_topic", ""), - "query_intent": paper.get("query_intent", ""), - "from_citation_expansion": bool(paper.get("from_citation_expansion")), - "parent_paper_id": paper.get("parent_paper_id", ""), - "relevance_score": paper.get("relevance_score", 0.0), - "final_selection_rank": paper.get("final_selection_rank"), - "source_completion_status": paper.get("source_completion_status", ""), - "source_completion_strategy": paper.get("source_completion_strategy", ""), - "source_completion_failure_reason": paper.get("source_completion_failure_reason", ""), - } - paper_text = f"## Paper {position}: {paper['title']}\n\n" - raw_ref = paper.get("raw_ref") - - if storage and raw_ref: - storage.compress_paper( - paper_id, - paper["title"], - raw_ref=raw_ref if hasattr(raw_ref, "artifact_type") else ArtifactRef.model_validate(raw_ref), - core_summary=core_compressed, - technical_depth=tech_compressed, - empirical_support=emp_compressed, - ) - - if core_compressed: - paper_text += f"### 核心摘要\n{core_compressed}\n\n" - if tech_compressed: - paper_text += f"### 技术细节\n{tech_compressed}\n\n" - if emp_compressed: - paper_text += f"### 实验验证\n{emp_compressed}\n\n" - for error in compression_errors: - paper_text += f"({error})\n\n" - - return { - "position": position, - "paper": completed_paper, - "text": paper_text, - "log": ( - f"[COMPRESS] {paper_id}: cache hit for {strategy_key}" - if compression_cache_hit - else f"[COMPRESS] {paper_id}: {strategy} compression done" - ), - } - - -def _finalize_critic_state( - state: Any, - *, - action: str, - status: str, - ran: bool, - successful_models: int = 0, - avg_score: float | None = None, -): - """Persist critic execution metadata on state.""" - state.temp_data["critic_action"] = action - state.temp_data["critic_status"] = status - state.temp_data["critic_ran"] = ran - state.temp_data["critic_successful_models"] = successful_models - if avg_score is None: - state.temp_data.pop("critic_avg_score", None) - else: - state.temp_data["critic_avg_score"] = avg_score - _save_node_state(state, f"critic_iter{state.critic_iteration_count}") - return state +__all__ = [ + "_get_output_dir", + "_save_artifact", + "_save_node_state", + "_create_paper_cache_store", + "_normalize_query_text", + "_normalize_query_key", + "_extract_domain", + "_plan_to_queries", + "_query_role_lookup", + "_build_role_feedback_entry", + "_is_selected_for_pdf", + "_label_relevance", + "_infer_coverage_summary", + "_missing_aspects_from_coverage", + "_infer_coverage_summary_from_selected_papers", + "_needs_web_query", + "_normalize_arxiv_identifier", + "_normalize_openalex_identifier", + "_build_arxiv_abs_url", + "_build_arxiv_pdf_url", + "_build_openalex_work_url", + "_build_semantic_scholar_url", + "_build_doi_url", + "_resolve_public_paper_url", + "_normalize_direct_download_url", + "_resolve_download_source_url", + "_lookup_openalex_access_paths", + "_lookup_semantic_scholar_access_paths", + "_normalize_relevance_score", + "_paper_ranking_sort_key", + "_complete_final_paper_sources", + "_build_query_feedback_v1", + "_summarize_query_feedback_for_prompt", + "_query_generation_mode", + "_parse_with_semaphore", + "_parse_with_fallback", + "_get_or_parse_paper_for_compress", + "_compress_section_content", + "_compress_single_paper", + "_finalize_critic_state", +] diff --git a/backend/src/modules/idea_gen/workflow.py b/backend/src/modules/idea_gen/workflow.py index 9d07d699..3741b83c 100644 --- a/backend/src/modules/idea_gen/workflow.py +++ b/backend/src/modules/idea_gen/workflow.py @@ -1,1504 +1,173 @@ -"""Idea Generation workflow - all nodes in one file.""" -import json -from typing import Any -from datetime import datetime -from concurrent.futures import ThreadPoolExecutor, as_completed -from pydantic import BaseModel, ConfigDict, Field +"""Idea generation workflow entrypoint.""" + +from __future__ import annotations try: - from langgraph.graph import StateGraph, END + from langgraph.graph import END, StateGraph + LANGGRAPH_AVAILABLE = True except ImportError: + END = "__end__" + StateGraph = None LANGGRAPH_AVAILABLE = False -from src.modules.idea_gen.utils import IdeaResearchExecutor, NodeExecutor, OpenAlexEnricher, PaperReranker -from src.utils.structured_output import parse_structured_output -from src.modules.idea_gen.prompts import ( - build_clarify_prompt, build_brief_prompt, build_supervisor_prompt, - build_query_generation_prompt, - build_compress_prompt, build_report_prompt, +from src.modules.idea_gen.nodes.bootstrap import ( + brief_node, + clarify_node, + query_generation_node, + start_node, ) -from src.modules.idea_gen.schemas import SupervisorDecision -from src.modules.idea_gen.config import get_node_tool_config, get_workflow_config, get_paper_processing_config -from src.modules.idea_gen.utils.storage_manager import StorageManager -from src.modules.idea_gen.utils.paper_pipeline import PaperPipeline -from src.modules.idea_gen.utils.paper_cache_store import ( - PaperCacheStore, - build_paper_key, - build_paper_key_from_record, +from src.modules.idea_gen.nodes.compress import compress_node +from src.modules.idea_gen.nodes.critic import critic_node +from src.modules.idea_gen.nodes.evidence import evidence_orchestrator_node, hypothesis_node +from src.modules.idea_gen.nodes.finalize import finalize_node +from src.modules.idea_gen.nodes.paper_pool import paper_pool_process_node +from src.modules.idea_gen.nodes.report import report_node +from src.modules.idea_gen.nodes.research import research_dispatch_node +from src.modules.idea_gen.nodes.routing import ( + route_clarify, + route_compress, + route_critic, + route_query_generation, + route_supervisor, ) -from src.modules.idea_gen.utils import query_utils -from src.modules.idea_gen.utils.query_state_manager import QueryStateManager -from src.modules.idea_gen.utils.result_formatter import ResultFormatter -from src.modules.idea_gen.utils.report_source_handler import ReportSourceHandler -from src.modules.idea_gen.utils.paper_pool import PaperPool -from src.modules.idea_gen.utils.paper_relevance_scorer import PaperRelevanceScorer -from src.modules.idea_gen.utils.citation_expander import CitationExpander -from src.modules.idea_gen.utils.workflow_helpers import ( - _build_arxiv_abs_url, - _build_arxiv_pdf_url, - _build_doi_url, - _build_openalex_work_url, - _build_query_feedback_v1, - _build_role_feedback_entry, - _build_semantic_scholar_url, - _compress_single_paper, - _complete_final_paper_sources, - _create_paper_cache_store, - _extract_domain, - _finalize_critic_state, - _get_or_parse_paper_for_compress, - _get_output_dir, - _infer_coverage_summary, - _infer_coverage_summary_from_selected_papers, - _is_selected_for_pdf, - _label_relevance, - _lookup_openalex_access_paths, - _lookup_semantic_scholar_access_paths, - _missing_aspects_from_coverage, - _needs_web_query, - _normalize_arxiv_identifier, - _normalize_direct_download_url, - _normalize_openalex_identifier, - _normalize_query_key, - _normalize_query_text, - _normalize_relevance_score, - _paper_ranking_sort_key, - _plan_to_queries, - _query_generation_mode, - _query_role_lookup, - _resolve_download_source_url, - _resolve_public_paper_url, - _save_artifact, - _save_node_state, - _summarize_query_feedback_for_prompt, +from src.modules.idea_gen.nodes.seed import seed_discovery_node, seed_discovery_validate_node +from src.modules.idea_gen.nodes.supervisor import supervisor_node, supervisor_validate_node +from src.modules.idea_gen.schemas import NodeError +from src.modules.idea_gen.state import IdeaGenInput, IdeaGenState +from src.modules.idea_gen.utils import ( + IdeaResearchExecutor, + NodeExecutor, + OpenAlexEnricher, + PaperReranker, ) +from src.modules.idea_gen.utils.cache_utils import _create_paper_cache_store +from src.modules.idea_gen.utils.query_planning_utils import _build_query_feedback_v1 -# ============ State Models ============ -class IdeaGenInput(BaseModel): - """Academic idea research input.""" - target: str # Research topic or question - academic_domain: str = "AI/ML" # e.g., "NLP", "CV", "RL" - target_venues: list[str] = Field(default_factory=lambda: ["NIPS", "ICML", "ICLR"]) - language: str = "zh" # Output language - allow_clarification: bool = True - max_iterations: int = Field(default_factory=lambda: get_workflow_config().default_max_iterations) - -class IdeaGenState(BaseModel): - model_config = ConfigDict(arbitrary_types_allowed=True) - - input: IdeaGenInput - thread_id: str = "" - research_brief: str = "" - paper_queries: list[str] = Field(default_factory=list) - web_queries: list[str] = Field(default_factory=list) - messages: list[Any] = Field(default_factory=list) - supervisor_messages: list[dict] = Field(default_factory=list) - researcher_messages: list[dict] = Field(default_factory=list) - notes: str = "" - raw_notes: str = "" - iteration_count: int = 0 - unit_count: int = 0 - critic_iteration_count: int = 0 - critic_feedback: dict[str, Any] | None = None - paper_pool: Any = None # PaperPool instance - temp_data: dict[str, Any] = Field(default_factory=dict) - -# ============ Node 1: Start ============ -def start_node(state: IdeaGenState) -> IdeaGenState: - print("\n[NODE] start - Initializing workflow...") - state.iteration_count = 0 - state.unit_count = 0 - state.temp_data["workflow_date"] = datetime.now().strftime("%Y-%m-%d") - QueryStateManager.ensure_state(state) - QueryStateManager.ensure_history(state) - return state - -# ============ Node 2: Clarify ============ -def clarify_node(state: IdeaGenState, executor: NodeExecutor | None = None) -> IdeaGenState: - print("\n[NODE] clarify - Checking if clarification needed...") - if not state.input.allow_clarification: - state.temp_data["need_clarification"] = False - print("[NODE] clarify - Skipped (not allowed)") - return state +class WorkflowWrapper: + """Small adapter that preserves the historical invoke(dict) API.""" - messages_str = "\n".join(f"{m.get('role', 'user')}: {m.get('content', '')}" for m in state.messages) if state.messages else f"user: {state.input.target}" + def __init__(self, compiled_graph): + self.graph = compiled_graph - system, user = build_clarify_prompt(messages_str, date=state.temp_data.get("workflow_date")) - executor = executor or NodeExecutor("clarify") - response = executor.invoke_text(system, user) - - try: - data = parse_structured_output(response) - need_clarification = data.get("need_clarification", False) - state.temp_data["need_clarification"] = need_clarification - - if need_clarification: - state.temp_data["clarification_question"] = data.get("question", "") - print(f"[NODE] clarify - Need clarification: {data.get('question', '')[:100]}") + def invoke(self, input_data): + if isinstance(input_data, IdeaGenState): + state = input_data else: - print("[NODE] clarify - No clarification needed") - except Exception as e: - state.temp_data["need_clarification"] = False - print(f"[NODE] clarify - Error: {e}, proceeding without clarification") - - return state - -# ============ Node 3: Brief ============ -def brief_node(state: IdeaGenState, executor: NodeExecutor | None = None) -> IdeaGenState: - print("\n[NODE] brief - Generating research brief...") - - messages_str = "\n".join(f"{m.get('role', 'user')}: {m.get('content', '')}" for m in state.messages) if state.messages else f"user: {state.input.target}" - - system, user = build_brief_prompt( - messages_str, - date=state.temp_data.get("workflow_date"), - language=state.input.language, - academic_domain=state.input.academic_domain, - target_venues=state.input.target_venues - ) - executor = executor or NodeExecutor("brief") - response = executor.invoke_text(system, user) - - try: - data = parse_structured_output(response) - state.research_brief = data.get("research_brief", state.input.target) - except Exception: - state.research_brief = state.input.target - - # Query generation is handled by a dedicated node. - state.paper_queries = [] - state.web_queries = [] - - print(f"[NODE] brief - Done: {state.research_brief[:100]}...") - _save_artifact(state, "research_brief.md", state.research_brief) - _save_node_state(state, "brief") - - return state - -# ============ Node 4: Query Generation ============ -def query_generation_node(state: IdeaGenState, executor: NodeExecutor | None = None) -> IdeaGenState: - mode = _query_generation_mode(state) - query_state = QueryStateManager.ensure_state(state) - QueryStateManager.ensure_history(state) - previous_active_paper_queries = list(query_state.get("active_paper_queries", []) or state.paper_queries) - previous_active_web_queries = list(query_state.get("active_web_queries", []) or state.web_queries) - previous_active_paper_plan = list(query_state.get("active_paper_query_plan", []) or []) - previous_active_web_plan = list(query_state.get("active_web_query_plan", []) or []) - print(f"\n[NODE] query_generation - Generating search queries ({mode})...") - - if mode == "reuse_initial": - state.paper_queries = list(previous_active_paper_queries) - state.web_queries = list(previous_active_web_queries) - print( - f"[NODE] query_generation - Reusing initial active queries: " - f"{len(state.paper_queries)} paper, {len(state.web_queries)} web" - ) - _save_node_state(state, "query_generation") - return state - - query_feedback = state.temp_data.get("query_feedback") or {} - latest_supervisor = state.supervisor_messages[-1] if state.supervisor_messages else {} - system, user = build_query_generation_prompt( - target=state.input.target, - research_brief=state.research_brief, - date=state.temp_data.get("workflow_date"), - academic_domain=state.input.academic_domain, - target_venues=state.input.target_venues, - mode=mode, - current_active_queries={ - "paper_queries": list(query_state.get("active_paper_queries", []) or state.paper_queries), - "web_queries": list(query_state.get("active_web_queries", []) or state.web_queries), - "paper_query_plan": list(query_state.get("active_paper_query_plan", []) or []), - "web_query_plan": list(query_state.get("active_web_query_plan", []) or []), - }, - supervisor_reflection=latest_supervisor.get("reflection", ""), - supervisor_topics=list(state.temp_data.get("supervisor_topics", []) or []), - query_feedback=_summarize_query_feedback_for_prompt(query_feedback), - query_history=state.temp_data.get("query_history") or {}, - ) - executor = executor or NodeExecutor("query_generation") - response = executor.invoke_text(system, user) - - rewrite_strategy = "initial" if mode == "initial" else "retain" - rewrite_reason = "initial query generation from target + brief" - next_paper_plan: list[dict[str, Any]] = [] - next_web_plan: list[dict[str, Any]] = [] - need_more_queries = True - missing_aspects = list(query_feedback.get("missing_aspects", []) or []) - - try: - data = parse_structured_output(response) - need_more_queries = bool(data.get("need_more_queries", True)) - response_missing = list(data.get("missing_aspects", []) or []) - if response_missing: - missing_aspects = [_normalize_query_text(item) for item in response_missing if _normalize_query_text(item)] - next_paper_plan = query_utils.coerce_plan( - data.get("paper_queries"), - channel="paper", - limit=6, - default_source="model", - fallback_role="candidate", - ) - next_web_plan = query_utils.coerce_plan( - data.get("web_queries"), - channel="web", - limit=6, - default_source="model", - fallback_role="candidate", - ) - rewrite_strategy = _normalize_query_text(data.get("rewrite_strategy", rewrite_strategy)).lower() or rewrite_strategy - rewrite_reason = _normalize_query_text(data.get("reason", rewrite_reason)) or rewrite_reason - except Exception: - next_paper_plan = [] - next_web_plan = [] - need_more_queries = mode == "initial" - - if mode == "initial": - next_paper_plan = query_utils.dedupe_and_filter_plan( - next_paper_plan, - target=state.input.target, - research_brief=state.research_brief, - channel="paper", - limit=3, - ) - if len(next_paper_plan) < 3: - for candidate in query_utils.default_initial_queries(state.input.target, state.research_brief): - if len(next_paper_plan) >= 3: - break - next_paper_plan = query_utils.dedupe_and_filter_plan( - [*next_paper_plan, candidate], - target=state.input.target, - research_brief=state.research_brief, - channel="paper", - limit=3, - ) - next_web_plan = [] - else: - if rewrite_strategy == "stop" or not need_more_queries: - next_paper_plan = [] - next_web_plan = [] - else: - next_paper_plan = query_utils.dedupe_and_filter_plan( - next_paper_plan, - target=state.input.target, - research_brief=state.research_brief, - channel="paper", - limit=2, - ) - next_web_plan = query_utils.dedupe_and_filter_plan( - next_web_plan, - target=state.input.target, - research_brief=state.research_brief, - channel="web", - limit=1, - ) - gap_inputs = missing_aspects or list(state.temp_data.get("supervisor_topics", []) or []) - if not next_paper_plan and gap_inputs: - next_paper_plan = query_utils.default_iterative_queries( - target=state.input.target, - research_brief=state.research_brief, - missing_aspects=gap_inputs, - channel="paper", - ) - if not next_web_plan and gap_inputs and _needs_web_query(gap_inputs): - next_web_plan = query_utils.default_iterative_queries( - target=state.input.target, - research_brief=state.research_brief, - missing_aspects=gap_inputs, - channel="web", - ) - - next_paper_queries = _plan_to_queries(next_paper_plan, limit=3) - next_web_queries = _plan_to_queries(next_web_plan, limit=3) - dropped_queries = ( - query_utils.compute_dropped(previous_active_paper_queries, next_paper_queries) - + query_utils.compute_dropped(previous_active_web_queries, next_web_queries) - ) if mode != "initial" else [] - - state.paper_queries = next_paper_queries - state.web_queries = next_web_queries - - if mode == "initial": - query_state["seed_paper_queries"] = list(next_paper_queries) - query_state["seed_web_queries"] = list(next_web_queries) - query_state["seed_paper_query_plan"] = list(next_paper_plan) - query_state["seed_web_query_plan"] = list(next_web_plan) - - query_state["active_paper_queries"] = list(next_paper_queries) - query_state["active_web_queries"] = list(next_web_queries) - query_state["active_paper_query_plan"] = list(next_paper_plan) - query_state["active_web_query_plan"] = list(next_web_plan) - query_state["last_rewrite_strategy"] = rewrite_strategy - query_state["coverage_summary"] = dict(query_feedback.get("coverage_summary", {}) or {}) - query_state["missing_aspects"] = list(missing_aspects) - - entry = { - "iteration": state.iteration_count, - "mode": mode, - "need_more_queries": need_more_queries, - "paper_queries": list(next_paper_queries), - "web_queries": list(next_web_queries), - "paper_query_plan": list(next_paper_plan), - "web_query_plan": list(next_web_plan), - "missing_aspects": list(missing_aspects), - "strategy": rewrite_strategy, - "reason": rewrite_reason, - "dropped_queries": dropped_queries, - } - QueryStateManager.record_entry(state, entry) - - print( - f"[NODE] query_generation - Done ({rewrite_strategy}): " - f"{len(state.paper_queries)} paper queries, {len(state.web_queries)} web queries" - ) - _save_node_state(state, "query_generation") - return state - -# ============ Node 5: Supervisor ============ -def supervisor_node(state: IdeaGenState, executor: NodeExecutor | None = None) -> IdeaGenState: - print(f"\n[NODE] supervisor - Iteration {state.iteration_count}") - workflow_config = get_workflow_config() - max_topics = workflow_config.max_concurrent_research_units - - if state.iteration_count >= state.input.max_iterations: - state.temp_data["supervisor_action"] = "RESEARCH_COMPLETE" - state.temp_data["supervisor_topics"] = [] - print("[NODE] supervisor - RESEARCH_COMPLETE (max iterations)") - _save_node_state(state, f"supervisor_iter{state.iteration_count}") - return state - - # Prevent infinite THINK loop - allow max 2 consecutive THINKs - last_action = state.temp_data.get("supervisor_action", "") - if last_action == "THINK": - consecutive_thinks = state.temp_data.get("consecutive_thinks", 0) + 1 - if consecutive_thinks >= 2: - state.temp_data["supervisor_action"] = "CONDUCT_RESEARCH" - state.temp_data["supervisor_topics"] = ["memory management in LLM agents"] - print("[NODE] supervisor - Forcing CONDUCT_RESEARCH (consecutive THINK limit)") - _save_node_state(state, f"supervisor_iter{state.iteration_count}") - return state - state.temp_data["consecutive_thinks"] = consecutive_thinks - else: - state.temp_data["consecutive_thinks"] = 0 - - # Get last action and reflection for ReAct context - last_action = state.temp_data.get("supervisor_action", "") - last_reflection = "" - if state.supervisor_messages: - last_reflection = state.supervisor_messages[-1].get("reflection", "") - - system = build_supervisor_prompt( - date=state.temp_data.get("workflow_date"), - max_researcher_iterations=state.input.max_iterations, - max_concurrent_research_units=max_topics, - last_action=last_action, - last_reflection=last_reflection - ) - user = f"Research brief:\n{state.research_brief}\n\nNotes:\n{state.notes}\n\nIteration: {state.iteration_count}" - query_state = state.temp_data.get("query_state") or {} - if query_state.get("active_paper_queries") or query_state.get("active_web_queries"): - user += ( - "\n\nCurrent Active Queries:\n" - f"- Paper: {json.dumps(query_state.get('active_paper_queries', []), ensure_ascii=False)}\n" - f"- Web: {json.dumps(query_state.get('active_web_queries', []), ensure_ascii=False)}" - ) - if state.temp_data.get("query_feedback"): - user += ( - "\n\nRecent Query Feedback Summary:\n" - f"{json.dumps(_summarize_query_feedback_for_prompt(state.temp_data['query_feedback']), ensure_ascii=False, indent=2)}" - ) - if state.critic_feedback: - user += f"\n\n[CRITIC FEEDBACK]\n{state.critic_feedback.get('blocking_issues', '')}\n{state.critic_feedback.get('suggestions', '')}" - executor = executor or NodeExecutor("supervisor") - response = executor.invoke_text(system, user) - - try: - data = parse_structured_output(response) - decision = SupervisorDecision(**data) # Schema validation enforces THINK/CONDUCT_RESEARCH rules - action = decision.action - topics = decision.topics[:max_topics] - - # Clean topics if present - cleaned_topics = [] - for topic in topics: - if len(topic) > 150 or '\n' in topic or topic.startswith('#'): - print(f"[WARN] Rejected polluted topic: {topic[:50]}...") - topic = topic.split('\n')[0].replace('#', '').strip()[:100] - cleaned_topics.append(topic) - - state.temp_data["supervisor_action"] = action - state.temp_data["supervisor_topics"] = cleaned_topics - state.supervisor_messages.append({"action": action, "reflection": decision.reflection, "topics": cleaned_topics}) - print(f"[NODE] supervisor - {action}, Topics: {len(cleaned_topics)}") - except Exception as e: - state.temp_data["supervisor_action"] = "RESEARCH_COMPLETE" - state.temp_data["supervisor_topics"] = [] - print(f"[NODE] supervisor - Error: {e}") - - _save_node_state(state, f"supervisor_iter{state.iteration_count}") - return state - -# ============ Node 6: Research Dispatch ============ -def research_dispatch_node(state: IdeaGenState, executor: IdeaResearchExecutor | None = None) -> IdeaGenState: - from src.modules.idea_gen.tools import ( - fetch_webpage_as_markdown, - search_sync, - summarize_webpage_markdown, - ) - from src.modules.idea_gen.schemas import PaperCandidate - from src.modules.idea_gen.utils.storage_manager import StorageManager - - tool_config = get_node_tool_config("research_dispatch") - workflow_config = get_workflow_config() - paper_config = get_paper_processing_config() - pending_raw_notes = "" - executor = executor or IdeaResearchExecutor() - runs = state.temp_data.setdefault("research_runs", []) - current_iteration = state.iteration_count - output_dir = _get_output_dir(state) - storage = StorageManager(output_dir) if paper_config.enable_two_tier_storage else None - cache_store = _create_paper_cache_store() - if cache_store: - state.temp_data["paper_cache_db_path"] = str(cache_store.db_path) - paper_metadata_providers = list(getattr(tool_config, "paper_metadata_providers", []) or []) - openalex_enricher = OpenAlexEnricher() if "openalex" in paper_metadata_providers else None - paper_reranker = PaperReranker(config=paper_config) if paper_config.enable_embedding_rerank else None - - if paper_config.enable_paper_pool and state.paper_pool is None: - state.paper_pool = PaperPool() - print("[NODE] research_dispatch - Initialized paper pool") - - all_paper_titles = state.temp_data.setdefault("all_paper_titles", set()) - query_state = QueryStateManager.ensure_state(state) - active_paper_plan = list(query_state.get("active_paper_query_plan", []) or []) - active_web_plan = list(query_state.get("active_web_query_plan", []) or []) - paper_role_lookup = _query_role_lookup(active_paper_plan) - web_role_lookup = _query_role_lookup(active_web_plan) - - compiled_query_state = state.temp_data.get("query_state") - has_explicit_query_state = isinstance(compiled_query_state, dict) - - if state.paper_queries or state.web_queries or has_explicit_query_state: - paper_queries = state.paper_queries or [] - web_queries = state.web_queries or [] - print( - f"\n[NODE] research_dispatch - Iteration {state.iteration_count}: " - f"{len(paper_queries)} paper + {len(web_queries)} web queries" - ) - topics = [] - for query in paper_queries: - topics.append({"query": query, "type": "paper", "providers": tool_config.paper_providers}) - for query in web_queries: - topics.append({"query": query, "type": "web", "providers": tool_config.web_providers}) - else: - supervisor_topics = state.temp_data.get("supervisor_topics", []) - paper_queries = supervisor_topics - print(f"\n[NODE] research_dispatch - Iteration {state.iteration_count}: {len(supervisor_topics)} supervisor topics") - topics = [{"query": topic, "type": "paper", "providers": tool_config.paper_providers} for topic in supervisor_topics] - - for topic_info in topics: - state.unit_count += 1 - topic = topic_info["query"] - query_type = topic_info["type"] - providers = topic_info["providers"] - - all_results: list[dict[str, Any]] = [] - provider_attempts: list[dict[str, Any]] = [] - selected_provider = "" - max_results = ( - paper_config.candidate_pool_size - if query_type == "paper" and paper_reranker is not None - else workflow_config.search_results_per_topic - ) - for provider in providers: - print(f"[NODE] research_dispatch - Unit {state.unit_count}: [{query_type}] {topic[:50]}... -> {provider}") - try: - results = search_sync(topic, provider, max_results) - provider_attempts.append( - { - "provider": provider, - "status": "ok", - "result_count": len(results), - } - ) - for result in results: - result["query_type"] = query_type - result["source_provider"] = provider - if results: - all_results = results - selected_provider = provider - if len(providers) > 1: - print( - "[NODE] research_dispatch - " - f"Selected provider {provider} with {len(results)} results" - ) - break - except Exception as e: - provider_attempts.append( - { - "provider": provider, - "status": "error", - "result_count": 0, - "error": str(e), - } - ) - print(f"[NODE] research_dispatch - Error with {provider}: {e}") - - seen = set() - deduped_results: list[dict[str, Any]] = [] - for result in all_results: - key = result.get("url") or result.get("title") - if key and key not in seen: - seen.add(key) - deduped_results.append(result) - - results = deduped_results[:max_results] - enrichment_summary = {"provider": None, "matched": 0, "attempted": 0, "cache_hits": 0} - if query_type == "paper" and results and openalex_enricher is not None: - try: - cached_results: list[dict[str, Any]] = [] - results_to_enrich: list[dict[str, Any]] = [] - - for result in results: - result_with_key = dict(result) - paper_key = build_paper_key_from_record(result_with_key) - result_with_key["paper_key"] = paper_key - cached_metadata = ( - cache_store.get_cached_metadata(paper_key, result.get("url", "")) - if cache_store - else None - ) - if cached_metadata is not None: - cached_results.append(PaperCacheStore.apply_metadata_payload(result_with_key, cached_metadata)) - else: - results_to_enrich.append(result_with_key) - - enriched_results = openalex_enricher.enrich_many(results_to_enrich) if results_to_enrich else [] - if cache_store: - for enriched_result in enriched_results: - cache_payload = PaperCacheStore.build_metadata_payload(enriched_result) - if cache_payload: - cache_store.upsert_metadata( - paper_key=enriched_result.get("paper_key") or build_paper_key_from_record(enriched_result), - title=enriched_result.get("title", ""), - source_url=enriched_result.get("url", ""), - metadata=cache_payload, - ) - - combined_results = cached_results + enriched_results - result_lookup = { - (result.get("paper_key") or build_paper_key_from_record(result)): result - for result in combined_results - } - ordered_results: list[dict[str, Any]] = [] - for result in results: - paper_key = build_paper_key_from_record(result) - ordered_results.append(result_lookup.get(paper_key, result)) - results = ordered_results - enrichment_summary = { - "provider": "openalex", - "attempted": len(results), - "matched": sum(1 for result in results if result.get("openalex_match_found")), - "cache_hits": len(cached_results), - } - print( - "[NODE] research_dispatch - " - f"OpenAlex enrichment matched {enrichment_summary['matched']}/{enrichment_summary['attempted']} papers " - f"(cache hits: {enrichment_summary['cache_hits']})" - ) - except Exception as e: - print(f"[NODE] research_dispatch - OpenAlex enrichment failed: {e}") - - if query_type == "web" and results: - for result in results: - url = result.get("url", "") - if not isinstance(url, str) or not url.startswith(("http://", "https://")): - continue - try: - markdown_content, fetch_method = fetch_webpage_as_markdown(url) - web_summary = summarize_webpage_markdown(markdown_content) - result["web_fetch_method"] = fetch_method - result["web_markdown_chars"] = len(markdown_content) - result["content"] = web_summary.get("summary", "") - result["key_excerpts"] = list(web_summary.get("key_excerpts", []) or []) - if result.get("content"): - result["snippet"] = result["content"][:500] - except Exception as e: - result["web_fetch_error"] = str(e) - - rerank_summary: dict[str, Any] = {} - pool_candidate_results = list(results) - if query_type == "paper" and results and paper_reranker is not None: - rerank_output = paper_reranker.rerank( - results=results, - original_target=state.input.target, - research_brief=state.research_brief, - paper_queries=paper_queries, - target_venues=state.input.target_venues, - ) - results = rerank_output.ranked_results - pool_candidate_results = list(results) - rerank_summary = rerank_output.summary - print( - "[NODE] research_dispatch - " - f"Paper rerank {rerank_summary.get('status', 'unknown')}: " - f"{rerank_summary.get('selected_count', 0)}/{rerank_summary.get('candidate_count', len(results))} selected for pool prioritization" - ) - elif query_type == "paper" and paper_reranker is not None: - rerank_summary = {"status": "skipped_no_results", "selected_count": 0, "candidate_count": 0} - - notes = f"## Research: {topic}\n\n" - for index, result in enumerate(results, 1): - notes += f"{index}. **{result.get('title', '')}**\n" - notes += f" Source: {result.get('url', '')} [{result.get('source_provider', 'unknown')}]\n" - for metadata_line in ResultFormatter.build_metadata_lines(result): - notes += f" {metadata_line}\n" - if query_type == "web" and result.get("web_fetch_method"): - notes += f" Web Fetch: {result.get('web_fetch_method')}\n" - snippet = result.get("content") or result.get("snippet", "") - notes += f" {snippet[:200]}...\n\n" - excerpts = result.get("key_excerpts") or [] - if query_type == "web" and excerpts: - for excerpt in excerpts[:2]: - notes += f" Evidence: {excerpt}\n" - notes += "\n" - - role_lookup = paper_role_lookup if query_type == "paper" else web_role_lookup - plan_item = role_lookup.get(_normalize_query_key(topic), {}) - runs.append( - { - "iteration": current_iteration, - "topic": topic, - "query_type": query_type, - "query_role": plan_item.get("role", ""), - "query_source": plan_item.get("source", ""), - "query_intent": plan_item.get("intent", ""), - "providers": providers, - "provider_attempts": provider_attempts, - "provider_used": selected_provider, - "metadata_providers": paper_metadata_providers if query_type == "paper" else [], - "metadata_enrichment": enrichment_summary if query_type == "paper" else {}, - "paper_rerank": rerank_summary if query_type == "paper" else {}, - "results": results, - "tool_calls": [], - } - ) - - if storage and results: - search_ref = storage.write_search_raw(topic, results) - compressed_search = storage.compress_search(topic, search_ref, results) - notes = f"## Research: {topic}\n\n" - for finding in compressed_search.key_findings[:3]: - notes += f"- {finding}\n" - metadata_highlights = ResultFormatter.build_highlights(results) - if metadata_highlights: - notes += "\n" + metadata_highlights - notes += "\n" - - if paper_config.enable_paper_pool and state.paper_pool and pool_candidate_results and query_type == "paper": - added = 0 - for result in pool_candidate_results: - candidate = dict(result) - candidate["query_topic"] = topic - candidate["query_intent"] = plan_item.get("intent", "") - candidate["from_citation_expansion"] = False - public_source_url = _resolve_public_paper_url(candidate) - download_source_url = _resolve_download_source_url(candidate) - if public_source_url: - if not candidate.get("source_url"): - candidate["source_url"] = public_source_url - if not candidate.get("url"): - candidate["url"] = public_source_url - if download_source_url: - candidate["download_source_url"] = download_source_url - if state.paper_pool.add_paper(candidate): - added += 1 - normalized_title = str(candidate.get("title", "") or "").lower().strip() - if normalized_title: - all_paper_titles.add(normalized_title) - - state.temp_data.setdefault("paper_pool_collection_runs", []).append( - { - "iteration": current_iteration, - "topic": topic, - "candidate_count": len(pool_candidate_results), - "added_count": added, - "pool_size": state.paper_pool.get_count(), - } - ) - print( - f"[PAPER POOL] Added {added}/{len(pool_candidate_results)} papers to pool " - f"(total: {state.paper_pool.get_count()})" - ) - - if paper_config.enable_scoring and results: - try: - candidates = [ - PaperCandidate( - paper_id=f"{r.get('retrieval_source') or r.get('source_provider') or r.get('source') or 'paper'}_{i}", - source=r.get("source_provider") or r.get("source") or "unknown", - external_id=r.get("external_id") or ResultFormatter.extract_arxiv_id(r.get("url", "")) or r.get("openalex_id", ""), - title=r.get("title", ""), - abstract=r.get("snippet", ""), - authors=ResultFormatter.normalize_authors(r.get("authors")), - url=r.get("url", ""), - snippet=r.get("snippet", ""), - retrieval_source=r.get("retrieval_source"), - metadata_sources=list(r.get("metadata_sources") or []), - identifiers=dict(r.get("identifiers") or {}), - venue=r.get("venue"), - publication_year=r.get("publication_year"), - cited_by_count=r.get("cited_by_count"), - primary_topic=r.get("primary_topic"), - topics=list(r.get("topics") or []), - institutions=list(r.get("institutions") or []), - oa_url=r.get("oa_url"), - pdf_url=r.get("pdf_url"), - metadata=dict(r.get("metadata") or {}), - ) - for i, r in enumerate(results) - if r.get("title") - ] - - if candidates: - pipeline = PaperPipeline() - enriched = pipeline.process_candidates(candidates) - state.temp_data.setdefault("enriched_papers", []).extend([candidate.model_dump() for candidate in enriched]) - except Exception: - pass - - pending_raw_notes += notes - state.researcher_messages.append({"unit_count": state.unit_count, "topic": topic, "notes": notes}) - - if storage: - storage.save_manifest(run_id=state.thread_id or "default") - state.temp_data["storage_manifest"] = { - "papers_count": len(storage.papers), - "search_results_count": len(storage.search_results), - } - - state.raw_notes = pending_raw_notes - state.temp_data["query_feedback"] = _build_query_feedback_v1(state) - _save_artifact( - state, - f"query_feedback_iter{current_iteration}.json", - json.dumps(state.temp_data["query_feedback"], ensure_ascii=False, indent=2), - ) - state.iteration_count += 1 - print(f"[NODE] research_dispatch - Completed {len(topics)} units") - _save_node_state(state, f"research_dispatch_iter{state.iteration_count - 1}") - return state - -# ============ Node 6.5: Paper Pool Process ============ -def paper_pool_process_node(state: IdeaGenState, executor: NodeExecutor | None = None) -> IdeaGenState: - """Process paper pool: score, expand citations, filter.""" - import time - node_start = time.time() - print("\n[NODE] paper_pool_process - Processing paper pool...") - paper_config = get_paper_processing_config() - if not paper_config.enable_paper_pool or not state.paper_pool: - print("[NODE] paper_pool_process - Skipped") - return state - paper_pool_manager = state.paper_pool - all_papers = paper_pool_manager.get_all_papers() - print(f"[NODE] paper_pool_process - Pool has {len(all_papers)} papers") - if not all_papers: - return state - _save_artifact( - state, - "paper_pool_pre_scoring.json", - json.dumps(all_papers, ensure_ascii=False, indent=2), - ) - - executor = executor or NodeExecutor("compress") - scorer = PaperRelevanceScorer(executor, threshold=paper_config.pool_relevance_threshold) - - # Score all papers in parallel - scoring_start = time.time() - scored_papers = [] - - from concurrent.futures import ThreadPoolExecutor, as_completed - max_workers = min(5, len(all_papers)) # Limit concurrent LLM calls - - with ThreadPoolExecutor(max_workers=max_workers) as scoring_pool: - future_to_paper = { - scoring_pool.submit(scorer._score_single, paper, state.input.target, state.research_brief): paper - for paper in all_papers - } - - for i, future in enumerate(as_completed(future_to_paper), 1): - paper = future_to_paper[future] - try: - score = future.result() - paper["relevance_score"] = score - scored_papers.append(paper) - if i % 5 == 0 or i == len(all_papers): - print(f" Scored {i}/{len(all_papers)} papers...") - except Exception as e: - print(f" Error scoring paper: {e}") - paper["relevance_score"] = 0.0 - scored_papers.append(paper) - - scored_papers.sort(key=_paper_ranking_sort_key) - scoring_time = time.time() - scoring_start - - # Save scoring results for debugging - state.temp_data["paper_pool_scores"] = [ - scorer.build_score_snapshot(p) - for p in scored_papers - ] - _save_artifact( - state, - "paper_pool_scores.json", - json.dumps(state.temp_data["paper_pool_scores"], ensure_ascii=False, indent=2), - ) - - print(f"[NODE] paper_pool_process - Scored {len(scored_papers)} papers in {scoring_time:.1f}s") - print(f"[NODE] paper_pool_process - Score range: {scored_papers[0].get('relevance_score', 0):.1f} - {scored_papers[-1].get('relevance_score', 0):.1f}") - - # Expand citations from top-k papers - citation_start = time.time() - top_k_papers = scored_papers[:paper_config.pool_top_k_for_expansion] - prepared_citations: list[dict[str, Any]] = [] - if top_k_papers: - _save_artifact( - state, - "paper_pool_expansion_seeds.json", - json.dumps(top_k_papers, ensure_ascii=False, indent=2), - ) - print(f"[NODE] paper_pool_process - Expanding citations from top {len(top_k_papers)} papers...") - for i, paper in enumerate(top_k_papers, 1): - identifiers = paper.get("identifiers") or {} - print( - f" [{i}] {paper.get('title', '')[:60]}... " - f"(score: {paper.get('relevance_score', 0):.1f}, " - f"openalex_id: {paper.get('openalex_id', '') or identifiers.get('openalex', '') or '-'}, " - f"arxiv_id: {paper.get('arxivId', '') or identifiers.get('arxiv', '') or '-'})" - ) - - expander = CitationExpander(max_citations_per_paper=paper_config.pool_max_citations_per_paper) - citations = expander.expand_from_paper_list(top_k_papers, max_papers=paper_config.pool_top_k_for_expansion) - citation_debug = dict(expander.last_run_stats or {}) - state.temp_data["citation_expansion_debug"] = citation_debug - citation_fetch_time = time.time() - citation_start - print(f"[NODE] paper_pool_process - Fetched {len(citations)} citations in {citation_fetch_time:.1f}s") - if citation_debug: - print( - "[NODE] paper_pool_process - Citation expansion summary: " - f"requested_papers={citation_debug.get('requested_papers', 0)}, " - f"raw_refs={citation_debug.get('total_raw_references', 0)}, " - f"unique_refs={citation_debug.get('total_unique_references', 0)}, " - f"duplicates={citation_debug.get('total_duplicate_references', 0)}" - ) - for paper_debug in citation_debug.get("papers", []): - attempts_summary = ", ".join( - f"{attempt.get('strategy', '?')}:{attempt.get('status', 'unknown')}" - + ( - f"({attempt.get('reference_count', 0)})" - if attempt.get("status") in {"success", "empty"} - else "" - ) - for attempt in paper_debug.get("lookup_attempts", []) - ) - print( - " [CITATION SUMMARY] " - f"[{paper_debug.get('index', '?')}] status={paper_debug.get('status', 'unknown')} " - f"reason={paper_debug.get('reason', '') or '-'} " - f"strategy={paper_debug.get('selected_strategy', '') or '-'} " - f"openalex_id={paper_debug.get('openalex_id', '') or '-'} " - f"arxiv_id={paper_debug.get('arxiv_id', '') or '-'} " - f"raw={paper_debug.get('raw_reference_count', 0)} " - f"added={paper_debug.get('added_reference_count', 0)} " - f"duplicates={paper_debug.get('duplicate_reference_count', 0)} " - f"attempts={attempts_summary or '-'} " - f"title={paper_debug.get('title', '')}" - ) - - if citations: - prepared_citations: list[dict[str, Any]] = [] - added_citations = 0 - for citation in citations: - prepared = dict(citation) - public_source_url = _resolve_public_paper_url(prepared) - download_source_url = _resolve_download_source_url(prepared) - if public_source_url: - if not prepared.get("source_url"): - prepared["source_url"] = public_source_url - if not prepared.get("url"): - prepared["url"] = public_source_url - if download_source_url: - prepared["download_source_url"] = download_source_url - prepared["from_citation_expansion"] = True - if paper_pool_manager.add_paper(prepared): - canonical_key = build_paper_key_from_record(prepared) - canonical_record = paper_pool_manager.get_paper_by_key(canonical_key) - if canonical_record is not None: - prepared_citations.append(canonical_record) - added_citations += 1 - - state.temp_data["paper_pool_citation_additions"] = { - "fetched_count": len(citations), - "added_count": added_citations, - "pool_size": paper_pool_manager.get_count(), - } - _save_artifact( - state, - "paper_pool_expanded_citations.json", - json.dumps(prepared_citations, ensure_ascii=False, indent=2), - ) - print( - f"[NODE] paper_pool_process - Added {added_citations}/{len(citations)} expanded citations to pool " - f"(total: {paper_pool_manager.get_count()})" - ) - - if prepared_citations: - citation_score_start = time.time() - print(f"[NODE] paper_pool_process - Scoring {len(prepared_citations)} citations...") - - with ThreadPoolExecutor(max_workers=max_workers) as citation_scoring_pool: - future_to_citation = { - citation_scoring_pool.submit(scorer._score_single, citation, state.input.target, state.research_brief): citation - for citation in prepared_citations - } - - for i, future in enumerate(as_completed(future_to_citation), 1): - citation = future_to_citation[future] - try: - score = future.result() - citation["relevance_score"] = score - if i % 5 == 0 or i == len(prepared_citations): - print(f" Scored {i}/{len(prepared_citations)} citations...") - except Exception as e: - print(f" Error scoring citation: {e}") - citation["relevance_score"] = 0.0 - - scored_papers = paper_pool_manager.get_all_papers() - scored_papers.sort(key=_paper_ranking_sort_key) - citation_score_time = time.time() - citation_score_start - print(f"[NODE] paper_pool_process - Citation scoring complete in {citation_score_time:.1f}s") - - # Filter by threshold and take top-N - filtered_papers = [p for p in scored_papers if p.get("relevance_score", 0.0) >= paper_config.pool_relevance_threshold] - filtered_papers.sort(key=_paper_ranking_sort_key) - final_papers = [] - source_completion_debug = [] - dropped_papers = [] - for rank, paper in enumerate(filtered_papers[:paper_config.pool_max_final_papers], 1): - prepared_paper = dict(paper) - prepared_paper["final_selected_for_pdf"] = True - prepared_paper["final_selection_rank"] = rank - completed_paper, completion_debug = _complete_final_paper_sources(prepared_paper) - source_completion_debug.append(completion_debug) - if completion_debug.get("status") == "failed": - dropped_papers.append( - { - "paper_key": completed_paper.get("paper_key", ""), - "title": completed_paper.get("title", ""), - "reason": completed_paper.get("source_completion_failure_reason", "unknown"), - "stage": "source_completion", - } - ) - continue - final_papers.append(completed_paper) - - final_selected_keys = { - paper.get("paper_key") - for paper in final_papers - if paper.get("paper_key") - } - selection_rank_by_key = { - str(paper.get("paper_key")): paper.get("final_selection_rank") - for paper in final_papers - if paper.get("paper_key") - } - for pool_paper in paper_pool_manager.get_all_papers(): - paper_key = pool_paper.get("paper_key") or build_paper_key_from_record(pool_paper) - if paper_key: - pool_paper["paper_key"] = paper_key - pool_paper["final_selected_for_pdf"] = bool(paper_key in final_selected_keys) - pool_paper["final_selection_rank"] = selection_rank_by_key.get(str(paper_key or "")) - - for run in state.temp_data.get("research_runs", []) or []: - for result in run.get("results", []) or []: - if not isinstance(result, dict): - continue - paper_key = result.get("paper_key") or build_paper_key_from_record(result) - if paper_key: - result["paper_key"] = paper_key - result["final_selected_for_pdf"] = bool(paper_key in final_selected_keys) - - state.temp_data["query_feedback"] = _build_query_feedback_v1(state) - _save_artifact( - state, - "query_feedback_final.json", - json.dumps(state.temp_data["query_feedback"], ensure_ascii=False, indent=2), - ) - - selection_status = "selected" if final_papers else "no_papers_passed_threshold" - if filtered_papers and not final_papers: - selection_status = "all_filtered_by_source_completion" - if not filtered_papers: - dropped_papers.append( - { - "paper_key": "", - "title": "", - "reason": "no_papers_passed_relevance_threshold", - "stage": "threshold", - } - ) - state.temp_data["paper_pool_selection"] = { - "status": selection_status, - "threshold": paper_config.pool_relevance_threshold, - "scored_count": len(scored_papers), - "threshold_pass_count": len(filtered_papers), - "selected_for_download_count": len(final_papers), - "dropped_count": len(dropped_papers), - } - state.temp_data["final_selected_paper_keys"] = list(final_selected_keys) - state.temp_data["paper_pool_dropped_papers"] = dropped_papers - ranked_pool_papers = sorted(paper_pool_manager.get_all_papers(), key=_paper_ranking_sort_key) - state.temp_data["paper_pool_post_expansion_ranking"] = [ - { - **scorer.build_score_snapshot(paper), - "final_selected_for_pdf": bool(paper.get("paper_key") in final_selected_keys), - "final_selection_rank": paper.get("final_selection_rank"), - } - for paper in ranked_pool_papers - ] - - total_time = time.time() - node_start - print(f"[NODE] paper_pool_process - {len(filtered_papers)} papers passed threshold {paper_config.pool_relevance_threshold}") - print(f"[NODE] paper_pool_process - Selected {len(final_papers)} final papers") - print(f"[NODE] paper_pool_process - Total time: {total_time:.1f}s") - - state.temp_data["final_papers_for_compress"] = final_papers - state.temp_data["paper_pool_source_completion"] = source_completion_debug - _save_artifact( - state, - "paper_pool_post_expansion_ranking.json", - json.dumps(state.temp_data["paper_pool_post_expansion_ranking"], ensure_ascii=False, indent=2), - ) - _save_artifact( - state, - "paper_pool_source_completion.json", - json.dumps(source_completion_debug, ensure_ascii=False, indent=2), - ) - _save_artifact( - state, - "paper_pool_final_selection.json", - json.dumps(final_papers, ensure_ascii=False, indent=2), - ) - if dropped_papers: - _save_artifact( - state, - "paper_pool_dropped_papers.json", - json.dumps(dropped_papers, ensure_ascii=False, indent=2), - ) - _save_node_state(state, "paper_pool_process") - return state - -# ============ Node 7: Compress ============ -def compress_node(state: IdeaGenState, executor: NodeExecutor | None = None) -> IdeaGenState: - from threading import Semaphore - from src.modules.idea_gen.tools import parse_paper_to_markdown_with_parser - - print("\n[NODE] compress - Section-based compression...") - - output_dir = _get_output_dir(state) - papers_dir = output_dir / "papers" - papers_dir.mkdir(parents=True, exist_ok=True) - executor = executor or NodeExecutor("compress") - language = state.input.language - paper_config = get_paper_processing_config() - storage = StorageManager(output_dir) if paper_config.enable_two_tier_storage else None - cache_store = _create_paper_cache_store() - if cache_store: - state.temp_data["paper_cache_db_path"] = str(cache_store.db_path) - - # Paper pool mode: download and parse final selected papers - if paper_config.enable_paper_pool and state.temp_data.get("final_papers_for_compress"): - final_papers = state.temp_data["final_papers_for_compress"] - print(f"[COMPRESS] Downloading {len(final_papers)} final selected papers...") - - mineru_semaphore = Semaphore(paper_config.mineru_max_concurrency) - - with ThreadPoolExecutor(max_workers=paper_config.pdf_parse_max_workers) as pool: - future_to_idx = { - pool.submit( - _get_or_parse_paper_for_compress, - paper, - i, - papers_dir=papers_dir, - storage=storage, - cache_store=cache_store, - parse_paper_to_markdown_with_parser=parse_paper_to_markdown_with_parser, - paper_config=paper_config, - mineru_semaphore=mineru_semaphore, - ): i - for i, paper in enumerate(final_papers) - } - parsed_papers = [] - for future in as_completed(future_to_idx): - idx = future_to_idx[future] - try: - result = future.result(timeout=paper_config.pdf_timeout) - parsed_papers.append((idx, result)) - status = "(cache)" if result["cache_hit"] else "(partial)" if result["is_partial"] else "" - print(f"[COMPRESS] ✓ {result['title'][:50]} {status}") - except Exception as e: - print(f"[COMPRESS] ✗ {final_papers[idx].get('title', 'unknown')[:50]}: {e}") - - parsed_papers.sort(key=lambda x: x[0]) - state.temp_data["compressed_papers"] = [p[1] for p in parsed_papers] - - compressed_papers_text = "" - if state.temp_data.get("compressed_papers"): - strategy = paper_config.compression_strategy - strategy_key = f"{strategy}|{language}" - print(f"[COMPRESS] Using strategy: {strategy}") - compressed_papers_text = f"\n\n[Papers - {strategy.upper()} Compressed]\n\n" - papers_to_compress = list(enumerate(state.temp_data["compressed_papers"], 1)) - paper_results: dict[int, dict[str, Any]] = {} - paper_worker_count = min(max(1, paper_config.pdf_parse_max_workers), max(1, len(papers_to_compress))) - - with ThreadPoolExecutor(max_workers=paper_worker_count) as paper_pool: - future_to_position = { - paper_pool.submit( - _compress_single_paper, - position, - paper, - papers_dir=papers_dir, - cache_store=cache_store, - strategy=strategy, - strategy_key=strategy_key, - paper_config=paper_config, - language=language, - executor=executor, - storage=storage, - ): position - for position, paper in papers_to_compress - } - for future in as_completed(future_to_position): - position = future_to_position[future] - try: - paper_results[position] = future.result() - except Exception as e: - paper = state.temp_data["compressed_papers"][position - 1] - print(f"[COMPRESS] Failed for {paper['paper_id']}: {e}") - paper_results[position] = { - "position": position, - "paper": paper, - "text": f"## Paper {position}: {paper['title']}\n\n(compression failed: {e})\n\n", - "log": None, - } - - completed_papers = [] - compression_cache_hits = 0 - for position, _ in papers_to_compress: - result = paper_results[position] - completed_papers.append(result["paper"]) - compressed_papers_text += result["text"] - if result["paper"].get("compression_cache_hit"): - compression_cache_hits += 1 - if result["log"]: - print(result["log"]) - - state.temp_data["compressed_papers"] = completed_papers - state.temp_data["compression_cache_summary"] = { - "strategy_key": strategy_key, - "hits": compression_cache_hits, - "misses": len(completed_papers) - compression_cache_hits, - "total": len(completed_papers), - } - if storage: - storage.save_manifest(run_id=state.thread_id or "default") - state.temp_data["storage_manifest"] = { - "papers_count": len(storage.papers), - "search_results_count": len(storage.search_results), - } - - if paper_config.enable_paper_pool: - final_papers = list(state.temp_data.get("final_papers_for_compress", []) or []) - selection_info = dict(state.temp_data.get("paper_pool_selection") or {}) - summary_lines = ["[Final Paper Pool Summary]"] - summary_lines.append(f"- Scored papers: {selection_info.get('scored_count', 0)}") - summary_lines.append(f"- Passed threshold: {selection_info.get('threshold_pass_count', 0)}") - summary_lines.append(f"- Selected for download: {selection_info.get('selected_for_download_count', 0)}") - for paper in final_papers: - summary_lines.append( - f"- #{paper.get('final_selection_rank', '?')} {paper.get('title', '')} " - f"(score={paper.get('relevance_score', 0.0):.2f}, " - f"citation_expanded={bool(paper.get('from_citation_expansion'))}, " - f"query_topic={paper.get('query_topic', '') or '-'})" + payload = dict(input_data) + state = IdeaGenState( + input=IdeaGenInput(**payload), + thread_id=payload.get("thread_id", ""), ) - state.notes = "\n".join(summary_lines) - if compressed_papers_text: - state.notes += "\n\n" + compressed_papers_text - state.raw_notes = "" - else: - # Append compressed papers to notes directly (no second-level compression) - state.notes += "\n\n" + compressed_papers_text if state.notes else compressed_papers_text - state.notes += "\n\n" + state.raw_notes if state.raw_notes else "" - state.raw_notes = "" - - if compressed_papers_text: - _save_artifact(state, "compressed_notes.md", compressed_papers_text) - - print(f"[NODE] compress - Done, total notes: {len(state.notes)} chars") - _save_node_state(state, "compress") - return state - -# ============ Node 8: Critic ============ -def critic_node(state: IdeaGenState) -> IdeaGenState: - import asyncio - from src.modules.idea_gen.config import get_critic_config, get_workflow_config - from src.modules.idea_gen.prompts import build_critic_prompt - from src.modules.idea_gen.utils.critic_executor import MultiModelCriticExecutor, aggregate_critiques + return self.graph.invoke(state) - print(f"\n[NODE] critic - Iteration {state.critic_iteration_count}") - critic_config = get_critic_config() - workflow_config = get_workflow_config() - - configured_models = [model_name for model_name in critic_config.model_names if model_name and model_name.strip()] - state.temp_data["critic_requested_models"] = configured_models - if not configured_models: - print("[NODE] critic - Skipped (no models configured)") - return _finalize_critic_state( - state, - action="ACCEPT", - status="skipped_no_models", - ran=False, - ) - - effective_min_successful_models = min( - max(1, critic_config.min_successful_models), - len(configured_models), - ) - - if state.critic_iteration_count >= workflow_config.max_critic_iterations: - print("[NODE] critic - ACCEPT (max iterations)") - return _finalize_critic_state( - state, - action="ACCEPT", - status="accepted_max_iterations", - ran=False, - ) - - system, user = build_critic_prompt( - research_brief=state.research_brief, - notes=state.notes, - previous_feedback=state.critic_feedback - ) - - executor = MultiModelCriticExecutor() - model_results = asyncio.run(executor.evaluate_parallel(system, user)) - - if not model_results: - print("[NODE] critic - ACCEPT (no results)") - return _finalize_critic_state( - state, - action="ACCEPT", - status="accepted_no_results", - ran=True, - successful_models=0, - ) - - if len(model_results) < effective_min_successful_models: - print( - "[NODE] critic - ACCEPT " - f"(insufficient successful models: {len(model_results)}/{effective_min_successful_models})" - ) - return _finalize_critic_state( - state, - action="ACCEPT", - status="accepted_insufficient_models", - ran=True, - successful_models=len(model_results), - ) - - aggregated = aggregate_critiques(model_results) - avg_score = sum(aggregated.consensus_scores.values()) / len(aggregated.consensus_scores) - - if avg_score >= workflow_config.critic_quality_threshold: - state.critic_feedback = None - print(f"[NODE] critic - ACCEPT (score: {avg_score:.1f})") - return _finalize_critic_state( - state, - action="ACCEPT", - status="accepted_threshold", - ran=True, - successful_models=len(model_results), - avg_score=avg_score, - ) - else: - blocking = [f"{dim}: {score:.1f}/10" for dim, score in aggregated.consensus_scores.items() if score < workflow_config.critic_quality_threshold] - state.critic_feedback = { - "blocking_issues": ", ".join(blocking), - "suggestions": aggregated.summary - } - state.critic_iteration_count += 1 - print(f"[NODE] critic - ITERATE (score: {avg_score:.1f})") - return _finalize_critic_state( - state, - action="ITERATE", - status="iterate_threshold", - ran=True, - successful_models=len(model_results), - avg_score=avg_score, - ) - -# ============ Node 9: Report ============ -def report_node(state: IdeaGenState, executor: NodeExecutor | None = None) -> IdeaGenState: - print("\n[NODE] report - Generating final report...") - - messages_str = "\n".join(f"{m.get('role', 'user')}: {m.get('content', '')}" for m in state.messages) if state.messages else f"user: {state.input.target}" - system = build_report_prompt( - research_brief=state.research_brief, - messages=messages_str, - findings=state.notes, - date=state.temp_data.get("workflow_date"), - language=state.input.language, - target_venues=state.input.target_venues - ) - user = f"Research brief:\n{state.research_brief}\n\nNotes:\n{state.notes}" - - executor = executor or NodeExecutor("report") - response = executor.invoke_text(system, user) - response = ReportSourceHandler.repair_sources(response, state) - - state.temp_data["final_report"] = response - _save_artifact(state, "final_report.md", response) - _save_node_state(state, "report") - - print(f"[NODE] report - Done, {len(response)} chars") - return state - -# ============ Node 10: Finalize ============ -def finalize_node(state: IdeaGenState) -> IdeaGenState: - print("\n[NODE] finalize - Workflow complete") - print(f"[SUMMARY] Iterations: {state.iteration_count}, Units: {state.unit_count}") - return state - -# ============ Graph Builder ============ def build_workflow(): - """Build idea generation workflow graph.""" + """Build the idea generation workflow graph.""" if not LANGGRAPH_AVAILABLE: - clarify_executor = NodeExecutor("clarify") - brief_executor = NodeExecutor("brief") - query_generation_executor = NodeExecutor("query_generation") - supervisor_executor = NodeExecutor("supervisor") - research_executor = IdeaResearchExecutor(node_executor=NodeExecutor("research_dispatch")) - compress_executor = NodeExecutor("compress") - report_executor = NodeExecutor("report") - - # Simple sequential execution for testing - class SimpleWorkflow: - def invoke(self, input_data): - state = IdeaGenState( - input=IdeaGenInput(**input_data), - thread_id=input_data.get("thread_id", "") - ) - state = start_node(state) - state = clarify_node(state, clarify_executor) - if state.temp_data.get("need_clarification"): - return state - state = brief_node(state, brief_executor) - state = query_generation_node(state, query_generation_executor) - for _ in range(state.input.max_iterations): - state = supervisor_node(state, supervisor_executor) - action = state.temp_data.get("supervisor_action", "RESEARCH_COMPLETE") - if action == "CONDUCT_RESEARCH": - state = query_generation_node(state, query_generation_executor) - state = research_dispatch_node(state, research_executor) - elif action == "THINK": - continue - elif action == "RESEARCH_COMPLETE": - break - paper_config = get_paper_processing_config() - if paper_config.enable_paper_pool: - state = paper_pool_process_node(state) - state = compress_node(state, compress_executor) - state = critic_node(state) - if state.temp_data.get("critic_action") == "ITERATE": - return state - state = report_node(state, report_executor) - state = finalize_node(state) - return state - return SimpleWorkflow() + raise RuntimeError("idea_gen workflow requires langgraph") clarify_executor = NodeExecutor("clarify") brief_executor = NodeExecutor("brief") query_generation_executor = NodeExecutor("query_generation") supervisor_executor = NodeExecutor("supervisor") + supervisor_validation_executor = NodeExecutor("supervisor_validator") research_executor = IdeaResearchExecutor(node_executor=NodeExecutor("research_dispatch")) compress_executor = NodeExecutor("compress") report_executor = NodeExecutor("report") - - # LangGraph implementation with input wrapper - class WorkflowWrapper: - def __init__(self, compiled_graph): - self.graph = compiled_graph - - def invoke(self, input_data): - # Convert dict input to proper state - state = IdeaGenState( - input=IdeaGenInput(**input_data), - thread_id=input_data.get("thread_id", "") - ) - return self.graph.invoke(state) + seed_validation_executor = NodeExecutor("seed_discovery_validator") graph = StateGraph(IdeaGenState) graph.add_node("start", start_node) graph.add_node("clarify", lambda state: clarify_node(state, clarify_executor)) graph.add_node("brief", lambda state: brief_node(state, brief_executor)) - graph.add_node("query_generation", lambda state: query_generation_node(state, query_generation_executor)) - graph.add_node("supervisor", lambda state: supervisor_node(state, supervisor_executor)) + graph.add_node( + "query_generation", + lambda state: query_generation_node(state, query_generation_executor), + ) + graph.add_node( + "supervisor", + lambda state: supervisor_node( + state, + supervisor_executor, + validate_immediately=False, + ), + ) + graph.add_node( + "supervisor_validate", + lambda state: supervisor_validate_node(state, supervisor_validation_executor), + ) graph.add_node("research_dispatch", lambda state: research_dispatch_node(state, research_executor)) + graph.add_node( + "seed_discovery", + lambda state: seed_discovery_node(state, validate_immediately=False), + ) + graph.add_node( + "seed_discovery_validate", + lambda state: seed_discovery_validate_node(state, seed_validation_executor), + ) + graph.add_node("evidence_orchestrator", evidence_orchestrator_node) + graph.add_node("hypothesis", lambda state: hypothesis_node(state)) graph.add_node("paper_pool_process", paper_pool_process_node) graph.add_node("compress", lambda state: compress_node(state, compress_executor)) graph.add_node("critic", critic_node) graph.add_node("report", lambda state: report_node(state, report_executor)) graph.add_node("finalize", finalize_node) - def route_clarify(state): - return END if state.temp_data.get("need_clarification") else "brief" - - def route_supervisor(state): - action = state.temp_data.get("supervisor_action", "RESEARCH_COMPLETE") - if action == "THINK": - return "supervisor" - if action == "CONDUCT_RESEARCH": - return "query_generation" - paper_config = get_paper_processing_config() - return "paper_pool_process" if paper_config.enable_paper_pool else "compress" - - def route_query_generation(state): - action = state.temp_data.get("supervisor_action", "") - return "research_dispatch" if action == "CONDUCT_RESEARCH" else "supervisor" - - def route_critic(state): - if state.temp_data.get("critic_action") == "ITERATE": - return "supervisor" - return "report" - - def route_compress(state): - return "critic" - graph.set_entry_point("start") graph.add_edge("start", "clarify") graph.add_conditional_edges("clarify", route_clarify) graph.add_edge("brief", "query_generation") graph.add_conditional_edges("query_generation", route_query_generation) - graph.add_conditional_edges("supervisor", route_supervisor) + graph.add_edge("supervisor", "supervisor_validate") + graph.add_conditional_edges("supervisor_validate", route_supervisor) graph.add_edge("research_dispatch", "supervisor") graph.add_edge("paper_pool_process", "compress") + graph.add_edge("seed_discovery", "seed_discovery_validate") + graph.add_edge("seed_discovery_validate", "evidence_orchestrator") + graph.add_edge("evidence_orchestrator", "supervisor") + graph.add_edge("hypothesis", "compress") graph.add_conditional_edges("compress", route_compress) graph.add_conditional_edges("critic", route_critic) graph.add_edge("report", "finalize") graph.add_edge("finalize", END) return WorkflowWrapper(graph.compile()) + + +__all__ = [ + "END", + "LANGGRAPH_AVAILABLE", + "WorkflowWrapper", + "IdeaGenInput", + "IdeaGenState", + "NodeError", + "OpenAlexEnricher", + "PaperReranker", + "start_node", + "clarify_node", + "brief_node", + "query_generation_node", + "supervisor_node", + "supervisor_validate_node", + "research_dispatch_node", + "seed_discovery_node", + "seed_discovery_validate_node", + "evidence_orchestrator_node", + "hypothesis_node", + "paper_pool_process_node", + "compress_node", + "critic_node", + "report_node", + "finalize_node", + "route_clarify", + "route_query_generation", + "route_compress", + "route_supervisor", + "route_critic", + "build_workflow", + "_build_query_feedback_v1", + "_create_paper_cache_store", +] diff --git a/backend/src/modules/paper2figure/utils/agent_utils.py b/backend/src/modules/paper2figure/utils/agent_utils.py index 82b5a30c..494aada6 100644 --- a/backend/src/modules/paper2figure/utils/agent_utils.py +++ b/backend/src/modules/paper2figure/utils/agent_utils.py @@ -15,11 +15,6 @@ from PIL import Image from pydantic import BaseModel, Field -try: - from .mineru_zip import collect_framework_image_items -except ImportError: # pragma: no cover - script mode fallback - from mineru_zip import collect_framework_image_items - TARGET = r"""\begin{abstract} Knowledge-Graph-based multi-hop Question Generation (KGQG) can effectively address the problem of data scarcity in Question Answering (QA) tasks, by reasoning over the graph topological structure, thereby generating high-quality complex multi-hop questions. diff --git a/backend/src/modules/paper2figure/utils/mineru_probe.py b/backend/src/modules/paper2figure/utils/mineru_probe.py index 9c562eee..a3eda575 100644 --- a/backend/src/modules/paper2figure/utils/mineru_probe.py +++ b/backend/src/modules/paper2figure/utils/mineru_probe.py @@ -2,7 +2,6 @@ from __future__ import annotations -import json import os import time import uuid diff --git a/backend/src/modules/paper2figure/utils/mineru_zip.py b/backend/src/modules/paper2figure/utils/mineru_zip.py index 1d6dbec9..15f4b3fc 100644 --- a/backend/src/modules/paper2figure/utils/mineru_zip.py +++ b/backend/src/modules/paper2figure/utils/mineru_zip.py @@ -3,7 +3,6 @@ from __future__ import annotations import hashlib import json -import os import shutil import zipfile from io import BytesIO diff --git a/backend/src/modules/registry.py b/backend/src/modules/registry.py index 59f28b31..7d0c011f 100644 --- a/backend/src/modules/registry.py +++ b/backend/src/modules/registry.py @@ -1,6 +1,5 @@ """Simplified module registry.""" from typing import Callable, Any -from pydantic import BaseModel _modules: dict[str, Callable] = {} diff --git a/backend/src/modules/review_gen/__init__.py b/backend/src/modules/review_gen/__init__.py index be603ca2..b905997e 100644 --- a/backend/src/modules/review_gen/__init__.py +++ b/backend/src/modules/review_gen/__init__.py @@ -2,7 +2,6 @@ from src.modules.registry import register_module from src.modules.review_gen.workflow import build_workflow -register_module("review_gen", build_workflow) register_module("review_gen", build_workflow) __all__ = ["build_workflow"] diff --git a/backend/src/modules/review_gen/config.py b/backend/src/modules/review_gen/config.py index ed0e10f0..46c3b6bf 100644 --- a/backend/src/modules/review_gen/config.py +++ b/backend/src/modules/review_gen/config.py @@ -1,6 +1,6 @@ """Configuration for review_gen workflow nodes and runtime limits.""" import os -from dataclasses import dataclass, field +from dataclasses import dataclass from langchain_openai import ChatOpenAI diff --git a/backend/src/modules/review_gen/workflow.py b/backend/src/modules/review_gen/workflow.py index 82444b6e..f8217044 100644 --- a/backend/src/modules/review_gen/workflow.py +++ b/backend/src/modules/review_gen/workflow.py @@ -6,9 +6,7 @@ from pathlib import Path from typing import Any -from pydantic import BaseModel, ConfigDict, Field - -from src.modules.review_gen.config import create_node_model, get_review_config +from src.modules.review_gen.config import get_review_config from src.modules.review_gen.utils.executor import NodeExecutor from src.modules.review_gen.prompts import ( build_analyze_prompt, @@ -26,7 +24,6 @@ AnnotationItem, KeyRef, ReviewInput, - ReviewSections, ReviewState, ) from src.modules.review_gen.utils.pdf_parser import ( diff --git a/backend/src/utils/structured_output.py b/backend/src/utils/structured_output.py index d61e9f4d..d06769e9 100644 --- a/backend/src/utils/structured_output.py +++ b/backend/src/utils/structured_output.py @@ -1,19 +1,72 @@ """Structured output parsing utilities.""" import json import re +from typing import Any -def parse_structured_output(text: str, schema_name: str = "output") -> dict: - """Extract JSON from LLM response.""" - # Try to find JSON block - json_match = re.search(r'```json\s*(\{.*?\})\s*```', text, re.DOTALL) - if json_match: - return json.loads(json_match.group(1)) +_FENCED_BLOCK_RE = re.compile(r"```(?:json|JSON)?\s*(.*?)\s*```", re.DOTALL) + + +def _expects_list(schema_name: str | type) -> bool: + return schema_name is list or (isinstance(schema_name, type) and issubclass(schema_name, list)) + + +def _coerce_expected_type(value: Any, expect_list: bool) -> dict | list | None: + if expect_list: + if isinstance(value, list): + return value + if isinstance(value, dict): + for key in ("seeds", "items", "results", "data", "output"): + candidate = value.get(key) + if isinstance(candidate, list): + return candidate + return None + + return value if isinstance(value, dict) else None + + +def _try_raw_decode(candidate: str, expect_list: bool) -> dict | list | None: + decoder = json.JSONDecoder() + stripped = candidate.strip() + if not stripped: + return None + + try: + parsed, _ = decoder.raw_decode(stripped) + except json.JSONDecodeError: + return None - # Try to find raw JSON - json_match = re.search(r'\{.*\}', text, re.DOTALL) - if json_match: - return json.loads(json_match.group(0)) + return _coerce_expected_type(parsed, expect_list) + + +def _iter_json_candidates(text: str, expect_list: bool): + preferred_start = "[" if expect_list else "{" + + yield text + + for match in _FENCED_BLOCK_RE.finditer(text): + yield match.group(1) + + start_chars = (preferred_start,) if preferred_start in text else () + fallback_chars = tuple(ch for ch in ("{", "[") if ch != preferred_start and ch in text) + for start_char in (*start_chars, *fallback_chars): + start = 0 + while True: + index = text.find(start_char, start) + if index == -1: + break + yield text[index:] + start = index + 1 + + +def parse_structured_output(text: str, schema_name: str | type = "output") -> dict | list: + """Extract JSON from LLM response.""" + expect_list = _expects_list(schema_name) - raise ValueError(f"No valid JSON found in {schema_name} response") + for candidate in _iter_json_candidates(text or "", expect_list): + parsed = _try_raw_decode(candidate, expect_list) + if parsed is not None: + return parsed + schema_desc = "list" if expect_list else "dict" + raise ValueError(f"No valid JSON {schema_desc} found in {schema_name} response") diff --git a/backend/tests/demo_resume.sh b/backend/tests/demo_resume.sh new file mode 100755 index 00000000..06f37322 --- /dev/null +++ b/backend/tests/demo_resume.sh @@ -0,0 +1,27 @@ +#!/bin/bash +# Resume 功能使用示例 + +echo "==========================================" +echo "Resume 功能演示" +echo "==========================================" + +# 1. 查看现有的输出目录 +echo -e "\n[1] 查看现有的输出目录:" +ls -lt backend/output/ | head -10 + +# 2. 显示帮助信息 +echo -e "\n[2] 显示帮助信息:" +uv run python tests/run_idea_gen_seed_hypothesis_e2e_simple.py --help + +# 3. 测试 resume 功能 +echo -e "\n[3] 测试 resume 辅助函数:" +uv run python tests/test_resume_functionality.py + +echo -e "\n==========================================" +echo "演示完成" +echo "==========================================" +echo "" +echo "使用方法:" +echo " 正常运行: uv run python tests/run_idea_gen_seed_hypothesis_e2e_simple.py" +echo " 从最新恢复: uv run python tests/run_idea_gen_seed_hypothesis_e2e_simple.py --resume" +echo " 从指定恢复: uv run python tests/run_idea_gen_seed_hypothesis_e2e_simple.py --resume-from <dir_name>" diff --git a/backend/tests/run_idea_gen_e2e.py b/backend/tests/run_idea_gen_e2e.py index f41dc21a..8ad977ba 100644 --- a/backend/tests/run_idea_gen_e2e.py +++ b/backend/tests/run_idea_gen_e2e.py @@ -49,7 +49,7 @@ def validate_workflow_execution(state: wf.IdeaGenState, output_dir: Path): print(f"✓ Notes generated ({len(state.notes)} chars)") # 4. Final report - final_report = state.temp_data.get("final_report", "") + final_report = state.final_report if not final_report: warnings.append("Final report not generated") else: @@ -81,7 +81,7 @@ def validate_workflow_execution(state: wf.IdeaGenState, output_dir: Path): # Gather paper channel status once so downstream validations can avoid # cascading warnings when retrieval produced no paper results. - research_runs = state.temp_data.get("research_runs", []) + research_runs = state.research_runs paper_runs = [run for run in research_runs if run.get("query_type") == "paper"] paper_result_count = sum(len(run.get("results", [])) for run in paper_runs) has_paper_results = paper_result_count > 0 @@ -109,7 +109,7 @@ def validate_workflow_execution(state: wf.IdeaGenState, output_dir: Path): else: errors.append("Paper pool not initialized") - final_papers = state.temp_data.get("final_papers_for_compress", []) + final_papers = state.temp.final_papers_for_compress if final_papers: print(f"✓ Final papers selected for compression ({len(final_papers)} papers)") if "relevance_score" in final_papers[0]: @@ -142,7 +142,7 @@ def validate_workflow_execution(state: wf.IdeaGenState, output_dir: Path): warnings.append("paper_pool_selection summary not recorded") # 7. Paper compression validation - compressed_papers = state.temp_data.get("compressed_papers", []) + compressed_papers = state.temp.compressed_papers if compressed_papers: print(f"✓ Paper compression executed ({len(compressed_papers)} papers)") @@ -489,7 +489,10 @@ def validate_workflow_execution(state: wf.IdeaGenState, output_dir: Path): }, "workflow_config": { "default_max_iterations": 2, #8 + "global_max_iterations": 4, "max_concurrent_research_units": 2, #6 + "max_consecutive_thinks": 2, + "max_total_thinks_per_phase": 3, "search_results_per_topic": 3, #15 "max_critic_iterations": 1, # 3 "critic_quality_threshold": 6.0, @@ -501,6 +504,64 @@ def validate_workflow_execution(state: wf.IdeaGenState, output_dir: Path): "max_tokens": 15000, "timeout_seconds": 480, }, + "seed_config": { + "target_seed_count": 2, + "min_distinct_seed_directions_for_completion": 2, + }, + "evidence_config": { + "top_k": 3, + "search_top_k": 8, + "max_parallel_seed_workers": 4, + "rrf_k": 60, + "ready_unique_papers_threshold": 5, + "ready_completeness_threshold": 0.75, + "lane_targets": { + "inspiration": 1, + "novelty": 2, + "contradiction": 1, + "execution": 1, + }, + "lane_hybrid_weights": { + "inspiration": {"embedding": 0.75, "bm25": 0.25}, + "novelty": {"embedding": 0.35, "bm25": 0.65}, + "contradiction": {"embedding": 0.60, "bm25": 0.40}, + "execution": {"embedding": 0.30, "bm25": 0.70}, + }, + "lane_completeness_weights": { + "inspiration": 0.15, + "novelty": 0.40, + "contradiction": 0.25, + "execution": 0.20, + }, + "lane_quality_thresholds": { + "inspiration": 0.55, + "novelty": 0.65, + "contradiction": 0.65, + "execution": 0.65, + }, + "required_lanes": ["novelty", "contradiction", "execution"], + }, + "report_config": { + "idea_detail_reference_pool_size": 10, + "report_search_top_k": 12, + "idea_detail_prior_work_limit": 4, + "idea_detail_contrast_limit": 3, + "idea_detail_inspiration_limit": 2, + "idea_detail_execution_limit": 1, + "idea_detail_lane_limits": { + "novelty": 3, + "contradiction": 3, + "inspiration": 3, + "execution": 2, + }, + "ideas_overview_citations_per_idea": 1, + "section_min_citations": { + "limitations": 2, + "idea_detail": 2, + "comparison": 2, + }, + "section_retry_max_rounds": 1, + }, "paper_config": { "enable_scoring": False, "enable_two_tier_storage": True, @@ -536,6 +597,15 @@ def main(): for key, value in TEST_CONFIG["critic_config"].items(): setattr(cfg.CRITIC_CONFIG, key, value) + for key, value in TEST_CONFIG.get("seed_config", {}).items(): + setattr(cfg.SEED_CONFIG, key, value) + + for key, value in TEST_CONFIG.get("evidence_config", {}).items(): + setattr(cfg.EVIDENCE_CONFIG, key, value) + + for key, value in TEST_CONFIG.get("report_config", {}).items(): + setattr(cfg.REPORT_CONFIG, key, value) + for key, value in TEST_CONFIG["paper_config"].items(): setattr(cfg.PAPER_PROCESSING_CONFIG, key, value) diff --git a/backend/tests/run_idea_gen_seed_hypothesis_e2e.py b/backend/tests/run_idea_gen_seed_hypothesis_e2e.py new file mode 100644 index 00000000..a1fb01d0 --- /dev/null +++ b/backend/tests/run_idea_gen_seed_hypothesis_e2e.py @@ -0,0 +1,271 @@ +"""End-to-end test for the seed → hypothesis workflow.""" +from __future__ import annotations + +from pathlib import Path + +import pytest + + +# Test configuration - aligned with run_idea_gen_e2e.py +TEST_CONFIG = { + # ========== 工作流输入参数 ========== + "target": ( + "Survey retrieval-augmented generation (RAG) for long-context LLM agents " + "and propose novel research ideas with concrete evaluation plans." + ), + "academic_domain": "AI/ML", # 研究领域 + "target_venues": ["NIPS", "ICML", "ICLR"], # 目标发表会议 + "max_iterations": 6, # 每个研究阶段(broad/evidence)的最大迭代次数 + "language": "zh", # 输出语言(zh/en) + "allow_clarification": False, # 是否允许澄清问题 + "thread_id": "seed-hypothesis-e2e-test", # 唯一线程标识符 + "run_dir_name": "idea_gen_seed_hypothesis_e2e", # 输出目录名称 + + # ========== 节点 LLM 配置 ========== + # 每个工作流节点的模型设置 + "node_configs": { + "clarify": {"model": "gpt-4o-mini", "temperature": 0.1, "max_tokens": 10000}, + "brief": {"model": "gpt-4o", "temperature": 0.1, "max_tokens": 5000}, + "query_generation": {"model": "gpt-4o", "temperature": 0.1, "max_tokens": 3000}, + "supervisor": {"model": "gpt-5.4", "temperature": 0.1, "max_tokens": 16000}, + "seed_discovery": {"model": "gpt-5.4", "temperature": 0.1, "max_tokens": 18000}, + "seed_discovery_validator": {"model": "gpt-5.4", "temperature": 0.1, "max_tokens": 8000}, + "evidence_orchestrator": {"model": "gpt-5.4", "temperature": 0.1, "max_tokens": 12000}, + "hypothesis": {"model": "gpt-5.4", "temperature": 0.1, "max_tokens": 16000}, + "compress": {"model": "gpt-4o", "temperature": 0.1, "max_tokens": 12000}, + "report": {"model": "gpt-5.4", "temperature": 0.1, "max_tokens": 18000}, + }, + + # ========== 节点工具配置 ========== + # 特定节点的可选工具/运行时配置 + "node_tool_configs": { + "research_dispatch": { + "enabled": True, + "paper_providers": ["ai4scholar", "semantic_scholar", "arxiv"], + "paper_metadata_providers": ["openalex"], + } + }, + + # ========== 工作流运行时配置 ========== + "workflow_config": { + "default_max_iterations": 6, # 每阶段默认最大迭代次数 + "global_max_iterations": 20, # 全局硬预算上限(防止无限循环) + "max_concurrent_research_units": 5, # 最大并行研究任务数 + "max_consecutive_thinks": 2, # 强制进入研究前的最大连续 THINK 次数 + "max_total_thinks_per_phase": 4, # 每阶段最大总 THINK 次数 + "search_results_per_topic": 15, # 每个研究主题的搜索结果数 + "max_critic_iterations": 3, # 最大 critic 反馈迭代次数 + "critic_quality_threshold": 7.0, # critic 接受的质量分数阈值(0-10) + }, + + # ========== Critic 配置 ========== + "critic_config": { + "model_names": ["gpt-5.4", "gemini-3-pro-preview", "claude-opus-4-6"], # 多模型评审使用的模型列表 + "temperature": 0.3, # 更高温度以获得多样化评审 + "max_tokens": 16000, + "timeout_seconds": 480, # 每个模型调用的超时时间 + }, + + # ========== 种子发现配置 ========== + "seed_config": { + "target_seed_count": 2, # 目标生成的研究种子数量 + "min_distinct_seed_directions_for_completion": 2, # 阶段完成所需的最小不同方向数 + }, + + # ========== 证据检索配置 ========== + "evidence_config": { + "top_k": 6, # 每个证据通道的 Top-K 论文数 + "search_top_k": 30, # 初始搜索的 Top-K 论文数 + "max_parallel_seed_workers": 4, # 每个种子的证据检索最大并行工作线程数 + "rrf_k": 60, # 混合排序的倒数排名融合参数 + "ready_unique_papers_threshold": 15, # 证据就绪所需的最小唯一论文数 + "ready_completeness_threshold": 0.75, # 证据就绪所需的最小完整性分数(0-1) + # 每个证据通道的目标论文数 + "lane_targets": { + "inspiration": 3, # 展示类似成功方法的论文 + "novelty": 3, # 突出研究空白的论文 + "contradiction": 2, # 有冲突发现的论文 + "execution": 2, # 有实现细节的论文 + }, + # 每个通道的混合检索权重(embedding vs BM25) + "lane_hybrid_weights": { + "inspiration": {"embedding": 0.75, "bm25": 0.25}, # 偏向语义相似度 + "novelty": {"embedding": 0.35, "bm25": 0.65}, # 偏向关键词匹配以发现空白 + "contradiction": {"embedding": 0.60, "bm25": 0.40}, + "execution": {"embedding": 0.30, "bm25": 0.70}, # 偏向关键词匹配以找方法 + }, + # 通道重要性权重(用于整体完整性计算) + "lane_completeness_weights": { + "inspiration": 0.15, + "novelty": 0.40, # 对研究想法最重要 + "contradiction": 0.25, + "execution": 0.20, + }, + # 每个通道的质量阈值(0-1) + "lane_quality_thresholds": { + "inspiration": 0.55, + "novelty": 0.65, + "contradiction": 0.65, + "execution": 0.65, + }, + "required_lanes": ["novelty", "contradiction", "execution"], # 生成假设所需的通道 + }, + + # ========== 报告生成配置 ========== + "report_config": { + "idea_detail_reference_pool_size": 25, # idea detail 章节的论文池大小 + "report_search_top_k": 30, # 报告检索的 Top-K 论文数 + "idea_detail_prior_work_limit": 3, # 每个 idea 的最大先前工作引用数 + "idea_detail_contrast_limit": 3, # 每个 idea 的最大对比引用数 + "idea_detail_inspiration_limit": 3, # 每个 idea 的最大启发引用数 + "idea_detail_execution_limit": 1, # 每个 idea 的最大执行引用数 + # idea detail 章节中每个通道的最大引用数 + "idea_detail_lane_limits": { + "novelty": 6, + "contradiction": 6, + "inspiration": 6, + "execution": 6, + }, + "ideas_overview_citations_per_idea": 1, # 概览章节中每个 idea 的引用数 + # 每个报告章节所需的最小引用数 + "section_min_citations": { + "limitations": 2, + "idea_detail": 2, + "comparison": 2, + }, + "section_retry_max_rounds": 2, # 章节生成的最大重试轮数 + }, + + # ========== 论文处理配置 ========== + "paper_config": { + "enable_scoring": True, # 启用基于 LLM 的论文相关性评分 + "enable_two_tier_storage": True, # 启用两层存储(原始 + 压缩) + "enable_pdf_parsing": True, # 启用 PDF 解析以提取全文 + "enable_embedding_rerank": True, # 启用基于 embedding 的重排序 + "enable_paper_pool": True, # 启用带引用扩展的论文池 + "candidate_pool_size": 40, # 重排序前的初始候选池大小 + "top_k_for_pdf": 30, # 下载和解析的 Top-K 论文数 + "rerank_pool_size": 30, # 重排序后的池大小 + "pool_relevance_threshold": 7.5, # 论文池的相关性分数阈值(0-10) + "pool_top_k_for_expansion": 10, # 用于引用扩展的 Top-K 论文数 + "pool_max_citations_per_paper": 5, # 每篇论文扩展的最大引用数 + "pool_max_final_papers": 20, # 扩展后最终池中的最大论文数 + "max_papers_to_compress": 30, # 报告压缩的最大论文数 + }, +} + + +def _normalize_state(result, workflow_module): + if isinstance(result, workflow_module.IdeaGenState): + return result + if isinstance(result, dict): + return workflow_module.IdeaGenState(**result) + raise TypeError(f"Unexpected workflow result type: {type(result)!r}") + + +def _apply_test_config(monkeypatch, config_module, test_config: dict) -> None: + for node_name, node_cfg in (test_config.get("node_configs") or {}).items(): + monkeypatch.setitem( + config_module.NODE_CONFIGS, + node_name, + config_module.NodeLLMConfig(**node_cfg), + ) + + for node_name, tool_cfg in (test_config.get("node_tool_configs") or {}).items(): + monkeypatch.setitem( + config_module.NODE_TOOL_CONFIGS, + node_name, + config_module.NodeToolConfig(**tool_cfg), + ) + + for key, value in (test_config.get("workflow_config") or {}).items(): + monkeypatch.setattr(config_module.WORKFLOW_CONFIG, key, value) + + for key, value in (test_config.get("critic_config") or {}).items(): + monkeypatch.setattr(config_module.CRITIC_CONFIG, key, value) + + for key, value in (test_config.get("seed_config") or {}).items(): + monkeypatch.setattr(config_module.SEED_CONFIG, key, value) + + for key, value in (test_config.get("evidence_config") or {}).items(): + monkeypatch.setattr(config_module.EVIDENCE_CONFIG, key, value) + + for key, value in (test_config.get("report_config") or {}).items(): + monkeypatch.setattr(config_module.REPORT_CONFIG, key, value) + + for key, value in (test_config.get("paper_config") or {}).items(): + monkeypatch.setattr(config_module.PAPER_PROCESSING_CONFIG, key, value) + + +@pytest.mark.integration +def test_seed_hypothesis_live_run(monkeypatch): + """Full workflow: broad search → seeds → evidence → hypotheses → report.""" + import httpx + import openai + from src.modules.idea_gen import config as cfg + from src.modules.idea_gen import workflow as wf + from src.modules.idea_gen.utils import workflow_helpers + + _apply_test_config(monkeypatch, cfg, TEST_CONFIG) + + run_dir = Path(__file__).resolve().parents[2] / "output" / TEST_CONFIG["run_dir_name"] + run_dir.mkdir(parents=True, exist_ok=True) + monkeypatch.setattr(workflow_helpers, "_get_output_dir", lambda state: run_dir) + + workflow = wf.build_workflow() + try: + result = workflow.invoke( + { + "target": TEST_CONFIG["target"], + "allow_clarification": TEST_CONFIG["allow_clarification"], + "academic_domain": TEST_CONFIG["academic_domain"], + "target_venues": TEST_CONFIG["target_venues"], + "max_iterations": TEST_CONFIG["max_iterations"], + "language": TEST_CONFIG["language"], + "thread_id": TEST_CONFIG["thread_id"], + } + ) + except (openai.APIConnectionError, httpx.ConnectError, OSError) as exc: + pytest.skip(f"Live test requires outbound model access: {exc}") + + state = _normalize_state(result, wf) + + # Basic workflow assertions + assert state.research_brief, "research_brief should be populated" + assert state.iteration_count >= 1 + assert state.notes, "notes should be populated after research" + + # Seed → hypothesis pipeline assertions + assert state.seeds, "seeds should be generated" + assert len(state.seeds) >= 1 + for seed in state.seeds: + assert seed.seed_id + assert seed.target_gap + assert seed.mechanism + + assert state.evidence_bundles, "evidence_bundles should be populated" + assert len(state.evidence_bundles) == len(state.seeds) + for bundle in state.evidence_bundles: + assert bundle.seed_id + assert 0.0 <= bundle.completeness <= 1.0 + + assert state.hypotheses, "hypotheses should be generated" + assert len(state.hypotheses) >= 1 + for hyp in state.hypotheses: + assert hyp.hypothesis_id + assert hyp.core_claim + assert hyp.falsification_signal + + # Final report + final_report = state.final_report + assert final_report, "final_report should be written" + assert (run_dir / "final_report.md").exists() + + print(f"\n=== Seeds ({len(state.seeds)}) ===") + for s in state.seeds: + print(f" [{s.seed_id}] gap={s.target_gap[:60]}") + + print(f"\n=== Hypotheses ({len(state.hypotheses)}) ===") + for h in state.hypotheses: + print(f" [{h.hypothesis_id}] {h.title}") + print(f" claim: {h.core_claim[:80]}") diff --git a/backend/tests/run_idea_gen_seed_hypothesis_e2e_simple.py b/backend/tests/run_idea_gen_seed_hypothesis_e2e_simple.py new file mode 100644 index 00000000..0bd6129d --- /dev/null +++ b/backend/tests/run_idea_gen_seed_hypothesis_e2e_simple.py @@ -0,0 +1,517 @@ +"""简化版测试配置 - 使用预设模式。""" +from __future__ import annotations + +import argparse +import json +import sys +from datetime import datetime +from pathlib import Path + +import pytest + +# Add backend to path for standalone execution +backend_dir = Path(__file__).resolve().parents[1] +if str(backend_dir) not in sys.path: + sys.path.insert(0, str(backend_dir)) + +from src.modules.idea_gen.config_presets import apply_preset + + +# ========== Resume 辅助函数 ========== +def find_latest_output_dir(base_output_dir: Path, pattern: str = "idea_gen_seed_hypothesis_e2e_*") -> Path | None: + """查找最新的输出目录。 + + Args: + base_output_dir: 输出基础目录 + pattern: 目录名匹配模式 + + Returns: + 最新的输出目录路径,如果不存在则返回 None + """ + matching_dirs = list(base_output_dir.glob(pattern)) + if not matching_dirs: + return None + # 按修改时间排序,返回最新的 + return max(matching_dirs, key=lambda p: p.stat().st_mtime) + + +def load_state_from_dir(output_dir: Path) -> dict | None: + """从输出目录加载最新的状态文件。 + + 优先级: + 1. node_report_state.json (最终报告节点) + 2. node_hypothesis_state.json (假设生成节点) + 3. node_evidence_orchestrator_state.json (证据编排节点) + 4. node_seed_discovery_state.json (种子发现节点) + 5. node_compress_state.json (压缩节点) + 6. 最新的 node_*_state.json + + Args: + output_dir: 输出目录路径 + + Returns: + 状态字典,如果无法加载则返回 None + """ + # 定义优先级顺序 + priority_files = [ + "node_report_state.json", + "node_hypothesis_state.json", + "node_evidence_orchestrator_state.json", + "node_seed_discovery_state.json", + "node_compress_state.json", + ] + + # 先尝试优先级文件 + for filename in priority_files: + state_file = output_dir / filename + if state_file.exists(): + try: + with open(state_file, "r", encoding="utf-8") as f: + return json.load(f) + except Exception as e: + print(f" ⚠ 无法加载 {filename}: {e}") + continue + + # 如果优先级文件都不存在,查找所有 node_*_state.json 文件 + state_files = list(output_dir.glob("node_*_state.json")) + if not state_files: + return None + + # 按修改时间排序,返回最新的 + latest_state_file = max(state_files, key=lambda p: p.stat().st_mtime) + try: + with open(latest_state_file, "r", encoding="utf-8") as f: + return json.load(f) + except Exception as e: + print(f" ✗ 无法加载状态文件 {latest_state_file.name}: {e}") + return None + + +# ========== 简化配置:只需选择预设模式 ========== +PRESET_MODE = "fast" # 可选:fast / balanced / quality + +# ========== 基础输入参数 ========== +# 生成带时间戳的运行目录名 +_timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") +_run_dir_name = f"idea_gen_seed_hypothesis_e2e_{PRESET_MODE}_{_timestamp}" + +TEST_INPUT = { + "target": ( + "Survey retrieval-augmented generation (RAG) for long-context LLM agents " + "and propose novel research ideas with concrete evaluation plans." + ), + "academic_domain": "AI/ML", + "target_venues": ["NIPS", "ICML", "ICLR"], + "language": "zh", + "allow_clarification": False, + "thread_id": f"seed-hypothesis-e2e-test-{_timestamp}", + "run_dir_name": _run_dir_name, +} + + +# ========== 根据预设模式动态选择节点模型 ========== +def get_node_models_for_preset(mode: str) -> dict[str, str]: + """根据预设模式返回最优的节点模型配置。 + + Args: + mode: 预设模式 (fast/balanced/quality) + + Returns: + 节点名到模型名的映射 + """ + if mode == "fast": + # 快速模式:优先使用轻量模型,降低成本 + return { + "clarify": "gpt-4o-mini", + "brief": "gpt-4o-mini", + "query_generation": "gpt-4o", + "supervisor": "gpt-4o", + "seed_discovery": "gpt-4o", + "seed_discovery_validator": "gpt-4o", + "evidence_orchestrator": "gpt-4o", + "hypothesis": "gpt-4o", + "compress": "gpt-4o-mini", + "report": "gpt-4o", + } + elif mode == "balanced": + # 平衡模式:兼顾质量和成本 + return { + "clarify": "gpt-4o-mini", + "brief": "gpt-4o", + "query_generation": "gpt-4o", + "supervisor": "gpt-5.4", + "seed_discovery": "gpt-5.4", + "seed_discovery_validator": "gpt-4o", + "evidence_orchestrator": "gpt-4o", + "hypothesis": "gpt-5.4", + "compress": "gpt-4o", + "report": "gpt-5.4", + } + elif mode == "quality": + # 高质量模式:使用最强模型,追求最佳结果 + return { + "clarify": "gpt-4o", + "brief": "gpt-4o", + "query_generation": "gpt-5.4", + "supervisor": "gpt-5.4", + "seed_discovery": "gpt-5.4", + "seed_discovery_validator": "gpt-4o", + "evidence_orchestrator": "gpt-5.4", + "hypothesis": "gpt-5.4", + "compress": "gpt-4o", + "report": "gpt-5.4", + } + else: + # 默认使用 balanced 配置 + return get_node_models_for_preset("balanced") + + +NODE_MODELS = get_node_models_for_preset(PRESET_MODE) + +# ========== 可选的微调覆盖(如果需要) ========== +# 根据预设模式决定是否启用论文评分 +# fast/balanced 模式关闭评分以节省时间,quality 模式启用评分 +CUSTOM_OVERRIDES = {} +if PRESET_MODE == "quality": + CUSTOM_OVERRIDES["paper_config"] = { + "enable_scoring": True, + } +elif PRESET_MODE == "fast": + # fast 模式进一步优化参数 + CUSTOM_OVERRIDES["paper_config"] = { + "enable_scoring": False, + "top_k_for_pdf": 5, # 从默认 8 降至 5 + "candidate_pool_size": 10, # 从默认 15 降至 10 + } +# balanced 模式使用预设默认值,不需要覆盖 + +# ========== 自动应用预设配置 ========== +TEST_CONFIG = { + **TEST_INPUT, + "node_configs": { + node: {"model": model, "temperature": 0.1, "max_tokens": 16000} + for node, model in NODE_MODELS.items() + }, + **apply_preset(PRESET_MODE, CUSTOM_OVERRIDES), +} + + +def _base_output_dir() -> Path: + """Return the single canonical output root for this script.""" + return backend_dir / "output" / "idea_gen" + + +def _resolve_run_dir(run_dir_name: str) -> Path: + """Resolve the concrete run directory under the canonical output root.""" + return _base_output_dir() / run_dir_name + + +def _bind_output_dir(monkeypatch, wf, workflow_helpers, artifact_utils, run_dir: Path) -> None: + """Force all workflow artifact writers to use the same run directory.""" + run_dir.mkdir(parents=True, exist_ok=True) + monkeypatch.setattr(artifact_utils, "get_output_dir", lambda state: run_dir) + monkeypatch.setattr(workflow_helpers, "_get_output_dir", lambda state: run_dir) + + +def _normalize_state(result, workflow_module): + if isinstance(result, workflow_module.IdeaGenState): + return result + if isinstance(result, dict): + return workflow_module.IdeaGenState(**result) + raise TypeError(f"Unexpected workflow result type: {type(result)!r}") + + +def _apply_test_config(monkeypatch, config_module, test_config: dict) -> None: + for node_name, node_cfg in (test_config.get("node_configs") or {}).items(): + monkeypatch.setitem( + config_module.NODE_CONFIGS, + node_name, + config_module.NodeLLMConfig(**node_cfg), + ) + + for node_name, tool_cfg in (test_config.get("node_tool_configs") or {}).items(): + monkeypatch.setitem( + config_module.NODE_TOOL_CONFIGS, + node_name, + config_module.NodeToolConfig(**tool_cfg), + ) + + for key, value in (test_config.get("workflow_config") or {}).items(): + monkeypatch.setattr(config_module.WORKFLOW_CONFIG, key, value) + + for key, value in (test_config.get("critic_config") or {}).items(): + monkeypatch.setattr(config_module.CRITIC_CONFIG, key, value) + + for key, value in (test_config.get("seed_config") or {}).items(): + monkeypatch.setattr(config_module.SEED_CONFIG, key, value) + + for key, value in (test_config.get("evidence_config") or {}).items(): + monkeypatch.setattr(config_module.EVIDENCE_CONFIG, key, value) + + for key, value in (test_config.get("report_config") or {}).items(): + monkeypatch.setattr(config_module.REPORT_CONFIG, key, value) + + for key, value in (test_config.get("paper_config") or {}).items(): + monkeypatch.setattr(config_module.PAPER_PROCESSING_CONFIG, key, value) + + +@pytest.mark.integration +def test_seed_hypothesis_live_run(monkeypatch): + """Full workflow: broad search → seeds → evidence → hypotheses → report.""" + import httpx + import openai + from src.modules.idea_gen import config as cfg + from src.modules.idea_gen import workflow as wf + from src.modules.idea_gen.utils import artifact_utils + from src.modules.idea_gen.utils import workflow_helpers + + _apply_test_config(monkeypatch, cfg, TEST_CONFIG) + + run_dir = _resolve_run_dir(TEST_CONFIG["run_dir_name"]) + _bind_output_dir(monkeypatch, wf, workflow_helpers, artifact_utils, run_dir) + + workflow = wf.build_workflow() + try: + result = workflow.invoke( + { + "target": TEST_CONFIG["target"], + "allow_clarification": TEST_CONFIG["allow_clarification"], + "academic_domain": TEST_CONFIG["academic_domain"], + "target_venues": TEST_CONFIG["target_venues"], + "max_iterations": TEST_CONFIG.get("workflow_config", {}).get( + "default_max_iterations", 3 + ), + "language": TEST_CONFIG["language"], + "thread_id": TEST_CONFIG["thread_id"], + } + ) + except (openai.APIConnectionError, httpx.ConnectError, OSError) as exc: + pytest.skip(f"Live test requires outbound model access: {exc}") + + state = _normalize_state(result, wf) + + # Basic workflow assertions + assert state.research_brief, "research_brief should be populated" + assert state.iteration_count >= 1 + assert state.notes, "notes should be populated after research" + + # Seed → hypothesis pipeline assertions + assert state.seeds, "seeds should be generated" + assert len(state.seeds) >= 1 + for seed in state.seeds: + assert seed.seed_id + assert seed.target_gap + assert seed.mechanism + + assert state.evidence_bundles, "evidence_bundles should be populated" + assert len(state.evidence_bundles) == len(state.seeds) + for bundle in state.evidence_bundles: + assert bundle.seed_id + assert 0.0 <= bundle.completeness <= 1.0 + + assert state.hypotheses, "hypotheses should be generated" + assert len(state.hypotheses) >= 1 + for hyp in state.hypotheses: + assert hyp.hypothesis_id + assert hyp.core_claim + assert hyp.falsification_signal + + # Final report + final_report = state.final_report + assert final_report, "final_report should be written" + assert (run_dir / "final_report.md").exists() + + print(f"\n=== Seeds ({len(state.seeds)}) ===") + for s in state.seeds: + print(f" [{s.seed_id}] gap={s.target_gap[:60]}") + + print(f"\n=== Hypotheses ({len(state.hypotheses)}) ===") + for h in state.hypotheses: + print(f" [{h.hypothesis_id}] {h.title}") + print(f" claim: {h.core_claim[:80]}") + + +if __name__ == "__main__": + """支持直接运行,带详细进度输出""" + import httpx + import openai + from src.modules.idea_gen import config as cfg + from src.modules.idea_gen import workflow as wf + from src.modules.idea_gen.utils import artifact_utils + from src.modules.idea_gen.utils import workflow_helpers + + # 解析命令行参数 + parser = argparse.ArgumentParser(description="运行 Idea Gen Seed Hypothesis E2E 测试") + parser.add_argument( + "--resume", + action="store_true", + help="从最新的输出目录恢复执行" + ) + parser.add_argument( + "--resume-from", + type=str, + help="从指定的输出目录恢复执行(相对于 backend/output/idea_gen/ 的路径)" + ) + args = parser.parse_args() + + # 确定是否为 resume 模式 + resume_mode = args.resume or args.resume_from + resume_state = None + run_dir = None + + if resume_mode: + base_output_dir = _base_output_dir() + + if args.resume_from: + # 从指定目录恢复 + run_dir = base_output_dir / args.resume_from + if not run_dir.exists(): + print(f"✗ 错误: 指定的目录不存在: {run_dir}") + sys.exit(1) + else: + # 从最新目录恢复 + run_dir = find_latest_output_dir(base_output_dir) + if not run_dir: + print(f"✗ 错误: 在 {base_output_dir} 中未找到匹配的输出目录") + sys.exit(1) + + print("=" * 80) + print(f"Resume 模式: 从 {run_dir.name} 恢复") + print("=" * 80) + + # 加载状态 + print("\n[1/7] 加载保存的状态...") + resume_state = load_state_from_dir(run_dir) + if not resume_state: + print(f" ✗ 错误: 无法从 {run_dir} 加载状态文件") + sys.exit(1) + print(f" ✓ 已加载状态 (迭代次数: {resume_state.get('iteration_count', 0)})") + else: + print("=" * 80) + print(f"开始测试: {TEST_CONFIG['run_dir_name']}") + print(f"预设模式: {PRESET_MODE}") + print(f"研究目标: {TEST_INPUT['target'][:100]}...") + print("=" * 80) + + # 应用配置 + step_offset = 1 if resume_mode else 0 + print(f"\n[{1 + step_offset}/{'7' if resume_mode else '6'}] 应用测试配置...") + class MockMonkeypatch: + def setitem(self, d, k, v): + d[k] = v + def setattr(self, obj, name, value): + setattr(obj, name, value) + + monkeypatch = MockMonkeypatch() + _apply_test_config(monkeypatch, cfg, TEST_CONFIG) + print(" ✓ 配置已应用") + + # 准备输出目录 + print(f"\n[{2 + step_offset}/{'7' if resume_mode else '6'}] 准备输出目录...") + if not resume_mode: + run_dir = _resolve_run_dir(TEST_CONFIG["run_dir_name"]) + _bind_output_dir(monkeypatch, wf, workflow_helpers, artifact_utils, run_dir) + print(f" ✓ 输出目录: {run_dir}") + + # 构建工作流 + print(f"\n[{3 + step_offset}/{'7' if resume_mode else '6'}] 构建 LangGraph 工作流...") + workflow = wf.build_workflow() + print(" ✓ 工作流已构建") + + # 执行工作流 + print(f"\n[{4 + step_offset}/{'7' if resume_mode else '6'}] 执行工作流(这可能需要几分钟)...") + if resume_mode: + print(f" - Resume 模式: 从已保存状态继续") + else: + print(f" - 目标领域: {TEST_CONFIG['academic_domain']}") + print(f" - 目标会议: {', '.join(TEST_CONFIG['target_venues'])}") + print(f" - 最大迭代: {TEST_CONFIG.get('workflow_config', {}).get('default_max_iterations', 3)}") + print(f" - 语言: {TEST_CONFIG['language']}") + print() + + try: + if resume_mode: + # Resume 模式:使用加载的状态 + # 注意:LangGraph 的 invoke 不直接支持从中间状态恢复 + # 这里我们需要重新构造完整的 state 对象 + print(" ⚠ 警告: LangGraph 不支持从中间状态直接恢复") + print(" ⚠ 将使用保存的状态重新初始化 workflow") + + # 从保存的状态中提取关键信息 + input_data = { + "target": resume_state.get("input", {}).get("target", TEST_CONFIG["target"]), + "allow_clarification": resume_state.get("input", {}).get("allow_clarification", TEST_CONFIG["allow_clarification"]), + "academic_domain": resume_state.get("input", {}).get("academic_domain", TEST_CONFIG["academic_domain"]), + "target_venues": resume_state.get("input", {}).get("target_venues", TEST_CONFIG["target_venues"]), + "max_iterations": TEST_CONFIG.get("workflow_config", {}).get("default_max_iterations", 3), + "language": resume_state.get("input", {}).get("language", TEST_CONFIG["language"]), + "thread_id": resume_state.get("thread_id", TEST_CONFIG["thread_id"]), + } + result = workflow.invoke(input_data) + else: + result = workflow.invoke( + { + "target": TEST_CONFIG["target"], + "allow_clarification": TEST_CONFIG["allow_clarification"], + "academic_domain": TEST_CONFIG["academic_domain"], + "target_venues": TEST_CONFIG["target_venues"], + "max_iterations": TEST_CONFIG.get("workflow_config", {}).get( + "default_max_iterations", 3 + ), + "language": TEST_CONFIG["language"], + "thread_id": TEST_CONFIG["thread_id"], + } + ) + print("\n ✓ 工作流执行完成") + except (openai.APIConnectionError, httpx.ConnectError, OSError) as exc: + print(f"\n ✗ 错误: 需要网络连接和模型访问权限") + print(f" 详情: {exc}") + sys.exit(1) + + # 验证结果 + print(f"\n[{5 + step_offset}/{'7' if resume_mode else '6'}] 验证结果...") + state = _normalize_state(result, wf) + + checks = [ + ("研究简报", bool(state.research_brief)), + ("迭代次数 >= 1", state.iteration_count >= 1), + ("研究笔记", bool(state.notes)), + ("种子生成", bool(state.seeds) and len(state.seeds) >= 1), + ("证据包", bool(state.evidence_bundles) and len(state.evidence_bundles) == len(state.seeds)), + ("假设生成", bool(state.hypotheses) and len(state.hypotheses) >= 1), + ("最终报告", bool(state.final_report)), + ("报告文件", (run_dir / "final_report.md").exists()), + ] + + for check_name, passed in checks: + status = "✓" if passed else "✗" + print(f" {status} {check_name}") + + if not all(passed for _, passed in checks): + print("\n ⚠ 部分检查未通过") + sys.exit(1) + + # 输出摘要 + print(f"\n[{6 + step_offset}/{'7' if resume_mode else '6'}] 测试摘要") + print(f" - 迭代次数: {state.iteration_count}") + print(f" - 种子数量: {len(state.seeds)}") + print(f" - 证据包数量: {len(state.evidence_bundles)}") + print(f" - 假设数量: {len(state.hypotheses)}") + print(f" - 输出目录: {run_dir}") + + print(f"\n{'=' * 80}") + print("详细结果:") + print(f"{'=' * 80}") + + print(f"\n=== Seeds ({len(state.seeds)}) ===") + for s in state.seeds: + print(f" [{s.seed_id}] gap={s.target_gap[:60]}") + + print(f"\n=== Hypotheses ({len(state.hypotheses)}) ===") + for h in state.hypotheses: + print(f" [{h.hypothesis_id}] {h.title}") + print(f" claim: {h.core_claim[:80]}") + + print(f"\n{'=' * 80}") + print("✓ 测试成功完成") + print(f"{'=' * 80}") diff --git a/backend/tests/test_citation_fix.py b/backend/tests/test_citation_fix.py new file mode 100644 index 00000000..42fe793c --- /dev/null +++ b/backend/tests/test_citation_fix.py @@ -0,0 +1,44 @@ +"""Test citation pattern fix for malformed multi-citations.""" +import re + + +def test_multi_citation_expansion(): + """Test that malformed multi-citations are expanded correctly.""" + # Simulate the fix + def expand_multi_citations(text: str) -> str: + def _expand(match: re.Match[str]) -> str: + content = match.group(1) + parts = [part.strip() for part in content.split(";")] + expanded = [] + for part in parts: + key = part.lstrip("@").strip() + if key: + expanded.append(f"[@{key}]") + return "".join(expanded) + + multi_citation_pattern = re.compile(r"\[@([^]]+;[^]]+)\]") + return multi_citation_pattern.sub(_expand, text) + + # Test case from the bug report + input_text = "这些问题限制了RAG技术在长上下文任务中的应用潜力[@paper:899f095df206; @paper:876dfeb9c32b; @paper:4b3cedeced2f]。" + expected = "这些问题限制了RAG技术在长上下文任务中的应用潜力[@paper:899f095df206][@paper:876dfeb9c32b][@paper:4b3cedeced2f]。" + + result = expand_multi_citations(input_text) + assert result == expected, f"Expected: {expected}\nGot: {result}" + + # Test with spaces around semicolons + input_text2 = "text[@key1 ; @key2 ; @key3]more" + expected2 = "text[@key1][@key2][@key3]more" + result2 = expand_multi_citations(input_text2) + assert result2 == expected2, f"Expected: {expected2}\nGot: {result2}" + + # Test normal citations are not affected + input_text3 = "text[@key1][@key2][@key3]more" + result3 = expand_multi_citations(input_text3) + assert result3 == input_text3, f"Normal citations should not be modified" + + print("✓ All tests passed") + + +if __name__ == "__main__": + test_multi_citation_expansion() diff --git a/backend/tests/test_citation_standalone.py b/backend/tests/test_citation_standalone.py new file mode 100644 index 00000000..c116a307 --- /dev/null +++ b/backend/tests/test_citation_standalone.py @@ -0,0 +1,78 @@ +"""Direct test of the citation expansion logic.""" +import re + + +# Copy the exact logic from report_builder.py +_SOURCE_CITATION_PATTERN = re.compile(r"(?:\[@([A-Za-z0-9:._-]+)\]|【@([A-Za-z0-9:._-]+)】)") + + +def renumber_citations(report_body: str, source_catalog: dict) -> tuple[str, list[str]]: + """Convert [@source_key] markers into sequential numeric citations.""" + used_keys: list[str] = [] + citation_numbers: dict[str, int] = {} + + def _replace(match: re.Match[str]) -> str: + source_key = match.group(1) or match.group(2) + if source_key not in source_catalog: + return "" + if source_key not in citation_numbers: + citation_numbers[source_key] = len(citation_numbers) + 1 + used_keys.append(source_key) + return f"[{citation_numbers[source_key]}]" + + # First, expand malformed multi-citation patterns like [@key1; @key2; @key3] + # into separate citations [@key1][@key2][@key3] + def _expand_multi_citations(match: re.Match[str]) -> str: + content = match.group(1) + # Split by semicolon and extract individual keys + parts = [part.strip() for part in content.split(";")] + expanded = [] + for part in parts: + # Remove leading @ if present + key = part.lstrip("@").strip() + if key: + expanded.append(f"[@{key}]") + return "".join(expanded) + + # Pattern to match malformed multi-citations: [@key1; @key2; @key3] + multi_citation_pattern = re.compile(r"\[@([^]]+;[^]]+)\]") + text = multi_citation_pattern.sub(_expand_multi_citations, report_body) + + # Now apply the standard single-citation replacement + text = _SOURCE_CITATION_PATTERN.sub(_replace, text) + text = re.sub(r"[ \t]+\n", "\n", text) + text = re.sub(r"\n{3,}", "\n\n", text).strip() + return text, used_keys + + +def test_renumber_with_malformed_citations(): + """Test that renumber_citations handles malformed multi-citations.""" + + # Mock source catalog + source_catalog = { + "paper:899f095df206": {"title": "Paper 1", "url": "http://example.com/1"}, + "paper:876dfeb9c32b": {"title": "Paper 2", "url": "http://example.com/2"}, + "paper:4b3cedeced2f": {"title": "Paper 3", "url": "http://example.com/3"}, + } + + # Test case from the bug report + input_text = "这些问题限制了RAG技术在长上下文任务中的应用潜力[@paper:899f095df206; @paper:876dfeb9c32b; @paper:4b3cedeced2f]。" + + result, used_keys = renumber_citations(input_text, source_catalog) + + # Should expand and renumber correctly + expected = "这些问题限制了RAG技术在长上下文任务中的应用潜力[1][2][3]。" + print(f"Input: {input_text}") + print(f"Expected: {expected}") + print(f"Got: {result}") + print(f"Used keys: {used_keys}") + + assert result == expected, f"Mismatch!" + assert used_keys == ["paper:899f095df206", "paper:876dfeb9c32b", "paper:4b3cedeced2f"], \ + f"Expected all three keys, got: {used_keys}" + + print("\n✓ renumber_citations handles malformed multi-citations correctly") + + +if __name__ == "__main__": + test_renumber_with_malformed_citations() diff --git a/backend/tests/test_df_api_models.py b/backend/tests/test_df_api_models.py new file mode 100644 index 00000000..6c922b08 --- /dev/null +++ b/backend/tests/test_df_api_models.py @@ -0,0 +1,172 @@ +#!/usr/bin/env python3 +""" +测试 DF API 模型可用性 +测试 Gemini、GPT 和 Claude 系列模型 +""" + +import os +from datetime import datetime +from pathlib import Path + +from dotenv import load_dotenv +from openai import OpenAI + + +def test_model(client: OpenAI, model_name: str) -> dict: + """测试单个模型是否可用""" + try: + response = client.chat.completions.create( + model=model_name, + messages=[{"role": "user", "content": "Say Hi"}], + max_tokens=50, + temperature=0.7, + ) + + reply = response.choices[0].message.content + return { + "model": model_name, + "status": "✅ 可用", + "response": reply, + "error": None, + } + except Exception as e: + error_msg = str(e) + return { + "model": model_name, + "status": "❌ 不可用", + "response": None, + "error": error_msg, + } + + +def generate_markdown_report(results: list[dict], output_path: Path) -> None: + """生成 Markdown 测试报告""" + available = [r for r in results if "✅" in r["status"]] + unavailable = [r for r in results if "❌" in r["status"]] + + report = f"""# DF API 模型可用性测试报告 + +**测试时间**: {datetime.now().strftime("%Y-%m-%d %H:%M:%S")} + +**测试 API**: `{os.getenv("DF_API_URL")}` + +## 📊 测试摘要 + +- **总测试模型数**: {len(results)} +- **可用模型数**: {len(available)} +- **不可用模型数**: {len(unavailable)} + +--- + +## ✅ 可用模型 ({len(available)}) + +""" + + if available: + report += "| 模型名称 | 响应内容 |\n" + report += "|---------|----------|\n" + for r in available: + response = r["response"][:100] if r["response"] else "N/A" + report += f"| `{r['model']}` | {response} |\n" + else: + report += "*无可用模型*\n" + + report += "\n---\n\n" + report += f"## ❌ 不可用模型 ({len(unavailable)})\n\n" + + if unavailable: + report += "| 模型名称 | 错误信息 |\n" + report += "|---------|----------|\n" + for r in unavailable: + error = r["error"][:150] if r["error"] else "Unknown error" + # 转义 markdown 特殊字符 + error = error.replace("|", "\\|").replace("\n", " ") + report += f"| `{r['model']}` | {error} |\n" + else: + report += "*所有模型均可用*\n" + + report += "\n---\n\n" + report += "## 📝 详细测试日志\n\n" + + for r in results: + report += f"### {r['model']}\n\n" + report += f"- **状态**: {r['status']}\n" + if r["response"]: + report += f"- **响应**: {r['response']}\n" + if r["error"]: + report += f"- **错误**: `{r['error']}`\n" + report += "\n" + + output_path.write_text(report, encoding="utf-8") + print(f"\n✅ 测试报告已保存到: {output_path}") + + +def main(): + # 加载环境变量 + env_path = Path(__file__).parent.parent.parent / ".env" + load_dotenv(env_path) + + api_url = os.getenv("DF_API_URL") + api_key = os.getenv("DF_API_KEY") + + if not api_url or not api_key: + raise ValueError("请在 .env 文件中配置 DF_API_URL 和 DF_API_KEY") + + print(f"🔍 测试 API: {api_url}") + print(f"🔑 API Key: {api_key[:20]}...") + + # 初始化客户端 + client = OpenAI(base_url=api_url, api_key=api_key) + + # 定义要测试的模型列表 + models_to_test = [ + # GPT-5 系列 + "gpt-5", + "gpt-5.1", + "gpt-5.2", + "gpt-5.3", + "gpt-5.4", + + # GPT-4 系列 + "gpt-4o", + "gpt-4o-mini", + "gpt-4-turbo", + "gpt-4", + "gpt-3.5-turbo", + + # Claude 系列 + "claude-3-5-sonnet-20241022", + "claude-3-opus-20240229", + "claude-3-sonnet-20240229", + "claude-3-haiku-20240307", + "claude-3-5-haiku-20241022", + + # Gemini 系列 + "gemini-1.5-pro", + "gemini-1.5-flash", + "gemini-pro", + "gemini-1.5-pro-latest", + "gemini-1.5-flash-latest", + ] + + print(f"\n📋 开始测试 {len(models_to_test)} 个模型...\n") + + results = [] + for i, model in enumerate(models_to_test, 1): + print(f"[{i}/{len(models_to_test)}] 测试 {model}...", end=" ") + result = test_model(client, model) + results.append(result) + print(result["status"]) + + # 生成报告 + output_path = Path(__file__).parent.parent.parent / "output" / "df_api_model_test_report.md" + output_path.parent.mkdir(parents=True, exist_ok=True) + generate_markdown_report(results, output_path) + + # 打印摘要 + available_count = sum(1 for r in results if "✅" in r["status"]) + print(f"\n📊 测试完成: {available_count}/{len(results)} 个模型可用") + + +if __name__ == "__main__": + main() diff --git a/backend/tests/test_df_api_seed_discovery.py b/backend/tests/test_df_api_seed_discovery.py new file mode 100644 index 00000000..72ae4d55 --- /dev/null +++ b/backend/tests/test_df_api_seed_discovery.py @@ -0,0 +1,100 @@ +"""Direct test of seed_discovery LLM call with DF API.""" +import os +import sys + +# Set environment +os.environ['DF_API_KEY'] = 'sk-5vdoRhGhhcSuj6XBSS5YSfEguQysFdIjdbsByhOdymt0gF4g' +os.environ['DF_API_URL'] = 'http://123.129.219.111:3000/v1' + +from openai import OpenAI + +# Simple test data +research_brief = "Survey RAG for long-context LLM agents." +paper_summaries = """## Paper 1: LongRAG +### 核心摘要 +Dual-perspective retrieval for long-context QA. +### 技术细节 +Extraction and filtering. +### 实验验证 +Strong results.""" + +catalog_lines = 'Available paper keys:\n- "longrag": LongRAG' +n_seeds = 2 + +# Build prompt (simplified from prompts.py) +system = f"""CRITICAL: Output ONLY a valid JSON array starting with '[' and ending with ']'. + +Generate EXACTLY {n_seeds} research seeds as JSON: +[ + {{ + "seed_id": "seed_1", + "target_gap": "gap", + "mechanism": "mechanism", + "scenario": "scenario", + "expected_gain": "gain", + "source_paper_keys": ["longrag"] + }} +] + +NO markdown, NO explanations. START WITH '[' NOW.""" + +user = f"""Research brief: +{research_brief} + +{catalog_lines} + +Paper summaries: +{paper_summaries} + +Output ONLY JSON array.""" + +print("="*80) +print("Testing seed_discovery LLM call with DF API") +print("="*80) +print(f"\nInput size:") +print(f" System: {len(system)} chars") +print(f" User: {len(user)} chars") +print(f" Total: {len(system) + len(user)} chars") + +client = OpenAI( + api_key=os.environ['DF_API_KEY'], + base_url=os.environ['DF_API_URL'] +) + +print(f"\nCalling gpt-5.4...") +try: + response = client.chat.completions.create( + model='gpt-5.4', + messages=[ + {'role': 'system', 'content': system}, + {'role': 'user', 'content': user} + ], + temperature=0.1, + max_tokens=12000, + timeout=30 + ) + + content = response.choices[0].message.content + + print(f"\n✅ Response received:") + print(f" Length: {len(content) if content else 0} chars") + print(f" Content: {content[:200] if content else 'None'}...") + + if not content or not content.strip(): + print(f"\n❌ PROBLEM: LLM returned empty/None content!") + print(f" This is why seed_discovery fails!") + else: + print(f"\n✅ Content is not empty") + + # Try to parse JSON + import json + try: + parsed = json.loads(content) + print(f"✅ JSON parsing successful: {len(parsed)} seeds") + except: + print(f"❌ JSON parsing failed") + +except Exception as e: + print(f"\n❌ API call failed: {type(e).__name__}: {e}") + import traceback + traceback.print_exc() diff --git a/backend/tests/test_idea_gen_concurrency_optimization.py b/backend/tests/test_idea_gen_concurrency_optimization.py new file mode 100644 index 00000000..fa61ba3a --- /dev/null +++ b/backend/tests/test_idea_gen_concurrency_optimization.py @@ -0,0 +1,147 @@ +"""Tests for idea_gen concurrency and cache optimization.""" + +from __future__ import annotations + +import threading +import time +from concurrent.futures import ThreadPoolExecutor + +from langchain_core.messages import AIMessage + + +def test_research_dispatch_topics_run_concurrently_and_merge_in_order(monkeypatch, tmp_path): + from src.modules.idea_gen import config as cfg + from src.modules.idea_gen import tools as idea_gen_tools + from src.modules.idea_gen import workflow + from src.modules.idea_gen.utils import workflow_helpers + + active_calls = 0 + max_active_calls = 0 + active_lock = threading.Lock() + release_event = threading.Event() + + def _fake_search(query: str, provider: str, max_results: int): + nonlocal active_calls, max_active_calls + with active_lock: + active_calls += 1 + max_active_calls = max(max_active_calls, active_calls) + if active_calls >= 2: + release_event.set() + release_event.wait(0.5) + time.sleep(0.05 if query == "topic-a" else 0.01) + with active_lock: + active_calls -= 1 + return [ + { + "title": f"Result for {query}", + "url": f"https://example.com/{query}", + "snippet": f"{query} via {provider} with {max_results}", + } + ] + + monkeypatch.setattr(workflow_helpers, "_get_output_dir", lambda state: tmp_path / state.thread_id) + monkeypatch.setattr(idea_gen_tools, "search_sync", _fake_search) + monkeypatch.setattr(cfg.PAPER_PROCESSING_CONFIG, "enable_two_tier_storage", False) + monkeypatch.setattr(cfg.PAPER_PROCESSING_CONFIG, "enable_embedding_rerank", False) + monkeypatch.setattr(cfg.PAPER_PROCESSING_CONFIG, "enable_paper_pool", False) + monkeypatch.setattr(cfg.NODE_TOOL_CONFIGS["research_dispatch"], "paper_metadata_providers", []) + monkeypatch.setattr(cfg.WORKFLOW_CONFIG, "search_max_concurrency", 2) + + state = workflow.IdeaGenState( + input=workflow.IdeaGenInput(target="concurrency test target", allow_clarification=False, language="en"), + thread_id="concurrency-test", + paper_queries=["topic-a", "topic-b"], + ) + + state = workflow.research_dispatch_node(state) + + assert max_active_calls >= 2 + assert [run["topic"] for run in state.research_runs] == ["topic-a", "topic-b"] + assert [msg["topic"] for msg in state.researcher_messages] == ["topic-a", "topic-b"] + + +def test_access_path_lookup_reuses_existing_paper_cache(tmp_path): + from src.modules.idea_gen.utils.paper_cache_store import PaperCacheStore + from src.modules.idea_gen.utils.paper_utils import ( + _lookup_openalex_access_paths, + _lookup_semantic_scholar_access_paths, + ) + + cache_store = PaperCacheStore(tmp_path / "paper_cache.sqlite3") + cache_store.upsert_metadata( + paper_key="openalex:W123", + title="W123", + source_url="https://openalex.org/W123", + metadata={ + "openalex_id": "W123", + "doi": "10.1234/example", + "oa_url": "https://oa.example/openalex.pdf", + "pdf_url": "https://pdf.example/openalex.pdf", + "source_url": "https://openalex.org/W123", + }, + ) + cache_store.upsert_metadata( + paper_key="s2:S2-123", + title="S2-123", + source_url="https://www.semanticscholar.org/paper/S2-123", + metadata={ + "paper_id": "S2-123", + "arxivId": "2401.00001", + "doi": "10.9999/semantic", + "pdf_url": "https://pdf.example/semantic.pdf", + "source_url": "https://www.semanticscholar.org/paper/S2-123", + "identifiers": {"semantic_scholar": "S2-123"}, + }, + ) + + openalex_paths = _lookup_openalex_access_paths("W123", cache_store=cache_store) + semantic_paths = _lookup_semantic_scholar_access_paths("S2-123", cache_store=cache_store) + + assert openalex_paths == { + "openalex_id": "W123", + "doi": "10.1234/example", + "oa_url": "https://oa.example/openalex.pdf", + "pdf_url": "https://pdf.example/openalex.pdf", + "source_url": "https://openalex.org/W123", + } + assert semantic_paths == { + "paper_id": "S2-123", + "arxivId": "2401.00001", + "doi": "10.9999/semantic", + "doi_url": "https://doi.org/10.9999/semantic", + "pdf_url": "https://pdf.example/semantic.pdf", + "source_url": "https://www.semanticscholar.org/paper/S2-123", + } + + +def test_node_executor_respects_global_llm_concurrency_limit(monkeypatch): + from src.modules.idea_gen import config as cfg + from src.modules.idea_gen.utils.node_executor import NodeExecutor + + active_calls = 0 + max_active_calls = 0 + active_lock = threading.Lock() + + class _FakeModel: + def invoke(self, _messages): + nonlocal active_calls, max_active_calls + with active_lock: + active_calls += 1 + max_active_calls = max(max_active_calls, active_calls) + time.sleep(0.05) + with active_lock: + active_calls -= 1 + return AIMessage(content="ok") + + monkeypatch.setattr(cfg.WORKFLOW_CONFIG, "llm_max_concurrency", 1) + monkeypatch.setattr(NodeExecutor, "_create_model", lambda self: _FakeModel()) + + executor = NodeExecutor("test-node") + with ThreadPoolExecutor(max_workers=2) as pool: + futures = [ + pool.submit(executor.invoke_text, "system", "user-1"), + pool.submit(executor.invoke_text, "system", "user-2"), + ] + assert [future.result() for future in futures] == ["ok", "ok"] + + assert max_active_calls == 1 diff --git a/backend/tests/test_idea_gen_dual_channel.py b/backend/tests/test_idea_gen_dual_channel.py index ad58aae9..03e63436 100644 --- a/backend/tests/test_idea_gen_dual_channel.py +++ b/backend/tests/test_idea_gen_dual_channel.py @@ -2,6 +2,7 @@ from src.modules.idea_gen.workflow import ( _build_query_feedback_v1, brief_node, + route_query_generation, query_generation_node, research_dispatch_node, IdeaGenState, @@ -211,6 +212,53 @@ def invoke_text(self, system, user): assert result.web_queries == [] assert result.temp_data["query_state"]["last_rewrite_strategy"] == "stop" assert result.temp_data["query_history"]["recent"][0]["paper_queries"] == [] + assert result.temp_data["supervisor_action"] == "GENERATE_SEEDS" + + +def test_query_generation_iterative_stop_routes_to_phase_completion_in_evidence(): + """An iterative stop in evidence phase should advance the workflow instead of looping back to supervisor.""" + state = IdeaGenState( + input=IdeaGenInput(target="Test research on LLM agents"), + thread_id="test" + ) + state.research_brief = "Focused brief" + state.iteration_count = 1 + state.temp_data["workflow_date"] = "2026-03-15" + state.temp_data["research_phase"] = "evidence" + state.temp_data["query_state"] = { + "seed_paper_queries": ["llm agent memory management long context"], + "seed_web_queries": [], + "active_paper_queries": ["llm agent memory management long context"], + "active_web_queries": [], + "active_paper_query_plan": [], + "active_web_query_plan": [], + "last_rewrite_strategy": "initial", + "coverage_summary": {}, + "missing_aspects": [], + } + state.temp_data["query_feedback"] = { + "missing_aspects": [], + "paper_query_feedback": [], + "web_query_feedback": [], + "global_summary": {"current_iteration_goal": "sufficient_coverage"}, + "coverage_summary": {}, + } + + class MockExecutor: + def invoke_text(self, system, user): + return ( + '{"need_more_queries": false, ' + '"paper_queries": [], ' + '"web_queries": [], ' + '"missing_aspects": [], ' + '"rewrite_strategy": "stop", ' + '"reason": "Current evidence is sufficient."}' + ) + + result = query_generation_node(state, executor=MockExecutor()) + + assert result.temp_data["supervisor_action"] == "BUILD_HYPOTHESIS" + assert route_query_generation(result) == "hypothesis" def test_build_query_feedback_v1_summarizes_latest_runs(): @@ -349,9 +397,9 @@ class DummyExecutor: executed_only = [query for query, _provider in executed_queries] assert "raw supervisor topic that should not execute" not in executed_only assert executed_only == ["anchor query", "exploit query", "benchmark survey query"] - assert result.temp_data["research_runs"][0]["query_role"] == "anchor" - assert result.temp_data["research_runs"][1]["query_role"] == "exploit" - assert result.temp_data["research_runs"][2]["query_role"] == "benchmark" + assert result.research_runs[0]["query_role"] == "anchor" + assert result.research_runs[1]["query_role"] == "exploit" + assert result.research_runs[2]["query_role"] == "benchmark" def test_dispatch_fetches_and_summarizes_web_results(monkeypatch): @@ -405,7 +453,7 @@ def fake_search_sync(query, provider, max_results): result = research_dispatch_node(state) - web_run = result.temp_data["research_runs"][0] + web_run = result.research_runs[0] web_result = web_run["results"][0] assert web_result["web_fetch_method"] == "markdown_new" assert "memory retention" in web_result["content"] diff --git a/backend/tests/test_idea_gen_e2e.py b/backend/tests/test_idea_gen_e2e.py index 231e55df..dd5509bc 100644 --- a/backend/tests/test_idea_gen_e2e.py +++ b/backend/tests/test_idea_gen_e2e.py @@ -21,10 +21,11 @@ def test_idea_gen_live_run(monkeypatch): import httpx import openai from src.modules.idea_gen import workflow as idea_gen_workflow + from src.modules.idea_gen.utils import workflow_helpers run_dir = Path(__file__).resolve().parents[2] / "output" / "idea_gen_test_live_run" run_dir.mkdir(parents=True, exist_ok=True) - monkeypatch.setattr(idea_gen_workflow, "_get_output_dir", lambda state: run_dir) + monkeypatch.setattr(workflow_helpers, "_get_output_dir", lambda state: run_dir) workflow = idea_gen_workflow.build_workflow() try: @@ -47,7 +48,7 @@ def test_idea_gen_live_run(monkeypatch): state = _normalize_state(result, idea_gen_workflow) - final_report = state.temp_data.get("final_report", "") + final_report = state.final_report assert state.research_brief assert state.iteration_count >= 1 assert state.unit_count >= 1 diff --git a/backend/tests/test_idea_gen_evidence_status.py b/backend/tests/test_idea_gen_evidence_status.py new file mode 100644 index 00000000..e03cb4fa --- /dev/null +++ b/backend/tests/test_idea_gen_evidence_status.py @@ -0,0 +1,199 @@ +"""Tests for idea_gen evidence readiness assessment.""" + +from src.modules.idea_gen.schemas import EvidenceBundle, EvidenceItem, Seed +from src.modules.idea_gen.utils.evidence_retriever import ( + LaneQueryPack, + RetrieverConfig, + build_evidence_status, +) + + +def _lane_queries() -> dict[str, LaneQueryPack]: + return { + "inspiration": LaneQueryPack( + dense_query="cognitive memory framework for long-context agents", + sparse_queries=["cognitive memory framework"], + ), + "novelty": LaneQueryPack( + dense_query="nearest prior work for long-context memory retrieval agents", + sparse_queries=["long-context memory retrieval prior work"], + ), + "contradiction": LaneQueryPack( + dense_query="limitations and failure modes of memory retrieval agents", + sparse_queries=["memory retrieval limitations"], + ), + "execution": LaneQueryPack( + dense_query="benchmarks and datasets for memory retrieval agents", + sparse_queries=["memory retrieval benchmark dataset"], + ), + } + + +def _lane_diag(*, support_origin: str = "both") -> dict: + return { + "dense_query": "dummy", + "selected_hit_support": [ + {"paper_key": "p1", "support_origin": support_origin}, + {"paper_key": "p2", "support_origin": support_origin}, + ], + } + + +def test_build_evidence_status_blocks_when_required_lane_has_no_typed_signal(): + seed = Seed( + seed_id="seed_1", + target_gap="Gap", + mechanism="Mechanism", + scenario="Scenario", + expected_gain="Gain", + ) + bundle = EvidenceBundle( + seed_id="seed_1", + inspiration=[ + EvidenceItem( + paper_key="paper_a", + title="Framework paper", + excerpt="Provides a cognitive theory for memory control.", + relevance="Useful inspiration.", + lane_fit="strong", + evidence_signals=["theoretical_framework"], + ) + ], + novelty=[ + EvidenceItem( + paper_key="paper_b", + title="Prior work A", + excerpt="A direct prior method for long-context memory retrieval.", + relevance="Nearest prior.", + lane_fit="strong", + evidence_signals=["nearest_prior"], + ), + EvidenceItem( + paper_key="paper_c", + title="Prior work B", + excerpt="Another strong baseline for the same task.", + relevance="Competing approach.", + lane_fit="partial", + evidence_signals=["strong_baseline"], + ), + ], + contradiction=[ + EvidenceItem( + paper_key="paper_d", + title="General memory paper", + excerpt="Discusses memory agents broadly without explicit limitations.", + relevance="Related background only.", + lane_fit="partial", + evidence_signals=[], + ) + ], + execution=[ + EvidenceItem( + paper_key="paper_e", + title="Benchmark paper", + excerpt="Introduces a benchmark and evaluation protocol for memory agents.", + relevance="Validation setup.", + lane_fit="strong", + evidence_signals=["benchmark", "evaluation_protocol"], + ) + ], + ) + diagnostics = { + "inspiration": _lane_diag(), + "novelty": _lane_diag(), + "contradiction": _lane_diag(), + "execution": _lane_diag(), + } + + status = build_evidence_status( + seed, + bundle, + _lane_queries(), + diagnostics, + RetrieverConfig(), + ) + + assert status["ready_for_hypothesis"] is False + assert "contradiction" in status["blocking_lanes"] + assert status["lane_quality"]["contradiction"]["quality_ok"] is False + assert any("contradiction lane" in gap for gap in status["evidence_gaps"]) + assert status["suggested_topics"] + + +def test_build_evidence_status_ready_when_required_lanes_are_typed_and_supported(): + seed = Seed( + seed_id="seed_2", + target_gap="Gap", + mechanism="Mechanism", + scenario="Scenario", + expected_gain="Gain", + ) + bundle = EvidenceBundle( + seed_id="seed_2", + inspiration=[ + EvidenceItem( + paper_key="paper_a", + title="Framework paper", + excerpt="Provides a theoretical framework for memory management.", + relevance="Good inspiration.", + lane_fit="strong", + evidence_signals=["theoretical_framework"], + ) + ], + novelty=[ + EvidenceItem( + paper_key="paper_b", + title="Prior work A", + excerpt="A direct prior method for long-context memory retrieval.", + relevance="Nearest prior.", + lane_fit="strong", + evidence_signals=["nearest_prior"], + ), + EvidenceItem( + paper_key="paper_c", + title="Prior work B", + excerpt="A recent competitor with a similar retrieval loop.", + relevance="Direct competitor.", + lane_fit="strong", + evidence_signals=["direct_competitor"], + ), + ], + contradiction=[ + EvidenceItem( + paper_key="paper_d", + title="Failure analysis paper", + excerpt="Reports bottlenecks and failure modes in memory retrieval.", + relevance="Explicit limitations.", + lane_fit="strong", + evidence_signals=["failure_mode", "bottleneck"], + ) + ], + execution=[ + EvidenceItem( + paper_key="paper_e", + title="Benchmark paper", + excerpt="Provides benchmark, dataset, and evaluation protocol.", + relevance="Execution evidence.", + lane_fit="strong", + evidence_signals=["benchmark", "dataset", "evaluation_protocol"], + ) + ], + ) + diagnostics = { + "inspiration": _lane_diag(), + "novelty": _lane_diag(), + "contradiction": _lane_diag(), + "execution": _lane_diag(), + } + + status = build_evidence_status( + seed, + bundle, + _lane_queries(), + diagnostics, + RetrieverConfig(), + ) + + assert status["ready_for_hypothesis"] is True + assert status["blocking_lanes"] == [] + assert status["weighted_completeness"] >= 0.75 diff --git a/backend/tests/test_idea_gen_live.py b/backend/tests/test_idea_gen_live.py index 93726630..64bf869f 100644 --- a/backend/tests/test_idea_gen_live.py +++ b/backend/tests/test_idea_gen_live.py @@ -32,7 +32,7 @@ def test_idea_gen_live_run(): # Verify results assert result.iteration_count > 0, "Should have completed at least one iteration" assert result.notes, "Should have accumulated research notes" - assert result.temp_data.get("final_report"), "Should have generated final report" + assert result.final_report, "Should have generated final report" # Verify output files assert (output_dir / "research_brief.md").exists(), "Should create research brief" diff --git a/backend/tests/test_idea_gen_module.py b/backend/tests/test_idea_gen_module.py index 043a7167..0331f927 100644 --- a/backend/tests/test_idea_gen_module.py +++ b/backend/tests/test_idea_gen_module.py @@ -2,9 +2,11 @@ from __future__ import annotations -from pathlib import Path +from types import SimpleNamespace import pytest +from src.modules.idea_gen.utils import workflow_helpers +from src.modules.idea_gen.utils.state_utils import set_temp_value class _FakeResponse: @@ -86,202 +88,361 @@ def test_idea_gen_module_registration(): def test_idea_gen_workflow_smoke(monkeypatch, tmp_path, idea_gen_workflow_module): - from src.modules.idea_gen import config as cfg - from src.modules.idea_gen.utils import node_executor as node_executor_module + """Legacy no-langgraph fallback has been removed.""" + monkeypatch.setattr(idea_gen_workflow_module, "LANGGRAPH_AVAILABLE", False) + with pytest.raises(RuntimeError, match="langgraph"): + idea_gen_workflow_module.build_workflow() - model = _RoutingModel() +def test_idea_gen_workflow_iterative_query_regeneration(monkeypatch, tmp_path, idea_gen_workflow_module): + """Legacy iterative fallback path has been removed with the no-langgraph runner.""" monkeypatch.setattr(idea_gen_workflow_module, "LANGGRAPH_AVAILABLE", False) - monkeypatch.setattr(node_executor_module, "create_node_model", lambda _node_name: model) - monkeypatch.setattr(node_executor_module, "create_chat_model", lambda: model) - monkeypatch.setattr( - idea_gen_workflow_module, - "_get_output_dir", - lambda state: tmp_path / state.thread_id, - ) + with pytest.raises(RuntimeError, match="langgraph"): + idea_gen_workflow_module.build_workflow() - from src.modules.idea_gen import tools as idea_gen_tools - - monkeypatch.setattr( - idea_gen_tools, - "search_sync", - lambda query, provider, max_results: [ - { - "title": "Memory Benchmark", - "url": "https://example.com/memory-benchmark", - "snippet": f"{query} via {provider} with {max_results} results", - } - ], - ) - monkeypatch.setattr( - idea_gen_tools, - "fetch_webpage_as_markdown", - lambda url: ("# Web Page\n\nMemory Benchmark article with concrete evaluation details.", "markdown_new"), - ) - monkeypatch.setattr(cfg.NODE_TOOL_CONFIGS["research_dispatch"], "paper_metadata_providers", []) - monkeypatch.setattr(cfg.PAPER_PROCESSING_CONFIG, "enable_pdf_parsing", False) - monkeypatch.setattr(cfg.PAPER_PROCESSING_CONFIG, "enable_scoring", False) - monkeypatch.setattr(cfg.PAPER_PROCESSING_CONFIG, "enable_embedding_rerank", False) - workflow = idea_gen_workflow_module.build_workflow() - result = workflow.invoke( - { - "target": "Investigate memory management for long-context agents", - "allow_clarification": False, - "academic_domain": "AI/ML", - "target_venues": ["NIPS", "ICML"], - "max_iterations": 1, - "language": "en", - "thread_id": "idea-gen-unit-test", - } +def test_idea_gen_supervisor_stops_at_max_iterations(idea_gen_workflow_module): + state = idea_gen_workflow_module.IdeaGenState( + input=idea_gen_workflow_module.IdeaGenInput( + target="Test target", + allow_clarification=False, + max_iterations=1, + ), + research_brief="Test brief", + iteration_count=1, + broad_iteration_count=1, + notes="Existing notes", ) - state = _normalize_state(result, idea_gen_workflow_module) - - output_dir = tmp_path / "idea-gen-unit-test" - assert state.research_brief == "Map open problems in agent memory systems." - assert state.iteration_count == 1 - assert state.unit_count == 3 - assert "Memory Benchmark" in state.notes - assert state.temp_data["final_report"].startswith("# Final Report") - assert state.temp_data["query_history"]["seed"]["paper_queries"][0] == "llm agent memory management" - assert len(state.temp_data["query_history"]["seed"]["paper_queries"]) == 3 - assert "query_feedback" in state.temp_data - assert len(state.supervisor_messages) == 1 - assert len(state.researcher_messages) == 3 - assert "Memory Benchmark" in state.researcher_messages[0]["notes"] - assert (output_dir / "research_brief.md").read_text(encoding="utf-8") == state.research_brief - assert (output_dir / "final_report.md").read_text(encoding="utf-8") == state.temp_data["final_report"] - assert model.query_generation_calls == 1 + result = idea_gen_workflow_module.supervisor_node(state) -def test_idea_gen_workflow_iterative_query_regeneration(monkeypatch, tmp_path, idea_gen_workflow_module): - from src.modules.idea_gen import config as cfg - from src.modules.idea_gen import tools as idea_gen_tools - from src.modules.idea_gen.utils import node_executor as node_executor_module + assert result.temp_data["supervisor_action"] == "GENERATE_SEEDS" + assert result.temp_data["supervisor_topics"] == [] + assert result.temp.supervisor_action == "GENERATE_SEEDS" - class _IterativeRoutingModel: - def __init__(self): - self.query_generation_calls = 0 - self.supervisor_calls = 0 - - def invoke(self, messages): - system_text = messages[0].content if messages else "" - lowered = system_text.lower() - - if "structured academic research brief" in lowered: - return _FakeResponse('{"research_brief": "Map open problems in agent memory systems."}') - - if "academic search strategist" in lowered: - self.query_generation_calls += 1 - if self.query_generation_calls == 1: - return _FakeResponse( - '{"paper_queries": ["llm agent memory management long context", ' - '"agent memory retrieval planning"], ' - '"web_queries": ["LLM agent memory management survey challenges"]}' - ) - return _FakeResponse( - '{"paper_queries": ["llm agent memory management long context", ' - '"episodic retrieval planning llm agents"], ' - '"web_queries": ["LLM agent memory benchmark failure modes 2025 2026"], ' - '"rewrite_strategy": "tighten", ' - '"reason": "Use the latest feedback to deepen episodic retrieval and add benchmark coverage."}' - ) - if "supervisor for an academic idea exploration workflow" in lowered: - self.supervisor_calls += 1 - if self.supervisor_calls == 1: - return _FakeResponse( - '{"action": "CONDUCT_RESEARCH", "reflection": "Need initial evidence.", "topics": ["memory management in LLM agents"]}' - ) - if self.supervisor_calls == 2: - return _FakeResponse( - '{"action": "CONDUCT_RESEARCH", "reflection": "Need benchmark evidence.", "topics": ["agent memory benchmark failure modes"]}' - ) - return _FakeResponse( - '{"action": "RESEARCH_COMPLETE", "reflection": "Enough research collected.", "topics": []}' - ) +def test_route_supervisor_uses_downstream_completion_when_global_budget_exhausted(idea_gen_workflow_module): + state = idea_gen_workflow_module.IdeaGenState( + input=idea_gen_workflow_module.IdeaGenInput(target="Test target", allow_clarification=False), + iteration_count=2, + global_max_iterations=2, + seeds=[SimpleNamespace(seed_id="seed_1")], + evidence_bundles=[SimpleNamespace(seed_id="seed_1")], + ) + set_temp_value(state, "supervisor_action", "CONDUCT_RESEARCH") - if "compress" in lowered or "synthesizing academic research findings" in lowered: - return _FakeResponse("Condensed notes.") + assert idea_gen_workflow_module.route_supervisor(state) == "hypothesis" - if ( - "academic research report writer" in lowered - or "write a comprehensive academic report" in lowered - or "generate an academic idea exploration memo" in lowered - ): - return _FakeResponse("# Final Report\n\nA focused idea generation summary.") + state.hypotheses = [SimpleNamespace(hypothesis_id="hyp_1")] + assert idea_gen_workflow_module.route_supervisor(state) == "compress" - raise AssertionError(f"Unhandled prompt in routing model: {system_text[:120]!r}") - model = _IterativeRoutingModel() - monkeypatch.setattr(idea_gen_workflow_module, "LANGGRAPH_AVAILABLE", False) - monkeypatch.setattr(node_executor_module, "create_node_model", lambda _node_name: model) - monkeypatch.setattr(node_executor_module, "create_chat_model", lambda: model) - monkeypatch.setattr( - idea_gen_workflow_module, - "_get_output_dir", - lambda state: tmp_path / state.thread_id, - ) - monkeypatch.setattr( - idea_gen_tools, - "search_sync", - lambda query, provider, max_results: [ - { - "title": f"Memory Benchmark for {query[:20]}", - "url": "https://arxiv.org/abs/2501.00001" if "benchmark" not in query.lower() else "https://huggingface.co/blog/agent-memory", - "snippet": f"{query} via {provider} with {max_results} results", - } - ], - ) - monkeypatch.setattr( - idea_gen_tools, - "fetch_webpage_as_markdown", - lambda url: ("# Web Page\n\nBenchmark notes for agent memory systems.", "markdown_new"), +def test_route_critic_stops_iteration_when_global_budget_exhausted(idea_gen_workflow_module): + state = idea_gen_workflow_module.IdeaGenState( + input=idea_gen_workflow_module.IdeaGenInput(target="Test target", allow_clarification=False), + iteration_count=3, + global_max_iterations=3, ) - monkeypatch.setattr(cfg.NODE_TOOL_CONFIGS["research_dispatch"], "paper_metadata_providers", []) - monkeypatch.setattr(cfg.PAPER_PROCESSING_CONFIG, "enable_pdf_parsing", False) - monkeypatch.setattr(cfg.PAPER_PROCESSING_CONFIG, "enable_scoring", False) - monkeypatch.setattr(cfg.PAPER_PROCESSING_CONFIG, "enable_embedding_rerank", False) + set_temp_value(state, "critic_action", "ITERATE") - workflow = idea_gen_workflow_module.build_workflow() - result = workflow.invoke( - { - "target": "Investigate memory management for long-context agents", - "allow_clarification": False, - "academic_domain": "AI/ML", - "target_venues": ["NIPS", "ICML"], - "max_iterations": 3, - "language": "en", - "thread_id": "idea-gen-iterative-test", - } + assert idea_gen_workflow_module.route_critic(state) == "report" + + +def test_query_generation_initial_failure_records_fatal_error(idea_gen_workflow_module): + class _RaisingExecutor: + def invoke_text(self, _system, _user): + raise RuntimeError("upstream llm unavailable") + + state = idea_gen_workflow_module.IdeaGenState( + input=idea_gen_workflow_module.IdeaGenInput(target="Test target", allow_clarification=False), + research_brief="Test brief", ) - state = _normalize_state(result, idea_gen_workflow_module) + idea_gen_workflow_module.start_node(state) - assert state.iteration_count == 2 - assert model.query_generation_calls == 2 - assert state.temp_data["query_history"]["seed"]["paper_queries"][:2] == [ - "llm agent memory management long context", - "agent memory retrieval planning", - ] - assert len(state.temp_data["query_history"]["seed"]["paper_queries"]) == 3 - assert state.temp_data["query_history"]["recent"][0]["paper_queries"] - assert state.temp_data["query_state"]["missing_aspects"] - assert state.temp_data["query_feedback"]["built_from_iteration"] == 1 + with pytest.raises(RuntimeError, match="query generation failed in initial mode"): + idea_gen_workflow_module.query_generation_node(state, executor=_RaisingExecutor()) + assert len(state.node_errors) == 1 + assert state.node_errors[0].node == "query_generation" + assert state.node_errors[0].recoverable is False + assert state.temp_data["node_errors"][0]["node"] == "query_generation" -def test_idea_gen_supervisor_stops_at_max_iterations(idea_gen_workflow_module): + +def test_supervisor_forces_fallback_when_total_thinks_hit_limit(monkeypatch, idea_gen_workflow_module): + monkeypatch.setattr(idea_gen_workflow_module.get_workflow_config(), "max_total_thinks_per_phase", 2) state = idea_gen_workflow_module.IdeaGenState( - input=idea_gen_workflow_module.IdeaGenInput( - target="Test target", - allow_clarification=False, - max_iterations=1, - ), + input=idea_gen_workflow_module.IdeaGenInput(target="Test target", allow_clarification=False), research_brief="Test brief", - iteration_count=1, notes="Existing notes", ) + set_temp_value(state, "research_phase", "broad") + set_temp_value(state, "supervisor_action", "THINK") + set_temp_value(state, "consecutive_thinks", 1) + set_temp_value(state, "total_thinks", 2) + set_temp_value(state, "think_phase", "broad") result = idea_gen_workflow_module.supervisor_node(state) - assert result.temp_data["supervisor_action"] == "RESEARCH_COMPLETE" - assert result.temp_data["supervisor_topics"] == [] + assert result.temp.supervisor_action == "GENERATE_SEEDS" + assert result.temp.supervisor_tasks == [] + + +def test_supervisor_validation_repairs_non_json_response(monkeypatch, idea_gen_workflow_module): + class _RepairingExecutor: + def invoke_text(self, _system, _user): + return """{ + "action": "CONDUCT_RESEARCH", + "reflection": "Need one more targeted evidence pass.", + "tasks": [ + { + "topic": "long-context RAG routing under evidence dispersion", + "reason": "fill missing evidence about adaptive routing", + "seed_id": "", + "lane": "" + } + ] + }""" + + state = idea_gen_workflow_module.IdeaGenState( + input=idea_gen_workflow_module.IdeaGenInput(target="Test target", allow_clarification=False), + research_brief="Test brief", + notes="Existing notes", + ) + set_temp_value(state, "research_phase", "broad") + state.temp_data["supervisor_raw_response"] = "We should conduct one more search pass focused on routing." + monkeypatch.setattr(workflow_helpers, "_save_artifact", lambda *_args, **_kwargs: None) + monkeypatch.setattr(workflow_helpers, "_save_node_state", lambda *_args, **_kwargs: None) + + result = idea_gen_workflow_module.supervisor_validate_node(state, executor=_RepairingExecutor()) + + assert result.temp.supervisor_action == "CONDUCT_RESEARCH" + assert len(result.temp.supervisor_tasks) == 1 + assert result.temp.supervisor_validation_status == "repaired" + + +def test_seed_discovery_validation_repairs_non_json_response(monkeypatch, idea_gen_workflow_module): + class _RepairingExecutor: + def __init__(self): + self.calls = 0 + + def invoke_text(self, _system, _user): + self.calls += 1 + return """[ + { + "seed_id": "seed_1", + "target_gap": "Current long-context RAG pipelines retrieve long units but lack explicit memory state across iterative agent steps.", + "mechanism": "Introduce a memory-aware retrieval controller that persists salient evidence slots across turns.", + "scenario": "Multi-step long-document QA agents with dispersed evidence.", + "expected_gain": "Higher multi-hop answer accuracy with fewer redundant retrievals.", + "source_paper_keys": ["paper_a", "paper_b"] + }, + { + "seed_id": "seed_2", + "target_gap": "Existing LC-vs-RAG routing policies are static and ignore evidence dispersion at query time.", + "mechanism": "Use an adaptive router that predicts when to prefer LC reading, long-unit retrieval, or hybrid filtering.", + "scenario": "Mixed workloads spanning narrative, dialogue, and report-style corpora.", + "expected_gain": "Better quality-cost tradeoff across heterogeneous long-context tasks.", + "source_paper_keys": ["paper_b"] + } + ]""" + + monkeypatch.setattr(workflow_helpers, "_save_artifact", lambda *_args, **_kwargs: None) + state = idea_gen_workflow_module.IdeaGenState( + input=idea_gen_workflow_module.IdeaGenInput(target="Test target", allow_clarification=False), + research_brief="Test brief", + notes="## Paper 1: Example summary\n## Paper 2: Another summary", + ) + set_temp_value( + state, + "final_papers_for_compress", + [ + {"paper_key": "paper_a", "title": "Paper A"}, + {"paper_key": "paper_b", "title": "Paper B"}, + ], + ) + state.temp_data["seed_discovery_raw_response"] = "Focused synthesis:\n- hierarchical long-context RAG is promising." + state.temp_data["seed_discovery_paper_catalog"] = [ + {"paper_key": "paper_a", "title": "Paper A"}, + {"paper_key": "paper_b", "title": "Paper B"}, + ] + + result = idea_gen_workflow_module.seed_discovery_validate_node(state, executor=_RepairingExecutor()) + + assert len(result.seeds) == 2 + assert result.seeds[0].seed_id == "seed_1" + assert result.seeds[0].source_paper_keys == ["paper_a", "paper_b"] + assert result.temp.research_phase == "evidence" + assert result.node_errors == [] + assert result.temp.seed_validation_status == "repaired" + + +def test_hypothesis_repairs_non_json_response(monkeypatch, idea_gen_workflow_module): + from src.modules.idea_gen.schemas import EvidenceBundle, EvidenceItem, Seed + + class _InitialExecutor: + def invoke_messages(self, _messages): + return _FakeResponse("Promising hypothesis: explicit memory-aware routing should improve long-context RAG.") + + class _RepairingExecutor: + def invoke_text(self, _system, _user): + return """{ + "hypothesis_id": "hyp_seed_1", + "seed_id": "seed_1", + "title": "Memory-Aware Routing for Long-Context RAG", + "core_claim": "A memory-aware router that adapts retrieval depth to evidence dispersion will improve long-context QA accuracy over static RAG baselines.", + "target_gap": "Existing long-context RAG systems use static routing despite variable evidence dispersion.", + "nearest_prior_work": ["paper_a"], + "novelty_delta": "It combines adaptive routing with persistent evidence memory across retrieval rounds.", + "assumptions": ["Evidence dispersion can be estimated from retrieved context."], + "risks": ["Router errors may waste context budget."], + "minimal_validation": "Evaluate on long-context QA benchmarks with dispersed evidence using fixed-compute comparisons.", + "falsification_signal": "No accuracy gain appears when retrieval depth is adapted under matched token budgets.", + "expected_upside": "Better quality-cost tradeoff on mixed long-context workloads.", + "expected_cost": "Moderate implementation and evaluation complexity." + }""" + + monkeypatch.setattr(workflow_helpers, "_save_artifact", lambda *_args, **_kwargs: None) + state = idea_gen_workflow_module.IdeaGenState( + input=idea_gen_workflow_module.IdeaGenInput(target="Test target", allow_clarification=False), + seeds=[ + Seed( + seed_id="seed_1", + target_gap="gap", + mechanism="mechanism", + scenario="scenario", + expected_gain="gain", + source_paper_keys=["paper_a"], + ) + ], + evidence_bundles=[ + EvidenceBundle( + seed_id="seed_1", + novelty=[ + EvidenceItem( + paper_key="paper_a", + title="Paper A", + excerpt="excerpt", + relevance="high", + ) + ], + ) + ], + ) + + result = idea_gen_workflow_module.hypothesis_node( + state, + executor=_InitialExecutor(), + validator_executor=_RepairingExecutor(), + ) + + assert len(result.hypotheses) == 1 + assert result.hypotheses[0].hypothesis_id == "hyp_seed_1" + assert result.hypotheses[0].nearest_prior_work == ["paper_a"] + + +def test_workflow_graph_routes_paper_pool_before_evidence(monkeypatch, idea_gen_workflow_module): + if not idea_gen_workflow_module.LANGGRAPH_AVAILABLE: + pytest.skip("langgraph is not available") + + call_order: list[str] = [] + + def _record(name): + def _inner(state, *_args, **_kwargs): + call_order.append(name) + return state + return _inner + + def _start(state): + call_order.append("start") + state.global_max_iterations = 5 + return state + + def _clarify(state, *_args, **_kwargs): + call_order.append("clarify") + state.temp.need_clarification = False + return state + + def _query_generation(state, *_args, **_kwargs): + call_order.append("query_generation") + set_temp_value(state, "supervisor_action", "CONDUCT_RESEARCH") + return state + + def _research_dispatch(state, *_args, **_kwargs): + call_order.append("research_dispatch") + state.iteration_count += 1 + return state + + def _supervisor(state, *_args, **_kwargs): + call_order.append("supervisor") + return state + + def _supervisor_validate(state, *_args, **_kwargs): + call_order.append("supervisor_validate") + if not state.seeds: + set_temp_value(state, "supervisor_action", "GENERATE_SEEDS") + else: + set_temp_value(state, "supervisor_action", "BUILD_HYPOTHESIS") + return state + + def _seed_discovery(state, *_args, **_kwargs): + call_order.append("seed_discovery") + state.seeds = [SimpleNamespace(seed_id="seed_1")] + set_temp_value(state, "research_phase", "evidence") + return state + + def _paper_pool_process(state, *_args, **_kwargs): + call_order.append("paper_pool_process") + set_temp_value(state, "final_papers_for_compress", [{"paper_key": "p1"}]) + return state + + def _seed_validate(state, *_args, **_kwargs): + call_order.append("seed_discovery_validate") + return state + + def _evidence_orchestrator(state, *_args, **_kwargs): + call_order.append("evidence_orchestrator") + state.evidence_bundles = [SimpleNamespace(seed_id="seed_1")] + set_temp_value( + state, + "evidence_status", + {"phase_ready": True, "seed_statuses": [{"seed_id": "seed_1", "ready_for_hypothesis": True}]}, + ) + return state + + def _hypothesis(state, *_args, **_kwargs): + call_order.append("hypothesis") + state.hypotheses = [SimpleNamespace(hypothesis_id="hyp_1")] + return state + + def _critic(state, *_args, **_kwargs): + call_order.append("critic") + set_temp_value(state, "critic_action", "ACCEPT") + return state + + monkeypatch.setattr(idea_gen_workflow_module, "start_node", _start) + monkeypatch.setattr(idea_gen_workflow_module, "clarify_node", _clarify) + monkeypatch.setattr(idea_gen_workflow_module, "brief_node", _record("brief")) + monkeypatch.setattr(idea_gen_workflow_module, "query_generation_node", _query_generation) + monkeypatch.setattr(idea_gen_workflow_module, "supervisor_node", _supervisor) + monkeypatch.setattr(idea_gen_workflow_module, "supervisor_validate_node", _supervisor_validate) + monkeypatch.setattr(idea_gen_workflow_module, "research_dispatch_node", _research_dispatch) + monkeypatch.setattr(idea_gen_workflow_module, "seed_discovery_node", _seed_discovery) + monkeypatch.setattr(idea_gen_workflow_module, "seed_discovery_validate_node", _seed_validate) + monkeypatch.setattr(idea_gen_workflow_module, "paper_pool_process_node", _paper_pool_process) + monkeypatch.setattr(idea_gen_workflow_module, "evidence_orchestrator_node", _evidence_orchestrator) + monkeypatch.setattr(idea_gen_workflow_module, "hypothesis_node", _hypothesis) + monkeypatch.setattr(idea_gen_workflow_module, "compress_node", _record("compress")) + monkeypatch.setattr(idea_gen_workflow_module, "critic_node", _critic) + monkeypatch.setattr(idea_gen_workflow_module, "report_node", _record("report")) + monkeypatch.setattr(idea_gen_workflow_module, "finalize_node", _record("finalize")) + + workflow = idea_gen_workflow_module.build_workflow() + workflow.invoke({"target": "Test target", "allow_clarification": False, "thread_id": "graph-order"}) + + assert call_order.index("supervisor") < call_order.index("supervisor_validate") + assert call_order.index("paper_pool_process") < call_order.index("seed_discovery") + assert call_order.index("seed_discovery") < call_order.index("seed_discovery_validate") + assert call_order.index("seed_discovery_validate") < call_order.index("evidence_orchestrator") + compress_positions = [index for index, name in enumerate(call_order) if name == "compress"] + assert compress_positions, f"compress not called: {call_order}" + assert call_order.index("hypothesis") < compress_positions[-1] diff --git a/backend/tests/test_idea_gen_multi_iteration.py b/backend/tests/test_idea_gen_multi_iteration.py index fb63347a..701465e6 100644 --- a/backend/tests/test_idea_gen_multi_iteration.py +++ b/backend/tests/test_idea_gen_multi_iteration.py @@ -17,10 +17,11 @@ def test_idea_gen_multi_iteration_with_multiple_units(monkeypatch): from src.modules.idea_gen import config as cfg from src.modules.idea_gen import workflow as idea_gen_workflow from src.modules.idea_gen.schemas import SupervisorDecision + from src.modules.idea_gen.utils import workflow_helpers run_dir = Path(__file__).resolve().parents[2] / "output" / "idea_gen_multi_iter_test" run_dir.mkdir(parents=True, exist_ok=True) - monkeypatch.setattr(idea_gen_workflow, "_get_output_dir", lambda state: run_dir) + monkeypatch.setattr(workflow_helpers, "_get_output_dir", lambda state: run_dir) monkeypatch.setattr(cfg.NODE_TOOL_CONFIGS["research_dispatch"], "paper_metadata_providers", []) # Mock supervisor decisions (iter 0 uses early return, so LLM calls start from iter 1) @@ -159,7 +160,7 @@ def mock_search_sync(query, provider, max_results): assert len(state.notes) > 100, "Notes should contain substantial content" # Verify final action - assert state.temp_data.get("supervisor_action") == "RESEARCH_COMPLETE" + assert state.temp.supervisor_action == "RESEARCH_COMPLETE" # Verify output files assert (run_dir / "research_brief.md").exists() diff --git a/backend/tests/test_idea_gen_paper_cache.py b/backend/tests/test_idea_gen_paper_cache.py index 781363eb..5a2c0eda 100644 --- a/backend/tests/test_idea_gen_paper_cache.py +++ b/backend/tests/test_idea_gen_paper_cache.py @@ -17,6 +17,7 @@ def test_research_dispatch_and_compress_reuse_paper_cache(monkeypatch, tmp_path) from src.modules.idea_gen import config as cfg from src.modules.idea_gen import tools as idea_gen_tools from src.modules.idea_gen import workflow + from src.modules.idea_gen.utils import workflow_helpers cache_db = tmp_path / "paper_cache.sqlite3" parse_calls = {"count": 0} @@ -26,7 +27,7 @@ def test_research_dispatch_and_compress_reuse_paper_cache(monkeypatch, tmp_path) "# Results\n\nResults section.\n" ) - monkeypatch.setattr(workflow, "_get_output_dir", lambda state: tmp_path / state.thread_id) + monkeypatch.setattr(workflow_helpers, "_get_output_dir", lambda state: tmp_path / state.thread_id) monkeypatch.setattr( idea_gen_tools, "search_sync", @@ -61,7 +62,7 @@ def _fake_parse(_url: str, parser_priority: str = "paddleocr_url", max_pages: in state_first = workflow.research_dispatch_node(state_first) assert parse_calls["count"] == 1 - assert state_first.temp_data["research_runs"][0]["paper_parse_metadata"][0]["cache_hit"] is False + assert state_first.research_runs[0]["paper_parse_metadata"][0]["cache_hit"] is False first_executor = _FakeCompressExecutor() state_first = workflow.compress_node(state_first, first_executor) @@ -73,7 +74,7 @@ def _fake_parse(_url: str, parser_priority: str = "paddleocr_url", max_pages: in "total": 1, } - first_paper = state_first.temp_data["compressed_papers"][0] + first_paper = state_first.temp.compressed_papers[0] assert first_paper["core_summary"].startswith("compressed:# Abstract") assert first_paper["technical_depth"].startswith("compressed:# Method") assert first_paper["empirical_support"].startswith("compressed:# Results") @@ -87,7 +88,7 @@ def _fake_parse(_url: str, parser_priority: str = "paddleocr_url", max_pages: in state_second = workflow.research_dispatch_node(state_second) assert parse_calls["count"] == 1 - assert state_second.temp_data["research_runs"][0]["paper_parse_metadata"][0]["cache_hit"] is True + assert state_second.research_runs[0]["paper_parse_metadata"][0]["cache_hit"] is True second_executor = _FakeCompressExecutor() state_second = workflow.compress_node(state_second, second_executor) @@ -99,7 +100,7 @@ def _fake_parse(_url: str, parser_priority: str = "paddleocr_url", max_pages: in "total": 1, } - second_paper = state_second.temp_data["compressed_papers"][0] + second_paper = state_second.temp.compressed_papers[0] assert second_paper["core_summary"] == first_paper["core_summary"] assert second_paper["technical_depth"] == first_paper["technical_depth"] assert second_paper["empirical_support"] == first_paper["empirical_support"] diff --git a/backend/tests/test_idea_gen_paper_reranker.py b/backend/tests/test_idea_gen_paper_reranker.py index 73e0eef4..dbcb6708 100644 --- a/backend/tests/test_idea_gen_paper_reranker.py +++ b/backend/tests/test_idea_gen_paper_reranker.py @@ -116,6 +116,7 @@ def test_research_dispatch_collects_reranked_pdf_candidates_without_parsing(monk from src.modules.idea_gen import config as cfg from src.modules.idea_gen import tools as idea_gen_tools from src.modules.idea_gen import workflow + from src.modules.idea_gen.utils import workflow_helpers parsed_urls: list[str] = [] @@ -169,7 +170,7 @@ def _fake_parse(url: str, max_pages=None): parsed_urls.append(url) return f"# Parsed {url}", "mock" - monkeypatch.setattr(workflow, "_get_output_dir", lambda state: tmp_path / state.thread_id) + monkeypatch.setattr(workflow_helpers, "_get_output_dir", lambda state: tmp_path / state.thread_id) monkeypatch.setattr(workflow, "_create_paper_cache_store", lambda: None) monkeypatch.setattr(workflow, "OpenAlexEnricher", _FakeEnricher) monkeypatch.setattr(workflow, "PaperReranker", _FakeReranker) @@ -193,7 +194,7 @@ def _fake_parse(url: str, max_pages=None): state = workflow.research_dispatch_node(state) assert parsed_urls == [] - assert state.temp_data["research_runs"][0]["paper_rerank"]["selected_count"] == 1 - assert state.temp_data["research_runs"][0]["results"][1]["rerank_selected_for_pdf"] is True + assert state.research_runs[0]["paper_rerank"]["selected_count"] == 1 + assert state.research_runs[0]["results"][1]["rerank_selected_for_pdf"] is True assert state.paper_pool is not None assert state.paper_pool.get_count() == 2 diff --git a/backend/tests/test_idea_gen_workflow_e2e.py b/backend/tests/test_idea_gen_workflow_e2e.py index fcc7effb..c2401b6f 100644 --- a/backend/tests/test_idea_gen_workflow_e2e.py +++ b/backend/tests/test_idea_gen_workflow_e2e.py @@ -51,7 +51,10 @@ # Workflow runtime configuration "workflow_config": { "default_max_iterations": 6, + "global_max_iterations": 8, "max_concurrent_research_units": 5, + "max_consecutive_thinks": 2, + "max_total_thinks_per_phase": 3, "search_results_per_topic": 15, "max_critic_iterations": 3, "critic_quality_threshold": 6.0, @@ -81,6 +84,7 @@ def test_idea_gen_workflow_effectiveness(monkeypatch): import openai from src.modules.idea_gen import workflow as wf from src.modules.idea_gen import config as cfg + from src.modules.idea_gen.utils import workflow_helpers # Apply node LLM configurations for node_name, node_cfg in TEST_CONFIG["node_configs"].items(): @@ -100,7 +104,7 @@ def test_idea_gen_workflow_effectiveness(monkeypatch): run_dir = Path(__file__).resolve().parents[2] / "output" / "idea_gen_workflow_e2e" run_dir.mkdir(parents=True, exist_ok=True) - monkeypatch.setattr(wf, "_get_output_dir", lambda state: run_dir) + monkeypatch.setattr(workflow_helpers, "_get_output_dir", lambda state: run_dir) workflow = wf.build_workflow() @@ -125,12 +129,12 @@ def test_idea_gen_workflow_effectiveness(monkeypatch): print(f"Iterations: {state.iteration_count}") print(f"Research Units: {state.unit_count}") print(f"Notes Length: {len(state.notes)} chars") - print(f"Report Length: {len(state.temp_data.get('final_report', ''))} chars") + print(f"Report Length: {len(state.final_report)} chars") print(f"Output Directory: {run_dir}") print(f"{'='*80}\n") if state.research_brief: print(f"Research Brief:\n{state.research_brief[:300]}...\n") - if state.temp_data.get("final_report"): - print(f"Final Report Preview:\n{state.temp_data['final_report'][:500]}...\n") + if state.final_report: + print(f"Final Report Preview:\n{state.final_report[:500]}...\n") diff --git a/backend/tests/test_model_availability.py b/backend/tests/test_model_availability.py new file mode 100644 index 00000000..806e4b8b --- /dev/null +++ b/backend/tests/test_model_availability.py @@ -0,0 +1,167 @@ +"""快速测试不同 preset mode 中的模型是否可用。 + +Usage: + python backend/tests/test_model_availability.py +""" +from __future__ import annotations + +import asyncio +import importlib.util +import os +import sys +from pathlib import Path + +from dotenv import load_dotenv + +# 加载 .env 文件 +env_path = Path(__file__).resolve().parents[2] / ".env" +if env_path.exists(): + load_dotenv(env_path) + print(f"✓ 已加载环境变量: {env_path}") +else: + print(f"⚠ 未找到 .env 文件: {env_path}") + +# 验证必需的环境变量 +required_vars = ["DF_API_URL", "DF_API_KEY"] +missing_vars = [var for var in required_vars if not os.getenv(var)] +if missing_vars: + print(f"✗ 缺少必需的环境变量: {', '.join(missing_vars)}") + sys.exit(1) + +print(f"✓ API URL: {os.getenv('DF_API_URL')}") +print(f"✓ API KEY: {'*' * 20}{os.getenv('DF_API_KEY')[-4:]}") + +# Add backend to path +backend_dir = Path(__file__).resolve().parents[1] +if str(backend_dir) not in sys.path: + sys.path.insert(0, str(backend_dir)) + +# 动态导入 get_node_models_for_preset 函数 +spec = importlib.util.spec_from_file_location( + "run_idea_gen_seed_hypothesis_e2e_simple", + Path(__file__).parent / "run_idea_gen_seed_hypothesis_e2e_simple.py" +) +module = importlib.util.module_from_spec(spec) +spec.loader.exec_module(module) +get_node_models_for_preset = module.get_node_models_for_preset + + +async def test_model(model_name: str) -> tuple[str, bool, str]: + """测试单个模型是否可用。 + + Args: + model_name: 模型名称 + + Returns: + (model_name, is_available, error_message) + """ + try: + from langchain_openai import ChatOpenAI + + # 从环境变量获取配置 + api_url = os.getenv("DF_API_URL") + api_key = os.getenv("DF_API_KEY") + + # 创建模型实例 + llm = ChatOpenAI( + model=model_name, + base_url=api_url, + api_key=api_key, + temperature=0.0, + max_tokens=10, + timeout=60, + ) + + # 发送简单请求 + response = await llm.ainvoke("Say Hi") + + return (model_name, True, response.content) + + except Exception as e: + error_msg = str(e) + # 提取关键错误信息 + if "Error code:" in error_msg: + error_msg = error_msg.split("Error code:")[1].split("\n")[0].strip() + return (model_name, False, error_msg[:100]) + + +async def main(): + """主测试函数。""" + print("=" * 80) + print("模型可用性测试") + print("=" * 80) + + # 收集所有模式的模型 + all_models = set() + mode_models = {} + + for mode in ["fast", "balanced", "quality"]: + models = get_node_models_for_preset(mode) + mode_models[mode] = models + all_models.update(models.values()) + + print(f"\n发现 {len(all_models)} 个唯一模型:") + for model in sorted(all_models): + print(f" - {model}") + + # 并行测试所有模型 + print(f"\n开始测试 (超时: 60秒/模型)...") + print("-" * 80) + + tasks = [test_model(model) for model in sorted(all_models)] + results = await asyncio.gather(*tasks) + + # 统计结果 + available = [] + unavailable = [] + + for model_name, is_available, message in results: + if is_available: + available.append(model_name) + print(f"✓ {model_name:<30} 可用 - {message[:50]}") + else: + unavailable.append(model_name) + print(f"✗ {model_name:<30} 不可用 - {message}") + + # 输出摘要 + print("\n" + "=" * 80) + print("测试摘要") + print("=" * 80) + print(f"可用模型: {len(available)}/{len(all_models)}") + print(f"不可用模型: {len(unavailable)}/{len(all_models)}") + + if unavailable: + print("\n不可用的模型:") + for model in unavailable: + print(f" ✗ {model}") + + # 检查每个 preset mode 的状态 + print("\n" + "=" * 80) + print("Preset Mode 状态") + print("=" * 80) + + for mode in ["fast", "balanced", "quality"]: + models = mode_models[mode] + unique_models = set(models.values()) + unavailable_in_mode = unique_models & set(unavailable) + + status = "✓ 全部可用" if not unavailable_in_mode else f"✗ {len(unavailable_in_mode)} 个不可用" + print(f"\n{mode.upper()} mode: {status}") + print(f" 使用的模型: {', '.join(sorted(unique_models))}") + + if unavailable_in_mode: + print(f" 不可用: {', '.join(sorted(unavailable_in_mode))}") + print(f" 影响的节点:") + for node, model in models.items(): + if model in unavailable_in_mode: + print(f" - {node}: {model}") + + print("\n" + "=" * 80) + + # 返回退出码 + return 0 if not unavailable else 1 + + +if __name__ == "__main__": + exit_code = asyncio.run(main()) + sys.exit(exit_code) diff --git a/backend/tests/test_openalex_enrichment.py b/backend/tests/test_openalex_enrichment.py index 4a54cd78..5beaa550 100644 --- a/backend/tests/test_openalex_enrichment.py +++ b/backend/tests/test_openalex_enrichment.py @@ -169,6 +169,7 @@ def test_research_dispatch_uses_ai4scholar_first_plus_openalex_enrichment(monkey from src.modules.idea_gen import config as cfg from src.modules.idea_gen import workflow from src.modules.idea_gen import tools as idea_gen_tools + from src.modules.idea_gen.utils import workflow_helpers providers_seen: list[str] = [] @@ -216,7 +217,7 @@ def enrich_many(self, results): enriched.append(item) return enriched - monkeypatch.setattr(workflow, "_get_output_dir", lambda state: tmp_path / state.thread_id) + monkeypatch.setattr(workflow_helpers, "_get_output_dir", lambda state: tmp_path / state.thread_id) monkeypatch.setattr(idea_gen_tools, "search_sync", _fake_search_sync) monkeypatch.setattr( idea_gen_tools, @@ -239,10 +240,10 @@ def enrich_many(self, results): assert providers_seen == ["ai4scholar", "tavily"] assert state.iteration_count == 1 - assert len(state.temp_data["research_runs"]) == 2 - assert state.temp_data["research_runs"][0]["provider_used"] == "ai4scholar" - assert state.temp_data["research_runs"][0]["metadata_providers"] == ["openalex"] - assert state.temp_data["research_runs"][0]["metadata_enrichment"]["matched"] == 1 + assert len(state.research_runs) == 2 + assert state.research_runs[0]["provider_used"] == "ai4scholar" + assert state.research_runs[0]["metadata_providers"] == ["openalex"] + assert state.research_runs[0]["metadata_enrichment"]["matched"] == 1 assert "Venue/Year: ICLR (2024)" in state.raw_notes assert "Metadata: openalex" in state.raw_notes assert "Industry overview" in state.raw_notes @@ -253,6 +254,7 @@ def test_research_dispatch_falls_back_to_arxiv_when_ai4scholar_and_semantic_scho from src.modules.idea_gen import config as cfg from src.modules.idea_gen import workflow from src.modules.idea_gen import tools as idea_gen_tools + from src.modules.idea_gen.utils import workflow_helpers providers_seen: list[str] = [] @@ -278,7 +280,7 @@ class _FakeEnricher: def enrich_many(self, results): return [dict(result, openalex_match_found=False) for result in results] - monkeypatch.setattr(workflow, "_get_output_dir", lambda state: tmp_path / state.thread_id) + monkeypatch.setattr(workflow_helpers, "_get_output_dir", lambda state: tmp_path / state.thread_id) monkeypatch.setattr(idea_gen_tools, "search_sync", _fake_search_sync) monkeypatch.setattr(workflow, "OpenAlexEnricher", _FakeEnricher) monkeypatch.setattr(cfg.PAPER_PROCESSING_CONFIG, "enable_pdf_parsing", False) @@ -295,10 +297,10 @@ def enrich_many(self, results): state = workflow.research_dispatch_node(state) assert providers_seen == ["ai4scholar", "semantic_scholar", "arxiv"] - assert state.temp_data["research_runs"][0]["provider_used"] == "arxiv" - assert state.temp_data["research_runs"][0]["provider_attempts"][0]["result_count"] == 0 - assert state.temp_data["research_runs"][0]["provider_attempts"][1]["result_count"] == 0 - assert state.temp_data["research_runs"][0]["provider_attempts"][2]["result_count"] == 1 + assert state.research_runs[0]["provider_used"] == "arxiv" + assert state.research_runs[0]["provider_attempts"][0]["result_count"] == 0 + assert state.research_runs[0]["provider_attempts"][1]["result_count"] == 0 + assert state.research_runs[0]["provider_attempts"][2]["result_count"] == 1 def test_research_dispatch_preserves_openalex_metadata_in_notes_with_two_tier_storage(monkeypatch, tmp_path): @@ -306,6 +308,7 @@ def test_research_dispatch_preserves_openalex_metadata_in_notes_with_two_tier_st from src.modules.idea_gen import config as cfg from src.modules.idea_gen import workflow from src.modules.idea_gen import tools as idea_gen_tools + from src.modules.idea_gen.utils import workflow_helpers def _fake_search_sync(query: str, provider: str, max_results: int): if provider == "ai4scholar": @@ -342,7 +345,7 @@ def enrich_many(self, results): enriched.append(item) return enriched - monkeypatch.setattr(workflow, "_get_output_dir", lambda state: tmp_path / state.thread_id) + monkeypatch.setattr(workflow_helpers, "_get_output_dir", lambda state: tmp_path / state.thread_id) monkeypatch.setattr(idea_gen_tools, "search_sync", _fake_search_sync) monkeypatch.setattr(workflow, "OpenAlexEnricher", _FakeEnricher) monkeypatch.setattr(cfg.PAPER_PROCESSING_CONFIG, "enable_pdf_parsing", False) @@ -370,6 +373,7 @@ def test_research_dispatch_reuses_cached_metadata_without_openalex_call(monkeypa from src.modules.idea_gen import config as cfg from src.modules.idea_gen import workflow from src.modules.idea_gen import tools as idea_gen_tools + from src.modules.idea_gen.utils import workflow_helpers from src.modules.idea_gen.utils.paper_cache_store import PaperCacheStore, build_paper_key cache_db = tmp_path / "paper_cache.sqlite3" @@ -408,7 +412,7 @@ class _FailEnricher: def enrich_many(self, results): raise AssertionError("OpenAlexEnricher should not be called on metadata cache hit") - monkeypatch.setattr(workflow, "_get_output_dir", lambda state: tmp_path / state.thread_id) + monkeypatch.setattr(workflow_helpers, "_get_output_dir", lambda state: tmp_path / state.thread_id) monkeypatch.setattr(idea_gen_tools, "search_sync", _fake_search_sync) monkeypatch.setattr(workflow, "OpenAlexEnricher", _FailEnricher) monkeypatch.setattr(cfg.PAPER_PROCESSING_CONFIG, "enable_pdf_parsing", False) @@ -425,7 +429,7 @@ def enrich_many(self, results): state = workflow.research_dispatch_node(state) - run = state.temp_data["research_runs"][0] + run = state.research_runs[0] assert run["metadata_enrichment"]["cache_hits"] == 1 assert run["metadata_enrichment"]["matched"] == 1 assert "Venue/Year: ICLR (2024)" in state.raw_notes diff --git a/backend/tests/test_parallel_optimization.py b/backend/tests/test_parallel_optimization.py new file mode 100644 index 00000000..8a7dbde4 --- /dev/null +++ b/backend/tests/test_parallel_optimization.py @@ -0,0 +1,140 @@ +"""Test parallel optimization for PDF download and LLM scoring.""" +import time +from pathlib import Path + +import pytest + +from src.modules.idea_gen.schemas import PaperCandidate +from src.modules.idea_gen.utils.paper_cache_store import PaperCacheStore +from src.modules.idea_gen.utils.paper_pipeline import PaperPipeline +from src.modules.idea_gen.utils.pdf_processor import PDFProcessor + + +@pytest.mark.integration +def test_pdf_cache_integration(): + """Test that PDF processor uses cache correctly.""" + cache_store = PaperCacheStore.create_default() + processor = PDFProcessor(cache_store=cache_store) + + # Test ArXiv ID + arxiv_id = "2301.00001" + + # First call - should download + start = time.time() + text1 = processor.download_and_parse(arxiv_id) + duration1 = time.time() - start + + # Second call - should hit cache + start = time.time() + text2 = processor.download_and_parse(arxiv_id) + duration2 = time.time() - start + + # Verify cache hit + assert text1 == text2 + assert duration2 < duration1 * 0.5, f"Cache should be faster: {duration2:.2f}s vs {duration1:.2f}s" + print(f"✓ Cache hit: {duration1:.2f}s → {duration2:.2f}s ({duration2/duration1*100:.1f}%)") + + +@pytest.mark.integration +def test_parallel_pdf_download(): + """Test that PDF downloads are parallelized.""" + cache_store = PaperCacheStore.create_default() + pipeline = PaperPipeline(cache_store=cache_store) + + # Create test candidates + arxiv_ids = ["2301.00001", "2301.00002", "2301.00003"] + candidates = [ + PaperCandidate( + title=f"Test Paper {i}", + abstract="Test abstract", + source="arxiv", + external_id=arxiv_id, + relevance_score=9.0, + ) + for i, arxiv_id in enumerate(arxiv_ids) + ] + + # Process with parallel pipeline + start = time.time() + results = pipeline.process_candidates(candidates) + duration = time.time() - start + + assert len(results) == len(candidates) + print(f"✓ Parallel processing: {len(candidates)} papers in {duration:.2f}s") + + +@pytest.mark.integration +def test_parallel_scoring(): + """Test that LLM scoring is parallelized.""" + from src.modules.idea_gen.utils.paper_relevance_scorer import PaperRelevanceScorer + from src.modules.idea_gen.utils import NodeExecutor + + executor = NodeExecutor("test_scorer") + scorer = PaperRelevanceScorer(executor, threshold=0.0) + + # Create test papers + papers = [ + { + "title": f"Test Paper {i}", + "abstract": "This is a test abstract about LLM agents and memory management.", + "venue": "Test Venue", + "publication_year": 2024, + } + for i in range(5) + ] + + user_query = "LLM agent memory management" + + # Score with parallel execution + start = time.time() + scored = scorer.score_papers(papers, user_query) + duration = time.time() - start + + assert len(scored) <= len(papers) + print(f"✓ Parallel scoring: {len(papers)} papers in {duration:.2f}s") + + +def test_cache_key_generation(): + """Test that cache keys are generated correctly.""" + from src.modules.idea_gen.utils.paper_cache_store import build_paper_key + + # Test ArXiv key + key1 = build_paper_key("Test Paper", "https://arxiv.org/abs/2301.00001") + assert key1.startswith("arxiv:"), f"Expected arxiv: prefix, got {key1}" + + # Test DOI key + key2 = build_paper_key("Test Paper", "https://doi.org/10.1234/test") + assert key2.startswith("doi:"), f"Expected doi: prefix, got {key2}" + + print(f"✓ Cache key generation: {key1}, {key2}") + + +if __name__ == "__main__": + print("=" * 80) + print("Testing Parallel Optimization") + print("=" * 80) + + print("\n[1/4] Testing cache key generation...") + test_cache_key_generation() + + print("\n[2/4] Testing PDF cache integration...") + try: + test_pdf_cache_integration() + except Exception as e: + print(f" ⚠ Skipped (requires network): {e}") + + print("\n[3/4] Testing parallel PDF download...") + try: + test_parallel_pdf_download() + except Exception as e: + print(f" ⚠ Skipped (requires network): {e}") + + print("\n[4/4] Testing parallel scoring...") + try: + test_parallel_scoring() + except Exception as e: + print(f" ⚠ Skipped (requires API): {e}") + + print("\n" + "=" * 80) + print("✓ All tests completed") + print("=" * 80) diff --git a/backend/tests/test_preset_mode_config.py b/backend/tests/test_preset_mode_config.py new file mode 100644 index 00000000..a1276fed --- /dev/null +++ b/backend/tests/test_preset_mode_config.py @@ -0,0 +1,201 @@ +"""验证预设模式配置的单元测试。""" +import pytest +from src.gateway.contracts.idea_gen import IdeaGenRunCreate +from src.gateway.routers.idea_gen import _config_overrides_from_input +from src.modules.idea_gen.config import ( + get_paper_processing_config, + get_workflow_config, + runtime_config_scope, +) +from src.modules.idea_gen.config_presets import ( + apply_preset, + build_runtime_config_bundle, + list_preset_descriptors, +) + + +def test_fast_preset_scoring_disabled(): + """验证 fast 模式关闭论文评分。""" + config = apply_preset("fast") + assert config["paper_config"]["enable_scoring"] is False, "fast 模式应关闭评分" + + +def test_balanced_preset_scoring_disabled(): + """验证 balanced 模式关闭论文评分。""" + config = apply_preset("balanced") + assert config["paper_config"]["enable_scoring"] is False, "balanced 模式应关闭评分" + + +def test_quality_preset_scoring_enabled(): + """验证 quality 模式启用论文评分。""" + config = apply_preset("quality") + assert config["paper_config"]["enable_scoring"] is True, "quality 模式应启用评分" + + +def test_fast_preset_pdf_count(): + """验证 fast 模式 PDF 下载数量较少。""" + config = apply_preset("fast") + assert config["paper_config"]["top_k_for_pdf"] == 8, "fast 模式应下载 8 篇 PDF" + assert config["paper_config"]["candidate_pool_size"] == 15 + + +def test_balanced_preset_pdf_count(): + """验证 balanced 模式 PDF 下载数量适中。""" + config = apply_preset("balanced") + assert config["paper_config"]["top_k_for_pdf"] == 15 + assert config["paper_config"]["candidate_pool_size"] == 30 + + +def test_quality_preset_pdf_count(): + """验证 quality 模式 PDF 下载数量最多。""" + config = apply_preset("quality") + assert config["paper_config"]["top_k_for_pdf"] == 25 + assert config["paper_config"]["candidate_pool_size"] == 50 + + +def test_critic_models_progression(): + """验证 critic 模型数量递增。""" + fast = apply_preset("fast") + balanced = apply_preset("balanced") + quality = apply_preset("quality") + + assert len(fast["critic_config"]["model_names"]) == 1, "fast 应使用 1 个 critic 模型" + assert len(balanced["critic_config"]["model_names"]) == 2, "balanced 应使用 2 个 critic 模型" + assert len(quality["critic_config"]["model_names"]) == 2, "quality 应使用 2 个 critic 模型" + + +def test_iterations_progression(): + """验证迭代次数递增。""" + fast = apply_preset("fast") + balanced = apply_preset("balanced") + quality = apply_preset("quality") + + assert fast["workflow_config"]["default_max_iterations"] == 2 + assert balanced["workflow_config"]["default_max_iterations"] == 4 + assert quality["workflow_config"]["default_max_iterations"] == 6 + + +def test_evidence_lane_targets_progression(): + """验证证据通道目标递增。""" + fast = apply_preset("fast") + balanced = apply_preset("balanced") + quality = apply_preset("quality") + + fast_total = sum(fast["evidence_config"]["lane_targets"].values()) + balanced_total = sum(balanced["evidence_config"]["lane_targets"].values()) + quality_total = sum(quality["evidence_config"]["lane_targets"].values()) + + assert fast_total == 5, "fast 模式总证据数应为 5" + assert balanced_total == 9, "balanced 模式总证据数应为 9" + assert quality_total == 12, "quality 模式总证据数应为 12" + + +def test_custom_override_merging(): + """验证自定义覆盖正确合并。""" + overrides = { + "paper_config": { + "enable_scoring": True, + "top_k_for_pdf": 10, + } + } + config = apply_preset("fast", overrides) + + # 覆盖的参数应生效 + assert config["paper_config"]["enable_scoring"] is True + assert config["paper_config"]["top_k_for_pdf"] == 10 + + # 未覆盖的参数应保持预设值 + assert config["paper_config"]["candidate_pool_size"] == 15 + + +def test_runtime_bundle_applies_preset_inside_scope(): + """验证 preset runtime bundle 会真正影响生产运行时 getter。""" + default_workflow = get_workflow_config() + default_paper = get_paper_processing_config() + bundle = build_runtime_config_bundle("fast") + + with runtime_config_scope(bundle): + workflow_config = get_workflow_config() + paper_config = get_paper_processing_config() + + assert workflow_config.max_concurrent_research_units == 2 + assert workflow_config.max_critic_iterations == 1 + assert paper_config.top_k_for_pdf == 8 + assert paper_config.enable_scoring is False + + assert get_workflow_config().max_concurrent_research_units == default_workflow.max_concurrent_research_units + assert get_paper_processing_config().top_k_for_pdf == default_paper.top_k_for_pdf + + +def test_flat_api_input_maps_to_preset_overrides(): + """验证 API 的平铺字段会转换为 backend preset override 结构。""" + input_payload = IdeaGenRunCreate( + target="Investigate graph-augmented test-time adaptation for multimodal agents.", + preset_mode="balanced", + max_iterations=4, + max_concurrent_research_units=7, + search_results_per_topic=18, + enable_scoring=True, + top_k_papers=6, + ) + + overrides = _config_overrides_from_input(input_payload) + + assert overrides == { + "workflow_config": { + "max_concurrent_research_units": 7, + "search_results_per_topic": 18, + }, + "paper_config": { + "enable_scoring": True, + "top_k_papers": 6, + }, + } + + +def test_preset_descriptors_follow_backend_registry(): + """验证前端展示用 preset 元数据来自 backend preset registry。""" + descriptors = list_preset_descriptors() + + assert [descriptor["mode"] for descriptor in descriptors] == [ + "fast", + "balanced", + "quality", + "custom", + ] + assert descriptors[1]["default_max_iterations"] == 4 + assert descriptors[2]["max_critic_iterations"] == 3 + + +def test_node_models_for_preset(): + """验证测试脚本中的节点模型配置函数。""" + import sys + from pathlib import Path + + # 动态导入测试脚本 + test_script = Path(__file__).parent / "run_idea_gen_seed_hypothesis_e2e_simple.py" + spec = __import__('importlib.util').util.spec_from_file_location("test_module", test_script) + module = __import__('importlib.util').util.module_from_spec(spec) + spec.loader.exec_module(module) + + # 验证 fast 模式使用轻量模型 + fast_models = module.get_node_models_for_preset("fast") + assert fast_models["clarify"] == "gpt-4o-mini" + assert fast_models["brief"] == "gpt-4o-mini" + assert fast_models["compress"] == "gpt-4o-mini" + + # 验证 balanced 模式使用当前脚本中的高质量模型 + balanced_models = module.get_node_models_for_preset("balanced") + assert balanced_models["seed_discovery"] == "gpt-5.4" + assert balanced_models["hypothesis"] == "gpt-5.4" + + # 验证 quality 模式使用当前脚本中的最强模型 + quality_models = module.get_node_models_for_preset("quality") + assert quality_models["supervisor"] == "gpt-5.4" + assert quality_models["seed_discovery"] == "gpt-5.4" + assert quality_models["hypothesis"] == "gpt-5.4" + assert quality_models["report"] == "gpt-5.4" + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/backend/tests/test_renumber_citations_fix.py b/backend/tests/test_renumber_citations_fix.py new file mode 100644 index 00000000..804e8d50 --- /dev/null +++ b/backend/tests/test_renumber_citations_fix.py @@ -0,0 +1,35 @@ +"""Test renumber_citations with malformed multi-citations.""" +import sys +sys.path.insert(0, "/mnt/paper2any/pzw/proj/paperagent/pzw/worktrees/idea/backend") + +from src.modules.idea_gen.utils.report_builder import renumber_citations + + +def test_renumber_with_malformed_citations(): + """Test that renumber_citations handles malformed multi-citations.""" + + # Mock source catalog + source_catalog = { + "paper:899f095df206": {"title": "Paper 1", "url": "http://example.com/1"}, + "paper:876dfeb9c32b": {"title": "Paper 2", "url": "http://example.com/2"}, + "paper:4b3cedeced2f": {"title": "Paper 3", "url": "http://example.com/3"}, + } + + # Test case from the bug report + input_text = "这些问题限制了RAG技术在长上下文任务中的应用潜力[@paper:899f095df206; @paper:876dfeb9c32b; @paper:4b3cedeced2f]。" + + result, used_keys = renumber_citations(input_text, source_catalog) + + # Should expand and renumber correctly + expected = "这些问题限制了RAG技术在长上下文任务中的应用潜力[1][2][3]。" + assert result == expected, f"Expected: {expected}\nGot: {result}" + + # Check used keys + assert used_keys == ["paper:899f095df206", "paper:876dfeb9c32b", "paper:4b3cedeced2f"], \ + f"Expected all three keys, got: {used_keys}" + + print("✓ renumber_citations handles malformed multi-citations correctly") + + +if __name__ == "__main__": + test_renumber_with_malformed_citations() diff --git a/backend/tests/test_resume_functionality.py b/backend/tests/test_resume_functionality.py new file mode 100644 index 00000000..0723e2e9 --- /dev/null +++ b/backend/tests/test_resume_functionality.py @@ -0,0 +1,73 @@ +"""测试 resume 功能的辅助函数""" +import sys +from pathlib import Path + +# Add backend to path +backend_dir = Path(__file__).resolve().parents[1] +if str(backend_dir) not in sys.path: + sys.path.insert(0, str(backend_dir)) + +# Import the resume functions +sys.path.insert(0, str(Path(__file__).parent)) +from run_idea_gen_seed_hypothesis_e2e_simple import find_latest_output_dir, load_state_from_dir + + +def test_find_latest_output_dir(): + """测试查找最新输出目录""" + base_output_dir = Path(__file__).resolve().parents[1] / "output" / "idea_gen" + + # 测试查找所有目录 + latest_dir = find_latest_output_dir(base_output_dir, "*") + if latest_dir: + print(f"✓ 找到最新目录: {latest_dir.name}") + else: + print("✗ 未找到任何目录") + + # 测试查找特定模式 + latest_e2e_dir = find_latest_output_dir(base_output_dir, "idea-gen-e2e-*") + if latest_e2e_dir: + print(f"✓ 找到最新 E2E 目录: {latest_e2e_dir.name}") + else: + print("✗ 未找到 E2E 目录") + + +def test_load_state_from_dir(): + """测试从目录加载状态""" + base_output_dir = Path(__file__).resolve().parents[1] / "output" / "idea_gen" + + # 查找一个有状态文件的目录 + test_dir = base_output_dir / "idea-gen-e2e-20260408_140050-evidence-check" + + if not test_dir.exists(): + print(f"✗ 测试目录不存在: {test_dir}") + return + + print(f"\n测试目录: {test_dir.name}") + + # 加载状态 + state = load_state_from_dir(test_dir) + + if state: + print(f"✓ 成功加载状态") + print(f" - 迭代次数: {state.get('iteration_count', 'N/A')}") + print(f" - 研究简报长度: {len(state.get('research_brief', ''))}") + print(f" - 种子数量: {len(state.get('seeds', []))}") + print(f" - 假设数量: {len(state.get('hypotheses', []))}") + else: + print("✗ 无法加载状态") + + +if __name__ == "__main__": + print("=" * 80) + print("测试 Resume 功能") + print("=" * 80) + + print("\n[1/2] 测试查找最新输出目录...") + test_find_latest_output_dir() + + print("\n[2/2] 测试加载状态...") + test_load_state_from_dir() + + print("\n" + "=" * 80) + print("✓ 测试完成") + print("=" * 80) diff --git a/backend/tests/test_resume_simple.py b/backend/tests/test_resume_simple.py new file mode 100644 index 00000000..33b65bdb --- /dev/null +++ b/backend/tests/test_resume_simple.py @@ -0,0 +1,100 @@ +"""简单测试 resume 功能(不依赖完整环境)""" +import json +from pathlib import Path + + +def find_latest_output_dir(base_output_dir: Path, pattern: str = "idea_gen_seed_hypothesis_e2e_*") -> Path | None: + """查找最新的输出目录""" + matching_dirs = list(base_output_dir.glob(pattern)) + if not matching_dirs: + return None + return max(matching_dirs, key=lambda p: p.stat().st_mtime) + + +def load_state_from_dir(output_dir: Path) -> dict | None: + """从输出目录加载最新的状态文件""" + priority_files = [ + "node_report_state.json", + "node_hypothesis_state.json", + "node_evidence_orchestrator_state.json", + "node_seed_discovery_state.json", + "node_compress_state.json", + ] + + for filename in priority_files: + state_file = output_dir / filename + if state_file.exists(): + try: + with open(state_file, "r", encoding="utf-8") as f: + return json.load(f) + except Exception as e: + print(f" ⚠ 无法加载 {filename}: {e}") + continue + + state_files = list(output_dir.glob("node_*_state.json")) + if not state_files: + return None + + latest_state_file = max(state_files, key=lambda p: p.stat().st_mtime) + try: + with open(latest_state_file, "r", encoding="utf-8") as f: + return json.load(f) + except Exception as e: + print(f" ✗ 无法加载状态文件 {latest_state_file.name}: {e}") + return None + + +if __name__ == "__main__": + print("=" * 80) + print("测试 Resume 功能(简化版)") + print("=" * 80) + + # 测试路径 + base_output_dir = Path(__file__).resolve().parents[2] / "output" + print(f"\n基础输出目录: {base_output_dir}") + print(f"目录存在: {base_output_dir.exists()}") + + # 测试查找最新目录 + print("\n[1/3] 查找最新输出目录...") + latest_dir = find_latest_output_dir(base_output_dir) + if latest_dir: + print(f" ✓ 找到最新目录: {latest_dir.name}") + else: + print(" ✗ 未找到匹配的目录") + exit(1) + + # 测试加载状态 + print("\n[2/3] 加载状态文件...") + state = load_state_from_dir(latest_dir) + if state: + print(f" ✓ 成功加载状态") + print(f" - 迭代次数: {state.get('iteration_count', 'N/A')}") + print(f" - 研究简报长度: {len(state.get('research_brief', ''))}") + print(f" - Thread ID: {state.get('thread_id', 'N/A')}") + + # 提取 input 信息 + input_data = state.get('input', {}) + if input_data: + print(f" - Target: {input_data.get('target', 'N/A')[:60]}...") + print(f" - Domain: {input_data.get('academic_domain', 'N/A')}") + print(f" - Venues: {input_data.get('target_venues', [])}") + else: + print(" ✗ 无法加载状态") + exit(1) + + # 测试指定目录 + print("\n[3/3] 测试指定目录...") + target_dir = base_output_dir / "idea_gen_seed_hypothesis_e2e_quality_20260411_013955" + if target_dir.exists(): + print(f" ✓ 目标目录存在: {target_dir.name}") + state2 = load_state_from_dir(target_dir) + if state2: + print(f" ✓ 成功加载指定目录的状态") + else: + print(" ✗ 无法加载指定目录的状态") + else: + print(f" ⚠ 目标目录不存在: {target_dir}") + + print("\n" + "=" * 80) + print("✓ 测试完成") + print("=" * 80) diff --git a/backend/tests/test_seed_discovery_from_compress.py b/backend/tests/test_seed_discovery_from_compress.py new file mode 100644 index 00000000..8c96c94e --- /dev/null +++ b/backend/tests/test_seed_discovery_from_compress.py @@ -0,0 +1,115 @@ +"""Test seed_discovery with optimized prompts from compress state.""" +import json +from pathlib import Path + +from src.modules.idea_gen.workflow import ( + IdeaGenState, + IdeaGenInput, + seed_discovery_node, +) +from src.modules.idea_gen.utils import NodeExecutor + + +def test_seed_discovery_from_compress(): + """Load compress state and test seed_discovery with optimized prompts.""" + + output_dir = Path(__file__).parent.parent.parent / "output" / "idea_gen_seed_hypothesis_e2e" + + # Load compress state + compress_state_file = output_dir / "node_compress_state.json" + if not compress_state_file.exists(): + print(f"❌ Compress state not found: {compress_state_file}") + return False + + compress_state = json.loads(compress_state_file.read_text(encoding="utf-8")) + + # Create state from compress output + state = IdeaGenState( + input=IdeaGenInput( + target="RAG for long-context LLM agents", + academic_domain="AI/ML", + target_venues=["NIPS", "ICML", "ICLR"], + language="zh", + ), + thread_id="test_seed_from_compress", + research_brief=compress_state.get("research_brief", ""), + notes=compress_state.get("notes", ""), + ) + + # Copy temp data + state.temp_data = dict(compress_state.get("temp_data", {})) + state.temp = compress_state.get("temp", {}) + + print(f"[TEST] Loaded state:") + print(f" - Research brief: {len(state.research_brief)} chars") + print(f" - Notes: {len(state.notes)} chars") + print(f" - Final papers: {len(state.temp.final_papers_for_compress)} papers") + + print("\n" + "="*80) + print("[TEST] Running seed_discovery with optimized prompts...") + print("="*80 + "\n") + + # Run seed_discovery + executor = NodeExecutor("seed_discovery") + validator_executor = NodeExecutor("seed_discovery_validator") + + try: + result_state = seed_discovery_node( + state, + executor=executor, + validate_immediately=True, + validator_executor=validator_executor, + ) + + print("\n" + "="*80) + print("[TEST] Results") + print("="*80) + + if result_state.seeds: + print(f"✅ SUCCESS: Generated {len(result_state.seeds)} seeds") + print(f"\nValidation status: {result_state.temp.seed_validation_status or 'unknown'}") + print(f"Validation rounds: {result_state.temp.seed_validation_rounds}") + + print("\n[SEEDS]") + for i, seed in enumerate(result_state.seeds, 1): + print(f"\n{i}. {seed.seed_id}") + print(f" Gap: {seed.target_gap[:80]}...") + print(f" Mechanism: {seed.mechanism[:80]}...") + print(f" Source Papers: {len(seed.source_paper_keys)}") + + # Save to output dir + output_file = output_dir / "test_seed_discovery_success.json" + output_file.write_text( + json.dumps( + [seed.model_dump() for seed in result_state.seeds], + ensure_ascii=False, + indent=2 + ), + encoding="utf-8" + ) + print(f"\n✅ Results saved to: {output_file}") + return True + else: + print("❌ FAILED: No seeds generated") + return False + + except Exception as e: + print(f"\n❌ EXCEPTION: {e}") + import traceback + traceback.print_exc() + return False + + +if __name__ == "__main__": + print("="*80) + print("Testing Seed Discovery from Compress State") + print("="*80 + "\n") + + success = test_seed_discovery_from_compress() + + print("\n" + "="*80) + if success: + print("✅ TEST PASSED") + else: + print("❌ TEST FAILED") + print("="*80) diff --git a/backend/tests/test_seed_discovery_llm_call.py b/backend/tests/test_seed_discovery_llm_call.py new file mode 100644 index 00000000..3efa72ae --- /dev/null +++ b/backend/tests/test_seed_discovery_llm_call.py @@ -0,0 +1,155 @@ +"""Test actual LLM call for seed discovery.""" +import os +import sys +from pathlib import Path + +# Minimal test without full workflow imports +def test_llm_call(): + """Test if LLM can generate seeds with the actual prompt.""" + + print("="*80) + print("Testing Actual LLM Call for Seed Discovery") + print("="*80) + + # Check API key + api_key = os.getenv("OPENAI_API_KEY") + if not api_key: + print("❌ OPENAI_API_KEY not set") + return + + print(f"✓ OPENAI_API_KEY is set") + + # Simple test prompt + research_brief = "Survey retrieval-augmented generation (RAG) for long-context LLM agents." + + paper_summaries = """## Paper 1: LongRAG + +### 核心摘要 +LongRAG proposes a dual-perspective retrieval paradigm for long-context QA. + +### 技术细节 +Uses both extraction and filtering perspectives. + +### 实验验证 +Achieves strong results on long-context benchmarks. + +## Paper 2: MemoRAG + +### 核心摘要 +MemoRAG introduces global memory for long-context processing. + +### 技术细节 +Maintains a memory state across retrieval rounds. + +### 实验验证 +Reduces redundant retrieval significantly.""" + + paper_catalog = [ + {"paper_key": "longrag_2024", "title": "LongRAG"}, + {"paper_key": "memorag_2024", "title": "MemoRAG"}, + ] + + n_seeds = 2 + + # Build prompt + catalog_lines = "Available paper keys:\n" + for item in paper_catalog: + catalog_lines += f'- "{item["paper_key"]}": {item["title"]}\n' + + system = f"""CRITICAL INSTRUCTION: You MUST output ONLY a valid JSON array. No other text is allowed. + +You are a JSON generator for research seed extraction. Your task: analyze the paper summaries and research brief, then output EXACTLY {n_seeds} research seeds as a JSON array. + +MANDATORY OUTPUT FORMAT - Your response must start with '[' and end with ']': +[ + {{ + "seed_id": "seed_1", + "target_gap": "specific gap in current research", + "mechanism": "concrete mechanism borrowed from papers", + "scenario": "target application scenario", + "expected_gain": "measurable expected improvement", + "source_paper_keys": ["paper_key_1", "paper_key_2"] + }} +] + +STRICT RULES: +- Output EXACTLY {n_seeds} seeds +- NO markdown fences +- NO explanations +- NO text before '[' or after ']' + +START YOUR RESPONSE WITH '[' NOW.""" + + user = f"""Research brief: +{research_brief} + +{catalog_lines} + +Paper pool summaries: +{paper_summaries} + +Remember: Output ONLY the JSON array. Start with '[' and end with ']'. No other text.""" + + print(f"\n📊 Test Input:") + print(f" - System prompt: {len(system)} chars") + print(f" - User prompt: {len(user)} chars") + print(f" - Total: {len(system) + len(user)} chars (~{(len(system) + len(user))//4} tokens)") + + # Try to call OpenAI + try: + from openai import OpenAI + + client = OpenAI(api_key=api_key) + + print(f"\n🔄 Calling OpenAI API (gpt-4o-mini)...") + + response = client.chat.completions.create( + model="gpt-4o-mini", + messages=[ + {"role": "system", "content": system}, + {"role": "user", "content": user} + ], + temperature=0.1, + max_tokens=12000, + ) + + content = response.choices[0].message.content + + print(f"\n✅ LLM Response Received:") + print(f" - Length: {len(content)} chars") + print(f" - Starts with: {content[:50]}...") + print(f" - Ends with: ...{content[-50:]}") + + # Validate JSON + import json + try: + parsed = json.loads(content) + print(f"\n✅ JSON Parsing Successful:") + print(f" - Type: {type(parsed)}") + print(f" - Seeds count: {len(parsed) if isinstance(parsed, list) else 'N/A'}") + + if isinstance(parsed, list) and len(parsed) > 0: + print(f"\n📋 First Seed:") + first_seed = parsed[0] + for key, value in first_seed.items(): + print(f" - {key}: {str(value)[:60]}...") + + print(f"\n✅ Test PASSED - LLM successfully generated valid JSON seeds!") + + except json.JSONDecodeError as e: + print(f"\n❌ JSON Parsing Failed: {e}") + print(f"\nFull response:") + print(content) + + except ImportError: + print(f"\n❌ OpenAI library not installed") + print(f" Run: pip install openai") + + except Exception as e: + print(f"\n❌ LLM Call Failed: {e}") + import traceback + traceback.print_exc() + + +if __name__ == "__main__": + test_llm_call() diff --git a/backend/tests/test_seed_discovery_prompt.py b/backend/tests/test_seed_discovery_prompt.py new file mode 100644 index 00000000..aa6e6819 --- /dev/null +++ b/backend/tests/test_seed_discovery_prompt.py @@ -0,0 +1,190 @@ +"""Test seed discovery prompt construction.""" +import json +from pathlib import Path + + +def test_prompt_construction(): + """Test that the prompt is correctly constructed.""" + + print("="*80) + print("Testing Seed Discovery Prompt Construction") + print("="*80) + + # Load test data + output_dir = Path("/mnt/paper2any/pzw/proj/paperagent/pzw/worktrees/idea/output/idea_gen_seed_hypothesis_e2e") + test_output = output_dir / "seed_discovery_topk_test_output.md" + + if not test_output.exists(): + print(f"❌ Test output not found: {test_output}") + return + + # Read the paper summaries we generated + with open(test_output, 'r', encoding='utf-8') as f: + content = f.read() + + # Extract paper summaries section + if "## Paper Summaries" in content: + paper_summaries = content.split("## Paper Summaries")[1].strip() + else: + print("❌ Could not find paper summaries in test output") + return + + # Mock research brief + research_brief = """ +# Research Brief: RAG for Long-Context LLM Agents + +Survey retrieval-augmented generation (RAG) for long-context LLM agents and propose novel research ideas. +""" + + # Mock paper catalog + paper_catalog = [ + {"paper_key": "paper_1", "title": "Paper 1"}, + {"paper_key": "paper_2", "title": "Paper 2"}, + ] + + n_seeds = 2 + + print(f"\n📊 Input Statistics:") + print(f" - Paper summaries: {len(paper_summaries):,} chars") + print(f" - Research brief: {len(research_brief):,} chars") + print(f" - Paper catalog: {len(paper_catalog)} papers") + print(f" - Target seeds: {n_seeds}") + + # Build prompt (copied from prompts.py) + catalog_lines = "" + if paper_catalog: + rendered = [ + f'- "{item.get("paper_key", "").strip()}": {item.get("title", "").strip()}' + for item in paper_catalog + if str(item.get("paper_key", "")).strip() and str(item.get("title", "")).strip() + ] + if rendered: + catalog_lines = "Available paper keys:\n" + "\n".join(rendered) + + system = f"""CRITICAL INSTRUCTION: You MUST output ONLY a valid JSON array. No other text is allowed. + +You are a JSON generator for research seed extraction. Your task: analyze the paper summaries and research brief, then output EXACTLY {n_seeds} research seeds as a JSON array. + +MANDATORY OUTPUT FORMAT - Your response must start with '[' and end with ']': +[ + {{ + "seed_id": "seed_1", + "target_gap": "specific gap in current research", + "mechanism": "concrete mechanism borrowed from papers", + "scenario": "target application scenario", + "expected_gain": "measurable expected improvement", + "source_paper_keys": ["paper_key_1", "paper_key_2"] + }} +] + +STRICT RULES: +- Output EXACTLY {n_seeds} seeds +- Each seed must be distinct (different gaps or mechanisms) +- source_paper_keys must reference actual paper keys from the provided catalog +- Be specific and falsifiable, not vague +- NO markdown fences (no ```) +- NO headings or explanations +- NO prose or commentary +- NO text before '[' or after ']' + +START YOUR RESPONSE WITH '[' NOW.""" + + user = f"""Research brief: +{research_brief} + +{catalog_lines} + +Paper pool summaries: +{paper_summaries} + +Remember: Output ONLY the JSON array. Start with '[' and end with ']'. No other text.""" + + print(f"\n📝 Prompt Statistics:") + print(f" - System prompt: {len(system):,} chars") + print(f" - User prompt: {len(user):,} chars") + print(f" - Total prompt: {len(system) + len(user):,} chars") + print(f" - Estimated tokens: ~{(len(system) + len(user)) // 4:,}") + + # Check for potential issues + print(f"\n🔍 Prompt Analysis:") + + issues = [] + + # Check if prompt is too long + total_chars = len(system) + len(user) + if total_chars > 50000: + issues.append(f"⚠️ Prompt is very long ({total_chars:,} chars, ~{total_chars//4:,} tokens)") + + # Check if paper summaries are empty + if len(paper_summaries.strip()) < 100: + issues.append("⚠️ Paper summaries are too short or empty") + + # Check if catalog is empty + if not paper_catalog: + issues.append("⚠️ Paper catalog is empty") + + # Check for conflicting instructions + if "markdown" in system.lower() and "no markdown" in system.lower(): + issues.append("⚠️ Conflicting markdown instructions") + + if issues: + print(" Issues found:") + for issue in issues: + print(f" {issue}") + else: + print(" ✓ No obvious issues detected") + + # Save prompts for inspection + prompt_file = output_dir / "seed_discovery_prompt_test.txt" + with open(prompt_file, 'w', encoding='utf-8') as f: + f.write("="*80 + "\n") + f.write("SYSTEM PROMPT\n") + f.write("="*80 + "\n") + f.write(system) + f.write("\n\n" + "="*80 + "\n") + f.write("USER PROMPT\n") + f.write("="*80 + "\n") + f.write(user) + + print(f"\n💾 Prompts saved to: {prompt_file}") + + # Test JSON parsing + print(f"\n🧪 Testing JSON Format:") + test_json = f"""[ + {{ + "seed_id": "seed_1", + "target_gap": "Test gap", + "mechanism": "Test mechanism", + "scenario": "Test scenario", + "expected_gain": "Test gain", + "source_paper_keys": ["paper_1"] + }}, + {{ + "seed_id": "seed_2", + "target_gap": "Test gap 2", + "mechanism": "Test mechanism 2", + "scenario": "Test scenario 2", + "expected_gain": "Test gain 2", + "source_paper_keys": ["paper_2"] + }} +]""" + + try: + parsed = json.loads(test_json) + print(f" ✓ Expected JSON format is valid") + print(f" ✓ Parsed {len(parsed)} seeds") + except json.JSONDecodeError as e: + print(f" ✗ JSON parsing failed: {e}") + + print("\n" + "="*80) + print("✅ Prompt Construction Test Completed") + print("="*80) + + +if __name__ == "__main__": + try: + test_prompt_construction() + except Exception as e: + print(f"\n❌ Test failed: {e}") + import traceback + traceback.print_exc() diff --git a/backend/tests/test_seed_discovery_resume.py b/backend/tests/test_seed_discovery_resume.py new file mode 100644 index 00000000..c042dd18 --- /dev/null +++ b/backend/tests/test_seed_discovery_resume.py @@ -0,0 +1,174 @@ +"""Test seed discovery with top-k selection using existing output.""" +import json +import sys +from pathlib import Path + +# Add backend to path +backend_root = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(backend_root)) + +from src.modules.idea_gen.config import get_seed_config +from src.modules.idea_gen.schemas import IdeaGenInput, IdeaGenState + + +def load_existing_state(): + """Load state from previous test run.""" + output_dir = backend_root.parent / "output" / "idea_gen_seed_hypothesis_e2e" + + # Load compressed papers from node_supervisor_iter1_state.json + state_file = output_dir / "node_supervisor_iter1_state.json" + if not state_file.exists(): + raise FileNotFoundError(f"State file not found: {state_file}") + + with open(state_file, 'r', encoding='utf-8') as f: + state_data = json.load(f) + + # Load compressed papers from temp_data + compressed_papers = state_data.get("temp_data", {}).get("compressed_papers", []) + research_brief = state_data.get("research_brief", "") + notes = state_data.get("notes", "") + + print(f"Loaded state from: {state_file}") + print(f" - Compressed papers: {len(compressed_papers)}") + print(f" - Research brief length: {len(research_brief)} chars") + print(f" - Notes length: {len(notes)} chars") + + return compressed_papers, research_brief, notes + + +def test_top_k_selection(): + """Test the top-k paper selection logic.""" + + print("="*80) + print("Testing Top-K Paper Selection for Seed Discovery") + print("="*80) + + # Load existing data + compressed_papers, research_brief, notes = load_existing_state() + + if not compressed_papers: + print("\n❌ No compressed papers found in state!") + print(" Using notes as fallback (original behavior)") + return + + # Get config + seed_config = get_seed_config() + max_papers = seed_config.max_papers_for_seed_discovery + max_chars_per_paper = seed_config.max_summary_chars_per_paper + + print(f"\n📋 Configuration:") + print(f" - max_papers_for_seed_discovery: {max_papers}") + print(f" - max_summary_chars_per_paper: {max_chars_per_paper}") + print(f" - Expected max input: {max_papers * max_chars_per_paper:,} chars") + + # Selection logic (from workflow.py) + def _select_top_papers_for_seed_discovery(papers, max_papers): + if not papers: + return [] + + sorted_papers = sorted( + papers, + key=lambda p: ( + -float(p.get("relevance_score", 0.0) or 0.0), + int(p.get("final_selection_rank") or 999), + str(p.get("title", "") or "").lower(), + ) + ) + return sorted_papers[:max_papers] + + def _build_focused_paper_summaries(selected_papers, max_chars_per_paper): + summaries = [] + for idx, paper in enumerate(selected_papers, 1): + title = paper.get("title", "Unknown") + core_summary = paper.get("core_summary", "") + technical_depth = paper.get("technical_depth", "") + empirical_support = paper.get("empirical_support", "") + + paper_text = f"## Paper {idx}: {title}\n\n" + + if core_summary: + truncated_core = core_summary[:max_chars_per_paper // 3] + paper_text += f"### 核心摘要\n{truncated_core}\n\n" + + if technical_depth: + truncated_tech = technical_depth[:max_chars_per_paper // 3] + paper_text += f"### 技术细节\n{truncated_tech}\n\n" + + if empirical_support: + truncated_emp = empirical_support[:max_chars_per_paper // 3] + paper_text += f"### 实验验证\n{truncated_emp}\n\n" + + summaries.append(paper_text) + + return "\n".join(summaries) + + # Select top-k papers + selected_papers = _select_top_papers_for_seed_discovery(compressed_papers, max_papers) + + print(f"\n📊 Paper Selection Results:") + print(f" - Available papers: {len(compressed_papers)}") + print(f" - Selected papers: {len(selected_papers)}") + print(f" - Selection ratio: {len(selected_papers)}/{len(compressed_papers)} ({len(selected_papers)/len(compressed_papers)*100:.1f}%)") + + print(f"\n🎯 Top-{len(selected_papers)} Selected Papers:") + for i, paper in enumerate(selected_papers, 1): + title = paper.get("title", "Unknown")[:60] + score = paper.get("relevance_score", 0.0) + rank = paper.get("final_selection_rank", "?") + print(f" {i}. [{rank}] {title}... (score={score:.2f})") + + # Build summaries + paper_summaries = _build_focused_paper_summaries(selected_papers, max_chars_per_paper) + + print(f"\n📝 Summary Statistics:") + print(f" - Original notes length: {len(notes):,} chars") + print(f" - New summaries length: {len(paper_summaries):,} chars") + print(f" - Reduction: {(1 - len(paper_summaries)/len(notes))*100:.1f}%") + print(f" - Expected max: {max_papers * max_chars_per_paper:,} chars") + print(f" - Actual: {len(paper_summaries):,} chars") + + # Calculate per-paper stats + avg_chars_per_paper = len(paper_summaries) // len(selected_papers) if selected_papers else 0 + print(f" - Avg chars per paper: {avg_chars_per_paper:,} chars") + + # Verify structure + print(f"\n✅ Verification:") + has_structure = all([ + "## Paper" in paper_summaries, + "### 核心摘要" in paper_summaries, + "### 技术细节" in paper_summaries, + "### 实验验证" in paper_summaries, + ]) + print(f" - Has proper structure: {has_structure}") + print(f" - Within char limit: {len(paper_summaries) <= max_papers * max_chars_per_paper * 1.1}") # 10% tolerance + + # Save output for inspection + output_dir = backend_root.parent / "output" / "idea_gen_seed_hypothesis_e2e" + output_file = output_dir / "seed_discovery_topk_test_output.md" + + with open(output_file, 'w', encoding='utf-8') as f: + f.write(f"# Top-K Paper Selection Test Output\n\n") + f.write(f"## Configuration\n") + f.write(f"- max_papers: {max_papers}\n") + f.write(f"- max_chars_per_paper: {max_chars_per_paper}\n\n") + f.write(f"## Selected Papers\n") + for i, paper in enumerate(selected_papers, 1): + f.write(f"{i}. {paper.get('title', 'Unknown')} (score={paper.get('relevance_score', 0.0):.2f})\n") + f.write(f"\n## Paper Summaries\n\n") + f.write(paper_summaries) + + print(f"\n💾 Output saved to: {output_file}") + + print("\n" + "="*80) + print("✅ Top-K Selection Test Completed Successfully!") + print("="*80) + + +if __name__ == "__main__": + try: + test_top_k_selection() + except Exception as e: + print(f"\n❌ Test failed: {e}") + import traceback + traceback.print_exc() + sys.exit(1) diff --git a/backend/tests/test_seed_discovery_topk.py b/backend/tests/test_seed_discovery_topk.py new file mode 100644 index 00000000..65bb3b90 --- /dev/null +++ b/backend/tests/test_seed_discovery_topk.py @@ -0,0 +1,150 @@ +"""Test top-k paper selection for seed discovery.""" + + +def test_select_top_papers_logic(): + """Test the paper selection logic without full workflow dependencies.""" + + # Mock compressed papers with different scores + compressed_papers = [ + { + "paper_id": "paper_1", + "title": "Paper A", + "relevance_score": 9.0, + "final_selection_rank": 1, + "core_summary": "A" * 1000, + "technical_depth": "B" * 1000, + "empirical_support": "C" * 1000, + }, + { + "paper_id": "paper_2", + "title": "Paper B", + "relevance_score": 8.5, + "final_selection_rank": 2, + "core_summary": "D" * 1000, + "technical_depth": "E" * 1000, + "empirical_support": "F" * 1000, + }, + { + "paper_id": "paper_3", + "title": "Paper C", + "relevance_score": 8.0, + "final_selection_rank": 3, + "core_summary": "G" * 1000, + "technical_depth": "H" * 1000, + "empirical_support": "I" * 1000, + }, + { + "paper_id": "paper_4", + "title": "Paper D", + "relevance_score": 7.5, + "final_selection_rank": 4, + "core_summary": "J" * 1000, + "technical_depth": "K" * 1000, + "empirical_support": "L" * 1000, + }, + { + "paper_id": "paper_5", + "title": "Paper E", + "relevance_score": 7.0, + "final_selection_rank": 5, + "core_summary": "M" * 1000, + "technical_depth": "N" * 1000, + "empirical_support": "O" * 1000, + }, + ] + + # Selection logic (copied from workflow.py) + def _select_top_papers_for_seed_discovery(papers, max_papers): + if not papers: + return [] + + sorted_papers = sorted( + papers, + key=lambda p: ( + -float(p.get("relevance_score", 0.0) or 0.0), + int(p.get("final_selection_rank") or 999), + str(p.get("title", "") or "").lower(), + ) + ) + return sorted_papers[:max_papers] + + # Build summaries logic + def _build_focused_paper_summaries(selected_papers, max_chars_per_paper): + summaries = [] + for idx, paper in enumerate(selected_papers, 1): + title = paper.get("title", "Unknown") + core_summary = paper.get("core_summary", "") + technical_depth = paper.get("technical_depth", "") + empirical_support = paper.get("empirical_support", "") + + paper_text = f"## Paper {idx}: {title}\n\n" + + if core_summary: + truncated_core = core_summary[:max_chars_per_paper // 3] + paper_text += f"### 核心摘要\n{truncated_core}\n\n" + + if technical_depth: + truncated_tech = technical_depth[:max_chars_per_paper // 3] + paper_text += f"### 技术细节\n{truncated_tech}\n\n" + + if empirical_support: + truncated_emp = empirical_support[:max_chars_per_paper // 3] + paper_text += f"### 实验验证\n{truncated_emp}\n\n" + + summaries.append(paper_text) + + return "\n".join(summaries) + + # Test 1: Select top 3 papers + max_papers = 3 + selected = _select_top_papers_for_seed_discovery(compressed_papers, max_papers) + + assert len(selected) == 3, f"Expected 3 papers, got {len(selected)}" + assert selected[0]["paper_id"] == "paper_1", "First paper should be paper_1" + assert selected[1]["paper_id"] == "paper_2", "Second paper should be paper_2" + assert selected[2]["paper_id"] == "paper_3", "Third paper should be paper_3" + + print(f"✓ Test 1 passed: Selected top {len(selected)} papers") + for i, p in enumerate(selected, 1): + print(f" {i}. {p['paper_id']} (score={p['relevance_score']}, rank={p['final_selection_rank']})") + + # Test 2: Build summaries with char limits + max_chars_per_paper = 3000 + summaries = _build_focused_paper_summaries(selected, max_chars_per_paper) + + print(f"\n✓ Test 2 passed: Built summaries with {len(summaries)} chars") + print(f" Max allowed: {max_papers * max_chars_per_paper} chars") + print(f" Actual: {len(summaries)} chars") + + # Verify each paper section is truncated + assert "## Paper 1: Paper A" in summaries + assert "## Paper 2: Paper B" in summaries + assert "## Paper 3: Paper C" in summaries + assert "### 核心摘要" in summaries + assert "### 技术细节" in summaries + assert "### 实验验证" in summaries + + # Test 3: Verify truncation works + # Each section should be truncated to max_chars_per_paper // 3 = 1000 chars + # Original was 1000 chars, so should be preserved + assert summaries.count("A" * 1000) == 1, "Core summary should be truncated to 1000 chars" + + print("\n✓ Test 3 passed: Truncation works correctly") + + # Test 4: Edge case - request more papers than available + selected_all = _select_top_papers_for_seed_discovery(compressed_papers, 10) + assert len(selected_all) == 5, f"Should return all 5 papers, got {len(selected_all)}" + print(f"\n✓ Test 4 passed: Handles request for more papers than available") + + # Test 5: Edge case - empty list + selected_empty = _select_top_papers_for_seed_discovery([], 3) + assert len(selected_empty) == 0, "Should return empty list" + print(f"✓ Test 5 passed: Handles empty input") + + print("\n" + "="*60) + print("All tests passed! ✓") + print("="*60) + + +if __name__ == "__main__": + test_select_top_papers_logic() diff --git a/backend/tests/test_seed_discovery_topk_standalone.py b/backend/tests/test_seed_discovery_topk_standalone.py new file mode 100644 index 00000000..d286c3b2 --- /dev/null +++ b/backend/tests/test_seed_discovery_topk_standalone.py @@ -0,0 +1,158 @@ +"""Test seed discovery with top-k selection using existing output - standalone version.""" +import json +from pathlib import Path + + +def test_top_k_selection(): + """Test the top-k paper selection logic.""" + + print("="*80) + print("Testing Top-K Paper Selection for Seed Discovery") + print("="*80) + + # Load existing data + output_dir = Path("/mnt/paper2any/pzw/proj/paperagent/pzw/worktrees/idea/output/idea_gen_seed_hypothesis_e2e") + state_file = output_dir / "node_supervisor_iter1_state.json" + + if not state_file.exists(): + print(f"❌ State file not found: {state_file}") + return + + with open(state_file, 'r', encoding='utf-8') as f: + state_data = json.load(f) + + # Load compressed papers from temp_data + compressed_papers = state_data.get("temp_data", {}).get("compressed_papers", []) + research_brief = state_data.get("research_brief", "") + notes = state_data.get("notes", "") + + print(f"\n📂 Loaded state from: {state_file.name}") + print(f" - Compressed papers: {len(compressed_papers)}") + print(f" - Research brief length: {len(research_brief)} chars") + print(f" - Notes length: {len(notes)} chars") + + if not compressed_papers: + print("\n❌ No compressed papers found in state!") + return + + # Config (hardcoded to match config.py) + max_papers = 8 + max_chars_per_paper = 3000 + + print(f"\n📋 Configuration:") + print(f" - max_papers_for_seed_discovery: {max_papers}") + print(f" - max_summary_chars_per_paper: {max_chars_per_paper}") + print(f" - Expected max input: {max_papers * max_chars_per_paper:,} chars") + + # Selection logic (from workflow.py) + def _select_top_papers_for_seed_discovery(papers, max_papers): + if not papers: + return [] + + sorted_papers = sorted( + papers, + key=lambda p: ( + -float(p.get("relevance_score", 0.0) or 0.0), + int(p.get("final_selection_rank") or 999), + str(p.get("title", "") or "").lower(), + ) + ) + return sorted_papers[:max_papers] + + def _build_focused_paper_summaries(selected_papers, max_chars_per_paper): + summaries = [] + for idx, paper in enumerate(selected_papers, 1): + title = paper.get("title", "Unknown") + core_summary = paper.get("core_summary", "") + technical_depth = paper.get("technical_depth", "") + empirical_support = paper.get("empirical_support", "") + + paper_text = f"## Paper {idx}: {title}\n\n" + + if core_summary: + truncated_core = core_summary[:max_chars_per_paper // 3] + paper_text += f"### 核心摘要\n{truncated_core}\n\n" + + if technical_depth: + truncated_tech = technical_depth[:max_chars_per_paper // 3] + paper_text += f"### 技术细节\n{truncated_tech}\n\n" + + if empirical_support: + truncated_emp = empirical_support[:max_chars_per_paper // 3] + paper_text += f"### 实验验证\n{truncated_emp}\n\n" + + summaries.append(paper_text) + + return "\n".join(summaries) + + # Select top-k papers + selected_papers = _select_top_papers_for_seed_discovery(compressed_papers, max_papers) + + print(f"\n📊 Paper Selection Results:") + print(f" - Available papers: {len(compressed_papers)}") + print(f" - Selected papers: {len(selected_papers)}") + print(f" - Selection ratio: {len(selected_papers)}/{len(compressed_papers)} ({len(selected_papers)/len(compressed_papers)*100:.1f}%)") + + print(f"\n🎯 Top-{len(selected_papers)} Selected Papers:") + for i, paper in enumerate(selected_papers, 1): + title = paper.get("title", "Unknown")[:60] + score = paper.get("relevance_score", 0.0) + rank = paper.get("final_selection_rank", "?") + print(f" {i}. [#{rank}] {title}... (score={score:.2f})") + + # Build summaries + paper_summaries = _build_focused_paper_summaries(selected_papers, max_chars_per_paper) + + print(f"\n📝 Summary Statistics:") + print(f" - Original notes length: {len(notes):,} chars") + print(f" - New summaries length: {len(paper_summaries):,} chars") + print(f" - Reduction: {(1 - len(paper_summaries)/len(notes))*100:.1f}%") + print(f" - Expected max: {max_papers * max_chars_per_paper:,} chars") + print(f" - Actual: {len(paper_summaries):,} chars") + + # Calculate per-paper stats + avg_chars_per_paper = len(paper_summaries) // len(selected_papers) if selected_papers else 0 + print(f" - Avg chars per paper: {avg_chars_per_paper:,} chars") + + # Verify structure + print(f"\n✅ Verification:") + has_structure = all([ + "## Paper" in paper_summaries, + "### 核心摘要" in paper_summaries, + "### 技术细节" in paper_summaries, + "### 实验验证" in paper_summaries, + ]) + print(f" - Has proper structure: {'✓' if has_structure else '✗'}") + within_limit = len(paper_summaries) <= max_papers * max_chars_per_paper * 1.1 # 10% tolerance + print(f" - Within char limit: {'✓' if within_limit else '✗'}") + + # Save output for inspection + output_file = output_dir / "seed_discovery_topk_test_output.md" + + with open(output_file, 'w', encoding='utf-8') as f: + f.write(f"# Top-K Paper Selection Test Output\n\n") + f.write(f"## Configuration\n") + f.write(f"- max_papers: {max_papers}\n") + f.write(f"- max_chars_per_paper: {max_chars_per_paper}\n\n") + f.write(f"## Selected Papers\n") + for i, paper in enumerate(selected_papers, 1): + f.write(f"{i}. {paper.get('title', 'Unknown')} (score={paper.get('relevance_score', 0.0):.2f})\n") + f.write(f"\n## Paper Summaries\n\n") + f.write(paper_summaries) + + print(f"\n💾 Output saved to: {output_file}") + + print("\n" + "="*80) + print("✅ Top-K Selection Test Completed Successfully!") + print("="*80) + + return selected_papers, paper_summaries + + +if __name__ == "__main__": + try: + test_top_k_selection() + except Exception as e: + print(f"\n❌ Test failed: {e}") + import traceback + traceback.print_exc() diff --git a/backend/tests/test_seed_validation_resume.py b/backend/tests/test_seed_validation_resume.py new file mode 100644 index 00000000..5e0ebd4d --- /dev/null +++ b/backend/tests/test_seed_validation_resume.py @@ -0,0 +1,146 @@ +"""Test seed validation with optimized prompts - resume from existing state.""" +import json +from pathlib import Path + +from src.modules.idea_gen.workflow import ( + IdeaGenState, + IdeaGenInput, + seed_discovery_validate_node, +) +from src.modules.idea_gen.utils import NodeExecutor + + +def test_seed_validation_resume(): + """Resume from existing output and test validation with optimized prompts.""" + + # Load existing state from previous run + backend_dir = Path(__file__).parent.parent + output_dir = backend_dir.parent / "output" / "idea_gen_seed_hypothesis_e2e" + + # Load raw response + raw_response_file = output_dir / "seed_discovery_raw_response.txt" + if not raw_response_file.exists(): + print(f"❌ Raw response file not found: {raw_response_file}") + return False + + raw_response = raw_response_file.read_text(encoding="utf-8") + print(f"[TEST] Loaded raw response: {len(raw_response)} chars") + print(f"[TEST] First 200 chars: {raw_response[:200]}...") + + # Load paper catalog + compress_state_file = output_dir / "node_compress_state.json" + if not compress_state_file.exists(): + print(f"❌ Compress state file not found: {compress_state_file}") + return False + + compress_state = json.loads(compress_state_file.read_text(encoding="utf-8")) + final_papers = compress_state.get("temp", {}).get("final_papers_for_compress", []) + + paper_catalog = [] + for paper in final_papers: + paper_key = paper.get("paper_key", "") + title = paper.get("title", "") + if paper_key and title: + paper_catalog.append({"paper_key": paper_key, "title": title}) + + print(f"[TEST] Loaded {len(paper_catalog)} papers from catalog") + + # Load research brief + brief_state_file = output_dir / "node_brief_state.json" + if not brief_state_file.exists(): + print(f"❌ Brief state file not found: {brief_state_file}") + return False + + brief_state = json.loads(brief_state_file.read_text(encoding="utf-8")) + research_brief = brief_state.get("research_brief", "") + print(f"[TEST] Loaded research brief: {len(research_brief)} chars") + + # Create minimal state for validation + state = IdeaGenState( + input=IdeaGenInput( + target="RAG for long-context LLM agents", + academic_domain="AI/ML", + target_venues=["NIPS", "ICML", "ICLR"], + language="zh", + ), + thread_id="test_resume", + research_brief=research_brief, + ) + + # Set temp data + state.temp_data["seed_discovery_raw_response"] = raw_response + state.temp_data["seed_discovery_paper_catalog"] = paper_catalog + state.temp_data["workflow_date"] = "2026-04-10" + + print("\n" + "="*80) + print("[TEST] Starting validation with optimized prompts...") + print("="*80 + "\n") + + # Run validation + executor = NodeExecutor("seed_discovery_validator") + try: + result_state = seed_discovery_validate_node(state, executor=executor) + + print("\n" + "="*80) + print("[TEST] Validation Results") + print("="*80) + + if result_state.seeds: + print(f"✅ SUCCESS: Generated {len(result_state.seeds)} seeds") + print(f"\nValidation status: {result_state.temp.seed_validation_status or 'unknown'}") + print(f"Validation rounds: {result_state.temp.seed_validation_rounds}") + + print("\n[SEEDS]") + for i, seed in enumerate(result_state.seeds, 1): + print(f"\n{i}. {seed.seed_id}") + print(f" Gap: {seed.target_gap[:100]}...") + print(f" Mechanism: {seed.mechanism[:100]}...") + print(f" Scenario: {seed.scenario[:100]}...") + print(f" Expected Gain: {seed.expected_gain[:100]}...") + print(f" Source Papers: {len(seed.source_paper_keys)} papers") + + # Save results + output_file = output_dir / "test_validation_resume_success.json" + output_file.write_text( + json.dumps( + [seed.model_dump() for seed in result_state.seeds], + ensure_ascii=False, + indent=2 + ), + encoding="utf-8" + ) + print(f"\n✅ Results saved to: {output_file}") + return True + else: + print("❌ FAILED: No seeds generated") + print(f"\nValidation status: {result_state.temp.seed_validation_status or 'unknown'}") + + # Check validation attempts + attempts = result_state.temp_data.get("seed_validation_attempts", []) + if attempts: + print(f"\nValidation attempts: {len(attempts)}") + for i, attempt in enumerate(attempts, 1): + print(f" Attempt {i}: {attempt.get('status', 'unknown')} - {attempt.get('error', '')[:100]}") + + return False + + except Exception as e: + print(f"\n❌ EXCEPTION during validation: {e}") + import traceback + traceback.print_exc() + return False + + +if __name__ == "__main__": + print("="*80) + print("Testing Seed Validation with Optimized Prompts (Resume Mode)") + print("="*80 + "\n") + + success = test_seed_validation_resume() + + print("\n" + "="*80) + if success: + print("✅ TEST PASSED: Validation successful with optimized prompts") + else: + print("❌ TEST FAILED: Validation still failing") + print("="*80) diff --git a/backend/tests/test_structured_output.py b/backend/tests/test_structured_output.py new file mode 100644 index 00000000..3c82bec5 --- /dev/null +++ b/backend/tests/test_structured_output.py @@ -0,0 +1,64 @@ +"""Tests for structured LLM output parsing helpers.""" + +from src.utils.structured_output import parse_structured_output + + +def test_parse_structured_output_extracts_list_from_plain_fence(): + text = """Here is the result: + +``` +[ + {"seed_id": "seed_1", "target_gap": "g1"}, + {"seed_id": "seed_2", "target_gap": "g2"} +] +```""" + + data = parse_structured_output(text, list) + + assert isinstance(data, list) + assert [item["seed_id"] for item in data] == ["seed_1", "seed_2"] + + +def test_parse_structured_output_extracts_dict_from_surrounding_text(): + text = """I will follow the schema. +{ + "action": "GENERATE_SEEDS", + "reflection": "enough evidence gathered", + "tasks": [] +} +Next step is seed generation.""" + + data = parse_structured_output(text, dict) + + assert data["action"] == "GENERATE_SEEDS" + assert data["tasks"] == [] + + +def test_parse_structured_output_prefers_expected_list_over_other_brackets(): + text = """Reasoning summary [draft]: + +```json +[ + {"seed_id": "seed_1"}, + {"seed_id": "seed_2"} +] +```""" + + data = parse_structured_output(text, list) + + assert len(data) == 2 + assert data[0]["seed_id"] == "seed_1" + + +def test_parse_structured_output_unwraps_common_list_wrapper(): + text = """{ + "seeds": [ + {"seed_id": "seed_1"}, + {"seed_id": "seed_2"} + ] +}""" + + data = parse_structured_output(text, list) + + assert isinstance(data, list) + assert data[1]["seed_id"] == "seed_2" diff --git a/backend/tests/test_two_tier_storage.py b/backend/tests/test_two_tier_storage.py index b2d983dc..188bf3ab 100644 --- a/backend/tests/test_two_tier_storage.py +++ b/backend/tests/test_two_tier_storage.py @@ -11,13 +11,14 @@ def test_two_tier_storage_e2e(monkeypatch): import openai from src.modules.idea_gen import workflow as idea_gen_workflow from src.modules.idea_gen.config import PAPER_PROCESSING_CONFIG + from src.modules.idea_gen.utils import workflow_helpers PAPER_PROCESSING_CONFIG.enable_two_tier_storage = True PAPER_PROCESSING_CONFIG.enable_pdf_parsing = True run_dir = Path(__file__).resolve().parents[2] / "output" / "two_tier_storage_test" run_dir.mkdir(parents=True, exist_ok=True) - monkeypatch.setattr(idea_gen_workflow, "_get_output_dir", lambda state: run_dir) + monkeypatch.setattr(workflow_helpers, "_get_output_dir", lambda state: run_dir) workflow = idea_gen_workflow.build_workflow() try: diff --git a/backend/uv.lock b/backend/uv.lock index 1532a1f5..b0df9596 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -600,6 +600,7 @@ dependencies = [ { name = "pydantic" }, { name = "python-multipart" }, { name = "pyyaml" }, + { name = "rank-bm25" }, { name = "rapidfuzz" }, { name = "readabilipy" }, { name = "scikit-learn" }, @@ -641,6 +642,7 @@ requires-dist = [ { name = "pydantic", specifier = ">=2.12.5" }, { name = "python-multipart", specifier = ">=0.0.20" }, { name = "pyyaml", specifier = "==6.0.2" }, + { name = "rank-bm25", specifier = ">=0.2.2" }, { name = "rapidfuzz", specifier = ">=3.5.0" }, { name = "readabilipy", specifier = ">=0.3.0" }, { name = "scikit-learn", specifier = ">=1.7.0" }, @@ -2916,6 +2918,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446, upload-time = "2024-08-06T20:33:04.33Z" }, ] +[[package]] +name = "rank-bm25" +version = "0.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/0a/f9579384aa017d8b4c15613f86954b92a95a93d641cc849182467cf0bb3b/rank_bm25-0.2.2.tar.gz", hash = "sha256:096ccef76f8188563419aaf384a02f0ea459503fdf77901378d4fd9d87e5e51d", size = 8347, upload-time = "2022-02-16T12:10:52.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/21/f691fb2613100a62b3fa91e9988c991e9ca5b89ea31c0d3152a3210344f9/rank_bm25-0.2.2-py3-none-any.whl", hash = "sha256:7bd4a95571adadfc271746fa146a4bcfd89c0cf731e49c3d1ad863290adbe8ae", size = 8584, upload-time = "2022-02-16T12:10:50.626Z" }, +] + [[package]] name = "rapidfuzz" version = "3.14.3" diff --git a/frontend/src/app/workspace/idea-gen/components/idea-gen-form.tsx b/frontend/src/app/workspace/idea-gen/components/idea-gen-form.tsx index d155a82f..ed8f4931 100644 --- a/frontend/src/app/workspace/idea-gen/components/idea-gen-form.tsx +++ b/frontend/src/app/workspace/idea-gen/components/idea-gen-form.tsx @@ -1,8 +1,9 @@ "use client"; -import { ChevronDownIcon, RocketIcon, SparklesIcon } from "lucide-react"; +import { RocketIcon, SparklesIcon } from "lucide-react"; import { useRouter } from "next/navigation"; -import { useMemo, useState } from "react"; +import { motion } from "motion/react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { z } from "zod"; import { Button } from "@/components/ui/button"; @@ -13,7 +14,6 @@ import { CardHeader, CardTitle, } from "@/components/ui/card"; -import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Switch } from "@/components/ui/switch"; @@ -21,11 +21,10 @@ import { Textarea } from "@/components/ui/textarea"; import { useI18n } from "@/core/i18n/hooks"; import { createDefaultIdeaGenRunInput, - getPresetConfig, normalizeIdeaGenRunInput, useCreateIdeaGenRunWithToast, - type IdeaGenPreset, - type IdeaGenRunCreate, + useIdeaGenPresets, + type IdeaGenPresetMode, } from "@/core/idea-gen"; import { cn } from "@/lib/utils"; @@ -34,37 +33,40 @@ const languageOptions = [ { value: "en", label: "English" }, ] as const; -const presetOptions = [ - { value: "light" as const, label: "轻量 Light", desc: "快速探索,3轮迭代" }, - { value: "medium" as const, label: "中等 Medium", desc: "平衡深度,6轮迭代" }, - { value: "complex" as const, label: "复杂 Complex", desc: "深度研究,10轮迭代" }, - { value: "custom" as const, label: "自定义 Custom", desc: "手动配置参数" }, -] as const; - export function IdeaGenForm() { const router = useRouter(); const { t } = useI18n(); const createRun = useCreateIdeaGenRunWithToast(); + const presetsQuery = useIdeaGenPresets(); const [form, setForm] = useState(() => createDefaultIdeaGenRunInput()); const [errors, setErrors] = useState<{ target?: string; target_venues?: string }>({}); - const [showAdvanced, setShowAdvanced] = useState(false); - const [preset, setPreset] = useState<IdeaGenPreset>("medium"); + const presetDefaultsInitializedRef = useRef(false); - function handlePresetChange(newPreset: IdeaGenPreset) { - setPreset(newPreset); - if (newPreset !== "custom") { - const presetConfig = getPresetConfig(newPreset); - setForm((current) => ({ ...current, ...presetConfig })); + useEffect(() => { + if (presetDefaultsInitializedRef.current || !presetsQuery.data?.length) { + return; } - } - function handleAdvancedFieldChange<K extends keyof IdeaGenRunCreate>( - key: K, - value: IdeaGenRunCreate[K] - ) { - setPreset("custom"); - setForm((current) => ({ ...current, [key]: value })); - } + const matchedPreset = presetsQuery.data.find( + (preset) => preset.mode === form.preset_mode, + ); + if (!matchedPreset) { + return; + } + + presetDefaultsInitializedRef.current = true; + setForm((current) => ({ + ...current, + max_iterations: matchedPreset.default_max_iterations, + })); + }, [form.preset_mode, presetsQuery.data]); + + const selectedPreset = useMemo( + () => + presetsQuery.data?.find((preset) => preset.mode === form.preset_mode) ?? + null, + [form.preset_mode, presetsQuery.data], + ); const formSchema = useMemo( () => @@ -75,6 +77,18 @@ export function IdeaGenForm() { [t.ideaGen.validationTarget, t.ideaGen.validationVenues], ); + function handlePresetChange(newPreset: IdeaGenPresetMode) { + const matchedPreset = presetsQuery.data?.find( + (preset) => preset.mode === newPreset, + ); + setForm((current) => ({ + ...current, + preset_mode: newPreset, + max_iterations: + matchedPreset?.default_max_iterations ?? current.max_iterations, + })); + } + async function handleSubmit(event: React.FormEvent<HTMLFormElement>) { event.preventDefault(); const normalized = normalizeIdeaGenRunInput({ @@ -194,23 +208,49 @@ export function IdeaGenForm() { </label> <div className="space-y-2"> - <div className="text-sm font-medium">研究复杂度 Research Complexity</div> - <div className="grid grid-cols-4 gap-2"> - {presetOptions.map((option) => ( - <button - key={option.value} - type="button" - onClick={() => handlePresetChange(option.value)} - className={cn( - "flex flex-col items-start gap-1 rounded-lg border-2 bg-white/70 p-3 text-left transition-all hover:border-emerald-500 hover:bg-white", - preset === option.value && "border-emerald-600 bg-emerald-50 shadow-sm" - )} - > - <div className="text-sm font-medium">{option.label}</div> - <div className="text-xs text-muted-foreground">{option.desc}</div> - </button> - ))} - </div> + <div className="text-sm font-medium">预设 Preset</div> + {presetsQuery.data?.length ? ( + <div className="grid gap-2 md:grid-cols-4"> + {presetsQuery.data.map((option) => ( + <motion.button + key={option.mode} + type="button" + onClick={() => handlePresetChange(option.mode)} + whileHover={{ scale: 1.02 }} + whileTap={{ scale: 0.98 }} + className={cn( + "flex flex-col items-start gap-1 rounded-lg border-2 bg-white/70 p-3 text-left transition-all hover:border-emerald-500 hover:bg-white hover:shadow-sm", + form.preset_mode === option.mode && + "border-emerald-600 bg-emerald-50 shadow-sm ring-2 ring-emerald-100", + )} + > + <div className="text-sm font-medium">{option.label}</div> + <div className="text-xs text-muted-foreground"> + {option.description} + </div> + {form.preset_mode === option.mode && ( + <motion.div + layoutId="preset-indicator" + className="mt-1 h-0.5 w-full rounded-full bg-emerald-600" + transition={{ type: "spring", stiffness: 300, damping: 30 }} + /> + )} + </motion.button> + ))} + </div> + ) : ( + <div className="rounded-lg border border-dashed bg-white/60 px-4 py-3 text-sm text-muted-foreground"> + {presetsQuery.isLoading + ? "正在加载后端 preset 配置..." + : "后端 preset 暂时不可用。"} + </div> + )} + {selectedPreset && ( + <p className="text-xs text-muted-foreground"> + 当前默认最大迭代次数为 {selectedPreset.default_max_iterations}, + 可在下方单独覆盖。 + </p> + )} </div> <div className="grid gap-5 md:grid-cols-[1fr_140px]"> @@ -233,7 +273,9 @@ export function IdeaGenForm() { <label className="flex items-end justify-between gap-3 rounded-xl border bg-white/70 px-4 py-3"> <div className="space-y-1"> - <div className="text-sm font-medium">{t.ideaGen.allowClarificationLabel}</div> + <div className="text-sm font-medium"> + {t.ideaGen.allowClarificationLabel} + </div> </div> <Switch checked={form.allow_clarification} @@ -247,251 +289,35 @@ export function IdeaGenForm() { </label> </div> - <Collapsible open={showAdvanced} onOpenChange={setShowAdvanced}> - <CollapsibleTrigger asChild> - <Button - type="button" - variant="ghost" - className="w-full justify-between text-sm" - > - <span>高级配置 Advanced Config</span> - <ChevronDownIcon - className={cn( - "size-4 transition-transform", - showAdvanced && "rotate-180" - )} - /> - </Button> - </CollapsibleTrigger> - <CollapsibleContent className="space-y-4 pt-4"> - <div className="rounded-lg border bg-muted/30 p-4 space-y-4"> - <div className="text-sm font-medium text-muted-foreground">工作流配置 Workflow Config</div> - - <div className="grid gap-4 md:grid-cols-2"> - <label className="block space-y-2"> - <div className="text-sm">并发研究单元 Concurrent Units</div> - <Input - type="number" - min={1} - max={20} - value={form.max_concurrent_research_units} - onChange={(e) => - handleAdvancedFieldChange("max_concurrent_research_units", Number(e.target.value || 8)) - } - className="bg-white/80" - /> - </label> - - <label className="block space-y-2"> - <div className="text-sm">每主题搜索数 Results/Topic</div> - <Input - type="number" - min={5} - max={50} - value={form.search_results_per_topic} - onChange={(e) => - handleAdvancedFieldChange("search_results_per_topic", Number(e.target.value || 20)) - } - className="bg-white/80" - /> - </label> - - <label className="block space-y-2"> - <div className="text-sm">Critic 最大迭代 Max Critic Iterations</div> - <Input - type="number" - min={1} - max={5} - value={form.max_critic_iterations} - onChange={(e) => - handleAdvancedFieldChange("max_critic_iterations", Number(e.target.value || 2)) - } - className="bg-white/80" - /> - </label> - - <label className="block space-y-2"> - <div className="text-sm">质量阈值 Quality Threshold</div> - <Input - type="number" - min={0} - max={10} - step={0.1} - value={form.critic_quality_threshold} - onChange={(e) => - handleAdvancedFieldChange("critic_quality_threshold", Number(e.target.value || 7.5)) - } - className="bg-white/80" - /> - </label> - </div> - </div> - - <div className="rounded-lg border bg-muted/30 p-4 space-y-4"> - <div className="text-sm font-medium text-muted-foreground">论文处理配置 Paper Config</div> - - <div className="grid gap-4 md:grid-cols-2"> - <label className="block space-y-2"> - <div className="text-sm">评分阈值 Score Threshold</div> - <Input - type="number" - min={0} - max={10} - step={0.1} - value={form.score_threshold} - onChange={(e) => - handleAdvancedFieldChange("score_threshold", Number(e.target.value || 7.0)) - } - className="bg-white/80" - /> - </label> - - <label className="block space-y-2"> - <div className="text-sm">Top K 论文 Top K Papers</div> - <Input - type="number" - min={1} - max={10} - value={form.top_k_papers} - onChange={(e) => - handleAdvancedFieldChange("top_k_papers", Number(e.target.value || 3)) - } - className="bg-white/80" - /> - </label> - </div> - - <div className="grid gap-3 md:grid-cols-2"> - <label className="flex items-center justify-between gap-3 rounded-lg border bg-white/70 px-3 py-2"> - <div className="text-sm">启用评分 Enable Scoring</div> - <Switch - checked={form.enable_scoring} - onCheckedChange={(checked) => - handleAdvancedFieldChange("enable_scoring", checked) - } - /> - </label> - - <label className="flex items-center justify-between gap-3 rounded-lg border bg-white/70 px-3 py-2"> - <div className="text-sm">PDF 解析 PDF Parsing</div> - <Switch - checked={form.enable_pdf_parsing} - onCheckedChange={(checked) => - handleAdvancedFieldChange("enable_pdf_parsing", checked) - } - /> - </label> - - <label className="flex items-center justify-between gap-3 rounded-lg border bg-white/70 px-3 py-2"> - <div className="text-sm">启用摘要 Summarization</div> - <Switch - checked={form.enable_summarization} - onCheckedChange={(checked) => - handleAdvancedFieldChange("enable_summarization", checked) - } - /> - </label> - - <label className="flex items-center justify-between gap-3 rounded-lg border bg-white/70 px-3 py-2"> - <div className="text-sm">论文缓存 Paper Cache</div> - <Switch - checked={form.enable_paper_cache} - onCheckedChange={(checked) => - handleAdvancedFieldChange("enable_paper_cache", checked) - } - /> - </label> - </div> - </div> - - <div className="rounded-lg border bg-muted/30 p-4 space-y-4"> - <div className="text-sm font-medium text-muted-foreground">模型配置 Model Config</div> - - <div className="grid gap-4 md:grid-cols-2"> - <label className="block space-y-2"> - <div className="text-sm">Clarify 模型</div> - <Input - value={form.model_clarify} - onChange={(e) => - handleAdvancedFieldChange("model_clarify", e.target.value) - } - placeholder="gpt-4o-mini" - className="bg-white/80" - /> - </label> - - <label className="block space-y-2"> - <div className="text-sm">Brief 模型</div> - <Input - value={form.model_brief} - onChange={(e) => - handleAdvancedFieldChange("model_brief", e.target.value) - } - placeholder="gpt-4o-mini" - className="bg-white/80" - /> - </label> - - <label className="block space-y-2"> - <div className="text-sm">Query Generation 模型</div> - <Input - value={form.model_query_generation} - onChange={(e) => - handleAdvancedFieldChange("model_query_generation", e.target.value) - } - placeholder="gpt-4o-mini" - className="bg-white/80" - /> - </label> - - <label className="block space-y-2"> - <div className="text-sm">Supervisor 模型</div> - <Input - value={form.model_supervisor} - onChange={(e) => - handleAdvancedFieldChange("model_supervisor", e.target.value) - } - placeholder="gpt-4o-mini" - className="bg-white/80" - /> - </label> - - <label className="block space-y-2"> - <div className="text-sm">Compress 模型</div> - <Input - value={form.model_compress} - onChange={(e) => - handleAdvancedFieldChange("model_compress", e.target.value) - } - placeholder="gpt-4o-mini" - className="bg-white/80" - /> - </label> - - <label className="block space-y-2"> - <div className="text-sm">Report 模型</div> - <Input - value={form.model_report} - onChange={(e) => - handleAdvancedFieldChange("model_report", e.target.value) - } - placeholder="gpt-4o-mini" - className="bg-white/80" - /> - </label> - </div> - </div> - </CollapsibleContent> - </Collapsible> - <Button type="submit" size="lg" - className="w-full justify-between bg-gradient-to-r from-emerald-600 to-teal-600 text-base font-semibold shadow-lg hover:from-emerald-700 hover:to-teal-700 hover:shadow-xl" - disabled={createRun.isPending} + className="group relative w-full justify-between overflow-hidden bg-gradient-to-r from-emerald-600 to-teal-600 text-base font-semibold shadow-lg transition-all hover:from-emerald-700 hover:to-teal-700 hover:shadow-xl" + disabled={createRun.isPending || presetsQuery.isLoading} > - <span>开始研究 Start Research</span> - <RocketIcon className="size-5" /> + <motion.div + className="absolute inset-0 bg-gradient-to-r from-white/0 via-white/20 to-white/0" + initial={{ x: "-100%" }} + animate={{ x: createRun.isPending ? ["100%", "-100%"] : "100%" }} + transition={{ + repeat: createRun.isPending ? Infinity : 0, + duration: 1.5, + ease: "linear", + }} + /> + <span className="relative"> + {createRun.isPending ? "启动中..." : "开始研究 Start Research"} + </span> + <motion.div + animate={{ rotate: createRun.isPending ? 360 : 0 }} + transition={{ + repeat: createRun.isPending ? Infinity : 0, + duration: 1, + ease: "linear", + }} + > + <RocketIcon className="relative size-5" /> + </motion.div> </Button> </form> </CardContent> diff --git a/frontend/src/app/workspace/idea-gen/components/idea-gen-list.tsx b/frontend/src/app/workspace/idea-gen/components/idea-gen-list.tsx index b98af44a..51fdf7be 100644 --- a/frontend/src/app/workspace/idea-gen/components/idea-gen-list.tsx +++ b/frontend/src/app/workspace/idea-gen/components/idea-gen-list.tsx @@ -2,7 +2,8 @@ import { ArrowUpRightIcon, SearchIcon } from "lucide-react"; import Link from "next/link"; -import { useEffect, useMemo, useState } from "react"; +import { motion } from "motion/react"; +import { useEffect, useMemo, useState, memo } from "react"; import { Button } from "@/components/ui/button"; import { @@ -25,6 +26,66 @@ import { IdeaGenStatusBadge } from "./idea-gen-status-badge"; type RunTimeFilter = "all" | "24h" | "7d" | "30d"; type PageSize = 6 | 12 | 24; +const RunCard = memo(({ run, index }: { run: IdeaGenRun; index: number }) => { + const { t } = useI18n(); + + return ( + <motion.div + initial={{ opacity: 0, y: 20 }} + animate={{ opacity: 1, y: 0 }} + transition={{ delay: index * 0.05, duration: 0.3 }} + > + <Link href={`/workspace/idea-gen/${run.run_id}`}> + <Card className="group gap-0 rounded-2xl border-white/70 bg-white/75 transition-all hover:-translate-y-1 hover:border-white hover:bg-white hover:shadow-lg"> + <CardHeader className="gap-3"> + <div className="flex flex-wrap items-start justify-between gap-3"> + <div className="space-y-2"> + <CardTitle className="text-base leading-6 transition-colors group-hover:text-emerald-700"> + {run.input.target} + </CardTitle> + <CardDescription className="text-xs"> + {t.ideaGen.runId}: {run.run_id} + </CardDescription> + </div> + <div className="flex items-center gap-2"> + <IdeaGenStatusBadge status={run.status} /> + <motion.div + whileHover={{ scale: 1.1, rotate: 45 }} + transition={{ type: "spring", stiffness: 300 }} + className="rounded-full border bg-white/80 p-2 transition-colors group-hover:border-emerald-200 group-hover:bg-emerald-50" + > + <ArrowUpRightIcon className="size-4 transition-colors group-hover:text-emerald-700" /> + </motion.div> + </div> + </div> + </CardHeader> + <CardContent className="grid gap-3 border-t pt-4 text-sm md:grid-cols-3"> + <div> + <div className="text-muted-foreground">{t.ideaGen.currentNode}</div> + <div className="mt-1 font-medium"> + {getIdeaGenNodeLabel(t.ideaGen, run.current_node) || "—"} + </div> + </div> + <div> + <div className="text-muted-foreground">{t.ideaGen.updatedAt}</div> + <div className="mt-1 font-medium"> + {formatTimeAgo(run.updated_at)} + </div> + </div> + <div> + <div className="text-muted-foreground">{t.ideaGen.papers}</div> + <div className="mt-1 font-medium"> + {run.papers_count} / {t.ideaGen.searchResults} {run.search_results_count} + </div> + </div> + </CardContent> + </Card> + </Link> + </motion.div> + ); +}); +RunCard.displayName = "RunCard"; + export function IdeaGenList({ runs, isLoading, @@ -80,14 +141,18 @@ export function IdeaGenList({ return ( <div className="space-y-4"> - <div className="flex flex-col gap-3 rounded-2xl border bg-white/70 p-4 backdrop-blur"> + <motion.div + initial={{ opacity: 0, y: -10 }} + animate={{ opacity: 1, y: 0 }} + className="flex flex-col gap-3 rounded-2xl border bg-white/70 p-4 shadow-sm backdrop-blur" + > <div className="relative flex-1"> <SearchIcon className="text-muted-foreground absolute top-1/2 left-3 size-4 -translate-y-1/2" /> <Input value={search} onChange={(event) => setSearch(event.target.value)} placeholder={t.ideaGen.searchRuns} - className="bg-background pl-9" + className="bg-background pl-9 transition-shadow focus-visible:shadow-sm" /> </div> <div className="grid gap-3 md:grid-cols-3"> @@ -140,73 +205,48 @@ export function IdeaGenList({ </SelectContent> </Select> </div> - <div className="text-muted-foreground flex flex-wrap items-center gap-3 text-sm"> + <motion.div + key={`${filteredRuns.length}-${page}-${totalPages}`} + initial={{ opacity: 0 }} + animate={{ opacity: 1 }} + className="text-muted-foreground flex flex-wrap items-center gap-3 text-sm" + > <span>{t.ideaGen.resultCount(filteredRuns.length)}</span> <span>{t.ideaGen.pageSummary(page, totalPages)}</span> - </div> - </div> + </motion.div> + </motion.div> {filteredRuns.length === 0 && !isLoading ? ( - <Empty className="min-h-72 rounded-2xl border bg-white/70"> - <EmptyHeader> - <EmptyMedia variant="icon"> - <SearchIcon /> - </EmptyMedia> - <EmptyTitle>{t.ideaGen.emptyTitle}</EmptyTitle> - <EmptyDescription>{t.ideaGen.emptyDescription}</EmptyDescription> - </EmptyHeader> - </Empty> + <motion.div + initial={{ opacity: 0, scale: 0.95 }} + animate={{ opacity: 1, scale: 1 }} + transition={{ duration: 0.3 }} + > + <Empty className="min-h-72 rounded-2xl border bg-white/70 shadow-sm"> + <EmptyHeader> + <EmptyMedia variant="icon"> + <SearchIcon /> + </EmptyMedia> + <EmptyTitle>{t.ideaGen.emptyTitle}</EmptyTitle> + <EmptyDescription>{t.ideaGen.emptyDescription}</EmptyDescription> + </EmptyHeader> + </Empty> + </motion.div> ) : ( <div className="space-y-4"> <div className="grid gap-4"> - {paginatedRuns.map((run) => ( - <Link key={run.run_id} href={`/workspace/idea-gen/${run.run_id}`}> - <Card className="gap-0 rounded-2xl border-white/70 bg-white/75 transition hover:-translate-y-0.5 hover:shadow-lg"> - <CardHeader className="gap-3"> - <div className="flex flex-wrap items-start justify-between gap-3"> - <div className="space-y-2"> - <CardTitle className="text-base leading-6"> - {run.input.target} - </CardTitle> - <CardDescription className="text-xs"> - {t.ideaGen.runId}: {run.run_id} - </CardDescription> - </div> - <div className="flex items-center gap-2"> - <IdeaGenStatusBadge status={run.status} /> - <div className="rounded-full border bg-white/80 p-2"> - <ArrowUpRightIcon className="size-4" /> - </div> - </div> - </div> - </CardHeader> - <CardContent className="grid gap-3 border-t pt-4 text-sm md:grid-cols-3"> - <div> - <div className="text-muted-foreground">{t.ideaGen.currentNode}</div> - <div className="mt-1 font-medium"> - {getIdeaGenNodeLabel(t.ideaGen, run.current_node) || "—"} - </div> - </div> - <div> - <div className="text-muted-foreground">{t.ideaGen.updatedAt}</div> - <div className="mt-1 font-medium"> - {formatTimeAgo(run.updated_at)} - </div> - </div> - <div> - <div className="text-muted-foreground">{t.ideaGen.papers}</div> - <div className="mt-1 font-medium"> - {run.papers_count} / {t.ideaGen.searchResults} {run.search_results_count} - </div> - </div> - </CardContent> - </Card> - </Link> + {paginatedRuns.map((run, index) => ( + <RunCard key={run.run_id} run={run} index={index} /> ))} </div> {filteredRuns.length > 0 && ( - <div className="flex flex-col gap-3 rounded-2xl border bg-white/70 p-4 md:flex-row md:items-center md:justify-between"> + <motion.div + initial={{ opacity: 0 }} + animate={{ opacity: 1 }} + transition={{ delay: 0.2 }} + className="flex flex-col gap-3 rounded-2xl border bg-white/70 p-4 shadow-sm md:flex-row md:items-center md:justify-between" + > <div className="text-muted-foreground text-sm"> {t.ideaGen.pageSummary(page, totalPages)} </div> @@ -215,6 +255,7 @@ export function IdeaGenList({ variant="outline" onClick={() => setPage((current) => Math.max(1, current - 1))} disabled={page <= 1} + className="transition-all hover:scale-105" > {t.ideaGen.previousPage} </Button> @@ -224,11 +265,12 @@ export function IdeaGenList({ setPage((current) => Math.min(totalPages, current + 1)) } disabled={page >= totalPages} + className="transition-all hover:scale-105" > {t.ideaGen.nextPage} </Button> </div> - </div> + </motion.div> )} </div> )} diff --git a/frontend/src/app/workspace/idea-gen/components/idea-gen-progress-card.tsx b/frontend/src/app/workspace/idea-gen/components/idea-gen-progress-card.tsx index 27a2d56b..b817e9f5 100644 --- a/frontend/src/app/workspace/idea-gen/components/idea-gen-progress-card.tsx +++ b/frontend/src/app/workspace/idea-gen/components/idea-gen-progress-card.tsx @@ -1,10 +1,14 @@ "use client"; +import { motion } from "motion/react"; +import { memo, useEffect, useState } from "react"; + import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Progress } from "@/components/ui/progress"; import { useI18n } from "@/core/i18n/hooks"; import type { IdeaGenRun } from "@/core/idea-gen"; import { getIdeaGenNodeLabel } from "@/core/idea-gen"; +import { cn } from "@/lib/utils"; import { IdeaGenStatusBadge } from "./idea-gen-status-badge"; @@ -15,7 +19,68 @@ function statValueLabel(value: number, total?: number) { return String(value); } -export function IdeaGenProgressCard({ run }: { run: IdeaGenRun }) { +const AnimatedProgress = memo(({ value, label }: { value: number; label: string }) => { + const [displayValue, setDisplayValue] = useState(0); + + useEffect(() => { + const timer = setTimeout(() => setDisplayValue(value), 50); + return () => clearTimeout(timer); + }, [value]); + + return ( + <div className="space-y-2"> + <div className="flex items-center justify-between text-sm"> + <span>{label}</span> + <motion.span + key={value} + initial={{ scale: 1.2, opacity: 0 }} + animate={{ scale: 1, opacity: 1 }} + className="font-medium tabular-nums" + > + {Math.round(displayValue)}% + </motion.span> + </div> + <div className="relative overflow-hidden rounded-full bg-zinc-100"> + <motion.div + initial={{ width: 0 }} + animate={{ width: `${displayValue}%` }} + transition={{ duration: 0.8, ease: "easeOut" }} + className={cn( + "h-2 rounded-full bg-gradient-to-r", + displayValue >= 100 + ? "from-emerald-500 to-teal-500" + : "from-blue-500 to-cyan-500" + )} + /> + </div> + </div> + ); +}); +AnimatedProgress.displayName = "AnimatedProgress"; + +const StatCard = memo( + ({ label, value, delay = 0 }: { label: string; value: string | number; delay?: number }) => ( + <motion.div + initial={{ opacity: 0, y: 10 }} + animate={{ opacity: 1, y: 0 }} + transition={{ delay, duration: 0.3 }} + className="group rounded-xl border bg-white/70 p-3 transition-all hover:bg-white hover:shadow-sm" + > + <div className="text-muted-foreground text-sm">{label}</div> + <motion.div + key={value} + initial={{ scale: 1.1, opacity: 0 }} + animate={{ scale: 1, opacity: 1 }} + className="mt-1 font-medium tabular-nums" + > + {value} + </motion.div> + </motion.div> + ) +); +StatCard.displayName = "StatCard"; + +export const IdeaGenProgressCard = memo(({ run }: { run: IdeaGenRun }) => { const { t } = useI18n(); const iterationProgress = Math.min( 100, @@ -27,64 +92,47 @@ export function IdeaGenProgressCard({ run }: { run: IdeaGenRun }) { ); return ( - <Card className="rounded-2xl border-white/70 bg-white/80"> + <Card className="overflow-hidden rounded-2xl border-white/70 bg-white/80 shadow-sm"> <CardHeader className="gap-3"> <div className="flex items-center justify-between gap-3"> - <CardTitle>{t.ideaGen.progressTitle}</CardTitle> + <CardTitle className="text-lg">{t.ideaGen.progressTitle}</CardTitle> <IdeaGenStatusBadge status={run.status} /> </div> </CardHeader> <CardContent className="space-y-5"> - <div className="space-y-2"> - <div className="flex items-center justify-between text-sm"> - <span>{t.ideaGen.iterationProgress}</span> - <span className="font-medium"> - {statValueLabel(run.iteration_count, run.max_iterations)} - </span> - </div> - <Progress value={iterationProgress} /> - </div> + <AnimatedProgress + value={iterationProgress} + label={`${t.ideaGen.iterationProgress} (${statValueLabel(run.iteration_count, run.max_iterations)})`} + /> - <div className="space-y-2"> - <div className="flex items-center justify-between text-sm"> - <span>{t.ideaGen.criticIterations}</span> - <span className="font-medium"> - {statValueLabel( - run.critic_iteration_count, - run.max_critic_iterations, - )} - </span> - </div> - <Progress value={criticProgress} /> - </div> + <AnimatedProgress + value={criticProgress} + label={`${t.ideaGen.criticIterations} (${statValueLabel(run.critic_iteration_count, run.max_critic_iterations)})`} + /> <div className="grid gap-3 text-sm sm:grid-cols-2"> - <div className="rounded-xl border bg-white/70 p-3"> - <div className="text-muted-foreground">{t.ideaGen.currentNode}</div> - <div className="mt-1 font-medium"> - {getIdeaGenNodeLabel(t.ideaGen, run.current_node) || "—"} - </div> - </div> - <div className="rounded-xl border bg-white/70 p-3"> - <div className="text-muted-foreground">{t.ideaGen.researchUnits}</div> - <div className="mt-1 font-medium">{run.unit_count}</div> - </div> - <div className="rounded-xl border bg-white/70 p-3"> - <div className="text-muted-foreground">{t.ideaGen.papers}</div> - <div className="mt-1 font-medium">{run.papers_count}</div> - </div> - <div className="rounded-xl border bg-white/70 p-3"> - <div className="text-muted-foreground"> - {t.ideaGen.compressedPapers} - </div> - <div className="mt-1 font-medium">{run.compressed_papers_count}</div> - </div> - <div className="rounded-xl border bg-white/70 p-3 sm:col-span-2"> - <div className="text-muted-foreground">{t.ideaGen.searchResults}</div> - <div className="mt-1 font-medium">{run.search_results_count}</div> + <StatCard + label={t.ideaGen.currentNode} + value={getIdeaGenNodeLabel(t.ideaGen, run.current_node) || "—"} + delay={0} + /> + <StatCard label={t.ideaGen.researchUnits} value={run.unit_count} delay={0.05} /> + <StatCard label={t.ideaGen.papers} value={run.papers_count} delay={0.1} /> + <StatCard + label={t.ideaGen.compressedPapers} + value={run.compressed_papers_count} + delay={0.15} + /> + <div className="sm:col-span-2"> + <StatCard + label={t.ideaGen.searchResults} + value={run.search_results_count} + delay={0.2} + /> </div> </div> </CardContent> </Card> ); -} +}); +IdeaGenProgressCard.displayName = "IdeaGenProgressCard"; diff --git a/frontend/src/app/workspace/idea-gen/components/idea-gen-status-badge.tsx b/frontend/src/app/workspace/idea-gen/components/idea-gen-status-badge.tsx index a379aeca..e799a50c 100644 --- a/frontend/src/app/workspace/idea-gen/components/idea-gen-status-badge.tsx +++ b/frontend/src/app/workspace/idea-gen/components/idea-gen-status-badge.tsx @@ -1,5 +1,8 @@ "use client"; +import { motion } from "motion/react"; +import { memo } from "react"; + import { Badge } from "@/components/ui/badge"; import { useI18n } from "@/core/i18n/hooks"; import type { IdeaGenRunStatus } from "@/core/idea-gen"; @@ -7,28 +10,59 @@ import { cn } from "@/lib/utils"; const statusClassName: Record<IdeaGenRunStatus, string> = { pending: "border-slate-300 bg-slate-100 text-slate-700", - running: "border-blue-200 bg-blue-100 text-blue-700", - completed: "border-emerald-200 bg-emerald-100 text-emerald-700", - failed: "border-red-200 bg-red-100 text-red-700", - clarification_required: "border-amber-200 bg-amber-100 text-amber-700", + running: "border-blue-200 bg-blue-100 text-blue-700 shadow-sm", + completed: "border-emerald-200 bg-emerald-100 text-emerald-700 shadow-sm", + failed: "border-red-200 bg-red-100 text-red-700 shadow-sm", + clarification_required: "border-amber-200 bg-amber-100 text-amber-700 shadow-sm", deleted: "border-zinc-300 bg-zinc-100 text-zinc-700", }; -export function IdeaGenStatusBadge({ - status, - className, -}: { - status: IdeaGenRunStatus; - className?: string; -}) { - const { t } = useI18n(); +const statusAnimation: Record<IdeaGenRunStatus, any> = { + pending: {}, + running: { + scale: [1, 1.05, 1], + transition: { repeat: Infinity, duration: 2, ease: "easeInOut" }, + }, + completed: { + scale: [0.95, 1], + transition: { type: "spring", stiffness: 200, damping: 10 }, + }, + failed: { + x: [-2, 2, -2, 2, 0], + transition: { duration: 0.4 }, + }, + clarification_required: { + scale: [1, 1.05, 1], + transition: { repeat: Infinity, duration: 1.5, ease: "easeInOut" }, + }, + deleted: {}, +}; + +export const IdeaGenStatusBadge = memo( + ({ status, className }: { status: IdeaGenRunStatus; className?: string }) => { + const { t } = useI18n(); - return ( - <Badge - variant="outline" - className={cn("border px-2.5 py-1 text-xs", statusClassName[status], className)} - > - {t.ideaGen.status[status]} - </Badge> - ); -} + return ( + <motion.div + initial={{ opacity: 0, scale: 0.8 }} + animate={{ opacity: 1, scale: 1, ...statusAnimation[status] }} + transition={{ duration: 0.2 }} + > + <Badge + variant="outline" + className={cn( + "border px-2.5 py-1 text-xs font-medium transition-all", + statusClassName[status], + className + )} + > + {status === "running" && ( + <span className="mr-1.5 inline-block size-1.5 animate-pulse rounded-full bg-blue-600" /> + )} + {t.ideaGen.status[status]} + </Badge> + </motion.div> + ); + } +); +IdeaGenStatusBadge.displayName = "IdeaGenStatusBadge"; diff --git a/frontend/src/app/workspace/idea-gen/components/idea-gen-timeline.tsx b/frontend/src/app/workspace/idea-gen/components/idea-gen-timeline.tsx index ba734df4..9aca4770 100644 --- a/frontend/src/app/workspace/idea-gen/components/idea-gen-timeline.tsx +++ b/frontend/src/app/workspace/idea-gen/components/idea-gen-timeline.tsx @@ -1,6 +1,8 @@ "use client"; import { CheckIcon, CircleDotIcon, LoaderCircleIcon, RotateCcwIcon } from "lucide-react"; +import { motion } from "motion/react"; +import { memo } from "react"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { useI18n } from "@/core/i18n/hooks"; @@ -44,54 +46,126 @@ function iconOfState(state: TimelineState) { function classNameOfState(state: TimelineState) { if (state === "done") return "border-emerald-200 bg-emerald-100 text-emerald-700"; - if (state === "active") return "border-blue-200 bg-blue-100 text-blue-700"; + if (state === "active") return "border-blue-200 bg-blue-100 text-blue-700 shadow-sm"; if (state === "warning") return "border-amber-200 bg-amber-100 text-amber-700"; if (state === "failed") return "border-red-200 bg-red-100 text-red-700"; return "border-zinc-200 bg-zinc-100 text-zinc-500"; } -export function IdeaGenTimeline({ run }: { run: IdeaGenRun }) { +const TimelineNode = memo( + ({ + nodeName, + state, + label, + isLast, + index, + iterationCount, + criticIterationCount, + t, + }: { + nodeName: IdeaGenNodeName; + state: TimelineState; + label: string; + isLast: boolean; + index: number; + iterationCount?: number; + criticIterationCount?: number; + t: any; + }) => ( + <motion.div + initial={{ opacity: 0, x: -20 }} + animate={{ opacity: 1, x: 0 }} + transition={{ delay: index * 0.05, duration: 0.3 }} + className="flex gap-3" + > + <div className="flex w-8 flex-col items-center"> + <motion.div + initial={{ scale: 0 }} + animate={{ scale: 1 }} + transition={{ delay: index * 0.05 + 0.1, type: "spring", stiffness: 200 }} + className={cn( + "flex size-8 items-center justify-center rounded-full border-2 transition-all", + classNameOfState(state), + state === "active" && "animate-pulse" + )} + > + {iconOfState(state)} + </motion.div> + {!isLast && ( + <motion.div + initial={{ scaleY: 0 }} + animate={{ scaleY: 1 }} + transition={{ delay: index * 0.05 + 0.2, duration: 0.3 }} + className={cn( + "mt-1 h-full w-0.5 origin-top", + state === "done" ? "bg-emerald-200" : "bg-border" + )} + /> + )} + </div> + <motion.div + initial={{ opacity: 0, y: 10 }} + animate={{ opacity: 1, y: 0 }} + transition={{ delay: index * 0.05 + 0.15, duration: 0.3 }} + className={cn( + "flex-1 rounded-xl border bg-white/70 px-4 py-3 transition-all", + state === "active" && "border-blue-200 bg-blue-50/50 shadow-sm" + )} + > + <div className="font-medium">{label}</div> + {nodeName === "supervisor" && iterationCount && iterationCount > 1 && ( + <motion.div + initial={{ opacity: 0, height: 0 }} + animate={{ opacity: 1, height: "auto" }} + className="text-muted-foreground mt-1 text-xs" + > + <RotateCcwIcon className="mr-1 inline size-3" /> + {t.ideaGen.iterationProgress}: {iterationCount} + </motion.div> + )} + {nodeName === "critic" && criticIterationCount && criticIterationCount > 0 && ( + <motion.div + initial={{ opacity: 0, height: 0 }} + animate={{ opacity: 1, height: "auto" }} + className="text-muted-foreground mt-1 text-xs" + > + <RotateCcwIcon className="mr-1 inline size-3" /> + {t.ideaGen.criticIterations}: {criticIterationCount} + </motion.div> + )} + </motion.div> + </motion.div> + ) +); +TimelineNode.displayName = "TimelineNode"; + +export const IdeaGenTimeline = memo(({ run }: { run: IdeaGenRun }) => { const { t } = useI18n(); return ( - <Card className="rounded-2xl border-white/70 bg-white/80"> + <Card className="overflow-hidden rounded-2xl border-white/70 bg-white/80 shadow-sm"> <CardHeader> - <CardTitle>{t.ideaGen.timelineTitle}</CardTitle> + <CardTitle className="text-lg">{t.ideaGen.timelineTitle}</CardTitle> </CardHeader> <CardContent className="space-y-3"> {IDEA_GEN_NODE_NAMES.map((nodeName, index) => { const state = stateOfTimelineNode(run, nodeName); return ( - <div key={nodeName} className="flex gap-3"> - <div className="flex w-8 flex-col items-center"> - <div className={cn("flex size-8 items-center justify-center rounded-full border", classNameOfState(state))}> - {iconOfState(state)} - </div> - {index < IDEA_GEN_NODE_NAMES.length - 1 && ( - <div className="bg-border mt-1 h-full w-px" /> - )} - </div> - <div className="flex-1 rounded-xl border bg-white/70 px-4 py-3"> - <div className="font-medium"> - {getIdeaGenNodeLabel(t.ideaGen, nodeName)} - </div> - {nodeName === "supervisor" && run.iteration_count > 1 && ( - <div className="text-muted-foreground mt-1 text-xs"> - <RotateCcwIcon className="mr-1 inline size-3" /> - {t.ideaGen.iterationProgress}: {run.iteration_count} - </div> - )} - {nodeName === "critic" && run.critic_iteration_count > 0 && ( - <div className="text-muted-foreground mt-1 text-xs"> - <RotateCcwIcon className="mr-1 inline size-3" /> - {t.ideaGen.criticIterations}: {run.critic_iteration_count} - </div> - )} - </div> - </div> + <TimelineNode + key={nodeName} + nodeName={nodeName} + state={state} + label={getIdeaGenNodeLabel(t.ideaGen, nodeName)} + isLast={index === IDEA_GEN_NODE_NAMES.length - 1} + index={index} + iterationCount={run.iteration_count} + criticIterationCount={run.critic_iteration_count} + t={t} + /> ); })} </CardContent> </Card> ); -} +}); +IdeaGenTimeline.displayName = "IdeaGenTimeline"; diff --git a/frontend/src/core/idea-gen/api.ts b/frontend/src/core/idea-gen/api.ts index 53eef912..219ca2ca 100644 --- a/frontend/src/core/idea-gen/api.ts +++ b/frontend/src/core/idea-gen/api.ts @@ -2,6 +2,7 @@ import { getBackendBaseURL } from "@/core/config"; import type { ClarificationResponse, + IdeaGenPresetDefinition, IdeaGenRun, IdeaGenRunCreate, } from "./types"; @@ -51,6 +52,10 @@ export async function fetchIdeaGenRuns(): Promise<IdeaGenRun[]> { return requestIdeaGen<IdeaGenRun[]>("/api/idea-gen/runs"); } +export async function fetchIdeaGenPresets(): Promise<IdeaGenPresetDefinition[]> { + return requestIdeaGen<IdeaGenPresetDefinition[]>("/api/idea-gen/runs/presets"); +} + export async function fetchIdeaGenRun(runId: string): Promise<IdeaGenRun> { return requestIdeaGen<IdeaGenRun>(getIdeaGenRunAPIPath(runId)); } diff --git a/frontend/src/core/idea-gen/hooks.ts b/frontend/src/core/idea-gen/hooks.ts index 781c198f..ec91f8db 100644 --- a/frontend/src/core/idea-gen/hooks.ts +++ b/frontend/src/core/idea-gen/hooks.ts @@ -10,6 +10,7 @@ import { createIdeaGenRun, deleteIdeaGenRun, fetchIdeaGenArtifactText, + fetchIdeaGenPresets, fetchIdeaGenRun, fetchIdeaGenRuns, } from "./api"; @@ -27,6 +28,7 @@ import { export const ideaGenQueryKeys = { all: ["idea-gen-runs"] as const, + presets: ["idea-gen-presets"] as const, detail: (runId: string) => ["idea-gen-run", runId] as const, }; @@ -40,6 +42,14 @@ export function useIdeaGenRuns() { }); } +export function useIdeaGenPresets() { + return useQuery({ + queryKey: ideaGenQueryKeys.presets, + queryFn: fetchIdeaGenPresets, + staleTime: 60_000, + }); +} + export function useIdeaGenRun(runId: string | null | undefined) { return useQuery({ queryKey: runId ? ideaGenQueryKeys.detail(runId) : ["idea-gen-run", "idle"], diff --git a/frontend/src/core/idea-gen/types.ts b/frontend/src/core/idea-gen/types.ts index 4bbc1fb2..f5780779 100644 --- a/frontend/src/core/idea-gen/types.ts +++ b/frontend/src/core/idea-gen/types.ts @@ -52,6 +52,7 @@ export const IDEA_GEN_DEFAULT_LANGUAGE = "zh"; export const IDEA_GEN_DEFAULT_MAX_ITERATIONS = 8; export const IDEA_GEN_DEFAULT_MAX_CRITIC_ITERATIONS = 2; +export type IdeaGenPresetMode = "fast" | "balanced" | "quality" | "custom"; export type IdeaGenRunStatus = (typeof IDEA_GEN_RUN_STATUSES)[number]; export type IdeaGenTerminalStatus = (typeof IDEA_GEN_TERMINAL_STATUSES)[number]; export type IdeaGenNodeName = (typeof IDEA_GEN_NODE_NAMES)[number]; @@ -65,6 +66,7 @@ export type IdeaGenStreamStatus = export interface IdeaGenRunCreate { target: string; + preset_mode: IdeaGenPresetMode; academic_domain: string; target_venues: string[]; language: string; @@ -82,13 +84,14 @@ export interface IdeaGenRunCreate { enable_pdf_parsing?: boolean; enable_summarization?: boolean; enable_paper_cache?: boolean; - // Node model config - model_clarify?: string; - model_brief?: string; - model_query_generation?: string; - model_supervisor?: string; - model_compress?: string; - model_report?: string; +} + +export interface IdeaGenPresetDefinition { + mode: IdeaGenPresetMode; + label: string; + description: string; + default_max_iterations: number; + max_critic_iterations: number; } export interface ClarificationResponse { diff --git a/frontend/src/core/idea-gen/utils.ts b/frontend/src/core/idea-gen/utils.ts index a9f6d9f4..f4baf25e 100644 --- a/frontend/src/core/idea-gen/utils.ts +++ b/frontend/src/core/idea-gen/utils.ts @@ -18,111 +18,24 @@ import { IDEA_GEN_TERMINAL_STATUSES, } from "./types"; -export type IdeaGenPreset = "light" | "medium" | "complex" | "custom"; - export function createDefaultIdeaGenRunInput(): IdeaGenRunCreate { return { target: "", + preset_mode: "balanced", academic_domain: IDEA_GEN_DEFAULT_ACADEMIC_DOMAIN, target_venues: [...IDEA_GEN_DEFAULT_TARGET_VENUES], language: IDEA_GEN_DEFAULT_LANGUAGE, allow_clarification: true, max_iterations: IDEA_GEN_DEFAULT_MAX_ITERATIONS, - // Advanced workflow config (optional) - max_concurrent_research_units: 8, - search_results_per_topic: 20, - max_critic_iterations: 2, - critic_quality_threshold: 7.5, - // Advanced paper config (optional) - enable_scoring: false, - score_threshold: 7.0, - top_k_papers: 3, - enable_pdf_parsing: true, - enable_summarization: false, - enable_paper_cache: true, - // Node model config (optional) - model_clarify: "gpt-4o-mini", - model_brief: "gpt-4o-mini", - model_query_generation: "gpt-4o-mini", - model_supervisor: "gpt-4o-mini", - model_compress: "gpt-4o-mini", - model_report: "gpt-4o-mini", }; } -export function getPresetConfig(preset: IdeaGenPreset): Partial<IdeaGenRunCreate> { - switch (preset) { - case "light": - return { - max_iterations: 3, - max_concurrent_research_units: 4, - search_results_per_topic: 10, - max_critic_iterations: 1, - critic_quality_threshold: 7.0, - enable_scoring: false, - score_threshold: 6.5, - top_k_papers: 2, - enable_pdf_parsing: false, - enable_summarization: false, - enable_paper_cache: true, - model_clarify: "gpt-4o-mini", - model_brief: "gpt-4o-mini", - model_query_generation: "gpt-4", - model_supervisor: "gpt-4o", - model_compress: "gpt-4o", - model_report: "gpt-4o", - }; - case "medium": - return { - max_iterations: 6, - max_concurrent_research_units: 8, - search_results_per_topic: 20, - max_critic_iterations: 2, - critic_quality_threshold: 7.5, - enable_scoring: true, - score_threshold: 7.0, - top_k_papers: 3, - enable_pdf_parsing: true, - enable_summarization: false, - enable_paper_cache: true, - model_clarify: "gpt-4o", - model_brief: "gpt-4o", - model_query_generation: "gpt-4o", - model_supervisor: "gpt-5.4", - model_compress: "gpt-4o", - model_report: "gpt-5.4", - }; - case "complex": - return { - max_iterations: 10, - max_concurrent_research_units: 12, - search_results_per_topic: 30, - max_critic_iterations: 3, - critic_quality_threshold: 8.0, - enable_scoring: true, - score_threshold: 7.5, - top_k_papers: 5, - enable_pdf_parsing: true, - enable_summarization: true, - enable_paper_cache: true, - model_clarify: "gpt-4o", - model_brief: "gpt-5.4", - model_query_generation: "gpt-5.4", - model_supervisor: "gpt-5.4", - model_compress: "gpt-4o", - model_report: "gpt-5.4", - }; - case "custom": - default: - return {}; - } -} - export function normalizeIdeaGenRunInput( input: Partial<IdeaGenRunCreate>, ): IdeaGenRunCreate { return { target: input.target?.trim() ?? "", + preset_mode: input.preset_mode ?? "balanced", academic_domain: input.academic_domain?.trim() ?? IDEA_GEN_DEFAULT_ACADEMIC_DOMAIN, target_venues: (input.target_venues ?? IDEA_GEN_DEFAULT_TARGET_VENUES) @@ -142,13 +55,6 @@ export function normalizeIdeaGenRunInput( ...(input.enable_pdf_parsing !== undefined && { enable_pdf_parsing: input.enable_pdf_parsing }), ...(input.enable_summarization !== undefined && { enable_summarization: input.enable_summarization }), ...(input.enable_paper_cache !== undefined && { enable_paper_cache: input.enable_paper_cache }), - // Node model config (optional) - ...(input.model_clarify && { model_clarify: input.model_clarify.trim() }), - ...(input.model_brief && { model_brief: input.model_brief.trim() }), - ...(input.model_query_generation && { model_query_generation: input.model_query_generation.trim() }), - ...(input.model_supervisor && { model_supervisor: input.model_supervisor.trim() }), - ...(input.model_compress && { model_compress: input.model_compress.trim() }), - ...(input.model_report && { model_report: input.model_report.trim() }), }; } diff --git a/scripts/start-services.sh b/scripts/start-services.sh index 0156e608..38ab1262 100755 --- a/scripts/start-services.sh +++ b/scripts/start-services.sh @@ -152,7 +152,7 @@ start_bg \ -u NEXT_PUBLIC_BACKEND_BASE_URL \ BETTER_AUTH_SECRET="$BETTER_AUTH_SECRET_VALUE" \ DEV_BACKEND_PROXY_TARGET="$DEV_BACKEND_PROXY_TARGET" \ - bash -c "cd '$PROJECT_ROOT/frontend' && exec pnpm exec next dev --turbo --hostname 0.0.0.0 --port ${FRONTEND_PORT}" + bash -c "cd '$PROJECT_ROOT/frontend' && exec pnpm dev --hostname 0.0.0.0 --port ${FRONTEND_PORT}" wait_for_http "Frontend" "http://127.0.0.1:${FRONTEND_PORT}/workspace/review-gen" if [[ "$START_NGINX" == "1" || "$START_NGINX" == "true" ]]; then