offer-catcher-agent / src /matcher.py
hungryb's picture
Deploy Offer Catcher Agent demo
3161850 verified
Raw
History Blame Contribute Delete
16.2 kB
import json
import math
import os
from collections import Counter
from pathlib import Path
from src.resume_parser import contains
try:
from src.conversion import attach_conversion_scores
except Exception: # pragma: no cover — conversion 模块不存在时回退
attach_conversion_scores = None
# ---------------------------------------------------------------------------
# 语义匹配器(可选,sentence_transformers 未安装时回退到 token 相似度)
# ---------------------------------------------------------------------------
_SEMANTIC_MATCHER = None
_ENABLE_EMBEDDING_CLIENT = os.getenv("ENABLE_EMBEDDING_CLIENT", "").lower() in {"1", "true", "yes"}
_ENABLE_LOCAL_SEMANTIC = os.getenv("ENABLE_LOCAL_SEMANTIC_MATCHER", "").lower() in {"1", "true", "yes"}
_TRY_LOAD_SEMANTIC = False # 默认不加载,避免自动下载模型
# 主排序里的 embedding 能力必须显式开启,避免 API key 或缓存模型改变 core eval 结果。
if _ENABLE_LOCAL_SEMANTIC and os.getenv("SEMANTIC_MODEL_PATH"):
_TRY_LOAD_SEMANTIC = True
def _get_semantic_matcher():
"""Lazy-load SemanticMatcher(避免导入时即失败)。"""
global _SEMANTIC_MATCHER, _TRY_LOAD_SEMANTIC
if not _TRY_LOAD_SEMANTIC:
return _SEMANTIC_MATCHER
_TRY_LOAD_SEMANTIC = False
try:
from src.semantic_matcher import SemanticMatcher
_SEMANTIC_MATCHER = SemanticMatcher()
print("[OK] SemanticMatcher loaded — using embedding-based semantic recall.")
except Exception as exc:
print(f"[WARN] SemanticMatcher not available ({exc}) — falling back to token cosine.")
_SEMANTIC_MATCHER = None
return _SEMANTIC_MATCHER
def semantic_similarity(resume_text: str, job_text: str) -> float:
"""语义相似度(优先 API/client,回退 embedding,最后 token cosine)。"""
# 1) EmbeddingClient 默认关闭,避免误用 API 或自动加载未声明的 embedding 模型。
if _ENABLE_EMBEDDING_CLIENT:
try:
from src.embedding_client import get_embedding_client
_CLIENT = get_embedding_client()
if _CLIENT.is_available():
sim = _CLIENT.semantic_similarity(resume_text, job_text)
if sim is not None:
return sim
except Exception:
pass
# 2) SemanticMatcher(本地 sentence-transformers)
matcher = _get_semantic_matcher()
if matcher is not None:
try:
return matcher.compute_similarity(resume_text, job_text)
except Exception:
pass
# 3) fallback:token cosine
return cosine_similarity(resume_text, job_text)
WEIGHTS = {
"技能匹配": 0.28,
"项目匹配": 0.25,
"经历匹配": 0.10,
"方向匹配": 0.27,
"成长潜力": 0.10,
}
def clamp_score(value: float) -> int:
return max(0, min(100, round(value)))
def tokenize(text: str) -> list[str]:
text = (text or "").lower()
tokens = []
buff = []
for char in text:
if char.isascii() and char.isalnum():
buff.append(char)
else:
if buff:
tokens.append("".join(buff))
buff = []
if "\u4e00" <= char <= "\u9fff":
tokens.append(char)
if buff:
tokens.append("".join(buff))
return tokens
def cosine_similarity(left: str, right: str) -> float:
left_counter = Counter(tokenize(left))
right_counter = Counter(tokenize(right))
if not left_counter or not right_counter:
return 0.0
keys = set(left_counter) | set(right_counter)
dot = sum(left_counter[k] * right_counter[k] for k in keys)
left_norm = math.sqrt(sum(v * v for v in left_counter.values()))
right_norm = math.sqrt(sum(v * v for v in right_counter.values()))
return dot / (left_norm * right_norm)
def load_jobs(jobs_path: Path) -> list[dict]:
with jobs_path.open("r", encoding="utf-8") as file:
return json.load(file)
def overlap(required: list[str], actual: list[str], resume_text: str) -> list[str]:
return [
term
for term in required
if term in actual or contains(resume_text, term)
]
def score_job(job: dict, profile: dict, resume_text: str, target_role: str, target_city: str, stage: str) -> dict:
matched_skills = overlap(job["skills"], profile["skills"], resume_text)
missing_skills = [skill for skill in job["skills"] if skill not in matched_skills]
matched_project = overlap(job["project_signals"], profile["project_signals"], resume_text)
semantic_recall = semantic_similarity(resume_text, f"{job['title']} {job['jd']} {' '.join(job['skills'])}")
skill_score = clamp_score((len(matched_skills) / max(len(job["skills"]), 1)) * 100)
project_score = clamp_score(
(len(matched_project) / max(len(job["project_signals"]), 1)) * 100
+ (8 if profile["has_metrics"] else 0)
+ semantic_recall * 12
)
experience_score = 86 if job["stage"] == stage else 68
direction_score = 92 if job["direction"] == target_role else 40
city_score = 88 if target_city == "不限" else 96 if job["city"] == target_city else 62
preference_score = clamp_score(0.72 * direction_score + 0.28 * city_score)
growth_score = clamp_score(
48
+ min(len(profile["skills"]), 10) * 3
+ (12 if profile["has_llm_project"] else 0)
+ (8 if profile["has_rec_project"] else 0)
)
dimensions = {
"技能匹配": skill_score,
"项目匹配": project_score,
"经历匹配": experience_score,
"方向匹配": preference_score,
"成长潜力": growth_score,
}
match_score = clamp_score(sum(dimensions[name] * weight for name, weight in WEIGHTS.items()))
# direction match bonus: 目标方向岗位获得额外加分
if job["direction"] == target_role:
match_score = clamp_score(match_score + 15)
# title match bonus: 精确匹配 target_role 的岗位加分最高;含额外方向词(如 Agent)的次之
import re as _re
title_lower = job["title"].lower()
target_lower = target_role.lower()
title_exact_match = False
if target_lower in title_lower:
# 精确匹配:title 去掉「实习生/工程师」后缀后与 target_role 高度一致
title_clean = _re.sub(r"(实习生|工程师|生|员)$", "", job["title"]).lower()
target_clean = _re.sub(r"(实习生|工程师|生|员)$", "", target_role).lower()
if target_clean in title_clean or title_clean in target_clean:
match_score = clamp_score(match_score + 20)
title_exact_match = True
else:
# 模糊匹配(如 target=大模型应用算法,title=大模型 Agent 应用实习生)
match_score = clamp_score(match_score + 6)
else:
title_exact_match = False
# 迁移型强匹配补偿:当用户目标方向较宽,但某个非目标方向岗位在技能和项目上
# 明显更贴合简历时,不能只因 direction 字段不同被平台/泛算法岗位压下去。
if job["direction"] != target_role and skill_score >= 75 and project_score >= 70:
match_score = clamp_score(match_score + 5)
# 基础返回值
scored = dict(job)
scored.update(
{
"match_score": match_score,
"score": match_score, # 保留兼容
"growth_score": growth_score,
"semantic_recall": round(semantic_recall, 4),
"target_role": target_role,
"target_city": target_city,
"target_stage": stage,
"matched_skills": matched_skills,
"missing_skills": missing_skills,
"dimensions": dimensions,
"strengths": build_strengths(job, matched_skills, profile),
"gaps": build_gaps(job, missing_skills, profile),
"rewrites": build_rewrites(job, matched_skills, missing_skills),
"interview_plan": build_interview_plan(job, missing_skills),
}
)
# 附加 conversion 分数(PassScore / RiskScore / KeywordCoverage / GrowthScore)
if attach_conversion_scores is not None:
try:
conv = attach_conversion_scores(
profile=profile,
job=scored,
resume_text=resume_text,
target_role=target_role,
target_city=target_city,
stage=stage,
)
# 安全赋值:过滤 None 值(conv 返回值可能包含显式 None)
_conv_map = {
"pass_score": 50,
"risk_score": 50,
"growth_score": growth_score,
"keyword_coverage": 0.0,
}
for key, default in _conv_map.items():
val = conv.get(key) if isinstance(conv, dict) else None
# 关键:val is None 时用 default(不仅 key 不存在,值本身为 None 也要覆盖)
scored[key] = default if val is None else val
except Exception as e:
import sys
print(f"[WARN] attach_conversion_scores failed: {e}", file=sys.stderr)
scored.setdefault("pass_score", 50)
scored.setdefault("risk_score", 50)
scored.setdefault("growth_score", growth_score)
scored.setdefault("keyword_coverage", 0.0)
else:
scored.setdefault("pass_score", 50)
scored.setdefault("risk_score", 50)
scored.setdefault("keyword_coverage", 0.0)
# 计算 ApplyPriority(确保 growth_score 不为 None)
ps = scored.get("pass_score", 50)
rs = scored.get("risk_score", 50)
gs = scored.get("growth_score")
if gs is None:
gs = growth_score # 使用 score_job 本地计算的 growth_score
scored["growth_score"] = gs # 写回字典,防止后续读者读到 None
ms = scored.get("match_score", match_score)
apply_priority = round(0.40 * ms + 0.30 * ps - 0.15 * rs + 0.15 * gs, 2)
# title_exact_match bonus:精确匹配 target_role 的岗位在 apply_priority 上加权重,
# 防止 match_score 被 clamp_score 封顶后无法区分精确/模糊匹配
if title_exact_match:
apply_priority = round(apply_priority + 1.5, 2)
scored["apply_priority"] = apply_priority
# 附加证据链(Evidence Chain)
try:
from src.evidence import attach_evidence
scored = attach_evidence(scored, resume_text, profile)
except Exception:
pass
# 标记 title 精确匹配,供排序破 tie 使用
scored["title_exact_match"] = title_exact_match
return scored
def rank_jobs(
resume_text: str,
profile: dict,
target_role: str,
target_city: str,
stage: str,
top_k: int,
jobs_path: Path,
) -> list[dict]:
"""
返回全部打分后的岗位列表(按 apply_priority 降序)。
调用方可根据需要用 sorted() 构造 by_match / by_priority 两个榜单。
"""
jobs = load_jobs(jobs_path)
scored_jobs = [
score_job(job, profile, resume_text, target_role, target_city, stage)
for job in jobs
]
# 默认按 apply_priority 降序排列,title_exact_match=True 的岗位排在同分前
scored_jobs.sort(
key=lambda item: (
item.get("apply_priority", item.get("score", 0)),
int(item.get("title_exact_match", False)),
),
reverse=True,
)
return scored_jobs[:top_k]
def rank_job_list(
resume_text: str,
profile: dict,
target_role: str,
target_city: str,
stage: str,
top_k: int,
jobs: list[dict],
) -> list[dict]:
"""
对传入的 jobs list 打分排序(不读取文件)。
与 rank_jobs 参数兼容,jobs_path 替换为 jobs 列表。
"""
scored_jobs = [
score_job(job, profile, resume_text, target_role, target_city, stage)
for job in jobs
]
scored_jobs.sort(
key=lambda item: (
item.get("apply_priority", item.get("score", 0)),
int(item.get("title_exact_match", False)),
),
reverse=True,
)
return scored_jobs[:top_k]
def build_strengths(job: dict, matched_skills: list[str], profile: dict) -> list[str]:
strengths = []
if matched_skills:
strengths.append(f"命中核心技能:{'、'.join(matched_skills[:6])},具备进入岗位精排池的基础。")
if profile["has_llm_project"] and "大模型" in job["direction"]:
strengths.append("简历中已有 LLM/RAG/Agent 信号,能对齐大模型应用算法岗位。")
if profile["has_rec_project"] and ("推荐" in job["direction"] or "匹配" in job["jd"]):
strengths.append("推荐系统、召回排序、语义表征经历可以迁移到人岗匹配推荐。")
if profile["has_metrics"]:
strengths.append("项目描述包含 NDCG、HitRate、TopK 等指标,便于证明算法效果。")
return strengths or ["当前简历具备基础技术栈,但需要补充更明确的项目证据。"]
def build_gaps(job: dict, missing_skills: list[str], profile: dict) -> list[str]:
gaps = []
if missing_skills:
gaps.append(f"岗位还要求 {'、'.join(missing_skills[:5])},建议补充真实项目或技能证据。")
if not profile["has_metrics"]:
gaps.append("项目缺少可量化指标,容易被认为只是功能实现。")
if "Agent" in job["jd"] and "Agent" not in profile["project_signals"]:
gaps.append("Agent 工作流表达不够突出,需要说明任务拆解、工具调用和失败兜底。")
return gaps or ["暂无明显硬伤,建议继续压缩表达并突出岗位关键词。"]
def build_rewrites(job: dict, matched_skills: list[str], missing_skills: list[str]) -> list[dict]:
skills = job.get("skills") or ["岗位相关技能"]
main_skill = matched_skills[0] if matched_skills else skills[0]
missing = missing_skills[0] if missing_skills else skills[-1]
return [
{
"before": "负责推荐系统建模和模型结果分析。",
"after": f"围绕 {job['direction']} 场景,使用 {main_skill} 构建候选召回与精排链路,并通过 NDCG@10 / TopK 命中率评估模型收益。",
},
{
"before": "做过一个 LLM 求职助手 Demo。",
"after": "设计多 Agent 求职匹配流程,将简历解析、JD 理解、岗位排序、能力缺口诊断和简历优化拆成可解释节点。",
},
{
"before": "熟悉大模型相关技术。",
"after": f"熟悉 {'、'.join(skills[:4])},可独立完成 Prompt 模板、结构化输出、检索增强和结果评估配置。",
},
{
"before": "技能栏可以继续补充。",
"after": f"若确有实践,建议补充 {missing},并用一次实验、接口调用或离线评估证明不是仅停留在概念层。",
},
]
def build_interview_plan(job: dict, missing_skills: list[str]) -> list[str]:
themes = job.get("interview_themes", ["算法基础", "项目深挖", "系统设计", "业务理解"])
if not themes or len(themes) < 2:
themes = ["算法基础", "项目深挖", "系统设计", "业务理解"]
skills = job.get("skills") or ["岗位相关技能"]
focus = missing_skills[:3] or skills[:3]
if not focus:
focus = ["岗位相关技能"]
return [
f"第 1 天:复盘 {job['direction']} 岗位 JD,整理关键词和项目证据。",
f"第 2 天:准备 {themes[0]},讲清输入、模型、输出和指标。",
f"第 3 天:补齐 {focus[0]} 的实践案例,写成可追问的简历 bullet。",
f"第 4 天:准备 {themes[1]},重点讲失败 case 和优化方案。",
f"第 5 天:针对 {job['company']} 场景模拟 3 个算法方案题。",
"第 6 天:做一次 20 分钟项目深挖 mock,覆盖数据、模型、评估、落地。",
"第 7 天:压缩成 1 分钟自我介绍、3 分钟项目介绍和 5 个高频追问答案。",
]