dhanvanth183 commited on
Commit
84026c9
Β·
verified Β·
1 Parent(s): f0ee547

Added cold text feature

Browse files
Files changed (2) hide show
  1. app.py +836 -608
  2. requirements.txt +2 -1
app.py CHANGED
@@ -1,608 +1,836 @@
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
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ from pypdf import PdfReader
14
+
15
+ # Load environment variables
16
+ load_dotenv()
17
+
18
+ # Page configuration
19
+ st.set_page_config(page_title="AI Resume Assistant", layout="wide")
20
+ st.title("πŸ€– AI Resume Assistant")
21
+
22
+ # Load API keys from environment variables
23
+ openrouter_api_key = os.getenv("OPENROUTER_API_KEY")
24
+ openai_api_key = os.getenv("OPENAI_API_KEY")
25
+
26
+ # Check if API keys are available
27
+ if not openrouter_api_key or not openai_api_key:
28
+ st.error("❌ API keys not found. Please set OPENROUTER_API_KEY and OPENAI_API_KEY in your environment variables (.env file).")
29
+ st.stop()
30
+
31
+ # Initialize session state for company context caching
32
+ if "cached_job_description" not in st.session_state:
33
+ st.session_state.cached_job_description = None
34
+ if "cached_company_context" not in st.session_state:
35
+ st.session_state.cached_company_context = None
36
+
37
+ def get_est_timestamp():
38
+ """Get current timestamp in EST timezone with format dd-mm-yyyy-HH-MM"""
39
+ est = pytz.timezone('US/Eastern')
40
+ now = datetime.now(est)
41
+ return now.strftime("%d-%m-%Y-%H-%M")
42
+
43
+
44
+ def generate_pdf(content, filename):
45
+ """Generate PDF from content and return as bytes"""
46
+ try:
47
+ pdf_buffer = io.BytesIO()
48
+ doc = SimpleDocTemplate(
49
+ pdf_buffer,
50
+ pagesize=letter,
51
+ rightMargin=0.75*inch,
52
+ leftMargin=0.75*inch,
53
+ topMargin=0.75*inch,
54
+ bottomMargin=0.75*inch
55
+ )
56
+
57
+ story = []
58
+ styles = getSampleStyleSheet()
59
+
60
+ # Custom style for body text
61
+ body_style = ParagraphStyle(
62
+ 'CustomBody',
63
+ parent=styles['Normal'],
64
+ fontSize=11,
65
+ leading=14,
66
+ alignment=TA_JUSTIFY,
67
+ spaceAfter=12
68
+ )
69
+
70
+ # Add content only (no preamble)
71
+ # Split content into paragraphs for better formatting
72
+ paragraphs = content.split('\n\n')
73
+ for para in paragraphs:
74
+ if para.strip():
75
+ # Replace line breaks with spaces within paragraphs
76
+ clean_para = para.replace('\n', ' ').strip()
77
+ story.append(Paragraph(clean_para, body_style))
78
+
79
+ # Build PDF
80
+ doc.build(story)
81
+ pdf_buffer.seek(0)
82
+ return pdf_buffer.getvalue()
83
+
84
+ except Exception as e:
85
+ st.error(f"Error generating PDF: {str(e)}")
86
+ return None
87
+
88
+
89
+ def extract_pdf_text(uploaded_file):
90
+ """Extract text from uploaded PDF file"""
91
+ try:
92
+ pdf_reader = PdfReader(uploaded_file)
93
+ text = ""
94
+ for page in pdf_reader.pages:
95
+ text += page.extract_text() or ""
96
+ return text.strip()
97
+ except Exception as e:
98
+ st.error(f"Error extracting PDF: {str(e)}")
99
+ return None
100
+
101
+
102
+ def categorize_input(resume_finder, cover_letter, cold_text, select_resume, entry_query, uploaded_pdf=None):
103
+ """
104
+ Categorize input into one of 5 groups:
105
+ - resume_finder: resume_finder=T, others=F, No Select
106
+ - cover_letter: cover_letter=T, others=F, not No Select
107
+ - cold_text: cold_text=T, others=F, not No Select, (pdf OR entry_query)
108
+ - general_query: all checkboxes=F, not No Select, has query
109
+ - retry: any other combination
110
+ """
111
+
112
+ # Count how many options are selected
113
+ selected_count = sum([resume_finder, cover_letter, cold_text])
114
+
115
+ # Only one option should be selected at a time
116
+ if selected_count > 1:
117
+ return "retry", "Please select only one option: Resume Finder, Cover Letter, or Cold Text."
118
+
119
+ if resume_finder and select_resume == "No Select":
120
+ return "resume_finder", None
121
+
122
+ elif cover_letter and select_resume != "No Select":
123
+ return "cover_letter", None
124
+
125
+ elif cold_text and select_resume != "No Select":
126
+ # Cold text requires either PDF or entry query
127
+ has_pdf = uploaded_pdf is not None
128
+ has_query = entry_query.strip() != ""
129
+ if not has_pdf and not has_query:
130
+ return "retry", "Cold Text requires either a PDF upload or an Entry Query."
131
+ return "cold_text", None
132
+
133
+ elif not resume_finder and not cover_letter and not cold_text and select_resume != "No Select":
134
+ if not entry_query.strip():
135
+ return "retry", "Please enter a query for General Query mode."
136
+ return "general_query", None
137
+
138
+ else:
139
+ return "retry", "Please check your entries and try again"
140
+
141
+
142
+ def load_portfolio(file_path):
143
+ """Load portfolio markdown file"""
144
+ try:
145
+ full_path = os.path.join(os.path.dirname(__file__), file_path)
146
+ with open(full_path, 'r', encoding='utf-8') as f:
147
+ return f.read()
148
+ except FileNotFoundError:
149
+ st.error(f"Portfolio file {file_path} not found!")
150
+ return None
151
+
152
+
153
+ def handle_resume_finder(job_description, ai_portfolio, ds_portfolio, api_key):
154
+ """Handle Resume Finder category using OpenRouter"""
155
+
156
+ prompt = f"""You are an expert resume matcher. Analyze the following job description and two portfolios to determine which is the best match.
157
+
158
+ IMPORTANT MAPPING:
159
+ - If AI_portfolio is most relevant β†’ Resume = Resume_P
160
+ - If DS_portfolio is most relevant β†’ Resume = Resume_Dss
161
+
162
+ Job Description:
163
+ {job_description}
164
+
165
+ AI_portfolio (Maps to: Resume_P):
166
+ {ai_portfolio}
167
+
168
+ DS_portfolio (Maps to: Resume_Dss):
169
+ {ds_portfolio}
170
+
171
+ Respond ONLY with:
172
+ Resume: [Resume_P or Resume_Dss]
173
+ Reasoning: [25-30 words explaining the match]
174
+
175
+ NO PREAMBLE."""
176
+
177
+ try:
178
+ client = OpenAI(
179
+ base_url="https://openrouter.ai/api/v1",
180
+ api_key=api_key,
181
+ )
182
+
183
+ completion = client.chat.completions.create(
184
+ model="openai/gpt-oss-safeguard-20b",
185
+ messages=[
186
+ {
187
+ "role": "user",
188
+ "content": prompt
189
+ }
190
+ ]
191
+ )
192
+
193
+ response = completion.choices[0].message.content
194
+ if response:
195
+ return response
196
+ else:
197
+ st.error("❌ No response received from OpenRouter API")
198
+ return None
199
+
200
+ except Exception as e:
201
+ st.error(f"❌ Error calling OpenRouter API: {str(e)}")
202
+ return None
203
+
204
+
205
+ def generate_cover_letter_context(job_description, portfolio, api_key):
206
+ """Generate company research, role problem analysis, and achievement matching using web search via Perplexity Sonar
207
+
208
+ Args:
209
+ job_description: The job posting
210
+ portfolio: Candidate's resume/portfolio
211
+ api_key: OpenRouter API key (used for Perplexity Sonar with web search)
212
+
213
+ Returns:
214
+ dict: {"company_motivation": str, "role_problem": str, "achievement_section": str}
215
+ """
216
+
217
+ prompt = f"""You are an expert career strategist researching a company and role to craft authentic, researched-backed cover letter context.
218
+
219
+ Your task: Use web search to find SPECIFIC, RECENT company intelligence, then match it to the candidate's achievements.
220
+
221
+ REQUIRED WEB SEARCHES:
222
+ 1. Recent company moves (funding rounds, product launches, acquisitions, market expansion, hiring momentum)
223
+ 2. Current company challenges (what problem are they actively solving?)
224
+ 3. Company tech stack / tools they use
225
+ 4. Why they're hiring NOW (growth? new product? team expansion?)
226
+ 5. Company market position and strategy
227
+
228
+ Job Description:
229
+ {job_description}
230
+
231
+ Candidate's Portfolio:
232
+ {portfolio}
233
+
234
+ Generate a JSON response with this format (no additional text):
235
+ {{
236
+ "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.",
237
+ "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.'",
238
+ "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."
239
+ }}
240
+
241
+ REQUIREMENTS FOR AUTHENTICITY:
242
+ - company_motivation: Must reference verifiable findings from web search (recent news, funding, product launch, specific challenge)
243
+ - role_problem: Explicitly state the core problem extracted from job description + company research
244
+ - achievement_section: Must map directly to role_problem with clear cause-effect (not just "relevant to job")
245
+ - NO FORCED CONNECTIONS: If no genuine connection exists between candidate achievement and role problem, return empty string rather than forcing weak match
246
+ - AUTHENTICITY PRIORITY: A short, genuine motivation beats a longer forced one. Minimize if needed to avoid template-feel.
247
+
248
+ Return ONLY the JSON object, no other text."""
249
+
250
+ # Use Perplexity Sonar via OpenRouter (has built-in web search)
251
+ client = OpenAI(
252
+ base_url="https://openrouter.ai/api/v1",
253
+ api_key=api_key,
254
+ )
255
+
256
+ completion = client.chat.completions.create(
257
+ model="perplexity/sonar",
258
+ messages=[
259
+ {
260
+ "role": "user",
261
+ "content": prompt
262
+ }
263
+ ]
264
+ )
265
+
266
+ response_text = completion.choices[0].message.content
267
+
268
+ # Parse JSON response
269
+ import json
270
+ try:
271
+ result = json.loads(response_text)
272
+ return {
273
+ "company_motivation": result.get("company_motivation", ""),
274
+ "role_problem": result.get("role_problem", ""),
275
+ "achievement_section": result.get("achievement_section", "")
276
+ }
277
+ except json.JSONDecodeError:
278
+ # Fallback if JSON parsing fails
279
+ return {
280
+ "company_motivation": "",
281
+ "role_problem": "",
282
+ "achievement_section": ""
283
+ }
284
+
285
+
286
+ def get_or_fetch_company_context(job_description, portfolio, api_key, should_fetch=True):
287
+ """Get cached company context or fetch new one if needed.
288
+
289
+ Args:
290
+ job_description: The job posting
291
+ portfolio: Candidate's resume/portfolio
292
+ api_key: OpenRouter API key
293
+ should_fetch: If True, fetch new context when cache is invalid. If False, return None.
294
+
295
+ Returns:
296
+ dict with company_motivation, role_problem, achievement_section, or None if not available
297
+ """
298
+ # Check if we have valid cached context for this job description
299
+ if (st.session_state.cached_job_description is not None and
300
+ st.session_state.cached_job_description == job_description and
301
+ st.session_state.cached_company_context is not None):
302
+ st.info("πŸ’Ύ Using cached company context (same job description)")
303
+ return st.session_state.cached_company_context
304
+
305
+ # Cache miss - either fetch or return None based on should_fetch
306
+ if not should_fetch:
307
+ return None
308
+
309
+ # Fetch new context
310
+ st.info("πŸ” Fetching company context with web search...")
311
+ context = generate_cover_letter_context(job_description, portfolio, api_key)
312
+
313
+ # Cache the result
314
+ st.session_state.cached_job_description = job_description
315
+ st.session_state.cached_company_context = context
316
+ st.success("βœ… Company context fetched and cached!")
317
+
318
+ return context
319
+
320
+
321
+ def handle_cover_letter(job_description, portfolio, api_key, company_motivation="", role_problem="", specific_achievement=""):
322
+ """Handle Cover Letter category using OpenAI
323
+
324
+ Args:
325
+ job_description: The job posting
326
+ portfolio: Candidate's resume/portfolio
327
+ api_key: OpenAI API key
328
+ company_motivation: Researched company interest with recent moves/challenges (auto-generated if empty)
329
+ role_problem: The core problem this role solves for the company (auto-generated if empty)
330
+ specific_achievement: One concrete achievement that solves role_problem (auto-generated if empty)
331
+ """
332
+
333
+ # Build context sections if provided
334
+ motivation_section = ""
335
+ if company_motivation.strip():
336
+ motivation_section = f"\nCompany Research (Recent Moves/Challenges):\n{company_motivation}"
337
+
338
+ problem_section = ""
339
+ if role_problem.strip():
340
+ problem_section = f"\nRole's Core Problem:\n{role_problem}"
341
+
342
+ achievement_section = ""
343
+ if specific_achievement.strip():
344
+ achievement_section = f"\nAchievement That Solves This Problem:\n{specific_achievement}"
345
+
346
+ prompt = f"""You are an expert career coach writing authentic, researched cover letters that prove specific company knowledge and solve real problems.
347
+
348
+ Your goal: Write a letter showing you researched THIS company (not a template) and authentically connect your achievements to THEIR specific problem.
349
+
350
+ CRITICAL FOUNDATION:
351
+ You have three inputs: company research (recent moves/challenges), role problem (what they're hiring to solve), and one matching achievement.
352
+ Construct narrative: "Because you [company context] need to [role problem], my experience with [achievement] makes me valuable."
353
+
354
+ Cover Letter Structure:
355
+ 1. Opening (2-3 sentences): Hook with SPECIFIC company research (recent move, funding, product, market challenge)
356
+ - NOT: "I'm interested in your company"
357
+ - YES: "Your recent expansion to [X markets] and focus on [tech] align with my experience"
358
+
359
+ 2. Middle (4-5 sentences):
360
+ - State role's core problem (what you understand they're hiring to solve)
361
+ - Connect achievement DIRECTLY to that problem (show cause-effect)
362
+ - Reference job description specifics your achievement addresses
363
+ - Show understanding of their constraint/challenge
364
+
365
+ 3. Closing (1-2 sentences): Express genuine enthusiasm about solving THIS specific problem
366
+
367
+ CRITICAL REQUIREMENTS:
368
+ - RESEARCH PROOF: Opening must show specific company knowledge (recent news, not generic mission)
369
+ - PROBLEM CLARITY: Explicitly state what problem you're solving for them
370
+ - SPECIFIC MAPPING: Achievement β†’ Role Problem β†’ Company Need (clear cause-effect chain)
371
+ - NO TEMPLATE: Varied sentence length, conversational tone, human voice
372
+ - NO FORCED CONNECTIONS: If something doesn't link cleanly, leave it out
373
+ - NO FLUFF: Every sentence serves a purpose (authentic < complete)
374
+ - NO SALARY TALK: Omit expectations or negotiations
375
+ - NO CORPORATE JARGON: Write like a real human
376
+ - NO EM DASHES: Use commas or separate sentences
377
+
378
+ Formatting:
379
+ - Start: "Dear Hiring Manager,"
380
+ - End: "Best,\nDhanvanth Voona" (on separate lines)
381
+ - Max 250 words
382
+ - NO PREAMBLE (start directly)
383
+ - Multiple short paragraphs OK
384
+
385
+ Context for Writing:
386
+ Resume:
387
+ {portfolio}
388
+
389
+ Job Description:
390
+ {job_description}{motivation_section}{problem_section}{achievement_section}
391
+
392
+ Response (Max 250 words, researched + authentic tone):"""
393
+
394
+ client = OpenAI(api_key=api_key)
395
+
396
+ completion = client.chat.completions.create(
397
+ model="gpt-5-mini-2025-08-07",
398
+ messages=[
399
+ {
400
+ "role": "user",
401
+ "content": prompt
402
+ }
403
+ ]
404
+ )
405
+
406
+ response = completion.choices[0].message.content
407
+ return response
408
+
409
+
410
+ def handle_general_query(job_description, portfolio, query, length, api_key):
411
+ """Handle General Query category using OpenAI"""
412
+
413
+ word_count_map = {
414
+ "short": "40-60",
415
+ "medium": "80-100",
416
+ "long": "120-150"
417
+ }
418
+
419
+ word_count = word_count_map.get(length, "40-60")
420
+
421
+ prompt = f"""You are an expert career consultant helping a candidate answer application questions with authentic, tailored responses.
422
+
423
+ Your task: Answer the query authentically using ONLY genuine connections between the candidate's experience and the job context.
424
+
425
+ Word Count Strategy (Important - Read Carefully):
426
+ - Target: {word_count} words MAXIMUM
427
+ - Adaptive: Use fewer words if the query can be answered completely and convincingly with fewer words
428
+ - Examples: "What is your greatest strength?" might need only 45 words. "Why our company?" needs 85-100 words to show genuine research
429
+ - NEVER force content to hit word count targets - prioritize authentic connection over word count
430
+
431
+ Connection Quality Guidelines:
432
+ - Extract key company values/needs, salary ranges from job description
433
+ - Find 1-2 direct experiences from resume that align with these
434
+ - Show cause-and-effect: "Because you need X, my experience with Y makes me valuable"
435
+ - If connection is weak or forced, acknowledge limitations honestly
436
+ - Avoid generic statements - every sentence should reference either the job, company, or specific experience
437
+ - For questions related to salary, use the same salary ranges if provided in job description, ONLY if you could not extract salary from
438
+ job description, use the salary range given in portfolio.
439
+
440
+ Requirements:
441
+ - Answer naturally as if written by the candidate
442
+ - Start directly with the answer (NO PREAMBLE or "Let me tell you...")
443
+ - Response must be directly usable in an application
444
+ - Make it engaging and personalized, not templated
445
+ - STRICTLY NO EM DASHES
446
+ - One authentic connection beats three forced ones
447
+
448
+ Resume:
449
+ {portfolio}
450
+
451
+ Job Description:
452
+ {job_description}
453
+
454
+ Query:
455
+ {query}
456
+
457
+ Response (Max {word_count} words, use fewer if appropriate):"""
458
+
459
+ client = OpenAI(api_key=api_key)
460
+
461
+ completion = client.chat.completions.create(
462
+ model="gpt-5-mini-2025-08-07",
463
+ messages=[
464
+ {
465
+ "role": "user",
466
+ "content": prompt
467
+ }
468
+ ]
469
+ )
470
+
471
+ response = completion.choices[0].message.content
472
+ return response
473
+
474
+
475
+ def handle_cold_text(job_description, portfolio, receiver_profile, length, api_key, company_context=None):
476
+ """Handle Cold Text category - Generate LinkedIn connection request notes using OpenRouter
477
+
478
+ Args:
479
+ job_description: The job posting
480
+ portfolio: Candidate's resume/portfolio
481
+ receiver_profile: Receiver's LinkedIn profile (extracted from PDF + optional user query)
482
+ length: short/medium/long
483
+ api_key: OpenRouter API key
484
+ company_context: Optional dict with company_motivation, role_problem, achievement_section
485
+ """
486
+
487
+ # Map length to character/word limits
488
+ length_map = {
489
+ "short": "290 characters (STRICTLY count characters including spaces)",
490
+ "medium": "100-120 words",
491
+ "long": "120-150 words"
492
+ }
493
+
494
+ limit_instruction = length_map.get(length, "290 characters")
495
+
496
+ # Build company context section if available
497
+ company_context_section = ""
498
+ if company_context:
499
+ company_context_section = f"""
500
+ - Company Motivation: {company_context.get('company_motivation', 'N/A')}
501
+ - Role's Core Problem: {company_context.get('role_problem', 'N/A')}
502
+ """
503
+
504
+ # Truncate job description to first 300 characters (job title is usually in the beginning)
505
+ job_description_truncated = job_description[:300] if len(job_description) > 300 else job_description
506
+
507
+ prompt = f"""You are an expert career networking assistant drafting a genuine "Connection Request Note" that feels like natural networking, NOT a sales pitch.
508
+
509
+ FORMATTING RULE (CRITICAL): Never use em dashes (the long dash character). Use commas or periods instead.
510
+
511
+ INPUTS:
512
+ 1. User Resume: {portfolio}
513
+ 2. Receiver's LinkedIn Profile: {receiver_profile}
514
+ 3. Job Title/Context: {job_description_truncated}
515
+ 4. Company Context: {company_context_section}
516
+
517
+ CORE PRINCIPLE:
518
+ The message should be about the receiver, not about the user. The resume is already submitted, this note is about making a genuine human connection.
519
+
520
+ TOPIC & HOOK SELECTION:
521
+ The hook and any follow-up question must be both RELEVANT and SPECIFIC.
522
+
523
+ Step 1 - Find a relevant topic:
524
+ 1. First, look for topics that appear in ALL inputs: receiver's profile, user resume, job description, and company context. This is the ideal intersection.
525
+ 2. If no 4-way match exists, find topics in the receiver's profile that connect to at least 1 other input (resume, job description, company context).
526
+ 3. Only if no connection exists, use a topic purely from the receiver's profile.
527
+
528
+ Step 2 - Within the relevant topic, prefer this hook type (in order):
529
+ 1. Their recent content, posts, or published work that the user found valuable
530
+ 2. Their career path or transitions
531
+ 3. Shared background (same school, company, community)
532
+ 4. Their current role or team
533
+
534
+ IMPORTANT: Check the "User Query" section at the end of the Receiver's LinkedIn Profile (if present) to understand any specific hooks or context the user wants emphasized.
535
+
536
+ MESSAGE STRUCTURE:
537
+ 1. Salutation with their name
538
+ 2. The Hook: one specific observation about the receiver that shows the user researched them (not generic praise)
539
+ 3. Brief context: mention the user applied for the role (one short phrase)
540
+ 4. The Ask: Pick from these categories (use the first that fits naturally):
541
+ - Priority 1: What they value ("What skills have helped you most in this role?")
542
+ - Priority 2: Light advice ("Any advice for someone joining?")
543
+ - Priority 3: Simple binary choice ("Would you say the role is more X or Y?")
544
+ 5. Sign-off: Use "Thanks," followed by "- Dhanvanth" on the next line
545
+
546
+ AVOID:
547
+ - Em dashes (use commas or periods)
548
+ - Technical questions about tools, trends, platforms, or architecture
549
+ - Open-ended questions that require lengthy explanations
550
+ - Generic closings or filler phrases
551
+
552
+ TONE: Humble, curious, low-pressure, conversational.
553
+
554
+ LENGTH:
555
+ STRICTLY under the limit: {limit_instruction}. Count before outputting.
556
+
557
+ NO PREAMBLE. Start directly with the connection request note."""
558
+
559
+ client = OpenAI(
560
+ base_url="https://openrouter.ai/api/v1",
561
+ api_key=api_key,
562
+ )
563
+
564
+ completion = client.chat.completions.create(
565
+ model="openai/gpt-4.1-mini",
566
+ messages=[
567
+ {
568
+ "role": "user",
569
+ "content": prompt
570
+ }
571
+ ]
572
+ )
573
+
574
+ response = completion.choices[0].message.content
575
+ return response
576
+
577
+
578
+ # Main input section
579
+ st.header("πŸ“‹ Input Form")
580
+
581
+ # Create columns for better layout
582
+ col1, col2 = st.columns(2)
583
+
584
+ with col1:
585
+ job_description = st.text_area(
586
+ "Job Description (Required)*",
587
+ placeholder="Paste the job description here...",
588
+ height=150
589
+ )
590
+
591
+ with col2:
592
+ st.subheader("Options")
593
+ resume_finder = st.checkbox("Resume Finder", value=False)
594
+ cover_letter = st.checkbox("Cover Letter", value=False)
595
+ cold_text = st.checkbox("Cold Text", value=False)
596
+
597
+ # Length of Resume
598
+ length_options = {
599
+ "Short": "short",
600
+ "Medium": "medium",
601
+ "Long": "long"
602
+ }
603
+ length_of_resume = st.selectbox(
604
+ "Length of Response",
605
+ options=list(length_options.keys()),
606
+ index=0
607
+ )
608
+ length_value = length_options[length_of_resume]
609
+
610
+ # Select Resume dropdown
611
+ resume_options = ["No Select", "Resume_P", "Resume_Dss"]
612
+ select_resume = st.selectbox(
613
+ "Select Resume",
614
+ options=resume_options,
615
+ index=0
616
+ )
617
+
618
+ # Entry Query
619
+ entry_query = st.text_area(
620
+ "Entry Query (Optional)",
621
+ placeholder="Ask any question related to your application...",
622
+ max_chars=5000,
623
+ height=100
624
+ )
625
+
626
+ # PDF Upload for Cold Text (LinkedIn profile of receiver)
627
+ uploaded_pdf = st.file_uploader(
628
+ "Upload LinkedIn Profile PDF (for Cold Text)",
629
+ type=["pdf"],
630
+ help="Upload the receiver's LinkedIn profile PDF for Cold Text feature"
631
+ )
632
+
633
+ # Submit button
634
+ if st.button("πŸš€ Generate", type="primary", use_container_width=True):
635
+ # Validate job description
636
+ if not job_description.strip():
637
+ st.error("❌ Job Description is required!")
638
+ st.stop()
639
+
640
+ # Categorize input
641
+ category, error_message = categorize_input(
642
+ resume_finder, cover_letter, cold_text, select_resume, entry_query, uploaded_pdf
643
+ )
644
+
645
+ if category == "retry":
646
+ st.warning(f"⚠️ {error_message}")
647
+ else:
648
+ st.header("πŸ“€ Response")
649
+
650
+ # Debug info (can be removed later)
651
+ with st.expander("πŸ“Š Debug Info"):
652
+ st.write(f"**Category:** {category}")
653
+ st.write(f"**Resume Finder:** {resume_finder}")
654
+ st.write(f"**Cover Letter:** {cover_letter}")
655
+ st.write(f"**Cold Text:** {cold_text}")
656
+ st.write(f"**Select Resume:** {select_resume}")
657
+ st.write(f"**Has Query:** {bool(entry_query.strip())}")
658
+ st.write(f"**Has PDF:** {uploaded_pdf is not None}")
659
+ st.write(f"**Company Context Cached:** {'βœ… Yes' if st.session_state.cached_company_context else '❌ No'}")
660
+ st.write(f"**Cached JD Match:** {'βœ… Yes' if st.session_state.cached_job_description == job_description else '❌ No'}")
661
+ st.write(f"**OpenAI API Key Set:** {'βœ… Yes' if openai_api_key else '❌ No'}")
662
+ st.write(f"**OpenRouter API Key Set:** {'βœ… Yes' if openrouter_api_key else '❌ No'}")
663
+
664
+ # Load portfolios
665
+ ai_portfolio = load_portfolio("AI_portfolio.md")
666
+ ds_portfolio = load_portfolio("DS_portfolio.md")
667
+
668
+ if ai_portfolio is None or ds_portfolio is None:
669
+ st.stop()
670
+
671
+ response = None
672
+ error_occurred = None
673
+
674
+ if category == "resume_finder":
675
+ with st.spinner("πŸ” Finding the best resume for you..."):
676
+ try:
677
+ response = handle_resume_finder(
678
+ job_description, ai_portfolio, ds_portfolio, openrouter_api_key
679
+ )
680
+ except Exception as e:
681
+ error_occurred = f"Resume Finder Error: {str(e)}"
682
+
683
+ elif category == "cover_letter":
684
+ selected_portfolio = ai_portfolio if select_resume == "Resume_P" else ds_portfolio
685
+
686
+ # Get or fetch company context (with caching)
687
+ try:
688
+ context = get_or_fetch_company_context(
689
+ job_description, selected_portfolio, openrouter_api_key, should_fetch=True
690
+ )
691
+ company_motivation = context.get("company_motivation", "") if context else ""
692
+ role_problem = context.get("role_problem", "") if context else ""
693
+ specific_achievement = context.get("achievement_section", "") if context else ""
694
+ except Exception as e:
695
+ error_occurred = f"Context Generation Error: {str(e)}"
696
+ st.error(f"❌ Failed to generate context: {str(e)}")
697
+ st.info("πŸ’‘ Proceeding with cover letter generation without auto-generated context...")
698
+ company_motivation = ""
699
+ role_problem = ""
700
+ specific_achievement = ""
701
+
702
+ # Now generate the cover letter
703
+ with st.spinner("✍️ Generating your cover letter..."):
704
+ try:
705
+ response = handle_cover_letter(
706
+ job_description, selected_portfolio, openai_api_key,
707
+ company_motivation=company_motivation,
708
+ role_problem=role_problem,
709
+ specific_achievement=specific_achievement
710
+ )
711
+ except Exception as e:
712
+ error_occurred = f"Cover Letter Error: {str(e)}"
713
+
714
+ elif category == "general_query":
715
+ selected_portfolio = ai_portfolio if select_resume == "Resume_P" else ds_portfolio
716
+
717
+ # Try to use cached company context if available (don't fetch if not cached)
718
+ cached_context = get_or_fetch_company_context(
719
+ job_description, selected_portfolio, openrouter_api_key, should_fetch=False
720
+ )
721
+
722
+ with st.spinner("πŸ’­ Crafting your response..."):
723
+ try:
724
+ # Note: handle_general_query doesn't use company context currently
725
+ # But we could enhance it later if needed
726
+ response = handle_general_query(
727
+ job_description, selected_portfolio, entry_query,
728
+ length_value, openai_api_key
729
+ )
730
+ except Exception as e:
731
+ error_occurred = f"General Query Error: {str(e)}"
732
+
733
+ elif category == "cold_text":
734
+ selected_portfolio = ai_portfolio if select_resume == "Resume_P" else ds_portfolio
735
+
736
+ # Use cached company context only (don't trigger fetch) - Cold Text is short, doesn't need full research
737
+ try:
738
+ company_context = get_or_fetch_company_context(
739
+ job_description, selected_portfolio, openrouter_api_key, should_fetch=False
740
+ )
741
+ except Exception as e:
742
+ st.warning(f"⚠️ Could not fetch company context: {str(e)}. Proceeding without it.")
743
+ company_context = None
744
+
745
+ with st.spinner("πŸ“ Generating your connection request note..."):
746
+ try:
747
+ # Build receiver profile from PDF and/or query
748
+ receiver_profile = ""
749
+
750
+ # Extract PDF if uploaded
751
+ if uploaded_pdf is not None:
752
+ pdf_text = extract_pdf_text(uploaded_pdf)
753
+ if pdf_text:
754
+ receiver_profile = pdf_text
755
+ else:
756
+ st.warning("⚠️ Could not extract text from PDF. Using query only.")
757
+
758
+ # Append user query if provided
759
+ if entry_query.strip():
760
+ if receiver_profile:
761
+ receiver_profile += f"\n\n--- User Query ---\n{entry_query.strip()}"
762
+ else:
763
+ receiver_profile = f"--- User Query ---\n{entry_query.strip()}"
764
+
765
+ response = handle_cold_text(
766
+ job_description, selected_portfolio, receiver_profile,
767
+ length_value, openrouter_api_key, company_context=company_context
768
+ )
769
+ except Exception as e:
770
+ error_occurred = f"Cold Text Error: {str(e)}"
771
+
772
+ # Display error if one occurred
773
+ if error_occurred:
774
+ st.error(f"❌ {error_occurred}")
775
+ 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")
776
+
777
+ # Store response in session state only if new response generated
778
+ if response:
779
+ st.session_state.edited_response = response
780
+ st.session_state.editing = False
781
+ elif not error_occurred:
782
+ st.error("❌ Failed to generate response. Please check the error messages above and try again.")
783
+
784
+ # Display stored response if available (persists across button clicks)
785
+ if "edited_response" in st.session_state and st.session_state.edited_response:
786
+ st.header("πŸ“€ Response")
787
+
788
+ # Toggle edit mode
789
+ col_response, col_buttons = st.columns([3, 1])
790
+
791
+ with col_buttons:
792
+ if st.button("✏️ Edit", key="edit_btn", use_container_width=True):
793
+ st.session_state.editing = not st.session_state.editing
794
+
795
+ # Display response or edit area
796
+ if st.session_state.editing:
797
+ st.session_state.edited_response = st.text_area(
798
+ "Edit your response:",
799
+ value=st.session_state.edited_response,
800
+ height=250,
801
+ key="response_editor"
802
+ )
803
+
804
+ col_save, col_cancel = st.columns(2)
805
+ with col_save:
806
+ if st.button("πŸ’Ύ Save Changes", use_container_width=True):
807
+ st.session_state.editing = False
808
+ st.success("βœ… Response updated!")
809
+ st.rerun()
810
+
811
+ with col_cancel:
812
+ if st.button("❌ Cancel", use_container_width=True):
813
+ st.session_state.editing = False
814
+ st.rerun()
815
+ else:
816
+ # Display the response
817
+ st.success(st.session_state.edited_response)
818
+
819
+ # Download PDF button
820
+ timestamp = get_est_timestamp()
821
+ pdf_filename = f"Dhanvanth_{timestamp}.pdf"
822
+
823
+ pdf_content = generate_pdf(st.session_state.edited_response, pdf_filename)
824
+ if pdf_content:
825
+ st.download_button(
826
+ label="πŸ“₯ Download as PDF",
827
+ data=pdf_content,
828
+ file_name=pdf_filename,
829
+ mime="application/pdf",
830
+ use_container_width=True
831
+ )
832
+
833
+ st.markdown("---")
834
+ st.markdown(
835
+ "Say Hi to Griva thalli from her mama ❀️"
836
+ )
requirements.txt CHANGED
@@ -3,4 +3,5 @@ openai==1.58.1
3
  python-dotenv==1.0.0
4
  openrouter
5
  reportlab>=4.0.0
6
- pytz>=2023.3
 
 
3
  python-dotenv==1.0.0
4
  openrouter
5
  reportlab>=4.0.0
6
+ pytz>=2023.3
7
+ pypdf>=4.0.0