File size: 7,782 Bytes
9bf4a3d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""
Provider evaluation harness (spec #6, #7).

Reusable functions to run the EXACT production generation logic
(`ResumeCustomizer._generate_resume_v4`) once per provider on a shared set of
JDs, then collect comparable metrics. The scripts in scripts/ format the tables.

Model-independent: works with any provider (Claude / Kimi / NVIDIA) and with the
deterministic StubProvider for offline smoke runs.
"""

from __future__ import annotations

import os
import re
import json
import time
import statistics
from typing import List, Optional

from .resume_model import Resume


# ── loaders ──────────────────────────────────────────────────────────────────

def load_base_resume() -> Optional[Resume]:
    """Load the candidate base resume from the parsed cache or the real PDF."""
    cache = os.path.join("data", "resume", "_parsed.json")
    if os.path.exists(cache):
        try:
            with open(cache, encoding="utf-8") as f:
                data = json.load(f)
            return Resume.from_dict(data.get("resume", data))
        except Exception:
            pass
    pdf = os.path.join("data", "resume", "resume.pdf")
    if os.path.exists(pdf):
        from .resume_parser_v2 import parse_resume_pdf_cached
        return parse_resume_pdf_cached(pdf)
    return None


def load_jd_fixtures(limit: int = None) -> List[dict]:
    """Load JD fixtures as job dicts from tests/fixtures/jds/*.txt."""
    jd_dir = os.path.join("tests", "fixtures", "jds")
    jobs: List[dict] = []
    if not os.path.isdir(jd_dir):
        return jobs
    for fn in sorted(os.listdir(jd_dir)):
        if not fn.endswith(".txt") or fn.lower() == "readme.txt":
            continue
        path = os.path.join(jd_dir, fn)
        try:
            with open(path, encoding="utf-8") as f:
                text = f.read()
        except Exception:
            continue
        slug = os.path.splitext(fn)[0]
        title, company = _title_company_from_slug(slug, text)
        jobs.append({"title": title, "company": company, "description": text,
                     "ats_keywords": "", "_raw_assessment": {}, "_jd_slug": slug})
    if limit:
        jobs = jobs[:limit]
    return jobs


def _title_company_from_slug(slug: str, text: str) -> tuple:
    first = (text.strip().splitlines() or [""])[0][:60]
    pretty = slug.replace("_", " ").title()
    return (f"Product Manager ({pretty})", pretty)


# ── single evaluation ──────────────────────────────────────────────────────────

def _make_customizer(llm, base_text: str, output_dir: str):
    from .resume_customizer import ResumeCustomizer
    c = ResumeCustomizer.__new__(ResumeCustomizer)
    c.llm = llm
    c.resume_text = base_text
    c.fast_model_cfg = None
    c.output_dir = output_dir
    os.makedirs(output_dir, exist_ok=True)
    return c


def evaluate_provider_on_job(provider, base_resume: Resume, job: dict,
                             output_dir: str, llm=None) -> dict:
    """Run one provider on one job through the production v4 path; return a row."""
    c = _make_customizer(llm, base_resume.to_flat_text(), output_dir)
    safe = re.sub(r'[\\/*?:"<>|]', "", f"{provider.name}_{job.get('_jd_slug', job.get('company',''))}")[:90]
    filepath = os.path.join(output_dir, safe + ".docx")

    row = {"provider": getattr(provider, "name", "?"),
           "job": job.get("_jd_slug", job.get("company", "")),
           "internal": 0, "independent": 0, "readability": 0,
           "status": "", "repair_attempts": 0, "risk_terms": 0,
           "schema_errors": 0, "provider_errors": 0, "download_allowed": False,
           "runtime_s": 0.0}

    t0 = time.time()
    try:
        path = c._generate_resume_v4(job, cfg=None, filepath=filepath,
                                     provider=provider, base_resume_override=base_resume)
    except Exception as e:
        row["provider_errors"] = 1
        row["status"] = f"ERROR:{str(e)[:40]}"
        row["runtime_s"] = round(time.time() - t0, 1)
        return row
    row["runtime_s"] = round(time.time() - t0, 1)

    report = job.get("_v2_report", {}) or {}
    est = report.get("estimated_scores", {}) or {}
    row["internal"] = est.get("jd_match", 0)
    row["independent"] = report.get("independent_jd_match", 0)
    row["readability"] = est.get("ats_readability", 0)
    row["status"] = report.get("status", job.get("_v2_status", ""))
    row["repair_attempts"] = len(report.get("repair_attempts", []) or [])
    row["risk_terms"] = (len(report.get("review_terms_for_user_review", []) or [])
                         + len(report.get("high_risk_terms_for_confirmation", []) or []))
    row["download_allowed"] = bool(report.get("download_allowed"))
    pq = report.get("provider_response_quality", "ok")
    row["schema_errors"] = 0 if pq == "ok" else 1
    return row


def run_provider_matrix(providers: List, base_resume: Resume, jobs: List[dict],
                        output_root: str, llm=None) -> List[dict]:
    rows: List[dict] = []
    for provider in providers:
        out = os.path.join(output_root, getattr(provider, "name", "provider"))
        for job in jobs:
            rows.append(evaluate_provider_on_job(provider, base_resume, job, out, llm))
    return rows


def summarize(rows: List[dict]) -> List[dict]:
    """Per-provider summary table (spec #6)."""
    by_provider: dict = {}
    for r in rows:
        by_provider.setdefault(r["provider"], []).append(r)
    out: List[dict] = []
    for prov, rs in by_provider.items():
        n = len(rs) or 1
        ready = sum(1 for r in rs if r.get("download_allowed"))
        out.append({
            "provider": prov,
            "ready_rate": round(100 * ready / n),
            "avg_independent": round(statistics.mean(r["independent"] for r in rs), 1),
            "avg_internal": round(statistics.mean(r["internal"] for r in rs), 1),
            "avg_repairs": round(statistics.mean(r["repair_attempts"] for r in rs), 1),
            "schema_error_rate": round(100 * sum(r["schema_errors"] for r in rs) / n),
            "provider_error_rate": round(100 * sum(r["provider_errors"] for r in rs) / n),
            "risk_overuse": round(statistics.mean(r["risk_terms"] for r in rs), 1),
            "avg_runtime_s": round(statistics.mean(r["runtime_s"] for r in rs), 1),
            "recommendation": "preferred" if (ready / n) >= 0.8 else (
                "usable" if (ready / n) >= 0.5 else "deprioritize"),
        })
    out.sort(key=lambda x: (x["ready_rate"], x["avg_independent"]), reverse=True)
    return out


# ── consistency (spec #7) ──────────────────────────────────────────────────────

def consistency_runs(provider, base_resume: Resume, job: dict,
                     output_dir: str, n: int = 3, llm=None) -> dict:
    """Run the same provider on the same JD n times; report variance + stability."""
    scores: List[int] = []
    for i in range(n):
        out = os.path.join(output_dir, f"run{i+1}")
        row = evaluate_provider_on_job(provider, base_resume, dict(job), out, llm)
        scores.append(row["independent"])
    variance = round(statistics.pvariance(scores), 1) if len(scores) > 1 else 0.0
    spread = max(scores) - min(scores) if scores else 0
    return {"provider": getattr(provider, "name", "?"),
            "job": job.get("_jd_slug", job.get("company", "")),
            "scores": scores, "variance": variance, "spread": spread,
            "stable": spread <= 5}