Hemprasad Badgujar commited on
Commit
051be4e
Β·
1 Parent(s): 99deeee

Tune JD parse: generic compressed prompt, best-of-N recall, JD phases in explorer

Browse files

JD prompts:
- Restructure objective/subjective prompts to Role-Task-Context-Constraints-Output;
pin the persona + Mode constraint so Qwen3 skips its thinking loop.
- Make the objective prompt GENERIC (no leaked JD-specific skill/title examples) after
experiments showed listed examples don't drive recall and can anchor/echo.
- Compress the schema 12->7 keys: merge nice_to_have into must_have_skills, merge the
two location fields, drop certifications/target_industries/exclude_companies from the
prompt (they default to [] in _coerce_objective, so the funnel is unaffected). The
leaner prompt freed model capacity -> ~30 skills recall.
- job_titles: keep the verbatim posted title first, add the role family, and always
include the JD's core-activity roles (Search/Ranking/IR Engineer). Generic framing.
- acceptable_countries inferred from cities/"outside <country>"; capture every city.

Recall controls (config):
- Best-of-N objective extraction: parse_temperature (0.5), parse_attempts, min_skills,
parse_max_tokens (1536). Sample at temperature, keep the richest, early-stop at
min_skills; temperature 0 = single greedy pass.

Explorer:
- Run JD objective_extract / summary_extract as timed phases (their time counts in the
total; non-funnel so they don't skew the candidate funnel); persist & replay the
original parse time via a _timing block so cached runs show real timing.

Tooling/tests:
- scripts/jd_prompt_experiment.py: greedy A/B harness for skills + titles recall.
- tests for best-of-N keep-richest, early-stop, greedy-single-pass.

config/settings.yaml CHANGED
@@ -100,3 +100,7 @@ hard_filter: # funnel entry β€” candidate-only cuts (ind
100
  jd: # JD-parse inputs/outputs
101
  path: problem/job_description.md
102
  extraction_path: data/artifacts/jd_extraction.json # ONE combined artifact (objective + subjective + summary)
 
 
 
 
 
100
  jd: # JD-parse inputs/outputs
101
  path: problem/job_description.md
102
  extraction_path: data/artifacts/jd_extraction.json # ONE combined artifact (objective + subjective + summary)
103
+ parse_temperature: 0.5 # objective-parse sampling temperature; >0 = best-of-N keep-richest
104
+ parse_attempts: 3 # samples to draw when temperature>0; keep the richest
105
+ min_skills: 14 # early-stop target for the best-of-N path
106
+ parse_max_tokens: 1536 # objective-parse generation budget (raise if the JSON truncates)
scripts/jd_prompt_experiment.py ADDED
@@ -0,0 +1,242 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Experiment: which GENERIC objective-extraction wording best recovers the JD's skills / job_titles?
2
+
3
+ The committed prompt must NOT name the target JD's own answers as examples (that leaks the key and overfits to
4
+ one JD). This harness pits generic prompt variants against the real JD and measures:
5
+ * skills β€” recall against a hand-built ground-truth skill set (independent of any prompt's examples).
6
+ * titles β€” recall of RELEVANT adjacent roles, while flagging IRRELEVANT title leakage (precision).
7
+ Only the field-under-test's wording varies; the other fields stay at the winning generic wording.
8
+
9
+ Run: uv run python scripts/jd_prompt_experiment.py [skills|titles] (default: skills)
10
+ Greedy (temperature=0) so the comparison isolates the PROMPT effect, not sampling noise.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import sys
16
+ from pathlib import Path
17
+
18
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
19
+
20
+ from common.runtime import configure_compute # noqa: E402
21
+
22
+ configure_compute()
23
+
24
+ from config import load_settings # noqa: E402
25
+ from jd.parse import _SYSTEM, _coerce_objective, _extract_json_object # noqa: E402
26
+ from jd.spec import resolve # noqa: E402
27
+ from models.llm import index_gen_client # noqa: E402
28
+
29
+ # ---- Ground truth (built by READING the JD, never from a prompt) -------------------------------------------
30
+ # Skills genuinely present in the JD; each with match aliases. Short acronyms match by exact token; others by
31
+ # substring.
32
+ SKILLS_TRUTH: dict[str, list[str]] = {
33
+ "embeddings": ["embedding"], "retrieval": ["retrieval"], "ranking": ["ranking"],
34
+ "sentence-transformers": ["sentence-transformer", "sentence transformers"], "BGE": ["bge"], "E5": ["e5"],
35
+ "vector databases": ["vector database", "vector db"], "hybrid search": ["hybrid search"],
36
+ "Pinecone": ["pinecone"], "Weaviate": ["weaviate"], "Qdrant": ["qdrant"], "Milvus": ["milvus"],
37
+ "OpenSearch": ["opensearch"], "Elasticsearch": ["elasticsearch", "elastic search"], "FAISS": ["faiss"],
38
+ "Python": ["python"], "evaluation": ["evaluation", "eval framework"], "NDCG": ["ndcg"], "MRR": ["mrr"],
39
+ "MAP": ["map"], "A/B testing": ["a/b test", "ab test"], "fine-tuning": ["fine-tun", "finetun"],
40
+ "LoRA": ["lora"], "QLoRA": ["qlora"], "PEFT": ["peft"],
41
+ "learning-to-rank": ["learning-to-rank", "learning to rank"], "XGBoost": ["xgboost"],
42
+ "distributed systems": ["distributed system"], "inference optimization": ["inference optim"],
43
+ "open-source": ["open-source", "open source"],
44
+ }
45
+ _ACRONYMS = {"bge", "e5", "ndcg", "mrr", "map", "lora", "qlora", "peft", "faiss"}
46
+
47
+ # Job titles the ideal candidate for THIS JD (ranking / retrieval / search / applied-ML) might plausibly hold.
48
+ TITLES_GOOD: dict[str, list[str]] = {
49
+ "AI Engineer": ["ai engineer"], "ML Engineer": ["machine learning engineer", "ml engineer"],
50
+ "Search Engineer": ["search engineer"], "Ranking Engineer": ["ranking engineer", "rank engineer"],
51
+ "Applied Scientist": ["applied scientist"], "Research Engineer": ["research engineer"],
52
+ "Research Scientist": ["research scientist"], "Recommendation Engineer": ["recommendation"],
53
+ "NLP Engineer": ["nlp engineer", "natural language"],
54
+ "Information Retrieval Engineer": ["information retrieval", "retrieval engineer", "ir engineer"],
55
+ "Relevance Engineer": ["relevance engineer", "search relevance"], "MLOps Engineer": ["mlops"],
56
+ "Data Scientist": ["data scientist"],
57
+ }
58
+ # Roles that should NOT show up β€” they would mis-match candidates in the BM25 title field.
59
+ TITLES_BAD: dict[str, list[str]] = {
60
+ "Marketing": ["marketing"], "Product Manager": ["product manager"], "Sales": ["sales"],
61
+ "Frontend": ["frontend", "front-end", "front end"], "QA": ["qa engineer", "quality assurance"],
62
+ "Designer": ["designer"], "Accountant": ["accountant"], "HR": ["hr ", "human resources"],
63
+ }
64
+
65
+ # ---- Generic prompt skeleton; one field's hint varies per experiment ---------------------------------------
66
+ _KEYS = """- must_have_skills: {skills_hint}
67
+ - nice_to_have: {nice_hint}
68
+ - job_titles: {titles_hint}
69
+ - target_industries: industry keywords the ideal candidate should come from
70
+ - education_fields: field-of-study keywords
71
+ - certifications: required/preferred certification keywords; [] if none
72
+ - years_min: minimum years of experience (number)
73
+ - years_max: maximum years of experience (number)
74
+ - locations_preferred: the JD's PRIMARY work cities
75
+ - locations_acceptable: ALL OTHER cities the JD explicitly welcomes; [] if none beyond the preferred
76
+ - acceptable_countries: countries candidates are accepted from; [] if remote/worldwide with no restriction
77
+ - exclude_companies: companies the JD rules out; [] if none"""
78
+
79
+ _BODY = """# Role Definition
80
+ You are an expert technical recruiter and structured-data extractor.
81
+
82
+ # Context
83
+ The text under "Job Description" is a hiring JD. Downstream code feeds your JSON into keyword search and hard
84
+ filters, and every key maps to a candidate-database field β€” so list values must be SHORT search keywords.
85
+
86
+ # Task
87
+ Extract the facts into ONE JSON object with EXACTLY these keys:
88
+ {keys}
89
+
90
+ # Constraints & Guidelines
91
+ - Output SHORT keywords for the list fields β€” not phrases or sentences.
92
+ - Extract / infer EVERY relevant value; missing a relevant one is the worst error, but do not invent values
93
+ unrelated to this JD.
94
+ - Output EVERY key above; use [] for an empty list and a bare number for the years fields.
95
+ - Mode: output your immediate final answer with no reasoning, planning, or explanation.
96
+ - Return ONLY the JSON object β€” no markdown fences and no text before or after it.
97
+
98
+ # Job Description
99
+ {jd_text}
100
+
101
+ # JSON
102
+ """
103
+
104
+ # The winning generic hints from the skills experiment β€” held fixed while testing the OTHER field.
105
+ _WIN_SKILLS = ("EVERY required hard skill, programming language, framework, library, tool, platform, database, "
106
+ "algorithm, technique, and evaluation metric β€” include BOTH the broad concept AND each "
107
+ "specifically-named product/library/metric")
108
+ _WIN_NICE = ("EVERY preferred / bonus skill mentioned (e.g. under a 'nice to have', 'bonus', or 'would like' "
109
+ "heading); never empty if the JD lists any")
110
+ _WIN_TITLES = "MANY related job titles the ideal candidate may hold β€” several variants and adjacent roles, not just the posted role"
111
+
112
+
113
+ def _build(skills_hint: str, nice_hint: str, titles_hint: str) -> str:
114
+ return _BODY.replace("{keys}", _KEYS.format(skills_hint=skills_hint, nice_hint=nice_hint, titles_hint=titles_hint))
115
+
116
+
117
+ # Skills variants (titles fixed at the baseline) ------------------------------------------------------------
118
+ SKILLS_VARIANTS: dict[str, str] = {
119
+ "category_only": _build(_WIN_SKILLS, _WIN_NICE, _WIN_TITLES),
120
+ "neutral_examples": _build(
121
+ 'every required hard skill, tool, library, metric, platform (the KIND of token wanted, from an '
122
+ 'UNRELATED domain: "Java","Kubernetes","PostgreSQL","Spark","unit testing")',
123
+ 'every preferred / bonus skill (unrelated-domain examples: "GraphQL","Terraform","gRPC")', _WIN_TITLES),
124
+ "minimal": _build("a comprehensive keyword list of ALL required technical skills, tools, libraries, metrics "
125
+ "and platforms named in the JD", "ALL preferred / bonus skills named in the JD; [] if none",
126
+ _WIN_TITLES),
127
+ }
128
+
129
+ # Title variants (skills fixed at the winner) ---------------------------------------------------------------
130
+ TITLES_VARIANTS: dict[str, str] = {
131
+ "baseline": _build(_WIN_SKILLS, _WIN_NICE, _WIN_TITLES),
132
+ "role_family": _build(_WIN_SKILLS, _WIN_NICE,
133
+ "ALL job titles in the candidate's role family β€” the posted role, its seniority variants "
134
+ "(junior/mid/senior/staff/principal/lead), AND adjacent roles that do the SAME core work the JD "
135
+ "describes; aim for 10-15 distinct titles, do not invent unrelated roles"),
136
+ "work_grounded": _build(_WIN_SKILLS, _WIN_NICE,
137
+ "infer job titles from the WORK the JD describes (the systems they would build and own), not only the "
138
+ "posted title β€” list every engineering and scientific role that builds such systems, including "
139
+ "seniority variants; be exhaustive but stay on-topic"),
140
+ "count_target": _build(_WIN_SKILLS, _WIN_NICE,
141
+ "8-15 distinct related job titles the ideal candidate may currently or previously hold, covering "
142
+ "seniority levels and adjacent specializations relevant to this JD"),
143
+ # Construction recipe: domain Γ— suffix + the generic ML/AI role family + seniority (no JD-specific titles).
144
+ "recipe_family": _build(_WIN_SKILLS, _WIN_NICE,
145
+ "a BROAD, ON-TOPIC set (12-20) of job titles the ideal candidate may hold now or previously. Build it "
146
+ "THREE ways: (1) combine EACH core technical domain/area this JD centers on with the suffixes "
147
+ "'Engineer' and 'Scientist'; (2) add the general ML/AI role family β€” AI Engineer, Machine Learning "
148
+ "Engineer, Applied Scientist, Research Scientist, Research Engineer, Data Scientist, MLOps Engineer, "
149
+ "Software Engineer; (3) add seniority variants (Senior/Staff/Principal/Lead) of the main role. Prefer "
150
+ "'Engineer'/'Scientist' forms over 'Architect'. Do NOT invent unrelated roles (no marketing/sales/PM)."),
151
+ # Pure recipe: domain Γ— suffix + seniority, WITHOUT enumerating the role family (tests if the recipe alone works).
152
+ "recipe_pure": _build(_WIN_SKILLS, _WIN_NICE,
153
+ "a BROAD, ON-TOPIC set (12-20) of job titles. For EACH core technical domain/area this JD is about, "
154
+ "emit '<domain> Engineer' and '<domain> Scientist'; also emit the broad role family for this field and "
155
+ "seniority variants (Senior/Staff/Principal/Lead). Prefer 'Engineer'/'Scientist' forms. Stay on-topic β€” "
156
+ "no unrelated roles."),
157
+ # Winner-so-far (family enumeration) PLUS a generic ML problem-area checklist to capture the domain roles.
158
+ "recipe_full": _build(_WIN_SKILLS, _WIN_NICE,
159
+ "a BROAD, ON-TOPIC set (15-20) of distinct job titles the ideal candidate may hold now or previously. "
160
+ "Combine THREE sources: (1) the general ML/AI role family β€” AI Engineer, Machine Learning Engineer, "
161
+ "Applied Scientist, Research Scientist, Research Engineer, Data Scientist, MLOps Engineer, Software "
162
+ "Engineer; (2) for EACH ML problem-area this JD involves (e.g. search, ranking, retrieval, "
163
+ "recommendation, relevance, personalization, NLP, information retrieval), emit '<area> Engineer' and "
164
+ "where natural '<area> Scientist'; (3) seniority variants (Senior/Staff/Principal/Lead) of the primary "
165
+ "role. Prefer 'Engineer'/'Scientist' over 'Architect'. Do NOT invent unrelated roles (no "
166
+ "marketing/sales/PM/design)."),
167
+ }
168
+
169
+
170
+ def _skill_hits(extracted: list[str]) -> set[str]:
171
+ low = [s.lower() for s in extracted]
172
+ tokens = {tok for s in low for tok in s.replace("/", " ").replace(",", " ").split()}
173
+ hits: set[str] = set()
174
+ for canon, aliases in SKILLS_TRUTH.items():
175
+ for alias in aliases:
176
+ if (alias in tokens) if alias in _ACRONYMS else any(alias in s for s in low):
177
+ hits.add(canon)
178
+ return hits
179
+
180
+
181
+ def _title_good_hits(titles: list[str]) -> set[str]:
182
+ low = [t.lower() for t in titles]
183
+ return {canon for canon, aliases in TITLES_GOOD.items() if any(a in t for a in aliases for t in low)}
184
+
185
+
186
+ def _title_bad_hits(titles: list[str]) -> list[str]:
187
+ low = [t.lower() for t in titles]
188
+ return [t for t in low if any(a in t for aliases in TITLES_BAD.values() for a in aliases)]
189
+
190
+
191
+ def _extract(prompt: str, jd_text: str, client) -> dict | None:
192
+ raw = client.generate(prompt.replace("{jd_text}", jd_text), system=_SYSTEM, temperature=0.0, max_tokens=1024)
193
+ parsed = _extract_json_object(raw)
194
+ return _coerce_objective(parsed) if parsed else None
195
+
196
+
197
+ def _run_skills(jd_text: str, client) -> None:
198
+ total = len(SKILLS_TRUTH)
199
+ rows = []
200
+ for name, prompt in SKILLS_VARIANTS.items():
201
+ obj = _extract(prompt, jd_text, client) or {}
202
+ skills = obj.get("must_have_skills", []) + obj.get("nice_to_have", [])
203
+ hits = _skill_hits(skills)
204
+ rows.append((name, len(skills), len(hits)))
205
+ print(f"\n=== {name} === skills={len(skills)} recall={len(hits)}/{total} ({100*len(hits)/total:.0f}%)")
206
+ print("extracted:", skills)
207
+ print("MISSED:", sorted(set(SKILLS_TRUTH) - hits))
208
+ print("\n=========== SKILLS SUMMARY (greedy) ===========")
209
+ print(f"{'variant':<18}{'skills':>8}{'recall':>10}")
210
+ for name, n, h in rows:
211
+ print(f"{name:<18}{n:>8}{f'{h}/{total}':>10}")
212
+
213
+
214
+ def _run_titles(jd_text: str, client) -> None:
215
+ total = len(TITLES_GOOD)
216
+ rows = []
217
+ for name, prompt in TITLES_VARIANTS.items():
218
+ obj = _extract(prompt, jd_text, client) or {}
219
+ titles = obj.get("job_titles", [])
220
+ good, bad = _title_good_hits(titles), _title_bad_hits(titles)
221
+ rows.append((name, len(titles), len(good), len(bad)))
222
+ print(f"\n=== {name} === titles={len(titles)} relevant={len(good)}/{total} "
223
+ f"({100*len(good)/total:.0f}%) irrelevant={len(bad)}")
224
+ print("extracted:", titles)
225
+ print("MISSED relevant:", sorted(set(TITLES_GOOD) - good), "| IRRELEVANT:", bad)
226
+ print("\n=========== TITLES SUMMARY (greedy) ===========")
227
+ print(f"{'variant':<16}{'titles':>8}{'relevant':>12}{'irrelevant':>12}")
228
+ for name, n, g, b in rows:
229
+ print(f"{name:<16}{n:>8}{f'{g}/{total}':>12}{b:>12}")
230
+
231
+
232
+ def main() -> int:
233
+ which = sys.argv[1] if len(sys.argv) > 1 else "skills"
234
+ settings = load_settings()
235
+ jd_text = resolve(settings.jd.path).read_text(encoding="utf-8")
236
+ client = index_gen_client(settings)
237
+ (_run_titles if which == "titles" else _run_skills)(jd_text, client)
238
+ return 0
239
+
240
+
241
+ if __name__ == "__main__":
242
+ raise SystemExit(main())
src/config.py CHANGED
@@ -65,6 +65,13 @@ class JdSettings(BaseModel):
65
 
66
  path: str = "problem/job_description.md" # raw JD prose (live-parse input)
67
  extraction_path: str = "data/artifacts/jd_extraction.json" # ONE combined artifact: objective + subjective + summary
 
 
 
 
 
 
 
68
 
69
 
70
  class HardFilterSettings(BaseModel):
 
65
 
66
  path: str = "problem/job_description.md" # raw JD prose (live-parse input)
67
  extraction_path: str = "data/artifacts/jd_extraction.json" # ONE combined artifact: objective + subjective + summary
68
+ # Objective-extraction recall. With temperature>0, draw up to ``parse_attempts`` samples and keep the
69
+ # RICHEST (best-of-N), stopping early once total skills reach ``min_skills``. temperature=0.0 β‡’ a single
70
+ # greedy/deterministic pass.
71
+ parse_temperature: float = 0.5 # sampling temperature for the objective parse
72
+ parse_attempts: int = 3 # samples to draw when temperature>0; keep the most skills
73
+ min_skills: int = 14 # early-stop target for the best-of-N sampling path
74
+ parse_max_tokens: int = 1536 # objective-parse generation budget (raise if JSON truncates)
75
 
76
 
77
  class HardFilterSettings(BaseModel):
src/jd/parse.py CHANGED
@@ -36,30 +36,30 @@ hard filters, and every key maps to a candidate-database field β€” so list value
36
  never full phrases or sentences.
37
 
38
  # Task
39
- Extract the facts into ONE JSON object with EXACTLY these keys:
40
- - must_have_skills: required skill/technology KEYWORDS (e.g. "embeddings","hybrid search","Python")
41
- - nice_to_have: preferred / bonus skill KEYWORDS (e.g. "LoRA","QLoRA","learning-to-rank","distributed systems")
42
- - job_titles: MANY related job titles the ideal candidate may hold (e.g. "AI Engineer","Machine Learning Engineer","Search Engineer","Ranking Engineer","Applied Scientist") β€” several, not just the posted role
43
- - target_industries: industry KEYWORDS the ideal candidate should come from (e.g. "AI","search","fintech")
44
- - education_fields: field-of-study KEYWORDS (e.g. "Computer Science","Machine Learning")
45
- - certifications: required/preferred certification KEYWORDS (e.g. "AWS Solutions Architect"); [] if none
46
  - years_min: minimum years of experience (number)
47
  - years_max: maximum years of experience (number)
48
- - locations_preferred: the JD's PRIMARY work cities (e.g. "Pune","Noida")
49
- - locations_acceptable: ALL OTHER cities the JD explicitly welcomes β€” every other location mentioned (e.g. "Hyderabad","Mumbai","Delhi NCR","Bangalore"); [] if none beyond the preferred
50
- - acceptable_countries: countries candidates are accepted from (e.g. "India"); [] if remote/worldwide with no restriction (empty = accept everyone)
51
- - exclude_companies: company names the JD rules out or wants candidates NOT to come from; [] if none
52
 
53
  # Constraints & Guidelines
54
  - Output SHORT keywords for the list fields β€” not phrases or sentences.
 
 
 
 
55
  - Output EVERY key above; use [] for an empty list and a bare number for the years fields.
56
- - Be exhaustive for job_titles and must_have_skills.
57
  - Mode: output your immediate final answer with no reasoning, planning, or explanation.
58
  - Return ONLY the JSON object β€” no markdown fences and no text before or after it.
59
 
60
  # Output Format
61
- A single JSON object of exactly the keys above, e.g.:
62
- {"must_have_skills":["embeddings","Python"],"nice_to_have":[],"job_titles":["AI Engineer","ML Engineer"],"target_industries":["AI"],"education_fields":["Computer Science"],"certifications":[],"years_min":5,"years_max":9,"locations_preferred":["Pune"],"locations_acceptable":["Bangalore"],"acceptable_countries":["India"],"exclude_companies":[]}
 
63
 
64
  # Job Description
65
  {jd_text}
@@ -129,17 +129,24 @@ def _as_years(value: object, fallback: float) -> float:
129
 
130
 
131
  def _coerce_objective(parsed: dict[str, Any]) -> dict[str, Any] | None:
132
- """Clean the objective object into the JDSpec-shaped dict; None if no role AND no skills (unusable)."""
 
 
 
 
 
 
 
133
  objective = {
134
  "must_have_skills": _as_str_list(parsed.get("must_have_skills")),
135
- "nice_to_have": _as_str_list(parsed.get("nice_to_have")),
136
  "job_titles": _as_str_list(parsed.get("job_titles")),
137
  "target_industries": _as_str_list(parsed.get("target_industries")),
138
  "education_fields": _as_str_list(parsed.get("education_fields")),
139
  "certifications": _as_str_list(parsed.get("certifications")),
140
  "years_min": _as_years(parsed.get("years_min"), 5.0),
141
  "years_max": _as_years(parsed.get("years_max"), 9.0),
142
- "locations_preferred": _as_str_list(parsed.get("locations_preferred")),
143
  "locations_acceptable": _as_str_list(parsed.get("locations_acceptable")),
144
  "acceptable_countries": _as_str_list(parsed.get("acceptable_countries")),
145
  "exclude_companies": _as_str_list(parsed.get("exclude_companies")),
@@ -156,23 +163,58 @@ def _coerce_subjective(parsed: dict[str, Any]) -> dict[str, Any] | None:
156
 
157
 
158
  def _generate_json(
159
- prompt: str, client: LlmClient, concern: str, max_tokens: int = 1024
160
  ) -> dict[str, Any] | None:
161
- """One greedy (temp=0) generation β†’ parsed JSON object; None on any failure."""
162
  try:
163
- raw_text = client.generate(prompt, system=_SYSTEM, temperature=0.0, max_tokens=max_tokens)
164
  except Exception as failure:
165
  get_logger("jd").warning(f"jd.{concern}.failed", reason=type(failure).__name__)
166
  return None
167
  return _extract_json_object(raw_text)
168
 
169
 
170
- def extract_objective(jd_text: str, client: LlmClient) -> dict[str, Any] | None:
171
- """CALL 1 β€” objective keyword bags + filter facts; None on failure / nothing usable."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
172
  if not jd_text.strip():
173
  return None
174
- parsed = _generate_json(_OBJECTIVE_PROMPT.replace("{jd_text}", jd_text), client, "objective")
175
- return _coerce_objective(parsed) if parsed is not None else None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
176
 
177
 
178
  def extract_subjective(jd_text: str, client: LlmClient) -> dict[str, Any] | None:
 
36
  never full phrases or sentences.
37
 
38
  # Task
39
+ Extract the facts into ONE JSON object with EXACTLY these keys (descriptions are GENERIC β€” values must come
40
+ only from THIS job description, never from the wording here):
41
+ - must_have_skills: EVERY skill the JD names, REQUIRED and PREFERRED/BONUS alike β€” every programming language, framework, library, tool, platform, database, algorithm, technique, and evaluation metric; include BOTH the broad concept AND each specifically-named product / library / metric
42
+ - education_fields: field-of-study keywords
 
 
 
43
  - years_min: minimum years of experience (number)
44
  - years_max: maximum years of experience (number)
45
+ - locations: EVERY city/location the JD names β€” the primary work cities AND every OTHER city it says candidates are "welcome" from, can "apply" from, or are otherwise acceptable; list them ALL, do not stop at the primary ones
46
+ - acceptable_countries: countries candidates are accepted from β€” INFER from the work locations/cities and phrases like "outside <country>" (e.g. Indian cities such as Pune/Noida, or "outside India", β‡’ ["India"]); [] only if the role is truly remote/worldwide with no restriction
47
+ - job_titles: 12-18 relevant titles the candidate may hold now or previously. START with the JD's EXACT posted title, verbatim. THEN add the role family for the JD's field β€” the adjacent job titles that do the same core work (for an ML/AI role these are e.g. AI Engineer, Machine Learning Engineer, Search Engineer, Ranking Engineer, Applied Scientist, Research Scientist, Research Engineer, Data Scientist, MLOps Engineer, Software Engineer; for a different field use THAT field's equivalent roles) β€” and ALWAYS include the role titles named after the JD's CORE activities (e.g. a search/ranking/retrieval JD β‡’ Search Engineer, Ranking Engineer, Information Retrieval Engineer) β€” plus seniority variants of the main role (e.g. "Senior <role>", "Staff <role>", "Principal <role>"). Full, real role titles only β€” no bare words; keep every title on-topic for this JD's work
 
48
 
49
  # Constraints & Guidelines
50
  - Output SHORT keywords for the list fields β€” not phrases or sentences.
51
+ - SKILL RECALL IS THE PRIORITY: put EVERY concrete skill / tool / library / metric / technique / platform named
52
+ ANYWHERE in the JD (required OR preferred) into must_have_skills β€” the responsibilities and narrative as well
53
+ as the skills lists, not only the bullet headers. Missing a named skill is the worst error.
54
+ - Expand any parenthetical, comma, or slash list into separate keywords β€” each named item becomes its own keyword.
55
  - Output EVERY key above; use [] for an empty list and a bare number for the years fields.
 
56
  - Mode: output your immediate final answer with no reasoning, planning, or explanation.
57
  - Return ONLY the JSON object β€” no markdown fences and no text before or after it.
58
 
59
  # Output Format
60
+ A single JSON object with EXACTLY the keys above β€” list fields as arrays of short keyword strings, the years
61
+ fields as bare numbers. Shape only (the [...] are placeholders to fill from the JD, NOT values to copy):
62
+ {"must_have_skills":[...],"education_fields":[...],"years_min":0,"years_max":0,"locations":[...],"acceptable_countries":[...],"job_titles":[...]}
63
 
64
  # Job Description
65
  {jd_text}
 
129
 
130
 
131
  def _coerce_objective(parsed: dict[str, Any]) -> dict[str, Any] | None:
132
+ """Clean the objective object into the JDSpec-shaped dict; None if no role AND no skills (unusable).
133
+
134
+ The compressed prompt emits a slimmer key set β€” all skills in ``must_have_skills`` (nice_to_have merged)
135
+ and a single ``locations`` (preferred+acceptable merged). The dropped keys (target_industries,
136
+ certifications, exclude_companies) default to [] here, so JDSpec and the funnel are unaffected. Older keys
137
+ are still read as a fallback.
138
+ """
139
+ locations = _as_str_list(parsed.get("locations")) or _as_str_list(parsed.get("locations_preferred"))
140
  objective = {
141
  "must_have_skills": _as_str_list(parsed.get("must_have_skills")),
142
+ "nice_to_have": _as_str_list(parsed.get("nice_to_have")), # merged into must_have now; [] from the new prompt
143
  "job_titles": _as_str_list(parsed.get("job_titles")),
144
  "target_industries": _as_str_list(parsed.get("target_industries")),
145
  "education_fields": _as_str_list(parsed.get("education_fields")),
146
  "certifications": _as_str_list(parsed.get("certifications")),
147
  "years_min": _as_years(parsed.get("years_min"), 5.0),
148
  "years_max": _as_years(parsed.get("years_max"), 9.0),
149
+ "locations_preferred": locations,
150
  "locations_acceptable": _as_str_list(parsed.get("locations_acceptable")),
151
  "acceptable_countries": _as_str_list(parsed.get("acceptable_countries")),
152
  "exclude_companies": _as_str_list(parsed.get("exclude_companies")),
 
163
 
164
 
165
  def _generate_json(
166
+ prompt: str, client: LlmClient, concern: str, max_tokens: int = 1024, temperature: float = 0.0
167
  ) -> dict[str, Any] | None:
168
+ """One generation β†’ parsed JSON object; None on any failure. temperature=0 β‡’ greedy/deterministic."""
169
  try:
170
+ raw_text = client.generate(prompt, system=_SYSTEM, temperature=temperature, max_tokens=max_tokens)
171
  except Exception as failure:
172
  get_logger("jd").warning(f"jd.{concern}.failed", reason=type(failure).__name__)
173
  return None
174
  return _extract_json_object(raw_text)
175
 
176
 
177
+ def _skill_count(objective: dict[str, Any]) -> int:
178
+ """Total extracted skills (must_have + nice_to_have) β€” the recall measure best-of-N maximizes."""
179
+ return len(objective.get("must_have_skills", [])) + len(objective.get("nice_to_have", []))
180
+
181
+
182
+ def extract_objective(
183
+ jd_text: str, client: LlmClient, *,
184
+ temperature: float | None = None, attempts: int | None = None, min_skills: int | None = None,
185
+ ) -> dict[str, Any] | None:
186
+ """CALL 1 β€” objective keyword bags + filter facts, with a best-of-N recall pass.
187
+
188
+ Samples the objective parse at ``temperature`` up to ``attempts`` times and KEEPS THE RICHEST result (most
189
+ skills), stopping early once ``min_skills`` is reached. A greedy temperature (0.0) collapses to a single
190
+ deterministic pass (identical samples), as does ``attempts=1``. Overrides fall back to the JD config.
191
+ Returns None on failure / nothing usable.
192
+ """
193
  if not jd_text.strip():
194
  return None
195
+ from config import load_settings # lazy import β€” keeps this module config/backend-free at import time
196
+
197
+ jd_cfg = load_settings().jd
198
+ temperature = jd_cfg.parse_temperature if temperature is None else temperature
199
+ attempts = jd_cfg.parse_attempts if attempts is None else attempts
200
+ min_skills = jd_cfg.min_skills if min_skills is None else min_skills
201
+ attempts = max(1, attempts) if temperature > 0 else 1 # greedy β‡’ identical samples β‡’ one pass only
202
+ prompt = _OBJECTIVE_PROMPT.replace("{jd_text}", jd_text)
203
+ log = get_logger("jd")
204
+
205
+ best: dict[str, Any] | None = None
206
+ for attempt in range(1, attempts + 1):
207
+ parsed = _generate_json(prompt, client, "objective", max_tokens=jd_cfg.parse_max_tokens, temperature=temperature)
208
+ objective = _coerce_objective(parsed) if parsed is not None else None
209
+ if objective is None:
210
+ continue
211
+ if best is None or _skill_count(objective) > _skill_count(best):
212
+ best = objective
213
+ log.info("jd.objective.sample", attempt=attempt, attempts=attempts,
214
+ skills=_skill_count(objective), best=_skill_count(best), target=min_skills)
215
+ if _skill_count(best) >= min_skills:
216
+ break
217
+ return best
218
 
219
 
220
  def extract_subjective(jd_text: str, client: LlmClient) -> dict[str, Any] | None:
src/streamlit_app.py CHANGED
@@ -206,17 +206,29 @@ def _jd_phases(settings: Settings, regenerate: bool) -> list[PhaseOutput]:
206
  started = time.perf_counter()
207
  subjective = extract_subjective(jd_text, client) or {}
208
  sum_seconds = time.perf_counter() - started
209
- atomic_write_bytes( # one combined artifact, same shape run_jd_parse writes (objective + subjective)
210
- extraction_path, orjson.dumps({**objective, "subjective": subjective}, option=orjson.OPT_INDENT_2)
 
 
 
 
 
 
 
211
  )
212
  source = "live Qwen3 parse"
213
- else: # cached read β€” still a (fast) phase so it appears in the funnel timing
214
  started = time.perf_counter()
215
  raw = orjson.loads(extraction_path.read_bytes()) if extraction_path.exists() else {}
216
- obj_seconds = time.perf_counter() - started
217
  raw = raw if isinstance(raw, dict) else {}
218
  subjective = raw.pop("subjective", {}) or {}
219
- objective, sum_seconds, source = raw, 0.0, "cached jd_extraction.json"
 
 
 
 
 
220
 
221
  model = settings.models.index_gen_llm_repo
222
  obj_inputs = {
 
206
  started = time.perf_counter()
207
  subjective = extract_subjective(jd_text, client) or {}
208
  sum_seconds = time.perf_counter() - started
209
+ # Persist the measured parse time alongside the spec so cached runs can replay the ORIGINAL timing.
210
+ # ``_timing`` is inert to the engine (extract_spec/read_subjective only read the keys they know).
211
+ atomic_write_bytes(
212
+ extraction_path,
213
+ orjson.dumps(
214
+ {**objective, "subjective": subjective,
215
+ "_timing": {"objective_seconds": obj_seconds, "summary_seconds": sum_seconds}},
216
+ option=orjson.OPT_INDENT_2,
217
+ ),
218
  )
219
  source = "live Qwen3 parse"
220
+ else: # cached read β€” replay the original parse time recorded in the artifact (else the bare file-read time)
221
  started = time.perf_counter()
222
  raw = orjson.loads(extraction_path.read_bytes()) if extraction_path.exists() else {}
223
+ read_seconds = time.perf_counter() - started
224
  raw = raw if isinstance(raw, dict) else {}
225
  subjective = raw.pop("subjective", {}) or {}
226
+ timing = raw.pop("_timing", {}) or {}
227
+ objective = raw
228
+ obj_seconds = float(timing.get("objective_seconds", read_seconds))
229
+ sum_seconds = float(timing.get("summary_seconds", 0.0))
230
+ source = ("cached jd_extraction.json (original parse time)" if timing
231
+ else "cached jd_extraction.json (no recorded time β€” tick Regenerate to time the live parse)")
232
 
233
  model = settings.models.index_gen_llm_repo
234
  obj_inputs = {
tests/test_jd.py CHANGED
@@ -4,9 +4,24 @@ from __future__ import annotations
4
 
5
  from pathlib import Path
6
 
 
 
7
  from config import JdSettings, Settings
8
  from jd import extract_spec
9
- from jd.parse import _coerce_objective, _coerce_subjective, _extract_json_object
 
 
 
 
 
 
 
 
 
 
 
 
 
10
 
11
 
12
  def _settings(extraction_path: Path) -> Settings:
@@ -51,3 +66,24 @@ def test_coerce_objective_and_subjective() -> None:
51
  subjective = _coerce_subjective({"ideal_summary": "Senior AI Engineer with 7 years"})
52
  assert subjective == {"ideal_summary": "Senior AI Engineer with 7 years"} # only the one field
53
  assert _coerce_subjective({"ideal_traits": ["x"]}) is None # no ideal_summary β†’ unusable
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
 
5
  from pathlib import Path
6
 
7
+ import orjson
8
+
9
  from config import JdSettings, Settings
10
  from jd import extract_spec
11
+ from jd.parse import _coerce_objective, _coerce_subjective, _extract_json_object, extract_objective
12
+
13
+
14
+ class _StubClient:
15
+ """A no-model LlmClient stand-in: returns a scripted JSON string per call (records call count)."""
16
+
17
+ def __init__(self, skill_lists: list[list[str]]) -> None:
18
+ self._skill_lists, self.calls = skill_lists, 0
19
+
20
+ def generate(self, prompt: str, system: str | None = None, temperature: float = 0.0,
21
+ top_p: float = 0.95, max_tokens: int = 512, think: bool = False) -> str:
22
+ skills = self._skill_lists[min(self.calls, len(self._skill_lists) - 1)]
23
+ self.calls += 1
24
+ return "noise " + orjson.dumps({"job_titles": ["AI Engineer"], "must_have_skills": skills}).decode() + " end"
25
 
26
 
27
  def _settings(extraction_path: Path) -> Settings:
 
66
  subjective = _coerce_subjective({"ideal_summary": "Senior AI Engineer with 7 years"})
67
  assert subjective == {"ideal_summary": "Senior AI Engineer with 7 years"} # only the one field
68
  assert _coerce_subjective({"ideal_traits": ["x"]}) is None # no ideal_summary β†’ unusable
69
+
70
+
71
+ def test_extract_objective_best_of_n_keeps_richest() -> None:
72
+ # two samples: the second is richer β†’ best-of-N keeps it (and draws both since min_skills not reached)
73
+ client = _StubClient([["python"], ["python", "retrieval", "ranking", "embeddings"]])
74
+ objective = extract_objective("a real JD body", client, temperature=0.5, attempts=2, min_skills=99)
75
+ assert objective is not None
76
+ assert objective["must_have_skills"] == ["python", "retrieval", "ranking", "embeddings"]
77
+ assert client.calls == 2 # both samples drawn
78
+
79
+
80
+ def test_extract_objective_early_stops_at_min_skills() -> None:
81
+ client = _StubClient([["a", "b", "c"]])
82
+ extract_objective("jd", client, temperature=0.5, attempts=5, min_skills=3)
83
+ assert client.calls == 1 # reached the skill target on the first sample β†’ stop early
84
+
85
+
86
+ def test_extract_objective_greedy_is_single_pass() -> None:
87
+ client = _StubClient([["a"]])
88
+ extract_objective("jd", client, temperature=0.0, attempts=5, min_skills=99)
89
+ assert client.calls == 1 # temperature 0 β‡’ identical samples β‡’ one pass regardless of attempts