TalentSonar / hack /streamlit_app.py
Seyyed Alireza Khoshsolat
Upload 13896 files
6464253 verified
Raw
History Blame Contribute Delete
71 kB
"""
JD2GH - Mini ATS (Applicant Tracking System)
Streamlit Web Application - Single File
"""
import os
import json
import asyncio
import secrets
import base64
from datetime import datetime, timedelta, timezone
from collections import defaultdict
from typing import Optional, List, Dict, Any, Tuple
import math
import pandas as pd
from io import StringIO, BytesIO
import streamlit as st
import streamlit.components.v1 as components
import httpx
from dotenv import load_dotenv
import google.generativeai as genai
# SQLModel imports
from sqlmodel import Field, SQLModel, create_engine, Session, select, Column, JSON
from sqlalchemy import func
# Load environment variables
load_dotenv()
# Configure page
st.set_page_config(
page_title="JD2GH - Mini ATS",
page_icon="🎯",
layout="wide",
initial_sidebar_state="expanded"
)
# Configure APIs
GEMINI_API_KEY = os.getenv("GEMINI_API_KEY")
GITHUB_TOKEN = os.getenv("GITHUB_TOKEN")
if GEMINI_API_KEY:
genai.configure(api_key=GEMINI_API_KEY)
# ============================================================================
# DATA MODELS (SQLModel)
# ============================================================================
class JobPosting(SQLModel, table=True):
__table_args__ = {'extend_existing': True}
id: Optional[int] = Field(default=None, primary_key=True)
title: str
city: str
city_synonyms: str = Field(default="[]") # JSON string
min_repos: int = Field(default=5)
raw_description: str
parsed_description: str = Field(default="{}") # JSON string
weights: str = Field(default='{"skills":60,"activity":25,"quality":10,"completeness":5}') # JSON
is_active: bool = Field(default=True)
num_candidates: int = Field(default=0)
num_applied: int = Field(default=0)
num_tested: int = Field(default=0)
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
class Candidate(SQLModel, table=True):
__table_args__ = {'extend_existing': True}
id: Optional[int] = Field(default=None, primary_key=True)
login: str = Field(unique=True, index=True)
name: Optional[str] = None
github_url: Optional[str] = None
linkedin_url: Optional[str] = None
email: Optional[str] = None
location: Optional[str] = None
followers: int = Field(default=0)
total_stars: int = Field(default=0)
years_experience: Optional[int] = None
portfolio: str = Field(default="[]", sa_column=Column(JSON)) # JSON list of repos
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
class JobCandidateMatch(SQLModel, table=True):
__table_args__ = {'extend_existing': True}
id: Optional[int] = Field(default=None, primary_key=True)
job_id: int = Field(foreign_key="jobposting.id", index=True)
candidate_id: int = Field(foreign_key="candidate.id", index=True)
langs_found: str = Field(default="[]", sa_column=Column(JSON))
topics_found: str = Field(default="[]", sa_column=Column(JSON))
requirement_scores: str = Field(default="{}", sa_column=Column(JSON))
skill_subscore: float = Field(default=0.0)
activity_subscore: float = Field(default=0.0)
quality_subscore: float = Field(default=0.0)
completeness_subscore: float = Field(default=0.0)
total_score: float = Field(default=0.0)
evidence: str = Field(default="[]", sa_column=Column(JSON)) # list of reason strings
status: str = Field(default="DISCOVERED") # DISCOVERED, INVITED, APPLIED, etc.
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
class Invitation(SQLModel, table=True):
__table_args__ = {'extend_existing': True}
id: Optional[int] = Field(default=None, primary_key=True)
job_id: int = Field(foreign_key="jobposting.id")
candidate_id: int = Field(foreign_key="candidate.id")
token: str = Field(unique=True, index=True)
expires_at: datetime
used_at: Optional[datetime] = None
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
class AssessmentTemplate(SQLModel, table=True):
__table_args__ = {'extend_existing': True}
id: Optional[int] = Field(default=None, primary_key=True)
kind: str # "SOFT" or "TECH"
title: str
questions: str = Field(sa_column=Column(JSON)) # JSON list of question dicts
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
class AssessmentAttempt(SQLModel, table=True):
__table_args__ = {'extend_existing': True}
id: Optional[int] = Field(default=None, primary_key=True)
job_id: int = Field(foreign_key="jobposting.id")
candidate_id: int = Field(foreign_key="candidate.id")
kind: str # "SOFT" or "TECH"
answers: str = Field(default="{}", sa_column=Column(JSON))
soft_score: float = Field(default=0.0)
tech_score: float = Field(default=0.0)
started_at: Optional[datetime] = None
finished_at: Optional[datetime] = None
duration_sec: int = Field(default=0)
cheating_flags: str = Field(default="[]", sa_column=Column(JSON))
max_tab_switches: int = Field(default=0)
copy_paste_count: int = Field(default=0)
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
# Database setup
DATABASE_URL = "sqlite:///ats.db"
engine = create_engine(DATABASE_URL, echo=False)
def init_db():
"""Initialize database tables."""
SQLModel.metadata.create_all(engine)
# Initialize DB on import
init_db()
# ============================================================================
# UTILITY FUNCTIONS
# ============================================================================
def json_dumps(obj: Any) -> str:
"""Safe JSON serialization."""
return json.dumps(obj, default=str)
def json_loads(s: str, default: Any = None) -> Any:
"""Safe JSON deserialization."""
try:
return json.loads(s) if s else default
except:
return default
def make_token(job_id: int, candidate_id: int, ttl_hours: int = 168) -> str:
"""Create a URL-safe token for invitation."""
data = f"{job_id}:{candidate_id}:{secrets.token_urlsafe(16)}"
return base64.urlsafe_b64encode(data.encode()).decode()
def parse_token(token: str) -> Tuple[Optional[int], Optional[int]]:
"""Parse invitation token. Returns (job_id, candidate_id) or (None, None)."""
try:
decoded = base64.urlsafe_b64decode(token.encode()).decode()
parts = decoded.split(":")
if len(parts) >= 2:
return int(parts[0]), int(parts[1])
except:
pass
return None, None
def recalc_job_stats(job_id: int):
"""Recalculate and update job statistics."""
with Session(engine) as session:
job = session.get(JobPosting, job_id)
if not job:
return
# Count candidates
num_candidates = session.exec(
select(func.count(JobCandidateMatch.id)).where(JobCandidateMatch.job_id == job_id)
).one()
# Count applied (status >= APPLIED)
num_applied = session.exec(
select(func.count(JobCandidateMatch.id)).where(
JobCandidateMatch.job_id == job_id,
JobCandidateMatch.status.in_(["APPLIED", "TESTED", "HIRED"])
)
).one()
# Count tested (distinct candidates with finished attempts)
num_tested = session.exec(
select(func.count(func.distinct(AssessmentAttempt.candidate_id))).where(
AssessmentAttempt.job_id == job_id,
AssessmentAttempt.finished_at.isnot(None)
)
).one()
job.num_candidates = num_candidates
job.num_applied = num_applied
job.num_tested = num_tested
job.updated_at = datetime.now(timezone.utc)
session.add(job)
session.commit()
def extract_text_from_upload(uploaded_file) -> str:
"""Extract text from uploaded file (PDF, DOCX, MD, TXT)."""
filename = uploaded_file.name.lower()
try:
if filename.endswith('.txt') or filename.endswith('.md'):
return uploaded_file.read().decode('utf-8')
elif filename.endswith('.pdf'):
try:
from pypdf import PdfReader
pdf = PdfReader(BytesIO(uploaded_file.read()))
text = ""
for page in pdf.pages:
text += page.extract_text() + "\n"
return text
except ImportError:
st.error("pypdf not installed. Install with: pip install pypdf")
return ""
elif filename.endswith('.docx'):
try:
from docx import Document
doc = Document(BytesIO(uploaded_file.read()))
text = "\n".join([para.text for para in doc.paragraphs])
return text
except ImportError:
st.error("python-docx not installed. Install with: pip install python-docx")
return ""
else:
st.error(f"Unsupported file type: {filename}")
return ""
except Exception as e:
st.error(f"Error reading file: {e}")
return ""
# ============================================================================
# GEMINI WRAPPER (reuses existing extract_jd_spec)
# ============================================================================
def gemini_extract_spec(text: str) -> dict:
"""Extract JD spec using Gemini. Returns normalized dict with lowercase tokens."""
model = genai.GenerativeModel('gemini-2.5-flash')
prompt = f"""Analyze this job description and extract structured information in JSON format.
Focus on technical skills, programming languages, frameworks, and technologies.
For languages, use standard names like: Python, JavaScript, Java, Go, TypeScript, etc.
For topics, include frameworks, tools, platforms: React, Django, FastAPI, Docker, Kubernetes, etc.
Job Description:
{text}
Return ONLY a valid JSON object with this exact structure (no markdown, no extra text):
{{
"role": "job title or role name",
"languages": ["list", "of", "programming", "languages"],
"topics": ["list", "of", "frameworks", "tools", "technologies"],
"must_have": ["list", "of", "required", "skills"],
"nice_to_have": ["list", "of", "optional", "skills"]
}}"""
response = model.generate_content(prompt)
response_text = response.text.strip()
# Remove markdown code blocks if present
if response_text.startswith('```'):
lines = response_text.split('\n')
json_lines = []
in_code_block = False
for line in lines:
if line.startswith('```'):
in_code_block = not in_code_block
continue
if in_code_block or not line.startswith('```'):
json_lines.append(line)
response_text = '\n'.join(json_lines).strip()
spec = json.loads(response_text)
# Normalize to lowercase
spec['languages'] = [lang.lower() for lang in spec.get('languages', [])]
spec['topics'] = [topic.lower() for topic in spec.get('topics', [])]
spec['must_have'] = [item.lower() for item in spec.get('must_have', [])]
spec['nice_to_have'] = [item.lower() for item in spec.get('nice_to_have', [])]
return spec
# ============================================================================
# GEMINI: Extract structured spec from JD (ORIGINAL - kept for compatibility)
# ============================================================================
def extract_jd_spec(job_description: str) -> dict:
"""Use Gemini to extract structured requirements from job description."""
model = genai.GenerativeModel('gemini-2.5-flash')
prompt = f"""Analyze this job description and extract structured information in JSON format.
Focus on technical skills, programming languages, frameworks, and technologies.
For languages, use standard names like: Python, JavaScript, Java, Go, TypeScript, etc.
For topics, include frameworks, tools, platforms: React, Django, FastAPI, Docker, Kubernetes, etc.
Job Description:
{job_description}
Return ONLY a valid JSON object with this exact structure (no markdown, no extra text):
{{
"role": "job title or role name",
"languages": ["list", "of", "programming", "languages"],
"topics": ["list", "of", "frameworks", "tools", "technologies"],
"must_have": ["list", "of", "required", "skills"],
"nice_to_have": ["list", "of", "optional", "skills"]
}}"""
response = model.generate_content(prompt)
response_text = response.text.strip()
# Remove markdown code blocks if present
if response_text.startswith('```'):
lines = response_text.split('\n')
json_lines = []
in_code_block = False
for line in lines:
if line.startswith('```'):
in_code_block = not in_code_block
continue
if in_code_block or not line.startswith('```'):
json_lines.append(line)
response_text = '\n'.join(json_lines).strip()
spec = json.loads(response_text)
# Normalize languages and topics to lowercase for matching
spec['languages'] = [lang.lower() for lang in spec.get('languages', [])]
spec['topics'] = [topic.lower() for topic in spec.get('topics', [])]
return spec
# ============================================================================
# GITHUB: Search and fetch user data
# ============================================================================
async def search_github_users(city: str, cities_synonyms: list, languages: list, min_repos: int) -> list:
"""Search GitHub for users matching criteria."""
headers = {
"Authorization": f"Bearer {GITHUB_TOKEN}",
"Content-Type": "application/json"
}
all_cities = [city] + cities_synonyms
all_users = {}
async with httpx.AsyncClient(timeout=30.0) as client:
for city_name in all_cities:
for lang in languages if languages else [""]:
# Build search query
query_parts = [f'type:user location:"{city_name}"']
if lang:
query_parts.append(f'language:{lang}')
query_parts.append(f'repos:>{min_repos}')
search_query = ' '.join(query_parts)
# GitHub GraphQL query - reduced complexity to avoid rate limits
cursor = None
has_next = True
pages_fetched = 0
max_pages = 3 # Limit pagination to avoid excessive API calls
while has_next and pages_fetched < max_pages:
graphql_query = """
query($searchQuery: String!, $cursor: String) {
search(query: $searchQuery, type: USER, first: 10, after: $cursor) {
pageInfo {
hasNextPage
endCursor
}
nodes {
... on User {
login
name
location
followers {
totalCount
}
repositories(first: 3, orderBy: {field: STARGAZERS, direction: DESC}, isFork: false) {
nodes {
name
primaryLanguage {
name
}
stargazerCount
updatedAt
repositoryTopics(first: 5) {
nodes {
topic {
name
}
}
}
}
}
contributionsCollection {
totalCommitContributions
totalPullRequestContributions
totalIssueContributions
}
}
}
}
}
"""
variables = {
"searchQuery": search_query,
"cursor": cursor
}
try:
response = await client.post(
"https://api.github.com/graphql",
headers=headers,
json={"query": graphql_query, "variables": variables}
)
if response.status_code != 200:
st.error(f"GitHub API error: {response.status_code}")
break
data = response.json()
if "errors" in data:
st.warning(f"GraphQL partial errors (continuing): {len(data['errors'])} errors")
search_results = data.get("data", {}).get("search", {})
if not search_results:
break
users = search_results.get("nodes", [])
# Merge/dedupe users
for user in users:
if user and user.get("login"):
login = user["login"]
if login not in all_users:
all_users[login] = user
# Check pagination
page_info = search_results.get("pageInfo", {})
has_next = page_info.get("hasNextPage", False)
cursor = page_info.get("endCursor")
pages_fetched += 1
if not has_next:
break
except Exception as e:
st.error(f"Error fetching users: {e}")
has_next = False
return list(all_users.values())
# ============================================================================
# SCORING: Calculate match scores
# ============================================================================
def score_user(user: dict, jd_spec: dict) -> dict:
"""Score a user based on JD requirements (0-100)."""
jd_languages = set(jd_spec.get('languages', []))
jd_topics = set(jd_spec.get('topics', []))
# Extract user's languages and topics from repos
user_languages = set()
user_topics = set()
max_stars = 0
most_recent_update = None
repos = user.get('repositories', {}).get('nodes', [])
for repo in repos:
if repo:
# Language
if repo.get('primaryLanguage') and repo['primaryLanguage'].get('name'):
user_languages.add(repo['primaryLanguage']['name'].lower())
# Topics
repo_topics = repo.get('repositoryTopics', {}).get('nodes', [])
for topic_node in repo_topics:
if topic_node and topic_node.get('topic'):
user_topics.add(topic_node['topic']['name'].lower())
# Stars
stars = repo.get('stargazerCount', 0)
max_stars = max(max_stars, stars)
# Updated at
updated_at = repo.get('updatedAt')
if updated_at:
updated_date = datetime.fromisoformat(updated_at.replace('Z', '+00:00'))
if most_recent_update is None or updated_date > most_recent_update:
most_recent_update = updated_date
# 1. Skill match (60%)
lang_match = len(jd_languages & user_languages)
topic_match = len(jd_topics & user_topics)
total_jd_skills = len(jd_languages) + len(jd_topics)
if total_jd_skills > 0:
skill_score = ((lang_match + topic_match) / total_jd_skills) * 60
else:
skill_score = 0
# 2. Activity/Recency (25%)
contributions = user.get('contributionsCollection', {})
commits = contributions.get('totalCommitContributions', 0)
prs = contributions.get('totalPullRequestContributions', 0)
issues = contributions.get('totalIssueContributions', 0)
total_activity = commits + prs + issues
# Cap activity score (log scale)
activity_base = min(math.log(total_activity + 1) / math.log(1000), 1) * 15
# Recency bonus
recency_bonus = 0
if most_recent_update:
days_ago = (datetime.now(most_recent_update.tzinfo) - most_recent_update).days
if days_ago <= 90:
recency_bonus = 10
activity_score = activity_base + recency_bonus
# 3. Quality (10%)
followers = user.get('followers', {}).get('totalCount', 0)
# Log-capped quality score
quality_score = min(math.log(max_stars + followers + 1) / math.log(1000), 1) * 10
# 4. Completeness (5%)
completeness_score = 0
if user.get('name'):
completeness_score += 2.5
if user.get('location'):
completeness_score += 2.5
# Total score
total_score = skill_score + activity_score + quality_score + completeness_score
total_score = min(max(total_score, 0), 100) # Clamp to 0-100
# Generate human-readable reasons
reasons = []
if lang_match > 0:
reasons.append(f"{lang_match} language match(es)")
if topic_match > 0:
reasons.append(f"{topic_match} topic match(es)")
if total_activity > 100:
reasons.append(f"{total_activity} contributions last year")
if recency_bonus > 0:
reasons.append("Recent activity (≤90 days)")
if max_stars > 50:
reasons.append(f"Top repo has {max_stars} stars")
if followers > 20:
reasons.append(f"{followers} followers")
return {
'rank': 0, # Will be set later
'login': user.get('login', ''),
'name': user.get('name', ''),
'location': user.get('location', ''),
'score': round(total_score, 1),
'reasons': ', '.join(reasons) if reasons else 'No strong signals',
'languages': sorted(list(user_languages)),
'topics': sorted(list(user_topics)),
'followers': followers
}
# ============================================================================
# GITHUB + SCORING WRAPPER (reuses existing functions)
# ============================================================================
async def run_discovery_for_job(job: JobPosting, parsed: dict, custom_weights: dict = None) -> List[dict]:
"""
Run GitHub discovery and scoring for a job.
Returns list of candidate dicts with scoring data.
Reuses existing search_github_users and score_user functions.
"""
city_synonyms = json_loads(job.city_synonyms, [])
languages = parsed.get('languages', [])
topics = parsed.get('topics', [])
# Search GitHub
users = await search_github_users(job.city, city_synonyms, languages, job.min_repos)
# Build JD spec for scoring
jd_spec = {
'languages': languages,
'topics': topics,
'must_have': parsed.get('must_have', []),
'nice_to_have': parsed.get('nice_to_have', [])
}
# Score each user
results = []
for user in users:
scored = score_user(user, jd_spec)
# Extract detailed info for DB storage
user_languages = set()
user_topics = set()
portfolio = []
max_stars = 0
repos = user.get('repositories', {}).get('nodes', [])
for repo in repos:
if repo:
repo_data = {
'name': repo.get('name', ''),
'stars': repo.get('stargazerCount', 0),
'language': None,
'topics': [],
'updated_at': repo.get('updatedAt', '')
}
if repo.get('primaryLanguage') and repo['primaryLanguage'].get('name'):
lang = repo['primaryLanguage']['name'].lower()
user_languages.add(lang)
repo_data['language'] = lang
repo_topics = repo.get('repositoryTopics', {}).get('nodes', [])
for topic_node in repo_topics:
if topic_node and topic_node.get('topic'):
topic = topic_node['topic']['name'].lower()
user_topics.add(topic)
repo_data['topics'].append(topic)
max_stars = max(max_stars, repo.get('stargazerCount', 0))
portfolio.append(repo_data)
# Calculate requirement scores
requirement_scores = {}
for req in parsed.get('must_have', []):
req_lower = req.lower()
if req_lower in user_languages or req_lower in user_topics:
requirement_scores[req] = 1.0
else:
requirement_scores[req] = 0.0
for req in parsed.get('nice_to_have', []):
req_lower = req.lower()
if req_lower in user_languages or req_lower in user_topics:
requirement_scores[req] = 1.0
else:
requirement_scores[req] = 0.0
results.append({
'login': user.get('login', ''),
'name': user.get('name', ''),
'location': user.get('location', ''),
'followers': user.get('followers', {}).get('totalCount', 0),
'total_stars': max_stars,
'portfolio': portfolio,
'langsFound': sorted(list(user_languages)),
'topicsFound': sorted(list(user_topics)),
'requirement_scores': requirement_scores,
'reasons': scored['reasons'].split(', ') if scored['reasons'] else [],
'skill_subscore': scored.get('skill_subscore', 0),
'activity_subscore': scored.get('activity_subscore', 0),
'quality_subscore': scored.get('quality_subscore', 0),
'completeness_subscore': scored.get('completeness_subscore', 0),
'score': scored['score']
})
return results
# ============================================================================
# ASSESSMENT TEMPLATES
# ============================================================================
def seed_assessment_templates():
"""Seed default assessment templates if they don't exist."""
with Session(engine) as session:
# Check if template exists
soft_exists = session.exec(select(AssessmentTemplate).where(AssessmentTemplate.kind == "SOFT")).first()
if not soft_exists:
soft_questions = [
{
"id": "s1",
"prompt": "How do you prioritize tasks when working on multiple projects?",
"type": "mcq",
"choices": [
"Based on deadlines",
"Based on importance to stakeholders",
"Using a prioritization framework (e.g., Eisenhower matrix)",
"First come, first served"
],
"answer": 2
},
{
"id": "s2",
"prompt": "A team member disagrees with your approach. What do you do?",
"type": "mcq",
"choices": [
"Insist on your approach",
"Listen to their perspective and discuss pros/cons",
"Ask the manager to decide",
"Compromise without discussion"
],
"answer": 1
},
{
"id": "s3",
"prompt": "How do you handle stress during tight deadlines?",
"type": "mcq",
"choices": [
"Work longer hours",
"Break tasks into smaller chunks and focus",
"Ask for deadline extension",
"Delegate everything"
],
"answer": 1
},
{
"id": "s4",
"prompt": "What's your approach to learning new technologies?",
"type": "mcq",
"choices": [
"Read documentation cover-to-cover",
"Build a small project immediately",
"Take an online course first",
"Ask colleagues to teach me"
],
"answer": 1
},
{
"id": "s5",
"prompt": "How do you give feedback to peers?",
"type": "mcq",
"choices": [
"Direct and immediate",
"Sandwich method (positive-negative-positive)",
"Only when asked",
"Through the manager"
],
"answer": 1
}
]
template = AssessmentTemplate(
kind="SOFT",
title="Soft Skills Assessment",
questions=json_dumps(soft_questions)
)
session.add(template)
session.commit()
# Seed templates on startup
seed_assessment_templates()
# ============================================================================
# STREAMLIT APP - NAVIGATION
# ============================================================================
# ============================================================================
# STREAMLIT APP - NAVIGATION
# ============================================================================
def main():
"""Main application with navigation."""
# Initialize session state
if 'selected_job_id' not in st.session_state:
st.session_state.selected_job_id = None
# Check query params for page navigation
query_params = st.query_params
query_page = query_params.get("page", None)
# Sidebar navigation
with st.sidebar:
st.title("🎯 JD2GH Mini ATS")
# Check API keys
if not GEMINI_API_KEY:
st.error("⚠️ GEMINI_API_KEY not set")
else:
st.success("✅ Gemini API")
if not GITHUB_TOKEN:
st.error("⚠️ GITHUB_TOKEN not set")
else:
st.success("✅ GitHub API")
st.markdown("---")
# Navigation - use query param if available, otherwise use radio
page_options = ["Dashboard", "Job Postings", "Candidates", "Assessments", "Candidate Portal"]
# Set default index based on query param
default_index = 0
if query_page and query_page in page_options:
default_index = page_options.index(query_page)
page = st.radio(
"Navigation",
page_options,
index=default_index,
key="nav_page"
)
st.markdown("---")
st.caption("Mini ATS v1.0")
# Route to pages (map "Assessments" display name to internal "Tests" page)
if page == "Dashboard":
page_dashboard()
elif page == "Job Postings":
page_job_postings()
elif page == "Candidates":
page_candidates()
elif page == "Assessments": # Display name
page_tests() # Internal page function
elif page == "Candidate Portal":
page_candidate_portal()
# ============================================================================
# PAGE: Dashboard
# ============================================================================
def page_dashboard():
"""Dashboard showing overview metrics and job list."""
st.title("📊 Dashboard")
with Session(engine) as session:
jobs = session.exec(select(JobPosting).where(JobPosting.is_active == True)).all()
if not jobs:
st.info("� Welcome! No active job postings yet.")
st.markdown("### Get Started")
st.markdown("1. Go to **Job Postings** to create your first job")
st.markdown("2. Upload or paste a job description")
st.markdown("3. Discover candidates from GitHub")
st.markdown("4. Invite top candidates to apply")
return
# Metrics
total_jobs = len(jobs)
total_candidates = sum(j.num_candidates for j in jobs)
total_applied = sum(j.num_applied for j in jobs)
total_tested = sum(j.num_tested for j in jobs)
col1, col2, col3, col4 = st.columns(4)
col1.metric("📋 Job Posts", total_jobs)
col2.metric("👥 Candidates", total_candidates)
col3.metric("✅ Applied", total_applied)
col4.metric("📝 Tested", total_tested)
st.markdown("---")
# Job list table
st.subheader("Active Job Postings")
job_data = []
for job in jobs:
job_data.append({
"ID": job.id,
"Title": job.title,
"City": job.city,
"Candidates": job.num_candidates,
"Applied": job.num_applied,
"Tested": job.num_tested,
"Created": job.created_at.strftime("%Y-%m-%d")
})
if job_data:
df = pd.DataFrame(job_data)
st.dataframe(df, use_container_width=True, hide_index=True)
# ============================================================================
# PAGE: Job Postings
# ============================================================================
def page_job_postings():
"""Job postings management - create and manage jobs."""
st.title("📋 Job Postings")
tab1, tab2 = st.tabs(["➕ New Job", "📂 Manage"])
# TAB 1: Create new job
with tab1:
st.subheader("Create New Job Posting")
with st.form("new_job_form"):
title = st.text_input("Job Title*", placeholder="e.g., Senior Backend Engineer")
col1, col2 = st.columns(2)
with col1:
city = st.text_input("City*", value="Rome")
with col2:
city_synonyms_str = st.text_input("City Synonyms (comma-separated)", value="Roma")
min_repos = st.number_input("Minimum Repositories", min_value=1, value=5)
jd_text = st.text_area(
"Job Description",
height=200,
placeholder="Paste job description here..."
)
uploaded_file = st.file_uploader(
"Or upload JD file (.pdf, .docx, .md, .txt)",
type=['pdf', 'docx', 'md', 'txt']
)
submitted = st.form_submit_button("🔍 Extract & Preview", type="primary")
if submitted:
if not title.strip():
st.error("Please enter a job title")
return
if not city.strip():
st.error("Please enter a city")
return
# Get JD text
final_jd_text = jd_text.strip()
if uploaded_file and not final_jd_text:
final_jd_text = extract_text_from_upload(uploaded_file)
if not final_jd_text:
st.error("Please provide a job description (text or file)")
return
# Extract with Gemini
with st.spinner("🤖 Analyzing job description with AI..."):
try:
parsed = gemini_extract_spec(final_jd_text)
st.success("✅ Job description extracted!")
# Store in session for editing
st.session_state['new_job_data'] = {
'title': title,
'city': city,
'city_synonyms': city_synonyms_str,
'min_repos': min_repos,
'raw_description': final_jd_text,
'parsed': parsed
}
except Exception as e:
st.error(f"Error extracting JD: {e}")
return
# Show editable preview if extracted
if 'new_job_data' in st.session_state:
st.markdown("---")
st.subheader("📝 Edit Extracted Requirements")
data = st.session_state['new_job_data']
parsed = data['parsed']
st.markdown(f"**Role:** {parsed.get('role', 'N/A')}")
# Editable multiselects
languages = st.multiselect(
"Programming Languages",
options=parsed.get('languages', []) + ['python', 'javascript', 'java', 'go', 'typescript', 'rust', 'c++'],
default=parsed.get('languages', [])
)
topics = st.multiselect(
"Topics/Frameworks",
options=parsed.get('topics', []) + ['react', 'django', 'fastapi', 'docker', 'kubernetes', 'aws', 'postgresql'],
default=parsed.get('topics', [])
)
must_have = st.multiselect(
"Must Have Skills",
options=parsed.get('must_have', []) + languages + topics,
default=parsed.get('must_have', [])
)
nice_to_have = st.multiselect(
"Nice to Have Skills",
options=parsed.get('nice_to_have', []) + languages + topics,
default=parsed.get('nice_to_have', [])
)
st.markdown("### ⚖️ Scoring Weights")
col1, col2, col3, col4 = st.columns(4)
with col1:
w_skills = st.slider("Skills", 0, 100, 60)
with col2:
w_activity = st.slider("Activity", 0, 100, 25)
with col3:
w_quality = st.slider("Quality", 0, 100, 10)
with col4:
w_completeness = st.slider("Completeness", 0, 100, 5)
total_weight = w_skills + w_activity + w_quality + w_completeness
if total_weight != 100:
st.warning(f"⚠️ Weights sum to {total_weight}. Should be 100.")
if st.button("💾 Save Job Posting", type="primary"):
# Update parsed data
parsed['languages'] = languages
parsed['topics'] = topics
parsed['must_have'] = must_have
parsed['nice_to_have'] = nice_to_have
weights = {
'skills': w_skills,
'activity': w_activity,
'quality': w_quality,
'completeness': w_completeness
}
city_synonyms = [s.strip() for s in data['city_synonyms'].split(',') if s.strip()]
# Save to DB
with Session(engine) as session:
job = JobPosting(
title=data['title'],
city=data['city'],
city_synonyms=json_dumps(city_synonyms),
min_repos=data['min_repos'],
raw_description=data['raw_description'],
parsed_description=json_dumps(parsed),
weights=json_dumps(weights)
)
session.add(job)
session.commit()
session.refresh(job)
st.success(f"✅ Job posting #{job.id} created!")
del st.session_state['new_job_data']
st.rerun()
# TAB 2: Manage existing jobs
with tab2:
st.subheader("Manage Job Postings")
with Session(engine) as session:
jobs = session.exec(select(JobPosting).order_by(JobPosting.created_at.desc())).all()
if not jobs:
st.info("No job postings yet. Create one in the 'New Job' tab!")
return
# Display jobs as cards
for job in jobs:
with st.expander(f"**{job.title}** (ID: {job.id}) - {job.city}", expanded=False):
col1, col2, col3, col4 = st.columns(4)
col1.metric("Candidates", job.num_candidates)
col2.metric("Applied", job.num_applied)
col3.metric("Tested", job.num_tested)
col4.metric("Min Repos", job.min_repos)
st.markdown(f"**Created:** {job.created_at.strftime('%Y-%m-%d %H:%M')}")
st.markdown(f"**Active:** {'✅ Yes' if job.is_active else '❌ No'}")
if st.button(f"📊 Open Dataset", key=f"open_{job.id}"):
st.session_state.selected_job_id = job.id
st.query_params["page"] = "Candidates"
st.rerun()
if st.button(f"🗑️ Delete", key=f"del_{job.id}"):
session.delete(job)
session.commit()
st.success(f"Deleted job #{job.id}")
st.rerun()
# ============================================================================
# PAGE: Candidates
# ============================================================================
def page_candidates():
"""Candidates page - discovery and management per job."""
st.title("👥 Candidates")
with Session(engine) as session:
jobs = session.exec(select(JobPosting).where(JobPosting.is_active == True)).all()
if not jobs:
st.info("No active jobs. Create one in Job Postings first!")
return
# Job selector
job_options = {f"{j.id}: {j.title}": j.id for j in jobs}
# Use selected_job_id from session if available
default_idx = 0
if st.session_state.selected_job_id:
for idx, (label, jid) in enumerate(job_options.items()):
if jid == st.session_state.selected_job_id:
default_idx = idx
break
selected_label = st.selectbox(
"Select Job",
options=list(job_options.keys()),
index=default_idx
)
job_id = job_options[selected_label]
st.session_state.selected_job_id = job_id
job = session.get(JobPosting, job_id)
if not job:
st.error("Job not found")
return
parsed = json_loads(job.parsed_description, {})
city_synonyms = json_loads(job.city_synonyms, [])
# Show config
with st.expander("⚙️ Job Configuration", expanded=False):
col1, col2, col3 = st.columns(3)
col1.metric("City", job.city)
col2.metric("City Synonyms", len(city_synonyms))
col3.metric("Min Repos", job.min_repos)
st.markdown(f"**Languages:** {', '.join(parsed.get('languages', []))}")
st.markdown(f"**Topics:** {', '.join(parsed.get('topics', []))}")
st.markdown(f"**Must Have:** {', '.join(parsed.get('must_have', []))}")
# Discovery button
if st.button("🔍 Run / Refresh Discovery Now", type="primary"):
with st.spinner("Searching GitHub and scoring candidates..."):
try:
# Run discovery
results = asyncio.run(run_discovery_for_job(job, parsed))
# Upsert candidates and matches
for res in results:
# Upsert candidate
cand = session.exec(
select(Candidate).where(Candidate.login == res['login'])
).first()
if not cand:
cand = Candidate(
login=res['login'],
name=res['name'],
github_url=f"https://github.com/{res['login']}",
location=res['location'],
followers=res['followers'],
total_stars=res['total_stars'],
portfolio=json_dumps(res['portfolio'])
)
session.add(cand)
session.commit()
session.refresh(cand)
else:
# Update existing
cand.name = res['name'] or cand.name
cand.location = res['location'] or cand.location
cand.followers = res['followers']
cand.total_stars = res['total_stars']
cand.portfolio = json_dumps(res['portfolio'])
cand.updated_at = datetime.now(timezone.utc)
session.add(cand)
session.commit()
# Upsert match
match = session.exec(
select(JobCandidateMatch).where(
JobCandidateMatch.job_id == job_id,
JobCandidateMatch.candidate_id == cand.id
)
).first()
if not match:
match = JobCandidateMatch(
job_id=job_id,
candidate_id=cand.id,
langs_found=json_dumps(res['langsFound']),
topics_found=json_dumps(res['topicsFound']),
requirement_scores=json_dumps(res['requirement_scores']),
skill_subscore=res.get('skill_subscore', 0),
activity_subscore=res.get('activity_subscore', 0),
quality_subscore=res.get('quality_subscore', 0),
completeness_subscore=res.get('completeness_subscore', 0),
total_score=res['score'],
evidence=json_dumps(res['reasons'])
)
session.add(match)
else:
match.langs_found = json_dumps(res['langsFound'])
match.topics_found = json_dumps(res['topicsFound'])
match.requirement_scores = json_dumps(res['requirement_scores'])
match.skill_subscore = res.get('skill_subscore', 0)
match.activity_subscore = res.get('activity_subscore', 0)
match.quality_subscore = res.get('quality_subscore', 0)
match.completeness_subscore = res.get('completeness_subscore', 0)
match.total_score = res['score']
match.evidence = json_dumps(res['reasons'])
match.updated_at = datetime.now(timezone.utc)
session.add(match)
session.commit()
recalc_job_stats(job_id)
st.success(f"✅ Discovery complete! Found {len(results)} candidates.")
st.rerun()
except Exception as e:
st.error(f"Error during discovery: {e}")
import traceback
st.code(traceback.format_exc())
# Get matches
matches = session.exec(
select(JobCandidateMatch, Candidate).where(
JobCandidateMatch.job_id == job_id
).join(Candidate).order_by(JobCandidateMatch.total_score.desc())
).all()
if not matches:
st.info("No candidates yet. Run discovery above!")
return
st.markdown("---")
st.subheader(f"📊 Dataset ({len(matches)} candidates)")
# Filters
st.markdown("### 🔧 Filters")
col1, col2 = st.columns(2)
with col1:
filter_must_have = st.checkbox("Has all must-haves", value=False)
with col2:
filter_active_90d = st.checkbox("Active in last 90 days", value=False)
# Build table data
table_data = []
for match, cand in matches:
langs_found = json_loads(match.langs_found, [])
topics_found = json_loads(match.topics_found, [])
portfolio = json_loads(cand.portfolio, [])
# Filter: must-haves
if filter_must_have:
must_haves = parsed.get('must_have', [])
if must_haves:
all_skills = set(langs_found + topics_found)
if not all(mh.lower() in all_skills for mh in must_haves):
continue
# Filter: active 90d
if filter_active_90d:
is_active = False
for repo in portfolio:
updated_str = repo.get('updated_at', '')
if updated_str:
try:
updated_date = datetime.fromisoformat(updated_str.replace('Z', '+00:00'))
days_ago = (datetime.now(timezone.utc) - updated_date).days
if days_ago <= 90:
is_active = True
break
except:
pass
if not is_active:
continue
table_data.append({
'match': match,
'cand': cand,
'login': cand.login,
'name': cand.name or '',
'location': cand.location or '',
'followers': cand.followers,
'stars': cand.total_stars,
'score': match.total_score,
'langs': ', '.join(langs_found),
'topics': ', '.join(topics_found),
'email': cand.email or '',
'linkedin': cand.linkedin_url or ''
})
st.markdown(f"Showing {len(table_data)} candidates")
# Top-N selector
st.markdown("### 🏆 Top N Candidates")
top_n = st.slider("Select Top N", 1, min(50, len(table_data)), min(10, len(table_data)))
top_candidates = table_data[:top_n]
for idx, item in enumerate(top_candidates, 1):
match = item['match']
cand = item['cand']
evidence = json_loads(match.evidence, [])
with st.container():
col1, col2, col3 = st.columns([1, 4, 2])
with col1:
st.markdown(f"### #{idx}")
score_color = "🟢" if match.total_score >= 70 else "🟡" if match.total_score >= 40 else "⚪"
st.markdown(f"{score_color} **{match.total_score:.1f}**")
with col2:
st.markdown(f"### [{cand.login}](https://github.com/{cand.login})")
if cand.name:
st.markdown(f"*{cand.name}*")
st.markdown(f"📍 {cand.location or 'N/A'}")
if evidence:
st.markdown(f"**Evidence:** {', '.join(evidence)}")
st.markdown(f"**Languages:** {item['langs']}")
st.markdown(f"**Topics:** {item['topics']}")
with col3:
st.metric("Followers", cand.followers)
st.metric("Stars", cand.total_stars)
# Invite button
if st.button(f"✉️ Invite", key=f"invite_{match.id}"):
# Create invitation
token = make_token(job.id, cand.id)
expires = datetime.now(timezone.utc) + timedelta(days=7)
invite = Invitation(
job_id=job.id,
candidate_id=cand.id,
token=token,
expires_at=expires
)
session.add(invite)
# Update match status
match.status = "INVITED"
match.updated_at = datetime.now(timezone.utc)
session.add(match)
session.commit()
# Show copyable link
invite_url = f"http://localhost:8501/?page=Candidate%20Portal&token={token}"
st.success("✅ Invitation created!")
st.code(invite_url, language="text")
st.caption("Copy this link and send it to the candidate")
st.markdown("---")
# Full dataset table
st.markdown("### 📋 Full Dataset Table")
if table_data:
df = pd.DataFrame([{
'Login': item['login'],
'Name': item['name'],
'Location': item['location'],
'Score': f"{item['score']:.1f}",
'Followers': item['followers'],
'Stars': item['stars'],
'Languages': item['langs'],
'Topics': item['topics']
} for item in table_data])
st.dataframe(df, use_container_width=True, hide_index=True)
# Export buttons
st.markdown("### 📥 Export")
col1, col2 = st.columns(2)
with col1:
if table_data:
csv_data = pd.DataFrame([{
'Login': item['login'],
'Name': item['name'],
'Location': item['location'],
'Score': item['score'],
'Followers': item['followers'],
'Stars': item['stars'],
'Languages': item['langs'],
'Topics': item['topics'],
'Email': item['email'],
'LinkedIn': item['linkedin']
} for item in table_data]).to_csv(index=False)
st.download_button(
"📥 Download CSV",
data=csv_data,
file_name=f"candidates_job_{job_id}.csv",
mime="text/csv"
)
with col2:
if table_data:
json_data = json_dumps([{
'login': item['login'],
'name': item['name'],
'location': item['location'],
'score': item['score'],
'followers': item['followers'],
'stars': item['stars'],
'languages': item['langs'],
'topics': item['topics'],
'email': item['email'],
'linkedin': item['linkedin']
} for item in table_data])
st.download_button(
"📥 Download JSON",
data=json_data,
file_name=f"candidates_job_{job_id}.json",
mime="application/json"
)
# ============================================================================
# PAGE: Tests (HR View)
# ============================================================================
def page_tests():
"""Tests page - HR view of assessment attempts."""
st.title("📝 Assessments (HR View)")
with Session(engine) as session:
jobs = session.exec(select(JobPosting).where(JobPosting.is_active == True)).all()
if not jobs:
st.info("No active jobs yet!")
return
# Job selector
job_options = {f"{j.id}: {j.title}": j.id for j in jobs}
selected_label = st.selectbox("Select Job", options=list(job_options.keys()))
job_id = job_options[selected_label]
# Get attempts for this job
attempts = session.exec(
select(AssessmentAttempt, Candidate).where(
AssessmentAttempt.job_id == job_id
).join(Candidate).order_by(AssessmentAttempt.created_at.desc())
).all()
if not attempts:
st.info("No test attempts for this job yet.")
return
st.subheader(f"📊 Assessment Attempts ({len(attempts)})")
# Build table
table_data = []
for attempt, cand in attempts:
table_data.append({
'Login': cand.login,
'Name': cand.name or '',
'Score': f"{attempt.soft_score:.1f}/10",
'Duration (sec)': attempt.duration_sec,
'Tab Switches': attempt.max_tab_switches,
'Copy/Paste': attempt.copy_paste_count,
'Finished': "✅" if attempt.finished_at else "⏳",
'Started': attempt.started_at.strftime("%Y-%m-%d %H:%M") if attempt.started_at else ""
})
df = pd.DataFrame(table_data)
st.dataframe(df, use_container_width=True, hide_index=True)
# ============================================================================
# PAGE: Candidate Portal
# ============================================================================
def page_candidate_portal():
"""Candidate portal - accessed via invite token."""
st.title("🎯 Candidate Portal")
# Check for token in query params
query_params = st.query_params
token = query_params.get("token", None)
if not token:
st.warning("⚠️ No invitation token provided.")
st.info("This page is accessed via an invitation link sent by the recruiter.")
return
# Parse token
job_id, candidate_id = parse_token(token)
if not job_id or not candidate_id:
st.error("❌ Invalid invitation token.")
return
with Session(engine) as session:
# Validate invitation
invite = session.exec(
select(Invitation).where(Invitation.token == token)
).first()
if not invite:
st.error("❌ Invitation not found.")
return
# Check expiration (handle both naive and aware datetimes)
now = datetime.now(timezone.utc)
expires_at = invite.expires_at
# If expires_at is naive, make it aware (assume UTC)
if expires_at.tzinfo is None:
expires_at = expires_at.replace(tzinfo=timezone.utc)
if expires_at < now:
st.error("❌ This invitation has expired.")
return
# Load job and candidate
job = session.get(JobPosting, job_id)
cand = session.get(Candidate, candidate_id)
if not job or not cand:
st.error("❌ Job or candidate not found.")
return
parsed = json_loads(job.parsed_description, {})
st.success(f"✅ Welcome! Invitation for: **{job.title}** in **{job.city}**")
st.markdown("---")
# Profile section
st.subheader("👤 Your Profile")
with st.form("profile_form"):
name = st.text_input("Full Name", value=cand.name or "")
email = st.text_input("Email", value=cand.email or "")
linkedin = st.text_input("LinkedIn URL", value=cand.linkedin_url or "")
years_exp = st.number_input("Years of Experience", min_value=0, max_value=50, value=cand.years_experience or 0)
if st.form_submit_button("💾 Save Profile"):
cand.name = name
cand.email = email
cand.linkedin_url = linkedin
cand.years_experience = years_exp
cand.updated_at = datetime.now(timezone.utc)
session.add(cand)
session.commit()
# Mark invitation as used if first time
if not invite.used_at:
invite.used_at = datetime.now(timezone.utc)
session.add(invite)
session.commit()
# Update match status to APPLIED
match = session.exec(
select(JobCandidateMatch).where(
JobCandidateMatch.job_id == job_id,
JobCandidateMatch.candidate_id == candidate_id
)
).first()
if match and match.status == "INVITED":
match.status = "APPLIED"
match.updated_at = datetime.now(timezone.utc)
session.add(match)
session.commit()
st.success("✅ Profile saved!")
st.rerun()
st.markdown("---")
# Assessments
st.subheader("📝 Assessment")
st.info("Complete the soft skills assessment to finish your application.")
st.markdown("### 🤝 Soft Skills Test")
st.markdown("**Duration:** 7 minutes")
st.markdown("**Questions:** 5 multiple choice")
# Check if already taken
soft_attempt = session.exec(
select(AssessmentAttempt).where(
AssessmentAttempt.job_id == job_id,
AssessmentAttempt.candidate_id == candidate_id,
AssessmentAttempt.kind == "SOFT",
AssessmentAttempt.finished_at.isnot(None)
)
).first()
if soft_attempt:
st.success(f"✅ Completed - Score: {soft_attempt.soft_score:.1f}/10")
st.markdown(f"**Duration:** {soft_attempt.duration_sec} seconds")
if soft_attempt.max_tab_switches > 0 or soft_attempt.copy_paste_count > 0:
st.warning(f"⚠️ Tab switches: {soft_attempt.max_tab_switches}, Copy/Paste attempts: {soft_attempt.copy_paste_count}")
else:
if st.button("▶️ Start Soft Skills Test", type="primary"):
st.session_state['active_test'] = 'SOFT'
st.session_state['test_start_time'] = datetime.now(timezone.utc)
st.session_state['tab_switches'] = 0
st.session_state['copy_paste_count'] = 0
st.rerun()
# Run active test
if 'active_test' in st.session_state:
run_assessment(job_id, candidate_id, st.session_state['active_test'], session)
def run_assessment(job_id: int, candidate_id: int, kind: str, session):
"""Run an assessment test with timer and anti-cheat."""
st.markdown("---")
st.subheader(f"📝 Soft Skills Assessment")
# Get template
template = session.exec(
select(AssessmentTemplate).where(AssessmentTemplate.kind == kind)
).first()
if not template:
st.error("Assessment template not found")
return
questions = json_loads(template.questions, [])
duration_minutes = 7 # Only soft skills, always 7 minutes
# Timer
start_time = st.session_state.get('test_start_time')
if not start_time:
st.session_state['test_start_time'] = datetime.now(timezone.utc)
start_time = st.session_state['test_start_time']
elapsed = (datetime.now(timezone.utc) - start_time).total_seconds()
remaining = (duration_minutes * 60) - elapsed
if remaining <= 0:
st.error("⏰ Time's up! Auto-submitting...")
# Auto-submit with current answers
submit_assessment(job_id, candidate_id, kind, st.session_state.get('test_answers', {}), session)
return
# Display timer
mins = int(remaining // 60)
secs = int(remaining % 60)
st.warning(f"⏱️ Time Remaining: {mins:02d}:{secs:02d}")
# Anti-cheat display
col1, col2 = st.columns(2)
col1.metric("Tab Switches", st.session_state.get('tab_switches', 0))
col2.metric("Copy/Paste Attempts", st.session_state.get('copy_paste_count', 0))
# Lightweight anti-cheat JS
anti_cheat_js = """
<script>
let tabSwitches = 0;
let copyPasteCount = 0;
document.addEventListener('visibilitychange', function() {
if (document.hidden) {
tabSwitches++;
console.log('Tab switch detected:', tabSwitches);
}
});
document.addEventListener('copy', function(e) {
e.preventDefault();
copyPasteCount++;
console.log('Copy blocked:', copyPasteCount);
});
document.addEventListener('paste', function(e) {
e.preventDefault();
copyPasteCount++;
console.log('Paste blocked:', copyPasteCount);
});
</script>
"""
components.html(anti_cheat_js, height=0)
# Questions
st.markdown("### Questions")
if 'test_answers' not in st.session_state:
st.session_state['test_answers'] = {}
for i, q in enumerate(questions):
st.markdown(f"**Q{i+1}.** {q['prompt']}")
answer = st.radio(
f"Select your answer for Q{i+1}",
options=q['choices'],
key=f"q_{q['id']}",
label_visibility="collapsed"
)
st.session_state['test_answers'][q['id']] = q['choices'].index(answer)
st.markdown("---")
# Submit button
if st.button("✅ Submit Assessment", type="primary"):
submit_assessment(job_id, candidate_id, kind, st.session_state['test_answers'], session)
def submit_assessment(job_id: int, candidate_id: int, kind: str, answers: dict, session):
"""Submit and score assessment."""
# Get template
template = session.exec(
select(AssessmentTemplate).where(AssessmentTemplate.kind == kind)
).first()
if not template:
st.error("Template not found")
return
questions = json_loads(template.questions, [])
# Score answers (only soft skills)
correct = 0
for q in questions:
user_answer = answers.get(q['id'])
if user_answer == q['answer']:
correct += 1
# Calculate score (5 questions, 2 points each, max 10)
soft_score = min(correct * 2, 10)
# Apply anti-cheat penalties
tab_switches = st.session_state.get('tab_switches', 0)
copy_paste = st.session_state.get('copy_paste_count', 0)
penalty = 0
cheating_flags = []
if tab_switches > 2:
penalty += 1
cheating_flags.append(f"Excessive tab switches: {tab_switches}")
if copy_paste > 0:
penalty += 1
cheating_flags.append(f"Copy/paste attempts: {copy_paste}")
soft_score = max(0, soft_score - penalty)
# Calculate duration
start_time = st.session_state.get('test_start_time')
duration_sec = int((datetime.now(timezone.utc) - start_time).total_seconds())
# Save attempt
attempt = AssessmentAttempt(
job_id=job_id,
candidate_id=candidate_id,
kind=kind,
answers=json_dumps(answers),
soft_score=soft_score,
tech_score=0, # Not used anymore
started_at=start_time,
finished_at=datetime.now(timezone.utc),
duration_sec=duration_sec,
cheating_flags=json_dumps(cheating_flags),
max_tab_switches=tab_switches,
copy_paste_count=copy_paste
)
session.add(attempt)
session.commit()
# Update match status
match = session.exec(
select(JobCandidateMatch).where(
JobCandidateMatch.job_id == job_id,
JobCandidateMatch.candidate_id == candidate_id
)
).first()
if match:
match.status = "TESTED"
match.updated_at = datetime.now(timezone.utc)
session.add(match)
session.commit()
# Recalc job stats
recalc_job_stats(job_id)
# Clear test state
for key in ['active_test', 'test_start_time', 'test_answers', 'tab_switches', 'copy_paste_count']:
if key in st.session_state:
del st.session_state[key]
st.success(f"✅ Assessment submitted! Score: {soft_score:.1f}/10")
st.balloons()
st.rerun()
# ============================================================================
# MAIN ENTRY POINT
# ============================================================================
if __name__ == "__main__":
main()