Spaces:
Sleeping
feat: LLM keyword extraction via Calibrated Keyword Match Framework
Browse filesV1 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>
- api_server.py +7 -0
- src/latex_resume.py +51 -37
- src/llm_client.py +59 -0
|
@@ -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 |
|
|
@@ -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 |
-
|
| 850 |
-
|
| 851 |
-
|
| 852 |
-
|
| 853 |
-
|
| 854 |
-
|
| 855 |
-
|
| 856 |
-
|
| 857 |
-
|
| 858 |
-
|
| 859 |
-
|
| 860 |
-
|
| 861 |
-
|
| 862 |
-
|
| 863 |
-
|
| 864 |
-
|
| 865 |
-
|
| 866 |
-
|
| 867 |
-
|
| 868 |
-
|
| 869 |
-
|
| 870 |
-
|
| 871 |
-
|
| 872 |
-
|
| 873 |
-
|
| 874 |
-
|
| 875 |
-
|
| 876 |
-
|
| 877 |
-
|
| 878 |
-
|
| 879 |
-
|
| 880 |
-
|
| 881 |
-
|
| 882 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 883 |
return freq >= 1
|
| 884 |
-
|
| 885 |
-
|
| 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)
|
|
@@ -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 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|