Spaces:
Sleeping
Sleeping
| """ | |
| 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 | |
| # --------------------------------------------------------------------------- | |
| # PassScore:简历初筛通过潜力(0-100,越高越好) | |
| # --------------------------------------------------------------------------- | |
| 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)) # 权重从 30 提升到 35 | |
| 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 | |
| # v2:跨方向转移时,只要有 LLM 项目信号就加分(而不是只加「方向完全一致」) | |
| 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"): # 推荐→LLM 转移也加分 | |
| score += 8 | |
| elif "推荐" in direction: | |
| if profile.get("has_rec_project"): | |
| score += 10 | |
| elif "后端" in direction or "Go" in direction or "Python" in direction: | |
| # 后端岗位:有 Python/Go/微服务项目就加分 | |
| if profile.get("has_backend_project") or "Python" in profile.get("skills", []): | |
| score += 12 | |
| if job.get("stage") == profile.get("_stage", ""): | |
| score += 10 | |
| # 保底 20 分,避免全零 | |
| return max(20, min(100, score)) | |
| # --------------------------------------------------------------------------- | |
| # KeywordCoverage:JD 关键词在简历中的覆盖率(0.0-1.0) | |
| # --------------------------------------------------------------------------- | |
| 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) | |
| # --------------------------------------------------------------------------- | |
| # RiskScore:投递风险(0-100,越高越危险) | |
| # --------------------------------------------------------------------------- | |
| 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 # 原来 25,降低 | |
| elif hit_rate < 0.6: | |
| risk += 5 # 原来 10,降低 | |
| if not profile.get("has_metrics"): | |
| risk += 15 # 原来 20,降低 | |
| 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: | |
| # v2:跨方向时,有相关项目(哪怕不是完全匹配)也不扣分 | |
| direction = job.get("direction", "") | |
| if "大模型" in direction and profile.get("has_llm_project"): | |
| pass # 有 LLM 项目,不扣分 | |
| elif "推荐" in direction and profile.get("has_rec_project"): | |
| pass # 有推荐项目,不扣分 | |
| else: | |
| risk += 10 # 原来 15,降低 | |
| if job.get("stage") != profile.get("_stage", "") and job.get("stage") != "不限": | |
| risk += 20 # 原来 30,降低 | |
| 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 # 原来 10,降低 | |
| if resume_text and len(resume_text.strip()) < 300: | |
| risk += 8 # 原来 10,降低 | |
| if job.get("city") and job.get("city") != profile.get("_city", "") and job.get("stage") != "不限": | |
| risk += 5 | |
| return min(100, max(0, risk)) | |
| # --------------------------------------------------------------------------- | |
| # GrowthScore:成长/冲刺价值(0-100,越高越值得冲刺) | |
| # --------------------------------------------------------------------------- | |
| 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 # 原来 10,提高(值得冲刺) | |
| else: | |
| score += 20 # 高命中率,成长空间一般 | |
| direction = job.get("direction", "") | |
| target = profile.get("_target_role", "") | |
| # v2:跨方向匹配也加分 | |
| if direction == target: | |
| score += 25 | |
| elif target in direction or direction in target: | |
| score += 20 # 原来 15,提高 | |
| elif "大模型" in direction and profile.get("has_llm_project"): | |
| score += 15 # 有 LLM 项目,跨方向也加分 | |
| 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)) | |
| # --------------------------------------------------------------------------- | |
| # 统一入口:对一个 job 计算所有转化相关分数 | |
| # --------------------------------------------------------------------------- | |
| 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) | |