saitejatirunagari Claude Sonnet 4.6 commited on
Commit
9a25145
Β·
1 Parent(s): dcd4a87

feat: LLM keyword extraction via Calibrated Keyword Match Framework

Browse files

V1 now uses an LLM call (Calibrated Keyword Match Framework) to identify
genuine hiring requirements before injection, replacing the run-gram
extractor that was pulling in people names, job board hashtags, company
marketing text, and LinkedIn UI noise from scraped JD pages.

- LLMClient.extract_keywords_llm(): sends JD to GLM with the framework
prompt; returns 15-25 ordered keyword strings (genuine skills, tools,
domain terms only); strictly excludes names, locations, UI noise
- optimize_latex_resume(): llm_client param; when provided, LLM keywords
replace the entire run-gram + quality-filter pipeline; falls back to
rule-based path if the LLM call fails
- latex_flow_for_api(): instantiates LLMClient and passes it through;
falls back gracefully if LLMClient construction fails

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Files changed (3) hide show
  1. api_server.py +7 -0
  2. src/latex_resume.py +51 -37
  3. src/llm_client.py +59 -0
api_server.py CHANGED
@@ -200,6 +200,7 @@ def latex_flow_for_api(
200
  """
201
  from src.latex_resume import optimize_latex_resume
202
  from src.candidate_vault import user_blocked_terms
 
203
 
204
  out_dir = tempfile.mkdtemp(prefix="latex_resume_")
205
  try:
@@ -207,6 +208,11 @@ def latex_flow_for_api(
207
  except Exception:
208
  blocked = []
209
 
 
 
 
 
 
210
  report = optimize_latex_resume(
211
  latex_src, jd_text,
212
  maximum_ats_mode=maximum_ats_mode,
@@ -216,6 +222,7 @@ def latex_flow_for_api(
216
  out_dir=out_dir,
217
  job_title=company or job_title or "resume",
218
  company=company or "",
 
219
  )
220
  return report, out_dir
221
 
 
200
  """
201
  from src.latex_resume import optimize_latex_resume
202
  from src.candidate_vault import user_blocked_terms
203
+ from src.llm_client import LLMClient
204
 
205
  out_dir = tempfile.mkdtemp(prefix="latex_resume_")
206
  try:
 
208
  except Exception:
209
  blocked = []
210
 
211
+ try:
212
+ llm = LLMClient()
213
+ except Exception:
214
+ llm = None
215
+
216
  report = optimize_latex_resume(
217
  latex_src, jd_text,
218
  maximum_ats_mode=maximum_ats_mode,
 
222
  out_dir=out_dir,
223
  job_title=company or job_title or "resume",
224
  company=company or "",
225
+ llm_client=llm,
226
  )
227
  return report, out_dir
228
 
src/latex_resume.py CHANGED
@@ -827,6 +827,7 @@ def optimize_latex_resume(
827
  job_title: str = "",
828
  company: str = "",
829
  progress_callback=None,
 
830
  ) -> Dict:
831
  """End-to-end LaTeX flow: gate β†’ inject β†’ (compile) β†’ measure coverage.
832
 
@@ -846,44 +847,57 @@ def optimize_latex_resume(
846
  base_text = latex_to_text(latex_src)
847
 
848
  _prog("Analyzing job description…", 10)
849
- decision = decide_includable_terms(
850
- jd_text, base_text,
851
- maximum_ats_mode=maximum_ats_mode,
852
- confirmed_terms=confirmed_terms,
853
- pasted_terms=pasted_terms,
854
- blocked_terms=blocked_terms,
855
- )
856
- expected = decision["expected_terms"]
857
- # V1 noise fix: strip LinkedIn UI / metadata noise from the keyword pool
858
- includable = filter_scraped_noise(decision["includable"], jd_text, company)
859
-
860
- # V1 ATS quality filter β€” three-tier trust model:
861
- # Tier 1: taxonomy floor + curated vocab β†’ always trusted, no frequency gate
862
- # Tier 2: 2-word JD phrases β†’ keep (any freq; specific enough)
863
- # Tier 3: 3-word JD phrases β†’ only if freq >= 2 in JD (else likely a sentence fragment)
864
- # Drop: 4+ word phrases (almost always run-on fragments from comma-separated lists)
865
- from .external_ats import extract_jd_keywords as _tax_fn
866
- try:
867
- from config import MAXIMUM_ATS_SAFE_TERMS as _SAFE
868
- except Exception:
869
- _SAFE = set()
870
- _jd_low = jd_text.lower()
871
- _trusted = set(_tax_fn(jd_text)) | {t for t in _SAFE if t in _jd_low}
872
- def _keep(t):
873
- if t in _trusted:
874
- return True
875
- words = t.split()
876
- nw = len(words)
877
- if nw >= 4:
878
- return False
879
- freq = len(re.findall(r'\b' + re.escape(t) + r'\b', _jd_low))
880
- if nw == 1:
881
- return freq >= 1 # any occurrence; filler/stop already stripped upstream
882
- if nw == 2:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
883
  return freq >= 1
884
- return freq >= 1 # 3-word: any occurrence qualifies
885
- includable = [t for t in includable if _keep(t)]
886
- gated = decision["gated"]
887
 
888
  _prog("Placing keywords in resume…", 45)
889
  final_src, injected = inject_keywords(latex_src, includable, trusted_set=_trusted)
 
827
  job_title: str = "",
828
  company: str = "",
829
  progress_callback=None,
830
+ llm_client=None,
831
  ) -> Dict:
832
  """End-to-end LaTeX flow: gate β†’ inject β†’ (compile) β†’ measure coverage.
833
 
 
847
  base_text = latex_to_text(latex_src)
848
 
849
  _prog("Analyzing job description…", 10)
850
+
851
+ if llm_client is not None:
852
+ # LLM path: Calibrated Keyword Match Framework extracts only genuine hiring
853
+ # requirements β€” no LinkedIn UI noise, people names, or company marketing.
854
+ _prog("Identifying keywords with AI…", 20)
855
+ llm_kws = llm_client.extract_keywords_llm(jd_text)
856
+ if llm_kws:
857
+ base_low = base_text.lower()
858
+ # Skip terms already present in the resume (word-boundary aware).
859
+ includable = [t for t in llm_kws if not _term_present(t.lower(), base_low)]
860
+ expected = [t.lower() for t in llm_kws]
861
+ _trusted = {t.lower() for t in llm_kws}
862
+ gated = {}
863
+ else:
864
+ # LLM call failed β€” fall through to the rule-based path below.
865
+ llm_client = None
866
+
867
+ if llm_client is None:
868
+ # Rule-based fallback (original run-gram extraction + quality filter).
869
+ decision = decide_includable_terms(
870
+ jd_text, base_text,
871
+ maximum_ats_mode=maximum_ats_mode,
872
+ confirmed_terms=confirmed_terms,
873
+ pasted_terms=pasted_terms,
874
+ blocked_terms=blocked_terms,
875
+ )
876
+ expected = decision["expected_terms"]
877
+ includable = filter_scraped_noise(decision["includable"], jd_text, company)
878
+
879
+ from .external_ats import extract_jd_keywords as _tax_fn
880
+ try:
881
+ from config import MAXIMUM_ATS_SAFE_TERMS as _SAFE
882
+ except Exception:
883
+ _SAFE = set()
884
+ _jd_low = jd_text.lower()
885
+ _trusted = set(_tax_fn(jd_text)) | {t for t in _SAFE if t in _jd_low}
886
+ def _keep(t):
887
+ if t in _trusted:
888
+ return True
889
+ words = t.split()
890
+ nw = len(words)
891
+ if nw >= 4:
892
+ return False
893
+ freq = len(re.findall(r'\b' + re.escape(t) + r'\b', _jd_low))
894
+ if nw == 1:
895
+ return freq >= 1
896
+ if nw == 2:
897
+ return freq >= 1
898
  return freq >= 1
899
+ includable = [t for t in includable if _keep(t)]
900
+ gated = decision["gated"]
 
901
 
902
  _prog("Placing keywords in resume…", 45)
903
  final_src, injected = inject_keywords(latex_src, includable, trusted_set=_trusted)
src/llm_client.py CHANGED
@@ -66,6 +66,65 @@ class LLMClient:
66
  break
67
  raise ValueError(f"Cannot parse JSON: {text[:200]}")
68
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69
  # ──────────────────────────────────────────────────────────
70
  # BATCH ASSESSMENT β€” sends 8 jobs per API call (8Γ— faster)
71
  # ──────────────────────────────────────────────────────────
 
66
  break
67
  raise ValueError(f"Cannot parse JSON: {text[:200]}")
68
 
69
+ # ──────────────────────────────────────────────────────────
70
+ # KEYWORD EXTRACTION β€” Calibrated Keyword Match Framework
71
+ # ──────────────────────────────────────────────────────────
72
+ def extract_keywords_llm(self, jd_text: str) -> list[str]:
73
+ """Extract ATS keywords from a JD using the Calibrated Keyword Match Framework.
74
+
75
+ Returns a list of 15-25 keyword strings ordered from highest to lowest ATS
76
+ priority. Returns [] on any failure so the caller can fall back gracefully.
77
+ """
78
+ system = (
79
+ "You are an ATS-aligned Job Description Keyword Intelligence Engine.\n\n"
80
+ "Identify the SMALLEST, HIGHEST-VALUE set of keywords that represent the "
81
+ "employer's actual hiring criteria for ATS platforms like Greenhouse.\n\n"
82
+ "INCLUDE ONLY:\n"
83
+ "- Specific skills, methodologies, and competencies (e.g. 'product strategy', "
84
+ "'A/B testing', 'roadmap prioritization')\n"
85
+ "- Named tools and technologies (e.g. 'SQL', 'Jira', 'Mixpanel', 'Salesforce')\n"
86
+ "- Domain/industry terms (e.g. 'e-commerce', 'B2C', 'SaaS', 'quick-commerce')\n"
87
+ "- Specific role competencies stated in the JD (e.g. 'stakeholder management', "
88
+ "'cross-functional leadership')\n"
89
+ "- Experience signals stated as requirements (e.g. '5+ years product management')\n\n"
90
+ "EXCLUDE STRICTLY β€” these must NEVER appear in the output:\n"
91
+ "- Company names, brand names, product names of the hiring company\n"
92
+ "- People names (recruiters, employees, executives listed anywhere on the page)\n"
93
+ "- City/country/location names\n"
94
+ "- Job board tags, hashtags, recruitment platform UI text (e.g. 'easy apply', "
95
+ "'search faster', 'trial ends', 'followers', career fair text)\n"
96
+ "- Generic personality adjectives ('passionate', 'dynamic', 'results-driven')\n"
97
+ "- Benefits, compensation, equal-opportunity, or marketing language\n"
98
+ "- Random words with no skill meaning\n"
99
+ "- Duplicate variations of the same concept\n\n"
100
+ "PRIORITY ORDER:\n"
101
+ "1. Terms marked required/must-have\n"
102
+ "2. Terms in the job title or opening summary\n"
103
+ "3. Specific skills/tools repeated across the JD\n"
104
+ "4. Domain expertise terms\n"
105
+ "5. Preferred/nice-to-have terms\n\n"
106
+ "Return ONLY a JSON array of 15-25 keyword strings, ordered highest to lowest "
107
+ "ATS priority. No markdown fences, no explanation, just the array.\n"
108
+ 'Example: ["product strategy","stakeholder management","A/B testing","SQL",'
109
+ '"roadmap prioritization","OKRs","go-to-market","data analytics"]'
110
+ )
111
+ user = f"Job Description:\n{(jd_text or '')[:4000]}"
112
+ try:
113
+ raw = self._call(system, user, max_tokens=512)
114
+ data = self._extract_json(raw)
115
+ if isinstance(data, list):
116
+ return [str(k).strip() for k in data
117
+ if k and len(str(k).strip()) >= 3][:30]
118
+ if isinstance(data, dict):
119
+ out: list[str] = []
120
+ for v in data.values():
121
+ if isinstance(v, list):
122
+ out.extend(str(k).strip() for k in v if k and len(str(k).strip()) >= 3)
123
+ return out[:30]
124
+ except Exception as e:
125
+ print(f"[extract_keywords_llm] failed: {e}")
126
+ return []
127
+
128
  # ──────────────────────────────────────────────────────────
129
  # BATCH ASSESSMENT β€” sends 8 jobs per API call (8Γ— faster)
130
  # ──────────────────────────────────────────────────────────