import os import docx from docxtpl import DocxTemplate from typing import TypedDict, List from pydantic import BaseModel, Field from langchain_google_genai import ChatGoogleGenerativeAI from langgraph.graph import StateGraph, START, END import logging logger = logging.getLogger(__name__) # --- Define State & Schemas --- class ResumeState(TypedDict): original_resume: str job_description: str resume_skills: List[str] jd_skills: List[str] matched_skills: List[str] tailored_resume: dict class SkillList(BaseModel): skills: List[str] = Field(description="A list of technical skills, languages, frameworks, and databases") class ExperienceItem(BaseModel): job_title: str = Field(description="The job title") company: str = Field(description="The company name") duration: str = Field(description="The time period worked") bullets: List[str] = Field(description="3-5 bullet points emphasizing the matched skills") class EducationItem(BaseModel): degree: str = Field(description="The name of the degree or certification") institution: str = Field(description="The name of the university or institution") graduation_date: str = Field(description="The graduation year or timeframe") class ATSResumeOutput(BaseModel): name: str = Field(description="The candidate's full name") contact_info: str = Field(description="Email, phone, and links (LinkedIn, GitHub)") summary: str = Field(description="A professional summary emphasizing matched skills") skills: List[str] = Field(description="The optimized list of relevant skills") experience: List[ExperienceItem] = Field(description="The candidate's work history") education: List[EducationItem] = Field(description="The candidate's educational background") class AILangGraphTailor: def __init__(self): # Initialize Gemini 2.5 Flash # Assumes GOOGLE_API_KEY is loaded in the environment self.llm = ChatGoogleGenerativeAI(model="gemini-2.5-flash", temperature=0.2) self.app = self._build_graph() def extract_text_from_docx(self, file_path: str) -> str: """Reads a .docx file and returns all text as a single string.""" try: doc = docx.Document(file_path) full_text = [para.text for para in doc.paragraphs if para.text.strip()] return "\n".join(full_text) except Exception as e: logger.error(f"Error reading document: {e}") raise def generate_final_docx(self, tailored_dict: dict, template_path: str, output_path: str): """Takes the JSON dictionary from Gemini and renders the final Word document using docxtpl.""" try: doc = DocxTemplate(template_path) doc.render(tailored_dict) doc.save(output_path) logger.info(f"Success! Tailored document saved to {output_path}.") except Exception as e: logger.error(f"Error rendering docxtpl: {e}") raise # --- Node Functions --- def extract_resume_skills(self, state: ResumeState): structured_llm = self.llm.with_structured_output(SkillList) prompt = f"Extract all technical skills from this resume. Return only the skills.\n\nResume:\n{state['original_resume']}" return {"resume_skills": structured_llm.invoke(prompt).skills} def extract_jd_skills(self, state: ResumeState): structured_llm = self.llm.with_structured_output(SkillList) prompt = f"Extract all required technical skills from this job description. Return only the skills.\n\nJob Description:\n{state['job_description']}" return {"jd_skills": structured_llm.invoke(prompt).skills} def match_skills(self, state: ResumeState): resume_set = {s.strip().lower() for s in state['resume_skills']} jd_set = {s.strip().lower() for s in state['jd_skills']} matches = list(resume_set.intersection(jd_set)) return {"matched_skills": matches} def tailor_resume(self, state: ResumeState): matched_skills_str = ", ".join(state['matched_skills']) structured_llm = self.llm.with_structured_output(ATSResumeOutput) prompt = f""" You are a professional resume writer. Your task is to rewrite the provided 'Original Resume' to strongly emphasize these 'Key Skills to Emphasize': {matched_skills_str} RULES: 1. Rephrase the summary and experience bullet points to highlight the key skills, and do not use buzzwords while writing any Summary and experience. Keep everything simple. 2. You MUST NOT add any new skills, projects, experiences, or education that are not in the Original Resume. 3. Ensure the output is a professional, complete resume. Original Resume: --- {state['original_resume']} --- """ tailored_data = structured_llm.invoke(prompt) return {"tailored_resume": tailored_data.model_dump()} def _build_graph(self): workflow = StateGraph(ResumeState) workflow.add_node("extract_resume", self.extract_resume_skills) workflow.add_node("extract_jd", self.extract_jd_skills) workflow.add_node("match_skills", self.match_skills) workflow.add_node("tailor_resume", self.tailor_resume) workflow.add_edge(START, "extract_resume") workflow.add_edge("extract_resume", "extract_jd") workflow.add_edge("extract_jd", "match_skills") workflow.add_edge("match_skills", "tailor_resume") workflow.add_edge("tailor_resume", END) return workflow.compile() def run_pipeline(self, original_resume_text: str, job_description: str) -> dict: """Executes the LangGraph pipeline.""" initial_state = { "original_resume": original_resume_text, "job_description": job_description } return self.app.invoke(initial_state)