diff --git a/.cursorrules b/.cursorrules new file mode 100644 index 0000000000000000000000000000000000000000..8fbe6def025d95d15c47f657eafbbbf0643a5ca5 --- /dev/null +++ b/.cursorrules @@ -0,0 +1,240 @@ +# DeepCritical Project - Cursor Rules + +## Project-Wide Rules + +**Architecture**: Multi-agent research system using Pydantic AI for agent orchestration, supporting iterative and deep research patterns. Uses middleware for state management, budget tracking, and workflow coordination. + +**Type Safety**: ALWAYS use complete type hints. All functions must have parameter and return type annotations. Use `mypy --strict` compliance. Use `TYPE_CHECKING` imports for circular dependencies: `from typing import TYPE_CHECKING; if TYPE_CHECKING: from src.services.embeddings import EmbeddingService` + +**Async Patterns**: ALL I/O operations must be async (`async def`, `await`). Use `asyncio.gather()` for parallel operations. CPU-bound work must use `run_in_executor()`: `loop = asyncio.get_running_loop(); result = await loop.run_in_executor(None, cpu_bound_function, args)`. Never block the event loop. + +**Error Handling**: Use custom exceptions from `src/utils/exceptions.py`: `DeepCriticalError`, `SearchError`, `RateLimitError`, `JudgeError`, `ConfigurationError`. Always chain exceptions: `raise SearchError(...) from e`. Log with structlog: `logger.error("Operation failed", error=str(e), context=value)`. + +**Logging**: Use `structlog` for ALL logging (NOT `print` or `logging`). Import: `import structlog; logger = structlog.get_logger()`. Log with structured data: `logger.info("event", key=value)`. Use appropriate levels: DEBUG, INFO, WARNING, ERROR. + +**Pydantic Models**: All data exchange uses Pydantic models from `src/utils/models.py`. Models are frozen (`model_config = {"frozen": True}`) for immutability. Use `Field()` with descriptions. Validate with `ge=`, `le=`, `min_length=`, `max_length=` constraints. + +**Code Style**: Ruff with 100-char line length. Ignore rules: `PLR0913` (too many arguments), `PLR0912` (too many branches), `PLR0911` (too many returns), `PLR2004` (magic values), `PLW0603` (global statement), `PLC0415` (lazy imports). + +**Docstrings**: Google-style docstrings for all public functions. Include Args, Returns, Raises sections. Use type hints in docstrings only if needed for clarity. + +**Testing**: Unit tests in `tests/unit/` (mocked, fast). Integration tests in `tests/integration/` (real APIs, marked `@pytest.mark.integration`). Use `respx` for httpx mocking, `pytest-mock` for general mocking. + +**State Management**: Use `ContextVar` in middleware for thread-safe isolation. Never use global mutable state (except singletons via `@lru_cache`). Use `WorkflowState` from `src/middleware/state_machine.py` for workflow state. + +**Citation Validation**: ALWAYS validate references before returning reports. Use `validate_references()` from `src/utils/citation_validator.py`. Remove hallucinated citations. Log warnings for removed citations. + +--- + +## src/agents/ - Agent Implementation Rules + +**Pattern**: All agents use Pydantic AI `Agent` class. Agents have structured output types (Pydantic models) or return strings. Use factory functions in `src/agent_factory/agents.py` for creation. + +**Agent Structure**: +- System prompt as module-level constant (with date injection: `datetime.now().strftime("%Y-%m-%d")`) +- Agent class with `__init__(model: Any | None = None)` +- Main method (e.g., `async def evaluate()`, `async def write_report()`) +- Factory function: `def create_agent_name(model: Any | None = None) -> AgentName` + +**Model Initialization**: Use `get_model()` from `src/agent_factory/judges.py` if no model provided. Support OpenAI/Anthropic/HF Inference via settings. + +**Error Handling**: Return fallback values (e.g., `KnowledgeGapOutput(research_complete=False, outstanding_gaps=[...])`) on failure. Log errors with context. Use retry logic (3 retries) in Pydantic AI Agent initialization. + +**Input Validation**: Validate query/inputs are not empty. Truncate very long inputs with warnings. Handle None values gracefully. + +**Output Types**: Use structured output types from `src/utils/models.py` (e.g., `KnowledgeGapOutput`, `AgentSelectionPlan`, `ReportDraft`). For text output (writer agents), return `str` directly. + +**Agent-Specific Rules**: +- `knowledge_gap.py`: Outputs `KnowledgeGapOutput`. Evaluates research completeness. +- `tool_selector.py`: Outputs `AgentSelectionPlan`. Selects tools (RAG/web/database). +- `writer.py`: Returns markdown string. Includes citations in numbered format. +- `long_writer.py`: Uses `ReportDraft` input/output. Handles section-by-section writing. +- `proofreader.py`: Takes `ReportDraft`, returns polished markdown. +- `thinking.py`: Returns observation string from conversation history. +- `input_parser.py`: Outputs `ParsedQuery` with research mode detection. + +--- + +## src/tools/ - Search Tool Rules + +**Protocol**: All tools implement `SearchTool` protocol from `src/tools/base.py`: `name` property and `async def search(query, max_results) -> list[Evidence]`. + +**Rate Limiting**: Use `@retry` decorator from tenacity: `@retry(stop=stop_after_attempt(3), wait=wait_exponential(...))`. Implement `_rate_limit()` method for APIs with limits. Use shared rate limiters from `src/tools/rate_limiter.py`. + +**Error Handling**: Raise `SearchError` or `RateLimitError` on failures. Handle HTTP errors (429, 500, timeout). Return empty list on non-critical errors (log warning). + +**Query Preprocessing**: Use `preprocess_query()` from `src/tools/query_utils.py` to remove noise and expand synonyms. + +**Evidence Conversion**: Convert API responses to `Evidence` objects with `Citation`. Extract metadata (title, url, date, authors). Set relevance scores (0.0-1.0). Handle missing fields gracefully. + +**Tool-Specific Rules**: +- `pubmed.py`: Use NCBI E-utilities (ESearch → EFetch). Rate limit: 0.34s between requests. Parse XML with `xmltodict`. Handle single vs. multiple articles. +- `clinicaltrials.py`: Use `requests` library (NOT httpx - WAF blocks httpx). Run in thread pool: `await asyncio.to_thread(requests.get, ...)`. Filter: Only interventional studies, active/completed. +- `europepmc.py`: Handle preprint markers: `[PREPRINT - Not peer-reviewed]`. Build URLs from DOI or PMID. +- `rag_tool.py`: Wraps `LlamaIndexRAGService`. Returns Evidence from RAG results. Handles ingestion. +- `search_handler.py`: Orchestrates parallel searches across multiple tools. Uses `asyncio.gather()` with `return_exceptions=True`. Aggregates results into `SearchResult`. + +--- + +## src/middleware/ - Middleware Rules + +**State Management**: Use `ContextVar` for thread-safe isolation. `WorkflowState` uses `ContextVar[WorkflowState | None]`. Initialize with `init_workflow_state(embedding_service)`. Access with `get_workflow_state()` (auto-initializes if missing). + +**WorkflowState**: Tracks `evidence: list[Evidence]`, `conversation: Conversation`, `embedding_service: Any`. Methods: `add_evidence()` (deduplicates by URL), `async search_related()` (semantic search). + +**WorkflowManager**: Manages parallel research loops. Methods: `add_loop()`, `run_loops_parallel()`, `update_loop_status()`, `sync_loop_evidence_to_state()`. Uses `asyncio.gather()` for parallel execution. Handles errors per loop (don't fail all if one fails). + +**BudgetTracker**: Tracks tokens, time, iterations per loop and globally. Methods: `create_budget()`, `add_tokens()`, `start_timer()`, `update_timer()`, `increment_iteration()`, `check_budget()`, `can_continue()`. Token estimation: `estimate_tokens(text)` (~4 chars per token), `estimate_llm_call_tokens(prompt, response)`. + +**Models**: All middleware models in `src/utils/models.py`. `IterationData`, `Conversation`, `ResearchLoop`, `BudgetStatus` are used by middleware. + +--- + +## src/orchestrator/ - Orchestration Rules + +**Research Flows**: Two patterns: `IterativeResearchFlow` (single loop) and `DeepResearchFlow` (plan → parallel loops → synthesis). Both support agent chains (`use_graph=False`) and graph execution (`use_graph=True`). + +**IterativeResearchFlow**: Pattern: Generate observations → Evaluate gaps → Select tools → Execute → Judge → Continue/Complete. Uses `KnowledgeGapAgent`, `ToolSelectorAgent`, `ThinkingAgent`, `WriterAgent`, `JudgeHandler`. Tracks iterations, time, budget. + +**DeepResearchFlow**: Pattern: Planner → Parallel iterative loops per section → Synthesizer. Uses `PlannerAgent`, `IterativeResearchFlow` (per section), `LongWriterAgent` or `ProofreaderAgent`. Uses `WorkflowManager` for parallel execution. + +**Graph Orchestrator**: Uses Pydantic AI Graphs (when available) or agent chains (fallback). Routes based on research mode (iterative/deep/auto). Streams `AgentEvent` objects for UI. + +**State Initialization**: Always call `init_workflow_state()` before running flows. Initialize `BudgetTracker` per loop. Use `WorkflowManager` for parallel coordination. + +**Event Streaming**: Yield `AgentEvent` objects during execution. Event types: "started", "search_complete", "judge_complete", "hypothesizing", "synthesizing", "complete", "error". Include iteration numbers and data payloads. + +--- + +## src/services/ - Service Rules + +**EmbeddingService**: Local sentence-transformers (NO API key required). All operations async-safe via `run_in_executor()`. ChromaDB for vector storage. Deduplication threshold: 0.85 (85% similarity = duplicate). + +**LlamaIndexRAGService**: Uses OpenAI embeddings (requires `OPENAI_API_KEY`). Methods: `ingest_evidence()`, `retrieve()`, `query()`. Returns documents with metadata (source, title, url, date, authors). Lazy initialization with graceful fallback. + +**StatisticalAnalyzer**: Generates Python code via LLM. Executes in Modal sandbox (secure, isolated). Library versions pinned in `SANDBOX_LIBRARIES` dict. Returns `AnalysisResult` with verdict (SUPPORTED/REFUTED/INCONCLUSIVE). + +**Singleton Pattern**: Use `@lru_cache(maxsize=1)` for singletons: `@lru_cache(maxsize=1); def get_service() -> Service: return Service()`. Lazy initialization to avoid requiring dependencies at import time. + +--- + +## src/utils/ - Utility Rules + +**Models**: All Pydantic models in `src/utils/models.py`. Use frozen models (`model_config = {"frozen": True}`) except where mutation needed. Use `Field()` with descriptions. Validate with constraints. + +**Config**: Settings via Pydantic Settings (`src/utils/config.py`). Load from `.env` automatically. Use `settings` singleton: `from src.utils.config import settings`. Validate API keys with properties: `has_openai_key`, `has_anthropic_key`. + +**Exceptions**: Custom exception hierarchy in `src/utils/exceptions.py`. Base: `DeepCriticalError`. Specific: `SearchError`, `RateLimitError`, `JudgeError`, `ConfigurationError`. Always chain exceptions. + +**LLM Factory**: Centralized LLM model creation in `src/utils/llm_factory.py`. Supports OpenAI, Anthropic, HF Inference. Use `get_model()` or factory functions. Check requirements before initialization. + +**Citation Validator**: Use `validate_references()` from `src/utils/citation_validator.py`. Removes hallucinated citations (URLs not in evidence). Logs warnings. Returns validated report string. + +--- + +## src/orchestrator_factory.py Rules + +**Purpose**: Factory for creating orchestrators. Supports "simple" (legacy) and "advanced" (magentic) modes. Auto-detects mode based on API key availability. + +**Pattern**: Lazy import for optional dependencies (`_get_magentic_orchestrator_class()`). Handles `ImportError` gracefully with clear error messages. + +**Mode Detection**: `_determine_mode()` checks explicit mode or auto-detects: "advanced" if `settings.has_openai_key`, else "simple". Maps "magentic" → "advanced". + +**Function Signature**: `create_orchestrator(search_handler, judge_handler, config, mode) -> Any`. Simple mode requires handlers. Advanced mode uses MagenticOrchestrator. + +**Error Handling**: Raise `ValueError` with clear messages if requirements not met. Log mode selection with structlog. + +--- + +## src/orchestrator_hierarchical.py Rules + +**Purpose**: Hierarchical orchestrator using middleware and sub-teams. Adapts Magentic ChatAgent to SubIterationTeam protocol. + +**Pattern**: Uses `SubIterationMiddleware` with `ResearchTeam` and `LLMSubIterationJudge`. Event-driven via callback queue. + +**State Initialization**: Initialize embedding service with graceful fallback. Use `init_magentic_state()` (deprecated, but kept for compatibility). + +**Event Streaming**: Uses `asyncio.Queue` for event coordination. Yields `AgentEvent` objects. Handles event callback pattern with `asyncio.wait()`. + +**Error Handling**: Log errors with context. Yield error events. Process remaining events after task completion. + +--- + +## src/orchestrator_magentic.py Rules + +**Purpose**: Magentic-based orchestrator using ChatAgent pattern. Each agent has internal LLM. Manager orchestrates agents. + +**Pattern**: Uses `MagenticBuilder` with participants (searcher, hypothesizer, judge, reporter). Manager uses `OpenAIChatClient`. Workflow built in `_build_workflow()`. + +**Event Processing**: `_process_event()` converts Magentic events to `AgentEvent`. Handles: `MagenticOrchestratorMessageEvent`, `MagenticAgentMessageEvent`, `MagenticFinalResultEvent`, `MagenticAgentDeltaEvent`, `WorkflowOutputEvent`. + +**Text Extraction**: `_extract_text()` defensively extracts text from messages. Priority: `.content` → `.text` → `str(message)`. Handles buggy message objects. + +**State Initialization**: Initialize embedding service with graceful fallback. Use `init_magentic_state()` (deprecated). + +**Requirements**: Must call `check_magentic_requirements()` in `__init__`. Requires `agent-framework-core` and OpenAI API key. + +**Event Types**: Maps agent names to event types: "search" → "search_complete", "judge" → "judge_complete", "hypothes" → "hypothesizing", "report" → "synthesizing". + +--- + +## src/agent_factory/ - Factory Rules + +**Pattern**: Factory functions for creating agents and handlers. Lazy initialization for optional dependencies. Support OpenAI/Anthropic/HF Inference. + +**Judges**: `create_judge_handler()` creates `JudgeHandler` with structured output (`JudgeAssessment`). Supports `MockJudgeHandler`, `HFInferenceJudgeHandler` as fallbacks. + +**Agents**: Factory functions in `agents.py` for all Pydantic AI agents. Pattern: `create_agent_name(model: Any | None = None) -> AgentName`. Use `get_model()` if model not provided. + +**Graph Builder**: `graph_builder.py` contains utilities for building research graphs. Supports iterative and deep research graph construction. + +**Error Handling**: Raise `ConfigurationError` if required API keys missing. Log agent creation. Handle import errors gracefully. + +--- + +## src/prompts/ - Prompt Rules + +**Pattern**: System prompts stored as module-level constants. Include date injection: `datetime.now().strftime("%Y-%m-%d")`. Format evidence with truncation (1500 chars per item). + +**Judge Prompts**: In `judge.py`. Handle empty evidence case separately. Always request structured JSON output. + +**Hypothesis Prompts**: In `hypothesis.py`. Use diverse evidence selection (MMR algorithm). Sentence-aware truncation. + +**Report Prompts**: In `report.py`. Include full citation details. Use diverse evidence selection (n=20). Emphasize citation validation rules. + +--- + +## Testing Rules + +**Structure**: Unit tests in `tests/unit/` (mocked, fast). Integration tests in `tests/integration/` (real APIs, marked `@pytest.mark.integration`). + +**Mocking**: Use `respx` for httpx mocking. Use `pytest-mock` for general mocking. Mock LLM calls in unit tests (use `MockJudgeHandler`). + +**Fixtures**: Common fixtures in `tests/conftest.py`: `mock_httpx_client`, `mock_llm_response`. + +**Coverage**: Aim for >80% coverage. Test error handling, edge cases, and integration paths. + +--- + +## File-Specific Agent Rules + +**knowledge_gap.py**: Outputs `KnowledgeGapOutput`. System prompt evaluates research completeness. Handles conversation history. Returns fallback on error. + +**writer.py**: Returns markdown string. System prompt includes citation format examples. Validates inputs. Truncates long findings. Retry logic for transient failures. + +**long_writer.py**: Uses `ReportDraft` input/output. Writes sections iteratively. Reformats references (deduplicates, renumbers). Reformats section headings. + +**proofreader.py**: Takes `ReportDraft`, returns polished markdown. Removes duplicates. Adds summary. Preserves references. + +**tool_selector.py**: Outputs `AgentSelectionPlan`. System prompt lists available agents (WebSearchAgent, SiteCrawlerAgent, RAGAgent). Guidelines for when to use each. + +**thinking.py**: Returns observation string. Generates observations from conversation history. Uses query and background context. + +**input_parser.py**: Outputs `ParsedQuery`. Detects research mode (iterative/deep). Extracts entities and research questions. Improves/refines query. + + + + + + + diff --git a/.env.example b/.env.example new file mode 100644 index 0000000000000000000000000000000000000000..cfea522c8e49c8e8de6145965e6269cbd616b788 --- /dev/null +++ b/.env.example @@ -0,0 +1,48 @@ +# ============== LLM CONFIGURATION ============== + +# Provider: "openai" or "anthropic" +LLM_PROVIDER=openai + +# API Keys (at least one required for full LLM analysis) +OPENAI_API_KEY=sk-your-key-here +ANTHROPIC_API_KEY=sk-ant-your-key-here + +# Model names (optional - sensible defaults set in config.py) +# ANTHROPIC_MODEL=claude-sonnet-4-5-20250929 +# OPENAI_MODEL=gpt-5.1 + +# ============== EMBEDDINGS ============== + +# OpenAI Embedding Model (used if LLM_PROVIDER is openai and performing RAG/Embeddings) +OPENAI_EMBEDDING_MODEL=text-embedding-3-small + +# Local Embedding Model (used for local/offline embeddings) +LOCAL_EMBEDDING_MODEL=sentence-transformers/all-MiniLM-L6-v2 + +# ============== HUGGINGFACE (FREE TIER) ============== + +# HuggingFace Token - enables Llama 3.1 (best quality free model) +# Get yours at: https://huggingface.co/settings/tokens +# +# WITHOUT HF_TOKEN: Falls back to ungated models (zephyr-7b-beta) +# WITH HF_TOKEN: Uses Llama 3.1 8B Instruct (requires accepting license) +# +# For HuggingFace Spaces deployment: +# Set this as a "Secret" in Space Settings -> Variables and secrets +# Users/judges don't need their own token - the Space secret is used +# +HF_TOKEN=hf_your-token-here + +# ============== AGENT CONFIGURATION ============== + +MAX_ITERATIONS=10 +SEARCH_TIMEOUT=30 +LOG_LEVEL=INFO + +# ============== EXTERNAL SERVICES ============== + +# PubMed (optional - higher rate limits) +NCBI_API_KEY=your-ncbi-key-here + +# Vector Database (optional - for LlamaIndex RAG) +CHROMA_DB_PATH=./chroma_db diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000000000000000000000000000000000000..a6344aac8c09253b3b630fb776ae94478aa0275b --- /dev/null +++ b/.gitattributes @@ -0,0 +1,35 @@ +*.7z filter=lfs diff=lfs merge=lfs -text +*.arrow filter=lfs diff=lfs merge=lfs -text +*.bin filter=lfs diff=lfs merge=lfs -text +*.bz2 filter=lfs diff=lfs merge=lfs -text +*.ckpt filter=lfs diff=lfs merge=lfs -text +*.ftz filter=lfs diff=lfs merge=lfs -text +*.gz filter=lfs diff=lfs merge=lfs -text +*.h5 filter=lfs diff=lfs merge=lfs -text +*.joblib filter=lfs diff=lfs merge=lfs -text +*.lfs.* filter=lfs diff=lfs merge=lfs -text +*.mlmodel filter=lfs diff=lfs merge=lfs -text +*.model filter=lfs diff=lfs merge=lfs -text +*.msgpack filter=lfs diff=lfs merge=lfs -text +*.npy filter=lfs diff=lfs merge=lfs -text +*.npz filter=lfs diff=lfs merge=lfs -text +*.onnx filter=lfs diff=lfs merge=lfs -text +*.ot filter=lfs diff=lfs merge=lfs -text +*.parquet filter=lfs diff=lfs merge=lfs -text +*.pb filter=lfs diff=lfs merge=lfs -text +*.pickle filter=lfs diff=lfs merge=lfs -text +*.pkl filter=lfs diff=lfs merge=lfs -text +*.pt filter=lfs diff=lfs merge=lfs -text +*.pth filter=lfs diff=lfs merge=lfs -text +*.rar filter=lfs diff=lfs merge=lfs -text +*.safetensors filter=lfs diff=lfs merge=lfs -text +saved_model/**/* filter=lfs diff=lfs merge=lfs -text +*.tar.* filter=lfs diff=lfs merge=lfs -text +*.tar filter=lfs diff=lfs merge=lfs -text +*.tflite filter=lfs diff=lfs merge=lfs -text +*.tgz filter=lfs diff=lfs merge=lfs -text +*.wasm filter=lfs diff=lfs merge=lfs -text +*.xz filter=lfs diff=lfs merge=lfs -text +*.zip filter=lfs diff=lfs merge=lfs -text +*.zst filter=lfs diff=lfs merge=lfs -text +*tfevents* filter=lfs diff=lfs merge=lfs -text diff --git a/.github/README.md b/.github/README.md new file mode 100644 index 0000000000000000000000000000000000000000..5ca61af8fee9f9feb0805df6636417391aa55a81 --- /dev/null +++ b/.github/README.md @@ -0,0 +1,203 @@ +--- +title: DeepCritical +emoji: 🧬 +colorFrom: blue +colorTo: purple +sdk: gradio +sdk_version: "6.0.1" +python_version: "3.11" +app_file: src/app.py +pinned: false +license: mit +tags: + - mcp-in-action-track-enterprise + - mcp-hackathon + - drug-repurposing + - biomedical-ai + - pydantic-ai + - llamaindex + - modal +--- + +# DeepCritical + +## Intro + +## Features + +- **Multi-Source Search**: PubMed, ClinicalTrials.gov, bioRxiv/medRxiv +- **MCP Integration**: Use our tools from Claude Desktop or any MCP client +- **Modal Sandbox**: Secure execution of AI-generated statistical code +- **LlamaIndex RAG**: Semantic search and evidence synthesis +- **HuggingfaceInference**: +- **HuggingfaceMCP Custom Config To Use Community Tools**: +- **Strongly Typed Composable Graphs**: +- **Specialized Research Teams of Agents**: + +## Quick Start + +### 1. Environment Setup + +```bash +# Install uv if you haven't already +pip install uv + +# Sync dependencies +uv sync +``` + +### 2. Run the UI + +```bash +# Start the Gradio app +uv run gradio run src/app.py +``` + +Open your browser to `http://localhost:7860`. + +### 3. Connect via MCP + +This application exposes a Model Context Protocol (MCP) server, allowing you to use its search tools directly from Claude Desktop or other MCP clients. + +**MCP Server URL**: `http://localhost:7860/gradio_api/mcp/` + +**Claude Desktop Configuration**: +Add this to your `claude_desktop_config.json`: +```json +{ + "mcpServers": { + "deepcritical": { + "url": "http://localhost:7860/gradio_api/mcp/" + } + } +} +``` + +**Available Tools**: +- `search_pubmed`: Search peer-reviewed biomedical literature. +- `search_clinical_trials`: Search ClinicalTrials.gov. +- `search_biorxiv`: Search bioRxiv/medRxiv preprints. +- `search_all`: Search all sources simultaneously. +- `analyze_hypothesis`: Secure statistical analysis using Modal sandboxes. + + +## Deep Research Flows + +- iterativeResearch +- deepResearch +- researchTeam + +### Iterative Research + +sequenceDiagram + participant IterativeFlow + participant ThinkingAgent + participant KnowledgeGapAgent + participant ToolSelector + participant ToolExecutor + participant JudgeHandler + participant WriterAgent + + IterativeFlow->>IterativeFlow: run(query) + + loop Until complete or max_iterations + IterativeFlow->>ThinkingAgent: generate_observations() + ThinkingAgent-->>IterativeFlow: observations + + IterativeFlow->>KnowledgeGapAgent: evaluate_gaps() + KnowledgeGapAgent-->>IterativeFlow: KnowledgeGapOutput + + alt Research complete + IterativeFlow->>WriterAgent: create_final_report() + WriterAgent-->>IterativeFlow: final_report + else Gaps remain + IterativeFlow->>ToolSelector: select_agents(gap) + ToolSelector-->>IterativeFlow: AgentSelectionPlan + + IterativeFlow->>ToolExecutor: execute_tool_tasks() + ToolExecutor-->>IterativeFlow: ToolAgentOutput[] + + IterativeFlow->>JudgeHandler: assess_evidence() + JudgeHandler-->>IterativeFlow: should_continue + end + end + + +### Deep Research + +sequenceDiagram + actor User + participant GraphOrchestrator + participant InputParser + participant GraphBuilder + participant GraphExecutor + participant Agent + participant BudgetTracker + participant WorkflowState + + User->>GraphOrchestrator: run(query) + GraphOrchestrator->>InputParser: detect_research_mode(query) + InputParser-->>GraphOrchestrator: mode (iterative/deep) + GraphOrchestrator->>GraphBuilder: build_graph(mode) + GraphBuilder-->>GraphOrchestrator: ResearchGraph + GraphOrchestrator->>WorkflowState: init_workflow_state() + GraphOrchestrator->>BudgetTracker: create_budget() + GraphOrchestrator->>GraphExecutor: _execute_graph(graph) + + loop For each node in graph + GraphExecutor->>Agent: execute_node(agent_node) + Agent->>Agent: process_input + Agent-->>GraphExecutor: result + GraphExecutor->>WorkflowState: update_state(result) + GraphExecutor->>BudgetTracker: add_tokens(used) + GraphExecutor->>BudgetTracker: check_budget() + alt Budget exceeded + GraphExecutor->>GraphOrchestrator: emit(error_event) + else Continue + GraphExecutor->>GraphOrchestrator: emit(progress_event) + end + end + + GraphOrchestrator->>User: AsyncGenerator[AgentEvent] + +### Research Team +Critical Deep Research Agent + +## Development + +### Run Tests + +```bash +uv run pytest +``` + +### Run Checks + +```bash +make check +``` + +## Architecture + +DeepCritical uses a Vertical Slice Architecture: + +1. **Search Slice**: Retrieving evidence from PubMed, ClinicalTrials.gov, and bioRxiv. +2. **Judge Slice**: Evaluating evidence quality using LLMs. +3. **Orchestrator Slice**: Managing the research loop and UI. + +Built with: +- **PydanticAI**: For robust agent interactions. +- **Gradio**: For the streaming user interface. +- **PubMed, ClinicalTrials.gov, bioRxiv**: For biomedical data. +- **MCP**: For universal tool access. +- **Modal**: For secure code execution. + +## Team + +- The-Obstacle-Is-The-Way +- MarioAderman +- Josephrp + +## Links + +- [GitHub Repository](https://github.com/The-Obstacle-Is-The-Way/DeepCritical-1) \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000000000000000000000000000000000000..c3d4ae628cb76fa4376889ab1e365d86ea34a8d5 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,67 @@ +name: CI + +on: + push: + branches: [main, develop] + pull_request: + branches: [main, develop] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.11"] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e ".[dev]" + + - name: Lint with ruff + run: | + ruff check . --exclude tests + ruff format --check . --exclude tests + + - name: Type check with mypy + run: | + mypy src + + - name: Install embedding dependencies + run: | + pip install -e ".[embeddings]" + + - name: Run unit tests (excluding OpenAI and embedding providers) + env: + HF_TOKEN: ${{ secrets.HF_TOKEN }} + run: | + pytest tests/unit/ -v -m "not openai and not embedding_provider" --tb=short -p no:logfire + + - name: Run local embeddings tests + env: + HF_TOKEN: ${{ secrets.HF_TOKEN }} + run: | + pytest tests/ -v -m "local_embeddings" --tb=short -p no:logfire || true + continue-on-error: true # Allow failures if dependencies not available + + - name: Run HuggingFace integration tests + env: + HF_TOKEN: ${{ secrets.HF_TOKEN }} + run: | + pytest tests/integration/ -v -m "huggingface and not embedding_provider" --tb=short -p no:logfire || true + continue-on-error: true # Allow failures if HF_TOKEN not set + + - name: Run non-OpenAI integration tests (excluding embedding providers) + env: + HF_TOKEN: ${{ secrets.HF_TOKEN }} + run: | + pytest tests/integration/ -v -m "integration and not openai and not embedding_provider" --tb=short -p no:logfire || true + continue-on-error: true # Allow failures if dependencies not available diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..8b9c2be2dd32820057ce520015e4904a7648f6b7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,77 @@ +folder/ +.cursor/ +.ruff_cache/ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Virtual environments +.venv/ +venv/ +ENV/ +env/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# Environment +.env +.env.local +*.local + +# Claude +.claude/ + +# Burner docs (working drafts, not for commit) +burner_docs/ + +# Reference repos (clone locally, don't commit) +reference_repos/autogen-microsoft/ +reference_repos/claude-agent-sdk/ +reference_repos/pydanticai-research-agent/ +reference_repos/pubmed-mcp-server/ +reference_repos/DeepCritical/ + +# Keep the README in reference_repos +!reference_repos/README.md + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log +logs/ + +# Testing +.pytest_cache/ +.mypy_cache/ +.coverage +htmlcov/ + +# Database files +chroma_db/ +*.sqlite3 + +# Trigger rebuild Wed Nov 26 17:51:41 EST 2025 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000000000000000000000000000000000000..12a77673427b329be27cc9533caffbde84013527 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,64 @@ +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.4.4 + hooks: + - id: ruff + args: [--fix, --exclude, tests] + exclude: ^reference_repos/ + - id: ruff-format + args: [--exclude, tests] + exclude: ^reference_repos/ + + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.10.0 + hooks: + - id: mypy + files: ^src/ + exclude: ^folder + additional_dependencies: + - pydantic>=2.7 + - pydantic-settings>=2.2 + - tenacity>=8.2 + - pydantic-ai>=0.0.16 + args: [--ignore-missing-imports] + + - repo: local + hooks: + - id: pytest-unit + name: pytest unit tests (no OpenAI) + entry: uv + language: system + types: [python] + args: [ + "run", + "pytest", + "tests/unit/", + "-v", + "-m", + "not openai and not embedding_provider", + "--tb=short", + "-p", + "no:logfire", + ] + pass_filenames: false + always_run: true + require_serial: false + - id: pytest-local-embeddings + name: pytest local embeddings tests + entry: uv + language: system + types: [python] + args: [ + "run", + "pytest", + "tests/", + "-v", + "-m", + "local_embeddings", + "--tb=short", + "-p", + "no:logfire", + ] + pass_filenames: false + always_run: true + require_serial: false diff --git a/.pre-commit-hooks/run_pytest.ps1 b/.pre-commit-hooks/run_pytest.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..ec548f3ca13fb782048df191298defe82e21518d --- /dev/null +++ b/.pre-commit-hooks/run_pytest.ps1 @@ -0,0 +1,14 @@ +# PowerShell pytest runner for pre-commit (Windows) +# Uses uv if available, otherwise falls back to pytest + +if (Get-Command uv -ErrorAction SilentlyContinue) { + uv run pytest $args +} else { + Write-Warning "uv not found, using system pytest (may have missing dependencies)" + pytest $args +} + + + + + diff --git a/.pre-commit-hooks/run_pytest.sh b/.pre-commit-hooks/run_pytest.sh new file mode 100644 index 0000000000000000000000000000000000000000..8ecca4a4ca37f53f7bf9f749e6add363225ead41 --- /dev/null +++ b/.pre-commit-hooks/run_pytest.sh @@ -0,0 +1,15 @@ +#!/bin/bash +# Cross-platform pytest runner for pre-commit +# Uses uv if available, otherwise falls back to pytest + +if command -v uv >/dev/null 2>&1; then + uv run pytest "$@" +else + echo "Warning: uv not found, using system pytest (may have missing dependencies)" + pytest "$@" +fi + + + + + diff --git a/.python-version b/.python-version new file mode 100644 index 0000000000000000000000000000000000000000..2c0733315e415bfb5e5b353f9996ecd964d395b2 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.11 diff --git a/AGENTS.txt b/AGENTS.txt new file mode 100644 index 0000000000000000000000000000000000000000..24cb3ed4b8ac8cd3c519cbe24b640faaac1217bd --- /dev/null +++ b/AGENTS.txt @@ -0,0 +1,236 @@ +# DeepCritical Project - Rules + +## Project-Wide Rules + +**Architecture**: Multi-agent research system using Pydantic AI for agent orchestration, supporting iterative and deep research patterns. Uses middleware for state management, budget tracking, and workflow coordination. + +**Type Safety**: ALWAYS use complete type hints. All functions must have parameter and return type annotations. Use `mypy --strict` compliance. Use `TYPE_CHECKING` imports for circular dependencies: `from typing import TYPE_CHECKING; if TYPE_CHECKING: from src.services.embeddings import EmbeddingService` + +**Async Patterns**: ALL I/O operations must be async (`async def`, `await`). Use `asyncio.gather()` for parallel operations. CPU-bound work must use `run_in_executor()`: `loop = asyncio.get_running_loop(); result = await loop.run_in_executor(None, cpu_bound_function, args)`. Never block the event loop. + +**Error Handling**: Use custom exceptions from `src/utils/exceptions.py`: `DeepCriticalError`, `SearchError`, `RateLimitError`, `JudgeError`, `ConfigurationError`. Always chain exceptions: `raise SearchError(...) from e`. Log with structlog: `logger.error("Operation failed", error=str(e), context=value)`. + +**Logging**: Use `structlog` for ALL logging (NOT `print` or `logging`). Import: `import structlog; logger = structlog.get_logger()`. Log with structured data: `logger.info("event", key=value)`. Use appropriate levels: DEBUG, INFO, WARNING, ERROR. + +**Pydantic Models**: All data exchange uses Pydantic models from `src/utils/models.py`. Models are frozen (`model_config = {"frozen": True}`) for immutability. Use `Field()` with descriptions. Validate with `ge=`, `le=`, `min_length=`, `max_length=` constraints. + +**Code Style**: Ruff with 100-char line length. Ignore rules: `PLR0913` (too many arguments), `PLR0912` (too many branches), `PLR0911` (too many returns), `PLR2004` (magic values), `PLW0603` (global statement), `PLC0415` (lazy imports). + +**Docstrings**: Google-style docstrings for all public functions. Include Args, Returns, Raises sections. Use type hints in docstrings only if needed for clarity. + +**Testing**: Unit tests in `tests/unit/` (mocked, fast). Integration tests in `tests/integration/` (real APIs, marked `@pytest.mark.integration`). Use `respx` for httpx mocking, `pytest-mock` for general mocking. + +**State Management**: Use `ContextVar` in middleware for thread-safe isolation. Never use global mutable state (except singletons via `@lru_cache`). Use `WorkflowState` from `src/middleware/state_machine.py` for workflow state. + +**Citation Validation**: ALWAYS validate references before returning reports. Use `validate_references()` from `src/utils/citation_validator.py`. Remove hallucinated citations. Log warnings for removed citations. + +--- + +## src/agents/ - Agent Implementation Rules + +**Pattern**: All agents use Pydantic AI `Agent` class. Agents have structured output types (Pydantic models) or return strings. Use factory functions in `src/agent_factory/agents.py` for creation. + +**Agent Structure**: +- System prompt as module-level constant (with date injection: `datetime.now().strftime("%Y-%m-%d")`) +- Agent class with `__init__(model: Any | None = None)` +- Main method (e.g., `async def evaluate()`, `async def write_report()`) +- Factory function: `def create_agent_name(model: Any | None = None) -> AgentName` + +**Model Initialization**: Use `get_model()` from `src/agent_factory/judges.py` if no model provided. Support OpenAI/Anthropic/HF Inference via settings. + +**Error Handling**: Return fallback values (e.g., `KnowledgeGapOutput(research_complete=False, outstanding_gaps=[...])`) on failure. Log errors with context. Use retry logic (3 retries) in Pydantic AI Agent initialization. + +**Input Validation**: Validate query/inputs are not empty. Truncate very long inputs with warnings. Handle None values gracefully. + +**Output Types**: Use structured output types from `src/utils/models.py` (e.g., `KnowledgeGapOutput`, `AgentSelectionPlan`, `ReportDraft`). For text output (writer agents), return `str` directly. + +**Agent-Specific Rules**: +- `knowledge_gap.py`: Outputs `KnowledgeGapOutput`. Evaluates research completeness. +- `tool_selector.py`: Outputs `AgentSelectionPlan`. Selects tools (RAG/web/database). +- `writer.py`: Returns markdown string. Includes citations in numbered format. +- `long_writer.py`: Uses `ReportDraft` input/output. Handles section-by-section writing. +- `proofreader.py`: Takes `ReportDraft`, returns polished markdown. +- `thinking.py`: Returns observation string from conversation history. +- `input_parser.py`: Outputs `ParsedQuery` with research mode detection. + +--- + +## src/tools/ - Search Tool Rules + +**Protocol**: All tools implement `SearchTool` protocol from `src/tools/base.py`: `name` property and `async def search(query, max_results) -> list[Evidence]`. + +**Rate Limiting**: Use `@retry` decorator from tenacity: `@retry(stop=stop_after_attempt(3), wait=wait_exponential(...))`. Implement `_rate_limit()` method for APIs with limits. Use shared rate limiters from `src/tools/rate_limiter.py`. + +**Error Handling**: Raise `SearchError` or `RateLimitError` on failures. Handle HTTP errors (429, 500, timeout). Return empty list on non-critical errors (log warning). + +**Query Preprocessing**: Use `preprocess_query()` from `src/tools/query_utils.py` to remove noise and expand synonyms. + +**Evidence Conversion**: Convert API responses to `Evidence` objects with `Citation`. Extract metadata (title, url, date, authors). Set relevance scores (0.0-1.0). Handle missing fields gracefully. + +**Tool-Specific Rules**: +- `pubmed.py`: Use NCBI E-utilities (ESearch → EFetch). Rate limit: 0.34s between requests. Parse XML with `xmltodict`. Handle single vs. multiple articles. +- `clinicaltrials.py`: Use `requests` library (NOT httpx - WAF blocks httpx). Run in thread pool: `await asyncio.to_thread(requests.get, ...)`. Filter: Only interventional studies, active/completed. +- `europepmc.py`: Handle preprint markers: `[PREPRINT - Not peer-reviewed]`. Build URLs from DOI or PMID. +- `rag_tool.py`: Wraps `LlamaIndexRAGService`. Returns Evidence from RAG results. Handles ingestion. +- `search_handler.py`: Orchestrates parallel searches across multiple tools. Uses `asyncio.gather()` with `return_exceptions=True`. Aggregates results into `SearchResult`. + +--- + +## src/middleware/ - Middleware Rules + +**State Management**: Use `ContextVar` for thread-safe isolation. `WorkflowState` uses `ContextVar[WorkflowState | None]`. Initialize with `init_workflow_state(embedding_service)`. Access with `get_workflow_state()` (auto-initializes if missing). + +**WorkflowState**: Tracks `evidence: list[Evidence]`, `conversation: Conversation`, `embedding_service: Any`. Methods: `add_evidence()` (deduplicates by URL), `async search_related()` (semantic search). + +**WorkflowManager**: Manages parallel research loops. Methods: `add_loop()`, `run_loops_parallel()`, `update_loop_status()`, `sync_loop_evidence_to_state()`. Uses `asyncio.gather()` for parallel execution. Handles errors per loop (don't fail all if one fails). + +**BudgetTracker**: Tracks tokens, time, iterations per loop and globally. Methods: `create_budget()`, `add_tokens()`, `start_timer()`, `update_timer()`, `increment_iteration()`, `check_budget()`, `can_continue()`. Token estimation: `estimate_tokens(text)` (~4 chars per token), `estimate_llm_call_tokens(prompt, response)`. + +**Models**: All middleware models in `src/utils/models.py`. `IterationData`, `Conversation`, `ResearchLoop`, `BudgetStatus` are used by middleware. + +--- + +## src/orchestrator/ - Orchestration Rules + +**Research Flows**: Two patterns: `IterativeResearchFlow` (single loop) and `DeepResearchFlow` (plan → parallel loops → synthesis). Both support agent chains (`use_graph=False`) and graph execution (`use_graph=True`). + +**IterativeResearchFlow**: Pattern: Generate observations → Evaluate gaps → Select tools → Execute → Judge → Continue/Complete. Uses `KnowledgeGapAgent`, `ToolSelectorAgent`, `ThinkingAgent`, `WriterAgent`, `JudgeHandler`. Tracks iterations, time, budget. + +**DeepResearchFlow**: Pattern: Planner → Parallel iterative loops per section → Synthesizer. Uses `PlannerAgent`, `IterativeResearchFlow` (per section), `LongWriterAgent` or `ProofreaderAgent`. Uses `WorkflowManager` for parallel execution. + +**Graph Orchestrator**: Uses Pydantic AI Graphs (when available) or agent chains (fallback). Routes based on research mode (iterative/deep/auto). Streams `AgentEvent` objects for UI. + +**State Initialization**: Always call `init_workflow_state()` before running flows. Initialize `BudgetTracker` per loop. Use `WorkflowManager` for parallel coordination. + +**Event Streaming**: Yield `AgentEvent` objects during execution. Event types: "started", "search_complete", "judge_complete", "hypothesizing", "synthesizing", "complete", "error". Include iteration numbers and data payloads. + +--- + +## src/services/ - Service Rules + +**EmbeddingService**: Local sentence-transformers (NO API key required). All operations async-safe via `run_in_executor()`. ChromaDB for vector storage. Deduplication threshold: 0.85 (85% similarity = duplicate). + +**LlamaIndexRAGService**: Uses OpenAI embeddings (requires `OPENAI_API_KEY`). Methods: `ingest_evidence()`, `retrieve()`, `query()`. Returns documents with metadata (source, title, url, date, authors). Lazy initialization with graceful fallback. + +**StatisticalAnalyzer**: Generates Python code via LLM. Executes in Modal sandbox (secure, isolated). Library versions pinned in `SANDBOX_LIBRARIES` dict. Returns `AnalysisResult` with verdict (SUPPORTED/REFUTED/INCONCLUSIVE). + +**Singleton Pattern**: Use `@lru_cache(maxsize=1)` for singletons: `@lru_cache(maxsize=1); def get_service() -> Service: return Service()`. Lazy initialization to avoid requiring dependencies at import time. + +--- + +## src/utils/ - Utility Rules + +**Models**: All Pydantic models in `src/utils/models.py`. Use frozen models (`model_config = {"frozen": True}`) except where mutation needed. Use `Field()` with descriptions. Validate with constraints. + +**Config**: Settings via Pydantic Settings (`src/utils/config.py`). Load from `.env` automatically. Use `settings` singleton: `from src.utils.config import settings`. Validate API keys with properties: `has_openai_key`, `has_anthropic_key`. + +**Exceptions**: Custom exception hierarchy in `src/utils/exceptions.py`. Base: `DeepCriticalError`. Specific: `SearchError`, `RateLimitError`, `JudgeError`, `ConfigurationError`. Always chain exceptions. + +**LLM Factory**: Centralized LLM model creation in `src/utils/llm_factory.py`. Supports OpenAI, Anthropic, HF Inference. Use `get_model()` or factory functions. Check requirements before initialization. + +**Citation Validator**: Use `validate_references()` from `src/utils/citation_validator.py`. Removes hallucinated citations (URLs not in evidence). Logs warnings. Returns validated report string. + +--- + +## src/orchestrator_factory.py Rules + +**Purpose**: Factory for creating orchestrators. Supports "simple" (legacy) and "advanced" (magentic) modes. Auto-detects mode based on API key availability. + +**Pattern**: Lazy import for optional dependencies (`_get_magentic_orchestrator_class()`). Handles `ImportError` gracefully with clear error messages. + +**Mode Detection**: `_determine_mode()` checks explicit mode or auto-detects: "advanced" if `settings.has_openai_key`, else "simple". Maps "magentic" → "advanced". + +**Function Signature**: `create_orchestrator(search_handler, judge_handler, config, mode) -> Any`. Simple mode requires handlers. Advanced mode uses MagenticOrchestrator. + +**Error Handling**: Raise `ValueError` with clear messages if requirements not met. Log mode selection with structlog. + +--- + +## src/orchestrator_hierarchical.py Rules + +**Purpose**: Hierarchical orchestrator using middleware and sub-teams. Adapts Magentic ChatAgent to SubIterationTeam protocol. + +**Pattern**: Uses `SubIterationMiddleware` with `ResearchTeam` and `LLMSubIterationJudge`. Event-driven via callback queue. + +**State Initialization**: Initialize embedding service with graceful fallback. Use `init_magentic_state()` (deprecated, but kept for compatibility). + +**Event Streaming**: Uses `asyncio.Queue` for event coordination. Yields `AgentEvent` objects. Handles event callback pattern with `asyncio.wait()`. + +**Error Handling**: Log errors with context. Yield error events. Process remaining events after task completion. + +--- + +## src/orchestrator_magentic.py Rules + +**Purpose**: Magentic-based orchestrator using ChatAgent pattern. Each agent has internal LLM. Manager orchestrates agents. + +**Pattern**: Uses `MagenticBuilder` with participants (searcher, hypothesizer, judge, reporter). Manager uses `OpenAIChatClient`. Workflow built in `_build_workflow()`. + +**Event Processing**: `_process_event()` converts Magentic events to `AgentEvent`. Handles: `MagenticOrchestratorMessageEvent`, `MagenticAgentMessageEvent`, `MagenticFinalResultEvent`, `MagenticAgentDeltaEvent`, `WorkflowOutputEvent`. + +**Text Extraction**: `_extract_text()` defensively extracts text from messages. Priority: `.content` → `.text` → `str(message)`. Handles buggy message objects. + +**State Initialization**: Initialize embedding service with graceful fallback. Use `init_magentic_state()` (deprecated). + +**Requirements**: Must call `check_magentic_requirements()` in `__init__`. Requires `agent-framework-core` and OpenAI API key. + +**Event Types**: Maps agent names to event types: "search" → "search_complete", "judge" → "judge_complete", "hypothes" → "hypothesizing", "report" → "synthesizing". + +--- + +## src/agent_factory/ - Factory Rules + +**Pattern**: Factory functions for creating agents and handlers. Lazy initialization for optional dependencies. Support OpenAI/Anthropic/HF Inference. + +**Judges**: `create_judge_handler()` creates `JudgeHandler` with structured output (`JudgeAssessment`). Supports `MockJudgeHandler`, `HFInferenceJudgeHandler` as fallbacks. + +**Agents**: Factory functions in `agents.py` for all Pydantic AI agents. Pattern: `create_agent_name(model: Any | None = None) -> AgentName`. Use `get_model()` if model not provided. + +**Graph Builder**: `graph_builder.py` contains utilities for building research graphs. Supports iterative and deep research graph construction. + +**Error Handling**: Raise `ConfigurationError` if required API keys missing. Log agent creation. Handle import errors gracefully. + +--- + +## src/prompts/ - Prompt Rules + +**Pattern**: System prompts stored as module-level constants. Include date injection: `datetime.now().strftime("%Y-%m-%d")`. Format evidence with truncation (1500 chars per item). + +**Judge Prompts**: In `judge.py`. Handle empty evidence case separately. Always request structured JSON output. + +**Hypothesis Prompts**: In `hypothesis.py`. Use diverse evidence selection (MMR algorithm). Sentence-aware truncation. + +**Report Prompts**: In `report.py`. Include full citation details. Use diverse evidence selection (n=20). Emphasize citation validation rules. + +--- + +## Testing Rules + +**Structure**: Unit tests in `tests/unit/` (mocked, fast). Integration tests in `tests/integration/` (real APIs, marked `@pytest.mark.integration`). + +**Mocking**: Use `respx` for httpx mocking. Use `pytest-mock` for general mocking. Mock LLM calls in unit tests (use `MockJudgeHandler`). + +**Fixtures**: Common fixtures in `tests/conftest.py`: `mock_httpx_client`, `mock_llm_response`. + +**Coverage**: Aim for >80% coverage. Test error handling, edge cases, and integration paths. + +--- + +## File-Specific Agent Rules + +**knowledge_gap.py**: Outputs `KnowledgeGapOutput`. System prompt evaluates research completeness. Handles conversation history. Returns fallback on error. + +**writer.py**: Returns markdown string. System prompt includes citation format examples. Validates inputs. Truncates long findings. Retry logic for transient failures. + +**long_writer.py**: Uses `ReportDraft` input/output. Writes sections iteratively. Reformats references (deduplicates, renumbers). Reformats section headings. + +**proofreader.py**: Takes `ReportDraft`, returns polished markdown. Removes duplicates. Adds summary. Preserves references. + +**tool_selector.py**: Outputs `AgentSelectionPlan`. System prompt lists available agents (WebSearchAgent, SiteCrawlerAgent, RAGAgent). Guidelines for when to use each. + +**thinking.py**: Returns observation string. Generates observations from conversation history. Uses query and background context. + +**input_parser.py**: Outputs `ParsedQuery`. Detects research mode (iterative/deep). Extracts entities and research questions. Improves/refines query. + + + diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000000000000000000000000000000000000..01b17d0d73a01b2c97d35f2d7d09c81437e274dc --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1 @@ +make sure you run the full pre-commit checks before opening a PR (not draft) otherwise Obstacle is the Way will loose his mind \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..9d6fc14dce9d1bdbc102a1479304490324313167 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,52 @@ +# Dockerfile for DeepCritical +FROM python:3.11-slim + +# Set working directory +WORKDIR /app + +# Install system dependencies (curl needed for HEALTHCHECK) +RUN apt-get update && apt-get install -y \ + git \ + curl \ + && rm -rf /var/lib/apt/lists/* + +# Install uv +RUN pip install uv==0.5.4 + +# Copy project files +COPY pyproject.toml . +COPY uv.lock . +COPY src/ src/ +COPY README.md . + +# Install runtime dependencies only (no dev/test tools) +RUN uv sync --frozen --no-dev --extra embeddings --extra magentic + +# Create non-root user BEFORE downloading models +RUN useradd --create-home --shell /bin/bash appuser + +# Set cache directory for HuggingFace models (must be writable by appuser) +ENV HF_HOME=/app/.cache +ENV TRANSFORMERS_CACHE=/app/.cache + +# Create cache dir with correct ownership +RUN mkdir -p /app/.cache && chown -R appuser:appuser /app/.cache + +# Pre-download the embedding model during build (as appuser to set correct ownership) +USER appuser +RUN uv run python -c "from sentence_transformers import SentenceTransformer; SentenceTransformer('all-MiniLM-L6-v2')" + +# Expose port +EXPOSE 7860 + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD curl -f http://localhost:7860/ || exit 1 + +# Set environment variables +ENV GRADIO_SERVER_NAME=0.0.0.0 +ENV GRADIO_SERVER_PORT=7860 +ENV PYTHONPATH=/app + +# Run the app +CMD ["uv", "run", "python", "-m", "src.app"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000000000000000000000000000000000000..eebf37bb63cd097a6f312bde21fe9877975bc8e1 --- /dev/null +++ b/Makefile @@ -0,0 +1,42 @@ +.PHONY: install test lint format typecheck check clean all cov cov-html + +# Default target +all: check + +install: + uv sync --all-extras + uv run pre-commit install + +test: + uv run pytest tests/unit/ -v -m "not openai" -p no:logfire + +test-hf: + uv run pytest tests/ -v -m "huggingface" -p no:logfire + +test-all: + uv run pytest tests/ -v -p no:logfire + +# Coverage aliases +cov: test-cov +test-cov: + uv run pytest --cov=src --cov-report=term-missing -m "not openai" -p no:logfire + +cov-html: + uv run pytest --cov=src --cov-report=html -p no:logfire + @echo "Coverage report: open htmlcov/index.html" + +lint: + uv run ruff check src tests + +format: + uv run ruff format src tests + +typecheck: + uv run mypy src + +check: lint typecheck test-cov + @echo "All checks passed!" + +clean: + rm -rf .pytest_cache .mypy_cache .ruff_cache __pycache__ .coverage htmlcov + find . -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true diff --git a/README.md b/README.md new file mode 100644 index 0000000000000000000000000000000000000000..2dd0e49df9108088969f2e7ebba53115201d7200 --- /dev/null +++ b/README.md @@ -0,0 +1,196 @@ +--- +title: DeepCritical +emoji: 🧬 +colorFrom: blue +colorTo: purple +sdk: gradio +sdk_version: "6.0.1" +python_version: "3.11" +app_file: src/app.py +pinned: false +license: mit +tags: + - mcp-in-action-track-enterprise + - mcp-hackathon + - drug-repurposing + - biomedical-ai + - pydantic-ai + - llamaindex + - modal +--- + +# DeepCritical + +## Intro + +## Features + +- **Multi-Source Search**: PubMed, ClinicalTrials.gov, bioRxiv/medRxiv +- **MCP Integration**: Use our tools from Claude Desktop or any MCP client +- **Modal Sandbox**: Secure execution of AI-generated statistical code +- **LlamaIndex RAG**: Semantic search and evidence synthesis +- **HuggingfaceInference**: +- **HuggingfaceMCP Custom Config To Use Community Tools**: +- **Strongly Typed Composable Graphs**: +- **Specialized Research Teams of Agents**: + +## Quick Start + +### 1. Environment Setup + +```bash +# Install uv if you haven't already +pip install uv + +# Sync dependencies +uv sync +``` + +### 2. Run the UI + +```bash +# Start the Gradio app +uv run gradio run src/app.py +``` + +Open your browser to `http://localhost:7860`. + +### 3. Connect via MCP + +This application exposes a Model Context Protocol (MCP) server, allowing you to use its search tools directly from Claude Desktop or other MCP clients. + +**MCP Server URL**: `http://localhost:7860/gradio_api/mcp/` + +**Claude Desktop Configuration**: +Add this to your `claude_desktop_config.json`: +```json +{ + "mcpServers": { + "deepcritical": { + "url": "http://localhost:7860/gradio_api/mcp/" + } + } +} +``` + +**Available Tools**: +- `search_pubmed`: Search peer-reviewed biomedical literature. +- `search_clinical_trials`: Search ClinicalTrials.gov. +- `search_biorxiv`: Search bioRxiv/medRxiv preprints. +- `search_all`: Search all sources simultaneously. +- `analyze_hypothesis`: Secure statistical analysis using Modal sandboxes. + + + +## Architecture + +DeepCritical uses a Vertical Slice Architecture: + +1. **Search Slice**: Retrieving evidence from PubMed, ClinicalTrials.gov, and bioRxiv. +2. **Judge Slice**: Evaluating evidence quality using LLMs. +3. **Orchestrator Slice**: Managing the research loop and UI. + +- iterativeResearch +- deepResearch +- researchTeam + +### Iterative Research + +sequenceDiagram + participant IterativeFlow + participant ThinkingAgent + participant KnowledgeGapAgent + participant ToolSelector + participant ToolExecutor + participant JudgeHandler + participant WriterAgent + + IterativeFlow->>IterativeFlow: run(query) + + loop Until complete or max_iterations + IterativeFlow->>ThinkingAgent: generate_observations() + ThinkingAgent-->>IterativeFlow: observations + + IterativeFlow->>KnowledgeGapAgent: evaluate_gaps() + KnowledgeGapAgent-->>IterativeFlow: KnowledgeGapOutput + + alt Research complete + IterativeFlow->>WriterAgent: create_final_report() + WriterAgent-->>IterativeFlow: final_report + else Gaps remain + IterativeFlow->>ToolSelector: select_agents(gap) + ToolSelector-->>IterativeFlow: AgentSelectionPlan + + IterativeFlow->>ToolExecutor: execute_tool_tasks() + ToolExecutor-->>IterativeFlow: ToolAgentOutput[] + + IterativeFlow->>JudgeHandler: assess_evidence() + JudgeHandler-->>IterativeFlow: should_continue + end + end + + +### Deep Research + +sequenceDiagram + actor User + participant GraphOrchestrator + participant InputParser + participant GraphBuilder + participant GraphExecutor + participant Agent + participant BudgetTracker + participant WorkflowState + + User->>GraphOrchestrator: run(query) + GraphOrchestrator->>InputParser: detect_research_mode(query) + InputParser-->>GraphOrchestrator: mode (iterative/deep) + GraphOrchestrator->>GraphBuilder: build_graph(mode) + GraphBuilder-->>GraphOrchestrator: ResearchGraph + GraphOrchestrator->>WorkflowState: init_workflow_state() + GraphOrchestrator->>BudgetTracker: create_budget() + GraphOrchestrator->>GraphExecutor: _execute_graph(graph) + + loop For each node in graph + GraphExecutor->>Agent: execute_node(agent_node) + Agent->>Agent: process_input + Agent-->>GraphExecutor: result + GraphExecutor->>WorkflowState: update_state(result) + GraphExecutor->>BudgetTracker: add_tokens(used) + GraphExecutor->>BudgetTracker: check_budget() + alt Budget exceeded + GraphExecutor->>GraphOrchestrator: emit(error_event) + else Continue + GraphExecutor->>GraphOrchestrator: emit(progress_event) + end + end + + GraphOrchestrator->>User: AsyncGenerator[AgentEvent] + +### Research Team + +Critical Deep Research Agent + +## Development + +### Run Tests + +```bash +uv run pytest +``` + +### Run Checks + +```bash +make check +``` + +## Join Us + +- The-Obstacle-Is-The-Way +- MarioAderman +- Josephrp + +## Links + +- [GitHub Repository](https://github.com/The-Obstacle-Is-The-Way/DeepCritical-1) \ No newline at end of file diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md new file mode 100644 index 0000000000000000000000000000000000000000..50d73b26220673abfc9f2b94368233454d49c0a3 --- /dev/null +++ b/docs/CONFIGURATION.md @@ -0,0 +1,301 @@ +# Configuration Guide + +## Overview + +DeepCritical uses **Pydantic Settings** for centralized configuration management. All settings are defined in `src/utils/config.py` and can be configured via environment variables or a `.env` file. + +## Quick Start + +1. Copy the example environment file (if available) or create a `.env` file in the project root +2. Set at least one LLM API key (`OPENAI_API_KEY` or `ANTHROPIC_API_KEY`) +3. Optionally configure other services as needed + +## Configuration System + +### How It Works + +- **Settings Class**: `Settings` class in `src/utils/config.py` extends `BaseSettings` from `pydantic_settings` +- **Environment File**: Automatically loads from `.env` file (if present) +- **Environment Variables**: Reads from environment variables (case-insensitive) +- **Type Safety**: Strongly-typed fields with validation +- **Singleton Pattern**: Global `settings` instance for easy access + +### Usage + +```python +from src.utils.config import settings + +# Check if API keys are available +if settings.has_openai_key: + # Use OpenAI + pass + +# Access configuration values +max_iterations = settings.max_iterations +web_search_provider = settings.web_search_provider +``` + +## Required Configuration + +### At Least One LLM Provider + +You must configure at least one LLM provider: + +**OpenAI:** +```bash +LLM_PROVIDER=openai +OPENAI_API_KEY=your_openai_api_key_here +OPENAI_MODEL=gpt-5.1 +``` + +**Anthropic:** +```bash +LLM_PROVIDER=anthropic +ANTHROPIC_API_KEY=your_anthropic_api_key_here +ANTHROPIC_MODEL=claude-sonnet-4-5-20250929 +``` + +## Optional Configuration + +### Embedding Configuration + +```bash +# Embedding Provider: "openai", "local", or "huggingface" +EMBEDDING_PROVIDER=local + +# OpenAI Embedding Model (used by LlamaIndex RAG) +OPENAI_EMBEDDING_MODEL=text-embedding-3-small + +# Local Embedding Model (sentence-transformers) +LOCAL_EMBEDDING_MODEL=all-MiniLM-L6-v2 + +# HuggingFace Embedding Model +HUGGINGFACE_EMBEDDING_MODEL=sentence-transformers/all-MiniLM-L6-v2 +``` + +### HuggingFace Configuration + +```bash +# HuggingFace API Token (for inference API) +HUGGINGFACE_API_KEY=your_huggingface_api_key_here +# Or use HF_TOKEN (alternative name) + +# Default HuggingFace Model ID +HUGGINGFACE_MODEL=meta-llama/Llama-3.1-8B-Instruct +``` + +### Web Search Configuration + +```bash +# Web Search Provider: "serper", "searchxng", "brave", "tavily", or "duckduckgo" +# Default: "duckduckgo" (no API key required) +WEB_SEARCH_PROVIDER=duckduckgo + +# Serper API Key (for Google search via Serper) +SERPER_API_KEY=your_serper_api_key_here + +# SearchXNG Host URL +SEARCHXNG_HOST=http://localhost:8080 + +# Brave Search API Key +BRAVE_API_KEY=your_brave_api_key_here + +# Tavily API Key +TAVILY_API_KEY=your_tavily_api_key_here +``` + +### PubMed Configuration + +```bash +# NCBI API Key (optional, for higher rate limits: 10 req/sec vs 3 req/sec) +NCBI_API_KEY=your_ncbi_api_key_here +``` + +### Agent Configuration + +```bash +# Maximum iterations per research loop +MAX_ITERATIONS=10 + +# Search timeout in seconds +SEARCH_TIMEOUT=30 + +# Use graph-based execution for research flows +USE_GRAPH_EXECUTION=false +``` + +### Budget & Rate Limiting Configuration + +```bash +# Default token budget per research loop +DEFAULT_TOKEN_LIMIT=100000 + +# Default time limit per research loop (minutes) +DEFAULT_TIME_LIMIT_MINUTES=10 + +# Default iterations limit per research loop +DEFAULT_ITERATIONS_LIMIT=10 +``` + +### RAG Service Configuration + +```bash +# ChromaDB collection name for RAG +RAG_COLLECTION_NAME=deepcritical_evidence + +# Number of top results to retrieve from RAG +RAG_SIMILARITY_TOP_K=5 + +# Automatically ingest evidence into RAG +RAG_AUTO_INGEST=true +``` + +### ChromaDB Configuration + +```bash +# ChromaDB storage path +CHROMA_DB_PATH=./chroma_db + +# Whether to persist ChromaDB to disk +CHROMA_DB_PERSIST=true + +# ChromaDB server host (for remote ChromaDB, optional) +# CHROMA_DB_HOST=localhost + +# ChromaDB server port (for remote ChromaDB, optional) +# CHROMA_DB_PORT=8000 +``` + +### External Services + +```bash +# Modal Token ID (for Modal sandbox execution) +MODAL_TOKEN_ID=your_modal_token_id_here + +# Modal Token Secret +MODAL_TOKEN_SECRET=your_modal_token_secret_here +``` + +### Logging Configuration + +```bash +# Log Level: "DEBUG", "INFO", "WARNING", or "ERROR" +LOG_LEVEL=INFO +``` + +## Configuration Properties + +The `Settings` class provides helpful properties for checking configuration: + +```python +from src.utils.config import settings + +# Check API key availability +settings.has_openai_key # bool +settings.has_anthropic_key # bool +settings.has_huggingface_key # bool +settings.has_any_llm_key # bool + +# Check service availability +settings.modal_available # bool +settings.web_search_available # bool +``` + +## Environment Variables Reference + +### Required (at least one LLM) +- `OPENAI_API_KEY` or `ANTHROPIC_API_KEY` - At least one LLM provider key + +### Optional LLM Providers +- `DEEPSEEK_API_KEY` (Phase 2) +- `OPENROUTER_API_KEY` (Phase 2) +- `GEMINI_API_KEY` (Phase 2) +- `PERPLEXITY_API_KEY` (Phase 2) +- `HUGGINGFACE_API_KEY` or `HF_TOKEN` +- `AZURE_OPENAI_ENDPOINT` (Phase 2) +- `AZURE_OPENAI_DEPLOYMENT` (Phase 2) +- `AZURE_OPENAI_API_KEY` (Phase 2) +- `AZURE_OPENAI_API_VERSION` (Phase 2) +- `LOCAL_MODEL_URL` (Phase 2) + +### Web Search +- `WEB_SEARCH_PROVIDER` (default: "duckduckgo") +- `SERPER_API_KEY` +- `SEARCHXNG_HOST` +- `BRAVE_API_KEY` +- `TAVILY_API_KEY` + +### Embeddings +- `EMBEDDING_PROVIDER` (default: "local") +- `HUGGINGFACE_EMBEDDING_MODEL` (optional) + +### RAG +- `RAG_COLLECTION_NAME` (default: "deepcritical_evidence") +- `RAG_SIMILARITY_TOP_K` (default: 5) +- `RAG_AUTO_INGEST` (default: true) + +### ChromaDB +- `CHROMA_DB_PATH` (default: "./chroma_db") +- `CHROMA_DB_PERSIST` (default: true) +- `CHROMA_DB_HOST` (optional) +- `CHROMA_DB_PORT` (optional) + +### Budget +- `DEFAULT_TOKEN_LIMIT` (default: 100000) +- `DEFAULT_TIME_LIMIT_MINUTES` (default: 10) +- `DEFAULT_ITERATIONS_LIMIT` (default: 10) + +### Other +- `LLM_PROVIDER` (default: "openai") +- `NCBI_API_KEY` (optional) +- `MODAL_TOKEN_ID` (optional) +- `MODAL_TOKEN_SECRET` (optional) +- `MAX_ITERATIONS` (default: 10) +- `LOG_LEVEL` (default: "INFO") +- `USE_GRAPH_EXECUTION` (default: false) + +## Validation + +Settings are validated on load using Pydantic validation: + +- **Type checking**: All fields are strongly typed +- **Range validation**: Numeric fields have min/max constraints +- **Literal validation**: Enum fields only accept specific values +- **Required fields**: API keys are checked when accessed via `get_api_key()` + +## Error Handling + +Configuration errors raise `ConfigurationError`: + +```python +from src.utils.config import settings +from src.utils.exceptions import ConfigurationError + +try: + api_key = settings.get_api_key() +except ConfigurationError as e: + print(f"Configuration error: {e}") +``` + +## Future Enhancements (Phase 2) + +The following configurations are planned for Phase 2: + +1. **Additional LLM Providers**: DeepSeek, OpenRouter, Gemini, Perplexity, Azure OpenAI, Local models +2. **Model Selection**: Reasoning/main/fast model configuration +3. **Service Integration**: Migrate `folder/llm_config.py` to centralized config + +See `CONFIGURATION_ANALYSIS.md` for the complete implementation plan. + + + + + + + + + + + + + diff --git a/docs/architecture/design-patterns.md b/docs/architecture/design-patterns.md new file mode 100644 index 0000000000000000000000000000000000000000..3fff9a0ce1dc7be118c9b328ee06c43f3445c3a6 --- /dev/null +++ b/docs/architecture/design-patterns.md @@ -0,0 +1,1509 @@ +# Design Patterns & Technical Decisions +## Explicit Answers to Architecture Questions + +--- + +## Purpose of This Document + +This document explicitly answers all the "design pattern" questions raised in team discussions. It provides clear technical decisions with rationale. + +--- + +## 1. Primary Architecture Pattern + +### Decision: Orchestrator with Search-Judge Loop + +**Pattern Name**: Iterative Research Orchestrator + +**Structure**: +``` +┌─────────────────────────────────────┐ +│ Research Orchestrator │ +│ ┌───────────────────────────────┐ │ +│ │ Search Strategy Planner │ │ +│ └───────────────────────────────┘ │ +│ ↓ │ +│ ┌───────────────────────────────┐ │ +│ │ Tool Coordinator │ │ +│ │ - PubMed Search │ │ +│ │ - Web Search │ │ +│ │ - Clinical Trials │ │ +│ └───────────────────────────────┘ │ +│ ↓ │ +│ ┌───────────────────────────────┐ │ +│ │ Evidence Aggregator │ │ +│ └───────────────────────────────┘ │ +│ ↓ │ +│ ┌───────────────────────────────┐ │ +│ │ Quality Judge │ │ +│ │ (LLM-based assessment) │ │ +│ └───────────────────────────────┘ │ +│ ↓ │ +│ Loop or Synthesize? │ +│ ↓ │ +│ ┌───────────────────────────────┐ │ +│ │ Report Generator │ │ +│ └───────────────────────────────┘ │ +└─────────────────────────────────────┘ +``` + +**Why NOT single-agent?** +- Need coordinated multi-tool queries +- Need iterative refinement +- Need quality assessment between searches + +**Why NOT pure ReAct?** +- Medical research requires structured workflow +- Need explicit quality gates +- Want deterministic tool selection + +**Why THIS pattern?** +- Clear separation of concerns +- Testable components +- Easy to debug +- Proven in similar systems + +--- + +## 2. Tool Selection & Orchestration Pattern + +### Decision: Static Tool Registry with Dynamic Selection + +**Pattern**: +```python +class ToolRegistry: + """Central registry of available research tools""" + tools = { + 'pubmed': PubMedSearchTool(), + 'web': WebSearchTool(), + 'trials': ClinicalTrialsTool(), + 'drugs': DrugInfoTool(), + } + +class Orchestrator: + def select_tools(self, question: str, iteration: int) -> List[Tool]: + """Dynamically choose tools based on context""" + if iteration == 0: + # First pass: broad search + return [tools['pubmed'], tools['web']] + else: + # Refinement: targeted search + return self.judge.recommend_tools(question, context) +``` + +**Why NOT on-the-fly agent factories?** +- 6-day timeline (too complex) +- Tools are known upfront +- Simpler to test and debug + +**Why NOT single tool?** +- Need multiple evidence sources +- Different tools for different info types +- Better coverage + +**Why THIS pattern?** +- Balance flexibility vs simplicity +- Tools can be added easily +- Selection logic is transparent + +--- + +## 3. Judge Pattern + +### Decision: Dual-Judge System (Quality + Budget) + +**Pattern**: +```python +class QualityJudge: + """LLM-based evidence quality assessment""" + + def is_sufficient(self, question: str, evidence: List[Evidence]) -> bool: + """Main decision: do we have enough?""" + return ( + self.has_mechanism_explanation(evidence) and + self.has_drug_candidates(evidence) and + self.has_clinical_evidence(evidence) and + self.confidence_score(evidence) > threshold + ) + + def identify_gaps(self, question: str, evidence: List[Evidence]) -> List[str]: + """What's missing?""" + gaps = [] + if not self.has_mechanism_explanation(evidence): + gaps.append("disease mechanism") + if not self.has_drug_candidates(evidence): + gaps.append("potential drug candidates") + if not self.has_clinical_evidence(evidence): + gaps.append("clinical trial data") + return gaps + +class BudgetJudge: + """Resource constraint enforcement""" + + def should_stop(self, state: ResearchState) -> bool: + """Hard limits""" + return ( + state.tokens_used >= max_tokens or + state.iterations >= max_iterations or + state.time_elapsed >= max_time + ) +``` + +**Why NOT just LLM judge?** +- Cost control (prevent runaway queries) +- Time bounds (hackathon demo needs to be fast) +- Safety (prevent infinite loops) + +**Why NOT just token budget?** +- Want early exit when answer is good +- Quality matters, not just quantity +- Better user experience + +**Why THIS pattern?** +- Best of both worlds +- Clear separation (quality vs resources) +- Each judge has single responsibility + +--- + +## 4. Break/Stopping Pattern + +### Decision: Three-Tier Break Conditions + +**Pattern**: +```python +def should_continue(state: ResearchState) -> bool: + """Multi-tier stopping logic""" + + # Tier 1: Quality-based (ideal stop) + if quality_judge.is_sufficient(state.question, state.evidence): + state.stop_reason = "sufficient_evidence" + return False + + # Tier 2: Budget-based (cost control) + if state.tokens_used >= config.max_tokens: + state.stop_reason = "token_budget_exceeded" + return False + + # Tier 3: Iteration-based (safety) + if state.iterations >= config.max_iterations: + state.stop_reason = "max_iterations_reached" + return False + + # Tier 4: Time-based (demo friendly) + if state.time_elapsed >= config.max_time: + state.stop_reason = "timeout" + return False + + return True # Continue researching +``` + +**Configuration**: +```toml +[research.limits] +max_tokens = 50000 # ~$0.50 at Claude pricing +max_iterations = 5 # Reasonable depth +max_time_seconds = 120 # 2 minutes for demo +judge_threshold = 0.8 # Quality confidence score +``` + +**Why multiple conditions?** +- Defense in depth +- Different failure modes +- Graceful degradation + +**Why these specific limits?** +- Tokens: Balances cost vs quality +- Iterations: Enough for refinement, not too deep +- Time: Fast enough for live demo +- Judge: High bar for quality + +--- + +## 5. State Management Pattern + +### Decision: Pydantic State Machine with Checkpoints + +**Pattern**: +```python +class ResearchState(BaseModel): + """Immutable state snapshots""" + query_id: str + question: str + iteration: int = 0 + evidence: List[Evidence] = [] + tokens_used: int = 0 + search_history: List[SearchQuery] = [] + stop_reason: Optional[str] = None + created_at: datetime + updated_at: datetime + +class StateManager: + def save_checkpoint(self, state: ResearchState) -> None: + """Save state to disk""" + path = f".deepresearch/checkpoints/{state.query_id}_iter{state.iteration}.json" + path.write_text(state.model_dump_json(indent=2)) + + def load_checkpoint(self, query_id: str, iteration: int) -> ResearchState: + """Resume from checkpoint""" + path = f".deepresearch/checkpoints/{query_id}_iter{iteration}.json" + return ResearchState.model_validate_json(path.read_text()) +``` + +**Directory Structure**: +``` +.deepresearch/ +├── state/ +│ └── current_123.json # Active research state +├── checkpoints/ +│ ├── query_123_iter0.json # Checkpoint after iteration 0 +│ ├── query_123_iter1.json # Checkpoint after iteration 1 +│ └── query_123_iter2.json # Checkpoint after iteration 2 +└── workspace/ + └── query_123/ + ├── papers/ # Downloaded PDFs + ├── search_results/ # Raw search results + └── analysis/ # Intermediate analysis +``` + +**Why Pydantic?** +- Type safety +- Validation +- Easy serialization +- Integration with Pydantic AI + +**Why checkpoints?** +- Resume interrupted research +- Debugging (inspect state at each iteration) +- Cost savings (don't re-query) +- Demo resilience + +--- + +## 6. Tool Interface Pattern + +### Decision: Async Unified Tool Protocol + +**Pattern**: +```python +from typing import Protocol, Optional, List, Dict +import asyncio + +class ResearchTool(Protocol): + """Standard async interface all tools must implement""" + + async def search( + self, + query: str, + max_results: int = 10, + filters: Optional[Dict] = None + ) -> List[Evidence]: + """Execute search and return structured evidence""" + ... + + def get_metadata(self) -> ToolMetadata: + """Tool capabilities and requirements""" + ... + +class PubMedSearchTool: + """Concrete async implementation""" + + def __init__(self): + self._rate_limiter = asyncio.Semaphore(3) # 3 req/sec + self._cache: Dict[str, List[Evidence]] = {} + + async def search(self, query: str, max_results: int = 10, **kwargs) -> List[Evidence]: + # Check cache first + cache_key = f"{query}:{max_results}" + if cache_key in self._cache: + return self._cache[cache_key] + + async with self._rate_limiter: + # 1. Query PubMed E-utilities API (async httpx) + async with httpx.AsyncClient() as client: + response = await client.get( + "https://eutils.ncbi.nlm.nih.gov/entrez/eutils/esearch.fcgi", + params={"db": "pubmed", "term": query, "retmax": max_results} + ) + # 2. Parse XML response + # 3. Extract: title, abstract, authors, citations + # 4. Convert to Evidence objects + evidence_list = self._parse_response(response.text) + + # Cache results + self._cache[cache_key] = evidence_list + return evidence_list + + def get_metadata(self) -> ToolMetadata: + return ToolMetadata( + name="PubMed", + description="Biomedical literature search", + rate_limit="3 requests/second", + requires_api_key=False + ) +``` + +**Parallel Tool Execution**: +```python +async def search_all_tools(query: str, tools: List[ResearchTool]) -> List[Evidence]: + """Run all tool searches in parallel""" + tasks = [tool.search(query) for tool in tools] + results = await asyncio.gather(*tasks, return_exceptions=True) + + # Flatten and filter errors + evidence = [] + for result in results: + if isinstance(result, Exception): + logger.warning(f"Tool failed: {result}") + else: + evidence.extend(result) + return evidence +``` + +**Why Async?** +- Tools are I/O bound (network calls) +- Parallel execution = faster searches +- Better UX (streaming progress) +- Standard in 2025 Python + +**Why Protocol?** +- Loose coupling +- Easy to add new tools +- Testable with mocks +- Clear contract + +**Why NOT abstract base class?** +- More Pythonic (PEP 544) +- Duck typing friendly +- Runtime checking with isinstance + +--- + +## 7. Report Generation Pattern + +### Decision: Structured Output with Citations + +**Pattern**: +```python +class DrugCandidate(BaseModel): + name: str + mechanism: str + evidence_quality: Literal["strong", "moderate", "weak"] + clinical_status: str # "FDA approved", "Phase 2", etc. + citations: List[Citation] + +class ResearchReport(BaseModel): + query: str + disease_mechanism: str + candidates: List[DrugCandidate] + methodology: str # How we searched + confidence: float + sources_used: List[str] + generated_at: datetime + + def to_markdown(self) -> str: + """Human-readable format""" + ... + + def to_json(self) -> str: + """Machine-readable format""" + ... +``` + +**Output Example**: +```markdown +# Research Report: Long COVID Fatigue + +## Disease Mechanism +Long COVID fatigue is associated with mitochondrial dysfunction +and persistent inflammation [1, 2]. + +## Drug Candidates + +### 1. Coenzyme Q10 (CoQ10) - STRONG EVIDENCE +- **Mechanism**: Mitochondrial support, ATP production +- **Status**: FDA approved (supplement) +- **Evidence**: 2 randomized controlled trials showing fatigue reduction +- **Citations**: + - Smith et al. (2023) - PubMed: 12345678 + - Johnson et al. (2023) - PubMed: 87654321 + +### 2. Low-dose Naltrexone (LDN) - MODERATE EVIDENCE +- **Mechanism**: Anti-inflammatory, immune modulation +- **Status**: FDA approved (different indication) +- **Evidence**: 3 case studies, 1 ongoing Phase 2 trial +- **Citations**: ... + +## Methodology +- Searched PubMed: 45 papers reviewed +- Searched Web: 12 sources +- Clinical trials: 8 trials identified +- Total iterations: 3 +- Tokens used: 12,450 + +## Confidence: 85% + +## Sources +- PubMed E-utilities +- ClinicalTrials.gov +- OpenFDA Database +``` + +**Why structured?** +- Parseable by other systems +- Consistent format +- Easy to validate +- Good for datasets + +**Why markdown?** +- Human-readable +- Renders nicely in Gradio +- Easy to convert to PDF +- Standard format + +--- + +## 8. Error Handling Pattern + +### Decision: Graceful Degradation with Fallbacks + +**Pattern**: +```python +class ResearchAgent: + def research(self, question: str) -> ResearchReport: + try: + return self._research_with_retry(question) + except TokenBudgetExceeded: + # Return partial results + return self._synthesize_partial(state) + except ToolFailure as e: + # Try alternate tools + return self._research_with_fallback(question, failed_tool=e.tool) + except Exception as e: + # Log and return error report + logger.error(f"Research failed: {e}") + return self._error_report(question, error=e) +``` + +**Why NOT fail fast?** +- Hackathon demo must be robust +- Partial results better than nothing +- Good user experience + +**Why NOT silent failures?** +- Need visibility for debugging +- User should know limitations +- Honest about confidence + +--- + +## 9. Configuration Pattern + +### Decision: Hydra-inspired but Simpler + +**Pattern**: +```toml +# config.toml + +[research] +max_iterations = 5 +max_tokens = 50000 +max_time_seconds = 120 +judge_threshold = 0.85 + +[tools] +enabled = ["pubmed", "web", "trials"] + +[tools.pubmed] +max_results = 20 +rate_limit = 3 # per second + +[tools.web] +engine = "serpapi" +max_results = 10 + +[llm] +provider = "anthropic" +model = "claude-3-5-sonnet-20241022" +temperature = 0.1 + +[output] +format = "markdown" +include_citations = true +include_methodology = true +``` + +**Loading**: +```python +from pathlib import Path +import tomllib + +def load_config() -> dict: + config_path = Path("config.toml") + with open(config_path, "rb") as f: + return tomllib.load(f) +``` + +**Why NOT full Hydra?** +- Simpler for hackathon +- Easier to understand +- Faster to modify +- Can upgrade later + +**Why TOML?** +- Human-readable +- Standard (PEP 680) +- Better than YAML edge cases +- Native in Python 3.11+ + +--- + +## 10. Testing Pattern + +### Decision: Three-Level Testing Strategy + +**Pattern**: +```python +# Level 1: Unit tests (fast, isolated) +def test_pubmed_tool(): + tool = PubMedSearchTool() + results = tool.search("aspirin cardiovascular") + assert len(results) > 0 + assert all(isinstance(r, Evidence) for r in results) + +# Level 2: Integration tests (tools + agent) +def test_research_loop(): + agent = ResearchAgent(config=test_config) + report = agent.research("aspirin repurposing") + assert report.candidates + assert report.confidence > 0 + +# Level 3: End-to-end tests (full system) +def test_full_workflow(): + # Simulate user query through Gradio UI + response = gradio_app.predict("test query") + assert "Drug Candidates" in response +``` + +**Why three levels?** +- Fast feedback (unit tests) +- Confidence (integration tests) +- Reality check (e2e tests) + +**Test Data**: +```python +# tests/fixtures/ +- mock_pubmed_response.xml +- mock_web_results.json +- sample_research_query.txt +- expected_report.md +``` + +--- + +## 11. Judge Prompt Templates + +### Decision: Structured JSON Output with Domain-Specific Criteria + +**Quality Judge System Prompt**: +```python +QUALITY_JUDGE_SYSTEM = """You are a medical research quality assessor specializing in drug repurposing. +Your task is to evaluate if collected evidence is sufficient to answer a drug repurposing question. + +You assess evidence against four criteria specific to drug repurposing research: +1. MECHANISM: Understanding of the disease's molecular/cellular mechanisms +2. CANDIDATES: Identification of potential drug candidates with known mechanisms +3. EVIDENCE: Clinical or preclinical evidence supporting repurposing +4. SOURCES: Quality and credibility of sources (peer-reviewed > preprints > web) + +You MUST respond with valid JSON only. No other text.""" +``` + +**Quality Judge User Prompt**: +```python +QUALITY_JUDGE_USER = """ +## Research Question +{question} + +## Evidence Collected (Iteration {iteration} of {max_iterations}) +{evidence_summary} + +## Token Budget +Used: {tokens_used} / {max_tokens} + +## Your Assessment + +Evaluate the evidence and respond with this exact JSON structure: + +```json +{{ + "assessment": {{ + "mechanism_score": <0-10>, + "mechanism_reasoning": "", + "candidates_score": <0-10>, + "candidates_found": ["", "", ...], + "evidence_score": <0-10>, + "evidence_reasoning": "", + "sources_score": <0-10>, + "sources_breakdown": {{ + "peer_reviewed": , + "clinical_trials": , + "preprints": , + "other": + }} + }}, + "overall_confidence": <0.0-1.0>, + "sufficient": , + "gaps": ["", ""], + "recommended_searches": ["", ""], + "recommendation": "" +}} +``` + +Decision rules: +- sufficient=true if overall_confidence >= 0.8 AND mechanism_score >= 6 AND candidates_score >= 6 +- sufficient=true if remaining budget < 10% (must synthesize with what we have) +- Otherwise, provide recommended_searches to fill gaps +""" +``` + +**Report Synthesis Prompt**: +```python +SYNTHESIS_PROMPT = """You are a medical research synthesizer creating a drug repurposing report. + +## Research Question +{question} + +## Collected Evidence +{all_evidence} + +## Judge Assessment +{final_assessment} + +## Your Task +Create a comprehensive research report with this structure: + +1. **Executive Summary** (2-3 sentences) +2. **Disease Mechanism** - What we understand about the condition +3. **Drug Candidates** - For each candidate: + - Drug name and current FDA status + - Proposed mechanism for this condition + - Evidence quality (strong/moderate/weak) + - Key citations +4. **Methodology** - How we searched (tools used, queries, iterations) +5. **Limitations** - What we couldn't find or verify +6. **Confidence Score** - Overall confidence in findings + +Format as Markdown. Include PubMed IDs as citations [PMID: 12345678]. +Be scientifically accurate. Do not hallucinate drug names or mechanisms. +If evidence is weak, say so clearly.""" +``` + +**Why Structured JSON?** +- Parseable by code (not just LLM output) +- Consistent format for logging/debugging +- Can trigger specific actions (continue vs synthesize) +- Testable with expected outputs + +**Why Domain-Specific Criteria?** +- Generic "is this good?" prompts fail +- Drug repurposing has specific requirements +- Physician on team validated criteria +- Maps to real research workflow + +--- + +## 12. MCP Server Integration (Hackathon Track) + +### Decision: Tools as MCP Servers for Reusability + +**Why MCP?** +- Hackathon has dedicated MCP track +- Makes our tools reusable by others +- Standard protocol (Model Context Protocol) +- Future-proof (industry adoption growing) + +**Architecture**: +``` +┌─────────────────────────────────────────────────┐ +│ DeepCritical Agent │ +│ (uses tools directly OR via MCP) │ +└─────────────────────────────────────────────────┘ + │ + ┌────────────┼────────────┐ + ↓ ↓ ↓ +┌─────────────┐ ┌──────────┐ ┌───────────────┐ +│ PubMed MCP │ │ Web MCP │ │ Trials MCP │ +│ Server │ │ Server │ │ Server │ +└─────────────┘ └──────────┘ └───────────────┘ + │ │ │ + ↓ ↓ ↓ + PubMed API Brave/DDG ClinicalTrials.gov +``` + +**PubMed MCP Server Implementation**: +```python +# src/mcp_servers/pubmed_server.py +from fastmcp import FastMCP + +mcp = FastMCP("PubMed Research Tool") + +@mcp.tool() +async def search_pubmed( + query: str, + max_results: int = 10, + date_range: str = "5y" +) -> dict: + """ + Search PubMed for biomedical literature. + + Args: + query: Search terms (supports PubMed syntax like [MeSH]) + max_results: Maximum papers to return (default 10, max 100) + date_range: Time filter - "1y", "5y", "10y", or "all" + + Returns: + dict with papers list containing title, abstract, authors, pmid, date + """ + tool = PubMedSearchTool() + results = await tool.search(query, max_results) + return { + "query": query, + "count": len(results), + "papers": [r.model_dump() for r in results] + } + +@mcp.tool() +async def get_paper_details(pmid: str) -> dict: + """ + Get full details for a specific PubMed paper. + + Args: + pmid: PubMed ID (e.g., "12345678") + + Returns: + Full paper metadata including abstract, MeSH terms, references + """ + tool = PubMedSearchTool() + return await tool.get_details(pmid) + +if __name__ == "__main__": + mcp.run() +``` + +**Running the MCP Server**: +```bash +# Start the server +python -m src.mcp_servers.pubmed_server + +# Or with uvx (recommended) +uvx fastmcp run src/mcp_servers/pubmed_server.py + +# Note: fastmcp uses stdio transport by default, which is perfect +# for local integration with Claude Desktop or the main agent. +``` + +**Claude Desktop Integration** (for demo): +```json +// ~/Library/Application Support/Claude/claude_desktop_config.json +{ + "mcpServers": { + "pubmed": { + "command": "python", + "args": ["-m", "src.mcp_servers.pubmed_server"], + "cwd": "/path/to/deepcritical" + } + } +} +``` + +**Why FastMCP?** +- Simple decorator syntax +- Handles protocol complexity +- Good docs and examples +- Works with Claude Desktop and API + +**MCP Track Submission Requirements**: +- [ ] At least one tool as MCP server +- [ ] README with setup instructions +- [ ] Demo showing MCP usage +- [ ] Bonus: Multiple tools as MCP servers + +--- + +## 13. Gradio UI Pattern (Hackathon Track) + +### Decision: Streaming Progress with Modern UI + +**Pattern**: +```python +import gradio as gr +from typing import Generator + +def research_with_streaming(question: str) -> Generator[str, None, None]: + """Stream research progress to UI""" + yield "🔍 Starting research...\n\n" + + agent = ResearchAgent() + + async for event in agent.research_stream(question): + match event.type: + case "search_start": + yield f"📚 Searching {event.tool}...\n" + case "search_complete": + yield f"✅ Found {event.count} results from {event.tool}\n" + case "judge_thinking": + yield f"🤔 Evaluating evidence quality...\n" + case "judge_decision": + yield f"📊 Confidence: {event.confidence:.0%}\n" + case "iteration_complete": + yield f"🔄 Iteration {event.iteration} complete\n\n" + case "synthesis_start": + yield f"📝 Generating report...\n" + case "complete": + yield f"\n---\n\n{event.report}" + +# Gradio 5 UI +with gr.Blocks(theme=gr.themes.Soft()) as demo: + gr.Markdown("# 🔬 DeepCritical: Drug Repurposing Research Agent") + gr.Markdown("Ask a question about potential drug repurposing opportunities.") + + with gr.Row(): + with gr.Column(scale=2): + question = gr.Textbox( + label="Research Question", + placeholder="What existing drugs might help treat long COVID fatigue?", + lines=2 + ) + examples = gr.Examples( + examples=[ + "What existing drugs might help treat long COVID fatigue?", + "Find existing drugs that might slow Alzheimer's progression", + "Which diabetes drugs show promise for cancer treatment?" + ], + inputs=question + ) + submit = gr.Button("🚀 Start Research", variant="primary") + + with gr.Column(scale=3): + output = gr.Markdown(label="Research Progress & Report") + + submit.click( + fn=research_with_streaming, + inputs=question, + outputs=output, + ) + +demo.launch() +``` + +**Why Streaming?** +- User sees progress, not loading spinner +- Builds trust (system is working) +- Better UX for long operations +- Gradio 5 native support + +**Why gr.Markdown Output?** +- Research reports are markdown +- Renders citations nicely +- Code blocks for methodology +- Tables for drug comparisons + +--- + +## Summary: Design Decision Table + +| # | Question | Decision | Why | +|---|----------|----------|-----| +| 1 | **Architecture** | Orchestrator with search-judge loop | Clear, testable, proven | +| 2 | **Tools** | Static registry, dynamic selection | Balance flexibility vs simplicity | +| 3 | **Judge** | Dual (quality + budget) | Quality + cost control | +| 4 | **Stopping** | Four-tier conditions | Defense in depth | +| 5 | **State** | Pydantic + checkpoints | Type-safe, resumable | +| 6 | **Tool Interface** | Async Protocol + parallel execution | Fast I/O, modern Python | +| 7 | **Output** | Structured + Markdown | Human & machine readable | +| 8 | **Errors** | Graceful degradation + fallbacks | Robust for demo | +| 9 | **Config** | TOML (Hydra-inspired) | Simple, standard | +| 10 | **Testing** | Three levels | Fast feedback + confidence | +| 11 | **Judge Prompts** | Structured JSON + domain criteria | Parseable, medical-specific | +| 12 | **MCP** | Tools as MCP servers | Hackathon track, reusability | +| 13 | **UI** | Gradio 5 streaming | Progress visibility, modern UX | + +--- + +## Answers to Specific Questions + +### "What's the orchestrator pattern?" +**Answer**: See Section 1 - Iterative Research Orchestrator with search-judge loop + +### "LLM-as-judge or token budget?" +**Answer**: Both - See Section 3 (Dual-Judge System) and Section 4 (Three-Tier Break Conditions) + +### "What's the break pattern?" +**Answer**: See Section 4 - Three stopping conditions: quality threshold, token budget, max iterations + +### "Should we use agent factories?" +**Answer**: No - See Section 2. Static tool registry is simpler for 6-day timeline + +### "How do we handle state?" +**Answer**: See Section 5 - Pydantic state machine with checkpoints + +--- + +## Appendix: Complete Data Models + +```python +# src/deepresearch/models.py +from pydantic import BaseModel, Field +from typing import List, Optional, Literal +from datetime import datetime + +class Citation(BaseModel): + """Reference to a source""" + source_type: Literal["pubmed", "web", "trial", "fda"] + identifier: str # PMID, URL, NCT number, etc. + title: str + authors: Optional[List[str]] = None + date: Optional[str] = None + url: Optional[str] = None + +class Evidence(BaseModel): + """Single piece of evidence from search""" + content: str + source: Citation + relevance_score: float = Field(ge=0, le=1) + evidence_type: Literal["mechanism", "candidate", "clinical", "safety"] + +class DrugCandidate(BaseModel): + """Potential drug for repurposing""" + name: str + generic_name: Optional[str] = None + mechanism: str + current_indications: List[str] + proposed_mechanism: str + evidence_quality: Literal["strong", "moderate", "weak"] + fda_status: str + citations: List[Citation] + +class JudgeAssessment(BaseModel): + """Output from quality judge""" + mechanism_score: int = Field(ge=0, le=10) + candidates_score: int = Field(ge=0, le=10) + evidence_score: int = Field(ge=0, le=10) + sources_score: int = Field(ge=0, le=10) + overall_confidence: float = Field(ge=0, le=1) + sufficient: bool + gaps: List[str] + recommended_searches: List[str] + recommendation: Literal["continue", "synthesize"] + +class ResearchState(BaseModel): + """Complete state of a research session""" + query_id: str + question: str + iteration: int = 0 + evidence: List[Evidence] = [] + assessments: List[JudgeAssessment] = [] + tokens_used: int = 0 + search_history: List[str] = [] + stop_reason: Optional[str] = None + created_at: datetime = Field(default_factory=datetime.utcnow) + updated_at: datetime = Field(default_factory=datetime.utcnow) + +class ResearchReport(BaseModel): + """Final output report""" + query: str + executive_summary: str + disease_mechanism: str + candidates: List[DrugCandidate] + methodology: str + limitations: str + confidence: float + sources_used: int + tokens_used: int + iterations: int + generated_at: datetime = Field(default_factory=datetime.utcnow) + + def to_markdown(self) -> str: + """Render as markdown for Gradio""" + md = f"# Research Report: {self.query}\n\n" + md += f"## Executive Summary\n{self.executive_summary}\n\n" + md += f"## Disease Mechanism\n{self.disease_mechanism}\n\n" + md += "## Drug Candidates\n\n" + for i, drug in enumerate(self.candidates, 1): + md += f"### {i}. {drug.name} - {drug.evidence_quality.upper()} EVIDENCE\n" + md += f"- **Mechanism**: {drug.proposed_mechanism}\n" + md += f"- **FDA Status**: {drug.fda_status}\n" + md += f"- **Current Uses**: {', '.join(drug.current_indications)}\n" + md += f"- **Citations**: {len(drug.citations)} sources\n\n" + md += f"## Methodology\n{self.methodology}\n\n" + md += f"## Limitations\n{self.limitations}\n\n" + md += f"## Confidence: {self.confidence:.0%}\n" + return md +``` + +--- + +## 14. Alternative Frameworks Considered + +We researched major agent frameworks before settling on our stack. Here's why we chose what we chose, and what we'd steal if we're shipping like animals and have time for Gucci upgrades. + +### Frameworks Evaluated + +| Framework | Repo | What It Does | +|-----------|------|--------------| +| **Microsoft AutoGen** | [github.com/microsoft/autogen](https://github.com/microsoft/autogen) | Multi-agent orchestration, complex workflows | +| **Claude Agent SDK** | [github.com/anthropics/claude-agent-sdk-python](https://github.com/anthropics/claude-agent-sdk-python) | Anthropic's official agent framework | +| **Pydantic AI** | [github.com/pydantic/pydantic-ai](https://github.com/pydantic/pydantic-ai) | Type-safe agents, structured outputs | + +### Why NOT AutoGen (Microsoft)? + +**Pros:** +- Battle-tested multi-agent orchestration +- `reflect_on_tool_use` - model reviews its own tool results +- `max_tool_iterations` - built-in iteration limits +- Concurrent tool execution +- Rich ecosystem (AutoGen Studio, benchmarks) + +**Cons for MVP:** +- Heavy dependency tree (50+ packages) +- Complex configuration (YAML + Python) +- Overkill for single-agent search-judge loop +- Learning curve eats into 6-day timeline + +**Verdict:** Great for multi-agent systems. Overkill for our MVP. + +### Why NOT Claude Agent SDK (Anthropic)? + +**Pros:** +- Official Anthropic framework +- Clean `@tool` decorator pattern +- In-process MCP servers (no subprocess) +- Hooks for pre/post tool execution +- Direct Claude Code integration + +**Cons for MVP:** +- Requires Claude Code CLI bundled +- Node.js dependency for some features +- Designed for Claude Code ecosystem, not standalone agents +- Less flexible for custom LLM providers + +**Verdict:** Would be great if we were building ON Claude Code. We're building a standalone agent. + +### Why Pydantic AI + FastMCP (Our Choice) + +**Pros:** +- ✅ Simple, Pythonic API +- ✅ Native async/await +- ✅ Type-safe with Pydantic +- ✅ Works with any LLM provider +- ✅ FastMCP for clean MCP servers +- ✅ Minimal dependencies +- ✅ Can ship MVP in 6 days + +**Cons:** +- Newer framework (less battle-tested) +- Smaller ecosystem +- May need to build more from scratch + +**Verdict:** Right tool for the job. Ship fast, iterate later. + +--- + +## 15. Stretch Goals: Gucci Bangers (If We're Shipping Like Animals) + +If MVP ships early and we're crushing it, here's what we'd steal from other frameworks: + +### Tier 1: Quick Wins (2-4 hours each) + +#### From Claude Agent SDK: `@tool` Decorator Pattern +Replace our Protocol-based tools with cleaner decorators: + +```python +# CURRENT (Protocol-based) +class PubMedSearchTool: + async def search(self, query: str, max_results: int = 10) -> List[Evidence]: + ... + +# UPGRADE (Decorator-based, stolen from Claude SDK) +from claude_agent_sdk import tool + +@tool("search_pubmed", "Search PubMed for biomedical papers", { + "query": str, + "max_results": int +}) +async def search_pubmed(args): + results = await _do_pubmed_search(args["query"], args["max_results"]) + return {"content": [{"type": "text", "text": json.dumps(results)}]} +``` + +**Why it's Gucci:** Cleaner syntax, automatic schema generation, less boilerplate. + +#### From AutoGen: Reflect on Tool Use +Add a reflection step where the model reviews its own tool results: + +```python +# CURRENT: Judge evaluates evidence +assessment = await judge.assess(question, evidence) + +# UPGRADE: Add reflection step (stolen from AutoGen) +class ReflectiveJudge: + async def assess_with_reflection(self, question, evidence, tool_results): + # First pass: raw assessment + initial = await self._assess(question, evidence) + + # Reflection: "Did I use the tools correctly?" + reflection = await self._reflect_on_tool_use(tool_results) + + # Final: combine assessment + reflection + return self._combine(initial, reflection) +``` + +**Why it's Gucci:** Catches tool misuse, improves accuracy, more robust judge. + +### Tier 2: Medium Lifts (4-8 hours each) + +#### From AutoGen: Concurrent Tool Execution +Run multiple tools in parallel with proper error handling: + +```python +# CURRENT: Sequential with asyncio.gather +results = await asyncio.gather(*[tool.search(query) for tool in tools]) + +# UPGRADE: AutoGen-style with cancellation + timeout +from autogen_core import CancellationToken + +async def execute_tools_concurrent(tools, query, timeout=30): + token = CancellationToken() + + async def run_with_timeout(tool): + try: + return await asyncio.wait_for( + tool.search(query, cancellation_token=token), + timeout=timeout + ) + except asyncio.TimeoutError: + token.cancel() # Cancel other tools + return ToolError(f"{tool.name} timed out") + + return await asyncio.gather(*[run_with_timeout(t) for t in tools]) +``` + +**Why it's Gucci:** Proper timeout handling, cancellation propagation, production-ready. + +#### From Claude SDK: Hooks System +Add pre/post hooks for logging, validation, cost tracking: + +```python +# UPGRADE: Hook system (stolen from Claude SDK) +class HookManager: + async def pre_tool_use(self, tool_name, args): + """Called before every tool execution""" + logger.info(f"Calling {tool_name} with {args}") + self.cost_tracker.start_timer() + + async def post_tool_use(self, tool_name, result, duration): + """Called after every tool execution""" + self.cost_tracker.record(tool_name, duration) + if result.is_error: + self.error_tracker.record(tool_name, result.error) +``` + +**Why it's Gucci:** Observability, debugging, cost tracking, production-ready. + +### Tier 3: Big Lifts (Post-Hackathon) + +#### Full AutoGen Integration +If we want multi-agent capabilities later: + +```python +# POST-HACKATHON: Multi-agent drug repurposing +from autogen_agentchat import AssistantAgent, GroupChat + +literature_agent = AssistantAgent( + name="LiteratureReviewer", + tools=[pubmed_search, web_search], + system_message="You search and summarize medical literature." +) + +mechanism_agent = AssistantAgent( + name="MechanismAnalyzer", + tools=[pathway_db, protein_db], + system_message="You analyze disease mechanisms and drug targets." +) + +synthesis_agent = AssistantAgent( + name="ReportSynthesizer", + system_message="You synthesize findings into actionable reports." +) + +# Orchestrate multi-agent workflow +group_chat = GroupChat( + agents=[literature_agent, mechanism_agent, synthesis_agent], + max_round=10 +) +``` + +**Why it's Gucci:** True multi-agent collaboration, specialized roles, scalable. + +--- + +## Priority Order for Stretch Goals + +| Priority | Feature | Source | Effort | Impact | +|----------|---------|--------|--------|--------| +| 1 | `@tool` decorator | Claude SDK | 2 hrs | High - cleaner code | +| 2 | Reflect on tool use | AutoGen | 3 hrs | High - better accuracy | +| 3 | Hooks system | Claude SDK | 4 hrs | Medium - observability | +| 4 | Concurrent + cancellation | AutoGen | 4 hrs | Medium - robustness | +| 5 | Multi-agent | AutoGen | 8+ hrs | Post-hackathon | + +--- + +## The Bottom Line + +``` +┌─────────────────────────────────────────────────────────────┐ +│ MVP (Days 1-4): Pydantic AI + FastMCP │ +│ - Ship working drug repurposing agent │ +│ - Search-judge loop with PubMed + Web │ +│ - Gradio UI with streaming │ +│ - MCP server for hackathon track │ +├─────────────────────────────────────────────────────────────┤ +│ If Crushing It (Days 5-6): Steal the Gucci │ +│ - @tool decorators from Claude SDK │ +│ - Reflect on tool use from AutoGen │ +│ - Hooks for observability │ +├─────────────────────────────────────────────────────────────┤ +│ Post-Hackathon: Full AutoGen Integration │ +│ - Multi-agent workflows │ +│ - Specialized agent roles │ +│ - Production-grade orchestration │ +└─────────────────────────────────────────────────────────────┘ +``` + +**Ship MVP first. Steal bangers if time. Scale later.** + +--- + +## 16. Reference Implementation Resources + +We've cloned production-ready repos into `reference_repos/` that we can vendor, copy from, or just USE directly. This section documents what's available and how to leverage it. + +### Cloned Repositories + +| Repository | Location | What It Provides | +|------------|----------|------------------| +| **pydanticai-research-agent** | `reference_repos/pydanticai-research-agent/` | Complete PydanticAI agent with Brave Search | +| **pubmed-mcp-server** | `reference_repos/pubmed-mcp-server/` | Production-grade PubMed MCP server (TypeScript) | +| **autogen-microsoft** | `reference_repos/autogen-microsoft/` | Microsoft's multi-agent framework | +| **claude-agent-sdk** | `reference_repos/claude-agent-sdk/` | Anthropic's agent SDK with @tool decorator | + +### 🔥 CHEAT CODE: Production PubMed MCP Already Exists + +The `pubmed-mcp-server` is **production-grade** and has EVERYTHING we need: + +```bash +# Already available tools in pubmed-mcp-server: +pubmed_search_articles # Search PubMed with filters, date ranges +pubmed_fetch_contents # Get full article details by PMID +pubmed_article_connections # Find citations, related articles +pubmed_research_agent # Generate research plan outlines +pubmed_generate_chart # Create PNG charts from data +``` + +**Option 1: Use it directly via npx** +```json +{ + "mcpServers": { + "pubmed": { + "command": "npx", + "args": ["@cyanheads/pubmed-mcp-server"], + "env": { "NCBI_API_KEY": "your_key" } + } + } +} +``` + +**Option 2: Vendor the logic into Python** +The TypeScript code in `reference_repos/pubmed-mcp-server/src/` shows exactly how to: +- Construct PubMed E-utilities queries +- Handle rate limiting (3/sec without key, 10/sec with key) +- Parse XML responses +- Extract article metadata + +### PydanticAI Research Agent Patterns + +The `pydanticai-research-agent` repo provides copy-paste patterns: + +**Agent Definition** (`agents/research_agent.py`): +```python +from pydantic_ai import Agent, RunContext +from dataclasses import dataclass + +@dataclass +class ResearchAgentDependencies: + brave_api_key: str + session_id: Optional[str] = None + +research_agent = Agent( + get_llm_model(), + deps_type=ResearchAgentDependencies, + system_prompt=SYSTEM_PROMPT +) + +@research_agent.tool +async def search_web( + ctx: RunContext[ResearchAgentDependencies], + query: str, + max_results: int = 10 +) -> List[Dict[str, Any]]: + """Search with context access via ctx.deps""" + results = await search_web_tool(ctx.deps.brave_api_key, query, max_results) + return results +``` + +**Brave Search Tool** (`tools/brave_search.py`): +```python +async def search_web_tool(api_key: str, query: str, count: int = 10) -> List[Dict]: + headers = {"X-Subscription-Token": api_key, "Accept": "application/json"} + async with httpx.AsyncClient() as client: + response = await client.get( + "https://api.search.brave.com/res/v1/web/search", + headers=headers, + params={"q": query, "count": count}, + timeout=30.0 + ) + # Handle 429 rate limit, 401 auth errors + data = response.json() + return data.get("web", {}).get("results", []) +``` + +**Pydantic Models** (`models/research_models.py`): +```python +class BraveSearchResult(BaseModel): + title: str + url: str + description: str + score: float = Field(ge=0.0, le=1.0) +``` + +### Microsoft Agent Framework Orchestration Patterns + +From [deepwiki.com/microsoft/agent-framework](https://deepwiki.com/microsoft/agent-framework/3.4-workflows-and-orchestration): + +#### Sequential Orchestration +``` +Agent A → Agent B → Agent C (each receives prior outputs) +``` +**Use when:** Tasks have dependencies, results inform next steps. + +#### Concurrent (Fan-out/Fan-in) +``` + ┌→ Agent A ─┐ +Dispatcher ├→ Agent B ─┼→ Aggregator + └→ Agent C ─┘ +``` +**Use when:** Independent tasks can run in parallel, results need consolidation. +**Our use:** Parallel PubMed + Web search. + +#### Handoff Orchestration +``` +Coordinator → routes to → Specialist A, B, or C based on request +``` +**Use when:** Router decides which search strategy based on query type. +**Our use:** Route "mechanism" vs "clinical trial" vs "drug info" queries. + +#### HITL (Human-in-the-Loop) +``` +Agent → RequestInfoEvent → Human validates → Agent continues +``` +**Use when:** Critical judgment points need human validation. +**Our use:** Optional "approve drug candidates before synthesis" step. + +### Recommended Hybrid Pattern for Our Agent + +Based on all the research, here's our recommended implementation: + +``` +┌─────────────────────────────────────────────────────────┐ +│ 1. ROUTER (Handoff Pattern) │ +│ - Analyze query type │ +│ - Choose search strategy │ +├─────────────────────────────────────────────────────────┤ +│ 2. SEARCH (Concurrent Pattern) │ +│ - Fan-out to PubMed + Web in parallel │ +│ - Timeout handling per AutoGen patterns │ +│ - Aggregate results │ +├─────────────────────────────────────────────────────────┤ +│ 3. JUDGE (Sequential + Budget) │ +│ - Quality assessment │ +│ - Token/iteration budget check │ +│ - Recommend: continue or synthesize │ +├─────────────────────────────────────────────────────────┤ +│ 4. SYNTHESIZE (Final Agent) │ +│ - Generate research report │ +│ - Include citations │ +│ - Stream to Gradio UI │ +└─────────────────────────────────────────────────────────┘ +``` + +### Quick Start: Minimal Implementation Path + +**Day 1-2: Core Loop** +1. Copy `search_web_tool` from `pydanticai-research-agent/tools/brave_search.py` +2. Implement PubMed search (reference `pubmed-mcp-server/src/` for E-utilities patterns) +3. Wire up basic search-judge loop + +**Day 3: Judge + State** +1. Implement quality judge with JSON structured output +2. Add budget judge +3. Add Pydantic state management + +**Day 4: UI + MCP** +1. Gradio streaming UI +2. Wrap PubMed tool as FastMCP server + +**Day 5-6: Polish + Deploy** +1. HuggingFace Spaces deployment +2. Demo video +3. Stretch goals if time + +--- + +## 17. External Resources & MCP Servers + +### Available PubMed MCP Servers (Community) + +| Server | Author | Features | Link | +|--------|--------|----------|------| +| **pubmed-mcp-server** | cyanheads | Full E-utilities, research agent, charts | [GitHub](https://github.com/cyanheads/pubmed-mcp-server) | +| **BioMCP** | GenomOncology | PubMed + ClinicalTrials + MyVariant | [GitHub](https://github.com/genomoncology/biomcp) | +| **PubMed-MCP-Server** | JackKuo666 | Basic search, metadata access | [GitHub](https://github.com/JackKuo666/PubMed-MCP-Server) | + +### Web Search Options + +| Tool | Free Tier | API Key | Async Support | +|------|-----------|---------|---------------| +| **Brave Search** | 2000/month | Required | Yes (httpx) | +| **DuckDuckGo** | Unlimited | No | Yes (duckduckgo-search) | +| **SerpAPI** | None | Required | Yes | + +**Recommended:** Start with DuckDuckGo (free, no key), upgrade to Brave for production. + +```python +# DuckDuckGo async search (no API key needed!) +from duckduckgo_search import DDGS + +async def search_ddg(query: str, max_results: int = 10) -> List[Dict]: + with DDGS() as ddgs: + results = list(ddgs.text(query, max_results=max_results)) + return [{"title": r["title"], "url": r["href"], "description": r["body"]} for r in results] +``` + +--- + +**Document Status**: Official Architecture Spec +**Review Score**: 100/100 (Ironclad Gucci Banger Edition) +**Sections**: 17 design patterns + data models appendix + reference repos + stretch goals +**Last Updated**: November 2025 diff --git a/docs/architecture/graph_orchestration.md b/docs/architecture/graph_orchestration.md new file mode 100644 index 0000000000000000000000000000000000000000..7bd82206e8cf305709158d9fdf1e2580ccb1dab9 --- /dev/null +++ b/docs/architecture/graph_orchestration.md @@ -0,0 +1,151 @@ +# Graph Orchestration Architecture + +## Overview + +Phase 4 implements a graph-based orchestration system for research workflows using Pydantic AI agents as nodes. This enables better parallel execution, conditional routing, and state management compared to simple agent chains. + +## Graph Structure + +### Nodes + +Graph nodes represent different stages in the research workflow: + +1. **Agent Nodes**: Execute Pydantic AI agents + - Input: Prompt/query + - Output: Structured or unstructured response + - Examples: `KnowledgeGapAgent`, `ToolSelectorAgent`, `ThinkingAgent` + +2. **State Nodes**: Update or read workflow state + - Input: Current state + - Output: Updated state + - Examples: Update evidence, update conversation history + +3. **Decision Nodes**: Make routing decisions based on conditions + - Input: Current state/results + - Output: Next node ID + - Examples: Continue research vs. complete research + +4. **Parallel Nodes**: Execute multiple nodes concurrently + - Input: List of node IDs + - Output: Aggregated results + - Examples: Parallel iterative research loops + +### Edges + +Edges define transitions between nodes: + +1. **Sequential Edges**: Always traversed (no condition) + - From: Source node + - To: Target node + - Condition: None (always True) + +2. **Conditional Edges**: Traversed based on condition + - From: Source node + - To: Target node + - Condition: Callable that returns bool + - Example: If research complete → go to writer, else → continue loop + +3. **Parallel Edges**: Used for parallel execution branches + - From: Parallel node + - To: Multiple target nodes + - Execution: All targets run concurrently + +## Graph Patterns + +### Iterative Research Graph + +``` +[Input] → [Thinking] → [Knowledge Gap] → [Decision: Complete?] + ↓ No ↓ Yes + [Tool Selector] [Writer] + ↓ + [Execute Tools] → [Loop Back] +``` + +### Deep Research Graph + +``` +[Input] → [Planner] → [Parallel Iterative Loops] → [Synthesizer] + ↓ ↓ ↓ + [Loop1] [Loop2] [Loop3] +``` + +## State Management + +State is managed via `WorkflowState` using `ContextVar` for thread-safe isolation: + +- **Evidence**: Collected evidence from searches +- **Conversation**: Iteration history (gaps, tool calls, findings, thoughts) +- **Embedding Service**: For semantic search + +State transitions occur at state nodes, which update the global workflow state. + +## Execution Flow + +1. **Graph Construction**: Build graph from nodes and edges +2. **Graph Validation**: Ensure graph is valid (no cycles, all nodes reachable) +3. **Graph Execution**: Traverse graph from entry node +4. **Node Execution**: Execute each node based on type +5. **Edge Evaluation**: Determine next node(s) based on edges +6. **Parallel Execution**: Use `asyncio.gather()` for parallel nodes +7. **State Updates**: Update state at state nodes +8. **Event Streaming**: Yield events during execution for UI + +## Conditional Routing + +Decision nodes evaluate conditions and return next node IDs: + +- **Knowledge Gap Decision**: If `research_complete` → writer, else → tool selector +- **Budget Decision**: If budget exceeded → exit, else → continue +- **Iteration Decision**: If max iterations → exit, else → continue + +## Parallel Execution + +Parallel nodes execute multiple nodes concurrently: + +- Each parallel branch runs independently +- Results are aggregated after all branches complete +- State is synchronized after parallel execution +- Errors in one branch don't stop other branches + +## Budget Enforcement + +Budget constraints are enforced at decision nodes: + +- **Token Budget**: Track LLM token usage +- **Time Budget**: Track elapsed time +- **Iteration Budget**: Track iteration count + +If any budget is exceeded, execution routes to exit node. + +## Error Handling + +Errors are handled at multiple levels: + +1. **Node Level**: Catch errors in individual node execution +2. **Graph Level**: Handle errors during graph traversal +3. **State Level**: Rollback state changes on error + +Errors are logged and yield error events for UI. + +## Backward Compatibility + +Graph execution is optional via feature flag: + +- `USE_GRAPH_EXECUTION=true`: Use graph-based execution +- `USE_GRAPH_EXECUTION=false`: Use agent chain execution (existing) + +This allows gradual migration and fallback if needed. + + + + + + + + + + + + + diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md new file mode 100644 index 0000000000000000000000000000000000000000..59467f2896848a2f8e5a9a503e5713bdd5e0d977 --- /dev/null +++ b/docs/architecture/overview.md @@ -0,0 +1,474 @@ +# DeepCritical: Medical Drug Repurposing Research Agent +## Project Overview + +--- + +## Executive Summary + +**DeepCritical** is a deep research agent designed to accelerate medical drug repurposing research by autonomously searching, analyzing, and synthesizing evidence from multiple biomedical databases. + +### The Problem We Solve + +Drug repurposing - finding new therapeutic uses for existing FDA-approved drugs - can take years of manual literature review. Researchers must: +- Search thousands of papers across multiple databases +- Identify molecular mechanisms +- Find relevant clinical trials +- Assess safety profiles +- Synthesize evidence into actionable insights + +**DeepCritical automates this process from hours to minutes.** + +### What Is Drug Repurposing? + +**Simple Explanation:** +Using existing approved drugs to treat NEW diseases they weren't originally designed for. + +**Real Examples:** +- **Viagra** (sildenafil): Originally for heart disease → Now treats erectile dysfunction +- **Thalidomide**: Once banned → Now treats multiple myeloma +- **Aspirin**: Pain reliever → Heart attack prevention +- **Metformin**: Diabetes drug → Being tested for aging/longevity + +**Why It Matters:** +- Faster than developing new drugs (years vs decades) +- Cheaper (known safety profiles) +- Lower risk (already FDA approved) +- Immediate patient benefit potential + +--- + +## Core Use Case + +### Primary Query Type +> "What existing drugs might help treat [disease/condition]?" + +### Example Queries + +1. **Long COVID Fatigue** + - Query: "What existing drugs might help treat long COVID fatigue?" + - Agent searches: PubMed, clinical trials, drug databases + - Output: List of candidate drugs with mechanisms + evidence + citations + +2. **Alzheimer's Disease** + - Query: "Find existing drugs that target beta-amyloid pathways" + - Agent identifies: Disease mechanisms → Drug candidates → Clinical evidence + - Output: Comprehensive research report with drug candidates + +3. **Rare Disease Treatment** + - Query: "What drugs might help with fibrodysplasia ossificans progressiva?" + - Agent finds: Similar conditions → Shared pathways → Potential treatments + - Output: Evidence-based treatment suggestions + +--- + +## System Architecture + +### High-Level Design (Phases 1-8) + +```text +User Query + ↓ +Gradio UI (Phase 4) + ↓ +Magentic Manager (Phase 5) ← LLM-powered coordinator + ├── SearchAgent (Phase 2+5) ←→ PubMed + Web + VectorDB (Phase 6) + ├── HypothesisAgent (Phase 7) ←→ Mechanistic Reasoning + ├── JudgeAgent (Phase 3+5) ←→ Evidence Assessment + └── ReportAgent (Phase 8) ←→ Final Synthesis + ↓ +Structured Research Report +``` + +### Key Components + +1. **Magentic Manager (Orchestrator)** + - LLM-powered multi-agent coordinator + - Dynamic planning and agent selection + - Built-in stall detection and replanning + - Microsoft Agent Framework integration + +2. **SearchAgent (Phase 2+5+6)** + - PubMed E-utilities search + - DuckDuckGo web search + - Semantic search via ChromaDB (Phase 6) + - Evidence deduplication + +3. **HypothesisAgent (Phase 7)** + - Generates Drug → Target → Pathway → Effect hypotheses + - Guides targeted searches + - Scientific reasoning about mechanisms + +4. **JudgeAgent (Phase 3+5)** + - LLM-based evidence assessment + - Mechanism score + Clinical score + - Recommends continue/synthesize + - Generates refined search queries + +5. **ReportAgent (Phase 8)** + - Structured scientific reports + - Executive summary, methodology + - Hypotheses tested with evidence counts + - Proper citations and limitations + +6. **Gradio UI (Phase 4)** + - Chat interface for questions + - Real-time progress via events + - Mode toggle (Simple/Magentic) + - Formatted markdown output + +--- + +## Design Patterns + +### 1. Search-and-Judge Loop (Primary Pattern) + +```python +def research(question: str) -> Report: + context = [] + for iteration in range(max_iterations): + # SEARCH: Query relevant tools + results = search_tools(question, context) + context.extend(results) + + # JUDGE: Evaluate quality + if judge.is_sufficient(question, context): + break + + # REFINE: Adjust search strategy + query = refine_query(question, context) + + # SYNTHESIZE: Generate report + return synthesize_report(question, context) +``` + +**Why This Pattern:** +- Simple to implement and debug +- Clear loop termination conditions +- Iterative improvement of search quality +- Balances depth vs speed + +### 2. Multi-Tool Orchestration + +``` +Question → Agent decides which tools to use + ↓ + ┌───┴────┬─────────┬──────────┐ + ↓ ↓ ↓ ↓ + PubMed Web Search Trials DB Drug DB + ↓ ↓ ↓ ↓ + └───┬────┴─────────┴──────────┘ + ↓ + Aggregate Results → Judge +``` + +**Why This Pattern:** +- Different sources provide different evidence types +- Parallel tool execution (when possible) +- Comprehensive coverage + +### 3. LLM-as-Judge with Token Budget + +**Dual Stopping Conditions:** +- **Smart Stop**: LLM judge says "we have sufficient evidence" +- **Hard Stop**: Token budget exhausted OR max iterations reached + +**Why Both:** +- Judge enables early exit when answer is good +- Budget prevents runaway costs +- Iterations prevent infinite loops + +### 4. Stateful Checkpointing + +``` +.deepresearch/ +├── state/ +│ └── query_123.json # Current research state +├── checkpoints/ +│ └── query_123_iter3/ # Checkpoint at iteration 3 +└── workspace/ + └── query_123/ # Downloaded papers, data +``` + +**Why This Pattern:** +- Resume interrupted research +- Debugging and analysis +- Cost savings (don't re-search) + +--- + +## Component Breakdown + +### Agent (Orchestrator) +- **Responsibility**: Coordinate research process +- **Size**: ~100 lines +- **Key Methods**: + - `research(question)` - Main entry point + - `plan_search_strategy()` - Decide what to search + - `execute_search()` - Run tool queries + - `evaluate_progress()` - Call judge + - `synthesize_findings()` - Generate report + +### Tools +- **Responsibility**: Interface with external data sources +- **Size**: ~50 lines per tool +- **Implementations**: + - `PubMedTool` - Search biomedical literature + - `WebSearchTool` - General medical information + - `ClinicalTrialsTool` - Trial data (optional) + - `DrugInfoTool` - FDA drug database (optional) + +### Judge +- **Responsibility**: Evaluate evidence quality +- **Size**: ~50 lines +- **Key Methods**: + - `is_sufficient(question, evidence)` → bool + - `assess_quality(evidence)` → score + - `identify_gaps(question, evidence)` → missing_info + +### Gradio App +- **Responsibility**: User interface +- **Size**: ~50 lines +- **Features**: + - Text input for questions + - Progress indicators + - Formatted output with citations + - Download research report + +--- + +## Technical Stack + +### Core Dependencies +```toml +[dependencies] +python = ">=3.10" +pydantic = "^2.7" +pydantic-ai = "^0.0.16" +fastmcp = "^0.1.0" +gradio = "^5.0" +beautifulsoup4 = "^4.12" +httpx = "^0.27" +``` + +### Optional Enhancements +- `modal` - For GPU-accelerated local LLM +- `fastmcp` - MCP server integration +- `sentence-transformers` - Semantic search +- `faiss-cpu` - Vector similarity + +### Tool APIs & Rate Limits + +| API | Cost | Rate Limit | API Key? | Notes | +|-----|------|------------|----------|-------| +| **PubMed E-utilities** | Free | 3/sec (no key), 10/sec (with key) | Optional | Register at NCBI for higher limits | +| **Brave Search API** | Free tier | 2000/month free | Required | Primary web search | +| **DuckDuckGo** | Free | Unofficial, ~1/sec | No | Fallback web search | +| **ClinicalTrials.gov** | Free | 100/min | No | Stretch goal | +| **OpenFDA** | Free | 240/min (no key), 120K/day (with key) | Optional | Drug info | + +**Web Search Strategy (Priority Order):** +1. **Brave Search API** (free tier: 2000 queries/month) - Primary +2. **DuckDuckGo** (unofficial, no API key) - Fallback +3. **SerpAPI** ($50/month) - Only if free options fail + +**Why NOT SerpAPI first?** +- Costs money (hackathon budget = $0) +- Free alternatives work fine for demo +- Can upgrade later if needed + +--- + +## Success Criteria + +### Phase 1-5 (MVP) ✅ COMPLETE +**Completed in ONE DAY:** +- [x] User can ask drug repurposing question +- [x] Agent searches PubMed (async) +- [x] Agent searches web (DuckDuckGo) +- [x] LLM judge evaluates evidence quality +- [x] System respects token budget and iterations +- [x] Output includes drug candidates + citations +- [x] Works end-to-end for demo query +- [x] Gradio UI with streaming progress +- [x] Magentic multi-agent orchestration +- [x] 38 unit tests passing +- [x] CI/CD pipeline green + +### Hackathon Submission ✅ COMPLETE +- [x] Gradio UI deployed on HuggingFace Spaces +- [x] Example queries working and tested +- [x] Architecture documentation +- [x] README with setup instructions + +### Phase 6-8 (Enhanced) +**Specs ready for implementation:** +- [ ] Embeddings & Semantic Search (Phase 6) +- [ ] Hypothesis Agent (Phase 7) +- [ ] Report Agent (Phase 8) + +### What's EXPLICITLY Out of Scope +**NOT building (to stay focused):** +- ❌ User authentication +- ❌ Database storage of queries +- ❌ Multi-user support +- ❌ Payment/billing +- ❌ Production monitoring +- ❌ Mobile UI + +--- + +## Implementation Timeline + +### Day 1 (Today): Architecture & Setup +- [x] Define use case (drug repurposing) ✅ +- [x] Write architecture docs ✅ +- [ ] Create project structure +- [ ] First PR: Structure + Docs + +### Day 2: Core Agent Loop +- [ ] Implement basic orchestrator +- [ ] Add PubMed search tool +- [ ] Simple judge (keyword-based) +- [ ] Test with 1 query + +### Day 3: Intelligence Layer +- [ ] Upgrade to LLM judge +- [ ] Add web search tool +- [ ] Token budget tracking +- [ ] Test with multiple queries + +### Day 4: UI & Integration +- [ ] Build Gradio interface +- [ ] Wire up agent to UI +- [ ] Add progress indicators +- [ ] Format output nicely + +### Day 5: Polish & Extend +- [ ] Add more tools (clinical trials) +- [ ] Improve judge prompts +- [ ] Checkpoint system +- [ ] Error handling + +### Day 6: Deploy & Document +- [ ] Deploy to HuggingFace Spaces +- [ ] Record demo video +- [ ] Write submission materials +- [ ] Final testing + +--- + +## Questions This Document Answers + +### For The Maintainer + +**Q: "What should our design pattern be?"** +A: Search-and-judge loop with multi-tool orchestration (detailed in Design Patterns section) + +**Q: "Should we use LLM-as-judge or token budget?"** +A: Both - judge for smart stopping, budget for cost control + +**Q: "What's the break pattern?"** +A: Three conditions: judge approval, token limit, or max iterations (whichever comes first) + +**Q: "What components do we need?"** +A: Agent orchestrator, tools (PubMed/web), judge, Gradio UI (see Component Breakdown) + +### For The Team + +**Q: "What are we actually building?"** +A: Medical drug repurposing research agent (see Core Use Case) + +**Q: "How complex should it be?"** +A: Simple but complete - ~300 lines of core code (see Component sizes) + +**Q: "What's the timeline?"** +A: 6 days, MVP by Day 3, polish Days 4-6 (see Implementation Timeline) + +**Q: "What datasets/APIs do we use?"** +A: PubMed (free), web search, clinical trials.gov (see Tool APIs) + +--- + +## Next Steps + +1. **Review this document** - Team feedback on architecture +2. **Finalize design** - Incorporate feedback +3. **Create project structure** - Scaffold repository +4. **Move to proper docs** - `docs/architecture/` folder +5. **Open first PR** - Structure + Documentation +6. **Start implementation** - Day 2 onward + +--- + +## Notes & Decisions + +### Why Drug Repurposing? +- Clear, impressive use case +- Real-world medical impact +- Good data availability (PubMed, trials) +- Easy to explain (Viagra example!) +- Physician on team ✅ + +### Why Simple Architecture? +- 6-day timeline +- Need working end-to-end system +- Hackathon judges value "works" over "complex" +- Can extend later if successful + +### Why These Tools First? +- PubMed: Best biomedical literature source +- Web search: General medical knowledge +- Clinical trials: Evidence of actual testing +- Others: Nice-to-have, not critical for MVP + +--- + +--- + +## Appendix A: Demo Queries (Pre-tested) + +These queries will be used for demo and testing. They're chosen because: +1. They have good PubMed coverage +2. They're medically interesting +3. They show the system's capabilities + +### Primary Demo Query +``` +"What existing drugs might help treat long COVID fatigue?" +``` +**Expected candidates**: CoQ10, Low-dose Naltrexone, Modafinil +**Expected sources**: 20+ PubMed papers, 2-3 clinical trials + +### Secondary Demo Queries +``` +"Find existing drugs that might slow Alzheimer's progression" +"What approved medications could help with fibromyalgia pain?" +"Which diabetes drugs show promise for cancer treatment?" +``` + +### Why These Queries? +- Represent real clinical needs +- Have substantial literature +- Show diverse drug classes +- Physician on team can validate results + +--- + +## Appendix B: Risk Assessment + +| Risk | Likelihood | Impact | Mitigation | +|------|------------|--------|------------| +| PubMed rate limiting | Medium | High | Implement caching, respect 3/sec | +| Web search API fails | Low | Medium | DuckDuckGo fallback | +| LLM costs exceed budget | Medium | Medium | Hard token cap at 50K | +| Judge quality poor | Medium | High | Pre-test prompts, iterate | +| HuggingFace deploy issues | Low | High | Test deployment Day 4 | +| Demo crashes live | Medium | High | Pre-recorded backup video | + +--- + +--- + +**Document Status**: Official Architecture Spec +**Review Score**: 98/100 +**Last Updated**: November 2025 diff --git a/docs/brainstorming/00_ROADMAP_SUMMARY.md b/docs/brainstorming/00_ROADMAP_SUMMARY.md new file mode 100644 index 0000000000000000000000000000000000000000..a67ae6741e446c774485534d2d6a2278d9b44686 --- /dev/null +++ b/docs/brainstorming/00_ROADMAP_SUMMARY.md @@ -0,0 +1,194 @@ +# DeepCritical Data Sources: Roadmap Summary + +**Created**: 2024-11-27 +**Purpose**: Future maintainability and hackathon continuation + +--- + +## Current State + +### Working Tools + +| Tool | Status | Data Quality | +|------|--------|--------------| +| PubMed | ✅ Works | Good (abstracts only) | +| ClinicalTrials.gov | ✅ Works | Good (filtered for interventional) | +| Europe PMC | ✅ Works | Good (includes preprints) | + +### Removed Tools + +| Tool | Status | Reason | +|------|--------|--------| +| bioRxiv | ❌ Removed | No search API - only date/DOI lookup | + +--- + +## Priority Improvements + +### P0: Critical (Do First) + +1. **Add Rate Limiting to PubMed** + - NCBI will block us without it + - Use `limits` library (see reference repo) + - 3/sec without key, 10/sec with key + +### P1: High Value, Medium Effort + +2. **Add OpenAlex as 4th Source** + - Citation network (huge for drug repurposing) + - Concept tagging (semantic discovery) + - Already implemented in reference repo + - Free, no API key + +3. **PubMed Full-Text via BioC** + - Get full paper text for PMC papers + - Already in reference repo + +### P2: Nice to Have + +4. **ClinicalTrials.gov Results** + - Get efficacy data from completed trials + - Requires more complex API calls + +5. **Europe PMC Annotations** + - Text-mined entities (genes, drugs, diseases) + - Automatic entity extraction + +--- + +## Effort Estimates + +| Improvement | Effort | Impact | Priority | +|-------------|--------|--------|----------| +| PubMed rate limiting | 1 hour | Stability | P0 | +| OpenAlex basic search | 2 hours | High | P1 | +| OpenAlex citations | 2 hours | Very High | P1 | +| PubMed full-text | 3 hours | Medium | P1 | +| CT.gov results | 4 hours | Medium | P2 | +| Europe PMC annotations | 3 hours | Medium | P2 | + +--- + +## Architecture Decision + +### Option A: Keep Current + Add OpenAlex + +``` + User Query + ↓ + ┌───────────────────┼───────────────────┐ + ↓ ↓ ↓ + PubMed ClinicalTrials Europe PMC + (abstracts) (trials only) (preprints) + ↓ ↓ ↓ + └───────────────────┼───────────────────┘ + ↓ + OpenAlex ← NEW + (citations, concepts) + ↓ + Orchestrator + ↓ + Report +``` + +**Pros**: Low risk, additive +**Cons**: More complexity, some overlap + +### Option B: OpenAlex as Primary + +``` + User Query + ↓ + ┌───────────────────┼───────────────────┐ + ↓ ↓ ↓ + OpenAlex ClinicalTrials Europe PMC + (primary (trials only) (full-text + search) fallback) + ↓ ↓ ↓ + └───────────────────┼───────────────────┘ + ↓ + Orchestrator + ↓ + Report +``` + +**Pros**: Simpler, citation network built-in +**Cons**: Lose some PubMed-specific features + +### Recommendation: Option A + +Keep current architecture working, add OpenAlex incrementally. + +--- + +## Quick Wins (Can Do Today) + +1. **Add `limits` to `pyproject.toml`** + ```toml + dependencies = [ + "limits>=3.0", + ] + ``` + +2. **Copy OpenAlex tool from reference repo** + - File: `reference_repos/DeepCritical/DeepResearch/src/tools/openalex_tools.py` + - Adapt to our `SearchTool` base class + +3. **Enable NCBI API Key** + - Add to `.env`: `NCBI_API_KEY=your_key` + - 10x rate limit improvement + +--- + +## External Resources Worth Exploring + +### Python Libraries + +| Library | For | Notes | +|---------|-----|-------| +| `limits` | Rate limiting | Used by reference repo | +| `pyalex` | OpenAlex wrapper | [GitHub](https://github.com/J535D165/pyalex) | +| `metapub` | PubMed | Full-featured | +| `sentence-transformers` | Semantic search | For embeddings | + +### APIs Not Yet Used + +| API | Provides | Effort | +|-----|----------|--------| +| RxNorm | Drug name normalization | Low | +| DrugBank | Drug targets/mechanisms | Medium (license) | +| UniProt | Protein data | Medium | +| ChEMBL | Bioactivity data | Medium | + +### RAG Tools (Future) + +| Tool | Purpose | +|------|---------| +| [PaperQA](https://github.com/Future-House/paper-qa) | RAG for scientific papers | +| [txtai](https://github.com/neuml/txtai) | Embeddings + search | +| [PubMedBERT](https://huggingface.co/NeuML/pubmedbert-base-embeddings) | Biomedical embeddings | + +--- + +## Files in This Directory + +| File | Contents | +|------|----------| +| `00_ROADMAP_SUMMARY.md` | This file | +| `01_PUBMED_IMPROVEMENTS.md` | PubMed enhancement details | +| `02_CLINICALTRIALS_IMPROVEMENTS.md` | ClinicalTrials.gov details | +| `03_EUROPEPMC_IMPROVEMENTS.md` | Europe PMC details | +| `04_OPENALEX_INTEGRATION.md` | OpenAlex integration plan | + +--- + +## For Future Maintainers + +If you're picking this up after the hackathon: + +1. **Start with OpenAlex** - biggest bang for buck +2. **Add rate limiting** - prevents API blocks +3. **Don't bother with bioRxiv** - use Europe PMC instead +4. **Reference repo is gold** - `reference_repos/DeepCritical/` has working implementations + +Good luck! 🚀 diff --git a/docs/brainstorming/01_PUBMED_IMPROVEMENTS.md b/docs/brainstorming/01_PUBMED_IMPROVEMENTS.md new file mode 100644 index 0000000000000000000000000000000000000000..6142e17b227eccca82eba26235de9d1e1f4f03b6 --- /dev/null +++ b/docs/brainstorming/01_PUBMED_IMPROVEMENTS.md @@ -0,0 +1,125 @@ +# PubMed Tool: Current State & Future Improvements + +**Status**: Currently Implemented +**Priority**: High (Core Data Source) + +--- + +## Current Implementation + +### What We Have (`src/tools/pubmed.py`) + +- Basic E-utilities search via `esearch.fcgi` and `efetch.fcgi` +- Query preprocessing (strips question words, expands synonyms) +- Returns: title, abstract, authors, journal, PMID +- Rate limiting: None implemented (relying on NCBI defaults) + +### Current Limitations + +1. **No Full-Text Access**: Only retrieves abstracts, not full paper text +2. **No Rate Limiting**: Risk of being blocked by NCBI +3. **No BioC Format**: Missing structured full-text extraction +4. **No Figure Retrieval**: No supplementary materials access +5. **No PMC Integration**: Missing open-access full-text via PMC + +--- + +## Reference Implementation (DeepCritical Reference Repo) + +The reference repo at `reference_repos/DeepCritical/DeepResearch/src/tools/bioinformatics_tools.py` has a more sophisticated implementation: + +### Features We're Missing + +```python +# Rate limiting (lines 47-50) +from limits import parse +from limits.storage import MemoryStorage +from limits.strategies import MovingWindowRateLimiter + +storage = MemoryStorage() +limiter = MovingWindowRateLimiter(storage) +rate_limit = parse("3/second") # NCBI allows 3/sec without API key, 10/sec with + +# Full-text via BioC format (lines 108-120) +def _get_fulltext(pmid: int) -> dict[str, Any] | None: + pmid_url = f"https://www.ncbi.nlm.nih.gov/research/bionlp/RESTful/pmcoa.cgi/BioC_json/{pmid}/unicode" + # Returns structured JSON with full text for open-access papers + +# Figure retrieval via Europe PMC (lines 123-149) +def _get_figures(pmcid: str) -> dict[str, str]: + suppl_url = f"https://www.ebi.ac.uk/europepmc/webservices/rest/{pmcid}/supplementaryFiles" + # Returns base64-encoded images from supplementary materials +``` + +--- + +## Recommended Improvements + +### Phase 1: Rate Limiting (Critical) + +```python +# Add to src/tools/pubmed.py +from limits import parse +from limits.storage import MemoryStorage +from limits.strategies import MovingWindowRateLimiter + +storage = MemoryStorage() +limiter = MovingWindowRateLimiter(storage) + +# With NCBI_API_KEY: 10/sec, without: 3/sec +def get_rate_limit(): + if settings.ncbi_api_key: + return parse("10/second") + return parse("3/second") +``` + +**Dependencies**: `pip install limits` + +### Phase 2: Full-Text Retrieval + +```python +async def get_fulltext(pmid: str) -> str | None: + """Get full text for open-access papers via BioC API.""" + url = f"https://www.ncbi.nlm.nih.gov/research/bionlp/RESTful/pmcoa.cgi/BioC_json/{pmid}/unicode" + # Only works for PMC papers (open access) +``` + +### Phase 3: PMC ID Resolution + +```python +async def get_pmc_id(pmid: str) -> str | None: + """Convert PMID to PMCID for full-text access.""" + url = f"https://www.ncbi.nlm.nih.gov/pmc/utils/idconv/v1.0/?ids={pmid}&format=json" +``` + +--- + +## Python Libraries to Consider + +| Library | Purpose | Notes | +|---------|---------|-------| +| [Biopython](https://biopython.org/) | `Bio.Entrez` module | Official, well-maintained | +| [PyMed](https://pypi.org/project/pymed/) | PubMed wrapper | Simpler API, less control | +| [metapub](https://pypi.org/project/metapub/) | Full-featured | Tested on 1/3 of PubMed | +| [limits](https://pypi.org/project/limits/) | Rate limiting | Used by reference repo | + +--- + +## API Endpoints Reference + +| Endpoint | Purpose | Rate Limit | +|----------|---------|------------| +| `esearch.fcgi` | Search for PMIDs | 3/sec (10 with key) | +| `efetch.fcgi` | Fetch metadata | 3/sec (10 with key) | +| `esummary.fcgi` | Quick metadata | 3/sec (10 with key) | +| `pmcoa.cgi/BioC_json` | Full text (PMC only) | Unknown | +| `idconv/v1.0` | PMID ↔ PMCID | Unknown | + +--- + +## Sources + +- [PubMed E-utilities Documentation](https://www.ncbi.nlm.nih.gov/books/NBK25501/) +- [NCBI BioC API](https://www.ncbi.nlm.nih.gov/research/bionlp/APIs/) +- [Searching PubMed with Python](https://marcobonzanini.com/2015/01/12/searching-pubmed-with-python/) +- [PyMed on PyPI](https://pypi.org/project/pymed/) diff --git a/docs/brainstorming/02_CLINICALTRIALS_IMPROVEMENTS.md b/docs/brainstorming/02_CLINICALTRIALS_IMPROVEMENTS.md new file mode 100644 index 0000000000000000000000000000000000000000..5bf5722bdd16dadc80dd5b984de1185163cdc1f2 --- /dev/null +++ b/docs/brainstorming/02_CLINICALTRIALS_IMPROVEMENTS.md @@ -0,0 +1,193 @@ +# ClinicalTrials.gov Tool: Current State & Future Improvements + +**Status**: Currently Implemented +**Priority**: High (Core Data Source for Drug Repurposing) + +--- + +## Current Implementation + +### What We Have (`src/tools/clinicaltrials.py`) + +- V2 API search via `clinicaltrials.gov/api/v2/studies` +- Filters: `INTERVENTIONAL` study type, `RECRUITING` status +- Returns: NCT ID, title, conditions, interventions, phase, status +- Query preprocessing via shared `query_utils.py` + +### Current Strengths + +1. **Good Filtering**: Already filtering for interventional + recruiting +2. **V2 API**: Using the modern API (v1 deprecated) +3. **Phase Info**: Extracting trial phases for drug development context + +### Current Limitations + +1. **No Outcome Data**: Missing primary/secondary outcomes +2. **No Eligibility Criteria**: Missing inclusion/exclusion details +3. **No Sponsor Info**: Missing who's running the trial +4. **No Result Data**: For completed trials, no efficacy data +5. **Limited Drug Mapping**: No integration with drug databases + +--- + +## API Capabilities We're Not Using + +### Fields We Could Request + +```python +# Current fields +fields = ["NCTId", "BriefTitle", "Condition", "InterventionName", "Phase", "OverallStatus"] + +# Additional valuable fields +additional_fields = [ + "PrimaryOutcomeMeasure", # What are they measuring? + "SecondaryOutcomeMeasure", # Secondary endpoints + "EligibilityCriteria", # Who can participate? + "LeadSponsorName", # Who's funding? + "ResultsFirstPostDate", # Has results? + "StudyFirstPostDate", # When started? + "CompletionDate", # When finished? + "EnrollmentCount", # Sample size + "InterventionDescription", # Drug details + "ArmGroupLabel", # Treatment arms + "InterventionOtherName", # Drug aliases +] +``` + +### Filter Enhancements + +```python +# Current +aggFilters = "studyType:INTERVENTIONAL,status:RECRUITING" + +# Could add +"status:RECRUITING,ACTIVE_NOT_RECRUITING,COMPLETED" # Include completed for results +"phase:PHASE2,PHASE3" # Only later-stage trials +"resultsFirstPostDateRange:2020-01-01_" # Trials with posted results +``` + +--- + +## Recommended Improvements + +### Phase 1: Richer Metadata + +```python +EXTENDED_FIELDS = [ + "NCTId", + "BriefTitle", + "OfficialTitle", + "Condition", + "InterventionName", + "InterventionDescription", + "InterventionOtherName", # Drug synonyms! + "Phase", + "OverallStatus", + "PrimaryOutcomeMeasure", + "EnrollmentCount", + "LeadSponsorName", + "StudyFirstPostDate", +] +``` + +### Phase 2: Results Retrieval + +For completed trials, we can get actual efficacy data: + +```python +async def get_trial_results(nct_id: str) -> dict | None: + """Fetch results for completed trials.""" + url = f"https://clinicaltrials.gov/api/v2/studies/{nct_id}" + params = { + "fields": "ResultsSection", + } + # Returns outcome measures and statistics +``` + +### Phase 3: Drug Name Normalization + +Map intervention names to standard identifiers: + +```python +# Problem: "Metformin", "Metformin HCl", "Glucophage" are the same drug +# Solution: Use RxNorm or DrugBank for normalization + +async def normalize_drug_name(intervention: str) -> str: + """Normalize drug name via RxNorm API.""" + url = f"https://rxnav.nlm.nih.gov/REST/rxcui.json?name={intervention}" + # Returns standardized RxCUI +``` + +--- + +## Integration Opportunities + +### With PubMed + +Cross-reference trials with publications: +```python +# ClinicalTrials.gov provides PMID links +# Can correlate trial results with published papers +``` + +### With DrugBank/ChEMBL + +Map interventions to: +- Mechanism of action +- Known targets +- Adverse effects +- Drug-drug interactions + +--- + +## Python Libraries to Consider + +| Library | Purpose | Notes | +|---------|---------|-------| +| [pytrials](https://pypi.org/project/pytrials/) | CT.gov wrapper | V2 API support unclear | +| [clinicaltrials](https://github.com/ebmdatalab/clinicaltrials-act-tracker) | Data tracking | More for analysis | +| [drugbank-downloader](https://pypi.org/project/drugbank-downloader/) | Drug mapping | Requires license | + +--- + +## API Quirks & Gotchas + +1. **Rate Limiting**: Undocumented, be conservative +2. **Pagination**: Max 1000 results per request +3. **Field Names**: Case-sensitive, camelCase +4. **Empty Results**: Some fields may be null even if requested +5. **Status Changes**: Trials change status frequently + +--- + +## Example Enhanced Query + +```python +async def search_drug_repurposing_trials( + drug_name: str, + condition: str, + include_completed: bool = True, +) -> list[Evidence]: + """Search for trials repurposing a drug for a new condition.""" + + statuses = ["RECRUITING", "ACTIVE_NOT_RECRUITING"] + if include_completed: + statuses.append("COMPLETED") + + params = { + "query.intr": drug_name, + "query.cond": condition, + "filter.overallStatus": ",".join(statuses), + "filter.studyType": "INTERVENTIONAL", + "fields": ",".join(EXTENDED_FIELDS), + "pageSize": 50, + } +``` + +--- + +## Sources + +- [ClinicalTrials.gov API Documentation](https://clinicaltrials.gov/data-api/api) +- [CT.gov Field Definitions](https://clinicaltrials.gov/data-api/about-api/study-data-structure) +- [RxNorm API](https://lhncbc.nlm.nih.gov/RxNav/APIs/api-RxNorm.findRxcuiByString.html) diff --git a/docs/brainstorming/03_EUROPEPMC_IMPROVEMENTS.md b/docs/brainstorming/03_EUROPEPMC_IMPROVEMENTS.md new file mode 100644 index 0000000000000000000000000000000000000000..dfec6cb16ac9d0539b43153e8c12fab206bb3009 --- /dev/null +++ b/docs/brainstorming/03_EUROPEPMC_IMPROVEMENTS.md @@ -0,0 +1,211 @@ +# Europe PMC Tool: Current State & Future Improvements + +**Status**: Currently Implemented (Replaced bioRxiv) +**Priority**: High (Preprint + Open Access Source) + +--- + +## Why Europe PMC Over bioRxiv? + +### bioRxiv API Limitations (Why We Abandoned It) + +1. **No Search API**: Only returns papers by date range or DOI +2. **No Query Capability**: Cannot search for "metformin cancer" +3. **Workaround Required**: Would need to download ALL preprints and build local search +4. **Known Issue**: [Gradio Issue #8861](https://github.com/gradio-app/gradio/issues/8861) documents the limitation + +### Europe PMC Advantages + +1. **Full Search API**: Boolean queries, filters, facets +2. **Aggregates bioRxiv**: Includes bioRxiv, medRxiv content anyway +3. **Includes PubMed**: Also has MEDLINE content +4. **34 Preprint Servers**: Not just bioRxiv +5. **Open Access Focus**: Full-text when available + +--- + +## Current Implementation + +### What We Have (`src/tools/europepmc.py`) + +- REST API search via `europepmc.org/webservices/rest/search` +- Preprint flagging via `firstPublicationDate` heuristics +- Returns: title, abstract, authors, DOI, source +- Marks preprints for transparency + +### Current Limitations + +1. **No Full-Text Retrieval**: Only metadata/abstracts +2. **No Citation Network**: Missing references/citations +3. **No Supplementary Files**: Not fetching figures/data +4. **Basic Preprint Detection**: Heuristic, not explicit flag + +--- + +## Europe PMC API Capabilities + +### Endpoints We Could Use + +| Endpoint | Purpose | Currently Using | +|----------|---------|-----------------| +| `/search` | Query papers | Yes | +| `/fulltext/{ID}` | Full text (XML/JSON) | No | +| `/{PMCID}/supplementaryFiles` | Figures, data | No | +| `/citations/{ID}` | Who cited this | No | +| `/references/{ID}` | What this cites | No | +| `/annotations` | Text-mined entities | No | + +### Rich Query Syntax + +```python +# Current simple query +query = "metformin cancer" + +# Could use advanced syntax +query = "(TITLE:metformin OR ABSTRACT:metformin) AND (cancer OR oncology)" +query += " AND (SRC:PPR)" # Only preprints +query += " AND (FIRST_PDATE:[2023-01-01 TO 2024-12-31])" # Date range +query += " AND (OPEN_ACCESS:y)" # Only open access +``` + +### Source Filters + +```python +# Filter by source +"SRC:MED" # MEDLINE +"SRC:PMC" # PubMed Central +"SRC:PPR" # Preprints (bioRxiv, medRxiv, etc.) +"SRC:AGR" # Agricola +"SRC:CBA" # Chinese Biological Abstracts +``` + +--- + +## Recommended Improvements + +### Phase 1: Rich Metadata + +```python +# Add to search results +additional_fields = [ + "citedByCount", # Impact indicator + "source", # Explicit source (MED, PMC, PPR) + "isOpenAccess", # Boolean flag + "fullTextUrlList", # URLs for full text + "authorAffiliations", # Institution info + "grantsList", # Funding info +] +``` + +### Phase 2: Full-Text Retrieval + +```python +async def get_fulltext(pmcid: str) -> str | None: + """Get full text for open access papers.""" + # XML format + url = f"https://www.ebi.ac.uk/europepmc/webservices/rest/{pmcid}/fullTextXML" + # Or JSON + url = f"https://www.ebi.ac.uk/europepmc/webservices/rest/{pmcid}/fullTextJSON" +``` + +### Phase 3: Citation Network + +```python +async def get_citations(pmcid: str) -> list[str]: + """Get papers that cite this one.""" + url = f"https://www.ebi.ac.uk/europepmc/webservices/rest/{pmcid}/citations" + +async def get_references(pmcid: str) -> list[str]: + """Get papers this one cites.""" + url = f"https://www.ebi.ac.uk/europepmc/webservices/rest/{pmcid}/references" +``` + +### Phase 4: Text-Mined Annotations + +Europe PMC extracts entities automatically: + +```python +async def get_annotations(pmcid: str) -> dict: + """Get text-mined entities (genes, diseases, drugs).""" + url = f"https://www.ebi.ac.uk/europepmc/annotations_api/annotationsByArticleIds" + params = { + "articleIds": f"PMC:{pmcid}", + "type": "Gene_Proteins,Diseases,Chemicals", + "format": "JSON", + } + # Returns structured entity mentions with positions +``` + +--- + +## Supplementary File Retrieval + +From reference repo (`bioinformatics_tools.py` lines 123-149): + +```python +def get_figures(pmcid: str) -> dict[str, str]: + """Download figures and supplementary files.""" + url = f"https://www.ebi.ac.uk/europepmc/webservices/rest/{pmcid}/supplementaryFiles?includeInlineImage=true" + # Returns ZIP with images, returns base64-encoded +``` + +--- + +## Preprint-Specific Features + +### Identify Preprint Servers + +```python +PREPRINT_SOURCES = { + "PPR": "General preprints", + "bioRxiv": "Biology preprints", + "medRxiv": "Medical preprints", + "chemRxiv": "Chemistry preprints", + "Research Square": "Multi-disciplinary", + "Preprints.org": "MDPI preprints", +} + +# Check if published version exists +async def check_published_version(preprint_doi: str) -> str | None: + """Check if preprint has been peer-reviewed and published.""" + # Europe PMC links preprints to final versions +``` + +--- + +## Rate Limiting + +Europe PMC is more generous than NCBI: + +```python +# No documented hard limit, but be respectful +# Recommend: 10-20 requests/second max +# Use email in User-Agent for polite pool +headers = { + "User-Agent": "DeepCritical/1.0 (mailto:your@email.com)" +} +``` + +--- + +## vs. The Lens & OpenAlex + +| Feature | Europe PMC | The Lens | OpenAlex | +|---------|------------|----------|----------| +| Biomedical Focus | Yes | Partial | Partial | +| Preprints | Yes (34 servers) | Yes | Yes | +| Full Text | PMC papers | Links | No | +| Citations | Yes | Yes | Yes | +| Annotations | Yes (text-mined) | No | No | +| Rate Limits | Generous | Moderate | Very generous | +| API Key | Optional | Required | Optional | + +--- + +## Sources + +- [Europe PMC REST API](https://europepmc.org/RestfulWebService) +- [Europe PMC Annotations API](https://europepmc.org/AnnotationsApi) +- [Europe PMC Articles API](https://europepmc.org/ArticlesApi) +- [rOpenSci medrxivr](https://docs.ropensci.org/medrxivr/) +- [bioRxiv TDM Resources](https://www.biorxiv.org/tdm) diff --git a/docs/brainstorming/04_OPENALEX_INTEGRATION.md b/docs/brainstorming/04_OPENALEX_INTEGRATION.md new file mode 100644 index 0000000000000000000000000000000000000000..3a191e4ed7945003128e15ef866ddfc9a2873568 --- /dev/null +++ b/docs/brainstorming/04_OPENALEX_INTEGRATION.md @@ -0,0 +1,303 @@ +# OpenAlex Integration: The Missing Piece? + +**Status**: NOT Implemented (Candidate for Addition) +**Priority**: HIGH - Could Replace Multiple Tools +**Reference**: Already implemented in `reference_repos/DeepCritical` + +--- + +## What is OpenAlex? + +OpenAlex is a **fully open** index of the global research system: + +- **209M+ works** (papers, books, datasets) +- **2B+ author records** (disambiguated) +- **124K+ venues** (journals, repositories) +- **109K+ institutions** +- **65K+ concepts** (hierarchical, linked to Wikidata) + +**Free. Open. No API key required.** + +--- + +## Why OpenAlex for DeepCritical? + +### Current Architecture + +``` +User Query + ↓ +┌──────────────────────────────────────┐ +│ PubMed ClinicalTrials Europe PMC │ ← 3 separate APIs +└──────────────────────────────────────┘ + ↓ +Orchestrator (deduplicate, judge, synthesize) +``` + +### With OpenAlex + +``` +User Query + ↓ +┌──────────────────────────────────────┐ +│ OpenAlex │ ← Single API +│ (includes PubMed + preprints + │ +│ citations + concepts + authors) │ +└──────────────────────────────────────┘ + ↓ +Orchestrator (enrich with CT.gov for trials) +``` + +**OpenAlex already aggregates**: +- PubMed/MEDLINE +- Crossref +- ORCID +- Unpaywall (open access links) +- Microsoft Academic Graph (legacy) +- Preprint servers + +--- + +## Reference Implementation + +From `reference_repos/DeepCritical/DeepResearch/src/tools/openalex_tools.py`: + +```python +class OpenAlexFetchTool(ToolRunner): + def __init__(self): + super().__init__( + ToolSpec( + name="openalex_fetch", + description="Fetch OpenAlex work or author", + inputs={"entity": "TEXT", "identifier": "TEXT"}, + outputs={"result": "JSON"}, + ) + ) + + def run(self, params: dict[str, Any]) -> ExecutionResult: + entity = params["entity"] # "works", "authors", "venues" + identifier = params["identifier"] + base = "https://api.openalex.org" + url = f"{base}/{entity}/{identifier}" + resp = requests.get(url, timeout=30) + return ExecutionResult(success=True, data={"result": resp.json()}) +``` + +--- + +## OpenAlex API Features + +### Search Works (Papers) + +```python +# Search for metformin + cancer papers +url = "https://api.openalex.org/works" +params = { + "search": "metformin cancer drug repurposing", + "filter": "publication_year:>2020,type:article", + "sort": "cited_by_count:desc", + "per_page": 50, +} +``` + +### Rich Filtering + +```python +# Filter examples +"publication_year:2023" +"type:article" # vs preprint, book, etc. +"is_oa:true" # Open access only +"concepts.id:C71924100" # Papers about "Medicine" +"authorships.institutions.id:I27837315" # From Harvard +"cited_by_count:>100" # Highly cited +"has_fulltext:true" # Full text available +``` + +### What You Get Back + +```json +{ + "id": "W2741809807", + "title": "Metformin: A candidate drug for...", + "publication_year": 2023, + "type": "article", + "cited_by_count": 45, + "is_oa": true, + "primary_location": { + "source": {"display_name": "Nature Medicine"}, + "pdf_url": "https://...", + "landing_page_url": "https://..." + }, + "concepts": [ + {"id": "C71924100", "display_name": "Medicine", "score": 0.95}, + {"id": "C54355233", "display_name": "Pharmacology", "score": 0.88} + ], + "authorships": [ + { + "author": {"id": "A123", "display_name": "John Smith"}, + "institutions": [{"display_name": "Harvard Medical School"}] + } + ], + "referenced_works": ["W123", "W456"], # Citations + "related_works": ["W789", "W012"] # Similar papers +} +``` + +--- + +## Key Advantages Over Current Tools + +### 1. Citation Network (We Don't Have This!) + +```python +# Get papers that cite a work +url = f"https://api.openalex.org/works?filter=cites:{work_id}" + +# Get papers cited by a work +# Already in `referenced_works` field +``` + +### 2. Concept Tagging (We Don't Have This!) + +OpenAlex auto-tags papers with hierarchical concepts: +- "Medicine" → "Pharmacology" → "Drug Repurposing" +- Can search by concept, not just keywords + +### 3. Author Disambiguation (We Don't Have This!) + +```python +# Find all works by an author +url = f"https://api.openalex.org/works?filter=authorships.author.id:{author_id}" +``` + +### 4. Institution Tracking + +```python +# Find drug repurposing papers from top institutions +url = "https://api.openalex.org/works" +params = { + "search": "drug repurposing", + "filter": "authorships.institutions.id:I27837315", # Harvard +} +``` + +### 5. Related Works + +Each paper comes with `related_works` - semantically similar papers discovered by OpenAlex's ML. + +--- + +## Proposed Implementation + +### New Tool: `src/tools/openalex.py` + +```python +"""OpenAlex search tool for comprehensive scholarly data.""" + +import httpx +from src.tools.base import SearchTool +from src.utils.models import Evidence + +class OpenAlexTool(SearchTool): + """Search OpenAlex for scholarly works with rich metadata.""" + + name = "openalex" + + async def search(self, query: str, max_results: int = 10) -> list[Evidence]: + async with httpx.AsyncClient() as client: + resp = await client.get( + "https://api.openalex.org/works", + params={ + "search": query, + "filter": "type:article,is_oa:true", + "sort": "cited_by_count:desc", + "per_page": max_results, + "mailto": "deepcritical@example.com", # Polite pool + }, + ) + data = resp.json() + + return [ + Evidence( + source="openalex", + title=work["title"], + abstract=work.get("abstract", ""), + url=work["primary_location"]["landing_page_url"], + metadata={ + "cited_by_count": work["cited_by_count"], + "concepts": [c["display_name"] for c in work["concepts"][:5]], + "is_open_access": work["is_oa"], + "pdf_url": work["primary_location"].get("pdf_url"), + }, + ) + for work in data["results"] + ] +``` + +--- + +## Rate Limits + +OpenAlex is **extremely generous**: + +- No hard rate limit documented +- Recommended: <100,000 requests/day +- **Polite pool**: Add `mailto=your@email.com` param for faster responses +- No API key required (optional for priority support) + +--- + +## Should We Add OpenAlex? + +### Arguments FOR + +1. **Already in reference repo** - proven pattern +2. **Richer data** - citations, concepts, authors +3. **Single source** - reduces API complexity +4. **Free & open** - no keys, no limits +5. **Institution adoption** - Leiden, Sorbonne switched to it + +### Arguments AGAINST + +1. **Adds complexity** - another data source +2. **Overlap** - duplicates some PubMed data +3. **Not biomedical-focused** - covers all disciplines +4. **No full text** - still need PMC/Europe PMC for that + +### Recommendation + +**Add OpenAlex as a 4th source**, don't replace existing tools. + +Use it for: +- Citation network analysis +- Concept-based discovery +- High-impact paper finding +- Author/institution tracking + +Keep PubMed, ClinicalTrials, Europe PMC for: +- Authoritative biomedical search +- Clinical trial data +- Full-text access +- Preprint tracking + +--- + +## Implementation Priority + +| Task | Effort | Value | +|------|--------|-------| +| Basic search | Low | High | +| Citation network | Medium | Very High | +| Concept filtering | Low | High | +| Related works | Low | High | +| Author tracking | Medium | Medium | + +--- + +## Sources + +- [OpenAlex Documentation](https://docs.openalex.org) +- [OpenAlex API Overview](https://docs.openalex.org/api) +- [OpenAlex Wikipedia](https://en.wikipedia.org/wiki/OpenAlex) +- [Leiden University Announcement](https://www.leidenranking.com/information/openalex) +- [OpenAlex: A fully-open index (Paper)](https://arxiv.org/abs/2205.01833) diff --git a/docs/brainstorming/implementation/15_PHASE_OPENALEX.md b/docs/brainstorming/implementation/15_PHASE_OPENALEX.md new file mode 100644 index 0000000000000000000000000000000000000000..9fb3afcc752cb37d22bd6c31a3412b4cb002df30 --- /dev/null +++ b/docs/brainstorming/implementation/15_PHASE_OPENALEX.md @@ -0,0 +1,603 @@ +# Phase 15: OpenAlex Integration + +**Priority**: HIGH - Biggest bang for buck +**Effort**: ~2-3 hours +**Dependencies**: None (existing codebase patterns sufficient) + +--- + +## Prerequisites (COMPLETED) + +The following model changes have been implemented to support this integration: + +1. **`SourceName` Literal Updated** (`src/utils/models.py:9`) + ```python + SourceName = Literal["pubmed", "clinicaltrials", "europepmc", "preprint", "openalex"] + ``` + - Without this, `source="openalex"` would fail Pydantic validation + +2. **`Evidence.metadata` Field Added** (`src/utils/models.py:39-42`) + ```python + metadata: dict[str, Any] = Field( + default_factory=dict, + description="Additional metadata (e.g., cited_by_count, concepts, is_open_access)", + ) + ``` + - Required for storing `cited_by_count`, `concepts`, etc. + - Model is still frozen - metadata must be passed at construction time + +3. **`__init__.py` Exports Updated** (`src/tools/__init__.py`) + - All tools are now exported: `ClinicalTrialsTool`, `EuropePMCTool`, `PubMedTool` + - OpenAlexTool should be added here after implementation + +--- + +## Overview + +Add OpenAlex as a 4th data source for comprehensive scholarly data including: +- Citation networks (who cites whom) +- Concept tagging (hierarchical topic classification) +- Author disambiguation +- 209M+ works indexed + +**Why OpenAlex?** +- Free, no API key required +- Already implemented in reference repo +- Provides citation data we don't have +- Aggregates PubMed + preprints + more + +--- + +## TDD Implementation Plan + +### Step 1: Write the Tests First + +**File**: `tests/unit/tools/test_openalex.py` + +```python +"""Tests for OpenAlex search tool.""" + +import pytest +import respx +from httpx import Response + +from src.tools.openalex import OpenAlexTool +from src.utils.models import Evidence + + +class TestOpenAlexTool: + """Test suite for OpenAlex search functionality.""" + + @pytest.fixture + def tool(self) -> OpenAlexTool: + return OpenAlexTool() + + def test_name_property(self, tool: OpenAlexTool) -> None: + """Tool should identify itself as 'openalex'.""" + assert tool.name == "openalex" + + @respx.mock + @pytest.mark.asyncio + async def test_search_returns_evidence(self, tool: OpenAlexTool) -> None: + """Search should return list of Evidence objects.""" + mock_response = { + "results": [ + { + "id": "W2741809807", + "title": "Metformin and cancer: A systematic review", + "publication_year": 2023, + "cited_by_count": 45, + "type": "article", + "is_oa": True, + "primary_location": { + "source": {"display_name": "Nature Medicine"}, + "landing_page_url": "https://doi.org/10.1038/example", + "pdf_url": None, + }, + "abstract_inverted_index": { + "Metformin": [0], + "shows": [1], + "anticancer": [2], + "effects": [3], + }, + "concepts": [ + {"display_name": "Medicine", "score": 0.95}, + {"display_name": "Oncology", "score": 0.88}, + ], + "authorships": [ + { + "author": {"display_name": "John Smith"}, + "institutions": [{"display_name": "Harvard"}], + } + ], + } + ] + } + + respx.get("https://api.openalex.org/works").mock( + return_value=Response(200, json=mock_response) + ) + + results = await tool.search("metformin cancer", max_results=10) + + assert len(results) == 1 + assert isinstance(results[0], Evidence) + assert "Metformin and cancer" in results[0].citation.title + assert results[0].citation.source == "openalex" + + @respx.mock + @pytest.mark.asyncio + async def test_search_empty_results(self, tool: OpenAlexTool) -> None: + """Search with no results should return empty list.""" + respx.get("https://api.openalex.org/works").mock( + return_value=Response(200, json={"results": []}) + ) + + results = await tool.search("xyznonexistentquery123") + assert results == [] + + @respx.mock + @pytest.mark.asyncio + async def test_search_handles_missing_abstract(self, tool: OpenAlexTool) -> None: + """Tool should handle papers without abstracts.""" + mock_response = { + "results": [ + { + "id": "W123", + "title": "Paper without abstract", + "publication_year": 2023, + "cited_by_count": 10, + "type": "article", + "is_oa": False, + "primary_location": { + "source": {"display_name": "Journal"}, + "landing_page_url": "https://example.com", + }, + "abstract_inverted_index": None, + "concepts": [], + "authorships": [], + } + ] + } + + respx.get("https://api.openalex.org/works").mock( + return_value=Response(200, json=mock_response) + ) + + results = await tool.search("test query") + assert len(results) == 1 + assert results[0].content == "" # No abstract + + @respx.mock + @pytest.mark.asyncio + async def test_search_extracts_citation_count(self, tool: OpenAlexTool) -> None: + """Citation count should be in metadata.""" + mock_response = { + "results": [ + { + "id": "W456", + "title": "Highly cited paper", + "publication_year": 2020, + "cited_by_count": 500, + "type": "article", + "is_oa": True, + "primary_location": { + "source": {"display_name": "Science"}, + "landing_page_url": "https://example.com", + }, + "abstract_inverted_index": {"Test": [0]}, + "concepts": [], + "authorships": [], + } + ] + } + + respx.get("https://api.openalex.org/works").mock( + return_value=Response(200, json=mock_response) + ) + + results = await tool.search("highly cited") + assert results[0].metadata["cited_by_count"] == 500 + + @respx.mock + @pytest.mark.asyncio + async def test_search_extracts_concepts(self, tool: OpenAlexTool) -> None: + """Concepts should be extracted for semantic discovery.""" + mock_response = { + "results": [ + { + "id": "W789", + "title": "Drug repurposing study", + "publication_year": 2023, + "cited_by_count": 25, + "type": "article", + "is_oa": True, + "primary_location": { + "source": {"display_name": "PLOS ONE"}, + "landing_page_url": "https://example.com", + }, + "abstract_inverted_index": {"Drug": [0], "repurposing": [1]}, + "concepts": [ + {"display_name": "Pharmacology", "score": 0.92}, + {"display_name": "Drug Discovery", "score": 0.85}, + {"display_name": "Medicine", "score": 0.80}, + ], + "authorships": [], + } + ] + } + + respx.get("https://api.openalex.org/works").mock( + return_value=Response(200, json=mock_response) + ) + + results = await tool.search("drug repurposing") + assert "Pharmacology" in results[0].metadata["concepts"] + assert "Drug Discovery" in results[0].metadata["concepts"] + + @respx.mock + @pytest.mark.asyncio + async def test_search_api_error_raises_search_error( + self, tool: OpenAlexTool + ) -> None: + """API errors should raise SearchError.""" + from src.utils.exceptions import SearchError + + respx.get("https://api.openalex.org/works").mock( + return_value=Response(500, text="Internal Server Error") + ) + + with pytest.raises(SearchError): + await tool.search("test query") + + def test_reconstruct_abstract(self, tool: OpenAlexTool) -> None: + """Test abstract reconstruction from inverted index.""" + inverted_index = { + "Metformin": [0, 5], + "is": [1], + "a": [2], + "diabetes": [3], + "drug": [4], + "effective": [6], + } + abstract = tool._reconstruct_abstract(inverted_index) + assert abstract == "Metformin is a diabetes drug Metformin effective" +``` + +--- + +### Step 2: Create the Implementation + +**File**: `src/tools/openalex.py` + +```python +"""OpenAlex search tool for comprehensive scholarly data.""" + +from typing import Any + +import httpx +from tenacity import retry, stop_after_attempt, wait_exponential + +from src.utils.exceptions import SearchError +from src.utils.models import Citation, Evidence + + +class OpenAlexTool: + """ + Search OpenAlex for scholarly works with rich metadata. + + OpenAlex provides: + - 209M+ scholarly works + - Citation counts and networks + - Concept tagging (hierarchical) + - Author disambiguation + - Open access links + + API Docs: https://docs.openalex.org/ + """ + + BASE_URL = "https://api.openalex.org/works" + + def __init__(self, email: str | None = None) -> None: + """ + Initialize OpenAlex tool. + + Args: + email: Optional email for polite pool (faster responses) + """ + self.email = email or "deepcritical@example.com" + + @property + def name(self) -> str: + return "openalex" + + @retry( + stop=stop_after_attempt(3), + wait=wait_exponential(multiplier=1, min=1, max=10), + reraise=True, + ) + async def search(self, query: str, max_results: int = 10) -> list[Evidence]: + """ + Search OpenAlex for scholarly works. + + Args: + query: Search terms + max_results: Maximum results to return (max 200 per request) + + Returns: + List of Evidence objects with citation metadata + + Raises: + SearchError: If API request fails + """ + params = { + "search": query, + "filter": "type:article", # Only peer-reviewed articles + "sort": "cited_by_count:desc", # Most cited first + "per_page": min(max_results, 200), + "mailto": self.email, # Polite pool for faster responses + } + + async with httpx.AsyncClient(timeout=30.0) as client: + try: + response = await client.get(self.BASE_URL, params=params) + response.raise_for_status() + + data = response.json() + results = data.get("results", []) + + return [self._to_evidence(work) for work in results[:max_results]] + + except httpx.HTTPStatusError as e: + raise SearchError(f"OpenAlex API error: {e}") from e + except httpx.RequestError as e: + raise SearchError(f"OpenAlex connection failed: {e}") from e + + def _to_evidence(self, work: dict[str, Any]) -> Evidence: + """Convert OpenAlex work to Evidence object.""" + title = work.get("title", "Untitled") + pub_year = work.get("publication_year", "Unknown") + cited_by = work.get("cited_by_count", 0) + is_oa = work.get("is_oa", False) + + # Reconstruct abstract from inverted index + abstract_index = work.get("abstract_inverted_index") + abstract = self._reconstruct_abstract(abstract_index) if abstract_index else "" + + # Extract concepts (top 5) + concepts = [ + c.get("display_name", "") + for c in work.get("concepts", [])[:5] + if c.get("display_name") + ] + + # Extract authors (top 5) + authorships = work.get("authorships", []) + authors = [ + a.get("author", {}).get("display_name", "") + for a in authorships[:5] + if a.get("author", {}).get("display_name") + ] + + # Get URL + primary_loc = work.get("primary_location") or {} + url = primary_loc.get("landing_page_url", "") + if not url: + # Fallback to OpenAlex page + work_id = work.get("id", "").replace("https://openalex.org/", "") + url = f"https://openalex.org/{work_id}" + + return Evidence( + content=abstract[:2000], + citation=Citation( + source="openalex", + title=title[:500], + url=url, + date=str(pub_year), + authors=authors, + ), + relevance=min(0.9, 0.5 + (cited_by / 1000)), # Boost by citations + metadata={ + "cited_by_count": cited_by, + "is_open_access": is_oa, + "concepts": concepts, + "pdf_url": primary_loc.get("pdf_url"), + }, + ) + + def _reconstruct_abstract( + self, inverted_index: dict[str, list[int]] + ) -> str: + """ + Reconstruct abstract from OpenAlex inverted index format. + + OpenAlex stores abstracts as {"word": [position1, position2, ...]}. + This rebuilds the original text. + """ + if not inverted_index: + return "" + + # Build position -> word mapping + position_word: dict[int, str] = {} + for word, positions in inverted_index.items(): + for pos in positions: + position_word[pos] = word + + # Reconstruct in order + if not position_word: + return "" + + max_pos = max(position_word.keys()) + words = [position_word.get(i, "") for i in range(max_pos + 1)] + return " ".join(w for w in words if w) +``` + +--- + +### Step 3: Register in Search Handler + +**File**: `src/tools/search_handler.py` (add to imports and tool list) + +```python +# Add import +from src.tools.openalex import OpenAlexTool + +# Add to _create_tools method +def _create_tools(self) -> list[SearchTool]: + return [ + PubMedTool(), + ClinicalTrialsTool(), + EuropePMCTool(), + OpenAlexTool(), # NEW + ] +``` + +--- + +### Step 4: Update `__init__.py` + +**File**: `src/tools/__init__.py` + +```python +from src.tools.openalex import OpenAlexTool + +__all__ = [ + "PubMedTool", + "ClinicalTrialsTool", + "EuropePMCTool", + "OpenAlexTool", # NEW + # ... +] +``` + +--- + +## Demo Script + +**File**: `examples/openalex_demo.py` + +```python +#!/usr/bin/env python3 +"""Demo script to verify OpenAlex integration.""" + +import asyncio +from src.tools.openalex import OpenAlexTool + + +async def main(): + """Run OpenAlex search demo.""" + tool = OpenAlexTool() + + print("=" * 60) + print("OpenAlex Integration Demo") + print("=" * 60) + + # Test 1: Basic drug repurposing search + print("\n[Test 1] Searching for 'metformin cancer drug repurposing'...") + results = await tool.search("metformin cancer drug repurposing", max_results=5) + + for i, evidence in enumerate(results, 1): + print(f"\n--- Result {i} ---") + print(f"Title: {evidence.citation.title}") + print(f"Year: {evidence.citation.date}") + print(f"Citations: {evidence.metadata.get('cited_by_count', 'N/A')}") + print(f"Concepts: {', '.join(evidence.metadata.get('concepts', []))}") + print(f"Open Access: {evidence.metadata.get('is_open_access', False)}") + print(f"URL: {evidence.citation.url}") + if evidence.content: + print(f"Abstract: {evidence.content[:200]}...") + + # Test 2: High-impact papers + print("\n" + "=" * 60) + print("[Test 2] Finding highly-cited papers on 'long COVID treatment'...") + results = await tool.search("long COVID treatment", max_results=3) + + for evidence in results: + print(f"\n- {evidence.citation.title}") + print(f" Citations: {evidence.metadata.get('cited_by_count', 0)}") + + print("\n" + "=" * 60) + print("Demo complete!") + + +if __name__ == "__main__": + asyncio.run(main()) +``` + +--- + +## Verification Checklist + +### Unit Tests +```bash +# Run just OpenAlex tests +uv run pytest tests/unit/tools/test_openalex.py -v + +# Expected: All tests pass +``` + +### Integration Test (Manual) +```bash +# Run demo script with real API +uv run python examples/openalex_demo.py + +# Expected: Real results from OpenAlex API +``` + +### Full Test Suite +```bash +# Ensure nothing broke +make check + +# Expected: All 110+ tests pass, mypy clean +``` + +--- + +## Success Criteria + +1. **Unit tests pass**: All mocked tests in `test_openalex.py` pass +2. **Integration works**: Demo script returns real results +3. **No regressions**: `make check` passes completely +4. **SearchHandler integration**: OpenAlex appears in search results alongside other sources +5. **Citation metadata**: Results include `cited_by_count`, `concepts`, `is_open_access` + +--- + +## Future Enhancements (P2) + +Once basic integration works: + +1. **Citation Network Queries** + ```python + # Get papers citing a specific work + async def get_citing_works(self, work_id: str) -> list[Evidence]: + params = {"filter": f"cites:{work_id}"} + ... + ``` + +2. **Concept-Based Search** + ```python + # Search by OpenAlex concept ID + async def search_by_concept(self, concept_id: str) -> list[Evidence]: + params = {"filter": f"concepts.id:{concept_id}"} + ... + ``` + +3. **Author Tracking** + ```python + # Find all works by an author + async def search_by_author(self, author_id: str) -> list[Evidence]: + params = {"filter": f"authorships.author.id:{author_id}"} + ... + ``` + +--- + +## Notes + +- OpenAlex is **very generous** with rate limits (no documented hard limit) +- Adding `mailto` parameter gives priority access (polite pool) +- Abstract is stored as inverted index - must reconstruct +- Citation count is a good proxy for paper quality/impact +- Consider caching responses for repeated queries diff --git a/docs/brainstorming/implementation/16_PHASE_PUBMED_FULLTEXT.md b/docs/brainstorming/implementation/16_PHASE_PUBMED_FULLTEXT.md new file mode 100644 index 0000000000000000000000000000000000000000..3284012fc70577f0d2cff5666b897c1799942102 --- /dev/null +++ b/docs/brainstorming/implementation/16_PHASE_PUBMED_FULLTEXT.md @@ -0,0 +1,586 @@ +# Phase 16: PubMed Full-Text Retrieval + +**Priority**: MEDIUM - Enhances evidence quality +**Effort**: ~3 hours +**Dependencies**: None (existing PubMed tool sufficient) + +--- + +## Prerequisites (COMPLETED) + +The `Evidence.metadata` field has been added to `src/utils/models.py` to support: +```python +metadata={"has_fulltext": True} +``` + +--- + +## Architecture Decision: Constructor Parameter vs Method Parameter + +**IMPORTANT**: The original spec proposed `include_fulltext` as a method parameter: +```python +# WRONG - SearchHandler won't pass this parameter +async def search(self, query: str, max_results: int = 10, include_fulltext: bool = False): +``` + +**Problem**: `SearchHandler` calls `tool.search(query, max_results)` uniformly across all tools. +It has no mechanism to pass tool-specific parameters like `include_fulltext`. + +**Solution**: Use constructor parameter instead: +```python +# CORRECT - Configured at instantiation time +class PubMedTool: + def __init__(self, api_key: str | None = None, include_fulltext: bool = False): + self.include_fulltext = include_fulltext + ... +``` + +This way, you can create a full-text-enabled PubMed tool: +```python +# In orchestrator or wherever tools are created +tools = [ + PubMedTool(include_fulltext=True), # Full-text enabled + ClinicalTrialsTool(), + EuropePMCTool(), +] +``` + +--- + +## Overview + +Add full-text retrieval for PubMed papers via the BioC API, enabling: +- Complete paper text for open-access PMC papers +- Structured sections (intro, methods, results, discussion) +- Better evidence for LLM synthesis + +**Why Full-Text?** +- Abstracts only give ~200-300 words +- Full text provides detailed methods, results, figures +- Reference repo already has this implemented +- Makes LLM judgments more accurate + +--- + +## TDD Implementation Plan + +### Step 1: Write the Tests First + +**File**: `tests/unit/tools/test_pubmed_fulltext.py` + +```python +"""Tests for PubMed full-text retrieval.""" + +import pytest +import respx +from httpx import Response + +from src.tools.pubmed import PubMedTool + + +class TestPubMedFullText: + """Test suite for PubMed full-text functionality.""" + + @pytest.fixture + def tool(self) -> PubMedTool: + return PubMedTool() + + @respx.mock + @pytest.mark.asyncio + async def test_get_pmc_id_success(self, tool: PubMedTool) -> None: + """Should convert PMID to PMCID for full-text access.""" + mock_response = { + "records": [ + { + "pmid": "12345678", + "pmcid": "PMC1234567", + } + ] + } + + respx.get("https://www.ncbi.nlm.nih.gov/pmc/utils/idconv/v1.0/").mock( + return_value=Response(200, json=mock_response) + ) + + pmcid = await tool.get_pmc_id("12345678") + assert pmcid == "PMC1234567" + + @respx.mock + @pytest.mark.asyncio + async def test_get_pmc_id_not_in_pmc(self, tool: PubMedTool) -> None: + """Should return None if paper not in PMC.""" + mock_response = { + "records": [ + { + "pmid": "12345678", + # No pmcid means not in PMC + } + ] + } + + respx.get("https://www.ncbi.nlm.nih.gov/pmc/utils/idconv/v1.0/").mock( + return_value=Response(200, json=mock_response) + ) + + pmcid = await tool.get_pmc_id("12345678") + assert pmcid is None + + @respx.mock + @pytest.mark.asyncio + async def test_get_fulltext_success(self, tool: PubMedTool) -> None: + """Should retrieve full text for PMC papers.""" + # Mock BioC API response + mock_bioc = { + "documents": [ + { + "passages": [ + { + "infons": {"section_type": "INTRO"}, + "text": "Introduction text here.", + }, + { + "infons": {"section_type": "METHODS"}, + "text": "Methods description here.", + }, + { + "infons": {"section_type": "RESULTS"}, + "text": "Results summary here.", + }, + { + "infons": {"section_type": "DISCUSS"}, + "text": "Discussion and conclusions.", + }, + ] + } + ] + } + + respx.get( + "https://www.ncbi.nlm.nih.gov/research/bionlp/RESTful/pmcoa.cgi/BioC_json/12345678/unicode" + ).mock(return_value=Response(200, json=mock_bioc)) + + fulltext = await tool.get_fulltext("12345678") + + assert fulltext is not None + assert "Introduction text here" in fulltext + assert "Methods description here" in fulltext + assert "Results summary here" in fulltext + + @respx.mock + @pytest.mark.asyncio + async def test_get_fulltext_not_available(self, tool: PubMedTool) -> None: + """Should return None if full text not available.""" + respx.get( + "https://www.ncbi.nlm.nih.gov/research/bionlp/RESTful/pmcoa.cgi/BioC_json/99999999/unicode" + ).mock(return_value=Response(404)) + + fulltext = await tool.get_fulltext("99999999") + assert fulltext is None + + @respx.mock + @pytest.mark.asyncio + async def test_get_fulltext_structured(self, tool: PubMedTool) -> None: + """Should return structured sections dict.""" + mock_bioc = { + "documents": [ + { + "passages": [ + {"infons": {"section_type": "INTRO"}, "text": "Intro..."}, + {"infons": {"section_type": "METHODS"}, "text": "Methods..."}, + {"infons": {"section_type": "RESULTS"}, "text": "Results..."}, + {"infons": {"section_type": "DISCUSS"}, "text": "Discussion..."}, + ] + } + ] + } + + respx.get( + "https://www.ncbi.nlm.nih.gov/research/bionlp/RESTful/pmcoa.cgi/BioC_json/12345678/unicode" + ).mock(return_value=Response(200, json=mock_bioc)) + + sections = await tool.get_fulltext_structured("12345678") + + assert sections is not None + assert "introduction" in sections + assert "methods" in sections + assert "results" in sections + assert "discussion" in sections + + @respx.mock + @pytest.mark.asyncio + async def test_search_with_fulltext_enabled(self) -> None: + """Search should include full text when tool is configured for it.""" + # Create tool WITH full-text enabled via constructor + tool = PubMedTool(include_fulltext=True) + + # Mock esearch + respx.get("https://eutils.ncbi.nlm.nih.gov/entrez/eutils/esearch.fcgi").mock( + return_value=Response( + 200, json={"esearchresult": {"idlist": ["12345678"]}} + ) + ) + + # Mock efetch (abstract) + mock_xml = """ + + + + 12345678 +
+ Test Paper + Short abstract. + Smith +
+
+
+
+ """ + respx.get("https://eutils.ncbi.nlm.nih.gov/entrez/eutils/efetch.fcgi").mock( + return_value=Response(200, text=mock_xml) + ) + + # Mock ID converter + respx.get("https://www.ncbi.nlm.nih.gov/pmc/utils/idconv/v1.0/").mock( + return_value=Response( + 200, json={"records": [{"pmid": "12345678", "pmcid": "PMC1234567"}]} + ) + ) + + # Mock BioC full text + mock_bioc = { + "documents": [ + { + "passages": [ + {"infons": {"section_type": "INTRO"}, "text": "Full intro..."}, + ] + } + ] + } + respx.get( + "https://www.ncbi.nlm.nih.gov/research/bionlp/RESTful/pmcoa.cgi/BioC_json/12345678/unicode" + ).mock(return_value=Response(200, json=mock_bioc)) + + # NOTE: No include_fulltext param - it's set via constructor + results = await tool.search("test", max_results=1) + + assert len(results) == 1 + # Full text should be appended or replace abstract + assert "Full intro" in results[0].content or "Short abstract" in results[0].content +``` + +--- + +### Step 2: Implement Full-Text Methods + +**File**: `src/tools/pubmed.py` (additions to existing class) + +```python +# Add these methods to PubMedTool class + +async def get_pmc_id(self, pmid: str) -> str | None: + """ + Convert PMID to PMCID for full-text access. + + Args: + pmid: PubMed ID + + Returns: + PMCID if paper is in PMC, None otherwise + """ + url = "https://www.ncbi.nlm.nih.gov/pmc/utils/idconv/v1.0/" + params = {"ids": pmid, "format": "json"} + + async with httpx.AsyncClient(timeout=30.0) as client: + try: + response = await client.get(url, params=params) + response.raise_for_status() + data = response.json() + + records = data.get("records", []) + if records and records[0].get("pmcid"): + return records[0]["pmcid"] + return None + + except httpx.HTTPError: + return None + + +async def get_fulltext(self, pmid: str) -> str | None: + """ + Get full text for a PubMed paper via BioC API. + + Only works for open-access papers in PubMed Central. + + Args: + pmid: PubMed ID + + Returns: + Full text as string, or None if not available + """ + url = f"https://www.ncbi.nlm.nih.gov/research/bionlp/RESTful/pmcoa.cgi/BioC_json/{pmid}/unicode" + + async with httpx.AsyncClient(timeout=60.0) as client: + try: + response = await client.get(url) + if response.status_code == 404: + return None + response.raise_for_status() + data = response.json() + + # Extract text from all passages + documents = data.get("documents", []) + if not documents: + return None + + passages = documents[0].get("passages", []) + text_parts = [p.get("text", "") for p in passages if p.get("text")] + + return "\n\n".join(text_parts) if text_parts else None + + except httpx.HTTPError: + return None + + +async def get_fulltext_structured(self, pmid: str) -> dict[str, str] | None: + """ + Get structured full text with sections. + + Args: + pmid: PubMed ID + + Returns: + Dict mapping section names to text, or None if not available + """ + url = f"https://www.ncbi.nlm.nih.gov/research/bionlp/RESTful/pmcoa.cgi/BioC_json/{pmid}/unicode" + + async with httpx.AsyncClient(timeout=60.0) as client: + try: + response = await client.get(url) + if response.status_code == 404: + return None + response.raise_for_status() + data = response.json() + + documents = data.get("documents", []) + if not documents: + return None + + # Map section types to readable names + section_map = { + "INTRO": "introduction", + "METHODS": "methods", + "RESULTS": "results", + "DISCUSS": "discussion", + "CONCL": "conclusion", + "ABSTRACT": "abstract", + } + + sections: dict[str, list[str]] = {} + for passage in documents[0].get("passages", []): + section_type = passage.get("infons", {}).get("section_type", "other") + section_name = section_map.get(section_type, "other") + text = passage.get("text", "") + + if text: + if section_name not in sections: + sections[section_name] = [] + sections[section_name].append(text) + + # Join multiple passages per section + return {k: "\n\n".join(v) for k, v in sections.items()} + + except httpx.HTTPError: + return None +``` + +--- + +### Step 3: Update Constructor and Search Method + +Add full-text flag to constructor and update search to use it: + +```python +class PubMedTool: + """Search tool for PubMed/NCBI.""" + + def __init__( + self, + api_key: str | None = None, + include_fulltext: bool = False, # NEW CONSTRUCTOR PARAM + ) -> None: + self.api_key = api_key or settings.ncbi_api_key + if self.api_key == "your-ncbi-key-here": + self.api_key = None + self._last_request_time = 0.0 + self.include_fulltext = include_fulltext # Store for use in search() + + async def search(self, query: str, max_results: int = 10) -> list[Evidence]: + """ + Search PubMed and return evidence. + + Note: Full-text enrichment is controlled by constructor parameter, + not method parameter, because SearchHandler doesn't pass extra args. + """ + # ... existing search logic ... + + evidence_list = self._parse_pubmed_xml(fetch_resp.text) + + # Optionally enrich with full text (if configured at construction) + if self.include_fulltext: + evidence_list = await self._enrich_with_fulltext(evidence_list) + + return evidence_list + + +async def _enrich_with_fulltext( + self, evidence_list: list[Evidence] +) -> list[Evidence]: + """Attempt to add full text to evidence items.""" + enriched = [] + + for evidence in evidence_list: + # Extract PMID from URL + url = evidence.citation.url + pmid = url.rstrip("/").split("/")[-1] if url else None + + if pmid: + fulltext = await self.get_fulltext(pmid) + if fulltext: + # Replace abstract with full text (truncated) + evidence = Evidence( + content=fulltext[:8000], # Larger limit for full text + citation=evidence.citation, + relevance=evidence.relevance, + metadata={ + **evidence.metadata, + "has_fulltext": True, + }, + ) + + enriched.append(evidence) + + return enriched +``` + +--- + +## Demo Script + +**File**: `examples/pubmed_fulltext_demo.py` + +```python +#!/usr/bin/env python3 +"""Demo script to verify PubMed full-text retrieval.""" + +import asyncio +from src.tools.pubmed import PubMedTool + + +async def main(): + """Run PubMed full-text demo.""" + tool = PubMedTool() + + print("=" * 60) + print("PubMed Full-Text Demo") + print("=" * 60) + + # Test 1: Convert PMID to PMCID + print("\n[Test 1] Converting PMID to PMCID...") + # Use a known open-access paper + test_pmid = "34450029" # Example: COVID-related open-access paper + pmcid = await tool.get_pmc_id(test_pmid) + print(f"PMID {test_pmid} -> PMCID: {pmcid or 'Not in PMC'}") + + # Test 2: Get full text + print("\n[Test 2] Fetching full text...") + if pmcid: + fulltext = await tool.get_fulltext(test_pmid) + if fulltext: + print(f"Full text length: {len(fulltext)} characters") + print(f"Preview: {fulltext[:500]}...") + else: + print("Full text not available") + + # Test 3: Get structured sections + print("\n[Test 3] Fetching structured sections...") + if pmcid: + sections = await tool.get_fulltext_structured(test_pmid) + if sections: + print("Available sections:") + for section, text in sections.items(): + print(f" - {section}: {len(text)} chars") + else: + print("Structured text not available") + + # Test 4: Search with full text + print("\n[Test 4] Search with full-text enrichment...") + results = await tool.search( + "metformin cancer open access", + max_results=3, + include_fulltext=True + ) + + for i, evidence in enumerate(results, 1): + has_ft = evidence.metadata.get("has_fulltext", False) + print(f"\n--- Result {i} ---") + print(f"Title: {evidence.citation.title}") + print(f"Has Full Text: {has_ft}") + print(f"Content Length: {len(evidence.content)} chars") + + print("\n" + "=" * 60) + print("Demo complete!") + + +if __name__ == "__main__": + asyncio.run(main()) +``` + +--- + +## Verification Checklist + +### Unit Tests +```bash +# Run full-text tests +uv run pytest tests/unit/tools/test_pubmed_fulltext.py -v + +# Run all PubMed tests +uv run pytest tests/unit/tools/test_pubmed.py -v + +# Expected: All tests pass +``` + +### Integration Test (Manual) +```bash +# Run demo with real API +uv run python examples/pubmed_fulltext_demo.py + +# Expected: Real full text from PMC papers +``` + +### Full Test Suite +```bash +make check +# Expected: All tests pass, mypy clean +``` + +--- + +## Success Criteria + +1. **ID Conversion works**: PMID -> PMCID conversion successful +2. **Full text retrieval works**: BioC API returns paper text +3. **Structured sections work**: Can get intro/methods/results/discussion separately +4. **Search integration works**: `include_fulltext=True` enriches results +5. **No regressions**: Existing tests still pass +6. **Graceful degradation**: Non-PMC papers still return abstracts + +--- + +## Notes + +- Only ~30% of PubMed papers have full text in PMC +- BioC API has no documented rate limit, but be respectful +- Full text can be very long - truncate appropriately +- Consider caching full text responses (they don't change) +- Timeout should be longer for full text (60s vs 30s) diff --git a/docs/brainstorming/implementation/17_PHASE_RATE_LIMITING.md b/docs/brainstorming/implementation/17_PHASE_RATE_LIMITING.md new file mode 100644 index 0000000000000000000000000000000000000000..322a2c10194be56a40c1cbdbd54bd49ea0b0246c --- /dev/null +++ b/docs/brainstorming/implementation/17_PHASE_RATE_LIMITING.md @@ -0,0 +1,540 @@ +# Phase 17: Rate Limiting with `limits` Library + +**Priority**: P0 CRITICAL - Prevents API blocks +**Effort**: ~1 hour +**Dependencies**: None + +--- + +## CRITICAL: Async Safety Requirements + +**WARNING**: The rate limiter MUST be async-safe. Blocking the event loop will freeze: +- The Gradio UI +- All parallel searches +- The orchestrator + +**Rules**: +1. **NEVER use `time.sleep()`** - Always use `await asyncio.sleep()` +2. **NEVER use blocking while loops** - Use async-aware polling +3. **The `limits` library check is synchronous** - Wrap it carefully + +The implementation below uses a polling pattern that: +- Checks the limit (synchronous, fast) +- If exceeded, `await asyncio.sleep()` (non-blocking) +- Retry the check + +**Alternative**: If `limits` proves problematic, use `aiolimiter` which is pure-async. + +--- + +## Overview + +Replace naive `asyncio.sleep` rate limiting with proper rate limiter using the `limits` library, which provides: +- Moving window rate limiting +- Per-API configurable limits +- Thread-safe storage +- Already used in reference repo + +**Why This Matters?** +- NCBI will block us without proper rate limiting (3/sec without key, 10/sec with) +- Current implementation only has simple sleep delay +- Need coordinated limits across all PubMed calls +- Professional-grade rate limiting prevents production issues + +--- + +## Current State + +### What We Have (`src/tools/pubmed.py:20-21, 34-41`) + +```python +RATE_LIMIT_DELAY = 0.34 # ~3 requests/sec without API key + +async def _rate_limit(self) -> None: + """Enforce NCBI rate limiting.""" + loop = asyncio.get_running_loop() + now = loop.time() + elapsed = now - self._last_request_time + if elapsed < self.RATE_LIMIT_DELAY: + await asyncio.sleep(self.RATE_LIMIT_DELAY - elapsed) + self._last_request_time = loop.time() +``` + +### Problems + +1. **Not shared across instances**: Each `PubMedTool()` has its own counter +2. **Simple delay vs moving window**: Doesn't handle bursts properly +3. **Hardcoded rate**: Doesn't adapt to API key presence +4. **No backoff on 429**: Just retries blindly + +--- + +## TDD Implementation Plan + +### Step 1: Add Dependency + +**File**: `pyproject.toml` + +```toml +dependencies = [ + # ... existing deps ... + "limits>=3.0", +] +``` + +Then run: +```bash +uv sync +``` + +--- + +### Step 2: Write the Tests First + +**File**: `tests/unit/tools/test_rate_limiting.py` + +```python +"""Tests for rate limiting functionality.""" + +import asyncio +import time + +import pytest + +from src.tools.rate_limiter import RateLimiter, get_pubmed_limiter + + +class TestRateLimiter: + """Test suite for rate limiter.""" + + def test_create_limiter_without_api_key(self) -> None: + """Should create 3/sec limiter without API key.""" + limiter = RateLimiter(rate="3/second") + assert limiter.rate == "3/second" + + def test_create_limiter_with_api_key(self) -> None: + """Should create 10/sec limiter with API key.""" + limiter = RateLimiter(rate="10/second") + assert limiter.rate == "10/second" + + @pytest.mark.asyncio + async def test_limiter_allows_requests_under_limit(self) -> None: + """Should allow requests under the rate limit.""" + limiter = RateLimiter(rate="10/second") + + # 3 requests should all succeed immediately + for _ in range(3): + allowed = await limiter.acquire() + assert allowed is True + + @pytest.mark.asyncio + async def test_limiter_blocks_when_exceeded(self) -> None: + """Should wait when rate limit exceeded.""" + limiter = RateLimiter(rate="2/second") + + # First 2 should be instant + await limiter.acquire() + await limiter.acquire() + + # Third should block briefly + start = time.monotonic() + await limiter.acquire() + elapsed = time.monotonic() - start + + # Should have waited ~0.5 seconds (half second window for 2/sec) + assert elapsed >= 0.3 + + @pytest.mark.asyncio + async def test_limiter_resets_after_window(self) -> None: + """Rate limit should reset after time window.""" + limiter = RateLimiter(rate="5/second") + + # Use up the limit + for _ in range(5): + await limiter.acquire() + + # Wait for window to pass + await asyncio.sleep(1.1) + + # Should be allowed again + start = time.monotonic() + await limiter.acquire() + elapsed = time.monotonic() - start + + assert elapsed < 0.1 # Should be nearly instant + + +class TestGetPubmedLimiter: + """Test PubMed-specific limiter factory.""" + + def test_limiter_without_api_key(self) -> None: + """Should return 3/sec limiter without key.""" + limiter = get_pubmed_limiter(api_key=None) + assert "3" in limiter.rate + + def test_limiter_with_api_key(self) -> None: + """Should return 10/sec limiter with key.""" + limiter = get_pubmed_limiter(api_key="my-api-key") + assert "10" in limiter.rate + + def test_limiter_is_singleton(self) -> None: + """Same API key should return same limiter instance.""" + limiter1 = get_pubmed_limiter(api_key="key1") + limiter2 = get_pubmed_limiter(api_key="key1") + assert limiter1 is limiter2 + + def test_different_keys_different_limiters(self) -> None: + """Different API keys should return different limiters.""" + limiter1 = get_pubmed_limiter(api_key="key1") + limiter2 = get_pubmed_limiter(api_key="key2") + # Clear cache for clean test + # Actually, different keys SHOULD share the same limiter + # since we're limiting against the same API + assert limiter1 is limiter2 # Shared NCBI rate limit +``` + +--- + +### Step 3: Create Rate Limiter Module + +**File**: `src/tools/rate_limiter.py` + +```python +"""Rate limiting utilities using the limits library.""" + +import asyncio +from typing import ClassVar + +from limits import RateLimitItem, parse +from limits.storage import MemoryStorage +from limits.strategies import MovingWindowRateLimiter + + +class RateLimiter: + """ + Async-compatible rate limiter using limits library. + + Uses moving window algorithm for smooth rate limiting. + """ + + def __init__(self, rate: str) -> None: + """ + Initialize rate limiter. + + Args: + rate: Rate string like "3/second" or "10/second" + """ + self.rate = rate + self._storage = MemoryStorage() + self._limiter = MovingWindowRateLimiter(self._storage) + self._rate_limit: RateLimitItem = parse(rate) + self._identity = "default" # Single identity for shared limiting + + async def acquire(self, wait: bool = True) -> bool: + """ + Acquire permission to make a request. + + ASYNC-SAFE: Uses asyncio.sleep(), never time.sleep(). + The polling pattern allows other coroutines to run while waiting. + + Args: + wait: If True, wait until allowed. If False, return immediately. + + Returns: + True if allowed, False if not (only when wait=False) + """ + while True: + # Check if we can proceed (synchronous, fast - ~microseconds) + if self._limiter.hit(self._rate_limit, self._identity): + return True + + if not wait: + return False + + # CRITICAL: Use asyncio.sleep(), NOT time.sleep() + # This yields control to the event loop, allowing other + # coroutines (UI, parallel searches) to run + await asyncio.sleep(0.1) + + def reset(self) -> None: + """Reset the rate limiter (for testing).""" + self._storage.reset() + + +# Singleton limiter for PubMed/NCBI +_pubmed_limiter: RateLimiter | None = None + + +def get_pubmed_limiter(api_key: str | None = None) -> RateLimiter: + """ + Get the shared PubMed rate limiter. + + Rate depends on whether API key is provided: + - Without key: 3 requests/second + - With key: 10 requests/second + + Args: + api_key: NCBI API key (optional) + + Returns: + Shared RateLimiter instance + """ + global _pubmed_limiter + + if _pubmed_limiter is None: + rate = "10/second" if api_key else "3/second" + _pubmed_limiter = RateLimiter(rate) + + return _pubmed_limiter + + +def reset_pubmed_limiter() -> None: + """Reset the PubMed limiter (for testing).""" + global _pubmed_limiter + _pubmed_limiter = None + + +# Factory for other APIs +class RateLimiterFactory: + """Factory for creating/getting rate limiters for different APIs.""" + + _limiters: ClassVar[dict[str, RateLimiter]] = {} + + @classmethod + def get(cls, api_name: str, rate: str) -> RateLimiter: + """ + Get or create a rate limiter for an API. + + Args: + api_name: Unique identifier for the API + rate: Rate limit string (e.g., "10/second") + + Returns: + RateLimiter instance (shared for same api_name) + """ + if api_name not in cls._limiters: + cls._limiters[api_name] = RateLimiter(rate) + return cls._limiters[api_name] + + @classmethod + def reset_all(cls) -> None: + """Reset all limiters (for testing).""" + cls._limiters.clear() +``` + +--- + +### Step 4: Update PubMed Tool + +**File**: `src/tools/pubmed.py` (replace rate limiting code) + +```python +# Replace imports and rate limiting + +from src.tools.rate_limiter import get_pubmed_limiter + + +class PubMedTool: + """Search tool for PubMed/NCBI.""" + + BASE_URL = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils" + HTTP_TOO_MANY_REQUESTS = 429 + + def __init__(self, api_key: str | None = None) -> None: + self.api_key = api_key or settings.ncbi_api_key + if self.api_key == "your-ncbi-key-here": + self.api_key = None + # Use shared rate limiter + self._limiter = get_pubmed_limiter(self.api_key) + + async def _rate_limit(self) -> None: + """Enforce NCBI rate limiting using shared limiter.""" + await self._limiter.acquire() + + # ... rest of class unchanged ... +``` + +--- + +### Step 5: Add Rate Limiters for Other APIs + +**File**: `src/tools/clinicaltrials.py` (optional) + +```python +from src.tools.rate_limiter import RateLimiterFactory + + +class ClinicalTrialsTool: + def __init__(self) -> None: + # ClinicalTrials.gov doesn't document limits, but be conservative + self._limiter = RateLimiterFactory.get("clinicaltrials", "5/second") + + async def search(self, query: str, max_results: int = 10) -> list[Evidence]: + await self._limiter.acquire() + # ... rest of method ... +``` + +**File**: `src/tools/europepmc.py` (optional) + +```python +from src.tools.rate_limiter import RateLimiterFactory + + +class EuropePMCTool: + def __init__(self) -> None: + # Europe PMC is generous, but still be respectful + self._limiter = RateLimiterFactory.get("europepmc", "10/second") + + async def search(self, query: str, max_results: int = 10) -> list[Evidence]: + await self._limiter.acquire() + # ... rest of method ... +``` + +--- + +## Demo Script + +**File**: `examples/rate_limiting_demo.py` + +```python +#!/usr/bin/env python3 +"""Demo script to verify rate limiting works correctly.""" + +import asyncio +import time + +from src.tools.rate_limiter import RateLimiter, get_pubmed_limiter, reset_pubmed_limiter +from src.tools.pubmed import PubMedTool + + +async def test_basic_limiter(): + """Test basic rate limiter behavior.""" + print("=" * 60) + print("Rate Limiting Demo") + print("=" * 60) + + # Test 1: Basic limiter + print("\n[Test 1] Testing 3/second limiter...") + limiter = RateLimiter("3/second") + + start = time.monotonic() + for i in range(6): + await limiter.acquire() + elapsed = time.monotonic() - start + print(f" Request {i+1} at {elapsed:.2f}s") + + total = time.monotonic() - start + print(f" Total time for 6 requests: {total:.2f}s (expected ~2s)") + + +async def test_pubmed_limiter(): + """Test PubMed-specific limiter.""" + print("\n[Test 2] Testing PubMed limiter (shared)...") + + reset_pubmed_limiter() # Clean state + + # Without API key: 3/sec + limiter = get_pubmed_limiter(api_key=None) + print(f" Rate without key: {limiter.rate}") + + # Multiple tools should share the same limiter + tool1 = PubMedTool() + tool2 = PubMedTool() + + # Verify they share the limiter + print(f" Tools share limiter: {tool1._limiter is tool2._limiter}") + + +async def test_concurrent_requests(): + """Test rate limiting under concurrent load.""" + print("\n[Test 3] Testing concurrent request limiting...") + + limiter = RateLimiter("5/second") + + async def make_request(i: int): + await limiter.acquire() + return time.monotonic() + + start = time.monotonic() + # Launch 10 concurrent requests + tasks = [make_request(i) for i in range(10)] + times = await asyncio.gather(*tasks) + + # Calculate distribution + relative_times = [t - start for t in times] + print(f" Request times: {[f'{t:.2f}s' for t in sorted(relative_times)]}") + + total = max(relative_times) + print(f" All 10 requests completed in {total:.2f}s (expected ~2s)") + + +async def main(): + await test_basic_limiter() + await test_pubmed_limiter() + await test_concurrent_requests() + + print("\n" + "=" * 60) + print("Demo complete!") + + +if __name__ == "__main__": + asyncio.run(main()) +``` + +--- + +## Verification Checklist + +### Unit Tests +```bash +# Run rate limiting tests +uv run pytest tests/unit/tools/test_rate_limiting.py -v + +# Expected: All tests pass +``` + +### Integration Test (Manual) +```bash +# Run demo +uv run python examples/rate_limiting_demo.py + +# Expected: Requests properly spaced +``` + +### Full Test Suite +```bash +make check +# Expected: All tests pass, mypy clean +``` + +--- + +## Success Criteria + +1. **`limits` library installed**: Dependency added to pyproject.toml +2. **RateLimiter class works**: Can create and use limiters +3. **PubMed uses new limiter**: Shared limiter across instances +4. **Rate adapts to API key**: 3/sec without, 10/sec with +5. **Concurrent requests handled**: Multiple async requests properly queued +6. **No regressions**: All existing tests pass + +--- + +## API Rate Limit Reference + +| API | Without Key | With Key | +|-----|-------------|----------| +| PubMed/NCBI | 3/sec | 10/sec | +| ClinicalTrials.gov | Undocumented (~5/sec safe) | N/A | +| Europe PMC | ~10-20/sec (generous) | N/A | +| OpenAlex | ~100k/day (no per-sec limit) | Faster with `mailto` | + +--- + +## Notes + +- `limits` library uses moving window algorithm (fairer than fixed window) +- Singleton pattern ensures all PubMed calls share the limit +- The factory pattern allows easy extension to other APIs +- Consider adding 429 response detection + exponential backoff +- In production, consider Redis storage for distributed rate limiting diff --git a/docs/brainstorming/implementation/README.md b/docs/brainstorming/implementation/README.md new file mode 100644 index 0000000000000000000000000000000000000000..6df1769754e718014f30f5a452d8366a0d2065c0 --- /dev/null +++ b/docs/brainstorming/implementation/README.md @@ -0,0 +1,143 @@ +# Implementation Plans + +TDD implementation plans based on the brainstorming documents. Each phase is a self-contained vertical slice with tests, implementation, and demo scripts. + +--- + +## Prerequisites (COMPLETED) + +The following foundational changes have been implemented to support all three phases: + +| Change | File | Status | +|--------|------|--------| +| Add `"openalex"` to `SourceName` | `src/utils/models.py:9` | ✅ Done | +| Add `metadata` field to `Evidence` | `src/utils/models.py:39-42` | ✅ Done | +| Export all tools from `__init__.py` | `src/tools/__init__.py` | ✅ Done | + +All 110 tests pass after these changes. + +--- + +## Priority Order + +| Phase | Name | Priority | Effort | Value | +|-------|------|----------|--------|-------| +| **17** | Rate Limiting | P0 CRITICAL | 1 hour | Stability | +| **15** | OpenAlex | HIGH | 2-3 hours | Very High | +| **16** | PubMed Full-Text | MEDIUM | 3 hours | High | + +**Recommended implementation order**: 17 → 15 → 16 + +--- + +## Phase 15: OpenAlex Integration + +**File**: [15_PHASE_OPENALEX.md](./15_PHASE_OPENALEX.md) + +Add OpenAlex as 4th data source for: +- Citation networks (who cites whom) +- Concept tagging (semantic discovery) +- 209M+ scholarly works +- Free, no API key required + +**Quick Start**: +```bash +# Create the tool +touch src/tools/openalex.py +touch tests/unit/tools/test_openalex.py + +# Run tests first (TDD) +uv run pytest tests/unit/tools/test_openalex.py -v + +# Demo +uv run python examples/openalex_demo.py +``` + +--- + +## Phase 16: PubMed Full-Text + +**File**: [16_PHASE_PUBMED_FULLTEXT.md](./16_PHASE_PUBMED_FULLTEXT.md) + +Add full-text retrieval via BioC API for: +- Complete paper text (not just abstracts) +- Structured sections (intro, methods, results) +- Better evidence for LLM synthesis + +**Quick Start**: +```bash +# Add methods to existing pubmed.py +# Tests in test_pubmed_fulltext.py + +# Run tests +uv run pytest tests/unit/tools/test_pubmed_fulltext.py -v + +# Demo +uv run python examples/pubmed_fulltext_demo.py +``` + +--- + +## Phase 17: Rate Limiting + +**File**: [17_PHASE_RATE_LIMITING.md](./17_PHASE_RATE_LIMITING.md) + +Replace naive sleep-based rate limiting with `limits` library for: +- Moving window algorithm +- Shared limits across instances +- Configurable per-API rates +- Production-grade stability + +**Quick Start**: +```bash +# Add dependency +uv add limits + +# Create module +touch src/tools/rate_limiter.py +touch tests/unit/tools/test_rate_limiting.py + +# Run tests +uv run pytest tests/unit/tools/test_rate_limiting.py -v + +# Demo +uv run python examples/rate_limiting_demo.py +``` + +--- + +## TDD Workflow + +Each implementation doc follows this pattern: + +1. **Write tests first** - Define expected behavior +2. **Run tests** - Verify they fail (red) +3. **Implement** - Write minimal code to pass +4. **Run tests** - Verify they pass (green) +5. **Refactor** - Clean up if needed +6. **Demo** - Verify end-to-end with real APIs +7. **`make check`** - Ensure no regressions + +--- + +## Related Brainstorming Docs + +These implementation plans are derived from: + +- [00_ROADMAP_SUMMARY.md](../00_ROADMAP_SUMMARY.md) - Priority overview +- [01_PUBMED_IMPROVEMENTS.md](../01_PUBMED_IMPROVEMENTS.md) - PubMed details +- [02_CLINICALTRIALS_IMPROVEMENTS.md](../02_CLINICALTRIALS_IMPROVEMENTS.md) - CT.gov details +- [03_EUROPEPMC_IMPROVEMENTS.md](../03_EUROPEPMC_IMPROVEMENTS.md) - Europe PMC details +- [04_OPENALEX_INTEGRATION.md](../04_OPENALEX_INTEGRATION.md) - OpenAlex integration + +--- + +## Future Phases (Not Yet Documented) + +Based on brainstorming, these could be added later: + +- **Phase 18**: ClinicalTrials.gov Results Retrieval +- **Phase 19**: Europe PMC Annotations API +- **Phase 20**: Drug Name Normalization (RxNorm) +- **Phase 21**: Citation Network Queries (OpenAlex) +- **Phase 22**: Semantic Search with Embeddings diff --git a/docs/brainstorming/magentic-pydantic/00_SITUATION_AND_PLAN.md b/docs/brainstorming/magentic-pydantic/00_SITUATION_AND_PLAN.md new file mode 100644 index 0000000000000000000000000000000000000000..77c443ae9f605904d9c55de3a729e4c06ac3f226 --- /dev/null +++ b/docs/brainstorming/magentic-pydantic/00_SITUATION_AND_PLAN.md @@ -0,0 +1,189 @@ +# Situation Analysis: Pydantic-AI + Microsoft Agent Framework Integration + +**Date:** November 27, 2025 +**Status:** ACTIVE DECISION REQUIRED +**Risk Level:** HIGH - DO NOT MERGE PR #41 UNTIL RESOLVED + +--- + +## 1. The Problem + +We almost merged a refactor that would have **deleted** multi-agent orchestration capability from the codebase, mistakenly believing pydantic-ai and Microsoft Agent Framework were mutually exclusive. + +**They are not.** They are complementary: +- **pydantic-ai** (Library): Ensures LLM outputs match Pydantic schemas +- **Microsoft Agent Framework** (Framework): Orchestrates multi-agent workflows + +--- + +## 2. Current Branch State + +| Branch | Location | Has Agent Framework? | Has Pydantic-AI Improvements? | Status | +|--------|----------|---------------------|------------------------------|--------| +| `origin/dev` | GitHub | YES | NO | **SAFE - Source of Truth** | +| `huggingface-upstream/dev` | HF Spaces | YES | NO | **SAFE - Same as GitHub** | +| `origin/main` | GitHub | YES | NO | **SAFE** | +| `feat/pubmed-fulltext` | GitHub | NO (deleted) | YES | **DANGER - Has destructive refactor** | +| `refactor/pydantic-unification` | Local | NO (deleted) | YES | **DANGER - Redundant, delete** | +| Local `dev` | Local only | NO (deleted) | YES | **DANGER - NOT PUSHED (thankfully)** | + +### Key Files at Risk + +**On `origin/dev` (PRESERVED):** +```text +src/agents/ +├── analysis_agent.py # StatisticalAnalyzer wrapper +├── hypothesis_agent.py # Hypothesis generation +├── judge_agent.py # JudgeHandler wrapper +├── magentic_agents.py # Multi-agent definitions +├── report_agent.py # Report synthesis +├── search_agent.py # SearchHandler wrapper +├── state.py # Thread-safe state management +└── tools.py # @ai_function decorated tools + +src/orchestrator_magentic.py # Multi-agent orchestrator +src/utils/llm_factory.py # Centralized LLM client factory +``` + +**Deleted in refactor branch (would be lost if merged):** +- All of the above + +--- + +## 3. Target Architecture + +```text +┌─────────────────────────────────────────────────────────────────┐ +│ Microsoft Agent Framework (Orchestration Layer) │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ SearchAgent │→ │ JudgeAgent │→ │ ReportAgent │ │ +│ │ (BaseAgent) │ │ (BaseAgent) │ │ (BaseAgent) │ │ +│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │ +│ │ │ │ │ +│ ▼ ▼ ▼ │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ pydantic-ai │ │ pydantic-ai │ │ pydantic-ai │ │ +│ │ Agent() │ │ Agent() │ │ Agent() │ │ +│ │ output_type= │ │ output_type= │ │ output_type= │ │ +│ │ SearchResult │ │ JudgeAssess │ │ Report │ │ +│ └──────────────┘ └──────────────┘ └──────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +**Why this architecture:** +1. **Agent Framework** handles: workflow coordination, state passing, middleware, observability +2. **pydantic-ai** handles: type-safe LLM calls within each agent + +--- + +## 4. CRITICAL: Naming Confusion Clarification + +> **Senior Agent Review Finding:** The codebase uses "magentic" in file names (e.g., `orchestrator_magentic.py`, `magentic_agents.py`) but this is **NOT** the `magentic` PyPI package by Jacky Liang. It's Microsoft Agent Framework (`agent-framework-core`). + +**The naming confusion:** +- `magentic` (PyPI package): A different library for structured LLM outputs +- "Magentic" (in our codebase): Our internal name for Microsoft Agent Framework integration +- `agent-framework-core` (PyPI package): Microsoft's actual multi-agent orchestration framework + +**Recommended future action:** Rename `orchestrator_magentic.py` → `orchestrator_advanced.py` to eliminate confusion. + +--- + +## 5. What the Refactor DID Get Right + +The refactor branch (`feat/pubmed-fulltext`) has some valuable improvements: + +1. **`judges.py` unified `get_model()`** - Supports OpenAI, Anthropic, AND HuggingFace via pydantic-ai +2. **HuggingFace free tier support** - `HuggingFaceModel` integration +3. **Test fix** - Properly mocks `HuggingFaceModel` class +4. **Removed broken magentic optional dependency** from pyproject.toml (this was correct - the old `magentic` package is different from Microsoft Agent Framework) + +**What it got WRONG:** +1. Deleted `src/agents/` entirely instead of refactoring them +2. Deleted `src/orchestrator_magentic.py` instead of fixing it +3. Conflated "magentic" (old package) with "Microsoft Agent Framework" (current framework) + +--- + +## 6. Options for Path Forward + +### Option A: Abandon Refactor, Start Fresh +- Close PR #41 +- Delete `feat/pubmed-fulltext` and `refactor/pydantic-unification` branches +- Reset local `dev` to match `origin/dev` +- Cherry-pick ONLY the good parts (judges.py improvements, HF support) +- **Pros:** Clean, safe +- **Cons:** Lose some work, need to redo carefully + +### Option B: Cherry-Pick Good Parts to origin/dev +- Do NOT merge PR #41 +- Create new branch from `origin/dev` +- Cherry-pick specific commits/changes that improve pydantic-ai usage +- Keep agent framework code intact +- **Pros:** Preserves both, surgical +- **Cons:** Requires careful file-by-file review + +### Option C: Revert Deletions in Refactor Branch +- On `feat/pubmed-fulltext`, restore deleted agent files from `origin/dev` +- Keep the pydantic-ai improvements +- Merge THAT to dev +- **Pros:** Gets both +- **Cons:** Complex git operations, risk of conflicts + +--- + +## 7. Recommended Action: Option B (Cherry-Pick) + +**Step-by-step:** + +1. **Close PR #41** (do not merge) +2. **Delete redundant branches:** + - `refactor/pydantic-unification` (local) + - Reset local `dev` to `origin/dev` +3. **Create new branch from origin/dev:** + ```bash + git checkout -b feat/pydantic-ai-improvements origin/dev + ``` +4. **Cherry-pick or manually port these improvements:** + - `src/agent_factory/judges.py` - the unified `get_model()` function + - `examples/free_tier_demo.py` - HuggingFace demo + - Test improvements +5. **Do NOT delete any agent framework files** +6. **Create PR for review** + +--- + +## 8. Files to Cherry-Pick (Safe Improvements) + +| File | What Changed | Safe to Port? | +|------|-------------|---------------| +| `src/agent_factory/judges.py` | Added `HuggingFaceModel` support in `get_model()` | YES | +| `examples/free_tier_demo.py` | New demo for HF inference | YES | +| `tests/unit/agent_factory/test_judges.py` | Fixed HF model mocking | YES | +| `pyproject.toml` | Removed old `magentic` optional dep | MAYBE (review carefully) | + +--- + +## 9. Questions to Answer Before Proceeding + +1. **For the hackathon**: Do we need full multi-agent orchestration, or is single-agent sufficient? +2. **For DeepCritical mainline**: Is the plan to use Microsoft Agent Framework for orchestration? +3. **Timeline**: How much time do we have to get this right? + +--- + +## 10. Immediate Actions (DO NOW) + +- [ ] **DO NOT merge PR #41** +- [ ] Close PR #41 with comment explaining the situation +- [ ] Do not push local `dev` branch anywhere +- [ ] Confirm HuggingFace Spaces is untouched (it is - verified) + +--- + +## 11. Decision Log + +| Date | Decision | Rationale | +|------|----------|-----------| +| 2025-11-27 | Pause refactor merge | Discovered agent framework and pydantic-ai are complementary, not exclusive | +| TBD | ? | Awaiting decision on path forward | diff --git a/docs/brainstorming/magentic-pydantic/01_ARCHITECTURE_SPEC.md b/docs/brainstorming/magentic-pydantic/01_ARCHITECTURE_SPEC.md new file mode 100644 index 0000000000000000000000000000000000000000..7886c89b807f1dbfb54e878bb326715ad62675f9 --- /dev/null +++ b/docs/brainstorming/magentic-pydantic/01_ARCHITECTURE_SPEC.md @@ -0,0 +1,289 @@ +# Architecture Specification: Dual-Mode Agent System + +**Date:** November 27, 2025 +**Status:** SPECIFICATION +**Goal:** Graceful degradation from full multi-agent orchestration to simple single-agent mode + +--- + +## 1. Core Concept: Two Operating Modes + +```text +┌─────────────────────────────────────────────────────────────────────┐ +│ USER REQUEST │ +│ │ │ +│ ▼ │ +│ ┌─────────────────┐ │ +│ │ Mode Selection │ │ +│ │ (Auto-detect) │ │ +│ └────────┬────────┘ │ +│ │ │ +│ ┌───────────────┴───────────────┐ │ +│ │ │ │ +│ ▼ ▼ │ +│ ┌─────────────────┐ ┌─────────────────┐ │ +│ │ SIMPLE MODE │ │ ADVANCED MODE │ │ +│ │ (Free Tier) │ │ (Paid Tier) │ │ +│ │ │ │ │ │ +│ │ pydantic-ai │ │ MS Agent Fwk │ │ +│ │ single-agent │ │ + pydantic-ai │ │ +│ │ loop │ │ multi-agent │ │ +│ └─────────────────┘ └─────────────────┘ │ +│ │ │ │ +│ └───────────────┬───────────────┘ │ +│ ▼ │ +│ ┌─────────────────┐ │ +│ │ Research Report │ │ +│ │ with Citations │ │ +│ └─────────────────┘ │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 2. Mode Comparison + +| Aspect | Simple Mode | Advanced Mode | +|--------|-------------|---------------| +| **Trigger** | No API key OR `LLM_PROVIDER=huggingface` | OpenAI API key present (currently OpenAI only) | +| **Framework** | pydantic-ai only | Microsoft Agent Framework + pydantic-ai | +| **Architecture** | Single orchestrator loop | Multi-agent coordination | +| **Agents** | One agent does Search→Judge→Report | SearchAgent, JudgeAgent, ReportAgent, AnalysisAgent | +| **State Management** | Simple dict | Thread-safe `MagenticState` with context vars | +| **Quality** | Good (functional) | Better (specialized agents, coordination) | +| **Cost** | Free (HuggingFace Inference) | Paid (OpenAI/Anthropic) | +| **Use Case** | Demos, hackathon, budget-constrained | Production, research quality | + +--- + +## 3. Simple Mode Architecture (pydantic-ai Only) + +```text +┌─────────────────────────────────────────────────────┐ +│ Orchestrator │ +│ │ +│ while not sufficient and iteration < max: │ +│ 1. SearchHandler.execute(query) │ +│ 2. JudgeHandler.assess(evidence) ◄── pydantic-ai Agent │ +│ 3. if sufficient: break │ +│ 4. query = judge.next_queries │ +│ │ +│ return ReportGenerator.generate(evidence) │ +└─────────────────────────────────────────────────────┘ +``` + +**Components:** +- `src/orchestrator.py` - Simple loop orchestrator +- `src/agent_factory/judges.py` - JudgeHandler with pydantic-ai +- `src/tools/search_handler.py` - Scatter-gather search +- `src/tools/pubmed.py`, `clinicaltrials.py`, `europepmc.py` - Search tools + +--- + +## 4. Advanced Mode Architecture (MS Agent Framework + pydantic-ai) + +```text +┌─────────────────────────────────────────────────────────────────────┐ +│ Microsoft Agent Framework Orchestrator │ +│ │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ +│ │ SearchAgent │───▶│ JudgeAgent │───▶│ ReportAgent │ │ +│ │ (BaseAgent) │ │ (BaseAgent) │ │ (BaseAgent) │ │ +│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │ +│ │ │ │ │ +│ ▼ ▼ ▼ │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ +│ │ pydantic-ai │ │ pydantic-ai │ │ pydantic-ai │ │ +│ │ Agent() │ │ Agent() │ │ Agent() │ │ +│ │ output_type=│ │ output_type=│ │ output_type=│ │ +│ │ SearchResult│ │ JudgeAssess │ │ Report │ │ +│ └─────────────┘ └─────────────┘ └─────────────┘ │ +│ │ +│ Shared State: MagenticState (thread-safe via contextvars) │ +│ - evidence: list[Evidence] │ +│ - embedding_service: EmbeddingService │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +**Components:** +- `src/orchestrator_magentic.py` - Multi-agent orchestrator +- `src/agents/search_agent.py` - SearchAgent (BaseAgent) +- `src/agents/judge_agent.py` - JudgeAgent (BaseAgent) +- `src/agents/report_agent.py` - ReportAgent (BaseAgent) +- `src/agents/analysis_agent.py` - AnalysisAgent (BaseAgent) +- `src/agents/state.py` - Thread-safe state management +- `src/agents/tools.py` - @ai_function decorated tools + +--- + +## 5. Mode Selection Logic + +```python +# src/orchestrator_factory.py (actual implementation) + +def create_orchestrator( + search_handler: SearchHandlerProtocol | None = None, + judge_handler: JudgeHandlerProtocol | None = None, + config: OrchestratorConfig | None = None, + mode: Literal["simple", "magentic", "advanced"] | None = None, +) -> Any: + """ + Auto-select orchestrator based on available credentials. + + Priority: + 1. If mode explicitly set, use that + 2. If OpenAI key available -> Advanced Mode (currently OpenAI only) + 3. Otherwise -> Simple Mode (HuggingFace free tier) + """ + effective_mode = _determine_mode(mode) + + if effective_mode == "advanced": + orchestrator_cls = _get_magentic_orchestrator_class() + return orchestrator_cls(max_rounds=config.max_iterations if config else 10) + + # Simple mode requires handlers + if search_handler is None or judge_handler is None: + raise ValueError("Simple mode requires search_handler and judge_handler") + + return Orchestrator( + search_handler=search_handler, + judge_handler=judge_handler, + config=config, + ) +``` + +--- + +## 6. Shared Components (Both Modes Use) + +These components work in both modes: + +| Component | Purpose | +|-----------|---------| +| `src/tools/pubmed.py` | PubMed search | +| `src/tools/clinicaltrials.py` | ClinicalTrials.gov search | +| `src/tools/europepmc.py` | Europe PMC search | +| `src/tools/search_handler.py` | Scatter-gather orchestration | +| `src/tools/rate_limiter.py` | Rate limiting | +| `src/utils/models.py` | Evidence, Citation, JudgeAssessment | +| `src/utils/config.py` | Settings | +| `src/services/embeddings.py` | Vector search (optional) | + +--- + +## 7. pydantic-ai Integration Points + +Both modes use pydantic-ai for structured LLM outputs: + +```python +# In JudgeHandler (both modes) +from pydantic_ai import Agent +from pydantic_ai.models.huggingface import HuggingFaceModel +from pydantic_ai.models.openai import OpenAIModel +from pydantic_ai.models.anthropic import AnthropicModel + +class JudgeHandler: + def __init__(self, model: Any = None): + self.model = model or get_model() # Auto-selects based on config + self.agent = Agent( + model=self.model, + output_type=JudgeAssessment, # Structured output! + system_prompt=SYSTEM_PROMPT, + ) + + async def assess(self, question: str, evidence: list[Evidence]) -> JudgeAssessment: + result = await self.agent.run(format_prompt(question, evidence)) + return result.output # Guaranteed to be JudgeAssessment +``` + +--- + +## 8. Microsoft Agent Framework Integration Points + +Advanced mode wraps pydantic-ai agents in BaseAgent: + +```python +# In JudgeAgent (advanced mode only) +from agent_framework import BaseAgent, AgentRunResponse, ChatMessage, Role + +class JudgeAgent(BaseAgent): + def __init__(self, judge_handler: JudgeHandlerProtocol): + super().__init__( + name="JudgeAgent", + description="Evaluates evidence quality", + ) + self._handler = judge_handler # Uses pydantic-ai internally + + async def run(self, messages, **kwargs) -> AgentRunResponse: + question = extract_question(messages) + evidence = self._evidence_store.get("current", []) + + # Delegate to pydantic-ai powered handler + assessment = await self._handler.assess(question, evidence) + + return AgentRunResponse( + messages=[ChatMessage(role=Role.ASSISTANT, text=format_response(assessment))], + additional_properties={"assessment": assessment.model_dump()}, + ) +``` + +--- + +## 9. Benefits of This Architecture + +1. **Graceful Degradation**: Works without API keys (free tier) +2. **Progressive Enhancement**: Better with API keys (orchestration) +3. **Code Reuse**: pydantic-ai handlers shared between modes +4. **Hackathon Ready**: Demo works without requiring paid keys +5. **Production Ready**: Full orchestration available when needed +6. **Future Proof**: Can add more agents to advanced mode +7. **Testable**: Simple mode is easier to unit test + +--- + +## 10. Known Risks and Mitigations + +> **From Senior Agent Review** + +### 10.1 Bridge Complexity (MEDIUM) + +**Risk:** In Advanced Mode, agents (Agent Framework) wrap handlers (pydantic-ai). Both are async. Context variables (`MagenticState`) must propagate correctly through the pydantic-ai call stack. + +**Mitigation:** +- pydantic-ai uses standard Python `contextvars`, which naturally propagate through `await` chains +- Test context propagation explicitly in integration tests +- If issues arise, pass state explicitly rather than via context vars + +### 10.2 Integration Drift (MEDIUM) + +**Risk:** Simple Mode and Advanced Mode might diverge in behavior over time (e.g., Simple Mode uses logic A, Advanced Mode uses logic B). + +**Mitigation:** +- Both modes MUST call the exact same underlying Tools (`src/tools/*`) and Handlers (`src/agent_factory/*`) +- Handlers are the single source of truth for business logic +- Agents are thin wrappers that delegate to handlers + +### 10.3 Testing Burden (LOW-MEDIUM) + +**Risk:** Two distinct orchestrators (`src/orchestrator.py` and `src/orchestrator_magentic.py`) doubles integration testing surface area. + +**Mitigation:** +- Unit test handlers independently (shared code) +- Integration tests for each mode separately +- End-to-end tests verify same output for same input (determinism permitting) + +### 10.4 Dependency Conflicts (LOW) + +**Risk:** `agent-framework-core` might conflict with `pydantic-ai`'s dependencies (e.g., different pydantic versions). + +**Status:** Both use `pydantic>=2.x`. Should be compatible. + +--- + +## 11. Naming Clarification + +> See `00_SITUATION_AND_PLAN.md` Section 4 for full details. + +**Important:** The codebase uses "magentic" in file names (`orchestrator_magentic.py`, `magentic_agents.py`) but this refers to our internal naming for Microsoft Agent Framework integration, **NOT** the `magentic` PyPI package. + +**Future action:** Rename to `orchestrator_advanced.py` to eliminate confusion. diff --git a/docs/brainstorming/magentic-pydantic/02_IMPLEMENTATION_PHASES.md b/docs/brainstorming/magentic-pydantic/02_IMPLEMENTATION_PHASES.md new file mode 100644 index 0000000000000000000000000000000000000000..37e2791a4123e3f2e78d2c750ddc77eff7d05814 --- /dev/null +++ b/docs/brainstorming/magentic-pydantic/02_IMPLEMENTATION_PHASES.md @@ -0,0 +1,112 @@ +# Implementation Phases: Dual-Mode Agent System + +**Date:** November 27, 2025 +**Status:** IMPLEMENTATION PLAN (REVISED) +**Strategy:** TDD (Test-Driven Development), SOLID Principles +**Dependency Strategy:** PyPI (agent-framework-core) + +--- + +## Phase 0: Environment Validation & Cleanup + +**Goal:** Ensure clean state and dependencies are correctly installed. + +### Step 0.1: Verify PyPI Package +The `agent-framework-core` package is published on PyPI by Microsoft. Verify installation: + +```bash +uv sync --all-extras +python -c "from agent_framework import ChatAgent; print('OK')" +``` + +### Step 0.2: Branch State +We are on `feat/dual-mode-architecture`. Ensure it is up to date with `origin/dev` before starting. + +**Note:** The `reference_repos/agent-framework` folder is kept for reference/documentation only. +The production dependency uses the official PyPI release. + +--- + +## Phase 1: Pydantic-AI Improvements (Simple Mode) + +**Goal:** Implement `HuggingFaceModel` support in `JudgeHandler` using strict TDD. + +### Step 1.1: Test First (Red) +Create `tests/unit/agent_factory/test_judges_factory.py`: +- Test `get_model()` returns `HuggingFaceModel` when `LLM_PROVIDER=huggingface`. +- Test `get_model()` respects `HF_TOKEN`. +- Test fallback to OpenAI. + +### Step 1.2: Implementation (Green) +Update `src/utils/config.py`: +- Add `huggingface_model` and `hf_token` fields. + +Update `src/agent_factory/judges.py`: +- Implement `get_model` with the logic derived from the tests. +- Use dependency injection for the model where possible. + +### Step 1.3: Refactor +Ensure `JudgeHandler` is loosely coupled from the specific model provider. + +--- + +## Phase 2: Orchestrator Factory (The Switch) + +**Goal:** Implement the factory pattern to switch between Simple and Advanced modes. + +### Step 2.1: Test First (Red) +Create `tests/unit/test_orchestrator_factory.py`: +- Test `create_orchestrator` returns `Orchestrator` (simple) when API keys are missing. +- Test `create_orchestrator` returns `MagenticOrchestrator` (advanced) when OpenAI key exists. +- Test explicit mode override. + +### Step 2.2: Implementation (Green) +Update `src/orchestrator_factory.py` to implement the selection logic. + +--- + +## Phase 3: Agent Framework Integration (Advanced Mode) + +**Goal:** Integrate Microsoft Agent Framework from PyPI. + +### Step 3.1: Dependency Management +The `agent-framework-core` package is installed from PyPI: +```toml +[project.optional-dependencies] +magentic = [ + "agent-framework-core>=1.0.0b251120,<2.0.0", # Microsoft Agent Framework (PyPI) +] +``` +Install with: `uv sync --all-extras` + +### Step 3.2: Verify Imports (Test First) +Create `tests/unit/agents/test_agent_imports.py`: +- Verify `from agent_framework import ChatAgent` works. +- Verify instantiation of `ChatAgent` with a mock client. + +### Step 3.3: Update Agents +Refactor `src/agents/*.py` to ensure they match the exact signature of the local `ChatAgent` class. +- **SOLID:** Ensure agents have single responsibilities. +- **DRY:** Share tool definitions between Pydantic-AI simple mode and Agent Framework advanced mode. + +--- + +## Phase 4: UI & End-to-End Verification + +**Goal:** Update Gradio to reflect the active mode. + +### Step 4.1: UI Updates +Update `src/app.py` to display "Simple Mode" vs "Advanced Mode". + +### Step 4.2: End-to-End Test +Run the full loop: +1. Simple Mode (No Keys) -> Search -> Judge (HF) -> Report. +2. Advanced Mode (OpenAI Key) -> SearchAgent -> JudgeAgent -> ReportAgent. + +--- + +## Phase 5: Cleanup & Documentation + +- Remove unused code. +- Update main README.md. +- Final `make check`. \ No newline at end of file diff --git a/docs/brainstorming/magentic-pydantic/03_IMMEDIATE_ACTIONS.md b/docs/brainstorming/magentic-pydantic/03_IMMEDIATE_ACTIONS.md new file mode 100644 index 0000000000000000000000000000000000000000..b09b6db248a8ddb37fa8f6c2deba01c929f694a4 --- /dev/null +++ b/docs/brainstorming/magentic-pydantic/03_IMMEDIATE_ACTIONS.md @@ -0,0 +1,112 @@ +# Immediate Actions Checklist + +**Date:** November 27, 2025 +**Priority:** Execute in order + +--- + +## Before Starting Implementation + +### 1. Close PR #41 (CRITICAL) + +```bash +gh pr close 41 --comment "Architecture decision changed. Cherry-picking improvements to preserve both pydantic-ai and Agent Framework capabilities." +``` + +### 2. Verify HuggingFace Spaces is Safe + +```bash +# Should show agent framework files exist +git ls-tree --name-only huggingface-upstream/dev -- src/agents/ +git ls-tree --name-only huggingface-upstream/dev -- src/orchestrator_magentic.py +``` + +Expected output: Files should exist (they do as of this writing). + +### 3. Clean Local Environment + +```bash +# Switch to main first +git checkout main + +# Delete problematic branches +git branch -D refactor/pydantic-unification 2>/dev/null || true +git branch -D feat/pubmed-fulltext 2>/dev/null || true + +# Reset local dev to origin/dev +git branch -D dev 2>/dev/null || true +git checkout -b dev origin/dev + +# Verify agent framework code exists +ls src/agents/ +# Expected: __init__.py, analysis_agent.py, hypothesis_agent.py, judge_agent.py, +# magentic_agents.py, report_agent.py, search_agent.py, state.py, tools.py + +ls src/orchestrator_magentic.py +# Expected: file exists +``` + +### 4. Create Fresh Feature Branch + +```bash +git checkout -b feat/dual-mode-architecture origin/dev +``` + +--- + +## Decision Points + +Before proceeding, confirm: + +1. **For hackathon**: Do we need advanced mode, or is simple mode sufficient? + - Simple mode = faster to implement, works today + - Advanced mode = better quality, more work + +2. **Timeline**: How much time do we have? + - If < 1 day: Focus on simple mode only + - If > 1 day: Implement dual-mode + +3. **Dependencies**: Is `agent-framework-core` available? + - Check: `pip index versions agent-framework-core` + - If not on PyPI, may need to install from GitHub + +--- + +## Quick Start (Simple Mode Only) + +If time is limited, implement only simple mode improvements: + +```bash +# On feat/dual-mode-architecture branch + +# 1. Update judges.py to add HuggingFace support +# 2. Update config.py to add HF settings +# 3. Create free_tier_demo.py +# 4. Run make check +# 5. Create PR to dev +``` + +This gives you free-tier capability without touching agent framework code. + +--- + +## Quick Start (Full Dual-Mode) + +If time permits, implement full dual-mode: + +Follow phases 1-6 in `02_IMPLEMENTATION_PHASES.md` + +--- + +## Emergency Rollback + +If anything goes wrong: + +```bash +# Reset to safe state +git checkout main +git branch -D feat/dual-mode-architecture +git checkout -b feat/dual-mode-architecture origin/dev +``` + +Origin/dev is the safe fallback - it has agent framework intact. diff --git a/docs/brainstorming/magentic-pydantic/04_FOLLOWUP_REVIEW_REQUEST.md b/docs/brainstorming/magentic-pydantic/04_FOLLOWUP_REVIEW_REQUEST.md new file mode 100644 index 0000000000000000000000000000000000000000..98b021373d2b3928be993b791ac5a9197503c92a --- /dev/null +++ b/docs/brainstorming/magentic-pydantic/04_FOLLOWUP_REVIEW_REQUEST.md @@ -0,0 +1,158 @@ +# Follow-Up Review Request: Did We Implement Your Feedback? + +**Date:** November 27, 2025 +**Context:** You previously reviewed our dual-mode architecture plan and provided feedback. We have updated the documentation. Please verify we correctly implemented your recommendations. + +--- + +## Your Original Feedback vs Our Changes + +### 1. Naming Confusion Clarification + +**Your feedback:** "You are using Microsoft Agent Framework, but you've named your integration 'Magentic'. This caused the confusion." + +**Our change:** Added Section 4 in `00_SITUATION_AND_PLAN.md`: +```markdown +## 4. CRITICAL: Naming Confusion Clarification + +> **Senior Agent Review Finding:** The codebase uses "magentic" in file names +> (e.g., `orchestrator_magentic.py`, `magentic_agents.py`) but this is **NOT** +> the `magentic` PyPI package by Jacky Liang. It's Microsoft Agent Framework. + +**The naming confusion:** +- `magentic` (PyPI package): A different library for structured LLM outputs +- "Magentic" (in our codebase): Our internal name for Microsoft Agent Framework integration +- `agent-framework-core` (PyPI package): Microsoft's actual multi-agent orchestration framework + +**Recommended future action:** Rename `orchestrator_magentic.py` → `orchestrator_advanced.py` +``` + +**Status:** ✅ IMPLEMENTED + +--- + +### 2. Bridge Complexity Warning + +**Your feedback:** "You must ensure MagenticState (context vars) propagates correctly through the pydantic-ai call stack." + +**Our change:** Added Section 10.1 in `01_ARCHITECTURE_SPEC.md`: +```markdown +### 10.1 Bridge Complexity (MEDIUM) + +**Risk:** In Advanced Mode, agents (Agent Framework) wrap handlers (pydantic-ai). +Both are async. Context variables (`MagenticState`) must propagate correctly. + +**Mitigation:** +- pydantic-ai uses standard Python `contextvars`, which naturally propagate through `await` chains +- Test context propagation explicitly in integration tests +- If issues arise, pass state explicitly rather than via context vars +``` + +**Status:** ✅ IMPLEMENTED + +--- + +### 3. Integration Drift Warning + +**Your feedback:** "Simple Mode and Advanced Mode might diverge in behavior." + +**Our change:** Added Section 10.2 in `01_ARCHITECTURE_SPEC.md`: +```markdown +### 10.2 Integration Drift (MEDIUM) + +**Risk:** Simple Mode and Advanced Mode might diverge in behavior over time. + +**Mitigation:** +- Both modes MUST call the exact same underlying Tools (`src/tools/*`) and Handlers (`src/agent_factory/*`) +- Handlers are the single source of truth for business logic +- Agents are thin wrappers that delegate to handlers +``` + +**Status:** ✅ IMPLEMENTED + +--- + +### 4. Testing Burden Warning + +**Your feedback:** "You now have two distinct orchestrators to maintain. This doubles your integration testing surface area." + +**Our change:** Added Section 10.3 in `01_ARCHITECTURE_SPEC.md`: +```markdown +### 10.3 Testing Burden (LOW-MEDIUM) + +**Risk:** Two distinct orchestrators doubles integration testing surface area. + +**Mitigation:** +- Unit test handlers independently (shared code) +- Integration tests for each mode separately +- End-to-end tests verify same output for same input +``` + +**Status:** ✅ IMPLEMENTED + +--- + +### 5. Rename Recommendation + +**Your feedback:** "Rename `src/orchestrator_magentic.py` to `src/orchestrator_advanced.py`" + +**Our change:** Added Step 3.4 in `02_IMPLEMENTATION_PHASES.md`: +```markdown +### Step 3.4: (OPTIONAL) Rename "Magentic" to "Advanced" + +> **Senior Agent Recommendation:** Rename files to eliminate confusion. + +git mv src/orchestrator_magentic.py src/orchestrator_advanced.py +git mv src/agents/magentic_agents.py src/agents/advanced_agents.py + +**Note:** This is optional for the hackathon. Can be done in a follow-up PR. +``` + +**Status:** ✅ DOCUMENTED (marked as optional for hackathon) + +--- + +### 6. Standardize Wrapper Recommendation + +**Your feedback:** "Create a generic `PydanticAiAgentWrapper(BaseAgent)` class instead of manually wrapping each handler." + +**Our change:** NOT YET DOCUMENTED + +**Status:** ⚠️ NOT IMPLEMENTED - Should we add this? + +--- + +## Questions for Your Review + +1. **Did we correctly implement your feedback?** Are there any misunderstandings in how we interpreted your recommendations? + +2. **Is the "Standardize Wrapper" recommendation critical?** Should we add it to the implementation phases, or is it a nice-to-have for later? + +3. **Dependency versioning:** You noted `agent-framework-core>=1.0.0b251120` might be ephemeral. Should we: + - Pin to a specific version? + - Use a version range? + - Install from GitHub source? + +4. **Anything else we missed?** + +--- + +## Files to Re-Review + +1. `00_SITUATION_AND_PLAN.md` - Added Section 4 (Naming Clarification) +2. `01_ARCHITECTURE_SPEC.md` - Added Sections 10-11 (Risks, Naming) +3. `02_IMPLEMENTATION_PHASES.md` - Added Step 3.4 (Optional Rename) + +--- + +## Current Branch State + +We are now on `feat/dual-mode-architecture` branched from `origin/dev`: +- ✅ Agent framework code intact (`src/agents/`, `src/orchestrator_magentic.py`) +- ✅ Documentation committed +- ❌ PR #41 still open (need to close it) +- ❌ Cherry-pick of pydantic-ai improvements not yet done + +--- + +Please confirm: **GO / NO-GO** to proceed with Phase 1 (cherry-picking pydantic-ai improvements)? diff --git a/docs/brainstorming/magentic-pydantic/REVIEW_PROMPT_FOR_SENIOR_AGENT.md b/docs/brainstorming/magentic-pydantic/REVIEW_PROMPT_FOR_SENIOR_AGENT.md new file mode 100644 index 0000000000000000000000000000000000000000..9f25b1f52a79193a28d4d5f9029cdfece1928be5 --- /dev/null +++ b/docs/brainstorming/magentic-pydantic/REVIEW_PROMPT_FOR_SENIOR_AGENT.md @@ -0,0 +1,113 @@ +# Senior Agent Review Prompt + +Copy and paste everything below this line to a fresh Claude/AI session: + +--- + +## Context + +I am a junior developer working on a HuggingFace hackathon project called DeepCritical. We made a significant architectural mistake and are now trying to course-correct. I need you to act as a **senior staff engineer** and critically review our proposed solution. + +## The Situation + +We almost merged a refactor that would have **deleted** our multi-agent orchestration capability, mistakenly believing that `pydantic-ai` (a library for structured LLM outputs) and Microsoft's `agent-framework` (a framework for multi-agent orchestration) were mutually exclusive alternatives. + +**They are not.** They are complementary: +- `pydantic-ai` ensures LLM responses match Pydantic schemas (type-safe outputs) +- `agent-framework` orchestrates multiple agents working together (coordination layer) + +We now want to implement a **dual-mode architecture** where: +- **Simple Mode (No API key):** Uses only pydantic-ai with HuggingFace free tier +- **Advanced Mode (With API key):** Uses Microsoft Agent Framework for orchestration, with pydantic-ai inside each agent for structured outputs + +## Your Task + +Please perform a **deep, critical review** of: + +1. **The architecture diagram** (image attached: `assets/magentic-pydantic.png`) +2. **Our documentation** (4 files listed below) +3. **The actual codebase** to verify our claims + +## Specific Questions to Answer + +### Architecture Validation +1. Is our understanding correct that pydantic-ai and agent-framework are complementary, not competing? +2. Does the dual-mode architecture diagram accurately represent how these should integrate? +3. Are there any architectural flaws or anti-patterns in our proposed design? + +### Documentation Accuracy +4. Are the branch states we documented accurate? (Check `git log`, `git ls-tree`) +5. Is our understanding of what code exists where correct? +6. Are the implementation phases realistic and in the correct order? +7. Are there any missing steps or dependencies we overlooked? + +### Codebase Reality Check +8. Does `origin/dev` actually have the agent framework code intact? Verify by checking: + - `git ls-tree origin/dev -- src/agents/` + - `git ls-tree origin/dev -- src/orchestrator_magentic.py` +9. What does the current `src/agents/` code actually import? Does it use `agent_framework` or `agent-framework-core`? +10. Is the `agent-framework-core` package actually available on PyPI, or do we need to install from source? + +### Implementation Feasibility +11. Can the cherry-pick strategy we outlined actually work, or are there merge conflicts we're not seeing? +12. Is the mode auto-detection logic sound? +13. What are the risks we haven't identified? + +### Critical Errors Check +14. Did we miss anything critical in our analysis? +15. Are there any factual errors in our documentation? +16. Would a Google/DeepMind senior engineer approve this plan, or would they flag issues? + +## Files to Review + +Please read these files in order: + +1. `/Users/ray/Desktop/CLARITY-DIGITAL-TWIN/DeepCritical-1/docs/brainstorming/magentic-pydantic/00_SITUATION_AND_PLAN.md` +2. `/Users/ray/Desktop/CLARITY-DIGITAL-TWIN/DeepCritical-1/docs/brainstorming/magentic-pydantic/01_ARCHITECTURE_SPEC.md` +3. `/Users/ray/Desktop/CLARITY-DIGITAL-TWIN/DeepCritical-1/docs/brainstorming/magentic-pydantic/02_IMPLEMENTATION_PHASES.md` +4. `/Users/ray/Desktop/CLARITY-DIGITAL-TWIN/DeepCritical-1/docs/brainstorming/magentic-pydantic/03_IMMEDIATE_ACTIONS.md` + +And the architecture diagram: +5. `/Users/ray/Desktop/CLARITY-DIGITAL-TWIN/DeepCritical-1/assets/magentic-pydantic.png` + +## Reference Repositories to Consult + +We have local clones of the source-of-truth repositories: + +- **Original DeepCritical:** `/Users/ray/Desktop/CLARITY-DIGITAL-TWIN/DeepCritical-1/reference_repos/DeepCritical/` +- **Microsoft Agent Framework:** `/Users/ray/Desktop/CLARITY-DIGITAL-TWIN/DeepCritical-1/reference_repos/agent-framework/` +- **Microsoft AutoGen:** `/Users/ray/Desktop/CLARITY-DIGITAL-TWIN/DeepCritical-1/reference_repos/autogen-microsoft/` + +Please cross-reference our hackathon fork against these to verify architectural alignment. + +## Codebase to Analyze + +Our hackathon fork is at: +`/Users/ray/Desktop/CLARITY-DIGITAL-TWIN/DeepCritical-1/` + +Key files to examine: +- `src/agents/` - Agent framework integration +- `src/agent_factory/judges.py` - pydantic-ai integration +- `src/orchestrator.py` - Simple mode orchestrator +- `src/orchestrator_magentic.py` - Advanced mode orchestrator +- `src/orchestrator_factory.py` - Mode selection +- `pyproject.toml` - Dependencies + +## Expected Output + +Please provide: + +1. **Validation Summary:** Is our plan sound? (YES/NO with explanation) +2. **Errors Found:** List any factual errors in our documentation +3. **Missing Items:** What did we overlook? +4. **Risk Assessment:** What could go wrong? +5. **Recommended Changes:** Specific edits to our documentation or plan +6. **Go/No-Go Recommendation:** Should we proceed with this plan? + +## Tone + +Be brutally honest. If our plan is flawed, say so directly. We would rather know now than after implementation. Don't soften criticism - we need accuracy. + +--- + +END OF PROMPT diff --git a/docs/bugs/FIX_PLAN_MAGENTIC_MODE.md b/docs/bugs/FIX_PLAN_MAGENTIC_MODE.md new file mode 100644 index 0000000000000000000000000000000000000000..a02e1a19a1de2b1937c7d181873879fbb1f1ddfb --- /dev/null +++ b/docs/bugs/FIX_PLAN_MAGENTIC_MODE.md @@ -0,0 +1,227 @@ +# Fix Plan: Magentic Mode Report Generation + +**Related Bug**: `P0_MAGENTIC_MODE_BROKEN.md` +**Approach**: Test-Driven Development (TDD) +**Estimated Scope**: 4 tasks, ~2-3 hours + +--- + +## Problem Summary + +Magentic mode runs but fails to produce readable reports due to: + +1. **Primary Bug**: `MagenticFinalResultEvent.message` returns `ChatMessage` object, not text +2. **Secondary Bug**: Max rounds (3) reached before ReportAgent completes +3. **Tertiary Issues**: Stale "bioRxiv" references in prompts + +--- + +## Fix Order (TDD) + +### Phase 1: Write Failing Tests + +**Task 1.1**: Create test for ChatMessage text extraction + +```python +# tests/unit/test_orchestrator_magentic.py + +def test_process_event_extracts_text_from_chat_message(): + """Final result event should extract text from ChatMessage object.""" + # Arrange: Mock ChatMessage with .content attribute + # Act: Call _process_event with MagenticFinalResultEvent + # Assert: Returned AgentEvent.message is a string, not object repr +``` + +**Task 1.2**: Create test for max rounds configuration + +```python +def test_orchestrator_uses_configured_max_rounds(): + """MagenticOrchestrator should use max_rounds from constructor.""" + # Arrange: Create orchestrator with max_rounds=10 + # Act: Build workflow + # Assert: Workflow has max_round_count=10 +``` + +**Task 1.3**: Create test for bioRxiv reference removal + +```python +def test_task_prompt_references_europe_pmc(): + """Task prompt should reference Europe PMC, not bioRxiv.""" + # Arrange: Create orchestrator + # Act: Check task string in run() + # Assert: Contains "Europe PMC", not "bioRxiv" +``` + +--- + +### Phase 2: Fix ChatMessage Text Extraction + +**File**: `src/orchestrator_magentic.py` +**Lines**: 192-199 + +**Current Code**: +```python +elif isinstance(event, MagenticFinalResultEvent): + text = event.message.text if event.message else "No result" +``` + +**Fixed Code**: +```python +elif isinstance(event, MagenticFinalResultEvent): + if event.message: + # ChatMessage may have .content or .text depending on version + if hasattr(event.message, 'content') and event.message.content: + text = str(event.message.content) + elif hasattr(event.message, 'text') and event.message.text: + text = str(event.message.text) + else: + # Fallback: convert entire message to string + text = str(event.message) + else: + text = "No result generated" +``` + +**Why**: The `agent_framework.ChatMessage` object structure may vary. We need defensive extraction. + +--- + +### Phase 3: Fix Max Rounds Configuration + +**File**: `src/orchestrator_magentic.py` +**Lines**: 97-99 + +**Current Code**: +```python +.with_standard_manager( + chat_client=manager_client, + max_round_count=self._max_rounds, # Already uses config + max_stall_count=3, + max_reset_count=2, +) +``` + +**Issue**: Default `max_rounds` in `__init__` is 10, but workflow may need more for complex queries. + +**Fix**: Verify the value flows through correctly. Add logging. + +```python +logger.info( + "Building Magentic workflow", + max_rounds=self._max_rounds, + max_stall=3, + max_reset=2, +) +``` + +**Also check**: `src/orchestrator_factory.py` passes config correctly: +```python +return MagenticOrchestrator( + max_rounds=config.max_iterations if config else 10, +) +``` + +--- + +### Phase 4: Fix Stale bioRxiv References + +**Files to update**: + +| File | Line | Change | +|------|------|--------| +| `src/orchestrator_magentic.py` | 131 | "bioRxiv" → "Europe PMC" | +| `src/agents/magentic_agents.py` | 32-33 | "bioRxiv" → "Europe PMC" | +| `src/app.py` | 202-203 | "bioRxiv" → "Europe PMC" | + +**Search command to verify**: +```bash +grep -rn "bioRxiv\|biorxiv" src/ +``` + +--- + +## Implementation Checklist + +``` +[ ] Phase 1: Write failing tests + [ ] 1.1 Test ChatMessage text extraction + [ ] 1.2 Test max rounds configuration + [ ] 1.3 Test Europe PMC references + +[ ] Phase 2: Fix ChatMessage extraction + [ ] Update _process_event() in orchestrator_magentic.py + [ ] Run test 1.1 - should pass + +[ ] Phase 3: Fix max rounds + [ ] Add logging to _build_workflow() + [ ] Verify factory passes config correctly + [ ] Run test 1.2 - should pass + +[ ] Phase 4: Fix bioRxiv references + [ ] Update orchestrator_magentic.py task prompt + [ ] Update magentic_agents.py descriptions + [ ] Update app.py UI text + [ ] Run test 1.3 - should pass + [ ] Run grep to verify no remaining refs + +[ ] Final Verification + [ ] make check passes + [ ] All tests pass (108+) + [ ] Manual test: run_magentic.py produces readable report +``` + +--- + +## Test Commands + +```bash +# Run specific test file +uv run pytest tests/unit/test_orchestrator_magentic.py -v + +# Run all tests +uv run pytest tests/unit/ -v + +# Full check +make check + +# Manual integration test +set -a && source .env && set +a +uv run python examples/orchestrator_demo/run_magentic.py "metformin alzheimer" +``` + +--- + +## Success Criteria + +1. `run_magentic.py` outputs a readable research report (not ``) +2. Report includes: Executive Summary, Key Findings, Drug Candidates, References +3. No "Max round count reached" error with default settings +4. No "bioRxiv" references anywhere in codebase +5. All 108+ tests pass +6. `make check` passes + +--- + +## Files Modified + +``` +src/ +├── orchestrator_magentic.py # ChatMessage fix, logging +├── agents/magentic_agents.py # bioRxiv → Europe PMC +└── app.py # bioRxiv → Europe PMC + +tests/unit/ +└── test_orchestrator_magentic.py # NEW: 3 tests +``` + +--- + +## Notes for AI Agent + +When implementing this fix plan: + +1. **DO NOT** create mock data or fake responses +2. **DO** write real tests that verify actual behavior +3. **DO** run `make check` after each phase +4. **DO** test with real OpenAI API key via `.env` +5. **DO** preserve existing functionality - simple mode must still work +6. **DO NOT** over-engineer - minimal changes to fix the specific bugs diff --git a/docs/bugs/P0_MAGENTIC_MODE_BROKEN.md b/docs/bugs/P0_MAGENTIC_MODE_BROKEN.md new file mode 100644 index 0000000000000000000000000000000000000000..5df9c0ee27df1b416923f445b08be928f34432a2 --- /dev/null +++ b/docs/bugs/P0_MAGENTIC_MODE_BROKEN.md @@ -0,0 +1,116 @@ +# P0 Bug: Magentic Mode Returns ChatMessage Object Instead of Report Text + +**Status**: OPEN +**Priority**: P0 (Critical) +**Date**: 2025-11-27 + +--- + +## Actual Bug Found (Not What We Thought) + +**The OpenAI key works fine.** The real bug is different: + +### The Problem + +When Magentic mode completes, the final report returns a `ChatMessage` object instead of the actual text: + +``` +FINAL REPORT: + +``` + +### Evidence + +Full test output shows: +1. Magentic orchestrator starts correctly +2. SearchAgent finds evidence +3. HypothesisAgent generates hypotheses +4. JudgeAgent evaluates +5. **BUT**: Final output is `ChatMessage` object, not text + +### Root Cause + +In `src/orchestrator_magentic.py` line 193: + +```python +elif isinstance(event, MagenticFinalResultEvent): + text = event.message.text if event.message else "No result" +``` + +The `event.message` is a `ChatMessage` object, and `.text` may not extract the content correctly, or the message structure changed in the agent-framework library. + +--- + +## Secondary Issue: Max Rounds Reached + +The orchestrator hits max rounds before producing a report: + +``` +[ERROR] Magentic Orchestrator: Max round count reached +``` + +This means the workflow times out before the ReportAgent synthesizes the final output. + +--- + +## What Works + +- OpenAI API key: **Works** (loaded from .env) +- SearchAgent: **Works** (finds evidence from PubMed, ClinicalTrials, Europe PMC) +- HypothesisAgent: **Works** (generates Drug -> Target -> Pathway chains) +- JudgeAgent: **Partial** (evaluates but sometimes loses context) + +--- + +## Files to Fix + +| File | Line | Issue | +|------|------|-------| +| `src/orchestrator_magentic.py` | 193 | `event.message.text` returns object, not string | +| `src/orchestrator_magentic.py` | 97-99 | `max_round_count=3` too low for full pipeline | + +--- + +## Suggested Fix + +```python +# In _process_event, line 192-199 +elif isinstance(event, MagenticFinalResultEvent): + # Handle ChatMessage object properly + if event.message: + if hasattr(event.message, 'content'): + text = event.message.content + elif hasattr(event.message, 'text'): + text = event.message.text + else: + text = str(event.message) + else: + text = "No result" +``` + +And increase rounds: + +```python +# In _build_workflow, line 97 +max_round_count=self._max_rounds, # Use configured value, default 10 +``` + +--- + +## Test Command + +```bash +set -a && source .env && set +a && uv run python examples/orchestrator_demo/run_magentic.py "metformin alzheimer" +``` + +--- + +## Simple Mode Works + +For reference, simple mode produces full reports: + +```bash +uv run python examples/orchestrator_demo/run_agent.py "metformin alzheimer" +``` + +Output includes structured report with Drug Candidates, Key Findings, etc. diff --git a/docs/bugs/P1_GRADIO_SETTINGS_CLEANUP.md b/docs/bugs/P1_GRADIO_SETTINGS_CLEANUP.md new file mode 100644 index 0000000000000000000000000000000000000000..7197b1ec4ef09ea29b98cc447994264e6b4b0f54 --- /dev/null +++ b/docs/bugs/P1_GRADIO_SETTINGS_CLEANUP.md @@ -0,0 +1,81 @@ +# P1 Bug: Gradio Settings Accordion Not Collapsing + +**Priority**: P1 (UX Bug) +**Status**: OPEN +**Date**: 2025-11-27 +**Target Component**: `src/app.py` + +--- + +## 1. Problem Description + +The "Settings" accordion in the Gradio UI (containing Orchestrator Mode, API Key, Provider) fails to collapse, even when configured with `open=False`. It remains permanently expanded, cluttering the interface and obscuring the chat history. + +### Symptoms +- Accordion arrow toggles visually, but content remains visible. +- Occurs in both local development (`uv run src/app.py`) and HuggingFace Spaces. + +--- + +## 2. Root Cause Analysis + +**Definitive Cause**: Nested `Blocks` Context Bug. +`gr.ChatInterface` is itself a high-level abstraction that creates a `gr.Blocks` context. Wrapping `gr.ChatInterface` inside an external `with gr.Blocks():` context causes event listener conflicts, specifically breaking the JavaScript state management for `additional_inputs_accordion`. + +**Reference**: [Gradio Issue #8861](https://github.com/gradio-app/gradio/issues/8861) confirms that `additional_inputs_accordion` malfunctions when `ChatInterface` is not the top-level block. + +--- + +## 3. Solution Strategy: "The Unwrap Fix" + +We will remove the redundant `gr.Blocks` wrapper. This restores the native behavior of `ChatInterface`, ensuring the accordion respects `open=False`. + +### Implementation Plan + +**Refactor `src/app.py` / `create_demo()`**: + +1. **Remove** the `with gr.Blocks() as demo:` context manager. +2. **Instantiate** `gr.ChatInterface` directly as the `demo` object. +3. **Migrate UI Elements**: + * **Header**: Move the H1/Title text into the `title` parameter of `ChatInterface`. + * **Footer**: Move the footer text ("MCP Server Active...") into the `description` parameter. `ChatInterface` supports Markdown in `description`, making it the ideal place for static info below the title but above the chat. + +### Before (Buggy) +```python +def create_demo(): + with gr.Blocks() as demo: # <--- CAUSE OF BUG + gr.Markdown("# Title") + gr.ChatInterface(..., additional_inputs_accordion=gr.Accordion(open=False)) + gr.Markdown("Footer") + return demo +``` + +### After (Correct) +```python +def create_demo(): + return gr.ChatInterface( # <--- FIX: Top-level component + ..., + title="🧬 DeepCritical", + description="*AI-Powered Drug Repurposing Agent...*\n\n---\n**MCP Server Active**...", + additional_inputs_accordion=gr.Accordion(label="⚙️ Settings", open=False) + ) +``` + +--- + +## 4. Validation + +1. **Run**: `uv run python src/app.py` +2. **Check**: Open `http://localhost:7860` +3. **Verify**: + * Settings accordion starts **COLLAPSED**. + * Header title ("DeepCritical") is visible. + * Footer text ("MCP Server Active") is visible in the description area. + * Chat functionality works (Magentic/Simple modes). + +--- + +## 5. Constraints & Notes + +- **Layout**: We lose the ability to place arbitrary elements *below* the chat box (footer will move to top, under title), but this is an acceptable trade-off for a working UI. +- **CSS**: `ChatInterface` handles its own CSS; any custom class styling from the previous footer will be standardized to the description text style. \ No newline at end of file diff --git a/docs/development/testing.md b/docs/development/testing.md new file mode 100644 index 0000000000000000000000000000000000000000..47c8c32d6a96ebfc01ed9c54627e6287b5c0e722 --- /dev/null +++ b/docs/development/testing.md @@ -0,0 +1,139 @@ +# Testing Strategy +## ensuring DeepCritical is Ironclad + +--- + +## Overview + +Our testing strategy follows a strict **Pyramid of Reliability**: +1. **Unit Tests**: Fast, isolated logic checks (60% of tests) +2. **Integration Tests**: Tool interactions & Agent loops (30% of tests) +3. **E2E / Regression Tests**: Full research workflows (10% of tests) + +**Goal**: Ship a research agent that doesn't hallucinate, crash on API limits, or burn $100 in tokens by accident. + +--- + +## 1. Unit Tests (Fast & Cheap) + +**Location**: `tests/unit/` + +Focus on individual components without external network calls. Mock everything. + +### Key Test Cases + +#### Agent Logic +- **Initialization**: Verify default config loads correctly. +- **State Updates**: Ensure `ResearchState` updates correctly (e.g., token counts increment). +- **Budget Checks**: Test `should_continue()` returns `False` when budget exceeded. +- **Error Handling**: Test partial failure recovery (e.g., one tool fails, agent continues). + +#### Tools (Mocked) +- **Parser Logic**: Feed raw XML/JSON to tool parsers and verify `Evidence` objects. +- **Validation**: Ensure tools reject invalid queries (empty strings, etc.). + +#### Judge Prompts +- **Schema Compliance**: Verify prompt templates generate valid JSON structure instructions. +- **Variable Injection**: Ensure `{question}` and `{context}` are injected correctly into prompts. + +```python +# Example: Testing State Logic +def test_budget_stop(): + state = ResearchState(tokens_used=50001, max_tokens=50000) + assert should_continue(state) is False +``` + +--- + +## 2. Integration Tests (Realistic & Mocked I/O) + +**Location**: `tests/integration/` + +Focus on the interaction between the Orchestrator, Tools, and LLM Judge. Use **VCR.py** or **Replay** patterns to record/replay API calls to save money/time. + +### Key Test Cases + +#### Search Loop +- **Iteration Flow**: Verify agent performs Search -> Judge -> Search loop. +- **Tool Selection**: Verify correct tools are called based on judge output (mocked judge). +- **Context Accumulation**: Ensure findings from Iteration 1 are passed to Iteration 2. + +#### MCP Server Integration +- **Server Startup**: Verify MCP server starts and exposes tools. +- **Client Connection**: Verify agent can call tools via MCP protocol. + +```python +# Example: Testing Search Loop with Mocked Tools +async def test_search_loop_flow(): + agent = ResearchAgent(tools=[MockPubMed(), MockWeb()]) + report = await agent.run("test query") + assert agent.state.iterations > 0 + assert len(report.sources) > 0 +``` + +--- + +## 3. End-to-End (E2E) Tests (The "Real Deal") + +**Location**: `tests/e2e/` + +Run against **real APIs** (with strict rate limits) to verify system integrity. Run these **on demand** or **nightly**, not on every commit. + +### Key Test Cases + +#### The "Golden Query" +Run the primary demo query: *"What existing drugs might help treat long COVID fatigue?"* +- **Success Criteria**: + - Returns at least 2 valid drug candidates (e.g., CoQ10, LDN). + - Includes citations from PubMed. + - Completes within 3 iterations. + - JSON output matches schema. + +#### Deployment Smoke Test +- **Gradio UI**: Verify UI launches and accepts input. +- **Streaming**: Verify generator yields chunks (first chunk within 2s). + +--- + +## 4. Tools & Config + +### Pytest Configuration +```toml +# pyproject.toml +[tool.pytest.ini_options] +markers = [ + "unit: fast, isolated tests", + "integration: mocked network tests", + "e2e: real network tests (slow, expensive)" +] +asyncio_mode = "auto" +``` + +### CI/CD Pipeline (GitHub Actions) +1. **Lint**: `ruff check .` +2. **Type Check**: `mypy .` +3. **Unit**: `pytest -m unit` +4. **Integration**: `pytest -m integration` +5. **E2E**: (Manual trigger only) + +--- + +## 5. Anti-Hallucination Validation + +How do we test if the agent is lying? + +1. **Citation Check**: + - Regex verify that every `[PMID: 12345]` in the report exists in the `Evidence` list. + - Fail if a citation is "orphaned" (hallucinated ID). + +2. **Negative Constraints**: + - Test queries for fake diseases ("Ligma syndrome") -> Agent should return "No evidence found". + +--- + +## Checklist for Implementation + +- [ ] Set up `tests/` directory structure +- [ ] Configure `pytest` and `vcrpy` +- [ ] Create `tests/fixtures/` for mock data (PubMed XMLs) +- [ ] Write first unit test for `ResearchState` diff --git a/docs/examples/writer_agents_usage.md b/docs/examples/writer_agents_usage.md new file mode 100644 index 0000000000000000000000000000000000000000..cb30feff24a641f5dba4caa1eca6e0569c9cd222 --- /dev/null +++ b/docs/examples/writer_agents_usage.md @@ -0,0 +1,425 @@ +# Writer Agents Usage Examples + +This document provides examples of how to use the writer agents in DeepCritical for generating research reports. + +## Overview + +DeepCritical provides three writer agents for different report generation scenarios: + +1. **WriterAgent** - Basic writer for simple reports from findings +2. **LongWriterAgent** - Iterative writer for long-form multi-section reports +3. **ProofreaderAgent** - Finalizes and polishes report drafts + +## WriterAgent + +The `WriterAgent` generates final reports from research findings. It's used in iterative research flows. + +### Basic Usage + +```python +from src.agent_factory.agents import create_writer_agent + +# Create writer agent +writer = create_writer_agent() + +# Generate report +query = "What is the capital of France?" +findings = """ +Paris is the capital of France [1]. +It is located in the north-central part of the country [2]. + +[1] https://example.com/france-info +[2] https://example.com/paris-info +""" + +report = await writer.write_report( + query=query, + findings=findings, +) + +print(report) +``` + +### With Output Length Specification + +```python +report = await writer.write_report( + query="Explain machine learning", + findings=findings, + output_length="500 words", +) +``` + +### With Additional Instructions + +```python +report = await writer.write_report( + query="Explain machine learning", + findings=findings, + output_length="A comprehensive overview", + output_instructions="Use formal academic language and include examples", +) +``` + +### Integration with IterativeResearchFlow + +The `WriterAgent` is automatically used by `IterativeResearchFlow`: + +```python +from src.agent_factory.agents import create_iterative_flow + +flow = create_iterative_flow(max_iterations=5, max_time_minutes=10) +report = await flow.run( + query="What is quantum computing?", + output_length="A detailed explanation", + output_instructions="Include practical applications", +) +``` + +## LongWriterAgent + +The `LongWriterAgent` iteratively writes report sections with proper citation management. It's used in deep research flows. + +### Basic Usage + +```python +from src.agent_factory.agents import create_long_writer_agent +from src.utils.models import ReportDraft, ReportDraftSection + +# Create long writer agent +long_writer = create_long_writer_agent() + +# Create report draft with sections +report_draft = ReportDraft( + sections=[ + ReportDraftSection( + section_title="Introduction", + section_content="Draft content for introduction with [1].", + ), + ReportDraftSection( + section_title="Methods", + section_content="Draft content for methods with [2].", + ), + ReportDraftSection( + section_title="Results", + section_content="Draft content for results with [3].", + ), + ] +) + +# Generate full report +report = await long_writer.write_report( + original_query="What are the main features of Python?", + report_title="Python Programming Language Overview", + report_draft=report_draft, +) + +print(report) +``` + +### Writing Individual Sections + +You can also write sections one at a time: + +```python +# Write first section +section_output = await long_writer.write_next_section( + original_query="What is Python?", + report_draft="", # No existing draft + next_section_title="Introduction", + next_section_draft="Python is a programming language...", +) + +print(section_output.next_section_markdown) +print(section_output.references) + +# Write second section with existing draft +section_output = await long_writer.write_next_section( + original_query="What is Python?", + report_draft="# Report\n\n## Introduction\n\nContent...", + next_section_title="Features", + next_section_draft="Python features include...", +) +``` + +### Integration with DeepResearchFlow + +The `LongWriterAgent` is automatically used by `DeepResearchFlow`: + +```python +from src.agent_factory.agents import create_deep_flow + +flow = create_deep_flow( + max_iterations=5, + max_time_minutes=10, + use_long_writer=True, # Use long writer (default) +) + +report = await flow.run("What are the main features of Python programming language?") +``` + +## ProofreaderAgent + +The `ProofreaderAgent` finalizes and polishes report drafts by removing duplicates, adding summaries, and refining wording. + +### Basic Usage + +```python +from src.agent_factory.agents import create_proofreader_agent +from src.utils.models import ReportDraft, ReportDraftSection + +# Create proofreader agent +proofreader = create_proofreader_agent() + +# Create report draft +report_draft = ReportDraft( + sections=[ + ReportDraftSection( + section_title="Introduction", + section_content="Python is a programming language [1].", + ), + ReportDraftSection( + section_title="Features", + section_content="Python has many features [2].", + ), + ] +) + +# Proofread and finalize +final_report = await proofreader.proofread( + query="What is Python?", + report_draft=report_draft, +) + +print(final_report) +``` + +### Integration with DeepResearchFlow + +Use `ProofreaderAgent` instead of `LongWriterAgent`: + +```python +from src.agent_factory.agents import create_deep_flow + +flow = create_deep_flow( + max_iterations=5, + max_time_minutes=10, + use_long_writer=False, # Use proofreader instead +) + +report = await flow.run("What are the main features of Python?") +``` + +## Error Handling + +All writer agents include robust error handling: + +### Handling Empty Inputs + +```python +# WriterAgent handles empty findings gracefully +report = await writer.write_report( + query="Test query", + findings="", # Empty findings +) +# Returns a fallback report + +# LongWriterAgent handles empty sections +report = await long_writer.write_report( + original_query="Test", + report_title="Test Report", + report_draft=ReportDraft(sections=[]), # Empty draft +) +# Returns minimal report + +# ProofreaderAgent handles empty drafts +report = await proofreader.proofread( + query="Test", + report_draft=ReportDraft(sections=[]), +) +# Returns minimal report +``` + +### Retry Logic + +All agents automatically retry on transient errors (timeouts, connection errors): + +```python +# Automatically retries up to 3 times on transient failures +report = await writer.write_report( + query="Test query", + findings=findings, +) +``` + +### Fallback Reports + +If all retries fail, agents return fallback reports: + +```python +# Returns fallback report with query and findings +report = await writer.write_report( + query="Test query", + findings=findings, +) +# Fallback includes: "# Research Report\n\n## Query\n...\n\n## Findings\n..." +``` + +## Citation Validation + +### For Markdown Reports + +Use the markdown citation validator: + +```python +from src.utils.citation_validator import validate_markdown_citations +from src.utils.models import Evidence, Citation + +# Collect evidence during research +evidence = [ + Evidence( + content="Paris is the capital of France", + citation=Citation( + source="web", + title="France Information", + url="https://example.com/france", + date="2024-01-01", + ), + ), +] + +# Generate report +report = await writer.write_report(query="What is the capital of France?", findings=findings) + +# Validate citations +validated_report, removed_count = validate_markdown_citations(report, evidence) + +if removed_count > 0: + print(f"Removed {removed_count} invalid citations") +``` + +### For ResearchReport Objects + +Use the structured citation validator: + +```python +from src.utils.citation_validator import validate_references + +# For ResearchReport objects (from ReportAgent) +validated_report = validate_references(report, evidence) +``` + +## Custom Model Configuration + +All writer agents support custom model configuration: + +```python +from pydantic_ai import Model + +# Create custom model +custom_model = Model("openai", "gpt-4") + +# Use with writer agents +writer = create_writer_agent(model=custom_model) +long_writer = create_long_writer_agent(model=custom_model) +proofreader = create_proofreader_agent(model=custom_model) +``` + +## Best Practices + +1. **Use WriterAgent for simple reports** - When you have findings as a string and need a quick report +2. **Use LongWriterAgent for structured reports** - When you need multiple sections with proper citation management +3. **Use ProofreaderAgent for final polish** - When you have draft sections and need a polished final report +4. **Validate citations** - Always validate citations against collected evidence +5. **Handle errors gracefully** - All agents return fallback reports on failure +6. **Specify output length** - Use `output_length` parameter to control report size +7. **Provide instructions** - Use `output_instructions` for specific formatting requirements + +## Integration Examples + +### Full Iterative Research Flow + +```python +from src.agent_factory.agents import create_iterative_flow + +flow = create_iterative_flow( + max_iterations=5, + max_time_minutes=10, +) + +report = await flow.run( + query="What is machine learning?", + output_length="A comprehensive 1000-word explanation", + output_instructions="Include practical examples and use cases", +) +``` + +### Full Deep Research Flow with Long Writer + +```python +from src.agent_factory.agents import create_deep_flow + +flow = create_deep_flow( + max_iterations=5, + max_time_minutes=10, + use_long_writer=True, +) + +report = await flow.run("What are the main features of Python programming language?") +``` + +### Full Deep Research Flow with Proofreader + +```python +from src.agent_factory.agents import create_deep_flow + +flow = create_deep_flow( + max_iterations=5, + max_time_minutes=10, + use_long_writer=False, # Use proofreader +) + +report = await flow.run("Explain quantum computing basics") +``` + +## Troubleshooting + +### Empty Reports + +If you get empty reports, check: +- Input validation logs (agents log warnings for empty inputs) +- LLM API key configuration +- Network connectivity + +### Citation Issues + +If citations are missing or invalid: +- Use `validate_markdown_citations()` to check citations +- Ensure Evidence objects are properly collected during research +- Check that URLs in findings match Evidence URLs + +### Performance Issues + +For large reports: +- Use `LongWriterAgent` for better section management +- Consider truncating very long findings (agents do this automatically) +- Use appropriate `max_time_minutes` settings + +## See Also + +- [Research Flows Documentation](../orchestrator/research_flows.md) +- [Citation Validation](../utils/citation_validation.md) +- [Agent Factory](../agent_factory/agents.md) + + + + + + + + + + + + + diff --git a/docs/guides/deployment.md b/docs/guides/deployment.md new file mode 100644 index 0000000000000000000000000000000000000000..35fe7e49ab2ea9f80ff502506cb22f37ce17608a --- /dev/null +++ b/docs/guides/deployment.md @@ -0,0 +1,142 @@ +# Deployment Guide +## Launching DeepCritical: Gradio, MCP, & Modal + +--- + +## Overview + +DeepCritical is designed for a multi-platform deployment strategy to maximize hackathon impact: + +1. **HuggingFace Spaces**: Host the Gradio UI (User Interface). +2. **MCP Server**: Expose research tools to Claude Desktop/Agents. +3. **Modal (Optional)**: Run heavy inference or local LLMs if API costs are prohibitive. + +--- + +## 1. HuggingFace Spaces (Gradio UI) + +**Goal**: A public URL where judges/users can try the research agent. + +### Prerequisites +- HuggingFace Account +- `gradio` installed (`uv add gradio`) + +### Steps + +1. **Create Space**: + - Go to HF Spaces -> Create New Space. + - SDK: **Gradio**. + - Hardware: **CPU Basic** (Free) is sufficient (since we use APIs). + +2. **Prepare Files**: + - Ensure `app.py` contains the Gradio interface construction. + - Ensure `requirements.txt` or `pyproject.toml` lists all dependencies. + +3. **Secrets**: + - Go to Space Settings -> **Repository secrets**. + - Add `ANTHROPIC_API_KEY` (or your chosen LLM provider key). + - Add `BRAVE_API_KEY` (for web search). + +4. **Deploy**: + - Push code to the Space's git repo. + - Watch "Build" logs. + +### Streaming Optimization +Ensure `app.py` uses generator functions for the chat interface to prevent timeouts: +```python +# app.py +def predict(message, history): + agent = ResearchAgent() + for update in agent.research_stream(message): + yield update +``` + +--- + +## 2. MCP Server Deployment + +**Goal**: Allow other agents (like Claude Desktop) to use our PubMed/Research tools directly. + +### Local Usage (Claude Desktop) + +1. **Install**: + ```bash + uv sync + ``` + +2. **Configure Claude Desktop**: + Edit `~/Library/Application Support/Claude/claude_desktop_config.json`: + ```json + { + "mcpServers": { + "deepcritical": { + "command": "uv", + "args": ["run", "fastmcp", "run", "src/mcp_servers/pubmed_server.py"], + "cwd": "/absolute/path/to/DeepCritical" + } + } + } + ``` + +3. **Restart Claude**: You should see a 🔌 icon indicating connected tools. + +### Remote Deployment (Smithery/Glama) +*Target for "MCP Track" bonus points.* + +1. **Dockerize**: Create a `Dockerfile` for the MCP server. + ```dockerfile + FROM python:3.11-slim + COPY . /app + RUN pip install fastmcp httpx + CMD ["fastmcp", "run", "src/mcp_servers/pubmed_server.py", "--transport", "sse"] + ``` + *Note: Use SSE transport for remote/HTTP servers.* + +2. **Deploy**: Host on Fly.io or Railway. + +--- + +## 3. Modal (GPU/Heavy Compute) + +**Goal**: Run a local LLM (e.g., Llama-3-70B) or handle massive parallel searches if APIs are too slow/expensive. + +### Setup +1. **Install**: `uv add modal` +2. **Auth**: `modal token new` + +### Logic +Instead of calling Anthropic API, we call a Modal function: + +```python +# src/llm/modal_client.py +import modal + +stub = modal.Stub("deepcritical-inference") + +@stub.function(gpu="A100") +def generate_text(prompt: str): + # Load vLLM or similar + ... +``` + +### When to use? +- **Hackathon Demo**: Stick to Anthropic/OpenAI APIs for speed/reliability. +- **Production/Stretch**: Use Modal if you hit rate limits or want to show off "Open Source Models" capability. + +--- + +## Deployment Checklist + +### Pre-Flight +- [ ] Run `pytest -m unit` to ensure logic is sound. +- [ ] Run `pytest -m e2e` (one pass) to verify APIs connect. +- [ ] Check `requirements.txt` matches `pyproject.toml`. + +### Secrets Management +- [ ] **NEVER** commit `.env` files. +- [ ] Verify keys are added to HF Space settings. + +### Post-Launch +- [ ] Test the live URL. +- [ ] Verify "Stop" button in Gradio works (interrupts the agent). +- [ ] Record a walkthrough video (crucial for hackathon submission). diff --git a/docs/implementation/01_phase_foundation.md b/docs/implementation/01_phase_foundation.md new file mode 100644 index 0000000000000000000000000000000000000000..2b44c4c0629e32444493f9ec60cf5ab0bfd22796 --- /dev/null +++ b/docs/implementation/01_phase_foundation.md @@ -0,0 +1,587 @@ +# Phase 1 Implementation Spec: Foundation & Tooling + +**Goal**: Establish a "Gucci Banger" development environment using 2025 best practices. +**Philosophy**: "If the build isn't solid, the agent won't be." + +--- + +## 1. Prerequisites + +Before starting, ensure these are installed: + +```bash +# Install uv (Rust-based package manager) +curl -LsSf https://astral.sh/uv/install.sh | sh + +# Verify +uv --version # Should be >= 0.4.0 +``` + +--- + +## 2. Project Initialization + +```bash +# From project root +uv init --name deepcritical +uv python install 3.11 # Pin Python version +``` + +--- + +## 3. The Tooling Stack (Exact Dependencies) + +### `pyproject.toml` (Complete, Copy-Paste Ready) + +```toml +[project] +name = "deepcritical" +version = "0.1.0" +description = "AI-Native Drug Repurposing Research Agent" +readme = "README.md" +requires-python = ">=3.11" +dependencies = [ + # Core + "pydantic>=2.7", + "pydantic-settings>=2.2", # For BaseSettings (config) + "pydantic-ai>=0.0.16", # Agent framework + + # HTTP & Parsing + "httpx>=0.27", # Async HTTP client + "beautifulsoup4>=4.12", # HTML parsing + "xmltodict>=0.13", # PubMed XML -> dict + + # Search + "duckduckgo-search>=6.0", # Free web search + + # UI + "gradio>=5.0", # Chat interface + + # Utils + "python-dotenv>=1.0", # .env loading + "tenacity>=8.2", # Retry logic + "structlog>=24.1", # Structured logging +] + +[project.optional-dependencies] +dev = [ + # Testing + "pytest>=8.0", + "pytest-asyncio>=0.23", + "pytest-sugar>=1.0", + "pytest-cov>=5.0", + "pytest-mock>=3.12", + "respx>=0.21", # Mock httpx requests + + # Quality + "ruff>=0.4.0", + "mypy>=1.10", + "pre-commit>=3.7", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src"] + +# ============== RUFF CONFIG ============== +[tool.ruff] +line-length = 100 +target-version = "py311" +src = ["src", "tests"] + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "F", # pyflakes + "B", # flake8-bugbear + "I", # isort + "N", # pep8-naming + "UP", # pyupgrade + "PL", # pylint + "RUF", # ruff-specific +] +ignore = [ + "PLR0913", # Too many arguments (agents need many params) +] + +[tool.ruff.lint.isort] +known-first-party = ["src"] + +# ============== MYPY CONFIG ============== +[tool.mypy] +python_version = "3.11" +strict = true +ignore_missing_imports = true +disallow_untyped_defs = true +warn_return_any = true +warn_unused_ignores = true + +# ============== PYTEST CONFIG ============== +[tool.pytest.ini_options] +testpaths = ["tests"] +asyncio_mode = "auto" +addopts = [ + "-v", + "--tb=short", + "--strict-markers", +] +markers = [ + "unit: Unit tests (mocked)", + "integration: Integration tests (real APIs)", + "slow: Slow tests", +] + +# ============== COVERAGE CONFIG ============== +[tool.coverage.run] +source = ["src"] +omit = ["*/__init__.py"] + +[tool.coverage.report] +exclude_lines = [ + "pragma: no cover", + "if TYPE_CHECKING:", + "raise NotImplementedError", +] +``` + +--- + +## 4. Directory Structure (Maintainer's Structure) + +```bash +# Execute these commands to create the directory structure +mkdir -p src/utils +mkdir -p src/tools +mkdir -p src/prompts +mkdir -p src/agent_factory +mkdir -p src/middleware +mkdir -p src/database_services +mkdir -p src/retrieval_factory +mkdir -p tests/unit/tools +mkdir -p tests/unit/agent_factory +mkdir -p tests/unit/utils +mkdir -p tests/integration + +# Create __init__.py files (required for imports) +touch src/__init__.py +touch src/utils/__init__.py +touch src/tools/__init__.py +touch src/prompts/__init__.py +touch src/agent_factory/__init__.py +touch tests/__init__.py +touch tests/unit/__init__.py +touch tests/unit/tools/__init__.py +touch tests/unit/agent_factory/__init__.py +touch tests/unit/utils/__init__.py +touch tests/integration/__init__.py +``` + +### Final Structure: + +``` +src/ +├── __init__.py +├── app.py # Entry point (Gradio UI) +├── orchestrator.py # Agent loop +├── agent_factory/ # Agent creation and judges +│ ├── __init__.py +│ ├── agents.py +│ └── judges.py +├── tools/ # Search tools +│ ├── __init__.py +│ ├── pubmed.py +│ ├── websearch.py +│ └── search_handler.py +├── prompts/ # Prompt templates +│ ├── __init__.py +│ └── judge.py +├── utils/ # Shared utilities +│ ├── __init__.py +│ ├── config.py +│ ├── exceptions.py +│ ├── models.py +│ ├── dataloaders.py +│ └── parsers.py +├── middleware/ # (Future) +├── database_services/ # (Future) +└── retrieval_factory/ # (Future) + +tests/ +├── __init__.py +├── conftest.py +├── unit/ +│ ├── __init__.py +│ ├── tools/ +│ │ ├── __init__.py +│ │ ├── test_pubmed.py +│ │ ├── test_websearch.py +│ │ └── test_search_handler.py +│ ├── agent_factory/ +│ │ ├── __init__.py +│ │ └── test_judges.py +│ ├── utils/ +│ │ ├── __init__.py +│ │ └── test_config.py +│ └── test_orchestrator.py +└── integration/ + ├── __init__.py + └── test_pubmed_live.py +``` + +--- + +## 5. Configuration Files + +### `.env.example` (Copy to `.env` and fill) + +```bash +# LLM Provider (choose one) +OPENAI_API_KEY=sk-your-key-here +ANTHROPIC_API_KEY=sk-ant-your-key-here + +# Optional: PubMed API key (higher rate limits) +NCBI_API_KEY=your-ncbi-key-here + +# Optional: For HuggingFace deployment +HF_TOKEN=hf_your-token-here + +# Agent Config +MAX_ITERATIONS=10 +LOG_LEVEL=INFO +``` + +### `.pre-commit-config.yaml` + +```yaml +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.4.4 + hooks: + - id: ruff + args: [--fix] + - id: ruff-format + + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.10.0 + hooks: + - id: mypy + additional_dependencies: + - pydantic>=2.7 + - pydantic-settings>=2.2 + args: [--ignore-missing-imports] +``` + +### `tests/conftest.py` (Pytest Fixtures) + +```python +"""Shared pytest fixtures for all tests.""" +import pytest +from unittest.mock import AsyncMock + + +@pytest.fixture +def mock_httpx_client(mocker): + """Mock httpx.AsyncClient for API tests.""" + mock = mocker.patch("httpx.AsyncClient") + mock.return_value.__aenter__ = AsyncMock(return_value=mock.return_value) + mock.return_value.__aexit__ = AsyncMock(return_value=None) + return mock + + +@pytest.fixture +def mock_llm_response(): + """Factory fixture for mocking LLM responses.""" + def _mock(content: str): + return AsyncMock(return_value=content) + return _mock + + +@pytest.fixture +def sample_evidence(): + """Sample Evidence objects for testing.""" + from src.utils.models import Evidence, Citation + return [ + Evidence( + content="Metformin shows promise in Alzheimer's...", + citation=Citation( + source="pubmed", + title="Metformin and Alzheimer's Disease", + url="https://pubmed.ncbi.nlm.nih.gov/12345678/", + date="2024-01-15" + ), + relevance=0.85 + ) + ] +``` + +--- + +## 6. Core Utilities Implementation + +### `src/utils/config.py` + +```python +"""Application configuration using Pydantic Settings.""" +from pydantic_settings import BaseSettings, SettingsConfigDict +from pydantic import Field +from typing import Literal +import structlog + + +class Settings(BaseSettings): + """Strongly-typed application settings.""" + + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + case_sensitive=False, + extra="ignore", + ) + + # LLM Configuration + openai_api_key: str | None = Field(default=None, description="OpenAI API key") + anthropic_api_key: str | None = Field(default=None, description="Anthropic API key") + llm_provider: Literal["openai", "anthropic"] = Field( + default="openai", + description="Which LLM provider to use" + ) + openai_model: str = Field(default="gpt-4o", description="OpenAI model name") + anthropic_model: str = Field(default="claude-3-5-sonnet-20241022", description="Anthropic model") + + # PubMed Configuration + ncbi_api_key: str | None = Field(default=None, description="NCBI API key for higher rate limits") + + # Agent Configuration + max_iterations: int = Field(default=10, ge=1, le=50) + search_timeout: int = Field(default=30, description="Seconds to wait for search") + + # Logging + log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR"] = "INFO" + + def get_api_key(self) -> str: + """Get the API key for the configured provider.""" + if self.llm_provider == "openai": + if not self.openai_api_key: + raise ValueError("OPENAI_API_KEY not set") + return self.openai_api_key + else: + if not self.anthropic_api_key: + raise ValueError("ANTHROPIC_API_KEY not set") + return self.anthropic_api_key + + +def get_settings() -> Settings: + """Factory function to get settings (allows mocking in tests).""" + return Settings() + + +def configure_logging(settings: Settings) -> None: + """Configure structured logging.""" + structlog.configure( + processors=[ + structlog.stdlib.filter_by_level, + structlog.stdlib.add_logger_name, + structlog.stdlib.add_log_level, + structlog.processors.TimeStamper(fmt="iso"), + structlog.processors.JSONRenderer(), + ], + wrapper_class=structlog.stdlib.BoundLogger, + context_class=dict, + logger_factory=structlog.stdlib.LoggerFactory(), + ) + + +# Singleton for easy import +settings = get_settings() +``` + +### `src/utils/exceptions.py` + +```python +"""Custom exceptions for DeepCritical.""" + + +class DeepCriticalError(Exception): + """Base exception for all DeepCritical errors.""" + pass + + +class SearchError(DeepCriticalError): + """Raised when a search operation fails.""" + pass + + +class JudgeError(DeepCriticalError): + """Raised when the judge fails to assess evidence.""" + pass + + +class ConfigurationError(DeepCriticalError): + """Raised when configuration is invalid.""" + pass + + +class RateLimitError(SearchError): + """Raised when we hit API rate limits.""" + pass +``` + +--- + +## 7. TDD Workflow: First Test + +### `tests/unit/utils/test_config.py` + +```python +"""Unit tests for configuration loading.""" +import pytest +from unittest.mock import patch +import os + + +class TestSettings: + """Tests for Settings class.""" + + def test_default_max_iterations(self): + """Settings should have default max_iterations of 10.""" + from src.utils.config import Settings + + # Clear any env vars + with patch.dict(os.environ, {}, clear=True): + settings = Settings() + assert settings.max_iterations == 10 + + def test_max_iterations_from_env(self): + """Settings should read MAX_ITERATIONS from env.""" + from src.utils.config import Settings + + with patch.dict(os.environ, {"MAX_ITERATIONS": "25"}): + settings = Settings() + assert settings.max_iterations == 25 + + def test_invalid_max_iterations_raises(self): + """Settings should reject invalid max_iterations.""" + from src.utils.config import Settings + from pydantic import ValidationError + + with patch.dict(os.environ, {"MAX_ITERATIONS": "100"}): + with pytest.raises(ValidationError): + Settings() # 100 > 50 (max) + + def test_get_api_key_openai(self): + """get_api_key should return OpenAI key when provider is openai.""" + from src.utils.config import Settings + + with patch.dict(os.environ, { + "LLM_PROVIDER": "openai", + "OPENAI_API_KEY": "sk-test-key" + }): + settings = Settings() + assert settings.get_api_key() == "sk-test-key" + + def test_get_api_key_missing_raises(self): + """get_api_key should raise when key is not set.""" + from src.utils.config import Settings + + with patch.dict(os.environ, {"LLM_PROVIDER": "openai"}, clear=True): + settings = Settings() + with pytest.raises(ValueError, match="OPENAI_API_KEY not set"): + settings.get_api_key() +``` + +--- + +## 8. Makefile (Developer Experience) + +Create a `Makefile` for standard devex commands: + +```makefile +.PHONY: install test lint format typecheck check clean + +install: + uv sync --all-extras + uv run pre-commit install + +test: + uv run pytest tests/unit/ -v + +test-cov: + uv run pytest --cov=src --cov-report=term-missing + +lint: + uv run ruff check src tests + +format: + uv run ruff format src tests + +typecheck: + uv run mypy src + +check: lint typecheck test + @echo "All checks passed!" + +clean: + rm -rf .pytest_cache .mypy_cache .ruff_cache __pycache__ .coverage + find . -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true +``` + +--- + +## 9. Execution Commands + +```bash +# Install all dependencies +uv sync --all-extras + +# Run tests (should pass after implementing config.py) +uv run pytest tests/unit/utils/test_config.py -v + +# Run full test suite with coverage +uv run pytest --cov=src --cov-report=term-missing + +# Run linting +uv run ruff check src tests +uv run ruff format src tests + +# Run type checking +uv run mypy src + +# Set up pre-commit hooks +uv run pre-commit install +``` + +--- + +## 10. Implementation Checklist + +- [ ] Install `uv` and verify version +- [ ] Run `uv init --name deepcritical` +- [ ] Create `pyproject.toml` (copy from above) +- [ ] Create directory structure (run mkdir commands) +- [ ] Create `.env.example` and `.env` +- [ ] Create `.pre-commit-config.yaml` +- [ ] Create `Makefile` (copy from above) +- [ ] Create `tests/conftest.py` +- [ ] Implement `src/utils/config.py` +- [ ] Implement `src/utils/exceptions.py` +- [ ] Write tests in `tests/unit/utils/test_config.py` +- [ ] Run `make install` +- [ ] Run `make check` — **ALL CHECKS MUST PASS** +- [ ] Commit: `git commit -m "feat: phase 1 foundation complete"` + +--- + +## 11. Definition of Done + +Phase 1 is **COMPLETE** when: + +1. `uv run pytest` passes with 100% of tests green +2. `uv run ruff check src tests` has 0 errors +3. `uv run mypy src` has 0 errors +4. Pre-commit hooks are installed and working +5. `from src.utils.config import settings` works in Python REPL + +**Proceed to Phase 2 ONLY after all checkboxes are complete.** diff --git a/docs/implementation/02_phase_search.md b/docs/implementation/02_phase_search.md new file mode 100644 index 0000000000000000000000000000000000000000..62a0564d0d21f9963e72d42728f5090b37523369 --- /dev/null +++ b/docs/implementation/02_phase_search.md @@ -0,0 +1,822 @@ +# Phase 2 Implementation Spec: Search Vertical Slice + +**Goal**: Implement the "Eyes and Ears" of the agent — retrieving real biomedical data. +**Philosophy**: "Real data, mocked connections." +**Prerequisite**: Phase 1 complete (all tests passing) + +> **⚠️ Implementation Note (2025-01-27)**: The DuckDuckGo WebTool specified in this phase was removed in favor of the Europe PMC tool (see Phase 11). Europe PMC provides better coverage for biomedical research by including preprints, peer-reviewed articles, and patents. The current implementation uses PubMed, ClinicalTrials.gov, and Europe PMC as search sources. + +--- + +## 1. The Slice Definition + +This slice covers: +1. **Input**: A string query (e.g., "metformin Alzheimer's disease"). +2. **Process**: + - Fetch from PubMed (E-utilities API). + - ~~Fetch from Web (DuckDuckGo).~~ **REMOVED** - Replaced by Europe PMC in Phase 11 + - Normalize results into `Evidence` models. +3. **Output**: A list of `Evidence` objects. + +**Files to Create**: +- `src/utils/models.py` - Pydantic models (Evidence, Citation, SearchResult) +- `src/tools/pubmed.py` - PubMed E-utilities tool +- ~~`src/tools/websearch.py` - DuckDuckGo search tool~~ **REMOVED** - See Phase 11 for Europe PMC replacement +- `src/tools/search_handler.py` - Orchestrates multiple tools +- `src/tools/__init__.py` - Exports + +**Additional Files (Post-Phase 2 Enhancements)**: +- `src/tools/query_utils.py` - Query preprocessing (removes question words, expands medical synonyms) + +--- + +## 2. PubMed E-utilities API Reference + +**Base URL**: `https://eutils.ncbi.nlm.nih.gov/entrez/eutils/` + +### Key Endpoints + +| Endpoint | Purpose | Example | +|----------|---------|---------| +| `esearch.fcgi` | Search for article IDs | `?db=pubmed&term=metformin+alzheimer&retmax=10` | +| `efetch.fcgi` | Fetch article details | `?db=pubmed&id=12345,67890&rettype=abstract&retmode=xml` | + +### Rate Limiting (CRITICAL!) + +NCBI **requires** rate limiting: +- **Without API key**: 3 requests/second +- **With API key**: 10 requests/second + +Get a free API key: https://www.ncbi.nlm.nih.gov/account/settings/ + +```python +# Add to .env +NCBI_API_KEY=your-key-here # Optional but recommended +``` + +### Example Search Flow + +``` +1. esearch: "metformin alzheimer" → [PMID: 12345, 67890, ...] +2. efetch: PMIDs → Full abstracts/metadata +3. Parse XML → Evidence objects +``` + +--- + +## 3. Models (`src/utils/models.py`) + +```python +"""Data models for the Search feature.""" +from pydantic import BaseModel, Field +from typing import Literal + + +class Citation(BaseModel): + """A citation to a source document.""" + + source: Literal["pubmed", "web"] = Field(description="Where this came from") + title: str = Field(min_length=1, max_length=500) + url: str = Field(description="URL to the source") + date: str = Field(description="Publication date (YYYY-MM-DD or 'Unknown')") + authors: list[str] = Field(default_factory=list) + + @property + def formatted(self) -> str: + """Format as a citation string.""" + author_str = ", ".join(self.authors[:3]) + if len(self.authors) > 3: + author_str += " et al." + return f"{author_str} ({self.date}). {self.title}. {self.source.upper()}" + + +class Evidence(BaseModel): + """A piece of evidence retrieved from search.""" + + content: str = Field(min_length=1, description="The actual text content") + citation: Citation + relevance: float = Field(default=0.0, ge=0.0, le=1.0, description="Relevance score 0-1") + + class Config: + frozen = True # Immutable after creation + + +class SearchResult(BaseModel): + """Result of a search operation.""" + + query: str + evidence: list[Evidence] + sources_searched: list[Literal["pubmed", "web"]] + total_found: int + errors: list[str] = Field(default_factory=list) +``` + +--- + +## 4. Tool Protocol (`src/tools/pubmed.py` and `src/tools/websearch.py`) + +### The Interface (Protocol) - Add to `src/tools/__init__.py` + +```python +"""Search tools package.""" +from typing import Protocol, List + +# Import implementations +from src.tools.pubmed import PubMedTool +from src.tools.websearch import WebTool +from src.tools.search_handler import SearchHandler + +# Re-export +__all__ = ["SearchTool", "PubMedTool", "WebTool", "SearchHandler"] + + +class SearchTool(Protocol): + """Protocol defining the interface for all search tools.""" + + @property + def name(self) -> str: + """Human-readable name of this tool.""" + ... + + async def search(self, query: str, max_results: int = 10) -> List["Evidence"]: + """ + Execute a search and return evidence. + + Args: + query: The search query string + max_results: Maximum number of results to return + + Returns: + List of Evidence objects + + Raises: + SearchError: If the search fails + RateLimitError: If we hit rate limits + """ + ... +``` + +### PubMed Tool Implementation (`src/tools/pubmed.py`) + +```python +"""PubMed search tool using NCBI E-utilities.""" +import asyncio +import httpx +import xmltodict +from typing import List +from tenacity import retry, stop_after_attempt, wait_exponential + +from src.utils.config import settings +from src.utils.exceptions import SearchError, RateLimitError +from src.utils.models import Evidence, Citation + + +class PubMedTool: + """Search tool for PubMed/NCBI.""" + + BASE_URL = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils" + RATE_LIMIT_DELAY = 0.34 # ~3 requests/sec without API key + + def __init__(self, api_key: str | None = None): + self.api_key = api_key or getattr(settings, "ncbi_api_key", None) + self._last_request_time = 0.0 + + @property + def name(self) -> str: + return "pubmed" + + async def _rate_limit(self) -> None: + """Enforce NCBI rate limiting.""" + now = asyncio.get_event_loop().time() + elapsed = now - self._last_request_time + if elapsed < self.RATE_LIMIT_DELAY: + await asyncio.sleep(self.RATE_LIMIT_DELAY - elapsed) + self._last_request_time = asyncio.get_event_loop().time() + + def _build_params(self, **kwargs) -> dict: + """Build request params with optional API key.""" + params = {**kwargs, "retmode": "json"} + if self.api_key: + params["api_key"] = self.api_key + return params + + @retry( + stop=stop_after_attempt(3), + wait=wait_exponential(multiplier=1, min=1, max=10), + reraise=True, + ) + async def search(self, query: str, max_results: int = 10) -> List[Evidence]: + """ + Search PubMed and return evidence. + + 1. ESearch: Get PMIDs matching query + 2. EFetch: Get abstracts for those PMIDs + 3. Parse and return Evidence objects + """ + await self._rate_limit() + + async with httpx.AsyncClient(timeout=30.0) as client: + # Step 1: Search for PMIDs + search_params = self._build_params( + db="pubmed", + term=query, + retmax=max_results, + sort="relevance", + ) + + try: + search_resp = await client.get( + f"{self.BASE_URL}/esearch.fcgi", + params=search_params, + ) + search_resp.raise_for_status() + except httpx.HTTPStatusError as e: + if e.response.status_code == 429: + raise RateLimitError("PubMed rate limit exceeded") + raise SearchError(f"PubMed search failed: {e}") + + search_data = search_resp.json() + pmids = search_data.get("esearchresult", {}).get("idlist", []) + + if not pmids: + return [] + + # Step 2: Fetch abstracts + await self._rate_limit() + fetch_params = self._build_params( + db="pubmed", + id=",".join(pmids), + rettype="abstract", + ) + # Use XML for fetch (more reliable parsing) + fetch_params["retmode"] = "xml" + + fetch_resp = await client.get( + f"{self.BASE_URL}/efetch.fcgi", + params=fetch_params, + ) + fetch_resp.raise_for_status() + + # Step 3: Parse XML to Evidence + return self._parse_pubmed_xml(fetch_resp.text) + + def _parse_pubmed_xml(self, xml_text: str) -> List[Evidence]: + """Parse PubMed XML into Evidence objects.""" + try: + data = xmltodict.parse(xml_text) + except Exception as e: + raise SearchError(f"Failed to parse PubMed XML: {e}") + + articles = data.get("PubmedArticleSet", {}).get("PubmedArticle", []) + + # Handle single article (xmltodict returns dict instead of list) + if isinstance(articles, dict): + articles = [articles] + + evidence_list = [] + for article in articles: + try: + evidence = self._article_to_evidence(article) + if evidence: + evidence_list.append(evidence) + except Exception: + continue # Skip malformed articles + + return evidence_list + + def _article_to_evidence(self, article: dict) -> Evidence | None: + """Convert a single PubMed article to Evidence.""" + medline = article.get("MedlineCitation", {}) + article_data = medline.get("Article", {}) + + # Extract PMID + pmid = medline.get("PMID", {}) + if isinstance(pmid, dict): + pmid = pmid.get("#text", "") + + # Extract title + title = article_data.get("ArticleTitle", "") + if isinstance(title, dict): + title = title.get("#text", str(title)) + + # Extract abstract + abstract_data = article_data.get("Abstract", {}).get("AbstractText", "") + if isinstance(abstract_data, list): + abstract = " ".join( + item.get("#text", str(item)) if isinstance(item, dict) else str(item) + for item in abstract_data + ) + elif isinstance(abstract_data, dict): + abstract = abstract_data.get("#text", str(abstract_data)) + else: + abstract = str(abstract_data) + + if not abstract or not title: + return None + + # Extract date + pub_date = article_data.get("Journal", {}).get("JournalIssue", {}).get("PubDate", {}) + year = pub_date.get("Year", "Unknown") + month = pub_date.get("Month", "01") + day = pub_date.get("Day", "01") + date_str = f"{year}-{month}-{day}" if year != "Unknown" else "Unknown" + + # Extract authors + author_list = article_data.get("AuthorList", {}).get("Author", []) + if isinstance(author_list, dict): + author_list = [author_list] + authors = [] + for author in author_list[:5]: # Limit to 5 authors + last = author.get("LastName", "") + first = author.get("ForeName", "") + if last: + authors.append(f"{last} {first}".strip()) + + return Evidence( + content=abstract[:2000], # Truncate long abstracts + citation=Citation( + source="pubmed", + title=title[:500], + url=f"https://pubmed.ncbi.nlm.nih.gov/{pmid}/", + date=date_str, + authors=authors, + ), + ) +``` + +### DuckDuckGo Tool Implementation (`src/tools/websearch.py`) + +```python +"""Web search tool using DuckDuckGo.""" +from typing import List +from duckduckgo_search import DDGS + +from src.utils.exceptions import SearchError +from src.utils.models import Evidence, Citation + + +class WebTool: + """Search tool for general web search via DuckDuckGo.""" + + def __init__(self): + pass + + @property + def name(self) -> str: + return "web" + + async def search(self, query: str, max_results: int = 10) -> List[Evidence]: + """ + Search DuckDuckGo and return evidence. + + Note: duckduckgo-search is synchronous, so we run it in executor. + """ + import asyncio + + loop = asyncio.get_event_loop() + try: + results = await loop.run_in_executor( + None, + lambda: self._sync_search(query, max_results), + ) + return results + except Exception as e: + raise SearchError(f"Web search failed: {e}") + + def _sync_search(self, query: str, max_results: int) -> List[Evidence]: + """Synchronous search implementation.""" + evidence_list = [] + + with DDGS() as ddgs: + results = list(ddgs.text(query, max_results=max_results)) + + for result in results: + evidence_list.append( + Evidence( + content=result.get("body", "")[:1000], + citation=Citation( + source="web", + title=result.get("title", "Unknown")[:500], + url=result.get("href", ""), + date="Unknown", + authors=[], + ), + ) + ) + + return evidence_list +``` + +--- + +## 5. Search Handler (`src/tools/search_handler.py`) + +The handler orchestrates multiple tools using the **Scatter-Gather** pattern. + +```python +"""Search handler - orchestrates multiple search tools.""" +import asyncio +from typing import List, Protocol +import structlog + +from src.utils.exceptions import SearchError +from src.utils.models import Evidence, SearchResult + +logger = structlog.get_logger() + + +class SearchTool(Protocol): + """Protocol defining the interface for all search tools.""" + + @property + def name(self) -> str: + ... + + async def search(self, query: str, max_results: int = 10) -> List[Evidence]: + ... + + +def flatten(nested: List[List[Evidence]]) -> List[Evidence]: + """Flatten a list of lists into a single list.""" + return [item for sublist in nested for item in sublist] + + +class SearchHandler: + """Orchestrates parallel searches across multiple tools.""" + + def __init__(self, tools: List[SearchTool], timeout: float = 30.0): + """ + Initialize the search handler. + + Args: + tools: List of search tools to use + timeout: Timeout for each search in seconds + """ + self.tools = tools + self.timeout = timeout + + async def execute(self, query: str, max_results_per_tool: int = 10) -> SearchResult: + """ + Execute search across all tools in parallel. + + Args: + query: The search query + max_results_per_tool: Max results from each tool + + Returns: + SearchResult containing all evidence and metadata + """ + logger.info("Starting search", query=query, tools=[t.name for t in self.tools]) + + # Create tasks for parallel execution + tasks = [ + self._search_with_timeout(tool, query, max_results_per_tool) + for tool in self.tools + ] + + # Gather results (don't fail if one tool fails) + results = await asyncio.gather(*tasks, return_exceptions=True) + + # Process results + all_evidence: List[Evidence] = [] + sources_searched: List[str] = [] + errors: List[str] = [] + + for tool, result in zip(self.tools, results): + if isinstance(result, Exception): + errors.append(f"{tool.name}: {str(result)}") + logger.warning("Search tool failed", tool=tool.name, error=str(result)) + else: + all_evidence.extend(result) + sources_searched.append(tool.name) + logger.info("Search tool succeeded", tool=tool.name, count=len(result)) + + return SearchResult( + query=query, + evidence=all_evidence, + sources_searched=sources_searched, + total_found=len(all_evidence), + errors=errors, + ) + + async def _search_with_timeout( + self, + tool: SearchTool, + query: str, + max_results: int, + ) -> List[Evidence]: + """Execute a single tool search with timeout.""" + try: + return await asyncio.wait_for( + tool.search(query, max_results), + timeout=self.timeout, + ) + except asyncio.TimeoutError: + raise SearchError(f"{tool.name} search timed out after {self.timeout}s") +``` + +--- + +## 6. TDD Workflow + +### Test File: `tests/unit/tools/test_pubmed.py` + +```python +"""Unit tests for PubMed tool.""" +import pytest +from unittest.mock import AsyncMock, MagicMock + + +# Sample PubMed XML response for mocking +SAMPLE_PUBMED_XML = """ + + + + 12345678 +
+ Metformin in Alzheimer's Disease: A Systematic Review + + Metformin shows neuroprotective properties... + + + + Smith + John + + + + + + 2024 + 01 + + + +
+
+
+
+""" + + +class TestPubMedTool: + """Tests for PubMedTool.""" + + @pytest.mark.asyncio + async def test_search_returns_evidence(self, mocker): + """PubMedTool should return Evidence objects from search.""" + from src.tools.pubmed import PubMedTool + + # Mock the HTTP responses + mock_search_response = MagicMock() + mock_search_response.json.return_value = { + "esearchresult": {"idlist": ["12345678"]} + } + mock_search_response.raise_for_status = MagicMock() + + mock_fetch_response = MagicMock() + mock_fetch_response.text = SAMPLE_PUBMED_XML + mock_fetch_response.raise_for_status = MagicMock() + + mock_client = AsyncMock() + mock_client.get = AsyncMock(side_effect=[mock_search_response, mock_fetch_response]) + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=None) + + mocker.patch("httpx.AsyncClient", return_value=mock_client) + + # Act + tool = PubMedTool() + results = await tool.search("metformin alzheimer") + + # Assert + assert len(results) == 1 + assert results[0].citation.source == "pubmed" + assert "Metformin" in results[0].citation.title + assert "12345678" in results[0].citation.url + + @pytest.mark.asyncio + async def test_search_empty_results(self, mocker): + """PubMedTool should return empty list when no results.""" + from src.tools.pubmed import PubMedTool + + mock_response = MagicMock() + mock_response.json.return_value = {"esearchresult": {"idlist": []}} + mock_response.raise_for_status = MagicMock() + + mock_client = AsyncMock() + mock_client.get = AsyncMock(return_value=mock_response) + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=None) + + mocker.patch("httpx.AsyncClient", return_value=mock_client) + + tool = PubMedTool() + results = await tool.search("xyznonexistentquery123") + + assert results == [] + + def test_parse_pubmed_xml(self): + """PubMedTool should correctly parse XML.""" + from src.tools.pubmed import PubMedTool + + tool = PubMedTool() + results = tool._parse_pubmed_xml(SAMPLE_PUBMED_XML) + + assert len(results) == 1 + assert results[0].citation.source == "pubmed" + assert "Smith John" in results[0].citation.authors +``` + +### Test File: `tests/unit/tools/test_websearch.py` + +```python +"""Unit tests for WebTool.""" +import pytest +from unittest.mock import MagicMock + + +class TestWebTool: + """Tests for WebTool.""" + + @pytest.mark.asyncio + async def test_search_returns_evidence(self, mocker): + """WebTool should return Evidence objects from search.""" + from src.tools.websearch import WebTool + + mock_results = [ + { + "title": "Drug Repurposing Article", + "href": "https://example.com/article", + "body": "Some content about drug repurposing...", + } + ] + + mock_ddgs = MagicMock() + mock_ddgs.__enter__ = MagicMock(return_value=mock_ddgs) + mock_ddgs.__exit__ = MagicMock(return_value=None) + mock_ddgs.text = MagicMock(return_value=mock_results) + + mocker.patch("src.tools.websearch.DDGS", return_value=mock_ddgs) + + tool = WebTool() + results = await tool.search("drug repurposing") + + assert len(results) == 1 + assert results[0].citation.source == "web" + assert "Drug Repurposing" in results[0].citation.title +``` + +### Test File: `tests/unit/tools/test_search_handler.py` + +```python +"""Unit tests for SearchHandler.""" +import pytest +from unittest.mock import AsyncMock + +from src.utils.models import Evidence, Citation +from src.utils.exceptions import SearchError + + +class TestSearchHandler: + """Tests for SearchHandler.""" + + @pytest.mark.asyncio + async def test_execute_aggregates_results(self): + """SearchHandler should aggregate results from all tools.""" + from src.tools.search_handler import SearchHandler + + # Create mock tools + mock_tool_1 = AsyncMock() + mock_tool_1.name = "mock1" + mock_tool_1.search = AsyncMock(return_value=[ + Evidence( + content="Result 1", + citation=Citation(source="pubmed", title="T1", url="u1", date="2024"), + ) + ]) + + mock_tool_2 = AsyncMock() + mock_tool_2.name = "mock2" + mock_tool_2.search = AsyncMock(return_value=[ + Evidence( + content="Result 2", + citation=Citation(source="web", title="T2", url="u2", date="2024"), + ) + ]) + + handler = SearchHandler(tools=[mock_tool_1, mock_tool_2]) + result = await handler.execute("test query") + + assert result.total_found == 2 + assert "mock1" in result.sources_searched + assert "mock2" in result.sources_searched + assert len(result.errors) == 0 + + @pytest.mark.asyncio + async def test_execute_handles_tool_failure(self): + """SearchHandler should continue if one tool fails.""" + from src.tools.search_handler import SearchHandler + + mock_tool_ok = AsyncMock() + mock_tool_ok.name = "ok_tool" + mock_tool_ok.search = AsyncMock(return_value=[ + Evidence( + content="Good result", + citation=Citation(source="pubmed", title="T", url="u", date="2024"), + ) + ]) + + mock_tool_fail = AsyncMock() + mock_tool_fail.name = "fail_tool" + mock_tool_fail.search = AsyncMock(side_effect=SearchError("API down")) + + handler = SearchHandler(tools=[mock_tool_ok, mock_tool_fail]) + result = await handler.execute("test") + + assert result.total_found == 1 + assert "ok_tool" in result.sources_searched + assert len(result.errors) == 1 + assert "fail_tool" in result.errors[0] +``` + +--- + +## 7. Integration Test (Optional, Real API) + +```python +# tests/integration/test_pubmed_live.py +"""Integration tests that hit real APIs (run manually).""" +import pytest + + +@pytest.mark.integration +@pytest.mark.slow +@pytest.mark.asyncio +async def test_pubmed_live_search(): + """Test real PubMed search (requires network).""" + from src.tools.pubmed import PubMedTool + + tool = PubMedTool() + results = await tool.search("metformin diabetes", max_results=3) + + assert len(results) > 0 + assert results[0].citation.source == "pubmed" + assert "pubmed.ncbi.nlm.nih.gov" in results[0].citation.url + + +# Run with: uv run pytest tests/integration -m integration +``` + +--- + +## 8. Implementation Checklist + +- [x] Create `src/utils/models.py` with all Pydantic models (Evidence, Citation, SearchResult) - **COMPLETE** +- [x] Create `src/tools/__init__.py` with SearchTool Protocol and exports - **COMPLETE** +- [x] Implement `src/tools/pubmed.py` with PubMedTool class - **COMPLETE** +- [ ] ~~Implement `src/tools/websearch.py` with WebTool class~~ - **REMOVED** (replaced by Europe PMC in Phase 11) +- [x] Create `src/tools/search_handler.py` with SearchHandler class - **COMPLETE** +- [x] Write tests in `tests/unit/tools/test_pubmed.py` - **COMPLETE** (basic tests) +- [ ] Write tests in `tests/unit/tools/test_websearch.py` - **N/A** (WebTool removed) +- [x] Write tests in `tests/unit/tools/test_search_handler.py` - **COMPLETE** (basic tests) +- [x] Run `uv run pytest tests/unit/tools/ -v` — **ALL TESTS MUST PASS** - **PASSING** +- [ ] (Optional) Run integration test: `uv run pytest -m integration` +- [ ] Add edge case tests (rate limiting, error handling, timeouts) - **PENDING** +- [ ] Commit: `git commit -m "feat: phase 2 search slice complete"` - **DONE** + +**Post-Phase 2 Enhancements**: +- [x] Query preprocessing (`src/tools/query_utils.py`) - **ADDED** +- [x] Europe PMC tool (Phase 11) - **ADDED** +- [x] ClinicalTrials tool (Phase 10) - **ADDED** + +--- + +## 9. Definition of Done + +Phase 2 is **COMPLETE** when: + +1. ✅ All unit tests pass: `uv run pytest tests/unit/tools/ -v` - **PASSING** +2. ✅ `SearchHandler` can execute with search tools - **WORKING** +3. ✅ Graceful degradation: if one tool fails, other tools still return results - **IMPLEMENTED** +4. ✅ Rate limiting is enforced (verify no 429 errors) - **IMPLEMENTED** +5. ✅ Can run this in Python REPL: + +```python +import asyncio +from src.tools.pubmed import PubMedTool +from src.tools.search_handler import SearchHandler + +async def test(): + handler = SearchHandler([PubMedTool()]) + result = await handler.execute("metformin alzheimer") + print(f"Found {result.total_found} results") + for e in result.evidence[:3]: + print(f"- {e.citation.title}") + +asyncio.run(test()) +``` + +**Note**: WebTool was removed in favor of Europe PMC (Phase 11). The current implementation uses PubMed as the primary Phase 2 tool, with Europe PMC and ClinicalTrials added in later phases. + +**Proceed to Phase 3 ONLY after all checkboxes are complete.** diff --git a/docs/implementation/03_phase_judge.md b/docs/implementation/03_phase_judge.md new file mode 100644 index 0000000000000000000000000000000000000000..f97ff8b814233e6d26046ba52906b39c2ae1b742 --- /dev/null +++ b/docs/implementation/03_phase_judge.md @@ -0,0 +1,1052 @@ +# Phase 3 Implementation Spec: Judge Vertical Slice + +**Goal**: Implement the "Brain" of the agent — evaluating evidence quality. +**Philosophy**: "Structured Output or Bust." +**Prerequisite**: Phase 2 complete (all search tests passing) + +--- + +## 1. The Slice Definition + +This slice covers: +1. **Input**: A user question + a list of `Evidence` (from Phase 2). +2. **Process**: + - Construct a prompt with the evidence. + - Call LLM (PydanticAI / OpenAI / Anthropic). + - Force JSON structured output. +3. **Output**: A `JudgeAssessment` object. + +**Files to Create**: +- `src/utils/models.py` - Add JudgeAssessment models (extend from Phase 2) +- `src/prompts/judge.py` - Judge prompt templates +- `src/agent_factory/judges.py` - JudgeHandler with PydanticAI +- `tests/unit/agent_factory/test_judges.py` - Unit tests + +--- + +## 2. Models (Add to `src/utils/models.py`) + +The output schema must be strict for reliable structured output. + +```python +"""Add these models to src/utils/models.py (after Evidence models from Phase 2).""" +from pydantic import BaseModel, Field +from typing import List, Literal + + +class AssessmentDetails(BaseModel): + """Detailed assessment of evidence quality.""" + + mechanism_score: int = Field( + ..., + ge=0, + le=10, + description="How well does the evidence explain the mechanism? 0-10" + ) + mechanism_reasoning: str = Field( + ..., + min_length=10, + description="Explanation of mechanism score" + ) + clinical_evidence_score: int = Field( + ..., + ge=0, + le=10, + description="Strength of clinical/preclinical evidence. 0-10" + ) + clinical_reasoning: str = Field( + ..., + min_length=10, + description="Explanation of clinical evidence score" + ) + drug_candidates: List[str] = Field( + default_factory=list, + description="List of specific drug candidates mentioned" + ) + key_findings: List[str] = Field( + default_factory=list, + description="Key findings from the evidence" + ) + + +class JudgeAssessment(BaseModel): + """Complete assessment from the Judge.""" + + details: AssessmentDetails + sufficient: bool = Field( + ..., + description="Is evidence sufficient to provide a recommendation?" + ) + confidence: float = Field( + ..., + ge=0.0, + le=1.0, + description="Confidence in the assessment (0-1)" + ) + recommendation: Literal["continue", "synthesize"] = Field( + ..., + description="continue = need more evidence, synthesize = ready to answer" + ) + next_search_queries: List[str] = Field( + default_factory=list, + description="If continue, what queries to search next" + ) + reasoning: str = Field( + ..., + min_length=20, + description="Overall reasoning for the recommendation" + ) +``` + +--- + +## 3. Prompt Engineering (`src/prompts/judge.py`) + +We treat prompts as code. They should be versioned and clean. + +```python +"""Judge prompts for evidence assessment.""" +from typing import List +from src.utils.models import Evidence + + +SYSTEM_PROMPT = """You are an expert drug repurposing research judge. + +Your task is to evaluate evidence from biomedical literature and determine if it's sufficient to recommend drug candidates for a given condition. + +## Evaluation Criteria + +1. **Mechanism Score (0-10)**: How well does the evidence explain the biological mechanism? + - 0-3: No clear mechanism, speculative + - 4-6: Some mechanistic insight, but gaps exist + - 7-10: Clear, well-supported mechanism of action + +2. **Clinical Evidence Score (0-10)**: Strength of clinical/preclinical support? + - 0-3: No clinical data, only theoretical + - 4-6: Preclinical or early clinical data + - 7-10: Strong clinical evidence (trials, meta-analyses) + +3. **Sufficiency**: Evidence is sufficient when: + - Combined scores >= 12 AND + - At least one specific drug candidate identified AND + - Clear mechanistic rationale exists + +## Output Rules + +- Always output valid JSON matching the schema +- Be conservative: only recommend "synthesize" when truly confident +- If continuing, suggest specific, actionable search queries +- Never hallucinate drug names or findings not in the evidence +""" + + +def format_user_prompt(question: str, evidence: List[Evidence]) -> str: + """ + Format the user prompt with question and evidence. + + Args: + question: The user's research question + evidence: List of Evidence objects from search + + Returns: + Formatted prompt string + """ + evidence_text = "\n\n".join([ + f"### Evidence {i+1}\n" + f"**Source**: {e.citation.source.upper()} - {e.citation.title}\n" + f"**URL**: {e.citation.url}\n" + f"**Date**: {e.citation.date}\n" + f"**Content**:\n{e.content[:1500]}..." + if len(e.content) > 1500 else + f"### Evidence {i+1}\n" + f"**Source**: {e.citation.source.upper()} - {e.citation.title}\n" + f"**URL**: {e.citation.url}\n" + f"**Date**: {e.citation.date}\n" + f"**Content**:\n{e.content}" + for i, e in enumerate(evidence) + ]) + + return f"""## Research Question +{question} + +## Available Evidence ({len(evidence)} sources) + +{evidence_text} + +## Your Task + +Evaluate this evidence and determine if it's sufficient to recommend drug repurposing candidates. +Respond with a JSON object matching the JudgeAssessment schema. +""" + + +def format_empty_evidence_prompt(question: str) -> str: + """ + Format prompt when no evidence was found. + + Args: + question: The user's research question + + Returns: + Formatted prompt string + """ + return f"""## Research Question +{question} + +## Available Evidence + +No evidence was found from the search. + +## Your Task + +Since no evidence was found, recommend search queries that might yield better results. +Set sufficient=False and recommendation="continue". +Suggest 3-5 specific search queries. +""" +``` + +--- + +## 4. JudgeHandler Implementation (`src/agent_factory/judges.py`) + +Using PydanticAI for structured output with retry logic. + +```python +"""Judge handler for evidence assessment using PydanticAI.""" +import os +from typing import List +import structlog +from pydantic_ai import Agent +from pydantic_ai.models.openai import OpenAIModel +from pydantic_ai.models.anthropic import AnthropicModel + +from src.utils.models import Evidence, JudgeAssessment, AssessmentDetails +from src.utils.config import settings +from src.prompts.judge import SYSTEM_PROMPT, format_user_prompt, format_empty_evidence_prompt + +logger = structlog.get_logger() + + +def get_model(): + """Get the LLM model based on configuration.""" + provider = getattr(settings, "llm_provider", "openai") + + if provider == "anthropic": + return AnthropicModel( + model_name=getattr(settings, "anthropic_model", "claude-3-5-sonnet-20241022"), + api_key=os.getenv("ANTHROPIC_API_KEY"), + ) + else: + return OpenAIModel( + model_name=getattr(settings, "openai_model", "gpt-4o"), + api_key=os.getenv("OPENAI_API_KEY"), + ) + + +class JudgeHandler: + """ + Handles evidence assessment using an LLM with structured output. + + Uses PydanticAI to ensure responses match the JudgeAssessment schema. + """ + + def __init__(self, model=None): + """ + Initialize the JudgeHandler. + + Args: + model: Optional PydanticAI model. If None, uses config default. + """ + self.model = model or get_model() + self.agent = Agent( + model=self.model, + result_type=JudgeAssessment, + system_prompt=SYSTEM_PROMPT, + retries=3, + ) + + async def assess( + self, + question: str, + evidence: List[Evidence], + ) -> JudgeAssessment: + """ + Assess evidence and determine if it's sufficient. + + Args: + question: The user's research question + evidence: List of Evidence objects from search + + Returns: + JudgeAssessment with evaluation results + + Raises: + JudgeError: If assessment fails after retries + """ + logger.info( + "Starting evidence assessment", + question=question[:100], + evidence_count=len(evidence), + ) + + # Format the prompt based on whether we have evidence + if evidence: + user_prompt = format_user_prompt(question, evidence) + else: + user_prompt = format_empty_evidence_prompt(question) + + try: + # Run the agent with structured output + result = await self.agent.run(user_prompt) + assessment = result.data + + logger.info( + "Assessment complete", + sufficient=assessment.sufficient, + recommendation=assessment.recommendation, + confidence=assessment.confidence, + ) + + return assessment + + except Exception as e: + logger.error("Assessment failed", error=str(e)) + # Return a safe default assessment on failure + return self._create_fallback_assessment(question, str(e)) + + def _create_fallback_assessment( + self, + question: str, + error: str, + ) -> JudgeAssessment: + """ + Create a fallback assessment when LLM fails. + + Args: + question: The original question + error: The error message + + Returns: + Safe fallback JudgeAssessment + """ + return JudgeAssessment( + details=AssessmentDetails( + mechanism_score=0, + mechanism_reasoning="Assessment failed due to LLM error", + clinical_evidence_score=0, + clinical_reasoning="Assessment failed due to LLM error", + drug_candidates=[], + key_findings=[], + ), + sufficient=False, + confidence=0.0, + recommendation="continue", + next_search_queries=[ + f"{question} mechanism", + f"{question} clinical trials", + f"{question} drug candidates", + ], + reasoning=f"Assessment failed: {error}. Recommend retrying with refined queries.", + ) + + +class HFInferenceJudgeHandler: + """ + JudgeHandler using HuggingFace Inference API for FREE LLM calls. + + This is the DEFAULT for demo mode - provides real AI analysis without + requiring users to have OpenAI/Anthropic API keys. + + Model Fallback Chain (handles gated models and rate limits): + 1. meta-llama/Llama-3.1-8B-Instruct (best quality, requires HF_TOKEN) + 2. mistralai/Mistral-7B-Instruct-v0.3 (good quality, may require token) + 3. HuggingFaceH4/zephyr-7b-beta (ungated, always works) + + Rate Limit Handling: + - Exponential backoff with 3 retries + - Falls back to next model on persistent 429/503 errors + """ + + # Model fallback chain: gated (best) → ungated (fallback) + FALLBACK_MODELS = [ + "meta-llama/Llama-3.1-8B-Instruct", # Best quality (gated) + "mistralai/Mistral-7B-Instruct-v0.3", # Good quality + "HuggingFaceH4/zephyr-7b-beta", # Ungated fallback + ] + + def __init__(self, model_id: str | None = None) -> None: + """ + Initialize with HF Inference client. + + Args: + model_id: Optional specific model ID. If None, uses FALLBACK_MODELS chain. + """ + self.model_id = model_id + # Will automatically use HF_TOKEN from env if available + self.client = InferenceClient() + self.call_count = 0 + self.last_question: str | None = None + self.last_evidence: list[Evidence] | None = None + + def _extract_json(self, text: str) -> dict[str, Any] | None: + """ + Robust JSON extraction that handles markdown blocks and nested braces. + """ + text = text.strip() + + # Remove markdown code blocks if present (with bounds checking) + if "```json" in text: + parts = text.split("```json", 1) + if len(parts) > 1: + inner_parts = parts[1].split("```", 1) + text = inner_parts[0] + elif "```" in text: + parts = text.split("```", 1) + if len(parts) > 1: + inner_parts = parts[1].split("```", 1) + text = inner_parts[0] + + text = text.strip() + + # Find first '{' + start_idx = text.find("{") + if start_idx == -1: + return None + + # Stack-based parsing ignoring chars in strings + count = 0 + in_string = False + escape = False + + for i, char in enumerate(text[start_idx:], start=start_idx): + if in_string: + if escape: + escape = False + elif char == "\\": + escape = True + elif char == '"': + in_string = False + elif char == '"': + in_string = True + elif char == "{": + count += 1 + elif char == "}": + count -= 1 + if count == 0: + try: + result = json.loads(text[start_idx : i + 1]) + if isinstance(result, dict): + return result + return None + except json.JSONDecodeError: + return None + + return None + + @retry( + stop=stop_after_attempt(3), + wait=wait_exponential(multiplier=1, min=1, max=4), + retry=retry_if_exception_type(Exception), + reraise=True, + ) + async def _call_with_retry(self, model: str, prompt: str, question: str) -> JudgeAssessment: + """Make API call with retry logic using chat_completion.""" + loop = asyncio.get_running_loop() + + # Build messages for chat_completion (model-agnostic) + messages = [ + { + "role": "system", + "content": f"""{SYSTEM_PROMPT} + +IMPORTANT: Respond with ONLY valid JSON matching this schema: +{{ + "details": {{ + "mechanism_score": , + "mechanism_reasoning": "", + "clinical_evidence_score": , + "clinical_reasoning": "", + "drug_candidates": ["", ...], + "key_findings": ["", ...] + }}, + "sufficient": , + "confidence": , + "recommendation": "continue" | "synthesize", + "next_search_queries": ["", ...], + "reasoning": "" +}}""", + }, + {"role": "user", "content": prompt}, + ] + + # Use chat_completion (conversational task - supported by all models) + response = await loop.run_in_executor( + None, + lambda: self.client.chat_completion( + messages=messages, + model=model, + max_tokens=1024, + temperature=0.1, + ), + ) + + # Extract content from response + content = response.choices[0].message.content + if not content: + raise ValueError("Empty response from model") + + # Extract and parse JSON + json_data = self._extract_json(content) + if not json_data: + raise ValueError("No valid JSON found in response") + + return JudgeAssessment(**json_data) + + async def assess( + self, + question: str, + evidence: list[Evidence], + ) -> JudgeAssessment: + """ + Assess evidence using HuggingFace Inference API. + Attempts models in order until one succeeds. + """ + self.call_count += 1 + self.last_question = question + self.last_evidence = evidence + + # Format the user prompt + if evidence: + user_prompt = format_user_prompt(question, evidence) + else: + user_prompt = format_empty_evidence_prompt(question) + + models_to_try: list[str] = [self.model_id] if self.model_id else self.FALLBACK_MODELS + last_error: Exception | None = None + + for model in models_to_try: + try: + return await self._call_with_retry(model, user_prompt, question) + except Exception as e: + logger.warning("Model failed", model=model, error=str(e)) + last_error = e + continue + + # All models failed + logger.error("All HF models failed", error=str(last_error)) + return self._create_fallback_assessment(question, str(last_error)) + + def _create_fallback_assessment( + self, + question: str, + error: str, + ) -> JudgeAssessment: + """Create a fallback assessment when inference fails.""" + return JudgeAssessment( + details=AssessmentDetails( + mechanism_score=0, + mechanism_reasoning=f"Assessment failed: {error}", + clinical_evidence_score=0, + clinical_reasoning=f"Assessment failed: {error}", + drug_candidates=[], + key_findings=[], + ), + sufficient=False, + confidence=0.0, + recommendation="continue", + next_search_queries=[ + f"{question} mechanism", + f"{question} clinical trials", + f"{question} drug candidates", + ], + reasoning=f"HF Inference failed: {error}. Recommend retrying.", + ) + + +class MockJudgeHandler: + """ + Mock JudgeHandler for UNIT TESTING ONLY. + + NOT for production use. Use HFInferenceJudgeHandler for demo mode. + """ + + def __init__(self, mock_response: JudgeAssessment | None = None): + """Initialize with optional mock response for testing.""" + self.mock_response = mock_response + self.call_count = 0 + self.last_question = None + self.last_evidence = None + + async def assess( + self, + question: str, + evidence: List[Evidence], + ) -> JudgeAssessment: + """Return the mock response (for testing only).""" + self.call_count += 1 + self.last_question = question + self.last_evidence = evidence + + if self.mock_response: + return self.mock_response + + # Default mock response for tests + return JudgeAssessment( + details=AssessmentDetails( + mechanism_score=7, + mechanism_reasoning="Mock assessment for testing", + clinical_evidence_score=6, + clinical_reasoning="Mock assessment for testing", + drug_candidates=["TestDrug"], + key_findings=["Test finding"], + ), + sufficient=len(evidence) >= 3, + confidence=0.75, + recommendation="synthesize" if len(evidence) >= 3 else "continue", + next_search_queries=["query 1", "query 2"] if len(evidence) < 3 else [], + reasoning="Mock assessment for unit testing only", + ) +``` + +--- + +## 5. TDD Workflow + +### Test File: `tests/unit/agent_factory/test_judges.py` + +```python +"""Unit tests for JudgeHandler.""" +import pytest +from unittest.mock import AsyncMock, MagicMock, patch + +from src.utils.models import ( + Evidence, + Citation, + JudgeAssessment, + AssessmentDetails, +) + + +class TestJudgeHandler: + """Tests for JudgeHandler.""" + + @pytest.mark.asyncio + async def test_assess_returns_assessment(self): + """JudgeHandler should return JudgeAssessment from LLM.""" + from src.agent_factory.judges import JudgeHandler + + # Create mock assessment + mock_assessment = JudgeAssessment( + details=AssessmentDetails( + mechanism_score=8, + mechanism_reasoning="Strong mechanistic evidence", + clinical_evidence_score=7, + clinical_reasoning="Good clinical support", + drug_candidates=["Metformin"], + key_findings=["Neuroprotective effects"], + ), + sufficient=True, + confidence=0.85, + recommendation="synthesize", + next_search_queries=[], + reasoning="Evidence is sufficient for synthesis", + ) + + # Mock the PydanticAI agent + mock_result = MagicMock() + mock_result.data = mock_assessment + + with patch("src.agent_factory.judges.Agent") as mock_agent_class: + mock_agent = AsyncMock() + mock_agent.run = AsyncMock(return_value=mock_result) + mock_agent_class.return_value = mock_agent + + handler = JudgeHandler() + # Replace the agent with our mock + handler.agent = mock_agent + + evidence = [ + Evidence( + content="Metformin shows neuroprotective properties...", + citation=Citation( + source="pubmed", + title="Metformin in AD", + url="https://pubmed.ncbi.nlm.nih.gov/12345/", + date="2024-01-01", + ), + ) + ] + + result = await handler.assess("metformin alzheimer", evidence) + + assert result.sufficient is True + assert result.recommendation == "synthesize" + assert result.confidence == 0.85 + assert "Metformin" in result.details.drug_candidates + + @pytest.mark.asyncio + async def test_assess_empty_evidence(self): + """JudgeHandler should handle empty evidence gracefully.""" + from src.agent_factory.judges import JudgeHandler + + mock_assessment = JudgeAssessment( + details=AssessmentDetails( + mechanism_score=0, + mechanism_reasoning="No evidence to assess", + clinical_evidence_score=0, + clinical_reasoning="No evidence to assess", + drug_candidates=[], + key_findings=[], + ), + sufficient=False, + confidence=0.0, + recommendation="continue", + next_search_queries=["metformin alzheimer mechanism"], + reasoning="No evidence found, need to search more", + ) + + mock_result = MagicMock() + mock_result.data = mock_assessment + + with patch("src.agent_factory.judges.Agent") as mock_agent_class: + mock_agent = AsyncMock() + mock_agent.run = AsyncMock(return_value=mock_result) + mock_agent_class.return_value = mock_agent + + handler = JudgeHandler() + handler.agent = mock_agent + + result = await handler.assess("metformin alzheimer", []) + + assert result.sufficient is False + assert result.recommendation == "continue" + assert len(result.next_search_queries) > 0 + + @pytest.mark.asyncio + async def test_assess_handles_llm_failure(self): + """JudgeHandler should return fallback on LLM failure.""" + from src.agent_factory.judges import JudgeHandler + + with patch("src.agent_factory.judges.Agent") as mock_agent_class: + mock_agent = AsyncMock() + mock_agent.run = AsyncMock(side_effect=Exception("API Error")) + mock_agent_class.return_value = mock_agent + + handler = JudgeHandler() + handler.agent = mock_agent + + evidence = [ + Evidence( + content="Some content", + citation=Citation( + source="pubmed", + title="Title", + url="url", + date="2024", + ), + ) + ] + + result = await handler.assess("test question", evidence) + + # Should return fallback, not raise + assert result.sufficient is False + assert result.recommendation == "continue" + assert "failed" in result.reasoning.lower() + + +class TestHFInferenceJudgeHandler: + """Tests for HFInferenceJudgeHandler.""" + + @pytest.mark.asyncio + async def test_extract_json_raw(self): + """Should extract raw JSON.""" + from src.agent_factory.judges import HFInferenceJudgeHandler + + handler = HFInferenceJudgeHandler.__new__(HFInferenceJudgeHandler) + # Bypass __init__ for unit testing extraction + + result = handler._extract_json('{"key": "value"}') + assert result == {"key": "value"} + + @pytest.mark.asyncio + async def test_extract_json_markdown_block(self): + """Should extract JSON from markdown code block.""" + from src.agent_factory.judges import HFInferenceJudgeHandler + + handler = HFInferenceJudgeHandler.__new__(HFInferenceJudgeHandler) + + response = '''Here is the assessment: +```json +{"key": "value", "nested": {"inner": 1}} +``` +''' + result = handler._extract_json(response) + assert result == {"key": "value", "nested": {"inner": 1}} + + @pytest.mark.asyncio + async def test_extract_json_with_preamble(self): + """Should extract JSON with preamble text.""" + from src.agent_factory.judges import HFInferenceJudgeHandler + + handler = HFInferenceJudgeHandler.__new__(HFInferenceJudgeHandler) + + response = 'Here is your JSON response:\n{"sufficient": true, "confidence": 0.85}' + result = handler._extract_json(response) + assert result == {"sufficient": True, "confidence": 0.85} + + @pytest.mark.asyncio + async def test_extract_json_nested_braces(self): + """Should handle nested braces correctly.""" + from src.agent_factory.judges import HFInferenceJudgeHandler + + handler = HFInferenceJudgeHandler.__new__(HFInferenceJudgeHandler) + + response = '{"details": {"mechanism_score": 8}, "reasoning": "test"}' + result = handler._extract_json(response) + assert result["details"]["mechanism_score"] == 8 + + @pytest.mark.asyncio + async def test_hf_handler_uses_fallback_models(self): + """HFInferenceJudgeHandler should have fallback model chain.""" + from src.agent_factory.judges import HFInferenceJudgeHandler + + # Check class has fallback models defined + assert len(HFInferenceJudgeHandler.FALLBACK_MODELS) >= 3 + assert "zephyr-7b-beta" in HFInferenceJudgeHandler.FALLBACK_MODELS[-1] + + @pytest.mark.asyncio + async def test_hf_handler_fallback_on_auth_error(self): + """Should fall back to ungated model on auth error.""" + from src.agent_factory.judges import HFInferenceJudgeHandler + from unittest.mock import MagicMock, patch + + with patch("src.agent_factory.judges.InferenceClient") as mock_client_class: + # First call raises 403, second succeeds + mock_client = MagicMock() + mock_client.chat_completion.side_effect = [ + Exception("403 Forbidden: gated model"), + MagicMock(choices=[MagicMock(message=MagicMock(content='{"sufficient": false}'))]) + ] + mock_client_class.return_value = mock_client + + handler = HFInferenceJudgeHandler() + # Manually trigger fallback test + assert handler._try_fallback_model() is True + assert handler.model_id != "meta-llama/Llama-3.1-8B-Instruct" + + +class TestMockJudgeHandler: + """Tests for MockJudgeHandler (UNIT TESTING ONLY).""" + + @pytest.mark.asyncio + async def test_mock_handler_returns_default(self): + """MockJudgeHandler should return default assessment.""" + from src.agent_factory.judges import MockJudgeHandler + + handler = MockJudgeHandler() + + evidence = [ + Evidence( + content="Content 1", + citation=Citation(source="pubmed", title="T1", url="u1", date="2024"), + ), + Evidence( + content="Content 2", + citation=Citation(source="web", title="T2", url="u2", date="2024"), + ), + ] + + result = await handler.assess("test", evidence) + + assert handler.call_count == 1 + assert handler.last_question == "test" + assert len(handler.last_evidence) == 2 + assert result.details.mechanism_score == 7 + + @pytest.mark.asyncio + async def test_mock_handler_custom_response(self): + """MockJudgeHandler should return custom response when provided.""" + from src.agent_factory.judges import MockJudgeHandler + + custom_assessment = JudgeAssessment( + details=AssessmentDetails( + mechanism_score=10, + mechanism_reasoning="Custom reasoning", + clinical_evidence_score=10, + clinical_reasoning="Custom clinical", + drug_candidates=["CustomDrug"], + key_findings=["Custom finding"], + ), + sufficient=True, + confidence=1.0, + recommendation="synthesize", + next_search_queries=[], + reasoning="Custom assessment", + ) + + handler = MockJudgeHandler(mock_response=custom_assessment) + result = await handler.assess("test", []) + + assert result.details.mechanism_score == 10 + assert result.details.drug_candidates == ["CustomDrug"] + + @pytest.mark.asyncio + async def test_mock_handler_insufficient_with_few_evidence(self): + """MockJudgeHandler should recommend continue with < 3 evidence.""" + from src.agent_factory.judges import MockJudgeHandler + + handler = MockJudgeHandler() + + # Only 2 pieces of evidence + evidence = [ + Evidence( + content="Content", + citation=Citation(source="pubmed", title="T", url="u", date="2024"), + ), + Evidence( + content="Content 2", + citation=Citation(source="web", title="T2", url="u2", date="2024"), + ), + ] + + result = await handler.assess("test", evidence) + + assert result.sufficient is False + assert result.recommendation == "continue" + assert len(result.next_search_queries) > 0 +``` + +--- + +## 6. Dependencies + +Add to `pyproject.toml`: + +```toml +[project] +dependencies = [ + # ... existing deps ... + "pydantic-ai>=0.0.16", + "openai>=1.0.0", + "anthropic>=0.18.0", + "huggingface-hub>=0.20.0", # For HFInferenceJudgeHandler (FREE LLM) +] +``` + +**Note**: `huggingface-hub` is required for the free tier to work. It: +- Provides `InferenceClient` for API calls +- Auto-reads `HF_TOKEN` from environment (optional, for gated models) +- Works without any token for ungated models like `zephyr-7b-beta` + +--- + +## 7. Configuration (`src/utils/config.py`) + +Add LLM configuration: + +```python +"""Add to src/utils/config.py.""" +from pydantic_settings import BaseSettings +from typing import Literal + + +class Settings(BaseSettings): + """Application settings.""" + + # LLM Configuration + llm_provider: Literal["openai", "anthropic"] = "openai" + openai_model: str = "gpt-4o" + anthropic_model: str = "claude-3-5-sonnet-20241022" + + # API Keys (loaded from environment) + openai_api_key: str | None = None + anthropic_api_key: str | None = None + ncbi_api_key: str | None = None + + class Config: + env_file = ".env" + env_file_encoding = "utf-8" + + +settings = Settings() +``` + +--- + +## 8. Implementation Checklist + +- [ ] Add `AssessmentDetails` and `JudgeAssessment` models to `src/utils/models.py` +- [ ] Create `src/prompts/__init__.py` (empty, for package) +- [ ] Create `src/prompts/judge.py` with prompt templates +- [ ] Create `src/agent_factory/__init__.py` with exports +- [ ] Implement `src/agent_factory/judges.py` with JudgeHandler +- [ ] Update `src/utils/config.py` with LLM settings +- [ ] Create `tests/unit/agent_factory/__init__.py` +- [ ] Write tests in `tests/unit/agent_factory/test_judges.py` +- [ ] Run `uv run pytest tests/unit/agent_factory/ -v` — **ALL TESTS MUST PASS** +- [ ] Commit: `git commit -m "feat: phase 3 judge slice complete"` + +--- + +## 9. Definition of Done + +Phase 3 is **COMPLETE** when: + +1. All unit tests pass: `uv run pytest tests/unit/agent_factory/ -v` +2. `JudgeHandler` can assess evidence and return structured output +3. Graceful degradation: if LLM fails, returns safe fallback +4. MockJudgeHandler works for testing without API calls +5. Can run this in Python REPL: + +```python +import asyncio +import os +from src.utils.models import Evidence, Citation +from src.agent_factory.judges import JudgeHandler, MockJudgeHandler + +# Test with mock (no API key needed) +async def test_mock(): + handler = MockJudgeHandler() + evidence = [ + Evidence( + content="Metformin shows neuroprotective effects in AD models", + citation=Citation( + source="pubmed", + title="Metformin and Alzheimer's", + url="https://pubmed.ncbi.nlm.nih.gov/12345/", + date="2024-01-01", + ), + ), + ] + result = await handler.assess("metformin alzheimer", evidence) + print(f"Sufficient: {result.sufficient}") + print(f"Recommendation: {result.recommendation}") + print(f"Drug candidates: {result.details.drug_candidates}") + +asyncio.run(test_mock()) + +# Test with real LLM (requires API key) +async def test_real(): + os.environ["OPENAI_API_KEY"] = "your-key-here" # Or set in .env + handler = JudgeHandler() + evidence = [ + Evidence( + content="Metformin shows neuroprotective effects in AD models...", + citation=Citation( + source="pubmed", + title="Metformin and Alzheimer's", + url="https://pubmed.ncbi.nlm.nih.gov/12345/", + date="2024-01-01", + ), + ), + ] + result = await handler.assess("metformin alzheimer", evidence) + print(f"Sufficient: {result.sufficient}") + print(f"Confidence: {result.confidence}") + print(f"Reasoning: {result.reasoning}") + +# asyncio.run(test_real()) # Uncomment with valid API key +``` + +**Proceed to Phase 4 ONLY after all checkboxes are complete.** diff --git a/docs/implementation/04_phase_ui.md b/docs/implementation/04_phase_ui.md new file mode 100644 index 0000000000000000000000000000000000000000..90767d7515a40a9958287e9a171f6adf2bb702b6 --- /dev/null +++ b/docs/implementation/04_phase_ui.md @@ -0,0 +1,1104 @@ +# Phase 4 Implementation Spec: Orchestrator & UI + +**Goal**: Connect the Brain and the Body, then give it a Face. +**Philosophy**: "Streaming is Trust." +**Prerequisite**: Phase 3 complete (all judge tests passing) + +--- + +## 1. The Slice Definition + +This slice connects: +1. **Orchestrator**: The state machine (While loop) calling Search -> Judge. +2. **UI**: Gradio interface that visualizes the loop. + +**Files to Create/Modify**: +- `src/orchestrator.py` - Agent loop logic +- `src/app.py` - Gradio UI +- `tests/unit/test_orchestrator.py` - Unit tests +- `Dockerfile` - Container for deployment +- `README.md` - Usage instructions (update) + +--- + +## 2. Agent Events (`src/utils/models.py`) + +Add event types for streaming UI updates: + +```python +"""Add to src/utils/models.py (after JudgeAssessment models).""" +from pydantic import BaseModel, Field +from typing import Literal, Any +from datetime import datetime + + +class AgentEvent(BaseModel): + """Event emitted by the orchestrator for UI streaming.""" + + type: Literal[ + "started", + "searching", + "search_complete", + "judging", + "judge_complete", + "looping", + "synthesizing", + "complete", + "error", + ] + message: str + data: Any = None + timestamp: datetime = Field(default_factory=datetime.now) + iteration: int = 0 + + def to_markdown(self) -> str: + """Format event as markdown for chat display.""" + icons = { + "started": "🚀", + "searching": "🔍", + "search_complete": "📚", + "judging": "🧠", + "judge_complete": "✅", + "looping": "🔄", + "synthesizing": "📝", + "complete": "🎉", + "error": "❌", + } + icon = icons.get(self.type, "•") + return f"{icon} **{self.type.upper()}**: {self.message}" + + +class OrchestratorConfig(BaseModel): + """Configuration for the orchestrator.""" + + max_iterations: int = Field(default=5, ge=1, le=10) + max_results_per_tool: int = Field(default=10, ge=1, le=50) + search_timeout: float = Field(default=30.0, ge=5.0, le=120.0) +``` + +--- + +## 3. The Orchestrator (`src/orchestrator.py`) + +This is the "Agent" logic — the while loop that drives search and judgment. + +```python +"""Orchestrator - the agent loop connecting Search and Judge.""" +import asyncio +from typing import AsyncGenerator, List, Protocol +import structlog + +from src.utils.models import ( + Evidence, + SearchResult, + JudgeAssessment, + AgentEvent, + OrchestratorConfig, +) + +logger = structlog.get_logger() + + +class SearchHandlerProtocol(Protocol): + """Protocol for search handler.""" + async def execute(self, query: str, max_results_per_tool: int = 10) -> SearchResult: + ... + + +class JudgeHandlerProtocol(Protocol): + """Protocol for judge handler.""" + async def assess(self, question: str, evidence: List[Evidence]) -> JudgeAssessment: + ... + + +class Orchestrator: + """ + The agent orchestrator - runs the Search -> Judge -> Loop cycle. + + This is a generator-based design that yields events for real-time UI updates. + """ + + def __init__( + self, + search_handler: SearchHandlerProtocol, + judge_handler: JudgeHandlerProtocol, + config: OrchestratorConfig | None = None, + ): + """ + Initialize the orchestrator. + + Args: + search_handler: Handler for executing searches + judge_handler: Handler for assessing evidence + config: Optional configuration (uses defaults if not provided) + """ + self.search = search_handler + self.judge = judge_handler + self.config = config or OrchestratorConfig() + self.history: List[dict] = [] + + async def run(self, query: str) -> AsyncGenerator[AgentEvent, None]: + """ + Run the agent loop for a query. + + Yields AgentEvent objects for each step, allowing real-time UI updates. + + Args: + query: The user's research question + + Yields: + AgentEvent objects for each step of the process + """ + logger.info("Starting orchestrator", query=query) + + yield AgentEvent( + type="started", + message=f"Starting research for: {query}", + iteration=0, + ) + + all_evidence: List[Evidence] = [] + current_queries = [query] + iteration = 0 + + while iteration < self.config.max_iterations: + iteration += 1 + logger.info("Iteration", iteration=iteration, queries=current_queries) + + # === SEARCH PHASE === + yield AgentEvent( + type="searching", + message=f"Searching for: {', '.join(current_queries[:3])}...", + iteration=iteration, + ) + + try: + # Execute searches for all current queries + search_tasks = [ + self.search.execute(q, self.config.max_results_per_tool) + for q in current_queries[:3] # Limit to 3 queries per iteration + ] + search_results = await asyncio.gather(*search_tasks, return_exceptions=True) + + # Collect evidence from successful searches + new_evidence: List[Evidence] = [] + errors: List[str] = [] + + for q, result in zip(current_queries[:3], search_results): + if isinstance(result, Exception): + errors.append(f"Search for '{q}' failed: {str(result)}") + else: + new_evidence.extend(result.evidence) + errors.extend(result.errors) + + # Deduplicate evidence by URL + seen_urls = {e.citation.url for e in all_evidence} + unique_new = [e for e in new_evidence if e.citation.url not in seen_urls] + all_evidence.extend(unique_new) + + yield AgentEvent( + type="search_complete", + message=f"Found {len(unique_new)} new sources ({len(all_evidence)} total)", + data={"new_count": len(unique_new), "total_count": len(all_evidence)}, + iteration=iteration, + ) + + if errors: + logger.warning("Search errors", errors=errors) + + except Exception as e: + logger.error("Search phase failed", error=str(e)) + yield AgentEvent( + type="error", + message=f"Search failed: {str(e)}", + iteration=iteration, + ) + continue + + # === JUDGE PHASE === + yield AgentEvent( + type="judging", + message=f"Evaluating {len(all_evidence)} sources...", + iteration=iteration, + ) + + try: + assessment = await self.judge.assess(query, all_evidence) + + yield AgentEvent( + type="judge_complete", + message=f"Assessment: {assessment.recommendation} (confidence: {assessment.confidence:.0%})", + data={ + "sufficient": assessment.sufficient, + "confidence": assessment.confidence, + "mechanism_score": assessment.details.mechanism_score, + "clinical_score": assessment.details.clinical_evidence_score, + }, + iteration=iteration, + ) + + # Record this iteration in history + self.history.append({ + "iteration": iteration, + "queries": current_queries, + "evidence_count": len(all_evidence), + "assessment": assessment.model_dump(), + }) + + # === DECISION PHASE === + if assessment.sufficient and assessment.recommendation == "synthesize": + yield AgentEvent( + type="synthesizing", + message="Evidence sufficient! Preparing synthesis...", + iteration=iteration, + ) + + # Generate final response + final_response = self._generate_synthesis(query, all_evidence, assessment) + + yield AgentEvent( + type="complete", + message=final_response, + data={ + "evidence_count": len(all_evidence), + "iterations": iteration, + "drug_candidates": assessment.details.drug_candidates, + "key_findings": assessment.details.key_findings, + }, + iteration=iteration, + ) + return + + else: + # Need more evidence - prepare next queries + current_queries = assessment.next_search_queries or [ + f"{query} mechanism of action", + f"{query} clinical evidence", + ] + + yield AgentEvent( + type="looping", + message=f"Need more evidence. Next searches: {', '.join(current_queries[:2])}...", + data={"next_queries": current_queries}, + iteration=iteration, + ) + + except Exception as e: + logger.error("Judge phase failed", error=str(e)) + yield AgentEvent( + type="error", + message=f"Assessment failed: {str(e)}", + iteration=iteration, + ) + continue + + # Max iterations reached + yield AgentEvent( + type="complete", + message=self._generate_partial_synthesis(query, all_evidence), + data={ + "evidence_count": len(all_evidence), + "iterations": iteration, + "max_reached": True, + }, + iteration=iteration, + ) + + def _generate_synthesis( + self, + query: str, + evidence: List[Evidence], + assessment: JudgeAssessment, + ) -> str: + """ + Generate the final synthesis response. + + Args: + query: The original question + evidence: All collected evidence + assessment: The final assessment + + Returns: + Formatted synthesis as markdown + """ + drug_list = "\n".join([f"- **{d}**" for d in assessment.details.drug_candidates]) or "- No specific candidates identified" + findings_list = "\n".join([f"- {f}" for f in assessment.details.key_findings]) or "- See evidence below" + + citations = "\n".join([ + f"{i+1}. [{e.citation.title}]({e.citation.url}) ({e.citation.source.upper()}, {e.citation.date})" + for i, e in enumerate(evidence[:10]) # Limit to 10 citations + ]) + + return f"""## Drug Repurposing Analysis + +### Question +{query} + +### Drug Candidates +{drug_list} + +### Key Findings +{findings_list} + +### Assessment +- **Mechanism Score**: {assessment.details.mechanism_score}/10 +- **Clinical Evidence Score**: {assessment.details.clinical_evidence_score}/10 +- **Confidence**: {assessment.confidence:.0%} + +### Reasoning +{assessment.reasoning} + +### Citations ({len(evidence)} sources) +{citations} + +--- +*Analysis based on {len(evidence)} sources across {len(self.history)} iterations.* +""" + + def _generate_partial_synthesis( + self, + query: str, + evidence: List[Evidence], + ) -> str: + """ + Generate a partial synthesis when max iterations reached. + + Args: + query: The original question + evidence: All collected evidence + + Returns: + Formatted partial synthesis as markdown + """ + citations = "\n".join([ + f"{i+1}. [{e.citation.title}]({e.citation.url}) ({e.citation.source.upper()})" + for i, e in enumerate(evidence[:10]) + ]) + + return f"""## Partial Analysis (Max Iterations Reached) + +### Question +{query} + +### Status +Maximum search iterations reached. The evidence gathered may be incomplete. + +### Evidence Collected +Found {len(evidence)} sources. Consider refining your query for more specific results. + +### Citations +{citations} + +--- +*Consider searching with more specific terms or drug names.* +""" +``` + +--- + +## 4. The Gradio UI (`src/app.py`) + +Using Gradio 5 generator pattern for real-time streaming. + +```python +"""Gradio UI for DeepCritical agent.""" +import asyncio +import gradio as gr +from typing import AsyncGenerator + +from src.orchestrator import Orchestrator +from src.tools.pubmed import PubMedTool +from src.tools.clinicaltrials import ClinicalTrialsTool +from src.tools.biorxiv import BioRxivTool +from src.tools.search_handler import SearchHandler +from src.agent_factory.judges import JudgeHandler, HFInferenceJudgeHandler +from src.utils.models import OrchestratorConfig, AgentEvent + + +def create_orchestrator( + user_api_key: str | None = None, + api_provider: str = "openai", +) -> tuple[Orchestrator, str]: + """ + Create an orchestrator instance. + + Args: + user_api_key: Optional user-provided API key (BYOK) + api_provider: API provider ("openai" or "anthropic") + + Returns: + Tuple of (Configured Orchestrator instance, backend_name) + + Priority: + 1. User-provided API key → JudgeHandler (OpenAI/Anthropic) + 2. Environment API key → JudgeHandler (OpenAI/Anthropic) + 3. No key → HFInferenceJudgeHandler (FREE, automatic fallback chain) + + HF Inference Fallback Chain: + 1. Llama 3.1 8B (requires HF_TOKEN for gated model) + 2. Mistral 7B (may require token) + 3. Zephyr 7B (ungated, always works) + """ + import os + + # Create search tools + search_handler = SearchHandler( + tools=[PubMedTool(), ClinicalTrialsTool(), BioRxivTool()], + timeout=30.0, + ) + + # Determine which judge to use + has_env_key = bool(os.getenv("OPENAI_API_KEY") or os.getenv("ANTHROPIC_API_KEY")) + has_user_key = bool(user_api_key) + has_hf_token = bool(os.getenv("HF_TOKEN")) + + if has_user_key: + # User provided their own key + judge_handler = JudgeHandler(model=None) + backend_name = f"your {api_provider.upper()} API key" + elif has_env_key: + # Environment has API key configured + judge_handler = JudgeHandler(model=None) + backend_name = "configured API key" + else: + # Use FREE HuggingFace Inference with automatic fallback + judge_handler = HFInferenceJudgeHandler() + if has_hf_token: + backend_name = "HuggingFace Inference (Llama 3.1)" + else: + backend_name = "HuggingFace Inference (free tier)" + + # Create orchestrator + config = OrchestratorConfig( + max_iterations=5, + max_results_per_tool=10, + ) + + return Orchestrator( + search_handler=search_handler, + judge_handler=judge_handler, + config=config, + ), backend_name + + +async def research_agent( + message: str, + history: list[dict], + api_key: str = "", + api_provider: str = "openai", +) -> AsyncGenerator[str, None]: + """ + Gradio chat function that runs the research agent. + + Args: + message: User's research question + history: Chat history (Gradio format) + api_key: Optional user-provided API key (BYOK) + api_provider: API provider ("openai" or "anthropic") + + Yields: + Markdown-formatted responses for streaming + """ + if not message.strip(): + yield "Please enter a research question." + return + + import os + + # Clean user-provided API key + user_api_key = api_key.strip() if api_key else None + + # Create orchestrator with appropriate judge + orchestrator, backend_name = create_orchestrator( + user_api_key=user_api_key, + api_provider=api_provider, + ) + + # Determine icon based on backend + has_hf_token = bool(os.getenv("HF_TOKEN")) + if "HuggingFace" in backend_name: + icon = "🤗" + extra_note = ( + "\n*For premium analysis, enter an OpenAI or Anthropic API key.*" + if not has_hf_token else "" + ) + else: + icon = "🔑" + extra_note = "" + + # Inform user which backend is being used + yield f"{icon} **Using {backend_name}**{extra_note}\n\n" + + # Run the agent and stream events + response_parts = [] + + try: + async for event in orchestrator.run(message): + # Format event as markdown + event_md = event.to_markdown() + response_parts.append(event_md) + + # If complete, show full response + if event.type == "complete": + yield event.message + else: + # Show progress + yield "\n\n".join(response_parts) + + except Exception as e: + yield f"❌ **Error**: {str(e)}" + + +def create_demo() -> gr.Blocks: + """ + Create the Gradio demo interface. + + Returns: + Configured Gradio Blocks interface + """ + with gr.Blocks( + title="DeepCritical - Drug Repurposing Research Agent", + theme=gr.themes.Soft(), + ) as demo: + gr.Markdown(""" + # 🧬 DeepCritical + ## AI-Powered Drug Repurposing Research Agent + + Ask questions about potential drug repurposing opportunities. + The agent will search PubMed and the web, evaluate evidence, and provide recommendations. + + **Example questions:** + - "What drugs could be repurposed for Alzheimer's disease?" + - "Is metformin effective for cancer treatment?" + - "What existing medications show promise for Long COVID?" + """) + + # Note: additional_inputs render in an accordion below the chat input + gr.ChatInterface( + fn=research_agent, + examples=[ + [ + "What drugs could be repurposed for Alzheimer's disease?", + "simple", + "", + "openai", + ], + [ + "Is metformin effective for treating cancer?", + "simple", + "", + "openai", + ], + ], + additional_inputs=[ + gr.Radio( + choices=["simple", "magentic"], + value="simple", + label="Orchestrator Mode", + info="Simple: Linear | Magentic: Multi-Agent (OpenAI)", + ), + gr.Textbox( + label="API Key (Optional - Bring Your Own Key)", + placeholder="sk-... or sk-ant-...", + type="password", + info="Enter your own API key for full AI analysis. Never stored.", + ), + gr.Radio( + choices=["openai", "anthropic"], + value="openai", + label="API Provider", + info="Select the provider for your API key", + ), + ], + ) + + gr.Markdown(""" + --- + **Note**: This is a research tool and should not be used for medical decisions. + Always consult healthcare professionals for medical advice. + + Built with 🤖 PydanticAI + 🔬 PubMed + 🦆 DuckDuckGo + """) + + return demo + + +def main(): + """Run the Gradio app.""" + demo = create_demo() + demo.launch( + server_name="0.0.0.0", + server_port=7860, + share=False, + ) + + +if __name__ == "__main__": + main() +``` + +--- + +## 5. TDD Workflow + +### Test File: `tests/unit/test_orchestrator.py` + +```python +"""Unit tests for Orchestrator.""" +import pytest +from unittest.mock import AsyncMock, MagicMock + +from src.utils.models import ( + Evidence, + Citation, + SearchResult, + JudgeAssessment, + AssessmentDetails, + OrchestratorConfig, +) + + +class TestOrchestrator: + """Tests for Orchestrator.""" + + @pytest.fixture + def mock_search_handler(self): + """Create a mock search handler.""" + handler = AsyncMock() + handler.execute = AsyncMock(return_value=SearchResult( + query="test", + evidence=[ + Evidence( + content="Test content", + citation=Citation( + source="pubmed", + title="Test Title", + url="https://pubmed.ncbi.nlm.nih.gov/12345/", + date="2024-01-01", + ), + ), + ], + sources_searched=["pubmed"], + total_found=1, + errors=[], + )) + return handler + + @pytest.fixture + def mock_judge_sufficient(self): + """Create a mock judge that returns sufficient.""" + handler = AsyncMock() + handler.assess = AsyncMock(return_value=JudgeAssessment( + details=AssessmentDetails( + mechanism_score=8, + mechanism_reasoning="Good mechanism", + clinical_evidence_score=7, + clinical_reasoning="Good clinical", + drug_candidates=["Drug A"], + key_findings=["Finding 1"], + ), + sufficient=True, + confidence=0.85, + recommendation="synthesize", + next_search_queries=[], + reasoning="Evidence is sufficient", + )) + return handler + + @pytest.fixture + def mock_judge_insufficient(self): + """Create a mock judge that returns insufficient.""" + handler = AsyncMock() + handler.assess = AsyncMock(return_value=JudgeAssessment( + details=AssessmentDetails( + mechanism_score=4, + mechanism_reasoning="Weak mechanism", + clinical_evidence_score=3, + clinical_reasoning="Weak clinical", + drug_candidates=[], + key_findings=[], + ), + sufficient=False, + confidence=0.3, + recommendation="continue", + next_search_queries=["more specific query"], + reasoning="Need more evidence", + )) + return handler + + @pytest.mark.asyncio + async def test_orchestrator_completes_with_sufficient_evidence( + self, + mock_search_handler, + mock_judge_sufficient, + ): + """Orchestrator should complete when evidence is sufficient.""" + from src.orchestrator import Orchestrator + + config = OrchestratorConfig(max_iterations=5) + orchestrator = Orchestrator( + search_handler=mock_search_handler, + judge_handler=mock_judge_sufficient, + config=config, + ) + + events = [] + async for event in orchestrator.run("test query"): + events.append(event) + + # Should have started, searched, judged, and completed + event_types = [e.type for e in events] + assert "started" in event_types + assert "searching" in event_types + assert "search_complete" in event_types + assert "judging" in event_types + assert "judge_complete" in event_types + assert "complete" in event_types + + # Should only have 1 iteration + complete_event = [e for e in events if e.type == "complete"][0] + assert complete_event.iteration == 1 + + @pytest.mark.asyncio + async def test_orchestrator_loops_when_insufficient( + self, + mock_search_handler, + mock_judge_insufficient, + ): + """Orchestrator should loop when evidence is insufficient.""" + from src.orchestrator import Orchestrator + + config = OrchestratorConfig(max_iterations=3) + orchestrator = Orchestrator( + search_handler=mock_search_handler, + judge_handler=mock_judge_insufficient, + config=config, + ) + + events = [] + async for event in orchestrator.run("test query"): + events.append(event) + + # Should have looping events + event_types = [e.type for e in events] + assert event_types.count("looping") >= 2 # At least 2 loop events + + # Should hit max iterations + complete_event = [e for e in events if e.type == "complete"][0] + assert complete_event.data.get("max_reached") is True + + @pytest.mark.asyncio + async def test_orchestrator_respects_max_iterations( + self, + mock_search_handler, + mock_judge_insufficient, + ): + """Orchestrator should stop at max_iterations.""" + from src.orchestrator import Orchestrator + + config = OrchestratorConfig(max_iterations=2) + orchestrator = Orchestrator( + search_handler=mock_search_handler, + judge_handler=mock_judge_insufficient, + config=config, + ) + + events = [] + async for event in orchestrator.run("test query"): + events.append(event) + + # Should have exactly 2 iterations + max_iteration = max(e.iteration for e in events) + assert max_iteration == 2 + + @pytest.mark.asyncio + async def test_orchestrator_handles_search_error(self): + """Orchestrator should handle search errors gracefully.""" + from src.orchestrator import Orchestrator + + mock_search = AsyncMock() + mock_search.execute = AsyncMock(side_effect=Exception("Search failed")) + + mock_judge = AsyncMock() + mock_judge.assess = AsyncMock(return_value=JudgeAssessment( + details=AssessmentDetails( + mechanism_score=0, + mechanism_reasoning="N/A", + clinical_evidence_score=0, + clinical_reasoning="N/A", + drug_candidates=[], + key_findings=[], + ), + sufficient=False, + confidence=0.0, + recommendation="continue", + next_search_queries=["retry query"], + reasoning="Search failed", + )) + + config = OrchestratorConfig(max_iterations=2) + orchestrator = Orchestrator( + search_handler=mock_search, + judge_handler=mock_judge, + config=config, + ) + + events = [] + async for event in orchestrator.run("test query"): + events.append(event) + + # Should have error events + event_types = [e.type for e in events] + assert "error" in event_types + + @pytest.mark.asyncio + async def test_orchestrator_deduplicates_evidence(self, mock_judge_insufficient): + """Orchestrator should deduplicate evidence by URL.""" + from src.orchestrator import Orchestrator + + # Search returns same evidence each time + duplicate_evidence = Evidence( + content="Duplicate content", + citation=Citation( + source="pubmed", + title="Same Title", + url="https://pubmed.ncbi.nlm.nih.gov/12345/", # Same URL + date="2024-01-01", + ), + ) + + mock_search = AsyncMock() + mock_search.execute = AsyncMock(return_value=SearchResult( + query="test", + evidence=[duplicate_evidence], + sources_searched=["pubmed"], + total_found=1, + errors=[], + )) + + config = OrchestratorConfig(max_iterations=2) + orchestrator = Orchestrator( + search_handler=mock_search, + judge_handler=mock_judge_insufficient, + config=config, + ) + + events = [] + async for event in orchestrator.run("test query"): + events.append(event) + + # Second search_complete should show 0 new evidence + search_complete_events = [e for e in events if e.type == "search_complete"] + assert len(search_complete_events) == 2 + + # First iteration should have 1 new + assert search_complete_events[0].data["new_count"] == 1 + + # Second iteration should have 0 new (duplicate) + assert search_complete_events[1].data["new_count"] == 0 + + +class TestAgentEvent: + """Tests for AgentEvent.""" + + def test_to_markdown(self): + """AgentEvent should format to markdown correctly.""" + from src.utils.models import AgentEvent + + event = AgentEvent( + type="searching", + message="Searching for: metformin alzheimer", + iteration=1, + ) + + md = event.to_markdown() + assert "🔍" in md + assert "SEARCHING" in md + assert "metformin alzheimer" in md + + def test_complete_event_icon(self): + """Complete event should have celebration icon.""" + from src.utils.models import AgentEvent + + event = AgentEvent( + type="complete", + message="Done!", + iteration=3, + ) + + md = event.to_markdown() + assert "🎉" in md +``` + +--- + +## 6. Dockerfile + +```dockerfile +# Dockerfile for DeepCritical +FROM python:3.11-slim + +# Set working directory +WORKDIR /app + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + git \ + && rm -rf /var/lib/apt/lists/* + +# Install uv +RUN pip install uv + +# Copy project files +COPY pyproject.toml . +COPY src/ src/ + +# Install dependencies +RUN uv pip install --system . + +# Expose port +EXPOSE 7860 + +# Set environment variables +ENV GRADIO_SERVER_NAME=0.0.0.0 +ENV GRADIO_SERVER_PORT=7860 + +# Run the app +CMD ["python", "-m", "src.app"] +``` + +--- + +## 7. HuggingFace Spaces Configuration + +Create `README.md` header for HuggingFace Spaces: + +```markdown +--- +title: DeepCritical +emoji: 🧬 +colorFrom: blue +colorTo: purple +sdk: gradio +sdk_version: 5.0.0 +app_file: src/app.py +pinned: false +license: mit +--- + +# DeepCritical + +AI-Powered Drug Repurposing Research Agent +``` + +--- + +## 8. Implementation Checklist + +- [ ] Add `AgentEvent` and `OrchestratorConfig` models to `src/utils/models.py` +- [ ] Implement `src/orchestrator.py` with full Orchestrator class +- [ ] Implement `src/app.py` with Gradio interface +- [ ] Create `tests/unit/test_orchestrator.py` with all tests +- [ ] Create `Dockerfile` for deployment +- [ ] Update project `README.md` with usage instructions +- [ ] Run `uv run pytest tests/unit/test_orchestrator.py -v` — **ALL TESTS MUST PASS** +- [ ] Test locally: `uv run python -m src.app` +- [ ] Commit: `git commit -m "feat: phase 4 orchestrator and UI complete"` + +--- + +## 9. Definition of Done + +Phase 4 is **COMPLETE** when: + +1. All unit tests pass: `uv run pytest tests/unit/test_orchestrator.py -v` +2. Orchestrator correctly loops Search -> Judge until sufficient +3. Max iterations limit is enforced +4. Graceful error handling throughout +5. Gradio UI streams events in real-time +6. Can run locally: + +```bash +# Start the UI +uv run python -m src.app + +# Open browser to http://localhost:7860 +# Enter a question like "What drugs could be repurposed for Alzheimer's disease?" +# Watch the agent search, evaluate, and respond +``` + +7. Can run the full flow in Python: + +```python +import asyncio +from src.orchestrator import Orchestrator +from src.tools.pubmed import PubMedTool +from src.tools.biorxiv import BioRxivTool +from src.tools.clinicaltrials import ClinicalTrialsTool +from src.tools.search_handler import SearchHandler +from src.agent_factory.judges import HFInferenceJudgeHandler, MockJudgeHandler +from src.utils.models import OrchestratorConfig + +async def test_full_flow(): + # Create components + search_handler = SearchHandler([PubMedTool(), ClinicalTrialsTool(), BioRxivTool()]) + + # Option 1: Use FREE HuggingFace Inference (real AI analysis) + judge_handler = HFInferenceJudgeHandler() + + # Option 2: Use MockJudgeHandler for UNIT TESTING ONLY + # judge_handler = MockJudgeHandler() + + config = OrchestratorConfig(max_iterations=3) + + # Create orchestrator + orchestrator = Orchestrator( + search_handler=search_handler, + judge_handler=judge_handler, + config=config, + ) + + # Run and collect events + print("Starting agent...") + async for event in orchestrator.run("metformin alzheimer"): + print(event.to_markdown()) + + print("\nDone!") + +asyncio.run(test_full_flow()) +``` + +**Important**: `MockJudgeHandler` is for **unit testing only**. For actual demo/production use, always use `HFInferenceJudgeHandler` (free) or `JudgeHandler` (with API key). + +--- + +## 10. Deployment Verification + +After deployment to HuggingFace Spaces: + +1. **Visit the Space URL** and verify the UI loads +2. **Test with example queries**: + - "What drugs could be repurposed for Alzheimer's disease?" + - "Is metformin effective for cancer treatment?" +3. **Verify streaming** - events should appear in real-time +4. **Check error handling** - try an empty query, verify graceful handling +5. **Monitor logs** for any errors + +--- + +## Project Complete! 🎉 + +When Phase 4 is done, the DeepCritical MVP is complete: + +- **Phase 1**: Foundation (uv, pytest, config) ✅ +- **Phase 2**: Search Slice (PubMed, DuckDuckGo) ✅ +- **Phase 3**: Judge Slice (PydanticAI, structured output) ✅ +- **Phase 4**: Orchestrator + UI (Gradio, streaming) ✅ + +The agent can: +1. Accept a drug repurposing question +2. Search PubMed and the web for evidence +3. Evaluate evidence quality with an LLM +4. Loop until confident or max iterations +5. Synthesize a research-backed recommendation +6. Display real-time progress in a beautiful UI diff --git a/docs/implementation/05_phase_magentic.md b/docs/implementation/05_phase_magentic.md new file mode 100644 index 0000000000000000000000000000000000000000..fd5de5fc30fea1802c6198923bc0b542a4f566aa --- /dev/null +++ b/docs/implementation/05_phase_magentic.md @@ -0,0 +1,1091 @@ +# Phase 5 Implementation Spec: Magentic Integration + +**Goal**: Upgrade orchestrator to use Microsoft Agent Framework's Magentic-One pattern. +**Philosophy**: "Same API, Better Engine." +**Prerequisite**: Phase 4 complete (MVP working end-to-end) + +--- + +## 1. Why Magentic? + +Magentic-One provides: +- **LLM-powered manager** that dynamically plans, selects agents, tracks progress +- **Built-in stall detection** and automatic replanning +- **Checkpointing** for pause/resume workflows +- **Event streaming** for real-time UI updates +- **Multi-agent coordination** with round limits and reset logic + +--- + +## 2. Critical Architecture Understanding + +### 2.1 How Magentic Actually Works + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ MagenticBuilder Workflow │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ User Task: "Research drug repurposing for metformin alzheimer" │ +│ ↓ │ +│ ┌──────────────────────────────────────────────────────────────────┐ │ +│ │ StandardMagenticManager │ │ +│ │ │ │ +│ │ 1. plan() → LLM generates facts & plan │ │ +│ │ 2. create_progress_ledger() → LLM decides: │ │ +│ │ - is_request_satisfied? │ │ +│ │ - next_speaker: "searcher" │ │ +│ │ - instruction_or_question: "Search for clinical trials..." │ │ +│ │ │ │ +│ └──────────────────────────────────────────────────────────────────┘ │ +│ ↓ │ +│ NATURAL LANGUAGE INSTRUCTION sent to agent │ +│ "Search for clinical trials about metformin..." │ +│ ↓ │ +│ ┌──────────────────────────────────────────────────────────────────┐ │ +│ │ ChatAgent (searcher) │ │ +│ │ │ │ +│ │ chat_client (INTERNAL LLM) ← understands instruction │ │ +│ │ ↓ │ │ +│ │ "I'll search for metformin alzheimer clinical trials" │ │ +│ │ ↓ │ │ +│ │ tools=[search_pubmed, search_clinicaltrials] ← calls tools │ │ +│ │ ↓ │ │ +│ │ Returns natural language response to manager │ │ +│ │ │ │ +│ └──────────────────────────────────────────────────────────────────┘ │ +│ ↓ │ +│ Manager evaluates response │ +│ Decides next agent or completion │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +### 2.2 The Critical Insight + +**Microsoft's ChatAgent has an INTERNAL LLM (`chat_client`) that:** +1. Receives natural language instructions from the manager +2. Understands what action to take +3. Calls attached tools (functions) +4. Returns natural language responses + +**Our previous implementation was WRONG because:** +- We wrapped handlers as bare `BaseAgent` subclasses +- No internal LLM to understand instructions +- Raw instruction text was passed directly to APIs (PubMed doesn't understand "Search for clinical trials...") + +### 2.3 Correct Pattern: ChatAgent with Tools + +```python +# CORRECT: Agent backed by LLM that calls tools +from agent_framework import ChatAgent, AIFunction +from agent_framework.openai import OpenAIChatClient + +# Define tool that ChatAgent can call +@AIFunction +async def search_pubmed(query: str, max_results: int = 10) -> str: + """Search PubMed for biomedical literature. + + Args: + query: Search keywords (e.g., "metformin alzheimer mechanism") + max_results: Maximum number of results to return + """ + result = await pubmed_tool.search(query, max_results) + return format_results(result) + +# ChatAgent with internal LLM + tools +search_agent = ChatAgent( + name="SearchAgent", + description="Searches biomedical databases for drug repurposing evidence", + instructions="You search PubMed, ClinicalTrials.gov, and bioRxiv for evidence.", + chat_client=OpenAIChatClient(model_id="gpt-4o-mini"), # INTERNAL LLM + tools=[search_pubmed, search_clinicaltrials, search_biorxiv], # TOOLS +) +``` + +--- + +## 3. Correct Implementation + +### 3.1 Shared State Module (`src/agents/state.py`) + +**CRITICAL**: Tools must update shared state so: +1. EmbeddingService can deduplicate across searches +2. ReportAgent can access structured Evidence objects for citations + +```python +"""Shared state for Magentic agents. + +This module provides global state that tools update as a side effect. +ChatAgent tools return strings to the LLM, but also update this state +for semantic deduplication and structured citation access. +""" +from __future__ import annotations + +from typing import TYPE_CHECKING + +import structlog + +if TYPE_CHECKING: + from src.services.embeddings import EmbeddingService + +from src.utils.models import Evidence + +logger = structlog.get_logger() + + +class MagenticState: + """Shared state container for Magentic workflow. + + Maintains: + - evidence_store: All collected Evidence objects (for citations) + - embedding_service: Optional semantic search (for deduplication) + """ + + def __init__(self) -> None: + self.evidence_store: list[Evidence] = [] + self.embedding_service: EmbeddingService | None = None + self._seen_urls: set[str] = set() + + def init_embedding_service(self) -> None: + """Lazy-initialize embedding service if available.""" + if self.embedding_service is not None: + return + try: + from src.services.embeddings import get_embedding_service + self.embedding_service = get_embedding_service() + logger.info("Embedding service enabled for Magentic mode") + except Exception as e: + logger.warning("Embedding service unavailable", error=str(e)) + + async def add_evidence(self, evidence_list: list[Evidence]) -> list[Evidence]: + """Add evidence with semantic deduplication. + + Args: + evidence_list: New evidence from search + + Returns: + List of unique evidence (not duplicates) + """ + if not evidence_list: + return [] + + # URL-based deduplication first (fast) + url_unique = [ + e for e in evidence_list + if e.citation.url not in self._seen_urls + ] + + # Semantic deduplication if available + if self.embedding_service and url_unique: + try: + unique = await self.embedding_service.deduplicate(url_unique, threshold=0.85) + logger.info( + "Semantic deduplication", + before=len(url_unique), + after=len(unique), + ) + except Exception as e: + logger.warning("Deduplication failed, using URL-based", error=str(e)) + unique = url_unique + else: + unique = url_unique + + # Update state + for e in unique: + self._seen_urls.add(e.citation.url) + self.evidence_store.append(e) + + return unique + + async def search_related(self, query: str, n_results: int = 5) -> list[Evidence]: + """Find semantically related evidence from vector store. + + Args: + query: Search query + n_results: Number of related items + + Returns: + Related Evidence objects (reconstructed from vector store) + """ + if not self.embedding_service: + return [] + + try: + from src.utils.models import Citation + + related = await self.embedding_service.search_similar(query, n_results) + evidence = [] + + for item in related: + if item["id"] in self._seen_urls: + continue # Already in results + + meta = item.get("metadata", {}) + authors_str = meta.get("authors", "") + authors = [a.strip() for a in authors_str.split(",") if a.strip()] + + ev = Evidence( + content=item["content"], + citation=Citation( + title=meta.get("title", "Related Evidence"), + url=item["id"], + source=meta.get("source", "pubmed"), + date=meta.get("date", "n.d."), + authors=authors, + ), + relevance=max(0.0, 1.0 - item.get("distance", 0.5)), + ) + evidence.append(ev) + + return evidence + except Exception as e: + logger.warning("Related search failed", error=str(e)) + return [] + + def reset(self) -> None: + """Reset state for new workflow run.""" + self.evidence_store.clear() + self._seen_urls.clear() + + +# Global singleton for workflow +_state: MagenticState | None = None + + +def get_magentic_state() -> MagenticState: + """Get or create the global Magentic state.""" + global _state + if _state is None: + _state = MagenticState() + return _state + + +def reset_magentic_state() -> None: + """Reset state for a fresh workflow run.""" + global _state + if _state is not None: + _state.reset() + else: + _state = MagenticState() +``` + +### 3.2 Tool Functions (`src/agents/tools.py`) + +Tools call APIs AND update shared state. Return strings to LLM, but also store structured Evidence. + +```python +"""Tool functions for Magentic agents. + +IMPORTANT: These tools do TWO things: +1. Return formatted strings to the ChatAgent's internal LLM +2. Update shared state (evidence_store, embeddings) as a side effect + +This preserves semantic deduplication and structured citation access. +""" +from agent_framework import AIFunction + +from src.agents.state import get_magentic_state +from src.tools.biorxiv import BioRxivTool +from src.tools.clinicaltrials import ClinicalTrialsTool +from src.tools.pubmed import PubMedTool + +# Singleton tool instances +_pubmed = PubMedTool() +_clinicaltrials = ClinicalTrialsTool() +_biorxiv = BioRxivTool() + + +def _format_results(results: list, source_name: str, query: str) -> str: + """Format search results for LLM consumption.""" + if not results: + return f"No {source_name} results found for: {query}" + + output = [f"Found {len(results)} {source_name} results:\n"] + for i, r in enumerate(results[:10], 1): + output.append(f"{i}. **{r.citation.title}**") + output.append(f" Source: {r.citation.source} | Date: {r.citation.date}") + output.append(f" {r.content[:300]}...") + output.append(f" URL: {r.citation.url}\n") + + return "\n".join(output) + + +@AIFunction +async def search_pubmed(query: str, max_results: int = 10) -> str: + """Search PubMed for biomedical research papers. + + Use this tool to find peer-reviewed scientific literature about + drugs, diseases, mechanisms of action, and clinical studies. + + Args: + query: Search keywords (e.g., "metformin alzheimer mechanism") + max_results: Maximum results to return (default 10) + + Returns: + Formatted list of papers with titles, abstracts, and citations + """ + # 1. Execute search + results = await _pubmed.search(query, max_results) + + # 2. Update shared state (semantic dedup + evidence store) + state = get_magentic_state() + unique = await state.add_evidence(results) + + # 3. Also get related evidence from vector store + related = await state.search_related(query, n_results=3) + if related: + await state.add_evidence(related) + + # 4. Return formatted string for LLM + total_new = len(unique) + total_stored = len(state.evidence_store) + + output = _format_results(results, "PubMed", query) + output += f"\n[State: {total_new} new, {total_stored} total in evidence store]" + + if related: + output += f"\n[Also found {len(related)} semantically related items from previous searches]" + + return output + + +@AIFunction +async def search_clinical_trials(query: str, max_results: int = 10) -> str: + """Search ClinicalTrials.gov for clinical studies. + + Use this tool to find ongoing and completed clinical trials + for drug repurposing candidates. + + Args: + query: Search terms (e.g., "metformin cancer phase 3") + max_results: Maximum results to return (default 10) + + Returns: + Formatted list of clinical trials with status and details + """ + # 1. Execute search + results = await _clinicaltrials.search(query, max_results) + + # 2. Update shared state + state = get_magentic_state() + unique = await state.add_evidence(results) + + # 3. Return formatted string + total_new = len(unique) + total_stored = len(state.evidence_store) + + output = _format_results(results, "ClinicalTrials.gov", query) + output += f"\n[State: {total_new} new, {total_stored} total in evidence store]" + + return output + + +@AIFunction +async def search_preprints(query: str, max_results: int = 10) -> str: + """Search bioRxiv/medRxiv for preprint papers. + + Use this tool to find the latest research that hasn't been + peer-reviewed yet. Good for cutting-edge findings. + + Args: + query: Search terms (e.g., "long covid treatment") + max_results: Maximum results to return (default 10) + + Returns: + Formatted list of preprints with abstracts and links + """ + # 1. Execute search + results = await _biorxiv.search(query, max_results) + + # 2. Update shared state + state = get_magentic_state() + unique = await state.add_evidence(results) + + # 3. Return formatted string + total_new = len(unique) + total_stored = len(state.evidence_store) + + output = _format_results(results, "bioRxiv/medRxiv", query) + output += f"\n[State: {total_new} new, {total_stored} total in evidence store]" + + return output + + +@AIFunction +async def get_evidence_summary() -> str: + """Get summary of all collected evidence. + + Use this tool when you need to review what evidence has been collected + before making an assessment or generating a report. + + Returns: + Summary of evidence store with counts and key citations + """ + state = get_magentic_state() + evidence = state.evidence_store + + if not evidence: + return "No evidence collected yet." + + # Group by source + by_source: dict[str, list] = {} + for e in evidence: + src = e.citation.source + if src not in by_source: + by_source[src] = [] + by_source[src].append(e) + + output = [f"**Evidence Store Summary** ({len(evidence)} total items)\n"] + + for source, items in by_source.items(): + output.append(f"\n### {source.upper()} ({len(items)} items)") + for e in items[:5]: # First 5 per source + output.append(f"- {e.citation.title[:80]}...") + + return "\n".join(output) + + +@AIFunction +async def get_bibliography() -> str: + """Get full bibliography of all collected evidence. + + Use this tool when generating a final report to get properly + formatted citations for all evidence. + + Returns: + Numbered bibliography with full citation details + """ + state = get_magentic_state() + evidence = state.evidence_store + + if not evidence: + return "No evidence collected for bibliography." + + output = ["## References\n"] + + for i, e in enumerate(evidence, 1): + # Format: Authors (Year). Title. Source. URL + authors = ", ".join(e.citation.authors[:3]) if e.citation.authors else "Unknown" + if e.citation.authors and len(e.citation.authors) > 3: + authors += " et al." + + year = e.citation.date[:4] if e.citation.date else "n.d." + + output.append( + f"{i}. {authors} ({year}). {e.citation.title}. " + f"*{e.citation.source.upper()}*. [{e.citation.url}]({e.citation.url})" + ) + + return "\n".join(output) +``` + +### 3.3 ChatAgent-Based Agents (`src/agents/magentic_agents.py`) + +```python +"""Magentic-compatible agents using ChatAgent pattern.""" +from agent_framework import ChatAgent +from agent_framework.openai import OpenAIChatClient + +from src.agents.tools import ( + get_bibliography, + get_evidence_summary, + search_clinical_trials, + search_preprints, + search_pubmed, +) +from src.utils.config import settings + + +def create_search_agent(chat_client: OpenAIChatClient | None = None) -> ChatAgent: + """Create a search agent with internal LLM and search tools. + + Args: + chat_client: Optional custom chat client. If None, uses default. + + Returns: + ChatAgent configured for biomedical search + """ + client = chat_client or OpenAIChatClient( + model_id="gpt-4o-mini", # Fast, cheap for tool orchestration + api_key=settings.openai_api_key, + ) + + return ChatAgent( + name="SearchAgent", + description="Searches biomedical databases (PubMed, ClinicalTrials.gov, bioRxiv) for drug repurposing evidence", + instructions="""You are a biomedical search specialist. When asked to find evidence: + +1. Analyze the request to determine what to search for +2. Extract key search terms (drug names, disease names, mechanisms) +3. Use the appropriate search tools: + - search_pubmed for peer-reviewed papers + - search_clinical_trials for clinical studies + - search_preprints for cutting-edge findings +4. Summarize what you found and highlight key evidence + +Be thorough - search multiple databases when appropriate. +Focus on finding: mechanisms of action, clinical evidence, and specific drug candidates.""", + chat_client=client, + tools=[search_pubmed, search_clinical_trials, search_preprints], + temperature=0.3, # More deterministic for tool use + ) + + +def create_judge_agent(chat_client: OpenAIChatClient | None = None) -> ChatAgent: + """Create a judge agent that evaluates evidence quality. + + Args: + chat_client: Optional custom chat client. If None, uses default. + + Returns: + ChatAgent configured for evidence assessment + """ + client = chat_client or OpenAIChatClient( + model_id="gpt-4o", # Better model for nuanced judgment + api_key=settings.openai_api_key, + ) + + return ChatAgent( + name="JudgeAgent", + description="Evaluates evidence quality and determines if sufficient for synthesis", + instructions="""You are an evidence quality assessor. When asked to evaluate: + +1. First, call get_evidence_summary() to see all collected evidence +2. Score on two dimensions (0-10 each): + - Mechanism Score: How well is the biological mechanism explained? + - Clinical Score: How strong is the clinical/preclinical evidence? +3. Determine if evidence is SUFFICIENT for a final report: + - Sufficient: Clear mechanism + supporting clinical data + - Insufficient: Gaps in mechanism OR weak clinical evidence +4. If insufficient, suggest specific search queries to fill gaps + +Be rigorous but fair. Look for: +- Molecular targets and pathways +- Animal model studies +- Human clinical trials +- Safety data +- Drug-drug interactions""", + chat_client=client, + tools=[get_evidence_summary], # Can review collected evidence + temperature=0.2, # Consistent judgments + ) + + +def create_hypothesis_agent(chat_client: OpenAIChatClient | None = None) -> ChatAgent: + """Create a hypothesis generation agent. + + Args: + chat_client: Optional custom chat client. If None, uses default. + + Returns: + ChatAgent configured for hypothesis generation + """ + client = chat_client or OpenAIChatClient( + model_id="gpt-4o", + api_key=settings.openai_api_key, + ) + + return ChatAgent( + name="HypothesisAgent", + description="Generates mechanistic hypotheses for drug repurposing", + instructions="""You are a biomedical hypothesis generator. Based on evidence: + +1. Identify the key molecular targets involved +2. Map the biological pathways affected +3. Generate testable hypotheses in this format: + + DRUG → TARGET → PATHWAY → THERAPEUTIC EFFECT + + Example: + Metformin → AMPK activation → mTOR inhibition → Reduced tau phosphorylation + +4. Explain the rationale for each hypothesis +5. Suggest what additional evidence would support or refute it + +Focus on mechanistic plausibility and existing evidence.""", + chat_client=client, + temperature=0.5, # Some creativity for hypothesis generation + ) + + +def create_report_agent(chat_client: OpenAIChatClient | None = None) -> ChatAgent: + """Create a report synthesis agent. + + Args: + chat_client: Optional custom chat client. If None, uses default. + + Returns: + ChatAgent configured for report generation + """ + client = chat_client or OpenAIChatClient( + model_id="gpt-4o", + api_key=settings.openai_api_key, + ) + + return ChatAgent( + name="ReportAgent", + description="Synthesizes research findings into structured reports", + instructions="""You are a scientific report writer. When asked to synthesize: + +1. First, call get_evidence_summary() to review all collected evidence +2. Then call get_bibliography() to get properly formatted citations + +Generate a structured report with these sections: + +## Executive Summary +Brief overview of findings and recommendation + +## Methodology +Databases searched, queries used, evidence reviewed + +## Key Findings +### Mechanism of Action +- Molecular targets +- Biological pathways +- Proposed mechanism + +### Clinical Evidence +- Preclinical studies +- Clinical trials +- Safety profile + +## Drug Candidates +List specific drugs with repurposing potential + +## Limitations +Gaps in evidence, conflicting data, caveats + +## Conclusion +Final recommendation with confidence level + +## References +Use the output from get_bibliography() - do not make up citations! + +Be comprehensive but concise. Cite evidence for all claims.""", + chat_client=client, + tools=[get_evidence_summary, get_bibliography], # Access to collected evidence + temperature=0.3, + ) +``` + +### 3.4 Magentic Orchestrator (`src/orchestrator_magentic.py`) + +```python +"""Magentic-based orchestrator using ChatAgent pattern.""" +from collections.abc import AsyncGenerator +from typing import Any + +import structlog +from agent_framework import ( + MagenticAgentDeltaEvent, + MagenticAgentMessageEvent, + MagenticBuilder, + MagenticFinalResultEvent, + MagenticOrchestratorMessageEvent, + WorkflowOutputEvent, +) +from agent_framework.openai import OpenAIChatClient + +from src.agents.magentic_agents import ( + create_hypothesis_agent, + create_judge_agent, + create_report_agent, + create_search_agent, +) +from src.agents.state import get_magentic_state, reset_magentic_state +from src.utils.config import settings +from src.utils.exceptions import ConfigurationError +from src.utils.models import AgentEvent + +logger = structlog.get_logger() + + +class MagenticOrchestrator: + """ + Magentic-based orchestrator using ChatAgent pattern. + + Each agent has an internal LLM that understands natural language + instructions from the manager and can call tools appropriately. + """ + + def __init__( + self, + max_rounds: int = 10, + chat_client: OpenAIChatClient | None = None, + ) -> None: + """Initialize orchestrator. + + Args: + max_rounds: Maximum coordination rounds + chat_client: Optional shared chat client for agents + """ + if not settings.openai_api_key: + raise ConfigurationError( + "Magentic mode requires OPENAI_API_KEY. " + "Set the key or use mode='simple'." + ) + + self._max_rounds = max_rounds + self._chat_client = chat_client + + def _build_workflow(self) -> Any: + """Build the Magentic workflow with ChatAgent participants.""" + # Create agents with internal LLMs + search_agent = create_search_agent(self._chat_client) + judge_agent = create_judge_agent(self._chat_client) + hypothesis_agent = create_hypothesis_agent(self._chat_client) + report_agent = create_report_agent(self._chat_client) + + # Manager chat client (orchestrates the agents) + manager_client = OpenAIChatClient( + model_id="gpt-4o", # Good model for planning/coordination + api_key=settings.openai_api_key, + ) + + return ( + MagenticBuilder() + .participants( + searcher=search_agent, + hypothesizer=hypothesis_agent, + judge=judge_agent, + reporter=report_agent, + ) + .with_standard_manager( + chat_client=manager_client, + max_round_count=self._max_rounds, + max_stall_count=3, + max_reset_count=2, + ) + .build() + ) + + async def run(self, query: str) -> AsyncGenerator[AgentEvent, None]: + """ + Run the Magentic workflow. + + Args: + query: User's research question + + Yields: + AgentEvent objects for real-time UI updates + """ + logger.info("Starting Magentic orchestrator", query=query) + + # CRITICAL: Reset state for fresh workflow run + reset_magentic_state() + + # Initialize embedding service if available + state = get_magentic_state() + state.init_embedding_service() + + yield AgentEvent( + type="started", + message=f"Starting research (Magentic mode): {query}", + iteration=0, + ) + + workflow = self._build_workflow() + + task = f"""Research drug repurposing opportunities for: {query} + +Workflow: +1. SearchAgent: Find evidence from PubMed, ClinicalTrials.gov, and bioRxiv +2. HypothesisAgent: Generate mechanistic hypotheses (Drug → Target → Pathway → Effect) +3. JudgeAgent: Evaluate if evidence is sufficient +4. If insufficient → SearchAgent refines search based on gaps +5. If sufficient → ReportAgent synthesizes final report + +Focus on: +- Identifying specific molecular targets +- Understanding mechanism of action +- Finding clinical evidence supporting hypotheses + +The final output should be a structured research report.""" + + iteration = 0 + try: + async for event in workflow.run_stream(task): + agent_event = self._process_event(event, iteration) + if agent_event: + if isinstance(event, MagenticAgentMessageEvent): + iteration += 1 + yield agent_event + + except Exception as e: + logger.error("Magentic workflow failed", error=str(e)) + yield AgentEvent( + type="error", + message=f"Workflow error: {e!s}", + iteration=iteration, + ) + + def _process_event(self, event: Any, iteration: int) -> AgentEvent | None: + """Process workflow event into AgentEvent.""" + if isinstance(event, MagenticOrchestratorMessageEvent): + text = event.message.text if event.message else "" + if text: + return AgentEvent( + type="judging", + message=f"Manager ({event.kind}): {text[:200]}...", + iteration=iteration, + ) + + elif isinstance(event, MagenticAgentMessageEvent): + agent_name = event.agent_id or "unknown" + text = event.message.text if event.message else "" + + event_type = "judging" + if "search" in agent_name.lower(): + event_type = "search_complete" + elif "judge" in agent_name.lower(): + event_type = "judge_complete" + elif "hypothes" in agent_name.lower(): + event_type = "hypothesizing" + elif "report" in agent_name.lower(): + event_type = "synthesizing" + + return AgentEvent( + type=event_type, + message=f"{agent_name}: {text[:200]}...", + iteration=iteration + 1, + ) + + elif isinstance(event, MagenticFinalResultEvent): + text = event.message.text if event.message else "No result" + return AgentEvent( + type="complete", + message=text, + data={"iterations": iteration}, + iteration=iteration, + ) + + elif isinstance(event, MagenticAgentDeltaEvent): + if event.text: + return AgentEvent( + type="streaming", + message=event.text, + data={"agent_id": event.agent_id}, + iteration=iteration, + ) + + elif isinstance(event, WorkflowOutputEvent): + if event.data: + return AgentEvent( + type="complete", + message=str(event.data), + iteration=iteration, + ) + + return None +``` + +### 3.4 Updated Factory (`src/orchestrator_factory.py`) + +```python +"""Factory for creating orchestrators.""" +from typing import Any, Literal + +from src.orchestrator import JudgeHandlerProtocol, Orchestrator, SearchHandlerProtocol +from src.utils.models import OrchestratorConfig + + +def create_orchestrator( + search_handler: SearchHandlerProtocol | None = None, + judge_handler: JudgeHandlerProtocol | None = None, + config: OrchestratorConfig | None = None, + mode: Literal["simple", "magentic"] = "simple", +) -> Any: + """ + Create an orchestrator instance. + + Args: + search_handler: The search handler (required for simple mode) + judge_handler: The judge handler (required for simple mode) + config: Optional configuration + mode: "simple" for Phase 4 loop, "magentic" for ChatAgent-based multi-agent + + Returns: + Orchestrator instance + + Note: + Magentic mode does NOT use search_handler/judge_handler. + It creates ChatAgent instances with internal LLMs that call tools directly. + """ + if mode == "magentic": + try: + from src.orchestrator_magentic import MagenticOrchestrator + + return MagenticOrchestrator( + max_rounds=config.max_iterations if config else 10, + ) + except ImportError: + # Fallback to simple if agent-framework not installed + pass + + # Simple mode requires handlers + if search_handler is None or judge_handler is None: + raise ValueError("Simple mode requires search_handler and judge_handler") + + return Orchestrator( + search_handler=search_handler, + judge_handler=judge_handler, + config=config, + ) +``` + +--- + +## 4. Why This Works + +### 4.1 The Manager → Agent Communication + +``` +Manager LLM decides: "Tell SearchAgent to find clinical trials for metformin" + ↓ +Sends instruction: "Search for clinical trials about metformin and cancer" + ↓ +SearchAgent's INTERNAL LLM receives this + ↓ +Internal LLM understands: "I should call search_clinical_trials('metformin cancer')" + ↓ +Tool executes: ClinicalTrials.gov API + ↓ +Internal LLM formats response: "I found 15 trials. Here are the key ones..." + ↓ +Manager receives natural language response +``` + +### 4.2 Why Our Old Implementation Failed + +``` +Manager sends: "Search for clinical trials about metformin..." + ↓ +OLD SearchAgent.run() extracts: query = "Search for clinical trials about metformin..." + ↓ +Passes to PubMed: pubmed.search("Search for clinical trials about metformin...") + ↓ +PubMed doesn't understand English instructions → garbage results or error +``` + +--- + +## 5. Directory Structure + +```text +src/ +├── agents/ +│ ├── __init__.py +│ ├── state.py # MagenticState (evidence_store + embeddings) +│ ├── tools.py # AIFunction tool definitions (update state) +│ └── magentic_agents.py # ChatAgent factory functions +├── services/ +│ └── embeddings.py # EmbeddingService (semantic dedup) +├── orchestrator.py # Simple mode (unchanged) +├── orchestrator_magentic.py # Magentic mode with ChatAgents +└── orchestrator_factory.py # Mode selection +``` + +--- + +## 6. Dependencies + +```toml +[project.optional-dependencies] +magentic = [ + "agent-framework-core>=1.0.0b", + "agent-framework-openai>=1.0.0b", # For OpenAIChatClient +] +embeddings = [ + "chromadb>=0.4.0", + "sentence-transformers>=2.2.0", +] +``` + +**IMPORTANT: Magentic mode REQUIRES OpenAI API key.** + +The Microsoft Agent Framework's standard manager and ChatAgent use OpenAIChatClient internally. +There is no AnthropicChatClient in the framework. If only `ANTHROPIC_API_KEY` is set: +- `mode="simple"` works fine +- `mode="magentic"` throws `ConfigurationError` + +This is enforced in `MagenticOrchestrator.__init__`. + +--- + +## 7. Implementation Checklist + +- [ ] Create `src/agents/state.py` with MagenticState class +- [ ] Create `src/agents/tools.py` with AIFunction search tools + state updates +- [ ] Create `src/agents/magentic_agents.py` with ChatAgent factories +- [ ] Rewrite `src/orchestrator_magentic.py` to use ChatAgent pattern +- [ ] Update `src/orchestrator_factory.py` for new signature +- [ ] Test with real OpenAI API +- [ ] Verify manager properly coordinates agents +- [ ] Ensure tools are called with correct parameters +- [ ] Verify semantic deduplication works (evidence_store populates) +- [ ] Verify bibliography generation in final reports + +--- + +## 8. Definition of Done + +Phase 5 is **COMPLETE** when: + +1. Magentic mode runs without hanging +2. Manager successfully coordinates agents via natural language +3. SearchAgent calls tools with proper search keywords (not raw instructions) +4. JudgeAgent evaluates evidence from conversation history +5. ReportAgent generates structured final report +6. Events stream to UI correctly + +--- + +## 9. Testing Magentic Mode + +```bash +# Test with real API +OPENAI_API_KEY=sk-... uv run python -c " +import asyncio +from src.orchestrator_factory import create_orchestrator + +async def test(): + orch = create_orchestrator(mode='magentic') + async for event in orch.run('metformin alzheimer'): + print(f'[{event.type}] {event.message[:100]}') + +asyncio.run(test()) +" +``` + +Expected output: +``` +[started] Starting research (Magentic mode): metformin alzheimer +[judging] Manager (plan): I will coordinate the agents to research... +[search_complete] SearchAgent: Found 25 PubMed results for metformin alzheimer... +[hypothesizing] HypothesisAgent: Based on the evidence, I propose... +[judge_complete] JudgeAgent: Mechanism Score: 7/10, Clinical Score: 6/10... +[synthesizing] ReportAgent: ## Executive Summary... +[complete] +``` + +--- + +## 10. Key Differences from Old Spec + +| Aspect | OLD (Wrong) | NEW (Correct) | +|--------|-------------|---------------| +| Agent type | `BaseAgent` subclass | `ChatAgent` with `chat_client` | +| Internal LLM | None | OpenAIChatClient | +| How tools work | Handler.execute(raw_instruction) | LLM understands instruction, calls AIFunction | +| Message handling | Extract text → pass to API | LLM interprets → extracts keywords → calls tool | +| State management | Passed to agent constructors | Global MagenticState singleton | +| Evidence storage | In agent instance | In MagenticState.evidence_store | +| Semantic search | Coupled to agents | Tools call state.add_evidence() | +| Citations for report | From agent's store | Via get_bibliography() tool | + +**Key Insights:** +1. Magentic agents must have internal LLMs to understand natural language instructions +2. Tools must update shared state as a side effect (return strings, but also store Evidence) +3. ReportAgent uses `get_bibliography()` tool to access structured citations +4. State is reset at start of each workflow run via `reset_magentic_state()` diff --git a/docs/implementation/06_phase_embeddings.md b/docs/implementation/06_phase_embeddings.md new file mode 100644 index 0000000000000000000000000000000000000000..e71887baa02988ea23f201b806ac9d31cb677d2c --- /dev/null +++ b/docs/implementation/06_phase_embeddings.md @@ -0,0 +1,409 @@ +# Phase 6 Implementation Spec: Embeddings & Semantic Search + +**Goal**: Add vector search for semantic evidence retrieval. +**Philosophy**: "Find what you mean, not just what you type." +**Prerequisite**: Phase 5 complete (Magentic working) + +--- + +## 1. Why Embeddings? + +Current limitation: **Keyword-only search misses semantically related papers.** + +Example problem: +- User searches: "metformin alzheimer" +- PubMed returns: Papers with exact keywords +- MISSED: Papers about "AMPK activation neuroprotection" (same mechanism, different words) + +With embeddings: +- Embed the query AND all evidence +- Find semantically similar papers even without keyword match +- Deduplicate by meaning, not just URL + +--- + +## 2. Architecture + +### Current (Phase 5) +``` +Query → SearchAgent → PubMed/Web (keyword) → Evidence +``` + +### Phase 6 +``` +Query → Embed(Query) → SearchAgent + ├── PubMed/Web (keyword) → Evidence + └── VectorDB (semantic) → Related Evidence + ↑ + Evidence → Embed → Store +``` + +### Shared Context Enhancement +```python +# Current +evidence_store = {"current": []} + +# Phase 6 +evidence_store = { + "current": [], # Raw evidence + "embeddings": {}, # URL -> embedding vector + "vector_index": None, # ChromaDB collection +} +``` + +--- + +## 3. Technology Choice + +### ChromaDB (Recommended) +- **Free**, open-source, local-first +- No API keys, no cloud dependency +- Supports sentence-transformers out of the box +- Perfect for hackathon (no infra setup) + +### Embedding Model +- `sentence-transformers/all-MiniLM-L6-v2` (fast, good quality) +- Or `BAAI/bge-small-en-v1.5` (better quality, still fast) + +--- + +## 4. Implementation + +### 4.1 Dependencies + +Add to `pyproject.toml`: +```toml +[project.optional-dependencies] +embeddings = [ + "chromadb>=0.4.0", + "sentence-transformers>=2.2.0", +] +``` + +### 4.2 Embedding Service (`src/services/embeddings.py`) + +> **CRITICAL: Async Pattern Required** +> +> `sentence-transformers` is synchronous and CPU-bound. Running it directly in async code +> will **block the event loop**, freezing the UI and halting all concurrent operations. +> +> **Solution**: Use `asyncio.run_in_executor()` to offload to thread pool. +> This pattern already exists in `src/tools/websearch.py:28-34`. + +```python +"""Embedding service for semantic search. + +IMPORTANT: All public methods are async to avoid blocking the event loop. +The sentence-transformers model is CPU-bound, so we use run_in_executor(). +""" +import asyncio +from typing import List + +import chromadb +from sentence_transformers import SentenceTransformer + + +class EmbeddingService: + """Handles text embedding and vector storage. + + All embedding operations run in a thread pool to avoid blocking + the async event loop. See src/tools/websearch.py for the pattern. + """ + + def __init__(self, model_name: str = "all-MiniLM-L6-v2"): + self._model = SentenceTransformer(model_name) + self._client = chromadb.Client() # In-memory for hackathon + self._collection = self._client.create_collection( + name="evidence", + metadata={"hnsw:space": "cosine"} + ) + + # ───────────────────────────────────────────────────────────────── + # Sync internal methods (run in thread pool) + # ───────────────────────────────────────────────────────────────── + + def _sync_embed(self, text: str) -> List[float]: + """Synchronous embedding - DO NOT call directly from async code.""" + return self._model.encode(text).tolist() + + def _sync_batch_embed(self, texts: List[str]) -> List[List[float]]: + """Batch embedding for efficiency - DO NOT call directly from async code.""" + return [e.tolist() for e in self._model.encode(texts)] + + # ───────────────────────────────────────────────────────────────── + # Async public methods (safe for event loop) + # ───────────────────────────────────────────────────────────────── + + async def embed(self, text: str) -> List[float]: + """Embed a single text (async-safe). + + Uses run_in_executor to avoid blocking the event loop. + """ + loop = asyncio.get_running_loop() + return await loop.run_in_executor(None, self._sync_embed, text) + + async def embed_batch(self, texts: List[str]) -> List[List[float]]: + """Batch embed multiple texts (async-safe, more efficient).""" + loop = asyncio.get_running_loop() + return await loop.run_in_executor(None, self._sync_batch_embed, texts) + + async def add_evidence(self, evidence_id: str, content: str, metadata: dict) -> None: + """Add evidence to vector store (async-safe).""" + embedding = await self.embed(content) + # ChromaDB operations are fast, but wrap for consistency + loop = asyncio.get_running_loop() + await loop.run_in_executor( + None, + lambda: self._collection.add( + ids=[evidence_id], + embeddings=[embedding], + metadatas=[metadata], + documents=[content] + ) + ) + + async def search_similar(self, query: str, n_results: int = 5) -> List[dict]: + """Find semantically similar evidence (async-safe).""" + query_embedding = await self.embed(query) + + loop = asyncio.get_running_loop() + results = await loop.run_in_executor( + None, + lambda: self._collection.query( + query_embeddings=[query_embedding], + n_results=n_results + ) + ) + + # Handle empty results gracefully + if not results["ids"] or not results["ids"][0]: + return [] + + return [ + {"id": id, "content": doc, "metadata": meta, "distance": dist} + for id, doc, meta, dist in zip( + results["ids"][0], + results["documents"][0], + results["metadatas"][0], + results["distances"][0] + ) + ] + + async def deduplicate(self, new_evidence: List, threshold: float = 0.9) -> List: + """Remove semantically duplicate evidence (async-safe).""" + unique = [] + for evidence in new_evidence: + similar = await self.search_similar(evidence.content, n_results=1) + if not similar or similar[0]["distance"] > (1 - threshold): + unique.append(evidence) + await self.add_evidence( + evidence_id=evidence.citation.url, + content=evidence.content, + metadata={"source": evidence.citation.source} + ) + return unique +``` + +### 4.3 Enhanced SearchAgent (`src/agents/search_agent.py`) + +Update SearchAgent to use embeddings. **Note**: All embedding calls are `await`ed: + +```python +class SearchAgent(BaseAgent): + def __init__( + self, + search_handler: SearchHandlerProtocol, + evidence_store: dict, + embedding_service: EmbeddingService | None = None, # NEW + ): + # ... existing init ... + self._embeddings = embedding_service + + async def run(self, messages, *, thread=None, **kwargs) -> AgentRunResponse: + # ... extract query ... + + # Execute keyword search + result = await self._handler.execute(query, max_results_per_tool=10) + + # Semantic deduplication (NEW) - ALL CALLS ARE AWAITED + if self._embeddings: + # Deduplicate by semantic similarity (async-safe) + unique_evidence = await self._embeddings.deduplicate(result.evidence) + + # Also search for semantically related evidence (async-safe) + related = await self._embeddings.search_similar(query, n_results=5) + + # Merge related evidence not already in results + existing_urls = {e.citation.url for e in unique_evidence} + for item in related: + if item["id"] not in existing_urls: + # Reconstruct Evidence from stored data + # ... merge logic ... + + # ... rest of method ... +``` + +### 4.4 Semantic Expansion in Orchestrator + +The MagenticOrchestrator can use embeddings to expand queries: + +```python +# In task instruction +task = f"""Research drug repurposing opportunities for: {query} + +The system has semantic search enabled. When evidence is found: +1. Related concepts will be automatically surfaced +2. Duplicates are removed by meaning, not just URL +3. Use the surfaced related concepts to refine searches +""" +``` + +### 4.5 HuggingFace Spaces Deployment + +> **⚠️ Important for HF Spaces** +> +> `sentence-transformers` downloads models (~500MB) to `~/.cache` on first use. +> HuggingFace Spaces have **ephemeral storage** - the cache is wiped on restart. +> This causes slow cold starts and bandwidth usage. + +**Solution**: Pre-download the model in your Dockerfile: + +```dockerfile +# In Dockerfile +FROM python:3.11-slim + +# Set cache directory +ENV HF_HOME=/app/.cache +ENV TRANSFORMERS_CACHE=/app/.cache + +# Pre-download the embedding model during build +RUN pip install sentence-transformers && \ + python -c "from sentence_transformers import SentenceTransformer; SentenceTransformer('all-MiniLM-L6-v2')" + +# ... rest of Dockerfile +``` + +**Alternative**: Use environment variable to specify persistent path: + +```yaml +# In HF Spaces settings or app.yaml +env: + - name: HF_HOME + value: /data/.cache # Persistent volume +``` + +--- + +## 5. Directory Structure After Phase 6 + +``` +src/ +├── services/ # NEW +│ ├── __init__.py +│ └── embeddings.py # EmbeddingService +├── agents/ +│ ├── search_agent.py # Updated with embeddings +│ └── judge_agent.py +└── ... +``` + +--- + +## 6. Tests + +### 6.1 Unit Tests (`tests/unit/services/test_embeddings.py`) + +> **Note**: All tests are async since the EmbeddingService methods are async. + +```python +"""Unit tests for EmbeddingService.""" +import pytest +from src.services.embeddings import EmbeddingService + + +class TestEmbeddingService: + @pytest.mark.asyncio + async def test_embed_returns_vector(self): + """Embedding should return a float vector.""" + service = EmbeddingService() + embedding = await service.embed("metformin diabetes") + assert isinstance(embedding, list) + assert len(embedding) > 0 + assert all(isinstance(x, float) for x in embedding) + + @pytest.mark.asyncio + async def test_similar_texts_have_close_embeddings(self): + """Semantically similar texts should have similar embeddings.""" + service = EmbeddingService() + e1 = await service.embed("metformin treats diabetes") + e2 = await service.embed("metformin is used for diabetes treatment") + e3 = await service.embed("the weather is sunny today") + + # Cosine similarity helper + from numpy import dot + from numpy.linalg import norm + cosine = lambda a, b: dot(a, b) / (norm(a) * norm(b)) + + # Similar texts should be closer + assert cosine(e1, e2) > cosine(e1, e3) + + @pytest.mark.asyncio + async def test_batch_embed_efficient(self): + """Batch embedding should be more efficient than individual calls.""" + service = EmbeddingService() + texts = ["text one", "text two", "text three"] + + # Batch embed + batch_results = await service.embed_batch(texts) + assert len(batch_results) == 3 + assert all(isinstance(e, list) for e in batch_results) + + @pytest.mark.asyncio + async def test_add_and_search(self): + """Should be able to add evidence and search for similar.""" + service = EmbeddingService() + await service.add_evidence( + evidence_id="test1", + content="Metformin activates AMPK pathway", + metadata={"source": "pubmed"} + ) + + results = await service.search_similar("AMPK activation drugs", n_results=1) + assert len(results) == 1 + assert "AMPK" in results[0]["content"] + + @pytest.mark.asyncio + async def test_search_similar_empty_collection(self): + """Search on empty collection should return empty list, not error.""" + service = EmbeddingService() + results = await service.search_similar("anything", n_results=5) + assert results == [] +``` + +--- + +## 7. Definition of Done + +Phase 6 is **COMPLETE** when: + +1. `EmbeddingService` implemented with ChromaDB +2. SearchAgent uses embeddings for deduplication +3. Semantic search surfaces related evidence +4. All unit tests pass +5. Integration test shows improved recall (finds related papers) + +--- + +## 8. Value Delivered + +| Before (Phase 5) | After (Phase 6) | +|------------------|-----------------| +| Keyword-only search | Semantic + keyword search | +| URL-based deduplication | Meaning-based deduplication | +| Miss related papers | Surface related concepts | +| Exact match required | Fuzzy semantic matching | + +**Real example improvement:** +- Query: "metformin alzheimer" +- Before: Only papers mentioning both words +- After: Also finds "AMPK neuroprotection", "biguanide cognitive", etc. diff --git a/docs/implementation/07_phase_hypothesis.md b/docs/implementation/07_phase_hypothesis.md new file mode 100644 index 0000000000000000000000000000000000000000..ee587cab2be7faee8b954906ee88cc35234fa067 --- /dev/null +++ b/docs/implementation/07_phase_hypothesis.md @@ -0,0 +1,630 @@ +# Phase 7 Implementation Spec: Hypothesis Agent + +**Goal**: Add an agent that generates scientific hypotheses to guide targeted searches. +**Philosophy**: "Don't just find evidence—understand the mechanisms." +**Prerequisite**: Phase 6 complete (Embeddings working) + +--- + +## 1. Why Hypothesis Agent? + +Current limitation: **Search is reactive, not hypothesis-driven.** + +Current flow: +1. User asks about "metformin alzheimer" +2. Search finds papers +3. Judge says "need more evidence" +4. Search again with slightly different keywords + +With Hypothesis Agent: +1. User asks about "metformin alzheimer" +2. Search finds initial papers +3. **Hypothesis Agent analyzes**: "Evidence suggests metformin → AMPK activation → autophagy → amyloid clearance" +4. Search can now target: "metformin AMPK", "autophagy neurodegeneration", "amyloid clearance drugs" + +**Key insight**: Scientific research is hypothesis-driven. The agent should think like a researcher. + +--- + +## 2. Architecture + +### Current (Phase 6) +``` +User Query → Magentic Manager + ├── SearchAgent → Evidence + └── JudgeAgent → Sufficient? → Synthesize/Continue +``` + +### Phase 7 +``` +User Query → Magentic Manager + ├── SearchAgent → Evidence + ├── HypothesisAgent → Mechanistic Hypotheses ← NEW + └── JudgeAgent → Sufficient? → Synthesize/Continue + ↑ + Uses hypotheses to guide next search +``` + +### Shared Context Enhancement +```python +evidence_store = { + "current": [], + "embeddings": {}, + "vector_index": None, + "hypotheses": [], # NEW: Generated hypotheses + "tested_hypotheses": [], # NEW: Hypotheses with supporting/contradicting evidence +} +``` + +--- + +## 3. Hypothesis Model + +### 3.1 Data Model (`src/utils/models.py`) + +```python +class MechanismHypothesis(BaseModel): + """A scientific hypothesis about drug mechanism.""" + + drug: str = Field(description="The drug being studied") + target: str = Field(description="Molecular target (e.g., AMPK, mTOR)") + pathway: str = Field(description="Biological pathway affected") + effect: str = Field(description="Downstream effect on disease") + confidence: float = Field(ge=0, le=1, description="Confidence in hypothesis") + supporting_evidence: list[str] = Field( + default_factory=list, + description="PMIDs or URLs supporting this hypothesis" + ) + contradicting_evidence: list[str] = Field( + default_factory=list, + description="PMIDs or URLs contradicting this hypothesis" + ) + search_suggestions: list[str] = Field( + default_factory=list, + description="Suggested searches to test this hypothesis" + ) + + def to_search_queries(self) -> list[str]: + """Generate search queries to test this hypothesis.""" + return [ + f"{self.drug} {self.target}", + f"{self.target} {self.pathway}", + f"{self.pathway} {self.effect}", + *self.search_suggestions + ] +``` + +### 3.2 Hypothesis Assessment + +```python +class HypothesisAssessment(BaseModel): + """Assessment of evidence against hypotheses.""" + + hypotheses: list[MechanismHypothesis] + primary_hypothesis: MechanismHypothesis | None = Field( + description="Most promising hypothesis based on current evidence" + ) + knowledge_gaps: list[str] = Field( + description="What we don't know yet" + ) + recommended_searches: list[str] = Field( + description="Searches to fill knowledge gaps" + ) +``` + +--- + +## 4. Implementation + +### 4.0 Text Utilities (`src/utils/text_utils.py`) + +> **Why These Utilities?** +> +> The original spec used arbitrary truncation (`evidence[:10]` and `content[:300]`). +> This loses important information randomly. These utilities provide: +> 1. **Sentence-aware truncation** - cuts at sentence boundaries, not mid-word +> 2. **Diverse evidence selection** - uses embeddings to select varied evidence (MMR) + +```python +"""Text processing utilities for evidence handling.""" +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from src.services.embeddings import EmbeddingService + from src.utils.models import Evidence + + +def truncate_at_sentence(text: str, max_chars: int = 300) -> str: + """Truncate text at sentence boundary, preserving meaning. + + Args: + text: The text to truncate + max_chars: Maximum characters (default 300) + + Returns: + Text truncated at last complete sentence within limit + """ + if len(text) <= max_chars: + return text + + # Find truncation point + truncated = text[:max_chars] + + # Look for sentence endings: . ! ? followed by space or end + for sep in ['. ', '! ', '? ', '.\n', '!\n', '?\n']: + last_sep = truncated.rfind(sep) + if last_sep > max_chars // 2: # Don't truncate too aggressively + return text[:last_sep + 1].strip() + + # Fallback: find last period + last_period = truncated.rfind('.') + if last_period > max_chars // 2: + return text[:last_period + 1].strip() + + # Last resort: truncate at word boundary + last_space = truncated.rfind(' ') + if last_space > 0: + return text[:last_space].strip() + "..." + + return truncated + "..." + + +async def select_diverse_evidence( + evidence: list["Evidence"], + n: int, + query: str, + embeddings: "EmbeddingService | None" = None +) -> list["Evidence"]: + """Select n most diverse and relevant evidence items. + + Uses Maximal Marginal Relevance (MMR) when embeddings available, + falls back to relevance_score sorting otherwise. + + Args: + evidence: All available evidence + n: Number of items to select + query: Original query for relevance scoring + embeddings: Optional EmbeddingService for semantic diversity + + Returns: + Selected evidence items, diverse and relevant + """ + if not evidence: + return [] + + if n >= len(evidence): + return evidence + + # Fallback: sort by relevance score if no embeddings + if embeddings is None: + return sorted( + evidence, + key=lambda e: e.relevance_score, + reverse=True + )[:n] + + # MMR: Maximal Marginal Relevance for diverse selection + # Score = λ * relevance - (1-λ) * max_similarity_to_selected + lambda_param = 0.7 # Balance relevance vs diversity + + # Get query embedding + query_emb = await embeddings.embed(query) + + # Get all evidence embeddings + evidence_embs = await embeddings.embed_batch([e.content for e in evidence]) + + # Compute relevance scores (cosine similarity to query) + from numpy import dot + from numpy.linalg import norm + cosine = lambda a, b: float(dot(a, b) / (norm(a) * norm(b))) + + relevance_scores = [cosine(query_emb, emb) for emb in evidence_embs] + + # Greedy MMR selection + selected_indices: list[int] = [] + remaining = set(range(len(evidence))) + + for _ in range(n): + best_score = float('-inf') + best_idx = -1 + + for idx in remaining: + # Relevance component + relevance = relevance_scores[idx] + + # Diversity component: max similarity to already selected + if selected_indices: + max_sim = max( + cosine(evidence_embs[idx], evidence_embs[sel]) + for sel in selected_indices + ) + else: + max_sim = 0 + + # MMR score + mmr_score = lambda_param * relevance - (1 - lambda_param) * max_sim + + if mmr_score > best_score: + best_score = mmr_score + best_idx = idx + + if best_idx >= 0: + selected_indices.append(best_idx) + remaining.remove(best_idx) + + return [evidence[i] for i in selected_indices] +``` + +### 4.1 Hypothesis Prompts (`src/prompts/hypothesis.py`) + +```python +"""Prompts for Hypothesis Agent.""" +from src.utils.text_utils import truncate_at_sentence, select_diverse_evidence + +SYSTEM_PROMPT = """You are a biomedical research scientist specializing in drug repurposing. + +Your role is to generate mechanistic hypotheses based on evidence. + +A good hypothesis: +1. Proposes a MECHANISM: Drug → Target → Pathway → Effect +2. Is TESTABLE: Can be supported or refuted by literature search +3. Is SPECIFIC: Names actual molecular targets and pathways +4. Generates SEARCH QUERIES: Helps find more evidence + +Example hypothesis format: +- Drug: Metformin +- Target: AMPK (AMP-activated protein kinase) +- Pathway: mTOR inhibition → autophagy activation +- Effect: Enhanced clearance of amyloid-beta in Alzheimer's +- Confidence: 0.7 +- Search suggestions: ["metformin AMPK brain", "autophagy amyloid clearance"] + +Be specific. Use actual gene/protein names when possible.""" + + +async def format_hypothesis_prompt( + query: str, + evidence: list, + embeddings=None +) -> str: + """Format prompt for hypothesis generation. + + Uses smart evidence selection instead of arbitrary truncation. + + Args: + query: The research query + evidence: All collected evidence + embeddings: Optional EmbeddingService for diverse selection + """ + # Select diverse, relevant evidence (not arbitrary first 10) + selected = await select_diverse_evidence( + evidence, n=10, query=query, embeddings=embeddings + ) + + # Format with sentence-aware truncation + evidence_text = "\n".join([ + f"- **{e.citation.title}** ({e.citation.source}): {truncate_at_sentence(e.content, 300)}" + for e in selected + ]) + + return f"""Based on the following evidence about "{query}", generate mechanistic hypotheses. + +## Evidence ({len(selected)} papers selected for diversity) +{evidence_text} + +## Task +1. Identify potential drug targets mentioned in the evidence +2. Propose mechanism hypotheses (Drug → Target → Pathway → Effect) +3. Rate confidence based on evidence strength +4. Suggest searches to test each hypothesis + +Generate 2-4 hypotheses, prioritized by confidence.""" +``` + +### 4.2 Hypothesis Agent (`src/agents/hypothesis_agent.py`) + +```python +"""Hypothesis agent for mechanistic reasoning.""" +from collections.abc import AsyncIterable +from typing import TYPE_CHECKING, Any + +from agent_framework import ( + AgentRunResponse, + AgentRunResponseUpdate, + AgentThread, + BaseAgent, + ChatMessage, + Role, +) +from pydantic_ai import Agent + +from src.prompts.hypothesis import SYSTEM_PROMPT, format_hypothesis_prompt +from src.utils.config import settings +from src.utils.models import Evidence, HypothesisAssessment + +if TYPE_CHECKING: + from src.services.embeddings import EmbeddingService + + +class HypothesisAgent(BaseAgent): + """Generates mechanistic hypotheses based on evidence.""" + + def __init__( + self, + evidence_store: dict[str, list[Evidence]], + embedding_service: "EmbeddingService | None" = None, # NEW: for diverse selection + ) -> None: + super().__init__( + name="HypothesisAgent", + description="Generates scientific hypotheses about drug mechanisms to guide research", + ) + self._evidence_store = evidence_store + self._embeddings = embedding_service # Used for MMR evidence selection + self._agent = Agent( + model=settings.llm_provider, # Uses configured LLM + output_type=HypothesisAssessment, + system_prompt=SYSTEM_PROMPT, + ) + + async def run( + self, + messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None, + *, + thread: AgentThread | None = None, + **kwargs: Any, + ) -> AgentRunResponse: + """Generate hypotheses based on current evidence.""" + # Extract query + query = self._extract_query(messages) + + # Get current evidence + evidence = self._evidence_store.get("current", []) + + if not evidence: + return AgentRunResponse( + messages=[ChatMessage( + role=Role.ASSISTANT, + text="No evidence available yet. Search for evidence first." + )], + response_id="hypothesis-no-evidence", + ) + + # Generate hypotheses with diverse evidence selection + # NOTE: format_hypothesis_prompt is now async + prompt = await format_hypothesis_prompt( + query, evidence, embeddings=self._embeddings + ) + result = await self._agent.run(prompt) + assessment = result.output + + # Store hypotheses in shared context + existing = self._evidence_store.get("hypotheses", []) + self._evidence_store["hypotheses"] = existing + assessment.hypotheses + + # Format response + response_text = self._format_response(assessment) + + return AgentRunResponse( + messages=[ChatMessage(role=Role.ASSISTANT, text=response_text)], + response_id=f"hypothesis-{len(assessment.hypotheses)}", + additional_properties={"assessment": assessment.model_dump()}, + ) + + def _format_response(self, assessment: HypothesisAssessment) -> str: + """Format hypothesis assessment as markdown.""" + lines = ["## Generated Hypotheses\n"] + + for i, h in enumerate(assessment.hypotheses, 1): + lines.append(f"### Hypothesis {i} (Confidence: {h.confidence:.0%})") + lines.append(f"**Mechanism**: {h.drug} → {h.target} → {h.pathway} → {h.effect}") + lines.append(f"**Suggested searches**: {', '.join(h.search_suggestions)}\n") + + if assessment.primary_hypothesis: + lines.append(f"### Primary Hypothesis") + h = assessment.primary_hypothesis + lines.append(f"{h.drug} → {h.target} → {h.pathway} → {h.effect}\n") + + if assessment.knowledge_gaps: + lines.append("### Knowledge Gaps") + for gap in assessment.knowledge_gaps: + lines.append(f"- {gap}") + + if assessment.recommended_searches: + lines.append("\n### Recommended Next Searches") + for search in assessment.recommended_searches: + lines.append(f"- `{search}`") + + return "\n".join(lines) + + def _extract_query(self, messages) -> str: + """Extract query from messages.""" + if isinstance(messages, str): + return messages + elif isinstance(messages, ChatMessage): + return messages.text or "" + elif isinstance(messages, list): + for msg in reversed(messages): + if isinstance(msg, ChatMessage) and msg.role == Role.USER: + return msg.text or "" + elif isinstance(msg, str): + return msg + return "" + + async def run_stream( + self, + messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None, + *, + thread: AgentThread | None = None, + **kwargs: Any, + ) -> AsyncIterable[AgentRunResponseUpdate]: + """Streaming wrapper.""" + result = await self.run(messages, thread=thread, **kwargs) + yield AgentRunResponseUpdate( + messages=result.messages, + response_id=result.response_id + ) +``` + +### 4.3 Update MagenticOrchestrator + +Add HypothesisAgent to the workflow: + +```python +# In MagenticOrchestrator.__init__ +self._hypothesis_agent = HypothesisAgent(self._evidence_store) + +# In workflow building +workflow = ( + MagenticBuilder() + .participants( + searcher=search_agent, + hypothesizer=self._hypothesis_agent, # NEW + judge=judge_agent, + ) + .with_standard_manager(...) + .build() +) + +# Update task instruction +task = f"""Research drug repurposing opportunities for: {query} + +Workflow: +1. SearchAgent: Find initial evidence from PubMed and web +2. HypothesisAgent: Generate mechanistic hypotheses (Drug → Target → Pathway → Effect) +3. SearchAgent: Use hypothesis-suggested queries for targeted search +4. JudgeAgent: Evaluate if evidence supports hypotheses +5. Repeat until confident or max rounds + +Focus on: +- Identifying specific molecular targets +- Understanding mechanism of action +- Finding supporting/contradicting evidence for hypotheses +""" +``` + +--- + +## 5. Directory Structure After Phase 7 + +``` +src/ +├── agents/ +│ ├── search_agent.py +│ ├── judge_agent.py +│ └── hypothesis_agent.py # NEW +├── prompts/ +│ ├── judge.py +│ └── hypothesis.py # NEW +├── services/ +│ └── embeddings.py +└── utils/ + └── models.py # Updated with hypothesis models +``` + +--- + +## 6. Tests + +### 6.1 Unit Tests (`tests/unit/agents/test_hypothesis_agent.py`) + +```python +"""Unit tests for HypothesisAgent.""" +import pytest +from unittest.mock import AsyncMock, MagicMock, patch + +from src.agents.hypothesis_agent import HypothesisAgent +from src.utils.models import Citation, Evidence, HypothesisAssessment, MechanismHypothesis + + +@pytest.fixture +def sample_evidence(): + return [ + Evidence( + content="Metformin activates AMPK, which inhibits mTOR signaling...", + citation=Citation( + source="pubmed", + title="Metformin and AMPK", + url="https://pubmed.ncbi.nlm.nih.gov/12345/", + date="2023" + ) + ) + ] + + +@pytest.fixture +def mock_assessment(): + return HypothesisAssessment( + hypotheses=[ + MechanismHypothesis( + drug="Metformin", + target="AMPK", + pathway="mTOR inhibition", + effect="Reduced cancer cell proliferation", + confidence=0.75, + search_suggestions=["metformin AMPK cancer", "mTOR cancer therapy"] + ) + ], + primary_hypothesis=None, + knowledge_gaps=["Clinical trial data needed"], + recommended_searches=["metformin clinical trial cancer"] + ) + + +@pytest.mark.asyncio +async def test_hypothesis_agent_generates_hypotheses(sample_evidence, mock_assessment): + """HypothesisAgent should generate mechanistic hypotheses.""" + store = {"current": sample_evidence, "hypotheses": []} + + with patch("src.agents.hypothesis_agent.Agent") as MockAgent: + mock_result = MagicMock() + mock_result.output = mock_assessment + MockAgent.return_value.run = AsyncMock(return_value=mock_result) + + agent = HypothesisAgent(store) + response = await agent.run("metformin cancer") + + assert "AMPK" in response.messages[0].text + assert len(store["hypotheses"]) == 1 + + +@pytest.mark.asyncio +async def test_hypothesis_agent_no_evidence(): + """HypothesisAgent should handle empty evidence gracefully.""" + store = {"current": [], "hypotheses": []} + agent = HypothesisAgent(store) + + response = await agent.run("test query") + + assert "No evidence" in response.messages[0].text +``` + +--- + +## 7. Definition of Done + +Phase 7 is **COMPLETE** when: + +1. `MechanismHypothesis` and `HypothesisAssessment` models implemented +2. `HypothesisAgent` generates hypotheses from evidence +3. Hypotheses stored in shared context +4. Search queries generated from hypotheses +5. Magentic workflow includes HypothesisAgent +6. All unit tests pass + +--- + +## 8. Value Delivered + +| Before (Phase 6) | After (Phase 7) | +|------------------|-----------------| +| Reactive search | Hypothesis-driven search | +| Generic queries | Mechanism-targeted queries | +| No scientific reasoning | Drug → Target → Pathway → Effect | +| Judge says "need more" | Hypothesis says "search for X to test Y" | + +**Real example improvement:** +- Query: "metformin alzheimer" +- Before: "metformin alzheimer mechanism", "metformin brain" +- After: "metformin AMPK activation", "AMPK autophagy neurodegeneration", "autophagy amyloid clearance" + +The search becomes **scientifically targeted** rather than keyword variations. diff --git a/docs/implementation/08_phase_report.md b/docs/implementation/08_phase_report.md new file mode 100644 index 0000000000000000000000000000000000000000..3618734ce9102b7aa40d8332c0049b88e3bb6653 --- /dev/null +++ b/docs/implementation/08_phase_report.md @@ -0,0 +1,854 @@ +# Phase 8 Implementation Spec: Report Agent + +**Goal**: Generate structured scientific reports with proper citations and methodology. +**Philosophy**: "Research isn't complete until it's communicated clearly." +**Prerequisite**: Phase 7 complete (Hypothesis Agent working) + +--- + +## 1. Why Report Agent? + +Current limitation: **Synthesis is basic markdown, not a scientific report.** + +Current output: +```markdown +## Drug Repurposing Analysis +### Drug Candidates +- Metformin +### Key Findings +- Some findings +### Citations +1. [Paper 1](url) +``` + +With Report Agent: +```markdown +## Executive Summary +One-paragraph summary for busy readers... + +## Research Question +Clear statement of what was investigated... + +## Methodology +- Sources searched: PubMed, DuckDuckGo +- Date range: ... +- Inclusion criteria: ... + +## Hypotheses Tested +1. Metformin → AMPK → neuroprotection (Supported: 7 papers, Contradicted: 2) + +## Findings +### Mechanistic Evidence +... +### Clinical Evidence +... + +## Limitations +- Only English language papers +- Abstract-level analysis only + +## Conclusion +... + +## References +Properly formatted citations... +``` + +--- + +## 2. Architecture + +### Phase 8 Addition +```text +Evidence + Hypotheses + Assessment + ↓ + Report Agent + ↓ + Structured Scientific Report +``` + +### Report Generation Flow +```text +1. JudgeAgent says "synthesize" +2. Magentic Manager selects ReportAgent +3. ReportAgent gathers: + - All evidence from shared context + - All hypotheses (supported/contradicted) + - Assessment scores +4. ReportAgent generates structured report +5. Final output to user +``` + +--- + +## 3. Report Model + +### 3.1 Data Model (`src/utils/models.py`) + +```python +class ReportSection(BaseModel): + """A section of the research report.""" + title: str + content: str + citations: list[str] = Field(default_factory=list) + + +class ResearchReport(BaseModel): + """Structured scientific report.""" + + title: str = Field(description="Report title") + executive_summary: str = Field( + description="One-paragraph summary for quick reading", + min_length=100, + max_length=500 + ) + research_question: str = Field(description="Clear statement of what was investigated") + + methodology: ReportSection = Field(description="How the research was conducted") + hypotheses_tested: list[dict] = Field( + description="Hypotheses with supporting/contradicting evidence counts" + ) + + mechanistic_findings: ReportSection = Field( + description="Findings about drug mechanisms" + ) + clinical_findings: ReportSection = Field( + description="Findings from clinical/preclinical studies" + ) + + drug_candidates: list[str] = Field(description="Identified drug candidates") + limitations: list[str] = Field(description="Study limitations") + conclusion: str = Field(description="Overall conclusion") + + references: list[dict] = Field( + description="Formatted references with title, authors, source, URL" + ) + + # Metadata + sources_searched: list[str] = Field(default_factory=list) + total_papers_reviewed: int = 0 + search_iterations: int = 0 + confidence_score: float = Field(ge=0, le=1) + + def to_markdown(self) -> str: + """Render report as markdown.""" + sections = [ + f"# {self.title}\n", + f"## Executive Summary\n{self.executive_summary}\n", + f"## Research Question\n{self.research_question}\n", + f"## Methodology\n{self.methodology.content}\n", + ] + + # Hypotheses + sections.append("## Hypotheses Tested\n") + for h in self.hypotheses_tested: + status = "✅ Supported" if h.get("supported", 0) > h.get("contradicted", 0) else "⚠️ Mixed" + sections.append( + f"- **{h['mechanism']}** ({status}): " + f"{h.get('supported', 0)} supporting, {h.get('contradicted', 0)} contradicting\n" + ) + + # Findings + sections.append(f"## Mechanistic Findings\n{self.mechanistic_findings.content}\n") + sections.append(f"## Clinical Findings\n{self.clinical_findings.content}\n") + + # Drug candidates + sections.append("## Drug Candidates\n") + for drug in self.drug_candidates: + sections.append(f"- **{drug}**\n") + + # Limitations + sections.append("## Limitations\n") + for lim in self.limitations: + sections.append(f"- {lim}\n") + + # Conclusion + sections.append(f"## Conclusion\n{self.conclusion}\n") + + # References + sections.append("## References\n") + for i, ref in enumerate(self.references, 1): + sections.append( + f"{i}. {ref.get('authors', 'Unknown')}. " + f"*{ref.get('title', 'Untitled')}*. " + f"{ref.get('source', '')} ({ref.get('date', '')}). " + f"[Link]({ref.get('url', '#')})\n" + ) + + # Metadata footer + sections.append("\n---\n") + sections.append( + f"*Report generated from {self.total_papers_reviewed} papers " + f"across {self.search_iterations} search iterations. " + f"Confidence: {self.confidence_score:.0%}*" + ) + + return "\n".join(sections) +``` + +--- + +## 4. Implementation + +### 4.0 Citation Validation (`src/utils/citation_validator.py`) + +> **🚨 CRITICAL: Why Citation Validation?** +> +> LLMs frequently **hallucinate** citations - inventing paper titles, authors, and URLs +> that don't exist. For a medical research tool, fake citations are **dangerous**. +> +> This validation layer ensures every reference in the report actually exists +> in the collected evidence. + +```python +"""Citation validation to prevent LLM hallucination. + +CRITICAL: Medical research requires accurate citations. +This module validates that all references exist in collected evidence. +""" +import logging +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from src.utils.models import Evidence, ResearchReport + +logger = logging.getLogger(__name__) + + +def validate_references( + report: "ResearchReport", + evidence: list["Evidence"] +) -> "ResearchReport": + """Ensure all references actually exist in collected evidence. + + CRITICAL: Prevents LLM hallucination of citations. + + Args: + report: The generated research report + evidence: All evidence collected during research + + Returns: + Report with only valid references (hallucinated ones removed) + """ + # Build set of valid URLs from evidence + valid_urls = {e.citation.url for e in evidence} + valid_titles = {e.citation.title.lower() for e in evidence} + + validated_refs = [] + removed_count = 0 + + for ref in report.references: + ref_url = ref.get("url", "") + ref_title = ref.get("title", "").lower() + + # Check if URL matches collected evidence + if ref_url in valid_urls: + validated_refs.append(ref) + # Fallback: check title match (URLs might differ slightly) + elif ref_title and any(ref_title in t or t in ref_title for t in valid_titles): + validated_refs.append(ref) + else: + removed_count += 1 + logger.warning( + f"Removed hallucinated reference: '{ref.get('title', 'Unknown')}' " + f"(URL: {ref_url[:50]}...)" + ) + + if removed_count > 0: + logger.info( + f"Citation validation removed {removed_count} hallucinated references. " + f"{len(validated_refs)} valid references remain." + ) + + # Update report with validated references + report.references = validated_refs + return report + + +def build_reference_from_evidence(evidence: "Evidence") -> dict: + """Build a properly formatted reference from evidence. + + Use this to ensure references match the original evidence exactly. + """ + return { + "title": evidence.citation.title, + "authors": evidence.citation.authors or ["Unknown"], + "source": evidence.citation.source, + "date": evidence.citation.date or "n.d.", + "url": evidence.citation.url, + } +``` + +### 4.1 Report Prompts (`src/prompts/report.py`) + +```python +"""Prompts for Report Agent.""" +from src.utils.text_utils import truncate_at_sentence, select_diverse_evidence + +SYSTEM_PROMPT = """You are a scientific writer specializing in drug repurposing research reports. + +Your role is to synthesize evidence and hypotheses into a clear, structured report. + +A good report: +1. Has a clear EXECUTIVE SUMMARY (one paragraph, key takeaways) +2. States the RESEARCH QUESTION clearly +3. Describes METHODOLOGY (what was searched, how) +4. Evaluates HYPOTHESES with evidence counts +5. Separates MECHANISTIC and CLINICAL findings +6. Lists specific DRUG CANDIDATES +7. Acknowledges LIMITATIONS honestly +8. Provides a balanced CONCLUSION +9. Includes properly formatted REFERENCES + +Write in scientific but accessible language. Be specific about evidence strength. + +───────────────────────────────────────────────────────────────────────────── +🚨 CRITICAL CITATION REQUIREMENTS 🚨 +───────────────────────────────────────────────────────────────────────────── + +You MUST follow these rules for the References section: + +1. You may ONLY cite papers that appear in the Evidence section above +2. Every reference URL must EXACTLY match a provided evidence URL +3. Do NOT invent, fabricate, or hallucinate any references +4. Do NOT modify paper titles, authors, dates, or URLs +5. If unsure about a citation, OMIT it rather than guess +6. Copy URLs exactly as provided - do not create similar-looking URLs + +VIOLATION OF THESE RULES PRODUCES DANGEROUS MISINFORMATION. +─────────────────────────────────────────────────────────────────────────────""" + + +async def format_report_prompt( + query: str, + evidence: list, + hypotheses: list, + assessment: dict, + metadata: dict, + embeddings=None +) -> str: + """Format prompt for report generation. + + Includes full evidence details for accurate citation. + """ + # Select diverse evidence (not arbitrary truncation) + selected = await select_diverse_evidence( + evidence, n=20, query=query, embeddings=embeddings + ) + + # Include FULL citation details for each evidence item + # This helps the LLM create accurate references + evidence_summary = "\n".join([ + f"- **Title**: {e.citation.title}\n" + f" **URL**: {e.citation.url}\n" + f" **Authors**: {', '.join(e.citation.authors or ['Unknown'])}\n" + f" **Date**: {e.citation.date or 'n.d.'}\n" + f" **Source**: {e.citation.source}\n" + f" **Content**: {truncate_at_sentence(e.content, 200)}\n" + for e in selected + ]) + + hypotheses_summary = "\n".join([ + f"- {h.drug} → {h.target} → {h.pathway} → {h.effect} (Confidence: {h.confidence:.0%})" + for h in hypotheses + ]) if hypotheses else "No hypotheses generated yet." + + return f"""Generate a structured research report for the following query. + +## Original Query +{query} + +## Evidence Collected ({len(selected)} papers, selected for diversity) + +{evidence_summary} + +## Hypotheses Generated +{hypotheses_summary} + +## Assessment Scores +- Mechanism Score: {assessment.get('mechanism_score', 'N/A')}/10 +- Clinical Evidence Score: {assessment.get('clinical_score', 'N/A')}/10 +- Overall Confidence: {assessment.get('confidence', 0):.0%} + +## Metadata +- Sources Searched: {', '.join(metadata.get('sources', []))} +- Search Iterations: {metadata.get('iterations', 0)} + +Generate a complete ResearchReport with all sections filled in. + +REMINDER: Only cite papers from the Evidence section above. Copy URLs exactly.""" +``` + +### 4.2 Report Agent (`src/agents/report_agent.py`) + +```python +"""Report agent for generating structured research reports.""" +from collections.abc import AsyncIterable +from typing import TYPE_CHECKING, Any + +from agent_framework import ( + AgentRunResponse, + AgentRunResponseUpdate, + AgentThread, + BaseAgent, + ChatMessage, + Role, +) +from pydantic_ai import Agent + +from src.prompts.report import SYSTEM_PROMPT, format_report_prompt +from src.utils.citation_validator import validate_references # CRITICAL +from src.utils.config import settings +from src.utils.models import Evidence, MechanismHypothesis, ResearchReport + +if TYPE_CHECKING: + from src.services.embeddings import EmbeddingService + + +class ReportAgent(BaseAgent): + """Generates structured scientific reports from evidence and hypotheses.""" + + def __init__( + self, + evidence_store: dict[str, list[Evidence]], + embedding_service: "EmbeddingService | None" = None, # For diverse selection + ) -> None: + super().__init__( + name="ReportAgent", + description="Generates structured scientific research reports with citations", + ) + self._evidence_store = evidence_store + self._embeddings = embedding_service + self._agent = Agent( + model=settings.llm_provider, + output_type=ResearchReport, + system_prompt=SYSTEM_PROMPT, + ) + + async def run( + self, + messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None, + *, + thread: AgentThread | None = None, + **kwargs: Any, + ) -> AgentRunResponse: + """Generate research report.""" + query = self._extract_query(messages) + + # Gather all context + evidence = self._evidence_store.get("current", []) + hypotheses = self._evidence_store.get("hypotheses", []) + assessment = self._evidence_store.get("last_assessment", {}) + + if not evidence: + return AgentRunResponse( + messages=[ChatMessage( + role=Role.ASSISTANT, + text="Cannot generate report: No evidence collected." + )], + response_id="report-no-evidence", + ) + + # Build metadata + metadata = { + "sources": list(set(e.citation.source for e in evidence)), + "iterations": self._evidence_store.get("iteration_count", 0), + } + + # Generate report (format_report_prompt is now async) + prompt = await format_report_prompt( + query=query, + evidence=evidence, + hypotheses=hypotheses, + assessment=assessment, + metadata=metadata, + embeddings=self._embeddings, + ) + + result = await self._agent.run(prompt) + report = result.output + + # ═══════════════════════════════════════════════════════════════════ + # 🚨 CRITICAL: Validate citations to prevent hallucination + # ═══════════════════════════════════════════════════════════════════ + report = validate_references(report, evidence) + + # Store validated report + self._evidence_store["final_report"] = report + + # Return markdown version + return AgentRunResponse( + messages=[ChatMessage(role=Role.ASSISTANT, text=report.to_markdown())], + response_id="report-complete", + additional_properties={"report": report.model_dump()}, + ) + + def _extract_query(self, messages) -> str: + """Extract query from messages.""" + if isinstance(messages, str): + return messages + elif isinstance(messages, ChatMessage): + return messages.text or "" + elif isinstance(messages, list): + for msg in reversed(messages): + if isinstance(msg, ChatMessage) and msg.role == Role.USER: + return msg.text or "" + elif isinstance(msg, str): + return msg + return "" + + async def run_stream( + self, + messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None, + *, + thread: AgentThread | None = None, + **kwargs: Any, + ) -> AsyncIterable[AgentRunResponseUpdate]: + """Streaming wrapper.""" + result = await self.run(messages, thread=thread, **kwargs) + yield AgentRunResponseUpdate( + messages=result.messages, + response_id=result.response_id + ) +``` + +### 4.3 Update MagenticOrchestrator + +Add ReportAgent as the final synthesis step: + +```python +# In MagenticOrchestrator.__init__ +self._report_agent = ReportAgent(self._evidence_store) + +# In workflow building +workflow = ( + MagenticBuilder() + .participants( + searcher=search_agent, + hypothesizer=hypothesis_agent, + judge=judge_agent, + reporter=self._report_agent, # NEW + ) + .with_standard_manager(...) + .build() +) + +# Update task instruction +task = f"""Research drug repurposing opportunities for: {query} + +Workflow: +1. SearchAgent: Find evidence from PubMed and web +2. HypothesisAgent: Generate mechanistic hypotheses +3. SearchAgent: Targeted search based on hypotheses +4. JudgeAgent: Evaluate evidence sufficiency +5. If sufficient → ReportAgent: Generate structured research report +6. If not sufficient → Repeat from step 1 with refined queries + +The final output should be a complete research report with: +- Executive summary +- Methodology +- Hypotheses tested +- Mechanistic and clinical findings +- Drug candidates +- Limitations +- Conclusion with references +""" +``` + +--- + +## 5. Directory Structure After Phase 8 + +``` +src/ +├── agents/ +│ ├── search_agent.py +│ ├── judge_agent.py +│ ├── hypothesis_agent.py +│ └── report_agent.py # NEW +├── prompts/ +│ ├── judge.py +│ ├── hypothesis.py +│ └── report.py # NEW +├── services/ +│ └── embeddings.py +└── utils/ + └── models.py # Updated with report models +``` + +--- + +## 6. Tests + +### 6.1 Unit Tests (`tests/unit/agents/test_report_agent.py`) + +```python +"""Unit tests for ReportAgent.""" +import pytest +from unittest.mock import AsyncMock, MagicMock, patch + +from src.agents.report_agent import ReportAgent +from src.utils.models import ( + Citation, Evidence, MechanismHypothesis, + ResearchReport, ReportSection +) + + +@pytest.fixture +def sample_evidence(): + return [ + Evidence( + content="Metformin activates AMPK...", + citation=Citation( + source="pubmed", + title="Metformin mechanisms", + url="https://pubmed.ncbi.nlm.nih.gov/12345/", + date="2023", + authors=["Smith J", "Jones A"] + ) + ) + ] + + +@pytest.fixture +def sample_hypotheses(): + return [ + MechanismHypothesis( + drug="Metformin", + target="AMPK", + pathway="mTOR inhibition", + effect="Neuroprotection", + confidence=0.8, + search_suggestions=[] + ) + ] + + +@pytest.fixture +def mock_report(): + return ResearchReport( + title="Drug Repurposing Analysis: Metformin for Alzheimer's", + executive_summary="This report analyzes metformin as a potential...", + research_question="Can metformin be repurposed for Alzheimer's disease?", + methodology=ReportSection( + title="Methodology", + content="Searched PubMed and web sources..." + ), + hypotheses_tested=[ + {"mechanism": "Metformin → AMPK → neuroprotection", "supported": 5, "contradicted": 1} + ], + mechanistic_findings=ReportSection( + title="Mechanistic Findings", + content="Evidence suggests AMPK activation..." + ), + clinical_findings=ReportSection( + title="Clinical Findings", + content="Limited clinical data available..." + ), + drug_candidates=["Metformin"], + limitations=["Abstract-level analysis only"], + conclusion="Metformin shows promise...", + references=[], + sources_searched=["pubmed", "web"], + total_papers_reviewed=10, + search_iterations=3, + confidence_score=0.75 + ) + + +@pytest.mark.asyncio +async def test_report_agent_generates_report( + sample_evidence, sample_hypotheses, mock_report +): + """ReportAgent should generate structured report.""" + store = { + "current": sample_evidence, + "hypotheses": sample_hypotheses, + "last_assessment": {"mechanism_score": 8, "clinical_score": 6} + } + + with patch("src.agents.report_agent.Agent") as MockAgent: + mock_result = MagicMock() + mock_result.output = mock_report + MockAgent.return_value.run = AsyncMock(return_value=mock_result) + + agent = ReportAgent(store) + response = await agent.run("metformin alzheimer") + + assert "Executive Summary" in response.messages[0].text + assert "Methodology" in response.messages[0].text + assert "References" in response.messages[0].text + + +@pytest.mark.asyncio +async def test_report_agent_no_evidence(): + """ReportAgent should handle empty evidence gracefully.""" + store = {"current": [], "hypotheses": []} + agent = ReportAgent(store) + + response = await agent.run("test query") + + assert "Cannot generate report" in response.messages[0].text + + +# ═══════════════════════════════════════════════════════════════════════════ +# 🚨 CRITICAL: Citation Validation Tests +# ═══════════════════════════════════════════════════════════════════════════ + +@pytest.mark.asyncio +async def test_report_agent_removes_hallucinated_citations(sample_evidence): + """ReportAgent should remove citations not in evidence.""" + from src.utils.citation_validator import validate_references + + # Create report with mix of valid and hallucinated references + report_with_hallucinations = ResearchReport( + title="Test Report", + executive_summary="This is a test report for citation validation...", + research_question="Testing citation validation", + methodology=ReportSection(title="Methodology", content="Test"), + hypotheses_tested=[], + mechanistic_findings=ReportSection(title="Mechanistic", content="Test"), + clinical_findings=ReportSection(title="Clinical", content="Test"), + drug_candidates=["TestDrug"], + limitations=["Test limitation"], + conclusion="Test conclusion", + references=[ + # Valid reference (matches sample_evidence) + { + "title": "Metformin mechanisms", + "url": "https://pubmed.ncbi.nlm.nih.gov/12345/", + "authors": ["Smith J", "Jones A"], + "date": "2023", + "source": "pubmed" + }, + # HALLUCINATED reference (URL doesn't exist in evidence) + { + "title": "Fake Paper That Doesn't Exist", + "url": "https://fake-journal.com/made-up-paper", + "authors": ["Hallucinated A"], + "date": "2024", + "source": "fake" + }, + # Another HALLUCINATED reference + { + "title": "Invented Research", + "url": "https://pubmed.ncbi.nlm.nih.gov/99999999/", + "authors": ["NotReal B"], + "date": "2025", + "source": "pubmed" + } + ], + sources_searched=["pubmed"], + total_papers_reviewed=1, + search_iterations=1, + confidence_score=0.5 + ) + + # Validate - should remove hallucinated references + validated_report = validate_references(report_with_hallucinations, sample_evidence) + + # Only the valid reference should remain + assert len(validated_report.references) == 1 + assert validated_report.references[0]["title"] == "Metformin mechanisms" + assert "Fake Paper" not in str(validated_report.references) + + +def test_citation_validator_handles_empty_references(): + """Citation validator should handle reports with no references.""" + from src.utils.citation_validator import validate_references + + report = ResearchReport( + title="Empty Refs Report", + executive_summary="This report has no references...", + research_question="Testing empty refs", + methodology=ReportSection(title="Methodology", content="Test"), + hypotheses_tested=[], + mechanistic_findings=ReportSection(title="Mechanistic", content="Test"), + clinical_findings=ReportSection(title="Clinical", content="Test"), + drug_candidates=[], + limitations=[], + conclusion="Test", + references=[], # Empty! + sources_searched=[], + total_papers_reviewed=0, + search_iterations=0, + confidence_score=0.0 + ) + + validated = validate_references(report, []) + assert validated.references == [] +``` + +--- + +## 7. Definition of Done + +Phase 8 is **COMPLETE** when: + +1. `ResearchReport` model implemented with all sections +2. `ReportAgent` generates structured reports +3. Reports include proper citations and methodology +4. Magentic workflow uses ReportAgent for final synthesis +5. Report renders as clean markdown +6. All unit tests pass + +--- + +## 8. Value Delivered + +| Before (Phase 7) | After (Phase 8) | +|------------------|-----------------| +| Basic synthesis | Structured scientific report | +| Simple bullet points | Executive summary + methodology | +| List of citations | Formatted references | +| No methodology | Clear research process | +| No limitations | Honest limitations section | + +**Sample output comparison:** + +Before: +``` +## Analysis +- Metformin might help +- Found 5 papers +[Link 1] [Link 2] +``` + +After: +``` +# Drug Repurposing Analysis: Metformin for Alzheimer's Disease + +## Executive Summary +Analysis of 15 papers suggests metformin may provide neuroprotection +through AMPK activation. Mechanistic evidence is strong (8/10), +while clinical evidence is moderate (6/10)... + +## Methodology +Systematic search of PubMed and web sources using queries... + +## Hypotheses Tested +- ✅ Metformin → AMPK → neuroprotection (7 supporting, 2 contradicting) + +## References +1. Smith J, Jones A. *Metformin mechanisms*. Nature (2023). [Link](...) +``` + +--- + +## 9. Complete Magentic Architecture (Phases 5-8) + +``` +User Query + ↓ +Gradio UI + ↓ +Magentic Manager (LLM Coordinator) + ├── SearchAgent ←→ PubMed + Web + VectorDB + ├── HypothesisAgent ←→ Mechanistic Reasoning + ├── JudgeAgent ←→ Evidence Assessment + └── ReportAgent ←→ Final Synthesis + ↓ +Structured Research Report +``` + +**This matches Mario's diagram** with the practical agents that add real value for drug repurposing research. diff --git a/docs/implementation/09_phase_source_cleanup.md b/docs/implementation/09_phase_source_cleanup.md new file mode 100644 index 0000000000000000000000000000000000000000..b4b9c818e1a51491acdfa9c25634b62aa7f62371 --- /dev/null +++ b/docs/implementation/09_phase_source_cleanup.md @@ -0,0 +1,257 @@ +# Phase 9 Implementation Spec: Remove DuckDuckGo + +**Goal**: Remove unreliable web search, focus on credible scientific sources. +**Philosophy**: "Scientific credibility over source quantity." +**Prerequisite**: Phase 8 complete (all agents working) +**Estimated Time**: 30-45 minutes + +--- + +## 1. Why Remove DuckDuckGo? + +### Current Problems + +| Issue | Impact | +|-------|--------| +| Rate-limited aggressively | Returns 0 results frequently | +| Not peer-reviewed | Random blogs, news, misinformation | +| Not citable | Cannot use in scientific reports | +| Adds noise | Dilutes quality evidence | + +### After Removal + +| Benefit | Impact | +|---------|--------| +| Cleaner codebase | -150 lines of dead code | +| No rate limit failures | 100% source reliability | +| Scientific credibility | All sources peer-reviewed/preprint | +| Simpler debugging | Fewer failure modes | + +--- + +## 2. Files to Modify/Delete + +### 2.1 DELETE: `src/tools/websearch.py` + +```bash +# File to delete entirely +src/tools/websearch.py # ~80 lines +``` + +### 2.2 MODIFY: SearchHandler Usage + +Update all files that instantiate `SearchHandler` with `WebTool()`: + +| File | Change | +|------|--------| +| `examples/search_demo/run_search.py` | Remove `WebTool()` from tools list | +| `examples/hypothesis_demo/run_hypothesis.py` | Remove `WebTool()` from tools list | +| `examples/full_stack_demo/run_full.py` | Remove `WebTool()` from tools list | +| `examples/orchestrator_demo/run_agent.py` | Remove `WebTool()` from tools list | +| `examples/orchestrator_demo/run_magentic.py` | Remove `WebTool()` from tools list | + +### 2.3 MODIFY: Type Definitions + +Update `src/utils/models.py`: + +```python +# BEFORE +sources_searched: list[Literal["pubmed", "web"]] + +# AFTER (Phase 9) +sources_searched: list[Literal["pubmed"]] + +# AFTER (Phase 10-11) +sources_searched: list[Literal["pubmed", "clinicaltrials", "biorxiv"]] +``` + +### 2.4 DELETE: Tests for WebTool + +```bash +# File to delete +tests/unit/tools/test_websearch.py +``` + +--- + +## 3. TDD Implementation + +### 3.1 Test: SearchHandler Works Without WebTool + +```python +# tests/unit/tools/test_search_handler.py + +@pytest.mark.asyncio +async def test_search_handler_pubmed_only(): + """SearchHandler should work with only PubMed tool.""" + from src.tools.pubmed import PubMedTool + from src.tools.search_handler import SearchHandler + + handler = SearchHandler(tools=[PubMedTool()], timeout=30.0) + + # Should not raise + result = await handler.execute("metformin diabetes", max_results_per_tool=3) + + assert result.sources_searched == ["pubmed"] + assert "web" not in result.sources_searched + assert len(result.errors) == 0 # No failures +``` + +### 3.2 Test: WebTool Import Fails (Deleted) + +```python +# tests/unit/tools/test_websearch_removed.py + +def test_websearch_module_deleted(): + """WebTool should no longer exist.""" + with pytest.raises(ImportError): + from src.tools.websearch import WebTool +``` + +### 3.3 Test: Examples Don't Reference WebTool + +```python +# tests/unit/test_no_webtool_references.py + +import ast +import pathlib + +def test_examples_no_webtool_imports(): + """No example files should import WebTool.""" + examples_dir = pathlib.Path("examples") + + for py_file in examples_dir.rglob("*.py"): + content = py_file.read_text() + tree = ast.parse(content) + + for node in ast.walk(tree): + if isinstance(node, ast.ImportFrom): + if node.module and "websearch" in node.module: + pytest.fail(f"{py_file} imports websearch (should be removed)") + if isinstance(node, ast.Import): + for alias in node.names: + if "websearch" in alias.name: + pytest.fail(f"{py_file} imports websearch (should be removed)") +``` + +--- + +## 4. Step-by-Step Implementation + +### Step 1: Write Tests First (TDD) + +```bash +# Create the test file +touch tests/unit/tools/test_websearch_removed.py +# Write the tests from section 3 +``` + +### Step 2: Run Tests (Should Fail) + +```bash +uv run pytest tests/unit/tools/test_websearch_removed.py -v +# Expected: FAIL (websearch still exists) +``` + +### Step 3: Delete WebTool + +```bash +rm src/tools/websearch.py +rm tests/unit/tools/test_websearch.py +``` + +### Step 4: Update SearchHandler Usages + +```python +# BEFORE (in each example file) +from src.tools.websearch import WebTool +search_handler = SearchHandler(tools=[PubMedTool(), WebTool()], timeout=30.0) + +# AFTER +from src.tools.pubmed import PubMedTool +search_handler = SearchHandler(tools=[PubMedTool()], timeout=30.0) +``` + +### Step 5: Update Type Definitions + +```python +# src/utils/models.py +# BEFORE +sources_searched: list[Literal["pubmed", "web"]] + +# AFTER +sources_searched: list[Literal["pubmed"]] +``` + +### Step 6: Run All Tests + +```bash +uv run pytest tests/unit/ -v +# Expected: ALL PASS +``` + +### Step 7: Run Lints + +```bash +uv run ruff check src tests examples +uv run mypy src +# Expected: No errors +``` + +--- + +## 5. Definition of Done + +Phase 9 is **COMPLETE** when: + +- [ ] `src/tools/websearch.py` deleted +- [ ] `tests/unit/tools/test_websearch.py` deleted +- [ ] All example files updated (no WebTool imports) +- [ ] Type definitions updated in models.py +- [ ] New tests verify WebTool is removed +- [ ] All existing tests pass +- [ ] Lints pass +- [ ] Examples run successfully with PubMed only + +--- + +## 6. Verification Commands + +```bash +# 1. Verify websearch.py is gone +ls src/tools/websearch.py 2>&1 | grep "No such file" + +# 2. Verify no WebTool imports remain +grep -r "WebTool" src/ examples/ && echo "FAIL: WebTool references found" || echo "PASS" +grep -r "websearch" src/ examples/ && echo "FAIL: websearch references found" || echo "PASS" + +# 3. Run tests +uv run pytest tests/unit/ -v + +# 4. Run example (should work) +source .env && uv run python examples/search_demo/run_search.py "metformin cancer" +``` + +--- + +## 7. Rollback Plan + +If something breaks: + +```bash +git checkout HEAD -- src/tools/websearch.py +git checkout HEAD -- tests/unit/tools/test_websearch.py +``` + +--- + +## 8. Value Delivered + +| Before | After | +|--------|-------| +| 2 search sources (1 broken) | 1 reliable source | +| Rate limit failures | No failures | +| Web noise in results | Pure scientific sources | +| ~230 lines for websearch | 0 lines | + +**Net effect**: Simpler, more reliable, more credible. diff --git a/docs/implementation/10_phase_clinicaltrials.md b/docs/implementation/10_phase_clinicaltrials.md new file mode 100644 index 0000000000000000000000000000000000000000..382b5fb631fc10b029404133375b01ed5375bde0 --- /dev/null +++ b/docs/implementation/10_phase_clinicaltrials.md @@ -0,0 +1,437 @@ +# Phase 10 Implementation Spec: ClinicalTrials.gov Integration + +**Goal**: Add clinical trial search for drug repurposing evidence. +**Philosophy**: "Clinical trials are the bridge from hypothesis to therapy." +**Prerequisite**: Phase 9 complete (DuckDuckGo removed) +**Estimated Time**: 2-3 hours + +--- + +## 1. Why ClinicalTrials.gov? + +### Scientific Value + +| Feature | Value for Drug Repurposing | +|---------|---------------------------| +| **400,000+ studies** | Massive evidence base | +| **Trial phase data** | Phase I/II/III = evidence strength | +| **Intervention details** | Exact drug + dosing | +| **Outcome measures** | What was measured | +| **Status tracking** | Completed vs recruiting | +| **Free API** | No cost, no key required | + +### Example Query Response + +Query: "metformin Alzheimer's" + +```json +{ + "studies": [ + { + "nctId": "NCT04098666", + "briefTitle": "Metformin in Alzheimer's Dementia Prevention", + "phase": "Phase 2", + "status": "Recruiting", + "conditions": ["Alzheimer Disease"], + "interventions": ["Drug: Metformin"] + } + ] +} +``` + +**This is GOLD for drug repurposing** - actual trials testing the hypothesis! + +--- + +## 2. API Specification + +### Endpoint + +``` +Base URL: https://clinicaltrials.gov/api/v2/studies +``` + +### Key Parameters + +| Parameter | Description | Example | +|-----------|-------------|---------| +| `query.cond` | Condition/disease | `Alzheimer` | +| `query.intr` | Intervention/drug | `Metformin` | +| `query.term` | General search | `metformin alzheimer` | +| `pageSize` | Results per page | `20` | +| `fields` | Fields to return | See below | + +### Fields We Need + +``` +NCTId, BriefTitle, Phase, OverallStatus, Condition, +InterventionName, StartDate, CompletionDate, BriefSummary +``` + +### Rate Limits + +- ~50 requests/minute per IP +- No authentication required +- Paginated (100 results max per call) + +### Documentation + +- [API v2 Docs](https://clinicaltrials.gov/data-api/api) +- [Migration Guide](https://www.nlm.nih.gov/pubs/techbull/ma24/ma24_clinicaltrials_api.html) + +--- + +## 3. Data Model + +### 3.1 Update Citation Source Type (`src/utils/models.py`) + +```python +# BEFORE +source: Literal["pubmed", "web"] + +# AFTER +source: Literal["pubmed", "clinicaltrials", "biorxiv"] +``` + +### 3.2 Evidence from Clinical Trials + +Clinical trial data maps to our existing `Evidence` model: + +```python +Evidence( + content=f"{brief_summary}. Phase: {phase}. Status: {status}.", + citation=Citation( + source="clinicaltrials", + title=brief_title, + url=f"https://clinicaltrials.gov/study/{nct_id}", + date=start_date or "Unknown", + authors=[] # Trials don't have authors in the same way + ), + relevance=0.8 # Trials are highly relevant for repurposing +) +``` + +--- + +## 4. Implementation + +### 4.0 Important: HTTP Client Selection + +**ClinicalTrials.gov's WAF blocks `httpx`'s TLS fingerprint.** Use `requests` instead. + +| Library | Status | Notes | +|---------|--------|-------| +| `httpx` | ❌ 403 Blocked | TLS/JA3 fingerprint flagged | +| `httpx[http2]` | ❌ 403 Blocked | HTTP/2 doesn't help | +| `requests` | ✅ Works | Industry standard, not blocked | +| `urllib` | ✅ Works | Stdlib alternative | + +We use `requests` wrapped in `asyncio.to_thread()` for async compatibility. + +### 4.1 ClinicalTrials Tool (`src/tools/clinicaltrials.py`) + +```python +"""ClinicalTrials.gov search tool using API v2.""" + +import asyncio +from typing import Any, ClassVar + +import requests +from tenacity import retry, stop_after_attempt, wait_exponential + +from src.utils.exceptions import SearchError +from src.utils.models import Citation, Evidence + + +class ClinicalTrialsTool: + """Search tool for ClinicalTrials.gov. + + Note: Uses `requests` library instead of `httpx` because ClinicalTrials.gov's + WAF blocks httpx's TLS fingerprint. The `requests` library is not blocked. + """ + + BASE_URL = "https://clinicaltrials.gov/api/v2/studies" + FIELDS: ClassVar[list[str]] = [ + "NCTId", + "BriefTitle", + "Phase", + "OverallStatus", + "Condition", + "InterventionName", + "StartDate", + "BriefSummary", + ] + + @property + def name(self) -> str: + return "clinicaltrials" + + @retry( + stop=stop_after_attempt(3), + wait=wait_exponential(multiplier=1, min=1, max=10), + reraise=True, + ) + async def search(self, query: str, max_results: int = 10) -> list[Evidence]: + """Search ClinicalTrials.gov for studies.""" + params = { + "query.term": query, + "pageSize": min(max_results, 100), + "fields": "|".join(self.FIELDS), + } + + try: + # Run blocking requests.get in a separate thread for async compatibility + response = await asyncio.to_thread( + requests.get, + self.BASE_URL, + params=params, + headers={"User-Agent": "DeepCritical-Research-Agent/1.0"}, + timeout=30, + ) + response.raise_for_status() + + data = response.json() + studies = data.get("studies", []) + return [self._study_to_evidence(study) for study in studies[:max_results]] + + except requests.HTTPError as e: + raise SearchError(f"ClinicalTrials.gov API error: {e}") from e + except requests.RequestException as e: + raise SearchError(f"ClinicalTrials.gov request failed: {e}") from e + + def _study_to_evidence(self, study: dict) -> Evidence: + """Convert a clinical trial study to Evidence.""" + # Navigate nested structure + protocol = study.get("protocolSection", {}) + id_module = protocol.get("identificationModule", {}) + status_module = protocol.get("statusModule", {}) + desc_module = protocol.get("descriptionModule", {}) + design_module = protocol.get("designModule", {}) + conditions_module = protocol.get("conditionsModule", {}) + arms_module = protocol.get("armsInterventionsModule", {}) + + nct_id = id_module.get("nctId", "Unknown") + title = id_module.get("briefTitle", "Untitled Study") + status = status_module.get("overallStatus", "Unknown") + start_date = status_module.get("startDateStruct", {}).get("date", "Unknown") + + # Get phase (might be a list) + phases = design_module.get("phases", []) + phase = phases[0] if phases else "Not Applicable" + + # Get conditions + conditions = conditions_module.get("conditions", []) + conditions_str = ", ".join(conditions[:3]) if conditions else "Unknown" + + # Get interventions + interventions = arms_module.get("interventions", []) + intervention_names = [i.get("name", "") for i in interventions[:3]] + interventions_str = ", ".join(intervention_names) if intervention_names else "Unknown" + + # Get summary + summary = desc_module.get("briefSummary", "No summary available.") + + # Build content with key trial info + content = ( + f"{summary[:500]}... " + f"Trial Phase: {phase}. " + f"Status: {status}. " + f"Conditions: {conditions_str}. " + f"Interventions: {interventions_str}." + ) + + return Evidence( + content=content[:2000], + citation=Citation( + source="clinicaltrials", + title=title[:500], + url=f"https://clinicaltrials.gov/study/{nct_id}", + date=start_date, + authors=[], # Trials don't have traditional authors + ), + relevance=0.85, # Trials are highly relevant for repurposing + ) +``` + +--- + +## 5. TDD Test Suite + +### 5.1 Unit Tests (`tests/unit/tools/test_clinicaltrials.py`) + +Uses `unittest.mock.patch` to mock `requests.get` (not `respx` since we're not using `httpx`). + +```python +"""Unit tests for ClinicalTrials.gov tool.""" + +from unittest.mock import MagicMock, patch + +import pytest +import requests + +from src.tools.clinicaltrials import ClinicalTrialsTool +from src.utils.exceptions import SearchError +from src.utils.models import Evidence + + +@pytest.fixture +def mock_clinicaltrials_response() -> dict: + """Mock ClinicalTrials.gov API response.""" + return { + "studies": [ + { + "protocolSection": { + "identificationModule": { + "nctId": "NCT04098666", + "briefTitle": "Metformin in Alzheimer's Dementia Prevention", + }, + "statusModule": { + "overallStatus": "Recruiting", + "startDateStruct": {"date": "2020-01-15"}, + }, + "descriptionModule": { + "briefSummary": "This study evaluates metformin for Alzheimer's prevention." + }, + "designModule": {"phases": ["PHASE2"]}, + "conditionsModule": {"conditions": ["Alzheimer Disease", "Dementia"]}, + "armsInterventionsModule": { + "interventions": [{"name": "Metformin", "type": "Drug"}] + }, + } + } + ] + } + + +class TestClinicalTrialsTool: + """Tests for ClinicalTrialsTool.""" + + def test_tool_name(self) -> None: + """Tool should have correct name.""" + tool = ClinicalTrialsTool() + assert tool.name == "clinicaltrials" + + @pytest.mark.asyncio + async def test_search_returns_evidence( + self, mock_clinicaltrials_response: dict + ) -> None: + """Search should return Evidence objects.""" + with patch("src.tools.clinicaltrials.requests.get") as mock_get: + mock_response = MagicMock() + mock_response.json.return_value = mock_clinicaltrials_response + mock_response.raise_for_status = MagicMock() + mock_get.return_value = mock_response + + tool = ClinicalTrialsTool() + results = await tool.search("metformin alzheimer", max_results=5) + + assert len(results) == 1 + assert isinstance(results[0], Evidence) + assert results[0].citation.source == "clinicaltrials" + assert "NCT04098666" in results[0].citation.url + assert "Metformin" in results[0].citation.title + + @pytest.mark.asyncio + async def test_search_api_error(self) -> None: + """Search should raise SearchError on API failure.""" + with patch("src.tools.clinicaltrials.requests.get") as mock_get: + mock_response = MagicMock() + mock_response.raise_for_status.side_effect = requests.HTTPError( + "500 Server Error" + ) + mock_get.return_value = mock_response + + tool = ClinicalTrialsTool() + + with pytest.raises(SearchError): + await tool.search("metformin alzheimer") + + +class TestClinicalTrialsIntegration: + """Integration tests (marked for separate run).""" + + @pytest.mark.integration + @pytest.mark.asyncio + async def test_real_api_call(self) -> None: + """Test actual API call (requires network).""" + tool = ClinicalTrialsTool() + results = await tool.search("metformin diabetes", max_results=3) + + assert len(results) > 0 + assert all(isinstance(r, Evidence) for r in results) + assert all(r.citation.source == "clinicaltrials" for r in results) +``` + +--- + +## 6. Integration with SearchHandler + +### 6.1 Update Example Files + +```python +# examples/search_demo/run_search.py +from src.tools.clinicaltrials import ClinicalTrialsTool +from src.tools.pubmed import PubMedTool +from src.tools.search_handler import SearchHandler + +search_handler = SearchHandler( + tools=[PubMedTool(), ClinicalTrialsTool()], + timeout=30.0 +) +``` + +### 6.2 Update SearchResult Type + +```python +# src/utils/models.py +sources_searched: list[Literal["pubmed", "clinicaltrials"]] +``` + +--- + +## 7. Definition of Done + +Phase 10 is **COMPLETE** when: + +- [ ] `src/tools/clinicaltrials.py` implemented +- [ ] Unit tests in `tests/unit/tools/test_clinicaltrials.py` +- [ ] Integration test marked with `@pytest.mark.integration` +- [ ] SearchHandler updated to include ClinicalTrialsTool +- [ ] Type definitions updated in models.py +- [ ] Example files updated +- [ ] All unit tests pass +- [ ] Lints pass +- [ ] Manual verification with real API + +--- + +## 8. Verification Commands + +```bash +# 1. Run unit tests +uv run pytest tests/unit/tools/test_clinicaltrials.py -v + +# 2. Run integration test (requires network) +uv run pytest tests/unit/tools/test_clinicaltrials.py -v -m integration + +# 3. Run full test suite +uv run pytest tests/unit/ -v + +# 4. Run example +source .env && uv run python examples/search_demo/run_search.py "metformin alzheimer" +# Should show results from BOTH PubMed AND ClinicalTrials.gov +``` + +--- + +## 9. Value Delivered + +| Before | After | +|--------|-------| +| Papers only | Papers + Clinical Trials | +| "Drug X might help" | "Drug X is in Phase II trial" | +| No trial status | Recruiting/Completed/Terminated | +| No phase info | Phase I/II/III evidence strength | + +**Demo pitch addition**: +> "DeepCritical searches PubMed for peer-reviewed evidence AND ClinicalTrials.gov for 400,000+ clinical trials." diff --git a/docs/implementation/11_phase_biorxiv.md b/docs/implementation/11_phase_biorxiv.md new file mode 100644 index 0000000000000000000000000000000000000000..4e17d3c8c16c7e0bd9ec6b28086141337e98b40c --- /dev/null +++ b/docs/implementation/11_phase_biorxiv.md @@ -0,0 +1,572 @@ +# Phase 11 Implementation Spec: bioRxiv Preprint Integration + +**Goal**: Add cutting-edge preprint search for the latest research. +**Philosophy**: "Preprints are where breakthroughs appear first." +**Prerequisite**: Phase 10 complete (ClinicalTrials.gov working) +**Estimated Time**: 2-3 hours + +--- + +## 1. Why bioRxiv? + +### Scientific Value + +| Feature | Value for Drug Repurposing | +|---------|---------------------------| +| **Cutting-edge research** | 6-12 months ahead of PubMed | +| **Rapid publication** | Days, not months | +| **Free full-text** | Complete papers, not just abstracts | +| **medRxiv included** | Medical preprints via same API | +| **No API key required** | Free and open | + +### The Preprint Advantage + +``` +Traditional Publication Timeline: + Research → Submit → Review → Revise → Accept → Publish + |___________________________ 6-18 months _______________| + +Preprint Timeline: + Research → Upload → Available + |______ 1-3 days ______| +``` + +**For drug repurposing**: Preprints contain the newest hypotheses and evidence! + +--- + +## 2. API Specification + +### Endpoint + +``` +Base URL: https://api.biorxiv.org/details/[server]/[interval]/[cursor]/[format] +``` + +### Servers + +| Server | Content | +|--------|---------| +| `biorxiv` | Biology preprints | +| `medrxiv` | Medical preprints (more relevant for us!) | + +### Interval Formats + +| Format | Example | Description | +|--------|---------|-------------| +| Date range | `2024-01-01/2024-12-31` | Papers between dates | +| Recent N | `50` | Most recent N papers | +| Recent N days | `30d` | Papers from last N days | + +### Response Format + +```json +{ + "collection": [ + { + "doi": "10.1101/2024.01.15.123456", + "title": "Metformin repurposing for neurodegeneration", + "authors": "Smith, J; Jones, A", + "date": "2024-01-15", + "category": "neuroscience", + "abstract": "We investigated metformin's potential..." + } + ], + "messages": [{"status": "ok", "count": 100}] +} +``` + +### Rate Limits + +- No official limit, but be respectful +- Results paginated (100 per call) +- Use cursor for pagination + +### Documentation + +- [bioRxiv API](https://api.biorxiv.org/) +- [medrxivr R package docs](https://docs.ropensci.org/medrxivr/) + +--- + +## 3. Search Strategy + +### Challenge: bioRxiv API Limitations + +The bioRxiv API does NOT support keyword search directly. It returns papers by: +- Date range +- Recent count + +### Solution: Client-Side Filtering + +```python +# Strategy: +# 1. Fetch recent papers (e.g., last 90 days) +# 2. Filter by keyword matching in title/abstract +# 3. Use embeddings for semantic matching (leverage Phase 6!) +``` + +### Alternative: Content Search Endpoint + +``` +https://api.biorxiv.org/pubs/[server]/[doi_prefix] +``` + +For searching, we can use the publisher endpoint with filtering. + +--- + +## 4. Data Model + +### 4.1 Update Citation Source Type (`src/utils/models.py`) + +```python +# After Phase 11 +source: Literal["pubmed", "clinicaltrials", "biorxiv"] +``` + +### 4.2 Evidence from Preprints + +```python +Evidence( + content=abstract[:2000], + citation=Citation( + source="biorxiv", # or "medrxiv" + title=title, + url=f"https://doi.org/{doi}", + date=date, + authors=authors.split("; ")[:5] + ), + relevance=0.75 # Preprints slightly lower than peer-reviewed +) +``` + +--- + +## 5. Implementation + +### 5.1 bioRxiv Tool (`src/tools/biorxiv.py`) + +```python +"""bioRxiv/medRxiv preprint search tool.""" + +import re +from datetime import datetime, timedelta + +import httpx +from tenacity import retry, stop_after_attempt, wait_exponential + +from src.utils.exceptions import SearchError +from src.utils.models import Citation, Evidence + + +class BioRxivTool: + """Search tool for bioRxiv and medRxiv preprints.""" + + BASE_URL = "https://api.biorxiv.org/details" + # Use medRxiv for medical/clinical content (more relevant for drug repurposing) + DEFAULT_SERVER = "medrxiv" + # Fetch papers from last N days + DEFAULT_DAYS = 90 + + def __init__(self, server: str = DEFAULT_SERVER, days: int = DEFAULT_DAYS): + """ + Initialize bioRxiv tool. + + Args: + server: "biorxiv" or "medrxiv" + days: How many days back to search + """ + self.server = server + self.days = days + + @property + def name(self) -> str: + return "biorxiv" + + @retry( + stop=stop_after_attempt(3), + wait=wait_exponential(multiplier=1, min=1, max=10), + reraise=True, + ) + async def search(self, query: str, max_results: int = 10) -> list[Evidence]: + """ + Search bioRxiv/medRxiv for preprints matching query. + + Note: bioRxiv API doesn't support keyword search directly. + We fetch recent papers and filter client-side. + + Args: + query: Search query (keywords) + max_results: Maximum results to return + + Returns: + List of Evidence objects from preprints + """ + # Build date range for last N days + end_date = datetime.now().strftime("%Y-%m-%d") + start_date = (datetime.now() - timedelta(days=self.days)).strftime("%Y-%m-%d") + interval = f"{start_date}/{end_date}" + + # Fetch recent papers + url = f"{self.BASE_URL}/{self.server}/{interval}/0/json" + + async with httpx.AsyncClient(timeout=30.0) as client: + try: + response = await client.get(url) + response.raise_for_status() + except httpx.HTTPStatusError as e: + raise SearchError(f"bioRxiv search failed: {e}") from e + + data = response.json() + papers = data.get("collection", []) + + # Filter papers by query keywords + query_terms = self._extract_terms(query) + matching = self._filter_by_keywords(papers, query_terms, max_results) + + return [self._paper_to_evidence(paper) for paper in matching] + + def _extract_terms(self, query: str) -> list[str]: + """Extract search terms from query.""" + # Simple tokenization, lowercase + terms = re.findall(r'\b\w+\b', query.lower()) + # Filter out common stop words + stop_words = {'the', 'a', 'an', 'in', 'on', 'for', 'and', 'or', 'of', 'to'} + return [t for t in terms if t not in stop_words and len(t) > 2] + + def _filter_by_keywords( + self, papers: list[dict], terms: list[str], max_results: int + ) -> list[dict]: + """Filter papers that contain query terms in title or abstract.""" + scored_papers = [] + + for paper in papers: + title = paper.get("title", "").lower() + abstract = paper.get("abstract", "").lower() + text = f"{title} {abstract}" + + # Count matching terms + matches = sum(1 for term in terms if term in text) + + if matches > 0: + scored_papers.append((matches, paper)) + + # Sort by match count (descending) + scored_papers.sort(key=lambda x: x[0], reverse=True) + + return [paper for _, paper in scored_papers[:max_results]] + + def _paper_to_evidence(self, paper: dict) -> Evidence: + """Convert a preprint paper to Evidence.""" + doi = paper.get("doi", "") + title = paper.get("title", "Untitled") + authors_str = paper.get("authors", "Unknown") + date = paper.get("date", "Unknown") + abstract = paper.get("abstract", "No abstract available.") + category = paper.get("category", "") + + # Parse authors (format: "Smith, J; Jones, A") + authors = [a.strip() for a in authors_str.split(";")][:5] + + # Note this is a preprint in the content + content = ( + f"[PREPRINT - Not peer-reviewed] " + f"{abstract[:1800]}... " + f"Category: {category}." + ) + + return Evidence( + content=content[:2000], + citation=Citation( + source="biorxiv", + title=title[:500], + url=f"https://doi.org/{doi}" if doi else f"https://www.medrxiv.org/", + date=date, + authors=authors, + ), + relevance=0.75, # Slightly lower than peer-reviewed + ) +``` + +--- + +## 6. TDD Test Suite + +### 6.1 Unit Tests (`tests/unit/tools/test_biorxiv.py`) + +```python +"""Unit tests for bioRxiv tool.""" + +import pytest +import respx +from httpx import Response + +from src.tools.biorxiv import BioRxivTool +from src.utils.models import Evidence + + +@pytest.fixture +def mock_biorxiv_response(): + """Mock bioRxiv API response.""" + return { + "collection": [ + { + "doi": "10.1101/2024.01.15.24301234", + "title": "Metformin repurposing for Alzheimer's disease: a systematic review", + "authors": "Smith, John; Jones, Alice; Brown, Bob", + "date": "2024-01-15", + "category": "neurology", + "abstract": "Background: Metformin has shown neuroprotective effects. " + "We conducted a systematic review of metformin's potential " + "for Alzheimer's disease treatment." + }, + { + "doi": "10.1101/2024.01.10.24301111", + "title": "COVID-19 vaccine efficacy study", + "authors": "Wilson, C", + "date": "2024-01-10", + "category": "infectious diseases", + "abstract": "This study evaluates COVID-19 vaccine efficacy." + } + ], + "messages": [{"status": "ok", "count": 2}] + } + + +class TestBioRxivTool: + """Tests for BioRxivTool.""" + + def test_tool_name(self): + """Tool should have correct name.""" + tool = BioRxivTool() + assert tool.name == "biorxiv" + + def test_default_server_is_medrxiv(self): + """Default server should be medRxiv for medical relevance.""" + tool = BioRxivTool() + assert tool.server == "medrxiv" + + @pytest.mark.asyncio + @respx.mock + async def test_search_returns_evidence(self, mock_biorxiv_response): + """Search should return Evidence objects.""" + respx.get(url__startswith="https://api.biorxiv.org/details").mock( + return_value=Response(200, json=mock_biorxiv_response) + ) + + tool = BioRxivTool() + results = await tool.search("metformin alzheimer", max_results=5) + + assert len(results) == 1 # Only the matching paper + assert isinstance(results[0], Evidence) + assert results[0].citation.source == "biorxiv" + assert "metformin" in results[0].citation.title.lower() + + @pytest.mark.asyncio + @respx.mock + async def test_search_filters_by_keywords(self, mock_biorxiv_response): + """Search should filter papers by query keywords.""" + respx.get(url__startswith="https://api.biorxiv.org/details").mock( + return_value=Response(200, json=mock_biorxiv_response) + ) + + tool = BioRxivTool() + + # Search for metformin - should match first paper + results = await tool.search("metformin") + assert len(results) == 1 + assert "metformin" in results[0].citation.title.lower() + + # Search for COVID - should match second paper + results = await tool.search("covid vaccine") + assert len(results) == 1 + assert "covid" in results[0].citation.title.lower() + + @pytest.mark.asyncio + @respx.mock + async def test_search_marks_as_preprint(self, mock_biorxiv_response): + """Evidence content should note it's a preprint.""" + respx.get(url__startswith="https://api.biorxiv.org/details").mock( + return_value=Response(200, json=mock_biorxiv_response) + ) + + tool = BioRxivTool() + results = await tool.search("metformin") + + assert "PREPRINT" in results[0].content + assert "Not peer-reviewed" in results[0].content + + @pytest.mark.asyncio + @respx.mock + async def test_search_empty_results(self): + """Search should handle empty results gracefully.""" + respx.get(url__startswith="https://api.biorxiv.org/details").mock( + return_value=Response(200, json={"collection": [], "messages": []}) + ) + + tool = BioRxivTool() + results = await tool.search("xyznonexistent") + + assert results == [] + + @pytest.mark.asyncio + @respx.mock + async def test_search_api_error(self): + """Search should raise SearchError on API failure.""" + from src.utils.exceptions import SearchError + + respx.get(url__startswith="https://api.biorxiv.org/details").mock( + return_value=Response(500, text="Internal Server Error") + ) + + tool = BioRxivTool() + + with pytest.raises(SearchError): + await tool.search("metformin") + + def test_extract_terms(self): + """Should extract meaningful search terms.""" + tool = BioRxivTool() + + terms = tool._extract_terms("metformin for Alzheimer's disease") + + assert "metformin" in terms + assert "alzheimer" in terms + assert "disease" in terms + assert "for" not in terms # Stop word + assert "the" not in terms # Stop word + + +class TestBioRxivIntegration: + """Integration tests (marked for separate run).""" + + @pytest.mark.integration + @pytest.mark.asyncio + async def test_real_api_call(self): + """Test actual API call (requires network).""" + tool = BioRxivTool(days=30) # Last 30 days + results = await tool.search("diabetes", max_results=3) + + # May or may not find results depending on recent papers + assert isinstance(results, list) + for r in results: + assert isinstance(r, Evidence) + assert r.citation.source == "biorxiv" +``` + +--- + +## 7. Integration with SearchHandler + +### 7.1 Final SearchHandler Configuration + +```python +# examples/search_demo/run_search.py +from src.tools.biorxiv import BioRxivTool +from src.tools.clinicaltrials import ClinicalTrialsTool +from src.tools.pubmed import PubMedTool +from src.tools.search_handler import SearchHandler + +search_handler = SearchHandler( + tools=[ + PubMedTool(), # Peer-reviewed papers + ClinicalTrialsTool(), # Clinical trials + BioRxivTool(), # Preprints (cutting edge) + ], + timeout=30.0 +) +``` + +### 7.2 Final Type Definition + +```python +# src/utils/models.py +sources_searched: list[Literal["pubmed", "clinicaltrials", "biorxiv"]] +``` + +--- + +## 8. Definition of Done + +Phase 11 is **COMPLETE** when: + +- [ ] `src/tools/biorxiv.py` implemented +- [ ] Unit tests in `tests/unit/tools/test_biorxiv.py` +- [ ] Integration test marked with `@pytest.mark.integration` +- [ ] SearchHandler updated to include BioRxivTool +- [ ] Type definitions updated in models.py +- [ ] Example files updated +- [ ] All unit tests pass +- [ ] Lints pass +- [ ] Manual verification with real API + +--- + +## 9. Verification Commands + +```bash +# 1. Run unit tests +uv run pytest tests/unit/tools/test_biorxiv.py -v + +# 2. Run integration test (requires network) +uv run pytest tests/unit/tools/test_biorxiv.py -v -m integration + +# 3. Run full test suite +uv run pytest tests/unit/ -v + +# 4. Run example with all three sources +source .env && uv run python examples/search_demo/run_search.py "metformin diabetes" +# Should show results from PubMed, ClinicalTrials.gov, AND bioRxiv/medRxiv +``` + +--- + +## 10. Value Delivered + +| Before | After | +|--------|-------| +| Only published papers | Published + Preprints | +| 6-18 month lag | Near real-time research | +| Miss cutting-edge | Catch breakthroughs early | + +**Demo pitch (final)**: +> "DeepCritical searches PubMed for peer-reviewed evidence, ClinicalTrials.gov for 400,000+ clinical trials, and bioRxiv/medRxiv for cutting-edge preprints - then uses LLMs to generate mechanistic hypotheses and synthesize findings into publication-quality reports." + +--- + +## 11. Complete Source Architecture (After Phase 11) + +``` +User Query: "Can metformin treat Alzheimer's?" + | + v + SearchHandler + | + ┌───────────────┼───────────────┐ + | | | + v v v +PubMedTool ClinicalTrials BioRxivTool + | Tool | + | | | + v v v +"15 peer- "3 Phase II "2 preprints +reviewed trials from last +papers" recruiting" 90 days" + | | | + └───────────────┼───────────────┘ + | + v + Evidence Pool + | + v + EmbeddingService.deduplicate() + | + v + HypothesisAgent → JudgeAgent → ReportAgent + | + v + Structured Research Report +``` + +**This is the Gucci Banger stack.** diff --git a/docs/implementation/12_phase_mcp_server.md b/docs/implementation/12_phase_mcp_server.md new file mode 100644 index 0000000000000000000000000000000000000000..64bc5559e3e4986eb362382627ea8cd7c753a2e2 --- /dev/null +++ b/docs/implementation/12_phase_mcp_server.md @@ -0,0 +1,832 @@ +# Phase 12 Implementation Spec: MCP Server Integration + +**Goal**: Expose DeepCritical search tools as MCP servers for Track 2 compliance. +**Philosophy**: "MCP is the bridge between tools and LLMs." +**Prerequisite**: Phase 11 complete (all search tools working) +**Priority**: P0 - REQUIRED FOR HACKATHON TRACK 2 +**Estimated Time**: 2-3 hours + +--- + +## 1. Why MCP Server? + +### Hackathon Requirement + +| Requirement | Status Before | Status After | +|-------------|---------------|--------------| +| Must use MCP servers as tools | **MISSING** | **COMPLIANT** | +| Autonomous Agent behavior | **Have it** | Have it | +| Must be Gradio app | **Have it** | Have it | +| Planning/reasoning/execution | **Have it** | Have it | + +**Bottom Line**: Without MCP server, we're disqualified from Track 2. + +### What MCP Enables + +```text +Current State: + Our Tools → Called directly by Python code → Only our app can use them + +After MCP: + Our Tools → Exposed via MCP protocol → Claude Desktop, Cursor, ANY MCP client +``` + +--- + +## 2. Implementation Options Analysis + +### Option A: Gradio MCP (Recommended) + +**Pros:** +- Single parameter: `demo.launch(mcp_server=True)` +- Already have Gradio app +- Automatic tool schema generation from docstrings +- Built into Gradio 5.0+ + +**Cons:** +- Requires Gradio 5.0+ with MCP extras +- Must follow strict docstring format + +### Option B: Native MCP SDK (FastMCP) + +**Pros:** +- More control over tool definitions +- Explicit server configuration +- Separate from UI concerns + +**Cons:** +- Separate server process +- More code to maintain +- Additional dependency + +### Decision: **Gradio MCP (Option A)** + +Rationale: +1. Already have Gradio app (`src/app.py`) +2. Minimal code changes +3. Judges will appreciate simplicity +4. Follows hackathon's official Gradio guide + +--- + +## 3. Technical Specification + +### 3.1 Dependencies + +```toml +# pyproject.toml - add MCP extras +dependencies = [ + "gradio[mcp]>=5.0.0", # Updated from gradio>=4.0 + # ... existing deps +] +``` + +### 3.2 MCP Tool Functions + +Each tool needs: +1. **Type hints** on all parameters +2. **Docstring** with Args section (Google style) +3. **Return type** annotation +4. **`api_name`** parameter for explicit endpoint naming + +```python +async def search_pubmed(query: str, max_results: int = 10) -> str: + """Search PubMed for biomedical literature. + + Args: + query: Search query for PubMed (e.g., "metformin alzheimer") + max_results: Maximum number of results to return (1-50) + + Returns: + Formatted search results with titles, citations, and abstracts + """ +``` + +### 3.3 MCP Server URL + +Once launched: +```text +http://localhost:7860/gradio_api/mcp/ +``` + +Or on HuggingFace Spaces: +```text +https://[space-id].hf.space/gradio_api/mcp/ +``` + +--- + +## 4. Implementation + +### 4.1 MCP Tool Wrappers (`src/mcp_tools.py`) + +```python +"""MCP tool wrappers for DeepCritical search tools. + +These functions expose our search tools via MCP protocol. +Each function follows the MCP tool contract: +- Full type hints +- Google-style docstrings with Args section +- Formatted string returns +""" + +from src.tools.biorxiv import BioRxivTool +from src.tools.clinicaltrials import ClinicalTrialsTool +from src.tools.pubmed import PubMedTool + + +# Singleton instances (avoid recreating on each call) +_pubmed = PubMedTool() +_trials = ClinicalTrialsTool() +_biorxiv = BioRxivTool() + + +async def search_pubmed(query: str, max_results: int = 10) -> str: + """Search PubMed for peer-reviewed biomedical literature. + + Searches NCBI PubMed database for scientific papers matching your query. + Returns titles, authors, abstracts, and citation information. + + Args: + query: Search query (e.g., "metformin alzheimer", "drug repurposing cancer") + max_results: Maximum results to return (1-50, default 10) + + Returns: + Formatted search results with paper titles, authors, dates, and abstracts + """ + max_results = max(1, min(50, max_results)) # Clamp to valid range + + results = await _pubmed.search(query, max_results) + + if not results: + return f"No PubMed results found for: {query}" + + formatted = [f"## PubMed Results for: {query}\n"] + for i, evidence in enumerate(results, 1): + formatted.append(f"### {i}. {evidence.citation.title}") + formatted.append(f"**Authors**: {', '.join(evidence.citation.authors[:3])}") + formatted.append(f"**Date**: {evidence.citation.date}") + formatted.append(f"**URL**: {evidence.citation.url}") + formatted.append(f"\n{evidence.content}\n") + + return "\n".join(formatted) + + +async def search_clinical_trials(query: str, max_results: int = 10) -> str: + """Search ClinicalTrials.gov for clinical trial data. + + Searches the ClinicalTrials.gov database for trials matching your query. + Returns trial titles, phases, status, conditions, and interventions. + + Args: + query: Search query (e.g., "metformin alzheimer", "diabetes phase 3") + max_results: Maximum results to return (1-50, default 10) + + Returns: + Formatted clinical trial information with NCT IDs, phases, and status + """ + max_results = max(1, min(50, max_results)) + + results = await _trials.search(query, max_results) + + if not results: + return f"No clinical trials found for: {query}" + + formatted = [f"## Clinical Trials for: {query}\n"] + for i, evidence in enumerate(results, 1): + formatted.append(f"### {i}. {evidence.citation.title}") + formatted.append(f"**URL**: {evidence.citation.url}") + formatted.append(f"**Date**: {evidence.citation.date}") + formatted.append(f"\n{evidence.content}\n") + + return "\n".join(formatted) + + +async def search_biorxiv(query: str, max_results: int = 10) -> str: + """Search bioRxiv/medRxiv for preprint research. + + Searches bioRxiv and medRxiv preprint servers for cutting-edge research. + Note: Preprints are NOT peer-reviewed but contain the latest findings. + + Args: + query: Search query (e.g., "metformin neuroprotection", "long covid treatment") + max_results: Maximum results to return (1-50, default 10) + + Returns: + Formatted preprint results with titles, authors, and abstracts + """ + max_results = max(1, min(50, max_results)) + + results = await _biorxiv.search(query, max_results) + + if not results: + return f"No bioRxiv/medRxiv preprints found for: {query}" + + formatted = [f"## Preprint Results for: {query}\n"] + for i, evidence in enumerate(results, 1): + formatted.append(f"### {i}. {evidence.citation.title}") + formatted.append(f"**Authors**: {', '.join(evidence.citation.authors[:3])}") + formatted.append(f"**Date**: {evidence.citation.date}") + formatted.append(f"**URL**: {evidence.citation.url}") + formatted.append(f"\n{evidence.content}\n") + + return "\n".join(formatted) + + +async def search_all_sources(query: str, max_per_source: int = 5) -> str: + """Search all biomedical sources simultaneously. + + Performs parallel search across PubMed, ClinicalTrials.gov, and bioRxiv. + This is the most comprehensive search option for drug repurposing research. + + Args: + query: Search query (e.g., "metformin alzheimer", "aspirin cancer prevention") + max_per_source: Maximum results per source (1-20, default 5) + + Returns: + Combined results from all sources with source labels + """ + import asyncio + + max_per_source = max(1, min(20, max_per_source)) + + # Run all searches in parallel + pubmed_task = search_pubmed(query, max_per_source) + trials_task = search_clinical_trials(query, max_per_source) + biorxiv_task = search_biorxiv(query, max_per_source) + + pubmed_results, trials_results, biorxiv_results = await asyncio.gather( + pubmed_task, trials_task, biorxiv_task, return_exceptions=True + ) + + formatted = [f"# Comprehensive Search: {query}\n"] + + # Add each result section (handle exceptions gracefully) + if isinstance(pubmed_results, str): + formatted.append(pubmed_results) + else: + formatted.append(f"## PubMed\n*Error: {pubmed_results}*\n") + + if isinstance(trials_results, str): + formatted.append(trials_results) + else: + formatted.append(f"## Clinical Trials\n*Error: {trials_results}*\n") + + if isinstance(biorxiv_results, str): + formatted.append(biorxiv_results) + else: + formatted.append(f"## Preprints\n*Error: {biorxiv_results}*\n") + + return "\n---\n".join(formatted) +``` + +### 4.2 Update Gradio App (`src/app.py`) + +```python +"""Gradio UI for DeepCritical agent with MCP server support.""" + +import os +from collections.abc import AsyncGenerator +from typing import Any + +import gradio as gr + +from src.agent_factory.judges import JudgeHandler, MockJudgeHandler +from src.mcp_tools import ( + search_all_sources, + search_biorxiv, + search_clinical_trials, + search_pubmed, +) +from src.orchestrator_factory import create_orchestrator +from src.tools.biorxiv import BioRxivTool +from src.tools.clinicaltrials import ClinicalTrialsTool +from src.tools.pubmed import PubMedTool +from src.tools.search_handler import SearchHandler +from src.utils.models import OrchestratorConfig + + +# ... (existing configure_orchestrator and research_agent functions unchanged) + + +def create_demo() -> Any: + """ + Create the Gradio demo interface with MCP support. + + Returns: + Configured Gradio Blocks interface with MCP server enabled + """ + with gr.Blocks( + title="DeepCritical - Drug Repurposing Research Agent", + theme=gr.themes.Soft(), + ) as demo: + gr.Markdown(""" + # DeepCritical + ## AI-Powered Drug Repurposing Research Agent + + Ask questions about potential drug repurposing opportunities. + The agent searches PubMed, ClinicalTrials.gov, and bioRxiv/medRxiv preprints. + + **Example questions:** + - "What drugs could be repurposed for Alzheimer's disease?" + - "Is metformin effective for cancer treatment?" + - "What existing medications show promise for Long COVID?" + """) + + # Main chat interface (existing) + gr.ChatInterface( + fn=research_agent, + type="messages", + title="", + examples=[ + "What drugs could be repurposed for Alzheimer's disease?", + "Is metformin effective for treating cancer?", + "What medications show promise for Long COVID treatment?", + "Can statins be repurposed for neurological conditions?", + ], + additional_inputs=[ + gr.Radio( + choices=["simple", "magentic"], + value="simple", + label="Orchestrator Mode", + info="Simple: Linear (OpenAI/Anthropic) | Magentic: Multi-Agent (OpenAI)", + ) + ], + ) + + # MCP Tool Interfaces (exposed via MCP protocol) + gr.Markdown("---\n## MCP Tools (Also Available via Claude Desktop)") + + with gr.Tab("PubMed Search"): + gr.Interface( + fn=search_pubmed, + inputs=[ + gr.Textbox(label="Query", placeholder="metformin alzheimer"), + gr.Slider(1, 50, value=10, step=1, label="Max Results"), + ], + outputs=gr.Markdown(label="Results"), + api_name="search_pubmed", + ) + + with gr.Tab("Clinical Trials"): + gr.Interface( + fn=search_clinical_trials, + inputs=[ + gr.Textbox(label="Query", placeholder="diabetes phase 3"), + gr.Slider(1, 50, value=10, step=1, label="Max Results"), + ], + outputs=gr.Markdown(label="Results"), + api_name="search_clinical_trials", + ) + + with gr.Tab("Preprints"): + gr.Interface( + fn=search_biorxiv, + inputs=[ + gr.Textbox(label="Query", placeholder="long covid treatment"), + gr.Slider(1, 50, value=10, step=1, label="Max Results"), + ], + outputs=gr.Markdown(label="Results"), + api_name="search_biorxiv", + ) + + with gr.Tab("Search All"): + gr.Interface( + fn=search_all_sources, + inputs=[ + gr.Textbox(label="Query", placeholder="metformin cancer"), + gr.Slider(1, 20, value=5, step=1, label="Max Per Source"), + ], + outputs=gr.Markdown(label="Results"), + api_name="search_all", + ) + + gr.Markdown(""" + --- + **Note**: This is a research tool and should not be used for medical decisions. + Always consult healthcare professionals for medical advice. + + Built with PydanticAI + PubMed, ClinicalTrials.gov & bioRxiv + + **MCP Server**: Available at `/gradio_api/mcp/` for Claude Desktop integration + """) + + return demo + + +def main() -> None: + """Run the Gradio app with MCP server enabled.""" + demo = create_demo() + demo.launch( + server_name="0.0.0.0", + server_port=7860, + share=False, + mcp_server=True, # Enable MCP server + ) + + +if __name__ == "__main__": + main() +``` + +--- + +## 5. TDD Test Suite + +### 5.1 Unit Tests (`tests/unit/test_mcp_tools.py`) + +```python +"""Unit tests for MCP tool wrappers.""" + +from unittest.mock import AsyncMock, patch + +import pytest + +from src.mcp_tools import ( + search_all_sources, + search_biorxiv, + search_clinical_trials, + search_pubmed, +) +from src.utils.models import Citation, Evidence + + +@pytest.fixture +def mock_evidence() -> Evidence: + """Sample evidence for testing.""" + return Evidence( + content="Metformin shows neuroprotective effects in preclinical models.", + citation=Citation( + source="pubmed", + title="Metformin and Alzheimer's Disease", + url="https://pubmed.ncbi.nlm.nih.gov/12345678/", + date="2024-01-15", + authors=["Smith J", "Jones M", "Brown K"], + ), + relevance=0.85, + ) + + +class TestSearchPubMed: + """Tests for search_pubmed MCP tool.""" + + @pytest.mark.asyncio + async def test_returns_formatted_string(self, mock_evidence: Evidence) -> None: + """Should return formatted markdown string.""" + with patch("src.mcp_tools._pubmed") as mock_tool: + mock_tool.search = AsyncMock(return_value=[mock_evidence]) + + result = await search_pubmed("metformin alzheimer", 10) + + assert isinstance(result, str) + assert "PubMed Results" in result + assert "Metformin and Alzheimer's Disease" in result + assert "Smith J" in result + + @pytest.mark.asyncio + async def test_clamps_max_results(self) -> None: + """Should clamp max_results to valid range (1-50).""" + with patch("src.mcp_tools._pubmed") as mock_tool: + mock_tool.search = AsyncMock(return_value=[]) + + # Test lower bound + await search_pubmed("test", 0) + mock_tool.search.assert_called_with("test", 1) + + # Test upper bound + await search_pubmed("test", 100) + mock_tool.search.assert_called_with("test", 50) + + @pytest.mark.asyncio + async def test_handles_no_results(self) -> None: + """Should return appropriate message when no results.""" + with patch("src.mcp_tools._pubmed") as mock_tool: + mock_tool.search = AsyncMock(return_value=[]) + + result = await search_pubmed("xyznonexistent", 10) + + assert "No PubMed results found" in result + + +class TestSearchClinicalTrials: + """Tests for search_clinical_trials MCP tool.""" + + @pytest.mark.asyncio + async def test_returns_formatted_string(self, mock_evidence: Evidence) -> None: + """Should return formatted markdown string.""" + mock_evidence.citation.source = "clinicaltrials" # type: ignore + + with patch("src.mcp_tools._trials") as mock_tool: + mock_tool.search = AsyncMock(return_value=[mock_evidence]) + + result = await search_clinical_trials("diabetes", 10) + + assert isinstance(result, str) + assert "Clinical Trials" in result + + +class TestSearchBiorxiv: + """Tests for search_biorxiv MCP tool.""" + + @pytest.mark.asyncio + async def test_returns_formatted_string(self, mock_evidence: Evidence) -> None: + """Should return formatted markdown string.""" + mock_evidence.citation.source = "biorxiv" # type: ignore + + with patch("src.mcp_tools._biorxiv") as mock_tool: + mock_tool.search = AsyncMock(return_value=[mock_evidence]) + + result = await search_biorxiv("preprint search", 10) + + assert isinstance(result, str) + assert "Preprint Results" in result + + +class TestSearchAllSources: + """Tests for search_all_sources MCP tool.""" + + @pytest.mark.asyncio + async def test_combines_all_sources(self, mock_evidence: Evidence) -> None: + """Should combine results from all sources.""" + with patch("src.mcp_tools.search_pubmed", new_callable=AsyncMock) as mock_pubmed, \ + patch("src.mcp_tools.search_clinical_trials", new_callable=AsyncMock) as mock_trials, \ + patch("src.mcp_tools.search_biorxiv", new_callable=AsyncMock) as mock_biorxiv: + + mock_pubmed.return_value = "## PubMed Results" + mock_trials.return_value = "## Clinical Trials" + mock_biorxiv.return_value = "## Preprints" + + result = await search_all_sources("metformin", 5) + + assert "Comprehensive Search" in result + assert "PubMed" in result + assert "Clinical Trials" in result + assert "Preprints" in result + + @pytest.mark.asyncio + async def test_handles_partial_failures(self) -> None: + """Should handle partial failures gracefully.""" + with patch("src.mcp_tools.search_pubmed", new_callable=AsyncMock) as mock_pubmed, \ + patch("src.mcp_tools.search_clinical_trials", new_callable=AsyncMock) as mock_trials, \ + patch("src.mcp_tools.search_biorxiv", new_callable=AsyncMock) as mock_biorxiv: + + mock_pubmed.return_value = "## PubMed Results" + mock_trials.side_effect = Exception("API Error") + mock_biorxiv.return_value = "## Preprints" + + result = await search_all_sources("metformin", 5) + + # Should still contain working sources + assert "PubMed" in result + assert "Preprints" in result + # Should show error for failed source + assert "Error" in result + + +class TestMCPDocstrings: + """Tests that docstrings follow MCP format.""" + + def test_search_pubmed_has_args_section(self) -> None: + """Docstring must have Args section for MCP schema generation.""" + assert search_pubmed.__doc__ is not None + assert "Args:" in search_pubmed.__doc__ + assert "query:" in search_pubmed.__doc__ + assert "max_results:" in search_pubmed.__doc__ + assert "Returns:" in search_pubmed.__doc__ + + def test_search_clinical_trials_has_args_section(self) -> None: + """Docstring must have Args section for MCP schema generation.""" + assert search_clinical_trials.__doc__ is not None + assert "Args:" in search_clinical_trials.__doc__ + + def test_search_biorxiv_has_args_section(self) -> None: + """Docstring must have Args section for MCP schema generation.""" + assert search_biorxiv.__doc__ is not None + assert "Args:" in search_biorxiv.__doc__ + + def test_search_all_sources_has_args_section(self) -> None: + """Docstring must have Args section for MCP schema generation.""" + assert search_all_sources.__doc__ is not None + assert "Args:" in search_all_sources.__doc__ + + +class TestMCPTypeHints: + """Tests that type hints are complete for MCP.""" + + def test_search_pubmed_type_hints(self) -> None: + """All parameters and return must have type hints.""" + import inspect + + sig = inspect.signature(search_pubmed) + + # Check parameter hints + assert sig.parameters["query"].annotation == str + assert sig.parameters["max_results"].annotation == int + + # Check return hint + assert sig.return_annotation == str + + def test_search_clinical_trials_type_hints(self) -> None: + """All parameters and return must have type hints.""" + import inspect + + sig = inspect.signature(search_clinical_trials) + assert sig.parameters["query"].annotation == str + assert sig.parameters["max_results"].annotation == int + assert sig.return_annotation == str +``` + +### 5.2 Integration Test (`tests/integration/test_mcp_server.py`) + +```python +"""Integration tests for MCP server functionality.""" + +import pytest + + +class TestMCPServerIntegration: + """Integration tests for MCP server (requires running app).""" + + @pytest.mark.integration + @pytest.mark.asyncio + async def test_mcp_tools_work_end_to_end(self) -> None: + """Test that MCP tools execute real searches.""" + from src.mcp_tools import search_pubmed + + result = await search_pubmed("metformin diabetes", 3) + + assert isinstance(result, str) + assert "PubMed Results" in result + # Should have actual content (not just "no results") + assert len(result) > 100 +``` + +--- + +## 6. Claude Desktop Configuration + +### 6.1 Local Development + +```json +// ~/.config/claude/claude_desktop_config.json (Linux/Mac) +// %APPDATA%\Claude\claude_desktop_config.json (Windows) +{ + "mcpServers": { + "deepcritical": { + "url": "http://localhost:7860/gradio_api/mcp/" + } + } +} +``` + +### 6.2 HuggingFace Spaces + +```json +{ + "mcpServers": { + "deepcritical": { + "url": "https://MCP-1st-Birthday-deepcritical.hf.space/gradio_api/mcp/" + } + } +} +``` + +### 6.3 Private Spaces (with auth) + +```json +{ + "mcpServers": { + "deepcritical": { + "url": "https://your-space.hf.space/gradio_api/mcp/", + "headers": { + "Authorization": "Bearer hf_xxxxxxxxxxxxx" + } + } + } +} +``` + +--- + +## 7. Verification Commands + +```bash +# 1. Install MCP extras +uv add "gradio[mcp]>=5.0.0" + +# 2. Run unit tests +uv run pytest tests/unit/test_mcp_tools.py -v + +# 3. Run full test suite +make check + +# 4. Start server with MCP +uv run python src/app.py + +# 5. Verify MCP schema (in another terminal) +curl http://localhost:7860/gradio_api/mcp/schema + +# 6. Test with MCP Inspector +npx @anthropic/mcp-inspector http://localhost:7860/gradio_api/mcp/ + +# 7. Integration test (requires running server) +uv run pytest tests/integration/test_mcp_server.py -v -m integration +``` + +--- + +## 8. Definition of Done + +Phase 12 is **COMPLETE** when: + +- [ ] `src/mcp_tools.py` created with all 4 MCP tools +- [ ] `src/app.py` updated with `mcp_server=True` +- [ ] Unit tests in `tests/unit/test_mcp_tools.py` +- [ ] Integration test in `tests/integration/test_mcp_server.py` +- [ ] `pyproject.toml` updated with `gradio[mcp]` +- [ ] MCP schema accessible at `/gradio_api/mcp/schema` +- [ ] Claude Desktop can connect and use tools +- [ ] All unit tests pass +- [ ] Lints pass + +--- + +## 9. Demo Script for Judges + +### Show MCP Integration Works + +1. **Start the server**: + ```bash + uv run python src/app.py + ``` + +2. **Show Claude Desktop using our tools**: + - Open Claude Desktop with DeepCritical MCP configured + - Ask: "Search PubMed for metformin Alzheimer's" + - Show real results appearing + - Ask: "Now search clinical trials for the same" + - Show combined analysis + +3. **Show MCP Inspector**: + ```bash + npx @anthropic/mcp-inspector http://localhost:7860/gradio_api/mcp/ + ``` + - Show all 4 tools listed + - Execute `search_pubmed` from inspector + - Show results + +--- + +## 10. Value Delivered + +| Before | After | +|--------|-------| +| Tools only usable in our app | Tools usable by ANY MCP client | +| Not Track 2 compliant | **FULLY TRACK 2 COMPLIANT** | +| Can't use with Claude Desktop | Full Claude Desktop integration | + +**Prize Impact**: +- Without MCP: **Disqualified from Track 2** +- With MCP: **Eligible for $2,500 1st place** + +--- + +## 11. Files to Create/Modify + +| File | Action | Purpose | +|------|--------|---------| +| `src/mcp_tools.py` | CREATE | MCP tool wrapper functions | +| `src/app.py` | MODIFY | Add `mcp_server=True`, add tool tabs | +| `pyproject.toml` | MODIFY | Add `gradio[mcp]>=5.0.0` | +| `tests/unit/test_mcp_tools.py` | CREATE | Unit tests for MCP tools | +| `tests/integration/test_mcp_server.py` | CREATE | Integration tests | +| `README.md` | MODIFY | Add MCP usage instructions | + +--- + +## 12. Architecture After Phase 12 + +```text +┌────────────────────────────────────────────────────────────────┐ +│ Claude Desktop / Cursor │ +│ (MCP Client) │ +└─────────────────────────────┬──────────────────────────────────┘ + │ MCP Protocol + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ Gradio MCP Server │ +│ /gradio_api/mcp/ │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌─────────┐ │ +│ │search_pubmed │ │search_trials │ │search_biorxiv│ │search_ │ │ +│ │ │ │ │ │ │ │all │ │ +│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ └────┬────┘ │ +└─────────┼────────────────┼────────────────┼──────────────┼──────┘ + │ │ │ │ + ▼ ▼ ▼ ▼ + ┌──────────┐ ┌──────────┐ ┌──────────┐ (calls all) + │PubMedTool│ │Trials │ │BioRxiv │ + │ │ │Tool │ │Tool │ + └──────────┘ └──────────┘ └──────────┘ +``` + +**This is the MCP compliance stack.** diff --git a/docs/implementation/13_phase_modal_integration.md b/docs/implementation/13_phase_modal_integration.md new file mode 100644 index 0000000000000000000000000000000000000000..edb0f7c628f3b7cf1687ab0ad24c188e6ecbfff8 --- /dev/null +++ b/docs/implementation/13_phase_modal_integration.md @@ -0,0 +1,1195 @@ +# Phase 13 Implementation Spec: Modal Pipeline Integration + +**Goal**: Wire existing Modal code execution into the agent pipeline. +**Philosophy**: "Sandboxed execution makes AI-generated code trustworthy." +**Prerequisite**: Phase 12 complete (MCP server working) +**Priority**: P1 - HIGH VALUE ($2,500 Modal Innovation Award) +**Estimated Time**: 2-3 hours + +--- + +## 1. Why Modal Integration? + +### Current State Analysis + +Mario already implemented `src/tools/code_execution.py`: + +| Component | Status | Notes | +|-----------|--------|-------| +| `ModalCodeExecutor` class | Built | Executes Python in Modal sandbox | +| `SANDBOX_LIBRARIES` | Defined | pandas, numpy, scipy, etc. | +| `execute()` method | Implemented | Stdout/stderr capture | +| `execute_with_return()` | Implemented | Returns `result` variable | +| `AnalysisAgent` | Built | Uses Modal for statistical analysis | +| **Pipeline Integration** | **MISSING** | Not wired into main orchestrator | + +### What's Missing + +```text +Current Flow: + User Query → Orchestrator → Search → Judge → [Report] → Done + +With Modal: + User Query → Orchestrator → Search → Judge → [Analysis*] → Report → Done + ↓ + Modal Sandbox Execution +``` + +*The AnalysisAgent exists but is NOT called by either orchestrator. + +--- + +## 2. Critical Dependency Analysis + +### The Problem (Senior Feedback) + +```python +# src/agents/analysis_agent.py - Line 8 +from agent_framework import ( + AgentRunResponse, + BaseAgent, + ... +) +``` + +```toml +# pyproject.toml - agent-framework is OPTIONAL +[project.optional-dependencies] +magentic = [ + "agent-framework-core", +] +``` + +**If we import `AnalysisAgent` in the simple orchestrator without the `magentic` extra installed, the app CRASHES on startup.** + +### The SOLID Solution + +**Single Responsibility Principle**: Decouple Modal execution logic from `agent_framework`. + +```text +BEFORE (Coupled): + AnalysisAgent (requires agent_framework) + ↓ + ModalCodeExecutor + +AFTER (Decoupled): + StatisticalAnalyzer (no agent_framework dependency) ← Simple mode uses this + ↓ + ModalCodeExecutor + ↑ + AnalysisAgent (wraps StatisticalAnalyzer) ← Magentic mode uses this +``` + +**Key insight**: Create `src/services/statistical_analyzer.py` with ZERO agent_framework imports. + +--- + +## 3. Prize Opportunity + +### Modal Innovation Award: $2,500 + +**Judging Criteria**: +1. **Sandbox Isolation** - Code runs in container, not local +2. **Scientific Computing** - Real pandas/scipy analysis +3. **Safety** - Can't access local filesystem +4. **Speed** - Modal's fast cold starts + +### What We Need to Show + +```python +# LLM generates analysis code +code = """ +import pandas as pd +import scipy.stats as stats + +data = pd.DataFrame({ + 'study': ['Study1', 'Study2', 'Study3'], + 'effect_size': [0.45, 0.52, 0.38], + 'sample_size': [120, 85, 200] +}) + +weighted_mean = (data['effect_size'] * data['sample_size']).sum() / data['sample_size'].sum() +t_stat, p_value = stats.ttest_1samp(data['effect_size'], 0) + +print(f"Weighted Effect Size: {weighted_mean:.3f}") +print(f"P-value: {p_value:.4f}") + +result = "SUPPORTED" if p_value < 0.05 else "INCONCLUSIVE" +""" + +# Executed SAFELY in Modal sandbox +executor = get_code_executor() +output = executor.execute(code) # Runs in isolated container! +``` + +--- + +## 4. Technical Specification + +### 4.1 Dependencies + +```toml +# pyproject.toml - NO CHANGES to dependencies +# StatisticalAnalyzer uses only: +# - pydantic-ai (already in main deps) +# - modal (already in main deps) +# - src.tools.code_execution (no agent_framework) +``` + +### 4.2 Environment Variables + +```bash +# .env +MODAL_TOKEN_ID=your-token-id +MODAL_TOKEN_SECRET=your-token-secret +``` + +### 4.3 Integration Points + +| Integration Point | File | Change Required | +|-------------------|------|-----------------| +| New Service | `src/services/statistical_analyzer.py` | CREATE (no agent_framework) | +| Simple Orchestrator | `src/orchestrator.py` | Use `StatisticalAnalyzer` | +| Config | `src/utils/config.py` | Add `enable_modal_analysis` setting | +| AnalysisAgent | `src/agents/analysis_agent.py` | Refactor to wrap `StatisticalAnalyzer` | +| MCP Tool | `src/mcp_tools.py` | Add `analyze_hypothesis` tool | + +--- + +## 5. Implementation + +### 5.1 Configuration Update (`src/utils/config.py`) + +```python +class Settings(BaseSettings): + # ... existing settings ... + + # Modal Configuration + modal_token_id: str | None = None + modal_token_secret: str | None = None + enable_modal_analysis: bool = False # Opt-in for hackathon demo + + @property + def modal_available(self) -> bool: + """Check if Modal credentials are configured.""" + return bool(self.modal_token_id and self.modal_token_secret) +``` + +### 5.2 StatisticalAnalyzer Service (`src/services/statistical_analyzer.py`) + +**This is the key fix - NO agent_framework imports.** + +```python +"""Statistical analysis service using Modal code execution. + +This module provides Modal-based statistical analysis WITHOUT depending on +agent_framework. This allows it to be used in the simple orchestrator mode +without requiring the magentic optional dependency. + +The AnalysisAgent (in src/agents/) wraps this service for magentic mode. +""" + +import asyncio +import re +from functools import partial +from typing import Any + +from pydantic import BaseModel, Field +from pydantic_ai import Agent + +from src.agent_factory.judges import get_model +from src.tools.code_execution import ( + CodeExecutionError, + get_code_executor, + get_sandbox_library_prompt, +) +from src.utils.models import Evidence + + +class AnalysisResult(BaseModel): + """Result of statistical analysis.""" + + verdict: str = Field( + description="SUPPORTED, REFUTED, or INCONCLUSIVE", + ) + confidence: float = Field(ge=0.0, le=1.0, description="Confidence in verdict (0-1)") + statistical_evidence: str = Field( + description="Summary of statistical findings from code execution" + ) + code_generated: str = Field(description="Python code that was executed") + execution_output: str = Field(description="Output from code execution") + key_findings: list[str] = Field(default_factory=list, description="Key takeaways") + limitations: list[str] = Field(default_factory=list, description="Limitations") + + +class StatisticalAnalyzer: + """Performs statistical analysis using Modal code execution. + + This service: + 1. Generates Python code for statistical analysis using LLM + 2. Executes code in Modal sandbox + 3. Interprets results + 4. Returns verdict (SUPPORTED/REFUTED/INCONCLUSIVE) + + Note: This class has NO agent_framework dependency, making it safe + to use in the simple orchestrator without the magentic extra. + """ + + def __init__(self) -> None: + """Initialize the analyzer.""" + self._code_executor: Any = None + self._agent: Agent[None, str] | None = None + + def _get_code_executor(self) -> Any: + """Lazy initialization of code executor.""" + if self._code_executor is None: + self._code_executor = get_code_executor() + return self._code_executor + + def _get_agent(self) -> Agent[None, str]: + """Lazy initialization of LLM agent for code generation.""" + if self._agent is None: + library_versions = get_sandbox_library_prompt() + self._agent = Agent( + model=get_model(), + output_type=str, + system_prompt=f"""You are a biomedical data scientist. + +Generate Python code to analyze research evidence and test hypotheses. + +Guidelines: +1. Use pandas, numpy, scipy.stats for analysis +2. Print clear, interpretable results +3. Include statistical tests (t-tests, chi-square, etc.) +4. Calculate effect sizes and confidence intervals +5. Keep code concise (<50 lines) +6. Set 'result' variable to SUPPORTED, REFUTED, or INCONCLUSIVE + +Available libraries: +{library_versions} + +Output format: Return ONLY executable Python code, no explanations.""", + ) + return self._agent + + async def analyze( + self, + query: str, + evidence: list[Evidence], + hypothesis: dict[str, Any] | None = None, + ) -> AnalysisResult: + """Run statistical analysis on evidence. + + Args: + query: The research question + evidence: List of Evidence objects to analyze + hypothesis: Optional hypothesis dict with drug, target, pathway, effect + + Returns: + AnalysisResult with verdict and statistics + """ + # Build analysis prompt + evidence_summary = self._summarize_evidence(evidence[:10]) + hypothesis_text = "" + if hypothesis: + hypothesis_text = f""" +Hypothesis: {hypothesis.get('drug', 'Unknown')} → {hypothesis.get('target', '?')} → {hypothesis.get('pathway', '?')} → {hypothesis.get('effect', '?')} +Confidence: {hypothesis.get('confidence', 0.5):.0%} +""" + + prompt = f"""Generate Python code to statistically analyze: + +**Research Question**: {query} +{hypothesis_text} + +**Evidence Summary**: +{evidence_summary} + +Generate executable Python code to analyze this evidence.""" + + try: + # Generate code + agent = self._get_agent() + code_result = await agent.run(prompt) + generated_code = code_result.output + + # Execute in Modal sandbox + loop = asyncio.get_running_loop() + executor = self._get_code_executor() + execution = await loop.run_in_executor( + None, partial(executor.execute, generated_code, timeout=120) + ) + + if not execution["success"]: + return AnalysisResult( + verdict="INCONCLUSIVE", + confidence=0.0, + statistical_evidence=f"Execution failed: {execution['error']}", + code_generated=generated_code, + execution_output=execution.get("stderr", ""), + key_findings=[], + limitations=["Code execution failed"], + ) + + # Interpret results + return self._interpret_results(generated_code, execution) + + except CodeExecutionError as e: + return AnalysisResult( + verdict="INCONCLUSIVE", + confidence=0.0, + statistical_evidence=str(e), + code_generated="", + execution_output="", + key_findings=[], + limitations=[f"Analysis error: {e}"], + ) + + def _summarize_evidence(self, evidence: list[Evidence]) -> str: + """Summarize evidence for code generation prompt.""" + if not evidence: + return "No evidence available." + + lines = [] + for i, ev in enumerate(evidence[:5], 1): + lines.append(f"{i}. {ev.content[:200]}...") + lines.append(f" Source: {ev.citation.title}") + lines.append(f" Relevance: {ev.relevance:.0%}\n") + + return "\n".join(lines) + + def _interpret_results( + self, + code: str, + execution: dict[str, Any], + ) -> AnalysisResult: + """Interpret code execution results.""" + stdout = execution["stdout"] + stdout_upper = stdout.upper() + + # Extract verdict with robust word-boundary matching + verdict = "INCONCLUSIVE" + if re.search(r"\bSUPPORTED\b", stdout_upper) and not re.search( + r"\b(?:NOT|UN)SUPPORTED\b", stdout_upper + ): + verdict = "SUPPORTED" + elif re.search(r"\bREFUTED\b", stdout_upper): + verdict = "REFUTED" + + # Extract key findings + key_findings = [] + for line in stdout.split("\n"): + line_lower = line.lower() + if any(kw in line_lower for kw in ["p-value", "significant", "effect", "mean"]): + key_findings.append(line.strip()) + + # Calculate confidence from p-values + confidence = self._calculate_confidence(stdout) + + return AnalysisResult( + verdict=verdict, + confidence=confidence, + statistical_evidence=stdout.strip(), + code_generated=code, + execution_output=stdout, + key_findings=key_findings[:5], + limitations=[ + "Analysis based on summary data only", + "Limited to available evidence", + "Statistical tests assume data independence", + ], + ) + + def _calculate_confidence(self, output: str) -> float: + """Calculate confidence based on statistical results.""" + p_values = re.findall(r"p[-\s]?value[:\s]+(\d+\.?\d*)", output.lower()) + + if p_values: + try: + min_p = min(float(p) for p in p_values) + if min_p < 0.001: + return 0.95 + elif min_p < 0.01: + return 0.90 + elif min_p < 0.05: + return 0.80 + else: + return 0.60 + except ValueError: + pass + + return 0.70 # Default + + +# Singleton for reuse +_analyzer: StatisticalAnalyzer | None = None + + +def get_statistical_analyzer() -> StatisticalAnalyzer: + """Get or create singleton StatisticalAnalyzer instance.""" + global _analyzer + if _analyzer is None: + _analyzer = StatisticalAnalyzer() + return _analyzer +``` + +### 5.3 Simple Orchestrator Update (`src/orchestrator.py`) + +**Uses `StatisticalAnalyzer` directly - NO agent_framework import.** + +```python +"""Main orchestrator with optional Modal analysis.""" + +from src.utils.config import settings + +# ... existing imports ... + + +class Orchestrator: + """Search-Judge-Analyze orchestration loop.""" + + def __init__( + self, + search_handler: SearchHandlerProtocol, + judge_handler: JudgeHandlerProtocol, + config: OrchestratorConfig | None = None, + enable_analysis: bool = False, # New parameter + ) -> None: + self.search = search_handler + self.judge = judge_handler + self.config = config or OrchestratorConfig() + self.history: list[dict[str, Any]] = [] + self._enable_analysis = enable_analysis and settings.modal_available + + # Lazy-load analysis (NO agent_framework dependency!) + self._analyzer: Any = None + + def _get_analyzer(self) -> Any: + """Lazy initialization of StatisticalAnalyzer. + + Note: This imports from src.services, NOT src.agents, + so it works without the magentic optional dependency. + """ + if self._analyzer is None: + from src.services.statistical_analyzer import get_statistical_analyzer + + self._analyzer = get_statistical_analyzer() + return self._analyzer + + async def run(self, query: str) -> AsyncGenerator[AgentEvent, None]: + """Main orchestration loop with optional Modal analysis.""" + # ... existing search/judge loop ... + + # After judge says "synthesize", optionally run analysis + if self._enable_analysis and assessment.recommendation == "synthesize": + yield AgentEvent( + type="analyzing", + message="Running statistical analysis in Modal sandbox...", + data={}, + iteration=iteration, + ) + + try: + analyzer = self._get_analyzer() + + # Run Modal analysis (no agent_framework needed!) + analysis_result = await analyzer.analyze( + query=query, + evidence=all_evidence, + hypothesis=None, # Could add hypothesis generation later + ) + + yield AgentEvent( + type="analysis_complete", + message=f"Analysis verdict: {analysis_result.verdict}", + data=analysis_result.model_dump(), + iteration=iteration, + ) + + except Exception as e: + yield AgentEvent( + type="error", + message=f"Modal analysis failed: {e}", + data={"error": str(e)}, + iteration=iteration, + ) + + # Continue to synthesis... +``` + +### 5.4 Refactor AnalysisAgent (`src/agents/analysis_agent.py`) + +**Wrap `StatisticalAnalyzer` for magentic mode.** + +```python +"""Analysis agent for statistical analysis using Modal code execution. + +This agent wraps StatisticalAnalyzer for use in magentic multi-agent mode. +The core logic is in src/services/statistical_analyzer.py to avoid +coupling agent_framework to the simple orchestrator. +""" + +from collections.abc import AsyncIterable +from typing import TYPE_CHECKING, Any + +from agent_framework import ( + AgentRunResponse, + AgentRunResponseUpdate, + AgentThread, + BaseAgent, + ChatMessage, + Role, +) + +from src.services.statistical_analyzer import ( + AnalysisResult, + get_statistical_analyzer, +) +from src.utils.models import Evidence + +if TYPE_CHECKING: + from src.services.embeddings import EmbeddingService + + +class AnalysisAgent(BaseAgent): # type: ignore[misc] + """Wraps StatisticalAnalyzer for magentic multi-agent mode.""" + + def __init__( + self, + evidence_store: dict[str, Any], + embedding_service: "EmbeddingService | None" = None, + ) -> None: + super().__init__( + name="AnalysisAgent", + description="Performs statistical analysis using Modal sandbox", + ) + self._evidence_store = evidence_store + self._embeddings = embedding_service + self._analyzer = get_statistical_analyzer() + + async def run( + self, + messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None, + *, + thread: AgentThread | None = None, + **kwargs: Any, + ) -> AgentRunResponse: + """Analyze evidence and return verdict.""" + query = self._extract_query(messages) + hypotheses = self._evidence_store.get("hypotheses", []) + evidence = self._evidence_store.get("current", []) + + if not evidence: + return self._error_response("No evidence available.") + + # Get primary hypothesis if available + hypothesis_dict = None + if hypotheses: + h = hypotheses[0] + hypothesis_dict = { + "drug": getattr(h, "drug", "Unknown"), + "target": getattr(h, "target", "?"), + "pathway": getattr(h, "pathway", "?"), + "effect": getattr(h, "effect", "?"), + "confidence": getattr(h, "confidence", 0.5), + } + + # Delegate to StatisticalAnalyzer + result = await self._analyzer.analyze( + query=query, + evidence=evidence, + hypothesis=hypothesis_dict, + ) + + # Store in shared context + self._evidence_store["analysis"] = result.model_dump() + + # Format response + response_text = self._format_response(result) + + return AgentRunResponse( + messages=[ChatMessage(role=Role.ASSISTANT, text=response_text)], + response_id=f"analysis-{result.verdict.lower()}", + additional_properties={"analysis": result.model_dump()}, + ) + + def _format_response(self, result: AnalysisResult) -> str: + """Format analysis result as markdown.""" + lines = [ + "## Statistical Analysis Complete\n", + f"### Verdict: **{result.verdict}**", + f"**Confidence**: {result.confidence:.0%}\n", + "### Key Findings", + ] + for finding in result.key_findings: + lines.append(f"- {finding}") + + lines.extend([ + "\n### Statistical Evidence", + "```", + result.statistical_evidence, + "```", + ]) + return "\n".join(lines) + + def _error_response(self, message: str) -> AgentRunResponse: + """Create error response.""" + return AgentRunResponse( + messages=[ChatMessage(role=Role.ASSISTANT, text=f"**Error**: {message}")], + response_id="analysis-error", + ) + + def _extract_query( + self, messages: str | ChatMessage | list[str] | list[ChatMessage] | None + ) -> str: + """Extract query from messages.""" + if isinstance(messages, str): + return messages + elif isinstance(messages, ChatMessage): + return messages.text or "" + elif isinstance(messages, list): + for msg in reversed(messages): + if isinstance(msg, ChatMessage) and msg.role == Role.USER: + return msg.text or "" + elif isinstance(msg, str): + return msg + return "" + + async def run_stream( + self, + messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None, + *, + thread: AgentThread | None = None, + **kwargs: Any, + ) -> AsyncIterable[AgentRunResponseUpdate]: + """Streaming wrapper.""" + result = await self.run(messages, thread=thread, **kwargs) + yield AgentRunResponseUpdate(messages=result.messages, response_id=result.response_id) +``` + +### 5.5 MCP Tool for Modal Analysis (`src/mcp_tools.py`) + +Add to existing MCP tools: + +```python +async def analyze_hypothesis( + drug: str, + condition: str, + evidence_summary: str, +) -> str: + """Perform statistical analysis of drug repurposing hypothesis using Modal. + + Executes AI-generated Python code in a secure Modal sandbox to analyze + the statistical evidence for a drug repurposing hypothesis. + + Args: + drug: The drug being evaluated (e.g., "metformin") + condition: The target condition (e.g., "Alzheimer's disease") + evidence_summary: Summary of evidence to analyze + + Returns: + Analysis result with verdict (SUPPORTED/REFUTED/INCONCLUSIVE) and statistics + """ + from src.services.statistical_analyzer import get_statistical_analyzer + from src.utils.config import settings + from src.utils.models import Citation, Evidence + + if not settings.modal_available: + return "Error: Modal credentials not configured. Set MODAL_TOKEN_ID and MODAL_TOKEN_SECRET." + + # Create evidence from summary + evidence = [ + Evidence( + content=evidence_summary, + citation=Citation( + source="pubmed", + title=f"Evidence for {drug} in {condition}", + url="https://example.com", + date="2024-01-01", + authors=["User Provided"], + ), + relevance=0.9, + ) + ] + + analyzer = get_statistical_analyzer() + result = await analyzer.analyze( + query=f"Can {drug} treat {condition}?", + evidence=evidence, + hypothesis={"drug": drug, "target": "unknown", "pathway": "unknown", "effect": condition}, + ) + + return f"""## Statistical Analysis: {drug} for {condition} + +### Verdict: **{result.verdict}** +**Confidence**: {result.confidence:.0%} + +### Key Findings +{chr(10).join(f"- {f}" for f in result.key_findings) or "- No specific findings extracted"} + +### Execution Output +``` +{result.execution_output} +``` + +### Generated Code +```python +{result.code_generated} +``` + +**Executed in Modal Sandbox** - Isolated, secure, reproducible. +""" +``` + +### 5.6 Demo Scripts + +#### `examples/modal_demo/verify_sandbox.py` + +```python +#!/usr/bin/env python3 +"""Verify that Modal sandbox is properly isolated. + +This script proves to judges that code runs in Modal, not locally. +NO agent_framework dependency - uses only src.tools.code_execution. + +Usage: + uv run python examples/modal_demo/verify_sandbox.py +""" + +import asyncio +from functools import partial + +from src.tools.code_execution import get_code_executor +from src.utils.config import settings + + +async def main() -> None: + """Verify Modal sandbox isolation.""" + if not settings.modal_available: + print("Error: Modal credentials not configured.") + print("Set MODAL_TOKEN_ID and MODAL_TOKEN_SECRET in .env") + return + + executor = get_code_executor() + loop = asyncio.get_running_loop() + + print("=" * 60) + print("Modal Sandbox Isolation Verification") + print("=" * 60 + "\n") + + # Test 1: Hostname + print("Test 1: Check hostname (should NOT be your machine)") + code1 = "import socket; print(f'Hostname: {socket.gethostname()}')" + result1 = await loop.run_in_executor(None, partial(executor.execute, code1)) + print(f" {result1['stdout'].strip()}\n") + + # Test 2: Scientific libraries + print("Test 2: Verify scientific libraries") + code2 = """ +import pandas as pd +import numpy as np +import scipy +print(f"pandas: {pd.__version__}") +print(f"numpy: {np.__version__}") +print(f"scipy: {scipy.__version__}") +""" + result2 = await loop.run_in_executor(None, partial(executor.execute, code2)) + print(f" {result2['stdout'].strip()}\n") + + # Test 3: Network blocked + print("Test 3: Verify network isolation") + code3 = """ +import urllib.request +try: + urllib.request.urlopen("https://google.com", timeout=2) + print("Network: ALLOWED (unexpected!)") +except Exception: + print("Network: BLOCKED (as expected)") +""" + result3 = await loop.run_in_executor(None, partial(executor.execute, code3)) + print(f" {result3['stdout'].strip()}\n") + + # Test 4: Real statistics + print("Test 4: Execute statistical analysis") + code4 = """ +import pandas as pd +import scipy.stats as stats + +data = pd.DataFrame({'effect': [0.42, 0.38, 0.51]}) +mean = data['effect'].mean() +t_stat, p_val = stats.ttest_1samp(data['effect'], 0) + +print(f"Mean Effect: {mean:.3f}") +print(f"P-value: {p_val:.4f}") +print(f"Verdict: {'SUPPORTED' if p_val < 0.05 else 'INCONCLUSIVE'}") +""" + result4 = await loop.run_in_executor(None, partial(executor.execute, code4)) + print(f" {result4['stdout'].strip()}\n") + + print("=" * 60) + print("All tests complete - Modal sandbox verified!") + print("=" * 60) + + +if __name__ == "__main__": + asyncio.run(main()) +``` + +#### `examples/modal_demo/run_analysis.py` + +```python +#!/usr/bin/env python3 +"""Demo: Modal-powered statistical analysis. + +This script uses StatisticalAnalyzer directly (NO agent_framework dependency). + +Usage: + uv run python examples/modal_demo/run_analysis.py "metformin alzheimer" +""" + +import argparse +import asyncio +import os +import sys + +from src.services.statistical_analyzer import get_statistical_analyzer +from src.tools.pubmed import PubMedTool +from src.utils.config import settings + + +async def main() -> None: + """Run the Modal analysis demo.""" + parser = argparse.ArgumentParser(description="Modal Analysis Demo") + parser.add_argument("query", help="Research query") + args = parser.parse_args() + + if not settings.modal_available: + print("Error: Modal credentials not configured.") + sys.exit(1) + + if not (os.getenv("OPENAI_API_KEY") or os.getenv("ANTHROPIC_API_KEY")): + print("Error: No LLM API key found.") + sys.exit(1) + + print(f"\n{'=' * 60}") + print("DeepCritical Modal Analysis Demo") + print(f"Query: {args.query}") + print(f"{'=' * 60}\n") + + # Step 1: Gather Evidence + print("Step 1: Gathering evidence from PubMed...") + pubmed = PubMedTool() + evidence = await pubmed.search(args.query, max_results=5) + print(f" Found {len(evidence)} papers\n") + + # Step 2: Run Modal Analysis + print("Step 2: Running statistical analysis in Modal sandbox...") + analyzer = get_statistical_analyzer() + result = await analyzer.analyze(query=args.query, evidence=evidence) + + # Step 3: Display Results + print("\n" + "=" * 60) + print("ANALYSIS RESULTS") + print("=" * 60) + print(f"\nVerdict: {result.verdict}") + print(f"Confidence: {result.confidence:.0%}") + print("\nKey Findings:") + for finding in result.key_findings: + print(f" - {finding}") + + print("\n[Demo Complete - Code executed in Modal, not locally]") + + +if __name__ == "__main__": + asyncio.run(main()) +``` + +--- + +## 6. TDD Test Suite + +### 6.1 Unit Tests (`tests/unit/services/test_statistical_analyzer.py`) + +```python +"""Unit tests for StatisticalAnalyzer service.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from src.services.statistical_analyzer import ( + AnalysisResult, + StatisticalAnalyzer, + get_statistical_analyzer, +) +from src.utils.models import Citation, Evidence + + +@pytest.fixture +def sample_evidence() -> list[Evidence]: + """Sample evidence for testing.""" + return [ + Evidence( + content="Metformin shows effect size of 0.45.", + citation=Citation( + source="pubmed", + title="Metformin Study", + url="https://pubmed.ncbi.nlm.nih.gov/12345/", + date="2024-01-15", + authors=["Smith J"], + ), + relevance=0.9, + ) + ] + + +class TestStatisticalAnalyzer: + """Tests for StatisticalAnalyzer (no agent_framework dependency).""" + + def test_no_agent_framework_import(self) -> None: + """StatisticalAnalyzer must NOT import agent_framework.""" + import src.services.statistical_analyzer as module + + # Check module doesn't import agent_framework + source = open(module.__file__).read() + assert "agent_framework" not in source + assert "BaseAgent" not in source + + @pytest.mark.asyncio + async def test_analyze_returns_result( + self, sample_evidence: list[Evidence] + ) -> None: + """analyze() should return AnalysisResult.""" + analyzer = StatisticalAnalyzer() + + with patch.object(analyzer, "_get_agent") as mock_agent, \ + patch.object(analyzer, "_get_code_executor") as mock_executor: + + # Mock LLM + mock_agent.return_value.run = AsyncMock( + return_value=MagicMock(output="print('SUPPORTED')") + ) + + # Mock Modal + mock_executor.return_value.execute.return_value = { + "stdout": "SUPPORTED\np-value: 0.01", + "stderr": "", + "success": True, + } + + result = await analyzer.analyze("test query", sample_evidence) + + assert isinstance(result, AnalysisResult) + assert result.verdict == "SUPPORTED" + + def test_singleton(self) -> None: + """get_statistical_analyzer should return singleton.""" + a1 = get_statistical_analyzer() + a2 = get_statistical_analyzer() + assert a1 is a2 + + +class TestAnalysisResult: + """Tests for AnalysisResult model.""" + + def test_verdict_values(self) -> None: + """Verdict should be one of the expected values.""" + for verdict in ["SUPPORTED", "REFUTED", "INCONCLUSIVE"]: + result = AnalysisResult( + verdict=verdict, + confidence=0.8, + statistical_evidence="test", + code_generated="print('test')", + execution_output="test", + ) + assert result.verdict == verdict + + def test_confidence_bounds(self) -> None: + """Confidence must be 0.0-1.0.""" + with pytest.raises(ValueError): + AnalysisResult( + verdict="SUPPORTED", + confidence=1.5, # Invalid + statistical_evidence="test", + code_generated="test", + execution_output="test", + ) +``` + +### 6.2 Integration Test (`tests/integration/test_modal.py`) + +```python +"""Integration tests for Modal (requires credentials).""" + +import pytest + +from src.utils.config import settings + + +@pytest.mark.integration +@pytest.mark.skipif(not settings.modal_available, reason="Modal not configured") +class TestModalIntegration: + """Integration tests requiring Modal credentials.""" + + @pytest.mark.asyncio + async def test_sandbox_executes_code(self) -> None: + """Modal sandbox should execute Python code.""" + import asyncio + from functools import partial + + from src.tools.code_execution import get_code_executor + + executor = get_code_executor() + code = "import pandas as pd; print(pd.DataFrame({'a': [1,2,3]})['a'].sum())" + + loop = asyncio.get_running_loop() + result = await loop.run_in_executor( + None, partial(executor.execute, code, timeout=30) + ) + + assert result["success"] + assert "6" in result["stdout"] + + @pytest.mark.asyncio + async def test_statistical_analyzer_works(self) -> None: + """StatisticalAnalyzer should work end-to-end.""" + from src.services.statistical_analyzer import get_statistical_analyzer + from src.utils.models import Citation, Evidence + + evidence = [ + Evidence( + content="Drug shows 40% improvement in trial.", + citation=Citation( + source="pubmed", + title="Test", + url="https://test.com", + date="2024-01-01", + authors=["Test"], + ), + relevance=0.9, + ) + ] + + analyzer = get_statistical_analyzer() + result = await analyzer.analyze("test drug efficacy", evidence) + + assert result.verdict in ["SUPPORTED", "REFUTED", "INCONCLUSIVE"] + assert 0.0 <= result.confidence <= 1.0 +``` + +--- + +## 7. Verification Commands + +```bash +# 1. Verify NO agent_framework in StatisticalAnalyzer +grep -r "agent_framework" src/services/statistical_analyzer.py +# Should return nothing! + +# 2. Run unit tests (no Modal needed) +uv run pytest tests/unit/services/test_statistical_analyzer.py -v + +# 3. Run verification script (requires Modal) +uv run python examples/modal_demo/verify_sandbox.py + +# 4. Run analysis demo (requires Modal + LLM) +uv run python examples/modal_demo/run_analysis.py "metformin alzheimer" + +# 5. Run integration tests +uv run pytest tests/integration/test_modal.py -v -m integration + +# 6. Full test suite +make check +``` + +--- + +## 8. Definition of Done + +Phase 13 is **COMPLETE** when: + +- [ ] `src/services/statistical_analyzer.py` created (NO agent_framework) +- [ ] `src/utils/config.py` has `enable_modal_analysis` setting +- [ ] `src/orchestrator.py` uses `StatisticalAnalyzer` directly +- [ ] `src/agents/analysis_agent.py` refactored to wrap `StatisticalAnalyzer` +- [ ] `src/mcp_tools.py` has `analyze_hypothesis` tool +- [ ] `examples/modal_demo/verify_sandbox.py` working +- [ ] `examples/modal_demo/run_analysis.py` working +- [ ] Unit tests pass WITHOUT magentic extra installed +- [ ] Integration tests pass WITH Modal credentials +- [ ] All lints pass + +--- + +## 9. Architecture After Phase 13 + +```text +┌─────────────────────────────────────────────────────────────────┐ +│ MCP Clients │ +│ (Claude Desktop, Cursor, etc.) │ +└───────────────────────────┬─────────────────────────────────────┘ + │ MCP Protocol + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ Gradio App + MCP Server │ +│ ┌──────────────────────────────────────────────────────────┐ │ +│ │ MCP Tools: search_pubmed, search_trials, search_biorxiv │ │ +│ │ search_all, analyze_hypothesis │ │ +│ └──────────────────────────────────────────────────────────┘ │ +└───────────────────────────┬─────────────────────────────────────┘ + │ + ┌───────────────────┴───────────────────┐ + │ │ + ▼ ▼ +┌───────────────────────┐ ┌───────────────────────────┐ +│ Simple Orchestrator │ │ Magentic Orchestrator │ +│ (no agent_framework) │ │ (with agent_framework) │ +│ │ │ │ +│ SearchHandler │ │ SearchAgent │ +│ JudgeHandler │ │ JudgeAgent │ +│ StatisticalAnalyzer ─┼────────────┼→ AnalysisAgent ───────────┤ +│ │ │ (wraps StatisticalAnalyzer) +└───────────┬───────────┘ └───────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────────────┐ +│ StatisticalAnalyzer │ +│ (src/services/statistical_analyzer.py) │ +│ NO agent_framework dependency │ +│ │ +│ 1. Generate code with pydantic-ai │ +│ 2. Execute in Modal sandbox │ +│ 3. Return AnalysisResult │ +└───────────────────────────┬──────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ Modal Sandbox │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ - pandas, numpy, scipy, sklearn, statsmodels │ │ +│ │ - Network: BLOCKED │ │ +│ │ - Filesystem: ISOLATED │ │ +│ │ - Timeout: ENFORCED │ │ +│ └─────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +**This is the dependency-safe Modal stack.** + +--- + +## 10. Files Summary + +| File | Action | Purpose | +|------|--------|---------| +| `src/services/statistical_analyzer.py` | **CREATE** | Core analysis (no agent_framework) | +| `src/utils/config.py` | MODIFY | Add `enable_modal_analysis` | +| `src/orchestrator.py` | MODIFY | Use `StatisticalAnalyzer` | +| `src/agents/analysis_agent.py` | MODIFY | Wrap `StatisticalAnalyzer` | +| `src/mcp_tools.py` | MODIFY | Add `analyze_hypothesis` | +| `examples/modal_demo/verify_sandbox.py` | CREATE | Sandbox verification | +| `examples/modal_demo/run_analysis.py` | CREATE | Demo script | +| `tests/unit/services/test_statistical_analyzer.py` | CREATE | Unit tests | +| `tests/integration/test_modal.py` | CREATE | Integration tests | + +**Key Fix**: `StatisticalAnalyzer` has ZERO agent_framework imports, making it safe for the simple orchestrator. diff --git a/docs/implementation/14_phase_demo_submission.md b/docs/implementation/14_phase_demo_submission.md new file mode 100644 index 0000000000000000000000000000000000000000..3dee9bc235fbe58e5aea4e0b48135bf4b08d4da5 --- /dev/null +++ b/docs/implementation/14_phase_demo_submission.md @@ -0,0 +1,464 @@ +# Phase 14 Implementation Spec: Demo Video & Hackathon Submission + +**Goal**: Create compelling demo video and complete hackathon submission. +**Philosophy**: "Ship it with style." +**Prerequisite**: Phases 12-13 complete (MCP + Modal working) +**Priority**: P0 - REQUIRED FOR SUBMISSION +**Deadline**: November 30, 2025 11:59 PM UTC +**Estimated Time**: 2-3 hours + +--- + +## 1. Submission Requirements + +### MCP's 1st Birthday Hackathon Checklist + +| Requirement | Status | Action | +|-------------|--------|--------| +| HuggingFace Space in `MCP-1st-Birthday` org | Pending | Transfer or create | +| Track tag in README.md | Pending | Add tag | +| Social media post link | Pending | Create post | +| Demo video (1-5 min) | Pending | Record | +| Team members registered | Pending | Verify | +| Original work (Nov 14-30) | **DONE** | All commits in range | + +### Track 2: MCP in Action - Tags + +```yaml +# Add to HuggingFace Space README.md +tags: + - mcp-in-action-track-enterprise # Healthcare/enterprise focus +``` + +--- + +## 2. Prize Eligibility Summary + +### After Phases 12-13 + +| Award | Amount | Eligible | Requirements Met | +|-------|--------|----------|------------------| +| Track 2: MCP in Action (1st) | $2,500 | **YES** | MCP server working | +| Modal Innovation | $2,500 | **YES** | Sandbox demo ready | +| LlamaIndex | $1,000 | **YES** | Using RAG | +| Community Choice | $1,000 | Possible | Need great demo | +| **Total Potential** | **$7,000** | | | + +--- + +## 3. Demo Video Specification + +### 3.1 Duration & Format + +- **Length**: 3-4 minutes (sweet spot) +- **Format**: Screen recording + voice-over +- **Resolution**: 1080p minimum +- **Audio**: Clear narration, no background music + +### 3.2 Recommended Tools + +| Tool | Purpose | Notes | +|------|---------|-------| +| OBS Studio | Screen recording | Free, cross-platform | +| Loom | Quick recording | Good for demos | +| QuickTime | Mac screen recording | Built-in | +| DaVinci Resolve | Editing | Free, professional | + +### 3.3 Demo Script (4 minutes) + +```markdown +## Section 1: Hook (30 seconds) + +[Show Gradio UI] + +"DeepCritical is an AI-powered drug repurposing research agent. +It searches peer-reviewed literature, clinical trials, and cutting-edge preprints +to find new uses for existing drugs." + +"Let me show you how it works." + +--- + +## Section 2: Core Functionality (60 seconds) + +[Type query: "Can metformin treat Alzheimer's disease?"] + +"When I ask about metformin for Alzheimer's, DeepCritical: +1. Searches PubMed for peer-reviewed papers +2. Queries ClinicalTrials.gov for active trials +3. Scans bioRxiv for the latest preprints" + +[Show search results streaming] + +"It then uses an LLM to assess the evidence quality and +synthesize findings into a structured research report." + +[Show final report] + +--- + +## Section 3: MCP Integration (60 seconds) + +[Switch to Claude Desktop] + +"What makes DeepCritical unique is full MCP integration. +These same tools are available to any MCP client." + +[Show Claude Desktop with DeepCritical tools] + +"I can ask Claude: 'Search PubMed for aspirin cancer prevention'" + +[Show results appearing in Claude Desktop] + +"The agent uses our MCP server to search real biomedical databases." + +[Show MCP Inspector briefly] + +"Here's the MCP schema - four tools exposed for any AI to use." + +--- + +## Section 4: Modal Innovation (45 seconds) + +[Run verify_sandbox.py] + +"For statistical analysis, we use Modal for secure code execution." + +[Show sandbox verification output] + +"Notice the hostname is NOT my machine - code runs in an isolated container. +Network is blocked. The AI can't reach the internet from the sandbox." + +[Run analysis demo] + +"Modal executes LLM-generated statistical code safely, +returning verdicts like SUPPORTED, REFUTED, or INCONCLUSIVE." + +--- + +## Section 5: Close (45 seconds) + +[Return to Gradio UI] + +"DeepCritical brings together: +- Three biomedical data sources +- MCP protocol for universal tool access +- Modal sandboxes for safe code execution +- LlamaIndex for semantic search + +All in a beautiful Gradio interface." + +"Check out the code on GitHub, try it on HuggingFace Spaces, +and let us know what you think." + +"Thanks for watching!" + +[Show links: GitHub, HuggingFace, Team names] +``` + +--- + +## 4. HuggingFace Space Configuration + +### 4.1 Space README.md + +```markdown +--- +title: DeepCritical +emoji: 🧬 +colorFrom: blue +colorTo: purple +sdk: gradio +sdk_version: "5.0.0" +app_file: src/app.py +pinned: false +license: mit +tags: + - mcp-in-action-track-enterprise + - mcp-hackathon + - drug-repurposing + - biomedical-ai + - pydantic-ai + - llamaindex + - modal +--- + +# DeepCritical + +AI-Powered Drug Repurposing Research Agent + +## Features + +- **Multi-Source Search**: PubMed, ClinicalTrials.gov, bioRxiv/medRxiv +- **MCP Integration**: Use our tools from Claude Desktop or any MCP client +- **Modal Sandbox**: Secure execution of AI-generated statistical code +- **LlamaIndex RAG**: Semantic search and evidence synthesis + +## MCP Tools + +Connect to our MCP server at: +``` +https://MCP-1st-Birthday-deepcritical.hf.space/gradio_api/mcp/ +``` + +Available tools: +- `search_pubmed` - Search peer-reviewed biomedical literature +- `search_clinical_trials` - Search ClinicalTrials.gov +- `search_biorxiv` - Search bioRxiv/medRxiv preprints +- `search_all` - Search all sources simultaneously + +## Team + +- The-Obstacle-Is-The-Way +- MarioAderman + +## Links + +- [GitHub Repository](https://github.com/The-Obstacle-Is-The-Way/DeepCritical-1) +- [Demo Video](link-to-video) +``` + +### 4.2 Environment Variables (Secrets) + +Set in HuggingFace Space settings: + +``` +OPENAI_API_KEY=sk-... +ANTHROPIC_API_KEY=sk-ant-... +NCBI_API_KEY=... +MODAL_TOKEN_ID=... +MODAL_TOKEN_SECRET=... +``` + +--- + +## 5. Social Media Post + +### Twitter/X Template + +``` +🧬 Excited to submit DeepCritical to MCP's 1st Birthday Hackathon! + +An AI agent that: +✅ Searches PubMed, ClinicalTrials.gov & bioRxiv +✅ Exposes tools via MCP protocol +✅ Runs statistical code in Modal sandboxes +✅ Uses LlamaIndex for semantic search + +Try it: [HuggingFace link] +Demo: [Video link] + +#MCPHackathon #AIAgents #DrugRepurposing @huggingface @AnthropicAI +``` + +### LinkedIn Template + +``` +Thrilled to share DeepCritical - our submission to MCP's 1st Birthday Hackathon! + +🔬 What it does: +DeepCritical is an AI-powered drug repurposing research agent that searches +peer-reviewed literature, clinical trials, and preprints to find new uses +for existing drugs. + +🛠️ Technical highlights: +• Full MCP integration - tools work with Claude Desktop +• Modal sandboxes for secure AI-generated code execution +• LlamaIndex RAG for semantic evidence search +• Three biomedical data sources in parallel + +Built with PydanticAI, Gradio, and deployed on HuggingFace Spaces. + +Try it: [link] +Watch the demo: [link] + +#ArtificialIntelligence #Healthcare #DrugDiscovery #MCP #Hackathon +``` + +--- + +## 6. Pre-Submission Checklist + +### 6.1 Code Quality + +```bash +# Run all checks +make check + +# Expected output: +# ✅ Linting passed (ruff) +# ✅ Type checking passed (mypy) +# ✅ All 80+ tests passed (pytest) +``` + +### 6.2 Documentation + +- [ ] README.md updated with MCP instructions +- [ ] All demo scripts have docstrings +- [ ] Example files work end-to-end +- [ ] CLAUDE.md is current + +### 6.3 Deployment Verification + +```bash +# Test locally +uv run python src/app.py +# Visit http://localhost:7860 + +# Test MCP schema +curl http://localhost:7860/gradio_api/mcp/schema + +# Test Modal (if configured) +uv run python examples/modal_demo/verify_sandbox.py +``` + +### 6.4 HuggingFace Space + +- [ ] Space created in `MCP-1st-Birthday` organization +- [ ] Secrets configured (API keys) +- [ ] App starts without errors +- [ ] MCP endpoint accessible +- [ ] Track tag in README + +--- + +## 7. Recording Checklist + +### Before Recording + +- [ ] Close unnecessary apps/notifications +- [ ] Clear browser history/tabs +- [ ] Test all demos work +- [ ] Prepare terminal windows +- [ ] Write down talking points + +### During Recording + +- [ ] Speak clearly and at moderate pace +- [ ] Pause briefly between sections +- [ ] Show your face? (optional, adds personality) +- [ ] Don't rush - 3-4 min is enough time + +### After Recording + +- [ ] Watch playback for errors +- [ ] Trim dead air at start/end +- [ ] Add title/end cards +- [ ] Export at 1080p +- [ ] Upload to YouTube/Loom + +--- + +## 8. Submission Steps + +### Step 1: Finalize Code + +```bash +# Ensure clean state +git status +make check + +# Push to GitHub +git push origin main + +# Sync to HuggingFace +git push huggingface-upstream main +``` + +### Step 2: Verify HuggingFace Space + +1. Visit Space URL +2. Test the chat interface +3. Test MCP endpoint: `/gradio_api/mcp/schema` +4. Verify README has track tag + +### Step 3: Record Demo Video + +1. Follow script from Section 3.3 +2. Edit and export +3. Upload to YouTube (unlisted) or Loom +4. Copy shareable link + +### Step 4: Create Social Post + +1. Write post (see templates) +2. Include video link +3. Tag relevant accounts +4. Post and copy link + +### Step 5: Submit + +1. Ensure Space is in `MCP-1st-Birthday` org +2. Verify track tag in README +3. Submit entry (check hackathon page for form) +4. Include all links + +--- + +## 9. Verification Commands + +```bash +# 1. Full test suite +make check + +# 2. Start local server +uv run python src/app.py + +# 3. Verify MCP works +curl http://localhost:7860/gradio_api/mcp/schema | jq + +# 4. Test with MCP Inspector +npx @anthropic/mcp-inspector http://localhost:7860/gradio_api/mcp/ + +# 5. Run Modal verification +uv run python examples/modal_demo/verify_sandbox.py + +# 6. Run full demo +uv run python examples/orchestrator_demo/run_agent.py "metformin alzheimer" +``` + +--- + +## 10. Definition of Done + +Phase 14 is **COMPLETE** when: + +- [ ] Demo video recorded (3-4 min) +- [ ] Video uploaded (YouTube/Loom) +- [ ] Social media post created with link +- [ ] HuggingFace Space in `MCP-1st-Birthday` org +- [ ] Track tag in Space README +- [ ] All team members registered +- [ ] Entry submitted before deadline +- [ ] Confirmation received + +--- + +## 11. Timeline + +| Task | Time | Deadline | +|------|------|----------| +| Phase 12: MCP Server | 2-3 hours | Nov 28 | +| Phase 13: Modal Integration | 2-3 hours | Nov 29 | +| Phase 14: Demo & Submit | 2-3 hours | Nov 30 | +| **Buffer** | ~24 hours | Before 11:59 PM UTC | + +--- + +## 12. Contact & Support + +### Hackathon Resources + +- Discord: `#agents-mcp-hackathon-winter25` +- HuggingFace: [MCP-1st-Birthday org](https://huggingface.co/MCP-1st-Birthday) +- MCP Docs: [modelcontextprotocol.io](https://modelcontextprotocol.io/) + +### Team Communication + +- Coordinate on final review +- Agree on who submits +- Celebrate when done! 🎉 + +--- + +**Good luck! Ship it with confidence.** diff --git a/docs/implementation/roadmap.md b/docs/implementation/roadmap.md new file mode 100644 index 0000000000000000000000000000000000000000..1f4862e9ee898881d04dbecd8c27b8bc4848fd61 --- /dev/null +++ b/docs/implementation/roadmap.md @@ -0,0 +1,247 @@ +# Implementation Roadmap: DeepCritical (Vertical Slices) + +**Philosophy:** AI-Native Engineering, Vertical Slice Architecture, TDD, Modern Tooling (2025). + +This roadmap defines the execution strategy to deliver **DeepCritical** effectively. We reject "overplanning" in favor of **ironclad, testable vertical slices**. Each phase delivers a fully functional slice of end-to-end value. + +--- + +## The 2025 "Gucci" Tooling Stack + +We are using the bleeding edge of Python engineering to ensure speed, safety, and developer joy. + +| Category | Tool | Why? | +|----------|------|------| +| **Package Manager** | **`uv`** | Rust-based, 10-100x faster than pip/poetry. Manages python versions, venvs, and deps. | +| **Linting/Format** | **`ruff`** | Rust-based, instant. Replaces black, isort, flake8. | +| **Type Checking** | **`mypy`** | Strict static typing. Run via `uv run mypy`. | +| **Testing** | **`pytest`** | The standard. | +| **Test Plugins** | **`pytest-sugar`** | Instant feedback, progress bars. "Gucci" visuals. | +| **Test Plugins** | **`pytest-asyncio`** | Essential for our async agent loop. | +| **Test Plugins** | **`pytest-cov`** | Coverage reporting to ensure TDD adherence. | +| **Git Hooks** | **`pre-commit`** | Enforce ruff/mypy before commit. | + +--- + +## Architecture: Vertical Slices + +Instead of horizontal layers (e.g., "Building the Database Layer"), we build **Vertical Slices**. +Each slice implements a feature from **Entry Point (UI/API) -> Logic -> Data/External**. + +### Directory Structure (Maintainer's Structure) + +```bash +src/ +├── app.py # Entry point (Gradio UI) +├── orchestrator.py # Agent loop (Search -> Judge -> Loop) +├── agent_factory/ # Agent creation and judges +│ ├── __init__.py +│ ├── agents.py # PydanticAI agent definitions +│ └── judges.py # JudgeHandler for evidence assessment +├── tools/ # Search tools +│ ├── __init__.py +│ ├── pubmed.py # PubMed E-utilities tool +│ ├── clinicaltrials.py # ClinicalTrials.gov API +│ ├── biorxiv.py # bioRxiv/medRxiv preprints +│ ├── code_execution.py # Modal sandbox execution +│ └── search_handler.py # Orchestrates multiple tools +├── prompts/ # Prompt templates +│ ├── __init__.py +│ └── judge.py # Judge prompts +├── utils/ # Shared utilities +│ ├── __init__.py +│ ├── config.py # Settings/configuration +│ ├── exceptions.py # Custom exceptions +│ ├── models.py # Shared Pydantic models +│ ├── dataloaders.py # Data loading utilities +│ └── parsers.py # Parsing utilities +├── middleware/ # (Future: middleware components) +├── database_services/ # (Future: database integrations) +└── retrieval_factory/ # (Future: RAG components) + +tests/ +├── unit/ +│ ├── tools/ +│ │ ├── test_pubmed.py +│ │ ├── test_clinicaltrials.py +│ │ ├── test_biorxiv.py +│ │ └── test_search_handler.py +│ ├── agent_factory/ +│ │ └── test_judges.py +│ └── test_orchestrator.py +└── integration/ + └── test_pubmed_live.py +``` + +--- + +## Phased Execution Plan + +### **Phase 1: Foundation & Tooling (Day 1)** + +*Goal: A rock-solid, CI-ready environment with `uv` and `pytest` configured.* + +- [ ] Initialize `pyproject.toml` with `uv`. +- [ ] Configure `ruff` (strict) and `mypy` (strict). +- [ ] Set up `pytest` with sugar and coverage. +- [ ] Implement `src/utils/config.py` (Configuration Slice). +- [ ] Implement `src/utils/exceptions.py` (Custom exceptions). +- **Deliverable**: A repo that passes CI with `uv run pytest`. + +### **Phase 2: The "Search" Vertical Slice (Day 2)** + +*Goal: Agent can receive a query and get raw results from PubMed/Web.* + +- [ ] **TDD**: Write test for `SearchHandler`. +- [ ] Implement `src/tools/pubmed.py` (PubMed E-utilities). +- [ ] Implement `src/tools/websearch.py` (DuckDuckGo). +- [ ] Implement `src/tools/search_handler.py` (Orchestrates tools). +- [ ] Implement `src/utils/models.py` (Evidence, Citation, SearchResult). +- **Deliverable**: Function that takes "long covid" -> returns `List[Evidence]`. + +### **Phase 3: The "Judge" Vertical Slice (Day 3)** + +*Goal: Agent can decide if evidence is sufficient.* + +- [ ] **TDD**: Write test for `JudgeHandler` (Mocked LLM). +- [ ] Implement `src/prompts/judge.py` (Structured outputs). +- [ ] Implement `src/agent_factory/judges.py` (LLM interaction). +- **Deliverable**: Function that takes `List[Evidence]` -> returns `JudgeAssessment`. + +### **Phase 4: The "Loop" & UI Slice (Day 4)** + +*Goal: End-to-End User Value.* + +- [ ] Implement `src/orchestrator.py` (Connects Search + Judge loops). +- [ ] Build `src/app.py` (Gradio with Streaming). +- **Deliverable**: Working DeepCritical Agent on HuggingFace. + +--- + +### **Phase 5: Magentic Integration** ✅ COMPLETE + +*Goal: Upgrade orchestrator to use Microsoft Agent Framework patterns.* + +- [x] Wrap SearchHandler as `AgentProtocol` (SearchAgent) with strict protocol compliance. +- [x] Wrap JudgeHandler as `AgentProtocol` (JudgeAgent) with strict protocol compliance. +- [x] Implement `MagenticOrchestrator` using `MagenticBuilder`. +- [x] Create factory pattern for switching implementations. +- **Deliverable**: Same API, better multi-agent orchestration engine. + +--- + +### **Phase 6: Embeddings & Semantic Search** + +*Goal: Add vector search for semantic evidence retrieval.* + +- [ ] Implement `EmbeddingService` with ChromaDB. +- [ ] Add semantic deduplication to SearchAgent. +- [ ] Enable semantic search for related evidence. +- [ ] Store embeddings in shared context. +- **Deliverable**: Find semantically related papers, not just keyword matches. + +--- + +### **Phase 7: Hypothesis Agent** + +*Goal: Generate scientific hypotheses to guide targeted searches.* + +- [ ] Implement `MechanismHypothesis` and `HypothesisAssessment` models. +- [ ] Implement `HypothesisAgent` for mechanistic reasoning. +- [ ] Add hypothesis-driven search queries. +- [ ] Integrate into Magentic workflow. +- **Deliverable**: Drug → Target → Pathway → Effect hypotheses that guide research. + +--- + +### **Phase 8: Report Agent** + +*Goal: Generate structured scientific reports with proper citations.* + +- [ ] Implement `ResearchReport` model with all sections. +- [ ] Implement `ReportAgent` for synthesis. +- [ ] Include methodology, limitations, formatted references. +- [ ] Integrate as final synthesis step in Magentic workflow. +- **Deliverable**: Publication-quality research reports. + +--- + +## Complete Architecture (Phases 1-8) + +```text +User Query + ↓ +Gradio UI (Phase 4) + ↓ +Magentic Manager (Phase 5) + ├── SearchAgent (Phase 2+5) ←→ PubMed + Web + VectorDB (Phase 6) + ├── HypothesisAgent (Phase 7) ←→ Mechanistic Reasoning + ├── JudgeAgent (Phase 3+5) ←→ Evidence Assessment + └── ReportAgent (Phase 8) ←→ Final Synthesis + ↓ +Structured Research Report +``` + +--- + +## Spec Documents + +### Core Platform (Phases 1-8) + +1. **[Phase 1 Spec: Foundation](01_phase_foundation.md)** ✅ +2. **[Phase 2 Spec: Search Slice](02_phase_search.md)** ✅ +3. **[Phase 3 Spec: Judge Slice](03_phase_judge.md)** ✅ +4. **[Phase 4 Spec: UI & Loop](04_phase_ui.md)** ✅ +5. **[Phase 5 Spec: Magentic Integration](05_phase_magentic.md)** ✅ +6. **[Phase 6 Spec: Embeddings & Semantic Search](06_phase_embeddings.md)** ✅ +7. **[Phase 7 Spec: Hypothesis Agent](07_phase_hypothesis.md)** ✅ +8. **[Phase 8 Spec: Report Agent](08_phase_report.md)** ✅ + +### Multi-Source Search (Phases 9-11) + +9. **[Phase 9 Spec: Remove DuckDuckGo](09_phase_source_cleanup.md)** ✅ +10. **[Phase 10 Spec: ClinicalTrials.gov](10_phase_clinicaltrials.md)** ✅ +11. **[Phase 11 Spec: bioRxiv Preprints](11_phase_biorxiv.md)** ✅ + +### Hackathon Integration (Phases 12-14) + +12. **[Phase 12 Spec: MCP Server](12_phase_mcp_server.md)** ✅ COMPLETE +13. **[Phase 13 Spec: Modal Pipeline](13_phase_modal_integration.md)** 📝 P1 - $2,500 +14. **[Phase 14 Spec: Demo & Submission](14_phase_demo_submission.md)** 📝 P0 - REQUIRED + +--- + +## Progress Summary + +| Phase | Status | Deliverable | +|-------|--------|-------------| +| Phase 1: Foundation | ✅ COMPLETE | CI-ready repo with uv/pytest | +| Phase 2: Search | ✅ COMPLETE | PubMed + Web search | +| Phase 3: Judge | ✅ COMPLETE | LLM evidence assessment | +| Phase 4: UI & Loop | ✅ COMPLETE | Working Gradio app | +| Phase 5: Magentic | ✅ COMPLETE | Multi-agent orchestration | +| Phase 6: Embeddings | ✅ COMPLETE | Semantic search + ChromaDB | +| Phase 7: Hypothesis | ✅ COMPLETE | Mechanistic reasoning chains | +| Phase 8: Report | ✅ COMPLETE | Structured scientific reports | +| Phase 9: Source Cleanup | ✅ COMPLETE | Remove DuckDuckGo | +| Phase 10: ClinicalTrials | ✅ COMPLETE | ClinicalTrials.gov API | +| Phase 11: bioRxiv | ✅ COMPLETE | Preprint search | +| Phase 12: MCP Server | ✅ COMPLETE | MCP protocol integration | +| Phase 13: Modal Pipeline | 📝 SPEC READY | Sandboxed code execution | +| Phase 14: Demo & Submit | 📝 SPEC READY | Hackathon submission | + +*Phases 1-12 COMPLETE. Phases 13-14 for hackathon prizes.* + +--- + +## Hackathon Prize Potential + +| Award | Amount | Requirement | Phase | +|-------|--------|-------------|-------| +| Track 2: MCP in Action (1st) | $2,500 | MCP server working | 12 | +| Modal Innovation | $2,500 | Sandbox demo ready | 13 | +| LlamaIndex | $1,000 | Using RAG | ✅ Done | +| Community Choice | $1,000 | Great demo video | 14 | +| **Total Potential** | **$7,000** | | | + +**Deadline: November 30, 2025 11:59 PM UTC** diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000000000000000000000000000000000000..400ddfa44d974f61407c1754bfe57e5d6dfedace --- /dev/null +++ b/docs/index.md @@ -0,0 +1,92 @@ +# DeepCritical Documentation + +## Medical Drug Repurposing Research Agent + +AI-powered deep research system for accelerating drug repurposing discovery. + +--- + +## Quick Links + +### Architecture +- **[Overview](architecture/overview.md)** - Project overview, use case, architecture +- **[Design Patterns](architecture/design-patterns.md)** - Technical patterns, data models + +### Implementation +- **[Roadmap](implementation/roadmap.md)** - Phased execution plan with TDD +- **[Phase 1: Foundation](implementation/01_phase_foundation.md)** ✅ - Tooling, config, first tests +- **[Phase 2: Search](implementation/02_phase_search.md)** ✅ - PubMed search +- **[Phase 3: Judge](implementation/03_phase_judge.md)** ✅ - LLM evidence assessment +- **[Phase 4: UI](implementation/04_phase_ui.md)** ✅ - Orchestrator + Gradio +- **[Phase 5: Magentic](implementation/05_phase_magentic.md)** ✅ - Multi-agent orchestration +- **[Phase 6: Embeddings](implementation/06_phase_embeddings.md)** ✅ - Semantic search + dedup +- **[Phase 7: Hypothesis](implementation/07_phase_hypothesis.md)** ✅ - Mechanistic reasoning +- **[Phase 8: Report](implementation/08_phase_report.md)** ✅ - Structured scientific reports +- **[Phase 9: Source Cleanup](implementation/09_phase_source_cleanup.md)** ✅ - Remove DuckDuckGo +- **[Phase 10: ClinicalTrials](implementation/10_phase_clinicaltrials.md)** ✅ - Clinical trials API +- **[Phase 11: bioRxiv](implementation/11_phase_biorxiv.md)** ✅ - Preprint search +- **[Phase 12: MCP Server](implementation/12_phase_mcp_server.md)** ✅ - Claude Desktop integration +- **[Phase 13: Modal Integration](implementation/13_phase_modal_integration.md)** ✅ - Secure code execution +- **[Phase 14: Demo Submission](implementation/14_phase_demo_submission.md)** ✅ - Hackathon submission + +### Guides +- **[Deployment Guide](guides/deployment.md)** - Gradio, MCP, and Modal launch steps + +### Development +- **[Testing Strategy](development/testing.md)** - Unit, Integration, and E2E testing patterns + +--- + +## What We're Building + +**One-liner**: AI agent that searches medical literature to find existing drugs that might treat new diseases. + +**Example Query**: +> "What existing drugs might help treat long COVID fatigue?" + +**Output**: Research report with drug candidates, mechanisms, evidence quality, and citations. + +--- + +## Architecture Summary + +``` +User Question → Research Agent (Orchestrator) + ↓ + Search Loop: + → Tools (PubMed, ClinicalTrials, bioRxiv) + → Judge (Quality + Budget) + → Repeat or Synthesize + ↓ + Research Report with Citations +``` + +--- + +## Features + +| Feature | Status | Description | +|---------|--------|-------------| +| **Gradio UI** | ✅ Complete | Streaming chat interface | +| **MCP Server** | ✅ Complete | Tools accessible from Claude Desktop | +| **Modal Sandbox** | ✅ Complete | Secure statistical analysis | +| **Multi-Source Search** | ✅ Complete | PubMed, ClinicalTrials, bioRxiv | + +--- + +## Team + +- The-Obstacle-Is-The-Way +- MarioAderman +- Josephrp + +--- + +## Status + +| Phase | Status | +|-------|--------| +| Phases 1-14 | ✅ COMPLETE | + +**Test Coverage**: 65% (96 tests passing) +**Architecture Review**: PASSED (98-99/100) diff --git a/docs/workflow-diagrams.md b/docs/workflow-diagrams.md new file mode 100644 index 0000000000000000000000000000000000000000..509a0985e70e07fc1829eecaa7fae34ff74462c2 --- /dev/null +++ b/docs/workflow-diagrams.md @@ -0,0 +1,662 @@ +# DeepCritical Workflow - Simplified Magentic Architecture + +> **Architecture Pattern**: Microsoft Magentic Orchestration +> **Design Philosophy**: Simple, dynamic, manager-driven coordination +> **Key Innovation**: Intelligent manager replaces rigid sequential phases + +--- + +## 1. High-Level Magentic Workflow + +```mermaid +flowchart TD + Start([User Query]) --> Manager[Magentic Manager
Plan • Select • Assess • Adapt] + + Manager -->|Plans| Task1[Task Decomposition] + Task1 --> Manager + + Manager -->|Selects & Executes| HypAgent[Hypothesis Agent] + Manager -->|Selects & Executes| SearchAgent[Search Agent] + Manager -->|Selects & Executes| AnalysisAgent[Analysis Agent] + Manager -->|Selects & Executes| ReportAgent[Report Agent] + + HypAgent -->|Results| Manager + SearchAgent -->|Results| Manager + AnalysisAgent -->|Results| Manager + ReportAgent -->|Results| Manager + + Manager -->|Assesses Quality| Decision{Good Enough?} + Decision -->|No - Refine| Manager + Decision -->|No - Different Agent| Manager + Decision -->|No - Stalled| Replan[Reset Plan] + Replan --> Manager + + Decision -->|Yes| Synthesis[Synthesize Final Result] + Synthesis --> Output([Research Report]) + + style Start fill:#e1f5e1 + style Manager fill:#ffe6e6 + style HypAgent fill:#fff4e6 + style SearchAgent fill:#fff4e6 + style AnalysisAgent fill:#fff4e6 + style ReportAgent fill:#fff4e6 + style Decision fill:#ffd6d6 + style Synthesis fill:#d4edda + style Output fill:#e1f5e1 +``` + +## 2. Magentic Manager: The 6-Phase Cycle + +```mermaid +flowchart LR + P1[1. Planning
Analyze task
Create strategy] --> P2[2. Agent Selection
Pick best agent
for subtask] + P2 --> P3[3. Execution
Run selected
agent with tools] + P3 --> P4[4. Assessment
Evaluate quality
Check progress] + P4 --> Decision{Quality OK?
Progress made?} + Decision -->|Yes| P6[6. Synthesis
Combine results
Generate report] + Decision -->|No| P5[5. Iteration
Adjust plan
Try again] + P5 --> P2 + P6 --> Done([Complete]) + + style P1 fill:#fff4e6 + style P2 fill:#ffe6e6 + style P3 fill:#e6f3ff + style P4 fill:#ffd6d6 + style P5 fill:#fff3cd + style P6 fill:#d4edda + style Done fill:#e1f5e1 +``` + +## 3. Simplified Agent Architecture + +```mermaid +graph TB + subgraph "Orchestration Layer" + Manager[Magentic Manager
• Plans workflow
• Selects agents
• Assesses quality
• Adapts strategy] + SharedContext[(Shared Context
• Hypotheses
• Search Results
• Analysis
• Progress)] + Manager <--> SharedContext + end + + subgraph "Specialist Agents" + HypAgent[Hypothesis Agent
• Domain understanding
• Hypothesis generation
• Testability refinement] + SearchAgent[Search Agent
• Multi-source search
• RAG retrieval
• Result ranking] + AnalysisAgent[Analysis Agent
• Evidence extraction
• Statistical analysis
• Code execution] + ReportAgent[Report Agent
• Report assembly
• Visualization
• Citation formatting] + end + + subgraph "MCP Tools" + WebSearch[Web Search
PubMed • arXiv • bioRxiv] + CodeExec[Code Execution
Sandboxed Python] + RAG[RAG Retrieval
Vector DB • Embeddings] + Viz[Visualization
Charts • Graphs] + end + + Manager -->|Selects & Directs| HypAgent + Manager -->|Selects & Directs| SearchAgent + Manager -->|Selects & Directs| AnalysisAgent + Manager -->|Selects & Directs| ReportAgent + + HypAgent --> SharedContext + SearchAgent --> SharedContext + AnalysisAgent --> SharedContext + ReportAgent --> SharedContext + + SearchAgent --> WebSearch + SearchAgent --> RAG + AnalysisAgent --> CodeExec + ReportAgent --> CodeExec + ReportAgent --> Viz + + style Manager fill:#ffe6e6 + style SharedContext fill:#ffe6f0 + style HypAgent fill:#fff4e6 + style SearchAgent fill:#fff4e6 + style AnalysisAgent fill:#fff4e6 + style ReportAgent fill:#fff4e6 + style WebSearch fill:#e6f3ff + style CodeExec fill:#e6f3ff + style RAG fill:#e6f3ff + style Viz fill:#e6f3ff +``` + +## 4. Dynamic Workflow Example + +```mermaid +sequenceDiagram + participant User + participant Manager + participant HypAgent + participant SearchAgent + participant AnalysisAgent + participant ReportAgent + + User->>Manager: "Research protein folding in Alzheimer's" + + Note over Manager: PLAN: Generate hypotheses → Search → Analyze → Report + + Manager->>HypAgent: Generate 3 hypotheses + HypAgent-->>Manager: Returns 3 hypotheses + Note over Manager: ASSESS: Good quality, proceed + + Manager->>SearchAgent: Search literature for hypothesis 1 + SearchAgent-->>Manager: Returns 15 papers + Note over Manager: ASSESS: Good results, continue + + Manager->>SearchAgent: Search for hypothesis 2 + SearchAgent-->>Manager: Only 2 papers found + Note over Manager: ASSESS: Insufficient, refine search + + Manager->>SearchAgent: Refined query for hypothesis 2 + SearchAgent-->>Manager: Returns 12 papers + Note over Manager: ASSESS: Better, proceed + + Manager->>AnalysisAgent: Analyze evidence for all hypotheses + AnalysisAgent-->>Manager: Returns analysis with code + Note over Manager: ASSESS: Complete, generate report + + Manager->>ReportAgent: Create comprehensive report + ReportAgent-->>Manager: Returns formatted report + Note over Manager: SYNTHESIZE: Combine all results + + Manager->>User: Final Research Report +``` + +## 5. Manager Decision Logic + +```mermaid +flowchart TD + Start([Manager Receives Task]) --> Plan[Create Initial Plan] + + Plan --> Select[Select Agent for Next Subtask] + Select --> Execute[Execute Agent] + Execute --> Collect[Collect Results] + + Collect --> Assess[Assess Quality & Progress] + + Assess --> Q1{Quality Sufficient?} + Q1 -->|No| Q2{Same Agent Can Fix?} + Q2 -->|Yes| Feedback[Provide Specific Feedback] + Feedback --> Execute + Q2 -->|No| Different[Try Different Agent] + Different --> Select + + Q1 -->|Yes| Q3{Task Complete?} + Q3 -->|No| Q4{Making Progress?} + Q4 -->|Yes| Select + Q4 -->|No - Stalled| Replan[Reset Plan & Approach] + Replan --> Plan + + Q3 -->|Yes| Synth[Synthesize Final Result] + Synth --> Done([Return Report]) + + style Start fill:#e1f5e1 + style Plan fill:#fff4e6 + style Select fill:#ffe6e6 + style Execute fill:#e6f3ff + style Assess fill:#ffd6d6 + style Q1 fill:#ffe6e6 + style Q2 fill:#ffe6e6 + style Q3 fill:#ffe6e6 + style Q4 fill:#ffe6e6 + style Synth fill:#d4edda + style Done fill:#e1f5e1 +``` + +## 6. Hypothesis Agent Workflow + +```mermaid +flowchart LR + Input[Research Query] --> Domain[Identify Domain
& Key Concepts] + Domain --> Context[Retrieve Background
Knowledge] + Context --> Generate[Generate 3-5
Initial Hypotheses] + Generate --> Refine[Refine for
Testability] + Refine --> Rank[Rank by
Quality Score] + Rank --> Output[Return Top
Hypotheses] + + Output --> Struct[Hypothesis Structure:
• Statement
• Rationale
• Testability Score
• Data Requirements
• Expected Outcomes] + + style Input fill:#e1f5e1 + style Output fill:#fff4e6 + style Struct fill:#e6f3ff +``` + +## 7. Search Agent Workflow + +```mermaid +flowchart TD + Input[Hypotheses] --> Strategy[Formulate Search
Strategy per Hypothesis] + + Strategy --> Multi[Multi-Source Search] + + Multi --> PubMed[PubMed Search
via MCP] + Multi --> ArXiv[arXiv Search
via MCP] + Multi --> BioRxiv[bioRxiv Search
via MCP] + + PubMed --> Aggregate[Aggregate Results] + ArXiv --> Aggregate + BioRxiv --> Aggregate + + Aggregate --> Filter[Filter & Rank
by Relevance] + Filter --> Dedup[Deduplicate
Cross-Reference] + Dedup --> Embed[Embed Documents
via MCP] + Embed --> Vector[(Vector DB)] + Vector --> RAGRetrieval[RAG Retrieval
Top-K per Hypothesis] + RAGRetrieval --> Output[Return Contextualized
Search Results] + + style Input fill:#fff4e6 + style Multi fill:#ffe6e6 + style Vector fill:#ffe6f0 + style Output fill:#e6f3ff +``` + +## 8. Analysis Agent Workflow + +```mermaid +flowchart TD + Input1[Hypotheses] --> Extract + Input2[Search Results] --> Extract[Extract Evidence
per Hypothesis] + + Extract --> Methods[Determine Analysis
Methods Needed] + + Methods --> Branch{Requires
Computation?} + Branch -->|Yes| GenCode[Generate Python
Analysis Code] + Branch -->|No| Qual[Qualitative
Synthesis] + + GenCode --> Execute[Execute Code
via MCP Sandbox] + Execute --> Interpret1[Interpret
Results] + Qual --> Interpret2[Interpret
Findings] + + Interpret1 --> Synthesize[Synthesize Evidence
Across Sources] + Interpret2 --> Synthesize + + Synthesize --> Verdict[Determine Verdict
per Hypothesis] + Verdict --> Support[• Supported
• Refuted
• Inconclusive] + Support --> Gaps[Identify Knowledge
Gaps & Limitations] + Gaps --> Output[Return Analysis
Report] + + style Input1 fill:#fff4e6 + style Input2 fill:#e6f3ff + style Execute fill:#ffe6e6 + style Output fill:#e6ffe6 +``` + +## 9. Report Agent Workflow + +```mermaid +flowchart TD + Input1[Query] --> Assemble + Input2[Hypotheses] --> Assemble + Input3[Search Results] --> Assemble + Input4[Analysis] --> Assemble[Assemble Report
Sections] + + Assemble --> Exec[Executive Summary] + Assemble --> Intro[Introduction] + Assemble --> Methods[Methods] + Assemble --> Results[Results per
Hypothesis] + Assemble --> Discussion[Discussion] + Assemble --> Future[Future Directions] + Assemble --> Refs[References] + + Results --> VizCheck{Needs
Visualization?} + VizCheck -->|Yes| GenViz[Generate Viz Code] + GenViz --> ExecViz[Execute via MCP
Create Charts] + ExecViz --> Combine + VizCheck -->|No| Combine[Combine All
Sections] + + Exec --> Combine + Intro --> Combine + Methods --> Combine + Discussion --> Combine + Future --> Combine + Refs --> Combine + + Combine --> Format[Format Output] + Format --> MD[Markdown] + Format --> PDF[PDF] + Format --> JSON[JSON] + + MD --> Output[Return Final
Report] + PDF --> Output + JSON --> Output + + style Input1 fill:#e1f5e1 + style Input2 fill:#fff4e6 + style Input3 fill:#e6f3ff + style Input4 fill:#e6ffe6 + style Output fill:#d4edda +``` + +## 10. Data Flow & Event Streaming + +```mermaid +flowchart TD + User[👤 User] -->|Research Query| UI[Gradio UI] + UI -->|Submit| Manager[Magentic Manager] + + Manager -->|Event: Planning| UI + Manager -->|Select Agent| HypAgent[Hypothesis Agent] + HypAgent -->|Event: Delta/Message| UI + HypAgent -->|Hypotheses| Context[(Shared Context)] + + Context -->|Retrieved by| Manager + Manager -->|Select Agent| SearchAgent[Search Agent] + SearchAgent -->|MCP Request| WebSearch[Web Search Tool] + WebSearch -->|Results| SearchAgent + SearchAgent -->|Event: Delta/Message| UI + SearchAgent -->|Documents| Context + SearchAgent -->|Embeddings| VectorDB[(Vector DB)] + + Context -->|Retrieved by| Manager + Manager -->|Select Agent| AnalysisAgent[Analysis Agent] + AnalysisAgent -->|MCP Request| CodeExec[Code Execution Tool] + CodeExec -->|Results| AnalysisAgent + AnalysisAgent -->|Event: Delta/Message| UI + AnalysisAgent -->|Analysis| Context + + Context -->|Retrieved by| Manager + Manager -->|Select Agent| ReportAgent[Report Agent] + ReportAgent -->|MCP Request| CodeExec + ReportAgent -->|Event: Delta/Message| UI + ReportAgent -->|Report| Context + + Manager -->|Event: Final Result| UI + UI -->|Display| User + + style User fill:#e1f5e1 + style UI fill:#e6f3ff + style Manager fill:#ffe6e6 + style Context fill:#ffe6f0 + style VectorDB fill:#ffe6f0 + style WebSearch fill:#f0f0f0 + style CodeExec fill:#f0f0f0 +``` + +## 11. MCP Tool Architecture + +```mermaid +graph TB + subgraph "Agent Layer" + Manager[Magentic Manager] + HypAgent[Hypothesis Agent] + SearchAgent[Search Agent] + AnalysisAgent[Analysis Agent] + ReportAgent[Report Agent] + end + + subgraph "MCP Protocol Layer" + Registry[MCP Tool Registry
• Discovers tools
• Routes requests
• Manages connections] + end + + subgraph "MCP Servers" + Server1[Web Search Server
localhost:8001
• PubMed
• arXiv
• bioRxiv] + Server2[Code Execution Server
localhost:8002
• Sandboxed Python
• Package management] + Server3[RAG Server
localhost:8003
• Vector embeddings
• Similarity search] + Server4[Visualization Server
localhost:8004
• Chart generation
• Plot rendering] + end + + subgraph "External Services" + PubMed[PubMed API] + ArXiv[arXiv API] + BioRxiv[bioRxiv API] + Modal[Modal Sandbox] + ChromaDB[(ChromaDB)] + end + + SearchAgent -->|Request| Registry + AnalysisAgent -->|Request| Registry + ReportAgent -->|Request| Registry + + Registry --> Server1 + Registry --> Server2 + Registry --> Server3 + Registry --> Server4 + + Server1 --> PubMed + Server1 --> ArXiv + Server1 --> BioRxiv + Server2 --> Modal + Server3 --> ChromaDB + + style Manager fill:#ffe6e6 + style Registry fill:#fff4e6 + style Server1 fill:#e6f3ff + style Server2 fill:#e6f3ff + style Server3 fill:#e6f3ff + style Server4 fill:#e6f3ff +``` + +## 12. Progress Tracking & Stall Detection + +```mermaid +stateDiagram-v2 + [*] --> Initialization: User Query + + Initialization --> Planning: Manager starts + + Planning --> AgentExecution: Select agent + + AgentExecution --> Assessment: Collect results + + Assessment --> QualityCheck: Evaluate output + + QualityCheck --> AgentExecution: Poor quality
(retry < max_rounds) + QualityCheck --> Planning: Poor quality
(try different agent) + QualityCheck --> NextAgent: Good quality
(task incomplete) + QualityCheck --> Synthesis: Good quality
(task complete) + + NextAgent --> AgentExecution: Select next agent + + state StallDetection <> + Assessment --> StallDetection: Check progress + StallDetection --> Planning: No progress
(stall count < max) + StallDetection --> ErrorRecovery: No progress
(max stalls reached) + + ErrorRecovery --> PartialReport: Generate partial results + PartialReport --> [*] + + Synthesis --> FinalReport: Combine all outputs + FinalReport --> [*] + + note right of QualityCheck + Manager assesses: + • Output completeness + • Quality metrics + • Progress made + end note + + note right of StallDetection + Stall = no new progress + after agent execution + Triggers plan reset + end note +``` + +## 13. Gradio UI Integration + +```mermaid +graph TD + App[Gradio App
DeepCritical Research Agent] + + App --> Input[Input Section] + App --> Status[Status Section] + App --> Output[Output Section] + + Input --> Query[Research Question
Text Area] + Input --> Controls[Controls] + Controls --> MaxHyp[Max Hypotheses: 1-10] + Controls --> MaxRounds[Max Rounds: 5-20] + Controls --> Submit[Start Research Button] + + Status --> Log[Real-time Event Log
• Manager planning
• Agent selection
• Execution updates
• Quality assessment] + Status --> Progress[Progress Tracker
• Current agent
• Round count
• Stall count] + + Output --> Tabs[Tabbed Results] + Tabs --> Tab1[Hypotheses Tab
Generated hypotheses with scores] + Tabs --> Tab2[Search Results Tab
Papers & sources found] + Tabs --> Tab3[Analysis Tab
Evidence & verdicts] + Tabs --> Tab4[Report Tab
Final research report] + Tab4 --> Download[Download Report
MD / PDF / JSON] + + Submit -.->|Triggers| Workflow[Magentic Workflow] + Workflow -.->|MagenticOrchestratorMessageEvent| Log + Workflow -.->|MagenticAgentDeltaEvent| Log + Workflow -.->|MagenticAgentMessageEvent| Log + Workflow -.->|MagenticFinalResultEvent| Tab4 + + style App fill:#e1f5e1 + style Input fill:#fff4e6 + style Status fill:#e6f3ff + style Output fill:#e6ffe6 + style Workflow fill:#ffe6e6 +``` + +## 14. Complete System Context + +```mermaid +graph LR + User[👤 Researcher
Asks research questions] -->|Submits query| DC[DeepCritical
Magentic Workflow] + + DC -->|Literature search| PubMed[PubMed API
Medical papers] + DC -->|Preprint search| ArXiv[arXiv API
Scientific preprints] + DC -->|Biology search| BioRxiv[bioRxiv API
Biology preprints] + DC -->|Agent reasoning| Claude[Claude API
Sonnet 4 / Opus] + DC -->|Code execution| Modal[Modal Sandbox
Safe Python env] + DC -->|Vector storage| Chroma[ChromaDB
Embeddings & RAG] + + DC -->|Deployed on| HF[HuggingFace Spaces
Gradio 6.0] + + PubMed -->|Results| DC + ArXiv -->|Results| DC + BioRxiv -->|Results| DC + Claude -->|Responses| DC + Modal -->|Output| DC + Chroma -->|Context| DC + + DC -->|Research report| User + + style User fill:#e1f5e1 + style DC fill:#ffe6e6 + style PubMed fill:#e6f3ff + style ArXiv fill:#e6f3ff + style BioRxiv fill:#e6f3ff + style Claude fill:#ffd6d6 + style Modal fill:#f0f0f0 + style Chroma fill:#ffe6f0 + style HF fill:#d4edda +``` + +## 15. Workflow Timeline (Simplified) + +```mermaid +gantt + title DeepCritical Magentic Workflow - Typical Execution + dateFormat mm:ss + axisFormat %M:%S + + section Manager Planning + Initial planning :p1, 00:00, 10s + + section Hypothesis Agent + Generate hypotheses :h1, after p1, 30s + Manager assessment :h2, after h1, 5s + + section Search Agent + Search hypothesis 1 :s1, after h2, 20s + Search hypothesis 2 :s2, after s1, 20s + Search hypothesis 3 :s3, after s2, 20s + RAG processing :s4, after s3, 15s + Manager assessment :s5, after s4, 5s + + section Analysis Agent + Evidence extraction :a1, after s5, 15s + Code generation :a2, after a1, 20s + Code execution :a3, after a2, 25s + Synthesis :a4, after a3, 20s + Manager assessment :a5, after a4, 5s + + section Report Agent + Report assembly :r1, after a5, 30s + Visualization :r2, after r1, 15s + Formatting :r3, after r2, 10s + + section Manager Synthesis + Final synthesis :f1, after r3, 10s +``` + +--- + +## Key Differences from Original Design + +| Aspect | Original (Judge-in-Loop) | New (Magentic) | +|--------|-------------------------|----------------| +| **Control Flow** | Fixed sequential phases | Dynamic agent selection | +| **Quality Control** | Separate Judge Agent | Manager assessment built-in | +| **Retry Logic** | Phase-level with feedback | Agent-level with adaptation | +| **Flexibility** | Rigid 4-phase pipeline | Adaptive workflow | +| **Complexity** | 5 agents (including Judge) | 4 agents (no Judge) | +| **Progress Tracking** | Manual state management | Built-in round/stall detection | +| **Agent Coordination** | Sequential handoff | Manager-driven dynamic selection | +| **Error Recovery** | Retry same phase | Try different agent or replan | + +--- + +## Simplified Design Principles + +1. **Manager is Intelligent**: LLM-powered manager handles planning, selection, and quality assessment +2. **No Separate Judge**: Manager's assessment phase replaces dedicated Judge Agent +3. **Dynamic Workflow**: Agents can be called multiple times in any order based on need +4. **Built-in Safety**: max_round_count (15) and max_stall_count (3) prevent infinite loops +5. **Event-Driven UI**: Real-time streaming updates to Gradio interface +6. **MCP-Powered Tools**: All external capabilities via Model Context Protocol +7. **Shared Context**: Centralized state accessible to all agents +8. **Progress Awareness**: Manager tracks what's been done and what's needed + +--- + +## Legend + +- 🔴 **Red/Pink**: Manager, orchestration, decision-making +- 🟡 **Yellow/Orange**: Specialist agents, processing +- 🔵 **Blue**: Data, tools, MCP services +- 🟣 **Purple/Pink**: Storage, databases, state +- 🟢 **Green**: User interactions, final outputs +- ⚪ **Gray**: External services, APIs + +--- + +## Implementation Highlights + +**Simple 4-Agent Setup:** +```python +workflow = ( + MagenticBuilder() + .participants( + hypothesis=HypothesisAgent(tools=[background_tool]), + search=SearchAgent(tools=[web_search, rag_tool]), + analysis=AnalysisAgent(tools=[code_execution]), + report=ReportAgent(tools=[code_execution, visualization]) + ) + .with_standard_manager( + chat_client=AnthropicClient(model="claude-sonnet-4"), + max_round_count=15, # Prevent infinite loops + max_stall_count=3 # Detect stuck workflows + ) + .build() +) +``` + +**Manager handles quality assessment in its instructions:** +- Checks hypothesis quality (testable, novel, clear) +- Validates search results (relevant, authoritative, recent) +- Assesses analysis soundness (methodology, evidence, conclusions) +- Ensures report completeness (all sections, proper citations) + +No separate Judge Agent needed - manager does it all! + +--- + +**Document Version**: 2.0 (Magentic Simplified) +**Last Updated**: 2025-11-24 +**Architecture**: Microsoft Magentic Orchestration Pattern +**Agents**: 4 (Hypothesis, Search, Analysis, Report) + 1 Manager +**License**: MIT diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000000000000000000000000000000000000..856e74fbc3f15a6080bb12b0c6501309392c484b --- /dev/null +++ b/examples/README.md @@ -0,0 +1,184 @@ +# DeepCritical Examples + +**NO MOCKS. NO FAKE DATA. REAL SCIENCE.** + +These demos run the REAL drug repurposing research pipeline with actual API calls. + +--- + +## Prerequisites + +You MUST have API keys configured: + +```bash +# Copy the example and add your keys +cp .env.example .env + +# Required (pick one): +OPENAI_API_KEY=sk-... +ANTHROPIC_API_KEY=sk-ant-... + +# Optional (higher PubMed rate limits): +NCBI_API_KEY=your-key +``` + +--- + +## Examples + +### 1. Search Demo (No LLM Required) + +Demonstrates REAL parallel search across PubMed, ClinicalTrials.gov, and Europe PMC. + +```bash +uv run python examples/search_demo/run_search.py "metformin cancer" +``` + +**What's REAL:** +- Actual NCBI E-utilities API calls (PubMed) +- Actual ClinicalTrials.gov API calls +- Actual Europe PMC API calls (includes preprints) +- Real papers, real trials, real preprints + +--- + +### 2. Embeddings Demo (No LLM Required) + +Demonstrates REAL semantic search and deduplication. + +```bash +uv run python examples/embeddings_demo/run_embeddings.py +``` + +**What's REAL:** +- Actual sentence-transformers model (all-MiniLM-L6-v2) +- Actual ChromaDB vector storage +- Real cosine similarity computations +- Real semantic deduplication + +--- + +### 3. Orchestrator Demo (LLM Required) + +Demonstrates the REAL search-judge-synthesize loop. + +```bash +uv run python examples/orchestrator_demo/run_agent.py "metformin cancer" +uv run python examples/orchestrator_demo/run_agent.py "aspirin alzheimer" --iterations 5 +``` + +**What's REAL:** +- Real PubMed + ClinicalTrials + Europe PMC searches +- Real LLM judge evaluating evidence quality +- Real iterative refinement based on LLM decisions +- Real research synthesis + +--- + +### 4. Magentic Demo (OpenAI Required) + +Demonstrates REAL multi-agent coordination using Microsoft Agent Framework. + +```bash +# Requires OPENAI_API_KEY specifically +uv run python examples/orchestrator_demo/run_magentic.py "metformin cancer" +``` + +**What's REAL:** +- Real MagenticBuilder orchestration +- Real SearchAgent, JudgeAgent, HypothesisAgent, ReportAgent +- Real manager-based coordination + +--- + +### 5. Hypothesis Demo (LLM Required) + +Demonstrates REAL mechanistic hypothesis generation. + +```bash +uv run python examples/hypothesis_demo/run_hypothesis.py "metformin Alzheimer's" +uv run python examples/hypothesis_demo/run_hypothesis.py "sildenafil heart failure" +``` + +**What's REAL:** +- Real PubMed + Web search first +- Real embedding-based deduplication +- Real LLM generating Drug -> Target -> Pathway -> Effect chains +- Real knowledge gap identification + +--- + +### 6. Full-Stack Demo (LLM Required) + +**THE COMPLETE PIPELINE** - All phases working together. + +```bash +uv run python examples/full_stack_demo/run_full.py "metformin Alzheimer's" +uv run python examples/full_stack_demo/run_full.py "sildenafil heart failure" -i 3 +``` + +**What's REAL:** +1. Real PubMed + ClinicalTrials + Europe PMC evidence collection +2. Real embedding-based semantic deduplication +3. Real LLM mechanistic hypothesis generation +4. Real LLM evidence quality assessment +5. Real LLM structured scientific report generation + +Output: Publication-quality research report with validated citations. + +--- + +## API Key Requirements + +| Example | LLM Required | Keys | +|---------|--------------|------| +| search_demo | No | Optional: `NCBI_API_KEY` | +| embeddings_demo | No | None | +| orchestrator_demo | Yes | `OPENAI_API_KEY` or `ANTHROPIC_API_KEY` | +| run_magentic | Yes | `OPENAI_API_KEY` (Magentic requires OpenAI) | +| hypothesis_demo | Yes | `OPENAI_API_KEY` or `ANTHROPIC_API_KEY` | +| full_stack_demo | Yes | `OPENAI_API_KEY` or `ANTHROPIC_API_KEY` | + +--- + +## Architecture + +```text +User Query + | + v +[REAL Search] --> PubMed + ClinicalTrials + Europe PMC APIs + | + v +[REAL Embeddings] --> Actual sentence-transformers + | + v +[REAL Hypothesis] --> Actual LLM reasoning + | + v +[REAL Judge] --> Actual LLM assessment + | + +---> Need more? --> Loop back to Search + | + +---> Sufficient --> Continue + | + v +[REAL Report] --> Actual LLM synthesis + | + v +Publication-Quality Research Report +``` + +--- + +## Why No Mocks? + +> "Authenticity is the feature." + +Mocks belong in `tests/unit/`, not in demos. When you run these examples, you see: +- Real papers from real databases +- Real AI reasoning about real evidence +- Real scientific hypotheses +- Real research reports + +This is what DeepCritical actually does. No fake data. No canned responses. diff --git a/examples/embeddings_demo/run_embeddings.py b/examples/embeddings_demo/run_embeddings.py new file mode 100644 index 0000000000000000000000000000000000000000..26ba4d374326a8dcdf272ac552527b5d77171529 --- /dev/null +++ b/examples/embeddings_demo/run_embeddings.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +""" +Demo: Semantic Search & Deduplication (Phase 6). + +This script demonstrates embedding-based capabilities using REAL data: +- Fetches REAL abstracts from PubMed +- Embeds text with sentence-transformers +- Performs semantic deduplication on LIVE research data + +Usage: + uv run python examples/embeddings_demo/run_embeddings.py +""" + +import asyncio + +from src.services.embeddings import EmbeddingService +from src.tools.pubmed import PubMedTool + + +def create_fresh_service(name_suffix: str = "") -> EmbeddingService: + """Create a fresh embedding service with unique collection name.""" + import uuid + + # Create service with unique collection by modifying the internal collection + service = EmbeddingService.__new__(EmbeddingService) + service._model = __import__("sentence_transformers").SentenceTransformer("all-MiniLM-L6-v2") + service._client = __import__("chromadb").Client() + collection_name = f"demo_{name_suffix}_{uuid.uuid4().hex[:8]}" + service._collection = service._client.create_collection( + name=collection_name, metadata={"hnsw:space": "cosine"} + ) + return service + + +async def demo_real_pipeline() -> None: + """Run the demo using REAL PubMed data.""" + print("\n" + "=" * 60) + print("DeepCritical Embeddings Demo (REAL DATA)") + print("=" * 60) + + # 1. Fetch Real Data + query = "metformin mechanism of action" + print(f"\n[1] Fetching real papers for: '{query}'...") + pubmed = PubMedTool() + # Fetch enough results to likely get some overlap/redundancy + evidence = await pubmed.search(query, max_results=10) + + print(f" Found {len(evidence)} papers.") + print("\n Sample Titles:") + for i, e in enumerate(evidence[:3], 1): + print(f" {i}. {e.citation.title[:80]}...") + + # 2. Embed Data + print("\n[2] Embedding abstracts (sentence-transformers)...") + service = create_fresh_service("real_demo") + + # 3. Semantic Search + print("\n[3] Semantic Search Demo") + print(" Indexing evidence...") + for e in evidence: + # Use URL as ID for uniqueness + await service.add_evidence( + evidence_id=e.citation.url, + content=e.content, + metadata={ + "source": e.citation.source, + "title": e.citation.title, + "date": e.citation.date, + }, + ) + + semantic_query = "activation of AMPK pathway" + print(f" Searching for concept: '{semantic_query}'") + results = await service.search_similar(semantic_query, n_results=2) + + print(" Top matches:") + for i, r in enumerate(results, 1): + similarity = 1 - r["distance"] + print(f" {i}. [{similarity:.1%} match] {r['metadata']['title'][:70]}...") + + # 4. Semantic Deduplication + print("\n[4] Semantic Deduplication Demo") + # Create a FRESH service for deduplication so we don't clash with Step 3's index + dedup_service = create_fresh_service("dedup_demo") + + print(" Checking for redundant papers (threshold=0.85)...") + + # To force a duplicate for demo purposes, let's double the evidence list + # simulating finding the same papers again or very similar ones + duplicated_evidence = evidence + evidence[:2] + print(f" Input pool: {len(duplicated_evidence)} items (with artificial duplicates added)") + + unique = await dedup_service.deduplicate(duplicated_evidence, threshold=0.85) + + print(f" Output pool: {len(unique)} unique items") + print(f" Removed {len(duplicated_evidence) - len(unique)} duplicates.") + + print("\n" + "=" * 60) + print("Demo complete! Verified with REAL PubMed data.") + print("=" * 60 + "\n") + + +if __name__ == "__main__": + asyncio.run(demo_real_pipeline()) diff --git a/examples/full_stack_demo/run_full.py b/examples/full_stack_demo/run_full.py new file mode 100644 index 0000000000000000000000000000000000000000..2464084cd802c55285cebc4f54cf7c4832f5ba4e --- /dev/null +++ b/examples/full_stack_demo/run_full.py @@ -0,0 +1,236 @@ +#!/usr/bin/env python3 +""" +Demo: Full Stack DeepCritical Agent (Phases 1-8). + +This script demonstrates the COMPLETE REAL drug repurposing research pipeline: +- Phase 2: REAL Search (PubMed + ClinicalTrials + Europe PMC) +- Phase 6: REAL Embeddings (sentence-transformers + ChromaDB) +- Phase 7: REAL Hypothesis (LLM mechanistic reasoning) +- Phase 3: REAL Judge (LLM evidence assessment) +- Phase 8: REAL Report (LLM structured scientific report) + +NO MOCKS. NO FAKE DATA. REAL SCIENCE. + +Usage: + uv run python examples/full_stack_demo/run_full.py "metformin Alzheimer's" + uv run python examples/full_stack_demo/run_full.py "sildenafil heart failure" -i 3 + +Requires: OPENAI_API_KEY or ANTHROPIC_API_KEY +""" + +import argparse +import asyncio +import os +import sys +from typing import Any + +from src.utils.models import Evidence + + +def print_header(title: str) -> None: + """Print a formatted section header.""" + print(f"\n{'=' * 70}") + print(f" {title}") + print(f"{'=' * 70}\n") + + +def print_step(step: int, name: str) -> None: + """Print a step indicator.""" + print(f"\n[Step {step}] {name}") + print("-" * 50) + + +_MAX_DISPLAY_LEN = 600 + + +def _print_truncated(text: str) -> None: + """Print text, truncating if too long.""" + if len(text) > _MAX_DISPLAY_LEN: + print(text[:_MAX_DISPLAY_LEN] + "\n... [truncated for display]") + else: + print(text) + + +async def _run_search_iteration( + query: str, + iteration: int, + evidence_store: dict[str, Any], + all_evidence: list[Evidence], + search_handler: Any, + embedding_service: Any, +) -> list[Evidence]: + """Run a single search iteration with deduplication.""" + search_queries = [query] + if evidence_store.get("hypotheses"): + for h in evidence_store["hypotheses"][-2:]: + search_queries.extend(h.search_suggestions[:1]) + + for q in search_queries[:2]: + result = await search_handler.execute(q, max_results_per_tool=5) + print(f" '{q}' -> {result.total_found} results") + new_unique = await embedding_service.deduplicate(result.evidence) + print(f" After dedup: {len(new_unique)} unique") + all_evidence.extend(new_unique) + + evidence_store["current"] = all_evidence + evidence_store["iteration_count"] = iteration + return all_evidence + + +async def _handle_judge_step( + judge_handler: Any, query: str, all_evidence: list[Evidence], evidence_store: dict[str, Any] +) -> tuple[bool, str]: + """Handle the judge assessment step. Returns (should_stop, next_query).""" + print("\n[Judge] Assessing evidence quality (REAL LLM)...") + assessment = await judge_handler.assess(query, all_evidence) + print(f" Mechanism Score: {assessment.details.mechanism_score}/10") + print(f" Clinical Score: {assessment.details.clinical_evidence_score}/10") + print(f" Confidence: {assessment.confidence:.0%}") + print(f" Recommendation: {assessment.recommendation.upper()}") + + if assessment.recommendation == "synthesize": + print("\n[Judge] Evidence sufficient! Proceeding to report generation...") + evidence_store["last_assessment"] = assessment.details.model_dump() + return True, query + + next_queries = assessment.next_search_queries[:2] if assessment.next_search_queries else [] + if next_queries: + print(f"\n[Judge] Need more evidence. Next queries: {next_queries}") + return False, next_queries[0] + + print("\n[Judge] Need more evidence but no suggested queries. Continuing with original query.") + return False, query + + +async def run_full_demo(query: str, max_iterations: int) -> None: + """Run the REAL full stack pipeline.""" + print_header("DeepCritical Full Stack Demo (REAL)") + print(f"Query: {query}") + print(f"Max iterations: {max_iterations}") + print("Mode: REAL (All live API calls - no mocks)\n") + + # Import real components + from src.agent_factory.judges import JudgeHandler + from src.agents.hypothesis_agent import HypothesisAgent + from src.agents.report_agent import ReportAgent + from src.services.embeddings import EmbeddingService + from src.tools.clinicaltrials import ClinicalTrialsTool + from src.tools.europepmc import EuropePMCTool + from src.tools.pubmed import PubMedTool + from src.tools.search_handler import SearchHandler + + # Initialize REAL services + print("[Init] Loading embedding model...") + embedding_service = EmbeddingService() + search_handler = SearchHandler( + tools=[PubMedTool(), ClinicalTrialsTool(), EuropePMCTool()], timeout=30.0 + ) + judge_handler = JudgeHandler() + + # Shared evidence store + evidence_store: dict[str, Any] = {"current": [], "hypotheses": [], "iteration_count": 0} + all_evidence: list[Evidence] = [] + + for iteration in range(1, max_iterations + 1): + print_step(iteration, f"ITERATION {iteration}/{max_iterations}") + + # Step 1: REAL Search + print("\n[Search] Querying PubMed + ClinicalTrials + Europe PMC (REAL API calls)...") + all_evidence = await _run_search_iteration( + query, iteration, evidence_store, all_evidence, search_handler, embedding_service + ) + + if not all_evidence: + print("\nNo evidence found. Try a different query.") + return + + # Step 2: REAL Hypothesis generation (first iteration only) + if iteration == 1: + print("\n[Hypothesis] Generating mechanistic hypotheses (REAL LLM)...") + hypothesis_agent = HypothesisAgent(evidence_store, embedding_service) + hyp_response = await hypothesis_agent.run(query) + _print_truncated(hyp_response.messages[0].text) + + # Step 3: REAL Judge + should_stop, query = await _handle_judge_step( + judge_handler, query, all_evidence, evidence_store + ) + if should_stop: + break + + # Step 4: REAL Report generation + print_step(iteration + 1, "REPORT GENERATION (REAL LLM)") + report_agent = ReportAgent(evidence_store, embedding_service) + report_response = await report_agent.run(query) + + print("\n" + "=" * 70) + print(" FINAL RESEARCH REPORT") + print("=" * 70) + print(report_response.messages[0].text) + + +async def main() -> None: + """Entry point.""" + parser = argparse.ArgumentParser( + description="DeepCritical Full Stack Demo - REAL, No Mocks", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +This demo runs the COMPLETE pipeline with REAL API calls: + 1. REAL search: Actual PubMed queries + 2. REAL embeddings: Actual sentence-transformers model + 3. REAL hypothesis: Actual LLM generating mechanistic chains + 4. REAL judge: Actual LLM assessing evidence quality + 5. REAL report: Actual LLM generating structured report + +Examples: + uv run python examples/full_stack_demo/run_full.py "metformin Alzheimer's" + uv run python examples/full_stack_demo/run_full.py "sildenafil heart failure" -i 3 + uv run python examples/full_stack_demo/run_full.py "aspirin cancer prevention" + """, + ) + parser.add_argument( + "query", + help="Research query (e.g., 'metformin Alzheimer's disease')", + ) + parser.add_argument( + "-i", + "--iterations", + type=int, + default=2, + help="Max search iterations (default: 2)", + ) + + args = parser.parse_args() + + if args.iterations < 1: + print("Error: iterations must be at least 1") + sys.exit(1) + + # Fail fast: require API key + if not (os.getenv("OPENAI_API_KEY") or os.getenv("ANTHROPIC_API_KEY")): + print("=" * 70) + print("ERROR: This demo requires a real LLM.") + print() + print("Set one of the following in your .env file:") + print(" OPENAI_API_KEY=sk-...") + print(" ANTHROPIC_API_KEY=sk-ant-...") + print() + print("This is a REAL demo. No mocks. No fake data.") + print("=" * 70) + sys.exit(1) + + await run_full_demo(args.query, args.iterations) + + print("\n" + "=" * 70) + print(" DeepCritical Full Stack Demo Complete!") + print(" ") + print(" Everything you just saw was REAL:") + print(" - Real PubMed + ClinicalTrials + Europe PMC searches") + print(" - Real embedding computations") + print(" - Real LLM reasoning") + print(" - Real scientific report") + print("=" * 70 + "\n") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/hypothesis_demo/run_hypothesis.py b/examples/hypothesis_demo/run_hypothesis.py new file mode 100644 index 0000000000000000000000000000000000000000..65a24224a0d8a32ced8f25de702e152ecba13590 --- /dev/null +++ b/examples/hypothesis_demo/run_hypothesis.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +""" +Demo: Hypothesis Generation (Phase 7). + +This script demonstrates the REAL hypothesis generation pipeline: +1. REAL search: PubMed + ClinicalTrials + Europe PMC (actual API calls) +2. REAL embeddings: Semantic deduplication +3. REAL LLM: Mechanistic hypothesis generation + +Usage: + # Requires OPENAI_API_KEY or ANTHROPIC_API_KEY + uv run python examples/hypothesis_demo/run_hypothesis.py "metformin Alzheimer's" + uv run python examples/hypothesis_demo/run_hypothesis.py "sildenafil heart failure" +""" + +import argparse +import asyncio +import os +import sys +from typing import Any + +from src.agents.hypothesis_agent import HypothesisAgent +from src.services.embeddings import EmbeddingService +from src.tools.clinicaltrials import ClinicalTrialsTool +from src.tools.europepmc import EuropePMCTool +from src.tools.pubmed import PubMedTool +from src.tools.search_handler import SearchHandler + + +async def run_hypothesis_demo(query: str) -> None: + """Run the REAL hypothesis generation pipeline.""" + try: + print(f"\n{'=' * 60}") + print("DeepCritical Hypothesis Agent Demo (Phase 7)") + print(f"Query: {query}") + print("Mode: REAL (Live API calls)") + print(f"{'=' * 60}\n") + + # Step 1: REAL Search + print("[Step 1] Searching PubMed + ClinicalTrials + Europe PMC...") + search_handler = SearchHandler( + tools=[PubMedTool(), ClinicalTrialsTool(), EuropePMCTool()], timeout=30.0 + ) + result = await search_handler.execute(query, max_results_per_tool=5) + + print(f" Found {result.total_found} results from {result.sources_searched}") + if result.errors: + print(f" Warnings: {result.errors}") + + if not result.evidence: + print("\nNo evidence found. Try a different query.") + return + + # Step 2: REAL Embeddings - Deduplicate + print("\n[Step 2] Semantic deduplication...") + embedding_service = EmbeddingService() + unique_evidence = await embedding_service.deduplicate(result.evidence, threshold=0.85) + print(f" {len(result.evidence)} -> {len(unique_evidence)} unique papers") + + # Show what we found + print("\n[Evidence collected]") + max_title_len = 50 + for i, e in enumerate(unique_evidence[:5], 1): + raw_title = e.citation.title + if len(raw_title) > max_title_len: + title = raw_title[:max_title_len] + "..." + else: + title = raw_title + print(f" {i}. [{e.citation.source.upper()}] {title}") + + # Step 3: REAL LLM - Generate hypotheses + print("\n[Step 3] Generating mechanistic hypotheses (LLM)...") + evidence_store: dict[str, Any] = {"current": unique_evidence, "hypotheses": []} + agent = HypothesisAgent(evidence_store, embedding_service) + + print("-" * 60) + response = await agent.run(query) + print(response.messages[0].text) + print("-" * 60) + + # Show stored hypotheses + hypotheses = evidence_store.get("hypotheses", []) + print(f"\n{len(hypotheses)} hypotheses stored") + + if hypotheses: + print("\nGenerated search queries for further investigation:") + for h in hypotheses: + queries = h.to_search_queries() + print(f" {h.drug} -> {h.target}:") + for q in queries[:3]: + print(f" - {q}") + + except Exception as e: + print(f"\n❌ Error during hypothesis generation: {e}") + raise + + +async def main() -> None: + """Entry point.""" + parser = argparse.ArgumentParser( + description="Hypothesis Generation Demo (REAL - No Mocks)", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + uv run python examples/hypothesis_demo/run_hypothesis.py "metformin Alzheimer's" + uv run python examples/hypothesis_demo/run_hypothesis.py "sildenafil heart failure" + uv run python examples/hypothesis_demo/run_hypothesis.py "aspirin cancer prevention" + """, + ) + parser.add_argument( + "query", + nargs="?", + default="metformin Alzheimer's disease", + help="Research query", + ) + args = parser.parse_args() + + # Fail fast: require API key + if not (os.getenv("OPENAI_API_KEY") or os.getenv("ANTHROPIC_API_KEY")): + print("=" * 60) + print("ERROR: This demo requires a real LLM.") + print() + print("Set one of the following in your .env file:") + print(" OPENAI_API_KEY=sk-...") + print(" ANTHROPIC_API_KEY=sk-ant-...") + print() + print("This is a REAL demo, not a mock. No fake data.") + print("=" * 60) + sys.exit(1) + + await run_hypothesis_demo(args.query) + + print("\n" + "=" * 60) + print("Demo complete! This was a REAL pipeline:") + print(" 1. REAL search: PubMed + ClinicalTrials + Europe PMC APIs") + print(" 2. REAL embeddings: Actual sentence-transformers") + print(" 3. REAL LLM: Actual hypothesis generation") + print("=" * 60 + "\n") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/modal_demo/run_analysis.py b/examples/modal_demo/run_analysis.py new file mode 100644 index 0000000000000000000000000000000000000000..82bbe7ff0fcdedb4b871d4479924db2108affcd8 --- /dev/null +++ b/examples/modal_demo/run_analysis.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python3 +"""Demo: Modal-powered statistical analysis. + +This script uses StatisticalAnalyzer directly (NO agent_framework dependency). + +Usage: + uv run python examples/modal_demo/run_analysis.py "metformin alzheimer" +""" + +import argparse +import asyncio +import os +import sys + +from src.services.statistical_analyzer import get_statistical_analyzer +from src.tools.pubmed import PubMedTool +from src.utils.config import settings + + +async def main() -> None: + """Run the Modal analysis demo.""" + parser = argparse.ArgumentParser(description="Modal Analysis Demo") + parser.add_argument("query", help="Research query") + args = parser.parse_args() + + if not settings.modal_available: + print("Error: Modal credentials not configured.") + sys.exit(1) + + if not (os.getenv("OPENAI_API_KEY") or os.getenv("ANTHROPIC_API_KEY")): + print("Error: No LLM API key found.") + sys.exit(1) + + print(f"\n{'=' * 60}") + print("DeepCritical Modal Analysis Demo") + print(f"Query: {args.query}") + print(f"{'=' * 60}\n") + + # Step 1: Gather Evidence + print("Step 1: Gathering evidence from PubMed...") + pubmed = PubMedTool() + evidence = await pubmed.search(args.query, max_results=5) + print(f" Found {len(evidence)} papers\n") + + # Step 2: Run Modal Analysis + print("Step 2: Running statistical analysis in Modal sandbox...") + analyzer = get_statistical_analyzer() + result = await analyzer.analyze(query=args.query, evidence=evidence) + + # Step 3: Display Results + print("\n" + "=" * 60) + print("ANALYSIS RESULTS") + print("=" * 60) + print(f"\nVerdict: {result.verdict}") + print(f"Confidence: {result.confidence:.0%}") + print("\nKey Findings:") + for finding in result.key_findings: + print(f" - {finding}") + + print("\n[Demo Complete - Code executed in Modal, not locally]") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/modal_demo/test_code_execution.py b/examples/modal_demo/test_code_execution.py new file mode 100644 index 0000000000000000000000000000000000000000..7addd107e03541fed68a85bf8ed5cb484cc44bc1 --- /dev/null +++ b/examples/modal_demo/test_code_execution.py @@ -0,0 +1,169 @@ +"""Demo script to test Modal code execution integration. + +Run with: uv run python examples/modal_demo/test_code_execution.py +""" + +import sys +from pathlib import Path + +# Add src to path +sys.path.insert(0, str(Path(__file__).parent.parent.parent)) + +from src.tools.code_execution import CodeExecutionError, get_code_executor + + +def test_basic_execution(): + """Test basic code execution.""" + print("\n=== Test 1: Basic Execution ===") + executor = get_code_executor() + + code = """ +print("Hello from Modal sandbox!") +result = 2 + 2 +print(f"2 + 2 = {result}") +""" + + result = executor.execute(code) + print(f"Success: {result['success']}") + print(f"Stdout:\n{result['stdout']}") + if result["stderr"]: + print(f"Stderr:\n{result['stderr']}") + + +def test_scientific_computing(): + """Test scientific computing libraries.""" + print("\n=== Test 2: Scientific Computing ===") + executor = get_code_executor() + + code = """ +import pandas as pd +import numpy as np + +# Create sample data +data = { + 'drug': ['DrugA', 'DrugB', 'DrugC'], + 'efficacy': [0.75, 0.82, 0.68], + 'sample_size': [100, 150, 120] +} + +df = pd.DataFrame(data) + +# Calculate weighted average +weighted_avg = np.average(df['efficacy'], weights=df['sample_size']) + +print(f"Drugs tested: {len(df)}") +print(f"Weighted average efficacy: {weighted_avg:.3f}") +print("\\nDataFrame:") +print(df.to_string()) +""" + + result = executor.execute(code) + print(f"Success: {result['success']}") + print(f"Output:\n{result['stdout']}") + + +def test_statistical_analysis(): + """Test statistical analysis.""" + print("\n=== Test 3: Statistical Analysis ===") + executor = get_code_executor() + + code = """ +import numpy as np +from scipy import stats + +# Simulate two treatment groups +np.random.seed(42) +control_group = np.random.normal(100, 15, 50) +treatment_group = np.random.normal(110, 15, 50) + +# Perform t-test +t_stat, p_value = stats.ttest_ind(treatment_group, control_group) + +print(f"Control mean: {np.mean(control_group):.2f}") +print(f"Treatment mean: {np.mean(treatment_group):.2f}") +print(f"T-statistic: {t_stat:.3f}") +print(f"P-value: {p_value:.4f}") + +if p_value < 0.05: + print("Result: Statistically significant difference") +else: + print("Result: No significant difference") +""" + + result = executor.execute(code) + print(f"Success: {result['success']}") + print(f"Output:\n{result['stdout']}") + + +def test_with_return_value(): + """Test execute_with_return method.""" + print("\n=== Test 4: Return Value ===") + executor = get_code_executor() + + code = """ +import numpy as np + +# Calculate something +data = np.array([1, 2, 3, 4, 5]) +result = { + 'mean': float(np.mean(data)), + 'std': float(np.std(data)), + 'sum': int(np.sum(data)) +} +""" + + try: + result = executor.execute_with_return(code) + print(f"Returned result: {result}") + print(f"Mean: {result['mean']}") + print(f"Std: {result['std']}") + print(f"Sum: {result['sum']}") + except CodeExecutionError as e: + print(f"Error: {e}") + + +def test_error_handling(): + """Test error handling.""" + print("\n=== Test 5: Error Handling ===") + executor = get_code_executor() + + code = """ +# This will fail +x = 1 / 0 +""" + + result = executor.execute(code) + print(f"Success: {result['success']}") + print(f"Error: {result['error']}") + + +def main(): + """Run all tests.""" + print("=" * 60) + print("Modal Code Execution Demo") + print("=" * 60) + + tests = [ + test_basic_execution, + test_scientific_computing, + test_statistical_analysis, + test_with_return_value, + test_error_handling, + ] + + for test in tests: + try: + test() + except Exception as e: + print(f"\n❌ Test failed: {e}") + import traceback + + traceback.print_exc() + + print("\n" + "=" * 60) + print("Demo completed!") + print("=" * 60) + + +if __name__ == "__main__": + main() diff --git a/examples/modal_demo/verify_sandbox.py b/examples/modal_demo/verify_sandbox.py new file mode 100644 index 0000000000000000000000000000000000000000..8ac94503607aafa506056fdf4434c96469a91834 --- /dev/null +++ b/examples/modal_demo/verify_sandbox.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 +"""Verify that Modal sandbox is properly isolated. + +This script proves to judges that code runs in Modal, not locally. +NO agent_framework dependency - uses only src.tools.code_execution. + +Usage: + uv run python examples/modal_demo/verify_sandbox.py +""" + +import asyncio +from functools import partial + +from src.tools.code_execution import CodeExecutionError, get_code_executor +from src.utils.config import settings + + +def print_result(result: dict) -> None: + """Print execution result, surfacing errors when they occur.""" + if result.get("success"): + print(f" {result['stdout'].strip()}\n") + else: + error = result.get("error") or result.get("stderr", "").strip() or "Unknown error" + print(f" ERROR: {error}\n") + + +async def main() -> None: + """Verify Modal sandbox isolation.""" + if not settings.modal_available: + print("Error: Modal credentials not configured.") + print("Set MODAL_TOKEN_ID and MODAL_TOKEN_SECRET in .env") + return + + try: + executor = get_code_executor() + loop = asyncio.get_running_loop() + + print("=" * 60) + print("Modal Sandbox Isolation Verification") + print("=" * 60 + "\n") + + # Test 1: Hostname + print("Test 1: Check hostname (should NOT be your machine)") + code1 = "import socket; print(f'Hostname: {socket.gethostname()}')" + result1 = await loop.run_in_executor(None, partial(executor.execute, code1)) + print_result(result1) + + # Test 2: Scientific libraries + print("Test 2: Verify scientific libraries") + code2 = """ +import pandas as pd +import numpy as np +import scipy +print(f"pandas: {pd.__version__}") +print(f"numpy: {np.__version__}") +print(f"scipy: {scipy.__version__}") +""" + result2 = await loop.run_in_executor(None, partial(executor.execute, code2)) + print_result(result2) + + # Test 3: Network blocked + print("Test 3: Verify network isolation") + code3 = """ +import urllib.request +try: + urllib.request.urlopen("https://google.com", timeout=2) + print("Network: ALLOWED (unexpected!)") +except Exception: + print("Network: BLOCKED (as expected)") +""" + result3 = await loop.run_in_executor(None, partial(executor.execute, code3)) + print_result(result3) + + # Test 4: Real statistics + print("Test 4: Execute statistical analysis") + code4 = """ +import pandas as pd +import scipy.stats as stats + +data = pd.DataFrame({'effect': [0.42, 0.38, 0.51]}) +mean = data['effect'].mean() +t_stat, p_val = stats.ttest_1samp(data['effect'], 0) + +print(f"Mean Effect: {mean:.3f}") +print(f"P-value: {p_val:.4f}") +print(f"Verdict: {'SUPPORTED' if p_val < 0.05 else 'INCONCLUSIVE'}") +""" + result4 = await loop.run_in_executor(None, partial(executor.execute, code4)) + print_result(result4) + + print("=" * 60) + print("All tests complete - Modal sandbox verified!") + print("=" * 60) + + except CodeExecutionError as e: + print(f"Error: Modal code execution failed: {e}") + print("Hint: Ensure Modal SDK is installed and credentials are valid.") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/orchestrator_demo/run_agent.py b/examples/orchestrator_demo/run_agent.py new file mode 100644 index 0000000000000000000000000000000000000000..44e569b0bf66a882e51ddebfd46e462c7d575365 --- /dev/null +++ b/examples/orchestrator_demo/run_agent.py @@ -0,0 +1,115 @@ +#!/usr/bin/env python3 +""" +Demo: DeepCritical Agent Loop (Search + Judge + Orchestrator). + +This script demonstrates the REAL Phase 4 orchestration: +- REAL Iterative Search (PubMed + ClinicalTrials + Europe PMC) +- REAL Evidence Evaluation (LLM Judge) +- REAL Orchestration Loop +- REAL Final Synthesis + +NO MOCKS. REAL API CALLS. + +Usage: + uv run python examples/orchestrator_demo/run_agent.py "metformin cancer" + uv run python examples/orchestrator_demo/run_agent.py "sildenafil heart failure" --iterations 5 + +Requires: OPENAI_API_KEY or ANTHROPIC_API_KEY +""" + +import argparse +import asyncio +import os +import sys + +from src.agent_factory.judges import JudgeHandler +from src.orchestrator import Orchestrator +from src.tools.clinicaltrials import ClinicalTrialsTool +from src.tools.europepmc import EuropePMCTool +from src.tools.pubmed import PubMedTool +from src.tools.search_handler import SearchHandler +from src.utils.models import OrchestratorConfig + +MAX_ITERATIONS = 10 + + +async def main() -> None: + """Run the REAL agent demo.""" + parser = argparse.ArgumentParser( + description="DeepCritical Agent Demo - REAL, No Mocks", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +This demo runs the REAL search-judge-synthesize loop: + 1. REAL search: PubMed + ClinicalTrials + Europe PMC queries + 2. REAL judge: Actual LLM assessing evidence quality + 3. REAL loop: Actual iterative refinement based on LLM decisions + 4. REAL synthesis: Actual research summary generation + +Examples: + uv run python examples/orchestrator_demo/run_agent.py "metformin cancer" + uv run python examples/orchestrator_demo/run_agent.py "aspirin alzheimer" --iterations 5 + """, + ) + parser.add_argument("query", help="Research query (e.g., 'metformin cancer')") + parser.add_argument("--iterations", type=int, default=3, help="Max iterations (default: 3)") + args = parser.parse_args() + + if not 1 <= args.iterations <= MAX_ITERATIONS: + print(f"Error: iterations must be between 1 and {MAX_ITERATIONS}") + sys.exit(1) + + # Fail fast: require API key + if not (os.getenv("OPENAI_API_KEY") or os.getenv("ANTHROPIC_API_KEY")): + print("=" * 60) + print("ERROR: This demo requires a real LLM.") + print() + print("Set one of the following in your .env file:") + print(" OPENAI_API_KEY=sk-...") + print(" ANTHROPIC_API_KEY=sk-ant-...") + print() + print("This is a REAL demo. No mocks. No fake data.") + print("=" * 60) + sys.exit(1) + + print(f"\n{'=' * 60}") + print("DeepCritical Agent Demo (REAL)") + print(f"Query: {args.query}") + print(f"Max Iterations: {args.iterations}") + print("Mode: REAL (All live API calls)") + print(f"{'=' * 60}\n") + + # Setup REAL components + search_handler = SearchHandler( + tools=[PubMedTool(), ClinicalTrialsTool(), EuropePMCTool()], timeout=30.0 + ) + judge_handler = JudgeHandler() # REAL LLM judge + + config = OrchestratorConfig(max_iterations=args.iterations) + orchestrator = Orchestrator( + search_handler=search_handler, judge_handler=judge_handler, config=config + ) + + # Run the REAL loop + try: + async for event in orchestrator.run(args.query): + # Print event with icon (remove markdown bold for CLI) + print(event.to_markdown().replace("**", "")) + + # Show search results count + if event.type == "search_complete" and event.data: + print(f" -> Found {event.data.get('new_count', 0)} new items") + + except Exception as e: + print(f"\n❌ Error: {e}") + raise + + print("\n" + "=" * 60) + print("Demo complete! Everything was REAL:") + print(" - Real PubMed + ClinicalTrials + Europe PMC searches") + print(" - Real LLM judge decisions") + print(" - Real iterative refinement") + print("=" * 60 + "\n") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/orchestrator_demo/run_magentic.py b/examples/orchestrator_demo/run_magentic.py new file mode 100644 index 0000000000000000000000000000000000000000..fe74450d9a19d40c706d32ffe97d452a2aa4f36b --- /dev/null +++ b/examples/orchestrator_demo/run_magentic.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +""" +Demo: Magentic-One Orchestrator for DeepCritical. + +This script demonstrates Phase 5 functionality: +- Multi-Agent Coordination (Searcher + Judge + Manager) +- Magentic-One Workflow + +Usage: + export OPENAI_API_KEY=... + uv run python examples/orchestrator_demo/run_magentic.py "metformin cancer" +""" + +import argparse +import asyncio +import os +import sys + +from src.agent_factory.judges import JudgeHandler +from src.orchestrator_factory import create_orchestrator +from src.tools.clinicaltrials import ClinicalTrialsTool +from src.tools.europepmc import EuropePMCTool +from src.tools.pubmed import PubMedTool +from src.tools.search_handler import SearchHandler +from src.utils.models import OrchestratorConfig + + +async def main() -> None: + """Run the magentic agent demo.""" + parser = argparse.ArgumentParser(description="Run DeepCritical Magentic Agent") + parser.add_argument("query", help="Research query (e.g., 'metformin cancer')") + parser.add_argument("--iterations", type=int, default=10, help="Max rounds") + args = parser.parse_args() + + # Check for OpenAI key specifically - Magentic requires function calling + # which is only supported by OpenAI's API (not Anthropic or HF Inference) + if not os.getenv("OPENAI_API_KEY"): + print("Error: OPENAI_API_KEY required. Magentic uses function calling") + print(" which requires OpenAI's API. For other providers, use mode='simple'.") + sys.exit(1) + + print(f"\n{'=' * 60}") + print("DeepCritical Magentic Agent Demo") + print(f"Query: {args.query}") + print("Mode: MAGENTIC (Multi-Agent)") + print(f"{'=' * 60}\n") + + # 1. Setup Search Tools + search_handler = SearchHandler( + tools=[PubMedTool(), ClinicalTrialsTool(), EuropePMCTool()], timeout=30.0 + ) + + # 2. Setup Judge + judge_handler = JudgeHandler() + + # 3. Setup Orchestrator via Factory + config = OrchestratorConfig(max_iterations=args.iterations) + orchestrator = create_orchestrator( + search_handler=search_handler, + judge_handler=judge_handler, + config=config, + mode="magentic", + ) + + if not orchestrator: + print("Failed to create Magentic orchestrator. Is agent-framework installed?") + sys.exit(1) + + # 4. Run Loop + try: + async for event in orchestrator.run(args.query): + # Print event with icon + # Clean up markdown for CLI + msg_obj = event.message + msg_text = "" + if hasattr(msg_obj, "text"): + msg_text = msg_obj.text + else: + msg_text = str(msg_obj) + + msg = msg_text.replace("\n", " ").replace("**", "")[:150] + print(f"[{event.type.upper()}] {msg}...") + + if event.type == "complete": + print("\n--- FINAL OUTPUT ---\n") + print(msg_text) + + except Exception as e: + print(f"\n❌ Error: {e}") + import traceback + + traceback.print_exc() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/rate_limiting_demo.py b/examples/rate_limiting_demo.py new file mode 100644 index 0000000000000000000000000000000000000000..90aab639ab00741300bd3e5c2f53f21e86789a1e --- /dev/null +++ b/examples/rate_limiting_demo.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +"""Demo script to verify rate limiting works correctly.""" + +import asyncio +import time + +from src.tools.pubmed import PubMedTool +from src.tools.rate_limiter import RateLimiter, get_pubmed_limiter, reset_pubmed_limiter + + +async def test_basic_limiter(): + """Test basic rate limiter behavior.""" + print("=" * 60) + print("Rate Limiting Demo") + print("=" * 60) + + # Test 1: Basic limiter + print("\n[Test 1] Testing 3/second limiter...") + limiter = RateLimiter("3/second") + + start = time.monotonic() + for i in range(6): + await limiter.acquire() + elapsed = time.monotonic() - start + print(f" Request {i + 1} at {elapsed:.2f}s") + + total = time.monotonic() - start + print(f" Total time for 6 requests: {total:.2f}s (expected ~2s)") + + +async def test_pubmed_limiter(): + """Test PubMed-specific limiter.""" + print("\n[Test 2] Testing PubMed limiter (shared)...") + + reset_pubmed_limiter() # Clean state + + # Without API key: 3/sec + limiter = get_pubmed_limiter(api_key=None) + print(f" Rate without key: {limiter.rate}") + + # Multiple tools should share the same limiter + tool1 = PubMedTool() + tool2 = PubMedTool() + + # Verify they share the limiter + print(f" Tools share limiter: {tool1._limiter is tool2._limiter}") + + +async def test_concurrent_requests(): + """Test rate limiting under concurrent load.""" + print("\n[Test 3] Testing concurrent request limiting...") + + limiter = RateLimiter("5/second") + + async def make_request(i: int): + await limiter.acquire() + return time.monotonic() + + start = time.monotonic() + # Launch 10 concurrent requests + tasks = [make_request(i) for i in range(10)] + times = await asyncio.gather(*tasks) + + # Calculate distribution + relative_times = [t - start for t in times] + print(f" Request times: {[f'{t:.2f}s' for t in sorted(relative_times)]}") + + total = max(relative_times) + print(f" All 10 requests completed in {total:.2f}s (expected ~2s)") + + +async def main(): + await test_basic_limiter() + await test_pubmed_limiter() + await test_concurrent_requests() + + print("\n" + "=" * 60) + print("Demo complete!") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/search_demo/run_search.py b/examples/search_demo/run_search.py new file mode 100644 index 0000000000000000000000000000000000000000..05f46d37bbef26d5674f050488c0f8b16f822ef1 --- /dev/null +++ b/examples/search_demo/run_search.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +""" +Demo: Search for drug repurposing evidence. + +This script demonstrates multi-source search functionality: +- PubMed search (biomedical literature) +- ClinicalTrials.gov search (clinical trial evidence) +- SearchHandler (parallel scatter-gather orchestration) + +Usage: + # From project root: + uv run python examples/search_demo/run_search.py + + # With custom query: + uv run python examples/search_demo/run_search.py "metformin cancer" + +Requirements: + - Optional: NCBI_API_KEY in .env for higher PubMed rate limits +""" + +import asyncio +import sys + +from src.tools.clinicaltrials import ClinicalTrialsTool +from src.tools.europepmc import EuropePMCTool +from src.tools.pubmed import PubMedTool +from src.tools.search_handler import SearchHandler + + +async def main(query: str) -> None: + """Run search demo with the given query.""" + print(f"\n{'=' * 60}") + print("DeepCritical Search Demo") + print(f"Query: {query}") + print(f"{'=' * 60}\n") + + # Initialize tools + pubmed = PubMedTool() + trials = ClinicalTrialsTool() + preprints = EuropePMCTool() + handler = SearchHandler(tools=[pubmed, trials, preprints], timeout=30.0) + + # Execute search + print("Searching PubMed, ClinicalTrials.gov, and Europe PMC in parallel...") + result = await handler.execute(query, max_results_per_tool=5) + + # Display results + print(f"\n{'=' * 60}") + print(f"Results: {result.total_found} pieces of evidence") + print(f"Sources: {', '.join(result.sources_searched)}") + if result.errors: + print(f"Errors: {result.errors}") + print(f"{'=' * 60}\n") + + for i, evidence in enumerate(result.evidence, 1): + print(f"[{i}] {evidence.citation.source.upper()}: {evidence.citation.title[:80]}...") + print(f" URL: {evidence.citation.url}") + print(f" Content: {evidence.content[:150]}...") + print() + + +if __name__ == "__main__": + # Default query or use command line arg + default_query = "metformin Alzheimer's disease drug repurposing" + query = sys.argv[1] if len(sys.argv) > 1 else default_query + + asyncio.run(main(query)) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000000000000000000000000000000000000..c055dd28068c414e8059ec55e7d4ea0fd3213d8b --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,172 @@ +[project] +name = "deepcritical" +version = "0.1.0" +description = "AI-Native Drug Repurposing Research Agent" +readme = "README.md" +requires-python = ">=3.11" +dependencies = [ + # Core + "pydantic>=2.7", + "pydantic-settings>=2.2", # For BaseSettings (config) + "pydantic-ai>=0.0.16", # Agent framework + # AI Providers + "openai>=1.0.0", + "anthropic>=0.18.0", + # HTTP & Parsing + "httpx>=0.27", # Async HTTP client (PubMed) + "beautifulsoup4>=4.12", # HTML parsing + "xmltodict>=0.13", # PubMed XML -> dict + "huggingface-hub>=0.20.0", # Hugging Face Inference API + # UI + "gradio[mcp]>=6.0.0", # Chat interface with MCP server support (6.0 required for css in launch()) + # Utils + "python-dotenv>=1.0", # .env loading + "tenacity>=8.2", # Retry logic + "structlog>=24.1", # Structured logging + "requests>=2.32.5", # ClinicalTrials.gov (httpx blocked by WAF) + "pydantic-graph>=1.22.0", + "limits>=3.0", # Rate limiting + "duckduckgo-search>=5.0", # Web search + "llama-index-llms-huggingface>=0.6.1", + "llama-index-llms-huggingface-api>=0.6.1", + "llama-index-vector-stores-chroma>=0.5.3", + "llama-index>=0.14.8", +] + +[project.optional-dependencies] +dev = [ + # Testing + "pytest>=8.0", + "pytest-asyncio>=0.23", + "pytest-sugar>=1.0", + "pytest-cov>=5.0", + "pytest-mock>=3.12", + "respx>=0.21", # Mock httpx requests + "typer>=0.9.0", # Gradio CLI dependency for smoke tests + + # Quality + "ruff>=0.4.0", + "mypy>=1.10", + "pre-commit>=3.7", +] +magentic = [ + "agent-framework-core>=1.0.0b251120,<2.0.0", # Microsoft Agent Framework (PyPI) +] +embeddings = [ + "chromadb>=0.4.0", + "sentence-transformers>=2.2.0", + "numpy<2.0", # chromadb compatibility: uses np.float_ removed in NumPy 2.0 +] +modal = [ + # Mario's Modal code execution + LlamaIndex RAG + "modal>=0.63.0", + "llama-index>=0.11.0", + "llama-index-llms-openai", + "llama-index-embeddings-openai", + "llama-index-vector-stores-chroma", + "chromadb>=0.4.0", + "numpy<2.0", # chromadb compatibility: uses np.float_ removed in NumPy 2.0 +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src"] + +# ============== RUFF CONFIG ============== +[tool.ruff] +line-length = 100 +target-version = "py311" +src = ["src"] +exclude = [ + "tests/", + "examples/", + "reference_repos/", + "folder/", +] + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "F", # pyflakes + "B", # flake8-bugbear + "I", # isort + "N", # pep8-naming + "UP", # pyupgrade + "PL", # pylint + "RUF", # ruff-specific +] +ignore = [ + "PLR0913", # Too many arguments (agents need many params) + "PLR0912", # Too many branches (complex orchestrator logic) + "PLR0911", # Too many return statements (complex agent logic) + "PLR2004", # Magic values (statistical constants like p-values) + "PLW0603", # Global statement (singleton pattern for Modal) + "PLC0415", # Lazy imports for optional dependencies + "E402", # Module level import not at top (needed for pytest.importorskip) + "E501", # Line too long (ignore line length violations) + "RUF100", # Unused noqa (version differences between local/CI) +] + +[tool.ruff.lint.isort] +known-first-party = ["src"] + +# ============== MYPY CONFIG ============== +[tool.mypy] +python_version = "3.11" +strict = true +ignore_missing_imports = true +disallow_untyped_defs = true +warn_return_any = true +warn_unused_ignores = false +explicit_package_bases = true +mypy_path = "." +exclude = [ + "^reference_repos/", + "^examples/", + "^folder/", +] + +# ============== PYTEST CONFIG ============== +[tool.pytest.ini_options] +testpaths = ["tests"] +asyncio_mode = "auto" +addopts = [ + "-v", + "--tb=short", + "--strict-markers", + "-p", + "no:logfire", +] +markers = [ + "unit: Unit tests (mocked)", + "integration: Integration tests (real APIs)", + "slow: Slow tests", + "openai: Tests that require OpenAI API key", + "huggingface: Tests that require HuggingFace API key or use HuggingFace models", + "embedding_provider: Tests that require API-based embedding providers (OpenAI, etc.)", + "local_embeddings: Tests that use local embeddings (sentence-transformers, ChromaDB)", +] + +# ============== COVERAGE CONFIG ============== +[tool.coverage.run] +source = ["src"] +omit = ["*/__init__.py"] + +[tool.coverage.report] +exclude_lines = [ + "pragma: no cover", + "if TYPE_CHECKING:", + "raise NotImplementedError", +] + +[dependency-groups] +dev = [ + "structlog>=25.5.0", + "ty>=0.0.1a28", +] + +# Note: agent-framework-core is optional for magentic mode (multi-agent orchestration) +# Version pinned to 1.0.0b* to avoid breaking changes. CI skips tests via pytest.importorskip diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..a9b57bb8ae90cc64a2ab0294b9848354d44f3b87 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,42 @@ +# Core dependencies for HuggingFace Spaces +pydantic>=2.7 +pydantic-settings>=2.2 +pydantic-ai>=0.0.16 + + +# AI Providers +openai>=1.0.0 +anthropic>=0.18.0 + +# Multi-agent orchestration (Advanced mode) +agent-framework-core>=1.0.0b251120 + +# Web search +duckduckgo-search>=5.0 + +# HTTP & Parsing +httpx>=0.27 +beautifulsoup4>=4.12 +xmltodict>=0.13 + +# UI (Gradio with MCP server support) +gradio[mcp]>=6.0.0 + +# Utils +python-dotenv>=1.0 +tenacity>=8.2 +structlog>=24.1 +requests>=2.32.5 +limits>=3.0 # Rate limiting (Phase 17) + +# Optional: Modal for code execution +modal>=0.63.0 + +# Optional: LlamaIndex RAG +llama-index>=0.11.0 +llama-index-llms-openai +llama-index-llms-huggingface # Optional: For HuggingFace LLM support in RAG +llama-index-embeddings-openai +llama-index-vector-stores-chroma +chromadb>=0.4.0 +sentence-transformers>=2.2.0 diff --git a/site/404.html b/site/404.html new file mode 100644 index 0000000000000000000000000000000000000000..45f282d33d43009ed9fdf61253bcd19eae81cc7b --- /dev/null +++ b/site/404.html @@ -0,0 +1 @@ + DeepCritical
\ No newline at end of file diff --git a/site/api/agents/index.html b/site/api/agents/index.html new file mode 100644 index 0000000000000000000000000000000000000000..6efb269a6634d8a0722480e2b9c840b64d4d3ea9 --- /dev/null +++ b/site/api/agents/index.html @@ -0,0 +1,59 @@ + Agents API Reference - DeepCritical

Agents API Reference

This page documents the API for DeepCritical agents.

KnowledgeGapAgent

Module: src.agents.knowledge_gap

Purpose: Evaluates research state and identifies knowledge gaps.

Methods

evaluate

async def evaluate(
+    self,
+    query: str,
+    background_context: str,
+    conversation_history: Conversation,
+    iteration: int,
+    time_elapsed_minutes: float,
+    max_time_minutes: float
+) -> KnowledgeGapOutput
+

Evaluates research completeness and identifies outstanding knowledge gaps.

Parameters: - query: Research query string - background_context: Background context for the query - conversation_history: Conversation history with previous iterations - iteration: Current iteration number - time_elapsed_minutes: Elapsed time in minutes - max_time_minutes: Maximum time limit in minutes

Returns: KnowledgeGapOutput with: - research_complete: Boolean indicating if research is complete - outstanding_gaps: List of remaining knowledge gaps

ToolSelectorAgent

Module: src.agents.tool_selector

Purpose: Selects appropriate tools for addressing knowledge gaps.

Methods

select_tools

async def select_tools(
+    self,
+    query: str,
+    knowledge_gaps: list[str],
+    available_tools: list[str]
+) -> AgentSelectionPlan
+

Selects tools for addressing knowledge gaps.

Parameters: - query: Research query string - knowledge_gaps: List of knowledge gaps to address - available_tools: List of available tool names

Returns: AgentSelectionPlan with list of AgentTask objects.

WriterAgent

Module: src.agents.writer

Purpose: Generates final reports from research findings.

Methods

write_report

async def write_report(
+    self,
+    query: str,
+    findings: str,
+    output_length: str = "medium",
+    output_instructions: str | None = None
+) -> str
+

Generates a markdown report from research findings.

Parameters: - query: Research query string - findings: Research findings to include in report - output_length: Desired output length ("short", "medium", "long") - output_instructions: Additional instructions for report generation

Returns: Markdown string with numbered citations.

LongWriterAgent

Module: src.agents.long_writer

Purpose: Long-form report generation with section-by-section writing.

Methods

write_next_section

async def write_next_section(
+    self,
+    query: str,
+    draft: ReportDraft,
+    section_title: str,
+    section_content: str
+) -> LongWriterOutput
+

Writes the next section of a long-form report.

Parameters: - query: Research query string - draft: Current report draft - section_title: Title of the section to write - section_content: Content/guidance for the section

Returns: LongWriterOutput with updated draft.

write_report

async def write_report(
+    self,
+    query: str,
+    report_title: str,
+    report_draft: ReportDraft
+) -> str
+

Generates final report from draft.

Parameters: - query: Research query string - report_title: Title of the report - report_draft: Complete report draft

Returns: Final markdown report string.

ProofreaderAgent

Module: src.agents.proofreader

Purpose: Proofreads and polishes report drafts.

Methods

proofread

async def proofread(
+    self,
+    query: str,
+    report_title: str,
+    report_draft: ReportDraft
+) -> str
+

Proofreads and polishes a report draft.

Parameters: - query: Research query string - report_title: Title of the report - report_draft: Report draft to proofread

Returns: Polished markdown string.

ThinkingAgent

Module: src.agents.thinking

Purpose: Generates observations from conversation history.

Methods

generate_observations

async def generate_observations(
+    self,
+    query: str,
+    background_context: str,
+    conversation_history: Conversation
+) -> str
+

Generates observations from conversation history.

Parameters: - query: Research query string - background_context: Background context - conversation_history: Conversation history

Returns: Observation string.

InputParserAgent

Module: src.agents.input_parser

Purpose: Parses and improves user queries, detects research mode.

Methods

parse_query

async def parse_query(
+    self,
+    query: str
+) -> ParsedQuery
+

Parses and improves a user query.

Parameters: - query: Original query string

Returns: ParsedQuery with: - original_query: Original query string - improved_query: Refined query string - research_mode: "iterative" or "deep" - key_entities: List of key entities - research_questions: List of research questions

Factory Functions

All agents have factory functions in src.agent_factory.agents:

def create_knowledge_gap_agent(model: Any | None = None) -> KnowledgeGapAgent
+def create_tool_selector_agent(model: Any | None = None) -> ToolSelectorAgent
+def create_writer_agent(model: Any | None = None) -> WriterAgent
+def create_long_writer_agent(model: Any | None = None) -> LongWriterAgent
+def create_proofreader_agent(model: Any | None = None) -> ProofreaderAgent
+def create_thinking_agent(model: Any | None = None) -> ThinkingAgent
+def create_input_parser_agent(model: Any | None = None) -> InputParserAgent
+

Parameters: - model: Optional Pydantic AI model. If None, uses get_model() from settings.

Returns: Agent instance.

See Also

\ No newline at end of file diff --git a/site/api/models/index.html b/site/api/models/index.html new file mode 100644 index 0000000000000000000000000000000000000000..eb7a8f364eb7affbfc6fff643977bff078869e12 --- /dev/null +++ b/site/api/models/index.html @@ -0,0 +1,54 @@ + Models API Reference - DeepCritical

Models API Reference

This page documents the Pydantic models used throughout DeepCritical.

Evidence

Module: src.utils.models

Purpose: Represents evidence from search results.

class Evidence(BaseModel):
+    citation: Citation
+    content: str
+    relevance_score: float = Field(ge=0.0, le=1.0)
+    metadata: dict[str, Any] = Field(default_factory=dict)
+

Fields: - citation: Citation information (title, URL, date, authors) - content: Evidence text content - relevance_score: Relevance score (0.0-1.0) - metadata: Additional metadata dictionary

Citation

Module: src.utils.models

Purpose: Citation information for evidence.

class Citation(BaseModel):
+    title: str
+    url: str
+    date: str | None = None
+    authors: list[str] = Field(default_factory=list)
+

Fields: - title: Article/trial title - url: Source URL - date: Publication date (optional) - authors: List of authors (optional)

KnowledgeGapOutput

Module: src.utils.models

Purpose: Output from knowledge gap evaluation.

class KnowledgeGapOutput(BaseModel):
+    research_complete: bool
+    outstanding_gaps: list[str] = Field(default_factory=list)
+

Fields: - research_complete: Boolean indicating if research is complete - outstanding_gaps: List of remaining knowledge gaps

AgentSelectionPlan

Module: src.utils.models

Purpose: Plan for tool/agent selection.

class AgentSelectionPlan(BaseModel):
+    tasks: list[AgentTask] = Field(default_factory=list)
+

Fields: - tasks: List of agent tasks to execute

AgentTask

Module: src.utils.models

Purpose: Individual agent task.

class AgentTask(BaseModel):
+    agent_name: str
+    query: str
+    context: dict[str, Any] = Field(default_factory=dict)
+

Fields: - agent_name: Name of agent to use - query: Task query - context: Additional context dictionary

ReportDraft

Module: src.utils.models

Purpose: Draft structure for long-form reports.

class ReportDraft(BaseModel):
+    title: str
+    sections: list[ReportSection] = Field(default_factory=list)
+    references: list[Citation] = Field(default_factory=list)
+

Fields: - title: Report title - sections: List of report sections - references: List of citations

ReportSection

Module: src.utils.models

Purpose: Individual section in a report draft.

class ReportSection(BaseModel):
+    title: str
+    content: str
+    order: int
+

Fields: - title: Section title - content: Section content - order: Section order number

ParsedQuery

Module: src.utils.models

Purpose: Parsed and improved query.

class ParsedQuery(BaseModel):
+    original_query: str
+    improved_query: str
+    research_mode: Literal["iterative", "deep"]
+    key_entities: list[str] = Field(default_factory=list)
+    research_questions: list[str] = Field(default_factory=list)
+

Fields: - original_query: Original query string - improved_query: Refined query string - research_mode: Research mode ("iterative" or "deep") - key_entities: List of key entities - research_questions: List of research questions

Conversation

Module: src.utils.models

Purpose: Conversation history with iterations.

class Conversation(BaseModel):
+    iterations: list[IterationData] = Field(default_factory=list)
+

Fields: - iterations: List of iteration data

IterationData

Module: src.utils.models

Purpose: Data for a single iteration.

class IterationData(BaseModel):
+    iteration: int
+    observations: str | None = None
+    knowledge_gaps: list[str] = Field(default_factory=list)
+    tool_calls: list[dict[str, Any]] = Field(default_factory=list)
+    findings: str | None = None
+    thoughts: str | None = None
+

Fields: - iteration: Iteration number - observations: Generated observations - knowledge_gaps: Identified knowledge gaps - tool_calls: Tool calls made - findings: Findings from tools - thoughts: Agent thoughts

AgentEvent

Module: src.utils.models

Purpose: Event emitted during research execution.

class AgentEvent(BaseModel):
+    type: str
+    iteration: int | None = None
+    data: dict[str, Any] = Field(default_factory=dict)
+

Fields: - type: Event type (e.g., "started", "search_complete", "complete") - iteration: Iteration number (optional) - data: Event data dictionary

BudgetStatus

Module: src.utils.models

Purpose: Current budget status.

class BudgetStatus(BaseModel):
+    tokens_used: int
+    tokens_limit: int
+    time_elapsed_seconds: float
+    time_limit_seconds: float
+    iterations: int
+    iterations_limit: int
+

Fields: - tokens_used: Tokens used so far - tokens_limit: Token limit - time_elapsed_seconds: Elapsed time in seconds - time_limit_seconds: Time limit in seconds - iterations: Current iteration count - iterations_limit: Iteration limit

See Also

\ No newline at end of file diff --git a/site/api/orchestrators/index.html b/site/api/orchestrators/index.html new file mode 100644 index 0000000000000000000000000000000000000000..60629fe2ea7c1a87da38f773fc8ab07131de9385 --- /dev/null +++ b/site/api/orchestrators/index.html @@ -0,0 +1,35 @@ + Orchestrators API Reference - DeepCritical

Orchestrators API Reference

This page documents the API for DeepCritical orchestrators.

IterativeResearchFlow

Module: src.orchestrator.research_flow

Purpose: Single-loop research with search-judge-synthesize cycles.

Methods

run

async def run(
+    self,
+    query: str,
+    background_context: str = "",
+    max_iterations: int | None = None,
+    max_time_minutes: float | None = None,
+    token_budget: int | None = None
+) -> AsyncGenerator[AgentEvent, None]
+

Runs iterative research flow.

Parameters: - query: Research query string - background_context: Background context (default: "") - max_iterations: Maximum iterations (default: from settings) - max_time_minutes: Maximum time in minutes (default: from settings) - token_budget: Token budget (default: from settings)

Yields: AgentEvent objects for: - started: Research started - search_complete: Search completed - judge_complete: Evidence evaluation completed - synthesizing: Generating report - complete: Research completed - error: Error occurred

DeepResearchFlow

Module: src.orchestrator.research_flow

Purpose: Multi-section parallel research with planning and synthesis.

Methods

run

async def run(
+    self,
+    query: str,
+    background_context: str = "",
+    max_iterations_per_section: int | None = None,
+    max_time_minutes: float | None = None,
+    token_budget: int | None = None
+) -> AsyncGenerator[AgentEvent, None]
+

Runs deep research flow.

Parameters: - query: Research query string - background_context: Background context (default: "") - max_iterations_per_section: Maximum iterations per section (default: from settings) - max_time_minutes: Maximum time in minutes (default: from settings) - token_budget: Token budget (default: from settings)

Yields: AgentEvent objects for: - started: Research started - planning: Creating research plan - looping: Running parallel research loops - synthesizing: Synthesizing results - complete: Research completed - error: Error occurred

GraphOrchestrator

Module: src.orchestrator.graph_orchestrator

Purpose: Graph-based execution using Pydantic AI agents as nodes.

Methods

run

async def run(
+    self,
+    query: str,
+    research_mode: str = "auto",
+    use_graph: bool = True
+) -> AsyncGenerator[AgentEvent, None]
+

Runs graph-based research orchestration.

Parameters: - query: Research query string - research_mode: Research mode ("iterative", "deep", or "auto") - use_graph: Whether to use graph execution (default: True)

Yields: AgentEvent objects during graph execution.

Orchestrator Factory

Module: src.orchestrator_factory

Purpose: Factory for creating orchestrators.

Functions

create_orchestrator

def create_orchestrator(
+    search_handler: SearchHandlerProtocol,
+    judge_handler: JudgeHandlerProtocol,
+    config: dict[str, Any],
+    mode: str | None = None
+) -> Any
+

Creates an orchestrator instance.

Parameters: - search_handler: Search handler protocol implementation - judge_handler: Judge handler protocol implementation - config: Configuration dictionary - mode: Orchestrator mode ("simple", "advanced", "magentic", or None for auto-detect)

Returns: Orchestrator instance.

Raises: - ValueError: If requirements not met

Modes: - "simple": Legacy orchestrator - "advanced" or "magentic": Magentic orchestrator (requires OpenAI API key) - None: Auto-detect based on API key availability

MagenticOrchestrator

Module: src.orchestrator_magentic

Purpose: Multi-agent coordination using Microsoft Agent Framework.

Methods

run

async def run(
+    self,
+    query: str,
+    max_rounds: int = 15,
+    max_stalls: int = 3
+) -> AsyncGenerator[AgentEvent, None]
+

Runs Magentic orchestration.

Parameters: - query: Research query string - max_rounds: Maximum rounds (default: 15) - max_stalls: Maximum stalls before reset (default: 3)

Yields: AgentEvent objects converted from Magentic events.

Requirements: - agent-framework-core package - OpenAI API key

See Also

\ No newline at end of file diff --git a/site/api/services/index.html b/site/api/services/index.html new file mode 100644 index 0000000000000000000000000000000000000000..cfe3dbdde686a4d295053e246d465c65bf8ac175 --- /dev/null +++ b/site/api/services/index.html @@ -0,0 +1,30 @@ + Services API Reference - DeepCritical

Services API Reference

This page documents the API for DeepCritical services.

EmbeddingService

Module: src.services.embeddings

Purpose: Local sentence-transformers for semantic search and deduplication.

Methods

embed

async def embed(self, text: str) -> list[float]
+

Generates embedding for a text string.

Parameters: - text: Text to embed

Returns: Embedding vector as list of floats.

embed_batch

async def embed_batch(self, texts: list[str]) -> list[list[float]]
+

Generates embeddings for multiple texts.

Parameters: - texts: List of texts to embed

Returns: List of embedding vectors.

similarity

async def similarity(self, text1: str, text2: str) -> float
+

Calculates similarity between two texts.

Parameters: - text1: First text - text2: Second text

Returns: Similarity score (0.0-1.0).

find_duplicates

async def find_duplicates(
+    self,
+    texts: list[str],
+    threshold: float = 0.85
+) -> list[tuple[int, int]]
+

Finds duplicate texts based on similarity threshold.

Parameters: - texts: List of texts to check - threshold: Similarity threshold (default: 0.85)

Returns: List of (index1, index2) tuples for duplicate pairs.

Factory Function

get_embedding_service

@lru_cache(maxsize=1)
+def get_embedding_service() -> EmbeddingService
+

Returns singleton EmbeddingService instance.

LlamaIndexRAGService

Module: src.services.rag

Purpose: Retrieval-Augmented Generation using LlamaIndex.

Methods

ingest_evidence

async def ingest_evidence(self, evidence: list[Evidence]) -> None
+

Ingests evidence into RAG service.

Parameters: - evidence: List of Evidence objects to ingest

Note: Requires OpenAI API key for embeddings.

retrieve

async def retrieve(
+    self,
+    query: str,
+    top_k: int = 5
+) -> list[Document]
+

Retrieves relevant documents for a query.

Parameters: - query: Search query string - top_k: Number of top results to return (default: 5)

Returns: List of Document objects with metadata.

query

async def query(
+    self,
+    query: str,
+    top_k: int = 5
+) -> str
+

Queries RAG service and returns formatted results.

Parameters: - query: Search query string - top_k: Number of top results to return (default: 5)

Returns: Formatted query results as string.

Factory Function

get_rag_service

@lru_cache(maxsize=1)
+def get_rag_service() -> LlamaIndexRAGService | None
+

Returns singleton LlamaIndexRAGService instance, or None if OpenAI key not available.

StatisticalAnalyzer

Module: src.services.statistical_analyzer

Purpose: Secure execution of AI-generated statistical code.

Methods

analyze

async def analyze(
+    self,
+    hypothesis: str,
+    evidence: list[Evidence],
+    data_description: str | None = None
+) -> AnalysisResult
+

Analyzes a hypothesis using statistical methods.

Parameters: - hypothesis: Hypothesis to analyze - evidence: List of Evidence objects - data_description: Optional data description

Returns: AnalysisResult with: - verdict: SUPPORTED, REFUTED, or INCONCLUSIVE - code: Generated analysis code - output: Execution output - error: Error message if execution failed

Note: Requires Modal credentials for sandbox execution.

See Also

\ No newline at end of file diff --git a/site/api/tools/index.html b/site/api/tools/index.html new file mode 100644 index 0000000000000000000000000000000000000000..97ec568dbe719ce1d4ce211db5bb0d4fc7715456 --- /dev/null +++ b/site/api/tools/index.html @@ -0,0 +1,44 @@ + Tools API Reference - DeepCritical

Tools API Reference

This page documents the API for DeepCritical search tools.

SearchTool Protocol

All tools implement the SearchTool protocol:

class SearchTool(Protocol):
+    @property
+    def name(self) -> str: ...
+    
+    async def search(
+        self, 
+        query: str, 
+        max_results: int = 10
+    ) -> list[Evidence]: ...
+

PubMedTool

Module: src.tools.pubmed

Purpose: Search peer-reviewed biomedical literature from PubMed.

Properties

name

@property
+def name(self) -> str
+

Returns tool name: "pubmed"

Methods

async def search(
+    self,
+    query: str,
+    max_results: int = 10
+) -> list[Evidence]
+

Searches PubMed for articles.

Parameters: - query: Search query string - max_results: Maximum number of results to return (default: 10)

Returns: List of Evidence objects with PubMed articles.

Raises: - SearchError: If search fails - RateLimitError: If rate limit is exceeded

ClinicalTrialsTool

Module: src.tools.clinicaltrials

Purpose: Search ClinicalTrials.gov for interventional studies.

Properties

name

@property
+def name(self) -> str
+

Returns tool name: "clinicaltrials"

Methods

search

async def search(
+    self,
+    query: str,
+    max_results: int = 10
+) -> list[Evidence]
+

Searches ClinicalTrials.gov for trials.

Parameters: - query: Search query string - max_results: Maximum number of results to return (default: 10)

Returns: List of Evidence objects with clinical trials.

Note: Only returns interventional studies with status: COMPLETED, ACTIVE_NOT_RECRUITING, RECRUITING, ENROLLING_BY_INVITATION

Raises: - SearchError: If search fails

EuropePMCTool

Module: src.tools.europepmc

Purpose: Search Europe PMC for preprints and peer-reviewed articles.

Properties

name

@property
+def name(self) -> str
+

Returns tool name: "europepmc"

Methods

search

async def search(
+    self,
+    query: str,
+    max_results: int = 10
+) -> list[Evidence]
+

Searches Europe PMC for articles and preprints.

Parameters: - query: Search query string - max_results: Maximum number of results to return (default: 10)

Returns: List of Evidence objects with articles/preprints.

Note: Includes both preprints (marked with [PREPRINT - Not peer-reviewed]) and peer-reviewed articles.

Raises: - SearchError: If search fails

RAGTool

Module: src.tools.rag_tool

Purpose: Semantic search within collected evidence.

Properties

name

@property
+def name(self) -> str
+

Returns tool name: "rag"

Methods

search

async def search(
+    self,
+    query: str,
+    max_results: int = 10
+) -> list[Evidence]
+

Searches collected evidence using semantic similarity.

Parameters: - query: Search query string - max_results: Maximum number of results to return (default: 10)

Returns: List of Evidence objects from collected evidence.

Note: Requires evidence to be ingested into RAG service first.

SearchHandler

Module: src.tools.search_handler

Purpose: Orchestrates parallel searches across multiple tools.

Methods

search

async def search(
+    self,
+    query: str,
+    tools: list[SearchTool] | None = None,
+    max_results_per_tool: int = 10
+) -> SearchResult
+

Searches multiple tools in parallel.

Parameters: - query: Search query string - tools: List of tools to use (default: all available tools) - max_results_per_tool: Maximum results per tool (default: 10)

Returns: SearchResult with: - evidence: Aggregated list of evidence - tool_results: Results per tool - total_count: Total number of results

Note: Uses asyncio.gather() for parallel execution. Handles tool failures gracefully.

See Also

\ No newline at end of file diff --git a/site/architecture/agents/index.html b/site/architecture/agents/index.html new file mode 100644 index 0000000000000000000000000000000000000000..931db87b207a110b9dfbc8166bad87e744d66ec8 --- /dev/null +++ b/site/architecture/agents/index.html @@ -0,0 +1,5 @@ + Agents Architecture - DeepCritical

Agents Architecture

DeepCritical uses Pydantic AI agents for all AI-powered operations. All agents follow a consistent pattern and use structured output types.

Agent Pattern

All agents use the Pydantic AI Agent class with the following structure:

  • System Prompt: Module-level constant with date injection
  • Agent Class: __init__(model: Any | None = None)
  • Main Method: Async method (e.g., async def evaluate(), async def write_report())
  • Factory Function: def create_agent_name(model: Any | None = None) -> AgentName

Model Initialization

Agents use get_model() from src/agent_factory/judges.py if no model is provided. This supports:

  • OpenAI models
  • Anthropic models
  • HuggingFace Inference API models

The model selection is based on the configured LLM_PROVIDER in settings.

Error Handling

Agents return fallback values on failure rather than raising exceptions:

  • KnowledgeGapOutput(research_complete=False, outstanding_gaps=[...])
  • Empty strings for text outputs
  • Default structured outputs

All errors are logged with context using structlog.

Input Validation

All agents validate inputs:

  • Check that queries/inputs are not empty
  • Truncate very long inputs with warnings
  • Handle None values gracefully

Output Types

Agents use structured output types from src/utils/models.py:

  • KnowledgeGapOutput: Research completeness evaluation
  • AgentSelectionPlan: Tool selection plan
  • ReportDraft: Long-form report structure
  • ParsedQuery: Query parsing and mode detection

For text output (writer agents), agents return str directly.

Agent Types

Knowledge Gap Agent

File: src/agents/knowledge_gap.py

Purpose: Evaluates research state and identifies knowledge gaps.

Output: KnowledgeGapOutput with: - research_complete: Boolean indicating if research is complete - outstanding_gaps: List of remaining knowledge gaps

Methods: - async def evaluate(query, background_context, conversation_history, iteration, time_elapsed_minutes, max_time_minutes) -> KnowledgeGapOutput

Tool Selector Agent

File: src/agents/tool_selector.py

Purpose: Selects appropriate tools for addressing knowledge gaps.

Output: AgentSelectionPlan with list of AgentTask objects.

Available Agents: - WebSearchAgent: General web search for fresh information - SiteCrawlerAgent: Research specific entities/companies - RAGAgent: Semantic search within collected evidence

Writer Agent

File: src/agents/writer.py

Purpose: Generates final reports from research findings.

Output: Markdown string with numbered citations.

Methods: - async def write_report(query, findings, output_length, output_instructions) -> str

Features: - Validates inputs - Truncates very long findings (max 50000 chars) with warning - Retry logic for transient failures (3 retries) - Citation validation before returning

Long Writer Agent

File: src/agents/long_writer.py

Purpose: Long-form report generation with section-by-section writing.

Input/Output: Uses ReportDraft models.

Methods: - async def write_next_section(query, draft, section_title, section_content) -> LongWriterOutput - async def write_report(query, report_title, report_draft) -> str

Features: - Writes sections iteratively - Aggregates references across sections - Reformats section headings and references - Deduplicates and renumbers references

Proofreader Agent

File: src/agents/proofreader.py

Purpose: Proofreads and polishes report drafts.

Input: ReportDraft Output: Polished markdown string

Methods: - async def proofread(query, report_title, report_draft) -> str

Features: - Removes duplicate content across sections - Adds executive summary if multiple sections - Preserves all references and citations - Improves flow and readability

Thinking Agent

File: src/agents/thinking.py

Purpose: Generates observations from conversation history.

Output: Observation string

Methods: - async def generate_observations(query, background_context, conversation_history) -> str

Input Parser Agent

File: src/agents/input_parser.py

Purpose: Parses and improves user queries, detects research mode.

Output: ParsedQuery with: - original_query: Original query string - improved_query: Refined query string - research_mode: "iterative" or "deep" - key_entities: List of key entities - research_questions: List of research questions

Factory Functions

All agents have factory functions in src/agent_factory/agents.py:

def create_knowledge_gap_agent(model: Any | None = None) -> KnowledgeGapAgent
+def create_tool_selector_agent(model: Any | None = None) -> ToolSelectorAgent
+def create_writer_agent(model: Any | None = None) -> WriterAgent
+# ... etc
+

Factory functions: - Use get_model() if no model provided - Raise ConfigurationError if creation fails - Log agent creation

See Also

\ No newline at end of file diff --git a/site/architecture/graph-orchestration/index.html b/site/architecture/graph-orchestration/index.html new file mode 100644 index 0000000000000000000000000000000000000000..d5129b04576d684a8a35fc0cb4de53ad87cb3f72 --- /dev/null +++ b/site/architecture/graph-orchestration/index.html @@ -0,0 +1,9 @@ + Graph Orchestration Architecture - DeepCritical

Graph Orchestration Architecture

Overview

Phase 4 implements a graph-based orchestration system for research workflows using Pydantic AI agents as nodes. This enables better parallel execution, conditional routing, and state management compared to simple agent chains.

Graph Structure

Nodes

Graph nodes represent different stages in the research workflow:

  1. Agent Nodes: Execute Pydantic AI agents
  2. Input: Prompt/query
  3. Output: Structured or unstructured response
  4. Examples: KnowledgeGapAgent, ToolSelectorAgent, ThinkingAgent

  5. State Nodes: Update or read workflow state

  6. Input: Current state
  7. Output: Updated state
  8. Examples: Update evidence, update conversation history

  9. Decision Nodes: Make routing decisions based on conditions

  10. Input: Current state/results
  11. Output: Next node ID
  12. Examples: Continue research vs. complete research

  13. Parallel Nodes: Execute multiple nodes concurrently

  14. Input: List of node IDs
  15. Output: Aggregated results
  16. Examples: Parallel iterative research loops

Edges

Edges define transitions between nodes:

  1. Sequential Edges: Always traversed (no condition)
  2. From: Source node
  3. To: Target node
  4. Condition: None (always True)

  5. Conditional Edges: Traversed based on condition

  6. From: Source node
  7. To: Target node
  8. Condition: Callable that returns bool
  9. Example: If research complete → go to writer, else → continue loop

  10. Parallel Edges: Used for parallel execution branches

  11. From: Parallel node
  12. To: Multiple target nodes
  13. Execution: All targets run concurrently

Graph Patterns

Iterative Research Graph

[Input] → [Thinking] → [Knowledge Gap] → [Decision: Complete?]
+                                              ↓ No          ↓ Yes
+                                    [Tool Selector]    [Writer]
+                                              ↓
+                                    [Execute Tools] → [Loop Back]
+

Deep Research Graph

[Input] → [Planner] → [Parallel Iterative Loops] → [Synthesizer]
+                           ↓         ↓         ↓
+                        [Loop1]  [Loop2]  [Loop3]
+

State Management

State is managed via WorkflowState using ContextVar for thread-safe isolation:

  • Evidence: Collected evidence from searches
  • Conversation: Iteration history (gaps, tool calls, findings, thoughts)
  • Embedding Service: For semantic search

State transitions occur at state nodes, which update the global workflow state.

Execution Flow

  1. Graph Construction: Build graph from nodes and edges
  2. Graph Validation: Ensure graph is valid (no cycles, all nodes reachable)
  3. Graph Execution: Traverse graph from entry node
  4. Node Execution: Execute each node based on type
  5. Edge Evaluation: Determine next node(s) based on edges
  6. Parallel Execution: Use asyncio.gather() for parallel nodes
  7. State Updates: Update state at state nodes
  8. Event Streaming: Yield events during execution for UI

Conditional Routing

Decision nodes evaluate conditions and return next node IDs:

  • Knowledge Gap Decision: If research_complete → writer, else → tool selector
  • Budget Decision: If budget exceeded → exit, else → continue
  • Iteration Decision: If max iterations → exit, else → continue

Parallel Execution

Parallel nodes execute multiple nodes concurrently:

  • Each parallel branch runs independently
  • Results are aggregated after all branches complete
  • State is synchronized after parallel execution
  • Errors in one branch don't stop other branches

Budget Enforcement

Budget constraints are enforced at decision nodes:

  • Token Budget: Track LLM token usage
  • Time Budget: Track elapsed time
  • Iteration Budget: Track iteration count

If any budget is exceeded, execution routes to exit node.

Error Handling

Errors are handled at multiple levels:

  1. Node Level: Catch errors in individual node execution
  2. Graph Level: Handle errors during graph traversal
  3. State Level: Rollback state changes on error

Errors are logged and yield error events for UI.

Backward Compatibility

Graph execution is optional via feature flag:

  • USE_GRAPH_EXECUTION=true: Use graph-based execution
  • USE_GRAPH_EXECUTION=false: Use agent chain execution (existing)

This allows gradual migration and fallback if needed.

\ No newline at end of file diff --git a/site/architecture/graph_orchestration/index.html b/site/architecture/graph_orchestration/index.html new file mode 100644 index 0000000000000000000000000000000000000000..6b380c789bd1f7de0481d1f719404be0c481c924 --- /dev/null +++ b/site/architecture/graph_orchestration/index.html @@ -0,0 +1,9 @@ + Graph Orchestration Architecture - DeepCritical

Graph Orchestration Architecture

Overview

Phase 4 implements a graph-based orchestration system for research workflows using Pydantic AI agents as nodes. This enables better parallel execution, conditional routing, and state management compared to simple agent chains.

Graph Structure

Nodes

Graph nodes represent different stages in the research workflow:

  1. Agent Nodes: Execute Pydantic AI agents
  2. Input: Prompt/query
  3. Output: Structured or unstructured response
  4. Examples: KnowledgeGapAgent, ToolSelectorAgent, ThinkingAgent

  5. State Nodes: Update or read workflow state

  6. Input: Current state
  7. Output: Updated state
  8. Examples: Update evidence, update conversation history

  9. Decision Nodes: Make routing decisions based on conditions

  10. Input: Current state/results
  11. Output: Next node ID
  12. Examples: Continue research vs. complete research

  13. Parallel Nodes: Execute multiple nodes concurrently

  14. Input: List of node IDs
  15. Output: Aggregated results
  16. Examples: Parallel iterative research loops

Edges

Edges define transitions between nodes:

  1. Sequential Edges: Always traversed (no condition)
  2. From: Source node
  3. To: Target node
  4. Condition: None (always True)

  5. Conditional Edges: Traversed based on condition

  6. From: Source node
  7. To: Target node
  8. Condition: Callable that returns bool
  9. Example: If research complete → go to writer, else → continue loop

  10. Parallel Edges: Used for parallel execution branches

  11. From: Parallel node
  12. To: Multiple target nodes
  13. Execution: All targets run concurrently

Graph Patterns

Iterative Research Graph

[Input] → [Thinking] → [Knowledge Gap] → [Decision: Complete?]
+                                              ↓ No          ↓ Yes
+                                    [Tool Selector]    [Writer]
+                                              ↓
+                                    [Execute Tools] → [Loop Back]
+

Deep Research Graph

[Input] → [Planner] → [Parallel Iterative Loops] → [Synthesizer]
+                           ↓         ↓         ↓
+                        [Loop1]  [Loop2]  [Loop3]
+

State Management

State is managed via WorkflowState using ContextVar for thread-safe isolation:

  • Evidence: Collected evidence from searches
  • Conversation: Iteration history (gaps, tool calls, findings, thoughts)
  • Embedding Service: For semantic search

State transitions occur at state nodes, which update the global workflow state.

Execution Flow

  1. Graph Construction: Build graph from nodes and edges
  2. Graph Validation: Ensure graph is valid (no cycles, all nodes reachable)
  3. Graph Execution: Traverse graph from entry node
  4. Node Execution: Execute each node based on type
  5. Edge Evaluation: Determine next node(s) based on edges
  6. Parallel Execution: Use asyncio.gather() for parallel nodes
  7. State Updates: Update state at state nodes
  8. Event Streaming: Yield events during execution for UI

Conditional Routing

Decision nodes evaluate conditions and return next node IDs:

  • Knowledge Gap Decision: If research_complete → writer, else → tool selector
  • Budget Decision: If budget exceeded → exit, else → continue
  • Iteration Decision: If max iterations → exit, else → continue

Parallel Execution

Parallel nodes execute multiple nodes concurrently:

  • Each parallel branch runs independently
  • Results are aggregated after all branches complete
  • State is synchronized after parallel execution
  • Errors in one branch don't stop other branches

Budget Enforcement

Budget constraints are enforced at decision nodes:

  • Token Budget: Track LLM token usage
  • Time Budget: Track elapsed time
  • Iteration Budget: Track iteration count

If any budget is exceeded, execution routes to exit node.

Error Handling

Errors are handled at multiple levels:

  1. Node Level: Catch errors in individual node execution
  2. Graph Level: Handle errors during graph traversal
  3. State Level: Rollback state changes on error

Errors are logged and yield error events for UI.

Backward Compatibility

Graph execution is optional via feature flag:

  • USE_GRAPH_EXECUTION=true: Use graph-based execution
  • USE_GRAPH_EXECUTION=false: Use agent chain execution (existing)

This allows gradual migration and fallback if needed.

See Also

\ No newline at end of file diff --git a/site/architecture/middleware/index.html b/site/architecture/middleware/index.html new file mode 100644 index 0000000000000000000000000000000000000000..4002a2c8a5060624c018734056b9ceb23372fbe4 --- /dev/null +++ b/site/architecture/middleware/index.html @@ -0,0 +1,26 @@ + Middleware Architecture - DeepCritical

Middleware Architecture

DeepCritical uses middleware for state management, budget tracking, and workflow coordination.

State Management

WorkflowState

File: src/middleware/state_machine.py

Purpose: Thread-safe state management for research workflows

Implementation: Uses ContextVar for thread-safe isolation

State Components: - evidence: list[Evidence]: Collected evidence from searches - conversation: Conversation: Iteration history (gaps, tool calls, findings, thoughts) - embedding_service: Any: Embedding service for semantic search

Methods: - add_evidence(evidence: Evidence): Adds evidence with URL-based deduplication - async search_related(query: str, top_k: int = 5) -> list[Evidence]: Semantic search

Initialization:

from src.middleware.state_machine import init_workflow_state
+
+init_workflow_state(embedding_service)
+

Access:

from src.middleware.state_machine import get_workflow_state
+
+state = get_workflow_state()  # Auto-initializes if missing
+

Workflow Manager

File: src/middleware/workflow_manager.py

Purpose: Coordinates parallel research loops

Methods: - add_loop(loop: ResearchLoop): Add a research loop to manage - async run_loops_parallel() -> list[ResearchLoop]: Run all loops in parallel - update_loop_status(loop_id: str, status: str): Update loop status - sync_loop_evidence_to_state(): Synchronize evidence from loops to global state

Features: - Uses asyncio.gather() for parallel execution - Handles errors per loop (doesn't fail all if one fails) - Tracks loop status: pending, running, completed, failed, cancelled - Evidence deduplication across parallel loops

Usage:

from src.middleware.workflow_manager import WorkflowManager
+
+manager = WorkflowManager()
+manager.add_loop(loop1)
+manager.add_loop(loop2)
+completed_loops = await manager.run_loops_parallel()
+

Budget Tracker

File: src/middleware/budget_tracker.py

Purpose: Tracks and enforces resource limits

Budget Components: - Tokens: LLM token usage - Time: Elapsed time in seconds - Iterations: Number of iterations

Methods: - create_budget(token_limit, time_limit_seconds, iterations_limit) -> BudgetStatus - add_tokens(tokens: int): Add token usage - start_timer(): Start time tracking - update_timer(): Update elapsed time - increment_iteration(): Increment iteration count - check_budget() -> BudgetStatus: Check current budget status - can_continue() -> bool: Check if research can continue

Token Estimation: - estimate_tokens(text: str) -> int: ~4 chars per token - estimate_llm_call_tokens(prompt: str, response: str) -> int: Estimate LLM call tokens

Usage:

from src.middleware.budget_tracker import BudgetTracker
+
+tracker = BudgetTracker()
+budget = tracker.create_budget(
+    token_limit=100000,
+    time_limit_seconds=600,
+    iterations_limit=10
+)
+tracker.start_timer()
+# ... research operations ...
+if not tracker.can_continue():
+    # Budget exceeded, stop research
+    pass
+

Models

All middleware models are defined in src/utils/models.py:

  • IterationData: Data for a single iteration
  • Conversation: Conversation history with iterations
  • ResearchLoop: Research loop state and configuration
  • BudgetStatus: Current budget status

Thread Safety

All middleware components use ContextVar for thread-safe isolation:

  • Each request/thread has its own workflow state
  • No global mutable state
  • Safe for concurrent requests

See Also

\ No newline at end of file diff --git a/site/architecture/orchestrators/index.html b/site/architecture/orchestrators/index.html new file mode 100644 index 0000000000000000000000000000000000000000..a7b1518354ae516ef609b24ed697a6534f8aa9d5 --- /dev/null +++ b/site/architecture/orchestrators/index.html @@ -0,0 +1,40 @@ + Orchestrators Architecture - DeepCritical

Orchestrators Architecture

DeepCritical supports multiple orchestration patterns for research workflows.

Research Flows

IterativeResearchFlow

File: src/orchestrator/research_flow.py

Pattern: Generate observations → Evaluate gaps → Select tools → Execute → Judge → Continue/Complete

Agents Used: - KnowledgeGapAgent: Evaluates research completeness - ToolSelectorAgent: Selects tools for addressing gaps - ThinkingAgent: Generates observations - WriterAgent: Creates final report - JudgeHandler: Assesses evidence sufficiency

Features: - Tracks iterations, time, budget - Supports graph execution (use_graph=True) and agent chains (use_graph=False) - Iterates until research complete or constraints met

Usage:

from src.orchestrator.research_flow import IterativeResearchFlow
+
+flow = IterativeResearchFlow(
+    search_handler=search_handler,
+    judge_handler=judge_handler,
+    use_graph=False
+)
+
+async for event in flow.run(query):
+    # Handle events
+    pass
+

DeepResearchFlow

File: src/orchestrator/research_flow.py

Pattern: Planner → Parallel iterative loops per section → Synthesizer

Agents Used: - PlannerAgent: Breaks query into report sections - IterativeResearchFlow: Per-section research (parallel) - LongWriterAgent or ProofreaderAgent: Final synthesis

Features: - Uses WorkflowManager for parallel execution - Budget tracking per section and globally - State synchronization across parallel loops - Supports graph execution and agent chains

Usage:

from src.orchestrator.research_flow import DeepResearchFlow
+
+flow = DeepResearchFlow(
+    search_handler=search_handler,
+    judge_handler=judge_handler,
+    use_graph=True
+)
+
+async for event in flow.run(query):
+    # Handle events
+    pass
+

Graph Orchestrator

File: src/orchestrator/graph_orchestrator.py

Purpose: Graph-based execution using Pydantic AI agents as nodes

Features: - Uses Pydantic AI Graphs (when available) or agent chains (fallback) - Routes based on research mode (iterative/deep/auto) - Streams AgentEvent objects for UI

Node Types: - Agent Nodes: Execute Pydantic AI agents - State Nodes: Update or read workflow state - Decision Nodes: Make routing decisions - Parallel Nodes: Execute multiple nodes concurrently

Edge Types: - Sequential Edges: Always traversed - Conditional Edges: Traversed based on condition - Parallel Edges: Used for parallel execution branches

Orchestrator Factory

File: src/orchestrator_factory.py

Purpose: Factory for creating orchestrators

Modes: - Simple: Legacy orchestrator (backward compatible) - Advanced: Magentic orchestrator (requires OpenAI API key) - Auto-detect: Chooses based on API key availability

Usage:

from src.orchestrator_factory import create_orchestrator
+
+orchestrator = create_orchestrator(
+    search_handler=search_handler,
+    judge_handler=judge_handler,
+    config={},
+    mode="advanced"  # or "simple" or None for auto-detect
+)
+

Magentic Orchestrator

File: src/orchestrator_magentic.py

Purpose: Multi-agent coordination using Microsoft Agent Framework

Features: - Uses agent-framework-core - ChatAgent pattern with internal LLMs per agent - MagenticBuilder with participants: searcher, hypothesizer, judge, reporter - Manager orchestrates agents via OpenAIChatClient - Requires OpenAI API key (function calling support) - Event-driven: converts Magentic events to AgentEvent for UI streaming

Requirements: - agent-framework-core package - OpenAI API key

Hierarchical Orchestrator

File: src/orchestrator_hierarchical.py

Purpose: Hierarchical orchestrator using middleware and sub-teams

Features: - Uses SubIterationMiddleware with ResearchTeam and LLMSubIterationJudge - Adapts Magentic ChatAgent to SubIterationTeam protocol - Event-driven via asyncio.Queue for coordination - Supports sub-iteration patterns for complex research tasks

Legacy Simple Mode

File: src/legacy_orchestrator.py

Purpose: Linear search-judge-synthesize loop

Features: - Uses SearchHandlerProtocol and JudgeHandlerProtocol - Generator-based design yielding AgentEvent objects - Backward compatibility for simple use cases

State Initialization

All orchestrators must initialize workflow state:

from src.middleware.state_machine import init_workflow_state
+from src.services.embeddings import get_embedding_service
+
+embedding_service = get_embedding_service()
+init_workflow_state(embedding_service)
+

Event Streaming

All orchestrators yield AgentEvent objects:

Event Types: - started: Research started - search_complete: Search completed - judge_complete: Evidence evaluation completed - hypothesizing: Generating hypotheses - synthesizing: Synthesizing results - complete: Research completed - error: Error occurred

Event Structure:

class AgentEvent:
+    type: str
+    iteration: int | None
+    data: dict[str, Any]
+

See Also

\ No newline at end of file diff --git a/site/architecture/services/index.html b/site/architecture/services/index.html new file mode 100644 index 0000000000000000000000000000000000000000..cf6c3db5d719442d9f7f6d1c899a911099d7f978 --- /dev/null +++ b/site/architecture/services/index.html @@ -0,0 +1,29 @@ + Services Architecture - DeepCritical

Services Architecture

DeepCritical provides several services for embeddings, RAG, and statistical analysis.

Embedding Service

File: src/services/embeddings.py

Purpose: Local sentence-transformers for semantic search and deduplication

Features: - No API Key Required: Uses local sentence-transformers models - Async-Safe: All operations use run_in_executor() to avoid blocking - ChromaDB Storage: Vector storage for embeddings - Deduplication: 0.85 similarity threshold (85% similarity = duplicate)

Model: Configurable via settings.local_embedding_model (default: all-MiniLM-L6-v2)

Methods: - async def embed(text: str) -> list[float]: Generate embeddings - async def embed_batch(texts: list[str]) -> list[list[float]]: Batch embedding - async def similarity(text1: str, text2: str) -> float: Calculate similarity - async def find_duplicates(texts: list[str], threshold: float = 0.85) -> list[tuple[int, int]]: Find duplicates

Usage:

from src.services.embeddings import get_embedding_service
+
+service = get_embedding_service()
+embedding = await service.embed("text to embed")
+

LlamaIndex RAG Service

File: src/services/rag.py

Purpose: Retrieval-Augmented Generation using LlamaIndex

Features: - OpenAI Embeddings: Requires OPENAI_API_KEY - ChromaDB Storage: Vector database for document storage - Metadata Preservation: Preserves source, title, URL, date, authors - Lazy Initialization: Graceful fallback if OpenAI key not available

Methods: - async def ingest_evidence(evidence: list[Evidence]) -> None: Ingest evidence into RAG - async def retrieve(query: str, top_k: int = 5) -> list[Document]: Retrieve relevant documents - async def query(query: str, top_k: int = 5) -> str: Query with RAG

Usage:

from src.services.rag import get_rag_service
+
+service = get_rag_service()
+if service:
+    documents = await service.retrieve("query", top_k=5)
+

Statistical Analyzer

File: src/services/statistical_analyzer.py

Purpose: Secure execution of AI-generated statistical code

Features: - Modal Sandbox: Secure, isolated execution environment - Code Generation: Generates Python code via LLM - Library Pinning: Version-pinned libraries in SANDBOX_LIBRARIES - Network Isolation: block_network=True by default

Libraries Available: - pandas, numpy, scipy - matplotlib, scikit-learn - statsmodels

Output: AnalysisResult with: - verdict: SUPPORTED, REFUTED, or INCONCLUSIVE - code: Generated analysis code - output: Execution output - error: Error message if execution failed

Usage:

from src.services.statistical_analyzer import StatisticalAnalyzer
+
+analyzer = StatisticalAnalyzer()
+result = await analyzer.analyze(
+    hypothesis="Metformin reduces cancer risk",
+    evidence=evidence_list
+)
+

Singleton Pattern

All services use the singleton pattern with @lru_cache(maxsize=1):

@lru_cache(maxsize=1)
+def get_embedding_service() -> EmbeddingService:
+    return EmbeddingService()
+

This ensures: - Single instance per process - Lazy initialization - No dependencies required at import time

Service Availability

Services check availability before use:

from src.utils.config import settings
+
+if settings.modal_available:
+    # Use Modal sandbox
+    pass
+
+if settings.has_openai_key:
+    # Use OpenAI embeddings for RAG
+    pass
+

See Also

\ No newline at end of file diff --git a/site/architecture/tools/index.html b/site/architecture/tools/index.html new file mode 100644 index 0000000000000000000000000000000000000000..d437ab3e4c8c244c447d59bb354ebf8d595271a9 --- /dev/null +++ b/site/architecture/tools/index.html @@ -0,0 +1,27 @@ + Tools Architecture - DeepCritical

Tools Architecture

DeepCritical implements a protocol-based search tool system for retrieving evidence from multiple sources.

SearchTool Protocol

All tools implement the SearchTool protocol from src/tools/base.py:

class SearchTool(Protocol):
+    @property
+    def name(self) -> str: ...
+    
+    async def search(
+        self, 
+        query: str, 
+        max_results: int = 10
+    ) -> list[Evidence]: ...
+

Rate Limiting

All tools use the @retry decorator from tenacity:

@retry(
+    stop=stop_after_attempt(3), 
+    wait=wait_exponential(...)
+)
+async def search(self, query: str, max_results: int = 10) -> list[Evidence]:
+    # Implementation
+

Tools with API rate limits implement _rate_limit() method and use shared rate limiters from src/tools/rate_limiter.py.

Error Handling

Tools raise custom exceptions:

  • SearchError: General search failures
  • RateLimitError: Rate limit exceeded

Tools handle HTTP errors (429, 500, timeout) and return empty lists on non-critical errors (with warning logs).

Query Preprocessing

Tools use preprocess_query() from src/tools/query_utils.py to:

  • Remove noise from queries
  • Expand synonyms
  • Normalize query format

Evidence Conversion

All tools convert API responses to Evidence objects with:

  • Citation: Title, URL, date, authors
  • content: Evidence text
  • relevance_score: 0.0-1.0 relevance score
  • metadata: Additional metadata

Missing fields are handled gracefully with defaults.

Tool Implementations

PubMed Tool

File: src/tools/pubmed.py

API: NCBI E-utilities (ESearch → EFetch)

Rate Limiting: - 0.34s between requests (3 req/sec without API key) - 0.1s between requests (10 req/sec with NCBI API key)

Features: - XML parsing with xmltodict - Handles single vs. multiple articles - Query preprocessing - Evidence conversion with metadata extraction

ClinicalTrials Tool

File: src/tools/clinicaltrials.py

API: ClinicalTrials.gov API v2

Important: Uses requests library (NOT httpx) because WAF blocks httpx TLS fingerprint.

Execution: Runs in thread pool: await asyncio.to_thread(requests.get, ...)

Filtering: - Only interventional studies - Status: COMPLETED, ACTIVE_NOT_RECRUITING, RECRUITING, ENROLLING_BY_INVITATION

Features: - Parses nested JSON structure - Extracts trial metadata - Evidence conversion

Europe PMC Tool

File: src/tools/europepmc.py

API: Europe PMC REST API

Features: - Handles preprint markers: [PREPRINT - Not peer-reviewed] - Builds URLs from DOI or PMID - Checks pubTypeList for preprint detection - Includes both preprints and peer-reviewed articles

RAG Tool

File: src/tools/rag_tool.py

Purpose: Semantic search within collected evidence

Implementation: Wraps LlamaIndexRAGService

Features: - Returns Evidence from RAG results - Handles evidence ingestion - Semantic similarity search - Metadata preservation

Search Handler

File: src/tools/search_handler.py

Purpose: Orchestrates parallel searches across multiple tools

Features: - Uses asyncio.gather() with return_exceptions=True - Aggregates results into SearchResult - Handles tool failures gracefully - Deduplicates results by URL

Tool Registration

Tools are registered in the search handler:

from src.tools.pubmed import PubMedTool
+from src.tools.clinicaltrials import ClinicalTrialsTool
+from src.tools.europepmc import EuropePMCTool
+
+search_handler = SearchHandler(
+    tools=[
+        PubMedTool(),
+        ClinicalTrialsTool(),
+        EuropePMCTool(),
+    ]
+)
+

See Also

\ No newline at end of file diff --git a/site/architecture/workflow-diagrams/index.html b/site/architecture/workflow-diagrams/index.html new file mode 100644 index 0000000000000000000000000000000000000000..b4c2cc0f4ce5653e998b57062cc0c1e12cd7cf33 --- /dev/null +++ b/site/architecture/workflow-diagrams/index.html @@ -0,0 +1,503 @@ + DeepCritical Workflow - Simplified Magentic Architecture - DeepCritical

DeepCritical Workflow - Simplified Magentic Architecture

Architecture Pattern: Microsoft Magentic Orchestration Design Philosophy: Simple, dynamic, manager-driven coordination Key Innovation: Intelligent manager replaces rigid sequential phases


1. High-Level Magentic Workflow

flowchart TD
+    Start([User Query]) --> Manager[Magentic Manager<br/>Plan • Select • Assess • Adapt]
+
+    Manager -->|Plans| Task1[Task Decomposition]
+    Task1 --> Manager
+
+    Manager -->|Selects & Executes| HypAgent[Hypothesis Agent]
+    Manager -->|Selects & Executes| SearchAgent[Search Agent]
+    Manager -->|Selects & Executes| AnalysisAgent[Analysis Agent]
+    Manager -->|Selects & Executes| ReportAgent[Report Agent]
+
+    HypAgent -->|Results| Manager
+    SearchAgent -->|Results| Manager
+    AnalysisAgent -->|Results| Manager
+    ReportAgent -->|Results| Manager
+
+    Manager -->|Assesses Quality| Decision{Good Enough?}
+    Decision -->|No - Refine| Manager
+    Decision -->|No - Different Agent| Manager
+    Decision -->|No - Stalled| Replan[Reset Plan]
+    Replan --> Manager
+
+    Decision -->|Yes| Synthesis[Synthesize Final Result]
+    Synthesis --> Output([Research Report])
+
+    style Start fill:#e1f5e1
+    style Manager fill:#ffe6e6
+    style HypAgent fill:#fff4e6
+    style SearchAgent fill:#fff4e6
+    style AnalysisAgent fill:#fff4e6
+    style ReportAgent fill:#fff4e6
+    style Decision fill:#ffd6d6
+    style Synthesis fill:#d4edda
+    style Output fill:#e1f5e1

2. Magentic Manager: The 6-Phase Cycle

flowchart LR
+    P1[1. Planning<br/>Analyze task<br/>Create strategy] --> P2[2. Agent Selection<br/>Pick best agent<br/>for subtask]
+    P2 --> P3[3. Execution<br/>Run selected<br/>agent with tools]
+    P3 --> P4[4. Assessment<br/>Evaluate quality<br/>Check progress]
+    P4 --> Decision{Quality OK?<br/>Progress made?}
+    Decision -->|Yes| P6[6. Synthesis<br/>Combine results<br/>Generate report]
+    Decision -->|No| P5[5. Iteration<br/>Adjust plan<br/>Try again]
+    P5 --> P2
+    P6 --> Done([Complete])
+
+    style P1 fill:#fff4e6
+    style P2 fill:#ffe6e6
+    style P3 fill:#e6f3ff
+    style P4 fill:#ffd6d6
+    style P5 fill:#fff3cd
+    style P6 fill:#d4edda
+    style Done fill:#e1f5e1

3. Simplified Agent Architecture

graph TB
+    subgraph "Orchestration Layer"
+        Manager[Magentic Manager<br/>• Plans workflow<br/>• Selects agents<br/>• Assesses quality<br/>• Adapts strategy]
+        SharedContext[(Shared Context<br/>• Hypotheses<br/>• Search Results<br/>• Analysis<br/>• Progress)]
+        Manager <--> SharedContext
+    end
+
+    subgraph "Specialist Agents"
+        HypAgent[Hypothesis Agent<br/>• Domain understanding<br/>• Hypothesis generation<br/>• Testability refinement]
+        SearchAgent[Search Agent<br/>• Multi-source search<br/>• RAG retrieval<br/>• Result ranking]
+        AnalysisAgent[Analysis Agent<br/>• Evidence extraction<br/>• Statistical analysis<br/>• Code execution]
+        ReportAgent[Report Agent<br/>• Report assembly<br/>• Visualization<br/>• Citation formatting]
+    end
+
+    subgraph "MCP Tools"
+        WebSearch[Web Search<br/>PubMed • arXiv • bioRxiv]
+        CodeExec[Code Execution<br/>Sandboxed Python]
+        RAG[RAG Retrieval<br/>Vector DB • Embeddings]
+        Viz[Visualization<br/>Charts • Graphs]
+    end
+
+    Manager -->|Selects & Directs| HypAgent
+    Manager -->|Selects & Directs| SearchAgent
+    Manager -->|Selects & Directs| AnalysisAgent
+    Manager -->|Selects & Directs| ReportAgent
+
+    HypAgent --> SharedContext
+    SearchAgent --> SharedContext
+    AnalysisAgent --> SharedContext
+    ReportAgent --> SharedContext
+
+    SearchAgent --> WebSearch
+    SearchAgent --> RAG
+    AnalysisAgent --> CodeExec
+    ReportAgent --> CodeExec
+    ReportAgent --> Viz
+
+    style Manager fill:#ffe6e6
+    style SharedContext fill:#ffe6f0
+    style HypAgent fill:#fff4e6
+    style SearchAgent fill:#fff4e6
+    style AnalysisAgent fill:#fff4e6
+    style ReportAgent fill:#fff4e6
+    style WebSearch fill:#e6f3ff
+    style CodeExec fill:#e6f3ff
+    style RAG fill:#e6f3ff
+    style Viz fill:#e6f3ff

4. Dynamic Workflow Example

sequenceDiagram
+    participant User
+    participant Manager
+    participant HypAgent
+    participant SearchAgent
+    participant AnalysisAgent
+    participant ReportAgent
+
+    User->>Manager: "Research protein folding in Alzheimer's"
+
+    Note over Manager: PLAN: Generate hypotheses → Search → Analyze → Report
+
+    Manager->>HypAgent: Generate 3 hypotheses
+    HypAgent-->>Manager: Returns 3 hypotheses
+    Note over Manager: ASSESS: Good quality, proceed
+
+    Manager->>SearchAgent: Search literature for hypothesis 1
+    SearchAgent-->>Manager: Returns 15 papers
+    Note over Manager: ASSESS: Good results, continue
+
+    Manager->>SearchAgent: Search for hypothesis 2
+    SearchAgent-->>Manager: Only 2 papers found
+    Note over Manager: ASSESS: Insufficient, refine search
+
+    Manager->>SearchAgent: Refined query for hypothesis 2
+    SearchAgent-->>Manager: Returns 12 papers
+    Note over Manager: ASSESS: Better, proceed
+
+    Manager->>AnalysisAgent: Analyze evidence for all hypotheses
+    AnalysisAgent-->>Manager: Returns analysis with code
+    Note over Manager: ASSESS: Complete, generate report
+
+    Manager->>ReportAgent: Create comprehensive report
+    ReportAgent-->>Manager: Returns formatted report
+    Note over Manager: SYNTHESIZE: Combine all results
+
+    Manager->>User: Final Research Report

5. Manager Decision Logic

flowchart TD
+    Start([Manager Receives Task]) --> Plan[Create Initial Plan]
+
+    Plan --> Select[Select Agent for Next Subtask]
+    Select --> Execute[Execute Agent]
+    Execute --> Collect[Collect Results]
+
+    Collect --> Assess[Assess Quality & Progress]
+
+    Assess --> Q1{Quality Sufficient?}
+    Q1 -->|No| Q2{Same Agent Can Fix?}
+    Q2 -->|Yes| Feedback[Provide Specific Feedback]
+    Feedback --> Execute
+    Q2 -->|No| Different[Try Different Agent]
+    Different --> Select
+
+    Q1 -->|Yes| Q3{Task Complete?}
+    Q3 -->|No| Q4{Making Progress?}
+    Q4 -->|Yes| Select
+    Q4 -->|No - Stalled| Replan[Reset Plan & Approach]
+    Replan --> Plan
+
+    Q3 -->|Yes| Synth[Synthesize Final Result]
+    Synth --> Done([Return Report])
+
+    style Start fill:#e1f5e1
+    style Plan fill:#fff4e6
+    style Select fill:#ffe6e6
+    style Execute fill:#e6f3ff
+    style Assess fill:#ffd6d6
+    style Q1 fill:#ffe6e6
+    style Q2 fill:#ffe6e6
+    style Q3 fill:#ffe6e6
+    style Q4 fill:#ffe6e6
+    style Synth fill:#d4edda
+    style Done fill:#e1f5e1

6. Hypothesis Agent Workflow

flowchart LR
+    Input[Research Query] --> Domain[Identify Domain<br/>& Key Concepts]
+    Domain --> Context[Retrieve Background<br/>Knowledge]
+    Context --> Generate[Generate 3-5<br/>Initial Hypotheses]
+    Generate --> Refine[Refine for<br/>Testability]
+    Refine --> Rank[Rank by<br/>Quality Score]
+    Rank --> Output[Return Top<br/>Hypotheses]
+
+    Output --> Struct[Hypothesis Structure:<br/>• Statement<br/>• Rationale<br/>• Testability Score<br/>• Data Requirements<br/>• Expected Outcomes]
+
+    style Input fill:#e1f5e1
+    style Output fill:#fff4e6
+    style Struct fill:#e6f3ff

7. Search Agent Workflow

flowchart TD
+    Input[Hypotheses] --> Strategy[Formulate Search<br/>Strategy per Hypothesis]
+
+    Strategy --> Multi[Multi-Source Search]
+
+    Multi --> PubMed[PubMed Search<br/>via MCP]
+    Multi --> ArXiv[arXiv Search<br/>via MCP]
+    Multi --> BioRxiv[bioRxiv Search<br/>via MCP]
+
+    PubMed --> Aggregate[Aggregate Results]
+    ArXiv --> Aggregate
+    BioRxiv --> Aggregate
+
+    Aggregate --> Filter[Filter & Rank<br/>by Relevance]
+    Filter --> Dedup[Deduplicate<br/>Cross-Reference]
+    Dedup --> Embed[Embed Documents<br/>via MCP]
+    Embed --> Vector[(Vector DB)]
+    Vector --> RAGRetrieval[RAG Retrieval<br/>Top-K per Hypothesis]
+    RAGRetrieval --> Output[Return Contextualized<br/>Search Results]
+
+    style Input fill:#fff4e6
+    style Multi fill:#ffe6e6
+    style Vector fill:#ffe6f0
+    style Output fill:#e6f3ff

8. Analysis Agent Workflow

flowchart TD
+    Input1[Hypotheses] --> Extract
+    Input2[Search Results] --> Extract[Extract Evidence<br/>per Hypothesis]
+
+    Extract --> Methods[Determine Analysis<br/>Methods Needed]
+
+    Methods --> Branch{Requires<br/>Computation?}
+    Branch -->|Yes| GenCode[Generate Python<br/>Analysis Code]
+    Branch -->|No| Qual[Qualitative<br/>Synthesis]
+
+    GenCode --> Execute[Execute Code<br/>via MCP Sandbox]
+    Execute --> Interpret1[Interpret<br/>Results]
+    Qual --> Interpret2[Interpret<br/>Findings]
+
+    Interpret1 --> Synthesize[Synthesize Evidence<br/>Across Sources]
+    Interpret2 --> Synthesize
+
+    Synthesize --> Verdict[Determine Verdict<br/>per Hypothesis]
+    Verdict --> Support[• Supported<br/>• Refuted<br/>• Inconclusive]
+    Support --> Gaps[Identify Knowledge<br/>Gaps & Limitations]
+    Gaps --> Output[Return Analysis<br/>Report]
+
+    style Input1 fill:#fff4e6
+    style Input2 fill:#e6f3ff
+    style Execute fill:#ffe6e6
+    style Output fill:#e6ffe6

9. Report Agent Workflow

flowchart TD
+    Input1[Query] --> Assemble
+    Input2[Hypotheses] --> Assemble
+    Input3[Search Results] --> Assemble
+    Input4[Analysis] --> Assemble[Assemble Report<br/>Sections]
+
+    Assemble --> Exec[Executive Summary]
+    Assemble --> Intro[Introduction]
+    Assemble --> Methods[Methods]
+    Assemble --> Results[Results per<br/>Hypothesis]
+    Assemble --> Discussion[Discussion]
+    Assemble --> Future[Future Directions]
+    Assemble --> Refs[References]
+
+    Results --> VizCheck{Needs<br/>Visualization?}
+    VizCheck -->|Yes| GenViz[Generate Viz Code]
+    GenViz --> ExecViz[Execute via MCP<br/>Create Charts]
+    ExecViz --> Combine
+    VizCheck -->|No| Combine[Combine All<br/>Sections]
+
+    Exec --> Combine
+    Intro --> Combine
+    Methods --> Combine
+    Discussion --> Combine
+    Future --> Combine
+    Refs --> Combine
+
+    Combine --> Format[Format Output]
+    Format --> MD[Markdown]
+    Format --> PDF[PDF]
+    Format --> JSON[JSON]
+
+    MD --> Output[Return Final<br/>Report]
+    PDF --> Output
+    JSON --> Output
+
+    style Input1 fill:#e1f5e1
+    style Input2 fill:#fff4e6
+    style Input3 fill:#e6f3ff
+    style Input4 fill:#e6ffe6
+    style Output fill:#d4edda

10. Data Flow & Event Streaming

flowchart TD
+    User[👤 User] -->|Research Query| UI[Gradio UI]
+    UI -->|Submit| Manager[Magentic Manager]
+
+    Manager -->|Event: Planning| UI
+    Manager -->|Select Agent| HypAgent[Hypothesis Agent]
+    HypAgent -->|Event: Delta/Message| UI
+    HypAgent -->|Hypotheses| Context[(Shared Context)]
+
+    Context -->|Retrieved by| Manager
+    Manager -->|Select Agent| SearchAgent[Search Agent]
+    SearchAgent -->|MCP Request| WebSearch[Web Search Tool]
+    WebSearch -->|Results| SearchAgent
+    SearchAgent -->|Event: Delta/Message| UI
+    SearchAgent -->|Documents| Context
+    SearchAgent -->|Embeddings| VectorDB[(Vector DB)]
+
+    Context -->|Retrieved by| Manager
+    Manager -->|Select Agent| AnalysisAgent[Analysis Agent]
+    AnalysisAgent -->|MCP Request| CodeExec[Code Execution Tool]
+    CodeExec -->|Results| AnalysisAgent
+    AnalysisAgent -->|Event: Delta/Message| UI
+    AnalysisAgent -->|Analysis| Context
+
+    Context -->|Retrieved by| Manager
+    Manager -->|Select Agent| ReportAgent[Report Agent]
+    ReportAgent -->|MCP Request| CodeExec
+    ReportAgent -->|Event: Delta/Message| UI
+    ReportAgent -->|Report| Context
+
+    Manager -->|Event: Final Result| UI
+    UI -->|Display| User
+
+    style User fill:#e1f5e1
+    style UI fill:#e6f3ff
+    style Manager fill:#ffe6e6
+    style Context fill:#ffe6f0
+    style VectorDB fill:#ffe6f0
+    style WebSearch fill:#f0f0f0
+    style CodeExec fill:#f0f0f0

11. MCP Tool Architecture

graph TB
+    subgraph "Agent Layer"
+        Manager[Magentic Manager]
+        HypAgent[Hypothesis Agent]
+        SearchAgent[Search Agent]
+        AnalysisAgent[Analysis Agent]
+        ReportAgent[Report Agent]
+    end
+
+    subgraph "MCP Protocol Layer"
+        Registry[MCP Tool Registry<br/>• Discovers tools<br/>• Routes requests<br/>• Manages connections]
+    end
+
+    subgraph "MCP Servers"
+        Server1[Web Search Server<br/>localhost:8001<br/>• PubMed<br/>• arXiv<br/>• bioRxiv]
+        Server2[Code Execution Server<br/>localhost:8002<br/>• Sandboxed Python<br/>• Package management]
+        Server3[RAG Server<br/>localhost:8003<br/>• Vector embeddings<br/>• Similarity search]
+        Server4[Visualization Server<br/>localhost:8004<br/>• Chart generation<br/>• Plot rendering]
+    end
+
+    subgraph "External Services"
+        PubMed[PubMed API]
+        ArXiv[arXiv API]
+        BioRxiv[bioRxiv API]
+        Modal[Modal Sandbox]
+        ChromaDB[(ChromaDB)]
+    end
+
+    SearchAgent -->|Request| Registry
+    AnalysisAgent -->|Request| Registry
+    ReportAgent -->|Request| Registry
+
+    Registry --> Server1
+    Registry --> Server2
+    Registry --> Server3
+    Registry --> Server4
+
+    Server1 --> PubMed
+    Server1 --> ArXiv
+    Server1 --> BioRxiv
+    Server2 --> Modal
+    Server3 --> ChromaDB
+
+    style Manager fill:#ffe6e6
+    style Registry fill:#fff4e6
+    style Server1 fill:#e6f3ff
+    style Server2 fill:#e6f3ff
+    style Server3 fill:#e6f3ff
+    style Server4 fill:#e6f3ff

12. Progress Tracking & Stall Detection

stateDiagram-v2
+    [*] --> Initialization: User Query
+
+    Initialization --> Planning: Manager starts
+
+    Planning --> AgentExecution: Select agent
+
+    AgentExecution --> Assessment: Collect results
+
+    Assessment --> QualityCheck: Evaluate output
+
+    QualityCheck --> AgentExecution: Poor quality<br/>(retry < max_rounds)
+    QualityCheck --> Planning: Poor quality<br/>(try different agent)
+    QualityCheck --> NextAgent: Good quality<br/>(task incomplete)
+    QualityCheck --> Synthesis: Good quality<br/>(task complete)
+
+    NextAgent --> AgentExecution: Select next agent
+
+    state StallDetection <<choice>>
+    Assessment --> StallDetection: Check progress
+    StallDetection --> Planning: No progress<br/>(stall count < max)
+    StallDetection --> ErrorRecovery: No progress<br/>(max stalls reached)
+
+    ErrorRecovery --> PartialReport: Generate partial results
+    PartialReport --> [*]
+
+    Synthesis --> FinalReport: Combine all outputs
+    FinalReport --> [*]
+
+    note right of QualityCheck
+        Manager assesses:
+        • Output completeness
+        • Quality metrics
+        • Progress made
+    end note
+
+    note right of StallDetection
+        Stall = no new progress
+        after agent execution
+        Triggers plan reset
+    end note

13. Gradio UI Integration

graph TD
+    App[Gradio App<br/>DeepCritical Research Agent]
+
+    App --> Input[Input Section]
+    App --> Status[Status Section]
+    App --> Output[Output Section]
+
+    Input --> Query[Research Question<br/>Text Area]
+    Input --> Controls[Controls]
+    Controls --> MaxHyp[Max Hypotheses: 1-10]
+    Controls --> MaxRounds[Max Rounds: 5-20]
+    Controls --> Submit[Start Research Button]
+
+    Status --> Log[Real-time Event Log<br/>• Manager planning<br/>• Agent selection<br/>• Execution updates<br/>• Quality assessment]
+    Status --> Progress[Progress Tracker<br/>• Current agent<br/>• Round count<br/>• Stall count]
+
+    Output --> Tabs[Tabbed Results]
+    Tabs --> Tab1[Hypotheses Tab<br/>Generated hypotheses with scores]
+    Tabs --> Tab2[Search Results Tab<br/>Papers & sources found]
+    Tabs --> Tab3[Analysis Tab<br/>Evidence & verdicts]
+    Tabs --> Tab4[Report Tab<br/>Final research report]
+    Tab4 --> Download[Download Report<br/>MD / PDF / JSON]
+
+    Submit -.->|Triggers| Workflow[Magentic Workflow]
+    Workflow -.->|MagenticOrchestratorMessageEvent| Log
+    Workflow -.->|MagenticAgentDeltaEvent| Log
+    Workflow -.->|MagenticAgentMessageEvent| Log
+    Workflow -.->|MagenticFinalResultEvent| Tab4
+
+    style App fill:#e1f5e1
+    style Input fill:#fff4e6
+    style Status fill:#e6f3ff
+    style Output fill:#e6ffe6
+    style Workflow fill:#ffe6e6

14. Complete System Context

graph LR
+    User[👤 Researcher<br/>Asks research questions] -->|Submits query| DC[DeepCritical<br/>Magentic Workflow]
+
+    DC -->|Literature search| PubMed[PubMed API<br/>Medical papers]
+    DC -->|Preprint search| ArXiv[arXiv API<br/>Scientific preprints]
+    DC -->|Biology search| BioRxiv[bioRxiv API<br/>Biology preprints]
+    DC -->|Agent reasoning| Claude[Claude API<br/>Sonnet 4 / Opus]
+    DC -->|Code execution| Modal[Modal Sandbox<br/>Safe Python env]
+    DC -->|Vector storage| Chroma[ChromaDB<br/>Embeddings & RAG]
+
+    DC -->|Deployed on| HF[HuggingFace Spaces<br/>Gradio 6.0]
+
+    PubMed -->|Results| DC
+    ArXiv -->|Results| DC
+    BioRxiv -->|Results| DC
+    Claude -->|Responses| DC
+    Modal -->|Output| DC
+    Chroma -->|Context| DC
+
+    DC -->|Research report| User
+
+    style User fill:#e1f5e1
+    style DC fill:#ffe6e6
+    style PubMed fill:#e6f3ff
+    style ArXiv fill:#e6f3ff
+    style BioRxiv fill:#e6f3ff
+    style Claude fill:#ffd6d6
+    style Modal fill:#f0f0f0
+    style Chroma fill:#ffe6f0
+    style HF fill:#d4edda

15. Workflow Timeline (Simplified)

gantt
+    title DeepCritical Magentic Workflow - Typical Execution
+    dateFormat mm:ss
+    axisFormat %M:%S
+
+    section Manager Planning
+    Initial planning         :p1, 00:00, 10s
+
+    section Hypothesis Agent
+    Generate hypotheses      :h1, after p1, 30s
+    Manager assessment       :h2, after h1, 5s
+
+    section Search Agent
+    Search hypothesis 1      :s1, after h2, 20s
+    Search hypothesis 2      :s2, after s1, 20s
+    Search hypothesis 3      :s3, after s2, 20s
+    RAG processing          :s4, after s3, 15s
+    Manager assessment      :s5, after s4, 5s
+
+    section Analysis Agent
+    Evidence extraction     :a1, after s5, 15s
+    Code generation        :a2, after a1, 20s
+    Code execution         :a3, after a2, 25s
+    Synthesis              :a4, after a3, 20s
+    Manager assessment     :a5, after a4, 5s
+
+    section Report Agent
+    Report assembly        :r1, after a5, 30s
+    Visualization          :r2, after r1, 15s
+    Formatting             :r3, after r2, 10s
+
+    section Manager Synthesis
+    Final synthesis        :f1, after r3, 10s

Key Differences from Original Design

Aspect Original (Judge-in-Loop) New (Magentic)
Control Flow Fixed sequential phases Dynamic agent selection
Quality Control Separate Judge Agent Manager assessment built-in
Retry Logic Phase-level with feedback Agent-level with adaptation
Flexibility Rigid 4-phase pipeline Adaptive workflow
Complexity 5 agents (including Judge) 4 agents (no Judge)
Progress Tracking Manual state management Built-in round/stall detection
Agent Coordination Sequential handoff Manager-driven dynamic selection
Error Recovery Retry same phase Try different agent or replan

Simplified Design Principles

  1. Manager is Intelligent: LLM-powered manager handles planning, selection, and quality assessment
  2. No Separate Judge: Manager's assessment phase replaces dedicated Judge Agent
  3. Dynamic Workflow: Agents can be called multiple times in any order based on need
  4. Built-in Safety: max_round_count (15) and max_stall_count (3) prevent infinite loops
  5. Event-Driven UI: Real-time streaming updates to Gradio interface
  6. MCP-Powered Tools: All external capabilities via Model Context Protocol
  7. Shared Context: Centralized state accessible to all agents
  8. Progress Awareness: Manager tracks what's been done and what's needed

Legend

  • 🔴 Red/Pink: Manager, orchestration, decision-making
  • 🟡 Yellow/Orange: Specialist agents, processing
  • 🔵 Blue: Data, tools, MCP services
  • 🟣 Purple/Pink: Storage, databases, state
  • 🟢 Green: User interactions, final outputs
  • Gray: External services, APIs

Implementation Highlights

Simple 4-Agent Setup:

workflow = (
+    MagenticBuilder()
+    .participants(
+        hypothesis=HypothesisAgent(tools=[background_tool]),
+        search=SearchAgent(tools=[web_search, rag_tool]),
+        analysis=AnalysisAgent(tools=[code_execution]),
+        report=ReportAgent(tools=[code_execution, visualization])
+    )
+    .with_standard_manager(
+        chat_client=AnthropicClient(model="claude-sonnet-4"),
+        max_round_count=15,    # Prevent infinite loops
+        max_stall_count=3      # Detect stuck workflows
+    )
+    .build()
+)
+

Manager handles quality assessment in its instructions: - Checks hypothesis quality (testable, novel, clear) - Validates search results (relevant, authoritative, recent) - Assesses analysis soundness (methodology, evidence, conclusions) - Ensures report completeness (all sections, proper citations)

No separate Judge Agent needed - manager does it all!


Document Version: 2.0 (Magentic Simplified) Last Updated: 2025-11-24 Architecture: Microsoft Magentic Orchestration Pattern Agents: 4 (Hypothesis, Search, Analysis, Report) + 1 Manager License: MIT

See Also

\ No newline at end of file diff --git a/site/architecture/workflows/index.html b/site/architecture/workflows/index.html new file mode 100644 index 0000000000000000000000000000000000000000..faf544bc41bb293903278741c209acc859e61ff5 --- /dev/null +++ b/site/architecture/workflows/index.html @@ -0,0 +1,503 @@ + DeepCritical Workflow - Simplified Magentic Architecture - DeepCritical

DeepCritical Workflow - Simplified Magentic Architecture

Architecture Pattern: Microsoft Magentic Orchestration Design Philosophy: Simple, dynamic, manager-driven coordination Key Innovation: Intelligent manager replaces rigid sequential phases


1. High-Level Magentic Workflow

flowchart TD
+    Start([User Query]) --> Manager[Magentic Manager<br/>Plan • Select • Assess • Adapt]
+
+    Manager -->|Plans| Task1[Task Decomposition]
+    Task1 --> Manager
+
+    Manager -->|Selects & Executes| HypAgent[Hypothesis Agent]
+    Manager -->|Selects & Executes| SearchAgent[Search Agent]
+    Manager -->|Selects & Executes| AnalysisAgent[Analysis Agent]
+    Manager -->|Selects & Executes| ReportAgent[Report Agent]
+
+    HypAgent -->|Results| Manager
+    SearchAgent -->|Results| Manager
+    AnalysisAgent -->|Results| Manager
+    ReportAgent -->|Results| Manager
+
+    Manager -->|Assesses Quality| Decision{Good Enough?}
+    Decision -->|No - Refine| Manager
+    Decision -->|No - Different Agent| Manager
+    Decision -->|No - Stalled| Replan[Reset Plan]
+    Replan --> Manager
+
+    Decision -->|Yes| Synthesis[Synthesize Final Result]
+    Synthesis --> Output([Research Report])
+
+    style Start fill:#e1f5e1
+    style Manager fill:#ffe6e6
+    style HypAgent fill:#fff4e6
+    style SearchAgent fill:#fff4e6
+    style AnalysisAgent fill:#fff4e6
+    style ReportAgent fill:#fff4e6
+    style Decision fill:#ffd6d6
+    style Synthesis fill:#d4edda
+    style Output fill:#e1f5e1

2. Magentic Manager: The 6-Phase Cycle

flowchart LR
+    P1[1. Planning<br/>Analyze task<br/>Create strategy] --> P2[2. Agent Selection<br/>Pick best agent<br/>for subtask]
+    P2 --> P3[3. Execution<br/>Run selected<br/>agent with tools]
+    P3 --> P4[4. Assessment<br/>Evaluate quality<br/>Check progress]
+    P4 --> Decision{Quality OK?<br/>Progress made?}
+    Decision -->|Yes| P6[6. Synthesis<br/>Combine results<br/>Generate report]
+    Decision -->|No| P5[5. Iteration<br/>Adjust plan<br/>Try again]
+    P5 --> P2
+    P6 --> Done([Complete])
+
+    style P1 fill:#fff4e6
+    style P2 fill:#ffe6e6
+    style P3 fill:#e6f3ff
+    style P4 fill:#ffd6d6
+    style P5 fill:#fff3cd
+    style P6 fill:#d4edda
+    style Done fill:#e1f5e1

3. Simplified Agent Architecture

graph TB
+    subgraph "Orchestration Layer"
+        Manager[Magentic Manager<br/>• Plans workflow<br/>• Selects agents<br/>• Assesses quality<br/>• Adapts strategy]
+        SharedContext[(Shared Context<br/>• Hypotheses<br/>• Search Results<br/>• Analysis<br/>• Progress)]
+        Manager <--> SharedContext
+    end
+
+    subgraph "Specialist Agents"
+        HypAgent[Hypothesis Agent<br/>• Domain understanding<br/>• Hypothesis generation<br/>• Testability refinement]
+        SearchAgent[Search Agent<br/>• Multi-source search<br/>• RAG retrieval<br/>• Result ranking]
+        AnalysisAgent[Analysis Agent<br/>• Evidence extraction<br/>• Statistical analysis<br/>• Code execution]
+        ReportAgent[Report Agent<br/>• Report assembly<br/>• Visualization<br/>• Citation formatting]
+    end
+
+    subgraph "MCP Tools"
+        WebSearch[Web Search<br/>PubMed • arXiv • bioRxiv]
+        CodeExec[Code Execution<br/>Sandboxed Python]
+        RAG[RAG Retrieval<br/>Vector DB • Embeddings]
+        Viz[Visualization<br/>Charts • Graphs]
+    end
+
+    Manager -->|Selects & Directs| HypAgent
+    Manager -->|Selects & Directs| SearchAgent
+    Manager -->|Selects & Directs| AnalysisAgent
+    Manager -->|Selects & Directs| ReportAgent
+
+    HypAgent --> SharedContext
+    SearchAgent --> SharedContext
+    AnalysisAgent --> SharedContext
+    ReportAgent --> SharedContext
+
+    SearchAgent --> WebSearch
+    SearchAgent --> RAG
+    AnalysisAgent --> CodeExec
+    ReportAgent --> CodeExec
+    ReportAgent --> Viz
+
+    style Manager fill:#ffe6e6
+    style SharedContext fill:#ffe6f0
+    style HypAgent fill:#fff4e6
+    style SearchAgent fill:#fff4e6
+    style AnalysisAgent fill:#fff4e6
+    style ReportAgent fill:#fff4e6
+    style WebSearch fill:#e6f3ff
+    style CodeExec fill:#e6f3ff
+    style RAG fill:#e6f3ff
+    style Viz fill:#e6f3ff

4. Dynamic Workflow Example

sequenceDiagram
+    participant User
+    participant Manager
+    participant HypAgent
+    participant SearchAgent
+    participant AnalysisAgent
+    participant ReportAgent
+
+    User->>Manager: "Research protein folding in Alzheimer's"
+
+    Note over Manager: PLAN: Generate hypotheses → Search → Analyze → Report
+
+    Manager->>HypAgent: Generate 3 hypotheses
+    HypAgent-->>Manager: Returns 3 hypotheses
+    Note over Manager: ASSESS: Good quality, proceed
+
+    Manager->>SearchAgent: Search literature for hypothesis 1
+    SearchAgent-->>Manager: Returns 15 papers
+    Note over Manager: ASSESS: Good results, continue
+
+    Manager->>SearchAgent: Search for hypothesis 2
+    SearchAgent-->>Manager: Only 2 papers found
+    Note over Manager: ASSESS: Insufficient, refine search
+
+    Manager->>SearchAgent: Refined query for hypothesis 2
+    SearchAgent-->>Manager: Returns 12 papers
+    Note over Manager: ASSESS: Better, proceed
+
+    Manager->>AnalysisAgent: Analyze evidence for all hypotheses
+    AnalysisAgent-->>Manager: Returns analysis with code
+    Note over Manager: ASSESS: Complete, generate report
+
+    Manager->>ReportAgent: Create comprehensive report
+    ReportAgent-->>Manager: Returns formatted report
+    Note over Manager: SYNTHESIZE: Combine all results
+
+    Manager->>User: Final Research Report

5. Manager Decision Logic

flowchart TD
+    Start([Manager Receives Task]) --> Plan[Create Initial Plan]
+
+    Plan --> Select[Select Agent for Next Subtask]
+    Select --> Execute[Execute Agent]
+    Execute --> Collect[Collect Results]
+
+    Collect --> Assess[Assess Quality & Progress]
+
+    Assess --> Q1{Quality Sufficient?}
+    Q1 -->|No| Q2{Same Agent Can Fix?}
+    Q2 -->|Yes| Feedback[Provide Specific Feedback]
+    Feedback --> Execute
+    Q2 -->|No| Different[Try Different Agent]
+    Different --> Select
+
+    Q1 -->|Yes| Q3{Task Complete?}
+    Q3 -->|No| Q4{Making Progress?}
+    Q4 -->|Yes| Select
+    Q4 -->|No - Stalled| Replan[Reset Plan & Approach]
+    Replan --> Plan
+
+    Q3 -->|Yes| Synth[Synthesize Final Result]
+    Synth --> Done([Return Report])
+
+    style Start fill:#e1f5e1
+    style Plan fill:#fff4e6
+    style Select fill:#ffe6e6
+    style Execute fill:#e6f3ff
+    style Assess fill:#ffd6d6
+    style Q1 fill:#ffe6e6
+    style Q2 fill:#ffe6e6
+    style Q3 fill:#ffe6e6
+    style Q4 fill:#ffe6e6
+    style Synth fill:#d4edda
+    style Done fill:#e1f5e1

6. Hypothesis Agent Workflow

flowchart LR
+    Input[Research Query] --> Domain[Identify Domain<br/>& Key Concepts]
+    Domain --> Context[Retrieve Background<br/>Knowledge]
+    Context --> Generate[Generate 3-5<br/>Initial Hypotheses]
+    Generate --> Refine[Refine for<br/>Testability]
+    Refine --> Rank[Rank by<br/>Quality Score]
+    Rank --> Output[Return Top<br/>Hypotheses]
+
+    Output --> Struct[Hypothesis Structure:<br/>• Statement<br/>• Rationale<br/>• Testability Score<br/>• Data Requirements<br/>• Expected Outcomes]
+
+    style Input fill:#e1f5e1
+    style Output fill:#fff4e6
+    style Struct fill:#e6f3ff

7. Search Agent Workflow

flowchart TD
+    Input[Hypotheses] --> Strategy[Formulate Search<br/>Strategy per Hypothesis]
+
+    Strategy --> Multi[Multi-Source Search]
+
+    Multi --> PubMed[PubMed Search<br/>via MCP]
+    Multi --> ArXiv[arXiv Search<br/>via MCP]
+    Multi --> BioRxiv[bioRxiv Search<br/>via MCP]
+
+    PubMed --> Aggregate[Aggregate Results]
+    ArXiv --> Aggregate
+    BioRxiv --> Aggregate
+
+    Aggregate --> Filter[Filter & Rank<br/>by Relevance]
+    Filter --> Dedup[Deduplicate<br/>Cross-Reference]
+    Dedup --> Embed[Embed Documents<br/>via MCP]
+    Embed --> Vector[(Vector DB)]
+    Vector --> RAGRetrieval[RAG Retrieval<br/>Top-K per Hypothesis]
+    RAGRetrieval --> Output[Return Contextualized<br/>Search Results]
+
+    style Input fill:#fff4e6
+    style Multi fill:#ffe6e6
+    style Vector fill:#ffe6f0
+    style Output fill:#e6f3ff

8. Analysis Agent Workflow

flowchart TD
+    Input1[Hypotheses] --> Extract
+    Input2[Search Results] --> Extract[Extract Evidence<br/>per Hypothesis]
+
+    Extract --> Methods[Determine Analysis<br/>Methods Needed]
+
+    Methods --> Branch{Requires<br/>Computation?}
+    Branch -->|Yes| GenCode[Generate Python<br/>Analysis Code]
+    Branch -->|No| Qual[Qualitative<br/>Synthesis]
+
+    GenCode --> Execute[Execute Code<br/>via MCP Sandbox]
+    Execute --> Interpret1[Interpret<br/>Results]
+    Qual --> Interpret2[Interpret<br/>Findings]
+
+    Interpret1 --> Synthesize[Synthesize Evidence<br/>Across Sources]
+    Interpret2 --> Synthesize
+
+    Synthesize --> Verdict[Determine Verdict<br/>per Hypothesis]
+    Verdict --> Support[• Supported<br/>• Refuted<br/>• Inconclusive]
+    Support --> Gaps[Identify Knowledge<br/>Gaps & Limitations]
+    Gaps --> Output[Return Analysis<br/>Report]
+
+    style Input1 fill:#fff4e6
+    style Input2 fill:#e6f3ff
+    style Execute fill:#ffe6e6
+    style Output fill:#e6ffe6

9. Report Agent Workflow

flowchart TD
+    Input1[Query] --> Assemble
+    Input2[Hypotheses] --> Assemble
+    Input3[Search Results] --> Assemble
+    Input4[Analysis] --> Assemble[Assemble Report<br/>Sections]
+
+    Assemble --> Exec[Executive Summary]
+    Assemble --> Intro[Introduction]
+    Assemble --> Methods[Methods]
+    Assemble --> Results[Results per<br/>Hypothesis]
+    Assemble --> Discussion[Discussion]
+    Assemble --> Future[Future Directions]
+    Assemble --> Refs[References]
+
+    Results --> VizCheck{Needs<br/>Visualization?}
+    VizCheck -->|Yes| GenViz[Generate Viz Code]
+    GenViz --> ExecViz[Execute via MCP<br/>Create Charts]
+    ExecViz --> Combine
+    VizCheck -->|No| Combine[Combine All<br/>Sections]
+
+    Exec --> Combine
+    Intro --> Combine
+    Methods --> Combine
+    Discussion --> Combine
+    Future --> Combine
+    Refs --> Combine
+
+    Combine --> Format[Format Output]
+    Format --> MD[Markdown]
+    Format --> PDF[PDF]
+    Format --> JSON[JSON]
+
+    MD --> Output[Return Final<br/>Report]
+    PDF --> Output
+    JSON --> Output
+
+    style Input1 fill:#e1f5e1
+    style Input2 fill:#fff4e6
+    style Input3 fill:#e6f3ff
+    style Input4 fill:#e6ffe6
+    style Output fill:#d4edda

10. Data Flow & Event Streaming

flowchart TD
+    User[👤 User] -->|Research Query| UI[Gradio UI]
+    UI -->|Submit| Manager[Magentic Manager]
+
+    Manager -->|Event: Planning| UI
+    Manager -->|Select Agent| HypAgent[Hypothesis Agent]
+    HypAgent -->|Event: Delta/Message| UI
+    HypAgent -->|Hypotheses| Context[(Shared Context)]
+
+    Context -->|Retrieved by| Manager
+    Manager -->|Select Agent| SearchAgent[Search Agent]
+    SearchAgent -->|MCP Request| WebSearch[Web Search Tool]
+    WebSearch -->|Results| SearchAgent
+    SearchAgent -->|Event: Delta/Message| UI
+    SearchAgent -->|Documents| Context
+    SearchAgent -->|Embeddings| VectorDB[(Vector DB)]
+
+    Context -->|Retrieved by| Manager
+    Manager -->|Select Agent| AnalysisAgent[Analysis Agent]
+    AnalysisAgent -->|MCP Request| CodeExec[Code Execution Tool]
+    CodeExec -->|Results| AnalysisAgent
+    AnalysisAgent -->|Event: Delta/Message| UI
+    AnalysisAgent -->|Analysis| Context
+
+    Context -->|Retrieved by| Manager
+    Manager -->|Select Agent| ReportAgent[Report Agent]
+    ReportAgent -->|MCP Request| CodeExec
+    ReportAgent -->|Event: Delta/Message| UI
+    ReportAgent -->|Report| Context
+
+    Manager -->|Event: Final Result| UI
+    UI -->|Display| User
+
+    style User fill:#e1f5e1
+    style UI fill:#e6f3ff
+    style Manager fill:#ffe6e6
+    style Context fill:#ffe6f0
+    style VectorDB fill:#ffe6f0
+    style WebSearch fill:#f0f0f0
+    style CodeExec fill:#f0f0f0

11. MCP Tool Architecture

graph TB
+    subgraph "Agent Layer"
+        Manager[Magentic Manager]
+        HypAgent[Hypothesis Agent]
+        SearchAgent[Search Agent]
+        AnalysisAgent[Analysis Agent]
+        ReportAgent[Report Agent]
+    end
+
+    subgraph "MCP Protocol Layer"
+        Registry[MCP Tool Registry<br/>• Discovers tools<br/>• Routes requests<br/>• Manages connections]
+    end
+
+    subgraph "MCP Servers"
+        Server1[Web Search Server<br/>localhost:8001<br/>• PubMed<br/>• arXiv<br/>• bioRxiv]
+        Server2[Code Execution Server<br/>localhost:8002<br/>• Sandboxed Python<br/>• Package management]
+        Server3[RAG Server<br/>localhost:8003<br/>• Vector embeddings<br/>• Similarity search]
+        Server4[Visualization Server<br/>localhost:8004<br/>• Chart generation<br/>• Plot rendering]
+    end
+
+    subgraph "External Services"
+        PubMed[PubMed API]
+        ArXiv[arXiv API]
+        BioRxiv[bioRxiv API]
+        Modal[Modal Sandbox]
+        ChromaDB[(ChromaDB)]
+    end
+
+    SearchAgent -->|Request| Registry
+    AnalysisAgent -->|Request| Registry
+    ReportAgent -->|Request| Registry
+
+    Registry --> Server1
+    Registry --> Server2
+    Registry --> Server3
+    Registry --> Server4
+
+    Server1 --> PubMed
+    Server1 --> ArXiv
+    Server1 --> BioRxiv
+    Server2 --> Modal
+    Server3 --> ChromaDB
+
+    style Manager fill:#ffe6e6
+    style Registry fill:#fff4e6
+    style Server1 fill:#e6f3ff
+    style Server2 fill:#e6f3ff
+    style Server3 fill:#e6f3ff
+    style Server4 fill:#e6f3ff

12. Progress Tracking & Stall Detection

stateDiagram-v2
+    [*] --> Initialization: User Query
+
+    Initialization --> Planning: Manager starts
+
+    Planning --> AgentExecution: Select agent
+
+    AgentExecution --> Assessment: Collect results
+
+    Assessment --> QualityCheck: Evaluate output
+
+    QualityCheck --> AgentExecution: Poor quality<br/>(retry < max_rounds)
+    QualityCheck --> Planning: Poor quality<br/>(try different agent)
+    QualityCheck --> NextAgent: Good quality<br/>(task incomplete)
+    QualityCheck --> Synthesis: Good quality<br/>(task complete)
+
+    NextAgent --> AgentExecution: Select next agent
+
+    state StallDetection <<choice>>
+    Assessment --> StallDetection: Check progress
+    StallDetection --> Planning: No progress<br/>(stall count < max)
+    StallDetection --> ErrorRecovery: No progress<br/>(max stalls reached)
+
+    ErrorRecovery --> PartialReport: Generate partial results
+    PartialReport --> [*]
+
+    Synthesis --> FinalReport: Combine all outputs
+    FinalReport --> [*]
+
+    note right of QualityCheck
+        Manager assesses:
+        • Output completeness
+        • Quality metrics
+        • Progress made
+    end note
+
+    note right of StallDetection
+        Stall = no new progress
+        after agent execution
+        Triggers plan reset
+    end note

13. Gradio UI Integration

graph TD
+    App[Gradio App<br/>DeepCritical Research Agent]
+
+    App --> Input[Input Section]
+    App --> Status[Status Section]
+    App --> Output[Output Section]
+
+    Input --> Query[Research Question<br/>Text Area]
+    Input --> Controls[Controls]
+    Controls --> MaxHyp[Max Hypotheses: 1-10]
+    Controls --> MaxRounds[Max Rounds: 5-20]
+    Controls --> Submit[Start Research Button]
+
+    Status --> Log[Real-time Event Log<br/>• Manager planning<br/>• Agent selection<br/>• Execution updates<br/>• Quality assessment]
+    Status --> Progress[Progress Tracker<br/>• Current agent<br/>• Round count<br/>• Stall count]
+
+    Output --> Tabs[Tabbed Results]
+    Tabs --> Tab1[Hypotheses Tab<br/>Generated hypotheses with scores]
+    Tabs --> Tab2[Search Results Tab<br/>Papers & sources found]
+    Tabs --> Tab3[Analysis Tab<br/>Evidence & verdicts]
+    Tabs --> Tab4[Report Tab<br/>Final research report]
+    Tab4 --> Download[Download Report<br/>MD / PDF / JSON]
+
+    Submit -.->|Triggers| Workflow[Magentic Workflow]
+    Workflow -.->|MagenticOrchestratorMessageEvent| Log
+    Workflow -.->|MagenticAgentDeltaEvent| Log
+    Workflow -.->|MagenticAgentMessageEvent| Log
+    Workflow -.->|MagenticFinalResultEvent| Tab4
+
+    style App fill:#e1f5e1
+    style Input fill:#fff4e6
+    style Status fill:#e6f3ff
+    style Output fill:#e6ffe6
+    style Workflow fill:#ffe6e6

14. Complete System Context

graph LR
+    User[👤 Researcher<br/>Asks research questions] -->|Submits query| DC[DeepCritical<br/>Magentic Workflow]
+
+    DC -->|Literature search| PubMed[PubMed API<br/>Medical papers]
+    DC -->|Preprint search| ArXiv[arXiv API<br/>Scientific preprints]
+    DC -->|Biology search| BioRxiv[bioRxiv API<br/>Biology preprints]
+    DC -->|Agent reasoning| Claude[Claude API<br/>Sonnet 4 / Opus]
+    DC -->|Code execution| Modal[Modal Sandbox<br/>Safe Python env]
+    DC -->|Vector storage| Chroma[ChromaDB<br/>Embeddings & RAG]
+
+    DC -->|Deployed on| HF[HuggingFace Spaces<br/>Gradio 6.0]
+
+    PubMed -->|Results| DC
+    ArXiv -->|Results| DC
+    BioRxiv -->|Results| DC
+    Claude -->|Responses| DC
+    Modal -->|Output| DC
+    Chroma -->|Context| DC
+
+    DC -->|Research report| User
+
+    style User fill:#e1f5e1
+    style DC fill:#ffe6e6
+    style PubMed fill:#e6f3ff
+    style ArXiv fill:#e6f3ff
+    style BioRxiv fill:#e6f3ff
+    style Claude fill:#ffd6d6
+    style Modal fill:#f0f0f0
+    style Chroma fill:#ffe6f0
+    style HF fill:#d4edda

15. Workflow Timeline (Simplified)

gantt
+    title DeepCritical Magentic Workflow - Typical Execution
+    dateFormat mm:ss
+    axisFormat %M:%S
+
+    section Manager Planning
+    Initial planning         :p1, 00:00, 10s
+
+    section Hypothesis Agent
+    Generate hypotheses      :h1, after p1, 30s
+    Manager assessment       :h2, after h1, 5s
+
+    section Search Agent
+    Search hypothesis 1      :s1, after h2, 20s
+    Search hypothesis 2      :s2, after s1, 20s
+    Search hypothesis 3      :s3, after s2, 20s
+    RAG processing          :s4, after s3, 15s
+    Manager assessment      :s5, after s4, 5s
+
+    section Analysis Agent
+    Evidence extraction     :a1, after s5, 15s
+    Code generation        :a2, after a1, 20s
+    Code execution         :a3, after a2, 25s
+    Synthesis              :a4, after a3, 20s
+    Manager assessment     :a5, after a4, 5s
+
+    section Report Agent
+    Report assembly        :r1, after a5, 30s
+    Visualization          :r2, after r1, 15s
+    Formatting             :r3, after r2, 10s
+
+    section Manager Synthesis
+    Final synthesis        :f1, after r3, 10s

Key Differences from Original Design

Aspect Original (Judge-in-Loop) New (Magentic)
Control Flow Fixed sequential phases Dynamic agent selection
Quality Control Separate Judge Agent Manager assessment built-in
Retry Logic Phase-level with feedback Agent-level with adaptation
Flexibility Rigid 4-phase pipeline Adaptive workflow
Complexity 5 agents (including Judge) 4 agents (no Judge)
Progress Tracking Manual state management Built-in round/stall detection
Agent Coordination Sequential handoff Manager-driven dynamic selection
Error Recovery Retry same phase Try different agent or replan

Simplified Design Principles

  1. Manager is Intelligent: LLM-powered manager handles planning, selection, and quality assessment
  2. No Separate Judge: Manager's assessment phase replaces dedicated Judge Agent
  3. Dynamic Workflow: Agents can be called multiple times in any order based on need
  4. Built-in Safety: max_round_count (15) and max_stall_count (3) prevent infinite loops
  5. Event-Driven UI: Real-time streaming updates to Gradio interface
  6. MCP-Powered Tools: All external capabilities via Model Context Protocol
  7. Shared Context: Centralized state accessible to all agents
  8. Progress Awareness: Manager tracks what's been done and what's needed

Legend

  • 🔴 Red/Pink: Manager, orchestration, decision-making
  • 🟡 Yellow/Orange: Specialist agents, processing
  • 🔵 Blue: Data, tools, MCP services
  • 🟣 Purple/Pink: Storage, databases, state
  • 🟢 Green: User interactions, final outputs
  • Gray: External services, APIs

Implementation Highlights

Simple 4-Agent Setup:

workflow = (
+    MagenticBuilder()
+    .participants(
+        hypothesis=HypothesisAgent(tools=[background_tool]),
+        search=SearchAgent(tools=[web_search, rag_tool]),
+        analysis=AnalysisAgent(tools=[code_execution]),
+        report=ReportAgent(tools=[code_execution, visualization])
+    )
+    .with_standard_manager(
+        chat_client=AnthropicClient(model="claude-sonnet-4"),
+        max_round_count=15,    # Prevent infinite loops
+        max_stall_count=3      # Detect stuck workflows
+    )
+    .build()
+)
+

Manager handles quality assessment in its instructions: - Checks hypothesis quality (testable, novel, clear) - Validates search results (relevant, authoritative, recent) - Assesses analysis soundness (methodology, evidence, conclusions) - Ensures report completeness (all sections, proper citations)

No separate Judge Agent needed - manager does it all!


Document Version: 2.0 (Magentic Simplified) Last Updated: 2025-11-24 Architecture: Microsoft Magentic Orchestration Pattern Agents: 4 (Hypothesis, Search, Analysis, Report) + 1 Manager License: MIT

\ No newline at end of file diff --git a/site/assets/images/favicon.png b/site/assets/images/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..1cf13b9f9d978896599290a74f77d5dbe7d1655c Binary files /dev/null and b/site/assets/images/favicon.png differ diff --git a/site/assets/javascripts/bundle.e71a0d61.min.js b/site/assets/javascripts/bundle.e71a0d61.min.js new file mode 100644 index 0000000000000000000000000000000000000000..c76b3b2b18a0e8a097ad2690dd51fa8adc12d0be --- /dev/null +++ b/site/assets/javascripts/bundle.e71a0d61.min.js @@ -0,0 +1,16 @@ +"use strict";(()=>{var Zi=Object.create;var _r=Object.defineProperty;var ea=Object.getOwnPropertyDescriptor;var ta=Object.getOwnPropertyNames,Bt=Object.getOwnPropertySymbols,ra=Object.getPrototypeOf,Ar=Object.prototype.hasOwnProperty,bo=Object.prototype.propertyIsEnumerable;var ho=(e,t,r)=>t in e?_r(e,t,{enumerable:!0,configurable:!0,writable:!0,value:r}):e[t]=r,P=(e,t)=>{for(var r in t||(t={}))Ar.call(t,r)&&ho(e,r,t[r]);if(Bt)for(var r of Bt(t))bo.call(t,r)&&ho(e,r,t[r]);return e};var vo=(e,t)=>{var r={};for(var o in e)Ar.call(e,o)&&t.indexOf(o)<0&&(r[o]=e[o]);if(e!=null&&Bt)for(var o of Bt(e))t.indexOf(o)<0&&bo.call(e,o)&&(r[o]=e[o]);return r};var Cr=(e,t)=>()=>(t||e((t={exports:{}}).exports,t),t.exports);var oa=(e,t,r,o)=>{if(t&&typeof t=="object"||typeof t=="function")for(let n of ta(t))!Ar.call(e,n)&&n!==r&&_r(e,n,{get:()=>t[n],enumerable:!(o=ea(t,n))||o.enumerable});return e};var $t=(e,t,r)=>(r=e!=null?Zi(ra(e)):{},oa(t||!e||!e.__esModule?_r(r,"default",{value:e,enumerable:!0}):r,e));var go=(e,t,r)=>new Promise((o,n)=>{var i=c=>{try{a(r.next(c))}catch(p){n(p)}},s=c=>{try{a(r.throw(c))}catch(p){n(p)}},a=c=>c.done?o(c.value):Promise.resolve(c.value).then(i,s);a((r=r.apply(e,t)).next())});var xo=Cr((kr,yo)=>{(function(e,t){typeof kr=="object"&&typeof yo!="undefined"?t():typeof define=="function"&&define.amd?define(t):t()})(kr,(function(){"use strict";function e(r){var o=!0,n=!1,i=null,s={text:!0,search:!0,url:!0,tel:!0,email:!0,password:!0,number:!0,date:!0,month:!0,week:!0,time:!0,datetime:!0,"datetime-local":!0};function a(k){return!!(k&&k!==document&&k.nodeName!=="HTML"&&k.nodeName!=="BODY"&&"classList"in k&&"contains"in k.classList)}function c(k){var ut=k.type,je=k.tagName;return!!(je==="INPUT"&&s[ut]&&!k.readOnly||je==="TEXTAREA"&&!k.readOnly||k.isContentEditable)}function p(k){k.classList.contains("focus-visible")||(k.classList.add("focus-visible"),k.setAttribute("data-focus-visible-added",""))}function l(k){k.hasAttribute("data-focus-visible-added")&&(k.classList.remove("focus-visible"),k.removeAttribute("data-focus-visible-added"))}function f(k){k.metaKey||k.altKey||k.ctrlKey||(a(r.activeElement)&&p(r.activeElement),o=!0)}function u(k){o=!1}function d(k){a(k.target)&&(o||c(k.target))&&p(k.target)}function v(k){a(k.target)&&(k.target.classList.contains("focus-visible")||k.target.hasAttribute("data-focus-visible-added"))&&(n=!0,window.clearTimeout(i),i=window.setTimeout(function(){n=!1},100),l(k.target))}function S(k){document.visibilityState==="hidden"&&(n&&(o=!0),X())}function X(){document.addEventListener("mousemove",ee),document.addEventListener("mousedown",ee),document.addEventListener("mouseup",ee),document.addEventListener("pointermove",ee),document.addEventListener("pointerdown",ee),document.addEventListener("pointerup",ee),document.addEventListener("touchmove",ee),document.addEventListener("touchstart",ee),document.addEventListener("touchend",ee)}function re(){document.removeEventListener("mousemove",ee),document.removeEventListener("mousedown",ee),document.removeEventListener("mouseup",ee),document.removeEventListener("pointermove",ee),document.removeEventListener("pointerdown",ee),document.removeEventListener("pointerup",ee),document.removeEventListener("touchmove",ee),document.removeEventListener("touchstart",ee),document.removeEventListener("touchend",ee)}function ee(k){k.target.nodeName&&k.target.nodeName.toLowerCase()==="html"||(o=!1,re())}document.addEventListener("keydown",f,!0),document.addEventListener("mousedown",u,!0),document.addEventListener("pointerdown",u,!0),document.addEventListener("touchstart",u,!0),document.addEventListener("visibilitychange",S,!0),X(),r.addEventListener("focus",d,!0),r.addEventListener("blur",v,!0),r.nodeType===Node.DOCUMENT_FRAGMENT_NODE&&r.host?r.host.setAttribute("data-js-focus-visible",""):r.nodeType===Node.DOCUMENT_NODE&&(document.documentElement.classList.add("js-focus-visible"),document.documentElement.setAttribute("data-js-focus-visible",""))}if(typeof window!="undefined"&&typeof document!="undefined"){window.applyFocusVisiblePolyfill=e;var t;try{t=new CustomEvent("focus-visible-polyfill-ready")}catch(r){t=document.createEvent("CustomEvent"),t.initCustomEvent("focus-visible-polyfill-ready",!1,!1,{})}window.dispatchEvent(t)}typeof document!="undefined"&&e(document)}))});var ro=Cr((jy,Rn)=>{"use strict";/*! + * escape-html + * Copyright(c) 2012-2013 TJ Holowaychuk + * Copyright(c) 2015 Andreas Lubbe + * Copyright(c) 2015 Tiancheng "Timothy" Gu + * MIT Licensed + */var qa=/["'&<>]/;Rn.exports=Ka;function Ka(e){var t=""+e,r=qa.exec(t);if(!r)return t;var o,n="",i=0,s=0;for(i=r.index;i{/*! + * clipboard.js v2.0.11 + * https://clipboardjs.com/ + * + * Licensed MIT © Zeno Rocha + */(function(t,r){typeof Nt=="object"&&typeof io=="object"?io.exports=r():typeof define=="function"&&define.amd?define([],r):typeof Nt=="object"?Nt.ClipboardJS=r():t.ClipboardJS=r()})(Nt,function(){return(function(){var e={686:(function(o,n,i){"use strict";i.d(n,{default:function(){return Xi}});var s=i(279),a=i.n(s),c=i(370),p=i.n(c),l=i(817),f=i.n(l);function u(q){try{return document.execCommand(q)}catch(C){return!1}}var d=function(C){var _=f()(C);return u("cut"),_},v=d;function S(q){var C=document.documentElement.getAttribute("dir")==="rtl",_=document.createElement("textarea");_.style.fontSize="12pt",_.style.border="0",_.style.padding="0",_.style.margin="0",_.style.position="absolute",_.style[C?"right":"left"]="-9999px";var D=window.pageYOffset||document.documentElement.scrollTop;return _.style.top="".concat(D,"px"),_.setAttribute("readonly",""),_.value=q,_}var X=function(C,_){var D=S(C);_.container.appendChild(D);var N=f()(D);return u("copy"),D.remove(),N},re=function(C){var _=arguments.length>1&&arguments[1]!==void 0?arguments[1]:{container:document.body},D="";return typeof C=="string"?D=X(C,_):C instanceof HTMLInputElement&&!["text","search","url","tel","password"].includes(C==null?void 0:C.type)?D=X(C.value,_):(D=f()(C),u("copy")),D},ee=re;function k(q){"@babel/helpers - typeof";return typeof Symbol=="function"&&typeof Symbol.iterator=="symbol"?k=function(_){return typeof _}:k=function(_){return _&&typeof Symbol=="function"&&_.constructor===Symbol&&_!==Symbol.prototype?"symbol":typeof _},k(q)}var ut=function(){var C=arguments.length>0&&arguments[0]!==void 0?arguments[0]:{},_=C.action,D=_===void 0?"copy":_,N=C.container,G=C.target,We=C.text;if(D!=="copy"&&D!=="cut")throw new Error('Invalid "action" value, use either "copy" or "cut"');if(G!==void 0)if(G&&k(G)==="object"&&G.nodeType===1){if(D==="copy"&&G.hasAttribute("disabled"))throw new Error('Invalid "target" attribute. Please use "readonly" instead of "disabled" attribute');if(D==="cut"&&(G.hasAttribute("readonly")||G.hasAttribute("disabled")))throw new Error(`Invalid "target" attribute. You can't cut text from elements with "readonly" or "disabled" attributes`)}else throw new Error('Invalid "target" value, use a valid Element');if(We)return ee(We,{container:N});if(G)return D==="cut"?v(G):ee(G,{container:N})},je=ut;function R(q){"@babel/helpers - typeof";return typeof Symbol=="function"&&typeof Symbol.iterator=="symbol"?R=function(_){return typeof _}:R=function(_){return _&&typeof Symbol=="function"&&_.constructor===Symbol&&_!==Symbol.prototype?"symbol":typeof _},R(q)}function se(q,C){if(!(q instanceof C))throw new TypeError("Cannot call a class as a function")}function ce(q,C){for(var _=0;_0&&arguments[0]!==void 0?arguments[0]:{};this.action=typeof N.action=="function"?N.action:this.defaultAction,this.target=typeof N.target=="function"?N.target:this.defaultTarget,this.text=typeof N.text=="function"?N.text:this.defaultText,this.container=R(N.container)==="object"?N.container:document.body}},{key:"listenClick",value:function(N){var G=this;this.listener=p()(N,"click",function(We){return G.onClick(We)})}},{key:"onClick",value:function(N){var G=N.delegateTarget||N.currentTarget,We=this.action(G)||"copy",Yt=je({action:We,container:this.container,target:this.target(G),text:this.text(G)});this.emit(Yt?"success":"error",{action:We,text:Yt,trigger:G,clearSelection:function(){G&&G.focus(),window.getSelection().removeAllRanges()}})}},{key:"defaultAction",value:function(N){return Mr("action",N)}},{key:"defaultTarget",value:function(N){var G=Mr("target",N);if(G)return document.querySelector(G)}},{key:"defaultText",value:function(N){return Mr("text",N)}},{key:"destroy",value:function(){this.listener.destroy()}}],[{key:"copy",value:function(N){var G=arguments.length>1&&arguments[1]!==void 0?arguments[1]:{container:document.body};return ee(N,G)}},{key:"cut",value:function(N){return v(N)}},{key:"isSupported",value:function(){var N=arguments.length>0&&arguments[0]!==void 0?arguments[0]:["copy","cut"],G=typeof N=="string"?[N]:N,We=!!document.queryCommandSupported;return G.forEach(function(Yt){We=We&&!!document.queryCommandSupported(Yt)}),We}}]),_})(a()),Xi=Ji}),828:(function(o){var n=9;if(typeof Element!="undefined"&&!Element.prototype.matches){var i=Element.prototype;i.matches=i.matchesSelector||i.mozMatchesSelector||i.msMatchesSelector||i.oMatchesSelector||i.webkitMatchesSelector}function s(a,c){for(;a&&a.nodeType!==n;){if(typeof a.matches=="function"&&a.matches(c))return a;a=a.parentNode}}o.exports=s}),438:(function(o,n,i){var s=i(828);function a(l,f,u,d,v){var S=p.apply(this,arguments);return l.addEventListener(u,S,v),{destroy:function(){l.removeEventListener(u,S,v)}}}function c(l,f,u,d,v){return typeof l.addEventListener=="function"?a.apply(null,arguments):typeof u=="function"?a.bind(null,document).apply(null,arguments):(typeof l=="string"&&(l=document.querySelectorAll(l)),Array.prototype.map.call(l,function(S){return a(S,f,u,d,v)}))}function p(l,f,u,d){return function(v){v.delegateTarget=s(v.target,f),v.delegateTarget&&d.call(l,v)}}o.exports=c}),879:(function(o,n){n.node=function(i){return i!==void 0&&i instanceof HTMLElement&&i.nodeType===1},n.nodeList=function(i){var s=Object.prototype.toString.call(i);return i!==void 0&&(s==="[object NodeList]"||s==="[object HTMLCollection]")&&"length"in i&&(i.length===0||n.node(i[0]))},n.string=function(i){return typeof i=="string"||i instanceof String},n.fn=function(i){var s=Object.prototype.toString.call(i);return s==="[object Function]"}}),370:(function(o,n,i){var s=i(879),a=i(438);function c(u,d,v){if(!u&&!d&&!v)throw new Error("Missing required arguments");if(!s.string(d))throw new TypeError("Second argument must be a String");if(!s.fn(v))throw new TypeError("Third argument must be a Function");if(s.node(u))return p(u,d,v);if(s.nodeList(u))return l(u,d,v);if(s.string(u))return f(u,d,v);throw new TypeError("First argument must be a String, HTMLElement, HTMLCollection, or NodeList")}function p(u,d,v){return u.addEventListener(d,v),{destroy:function(){u.removeEventListener(d,v)}}}function l(u,d,v){return Array.prototype.forEach.call(u,function(S){S.addEventListener(d,v)}),{destroy:function(){Array.prototype.forEach.call(u,function(S){S.removeEventListener(d,v)})}}}function f(u,d,v){return a(document.body,u,d,v)}o.exports=c}),817:(function(o){function n(i){var s;if(i.nodeName==="SELECT")i.focus(),s=i.value;else if(i.nodeName==="INPUT"||i.nodeName==="TEXTAREA"){var a=i.hasAttribute("readonly");a||i.setAttribute("readonly",""),i.select(),i.setSelectionRange(0,i.value.length),a||i.removeAttribute("readonly"),s=i.value}else{i.hasAttribute("contenteditable")&&i.focus();var c=window.getSelection(),p=document.createRange();p.selectNodeContents(i),c.removeAllRanges(),c.addRange(p),s=c.toString()}return s}o.exports=n}),279:(function(o){function n(){}n.prototype={on:function(i,s,a){var c=this.e||(this.e={});return(c[i]||(c[i]=[])).push({fn:s,ctx:a}),this},once:function(i,s,a){var c=this;function p(){c.off(i,p),s.apply(a,arguments)}return p._=s,this.on(i,p,a)},emit:function(i){var s=[].slice.call(arguments,1),a=((this.e||(this.e={}))[i]||[]).slice(),c=0,p=a.length;for(c;c0&&i[i.length-1])&&(p[0]===6||p[0]===2)){r=0;continue}if(p[0]===3&&(!i||p[1]>i[0]&&p[1]=e.length&&(e=void 0),{value:e&&e[o++],done:!e}}};throw new TypeError(t?"Object is not iterable.":"Symbol.iterator is not defined.")}function K(e,t){var r=typeof Symbol=="function"&&e[Symbol.iterator];if(!r)return e;var o=r.call(e),n,i=[],s;try{for(;(t===void 0||t-- >0)&&!(n=o.next()).done;)i.push(n.value)}catch(a){s={error:a}}finally{try{n&&!n.done&&(r=o.return)&&r.call(o)}finally{if(s)throw s.error}}return i}function B(e,t,r){if(r||arguments.length===2)for(var o=0,n=t.length,i;o1||c(d,S)})},v&&(n[d]=v(n[d])))}function c(d,v){try{p(o[d](v))}catch(S){u(i[0][3],S)}}function p(d){d.value instanceof dt?Promise.resolve(d.value.v).then(l,f):u(i[0][2],d)}function l(d){c("next",d)}function f(d){c("throw",d)}function u(d,v){d(v),i.shift(),i.length&&c(i[0][0],i[0][1])}}function To(e){if(!Symbol.asyncIterator)throw new TypeError("Symbol.asyncIterator is not defined.");var t=e[Symbol.asyncIterator],r;return t?t.call(e):(e=typeof Oe=="function"?Oe(e):e[Symbol.iterator](),r={},o("next"),o("throw"),o("return"),r[Symbol.asyncIterator]=function(){return this},r);function o(i){r[i]=e[i]&&function(s){return new Promise(function(a,c){s=e[i](s),n(a,c,s.done,s.value)})}}function n(i,s,a,c){Promise.resolve(c).then(function(p){i({value:p,done:a})},s)}}function I(e){return typeof e=="function"}function yt(e){var t=function(o){Error.call(o),o.stack=new Error().stack},r=e(t);return r.prototype=Object.create(Error.prototype),r.prototype.constructor=r,r}var Jt=yt(function(e){return function(r){e(this),this.message=r?r.length+` errors occurred during unsubscription: +`+r.map(function(o,n){return n+1+") "+o.toString()}).join(` + `):"",this.name="UnsubscriptionError",this.errors=r}});function Ze(e,t){if(e){var r=e.indexOf(t);0<=r&&e.splice(r,1)}}var qe=(function(){function e(t){this.initialTeardown=t,this.closed=!1,this._parentage=null,this._finalizers=null}return e.prototype.unsubscribe=function(){var t,r,o,n,i;if(!this.closed){this.closed=!0;var s=this._parentage;if(s)if(this._parentage=null,Array.isArray(s))try{for(var a=Oe(s),c=a.next();!c.done;c=a.next()){var p=c.value;p.remove(this)}}catch(S){t={error:S}}finally{try{c&&!c.done&&(r=a.return)&&r.call(a)}finally{if(t)throw t.error}}else s.remove(this);var l=this.initialTeardown;if(I(l))try{l()}catch(S){i=S instanceof Jt?S.errors:[S]}var f=this._finalizers;if(f){this._finalizers=null;try{for(var u=Oe(f),d=u.next();!d.done;d=u.next()){var v=d.value;try{So(v)}catch(S){i=i!=null?i:[],S instanceof Jt?i=B(B([],K(i)),K(S.errors)):i.push(S)}}}catch(S){o={error:S}}finally{try{d&&!d.done&&(n=u.return)&&n.call(u)}finally{if(o)throw o.error}}}if(i)throw new Jt(i)}},e.prototype.add=function(t){var r;if(t&&t!==this)if(this.closed)So(t);else{if(t instanceof e){if(t.closed||t._hasParent(this))return;t._addParent(this)}(this._finalizers=(r=this._finalizers)!==null&&r!==void 0?r:[]).push(t)}},e.prototype._hasParent=function(t){var r=this._parentage;return r===t||Array.isArray(r)&&r.includes(t)},e.prototype._addParent=function(t){var r=this._parentage;this._parentage=Array.isArray(r)?(r.push(t),r):r?[r,t]:t},e.prototype._removeParent=function(t){var r=this._parentage;r===t?this._parentage=null:Array.isArray(r)&&Ze(r,t)},e.prototype.remove=function(t){var r=this._finalizers;r&&Ze(r,t),t instanceof e&&t._removeParent(this)},e.EMPTY=(function(){var t=new e;return t.closed=!0,t})(),e})();var $r=qe.EMPTY;function Xt(e){return e instanceof qe||e&&"closed"in e&&I(e.remove)&&I(e.add)&&I(e.unsubscribe)}function So(e){I(e)?e():e.unsubscribe()}var De={onUnhandledError:null,onStoppedNotification:null,Promise:void 0,useDeprecatedSynchronousErrorHandling:!1,useDeprecatedNextContext:!1};var xt={setTimeout:function(e,t){for(var r=[],o=2;o0},enumerable:!1,configurable:!0}),t.prototype._trySubscribe=function(r){return this._throwIfClosed(),e.prototype._trySubscribe.call(this,r)},t.prototype._subscribe=function(r){return this._throwIfClosed(),this._checkFinalizedStatuses(r),this._innerSubscribe(r)},t.prototype._innerSubscribe=function(r){var o=this,n=this,i=n.hasError,s=n.isStopped,a=n.observers;return i||s?$r:(this.currentObservers=null,a.push(r),new qe(function(){o.currentObservers=null,Ze(a,r)}))},t.prototype._checkFinalizedStatuses=function(r){var o=this,n=o.hasError,i=o.thrownError,s=o.isStopped;n?r.error(i):s&&r.complete()},t.prototype.asObservable=function(){var r=new F;return r.source=this,r},t.create=function(r,o){return new Ho(r,o)},t})(F);var Ho=(function(e){ie(t,e);function t(r,o){var n=e.call(this)||this;return n.destination=r,n.source=o,n}return t.prototype.next=function(r){var o,n;(n=(o=this.destination)===null||o===void 0?void 0:o.next)===null||n===void 0||n.call(o,r)},t.prototype.error=function(r){var o,n;(n=(o=this.destination)===null||o===void 0?void 0:o.error)===null||n===void 0||n.call(o,r)},t.prototype.complete=function(){var r,o;(o=(r=this.destination)===null||r===void 0?void 0:r.complete)===null||o===void 0||o.call(r)},t.prototype._subscribe=function(r){var o,n;return(n=(o=this.source)===null||o===void 0?void 0:o.subscribe(r))!==null&&n!==void 0?n:$r},t})(T);var jr=(function(e){ie(t,e);function t(r){var o=e.call(this)||this;return o._value=r,o}return Object.defineProperty(t.prototype,"value",{get:function(){return this.getValue()},enumerable:!1,configurable:!0}),t.prototype._subscribe=function(r){var o=e.prototype._subscribe.call(this,r);return!o.closed&&r.next(this._value),o},t.prototype.getValue=function(){var r=this,o=r.hasError,n=r.thrownError,i=r._value;if(o)throw n;return this._throwIfClosed(),i},t.prototype.next=function(r){e.prototype.next.call(this,this._value=r)},t})(T);var Rt={now:function(){return(Rt.delegate||Date).now()},delegate:void 0};var It=(function(e){ie(t,e);function t(r,o,n){r===void 0&&(r=1/0),o===void 0&&(o=1/0),n===void 0&&(n=Rt);var i=e.call(this)||this;return i._bufferSize=r,i._windowTime=o,i._timestampProvider=n,i._buffer=[],i._infiniteTimeWindow=!0,i._infiniteTimeWindow=o===1/0,i._bufferSize=Math.max(1,r),i._windowTime=Math.max(1,o),i}return t.prototype.next=function(r){var o=this,n=o.isStopped,i=o._buffer,s=o._infiniteTimeWindow,a=o._timestampProvider,c=o._windowTime;n||(i.push(r),!s&&i.push(a.now()+c)),this._trimBuffer(),e.prototype.next.call(this,r)},t.prototype._subscribe=function(r){this._throwIfClosed(),this._trimBuffer();for(var o=this._innerSubscribe(r),n=this,i=n._infiniteTimeWindow,s=n._buffer,a=s.slice(),c=0;c0?e.prototype.schedule.call(this,r,o):(this.delay=o,this.state=r,this.scheduler.flush(this),this)},t.prototype.execute=function(r,o){return o>0||this.closed?e.prototype.execute.call(this,r,o):this._execute(r,o)},t.prototype.requestAsyncId=function(r,o,n){return n===void 0&&(n=0),n!=null&&n>0||n==null&&this.delay>0?e.prototype.requestAsyncId.call(this,r,o,n):(r.flush(this),0)},t})(St);var Ro=(function(e){ie(t,e);function t(){return e!==null&&e.apply(this,arguments)||this}return t})(Ot);var Dr=new Ro(Po);var Io=(function(e){ie(t,e);function t(r,o){var n=e.call(this,r,o)||this;return n.scheduler=r,n.work=o,n}return t.prototype.requestAsyncId=function(r,o,n){return n===void 0&&(n=0),n!==null&&n>0?e.prototype.requestAsyncId.call(this,r,o,n):(r.actions.push(this),r._scheduled||(r._scheduled=Tt.requestAnimationFrame(function(){return r.flush(void 0)})))},t.prototype.recycleAsyncId=function(r,o,n){var i;if(n===void 0&&(n=0),n!=null?n>0:this.delay>0)return e.prototype.recycleAsyncId.call(this,r,o,n);var s=r.actions;o!=null&&o===r._scheduled&&((i=s[s.length-1])===null||i===void 0?void 0:i.id)!==o&&(Tt.cancelAnimationFrame(o),r._scheduled=void 0)},t})(St);var Fo=(function(e){ie(t,e);function t(){return e!==null&&e.apply(this,arguments)||this}return t.prototype.flush=function(r){this._active=!0;var o;r?o=r.id:(o=this._scheduled,this._scheduled=void 0);var n=this.actions,i;r=r||n.shift();do if(i=r.execute(r.state,r.delay))break;while((r=n[0])&&r.id===o&&n.shift());if(this._active=!1,i){for(;(r=n[0])&&r.id===o&&n.shift();)r.unsubscribe();throw i}},t})(Ot);var ye=new Fo(Io);var y=new F(function(e){return e.complete()});function tr(e){return e&&I(e.schedule)}function Vr(e){return e[e.length-1]}function pt(e){return I(Vr(e))?e.pop():void 0}function Fe(e){return tr(Vr(e))?e.pop():void 0}function rr(e,t){return typeof Vr(e)=="number"?e.pop():t}var Lt=(function(e){return e&&typeof e.length=="number"&&typeof e!="function"});function or(e){return I(e==null?void 0:e.then)}function nr(e){return I(e[wt])}function ir(e){return Symbol.asyncIterator&&I(e==null?void 0:e[Symbol.asyncIterator])}function ar(e){return new TypeError("You provided "+(e!==null&&typeof e=="object"?"an invalid object":"'"+e+"'")+" where a stream was expected. You can provide an Observable, Promise, ReadableStream, Array, AsyncIterable, or Iterable.")}function fa(){return typeof Symbol!="function"||!Symbol.iterator?"@@iterator":Symbol.iterator}var sr=fa();function cr(e){return I(e==null?void 0:e[sr])}function pr(e){return wo(this,arguments,function(){var r,o,n,i;return Gt(this,function(s){switch(s.label){case 0:r=e.getReader(),s.label=1;case 1:s.trys.push([1,,9,10]),s.label=2;case 2:return[4,dt(r.read())];case 3:return o=s.sent(),n=o.value,i=o.done,i?[4,dt(void 0)]:[3,5];case 4:return[2,s.sent()];case 5:return[4,dt(n)];case 6:return[4,s.sent()];case 7:return s.sent(),[3,2];case 8:return[3,10];case 9:return r.releaseLock(),[7];case 10:return[2]}})})}function lr(e){return I(e==null?void 0:e.getReader)}function U(e){if(e instanceof F)return e;if(e!=null){if(nr(e))return ua(e);if(Lt(e))return da(e);if(or(e))return ha(e);if(ir(e))return jo(e);if(cr(e))return ba(e);if(lr(e))return va(e)}throw ar(e)}function ua(e){return new F(function(t){var r=e[wt]();if(I(r.subscribe))return r.subscribe(t);throw new TypeError("Provided object does not correctly implement Symbol.observable")})}function da(e){return new F(function(t){for(var r=0;r=2;return function(o){return o.pipe(e?g(function(n,i){return e(n,i,o)}):be,Ee(1),r?Qe(t):tn(function(){return new fr}))}}function Yr(e){return e<=0?function(){return y}:E(function(t,r){var o=[];t.subscribe(w(r,function(n){o.push(n),e=2,!0))}function le(e){e===void 0&&(e={});var t=e.connector,r=t===void 0?function(){return new T}:t,o=e.resetOnError,n=o===void 0?!0:o,i=e.resetOnComplete,s=i===void 0?!0:i,a=e.resetOnRefCountZero,c=a===void 0?!0:a;return function(p){var l,f,u,d=0,v=!1,S=!1,X=function(){f==null||f.unsubscribe(),f=void 0},re=function(){X(),l=u=void 0,v=S=!1},ee=function(){var k=l;re(),k==null||k.unsubscribe()};return E(function(k,ut){d++,!S&&!v&&X();var je=u=u!=null?u:r();ut.add(function(){d--,d===0&&!S&&!v&&(f=Br(ee,c))}),je.subscribe(ut),!l&&d>0&&(l=new bt({next:function(R){return je.next(R)},error:function(R){S=!0,X(),f=Br(re,n,R),je.error(R)},complete:function(){v=!0,X(),f=Br(re,s),je.complete()}}),U(k).subscribe(l))})(p)}}function Br(e,t){for(var r=[],o=2;oe.next(document)),e}function M(e,t=document){return Array.from(t.querySelectorAll(e))}function j(e,t=document){let r=ue(e,t);if(typeof r=="undefined")throw new ReferenceError(`Missing element: expected "${e}" to be present`);return r}function ue(e,t=document){return t.querySelector(e)||void 0}function Ne(){var e,t,r,o;return(o=(r=(t=(e=document.activeElement)==null?void 0:e.shadowRoot)==null?void 0:t.activeElement)!=null?r:document.activeElement)!=null?o:void 0}var Ra=L(h(document.body,"focusin"),h(document.body,"focusout")).pipe(Ae(1),Q(void 0),m(()=>Ne()||document.body),Z(1));function Ye(e){return Ra.pipe(m(t=>e.contains(t)),Y())}function it(e,t){return H(()=>L(h(e,"mouseenter").pipe(m(()=>!0)),h(e,"mouseleave").pipe(m(()=>!1))).pipe(t?jt(r=>He(+!r*t)):be,Q(e.matches(":hover"))))}function sn(e,t){if(typeof t=="string"||typeof t=="number")e.innerHTML+=t.toString();else if(t instanceof Node)e.appendChild(t);else if(Array.isArray(t))for(let r of t)sn(e,r)}function x(e,t,...r){let o=document.createElement(e);if(t)for(let n of Object.keys(t))typeof t[n]!="undefined"&&(typeof t[n]!="boolean"?o.setAttribute(n,t[n]):o.setAttribute(n,""));for(let n of r)sn(o,n);return o}function br(e){if(e>999){let t=+((e-950)%1e3>99);return`${((e+1e-6)/1e3).toFixed(t)}k`}else return e.toString()}function _t(e){let t=x("script",{src:e});return H(()=>(document.head.appendChild(t),L(h(t,"load"),h(t,"error").pipe(b(()=>Nr(()=>new ReferenceError(`Invalid script: ${e}`))))).pipe(m(()=>{}),A(()=>document.head.removeChild(t)),Ee(1))))}var cn=new T,Ia=H(()=>typeof ResizeObserver=="undefined"?_t("https://unpkg.com/resize-observer-polyfill"):$(void 0)).pipe(m(()=>new ResizeObserver(e=>e.forEach(t=>cn.next(t)))),b(e=>L(tt,$(e)).pipe(A(()=>e.disconnect()))),Z(1));function de(e){return{width:e.offsetWidth,height:e.offsetHeight}}function Le(e){let t=e;for(;t.clientWidth===0&&t.parentElement;)t=t.parentElement;return Ia.pipe(O(r=>r.observe(t)),b(r=>cn.pipe(g(o=>o.target===t),A(()=>r.unobserve(t)))),m(()=>de(e)),Q(de(e)))}function At(e){return{width:e.scrollWidth,height:e.scrollHeight}}function vr(e){let t=e.parentElement;for(;t&&(e.scrollWidth<=t.scrollWidth&&e.scrollHeight<=t.scrollHeight);)t=(e=t).parentElement;return t?e:void 0}function pn(e){let t=[],r=e.parentElement;for(;r;)(e.clientWidth>r.clientWidth||e.clientHeight>r.clientHeight)&&t.push(r),r=(e=r).parentElement;return t.length===0&&t.push(document.documentElement),t}function Be(e){return{x:e.offsetLeft,y:e.offsetTop}}function ln(e){let t=e.getBoundingClientRect();return{x:t.x+window.scrollX,y:t.y+window.scrollY}}function mn(e){return L(h(window,"load"),h(window,"resize")).pipe($e(0,ye),m(()=>Be(e)),Q(Be(e)))}function gr(e){return{x:e.scrollLeft,y:e.scrollTop}}function Ge(e){return L(h(e,"scroll"),h(window,"scroll"),h(window,"resize")).pipe($e(0,ye),m(()=>gr(e)),Q(gr(e)))}var fn=new T,Fa=H(()=>$(new IntersectionObserver(e=>{for(let t of e)fn.next(t)},{threshold:0}))).pipe(b(e=>L(tt,$(e)).pipe(A(()=>e.disconnect()))),Z(1));function mt(e){return Fa.pipe(O(t=>t.observe(e)),b(t=>fn.pipe(g(({target:r})=>r===e),A(()=>t.unobserve(e)),m(({isIntersecting:r})=>r))))}function un(e,t=16){return Ge(e).pipe(m(({y:r})=>{let o=de(e),n=At(e);return r>=n.height-o.height-t}),Y())}var yr={drawer:j("[data-md-toggle=drawer]"),search:j("[data-md-toggle=search]")};function dn(e){return yr[e].checked}function at(e,t){yr[e].checked!==t&&yr[e].click()}function Je(e){let t=yr[e];return h(t,"change").pipe(m(()=>t.checked),Q(t.checked))}function ja(e,t){switch(e.constructor){case HTMLInputElement:return e.type==="radio"?/^Arrow/.test(t):!0;case HTMLSelectElement:case HTMLTextAreaElement:return!0;default:return e.isContentEditable}}function Ua(){return L(h(window,"compositionstart").pipe(m(()=>!0)),h(window,"compositionend").pipe(m(()=>!1))).pipe(Q(!1))}function hn(){let e=h(window,"keydown").pipe(g(t=>!(t.metaKey||t.ctrlKey)),m(t=>({mode:dn("search")?"search":"global",type:t.key,claim(){t.preventDefault(),t.stopPropagation()}})),g(({mode:t,type:r})=>{if(t==="global"){let o=Ne();if(typeof o!="undefined")return!ja(o,r)}return!0}),le());return Ua().pipe(b(t=>t?y:e))}function we(){return new URL(location.href)}function st(e,t=!1){if(V("navigation.instant")&&!t){let r=x("a",{href:e.href});document.body.appendChild(r),r.click(),r.remove()}else location.href=e.href}function bn(){return new T}function vn(){return location.hash.slice(1)}function gn(e){let t=x("a",{href:e});t.addEventListener("click",r=>r.stopPropagation()),t.click()}function Zr(e){return L(h(window,"hashchange"),e).pipe(m(vn),Q(vn()),g(t=>t.length>0),Z(1))}function yn(e){return Zr(e).pipe(m(t=>ue(`[id="${t}"]`)),g(t=>typeof t!="undefined"))}function Wt(e){let t=matchMedia(e);return ur(r=>t.addListener(()=>r(t.matches))).pipe(Q(t.matches))}function xn(){let e=matchMedia("print");return L(h(window,"beforeprint").pipe(m(()=>!0)),h(window,"afterprint").pipe(m(()=>!1))).pipe(Q(e.matches))}function eo(e,t){return e.pipe(b(r=>r?t():y))}function to(e,t){return new F(r=>{let o=new XMLHttpRequest;return o.open("GET",`${e}`),o.responseType="blob",o.addEventListener("load",()=>{o.status>=200&&o.status<300?(r.next(o.response),r.complete()):r.error(new Error(o.statusText))}),o.addEventListener("error",()=>{r.error(new Error("Network error"))}),o.addEventListener("abort",()=>{r.complete()}),typeof(t==null?void 0:t.progress$)!="undefined"&&(o.addEventListener("progress",n=>{var i;if(n.lengthComputable)t.progress$.next(n.loaded/n.total*100);else{let s=(i=o.getResponseHeader("Content-Length"))!=null?i:0;t.progress$.next(n.loaded/+s*100)}}),t.progress$.next(5)),o.send(),()=>o.abort()})}function ze(e,t){return to(e,t).pipe(b(r=>r.text()),m(r=>JSON.parse(r)),Z(1))}function xr(e,t){let r=new DOMParser;return to(e,t).pipe(b(o=>o.text()),m(o=>r.parseFromString(o,"text/html")),Z(1))}function En(e,t){let r=new DOMParser;return to(e,t).pipe(b(o=>o.text()),m(o=>r.parseFromString(o,"text/xml")),Z(1))}function wn(){return{x:Math.max(0,scrollX),y:Math.max(0,scrollY)}}function Tn(){return L(h(window,"scroll",{passive:!0}),h(window,"resize",{passive:!0})).pipe(m(wn),Q(wn()))}function Sn(){return{width:innerWidth,height:innerHeight}}function On(){return h(window,"resize",{passive:!0}).pipe(m(Sn),Q(Sn()))}function Ln(){return z([Tn(),On()]).pipe(m(([e,t])=>({offset:e,size:t})),Z(1))}function Er(e,{viewport$:t,header$:r}){let o=t.pipe(ne("size")),n=z([o,r]).pipe(m(()=>Be(e)));return z([r,t,n]).pipe(m(([{height:i},{offset:s,size:a},{x:c,y:p}])=>({offset:{x:s.x-c,y:s.y-p+i},size:a})))}function Wa(e){return h(e,"message",t=>t.data)}function Da(e){let t=new T;return t.subscribe(r=>e.postMessage(r)),t}function Mn(e,t=new Worker(e)){let r=Wa(t),o=Da(t),n=new T;n.subscribe(o);let i=o.pipe(oe(),ae(!0));return n.pipe(oe(),Ve(r.pipe(W(i))),le())}var Va=j("#__config"),Ct=JSON.parse(Va.textContent);Ct.base=`${new URL(Ct.base,we())}`;function Te(){return Ct}function V(e){return Ct.features.includes(e)}function Me(e,t){return typeof t!="undefined"?Ct.translations[e].replace("#",t.toString()):Ct.translations[e]}function Ce(e,t=document){return j(`[data-md-component=${e}]`,t)}function me(e,t=document){return M(`[data-md-component=${e}]`,t)}function Na(e){let t=j(".md-typeset > :first-child",e);return h(t,"click",{once:!0}).pipe(m(()=>j(".md-typeset",e)),m(r=>({hash:__md_hash(r.innerHTML)})))}function _n(e){if(!V("announce.dismiss")||!e.childElementCount)return y;if(!e.hidden){let t=j(".md-typeset",e);__md_hash(t.innerHTML)===__md_get("__announce")&&(e.hidden=!0)}return H(()=>{let t=new T;return t.subscribe(({hash:r})=>{e.hidden=!0,__md_set("__announce",r)}),Na(e).pipe(O(r=>t.next(r)),A(()=>t.complete()),m(r=>P({ref:e},r)))})}function za(e,{target$:t}){return t.pipe(m(r=>({hidden:r!==e})))}function An(e,t){let r=new T;return r.subscribe(({hidden:o})=>{e.hidden=o}),za(e,t).pipe(O(o=>r.next(o)),A(()=>r.complete()),m(o=>P({ref:e},o)))}function Dt(e,t){return t==="inline"?x("div",{class:"md-tooltip md-tooltip--inline",id:e,role:"tooltip"},x("div",{class:"md-tooltip__inner md-typeset"})):x("div",{class:"md-tooltip",id:e,role:"tooltip"},x("div",{class:"md-tooltip__inner md-typeset"}))}function wr(...e){return x("div",{class:"md-tooltip2",role:"dialog"},x("div",{class:"md-tooltip2__inner md-typeset"},e))}function Cn(...e){return x("div",{class:"md-tooltip2",role:"tooltip"},x("div",{class:"md-tooltip2__inner md-typeset"},e))}function kn(e,t){if(t=t?`${t}_annotation_${e}`:void 0,t){let r=t?`#${t}`:void 0;return x("aside",{class:"md-annotation",tabIndex:0},Dt(t),x("a",{href:r,class:"md-annotation__index",tabIndex:-1},x("span",{"data-md-annotation-id":e})))}else return x("aside",{class:"md-annotation",tabIndex:0},Dt(t),x("span",{class:"md-annotation__index",tabIndex:-1},x("span",{"data-md-annotation-id":e})))}function Hn(e){return x("button",{class:"md-code__button",title:Me("clipboard.copy"),"data-clipboard-target":`#${e} > code`,"data-md-type":"copy"})}function $n(){return x("button",{class:"md-code__button",title:"Toggle line selection","data-md-type":"select"})}function Pn(){return x("nav",{class:"md-code__nav"})}var In=$t(ro());function oo(e,t){let r=t&2,o=t&1,n=Object.keys(e.terms).filter(c=>!e.terms[c]).reduce((c,p)=>[...c,x("del",null,(0,In.default)(p))," "],[]).slice(0,-1),i=Te(),s=new URL(e.location,i.base);V("search.highlight")&&s.searchParams.set("h",Object.entries(e.terms).filter(([,c])=>c).reduce((c,[p])=>`${c} ${p}`.trim(),""));let{tags:a}=Te();return x("a",{href:`${s}`,class:"md-search-result__link",tabIndex:-1},x("article",{class:"md-search-result__article md-typeset","data-md-score":e.score.toFixed(2)},r>0&&x("div",{class:"md-search-result__icon md-icon"}),r>0&&x("h1",null,e.title),r<=0&&x("h2",null,e.title),o>0&&e.text.length>0&&e.text,e.tags&&x("nav",{class:"md-tags"},e.tags.map(c=>{let p=a?c in a?`md-tag-icon md-tag--${a[c]}`:"md-tag-icon":"";return x("span",{class:`md-tag ${p}`},c)})),o>0&&n.length>0&&x("p",{class:"md-search-result__terms"},Me("search.result.term.missing"),": ",...n)))}function Fn(e){let t=e[0].score,r=[...e],o=Te(),n=r.findIndex(l=>!`${new URL(l.location,o.base)}`.includes("#")),[i]=r.splice(n,1),s=r.findIndex(l=>l.scoreoo(l,1)),...c.length?[x("details",{class:"md-search-result__more"},x("summary",{tabIndex:-1},x("div",null,c.length>0&&c.length===1?Me("search.result.more.one"):Me("search.result.more.other",c.length))),...c.map(l=>oo(l,1)))]:[]];return x("li",{class:"md-search-result__item"},p)}function jn(e){return x("ul",{class:"md-source__facts"},Object.entries(e).map(([t,r])=>x("li",{class:`md-source__fact md-source__fact--${t}`},typeof r=="number"?br(r):r)))}function no(e){let t=`tabbed-control tabbed-control--${e}`;return x("div",{class:t,hidden:!0},x("button",{class:"tabbed-button",tabIndex:-1,"aria-hidden":"true"}))}function Un(e){return x("div",{class:"md-typeset__scrollwrap"},x("div",{class:"md-typeset__table"},e))}function Qa(e){var o;let t=Te(),r=new URL(`../${e.version}/`,t.base);return x("li",{class:"md-version__item"},x("a",{href:`${r}`,class:"md-version__link"},e.title,((o=t.version)==null?void 0:o.alias)&&e.aliases.length>0&&x("span",{class:"md-version__alias"},e.aliases[0])))}function Wn(e,t){var o;let r=Te();return e=e.filter(n=>{var i;return!((i=n.properties)!=null&&i.hidden)}),x("div",{class:"md-version"},x("button",{class:"md-version__current","aria-label":Me("select.version")},t.title,((o=r.version)==null?void 0:o.alias)&&t.aliases.length>0&&x("span",{class:"md-version__alias"},t.aliases[0])),x("ul",{class:"md-version__list"},e.map(Qa)))}var Ya=0;function Ba(e,t=250){let r=z([Ye(e),it(e,t)]).pipe(m(([n,i])=>n||i),Y()),o=H(()=>pn(e)).pipe(J(Ge),gt(1),Pe(r),m(()=>ln(e)));return r.pipe(Re(n=>n),b(()=>z([r,o])),m(([n,i])=>({active:n,offset:i})),le())}function Vt(e,t,r=250){let{content$:o,viewport$:n}=t,i=`__tooltip2_${Ya++}`;return H(()=>{let s=new T,a=new jr(!1);s.pipe(oe(),ae(!1)).subscribe(a);let c=a.pipe(jt(l=>He(+!l*250,Dr)),Y(),b(l=>l?o:y),O(l=>l.id=i),le());z([s.pipe(m(({active:l})=>l)),c.pipe(b(l=>it(l,250)),Q(!1))]).pipe(m(l=>l.some(f=>f))).subscribe(a);let p=a.pipe(g(l=>l),te(c,n),m(([l,f,{size:u}])=>{let d=e.getBoundingClientRect(),v=d.width/2;if(f.role==="tooltip")return{x:v,y:8+d.height};if(d.y>=u.height/2){let{height:S}=de(f);return{x:v,y:-16-S}}else return{x:v,y:16+d.height}}));return z([c,s,p]).subscribe(([l,{offset:f},u])=>{l.style.setProperty("--md-tooltip-host-x",`${f.x}px`),l.style.setProperty("--md-tooltip-host-y",`${f.y}px`),l.style.setProperty("--md-tooltip-x",`${u.x}px`),l.style.setProperty("--md-tooltip-y",`${u.y}px`),l.classList.toggle("md-tooltip2--top",u.y<0),l.classList.toggle("md-tooltip2--bottom",u.y>=0)}),a.pipe(g(l=>l),te(c,(l,f)=>f),g(l=>l.role==="tooltip")).subscribe(l=>{let f=de(j(":scope > *",l));l.style.setProperty("--md-tooltip-width",`${f.width}px`),l.style.setProperty("--md-tooltip-tail","0px")}),a.pipe(Y(),xe(ye),te(c)).subscribe(([l,f])=>{f.classList.toggle("md-tooltip2--active",l)}),z([a.pipe(g(l=>l)),c]).subscribe(([l,f])=>{f.role==="dialog"?(e.setAttribute("aria-controls",i),e.setAttribute("aria-haspopup","dialog")):e.setAttribute("aria-describedby",i)}),a.pipe(g(l=>!l)).subscribe(()=>{e.removeAttribute("aria-controls"),e.removeAttribute("aria-describedby"),e.removeAttribute("aria-haspopup")}),Ba(e,r).pipe(O(l=>s.next(l)),A(()=>s.complete()),m(l=>P({ref:e},l)))})}function Xe(e,{viewport$:t},r=document.body){return Vt(e,{content$:new F(o=>{let n=e.title,i=Cn(n);return o.next(i),e.removeAttribute("title"),r.append(i),()=>{i.remove(),e.setAttribute("title",n)}}),viewport$:t},0)}function Ga(e,t){let r=H(()=>z([mn(e),Ge(t)])).pipe(m(([{x:o,y:n},i])=>{let{width:s,height:a}=de(e);return{x:o-i.x+s/2,y:n-i.y+a/2}}));return Ye(e).pipe(b(o=>r.pipe(m(n=>({active:o,offset:n})),Ee(+!o||1/0))))}function Dn(e,t,{target$:r}){let[o,n]=Array.from(e.children);return H(()=>{let i=new T,s=i.pipe(oe(),ae(!0));return i.subscribe({next({offset:a}){e.style.setProperty("--md-tooltip-x",`${a.x}px`),e.style.setProperty("--md-tooltip-y",`${a.y}px`)},complete(){e.style.removeProperty("--md-tooltip-x"),e.style.removeProperty("--md-tooltip-y")}}),mt(e).pipe(W(s)).subscribe(a=>{e.toggleAttribute("data-md-visible",a)}),L(i.pipe(g(({active:a})=>a)),i.pipe(Ae(250),g(({active:a})=>!a))).subscribe({next({active:a}){a?e.prepend(o):o.remove()},complete(){e.prepend(o)}}),i.pipe($e(16,ye)).subscribe(({active:a})=>{o.classList.toggle("md-tooltip--active",a)}),i.pipe(gt(125,ye),g(()=>!!e.offsetParent),m(()=>e.offsetParent.getBoundingClientRect()),m(({x:a})=>a)).subscribe({next(a){a?e.style.setProperty("--md-tooltip-0",`${-a}px`):e.style.removeProperty("--md-tooltip-0")},complete(){e.style.removeProperty("--md-tooltip-0")}}),h(n,"click").pipe(W(s),g(a=>!(a.metaKey||a.ctrlKey))).subscribe(a=>{a.stopPropagation(),a.preventDefault()}),h(n,"mousedown").pipe(W(s),te(i)).subscribe(([a,{active:c}])=>{var p;if(a.button!==0||a.metaKey||a.ctrlKey)a.preventDefault();else if(c){a.preventDefault();let l=e.parentElement.closest(".md-annotation");l instanceof HTMLElement?l.focus():(p=Ne())==null||p.blur()}}),r.pipe(W(s),g(a=>a===o),nt(125)).subscribe(()=>e.focus()),Ga(e,t).pipe(O(a=>i.next(a)),A(()=>i.complete()),m(a=>P({ref:e},a)))})}function Ja(e){let t=Te();if(e.tagName!=="CODE")return[e];let r=[".c",".c1",".cm"];if(t.annotate&&typeof t.annotate=="object"){let o=e.closest("[class|=language]");if(o)for(let n of Array.from(o.classList)){if(!n.startsWith("language-"))continue;let[,i]=n.split("-");i in t.annotate&&r.push(...t.annotate[i])}}return M(r.join(", "),e)}function Xa(e){let t=[];for(let r of Ja(e)){let o=[],n=document.createNodeIterator(r,NodeFilter.SHOW_TEXT);for(let i=n.nextNode();i;i=n.nextNode())o.push(i);for(let i of o){let s;for(;s=/(\(\d+\))(!)?/.exec(i.textContent);){let[,a,c]=s;if(typeof c=="undefined"){let p=i.splitText(s.index);i=p.splitText(a.length),t.push(p)}else{i.textContent=a,t.push(i);break}}}}return t}function Vn(e,t){t.append(...Array.from(e.childNodes))}function Tr(e,t,{target$:r,print$:o}){let n=t.closest("[id]"),i=n==null?void 0:n.id,s=new Map;for(let a of Xa(t)){let[,c]=a.textContent.match(/\((\d+)\)/);ue(`:scope > li:nth-child(${c})`,e)&&(s.set(c,kn(c,i)),a.replaceWith(s.get(c)))}return s.size===0?y:H(()=>{let a=new T,c=a.pipe(oe(),ae(!0)),p=[];for(let[l,f]of s)p.push([j(".md-typeset",f),j(`:scope > li:nth-child(${l})`,e)]);return o.pipe(W(c)).subscribe(l=>{e.hidden=!l,e.classList.toggle("md-annotation-list",l);for(let[f,u]of p)l?Vn(f,u):Vn(u,f)}),L(...[...s].map(([,l])=>Dn(l,t,{target$:r}))).pipe(A(()=>a.complete()),le())})}function Nn(e){if(e.nextElementSibling){let t=e.nextElementSibling;if(t.tagName==="OL")return t;if(t.tagName==="P"&&!t.children.length)return Nn(t)}}function zn(e,t){return H(()=>{let r=Nn(e);return typeof r!="undefined"?Tr(r,e,t):y})}var Kn=$t(ao());var Za=0,qn=L(h(window,"keydown").pipe(m(()=>!0)),L(h(window,"keyup"),h(window,"contextmenu")).pipe(m(()=>!1))).pipe(Q(!1),Z(1));function Qn(e){if(e.nextElementSibling){let t=e.nextElementSibling;if(t.tagName==="OL")return t;if(t.tagName==="P"&&!t.children.length)return Qn(t)}}function es(e){return Le(e).pipe(m(({width:t})=>({scrollable:At(e).width>t})),ne("scrollable"))}function Yn(e,t){let{matches:r}=matchMedia("(hover)"),o=H(()=>{let n=new T,i=n.pipe(Yr(1));n.subscribe(({scrollable:d})=>{d&&r?e.setAttribute("tabindex","0"):e.removeAttribute("tabindex")});let s=[],a=e.closest("pre"),c=a.closest("[id]"),p=c?c.id:Za++;a.id=`__code_${p}`;let l=[],f=e.closest(".highlight");if(f instanceof HTMLElement){let d=Qn(f);if(typeof d!="undefined"&&(f.classList.contains("annotate")||V("content.code.annotate"))){let v=Tr(d,e,t);l.push(Le(f).pipe(W(i),m(({width:S,height:X})=>S&&X),Y(),b(S=>S?v:y)))}}let u=M(":scope > span[id]",e);if(u.length&&(e.classList.add("md-code__content"),e.closest(".select")||V("content.code.select")&&!e.closest(".no-select"))){let d=+u[0].id.split("-").pop(),v=$n();s.push(v),V("content.tooltips")&&l.push(Xe(v,{viewport$}));let S=h(v,"click").pipe(Ut(R=>!R,!1),O(()=>v.blur()),le());S.subscribe(R=>{v.classList.toggle("md-code__button--active",R)});let X=fe(u).pipe(J(R=>it(R).pipe(m(se=>[R,se]))));S.pipe(b(R=>R?X:y)).subscribe(([R,se])=>{let ce=ue(".hll.select",R);if(ce&&!se)ce.replaceWith(...Array.from(ce.childNodes));else if(!ce&&se){let he=document.createElement("span");he.className="hll select",he.append(...Array.from(R.childNodes).slice(1)),R.append(he)}});let re=fe(u).pipe(J(R=>h(R,"mousedown").pipe(O(se=>se.preventDefault()),m(()=>R)))),ee=S.pipe(b(R=>R?re:y),te(qn),m(([R,se])=>{var he;let ce=u.indexOf(R)+d;if(se===!1)return[ce,ce];{let Se=M(".hll",e).map(Ue=>u.indexOf(Ue.parentElement)+d);return(he=window.getSelection())==null||he.removeAllRanges(),[Math.min(ce,...Se),Math.max(ce,...Se)]}})),k=Zr(y).pipe(g(R=>R.startsWith(`__codelineno-${p}-`)));k.subscribe(R=>{let[,,se]=R.split("-"),ce=se.split(":").map(Se=>+Se-d+1);ce.length===1&&ce.push(ce[0]);for(let Se of M(".hll:not(.select)",e))Se.replaceWith(...Array.from(Se.childNodes));let he=u.slice(ce[0]-1,ce[1]);for(let Se of he){let Ue=document.createElement("span");Ue.className="hll",Ue.append(...Array.from(Se.childNodes).slice(1)),Se.append(Ue)}}),k.pipe(Ee(1),xe(pe)).subscribe(R=>{if(R.includes(":")){let se=document.getElementById(R.split(":")[0]);se&&setTimeout(()=>{let ce=se,he=-64;for(;ce!==document.body;)he+=ce.offsetTop,ce=ce.offsetParent;window.scrollTo({top:he})},1)}});let je=fe(M('a[href^="#__codelineno"]',f)).pipe(J(R=>h(R,"click").pipe(O(se=>se.preventDefault()),m(()=>R)))).pipe(W(i),te(qn),m(([R,se])=>{let he=+j(`[id="${R.hash.slice(1)}"]`).parentElement.id.split("-").pop();if(se===!1)return[he,he];{let Se=M(".hll",e).map(Ue=>+Ue.parentElement.id.split("-").pop());return[Math.min(he,...Se),Math.max(he,...Se)]}}));L(ee,je).subscribe(R=>{let se=`#__codelineno-${p}-`;R[0]===R[1]?se+=R[0]:se+=`${R[0]}:${R[1]}`,history.replaceState({},"",se),window.dispatchEvent(new HashChangeEvent("hashchange",{newURL:window.location.origin+window.location.pathname+se,oldURL:window.location.href}))})}if(Kn.default.isSupported()&&(e.closest(".copy")||V("content.code.copy")&&!e.closest(".no-copy"))){let d=Hn(a.id);s.push(d),V("content.tooltips")&&l.push(Xe(d,{viewport$}))}if(s.length){let d=Pn();d.append(...s),a.insertBefore(d,e)}return es(e).pipe(O(d=>n.next(d)),A(()=>n.complete()),m(d=>P({ref:e},d)),Ve(L(...l).pipe(W(i))))});return V("content.lazy")?mt(e).pipe(g(n=>n),Ee(1),b(()=>o)):o}function ts(e,{target$:t,print$:r}){let o=!0;return L(t.pipe(m(n=>n.closest("details:not([open])")),g(n=>e===n),m(()=>({action:"open",reveal:!0}))),r.pipe(g(n=>n||!o),O(()=>o=e.open),m(n=>({action:n?"open":"close"}))))}function Bn(e,t){return H(()=>{let r=new T;return r.subscribe(({action:o,reveal:n})=>{e.toggleAttribute("open",o==="open"),n&&e.scrollIntoView()}),ts(e,t).pipe(O(o=>r.next(o)),A(()=>r.complete()),m(o=>P({ref:e},o)))})}var Gn=0;function rs(e){let t=document.createElement("h3");t.innerHTML=e.innerHTML;let r=[t],o=e.nextElementSibling;for(;o&&!(o instanceof HTMLHeadingElement);)r.push(o),o=o.nextElementSibling;return r}function os(e,t){for(let r of M("[href], [src]",e))for(let o of["href","src"]){let n=r.getAttribute(o);if(n&&!/^(?:[a-z]+:)?\/\//i.test(n)){r[o]=new URL(r.getAttribute(o),t).toString();break}}for(let r of M("[name^=__], [for]",e))for(let o of["id","for","name"]){let n=r.getAttribute(o);n&&r.setAttribute(o,`${n}$preview_${Gn}`)}return Gn++,$(e)}function Jn(e,t){let{sitemap$:r}=t;if(!(e instanceof HTMLAnchorElement))return y;if(!(V("navigation.instant.preview")||e.hasAttribute("data-preview")))return y;e.removeAttribute("title");let o=z([Ye(e),it(e)]).pipe(m(([i,s])=>i||s),Y(),g(i=>i));return rt([r,o]).pipe(b(([i])=>{let s=new URL(e.href);return s.search=s.hash="",i.has(`${s}`)?$(s):y}),b(i=>xr(i).pipe(b(s=>os(s,i)))),b(i=>{let s=e.hash?`article [id="${e.hash.slice(1)}"]`:"article h1",a=ue(s,i);return typeof a=="undefined"?y:$(rs(a))})).pipe(b(i=>{let s=new F(a=>{let c=wr(...i);return a.next(c),document.body.append(c),()=>c.remove()});return Vt(e,P({content$:s},t))}))}var Xn=".node circle,.node ellipse,.node path,.node polygon,.node rect{fill:var(--md-mermaid-node-bg-color);stroke:var(--md-mermaid-node-fg-color)}marker{fill:var(--md-mermaid-edge-color)!important}.edgeLabel .label rect{fill:#0000}.flowchartTitleText{fill:var(--md-mermaid-label-fg-color)}.label{color:var(--md-mermaid-label-fg-color);font-family:var(--md-mermaid-font-family)}.label foreignObject{line-height:normal;overflow:visible}.label div .edgeLabel{color:var(--md-mermaid-label-fg-color)}.edgeLabel,.edgeLabel p,.label div .edgeLabel{background-color:var(--md-mermaid-label-bg-color)}.edgeLabel,.edgeLabel p{fill:var(--md-mermaid-label-bg-color);color:var(--md-mermaid-edge-color)}.edgePath .path,.flowchart-link{stroke:var(--md-mermaid-edge-color)}.edgePath .arrowheadPath{fill:var(--md-mermaid-edge-color);stroke:none}.cluster rect{fill:var(--md-default-fg-color--lightest);stroke:var(--md-default-fg-color--lighter)}.cluster span{color:var(--md-mermaid-label-fg-color);font-family:var(--md-mermaid-font-family)}g #flowchart-circleEnd,g #flowchart-circleStart,g #flowchart-crossEnd,g #flowchart-crossStart,g #flowchart-pointEnd,g #flowchart-pointStart{stroke:none}.classDiagramTitleText{fill:var(--md-mermaid-label-fg-color)}g.classGroup line,g.classGroup rect{fill:var(--md-mermaid-node-bg-color);stroke:var(--md-mermaid-node-fg-color)}g.classGroup text{fill:var(--md-mermaid-label-fg-color);font-family:var(--md-mermaid-font-family)}.classLabel .box{fill:var(--md-mermaid-label-bg-color);background-color:var(--md-mermaid-label-bg-color);opacity:1}.classLabel .label{fill:var(--md-mermaid-label-fg-color);font-family:var(--md-mermaid-font-family)}.node .divider{stroke:var(--md-mermaid-node-fg-color)}.relation{stroke:var(--md-mermaid-edge-color)}.cardinality{fill:var(--md-mermaid-label-fg-color);font-family:var(--md-mermaid-font-family)}.cardinality text{fill:inherit!important}defs marker.marker.composition.class path,defs marker.marker.dependency.class path,defs marker.marker.extension.class path{fill:var(--md-mermaid-edge-color)!important;stroke:var(--md-mermaid-edge-color)!important}defs marker.marker.aggregation.class path{fill:var(--md-mermaid-label-bg-color)!important;stroke:var(--md-mermaid-edge-color)!important}.statediagramTitleText{fill:var(--md-mermaid-label-fg-color)}g.stateGroup rect{fill:var(--md-mermaid-node-bg-color);stroke:var(--md-mermaid-node-fg-color)}g.stateGroup .state-title{fill:var(--md-mermaid-label-fg-color)!important;font-family:var(--md-mermaid-font-family)}g.stateGroup .composit{fill:var(--md-mermaid-label-bg-color)}.nodeLabel,.nodeLabel p{color:var(--md-mermaid-label-fg-color);font-family:var(--md-mermaid-font-family)}a .nodeLabel{text-decoration:underline}.node circle.state-end,.node circle.state-start,.start-state{fill:var(--md-mermaid-edge-color);stroke:none}.end-state-inner,.end-state-outer{fill:var(--md-mermaid-edge-color)}.end-state-inner,.node circle.state-end{stroke:var(--md-mermaid-label-bg-color)}.transition{stroke:var(--md-mermaid-edge-color)}[id^=state-fork] rect,[id^=state-join] rect{fill:var(--md-mermaid-edge-color)!important;stroke:none!important}.statediagram-cluster.statediagram-cluster .inner{fill:var(--md-default-bg-color)}.statediagram-cluster rect{fill:var(--md-mermaid-node-bg-color);stroke:var(--md-mermaid-node-fg-color)}.statediagram-state rect.divider{fill:var(--md-default-fg-color--lightest);stroke:var(--md-default-fg-color--lighter)}defs #statediagram-barbEnd{stroke:var(--md-mermaid-edge-color)}[id^=entity] path,[id^=entity] rect{fill:var(--md-default-bg-color)}.relationshipLine{stroke:var(--md-mermaid-edge-color)}defs .marker.oneOrMore.er *,defs .marker.onlyOne.er *,defs .marker.zeroOrMore.er *,defs .marker.zeroOrOne.er *{stroke:var(--md-mermaid-edge-color)!important}text:not([class]):last-child{fill:var(--md-mermaid-label-fg-color)}.actor{fill:var(--md-mermaid-sequence-actor-bg-color);stroke:var(--md-mermaid-sequence-actor-border-color)}text.actor>tspan{fill:var(--md-mermaid-sequence-actor-fg-color);font-family:var(--md-mermaid-font-family)}line{stroke:var(--md-mermaid-sequence-actor-line-color)}.actor-man circle,.actor-man line{fill:var(--md-mermaid-sequence-actorman-bg-color);stroke:var(--md-mermaid-sequence-actorman-line-color)}.messageLine0,.messageLine1{stroke:var(--md-mermaid-sequence-message-line-color)}.note{fill:var(--md-mermaid-sequence-note-bg-color);stroke:var(--md-mermaid-sequence-note-border-color)}.loopText,.loopText>tspan,.messageText,.noteText>tspan{stroke:none;font-family:var(--md-mermaid-font-family)!important}.messageText{fill:var(--md-mermaid-sequence-message-fg-color)}.loopText,.loopText>tspan{fill:var(--md-mermaid-sequence-loop-fg-color)}.noteText>tspan{fill:var(--md-mermaid-sequence-note-fg-color)}#arrowhead path{fill:var(--md-mermaid-sequence-message-line-color);stroke:none}.loopLine{fill:var(--md-mermaid-sequence-loop-bg-color);stroke:var(--md-mermaid-sequence-loop-border-color)}.labelBox{fill:var(--md-mermaid-sequence-label-bg-color);stroke:none}.labelText,.labelText>span{fill:var(--md-mermaid-sequence-label-fg-color);font-family:var(--md-mermaid-font-family)}.sequenceNumber{fill:var(--md-mermaid-sequence-number-fg-color)}rect.rect{fill:var(--md-mermaid-sequence-box-bg-color);stroke:none}rect.rect+text.text{fill:var(--md-mermaid-sequence-box-fg-color)}defs #sequencenumber{fill:var(--md-mermaid-sequence-number-bg-color)!important}";var so,is=0;function as(){return typeof mermaid=="undefined"||mermaid instanceof Element?_t("https://unpkg.com/mermaid@11/dist/mermaid.min.js"):$(void 0)}function Zn(e){return e.classList.remove("mermaid"),so||(so=as().pipe(O(()=>mermaid.initialize({startOnLoad:!1,themeCSS:Xn,sequence:{actorFontSize:"16px",messageFontSize:"16px",noteFontSize:"16px"}})),m(()=>{}),Z(1))),so.subscribe(()=>go(null,null,function*(){e.classList.add("mermaid");let t=`__mermaid_${is++}`,r=x("div",{class:"mermaid"}),o=e.textContent,{svg:n,fn:i}=yield mermaid.render(t,o),s=r.attachShadow({mode:"closed"});s.innerHTML=n,e.replaceWith(r),i==null||i(s)})),so.pipe(m(()=>({ref:e})))}var ei=x("table");function ti(e){return e.replaceWith(ei),ei.replaceWith(Un(e)),$({ref:e})}function ss(e){let t=e.find(r=>r.checked)||e[0];return L(...e.map(r=>h(r,"change").pipe(m(()=>j(`label[for="${r.id}"]`))))).pipe(Q(j(`label[for="${t.id}"]`)),m(r=>({active:r})))}function ri(e,{viewport$:t,target$:r}){let o=j(".tabbed-labels",e),n=M(":scope > input",e),i=no("prev");e.append(i);let s=no("next");return e.append(s),H(()=>{let a=new T,c=a.pipe(oe(),ae(!0));z([a,Le(e),mt(e)]).pipe(W(c),$e(1,ye)).subscribe({next([{active:p},l]){let f=Be(p),{width:u}=de(p);e.style.setProperty("--md-indicator-x",`${f.x}px`),e.style.setProperty("--md-indicator-width",`${u}px`);let d=gr(o);(f.xd.x+l.width)&&o.scrollTo({left:Math.max(0,f.x-16),behavior:"smooth"})},complete(){e.style.removeProperty("--md-indicator-x"),e.style.removeProperty("--md-indicator-width")}}),z([Ge(o),Le(o)]).pipe(W(c)).subscribe(([p,l])=>{let f=At(o);i.hidden=p.x<16,s.hidden=p.x>f.width-l.width-16}),L(h(i,"click").pipe(m(()=>-1)),h(s,"click").pipe(m(()=>1))).pipe(W(c)).subscribe(p=>{let{width:l}=de(o);o.scrollBy({left:l*p,behavior:"smooth"})}),r.pipe(W(c),g(p=>n.includes(p))).subscribe(p=>p.click()),o.classList.add("tabbed-labels--linked");for(let p of n){let l=j(`label[for="${p.id}"]`);l.replaceChildren(x("a",{href:`#${l.htmlFor}`,tabIndex:-1},...Array.from(l.childNodes))),h(l.firstElementChild,"click").pipe(W(c),g(f=>!(f.metaKey||f.ctrlKey)),O(f=>{f.preventDefault(),f.stopPropagation()})).subscribe(()=>{history.replaceState({},"",`#${l.htmlFor}`),l.click()})}return V("content.tabs.link")&&a.pipe(Ie(1),te(t)).subscribe(([{active:p},{offset:l}])=>{let f=p.innerText.trim();if(p.hasAttribute("data-md-switching"))p.removeAttribute("data-md-switching");else{let u=e.offsetTop-l.y;for(let v of M("[data-tabs]"))for(let S of M(":scope > input",v)){let X=j(`label[for="${S.id}"]`);if(X!==p&&X.innerText.trim()===f){X.setAttribute("data-md-switching",""),S.click();break}}window.scrollTo({top:e.offsetTop-u});let d=__md_get("__tabs")||[];__md_set("__tabs",[...new Set([f,...d])])}}),a.pipe(W(c)).subscribe(()=>{for(let p of M("audio, video",e))p.offsetWidth&&p.autoplay?p.play().catch(()=>{}):p.pause()}),ss(n).pipe(O(p=>a.next(p)),A(()=>a.complete()),m(p=>P({ref:e},p)))}).pipe(et(pe))}function oi(e,t){let{viewport$:r,target$:o,print$:n}=t;return L(...M(".annotate:not(.highlight)",e).map(i=>zn(i,{target$:o,print$:n})),...M("pre:not(.mermaid) > code",e).map(i=>Yn(i,{target$:o,print$:n})),...M("a",e).map(i=>Jn(i,t)),...M("pre.mermaid",e).map(i=>Zn(i)),...M("table:not([class])",e).map(i=>ti(i)),...M("details",e).map(i=>Bn(i,{target$:o,print$:n})),...M("[data-tabs]",e).map(i=>ri(i,{viewport$:r,target$:o})),...M("[title]:not([data-preview])",e).filter(()=>V("content.tooltips")).map(i=>Xe(i,{viewport$:r})),...M(".footnote-ref",e).filter(()=>V("content.footnote.tooltips")).map(i=>Vt(i,{content$:new F(s=>{let a=new URL(i.href).hash.slice(1),c=Array.from(document.getElementById(a).cloneNode(!0).children),p=wr(...c);return s.next(p),document.body.append(p),()=>p.remove()}),viewport$:r})))}function cs(e,{alert$:t}){return t.pipe(b(r=>L($(!0),$(!1).pipe(nt(2e3))).pipe(m(o=>({message:r,active:o})))))}function ni(e,t){let r=j(".md-typeset",e);return H(()=>{let o=new T;return o.subscribe(({message:n,active:i})=>{e.classList.toggle("md-dialog--active",i),r.textContent=n}),cs(e,t).pipe(O(n=>o.next(n)),A(()=>o.complete()),m(n=>P({ref:e},n)))})}var ps=0;function ls(e,t){document.body.append(e);let{width:r}=de(e);e.style.setProperty("--md-tooltip-width",`${r}px`),e.remove();let o=vr(t),n=typeof o!="undefined"?Ge(o):$({x:0,y:0}),i=L(Ye(t),it(t)).pipe(Y());return z([i,n]).pipe(m(([s,a])=>{let{x:c,y:p}=Be(t),l=de(t),f=t.closest("table");return f&&t.parentElement&&(c+=f.offsetLeft+t.parentElement.offsetLeft,p+=f.offsetTop+t.parentElement.offsetTop),{active:s,offset:{x:c-a.x+l.width/2-r/2,y:p-a.y+l.height+8}}}))}function ii(e){let t=e.title;if(!t.length)return y;let r=`__tooltip_${ps++}`,o=Dt(r,"inline"),n=j(".md-typeset",o);return n.innerHTML=t,H(()=>{let i=new T;return i.subscribe({next({offset:s}){o.style.setProperty("--md-tooltip-x",`${s.x}px`),o.style.setProperty("--md-tooltip-y",`${s.y}px`)},complete(){o.style.removeProperty("--md-tooltip-x"),o.style.removeProperty("--md-tooltip-y")}}),L(i.pipe(g(({active:s})=>s)),i.pipe(Ae(250),g(({active:s})=>!s))).subscribe({next({active:s}){s?(e.insertAdjacentElement("afterend",o),e.setAttribute("aria-describedby",r),e.removeAttribute("title")):(o.remove(),e.removeAttribute("aria-describedby"),e.setAttribute("title",t))},complete(){o.remove(),e.removeAttribute("aria-describedby"),e.setAttribute("title",t)}}),i.pipe($e(16,ye)).subscribe(({active:s})=>{o.classList.toggle("md-tooltip--active",s)}),i.pipe(gt(125,ye),g(()=>!!e.offsetParent),m(()=>e.offsetParent.getBoundingClientRect()),m(({x:s})=>s)).subscribe({next(s){s?o.style.setProperty("--md-tooltip-0",`${-s}px`):o.style.removeProperty("--md-tooltip-0")},complete(){o.style.removeProperty("--md-tooltip-0")}}),ls(o,e).pipe(O(s=>i.next(s)),A(()=>i.complete()),m(s=>P({ref:e},s)))}).pipe(et(pe))}function ms({viewport$:e}){if(!V("header.autohide"))return $(!1);let t=e.pipe(m(({offset:{y:n}})=>n),ot(2,1),m(([n,i])=>[nMath.abs(i-n.y)>100),m(([,[n]])=>n),Y()),o=Je("search");return z([e,o]).pipe(m(([{offset:n},i])=>n.y>400&&!i),Y(),b(n=>n?r:$(!1)),Q(!1))}function ai(e,t){return H(()=>z([Le(e),ms(t)])).pipe(m(([{height:r},o])=>({height:r,hidden:o})),Y((r,o)=>r.height===o.height&&r.hidden===o.hidden),Z(1))}function si(e,{header$:t,main$:r}){return H(()=>{let o=new T,n=o.pipe(oe(),ae(!0));o.pipe(ne("active"),Pe(t)).subscribe(([{active:s},{hidden:a}])=>{e.classList.toggle("md-header--shadow",s&&!a),e.hidden=a});let i=fe(M("[title]",e)).pipe(g(()=>V("content.tooltips")),J(s=>ii(s)));return r.subscribe(o),t.pipe(W(n),m(s=>P({ref:e},s)),Ve(i.pipe(W(n))))})}function fs(e,{viewport$:t,header$:r}){return Er(e,{viewport$:t,header$:r}).pipe(m(({offset:{y:o}})=>{let{height:n}=de(e);return{active:n>0&&o>=n}}),ne("active"))}function ci(e,t){return H(()=>{let r=new T;r.subscribe({next({active:n}){e.classList.toggle("md-header__title--active",n)},complete(){e.classList.remove("md-header__title--active")}});let o=ue(".md-content h1");return typeof o=="undefined"?y:fs(o,t).pipe(O(n=>r.next(n)),A(()=>r.complete()),m(n=>P({ref:e},n)))})}function pi(e,{viewport$:t,header$:r}){let o=r.pipe(m(({height:i})=>i),Y()),n=o.pipe(b(()=>Le(e).pipe(m(({height:i})=>({top:e.offsetTop,bottom:e.offsetTop+i})),ne("bottom"))));return z([o,n,t]).pipe(m(([i,{top:s,bottom:a},{offset:{y:c},size:{height:p}}])=>(p=Math.max(0,p-Math.max(0,s-c,i)-Math.max(0,p+c-a)),{offset:s-i,height:p,active:s-i<=c})),Y((i,s)=>i.offset===s.offset&&i.height===s.height&&i.active===s.active))}function us(e){let t=__md_get("__palette")||{index:e.findIndex(o=>matchMedia(o.getAttribute("data-md-color-media")).matches)},r=Math.max(0,Math.min(t.index,e.length-1));return $(...e).pipe(J(o=>h(o,"change").pipe(m(()=>o))),Q(e[r]),m(o=>({index:e.indexOf(o),color:{media:o.getAttribute("data-md-color-media"),scheme:o.getAttribute("data-md-color-scheme"),primary:o.getAttribute("data-md-color-primary"),accent:o.getAttribute("data-md-color-accent")}})),Z(1))}function li(e){let t=M("input",e),r=x("meta",{name:"theme-color"});document.head.appendChild(r);let o=x("meta",{name:"color-scheme"});document.head.appendChild(o);let n=Wt("(prefers-color-scheme: light)");return H(()=>{let i=new T;return i.subscribe(s=>{if(document.body.setAttribute("data-md-color-switching",""),s.color.media==="(prefers-color-scheme)"){let a=matchMedia("(prefers-color-scheme: light)"),c=document.querySelector(a.matches?"[data-md-color-media='(prefers-color-scheme: light)']":"[data-md-color-media='(prefers-color-scheme: dark)']");s.color.scheme=c.getAttribute("data-md-color-scheme"),s.color.primary=c.getAttribute("data-md-color-primary"),s.color.accent=c.getAttribute("data-md-color-accent")}for(let[a,c]of Object.entries(s.color))document.body.setAttribute(`data-md-color-${a}`,c);for(let a=0;as.key==="Enter"),te(i,(s,a)=>a)).subscribe(({index:s})=>{s=(s+1)%t.length,t[s].click(),t[s].focus()}),i.pipe(m(()=>{let s=Ce("header"),a=window.getComputedStyle(s);return o.content=a.colorScheme,a.backgroundColor.match(/\d+/g).map(c=>(+c).toString(16).padStart(2,"0")).join("")})).subscribe(s=>r.content=`#${s}`),i.pipe(xe(pe)).subscribe(()=>{document.body.removeAttribute("data-md-color-switching")}),us(t).pipe(W(n.pipe(Ie(1))),vt(),O(s=>i.next(s)),A(()=>i.complete()),m(s=>P({ref:e},s)))})}function mi(e,{progress$:t}){return H(()=>{let r=new T;return r.subscribe(({value:o})=>{e.style.setProperty("--md-progress-value",`${o}`)}),t.pipe(O(o=>r.next({value:o})),A(()=>r.complete()),m(o=>({ref:e,value:o})))})}function fi(e,t){return e.protocol=t.protocol,e.hostname=t.hostname,e}function ds(e,t){let r=new Map;for(let o of M("url",e)){let n=j("loc",o),i=[fi(new URL(n.textContent),t)];r.set(`${i[0]}`,i);for(let s of M("[rel=alternate]",o)){let a=s.getAttribute("href");a!=null&&i.push(fi(new URL(a),t))}}return r}function kt(e){return En(new URL("sitemap.xml",e)).pipe(m(t=>ds(t,new URL(e))),ve(()=>$(new Map)),le())}function ui({document$:e}){let t=new Map;e.pipe(b(()=>M("link[rel=alternate]")),m(r=>new URL(r.href)),g(r=>!t.has(r.toString())),J(r=>kt(r).pipe(m(o=>[r,o]),ve(()=>y)))).subscribe(([r,o])=>{t.set(r.toString().replace(/\/$/,""),o)}),h(document.body,"click").pipe(g(r=>!r.metaKey&&!r.ctrlKey),b(r=>{if(r.target instanceof Element){let o=r.target.closest("a");if(o&&!o.target){let n=[...t].find(([f])=>o.href.startsWith(`${f}/`));if(typeof n=="undefined")return y;let[i,s]=n,a=we();if(a.href.startsWith(i))return y;let c=Te(),p=a.href.replace(c.base,"");p=`${i}/${p}`;let l=s.has(p.split("#")[0])?new URL(p,c.base):new URL(i);return r.preventDefault(),$(l)}}return y})).subscribe(r=>st(r,!0))}var co=$t(ao());function hs(e){e.setAttribute("data-md-copying","");let t=e.closest("[data-copy]"),r=t?t.getAttribute("data-copy"):e.innerText;return e.removeAttribute("data-md-copying"),r.trimEnd()}function di({alert$:e}){co.default.isSupported()&&new F(t=>{new co.default("[data-clipboard-target], [data-clipboard-text]",{text:r=>r.getAttribute("data-clipboard-text")||hs(j(r.getAttribute("data-clipboard-target")))}).on("success",r=>t.next(r))}).pipe(O(t=>{t.trigger.focus()}),m(()=>Me("clipboard.copied"))).subscribe(e)}function hi(e,t){if(!(e.target instanceof Element))return y;let r=e.target.closest("a");if(r===null)return y;if(r.target||e.metaKey||e.ctrlKey)return y;let o=new URL(r.href);return o.search=o.hash="",t.has(`${o}`)?(e.preventDefault(),$(r)):y}function bi(e){let t=new Map;for(let r of M(":scope > *",e.head))t.set(r.outerHTML,r);return t}function vi(e){for(let t of M("[href], [src]",e))for(let r of["href","src"]){let o=t.getAttribute(r);if(o&&!/^(?:[a-z]+:)?\/\//i.test(o)){t[r]=t[r];break}}return $(e)}function bs(e){for(let o of["[data-md-component=announce]","[data-md-component=container]","[data-md-component=header-topic]","[data-md-component=outdated]","[data-md-component=logo]","[data-md-component=skip]",...V("navigation.tabs.sticky")?["[data-md-component=tabs]"]:[]]){let n=ue(o),i=ue(o,e);typeof n!="undefined"&&typeof i!="undefined"&&n.replaceWith(i)}let t=bi(document);for(let[o,n]of bi(e))t.has(o)?t.delete(o):document.head.appendChild(n);for(let o of t.values()){let n=o.getAttribute("name");n!=="theme-color"&&n!=="color-scheme"&&o.remove()}let r=Ce("container");return Ke(M("script",r)).pipe(b(o=>{let n=e.createElement("script");if(o.src){for(let i of o.getAttributeNames())n.setAttribute(i,o.getAttribute(i));return o.replaceWith(n),new F(i=>{n.onload=()=>i.complete()})}else return n.textContent=o.textContent,o.replaceWith(n),y}),oe(),ae(document))}function gi({sitemap$:e,location$:t,viewport$:r,progress$:o}){if(location.protocol==="file:")return y;$(document).subscribe(vi);let n=h(document.body,"click").pipe(Pe(e),b(([a,c])=>hi(a,c)),m(({href:a})=>new URL(a)),le()),i=h(window,"popstate").pipe(m(we),le());n.pipe(te(r)).subscribe(([a,{offset:c}])=>{history.replaceState(c,""),history.pushState(null,"",a)}),L(n,i).subscribe(t);let s=t.pipe(ne("pathname"),b(a=>xr(a,{progress$:o}).pipe(ve(()=>(st(a,!0),y)))),b(vi),b(bs),le());return L(s.pipe(te(t,(a,c)=>c)),s.pipe(b(()=>t),ne("hash")),t.pipe(Y((a,c)=>a.pathname===c.pathname&&a.hash===c.hash),b(()=>n),O(()=>history.back()))).subscribe(a=>{var c,p;history.state!==null||!a.hash?window.scrollTo(0,(p=(c=history.state)==null?void 0:c.y)!=null?p:0):(history.scrollRestoration="auto",gn(a.hash),history.scrollRestoration="manual")}),t.subscribe(()=>{history.scrollRestoration="manual"}),h(window,"beforeunload").subscribe(()=>{history.scrollRestoration="auto"}),r.pipe(ne("offset"),Ae(100)).subscribe(({offset:a})=>{history.replaceState(a,"")}),V("navigation.instant.prefetch")&&L(h(document.body,"mousemove"),h(document.body,"focusin")).pipe(Pe(e),b(([a,c])=>hi(a,c)),Ae(25),Qr(({href:a})=>a),hr(a=>{let c=document.createElement("link");return c.rel="prefetch",c.href=a.toString(),document.head.appendChild(c),h(c,"load").pipe(m(()=>c),Ee(1))})).subscribe(a=>a.remove()),s}var yi=$t(ro());function xi(e){let t=e.separator.split("|").map(n=>n.replace(/(\(\?[!=<][^)]+\))/g,"").length===0?"\uFFFD":n).join("|"),r=new RegExp(t,"img"),o=(n,i,s)=>`${i}${s}`;return n=>{n=n.replace(/[\s*+\-:~^]+/g," ").replace(/&/g,"&").trim();let i=new RegExp(`(^|${e.separator}|)(${n.replace(/[|\\{}()[\]^$+*?.-]/g,"\\$&").replace(r,"|")})`,"img");return s=>(0,yi.default)(s).replace(i,o).replace(/<\/mark>(\s+)]*>/img,"$1")}}function zt(e){return e.type===1}function Sr(e){return e.type===3}function Ei(e,t){let r=Mn(e);return L($(location.protocol!=="file:"),Je("search")).pipe(Re(o=>o),b(()=>t)).subscribe(({config:o,docs:n})=>r.next({type:0,data:{config:o,docs:n,options:{suggest:V("search.suggest")}}})),r}function wi(e){var l;let{selectedVersionSitemap:t,selectedVersionBaseURL:r,currentLocation:o,currentBaseURL:n}=e,i=(l=po(n))==null?void 0:l.pathname;if(i===void 0)return;let s=ys(o.pathname,i);if(s===void 0)return;let a=Es(t.keys());if(!t.has(a))return;let c=po(s,a);if(!c||!t.has(c.href))return;let p=po(s,r);if(p)return p.hash=o.hash,p.search=o.search,p}function po(e,t){try{return new URL(e,t)}catch(r){return}}function ys(e,t){if(e.startsWith(t))return e.slice(t.length)}function xs(e,t){let r=Math.min(e.length,t.length),o;for(o=0;oy)),o=r.pipe(m(n=>{let[,i]=t.base.match(/([^/]+)\/?$/);return n.find(({version:s,aliases:a})=>s===i||a.includes(i))||n[0]}));r.pipe(m(n=>new Map(n.map(i=>[`${new URL(`../${i.version}/`,t.base)}`,i]))),b(n=>h(document.body,"click").pipe(g(i=>!i.metaKey&&!i.ctrlKey),te(o),b(([i,s])=>{if(i.target instanceof Element){let a=i.target.closest("a");if(a&&!a.target&&n.has(a.href)){let c=a.href;return!i.target.closest(".md-version")&&n.get(c)===s?y:(i.preventDefault(),$(new URL(c)))}}return y}),b(i=>kt(i).pipe(m(s=>{var a;return(a=wi({selectedVersionSitemap:s,selectedVersionBaseURL:i,currentLocation:we(),currentBaseURL:t.base}))!=null?a:i})))))).subscribe(n=>st(n,!0)),z([r,o]).subscribe(([n,i])=>{j(".md-header__topic").appendChild(Wn(n,i))}),e.pipe(b(()=>o)).subscribe(n=>{var a;let i=new URL(t.base),s=__md_get("__outdated",sessionStorage,i);if(s===null){s=!0;let c=((a=t.version)==null?void 0:a.default)||"latest";Array.isArray(c)||(c=[c]);e:for(let p of c)for(let l of n.aliases.concat(n.version))if(new RegExp(p,"i").test(l)){s=!1;break e}__md_set("__outdated",s,sessionStorage,i)}if(s)for(let c of me("outdated"))c.hidden=!1})}function ws(e,{worker$:t}){let{searchParams:r}=we();r.has("q")&&(at("search",!0),e.value=r.get("q"),e.focus(),Je("search").pipe(Re(i=>!i)).subscribe(()=>{let i=we();i.searchParams.delete("q"),history.replaceState({},"",`${i}`)}));let o=Ye(e),n=L(t.pipe(Re(zt)),h(e,"keyup"),o).pipe(m(()=>e.value),Y());return z([n,o]).pipe(m(([i,s])=>({value:i,focus:s})),Z(1))}function Si(e,{worker$:t}){let r=new T,o=r.pipe(oe(),ae(!0));z([t.pipe(Re(zt)),r],(i,s)=>s).pipe(ne("value")).subscribe(({value:i})=>t.next({type:2,data:i})),r.pipe(ne("focus")).subscribe(({focus:i})=>{i&&at("search",i)}),h(e.form,"reset").pipe(W(o)).subscribe(()=>e.focus());let n=j("header [for=__search]");return h(n,"click").subscribe(()=>e.focus()),ws(e,{worker$:t}).pipe(O(i=>r.next(i)),A(()=>r.complete()),m(i=>P({ref:e},i)),Z(1))}function Oi(e,{worker$:t,query$:r}){let o=new T,n=un(e.parentElement).pipe(g(Boolean)),i=e.parentElement,s=j(":scope > :first-child",e),a=j(":scope > :last-child",e);Je("search").subscribe(l=>{a.setAttribute("role",l?"list":"presentation"),a.hidden=!l}),o.pipe(te(r),Gr(t.pipe(Re(zt)))).subscribe(([{items:l},{value:f}])=>{switch(l.length){case 0:s.textContent=f.length?Me("search.result.none"):Me("search.result.placeholder");break;case 1:s.textContent=Me("search.result.one");break;default:let u=br(l.length);s.textContent=Me("search.result.other",u)}});let c=o.pipe(O(()=>a.innerHTML=""),b(({items:l})=>L($(...l.slice(0,10)),$(...l.slice(10)).pipe(ot(4),Xr(n),b(([f])=>f)))),m(Fn),le());return c.subscribe(l=>a.appendChild(l)),c.pipe(J(l=>{let f=ue("details",l);return typeof f=="undefined"?y:h(f,"toggle").pipe(W(o),m(()=>f))})).subscribe(l=>{l.open===!1&&l.offsetTop<=i.scrollTop&&i.scrollTo({top:l.offsetTop})}),t.pipe(g(Sr),m(({data:l})=>l)).pipe(O(l=>o.next(l)),A(()=>o.complete()),m(l=>P({ref:e},l)))}function Ts(e,{query$:t}){return t.pipe(m(({value:r})=>{let o=we();return o.hash="",r=r.replace(/\s+/g,"+").replace(/&/g,"%26").replace(/=/g,"%3D"),o.search=`q=${r}`,{url:o}}))}function Li(e,t){let r=new T,o=r.pipe(oe(),ae(!0));return r.subscribe(({url:n})=>{e.setAttribute("data-clipboard-text",e.href),e.href=`${n}`}),h(e,"click").pipe(W(o)).subscribe(n=>n.preventDefault()),Ts(e,t).pipe(O(n=>r.next(n)),A(()=>r.complete()),m(n=>P({ref:e},n)))}function Mi(e,{worker$:t,keyboard$:r}){let o=new T,n=Ce("search-query"),i=L(h(n,"keydown"),h(n,"focus")).pipe(xe(pe),m(()=>n.value),Y());return o.pipe(Pe(i),m(([{suggest:a},c])=>{let p=c.split(/([\s-]+)/);if(a!=null&&a.length&&p[p.length-1]){let l=a[a.length-1];l.startsWith(p[p.length-1])&&(p[p.length-1]=l)}else p.length=0;return p})).subscribe(a=>e.innerHTML=a.join("").replace(/\s/g," ")),r.pipe(g(({mode:a})=>a==="search")).subscribe(a=>{switch(a.type){case"ArrowRight":e.innerText.length&&n.selectionStart===n.value.length&&(n.value=e.innerText);break}}),t.pipe(g(Sr),m(({data:a})=>a)).pipe(O(a=>o.next(a)),A(()=>o.complete()),m(()=>({ref:e})))}function _i(e,{index$:t,keyboard$:r}){let o=Te();try{let n=Ei(o.search,t),i=Ce("search-query",e),s=Ce("search-result",e);h(e,"click").pipe(g(({target:c})=>c instanceof Element&&!!c.closest("a"))).subscribe(()=>at("search",!1)),r.pipe(g(({mode:c})=>c==="search")).subscribe(c=>{let p=Ne();switch(c.type){case"Enter":if(p===i){let l=new Map;for(let f of M(":first-child [href]",s)){let u=f.firstElementChild;l.set(f,parseFloat(u.getAttribute("data-md-score")))}if(l.size){let[[f]]=[...l].sort(([,u],[,d])=>d-u);f.click()}c.claim()}break;case"Escape":case"Tab":at("search",!1),i.blur();break;case"ArrowUp":case"ArrowDown":if(typeof p=="undefined")i.focus();else{let l=[i,...M(":not(details) > [href], summary, details[open] [href]",s)],f=Math.max(0,(Math.max(0,l.indexOf(p))+l.length+(c.type==="ArrowUp"?-1:1))%l.length);l[f].focus()}c.claim();break;default:i!==Ne()&&i.focus()}}),r.pipe(g(({mode:c})=>c==="global")).subscribe(c=>{switch(c.type){case"f":case"s":case"/":i.focus(),i.select(),c.claim();break}});let a=Si(i,{worker$:n});return L(a,Oi(s,{worker$:n,query$:a})).pipe(Ve(...me("search-share",e).map(c=>Li(c,{query$:a})),...me("search-suggest",e).map(c=>Mi(c,{worker$:n,keyboard$:r}))))}catch(n){return e.hidden=!0,tt}}function Ai(e,{index$:t,location$:r}){return z([t,r.pipe(Q(we()),g(o=>!!o.searchParams.get("h")))]).pipe(m(([o,n])=>xi(o.config)(n.searchParams.get("h"))),m(o=>{var s;let n=new Map,i=document.createNodeIterator(e,NodeFilter.SHOW_TEXT);for(let a=i.nextNode();a;a=i.nextNode())if((s=a.parentElement)!=null&&s.offsetHeight){let c=a.textContent,p=o(c);p.length>c.length&&n.set(a,p)}for(let[a,c]of n){let{childNodes:p}=x("span",null,c);a.replaceWith(...Array.from(p))}return{ref:e,nodes:n}}))}function Ss(e,{viewport$:t,main$:r}){let o=e.closest(".md-grid"),n=o.offsetTop-o.parentElement.offsetTop;return z([r,t]).pipe(m(([{offset:i,height:s},{offset:{y:a}}])=>(s=s+Math.min(n,Math.max(0,a-i))-n,{height:s,locked:a>=i+n})),Y((i,s)=>i.height===s.height&&i.locked===s.locked))}function lo(e,o){var n=o,{header$:t}=n,r=vo(n,["header$"]);let i=j(".md-sidebar__scrollwrap",e),{y:s}=Be(i);return H(()=>{let a=new T,c=a.pipe(oe(),ae(!0)),p=a.pipe($e(0,ye));return p.pipe(te(t)).subscribe({next([{height:l},{height:f}]){i.style.height=`${l-2*s}px`,e.style.top=`${f}px`},complete(){i.style.height="",e.style.top=""}}),p.pipe(Re()).subscribe(()=>{for(let l of M(".md-nav__link--active[href]",e)){if(!l.clientHeight)continue;let f=l.closest(".md-sidebar__scrollwrap");if(typeof f!="undefined"){let u=l.offsetTop-f.offsetTop,{height:d}=de(f);f.scrollTo({top:u-d/2})}}}),fe(M("label[tabindex]",e)).pipe(J(l=>h(l,"click").pipe(xe(pe),m(()=>l),W(c)))).subscribe(l=>{let f=j(`[id="${l.htmlFor}"]`);j(`[aria-labelledby="${l.id}"]`).setAttribute("aria-expanded",`${f.checked}`)}),V("content.tooltips")&&fe(M("abbr[title]",e)).pipe(J(l=>Xe(l,{viewport$})),W(c)).subscribe(),Ss(e,r).pipe(O(l=>a.next(l)),A(()=>a.complete()),m(l=>P({ref:e},l)))})}function Ci(e,t){if(typeof t!="undefined"){let r=`https://api.github.com/repos/${e}/${t}`;return rt(ze(`${r}/releases/latest`).pipe(ve(()=>y),m(o=>({version:o.tag_name})),Qe({})),ze(r).pipe(ve(()=>y),m(o=>({stars:o.stargazers_count,forks:o.forks_count})),Qe({}))).pipe(m(([o,n])=>P(P({},o),n)))}else{let r=`https://api.github.com/users/${e}`;return ze(r).pipe(m(o=>({repositories:o.public_repos})),Qe({}))}}function ki(e,t){let r=`https://${e}/api/v4/projects/${encodeURIComponent(t)}`;return rt(ze(`${r}/releases/permalink/latest`).pipe(ve(()=>y),m(({tag_name:o})=>({version:o})),Qe({})),ze(r).pipe(ve(()=>y),m(({star_count:o,forks_count:n})=>({stars:o,forks:n})),Qe({}))).pipe(m(([o,n])=>P(P({},o),n)))}function Hi(e){let t=e.match(/^.+github\.com\/([^/]+)\/?([^/]+)?/i);if(t){let[,r,o]=t;return Ci(r,o)}if(t=e.match(/^.+?([^/]*gitlab[^/]+)\/(.+?)\/?$/i),t){let[,r,o]=t;return ki(r,o)}return y}var Os;function Ls(e){return Os||(Os=H(()=>{let t=__md_get("__source",sessionStorage);if(t)return $(t);if(me("consent").length){let o=__md_get("__consent");if(!(o&&o.github))return y}return Hi(e.href).pipe(O(o=>__md_set("__source",o,sessionStorage)))}).pipe(ve(()=>y),g(t=>Object.keys(t).length>0),m(t=>({facts:t})),Z(1)))}function $i(e){let t=j(":scope > :last-child",e);return H(()=>{let r=new T;return r.subscribe(({facts:o})=>{t.appendChild(jn(o)),t.classList.add("md-source__repository--active")}),Ls(e).pipe(O(o=>r.next(o)),A(()=>r.complete()),m(o=>P({ref:e},o)))})}function Ms(e,{viewport$:t,header$:r}){return Le(document.body).pipe(b(()=>Er(e,{header$:r,viewport$:t})),m(({offset:{y:o}})=>({hidden:o>=10})),ne("hidden"))}function Pi(e,t){return H(()=>{let r=new T;return r.subscribe({next({hidden:o}){e.hidden=o},complete(){e.hidden=!1}}),(V("navigation.tabs.sticky")?$({hidden:!1}):Ms(e,t)).pipe(O(o=>r.next(o)),A(()=>r.complete()),m(o=>P({ref:e},o)))})}function _s(e,{viewport$:t,header$:r}){let o=new Map,n=M(".md-nav__link",e);for(let a of n){let c=decodeURIComponent(a.hash.substring(1)),p=ue(`[id="${c}"]`);typeof p!="undefined"&&o.set(a,p)}let i=r.pipe(ne("height"),m(({height:a})=>{let c=Ce("main"),p=j(":scope > :first-child",c);return a+.8*(p.offsetTop-c.offsetTop)}),le());return Le(document.body).pipe(ne("height"),b(a=>H(()=>{let c=[];return $([...o].reduce((p,[l,f])=>{for(;c.length&&o.get(c[c.length-1]).tagName>=f.tagName;)c.pop();let u=f.offsetTop;for(;!u&&f.parentElement;)f=f.parentElement,u=f.offsetTop;let d=f.offsetParent;for(;d;d=d.offsetParent)u+=d.offsetTop;return p.set([...c=[...c,l]].reverse(),u)},new Map))}).pipe(m(c=>new Map([...c].sort(([,p],[,l])=>p-l))),Pe(i),b(([c,p])=>t.pipe(Ut(([l,f],{offset:{y:u},size:d})=>{let v=u+d.height>=Math.floor(a.height);for(;f.length;){let[,S]=f[0];if(S-p=u&&!v)f=[l.pop(),...f];else break}return[l,f]},[[],[...c]]),Y((l,f)=>l[0]===f[0]&&l[1]===f[1])))))).pipe(m(([a,c])=>({prev:a.map(([p])=>p),next:c.map(([p])=>p)})),Q({prev:[],next:[]}),ot(2,1),m(([a,c])=>a.prev.length{let i=new T,s=i.pipe(oe(),ae(!0));if(i.subscribe(({prev:a,next:c})=>{for(let[p]of c)p.classList.remove("md-nav__link--passed"),p.classList.remove("md-nav__link--active");for(let[p,[l]]of a.entries())l.classList.add("md-nav__link--passed"),l.classList.toggle("md-nav__link--active",p===a.length-1)}),V("toc.follow")){let a=L(t.pipe(Ae(1),m(()=>{})),t.pipe(Ae(250),m(()=>"smooth")));i.pipe(g(({prev:c})=>c.length>0),Pe(o.pipe(xe(pe))),te(a)).subscribe(([[{prev:c}],p])=>{let[l]=c[c.length-1];if(l.offsetHeight){let f=vr(l);if(typeof f!="undefined"){let u=l.offsetTop-f.offsetTop,{height:d}=de(f);f.scrollTo({top:u-d/2,behavior:p})}}})}return V("navigation.tracking")&&t.pipe(W(s),ne("offset"),Ae(250),Ie(1),W(n.pipe(Ie(1))),vt({delay:250}),te(i)).subscribe(([,{prev:a}])=>{let c=we(),p=a[a.length-1];if(p&&p.length){let[l]=p,{hash:f}=new URL(l.href);c.hash!==f&&(c.hash=f,history.replaceState({},"",`${c}`))}else c.hash="",history.replaceState({},"",`${c}`)}),_s(e,{viewport$:t,header$:r}).pipe(O(a=>i.next(a)),A(()=>i.complete()),m(a=>P({ref:e},a)))})}function As(e,{viewport$:t,main$:r,target$:o}){let n=t.pipe(m(({offset:{y:s}})=>s),ot(2,1),m(([s,a])=>s>a&&a>0),Y()),i=r.pipe(m(({active:s})=>s));return z([i,n]).pipe(m(([s,a])=>!(s&&a)),Y(),W(o.pipe(Ie(1))),ae(!0),vt({delay:250}),m(s=>({hidden:s})))}function Ii(e,{viewport$:t,header$:r,main$:o,target$:n}){let i=new T,s=i.pipe(oe(),ae(!0));return i.subscribe({next({hidden:a}){e.hidden=a,a?(e.setAttribute("tabindex","-1"),e.blur()):e.removeAttribute("tabindex")},complete(){e.style.top="",e.hidden=!0,e.removeAttribute("tabindex")}}),r.pipe(W(s),ne("height")).subscribe(({height:a})=>{e.style.top=`${a+16}px`}),h(e,"click").subscribe(a=>{a.preventDefault(),window.scrollTo({top:0})}),As(e,{viewport$:t,main$:o,target$:n}).pipe(O(a=>i.next(a)),A(()=>i.complete()),m(a=>P({ref:e},a)))}function Fi({document$:e,viewport$:t}){e.pipe(b(()=>M(".md-ellipsis")),J(r=>mt(r).pipe(W(e.pipe(Ie(1))),g(o=>o),m(()=>r),Ee(1))),g(r=>r.offsetWidth{let o=r.innerText,n=r.closest("a")||r;return n.title=o,V("content.tooltips")?Xe(n,{viewport$:t}).pipe(W(e.pipe(Ie(1))),A(()=>n.removeAttribute("title"))):y})).subscribe(),V("content.tooltips")&&e.pipe(b(()=>M(".md-status")),J(r=>Xe(r,{viewport$:t}))).subscribe()}function ji({document$:e,tablet$:t}){e.pipe(b(()=>M(".md-toggle--indeterminate")),O(r=>{r.indeterminate=!0,r.checked=!1}),J(r=>h(r,"change").pipe(Jr(()=>r.classList.contains("md-toggle--indeterminate")),m(()=>r))),te(t)).subscribe(([r,o])=>{r.classList.remove("md-toggle--indeterminate"),o&&(r.checked=!1)})}function Cs(){return/(iPad|iPhone|iPod)/.test(navigator.userAgent)}function Ui({document$:e}){e.pipe(b(()=>M("[data-md-scrollfix]")),O(t=>t.removeAttribute("data-md-scrollfix")),g(Cs),J(t=>h(t,"touchstart").pipe(m(()=>t)))).subscribe(t=>{let r=t.scrollTop;r===0?t.scrollTop=1:r+t.offsetHeight===t.scrollHeight&&(t.scrollTop=r-1)})}function Wi({viewport$:e,tablet$:t}){z([Je("search"),t]).pipe(m(([r,o])=>r&&!o),b(r=>$(r).pipe(nt(r?400:100))),te(e)).subscribe(([r,{offset:{y:o}}])=>{if(r)document.body.setAttribute("data-md-scrolllock",""),document.body.style.top=`-${o}px`;else{let n=-1*parseInt(document.body.style.top,10);document.body.removeAttribute("data-md-scrolllock"),document.body.style.top="",n&&window.scrollTo(0,n)}})}Object.entries||(Object.entries=function(e){let t=[];for(let r of Object.keys(e))t.push([r,e[r]]);return t});Object.values||(Object.values=function(e){let t=[];for(let r of Object.keys(e))t.push(e[r]);return t});typeof Element!="undefined"&&(Element.prototype.scrollTo||(Element.prototype.scrollTo=function(e,t){typeof e=="object"?(this.scrollLeft=e.left,this.scrollTop=e.top):(this.scrollLeft=e,this.scrollTop=t)}),Element.prototype.replaceWith||(Element.prototype.replaceWith=function(...e){let t=this.parentNode;if(t){e.length===0&&t.removeChild(this);for(let r=e.length-1;r>=0;r--){let o=e[r];typeof o=="string"?o=document.createTextNode(o):o.parentNode&&o.parentNode.removeChild(o),r?t.insertBefore(this.previousSibling,o):t.replaceChild(o,this)}}}));function ks(){return location.protocol==="file:"?_t(`${new URL("search/search_index.js",Or.base)}`).pipe(m(()=>__index),Z(1)):ze(new URL("search/search_index.json",Or.base))}document.documentElement.classList.remove("no-js");document.documentElement.classList.add("js");var ct=an(),Kt=bn(),Ht=yn(Kt),mo=hn(),ke=Ln(),Lr=Wt("(min-width: 60em)"),Vi=Wt("(min-width: 76.25em)"),Ni=xn(),Or=Te(),zi=document.forms.namedItem("search")?ks():tt,fo=new T;di({alert$:fo});ui({document$:ct});var uo=new T,qi=kt(Or.base);V("navigation.instant")&&gi({sitemap$:qi,location$:Kt,viewport$:ke,progress$:uo}).subscribe(ct);var Di;((Di=Or.version)==null?void 0:Di.provider)==="mike"&&Ti({document$:ct});L(Kt,Ht).pipe(nt(125)).subscribe(()=>{at("drawer",!1),at("search",!1)});mo.pipe(g(({mode:e})=>e==="global")).subscribe(e=>{switch(e.type){case"p":case",":let t=ue("link[rel=prev]");typeof t!="undefined"&&st(t);break;case"n":case".":let r=ue("link[rel=next]");typeof r!="undefined"&&st(r);break;case"Enter":let o=Ne();o instanceof HTMLLabelElement&&o.click()}});Fi({viewport$:ke,document$:ct});ji({document$:ct,tablet$:Lr});Ui({document$:ct});Wi({viewport$:ke,tablet$:Lr});var ft=ai(Ce("header"),{viewport$:ke}),qt=ct.pipe(m(()=>Ce("main")),b(e=>pi(e,{viewport$:ke,header$:ft})),Z(1)),Hs=L(...me("consent").map(e=>An(e,{target$:Ht})),...me("dialog").map(e=>ni(e,{alert$:fo})),...me("palette").map(e=>li(e)),...me("progress").map(e=>mi(e,{progress$:uo})),...me("search").map(e=>_i(e,{index$:zi,keyboard$:mo})),...me("source").map(e=>$i(e))),$s=H(()=>L(...me("announce").map(e=>_n(e)),...me("content").map(e=>oi(e,{sitemap$:qi,viewport$:ke,target$:Ht,print$:Ni})),...me("content").map(e=>V("search.highlight")?Ai(e,{index$:zi,location$:Kt}):y),...me("header").map(e=>si(e,{viewport$:ke,header$:ft,main$:qt})),...me("header-title").map(e=>ci(e,{viewport$:ke,header$:ft})),...me("sidebar").map(e=>e.getAttribute("data-md-type")==="navigation"?eo(Vi,()=>lo(e,{viewport$:ke,header$:ft,main$:qt})):eo(Lr,()=>lo(e,{viewport$:ke,header$:ft,main$:qt}))),...me("tabs").map(e=>Pi(e,{viewport$:ke,header$:ft})),...me("toc").map(e=>Ri(e,{viewport$:ke,header$:ft,main$:qt,target$:Ht})),...me("top").map(e=>Ii(e,{viewport$:ke,header$:ft,main$:qt,target$:Ht})))),Ki=ct.pipe(b(()=>$s),Ve(Hs),Z(1));Ki.subscribe();window.document$=ct;window.location$=Kt;window.target$=Ht;window.keyboard$=mo;window.viewport$=ke;window.tablet$=Lr;window.screen$=Vi;window.print$=Ni;window.alert$=fo;window.progress$=uo;window.component$=Ki;})(); +//# sourceMappingURL=bundle.e71a0d61.min.js.map + diff --git a/site/assets/javascripts/bundle.e71a0d61.min.js.map b/site/assets/javascripts/bundle.e71a0d61.min.js.map new file mode 100644 index 0000000000000000000000000000000000000000..23451b54d11b39ef33a5f94d16d9e351dec9972c --- /dev/null +++ b/site/assets/javascripts/bundle.e71a0d61.min.js.map @@ -0,0 +1,7 @@ +{ + "version": 3, + "sources": ["node_modules/focus-visible/dist/focus-visible.js", "node_modules/escape-html/index.js", "node_modules/clipboard/dist/clipboard.js", "src/templates/assets/javascripts/bundle.ts", "node_modules/tslib/tslib.es6.mjs", "node_modules/rxjs/src/internal/util/isFunction.ts", "node_modules/rxjs/src/internal/util/createErrorClass.ts", "node_modules/rxjs/src/internal/util/UnsubscriptionError.ts", "node_modules/rxjs/src/internal/util/arrRemove.ts", "node_modules/rxjs/src/internal/Subscription.ts", "node_modules/rxjs/src/internal/config.ts", "node_modules/rxjs/src/internal/scheduler/timeoutProvider.ts", "node_modules/rxjs/src/internal/util/reportUnhandledError.ts", "node_modules/rxjs/src/internal/util/noop.ts", "node_modules/rxjs/src/internal/NotificationFactories.ts", "node_modules/rxjs/src/internal/util/errorContext.ts", "node_modules/rxjs/src/internal/Subscriber.ts", "node_modules/rxjs/src/internal/symbol/observable.ts", "node_modules/rxjs/src/internal/util/identity.ts", "node_modules/rxjs/src/internal/util/pipe.ts", "node_modules/rxjs/src/internal/Observable.ts", "node_modules/rxjs/src/internal/util/lift.ts", "node_modules/rxjs/src/internal/operators/OperatorSubscriber.ts", "node_modules/rxjs/src/internal/scheduler/animationFrameProvider.ts", "node_modules/rxjs/src/internal/util/ObjectUnsubscribedError.ts", "node_modules/rxjs/src/internal/Subject.ts", "node_modules/rxjs/src/internal/BehaviorSubject.ts", "node_modules/rxjs/src/internal/scheduler/dateTimestampProvider.ts", "node_modules/rxjs/src/internal/ReplaySubject.ts", "node_modules/rxjs/src/internal/scheduler/Action.ts", "node_modules/rxjs/src/internal/scheduler/intervalProvider.ts", "node_modules/rxjs/src/internal/scheduler/AsyncAction.ts", "node_modules/rxjs/src/internal/Scheduler.ts", "node_modules/rxjs/src/internal/scheduler/AsyncScheduler.ts", "node_modules/rxjs/src/internal/scheduler/async.ts", "node_modules/rxjs/src/internal/scheduler/QueueAction.ts", "node_modules/rxjs/src/internal/scheduler/QueueScheduler.ts", "node_modules/rxjs/src/internal/scheduler/queue.ts", "node_modules/rxjs/src/internal/scheduler/AnimationFrameAction.ts", "node_modules/rxjs/src/internal/scheduler/AnimationFrameScheduler.ts", "node_modules/rxjs/src/internal/scheduler/animationFrame.ts", "node_modules/rxjs/src/internal/observable/empty.ts", "node_modules/rxjs/src/internal/util/isScheduler.ts", "node_modules/rxjs/src/internal/util/args.ts", "node_modules/rxjs/src/internal/util/isArrayLike.ts", "node_modules/rxjs/src/internal/util/isPromise.ts", "node_modules/rxjs/src/internal/util/isInteropObservable.ts", "node_modules/rxjs/src/internal/util/isAsyncIterable.ts", "node_modules/rxjs/src/internal/util/throwUnobservableError.ts", "node_modules/rxjs/src/internal/symbol/iterator.ts", "node_modules/rxjs/src/internal/util/isIterable.ts", "node_modules/rxjs/src/internal/util/isReadableStreamLike.ts", "node_modules/rxjs/src/internal/observable/innerFrom.ts", "node_modules/rxjs/src/internal/util/executeSchedule.ts", "node_modules/rxjs/src/internal/operators/observeOn.ts", "node_modules/rxjs/src/internal/operators/subscribeOn.ts", "node_modules/rxjs/src/internal/scheduled/scheduleObservable.ts", "node_modules/rxjs/src/internal/scheduled/schedulePromise.ts", "node_modules/rxjs/src/internal/scheduled/scheduleArray.ts", "node_modules/rxjs/src/internal/scheduled/scheduleIterable.ts", "node_modules/rxjs/src/internal/scheduled/scheduleAsyncIterable.ts", "node_modules/rxjs/src/internal/scheduled/scheduleReadableStreamLike.ts", "node_modules/rxjs/src/internal/scheduled/scheduled.ts", "node_modules/rxjs/src/internal/observable/from.ts", "node_modules/rxjs/src/internal/observable/of.ts", "node_modules/rxjs/src/internal/observable/throwError.ts", "node_modules/rxjs/src/internal/util/EmptyError.ts", "node_modules/rxjs/src/internal/util/isDate.ts", "node_modules/rxjs/src/internal/operators/map.ts", "node_modules/rxjs/src/internal/util/mapOneOrManyArgs.ts", "node_modules/rxjs/src/internal/util/argsArgArrayOrObject.ts", "node_modules/rxjs/src/internal/util/createObject.ts", "node_modules/rxjs/src/internal/observable/combineLatest.ts", "node_modules/rxjs/src/internal/operators/mergeInternals.ts", "node_modules/rxjs/src/internal/operators/mergeMap.ts", "node_modules/rxjs/src/internal/operators/mergeAll.ts", "node_modules/rxjs/src/internal/operators/concatAll.ts", "node_modules/rxjs/src/internal/observable/concat.ts", "node_modules/rxjs/src/internal/observable/defer.ts", "node_modules/rxjs/src/internal/observable/fromEvent.ts", "node_modules/rxjs/src/internal/observable/fromEventPattern.ts", "node_modules/rxjs/src/internal/observable/timer.ts", "node_modules/rxjs/src/internal/observable/merge.ts", "node_modules/rxjs/src/internal/observable/never.ts", "node_modules/rxjs/src/internal/util/argsOrArgArray.ts", "node_modules/rxjs/src/internal/operators/filter.ts", "node_modules/rxjs/src/internal/observable/zip.ts", "node_modules/rxjs/src/internal/operators/audit.ts", "node_modules/rxjs/src/internal/operators/auditTime.ts", "node_modules/rxjs/src/internal/operators/bufferCount.ts", "node_modules/rxjs/src/internal/operators/catchError.ts", "node_modules/rxjs/src/internal/operators/scanInternals.ts", "node_modules/rxjs/src/internal/operators/combineLatest.ts", "node_modules/rxjs/src/internal/operators/combineLatestWith.ts", "node_modules/rxjs/src/internal/operators/debounce.ts", "node_modules/rxjs/src/internal/operators/debounceTime.ts", "node_modules/rxjs/src/internal/operators/defaultIfEmpty.ts", "node_modules/rxjs/src/internal/operators/take.ts", "node_modules/rxjs/src/internal/operators/ignoreElements.ts", "node_modules/rxjs/src/internal/operators/mapTo.ts", "node_modules/rxjs/src/internal/operators/delayWhen.ts", "node_modules/rxjs/src/internal/operators/delay.ts", "node_modules/rxjs/src/internal/operators/distinct.ts", "node_modules/rxjs/src/internal/operators/distinctUntilChanged.ts", "node_modules/rxjs/src/internal/operators/distinctUntilKeyChanged.ts", "node_modules/rxjs/src/internal/operators/throwIfEmpty.ts", "node_modules/rxjs/src/internal/operators/endWith.ts", "node_modules/rxjs/src/internal/operators/exhaustMap.ts", "node_modules/rxjs/src/internal/operators/finalize.ts", "node_modules/rxjs/src/internal/operators/first.ts", "node_modules/rxjs/src/internal/operators/takeLast.ts", "node_modules/rxjs/src/internal/operators/merge.ts", "node_modules/rxjs/src/internal/operators/mergeWith.ts", "node_modules/rxjs/src/internal/operators/repeat.ts", "node_modules/rxjs/src/internal/operators/scan.ts", "node_modules/rxjs/src/internal/operators/share.ts", "node_modules/rxjs/src/internal/operators/shareReplay.ts", "node_modules/rxjs/src/internal/operators/skip.ts", "node_modules/rxjs/src/internal/operators/skipUntil.ts", "node_modules/rxjs/src/internal/operators/startWith.ts", "node_modules/rxjs/src/internal/operators/switchMap.ts", "node_modules/rxjs/src/internal/operators/takeUntil.ts", "node_modules/rxjs/src/internal/operators/takeWhile.ts", "node_modules/rxjs/src/internal/operators/tap.ts", "node_modules/rxjs/src/internal/operators/throttle.ts", "node_modules/rxjs/src/internal/operators/throttleTime.ts", "node_modules/rxjs/src/internal/operators/withLatestFrom.ts", "node_modules/rxjs/src/internal/operators/zip.ts", "node_modules/rxjs/src/internal/operators/zipWith.ts", "src/templates/assets/javascripts/browser/document/index.ts", "src/templates/assets/javascripts/browser/element/_/index.ts", "src/templates/assets/javascripts/browser/element/focus/index.ts", "src/templates/assets/javascripts/browser/element/hover/index.ts", "src/templates/assets/javascripts/utilities/h/index.ts", "src/templates/assets/javascripts/utilities/round/index.ts", "src/templates/assets/javascripts/browser/script/index.ts", "src/templates/assets/javascripts/browser/element/size/_/index.ts", "src/templates/assets/javascripts/browser/element/size/content/index.ts", "src/templates/assets/javascripts/browser/element/offset/_/index.ts", "src/templates/assets/javascripts/browser/element/offset/content/index.ts", "src/templates/assets/javascripts/browser/element/visibility/index.ts", "src/templates/assets/javascripts/browser/toggle/index.ts", "src/templates/assets/javascripts/browser/keyboard/index.ts", "src/templates/assets/javascripts/browser/location/_/index.ts", "src/templates/assets/javascripts/browser/location/hash/index.ts", "src/templates/assets/javascripts/browser/media/index.ts", "src/templates/assets/javascripts/browser/request/index.ts", "src/templates/assets/javascripts/browser/viewport/offset/index.ts", "src/templates/assets/javascripts/browser/viewport/size/index.ts", "src/templates/assets/javascripts/browser/viewport/_/index.ts", "src/templates/assets/javascripts/browser/viewport/at/index.ts", "src/templates/assets/javascripts/browser/worker/index.ts", "src/templates/assets/javascripts/_/index.ts", "src/templates/assets/javascripts/components/_/index.ts", "src/templates/assets/javascripts/components/announce/index.ts", "src/templates/assets/javascripts/components/consent/index.ts", "src/templates/assets/javascripts/templates/tooltip/index.tsx", "src/templates/assets/javascripts/templates/annotation/index.tsx", "src/templates/assets/javascripts/templates/clipboard/index.tsx", "src/templates/assets/javascripts/templates/search/index.tsx", "src/templates/assets/javascripts/templates/source/index.tsx", "src/templates/assets/javascripts/templates/tabbed/index.tsx", "src/templates/assets/javascripts/templates/table/index.tsx", "src/templates/assets/javascripts/templates/version/index.tsx", "src/templates/assets/javascripts/components/tooltip2/index.ts", "src/templates/assets/javascripts/components/content/annotation/_/index.ts", "src/templates/assets/javascripts/components/content/annotation/list/index.ts", "src/templates/assets/javascripts/components/content/annotation/block/index.ts", "src/templates/assets/javascripts/components/content/code/_/index.ts", "src/templates/assets/javascripts/components/content/details/index.ts", "src/templates/assets/javascripts/components/content/link/index.ts", "src/templates/assets/javascripts/components/content/mermaid/index.css", "src/templates/assets/javascripts/components/content/mermaid/index.ts", "src/templates/assets/javascripts/components/content/table/index.ts", "src/templates/assets/javascripts/components/content/tabs/index.ts", "src/templates/assets/javascripts/components/content/_/index.ts", "src/templates/assets/javascripts/components/dialog/index.ts", "src/templates/assets/javascripts/components/tooltip/index.ts", "src/templates/assets/javascripts/components/header/_/index.ts", "src/templates/assets/javascripts/components/header/title/index.ts", "src/templates/assets/javascripts/components/main/index.ts", "src/templates/assets/javascripts/components/palette/index.ts", "src/templates/assets/javascripts/components/progress/index.ts", "src/templates/assets/javascripts/integrations/sitemap/index.ts", "src/templates/assets/javascripts/integrations/alternate/index.ts", "src/templates/assets/javascripts/integrations/clipboard/index.ts", "src/templates/assets/javascripts/integrations/instant/index.ts", "src/templates/assets/javascripts/integrations/search/highlighter/index.ts", "src/templates/assets/javascripts/integrations/search/worker/message/index.ts", "src/templates/assets/javascripts/integrations/search/worker/_/index.ts", "src/templates/assets/javascripts/integrations/version/findurl/index.ts", "src/templates/assets/javascripts/integrations/version/index.ts", "src/templates/assets/javascripts/components/search/query/index.ts", "src/templates/assets/javascripts/components/search/result/index.ts", "src/templates/assets/javascripts/components/search/share/index.ts", "src/templates/assets/javascripts/components/search/suggest/index.ts", "src/templates/assets/javascripts/components/search/_/index.ts", "src/templates/assets/javascripts/components/search/highlight/index.ts", "src/templates/assets/javascripts/components/sidebar/index.ts", "src/templates/assets/javascripts/components/source/facts/github/index.ts", "src/templates/assets/javascripts/components/source/facts/gitlab/index.ts", "src/templates/assets/javascripts/components/source/facts/_/index.ts", "src/templates/assets/javascripts/components/source/_/index.ts", "src/templates/assets/javascripts/components/tabs/index.ts", "src/templates/assets/javascripts/components/toc/index.ts", "src/templates/assets/javascripts/components/top/index.ts", "src/templates/assets/javascripts/patches/ellipsis/index.ts", "src/templates/assets/javascripts/patches/indeterminate/index.ts", "src/templates/assets/javascripts/patches/scrollfix/index.ts", "src/templates/assets/javascripts/patches/scrolllock/index.ts", "src/templates/assets/javascripts/polyfills/index.ts"], + "sourcesContent": ["(function (global, factory) {\n typeof exports === 'object' && typeof module !== 'undefined' ? factory() :\n typeof define === 'function' && define.amd ? define(factory) :\n (factory());\n}(this, (function () { 'use strict';\n\n /**\n * Applies the :focus-visible polyfill at the given scope.\n * A scope in this case is either the top-level Document or a Shadow Root.\n *\n * @param {(Document|ShadowRoot)} scope\n * @see https://github.com/WICG/focus-visible\n */\n function applyFocusVisiblePolyfill(scope) {\n var hadKeyboardEvent = true;\n var hadFocusVisibleRecently = false;\n var hadFocusVisibleRecentlyTimeout = null;\n\n var inputTypesAllowlist = {\n text: true,\n search: true,\n url: true,\n tel: true,\n email: true,\n password: true,\n number: true,\n date: true,\n month: true,\n week: true,\n time: true,\n datetime: true,\n 'datetime-local': true\n };\n\n /**\n * Helper function for legacy browsers and iframes which sometimes focus\n * elements like document, body, and non-interactive SVG.\n * @param {Element} el\n */\n function isValidFocusTarget(el) {\n if (\n el &&\n el !== document &&\n el.nodeName !== 'HTML' &&\n el.nodeName !== 'BODY' &&\n 'classList' in el &&\n 'contains' in el.classList\n ) {\n return true;\n }\n return false;\n }\n\n /**\n * Computes whether the given element should automatically trigger the\n * `focus-visible` class being added, i.e. whether it should always match\n * `:focus-visible` when focused.\n * @param {Element} el\n * @return {boolean}\n */\n function focusTriggersKeyboardModality(el) {\n var type = el.type;\n var tagName = el.tagName;\n\n if (tagName === 'INPUT' && inputTypesAllowlist[type] && !el.readOnly) {\n return true;\n }\n\n if (tagName === 'TEXTAREA' && !el.readOnly) {\n return true;\n }\n\n if (el.isContentEditable) {\n return true;\n }\n\n return false;\n }\n\n /**\n * Add the `focus-visible` class to the given element if it was not added by\n * the author.\n * @param {Element} el\n */\n function addFocusVisibleClass(el) {\n if (el.classList.contains('focus-visible')) {\n return;\n }\n el.classList.add('focus-visible');\n el.setAttribute('data-focus-visible-added', '');\n }\n\n /**\n * Remove the `focus-visible` class from the given element if it was not\n * originally added by the author.\n * @param {Element} el\n */\n function removeFocusVisibleClass(el) {\n if (!el.hasAttribute('data-focus-visible-added')) {\n return;\n }\n el.classList.remove('focus-visible');\n el.removeAttribute('data-focus-visible-added');\n }\n\n /**\n * If the most recent user interaction was via the keyboard;\n * and the key press did not include a meta, alt/option, or control key;\n * then the modality is keyboard. Otherwise, the modality is not keyboard.\n * Apply `focus-visible` to any current active element and keep track\n * of our keyboard modality state with `hadKeyboardEvent`.\n * @param {KeyboardEvent} e\n */\n function onKeyDown(e) {\n if (e.metaKey || e.altKey || e.ctrlKey) {\n return;\n }\n\n if (isValidFocusTarget(scope.activeElement)) {\n addFocusVisibleClass(scope.activeElement);\n }\n\n hadKeyboardEvent = true;\n }\n\n /**\n * If at any point a user clicks with a pointing device, ensure that we change\n * the modality away from keyboard.\n * This avoids the situation where a user presses a key on an already focused\n * element, and then clicks on a different element, focusing it with a\n * pointing device, while we still think we're in keyboard modality.\n * @param {Event} e\n */\n function onPointerDown(e) {\n hadKeyboardEvent = false;\n }\n\n /**\n * On `focus`, add the `focus-visible` class to the target if:\n * - the target received focus as a result of keyboard navigation, or\n * - the event target is an element that will likely require interaction\n * via the keyboard (e.g. a text box)\n * @param {Event} e\n */\n function onFocus(e) {\n // Prevent IE from focusing the document or HTML element.\n if (!isValidFocusTarget(e.target)) {\n return;\n }\n\n if (hadKeyboardEvent || focusTriggersKeyboardModality(e.target)) {\n addFocusVisibleClass(e.target);\n }\n }\n\n /**\n * On `blur`, remove the `focus-visible` class from the target.\n * @param {Event} e\n */\n function onBlur(e) {\n if (!isValidFocusTarget(e.target)) {\n return;\n }\n\n if (\n e.target.classList.contains('focus-visible') ||\n e.target.hasAttribute('data-focus-visible-added')\n ) {\n // To detect a tab/window switch, we look for a blur event followed\n // rapidly by a visibility change.\n // If we don't see a visibility change within 100ms, it's probably a\n // regular focus change.\n hadFocusVisibleRecently = true;\n window.clearTimeout(hadFocusVisibleRecentlyTimeout);\n hadFocusVisibleRecentlyTimeout = window.setTimeout(function() {\n hadFocusVisibleRecently = false;\n }, 100);\n removeFocusVisibleClass(e.target);\n }\n }\n\n /**\n * If the user changes tabs, keep track of whether or not the previously\n * focused element had .focus-visible.\n * @param {Event} e\n */\n function onVisibilityChange(e) {\n if (document.visibilityState === 'hidden') {\n // If the tab becomes active again, the browser will handle calling focus\n // on the element (Safari actually calls it twice).\n // If this tab change caused a blur on an element with focus-visible,\n // re-apply the class when the user switches back to the tab.\n if (hadFocusVisibleRecently) {\n hadKeyboardEvent = true;\n }\n addInitialPointerMoveListeners();\n }\n }\n\n /**\n * Add a group of listeners to detect usage of any pointing devices.\n * These listeners will be added when the polyfill first loads, and anytime\n * the window is blurred, so that they are active when the window regains\n * focus.\n */\n function addInitialPointerMoveListeners() {\n document.addEventListener('mousemove', onInitialPointerMove);\n document.addEventListener('mousedown', onInitialPointerMove);\n document.addEventListener('mouseup', onInitialPointerMove);\n document.addEventListener('pointermove', onInitialPointerMove);\n document.addEventListener('pointerdown', onInitialPointerMove);\n document.addEventListener('pointerup', onInitialPointerMove);\n document.addEventListener('touchmove', onInitialPointerMove);\n document.addEventListener('touchstart', onInitialPointerMove);\n document.addEventListener('touchend', onInitialPointerMove);\n }\n\n function removeInitialPointerMoveListeners() {\n document.removeEventListener('mousemove', onInitialPointerMove);\n document.removeEventListener('mousedown', onInitialPointerMove);\n document.removeEventListener('mouseup', onInitialPointerMove);\n document.removeEventListener('pointermove', onInitialPointerMove);\n document.removeEventListener('pointerdown', onInitialPointerMove);\n document.removeEventListener('pointerup', onInitialPointerMove);\n document.removeEventListener('touchmove', onInitialPointerMove);\n document.removeEventListener('touchstart', onInitialPointerMove);\n document.removeEventListener('touchend', onInitialPointerMove);\n }\n\n /**\n * When the polfyill first loads, assume the user is in keyboard modality.\n * If any event is received from a pointing device (e.g. mouse, pointer,\n * touch), turn off keyboard modality.\n * This accounts for situations where focus enters the page from the URL bar.\n * @param {Event} e\n */\n function onInitialPointerMove(e) {\n // Work around a Safari quirk that fires a mousemove on whenever the\n // window blurs, even if you're tabbing out of the page. \u00AF\\_(\u30C4)_/\u00AF\n if (e.target.nodeName && e.target.nodeName.toLowerCase() === 'html') {\n return;\n }\n\n hadKeyboardEvent = false;\n removeInitialPointerMoveListeners();\n }\n\n // For some kinds of state, we are interested in changes at the global scope\n // only. For example, global pointer input, global key presses and global\n // visibility change should affect the state at every scope:\n document.addEventListener('keydown', onKeyDown, true);\n document.addEventListener('mousedown', onPointerDown, true);\n document.addEventListener('pointerdown', onPointerDown, true);\n document.addEventListener('touchstart', onPointerDown, true);\n document.addEventListener('visibilitychange', onVisibilityChange, true);\n\n addInitialPointerMoveListeners();\n\n // For focus and blur, we specifically care about state changes in the local\n // scope. This is because focus / blur events that originate from within a\n // shadow root are not re-dispatched from the host element if it was already\n // the active element in its own scope:\n scope.addEventListener('focus', onFocus, true);\n scope.addEventListener('blur', onBlur, true);\n\n // We detect that a node is a ShadowRoot by ensuring that it is a\n // DocumentFragment and also has a host property. This check covers native\n // implementation and polyfill implementation transparently. If we only cared\n // about the native implementation, we could just check if the scope was\n // an instance of a ShadowRoot.\n if (scope.nodeType === Node.DOCUMENT_FRAGMENT_NODE && scope.host) {\n // Since a ShadowRoot is a special kind of DocumentFragment, it does not\n // have a root element to add a class to. So, we add this attribute to the\n // host element instead:\n scope.host.setAttribute('data-js-focus-visible', '');\n } else if (scope.nodeType === Node.DOCUMENT_NODE) {\n document.documentElement.classList.add('js-focus-visible');\n document.documentElement.setAttribute('data-js-focus-visible', '');\n }\n }\n\n // It is important to wrap all references to global window and document in\n // these checks to support server-side rendering use cases\n // @see https://github.com/WICG/focus-visible/issues/199\n if (typeof window !== 'undefined' && typeof document !== 'undefined') {\n // Make the polyfill helper globally available. This can be used as a signal\n // to interested libraries that wish to coordinate with the polyfill for e.g.,\n // applying the polyfill to a shadow root:\n window.applyFocusVisiblePolyfill = applyFocusVisiblePolyfill;\n\n // Notify interested libraries of the polyfill's presence, in case the\n // polyfill was loaded lazily:\n var event;\n\n try {\n event = new CustomEvent('focus-visible-polyfill-ready');\n } catch (error) {\n // IE11 does not support using CustomEvent as a constructor directly:\n event = document.createEvent('CustomEvent');\n event.initCustomEvent('focus-visible-polyfill-ready', false, false, {});\n }\n\n window.dispatchEvent(event);\n }\n\n if (typeof document !== 'undefined') {\n // Apply the polyfill to the global document, so that no JavaScript\n // coordination is required to use the polyfill in the top-level document:\n applyFocusVisiblePolyfill(document);\n }\n\n})));\n", "/*!\n * escape-html\n * Copyright(c) 2012-2013 TJ Holowaychuk\n * Copyright(c) 2015 Andreas Lubbe\n * Copyright(c) 2015 Tiancheng \"Timothy\" Gu\n * MIT Licensed\n */\n\n'use strict';\n\n/**\n * Module variables.\n * @private\n */\n\nvar matchHtmlRegExp = /[\"'&<>]/;\n\n/**\n * Module exports.\n * @public\n */\n\nmodule.exports = escapeHtml;\n\n/**\n * Escape special characters in the given string of html.\n *\n * @param {string} string The string to escape for inserting into HTML\n * @return {string}\n * @public\n */\n\nfunction escapeHtml(string) {\n var str = '' + string;\n var match = matchHtmlRegExp.exec(str);\n\n if (!match) {\n return str;\n }\n\n var escape;\n var html = '';\n var index = 0;\n var lastIndex = 0;\n\n for (index = match.index; index < str.length; index++) {\n switch (str.charCodeAt(index)) {\n case 34: // \"\n escape = '"';\n break;\n case 38: // &\n escape = '&';\n break;\n case 39: // '\n escape = ''';\n break;\n case 60: // <\n escape = '<';\n break;\n case 62: // >\n escape = '>';\n break;\n default:\n continue;\n }\n\n if (lastIndex !== index) {\n html += str.substring(lastIndex, index);\n }\n\n lastIndex = index + 1;\n html += escape;\n }\n\n return lastIndex !== index\n ? html + str.substring(lastIndex, index)\n : html;\n}\n", "/*!\n * clipboard.js v2.0.11\n * https://clipboardjs.com/\n *\n * Licensed MIT \u00A9 Zeno Rocha\n */\n(function webpackUniversalModuleDefinition(root, factory) {\n\tif(typeof exports === 'object' && typeof module === 'object')\n\t\tmodule.exports = factory();\n\telse if(typeof define === 'function' && define.amd)\n\t\tdefine([], factory);\n\telse if(typeof exports === 'object')\n\t\texports[\"ClipboardJS\"] = factory();\n\telse\n\t\troot[\"ClipboardJS\"] = factory();\n})(this, function() {\nreturn /******/ (function() { // webpackBootstrap\n/******/ \tvar __webpack_modules__ = ({\n\n/***/ 686:\n/***/ (function(__unused_webpack_module, __webpack_exports__, __webpack_require__) {\n\n\"use strict\";\n\n// EXPORTS\n__webpack_require__.d(__webpack_exports__, {\n \"default\": function() { return /* binding */ clipboard; }\n});\n\n// EXTERNAL MODULE: ./node_modules/tiny-emitter/index.js\nvar tiny_emitter = __webpack_require__(279);\nvar tiny_emitter_default = /*#__PURE__*/__webpack_require__.n(tiny_emitter);\n// EXTERNAL MODULE: ./node_modules/good-listener/src/listen.js\nvar listen = __webpack_require__(370);\nvar listen_default = /*#__PURE__*/__webpack_require__.n(listen);\n// EXTERNAL MODULE: ./node_modules/select/src/select.js\nvar src_select = __webpack_require__(817);\nvar select_default = /*#__PURE__*/__webpack_require__.n(src_select);\n;// CONCATENATED MODULE: ./src/common/command.js\n/**\n * Executes a given operation type.\n * @param {String} type\n * @return {Boolean}\n */\nfunction command(type) {\n try {\n return document.execCommand(type);\n } catch (err) {\n return false;\n }\n}\n;// CONCATENATED MODULE: ./src/actions/cut.js\n\n\n/**\n * Cut action wrapper.\n * @param {String|HTMLElement} target\n * @return {String}\n */\n\nvar ClipboardActionCut = function ClipboardActionCut(target) {\n var selectedText = select_default()(target);\n command('cut');\n return selectedText;\n};\n\n/* harmony default export */ var actions_cut = (ClipboardActionCut);\n;// CONCATENATED MODULE: ./src/common/create-fake-element.js\n/**\n * Creates a fake textarea element with a value.\n * @param {String} value\n * @return {HTMLElement}\n */\nfunction createFakeElement(value) {\n var isRTL = document.documentElement.getAttribute('dir') === 'rtl';\n var fakeElement = document.createElement('textarea'); // Prevent zooming on iOS\n\n fakeElement.style.fontSize = '12pt'; // Reset box model\n\n fakeElement.style.border = '0';\n fakeElement.style.padding = '0';\n fakeElement.style.margin = '0'; // Move element out of screen horizontally\n\n fakeElement.style.position = 'absolute';\n fakeElement.style[isRTL ? 'right' : 'left'] = '-9999px'; // Move element to the same position vertically\n\n var yPosition = window.pageYOffset || document.documentElement.scrollTop;\n fakeElement.style.top = \"\".concat(yPosition, \"px\");\n fakeElement.setAttribute('readonly', '');\n fakeElement.value = value;\n return fakeElement;\n}\n;// CONCATENATED MODULE: ./src/actions/copy.js\n\n\n\n/**\n * Create fake copy action wrapper using a fake element.\n * @param {String} target\n * @param {Object} options\n * @return {String}\n */\n\nvar fakeCopyAction = function fakeCopyAction(value, options) {\n var fakeElement = createFakeElement(value);\n options.container.appendChild(fakeElement);\n var selectedText = select_default()(fakeElement);\n command('copy');\n fakeElement.remove();\n return selectedText;\n};\n/**\n * Copy action wrapper.\n * @param {String|HTMLElement} target\n * @param {Object} options\n * @return {String}\n */\n\n\nvar ClipboardActionCopy = function ClipboardActionCopy(target) {\n var options = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {\n container: document.body\n };\n var selectedText = '';\n\n if (typeof target === 'string') {\n selectedText = fakeCopyAction(target, options);\n } else if (target instanceof HTMLInputElement && !['text', 'search', 'url', 'tel', 'password'].includes(target === null || target === void 0 ? void 0 : target.type)) {\n // If input type doesn't support `setSelectionRange`. Simulate it. https://developer.mozilla.org/en-US/docs/Web/API/HTMLInputElement/setSelectionRange\n selectedText = fakeCopyAction(target.value, options);\n } else {\n selectedText = select_default()(target);\n command('copy');\n }\n\n return selectedText;\n};\n\n/* harmony default export */ var actions_copy = (ClipboardActionCopy);\n;// CONCATENATED MODULE: ./src/actions/default.js\nfunction _typeof(obj) { \"@babel/helpers - typeof\"; if (typeof Symbol === \"function\" && typeof Symbol.iterator === \"symbol\") { _typeof = function _typeof(obj) { return typeof obj; }; } else { _typeof = function _typeof(obj) { return obj && typeof Symbol === \"function\" && obj.constructor === Symbol && obj !== Symbol.prototype ? \"symbol\" : typeof obj; }; } return _typeof(obj); }\n\n\n\n/**\n * Inner function which performs selection from either `text` or `target`\n * properties and then executes copy or cut operations.\n * @param {Object} options\n */\n\nvar ClipboardActionDefault = function ClipboardActionDefault() {\n var options = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};\n // Defines base properties passed from constructor.\n var _options$action = options.action,\n action = _options$action === void 0 ? 'copy' : _options$action,\n container = options.container,\n target = options.target,\n text = options.text; // Sets the `action` to be performed which can be either 'copy' or 'cut'.\n\n if (action !== 'copy' && action !== 'cut') {\n throw new Error('Invalid \"action\" value, use either \"copy\" or \"cut\"');\n } // Sets the `target` property using an element that will be have its content copied.\n\n\n if (target !== undefined) {\n if (target && _typeof(target) === 'object' && target.nodeType === 1) {\n if (action === 'copy' && target.hasAttribute('disabled')) {\n throw new Error('Invalid \"target\" attribute. Please use \"readonly\" instead of \"disabled\" attribute');\n }\n\n if (action === 'cut' && (target.hasAttribute('readonly') || target.hasAttribute('disabled'))) {\n throw new Error('Invalid \"target\" attribute. You can\\'t cut text from elements with \"readonly\" or \"disabled\" attributes');\n }\n } else {\n throw new Error('Invalid \"target\" value, use a valid Element');\n }\n } // Define selection strategy based on `text` property.\n\n\n if (text) {\n return actions_copy(text, {\n container: container\n });\n } // Defines which selection strategy based on `target` property.\n\n\n if (target) {\n return action === 'cut' ? actions_cut(target) : actions_copy(target, {\n container: container\n });\n }\n};\n\n/* harmony default export */ var actions_default = (ClipboardActionDefault);\n;// CONCATENATED MODULE: ./src/clipboard.js\nfunction clipboard_typeof(obj) { \"@babel/helpers - typeof\"; if (typeof Symbol === \"function\" && typeof Symbol.iterator === \"symbol\") { clipboard_typeof = function _typeof(obj) { return typeof obj; }; } else { clipboard_typeof = function _typeof(obj) { return obj && typeof Symbol === \"function\" && obj.constructor === Symbol && obj !== Symbol.prototype ? \"symbol\" : typeof obj; }; } return clipboard_typeof(obj); }\n\nfunction _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError(\"Cannot call a class as a function\"); } }\n\nfunction _defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if (\"value\" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } }\n\nfunction _createClass(Constructor, protoProps, staticProps) { if (protoProps) _defineProperties(Constructor.prototype, protoProps); if (staticProps) _defineProperties(Constructor, staticProps); return Constructor; }\n\nfunction _inherits(subClass, superClass) { if (typeof superClass !== \"function\" && superClass !== null) { throw new TypeError(\"Super expression must either be null or a function\"); } subClass.prototype = Object.create(superClass && superClass.prototype, { constructor: { value: subClass, writable: true, configurable: true } }); if (superClass) _setPrototypeOf(subClass, superClass); }\n\nfunction _setPrototypeOf(o, p) { _setPrototypeOf = Object.setPrototypeOf || function _setPrototypeOf(o, p) { o.__proto__ = p; return o; }; return _setPrototypeOf(o, p); }\n\nfunction _createSuper(Derived) { var hasNativeReflectConstruct = _isNativeReflectConstruct(); return function _createSuperInternal() { var Super = _getPrototypeOf(Derived), result; if (hasNativeReflectConstruct) { var NewTarget = _getPrototypeOf(this).constructor; result = Reflect.construct(Super, arguments, NewTarget); } else { result = Super.apply(this, arguments); } return _possibleConstructorReturn(this, result); }; }\n\nfunction _possibleConstructorReturn(self, call) { if (call && (clipboard_typeof(call) === \"object\" || typeof call === \"function\")) { return call; } return _assertThisInitialized(self); }\n\nfunction _assertThisInitialized(self) { if (self === void 0) { throw new ReferenceError(\"this hasn't been initialised - super() hasn't been called\"); } return self; }\n\nfunction _isNativeReflectConstruct() { if (typeof Reflect === \"undefined\" || !Reflect.construct) return false; if (Reflect.construct.sham) return false; if (typeof Proxy === \"function\") return true; try { Date.prototype.toString.call(Reflect.construct(Date, [], function () {})); return true; } catch (e) { return false; } }\n\nfunction _getPrototypeOf(o) { _getPrototypeOf = Object.setPrototypeOf ? Object.getPrototypeOf : function _getPrototypeOf(o) { return o.__proto__ || Object.getPrototypeOf(o); }; return _getPrototypeOf(o); }\n\n\n\n\n\n\n/**\n * Helper function to retrieve attribute value.\n * @param {String} suffix\n * @param {Element} element\n */\n\nfunction getAttributeValue(suffix, element) {\n var attribute = \"data-clipboard-\".concat(suffix);\n\n if (!element.hasAttribute(attribute)) {\n return;\n }\n\n return element.getAttribute(attribute);\n}\n/**\n * Base class which takes one or more elements, adds event listeners to them,\n * and instantiates a new `ClipboardAction` on each click.\n */\n\n\nvar Clipboard = /*#__PURE__*/function (_Emitter) {\n _inherits(Clipboard, _Emitter);\n\n var _super = _createSuper(Clipboard);\n\n /**\n * @param {String|HTMLElement|HTMLCollection|NodeList} trigger\n * @param {Object} options\n */\n function Clipboard(trigger, options) {\n var _this;\n\n _classCallCheck(this, Clipboard);\n\n _this = _super.call(this);\n\n _this.resolveOptions(options);\n\n _this.listenClick(trigger);\n\n return _this;\n }\n /**\n * Defines if attributes would be resolved using internal setter functions\n * or custom functions that were passed in the constructor.\n * @param {Object} options\n */\n\n\n _createClass(Clipboard, [{\n key: \"resolveOptions\",\n value: function resolveOptions() {\n var options = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};\n this.action = typeof options.action === 'function' ? options.action : this.defaultAction;\n this.target = typeof options.target === 'function' ? options.target : this.defaultTarget;\n this.text = typeof options.text === 'function' ? options.text : this.defaultText;\n this.container = clipboard_typeof(options.container) === 'object' ? options.container : document.body;\n }\n /**\n * Adds a click event listener to the passed trigger.\n * @param {String|HTMLElement|HTMLCollection|NodeList} trigger\n */\n\n }, {\n key: \"listenClick\",\n value: function listenClick(trigger) {\n var _this2 = this;\n\n this.listener = listen_default()(trigger, 'click', function (e) {\n return _this2.onClick(e);\n });\n }\n /**\n * Defines a new `ClipboardAction` on each click event.\n * @param {Event} e\n */\n\n }, {\n key: \"onClick\",\n value: function onClick(e) {\n var trigger = e.delegateTarget || e.currentTarget;\n var action = this.action(trigger) || 'copy';\n var text = actions_default({\n action: action,\n container: this.container,\n target: this.target(trigger),\n text: this.text(trigger)\n }); // Fires an event based on the copy operation result.\n\n this.emit(text ? 'success' : 'error', {\n action: action,\n text: text,\n trigger: trigger,\n clearSelection: function clearSelection() {\n if (trigger) {\n trigger.focus();\n }\n\n window.getSelection().removeAllRanges();\n }\n });\n }\n /**\n * Default `action` lookup function.\n * @param {Element} trigger\n */\n\n }, {\n key: \"defaultAction\",\n value: function defaultAction(trigger) {\n return getAttributeValue('action', trigger);\n }\n /**\n * Default `target` lookup function.\n * @param {Element} trigger\n */\n\n }, {\n key: \"defaultTarget\",\n value: function defaultTarget(trigger) {\n var selector = getAttributeValue('target', trigger);\n\n if (selector) {\n return document.querySelector(selector);\n }\n }\n /**\n * Allow fire programmatically a copy action\n * @param {String|HTMLElement} target\n * @param {Object} options\n * @returns Text copied.\n */\n\n }, {\n key: \"defaultText\",\n\n /**\n * Default `text` lookup function.\n * @param {Element} trigger\n */\n value: function defaultText(trigger) {\n return getAttributeValue('text', trigger);\n }\n /**\n * Destroy lifecycle.\n */\n\n }, {\n key: \"destroy\",\n value: function destroy() {\n this.listener.destroy();\n }\n }], [{\n key: \"copy\",\n value: function copy(target) {\n var options = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {\n container: document.body\n };\n return actions_copy(target, options);\n }\n /**\n * Allow fire programmatically a cut action\n * @param {String|HTMLElement} target\n * @returns Text cutted.\n */\n\n }, {\n key: \"cut\",\n value: function cut(target) {\n return actions_cut(target);\n }\n /**\n * Returns the support of the given action, or all actions if no action is\n * given.\n * @param {String} [action]\n */\n\n }, {\n key: \"isSupported\",\n value: function isSupported() {\n var action = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : ['copy', 'cut'];\n var actions = typeof action === 'string' ? [action] : action;\n var support = !!document.queryCommandSupported;\n actions.forEach(function (action) {\n support = support && !!document.queryCommandSupported(action);\n });\n return support;\n }\n }]);\n\n return Clipboard;\n}((tiny_emitter_default()));\n\n/* harmony default export */ var clipboard = (Clipboard);\n\n/***/ }),\n\n/***/ 828:\n/***/ (function(module) {\n\nvar DOCUMENT_NODE_TYPE = 9;\n\n/**\n * A polyfill for Element.matches()\n */\nif (typeof Element !== 'undefined' && !Element.prototype.matches) {\n var proto = Element.prototype;\n\n proto.matches = proto.matchesSelector ||\n proto.mozMatchesSelector ||\n proto.msMatchesSelector ||\n proto.oMatchesSelector ||\n proto.webkitMatchesSelector;\n}\n\n/**\n * Finds the closest parent that matches a selector.\n *\n * @param {Element} element\n * @param {String} selector\n * @return {Function}\n */\nfunction closest (element, selector) {\n while (element && element.nodeType !== DOCUMENT_NODE_TYPE) {\n if (typeof element.matches === 'function' &&\n element.matches(selector)) {\n return element;\n }\n element = element.parentNode;\n }\n}\n\nmodule.exports = closest;\n\n\n/***/ }),\n\n/***/ 438:\n/***/ (function(module, __unused_webpack_exports, __webpack_require__) {\n\nvar closest = __webpack_require__(828);\n\n/**\n * Delegates event to a selector.\n *\n * @param {Element} element\n * @param {String} selector\n * @param {String} type\n * @param {Function} callback\n * @param {Boolean} useCapture\n * @return {Object}\n */\nfunction _delegate(element, selector, type, callback, useCapture) {\n var listenerFn = listener.apply(this, arguments);\n\n element.addEventListener(type, listenerFn, useCapture);\n\n return {\n destroy: function() {\n element.removeEventListener(type, listenerFn, useCapture);\n }\n }\n}\n\n/**\n * Delegates event to a selector.\n *\n * @param {Element|String|Array} [elements]\n * @param {String} selector\n * @param {String} type\n * @param {Function} callback\n * @param {Boolean} useCapture\n * @return {Object}\n */\nfunction delegate(elements, selector, type, callback, useCapture) {\n // Handle the regular Element usage\n if (typeof elements.addEventListener === 'function') {\n return _delegate.apply(null, arguments);\n }\n\n // Handle Element-less usage, it defaults to global delegation\n if (typeof type === 'function') {\n // Use `document` as the first parameter, then apply arguments\n // This is a short way to .unshift `arguments` without running into deoptimizations\n return _delegate.bind(null, document).apply(null, arguments);\n }\n\n // Handle Selector-based usage\n if (typeof elements === 'string') {\n elements = document.querySelectorAll(elements);\n }\n\n // Handle Array-like based usage\n return Array.prototype.map.call(elements, function (element) {\n return _delegate(element, selector, type, callback, useCapture);\n });\n}\n\n/**\n * Finds closest match and invokes callback.\n *\n * @param {Element} element\n * @param {String} selector\n * @param {String} type\n * @param {Function} callback\n * @return {Function}\n */\nfunction listener(element, selector, type, callback) {\n return function(e) {\n e.delegateTarget = closest(e.target, selector);\n\n if (e.delegateTarget) {\n callback.call(element, e);\n }\n }\n}\n\nmodule.exports = delegate;\n\n\n/***/ }),\n\n/***/ 879:\n/***/ (function(__unused_webpack_module, exports) {\n\n/**\n * Check if argument is a HTML element.\n *\n * @param {Object} value\n * @return {Boolean}\n */\nexports.node = function(value) {\n return value !== undefined\n && value instanceof HTMLElement\n && value.nodeType === 1;\n};\n\n/**\n * Check if argument is a list of HTML elements.\n *\n * @param {Object} value\n * @return {Boolean}\n */\nexports.nodeList = function(value) {\n var type = Object.prototype.toString.call(value);\n\n return value !== undefined\n && (type === '[object NodeList]' || type === '[object HTMLCollection]')\n && ('length' in value)\n && (value.length === 0 || exports.node(value[0]));\n};\n\n/**\n * Check if argument is a string.\n *\n * @param {Object} value\n * @return {Boolean}\n */\nexports.string = function(value) {\n return typeof value === 'string'\n || value instanceof String;\n};\n\n/**\n * Check if argument is a function.\n *\n * @param {Object} value\n * @return {Boolean}\n */\nexports.fn = function(value) {\n var type = Object.prototype.toString.call(value);\n\n return type === '[object Function]';\n};\n\n\n/***/ }),\n\n/***/ 370:\n/***/ (function(module, __unused_webpack_exports, __webpack_require__) {\n\nvar is = __webpack_require__(879);\nvar delegate = __webpack_require__(438);\n\n/**\n * Validates all params and calls the right\n * listener function based on its target type.\n *\n * @param {String|HTMLElement|HTMLCollection|NodeList} target\n * @param {String} type\n * @param {Function} callback\n * @return {Object}\n */\nfunction listen(target, type, callback) {\n if (!target && !type && !callback) {\n throw new Error('Missing required arguments');\n }\n\n if (!is.string(type)) {\n throw new TypeError('Second argument must be a String');\n }\n\n if (!is.fn(callback)) {\n throw new TypeError('Third argument must be a Function');\n }\n\n if (is.node(target)) {\n return listenNode(target, type, callback);\n }\n else if (is.nodeList(target)) {\n return listenNodeList(target, type, callback);\n }\n else if (is.string(target)) {\n return listenSelector(target, type, callback);\n }\n else {\n throw new TypeError('First argument must be a String, HTMLElement, HTMLCollection, or NodeList');\n }\n}\n\n/**\n * Adds an event listener to a HTML element\n * and returns a remove listener function.\n *\n * @param {HTMLElement} node\n * @param {String} type\n * @param {Function} callback\n * @return {Object}\n */\nfunction listenNode(node, type, callback) {\n node.addEventListener(type, callback);\n\n return {\n destroy: function() {\n node.removeEventListener(type, callback);\n }\n }\n}\n\n/**\n * Add an event listener to a list of HTML elements\n * and returns a remove listener function.\n *\n * @param {NodeList|HTMLCollection} nodeList\n * @param {String} type\n * @param {Function} callback\n * @return {Object}\n */\nfunction listenNodeList(nodeList, type, callback) {\n Array.prototype.forEach.call(nodeList, function(node) {\n node.addEventListener(type, callback);\n });\n\n return {\n destroy: function() {\n Array.prototype.forEach.call(nodeList, function(node) {\n node.removeEventListener(type, callback);\n });\n }\n }\n}\n\n/**\n * Add an event listener to a selector\n * and returns a remove listener function.\n *\n * @param {String} selector\n * @param {String} type\n * @param {Function} callback\n * @return {Object}\n */\nfunction listenSelector(selector, type, callback) {\n return delegate(document.body, selector, type, callback);\n}\n\nmodule.exports = listen;\n\n\n/***/ }),\n\n/***/ 817:\n/***/ (function(module) {\n\nfunction select(element) {\n var selectedText;\n\n if (element.nodeName === 'SELECT') {\n element.focus();\n\n selectedText = element.value;\n }\n else if (element.nodeName === 'INPUT' || element.nodeName === 'TEXTAREA') {\n var isReadOnly = element.hasAttribute('readonly');\n\n if (!isReadOnly) {\n element.setAttribute('readonly', '');\n }\n\n element.select();\n element.setSelectionRange(0, element.value.length);\n\n if (!isReadOnly) {\n element.removeAttribute('readonly');\n }\n\n selectedText = element.value;\n }\n else {\n if (element.hasAttribute('contenteditable')) {\n element.focus();\n }\n\n var selection = window.getSelection();\n var range = document.createRange();\n\n range.selectNodeContents(element);\n selection.removeAllRanges();\n selection.addRange(range);\n\n selectedText = selection.toString();\n }\n\n return selectedText;\n}\n\nmodule.exports = select;\n\n\n/***/ }),\n\n/***/ 279:\n/***/ (function(module) {\n\nfunction E () {\n // Keep this empty so it's easier to inherit from\n // (via https://github.com/lipsmack from https://github.com/scottcorgan/tiny-emitter/issues/3)\n}\n\nE.prototype = {\n on: function (name, callback, ctx) {\n var e = this.e || (this.e = {});\n\n (e[name] || (e[name] = [])).push({\n fn: callback,\n ctx: ctx\n });\n\n return this;\n },\n\n once: function (name, callback, ctx) {\n var self = this;\n function listener () {\n self.off(name, listener);\n callback.apply(ctx, arguments);\n };\n\n listener._ = callback\n return this.on(name, listener, ctx);\n },\n\n emit: function (name) {\n var data = [].slice.call(arguments, 1);\n var evtArr = ((this.e || (this.e = {}))[name] || []).slice();\n var i = 0;\n var len = evtArr.length;\n\n for (i; i < len; i++) {\n evtArr[i].fn.apply(evtArr[i].ctx, data);\n }\n\n return this;\n },\n\n off: function (name, callback) {\n var e = this.e || (this.e = {});\n var evts = e[name];\n var liveEvents = [];\n\n if (evts && callback) {\n for (var i = 0, len = evts.length; i < len; i++) {\n if (evts[i].fn !== callback && evts[i].fn._ !== callback)\n liveEvents.push(evts[i]);\n }\n }\n\n // Remove event from queue to prevent memory leak\n // Suggested by https://github.com/lazd\n // Ref: https://github.com/scottcorgan/tiny-emitter/commit/c6ebfaa9bc973b33d110a84a307742b7cf94c953#commitcomment-5024910\n\n (liveEvents.length)\n ? e[name] = liveEvents\n : delete e[name];\n\n return this;\n }\n};\n\nmodule.exports = E;\nmodule.exports.TinyEmitter = E;\n\n\n/***/ })\n\n/******/ \t});\n/************************************************************************/\n/******/ \t// The module cache\n/******/ \tvar __webpack_module_cache__ = {};\n/******/ \t\n/******/ \t// The require function\n/******/ \tfunction __webpack_require__(moduleId) {\n/******/ \t\t// Check if module is in cache\n/******/ \t\tif(__webpack_module_cache__[moduleId]) {\n/******/ \t\t\treturn __webpack_module_cache__[moduleId].exports;\n/******/ \t\t}\n/******/ \t\t// Create a new module (and put it into the cache)\n/******/ \t\tvar module = __webpack_module_cache__[moduleId] = {\n/******/ \t\t\t// no module.id needed\n/******/ \t\t\t// no module.loaded needed\n/******/ \t\t\texports: {}\n/******/ \t\t};\n/******/ \t\n/******/ \t\t// Execute the module function\n/******/ \t\t__webpack_modules__[moduleId](module, module.exports, __webpack_require__);\n/******/ \t\n/******/ \t\t// Return the exports of the module\n/******/ \t\treturn module.exports;\n/******/ \t}\n/******/ \t\n/************************************************************************/\n/******/ \t/* webpack/runtime/compat get default export */\n/******/ \t!function() {\n/******/ \t\t// getDefaultExport function for compatibility with non-harmony modules\n/******/ \t\t__webpack_require__.n = function(module) {\n/******/ \t\t\tvar getter = module && module.__esModule ?\n/******/ \t\t\t\tfunction() { return module['default']; } :\n/******/ \t\t\t\tfunction() { return module; };\n/******/ \t\t\t__webpack_require__.d(getter, { a: getter });\n/******/ \t\t\treturn getter;\n/******/ \t\t};\n/******/ \t}();\n/******/ \t\n/******/ \t/* webpack/runtime/define property getters */\n/******/ \t!function() {\n/******/ \t\t// define getter functions for harmony exports\n/******/ \t\t__webpack_require__.d = function(exports, definition) {\n/******/ \t\t\tfor(var key in definition) {\n/******/ \t\t\t\tif(__webpack_require__.o(definition, key) && !__webpack_require__.o(exports, key)) {\n/******/ \t\t\t\t\tObject.defineProperty(exports, key, { enumerable: true, get: definition[key] });\n/******/ \t\t\t\t}\n/******/ \t\t\t}\n/******/ \t\t};\n/******/ \t}();\n/******/ \t\n/******/ \t/* webpack/runtime/hasOwnProperty shorthand */\n/******/ \t!function() {\n/******/ \t\t__webpack_require__.o = function(obj, prop) { return Object.prototype.hasOwnProperty.call(obj, prop); }\n/******/ \t}();\n/******/ \t\n/************************************************************************/\n/******/ \t// module exports must be returned from runtime so entry inlining is disabled\n/******/ \t// startup\n/******/ \t// Load entry module and return exports\n/******/ \treturn __webpack_require__(686);\n/******/ })()\n.default;\n});", "/*\n * Copyright (c) 2016-2025 Martin Donath \n *\n * Permission is hereby granted, free of charge, to any person obtaining a copy\n * of this software and associated documentation files (the \"Software\"), to\n * deal in the Software without restriction, including without limitation the\n * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or\n * sell copies of the Software, and to permit persons to whom the Software is\n * furnished to do so, subject to the following conditions:\n *\n * The above copyright notice and this permission notice shall be included in\n * all copies or substantial portions of the Software.\n *\n * THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n * FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE\n * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING\n * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS\n * IN THE SOFTWARE.\n */\n\nimport \"focus-visible\"\n\nimport {\n EMPTY,\n NEVER,\n Observable,\n Subject,\n defer,\n delay,\n filter,\n map,\n merge,\n mergeWith,\n shareReplay,\n switchMap\n} from \"rxjs\"\n\nimport { configuration, feature } from \"./_\"\nimport {\n at,\n getActiveElement,\n getOptionalElement,\n requestJSON,\n setLocation,\n setToggle,\n watchDocument,\n watchKeyboard,\n watchLocation,\n watchLocationTarget,\n watchMedia,\n watchPrint,\n watchScript,\n watchViewport\n} from \"./browser\"\nimport {\n getComponentElement,\n getComponentElements,\n mountAnnounce,\n mountBackToTop,\n mountConsent,\n mountContent,\n mountDialog,\n mountHeader,\n mountHeaderTitle,\n mountPalette,\n mountProgress,\n mountSearch,\n mountSearchHiglight,\n mountSidebar,\n mountSource,\n mountTableOfContents,\n mountTabs,\n watchHeader,\n watchMain\n} from \"./components\"\nimport {\n SearchIndex,\n fetchSitemap,\n setupAlternate,\n setupClipboardJS,\n setupInstantNavigation,\n setupVersionSelector\n} from \"./integrations\"\nimport {\n patchEllipsis,\n patchIndeterminate,\n patchScrollfix,\n patchScrolllock\n} from \"./patches\"\nimport \"./polyfills\"\n\n/* ----------------------------------------------------------------------------\n * Functions - @todo refactor\n * ------------------------------------------------------------------------- */\n\n/**\n * Fetch search index\n *\n * @returns Search index observable\n */\nfunction fetchSearchIndex(): Observable {\n if (location.protocol === \"file:\") {\n return watchScript(\n `${new URL(\"search/search_index.js\", config.base)}`\n )\n .pipe(\n // @ts-ignore - @todo fix typings\n map(() => __index),\n shareReplay(1)\n )\n } else {\n return requestJSON(\n new URL(\"search/search_index.json\", config.base)\n )\n }\n}\n\n/* ----------------------------------------------------------------------------\n * Application\n * ------------------------------------------------------------------------- */\n\n/* Yay, JavaScript is available */\ndocument.documentElement.classList.remove(\"no-js\")\ndocument.documentElement.classList.add(\"js\")\n\n/* Set up navigation observables and subjects */\nconst document$ = watchDocument()\nconst location$ = watchLocation()\nconst target$ = watchLocationTarget(location$)\nconst keyboard$ = watchKeyboard()\n\n/* Set up media observables */\nconst viewport$ = watchViewport()\nconst tablet$ = watchMedia(\"(min-width: 60em)\")\nconst screen$ = watchMedia(\"(min-width: 76.25em)\")\nconst print$ = watchPrint()\n\n/* Retrieve search index, if search is enabled */\nconst config = configuration()\nconst index$ = document.forms.namedItem(\"search\")\n ? fetchSearchIndex()\n : NEVER\n\n/* Set up Clipboard.js integration */\nconst alert$ = new Subject()\nsetupClipboardJS({ alert$ })\n\n/* Set up language selector */\nsetupAlternate({ document$ })\n\n/* Set up progress indicator */\nconst progress$ = new Subject()\n\n/* Set up sitemap for instant navigation and previews */\nconst sitemap$ = fetchSitemap(config.base)\n\n/* Set up instant navigation, if enabled */\nif (feature(\"navigation.instant\"))\n setupInstantNavigation({ sitemap$, location$, viewport$, progress$ })\n .subscribe(document$)\n\n/* Set up version selector */\nif (config.version?.provider === \"mike\")\n setupVersionSelector({ document$ })\n\n/* Always close drawer and search on navigation */\nmerge(location$, target$)\n .pipe(\n delay(125)\n )\n .subscribe(() => {\n setToggle(\"drawer\", false)\n setToggle(\"search\", false)\n })\n\n/* Set up global keyboard handlers */\nkeyboard$\n .pipe(\n filter(({ mode }) => mode === \"global\")\n )\n .subscribe(key => {\n switch (key.type) {\n\n /* Go to previous page */\n case \"p\":\n case \",\":\n const prev = getOptionalElement(\"link[rel=prev]\")\n if (typeof prev !== \"undefined\")\n setLocation(prev)\n break\n\n /* Go to next page */\n case \"n\":\n case \".\":\n const next = getOptionalElement(\"link[rel=next]\")\n if (typeof next !== \"undefined\")\n setLocation(next)\n break\n\n /* Expand navigation, see https://bit.ly/3ZjG5io */\n case \"Enter\":\n const active = getActiveElement()\n if (active instanceof HTMLLabelElement)\n active.click()\n }\n })\n\n/* Set up patches */\npatchEllipsis({ viewport$, document$ })\npatchIndeterminate({ document$, tablet$ })\npatchScrollfix({ document$ })\npatchScrolllock({ viewport$, tablet$ })\n\n/* Set up header and main area observable */\nconst header$ = watchHeader(getComponentElement(\"header\"), { viewport$ })\nconst main$ = document$\n .pipe(\n map(() => getComponentElement(\"main\")),\n switchMap(el => watchMain(el, { viewport$, header$ })),\n shareReplay(1)\n )\n\n/* Set up control component observables */\nconst control$ = merge(\n\n /* Consent */\n ...getComponentElements(\"consent\")\n .map(el => mountConsent(el, { target$ })),\n\n /* Dialog */\n ...getComponentElements(\"dialog\")\n .map(el => mountDialog(el, { alert$ })),\n\n /* Color palette */\n ...getComponentElements(\"palette\")\n .map(el => mountPalette(el)),\n\n /* Progress bar */\n ...getComponentElements(\"progress\")\n .map(el => mountProgress(el, { progress$ })),\n\n /* Search */\n ...getComponentElements(\"search\")\n .map(el => mountSearch(el, { index$, keyboard$ })),\n\n /* Repository information */\n ...getComponentElements(\"source\")\n .map(el => mountSource(el))\n)\n\n/* Set up content component observables */\nconst content$ = defer(() => merge(\n\n /* Announcement bar */\n ...getComponentElements(\"announce\")\n .map(el => mountAnnounce(el)),\n\n /* Content */\n ...getComponentElements(\"content\")\n .map(el => mountContent(el, { sitemap$, viewport$, target$, print$ })),\n\n /* Search highlighting */\n ...getComponentElements(\"content\")\n .map(el => feature(\"search.highlight\")\n ? mountSearchHiglight(el, { index$, location$ })\n : EMPTY\n ),\n\n /* Header */\n ...getComponentElements(\"header\")\n .map(el => mountHeader(el, { viewport$, header$, main$ })),\n\n /* Header title */\n ...getComponentElements(\"header-title\")\n .map(el => mountHeaderTitle(el, { viewport$, header$ })),\n\n /* Sidebar */\n ...getComponentElements(\"sidebar\")\n .map(el => el.getAttribute(\"data-md-type\") === \"navigation\"\n ? at(screen$, () => mountSidebar(el, { viewport$, header$, main$ }))\n : at(tablet$, () => mountSidebar(el, { viewport$, header$, main$ }))\n ),\n\n /* Navigation tabs */\n ...getComponentElements(\"tabs\")\n .map(el => mountTabs(el, { viewport$, header$ })),\n\n /* Table of contents */\n ...getComponentElements(\"toc\")\n .map(el => mountTableOfContents(el, {\n viewport$, header$, main$, target$\n })),\n\n /* Back-to-top button */\n ...getComponentElements(\"top\")\n .map(el => mountBackToTop(el, { viewport$, header$, main$, target$ }))\n))\n\n/* Set up component observables */\nconst component$ = document$\n .pipe(\n switchMap(() => content$),\n mergeWith(control$),\n shareReplay(1)\n )\n\n/* Subscribe to all components */\ncomponent$.subscribe()\n\n/* ----------------------------------------------------------------------------\n * Exports\n * ------------------------------------------------------------------------- */\n\nwindow.document$ = document$ /* Document observable */\nwindow.location$ = location$ /* Location subject */\nwindow.target$ = target$ /* Location target observable */\nwindow.keyboard$ = keyboard$ /* Keyboard observable */\nwindow.viewport$ = viewport$ /* Viewport observable */\nwindow.tablet$ = tablet$ /* Media tablet observable */\nwindow.screen$ = screen$ /* Media screen observable */\nwindow.print$ = print$ /* Media print observable */\nwindow.alert$ = alert$ /* Alert subject */\nwindow.progress$ = progress$ /* Progress indicator subject */\nwindow.component$ = component$ /* Component observable */\n", "/******************************************************************************\nCopyright (c) Microsoft Corporation.\n\nPermission to use, copy, modify, and/or distribute this software for any\npurpose with or without fee is hereby granted.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH\nREGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY\nAND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,\nINDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM\nLOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR\nOTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR\nPERFORMANCE OF THIS SOFTWARE.\n***************************************************************************** */\n/* global Reflect, Promise, SuppressedError, Symbol, Iterator */\n\nvar extendStatics = function(d, b) {\n extendStatics = Object.setPrototypeOf ||\n ({ __proto__: [] } instanceof Array && function (d, b) { d.__proto__ = b; }) ||\n function (d, b) { for (var p in b) if (Object.prototype.hasOwnProperty.call(b, p)) d[p] = b[p]; };\n return extendStatics(d, b);\n};\n\nexport function __extends(d, b) {\n if (typeof b !== \"function\" && b !== null)\n throw new TypeError(\"Class extends value \" + String(b) + \" is not a constructor or null\");\n extendStatics(d, b);\n function __() { this.constructor = d; }\n d.prototype = b === null ? Object.create(b) : (__.prototype = b.prototype, new __());\n}\n\nexport var __assign = function() {\n __assign = Object.assign || function __assign(t) {\n for (var s, i = 1, n = arguments.length; i < n; i++) {\n s = arguments[i];\n for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p)) t[p] = s[p];\n }\n return t;\n }\n return __assign.apply(this, arguments);\n}\n\nexport function __rest(s, e) {\n var t = {};\n for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0)\n t[p] = s[p];\n if (s != null && typeof Object.getOwnPropertySymbols === \"function\")\n for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) {\n if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i]))\n t[p[i]] = s[p[i]];\n }\n return t;\n}\n\nexport function __decorate(decorators, target, key, desc) {\n var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;\n if (typeof Reflect === \"object\" && typeof Reflect.decorate === \"function\") r = Reflect.decorate(decorators, target, key, desc);\n else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;\n return c > 3 && r && Object.defineProperty(target, key, r), r;\n}\n\nexport function __param(paramIndex, decorator) {\n return function (target, key) { decorator(target, key, paramIndex); }\n}\n\nexport function __esDecorate(ctor, descriptorIn, decorators, contextIn, initializers, extraInitializers) {\n function accept(f) { if (f !== void 0 && typeof f !== \"function\") throw new TypeError(\"Function expected\"); return f; }\n var kind = contextIn.kind, key = kind === \"getter\" ? \"get\" : kind === \"setter\" ? \"set\" : \"value\";\n var target = !descriptorIn && ctor ? contextIn[\"static\"] ? ctor : ctor.prototype : null;\n var descriptor = descriptorIn || (target ? Object.getOwnPropertyDescriptor(target, contextIn.name) : {});\n var _, done = false;\n for (var i = decorators.length - 1; i >= 0; i--) {\n var context = {};\n for (var p in contextIn) context[p] = p === \"access\" ? {} : contextIn[p];\n for (var p in contextIn.access) context.access[p] = contextIn.access[p];\n context.addInitializer = function (f) { if (done) throw new TypeError(\"Cannot add initializers after decoration has completed\"); extraInitializers.push(accept(f || null)); };\n var result = (0, decorators[i])(kind === \"accessor\" ? { get: descriptor.get, set: descriptor.set } : descriptor[key], context);\n if (kind === \"accessor\") {\n if (result === void 0) continue;\n if (result === null || typeof result !== \"object\") throw new TypeError(\"Object expected\");\n if (_ = accept(result.get)) descriptor.get = _;\n if (_ = accept(result.set)) descriptor.set = _;\n if (_ = accept(result.init)) initializers.unshift(_);\n }\n else if (_ = accept(result)) {\n if (kind === \"field\") initializers.unshift(_);\n else descriptor[key] = _;\n }\n }\n if (target) Object.defineProperty(target, contextIn.name, descriptor);\n done = true;\n};\n\nexport function __runInitializers(thisArg, initializers, value) {\n var useValue = arguments.length > 2;\n for (var i = 0; i < initializers.length; i++) {\n value = useValue ? initializers[i].call(thisArg, value) : initializers[i].call(thisArg);\n }\n return useValue ? value : void 0;\n};\n\nexport function __propKey(x) {\n return typeof x === \"symbol\" ? x : \"\".concat(x);\n};\n\nexport function __setFunctionName(f, name, prefix) {\n if (typeof name === \"symbol\") name = name.description ? \"[\".concat(name.description, \"]\") : \"\";\n return Object.defineProperty(f, \"name\", { configurable: true, value: prefix ? \"\".concat(prefix, \" \", name) : name });\n};\n\nexport function __metadata(metadataKey, metadataValue) {\n if (typeof Reflect === \"object\" && typeof Reflect.metadata === \"function\") return Reflect.metadata(metadataKey, metadataValue);\n}\n\nexport function __awaiter(thisArg, _arguments, P, generator) {\n function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }\n return new (P || (P = Promise))(function (resolve, reject) {\n function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }\n function rejected(value) { try { step(generator[\"throw\"](value)); } catch (e) { reject(e); } }\n function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }\n step((generator = generator.apply(thisArg, _arguments || [])).next());\n });\n}\n\nexport function __generator(thisArg, body) {\n var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g = Object.create((typeof Iterator === \"function\" ? Iterator : Object).prototype);\n return g.next = verb(0), g[\"throw\"] = verb(1), g[\"return\"] = verb(2), typeof Symbol === \"function\" && (g[Symbol.iterator] = function() { return this; }), g;\n function verb(n) { return function (v) { return step([n, v]); }; }\n function step(op) {\n if (f) throw new TypeError(\"Generator is already executing.\");\n while (g && (g = 0, op[0] && (_ = 0)), _) try {\n if (f = 1, y && (t = op[0] & 2 ? y[\"return\"] : op[0] ? y[\"throw\"] || ((t = y[\"return\"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t;\n if (y = 0, t) op = [op[0] & 2, t.value];\n switch (op[0]) {\n case 0: case 1: t = op; break;\n case 4: _.label++; return { value: op[1], done: false };\n case 5: _.label++; y = op[1]; op = [0]; continue;\n case 7: op = _.ops.pop(); _.trys.pop(); continue;\n default:\n if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; }\n if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; }\n if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; }\n if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; }\n if (t[2]) _.ops.pop();\n _.trys.pop(); continue;\n }\n op = body.call(thisArg, _);\n } catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; }\n if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true };\n }\n}\n\nexport var __createBinding = Object.create ? (function(o, m, k, k2) {\n if (k2 === undefined) k2 = k;\n var desc = Object.getOwnPropertyDescriptor(m, k);\n if (!desc || (\"get\" in desc ? !m.__esModule : desc.writable || desc.configurable)) {\n desc = { enumerable: true, get: function() { return m[k]; } };\n }\n Object.defineProperty(o, k2, desc);\n}) : (function(o, m, k, k2) {\n if (k2 === undefined) k2 = k;\n o[k2] = m[k];\n});\n\nexport function __exportStar(m, o) {\n for (var p in m) if (p !== \"default\" && !Object.prototype.hasOwnProperty.call(o, p)) __createBinding(o, m, p);\n}\n\nexport function __values(o) {\n var s = typeof Symbol === \"function\" && Symbol.iterator, m = s && o[s], i = 0;\n if (m) return m.call(o);\n if (o && typeof o.length === \"number\") return {\n next: function () {\n if (o && i >= o.length) o = void 0;\n return { value: o && o[i++], done: !o };\n }\n };\n throw new TypeError(s ? \"Object is not iterable.\" : \"Symbol.iterator is not defined.\");\n}\n\nexport function __read(o, n) {\n var m = typeof Symbol === \"function\" && o[Symbol.iterator];\n if (!m) return o;\n var i = m.call(o), r, ar = [], e;\n try {\n while ((n === void 0 || n-- > 0) && !(r = i.next()).done) ar.push(r.value);\n }\n catch (error) { e = { error: error }; }\n finally {\n try {\n if (r && !r.done && (m = i[\"return\"])) m.call(i);\n }\n finally { if (e) throw e.error; }\n }\n return ar;\n}\n\n/** @deprecated */\nexport function __spread() {\n for (var ar = [], i = 0; i < arguments.length; i++)\n ar = ar.concat(__read(arguments[i]));\n return ar;\n}\n\n/** @deprecated */\nexport function __spreadArrays() {\n for (var s = 0, i = 0, il = arguments.length; i < il; i++) s += arguments[i].length;\n for (var r = Array(s), k = 0, i = 0; i < il; i++)\n for (var a = arguments[i], j = 0, jl = a.length; j < jl; j++, k++)\n r[k] = a[j];\n return r;\n}\n\nexport function __spreadArray(to, from, pack) {\n if (pack || arguments.length === 2) for (var i = 0, l = from.length, ar; i < l; i++) {\n if (ar || !(i in from)) {\n if (!ar) ar = Array.prototype.slice.call(from, 0, i);\n ar[i] = from[i];\n }\n }\n return to.concat(ar || Array.prototype.slice.call(from));\n}\n\nexport function __await(v) {\n return this instanceof __await ? (this.v = v, this) : new __await(v);\n}\n\nexport function __asyncGenerator(thisArg, _arguments, generator) {\n if (!Symbol.asyncIterator) throw new TypeError(\"Symbol.asyncIterator is not defined.\");\n var g = generator.apply(thisArg, _arguments || []), i, q = [];\n return i = Object.create((typeof AsyncIterator === \"function\" ? AsyncIterator : Object).prototype), verb(\"next\"), verb(\"throw\"), verb(\"return\", awaitReturn), i[Symbol.asyncIterator] = function () { return this; }, i;\n function awaitReturn(f) { return function (v) { return Promise.resolve(v).then(f, reject); }; }\n function verb(n, f) { if (g[n]) { i[n] = function (v) { return new Promise(function (a, b) { q.push([n, v, a, b]) > 1 || resume(n, v); }); }; if (f) i[n] = f(i[n]); } }\n function resume(n, v) { try { step(g[n](v)); } catch (e) { settle(q[0][3], e); } }\n function step(r) { r.value instanceof __await ? Promise.resolve(r.value.v).then(fulfill, reject) : settle(q[0][2], r); }\n function fulfill(value) { resume(\"next\", value); }\n function reject(value) { resume(\"throw\", value); }\n function settle(f, v) { if (f(v), q.shift(), q.length) resume(q[0][0], q[0][1]); }\n}\n\nexport function __asyncDelegator(o) {\n var i, p;\n return i = {}, verb(\"next\"), verb(\"throw\", function (e) { throw e; }), verb(\"return\"), i[Symbol.iterator] = function () { return this; }, i;\n function verb(n, f) { i[n] = o[n] ? function (v) { return (p = !p) ? { value: __await(o[n](v)), done: false } : f ? f(v) : v; } : f; }\n}\n\nexport function __asyncValues(o) {\n if (!Symbol.asyncIterator) throw new TypeError(\"Symbol.asyncIterator is not defined.\");\n var m = o[Symbol.asyncIterator], i;\n return m ? m.call(o) : (o = typeof __values === \"function\" ? __values(o) : o[Symbol.iterator](), i = {}, verb(\"next\"), verb(\"throw\"), verb(\"return\"), i[Symbol.asyncIterator] = function () { return this; }, i);\n function verb(n) { i[n] = o[n] && function (v) { return new Promise(function (resolve, reject) { v = o[n](v), settle(resolve, reject, v.done, v.value); }); }; }\n function settle(resolve, reject, d, v) { Promise.resolve(v).then(function(v) { resolve({ value: v, done: d }); }, reject); }\n}\n\nexport function __makeTemplateObject(cooked, raw) {\n if (Object.defineProperty) { Object.defineProperty(cooked, \"raw\", { value: raw }); } else { cooked.raw = raw; }\n return cooked;\n};\n\nvar __setModuleDefault = Object.create ? (function(o, v) {\n Object.defineProperty(o, \"default\", { enumerable: true, value: v });\n}) : function(o, v) {\n o[\"default\"] = v;\n};\n\nexport function __importStar(mod) {\n if (mod && mod.__esModule) return mod;\n var result = {};\n if (mod != null) for (var k in mod) if (k !== \"default\" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);\n __setModuleDefault(result, mod);\n return result;\n}\n\nexport function __importDefault(mod) {\n return (mod && mod.__esModule) ? mod : { default: mod };\n}\n\nexport function __classPrivateFieldGet(receiver, state, kind, f) {\n if (kind === \"a\" && !f) throw new TypeError(\"Private accessor was defined without a getter\");\n if (typeof state === \"function\" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError(\"Cannot read private member from an object whose class did not declare it\");\n return kind === \"m\" ? f : kind === \"a\" ? f.call(receiver) : f ? f.value : state.get(receiver);\n}\n\nexport function __classPrivateFieldSet(receiver, state, value, kind, f) {\n if (kind === \"m\") throw new TypeError(\"Private method is not writable\");\n if (kind === \"a\" && !f) throw new TypeError(\"Private accessor was defined without a setter\");\n if (typeof state === \"function\" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError(\"Cannot write private member to an object whose class did not declare it\");\n return (kind === \"a\" ? f.call(receiver, value) : f ? f.value = value : state.set(receiver, value)), value;\n}\n\nexport function __classPrivateFieldIn(state, receiver) {\n if (receiver === null || (typeof receiver !== \"object\" && typeof receiver !== \"function\")) throw new TypeError(\"Cannot use 'in' operator on non-object\");\n return typeof state === \"function\" ? receiver === state : state.has(receiver);\n}\n\nexport function __addDisposableResource(env, value, async) {\n if (value !== null && value !== void 0) {\n if (typeof value !== \"object\" && typeof value !== \"function\") throw new TypeError(\"Object expected.\");\n var dispose, inner;\n if (async) {\n if (!Symbol.asyncDispose) throw new TypeError(\"Symbol.asyncDispose is not defined.\");\n dispose = value[Symbol.asyncDispose];\n }\n if (dispose === void 0) {\n if (!Symbol.dispose) throw new TypeError(\"Symbol.dispose is not defined.\");\n dispose = value[Symbol.dispose];\n if (async) inner = dispose;\n }\n if (typeof dispose !== \"function\") throw new TypeError(\"Object not disposable.\");\n if (inner) dispose = function() { try { inner.call(this); } catch (e) { return Promise.reject(e); } };\n env.stack.push({ value: value, dispose: dispose, async: async });\n }\n else if (async) {\n env.stack.push({ async: true });\n }\n return value;\n}\n\nvar _SuppressedError = typeof SuppressedError === \"function\" ? SuppressedError : function (error, suppressed, message) {\n var e = new Error(message);\n return e.name = \"SuppressedError\", e.error = error, e.suppressed = suppressed, e;\n};\n\nexport function __disposeResources(env) {\n function fail(e) {\n env.error = env.hasError ? new _SuppressedError(e, env.error, \"An error was suppressed during disposal.\") : e;\n env.hasError = true;\n }\n var r, s = 0;\n function next() {\n while (r = env.stack.pop()) {\n try {\n if (!r.async && s === 1) return s = 0, env.stack.push(r), Promise.resolve().then(next);\n if (r.dispose) {\n var result = r.dispose.call(r.value);\n if (r.async) return s |= 2, Promise.resolve(result).then(next, function(e) { fail(e); return next(); });\n }\n else s |= 1;\n }\n catch (e) {\n fail(e);\n }\n }\n if (s === 1) return env.hasError ? Promise.reject(env.error) : Promise.resolve();\n if (env.hasError) throw env.error;\n }\n return next();\n}\n\nexport default {\n __extends,\n __assign,\n __rest,\n __decorate,\n __param,\n __metadata,\n __awaiter,\n __generator,\n __createBinding,\n __exportStar,\n __values,\n __read,\n __spread,\n __spreadArrays,\n __spreadArray,\n __await,\n __asyncGenerator,\n __asyncDelegator,\n __asyncValues,\n __makeTemplateObject,\n __importStar,\n __importDefault,\n __classPrivateFieldGet,\n __classPrivateFieldSet,\n __classPrivateFieldIn,\n __addDisposableResource,\n __disposeResources,\n};\n", "/**\n * Returns true if the object is a function.\n * @param value The value to check\n */\nexport function isFunction(value: any): value is (...args: any[]) => any {\n return typeof value === 'function';\n}\n", "/**\n * Used to create Error subclasses until the community moves away from ES5.\n *\n * This is because compiling from TypeScript down to ES5 has issues with subclassing Errors\n * as well as other built-in types: https://github.com/Microsoft/TypeScript/issues/12123\n *\n * @param createImpl A factory function to create the actual constructor implementation. The returned\n * function should be a named function that calls `_super` internally.\n */\nexport function createErrorClass(createImpl: (_super: any) => any): T {\n const _super = (instance: any) => {\n Error.call(instance);\n instance.stack = new Error().stack;\n };\n\n const ctorFunc = createImpl(_super);\n ctorFunc.prototype = Object.create(Error.prototype);\n ctorFunc.prototype.constructor = ctorFunc;\n return ctorFunc;\n}\n", "import { createErrorClass } from './createErrorClass';\n\nexport interface UnsubscriptionError extends Error {\n readonly errors: any[];\n}\n\nexport interface UnsubscriptionErrorCtor {\n /**\n * @deprecated Internal implementation detail. Do not construct error instances.\n * Cannot be tagged as internal: https://github.com/ReactiveX/rxjs/issues/6269\n */\n new (errors: any[]): UnsubscriptionError;\n}\n\n/**\n * An error thrown when one or more errors have occurred during the\n * `unsubscribe` of a {@link Subscription}.\n */\nexport const UnsubscriptionError: UnsubscriptionErrorCtor = createErrorClass(\n (_super) =>\n function UnsubscriptionErrorImpl(this: any, errors: (Error | string)[]) {\n _super(this);\n this.message = errors\n ? `${errors.length} errors occurred during unsubscription:\n${errors.map((err, i) => `${i + 1}) ${err.toString()}`).join('\\n ')}`\n : '';\n this.name = 'UnsubscriptionError';\n this.errors = errors;\n }\n);\n", "/**\n * Removes an item from an array, mutating it.\n * @param arr The array to remove the item from\n * @param item The item to remove\n */\nexport function arrRemove(arr: T[] | undefined | null, item: T) {\n if (arr) {\n const index = arr.indexOf(item);\n 0 <= index && arr.splice(index, 1);\n }\n}\n", "import { isFunction } from './util/isFunction';\nimport { UnsubscriptionError } from './util/UnsubscriptionError';\nimport { SubscriptionLike, TeardownLogic, Unsubscribable } from './types';\nimport { arrRemove } from './util/arrRemove';\n\n/**\n * Represents a disposable resource, such as the execution of an Observable. A\n * Subscription has one important method, `unsubscribe`, that takes no argument\n * and just disposes the resource held by the subscription.\n *\n * Additionally, subscriptions may be grouped together through the `add()`\n * method, which will attach a child Subscription to the current Subscription.\n * When a Subscription is unsubscribed, all its children (and its grandchildren)\n * will be unsubscribed as well.\n */\nexport class Subscription implements SubscriptionLike {\n public static EMPTY = (() => {\n const empty = new Subscription();\n empty.closed = true;\n return empty;\n })();\n\n /**\n * A flag to indicate whether this Subscription has already been unsubscribed.\n */\n public closed = false;\n\n private _parentage: Subscription[] | Subscription | null = null;\n\n /**\n * The list of registered finalizers to execute upon unsubscription. Adding and removing from this\n * list occurs in the {@link #add} and {@link #remove} methods.\n */\n private _finalizers: Exclude[] | null = null;\n\n /**\n * @param initialTeardown A function executed first as part of the finalization\n * process that is kicked off when {@link #unsubscribe} is called.\n */\n constructor(private initialTeardown?: () => void) {}\n\n /**\n * Disposes the resources held by the subscription. May, for instance, cancel\n * an ongoing Observable execution or cancel any other type of work that\n * started when the Subscription was created.\n */\n unsubscribe(): void {\n let errors: any[] | undefined;\n\n if (!this.closed) {\n this.closed = true;\n\n // Remove this from it's parents.\n const { _parentage } = this;\n if (_parentage) {\n this._parentage = null;\n if (Array.isArray(_parentage)) {\n for (const parent of _parentage) {\n parent.remove(this);\n }\n } else {\n _parentage.remove(this);\n }\n }\n\n const { initialTeardown: initialFinalizer } = this;\n if (isFunction(initialFinalizer)) {\n try {\n initialFinalizer();\n } catch (e) {\n errors = e instanceof UnsubscriptionError ? e.errors : [e];\n }\n }\n\n const { _finalizers } = this;\n if (_finalizers) {\n this._finalizers = null;\n for (const finalizer of _finalizers) {\n try {\n execFinalizer(finalizer);\n } catch (err) {\n errors = errors ?? [];\n if (err instanceof UnsubscriptionError) {\n errors = [...errors, ...err.errors];\n } else {\n errors.push(err);\n }\n }\n }\n }\n\n if (errors) {\n throw new UnsubscriptionError(errors);\n }\n }\n }\n\n /**\n * Adds a finalizer to this subscription, so that finalization will be unsubscribed/called\n * when this subscription is unsubscribed. If this subscription is already {@link #closed},\n * because it has already been unsubscribed, then whatever finalizer is passed to it\n * will automatically be executed (unless the finalizer itself is also a closed subscription).\n *\n * Closed Subscriptions cannot be added as finalizers to any subscription. Adding a closed\n * subscription to a any subscription will result in no operation. (A noop).\n *\n * Adding a subscription to itself, or adding `null` or `undefined` will not perform any\n * operation at all. (A noop).\n *\n * `Subscription` instances that are added to this instance will automatically remove themselves\n * if they are unsubscribed. Functions and {@link Unsubscribable} objects that you wish to remove\n * will need to be removed manually with {@link #remove}\n *\n * @param teardown The finalization logic to add to this subscription.\n */\n add(teardown: TeardownLogic): void {\n // Only add the finalizer if it's not undefined\n // and don't add a subscription to itself.\n if (teardown && teardown !== this) {\n if (this.closed) {\n // If this subscription is already closed,\n // execute whatever finalizer is handed to it automatically.\n execFinalizer(teardown);\n } else {\n if (teardown instanceof Subscription) {\n // We don't add closed subscriptions, and we don't add the same subscription\n // twice. Subscription unsubscribe is idempotent.\n if (teardown.closed || teardown._hasParent(this)) {\n return;\n }\n teardown._addParent(this);\n }\n (this._finalizers = this._finalizers ?? []).push(teardown);\n }\n }\n }\n\n /**\n * Checks to see if a this subscription already has a particular parent.\n * This will signal that this subscription has already been added to the parent in question.\n * @param parent the parent to check for\n */\n private _hasParent(parent: Subscription) {\n const { _parentage } = this;\n return _parentage === parent || (Array.isArray(_parentage) && _parentage.includes(parent));\n }\n\n /**\n * Adds a parent to this subscription so it can be removed from the parent if it\n * unsubscribes on it's own.\n *\n * NOTE: THIS ASSUMES THAT {@link _hasParent} HAS ALREADY BEEN CHECKED.\n * @param parent The parent subscription to add\n */\n private _addParent(parent: Subscription) {\n const { _parentage } = this;\n this._parentage = Array.isArray(_parentage) ? (_parentage.push(parent), _parentage) : _parentage ? [_parentage, parent] : parent;\n }\n\n /**\n * Called on a child when it is removed via {@link #remove}.\n * @param parent The parent to remove\n */\n private _removeParent(parent: Subscription) {\n const { _parentage } = this;\n if (_parentage === parent) {\n this._parentage = null;\n } else if (Array.isArray(_parentage)) {\n arrRemove(_parentage, parent);\n }\n }\n\n /**\n * Removes a finalizer from this subscription that was previously added with the {@link #add} method.\n *\n * Note that `Subscription` instances, when unsubscribed, will automatically remove themselves\n * from every other `Subscription` they have been added to. This means that using the `remove` method\n * is not a common thing and should be used thoughtfully.\n *\n * If you add the same finalizer instance of a function or an unsubscribable object to a `Subscription` instance\n * more than once, you will need to call `remove` the same number of times to remove all instances.\n *\n * All finalizer instances are removed to free up memory upon unsubscription.\n *\n * @param teardown The finalizer to remove from this subscription\n */\n remove(teardown: Exclude): void {\n const { _finalizers } = this;\n _finalizers && arrRemove(_finalizers, teardown);\n\n if (teardown instanceof Subscription) {\n teardown._removeParent(this);\n }\n }\n}\n\nexport const EMPTY_SUBSCRIPTION = Subscription.EMPTY;\n\nexport function isSubscription(value: any): value is Subscription {\n return (\n value instanceof Subscription ||\n (value && 'closed' in value && isFunction(value.remove) && isFunction(value.add) && isFunction(value.unsubscribe))\n );\n}\n\nfunction execFinalizer(finalizer: Unsubscribable | (() => void)) {\n if (isFunction(finalizer)) {\n finalizer();\n } else {\n finalizer.unsubscribe();\n }\n}\n", "import { Subscriber } from './Subscriber';\nimport { ObservableNotification } from './types';\n\n/**\n * The {@link GlobalConfig} object for RxJS. It is used to configure things\n * like how to react on unhandled errors.\n */\nexport const config: GlobalConfig = {\n onUnhandledError: null,\n onStoppedNotification: null,\n Promise: undefined,\n useDeprecatedSynchronousErrorHandling: false,\n useDeprecatedNextContext: false,\n};\n\n/**\n * The global configuration object for RxJS, used to configure things\n * like how to react on unhandled errors. Accessible via {@link config}\n * object.\n */\nexport interface GlobalConfig {\n /**\n * A registration point for unhandled errors from RxJS. These are errors that\n * cannot were not handled by consuming code in the usual subscription path. For\n * example, if you have this configured, and you subscribe to an observable without\n * providing an error handler, errors from that subscription will end up here. This\n * will _always_ be called asynchronously on another job in the runtime. This is because\n * we do not want errors thrown in this user-configured handler to interfere with the\n * behavior of the library.\n */\n onUnhandledError: ((err: any) => void) | null;\n\n /**\n * A registration point for notifications that cannot be sent to subscribers because they\n * have completed, errored or have been explicitly unsubscribed. By default, next, complete\n * and error notifications sent to stopped subscribers are noops. However, sometimes callers\n * might want a different behavior. For example, with sources that attempt to report errors\n * to stopped subscribers, a caller can configure RxJS to throw an unhandled error instead.\n * This will _always_ be called asynchronously on another job in the runtime. This is because\n * we do not want errors thrown in this user-configured handler to interfere with the\n * behavior of the library.\n */\n onStoppedNotification: ((notification: ObservableNotification, subscriber: Subscriber) => void) | null;\n\n /**\n * The promise constructor used by default for {@link Observable#toPromise toPromise} and {@link Observable#forEach forEach}\n * methods.\n *\n * @deprecated As of version 8, RxJS will no longer support this sort of injection of a\n * Promise constructor. If you need a Promise implementation other than native promises,\n * please polyfill/patch Promise as you see appropriate. Will be removed in v8.\n */\n Promise?: PromiseConstructorLike;\n\n /**\n * If true, turns on synchronous error rethrowing, which is a deprecated behavior\n * in v6 and higher. This behavior enables bad patterns like wrapping a subscribe\n * call in a try/catch block. It also enables producer interference, a nasty bug\n * where a multicast can be broken for all observers by a downstream consumer with\n * an unhandled error. DO NOT USE THIS FLAG UNLESS IT'S NEEDED TO BUY TIME\n * FOR MIGRATION REASONS.\n *\n * @deprecated As of version 8, RxJS will no longer support synchronous throwing\n * of unhandled errors. All errors will be thrown on a separate call stack to prevent bad\n * behaviors described above. Will be removed in v8.\n */\n useDeprecatedSynchronousErrorHandling: boolean;\n\n /**\n * If true, enables an as-of-yet undocumented feature from v5: The ability to access\n * `unsubscribe()` via `this` context in `next` functions created in observers passed\n * to `subscribe`.\n *\n * This is being removed because the performance was severely problematic, and it could also cause\n * issues when types other than POJOs are passed to subscribe as subscribers, as they will likely have\n * their `this` context overwritten.\n *\n * @deprecated As of version 8, RxJS will no longer support altering the\n * context of next functions provided as part of an observer to Subscribe. Instead,\n * you will have access to a subscription or a signal or token that will allow you to do things like\n * unsubscribe and test closed status. Will be removed in v8.\n */\n useDeprecatedNextContext: boolean;\n}\n", "import type { TimerHandle } from './timerHandle';\ntype SetTimeoutFunction = (handler: () => void, timeout?: number, ...args: any[]) => TimerHandle;\ntype ClearTimeoutFunction = (handle: TimerHandle) => void;\n\ninterface TimeoutProvider {\n setTimeout: SetTimeoutFunction;\n clearTimeout: ClearTimeoutFunction;\n delegate:\n | {\n setTimeout: SetTimeoutFunction;\n clearTimeout: ClearTimeoutFunction;\n }\n | undefined;\n}\n\nexport const timeoutProvider: TimeoutProvider = {\n // When accessing the delegate, use the variable rather than `this` so that\n // the functions can be called without being bound to the provider.\n setTimeout(handler: () => void, timeout?: number, ...args) {\n const { delegate } = timeoutProvider;\n if (delegate?.setTimeout) {\n return delegate.setTimeout(handler, timeout, ...args);\n }\n return setTimeout(handler, timeout, ...args);\n },\n clearTimeout(handle) {\n const { delegate } = timeoutProvider;\n return (delegate?.clearTimeout || clearTimeout)(handle as any);\n },\n delegate: undefined,\n};\n", "import { config } from '../config';\nimport { timeoutProvider } from '../scheduler/timeoutProvider';\n\n/**\n * Handles an error on another job either with the user-configured {@link onUnhandledError},\n * or by throwing it on that new job so it can be picked up by `window.onerror`, `process.on('error')`, etc.\n *\n * This should be called whenever there is an error that is out-of-band with the subscription\n * or when an error hits a terminal boundary of the subscription and no error handler was provided.\n *\n * @param err the error to report\n */\nexport function reportUnhandledError(err: any) {\n timeoutProvider.setTimeout(() => {\n const { onUnhandledError } = config;\n if (onUnhandledError) {\n // Execute the user-configured error handler.\n onUnhandledError(err);\n } else {\n // Throw so it is picked up by the runtime's uncaught error mechanism.\n throw err;\n }\n });\n}\n", "/* tslint:disable:no-empty */\nexport function noop() { }\n", "import { CompleteNotification, NextNotification, ErrorNotification } from './types';\n\n/**\n * A completion object optimized for memory use and created to be the\n * same \"shape\" as other notifications in v8.\n * @internal\n */\nexport const COMPLETE_NOTIFICATION = (() => createNotification('C', undefined, undefined) as CompleteNotification)();\n\n/**\n * Internal use only. Creates an optimized error notification that is the same \"shape\"\n * as other notifications.\n * @internal\n */\nexport function errorNotification(error: any): ErrorNotification {\n return createNotification('E', undefined, error) as any;\n}\n\n/**\n * Internal use only. Creates an optimized next notification that is the same \"shape\"\n * as other notifications.\n * @internal\n */\nexport function nextNotification(value: T) {\n return createNotification('N', value, undefined) as NextNotification;\n}\n\n/**\n * Ensures that all notifications created internally have the same \"shape\" in v8.\n *\n * TODO: This is only exported to support a crazy legacy test in `groupBy`.\n * @internal\n */\nexport function createNotification(kind: 'N' | 'E' | 'C', value: any, error: any) {\n return {\n kind,\n value,\n error,\n };\n}\n", "import { config } from '../config';\n\nlet context: { errorThrown: boolean; error: any } | null = null;\n\n/**\n * Handles dealing with errors for super-gross mode. Creates a context, in which\n * any synchronously thrown errors will be passed to {@link captureError}. Which\n * will record the error such that it will be rethrown after the call back is complete.\n * TODO: Remove in v8\n * @param cb An immediately executed function.\n */\nexport function errorContext(cb: () => void) {\n if (config.useDeprecatedSynchronousErrorHandling) {\n const isRoot = !context;\n if (isRoot) {\n context = { errorThrown: false, error: null };\n }\n cb();\n if (isRoot) {\n const { errorThrown, error } = context!;\n context = null;\n if (errorThrown) {\n throw error;\n }\n }\n } else {\n // This is the general non-deprecated path for everyone that\n // isn't crazy enough to use super-gross mode (useDeprecatedSynchronousErrorHandling)\n cb();\n }\n}\n\n/**\n * Captures errors only in super-gross mode.\n * @param err the error to capture\n */\nexport function captureError(err: any) {\n if (config.useDeprecatedSynchronousErrorHandling && context) {\n context.errorThrown = true;\n context.error = err;\n }\n}\n", "import { isFunction } from './util/isFunction';\nimport { Observer, ObservableNotification } from './types';\nimport { isSubscription, Subscription } from './Subscription';\nimport { config } from './config';\nimport { reportUnhandledError } from './util/reportUnhandledError';\nimport { noop } from './util/noop';\nimport { nextNotification, errorNotification, COMPLETE_NOTIFICATION } from './NotificationFactories';\nimport { timeoutProvider } from './scheduler/timeoutProvider';\nimport { captureError } from './util/errorContext';\n\n/**\n * Implements the {@link Observer} interface and extends the\n * {@link Subscription} class. While the {@link Observer} is the public API for\n * consuming the values of an {@link Observable}, all Observers get converted to\n * a Subscriber, in order to provide Subscription-like capabilities such as\n * `unsubscribe`. Subscriber is a common type in RxJS, and crucial for\n * implementing operators, but it is rarely used as a public API.\n */\nexport class Subscriber extends Subscription implements Observer {\n /**\n * A static factory for a Subscriber, given a (potentially partial) definition\n * of an Observer.\n * @param next The `next` callback of an Observer.\n * @param error The `error` callback of an\n * Observer.\n * @param complete The `complete` callback of an\n * Observer.\n * @return A Subscriber wrapping the (partially defined)\n * Observer represented by the given arguments.\n * @deprecated Do not use. Will be removed in v8. There is no replacement for this\n * method, and there is no reason to be creating instances of `Subscriber` directly.\n * If you have a specific use case, please file an issue.\n */\n static create(next?: (x?: T) => void, error?: (e?: any) => void, complete?: () => void): Subscriber {\n return new SafeSubscriber(next, error, complete);\n }\n\n /** @deprecated Internal implementation detail, do not use directly. Will be made internal in v8. */\n protected isStopped: boolean = false;\n /** @deprecated Internal implementation detail, do not use directly. Will be made internal in v8. */\n protected destination: Subscriber | Observer; // this `any` is the escape hatch to erase extra type param (e.g. R)\n\n /**\n * @deprecated Internal implementation detail, do not use directly. Will be made internal in v8.\n * There is no reason to directly create an instance of Subscriber. This type is exported for typings reasons.\n */\n constructor(destination?: Subscriber | Observer) {\n super();\n if (destination) {\n this.destination = destination;\n // Automatically chain subscriptions together here.\n // if destination is a Subscription, then it is a Subscriber.\n if (isSubscription(destination)) {\n destination.add(this);\n }\n } else {\n this.destination = EMPTY_OBSERVER;\n }\n }\n\n /**\n * The {@link Observer} callback to receive notifications of type `next` from\n * the Observable, with a value. The Observable may call this method 0 or more\n * times.\n * @param value The `next` value.\n */\n next(value: T): void {\n if (this.isStopped) {\n handleStoppedNotification(nextNotification(value), this);\n } else {\n this._next(value!);\n }\n }\n\n /**\n * The {@link Observer} callback to receive notifications of type `error` from\n * the Observable, with an attached `Error`. Notifies the Observer that\n * the Observable has experienced an error condition.\n * @param err The `error` exception.\n */\n error(err?: any): void {\n if (this.isStopped) {\n handleStoppedNotification(errorNotification(err), this);\n } else {\n this.isStopped = true;\n this._error(err);\n }\n }\n\n /**\n * The {@link Observer} callback to receive a valueless notification of type\n * `complete` from the Observable. Notifies the Observer that the Observable\n * has finished sending push-based notifications.\n */\n complete(): void {\n if (this.isStopped) {\n handleStoppedNotification(COMPLETE_NOTIFICATION, this);\n } else {\n this.isStopped = true;\n this._complete();\n }\n }\n\n unsubscribe(): void {\n if (!this.closed) {\n this.isStopped = true;\n super.unsubscribe();\n this.destination = null!;\n }\n }\n\n protected _next(value: T): void {\n this.destination.next(value);\n }\n\n protected _error(err: any): void {\n try {\n this.destination.error(err);\n } finally {\n this.unsubscribe();\n }\n }\n\n protected _complete(): void {\n try {\n this.destination.complete();\n } finally {\n this.unsubscribe();\n }\n }\n}\n\n/**\n * This bind is captured here because we want to be able to have\n * compatibility with monoid libraries that tend to use a method named\n * `bind`. In particular, a library called Monio requires this.\n */\nconst _bind = Function.prototype.bind;\n\nfunction bind any>(fn: Fn, thisArg: any): Fn {\n return _bind.call(fn, thisArg);\n}\n\n/**\n * Internal optimization only, DO NOT EXPOSE.\n * @internal\n */\nclass ConsumerObserver implements Observer {\n constructor(private partialObserver: Partial>) {}\n\n next(value: T): void {\n const { partialObserver } = this;\n if (partialObserver.next) {\n try {\n partialObserver.next(value);\n } catch (error) {\n handleUnhandledError(error);\n }\n }\n }\n\n error(err: any): void {\n const { partialObserver } = this;\n if (partialObserver.error) {\n try {\n partialObserver.error(err);\n } catch (error) {\n handleUnhandledError(error);\n }\n } else {\n handleUnhandledError(err);\n }\n }\n\n complete(): void {\n const { partialObserver } = this;\n if (partialObserver.complete) {\n try {\n partialObserver.complete();\n } catch (error) {\n handleUnhandledError(error);\n }\n }\n }\n}\n\nexport class SafeSubscriber extends Subscriber {\n constructor(\n observerOrNext?: Partial> | ((value: T) => void) | null,\n error?: ((e?: any) => void) | null,\n complete?: (() => void) | null\n ) {\n super();\n\n let partialObserver: Partial>;\n if (isFunction(observerOrNext) || !observerOrNext) {\n // The first argument is a function, not an observer. The next\n // two arguments *could* be observers, or they could be empty.\n partialObserver = {\n next: (observerOrNext ?? undefined) as ((value: T) => void) | undefined,\n error: error ?? undefined,\n complete: complete ?? undefined,\n };\n } else {\n // The first argument is a partial observer.\n let context: any;\n if (this && config.useDeprecatedNextContext) {\n // This is a deprecated path that made `this.unsubscribe()` available in\n // next handler functions passed to subscribe. This only exists behind a flag\n // now, as it is *very* slow.\n context = Object.create(observerOrNext);\n context.unsubscribe = () => this.unsubscribe();\n partialObserver = {\n next: observerOrNext.next && bind(observerOrNext.next, context),\n error: observerOrNext.error && bind(observerOrNext.error, context),\n complete: observerOrNext.complete && bind(observerOrNext.complete, context),\n };\n } else {\n // The \"normal\" path. Just use the partial observer directly.\n partialObserver = observerOrNext;\n }\n }\n\n // Wrap the partial observer to ensure it's a full observer, and\n // make sure proper error handling is accounted for.\n this.destination = new ConsumerObserver(partialObserver);\n }\n}\n\nfunction handleUnhandledError(error: any) {\n if (config.useDeprecatedSynchronousErrorHandling) {\n captureError(error);\n } else {\n // Ideal path, we report this as an unhandled error,\n // which is thrown on a new call stack.\n reportUnhandledError(error);\n }\n}\n\n/**\n * An error handler used when no error handler was supplied\n * to the SafeSubscriber -- meaning no error handler was supplied\n * do the `subscribe` call on our observable.\n * @param err The error to handle\n */\nfunction defaultErrorHandler(err: any) {\n throw err;\n}\n\n/**\n * A handler for notifications that cannot be sent to a stopped subscriber.\n * @param notification The notification being sent.\n * @param subscriber The stopped subscriber.\n */\nfunction handleStoppedNotification(notification: ObservableNotification, subscriber: Subscriber) {\n const { onStoppedNotification } = config;\n onStoppedNotification && timeoutProvider.setTimeout(() => onStoppedNotification(notification, subscriber));\n}\n\n/**\n * The observer used as a stub for subscriptions where the user did not\n * pass any arguments to `subscribe`. Comes with the default error handling\n * behavior.\n */\nexport const EMPTY_OBSERVER: Readonly> & { closed: true } = {\n closed: true,\n next: noop,\n error: defaultErrorHandler,\n complete: noop,\n};\n", "/**\n * Symbol.observable or a string \"@@observable\". Used for interop\n *\n * @deprecated We will no longer be exporting this symbol in upcoming versions of RxJS.\n * Instead polyfill and use Symbol.observable directly *or* use https://www.npmjs.com/package/symbol-observable\n */\nexport const observable: string | symbol = (() => (typeof Symbol === 'function' && Symbol.observable) || '@@observable')();\n", "/**\n * This function takes one parameter and just returns it. Simply put,\n * this is like `(x: T): T => x`.\n *\n * ## Examples\n *\n * This is useful in some cases when using things like `mergeMap`\n *\n * ```ts\n * import { interval, take, map, range, mergeMap, identity } from 'rxjs';\n *\n * const source$ = interval(1000).pipe(take(5));\n *\n * const result$ = source$.pipe(\n * map(i => range(i)),\n * mergeMap(identity) // same as mergeMap(x => x)\n * );\n *\n * result$.subscribe({\n * next: console.log\n * });\n * ```\n *\n * Or when you want to selectively apply an operator\n *\n * ```ts\n * import { interval, take, identity } from 'rxjs';\n *\n * const shouldLimit = () => Math.random() < 0.5;\n *\n * const source$ = interval(1000);\n *\n * const result$ = source$.pipe(shouldLimit() ? take(5) : identity);\n *\n * result$.subscribe({\n * next: console.log\n * });\n * ```\n *\n * @param x Any value that is returned by this function\n * @returns The value passed as the first parameter to this function\n */\nexport function identity(x: T): T {\n return x;\n}\n", "import { identity } from './identity';\nimport { UnaryFunction } from '../types';\n\nexport function pipe(): typeof identity;\nexport function pipe(fn1: UnaryFunction): UnaryFunction;\nexport function pipe(fn1: UnaryFunction, fn2: UnaryFunction): UnaryFunction;\nexport function pipe(fn1: UnaryFunction, fn2: UnaryFunction, fn3: UnaryFunction): UnaryFunction;\nexport function pipe(\n fn1: UnaryFunction,\n fn2: UnaryFunction,\n fn3: UnaryFunction,\n fn4: UnaryFunction\n): UnaryFunction;\nexport function pipe(\n fn1: UnaryFunction,\n fn2: UnaryFunction,\n fn3: UnaryFunction,\n fn4: UnaryFunction,\n fn5: UnaryFunction\n): UnaryFunction;\nexport function pipe(\n fn1: UnaryFunction,\n fn2: UnaryFunction,\n fn3: UnaryFunction,\n fn4: UnaryFunction,\n fn5: UnaryFunction,\n fn6: UnaryFunction\n): UnaryFunction;\nexport function pipe(\n fn1: UnaryFunction,\n fn2: UnaryFunction,\n fn3: UnaryFunction,\n fn4: UnaryFunction,\n fn5: UnaryFunction,\n fn6: UnaryFunction,\n fn7: UnaryFunction\n): UnaryFunction;\nexport function pipe(\n fn1: UnaryFunction,\n fn2: UnaryFunction,\n fn3: UnaryFunction,\n fn4: UnaryFunction,\n fn5: UnaryFunction,\n fn6: UnaryFunction,\n fn7: UnaryFunction,\n fn8: UnaryFunction\n): UnaryFunction;\nexport function pipe(\n fn1: UnaryFunction,\n fn2: UnaryFunction,\n fn3: UnaryFunction,\n fn4: UnaryFunction,\n fn5: UnaryFunction,\n fn6: UnaryFunction,\n fn7: UnaryFunction,\n fn8: UnaryFunction,\n fn9: UnaryFunction\n): UnaryFunction;\nexport function pipe(\n fn1: UnaryFunction,\n fn2: UnaryFunction,\n fn3: UnaryFunction,\n fn4: UnaryFunction,\n fn5: UnaryFunction,\n fn6: UnaryFunction,\n fn7: UnaryFunction,\n fn8: UnaryFunction,\n fn9: UnaryFunction,\n ...fns: UnaryFunction[]\n): UnaryFunction;\n\n/**\n * pipe() can be called on one or more functions, each of which can take one argument (\"UnaryFunction\")\n * and uses it to return a value.\n * It returns a function that takes one argument, passes it to the first UnaryFunction, and then\n * passes the result to the next one, passes that result to the next one, and so on. \n */\nexport function pipe(...fns: Array>): UnaryFunction {\n return pipeFromArray(fns);\n}\n\n/** @internal */\nexport function pipeFromArray(fns: Array>): UnaryFunction {\n if (fns.length === 0) {\n return identity as UnaryFunction;\n }\n\n if (fns.length === 1) {\n return fns[0];\n }\n\n return function piped(input: T): R {\n return fns.reduce((prev: any, fn: UnaryFunction) => fn(prev), input as any);\n };\n}\n", "import { Operator } from './Operator';\nimport { SafeSubscriber, Subscriber } from './Subscriber';\nimport { isSubscription, Subscription } from './Subscription';\nimport { TeardownLogic, OperatorFunction, Subscribable, Observer } from './types';\nimport { observable as Symbol_observable } from './symbol/observable';\nimport { pipeFromArray } from './util/pipe';\nimport { config } from './config';\nimport { isFunction } from './util/isFunction';\nimport { errorContext } from './util/errorContext';\n\n/**\n * A representation of any set of values over any amount of time. This is the most basic building block\n * of RxJS.\n */\nexport class Observable implements Subscribable {\n /**\n * @deprecated Internal implementation detail, do not use directly. Will be made internal in v8.\n */\n source: Observable | undefined;\n\n /**\n * @deprecated Internal implementation detail, do not use directly. Will be made internal in v8.\n */\n operator: Operator | undefined;\n\n /**\n * @param subscribe The function that is called when the Observable is\n * initially subscribed to. This function is given a Subscriber, to which new values\n * can be `next`ed, or an `error` method can be called to raise an error, or\n * `complete` can be called to notify of a successful completion.\n */\n constructor(subscribe?: (this: Observable, subscriber: Subscriber) => TeardownLogic) {\n if (subscribe) {\n this._subscribe = subscribe;\n }\n }\n\n // HACK: Since TypeScript inherits static properties too, we have to\n // fight against TypeScript here so Subject can have a different static create signature\n /**\n * Creates a new Observable by calling the Observable constructor\n * @param subscribe the subscriber function to be passed to the Observable constructor\n * @return A new observable.\n * @deprecated Use `new Observable()` instead. Will be removed in v8.\n */\n static create: (...args: any[]) => any = (subscribe?: (subscriber: Subscriber) => TeardownLogic) => {\n return new Observable(subscribe);\n };\n\n /**\n * Creates a new Observable, with this Observable instance as the source, and the passed\n * operator defined as the new observable's operator.\n * @param operator the operator defining the operation to take on the observable\n * @return A new observable with the Operator applied.\n * @deprecated Internal implementation detail, do not use directly. Will be made internal in v8.\n * If you have implemented an operator using `lift`, it is recommended that you create an\n * operator by simply returning `new Observable()` directly. See \"Creating new operators from\n * scratch\" section here: https://rxjs.dev/guide/operators\n */\n lift(operator?: Operator): Observable {\n const observable = new Observable();\n observable.source = this;\n observable.operator = operator;\n return observable;\n }\n\n subscribe(observerOrNext?: Partial> | ((value: T) => void)): Subscription;\n /** @deprecated Instead of passing separate callback arguments, use an observer argument. Signatures taking separate callback arguments will be removed in v8. Details: https://rxjs.dev/deprecations/subscribe-arguments */\n subscribe(next?: ((value: T) => void) | null, error?: ((error: any) => void) | null, complete?: (() => void) | null): Subscription;\n /**\n * Invokes an execution of an Observable and registers Observer handlers for notifications it will emit.\n *\n * Use it when you have all these Observables, but still nothing is happening.\n *\n * `subscribe` is not a regular operator, but a method that calls Observable's internal `subscribe` function. It\n * might be for example a function that you passed to Observable's constructor, but most of the time it is\n * a library implementation, which defines what will be emitted by an Observable, and when it be will emitted. This means\n * that calling `subscribe` is actually the moment when Observable starts its work, not when it is created, as it is often\n * the thought.\n *\n * Apart from starting the execution of an Observable, this method allows you to listen for values\n * that an Observable emits, as well as for when it completes or errors. You can achieve this in two\n * of the following ways.\n *\n * The first way is creating an object that implements {@link Observer} interface. It should have methods\n * defined by that interface, but note that it should be just a regular JavaScript object, which you can create\n * yourself in any way you want (ES6 class, classic function constructor, object literal etc.). In particular, do\n * not attempt to use any RxJS implementation details to create Observers - you don't need them. Remember also\n * that your object does not have to implement all methods. If you find yourself creating a method that doesn't\n * do anything, you can simply omit it. Note however, if the `error` method is not provided and an error happens,\n * it will be thrown asynchronously. Errors thrown asynchronously cannot be caught using `try`/`catch`. Instead,\n * use the {@link onUnhandledError} configuration option or use a runtime handler (like `window.onerror` or\n * `process.on('error)`) to be notified of unhandled errors. Because of this, it's recommended that you provide\n * an `error` method to avoid missing thrown errors.\n *\n * The second way is to give up on Observer object altogether and simply provide callback functions in place of its methods.\n * This means you can provide three functions as arguments to `subscribe`, where the first function is equivalent\n * of a `next` method, the second of an `error` method and the third of a `complete` method. Just as in case of an Observer,\n * if you do not need to listen for something, you can omit a function by passing `undefined` or `null`,\n * since `subscribe` recognizes these functions by where they were placed in function call. When it comes\n * to the `error` function, as with an Observer, if not provided, errors emitted by an Observable will be thrown asynchronously.\n *\n * You can, however, subscribe with no parameters at all. This may be the case where you're not interested in terminal events\n * and you also handled emissions internally by using operators (e.g. using `tap`).\n *\n * Whichever style of calling `subscribe` you use, in both cases it returns a Subscription object.\n * This object allows you to call `unsubscribe` on it, which in turn will stop the work that an Observable does and will clean\n * up all resources that an Observable used. Note that cancelling a subscription will not call `complete` callback\n * provided to `subscribe` function, which is reserved for a regular completion signal that comes from an Observable.\n *\n * Remember that callbacks provided to `subscribe` are not guaranteed to be called asynchronously.\n * It is an Observable itself that decides when these functions will be called. For example {@link of}\n * by default emits all its values synchronously. Always check documentation for how given Observable\n * will behave when subscribed and if its default behavior can be modified with a `scheduler`.\n *\n * #### Examples\n *\n * Subscribe with an {@link guide/observer Observer}\n *\n * ```ts\n * import { of } from 'rxjs';\n *\n * const sumObserver = {\n * sum: 0,\n * next(value) {\n * console.log('Adding: ' + value);\n * this.sum = this.sum + value;\n * },\n * error() {\n * // We actually could just remove this method,\n * // since we do not really care about errors right now.\n * },\n * complete() {\n * console.log('Sum equals: ' + this.sum);\n * }\n * };\n *\n * of(1, 2, 3) // Synchronously emits 1, 2, 3 and then completes.\n * .subscribe(sumObserver);\n *\n * // Logs:\n * // 'Adding: 1'\n * // 'Adding: 2'\n * // 'Adding: 3'\n * // 'Sum equals: 6'\n * ```\n *\n * Subscribe with functions ({@link deprecations/subscribe-arguments deprecated})\n *\n * ```ts\n * import { of } from 'rxjs'\n *\n * let sum = 0;\n *\n * of(1, 2, 3).subscribe(\n * value => {\n * console.log('Adding: ' + value);\n * sum = sum + value;\n * },\n * undefined,\n * () => console.log('Sum equals: ' + sum)\n * );\n *\n * // Logs:\n * // 'Adding: 1'\n * // 'Adding: 2'\n * // 'Adding: 3'\n * // 'Sum equals: 6'\n * ```\n *\n * Cancel a subscription\n *\n * ```ts\n * import { interval } from 'rxjs';\n *\n * const subscription = interval(1000).subscribe({\n * next(num) {\n * console.log(num)\n * },\n * complete() {\n * // Will not be called, even when cancelling subscription.\n * console.log('completed!');\n * }\n * });\n *\n * setTimeout(() => {\n * subscription.unsubscribe();\n * console.log('unsubscribed!');\n * }, 2500);\n *\n * // Logs:\n * // 0 after 1s\n * // 1 after 2s\n * // 'unsubscribed!' after 2.5s\n * ```\n *\n * @param observerOrNext Either an {@link Observer} with some or all callback methods,\n * or the `next` handler that is called for each value emitted from the subscribed Observable.\n * @param error A handler for a terminal event resulting from an error. If no error handler is provided,\n * the error will be thrown asynchronously as unhandled.\n * @param complete A handler for a terminal event resulting from successful completion.\n * @return A subscription reference to the registered handlers.\n */\n subscribe(\n observerOrNext?: Partial> | ((value: T) => void) | null,\n error?: ((error: any) => void) | null,\n complete?: (() => void) | null\n ): Subscription {\n const subscriber = isSubscriber(observerOrNext) ? observerOrNext : new SafeSubscriber(observerOrNext, error, complete);\n\n errorContext(() => {\n const { operator, source } = this;\n subscriber.add(\n operator\n ? // We're dealing with a subscription in the\n // operator chain to one of our lifted operators.\n operator.call(subscriber, source)\n : source\n ? // If `source` has a value, but `operator` does not, something that\n // had intimate knowledge of our API, like our `Subject`, must have\n // set it. We're going to just call `_subscribe` directly.\n this._subscribe(subscriber)\n : // In all other cases, we're likely wrapping a user-provided initializer\n // function, so we need to catch errors and handle them appropriately.\n this._trySubscribe(subscriber)\n );\n });\n\n return subscriber;\n }\n\n /** @internal */\n protected _trySubscribe(sink: Subscriber): TeardownLogic {\n try {\n return this._subscribe(sink);\n } catch (err) {\n // We don't need to return anything in this case,\n // because it's just going to try to `add()` to a subscription\n // above.\n sink.error(err);\n }\n }\n\n /**\n * Used as a NON-CANCELLABLE means of subscribing to an observable, for use with\n * APIs that expect promises, like `async/await`. You cannot unsubscribe from this.\n *\n * **WARNING**: Only use this with observables you *know* will complete. If the source\n * observable does not complete, you will end up with a promise that is hung up, and\n * potentially all of the state of an async function hanging out in memory. To avoid\n * this situation, look into adding something like {@link timeout}, {@link take},\n * {@link takeWhile}, or {@link takeUntil} amongst others.\n *\n * #### Example\n *\n * ```ts\n * import { interval, take } from 'rxjs';\n *\n * const source$ = interval(1000).pipe(take(4));\n *\n * async function getTotal() {\n * let total = 0;\n *\n * await source$.forEach(value => {\n * total += value;\n * console.log('observable -> ' + value);\n * });\n *\n * return total;\n * }\n *\n * getTotal().then(\n * total => console.log('Total: ' + total)\n * );\n *\n * // Expected:\n * // 'observable -> 0'\n * // 'observable -> 1'\n * // 'observable -> 2'\n * // 'observable -> 3'\n * // 'Total: 6'\n * ```\n *\n * @param next A handler for each value emitted by the observable.\n * @return A promise that either resolves on observable completion or\n * rejects with the handled error.\n */\n forEach(next: (value: T) => void): Promise;\n\n /**\n * @param next a handler for each value emitted by the observable\n * @param promiseCtor a constructor function used to instantiate the Promise\n * @return a promise that either resolves on observable completion or\n * rejects with the handled error\n * @deprecated Passing a Promise constructor will no longer be available\n * in upcoming versions of RxJS. This is because it adds weight to the library, for very\n * little benefit. If you need this functionality, it is recommended that you either\n * polyfill Promise, or you create an adapter to convert the returned native promise\n * to whatever promise implementation you wanted. Will be removed in v8.\n */\n forEach(next: (value: T) => void, promiseCtor: PromiseConstructorLike): Promise;\n\n forEach(next: (value: T) => void, promiseCtor?: PromiseConstructorLike): Promise {\n promiseCtor = getPromiseCtor(promiseCtor);\n\n return new promiseCtor((resolve, reject) => {\n const subscriber = new SafeSubscriber({\n next: (value) => {\n try {\n next(value);\n } catch (err) {\n reject(err);\n subscriber.unsubscribe();\n }\n },\n error: reject,\n complete: resolve,\n });\n this.subscribe(subscriber);\n }) as Promise;\n }\n\n /** @internal */\n protected _subscribe(subscriber: Subscriber): TeardownLogic {\n return this.source?.subscribe(subscriber);\n }\n\n /**\n * An interop point defined by the es7-observable spec https://github.com/zenparsing/es-observable\n * @return This instance of the observable.\n */\n [Symbol_observable]() {\n return this;\n }\n\n /* tslint:disable:max-line-length */\n pipe(): Observable;\n pipe(op1: OperatorFunction): Observable;\n pipe(op1: OperatorFunction, op2: OperatorFunction): Observable;\n pipe(op1: OperatorFunction, op2: OperatorFunction, op3: OperatorFunction): Observable;\n pipe(\n op1: OperatorFunction,\n op2: OperatorFunction,\n op3: OperatorFunction,\n op4: OperatorFunction\n ): Observable;\n pipe(\n op1: OperatorFunction,\n op2: OperatorFunction,\n op3: OperatorFunction,\n op4: OperatorFunction,\n op5: OperatorFunction\n ): Observable;\n pipe(\n op1: OperatorFunction,\n op2: OperatorFunction,\n op3: OperatorFunction,\n op4: OperatorFunction,\n op5: OperatorFunction,\n op6: OperatorFunction\n ): Observable;\n pipe(\n op1: OperatorFunction,\n op2: OperatorFunction,\n op3: OperatorFunction,\n op4: OperatorFunction,\n op5: OperatorFunction,\n op6: OperatorFunction,\n op7: OperatorFunction\n ): Observable;\n pipe(\n op1: OperatorFunction,\n op2: OperatorFunction,\n op3: OperatorFunction,\n op4: OperatorFunction,\n op5: OperatorFunction,\n op6: OperatorFunction,\n op7: OperatorFunction,\n op8: OperatorFunction\n ): Observable;\n pipe(\n op1: OperatorFunction,\n op2: OperatorFunction,\n op3: OperatorFunction,\n op4: OperatorFunction,\n op5: OperatorFunction,\n op6: OperatorFunction,\n op7: OperatorFunction,\n op8: OperatorFunction,\n op9: OperatorFunction\n ): Observable;\n pipe(\n op1: OperatorFunction,\n op2: OperatorFunction,\n op3: OperatorFunction,\n op4: OperatorFunction,\n op5: OperatorFunction,\n op6: OperatorFunction,\n op7: OperatorFunction,\n op8: OperatorFunction,\n op9: OperatorFunction,\n ...operations: OperatorFunction[]\n ): Observable;\n /* tslint:enable:max-line-length */\n\n /**\n * Used to stitch together functional operators into a chain.\n *\n * ## Example\n *\n * ```ts\n * import { interval, filter, map, scan } from 'rxjs';\n *\n * interval(1000)\n * .pipe(\n * filter(x => x % 2 === 0),\n * map(x => x + x),\n * scan((acc, x) => acc + x)\n * )\n * .subscribe(x => console.log(x));\n * ```\n *\n * @return The Observable result of all the operators having been called\n * in the order they were passed in.\n */\n pipe(...operations: OperatorFunction[]): Observable {\n return pipeFromArray(operations)(this);\n }\n\n /* tslint:disable:max-line-length */\n /** @deprecated Replaced with {@link firstValueFrom} and {@link lastValueFrom}. Will be removed in v8. Details: https://rxjs.dev/deprecations/to-promise */\n toPromise(): Promise;\n /** @deprecated Replaced with {@link firstValueFrom} and {@link lastValueFrom}. Will be removed in v8. Details: https://rxjs.dev/deprecations/to-promise */\n toPromise(PromiseCtor: typeof Promise): Promise;\n /** @deprecated Replaced with {@link firstValueFrom} and {@link lastValueFrom}. Will be removed in v8. Details: https://rxjs.dev/deprecations/to-promise */\n toPromise(PromiseCtor: PromiseConstructorLike): Promise;\n /* tslint:enable:max-line-length */\n\n /**\n * Subscribe to this Observable and get a Promise resolving on\n * `complete` with the last emission (if any).\n *\n * **WARNING**: Only use this with observables you *know* will complete. If the source\n * observable does not complete, you will end up with a promise that is hung up, and\n * potentially all of the state of an async function hanging out in memory. To avoid\n * this situation, look into adding something like {@link timeout}, {@link take},\n * {@link takeWhile}, or {@link takeUntil} amongst others.\n *\n * @param [promiseCtor] a constructor function used to instantiate\n * the Promise\n * @return A Promise that resolves with the last value emit, or\n * rejects on an error. If there were no emissions, Promise\n * resolves with undefined.\n * @deprecated Replaced with {@link firstValueFrom} and {@link lastValueFrom}. Will be removed in v8. Details: https://rxjs.dev/deprecations/to-promise\n */\n toPromise(promiseCtor?: PromiseConstructorLike): Promise {\n promiseCtor = getPromiseCtor(promiseCtor);\n\n return new promiseCtor((resolve, reject) => {\n let value: T | undefined;\n this.subscribe(\n (x: T) => (value = x),\n (err: any) => reject(err),\n () => resolve(value)\n );\n }) as Promise;\n }\n}\n\n/**\n * Decides between a passed promise constructor from consuming code,\n * A default configured promise constructor, and the native promise\n * constructor and returns it. If nothing can be found, it will throw\n * an error.\n * @param promiseCtor The optional promise constructor to passed by consuming code\n */\nfunction getPromiseCtor(promiseCtor: PromiseConstructorLike | undefined) {\n return promiseCtor ?? config.Promise ?? Promise;\n}\n\nfunction isObserver(value: any): value is Observer {\n return value && isFunction(value.next) && isFunction(value.error) && isFunction(value.complete);\n}\n\nfunction isSubscriber(value: any): value is Subscriber {\n return (value && value instanceof Subscriber) || (isObserver(value) && isSubscription(value));\n}\n", "import { Observable } from '../Observable';\nimport { Subscriber } from '../Subscriber';\nimport { OperatorFunction } from '../types';\nimport { isFunction } from './isFunction';\n\n/**\n * Used to determine if an object is an Observable with a lift function.\n */\nexport function hasLift(source: any): source is { lift: InstanceType['lift'] } {\n return isFunction(source?.lift);\n}\n\n/**\n * Creates an `OperatorFunction`. Used to define operators throughout the library in a concise way.\n * @param init The logic to connect the liftedSource to the subscriber at the moment of subscription.\n */\nexport function operate(\n init: (liftedSource: Observable, subscriber: Subscriber) => (() => void) | void\n): OperatorFunction {\n return (source: Observable) => {\n if (hasLift(source)) {\n return source.lift(function (this: Subscriber, liftedSource: Observable) {\n try {\n return init(liftedSource, this);\n } catch (err) {\n this.error(err);\n }\n });\n }\n throw new TypeError('Unable to lift unknown Observable type');\n };\n}\n", "import { Subscriber } from '../Subscriber';\n\n/**\n * Creates an instance of an `OperatorSubscriber`.\n * @param destination The downstream subscriber.\n * @param onNext Handles next values, only called if this subscriber is not stopped or closed. Any\n * error that occurs in this function is caught and sent to the `error` method of this subscriber.\n * @param onError Handles errors from the subscription, any errors that occur in this handler are caught\n * and send to the `destination` error handler.\n * @param onComplete Handles completion notification from the subscription. Any errors that occur in\n * this handler are sent to the `destination` error handler.\n * @param onFinalize Additional teardown logic here. This will only be called on teardown if the\n * subscriber itself is not already closed. This is called after all other teardown logic is executed.\n */\nexport function createOperatorSubscriber(\n destination: Subscriber,\n onNext?: (value: T) => void,\n onComplete?: () => void,\n onError?: (err: any) => void,\n onFinalize?: () => void\n): Subscriber {\n return new OperatorSubscriber(destination, onNext, onComplete, onError, onFinalize);\n}\n\n/**\n * A generic helper for allowing operators to be created with a Subscriber and\n * use closures to capture necessary state from the operator function itself.\n */\nexport class OperatorSubscriber extends Subscriber {\n /**\n * Creates an instance of an `OperatorSubscriber`.\n * @param destination The downstream subscriber.\n * @param onNext Handles next values, only called if this subscriber is not stopped or closed. Any\n * error that occurs in this function is caught and sent to the `error` method of this subscriber.\n * @param onError Handles errors from the subscription, any errors that occur in this handler are caught\n * and send to the `destination` error handler.\n * @param onComplete Handles completion notification from the subscription. Any errors that occur in\n * this handler are sent to the `destination` error handler.\n * @param onFinalize Additional finalization logic here. This will only be called on finalization if the\n * subscriber itself is not already closed. This is called after all other finalization logic is executed.\n * @param shouldUnsubscribe An optional check to see if an unsubscribe call should truly unsubscribe.\n * NOTE: This currently **ONLY** exists to support the strange behavior of {@link groupBy}, where unsubscription\n * to the resulting observable does not actually disconnect from the source if there are active subscriptions\n * to any grouped observable. (DO NOT EXPOSE OR USE EXTERNALLY!!!)\n */\n constructor(\n destination: Subscriber,\n onNext?: (value: T) => void,\n onComplete?: () => void,\n onError?: (err: any) => void,\n private onFinalize?: () => void,\n private shouldUnsubscribe?: () => boolean\n ) {\n // It's important - for performance reasons - that all of this class's\n // members are initialized and that they are always initialized in the same\n // order. This will ensure that all OperatorSubscriber instances have the\n // same hidden class in V8. This, in turn, will help keep the number of\n // hidden classes involved in property accesses within the base class as\n // low as possible. If the number of hidden classes involved exceeds four,\n // the property accesses will become megamorphic and performance penalties\n // will be incurred - i.e. inline caches won't be used.\n //\n // The reasons for ensuring all instances have the same hidden class are\n // further discussed in this blog post from Benedikt Meurer:\n // https://benediktmeurer.de/2018/03/23/impact-of-polymorphism-on-component-based-frameworks-like-react/\n super(destination);\n this._next = onNext\n ? function (this: OperatorSubscriber, value: T) {\n try {\n onNext(value);\n } catch (err) {\n destination.error(err);\n }\n }\n : super._next;\n this._error = onError\n ? function (this: OperatorSubscriber, err: any) {\n try {\n onError(err);\n } catch (err) {\n // Send any errors that occur down stream.\n destination.error(err);\n } finally {\n // Ensure finalization.\n this.unsubscribe();\n }\n }\n : super._error;\n this._complete = onComplete\n ? function (this: OperatorSubscriber) {\n try {\n onComplete();\n } catch (err) {\n // Send any errors that occur down stream.\n destination.error(err);\n } finally {\n // Ensure finalization.\n this.unsubscribe();\n }\n }\n : super._complete;\n }\n\n unsubscribe() {\n if (!this.shouldUnsubscribe || this.shouldUnsubscribe()) {\n const { closed } = this;\n super.unsubscribe();\n // Execute additional teardown if we have any and we didn't already do so.\n !closed && this.onFinalize?.();\n }\n }\n}\n", "import { Subscription } from '../Subscription';\n\ninterface AnimationFrameProvider {\n schedule(callback: FrameRequestCallback): Subscription;\n requestAnimationFrame: typeof requestAnimationFrame;\n cancelAnimationFrame: typeof cancelAnimationFrame;\n delegate:\n | {\n requestAnimationFrame: typeof requestAnimationFrame;\n cancelAnimationFrame: typeof cancelAnimationFrame;\n }\n | undefined;\n}\n\nexport const animationFrameProvider: AnimationFrameProvider = {\n // When accessing the delegate, use the variable rather than `this` so that\n // the functions can be called without being bound to the provider.\n schedule(callback) {\n let request = requestAnimationFrame;\n let cancel: typeof cancelAnimationFrame | undefined = cancelAnimationFrame;\n const { delegate } = animationFrameProvider;\n if (delegate) {\n request = delegate.requestAnimationFrame;\n cancel = delegate.cancelAnimationFrame;\n }\n const handle = request((timestamp) => {\n // Clear the cancel function. The request has been fulfilled, so\n // attempting to cancel the request upon unsubscription would be\n // pointless.\n cancel = undefined;\n callback(timestamp);\n });\n return new Subscription(() => cancel?.(handle));\n },\n requestAnimationFrame(...args) {\n const { delegate } = animationFrameProvider;\n return (delegate?.requestAnimationFrame || requestAnimationFrame)(...args);\n },\n cancelAnimationFrame(...args) {\n const { delegate } = animationFrameProvider;\n return (delegate?.cancelAnimationFrame || cancelAnimationFrame)(...args);\n },\n delegate: undefined,\n};\n", "import { createErrorClass } from './createErrorClass';\n\nexport interface ObjectUnsubscribedError extends Error {}\n\nexport interface ObjectUnsubscribedErrorCtor {\n /**\n * @deprecated Internal implementation detail. Do not construct error instances.\n * Cannot be tagged as internal: https://github.com/ReactiveX/rxjs/issues/6269\n */\n new (): ObjectUnsubscribedError;\n}\n\n/**\n * An error thrown when an action is invalid because the object has been\n * unsubscribed.\n *\n * @see {@link Subject}\n * @see {@link BehaviorSubject}\n *\n * @class ObjectUnsubscribedError\n */\nexport const ObjectUnsubscribedError: ObjectUnsubscribedErrorCtor = createErrorClass(\n (_super) =>\n function ObjectUnsubscribedErrorImpl(this: any) {\n _super(this);\n this.name = 'ObjectUnsubscribedError';\n this.message = 'object unsubscribed';\n }\n);\n", "import { Operator } from './Operator';\nimport { Observable } from './Observable';\nimport { Subscriber } from './Subscriber';\nimport { Subscription, EMPTY_SUBSCRIPTION } from './Subscription';\nimport { Observer, SubscriptionLike, TeardownLogic } from './types';\nimport { ObjectUnsubscribedError } from './util/ObjectUnsubscribedError';\nimport { arrRemove } from './util/arrRemove';\nimport { errorContext } from './util/errorContext';\n\n/**\n * A Subject is a special type of Observable that allows values to be\n * multicasted to many Observers. Subjects are like EventEmitters.\n *\n * Every Subject is an Observable and an Observer. You can subscribe to a\n * Subject, and you can call next to feed values as well as error and complete.\n */\nexport class Subject extends Observable implements SubscriptionLike {\n closed = false;\n\n private currentObservers: Observer[] | null = null;\n\n /** @deprecated Internal implementation detail, do not use directly. Will be made internal in v8. */\n observers: Observer[] = [];\n /** @deprecated Internal implementation detail, do not use directly. Will be made internal in v8. */\n isStopped = false;\n /** @deprecated Internal implementation detail, do not use directly. Will be made internal in v8. */\n hasError = false;\n /** @deprecated Internal implementation detail, do not use directly. Will be made internal in v8. */\n thrownError: any = null;\n\n /**\n * Creates a \"subject\" by basically gluing an observer to an observable.\n *\n * @deprecated Recommended you do not use. Will be removed at some point in the future. Plans for replacement still under discussion.\n */\n static create: (...args: any[]) => any = (destination: Observer, source: Observable): AnonymousSubject => {\n return new AnonymousSubject(destination, source);\n };\n\n constructor() {\n // NOTE: This must be here to obscure Observable's constructor.\n super();\n }\n\n /** @deprecated Internal implementation detail, do not use directly. Will be made internal in v8. */\n lift(operator: Operator): Observable {\n const subject = new AnonymousSubject(this, this);\n subject.operator = operator as any;\n return subject as any;\n }\n\n /** @internal */\n protected _throwIfClosed() {\n if (this.closed) {\n throw new ObjectUnsubscribedError();\n }\n }\n\n next(value: T) {\n errorContext(() => {\n this._throwIfClosed();\n if (!this.isStopped) {\n if (!this.currentObservers) {\n this.currentObservers = Array.from(this.observers);\n }\n for (const observer of this.currentObservers) {\n observer.next(value);\n }\n }\n });\n }\n\n error(err: any) {\n errorContext(() => {\n this._throwIfClosed();\n if (!this.isStopped) {\n this.hasError = this.isStopped = true;\n this.thrownError = err;\n const { observers } = this;\n while (observers.length) {\n observers.shift()!.error(err);\n }\n }\n });\n }\n\n complete() {\n errorContext(() => {\n this._throwIfClosed();\n if (!this.isStopped) {\n this.isStopped = true;\n const { observers } = this;\n while (observers.length) {\n observers.shift()!.complete();\n }\n }\n });\n }\n\n unsubscribe() {\n this.isStopped = this.closed = true;\n this.observers = this.currentObservers = null!;\n }\n\n get observed() {\n return this.observers?.length > 0;\n }\n\n /** @internal */\n protected _trySubscribe(subscriber: Subscriber): TeardownLogic {\n this._throwIfClosed();\n return super._trySubscribe(subscriber);\n }\n\n /** @internal */\n protected _subscribe(subscriber: Subscriber): Subscription {\n this._throwIfClosed();\n this._checkFinalizedStatuses(subscriber);\n return this._innerSubscribe(subscriber);\n }\n\n /** @internal */\n protected _innerSubscribe(subscriber: Subscriber) {\n const { hasError, isStopped, observers } = this;\n if (hasError || isStopped) {\n return EMPTY_SUBSCRIPTION;\n }\n this.currentObservers = null;\n observers.push(subscriber);\n return new Subscription(() => {\n this.currentObservers = null;\n arrRemove(observers, subscriber);\n });\n }\n\n /** @internal */\n protected _checkFinalizedStatuses(subscriber: Subscriber) {\n const { hasError, thrownError, isStopped } = this;\n if (hasError) {\n subscriber.error(thrownError);\n } else if (isStopped) {\n subscriber.complete();\n }\n }\n\n /**\n * Creates a new Observable with this Subject as the source. You can do this\n * to create custom Observer-side logic of the Subject and conceal it from\n * code that uses the Observable.\n * @return Observable that this Subject casts to.\n */\n asObservable(): Observable {\n const observable: any = new Observable();\n observable.source = this;\n return observable;\n }\n}\n\nexport class AnonymousSubject extends Subject {\n constructor(\n /** @deprecated Internal implementation detail, do not use directly. Will be made internal in v8. */\n public destination?: Observer,\n source?: Observable\n ) {\n super();\n this.source = source;\n }\n\n next(value: T) {\n this.destination?.next?.(value);\n }\n\n error(err: any) {\n this.destination?.error?.(err);\n }\n\n complete() {\n this.destination?.complete?.();\n }\n\n /** @internal */\n protected _subscribe(subscriber: Subscriber): Subscription {\n return this.source?.subscribe(subscriber) ?? EMPTY_SUBSCRIPTION;\n }\n}\n", "import { Subject } from './Subject';\nimport { Subscriber } from './Subscriber';\nimport { Subscription } from './Subscription';\n\n/**\n * A variant of Subject that requires an initial value and emits its current\n * value whenever it is subscribed to.\n */\nexport class BehaviorSubject extends Subject {\n constructor(private _value: T) {\n super();\n }\n\n get value(): T {\n return this.getValue();\n }\n\n /** @internal */\n protected _subscribe(subscriber: Subscriber): Subscription {\n const subscription = super._subscribe(subscriber);\n !subscription.closed && subscriber.next(this._value);\n return subscription;\n }\n\n getValue(): T {\n const { hasError, thrownError, _value } = this;\n if (hasError) {\n throw thrownError;\n }\n this._throwIfClosed();\n return _value;\n }\n\n next(value: T): void {\n super.next((this._value = value));\n }\n}\n", "import { TimestampProvider } from '../types';\n\ninterface DateTimestampProvider extends TimestampProvider {\n delegate: TimestampProvider | undefined;\n}\n\nexport const dateTimestampProvider: DateTimestampProvider = {\n now() {\n // Use the variable rather than `this` so that the function can be called\n // without being bound to the provider.\n return (dateTimestampProvider.delegate || Date).now();\n },\n delegate: undefined,\n};\n", "import { Subject } from './Subject';\nimport { TimestampProvider } from './types';\nimport { Subscriber } from './Subscriber';\nimport { Subscription } from './Subscription';\nimport { dateTimestampProvider } from './scheduler/dateTimestampProvider';\n\n/**\n * A variant of {@link Subject} that \"replays\" old values to new subscribers by emitting them when they first subscribe.\n *\n * `ReplaySubject` has an internal buffer that will store a specified number of values that it has observed. Like `Subject`,\n * `ReplaySubject` \"observes\" values by having them passed to its `next` method. When it observes a value, it will store that\n * value for a time determined by the configuration of the `ReplaySubject`, as passed to its constructor.\n *\n * When a new subscriber subscribes to the `ReplaySubject` instance, it will synchronously emit all values in its buffer in\n * a First-In-First-Out (FIFO) manner. The `ReplaySubject` will also complete, if it has observed completion; and it will\n * error if it has observed an error.\n *\n * There are two main configuration items to be concerned with:\n *\n * 1. `bufferSize` - This will determine how many items are stored in the buffer, defaults to infinite.\n * 2. `windowTime` - The amount of time to hold a value in the buffer before removing it from the buffer.\n *\n * Both configurations may exist simultaneously. So if you would like to buffer a maximum of 3 values, as long as the values\n * are less than 2 seconds old, you could do so with a `new ReplaySubject(3, 2000)`.\n *\n * ### Differences with BehaviorSubject\n *\n * `BehaviorSubject` is similar to `new ReplaySubject(1)`, with a couple of exceptions:\n *\n * 1. `BehaviorSubject` comes \"primed\" with a single value upon construction.\n * 2. `ReplaySubject` will replay values, even after observing an error, where `BehaviorSubject` will not.\n *\n * @see {@link Subject}\n * @see {@link BehaviorSubject}\n * @see {@link shareReplay}\n */\nexport class ReplaySubject extends Subject {\n private _buffer: (T | number)[] = [];\n private _infiniteTimeWindow = true;\n\n /**\n * @param _bufferSize The size of the buffer to replay on subscription\n * @param _windowTime The amount of time the buffered items will stay buffered\n * @param _timestampProvider An object with a `now()` method that provides the current timestamp. This is used to\n * calculate the amount of time something has been buffered.\n */\n constructor(\n private _bufferSize = Infinity,\n private _windowTime = Infinity,\n private _timestampProvider: TimestampProvider = dateTimestampProvider\n ) {\n super();\n this._infiniteTimeWindow = _windowTime === Infinity;\n this._bufferSize = Math.max(1, _bufferSize);\n this._windowTime = Math.max(1, _windowTime);\n }\n\n next(value: T): void {\n const { isStopped, _buffer, _infiniteTimeWindow, _timestampProvider, _windowTime } = this;\n if (!isStopped) {\n _buffer.push(value);\n !_infiniteTimeWindow && _buffer.push(_timestampProvider.now() + _windowTime);\n }\n this._trimBuffer();\n super.next(value);\n }\n\n /** @internal */\n protected _subscribe(subscriber: Subscriber): Subscription {\n this._throwIfClosed();\n this._trimBuffer();\n\n const subscription = this._innerSubscribe(subscriber);\n\n const { _infiniteTimeWindow, _buffer } = this;\n // We use a copy here, so reentrant code does not mutate our array while we're\n // emitting it to a new subscriber.\n const copy = _buffer.slice();\n for (let i = 0; i < copy.length && !subscriber.closed; i += _infiniteTimeWindow ? 1 : 2) {\n subscriber.next(copy[i] as T);\n }\n\n this._checkFinalizedStatuses(subscriber);\n\n return subscription;\n }\n\n private _trimBuffer() {\n const { _bufferSize, _timestampProvider, _buffer, _infiniteTimeWindow } = this;\n // If we don't have an infinite buffer size, and we're over the length,\n // use splice to truncate the old buffer values off. Note that we have to\n // double the size for instances where we're not using an infinite time window\n // because we're storing the values and the timestamps in the same array.\n const adjustedBufferSize = (_infiniteTimeWindow ? 1 : 2) * _bufferSize;\n _bufferSize < Infinity && adjustedBufferSize < _buffer.length && _buffer.splice(0, _buffer.length - adjustedBufferSize);\n\n // Now, if we're not in an infinite time window, remove all values where the time is\n // older than what is allowed.\n if (!_infiniteTimeWindow) {\n const now = _timestampProvider.now();\n let last = 0;\n // Search the array for the first timestamp that isn't expired and\n // truncate the buffer up to that point.\n for (let i = 1; i < _buffer.length && (_buffer[i] as number) <= now; i += 2) {\n last = i;\n }\n last && _buffer.splice(0, last + 1);\n }\n }\n}\n", "import { Scheduler } from '../Scheduler';\nimport { Subscription } from '../Subscription';\nimport { SchedulerAction } from '../types';\n\n/**\n * A unit of work to be executed in a `scheduler`. An action is typically\n * created from within a {@link SchedulerLike} and an RxJS user does not need to concern\n * themselves about creating and manipulating an Action.\n *\n * ```ts\n * class Action extends Subscription {\n * new (scheduler: Scheduler, work: (state?: T) => void);\n * schedule(state?: T, delay: number = 0): Subscription;\n * }\n * ```\n */\nexport class Action extends Subscription {\n constructor(scheduler: Scheduler, work: (this: SchedulerAction, state?: T) => void) {\n super();\n }\n /**\n * Schedules this action on its parent {@link SchedulerLike} for execution. May be passed\n * some context object, `state`. May happen at some point in the future,\n * according to the `delay` parameter, if specified.\n * @param state Some contextual data that the `work` function uses when called by the\n * Scheduler.\n * @param delay Time to wait before executing the work, where the time unit is implicit\n * and defined by the Scheduler.\n * @return A subscription in order to be able to unsubscribe the scheduled work.\n */\n public schedule(state?: T, delay: number = 0): Subscription {\n return this;\n }\n}\n", "import type { TimerHandle } from './timerHandle';\ntype SetIntervalFunction = (handler: () => void, timeout?: number, ...args: any[]) => TimerHandle;\ntype ClearIntervalFunction = (handle: TimerHandle) => void;\n\ninterface IntervalProvider {\n setInterval: SetIntervalFunction;\n clearInterval: ClearIntervalFunction;\n delegate:\n | {\n setInterval: SetIntervalFunction;\n clearInterval: ClearIntervalFunction;\n }\n | undefined;\n}\n\nexport const intervalProvider: IntervalProvider = {\n // When accessing the delegate, use the variable rather than `this` so that\n // the functions can be called without being bound to the provider.\n setInterval(handler: () => void, timeout?: number, ...args) {\n const { delegate } = intervalProvider;\n if (delegate?.setInterval) {\n return delegate.setInterval(handler, timeout, ...args);\n }\n return setInterval(handler, timeout, ...args);\n },\n clearInterval(handle) {\n const { delegate } = intervalProvider;\n return (delegate?.clearInterval || clearInterval)(handle as any);\n },\n delegate: undefined,\n};\n", "import { Action } from './Action';\nimport { SchedulerAction } from '../types';\nimport { Subscription } from '../Subscription';\nimport { AsyncScheduler } from './AsyncScheduler';\nimport { intervalProvider } from './intervalProvider';\nimport { arrRemove } from '../util/arrRemove';\nimport { TimerHandle } from './timerHandle';\n\nexport class AsyncAction extends Action {\n public id: TimerHandle | undefined;\n public state?: T;\n // @ts-ignore: Property has no initializer and is not definitely assigned\n public delay: number;\n protected pending: boolean = false;\n\n constructor(protected scheduler: AsyncScheduler, protected work: (this: SchedulerAction, state?: T) => void) {\n super(scheduler, work);\n }\n\n public schedule(state?: T, delay: number = 0): Subscription {\n if (this.closed) {\n return this;\n }\n\n // Always replace the current state with the new state.\n this.state = state;\n\n const id = this.id;\n const scheduler = this.scheduler;\n\n //\n // Important implementation note:\n //\n // Actions only execute once by default, unless rescheduled from within the\n // scheduled callback. This allows us to implement single and repeat\n // actions via the same code path, without adding API surface area, as well\n // as mimic traditional recursion but across asynchronous boundaries.\n //\n // However, JS runtimes and timers distinguish between intervals achieved by\n // serial `setTimeout` calls vs. a single `setInterval` call. An interval of\n // serial `setTimeout` calls can be individually delayed, which delays\n // scheduling the next `setTimeout`, and so on. `setInterval` attempts to\n // guarantee the interval callback will be invoked more precisely to the\n // interval period, regardless of load.\n //\n // Therefore, we use `setInterval` to schedule single and repeat actions.\n // If the action reschedules itself with the same delay, the interval is not\n // canceled. If the action doesn't reschedule, or reschedules with a\n // different delay, the interval will be canceled after scheduled callback\n // execution.\n //\n if (id != null) {\n this.id = this.recycleAsyncId(scheduler, id, delay);\n }\n\n // Set the pending flag indicating that this action has been scheduled, or\n // has recursively rescheduled itself.\n this.pending = true;\n\n this.delay = delay;\n // If this action has already an async Id, don't request a new one.\n this.id = this.id ?? this.requestAsyncId(scheduler, this.id, delay);\n\n return this;\n }\n\n protected requestAsyncId(scheduler: AsyncScheduler, _id?: TimerHandle, delay: number = 0): TimerHandle {\n return intervalProvider.setInterval(scheduler.flush.bind(scheduler, this), delay);\n }\n\n protected recycleAsyncId(_scheduler: AsyncScheduler, id?: TimerHandle, delay: number | null = 0): TimerHandle | undefined {\n // If this action is rescheduled with the same delay time, don't clear the interval id.\n if (delay != null && this.delay === delay && this.pending === false) {\n return id;\n }\n // Otherwise, if the action's delay time is different from the current delay,\n // or the action has been rescheduled before it's executed, clear the interval id\n if (id != null) {\n intervalProvider.clearInterval(id);\n }\n\n return undefined;\n }\n\n /**\n * Immediately executes this action and the `work` it contains.\n */\n public execute(state: T, delay: number): any {\n if (this.closed) {\n return new Error('executing a cancelled action');\n }\n\n this.pending = false;\n const error = this._execute(state, delay);\n if (error) {\n return error;\n } else if (this.pending === false && this.id != null) {\n // Dequeue if the action didn't reschedule itself. Don't call\n // unsubscribe(), because the action could reschedule later.\n // For example:\n // ```\n // scheduler.schedule(function doWork(counter) {\n // /* ... I'm a busy worker bee ... */\n // var originalAction = this;\n // /* wait 100ms before rescheduling the action */\n // setTimeout(function () {\n // originalAction.schedule(counter + 1);\n // }, 100);\n // }, 1000);\n // ```\n this.id = this.recycleAsyncId(this.scheduler, this.id, null);\n }\n }\n\n protected _execute(state: T, _delay: number): any {\n let errored: boolean = false;\n let errorValue: any;\n try {\n this.work(state);\n } catch (e) {\n errored = true;\n // HACK: Since code elsewhere is relying on the \"truthiness\" of the\n // return here, we can't have it return \"\" or 0 or false.\n // TODO: Clean this up when we refactor schedulers mid-version-8 or so.\n errorValue = e ? e : new Error('Scheduled action threw falsy error');\n }\n if (errored) {\n this.unsubscribe();\n return errorValue;\n }\n }\n\n unsubscribe() {\n if (!this.closed) {\n const { id, scheduler } = this;\n const { actions } = scheduler;\n\n this.work = this.state = this.scheduler = null!;\n this.pending = false;\n\n arrRemove(actions, this);\n if (id != null) {\n this.id = this.recycleAsyncId(scheduler, id, null);\n }\n\n this.delay = null!;\n super.unsubscribe();\n }\n }\n}\n", "import { Action } from './scheduler/Action';\nimport { Subscription } from './Subscription';\nimport { SchedulerLike, SchedulerAction } from './types';\nimport { dateTimestampProvider } from './scheduler/dateTimestampProvider';\n\n/**\n * An execution context and a data structure to order tasks and schedule their\n * execution. Provides a notion of (potentially virtual) time, through the\n * `now()` getter method.\n *\n * Each unit of work in a Scheduler is called an `Action`.\n *\n * ```ts\n * class Scheduler {\n * now(): number;\n * schedule(work, delay?, state?): Subscription;\n * }\n * ```\n *\n * @deprecated Scheduler is an internal implementation detail of RxJS, and\n * should not be used directly. Rather, create your own class and implement\n * {@link SchedulerLike}. Will be made internal in v8.\n */\nexport class Scheduler implements SchedulerLike {\n public static now: () => number = dateTimestampProvider.now;\n\n constructor(private schedulerActionCtor: typeof Action, now: () => number = Scheduler.now) {\n this.now = now;\n }\n\n /**\n * A getter method that returns a number representing the current time\n * (at the time this function was called) according to the scheduler's own\n * internal clock.\n * @return A number that represents the current time. May or may not\n * have a relation to wall-clock time. May or may not refer to a time unit\n * (e.g. milliseconds).\n */\n public now: () => number;\n\n /**\n * Schedules a function, `work`, for execution. May happen at some point in\n * the future, according to the `delay` parameter, if specified. May be passed\n * some context object, `state`, which will be passed to the `work` function.\n *\n * The given arguments will be processed an stored as an Action object in a\n * queue of actions.\n *\n * @param work A function representing a task, or some unit of work to be\n * executed by the Scheduler.\n * @param delay Time to wait before executing the work, where the time unit is\n * implicit and defined by the Scheduler itself.\n * @param state Some contextual data that the `work` function uses when called\n * by the Scheduler.\n * @return A subscription in order to be able to unsubscribe the scheduled work.\n */\n public schedule(work: (this: SchedulerAction, state?: T) => void, delay: number = 0, state?: T): Subscription {\n return new this.schedulerActionCtor(this, work).schedule(state, delay);\n }\n}\n", "import { Scheduler } from '../Scheduler';\nimport { Action } from './Action';\nimport { AsyncAction } from './AsyncAction';\nimport { TimerHandle } from './timerHandle';\n\nexport class AsyncScheduler extends Scheduler {\n public actions: Array> = [];\n /**\n * A flag to indicate whether the Scheduler is currently executing a batch of\n * queued actions.\n * @internal\n */\n public _active: boolean = false;\n /**\n * An internal ID used to track the latest asynchronous task such as those\n * coming from `setTimeout`, `setInterval`, `requestAnimationFrame`, and\n * others.\n * @internal\n */\n public _scheduled: TimerHandle | undefined;\n\n constructor(SchedulerAction: typeof Action, now: () => number = Scheduler.now) {\n super(SchedulerAction, now);\n }\n\n public flush(action: AsyncAction): void {\n const { actions } = this;\n\n if (this._active) {\n actions.push(action);\n return;\n }\n\n let error: any;\n this._active = true;\n\n do {\n if ((error = action.execute(action.state, action.delay))) {\n break;\n }\n } while ((action = actions.shift()!)); // exhaust the scheduler queue\n\n this._active = false;\n\n if (error) {\n while ((action = actions.shift()!)) {\n action.unsubscribe();\n }\n throw error;\n }\n }\n}\n", "import { AsyncAction } from './AsyncAction';\nimport { AsyncScheduler } from './AsyncScheduler';\n\n/**\n *\n * Async Scheduler\n *\n * Schedule task as if you used setTimeout(task, duration)\n *\n * `async` scheduler schedules tasks asynchronously, by putting them on the JavaScript\n * event loop queue. It is best used to delay tasks in time or to schedule tasks repeating\n * in intervals.\n *\n * If you just want to \"defer\" task, that is to perform it right after currently\n * executing synchronous code ends (commonly achieved by `setTimeout(deferredTask, 0)`),\n * better choice will be the {@link asapScheduler} scheduler.\n *\n * ## Examples\n * Use async scheduler to delay task\n * ```ts\n * import { asyncScheduler } from 'rxjs';\n *\n * const task = () => console.log('it works!');\n *\n * asyncScheduler.schedule(task, 2000);\n *\n * // After 2 seconds logs:\n * // \"it works!\"\n * ```\n *\n * Use async scheduler to repeat task in intervals\n * ```ts\n * import { asyncScheduler } from 'rxjs';\n *\n * function task(state) {\n * console.log(state);\n * this.schedule(state + 1, 1000); // `this` references currently executing Action,\n * // which we reschedule with new state and delay\n * }\n *\n * asyncScheduler.schedule(task, 3000, 0);\n *\n * // Logs:\n * // 0 after 3s\n * // 1 after 4s\n * // 2 after 5s\n * // 3 after 6s\n * ```\n */\n\nexport const asyncScheduler = new AsyncScheduler(AsyncAction);\n\n/**\n * @deprecated Renamed to {@link asyncScheduler}. Will be removed in v8.\n */\nexport const async = asyncScheduler;\n", "import { AsyncAction } from './AsyncAction';\nimport { Subscription } from '../Subscription';\nimport { QueueScheduler } from './QueueScheduler';\nimport { SchedulerAction } from '../types';\nimport { TimerHandle } from './timerHandle';\n\nexport class QueueAction extends AsyncAction {\n constructor(protected scheduler: QueueScheduler, protected work: (this: SchedulerAction, state?: T) => void) {\n super(scheduler, work);\n }\n\n public schedule(state?: T, delay: number = 0): Subscription {\n if (delay > 0) {\n return super.schedule(state, delay);\n }\n this.delay = delay;\n this.state = state;\n this.scheduler.flush(this);\n return this;\n }\n\n public execute(state: T, delay: number): any {\n return delay > 0 || this.closed ? super.execute(state, delay) : this._execute(state, delay);\n }\n\n protected requestAsyncId(scheduler: QueueScheduler, id?: TimerHandle, delay: number = 0): TimerHandle {\n // If delay exists and is greater than 0, or if the delay is null (the\n // action wasn't rescheduled) but was originally scheduled as an async\n // action, then recycle as an async action.\n\n if ((delay != null && delay > 0) || (delay == null && this.delay > 0)) {\n return super.requestAsyncId(scheduler, id, delay);\n }\n\n // Otherwise flush the scheduler starting with this action.\n scheduler.flush(this);\n\n // HACK: In the past, this was returning `void`. However, `void` isn't a valid\n // `TimerHandle`, and generally the return value here isn't really used. So the\n // compromise is to return `0` which is both \"falsy\" and a valid `TimerHandle`,\n // as opposed to refactoring every other instanceo of `requestAsyncId`.\n return 0;\n }\n}\n", "import { AsyncScheduler } from './AsyncScheduler';\n\nexport class QueueScheduler extends AsyncScheduler {\n}\n", "import { QueueAction } from './QueueAction';\nimport { QueueScheduler } from './QueueScheduler';\n\n/**\n *\n * Queue Scheduler\n *\n * Put every next task on a queue, instead of executing it immediately\n *\n * `queue` scheduler, when used with delay, behaves the same as {@link asyncScheduler} scheduler.\n *\n * When used without delay, it schedules given task synchronously - executes it right when\n * it is scheduled. However when called recursively, that is when inside the scheduled task,\n * another task is scheduled with queue scheduler, instead of executing immediately as well,\n * that task will be put on a queue and wait for current one to finish.\n *\n * This means that when you execute task with `queue` scheduler, you are sure it will end\n * before any other task scheduled with that scheduler will start.\n *\n * ## Examples\n * Schedule recursively first, then do something\n * ```ts\n * import { queueScheduler } from 'rxjs';\n *\n * queueScheduler.schedule(() => {\n * queueScheduler.schedule(() => console.log('second')); // will not happen now, but will be put on a queue\n *\n * console.log('first');\n * });\n *\n * // Logs:\n * // \"first\"\n * // \"second\"\n * ```\n *\n * Reschedule itself recursively\n * ```ts\n * import { queueScheduler } from 'rxjs';\n *\n * queueScheduler.schedule(function(state) {\n * if (state !== 0) {\n * console.log('before', state);\n * this.schedule(state - 1); // `this` references currently executing Action,\n * // which we reschedule with new state\n * console.log('after', state);\n * }\n * }, 0, 3);\n *\n * // In scheduler that runs recursively, you would expect:\n * // \"before\", 3\n * // \"before\", 2\n * // \"before\", 1\n * // \"after\", 1\n * // \"after\", 2\n * // \"after\", 3\n *\n * // But with queue it logs:\n * // \"before\", 3\n * // \"after\", 3\n * // \"before\", 2\n * // \"after\", 2\n * // \"before\", 1\n * // \"after\", 1\n * ```\n */\n\nexport const queueScheduler = new QueueScheduler(QueueAction);\n\n/**\n * @deprecated Renamed to {@link queueScheduler}. Will be removed in v8.\n */\nexport const queue = queueScheduler;\n", "import { AsyncAction } from './AsyncAction';\nimport { AnimationFrameScheduler } from './AnimationFrameScheduler';\nimport { SchedulerAction } from '../types';\nimport { animationFrameProvider } from './animationFrameProvider';\nimport { TimerHandle } from './timerHandle';\n\nexport class AnimationFrameAction extends AsyncAction {\n constructor(protected scheduler: AnimationFrameScheduler, protected work: (this: SchedulerAction, state?: T) => void) {\n super(scheduler, work);\n }\n\n protected requestAsyncId(scheduler: AnimationFrameScheduler, id?: TimerHandle, delay: number = 0): TimerHandle {\n // If delay is greater than 0, request as an async action.\n if (delay !== null && delay > 0) {\n return super.requestAsyncId(scheduler, id, delay);\n }\n // Push the action to the end of the scheduler queue.\n scheduler.actions.push(this);\n // If an animation frame has already been requested, don't request another\n // one. If an animation frame hasn't been requested yet, request one. Return\n // the current animation frame request id.\n return scheduler._scheduled || (scheduler._scheduled = animationFrameProvider.requestAnimationFrame(() => scheduler.flush(undefined)));\n }\n\n protected recycleAsyncId(scheduler: AnimationFrameScheduler, id?: TimerHandle, delay: number = 0): TimerHandle | undefined {\n // If delay exists and is greater than 0, or if the delay is null (the\n // action wasn't rescheduled) but was originally scheduled as an async\n // action, then recycle as an async action.\n if (delay != null ? delay > 0 : this.delay > 0) {\n return super.recycleAsyncId(scheduler, id, delay);\n }\n // If the scheduler queue has no remaining actions with the same async id,\n // cancel the requested animation frame and set the scheduled flag to\n // undefined so the next AnimationFrameAction will request its own.\n const { actions } = scheduler;\n if (id != null && id === scheduler._scheduled && actions[actions.length - 1]?.id !== id) {\n animationFrameProvider.cancelAnimationFrame(id as number);\n scheduler._scheduled = undefined;\n }\n // Return undefined so the action knows to request a new async id if it's rescheduled.\n return undefined;\n }\n}\n", "import { AsyncAction } from './AsyncAction';\nimport { AsyncScheduler } from './AsyncScheduler';\n\nexport class AnimationFrameScheduler extends AsyncScheduler {\n public flush(action?: AsyncAction): void {\n this._active = true;\n // The async id that effects a call to flush is stored in _scheduled.\n // Before executing an action, it's necessary to check the action's async\n // id to determine whether it's supposed to be executed in the current\n // flush.\n // Previous implementations of this method used a count to determine this,\n // but that was unsound, as actions that are unsubscribed - i.e. cancelled -\n // are removed from the actions array and that can shift actions that are\n // scheduled to be executed in a subsequent flush into positions at which\n // they are executed within the current flush.\n let flushId;\n if (action) {\n flushId = action.id;\n } else {\n flushId = this._scheduled;\n this._scheduled = undefined;\n }\n\n const { actions } = this;\n let error: any;\n action = action || actions.shift()!;\n\n do {\n if ((error = action.execute(action.state, action.delay))) {\n break;\n }\n } while ((action = actions[0]) && action.id === flushId && actions.shift());\n\n this._active = false;\n\n if (error) {\n while ((action = actions[0]) && action.id === flushId && actions.shift()) {\n action.unsubscribe();\n }\n throw error;\n }\n }\n}\n", "import { AnimationFrameAction } from './AnimationFrameAction';\nimport { AnimationFrameScheduler } from './AnimationFrameScheduler';\n\n/**\n *\n * Animation Frame Scheduler\n *\n * Perform task when `window.requestAnimationFrame` would fire\n *\n * When `animationFrame` scheduler is used with delay, it will fall back to {@link asyncScheduler} scheduler\n * behaviour.\n *\n * Without delay, `animationFrame` scheduler can be used to create smooth browser animations.\n * It makes sure scheduled task will happen just before next browser content repaint,\n * thus performing animations as efficiently as possible.\n *\n * ## Example\n * Schedule div height animation\n * ```ts\n * // html:
\n * import { animationFrameScheduler } from 'rxjs';\n *\n * const div = document.querySelector('div');\n *\n * animationFrameScheduler.schedule(function(height) {\n * div.style.height = height + \"px\";\n *\n * this.schedule(height + 1); // `this` references currently executing Action,\n * // which we reschedule with new state\n * }, 0, 0);\n *\n * // You will see a div element growing in height\n * ```\n */\n\nexport const animationFrameScheduler = new AnimationFrameScheduler(AnimationFrameAction);\n\n/**\n * @deprecated Renamed to {@link animationFrameScheduler}. Will be removed in v8.\n */\nexport const animationFrame = animationFrameScheduler;\n", "import { Observable } from '../Observable';\nimport { SchedulerLike } from '../types';\n\n/**\n * A simple Observable that emits no items to the Observer and immediately\n * emits a complete notification.\n *\n * Just emits 'complete', and nothing else.\n *\n * ![](empty.png)\n *\n * A simple Observable that only emits the complete notification. It can be used\n * for composing with other Observables, such as in a {@link mergeMap}.\n *\n * ## Examples\n *\n * Log complete notification\n *\n * ```ts\n * import { EMPTY } from 'rxjs';\n *\n * EMPTY.subscribe({\n * next: () => console.log('Next'),\n * complete: () => console.log('Complete!')\n * });\n *\n * // Outputs\n * // Complete!\n * ```\n *\n * Emit the number 7, then complete\n *\n * ```ts\n * import { EMPTY, startWith } from 'rxjs';\n *\n * const result = EMPTY.pipe(startWith(7));\n * result.subscribe(x => console.log(x));\n *\n * // Outputs\n * // 7\n * ```\n *\n * Map and flatten only odd numbers to the sequence `'a'`, `'b'`, `'c'`\n *\n * ```ts\n * import { interval, mergeMap, of, EMPTY } from 'rxjs';\n *\n * const interval$ = interval(1000);\n * const result = interval$.pipe(\n * mergeMap(x => x % 2 === 1 ? of('a', 'b', 'c') : EMPTY),\n * );\n * result.subscribe(x => console.log(x));\n *\n * // Results in the following to the console:\n * // x is equal to the count on the interval, e.g. (0, 1, 2, 3, ...)\n * // x will occur every 1000ms\n * // if x % 2 is equal to 1, print a, b, c (each on its own)\n * // if x % 2 is not equal to 1, nothing will be output\n * ```\n *\n * @see {@link Observable}\n * @see {@link NEVER}\n * @see {@link of}\n * @see {@link throwError}\n */\nexport const EMPTY = new Observable((subscriber) => subscriber.complete());\n\n/**\n * @param scheduler A {@link SchedulerLike} to use for scheduling\n * the emission of the complete notification.\n * @deprecated Replaced with the {@link EMPTY} constant or {@link scheduled} (e.g. `scheduled([], scheduler)`). Will be removed in v8.\n */\nexport function empty(scheduler?: SchedulerLike) {\n return scheduler ? emptyScheduled(scheduler) : EMPTY;\n}\n\nfunction emptyScheduled(scheduler: SchedulerLike) {\n return new Observable((subscriber) => scheduler.schedule(() => subscriber.complete()));\n}\n", "import { SchedulerLike } from '../types';\nimport { isFunction } from './isFunction';\n\nexport function isScheduler(value: any): value is SchedulerLike {\n return value && isFunction(value.schedule);\n}\n", "import { SchedulerLike } from '../types';\nimport { isFunction } from './isFunction';\nimport { isScheduler } from './isScheduler';\n\nfunction last(arr: T[]): T | undefined {\n return arr[arr.length - 1];\n}\n\nexport function popResultSelector(args: any[]): ((...args: unknown[]) => unknown) | undefined {\n return isFunction(last(args)) ? args.pop() : undefined;\n}\n\nexport function popScheduler(args: any[]): SchedulerLike | undefined {\n return isScheduler(last(args)) ? args.pop() : undefined;\n}\n\nexport function popNumber(args: any[], defaultValue: number): number {\n return typeof last(args) === 'number' ? args.pop()! : defaultValue;\n}\n", "export const isArrayLike = ((x: any): x is ArrayLike => x && typeof x.length === 'number' && typeof x !== 'function');", "import { isFunction } from \"./isFunction\";\n\n/**\n * Tests to see if the object is \"thennable\".\n * @param value the object to test\n */\nexport function isPromise(value: any): value is PromiseLike {\n return isFunction(value?.then);\n}\n", "import { InteropObservable } from '../types';\nimport { observable as Symbol_observable } from '../symbol/observable';\nimport { isFunction } from './isFunction';\n\n/** Identifies an input as being Observable (but not necessary an Rx Observable) */\nexport function isInteropObservable(input: any): input is InteropObservable {\n return isFunction(input[Symbol_observable]);\n}\n", "import { isFunction } from './isFunction';\n\nexport function isAsyncIterable(obj: any): obj is AsyncIterable {\n return Symbol.asyncIterator && isFunction(obj?.[Symbol.asyncIterator]);\n}\n", "/**\n * Creates the TypeError to throw if an invalid object is passed to `from` or `scheduled`.\n * @param input The object that was passed.\n */\nexport function createInvalidObservableTypeError(input: any) {\n // TODO: We should create error codes that can be looked up, so this can be less verbose.\n return new TypeError(\n `You provided ${\n input !== null && typeof input === 'object' ? 'an invalid object' : `'${input}'`\n } where a stream was expected. You can provide an Observable, Promise, ReadableStream, Array, AsyncIterable, or Iterable.`\n );\n}\n", "export function getSymbolIterator(): symbol {\n if (typeof Symbol !== 'function' || !Symbol.iterator) {\n return '@@iterator' as any;\n }\n\n return Symbol.iterator;\n}\n\nexport const iterator = getSymbolIterator();\n", "import { iterator as Symbol_iterator } from '../symbol/iterator';\nimport { isFunction } from './isFunction';\n\n/** Identifies an input as being an Iterable */\nexport function isIterable(input: any): input is Iterable {\n return isFunction(input?.[Symbol_iterator]);\n}\n", "import { ReadableStreamLike } from '../types';\nimport { isFunction } from './isFunction';\n\nexport async function* readableStreamLikeToAsyncGenerator(readableStream: ReadableStreamLike): AsyncGenerator {\n const reader = readableStream.getReader();\n try {\n while (true) {\n const { value, done } = await reader.read();\n if (done) {\n return;\n }\n yield value!;\n }\n } finally {\n reader.releaseLock();\n }\n}\n\nexport function isReadableStreamLike(obj: any): obj is ReadableStreamLike {\n // We don't want to use instanceof checks because they would return\n // false for instances from another Realm, like an