"""离线确定性抽取器:技能词表 + 版面规则,从 JD 文本抽出结构化岗位定义。 版面约定(数据集与真实主流招聘平台格式兼容): - “岗位名称:X”行给出岗位名称; - 职责段(岗位职责/工作职责/工作内容/职位职责)逐条编号; - 要求段(任职要求/岗位要求/任职条件/任职资格)出现必备技能; - 加分段(加分项/优先考虑/优先条件)出现加分技能; - 尾部段落(团队与福利/福利待遇/岗位福利/公司福利)不属于任何技能区, 作为加分段的结束边界,避免福利文案中的技能词混入加分技能; - 应用场景词表全文匹配。 每个字段都附原文证据片段,供人机协同审核防“幻觉”。 """ from __future__ import annotations import logging import re from app.extract.base import SkillMatcher, load_lexicon from app.models import ExtractionResult, JDRecord logger = logging.getLogger(__name__) # 版面标题词表(生成器与抽取器共用,保证规则与数据一致) DUTY_HEADERS = ["岗位职责", "工作职责", "工作内容", "职位职责"] REQ_HEADERS = ["任职要求", "岗位要求", "任职条件", "任职资格"] PREF_HEADERS = ["加分项", "优先考虑", "优先条件"] # 尾部段落标题:出现在加分段之后的团队/福利介绍,作为版面区域结束边界 TAIL_HEADERS = ["团队与福利", "福利待遇", "岗位福利", "公司福利"] _TITLE_RE = re.compile(r"岗位名称[::]\s*([^\n]+)") _NUMBER_RE = re.compile(r"^\s*(?:\d+\s*[、..))]|[-•·])\s*") def _find_header(text: str, headers: list[str]) -> tuple[int, int] | None: """返回第一处标题的 (起, 止),找不到返回 None。""" best: tuple[int, int] | None = None for h in headers: i = text.find(h) if i >= 0 and (best is None or i < best[0]): best = (i, i + len(h)) return best class OfflineExtractor: """离线确定性抽取器(默认通道,无需任何外部依赖)。""" name = "offline" def __init__(self, lexicon: dict | None = None): self.lexicon = lexicon or load_lexicon() self.matcher = SkillMatcher(self.lexicon) def _zones(self, text: str) -> dict[str, str]: """把文本切成 职责/要求/加分 三个版面区域(尾部福利段落不计入)。""" duty = _find_header(text, DUTY_HEADERS) req = _find_header(text, REQ_HEADERS) pref = _find_header(text, PREF_HEADERS) tail = _find_header(text, TAIL_HEADERS) def zone(start_h, *end_hs): if start_h is None: return "" s = start_h[1] ends = [h[0] for h in end_hs if h is not None and h[0] >= s] e = min(ends) if ends else len(text) return text[s:e] return { "duty": zone(duty, req, pref, tail), "req": zone(req, pref, tail), "pref": zone(pref, tail), } @staticmethod def _parse_duties(duty_zone: str) -> list[str]: """逐行解析编号职责,去掉编号前缀。""" out: list[str] = [] for line in duty_zone.splitlines(): line = _NUMBER_RE.sub("", line).strip().strip("::") if line: out.append(line) return out def extract(self, record: JDRecord) -> ExtractionResult: text = record.text zones = self._zones(text) m = _TITLE_RE.search(text) title = m.group(1).strip() if m else record.job_title duties = self._parse_duties(zones["duty"]) if zones["req"]: required = self.matcher.find_names(zones["req"]) else: # 无版面结构的真实 JD(如 AutoWeakS 语料)没有"任职要求"段, # 兜底为全文扫描,避免技能字段整体漏抽 required = self.matcher.find_names(text) preferred = self.matcher.find_names(zones["pref"]) if zones["pref"] else [] # 加分技能不应重复出现在必备里(极少数版面混排的兜底) preferred = [s for s in preferred if s not in required] scenarios = self.matcher.find_scenarios(text) def snippet(zone_text: str, limit: int = 80) -> str: return " ".join(zone_text.split())[:limit] evidence = { "job_title": m.group(0) if m else "", "responsibilities": snippet(zones["duty"]), "required_skills": snippet(zones["req"]), "preferred_skills": snippet(zones["pref"]), "scenarios": "、".join(scenarios), } # 置信度:五个字段命中比例(离线规则全命中即 1.0) hits = sum([bool(title), bool(duties), bool(required), True, bool(scenarios)]) confidence = round(hits / 5.0, 4) return ExtractionResult( jid=record.jid, job_title=title, responsibilities=duties, required_skills=required, preferred_skills=preferred, scenarios=scenarios, evidence=evidence, confidence=confidence, extractor=self.name, prompt_version="offline-v1", )