File size: 12,116 Bytes
a41b023 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 | """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()
|