reranker / scripts /explain_prompt_experiment.py
Hemprasad Badgujar
Add CPU threading, T1-only LLM polish, experiment script
a41b023
Raw History Blame Contribute Delete
12.1 kB
"""Step 11 reasoning-prompt experiment harness β€” fast iteration on the LLM polish prompt WITHOUT a full run.
Reuses the REAL production prompt-building/parsing/acceptance code from ``rank.explain.tiers`` (imported, not
copied) so a result here reflects actual pipeline behavior, not a diverged approximation. Candidate data comes
from the last full run's ``data/reports/explanations.json`` (facts + pre-polish ``reasoning``) plus
``data/candidates.jsonl`` (evidence, reconstructed the same way ``engine._evidence_snippet`` does) β€” no
retrieval funnel, no preprocessing, so one iteration is a few seconds instead of the ~2 minutes a full
``rank.py --out ... --limit 0`` run costs.
Run:
uv run python scripts/explain_prompt_experiment.py # single-note mode, current _SYSTEM
uv run python scripts/explain_prompt_experiment.py --mode batch # batch mode, current _BATCH_SYSTEM
uv run python scripts/explain_prompt_experiment.py --variant my_try -v # a variant defined in VARIANTS below
uv run python scripts/explain_prompt_experiment.py --n 20 --batch-size 5
To try a prompt tweak: add a named entry to ``VARIANTS`` below (don't edit ``tiers.py`` until a variant proves
out), then run with ``--variant <name>``. Requires an existing ``data/reports/explanations.json`` β€” regenerate
one first (``rank.py --out submission.csv --limit 0`` or a Streamlit run) if it's missing or stale.
"""
from __future__ import annotations
import argparse
import re
import sys
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from common.runtime import configure_compute # noqa: E402
configure_compute()
import orjson # noqa: E402
from config import load_settings # noqa: E402
from jd import extract_spec # noqa: E402
from models.llm import index_gen_client # noqa: E402
from rank.explain.tiers import ( # noqa: E402
_BATCH_SIZE,
_BATCH_SYSTEM,
_RULES,
_SYSTEM,
_accept_note,
_build_batch_prompt,
_build_prompt,
_clean_blurb,
_deformulate,
_extract_notes_array,
_vary_opener,
)
REPO_ROOT = Path(__file__).resolve().parents[1]
EXPLANATIONS_PATH = REPO_ROOT / "data" / "reports" / "explanations.json"
CANDIDATES_PATH = REPO_ROOT / "data" / "candidates.jsonl"
# Named prompt variants to try head-to-head against the shipped default (_SYSTEM / _BATCH_SYSTEM, imported from
# tiers.py β€” never copied here, so "default" always reflects production exactly). Add an entry to try a tweak
# without touching tiers.py; promote it there (into _RULES) only once it wins on a real n=25+ comparison, not
# just n=10 (a "tight" 55-70-word variant looked better at n=10 β€” 80% concrete vs 70% β€” but reversed at n=25 β€”
# 62% vs 67% β€” pure sample noise; always confirm at n=25 before trusting a result).
_SYSTEM_NUMBER = (
"Write a 3 to 4 sentence recruiter note (third person, about 70 to 85 words) on why a candidate fits a role, "
+ _RULES
+ "If the candidate's own words contain ANY number or metric (scale, percentage, latency, team size, time "
"span), sentence 1 MUST include it β€” a concrete number beats a vague achievement claim every time. If no "
"number is present, describe the concrete outcome without inventing one.\n"
"Output ONLY the note, no preamble, labels, quotes, or markdown."
)
_SYSTEM_TIGHT = (
"Write a 3 sentence recruiter note (third person, about 55 to 70 words) on why a candidate fits a role, "
+ _RULES
+ "Be economical β€” no filler words, no throat-clearing, every clause earns its place. "
"Output ONLY the note, no preamble, labels, quotes, or markdown."
)
VARIANTS: dict[str, dict[str, str]] = {
"default": {"single": _SYSTEM, "batch": _BATCH_SYSTEM},
"number": {"single": _SYSTEM_NUMBER, "batch": _BATCH_SYSTEM},
"tight": {"single": _SYSTEM_TIGHT, "batch": _BATCH_SYSTEM},
}
def _load_candidates_index(candidate_ids: set[str]) -> dict[str, dict]:
"""One pass over candidates.jsonl, keeping only the requested ids (cheap substring pre-check before the
JSON parse, so scanning the ~465 MB file for a handful of ids still takes only a couple of seconds)."""
found: dict[str, dict] = {}
with CANDIDATES_PATH.open(encoding="utf-8") as handle:
for line in handle:
if len(found) == len(candidate_ids):
break
if not any(cid in line for cid in candidate_ids):
continue
record = orjson.loads(line)
cid = record.get("candidate_id")
if cid in candidate_ids:
found[cid] = record
return found
def _evidence_for(record: dict, summary_limit: int = 500, experience_limit: int = 900) -> str:
"""Mirrors ``engine._evidence_snippet`` β€” kept in sync by hand (small, stable function; if that one changes
its truncation scheme, update this to match so experiment results stay representative)."""
profile = record.get("profile") or {}
summary = str(profile.get("summary") or "").strip()[:summary_limit]
roles = record.get("career_history") or []
experience = "; ".join(
f"{r.get('title', '')} @ {r.get('company', '')} ({r.get('industry', '')}) - {r.get('description', '')}"
for r in roles[:8]
)[:experience_limit]
return f"{summary} {experience}".strip()
def _load_rows(n: int) -> list[dict]:
if not EXPLANATIONS_PATH.exists():
raise SystemExit(
f"{EXPLANATIONS_PATH} not found β€” run `uv run python rank.py --out submission.csv --limit 0` "
"(or a Streamlit run) at least once first."
)
data = orjson.loads(EXPLANATIONS_PATH.read_bytes())[:n]
ids = {row["candidate_id"] for row in data}
records = _load_candidates_index(ids)
for row in data:
row["_evidence"] = _evidence_for(records.get(row["candidate_id"], {}))
return data
def _requirements(settings) -> str:
spec = extract_spec(settings)
titles = ["role: " + ", ".join(spec.job_titles[:2])] if spec.job_titles else []
return "; ".join([*spec.must_have_skills[:6], *titles])
def _report(candidate_id: str, rank: int, cleaned: str, nlg: str, evidence: str, requirements: str, verbose: bool) -> bool:
ok = _accept_note(cleaned, nlg, evidence, requirements)
tag = "PASS" if ok else "FAIL"
sentences = len(re.findall(r"[.!?](?:\s|$)", cleaned))
print(f"{candidate_id} (rank {rank}) [{tag}] {len(cleaned)} chars, {sentences} sentences")
print(f" {cleaned[:220]}{'...' if len(cleaned) > 220 else ''}")
if verbose and not ok:
print(f" (nlg source: {nlg[:150]})")
return ok
_GAP_WORDS = ("though", "however", "gap", "lacks", "missing", "without", "albeit", "unfortunately", "limited")
def run_single(rows: list[dict], client, requirements: str, system_prompt: str, verbose: bool) -> list[tuple[bool, str]]:
results: list[tuple[bool, str]] = []
for row in rows:
nlg, evidence = row["reasoning"], row["_evidence"]
raw = client.generate(
_build_prompt(requirements, nlg, evidence), system=system_prompt, temperature=0.0, max_tokens=240,
)
if verbose:
print(f"--- raw ({row['candidate_id']}) ---\n{raw}\n")
cleaned = _vary_opener(_deformulate(_clean_blurb(raw, nlg)))
ok = _report(row["candidate_id"], row["rank"], cleaned, nlg, evidence, requirements, verbose)
results.append((ok, cleaned))
return results
def run_batch(
rows: list[dict], client, requirements: str, system_prompt: str, batch_size: int, verbose: bool
) -> list[tuple[bool, str]]:
results: list[tuple[bool, str]] = []
for start in range(0, len(rows), batch_size):
batch = rows[start : start + batch_size]
items = [(row["reasoning"], row["_evidence"]) for row in batch]
raw = client.generate(
_build_batch_prompt(requirements, items), system=system_prompt, temperature=0.0,
max_tokens=240 * len(batch) + 60,
)
if verbose:
print(f"--- raw (batch {start // batch_size + 1}, {len(batch)} candidates) ---\n{raw}\n")
notes = _extract_notes_array(raw, len(batch))
if notes is None:
print(f"batch {start // batch_size + 1}: PARSE FAILED (expected {len(batch)} notes) β€” all keep NLG")
results.extend((False, row["reasoning"]) for row in batch)
continue
for row, raw_note in zip(batch, notes):
nlg, evidence = row["reasoning"], row["_evidence"]
cleaned = _vary_opener(_deformulate(_clean_blurb(raw_note, nlg)))
ok = _report(row["candidate_id"], row["rank"], cleaned, nlg, evidence, requirements, verbose)
results.append((ok, cleaned))
return results
def _score(name: str, results: list[tuple[bool, str]], elapsed: float) -> dict:
n = len(results)
accepted = [text for ok, text in results if ok]
lengths = [len(t) for t in accepted]
words = [len(t.split()) for t in accepted]
concrete = sum(1 for t in accepted if any(ch.isdigit() for ch in t))
gappy = sum(1 for t in accepted if any(w in t.lower() for w in _GAP_WORDS))
return {
"variant": name, "accepted": len(accepted), "n": n, "seconds": round(elapsed, 1),
"avg_chars": round(sum(lengths) / len(lengths), 0) if lengths else 0,
"avg_words": round(sum(words) / len(words), 1) if words else 0,
"concrete_pct": round(100 * concrete / len(accepted), 0) if accepted else 0,
"gap_mentions": gappy,
}
def _print_scoreboard(scores: list[dict]) -> None:
print("\n=== Scoreboard ===")
header = f"{'variant':<10} {'accept':>8} {'avg_chars':>10} {'avg_words':>10} {'concrete%':>10} {'gap_mentions':>13} {'seconds':>8}"
print(header)
for s in scores:
ratio = f"{s['accepted']}/{s['n']}"
print(
f"{s['variant']:<10} {ratio:>8} {s['avg_chars']:>10} {s['avg_words']:>10} "
f"{s['concrete_pct']:>10} {s['gap_mentions']:>13} {s['seconds']:>8}"
)
print(
"\naccept = passed the real _accept_note gate (grounded + length + sentences + third-person) | "
"concrete% = share of accepted notes containing at least one digit | "
"gap_mentions = accepted notes containing a gap/caveat word (should be 0 for a strengths-only tier)"
)
def main() -> None:
parser = argparse.ArgumentParser(description="Fast iteration harness for the Step 11 reasoning prompt.")
parser.add_argument("--mode", choices=["single", "batch"], default="single")
parser.add_argument(
"--variant", default="default", choices=[*sorted(VARIANTS), "all"],
help="named prompt to test, or 'all' to score every variant head-to-head",
)
parser.add_argument("--n", type=int, default=10, help="how many real (already-ranked) candidates to test")
parser.add_argument("--batch-size", type=int, default=_BATCH_SIZE)
parser.add_argument("-v", "--verbose", action="store_true", help="also print raw LLM output")
args = parser.parse_args()
settings = load_settings()
rows = _load_rows(args.n)
requirements = _requirements(settings)
client = index_gen_client(settings)
client.ensure()
variant_names = sorted(VARIANTS) if args.variant == "all" else [args.variant]
scores = []
for name in variant_names:
system_prompt = VARIANTS[name][args.mode]
print(f"\n########## variant: {name} ##########")
started = time.perf_counter()
if args.mode == "batch":
results = run_batch(rows, client, requirements, system_prompt, args.batch_size, args.verbose)
else:
results = run_single(rows, client, requirements, system_prompt, args.verbose)
elapsed = time.perf_counter() - started
accepted = sum(1 for ok, _ in results if ok)
print(f"{accepted}/{len(rows)} accepted in {elapsed:.1f}s (mode={args.mode}, variant={name})")
scores.append(_score(name, results, elapsed))
if len(variant_names) > 1:
_print_scoreboard(scores)
if __name__ == "__main__":
main()