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 个高频追问答案。", ]