VibecoderMcSwaggins commited on
Commit
0cdf561
·
unverified ·
1 Parent(s): e85ccf5

refactor(cleanup): Remove Anthropic + Modal partial wiring (P3 Tech Debt) (#130)

Browse files

## P3 Tech Debt Cleanup Complete

### Anthropic Removal
- Removed partial wiring that never worked end-to-end
- Cleaned config, factory, app, agent_factory

### Modal Removal
- Deleted code_execution, statistical_analyzer, analysis agents
- Net reduction: ~1400 lines of dead code

### CodeRabbit Fixes
- Removed duplicate chromadb/sentence-transformers from extras
- Kept them in core deps (needed for evidence deduplication)
- Pinned requires-python to >=3.11,<4.0
- Removed Modal deps from requirements.txt

✅ 302 tests pass

AGENTS.md CHANGED
@@ -60,13 +60,11 @@ Research Report with Citations
60
  - `src/tools/pubmed.py` - PubMed E-utilities search
61
  - `src/tools/clinicaltrials.py` - ClinicalTrials.gov API
62
  - `src/tools/europepmc.py` - Europe PMC search
63
- - `src/tools/code_execution.py` - Modal sandbox execution
64
  - `src/tools/search_handler.py` - Scatter-gather orchestration
65
  - `src/services/embeddings.py` - Local embeddings (sentence-transformers, in-memory)
66
  - `src/services/llamaindex_rag.py` - Premium embeddings (OpenAI, persistent ChromaDB)
67
  - `src/services/embedding_protocol.py` - Protocol interface for embedding services
68
  - `src/services/research_memory.py` - Shared memory layer for research state
69
- - `src/services/statistical_analyzer.py` - Statistical analysis via Modal
70
  - `src/utils/service_loader.py` - Tiered service selection (free vs premium)
71
  - `src/agent_factory/judges.py` - LLM-based evidence assessment
72
  - `src/agents/` - Magentic multi-agent mode (SearchAgent, JudgeAgent, etc.)
@@ -82,10 +80,9 @@ Research Report with Citations
82
 
83
  Settings via pydantic-settings from `.env`:
84
 
85
- - `LLM_PROVIDER`: "openai" or "anthropic"
86
- - `OPENAI_API_KEY` / `ANTHROPIC_API_KEY`: LLM keys
87
  - `NCBI_API_KEY`: Optional, for higher PubMed rate limits
88
- - `MODAL_TOKEN_ID` / `MODAL_TOKEN_SECRET`: For Modal sandbox (optional)
89
  - `MAX_ITERATIONS`: 1-50, default 10
90
  - `LOG_LEVEL`: DEBUG, INFO, WARNING, ERROR
91
 
@@ -107,8 +104,6 @@ Default models in `src/utils/config.py`:
107
  - **OpenAI:** `gpt-5` - Flagship model
108
  - **HuggingFace (Free Tier):** `Qwen/Qwen2.5-7B-Instruct` - See critical note below
109
 
110
- **NOTE:** Anthropic is NOT supported (no embeddings API). See `P3_REMOVE_ANTHROPIC_PARTIAL_WIRING.md`.
111
-
112
  ---
113
 
114
  ## ⚠️ OpenAI API Keys
 
60
  - `src/tools/pubmed.py` - PubMed E-utilities search
61
  - `src/tools/clinicaltrials.py` - ClinicalTrials.gov API
62
  - `src/tools/europepmc.py` - Europe PMC search
 
63
  - `src/tools/search_handler.py` - Scatter-gather orchestration
64
  - `src/services/embeddings.py` - Local embeddings (sentence-transformers, in-memory)
65
  - `src/services/llamaindex_rag.py` - Premium embeddings (OpenAI, persistent ChromaDB)
66
  - `src/services/embedding_protocol.py` - Protocol interface for embedding services
67
  - `src/services/research_memory.py` - Shared memory layer for research state
 
68
  - `src/utils/service_loader.py` - Tiered service selection (free vs premium)
69
  - `src/agent_factory/judges.py` - LLM-based evidence assessment
70
  - `src/agents/` - Magentic multi-agent mode (SearchAgent, JudgeAgent, etc.)
 
80
 
81
  Settings via pydantic-settings from `.env`:
82
 
83
+ - `LLM_PROVIDER`: "openai" or "huggingface"
84
+ - `OPENAI_API_KEY`: LLM keys
85
  - `NCBI_API_KEY`: Optional, for higher PubMed rate limits
 
86
  - `MAX_ITERATIONS`: 1-50, default 10
87
  - `LOG_LEVEL`: DEBUG, INFO, WARNING, ERROR
88
 
 
104
  - **OpenAI:** `gpt-5` - Flagship model
105
  - **HuggingFace (Free Tier):** `Qwen/Qwen2.5-7B-Instruct` - See critical note below
106
 
 
 
107
  ---
108
 
109
  ## ⚠️ OpenAI API Keys
CLAUDE.md CHANGED
@@ -60,13 +60,11 @@ Research Report with Citations
60
  - `src/tools/pubmed.py` - PubMed E-utilities search
61
  - `src/tools/clinicaltrials.py` - ClinicalTrials.gov API
62
  - `src/tools/europepmc.py` - Europe PMC search
63
- - `src/tools/code_execution.py` - Modal sandbox execution
64
  - `src/tools/search_handler.py` - Scatter-gather orchestration
65
  - `src/services/embeddings.py` - Local embeddings (sentence-transformers, in-memory)
66
  - `src/services/llamaindex_rag.py` - Premium embeddings (OpenAI, persistent ChromaDB)
67
  - `src/services/embedding_protocol.py` - Protocol interface for embedding services
68
  - `src/services/research_memory.py` - Shared memory layer for research state
69
- - `src/services/statistical_analyzer.py` - Statistical analysis via Modal
70
  - `src/utils/service_loader.py` - Tiered service selection (free vs premium)
71
  - `src/agent_factory/judges.py` - LLM-based evidence assessment
72
  - `src/agents/` - Magentic multi-agent mode (SearchAgent, JudgeAgent, etc.)
@@ -82,10 +80,9 @@ Research Report with Citations
82
 
83
  Settings via pydantic-settings from `.env`:
84
 
85
- - `LLM_PROVIDER`: "openai" or "anthropic"
86
- - `OPENAI_API_KEY` / `ANTHROPIC_API_KEY`: LLM keys
87
  - `NCBI_API_KEY`: Optional, for higher PubMed rate limits
88
- - `MODAL_TOKEN_ID` / `MODAL_TOKEN_SECRET`: For Modal sandbox (optional)
89
  - `MAX_ITERATIONS`: 1-50, default 10
90
  - `LOG_LEVEL`: DEBUG, INFO, WARNING, ERROR
91
 
@@ -114,8 +111,6 @@ Default models in `src/utils/config.py`:
114
  - **OpenAI:** `gpt-5` - Flagship model
115
  - **HuggingFace (Free Tier):** `Qwen/Qwen2.5-7B-Instruct` - See critical note below
116
 
117
- **NOTE:** Anthropic is NOT supported (no embeddings API). See `P3_REMOVE_ANTHROPIC_PARTIAL_WIRING.md`.
118
-
119
  ---
120
 
121
  ## ⚠️ OpenAI API Keys
 
60
  - `src/tools/pubmed.py` - PubMed E-utilities search
61
  - `src/tools/clinicaltrials.py` - ClinicalTrials.gov API
62
  - `src/tools/europepmc.py` - Europe PMC search
 
63
  - `src/tools/search_handler.py` - Scatter-gather orchestration
64
  - `src/services/embeddings.py` - Local embeddings (sentence-transformers, in-memory)
65
  - `src/services/llamaindex_rag.py` - Premium embeddings (OpenAI, persistent ChromaDB)
66
  - `src/services/embedding_protocol.py` - Protocol interface for embedding services
67
  - `src/services/research_memory.py` - Shared memory layer for research state
 
68
  - `src/utils/service_loader.py` - Tiered service selection (free vs premium)
69
  - `src/agent_factory/judges.py` - LLM-based evidence assessment
70
  - `src/agents/` - Magentic multi-agent mode (SearchAgent, JudgeAgent, etc.)
 
80
 
81
  Settings via pydantic-settings from `.env`:
82
 
83
+ - `LLM_PROVIDER`: "openai" or "huggingface"
84
+ - `OPENAI_API_KEY`: LLM keys
85
  - `NCBI_API_KEY`: Optional, for higher PubMed rate limits
 
86
  - `MAX_ITERATIONS`: 1-50, default 10
87
  - `LOG_LEVEL`: DEBUG, INFO, WARNING, ERROR
88
 
 
111
  - **OpenAI:** `gpt-5` - Flagship model
112
  - **HuggingFace (Free Tier):** `Qwen/Qwen2.5-7B-Instruct` - See critical note below
113
 
 
 
114
  ---
115
 
116
  ## ⚠️ OpenAI API Keys
GEMINI.md CHANGED
@@ -16,7 +16,6 @@ The project follows a **Vertical Slice Architecture** (Search -> Judge -> Orches
16
  - **Package Manager:** `uv` (Rust-based, extremely fast)
17
  - **Frameworks:** `pydantic`, `pydantic-ai`, `httpx`, `gradio[mcp]`
18
  - **Vector DB:** `chromadb` with `sentence-transformers` for semantic search
19
- - **Code Execution:** `modal` for secure sandboxed Python execution
20
  - **Testing:** `pytest`, `pytest-asyncio`, `respx` (for mocking)
21
  - **Quality:** `ruff` (linting/formatting), `mypy` (strict type checking), `pre-commit`
22
 
@@ -60,13 +59,11 @@ The project follows a **Vertical Slice Architecture** (Search -> Judge -> Orches
60
  - `src/tools/pubmed.py` - PubMed E-utilities search
61
  - `src/tools/clinicaltrials.py` - ClinicalTrials.gov API
62
  - `src/tools/europepmc.py` - Europe PMC search
63
- - `src/tools/code_execution.py` - Modal sandbox execution
64
  - `src/tools/search_handler.py` - Scatter-gather orchestration
65
  - `src/services/embeddings.py` - Local embeddings (sentence-transformers, in-memory)
66
  - `src/services/llamaindex_rag.py` - Premium embeddings (OpenAI, persistent ChromaDB)
67
  - `src/services/embedding_protocol.py` - Protocol interface for embedding services
68
  - `src/services/research_memory.py` - Shared memory layer for research state
69
- - `src/services/statistical_analyzer.py` - Statistical analysis via Modal
70
  - `src/utils/service_loader.py` - Tiered service selection (free vs premium)
71
  - `src/mcp_tools.py` - MCP tool wrappers
72
  - `src/app.py` - Gradio UI (HuggingFace Spaces) with MCP server
@@ -75,10 +72,9 @@ The project follows a **Vertical Slice Architecture** (Search -> Judge -> Orches
75
 
76
  Settings via pydantic-settings from `.env`:
77
 
78
- - `LLM_PROVIDER`: "openai" or "anthropic"
79
- - `OPENAI_API_KEY` / `ANTHROPIC_API_KEY`: LLM keys
80
  - `NCBI_API_KEY`: Optional, for higher PubMed rate limits
81
- - `MODAL_TOKEN_ID` / `MODAL_TOKEN_SECRET`: For Modal sandbox (optional)
82
  - `MAX_ITERATIONS`: 1-50, default 10
83
  - `LOG_LEVEL`: DEBUG, INFO, WARNING, ERROR
84
 
@@ -89,8 +85,6 @@ Default models in `src/utils/config.py`:
89
  - **OpenAI:** `gpt-5` - Flagship model
90
  - **HuggingFace (Free Tier):** `Qwen/Qwen2.5-7B-Instruct` - See critical note below
91
 
92
- **NOTE:** Anthropic is NOT supported (no embeddings API). See `P3_REMOVE_ANTHROPIC_PARTIAL_WIRING.md`.
93
-
94
  ---
95
 
96
  ## ⚠️ OpenAI API Keys
 
16
  - **Package Manager:** `uv` (Rust-based, extremely fast)
17
  - **Frameworks:** `pydantic`, `pydantic-ai`, `httpx`, `gradio[mcp]`
18
  - **Vector DB:** `chromadb` with `sentence-transformers` for semantic search
 
19
  - **Testing:** `pytest`, `pytest-asyncio`, `respx` (for mocking)
20
  - **Quality:** `ruff` (linting/formatting), `mypy` (strict type checking), `pre-commit`
21
 
 
59
  - `src/tools/pubmed.py` - PubMed E-utilities search
60
  - `src/tools/clinicaltrials.py` - ClinicalTrials.gov API
61
  - `src/tools/europepmc.py` - Europe PMC search
 
62
  - `src/tools/search_handler.py` - Scatter-gather orchestration
63
  - `src/services/embeddings.py` - Local embeddings (sentence-transformers, in-memory)
64
  - `src/services/llamaindex_rag.py` - Premium embeddings (OpenAI, persistent ChromaDB)
65
  - `src/services/embedding_protocol.py` - Protocol interface for embedding services
66
  - `src/services/research_memory.py` - Shared memory layer for research state
 
67
  - `src/utils/service_loader.py` - Tiered service selection (free vs premium)
68
  - `src/mcp_tools.py` - MCP tool wrappers
69
  - `src/app.py` - Gradio UI (HuggingFace Spaces) with MCP server
 
72
 
73
  Settings via pydantic-settings from `.env`:
74
 
75
+ - `LLM_PROVIDER`: "openai" or "huggingface"
76
+ - `OPENAI_API_KEY`: LLM keys
77
  - `NCBI_API_KEY`: Optional, for higher PubMed rate limits
 
78
  - `MAX_ITERATIONS`: 1-50, default 10
79
  - `LOG_LEVEL`: DEBUG, INFO, WARNING, ERROR
80
 
 
85
  - **OpenAI:** `gpt-5` - Flagship model
86
  - **HuggingFace (Free Tier):** `Qwen/Qwen2.5-7B-Instruct` - See critical note below
87
 
 
 
88
  ---
89
 
90
  ## ⚠️ OpenAI API Keys
docs/future-roadmap/P3_MODAL_INTEGRATION_REMOVAL.md CHANGED
@@ -1,7 +1,7 @@
1
  # P3 Tech Debt: Modal Integration Removal
2
 
3
  **Date**: 2025-12-04
4
- **Status**: OPEN - Tech Debt (Future Roadmap)
5
  **Severity**: P3 (Tech Debt - Not blocking functionality)
6
  **Component**: Multiple files
7
 
 
1
  # P3 Tech Debt: Modal Integration Removal
2
 
3
  **Date**: 2025-12-04
4
+ **Status**: DONE
5
  **Severity**: P3 (Tech Debt - Not blocking functionality)
6
  **Component**: Multiple files
7
 
docs/future-roadmap/P3_REMOVE_ANTHROPIC_PARTIAL_WIRING.md CHANGED
@@ -1,7 +1,7 @@
1
  # P3 Tech Debt: Remove Anthropic Partial Wiring
2
 
3
  **Date**: 2025-12-03
4
- **Status**: OPEN
5
  **Severity**: P3 (Tech Debt / Simplification)
6
  **Component**: Architecture / Provider Integration
7
 
 
1
  # P3 Tech Debt: Remove Anthropic Partial Wiring
2
 
3
  **Date**: 2025-12-03
4
+ **Status**: DONE
5
  **Severity**: P3 (Tech Debt / Simplification)
6
  **Component**: Architecture / Provider Integration
7
 
examples/modal_demo/run_analysis.py DELETED
@@ -1,65 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Demo: Modal-powered statistical analysis.
3
-
4
- This script uses StatisticalAnalyzer directly (NO agent_framework dependency).
5
-
6
- # Usage:
7
- # source .env
8
- # uv run python examples/modal_demo/run_analysis.py "testosterone libido"
9
- """
10
-
11
- import argparse
12
- import asyncio
13
- import os
14
- import sys
15
-
16
- from src.services.statistical_analyzer import get_statistical_analyzer
17
- from src.tools.pubmed import PubMedTool
18
- from src.utils.config import settings
19
-
20
-
21
- async def main() -> None:
22
- """Run the Modal analysis demo."""
23
- parser = argparse.ArgumentParser(description="Modal Analysis Demo")
24
- parser.add_argument("query", help="Research query")
25
- args = parser.parse_args()
26
-
27
- if not settings.modal_available:
28
- print("Error: Modal credentials not configured.")
29
- sys.exit(1)
30
-
31
- if not (os.getenv("OPENAI_API_KEY") or os.getenv("ANTHROPIC_API_KEY")):
32
- print("Error: No LLM API key found.")
33
- sys.exit(1)
34
-
35
- print(f"\n{'=' * 60}")
36
- print("DeepBoner Modal Analysis Demo")
37
- print(f"Query: {args.query}")
38
- print(f"{'=' * 60}\n")
39
-
40
- # Step 1: Gather Evidence
41
- print("Step 1: Gathering evidence from PubMed...")
42
- pubmed = PubMedTool()
43
- evidence = await pubmed.search(args.query, max_results=5)
44
- print(f" Found {len(evidence)} papers\n")
45
-
46
- # Step 2: Run Modal Analysis
47
- print("Step 2: Running statistical analysis in Modal sandbox...")
48
- analyzer = get_statistical_analyzer()
49
- result = await analyzer.analyze(query=args.query, evidence=evidence)
50
-
51
- # Step 3: Display Results
52
- print("\n" + "=" * 60)
53
- print("ANALYSIS RESULTS")
54
- print("=" * 60)
55
- print(f"\nVerdict: {result.verdict}")
56
- print(f"Confidence: {result.confidence:.0%}")
57
- print("\nKey Findings:")
58
- for finding in result.key_findings:
59
- print(f" - {finding}")
60
-
61
- print("\n[Demo Complete - Code executed in Modal, not locally]")
62
-
63
-
64
- if __name__ == "__main__":
65
- asyncio.run(main())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
examples/modal_demo/test_code_execution.py DELETED
@@ -1,169 +0,0 @@
1
- """Demo script to test Modal code execution integration.
2
-
3
- Run with: uv run python examples/modal_demo/test_code_execution.py
4
- """
5
-
6
- import sys
7
- from pathlib import Path
8
-
9
- # Add src to path
10
- sys.path.insert(0, str(Path(__file__).parent.parent.parent))
11
-
12
- from src.tools.code_execution import CodeExecutionError, get_code_executor
13
-
14
-
15
- def test_basic_execution():
16
- """Test basic code execution."""
17
- print("\n=== Test 1: Basic Execution ===")
18
- executor = get_code_executor()
19
-
20
- code = """
21
- print("Hello from Modal sandbox!")
22
- result = 2 + 2
23
- print(f"2 + 2 = {result}")
24
- """
25
-
26
- result = executor.execute(code)
27
- print(f"Success: {result['success']}")
28
- print(f"Stdout:\n{result['stdout']}")
29
- if result["stderr"]:
30
- print(f"Stderr:\n{result['stderr']}")
31
-
32
-
33
- def test_scientific_computing():
34
- """Test scientific computing libraries."""
35
- print("\n=== Test 2: Scientific Computing ===")
36
- executor = get_code_executor()
37
-
38
- code = """
39
- import pandas as pd
40
- import numpy as np
41
-
42
- # Create sample data
43
- data = {
44
- 'drug': ['DrugA', 'DrugB', 'DrugC'],
45
- 'efficacy': [0.75, 0.82, 0.68],
46
- 'sample_size': [100, 150, 120]
47
- }
48
-
49
- df = pd.DataFrame(data)
50
-
51
- # Calculate weighted average
52
- weighted_avg = np.average(df['efficacy'], weights=df['sample_size'])
53
-
54
- print(f"Drugs tested: {len(df)}")
55
- print(f"Weighted average efficacy: {weighted_avg:.3f}")
56
- print("\\nDataFrame:")
57
- print(df.to_string())
58
- """
59
-
60
- result = executor.execute(code)
61
- print(f"Success: {result['success']}")
62
- print(f"Output:\n{result['stdout']}")
63
-
64
-
65
- def test_statistical_analysis():
66
- """Test statistical analysis."""
67
- print("\n=== Test 3: Statistical Analysis ===")
68
- executor = get_code_executor()
69
-
70
- code = """
71
- import numpy as np
72
- from scipy import stats
73
-
74
- # Simulate two treatment groups
75
- np.random.seed(42)
76
- control_group = np.random.normal(100, 15, 50)
77
- treatment_group = np.random.normal(110, 15, 50)
78
-
79
- # Perform t-test
80
- t_stat, p_value = stats.ttest_ind(treatment_group, control_group)
81
-
82
- print(f"Control mean: {np.mean(control_group):.2f}")
83
- print(f"Treatment mean: {np.mean(treatment_group):.2f}")
84
- print(f"T-statistic: {t_stat:.3f}")
85
- print(f"P-value: {p_value:.4f}")
86
-
87
- if p_value < 0.05:
88
- print("Result: Statistically significant difference")
89
- else:
90
- print("Result: No significant difference")
91
- """
92
-
93
- result = executor.execute(code)
94
- print(f"Success: {result['success']}")
95
- print(f"Output:\n{result['stdout']}")
96
-
97
-
98
- def test_with_return_value():
99
- """Test execute_with_return method."""
100
- print("\n=== Test 4: Return Value ===")
101
- executor = get_code_executor()
102
-
103
- code = """
104
- import numpy as np
105
-
106
- # Calculate something
107
- data = np.array([1, 2, 3, 4, 5])
108
- result = {
109
- 'mean': float(np.mean(data)),
110
- 'std': float(np.std(data)),
111
- 'sum': int(np.sum(data))
112
- }
113
- """
114
-
115
- try:
116
- result = executor.execute_with_return(code)
117
- print(f"Returned result: {result}")
118
- print(f"Mean: {result['mean']}")
119
- print(f"Std: {result['std']}")
120
- print(f"Sum: {result['sum']}")
121
- except CodeExecutionError as e:
122
- print(f"Error: {e}")
123
-
124
-
125
- def test_error_handling():
126
- """Test error handling."""
127
- print("\n=== Test 5: Error Handling ===")
128
- executor = get_code_executor()
129
-
130
- code = """
131
- # This will fail
132
- x = 1 / 0
133
- """
134
-
135
- result = executor.execute(code)
136
- print(f"Success: {result['success']}")
137
- print(f"Error: {result['error']}")
138
-
139
-
140
- def main():
141
- """Run all tests."""
142
- print("=" * 60)
143
- print("Modal Code Execution Demo")
144
- print("=" * 60)
145
-
146
- tests = [
147
- test_basic_execution,
148
- test_scientific_computing,
149
- test_statistical_analysis,
150
- test_with_return_value,
151
- test_error_handling,
152
- ]
153
-
154
- for test in tests:
155
- try:
156
- test()
157
- except Exception as e:
158
- print(f"\n❌ Test failed: {e}")
159
- import traceback
160
-
161
- traceback.print_exc()
162
-
163
- print("\n" + "=" * 60)
164
- print("Demo completed!")
165
- print("=" * 60)
166
-
167
-
168
- if __name__ == "__main__":
169
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
examples/modal_demo/verify_sandbox.py DELETED
@@ -1,101 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Verify that Modal sandbox is properly isolated.
3
-
4
- This script proves to judges that code runs in Modal, not locally.
5
- NO agent_framework dependency - uses only src.tools.code_execution.
6
-
7
- Usage:
8
- uv run python examples/modal_demo/verify_sandbox.py
9
- """
10
-
11
- import asyncio
12
- from functools import partial
13
-
14
- from src.tools.code_execution import CodeExecutionError, get_code_executor
15
- from src.utils.config import settings
16
-
17
-
18
- def print_result(result: dict) -> None:
19
- """Print execution result, surfacing errors when they occur."""
20
- if result.get("success"):
21
- print(f" {result['stdout'].strip()}\n")
22
- else:
23
- error = result.get("error") or result.get("stderr", "").strip() or "Unknown error"
24
- print(f" ERROR: {error}\n")
25
-
26
-
27
- async def main() -> None:
28
- """Verify Modal sandbox isolation."""
29
- if not settings.modal_available:
30
- print("Error: Modal credentials not configured.")
31
- print("Set MODAL_TOKEN_ID and MODAL_TOKEN_SECRET in .env")
32
- return
33
-
34
- try:
35
- executor = get_code_executor()
36
- loop = asyncio.get_running_loop()
37
-
38
- print("=" * 60)
39
- print("Modal Sandbox Isolation Verification")
40
- print("=" * 60 + "\n")
41
-
42
- # Test 1: Hostname
43
- print("Test 1: Check hostname (should NOT be your machine)")
44
- code1 = "import socket; print(f'Hostname: {socket.gethostname()}')"
45
- result1 = await loop.run_in_executor(None, partial(executor.execute, code1))
46
- print_result(result1)
47
-
48
- # Test 2: Scientific libraries
49
- print("Test 2: Verify scientific libraries")
50
- code2 = """
51
- import pandas as pd
52
- import numpy as np
53
- import scipy
54
- print(f"pandas: {pd.__version__}")
55
- print(f"numpy: {np.__version__}")
56
- print(f"scipy: {scipy.__version__}")
57
- """
58
- result2 = await loop.run_in_executor(None, partial(executor.execute, code2))
59
- print_result(result2)
60
-
61
- # Test 3: Network blocked
62
- print("Test 3: Verify network isolation")
63
- code3 = """
64
- import urllib.request
65
- try:
66
- urllib.request.urlopen("https://google.com", timeout=2)
67
- print("Network: ALLOWED (unexpected!)")
68
- except Exception:
69
- print("Network: BLOCKED (as expected)")
70
- """
71
- result3 = await loop.run_in_executor(None, partial(executor.execute, code3))
72
- print_result(result3)
73
-
74
- # Test 4: Real statistics
75
- print("Test 4: Execute statistical analysis")
76
- code4 = """
77
- import pandas as pd
78
- import scipy.stats as stats
79
-
80
- data = pd.DataFrame({'effect': [0.42, 0.38, 0.51]})
81
- mean = data['effect'].mean()
82
- t_stat, p_val = stats.ttest_1samp(data['effect'], 0)
83
-
84
- print(f"Mean Effect: {mean:.3f}")
85
- print(f"P-value: {p_val:.4f}")
86
- print(f"Verdict: {'SUPPORTED' if p_val < 0.05 else 'INCONCLUSIVE'}")
87
- """
88
- result4 = await loop.run_in_executor(None, partial(executor.execute, code4))
89
- print_result(result4)
90
-
91
- print("=" * 60)
92
- print("All tests complete - Modal sandbox verified!")
93
- print("=" * 60)
94
-
95
- except CodeExecutionError as e:
96
- print(f"Error: Modal code execution failed: {e}")
97
- print("Hint: Ensure Modal SDK is installed and credentials are valid.")
98
-
99
-
100
- if __name__ == "__main__":
101
- asyncio.run(main())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
pyproject.toml CHANGED
@@ -4,7 +4,7 @@ version = "0.1.0"
4
  description = "AI-Native Sexual Health Research Agent"
5
  readme = "README.md"
6
  license = "Apache-2.0"
7
- requires-python = ">=3.11"
8
  dependencies = [
9
  # Core
10
  "pydantic>=2.7",
@@ -12,7 +12,8 @@ dependencies = [
12
  "pydantic-ai>=0.0.16", # Agent framework
13
  # AI Providers
14
  "openai>=1.0.0",
15
- "anthropic>=0.18.0",
 
16
  # HTTP & Parsing
17
  "httpx>=0.27", # Async HTTP client (PubMed)
18
  "beautifulsoup4>=4.12", # HTML parsing
@@ -62,18 +63,12 @@ dev = [
62
  magentic = [
63
  "agent-framework-core>=1.0.0b251120,<2.0.0", # Microsoft Agent Framework (PyPI)
64
  ]
65
- embeddings = [
66
- "chromadb>=0.4.0",
67
- "sentence-transformers>=2.2.0",
68
- ]
69
- modal = [
70
- # Mario's Modal code execution + LlamaIndex RAG
71
- "modal>=0.63.0",
72
  "llama-index>=0.11.0",
73
  "llama-index-llms-openai",
74
  "llama-index-embeddings-openai",
75
  "llama-index-vector-stores-chroma",
76
- "chromadb>=0.4.0",
77
  ]
78
 
79
  [build-system]
 
4
  description = "AI-Native Sexual Health Research Agent"
5
  readme = "README.md"
6
  license = "Apache-2.0"
7
+ requires-python = ">=3.11,<4.0"
8
  dependencies = [
9
  # Core
10
  "pydantic>=2.7",
 
12
  "pydantic-ai>=0.0.16", # Agent framework
13
  # AI Providers
14
  "openai>=1.0.0",
15
+ "chromadb>=0.4.22",
16
+ "sentence-transformers>=2.2.2",
17
  # HTTP & Parsing
18
  "httpx>=0.27", # Async HTTP client (PubMed)
19
  "beautifulsoup4>=4.12", # HTML parsing
 
63
  magentic = [
64
  "agent-framework-core>=1.0.0b251120,<2.0.0", # Microsoft Agent Framework (PyPI)
65
  ]
66
+ rag = [
67
+ # LlamaIndex RAG support (chromadb already in core deps)
 
 
 
 
 
68
  "llama-index>=0.11.0",
69
  "llama-index-llms-openai",
70
  "llama-index-embeddings-openai",
71
  "llama-index-vector-stores-chroma",
 
72
  ]
73
 
74
  [build-system]
requirements.txt CHANGED
@@ -5,7 +5,8 @@ pydantic-ai>=0.0.16
5
 
6
  # AI Providers
7
  openai>=1.0.0
8
- anthropic>=0.18.0
 
9
  huggingface-hub>=0.20.0
10
 
11
  # Multi-agent orchestration (Advanced mode)
@@ -37,9 +38,8 @@ requests>=2.32.5
37
  limits>=3.0
38
  urllib3>=2.5.0 # Security fix for GHSA-48p4-8xcf-vxj5
39
 
40
- # Optional: Modal for code execution
41
- modal>=0.63.0
42
-
43
- # Optional: Embeddings & Vector Store
44
- chromadb>=0.4.0
45
- sentence-transformers>=2.2.0
 
5
 
6
  # AI Providers
7
  openai>=1.0.0
8
+ chromadb>=0.4.22
9
+ sentence-transformers>=2.2.2
10
  huggingface-hub>=0.20.0
11
 
12
  # Multi-agent orchestration (Advanced mode)
 
38
  limits>=3.0
39
  urllib3>=2.5.0 # Security fix for GHSA-48p4-8xcf-vxj5
40
 
41
+ # Optional: LlamaIndex RAG (chromadb/sentence-transformers already in core above)
42
+ llama-index>=0.11.0
43
+ llama-index-llms-openai
44
+ llama-index-embeddings-openai
45
+ llama-index-vector-stores-chroma
 
src/agent_factory/judges.py CHANGED
@@ -63,24 +63,11 @@ def get_model(api_key: str | None = None) -> Any:
63
 
64
  Args:
65
  api_key: Optional BYOK key. Auto-detects provider from prefix:
66
- - "sk-ant-..." → Anthropic (NOT SUPPORTED - raises error)
67
  - "sk-..." → OpenAI
68
  - Other → Falls through to env vars
69
-
70
- Raises:
71
- NotImplementedError: If Anthropic key detected (no embeddings support).
72
-
73
- Note: Anthropic is NOT supported because it lacks embeddings API.
74
- See P3_REMOVE_ANTHROPIC_PARTIAL_WIRING.md.
75
  """
76
  # Priority 1: BYOK - Auto-detect provider from key prefix
77
  if api_key:
78
- if api_key.startswith("sk-ant-"):
79
- # Anthropic not supported - no embeddings API
80
- raise NotImplementedError(
81
- "Anthropic is not supported (no embeddings API). "
82
- "Use OpenAI key (sk-...) or leave empty for free HuggingFace tier."
83
- )
84
  if api_key.startswith("sk-"):
85
  # OpenAI BYOK
86
  openai_provider = OpenAIProvider(api_key=api_key)
 
63
 
64
  Args:
65
  api_key: Optional BYOK key. Auto-detects provider from prefix:
 
66
  - "sk-..." → OpenAI
67
  - Other → Falls through to env vars
 
 
 
 
 
 
68
  """
69
  # Priority 1: BYOK - Auto-detect provider from key prefix
70
  if api_key:
 
 
 
 
 
 
71
  if api_key.startswith("sk-"):
72
  # OpenAI BYOK
73
  openai_provider = OpenAIProvider(api_key=api_key)
src/agents/analysis_agent.py DELETED
@@ -1,145 +0,0 @@
1
- """Analysis agent for statistical analysis using Modal code execution.
2
-
3
- This agent wraps StatisticalAnalyzer for use in magentic multi-agent mode.
4
- The core logic is in src/services/statistical_analyzer.py to avoid
5
- coupling agent_framework to the simple orchestrator.
6
- """
7
-
8
- from collections.abc import AsyncIterable
9
- from typing import TYPE_CHECKING, Any
10
-
11
- from agent_framework import (
12
- AgentRunResponse,
13
- AgentRunResponseUpdate,
14
- AgentThread,
15
- BaseAgent,
16
- ChatMessage,
17
- Role,
18
- )
19
-
20
- from src.services.statistical_analyzer import (
21
- AnalysisResult,
22
- get_statistical_analyzer,
23
- )
24
-
25
- if TYPE_CHECKING:
26
- from src.services.embeddings import EmbeddingService
27
-
28
-
29
- class AnalysisAgent(BaseAgent): # type: ignore[misc]
30
- """Wraps StatisticalAnalyzer for magentic multi-agent mode."""
31
-
32
- def __init__(
33
- self,
34
- evidence_store: dict[str, Any],
35
- embedding_service: "EmbeddingService | None" = None,
36
- ) -> None:
37
- super().__init__(
38
- name="AnalysisAgent",
39
- description="Performs statistical analysis using Modal sandbox",
40
- )
41
- self._evidence_store = evidence_store
42
- self._embeddings = embedding_service
43
- self._analyzer = get_statistical_analyzer()
44
-
45
- async def run(
46
- self,
47
- messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None,
48
- *,
49
- thread: AgentThread | None = None,
50
- **kwargs: Any,
51
- ) -> AgentRunResponse:
52
- """Analyze evidence and return verdict."""
53
- query = self._extract_query(messages)
54
- hypotheses = self._evidence_store.get("hypotheses", [])
55
- evidence = self._evidence_store.get("current", [])
56
-
57
- if not evidence:
58
- return self._error_response("No evidence available.")
59
-
60
- # Get primary hypothesis if available
61
- hypothesis_dict = None
62
- if hypotheses:
63
- h = hypotheses[0]
64
- hypothesis_dict = {
65
- "drug": getattr(h, "drug", "Unknown"),
66
- "target": getattr(h, "target", "?"),
67
- "pathway": getattr(h, "pathway", "?"),
68
- "effect": getattr(h, "effect", "?"),
69
- "confidence": getattr(h, "confidence", 0.5),
70
- }
71
-
72
- # Delegate to StatisticalAnalyzer
73
- result = await self._analyzer.analyze(
74
- query=query,
75
- evidence=evidence,
76
- hypothesis=hypothesis_dict,
77
- )
78
-
79
- # Store in shared context
80
- self._evidence_store["analysis"] = result.model_dump()
81
-
82
- # Format response
83
- response_text = self._format_response(result)
84
-
85
- return AgentRunResponse(
86
- messages=[ChatMessage(role=Role.ASSISTANT, text=response_text)],
87
- response_id=f"analysis-{result.verdict.lower()}",
88
- additional_properties={"analysis": result.model_dump()},
89
- )
90
-
91
- def _format_response(self, result: AnalysisResult) -> str:
92
- """Format analysis result as markdown."""
93
- lines = [
94
- "## Statistical Analysis Complete\n",
95
- f"### Verdict: **{result.verdict}**",
96
- f"**Confidence**: {result.confidence:.0%}\n",
97
- "### Key Findings",
98
- ]
99
- for finding in result.key_findings:
100
- lines.append(f"- {finding}")
101
-
102
- lines.extend(
103
- [
104
- "\n### Statistical Evidence",
105
- "```",
106
- result.statistical_evidence,
107
- "```",
108
- ]
109
- )
110
- return "\n".join(lines)
111
-
112
- def _error_response(self, message: str) -> AgentRunResponse:
113
- """Create error response."""
114
- return AgentRunResponse(
115
- messages=[ChatMessage(role=Role.ASSISTANT, text=f"**Error**: {message}")],
116
- response_id="analysis-error",
117
- )
118
-
119
- def _extract_query(
120
- self,
121
- messages: str | ChatMessage | list[str] | list[ChatMessage] | None,
122
- ) -> str:
123
- """Extract query from messages."""
124
- if isinstance(messages, str):
125
- return messages
126
- elif isinstance(messages, ChatMessage):
127
- return messages.text or ""
128
- elif isinstance(messages, list):
129
- for msg in reversed(messages):
130
- if isinstance(msg, ChatMessage) and msg.role == Role.USER:
131
- return msg.text or ""
132
- elif isinstance(msg, str):
133
- return msg
134
- return ""
135
-
136
- async def run_stream(
137
- self,
138
- messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None,
139
- *,
140
- thread: AgentThread | None = None,
141
- **kwargs: Any,
142
- ) -> AsyncIterable[AgentRunResponseUpdate]:
143
- """Streaming wrapper."""
144
- result = await self.run(messages, thread=thread, **kwargs)
145
- yield AgentRunResponseUpdate(messages=result.messages, response_id=result.response_id)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/agents/code_executor_agent.py DELETED
@@ -1,66 +0,0 @@
1
- """Code execution agent using Modal."""
2
-
3
- import asyncio
4
-
5
- import structlog
6
- from agent_framework import ChatAgent, ai_function
7
-
8
- from src.clients.base import BaseChatClient
9
- from src.clients.factory import get_chat_client
10
- from src.tools.code_execution import get_code_executor
11
-
12
- logger = structlog.get_logger()
13
-
14
-
15
- @ai_function # type: ignore[arg-type, misc]
16
- async def execute_python_code(code: str) -> str:
17
- """Execute Python code in a secure sandbox.
18
-
19
- Args:
20
- code: The Python code to execute.
21
-
22
- Returns:
23
- The standard output and standard error of the execution.
24
- """
25
- logger.info("Code execution starting", code_length=len(code))
26
- executor = get_code_executor()
27
- loop = asyncio.get_running_loop()
28
-
29
- # Run in executor to avoid blocking
30
- try:
31
- result = await loop.run_in_executor(None, lambda: executor.execute(code))
32
- if result["success"]:
33
- logger.info("Code execution succeeded")
34
- return f"Stdout:\n{result['stdout']}"
35
- else:
36
- logger.warning("Code execution failed", error=result.get("error"))
37
- return f"Error:\n{result['error']}\nStderr:\n{result['stderr']}"
38
- except Exception as e:
39
- logger.error("Code execution exception", error=str(e))
40
- return f"Execution failed: {e}"
41
-
42
-
43
- def create_code_executor_agent(chat_client: BaseChatClient | None = None) -> ChatAgent:
44
- """Create a code executor agent.
45
-
46
- Args:
47
- chat_client: Optional custom chat client.
48
-
49
- Returns:
50
- ChatAgent configured for code execution.
51
- """
52
- client = chat_client or get_chat_client()
53
-
54
- return ChatAgent(
55
- name="CodeExecutorAgent",
56
- description="Executes Python code for data analysis, calculation, and simulation.",
57
- instructions="""You are a code execution expert.
58
- When asked to analyze data or perform calculations, write Python code and execute it.
59
- Use libraries like pandas, numpy, scipy, matplotlib.
60
-
61
- Always output the code you want to execute using the `execute_python_code` tool.
62
- Check the output and interpret the results.""",
63
- chat_client=client,
64
- tools=[execute_python_code],
65
- temperature=0.0, # Strict code generation
66
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/app.py CHANGED
@@ -161,7 +161,7 @@ async def research_agent(
161
  if not has_paid_key:
162
  yield (
163
  "🤗 **Free Tier**: Using HuggingFace Inference (Llama 3.1 / Mistral) for AI analysis.\n"
164
- "For premium models, enter an OpenAI or Anthropic API key below.\n\n"
165
  )
166
 
167
  # Run the agent and stream events
 
161
  if not has_paid_key:
162
  yield (
163
  "🤗 **Free Tier**: Using HuggingFace Inference (Llama 3.1 / Mistral) for AI analysis.\n"
164
+ "For premium models, enter an OpenAI API key below.\n\n"
165
  )
166
 
167
  # Run the agent and stream events
src/mcp_tools.py CHANGED
@@ -161,72 +161,3 @@ async def search_all_sources(
161
  formatted.append(f"## Europe PMC\n*Error: {europepmc_results}*\n")
162
 
163
  return "\n---\n".join(formatted)
164
-
165
-
166
- async def analyze_hypothesis(
167
- drug: str,
168
- condition: str,
169
- evidence_summary: str,
170
- ) -> str:
171
- """Perform statistical analysis of research hypothesis using Modal.
172
-
173
- Executes AI-generated Python code in a secure Modal sandbox to analyze
174
- the statistical evidence for a research hypothesis.
175
-
176
- Args:
177
- drug: The drug being evaluated (e.g., "sildenafil")
178
- condition: The target condition (e.g., "erectile dysfunction")
179
- evidence_summary: Summary of evidence to analyze
180
-
181
- Returns:
182
- Analysis result with verdict (SUPPORTED/REFUTED/INCONCLUSIVE) and statistics
183
- """
184
- from src.services.statistical_analyzer import get_statistical_analyzer
185
- from src.utils.config import settings
186
- from src.utils.models import Citation, Evidence
187
-
188
- if not settings.modal_available:
189
- return "Error: Modal credentials not configured. Set MODAL_TOKEN_ID and MODAL_TOKEN_SECRET."
190
-
191
- # Create evidence from summary
192
- evidence = [
193
- Evidence(
194
- content=evidence_summary,
195
- citation=Citation(
196
- source="pubmed",
197
- title=f"Evidence for {drug} in {condition}",
198
- url="https://example.com",
199
- date="2024-01-01",
200
- authors=["User Provided"],
201
- ),
202
- relevance=0.9,
203
- )
204
- ]
205
-
206
- analyzer = get_statistical_analyzer()
207
- result = await analyzer.analyze(
208
- query=f"Can {drug} treat {condition}?",
209
- evidence=evidence,
210
- hypothesis={"drug": drug, "target": "unknown", "pathway": "unknown", "effect": condition},
211
- )
212
-
213
- return f"""## Statistical Analysis: {drug} for {condition}
214
-
215
- ### Verdict: **{result.verdict}**
216
- **Confidence**: {result.confidence:.0%}
217
-
218
- ### Key Findings
219
- {chr(10).join(f"- {f}" for f in result.key_findings) or "- No specific findings extracted"}
220
-
221
- ### Execution Output
222
- ```
223
- {result.execution_output}
224
- ```
225
-
226
- ### Generated Code
227
- ```python
228
- {result.code_generated}
229
- ```
230
-
231
- **Executed in Modal Sandbox** - Isolated, secure, reproducible.
232
- """
 
161
  formatted.append(f"## Europe PMC\n*Error: {europepmc_results}*\n")
162
 
163
  return "\n---\n".join(formatted)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/services/llamaindex_rag.py CHANGED
@@ -1,6 +1,6 @@
1
  """LlamaIndex RAG service for evidence retrieval and indexing.
2
 
3
- Requires optional dependencies: uv sync --extra modal
4
 
5
  Migration Note (v1.0 rebrand):
6
  Default collection_name changed from "deepcritical_evidence" to "deepboner_evidence".
@@ -64,7 +64,7 @@ class LlamaIndexRAGService:
64
  from llama_index.vector_stores.chroma import ChromaVectorStore
65
  except ImportError as e:
66
  raise ImportError(
67
- "LlamaIndex dependencies not installed. Run: uv sync --extra modal"
68
  ) from e
69
 
70
  # Store references for use in other methods
@@ -91,12 +91,6 @@ class LlamaIndexRAGService:
91
  raise ConfigurationError("OPENAI_API_KEY required for LlamaIndex RAG service")
92
 
93
  # Defense-in-depth: Validate key prefix to prevent cryptic auth errors
94
- # Note: Anthropic keys start with sk-ant-, which would pass startswith("sk-")
95
- if self.api_key.startswith("sk-ant-"):
96
- raise ConfigurationError(
97
- "Anthropic keys (sk-ant-...) are not supported for embeddings. "
98
- "LlamaIndex RAG requires an OpenAI API key (sk-...)."
99
- )
100
  if not self.api_key.startswith("sk-"):
101
  raise ConfigurationError(
102
  f"Invalid API key format. Expected OpenAI key starting with 'sk-', "
 
1
  """LlamaIndex RAG service for evidence retrieval and indexing.
2
 
3
+ Requires optional dependencies: uv sync --extra rag
4
 
5
  Migration Note (v1.0 rebrand):
6
  Default collection_name changed from "deepcritical_evidence" to "deepboner_evidence".
 
64
  from llama_index.vector_stores.chroma import ChromaVectorStore
65
  except ImportError as e:
66
  raise ImportError(
67
+ "LlamaIndex dependencies not installed. Run: uv sync --extra rag"
68
  ) from e
69
 
70
  # Store references for use in other methods
 
91
  raise ConfigurationError("OPENAI_API_KEY required for LlamaIndex RAG service")
92
 
93
  # Defense-in-depth: Validate key prefix to prevent cryptic auth errors
 
 
 
 
 
 
94
  if not self.api_key.startswith("sk-"):
95
  raise ConfigurationError(
96
  f"Invalid API key format. Expected OpenAI key starting with 'sk-', "
src/services/statistical_analyzer.py DELETED
@@ -1,259 +0,0 @@
1
- """Statistical analysis service using Modal code execution.
2
-
3
- This module provides Modal-based statistical analysis WITHOUT depending on
4
- agent_framework. This allows it to be used in the simple orchestrator mode
5
- without requiring the magentic optional dependency.
6
-
7
- The AnalysisAgent (in src/agents/) wraps this service for magentic mode.
8
- """
9
-
10
- import asyncio
11
- import re
12
- from functools import lru_cache, partial
13
- from typing import Any, Literal
14
-
15
- import structlog
16
-
17
- # Type alias for verdict values
18
- VerdictType = Literal["SUPPORTED", "REFUTED", "INCONCLUSIVE"]
19
-
20
- from pydantic import BaseModel, Field
21
- from pydantic_ai import Agent
22
-
23
- from src.agent_factory.judges import get_model
24
- from src.tools.code_execution import (
25
- CodeExecutionError,
26
- get_code_executor,
27
- get_sandbox_library_prompt,
28
- )
29
- from src.utils.models import Evidence
30
-
31
- logger = structlog.get_logger()
32
-
33
-
34
- class AnalysisResult(BaseModel):
35
- """Result of statistical analysis."""
36
-
37
- verdict: VerdictType = Field(
38
- description="SUPPORTED, REFUTED, or INCONCLUSIVE",
39
- )
40
- confidence: float = Field(ge=0.0, le=1.0, description="Confidence in verdict (0-1)")
41
- statistical_evidence: str = Field(
42
- description="Summary of statistical findings from code execution"
43
- )
44
- code_generated: str = Field(description="Python code that was executed")
45
- execution_output: str = Field(description="Output from code execution")
46
- key_findings: list[str] = Field(default_factory=list, description="Key takeaways")
47
- limitations: list[str] = Field(default_factory=list, description="Limitations")
48
-
49
-
50
- class StatisticalAnalyzer:
51
- """Performs statistical analysis using Modal code execution.
52
-
53
- This service:
54
- 1. Generates Python code for statistical analysis using LLM
55
- 2. Executes code in Modal sandbox
56
- 3. Interprets results
57
- 4. Returns verdict (SUPPORTED/REFUTED/INCONCLUSIVE)
58
-
59
- Note: This class has NO agent_framework dependency, making it safe
60
- to use in the simple orchestrator without the magentic extra.
61
- """
62
-
63
- def __init__(self) -> None:
64
- """Initialize the analyzer."""
65
- self._code_executor: Any = None
66
- self._agent: Agent[None, str] | None = None
67
-
68
- def _get_code_executor(self) -> Any:
69
- """Lazy initialization of code executor."""
70
- if self._code_executor is None:
71
- self._code_executor = get_code_executor()
72
- return self._code_executor
73
-
74
- def _get_agent(self) -> Agent[None, str]:
75
- """Lazy initialization of LLM agent for code generation."""
76
- if self._agent is None:
77
- library_versions = get_sandbox_library_prompt()
78
- self._agent = Agent(
79
- model=get_model(),
80
- output_type=str,
81
- system_prompt=f"""You are a biomedical data scientist.
82
-
83
- Generate Python code to analyze research evidence and test hypotheses.
84
-
85
- Guidelines:
86
- 1. Use pandas, numpy, scipy.stats for analysis
87
- 2. Print clear, interpretable results
88
- 3. Include statistical tests (t-tests, chi-square, etc.)
89
- 4. Calculate effect sizes and confidence intervals
90
- 5. Keep code concise (<50 lines)
91
- 6. Set 'result' variable to SUPPORTED, REFUTED, or INCONCLUSIVE
92
-
93
- Available libraries:
94
- {library_versions}
95
-
96
- Output format: Return ONLY executable Python code, no explanations.""",
97
- )
98
- return self._agent
99
-
100
- async def analyze(
101
- self,
102
- query: str,
103
- evidence: list[Evidence],
104
- hypothesis: dict[str, Any] | None = None,
105
- ) -> AnalysisResult:
106
- """Run statistical analysis on evidence.
107
-
108
- Args:
109
- query: The research question
110
- evidence: List of Evidence objects to analyze
111
- hypothesis: Optional hypothesis dict with drug, target, pathway, effect
112
-
113
- Returns:
114
- AnalysisResult with verdict and statistics
115
- """
116
- # Build analysis prompt (method handles slicing internally)
117
- evidence_summary = self._summarize_evidence(evidence)
118
- hypothesis_text = ""
119
- if hypothesis:
120
- hypothesis_text = (
121
- f"\nHypothesis: {hypothesis.get('drug', 'Unknown')} → "
122
- f"{hypothesis.get('target', '?')} → "
123
- f"{hypothesis.get('pathway', '?')} → "
124
- f"{hypothesis.get('effect', '?')}\n"
125
- f"Confidence: {hypothesis.get('confidence', 0.5):.0%}\n"
126
- )
127
-
128
- prompt = f"""Generate Python code to statistically analyze:
129
-
130
- **Research Question**: {query}
131
- {hypothesis_text}
132
-
133
- **Evidence Summary**:
134
- {evidence_summary}
135
-
136
- Generate executable Python code to analyze this evidence."""
137
-
138
- try:
139
- # Generate code
140
- agent = self._get_agent()
141
- code_result = await agent.run(prompt)
142
- generated_code = code_result.output
143
-
144
- # Execute in Modal sandbox
145
- loop = asyncio.get_running_loop()
146
- executor = self._get_code_executor()
147
- execution = await loop.run_in_executor(
148
- None, partial(executor.execute, generated_code, timeout=120)
149
- )
150
-
151
- if not execution["success"]:
152
- return AnalysisResult(
153
- verdict="INCONCLUSIVE",
154
- confidence=0.0,
155
- statistical_evidence=(
156
- f"Execution failed: {execution.get('error', 'Unknown error')}"
157
- ),
158
- code_generated=generated_code,
159
- execution_output=execution.get("stderr", ""),
160
- key_findings=[],
161
- limitations=["Code execution failed"],
162
- )
163
-
164
- # Interpret results
165
- return self._interpret_results(generated_code, execution)
166
-
167
- except CodeExecutionError as e:
168
- return AnalysisResult(
169
- verdict="INCONCLUSIVE",
170
- confidence=0.0,
171
- statistical_evidence=str(e),
172
- code_generated="",
173
- execution_output="",
174
- key_findings=[],
175
- limitations=[f"Analysis error: {e}"],
176
- )
177
-
178
- def _summarize_evidence(self, evidence: list[Evidence]) -> str:
179
- """Summarize evidence for code generation prompt."""
180
- if not evidence:
181
- return "No evidence available."
182
-
183
- lines = []
184
- for i, ev in enumerate(evidence[:5], 1):
185
- content = ev.content
186
- truncated = content[:200] + ("..." if len(content) > 200 else "")
187
- lines.append(f"{i}. {truncated}")
188
- lines.append(f" Source: {ev.citation.title}")
189
- lines.append(f" Relevance: {ev.relevance:.0%}\n")
190
-
191
- return "\n".join(lines)
192
-
193
- def _interpret_results(
194
- self,
195
- code: str,
196
- execution: dict[str, Any],
197
- ) -> AnalysisResult:
198
- """Interpret code execution results."""
199
- stdout = execution["stdout"]
200
- stdout_upper = stdout.upper()
201
-
202
- # Extract verdict with robust word-boundary matching
203
- verdict: VerdictType = "INCONCLUSIVE"
204
- if re.search(r"\bSUPPORTED\b", stdout_upper) and not re.search(
205
- r"\b(?:NOT|UN)SUPPORTED\b", stdout_upper
206
- ):
207
- verdict = "SUPPORTED"
208
- elif re.search(r"\bREFUTED\b", stdout_upper):
209
- verdict = "REFUTED"
210
-
211
- # Extract key findings
212
- key_findings = []
213
- for line in stdout.split("\n"):
214
- line_lower = line.lower()
215
- if any(kw in line_lower for kw in ["p-value", "significant", "effect", "mean"]):
216
- key_findings.append(line.strip())
217
-
218
- # Calculate confidence from p-values
219
- confidence = self._calculate_confidence(stdout)
220
-
221
- return AnalysisResult(
222
- verdict=verdict,
223
- confidence=confidence,
224
- statistical_evidence=stdout.strip(),
225
- code_generated=code,
226
- execution_output=stdout,
227
- key_findings=key_findings[:5],
228
- limitations=[
229
- "Analysis based on summary data only",
230
- "Limited to available evidence",
231
- "Statistical tests assume data independence",
232
- ],
233
- )
234
-
235
- def _calculate_confidence(self, output: str) -> float:
236
- """Calculate confidence based on statistical results."""
237
- p_values = re.findall(r"p[-\s]?value[:\s]+(\d+\.?\d*)", output.lower())
238
-
239
- if p_values:
240
- try:
241
- min_p = min(float(p) for p in p_values)
242
- if min_p < 0.001:
243
- return 0.95
244
- elif min_p < 0.01:
245
- return 0.90
246
- elif min_p < 0.05:
247
- return 0.80
248
- else:
249
- return 0.60
250
- except ValueError:
251
- logger.debug("Failed to parse p-values", p_values=p_values)
252
-
253
- return 0.70 # Default
254
-
255
-
256
- @lru_cache(maxsize=1)
257
- def get_statistical_analyzer() -> StatisticalAnalyzer:
258
- """Get or create singleton StatisticalAnalyzer instance (thread-safe via lru_cache)."""
259
- return StatisticalAnalyzer()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/tools/code_execution.py DELETED
@@ -1,260 +0,0 @@
1
- """Modal-based secure code execution tool for statistical analysis.
2
-
3
- This module provides sandboxed Python code execution using Modal's serverless infrastructure.
4
- It's designed for running LLM-generated statistical analysis code safely.
5
- """
6
-
7
- from functools import lru_cache
8
- from typing import Any
9
-
10
- import structlog
11
-
12
- from src.utils.config import settings
13
-
14
- logger = structlog.get_logger(__name__)
15
-
16
- # Shared library versions for Modal sandbox - used by both executor and LLM prompts
17
- # Keep these in sync to avoid version mismatch between generated code and execution
18
- SANDBOX_LIBRARIES: dict[str, str] = {
19
- "pandas": "2.2.0",
20
- "numpy": "1.26.4",
21
- "scipy": "1.11.4",
22
- "matplotlib": "3.8.2",
23
- "scikit-learn": "1.4.0",
24
- "statsmodels": "0.14.1",
25
- }
26
-
27
-
28
- def get_sandbox_library_list() -> list[str]:
29
- """Get list of library==version strings for Modal image."""
30
- return [f"{lib}=={ver}" for lib, ver in SANDBOX_LIBRARIES.items()]
31
-
32
-
33
- def get_sandbox_library_prompt() -> str:
34
- """Get formatted library versions for LLM prompts."""
35
- return "\n".join(f"- {lib}=={ver}" for lib, ver in SANDBOX_LIBRARIES.items())
36
-
37
-
38
- class CodeExecutionError(Exception):
39
- """Raised when code execution fails."""
40
-
41
- pass
42
-
43
-
44
- class ModalCodeExecutor:
45
- """Execute Python code securely using Modal sandboxes.
46
-
47
- This class provides a safe environment for executing LLM-generated code,
48
- particularly for scientific computing and statistical analysis tasks.
49
-
50
- Features:
51
- - Sandboxed execution (isolated from host system)
52
- - Pre-installed scientific libraries (numpy, scipy, pandas, matplotlib)
53
- - Network isolation for security
54
- - Timeout protection
55
- - Stdout/stderr capture
56
-
57
- Example:
58
- >>> executor = ModalCodeExecutor()
59
- >>> result = executor.execute('''
60
- ... import pandas as pd
61
- ... df = pd.DataFrame({'a': [1, 2, 3]})
62
- ... result = df['a'].sum()
63
- ... ''')
64
- >>> print(result['stdout'])
65
- 6
66
- """
67
-
68
- def __init__(self) -> None:
69
- """Initialize Modal code executor.
70
-
71
- Note:
72
- Logs a warning if Modal credentials are not configured.
73
- Execution will fail at runtime without valid credentials.
74
- """
75
- # Check for Modal credentials
76
- self.modal_token_id = settings.modal_token_id
77
- self.modal_token_secret = settings.modal_token_secret
78
-
79
- if not self.modal_token_id or not self.modal_token_secret:
80
- logger.warning(
81
- "Modal credentials not found. Code execution will fail unless modal setup is run."
82
- )
83
-
84
- def execute(self, code: str, timeout: int = 60, allow_network: bool = False) -> dict[str, Any]:
85
- """Execute Python code in a Modal sandbox.
86
-
87
- Args:
88
- code: Python code to execute
89
- timeout: Maximum execution time in seconds (default: 60)
90
- allow_network: Whether to allow network access (default: False for security)
91
-
92
- Returns:
93
- Dictionary containing:
94
- - stdout: Standard output from code execution
95
- - stderr: Standard error from code execution
96
- - success: Boolean indicating if execution succeeded
97
- - error: Error message if execution failed
98
-
99
- Raises:
100
- CodeExecutionError: If execution fails or times out
101
- """
102
- try:
103
- import modal
104
- except ImportError as e:
105
- raise CodeExecutionError(
106
- "Modal SDK not installed. Run: uv sync or pip install modal>=0.63.0"
107
- ) from e
108
-
109
- logger.info("executing_code", code_length=len(code), timeout=timeout)
110
-
111
- try:
112
- # Create or lookup Modal app
113
- app = modal.App.lookup("deepboner-code-execution", create_if_missing=True)
114
-
115
- # Define scientific computing image with common libraries
116
- scientific_image = modal.Image.debian_slim(python_version="3.11").pip_install(
117
- *get_sandbox_library_list()
118
- )
119
-
120
- # Create sandbox with security restrictions
121
- sandbox = modal.Sandbox.create(
122
- app=app,
123
- image=scientific_image,
124
- timeout=timeout,
125
- block_network=not allow_network, # Wire the network control
126
- )
127
-
128
- try:
129
- # Execute the code
130
- # Wrap code to capture result
131
- wrapped_code = f"""
132
- import sys
133
- import io
134
- from contextlib import redirect_stdout, redirect_stderr
135
-
136
- stdout_io = io.StringIO()
137
- stderr_io = io.StringIO()
138
-
139
- try:
140
- with redirect_stdout(stdout_io), redirect_stderr(stderr_io):
141
- {self._indent_code(code, 8)}
142
- print("__EXECUTION_SUCCESS__")
143
- except Exception as e:
144
- print(f"__EXECUTION_ERROR__: {{type(e).__name__}}: {{e}}", file=sys.stderr)
145
-
146
- print("__STDOUT_START__")
147
- print(stdout_io.getvalue())
148
- print("__STDOUT_END__")
149
- print("__STDERR_START__")
150
- print(stderr_io.getvalue(), file=sys.stderr)
151
- print("__STDERR_END__", file=sys.stderr)
152
- """
153
-
154
- # Run the wrapped code
155
- process = sandbox.exec("python", "-c", wrapped_code, timeout=timeout)
156
-
157
- # Read output
158
- stdout_raw = process.stdout.read()
159
- stderr_raw = process.stderr.read()
160
- finally:
161
- # Always clean up sandbox to prevent resource leaks
162
- sandbox.terminate()
163
-
164
- # Parse output
165
- success = "__EXECUTION_SUCCESS__" in stdout_raw
166
-
167
- # Extract actual stdout/stderr
168
- stdout = self._extract_output(stdout_raw, "__STDOUT_START__", "__STDOUT_END__")
169
- stderr = self._extract_output(stderr_raw, "__STDERR_START__", "__STDERR_END__")
170
-
171
- result = {
172
- "stdout": stdout,
173
- "stderr": stderr,
174
- "success": success,
175
- "error": stderr if not success else None,
176
- }
177
-
178
- logger.info(
179
- "code_execution_completed",
180
- success=success,
181
- stdout_length=len(stdout),
182
- stderr_length=len(stderr),
183
- )
184
-
185
- return result
186
-
187
- except Exception as e:
188
- logger.error("code_execution_failed", error=str(e), error_type=type(e).__name__)
189
- raise CodeExecutionError(f"Code execution failed: {e}") from e
190
-
191
- def execute_with_return(self, code: str, timeout: int = 60) -> Any:
192
- """Execute code and return the value of the 'result' variable.
193
-
194
- Convenience method that executes code and extracts a return value.
195
- The code should assign its final result to a variable named 'result'.
196
-
197
- Args:
198
- code: Python code to execute (must set 'result' variable)
199
- timeout: Maximum execution time in seconds
200
-
201
- Returns:
202
- The value of the 'result' variable from the executed code
203
-
204
- Example:
205
- >>> executor.execute_with_return("result = 2 + 2")
206
- 4
207
- """
208
- # Modify code to print result as JSON
209
- wrapped = f"""
210
- import json
211
- {code}
212
- print(json.dumps({{"__RESULT__": result}}))
213
- """
214
-
215
- execution_result = self.execute(wrapped, timeout=timeout)
216
-
217
- if not execution_result["success"]:
218
- raise CodeExecutionError(f"Execution failed: {execution_result['error']}")
219
-
220
- # Parse result from stdout
221
- import json
222
-
223
- try:
224
- output = execution_result["stdout"].strip()
225
- if "__RESULT__" in output:
226
- # Extract JSON line
227
- for line in output.split("\n"):
228
- if "__RESULT__" in line:
229
- data = json.loads(line)
230
- return data["__RESULT__"]
231
- raise ValueError("Result not found in output")
232
- except (json.JSONDecodeError, ValueError) as e:
233
- logger.warning(
234
- "failed_to_parse_result", error=str(e), stdout=execution_result["stdout"]
235
- )
236
- return execution_result["stdout"]
237
-
238
- def _indent_code(self, code: str, spaces: int) -> str:
239
- """Indent code by specified number of spaces."""
240
- indent = " " * spaces
241
- return "\n".join(indent + line if line.strip() else line for line in code.split("\n"))
242
-
243
- def _extract_output(self, text: str, start_marker: str, end_marker: str) -> str:
244
- """Extract content between markers."""
245
- start_idx = text.find(start_marker)
246
- if start_idx == -1:
247
- return text.strip()
248
- start_idx += len(start_marker)
249
-
250
- end_idx = text.find(end_marker, start_idx)
251
- if end_idx == -1:
252
- return text.strip()
253
-
254
- return text[start_idx:end_idx].strip()
255
-
256
-
257
- @lru_cache(maxsize=1)
258
- def get_code_executor() -> ModalCodeExecutor:
259
- """Get or create singleton code executor instance (thread-safe via lru_cache)."""
260
- return ModalCodeExecutor()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/utils/config.py CHANGED
@@ -42,7 +42,7 @@ class Settings(BaseSettings):
42
  )
43
 
44
  # Embedding Configuration
45
- # Note: OpenAI embeddings require OPENAI_API_KEY (Anthropic has no embeddings API)
46
  openai_embedding_model: str = Field(
47
  default="text-embedding-3-small",
48
  description="OpenAI embedding model (used by LlamaIndex RAG)",
@@ -77,15 +77,8 @@ class Settings(BaseSettings):
77
  log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR"] = "INFO"
78
 
79
  # External Services
80
- modal_token_id: str | None = Field(default=None, description="Modal token ID")
81
- modal_token_secret: str | None = Field(default=None, description="Modal token secret")
82
  chroma_db_path: str = Field(default="./chroma_db", description="ChromaDB storage path")
83
 
84
- @property
85
- def modal_available(self) -> bool:
86
- """Check if Modal credentials are configured."""
87
- return bool(self.modal_token_id and self.modal_token_secret)
88
-
89
  def get_api_key(self) -> str:
90
  """Get the API key for the configured provider."""
91
  # Normalize provider for case-insensitive matching
 
42
  )
43
 
44
  # Embedding Configuration
45
+ # Note: OpenAI embeddings require OPENAI_API_KEY
46
  openai_embedding_model: str = Field(
47
  default="text-embedding-3-small",
48
  description="OpenAI embedding model (used by LlamaIndex RAG)",
 
77
  log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR"] = "INFO"
78
 
79
  # External Services
 
 
80
  chroma_db_path: str = Field(default="./chroma_db", description="ChromaDB storage path")
81
 
 
 
 
 
 
82
  def get_api_key(self) -> str:
83
  """Get the API key for the configured provider."""
84
  # Normalize provider for case-insensitive matching
src/utils/exceptions.py CHANGED
@@ -49,12 +49,6 @@ class QuotaExceededError(LLMError):
49
  pass
50
 
51
 
52
- class ModalError(DeepBonerError):
53
- """Raised when Modal sandbox operations fail."""
54
-
55
- pass
56
-
57
-
58
  class SynthesisError(DeepBonerError):
59
  """Raised when report synthesis fails after trying all available models.
60
 
 
49
  pass
50
 
51
 
 
 
 
 
 
 
52
  class SynthesisError(DeepBonerError):
53
  """Raised when report synthesis fails after trying all available models.
54
 
src/utils/llm_factory.py DELETED
@@ -1,64 +0,0 @@
1
- """Centralized LLM client factory.
2
-
3
- This module provides factory functions for creating LLM clients.
4
- DEPRECATED: Prefer src.clients.factory.get_chat_client() directly.
5
- """
6
-
7
- from typing import Any
8
-
9
- from src.clients.base import BaseChatClient
10
- from src.clients.factory import get_chat_client
11
- from src.utils.config import settings
12
- from src.utils.exceptions import ConfigurationError
13
-
14
-
15
- def get_magentic_client() -> BaseChatClient:
16
- """
17
- Get the chat client for Magentic agents.
18
-
19
- Now unified to support OpenAI, Gemini, and HuggingFace.
20
- """
21
- return get_chat_client()
22
-
23
-
24
- def get_pydantic_ai_model() -> Any:
25
- """
26
- Get the appropriate model for pydantic-ai based on configuration.
27
- Used by legacy Simple Mode components.
28
- """
29
- from pydantic_ai.models.openai import OpenAIChatModel
30
- from pydantic_ai.providers.openai import OpenAIProvider
31
-
32
- # Normalize provider for case-insensitive matching
33
- provider_lower = settings.llm_provider.lower() if settings.llm_provider else ""
34
-
35
- if provider_lower == "openai":
36
- if not settings.openai_api_key:
37
- raise ConfigurationError("OPENAI_API_KEY not set for pydantic-ai")
38
- provider = OpenAIProvider(api_key=settings.openai_api_key)
39
- return OpenAIChatModel(settings.openai_model, provider=provider)
40
-
41
- if provider_lower == "anthropic":
42
- raise ConfigurationError("Anthropic is not supported (no embeddings API). See P3 doc.")
43
-
44
- raise ConfigurationError(f"Unknown LLM provider for simple mode: {settings.llm_provider}")
45
-
46
-
47
- def check_magentic_requirements() -> None:
48
- """
49
- Check if Magentic mode requirements are met.
50
- Now supports multiple providers via ChatClientFactory.
51
- """
52
- # Advanced/Magentic mode now works with ANY provider (including free HF)
53
- pass
54
-
55
-
56
- def check_simple_mode_requirements() -> None:
57
- """
58
- Check if simple mode requirements are met.
59
- """
60
- if not settings.has_any_llm_key:
61
- # Simple mode still requires explicit keys?
62
- # Actually, simple mode also had HF support but it was brittle.
63
- # We are deleting simple mode later, so let's leave this as is for now.
64
- pass
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/utils/service_loader.py CHANGED
@@ -18,7 +18,6 @@ from src.utils.config import settings
18
 
19
  if TYPE_CHECKING:
20
  from src.services.embedding_protocol import EmbeddingServiceProtocol
21
- from src.services.statistical_analyzer import StatisticalAnalyzer
22
 
23
  logger = structlog.get_logger()
24
 
@@ -66,13 +65,9 @@ def get_embedding_service(api_key: str | None = None) -> "EmbeddingServiceProtoc
66
  ImportError: If no embedding service dependencies are available
67
  """
68
  # Determine if we have a valid OpenAI key (BYOK or Env)
69
- # Note: Must check sk-ant- BEFORE sk- since Anthropic keys start with sk-ant-
70
  has_openai = False
71
  if api_key:
72
- if api_key.startswith("sk-ant-"):
73
- # Anthropic key - not supported for embeddings
74
- logger.warning("Anthropic keys don't support embeddings, falling back to free tier")
75
- elif api_key.startswith("sk-"):
76
  # OpenAI BYOK
77
  has_openai = True
78
  elif settings.has_openai_key:
@@ -125,7 +120,7 @@ def get_embedding_service(api_key: str | None = None) -> "EmbeddingServiceProtoc
125
  raise ImportError(
126
  "No embedding service available. Install either:\n"
127
  " - uv sync --extra embeddings (for local embeddings)\n"
128
- " - uv sync --extra modal (for LlamaIndex with OpenAI)"
129
  ) from e
130
 
131
 
@@ -157,29 +152,3 @@ def get_embedding_service_if_available(
157
  error_type=type(e).__name__,
158
  )
159
  return None
160
-
161
-
162
- def get_analyzer_if_available() -> "StatisticalAnalyzer | None":
163
- """Safely attempt to load and initialize the StatisticalAnalyzer.
164
-
165
- Returns:
166
- StatisticalAnalyzer instance if Modal is available, else None.
167
- """
168
- try:
169
- from src.services.statistical_analyzer import get_statistical_analyzer
170
-
171
- analyzer = get_statistical_analyzer()
172
- logger.info("StatisticalAnalyzer initialized successfully")
173
- return analyzer
174
- except ImportError as e:
175
- logger.info(
176
- "StatisticalAnalyzer not available (Modal dependencies missing)",
177
- missing_dependency=str(e),
178
- )
179
- except Exception as e:
180
- logger.warning(
181
- "StatisticalAnalyzer initialization failed unexpectedly",
182
- error=str(e),
183
- error_type=type(e).__name__,
184
- )
185
- return None
 
18
 
19
  if TYPE_CHECKING:
20
  from src.services.embedding_protocol import EmbeddingServiceProtocol
 
21
 
22
  logger = structlog.get_logger()
23
 
 
65
  ImportError: If no embedding service dependencies are available
66
  """
67
  # Determine if we have a valid OpenAI key (BYOK or Env)
 
68
  has_openai = False
69
  if api_key:
70
+ if api_key.startswith("sk-"):
 
 
 
71
  # OpenAI BYOK
72
  has_openai = True
73
  elif settings.has_openai_key:
 
120
  raise ImportError(
121
  "No embedding service available. Install either:\n"
122
  " - uv sync --extra embeddings (for local embeddings)\n"
123
+ " - uv sync --extra rag (for LlamaIndex with OpenAI)"
124
  ) from e
125
 
126
 
 
152
  error_type=type(e).__name__,
153
  )
154
  return None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/integration/test_modal.py DELETED
@@ -1,67 +0,0 @@
1
- """Integration tests for Modal (requires credentials and modal package)."""
2
-
3
- import pytest
4
-
5
- from src.utils.config import settings
6
-
7
- # Check if any LLM API key is available
8
- _llm_available = bool(settings.openai_api_key or settings.anthropic_api_key)
9
-
10
- # Check if modal package is installed
11
- try:
12
- import modal # noqa: F401
13
-
14
- _modal_installed = True
15
- except ImportError:
16
- _modal_installed = False
17
-
18
-
19
- @pytest.mark.integration
20
- @pytest.mark.skipif(not _modal_installed, reason="Modal package not installed")
21
- @pytest.mark.skipif(not settings.modal_available, reason="Modal credentials not configured")
22
- class TestModalIntegration:
23
- """Integration tests requiring Modal credentials."""
24
-
25
- @pytest.mark.asyncio
26
- async def test_sandbox_executes_code(self) -> None:
27
- """Modal sandbox should execute Python code."""
28
- import asyncio
29
- from functools import partial
30
-
31
- from src.tools.code_execution import get_code_executor
32
-
33
- executor = get_code_executor()
34
- code = "import pandas as pd; print(pd.DataFrame({'a': [1,2,3]})['a'].sum())"
35
-
36
- loop = asyncio.get_running_loop()
37
- result = await loop.run_in_executor(None, partial(executor.execute, code, timeout=30))
38
-
39
- assert result["success"]
40
- assert "6" in result["stdout"]
41
-
42
- @pytest.mark.asyncio
43
- @pytest.mark.skipif(not _llm_available, reason="LLM API key not configured")
44
- async def test_statistical_analyzer_works(self) -> None:
45
- """StatisticalAnalyzer should work end-to-end (requires Modal + LLM)."""
46
- from src.services.statistical_analyzer import get_statistical_analyzer
47
- from src.utils.models import Citation, Evidence
48
-
49
- evidence = [
50
- Evidence(
51
- content="Drug shows 40% improvement in trial.",
52
- citation=Citation(
53
- source="pubmed",
54
- title="Test",
55
- url="https://test.com",
56
- date="2024-01-01",
57
- authors=["Test"],
58
- ),
59
- relevance=0.9,
60
- )
61
- ]
62
-
63
- analyzer = get_statistical_analyzer()
64
- result = await analyzer.analyze("test drug efficacy", evidence)
65
-
66
- assert result.verdict in ["SUPPORTED", "REFUTED", "INCONCLUSIVE"]
67
- assert 0.0 <= result.confidence <= 1.0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/unit/agent_factory/test_get_model_auto_detect.py CHANGED
@@ -7,11 +7,7 @@ from src.utils.config import settings
7
 
8
 
9
  class TestGetModelAutoDetect:
10
- """Test that get_model() auto-detects available providers.
11
-
12
- NOTE: Anthropic is NOT supported (no embeddings API).
13
- See P3_REMOVE_ANTHROPIC_PARTIAL_WIRING.md.
14
- """
15
 
16
  def test_returns_openai_when_key_present(self, monkeypatch):
17
  """OpenAI key present → OpenAI model."""
@@ -30,16 +26,6 @@ class TestGetModelAutoDetect:
30
  model = get_model(api_key="sk-byok-test-key")
31
  assert isinstance(model, OpenAIChatModel)
32
 
33
- def test_byok_anthropic_key_raises_not_implemented(self, monkeypatch):
34
- """BYOK: api_key='sk-ant-...' → NotImplementedError (Anthropic not supported)."""
35
- monkeypatch.setattr(settings, "openai_api_key", None)
36
- monkeypatch.setattr(settings, "hf_token", None)
37
-
38
- with pytest.raises(NotImplementedError) as exc_info:
39
- get_model(api_key="sk-ant-test-key")
40
-
41
- assert "Anthropic is not supported" in str(exc_info.value)
42
-
43
  def test_returns_huggingface_when_hf_token_present(self, monkeypatch):
44
  """HF_TOKEN present (no paid keys) → HuggingFace model."""
45
  monkeypatch.setattr(settings, "openai_api_key", None)
@@ -57,7 +43,6 @@ class TestGetModelAutoDetect:
57
  monkeypatch.delenv("HF_TOKEN", raising=False)
58
 
59
  # Should raise clear error when no tokens available
60
- import pytest
61
 
62
  with pytest.raises(RuntimeError) as exc_info:
63
  get_model()
 
7
 
8
 
9
  class TestGetModelAutoDetect:
10
+ """Test that get_model() auto-detects available providers."""
 
 
 
 
11
 
12
  def test_returns_openai_when_key_present(self, monkeypatch):
13
  """OpenAI key present → OpenAI model."""
 
26
  model = get_model(api_key="sk-byok-test-key")
27
  assert isinstance(model, OpenAIChatModel)
28
 
 
 
 
 
 
 
 
 
 
 
29
  def test_returns_huggingface_when_hf_token_present(self, monkeypatch):
30
  """HF_TOKEN present (no paid keys) → HuggingFace model."""
31
  monkeypatch.setattr(settings, "openai_api_key", None)
 
43
  monkeypatch.delenv("HF_TOKEN", raising=False)
44
 
45
  # Should raise clear error when no tokens available
 
46
 
47
  with pytest.raises(RuntimeError) as exc_info:
48
  get_model()
tests/unit/agent_factory/test_judges_factory.py CHANGED
@@ -1,8 +1,4 @@
1
- """Unit tests for Judge Factory and Model Selection.
2
-
3
- NOTE: Anthropic is NOT supported (no embeddings API).
4
- See P3_REMOVE_ANTHROPIC_PARTIAL_WIRING.md.
5
- """
6
 
7
  from unittest.mock import patch
8
 
@@ -42,16 +38,6 @@ def test_get_model_byok_openai(mock_settings):
42
  assert isinstance(model, OpenAIChatModel)
43
 
44
 
45
- def test_get_model_byok_anthropic_raises(mock_settings):
46
- """Test that BYOK Anthropic key raises NotImplementedError."""
47
- mock_settings.has_openai_key = False
48
-
49
- with pytest.raises(NotImplementedError) as exc_info:
50
- get_model(api_key="sk-ant-test")
51
-
52
- assert "Anthropic is not supported" in str(exc_info.value)
53
-
54
-
55
  def test_get_model_huggingface(mock_settings):
56
  """Test that HuggingFace model is returned when no paid keys."""
57
  mock_settings.has_openai_key = False
 
1
+ """Unit tests for Judge Factory and Model Selection."""
 
 
 
 
2
 
3
  from unittest.mock import patch
4
 
 
38
  assert isinstance(model, OpenAIChatModel)
39
 
40
 
 
 
 
 
 
 
 
 
 
 
41
  def test_get_model_huggingface(mock_settings):
42
  """Test that HuggingFace model is returned when no paid keys."""
43
  mock_settings.has_openai_key = False
tests/unit/orchestrators/test_advanced_p2_dead_zones.py CHANGED
@@ -14,7 +14,6 @@ async def test_advanced_initialization_events():
14
  patch("src.orchestrators.advanced.AdvancedOrchestrator._init_embedding_service"),
15
  patch("src.orchestrators.advanced.init_magentic_state"),
16
  patch("src.orchestrators.advanced.AdvancedOrchestrator._build_workflow") as mock_build,
17
- patch("src.utils.llm_factory.check_magentic_requirements"),
18
  ): # Bypass check
19
  # Setup mocks
20
  mock_workflow = MagicMock()
 
14
  patch("src.orchestrators.advanced.AdvancedOrchestrator._init_embedding_service"),
15
  patch("src.orchestrators.advanced.init_magentic_state"),
16
  patch("src.orchestrators.advanced.AdvancedOrchestrator._build_workflow") as mock_build,
 
17
  ): # Bypass check
18
  # Setup mocks
19
  mock_workflow = MagicMock()
tests/unit/services/test_statistical_analyzer.py DELETED
@@ -1,104 +0,0 @@
1
- "Unit tests for StatisticalAnalyzer service."
2
-
3
- from unittest.mock import AsyncMock, MagicMock, patch
4
-
5
- import pytest
6
-
7
- from src.services.statistical_analyzer import (
8
- AnalysisResult,
9
- StatisticalAnalyzer,
10
- get_statistical_analyzer,
11
- )
12
- from src.utils.models import Citation, Evidence
13
-
14
-
15
- @pytest.fixture
16
- def sample_evidence() -> list[Evidence]:
17
- """Sample evidence for testing."""
18
- return [
19
- Evidence(
20
- content="Testosterone therapy shows effect size of 0.45.",
21
- citation=Citation(
22
- source="pubmed",
23
- title="Testosterone HSDD Study",
24
- url="https://pubmed.ncbi.nlm.nih.gov/12345/",
25
- date="2024-01-15",
26
- authors=["Smith J"],
27
- ),
28
- relevance=0.9,
29
- )
30
- ]
31
-
32
-
33
- class TestStatisticalAnalyzer:
34
- """Tests for StatisticalAnalyzer (no agent_framework dependency)."""
35
-
36
- def test_no_agent_framework_import(self) -> None:
37
- """StatisticalAnalyzer must NOT import agent_framework."""
38
- import src.services.statistical_analyzer as module
39
-
40
- # Check module doesn't import agent_framework
41
- with open(module.__file__) as f:
42
- source = f.read()
43
- assert "from agent_framework" not in source
44
- assert "import agent_framework" not in source
45
- assert "BaseAgent" not in source
46
-
47
- @pytest.mark.asyncio
48
- async def test_analyze_returns_result(self, sample_evidence: list[Evidence]) -> None:
49
- """analyze() should return AnalysisResult."""
50
- analyzer = StatisticalAnalyzer()
51
-
52
- with (
53
- patch.object(analyzer, "_get_agent") as mock_agent,
54
- patch.object(analyzer, "_get_code_executor") as mock_executor,
55
- ):
56
- # Mock LLM
57
- mock_agent.return_value.run = AsyncMock(
58
- return_value=MagicMock(output="print('SUPPORTED')")
59
- )
60
-
61
- # Mock Modal
62
- mock_executor.return_value.execute.return_value = {
63
- "stdout": "SUPPORTED\np-value: 0.01",
64
- "stderr": "",
65
- "success": True,
66
- }
67
-
68
- result = await analyzer.analyze("test query", sample_evidence)
69
-
70
- assert isinstance(result, AnalysisResult)
71
- assert result.verdict == "SUPPORTED"
72
-
73
- def test_singleton(self) -> None:
74
- """get_statistical_analyzer should return singleton."""
75
- a1 = get_statistical_analyzer()
76
- a2 = get_statistical_analyzer()
77
- assert a1 is a2
78
-
79
-
80
- class TestAnalysisResult:
81
- """Tests for AnalysisResult model."""
82
-
83
- def test_verdict_values(self) -> None:
84
- """Verdict should be one of the expected values."""
85
- for verdict in ["SUPPORTED", "REFUTED", "INCONCLUSIVE"]:
86
- result = AnalysisResult(
87
- verdict=verdict, # type: ignore
88
- confidence=0.8,
89
- statistical_evidence="test",
90
- code_generated="print('test')",
91
- execution_output="test",
92
- )
93
- assert result.verdict == verdict
94
-
95
- def test_confidence_bounds(self) -> None:
96
- """Confidence must be 0.0-1.0."""
97
- with pytest.raises(ValueError):
98
- AnalysisResult(
99
- verdict="SUPPORTED",
100
- confidence=1.5, # Invalid
101
- statistical_evidence="test",
102
- code_generated="test",
103
- execution_output="test",
104
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/unit/test_app_smoke.py CHANGED
@@ -35,7 +35,6 @@ class TestAppSmoke:
35
  Ensures the MCP server can expose these tools.
36
  """
37
  from src.mcp_tools import (
38
- analyze_hypothesis,
39
  search_all_sources,
40
  search_clinical_trials,
41
  search_europepmc,
@@ -47,4 +46,3 @@ class TestAppSmoke:
47
  assert callable(search_clinical_trials)
48
  assert callable(search_europepmc)
49
  assert callable(search_all_sources)
50
- assert callable(analyze_hypothesis)
 
35
  Ensures the MCP server can expose these tools.
36
  """
37
  from src.mcp_tools import (
 
38
  search_all_sources,
39
  search_clinical_trials,
40
  search_europepmc,
 
46
  assert callable(search_clinical_trials)
47
  assert callable(search_europepmc)
48
  assert callable(search_all_sources)
 
tests/unit/utils/test_service_loader.py CHANGED
@@ -1,36 +1,37 @@
1
  from unittest.mock import MagicMock, patch
2
 
3
  from src.utils.service_loader import (
4
- get_analyzer_if_available,
5
  get_embedding_service_if_available,
6
  )
7
 
8
 
9
- def test_get_embedding_service_success():
10
- """Test successful loading of embedding service (free tier fallback)."""
11
- mock_service = MagicMock()
12
 
13
- # Patch settings to disable premium tier, then patch the local service
14
- with patch("src.utils.service_loader.settings") as mock_settings:
15
- mock_settings.has_openai_key = False
16
 
17
- with patch("src.services.embeddings.get_embedding_service", return_value=mock_service):
18
- service = get_embedding_service_if_available()
19
- assert service is mock_service
20
 
 
 
 
21
 
22
- def test_get_embedding_service_import_error():
23
- """Test handling of ImportError when loading embedding service."""
24
- # Disable premium tier, then make local service fail
25
- with patch("src.utils.service_loader.settings") as mock_settings:
26
- mock_settings.has_openai_key = False
27
 
28
- with patch(
29
- "src.services.embeddings.get_embedding_service",
30
- side_effect=ImportError("Missing deps"),
31
- ):
32
- service = get_embedding_service_if_available()
33
- assert service is None
34
 
35
 
36
  def test_get_embedding_service_generic_error():
@@ -47,33 +48,97 @@ def test_get_embedding_service_generic_error():
47
  assert service is None
48
 
49
 
50
- def test_get_analyzer_success():
51
- """Test successful loading of analyzer."""
52
- with patch("src.services.statistical_analyzer.get_statistical_analyzer") as mock_get:
53
- mock_analyzer = MagicMock()
54
- mock_get.return_value = mock_analyzer
55
-
56
- analyzer = get_analyzer_if_available()
57
-
58
- assert analyzer is mock_analyzer
59
- mock_get.assert_called_once()
60
-
61
-
62
- def test_get_analyzer_import_error():
63
- """Test handling of ImportError when loading analyzer."""
64
- with patch(
65
- "src.services.statistical_analyzer.get_statistical_analyzer",
66
- side_effect=ImportError("No Modal"),
67
- ):
68
- analyzer = get_analyzer_if_available()
69
- assert analyzer is None
70
-
71
-
72
- def test_get_analyzer_generic_error():
73
- """Test handling of generic Exception when loading analyzer."""
74
- with patch(
75
- "src.services.statistical_analyzer.get_statistical_analyzer",
76
- side_effect=RuntimeError("Fail"),
77
- ):
78
- analyzer = get_analyzer_if_available()
79
- assert analyzer is None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  from unittest.mock import MagicMock, patch
2
 
3
  from src.utils.service_loader import (
 
4
  get_embedding_service_if_available,
5
  )
6
 
7
 
8
+ class TestGetEmbeddingServiceIfAvailable:
9
+ """Test get_embedding_service_if_available() safety wrapper."""
 
10
 
11
+ def test_returns_service_when_available(self):
12
+ """Test successful loading of embedding service (free tier fallback)."""
13
+ mock_service = MagicMock()
14
 
15
+ # Patch settings to disable premium tier, then patch the local service
16
+ with patch("src.utils.service_loader.settings") as mock_settings:
17
+ mock_settings.has_openai_key = False
18
 
19
+ with patch("src.services.embeddings.get_embedding_service", return_value=mock_service):
20
+ service = get_embedding_service_if_available()
21
+ assert service is mock_service
22
 
23
+ def test_returns_none_when_no_service_available(self):
24
+ """Test handling of ImportError when loading embedding service."""
25
+ # Disable premium tier, then make local service fail
26
+ with patch("src.utils.service_loader.settings") as mock_settings:
27
+ mock_settings.has_openai_key = False
28
 
29
+ with patch(
30
+ "src.services.embeddings.get_embedding_service",
31
+ side_effect=ImportError("Missing deps"),
32
+ ):
33
+ service = get_embedding_service_if_available()
34
+ assert service is None
35
 
36
 
37
  def test_get_embedding_service_generic_error():
 
48
  assert service is None
49
 
50
 
51
+ class TestGetEmbeddingService:
52
+ """Test get_embedding_service() logic."""
53
+
54
+ def test_uses_llamaindex_when_openai_key_present(self):
55
+ """OpenAI key (env) → LlamaIndex."""
56
+ with patch("src.utils.service_loader.settings") as mock_settings:
57
+ mock_settings.has_openai_key = True
58
+ mock_settings.openai_api_key = "sk-env"
59
+
60
+ # Mock LlamaIndex dependencies and factory
61
+ with patch.dict(
62
+ "sys.modules",
63
+ {
64
+ "src.services.llamaindex_rag": MagicMock(),
65
+ "chromadb": MagicMock(),
66
+ "llama_index": MagicMock(),
67
+ },
68
+ ):
69
+ mock_rag_service = MagicMock()
70
+ with patch(
71
+ "src.services.llamaindex_rag.get_rag_service", return_value=mock_rag_service
72
+ ):
73
+ from src.utils.service_loader import get_embedding_service
74
+
75
+ service = get_embedding_service()
76
+ assert service is mock_rag_service
77
+
78
+ def test_uses_llamaindex_when_byok_key_present(self):
79
+ """BYOK key → LlamaIndex."""
80
+ with patch("src.utils.service_loader.settings") as mock_settings:
81
+ mock_settings.has_openai_key = False
82
+
83
+ with patch.dict(
84
+ "sys.modules",
85
+ {
86
+ "src.services.llamaindex_rag": MagicMock(),
87
+ },
88
+ ):
89
+ mock_rag_service = MagicMock()
90
+ with patch(
91
+ "src.services.llamaindex_rag.get_rag_service", return_value=mock_rag_service
92
+ ):
93
+ from src.utils.service_loader import get_embedding_service
94
+
95
+ service = get_embedding_service(api_key="sk-test")
96
+ assert service is mock_rag_service
97
+
98
+ def test_falls_back_to_local_when_no_openai_key(self):
99
+ """No OpenAI key → Local embeddings."""
100
+ with patch("src.utils.service_loader.settings") as mock_settings:
101
+ mock_settings.has_openai_key = False
102
+
103
+ mock_local_service = MagicMock()
104
+ with patch(
105
+ "src.services.embeddings.get_embedding_service", return_value=mock_local_service
106
+ ):
107
+ from src.utils.service_loader import get_embedding_service
108
+
109
+ service = get_embedding_service()
110
+ assert service is mock_local_service
111
+
112
+ def test_falls_back_when_llamaindex_import_fails(self):
113
+ """LlamaIndex fails import → Local embeddings."""
114
+ with patch("src.utils.service_loader.settings") as mock_settings:
115
+ mock_settings.has_openai_key = True
116
+
117
+ # Mock ImportError for LlamaIndex
118
+ with patch(
119
+ "src.services.llamaindex_rag.get_rag_service", side_effect=ImportError("No deps")
120
+ ):
121
+ mock_local_service = MagicMock()
122
+ with patch(
123
+ "src.services.embeddings.get_embedding_service", return_value=mock_local_service
124
+ ):
125
+ from src.utils.service_loader import get_embedding_service
126
+
127
+ service = get_embedding_service()
128
+ assert service is mock_local_service
129
+
130
+ def test_raises_when_no_embedding_service_available(self):
131
+ """All services fail → ImportError."""
132
+ with patch("src.utils.service_loader.settings") as mock_settings:
133
+ mock_settings.has_openai_key = False
134
+
135
+ with patch(
136
+ "src.services.embeddings.get_embedding_service", side_effect=ImportError("No deps")
137
+ ):
138
+ import pytest
139
+
140
+ from src.utils.service_loader import get_embedding_service
141
+
142
+ with pytest.raises(ImportError) as exc:
143
+ get_embedding_service()
144
+ assert "No embedding service available" in str(exc.value)
uv.lock CHANGED
@@ -1,6 +1,6 @@
1
  version = 1
2
  revision = 1
3
- requires-python = ">=3.11"
4
  resolution-markers = [
5
  "python_full_version >= '3.13'",
6
  "python_full_version == '3.12.*'",
@@ -619,47 +619,6 @@ wheels = [
619
  { url = "https://files.pythonhosted.org/packages/e6/46/eb6eca305c77a4489affe1c5d8f4cae82f285d9addd8de4ec084a7184221/cachetools-6.2.2-py3-none-any.whl", hash = "sha256:6c09c98183bf58560c97b2abfcedcbaf6a896a490f534b031b661d3723b45ace", size = 11503 },
620
  ]
621
 
622
- [[package]]
623
- name = "cbor2"
624
- version = "5.7.1"
625
- source = { registry = "https://pypi.org/simple" }
626
- sdist = { url = "https://files.pythonhosted.org/packages/a2/b8/c0f6a7d46f816cb18b1fda61a2fe648abe16039f1ff93ea720a6e9fb3cee/cbor2-5.7.1.tar.gz", hash = "sha256:7a405a1d7c8230ee9acf240aad48ae947ef584e8af05f169f3c1bde8f01f8b71", size = 102467 }
627
- wheels = [
628
- { url = "https://files.pythonhosted.org/packages/52/67/319baac9c51de0053f58fa74a9548f93f3629aa3adeebd7d2c99d1379370/cbor2-5.7.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2b1efbe6e82721be44b9faf47d0fd97b0150213eb6a4ba554f4947442bc4e13f", size = 67894 },
629
- { url = "https://files.pythonhosted.org/packages/2c/53/d23d0a234a4a098b019ac1cadd33631c973142fc947a68c4a38ca47aa5dc/cbor2-5.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fb94bab27e00283bdd8f160e125e17dbabec4c9e6ffc8da91c36547ec1eb707f", size = 68444 },
630
- { url = "https://files.pythonhosted.org/packages/3a/a2/a6fa59e1c23b0bc77628d64153eb9fc69ac8dde5f8ed41a7d5316fcd0bcd/cbor2-5.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:29f22266b5e08e0e4152e87ba185e04d3a84a4fd545b99ae3ebe42c658c66a53", size = 261600 },
631
- { url = "https://files.pythonhosted.org/packages/3d/cb/e0fa066aa7a09b15b8f56bafef6b2be19d9db31310310b0a5601af5c0128/cbor2-5.7.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:25d4c7554d6627da781c9bd1d0dd0709456eecb71f605829f98961bb98487dda", size = 254904 },
632
- { url = "https://files.pythonhosted.org/packages/2c/d5/b1fb4a3828c440e100a4b2658dd2e8f422faf08f4fcc8e2c92b240656b44/cbor2-5.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f1e15c3a08008cf13ce1dfc64d17c960df5d66d935788d28ec7df54bf0ffb0ef", size = 257388 },
633
- { url = "https://files.pythonhosted.org/packages/34/d5/252657bc5af964fc5f19c0e0e82031b4c32eba5d3ed4098e963e0e8c47a6/cbor2-5.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9f6cdf7eb604ea0e7ef34e3f0b5447da0029ecd3ab7b2dc70e43fa5f7bcfca89", size = 251494 },
634
- { url = "https://files.pythonhosted.org/packages/8a/3a/503ea4c2977411858ca287808d077fdb4bb1fafdb4b39177b8ce3d5619ac/cbor2-5.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:dd25cbef8e8e6dbf69f0de95311aecaca7217230cda83ae99fdc37cd20d99250", size = 68147 },
635
- { url = "https://files.pythonhosted.org/packages/49/9e/fe4c9703fd444da193f892787110c5da2a85c16d26917fcb2584f5d00077/cbor2-5.7.1-cp311-cp311-win_arm64.whl", hash = "sha256:40cc9c67242a7abac5a4e062bc4d1d2376979878c0565a4b2f08fd9ed9212945", size = 64126 },
636
- { url = "https://files.pythonhosted.org/packages/56/54/48426472f0c051982c647331441aed09b271a0500356ae0b7054c813d174/cbor2-5.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bd5ca44891c06f6b85d440836c967187dc1d30b15f86f315d55c675d3a841078", size = 69031 },
637
- { url = "https://files.pythonhosted.org/packages/d3/68/1dd58c7706e9752188358223db58c83f3c48e07f728aa84221ffd244652f/cbor2-5.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:537d73ef930ccc1a7b6a2e8d2cbf81407d270deb18e40cda5eb511bd70f71078", size = 68825 },
638
- { url = "https://files.pythonhosted.org/packages/09/4e/380562fe9f9995a1875fb5ec26fd041e19d61f4630cb690a98c5195945fc/cbor2-5.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:edbf814dd7763b6eda27a5770199f6ccd55bd78be8f4367092460261bfbf19d0", size = 286222 },
639
- { url = "https://files.pythonhosted.org/packages/7c/bb/9eccdc1ea3c4d5c7cdb2e49b9de49534039616be5455ce69bd64c0b2efe2/cbor2-5.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9fc81da8c0e09beb42923e455e477b36ff14a03b9ca18a8a2e9b462de9a953e8", size = 285688 },
640
- { url = "https://files.pythonhosted.org/packages/59/8c/4696d82f5bd04b3d45d9a64ec037fa242630c134e3218d6c252b4f59b909/cbor2-5.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e4a7d660d428911a3aadb7105e94438d7671ab977356fdf647a91aab751033bd", size = 277063 },
641
- { url = "https://files.pythonhosted.org/packages/95/50/6538e44ca970caaad2fa376b81701d073d84bf597aac07a59d0a253b1a7f/cbor2-5.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:228e0af9c0a9ddf6375b6ae010eaa1942a1901d403f134ac9ee6a76a322483f9", size = 278334 },
642
- { url = "https://files.pythonhosted.org/packages/64/a9/156ccd2207fb26b5b61d23728b4dbdc595d1600125aa79683a4a8ddc9313/cbor2-5.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:2d08a6c0d9ed778448e185508d870f4160ba74f59bb17a966abd0d14d0ff4dd3", size = 68404 },
643
- { url = "https://files.pythonhosted.org/packages/4f/49/adc53615e9dd32c4421f6935dfa2235013532c6e6b28ee515bbdd92618be/cbor2-5.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:752506cfe72da0f4014b468b30191470ee8919a64a0772bd3b36a4fccf5fcefc", size = 64047 },
644
- { url = "https://files.pythonhosted.org/packages/16/b1/51fb868fe38d893c570bb90b38d365ff0f00421402c1ae8f63b31b25d665/cbor2-5.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:59d5da59fffe89692d5bd1530eef4d26e4eb7aa794aaa1f4e192614786409009", size = 69068 },
645
- { url = "https://files.pythonhosted.org/packages/b9/db/5abc62ec456f552f617aac3359a5d7114b23be9c4d886169592cd5f074b9/cbor2-5.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:533117918d518e01348f8cd0331271c207e7224b9a1ed492a0ff00847f28edc8", size = 68927 },
646
- { url = "https://files.pythonhosted.org/packages/9a/c2/58d787395c99874d2a2395b3a22c9d48a3cfc5a7dcd5817bf74764998b75/cbor2-5.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8d6d9436ff3c3323ea5863ecf7ae1139590991685b44b9eb6b7bb1734a594af6", size = 285185 },
647
- { url = "https://files.pythonhosted.org/packages/d0/9c/b680b264a8f4b9aa59c95e166c816275a13138cbee92dd2917f58bca47b9/cbor2-5.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:661b871ca754a619fcd98c13a38b4696b2b57dab8b24235c00b0ba322c040d24", size = 284440 },
648
- { url = "https://files.pythonhosted.org/packages/1f/59/68183c655d6226d0eee10027f52516882837802a8d5746317a88362ed686/cbor2-5.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d8065aa90d715fd9bb28727b2d774ee16e695a0e1627ae76e54bf19f9d99d63f", size = 276876 },
649
- { url = "https://files.pythonhosted.org/packages/ee/a2/1964e0a569d2b81e8f4862753fee7701ae5773c22e45492a26f92f62e75a/cbor2-5.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cb1b7047d73590cfe8e373e2c804fa99be47e55b1b6186602d0f86f384cecec1", size = 278216 },
650
- { url = "https://files.pythonhosted.org/packages/00/78/9b566d68cb88bb1ecebe354765625161c9d6060a16e55008006d6359f776/cbor2-5.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:31d511df7ebd6624fdb4cecdafb4ffb9a205f9ff8c8d98edd1bef0d27f944d74", size = 68451 },
651
- { url = "https://files.pythonhosted.org/packages/db/85/7a6a922d147d027fd5d8fd5224b39e8eaf152a42e8cf16351458096d3d62/cbor2-5.7.1-cp313-cp313-win_arm64.whl", hash = "sha256:f5d37f7b0f84394d2995bd8722cb01c86a885c4821a864a34b7b4d9950c5e26e", size = 64111 },
652
- { url = "https://files.pythonhosted.org/packages/5f/f0/f220222a57371e33434ba7bdc25de31d611cbc0ade2a868e03c3553305e7/cbor2-5.7.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e5826e4fa4c33661960073f99cf67c82783895524fb66f3ebdd635c19b5a7d68", size = 69002 },
653
- { url = "https://files.pythonhosted.org/packages/c7/3c/34b62ba5173541659f248f005d13373530f02fb997b78fde00bf01ede4f4/cbor2-5.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f19a00d6ac9a77cb611073250b06bf4494b41ba78a1716704f7008e0927d9366", size = 69177 },
654
- { url = "https://files.pythonhosted.org/packages/77/fd/2400d820d9733df00a5c18aa74201e51d710fb91588687eb594f4a7688ea/cbor2-5.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d2113aea044cd172f199da3520bc4401af69eae96c5180ca7eb660941928cb89", size = 284259 },
655
- { url = "https://files.pythonhosted.org/packages/42/65/280488ef196c1d71ba123cd406ea47727bb3a0e057767a733d9793fcc428/cbor2-5.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f17eacea2d28fecf28ac413c1d7927cde0a11957487d2630655d6b5c9c46a0b", size = 281958 },
656
- { url = "https://files.pythonhosted.org/packages/42/82/bcdd3fdc73bd5f4194fdb08c808112010add9530bae1dcfdb1e2b2ceae19/cbor2-5.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d65deea39cae533a629561e7da672402c46731122b6129ed7c8eaa1efe04efce", size = 276025 },
657
- { url = "https://files.pythonhosted.org/packages/ae/a8/a6065dd6a157b877d7d8f3fe96f410fb191a2db1e6588f4d20b5f9a507c2/cbor2-5.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:57d8cc29ec1fd20500748e0e767ff88c13afcee839081ba4478c41fcda6ee18b", size = 275978 },
658
- { url = "https://files.pythonhosted.org/packages/62/f4/37934045174af9e4253a340b43f07197af54002070cb80fae82d878f1f14/cbor2-5.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:94fb939d0946f80c49ba45105ca3a3e13e598fc9abd63efc6661b02d4b4d2c50", size = 70269 },
659
- { url = "https://files.pythonhosted.org/packages/0b/fd/933416643e7f5540ae818691fb23fa4189010c6efa39a12c4f59d825da28/cbor2-5.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4fd7225ac820bbb9f03bd16bc1a7efb6c4d1c451f22c0a153ff4ec46495c59c5", size = 66182 },
660
- { url = "https://files.pythonhosted.org/packages/d5/7d/383bafeabb54c17fe5b6d5aca4e863e6b7df10bcc833b34aa169e9dfce1a/cbor2-5.7.1-py3-none-any.whl", hash = "sha256:68834e4eff2f56629ce6422b0634bc3f74c5a4269de5363f5265fe452c706ba7", size = 23829 },
661
- ]
662
-
663
  [[package]]
664
  name = "certifi"
665
  version = "2025.11.12"
@@ -1118,8 +1077,8 @@ name = "deepboner"
1118
  version = "0.1.0"
1119
  source = { editable = "." }
1120
  dependencies = [
1121
- { name = "anthropic" },
1122
  { name = "beautifulsoup4" },
 
1123
  { name = "duckduckgo-search" },
1124
  { name = "gradio", extra = ["mcp"] },
1125
  { name = "httpx" },
@@ -1137,6 +1096,7 @@ dependencies = [
1137
  { name = "pydantic-settings" },
1138
  { name = "python-dotenv" },
1139
  { name = "requests" },
 
1140
  { name = "structlog" },
1141
  { name = "tenacity" },
1142
  { name = "urllib3" },
@@ -1158,30 +1118,22 @@ dev = [
1158
  { name = "ruff" },
1159
  { name = "typer" },
1160
  ]
1161
- embeddings = [
1162
- { name = "chromadb" },
1163
- { name = "sentence-transformers" },
1164
- ]
1165
  magentic = [
1166
  { name = "agent-framework-core" },
1167
  ]
1168
- modal = [
1169
- { name = "chromadb" },
1170
  { name = "llama-index" },
1171
  { name = "llama-index-embeddings-openai" },
1172
  { name = "llama-index-llms-openai" },
1173
  { name = "llama-index-vector-stores-chroma" },
1174
- { name = "modal" },
1175
  ]
1176
 
1177
  [package.metadata]
1178
  requires-dist = [
1179
  { name = "agent-framework-core", marker = "extra == 'magentic'", specifier = ">=1.0.0b251120,<2.0.0" },
1180
- { name = "anthropic", specifier = ">=0.18.0" },
1181
  { name = "bandit", marker = "extra == 'dev'", specifier = ">=1.7.0" },
1182
  { name = "beautifulsoup4", specifier = ">=4.12" },
1183
- { name = "chromadb", marker = "extra == 'embeddings'", specifier = ">=0.4.0" },
1184
- { name = "chromadb", marker = "extra == 'modal'", specifier = ">=0.4.0" },
1185
  { name = "duckduckgo-search", specifier = ">=5.0" },
1186
  { name = "gradio", extras = ["mcp"], specifier = ">=6.0.0" },
1187
  { name = "httpx", specifier = ">=0.27" },
@@ -1192,12 +1144,11 @@ requires-dist = [
1192
  { name = "langgraph", specifier = ">=0.2.50,<1.0" },
1193
  { name = "langgraph-checkpoint-sqlite", specifier = ">=3.0.0,<4.0" },
1194
  { name = "limits", specifier = ">=3.0" },
1195
- { name = "llama-index", marker = "extra == 'modal'", specifier = ">=0.11.0" },
1196
- { name = "llama-index-embeddings-openai", marker = "extra == 'modal'" },
1197
- { name = "llama-index-llms-openai", marker = "extra == 'modal'" },
1198
- { name = "llama-index-vector-stores-chroma", marker = "extra == 'modal'" },
1199
  { name = "mcp", specifier = ">=1.23.0" },
1200
- { name = "modal", marker = "extra == 'modal'", specifier = ">=0.63.0" },
1201
  { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.10" },
1202
  { name = "openai", specifier = ">=1.0.0" },
1203
  { name = "pip-audit", marker = "extra == 'dev'", specifier = ">=2.7.0" },
@@ -1214,14 +1165,14 @@ requires-dist = [
1214
  { name = "requests", specifier = ">=2.32.5" },
1215
  { name = "respx", marker = "extra == 'dev'", specifier = ">=0.21" },
1216
  { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.4.0" },
1217
- { name = "sentence-transformers", marker = "extra == 'embeddings'", specifier = ">=2.2.0" },
1218
  { name = "structlog", specifier = ">=24.1" },
1219
  { name = "tenacity", specifier = ">=8.2" },
1220
  { name = "typer", marker = "extra == 'dev'", specifier = ">=0.9.0" },
1221
  { name = "urllib3", specifier = ">=2.5.0" },
1222
  { name = "xmltodict", specifier = ">=0.13" },
1223
  ]
1224
- provides-extras = ["dev", "magentic", "embeddings", "modal"]
1225
 
1226
  [[package]]
1227
  name = "defusedxml"
@@ -1863,19 +1814,6 @@ wheels = [
1863
  { url = "https://files.pythonhosted.org/packages/19/41/0b430b01a2eb38ee887f88c1f07644a1df8e289353b78e82b37ef988fb64/grpcio-1.76.0-cp314-cp314-win_amd64.whl", hash = "sha256:922fa70ba549fce362d2e2871ab542082d66e2aaf0c19480ea453905b01f384e", size = 4834462 },
1864
  ]
1865
 
1866
- [[package]]
1867
- name = "grpclib"
1868
- version = "0.4.8"
1869
- source = { registry = "https://pypi.org/simple" }
1870
- dependencies = [
1871
- { name = "h2" },
1872
- { name = "multidict" },
1873
- ]
1874
- sdist = { url = "https://files.pythonhosted.org/packages/19/75/0f0d3524b38b35e5cd07334b754aa9bd0570140ad982131b04ebfa3b0374/grpclib-0.4.8.tar.gz", hash = "sha256:d8823763780ef94fed8b2c562f7485cf0bbee15fc7d065a640673667f7719c9a", size = 62793 }
1875
- wheels = [
1876
- { url = "https://files.pythonhosted.org/packages/03/8b/ad381ec1b8195fa4a9a693cb8087e031b99530c0d6b8ad036dcb99e144c4/grpclib-0.4.8-py3-none-any.whl", hash = "sha256:a5047733a7acc1c1cee6abf3c841c7c6fab67d2844a45a853b113fa2e6cd2654", size = 76311 },
1877
- ]
1878
-
1879
  [[package]]
1880
  name = "h11"
1881
  version = "0.16.0"
@@ -1885,19 +1823,6 @@ wheels = [
1885
  { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515 },
1886
  ]
1887
 
1888
- [[package]]
1889
- name = "h2"
1890
- version = "4.3.0"
1891
- source = { registry = "https://pypi.org/simple" }
1892
- dependencies = [
1893
- { name = "hpack" },
1894
- { name = "hyperframe" },
1895
- ]
1896
- sdist = { url = "https://files.pythonhosted.org/packages/1d/17/afa56379f94ad0fe8defd37d6eb3f89a25404ffc71d4d848893d270325fc/h2-4.3.0.tar.gz", hash = "sha256:6c59efe4323fa18b47a632221a1888bd7fde6249819beda254aeca909f221bf1", size = 2152026 }
1897
- wheels = [
1898
- { url = "https://files.pythonhosted.org/packages/69/b2/119f6e6dcbd96f9069ce9a2665e0146588dc9f88f29549711853645e736a/h2-4.3.0-py3-none-any.whl", hash = "sha256:c438f029a25f7945c69e0ccf0fb951dc3f73a5f6412981daee861431b70e2bdd", size = 61779 },
1899
- ]
1900
-
1901
  [[package]]
1902
  name = "hf-xet"
1903
  version = "1.2.0"
@@ -1927,15 +1852,6 @@ wheels = [
1927
  { url = "https://files.pythonhosted.org/packages/cb/44/870d44b30e1dcfb6a65932e3e1506c103a8a5aea9103c337e7a53180322c/hf_xet-1.2.0-cp37-abi3-win_amd64.whl", hash = "sha256:e6584a52253f72c9f52f9e549d5895ca7a471608495c4ecaa6cc73dba2b24d69", size = 2905735 },
1928
  ]
1929
 
1930
- [[package]]
1931
- name = "hpack"
1932
- version = "4.1.0"
1933
- source = { registry = "https://pypi.org/simple" }
1934
- sdist = { url = "https://files.pythonhosted.org/packages/2c/48/71de9ed269fdae9c8057e5a4c0aa7402e8bb16f2c6e90b3aa53327b113f8/hpack-4.1.0.tar.gz", hash = "sha256:ec5eca154f7056aa06f196a557655c5b009b382873ac8d1e66e79e87535f1dca", size = 51276 }
1935
- wheels = [
1936
- { url = "https://files.pythonhosted.org/packages/07/c6/80c95b1b2b94682a72cbdbfb85b81ae2daffa4291fbfa1b1464502ede10d/hpack-4.1.0-py3-none-any.whl", hash = "sha256:157ac792668d995c657d93111f46b4535ed114f0c9c8d672271bbec7eae1b496", size = 34357 },
1937
- ]
1938
-
1939
  [[package]]
1940
  name = "httpcore"
1941
  version = "1.0.9"
@@ -2045,15 +1961,6 @@ wheels = [
2045
  { url = "https://files.pythonhosted.org/packages/f0/0f/310fb31e39e2d734ccaa2c0fb981ee41f7bd5056ce9bc29b2248bd569169/humanfriendly-10.0-py2.py3-none-any.whl", hash = "sha256:1697e1a8a8f550fd43c2865cd84542fc175a61dcb779b6fee18cf6b6ccba1477", size = 86794 },
2046
  ]
2047
 
2048
- [[package]]
2049
- name = "hyperframe"
2050
- version = "6.1.0"
2051
- source = { registry = "https://pypi.org/simple" }
2052
- sdist = { url = "https://files.pythonhosted.org/packages/02/e7/94f8232d4a74cc99514c13a9f995811485a6903d48e5d952771ef6322e30/hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08", size = 26566 }
2053
- wheels = [
2054
- { url = "https://files.pythonhosted.org/packages/48/30/47d0bf6072f7252e6521f3447ccfa40b421b6824517f82854703d0f5a98b/hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5", size = 13007 },
2055
- ]
2056
-
2057
  [[package]]
2058
  name = "identify"
2059
  version = "2.6.15"
@@ -3160,31 +3067,6 @@ wheels = [
3160
  { url = "https://files.pythonhosted.org/packages/6a/fc/0e61d9a4e29c8679356795a40e48f647b4aad58d71bfc969f0f8f56fb912/mmh3-5.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:e7884931fe5e788163e7b3c511614130c2c59feffdc21112290a194487efb2e9", size = 40455 },
3161
  ]
3162
 
3163
- [[package]]
3164
- name = "modal"
3165
- version = "1.2.4"
3166
- source = { registry = "https://pypi.org/simple" }
3167
- dependencies = [
3168
- { name = "aiohttp" },
3169
- { name = "cbor2" },
3170
- { name = "certifi" },
3171
- { name = "click" },
3172
- { name = "grpclib" },
3173
- { name = "protobuf" },
3174
- { name = "rich" },
3175
- { name = "synchronicity" },
3176
- { name = "toml" },
3177
- { name = "typer" },
3178
- { name = "types-certifi" },
3179
- { name = "types-toml" },
3180
- { name = "typing-extensions" },
3181
- { name = "watchfiles" },
3182
- ]
3183
- sdist = { url = "https://files.pythonhosted.org/packages/91/b1/7bd589a3e1cc1ffc3fc2c05d1fab4b02459552d1ed416e00f19969e54f32/modal-1.2.4.tar.gz", hash = "sha256:5acb4a57a4bc857944579a3cf36e93f38d39499837628e9acf591d45d0c88c89", size = 645018 }
3184
- wheels = [
3185
- { url = "https://files.pythonhosted.org/packages/ad/5a/a6bb9d01111109398bad8405587dde4f65088604b958c4f5e8cc5b212460/modal-1.2.4-py3-none-any.whl", hash = "sha256:cf4f01081bd9e5e1ec844d87a2c6a5805fd7c8f4deff5671c20d3b1505899aa8", size = 742291 },
3186
- ]
3187
-
3188
  [[package]]
3189
  name = "more-itertools"
3190
  version = "10.8.0"
@@ -5919,18 +5801,6 @@ wheels = [
5919
  { url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353 },
5920
  ]
5921
 
5922
- [[package]]
5923
- name = "synchronicity"
5924
- version = "0.10.4"
5925
- source = { registry = "https://pypi.org/simple" }
5926
- dependencies = [
5927
- { name = "typing-extensions" },
5928
- ]
5929
- sdist = { url = "https://files.pythonhosted.org/packages/9e/92/2abaf9f4d846c2b7c240e9ce3c9198abf6660265bc1031640cbca5365351/synchronicity-0.10.4.tar.gz", hash = "sha256:3a9ac19f9a58cad64fcb3729812b828b77e54e0a90ced4439e09d3d9c19a90f0", size = 66903 }
5930
- wheels = [
5931
- { url = "https://files.pythonhosted.org/packages/01/c6/a3631d119c9979816c0ed0354aa9fb829a14f53a43337f263dc3329b3a6e/synchronicity-0.10.4-py3-none-any.whl", hash = "sha256:0e3f00b2123cf2a77a8bb3b65fbeccad04adea682bfbd50c01637b75a168c73b", size = 39652 },
5932
- ]
5933
-
5934
  [[package]]
5935
  name = "temporalio"
5936
  version = "1.19.0"
@@ -6239,15 +6109,6 @@ wheels = [
6239
  { url = "https://files.pythonhosted.org/packages/78/64/7713ffe4b5983314e9d436a90d5bd4f63b6054e2aca783a3cfc44cb95bbf/typer-0.20.0-py3-none-any.whl", hash = "sha256:5b463df6793ec1dca6213a3cf4c0f03bc6e322ac5e16e13ddd622a889489784a", size = 47028 },
6240
  ]
6241
 
6242
- [[package]]
6243
- name = "types-certifi"
6244
- version = "2021.10.8.3"
6245
- source = { registry = "https://pypi.org/simple" }
6246
- sdist = { url = "https://files.pythonhosted.org/packages/52/68/943c3aeaf14624712a0357c4a67814dba5cea36d194f5c764dad7959a00c/types-certifi-2021.10.8.3.tar.gz", hash = "sha256:72cf7798d165bc0b76e1c10dd1ea3097c7063c42c21d664523b928e88b554a4f", size = 2095 }
6247
- wheels = [
6248
- { url = "https://files.pythonhosted.org/packages/b5/63/2463d89481e811f007b0e1cd0a91e52e141b47f9de724d20db7b861dcfec/types_certifi-2021.10.8.3-py3-none-any.whl", hash = "sha256:b2d1e325e69f71f7c78e5943d410e650b4707bb0ef32e4ddf3da37f54176e88a", size = 2136 },
6249
- ]
6250
-
6251
  [[package]]
6252
  name = "types-protobuf"
6253
  version = "6.32.1.20251105"
@@ -6269,15 +6130,6 @@ wheels = [
6269
  { url = "https://files.pythonhosted.org/packages/2a/20/9a227ea57c1285986c4cf78400d0a91615d25b24e257fd9e2969606bdfae/types_requests-2.32.4.20250913-py3-none-any.whl", hash = "sha256:78c9c1fffebbe0fa487a418e0fa5252017e9c60d1a2da394077f1780f655d7e1", size = 20658 },
6270
  ]
6271
 
6272
- [[package]]
6273
- name = "types-toml"
6274
- version = "0.10.8.20240310"
6275
- source = { registry = "https://pypi.org/simple" }
6276
- sdist = { url = "https://files.pythonhosted.org/packages/86/47/3e4c75042792bff8e90d7991aa5c51812cc668828cc6cce711e97f63a607/types-toml-0.10.8.20240310.tar.gz", hash = "sha256:3d41501302972436a6b8b239c850b26689657e25281b48ff0ec06345b8830331", size = 4392 }
6277
- wheels = [
6278
- { url = "https://files.pythonhosted.org/packages/da/a2/d32ab58c0b216912638b140ab2170ee4b8644067c293b170e19fba340ccc/types_toml-0.10.8.20240310-py3-none-any.whl", hash = "sha256:627b47775d25fa29977d9c70dc0cbab3f314f32c8d8d0c012f2ef5de7aaec05d", size = 4777 },
6279
- ]
6280
-
6281
  [[package]]
6282
  name = "typing-extensions"
6283
  version = "4.15.0"
 
1
  version = 1
2
  revision = 1
3
+ requires-python = ">=3.11, <4.0"
4
  resolution-markers = [
5
  "python_full_version >= '3.13'",
6
  "python_full_version == '3.12.*'",
 
619
  { url = "https://files.pythonhosted.org/packages/e6/46/eb6eca305c77a4489affe1c5d8f4cae82f285d9addd8de4ec084a7184221/cachetools-6.2.2-py3-none-any.whl", hash = "sha256:6c09c98183bf58560c97b2abfcedcbaf6a896a490f534b031b661d3723b45ace", size = 11503 },
620
  ]
621
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
622
  [[package]]
623
  name = "certifi"
624
  version = "2025.11.12"
 
1077
  version = "0.1.0"
1078
  source = { editable = "." }
1079
  dependencies = [
 
1080
  { name = "beautifulsoup4" },
1081
+ { name = "chromadb" },
1082
  { name = "duckduckgo-search" },
1083
  { name = "gradio", extra = ["mcp"] },
1084
  { name = "httpx" },
 
1096
  { name = "pydantic-settings" },
1097
  { name = "python-dotenv" },
1098
  { name = "requests" },
1099
+ { name = "sentence-transformers" },
1100
  { name = "structlog" },
1101
  { name = "tenacity" },
1102
  { name = "urllib3" },
 
1118
  { name = "ruff" },
1119
  { name = "typer" },
1120
  ]
 
 
 
 
1121
  magentic = [
1122
  { name = "agent-framework-core" },
1123
  ]
1124
+ rag = [
 
1125
  { name = "llama-index" },
1126
  { name = "llama-index-embeddings-openai" },
1127
  { name = "llama-index-llms-openai" },
1128
  { name = "llama-index-vector-stores-chroma" },
 
1129
  ]
1130
 
1131
  [package.metadata]
1132
  requires-dist = [
1133
  { name = "agent-framework-core", marker = "extra == 'magentic'", specifier = ">=1.0.0b251120,<2.0.0" },
 
1134
  { name = "bandit", marker = "extra == 'dev'", specifier = ">=1.7.0" },
1135
  { name = "beautifulsoup4", specifier = ">=4.12" },
1136
+ { name = "chromadb", specifier = ">=0.4.22" },
 
1137
  { name = "duckduckgo-search", specifier = ">=5.0" },
1138
  { name = "gradio", extras = ["mcp"], specifier = ">=6.0.0" },
1139
  { name = "httpx", specifier = ">=0.27" },
 
1144
  { name = "langgraph", specifier = ">=0.2.50,<1.0" },
1145
  { name = "langgraph-checkpoint-sqlite", specifier = ">=3.0.0,<4.0" },
1146
  { name = "limits", specifier = ">=3.0" },
1147
+ { name = "llama-index", marker = "extra == 'rag'", specifier = ">=0.11.0" },
1148
+ { name = "llama-index-embeddings-openai", marker = "extra == 'rag'" },
1149
+ { name = "llama-index-llms-openai", marker = "extra == 'rag'" },
1150
+ { name = "llama-index-vector-stores-chroma", marker = "extra == 'rag'" },
1151
  { name = "mcp", specifier = ">=1.23.0" },
 
1152
  { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.10" },
1153
  { name = "openai", specifier = ">=1.0.0" },
1154
  { name = "pip-audit", marker = "extra == 'dev'", specifier = ">=2.7.0" },
 
1165
  { name = "requests", specifier = ">=2.32.5" },
1166
  { name = "respx", marker = "extra == 'dev'", specifier = ">=0.21" },
1167
  { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.4.0" },
1168
+ { name = "sentence-transformers", specifier = ">=2.2.2" },
1169
  { name = "structlog", specifier = ">=24.1" },
1170
  { name = "tenacity", specifier = ">=8.2" },
1171
  { name = "typer", marker = "extra == 'dev'", specifier = ">=0.9.0" },
1172
  { name = "urllib3", specifier = ">=2.5.0" },
1173
  { name = "xmltodict", specifier = ">=0.13" },
1174
  ]
1175
+ provides-extras = ["dev", "magentic", "rag"]
1176
 
1177
  [[package]]
1178
  name = "defusedxml"
 
1814
  { url = "https://files.pythonhosted.org/packages/19/41/0b430b01a2eb38ee887f88c1f07644a1df8e289353b78e82b37ef988fb64/grpcio-1.76.0-cp314-cp314-win_amd64.whl", hash = "sha256:922fa70ba549fce362d2e2871ab542082d66e2aaf0c19480ea453905b01f384e", size = 4834462 },
1815
  ]
1816
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1817
  [[package]]
1818
  name = "h11"
1819
  version = "0.16.0"
 
1823
  { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515 },
1824
  ]
1825
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1826
  [[package]]
1827
  name = "hf-xet"
1828
  version = "1.2.0"
 
1852
  { url = "https://files.pythonhosted.org/packages/cb/44/870d44b30e1dcfb6a65932e3e1506c103a8a5aea9103c337e7a53180322c/hf_xet-1.2.0-cp37-abi3-win_amd64.whl", hash = "sha256:e6584a52253f72c9f52f9e549d5895ca7a471608495c4ecaa6cc73dba2b24d69", size = 2905735 },
1853
  ]
1854
 
 
 
 
 
 
 
 
 
 
1855
  [[package]]
1856
  name = "httpcore"
1857
  version = "1.0.9"
 
1961
  { url = "https://files.pythonhosted.org/packages/f0/0f/310fb31e39e2d734ccaa2c0fb981ee41f7bd5056ce9bc29b2248bd569169/humanfriendly-10.0-py2.py3-none-any.whl", hash = "sha256:1697e1a8a8f550fd43c2865cd84542fc175a61dcb779b6fee18cf6b6ccba1477", size = 86794 },
1962
  ]
1963
 
 
 
 
 
 
 
 
 
 
1964
  [[package]]
1965
  name = "identify"
1966
  version = "2.6.15"
 
3067
  { url = "https://files.pythonhosted.org/packages/6a/fc/0e61d9a4e29c8679356795a40e48f647b4aad58d71bfc969f0f8f56fb912/mmh3-5.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:e7884931fe5e788163e7b3c511614130c2c59feffdc21112290a194487efb2e9", size = 40455 },
3068
  ]
3069
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3070
  [[package]]
3071
  name = "more-itertools"
3072
  version = "10.8.0"
 
5801
  { url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353 },
5802
  ]
5803
 
 
 
 
 
 
 
 
 
 
 
 
 
5804
  [[package]]
5805
  name = "temporalio"
5806
  version = "1.19.0"
 
6109
  { url = "https://files.pythonhosted.org/packages/78/64/7713ffe4b5983314e9d436a90d5bd4f63b6054e2aca783a3cfc44cb95bbf/typer-0.20.0-py3-none-any.whl", hash = "sha256:5b463df6793ec1dca6213a3cf4c0f03bc6e322ac5e16e13ddd622a889489784a", size = 47028 },
6110
  ]
6111
 
 
 
 
 
 
 
 
 
 
6112
  [[package]]
6113
  name = "types-protobuf"
6114
  version = "6.32.1.20251105"
 
6130
  { url = "https://files.pythonhosted.org/packages/2a/20/9a227ea57c1285986c4cf78400d0a91615d25b24e257fd9e2969606bdfae/types_requests-2.32.4.20250913-py3-none-any.whl", hash = "sha256:78c9c1fffebbe0fa487a418e0fa5252017e9c60d1a2da394077f1780f655d7e1", size = 20658 },
6131
  ]
6132
 
 
 
 
 
 
 
 
 
 
6133
  [[package]]
6134
  name = "typing-extensions"
6135
  version = "4.15.0"