|
|
""" |
|
|
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 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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: |
|
|
|
|
|
|
|
|
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 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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: |
|
|
|
|
|
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 |
|
|
|
|
|
|
|
|
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') |
|
|
|
|
|
|
|
|
voice_screening_url = get_voice_screening_url() |
|
|
redirect_url = f"{voice_screening_url}?candidate_id={candidate_id}" |
|
|
|
|
|
|
|
|
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 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 = """ |
|
|
/* ===================================================== |
|
|
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; |
|
|
} |
|
|
""" |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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" |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def create_app(): |
|
|
|
|
|
with gr.Blocks() as app: |
|
|
|
|
|
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> |
|
|
""") |
|
|
|
|
|
|
|
|
session_state = gr.State(value=None) |
|
|
|
|
|
with gr.Tabs(): |
|
|
|
|
|
|
|
|
|
|
|
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_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]) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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]) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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>''') |
|
|
|
|
|
|
|
|
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() |
|
|
|
|
|
|
|
|
vs_session_token = gr.State() |
|
|
vs_candidate_id = gr.State() |
|
|
vs_session_id = gr.State() |
|
|
|
|
|
|
|
|
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), |
|
|
session_token, |
|
|
candidate_id or "", |
|
|
session_id, |
|
|
session |
|
|
) |
|
|
return ( |
|
|
status_msg, |
|
|
gr.update(visible=False), |
|
|
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() |
|
|
|
|
|
|
|
|
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() |
|
|
|
|
|
|
|
|
proxy_url = get_proxy_url(for_client=True) |
|
|
ws_url = f"{proxy_url}?token={session_token}" |
|
|
|
|
|
|
|
|
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] |
|
|
) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 |
|
|
""") |
|
|
|
|
|
|
|
|
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_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_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 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__": |
|
|
print(f"Gradio version: {gr.__version__}") |
|
|
app = create_app() |
|
|
|
|
|
|
|
|
|
|
|
raw_port = os.getenv("PORT", "7860").strip().strip("\"'") |
|
|
port = int(raw_port) |
|
|
|
|
|
|
|
|
app.launch( |
|
|
server_name="0.0.0.0", |
|
|
server_port=port, |
|
|
theme=THEME, |
|
|
css=CUSTOM_CSS, |
|
|
|
|
|
|
|
|
) |
|
|
|