# File: app.py import pandas as pd import datasets from sentence_transformers import SentenceTransformer, util import torch import re import nltk from nltk.corpus import words, stopwords import urllib.parse as _url from transformers import AutoModelForCausalLM, AutoTokenizer, pipeline import gradio as gr import spacy from spacy.matcher import Matcher import json from collections import Counter # --- Download NLTK data --- for package in ['words', 'stopwords', 'averaged_perceptron_tagger', 'punkt']: try: if package in ['words', 'stopwords']: nltk.data.find(f'corpora/{package}') elif package == 'averaged_perceptron_tagger': nltk.data.find(f'taggers/{package}') else: nltk.data.find(f'tokenizers/{package}') except LookupError: nltk.download(package) STOPWORDS = set(stopwords.words('english')) # --- GLOBAL STATE & DATA --- original_df, combined_df, model = None, None, None combined_job_embeddings, original_job_title_embeddings = None, None LLM_PIPELINE = None LLM_MODEL_NAME = "microsoft/phi-2" FINETUNED_MODEL_ID = "its-zion-18/projfinetuned" KNOWN_WORDS = set() AI_VALIDATED_SKILLS = set() # --- Initialize spaCy --- print("--- Initializing spaCy ---") try: nlp = spacy.load("en_core_web_sm") skill_patterns = [ [{"POS": "PROPN"}], [{"POS": "NOUN"}, {"POS": "NOUN"}], [{"POS": "ADJ"}, {"POS": "NOUN"}], [{"POS": "PROPN"}, {"POS": "NOUN"}] ] matcher = Matcher(nlp.vocab) matcher.add("SKILL", skill_patterns) print("--- spaCy Initialized Successfully ---") except Exception as e: print(f"🚨 ERROR initializing spaCy: {e}") nlp, matcher = None, None # --- CORE NLP & HELPER FUNCTIONS --- def _norm_skill_token(s: str) -> str: s = s.lower().strip() s = re.sub(r'[\(\)\[\]\{\}\*]', '', s) s = re.sub(r'^\W+|\W+$', '', s) s = re.sub(r'\s+', ' ', s) return s def _skill_match(token1: str, token2: str) -> bool: t1, t2 = _norm_skill_token(token1), _norm_skill_token(token2) return t1 == t2 or t1 in t2 or t2 in t1 def build_known_vocabulary(df: pd.DataFrame): global KNOWN_WORDS english_words = set(w.lower() for w in words.words()) job_words = set(re.findall(r'\b\w+\b', " ".join(df['full_text'].astype(str).tolist()).lower())) KNOWN_WORDS = english_words | {w for w in job_words if w.isalpha() and len(w) > 2} return "Known vocabulary built." def check_spelling_in_query(query: str) -> list[str]: words_in_query = query.lower().split() return list({w for w in words_in_query if w.isalpha() and len(w) > 1 and w not in KNOWN_WORDS}) def initialize_llm_client(): global LLM_PIPELINE try: tokenizer = AutoTokenizer.from_pretrained(LLM_MODEL_NAME, trust_remote_code=True) model_llm = AutoModelForCausalLM.from_pretrained(LLM_MODEL_NAME, torch_dtype="auto", device_map="auto", trust_remote_code=True) LLM_PIPELINE = pipeline("text-generation", model=model_llm, tokenizer=tokenizer, max_new_tokens=100, do_sample=True, temperature=0.7) return True except Exception as e: print(f"🚨 ERROR initializing local LLM: {e}") return False def llm_expand_query(user_input: str) -> str: if not LLM_PIPELINE: return user_input prompt = f"User's career interest: '{user_input}'\nInstruction: Based on the user's interest, write a concise, single-sentence summary (40-60 words) that elaborates on the core intent, typical skills, and responsibilities. Do not include a preamble, the user input, or any list formatting in the output. Just the expanded sentence.\nExpanded Intent:" try: response = LLM_PIPELINE(prompt, max_new_tokens=100, do_sample=True, temperature=0.6) expanded_query = response[0]['generated_text'].strip().split("Expanded Intent:")[-1].strip() final_query = user_input + ". " + expanded_query.replace('\n', ' ').replace(':', '').strip() return final_query.replace('..', '.').strip() except Exception: return user_input def extract_fallback_keywords(text: str, user_skills: list[str], top_n=7) -> list[str]: """Smarter fallback that prioritizes keywords semantically similar to the user's input.""" if not isinstance(text, str) or not nlp: return [] junk_words = STOPWORDS.union({ 'experience', 'ability', 'knowledge', 'skill', 'skills', 'degree', 'education', 'work', 'year', 'years', 'job', 'role', 'team', 'company', 'duties', 'responsibilities', 'requirements', 'qualifications', 'description', 'position', 'opportunity', 'candidate', 'application', 'applications', 'university', 'college', 'school', 'department', 'program', 'field', 'service', 'level' }) doc = nlp(text.lower()) candidates = set() for ent in doc.ents: if ent.label_ in ['GPE', 'ORG', 'DATE', 'PERSON', 'MONEY', 'CARDINAL', 'TIME']: junk_words.add(ent.text) for chunk in doc.noun_chunks: chunk_text = chunk.text.strip() if len(chunk_text) > 3 and not any(junk in chunk_text.split() for junk in junk_words) and not chunk_text.isnumeric(): candidates.add(chunk_text) if not candidates: return [] candidates = list(candidates) if user_skills and model: user_skills_embedding = model.encode(user_skills, convert_to_tensor=True) candidate_embeddings = model.encode(candidates, convert_to_tensor=True) cos_scores = util.cos_sim(candidate_embeddings, user_skills_embedding) top_scores, _ = torch.max(cos_scores, dim=1) scored_candidates = sorted(zip(candidates, top_scores.tolist()), key=lambda x: x[1], reverse=True) return [candidate for candidate, score in scored_candidates if score > 0.2][:top_n] return sorted(candidates)[:top_n] def get_skills_from_text(row: pd.Series, user_skills: list[str]) -> list[str]: """Primary skill extraction: uses AI-validated list first, then a smart fallback.""" full_text = " ".join([str(row.get(col, '')) for col in ['qualifications', 'Duties', 'Description']]) if not full_text.strip(): return [] if nlp and matcher: doc = nlp(full_text.lower()) matches = matcher(doc) skills = {doc[start:end].text.strip() for _, start, end in matches} validated_skills = sorted([s for s in skills if s in AI_VALIDATED_SKILLS]) if validated_skills: return validated_skills return extract_fallback_keywords(full_text, user_skills) def initialize_data_and_model(): global original_df, combined_df, model, combined_job_embeddings, original_job_title_embeddings, AI_VALIDATED_SKILLS if not initialize_llm_client(): print("Warning: LLM Client failed to initialize.") print("--- Loading pre-computed skills from validated_skills.json ---") try: with open("validated_skills.json", "r") as f: AI_VALIDATED_SKILLS = set(json.load(f)) print(f"--- Loaded {len(AI_VALIDATED_SKILLS)} AI-validated skills ---") except FileNotFoundError: print("🚨 WARNING: validated_skills.json not found. Skill extraction will rely on fallback method.") AI_VALIDATED_SKILLS = set() print("--- Loading Datasets ---") ds = datasets.load_dataset("its-zion-18/Jobs-tabular-dataset") original_df = ds["original"].to_pandas() augmented_df = ds["augmented"].to_pandas() print("--- Mapping skills to each job description (initial pass) ---") original_df['Skills'] = original_df.apply(lambda row: get_skills_from_text(row, user_skills=[]), axis=1) original_df['job_id'] = original_df.index max_id = len(original_df) - 1 augmented_df['job_id'] = augmented_df.index.map(lambda i: min(i // 20, max_id)) def create_full_text(row): return " ".join([str(s) for s in [row.get("Job title"), row.get("Company"), row.get("Duties"), row.get("qualifications"), row.get("Description")]]) original_df["full_text"] = original_df.apply(create_full_text, axis=1) augmented_df["full_text"] = augmented_df.apply(create_full_text, axis=1) combined_df = pd.concat([original_df.copy(), augmented_df.copy()], ignore_index=True) original_df = original_df.rename(columns={'Job title': 'job_title', 'Company': 'company'}) print("--- Loading Fine-Tuned Sentence Transformer Model ---") model = SentenceTransformer(FINETUNED_MODEL_ID) print("--- Encoding Embeddings ---") combined_job_embeddings = model.encode(combined_df["full_text"].tolist(), convert_to_tensor=True, show_progress_bar=True) original_job_title_embeddings = model.encode(original_df["job_title"].tolist(), convert_to_tensor=True, show_progress_bar=True) build_known_vocabulary(combined_df) return "--- Initialization Complete ---" def find_job_matches(original_user_query: str, expanded_user_query: str, top_k: int = 50) -> pd.DataFrame: expanded_user_embedding = model.encode(expanded_user_query, convert_to_tensor=True) general_similarity_scores = util.cos_sim(expanded_user_embedding, combined_job_embeddings)[0] top_indices = torch.topk(general_similarity_scores, k=len(combined_df)) sorted_combined_df = combined_df.iloc[top_indices.indices.cpu()].copy() sorted_combined_df['general_score'] = top_indices.values.cpu().numpy() unique_matches = sorted_combined_df.drop_duplicates(subset=['job_id'], keep='first').set_index('job_id') original_user_embedding = model.encode(original_user_query, convert_to_tensor=True) title_boost_scores = util.cos_sim(original_user_embedding, original_job_title_embeddings)[0].cpu().numpy() title_boost_map = pd.Series(title_boost_scores, index=original_df['job_id']) unique_matches['title_boost_score'] = unique_matches.index.map(title_boost_map).fillna(0) unique_matches['Similarity Score'] = (0.70 * unique_matches['general_score'] + 0.30 * unique_matches['title_boost_score']) final_job_ids = unique_matches.sort_values(by='Similarity Score', ascending=False).head(top_k).index.tolist() final_results_df = original_df[original_df['job_id'].isin(final_job_ids)].copy() scores_df = unique_matches.reset_index()[['job_id', 'Similarity Score']].copy() final_results_df = pd.merge(final_results_df, scores_df, on='job_id', how='left') return final_results_df.sort_values(by='Similarity Score', ascending=False).reset_index(drop=True).set_index('job_id', drop=False).rename(columns={'job_id': 'Job ID'}) def score_jobs_by_skills(user_tokens: list[str], df_to_rank: pd.DataFrame) -> pd.DataFrame: if df_to_rank is None or df_to_rank.empty: return pd.DataFrame() ranked_df = df_to_rank.copy() # Re-extract skills for the ranked DF using the user's context for better fallback results ranked_df['Skills'] = ranked_df.apply(lambda row: get_skills_from_text(row, user_skills=user_tokens), axis=1) if 'Skills' not in ranked_df.columns: return ranked_df.sort_values(by='Similarity Score', ascending=False) def calculate_match(row, user_tokens): job_skills = row.get('Skills', []) if not isinstance(job_skills, list): return [], 0, 0.0 matched_skills = [s for s in job_skills if any(_skill_match(ut, s) for ut in user_tokens)] return matched_skills, len(matched_skills), len(matched_skills) / len(job_skills) if job_skills else 0.0 results = ranked_df.apply(lambda row: calculate_match(row, user_tokens), axis=1, result_type='expand') ranked_df[['Skill Matches', 'Skill Match Count', 'Skill Match Score']] = results return ranked_df.sort_values(by=['Skill Match Score', 'Similarity Score'], ascending=[False, False]).reset_index(drop=True).set_index('Job ID', drop=False).rename_axis(None) def _course_links_for(skill: str) -> str: q = _url.quote(skill) links = [("Coursera", f"https://www.coursera.org/search?query={q}"), ("edX", f"https://www.edx.org/search?q={q}"), ("Udemy", f"https://www.udemy.com/courses/search/?q={q}"), ("YouTube", f"https://www.youtube.com/results?search_query={q}+tutorial")] return " • ".join([f'{name}' for name, u in links]) # --- GRADIO INTERFACE FUNCTIONS --- def get_job_matches(dream_job: str, top_n: int, skills_text: str): expanded_desc = llm_expand_query(dream_job) emb_matches = find_job_matches(dream_job, expanded_desc, top_k=50) user_skills = [_norm_skill_token(s) for s in skills_text.split(',') if _norm_skill_token(s)] display_df = score_jobs_by_skills(user_skills, emb_matches) if user_skills else emb_matches display_df = display_df.head(top_n) status = f"Found and **re-ranked** results by your {len(user_skills)} skills." if user_skills else f"Found {len(display_df)} top matches." table_to_show = display_df[['job_title', 'company', 'Similarity Score', 'Skill Match Score']] if 'Skill Match Score' in display_df.columns else display_df[['job_title', 'company', 'Similarity Score']] options = [(f"{i+1}. {row['job_title']} - {row['company']}", row.name) for i, row in display_df.iterrows()] return status, emb_matches, table_to_show, gr.Dropdown(choices=options, value=options[0][1] if options else None, visible=True), gr.Accordion(visible=True) def rerank_current_results(initial_matches_df, skills_text, top_n): if initial_matches_df is None or pd.DataFrame(initial_matches_df).empty: return "Please find matches first.", pd.DataFrame(), gr.Dropdown(visible=False) initial_matches_df = pd.DataFrame(initial_matches_df) user_skills = [_norm_skill_token(s) for s in skills_text.split(',') if _norm_skill_token(s)] if not user_skills: display_df = initial_matches_df.head(top_n) table_to_show = display_df[['job_title', 'company', 'Similarity Score']] status = "Skills cleared. Showing original results." else: ranked_df = score_jobs_by_skills(user_skills, initial_matches_df) display_df = ranked_df.head(top_n) table_to_show = display_df[['job_title', 'company', 'Similarity Score', 'Skill Match Score']] status = f"Results **re-ranked** based on {len(user_skills)} skills." options = [(f"{i+1}. {row['job_title']} - {row['company']}", row.name) for i, row in display_df.iterrows()] return status, table_to_show, gr.Dropdown(choices=options, value=options[0][1] if options else None, visible=True) def find_matches_and_rank_with_check(dream_job, top_n, skills_text): if not dream_job: return "Please describe your dream job first.", None, pd.DataFrame(), gr.Dropdown(visible=False), gr.Accordion(visible=False), gr.Markdown(), gr.Row(visible=False) unrecognized = check_spelling_in_query(dream_job) if unrecognized: word_list_html = ", ".join(f"{w}" for w in unrecognized) alert = f"⚠️ Possible Spelling Error: Unrecognized: {word_list_html}." return "Status: Awaiting confirmation.", None, pd.DataFrame(), gr.Dropdown(visible=False), gr.Accordion(visible=False), gr.Markdown(alert, visible=True), gr.Row(visible=True) status, matches, table, dropdown, accordion = get_job_matches(dream_job, top_n, skills_text) return status, matches, table, dropdown, accordion, gr.Markdown(visible=False), gr.Row(visible=False) def find_matches_and_rank_anyway(dream_job, top_n, skills_text): status, matches, table, dropdown, accordion = get_job_matches(dream_job, top_n, skills_text) return status, matches, table, dropdown, accordion, gr.Markdown(visible=False), gr.Row(visible=False) def on_select_job(job_id, skills_text): if job_id is None: return "", "", "", "", "", gr.Accordion(visible=False), [], 0, gr.Button(visible=False) row = original_df.loc[job_id] details = f"### {row.get('job_title', '')} — {row.get('company', '')}" user_skills = [_norm_skill_token(s) for s in skills_text.split(',') if _norm_skill_token(s)] # Re-run skill extraction with user context to ensure the learning plan is relevant job_skills = get_skills_from_text(row, user_skills) if not job_skills: plan = "

No specific skills were extracted for this job.

" return details, row.get('Duties', ''), row.get('qualifications', ''), row.get('Description', ''), plan, gr.Accordion(visible=True), [], 0, gr.Button(visible=False) missing = sorted([s for s in job_skills if not any(_skill_match(ut, s) for ut in user_skills)], key=str.lower) if not missing: plan = "

🎉 You have all the required skills!

" return details, row.get('Duties', ''), row.get('qualifications', ''), row.get('Description', ''), plan, gr.Accordion(visible=True), [], 0, gr.Button(visible=False) if user_skills: score = (len(job_skills) - len(missing)) / len(job_skills) if job_skills else 0 details += f"\n**Your skill match:** {score:.1%}" headline = "Great fit!" if score >= 0.8 else "Good progress!" if score >= 0.5 else "Solid starting point." plan = f"

{headline} Focus on these skills to improve your match:

" skills_to_show = missing[:5] items = [f"
  • {s}
    • Learn: {_course_links_for(s)}
  • " for s in skills_to_show] plan += f"" return details, row.get('Duties', ''), row.get('qualifications', ''), row.get('Description', ''), plan, gr.Accordion(visible=True), [], 0, gr.Button(visible=False) else: headline = "

    To be a good fit for this role, you'll need to learn these skills:

    " skills_to_show = missing[:5] items = [f"
  • {s}
    • Learn: {_course_links_for(s)}
  • " for s in skills_to_show] plan = f"{headline}" offset = len(skills_to_show) show_btn = len(missing) > 5 return details, row.get('Duties', ''), row.get('qualifications', ''), row.get('Description', ''), plan, gr.Accordion(visible=True), missing, offset, gr.Button(visible=show_btn) def load_more_skills(full_list, offset): new_offset = offset + 5 skills_to_show = full_list[:new_offset] items = [f"
  • {s}
    • Learn: {_course_links_for(s)}
  • " for s in skills_to_show] plan = f"

    To be a good fit for this role, you'll need to learn these skills:

    " show_btn = new_offset < len(full_list) return plan, new_offset, gr.Button(visible=show_btn) def on_reset(): return ("", 3, "", pd.DataFrame(), None, gr.Dropdown(visible=False), gr.Accordion(visible=False), "Status: Ready.", "", "", "", "", gr.Markdown(visible=False), gr.Row(visible=False), [], 0, gr.Button(visible=False)) # --- Run Initialization --- print("Starting application initialization...") initialization_status = initialize_data_and_model() print(initialization_status) # --- Gradio Interface Definition --- with gr.Blocks(theme=gr.themes.Soft()) as ui: gr.Markdown("# Hybrid Career Planner & Skill Gap Analyzer") initial_matches_state, missing_skills_state, skills_offset_state = gr.State(), gr.State([]), gr.State(0) with gr.Row(): with gr.Column(scale=3): dream_text = gr.Textbox(label='Your Dream Job Description', lines=3, placeholder="e.g., 'A role in a tech startup focused on machine learning...'") with gr.Accordion("Optional: Add Your Skills to Re-rank Results", open=False): with gr.Row(): skills_text = gr.Textbox(label='Your Skills (comma-separated)', placeholder="e.g., Python, data analysis", scale=3) rerank_btn = gr.Button("Re-rank", variant="secondary", scale=1) with gr.Column(scale=1): topk_slider = gr.Slider(minimum=1, maximum=5, value=3, step=1, label="Number of Matches") search_btn = gr.Button("Find Matches", variant="primary") reset_btn = gr.Button("Reset All") status_text = gr.Markdown("Status: Ready.") spelling_alert = gr.Markdown(visible=False) with gr.Row(visible=False) as spelling_row: search_anyway_btn, retype_btn = gr.Button("Search Anyway", variant="secondary"), gr.Button("Let Me Fix It", variant="stop") df_output = gr.DataFrame(label="Job Matches", interactive=False) job_selector = gr.Dropdown(label="Select a job to see more details & learning plan:", visible=False) with gr.Accordion("Job Details & Learning Plan", open=False, visible=False) as details_accordion: job_details_markdown = gr.Markdown() with gr.Tabs(): with gr.TabItem("Duties"): duties_markdown = gr.Markdown() with gr.TabItem("Qualifications"): qualifications_markdown = gr.Markdown() with gr.TabItem("Full Description"): description_markdown = gr.Markdown() learning_plan_output = gr.HTML(label="Learning Plan") load_more_btn = gr.Button("Load More Skills", visible=False) # --- Event Handlers --- search_btn.click(fn=find_matches_and_rank_with_check, inputs=[dream_text, topk_slider, skills_text], outputs=[status_text, initial_matches_state, df_output, job_selector, details_accordion, spelling_alert, spelling_row]) search_anyway_btn.click(fn=find_matches_and_rank_anyway, inputs=[dream_text, topk_slider, skills_text], outputs=[status_text, initial_matches_state, df_output, job_selector, details_accordion, spelling_alert, spelling_row]) retype_btn.click(lambda: ("Status: Ready for you to retype.", None, pd.DataFrame(), gr.Dropdown(visible=False), gr.Accordion(visible=False), gr.Markdown(visible=False), gr.Row(visible=False)), outputs=[status_text, initial_matches_state, df_output, job_selector, details_accordion, spelling_alert, spelling_row]) reset_btn.click(fn=on_reset, outputs=[dream_text, topk_slider, skills_text, df_output, initial_matches_state, job_selector, details_accordion, status_text, job_details_markdown, duties_markdown, qualifications_markdown, description_markdown, spelling_alert, spelling_row, missing_skills_state, skills_offset_state, load_more_btn], queue=False) rerank_btn.click(fn=rerank_current_results, inputs=[initial_matches_state, skills_text, topk_slider], outputs=[status_text, df_output, job_selector]) job_selector.change(fn=on_select_job, inputs=[job_selector, skills_text], outputs=[job_details_markdown, duties_markdown, qualifications_markdown, description_markdown, learning_plan_output, details_accordion, missing_skills_state, skills_offset_state, load_more_btn]) load_more_btn.click(fn=load_more_skills, inputs=[missing_skills_state, skills_offset_state], outputs=[learning_plan_output, skills_offset_state, load_more_btn]) # Only launch the UI if the initialization was successful if __name__ == '__main__': if initialization_status == "--- Initialization Complete ---": ui.launch() else: print("Gradio UI will not launch due to initialization failure.")