File size: 12,529 Bytes
fd1472e
25c3a8b
 
 
89f1173
25c3a8b
 
 
fd1472e
e82d9c9
f5747b1
da82e7e
25c3a8b
cc5dfc8
25c3a8b
cd7c282
89f1173
25c3a8b
9b8af35
36b8ff4
9b8af35
 
 
 
 
 
36b8ff4
 
9b8af35
 
 
36b8ff4
 
 
 
 
 
 
 
 
 
9b8af35
 
 
 
4d87419
 
cd7c282
4d87419
fd1472e
7fab6d4
25c3a8b
 
 
cd7c282
 
 
25c3a8b
 
cd7c282
6ea5a8b
fa696e8
25c3a8b
 
7fab6d4
25c3a8b
36983ae
 
72b2667
36983ae
 
 
7fab6d4
 
 
25c3a8b
7fab6d4
 
 
6ea5a8b
e217fae
6ea5a8b
7fab6d4
e217fae
25c3a8b
6ea5a8b
599a754
6ea5a8b
 
 
7fab6d4
 
 
 
25c3a8b
89f1173
d1f91a4
fd1472e
25c3a8b
 
7fab6d4
 
25c3a8b
cc5dfc8
 
 
cd7c282
 
 
 
 
cc5dfc8
 
cd7c282
cc5dfc8
 
 
 
 
 
e217fae
cc5dfc8
cd7c282
cc5dfc8
 
25c3a8b
 
 
fa696e8
4d87419
0c9be4a
cc5dfc8
25c3a8b
 
 
 
cd7c282
 
 
25c3a8b
 
 
fd1472e
6ea5a8b
0c9be4a
cc5dfc8
25c3a8b
 
 
 
 
 
 
 
6b5e05b
fa696e8
6b5e05b
cd7c282
 
1922dbd
6ea5a8b
4d87419
7fab6d4
0cdf561
4d87419
 
25c3a8b
506a9c0
0c9be4a
25c3a8b
 
7fab6d4
cd7c282
7fab6d4
cc5dfc8
cd7c282
4d87419
fd1472e
4d87419
7fab6d4
dbe535c
89f1173
 
5441526
89f1173
cb46aac
6b5e05b
 
25c3a8b
cc5dfc8
 
 
 
 
 
7c51be5
 
 
 
 
cc5dfc8
 
 
 
 
0c9be4a
 
 
 
9006d69
 
 
 
0c9be4a
 
 
 
 
 
 
 
25c3a8b
cb46aac
 
25c3a8b
0c9be4a
 
 
25c3a8b
 
 
0c9be4a
 
 
 
 
25c3a8b
 
 
 
b16e7a5
25c3a8b
10e234c
25c3a8b
 
10e234c
25c3a8b
cd7c282
0c9be4a
 
 
 
fcc601a
36b8ff4
fd1472e
36b8ff4
9b8af35
 
fd1472e
36b8ff4
 
 
 
 
9b8af35
 
 
 
fd1472e
 
fcc601a
 
5d12635
fd1472e
fcc601a
cd7c282
 
c295dfc
b16e7a5
 
fd1472e
6b5e05b
 
b16e7a5
 
627c291
 
6b5e05b
 
b16e7a5
 
fd1472e
 
6b5e05b
 
b16e7a5
fcc601a
c295dfc
 
 
 
b16e7a5
fcc601a
cd7c282
 
fd1472e
 
627c291
fd1472e
627c291
 
fd1472e
fcc601a
6ea5a8b
e217fae
fcc601a
6ea5a8b
ad823e0
fcc601a
0c9be4a
fcc601a
 
25c3a8b
b16e7a5
25c3a8b
 
 
10e234c
cc5dfc8
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
"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 src.config.domain import ResearchDomain
from src.orchestrators import create_orchestrator
from src.utils.config import settings
from src.utils.exceptions import ConfigurationError
from src.utils.models import OrchestratorConfig
from src.utils.service_loader import warmup_services

OrchestratorMode = Literal["advanced", "hierarchical"]  # Unified Architecture (SPEC-16)


# 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 = "advanced",
    user_api_key: str | None = None,
    domain: str | ResearchDomain | None = None,
) -> tuple[Any, str]:
    """
    Create an orchestrator instance.

    Unified Architecture (SPEC-16): All users get Advanced Mode.
    Backend auto-selects: OpenAI (if key) β†’ HuggingFace (free fallback).

    Args:
        use_mock: If True, use MockJudgeHandler (no API key needed)
        mode: Orchestrator mode (default "advanced", "hierarchical" for sub-iteration)
        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,
    )

    backend_info = "Unknown"

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

    # 2. Paid API Key (User provided or Env)
    elif user_api_key and user_api_key.strip():
        if user_api_key.startswith("sk-"):
            backend_info = "Paid API (OpenAI)"
        else:
            raise ConfigurationError("Invalid API key format. Expected sk-... (OpenAI)")

    # 3. Environment API Keys (fallback)
    elif settings.has_openai_key:
        backend_info = "Paid API (OpenAI from env)"

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

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

    return orchestrator, backend_info


def _validate_inputs(
    api_key: str | None,
    api_key_state: str | None,
) -> tuple[str | None, bool]:
    """Validate inputs and determine key status.

    Unified Architecture (SPEC-16): Mode is always "advanced".
    Backend auto-selects based on available API keys.

    Returns:
        Tuple of (effective_user_key, has_paid_key)
    """
    # Determine effective key
    user_api_key = (api_key or api_key_state or "").strip() or None

    # Check available keys
    has_openai = settings.has_openai_key
    has_paid_key = has_openai or bool(user_api_key)

    return user_api_key, has_paid_key


async def research_agent(
    message: str,
    history: list[dict[str, Any]],
    domain: str = "sexual_health",
    api_key: str = "",
    api_key_state: str = "",
    progress: gr.Progress = gr.Progress(),  # noqa: B008
) -> AsyncGenerator[str, None]:
    """
    Gradio chat function that runs the research agent.

    Unified Architecture (SPEC-16): Always uses Advanced Mode.
    Backend auto-selects: OpenAI (if key) β†’ HuggingFace (free fallback).

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

    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
    domain_str = domain or "sexual_health"

    # Validate inputs (SPEC-16: mode is always "advanced")
    user_api_key, has_paid_key = _validate_inputs(api_key, api_key_state)

    if not has_paid_key:
        yield (
            "πŸ€— **Free Tier**: Using HuggingFace Inference (Llama 3.1 / Mistral) for AI analysis.\n"
            "For premium models, enter an OpenAI 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
        # SPEC-16: mode is always "advanced" (unified architecture)
        orchestrator, backend_name = configure_orchestrator(
            use_mock=False,
            mode="advanced",
            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):
            # Update progress bar
            if event.type == "started":
                progress(0, desc="Starting research...")
            elif event.type == "thinking":
                progress(0.1, desc="Multi-agent reasoning...")
            elif event.type == "progress":
                # Calculate progress percentage (fallback to 0.15 for events without iteration)
                p = 0.15
                max_iters = getattr(orchestrator, "_max_rounds", None) or getattr(
                    getattr(orchestrator, "config", None), "max_iterations", 10
                )
                if event.iteration:
                    # Map 0..max to 0.2..0.9
                    p = 0.2 + (0.7 * (min(event.iteration, max_iters) / max_iters))
                progress(p, desc=event.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="βš™οΈ 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=[
            # SPEC-16: Mode is always "advanced" (unified architecture)
            # Examples now only need: [question, domain, api_key, api_key_state]
            # NOTE: None values preserve user's current input (not overwritten)
            [
                "What drugs improve female libido post-menopause?",
                "sexual_health",
                None,
                None,
            ],
            [
                "Testosterone therapy for hypoactive sexual desire disorder?",
                "sexual_health",
                None,
                None,
            ],
            [
                "Clinical trials for PDE5 inhibitors alternatives?",
                "sexual_health",
                None,
                None,
            ],
        ],
        # FIX: Prevent examples from auto-submitting (P1_GRADIO_EXAMPLE_CLICK_AUTO_SUBMIT)
        # cache_examples must be False for run_examples_on_click to take effect on HF Spaces
        cache_examples=False,
        run_examples_on_click=False,
        additional_inputs_accordion=additional_inputs_accordion,
        additional_inputs=[
            # SPEC-16: Mode toggle removed - everyone gets Advanced Mode
            # Backend auto-selects: OpenAI (if key) β†’ HuggingFace (free fallback)
            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)",
                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."""
    warmup_services()  # Phase 2: Pre-warm services
    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()