Gaykar commited on
Commit
586b608
·
1 Parent(s): 7ad07ce
app/graph.py CHANGED
@@ -2,15 +2,21 @@ from app.state.state import OnboardingState
2
  from app.nodes.graphnodes import *
3
  from langgraph.prebuilt import ToolNode ,tools_condition
4
  from langgraph.graph import StateGraph,END,START
 
5
 
6
 
 
 
 
 
 
7
 
8
  builder = StateGraph(OnboardingState)
9
 
10
  # Define Nodes
11
  builder.add_node("input_node", input_node)
12
  builder.add_node("resume_data_extraction", extractResumeDataNode)
13
- builder.add_node("jd_data_extraction", extractJDDataNode)
14
  builder.add_node("skill_gap_analysis", skill_gap_node)
15
 
16
  # The ReAct Agent Node
 
2
  from app.nodes.graphnodes import *
3
  from langgraph.prebuilt import ToolNode ,tools_condition
4
  from langgraph.graph import StateGraph,END,START
5
+ from langgraph.types import RetryPolicy
6
 
7
 
8
+ node_retry = RetryPolicy(
9
+ max_attempts=3,
10
+ initial_interval=1.5,
11
+ retry_on=[ConnectionError]
12
+ )
13
 
14
  builder = StateGraph(OnboardingState)
15
 
16
  # Define Nodes
17
  builder.add_node("input_node", input_node)
18
  builder.add_node("resume_data_extraction", extractResumeDataNode)
19
+ builder.add_node("jd_data_extraction", extractJDDataNode ,retry_policy=node_retry)
20
  builder.add_node("skill_gap_analysis", skill_gap_node)
21
 
22
  # The ReAct Agent Node
app/main.py CHANGED
@@ -5,7 +5,6 @@ from app.utils.ui_payload_constructor import UIPayload
5
  from app.graph import graph
6
  from app.utils.cloudinary_utils import get_resume_url
7
 
8
-
9
  app = FastAPI(title="Adaptive Onboarding Engine")
10
 
11
  app.add_middleware(
@@ -15,7 +14,6 @@ app.add_middleware(
15
  allow_headers=["*"],
16
  )
17
 
18
-
19
  @app.post("/analyze")
20
  async def analyze(
21
  user_id: str = Form(..., description="User ID — used to fetch resume from Cloudinary"),
@@ -68,6 +66,6 @@ def health():
68
  return {"status": "ok", "service": "Adaptive Onboarding Engine"}
69
 
70
 
71
- if __name__ == "__main__":
72
- import uvicorn
73
- uvicorn.run(app, host="127.0.0.1", port=8000)
 
5
  from app.graph import graph
6
  from app.utils.cloudinary_utils import get_resume_url
7
 
 
8
  app = FastAPI(title="Adaptive Onboarding Engine")
9
 
10
  app.add_middleware(
 
14
  allow_headers=["*"],
15
  )
16
 
 
17
  @app.post("/analyze")
18
  async def analyze(
19
  user_id: str = Form(..., description="User ID — used to fetch resume from Cloudinary"),
 
66
  return {"status": "ok", "service": "Adaptive Onboarding Engine"}
67
 
68
 
69
+ # if __name__ == "__main__":
70
+ # import uvicorn
71
+ # uvicorn.run(app, host="127.0.0.1", port=8000)
app/nodes/graphnodes.py CHANGED
@@ -1,22 +1,25 @@
1
  from app.state.state import OnboardingState
2
- from langchain_core.messages import SystemMessage, HumanMessage,ToolMessage,AIMessage
3
  from app.prompts.resume_agent_prompt import resume_agent_prompt
4
  from app.prompts.jd_agent_prompt import jd_agent_prompt
5
  from app.prompts.roadmap_planner_agent_prompt import roadmap_planner_agent_prompt
6
- from app.agents.agents import resume_agent,jd_agent,roadmap_planner_agent,gap_analysis_agent
7
  from app.prompts.gap_analysis_agent_prompt import gap_analysis_agent_prompt
8
  import json
 
9
  from app.tools.tools import *
10
  from langchain_community.document_loaders import PyMuPDFLoader
11
- from langgraph.prebuilt import ToolNode ,tools_condition
12
  from app.schemas.jd_extract_schema import JobDescriptionExtract
13
  from app.schemas.resume_extract_schema import ResumeExtract
14
  from app.schemas.skill_gap_analysis_schema import SkillGapAnalysis
15
 
 
16
 
17
- def input_node(state: OnboardingState):
18
 
19
- file_path = state.get("file_path")
 
 
20
 
21
  if not file_path:
22
  return {"extraction_error": "Missing file_path in state"}
@@ -24,8 +27,6 @@ def input_node(state: OnboardingState):
24
  try:
25
  loader = PyMuPDFLoader(file_path)
26
  docs = loader.load()
27
-
28
-
29
  resume_text = "\n".join([doc.page_content for doc in docs])
30
 
31
  return {
@@ -34,13 +35,14 @@ def input_node(state: OnboardingState):
34
  }
35
 
36
  except Exception as e:
 
37
  return {
38
  "extraction_error": f"Failed to load resume: {str(e)}"
39
  }
40
-
41
 
42
  def extractResumeDataNode(state: OnboardingState):
43
-
44
  resume_text = state["resume_text"]
45
 
46
  messages = [
@@ -48,105 +50,104 @@ def extractResumeDataNode(state: OnboardingState):
48
  HumanMessage(content=f"<resume_text>{resume_text}</resume_text>")
49
  ]
50
 
51
-
52
  result = resume_agent.invoke(messages)
53
 
54
  return {"resume_data": result["parsed"]}
55
 
56
 
57
  def extractJDDataNode(state: OnboardingState):
58
- # 1. Safety Check: Is the text even in the state?
59
  jd_text = state.get("job_description", "")
60
-
61
  if not jd_text or len(jd_text.strip()) < 5:
62
- print("DEBUGGER ERROR: job_description text is MISSING from state!")
63
  return {"JobDescriptionExtract_data": JobDescriptionExtract()}
64
 
65
- print(f"DEBUGGER: Sending {len(jd_text)} characters to JD Agent...")
66
 
67
  messages = [
68
  SystemMessage(content=jd_agent_prompt),
69
- HumanMessage(content=f"EXTRACT FROM THIS TEXT:\n\n{jd_text}")
70
  ]
71
 
72
  try:
73
- # 2. Invoke the agent
74
  result = jd_agent.invoke(messages)
75
-
76
- # 3. Handle the 'parsed' key (ensure your chain is configured correctly)
77
- # If result is already the Pydantic object, use it directly.
78
- # If result is a dict with 'parsed', use result['parsed'].
79
  parsed_data = result.get("parsed") if isinstance(result, dict) else result
80
 
81
- # 4. Critical Check: Did it actually find anything?
82
  if parsed_data.job_title is None and parsed_data.tools_technologies is None:
83
- print("DEBUGGER WARNING: LLM returned empty schema! Checking prompt...")
84
  else:
85
- print(f"DEBUGGER SUCCESS: Extracted {parsed_data.job_title}")
86
 
87
  return {"JobDescriptionExtract_data": parsed_data}
88
-
89
  except Exception as e:
90
- print(f"DEBUGGER CRITICAL: Invoke failed: {str(e)}")
91
  return {"JobDescriptionExtract_data": JobDescriptionExtract()}
92
-
93
-
94
 
95
 
96
  def skill_gap_node(state: OnboardingState):
97
-
98
- resume_data = state["resume_data"]
99
- candidate_name = state["candidate_name"]
100
-
101
- # To remove noise and reduce size of the prompt.
102
- lean_resume_dict = resume_data.model_dump(
103
- exclude_none=True # Bonus: Automatically drops any fields that are None/null!
104
- )
105
-
106
- raw_jd = state["JobDescriptionExtract_data"]
107
-
108
- # Strip the HR noise and text bloat
109
- lean_jd_dict = raw_jd.model_dump(
110
- exclude_none=True # Drops any null fields
111
- )
112
-
113
- lean_resume_json = json.dumps(lean_resume_dict, indent=2)
114
 
 
 
 
115
 
 
 
116
  lean_jd_json = json.dumps(lean_jd_dict, indent=2)
117
 
 
 
 
 
 
 
 
 
 
 
 
 
 
118
  messages = [
119
  SystemMessage(content=gap_analysis_agent_prompt),
120
- HumanMessage(content=f"Users Resume:UserName:<candidate_name>{candidate_name}</candidate_name> Resume:<lean_resume_json>{lean_resume_json}</lean_resume_json> Job Description:<lean_jd_json>{lean_jd_json}</lean_jd_json>"),
121
-
122
  ]
123
 
124
-
125
- result = gap_analysis_agent.invoke(messages)
126
-
127
- return {"skill_gap_analysis_data": result["parsed"]}
128
-
129
 
 
 
 
130
 
131
 
132
  def roadmap_planning_node(state: OnboardingState):
133
  """
134
- The agent's 'thinking' node. It looks at the Skill Gaps and
135
- decides which tool to call next.
136
  """
137
  skill_gap_data = state["skill_gap_analysis_data"]
138
 
139
- skill_gap_data= skill_gap_data.model_dump()
 
 
140
 
141
  system_prompt = SystemMessage(content=roadmap_planner_agent_prompt)
142
- input_msg = HumanMessage(content=f"<skill_gap_data> {skill_gap_data} </skill_gap_data>")
143
-
144
- response = roadmap_planner_agent.invoke([system_prompt, input_msg] + state["messages"])
145
 
146
-
147
-
148
- return {"messages": [response]}
149
 
 
 
 
150
 
151
 
152
- tool_node = ToolNode(roadmap_planner_agent_tools)
 
 
1
  from app.state.state import OnboardingState
2
+ from langchain_core.messages import SystemMessage, HumanMessage, ToolMessage, AIMessage
3
  from app.prompts.resume_agent_prompt import resume_agent_prompt
4
  from app.prompts.jd_agent_prompt import jd_agent_prompt
5
  from app.prompts.roadmap_planner_agent_prompt import roadmap_planner_agent_prompt
6
+ from app.agents.agents import resume_agent, jd_agent, roadmap_planner_agent, gap_analysis_agent, roadmap_planner_agent_tools
7
  from app.prompts.gap_analysis_agent_prompt import gap_analysis_agent_prompt
8
  import json
9
+ import logging
10
  from app.tools.tools import *
11
  from langchain_community.document_loaders import PyMuPDFLoader
12
+ from langgraph.prebuilt import ToolNode, tools_condition
13
  from app.schemas.jd_extract_schema import JobDescriptionExtract
14
  from app.schemas.resume_extract_schema import ResumeExtract
15
  from app.schemas.skill_gap_analysis_schema import SkillGapAnalysis
16
 
17
+ logger = logging.getLogger(__name__)
18
 
 
19
 
20
+ def input_node(state: OnboardingState):
21
+ """Load and extract text from resume PDF."""
22
+ file_path = state.get("file_path")
23
 
24
  if not file_path:
25
  return {"extraction_error": "Missing file_path in state"}
 
27
  try:
28
  loader = PyMuPDFLoader(file_path)
29
  docs = loader.load()
 
 
30
  resume_text = "\n".join([doc.page_content for doc in docs])
31
 
32
  return {
 
35
  }
36
 
37
  except Exception as e:
38
+ logger.error(f"Failed to load resume: {str(e)}")
39
  return {
40
  "extraction_error": f"Failed to load resume: {str(e)}"
41
  }
42
+
43
 
44
  def extractResumeDataNode(state: OnboardingState):
45
+ """Extract structured resume data using resume agent."""
46
  resume_text = state["resume_text"]
47
 
48
  messages = [
 
50
  HumanMessage(content=f"<resume_text>{resume_text}</resume_text>")
51
  ]
52
 
 
53
  result = resume_agent.invoke(messages)
54
 
55
  return {"resume_data": result["parsed"]}
56
 
57
 
58
  def extractJDDataNode(state: OnboardingState):
59
+ """Extract structured job description data using JD agent."""
60
  jd_text = state.get("job_description", "")
61
+
62
  if not jd_text or len(jd_text.strip()) < 5:
63
+ logger.warning("job_description text is missing from state")
64
  return {"JobDescriptionExtract_data": JobDescriptionExtract()}
65
 
66
+ logger.info(f"Extracting JD from {len(jd_text)} characters")
67
 
68
  messages = [
69
  SystemMessage(content=jd_agent_prompt),
70
+ HumanMessage(content=f"<job_description>{jd_text}</job_description>")
71
  ]
72
 
73
  try:
 
74
  result = jd_agent.invoke(messages)
 
 
 
 
75
  parsed_data = result.get("parsed") if isinstance(result, dict) else result
76
 
 
77
  if parsed_data.job_title is None and parsed_data.tools_technologies is None:
78
+ logger.warning("JD extraction returned empty schema")
79
  else:
80
+ logger.info(f"Successfully extracted job title: {parsed_data.job_title}")
81
 
82
  return {"JobDescriptionExtract_data": parsed_data}
83
+
84
  except Exception as e:
85
+ logger.error(f"JD extraction failed: {str(e)}")
86
  return {"JobDescriptionExtract_data": JobDescriptionExtract()}
 
 
87
 
88
 
89
  def skill_gap_node(state: OnboardingState):
90
+ """Analyze skill gaps between resume and job description."""
91
+ resume_data = state["resume_data"]
92
+ candidate_name = state.get("candidate_name", "Candidate")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
93
 
94
+ # Convert Pydantic models to lean dicts (exclude None values)
95
+ lean_resume_dict = resume_data.model_dump(exclude_none=True)
96
+ lean_jd_dict = state["JobDescriptionExtract_data"].model_dump(exclude_none=True)
97
 
98
+ # Serialize to JSON
99
+ lean_resume_json = json.dumps(lean_resume_dict, indent=2)
100
  lean_jd_json = json.dumps(lean_jd_dict, indent=2)
101
 
102
+ # Clean prompt with proper formatting
103
+ prompt_text = f"""Analyze the skill gaps for the following candidate:
104
+
105
+ Candidate Name: {candidate_name}
106
+
107
+ Resume:
108
+ {lean_resume_json}
109
+
110
+ Job Description:
111
+ {lean_jd_json}
112
+
113
+ Please provide a detailed skill gap analysis."""
114
+
115
  messages = [
116
  SystemMessage(content=gap_analysis_agent_prompt),
117
+ HumanMessage(content=prompt_text)
 
118
  ]
119
 
120
+ try:
121
+ result = gap_analysis_agent.invoke(messages)
122
+ return {"skill_gap_analysis_data": result["parsed"]}
 
 
123
 
124
+ except Exception as e:
125
+ logger.error(f"Skill gap analysis failed: {str(e)}")
126
+ return {"skill_gap_analysis_data": SkillGapAnalysis()}
127
 
128
 
129
  def roadmap_planning_node(state: OnboardingState):
130
  """
131
+ Plan learning roadmap based on skill gaps.
132
+ This node decides which tools to call next based on the analysis.
133
  """
134
  skill_gap_data = state["skill_gap_analysis_data"]
135
 
136
+ # Convert Pydantic model to dict, then to JSON
137
+ skill_gap_dict = skill_gap_data.model_dump()
138
+ skill_gap_json = json.dumps(skill_gap_dict, indent=2)
139
 
140
  system_prompt = SystemMessage(content=roadmap_planner_agent_prompt)
141
+ input_msg = HumanMessage(content=f"<skill_gap_analysis>\n{skill_gap_json}\n</skill_gap_analysis>")
 
 
142
 
143
+ try:
144
+ response = roadmap_planner_agent.invoke([system_prompt, input_msg] + state.get("messages", []))
145
+ return {"messages": [response]}
146
 
147
+ except Exception as e:
148
+ logger.error(f"Roadmap planning failed: {str(e)}")
149
+ return {"messages": [AIMessage(content=f"Error in roadmap planning: {str(e)}")]}
150
 
151
 
152
+ # Initialize tool node for roadmap planner
153
+ tool_node = ToolNode(roadmap_planner_agent_tools)
app/schemas/resume_extract_schema.py CHANGED
@@ -8,22 +8,6 @@ class Skill(BaseModel):
8
  )
9
 
10
 
11
- class ExperienceItem(BaseModel):
12
- job_title: str = Field(
13
- ...,
14
- description="Role title of the candidate. Example: 'Backend Intern', 'Software Engineer'"
15
- )
16
-
17
- technologies: Optional[List[str]] = Field(
18
- default_factory=list,
19
- description="Technologies, tools, or frameworks used in this role"
20
- )
21
-
22
- responsibilities: Optional[List[str]] = Field(
23
- default_factory=list,
24
- description="Key responsibilities, tasks, or learnings in concise bullet points keep it summarised detail *not* required"
25
- )
26
-
27
  class ProjectItem(BaseModel):
28
  name: str = Field(..., description="Project name")
29
  technologies: List[str] = Field(
@@ -52,32 +36,14 @@ class ResumeExtract(BaseModel):
52
  )
53
  )
54
 
55
-
56
-
57
-
58
  skills: List[Skill] = Field(
59
  default_factory=list,
60
  description="Skills explicitly listed by the candidate"
61
  )
62
- experience: List[ExperienceItem] = Field(
63
- default_factory=list,
64
- description=(
65
- "Each role as a separate entry. "
66
- "No company name needed — focus on what was done and learned."
67
- )
68
- )
69
  projects: List[ProjectItem] = Field(
70
  default_factory=list,
71
  description="Projects with technologies used and what was built"
72
  )
73
 
74
 
75
-
76
- is_fresher: bool = Field(
77
- ...,
78
- description=(
79
- "Set to True if the candidate lacks full-time professional employment. "
80
- "Academic projects, certifications, and internships are considered "
81
- "part of the learning phase and do not qualify a candidate as 'non-fresher' hence is_."
82
- )
83
- )
 
8
  )
9
 
10
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
  class ProjectItem(BaseModel):
12
  name: str = Field(..., description="Project name")
13
  technologies: List[str] = Field(
 
36
  )
37
  )
38
 
 
 
 
39
  skills: List[Skill] = Field(
40
  default_factory=list,
41
  description="Skills explicitly listed by the candidate"
42
  )
43
+
 
 
 
 
 
 
44
  projects: List[ProjectItem] = Field(
45
  default_factory=list,
46
  description="Projects with technologies used and what was built"
47
  )
48
 
49
 
 
 
 
 
 
 
 
 
 
app/schemas/skill_gap_analysis_schema.py CHANGED
@@ -5,7 +5,7 @@ from typing import List, Optional, Literal
5
  class SkillGap(BaseModel):
6
  skill_name: str = Field(
7
  ...,
8
- description="The specific technology or tool missing or requiring an upgrade (e.g., 'PostgreSQL')"
9
  )
10
 
11
  gap_type: Literal["missing_foundation", "needs_advanced_upgrade"] = Field(
@@ -24,7 +24,6 @@ class SkillGap(BaseModel):
24
  reasoning: str = Field(
25
  ...,
26
  description=(
27
- "The 'Reasoning Trace'. This MUST be provided for every skill gap identified. "
28
  "Explain exactly WHY this gap was flagged based on the resume vs JD comparison. "
29
  "Example: 'JD requires FastAPI; candidate has Python experience but no record of using FastAPI framework.'"
30
  )
 
5
  class SkillGap(BaseModel):
6
  skill_name: str = Field(
7
  ...,
8
+ description="The specific tool missing or requiring an upgrade (e.g., 'Docker')"
9
  )
10
 
11
  gap_type: Literal["missing_foundation", "needs_advanced_upgrade"] = Field(
 
24
  reasoning: str = Field(
25
  ...,
26
  description=(
 
27
  "Explain exactly WHY this gap was flagged based on the resume vs JD comparison. "
28
  "Example: 'JD requires FastAPI; candidate has Python experience but no record of using FastAPI framework.'"
29
  )
app/{graph_trial.py → test.py} RENAMED
@@ -33,9 +33,7 @@ initial_input = {
33
  "resume_data": None,
34
  "extraction_error": None,
35
  "JobDescriptionExtract_data": None,
36
- "skill_gap_analysis_data": None
37
-
38
-
39
  }
40
 
41
 
@@ -47,7 +45,21 @@ config = {"configurable": {"thread_id": THREAD_ID}}
47
  # final_result = graph.invoke(initial_input, config=config)
48
 
49
 
50
- # print(final_result)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
 
52
 
53
 
 
33
  "resume_data": None,
34
  "extraction_error": None,
35
  "JobDescriptionExtract_data": None,
36
+ "skill_gap_analysis_data": None
 
 
37
  }
38
 
39
 
 
45
  # final_result = graph.invoke(initial_input, config=config)
46
 
47
 
48
+ # print(final_result['resume_data'])
49
+
50
+ # print()
51
+
52
+ # print(final_result['JobDescriptionExtract_data'])
53
+
54
+ # print()
55
+
56
+ # print(final_result['skill_gap_analysis_data'])
57
+
58
+
59
+ # print()
60
+
61
+
62
+ # print(final_result['final_roadmap'])
63
 
64
 
65