Tune JD parse: generic compressed prompt, best-of-N recall, JD phases in explorer
Browse filesJD 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 +4 -0
- scripts/jd_prompt_experiment.py +242 -0
- src/config.py +7 -0
- src/jd/parse.py +66 -24
- src/streamlit_app.py +17 -5
- tests/test_jd.py +37 -1
|
@@ -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)
|
|
@@ -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())
|
|
@@ -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):
|
|
@@ -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 |
-
|
| 41 |
-
-
|
| 42 |
-
-
|
| 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 |
-
-
|
| 49 |
-
-
|
| 50 |
-
-
|
| 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
|
| 62 |
-
|
|
|
|
| 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":
|
| 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
|
| 162 |
try:
|
| 163 |
-
raw_text = client.generate(prompt, system=_SYSTEM, temperature=
|
| 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
|
| 171 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 172 |
if not jd_text.strip():
|
| 173 |
return None
|
| 174 |
-
|
| 175 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|
|
@@ -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 |
-
|
| 210 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 211 |
)
|
| 212 |
source = "live Qwen3 parse"
|
| 213 |
-
else: # cached read β
|
| 214 |
started = time.perf_counter()
|
| 215 |
raw = orjson.loads(extraction_path.read_bytes()) if extraction_path.exists() else {}
|
| 216 |
-
|
| 217 |
raw = raw if isinstance(raw, dict) else {}
|
| 218 |
subjective = raw.pop("subjective", {}) or {}
|
| 219 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 = {
|
|
@@ -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
|