CareerNexus 职联:岗位能力图谱构建与动态演化分析平台(挑战杯 XH-202621)

- 全链路:多源JD接入/清洗交叉验证/结构化抽取(星火+离线双通道)/时序知识图谱/新岗位发现/演化分析/人岗匹配/学习路径
- WebUI 五视图:驾驶舱/图谱工作台/岗位演化/匹配(单人+批量)/学习路径
- 设置中心(config.json)/Token鉴权/SQLite状态库/语义向量通道(BGE-M3本地或联网API)
- 71 个 pytest 用例;数据目录仅保留结构(.gitkeep),数据由 scripts/generate_dataset.py 复现
This commit is contained in:
RoyirinRosmontis
2026-08-03 13:15:35 +08:00
commit a10442d78c
78 changed files with 12860 additions and 0 deletions
+128
View File
@@ -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",
)