Spaces:
Sleeping
Sleeping
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()
|