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,131 @@
|
||||
"""抽取器基础:Extractor 协议、技能词表加载与匹配、字段级多源投票。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Protocol
|
||||
|
||||
from app.config import ROOT
|
||||
from app.models import ExtractionResult, JDRecord
|
||||
|
||||
|
||||
class Extractor(Protocol):
|
||||
"""抽取器协议:任何实现(离线/大模型)都必须遵守同一输出 Schema。"""
|
||||
|
||||
name: str
|
||||
|
||||
def extract(self, record: JDRecord) -> ExtractionResult:
|
||||
...
|
||||
|
||||
|
||||
def load_lexicon(path: str | Path | None = None) -> dict:
|
||||
"""加载技能词表 data/skills_lexicon.json。
|
||||
|
||||
结构:{"skills": [{name, category, aliases, level, prereqs}], "scenarios": [...]}
|
||||
"""
|
||||
p = Path(path) if path else ROOT / "data" / "skills_lexicon.json"
|
||||
return json.loads(p.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
class SkillMatcher:
|
||||
"""基于词表(含别名)的技能归一化匹配器。
|
||||
|
||||
- 含 ASCII 的词使用“非字母数字边界”匹配,避免 Java 误中 JavaScript、C 误中 C++;
|
||||
- 纯中文词直接子串匹配;
|
||||
- 重叠命中按“起点靠前、长度最长”优先,保证 Spring Boot 优先于 Spring。
|
||||
"""
|
||||
|
||||
def __init__(self, lexicon: dict):
|
||||
self.lexicon = lexicon
|
||||
self.alias2name: dict[str, str] = {}
|
||||
for s in lexicon["skills"]:
|
||||
self.alias2name[s["name"]] = s["name"]
|
||||
for a in s.get("aliases", []):
|
||||
self.alias2name[a] = s["name"]
|
||||
# 长词优先编译,Java 与 JavaScript 这类前缀词靠边界 + 长度双重保险
|
||||
self._patterns: list[tuple[str, re.Pattern]] = []
|
||||
for alias in sorted(self.alias2name, key=len, reverse=True):
|
||||
esc = re.escape(alias)
|
||||
if re.search(r"[A-Za-z0-9+#.]", alias):
|
||||
pat = re.compile(rf"(?<![A-Za-z0-9+#.]){esc}(?![A-Za-z0-9+#.])", re.IGNORECASE)
|
||||
else:
|
||||
pat = re.compile(esc)
|
||||
self._patterns.append((alias, pat))
|
||||
|
||||
def normalize(self, name: str) -> str:
|
||||
"""把用户输入的技能名归一到词表标准名,三级容错:
|
||||
|
||||
1. 精确/别名匹配(大小写不敏感):redis→Redis、K8s→Kubernetes;
|
||||
2. 包含匹配(语义近似兜底):输入包含某词表技能时取其最长者,
|
||||
如 微服务架构→微服务、Java语言→Java、SpringBoot项目→Spring Boot;
|
||||
3. 均未命中则原样返回(交由上层标记为"词表外技能")。
|
||||
"""
|
||||
if not hasattr(self, "_alias_lower"):
|
||||
self._alias_lower = {k.lower(): v for k, v in self.alias2name.items()}
|
||||
key = name.strip()
|
||||
hit = self._alias_lower.get(key.lower())
|
||||
if hit:
|
||||
return hit
|
||||
hits = self.find(key)
|
||||
if hits:
|
||||
# 取覆盖最长(最具体)的命中,避免 JavaScript 被 Java 截胡
|
||||
best = max(hits, key=lambda h: h[1] - h[0])
|
||||
return best[2]
|
||||
return key
|
||||
|
||||
def find(self, text: str) -> list[tuple[int, int, str]]:
|
||||
"""返回 [(start, end, canonical_name)],重叠命中已消解。"""
|
||||
hits: list[tuple[int, int, str]] = []
|
||||
for alias, pat in self._patterns:
|
||||
canonical = self.alias2name[alias]
|
||||
for m in pat.finditer(text):
|
||||
hits.append((m.start(), m.end(), canonical))
|
||||
hits.sort(key=lambda h: (h[0], -(h[1] - h[0])))
|
||||
out: list[tuple[int, int, str]] = []
|
||||
last_end = -1
|
||||
for start, end, name in hits:
|
||||
if start >= last_end:
|
||||
out.append((start, end, name))
|
||||
last_end = end
|
||||
return out
|
||||
|
||||
def find_names(self, text: str) -> list[str]:
|
||||
"""只返回归一化后的技能名(保序去重)。"""
|
||||
seen: list[str] = []
|
||||
for _, _, name in self.find(text):
|
||||
if name not in seen:
|
||||
seen.append(name)
|
||||
return seen
|
||||
|
||||
def find_scenarios(self, text: str) -> list[str]:
|
||||
"""应用场景词表匹配(纯子串,保序去重)。"""
|
||||
out: list[str] = []
|
||||
for sc in self.lexicon.get("scenarios", []):
|
||||
if sc in text and sc not in out:
|
||||
out.append(sc)
|
||||
return out
|
||||
|
||||
|
||||
def vote_field(
|
||||
candidates: list[tuple[list[str], float]],
|
||||
min_agreement: float = 0.5,
|
||||
) -> tuple[list[str], float]:
|
||||
"""字段级多源投票:关键字段多源一致才给高置信。
|
||||
|
||||
candidates: [(某来源抽到的技能列表, 该来源权重)],例如离线规则与大模型双通道。
|
||||
返回 (通过投票的技能列表, 置信度)。技能按加权支持率 >= min_agreement 入选。
|
||||
"""
|
||||
if not candidates:
|
||||
return [], 0.0
|
||||
total = sum(w for _, w in candidates) or 1.0
|
||||
support: dict[str, float] = {}
|
||||
order: list[str] = []
|
||||
for skills, w in candidates:
|
||||
for s in dict.fromkeys(skills):
|
||||
support[s] = support.get(s, 0.0) + w
|
||||
if s not in order:
|
||||
order.append(s)
|
||||
chosen = [s for s in order if support[s] / total >= min_agreement]
|
||||
confidence = max((support[s] / total for s in chosen), default=0.0)
|
||||
return chosen, round(confidence, 4)
|
||||
@@ -0,0 +1,124 @@
|
||||
"""星火 / OpenAI 兼容 API 抽取器:JSON 模式、Schema 校验、失败重试 2 次、超时降级。
|
||||
|
||||
- 密钥只从环境变量读取(默认 SPARK_API_KEY),绝不写入代码或配置;
|
||||
- 无密钥时 get_extractor() 显式降级到离线抽取器;
|
||||
- 返回结果先过 ExtractionResult(pydantic)强校验,不合法即重试;
|
||||
- 重试耗尽或超时 → 降级到离线通道,保证链路永不断。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
|
||||
import httpx
|
||||
|
||||
from app.extract.base import Extractor, load_lexicon
|
||||
from app.extract.offline import OfflineExtractor
|
||||
from app.extract.prompts import PROMPT_VERSION, SYSTEM_PROMPT, build_prompt
|
||||
from app.models import ExtractionResult, JDRecord
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MAX_RETRIES = 2 # 失败重试 2 次
|
||||
|
||||
|
||||
class LLMExtractor:
|
||||
"""大模型抽取器(需配置密钥才启用)。"""
|
||||
|
||||
name = "llm"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_key: str,
|
||||
base_url: str,
|
||||
model: str,
|
||||
timeout: float = 20.0,
|
||||
fallback: Extractor | None = None,
|
||||
):
|
||||
self.api_key = api_key
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.model = model
|
||||
self.timeout = timeout
|
||||
self.fallback = fallback or OfflineExtractor(load_lexicon())
|
||||
|
||||
def _call_api(self, jd_text: str) -> str:
|
||||
"""调用 OpenAI 兼容 chat/completions,返回模型输出文本。"""
|
||||
resp = httpx.post(
|
||||
f"{self.base_url}/chat/completions",
|
||||
headers={"Authorization": f"Bearer {self.api_key}"},
|
||||
json={
|
||||
"model": self.model,
|
||||
"messages": [
|
||||
{"role": "system", "content": SYSTEM_PROMPT},
|
||||
{"role": "user", "content": build_prompt(jd_text)},
|
||||
],
|
||||
"response_format": {"type": "json_object"},
|
||||
"temperature": 0.1,
|
||||
},
|
||||
timeout=self.timeout,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()["choices"][0]["message"]["content"]
|
||||
|
||||
@staticmethod
|
||||
def _parse(content: str, record: JDRecord) -> ExtractionResult:
|
||||
"""Schema 强校验:坏 JSON / 缺字段都会抛异常,由上层重试。
|
||||
|
||||
先剥离模型常见的 ```json 代码块包裹再解析。
|
||||
"""
|
||||
text = content.strip()
|
||||
if text.startswith("```"):
|
||||
text = text.split("\n", 1)[-1] if "\n" in text else text.strip("`")
|
||||
if text.rstrip().endswith("```"):
|
||||
text = text.rstrip()[:-3]
|
||||
data = json.loads(text.strip())
|
||||
result = ExtractionResult(**data)
|
||||
result.jid = record.jid
|
||||
result.extractor = "llm"
|
||||
result.prompt_version = PROMPT_VERSION
|
||||
result.confidence = 0.9
|
||||
return result
|
||||
|
||||
def extract(self, record: JDRecord) -> ExtractionResult:
|
||||
last_err: Exception | None = None
|
||||
for attempt in range(MAX_RETRIES + 1):
|
||||
try:
|
||||
return self._parse(self._call_api(record.text), record)
|
||||
except Exception as exc: # noqa: BLE001 - 网络/解析/校验任何失败都要可重试可降级
|
||||
last_err = exc
|
||||
logger.warning("LLM 抽取第 %d 次失败(%s),%s",
|
||||
attempt + 1, type(exc).__name__,
|
||||
"重试" if attempt < MAX_RETRIES else "重试耗尽")
|
||||
logger.warning("LLM 不可用(%s),降级离线抽取 jid=%s", last_err, record.jid)
|
||||
return self.fallback.extract(record)
|
||||
|
||||
|
||||
def get_extractor(config: dict) -> Extractor:
|
||||
"""按配置选择抽取通道:配置了密钥(config.json 或环境变量)→ LLM,否则显式降级离线。
|
||||
|
||||
密钥优先级:config.json 的 llm_api_key > 环境变量(默认 SPARK_API_KEY)。
|
||||
LLM 通道失败时自动降级离线抽取(见 LLMExtractor.extract),链路永不断。
|
||||
"""
|
||||
key_env = str(config.get("llm_api_key_env", "SPARK_API_KEY"))
|
||||
api_key = (str(config.get("llm_api_key") or "").strip()
|
||||
or os.environ.get(key_env, "").strip())
|
||||
offline = OfflineExtractor(load_lexicon(config.get("lexicon_path") or None))
|
||||
if api_key:
|
||||
logger.info("检测到 LLM 密钥,启用大模型抽取通道(模型 %s)", config.get("llm_model"))
|
||||
return LLMExtractor(
|
||||
api_key=api_key,
|
||||
base_url=str(config.get("llm_base_url")),
|
||||
model=str(config.get("llm_model")),
|
||||
timeout=float(config.get("llm_timeout", 20)),
|
||||
fallback=offline,
|
||||
)
|
||||
logger.info("未配置 LLM 密钥,显式降级到离线确定性抽取器")
|
||||
return offline
|
||||
|
||||
|
||||
def llm_ready(config: dict) -> bool:
|
||||
"""是否已配置 LLM 密钥(config.json 或环境变量)。"""
|
||||
key_env = str(config.get("llm_api_key_env", "SPARK_API_KEY"))
|
||||
return bool(str(config.get("llm_api_key") or "").strip()
|
||||
or os.environ.get(key_env, "").strip())
|
||||
@@ -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",
|
||||
)
|
||||
@@ -0,0 +1,43 @@
|
||||
"""大模型抽取的固化提示词模板与输出 Schema。
|
||||
|
||||
prompt_version 是抽取结果血缘的一部分:提示词变更必须升版本号,
|
||||
评测与图谱中都能据此追溯。
|
||||
"""
|
||||
|
||||
PROMPT_VERSION = "spark-jd-v1.1"
|
||||
|
||||
SYSTEM_PROMPT = (
|
||||
"你是招聘信息结构化专家。请从招聘 JD 文本中抽取岗位定义,"
|
||||
"只输出符合给定 JSON Schema 的 JSON,不要输出任何多余文字。"
|
||||
"技能名必须归一化到常见写法(如 LLM应用开发 归一化为 大模型应用开发),"
|
||||
"每个字段都要给出原文证据片段,不得编造原文没有的内容。"
|
||||
)
|
||||
|
||||
# 输出 Schema(JSON Schema 强校验的目标结构,与 ExtractionResult 对齐)
|
||||
OUTPUT_SCHEMA: dict = {
|
||||
"type": "object",
|
||||
"required": ["job_title", "responsibilities", "required_skills", "preferred_skills", "scenarios"],
|
||||
"properties": {
|
||||
"job_title": {"type": "string", "description": "岗位标准名称"},
|
||||
"responsibilities": {"type": "array", "items": {"type": "string"}, "description": "核心职责"},
|
||||
"required_skills": {"type": "array", "items": {"type": "string"}, "description": "必备技能"},
|
||||
"preferred_skills": {"type": "array", "items": {"type": "string"}, "description": "加分技能"},
|
||||
"scenarios": {"type": "array", "items": {"type": "string"}, "description": "应用场景"},
|
||||
"evidence": {"type": "object", "description": "字段名 -> 原文证据片段"},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def build_prompt(jd_text: str) -> str:
|
||||
"""拼接待抽取 JD 的用户提示词(含显式 Schema,字段名必须为英文键)。"""
|
||||
return (
|
||||
"请从以下招聘 JD 文本中抽取结构化岗位定义。\n"
|
||||
"要求:\n"
|
||||
"1. 只输出一个 JSON 对象,不要 markdown 代码块,不要任何解释文字;\n"
|
||||
"2. 字段名必须使用以下英文键:job_title(岗位标准名称)、responsibilities(核心职责,字符串数组)、"
|
||||
"required_skills(必备技能,字符串数组)、preferred_skills(加分/优先技能,字符串数组)、"
|
||||
"scenarios(应用场景,字符串数组)、evidence(对象,键为字段名,值为原文证据片段);\n"
|
||||
"3. 技能名归一化到常见写法(如 LLM应用开发→大模型应用开发);\n"
|
||||
"4. 不得编造原文没有的内容。\n\n"
|
||||
"JD 文本:\n" + jd_text
|
||||
)
|
||||
Reference in New Issue
Block a user