dhanvanth183 commited on
Commit
1588f28
Β·
verified Β·
1 Parent(s): c341754

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +608 -606
app.py CHANGED
@@ -1,606 +1,608 @@
1
- import streamlit as st
2
- from openai import OpenAI
3
- import os
4
- from dotenv import load_dotenv
5
- from datetime import datetime
6
- import pytz
7
- from reportlab.lib.pagesizes import letter
8
- from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
9
- from reportlab.lib.units import inch
10
- from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, PageBreak
11
- from reportlab.lib.enums import TA_LEFT, TA_JUSTIFY
12
- import io
13
-
14
- # Load environment variables
15
- load_dotenv()
16
-
17
- # Page configuration
18
- st.set_page_config(page_title="AI Resume Assistant", layout="wide")
19
- st.title("πŸ€– AI Resume Assistant")
20
-
21
- # Load API keys from environment variables
22
- openrouter_api_key = os.getenv("OPENROUTER_API_KEY")
23
- openai_api_key = os.getenv("OPENAI_API_KEY")
24
-
25
- # Check if API keys are available
26
- if not openrouter_api_key or not openai_api_key:
27
- st.error("❌ API keys not found. Please set OPENROUTER_API_KEY and OPENAI_API_KEY in your environment variables (.env file).")
28
- st.stop()
29
-
30
- def get_est_timestamp():
31
- """Get current timestamp in EST timezone with format dd-mm-yyyy-HH-MM"""
32
- est = pytz.timezone('US/Eastern')
33
- now = datetime.now(est)
34
- return now.strftime("%d-%m-%Y-%H-%M")
35
-
36
-
37
- def generate_pdf(content, filename):
38
- """Generate PDF from content and return as bytes"""
39
- try:
40
- pdf_buffer = io.BytesIO()
41
- doc = SimpleDocTemplate(
42
- pdf_buffer,
43
- pagesize=letter,
44
- rightMargin=0.75*inch,
45
- leftMargin=0.75*inch,
46
- topMargin=0.75*inch,
47
- bottomMargin=0.75*inch
48
- )
49
-
50
- story = []
51
- styles = getSampleStyleSheet()
52
-
53
- # Custom style for body text
54
- body_style = ParagraphStyle(
55
- 'CustomBody',
56
- parent=styles['Normal'],
57
- fontSize=11,
58
- leading=14,
59
- alignment=TA_JUSTIFY,
60
- spaceAfter=12
61
- )
62
-
63
- # Add content only (no preamble)
64
- # Split content into paragraphs for better formatting
65
- paragraphs = content.split('\n\n')
66
- for para in paragraphs:
67
- if para.strip():
68
- # Replace line breaks with spaces within paragraphs
69
- clean_para = para.replace('\n', ' ').strip()
70
- story.append(Paragraph(clean_para, body_style))
71
-
72
- # Build PDF
73
- doc.build(story)
74
- pdf_buffer.seek(0)
75
- return pdf_buffer.getvalue()
76
-
77
- except Exception as e:
78
- st.error(f"Error generating PDF: {str(e)}")
79
- return None
80
-
81
-
82
- def categorize_input(resume_finder, cover_letter, select_resume, entry_query):
83
- """
84
- Categorize input into one of 4 groups:
85
- - resume_finder: T, F, No Select
86
- - cover_letter: F, T, not No Select
87
- - general_query: F, F, not No Select
88
- - retry: any other combination
89
- """
90
-
91
- if resume_finder and not cover_letter and select_resume == "No Select":
92
- return "resume_finder", None
93
-
94
- elif not resume_finder and cover_letter and select_resume != "No Select":
95
- return "cover_letter", None
96
-
97
- elif not resume_finder and not cover_letter and select_resume != "No Select":
98
- if not entry_query.strip():
99
- return "retry", "Please enter a query for General Query mode."
100
- return "general_query", None
101
-
102
- else:
103
- return "retry", "Please check your entries and try again"
104
-
105
-
106
- def load_portfolio(file_path):
107
- """Load portfolio markdown file"""
108
- try:
109
- full_path = os.path.join(os.path.dirname(__file__), file_path)
110
- with open(full_path, 'r', encoding='utf-8') as f:
111
- return f.read()
112
- except FileNotFoundError:
113
- st.error(f"Portfolio file {file_path} not found!")
114
- return None
115
-
116
-
117
- def handle_resume_finder(job_description, ai_portfolio, ds_portfolio, api_key):
118
- """Handle Resume Finder category using OpenRouter"""
119
-
120
- prompt = f"""You are an expert resume matcher. Analyze the following job description and two portfolios to determine which is the best match.
121
-
122
- IMPORTANT MAPPING:
123
- - If AI_portfolio is most relevant β†’ Resume = Resume_P
124
- - If DS_portfolio is most relevant β†’ Resume = Resume_Dss
125
-
126
- Job Description:
127
- {job_description}
128
-
129
- AI_portfolio (Maps to: Resume_P):
130
- {ai_portfolio}
131
-
132
- DS_portfolio (Maps to: Resume_Dss):
133
- {ds_portfolio}
134
-
135
- Respond ONLY with:
136
- Resume: [Resume_P or Resume_Dss]
137
- Reasoning: [25-30 words explaining the match]
138
-
139
- NO PREAMBLE."""
140
-
141
- try:
142
- client = OpenAI(
143
- base_url="https://openrouter.ai/api/v1",
144
- api_key=api_key,
145
- )
146
-
147
- completion = client.chat.completions.create(
148
- model="openai/gpt-oss-safeguard-20b",
149
- messages=[
150
- {
151
- "role": "user",
152
- "content": prompt
153
- }
154
- ]
155
- )
156
-
157
- response = completion.choices[0].message.content
158
- if response:
159
- return response
160
- else:
161
- st.error("❌ No response received from OpenRouter API")
162
- return None
163
-
164
- except Exception as e:
165
- st.error(f"❌ Error calling OpenRouter API: {str(e)}")
166
- return None
167
-
168
-
169
- def generate_cover_letter_context(job_description, portfolio, api_key):
170
- """Generate company research, role problem analysis, and achievement matching using web search via Perplexity Sonar
171
-
172
- Args:
173
- job_description: The job posting
174
- portfolio: Candidate's resume/portfolio
175
- api_key: OpenRouter API key (used for Perplexity Sonar with web search)
176
-
177
- Returns:
178
- dict: {"company_motivation": str, "role_problem": str, "achievement_section": str}
179
- """
180
-
181
- prompt = f"""You are an expert career strategist researching a company and role to craft authentic, researched-backed cover letter context.
182
-
183
- Your task: Use web search to find SPECIFIC, RECENT company intelligence, then match it to the candidate's achievements.
184
-
185
- REQUIRED WEB SEARCHES:
186
- 1. Recent company moves (funding rounds, product launches, acquisitions, market expansion, hiring momentum)
187
- 2. Current company challenges (what problem are they actively solving?)
188
- 3. Company tech stack / tools they use
189
- 4. Why they're hiring NOW (growth? new product? team expansion?)
190
- 5. Company market position and strategy
191
-
192
- Job Description:
193
- {job_description}
194
-
195
- Candidate's Portfolio:
196
- {portfolio}
197
-
198
- Generate a JSON response with this format (no additional text):
199
- {{
200
- "company_motivation": "2-3 sentences showing specific, researched interest. Reference recent company moves (funding, product launches, market position) OR specific challenge. Format: '[Company name] recently [specific move/challenge], and your focus on [specific need] aligns with my experience building [domain].' Avoid forced connectionsβ€”if authenticity is low, keep motivation minimal.",
201
- "role_problem": "1 sentence defining CORE PROBLEM this role solves for company. Example: 'Improving demand forecasting accuracy for franchisee decision-making' OR 'Building production vision models under real-time latency constraints.'",
202
- "achievement_section": "ONE specific achievement from portfolio solving role_problem (not just relevant). Format: 'Built [X] to solve [problem/constraint], achieving [metric] across [scale].' Example: 'Built self-serve ML agents (FastAPI+LangChain) to reduce business team dependency on Data Engineering by 60% across 150k+ samples.' This must map directly to role_problem."
203
- }}
204
-
205
- REQUIREMENTS FOR AUTHENTICITY:
206
- - company_motivation: Must reference verifiable findings from web search (recent news, funding, product launch, specific challenge)
207
- - role_problem: Explicitly state the core problem extracted from job description + company research
208
- - achievement_section: Must map directly to role_problem with clear cause-effect (not just "relevant to job")
209
- - NO FORCED CONNECTIONS: If no genuine connection exists between candidate achievement and role problem, return empty string rather than forcing weak match
210
- - AUTHENTICITY PRIORITY: A short, genuine motivation beats a longer forced one. Minimize if needed to avoid template-feel.
211
-
212
- Return ONLY the JSON object, no other text."""
213
-
214
- # Use Perplexity Sonar via OpenRouter (has built-in web search)
215
- client = OpenAI(
216
- base_url="https://openrouter.ai/api/v1",
217
- api_key=api_key,
218
- )
219
-
220
- completion = client.chat.completions.create(
221
- model="perplexity/sonar",
222
- messages=[
223
- {
224
- "role": "user",
225
- "content": prompt
226
- }
227
- ]
228
- )
229
-
230
- response_text = completion.choices[0].message.content
231
-
232
- # Parse JSON response
233
- import json
234
- try:
235
- result = json.loads(response_text)
236
- return {
237
- "company_motivation": result.get("company_motivation", ""),
238
- "role_problem": result.get("role_problem", ""),
239
- "achievement_section": result.get("achievement_section", "")
240
- }
241
- except json.JSONDecodeError:
242
- # Fallback if JSON parsing fails
243
- return {
244
- "company_motivation": "",
245
- "role_problem": "",
246
- "achievement_section": ""
247
- }
248
-
249
-
250
- def handle_cover_letter(job_description, portfolio, api_key, company_motivation="", role_problem="", specific_achievement=""):
251
- """Handle Cover Letter category using OpenAI
252
-
253
- Args:
254
- job_description: The job posting
255
- portfolio: Candidate's resume/portfolio
256
- api_key: OpenAI API key
257
- company_motivation: Researched company interest with recent moves/challenges (auto-generated if empty)
258
- role_problem: The core problem this role solves for the company (auto-generated if empty)
259
- specific_achievement: One concrete achievement that solves role_problem (auto-generated if empty)
260
- """
261
-
262
- # Build context sections if provided
263
- motivation_section = ""
264
- if company_motivation.strip():
265
- motivation_section = f"\nCompany Research (Recent Moves/Challenges):\n{company_motivation}"
266
-
267
- problem_section = ""
268
- if role_problem.strip():
269
- problem_section = f"\nRole's Core Problem:\n{role_problem}"
270
-
271
- achievement_section = ""
272
- if specific_achievement.strip():
273
- achievement_section = f"\nAchievement That Solves This Problem:\n{specific_achievement}"
274
-
275
- prompt = f"""You are an expert career coach writing authentic, researched cover letters that prove specific company knowledge and solve real problems.
276
-
277
- Your goal: Write a letter showing you researched THIS company (not a template) and authentically connect your achievements to THEIR specific problem.
278
-
279
- CRITICAL FOUNDATION:
280
- You have three inputs: company research (recent moves/challenges), role problem (what they're hiring to solve), and one matching achievement.
281
- Construct narrative: "Because you [company context] need to [role problem], my experience with [achievement] makes me valuable."
282
-
283
- Cover Letter Structure:
284
- 1. Opening (2-3 sentences): Hook with SPECIFIC company research (recent move, funding, product, market challenge)
285
- - NOT: "I'm interested in your company"
286
- - YES: "Your recent expansion to [X markets] and focus on [tech] align with my experience"
287
-
288
- 2. Middle (4-5 sentences):
289
- - State role's core problem (what you understand they're hiring to solve)
290
- - Connect achievement DIRECTLY to that problem (show cause-effect)
291
- - Reference job description specifics your achievement addresses
292
- - Show understanding of their constraint/challenge
293
-
294
- 3. Closing (1-2 sentences): Express genuine enthusiasm about solving THIS specific problem
295
-
296
- CRITICAL REQUIREMENTS:
297
- - RESEARCH PROOF: Opening must show specific company knowledge (recent news, not generic mission)
298
- - PROBLEM CLARITY: Explicitly state what problem you're solving for them
299
- - SPECIFIC MAPPING: Achievement β†’ Role Problem β†’ Company Need (clear cause-effect chain)
300
- - NO TEMPLATE: Varied sentence length, conversational tone, human voice
301
- - NO FORCED CONNECTIONS: If something doesn't link cleanly, leave it out
302
- - NO FLUFF: Every sentence serves a purpose (authentic < complete)
303
- - NO SALARY TALK: Omit expectations or negotiations
304
- - NO CORPORATE JARGON: Write like a real human
305
- - NO EM DASHES: Use commas or separate sentences
306
-
307
- Formatting:
308
- - Start: "Dear Hiring Manager,"
309
- - End: "Best,\nDhanvanth Voona" (on separate lines)
310
- - Max 250 words
311
- - NO PREAMBLE (start directly)
312
- - Multiple short paragraphs OK
313
-
314
- Context for Writing:
315
- Resume:
316
- {portfolio}
317
-
318
- Job Description:
319
- {job_description}{motivation_section}{problem_section}{achievement_section}
320
-
321
- Response (Max 250 words, researched + authentic tone):"""
322
-
323
- client = OpenAI(api_key=api_key)
324
-
325
- completion = client.chat.completions.create(
326
- model="gpt-5-mini-2025-08-07",
327
- messages=[
328
- {
329
- "role": "user",
330
- "content": prompt
331
- }
332
- ]
333
- )
334
-
335
- response = completion.choices[0].message.content
336
- return response
337
-
338
-
339
- def handle_general_query(job_description, portfolio, query, length, api_key):
340
- """Handle General Query category using OpenAI"""
341
-
342
- word_count_map = {
343
- "short": "40-60",
344
- "medium": "80-100",
345
- "long": "120-150"
346
- }
347
-
348
- word_count = word_count_map.get(length, "40-60")
349
-
350
- prompt = f"""You are an expert career consultant helping a candidate answer application questions with authentic, tailored responses.
351
-
352
- Your task: Answer the query authentically using ONLY genuine connections between the candidate's experience and the job context.
353
-
354
- Word Count Strategy (Important - Read Carefully):
355
- - Target: {word_count} words MAXIMUM
356
- - Adaptive: Use fewer words if the query can be answered completely and convincingly with fewer words
357
- - Examples: "What is your greatest strength?" might need only 45 words. "Why our company?" needs 85-100 words to show genuine research
358
- - NEVER force content to hit word count targets - prioritize authentic connection over word count
359
-
360
- Connection Quality Guidelines:
361
- - Extract key company values/needs from job description
362
- - Find 1-2 direct experiences from resume that align with these
363
- - Show cause-and-effect: "Because you need X, my experience with Y makes me valuable"
364
- - If connection is weak or forced, acknowledge limitations honestly
365
- - Avoid generic statements - every sentence should reference either the job, company, or specific experience
366
-
367
- Requirements:
368
- - Answer naturally as if written by the candidate
369
- - Start directly with the answer (NO PREAMBLE or "Let me tell you...")
370
- - Response must be directly usable in an application
371
- - Make it engaging and personalized, not templated
372
- - STRICTLY NO EM DASHES
373
- - One authentic connection beats three forced ones
374
-
375
- Resume:
376
- {portfolio}
377
-
378
- Job Description:
379
- {job_description}
380
-
381
- Query:
382
- {query}
383
-
384
- Response (Max {word_count} words, use fewer if appropriate):"""
385
-
386
- client = OpenAI(api_key=api_key)
387
-
388
- completion = client.chat.completions.create(
389
- model="gpt-5-mini-2025-08-07",
390
- messages=[
391
- {
392
- "role": "user",
393
- "content": prompt
394
- }
395
- ]
396
- )
397
-
398
- response = completion.choices[0].message.content
399
- return response
400
-
401
-
402
- # Main input section
403
- st.header("πŸ“‹ Input Form")
404
-
405
- # Create columns for better layout
406
- col1, col2 = st.columns(2)
407
-
408
- with col1:
409
- job_description = st.text_area(
410
- "Job Description (Required)*",
411
- placeholder="Paste the job description here...",
412
- height=150
413
- )
414
-
415
- with col2:
416
- st.subheader("Options")
417
- resume_finder = st.checkbox("Resume Finder", value=False)
418
- cover_letter = st.checkbox("Cover Letter", value=False)
419
-
420
- # Length of Resume
421
- length_options = {
422
- "Short (40-60 words)": "short",
423
- "Medium (80-100 words)": "medium",
424
- "Long (120-150 words)": "long"
425
- }
426
- length_of_resume = st.selectbox(
427
- "Length of Resume",
428
- options=list(length_options.keys()),
429
- index=0
430
- )
431
- length_value = length_options[length_of_resume]
432
-
433
- # Select Resume dropdown
434
- resume_options = ["No Select", "Resume_P", "Resume_Dss"]
435
- select_resume = st.selectbox(
436
- "Select Resume",
437
- options=resume_options,
438
- index=0
439
- )
440
-
441
- # Entry Query
442
- entry_query = st.text_area(
443
- "Entry Query (Optional)",
444
- placeholder="Ask any question related to your application...",
445
- max_chars=5000,
446
- height=100
447
- )
448
-
449
- # Submit button
450
- if st.button("πŸš€ Generate", type="primary", use_container_width=True):
451
- # Validate job description
452
- if not job_description.strip():
453
- st.error("❌ Job Description is required!")
454
- st.stop()
455
-
456
- # Categorize input
457
- category, error_message = categorize_input(
458
- resume_finder, cover_letter, select_resume, entry_query
459
- )
460
-
461
- if category == "retry":
462
- st.warning(f"⚠️ {error_message}")
463
- else:
464
- st.header("πŸ“€ Response")
465
-
466
- # Debug info (can be removed later)
467
- with st.expander("πŸ“Š Debug Info"):
468
- st.write(f"**Category:** {category}")
469
- st.write(f"**Resume Finder:** {resume_finder}")
470
- st.write(f"**Cover Letter:** {cover_letter}")
471
- st.write(f"**Select Resume:** {select_resume}")
472
- st.write(f"**Has Query:** {bool(entry_query.strip())}")
473
- st.write(f"**OpenAI API Key Set:** {'βœ… Yes' if openai_api_key else '❌ No'}")
474
- st.write(f"**OpenRouter API Key Set:** {'βœ… Yes' if openrouter_api_key else '❌ No'}")
475
- st.write(f"**OpenAI Key First 10 chars:** {openai_api_key[:10] + '...' if openai_api_key else 'N/A'}")
476
- st.write(f"**OpenRouter Key First 10 chars:** {openrouter_api_key[:10] + '...' if openrouter_api_key else 'N/A'}")
477
-
478
- # Load portfolios
479
- ai_portfolio = load_portfolio("AI_portfolio.md")
480
- ds_portfolio = load_portfolio("DS_portfolio.md")
481
-
482
- if ai_portfolio is None or ds_portfolio is None:
483
- st.stop()
484
-
485
- response = None
486
- error_occurred = None
487
-
488
- if category == "resume_finder":
489
- with st.spinner("πŸ” Finding the best resume for you..."):
490
- try:
491
- response = handle_resume_finder(
492
- job_description, ai_portfolio, ds_portfolio, openrouter_api_key
493
- )
494
- except Exception as e:
495
- error_occurred = f"Resume Finder Error: {str(e)}"
496
-
497
- elif category == "cover_letter":
498
- selected_portfolio = ai_portfolio if select_resume == "Resume_P" else ds_portfolio
499
-
500
- # Generate company motivation and achievement section
501
- st.info("πŸ” Analyzing company and generating personalized context with web search...")
502
- context_placeholder = st.empty()
503
-
504
- try:
505
- context_placeholder.info("πŸ“Š Researching company, analyzing role, and matching achievements (with web search)...")
506
- context = generate_cover_letter_context(job_description, selected_portfolio, openrouter_api_key)
507
- company_motivation = context.get("company_motivation", "")
508
- role_problem = context.get("role_problem", "")
509
- specific_achievement = context.get("achievement_section", "")
510
- context_placeholder.success("βœ… Company research and achievement matching complete!")
511
- except Exception as e:
512
- error_occurred = f"Context Generation Error: {str(e)}"
513
- context_placeholder.error(f"❌ Failed to generate context: {str(e)}")
514
- st.info("πŸ’‘ Proceeding with cover letter generation without auto-generated context...")
515
- company_motivation = ""
516
- role_problem = ""
517
- specific_achievement = ""
518
-
519
- # Now generate the cover letter
520
- with st.spinner("✍️ Generating your cover letter..."):
521
- try:
522
- response = handle_cover_letter(
523
- job_description, selected_portfolio, openai_api_key,
524
- company_motivation=company_motivation,
525
- role_problem=role_problem,
526
- specific_achievement=specific_achievement
527
- )
528
- except Exception as e:
529
- error_occurred = f"Cover Letter Error: {str(e)}"
530
-
531
- elif category == "general_query":
532
- selected_portfolio = ai_portfolio if select_resume == "Resume_P" else ds_portfolio
533
- with st.spinner("πŸ’­ Crafting your response..."):
534
- try:
535
- response = handle_general_query(
536
- job_description, selected_portfolio, entry_query,
537
- length_value, openai_api_key
538
- )
539
- except Exception as e:
540
- error_occurred = f"General Query Error: {str(e)}"
541
-
542
- # Display error if one occurred
543
- if error_occurred:
544
- st.error(f"❌ {error_occurred}")
545
- st.info("πŸ’‘ **Troubleshooting Tips:**\n- Check your API keys in the .env file\n- Verify your API key has sufficient credits/permissions\n- Ensure the model name is correct for your API tier")
546
-
547
- # Store response in session state only if new response generated
548
- if response:
549
- st.session_state.edited_response = response
550
- st.session_state.editing = False
551
- elif not error_occurred:
552
- st.error("❌ Failed to generate response. Please check the error messages above and try again.")
553
-
554
- # Display stored response if available (persists across button clicks)
555
- if "edited_response" in st.session_state and st.session_state.edited_response:
556
- st.header("πŸ“€ Response")
557
-
558
- # Toggle edit mode
559
- col_response, col_buttons = st.columns([3, 1])
560
-
561
- with col_buttons:
562
- if st.button("✏️ Edit", key="edit_btn", use_container_width=True):
563
- st.session_state.editing = not st.session_state.editing
564
-
565
- # Display response or edit area
566
- if st.session_state.editing:
567
- st.session_state.edited_response = st.text_area(
568
- "Edit your response:",
569
- value=st.session_state.edited_response,
570
- height=250,
571
- key="response_editor"
572
- )
573
-
574
- col_save, col_cancel = st.columns(2)
575
- with col_save:
576
- if st.button("πŸ’Ύ Save Changes", use_container_width=True):
577
- st.session_state.editing = False
578
- st.success("βœ… Response updated!")
579
- st.rerun()
580
-
581
- with col_cancel:
582
- if st.button("❌ Cancel", use_container_width=True):
583
- st.session_state.editing = False
584
- st.rerun()
585
- else:
586
- # Display the response
587
- st.success(st.session_state.edited_response)
588
-
589
- # Download PDF button
590
- timestamp = get_est_timestamp()
591
- pdf_filename = f"Dhanvanth_{timestamp}.pdf"
592
-
593
- pdf_content = generate_pdf(st.session_state.edited_response, pdf_filename)
594
- if pdf_content:
595
- st.download_button(
596
- label="πŸ“₯ Download as PDF",
597
- data=pdf_content,
598
- file_name=pdf_filename,
599
- mime="application/pdf",
600
- use_container_width=True
601
- )
602
-
603
- st.markdown("---")
604
- st.markdown(
605
- "Say Hi to Griva thalli from her mama ❀️"
606
- )
 
 
 
1
+ import streamlit as st
2
+ from openai import OpenAI
3
+ import os
4
+ from dotenv import load_dotenv
5
+ from datetime import datetime
6
+ import pytz
7
+ from reportlab.lib.pagesizes import letter
8
+ from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
9
+ from reportlab.lib.units import inch
10
+ from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, PageBreak
11
+ from reportlab.lib.enums import TA_LEFT, TA_JUSTIFY
12
+ import io
13
+
14
+ # Load environment variables
15
+ load_dotenv()
16
+
17
+ # Page configuration
18
+ st.set_page_config(page_title="AI Resume Assistant", layout="wide")
19
+ st.title("πŸ€– AI Resume Assistant")
20
+
21
+ # Load API keys from environment variables
22
+ openrouter_api_key = os.getenv("OPENROUTER_API_KEY")
23
+ openai_api_key = os.getenv("OPENAI_API_KEY")
24
+
25
+ # Check if API keys are available
26
+ if not openrouter_api_key or not openai_api_key:
27
+ st.error("❌ API keys not found. Please set OPENROUTER_API_KEY and OPENAI_API_KEY in your environment variables (.env file).")
28
+ st.stop()
29
+
30
+ def get_est_timestamp():
31
+ """Get current timestamp in EST timezone with format dd-mm-yyyy-HH-MM"""
32
+ est = pytz.timezone('US/Eastern')
33
+ now = datetime.now(est)
34
+ return now.strftime("%d-%m-%Y-%H-%M")
35
+
36
+
37
+ def generate_pdf(content, filename):
38
+ """Generate PDF from content and return as bytes"""
39
+ try:
40
+ pdf_buffer = io.BytesIO()
41
+ doc = SimpleDocTemplate(
42
+ pdf_buffer,
43
+ pagesize=letter,
44
+ rightMargin=0.75*inch,
45
+ leftMargin=0.75*inch,
46
+ topMargin=0.75*inch,
47
+ bottomMargin=0.75*inch
48
+ )
49
+
50
+ story = []
51
+ styles = getSampleStyleSheet()
52
+
53
+ # Custom style for body text
54
+ body_style = ParagraphStyle(
55
+ 'CustomBody',
56
+ parent=styles['Normal'],
57
+ fontSize=11,
58
+ leading=14,
59
+ alignment=TA_JUSTIFY,
60
+ spaceAfter=12
61
+ )
62
+
63
+ # Add content only (no preamble)
64
+ # Split content into paragraphs for better formatting
65
+ paragraphs = content.split('\n\n')
66
+ for para in paragraphs:
67
+ if para.strip():
68
+ # Replace line breaks with spaces within paragraphs
69
+ clean_para = para.replace('\n', ' ').strip()
70
+ story.append(Paragraph(clean_para, body_style))
71
+
72
+ # Build PDF
73
+ doc.build(story)
74
+ pdf_buffer.seek(0)
75
+ return pdf_buffer.getvalue()
76
+
77
+ except Exception as e:
78
+ st.error(f"Error generating PDF: {str(e)}")
79
+ return None
80
+
81
+
82
+ def categorize_input(resume_finder, cover_letter, select_resume, entry_query):
83
+ """
84
+ Categorize input into one of 4 groups:
85
+ - resume_finder: T, F, No Select
86
+ - cover_letter: F, T, not No Select
87
+ - general_query: F, F, not No Select
88
+ - retry: any other combination
89
+ """
90
+
91
+ if resume_finder and not cover_letter and select_resume == "No Select":
92
+ return "resume_finder", None
93
+
94
+ elif not resume_finder and cover_letter and select_resume != "No Select":
95
+ return "cover_letter", None
96
+
97
+ elif not resume_finder and not cover_letter and select_resume != "No Select":
98
+ if not entry_query.strip():
99
+ return "retry", "Please enter a query for General Query mode."
100
+ return "general_query", None
101
+
102
+ else:
103
+ return "retry", "Please check your entries and try again"
104
+
105
+
106
+ def load_portfolio(file_path):
107
+ """Load portfolio markdown file"""
108
+ try:
109
+ full_path = os.path.join(os.path.dirname(__file__), file_path)
110
+ with open(full_path, 'r', encoding='utf-8') as f:
111
+ return f.read()
112
+ except FileNotFoundError:
113
+ st.error(f"Portfolio file {file_path} not found!")
114
+ return None
115
+
116
+
117
+ def handle_resume_finder(job_description, ai_portfolio, ds_portfolio, api_key):
118
+ """Handle Resume Finder category using OpenRouter"""
119
+
120
+ prompt = f"""You are an expert resume matcher. Analyze the following job description and two portfolios to determine which is the best match.
121
+
122
+ IMPORTANT MAPPING:
123
+ - If AI_portfolio is most relevant β†’ Resume = Resume_P
124
+ - If DS_portfolio is most relevant β†’ Resume = Resume_Dss
125
+
126
+ Job Description:
127
+ {job_description}
128
+
129
+ AI_portfolio (Maps to: Resume_P):
130
+ {ai_portfolio}
131
+
132
+ DS_portfolio (Maps to: Resume_Dss):
133
+ {ds_portfolio}
134
+
135
+ Respond ONLY with:
136
+ Resume: [Resume_P or Resume_Dss]
137
+ Reasoning: [25-30 words explaining the match]
138
+
139
+ NO PREAMBLE."""
140
+
141
+ try:
142
+ client = OpenAI(
143
+ base_url="https://openrouter.ai/api/v1",
144
+ api_key=api_key,
145
+ )
146
+
147
+ completion = client.chat.completions.create(
148
+ model="openai/gpt-oss-safeguard-20b",
149
+ messages=[
150
+ {
151
+ "role": "user",
152
+ "content": prompt
153
+ }
154
+ ]
155
+ )
156
+
157
+ response = completion.choices[0].message.content
158
+ if response:
159
+ return response
160
+ else:
161
+ st.error("❌ No response received from OpenRouter API")
162
+ return None
163
+
164
+ except Exception as e:
165
+ st.error(f"❌ Error calling OpenRouter API: {str(e)}")
166
+ return None
167
+
168
+
169
+ def generate_cover_letter_context(job_description, portfolio, api_key):
170
+ """Generate company research, role problem analysis, and achievement matching using web search via Perplexity Sonar
171
+
172
+ Args:
173
+ job_description: The job posting
174
+ portfolio: Candidate's resume/portfolio
175
+ api_key: OpenRouter API key (used for Perplexity Sonar with web search)
176
+
177
+ Returns:
178
+ dict: {"company_motivation": str, "role_problem": str, "achievement_section": str}
179
+ """
180
+
181
+ prompt = f"""You are an expert career strategist researching a company and role to craft authentic, researched-backed cover letter context.
182
+
183
+ Your task: Use web search to find SPECIFIC, RECENT company intelligence, then match it to the candidate's achievements.
184
+
185
+ REQUIRED WEB SEARCHES:
186
+ 1. Recent company moves (funding rounds, product launches, acquisitions, market expansion, hiring momentum)
187
+ 2. Current company challenges (what problem are they actively solving?)
188
+ 3. Company tech stack / tools they use
189
+ 4. Why they're hiring NOW (growth? new product? team expansion?)
190
+ 5. Company market position and strategy
191
+
192
+ Job Description:
193
+ {job_description}
194
+
195
+ Candidate's Portfolio:
196
+ {portfolio}
197
+
198
+ Generate a JSON response with this format (no additional text):
199
+ {{
200
+ "company_motivation": "2-3 sentences showing specific, researched interest. Reference recent company moves (funding, product launches, market position) OR specific challenge. Format: '[Company name] recently [specific move/challenge], and your focus on [specific need] aligns with my experience building [domain].' Avoid forced connectionsβ€”if authenticity is low, keep motivation minimal.",
201
+ "role_problem": "1 sentence defining CORE PROBLEM this role solves for company. Example: 'Improving demand forecasting accuracy for franchisee decision-making' OR 'Building production vision models under real-time latency constraints.'",
202
+ "achievement_section": "ONE specific achievement from portfolio solving role_problem (not just relevant). Format: 'Built [X] to solve [problem/constraint], achieving [metric] across [scale].' Example: 'Built self-serve ML agents (FastAPI+LangChain) to reduce business team dependency on Data Engineering by 60% across 150k+ samples.' This must map directly to role_problem."
203
+ }}
204
+
205
+ REQUIREMENTS FOR AUTHENTICITY:
206
+ - company_motivation: Must reference verifiable findings from web search (recent news, funding, product launch, specific challenge)
207
+ - role_problem: Explicitly state the core problem extracted from job description + company research
208
+ - achievement_section: Must map directly to role_problem with clear cause-effect (not just "relevant to job")
209
+ - NO FORCED CONNECTIONS: If no genuine connection exists between candidate achievement and role problem, return empty string rather than forcing weak match
210
+ - AUTHENTICITY PRIORITY: A short, genuine motivation beats a longer forced one. Minimize if needed to avoid template-feel.
211
+
212
+ Return ONLY the JSON object, no other text."""
213
+
214
+ # Use Perplexity Sonar via OpenRouter (has built-in web search)
215
+ client = OpenAI(
216
+ base_url="https://openrouter.ai/api/v1",
217
+ api_key=api_key,
218
+ )
219
+
220
+ completion = client.chat.completions.create(
221
+ model="perplexity/sonar",
222
+ messages=[
223
+ {
224
+ "role": "user",
225
+ "content": prompt
226
+ }
227
+ ]
228
+ )
229
+
230
+ response_text = completion.choices[0].message.content
231
+
232
+ # Parse JSON response
233
+ import json
234
+ try:
235
+ result = json.loads(response_text)
236
+ return {
237
+ "company_motivation": result.get("company_motivation", ""),
238
+ "role_problem": result.get("role_problem", ""),
239
+ "achievement_section": result.get("achievement_section", "")
240
+ }
241
+ except json.JSONDecodeError:
242
+ # Fallback if JSON parsing fails
243
+ return {
244
+ "company_motivation": "",
245
+ "role_problem": "",
246
+ "achievement_section": ""
247
+ }
248
+
249
+
250
+ def handle_cover_letter(job_description, portfolio, api_key, company_motivation="", role_problem="", specific_achievement=""):
251
+ """Handle Cover Letter category using OpenAI
252
+
253
+ Args:
254
+ job_description: The job posting
255
+ portfolio: Candidate's resume/portfolio
256
+ api_key: OpenAI API key
257
+ company_motivation: Researched company interest with recent moves/challenges (auto-generated if empty)
258
+ role_problem: The core problem this role solves for the company (auto-generated if empty)
259
+ specific_achievement: One concrete achievement that solves role_problem (auto-generated if empty)
260
+ """
261
+
262
+ # Build context sections if provided
263
+ motivation_section = ""
264
+ if company_motivation.strip():
265
+ motivation_section = f"\nCompany Research (Recent Moves/Challenges):\n{company_motivation}"
266
+
267
+ problem_section = ""
268
+ if role_problem.strip():
269
+ problem_section = f"\nRole's Core Problem:\n{role_problem}"
270
+
271
+ achievement_section = ""
272
+ if specific_achievement.strip():
273
+ achievement_section = f"\nAchievement That Solves This Problem:\n{specific_achievement}"
274
+
275
+ prompt = f"""You are an expert career coach writing authentic, researched cover letters that prove specific company knowledge and solve real problems.
276
+
277
+ Your goal: Write a letter showing you researched THIS company (not a template) and authentically connect your achievements to THEIR specific problem.
278
+
279
+ CRITICAL FOUNDATION:
280
+ You have three inputs: company research (recent moves/challenges), role problem (what they're hiring to solve), and one matching achievement.
281
+ Construct narrative: "Because you [company context] need to [role problem], my experience with [achievement] makes me valuable."
282
+
283
+ Cover Letter Structure:
284
+ 1. Opening (2-3 sentences): Hook with SPECIFIC company research (recent move, funding, product, market challenge)
285
+ - NOT: "I'm interested in your company"
286
+ - YES: "Your recent expansion to [X markets] and focus on [tech] align with my experience"
287
+
288
+ 2. Middle (4-5 sentences):
289
+ - State role's core problem (what you understand they're hiring to solve)
290
+ - Connect achievement DIRECTLY to that problem (show cause-effect)
291
+ - Reference job description specifics your achievement addresses
292
+ - Show understanding of their constraint/challenge
293
+
294
+ 3. Closing (1-2 sentences): Express genuine enthusiasm about solving THIS specific problem
295
+
296
+ CRITICAL REQUIREMENTS:
297
+ - RESEARCH PROOF: Opening must show specific company knowledge (recent news, not generic mission)
298
+ - PROBLEM CLARITY: Explicitly state what problem you're solving for them
299
+ - SPECIFIC MAPPING: Achievement β†’ Role Problem β†’ Company Need (clear cause-effect chain)
300
+ - NO TEMPLATE: Varied sentence length, conversational tone, human voice
301
+ - NO FORCED CONNECTIONS: If something doesn't link cleanly, leave it out
302
+ - NO FLUFF: Every sentence serves a purpose (authentic < complete)
303
+ - NO SALARY TALK: Omit expectations or negotiations
304
+ - NO CORPORATE JARGON: Write like a real human
305
+ - NO EM DASHES: Use commas or separate sentences
306
+
307
+ Formatting:
308
+ - Start: "Dear Hiring Manager,"
309
+ - End: "Best,\nDhanvanth Voona" (on separate lines)
310
+ - Max 250 words
311
+ - NO PREAMBLE (start directly)
312
+ - Multiple short paragraphs OK
313
+
314
+ Context for Writing:
315
+ Resume:
316
+ {portfolio}
317
+
318
+ Job Description:
319
+ {job_description}{motivation_section}{problem_section}{achievement_section}
320
+
321
+ Response (Max 250 words, researched + authentic tone):"""
322
+
323
+ client = OpenAI(api_key=api_key)
324
+
325
+ completion = client.chat.completions.create(
326
+ model="gpt-5-mini-2025-08-07",
327
+ messages=[
328
+ {
329
+ "role": "user",
330
+ "content": prompt
331
+ }
332
+ ]
333
+ )
334
+
335
+ response = completion.choices[0].message.content
336
+ return response
337
+
338
+
339
+ def handle_general_query(job_description, portfolio, query, length, api_key):
340
+ """Handle General Query category using OpenAI"""
341
+
342
+ word_count_map = {
343
+ "short": "40-60",
344
+ "medium": "80-100",
345
+ "long": "120-150"
346
+ }
347
+
348
+ word_count = word_count_map.get(length, "40-60")
349
+
350
+ prompt = f"""You are an expert career consultant helping a candidate answer application questions with authentic, tailored responses.
351
+
352
+ Your task: Answer the query authentically using ONLY genuine connections between the candidate's experience and the job context.
353
+
354
+ Word Count Strategy (Important - Read Carefully):
355
+ - Target: {word_count} words MAXIMUM
356
+ - Adaptive: Use fewer words if the query can be answered completely and convincingly with fewer words
357
+ - Examples: "What is your greatest strength?" might need only 45 words. "Why our company?" needs 85-100 words to show genuine research
358
+ - NEVER force content to hit word count targets - prioritize authentic connection over word count
359
+
360
+ Connection Quality Guidelines:
361
+ - Extract key company values/needs, salary ranges from job description
362
+ - Find 1-2 direct experiences from resume that align with these
363
+ - Show cause-and-effect: "Because you need X, my experience with Y makes me valuable"
364
+ - If connection is weak or forced, acknowledge limitations honestly
365
+ - Avoid generic statements - every sentence should reference either the job, company, or specific experience
366
+ - For questions related to salary, use the same salary ranges if provided in job description, ONLY if you could not extract salary from
367
+ job description, use the salary range given in portfolio.
368
+
369
+ Requirements:
370
+ - Answer naturally as if written by the candidate
371
+ - Start directly with the answer (NO PREAMBLE or "Let me tell you...")
372
+ - Response must be directly usable in an application
373
+ - Make it engaging and personalized, not templated
374
+ - STRICTLY NO EM DASHES
375
+ - One authentic connection beats three forced ones
376
+
377
+ Resume:
378
+ {portfolio}
379
+
380
+ Job Description:
381
+ {job_description}
382
+
383
+ Query:
384
+ {query}
385
+
386
+ Response (Max {word_count} words, use fewer if appropriate):"""
387
+
388
+ client = OpenAI(api_key=api_key)
389
+
390
+ completion = client.chat.completions.create(
391
+ model="gpt-5-mini-2025-08-07",
392
+ messages=[
393
+ {
394
+ "role": "user",
395
+ "content": prompt
396
+ }
397
+ ]
398
+ )
399
+
400
+ response = completion.choices[0].message.content
401
+ return response
402
+
403
+
404
+ # Main input section
405
+ st.header("πŸ“‹ Input Form")
406
+
407
+ # Create columns for better layout
408
+ col1, col2 = st.columns(2)
409
+
410
+ with col1:
411
+ job_description = st.text_area(
412
+ "Job Description (Required)*",
413
+ placeholder="Paste the job description here...",
414
+ height=150
415
+ )
416
+
417
+ with col2:
418
+ st.subheader("Options")
419
+ resume_finder = st.checkbox("Resume Finder", value=False)
420
+ cover_letter = st.checkbox("Cover Letter", value=False)
421
+
422
+ # Length of Resume
423
+ length_options = {
424
+ "Short (40-60 words)": "short",
425
+ "Medium (80-100 words)": "medium",
426
+ "Long (120-150 words)": "long"
427
+ }
428
+ length_of_resume = st.selectbox(
429
+ "Length of Resume",
430
+ options=list(length_options.keys()),
431
+ index=0
432
+ )
433
+ length_value = length_options[length_of_resume]
434
+
435
+ # Select Resume dropdown
436
+ resume_options = ["No Select", "Resume_P", "Resume_Dss"]
437
+ select_resume = st.selectbox(
438
+ "Select Resume",
439
+ options=resume_options,
440
+ index=0
441
+ )
442
+
443
+ # Entry Query
444
+ entry_query = st.text_area(
445
+ "Entry Query (Optional)",
446
+ placeholder="Ask any question related to your application...",
447
+ max_chars=5000,
448
+ height=100
449
+ )
450
+
451
+ # Submit button
452
+ if st.button("πŸš€ Generate", type="primary", use_container_width=True):
453
+ # Validate job description
454
+ if not job_description.strip():
455
+ st.error("❌ Job Description is required!")
456
+ st.stop()
457
+
458
+ # Categorize input
459
+ category, error_message = categorize_input(
460
+ resume_finder, cover_letter, select_resume, entry_query
461
+ )
462
+
463
+ if category == "retry":
464
+ st.warning(f"⚠️ {error_message}")
465
+ else:
466
+ st.header("πŸ“€ Response")
467
+
468
+ # Debug info (can be removed later)
469
+ with st.expander("πŸ“Š Debug Info"):
470
+ st.write(f"**Category:** {category}")
471
+ st.write(f"**Resume Finder:** {resume_finder}")
472
+ st.write(f"**Cover Letter:** {cover_letter}")
473
+ st.write(f"**Select Resume:** {select_resume}")
474
+ st.write(f"**Has Query:** {bool(entry_query.strip())}")
475
+ st.write(f"**OpenAI API Key Set:** {'βœ… Yes' if openai_api_key else '❌ No'}")
476
+ st.write(f"**OpenRouter API Key Set:** {'βœ… Yes' if openrouter_api_key else '❌ No'}")
477
+ st.write(f"**OpenAI Key First 10 chars:** {openai_api_key[:10] + '...' if openai_api_key else 'N/A'}")
478
+ st.write(f"**OpenRouter Key First 10 chars:** {openrouter_api_key[:10] + '...' if openrouter_api_key else 'N/A'}")
479
+
480
+ # Load portfolios
481
+ ai_portfolio = load_portfolio("AI_portfolio.md")
482
+ ds_portfolio = load_portfolio("DS_portfolio.md")
483
+
484
+ if ai_portfolio is None or ds_portfolio is None:
485
+ st.stop()
486
+
487
+ response = None
488
+ error_occurred = None
489
+
490
+ if category == "resume_finder":
491
+ with st.spinner("πŸ” Finding the best resume for you..."):
492
+ try:
493
+ response = handle_resume_finder(
494
+ job_description, ai_portfolio, ds_portfolio, openrouter_api_key
495
+ )
496
+ except Exception as e:
497
+ error_occurred = f"Resume Finder Error: {str(e)}"
498
+
499
+ elif category == "cover_letter":
500
+ selected_portfolio = ai_portfolio if select_resume == "Resume_P" else ds_portfolio
501
+
502
+ # Generate company motivation and achievement section
503
+ st.info("πŸ” Analyzing company and generating personalized context with web search...")
504
+ context_placeholder = st.empty()
505
+
506
+ try:
507
+ context_placeholder.info("πŸ“Š Researching company, analyzing role, and matching achievements (with web search)...")
508
+ context = generate_cover_letter_context(job_description, selected_portfolio, openrouter_api_key)
509
+ company_motivation = context.get("company_motivation", "")
510
+ role_problem = context.get("role_problem", "")
511
+ specific_achievement = context.get("achievement_section", "")
512
+ context_placeholder.success("βœ… Company research and achievement matching complete!")
513
+ except Exception as e:
514
+ error_occurred = f"Context Generation Error: {str(e)}"
515
+ context_placeholder.error(f"❌ Failed to generate context: {str(e)}")
516
+ st.info("πŸ’‘ Proceeding with cover letter generation without auto-generated context...")
517
+ company_motivation = ""
518
+ role_problem = ""
519
+ specific_achievement = ""
520
+
521
+ # Now generate the cover letter
522
+ with st.spinner("✍️ Generating your cover letter..."):
523
+ try:
524
+ response = handle_cover_letter(
525
+ job_description, selected_portfolio, openai_api_key,
526
+ company_motivation=company_motivation,
527
+ role_problem=role_problem,
528
+ specific_achievement=specific_achievement
529
+ )
530
+ except Exception as e:
531
+ error_occurred = f"Cover Letter Error: {str(e)}"
532
+
533
+ elif category == "general_query":
534
+ selected_portfolio = ai_portfolio if select_resume == "Resume_P" else ds_portfolio
535
+ with st.spinner("πŸ’­ Crafting your response..."):
536
+ try:
537
+ response = handle_general_query(
538
+ job_description, selected_portfolio, entry_query,
539
+ length_value, openai_api_key
540
+ )
541
+ except Exception as e:
542
+ error_occurred = f"General Query Error: {str(e)}"
543
+
544
+ # Display error if one occurred
545
+ if error_occurred:
546
+ st.error(f"οΏ½οΏ½οΏ½ {error_occurred}")
547
+ st.info("πŸ’‘ **Troubleshooting Tips:**\n- Check your API keys in the .env file\n- Verify your API key has sufficient credits/permissions\n- Ensure the model name is correct for your API tier")
548
+
549
+ # Store response in session state only if new response generated
550
+ if response:
551
+ st.session_state.edited_response = response
552
+ st.session_state.editing = False
553
+ elif not error_occurred:
554
+ st.error("❌ Failed to generate response. Please check the error messages above and try again.")
555
+
556
+ # Display stored response if available (persists across button clicks)
557
+ if "edited_response" in st.session_state and st.session_state.edited_response:
558
+ st.header("πŸ“€ Response")
559
+
560
+ # Toggle edit mode
561
+ col_response, col_buttons = st.columns([3, 1])
562
+
563
+ with col_buttons:
564
+ if st.button("✏️ Edit", key="edit_btn", use_container_width=True):
565
+ st.session_state.editing = not st.session_state.editing
566
+
567
+ # Display response or edit area
568
+ if st.session_state.editing:
569
+ st.session_state.edited_response = st.text_area(
570
+ "Edit your response:",
571
+ value=st.session_state.edited_response,
572
+ height=250,
573
+ key="response_editor"
574
+ )
575
+
576
+ col_save, col_cancel = st.columns(2)
577
+ with col_save:
578
+ if st.button("πŸ’Ύ Save Changes", use_container_width=True):
579
+ st.session_state.editing = False
580
+ st.success("βœ… Response updated!")
581
+ st.rerun()
582
+
583
+ with col_cancel:
584
+ if st.button("❌ Cancel", use_container_width=True):
585
+ st.session_state.editing = False
586
+ st.rerun()
587
+ else:
588
+ # Display the response
589
+ st.success(st.session_state.edited_response)
590
+
591
+ # Download PDF button
592
+ timestamp = get_est_timestamp()
593
+ pdf_filename = f"Dhanvanth_{timestamp}.pdf"
594
+
595
+ pdf_content = generate_pdf(st.session_state.edited_response, pdf_filename)
596
+ if pdf_content:
597
+ st.download_button(
598
+ label="πŸ“₯ Download as PDF",
599
+ data=pdf_content,
600
+ file_name=pdf_filename,
601
+ mime="application/pdf",
602
+ use_container_width=True
603
+ )
604
+
605
+ st.markdown("---")
606
+ st.markdown(
607
+ "Say Hi to Griva thalli from her mama ❀️"
608
+ )