| """ |
| Wemed AI Counsellor — Gradio 6 — Concept B split-pane (light/mint) |
| """ |
| import os, requests, gradio as gr |
| from dotenv import load_dotenv |
| from prompts import SYSTEM_PROMPT, INTAKE_FIELDS, build_intake_summary |
|
|
| load_dotenv() |
| MODAL_ENDPOINT = os.getenv("MODAL_ENDPOINT", "") |
|
|
| def stream_modal(messages): |
| if not MODAL_ENDPOINT: |
| import time |
| mock = ( |
| "Namaste! Based on what you've shared, here are my top picks:\n\n" |
| "**1. Russia** 🇷🇺 — A great fit for your ₹20–30L budget. Kazan Federal University " |
| "is NMC-recognized with a strong Indian student community.\n\n" |
| "**2. Kazakhstan** 🇰🇿 — More affordable (₹18–25L) and closer to home. " |
| "Al-Farabi KNU is an excellent option.\n\n" |
| "**3. Kyrgyzstan** 🇰🇬 — Most budget-friendly at ₹18–22L. " |
| "Osh State University has solid FMGE pass rates.\n\n" |
| "Want me to compare FMGE pass rates, or talk through the admission process? 😊" |
| ) |
| for word in mock.split(" "): |
| yield word + " " |
| time.sleep(0.02) |
| return |
| try: |
| import codecs |
| decoder = codecs.getincrementaldecoder("utf-8")() |
| with requests.post(MODAL_ENDPOINT, |
| json={"messages": messages, "max_tokens": 1024, "temperature": 0.7}, |
| headers={"Accept-Encoding": "identity"}, |
| stream=True, timeout=120) as r: |
| r.raise_for_status() |
| for chunk in r.iter_content(chunk_size=None): |
| if chunk: |
| yield decoder.decode(chunk) |
| yield decoder.decode(b"", final=True) |
| except requests.exceptions.Timeout: |
| yield "\n\n⏳ Model warming up — please retry!" |
| except Exception as e: |
| yield f"\n\n❌ {e}" |
|
|
| def swap_to_chat(): |
| |
| return gr.update(visible=False), gr.update(visible=True) |
|
|
| def start_chat(budget, neet_score, country_pref, concern, name): |
| intake = {"budget": budget, "neet_score": neet_score, |
| "country_pref": country_pref, "concern": concern, |
| "name": name.strip() if name else ""} |
| first = build_intake_summary(intake) |
| msgs = [{"role": "system", "content": SYSTEM_PROMPT}, |
| {"role": "user", "content": first}] |
| history = [gr.ChatMessage(role="user", content=first), |
| gr.ChatMessage(role="assistant", content="")] |
| acc = "" |
| for tok in stream_modal(msgs): |
| acc += tok |
| history[-1] = gr.ChatMessage(role="assistant", content=acc) |
| yield history |
|
|
| def _to_str(content): |
| """Safely convert any Gradio message content to a plain string.""" |
| if content is None: |
| return "" |
| if isinstance(content, str): |
| return content |
| if isinstance(content, list): |
| parts = [] |
| for item in content: |
| if isinstance(item, str): |
| parts.append(item) |
| elif isinstance(item, dict): |
| parts.append(item.get("text", item.get("content", str(item)))) |
| elif hasattr(item, "text"): |
| parts.append(str(item.text)) |
| else: |
| parts.append(str(item)) |
| return "".join(parts) |
| if isinstance(content, dict): |
| return content.get("text", content.get("content", str(content))) |
| return str(content) |
|
|
| def respond(user_msg, history): |
| if not user_msg.strip(): |
| yield history, ""; return |
| msgs = [{"role": "system", "content": SYSTEM_PROMPT}] |
| for m in history: |
| r = m.role if hasattr(m, "role") else m.get("role", "user") |
| c = m.content if hasattr(m, "content") else m.get("content", "") |
| msgs.append({"role": str(r), "content": _to_str(c)}) |
| msgs.append({"role": "user", "content": str(user_msg)}) |
| |
| print("[DEBUG respond] Sending messages to Modal:") |
| for i, msg in enumerate(msgs): |
| print(f" [{i}] role={msg['role']!r}, content_type={type(msg['content']).__name__}, len={len(msg['content'])}") |
| if i > 0: |
| print(f" preview: {msg['content'][:100]!r}") |
| print(f" Total messages: {len(msgs)}") |
| history = list(history) + [ |
| gr.ChatMessage(role="user", content=user_msg), |
| gr.ChatMessage(role="assistant", content=""), |
| ] |
| acc = "" |
| for tok in stream_modal(msgs): |
| acc += tok |
| history[-1] = gr.ChatMessage(role="assistant", content=acc) |
| yield history, "" |
|
|
| def reset(): |
| return gr.update(visible=True), gr.update(visible=False), [] |
|
|
| CSS = """ |
| @import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&display=swap'); |
| |
| /* Force light surfaces — override Gradio's dark-mode CSS vars directly */ |
| :root, .dark { |
| --background-fill-primary: #ffffff !important; |
| --background-fill-secondary: #ffffff !important; |
| --block-background-fill: #ffffff !important; |
| --body-background-fill: #eef4f0 !important; |
| --panel-background-fill: #ffffff !important; |
| --border-color-primary: transparent !important; |
| --block-border-color: transparent !important; |
| --block-label-background-fill: transparent !important; |
| --block-info-text-color: #5d7a6b !important; |
| --body-text-color: #1d3b2c !important; |
| --body-text-color-subdued: #5d7a6b !important; |
| --block-title-text-color: #1d3b2c !important; |
| --block-label-text-color: #5d7a6b !important; |
| --input-text-color: #1d3b2c !important; |
| color-scheme: light !important; |
| } |
| html, body, .gradio-container, gradio-app, .dark { |
| background: #eef4f0 !important; |
| color-scheme: light !important; |
| } |
| html, body { margin: 0 !important; padding: 0 !important; font-family: 'Space Grotesk', sans-serif !important; } |
| .gradio-container { |
| max-width: 1060px !important; |
| margin: 0 auto !important; |
| padding: 36px 20px !important; |
| } |
| footer { display: none !important; } |
| |
| /* neutralize default grey blocks/wrappers so cards look clean */ |
| .block, .form, .gr-box, .gr-group { background: transparent !important; border: none !important; box-shadow: none !important; } |
| |
| /* kill dark wrapper boxes around dropdowns/inputs in dark mode */ |
| #intake-card .block, |
| #intake-card .form, |
| #intake-card .wrap, |
| #intake-card [class*="container"] { |
| background: transparent !important; |
| border: none !important; |
| box-shadow: none !important; |
| } |
| /* dropdown label text — keep readable */ |
| #intake-card .label-wrap span, |
| #intake-card label span, |
| #intake-card span[data-testid="block-info"] { |
| color: #5d7a6b !important; |
| opacity: 1 !important; |
| } |
| |
| /* ── The split card wraps both panes ── */ |
| #split-card { |
| background: #ffffff !important; |
| border-radius: 32px !important; |
| border: 0.5px solid #d4e6dc !important; |
| overflow: hidden !important; |
| box-shadow: 0 2px 4px rgba(29,59,44,0.04), 0 24px 60px rgba(29,59,44,0.09) !important; |
| padding: 0 !important; |
| } |
| #split-row { gap: 0 !important; flex-wrap: nowrap !important; } |
| |
| /* ── LEFT BRAND PANE ── */ |
| #brand-pane { |
| background: #2f9e6e !important; |
| min-height: 600px !important; |
| padding: 44px 40px !important; |
| position: relative !important; |
| overflow: hidden !important; |
| display: flex !important; |
| flex-direction: column !important; |
| justify-content: space-between !important; |
| } |
| .bp-orb1 { position: absolute; top: -70px; right: -70px; width: 280px; height: 280px; border-radius: 50%; background: rgba(255,255,255,0.08); pointer-events: none; } |
| .bp-orb2 { position: absolute; bottom: -50px; left: -50px; width: 200px; height: 200px; border-radius: 50%; background: rgba(255,255,255,0.06); pointer-events: none; } |
| .bp-logo { display: flex; align-items: center; gap: 11px; margin-bottom: 48px; position: relative; } |
| .bp-logo-mark { width: 42px; height: 42px; border-radius: 13px; background: rgba(255,255,255,0.2); display: flex; align-items: center; justify-content: center; font-size: 21px; } |
| .bp-logo-name { color: #fff; font-size: 16px; font-weight: 600; } |
| .bp-logo-sub { color: rgba(255,255,255,0.7); font-size: 11px; margin-top: 1px; } |
| .bp-title { font-size: 38px; font-weight: 600; color: #fff; line-height: 1.12; letter-spacing: -1px; margin-bottom: 14px; position: relative; } |
| .bp-desc { font-size: 14px; color: rgba(255,255,255,0.8); line-height: 1.65; max-width: 280px; position: relative; } |
| .bp-stats { display: flex; gap: 28px; margin-bottom: 26px; position: relative; } |
| .bp-stat-n { font-size: 24px; font-weight: 700; color: #fff; line-height: 1; margin-bottom: 4px; } |
| .bp-stat-l { font-size: 11px; color: rgba(255,255,255,0.65); } |
| .bp-trust { display: flex; flex-direction: column; gap: 10px; position: relative; } |
| .bp-trust-row { display: flex; align-items: center; gap: 9px; font-size: 12px; color: rgba(255,255,255,0.8); } |
| .bp-dot { width: 6px; height: 6px; border-radius: 50%; background: #b9f5d8; flex-shrink: 0; } |
| |
| /* ── RIGHT PANE ── */ |
| #right-pane { |
| background: #fff !important; |
| padding: 0 !important; |
| display: flex !important; |
| flex-direction: column !important; |
| justify-content: flex-start !important; |
| align-items: stretch !important; |
| } |
| #right-pane > div { width: 100% !important; } |
| |
| /* INTAKE inside right pane — do NOT force display, let Gradio toggle visibility */ |
| #intake-card { background: #fff !important; padding: 40px 40px 36px !important; } |
| #intake-card.hide, #intake-card[style*="display: none"] { display: none !important; } |
| .intake-h { font-size: 23px; font-weight: 600; color: #1d3b2c; margin-bottom: 5px; } |
| .intake-sub { font-size: 14px; color: #8aa899; margin-bottom: 26px; } |
| #intake-card label > span { |
| font-size: 12px !important; font-weight: 500 !important; color: #5d7a6b !important; |
| font-family: 'Space Grotesk', sans-serif !important; margin-bottom: 7px !important; |
| letter-spacing: 0 !important; text-transform: none !important; |
| } |
| #intake-card select, #intake-card input, |
| #intake-card .wrap input, #intake-card [class*="dropdown"] input, |
| #intake-card [class*="dropdown"] .single-select { |
| background: #f5faf7 !important; border: 1.5px solid #e0ede7 !important; border-radius: 14px !important; |
| color: #1d3b2c !important; font-family: 'Space Grotesk', sans-serif !important; font-size: 14px !important; |
| padding: 12px 15px !important; transition: all 0.18s !important; |
| } |
| /* dropdown displayed value + listbox options must be dark text */ |
| #intake-card [class*="dropdown"] *, #intake-card .token, #intake-card .single-select span { |
| color: #1d3b2c !important; |
| } |
| #intake-card ul[role="listbox"], #intake-card .options { background: #fff !important; } |
| #intake-card ul[role="listbox"] li, #intake-card .options .item { color: #1d3b2c !important; background: #fff !important; } |
| #intake-card ul[role="listbox"] li:hover, #intake-card .options .item:hover { background: #eef7f1 !important; } |
| #intake-card select:hover, #intake-card input:hover { border-color: #b8d8c8 !important; } |
| #intake-card select:focus, #intake-card input:focus { |
| border-color: #2f9e6e !important; box-shadow: 0 0 0 4px rgba(47,158,110,0.1) !important; |
| outline: none !important; background: #fff !important; |
| } |
| #intake-card select option { background: #fff; color: #1d3b2c; } |
| #start-btn button { |
| background: #2f9e6e !important; color: #fff !important; border: none !important; border-radius: 16px !important; |
| font-family: 'Space Grotesk', sans-serif !important; font-size: 15px !important; font-weight: 600 !important; |
| width: 100% !important; padding: 15px !important; margin-top: 18px !important; cursor: pointer !important; transition: all 0.2s !important; |
| } |
| #start-btn button:hover { background: #268a5e !important; transform: translateY(-2px) !important; box-shadow: 0 8px 24px rgba(47,158,110,0.3) !important; } |
| |
| /* CHAT inside right pane */ |
| #chat-card { background: #fff !important; display: flex !important; flex-direction: column !important; justify-content: flex-start !important; padding: 0 !important; } |
| #chat-card .block.padded { padding: 0 !important; } |
| #chat-card .html-container { padding: 0 !important; } |
| #chat-topbar { background: #1d3b2c; padding: 18px 24px; display: flex; align-items: center; gap: 12px; } |
| .ctb-av { width: 40px; height: 40px; border-radius: 13px; background: rgba(255,255,255,0.18); display: flex; align-items: center; justify-content: center; font-size: 20px; } |
| .ctb-name { color: #fff; font-size: 15px; font-weight: 600; margin: 0; } |
| .ctb-status { font-size: 12px; color: rgba(255,255,255,0.7); display: flex; align-items: center; gap: 5px; margin: 2px 0 0; } |
| .ctb-dot { width: 7px; height: 7px; border-radius: 50%; background: #6fe3a8; display: inline-block; } |
| |
| #chat-card .chatbot, #chat-card [data-testid="chatbot"] { |
| height: 520px !important; min-height: 520px !important; max-height: 520px !important; |
| background: #f5faf7 !important; border: none !important; border-radius: 0 !important; overflow-y: auto !important; |
| } |
| /* kill dark message-row wrappers so only the bubble shows */ |
| #chat-card .message-row, #chat-card .message-wrap, #chat-card .message, |
| #chat-card [class*="message-row"], #chat-card [class*="bubble"] { |
| background: transparent !important; border: none !important; box-shadow: none !important; |
| } |
| #chat-card .message.user { justify-content: flex-end !important; padding-left: 60px !important; background: transparent !important; } |
| #chat-card .message.user > div, #chat-card .user .prose { |
| background: #2f9e6e !important; color: #fff !important; border-radius: 20px 20px 6px 20px !important; |
| padding: 11px 16px !important; font-size: 14px !important; line-height: 1.55 !important; border: none !important; |
| } |
| #chat-card .user .prose * { |
| color: #fff !important; |
| } |
| #chat-card .message.bot > div, #chat-card .message.assistant > div, #chat-card .bot .prose, #chat-card .assistant .prose { |
| background: #1d3b2c !important; color: #fff !important; border-radius: 20px 20px 20px 6px !important; |
| padding: 13px 17px !important; font-size: 14px !important; line-height: 1.65 !important; border: none !important; |
| } |
| #chat-card .bot .prose *, #chat-card .assistant .prose * { |
| color: #fff !important; |
| } |
| #chat-card .bot .prose a, #chat-card .assistant .prose a { |
| color: #6fe3a8 !important; |
| text-decoration: underline !important; |
| } |
| #chat-card .bot .prose strong, #chat-card .assistant .prose strong { color: #b9f5d8 !important; font-weight: 600 !important; } |
| #input-area { border-top: 1px solid #edf4f0; padding: 16px 18px; background: #fff; display: flex; gap: 10px; align-items: flex-end; } |
| #input-area textarea { |
| background: #f5faf7 !important; border: 1.5px solid #e0ede7 !important; border-radius: 16px !important; |
| color: #1d3b2c !important; font-family: 'Space Grotesk', sans-serif !important; font-size: 14px !important; |
| resize: none !important; padding: 12px 16px !important; |
| } |
| #input-area textarea:focus { border-color: #2f9e6e !important; box-shadow: 0 0 0 4px rgba(47,158,110,0.1) !important; } |
| #input-area textarea::placeholder { color: #a8c2b5 !important; } |
| #send-btn button { background: #2f9e6e !important; color: #fff !important; border: none !important; border-radius: 14px !important; font-weight: 600 !important; font-size: 14px !important; height: 46px !important; min-width: 78px !important; cursor: pointer !important; } |
| #send-btn button:hover { background: #268a5e !important; } |
| #cta-row { padding: 13px 24px; background: #f5faf7; border-top: 1px solid #edf4f0; font-size: 13px; color: #5d7a6b; } |
| #cta-row a { color: #2f9e6e; font-weight: 600; text-decoration: none; } |
| """ |
|
|
| FORCE_LIGHT_JS = """ |
| function() { |
| const url = new URL(window.location.href); |
| if (url.searchParams.get('__theme') !== 'light') { |
| url.searchParams.set('__theme', 'light'); |
| window.location.replace(url.toString()); |
| } |
| } |
| """ |
|
|
| HEAD_SCRIPT = """ |
| <script> |
| (function() { |
| function syncPanels() { |
| var intake = document.getElementById('intake-card'); |
| var chat = document.getElementById('chat-card'); |
| var tb = document.getElementById('chat-topbar'); |
| if (!intake || !chat || !tb) return; |
| var chatActive = tb.getBoundingClientRect().height > 10 |
| && chat.getBoundingClientRect().height > 60; |
| if (chatActive) { |
| intake.style.setProperty('display', 'none', 'important'); |
| } |
| } |
| function boot() { |
| syncPanels(); |
| var obs = new MutationObserver(syncPanels); |
| obs.observe(document.body, { childList: true, subtree: true, attributes: true }); |
| setInterval(syncPanels, 400); |
| } |
| if (document.readyState === 'loading') { |
| document.addEventListener('DOMContentLoaded', boot); |
| } else { |
| setTimeout(boot, 300); |
| } |
| })(); |
| </script> |
| """ |
|
|
| with gr.Blocks(title="Wemed AI — MBBS Abroad Counsellor", fill_width=True) as demo: |
|
|
| with gr.Group(elem_id="split-card"): |
| with gr.Row(equal_height=True, elem_id="split-row"): |
|
|
| |
| with gr.Column(scale=1, min_width=300, elem_id="brand-pane"): |
| gr.HTML(""" |
| <div class="bp-orb1"></div><div class="bp-orb2"></div> |
| <div> |
| <div class="bp-logo"> |
| <div class="bp-logo-mark">🩺</div> |
| <div><div class="bp-logo-name">Wemed</div><div class="bp-logo-sub">AI Counsellor</div></div> |
| </div> |
| <div class="bp-title">Your medical<br>school abroad,<br>matched by AI 🌍</div> |
| <div class="bp-desc">Personalised country and college picks based on your budget, scores, and goals.</div> |
| </div> |
| <div> |
| <div class="bp-stats"> |
| <div><div class="bp-stat-n">50+</div><div class="bp-stat-l">NMC colleges</div></div> |
| <div><div class="bp-stat-n">6</div><div class="bp-stat-l">Countries</div></div> |
| <div><div class="bp-stat-n">Free</div><div class="bp-stat-l">Consultation</div></div> |
| </div> |
| <div class="bp-trust"> |
| <div class="bp-trust-row"><span class="bp-dot"></span>Only NMC-recognized colleges</div> |
| <div class="bp-trust-row"><span class="bp-dot"></span>FMGE/NEXT pass rates considered</div> |
| <div class="bp-trust-row"><span class="bp-dot"></span>Real human counsellors after</div> |
| </div> |
| </div> |
| """) |
|
|
| |
| with gr.Column(scale=2, min_width=380, elem_id="right-pane"): |
|
|
| with gr.Group(visible=True, elem_id="intake-card") as intake_panel: |
| gr.HTML('<div class="intake-h">Tell me about yourself</div><div class="intake-sub">A few quick questions and I\'ll find your best options.</div>') |
| with gr.Row(): |
| budget_dd = gr.Dropdown(label="What's your budget?", |
| choices=[f["options"] for f in INTAKE_FIELDS if f["id"] == "budget"][0], |
| value="₹20–30 lakhs", interactive=True) |
| neet_dd = gr.Dropdown(label="NEET score range?", |
| choices=[f["options"] for f in INTAKE_FIELDS if f["id"] == "neet_score"][0], |
| value="200–350", interactive=True) |
| with gr.Row(): |
| country_dd = gr.Dropdown(label="Any country in mind?", |
| choices=[f["options"] for f in INTAKE_FIELDS if f["id"] == "country_pref"][0], |
| value="No preference", interactive=True) |
| concern_dd = gr.Dropdown(label="Biggest worry?", |
| choices=[f["options"] for f in INTAKE_FIELDS if f["id"] == "concern"][0], |
| value="NMC recognition & FMGE/NEXT", interactive=True) |
| name_box = gr.Textbox(label="What's your name? (optional)", placeholder="e.g. Priya", max_lines=1) |
| with gr.Row(elem_id="start-btn"): |
| start_btn = gr.Button("Find my matches", variant="primary", size="lg") |
|
|
| with gr.Group(visible=False, elem_id="chat-card") as chat_panel: |
| gr.HTML(""" |
| <div id="chat-topbar"> |
| <div class="ctb-av">🩺</div> |
| <div><p class="ctb-name">Wemed AI Counsellor</p> |
| <p class="ctb-status"><span class="ctb-dot"></span> Online · replies instantly</p></div> |
| </div> |
| """) |
| chatbot = gr.Chatbot(label="", show_label=False, avatar_images=(None, None)) |
| with gr.Row(elem_id="input-area"): |
| user_input = gr.Textbox(show_label=False, container=False, |
| placeholder="Ask me anything about MBBS abroad…", scale=8, max_lines=3) |
| with gr.Column(scale=1, min_width=78, elem_id="send-btn"): |
| send_btn = gr.Button("Send", variant="primary") |
| gr.HTML('<div id="cta-row">📞 Want a real person? <a href="https://wemed.in" target="_blank">Book a free call with Wemed →</a></div>') |
|
|
| start_btn.click(fn=swap_to_chat, inputs=None, |
| outputs=[intake_panel, chat_panel]).then( |
| fn=start_chat, |
| inputs=[budget_dd, neet_dd, country_dd, concern_dd, name_box], |
| outputs=[chatbot]) |
| send_btn.click(fn=respond, inputs=[user_input, chatbot], outputs=[chatbot, user_input]) |
| user_input.submit(fn=respond, inputs=[user_input, chatbot], outputs=[chatbot, user_input]) |
|
|
| if __name__ == "__main__": |
| demo.launch(share=False, theme=gr.themes.Base(), css=CSS, js=FORCE_LIGHT_JS, head=HEAD_SCRIPT) |