offer-catcher-agent-final / src /conversion.py
hungryb's picture
Deploy Offer Catcher final public demo
c974f5d verified
Raw
History Blame Contribute Delete
10.4 kB
"""
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)