""" jd_parser.py — JD 文本解析器(规则版) 将自由文本 JD 转为标准化 dict,与 jobs.json 结构兼容。 """ from __future__ import annotations import re from typing import Optional # 方向映射(JD 关键词 → direction 标签) DIRECTION_KEYWORDS = { "大模型应用": "大模型应用算法", "LLM": "大模型应用算法", "Agent": "大模型应用算法", "RAG": "大模型应用算法", "大模型算法": "大模型应用算法", "推荐算法": "推荐算法", "推荐系统": "推荐算法", "推荐排序": "推荐算法", "搜索算法": "大模型应用算法", "检索": "大模型应用算法", "自然语言处理": "大模型应用算法", "NLP": "大模型应用算法", "计算机视觉": "大模型应用算法", "CV": "大模型应用算法", "YOLO": "大模型应用算法", "后端研发": "后端研发", "后端开发": "后端研发", "Go": "后端研发", "数据分析": "数据分析", "SQL": "数据分析", "产品经理": "产品经理", "匹配算法": "大模型应用算法", "人岗匹配": "大模型应用算法", } SKILL_PATTERNS = [ "Python", "PyTorch", "TensorFlow", "Transformer", "BERT", "GPT", "LLM", "RAG", "Agent", "Embedding", "LangChain", "FAISS", "Faiss", "推荐系统", "召回", "排序", "NDCG", "A/B Test", "ABTest", "A/B 测试", "Prompt", "Prompt Engineering", "Prompt 工程", "语义检索", "向量检索", "搜索", "重排", "rerank", "Java", "Go", "C\\+\\+", "Rust", "Scala", "Docker", "Kubernetes", "K8s", "gRPC", "微服务", "MySQL", "Redis", "Kafka", "MongoDB", "Elasticsearch", "SQL", "Spark", "Hadoop", "数据仓库", "模型微调", "LoRA", "QLoRA", "SFT", "RLHF", "OpenCV", "YOLO", "目标检测", "图像分类", "自然语言处理", "NLP", "命名实体识别", "NER", "文本分类", "Seq2Seq", "摘要生成", "Beam Search", "Attention", "数据清洗", "特征工程", "模型部署", ] def parse_jd(jd_text: str) -> dict: """解析单个 JD 文本,返回标准化 dict。""" text = jd_text.strip() lines = text.split("\n") title = _extract_title(lines, text) company = _extract_company(lines, text) city = _extract_city(lines, text) stage = _extract_stage(lines, text) direction = _infer_direction(title, text) skills = _extract_skills(text) project_signals = _extract_project_signals(text, skills) hard_requirements = _extract_hard_requirements(text) bonus_requirements = _extract_bonus_requirements(text) risk_flags = _extract_risk_flags(text) interview_themes = _infer_interview_themes(skills, text) return { "id": f"user_{_hash_title(title)}", "title": title, "company": company, "city": city, "direction": direction, "stage": stage, "skills": skills, "project_signals": project_signals, "jd": text[:500], "hard_requirements": hard_requirements, "bonus_requirements": bonus_requirements, "risk_flags": risk_flags, "interview_themes": interview_themes, "source": "user_pasted", "raw_text": text, } def _hash_title(title: str) -> str: h = 0 for c in title: h = (h * 31 + ord(c)) & 0xFFFFFFFF return hex(h)[2:] def _extract_title(lines: list[str], text: str) -> str: # 优先从第一行提取 first = lines[0].strip().lstrip("#").strip() # 去掉常见前缀 for prefix in ["岗位:", "职位:", "岗位名称:", "标题:", "Title:"]: if first.startswith(prefix): first = first[len(prefix):].strip() if first and len(first) < 50: if any(kw in first for kw in ["算法", "实习", "工程师", "开发", "产品", "数据"]): return first for line in lines: s = line.strip().lstrip("#").strip() for prefix in ["岗位:", "职位:"]: if s.startswith(prefix): s = s[len(prefix):].strip() if any(kw in s for kw in ["实习生", "工程师", "岗位"]) and len(s) < 60: return s return "算法岗位" def _extract_company(lines: list[str], text: str) -> str: pat = re.compile(r"(公司|部门|事业群|企业)[::]\s*(.+)") m = pat.search(text) if m: return m.group(2).strip()[:30] for kw in ["腾讯", "字节", "阿里", "百度", "美团", "快手", "小红书", "B站", "微软", "Google"]: if kw in text: return kw return "未知公司" def _extract_city(lines: list[str], text: str) -> str: cities = ["北京", "上海", "深圳", "广州", "杭州", "成都", "武汉", "南京", "苏州", "西安"] for c in cities: if c in text: return c pat = re.compile(r"(城市|地点|工作地点)[::]\s*(.+)") m = pat.search(text) if m: return m.group(2).strip()[:20] return "不限" def _extract_stage(lines: list[str], text: str) -> str: if "实习" in text: return "实习" if "校招" in text or "应届" in text or "毕业" in text: return "校招" if "社招" in text or "经验" in text or "年以" in text: return "社招" return "不限" def _infer_direction(title: str, text: str) -> str: combined = title + " " + text # 按优先级匹配 for kw, direction in DIRECTION_KEYWORDS.items(): if kw.lower() in combined.lower(): return direction # 从 title 推断 if "算法" in title: return "大模型应用算法" if "后端" in title or "研发" in title: return "后端研发" return "通用" def _extract_skills(text: str) -> list[str]: found = [] text_lower = text.lower() for skill in SKILL_PATTERNS: if skill.lower() in text_lower: found.append(skill) # 去重 + 保持顺序 seen = set() result = [] for s in found: if s.lower() not in seen: result.append(s) seen.add(s.lower()) return result[:20] def _extract_project_signals(text: str, skills: list[str]) -> list[str]: signals = [] # 从 skills 中选最具项目信号价值的 signal_keywords = { "RAG", "Agent", "Embedding", "Transformer", "微服务", "推荐系统", "召回", "排序", "重排", "检测", "识别", "分类", "搜索", "LLM", "Prompt", "模型部署", "模型微调", "LoRA", } for s in skills: if s in signal_keywords: signals.append(s) return signals[:10] def _extract_hard_requirements(text: str) -> list[str]: reqs = [] if "Python" in text: reqs.append("熟练使用 Python") if "PyTorch" in text or "TensorFlow" in text: reqs.append("熟悉 PyTorch / TensorFlow") if "硕士" in text or "研究生" in text: reqs.append("硕士及以上学历") if "本科" in text: reqs.append("本科及以上学历") if any(kw in text for kw in ["3 年", "3年", "三年", "5 年", "5年"]): reqs.append("相关经验") return reqs or ["具备基本编程能力"] def _extract_bonus_requirements(text: str) -> list[str]: bonus = [] for kw in ["LLM", "大模型", "Agent", "RAG", "论文", "开源", "Kaggle"]: if kw in text: bonus.append(f"有 {kw} 经验者优先") return bonus[:5] def _extract_risk_flags(text: str) -> list[str]: flags = [] if "5 年" in text or "5年" in text or "资深" in text: flags.append("要求较高经验") if "985" in text or "211" in text or "一本" in text: flags.append("学历门槛") return flags def _infer_interview_themes(skills: list[str], text: str) -> list[str]: themes = [] skill_set = {s.lower() for s in skills} if "rag" in skill_set or "检索" in skill_set: themes.append("RAG 检索增强") if "agent" in skill_set: themes.append("Agent 工具调用") if "transformer" in skill_set: themes.append("Transformer 原理") if "推荐系统" in skill_set or "召回" in skill_set: themes.append("推荐系统与召回排序") if "embedding" in skill_set: themes.append("Embedding 与向量检索") if "模型微调" in skill_set or "lora" in skill_set: themes.append("模型微调技术") if not themes: themes = ["算法基础", "项目深挖", "系统设计"] return themes[:4] # --------------------------------------------------------------------------- # LLM 增强解析(可选,失败自动 fallback 到规则版) # --------------------------------------------------------------------------- JD_LLM_SCHEMA = """{ "title": "岗位名称", "company": "公司/部门", "city": "城市", "direction": "技术方向(大模型应用算法/推荐算法/后端研发/数据分析/产品经理)", "stage": "阶段(实习/校招/社招)", "skills": ["要求的技能列表"], "project_signals": ["项目信号词"], "hard_requirements": ["硬性要求"], "bonus_requirements": ["加分项"], "risk_flags": ["潜在风险提示"], "interview_themes": ["面试主题"] }""" JD_LLM_PROMPT = """你是一个岗位 JD 解析器。请从以下 JD 文本中提取结构化信息,严格按 JSON Schema 输出。 Schema: {schema} 要求: 1. 只输出 JSON,不要输出任何其他文字 2. skills 只提取 JD 中明确提到或强烈暗示的技术名称 3. direction 从 大模型应用算法/推荐算法/后端研发/数据分析/产品经理 中选择最匹配的 4. stage 从 实习/校招/社招 中选择 5. 如果某字段无法确定,填写空列表或"不限" JD 文本: {jd_text}""" def parse_jd_with_llm(jd_text: str, llm_client=None) -> dict: """LLM 增强 JD 解析,失败自动 fallback 到 parse_jd。""" if llm_client is None: return parse_jd(jd_text) try: from src.llm_client import LLMClient if not isinstance(llm_client, LLMClient) or not llm_client.available: return parse_jd(jd_text) except ImportError: return parse_jd(jd_text) prompt = JD_LLM_PROMPT.format(schema=JD_LLM_SCHEMA, jd_text=jd_text[:3000]) result = llm_client.chat_json("你是精确的岗位 JD 解析器。", prompt) if result is None: return parse_jd(jd_text) # LLM 失败 → fallback # 校验 + 补全。LLM 输出只作为结构化候选,类型不可信时退回规则值。 fallback = parse_jd(jd_text) scalar_keys = ["title", "company", "city", "direction", "stage"] list_keys = [ "skills", "project_signals", "hard_requirements", "bonus_requirements", "risk_flags", "interview_themes", ] for key in scalar_keys: if not isinstance(result.get(key), str) or not result.get(key, "").strip(): result[key] = fallback.get(key, "不限") for key in list_keys: if not isinstance(result.get(key), list): result[key] = fallback.get(key, []) else: result[key] = [str(item).strip() for item in result[key] if str(item).strip()] if not result["skills"]: result["skills"] = fallback.get("skills", []) if not result["project_signals"]: result["project_signals"] = fallback.get("project_signals", []) if not result["interview_themes"]: result["interview_themes"] = fallback.get("interview_themes", ["项目深挖"]) result["id"] = f"llm_{_hash_title(result.get('title', ''))}" result["jd"] = jd_text[:500] result["source"] = "user_pasted" result["raw_text"] = jd_text return result