File size: 16,017 Bytes
b6bf2df
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b8d610c
b6bf2df
 
 
 
 
d23a0ad
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b6bf2df
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
41bcb29
 
 
 
b6bf2df
 
 
41bcb29
 
 
 
 
b6bf2df
 
 
 
 
 
 
 
 
 
 
 
 
41bcb29
 
 
 
 
eb5f99c
b6bf2df
 
 
34b2eec
 
 
 
 
 
b6bf2df
 
 
 
 
5a2849e
 
 
 
 
 
 
b6bf2df
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79a0908
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3c057b1
 
 
 
 
 
 
 
 
 
 
 
 
 
b6bf2df
 
 
 
79a0908
 
b6bf2df
 
 
 
503f63d
 
 
 
 
 
 
 
 
 
 
 
b6bf2df
 
 
 
 
 
3c057b1
 
b6bf2df
 
 
89b4875
 
 
 
 
b6bf2df
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5a2849e
 
d23a0ad
 
5a2849e
d23a0ad
 
5a2849e
 
 
 
 
 
 
 
 
 
 
3c057b1
 
 
 
 
d23a0ad
 
 
 
3c057b1
 
d23a0ad
 
 
 
3c057b1
 
 
 
 
 
 
 
 
b6bf2df
 
 
 
 
 
 
3c057b1
 
 
 
 
 
 
 
b6bf2df
 
 
 
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
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
"""Grounded product-help assistant for ResumeMatch Lab.

A narrow product guide: it explains what ResumeMatch measures, what each metric
means, and how to read the charts — grounded on the CURRENT comparison's result
JSON so it can explain *these* numbers, never invent them. Unlike CreatorPulse's
assistant it needs no tools: the result the user is looking at is passed in as
context, so there is nothing to fetch.

Uses Groq's OpenAI-compatible endpoint via ``requests`` (already a core dep). The
endpoint that calls it is sync, so FastAPI runs the blocking request in a threadpool.
"""

from __future__ import annotations

import os

import requests

GROQ_URL = "https://api.groq.com/openai/v1/chat/completions"
DEFAULT_MODEL = "openai/gpt-oss-120b"
MAX_USER_CHARS = 1500
MAX_TURNS = 12
TIMEOUT_S = 30

SYSTEM_PROMPT = """You are the product guide for ResumeMatch Lab, a statistical
tool for resumes, built by Ayush Gupta (GitHub: ayushgupta07xx).
You work on every page, so a visitor can understand the product WITHOUT running
anything first — explain concepts and the product generally when no result is present.

The tool has TWO modes, both scoring against 9,014 real Indian tech job postings:
1. COMPARE (A/B): the user uploads two versions of one resume; the tool runs a full
   statistical pipeline to say which version matches the market better, by how much,
   and in which job segments. This is the primary mode and most metrics below describe it.
2. SINGLE FIT: the user scores ONE resume against the market. Instead of an A/B verdict,
   it returns a per-cluster "Role Fit" — a 0-100 percentile of how well the resume
   matches each of the 9 job clusters (ranked against every corpus job's match to that
   cluster), plus matched skills, missing skills, and section coverage. Use this to help
   a user see which roles they lean toward and what to add. When a single-fit result is
   present, ground your answer in its role_fit percentiles and matched/missing skills.

You explain what the tool does, what its numbers mean, and how to read its charts.
You are friendly, concise, and above all HONEST.

WHAT THE TOOL MEASURES
- Each resume is embedded (BAAI/bge-small-en-v1.5, 384-dim) and scored against every
  job by cosine similarity, using the resume's BEST-MATCHING chunk (asymmetric late
  interaction) so a specialist section isn't diluted by breadth. Higher = closer to
  that job's text. This is a proxy for TEXTUAL fit, NOT a hiring-outcome predictor.
- For each job, delta = score_B - score_A. The whole analysis is about that paired
  difference across 9,014 jobs.

WHAT EACH METRIC MEANS (explain in plain terms; use the live numbers you are given)
- Verdict "wins by X points": the mean of (score_B - score_A) across all jobs, x100
  for readability. Sign says which resume matches more jobs better on average.
- Paired t-test / Wilcoxon signed-rank (normality-gated): asks "is that average
  difference real, or noise?" A Shapiro-Wilk test picks the t-test when the deltas are
  roughly normal, else the rank-based Wilcoxon. A tiny p-value means the difference is
  very unlikely to be zero. p can underflow to 0 at this sample size.
- Bootstrap BCa 95% CI: resamples the paired deltas 10,000 times to get a
  bias-corrected-and-accelerated confidence interval. It's the headline interval
  because it makes no normality assumption. If it excludes zero, the effect is real.
- Cohen's d: the mean delta in standard-deviation units — effect SIZE, not just
  significance. ~0.2 small, ~0.5 medium, ~0.8 large. A big sample can make a trivial
  gap significant; d tells you if it's practically meaningful.
- CUPED variance reduction: uses pre-experiment job covariates (cluster, description
  length) to strip out variance unrelated to the resumes, tightening the estimate. A
  reported "40% variance reduction, x1.67 effective N" means the CI is as tight as a
  ~67% bigger sample would give. It never changes which resume wins — only the precision.
  (Use the actual figures from THIS comparison, shown in the results.)
- mSPRT (always-valid p): a sequential test whose p-value stays valid even if you peek
  as jobs stream in — no inflated false positives from optional stopping.
- Bayesian Beta-Binomial posterior: treats each job as a win/loss for B and gives the
  probability that B beats A on a given job, with a credible interval. A posterior
  mean near 0.29 would mean B out-scores A on roughly 29% of jobs. (Use the actual
  figure from THIS comparison, shown in the results.)
- Per-cluster breakdown with BH-FDR: the same test run inside each of the 9 job
  clusters, with a Benjamini-Hochberg correction so testing 9 clusters at once doesn't
  inflate false positives. This can reveal a resume winning overall yet LOSING the
  clusters it targets (a Simpson's-paradox reversal).

HOW TO READ THE CHARTS
- Match-score distributions: two overlaid histograms of every job's similarity score,
  one per resume. Shifted right = higher typical match. Overlap = the resumes are close.
- Bayesian posterior curve: the probability distribution over the share of jobs B wins.
  A narrow peak = high certainty; the petrol dashes mark the 95% credible interval; the
  gray line at 0.50 is the tie point. A peak left of 0.50 means A tends to win.
- Per-cluster forest plot: each cluster's delta (B-A) with its 95% interval; bars right
  of zero favor B, left favor A; an interval crossing zero is not significant there.

THE CLUSTERS
- The 9 clusters are K-means groups of the job corpus, labeled by their dominant titles.
  Most are crisp (Data & Analytics, Data Engineering, Machine Learning / AI, DevOps / SRE / Cloud,
  Frontend / Backend / Full-stack, Product / Project Management, Software Engineering / QA); the
  two boilerplate-dominated residual clusters have no crisp segment and are named "Mixed —
  Enterprise/Generalist" and "Mixed — IT Services/Ops" rather than forced into a specialty the
  jobs don't support. A cluster is a rough segment of the market, not a recruiter's verdict.

YOUR RULES
- Base every number on the RESULT CONTEXT you are given. Restate and explain those
  figures; never invent or estimate a number, CI, or p-value that isn't there. This
  includes PROJECTED or ILLUSTRATIVE numbers: do not claim a skill appears in "~80% of
  resumes", that a change "lifts the score to ~0.7", or any cohort/percentage/
  before-after figure not present in the context. Give improvement advice
  QUALITATIVELY (which skills to add, which sections to strengthen) and attach a number
  only when it is one the context actually provides.
- If asked about something the result doesn't contain, say you don't have it.
- Be honest about the ceiling: cosine similarity measures textual/vocabulary fit, not
  whether a candidate is a good hire. Say so when relevant.
- Stay on ResumeMatch topics. If asked for medical, legal, or financial advice or
  something unrelated, briefly decline and steer back.
- If asked who made it: ResumeMatch Lab is designed, built, and maintained by Ayush
  Gupta, a developer targeting data/analytics/engineering roles; GitHub ayushgupta07xx.
- It is free to use, runs in the browser, and never stores an uploaded resume.
- Job data provenance: the 9,014-job corpus comes from JobAtlas, a sibling project by
  the same developer that aggregates and de-duplicates Indian tech postings. ResumeMatch
  reuses JobAtlas's job universe and BGE embeddings for consistency. If asked where the
  jobs come from or about JobAtlas, share: https://github.com/ayushgupta07xx/JobAtlas
- Keep answers short and plain — usually a few sentences. Use the user's actual numbers."""


class GroqError(RuntimeError):
    """Raised on any upstream/config failure; carries a short reason string."""


def prepare_messages(raw: list[dict]) -> list[dict]:
    out: list[dict] = []
    for m in raw[-MAX_TURNS:]:
        role = m.get("role")
        content = (m.get("content") or "").strip()[:MAX_USER_CHARS]
        if role in ("user", "assistant") and content:
            out.append({"role": role, "content": content})
    if not out or out[-1]["role"] != "user":
        return []
    return out


def _fit_context(result: dict) -> str:
    """Grounding block for a single-résumé fit (no A/B verdict): per-cluster
    role_fit percentile, matched/missing skills, and coverage."""
    ov = result.get("overall", {})
    rows = sorted(
        result.get("clusters", []),
        key=lambda c: c.get("role_fit", 0),
        reverse=True,
    )
    lines = [
        "FIT CONTEXT (single résumé scored against the job market — cite these):",
        f"- overall: mean_score={ov.get('mean_score')}, best_score={ov.get('best_score')}, "
        f"n_jobs={ov.get('n_jobs')}",
        "- role_fit is a within-role percentile (0-100): the résumé's match to that "
        "cluster ranked against every corpus job's match to it. NOT a hireability score.",
        "- per-cluster (strongest first):",
    ]
    for c in rows:
        matched = ", ".join(sk["skill"] for sk in (c.get("matched_skills") or [])[:6])
        missing = ", ".join(sk["skill"] for sk in (c.get("missing_skills") or [])[:6])
        cov = c.get("coverage", {})
        secs = cov.get("sections", {})
        present = ", ".join(k for k, v in secs.items() if v) or "none"
        lines.append(
            f"  {c.get('label')}: role_fit={c.get('role_fit')}, "
            f"skills_ratio={cov.get('skills_ratio')}, quantified={cov.get('quantified')}, "
            f"sections=[{present}]"
        )
        lines.append(f"      matched: {matched or 'none'}; missing: {missing or 'none'}")
    return "\n".join(lines)


def _mde_summary(mde: list | None) -> str:
    """One-line MDE grid: each power row with its alpha=0.05 floor (the
    conventional operating point), plus the strictest/loosest corners."""
    if not mde:
        return "not available"
    parts = []
    for row in mde:
        power = row.get("power")
        a05 = row.get("alpha=0.05")
        if power is not None and a05 is not None:
            parts.append(f"{int(power * 100)}% power -> d>={a05}")
    return "; ".join(parts) if parts else "not available"


def _result_context(result: dict | None) -> str:
    """Compact the result JSON into a grounding block the model can cite."""
    if not result:
        return "No comparison has been run yet; explain concepts generally."
    if result.get("mode") == "single_fit":
        return _fit_context(result)
    v = result.get("verdict", {})
    cu = result.get("cuped", {})
    ba = result.get("bayes", {})
    eff = result.get("effect", {})
    clusters = []
    for c in result.get("clusters", []):
        diff = c.get("differentiators") or {}
        af = ", ".join(s["skill"] for s in diff.get("a_favoring", [])[:4])
        bf = ", ".join(s["skill"] for s in diff.get("b_favoring", [])[:4])
        line = (
            f"{c.get('label')}: delta={c.get('mean_delta')}, winner={c.get('winner')}, "
            f"p_bh={c.get('p_bh_fdr')}"
        )
        if af or bf:
            line += f"  [A-favoring skills: {af or 'none'}; B-favoring: {bf or 'none'}]"
        clusters.append(line)
    lines = [
        "RESULT CONTEXT (the comparison currently on screen — cite these):",
        f"- verdict: {v.get('headline')}",
        f"- mean_delta_points: {v.get('mean_delta_points')}, ci_points: {v.get('ci_points')}, "
        f"p={v.get('p_value')}, cohens_d={v.get('cohens_d')}, confidence={v.get('confidence')}",
        f"- effect: achieved_power={eff.get('achieved_power')}, required_n_80={eff.get('required_n_80')}",
        "- mde (minimum detectable effect, Cohen's d; rows=power, cols=alpha): "
        + _mde_summary(eff.get("mde")),
        f"- cuped: variance_reduction={cu.get('variance_reduction')}, "
        f"effective_n_multiplier={cu.get('effective_n_multiplier')}",
        f"- bayes: P(B>A)={ba.get('prob_b_beats_a')}, credible_interval={ba.get('credible_interval')}",
        f"- cluster win-counts (authoritative — use these, do not recount): "
        f"A wins {sum(1 for c in result.get('clusters', []) if c.get('winner') == 'A')}, "
        f"B wins {sum(1 for c in result.get('clusters', []) if c.get('winner') == 'B')}, "
        f"tie {sum(1 for c in result.get('clusters', []) if c.get('winner') == 'tie')} "
        f"of {len(result.get('clusters', []))}",
        "- per-cluster: " + " | ".join(clusters),
    ]
    return "\n".join(lines)


def _call(api_key: str, model: str, convo: list[dict]) -> dict:
    payload = {
        "model": model,
        "messages": convo,
        "temperature": 0.2,
        "max_tokens": 700,
    }
    try:
        r = requests.post(
            GROQ_URL,
            headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
            json=payload,
            timeout=TIMEOUT_S,
        )
    except requests.RequestException as e:
        raise GroqError("network") from e
    if r.status_code == 429:
        raise GroqError("rate_limited")
    if r.status_code >= 400:
        raise GroqError(f"upstream_{r.status_code}")
    try:
        return r.json()["choices"][0]["message"]
    except (KeyError, IndexError, ValueError) as e:
        raise GroqError("bad_response") from e


_PAGE_MAP = {
    "/": "the home page — product intro with an example verdict.",
    "/compare": "the upload page — the user adds two resume versions (A and B) to compare, or switches to single-resume mode to score one resume against the market.",
    "/fit": "the single-resume fit page, where the user scores one resume against the job market.",
    "/how-it-works": "the methodology page explaining the statistical pipeline stage by stage.",
    "/results": "the A/B results page — this comparison's verdict, metric cards, charts, and per-cluster breakdown.",
    "/fit/results": "the single-resume fit results — per-cluster Role Fit percentiles, matched and missing skills, and section coverage.",
    "/about": "the About page describing the product and its maker.",
}


def _page_line(page: str | None) -> str:
    if not page:
        return ""
    desc = _PAGE_MAP.get(page.rstrip("/") or "/") or _PAGE_MAP.get(page)
    return f"The user is currently viewing {desc}" if desc else f"The user is on {page}."


def _length_directive(mode: str) -> str:
    """Answer-length steer. Soft targets — the model won't hit exact counts,
    but brief stays tight and bulleted, detailed goes fuller."""
    if mode == "detailed":
        return (
            "ANSWER LENGTH — DETAILED MODE: give a thorough, well-structured answer of "
            "roughly 220-320 words. Open with a one-sentence direct answer, then expand "
            "with context, the 'why', and a concrete example or edge case where useful. "
            "Use short paragraphs or bullets. Do not pad, but do be genuinely complete."
        )
    return (
        "ANSWER LENGTH — BRIEF MODE: answer in AT MOST 4 short bullet points (or 3 short "
        "sentences), UNDER 90 words total. No opening sentence, no preamble, no restating "
        "the question, no closing summary. Lead straight with the answer. If the topic is "
        "large, give only the single most important point and stop."
    )


def groq_chat(
    messages: list[dict],
    result: dict | None = None,
    page: str | None = None,
    mode: str = "brief",
) -> str:
    """Grounded chat: system prompt + result context + user turns. No tools."""
    api_key = os.environ.get("GROQ_API_KEY")
    if not api_key:
        raise GroqError("unconfigured")
    model = os.environ.get("GROQ_MODEL", DEFAULT_MODEL)
    convo = [
        {"role": "system", "content": SYSTEM_PROMPT},
        {
            "role": "system",
            "content": _page_line(page)
            + "\n"
            + _result_context(result)
            + "\n"
            + _length_directive(mode),
        },
        *messages,
    ]
    msg = _call(api_key, model, convo)
    return (msg.get("content") or "").strip()