Files
RoyirinRosmontis a10442d78c CareerNexus 职联:岗位能力图谱构建与动态演化分析平台(挑战杯 XH-202621)
- 全链路:多源JD接入/清洗交叉验证/结构化抽取(星火+离线双通道)/时序知识图谱/新岗位发现/演化分析/人岗匹配/学习路径
- WebUI 五视图:驾驶舱/图谱工作台/岗位演化/匹配(单人+批量)/学习路径
- 设置中心(config.json)/Token鉴权/SQLite状态库/语义向量通道(BGE-M3本地或联网API)
- 71 个 pytest 用例;数据目录仅保留结构(.gitkeep),数据由 scripts/generate_dataset.py 复现
2026-08-03 13:15:35 +08:00

129 lines
5.1 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""离线确定性抽取器:技能词表 + 版面规则,从 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",
)