ak0601 commited on
Commit
b3ff1bd
Β·
verified Β·
1 Parent(s): 265750f

Update app_job_copy_1.py

Browse files
Files changed (1) hide show
  1. app_job_copy_1.py +447 -305
app_job_copy_1.py CHANGED
@@ -3,41 +3,87 @@ import pandas as pd
3
  import json
4
  import os
5
  from pydantic import BaseModel, Field
6
- from typing import List, Set, Dict, Any, Optional
7
- import time
8
  from langchain_openai import ChatOpenAI
9
- from langchain_core.messages import HumanMessage
10
  from langchain_core.prompts import ChatPromptTemplate
11
- from langchain_core.output_parsers import StrOutputParser
12
- from langchain_core.prompts import PromptTemplate
13
  import gspread
 
14
  from google.oauth2 import service_account
15
- os.environ["STREAMLIT_DISABLE_USAGE_STATS"] = "1"
 
16
  st.set_page_config(
17
  page_title="Candidate Matching App",
18
  page_icon="πŸ‘¨β€πŸ’»πŸŽ―",
19
  layout="wide"
20
  )
21
-
 
22
  # Define pydantic model for structured output
23
  class Shortlist(BaseModel):
24
- fit_score: float = Field(description="A score between 0 and 10 indicating how closely the candidate profile matches the job requirements.")
25
  candidate_name: str = Field(description="The name of the candidate.")
26
  candidate_url: str = Field(description="The URL of the candidate's LinkedIn profile.")
27
  candidate_summary: str = Field(description="A brief summary of the candidate's skills and experience along with its educational background.")
28
  candidate_location: str = Field(description="The location of the candidate.")
29
  justification: str = Field(description="Justification for the shortlisted candidate with the fit score")
30
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
  # Function to parse and normalize tech stacks
32
  def parse_tech_stack(stack):
33
- if pd.isna(stack) or stack == "" or stack is None:
34
- return set()
35
- if isinstance(stack, set):
36
- return stack
37
  try:
38
- # Handle potential string representation of sets
39
  if isinstance(stack, str) and stack.startswith("{") and stack.endswith("}"):
40
- # This could be a string representation of a set
41
  items = stack.strip("{}").split(",")
42
  return set(item.strip().strip("'\"") for item in items if item.strip())
43
  return set(map(lambda x: x.strip().lower(), str(stack).split(',')))
@@ -46,38 +92,40 @@ def parse_tech_stack(stack):
46
  return set()
47
 
48
  def display_tech_stack(stack_set):
49
- if isinstance(stack_set, set):
50
- return ", ".join(sorted(stack_set))
51
- return str(stack_set)
52
 
53
  def get_matching_candidates(job_stack, candidates_df):
54
- """Find candidates with matching tech stack for a specific job"""
55
  matched = []
56
  job_stack_set = parse_tech_stack(job_stack)
57
-
58
  for _, candidate in candidates_df.iterrows():
59
  candidate_stack = parse_tech_stack(candidate['Key Tech Stack'])
60
  common = job_stack_set & candidate_stack
61
- if len(common) >= 2:
62
  matched.append({
63
- "Name": candidate["Full Name"],
64
- "URL": candidate["LinkedIn URL"],
65
  "Degree & Education": candidate["Degree & University"],
66
  "Years of Experience": candidate["Years of Experience"],
67
  "Current Title & Company": candidate['Current Title & Company'],
68
  "Key Highlights": candidate["Key Highlights"],
69
  "Location": candidate["Location (from most recent experience)"],
70
- "Experience": str(candidate["Experience"]),
71
- "Tech Stack": candidate_stack
72
  })
73
  return matched
74
 
75
  def setup_llm():
76
  """Set up the LangChain LLM with structured output"""
 
 
 
 
 
 
 
77
  # Create LLM instance
78
  llm = ChatOpenAI(
79
- model="gpt-4o-mini",
80
- temperature=0,
81
  max_tokens=None,
82
  timeout=None,
83
  max_retries=2,
@@ -87,30 +135,33 @@ def setup_llm():
87
  sum_llm = llm.with_structured_output(Shortlist)
88
 
89
  # Create system prompt
90
- system = """You are an expert Recruitor, your task is to analyse the Candidate profile and determine if it matches with the job details and provide a score(out of 10) indicating how compatible the
91
  the profile is according to job.
 
 
92
  Try to ensure following points while estimating the candidate's fit score:
93
  For education:
94
  Tier1 - MIT, Stanford, CMU, UC Berkeley, Caltech, Harvard, IIT Bombay, IIT Delhi, Princeton, UIUC, University of Washington, Columbia, University of Chicago, Cornell, University of Michigan (Ann Arbor), UT Austin - Maximum points
95
  Tier2 - UC Davis, Georgia Tech, Purdue, UMass Amherst,etc - Moderate points
96
  Tier3 - Unknown or unranked institutions - Lower points or reject
97
-
98
  Startup Experience Requirement:
99
  Candidates must have worked as a direct employee at a VC-backed startup (Seed to series C/D)
100
- preferred - Y Combinator, Sequoia,a16z,Accel,Founders Fund,LightSpeed,Greylock,Benchmark,Index Ventures,etc.
101
-
102
  The fit score signifies based on following metrics:
103
  1–5 - Poor Fit - Auto-reject
104
  6–7 - Weak Fit - Auto-reject
105
  8.0–8.7 - Moderate Fit - Auto-reject
106
  8.8–10 - STRONG Fit - Include in results
 
107
  """
108
 
109
  # Create query prompt
110
  query_prompt = ChatPromptTemplate.from_messages([
111
  ("system", system),
112
  ("human", """
113
- You are an expert Recruitor, your task is to determine if the user is a correct match for the given job or not.
 
 
114
  For this you will be provided with the follwing inputs of job and candidates:
115
  Job Details
116
  Company: {Company}
@@ -120,7 +171,6 @@ preferred - Y Combinator, Sequoia,a16z,Accel,Founders Fund,LightSpeed,Greylock,B
120
  Tech Stack: {Tech_Stack}
121
  Industry: {Industry}
122
 
123
-
124
  Candidate Details:
125
  Full Name: {Full_Name}
126
  LinkedIn URL: {LinkedIn_URL}
@@ -131,10 +181,9 @@ preferred - Y Combinator, Sequoia,a16z,Accel,Founders Fund,LightSpeed,Greylock,B
131
  Key Highlights: {Key_Highlights}
132
  Location (from most recent experience): {cand_Location}
133
  Past_Experience: {Experience}
134
-
135
-
136
  Answer in the structured manner as per the schema.
137
  If any parameter is Unknown try not to include in the summary, only include those parameters which are known.
 
138
  """),
139
  ])
140
 
@@ -144,332 +193,425 @@ preferred - Y Combinator, Sequoia,a16z,Accel,Founders Fund,LightSpeed,Greylock,B
144
  return cat_class
145
 
146
  def call_llm(candidate_data, job_data, llm_chain):
147
- """Call the actual LLM to evaluate the candidate"""
148
  try:
149
- # Convert tech stacks to strings for the LLM payload
150
- job_tech_stack = job_data.get("Tech_Stack", set())
151
- candidate_tech_stack = candidate_data.get("Tech Stack", set())
152
 
153
- if isinstance(job_tech_stack, set):
154
- job_tech_stack = ", ".join(sorted(job_tech_stack))
155
-
156
- if isinstance(candidate_tech_stack, set):
157
- candidate_tech_stack = ", ".join(sorted(candidate_tech_stack))
158
-
159
- # Prepare payload for LLM
160
  payload = {
161
- "Company": job_data.get("Company", ""),
162
- "Role": job_data.get("Role", ""),
163
- "desc": job_data.get("desc", ""),
164
- "Locations": job_data.get("Locations", ""),
165
- "Tech_Stack": job_tech_stack,
166
- "Industry": job_data.get("Industry", ""),
167
-
168
- "Full_Name": candidate_data.get("Name", ""),
169
- "LinkedIn_URL": candidate_data.get("URL", ""),
170
  "Current_Title_Company": candidate_data.get("Current Title & Company", ""),
171
  "Years_of_Experience": candidate_data.get("Years of Experience", ""),
172
  "Degree_University": candidate_data.get("Degree & Education", ""),
173
- "Key_Tech_Stack": candidate_tech_stack,
174
- "Key_Highlights": candidate_data.get("Key Highlights", ""),
175
- "cand_Location": candidate_data.get("Location", ""),
176
- "Experience": candidate_data.get("Experience", "")
177
  }
178
-
179
- # Call LLM
180
  response = llm_chain.invoke(payload)
181
- print(candidate_data.get("Experience", ""))
 
 
 
 
 
 
 
 
182
 
183
- # Return response in expected format
184
  return {
185
- "candidate_name": response.candidate_name,
186
- "candidate_url": response.candidate_url,
187
- "candidate_summary": response.candidate_summary,
188
- "candidate_location": response.candidate_location,
189
- "fit_score": response.fit_score,
190
- "justification": response.justification
191
  }
192
  except Exception as e:
193
- st.error(f"Error calling LLM: {e}")
194
- # Fallback to a default response
195
  return {
196
- "candidate_name": candidate_data.get("Name", "Unknown"),
197
- "candidate_url": candidate_data.get("URL", ""),
198
- "candidate_summary": "Error processing candidate profile",
199
- "candidate_location": candidate_data.get("Location", "Unknown"),
200
- "fit_score": 0.0,
201
- "justification": f"Error in LLM processing: {str(e)}"
202
  }
203
 
204
  def process_candidates_for_job(job_row, candidates_df, llm_chain=None):
205
- """Process candidates for a specific job using the LLM"""
 
 
206
  if llm_chain is None:
207
- with st.spinner("Setting up LLM..."):
208
- llm_chain = setup_llm()
209
 
210
  selected_candidates = []
 
 
 
 
211
 
212
- try:
213
- # Get job-specific data
214
- job_data = {
215
- "Company": job_row["Company"],
216
- "Role": job_row["Role"],
217
- "desc": job_row.get("One liner", ""),
218
- "Locations": job_row.get("Locations", ""),
219
- "Tech_Stack": job_row["Tech Stack"],
220
- "Industry": job_row.get("Industry", "")
221
- }
222
-
223
- # Find matching candidates for this job
224
- with st.spinner("Finding matching candidates based on tech stack..."):
225
- matching_candidates = get_matching_candidates(job_row["Tech Stack"], candidates_df)
226
-
227
- if not matching_candidates:
228
- st.warning("No candidates with matching tech stack found for this job.")
229
- return []
230
-
231
- st.success(f"Found {len(matching_candidates)} candidates with matching tech stack.")
232
-
233
- # Create progress elements
234
- candidates_progress = st.progress(0)
235
- candidate_status = st.empty()
236
-
237
- # Process each candidate
238
- for i, candidate_data in enumerate(matching_candidates):
239
- # Update progress
240
- candidates_progress.progress((i + 1) / len(matching_candidates))
241
- candidate_status.text(f"Evaluating candidate {i+1}/{len(matching_candidates)}: {candidate_data.get('Name', 'Unknown')}")
242
-
243
- # Process the candidate with the LLM
244
- response = call_llm(candidate_data, job_data, llm_chain)
245
-
246
- response_dict = {
247
- "Name": response["candidate_name"],
248
- "LinkedIn": response["candidate_url"],
249
- "summary": response["candidate_summary"],
250
- "Location": response["candidate_location"],
251
- "Fit Score": response["fit_score"],
252
- "justification": response["justification"],
253
- # Add back original candidate data for context
254
- "Educational Background": candidate_data.get("Degree & Education", ""),
255
- "Years of Experience": candidate_data.get("Years of Experience", ""),
256
- "Current Title & Company": candidate_data.get("Current Title & Company", "")
257
- }
258
-
259
- # Add to selected candidates if score is high enough
260
- if response["fit_score"] >= 8.8:
261
- selected_candidates.append(response_dict)
262
- st.markdown(response_dict)
263
- else:
264
- st.write(f"Rejected candidate: {response_dict['Name']} with score: {response['fit_score']}")
265
 
266
- # Clear progress indicators
267
- candidates_progress.empty()
268
- candidate_status.empty()
 
 
 
 
 
269
 
270
- # Show results
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
271
  if selected_candidates:
272
- st.success(f"βœ… Found {len(selected_candidates)} suitable candidates for this job!")
273
  else:
274
- st.info("No candidates met the minimum fit score threshold for this job.")
275
-
276
- return selected_candidates
277
-
278
- except Exception as e:
279
- st.error(f"Error processing job: {e}")
280
- return []
281
 
282
  def main():
283
  st.title("πŸ‘¨β€πŸ’» Candidate Matching App")
 
 
 
 
 
 
 
 
284
 
285
- # Initialize session state
286
- if 'processed_jobs' not in st.session_state:
287
- st.session_state.processed_jobs = {}
288
-
289
- st.write("""
290
- This app matches job listings with candidate profiles based on tech stack and other criteria.
291
- Select a job to find matching candidates.
292
- """)
293
-
294
- # API Key input
295
  with st.sidebar:
296
  st.header("API Configuration")
297
- api_key = st.text_input("Enter OpenAI API Key", type="password")
298
  if api_key:
299
  os.environ["OPENAI_API_KEY"] = api_key
300
- st.success("API Key set!")
 
 
 
 
301
  else:
302
  st.warning("Please enter OpenAI API Key to use LLM features")
 
303
 
304
- # Show API key warning if not set
305
- secret_content = os.getenv("GCP_SERVICE_ACCOUNT")
306
- # secret_content = secret_content.replace("\n", "\\n")
307
- secret_content = json.loads(secret_content)
308
- SCOPES = ['https://www.googleapis.com/auth/spreadsheets']
309
- creds = service_account.Credentials.from_service_account_info(secret_content, scopes=SCOPES)
310
- gc = gspread.authorize(creds)
311
- job_sheet = gc.open_by_key('1BZlvbtFyiQ9Pgr_lpepDJua1ZeVEqrCLjssNd6OiG9k')
312
- candidates_sheet = gc.open_by_key('1u_9o5f0MPHFUSScjEcnA8Lojm4Y9m9LuWhvjYm6ytF4')
313
-
314
- if not api_key:
 
 
 
 
315
  st.warning("⚠️ You need to provide an OpenAI API key in the sidebar to use this app.")
 
 
 
 
 
316
 
317
- if api_key:
318
- try:
319
- # Load data from Google Sheets
320
- job_worksheet = job_sheet.worksheet('paraform_jobs_formatted')
321
- job_data = job_worksheet.get_all_values()
322
- candidate_worksheet = candidates_sheet.worksheet('transformed_candidates_updated')
323
- candidate_data = candidate_worksheet.get_all_values()
324
-
325
- # Convert to DataFrames
326
- jobs_df = pd.DataFrame(job_data[1:], columns=job_data[0])
327
- candidates_df = pd.DataFrame(candidate_data[1:], columns=candidate_data[0])
328
- candidates_df = candidates_df.fillna("Unknown")
329
-
330
- # Display data preview
331
- with st.expander("Preview uploaded data"):
332
- st.subheader("Jobs Data Preview")
333
- st.dataframe(jobs_df.head(3))
334
-
335
- st.subheader("Candidates Data Preview")
336
- st.dataframe(candidates_df.head(3))
337
-
338
- # Map column names if needed
339
- column_mapping = {
340
- "Full Name": "Full Name",
341
- "LinkedIn URL": "LinkedIn URL",
342
- "Current Title & Company": "Current Title & Company",
343
- "Years of Experience": "Years of Experience",
344
- "Degree & University": "Degree & University",
345
- "Key Tech Stack": "Key Tech Stack",
346
- "Key Highlights": "Key Highlights",
347
- "Location (from most recent experience)": "Location (from most recent experience)"
348
- }
349
-
350
- # Rename columns if they don't match expected
351
- candidates_df = candidates_df.rename(columns={
352
- col: mapping for col, mapping in column_mapping.items()
353
- if col in candidates_df.columns and col != mapping
354
- })
355
 
356
- # Now, instead of processing all jobs upfront, we'll display job selection
357
- # and only process the selected job when the user chooses it
358
- display_job_selection(jobs_df, candidates_df)
359
 
360
- except Exception as e:
361
- st.error(f"Error processing files: {e}")
362
-
363
  st.divider()
364
 
 
 
 
 
 
 
 
365
 
366
- def display_job_selection(jobs_df, candidates_df):
367
- # Store the LLM chain as a session state to avoid recreating it
368
- if 'llm_chain' not in st.session_state:
369
- st.session_state.llm_chain = None
370
 
371
- st.subheader("Select a job to view potential matches")
 
372
 
373
- # Create job options - but don't compute matches yet
374
- job_options = []
375
- for i, row in jobs_df.iterrows():
376
- job_options.append(f"{row['Role']} at {row['Company']}")
 
 
 
 
 
 
 
 
 
 
 
 
 
377
 
378
- if job_options:
379
- selected_job_index = st.selectbox("Jobs:",
380
- range(len(job_options)),
381
- format_func=lambda x: job_options[x])
 
 
 
 
 
 
 
 
 
 
 
382
 
383
- # Display job details
384
- job_row = jobs_df.iloc[selected_job_index]
 
 
 
 
 
 
 
 
 
 
 
 
385
 
386
- # Parse tech stack for display
387
- job_row_stack = parse_tech_stack(job_row["Tech Stack"])
 
 
 
 
 
 
 
 
 
 
 
388
 
389
- col1, col2 = st.columns([2, 1])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
390
 
391
- with col1:
392
- st.subheader(f"Job Details: {job_row['Role']}")
393
-
394
- job_details = {
395
- "Company": job_row["Company"],
396
- "Role": job_row["Role"],
397
- "Description": job_row.get("One liner", "N/A"),
398
- "Locations": job_row.get("Locations", "N/A"),
399
- "Industry": job_row.get("Industry", "N/A"),
400
- "Tech Stack": display_tech_stack(job_row_stack)
401
- }
 
 
 
 
 
 
 
 
 
402
 
403
- for key, value in job_details.items():
404
- st.markdown(f"**{key}:** {value}")
405
-
406
- # Create a key for this job in session state
407
- job_key = f"job_{selected_job_index}_processed"
408
-
409
- if job_key not in st.session_state:
410
- st.session_state[job_key] = False
 
 
 
 
 
 
 
 
 
 
411
 
412
- # Add a process button for this job
413
- if not st.session_state[job_key]:
414
- if st.button(f"Find Matching Candidates for this Job"):
415
- if "OPENAI_API_KEY" not in os.environ or not os.environ["OPENAI_API_KEY"]:
416
- st.error("Please enter your OpenAI API key in the sidebar before processing")
417
- else:
418
- # Process candidates for this job (only when requested)
419
- selected_candidates = process_candidates_for_job(
420
- job_row,
421
- candidates_df,
422
- st.session_state.llm_chain
423
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
424
 
425
- # Store the results and set as processed
426
- if 'Selected_Candidates' not in st.session_state:
427
- st.session_state.Selected_Candidates = {}
428
- st.session_state.Selected_Candidates[selected_job_index] = selected_candidates
429
- st.session_state[job_key] = True
430
 
431
- # Store the LLM chain for reuse
432
- if st.session_state.llm_chain is None:
433
- st.session_state.llm_chain = setup_llm()
 
 
 
 
 
 
 
434
 
435
- # Force refresh
436
- st.rerun()
437
-
438
- # Display selected candidates if already processed
439
- if st.session_state[job_key] and 'Selected_Candidates' in st.session_state:
440
- selected_candidates = st.session_state.Selected_Candidates.get(selected_job_index, [])
441
-
442
- # Display selected candidates
443
- st.subheader("Selected Candidates")
444
-
445
- if len(selected_candidates) > 0:
446
- for i, candidate in enumerate(selected_candidates):
447
- with st.expander(f"{i+1}. {candidate['Name']} (Score: {candidate['Fit Score']})"):
448
- col1, col2 = st.columns([3, 1])
449
-
450
- with col1:
451
- st.markdown(f"**Summary:** {candidate['summary']}")
452
- st.markdown(f"**Current:** {candidate['Current Title & Company']}")
453
- st.markdown(f"**Education:** {candidate['Educational Background']}")
454
- st.markdown(f"**Experience:** {candidate['Years of Experience']}")
455
- st.markdown(f"**Location:** {candidate['Location']}")
456
- st.markdown(f"**[LinkedIn Profile]({candidate['LinkedIn']})**")
457
-
458
- with col2:
459
- st.markdown(f"**Fit Score:** {candidate['Fit Score']}")
460
-
461
  st.markdown("**Justification:**")
462
  st.info(candidate['justification'])
463
- else:
464
- st.info("No candidates met the minimum score threshold (8.8) for this job.")
465
-
466
- # We don't show tech-matched candidates here since they are generated
467
- # during the LLM matching process now
468
-
469
- # Add a reset button to start over
470
- if st.button("Reset and Process Again"):
471
- st.session_state[job_key] = False
472
- st.rerun()
 
 
 
 
 
473
 
474
  if __name__ == "__main__":
475
- main()
 
3
  import json
4
  import os
5
  from pydantic import BaseModel, Field
6
+ from typing import List, Set, Dict, Any, Optional # Already have these, but commented for brevity if not all used
7
+ import time # Added for potential small delays if needed
8
  from langchain_openai import ChatOpenAI
9
+ from langchain_core.messages import HumanMessage # Not directly used in provided snippet
10
  from langchain_core.prompts import ChatPromptTemplate
11
+ from langchain_core.output_parsers import StrOutputParser # Not directly used in provided snippet
12
+ from langchain_core.prompts import PromptTemplate # Not directly used in provided snippet
13
  import gspread
14
+ import tempfile
15
  from google.oauth2 import service_account
16
+ import tiktoken
17
+
18
  st.set_page_config(
19
  page_title="Candidate Matching App",
20
  page_icon="πŸ‘¨β€πŸ’»πŸŽ―",
21
  layout="wide"
22
  )
23
+ os.environ["STREAMLIT_HOME"] = tempfile.gettempdir()
24
+ os.environ["STREAMLIT_DISABLE_TELEMETRY"] = "1"
25
  # Define pydantic model for structured output
26
  class Shortlist(BaseModel):
27
+ fit_score: float = Field(description="A score between 0 and 10 indicating how closely the candidate profile matches the job requirements upto 3 decimal points.")
28
  candidate_name: str = Field(description="The name of the candidate.")
29
  candidate_url: str = Field(description="The URL of the candidate's LinkedIn profile.")
30
  candidate_summary: str = Field(description="A brief summary of the candidate's skills and experience along with its educational background.")
31
  candidate_location: str = Field(description="The location of the candidate.")
32
  justification: str = Field(description="Justification for the shortlisted candidate with the fit score")
33
 
34
+ # Function to calculate tokens
35
+ def calculate_tokens(text, model="gpt-4o-mini"):
36
+ try:
37
+ if "gpt-4" in model:
38
+ encoding = tiktoken.encoding_for_model("gpt-4o-mini")
39
+ elif "gpt-3.5" in model:
40
+ encoding = tiktoken.encoding_for_model("gpt-3.5-turbo")
41
+ else:
42
+ encoding = tiktoken.get_encoding("cl100k_base")
43
+ return len(encoding.encode(text))
44
+ except Exception as e:
45
+ return len(text) // 4
46
+
47
+ # Function to display token usage
48
+ def display_token_usage():
49
+ if 'total_input_tokens' not in st.session_state:
50
+ st.session_state.total_input_tokens = 0
51
+ if 'total_output_tokens' not in st.session_state:
52
+ st.session_state.total_output_tokens = 0
53
+
54
+ total_input = st.session_state.total_input_tokens
55
+ total_output = st.session_state.total_output_tokens
56
+ total_tokens = total_input + total_output
57
+
58
+ model_to_check = st.session_state.get('model_name', "gpt-4o-mini") # Use a default if not set
59
+
60
+ if model_to_check == "gpt-4o-mini":
61
+ input_cost_per_1k = 0.00015 # Adjusted to example rates ($0.15 / 1M tokens)
62
+ output_cost_per_1k = 0.0006 # Adjusted to example rates ($0.60 / 1M tokens)
63
+ elif "gpt-4" in model_to_check: # Fallback for other gpt-4
64
+ input_cost_per_1k = 0.005
65
+ output_cost_per_1k = 0.015 # General gpt-4 pricing can vary
66
+ else: # Assume gpt-3.5-turbo pricing
67
+ input_cost_per_1k = 0.0005 # $0.0005 per 1K input tokens
68
+ output_cost_per_1k = 0.0015 # $0.0015 per 1K output tokens
69
+
70
+ estimated_cost = (total_input / 1000 * input_cost_per_1k) + (total_output / 1000 * output_cost_per_1k)
71
+
72
+ st.subheader("πŸ“Š Token Usage Statistics (for last processed job)")
73
+
74
+ col1, col2, col3 = st.columns(3)
75
+ with col1: st.metric("Input Tokens", f"{total_input:,}")
76
+ with col2: st.metric("Output Tokens", f"{total_output:,}")
77
+ with col3: st.metric("Total Tokens", f"{total_tokens:,}")
78
+ st.markdown(f"**Estimated Cost:** ${estimated_cost:.4f}")
79
+ return total_tokens
80
+
81
  # Function to parse and normalize tech stacks
82
  def parse_tech_stack(stack):
83
+ if pd.isna(stack) or stack == "" or stack is None: return set()
84
+ if isinstance(stack, set): return stack
 
 
85
  try:
 
86
  if isinstance(stack, str) and stack.startswith("{") and stack.endswith("}"):
 
87
  items = stack.strip("{}").split(",")
88
  return set(item.strip().strip("'\"") for item in items if item.strip())
89
  return set(map(lambda x: x.strip().lower(), str(stack).split(',')))
 
92
  return set()
93
 
94
  def display_tech_stack(stack_set):
95
+ return ", ".join(sorted(list(stack_set))) if isinstance(stack_set, set) else str(stack_set)
96
+
 
97
 
98
  def get_matching_candidates(job_stack, candidates_df):
 
99
  matched = []
100
  job_stack_set = parse_tech_stack(job_stack)
 
101
  for _, candidate in candidates_df.iterrows():
102
  candidate_stack = parse_tech_stack(candidate['Key Tech Stack'])
103
  common = job_stack_set & candidate_stack
104
+ if len(common) >= 2: # Original condition
105
  matched.append({
106
+ "Name": candidate["Full Name"], "URL": candidate["LinkedIn URL"],
 
107
  "Degree & Education": candidate["Degree & University"],
108
  "Years of Experience": candidate["Years of Experience"],
109
  "Current Title & Company": candidate['Current Title & Company'],
110
  "Key Highlights": candidate["Key Highlights"],
111
  "Location": candidate["Location (from most recent experience)"],
112
+ "Experience": str(candidate["Experience"]), "Tech Stack": candidate_stack
 
113
  })
114
  return matched
115
 
116
  def setup_llm():
117
  """Set up the LangChain LLM with structured output"""
118
+ # Define the model to use
119
+ model_name = "gpt-4o-mini"
120
+
121
+ # Store model name in session state for token calculation
122
+ if 'model_name' not in st.session_state:
123
+ st.session_state.model_name = model_name
124
+
125
  # Create LLM instance
126
  llm = ChatOpenAI(
127
+ model=model_name,
128
+ temperature=0.3,
129
  max_tokens=None,
130
  timeout=None,
131
  max_retries=2,
 
135
  sum_llm = llm.with_structured_output(Shortlist)
136
 
137
  # Create system prompt
138
+ system = """You are an expert Tech Recruitor, your task is to analyse the Candidate profile and determine if it matches with the job details and provide a score(out of 10) indicating how compatible the
139
  the profile is according to job.
140
+ First of all check the location of the candidate, if the location is not in the range of the job location then reject the candidate directly without any further analysis.
141
+ for example if the job location is New York and the candidate is in San Francisco then reject the candidate. Similarly for other states as well.
142
  Try to ensure following points while estimating the candidate's fit score:
143
  For education:
144
  Tier1 - MIT, Stanford, CMU, UC Berkeley, Caltech, Harvard, IIT Bombay, IIT Delhi, Princeton, UIUC, University of Washington, Columbia, University of Chicago, Cornell, University of Michigan (Ann Arbor), UT Austin - Maximum points
145
  Tier2 - UC Davis, Georgia Tech, Purdue, UMass Amherst,etc - Moderate points
146
  Tier3 - Unknown or unranked institutions - Lower points or reject
 
147
  Startup Experience Requirement:
148
  Candidates must have worked as a direct employee at a VC-backed startup (Seed to series C/D)
149
+ preferred - Y Combinator, Sequoia,a16z,Accel,Founders Fund,LightSpeed,Greylock,Benchmark,Index Ventures,etc.
 
150
  The fit score signifies based on following metrics:
151
  1–5 - Poor Fit - Auto-reject
152
  6–7 - Weak Fit - Auto-reject
153
  8.0–8.7 - Moderate Fit - Auto-reject
154
  8.8–10 - STRONG Fit - Include in results
155
+ Each candidate's fit score should be calculated based on a weighted evaluation of their background and must be distinct even if candidates have similar profiles.
156
  """
157
 
158
  # Create query prompt
159
  query_prompt = ChatPromptTemplate.from_messages([
160
  ("system", system),
161
  ("human", """
162
+ You are an expert Recruitor. Your task is to determine if the candidate matches the given job.
163
+ Provide the score as a `float` rounded to exactly **three decimal places** (e.g., 8.943, 9.211, etc.).
164
+ Avoid rounding to whole or one-decimal numbers. Every candidate should have a **unique** fit score.
165
  For this you will be provided with the follwing inputs of job and candidates:
166
  Job Details
167
  Company: {Company}
 
171
  Tech Stack: {Tech_Stack}
172
  Industry: {Industry}
173
 
 
174
  Candidate Details:
175
  Full Name: {Full_Name}
176
  LinkedIn URL: {LinkedIn_URL}
 
181
  Key Highlights: {Key_Highlights}
182
  Location (from most recent experience): {cand_Location}
183
  Past_Experience: {Experience}
 
 
184
  Answer in the structured manner as per the schema.
185
  If any parameter is Unknown try not to include in the summary, only include those parameters which are known.
186
+ The `fit_score` must be a float with **exactly three decimal digits** (e.g. 8.812, 9.006). Do not round to 1 or 2 decimals.
187
  """),
188
  ])
189
 
 
193
  return cat_class
194
 
195
  def call_llm(candidate_data, job_data, llm_chain):
 
196
  try:
197
+ job_tech_stack = ", ".join(sorted(list(job_data.get("Tech_Stack", set())))) if isinstance(job_data.get("Tech_Stack"), set) else job_data.get("Tech_Stack", "")
198
+ candidate_tech_stack = ", ".join(sorted(list(candidate_data.get("Tech Stack", set())))) if isinstance(candidate_data.get("Tech Stack"), set) else candidate_data.get("Tech Stack", "")
 
199
 
 
 
 
 
 
 
 
200
  payload = {
201
+ "Company": job_data.get("Company", ""), "Role": job_data.get("Role", ""),
202
+ "desc": job_data.get("desc", ""), "Locations": job_data.get("Locations", ""),
203
+ "Tech_Stack": job_tech_stack, "Industry": job_data.get("Industry", ""),
204
+ "Full_Name": candidate_data.get("Name", ""), "LinkedIn_URL": candidate_data.get("URL", ""),
 
 
 
 
 
205
  "Current_Title_Company": candidate_data.get("Current Title & Company", ""),
206
  "Years_of_Experience": candidate_data.get("Years of Experience", ""),
207
  "Degree_University": candidate_data.get("Degree & Education", ""),
208
+ "Key_Tech_Stack": candidate_tech_stack, "Key_Highlights": candidate_data.get("Key Highlights", ""),
209
+ "cand_Location": candidate_data.get("Location", ""), "Experience": candidate_data.get("Experience", "")
 
 
210
  }
211
+ payload_str = json.dumps(payload)
212
+ input_tokens = calculate_tokens(payload_str, st.session_state.model_name)
213
  response = llm_chain.invoke(payload)
214
+ # print(candidate_data.get("Experience", "")) # Kept for your debugging if needed
215
+
216
+ response_str = f"candidate_name: {response.candidate_name} ... fit_score: {float(f'{response.fit_score:.3f}')} ..." # Truncated
217
+ output_tokens = calculate_tokens(response_str, st.session_state.model_name)
218
+
219
+ if 'total_input_tokens' not in st.session_state: st.session_state.total_input_tokens = 0
220
+ if 'total_output_tokens' not in st.session_state: st.session_state.total_output_tokens = 0
221
+ st.session_state.total_input_tokens += input_tokens
222
+ st.session_state.total_output_tokens += output_tokens
223
 
 
224
  return {
225
+ "candidate_name": response.candidate_name, "candidate_url": response.candidate_url,
226
+ "candidate_summary": response.candidate_summary, "candidate_location": response.candidate_location,
227
+ "fit_score": response.fit_score, "justification": response.justification
 
 
 
228
  }
229
  except Exception as e:
230
+ st.error(f"Error calling LLM for {candidate_data.get('Name', 'Unknown')}: {e}")
 
231
  return {
232
+ "candidate_name": candidate_data.get("Name", "Unknown"), "candidate_url": candidate_data.get("URL", ""),
233
+ "candidate_summary": "Error processing candidate profile", "candidate_location": candidate_data.get("Location", "Unknown"),
234
+ "fit_score": 0.0, "justification": f"Error in LLM processing: {str(e)}"
 
 
 
235
  }
236
 
237
  def process_candidates_for_job(job_row, candidates_df, llm_chain=None):
238
+ st.session_state.total_input_tokens = 0 # Reset for this job
239
+ st.session_state.total_output_tokens = 0
240
+
241
  if llm_chain is None:
242
+ with st.spinner("Setting up LLM..."): llm_chain = setup_llm()
 
243
 
244
  selected_candidates = []
245
+ job_data = {
246
+ "Company": job_row["Company"], "Role": job_row["Role"], "desc": job_row.get("One liner", ""),
247
+ "Locations": job_row.get("Locations", ""), "Tech_Stack": job_row["Tech Stack"], "Industry": job_row.get("Industry", "")
248
+ }
249
 
250
+ with st.spinner("Sourcing candidates based on tech stack..."):
251
+ matching_candidates = get_matching_candidates(job_row["Tech Stack"], candidates_df)
252
+
253
+ if not matching_candidates:
254
+ st.warning("No candidates with matching tech stack found for this job.")
255
+ return []
256
+
257
+ st.success(f"Found {len(matching_candidates)} candidates with matching tech stack. Evaluating with LLM...")
258
+
259
+ candidates_progress = st.progress(0)
260
+ candidate_status = st.empty() # For live updates
261
+
262
+ for i, candidate_data in enumerate(matching_candidates):
263
+ # *** MODIFICATION: Check for stop flag ***
264
+ if st.session_state.get('stop_processing_flag', False):
265
+ candidate_status.warning("Processing stopped by user.")
266
+ time.sleep(1) # Allow message to be seen
267
+ break
268
+
269
+ candidate_status.text(f"Evaluating candidate {i+1}/{len(matching_candidates)}: {candidate_data.get('Name', 'Unknown')}")
270
+ response = call_llm(candidate_data, job_data, llm_chain)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
271
 
272
+ response_dict = {
273
+ "Name": response["candidate_name"], "LinkedIn": response["candidate_url"],
274
+ "summary": response["candidate_summary"], "Location": response["candidate_location"],
275
+ "Fit Score": float(f"{response['fit_score']:.3f}"), "justification": response["justification"],
276
+ "Educational Background": candidate_data.get("Degree & Education", ""),
277
+ "Years of Experience": candidate_data.get("Years of Experience", ""),
278
+ "Current Title & Company": candidate_data.get("Current Title & Company", "")
279
+ }
280
 
281
+ # *** MODIFICATION: Live output of candidate dicts - will disappear on rerun after processing ***
282
+ if response["fit_score"] >= 8.800:
283
+ selected_candidates.append(response_dict)
284
+ # This st.markdown will be visible during processing and cleared on the next full script rerun
285
+ # after this processing block finishes or is stopped.
286
+ st.markdown(
287
+ f"**Selected Candidate:** [{response_dict['Name']}]({response_dict['LinkedIn']}) "
288
+ f"(Score: {response_dict['Fit Score']:.3f}, Location: {response_dict['Location']})"
289
+ )
290
+ else:
291
+ # This st.write will also be visible during processing and cleared later.
292
+ st.write(f"Rejected candidate: {response_dict['Name']} with score: {response_dict['Fit Score']:.3f}, Location: {response_dict['Location']})")
293
+ candidates_progress.progress((i + 1) / len(matching_candidates))
294
+
295
+ candidates_progress.empty()
296
+ candidate_status.empty()
297
+
298
+ if not st.session_state.get('stop_processing_flag', False): # Only show if not stopped
299
  if selected_candidates:
300
+ st.success(f"βœ… LLM evaluation complete. Found {len(selected_candidates)} suitable candidates for this job!")
301
  else:
302
+ st.info("LLM evaluation complete. No candidates met the minimum fit score threshold for this job.")
303
+
304
+ return selected_candidates
305
+
 
 
 
306
 
307
  def main():
308
  st.title("πŸ‘¨β€πŸ’» Candidate Matching App")
309
+ if 'processed_jobs' not in st.session_state: st.session_state.processed_jobs = {} # May not be used with new logic
310
+ if 'Selected_Candidates' not in st.session_state: st.session_state.Selected_Candidates = {}
311
+ if 'llm_chain' not in st.session_state: st.session_state.llm_chain = None # Initialize to None
312
+ # *** MODIFICATION: Initialize stop flag ***
313
+ if 'stop_processing_flag' not in st.session_state: st.session_state.stop_processing_flag = False
314
+
315
+
316
+ st.write("This app matches job listings with candidate profiles...")
317
 
 
 
 
 
 
 
 
 
 
 
318
  with st.sidebar:
319
  st.header("API Configuration")
320
+ api_key = st.text_input("Enter OpenAI API Key", type="password", key="api_key_input")
321
  if api_key:
322
  os.environ["OPENAI_API_KEY"] = api_key
323
+ # Initialize LLM chain once API key is set
324
+ if st.session_state.llm_chain is None:
325
+ with st.spinner("Setting up LLM..."):
326
+ st.session_state.llm_chain = setup_llm()
327
+ st.success("API Key set")
328
  else:
329
  st.warning("Please enter OpenAI API Key to use LLM features")
330
+ st.session_state.llm_chain = None # Clear chain if key removed
331
 
332
+
333
+ # ... (rest of your gspread setup) ...
334
+ try:
335
+ SERVICE_ACCOUNT_FILE = 'src/synapse-recruitment-e94255ca76fd.json' # Ensure this path is correct
336
+ SCOPES = ['https://www.googleapis.com/auth/spreadsheets']
337
+ creds = service_account.Credentials.from_service_account_file(SERVICE_ACCOUNT_FILE, scopes=SCOPES)
338
+ gc = gspread.authorize(creds)
339
+ job_sheet = gc.open_by_key('1BZlvbtFyiQ9Pgr_lpepDJua1ZeVEqrCLjssNd6OiG9k')
340
+ candidates_sheet = gc.open_by_key('1u_9o5f0MPHFUSScjEcnA8Lojm4Y9m9LuWhvjYm6ytF4')
341
+ except Exception as e:
342
+ st.error(f"Failed to connect to Google Sheets. Ensure '{SERVICE_ACCOUNT_FILE}' is valid and has permissions. Error: {e}")
343
+ st.stop()
344
+
345
+
346
+ if not os.environ.get("OPENAI_API_KEY"):
347
  st.warning("⚠️ You need to provide an OpenAI API key in the sidebar to use this app.")
348
+ st.stop()
349
+ if st.session_state.llm_chain is None and os.environ.get("OPENAI_API_KEY"):
350
+ with st.spinner("Setting up LLM..."):
351
+ st.session_state.llm_chain = setup_llm()
352
+ st.rerun() # Rerun to ensure LLM is ready for the main display logic
353
 
354
+ try:
355
+ job_worksheet = job_sheet.worksheet('paraform_jobs_formatted')
356
+ job_data = job_worksheet.get_all_values()
357
+ candidate_worksheet = candidates_sheet.worksheet('transformed_candidates_updated')
358
+ candidate_data = candidate_worksheet.get_all_values()
359
+
360
+ jobs_df = pd.DataFrame(job_data[1:], columns=job_data[0]).drop(["Link"], axis=1, errors='ignore')
361
+ jobs_df1 = jobs_df[["Company","Role","One liner","Locations","Tech Stack","Workplace","Industry","YOE"]]
362
+ candidates_df = pd.DataFrame(candidate_data[1:], columns=candidate_data[0]).fillna("Unknown")
363
+ candidates_df.drop_duplicates(subset=['LinkedIn URL'], keep='first', inplace=True)
364
+
365
+ with st.expander("Preview uploaded data"):
366
+ st.subheader("Jobs Data Preview"); st.dataframe(jobs_df1.head(3))
367
+ # st.subheader("Candidates Data Preview"); st.dataframe(candidates_df.head(3))
368
+
369
+ # Column mapping (simplified, ensure your CSVs have these exact names or adjust)
370
+ # candidates_df = candidates_df.rename(columns={...}) # Add if needed
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
371
 
372
+ display_job_selection(jobs_df, candidates_df, job_sheet) # job_sheet is 'sh'
 
 
373
 
374
+ except Exception as e:
375
+ st.error(f"Error processing files or data: {e}")
 
376
  st.divider()
377
 
378
+ def display_job_selection(jobs_df, candidates_df, sh): # 'sh' is the Google Sheets client
379
+ st.subheader("Select a job to Source for potential matches")
380
+ job_options = [f"{row['Role']} at {row['Company']}" for _, row in jobs_df.iterrows()]
381
+
382
+ if not job_options:
383
+ st.warning("No jobs found to display.")
384
+ return
385
 
386
+ selected_job_index = st.selectbox("Jobs:", range(len(job_options)), format_func=lambda x: job_options[x], key="job_selectbox")
 
 
 
387
 
388
+ job_row = jobs_df.iloc[selected_job_index]
389
+ job_row_stack = parse_tech_stack(job_row["Tech Stack"]) # Assuming parse_tech_stack is defined
390
 
391
+ col_job_details_display, _ = st.columns([2,1])
392
+ with col_job_details_display:
393
+ st.subheader(f"Job Details: {job_row['Role']}")
394
+ job_details_dict = {
395
+ "Company": job_row["Company"], "Role": job_row["Role"], "Description": job_row.get("One liner", "N/A"),
396
+ "Locations": job_row.get("Locations", "N/A"), "Industry": job_row.get("Industry", "N/A"),
397
+ "Tech Stack": display_tech_stack(job_row_stack) # Assuming display_tech_stack is defined
398
+ }
399
+ for key, value in job_details_dict.items(): st.markdown(f"**{key}:** {value}")
400
+
401
+ # State keys for the selected job
402
+ job_processed_key = f"job_{selected_job_index}_processed_successfully"
403
+ job_is_processing_key = f"job_{selected_job_index}_is_currently_processing"
404
+
405
+ # Initialize states if they don't exist for this job
406
+ if job_processed_key not in st.session_state: st.session_state[job_processed_key] = False
407
+ if job_is_processing_key not in st.session_state: st.session_state[job_is_processing_key] = False
408
 
409
+ sheet_name = f"{job_row['Role']} at {job_row['Company']}".strip()[:100]
410
+ worksheet_exists = False
411
+ existing_candidates_from_sheet = [] # This will store raw data from sheet
412
+ try:
413
+ cand_worksheet = sh.worksheet(sheet_name)
414
+ worksheet_exists = True
415
+ existing_data = cand_worksheet.get_all_values() # Get all values as list of lists
416
+ if len(existing_data) > 1: # Has data beyond header
417
+ existing_candidates_from_sheet = existing_data # Store raw data
418
+ except gspread.exceptions.WorksheetNotFound:
419
+ pass
420
+
421
+ # --- Processing Control Area ---
422
+ # Show controls if not successfully processed in this session OR if sheet exists (allow re-process/overwrite)
423
+ if not st.session_state.get(job_processed_key, False) or existing_candidates_from_sheet:
424
 
425
+ if existing_candidates_from_sheet and not st.session_state.get(job_is_processing_key, False) and not st.session_state.get(job_processed_key, False):
426
+ st.info(f"Processing ('{sheet_name}')")
427
+
428
+ col_find, col_stop = st.columns(2)
429
+ with col_find:
430
+ if st.button(f"Find Matching Candidates for this Job", key=f"find_btn_{selected_job_index}", disabled=st.session_state.get(job_is_processing_key, False)):
431
+ if not os.environ.get("OPENAI_API_KEY") or st.session_state.llm_chain is None: # Assuming llm_chain is in session_state
432
+ st.error("OpenAI API key not set or LLM not initialized. Please check sidebar.")
433
+ else:
434
+ st.session_state[job_is_processing_key] = True
435
+ st.session_state.stop_processing_flag = False # Reset for new run, assuming stop_processing_flag is used
436
+ st.session_state.Selected_Candidates[selected_job_index] = [] # Clear previous run for this job
437
+ st.session_state[job_processed_key] = False # Mark as not successfully processed yet for this attempt
438
+ st.rerun()
439
 
440
+ with col_stop:
441
+ if st.session_state.get(job_is_processing_key, False): # Show STOP only if "Find" was clicked and currently processing
442
+ if st.button("STOP Processing", key=f"stop_btn_{selected_job_index}"):
443
+ st.session_state.stop_processing_flag = True # Assuming stop_processing_flag is used
444
+ st.warning("Stop request sent. Processing will halt shortly.")
445
+
446
+ # --- Actual Processing Logic ---
447
+ if st.session_state.get(job_is_processing_key, False):
448
+ with st.spinner(f"Sourcing candidates for {job_row['Role']} at {job_row['Company']}..."):
449
+ # Assuming process_candidates_for_job is defined and handles stop_processing_flag
450
+ processed_candidates_list = process_candidates_for_job(
451
+ job_row, candidates_df, st.session_state.llm_chain # Assuming llm_chain from session_state
452
+ )
453
 
454
+ st.session_state[job_is_processing_key] = False # Mark as no longer actively processing
455
+
456
+ if not st.session_state.get('stop_processing_flag', False): # If processing was NOT stopped
457
+ if processed_candidates_list:
458
+ # Ensure Fit Score is float for reliable sorting
459
+ for cand in processed_candidates_list:
460
+ if 'Fit Score' in cand and isinstance(cand['Fit Score'], str):
461
+ try: cand['Fit Score'] = float(cand['Fit Score'])
462
+ except ValueError: cand['Fit Score'] = 0.0 # Default if conversion fails
463
+ elif 'Fit Score' not in cand:
464
+ cand['Fit Score'] = 0.0
465
+
466
+ processed_candidates_list.sort(key=lambda x: x.get("Fit Score", 0.0), reverse=True)
467
+ st.session_state.Selected_Candidates[selected_job_index] = processed_candidates_list
468
+ st.session_state[job_processed_key] = True # Mark as successfully processed
469
+
470
+ # Save to Google Sheet
471
+ try:
472
+ target_worksheet = None
473
+ if not worksheet_exists:
474
+ target_worksheet = sh.add_worksheet(title=sheet_name, rows=max(100, len(processed_candidates_list) + 10), cols=20)
475
+ else:
476
+ target_worksheet = sh.worksheet(sheet_name)
477
+
478
+ headers = list(processed_candidates_list[0].keys())
479
+ # Ensure all values are converted to strings for gspread
480
+ rows_to_write = [headers] + [[str(candidate.get(h, "")) for h in headers] for candidate in processed_candidates_list]
481
+ target_worksheet.clear()
482
+ target_worksheet.update('A1', rows_to_write)
483
+ st.success(f"Results saved to Google Sheet: '{sheet_name}'")
484
+ except Exception as e:
485
+ st.error(f"Error writing to Google Sheet '{sheet_name}': {e}")
486
+ else:
487
+ st.info("No suitable candidates found after processing.")
488
+ st.session_state.Selected_Candidates[selected_job_index] = []
489
+ st.session_state[job_processed_key] = True # Mark as processed, even if no results
490
+ else: # If processing WAS stopped
491
+ st.info("Processing was stopped by user. Results (if any) were not saved. You can try processing again.")
492
+ st.session_state.Selected_Candidates[selected_job_index] = [] # Clear any partial results
493
+ st.session_state[job_processed_key] = False # Not successfully processed
494
 
495
+ st.session_state.pop('stop_processing_flag', None) # Clean up flag
496
+ st.rerun() # Rerun to update UI based on new state
497
+
498
+ # --- Display Results Area ---
499
+ should_display_results_area = False
500
+ final_candidates_to_display = [] # Initialize to ensure it's always defined
501
+
502
+ if st.session_state.get(job_is_processing_key, False):
503
+ should_display_results_area = False # Not if actively processing
504
+ elif st.session_state.get(job_processed_key, False): # If successfully processed in this session
505
+ should_display_results_area = True
506
+ final_candidates_to_display = st.session_state.Selected_Candidates.get(selected_job_index, [])
507
+ elif existing_candidates_from_sheet: # If not processed in this session, but sheet has data
508
+ should_display_results_area = True
509
+ headers = existing_candidates_from_sheet[0]
510
+ parsed_sheet_candidates = []
511
+ for row_idx, row_data in enumerate(existing_candidates_from_sheet[1:]): # Skip header row
512
+ candidate_dict = {}
513
+ for col_idx, header_name in enumerate(headers):
514
+ candidate_dict[header_name] = row_data[col_idx] if col_idx < len(row_data) else None
515
 
516
+ # Convert Fit Score from string to float for consistent handling
517
+ if 'Fit Score' in candidate_dict and isinstance(candidate_dict['Fit Score'], str):
518
+ try:
519
+ candidate_dict['Fit Score'] = float(candidate_dict['Fit Score'])
520
+ except ValueError:
521
+ st.warning(f"Could not convert Fit Score '{candidate_dict['Fit Score']}' to float for candidate in sheet row {row_idx+2}.")
522
+ candidate_dict['Fit Score'] = 0.0 # Default if conversion fails
523
+ elif 'Fit Score' not in candidate_dict:
524
+ candidate_dict['Fit Score'] = 0.0
525
+
526
+
527
+ parsed_sheet_candidates.append(candidate_dict)
528
+ final_candidates_to_display = sorted(parsed_sheet_candidates, key=lambda x: x.get("Fit Score", 0.0), reverse=True)
529
+ if not st.session_state.get(job_processed_key, False): # Inform if loading from sheet and not explicitly processed
530
+ st.info(f"Displaying: '{sheet_name}'.")
531
+
532
+ if should_display_results_area:
533
+ st.subheader("Selected Candidates")
534
 
535
+ # Display token usage if it was just processed (job_processed_key is True and tokens exist)
536
+ if st.session_state.get(job_processed_key, False) and \
537
+ (st.session_state.get('total_input_tokens', 0) > 0 or st.session_state.get('total_output_tokens', 0) > 0):
538
+ display_token_usage() # Assuming display_token_usage is defined
539
+
540
+ if final_candidates_to_display:
541
+ for i, candidate in enumerate(final_candidates_to_display):
542
+ score_display = candidate.get('Fit Score', 'N/A')
543
+ if isinstance(score_display, (float, int)):
544
+ score_display = f"{score_display:.3f}"
545
+ # If score_display is still a string (e.g. 'N/A' or failed float conversion), it will be displayed as is.
546
+
547
+ expander_title = f"{i+1}. {candidate.get('Name', 'N/A')} (Score: {score_display})"
548
+
549
+ with st.expander(expander_title):
550
+ text_to_copy = f"""Candidate: {candidate.get('Name', 'N/A')} (Score: {score_display})
551
+ Summary: {candidate.get('summary', 'N/A')}
552
+ Current: {candidate.get('Current Title & Company', 'N/A')}
553
+ Education: {candidate.get('Educational Background', 'N/A')}
554
+ Experience: {candidate.get('Years of Experience', 'N/A')}
555
+ Location: {candidate.get('Location', 'N/A')}
556
+ LinkedIn: {candidate.get('LinkedIn', 'N/A')}
557
+ Justification: {candidate.get('justification', 'N/A')}
558
+ """
559
+ js_text_to_copy = json.dumps(text_to_copy)
560
+ button_unique_id = f"copy_btn_job{selected_job_index}_cand{i}"
561
+
562
+ copy_button_html = f"""
563
+ <script>
564
+ function copyToClipboard_{button_unique_id}() {{
565
+ const textToCopy = {js_text_to_copy};
566
+ navigator.clipboard.writeText(textToCopy).then(function() {{
567
+ const btn = document.getElementById('{button_unique_id}');
568
+ if (btn) {{ // Check if button exists
569
+ const originalText = btn.innerText;
570
+ btn.innerText = 'Copied!';
571
+ setTimeout(function() {{ btn.innerText = originalText; }}, 1500);
572
+ }}
573
+ }}, function(err) {{
574
+ console.error('Could not copy text: ', err);
575
+ alert('Failed to copy text. Please use Ctrl+C or your browser\\'s copy function.');
576
+ }});
577
+ }}
578
+ </script>
579
+ <button id="{button_unique_id}" onclick="copyToClipboard_{button_unique_id}()">πŸ“‹ Copy Details</button>
580
+ """
581
 
582
+ expander_cols = st.columns([0.82, 0.18])
583
+ with expander_cols[1]:
584
+ st.components.v1.html(copy_button_html, height=40)
 
 
585
 
586
+ with expander_cols[0]:
587
+ st.markdown(f"**Summary:** {candidate.get('summary', 'N/A')}")
588
+ st.markdown(f"**Current:** {candidate.get('Current Title & Company', 'N/A')}")
589
+ st.markdown(f"**Education:** {candidate.get('Educational Background', 'N/A')}")
590
+ st.markdown(f"**Experience:** {candidate.get('Years of Experience', 'N/A')}")
591
+ st.markdown(f"**Location:** {candidate.get('Location', 'N/A')}")
592
+ if 'LinkedIn' in candidate and candidate.get('LinkedIn'):
593
+ st.markdown(f"**[LinkedIn Profile]({candidate['LinkedIn']})**")
594
+ else:
595
+ st.markdown("**LinkedIn Profile:** N/A")
596
 
597
+ if 'justification' in candidate and candidate.get('justification'):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
598
  st.markdown("**Justification:**")
599
  st.info(candidate['justification'])
600
+
601
+ elif st.session_state.get(job_processed_key, False): # Processed but no candidates
602
+ st.info("No candidates met the criteria for this job after processing.")
603
+
604
+ # This "Reset" button is now governed by should_display_results_area
605
+ if st.button("Reset and Process Again", key=f"reset_btn_{selected_job_index}"):
606
+ st.session_state[job_processed_key] = False
607
+ st.session_state.pop(job_is_processing_key, None)
608
+ if selected_job_index in st.session_state.Selected_Candidates:
609
+ del st.session_state.Selected_Candidates[selected_job_index]
610
+ try:
611
+ sh.worksheet(sheet_name).clear()
612
+ st.info(f"Cleared Google Sheet '{sheet_name}' as part of reset.")
613
+ except: pass # Ignore if sheet not found or error
614
+ st.rerun()
615
 
616
  if __name__ == "__main__":
617
+ main()