CareerNexus 职联:岗位能力图谱构建与动态演化分析平台(挑战杯 XH-202621)
- 全链路:多源JD接入/清洗交叉验证/结构化抽取(星火+离线双通道)/时序知识图谱/新岗位发现/演化分析/人岗匹配/学习路径 - WebUI 五视图:驾驶舱/图谱工作台/岗位演化/匹配(单人+批量)/学习路径 - 设置中心(config.json)/Token鉴权/SQLite状态库/语义向量通道(BGE-M3本地或联网API) - 71 个 pytest 用例;数据目录仅保留结构(.gitkeep),数据由 scripts/generate_dataset.py 复现
This commit is contained in:
@@ -0,0 +1,128 @@
|
||||
"""离线确定性抽取器:技能词表 + 版面规则,从 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",
|
||||
)
|
||||
Reference in New Issue
Block a user