Spaces:
Sleeping
Sleeping
Download apps/api/chatbot.py from ayushgupta7777/resumematch-api: direct link, hf CLI and curl.
- Browser
- Download file 16 kB
-
https://huggingface.co/spaces/ayushgupta7777/resumematch-api/resolve/main/apps/api/chatbot.py
- Command line
-
hf download hf://spaces/ayushgupta7777/resumematch-api/apps/api/chatbot.py
-
curl -L -o chatbot.py https://huggingface.co/spaces/ayushgupta7777/resumematch-api/resolve/main/apps/api/chatbot.py
16 kB
| """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() | |