| """ |
| Conversion Agent — 简历转化率优化模块(v2:校准分数,减少跨方向过度惩罚) |
| 计算 PassScore(简历初筛通过潜力)、KeywordCoverage(JD 关键词覆盖率)、 |
| RiskScore(投递风险,越高越危险),供 Application Ranker Agent 使用。 |
| """ |
|
|
| from __future__ import annotations |
|
|
| import re |
| from collections import Counter |
| from typing import Optional |
|
|
| |
| |
| |
|
|
| _SKILL_TOKEN_RE = re.compile(r"[a-zA-Z0-9_\-\+/]+(?:\s*[+/]\s*[a-zA-Z0-9_\-\+]+)*") |
|
|
|
|
| def _tokenize(text: str) -> list[str]: |
| """小写、ASCII 词 + 中文字符分离。""" |
| tokens: list[str] = [] |
| buf = [] |
| for ch in (text or "").lower(): |
| if ch.isascii() and (ch.isalnum() or ch in "_-+/"): |
| buf.append(ch) |
| else: |
| if buf: |
| tokens.append("".join(buf)) |
| buf = [] |
| if "\u4e00" <= ch <= "\u9fff": |
| tokens.append(ch) |
| if buf: |
| tokens.append("".join(buf)) |
| return tokens |
|
|
|
|
| def _counter(text: str) -> Counter: |
| return Counter(_tokenize(text)) |
|
|
|
|
| def _cosine(a: Counter, b: Counter) -> float: |
| if not a or not b: |
| return 0.0 |
| dot = sum(a[k] * b[k] for k in set(a) | set(b)) |
| norm_a = sum(v * v for v in a.values()) ** 0.5 |
| norm_b = sum(v * v for v in b.values()) ** 0.5 |
| return dot / (norm_a * norm_b) if norm_a and norm_b else 0.0 |
|
|
|
|
| |
| |
| |
|
|
| def calc_pass_score(profile: dict, job: dict, resume_text: Optional[str] = None) -> int: |
| """ |
| 基于简历结构化画像 + JD,估算 HR/系统初筛通过概率对应的分数。 |
| |
| v2 校准: |
| - 降低「阶段不一致」的惩罚(从 -10 改为更温和的处理) |
| - 跨方向转移(如 CV→LLM)不应过度扣分 |
| - 纯推荐/纯后端选手投 AI 平台岗位,技能命中率应合理计算 |
| """ |
| score = 0 |
|
|
| skills = job.get("skills", []) |
| matched = [s for s in skills if s in profile.get("skills", [])] |
| if skills: |
| hit_rate = len(matched) / len(skills) |
| score += int(35 * min(hit_rate, 1.0)) |
|
|
| project_sigs = job.get("project_signals", job.get("project_signals", [])) |
| if project_sigs: |
| matched_proj = [s for s in project_sigs if s in profile.get("project_signals", [])] |
| score += int(20 * min(len(matched_proj) / len(project_sigs), 1.0)) |
|
|
| if profile.get("has_metrics"): |
| score += 15 |
|
|
| |
| direction = job.get("direction", "") |
| if "大模型" in direction or "LLM" in direction or "Agent" in direction: |
| if profile.get("has_llm_project"): |
| score += 15 |
| elif profile.get("has_rec_project"): |
| score += 8 |
| elif "推荐" in direction: |
| if profile.get("has_rec_project"): |
| score += 10 |
| elif "后端" in direction or "Go" in direction or "Python" in direction: |
| |
| if profile.get("has_backend_project") or "Python" in profile.get("skills", []): |
| score += 12 |
|
|
| if job.get("stage") == profile.get("_stage", ""): |
| score += 10 |
|
|
| |
| return max(20, min(100, score)) |
|
|
|
|
| |
| |
| |
|
|
| def calc_keyword_coverage(resume_text: str, job: dict) -> float: |
| if not resume_text: |
| return 0.0 |
|
|
| jd_text = job.get("jd", "") |
| skill_tokens = _tokenize(" ".join(job.get("skills", []))) |
| jd_tokens = _tokenize(jd_text) |
| all_tokens = Counter(skill_tokens + jd_tokens) |
|
|
| resume_counter = _counter(resume_text) |
|
|
| if not all_tokens: |
| return 0.0 |
|
|
| hit = sum(1 for t in all_tokens if resume_counter.get(t, 0) > 0) |
| return round(hit / max(len(all_tokens), 1), 4) |
|
|
|
|
| |
| |
| |
|
|
| def calc_risk_score(profile: dict, job: dict, resume_text: Optional[str] = None) -> int: |
| """ |
| 投递风险分:越高表示这份简历投这个岗位越容易炮灰/被刷。 |
| |
| v2 校准: |
| - 跨方向转移(CV→LLM、后端→AI平台)不应过度惩罚 |
| - 「无量化指标」扣 15(原来 20),避免过度惩罚 |
| - 「阶段不一致」扣 20(原来 30),更温和 |
| """ |
| risk = 0 |
|
|
| skills = job.get("skills", []) |
| matched = [s for s in skills if s in profile.get("skills", [])] |
| hit_rate = len(matched) / max(len(skills), 1) |
| if hit_rate < 0.2: |
| risk += 30 |
| elif hit_rate < 0.4: |
| risk += 15 |
| elif hit_rate < 0.6: |
| risk += 5 |
|
|
| if not profile.get("has_metrics"): |
| risk += 15 |
|
|
| project_sigs = job.get("project_signals", job.get("project_signals", [])) |
| if project_sigs: |
| matched_proj = [s for s in project_sigs if s in profile.get("project_signals", [])] |
| if not matched_proj: |
| |
| direction = job.get("direction", "") |
| if "大模型" in direction and profile.get("has_llm_project"): |
| pass |
| elif "推荐" in direction and profile.get("has_rec_project"): |
| pass |
| else: |
| risk += 10 |
|
|
| if job.get("stage") != profile.get("_stage", "") and job.get("stage") != "不限": |
| risk += 20 |
|
|
| jd_text = job.get("jd", "").lower() |
| if any(kw in jd_text for kw in ["agent", "工具调用", "多轮", "tool"]): |
| if not profile.get("has_llm_project"): |
| risk += 8 |
|
|
| if resume_text and len(resume_text.strip()) < 300: |
| risk += 8 |
|
|
| if job.get("city") and job.get("city") != profile.get("_city", "") and job.get("stage") != "不限": |
| risk += 5 |
|
|
| return min(100, max(0, risk)) |
|
|
|
|
| |
| |
| |
|
|
| def calc_growth_score(profile: dict, job: dict) -> int: |
| """ |
| 冲刺价值分:岗位方向是否有助于能力成长、是否值得「跳一跳」投。 |
| |
| v2 校准: |
| - 跨方向转移(CV→LLM、后端→AI平台)应该有更高的 growth(值得冲刺) |
| - 纯推荐/纯后端选手投 AI 平台岗位,growth 应该反映「学习空间」 |
| """ |
| score = 0 |
|
|
| skills = job.get("skills", []) |
| matched = [s for s in skills if s in profile.get("skills", [])] |
| if skills: |
| miss_rate = 1.0 - len(matched) / len(skills) |
| if 0.3 <= miss_rate <= 0.7: |
| score += 30 |
| elif miss_rate > 0.7: |
| score += 15 |
| else: |
| score += 20 |
|
|
| direction = job.get("direction", "") |
| target = profile.get("_target_role", "") |
|
|
| |
| if direction == target: |
| score += 25 |
| elif target in direction or direction in target: |
| score += 20 |
| elif "大模型" in direction and profile.get("has_llm_project"): |
| score += 15 |
| elif "推荐" in direction and profile.get("has_rec_project"): |
| score += 10 |
|
|
| jd_text = job.get("jd", "").lower() |
| if any(kw in jd_text for kw in ["agent", "llm", "排序", "召回", "rag"]): |
| if profile.get("has_llm_project") or profile.get("has_rec_project"): |
| score += 20 |
|
|
| if job.get("stage") == profile.get("_stage", ""): |
| score += 15 |
|
|
| if job.get("city") == profile.get("_city", ""): |
| score += 10 |
|
|
| return max(10, min(100, score)) |
|
|
|
|
| |
| |
| |
|
|
| def attach_conversion_scores( |
| jobs: list[dict] | dict | None = None, |
| profile: Optional[dict] = None, |
| resume_text: Optional[str] = None, |
| target_role: str = "", |
| target_city: str = "", |
| stage: str = "", |
| job: Optional[dict] = None, |
| ) -> list[dict] | dict: |
| """ |
| 给 job 或 jobs 附加 PassScore / RiskScore / GrowthScore / KeywordCoverage。 |
| |
| 兼容两种调用: |
| - attach_conversion_scores(job=one_job, profile=...) |
| - attach_conversion_scores(jobs_list, profile=...) |
| """ |
| prof = dict(profile) if profile else {} |
| prof["_stage"] = stage |
| prof["_city"] = target_city |
| prof["_target_role"] = target_role |
|
|
| single = False |
| if job is not None: |
| jobs_list = [job] |
| single = True |
| elif isinstance(jobs, dict): |
| jobs_list = [jobs] |
| single = True |
| else: |
| jobs_list = list(jobs or []) |
|
|
| result = [] |
| for item in jobs_list: |
| pass_score = calc_pass_score(prof, item, resume_text) |
| risk_score = calc_risk_score(prof, item, resume_text) |
| growth_score = calc_growth_score(prof, item) |
| kw_cov = calc_keyword_coverage(resume_text or "", item) |
|
|
| j = dict(item) |
| j["pass_score"] = pass_score |
| j["risk_score"] = risk_score |
| j["growth_score"] = growth_score |
| j["keyword_coverage"] = kw_cov |
| result.append(j) |
| return result[0] if single and result else ({} if single else result) |
|
|