owenkaplinsky's picture
websocket url conversion fix (#8)
d864cc1 verified
"""
Gradio Frontend for Recruitment Agent - Hugging Face Spaces Deployment
Requires Gradio 6.0+
"""
import os
import gradio as gr
from typing import Optional, Tuple, Dict, Any
import sys
from pathlib import Path
from uuid import uuid4
import requests
import uuid
project_root = Path(__file__).resolve().parent.parent.parent.parent
sys.path.insert(0, str(project_root))
try:
from src.sdk import SupervisorClient, DatabaseClient, CVUploadClient
SDK_AVAILABLE = True
except ImportError as e:
SDK_AVAILABLE = False
try:
alt_root = Path(__file__).parent.parent.parent.parent
if str(alt_root) not in sys.path:
sys.path.insert(0, str(alt_root))
from src.sdk import SupervisorClient, DatabaseClient, CVUploadClient
SDK_AVAILABLE = True
except Exception:
pass
# ============================================================================
# CONFIGURATION
# ============================================================================
def get_api_url(service: str) -> str:
env_map = {
"supervisor": "SUPERVISOR_API_URL",
"db": "DATABASE_API_URL",
"database": "DATABASE_API_URL",
"cv": "CV_UPLOAD_API_URL",
"voice-screener": "VOICE_SCREENER_API_URL",
}
path_map = {
"supervisor": "supervisor",
"db": "db",
"database": "db",
"cv": "cv",
"voice-screener": "voice-screener",
}
env_var = env_map.get(service, f"{service.upper()}_API_URL")
api_url = os.getenv(env_var)
if api_url:
return api_url
api_path = path_map.get(service, service)
space_id = os.getenv("SPACE_ID")
if space_id:
return f"https://{space_id}.hf.space/api/v1/{api_path}"
return f"http://localhost:8080/api/v1/{api_path}"
def get_voice_screening_url() -> str:
"""Get the URL for the Streamlit voice screening page."""
voice_url = os.getenv("VOICE_SCREENING_UI_URL", "http://localhost:8502")
return voice_url
def get_proxy_url(for_client: bool = False) -> str:
"""Get WebSocket proxy URL from environment or default."""
proxy_url = os.getenv("WEBSOCKET_PROXY_URL", "ws://localhost:8000/ws/realtime")
if for_client:
if "websocket_proxy" in proxy_url:
proxy_url = proxy_url.replace("websocket_proxy", "localhost")
return proxy_url
def get_proxy_base_url(for_client: bool = True) -> str:
"""Get HTTP base URL for proxy API calls.
Args:
for_client: If True, returns URL accessible from browser (localhost).
If False, returns internal Docker URL (websocket_proxy).
"""
proxy_url = get_proxy_url(for_client=for_client)
return proxy_url.replace("ws://", "http://").replace("wss://", "https://").replace("/ws/realtime", "")
def authenticate_voice_screening(email: str, auth_code: str) -> Tuple[str, Optional[str], Optional[str]]:
"""Authenticate user for voice screening. Returns (status_message, session_token, candidate_id)."""
if not email or not auth_code:
return "❌ Please enter both email and authentication code.", None, None
try:
# Use for_client=True since Gradio app may run locally and needs to connect to localhost:8000
# If running in Docker, the environment variable should be set to use internal URL
proxy_base = get_proxy_base_url(for_client=True)
response = requests.post(
f"{proxy_base}/auth/verify",
json={"email": email, "code": auth_code},
timeout=5
)
if response.status_code == 200:
data = response.json()
session_token = data.get("session_token")
candidate_id = data.get("candidate_id")
return f"βœ… Authentication successful! Session token: {session_token[:20]}...", session_token, candidate_id
else:
error_data = response.json() if response.content else {}
return f"❌ Authentication failed: {error_data.get('detail', response.text)}", None, None
except requests.exceptions.ConnectionError as e:
proxy_base = get_proxy_base_url(for_client=True)
return f"❌ Error connecting to proxy at {proxy_base}. Make sure the websocket_proxy service is running on port 8000.\n\nIf using Docker, ensure the service is started: `docker compose -f docker/docker-compose.yml up websocket_proxy`", None, None
except Exception as e:
return f"❌ Error connecting to proxy: {str(e)}", None, None
# ============================================================================
# CANDIDATE APPLICATION PORTAL
# ============================================================================
def submit_application(full_name: str, email: str, phone: str, cv_file, session_state: Optional[Dict[str, Any]] = None) -> Tuple[str, Dict[str, Any]]:
if not SDK_AVAILABLE:
return "❌ SDK not available. Please check backend connection.", ensure_session(session_state)
session = ensure_session(session_state)
if not full_name or not email:
return "❌ Full name and email are required.", session
if not cv_file:
return "❌ Please upload your CV (PDF or DOCX).", session
try:
client = CVUploadClient(base_url=get_api_url("cv"), session_id=session["session_id"])
file_path = cv_file.name if hasattr(cv_file, 'name') else str(cv_file)
filename = Path(file_path).name
with open(file_path, 'rb') as f:
response = client.submit(full_name=full_name, email=email, phone=phone or "", cv_file=f, filename=filename)
if response.success:
return f"βœ… {response.message}\n\nYour application has been recorded.", session
elif response.already_exists:
return f"⚠️ {response.message}\n\nPlease wait for review.", session
return f"❌ {response.message}", session
except Exception as e:
return f"❌ Failed to submit application: {str(e)}", session
def check_application_status(email: str, session_state: Optional[Dict[str, Any]] = None) -> Tuple[str, Dict[str, Any]]:
if not SDK_AVAILABLE:
return "❌ SDK not available.", ensure_session(session_state)
session = ensure_session(session_state)
if not email:
return "❌ Please enter your email address.", session
try:
client = DatabaseClient(base_url=get_api_url("database"), session_id=session["session_id"])
response = client.get_candidate_by_email(email, include_relations=True)
if response.success and response.data:
c = response.data
info = f"**Status:** {c.get('status', 'unknown')}\n\n"
info += f"**Applied:** {c.get('created_at', 'N/A')}\n\n"
if c.get('cv_screening_results'):
score = c['cv_screening_results'][0].get('overall_fit_score', 0)
info += f"**CV Score:** {score * 100:.1f}%\n\n"
if c.get('voice_screening_results'):
info += "**Voice Screening:** βœ… Completed\n\n"
if c.get('interview_scheduling'):
info += f"**Interview:** {c['interview_scheduling'][0].get('status', 'Scheduled')}\n\n"
if c.get('final_decision'):
info += f"**Decision:** {c['final_decision'].get('decision', 'Pending')}"
return info, session
return f"❌ No application found for {email}.", session
except Exception as e:
return f"❌ Error: {str(e)}", session
# ============================================================================
# HR PORTAL
# ============================================================================
def load_candidates(status_filter: Optional[str] = None, session_state: Optional[Dict[str, Any]] = None) -> Tuple[str, Dict[str, Any]]:
if not SDK_AVAILABLE:
return "❌ SDK not available.", ensure_session(session_state)
session = ensure_session(session_state)
try:
client = DatabaseClient(base_url=get_api_url("database"), session_id=session["session_id"])
response = client.get_candidates(status=status_filter if status_filter != "All" else None, limit=100, include_relations=True)
if response.success and response.data:
candidates = response.data
if not candidates:
return "No candidates found.", session
table = "| Name | Email | Status | Applied | Voice |\n|------|-------|--------|---------|-------|\n"
for c in candidates:
name = c.get('full_name', 'Unknown')
email = c.get('email', 'N/A')
status = c.get('status', 'unknown')
applied = str(c.get('created_at', 'N/A'))[:10]
voice = "βœ…" if c.get('voice_screening_results') else "❌"
table += f"| {name} | {email} | {status} | {applied} | {voice} |\n"
return f"**Found {len(candidates)} candidate(s)**\n\n{table}", session
return "No candidates found.", session
except Exception as e:
return f"❌ Error: {str(e)}", session
def trigger_voice_screening(candidate_email: str, session_state: Optional[Dict[str, Any]] = None) -> Tuple[str, Dict[str, Any]]:
if not SDK_AVAILABLE:
return "❌ SDK not available.", ensure_session(session_state)
session = ensure_session(session_state)
if not candidate_email:
return "❌ Please enter candidate email.", session
try:
# First, get candidate info to retrieve candidate_id and auth_code
db_client = DatabaseClient(base_url=get_api_url("database"), session_id=session["session_id"])
candidate_response = db_client.get_candidate_by_email(candidate_email, include_relations=False)
if not candidate_response.success or not candidate_response.data:
return f"❌ Candidate not found with email: {candidate_email}", session
candidate = candidate_response.data
candidate_id = candidate.get('id')
candidate_name = candidate.get('full_name', 'Unknown')
auth_code = candidate.get('auth_code')
if not candidate_id:
return f"❌ Could not retrieve candidate ID for {candidate_email}", session
# Create voice screening session
voice_api_url = get_api_url("voice-screener")
session_response = requests.post(
f"{voice_api_url}/session/create",
json={"candidate_id": candidate_id},
timeout=10
)
if session_response.status_code != 200:
error_detail = session_response.json().get('detail', 'Unknown error') if session_response.content else 'Unknown error'
return f"❌ Failed to create voice screening session: {error_detail}", session
session_data = session_response.json()
session_id = session_data.get('session_id')
# Construct the Streamlit URL with candidate_id
voice_screening_url = get_voice_screening_url()
redirect_url = f"{voice_screening_url}?candidate_id={candidate_id}"
# Build HTML response message
result = f"""
<div style="background: #eff6ff; border-left: 4px solid #2563eb; padding: 1.5rem; border-radius: 0 8px 8px 0; margin: 1rem 0;">
<h3 style="margin-top: 0; color: #1e40af;">βœ… Voice Screening Session Created</h3>
<p><strong>Candidate:</strong> {candidate_name}</p>
<p><strong>Email:</strong> {candidate_email}</p>
<p><strong>Session ID:</strong> <code style="background: #e0e7ff; padding: 0.25rem 0.5rem; border-radius: 4px;">{session_id}</code></p>
"""
if auth_code:
result += f"""
<div style="background: #fff3cd; border: 1px solid #ffc107; padding: 1rem; border-radius: 6px; margin-top: 1rem;">
<p style="margin: 0 0 0.5rem 0;"><strong>πŸ” Authentication Code:</strong></p>
<p style="margin: 0; font-size: 1.5rem; font-weight: bold; color: #856404; letter-spacing: 0.2em; text-align: center;">
{auth_code}
</p>
<p style="margin: 0.5rem 0 0 0; font-size: 0.9rem; color: #856404;">
Share this code with the candidate to access the voice screening interview.
</p>
</div>
"""
result += f"""
<div style="margin-top: 1rem;">
<p><strong>πŸŽ™οΈ Voice Screening URL:</strong></p>
<p><a href="{redirect_url}" target="_blank" style="color: #2563eb; text-decoration: underline; font-weight: 600;">
{redirect_url}
</a></p>
</div>
</div>
"""
return result, session
except Exception as e:
return f"❌ Failed: {str(e)}", session
def schedule_interview(candidate_email: str, session_state: Optional[Dict[str, Any]] = None) -> Tuple[str, Dict[str, Any]]:
if not SDK_AVAILABLE:
return "❌ SDK not available.", ensure_session(session_state)
session = ensure_session(session_state)
if not candidate_email:
return "❌ Please enter candidate email.", session
try:
client = SupervisorClient(base_url=get_api_url("supervisor"), session_id=session["session_id"])
thread_id = client.new_chat()
response = client.chat(message=f"Please schedule an interview for candidate with email {candidate_email}", thread_id=thread_id)
token_info = f"\n\nπŸ“Š Tokens: {response.token_count:,}" if response.token_count else ""
return f"βœ… Interview scheduling initiated!\n\n{response.content}{token_info}", session
except Exception as e:
return f"❌ Failed: {str(e)}", session
# ============================================================================
# SUPERVISOR AGENT CHAT (per-user state via session dict)
# ============================================================================
def ensure_session(state: Optional[Dict[str, Any]]) -> Dict[str, Any]:
"""Ensure a per-user session dict exists with a unique session_id."""
if state is None:
state = {}
if not state.get("session_id"):
state["session_id"] = uuid4().hex
state.setdefault("thread_id", None)
state.setdefault("messages", [])
state.setdefault("total_tokens", 0)
return state
def format_chat_history(messages: list) -> str:
if not messages:
return ""
formatted = []
for role, content in messages:
if role == "user":
formatted.append(f"πŸ‘€ **You**\n\n{content}")
else:
formatted.append(f"πŸ€– **Assistant**\n\n{content}")
return "\n\n---\n\n".join(formatted)
def init_chat(session_state: Optional[Dict[str, Any]] = None) -> Tuple[str, str, Dict[str, Any]]:
if not SDK_AVAILABLE:
return "❌ SDK not available.", "πŸ“Š Tokens: 0", ensure_session(session_state)
session = ensure_session(session_state)
try:
client = SupervisorClient(base_url=get_api_url("supervisor"), session_id=session["session_id"])
thread_id = client.new_chat()
session["thread_id"] = thread_id
session["messages"] = []
session["total_tokens"] = 0
welcome = """Hello! I'm the HR Supervisor Agent. I can help you with:
- **Querying** candidate information
- **Screening** CVs and providing insights
- **Scheduling** interviews automatically
- **Managing** the recruitment pipeline
What would you like to know?"""
session["messages"].append(("assistant", welcome))
return format_chat_history(session["messages"]), "πŸ“Š Tokens: 0", session
except Exception as e:
return f"❌ Failed to initialize: {str(e)}", "πŸ“Š Tokens: 0", session
def chat_with_supervisor(message: str, history: str, session_state: Optional[Dict[str, Any]]) -> Tuple[str, str, str, Dict[str, Any]]:
if not SDK_AVAILABLE:
return history, "❌ SDK not available.", "", ensure_session(session_state)
session = ensure_session(session_state)
if not message.strip():
return history, f"πŸ“Š Tokens: {session['total_tokens']:,}", "", session
if not session.get("thread_id"):
_, _, session = init_chat(session)
try:
client = SupervisorClient(base_url=get_api_url("supervisor"), session_id=session["session_id"])
session["messages"].append(("user", message))
response = client.chat(message=message, thread_id=session["thread_id"])
session["messages"].append(("assistant", response.content))
session["total_tokens"] += response.token_count or 0
return format_chat_history(session["messages"]), f"πŸ“Š Tokens: {session['total_tokens']:,}", "", session
except Exception as e:
error_msg = f"❌ Error: {str(e)}"
session["messages"].append(("assistant", error_msg))
return format_chat_history(session["messages"]), f"πŸ“Š Tokens: {session['total_tokens']:,}", "", session
# ============================================================================
# CUSTOM CSS
# ============================================================================
CUSTOM_CSS = """
/* =====================================================
FORCE LIGHT MODE - Aggressive overrides for Gradio 6
===================================================== */
/* Root level - override everything */
:root {
--body-background-fill: #ffffff !important;
--background-fill-primary: #ffffff !important;
--background-fill-secondary: #f8fafc !important;
--block-background-fill: #ffffff !important;
--input-background-fill: #ffffff !important;
--body-text-color: #1e293b !important;
--block-label-text-color: #1e293b !important;
--block-title-text-color: #1e293b !important;
--color-text-body: #1e293b !important;
--text-color: #1e293b !important;
color-scheme: light !important;
}
/* Target the main gradio wrapper */
#__next, #root, #app, main, .main,
gradio-app, .gradio-app,
[class*="gradio"], [id*="gradio"] {
background-color: #ffffff !important;
background: #ffffff !important;
color: #1e293b !important;
}
/* Dark mode class overrides */
.dark, [data-theme="dark"], html.dark, body.dark,
.dark *, [data-theme="dark"] * {
background-color: #ffffff !important;
color: #1e293b !important;
}
html, body {
background-color: #ffffff !important;
background: #ffffff !important;
color: #1e293b !important;
}
.gradio-container {
background-color: #ffffff !important;
background: #ffffff !important;
color: #1e293b !important;
}
/* Wrap everything */
.wrap, .wrapper, .contain,
[class*="wrap"], [class*="contain"] {
background-color: #ffffff !important;
background: #ffffff !important;
}
/* ALL text elements - force dark text */
*, *::before, *::after {
--tw-text-opacity: 1 !important;
}
h1, h2, h3, h4, h5, h6,
p, span, div, label,
li, td, th, a:not(.main-header a),
strong, b, em, i, u,
.text, [class*="text"] {
color: #1e293b !important;
}
/* Prose/Markdown specific */
.prose, .prose *,
.markdown, .markdown *,
[class*="prose"], [class*="markdown"],
.md, .md * {
color: #1e293b !important;
background-color: transparent !important;
}
/* Strong/bold text emphasis */
strong, b, .font-bold, .font-semibold {
color: #0f172a !important;
font-weight: 600 !important;
}
/* =====================================================
FORM ELEMENTS
===================================================== */
input, textarea, select, option {
background-color: #ffffff !important;
background: #ffffff !important;
color: #1e293b !important;
border: 1px solid #cbd5e1 !important;
border-radius: 6px !important;
}
input::placeholder, textarea::placeholder {
color: #94a3b8 !important;
opacity: 1 !important;
}
input:focus, textarea:focus, select:focus {
border-color: #2563eb !important;
outline: none !important;
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.2) !important;
}
/* Labels */
label, .label, [class*="label"] {
color: #1e293b !important;
font-weight: 500 !important;
}
/* =====================================================
BLOCKS AND CONTAINERS
===================================================== */
.block, .form, .container, .panel, .card, .box,
[class*="block"], [class*="panel"], [class*="card"],
[class*="svelte-"] {
background-color: #ffffff !important;
background: #ffffff !important;
}
/* =====================================================
HEADER WITH GRADIENT (WHITE TEXT)
===================================================== */
.main-header {
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%) !important;
padding: 2rem !important;
border-radius: 12px !important;
margin-bottom: 1.5rem !important;
text-align: center !important;
}
.main-header h1,
.main-header p,
.main-header span,
.main-header * {
color: white !important;
}
.main-header h1 {
font-size: 2.5rem !important;
margin: 0 !important;
font-weight: 700 !important;
}
.main-header p {
margin: 0.5rem 0 0 0 !important;
font-size: 1.1rem !important;
opacity: 0.95 !important;
}
/* =====================================================
INFO BOXES (BLUE THEMED)
===================================================== */
.info-box {
background: #eff6ff !important;
border-left: 4px solid #2563eb !important;
padding: 1rem !important;
border-radius: 0 8px 8px 0 !important;
margin: 1rem 0 !important;
}
.info-box, .info-box * {
color: #1e40af !important;
}
/* =====================================================
CHAT DISPLAY
===================================================== */
.chat-display {
border: 1px solid #e2e8f0 !important;
border-radius: 12px !important;
padding: 1.5rem !important;
background-color: #ffffff !important;
background: #ffffff !important;
min-height: 400px !important;
max-height: 500px !important;
overflow-y: auto !important;
}
.chat-display, .chat-display *,
.chat-display p, .chat-display span,
.chat-display strong, .chat-display li {
color: #1e293b !important;
}
/* =====================================================
STATS BOX
===================================================== */
.stats-box {
background-color: #f1f5f9 !important;
background: #f1f5f9 !important;
border-radius: 8px !important;
padding: 1rem !important;
text-align: center !important;
}
.stats-box, .stats-box * {
color: #475569 !important;
font-weight: 600 !important;
}
/* =====================================================
BUTTONS
===================================================== */
/* Primary buttons - blue bg, white text */
button.primary,
.primary,
button[class*="primary"],
[class*="primary"] button,
button[variant="primary"] {
background-color: #2563eb !important;
background: #2563eb !important;
color: white !important;
border: none !important;
border-radius: 6px !important;
}
button.primary:hover,
.primary:hover,
button[class*="primary"]:hover {
background-color: #1d4ed8 !important;
background: #1d4ed8 !important;
}
button.primary *,
.primary *,
button[class*="primary"] * {
color: white !important;
}
/* Secondary buttons */
button.secondary,
.secondary,
button[class*="secondary"],
button[variant="secondary"] {
background-color: #f1f5f9 !important;
background: #f1f5f9 !important;
color: #1e293b !important;
border: 1px solid #cbd5e1 !important;
}
button.secondary *,
.secondary *,
button[class*="secondary"] * {
color: #1e293b !important;
}
/* =====================================================
TABS
===================================================== */
button[role="tab"],
[role="tab"],
.tab, .tabs button {
color: #1e293b !important;
background-color: transparent !important;
}
button[role="tab"][aria-selected="true"],
[role="tab"][aria-selected="true"],
.tab.selected, .tab.active {
color: #2563eb !important;
border-bottom: 2px solid #2563eb !important;
}
.tab-content, .tabs-content, [role="tabpanel"] {
background-color: #ffffff !important;
}
/* =====================================================
TABLES
===================================================== */
table {
background-color: #ffffff !important;
}
th, td {
background-color: #ffffff !important;
color: #1e293b !important;
border-color: #e2e8f0 !important;
}
th {
background-color: #f8fafc !important;
font-weight: 600 !important;
}
/* =====================================================
DROPDOWN / SELECT
===================================================== */
select,
.dropdown,
[data-testid="dropdown"],
[class*="dropdown"] {
background-color: #ffffff !important;
background: #ffffff !important;
color: #1e293b !important;
border: 1px solid #cbd5e1 !important;
}
/* Dropdown options */
option {
background-color: #ffffff !important;
color: #1e293b !important;
}
/* =====================================================
FILE UPLOAD
===================================================== */
[class*="file"],
[class*="upload"],
.upload-area,
.dropzone,
[class*="drop"] {
background-color: #f8fafc !important;
background: #f8fafc !important;
border: 2px dashed #cbd5e1 !important;
border-radius: 8px !important;
}
[class*="file"] *,
[class*="upload"] *,
.dropzone * {
color: #64748b !important;
}
/* =====================================================
MISC
===================================================== */
hr {
border-color: #e2e8f0 !important;
}
/* Links (except in header) */
a:not(.main-header a) {
color: #2563eb !important;
}
a:not(.main-header a):hover {
color: #1d4ed8 !important;
}
/* Scrollbar styling */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #f1f5f9;
border-radius: 4px;
}
::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
/* =====================================================
AUTO-SCROLL FOR CHAT
===================================================== */
.auto-scroll {
overflow-y: auto !important;
scroll-behavior: smooth !important;
}
/* Remove scrollbar from voice and interview output sections */
.no-scroll-output,
.no-scroll-output *,
[class*="no-scroll-output"] {
overflow: visible !important;
overflow-y: visible !important;
overflow-x: visible !important;
max-height: none !important;
height: auto !important;
}
/* Ensure processing loaders are visible */
.no-scroll-output .gradio-loading,
.no-scroll-output [class*="loading"],
.no-scroll-output [class*="spinner"] {
display: block !important;
visibility: visible !important;
opacity: 1 !important;
}
"""
# ============================================================================
# THEME - Use Default theme as base (lighter than Soft)
# ============================================================================
try:
THEME = gr.themes.Default(
primary_hue="blue",
secondary_hue="slate",
neutral_hue="slate",
)
except Exception as e:
print(f"Theme creation failed: {e}, using string theme")
THEME = "default"
# ============================================================================
# GRADIO INTERFACE - Gradio 6+ Compatible
# In Gradio 6, theme and css are passed to launch(), not Blocks()
# ============================================================================
def create_app():
# In Gradio 6, gr.Blocks() takes no theme/css args - they go to launch()
with gr.Blocks() as app:
# Force light mode via JavaScript - runs on load and observes for changes
gr.HTML("""
<script>
// Force light mode immediately
function forceLightMode() {
document.documentElement.classList.remove('dark');
document.documentElement.setAttribute('data-theme', 'light');
document.documentElement.style.colorScheme = 'light';
document.body.classList.remove('dark');
document.body.style.backgroundColor = '#ffffff';
document.body.style.color = '#1e293b';
// Remove dark class from all elements
document.querySelectorAll('.dark, [data-theme="dark"]').forEach(el => {
el.classList.remove('dark');
el.setAttribute('data-theme', 'light');
});
}
// Run immediately
forceLightMode();
// Run when DOM is ready
document.addEventListener('DOMContentLoaded', forceLightMode);
// Observe for any dark mode changes
const observer = new MutationObserver(forceLightMode);
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class', 'data-theme']
});
// Also run after a short delay to catch late changes
setTimeout(forceLightMode, 100);
setTimeout(forceLightMode, 500);
setTimeout(forceLightMode, 1000);
</script>
<div class="main-header">
<h1>πŸ€– ScionHire AI Labs</h1>
<p>AI-Powered Recruitment System</p>
</div>
""")
# Per-user session state (persists across interactions)
session_state = gr.State(value=None)
with gr.Tabs():
# ============================================================
# TAB 1: Candidate Portal
# ============================================================
with gr.Tab("πŸ‘€ Candidate Portal"):
gr.Markdown("## πŸ“ Submit Your Application")
gr.HTML('<div class="info-box"><strong>Welcome!</strong> We\'re seeking talented engineers. Submit your CV below to start your application.</div>')
# Voice screening link for candidates
voice_screening_url = get_voice_screening_url()
gr.HTML(f"""
<div style="background: #eff6ff; border-left: 4px solid #2563eb; padding: 1rem; border-radius: 0 8px 8px 0; margin: 1rem 0;">
<strong>πŸŽ™οΈ Complete Your Voice Screening:</strong><br>
<a href="{voice_screening_url}" target="_blank" style="color: #2563eb; text-decoration: underline; font-weight: 600;">
Start Voice Screening Interview β†’ {voice_screening_url}
</a>
<br><small style="color: #64748b;">After submitting your application, you can complete your voice screening interview here.</small>
</div>
""")
with gr.Row():
with gr.Column():
full_name = gr.Textbox(label="Full Name", placeholder="Ada Lovelace")
email = gr.Textbox(label="Email", placeholder="ada@example.com")
phone = gr.Textbox(label="Phone (Optional)", placeholder="+1 234 567 8900")
cv_file = gr.File(label="Upload CV (PDF or DOCX)", file_types=[".pdf", ".docx"])
submit_btn = gr.Button("πŸ“¨ Submit Application", variant="primary", size="lg")
with gr.Column():
gr.Markdown("### πŸ“Š Application Result")
application_output = gr.Markdown()
submit_btn.click(
fn=submit_application,
inputs=[full_name, email, phone, cv_file, session_state],
outputs=[application_output, session_state]
)
gr.Markdown("---")
gr.Markdown("## πŸ” Check Application Status")
with gr.Row():
status_email = gr.Textbox(label="Email", placeholder="Enter your email to check status", scale=3)
check_btn = gr.Button("πŸ” Check Status", variant="secondary", scale=1)
status_output = gr.Markdown()
check_btn.click(fn=check_application_status, inputs=[status_email, session_state], outputs=[status_output, session_state])
# ============================================================
# TAB 2: HR Portal
# ============================================================
with gr.Tab("πŸ§‘β€πŸ’Ό HR Portal"):
gr.Markdown("## πŸ‘₯ Candidate Management")
with gr.Row():
status_filter = gr.Dropdown(
label="Filter by Status",
choices=["All", "applied", "cv_screened", "cv_passed", "voice_done", "voice_passed", "interview_scheduled"],
value="All",
scale=2
)
load_btn = gr.Button("πŸ”„ Load Candidates", variant="primary", scale=1)
candidates_output = gr.Markdown()
load_btn.click(fn=load_candidates, inputs=[status_filter, session_state], outputs=[candidates_output, session_state])
gr.Markdown("---")
gr.Markdown("## πŸŽ™οΈ Voice Screening")
gr.HTML('''<div class="info-box">
<strong>Note:</strong> Use the "πŸŽ™οΈ Voice Screening" tab to access the voice interview interface.
Trigger voice screening for a candidate below to generate their authentication code.
</div>''')
with gr.Row():
voice_email = gr.Textbox(label="Candidate Email", placeholder="candidate@example.com", scale=3)
voice_btn = gr.Button("πŸŽ™οΈ Trigger Screening", variant="secondary", scale=1)
voice_output = gr.HTML(elem_classes=["no-scroll-output"])
voice_btn.click(fn=trigger_voice_screening, inputs=[voice_email, session_state], outputs=[voice_output, session_state])
gr.Markdown("---")
gr.Markdown("## πŸ“… Interview Scheduling")
with gr.Row():
interview_email = gr.Textbox(label="Candidate Email", placeholder="candidate@example.com", scale=3)
interview_btn = gr.Button("πŸ“… Schedule Interview", variant="secondary", scale=1)
interview_output = gr.Markdown(elem_classes=["no-scroll-output"])
interview_btn.click(fn=schedule_interview, inputs=[interview_email, session_state], outputs=[interview_output, session_state])
# ============================================================
# TAB 3: Voice Screening
# ============================================================
with gr.Tab("πŸŽ™οΈ Voice Screening"):
gr.Markdown("## πŸŽ™οΈ Voice Screening Interview")
gr.HTML('''<div class="info-box">
<strong>Instructions:</strong> Enter your email and authentication code to start the voice screening interview.
You will receive the authentication code when HR triggers voice screening for you.
</div>''')
# Authentication section
with gr.Row():
with gr.Column():
gr.Markdown("### πŸ” Authentication")
vs_email = gr.Textbox(label="Email", placeholder="your.email@example.com")
vs_auth_code = gr.Textbox(label="Authentication Code", placeholder="Enter your 6-digit code", type="password")
vs_auth_btn = gr.Button("βœ… Authenticate", variant="primary")
vs_auth_status = gr.Markdown()
# Hidden components for session management
vs_session_token = gr.State()
vs_candidate_id = gr.State()
vs_session_id = gr.State()
# Voice interface (shown after authentication)
voice_interface_row = gr.Row(visible=False)
with voice_interface_row:
with gr.Column():
gr.Markdown("### 🎀 Voice Interview")
voice_interface_html = gr.HTML()
transcript_display = gr.Markdown("### πŸ“ Transcript\n\n*Transcript will appear here during the interview...*")
with gr.Row():
start_interview_btn = gr.Button("πŸš€ Start Interview", variant="primary")
end_interview_btn = gr.Button("⏹️ End Interview", variant="secondary")
def handle_authentication(email: str, auth_code: str, session_state: Optional[Dict[str, Any]] = None) -> Tuple[str, Dict, Optional[str], Optional[str], Optional[str], Dict[str, Any]]:
"""Handle voice screening authentication."""
session = ensure_session(session_state)
status_msg, session_token, candidate_id = authenticate_voice_screening(email, auth_code)
if session_token:
session_id = str(uuid.uuid4())
return (
status_msg,
gr.update(visible=True), # Show voice interface
session_token,
candidate_id or "",
session_id,
session
)
return (
status_msg,
gr.update(visible=False), # Hide voice interface
None,
None,
None,
session
)
def start_interview(session_token, candidate_id, session_id, session_state: Optional[Dict[str, Any]] = None) -> Tuple[str, str]:
"""Start the voice interview and load HTML component."""
session = ensure_session(session_state)
if not session_token:
return "❌ Please authenticate first.", gr.HTML()
# Load the HTML component
html_file_path = Path(__file__).parent.parent / "streamlit" / "voice_screening_ui" / "components" / "voice_interface.html"
if not html_file_path.exists():
return "❌ Voice interface component not found.", gr.HTML("<p>Voice interface HTML file not found.</p>")
with open(html_file_path, 'r', encoding='utf-8') as f:
html_content = f.read()
# Get proxy URL
proxy_url = get_proxy_url(for_client=True)
ws_url = f"{proxy_url}?token={session_token}"
# Replace placeholders
html_content = html_content.replace("{{SESSION_ID}}", session_id or "")
html_content = html_content.replace("{{SESSION_TOKEN}}", session_token)
html_content = html_content.replace("{{PROXY_URL}}", ws_url)
return "βœ… Interview started! Use the microphone button to speak.", gr.HTML(html_content)
vs_auth_btn.click(
fn=handle_authentication,
inputs=[vs_email, vs_auth_code, session_state],
outputs=[vs_auth_status, voice_interface_row, vs_session_token, vs_candidate_id, vs_session_id, session_state]
)
start_interview_btn.click(
fn=start_interview,
inputs=[vs_session_token, vs_candidate_id, vs_session_id, session_state],
outputs=[vs_auth_status, voice_interface_html]
)
# ============================================================
# TAB 4: Supervisor Chat
# ============================================================
with gr.Tab("πŸ€– Supervisor Chat"):
gr.Markdown("## πŸ’¬ Chat with HR Supervisor Agent")
gr.HTML('''<div class="info-box">
<strong>Capabilities:</strong> Query candidates β€’ Screen CVs β€’ Schedule interviews β€’ Manage recruitment pipeline
</div>''')
with gr.Row():
with gr.Column(scale=3):
chat_history = gr.Markdown(elem_classes=["chat-display", "auto-scroll"])
chat_input = gr.Textbox(
label="Your Message",
placeholder="Ask about candidates, screening, interviews...",
lines=2
)
with gr.Row():
send_btn = gr.Button("πŸ’¬ Send Message", variant="primary", scale=2)
new_chat_btn = gr.Button("πŸ†• New Chat", variant="secondary", scale=1)
with gr.Column(scale=1):
gr.Markdown("### πŸ“Š Session Stats")
token_info = gr.Markdown("πŸ“Š Tokens: 0", elem_classes=["stats-box"])
gr.Markdown("""
**πŸ’‘ Tips:**
- Ask about specific candidates by email
- Request CV screening summaries
- Schedule interviews directly
- Get pipeline statistics
""")
# Initialize chat on load with auto-scroll
def init_chat_with_scroll(state):
hist, tokens, new_state = init_chat(state)
return hist, tokens, new_state
app.load(
fn=init_chat_with_scroll,
inputs=[session_state],
outputs=[chat_history, token_info, session_state]
).then(
fn=None,
js="""
() => {
const chatDisplay = document.querySelector('.auto-scroll');
if (chatDisplay) {
setTimeout(() => {
chatDisplay.scrollTop = chatDisplay.scrollHeight;
}, 100);
}
}
"""
)
# Send message with auto-scroll
send_btn.click(
fn=chat_with_supervisor,
inputs=[chat_input, chat_history, session_state],
outputs=[chat_history, token_info, chat_input, session_state]
).then(
fn=None,
js="""
() => {
// Auto-scroll chat history to bottom
const chatDisplay = document.querySelector('.auto-scroll');
if (chatDisplay) {
setTimeout(() => {
chatDisplay.scrollTop = chatDisplay.scrollHeight;
}, 100);
}
}
"""
)
# New chat with auto-scroll
new_chat_btn.click(
fn=init_chat,
inputs=[session_state],
outputs=[chat_history, token_info, session_state]
).then(
fn=None,
js="""
() => {
const chatDisplay = document.querySelector('.auto-scroll');
if (chatDisplay) {
setTimeout(() => {
chatDisplay.scrollTop = chatDisplay.scrollHeight;
}, 100);
}
}
"""
)
gr.Markdown("---")
gr.Markdown("<center><small>Built with ❀️ for the MCP Hackathon</small></center>")
return app
# ============================================================================
# MAIN
# ============================================================================
if __name__ == "__main__":
print(f"Gradio version: {gr.__version__}")
app = create_app()
# Honor PORT if provided by hosting platform (e.g., Hugging Face Spaces)
# Some platforms inject quotes around PORT (e.g., "\"7860\""); strip them.
raw_port = os.getenv("PORT", "7860").strip().strip("\"'")
port = int(raw_port)
# In Gradio 6, theme and css are passed to launch(), not Blocks()
app.launch(
server_name="0.0.0.0",
server_port=port,
theme=THEME,
css=CUSTOM_CSS,
# Try to force light mode if available
# dark_mode=False, # Uncomment if supported in your version
)