File size: 13,769 Bytes
fd1472e
25c3a8b
 
 
89f1173
25c3a8b
 
4d87419
c881895
4d87419
 
25c3a8b
7fab6d4
fd1472e
e82d9c9
4e2ccbf
2f8ae1f
cb46aac
25c3a8b
 
f5747b1
da82e7e
25c3a8b
 
89f1173
 
25c3a8b
9b8af35
36b8ff4
9b8af35
 
 
 
 
 
36b8ff4
 
9b8af35
 
 
36b8ff4
 
 
 
 
 
 
 
 
 
9b8af35
 
 
 
4d87419
 
89f1173
4d87419
fd1472e
7fab6d4
25c3a8b
 
 
 
 
b2929fc
6ea5a8b
fa696e8
25c3a8b
 
7fab6d4
25c3a8b
36983ae
 
72b2667
36983ae
 
 
25c3a8b
 
cb46aac
36983ae
25c3a8b
 
7fab6d4
 
 
 
 
25c3a8b
fd1472e
7fab6d4
 
 
6ea5a8b
 
c881895
6ea5a8b
 
 
 
 
 
 
 
c881895
6ea5a8b
7fab6d4
da82e7e
6ea5a8b
 
fd1472e
25c3a8b
6ea5a8b
599a754
fd1472e
6ea5a8b
 
599a754
fd1472e
6ea5a8b
 
 
7fab6d4
fd1472e
7fab6d4
 
 
25c3a8b
 
 
89f1173
d1f91a4
fd1472e
25c3a8b
 
7fab6d4
 
25c3a8b
 
 
 
89f1173
fa696e8
4d87419
0c9be4a
25c3a8b
 
 
 
 
 
 
b2929fc
fd1472e
6ea5a8b
0c9be4a
25c3a8b
 
 
 
 
 
 
 
6b5e05b
 
 
 
fa696e8
6b5e05b
89f1173
 
 
 
82503b1
6b5e05b
4d87419
7fab6d4
599a754
 
6ea5a8b
 
 
 
 
1922dbd
b2929fc
89f1173
1922dbd
b2929fc
6ea5a8b
1922dbd
89f1173
1922dbd
6ea5a8b
 
7fab6d4
4d87419
7fab6d4
 
4d87419
 
25c3a8b
506a9c0
0c9be4a
25c3a8b
 
7fab6d4
 
 
 
89f1173
4d87419
fd1472e
4d87419
7fab6d4
dbe535c
89f1173
 
5441526
89f1173
cb46aac
6b5e05b
 
25c3a8b
0c9be4a
 
 
 
9006d69
 
 
 
0c9be4a
 
 
 
 
 
 
 
25c3a8b
cb46aac
 
25c3a8b
0c9be4a
 
 
25c3a8b
 
 
0c9be4a
 
 
 
 
25c3a8b
 
 
 
b16e7a5
25c3a8b
10e234c
25c3a8b
 
10e234c
25c3a8b
b16e7a5
 
 
0c9be4a
 
 
 
fcc601a
36b8ff4
fd1472e
36b8ff4
9b8af35
 
fd1472e
36b8ff4
 
 
 
 
9b8af35
 
 
 
fd1472e
 
fcc601a
 
5d12635
fd1472e
fcc601a
b16e7a5
 
 
fd1472e
6b5e05b
 
b16e7a5
 
627c291
fd1472e
627c291
6b5e05b
 
b16e7a5
 
fd1472e
 
 
6b5e05b
 
b16e7a5
fcc601a
b16e7a5
fcc601a
 
e0c585c
fcc601a
 
e0c585c
fcc601a
fd1472e
 
627c291
fd1472e
627c291
 
fd1472e
fcc601a
6ea5a8b
 
fcc601a
6ea5a8b
ad823e0
fcc601a
0c9be4a
fcc601a
 
25c3a8b
b16e7a5
25c3a8b
 
 
10e234c
b16e7a5
25c3a8b
da82e7e
25c3a8b
 
1812a2a
420d8ba
9b8af35
25c3a8b
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
"Gradio UI for DeepBoner agent with MCP server support."

import os
from collections.abc import AsyncGenerator
from typing import Any, Literal

import gradio as gr
from pydantic_ai.models.anthropic import AnthropicModel
from pydantic_ai.models.openai import OpenAIChatModel
from pydantic_ai.providers.anthropic import AnthropicProvider
from pydantic_ai.providers.openai import OpenAIProvider

from src.agent_factory.judges import HFInferenceJudgeHandler, JudgeHandler, MockJudgeHandler
from src.config.domain import ResearchDomain
from src.orchestrators import create_orchestrator
from src.tools.clinicaltrials import ClinicalTrialsTool
from src.tools.europepmc import EuropePMCTool
from src.tools.openalex import OpenAlexTool
from src.tools.pubmed import PubMedTool
from src.tools.search_handler import SearchHandler
from src.utils.config import settings
from src.utils.exceptions import ConfigurationError
from src.utils.models import OrchestratorConfig

OrchestratorMode = Literal["simple", "magentic", "advanced", "hierarchical"]


# CSS to force dark mode on API key input
# NOTE: Browser autofill requires -webkit-autofill selectors to override
CUSTOM_CSS = """
.api-key-input input {
    background-color: #1f2937 !important;
    color: white !important;
    border-color: #374151 !important;
}
.api-key-input input:focus,
.api-key-input input:focus-visible {
    background-color: #1f2937 !important;
    color: white !important;
    border-color: #e879f9 !important;
    outline: none !important;
}
/* Override aggressive browser autofill styling */
.api-key-input input:-webkit-autofill,
.api-key-input input:-webkit-autofill:hover,
.api-key-input input:-webkit-autofill:focus {
    -webkit-box-shadow: 0 0 0px 1000px #1f2937 inset !important;
    -webkit-text-fill-color: white !important;
    caret-color: white !important;
    transition: background-color 5000s ease-in-out 0s;
}
"""


def configure_orchestrator(
    use_mock: bool = False,
    mode: OrchestratorMode = "simple",
    user_api_key: str | None = None,
    domain: str | ResearchDomain | None = None,
) -> tuple[Any, str]:
    """
    Create an orchestrator instance.

    Args:
        use_mock: If True, use MockJudgeHandler (no API key needed)
        mode: Orchestrator mode ("simple" or "advanced")
        user_api_key: Optional user-provided API key (BYOK) - auto-detects provider
        domain: Research domain (defaults to "sexual_health")

    Returns:
        Tuple of (Orchestrator instance, backend_name)
    """
    # Create orchestrator config
    config = OrchestratorConfig(
        max_iterations=10,
        max_results_per_tool=10,
    )

    # Create search tools
    search_handler = SearchHandler(
        tools=[PubMedTool(), ClinicalTrialsTool(), EuropePMCTool(), OpenAlexTool()],
        timeout=config.search_timeout,
    )

    # Create judge (mock, real, or free tier)
    judge_handler: JudgeHandler | MockJudgeHandler | HFInferenceJudgeHandler
    backend_info = "Unknown"

    # 1. Forced Mock (Unit Testing)
    if use_mock:
        judge_handler = MockJudgeHandler(domain=domain)
        backend_info = "Mock (Testing)"

    # 2. Paid API Key (User provided or Env)
    elif user_api_key and user_api_key.strip():
        # Auto-detect provider from key prefix
        model: AnthropicModel | OpenAIChatModel
        if user_api_key.startswith("sk-ant-"):
            # Anthropic key
            anthropic_provider = AnthropicProvider(api_key=user_api_key)
            model = AnthropicModel(settings.anthropic_model, provider=anthropic_provider)
            backend_info = "Paid API (Anthropic)"
        elif user_api_key.startswith("sk-"):
            # OpenAI key
            openai_provider = OpenAIProvider(api_key=user_api_key)
            model = OpenAIChatModel(settings.openai_model, provider=openai_provider)
            backend_info = "Paid API (OpenAI)"
        else:
            raise ConfigurationError(
                "Invalid API key format. Expected sk-... (OpenAI) or sk-ant-... (Anthropic)"
            )
        judge_handler = JudgeHandler(model=model, domain=domain)

    # 3. Environment API Keys (fallback)
    elif settings.has_openai_key:
        judge_handler = JudgeHandler(model=None, domain=domain)  # Uses env key
        backend_info = "Paid API (OpenAI from env)"

    elif settings.has_anthropic_key:
        judge_handler = JudgeHandler(model=None, domain=domain)  # Uses env key
        backend_info = "Paid API (Anthropic from env)"

    # 4. Free Tier (HuggingFace Inference)
    else:
        judge_handler = HFInferenceJudgeHandler(domain=domain)
        backend_info = "Free Tier (Llama 3.1 / Mistral)"

    orchestrator = create_orchestrator(
        search_handler=search_handler,
        judge_handler=judge_handler,
        config=config,
        mode=mode,
        api_key=user_api_key,
        domain=domain,
    )

    return orchestrator, backend_info


async def research_agent(
    message: str,
    history: list[dict[str, Any]],
    mode: str = "simple",  # Gradio passes strings; validated below
    domain: str = "sexual_health",
    api_key: str = "",
    api_key_state: str = "",
) -> AsyncGenerator[str, None]:
    """
    Gradio chat function that runs the research agent.

    Args:
        message: User's research question
        history: Chat history (Gradio format)
        mode: Orchestrator mode ("simple" or "advanced")
        domain: Research domain
        api_key: Optional user-provided API key (BYOK - auto-detects provider)
        api_key_state: Persistent API key state (survives example clicks)

    Yields:
        Markdown-formatted responses for streaming
    """
    if not message.strip():
        yield "Please enter a research question."
        return

    # BUG FIX: Handle None values from Gradio example caching
    # Gradio passes None for missing example columns, overriding defaults
    api_key_str = api_key or ""
    api_key_state_str = api_key_state or ""
    domain_str = domain or "sexual_health"

    # Validate and cast mode to proper type
    valid_modes: set[str] = {"simple", "magentic", "advanced", "hierarchical"}
    mode_validated: OrchestratorMode = mode if mode in valid_modes else "simple"  # type: ignore[assignment]

    # BUG FIX: Prefer freshly-entered key, then persisted state
    user_api_key = (api_key_str.strip() or api_key_state_str.strip()) or None

    # Check available keys
    has_openai = settings.has_openai_key
    has_anthropic = settings.has_anthropic_key
    # Check for OpenAI user key
    is_openai_user_key = (
        user_api_key and user_api_key.startswith("sk-") and not user_api_key.startswith("sk-ant-")
    )
    has_paid_key = has_openai or has_anthropic or bool(user_api_key)

    # Advanced mode requires OpenAI specifically (due to agent-framework binding)
    if mode_validated == "advanced" and not (has_openai or is_openai_user_key):
        yield (
            "⚠️ **Warning**: Advanced mode currently requires OpenAI API key. "
            "Anthropic keys only work in Simple mode. Falling back to Simple.\n\n"
        )
        mode_validated = "simple"

    # Inform user about fallback if no keys
    if not has_paid_key:
        # No paid keys - will use FREE HuggingFace Inference
        yield (
            "πŸ€— **Free Tier**: Using HuggingFace Inference (Llama 3.1 / Mistral) for AI analysis.\n"
            "For premium models, enter an OpenAI or Anthropic API key below.\n\n"
        )

    # Run the agent and stream events
    response_parts: list[str] = []
    streaming_buffer = ""  # Buffer for accumulating streaming tokens

    try:
        # use_mock=False - let configure_orchestrator decide based on available keys
        # It will use: Paid API > HF Inference (free tier)
        orchestrator, backend_name = configure_orchestrator(
            use_mock=False,  # Never use mock in production - HF Inference is the free fallback
            mode=mode_validated,
            user_api_key=user_api_key,
            domain=domain_str,
        )

        # Immediate backend info + loading feedback so user knows something is happening
        # Use replace to get "Sexual Health" instead of "Sexual_Health" from .title()
        domain_display = domain_str.replace("_", " ").title()
        yield (
            f"🧠 **Backend**: {backend_name} | **Domain**: {domain_display}\n\n"
            "⏳ **Processing...** Searching PubMed, ClinicalTrials.gov, Europe PMC, OpenAlex...\n"
        )

        async for event in orchestrator.run(message):
            # BUG FIX: Handle streaming events separately to avoid token-by-token spam
            if event.type == "streaming":
                # Accumulate streaming tokens without emitting individual events
                streaming_buffer += event.message
                # Yield the current buffer combined with previous parts to show progress
                # But DO NOT append to response_parts list yet (to avoid O(N^2) list growth)
                current_parts = [*response_parts, f"πŸ“‘ **STREAMING**: {streaming_buffer}"]
                yield "\n\n".join(current_parts)
                continue

            # For non-streaming events, flush any buffered streaming content first
            if streaming_buffer:
                response_parts.append(f"πŸ“‘ **STREAMING**: {streaming_buffer}")
                streaming_buffer = ""  # Reset buffer

            # Handle complete events specially
            if event.type == "complete":
                response_parts.append(event.message)
                yield "\n\n".join(response_parts)
            else:
                # Format and append non-streaming events
                event_md = event.to_markdown()
                response_parts.append(event_md)
                # Show progress
                yield "\n\n".join(response_parts)

        # Flush any remaining streaming content at the end
        if streaming_buffer:
            response_parts.append(f"πŸ“‘ **STREAMING**: {streaming_buffer}")
            yield "\n\n".join(response_parts)

    except Exception as e:
        yield f"❌ **Error**: {e!s}"


def create_demo() -> tuple[gr.ChatInterface, gr.Accordion]:
    """
    Create the Gradio demo interface with MCP support.

    Returns:
        Configured Gradio Blocks interface with MCP server enabled
    """
    additional_inputs_accordion = gr.Accordion(
        label="βš™οΈ Mode & API Key (Free tier works!)", open=False
    )

    # BUG FIX: Add gr.State for API key persistence across example clicks
    api_key_state = gr.State("")

    # 1. Unwrapped ChatInterface (Fixes Accordion Bug)
    # NOTE: Using inline styles on each element because HR breaks text-align inheritance
    description = (
        "<div style='text-align: center;'>"
        "<em>AI-Powered Research Agent β€” searches PubMed, "
        "ClinicalTrials.gov, Europe PMC & OpenAlex</em><br><br>"
        "Deep research for sexual wellness, ED treatments, hormone therapy, "
        "libido, and reproductive health - for all genders."
        "</div>"
        "<hr style='margin: 1em auto; width: 80%; border: none; "
        "border-top: 1px solid #374151;'>"
        "<div style='text-align: center;'>"
        "<em>Research tool only β€” not for medical advice.</em><br>"
        "<strong>MCP Server Active</strong>: Connect Claude Desktop to "
        "<code>/gradio_api/mcp/</code>"
        "</div>"
    )

    demo = gr.ChatInterface(
        fn=research_agent,
        title="πŸ† DeepBoner",
        description=description,
        examples=[
            [
                "What drugs improve female libido post-menopause?",
                "simple",
                "sexual_health",
                None,
                None,
            ],
            [
                "Testosterone therapy for hypoactive sexual desire disorder?",
                "simple",
                "sexual_health",
                None,
                None,
            ],
            [
                "Clinical trials for PDE5 inhibitors alternatives?",
                "advanced",
                "sexual_health",
                None,
                None,
            ],
        ],
        additional_inputs_accordion=additional_inputs_accordion,
        additional_inputs=[
            gr.Radio(
                choices=["simple", "advanced"],
                value="simple",
                label="Orchestrator Mode",
                info="⚑ Simple: Free/Any | πŸ”¬ Advanced: OpenAI (Deep Research)",
            ),
            gr.Dropdown(
                choices=[d.value for d in ResearchDomain],
                value="sexual_health",
                label="Research Domain",
                info="DeepBoner specializes in sexual health research",
                visible=False,  # Hidden - only sexual_health supported
            ),
            gr.Textbox(
                label="πŸ”‘ API Key (Optional)",
                placeholder="sk-... (OpenAI) or sk-ant-... (Anthropic)",
                type="password",
                info="Leave empty for free tier. Auto-detects provider from key prefix.",
                elem_classes=["api-key-input"],
            ),
            api_key_state,  # Hidden state component for persistence
        ],
    )

    return demo, additional_inputs_accordion


def main() -> None:
    """Run the Gradio app with MCP server enabled."""
    demo, _ = create_demo()
    demo.launch(
        server_name=os.getenv("GRADIO_SERVER_NAME", "0.0.0.0"),  # nosec B104
        server_port=7860,
        share=False,
        mcp_server=True,
        ssr_mode=False,  # Fix for intermittent loading/hydration issues in HF Spaces
        css=CUSTOM_CSS,  # Moved here for Gradio 6.0 support
    )


if __name__ == "__main__":
    main()