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()