resumemakerats 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. resumemakerats/__init__.py +0 -0
  2. resumemakerats/__main__.py +3 -0
  3. resumemakerats/ats_scorer.py +316 -0
  4. resumemakerats/cli.py +346 -0
  5. resumemakerats/config.py +6 -0
  6. resumemakerats/evaluator.py +91 -0
  7. resumemakerats/fabricator.py +382 -0
  8. resumemakerats/github.py +476 -0
  9. resumemakerats/inception_provider.py +144 -0
  10. resumemakerats/jd_analyzer.py +70 -0
  11. resumemakerats/llm_router.py +199 -0
  12. resumemakerats/llm_utils.py +54 -0
  13. resumemakerats/models.py +364 -0
  14. resumemakerats/page_manager.py +406 -0
  15. resumemakerats/pdf.py +324 -0
  16. resumemakerats/pdf_editor.py +307 -0
  17. resumemakerats/prompt.py +25 -0
  18. resumemakerats/prompts/__init__.py +0 -0
  19. resumemakerats/prompts/template_manager.py +110 -0
  20. resumemakerats/prompts/templates/ats_scoring.jinja +28 -0
  21. resumemakerats/prompts/templates/awards.jinja +20 -0
  22. resumemakerats/prompts/templates/basics.jinja +55 -0
  23. resumemakerats/prompts/templates/consolidate_content.jinja +42 -0
  24. resumemakerats/prompts/templates/critic_bullet_enhance.jinja +23 -0
  25. resumemakerats/prompts/templates/critic_improvement.jinja +24 -0
  26. resumemakerats/prompts/templates/education.jinja +23 -0
  27. resumemakerats/prompts/templates/fabricate_project.jinja +32 -0
  28. resumemakerats/prompts/templates/fabricate_work_bullets.jinja +25 -0
  29. resumemakerats/prompts/templates/fabrication_quality.jinja +32 -0
  30. resumemakerats/prompts/templates/github_project_selection.jinja +100 -0
  31. resumemakerats/prompts/templates/jd_analysis.jinja +39 -0
  32. resumemakerats/prompts/templates/page_fill_improvement.jinja +26 -0
  33. resumemakerats/prompts/templates/projects.jinja +21 -0
  34. resumemakerats/prompts/templates/regeneration_feedback.jinja +31 -0
  35. resumemakerats/prompts/templates/resume_evaluation_criteria.jinja +185 -0
  36. resumemakerats/prompts/templates/resume_evaluation_system_message.jinja +49 -0
  37. resumemakerats/prompts/templates/resume_tailor_highlights.jinja +40 -0
  38. resumemakerats/prompts/templates/resume_tailor_projects.jinja +27 -0
  39. resumemakerats/prompts/templates/resume_tailor_skills.jinja +33 -0
  40. resumemakerats/prompts/templates/resume_tailor_summary.jinja +21 -0
  41. resumemakerats/prompts/templates/resume_to_json.jinja +100 -0
  42. resumemakerats/prompts/templates/skills.jinja +20 -0
  43. resumemakerats/prompts/templates/system_message.jinja +5 -0
  44. resumemakerats/prompts/templates/technical_audit.jinja +29 -0
  45. resumemakerats/prompts/templates/work.jinja +36 -0
  46. resumemakerats/pymupdf_rag.py +1377 -0
  47. resumemakerats/renderer.py +515 -0
  48. resumemakerats/resume_converter.py +133 -0
  49. resumemakerats/resume_parser.py +682 -0
  50. resumemakerats/resume_tailor.py +1415 -0
  51. resumemakerats/score.py +309 -0
  52. resumemakerats/skills_gap_analyzer.py +89 -0
  53. resumemakerats/transform.py +939 -0
  54. resumemakerats-0.1.0.dist-info/METADATA +309 -0
  55. resumemakerats-0.1.0.dist-info/RECORD +59 -0
  56. resumemakerats-0.1.0.dist-info/WHEEL +5 -0
  57. resumemakerats-0.1.0.dist-info/entry_points.txt +2 -0
  58. resumemakerats-0.1.0.dist-info/licenses/LICENSE +21 -0
  59. resumemakerats-0.1.0.dist-info/top_level.txt +1 -0
File without changes
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ main()
@@ -0,0 +1,316 @@
1
+ """
2
+ ATS Scorer - Hybrid rule-based + AI scoring.
3
+
4
+ Independently scores each component (no circular derivation):
5
+ - Keyword Match (40%): Fuzzy matching of JD keywords against resume text
6
+ - Format Score (20%): ATS-unfriendly element detection
7
+ - Section Completeness (10%): Required sections present
8
+ - Experience Relevance (15%): AI-based semantic relevance
9
+ - Skills Match (15%): Direct + fuzzy skill comparison
10
+ """
11
+
12
+ import logging
13
+ import re
14
+ from typing import Dict, List, Optional, Set, Tuple
15
+
16
+ from .models import ATSScore, ATSComponentScore, JobAnalysis, JSONResume
17
+ from .llm_router import LLMRouter, LLMRole
18
+ from .prompts.template_manager import TemplateManager
19
+ from .transform import convert_json_resume_to_text
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+ ATS_REQUIRED_SECTIONS = {"basics", "work", "education", "skills"}
24
+ ATS_OPTIONAL_SECTIONS = {"projects", "awards", "certificates", "volunteer"}
25
+
26
+ ATS_UNFRIENDLY_PATTERNS = [
27
+ r"\\begin{table}",
28
+ r"\\begin{tabular}",
29
+ r"<table",
30
+ r"\\column",
31
+ r"\\begin{columns}",
32
+ r"\\multirow",
33
+ r"\\multicolumn",
34
+ r"<img ",
35
+ r"\\includegraphics",
36
+ r"\\tikz",
37
+ r"\\begin{tikzpicture}",
38
+ r"\\header",
39
+ r"\\fancyhead",
40
+ r"\\fancyfoot",
41
+ r"textbf{",
42
+ r"\\begin{figure}",
43
+ ]
44
+
45
+ CHARS_PER_PAGE = 3800
46
+
47
+
48
+ def _normalize_skill(skill: str) -> str:
49
+ return re.sub(r"[^a-z0-9]", "", skill.lower().strip())
50
+
51
+
52
+ def _fuzzy_match_ratio(a: str, b: str) -> float:
53
+ try:
54
+ from rapidfuzz import fuzz
55
+ return fuzz.token_sort_ratio(a.lower(), b.lower()) / 100.0
56
+ except ImportError:
57
+ if a.lower() == b.lower():
58
+ return 1.0
59
+ if a.lower() in b.lower() or b.lower() in a.lower():
60
+ return 0.8
61
+ return 0.0
62
+
63
+
64
+ class ATSScorer:
65
+ def __init__(self, router: LLMRouter, template_dir: str = "prompts/templates"):
66
+ self.router = router
67
+ self.template_manager = TemplateManager(template_dir)
68
+
69
+ def score(
70
+ self,
71
+ resume_data: JSONResume,
72
+ job_analysis: JobAnalysis,
73
+ resume_text: Optional[str] = None,
74
+ ) -> ATSScore:
75
+ if not resume_text:
76
+ resume_text = convert_json_resume_to_text(resume_data)
77
+ resume_lower = resume_text.lower()
78
+
79
+ keyword_score = self._score_keywords(resume_lower, job_analysis)
80
+ format_score = self._score_format(resume_text, resume_data)
81
+ completeness_score = self._score_completeness(resume_data)
82
+ skills_score = self._score_skills(resume_data, job_analysis)
83
+ relevance_score = self._score_relevance_ai(resume_text, job_analysis)
84
+
85
+ components = ATSComponentScore(
86
+ keyword_match=keyword_score["score"],
87
+ format_score=format_score,
88
+ section_completeness=completeness_score,
89
+ experience_relevance=relevance_score,
90
+ skills_match=skills_score["score"],
91
+ )
92
+
93
+ overall = (
94
+ keyword_score["score"] * 0.40
95
+ + format_score * 0.20
96
+ + completeness_score * 0.10
97
+ + relevance_score * 0.15
98
+ + skills_score["score"] * 0.15
99
+ )
100
+
101
+ suggestions = self._generate_suggestions(
102
+ keyword_score, format_score, completeness_score, skills_score, job_analysis
103
+ )
104
+
105
+ return ATSScore(
106
+ overall_score=round(overall, 1),
107
+ components=components,
108
+ suggestions=suggestions,
109
+ missing_keywords=keyword_score["missing"],
110
+ matched_keywords=keyword_score["matched"],
111
+ )
112
+
113
+ def _score_keywords(
114
+ self, resume_lower: str, job_analysis: JobAnalysis
115
+ ) -> Dict:
116
+ all_keywords = list(
117
+ set(job_analysis.keywords + job_analysis.required_skills + job_analysis.preferred_skills)
118
+ )
119
+ if not all_keywords:
120
+ return {"score": 75.0, "matched": [], "missing": []}
121
+
122
+ matched = []
123
+ missing = []
124
+
125
+ for kw in all_keywords:
126
+ kw_lower = kw.lower().strip()
127
+ kw_norm = _normalize_skill(kw)
128
+
129
+ found = False
130
+ # Guard against substring false matches (e.g., "Java" in "JavaScript")
131
+ # Use (?<!\w) and (?!\w) so "C++" still matches but "R" won't match "React"
132
+ if re.search(r'(?<!\w)' + re.escape(kw_lower) + r'(?!\w)', resume_lower):
133
+ found = True
134
+ elif kw_norm:
135
+ # Only fuzzy-match against individual words, not the full resume string
136
+ for word in resume_lower.split():
137
+ if _fuzzy_match_ratio(kw_norm, _normalize_skill(word)) >= 0.85:
138
+ found = True
139
+ break
140
+
141
+ if found:
142
+ matched.append(kw)
143
+ else:
144
+ missing.append(kw)
145
+
146
+ score = (len(matched) / len(all_keywords)) * 100 if all_keywords else 0
147
+ return {"score": round(score, 1), "matched": matched, "missing": missing}
148
+
149
+ def _score_format(self, resume_text: str, resume_data: JSONResume) -> float:
150
+ score = 100.0
151
+
152
+ if resume_data.basics and resume_data.basics.profiles:
153
+ for p in resume_data.basics.profiles:
154
+ if p.url and "github.com" in p.url:
155
+ score = min(score + 2, 100)
156
+ break
157
+
158
+ if not resume_data.basics or not resume_data.basics.name:
159
+ score -= 5
160
+
161
+ if resume_data.basics and resume_data.basics.email and resume_data.basics.phone:
162
+ pass
163
+ else:
164
+ score -= 5
165
+
166
+ clean_text = convert_json_resume_to_text(resume_data)
167
+ char_count = len(clean_text)
168
+ estimated_pages = char_count / CHARS_PER_PAGE
169
+ if estimated_pages > 3:
170
+ score -= 15
171
+ elif estimated_pages > 2:
172
+ score -= 5
173
+
174
+ return max(0, round(score, 1))
175
+
176
+ def _score_completeness(self, resume_data: JSONResume) -> float:
177
+ score = 0.0
178
+ total_possible = 0.0
179
+
180
+ section_checks = {
181
+ "basics": lambda r: r.basics is not None and r.basics.name is not None,
182
+ "work": lambda r: r.work is not None and len(r.work) > 0,
183
+ "education": lambda r: r.education is not None and len(r.education) > 0,
184
+ "skills": lambda r: r.skills is not None and len(r.skills) > 0,
185
+ "projects": lambda r: r.projects is not None and len(r.projects) > 0,
186
+ "summary": lambda r: r.basics is not None and r.basics.summary is not None and len(r.basics.summary) > 20,
187
+ }
188
+
189
+ weights = {
190
+ "basics": 15,
191
+ "work": 25,
192
+ "education": 20,
193
+ "skills": 20,
194
+ "projects": 10,
195
+ "summary": 10,
196
+ }
197
+
198
+ for section, check_fn in section_checks.items():
199
+ weight = weights[section]
200
+ total_possible += weight
201
+ if check_fn(resume_data):
202
+ score += weight
203
+
204
+ return round((score / total_possible) * 100, 1) if total_possible > 0 else 0
205
+
206
+ def _score_skills(
207
+ self, resume_data: JSONResume, job_analysis: JobAnalysis
208
+ ) -> Dict:
209
+ if not resume_data.skills:
210
+ return {"score": 50.0, "matched": 0, "total": 0}
211
+
212
+ jd_skill_sources = list(
213
+ set(job_analysis.keywords + job_analysis.required_skills + job_analysis.preferred_skills)
214
+ )
215
+ if not jd_skill_sources:
216
+ return {"score": 75.0, "matched": 0, "total": 0}
217
+
218
+ resume_skills = set()
219
+ for skill_cat in resume_data.skills:
220
+ if skill_cat.keywords:
221
+ resume_skills.update(_normalize_skill(k) for k in skill_cat.keywords)
222
+ if skill_cat.name:
223
+ resume_skills.add(_normalize_skill(skill_cat.name))
224
+
225
+ jd_skills = set(_normalize_skill(k) for k in jd_skill_sources)
226
+
227
+ if not jd_skills:
228
+ return {"score": 75.0, "matched": 0, "total": 0}
229
+
230
+ matched = 0
231
+ for jsk in jd_skills:
232
+ if any(
233
+ _fuzzy_match_ratio(jsk, rsk) >= 0.8 for rsk in resume_skills
234
+ ):
235
+ matched += 1
236
+
237
+ score = (matched / len(jd_skills)) * 100
238
+ return {"score": round(score, 1), "matched": matched, "total": len(jd_skills)}
239
+
240
+ def _score_relevance_ai(
241
+ self, resume_text: str, job_analysis: JobAnalysis
242
+ ) -> float:
243
+ prompt = self.template_manager.render_template(
244
+ "ats_scoring",
245
+ resume_text=resume_text[:3000],
246
+ job_title=job_analysis.title,
247
+ job_responsibilities="\n".join(job_analysis.key_responsibilities[:5]),
248
+ required_skills=", ".join(job_analysis.required_skills[:10]),
249
+ )
250
+
251
+ if not prompt:
252
+ return 50.0
253
+
254
+ try:
255
+ result = self.router.call_json(
256
+ role=LLMRole.JUDGE,
257
+ messages=[
258
+ {"role": "system", "content": "You are an ATS scoring system. Score resume relevance to the job. Return ONLY valid JSON."},
259
+ {"role": "user", "content": prompt},
260
+ ],
261
+ )
262
+ if isinstance(result, dict) and "score" in result:
263
+ return float(max(0, min(100, result["score"])))
264
+ except Exception as e:
265
+ logger.warning(f"AI relevance scoring failed: {e}")
266
+
267
+ return 50.0
268
+
269
+ def _generate_suggestions(
270
+ self,
271
+ keyword_score: Dict,
272
+ format_score: float,
273
+ completeness_score: float,
274
+ skills_score: Dict,
275
+ job_analysis: JobAnalysis,
276
+ ) -> List[str]:
277
+ suggestions = []
278
+
279
+ if keyword_score["missing"]:
280
+ top_missing = keyword_score["missing"][:5]
281
+ suggestions.append(
282
+ f"The Skills section is missing keywords: {', '.join(top_missing)}. Do NOT add them to the work bullets."
283
+ )
284
+
285
+ if format_score < 80:
286
+ suggestions.append(
287
+ "Remove ATS-unfriendly formatting (tables, columns, images, headers/footers)"
288
+ )
289
+
290
+ if completeness_score < 70:
291
+ suggestions.append(
292
+ "Add missing resume sections (summary, projects, or skills)"
293
+ )
294
+
295
+ if skills_score["score"] < 50:
296
+ suggestions.append(
297
+ "Reorder skills section to prioritize JD-required skills"
298
+ )
299
+
300
+ if keyword_score["score"] < 60:
301
+ suggestions.append(
302
+ "Mirror JD language more closely - use the same terminology"
303
+ )
304
+
305
+ if job_analysis.required_skills and len(keyword_score["matched"]) < len(
306
+ job_analysis.required_skills
307
+ ):
308
+ missing_req = [
309
+ s for s in job_analysis.required_skills if s in keyword_score["missing"]
310
+ ]
311
+ if missing_req:
312
+ suggestions.append(
313
+ f"The Skills section is missing required skills: {', '.join(missing_req[:3])}. Do NOT add them to the work bullets."
314
+ )
315
+
316
+ return suggestions
resumemakerats/cli.py ADDED
@@ -0,0 +1,346 @@
1
+ import json
2
+ import logging
3
+ import os
4
+ import sys
5
+ import shutil
6
+ from datetime import datetime
7
+
8
+ from dotenv import load_dotenv
9
+
10
+ load_dotenv()
11
+
12
+ logging.basicConfig(
13
+ level=logging.INFO,
14
+ format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
15
+ datefmt="%H:%M:%S",
16
+ )
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ def print_separator(char: str = "=", length: int = 60):
21
+ print(char * length)
22
+
23
+
24
+ def print_section(title: str):
25
+ print()
26
+ print_separator()
27
+ print(f" {title}")
28
+ print_separator()
29
+
30
+
31
+ def print_ats_score(score):
32
+ print_section("ATS SCORE")
33
+ print(f" Overall Score: {score.overall_score}/100")
34
+ print()
35
+ print(f" Keyword Match: {score.components.keyword_match:.1f}% (weight: 40%)")
36
+ print(f" Format Score: {score.components.format_score:.1f}% (weight: 20%)")
37
+ print(f" Section Completeness: {score.components.section_completeness:.1f}% (weight: 10%)")
38
+ print(f" Experience Relevance: {score.components.experience_relevance:.1f}% (weight: 15%)")
39
+ print(f" Skills Match: {score.components.skills_match:.1f}% (weight: 15%)")
40
+
41
+ if score.matched_keywords:
42
+ print()
43
+ print(f" Matched Keywords ({len(score.matched_keywords)}):")
44
+ for kw in score.matched_keywords[:15]:
45
+ print(f" + {kw}")
46
+ if len(score.matched_keywords) > 15:
47
+ print(f" ... and {len(score.matched_keywords) - 15} more")
48
+
49
+ if score.missing_keywords:
50
+ print()
51
+ print(f" Missing Keywords ({len(score.missing_keywords)}):")
52
+ for kw in score.missing_keywords[:15]:
53
+ print(f" - {kw}")
54
+ if len(score.missing_keywords) > 15:
55
+ print(f" ... and {len(score.missing_keywords) - 15} more")
56
+
57
+ if score.suggestions:
58
+ print()
59
+ print(" Suggestions:")
60
+ for i, s in enumerate(score.suggestions, 1):
61
+ print(f" {i}. {s}")
62
+
63
+
64
+ def print_skills_gap(gap):
65
+ print_section("SKILLS GAP ANALYSIS")
66
+ print(f" Coverage: {gap.coverage_percentage}%")
67
+ print(f" Gap Score: {gap.gap_score}/100")
68
+
69
+ if gap.matching_skills:
70
+ print()
71
+ print(f" Matching Skills ({len(gap.matching_skills)}):")
72
+ for s in gap.matching_skills[:15]:
73
+ print(f" + {s}")
74
+
75
+ if gap.missing_skills:
76
+ print()
77
+ print(f" Missing Skills ({len(gap.missing_skills)}):")
78
+ for s in gap.missing_skills[:15]:
79
+ print(f" - {s}")
80
+
81
+ if gap.partial_matches:
82
+ print()
83
+ print(f" Partial Matches ({len(gap.partial_matches)}):")
84
+ for pm in gap.partial_matches[:10]:
85
+ print(f" ~ {pm.skill} ({pm.match_percentage}% match)")
86
+
87
+
88
+ def print_tailored_result(result):
89
+ print_section("TAILORED RESUME RESULTS")
90
+
91
+ if result.ats_score:
92
+ print(f" Final ATS Score: {result.ats_score.overall_score}/100")
93
+
94
+ print(f" Regeneration Attempts: {result.regeneration_attempts}")
95
+ print(f" Consolidation Attempts: {result.consolidation_attempts}")
96
+ print(f" Improvement Attempts: {result.improvement_attempts}")
97
+
98
+ if result.changes_made:
99
+ print()
100
+ print(f" Changes Made ({len(result.changes_made)}):")
101
+ for change in result.changes_made:
102
+ print(f" * {change}")
103
+
104
+ if result.keyword_injections:
105
+ print()
106
+ print(f" Keywords Injected ({len(result.keyword_injections)}):")
107
+ for kw in result.keyword_injections:
108
+ print(f" + {kw}")
109
+
110
+ if result.fabricated_items:
111
+ print()
112
+ print(f" Fabricated Items ({len(result.fabricated_items)}):")
113
+ for item in result.fabricated_items:
114
+ print(f" [{item.item_type}] {item.reason}")
115
+
116
+ if result.truncated_items:
117
+ print()
118
+ print(f" Truncated Items ({len(result.truncated_items)}):")
119
+ for item in result.truncated_items:
120
+ section = item.get("section", "unknown")
121
+ name = item.get("name", "")
122
+ print(f" [{section}] {name}")
123
+
124
+
125
+ def prompt_input(prompt_text: str, default: str = None) -> str:
126
+ if default:
127
+ full_prompt = f"{prompt_text} [{default}]: "
128
+ else:
129
+ full_prompt = f"{prompt_text}: "
130
+ try:
131
+ value = input(full_prompt).strip()
132
+ except (EOFError, KeyboardInterrupt):
133
+ print()
134
+ sys.exit(1)
135
+ if not value and default:
136
+ return default
137
+ return value
138
+
139
+
140
+ def prompt_multiline(prompt_text: str) -> str:
141
+ print(f"{prompt_text} (enter an empty line to finish):")
142
+ lines = []
143
+ try:
144
+ while True:
145
+ line = input()
146
+ if line.strip() == "":
147
+ break
148
+ lines.append(line)
149
+ except (EOFError, KeyboardInterrupt):
150
+ print()
151
+ return "\n".join(lines)
152
+
153
+
154
+ def get_resume_text(resume_path: str) -> tuple:
155
+ if not os.path.exists(resume_path):
156
+ print(f" ERROR: File not found: {resume_path}")
157
+ return None, None
158
+ with open(resume_path, "rb") as f:
159
+ resume_bytes = f.read()
160
+ filename = os.path.basename(resume_path)
161
+ return resume_bytes, filename
162
+
163
+
164
+ def get_jd_text(jd_input: str) -> str:
165
+ if os.path.exists(jd_input):
166
+ with open(jd_input, "r", encoding="utf-8") as f:
167
+ return f.read().strip()
168
+ return jd_input.strip()
169
+
170
+
171
+ def main():
172
+ print_separator("*")
173
+ print(" ResumeMakerATS - AI Resume Tailoring")
174
+ print_separator("*")
175
+ print()
176
+
177
+ api_key = os.environ.get("INCEPTION_API_KEY", "")
178
+ if not api_key:
179
+ api_key = prompt_input("INCEPTION_API_KEY")
180
+ if not api_key:
181
+ print("\nERROR: INCEPTION_API_KEY is required.")
182
+ print("Set it in .env file, as environment variable, or enter it now.")
183
+ sys.exit(1)
184
+
185
+ print()
186
+
187
+ resume_path = prompt_input("Path to your resume PDF")
188
+ if not resume_path:
189
+ print("\nERROR: Resume path is required.")
190
+ sys.exit(1)
191
+
192
+ resume_bytes, resume_filename = get_resume_text(resume_path)
193
+ if resume_bytes is None:
194
+ sys.exit(1)
195
+
196
+ print()
197
+ jd_prompt = ("Path to job description file (or press Enter to paste text")
198
+ jd_input = prompt_input(jd_prompt)
199
+ jd_text = ""
200
+
201
+ if jd_input:
202
+ jd_text = get_jd_text(jd_input)
203
+ else:
204
+ print()
205
+ jd_text = prompt_multiline("Paste the job description text")
206
+
207
+ if not jd_text:
208
+ print("\nERROR: Job description is required.")
209
+ sys.exit(1)
210
+
211
+ print()
212
+ output_path = prompt_input("Output tailored resume as", default="resume_tailored.pdf")
213
+ output_path = output_path or "resume_tailored.pdf"
214
+
215
+ print()
216
+ print_separator("-")
217
+ print(f" Resume: {resume_path}")
218
+ print(f" JD: {'<pasted text>' if not os.path.exists(jd_input) else jd_input}")
219
+ print(f" Output: {output_path}")
220
+ print_separator("-")
221
+ print()
222
+
223
+ print(" Reading resume...")
224
+ from .resume_parser import extract_text_from_file, parse_resume_to_json
225
+
226
+ resume_text = extract_text_from_file(resume_bytes, resume_filename)
227
+ if not resume_text:
228
+ print("ERROR: Could not extract text from resume")
229
+ sys.exit(1)
230
+
231
+ from .llm_router import LLMRouter
232
+ router = LLMRouter(inception_api_key=api_key)
233
+ logger.info("LLM Router initialized")
234
+
235
+ template_dir = os.path.join(os.path.dirname(__file__), "prompts", "templates")
236
+ resume_text_truncated = resume_text[:8000]
237
+
238
+ from .resume_converter import ResumeConverter
239
+ resume_dict = ResumeConverter(router, template_dir=template_dir).convert(resume_text_truncated)
240
+
241
+ if not resume_dict or not resume_dict.get("basics", {}).get("name"):
242
+ logger.warning("AI parsing failed, falling back to rule-based parser")
243
+ resume_dict = parse_resume_to_json(resume_text)
244
+
245
+ from .models import JSONResume, GenerationConfig, FabricationConfig
246
+ try:
247
+ parsed = JSONResume(**resume_dict)
248
+ except Exception as e:
249
+ print(f"ERROR: Could not parse resume data: {e}")
250
+ sys.exit(1)
251
+
252
+ print(f" Parsed resume for: {parsed.basics.name if parsed.basics else 'Unknown'}")
253
+ print()
254
+
255
+ if jd_input and os.path.exists(jd_input):
256
+ print(f" Reading job description from file ({len(jd_text)} chars)...")
257
+ else:
258
+ print(f" Using pasted job description ({len(jd_text)} chars)...")
259
+
260
+ from .jd_analyzer import JDAnalyzer
261
+
262
+ print()
263
+ print(" Analyzing job description...")
264
+ jd_analyzer = JDAnalyzer(router, template_dir=template_dir)
265
+ job_analysis = jd_analyzer.analyze(jd_text)
266
+
267
+ if not job_analysis:
268
+ print("ERROR: Failed to analyze job description")
269
+ sys.exit(1)
270
+
271
+ print(f" Job Title: {job_analysis.title}")
272
+ print(f" Required Skills: {', '.join(job_analysis.required_skills[:10])}")
273
+ print(f" Keywords: {', '.join(job_analysis.keywords[:10])}")
274
+
275
+ print()
276
+ print(" Tailoring resume (this may take 1-3 minutes)...")
277
+ from .resume_tailor import ResumeTailor
278
+
279
+ config = GenerationConfig(
280
+ target_ats_score=92.0,
281
+ target_pages=1,
282
+ fabrication=FabricationConfig(
283
+ enabled=False,
284
+ aggressiveness=3,
285
+ max_fake_work_entries=0,
286
+ max_fake_projects=0,
287
+ ),
288
+ )
289
+
290
+ tailor = ResumeTailor(router=router, config=config, template_dir=template_dir)
291
+ result = tailor.tailor(parsed, jd_text, job_analysis=job_analysis)
292
+
293
+ print_tailored_result(result)
294
+
295
+ print()
296
+ print(" Generating ATS-friendly PDF...")
297
+
298
+ tailored_highlights = []
299
+ if result.resume_data and result.resume_data.work:
300
+ for job in result.resume_data.work:
301
+ if job.highlights:
302
+ tailored_highlights.extend(job.highlights)
303
+
304
+ pdf_bytes = None
305
+ if tailored_highlights:
306
+ try:
307
+ from .pdf_editor import replace_work_bullets
308
+ print(f" Editing original PDF in-place ({len(tailored_highlights)} tailored bullets)...")
309
+ pdf_bytes = replace_work_bullets(resume_path, tailored_highlights, output_path)
310
+ print(" Format preserved from original resume!")
311
+ except Exception as e:
312
+ logger.warning(f"In-place PDF editing failed: {e}")
313
+ print(f" In-place editing failed: {e}")
314
+ print(" Falling back to full render...")
315
+ pdf_bytes = None
316
+
317
+ if pdf_bytes is None:
318
+ from .renderer import render_pdf
319
+ pdf_bytes = render_pdf(result.resume_data, output_path=output_path)
320
+
321
+ print_section("OUTPUT")
322
+ print(f" PDF saved: {output_path}")
323
+ print(f" PDF size: {len(pdf_bytes):,} bytes")
324
+
325
+ if result.ats_score:
326
+ print()
327
+ print(f" Final ATS Score: {result.ats_score.overall_score}/100")
328
+ if result.ats_score.overall_score >= 85.0:
329
+ print(" Status: On par with JD requirements")
330
+ else:
331
+ print(" Status: Below target (target: 85)")
332
+
333
+ storage_dir = os.path.join(os.path.dirname(output_path) or ".", "storage")
334
+ os.makedirs(storage_dir, exist_ok=True)
335
+ storage_path = os.path.join(storage_dir, "resume_tailored.pdf")
336
+ shutil.copy(output_path, storage_path)
337
+ print(f" Stored: {storage_path}")
338
+
339
+ print()
340
+ print_separator("*")
341
+ print(" Done!")
342
+ print_separator("*")
343
+
344
+
345
+ if __name__ == "__main__":
346
+ main()
@@ -0,0 +1,6 @@
1
+ """
2
+ Configuration settings for the hiring agent application.
3
+ """
4
+
5
+ # Global development mode flag
6
+ DEVELOPMENT_MODE = True