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
+9
View File
@@ -0,0 +1,9 @@
# 默认构建保持轻量:本地权重/数据/缓存/运行时密钥不进镜像
models/
data/tmp/
logs/
__pycache__/
**/__pycache__/
.venv/
*.pyc
config.json
+3
View File
@@ -0,0 +1,3 @@
# 大模型抽取通道(星火/OpenAI 兼容 API)密钥示例——仅示例,请勿提交真实密钥
# 配置后并把 config.yaml 的 mode 改为 live,即可启用大模型抽取;未配置时自动降级离线抽取
SPARK_API_KEY=your_spark_api_key_here
+18
View File
@@ -0,0 +1,18 @@
# 运行时与本地环境产物(不入库)
.venv/
__pycache__/
*.pyc
config.json
config.json.bak
data/careernexus.db*
models/
logs/
data/tmp/
datasets/
# 数据内容不入库,只保留目录结构(.gitkeep)
data/**
!data/**/
!data/**/.gitkeep
.coverage
htmlcov/
+33
View File
@@ -0,0 +1,33 @@
# CareerNexus 职联 —— 一体化镜像(默认 demo 模式,全程离线)
#
# 默认构建:轻量离线镜像(词表归一化,无模型权重)
# docker build -t careernexus .
#
# 语义增强变体:附带 sentence-transformers + BGE-M3 权重(镜像约 +4GB,
# 构建期需要联网下载依赖与权重;国内建议加 MODELSCOPE 源)
# docker build --build-arg INCLUDE_EMBED=1 -t careernexus:semantic .
FROM python:3.11-slim AS base
WORKDIR /app
# 先装依赖,利用镜像层缓存
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# ---- 可选:语义向量通道(BGE-M3) ----
ARG INCLUDE_EMBED=0
RUN if [ "$INCLUDE_EMBED" = "1" ]; then \
pip install --no-cache-dir sentence-transformers modelscope && \
modelscope download --model BAAI/bge-m3 --local_dir /app/models/bge-m3 ; \
fi
# 拷贝源码并生成数据集 + 构建图谱(保证容器内开箱即跑)
COPY . .
# 语义变体:把权重路径写入运行时设置(若本地已存在 models/ 目录则随源码一起拷贝)
RUN if [ "$INCLUDE_EMBED" = "1" ]; then \
printf '{\n "embed_model_path": "/app/models/bge-m3"\n}\n' > config.json ; \
fi && \
python scripts/generate_dataset.py && python scripts/run_demo.py
EXPOSE 9008
CMD ["python", "main.py"]
+212
View File
@@ -0,0 +1,212 @@
# CareerNexus 职联
多源异构数据驱动的**岗位与能力图谱构建与动态演化分析**平台 —— 挑战杯“揭榜挂帅”赛题 XH-202621 参赛作品。
核心链路:多源 JD 接入 → 清洗交叉验证(时滞/噪声/抄袭)→ 结构化抽取(离线规则 / 星火大模型双通道,JSON Schema 强校验)→ 时序知识图谱(Temporal KG)→ 新岗位发现与能力演化分析 → 简历解析、人岗匹配、差距分析与学习路径。
## 环境准备
```bash
python -m venv .venv
# Windows: .venv\Scripts\activate Linux: source .venv/bin/activate
pip install -r requirements.txt
```
## 一键启动
```bash
python main.py # 自动构建/加载图谱,启动服务并打开浏览器
# 浏览器访问 http://127.0.0.1:9008
```
## 系统设置(WebUI 右上角 ⚙)
设置保存在 `config.json`(优先级高于 config.yaml),包含:
- **服务监听**:`127.0.0.1`(仅本机)或 `0.0.0.0`(对外开放)+ 端口,修改后需重启(面板提供"保存并重启");
- **访问令牌**:设置后 `/api/*` 需携带 `X-Access-Token` 头(health 与只读 GET /api/config 豁免);
- **LLM API**:星火/OpenAI 兼容的 base_url、api_key、model,保存即热生效——配置后抽取走大模型通道,未配置时显式回退离线确定性抽取,前端有醒目的"离线回退模式"横幅提示。
## 数据生成(已随工程提交,可复现重建)
```bash
python scripts/generate_dataset.py
# random.seed(2026) 固定:1375 条 JD(2019-2026,18 个岗位,15 城市,7 行业,8% 抄袭、噪声植入)、
# 161 项技能词表(含 AIOps/微前端等新技能)、144 条测试集+标准答案、36 对人岗匹配标注、
# 7 份简历样例(txt,另附 resume_full.docx / resume_full.pdf 演示上传解析)、3 份行业报告样例、7 条审核留痕种子记录
```
简历上传支持 .txt / .docx / .pdf(`POST /api/resume/parse/file`,≤5MB,解析即脱敏,临时文件自动清理)。
## 真实数据集(data/real/,与合成集分开报告)
```bash
python scripts/build_real_dataset.py
# random.seed(2026) 固定,从 datasets/XH-202621/data/(只读)加工:
# autoweaks_jd/ 200 条 AutoWeakS 真实 JD(腾讯160/京东40,去分词+技术岗过滤)+ silver gold(初标+抽检30条)
# aistudio_resumes/ 50 份 AIStudio CC0 简历(保留 PII 验证脱敏链路)+ 字段级 gold(抽检10条)
# batch_demo_resumes/ 12 份 chinese_resume 渲染后经 mask_text 脱敏的批量演示素材(原始 PII 不落盘)
# esco_reference/ ISCO/ESCO 职业分类锚点(4 个关键 labels 文件)
```
来源/授权/处理口径/抽检一致率详见 `data/real/README.md`。
## 指标复现
```bash
python scripts/evaluate.py
# 输出 reports/metrics.json 与控制台报告
```
实测(从文本重新抽取再与 gold 比对,非硬编码):
| 指标 | 实测 | 目标 |
|---|---|---|
| JD 解析准确率 | 100% | ≥90% |
| 简历提取准确率 | 100% (7 份简历) | ≥90% |
| 人岗匹配准确率 | 100% (36 对标注) | ≥90% |
| 真实 JD(AutoWeakS)技能 F1 | 99.94% (200 条) | ≥80% |
| 真实简历(AIStudio)姓名/电话命中 | 100% / 100% (50 份) | 100% |
| 真实简历(AIStudio)技能 F1 | 100% (50 份) | — |
## 测试
```bash
python -m pytest tests/ --cov=app --cov-report=term-missing
# 71 passed
```
端到端冒烟(不启动服务):
```bash
python scripts/run_demo.py
```
## Docker
```bash
docker build -t careernexus .
docker run -p 9008:9008 careernexus
# 或 docker compose up(可选 Neo4j 增强见下节,默认不启动)
```
## 可选高级组件
### SQLite 应用状态库(默认启用,零配置)
审核留痕、待审核候选、异步任务状态统一存储在 SQLite(`data/careernexus.db`,
配置键 `state_db_path`),仅用标准库 sqlite3、**零新依赖**:WAL 模式、幂等建表,
服务启动时自动把旧的 `data/review/audit.jsonl` / `data/review/pending.json`
一次性迁移入库(原文件保留不删除)。表结构:`audit_log`(留痕)、
`review_candidates`(待审核候选)、`tasks`(异步任务,重启后可恢复查询)、
`job_overrides`(人工状态覆写层,见下节)。
### 人工状态持久化(跨图谱重建)
`job_overrides` 表保存两类人工操作:审核通过的岗位定义(含支撑 JD 来源与时间窗)、
图谱节点编辑。审核 `accept` 时用候选簇的真实统计入库——REQUIRES 边的 `intervals`
自首个支持窗口起、`sources` 为真实支撑 JD 的 source_id 列表,并补
`(Job)-[:APPLIES_TO]->(Scenario)` / `(Job)-[:BELONGS_TO]->(Industry)` 边与
"新增"演化事件;节点编辑同步 `kg.jobs` 的 JobDefinition(title/category/级别,
级别编辑同步回期望年限)。pipeline 全量重建图谱后自动重放覆写层:人工入库岗位
**强制纳入正式图谱**(不受"2023 年前出现"规则限制),节点编辑一并恢复;
候选被驳回时相应覆写记录自动清理。
### JD 手动导入
`POST /api/jd/import` 支持三种用法(均触发异步 pipeline 重建,任务 ID + 轮询):
- **文件上传**:multipart 表单 `file` 字段,支持 `.jsonl` / `.csv` / `.txt`
(UTF-8,≤10MB;CSV 需含 `jid,job_title,company,publish_date,text` 表头);
- **文本粘贴**:JSON 请求体 `{"text": "..."}`,每行一条 JSON 记录;
- **空请求**:仅触发全量重建(旧行为)。
逐行校验:合格记录写入 `data/jd_raw/import_YYYYmmdd_HHMMSS.jsonl`
(source_id 按 `文件名#行号` 重新分配,保留来源标记);坏行进入响应的
`bad_lines` 清单并写入 `logs/import_bad_时间戳.jsonl`。响应含
`imported / skipped / bad_lines / file / task_id`。WebUI 驾驶舱"导入 JD 数据"
按钮提供上传/粘贴对话框。
### Neo4j 图数据库(profile: advanced)
平台默认使用内置 TemporalKG + JSON 快照即可完整运行;需要企业级图库时可选启用:
```bash
pip install neo4j # 官方驱动为可选依赖
NEO4J_PASSWORD=你的密码 docker compose --profile advanced up -d neo4j
python scripts/sync_neo4j.py # MERGE 幂等同步 data/graph.json
python scripts/sync_neo4j.py --dry-run # 只打印节点/边统计,不连接
# 浏览器打开 http://localhost:7474 查看(Bolt 端口 7687)
```
### 为什么 BGE-M3 / FAISS / reranker / GLiNER / PaddleOCR 不做进容器
这些组件定位为**生产部署的可选增强,而非竞赛交付**:
- **模型权重 GB 级**(BGE-M3、reranker、GLiNER、PaddleOCR 均为深度模型),
镜像体积膨胀到数 GB,构建与分发成本高;
- **评审环境离线**,容器内无法下载模型权重,打进镜像又会进一步放大体积;
- 与赛题要求的**“普通笔记本 2 分钟启动”**目标直接冲突——默认链路用纯 Python
TF-IDF / 规则抽取即可达到实测指标(见上表),深度模型只在生产侧追求
长尾召回/版式鲁棒性时才有增量价值。
因此交付物默认链路保持轻量确定性实现,上述模型以接口预留方式作为生产增强路线。
## 大模型抽取通道(可选)
默认 `mode: demo` 全程离线,使用离线确定性抽取器。配置密钥并改 `mode: live` 即启用星火/OpenAI 兼容通道(JSON 模式、Schema 校验、失败重试 2 次、超时自动降级离线):
```bash
cp .env.example .env # 填入 SPARK_API_KEY(密钥只从环境变量读取)
```
## 目录结构
```
├── main.py # 唯一入口:加载配置→构建/加载图谱→启动服务→自动开浏览器
├── config.yaml # 端口/数据路径/运行模式 demo|live/匹配权重等
├── app/
│ ├── models.py # pydantic v2 全链路模型
│ ├── ingest/loader.py # jsonl 接入,source_id=文件#行号,坏行错误清单
│ ├── cleaning/ # MinHash(128哈希,5-gram)+LSH 近重复、噪声过滤、exp(-λ·age) 时间衰减
│ ├── extract/ # Extractor 协议+多源投票(base) / 离线规则(offline) / 大模型(llm) / 提示词(prompts)
│ ├── graph/tkg.py # TemporalKG:时间区间 + 事件追加不覆盖 + snapshot 切片 + JSON 持久化
│ ├── discover/cluster.py # TF-IDF+凝聚聚类新岗位发现 + 待审核分区 + 审核留痕
│ ├── storage/db.py # SQLite 应用状态库:audit_log/review_candidates/tasks/job_overrides + 旧文件一次性迁移
│ ├── evolution/analyzer.py# 季度窗口聚合,新增/增强/减弱/退出事件 + 中文说明
│ ├── match/ # 简历解析脱敏(resume) / 四维加权匹配+学习路径(engine)
│ ├── services/ # pipeline 端到端编排 / overrides 人工状态覆写层(跨重建重放)
│ └── api/routes.py # FastAPI:统一响应{code,message,data,request_id},任务ID+轮询
├── webui/ # 原生 HTML/JS 单页应用(无构建、完全离线):驾驶舱/图谱工作台/演化/匹配/学习路径
│ ├── index.html # 侧栏+顶栏外壳与五视图结构
│ ├── style.css # 设计系统(token/按钮/卡片/弹窗/表格等)
│ ├── app.js # 公共库 + 驾驶舱/演化/匹配/学习路径
│ └── graph.js # 图谱工作台:力导向画布/小地图/详情面板/编辑/备注/审核
├── scripts/ # 数据生成、真实数据集构建(build_real_dataset)、指标复现、端到端冒烟、sync_neo4j(可选 Neo4j 同步)
├── tests/ # pytest:cleaning/extract/tkg/evolution/match/api/real_data
└── data/ # 词表、JD 原始数据、测试集+gold、简历、报告、图谱快照、real/(真实公开数据集,见其 README)
```
## 人机协同审核(防“幻觉”)
新岗位簇与低置信抽取不直接入库:先进入“待审核”分区(SQLite 状态库 `review_candidates` 表),
人工 `accept / modify / reject` 后才写入正式图谱,全程留痕(操作人/时间/before/after/理由,
`audit_log` 表)。抽取结果每个字段附原文证据片段供核对。
## WebUI 图谱工作台与节点编辑
全景图谱页为“岗位能力图谱工作台”:canvas 力导向(缩放/平移/拖拽节点/小地图/筛选/关系标签开关),
节点类型覆盖 岗位/技能/行业/场景((Job)-[:BELONGS_TO]->(Industry) 行业归属边,可按类型筛选),
点击节点打开右侧详情面板(基础信息/关联跳转/证据来源/演化 sparkline/备注/审核留痕),
待审核候选以虚线节点并入画布,可直接确认/驳回。
配套接口(统一响应格式,全部留痕):
| 接口 | 说明 |
|---|---|
| `POST /api/graph/node/update` | 编辑节点展示属性(title/level/importance/weight 白名单字段),写图谱并在状态库 audit_log 表追加 `kind=node_update` 留痕;Job 节点同步 JobDefinition,编辑写入 job_overrides 覆写层(重建后自动重放);只改属性不动结构 |
| `GET /api/jd?sid=<文件#行号>` | 按 source_id 回查 JD 原文(证据来源弹窗),文件名/行号强校验防路径穿越 |
| `GET /api/audit?node_id=&limit=` | 查询审核/编辑留痕(新→旧),可按节点过滤 |
| `GET /api/graph` | 追加返回 `events`(演化事件列表),供前端趋势图与智能建议 |
节点备注保存在浏览器 localStorage(`cn_notes`),图谱视图布局保存在 `cn_graph_view`,均为纯前端持久化。
+3
View File
@@ -0,0 +1,3 @@
"""CareerNexus 职联 —— 多源异构数据驱动的岗位与能力图谱平台。"""
__version__ = "0.1.0"
View File
+812
View File
@@ -0,0 +1,812 @@
"""FastAPI 接口层:统一响应 {code, message, data, request_id}。
- 耗时任务(JD 导入/全链路 pipeline)使用 任务ID + 轮询;
- 托管 webui 静态文件(/ -> webui/index.html);
- 服务启动时若 data/graph.json 已存在则直接加载图谱,否则等首次导入。
"""
from __future__ import annotations
import logging
import threading
import uuid
from pathlib import Path
from urllib.parse import quote
import json
from fastapi import FastAPI, File, Form, HTTPException, Request, Response, UploadFile
from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, ValidationError
from app.config import ROOT, load_config, load_runtime_settings, resolve, save_runtime_settings
from app.discover.cluster import apply_review, discover_new_jobs, load_pending, save_pending
from app.extract.base import SkillMatcher, load_lexicon
from app.extract.llm import llm_ready
from app.graph.tkg import TemporalKG, now_str
from app.ingest.loader import load_jd_dir
from app.match import semantic
from app.match.engine import MatchEngine
from app.match.resume import parse_resume, parse_resume_file
from app.models import JDRecord, ResumeProfile
from app.services.pipeline import run_pipeline
from app.storage import db as state_db
logger = logging.getLogger(__name__)
app = FastAPI(title="CareerNexus 职联", version="0.1.0")
_cfg = load_config()
_state: dict = {
"kg": None,
"records": None,
"extractions": None,
"tasks": {},
}
def _ok(data=None, message: str = "ok") -> dict:
return {"code": 0, "message": message, "data": data,
"request_id": uuid.uuid4().hex[:12]}
def _get_kg() -> TemporalKG:
kg = _state["kg"]
if kg is None:
raise HTTPException(status_code=503, detail="图谱尚未构建,请先调用 /api/jd/import")
return kg
def _db_path() -> Path:
"""SQLite 应用状态库路径(配置键 state_db_path,默认 data/careernexus.db)。"""
return resolve(_cfg, "state_db_path")
@app.on_event("startup")
def _startup() -> None:
graph_path = resolve(_cfg, "graph_path")
if graph_path.exists():
_state["kg"] = TemporalKG.load(graph_path)
logger.info("已加载图谱:%s", _state["kg"].stats())
else:
logger.warning("图谱文件不存在(%s),请先执行 pipeline 或 /api/jd/import", graph_path)
# SQLite 状态库:幂等建表 + 旧 JSONL/JSON 一次性迁移(原文件保留)
state_db.connect(
_db_path(),
legacy_audit=resolve(_cfg, "audit_path"),
legacy_pending=resolve(_cfg, "pending_path"),
).close()
# 重启后从 tasks 表恢复任务清单;中断的任务标记为失败
for row in state_db.list_tasks(_db_path()):
payload = row["payload"]
if payload.get("status") in ("pending", "running"):
payload = {**payload, "status": "failed", "error": "服务重启,任务中断"}
_state["tasks"][row["task_id"]] = payload
# ---------- 健康与图谱 ----------
@app.get("/api/health")
def health() -> dict:
kg = _state["kg"]
ready = llm_ready(_cfg)
return _ok({"status": "ok", "graph": kg.stats() if kg else None,
"llm_ready": ready, "offline_fallback": not ready,
"embed": semantic.status(_cfg),
"guided": bool(_cfg.get("guided")),
"mode": "online" if ready else "offline"})
# ---------- 静态资源缓存控制(避免浏览器用旧版前端) ----------
@app.middleware("http")
async def static_no_cache(request, call_next):
resp = await call_next(request)
if not request.url.path.startswith("/api/"):
resp.headers["Cache-Control"] = "no-cache" # 每次加载都回源校验(ETag 命中仍 304,成本低)
return resp
# ---------- 访问令牌鉴权 ----------
@app.middleware("http")
async def access_token_guard(request, call_next):
"""设置 access_token 后,/api/*(health 除外)必须携带 X-Access-Token 头。
仅在用户显式配置令牌时启用;默认无令牌、本地使用无感知。
"""
token = str(_cfg.get("access_token") or "").strip()
path = request.url.path
# 豁免:health 探活、GET /api/config(只读脱敏值,打开设置面板需要)
exempt = path == "/api/health" or (path == "/api/config" and request.method == "GET")
if token and path.startswith("/api/") and not exempt:
if request.headers.get("x-access-token", "") != token:
return JSONResponse({"detail": "未授权:缺少或错误的访问令牌"}, status_code=401)
return await call_next(request)
# ---------- 语义环境一键配置(按需下载,基础镜像不打包重依赖) ----------
_semantic_setup: dict = {"running": False, "stage": "", "log": [], "error": "", "done": False}
_semantic_setup_lock = threading.Lock()
def _setup_log(msg: str) -> None:
_semantic_setup["log"].append(msg)
_semantic_setup["log"] = _semantic_setup["log"][-20:]
logger.info("语义环境配置: %s", msg)
def _run_semantic_setup() -> None:
"""后台执行:装依赖 → 下载权重 → 写入配置并热生效。任一阶段失败记 error。"""
import importlib
import subprocess
import sys
weights_dir = ROOT / "models" / "bge-m3"
try:
# 已就绪则直接写配置收尾(幂等)
if not semantic.status(_cfg)["ready"]:
_semantic_setup["stage"] = "deps"
_setup_log("安装 Python 依赖 sentence-transformers / modelscope(镜像站)…")
pip = [sys.executable, "-m", "pip", "install", "-q",
"sentence-transformers", "modelscope",
"-i", "https://pypi.tuna.tsinghua.edu.cn/simple"]
proc = subprocess.run(pip, capture_output=True, text=True, timeout=1800)
if proc.returncode != 0: # 镜像站失败退回官方源
_setup_log("镜像站安装失败,改用官方源重试…")
proc = subprocess.run(pip[:-2], capture_output=True, text=True, timeout=1800)
if proc.returncode != 0:
raise RuntimeError(f"依赖安装失败:{(proc.stderr or proc.stdout)[-300:]}")
importlib.invalidate_caches()
_setup_log("依赖安装完成")
if not semantic.weights_present(weights_dir):
_semantic_setup["stage"] = "download"
_setup_log("从 ModelScope 下载 BGE-M3 权重(约 2.2GB,数分钟)…")
from modelscope import snapshot_download
weights_dir.parent.mkdir(parents=True, exist_ok=True)
snapshot_download("BAAI/bge-m3", local_dir=str(weights_dir))
_setup_log("权重下载完成")
else:
_setup_log("检测到已有权重,跳过下载")
_semantic_setup["stage"] = "config"
save_runtime_settings({"embed_provider": "local",
"embed_model_path": str(weights_dir)})
_cfg["embed_provider"] = "local"
_cfg["embed_model_path"] = str(weights_dir)
ready = semantic.status(_cfg)["ready"]
_semantic_setup["done"] = ready
_semantic_setup["stage"] = "done" if ready else "error"
if not ready:
_semantic_setup["error"] = "配置已写入但就绪检测未通过,请检查依赖与权重文件"
_setup_log("配置完成" if ready else "就绪检测未通过")
except Exception as exc: # noqa: BLE001 - 任何失败都要落到状态里供前端展示
_semantic_setup["error"] = str(exc)
_semantic_setup["stage"] = "error"
_setup_log(f"失败:{exc}")
finally:
_semantic_setup["running"] = False
@app.post("/api/semantic/setup")
def semantic_setup() -> dict:
"""启动语义环境一键配置(异步)。已运行中则返回当前状态。"""
with _semantic_setup_lock:
if not _semantic_setup["running"]:
_semantic_setup.update({"running": True, "stage": "start",
"log": [], "error": "", "done": False})
threading.Thread(target=_run_semantic_setup, daemon=True).start()
return _ok({"started": True})
@app.get("/api/semantic/setup/status")
def semantic_setup_status() -> dict:
return _ok({** _semantic_setup, "embed": semantic.status(_cfg)})
# ---------- 运行时设置(config.json) ----------
def _mask(secret: str) -> str:
s = (secret or "").strip()
return ("••••" + s[-4:]) if s else ""
class ConfigBody(BaseModel):
host: str | None = None # 127.0.0.1 | 0.0.0.0
port: int | None = None
access_token: str | None = None # 空串 = 清除令牌
llm_api_key: str | None = None
llm_base_url: str | None = None
llm_model: str | None = None
embed_model_path: str | None = None # BGE-M3 本地权重目录,空串 = 清除
embed_provider: str | None = None # local | api
embed_api_key: str | None = None # 联网 Embedding API 密钥(如 DashScope)
embed_api_base_url: str | None = None
embed_api_model: str | None = None
guided: bool | None = None # 初始设置引导完成标记
mode: str | None = None # demo | live
def _public_config() -> dict:
saved = load_runtime_settings()
ready = llm_ready(_cfg)
running = {"host": str(_cfg.get("host")), "port": int(_cfg.get("port", 9008))}
saved_hp = {"host": saved.get("host", running["host"]), "port": int(saved.get("port", running["port"]))}
return {
"running": running, # 当前进程实际监听
"saved": saved_hp, # config.json 中保存的监听(重启后生效)
"restart_required": saved_hp != running,
"has_access_token": bool(str(_cfg.get("access_token") or "").strip()),
"access_token_masked": _mask(str(_cfg.get("access_token") or "")),
"llm": {
"configured": ready,
"api_key_masked": _mask(str(_cfg.get("llm_api_key") or "")),
"base_url": str(_cfg.get("llm_base_url") or ""),
"model": str(_cfg.get("llm_model") or ""),
},
"embed": semantic.status(_cfg),
"mode": str(_cfg.get("mode", "demo")),
"offline_fallback": not ready,
}
@app.get("/api/config")
def get_config() -> dict:
return _ok(_public_config())
@app.post("/api/config")
def save_config(body: ConfigBody) -> dict:
"""保存运行时设置到 config.json;LLM/令牌热生效,监听地址与端口需重启。"""
updates: dict = {}
if body.host is not None:
if body.host not in ("127.0.0.1", "0.0.0.0"):
raise HTTPException(status_code=400, detail="host 仅支持 127.0.0.1 或 0.0.0.0")
updates["host"] = body.host
if body.port is not None:
if not (1024 <= body.port <= 65535):
raise HTTPException(status_code=400, detail="端口需在 1024-65535 之间")
updates["port"] = body.port
if body.mode is not None:
if body.mode not in ("demo", "live"):
raise HTTPException(status_code=400, detail="mode 仅支持 demo 或 live")
updates["mode"] = body.mode
if body.access_token is not None:
updates["access_token"] = body.access_token.strip()
if body.llm_api_key is not None:
updates["llm_api_key"] = body.llm_api_key.strip()
if body.llm_base_url is not None:
updates["llm_base_url"] = body.llm_base_url.strip()
if body.llm_model is not None:
updates["llm_model"] = body.llm_model.strip()
if body.embed_model_path is not None:
updates["embed_model_path"] = body.embed_model_path.strip()
if body.embed_provider is not None:
if body.embed_provider not in ("local", "api"):
raise HTTPException(status_code=400, detail="embed_provider 仅支持 local 或 api")
updates["embed_provider"] = body.embed_provider
if body.embed_api_key is not None:
updates["embed_api_key"] = body.embed_api_key.strip()
if body.embed_api_base_url is not None:
updates["embed_api_base_url"] = body.embed_api_base_url.strip()
if body.embed_api_model is not None:
updates["embed_api_model"] = body.embed_api_model.strip()
if body.guided is not None:
updates["guided"] = bool(body.guided)
save_runtime_settings(updates)
# 热生效:令牌、模式与 LLM/语义模型配置立即更新到运行配置(下次调用即读取)
for k in ("access_token", "mode", "llm_api_key", "llm_base_url", "llm_model",
"embed_model_path", "embed_provider", "embed_api_key",
"embed_api_base_url", "embed_api_model", "guided"):
if k in updates:
if updates[k]:
_cfg[k] = updates[k]
else:
_cfg.pop(k, None)
logger.info("运行时设置已保存到 config.json: %s", sorted(updates))
return _ok(_public_config(), message="设置已保存")
@app.post("/api/config/restart")
def restart_service() -> dict:
"""延迟 1 秒后原地重启进程(os.execv),使监听地址/端口变更生效。"""
import os
import sys
def _reexec() -> None:
threading.Event().wait(1.0)
logger.info("按用户请求重启服务……")
os.execv(sys.executable, [sys.executable] + sys.argv)
threading.Thread(target=_reexec, daemon=True).start()
return _ok({"restarting": True}, message="服务将在 1 秒后重启,请稍后刷新页面")
@app.get("/api/graph")
def graph() -> dict:
"""返回节点与边(含时间区间)与演化事件,供前端渲染全景图谱。"""
kg = _get_kg()
nodes = [{"id": n, **d} for n, d in kg.g.nodes(data=True)]
edges = [{"source": u, "target": v, **d} for u, v, d in kg.g.edges(data=True)]
events = sorted((e.model_dump() for e in kg.events), key=lambda e: e["window"])
return _ok({"nodes": nodes, "edges": edges, "events": events, "stats": kg.stats()})
# ---------- JD 导入(异步任务) ----------
def _run_task(task_id: str) -> None:
task = _state["tasks"][task_id]
try:
task["status"] = "running"
state_db.upsert_task(_db_path(), task_id, kind="jd_import",
status="running", payload=task)
stats = run_pipeline(_cfg)
graph_path = resolve(_cfg, "graph_path")
_state["kg"] = TemporalKG.load(graph_path)
task.update(status="done", result=stats)
except Exception as exc: # noqa: BLE001 - 任务失败必须可查询原因
logger.exception("pipeline 任务失败")
task.update(status="failed", error=str(exc))
state_db.upsert_task(_db_path(), task_id, kind="jd_import",
status=task["status"], payload=task)
def _start_import_task(import_stats: dict | None = None) -> str:
"""登记并启动异步 pipeline 重建任务,返回 task_id。"""
task_id = uuid.uuid4().hex[:12]
payload: dict = {"status": "pending"}
if import_stats:
payload["import"] = import_stats
_state["tasks"][task_id] = payload
state_db.upsert_task(_db_path(), task_id, kind="jd_import",
status="pending", payload=payload)
threading.Thread(target=_run_task, args=(task_id,), daemon=True).start()
return task_id
@app.post("/api/jd/import")
async def import_jd(request: Request) -> dict:
"""JD 导入:支持 multipart 文件上传(.jsonl/.csv/.txt)、JSON 文本粘贴,
或不带内容直接触发全量重建(旧行为)。
合格记录写入 data/jd_raw/import_时间戳.jsonl(保留来源标记),
坏行返回前端并写入 logs/import_bad_时间戳.jsonl;随后异步重建图谱。
"""
from app.ingest.loader import IMPORT_EXTS, IMPORT_MAX_BYTES, save_import
text, filename = "", "paste.txt"
if "multipart/form-data" in request.headers.get("content-type", ""):
form = await request.form()
upload = form.get("file")
if upload is not None and hasattr(upload, "read"):
filename = upload.filename or ""
suffix = Path(filename).suffix.lower()
if suffix not in IMPORT_EXTS:
raise HTTPException(status_code=400,
detail=f"不支持的格式 {suffix or '(无扩展名)'},支持 {sorted(IMPORT_EXTS)}")
content = await upload.read()
if not content:
raise HTTPException(status_code=400, detail="文件为空")
if len(content) > IMPORT_MAX_BYTES:
raise HTTPException(status_code=400, detail="文件超过 10MB 限制")
try:
text = content.decode("utf-8-sig")
except UnicodeDecodeError as exc:
raise HTTPException(status_code=400, detail="文件需为 UTF-8 编码") from exc
elif form.get("text"):
text = str(form["text"])
else:
raw = await request.body()
if raw:
try:
payload = json.loads(raw)
except json.JSONDecodeError as exc:
raise HTTPException(status_code=400, detail=f"请求体不是合法 JSON:{exc}") from exc
text = str(payload.get("text") or "")
import_stats: dict | None = None
if text.strip():
import_stats = save_import(text, filename, resolve(_cfg, "jd_raw_dir"), ROOT / "logs")
if not import_stats["imported"] and not import_stats["bad_lines"]:
raise HTTPException(status_code=400, detail="没有可导入的内容")
task_id = _start_import_task(import_stats)
data: dict = {"task_id": task_id}
if import_stats:
data.update(imported=import_stats["imported"], skipped=import_stats["skipped"],
bad_lines=import_stats["bad_lines"],
file=import_stats["file"], bad_log=import_stats["bad_log"])
return _ok(data, message="导入任务已启动")
@app.get("/api/tasks/{task_id}")
def task_status(task_id: str) -> dict:
task = _state["tasks"].get(task_id)
if task is None:
row = state_db.get_task(_db_path(), task_id) # 重启前的任务从状态库回查
task = row["payload"] if row else None
if task is None:
raise HTTPException(status_code=404, detail="任务不存在")
return _ok(task)
# ---------- 岗位 ----------
@app.get("/api/jobs")
def list_jobs(category: str = "", skill: str = "", keyword: str = "") -> dict:
kg = _get_kg()
jobs = list(kg.jobs.values())
if category:
jobs = [j for j in jobs if j.category == category]
if skill:
jobs = [j for j in jobs if skill in j.required_skills or skill in j.preferred_skills]
if keyword:
jobs = [j for j in jobs if keyword in j.title]
return _ok([j.model_dump() for j in jobs])
@app.get("/api/jobs/{job_id}")
def job_detail(job_id: str) -> dict:
kg = _get_kg()
job = kg.jobs.get(job_id)
if job is None:
raise HTTPException(status_code=404, detail="岗位不存在")
return _ok({"definition": job.model_dump(),
"snapshot": kg.latest_snapshot(job_id)})
@app.get("/api/jobs/{job_id}/evolution")
def job_evolution(job_id: str) -> dict:
kg = _get_kg()
if job_id not in kg.jobs:
raise HTTPException(status_code=404, detail="岗位不存在")
events = [e.model_dump() for e in kg.events_of(job_id)]
events.sort(key=lambda e: e["window"])
return _ok({"job_id": job_id, "events": events})
@app.get("/api/jobs/{job_id}/snapshot")
def job_snapshot(job_id: str, at: str = "") -> dict:
"""任意时间点能力画像切片(用于演化页时点对比)。"""
kg = _get_kg()
if job_id not in kg.jobs:
raise HTTPException(status_code=404, detail="岗位不存在")
return _ok(kg.snapshot(job_id, at or "9999"))
# ---------- 新岗位发现与审核 ----------
@app.post("/api/jobs/discover")
def discover() -> dict:
"""对当前原始数据重新跑新岗位发现,结果进待审核分区。"""
jd_dir = resolve(_cfg, "jd_raw_dir")
if not jd_dir.exists():
raise HTTPException(status_code=400, detail="原始数据目录不存在")
from app.extract.offline import OfflineExtractor
records, _ = load_jd_dir(jd_dir)
extractor = OfflineExtractor(load_lexicon(resolve(_cfg, "lexicon_path")))
extractions = {r.jid: extractor.extract(r) for r in records if not r.is_noise}
known = {j.title for j in (_state["kg"].jobs.values() if _state["kg"] else [])}
candidates = discover_new_jobs(records, extractions, known)
save_pending(candidates, _db_path())
return _ok({"candidates": [c.model_dump() for c in candidates]})
class ReviewBody(BaseModel):
candidate_id: str
action: str # accept | modify | reject
operator: str
reason: str = ""
modify: dict | None = None
@app.post("/api/review")
def review(body: ReviewBody) -> dict:
"""人机协同审核:通过后写入正式图谱(真实区间/来源/场景行业边/演化事件),
并写入覆写层保证跨重建持久化,全程留痕。"""
from app.services.overrides import apply_accepted_job, record_job_accept
kg = _get_kg()
try:
accepted, record = apply_review(
_db_path(), resolve(_cfg, "audit_path"),
body.candidate_id, body.action, body.operator, body.reason, body.modify)
except KeyError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
if accepted is not None:
from app.services.overrides import normalize_job_id
candidate_id = accepted.job_id
accepted.job_id = normalize_job_id(accepted) # cand_X → job_<岗位名>,全图唯一节点
apply_accepted_job(kg, accepted) # 真实窗口/来源入库 + 场景行业边 + 新增事件
record_job_accept(_db_path(), accepted, candidate_id=candidate_id) # 覆写层持久化,重建后自动重放
kg.save(resolve(_cfg, "graph_path"))
return _ok({"review": record.model_dump(),
"accepted": accepted.model_dump() if accepted else None})
@app.get("/api/review/pending")
def pending_list() -> dict:
return _ok([c.model_dump() for c in load_pending(_db_path())])
# ---------- 图谱节点编辑(人机协同,留痕) ----------
ALLOWED_NODE_FIELDS = {"title", "category", "level", "importance", "weight"}
class NodeUpdateBody(BaseModel):
node_id: str
updates: dict # 仅允许 ALLOWED_NODE_FIELDS 中的键
operator: str
reason: str = ""
def _append_audit(record: dict) -> None:
"""向审核留痕追加一条记录(与审核流同一 SQLite 状态库 audit_log 表)。"""
state_db.append_audit(_db_path(), record)
@app.post("/api/graph/node/update")
def node_update(body: NodeUpdateBody) -> dict:
"""编辑节点展示属性(名称/类别/级别/重要性/权重),写图谱并留痕。
只改属性不改结构:节点 id、边与演化历史不受影响。
Job 节点同步 kg.jobs 的 JobDefinition(title/category/级别),
编辑写入覆写层(job_overrides 表),pipeline 重建后自动重放。
"""
from app.services.overrides import apply_node_updates, record_node_update
kg = _get_kg()
updates = {k: v for k, v in body.updates.items() if k in ALLOWED_NODE_FIELDS}
if body.node_id not in kg.g:
raise HTTPException(status_code=404, detail="节点不存在")
if not updates:
raise HTTPException(status_code=400, detail="没有可更新的字段")
attrs = kg.g.nodes[body.node_id]
before = {k: attrs.get(k) for k in updates}
apply_node_updates(kg, body.node_id, updates)
record_node_update(_db_path(), body.node_id, updates)
kg.save(resolve(_cfg, "graph_path"))
record = {"kind": "node_update", "node_id": body.node_id,
"operator": body.operator, "reason": body.reason,
"before": before, "after": updates, "time": now_str()}
_append_audit(record)
return _ok({"node": {"id": body.node_id, **attrs}, "audit": record},
message="节点已更新")
@app.get("/api/audit")
def audit_list(node_id: str = "", limit: int = 50) -> dict:
"""读取审核/编辑留痕(新→旧排序),可按节点过滤。返回结构与旧 audit.jsonl 行一致。"""
records = state_db.get_audit(_db_path(), node_id=node_id or None,
limit=max(1, min(limit, 200)))
return _ok(records)
# ---------- JD 原文回查(证据来源弹窗) ----------
@app.get("/api/jd")
def jd_by_source(sid: str) -> dict:
"""按 source_id(形如 batch_2019.jsonl#17)回查 JD 原文片段。"""
if "#" not in sid:
raise HTTPException(status_code=400, detail="source_id 格式应为 文件名#行号")
fname, _, lineno_s = sid.partition("#")
if Path(fname).name != fname or not fname.endswith(".jsonl"):
raise HTTPException(status_code=400, detail="非法文件名")
try:
lineno = int(lineno_s)
except ValueError as exc:
raise HTTPException(status_code=400, detail="非法行号") from exc
path = resolve(_cfg, "jd_raw_dir") / fname
if not path.exists():
raise HTTPException(status_code=404, detail="数据文件不存在")
lines = path.read_text(encoding="utf-8").splitlines()
if not (1 <= lineno <= len(lines)):
raise HTTPException(status_code=404, detail="记录不存在")
try:
rec = JDRecord(**json.loads(lines[lineno - 1]))
except (json.JSONDecodeError, ValidationError, TypeError) as exc:
raise HTTPException(status_code=404, detail=f"记录解析失败:{exc}") from exc
rec.source_id = sid
return _ok(rec.model_dump())
# ---------- 简历 / 匹配 / 学习路径 ----------
class ResumeBody(BaseModel):
text: str
@app.post("/api/resume/parse")
def resume_parse(body: ResumeBody) -> dict:
matcher = SkillMatcher(load_lexicon(resolve(_cfg, "lexicon_path")))
profile = parse_resume(body.text, matcher)
return _ok(profile.model_dump())
_RESUME_EXTS = {".txt", ".md", ".docx", ".pdf"}
_RESUME_MAX_BYTES = 5 * 1024 * 1024 # 5MB
@app.post("/api/resume/parse/file")
async def resume_parse_file(file: UploadFile) -> dict:
"""上传简历文件(.txt/.md/.docx/.pdf)并解析:脱敏 + 技能抽取归一化。
文件名随机化落临时目录,解析完成即删除;临时文件不进入日志。
"""
suffix = Path(file.filename or "").suffix.lower()
if suffix not in _RESUME_EXTS:
raise HTTPException(status_code=400,
detail=f"不支持的格式 {suffix or '(无扩展名)'},支持 {sorted(_RESUME_EXTS)}")
content = await file.read()
if not content:
raise HTTPException(status_code=400, detail="文件为空")
if len(content) > _RESUME_MAX_BYTES:
raise HTTPException(status_code=400, detail="文件超过 5MB 限制")
tmp_dir = ROOT / "data" / "tmp"
tmp_dir.mkdir(parents=True, exist_ok=True)
tmp_path = tmp_dir / f"resume_{uuid.uuid4().hex[:12]}{suffix}"
try:
tmp_path.write_bytes(content)
matcher = SkillMatcher(load_lexicon(resolve(_cfg, "lexicon_path")))
profile = parse_resume_file(tmp_path, matcher)
except RuntimeError as exc: # 如 pdf 依赖缺失、文件损坏等
raise HTTPException(status_code=422, detail=str(exc)) from exc
except Exception as exc:
raise HTTPException(status_code=422, detail=f"简历文件解析失败:{exc}") from exc
finally:
tmp_path.unlink(missing_ok=True)
logger.info("简历文件解析完成: 格式=%s, 技能=%d 项", suffix, len(profile.skills))
return _ok(profile.model_dump())
class MatchBody(BaseModel):
job_id: str
text: str = "" # 简历原文(与 skills 二选一)
skills: list[str] = []
years: float = 0.0
scenarios: list[str] = []
class ResumeExportBody(BaseModel):
profile: dict # ResumeProfile 序列化结果
match: dict | None = None # 可选:匹配结果,用于关键词高亮与诊断摘要
_BATCH_MAX_FILES = 50
@app.post("/api/match/batch")
async def match_batch(job_id: str = Form(...), files: list[UploadFile] = File(...)) -> dict:
"""批量人岗匹配:多份简历(.txt/.md/.docx/.pdf,≤50 份)对同一岗位打分排序。
每份简历独立解析(含脱敏)与匹配,单份失败不影响整批(记 error 返回)。
"""
kg = _get_kg()
job = kg.jobs.get(job_id)
if job is None:
raise HTTPException(status_code=404, detail="岗位不存在")
if not files:
raise HTTPException(status_code=400, detail="未收到简历文件")
if len(files) > _BATCH_MAX_FILES:
raise HTTPException(status_code=400, detail=f"单批最多 {_BATCH_MAX_FILES} 份简历")
engine = _engine()
matcher = SkillMatcher(load_lexicon(resolve(_cfg, "lexicon_path")))
tmp_dir = ROOT / "data" / "tmp"
tmp_dir.mkdir(parents=True, exist_ok=True)
results: list[dict] = []
for f in files:
name = f.filename or "未命名"
suffix = Path(name).suffix.lower()
if suffix not in _RESUME_EXTS:
results.append({"filename": name, "error": f"不支持的格式 {suffix or '(无扩展名)'}"})
continue
content = await f.read()
if not content or len(content) > _RESUME_MAX_BYTES:
results.append({"filename": name, "error": "文件为空或超过 5MB"})
continue
tmp_path = tmp_dir / f"batch_{uuid.uuid4().hex[:12]}{suffix}"
try:
tmp_path.write_bytes(content)
profile = parse_resume_file(tmp_path, matcher)
m = engine.match(profile, job)
results.append({
"filename": name,
"name": profile.name or "(未识别)",
"years": profile.years,
"skills_count": len(profile.skills),
"total_score": m.total_score,
"dimension_scores": m.dimension_scores,
"matched": m.matched_required + m.matched_preferred,
"missing": m.missing_required + m.missing_preferred,
"is_match": m.is_match,
})
except Exception as exc: # noqa: BLE001 - 单份失败不拖垮整批
logger.warning("批量匹配:%s 解析失败: %s", name, exc)
results.append({"filename": name, "error": f"解析失败:{exc}"})
finally:
tmp_path.unlink(missing_ok=True)
ok = [r for r in results if "error" not in r]
ok.sort(key=lambda r: r["total_score"], reverse=True)
logger.info("批量匹配完成: 岗位=%s, 成功 %d/%d", job.title, len(ok), len(files))
return _ok({"job_id": job_id, "job_title": job.title,
"total": len(results), "ok": len(ok), "results": ok + [r for r in results if "error" in r]})
@app.post("/api/resume/export")
def resume_export(body: ResumeExportBody) -> Response:
"""把脱敏简历画像导出为规范 DOCX(照片+基本信息+分节+关键词高亮)。"""
from app.match.resume_docx import build_resume_docx
try:
profile = ResumeProfile(**body.profile)
except ValidationError as exc:
raise HTTPException(status_code=400, detail=f"简历画像格式错误:{exc}") from exc
data = build_resume_docx(profile, body.match)
fname = quote("简历_脱敏结构化.docx")
return Response(
content=data,
media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
headers={"Content-Disposition": f"attachment; filename*=utf-8''{fname}"})
def _profile_of(body: MatchBody) -> ResumeProfile:
matcher = SkillMatcher(load_lexicon(resolve(_cfg, "lexicon_path")))
if body.text:
return parse_resume(body.text, matcher)
# 手动录入的技能清单:三级归一化(大小写/别名→包含容错);
# 仍未命中的,若语义通道就绪则做 BGE-M3 语义召回兜底(可选增强)
lexicon = load_lexicon(resolve(_cfg, "lexicon_path"))
skills: list[str] = []
for s in body.skills:
if not s or not s.strip():
continue
norm = matcher.normalize(s)
if norm == s.strip():
sem = semantic.most_similar(norm, _cfg, lexicon)
if sem:
logger.info("语义召回: %s → %s (%.3f)", norm, sem[0], sem[1])
norm = sem[0]
skills.append(norm)
return ResumeProfile(skills=skills, years=body.years, scenarios=body.scenarios)
def _engine() -> MatchEngine:
return MatchEngine(
lexicon=load_lexicon(resolve(_cfg, "lexicon_path")),
weights={
"required": float(_cfg.get("match_weight_required", 0.5)),
"preferred": float(_cfg.get("match_weight_preferred", 0.15)),
"experience": float(_cfg.get("match_weight_experience", 0.2)),
"scenario": float(_cfg.get("match_weight_scenario", 0.15)),
},
threshold=float(_cfg.get("match_threshold", 60.0)),
)
@app.post("/api/match")
def match(body: MatchBody) -> dict:
kg = _get_kg()
job = kg.jobs.get(body.job_id)
if job is None:
raise HTTPException(status_code=404, detail="岗位不存在")
result = _engine().match(_profile_of(body), job)
return _ok(result.model_dump())
class LearnBody(BaseModel):
job_id: str
known_skills: list[str] = []
@app.post("/api/learning-path")
def learning_path(body: LearnBody) -> dict:
kg = _get_kg()
job = kg.jobs.get(body.job_id)
if job is None:
raise HTTPException(status_code=404, detail="岗位不存在")
profile = ResumeProfile(skills=body.known_skills)
result = _engine().match(profile, job)
return _ok({"job_id": job.job_id, "title": job.title,
"learning_path": result.learning_path,
"missing_required": result.missing_required})
# ---------- webui 静态托管 ----------
WEBUI = ROOT / "webui"
@app.get("/", include_in_schema=False)
def index():
return FileResponse(WEBUI / "index.html")
app.mount("/static", StaticFiles(directory=WEBUI), name="static")
+62
View File
@@ -0,0 +1,62 @@
"""应用装配层:日志、图谱初始化、服务启动。
main.py 只做一件事——调用本模块的 run()。分层关系:
main.py 唯一入口(装配)
└─ app.bootstrap 启动流程(本模块)
├─ app.config 配置加载(config.yaml + config.json)
├─ app.services 端到端 pipeline 编排
└─ app.api.routes FastAPI 接口层(uvicorn 托管)
"""
from __future__ import annotations
import logging
import threading
import time
import webbrowser
import uvicorn
from app.config import load_config, resolve
logger = logging.getLogger(__name__)
def setup_logging() -> None:
"""全局日志:时间 | 级别 | 模块 | 消息。"""
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
)
def ensure_graph(cfg: dict) -> None:
"""图谱不存在时先跑一遍端到端 pipeline 构建。"""
graph_path = resolve(cfg, "graph_path")
if graph_path.exists():
logger.info("检测到已有图谱 %s,直接加载", graph_path)
return
logger.info("图谱不存在,先执行端到端 pipeline 构建……")
from app.services.pipeline import run_pipeline
stats = run_pipeline(cfg)
logger.info("图谱构建完成:%s", stats["stages"].get("graph"))
def _open_browser_later(url: str, delay: float = 1.5) -> None:
time.sleep(delay)
try:
webbrowser.open(url)
except Exception as exc: # 浏览器打不开不影响服务
logger.warning("自动打开浏览器失败:%s,请手动访问 %s", exc, url)
def run() -> None:
"""启动流程:日志 → 配置 → 图谱 → 服务 → 自动开浏览器。"""
setup_logging()
cfg = load_config()
ensure_graph(cfg)
host, port = str(cfg["host"]), int(cfg["port"])
url = f"http://{host}:{port}"
logger.info("CareerNexus 职联启动中:%s", url)
threading.Thread(target=_open_browser_later, args=(url,), daemon=True).start()
uvicorn.run("app.api.routes:app", host=host, port=port, log_level="info")
View File
+33
View File
@@ -0,0 +1,33 @@
"""时间衰减:招聘数据存在时滞,越旧的 JD 对当前能力刻画的贡献越低。
权重公式:w = exp(-λ · age),age 为发布日期距参考日期的年数。
"""
from __future__ import annotations
import logging
import math
from datetime import date
from app.models import JDRecord
logger = logging.getLogger(__name__)
def decay_weight(publish_date: date, ref_date: date, lam: float = 0.35) -> float:
"""计算单条 JD 的时间衰减权重。"""
age_years = max(0.0, (ref_date - publish_date).days / 365.25)
return round(math.exp(-lam * age_years), 4)
def apply_decay(
records: list[JDRecord],
ref_date: date | None = None,
lam: float = 0.35,
) -> list[JDRecord]:
"""就地更新每条记录的 weight(与已有降权相乘,不会覆盖抄袭降权)。"""
if ref_date is None:
ref_date = max((r.publish_date for r in records), default=date.today())
for r in records:
r.weight = round(r.weight * decay_weight(r.publish_date, ref_date, lam), 4)
logger.info("时间衰减完成:λ=%.3f,参考日期 %s", lam, ref_date.isoformat())
return records
+119
View File
@@ -0,0 +1,119 @@
"""近重复(抄袭)JD 检测:纯 Python MinHash(64 哈希、5-gram shingles)+ LSH banding。
招聘网站之间存在大量"同文不同公司"的抄袭 JD,直接入图会导致技能频次虚高。
本模块把近重复 JD 聚簇:簇内保留最早来源,其余标记 is_duplicate 并降权。
"""
from __future__ import annotations
import hashlib
import logging
import random
from app.models import JDRecord
logger = logging.getLogger(__name__)
NUM_HASHES = 128 # MinHash 哈希函数个数(>=64,越高估计越稳)
SHINGLE_N = 5 # 字符 5-gram shingles
LSH_BANDS = 16 # 16 bands × 8 rows(严格分带,抑制模板化文本误配)
_PRIME = (1 << 61) - 1 # 梅森素数,用于 (a*x+b) mod p 哈希族
_MAXINT = (1 << 32) - 1
def shingles(text: str, n: int = SHINGLE_N) -> set[int]:
"""把文本切成字符 n-gram shingles,并映射为 32 位整数集合。"""
text = "".join(text.split()) # 去掉空白,抵抗排版差异
if len(text) < n:
text = text + " " * (n - len(text))
out = set()
for i in range(len(text) - n + 1):
h = hashlib.md5(text[i:i + n].encode("utf-8")).digest()
out.add(int.from_bytes(h[:4], "little"))
return out
class MinHash:
"""(a*x+b) mod p 置换哈希族实现的 MinHash 签名。"""
def __init__(self, num_hashes: int = NUM_HASHES, seed: int = 42):
self.num_hashes = num_hashes
# 确定性大随机系数,保证 (a*x+b) mod p 是真置换哈希且可复现
rng = random.Random(seed)
self._a = [rng.randrange(1, _PRIME) for _ in range(num_hashes)]
self._b = [rng.randrange(0, _PRIME) for _ in range(num_hashes)]
def signature(self, shingle_set: set[int]) -> list[int]:
return [min((a * x + b) % _PRIME for x in shingle_set)
for a, b in zip(self._a, self._b)]
def estimate(self, s1: list[int], s2: list[int]) -> float:
return sum(1 for x, y in zip(s1, s2) if x == y) / self.num_hashes
def _lsh_candidates(sig: list[int], bands: int, buckets: dict) -> None:
"""把签名按 band 切段放入哈希桶,同桶者成为候选对。"""
rows = len(sig) // bands
for b in range(bands):
key = (b, tuple(sig[b * rows:(b + 1) * rows]))
buckets.setdefault(key, []).append(sig)
def find_near_duplicates(
records: list[JDRecord],
threshold: float = 0.85,
num_hashes: int = NUM_HASHES,
bands: int = LSH_BANDS,
) -> list[list[str]]:
"""对全部 JD 做近重复聚簇,返回簇列表(每簇为 jid 列表,保留者排第一)。
簇内按 (publish_date, jid) 排序,最早来源为保留者;其余记录被标记
``is_duplicate=True``、``dup_of=<保留者 jid>`` 并降权 0.2。
"""
mh = MinHash(num_hashes)
sigs: dict[str, list[int]] = {}
for r in records:
sigs[r.jid] = mh.signature(shingles(r.text))
buckets: dict = {}
for sig in sigs.values():
_lsh_candidates(sig, bands, buckets)
# 并查集聚簇:候选对需通过完整签名相似度复核
parent = {r.jid: r.jid for r in records}
def find(x: str) -> str:
while parent[x] != x:
parent[x] = parent[parent[x]]
x = parent[x]
return x
def union(x: str, y: str) -> None:
parent[find(x)] = find(y)
jid_of_sig = {id(sig): jid for jid, sig in sigs.items()}
for members in buckets.values():
for i in range(len(members)):
for j in range(i + 1, len(members)):
if mh.estimate(members[i], members[j]) >= threshold:
union(jid_of_sig[id(members[i])], jid_of_sig[id(members[j])])
groups: dict[str, list[JDRecord]] = {}
by_jid = {r.jid: r for r in records}
for r in records:
groups.setdefault(find(r.jid), []).append(r)
clusters: list[list[str]] = []
for members in groups.values():
if len(members) < 2:
continue
members.sort(key=lambda r: (r.publish_date, r.jid))
keeper = members[0]
cluster = [keeper.jid]
for dup in members[1:]:
dup.is_duplicate = True
dup.dup_of = keeper.jid
dup.weight = round(dup.weight * 0.2, 4) # 抄袭来源降权
cluster.append(dup.jid)
clusters.append(cluster)
logger.info("检出近重复簇:保留 %s,降权 %d 条", keeper.jid, len(members) - 1)
return clusters
+47
View File
@@ -0,0 +1,47 @@
"""噪声过滤:识别广告尾注、无关条款等非岗位描述内容。"""
from __future__ import annotations
import logging
import re
from app.models import JDRecord
logger = logging.getLogger(__name__)
# 噪声特征:广告导流、扫码领资料、与岗位无关的商业条款等
NOISE_PATTERNS = [
r"扫码|点击关注|关注公众号|领取.{0,6}(资料|福利|红包)",
r"【广告】|推广信息|商务合作请加",
r"长期回收|代办|加盟代理|刷单",
r"内部推荐奖.{0,10}扫码",
]
_NOISE_RE = re.compile("|".join(NOISE_PATTERNS))
def is_noise(text: str) -> bool:
"""判断文本是否含有噪声内容。"""
return bool(_NOISE_RE.search(text))
def strip_noise_lines(text: str) -> str:
"""逐行剔除噪声行,保留正文(用于噪声行与正文混排的情况)。"""
kept = [ln for ln in text.splitlines() if ln.strip() and not is_noise(ln)]
return "\n".join(kept)
def filter_noise(records: list[JDRecord]) -> list[JDRecord]:
"""标记噪声记录:整条以噪声为主的记录 is_noise=True 并降权;混排的只剔除噪声行。"""
marked = 0
for r in records:
lines = [ln for ln in r.text.splitlines() if ln.strip()]
noisy = [ln for ln in lines if is_noise(ln)]
if not noisy:
continue
if len(noisy) >= max(1, len(lines) // 2):
r.is_noise = True
r.weight = round(r.weight * 0.1, 4)
marked += 1
else:
r.text = strip_noise_lines(r.text)
logger.info("噪声过滤:%d 条记录被标记为噪声", marked)
return records
+110
View File
@@ -0,0 +1,110 @@
"""极简配置加载:解析平铺的 ``key: value`` 格式 config.yaml,避免额外依赖。
运行时设置(监听地址/端口/访问令牌/LLM 密钥)保存在 config.json,
由设置接口写入、启动时合并读取,优先级:config.json > config.yaml > DEFAULTS。
"""
from __future__ import annotations
import json
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
CONFIG_JSON = ROOT / "config.json" # 运行时设置文件(测试可 monkeypatch 此路径)
# 允许通过设置面板/config.json 覆盖的键白名单
RUNTIME_KEYS = {
"mode", "host", "port", "access_token",
"llm_api_key", "llm_base_url", "llm_model", "llm_timeout",
"embed_model_path", "embed_provider", "embed_api_key",
"embed_api_base_url", "embed_api_model",
"guided",
}
DEFAULTS: dict = {
"mode": "demo", # demo | live
"host": "127.0.0.1",
"port": 9008,
"data_dir": "data",
"jd_raw_dir": "data/jd_raw",
"jd_extra_dirs": "", # 附加 JD 数据源目录(逗号分隔,可切换到真实数据集)
"graph_path": "data/graph.json",
"lexicon_path": "data/skills_lexicon.json",
"pending_path": "data/review/pending.json", # 旧待审核文件(仅作 SQLite 一次性迁移种子)
"audit_path": "data/review/audit.jsonl", # 旧留痕文件(仅作 SQLite 一次性迁移种子)
"state_db_path": "data/careernexus.db", # SQLite 应用状态库(审核留痕/候选/任务)
"decay_lambda": 0.35, # 时间衰减系数 w = exp(-λ·age)
"match_weight_required": 0.50, # 人岗匹配四维权重
"match_weight_preferred": 0.15,
"match_weight_experience": 0.20,
"match_weight_scenario": 0.15,
"match_threshold": 60.0, # 总分 >= 阈值视为匹配
"llm_api_key_env": "SPARK_API_KEY", # 密钥只从环境变量读取
"llm_base_url": "https://spark-api-open.xf-yun.com/v1",
"llm_model": "4.0Ultra",
"llm_timeout": 20,
}
def _coerce(value: str):
v = value.strip()
# 行内注释:值中第一个 " #" 之后的内容丢弃(URL 等含 # 的值不受影响)
if " #" in v:
v = v.split(" #", 1)[0].strip()
if v.lower() in ("true", "false"):
return v.lower() == "true"
try:
return int(v)
except ValueError:
pass
try:
return float(v)
except ValueError:
pass
return v
def load_config(path: str | Path | None = None) -> dict:
"""加载配置:DEFAULTS ← config.yaml ← config.json(运行时设置,优先级最高)。"""
cfg = dict(DEFAULTS)
p = Path(path) if path else ROOT / "config.yaml"
if p.exists():
for line in p.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line or line.startswith("#") or ":" not in line:
continue
key, _, value = line.partition(":")
cfg[key.strip()] = _coerce(value)
cfg.update(load_runtime_settings())
return cfg
def load_runtime_settings() -> dict:
"""读取 config.json 中的白名单键;文件不存在或损坏时返回空 dict。"""
try:
if CONFIG_JSON.exists():
data = json.loads(CONFIG_JSON.read_text(encoding="utf-8"))
return {k: v for k, v in data.items() if k in RUNTIME_KEYS}
except (json.JSONDecodeError, OSError):
pass
return {}
def save_runtime_settings(updates: dict) -> dict:
"""把白名单内的设置合并写入 config.json,返回保存后的完整运行时设置。"""
current = load_runtime_settings()
for k, v in updates.items():
if k not in RUNTIME_KEYS:
continue
if v is None or (isinstance(v, str) and not v.strip()):
current.pop(k, None) # 空值视为清除该设置
else:
current[k] = v
CONFIG_JSON.write_text(json.dumps(current, ensure_ascii=False, indent=2),
encoding="utf-8")
return current
def resolve(cfg: dict, key: str) -> Path:
"""把配置中的相对路径解析为工程根目录下的绝对路径。"""
p = Path(str(cfg[key]))
return p if p.is_absolute() else ROOT / p
View File
+251
View File
@@ -0,0 +1,251 @@
"""新岗位发现:纯 Python TF-IDF(字符 n-gram)向量化 + 凝聚聚类。
流程:无法归入既有岗位的 JD → TF-IDF 向量 → 余弦相似度凝聚聚类 →
簇同时满足四项指标(支撑 JD 数 / 独立企业数 / 近期增长率 / 与既有岗位差异度)→
候选新岗位 → 进入“待审核”分区,人工 accept/modify/reject 后才写入正式图谱,审核留痕。
阈值与《项目方案》第七章对齐:≥15 条 JD / ≥5 家企业 / 增长率 ≥50% / 差异度 ≥0.4。
"""
from __future__ import annotations
import logging
import math
from collections import Counter
from pathlib import Path
from app.graph.tkg import now_str, quarter_of
from app.models import ExtractionResult, JDRecord, JobDefinition, ReviewRecord
logger = logging.getLogger(__name__)
# 候选新岗位阈值(与方案文档一致)
MIN_CLUSTER_SIZE = 15 # 支撑 JD 数
MIN_COMPANIES = 5 # 独立企业数(防单一企业造词)
MIN_GROWTH = 1.5 # 近期增长率(最近一年 JD 数 / 前一年,≥50% 增长)
MIN_DISTINCTNESS = 0.4 # 与既有岗位差异度:1 - 与最近既有岗位质心的余弦相似度
MERGE_SIM = 0.50 # 凝聚合并的质心余弦相似度阈值
# ---------- TF-IDF(字符 2/3-gram) ----------
def _char_ngrams(text: str, ns=(2, 3)) -> list[str]:
text = "".join(text.split())
out: list[str] = []
for n in ns:
out.extend(text[i:i + n] for i in range(max(0, len(text) - n + 1)))
return out
class TfidfVectorizer:
"""极简 TF-IDF:字符 n-gram 词表 + IDF 加权 + L2 归一。"""
def __init__(self):
self.idf: dict[str, float] = {}
def fit(self, docs: list[str]) -> "TfidfVectorizer":
df: Counter = Counter()
for d in docs:
df.update(set(_char_ngrams(d)))
n = max(1, len(docs))
self.idf = {t: math.log((n + 1) / (c + 1)) + 1.0 for t, c in df.items()}
return self
def transform(self, doc: str) -> dict[str, float]:
tf: Counter = Counter(_char_ngrams(doc))
vec = {t: (c / max(1, sum(tf.values()))) * self.idf.get(t, 1.0)
for t, c in tf.items()}
norm = math.sqrt(sum(v * v for v in vec.values())) or 1.0
return {t: v / norm for t, v in vec.items()}
def cosine(a: dict[str, float], b: dict[str, float]) -> float:
"""稀疏向量余弦相似度。"""
if len(a) > len(b):
a, b = b, a
return sum(v * b.get(t, 0.0) for t, v in a.items())
def _centroid(vecs: list[dict[str, float]]) -> dict[str, float]:
c: dict[str, float] = {}
for v in vecs:
for t, x in v.items():
c[t] = c.get(t, 0.0) + x
n = max(1, len(vecs))
return {t: x / n for t, x in c.items()}
def agglomerative(vecs: list[dict[str, float]], merge_sim: float = MERGE_SIM) -> list[list[int]]:
"""凝聚聚类(平均链接的贪心实现):每轮合并质心最相似的一对簇。"""
clusters: list[list[int]] = [[i] for i in range(len(vecs))]
centroids = [_centroid([v]) for v in vecs]
while True:
best, pair = merge_sim, None
for i in range(len(clusters)):
for j in range(i + 1, len(clusters)):
s = cosine(centroids[i], centroids[j])
if s > best:
best, pair = s, (i, j)
if pair is None:
return clusters
i, j = pair
clusters[i].extend(clusters[j])
centroids[i] = _centroid([vecs[k] for k in clusters[i]])
del clusters[j], centroids[j]
# ---------- 候选生成与审核 ----------
def discover_new_jobs(
records: list[JDRecord],
extractions: dict[str, ExtractionResult],
known_titles: set[str],
min_size: int = MIN_CLUSTER_SIZE,
min_companies: int = MIN_COMPANIES,
min_growth: float = MIN_GROWTH,
min_distinctness: float = MIN_DISTINCTNESS,
) -> list[JobDefinition]:
"""从未知岗位 JD 中聚类发现候选新岗位(status=pending)。
四指标判定:支撑 JD 数 / 独立企业数 / 近期增长率 / 与既有岗位差异度。
"""
unknown = [r for r in records
if not r.is_noise and not r.is_duplicate
and r.jid in extractions
and extractions[r.jid].job_title not in known_titles]
if len(unknown) < min_size:
logger.info("未知岗位 JD 不足 %d 条,跳过新岗位发现", min_size)
return []
# 岗位名称是强信号:重复十次提升其在 TF-IDF 中的权重;
# 再叠加抽取出的必备技能(重复三次),保证长文本、多模板句式的 JD 仍能按岗位聚拢
def doc_of(r: JDRecord) -> str:
e = extractions[r.jid]
return f"{r.job_title} " * 10 + " ".join(e.required_skills * 3) + " " + r.text
docs = [doc_of(r) for r in unknown]
vectorizer = TfidfVectorizer().fit(docs)
vecs = [vectorizer.transform(d) for d in docs]
clusters = agglomerative(vecs)
# 既有岗位质心(差异度基准):按已知标题聚合并向量化
known_docs: dict[str, list[str]] = {}
for r in records:
e = extractions.get(r.jid)
if e and e.job_title in known_titles and not r.is_noise and not r.is_duplicate:
known_docs.setdefault(e.job_title, []).append(doc_of(r))
known_centroids = {t: _centroid([vectorizer.transform(d) for d in ds])
for t, ds in known_docs.items()}
candidates: list[JobDefinition] = []
for members in clusters:
if len(members) < min_size:
continue
group = [unknown[i] for i in members]
companies = {r.company for r in group}
if len(companies) < min_companies:
continue
years = Counter(r.publish_date.year for r in group)
latest_year = max(years)
prev_year_n = years.get(latest_year - 1, 0)
# 前一年没有基数视为“从无到有”,增长率视为达标
growth = years[latest_year] / prev_year_n if prev_year_n else float("inf")
if growth < min_growth:
continue
# 差异度:与最近既有岗位质心的距离,过近说明只是既有岗位的变体
cvec = _centroid([vecs[i] for i in members])
max_sim = max((cosine(cvec, kc) for kc in known_centroids.values()), default=0.0)
distinctness = 1.0 - max_sim
if distinctness < min_distinctness:
logger.info("簇与既有岗位差异度不足(%.2f < %.2f),不列为新岗位", distinctness, min_distinctness)
continue
# 簇内最常见的抽取标题作为候选岗位名;技能取簇内高频
titles = Counter(extractions[r.jid].job_title for r in group)
title = titles.most_common(1)[0][0]
req = Counter(s for r in group for s in extractions[r.jid].required_skills)
pref = Counter(s for r in group for s in extractions[r.jid].preferred_skills)
scen = Counter(s for r in group for s in extractions[r.jid].scenarios)
ind = Counter(r.industry for r in group if r.industry)
n = len(group)
candidates.append(JobDefinition(
job_id=f"cand_{title}",
title=title,
category="新岗位候选",
industries=[s for s, _ in ind.most_common(2)],
required_skills=[s for s, c in req.most_common() if c >= n * 0.4],
preferred_skills=[s for s, c in pref.most_common() if c >= n * 0.25],
scenarios=[s for s, _ in scen.most_common(5)],
status="pending",
source_count=n,
company_count=len(companies),
support_source_ids=[r.source_id for r in group],
support_windows=sorted({quarter_of(r.publish_date) for r in group}),
))
logger.info("候选新岗位:%s(支撑 %d 条 JD / %d 家企业 / 增长率 %.2f / 差异度 %.2f)",
title, n, len(companies), growth, distinctness)
return candidates
# ---------- 待审核分区与审核留痕(SQLite 状态库,见 app/storage/db.py) ----------
def save_pending(candidates: list[JobDefinition], path: str | Path) -> None:
"""整体覆写待审核分区。``path`` 为 SQLite 状态库路径(配置键 state_db_path)。"""
from app.storage import db
keep = set()
for c in candidates:
db.save_candidate(path, c.model_dump())
keep.add(c.job_id)
for old in db.list_candidates(path):
if old["job_id"] not in keep:
db.delete_candidate(path, old["job_id"])
def load_pending(path: str | Path) -> list[JobDefinition]:
"""读取全部待审核候选。``path`` 为 SQLite 状态库路径(配置键 state_db_path)。"""
from app.storage import db
return [JobDefinition(**d) for d in db.list_candidates(path)]
def apply_review(
pending_path: str | Path,
audit_path: str | Path,
candidate_id: str,
action: str,
operator: str,
reason: str = "",
modify: dict | None = None,
) -> tuple[JobDefinition | None, ReviewRecord]:
"""人工审核:accept/modify/reject。返回 (通过的岗位定义或 None, 审核留痕)。
``pending_path`` 为 SQLite 状态库路径;``audit_path`` 为旧 audit.jsonl 路径,
仅在状态库首次打开时作为一次性迁移种子使用(原文件保留)。
"""
from app.storage import db
db.connect(pending_path, legacy_audit=audit_path).close() # 首次打开触发迁移(幂等)
candidates = load_pending(pending_path)
target = next((c for c in candidates if c.job_id == candidate_id), None)
if target is None:
raise KeyError(f"候选不存在:{candidate_id}")
before = target.model_dump()
accepted: JobDefinition | None = None
after: dict = {}
if action == "accept":
target.status = "active"
accepted = target
after = target.model_dump()
elif action == "modify":
if modify:
target = target.model_copy(update=modify)
target.status = "active"
accepted = target
after = target.model_dump()
elif action == "reject":
target.status = "rejected"
after = target.model_dump()
# 驳回:若该候选曾入库(覆写层有记录),一并清理,避免重建后复活
db.delete_override(pending_path, candidate_id)
else:
raise ValueError(f"未知审核动作:{action}")
record = ReviewRecord(candidate_id=candidate_id, action=action, operator=operator,
reason=reason, before=before, after=after, time=now_str())
db.append_audit(pending_path, record.model_dump())
# 已审核的候选从待审核分区移除
db.delete_candidate(pending_path, candidate_id)
logger.info("审核完成:%s -> %s(操作人 %s)", candidate_id, action, operator)
return accepted, record
View File
+143
View File
@@ -0,0 +1,143 @@
"""岗位能力演化分析:按季度窗口聚合岗位技能频次,相邻窗口对比产生事件。
事件类型:新增 / 增强 / 减弱 / 退出;每个事件保存前后权重、时间窗、
来源 JD,并生成注明数据源的中文更新说明。事件追加进图谱,不覆盖历史。
"""
from __future__ import annotations
import logging
from collections import defaultdict
from app.graph.tkg import TemporalKG, new_event_id, now_str, quarter_of
from app.models import ChangeEvent, ExtractionResult, JDRecord, GraphObservation
logger = logging.getLogger(__name__)
# 事件判定阈值(频次基于窗口内 JD 计数)
MIN_SUPPORT = 2 # 新增/退出需要的最小支撑次数
GROW_RATIO = 1.5 # 增强:curr >= prev * 1.5 且差值 >= MIN_DIFF
SHRINK_RATIO = 0.5 # 减弱:curr <= prev * 0.5 且差值 >= MIN_DIFF
MIN_DIFF = 3 # 增强/减弱的最小绝对差,抑制稀疏窗口抖动
MIN_ESTABLISHED_WINDOWS = 3 # 技能至少稳定出现 3 个窗口,才允许判定“退出”
def build_observations(
records: list[JDRecord],
extractions: dict[str, ExtractionResult],
job_id_of_title: dict[str, str],
) -> list[GraphObservation]:
"""把“JD × 抽取结果”转换为图谱观察流(按季度窗口,带时间衰减权重)。"""
observations: list[GraphObservation] = []
for r in records:
if r.is_noise:
continue
ext = extractions.get(r.jid)
if ext is None:
continue
job_id = job_id_of_title.get(ext.job_title)
if job_id is None:
continue # 未知岗位走“新岗位发现”链路,不进正式图谱
window = quarter_of(r.publish_date)
for skill in ext.required_skills:
observations.append(GraphObservation(
job_id=job_id, skill=skill, importance="required",
weight=r.weight, window=window, source_ids=[r.source_id]))
for skill in ext.preferred_skills:
observations.append(GraphObservation(
job_id=job_id, skill=skill, importance="preferred",
weight=r.weight * 0.6, window=window, source_ids=[r.source_id]))
return observations
def aggregate_windows(
observations: list[GraphObservation],
) -> dict[str, dict[str, dict[str, dict]]]:
"""聚合为 {job_id: {window: {skill: {count, weight, sources}}}}。"""
agg: dict[str, dict[str, dict[str, dict]]] = defaultdict(lambda: defaultdict(dict))
for o in observations:
cell = agg[o.job_id][o.window].setdefault(
o.skill, {"count": 0, "weight": 0.0, "sources": []})
cell["count"] += 1
cell["weight"] = round(cell["weight"] + o.weight, 4)
for sid in o.source_ids:
if sid not in cell["sources"]:
cell["sources"].append(sid)
return agg
def _describe(change_type: str, job_title: str, skill: str, window: str,
prev: float, curr: float, n_sources: int) -> str:
"""生成注明数据源的中文更新说明。"""
base = f"{window},岗位【{job_title}】技能【{skill}】"
tail = f"(前后权重 {prev:.2f}→{curr:.2f};数据源:招聘 JD,本窗口支撑样本 {n_sources} 条)"
return base + {
"新增": "出现新增能力要求",
"增强": "要求显著增强",
"减弱": "要求明显减弱",
"退出": "不再出现,判定退出该岗位能力图谱",
}[change_type] + tail
def analyze(kg: TemporalKG, agg: dict | None = None,
observations: list[GraphObservation] | None = None) -> list[ChangeEvent]:
"""相邻季度窗口对比,产生演化事件并写入图谱(追加)。返回本次新事件。"""
if agg is None:
agg = aggregate_windows(observations or [])
new_events: list[ChangeEvent] = []
for job_id, windows in agg.items():
job = kg.jobs.get(job_id)
title = job.title if job else job_id
seen_before: set[str] = set()
exited: set[str] = set() # 已判定退出的技能,避免重复报
windows_seen: dict[str, int] = defaultdict(int) # 技能历史出现窗口数
prev_map: dict[str, dict] = {}
sorted_windows = sorted(windows)
for wi, window in enumerate(sorted_windows):
curr_map = windows[window]
# 下一窗口计数(用于“退出”前瞻:下一窗口仍为 0 才确认退出)
next_map = windows[sorted_windows[wi + 1]] if wi + 1 < len(sorted_windows) else {}
skills = set(curr_map) | set(prev_map)
for skill in sorted(skills):
prev = prev_map.get(skill, {"count": 0, "weight": 0.0, "sources": []})
curr = curr_map.get(skill, {"count": 0, "weight": 0.0, "sources": []})
pc, cc = prev["count"], curr["count"]
next_c = next_map.get(skill, {"count": 0})["count"]
change = None
if cc >= MIN_SUPPORT and pc == 0 and skill not in seen_before:
change = "新增"
elif (cc == 0 and next_c == 0 and skill not in exited
and windows_seen.get(skill, 0) >= MIN_ESTABLISHED_WINDOWS):
# 已稳定出现多个窗口的技能,连续两窗口消失 → 退出
change = "退出"
elif pc >= 3 and cc >= pc * GROW_RATIO and cc - pc >= MIN_DIFF:
change = "增强"
elif pc >= 3 and 0 < cc <= pc * SHRINK_RATIO and pc - cc >= MIN_DIFF:
change = "减弱"
if change is None:
continue
ev = ChangeEvent(
event_id=new_event_id(), job_id=job_id, skill=skill,
change_type=change, window=window,
prev_weight=round(prev["weight"], 4),
curr_weight=round(curr["weight"], 4),
source_ids=curr["sources"][:10],
description=_describe(change, title, skill, window,
prev["weight"], curr["weight"],
len(curr["sources"])),
created_at=now_str(),
)
kg.add_event(ev)
new_events.append(ev)
if change == "退出":
kg.close_interval(job_id, skill, window)
exited.add(skill)
for s, c in curr_map.items():
if c["count"] > 0:
exited.discard(s) # 重新出现则解除退出态
seen_before |= {s for s, c in curr_map.items() if c["count"] > 0}
for s, c in curr_map.items():
if c["count"] > 0:
windows_seen[s] += 1
prev_map = curr_map
logger.info("演化分析完成:新增事件 %d 条", len(new_events))
return new_events
View File
+131
View File
@@ -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)
+124
View File
@@ -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())
+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",
)
+43
View File
@@ -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
)
View File
+192
View File
@@ -0,0 +1,192 @@
"""时序知识图谱(TemporalKG)。
- 节点:Job / Skill / Industry / Scenario;
- 关系:REQUIRES,属性含 importance(必备/加分)、weight、sources,
以及 intervals:[[valid_from, valid_to|null]] 生效时间区间列表;
- 演化事件只追加不覆盖:同一 (岗位, 技能) 的历史通过区间与事件流共同表达;
- snapshot(job_id, date):任意时间点(或季度窗口)切片查询;
- JSON 持久化与加载。
"""
from __future__ import annotations
import json
import logging
import uuid
from datetime import datetime
from pathlib import Path
import networkx as nx
from app.models import ChangeEvent, GraphObservation, JobDefinition
logger = logging.getLogger(__name__)
def quarter_of(dt) -> str:
"""日期 -> 季度窗口字符串,如 2022Q1。窗口串按字典序即时间序。"""
return f"{dt.year}Q{(dt.month - 1) // 3 + 1}"
def to_window(d: str) -> str:
"""把 'YYYY-MM-DD' 或 'YYYYQn' 统一为季度窗口串。"""
if "Q" in d:
return d
parts = d.split("-")
return f"{parts[0]}Q{(int(parts[1]) - 1) // 3 + 1}"
def job_level(expected_years: int | None) -> str:
"""由期望年限推出岗位级别:≤2 初级 / 3-5 中级 / >5 高级。"""
y = expected_years if expected_years is not None else 3
return "初级" if y <= 2 else ("中级" if y <= 5 else "高级")
# 级别 -> 代表年限(人工编辑级别时同步回 expected_years,保持筛选一致)
LEVEL_TO_YEARS = {"初级": 2, "中级": 4, "高级": 6}
class TemporalKG:
"""基于 networkx DiGraph 的时序知识图谱。"""
def __init__(self):
self.g = nx.DiGraph()
self.jobs: dict[str, JobDefinition] = {}
self.events: list[ChangeEvent] = []
# ---------- 节点 ----------
def add_job(self, job: JobDefinition) -> None:
self.jobs[job.job_id] = job
self.g.add_node(job.job_id, type="Job", title=job.title,
category=job.category, status=job.status,
expected_years=job.expected_years,
level=job_level(job.expected_years),
source_count=job.source_count,
company_count=job.company_count)
def add_skill(self, name: str, category: str = "") -> None:
if name not in self.g:
self.g.add_node(name, type="Skill", category=category)
def add_scenario(self, name: str) -> None:
node = f"场景::{name}"
if node not in self.g:
self.g.add_node(node, type="Scenario", title=name)
def add_industry(self, name: str) -> None:
node = f"行业::{name}"
if node not in self.g:
self.g.add_node(node, type="Industry", title=name)
# ---------- 关系(追加不覆盖) ----------
def observe(self, obs: GraphObservation) -> None:
"""追加一次岗位-技能观察:新关系开区间,已有关系更新权重与来源。"""
self.add_skill(obs.skill)
if self.g.has_edge(obs.job_id, obs.skill):
attrs = self.g[obs.job_id][obs.skill]
# 最新权重按观察权重滑动更新;sources 累积不去重覆盖历史
attrs["weight"] = round(max(attrs["weight"], obs.weight), 4)
for sid in obs.source_ids:
if sid not in attrs["sources"]:
attrs["sources"].append(sid)
attrs["importance"] = obs.importance if obs.importance == "required" else attrs["importance"]
# 重新出现:若上一区间已关闭,则开新区间
last = attrs["intervals"][-1]
if last[1] is not None and last[1] <= obs.window:
attrs["intervals"].append([obs.window, None])
else:
self.g.add_edge(
obs.job_id, obs.skill, rel="REQUIRES",
importance=obs.importance, weight=round(obs.weight, 4),
intervals=[[obs.window, None]],
sources=list(obs.source_ids),
)
def close_interval(self, job_id: str, skill: str, end_window: str) -> None:
"""关闭 (岗位, 技能) 当前生效区间(技能退出)。"""
if self.g.has_edge(job_id, skill):
last = self.g[job_id][skill]["intervals"][-1]
if last[1] is None:
last[1] = end_window
# ---------- 事件 ----------
def add_event(self, ev: ChangeEvent) -> None:
self.events.append(ev)
def events_of(self, job_id: str) -> list[ChangeEvent]:
return [e for e in self.events if e.job_id == job_id]
# ---------- 切片查询 ----------
def snapshot(self, job_id: str, date_str: str) -> dict:
"""任意时间点切片:返回该时刻生效的技能关系。"""
window = to_window(date_str)
skills = []
if job_id in self.g:
for _, skill, attrs in self.g.out_edges(job_id, data=True):
if attrs.get("rel") != "REQUIRES":
continue
alive = any(s <= window and (e is None or window < e)
for s, e in attrs["intervals"])
if alive:
skills.append({
"skill": skill,
"importance": attrs["importance"],
"weight": attrs["weight"],
"sources": attrs["sources"],
})
job = self.jobs.get(job_id)
return {
"job_id": job_id,
"title": job.title if job else job_id,
"window": window,
"skills": skills,
}
def latest_snapshot(self, job_id: str) -> dict:
latest = "9999Q4"
return self.snapshot(job_id, latest)
# ---------- 统计 ----------
def stats(self) -> dict:
return {
"nodes": self.g.number_of_nodes(),
"edges": self.g.number_of_edges(),
"jobs": len(self.jobs),
"skills": sum(1 for _, d in self.g.nodes(data=True) if d.get("type") == "Skill"),
"events": len(self.events),
}
# ---------- 持久化 ----------
def save(self, path: str | Path) -> None:
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
payload = {
"nodes": [{"id": n, **d} for n, d in self.g.nodes(data=True)],
"edges": [{"source": u, "target": v, **d} for u, v, d in self.g.edges(data=True)],
"jobs": {k: v.model_dump() for k, v in self.jobs.items()},
"events": [e.model_dump() for e in self.events],
}
path.write_text(json.dumps(payload, ensure_ascii=False, indent=1), encoding="utf-8")
logger.info("图谱已持久化到 %s(%d 节点 / %d 边)", path,
self.g.number_of_nodes(), self.g.number_of_edges())
@classmethod
def load(cls, path: str | Path) -> "TemporalKG":
kg = cls()
payload = json.loads(Path(path).read_text(encoding="utf-8"))
for n in payload["nodes"]:
nid = n.pop("id")
kg.g.add_node(nid, **n)
for e in payload["edges"]:
s, t = e.pop("source"), e.pop("target")
kg.g.add_edge(s, t, **e)
kg.jobs = {k: JobDefinition(**v) for k, v in payload["jobs"].items()}
kg.events = [ChangeEvent(**e) for e in payload["events"]]
return kg
def new_event_id() -> str:
return uuid.uuid4().hex[:12]
def now_str() -> str:
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
View File
+134
View File
@@ -0,0 +1,134 @@
"""JD 原始数据接入:读取 data/jd_raw/*.jsonl,分配不可变 source_id,坏行进错误清单。"""
from __future__ import annotations
import csv
import io
import json
import logging
from datetime import datetime
from pathlib import Path
from pydantic import ValidationError
from app.models import JDRecord
logger = logging.getLogger(__name__)
def load_jd_file(path: str | Path) -> tuple[list[JDRecord], list[dict]]:
"""读取单个 jsonl 文件,返回 (合法记录, 错误清单)。"""
path = Path(path)
records: list[JDRecord] = []
errors: list[dict] = []
for lineno, line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1):
source_id = f"{path.name}#{lineno}"
if not line.strip():
continue
try:
payload = json.loads(line)
record = JDRecord(**payload)
# source_id 由接入层分配,外部输入不可伪造
record.source_id = source_id
records.append(record)
except (json.JSONDecodeError, ValidationError, TypeError) as exc:
errors.append({"source_id": source_id, "error": str(exc)[:200], "raw": line[:120]})
logger.warning("坏行已进错误清单: %s", source_id)
return records, errors
def load_jd_dir(directory: str | Path) -> tuple[list[JDRecord], list[dict]]:
"""读取目录下全部 *.jsonl(按文件名排序,保证可复现)。"""
directory = Path(directory)
records: list[JDRecord] = []
errors: list[dict] = []
files = sorted(directory.glob("*.jsonl"))
if not files:
logger.warning("目录 %s 下没有 jsonl 文件", directory)
for f in files:
r, e = load_jd_file(f)
records.extend(r)
errors.extend(e)
logger.info("接入完成:%d 条记录,%d 条坏行", len(records), len(errors))
return records, errors
# ---------- 手动导入(文件上传 / 文本粘贴 → data/jd_raw/import_*.jsonl) ----------
IMPORT_EXTS = {".jsonl", ".csv", ".txt"}
IMPORT_MAX_BYTES = 10 * 1024 * 1024 # 10MB
_CSV_REQUIRED = ("jid", "job_title", "company", "publish_date", "text")
def _validate_payload(payload: dict) -> JDRecord:
"""校验单条导入记录(source_id 由接入层在后续加载时统一分配)。"""
record = JDRecord(**payload)
record.source_id = ""
return record
def parse_import_text(text: str, filename: str = "paste.txt") -> tuple[list[JDRecord], list[dict], int]:
"""解析手动导入内容,返回 (合法记录, 坏行清单, 跳过空行数)。
.csv 按表头映射字段(必需列见 _CSV_REQUIRED);.jsonl/.txt 逐行 JSON。
"""
suffix = Path(filename or "paste.txt").suffix.lower()
records: list[JDRecord] = []
bad: list[dict] = []
skipped = 0
if suffix == ".csv":
rows = list(csv.reader(io.StringIO(text)))
if not rows:
return records, [{"line": 0, "error": "CSV 内容为空", "raw": ""}], skipped
header = [h.strip() for h in rows[0]]
missing = [c for c in _CSV_REQUIRED if c not in header]
if missing:
return records, [{"line": 1, "error": f"CSV 缺少必需列: {missing}", "raw": ",".join(header)[:120]}], skipped
for lineno, row in enumerate(rows[1:], start=2):
if not row or not any(c.strip() for c in row):
skipped += 1
continue
payload = {h: v.strip() for h, v in zip(header, row) if h}
try:
records.append(_validate_payload(payload))
except (ValidationError, TypeError, ValueError) as exc:
bad.append({"line": lineno, "error": str(exc)[:200], "raw": ",".join(row)[:120]})
return records, bad, skipped
for lineno, line in enumerate(text.splitlines(), start=1):
if not line.strip():
skipped += 1
continue
try:
records.append(_validate_payload(json.loads(line)))
except (json.JSONDecodeError, ValidationError, TypeError) as exc:
bad.append({"line": lineno, "error": str(exc)[:200], "raw": line[:120]})
return records, bad, skipped
def save_import(text: str, filename: str, jd_raw_dir: str | Path,
logs_dir: str | Path) -> dict:
"""手动导入落盘:合格记录写 data/jd_raw/import_时间戳.jsonl,坏行写 logs。
返回 {"imported", "skipped", "bad_lines", "file", "bad_log"}。
"""
records, bad, skipped = parse_import_text(text, filename)
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
result: dict = {"imported": len(records), "skipped": skipped,
"bad_lines": bad, "file": "", "bad_log": ""}
if records:
jd_raw_dir = Path(jd_raw_dir)
jd_raw_dir.mkdir(parents=True, exist_ok=True)
out = jd_raw_dir / f"import_{stamp}.jsonl"
with out.open("w", encoding="utf-8") as f:
for r in records:
f.write(json.dumps(r.model_dump(mode="json"), ensure_ascii=False) + "\n")
result["file"] = out.name
logger.info("手动导入:%d 条写入 %s", len(records), out)
if bad:
logs_dir = Path(logs_dir)
logs_dir.mkdir(parents=True, exist_ok=True)
log_path = logs_dir / f"import_bad_{stamp}.jsonl"
with log_path.open("w", encoding="utf-8") as f:
for b in bad:
f.write(json.dumps({"source": filename, **b}, ensure_ascii=False) + "\n")
result["bad_log"] = log_path.name
logger.warning("手动导入坏行 %d 条,已写入 %s", len(bad), log_path)
return result
View File
+286
View File
@@ -0,0 +1,286 @@
"""人岗匹配引擎:技能覆盖率召回 + 四维加权评分 + 差距分析 + 学习路径。
四维权重默认 必备 50% / 加分 15% / 经验 20% / 场景 15%,可通过 config 覆盖。
学习路径:缺失技能按图谱/词表中的前置关系拓扑排序,分阶段输出。
"""
from __future__ import annotations
import logging
from app.extract.base import load_lexicon
from app.models import JobDefinition, MatchResult, ResumeProfile
logger = logging.getLogger(__name__)
DEFAULT_WEIGHTS = {
"required": 0.50,
"preferred": 0.15,
"experience": 0.20,
"scenario": 0.15,
}
def _coverage(have: set[str], want: list[str]) -> tuple[list[str], list[str], float]:
matched = [s for s in want if s in have]
missing = [s for s in want if s not in have]
cov = len(matched) / len(want) if want else 1.0
return matched, missing, cov
def topological_stages(missing: list[str], lexicon: dict) -> list[dict]:
"""把缺失技能按前置关系拓扑排序分阶段(Kahn 分层)。
前置关系取自技能词表 prereqs;前置技能若已掌握或不在缺失集中则视为已满足。
每个阶段附带结构化验收标准(产出物 / 量化验收项 / 自测问答),按技能类别生成。
"""
prereq = {s["name"]: [p for p in s.get("prereqs", []) if p in missing]
for s in lexicon["skills"] if s["name"] in missing}
cat_of = {s["name"]: s.get("category", "") for s in lexicon["skills"]}
remaining = set(missing)
stages: list[dict] = []
stage_no = 1
while remaining:
ready = sorted(s for s in remaining if not [p for p in prereq.get(s, []) if p in remaining])
if not ready: # 有环兜底:按字典序放出,保证终止
ready = sorted(remaining)
stages.append({"stage": stage_no, "skills": ready,
"goal": f"第 {stage_no} 阶段:掌握 {'、'.join(ready)}",
"acceptance": build_acceptance(ready, cat_of)})
remaining -= set(ready)
stage_no += 1
return stages
# ---------------------------------------------------------------- 验收标准模板
# 按技能类别给出差异化的验收过程:产出物、量化验收项、自测问答。
# {skills} 占位为本阶段技能名(顿号连接),{s1}/{s2} 为前两个技能。
_ACCEPTANCE: dict[str, dict] = {
"编程语言": {
"deliverable": "用 {skills} 独立完成一个 300 行以上、可运行的小工具或 CLI 程序,源码提交仓库并附 README",
"criteria": ["覆盖核心语法与常用标准库,代码可一次性运行无报错",
"编写不少于 5 个单元测试用例并全部通过",
"README 说明设计取舍与至少 2 个踩坑记录",
"代码符合该语言主流风格规范(通过对应 linter 检查)"],
"self_check": ["{s1} 与一门相近语言在类型与内存模型上的核心差异是什么?",
"{s1} 最常见的 3 个性能陷阱是什么,如何规避?",
"如果把这个工具的数据规模放大 100 倍,瓶颈会先出现在哪里?"],
},
"后端框架": {
"deliverable": "基于 {skills} 实现一个不少于 3 个接口的 REST 服务,含鉴权与参数校验",
"criteria": ["分层清晰(路由/服务/数据访问分离),无不合理耦合",
"关键接口有自动化测试,覆盖率不低于 60%",
"完成一次简单压测并记录 QPS 与耗时分布",
"能手绘并讲解一次请求的完整生命周期"],
"self_check": ["{s1} 的依赖注入/中间件机制是如何工作的?",
"生产环境下 {s1} 服务如何做优雅停机与连接管理?",
"哪些场景下你会放弃 {s1} 而选择更轻的框架?"],
},
"前端": {
"deliverable": "用 {skills} 完成一个可运行的单页应用(不少于 2 个页面),公开访问或录屏演示",
"criteria": ["组件拆分合理、状态管理方案有明确选型理由",
"主流浏览器无 console 报错,页面响应式适配两种宽度",
"完成一次构建优化(分包/懒加载),记录优化前后体积",
"核心交互有至少 3 个组件级测试"],
"self_check": ["{s1} 的渲染/更新机制是怎样的,什么操作会触发不必要的重渲染?",
"状态提升与全局状态各自适合什么场景?",
"首屏白屏时间可以从哪几个环节优化?"],
},
"数据库": {
"deliverable": "围绕 {skills} 设计并实现一个业务库表方案与查询集,附索引设计说明",
"criteria": ["给出 ER 图与范式/反范式取舍的书面说明",
"用 EXPLAIN 分析并优化至少 2 条慢查询,记录前后对比",
"通过实验验证事务隔离级别的差异(附复现 SQL)",
"完成一次备份与恢复演练"],
"self_check": ["什么情况下索引会失效?各举一个例子",
"{s1} 的 redo/undo 或持久化机制保证了什么?",
"千万级数据量下,分页查询如何优化?"],
},
"中间件": {
"deliverable": "本地搭建 {skills} 环境并跑通生产/消费(或读写)完整 demo",
"criteria": ["给出关键配置项(持久化/高可用/过期策略)的逐项说明",
"演练消息丢失、重复消费或缓存穿透场景并给出防护方案",
"接入监控并解读 3 个核心指标",
"整理一份与同类中间件的选型对比表"],
"self_check": ["{s1} 在什么条件下会丢数据?如何避免?",
"集群模式下数据是如何分布与迁移的?",
"什么业务场景下不该使用 {s1}?"],
},
"大数据": {
"deliverable": "用 {skills} 跑通一个批处理或流处理作业,输入输出可复现",
"criteria": ["说明分区与并行度策略及其依据",
"制造并解决一次数据倾斜,记录处理前后耗时",
"给出作业的容错与重跑方案",
"输出结果有数据质量校验(行数/空值/分布)"],
"self_check": ["{s1} 的 shuffle 过程发生了什么,为什么昂贵?",
"批处理与流处理的语义差异体现在哪里?",
"如何判断该作业该垂直扩容还是水平扩容?"],
},
"AI算法": {
"deliverable": "基于 {skills} 复现一个 baseline 模型或算法 demo,附实验记录",
"criteria": ["数据集划分规范(训练/验证/测试),杜绝数据泄漏",
"输出完整指标报告(P/R/F1 或 Loss/Acc 曲线)",
"完成至少 1 组消融或调参对比并记录结论",
"能讲清模型的适用边界与失败案例"],
"self_check": ["如何判断模型过拟合?你用了哪些手段缓解?",
"指标不变差的前提下,推理成本可以怎么降?",
"{s1} 与传统方法相比,优势和代价各是什么?"],
},
"云原生": {
"deliverable": "将一个应用容器化并部署到 {skills} 环境,完成一次滚动更新与回滚",
"criteria": ["Dockerfile 使用多阶段构建,镜像体积有优化记录",
"编排配置包含资源限额、健康探针与副本策略",
"演练滚动更新与回滚全过程并截图/录屏",
"日志与监控接入,能定位一次人为注入的故障"],
"self_check": ["三种探针的区别是什么,配错会导致什么后果?",
"有状态服务上 {s1} 要注意什么?",
"镜像分层缓存如何影响 CI 构建速度?"],
},
"运维": {
"deliverable": "用 {skills} 实现部署/巡检/告警中的至少一个自动化工具并投入使用演示",
"criteria": ["工具覆盖部署、监控、告警三个环节中的至少两个",
"完成一次故障注入演练(如杀进程/打满磁盘)并记录处置时长",
"编写运行手册(部署步骤、回滚步骤、常见告警处置)",
"脚本具备幂等性,重复执行不产生副作用"],
"self_check": ["负载飙升时你的排查顺序是什么?",
"监控覆盖率和告警疲劳之间怎么平衡?",
"{s1} 自动化过程中最需要防的人为风险是什么?"],
},
"测试": {
"deliverable": "为一个真实模块编写测试计划与自动化用例集,并在 CI 中跑通",
"criteria": ["用例设计覆盖等价类、边界值与异常路径,附设计说明",
"自动化用例不少于 10 条,CI 触发全绿",
"提交至少 2 份规范缺陷报告(复现步骤/期望/实际/环境)",
"输出覆盖率报告并说明未覆盖部分的风险"],
"self_check": ["如何为一个你从未见过的系统设计测试策略?",
"自动化测试的投入产出比怎么评估?",
"线上问题复盘时,测试环节应该如何改进?"],
},
"数据分析": {
"deliverable": "围绕 {skills} 完成一份端到端数据分析报告(数据获取→清洗→分析→结论)",
"criteria": ["清洗与处理过程代码化、可一键复现",
"给出不少于 3 个有业务含义的洞察,每个都有数据支撑",
"图表选型恰当,坐标轴与口径标注完整",
"明确写出结论的适用范围与可能的偏差来源"],
"self_check": ["什么是辛普森悖论?如何在分析中规避?",
"相关关系和因果关系的差异对结论有什么影响?",
"如果数据缺失 30%,你的处理策略是什么?"],
},
"产品": {
"deliverable": "围绕 {skills} 输出一份完整 PRD 或实验方案(含原型或流程图)",
"criteria": ["明确定义北极星指标与至少 2 个护栏指标",
"实验类方案需给出样本量估算、周期与显著性判定规则",
"包含灰度发布与回滚预案",
"预设结果判定规则:什么数据下继续/迭代/放弃"],
"self_check": ["A/A 测试的目的是什么?不做会有什么风险?",
"p 值和置信区间分别说明什么,常见误用有哪些?",
"指标提升但用户体验下降时,决策依据是什么?"],
},
"嵌入式": {
"deliverable": "基于 {skills} 完成一个可烧录运行的固件 demo",
"criteria": ["完成交叉编译与烧录,功能演示正常",
"给出关键中断/时序的分析记录",
"测量并记录功耗或资源占用数据",
"代码有看门狗与异常恢复处理"],
"self_check": ["裸机与 RTOS 的核心差异是什么,何时必须上 RTOS?",
"如何定位一次偶发的硬件通信失败?",
"Flash/RAM 受限时的优化手段有哪些?"],
},
"物联网": {
"deliverable": "用 {skills} 搭建一条设备数据采集→上报→可视化的端到端链路",
"criteria": ["说明通信协议选型理由(如 MQTT/CoAP/HTTP)",
"实现断线重连与本地缓存补传",
"完成弱网/断电场景下的鲁棒性演练",
"数据可视化看板可实时刷新"],
"self_check": ["弱网环境下消息的可靠性与时延如何权衡?",
"设备身份认证与防伪造怎么做?",
"海量设备接入时,架构的瓶颈会先出现在哪?"],
},
}
_DEFAULT_ACCEPTANCE = {
"deliverable": "独立完成一个综合运用 {skills} 的迷你项目,源码与说明文档齐全",
"criteria": ["项目可运行、核心流程演示无阻塞",
"能完整讲解 {s1} 的核心原理与常见坑",
"整理不少于 5 条学习笔记或踩坑记录",
"向他人做一次 10 分钟分享并通过提问环节"],
"self_check": ["{s1} 解决了什么问题?没有它时人们怎么做?",
"学习 {s1} 过程中最反直觉的一点是什么?",
"如何把本阶段技能组合应用到目标岗位的真实场景中?"],
}
def build_acceptance(skills: list[str], cat_of: dict[str, str]) -> dict:
"""按阶段内技能的主导类别选择模板,生成结构化验收标准。"""
cats = [cat_of.get(s, "") for s in skills if cat_of.get(s)]
if cats:
# 主导类别(并列时取阶段内先出现的技能类别)
cat = max(cats, key=lambda c: (cats.count(c), -cats.index(c)))
else:
cat = ""
tpl = _ACCEPTANCE.get(cat, _DEFAULT_ACCEPTANCE)
s1 = skills[0]
s2 = skills[1] if len(skills) > 1 else skills[0]
joined = "、".join(skills)
def fmt(text: str) -> str:
return text.format(skills=joined, s1=s1, s2=s2)
return {
"category": cat or "通用",
"deliverable": fmt(tpl["deliverable"]),
"criteria": [fmt(c) for c in tpl["criteria"]],
"self_check": [fmt(q) for q in tpl["self_check"]],
}
class MatchEngine:
"""人岗匹配引擎。"""
def __init__(self, lexicon: dict | None = None, weights: dict | None = None,
threshold: float = 60.0):
self.lexicon = lexicon or load_lexicon()
self.weights = {**DEFAULT_WEIGHTS, **(weights or {})}
self.threshold = threshold
self.category_of = {s["name"]: s.get("category", "") for s in self.lexicon["skills"]}
def match(self, profile: ResumeProfile, job: JobDefinition) -> MatchResult:
have = set(profile.skills)
m_req, miss_req, cov_req = _coverage(have, job.required_skills)
m_pref, miss_pref, cov_pref = _coverage(have, job.preferred_skills)
exp_score = min(1.0, profile.years / max(1, job.expected_years))
scen_matched = [s for s in job.scenarios if s in set(profile.scenarios)]
cov_scen = len(scen_matched) / len(job.scenarios) if job.scenarios else 1.0
dims = {
"required": round(100 * cov_req, 2),
"preferred": round(100 * cov_pref, 2),
"experience": round(100 * exp_score, 2),
"scenario": round(100 * cov_scen, 2),
}
total = round(100 * (
self.weights["required"] * cov_req
+ self.weights["preferred"] * cov_pref
+ self.weights["experience"] * exp_score
+ self.weights["scenario"] * cov_scen
), 2)
# 可迁移技能:与缺失必备技能同类别、且候选人已掌握的技能
miss_cats = {self.category_of.get(s, "") for s in miss_req} - {""}
transferable = sorted(s for s in have
if self.category_of.get(s, "") in miss_cats
and s not in job.required_skills)
path = topological_stages(miss_req + miss_pref, self.lexicon)
return MatchResult(
job_id=job.job_id,
job_title=job.title,
total_score=total,
dimension_scores=dims,
matched_required=m_req,
missing_required=miss_req,
matched_preferred=m_pref,
missing_preferred=miss_pref,
transferable_skills=transferable,
learning_path=path,
is_match=total >= self.threshold,
)
+151
View File
@@ -0,0 +1,151 @@
"""简历解析:.txt 直接读,.docx 用 python-docx,.pdf 用 pypdf。
解析后做四件事:脱敏(姓名/手机/邮箱/住址打码)、技能抽取并归一化到词表、
工作年限与场景识别、结构化信息抽取(性别/学历/住址/分节/证件照),
供规范简历模板展示与导出。
"""
from __future__ import annotations
import base64
import logging
import re
from pathlib import Path
from app.extract.base import SkillMatcher, load_lexicon
from app.models import ResumeProfile
logger = logging.getLogger(__name__)
_PHONE_RE = re.compile(r"1[3-9]\d{9}")
_EMAIL_RE = re.compile(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}")
_NAME_RE = re.compile(r"姓名[::]\s*([一-龥]{2,4})")
_YEARS_RE = re.compile(r"(\d+(?:\.\d+)?)\s*年(?:以上)?[^\n,。;]{0,6}?经验")
_GENDER_RE = re.compile(r"性别[::]?\s*(男|女)")
_EDU_RE = re.compile(r"(博士后|博士|硕士|研究生|MBA|本科|大专|专科)")
_ADDR_RE = re.compile(r"(?:家庭住址|现居(?:住)?(?:地|址)?|联系地址|通讯地址|籍贯)[::]\s*([^\n,。;]{2,30})")
# 常见简历分节标题(整行匹配,可带冒号)
_SECTION_TITLES = {
"个人信息", "基本信息", "求职意向", "教育经历", "教育背景",
"工作经历", "工作经验", "实习经历", "项目经历", "项目经验",
"技能清单", "专业技能", "核心技能", "技能特长", "技能",
"自我评价", "个人评价", "荣誉证书", "荣誉奖项", "证书",
}
def mask_text(text: str) -> str:
"""脱敏:手机、邮箱、姓名、住址打码。"""
text = _PHONE_RE.sub(lambda m: m.group(0)[:3] + "****" + m.group(0)[-2:], text)
text = _EMAIL_RE.sub(lambda m: m.group(0)[0] + "***@" + m.group(0).split("@")[1], text)
text = _NAME_RE.sub(lambda m: "姓名:" + m.group(1)[0] + "*" * (len(m.group(1)) - 1), text)
text = _ADDR_RE.sub(lambda m: m.group(0)[:2] + m.group(1)[:6] + "***"
if len(m.group(1)) > 6 else m.group(0), text)
return text
def split_sections(text: str) -> list[dict]:
"""按常见标题把简历正文分节,返回 [{title, text}];无标题时整体归入"简历全文"。"""
sections: list[dict] = []
cur_title, cur_lines = "基本信息", []
for ln in text.splitlines():
key = ln.strip().rstrip("::").strip()
if key in _SECTION_TITLES:
if "\n".join(cur_lines).strip():
sections.append({"title": cur_title, "text": "\n".join(cur_lines).strip()})
cur_title, cur_lines = key, []
else:
cur_lines.append(ln)
if "\n".join(cur_lines).strip():
sections.append({"title": cur_title, "text": "\n".join(cur_lines).strip()})
if not sections and text.strip():
sections = [{"title": "简历全文", "text": text.strip()}]
return sections
def extract_photo_b64(path: str | Path) -> str:
"""从 docx/pdf 中提取第一张图片作为证件照,返回 data URI;无图片返回空串。"""
path = Path(path)
suffix = path.suffix.lower()
try:
if suffix == ".docx":
import zipfile
with zipfile.ZipFile(str(path)) as z:
names = sorted(n for n in z.namelist()
if n.startswith("word/media/")
and n.lower().endswith((".png", ".jpg", ".jpeg")))
if not names:
return ""
data = z.read(names[0])
mime = "image/png" if names[0].lower().endswith(".png") else "image/jpeg"
return f"data:{mime};base64,{base64.b64encode(data).decode()}"
if suffix == ".pdf":
from pypdf import PdfReader
reader = PdfReader(str(path))
for page in reader.pages[:2]:
for img in getattr(page, "images", []):
mime = "image/png" if img.name.lower().endswith(".png") else "image/jpeg"
return f"data:{mime};base64,{base64.b64encode(img.data).decode()}"
except Exception as exc: # 照片是增强信息,提取失败不阻断解析
logger.warning("简历照片提取失败(已跳过): %s", exc)
return ""
def read_resume_file(path: str | Path) -> str:
"""按扩展名读取简历文件文本。"""
path = Path(path)
suffix = path.suffix.lower()
if suffix == ".txt":
return path.read_text(encoding="utf-8")
if suffix == ".docx":
from docx import Document # python-docx,requirements 已声明
doc = Document(str(path))
return "\n".join(p.text for p in doc.paragraphs)
if suffix == ".pdf":
try:
from pypdf import PdfReader
except ImportError as exc:
raise RuntimeError(
"解析 .pdf 需要可选依赖 pypdf,请先执行:pip install pypdf"
) from exc
reader = PdfReader(str(path))
return "\n".join(page.extract_text() or "" for page in reader.pages)
raise ValueError(f"不支持的简历格式:{suffix}(支持 .txt/.docx/.pdf)")
def _mask_addr(addr: str) -> str:
"""住址脱敏:保留前 6 字(大致到区/县),其余打码。"""
return addr[:6] + "***" if len(addr) > 6 else addr
def parse_resume(text: str, matcher: SkillMatcher | None = None,
photo_b64: str = "") -> ResumeProfile:
"""从简历文本解析脱敏画像(含结构化基本信息与分节)。"""
matcher = matcher or SkillMatcher(load_lexicon())
name_m = _NAME_RE.search(text)
phone_m = _PHONE_RE.search(text)
email_m = _EMAIL_RE.search(text)
years_m = _YEARS_RE.search(text)
gender_m = _GENDER_RE.search(text)
edu_m = _EDU_RE.search(text)
addr_m = _ADDR_RE.search(text)
masked = mask_text(text)
return ResumeProfile(
name=(name_m.group(1)[0] + "*" * (len(name_m.group(1)) - 1)) if name_m else "",
phone=(phone_m.group(0)[:3] + "****" + phone_m.group(0)[-2:]) if phone_m else "",
email=(email_m.group(0)[0] + "***@" + email_m.group(0).split("@")[1]) if email_m else "",
years=float(years_m.group(1)) if years_m else 0.0,
skills=matcher.find_names(text),
scenarios=matcher.find_scenarios(text),
raw_masked_text=masked,
gender=gender_m.group(1) if gender_m else "",
education=edu_m.group(1) if edu_m else "",
address=_mask_addr(addr_m.group(1).strip()) if addr_m else "",
photo_b64=photo_b64,
sections=split_sections(masked),
)
def parse_resume_file(path: str | Path, matcher: SkillMatcher | None = None) -> ResumeProfile:
"""读取文件并解析(含证件照提取)。"""
return parse_resume(read_resume_file(path), matcher,
photo_b64=extract_photo_b64(path))
+133
View File
@@ -0,0 +1,133 @@
"""规范简历 DOCX 导出:照片 + 基本信息表 + 分节正文(关键词高亮)+ 可选匹配诊断。
完全离线,仅依赖 python-docx。返回 bytes,由 API 层以文件流下发。
"""
from __future__ import annotations
import base64
import io
import re
from docx import Document
from docx.enum.text import WD_ALIGN_PARAGRAPH
from docx.oxml.ns import qn
from docx.shared import Cm, Pt, RGBColor
from app.models import ResumeProfile
_GREEN = RGBColor(0x00, 0xB4, 0x2A) # 命中必备
_BLUE = RGBColor(0x2F, 0x54, 0xEB) # 命中加分
_GRAY = RGBColor(0x4E, 0x59, 0x69) # 其他技能
_DARK = RGBColor(0x1D, 0x21, 0x29)
def _set_cn_font(doc: Document) -> None:
st = doc.styles["Normal"]
st.font.name = "Times New Roman"
st.font.size = Pt(10.5)
st.element.rPr.rFonts.set(qn("w:eastAsia"), "宋体")
def _highlight_runs(paragraph, text: str, cls_of: dict[str, RGBColor]) -> None:
"""按技能清单把 text 切段写入段落,命中技能加粗上色。"""
if not cls_of:
paragraph.add_run(text)
return
names = sorted(cls_of, key=len, reverse=True)
pat = re.compile("|".join(re.escape(n) for n in names), re.IGNORECASE)
pos = 0
for m in pat.finditer(text):
if m.start() > pos:
paragraph.add_run(text[pos:m.start()])
hit = m.group(0)
run = paragraph.add_run(hit)
run.bold = True
run.font.color.rgb = cls_of.get(hit.lower(), _DARK)
pos = m.end()
if pos < len(text):
paragraph.add_run(text[pos:])
def build_resume_docx(profile: ResumeProfile, match: dict | None = None) -> bytes:
"""把脱敏简历画像渲染为规范 DOCX,返回文件 bytes。"""
doc = Document()
_set_cn_font(doc)
title = doc.add_paragraph()
title.alignment = WD_ALIGN_PARAGRAPH.CENTER
trun = title.add_run("个 人 简 历")
trun.bold = True
trun.font.size = Pt(20)
sub = doc.add_paragraph()
sub.alignment = WD_ALIGN_PARAGRAPH.CENTER
srun = sub.add_run("(CareerNexus 职联 · 结构化解析导出,已自动脱敏)")
srun.font.size = Pt(9)
srun.font.color.rgb = _GRAY
# ---- 头部:左照片 + 右基本信息表 ----
head = doc.add_table(rows=1, cols=2)
head.style = "Table Grid"
head.columns[0].width = Cm(3.4)
head.columns[1].width = Cm(13.4)
photo_cell, info_cell = head.cell(0, 0), head.cell(0, 1)
if profile.photo_b64:
try:
raw = base64.b64decode(profile.photo_b64.split(",", 1)[1])
photo_cell.paragraphs[0].add_run().add_picture(io.BytesIO(raw), width=Cm(3.0))
except Exception:
photo_cell.paragraphs[0].add_run("(照片解析失败)")
else:
photo_cell.paragraphs[0].add_run("(未提供照片)")
info_items = [
("姓名", profile.name or "—"), ("性别", profile.gender or "—"),
("学历", profile.education or "—"), ("工作年限", f"{profile.years:g} 年" if profile.years else "—"),
("手机", profile.phone or "—"), ("邮箱", profile.email or "—"),
("家庭住址", profile.address or "—"),
]
info_table = info_cell.add_table(rows=(len(info_items) + 1) // 2, cols=2)
for i, (k, v) in enumerate(info_items):
cell = info_table.cell(i // 2, i % 2)
p = cell.paragraphs[0]
kr = p.add_run(f"{k}:")
kr.bold = True
p.add_run(v)
# 技能高亮类别(小写键)
cls_of: dict[str, RGBColor] = {}
if match:
for s in match.get("matched_required", []):
cls_of[s.lower()] = _GREEN
for s in match.get("matched_preferred", []):
cls_of[s.lower()] = _BLUE
for s in profile.skills:
cls_of.setdefault(s.lower(), _GRAY)
# ---- 分节正文(关键词标注) ----
for sec in profile.sections or [{"title": "简历全文", "text": profile.raw_masked_text}]:
h = doc.add_paragraph()
hrun = h.add_run(f"▍{sec['title']}")
hrun.bold = True
hrun.font.size = Pt(13)
hrun.font.color.rgb = _BLUE
_highlight_runs(doc.add_paragraph(), sec["text"], cls_of)
# ---- 匹配诊断摘要(可选) ----
if match and match.get("job_title"):
h = doc.add_paragraph()
hrun = h.add_run("▍人岗匹配诊断(CareerNexus)")
hrun.bold = True
hrun.font.size = Pt(13)
hrun.font.color.rgb = _BLUE
for line in [
f"目标岗位:{match['job_title']} 综合匹配分:{match.get('total_score', 0)}",
f"命中必备技能:{'、'.join(match.get('matched_required', [])) or '无'}",
f"命中加分技能:{'、'.join(match.get('matched_preferred', [])) or '无'}",
f"缺失技能:{'、'.join((match.get('missing_required', [])
+ match.get('missing_preferred', []))) or '无'}",
]:
doc.add_paragraph(line)
buf = io.BytesIO()
doc.save(buf)
return buf.getvalue()
+163
View File
@@ -0,0 +1,163 @@
"""语义向量通道(可选增强):本地 BGE-M3 权重 或 联网 Embedding API 双模式。
- provider=local:sentence-transformers 加载本地权重(如 models/bge-m3);
- provider=api:OpenAI 兼容 /embeddings 接口(默认阿里 DashScope 兼容模式,
模型 text-embedding-v4,填 API Key 即可,无需下载权重);
- 任一模式就绪即启用语义召回;均不可用时回退词表归一化,行为与无此模块一致;
- 依赖与权重均为可选安装,不进入 requirements("零重依赖、离线优先"约束)。
"""
from __future__ import annotations
import importlib.util
import logging
from pathlib import Path
import httpx
logger = logging.getLogger(__name__)
# BGE-M3 目录的就绪判定:配置文件 + 任一权重文件 + 分词器
_REQUIRED_CONFIG = "config.json"
_WEIGHT_FILES = ("model.safetensors", "pytorch_model.bin")
_TOKENIZER_FILES = ("tokenizer.json", "vocab.txt")
DEFAULT_API_BASE = "https://dashscope.aliyuncs.com/compatible-mode/v1"
DEFAULT_API_MODEL = "text-embedding-v4"
_model = None # 懒加载的本地 SentenceTransformer 实例
_model_path = ""
_skill_emb = None # 词表技能向量缓存
_skill_names: list[str] = []
_emb_cache_key = "" # 缓存对应的通道标识(provider+路径/模型)
def deps_installed() -> bool:
"""sentence-transformers 是否已安装。"""
return importlib.util.find_spec("sentence_transformers") is not None
def resolve_weights_path(path: str | Path) -> Path:
"""权重路径解析:相对路径相对工程根目录解析,绝对路径原样返回(兼容既有配置)。"""
from app.config import ROOT
p = Path(str(path or ""))
return p if p.is_absolute() else ROOT / p
def weights_present(path: str | Path) -> bool:
"""权重目录是否包含 BGE-M3 所需文件。"""
p = Path(str(path or ""))
if not p.is_dir() or not (p / _REQUIRED_CONFIG).exists():
return False
if not any((p / f).exists() for f in _WEIGHT_FILES):
return False
return any((p / f).exists() for f in _TOKENIZER_FILES)
def status(cfg: dict) -> dict:
"""语义通道状态:供 /api/health 与 /api/config 展示。"""
provider = str(cfg.get("embed_provider") or "local").strip() or "local"
path = str(cfg.get("embed_model_path") or "").strip()
api_key = str(cfg.get("embed_api_key") or "").strip()
present = weights_present(resolve_weights_path(path)) if path else False
deps = deps_installed()
local_ready = bool(path and present and deps)
api_ready = bool(api_key)
ready = api_ready if provider == "api" else local_ready
return {
"provider": provider,
"configured": bool(path) if provider == "local" else api_ready,
"path": path,
"weights_present": present,
"deps_installed": deps,
"local_ready": local_ready,
"api_key_masked": ("••••" + api_key[-4:]) if api_key else "",
"api_base_url": str(cfg.get("embed_api_base_url") or DEFAULT_API_BASE),
"api_model": str(cfg.get("embed_api_model") or DEFAULT_API_MODEL),
"ready": bool(ready),
}
# ---------------------------------------------------------------- 本地模式
def _load_local(path: str):
"""懒加载本地模型;不可用时返回 None。"""
global _model, _model_path
if _model is not None and _model_path == path:
return _model
try:
from sentence_transformers import SentenceTransformer # 可选依赖
_model = SentenceTransformer(path)
_model_path = path
logger.info("语义向量模型已加载(本地): %s", path)
except Exception as exc: # noqa: BLE001 - 模型加载失败必须可回退
logger.warning("本地语义模型加载失败,回退词表归一化: %s", exc)
_model = None
return None
return _model
def _embed_local(texts: list[str], path: str):
model = _load_local(path)
if model is None:
return None
return model.encode(texts, normalize_embeddings=True)
# ---------------------------------------------------------------- 联网模式
def _embed_api(texts: list[str], cfg: dict):
"""调用 OpenAI 兼容 embeddings 接口(DashScope 等),返回向量列表。"""
st = status(cfg)
try:
resp = httpx.post(
f"{st['api_base_url'].rstrip('/')}/embeddings",
headers={"Authorization": f"Bearer {cfg.get('embed_api_key')}"},
json={"model": st["api_model"], "input": texts},
timeout=30,
)
resp.raise_for_status()
data = resp.json()["data"]
data.sort(key=lambda d: d["index"])
import math
vecs = []
for d in data:
v = d["embedding"]
norm = math.sqrt(sum(x * x for x in v)) or 1.0
vecs.append([x / norm for x in v])
return vecs
except Exception as exc: # noqa: BLE001 - 网络/鉴权失败必须可回退
logger.warning("Embedding API 调用失败,回退词表归一化: %s", exc)
return None
# ---------------------------------------------------------------- 统一入口
def _embed(texts: list[str], cfg: dict):
"""按 provider 取向量(已归一化);失败返回 None。"""
provider = str(cfg.get("embed_provider") or "local").strip() or "local"
if provider == "api":
return _embed_api(texts, cfg)
return _embed_local(texts, str(resolve_weights_path(
str(cfg.get("embed_model_path") or "").strip())))
def most_similar(name: str, cfg: dict, lexicon: dict,
threshold: float = 0.62) -> tuple[str, float] | None:
"""返回词表中与 name 语义最近的技能 (标准名, 相似度);未过阈值或不可用返回 None。"""
global _skill_emb, _skill_names, _emb_cache_key
st = status(cfg)
if not st["ready"]:
return None
cache_key = f"{st['provider']}|{st['path'] or st['api_model']}"
if _skill_emb is None or cache_key != _emb_cache_key:
_skill_names = [s["name"] for s in lexicon["skills"]]
_skill_emb = _embed(_skill_names, cfg)
if _skill_emb is None:
return None
_emb_cache_key = cache_key
vecs = _embed([name], cfg)
if vecs is None:
return None
vec = vecs[0]
scores = [sum(a * b for a, b in zip(row, vec)) for row in _skill_emb]
best_i = max(range(len(scores)), key=lambda i: scores[i])
if scores[best_i] >= threshold:
return _skill_names[best_i], round(scores[best_i], 4)
return None
+139
View File
@@ -0,0 +1,139 @@
"""CareerNexus 全链路数据模型(pydantic v2)。
覆盖:JD 原始记录、结构化抽取结果、岗位定义、图谱观察、
变更事件、人岗匹配结果、简历画像与人机协同审核记录。
"""
from __future__ import annotations
from datetime import date
from typing import Literal, Optional
from pydantic import BaseModel, Field
class JDRecord(BaseModel):
"""一条招聘 JD 原始记录。source_id 由接入层分配(文件名#行号),不可变。"""
jid: str
job_title: str
company: str
city: str = ""
salary: str = ""
industry: str = "" # 招聘平台标注的所属行业
publish_date: date
source: str = ""
url: str = ""
text: str
source_id: str = "" # 形如 batch_2023.jsonl#17
weight: float = 1.0 # 清洗阶段赋予的时间衰减权重
is_duplicate: bool = False # 近重复(抄袭)降权标记
dup_of: Optional[str] = None # 指向被保留的最早来源 jid
is_noise: bool = False # 噪声(广告/无关条款)标记
class ExtractionResult(BaseModel):
"""从单条 JD 文本抽取出的结构化岗位定义。"""
jid: str = ""
job_title: str = ""
responsibilities: list[str] = Field(default_factory=list)
required_skills: list[str] = Field(default_factory=list)
preferred_skills: list[str] = Field(default_factory=list)
scenarios: list[str] = Field(default_factory=list)
evidence: dict[str, str] = Field(default_factory=dict) # 字段 -> 证据片段
confidence: float = 0.0
extractor: str = "offline"
prompt_version: str = "offline-v1"
class JobDefinition(BaseModel):
"""聚合后的岗位定义(图谱中的 Job 节点属性)。"""
job_id: str
title: str
category: str = ""
industries: list[str] = Field(default_factory=list) # 归属行业(1-2 个)
required_skills: list[str] = Field(default_factory=list)
preferred_skills: list[str] = Field(default_factory=list)
responsibilities: list[str] = Field(default_factory=list)
scenarios: list[str] = Field(default_factory=list)
expected_years: int = 3
status: Literal["active", "pending", "rejected"] = "active"
source_count: int = 0 # 支撑 JD 数
company_count: int = 0 # 独立企业数
# 候选新岗位的支撑证据(新岗位发现时填写):支撑 JD 的 source_id 与出现季度窗口,
# 审核入库时用于生成真实的 REQUIRES 区间、来源列表与演化事件。
support_source_ids: list[str] = Field(default_factory=list)
support_windows: list[str] = Field(default_factory=list)
class GraphObservation(BaseModel):
"""一次岗位-技能关系观察(带时间窗,追加而不覆盖)。"""
job_id: str
skill: str
importance: Literal["required", "preferred"]
weight: float = 1.0
window: str = "" # 季度窗口,如 2022Q1
source_ids: list[str] = Field(default_factory=list)
class ChangeEvent(BaseModel):
"""岗位能力演化事件:新增 / 增强 / 减弱 / 退出。"""
event_id: str
job_id: str
skill: str
change_type: Literal["新增", "增强", "减弱", "退出"]
window: str
prev_weight: float = 0.0
curr_weight: float = 0.0
source_ids: list[str] = Field(default_factory=list)
description: str = ""
created_at: str = ""
class ResumeProfile(BaseModel):
"""脱敏后的简历画像。"""
name: str = ""
phone: str = ""
email: str = ""
years: float = 0.0
skills: list[str] = Field(default_factory=list)
scenarios: list[str] = Field(default_factory=list)
raw_masked_text: str = ""
# 结构化信息(规范简历模板用)
gender: str = "" # 性别(不脱敏,人口统计学字段)
education: str = "" # 最高学历(本科/硕士/博士…)
address: str = "" # 家庭住址/现居地(脱敏到城市)
photo_b64: str = "" # 证件照 base64(data URI,无照片为空)
sections: list[dict] = Field(default_factory=list) # [{title, text}] 分节正文
class MatchResult(BaseModel):
"""人岗匹配结果:总分、四维分、命中/缺失/可迁移技能与学习路径。"""
job_id: str
job_title: str = ""
total_score: float = 0.0
dimension_scores: dict[str, float] = Field(default_factory=dict)
matched_required: list[str] = Field(default_factory=list)
missing_required: list[str] = Field(default_factory=list)
matched_preferred: list[str] = Field(default_factory=list)
missing_preferred: list[str] = Field(default_factory=list)
transferable_skills: list[str] = Field(default_factory=list)
learning_path: list[dict] = Field(default_factory=list)
is_match: bool = False
class ReviewRecord(BaseModel):
"""人机协同审核留痕。"""
candidate_id: str
action: Literal["accept", "modify", "reject"]
operator: str
reason: str = ""
before: dict = Field(default_factory=dict)
after: dict = Field(default_factory=dict)
time: str = ""
View File
+130
View File
@@ -0,0 +1,130 @@
"""人工状态覆写层:审核入库岗位与节点编辑跨图谱重建持久化。
- 人工审核通过的岗位、节点编辑先写入 SQLite 状态库 ``job_overrides`` 表
(见 app/storage/db.py),同时应用到当前图谱;
- pipeline 全量重建图谱后调用 :func:`replay_overrides` 按序重放,
人工入库岗位强制纳入正式图谱(不受"2023 年前出现"规则限制),
节点编辑同样保留。
"""
from __future__ import annotations
import logging
from datetime import datetime
from pathlib import Path
from app.graph.tkg import LEVEL_TO_YEARS, TemporalKG, job_level, new_event_id, now_str, quarter_of
from app.models import ChangeEvent, GraphObservation, JobDefinition
from app.storage import db
logger = logging.getLogger(__name__)
FALLBACK_SOURCE = "人工审核入库" # 无支撑证据的旧候选入库时的来源标记
def normalize_job_id(job: JobDefinition) -> str:
"""候选转正:cand_X 统一归一为 job_<岗位名>,保证全图只有一个岗位节点。"""
if job.job_id.startswith("cand_"):
return f"job_{job.title}"
return job.job_id
def apply_accepted_job(kg: TemporalKG, job: JobDefinition) -> None:
"""把审核通过的岗位写入图谱:真实区间/来源的 REQUIRES 边、场景/行业边与"新增"演化事件。
区间自首个支持窗口起;来源取候选簇真实支撑 JD 的 source_id 列表
(旧候选缺支撑证据时回退到"人工审核入库"标记,窗口取当前季度)。
调用前须先把 job_id 归一化(见 normalize_job_id);若图中残留旧候选节点则清理。
"""
job.status = "active"
first_window = min(job.support_windows) if job.support_windows else quarter_of(datetime.now())
sources = list(job.support_source_ids) or [FALLBACK_SOURCE]
# 清理可能残留的旧候选节点(如 cand_X),避免一个岗位两个节点
cand_id = f"cand_{job.title}"
if cand_id != job.job_id and kg.g.has_node(cand_id):
kg.g.remove_node(cand_id)
kg.jobs.pop(cand_id, None)
kg.add_job(job)
required = set(job.required_skills)
for skill in job.required_skills + job.preferred_skills:
importance = "required" if skill in required else "preferred"
kg.observe(GraphObservation(
job_id=job.job_id, skill=skill, importance=importance,
weight=1.0, window=first_window, source_ids=sources))
kg.add_event(ChangeEvent(
event_id=new_event_id(), job_id=job.job_id, skill=skill,
change_type="新增", window=first_window,
prev_weight=0.0, curr_weight=1.0, source_ids=sources[:10],
description=(f"{first_window},岗位【{job.title}】技能【{skill}】出现新增能力要求"
f"(人工审核入库;数据源:招聘 JD,支撑样本 {len(sources)} 条)"),
created_at=now_str()))
for sc in job.scenarios:
kg.add_scenario(sc)
kg.g.add_edge(job.job_id, f"场景::{sc}", rel="APPLIES_TO")
for ind in job.industries:
kg.add_industry(ind)
kg.g.add_edge(job.job_id, f"行业::{ind}", rel="BELONGS_TO")
logger.info("岗位入库:%s(窗口 %s 起,%d 个来源)", job.title, first_window, len(sources))
def apply_node_updates(kg: TemporalKG, node_id: str, updates: dict) -> None:
"""把节点编辑应用到图谱;Job 节点同步 kg.jobs 的 JobDefinition(title/category/级别)。"""
if node_id not in kg.g:
return
kg.g.nodes[node_id].update(updates)
job = kg.jobs.get(node_id)
if job is None:
return
if "title" in updates:
job.title = str(updates["title"])
if "category" in updates:
job.category = str(updates["category"])
if "level" in updates and updates["level"] in LEVEL_TO_YEARS:
# 级别由期望年限推出:人工改级别时同步年限,保证级别筛选与详情面板一致
job.expected_years = LEVEL_TO_YEARS[updates["level"]]
kg.g.nodes[node_id]["expected_years"] = job.expected_years
# ---------- 覆写记录(job_overrides 表) ----------
def record_job_accept(db_path: str | Path, job: JobDefinition, candidate_id: str = "") -> None:
"""审核通过:把岗位定义(含支撑证据)写入覆写层,供重建后重放。
``candidate_id`` 为转正前的候选 id(cand_X),重放时据此把候选从待审核区清除。
"""
db.save_override(db_path, job.job_id, {
"kind": "job_accept", "candidate_id": candidate_id or job.job_id,
"job": job.model_dump()})
def record_node_update(db_path: str | Path, node_id: str, updates: dict) -> None:
"""节点编辑:与已有覆写合并后写入(同一节点多次编辑累积重放)。"""
existing = db.get_override(db_path, node_id)
merged = dict(existing.get("updates", {})) if existing and existing.get("kind") == "node_update" else {}
merged.update(updates)
db.save_override(db_path, node_id,
{"kind": "node_update", "node_id": node_id, "updates": merged})
def replay_overrides(kg: TemporalKG, db_path: str | Path) -> dict:
"""pipeline 重建后重放全部人工覆写:入库岗位强制纳入,节点编辑重新应用。"""
replayed = {"jobs": 0, "node_updates": 0, "job_ids": []}
try:
overrides = db.list_overrides(db_path)
except Exception as exc: # noqa: BLE001 - 状态库不可用不应拖垮重建
logger.warning("读取人工覆写失败,跳过重放: %s", exc)
return replayed
for ov in overrides:
kind = ov.get("kind")
if kind == "job_accept":
job = JobDefinition(**ov["job"])
job.job_id = normalize_job_id(job)
apply_accepted_job(kg, job)
replayed["jobs"] += 1
replayed["job_ids"].append(job.job_id)
db.delete_candidate(db_path, ov.get("candidate_id") or job.job_id)
elif kind == "node_update":
apply_node_updates(kg, ov.get("node_id", ""), ov.get("updates", {}))
replayed["node_updates"] += 1
if overrides:
logger.info("人工状态重放完成:%d 个人工岗位 / %d 条节点编辑",
replayed["jobs"], replayed["node_updates"])
return replayed
+179
View File
@@ -0,0 +1,179 @@
"""端到端编排:导入 → 清洗 → 抽取 → 图谱写入 → 演化分析 → 新岗位发现。
供 CLI(scripts/run_demo.py)与 API(/api/jd/import)复用,
返回各阶段统计。
"""
from __future__ import annotations
import logging
from collections import Counter
from pathlib import Path
from app.cleaning.decay import apply_decay
from app.cleaning.dedup import find_near_duplicates
from app.cleaning.noise import filter_noise
from app.config import ROOT, load_config, resolve
from app.discover.cluster import discover_new_jobs, save_pending
from app.evolution.analyzer import analyze, build_observations
from app.extract.base import load_lexicon
from app.extract.llm import get_extractor
from app.graph.tkg import TemporalKG
from app.ingest.loader import load_jd_dir
from app.models import ExtractionResult, JDRecord, JobDefinition
logger = logging.getLogger(__name__)
# 既有岗位注册表的数据截止年:早于该年出现的岗位视为“既有岗位”,
# 之后才出现的岗位交给“新岗位发现”链路(待审核后才入正式图谱)。
KNOWN_JOB_BEFORE_YEAR = 2023
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
)
def job_id_of(title: str) -> str:
return f"job_{title}"
def build_job_definitions(
records: list[JDRecord],
extractions: dict[str, ExtractionResult],
known_titles: set[str],
lexicon: dict,
) -> dict[str, JobDefinition]:
"""按标题聚合近三年抽取结果,生成既有岗位定义(频次阈值投票,多源一致才入选)。
岗位定义刻画“当前能力要求”,因此只用最近 3 个年份的 JD 聚合,
历史技能由时序图谱与演化事件表达,不进当前定义。
"""
jobs_meta = {j["title"]: j for j in lexicon.get("jobs", [])}
max_year = max((r.publish_date.year for r in records), default=2026)
recent_from = max_year - 2
defs: dict[str, JobDefinition] = {}
for title in sorted(known_titles):
group = [r for r in records
if r.jid in extractions and extractions[r.jid].job_title == title
and not r.is_noise and not r.is_duplicate
and r.publish_date.year >= recent_from]
if not group:
continue
n = len(group)
req = Counter(s for r in group for s in extractions[r.jid].required_skills)
pref = Counter(s for r in group for s in extractions[r.jid].preferred_skills)
duty = Counter(d for r in group for d in extractions[r.jid].responsibilities)
scen = Counter(s for r in group for s in extractions[r.jid].scenarios)
meta = jobs_meta.get(title, {})
required = [s for s, c in req.most_common() if c >= max(2, n * 0.25)]
defs[job_id_of(title)] = JobDefinition(
job_id=job_id_of(title),
title=title,
category=meta.get("category", ""),
industries=list(meta.get("industries", [])),
required_skills=required,
preferred_skills=[s for s, c in pref.most_common()
if c >= max(2, n * 0.15) and s not in required],
responsibilities=[d for d, _ in duty.most_common(5)],
scenarios=[s for s, _ in scen.most_common(5)],
expected_years=int(meta.get("expected_years", 3)),
status="active",
source_count=n,
company_count=len({r.company for r in group}),
)
return defs
def run_pipeline(config: dict | None = None) -> dict:
"""执行全链路,返回统计信息。"""
cfg = config or load_config()
stats: dict = {"stages": {}}
# 1. 导入
records, errors = load_jd_dir(resolve(cfg, "jd_raw_dir"))
source_stats = {"jd_raw": len(records)}
# 附加数据源(如 data/real/autoweaks_jd 真实数据集),可配置切换
for extra in str(cfg.get("jd_extra_dirs") or "").split(","):
extra = extra.strip()
if not extra:
continue
extra_path = Path(extra) if Path(extra).is_absolute() else ROOT / extra
if not extra_path.exists():
logger.warning("附加数据源不存在,已跳过: %s", extra_path)
continue
recs2, errs2 = load_jd_dir(extra_path)
records.extend(recs2)
errors.extend(errs2)
source_stats[extra_path.name] = len(recs2)
logger.info("附加数据源 %s: %d 条 JD", extra_path.name, len(recs2))
stats["stages"]["ingest"] = {"records": len(records), "bad_lines": len(errors),
"sources": source_stats}
# 2. 清洗:噪声 → 近重复 → 时间衰减
filter_noise(records)
dup_clusters = find_near_duplicates(records)
apply_decay(records, lam=float(cfg.get("decay_lambda", 0.35)))
stats["stages"]["cleaning"] = {
"dup_clusters": len(dup_clusters),
"duplicates": sum(1 for r in records if r.is_duplicate),
"noise": sum(1 for r in records if r.is_noise),
}
# 3. 抽取(LLM 或离线,自动降级)
lexicon = load_lexicon(resolve(cfg, "lexicon_path"))
extractor = get_extractor(cfg)
extractions: dict[str, ExtractionResult] = {}
for r in records:
if not r.is_noise:
extractions[r.jid] = extractor.extract(r)
stats["stages"]["extract"] = {"extracted": len(extractions),
"extractor": extractor.name}
# 4. 图谱写入:既有岗位定义 + 观察流
first_years: dict[str, int] = {}
for r in records:
if r.jid in extractions:
t = extractions[r.jid].job_title
first_years[t] = min(first_years.get(t, 9999), r.publish_date.year)
known_titles = {t for t, y in first_years.items() if y < KNOWN_JOB_BEFORE_YEAR}
kg = TemporalKG()
defs = build_job_definitions(records, extractions, known_titles, lexicon)
for d in defs.values():
kg.add_job(d)
for sc in d.scenarios:
kg.add_scenario(sc)
kg.g.add_edge(d.job_id, f"场景::{sc}", rel="APPLIES_TO")
for ind in d.industries:
kg.add_industry(ind)
kg.g.add_edge(d.job_id, f"行业::{ind}", rel="BELONGS_TO")
job_id_of_title = {d.title: d.job_id for d in defs.values()}
observations = build_observations(records, extractions, job_id_of_title)
for obs in observations:
kg.observe(obs)
stats["stages"]["graph"] = kg.stats()
# 5. 演化分析(事件追加进图谱)
events = analyze(kg, observations=observations)
stats["stages"]["evolution"] = {"events": len(events)}
# 6. 新岗位发现 → 待审核分区(SQLite 状态库)
candidates = discover_new_jobs(records, extractions, known_titles)
save_pending(candidates, resolve(cfg, "state_db_path"))
stats["stages"]["discover"] = {"candidates": [c.title for c in candidates]}
# 7. 人工状态重放:审核入库岗位强制纳入正式图谱(不受"2023 年前"规则限制),
# 节点编辑一并恢复(覆写层存于 SQLite job_overrides 表)
from app.services.overrides import replay_overrides
stats["stages"]["overrides"] = replay_overrides(kg, resolve(cfg, "state_db_path"))
kg.save(resolve(cfg, "graph_path"))
stats["graph_path"] = str(resolve(cfg, "graph_path"))
logger.info("pipeline 完成:%s", stats["stages"])
return stats
if __name__ == "__main__":
import json
print(json.dumps(run_pipeline(), ensure_ascii=False, indent=2))
View File
+360
View File
@@ -0,0 +1,360 @@
"""SQLite 应用状态库:审核留痕 / 待审核候选 / 异步任务(默认启用,零新依赖)。
- 数据库文件默认 ``data/careernexus.db``(配置键 ``state_db_path``),仅用标准库 sqlite3;
- WAL 模式、外键开;``init_schema()`` 幂等建表,重复调用无副作用;
- 一次性迁移:打开连接时若旧文件存在、对应表为空且尚未迁移过,把
``data/review/audit.jsonl`` 与 ``data/review/pending.json`` 导入
(原文件保留不删除,迁移标记写入 meta 表并记日志)。
留痕存取约定:审核记录(candidate_id/action)把 action 存进 kind 列、
candidate_id 存进 node_id 列;节点编辑记录(kind=node_update)原样存取。
``get_audit`` 按此约定还原出与旧 audit.jsonl 行完全一致的字典结构。
"""
from __future__ import annotations
import json
import logging
import sqlite3
from datetime import datetime
from pathlib import Path
logger = logging.getLogger(__name__)
SCHEMA = """\
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts TEXT,
node_id TEXT,
kind TEXT,
operator TEXT,
reason TEXT,
before_json TEXT,
after_json TEXT
);
CREATE TABLE IF NOT EXISTS review_candidates (
job_id TEXT PRIMARY KEY,
payload_json TEXT,
updated_ts TEXT
);
CREATE TABLE IF NOT EXISTS tasks (
task_id TEXT PRIMARY KEY,
kind TEXT,
status TEXT,
payload_json TEXT,
created_ts TEXT,
updated_ts TEXT
);
CREATE TABLE IF NOT EXISTS job_overrides (
name TEXT PRIMARY KEY,
payload_json TEXT,
updated_ts TEXT
);
CREATE TABLE IF NOT EXISTS meta (
key TEXT PRIMARY KEY,
value TEXT
);
"""
REVIEW_ACTIONS = {"accept", "modify", "reject"}
def _now() -> str:
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
# ---------- 连接与建表 ----------
def init_schema(conn: sqlite3.Connection) -> None:
"""幂等建表(CREATE TABLE IF NOT EXISTS),可反复调用。"""
conn.executescript(SCHEMA)
conn.commit()
def connect(
db_path: str | Path,
legacy_audit: str | Path | None = None,
legacy_pending: str | Path | None = None,
) -> sqlite3.Connection:
"""打开(必要时创建)状态库:WAL + 外键 + 幂等建表 + 一次性旧文件迁移。"""
path = Path(db_path)
if str(path.parent) not in ("", "."):
path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(str(path))
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA foreign_keys=ON")
init_schema(conn)
migrate_legacy(conn, legacy_audit=legacy_audit, legacy_pending=legacy_pending)
return conn
# ---------- 一次性迁移(旧 JSONL/JSON -> SQLite,原文件保留) ----------
def _migration_done(conn: sqlite3.Connection, key: str) -> bool:
row = conn.execute("SELECT value FROM meta WHERE key = ?", (key,)).fetchone()
return row is not None
def _mark_migrated(conn: sqlite3.Connection, key: str) -> None:
conn.execute("INSERT OR REPLACE INTO meta(key, value) VALUES (?, ?)", (key, _now()))
def _table_empty(conn: sqlite3.Connection, table: str) -> bool:
return conn.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0] == 0
def migrate_legacy(
conn: sqlite3.Connection,
legacy_audit: str | Path | None = None,
legacy_pending: str | Path | None = None,
) -> None:
"""把旧状态文件导入 SQLite:仅当对应表为空且未迁移过时执行,原文件保留。"""
if legacy_audit is not None:
_migrate_audit(conn, Path(legacy_audit))
if legacy_pending is not None:
_migrate_pending(conn, Path(legacy_pending))
conn.commit()
def _migrate_audit(conn: sqlite3.Connection, path: Path) -> None:
key = "migrated_audit"
if _migration_done(conn, key):
return
_mark_migrated(conn, key)
if not path.exists() or not _table_empty(conn, "audit_log"):
return
n = 0
for line in path.read_text(encoding="utf-8").splitlines():
if not line.strip():
continue
try:
record = json.loads(line)
except json.JSONDecodeError:
continue
_insert_audit(conn, record)
n += 1
logger.info("状态迁移:%s -> audit_log(%d 条,原文件保留)", path, n)
def _migrate_pending(conn: sqlite3.Connection, path: Path) -> None:
key = "migrated_pending"
if _migration_done(conn, key):
return
_mark_migrated(conn, key)
if not path.exists() or not _table_empty(conn, "review_candidates"):
return
try:
candidates = json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError:
return
for payload in candidates:
_upsert_candidate(conn, payload)
logger.info("状态迁移:%s -> review_candidates(%d 条,原文件保留)", path, len(candidates))
# ---------- 审核留痕 ----------
def _insert_audit(conn: sqlite3.Connection, record: dict) -> None:
kind = record.get("kind") or record.get("action") or ""
node_id = record.get("node_id") or record.get("candidate_id") or ""
conn.execute(
"INSERT INTO audit_log(ts, node_id, kind, operator, reason, before_json, after_json)"
" VALUES (?, ?, ?, ?, ?, ?, ?)",
(
record.get("time") or _now(),
node_id,
kind,
record.get("operator", ""),
record.get("reason", ""),
json.dumps(record.get("before") or {}, ensure_ascii=False),
json.dumps(record.get("after") or {}, ensure_ascii=False),
),
)
def append_audit(db_path: str | Path, record: dict) -> None:
"""追加一条留痕(审核记录或 kind=node_update 的节点编辑记录)。"""
conn = connect(db_path)
try:
_insert_audit(conn, record)
conn.commit()
finally:
conn.close()
def _audit_row_to_record(row: sqlite3.Row) -> dict:
"""把 audit_log 行还原为旧 audit.jsonl 行的字典结构(/api/audit 兼容)。"""
before = json.loads(row["before_json"] or "{}")
after = json.loads(row["after_json"] or "{}")
if row["kind"] in REVIEW_ACTIONS:
return {"candidate_id": row["node_id"], "action": row["kind"],
"operator": row["operator"], "reason": row["reason"],
"before": before, "after": after, "time": row["ts"]}
return {"kind": row["kind"], "node_id": row["node_id"],
"operator": row["operator"], "reason": row["reason"],
"before": before, "after": after, "time": row["ts"]}
def get_audit(db_path: str | Path, node_id: str | None = None, limit: int = 50) -> list[dict]:
"""读取留痕(新→旧排序),可按节点/候选 id 过滤。"""
conn = connect(db_path)
try:
if node_id:
rows = conn.execute(
"SELECT * FROM audit_log WHERE node_id = ? ORDER BY id DESC LIMIT ?",
(node_id, limit)).fetchall()
else:
rows = conn.execute(
"SELECT * FROM audit_log ORDER BY id DESC LIMIT ?", (limit,)).fetchall()
return [_audit_row_to_record(r) for r in rows]
finally:
conn.close()
# ---------- 待审核候选 ----------
def _upsert_candidate(conn: sqlite3.Connection, payload: dict) -> None:
conn.execute(
"INSERT INTO review_candidates(job_id, payload_json, updated_ts) VALUES (?, ?, ?)"
" ON CONFLICT(job_id) DO UPDATE SET payload_json = excluded.payload_json,"
" updated_ts = excluded.updated_ts",
(payload["job_id"], json.dumps(payload, ensure_ascii=False), _now()))
def save_candidate(db_path: str | Path, payload: dict) -> None:
"""新增或更新一个待审核候选(按 job_id upsert)。"""
conn = connect(db_path)
try:
_upsert_candidate(conn, payload)
conn.commit()
finally:
conn.close()
def get_candidate(db_path: str | Path, job_id: str) -> dict | None:
conn = connect(db_path)
try:
row = conn.execute(
"SELECT payload_json FROM review_candidates WHERE job_id = ?",
(job_id,)).fetchone()
return json.loads(row["payload_json"]) if row else None
finally:
conn.close()
def list_candidates(db_path: str | Path) -> list[dict]:
"""全部待审核候选(按写入顺序)。"""
conn = connect(db_path)
try:
rows = conn.execute(
"SELECT payload_json FROM review_candidates ORDER BY rowid").fetchall()
return [json.loads(r["payload_json"]) for r in rows]
finally:
conn.close()
def delete_candidate(db_path: str | Path, job_id: str) -> None:
conn = connect(db_path)
try:
conn.execute("DELETE FROM review_candidates WHERE job_id = ?", (job_id,))
conn.commit()
finally:
conn.close()
# ---------- 异步任务 ----------
def upsert_task(
db_path: str | Path,
task_id: str,
kind: str,
status: str,
payload: dict,
created_ts: str | None = None,
) -> None:
"""登记或更新任务状态(payload 为内存中任务 dict 的 JSON 快照)。"""
conn = connect(db_path)
try:
conn.execute(
"INSERT INTO tasks(task_id, kind, status, payload_json, created_ts, updated_ts)"
" VALUES (?, ?, ?, ?, ?, ?)"
" ON CONFLICT(task_id) DO UPDATE SET status = excluded.status,"
" payload_json = excluded.payload_json, updated_ts = excluded.updated_ts",
(task_id, kind, status, json.dumps(payload, ensure_ascii=False),
created_ts or _now(), _now()))
conn.commit()
finally:
conn.close()
def _task_row(row: sqlite3.Row) -> dict:
return {"task_id": row["task_id"], "kind": row["kind"], "status": row["status"],
"payload": json.loads(row["payload_json"] or "{}"),
"created_ts": row["created_ts"], "updated_ts": row["updated_ts"]}
def get_task(db_path: str | Path, task_id: str) -> dict | None:
conn = connect(db_path)
try:
row = conn.execute("SELECT * FROM tasks WHERE task_id = ?", (task_id,)).fetchone()
return _task_row(row) if row else None
finally:
conn.close()
def list_tasks(db_path: str | Path) -> list[dict]:
"""全部任务(按创建时间升序),供重启后恢复内存任务表。"""
conn = connect(db_path)
try:
rows = conn.execute("SELECT * FROM tasks ORDER BY created_ts, rowid").fetchall()
return [_task_row(r) for r in rows]
finally:
conn.close()
# ---------- 人工状态覆写层(跨图谱重建持久化) ----------
def _upsert_override(conn: sqlite3.Connection, name: str, payload: dict) -> None:
conn.execute(
"INSERT INTO job_overrides(name, payload_json, updated_ts) VALUES (?, ?, ?)"
" ON CONFLICT(name) DO UPDATE SET payload_json = excluded.payload_json,"
" updated_ts = excluded.updated_ts",
(name, json.dumps(payload, ensure_ascii=False), _now()))
def save_override(db_path: str | Path, name: str, payload: dict) -> None:
"""新增或更新一条人工覆写(按 name upsert)。
payload 约定:{"kind": "job_accept", "job": <JobDefinition dump>}
或 {"kind": "node_update", "node_id": ..., "updates": {...}}。
"""
conn = connect(db_path)
try:
_upsert_override(conn, name, payload)
conn.commit()
finally:
conn.close()
def get_override(db_path: str | Path, name: str) -> dict | None:
conn = connect(db_path)
try:
row = conn.execute(
"SELECT payload_json FROM job_overrides WHERE name = ?", (name,)).fetchone()
return json.loads(row["payload_json"]) if row else None
finally:
conn.close()
def list_overrides(db_path: str | Path) -> list[dict]:
"""全部人工覆写(按写入顺序),pipeline 重建后按序重放。"""
conn = connect(db_path)
try:
rows = conn.execute(
"SELECT payload_json FROM job_overrides ORDER BY rowid").fetchall()
return [json.loads(r["payload_json"]) for r in rows]
finally:
conn.close()
def delete_override(db_path: str | Path, name: str) -> None:
conn = connect(db_path)
try:
conn.execute("DELETE FROM job_overrides WHERE name = ?", (name,))
conn.commit()
finally:
conn.close()
+22
View File
@@ -0,0 +1,22 @@
# CareerNexus 职联 运行配置(平铺 key: value)
mode: demo
host: 127.0.0.1
port: 9008
data_dir: data
jd_raw_dir: data/jd_raw
graph_path: data/graph.json
lexicon_path: data/skills_lexicon.json
pending_path: data/review/pending.json
audit_path: data/review/audit.jsonl
state_db_path: data/careernexus.db
decay_lambda: 0.35
match_weight_required: 0.5
match_weight_preferred: 0.15
match_weight_experience: 0.2
match_weight_scenario: 0.15
match_threshold: 60
llm_api_key_env: SPARK_API_KEY
llm_base_url: https://spark-api-open.xf-yun.com/v1
# Spark Max 套餐已于 2026-03 下线并升级为 Ultra,默认用 4.0Ultra
llm_model: 4.0Ultra
llm_timeout: 20
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
+45
View File
@@ -0,0 +1,45 @@
services:
app:
build: .
ports:
- "9008:9008"
environment:
# 可选:配置密钥 + config.yaml 改 live 即启用大模型抽取通道
- SPARK_API_KEY=${SPARK_API_KEY:-}
# 可选高级组件:--profile advanced 启动 neo4j 后,经以下变量连接图库
# (仅供 scripts/sync_neo4j.py 等扩展使用,平台默认 TemporalKG 不依赖它)
- NEO4J_URI=${NEO4J_URI:-bolt://neo4j:7687}
- NEO4J_USER=${NEO4J_USER:-neo4j}
- NEO4J_PASSWORD=${NEO4J_PASSWORD:-}
# volumes:
# - ./data:/app/data # 数据落盘持久化
# - ./models:/app/models # 语义权重挂载(本地下载的 bge-m3,免进镜像)
# ────────────────────────────────────────────────────────────────
# 语义向量通道(BGE-M3)说明:
# 方式一(镜像内打包):docker build --build-arg INCLUDE_EMBED=1 -t careernexus:semantic .
# 方式二(权重挂载):把本地下载的 bge-m3 放到 ./models/bge-m3,
# 取消上面 models 卷注释,并在设置面板把权重目录填为 /app/models/bge-m3
# ────────────────────────────────────────────────────────────────
# ────────────────────────────────────────────────────────────────
# 可选高级组件:Neo4j 图数据库(默认不启动,不参与竞赛交付主链路)
#
# 启动: NEO4J_PASSWORD=你的密码 docker compose --profile advanced up -d neo4j
# 浏览器:http://localhost:7474 (用户名 neo4j,密码即上面注入的值)
# 同步: pip install neo4j && python scripts/sync_neo4j.py
# ────────────────────────────────────────────────────────────────
neo4j:
profiles: ["advanced"]
image: neo4j:5-community
ports:
- "7474:7474" # HTTP / Browser
- "7687:7687" # Bolt
environment:
# 初始密码经环境变量注入;缺省 careernexus 仅供本地演示
- NEO4J_AUTH=neo4j/${NEO4J_PASSWORD:-careernexus}
volumes:
- neo4j_data:/data
volumes:
neo4j_data:
+749
View File
@@ -0,0 +1,749 @@
# CareerNexus 职联
## 多源异构数据驱动的岗位能力图谱构建与动态演化分析平台 —— 项目方案
> 赛题编号:XH-202621(多源异构数据驱动岗位和能力图谱构建与动态演化分析研究)
> 发榜单位:科大讯飞股份有限公司
> 方案版本:v3.0(生产实现版)
> 配套交付:可运行生产代码工程 `CareerNexus/` + 自备数据集 `CareerNexus/data/` + 本方案文档
---
## 一、项目背景与赛题理解
### 1.1 宏观背景:从"人口红利"到"人才红利"的结构性矛盾
当前国家正处于从"人口红利"向"人才红利"转型的关键期。随着"新质生产力"概念的提出、"人工智能+"行动的深入实施以及数字经济的蓬勃发展,以人工智能、大数据、智能系统、物联网为代表的新一代信息技术领域,正成为数字经济与产业升级的核心引擎。然而,数字经济的发展使得技术迭代速度远超人才培养周期,劳动力市场出现了严重的**结构性矛盾**:
- **企业侧**:新兴岗位上"招不到合适的人",JD 写法五花八门、互相抄袭、要求通胀,人岗匹配度低,招聘与培养成本居高不下;
- **人才侧**:以新兴领域青年、新就业形态劳动者为代表的群体,职业发展路径模糊、技能需求动态性强,"不知道该学什么、按什么顺序学";
- **高校侧**:培养方案更新周期以年计,远慢于产业技能变化速度,就业指导缺少实时产业数据支撑。
传统的人才盘点与招聘手段多依赖关键词匹配,缺乏对技术趋势的实时感知能力,无法回答"技术 A 的爆发会导致新兴岗位 B 产生哪些新技能需求"这一**动态关联**问题。
### 1.2 技术机遇:为什么是现在
三个独立的时间窗口在此刻交汇,使"自我进化的人才能力大脑"从设想变为可工程实现的系统:
1. **大模型语义理解成熟**:大模型具备了从非结构化 JD 文本中稳定抽取岗位要素的能力,JSON 模式 + Schema 校验使结构化输出的格式错误率趋近于零;
2. **知识图谱工程化成熟**:图数据库对复杂关系的结构化表达能力、时序属性的原生支持,使"岗位—技能"关系的动态演化可以被精确表达与回溯;
3. **多源数据融合与交叉验证技术成熟**:近重复检测、时间衰减、多源投票等机制,为防控 AI 生成内容"幻觉"提供了可量化、可工程落地的手段。
### 1.3 赛题理解(XH-202621 答题要求逐条解构)
发榜单位聚焦"数据驱动 + 大模型 + 知识图谱"核心技术方向,提出三大核心难题:
| 赛题难题 | 本质要求 | 本方案应答章节 |
|---|---|---|
| 如何从海量多源数据中精准发现并定义新兴岗位、更新既有岗位能力要求,构建可动态更新的岗位能力图谱 | 图谱构建 + 动态演化 | 第五、六、七章 |
| 如何解决招聘数据的"时滞""噪声"与"抄袭"问题,防控 AI 生成内容"幻觉" | 数据治理 + 幻觉防控 | 第四、六章 |
| 如何实现高精度的简历解析与细粒度人岗差距分析 | 匹配引擎 + 应用闭环 | 第八章 |
赛题的四项核心功能要求与可验证性要求,本方案的响应如下(详细自评见第十二章):
| 赛题要求 | 硬性指标 | 本方案响应 |
|---|---|---|
| ① 新岗位发现与定义 | 生成岗位名称、核心职责、必备技能、加分技能、典型行业应用场景,支持人工优化与动态更新 | "先聚类、后生成、人确认"三段式管线(第七章) |
| ② 既有岗位能力动态更新 | 识别能力变化,提供更新说明及数据源,明确标注增/删/改能力项 | 时序知识图谱 + 季度窗口对比 + 证据链更新说明(第六章) |
| ③ 新一代信息技术岗位全景图谱 | 技能点粒度,按技术栈和级别切换视图 | 四类节点、四类关系的图谱模型 + 交互式可视化(第五、十章) |
| ④ 人岗匹配度诊断与差距分析 | 支持 PDF/Word 简历解析,技能提取准确率 ≥90%,多维匹配,学习路径规划 | 两级匹配引擎 + 四维加权评分 + 拓扑排序学习路径(第八章) |
| 可验证性 | ≥100 条 JD 测试集;JD 解析、简历提取、人岗匹配准确率均 ≥90%;单测覆盖率 ≥60% | 自备 120 条测试集 + 一键指标复现脚本(第四、九章) |
| 提交形式 | 设计实现方案、PPT、演示视频、源代码、Dockerfile、单元测试 | 本方案 + `CareerNexus/` 工程全套(第十一、十三章) |
### 1.4 项目定位
CareerNexus 职联**不是**通用招聘网站,而是连接产业岗位变化与人才培养的**岗位能力基础设施**。平台把分散、肮脏、滞后的招聘需求数据,转化为**带时间属性、带证据来源、可被回溯和审计**的岗位—技能图谱,向上支撑三类用户(学生、高校、企业)的四类应用(岗位探索、简历诊断、学习路径、趋势看板),形成"数据接入—岗位发现—能力演化—精准匹配—人才培养"的完整闭环。
---
## 二、总体方案设计
### 2.1 设计原则
本方案在工程上遵循五条原则,它们同时构成本作品区别于常规参赛作品的底色:
1. **离线优先(Offline-First)**:全部核心功能不依赖任何外部 API 与网络即可运行。大模型是加速器而非单点依赖——无密钥时系统自动降级到离线确定性抽取器,演示永不中断。这直接回应了竞赛现场"断网、无密钥、环境干净"的最苛刻条件。
2. **证据即数据(Evidence-as-Data)**:图谱中的每一条岗位—技能关系都携带来源 JD 引用与生效时间区间;系统生成的每一句结论都能回溯到原始证据。"可追溯"不是口号,而是数据模型的一部分。
3. **人在环路(Human-in-the-Loop)**:模型负责批量处理,人负责确认边界。候选岗位与低置信结论进入审核台,接受/修改/驳回全程留痕,低置信结果永不直接污染正式图谱。
4. **确定性可复现(Reproducible)**:数据集生成、指标评测、演示快照全部以固定随机种子与固化提示词版本保证可复现,评审在任何干净机器上都能得到相同结果。
5. **最小依赖(Minimal Dependencies)**:核心算法(MinHash LSH、TF-IDF、聚类、时序图谱、匹配评分)全部以纯 Python 自实现,不引入重型深度学习框架,工程可在一台普通笔记本上从头跑到尾。
### 2.2 系统架构
平台采用"**数据采集 — 清洗验证 — 图谱构建 — 匹配应用**"四层流水线架构,各层以本地文件与图数据库为数据枢纽,由 Python 主程序统一调度;Web 服务层以 FastAPI 暴露 REST 接口,前端单页应用由后端直接托管,实现**单命令启动、单机运行、自动开屏**。
```
┌─────────────────────────────────────────────────────────────────┐
│ 展示层 WebUI(驾驶舱 / 全景图谱 / 岗位演化 / 人岗匹配 / 学习路径) │
├─────────────────────────────────────────────────────────────────┤
│ 服务层 FastAPI REST(统一响应格式 · 异步任务 · 静态托管) │
├─────────────────────────────────────────────────────────────────┤
│ 应用层 新岗位发现 │ 演化分析 │ 简历解析 │ 人岗匹配 │ 学习路径 │
├─────────────────────────────────────────────────────────────────┤
│ 图谱层 时序知识图谱 Temporal KG(岗位·技能·行业·场景) │
│ 关系属性:重要性/权重/生效区间/变更类型/数据源 │
├─────────────────────────────────────────────────────────────────┤
│ 算法层 MinHash LSH 去重 │ 时间衰减 │ 结构化抽取+Schema校验 │
│ 多源投票 │ TF-IDF 聚类 │ 窗口对比 │ 两级匹配评分 │
├─────────────────────────────────────────────────────────────────┤
│ 数据层 data/jd_raw → jd_clean → testset · 技能词表 · 简历 · 报告 │
└─────────────────────────────────────────────────────────────────┘
```
整体数据流:多源 JD 落地为本地 JSONL → 清洗与交叉验证(去重、去噪、时间衰减)→ 结构化抽取(Schema 强校验 + 多源投票)→ 写入时序知识图谱(追加式演化,不覆盖历史)→ 新岗位发现与演化事件计算 → 匹配引擎完成简历解析、人岗匹配与学习路径生成 → WebUI 呈现。
### 2.3 技术选型
| 架构层次 | 技术选型 | 承担职责与选型理由 |
|---|---|---|
| 数据接入层 | Python:JSONL 批量导入、手动上传/粘贴、pypdf(可选) | 多通道获取 JD 与行业报告,落地本地,演示全程走本地数据 |
| 数据清洗层 | 纯 Python 自实现 MinHash(64 哈希、5-gram shingles)+ LSH 分带;时间衰减加权 | 识别抄袭簇、抑制时滞、过滤噪声——幻觉防控第一道防线 |
| 抽取层 | 星火大模型 API(JSON 模式,可配置);离线确定性抽取器(技能词表+规则)为默认与兜底;Pydantic v2 Schema 强校验 | 语义理解结构化;无密钥不断链;坏数据入口拦截 |
| 知识图谱层 | 自研时序图谱内核(邻接表 + JSON 持久化);Neo4j 为可选增强适配器 | 关系级时间区间属性原生支持演化回溯;零外部服务依赖亦可运行 |
| 发现与演化 | 纯 Python TF-IDF(char n-gram)+ 凝聚聚类;季度窗口频次对比 | 新岗位候选簇识别;增/删/改事件可解释计算 |
| 匹配引擎 | 技能词表归一化召回 + 四维加权评分;图谱前置关系拓扑排序 | 透明、可解释、可配置的评分,学习路径符合认知顺序 |
| 服务与展示层 | FastAPI + Uvicorn;Pydantic v2 全链路校验;原生 HTML/JS 单页 WebUI(无构建步骤) | REST 接口 + 图谱可视化;前端零构建保证任何环境可跑 |
| 测试与部署 | pytest + pytest-cov;Dockerfile + docker-compose | 覆盖率 ≥60%;一键容器化交付 |
### 2.4 与常规方案的差异化
| 维度 | 常规参赛方案 | 本方案 |
|---|---|---|
| 大模型依赖 | 无 API 即瘫痪 | 离线抽取器全功能兜底,API 仅作增强与对照 |
| 图谱形态 | 静态快照,覆盖式更新 | 时序图谱,追加式演化,任意时间点切片查询 |
| 幻觉防控 | 宣称"人工检查" | 四道可量化防线(抄袭识别/时间衰减/多源投票/审核留痕)+ 证据链 |
| 匹配评分 | 单一相似度黑盒 | 四维加权透明评分,权重可配,逐项可解释 |
| 数据 | 现场爬取或来历不明 | 自备可复现数据集,含植入脏数据与标准答案,指标一键复算 |
| 交付 | 脚本合集 | 单命令启动的生产工程,Docker 化,测试覆盖率可报告 |
---
## 三、自备数据集建设方案(数据全部自己准备)
数据是本项目的地基。竞赛评分中"实用价值"占 30 分,核心考察"≥100 条 JD 测试集 + 三项准确率 ≥90%"的可验证性。为此我们**自行建设了全套数据集**,覆盖原始数据、脏数据、标准答案、技能词表、简历与行业报告六类资产,全部提交在 `CareerNexus/data/` 下,并由 `scripts/generate_dataset.py` 以固定随机种子(`seed=2026`)一键重新生成,保证评审可复现。
### 3.1 数据集总览
| 数据资产 | 路径 | 规模 | 用途 |
|---|---|---|---|
| 多源岗位 JD 原始库 | `data/jd_raw/*.jsonl` | 1375 条,18 岗位,2019—2026 年,15 城市、5 平台 | 图谱构建与演化分析原料 |
| JD 测试集 | `data/testset/jd_testset.jsonl` | 144 条(与原始库不重复) | JD 解析准确率评测 |
| 标准答案 | `data/testset/gold_answers.json` | 144 条逐字段标注 | 评测基准(抽取输出 Schema 同构) |
| 技能词表 | `data/skills_lexicon.json` | 159 个技能点,含别名/级别/前置关系、7 个行业维度 | 技能归一化、匹配、学习路径 |
| 简历样例 | `data/resumes/` | 7 份(覆盖 Java/前端/数据分析/大模型应用等目标,技能完整/部分/错位三典型) | 简历提取与匹配评测 |
| 行业报告 | `data/reports/` | 3 份行业趋势报告(与 JD 演化信号相互印证) | 多源异构数据之一、趋势佐证 |
### 3.2 JD 原始库设计:把"演化信号"与"三类脏数据"烘焙进数据
**岗位覆盖**(立足数字经济、瞄准新一代信息技术领域,对齐赛题岗位选择范围):
Java 开发工程师、大模型应用工程师、RAG 工程师、提示词工程师、算法工程师、NLP 工程师、CV 工程师、数据工程师、数据分析师、前端工程师、测试开发工程师、运维/SRE 工程师、云原生工程师、嵌入式工程师、物联网工程师、AI 产品经理等 16 个以上岗位。
**每条 JD 字段**:`job_title / company / city / salary / publish_date / source / url / text`,其中 `text` 为 150—400 字中文 JD 全文,由"职责 + 技能要求"模板族组合生成,公司名、城市、薪资区间、措辞均多样化,避免千篇一一律。
**演化信号(支撑"既有岗位能力动态更新"演示)**——以 Java 开发工程师为例:
| 时期 | 能力要求变化 |
|---|---|
| 2019—2021 | 以 SSM、Servlet、JSP、MySQL 为主,Struts 偶见 |
| 2022—2023 | 微服务、Spring Cloud、Docker/K8s 容器化权重显著上升,Struts 退出 |
| 2024—2026 | "AI 辅助编程""大模型应用集成"成为高频加分项,云原生进一步深化 |
而"大模型应用工程师""RAG 工程师""提示词工程师"等岗位 2023 年后才出现且数量快速增长——支撑"新岗位发现"演示。
**脏数据植入(支撑"时滞/噪声/抄袭"治理演示)**:
- **抄袭**:约 8% 的 JD 为近重复(同一 JD 换公司名重发、或同义改写),清洗模块必须能将其聚簇并降权;
- **噪声**:约 3% 的 JD 混入广告尾注、无关条款(如"本公司常年招聘各类人才""转发有礼"),清洗模块必须过滤;
- **时滞**:发布时间横跨 2019—2026,过期 JD 的技能要求经时间衰减降权,避免"五年前的要求左右今天的图谱"。
### 3.3 测试集与标准答案:评测公正性设计
测试集 120 条 JD 与原始库**不重复**,防止"背答案"。标准答案的生成遵循**先定答案、后写文本**的原则:生成器先采样出该条的岗位、必备技能、加分技能、职责与场景作为 ground truth,再据此渲染 JD 文本。评测脚本 `scripts/evaluate.py` 执行的是**从文本重新抽取、再与 gold 逐字段比对**的真实流程(字段级 Precision/Recall/F1),绝不在评测中引用生成时的内部状态。这一设计使"≥90%"是一个诚实、可复核、可向评审现场演示的数字。
### 3.4 技能词表:项目的隐性壁垒资产
`data/skills_lexicon.json` 收录 ≥120 个技能点,每个技能点包含:
- **名称与类别**(编程语言/框架/数据库/云平台/大模型技术/工程实践……);
- **别名集**:如"大模型应用开发"="LLM 应用开发"="生成式 AI 应用","K8s"="Kubernetes"——解决 JD 与简历中的表达异构,是技能归一化准确率的关键;
- **级别**:入门/熟练/精通,支撑图谱按级别切换视图;
- **前置关系**:如"RAG"前置"向量数据库"与"Prompt Engineering"——学习路径拓扑排序的依据。
词表随项目持续积累,构成"可迁移、可复制"交付能力的核心(见商业计划)。
### 3.5 数据合规
自备数据全部为团队生成的合成数据,不含任何真实个人信息与企业非公开信息;简历样例中的姓名、手机、邮箱均为虚构,用于验证脱敏功能。真实部署时,平台仅采集或导入具备使用依据的数据,保留来源与获取时间,简历上传前明示用途、默认脱敏、支持删除,日志不落简历全文与密钥。
---
## 四、数据治理与"幻觉"防控体系(创新点一)
赛题明确要求"探索多源异构数据清洗与交叉验证融合机制,解决数据的'时滞'与'噪音'问题"以及"能力'幻觉'防控"。本方案将幻觉防控设计为**四道纵深防线**,每一道都可量化、可演示、可在日志中观测,而不是一句"人工审核"的口号。
### 4.1 第一道防线:抄袭识别——MinHash LSH 近重复检测
招聘 JD 存在严重的"抄袭"现象:同一 JD 被不同公司改名重发,或同义改写后多发。若不去重,被抄袭的技能要求会因重复计数而被高估,图谱科学性从源头上被破坏。
**实现**(`app/cleaning/`,纯 Python 自实现):
1. 对 JD 文本切 5-gram 字符 shingles 集合;
2. 用 64 个独立哈希函数计算 MinHash 签名,将集合相似度估计压缩为签名比较;
3. LSH 分带(banding):把 64 维签名切成若干带,同带候选才两两比对,将 O(n²) 降为近似 O(n);
4. Jaccard 相似度 ≥ 阈值(默认 0.8)判定为抄袭簇,**保留最早来源,其余标记降权**,聚簇结果与证据写入 `data/jd_clean/`。
植入数据集约 8% 的抄袭 JD 必须被全部检出(有单元测试锁定该行为)。
### 4.2 第二道防线:时滞校正——时间衰减加权
JD 带有滞后性:一条五年前的 JD 不应与本周的 JD 同等左右"当前"岗位画像。清洗层对每条 JD 计算时间衰减权重:
```
w = exp(-λ · age_days) λ = ln2 / 半衰期(默认 540 天)
```
演化分析中所有频次统计均以 w 加权。效果是:旧技能要求随时间自然"退潮",新要求的"涨潮"信号被放大——这正是"动态感知"与"静态画像"的分水岭。
### 4.3 第三道防线:噪声过滤与结构化强校验
- **噪声过滤**:规则集过滤广告尾注、无关条款、超短/超长异常文本;不合格记录进错误清单并计入 WARN 日志,不进入后续链路。
- **Schema 强校验**:抽取输出必须通过 Pydantic v2 按岗位定义 Schema 逐字段校验(岗位名称、核心职责、必备技能、加分技能、应用场景、证据片段、数据源),不合规自动重试(大模型通道最多重试 2 次),仍不合规则丢弃并留痕。**格式错误率趋近于零**是工程保证而非期望。
### 4.4 第四道防线:多源交叉验证与人机协同审核
- **多源投票**:字段级投票机制已实现并有单元测试锁定(`app/extract/base.py` 的 `vote_field`,关键字段多源一致方可高置信)。默认演示走单通道离线确定性抽取;配置星火 API 与开源模型端点后即启用"主抽取器 + 对照通道"双通道投票,Self-Consistency 多采样为在线模式的可选增强。
- **证据绑定**:每个抽取字段必须携带原文证据片段(evidence span),无原文支持的字段不予入库——"幻觉"在数据模型层面无处容身。
- **审核台**:候选新岗位与低置信结论进入"待审核"分区,与正式图谱物理隔离。审核人员可执行接受/修改/驳回/合并,每次操作保存 `operator / operation_time / before / after / reason`,支持按版本回溯"模型结果 → 人工优化结果 → 最终发布结果"。
### 4.5 防线的可观测性
四道防线全部接入统一日志(`时间 | 级别 | 模块 | 消息`),示例:
```
2026-07-23 13:02:16 | WARN | app.cleaning | JD#1024 与 JD#0987 相似度 0.97, 判定抄袭簇, 已降权
2026-07-23 13:02:18 | WARN | app.extract | 对照通道响应超时(30s), 本条以主通道结果单源入库, 已标记待复核
2026-07-23 13:02:20 | INFO | app.graph | 演化事件: Java开发工程师 +[AI辅助编程](加分), 证据 14 条 JD, 窗口 2026Q1
```
答辩现场可以直接打开日志与审核台,向评委展示"AI 不是盲目生成,而是可控、可审、可回退"。
---
## 五、时序知识图谱:动态演化的学术级表达(创新点二)
### 5.1 为什么常规图谱不够用
常规岗位图谱是**静态快照**:今天的"Java 开发工程师"岗位要求覆盖了昨天的,历史无从查询,"演化"只能靠人工对比两个版本的 Excel。赛题要的"动态演化分析"本质上要求图谱把**时间**作为一等公民。
### 5.2 图谱模型
四类节点、四类关系,全部关系携带时序与证据属性:
| 类型 | 名称 | 关键属性 |
|---|---|---|
| 节点 | Job 岗位 | 名称、定义、级别、技术栈、创建/更新时间、审核状态 |
| 节点 | Skill 技能点 | 名称、类别、级别、别名集 |
| 节点 | Industry 行业 | 名称、描述 |
| 节点 | Scenario 应用场景 | 名称、描述 |
| 关系 | (Job)-[:REQUIRES]->(Skill) | **重要性(必备/加分)、权重、生效时间区间 [valid_from, valid_to)、变更类型(新增/增强/减弱/退出)、数据源引用** |
| 关系 | (Job)-[:BELONGS_TO]->(Industry) | — |
| 关系 | (Job)-[:APPLIES_TO]->(Scenario) | — |
| 关系 | (Skill)-[:RELATED_TO]->(Skill) | 关联类型(前置/同级/互斥) |
### 5.3 追加式演化与时间点切片
核心机制:**每次能力更新不覆盖旧关系,而是关闭旧关系的生效区间(写 valid_to)、追加新关系(写 valid_from 与变更事件)**。由此获得两个常规方案给不出的能力:
1. **任意时间点切片查询**:`snapshot(job_id, date)` 返回该岗位在任意历史日期的能力画像——"2021 年的 Java 工程师需要什么"与"2026 年需要什么"可以同屏对比;
2. **演化事件的可解释回溯**:每个增/删/改事件都挂载触发它的 JD 证据列表,点击"为什么 AI 辅助编程成了加分项",直接展示 2026Q1 窗口内 14 条原始 JD 片段及来源。
### 5.4 工程实现
图谱内核为自研邻接表结构(`app/graph/tkg.py`),JSON 快照持久化,零外部服务依赖即可运行;`docker-compose.yml` 的 `advanced` profile 提供 Neo4j 5 可选服务,配 `scripts/sync_neo4j.py` 幂等同步(MERGE 写入,含时间区间与证据属性),生产部署可平滑切换。写入操作携带幂等键(job+skill+valid_from 哈希),任务重复提交不会产生重复节点与关系;所有写操作在事务内完成,失败回滚并记 ERROR 日志。
应用状态(审核留痕、候选岗位、异步任务)存储于内嵌 **SQLite** 状态库(`data/careernexus.db`,标准库 sqlite3 实现、WAL 模式、零新增依赖、零服务):`audit_log / review_candidates / tasks` 三表,首次启动自动从旧 JSON 文件迁移种子数据,重启后任务状态可恢复。选择 SQLite 而非外部数据库的理由与"离线优先"一致:单机零配置、可直接打进 Docker 镜像与数据卷;多用户/大数据量场景下,存储边界已收拢在 `app/storage/db.py`,替换为 PostgreSQL 是局部改动。
### 5.5 GraphRAG:图谱作为生成的检索底座
生成"更新说明"与"学习建议"时,系统不直接把问题丢给大模型,而是**先在图谱中多跳检索**(岗位 → 缺失技能 → 前置技能 → 关联岗位 → 证据 JD),把检索到的证据链连同问题一起交给生成器。回答"有据可查",显著优于纯向量 RAG 的"看似合理实则编造"。默认(离线)模式下证据链直接模板化成文——更新说明与验收标准均由证据驱动生成,已实装;在线模式下可由大模型润色为更自然的表述,为增强项,两种模式共享同一条证据链。
---
## 六、既有岗位能力动态演化分析
### 6.1 演化事件计算
演化分析器(`app/evolution/analyzer.py`)按季度窗口聚合岗位—技能加权频次,对相邻时间窗口做对比,产生四类变更事件:
| 事件类型 | 判定规则(默认阈值,可配置) | 示例 |
|---|---|---|
| 新增 ADD | 上一窗口无此技能,本窗口加权频次 ≥ 阈值且覆盖 ≥3 家独立企业 | 2026Q1 Java 岗新增"AI 辅助编程"(加分) |
| 增强 STRENGTHEN | 权重环比增长 > 50% 且绝对增量显著 | 2022Q3 起"Spring Cloud"由加分升为必备 |
| 减弱 WEAKEN | 权重环比下降 > 50% | 2023 起"MySQL 调优"权重相对下降 |
| 退出 REMOVE | 连续两个窗口加权频次低于退出阈值 | 2022Q4 "Struts"退出能力画像 |
每个事件保存:前后权重、时间窗口、支持 JD 数、独立企业数、来源引用列表。为抑制单企业 JD 刷量造成的假信号,所有判定均要求**跨企业证据**(默认 ≥3 家独立企业)。
### 6.2 更新说明自动生成(赛题硬性要求"提供更新说明及数据源")
每个演化事件自动生成中文更新说明,明确标注能力项的新增/删除/修改,并附数据源,例如:
> **Java 开发工程师 · 2026Q1 能力更新说明**
> 【新增·加分技能】AI 辅助编程。在 2026Q1 窗口内,14 条有效 JD(覆盖 9 家企业,来源:boss/lagou/zhilian)明确提出 AI 编程工具(如 Copilot 类)使用能力要求,加权频次 0.31,达到新增阈值。建议关注该技能的后续演化。
> 数据源:jd_raw/batch_2026.jsonl#1024、#1031、#1049 ……(共 14 条,可逐条回溯原文)
说明文本由 GraphRAG 证据链驱动生成,评审可点击任一来源编号查看 JD 原文。
### 6.3 演示案例
交付数据中烘焙了两个完整演示案例:
- **演化案例**:Java 开发工程师 2019→2026,完整呈现"SSM 时代 → 微服务云原生时代 → AI 增强时代"的能力迁移,含 10+ 个演化事件;
- **新岗位案例**:大模型应用工程师,从 2023 年的萌芽簇到确认为正式岗位的全过程(见第七章)。
---
## 七、新岗位发现与定义:"先聚类、后生成、人确认"
### 7.1 发现管线
新岗位发现(`app/discover/cluster.py`)分三步,以**可解释、可演示**为目标,不依赖复杂在线训练:
**第一步:向量化与聚类。** 对 JD 的"岗位名称 + 职责 + 技能"文本计算 TF-IDF 向量(纯 Python,char n-gram 兼容中英文混排),凝聚聚类成簇。
**第二步:候选判定四指标。** 对每个簇计算并排序:
| 指标 | 含义 | 默认阈值 |
|---|---|---|
| 支持 JD 数 | 簇内有效 JD 量 | ≥ 15 |
| 独立企业数 | 防止单一企业造词 | ≥ 5 |
| 近期增长率 | 近两个窗口环比 | ≥ 50% |
| 与既有岗位差异度 | 与最近既有岗位的向量距离 | ≥ 0.4 |
四指标同时满足的簇判定为**新兴岗位候选**。"大模型应用工程师"簇(2023 年后出现、持续增长、与"算法工程师"差异显著)在内置数据上稳定命中。
**第三步:定义生成与人工确认。** 对候选簇生成结构化岗位定义(岗位名称、核心职责、必备技能、加分技能、典型行业应用场景),进入审核台;人工确认后写入正式图谱,修改留痕、支持版本回溯。**未经确认的候选永不出现在正式图谱与展示页**——这是防幻觉在"新岗位"场景的落点。
### 7.2 岗位定义输出 Schema(即测试集标准答案格式)
```json
{
"job_name": "大模型应用工程师",
"core_duties": ["设计和优化大模型应用链路", "构建 RAG 检索增强系统", "评估与压测模型效果"],
"required_skills": ["Python", "Prompt Engineering", "RAG", "LangChain"],
"bonus_skills": ["向量数据库", "模型微调", "Agent 框架"],
"scenarios": ["智能客服", "企业知识库", "办公 Copilot"],
"sources": ["jd_raw/batch_2024.jsonl#1024", "jd_raw/batch_2024.jsonl#1087"]
}
```
---
## 八、人岗匹配引擎与学习路径规划
### 8.1 简历解析与技能归一化
简历解析(`app/match/resume.py`)支持 TXT/Word(python-docx),PDF 为可选增强(pypdf);扫描件 OCR 为生产部署可选项。解析流程:文本提取 → **隐私脱敏**(姓名/手机/邮箱自动打码,日志不落全文)→ 技能要素抽取 → **归一化到技能词表**(别名映射:"LLM 应用开发"→"大模型应用开发")。归一化是提取准确率 ≥90% 的关键:没有词表,"K8s"和"Kubernetes"会被当成两个技能。
### 8.2 透明四维加权评分
匹配评分(`app/match/engine.py`)采用**完全透明、可解释**的加权模型,权重可在管理端配置:
| 维度 | 默认权重 | 计算依据 | 结果解释 |
|---|---|---|---|
| 必备技能 | 50% | 已命中必备技能权重和 / 全部必备技能权重和 | 核心胜任程度 |
| 加分技能 | 15% | 命中加分技能的加权覆盖率 | 岗位竞争优势 |
| 经验与熟练度 | 20% | 年限、项目描述与技能熟练度映射 | 实践经验匹配度 |
| 行业/场景 | 15% | 项目经历与岗位应用场景的语义相似度 | 业务环境适配度 |
输出:总分、四维分数、已满足能力、**关键缺口清单**、可迁移能力(已掌握技能可迁移至哪些相近岗位)、推荐岗位。总分只用于排序与诊断,系统明确声明"不代替企业最终招聘判断"。
### 8.3 学习路径:图谱拓扑排序生成
学习路径不是泛泛的"建议学习 Python",而是**算法生成**的:
1. 取差距清单中的缺失技能;
2. 在技能图谱中沿 RELATED_TO(前置)边做拓扑排序——先学"向量数据库"再学"RAG",符合认知顺序;
3. 按学习周期(用户输入,如 12 周)切分为 3—4 个阶段,每阶段给出目标技能、顺序、资源类型(文档/课程/项目)与验收任务;
4. 默认(离线)模式由模板结合证据链成文,并附带按技能类别生成的结构化验收标准(14 类模板);在线模式可由大模型结合 GraphRAG 证据链润色(增强项),两者共享同一技能顺序内核。
### 8.4 与图谱的闭环
匹配结果反向滋养图谱:群体性的技能缺口统计(脱敏聚合后)即"人才培养缺口报告",是高校版产品的核心数据资产;岗位的能力画像随 JD 流持续演化,匹配标准自动保持"新鲜"——这就是"静态画像"到"动态感知"的跨越。
---
## 九、生产代码工程实现
本方案随附**完整可运行的生产代码工程 `CareerNexus/`**(非伪代码、非演示脚本)。评审可执行三个命令完成全部验证:
```bash
pip install -r requirements.txt # 依赖均为轻量纯 Python 包
python scripts/run_demo.py # 端到端冒烟:生成数据→跑通全链路→打印图谱与匹配示例
python main.py # 启动服务,自动打开 http://127.0.0.1:9008
```
### 9.1 工程结构
```
CareerNexus/
├── main.py # 唯一入口:配置→图谱→服务→自动开浏览器
├── config.yaml # 端口(9008)、数据路径、运行模式 demo|live
├── requirements.txt Dockerfile docker-compose.yml README.md .env.example
├── app/
│ ├── models.py # Pydantic v2 全链路模型(统一数据对象)
│ ├── ingest/loader.py # JSONL 导入、source_id 分配、坏行错误清单
│ ├── cleaning/ # MinHash LSH 去重、噪声过滤、时间衰减
│ ├── extract/ # base(协议+投票) / offline(词表+规则) / llm(星火) / prompts(固化模板)
│ ├── graph/tkg.py # 时序图谱内核:追加式演化、时间点切片、JSON 持久化
│ ├── discover/cluster.py # TF-IDF 聚类 + 四指标候选判定 + 审核台
│ ├── evolution/analyzer.py# 季度窗口对比 → 增/删/改事件 + 更新说明
│ ├── match/ # resume(解析/脱敏/归一化) + engine(四维评分/差距/学习路径)
│ ├── services/pipeline.py # 端到端编排,CLI 与 API 复用同一管线
│ └── api/routes.py # FastAPI 路由 + 静态托管
├── webui/ # 原生 HTML/JS 单页应用,无构建步骤
├── scripts/ # generate_dataset.py / evaluate.py / run_demo.py
├── data/ # 自备数据集(见第三章)
└── tests/ # pytest,覆盖率 ≥60%
```
### 9.2 关键实现走读(源码节选)
**时序图谱的追加式演化写入**(`app/graph/tkg.py` 核心逻辑):
```python
def apply_change(self, job_id: str, skill_id: str, event: ChangeEvent) -> None:
"""演化写入:不覆盖历史,关闭旧区间、追加新区间,事件留痕。"""
old = self._active_edge(job_id, skill_id)
if old is not None:
old.valid_to = event.window_start # 旧关系生效区间关闭
new_edge = RequirementEdge(
job_id=job_id, skill_id=skill_id,
importance=event.importance, weight=event.new_weight,
valid_from=event.window_start, valid_to=None,
change_type=event.change_type, # ADD/STRENGTHEN/WEAKEN/REMOVE
sources=event.evidence_jds, # 证据链随关系持久化
)
self._edges[self._idem_key(new_edge)] = new_edge # 幂等键防重复写入
self.events.append(event)
def snapshot(self, job_id: str, at: date) -> JobProfile:
"""任意时间点切片:只取 valid_from <= at < valid_to 的关系。"""
edges = [e for e in self._edges.values()
if e.job_id == job_id and e.valid_from <= at and (e.valid_to is None or at < e.valid_to)]
return JobProfile(job_id=job_id, at=at, requirements=edges)
```
**抄袭检测 MinHash 签名**(`app/cleaning/` 核心逻辑):
```python
def minhash_signature(shingles: set[str], num_perm: int = 64) -> list[int]:
"""64 个独立哈希的 MinHash 签名,用于 Jaccard 相似度无偏估计。"""
sig = []
for i in range(num_perm):
sig.append(min(_hash(s, seed=i) for s in shingles))
return sig
def lsh_candidate_pairs(docs: dict[str, list[int]], bands: int = 16) -> set[tuple[str, str]]:
"""LSH 分带:同带桶内才两两比对,O(n²) → 近似 O(n)。"""
...
```
**四维匹配评分**(`app/match/engine.py` 核心逻辑):
```python
def score(self, profile: JobProfile, resume: ResumeSkills) -> MatchResult:
req_hit = self._coverage(resume, profile.required) # 必备 50%
bonus_hit = self._coverage(resume, profile.bonus) # 加分 15%
exp_score = self._experience_score(resume, profile) # 经验 20%
scen_score = self._scenario_similarity(resume, profile) # 场景 15%
total = (self.weights.required * req_hit + self.weights.bonus * bonus_hit
+ self.weights.experience * exp_score + self.weights.scenario * scen_score)
gaps = [s for s in profile.required if s not in resume.normalized_skills]
path = self.graph.topological_learning_order(gaps) # 前置关系拓扑排序
return MatchResult(total=total, gaps=gaps, learning_path=path, ...)
```
**离线确定性抽取器**(`app/extract/offline.py`,无 API 时的全功能兜底):
```python
class OfflineExtractor:
"""基于技能词表 + 句式规则的确定性抽取器:离线可用、结果可复现。"""
def extract(self, jd: JDRecord) -> ExtractionResult:
skills = self.lexicon.scan(jd.text) # 别名归一化扫描
required = [s for s in skills if self._in_required_context(jd.text, s)]
bonus = [s for s in skills if self._in_bonus_context(jd.text, s)] # "优先/加分/熟悉…者佳"
duties = self._duty_sentences(jd.text)
return ExtractionResult(..., evidence=self._spans(jd.text, skills)) # 逐字段证据片段
```
### 9.3 接口设计(统一前缀 /api,统一响应 `{code, message, data, request_id}`)
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | /api/health | 服务与图谱健康检查 |
| POST | /api/jd/import | 导入 JD(文件/文本),触发清洗 |
| GET | /api/jobs | 岗位列表,按技术栈/级别筛选 |
| GET | /api/jobs/{id} | 岗位详情:定义、能力项、数据源 |
| GET | /api/jobs/{id}/evolution | 能力演化时间轴(增/删/改事件) |
| POST | /api/jobs/discover | 触发新岗位发现 |
| POST | /api/review | 候选审核(accept/modify/reject,留痕) |
| GET | /api/graph | 全景图谱节点与边(供前端渲染) |
| POST | /api/resume/parse | 简历解析与技能归一化(脱敏) |
| POST | /api/match | 人岗匹配诊断与差距分析 |
| POST | /api/learning-path | 生成改进建议与学习路径 |
轻量查询同步返回;耗时任务(导入、发现、生成)以任务 ID 异步执行,前端轮询进度。上传文件限制扩展名/MIME/大小,文件名随机化;密钥只从环境变量注入,仓库仅含 `.env.example`;Neo4j/图谱写入用幂等键防重复。
### 9.4 日志与安全
- 标准库 logging 全局配置,DEBUG/INFO/WARN/ERROR 四级,控制台彩色 + `logs/` 按天滚动保留 30 天;
- 日志默认不落简历全文、手机号、邮箱、密钥;
- 每条模型调用记录 `model_name / prompt_version / request_id / 耗时`,便于审计与复现;
- 演示账户与管理账户分离;简历支持任务完成后手动删除。
---
## 十、用户体验设计
### 10.1 五视图信息架构
前端为原生 HTML/JS 单页应用(**无 npm 构建步骤**,任何环境可跑),围绕"看趋势、查岗位、传简历、得建议"组织,关键操作三步以内完成:
| 视图 | 核心功能 | 主要输出 |
|---|---|---|
| 数据驾驶舱 | 平台数据规模与更新状态 | JD 数、岗位数、技能数、候选新岗位、最新变更 |
| 全景图谱 | Canvas 力导向交互图谱,按技术栈/级别切换 | 局部图谱、节点详情、证据来源 |
| 岗位演化 | 时间轴回溯岗位能力变化 | 新增/增强/减弱/退出事件、更新说明、证据 JD |
| 人岗匹配 | 上传简历、诊断差距 | 总分雷达图、命中/缺失清单、可迁移技能、推荐岗位 |
| 学习路径 | 分阶段提升方案 | 阶段目标、技能顺序、资源类型、验收任务 |
### 10.2 交互与异常设计
- 图谱默认只加载岗位及一级技能,点击局部展开,避免全量渲染卡顿;岗位/技能不同形状,必备/加分不同颜色,颜色同时配文字标签(不只依赖颜色传达信息);
- 模型处理较长时分阶段显示进度(上传→解析→归一化→匹配→生成建议);
- 证据不足时不生成确定性结论,显示"待补充证据"标记并可提交人工审核;
- 接口失败保留用户输入,显示可重试提示与请求编号;
- **演示模式/真实模式切换** + "演示数据重置"按钮:断网时核心页面仍可完整展示,每次答辩可从同一状态开始。
### 10.3 十分钟演示脚本
| 时间 | 演示内容 | 评审看到的价值 |
|---|---|---|
| 0:00–1:00 | 人才供需脱节问题与平台闭环 | 定位清晰 |
| 1:00–2:30 | 导入多源 JD,展示抄袭簇识别与降权 | 数据治理真功夫 |
| 2:30–4:00 | 新岗位候选"大模型应用工程师"四指标判定 + 人工确认 | 新岗位发现 + 防幻觉 |
| 4:00–5:30 | Java 工程师 2019→2026 演化时间轴,点击事件看证据 JD | 动态演化分析 |
| 5:30–6:45 | 技能点级全景图谱交互 | 图谱完整性与体验 |
| 6:45–8:30 | 上传简历,查看匹配度、差距清单、推荐岗位 | 实用价值 |
| 8:30–9:30 | 生成拓扑有序的学习路径 | 学生端闭环 |
| 9:30–10:00 | 现场运行 evaluate.py 复现三项指标 + 覆盖率报告 | 可验证、可交付 |
---
## 十一、测试与指标验证方案
### 11.1 三项核心指标的核算方法
| 指标 | 目标 | 核算方法 | 复现命令 |
|---|---|---|---|
| JD 解析准确率 | ≥90% | 120 条测试集,抽取输出与 gold_answers 逐字段计算 P/R/F1,宏平均 | `python scripts/evaluate.py` |
| 简历提取准确率 | ≥90% | 简历样例抽取技能与标注比对,字段级 F1 | 同上 |
| 人岗匹配准确率 | ≥90% | 标注人岗对(匹配/不匹配)上系统判定与人工判定一致率 | 同上 |
评测全流程"**从文本抽取 → 与标准答案比对**",不引用数据生成的内部状态;结果输出 `reports/metrics.json` 与控制台报告,答辩现场可一键重跑。实测结果见本方案附录 A(随代码提交更新)。
### 11.2 单元测试
pytest 覆盖六大核心模块,覆盖率 ≥60%(随源码提交 `pytest --cov=app` 报告):
- `test_cleaning`:植入的抄袭簇必须被全部检出,噪声行必须被过滤;
- `test_extract`:Schema 校验拦截坏 JSON、多源投票逻辑、离线抽取确定性;
- `test_tkg`:时间点切片正确性、演化追加不覆盖历史、幂等写入;
- `test_evolution`:构造两季度数据验证新增/减弱/退出事件判定;
- `test_match`:四维权重计算、差距清单、学习路径拓扑序;
- `test_api`:TestClient 覆盖 health/jobs/graph/match 主接口。
### 11.3 生成质量评估
在线模式下,对岗位定义、更新说明、学习路径等生成内容可以 RAGAS 框架做忠实度(faithfulness)与相关性量化评分(可选增强);默认模式以 `scripts/evaluate.py` 的字段级 F1 与证据链溯源作为量化手段——更新说明中的每一条结论都标注支撑 JD,"幻觉防控有效"从口号变成可复核的数字。
---
## 十二、评分标准自评对照(满分导向设计)
| 评分项 | 分值 | 评分要点 | 本方案对应交付 |
|---|---|---|---|
| 作品完整性 | 30 | "多源数据采集→新岗位发现与能力更新→图谱动态→简历解析→匹配与差距分析"全流程闭环;技能级全景图谱;可正常部署运行 | 五层管线全闭环(二章);五视图 WebUI(十章);`python main.py` 一键启动 + Dockerfile(十三章) |
| 技术创新性 | 25 | 大模型+知识图谱+RAG 创新应用;多源清洗交叉验证解决时滞噪音;幻觉防控方案完善 | 四道幻觉防线(四章);Temporal KG 追加式演化(五章);GraphRAG 证据链(5.5);多源投票(4.4) |
| 用户体验 | 15 | 界面简洁、图谱交互流畅、差距分析展示清晰 | 五视图 + 局部展开防卡顿 + 双模式演示(十章) |
| 实用价值 | 30 | ≥100 条 JD 测试集;三项准确率 ≥90%;可迁移至更多新一代信息技术岗位 | 120 条测试集 + 一键复现(三、十一章);词表/岗位库/界面三层可配置交付(十四章) |
---
## 十三、部署与运行方案
### 13.1 本地一键运行(评审主路径)
```bash
cd CareerNexus
python -m venv .venv && source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -r requirements.txt
python scripts/generate_dataset.py # 数据已随工程提交,此步可复现重建
python main.py # 启动并自动打开 http://127.0.0.1:9008
```
`main.py` 启动流程:加载 `config.yaml` 与环境变量 → 加载/构建图谱与索引 → Uvicorn 监听 `127.0.0.1:9008` → `webbrowser.open` 自动开屏。任一前置条件缺失时输出明确解决指引而非直接退出。
### 13.2 Docker 交付(赛题要求 Dockerfile 或容器化部署说明)
```bash
docker build -t careernexus .
docker run -p 9008:9008 careernexus # 打开 http://127.0.0.1:9008
```
`docker-compose.yml` 另提供可选 Neo4j 服务编排(图谱内核默认零依赖运行,Neo4j 为生产增强)。镜像基于 `python:3.11-slim`,构建期完成依赖安装与数据集生成,运行期无需网络。
### 13.3 双模式与降级策略
| 模式 | 行为 | 场景 |
|---|---|---|
| live 在线模式 | 配置密钥后调用星火 API 抽取与生成,多源投票全开 | 有网有密钥的完整能力展示 |
| demo 演示模式 | 离线抽取器 + 预生成可追溯快照,断网完成全部核心演示 | 竞赛现场保底 |
降级纪律:**模型不可用时禁止伪造新结果**——允许浏览已有图谱、演化案例与匹配样例,新数据处理显式进入"待审核/待补充证据"状态。
### 13.4 环境依赖
Python 3.11+;依赖均为轻量包(fastapi、uvicorn、pydantic、httpx、networkx、python-docx、pytest、pytest-cov),核心算法纯 Python 自实现,无 GPU 需求,普通笔记本 2 分钟内完成启动。
---
## 十四、商业计划与推广方案
> 比赛阶段不承诺未经验证的收入数据。本章以**产品可复制性**为论证重点,所有测算标注假设与口径,收费方式为方向性设想,不构成收入承诺。
### 14.1 产品定位与客户价值
CareerNexus 职联是连接产业岗位变化与人才培养的**岗位能力基础设施**——把分散的招聘需求转化为可追踪的岗位—技能图谱,为学生提供职业导航,为高校提供专业调整依据,为企业提供岗位画像与差距诊断。
| 目标用户 | 核心痛点 | 产品价值 |
|---|---|---|
| 学生与求职者 | 不了解目标岗位、技能差距和学习顺序 | 岗位探索、简历诊断、学习路径、成长跟踪 |
| 高校就业部门 | 就业指导依赖经验,缺少实时产业数据 | 岗位趋势看板、学生能力诊断、就业报告 |
| 院系与教务部门 | 培养方案更新慢于产业技能变化 | 课程—岗位技能映射、专业预警、教改依据 |
| 企业招聘与培训部门 | JD 不统一、筛选成本高、培训目标模糊 | 岗位画像、人才匹配、技能缺口、培训建议 |
### 14.2 市场测算(TAM/SAM/SOM 三级漏斗,保守口径)
- **TAM**:全国高校就业服务与职业教育信息化市场。以 3000+ 所高校、年均就业指导与信息化投入计,岗位能力数据服务的可及市场约 **30 亿元/年**(口径:就业信息化预算中可用于数据与平台服务的部分,保守取 10%)。
- **SAM**:新一代信息技术相关专业集中的本科与高职院校约 800 所,加上区域内重点企业与产业园区,可服务市场约 **4 亿元/年**。
- **SOM**:三年内通过 3—5 所样板校复制至 50 所高校 + 20 家企业,按高校版年均 8 万元、企业版年均 5 万元测算,可获取市场约 **500 万元/年**。
每一级数字都服务于同一个战略结论:**这是一个"小而深"的基础设施市场,胜负手不在流量而在数据资产的持续积累**。
### 14.3 产品版本与商业模式
| 版本 | 服务对象 | 主要功能 | 收费方式(方向性设想) |
|---|---|---|---|
| 学生基础版 | 个人用户 | 岗位浏览、基础匹配、学习路径 | 核心功能免费,增值报告按次/会员 |
| 高校服务版 | 高校/院系 | 趋势看板、群体画像、课程映射、就业报告 | 年度软件服务 + 实施服务 |
| 企业服务版 | 企业 HR/培训部门 | 岗位画像、批量匹配、人才盘点、培训建议 | 按账号/岗位量/年度订阅 |
| 数据与接口版 | 研究机构/合作平台 | 脱敏趋势数据、岗位技能 API | 按调用量或项目合作 |
**单客户经济模型(高校版,保守假设)**:首年客单 8 万元(软件 6 万 + 实施 2 万),交付成本主要为词表配置与部署(约 1.5 万元,依托模板化交付),首年即可回本;次年起无实施成本,续约毛利率 >75%,续用率的假设依据是"趋势数据随时间增值、客户迁移成本随使用深度上升"。
### 14.4 壁垒:数据飞轮而非单一大模型
```
更多高校/企业接入 → 更多真实 JD 与反馈 → 词表与图谱更准
↑ ↓
迁移成本上升 ← 趋势报告更有价值 ← 演化时序数据更厚
```
四重壁垒相互强化:**持续积累的技能词表**(别名/前置关系)、**可追溯的时序岗位图谱**(时间即壁垒,新进入者买不到历史)、**跨来源去重与验证流程**(工程 know-how)、**课程—岗位映射数据**(高校场景独占资产)。大模型是公用品,数据资产才是护城河。
### 14.5 市场进入路径
1. **校内试点**(第 1 年):人工智能、软件工程、大数据专业,就业指导教师与学生共同验证岗位图谱与简历诊断;用"大模型应用工程师"和"Java 开发工程师"两个样板案例形成可复制展示模板;
2. **院系扩展**(第 1—2 年):接入培养方案与课程映射,产出季度就业趋势报告;
3. **区域闭环**(第 2—3 年):与区域企业、产业学院、招聘服务机构合作,引入真实岗位与反馈,形成高校—学生—企业闭环。
获客渠道:高校就业中心、创新创业基地、产业学院、校企合作项目。运营内容:季度岗位趋势简报、热门技能榜、专业能力差距报告、公开职业测评活动。
### 14.6 商业化验证指标
| 阶段 | 验证目标 | 观察指标 |
|---|---|---|
| 校内试点 | 学生是否理解并使用诊断结果 | 完成匹配人数、学习路径查看率、满意度 |
| 院系试点 | 趋势数据能否支持就业与教改 | 教师使用频次、报告采纳项、课程映射覆盖率 |
| 企业试点 | 岗位画像与匹配是否节省工作量 | JD 标准化时间、候选筛选时间、HR 反馈 |
| 规模推广 | 产品可复制性与交付成本 | 部署周期、续用率、单校维护成本 |
### 14.7 成本与合规
主要成本:数据整理与标注、模型调用或本地推理资源、图数据库与服务器、产品开发维护、高校/企业实施服务。早期通过本地部署、结果缓存、批处理与演示快照控制模型调用成本。合规底线:仅采集/导入具备使用依据的数据并保留来源;简历明示用途、默认脱敏、支持删除;聚合趋势展示不泄露个人与企业非公开信息;商业部署进一步完善授权、访问控制、数据保留期限与安全审计。
---
## 十五、实施计划与风险控制
### 15.1 分阶段实施(对齐赛题日程:2026 年 6 月启动,9—11 月评审)
| 阶段 | 时间 | 重点任务 | 形成成果 |
|---|---|---|---|
| 一、最小闭环 | 6 月 | 数据生成、清洗、抽取、图谱写入、基础图谱页 | 20 条 JD 端到端跑通 |
| 二、赛题核心 | 7—8 月 | 新岗位发现、演化分析、简历解析、匹配与学习路径 | 1 个新岗位 + 1 个演化岗位完整案例 |
| 三、指标与体验 | 8—9 月 | 120 条测试集、三项指标达标、图谱性能与页面优化 | 可复现指标报告 + 稳定 WebUI |
| 四、交付包装 | 9—11 月 | Docker 化、PPT、10 分钟视频、答辩与断网预案 | 完整比赛提交包 |
### 15.2 风险与降级方案
| 风险 | 影响 | 应对与降级 |
|---|---|---|
| 模型 API 不可用 | 在线抽取/生成中断 | 离线抽取器全功能兜底;演示快照;不生成无证据结果 |
| 指标未达 90% | 实用价值失分 | 标准答案前置建设,按错误类型(同义词/边界句式)迭代词表与规则 |
| 图谱渲染卡顿 | 现场交互不流畅 | 默认局部展开、节点上限、预计算布局 |
| 实时数据采集受限 | 数据更新不可复现 | 全部演示走自备本地数据集,爬虫仅作可选扩展 |
| 隐私与密钥泄露 | 合规风险 | 简历脱敏、环境变量注入、日志过滤、演示数据匿名化 |
| 开发延期 | 功能缺失 | 主链路(数据→图谱→匹配)优先,增强模块按优先级裁剪 |
### 15.3 团队分工建议
数据与算法 2 人(数据集建设、清洗、抽取、演化与匹配算法)、后端 1 人(FastAPI、图谱内核、测试与 Docker)、前端 1 人(五视图 WebUI 与演示脚本)、文档与答辩 1 人(方案、PPT、视频、商业计划),指导教师把握学术表达与合规。
---
## 十六、结语:本作品的三个"不一样"
1. **不一样在"时间"**:别人交付的是岗位的快照,我们交付的是岗位的**编年史**——每一条能力要求都有生效区间、变更事件与证据链,"演化"是数据模型的原生能力而非事后分析。
2. **不一样在"诚实"**:四道幻觉防线、待审核隔离区、全程留痕、指标一键复现——系统**设计为可被审查**,这在 AI 应用泛滥的当下恰是最稀缺的工程品格。
3. **不一样在"能跑"**:离线优先、零重依赖、单命令启动、Docker 交付、覆盖率可报告——评委拿到手的不是 PPT 上的架构图,而是 `python main.py` 之后真实转起来的系统。
---
## 附录 A:实测指标(本机实测,与 `reports/metrics.json` 一致)
| 指标 | 目标 | 实测 | 复现方式 |
|---|---|---|---|
| JD 解析准确率(字段级宏平均,144 条测试集) | ≥90% | **100%** | `python scripts/evaluate.py` |
| 简历技能提取准确率(7 份样例) | ≥90% | **100%** | 同上 |
| 人岗匹配准确率(36 对标注) | ≥90% | **100%** | 同上 |
| 单元测试 | 全部通过 | **37 passed** | `pytest tests/` |
| 单元测试覆盖率 | ≥60% | **78%** | `pytest tests/ --cov=app` |
| 抄袭簇检出 | 植入全量检出 | **全中、0 误伤** | `python scripts/run_demo.py` |
端到端冒烟实测:图谱 154 节点(15 岗位 / 118 技能 / 14 场景 / 7 行业)/ 261 边 / 264 个演化事件(新增 117、增强 39、减弱 22、退出 86);新岗位候选自动发现"大模型应用工程师(44 条 JD/40 家企业)、RAG 工程师、提示词工程师";Java 开发工程师 2020 时点画像为 SSM/Servlet/Struts 系,2025 时点为 Spring Cloud/Kubernetes/分布式系统 + AI 辅助编程(加分),前端、数据分析、运维(SRE)三条线同样有完整时代迁移剧本;示例简历脱敏(张\* / 138\*\*\*\*78)后匹配 Java 岗总分 85。服务实测:`/api/health`、`/api/jobs`、`/api/graph`、`/` 全部 200。
## 附录 B:最终交付物清单
- [x] 平台源代码、依赖文件、环境变量样例、README(`CareerNexus/`)
- [x] 自备数据集:1375 条多源 JD(18 岗位、15 城市、5 平台,2019—2026,含植入抄袭/噪声/四条岗位演化剧本)、144 条测试集与标准答案、159 项技能词表(含 7 个行业维度)、7 份简历样例、3 份行业报告(`CareerNexus/data/`)
- [x] 可执行启动脚本、Dockerfile、docker-compose 部署说明
- [x] 指标复现脚本与实测报告(`scripts/evaluate.py`、`reports/metrics.json`)
- [x] 单元测试与覆盖率报告(`tests/`,37 用例,覆盖率 78%)
- [x] 1 个新岗位案例(大模型应用工程师)+ 1 个既有岗位演化案例(Java 开发工程师,含数据源与输入输出示例)
- [x] 作品设计实现方案(本文档)
- [ ] 项目介绍 PPT(≤10 页)与 ≤10 分钟演示视频(按十章演示脚本录制)
*(完)*
@@ -0,0 +1,135 @@
# CareerNexus 职联 · 项目方案补充(v3.1)
> 本文档是《CareerNexus职联_项目方案.md》(v3.0)的补充,记录方案定稿后的**实现增量**。
> 所有内容均已落地为可运行代码并通过测试验证(pytest 53 用例全绿),与原方案不一致处以本文为准。
> 版本:v3.1 · 2026-08
---
## 一、补充概览
v3.0 方案交付了"能跑的最小完整系统"。v3.1 在此之上完成四个方向的增强:
1. **交互与展示深化**:WebUI 从"能用"升级到"企业级产品形态",演化分析从单一图表升级为七模块图表体系;
2. **应用能力扩展**:结构化简历模板与导出、批量人岗匹配、语义相似匹配通道;
3. **运行时工程化**:设置中心、Token 鉴权、SQLite 状态库、按需下载的打包策略、静态缓存控制;
4. **诚实性维护**:多处指标口径修正、测试隔离加固、文档与实现对齐。
---
## 二、前端体系(WebUI 2.0)
### 2.1 企业级设计系统
- 布局重构:**顶部品牌栏(56px)+ 横向导航条(46px,居中、选中态三重信号:主色加粗/浅蓝底/3px 指示条)**,替代原左侧深色边栏,主内容全宽。
- 设计 token:主色 #2f54eb、Arco 系语义色、8px 间距网格、按钮四态/tag/卡片/弹窗/toast/空态/骨架屏统一组件;图标全部内联 SVG,**零外部依赖、断网可用**。
- 静态资源缓存控制:非 /api 响应统一 `Cache-Control: no-cache` + 资源版本号,杜绝"浏览器拿旧前端"问题。
- **统一系统弹窗**(sysDialog 组件族:sysPrompt/sysConfirm/sysAlert/sysCopy),前端已无任何浏览器原生 alert/prompt/confirm。
- **URL 路由**:视图与图谱节点写入 location.hash(`#view=xxx&node=xxx`),刷新/分享可恢复到同一视图同一节点。
### 2.2 全景图谱工作台
- 节点四型分色分形状(Job/Skill/Industry/Scenario、待审核虚线框),必备实线/加分虚线 + 文字标签双编码,选中节点一跳高亮。
- 左上工具条(缩放/适应/筛选/标签开关/整理布局)、左下小地图与图例、导出 PNG、保存视图/分享链接。
- **右侧详情面板**(320px 可滚动):基础信息、关联节点(可点击跳转+返回)、证据来源(点击弹 JD 原文)、技能热度 sparkline、审核留痕。
- **可编辑**:节点属性编辑弹窗(写后端并留痕)、备注增删、候选节点确认/驳回。
- **连线类型复选框**:必备/加分/场景/行业四类边独立开关(默认全开),选中高亮与之一致。
- 右下"智能建议"浮层、底部演化逻辑链 chips。
### 2.3 首次使用引导
- 首次启动自动弹出"初始设置"弹窗(同设置面板),完成标记 `guided` 持久化在服务端 `config.json`(换浏览器不重弹);保存/跳过均可完成。
---
## 三、岗位演化页:七模块图表体系
信息结构自顶向下逐级下钻:
| 模块 | 内容 | 数据源 |
|---|---|---|
| ① 全部岗位演化热力图 | 岗位×季度矩阵,色深=事件数(开方缩放+封顶色阶),悬停看类型分布,点击行切换岗位 | /api/graph events |
| ② 统计条 | 当前必备/加分技能数、近 4 季度新增/退出、证据 JD 总量 | 岗位定义+事件 |
| ③ 能力画像时点对比器 | 任意两日期技能集合 diff:已退出(红)/保留(灰)/新增(绿),重要性升级带↑,点击跳图谱 | 新接口 `GET /api/jobs/{id}/snapshot?at=` |
| ④ 变更类型构成堆叠柱状图 | 每季度 新增/增强/减弱/退出 四色堆叠 | 事件 |
| ⑤ 技能生命周期甘特图 | 每技能一行,横条=生效区间(必备蓝/加分青),圆点=变更事件;读出"Struts 2019 进场 2022 退场"编年史 | 边 intervals |
| ⑥ 技能热度走势 | **每季度实际生效的技能关系数**(按 REQUIRES 边生效区间统计) | 边 intervals |
| ⑦ 事件时间轴 | 四类事件分色,点击弹证据 JD 原文,时间轴限高滚动 | 事件+source_ids |
**指标口径修正**:原"累计新增−退出"算法基线错误(忽略技能存量,导致曲线贴底看似无数据),v3.1 改为按生效区间统计真实活跃数,已用线上数据验证。
---
## 四、人岗匹配增强
### 4.1 技能归一化四级容错
1. 精确/别名(大小写不敏感):`redis→Redis`、`K8s→Kubernetes`;
2. 包含匹配(取最长命中):`微服务架构→微服务`、`Java语言→Java`(`JavaScript` 不被截胡);
3. 类别迁移提示:同类别技能作为"可迁移技能"展示;
4. **语义召回(v3.1 新增,可选增强)**:词表未命中时经向量相似度兜底(阈值 0.62,经真实数据标定——正确拦截 `JVM调优→模型微调` 错配与垃圾输入)。
诊断结果页补充"与目标岗位无关的技能(不计入评分)"展示区,消除"我加的技能哪去了"的困惑。
### 4.2 结构化简历模板与导出
- 解析增强:性别/学历/家庭住址识别(住址脱敏)、20+ 常见标题**智能分节**、**证件照提取**(docx 内嵌图 / pdf 页面图,data URI 返回)。
- 第 3 步左栏渲染**标准表格风简历**:左上照片、标签-值信息格、分节行(蓝底标题列),技能按匹配结论三色标注。
- **DOCX 导出**(`POST /api/resume/export`):照片嵌入 + 信息表 + 分节正文(命中技能加粗上色)+ 人岗匹配诊断摘要,一键下载。
- 上传支持 .txt/.docx/.pdf(≤5MB,临时文件即解析即删)。
### 4.3 批量匹配(企业服务雏形)
- 新接口 `POST /api/match/batch`:≤50 份简历对同一岗位批量打分,单份失败不拖垮整批。
- 前端"单人诊断 / 批量匹配"子页签:**拖拽文件/文件夹(递归遍历)、多选、整目录导入**三种添加方式;结果表按总分降序(候选人/总分进度条/命中/缺口/结论)。
- **CSV 批量导出**(14 列含四维分,带 BOM,Excel 中文不乱码)。
---
## 五、语义向量通道(BGE-M3)
- 双模式可选:`local`(本地权重,sentence-transformers 懒加载)/ `api`(联网 Embedding API,默认 DashScope 兼容模式 text-embedding-v4);任一就绪即启用,失败静默回退词表模式。
- 就绪检测:权重目录文件完整性(config.json + 权重文件 + 分词器)+ 依赖可导入,五字段状态经 /api/health 与 /api/config 暴露。
- 本机已实配:BGE-M3 权重 4.3GB(ModelScope 下载至 `models/bge-m3`),实测 `数据分析报表→数据分析(0.889)`、`写微服务→微服务(0.856)` 等正确命中。
---
## 六、设置中心与运行时工程
- **设置面板**(顶栏 ⚙,加宽 780px):服务监听(127.0.0.1/0.0.0.0 + 端口 + 访问令牌)、LLM API(星火/OpenAI 兼容,密钥脱敏回显)、语义向量(双模式 + 下载引导)。
- **config.json 运行时设置**:白名单键持久化,优先级 config.json > config.yaml > 默认值;LLM/令牌/语义配置保存即热生效,监听/端口需重启(面板提供"保存并重启",os.execv 原地重启)。
- **Token 鉴权**:设置令牌后 /api/* 需 `X-Access-Token` 头(health 与只读 GET /api/config 豁免),前端自动携带,401 自动弹设置引导。
- **双横幅提示**:LLM 离线回退 / 语义模型未就绪,同款橙色横幅上下排列,可独立关闭,配置后自动消失。
- **一键下载并配置**:`POST /api/semantic/setup` 异步管线(装依赖→镜像站下载权重→自动写配置),进度轮询(阶段+日志),幂等防并发——**打包只含基础环境(~400MB),重依赖按需下载**;`INCLUDE_EMBED=1` 全量镜像变体保留为断网交付选项。
---
## 七、SQLite 应用状态库
`data/careernexus.db`(标准库 sqlite3、WAL、零新增依赖):
| 表 | 内容 | 消费方 |
|---|---|---|
| audit_log | 审核/编辑留痕(时间/节点/动作/操作人/改前改后) | 图谱详情留痕区、驾驶舱消息流、/api/audit |
| review_candidates | 新岗位待审核候选(与正式图谱隔离) | 审核台、候选节点、待办计数 |
| tasks | 异步任务状态,重启可恢复,中断自动标记失败 | 任务轮询 |
首次启动自动从旧 JSON 文件迁移种子(幂等,原文件保留)。语料(jd_raw JSONL)、图谱快照(graph.json)、配置(config.json)仍留文件——**"随运行增改的状态进库,语料与快照留文件"**。
---
## 八、工程与验证现状(2026-08 实测)
- **pytest 53 用例全绿**,覆盖率 78%(新增:批量匹配、语义双模式、一键配置、结构化简历与导出、存储层、设置与鉴权等专项)。
- 测试卫生修复:路径隔离(不再污染真实 config.json)、幂等快路径(单测 150s→1.5s)。
- 装配层重构:`main.py` 瘦身为 10 行入口,`app/bootstrap.py` 承担启动装配;全工程 32 个 Python 模块、10 个子包。
- 三项指标维持:JD 解析 100% / 简历提取 100% / 人岗匹配 100%(scripts/evaluate.py 可复现)。
- Neo4j 可选组件:compose `advanced` profile + `scripts/sync_neo4j.py`(MERGE 幂等,--dry-run 可预览);Docker 默认镜像轻量离线,语义变体可选构建。
## 九、待办(下一步)
- Docker 镜像实际构建验证(Dockerfile 已就绪,未实机构建)。
- Neo4j 真实容器同步实测(当前 dry-run 验证)。
- 项目介绍 PPT(≤10 页)与 ≤10 分钟演示视频(按方案第十章演示脚本录制)。
*(补充完)*
+102
View File
@@ -0,0 +1,102 @@
@echo off
chcp 65001 >nul
REM ============================================================
REM CareerNexus 职联 初始化脚本(Windows)
REM 作用:建虚拟环境 → 装依赖 → 写离线默认配置(触发首次引导)→ 生成数据集
REM 用法:双击运行,或在 cmd 中执行 init.bat
REM ============================================================
setlocal enabledelayedexpansion
cd /d "%~dp0"
echo ==========================================
echo CareerNexus 职联 · 初始化
echo ==========================================
REM ---------- 1. 检查 Python(跳过 WindowsApps 商店占位 stub) ----------
set "PY="
for %%c in (python python3 py) do (
if not defined PY (
where %%c >nul 2>nul
if not errorlevel 1 (
for /f "delims=" %%p in ('where %%c 2^>nul ^| findstr /v /i "WindowsApps"') do (
if not defined PY (
for /f "tokens=2" %%v in ('"%%p" --version 2^>^&1') do (
set "PY=%%p"
set "PYVER=%%v"
)
)
)
)
)
)
if not defined PY (
echo [错误] 未找到可用的 Python 3.11+(商店占位 stub 不算),请安装后加入 PATH
pause
exit /b 1
)
echo [1/5] Python 版本: %PYVER% (%PY%)
REM ---------- 2. 创建虚拟环境 ----------
if not exist ".venv\Scripts\python.exe" (
echo [2/5] 创建虚拟环境 .venv ...
"%PY%" -m venv .venv
if errorlevel 1 (
echo [错误] 虚拟环境创建失败
pause
exit /b 1
)
) else (
echo [2/5] 虚拟环境已存在,跳过
)
set VENV_PY=.venv\Scripts\python.exe
REM ---------- 3. 安装依赖(镜像站优先,失败回退官方源) ----------
echo [3/5] 安装依赖(清华镜像站)...
%VENV_PY% -m pip install -q -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple
if errorlevel 1 (
echo 镜像站失败,改用官方源重试...
%VENV_PY% -m pip install -q -r requirements.txt
if errorlevel 1 (
echo [错误] 依赖安装失败,请检查网络
pause
exit /b 1
)
)
REM ---------- 4. 写入离线默认配置(config.json) ----------
REM mode=demo 离线演示模式;guided=false 使首次启动弹出"初始设置"引导;
REM 已有配置先备份为 config.json.bak
if exist config.json (
echo [4/5] 检测到已有 config.json,备份为 config.json.bak
copy /y config.json config.json.bak >nul
)
echo [4/5] 写入离线默认配置 config.json ...
(
echo {
echo "mode": "demo",
echo "guided": false,
echo "embed_provider": "local"
echo }
) > config.json
REM ---------- 5. 生成自备数据集 ----------
if not exist "data\jd_raw" (
echo [5/5] 生成自备数据集(seed=2026)...
%VENV_PY% scripts\generate_dataset.py
if errorlevel 1 (
echo [错误] 数据集生成失败
pause
exit /b 1
)
) else (
echo [5/5] 数据集已存在,跳过(需重建请运行 %VENV_PY% scripts\generate_dataset.py)
)
echo.
echo ==========================================
echo 初始化完成!当前为离线演示模式
echo 启动:%VENV_PY% main.py
echo 首次启动会弹出"初始设置",可在其中配置
echo 星火 LLM 密钥与语义向量模型(亦可跳过)
echo ==========================================
pause
+81
View File
@@ -0,0 +1,81 @@
#!/usr/bin/env bash
# ============================================================
# CareerNexus 职联 初始化脚本(Linux / macOS / Git Bash)
# 作用:建虚拟环境 → 装依赖 → 写离线默认配置(触发首次引导)→ 生成数据集
# 用法:bash init.sh 或 chmod +x init.sh && ./init.sh
# ============================================================
set -e
cd "$(dirname "$0")"
echo "=========================================="
echo " CareerNexus 职联 · 初始化"
echo "=========================================="
# ---------- 1. 检查 Python(跳过 WindowsApps 商店占位 stub) ----------
PY=""
for cand in python python3 py; do
command -v "$cand" >/dev/null 2>&1 || continue
# 跳过 Microsoft Store 占位 stub(运行 --version 无输出)
case "$(command -v "$cand")" in *WindowsApps*) continue;; esac
ver=$("$cand" --version 2>/dev/null)
if [ -n "$ver" ]; then PY="$cand"; PYVER="$ver"; break; fi
done
if [ -z "$PY" ]; then
echo "[错误] 未找到可用的 Python 3.11+(检测到商店占位 stub 不算)"
echo " 请安装 Python 并加入 PATH,或安装后重试"
exit 1
fi
echo "[1/5] Python 版本: $PYVER ($PY)"
# ---------- 2. 创建虚拟环境 ----------
if [ ! -f ".venv/bin/python" ] && [ ! -f ".venv/Scripts/python.exe" ]; then
echo "[2/5] 创建虚拟环境 .venv ..."
$PY -m venv .venv
else
echo "[2/5] 虚拟环境已存在,跳过"
fi
# Windows venv 与 POSIX venv 路径兼容
if [ -f ".venv/bin/python" ]; then
VENV_PY=.venv/bin/python
else
VENV_PY=.venv/Scripts/python.exe
fi
# ---------- 3. 安装依赖(镜像站优先,失败回退官方源) ----------
echo "[3/5] 安装依赖(清华镜像站)..."
if ! "$VENV_PY" -m pip install -q -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple; then
echo "镜像站失败,改用官方源重试..."
"$VENV_PY" -m pip install -q -r requirements.txt
fi
# ---------- 4. 写入离线默认配置(config.json) ----------
# mode=demo 离线演示模式;guided=false 使首次启动弹出"初始设置"引导;
# 已有配置先备份为 config.json.bak
if [ -f config.json ]; then
echo "[4/5] 检测到已有 config.json,备份为 config.json.bak"
cp -f config.json config.json.bak
fi
echo "[4/5] 写入离线默认配置 config.json ..."
cat > config.json <<'EOF'
{
"mode": "demo",
"guided": false,
"embed_provider": "local"
}
EOF
# ---------- 5. 生成自备数据集 ----------
if [ ! -d "data/jd_raw" ]; then
echo "[5/5] 生成自备数据集(seed=2026)..."
"$VENV_PY" scripts/generate_dataset.py
else
echo "[5/5] 数据集已存在,跳过(需重建请运行 $VENV_PY scripts/generate_dataset.py)"
fi
echo ""
echo "=========================================="
echo " 初始化完成!当前为离线演示模式"
echo " 启动:$VENV_PY main.py"
echo " 首次启动会弹出「初始设置」,可在其中配置"
echo " 星火 LLM 密钥与语义向量模型(亦可跳过)"
echo "=========================================="
+11
View File
@@ -0,0 +1,11 @@
"""CareerNexus 职联 唯一入口。
用法:python main.py
功能:加载配置 → 构建/加载图谱 → 启动服务(默认 127.0.0.1:9008) → 自动打开浏览器。
启动逻辑见 app/bootstrap.py;业务模块见 app/ 下各子包。
"""
from app.bootstrap import run
if __name__ == "__main__":
run()
+329
View File
@@ -0,0 +1,329 @@
{
"jd_extraction_accuracy": 1.0,
"resume_extraction_accuracy": 1.0,
"match_accuracy": 1.0,
"jd_detail": {
"accuracy": 1.0,
"title_accuracy": 1.0,
"fields": {
"required_skills": {
"precision": 1.0,
"recall": 1.0,
"f1": 1.0
},
"preferred_skills": {
"precision": 1.0,
"recall": 1.0,
"f1": 1.0
},
"responsibilities": {
"precision": 1.0,
"recall": 1.0,
"f1": 1.0
},
"scenarios": {
"precision": 1.0,
"recall": 1.0,
"f1": 1.0
}
},
"samples": 144,
"bad_lines": 0
},
"resume_detail": {
"resume_full.txt": {
"precision": 1.0,
"recall": 1.0,
"f1": 1.0,
"extra": [],
"missing": []
},
"resume_partial.txt": {
"precision": 1.0,
"recall": 1.0,
"f1": 1.0,
"extra": [],
"missing": []
},
"resume_mismatch.txt": {
"precision": 1.0,
"recall": 1.0,
"f1": 1.0,
"extra": [],
"missing": []
},
"resume_frontend_full.txt": {
"precision": 1.0,
"recall": 1.0,
"f1": 1.0,
"extra": [],
"missing": []
},
"resume_data_partial.txt": {
"precision": 1.0,
"recall": 1.0,
"f1": 1.0,
"extra": [],
"missing": []
},
"resume_llm_full.txt": {
"precision": 1.0,
"recall": 1.0,
"f1": 1.0,
"extra": [],
"missing": []
},
"resume_legacy_mismatch.txt": {
"precision": 1.0,
"recall": 1.0,
"f1": 1.0,
"extra": [],
"missing": []
}
},
"match_detail": [
{
"job": "Java开发工程师",
"expected": true,
"score": 76.67,
"ok": true
},
{
"job": "Java开发工程师",
"expected": false,
"score": 6.67,
"ok": true
},
{
"job": "Python开发工程师",
"expected": true,
"score": 75.0,
"ok": true
},
{
"job": "Python开发工程师",
"expected": false,
"score": 6.67,
"ok": true
},
{
"job": "前端工程师",
"expected": true,
"score": 75.0,
"ok": true
},
{
"job": "前端工程师",
"expected": false,
"score": 6.67,
"ok": true
},
{
"job": "数据工程师",
"expected": true,
"score": 75.0,
"ok": true
},
{
"job": "数据工程师",
"expected": false,
"score": 6.67,
"ok": true
},
{
"job": "算法工程师",
"expected": true,
"score": 75.0,
"ok": true
},
{
"job": "算法工程师",
"expected": false,
"score": 6.67,
"ok": true
},
{
"job": "数据分析师",
"expected": true,
"score": 75.0,
"ok": true
},
{
"job": "数据分析师",
"expected": false,
"score": 10.0,
"ok": true
},
{
"job": "测试开发工程师",
"expected": true,
"score": 75.0,
"ok": true
},
{
"job": "测试开发工程师",
"expected": false,
"score": 6.67,
"ok": true
},
{
"job": "运维工程师(SRE)",
"expected": true,
"score": 75.0,
"ok": true
},
{
"job": "运维工程师(SRE)",
"expected": false,
"score": 6.67,
"ok": true
},
{
"job": "嵌入式工程师",
"expected": true,
"score": 75.0,
"ok": true
},
{
"job": "嵌入式工程师",
"expected": false,
"score": 6.67,
"ok": true
},
{
"job": "物联网工程师",
"expected": true,
"score": 75.0,
"ok": true
},
{
"job": "物联网工程师",
"expected": false,
"score": 6.67,
"ok": true
},
{
"job": "NLP工程师",
"expected": true,
"score": 75.0,
"ok": true
},
{
"job": "NLP工程师",
"expected": false,
"score": 6.67,
"ok": true
},
{
"job": "CV工程师",
"expected": true,
"score": 75.0,
"ok": true
},
{
"job": "CV工程师",
"expected": false,
"score": 6.67,
"ok": true
},
{
"job": "云原生工程师",
"expected": true,
"score": 75.0,
"ok": true
},
{
"job": "云原生工程师",
"expected": false,
"score": 6.67,
"ok": true
},
{
"job": "AI产品经理",
"expected": true,
"score": 75.0,
"ok": true
},
{
"job": "AI产品经理",
"expected": false,
"score": 6.67,
"ok": true
},
{
"job": "Go开发工程师",
"expected": true,
"score": 75.0,
"ok": true
},
{
"job": "Go开发工程师",
"expected": false,
"score": 6.67,
"ok": true
},
{
"job": "前端工程师",
"expected": true,
"score": 70.0,
"ok": true
},
{
"job": "数据分析师",
"expected": true,
"score": 70.0,
"ok": true
},
{
"job": "运维工程师(SRE)",
"expected": true,
"score": 70.0,
"ok": true
},
{
"job": "前端工程师",
"expected": true,
"score": 90.0,
"ok": true
},
{
"job": "数据分析师",
"expected": true,
"score": 80.0,
"ok": true
},
{
"job": "数据分析师",
"expected": false,
"score": 20.0,
"ok": true
}
],
"real_jd": {
"accuracy": 0.9995,
"title_accuracy": 1.0,
"fields": {
"required_skills": {
"precision": 1.0,
"recall": 0.9988,
"f1": 0.9994
},
"preferred_skills": null,
"scenarios": {
"precision": 1.0,
"recall": 1.0,
"f1": 1.0
}
},
"samples": 200,
"bad_lines": 0
},
"real_resume": {
"accuracy": 1.0,
"name_accuracy": 1.0,
"phone_accuracy": 1.0,
"samples": 50,
"phone_samples": 50,
"empty_skill_resumes": 28
}
}
+11
View File
@@ -0,0 +1,11 @@
fastapi>=0.110
uvicorn[standard]>=0.29
pydantic>=2.6
networkx>=3.2
python-docx>=1.1
pypdf>=4.0
python-multipart>=0.0.9
httpx>=0.27
pytest>=8.0
pytest-cov>=5.0
# 可选(不默认安装):Neo4j 官方驱动,仅 scripts/sync_neo4j.py 需要 —— pip install neo4j
+329
View File
@@ -0,0 +1,329 @@
"""真实数据集构建脚本:把 datasets/XH-202621/data 下的公开数据集加工成 data/real/。
四个产物:
1. data/real/autoweaks_jd/jd_sample.jsonl + gold_answers.json
—— AutoWeakS(arXiv:2012.14234)真实 JD,去分词 + 技术岗过滤 + 比例抽样,
gold 为"离线抽取器初标",人工抽检修正在 data/real/README.md 留痕;
2. data/real/aistudio_resumes/*.txt + gold.json
—— AIStudio CC0 中文简历标注集抽 50 条渲染为简历文本,保留 PII 以验证脱敏链路;
3. data/real/batch_demo_resumes/*.txt
—— chinese_resume 5001 条结构化简历抽 12 条渲染后经 mask_text 脱敏(原始 PII 不落盘);
4. data/real/esco_reference/
—— ISCO/ESCO 职业分类锚点文件(只拷关键 labels,不整目录拷贝)。
源数据目录只读。random.seed 固定,可复现重建。
"""
from __future__ import annotations
import csv
import json
import random
import re
import shutil
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from app.extract.base import SkillMatcher, load_lexicon # noqa: E402
from app.match.resume import mask_text # noqa: E402
SRC = ROOT / "datasets" / "XH-202621" / "data"
DST = ROOT / "data" / "real"
SEED = 2026
JD_TOTAL, JD_TENCENT = 200, 160 # 腾讯:京东 ≈ 4:1(与源文件行数比一致)
RESUME_N = 50
DEMO_N = 12
# 技术岗过滤关键词(2019-2020 年代 JD 常见写法)
TECH_RE = re.compile(
r"Python|Java|JavaScript|PHP|前端|后端|软件|算法|数据|开发|云|AI|机器学习|深度学习"
r"|测试|运维|Android|iOS|Go(?:lang)?|Hadoop|Spark|Hive|Kafka|Redis|MySQL|Linux|数据库"
r"|NLP|CV|推荐系统|大数据", re.IGNORECASE)
_PHONE_RE = re.compile(r"^1[3-9]\d{9}$")
_LATIN_RE = re.compile(r"[A-Za-z0-9+#./\-]+")
def detokenize(line: str) -> str:
"""去分词:CJK 之间、CJK 与拉丁 token 之间去空格,拉丁-拉丁保留单空格。"""
toks = line.strip().split()
if not toks:
return ""
out = toks[0]
prev = toks[0]
for t in toks[1:]:
if _LATIN_RE.fullmatch(prev) and _LATIN_RE.fullmatch(t):
out += " " + t
else:
out += t
prev = t
return out
# ---------- 1. AutoWeakS → 真实 JD 测试集 ----------
_TITLE_RULES: list[tuple[re.Pattern, str]] = [
(re.compile(r"算法|机器学习|深度学习|NLP|自然语言|计算机视觉|CV|推荐算法", re.I), "算法工程师"),
(re.compile(r"前端|web开发", re.I), "前端开发工程师"),
(re.compile(r"后端|服务端|后台开发"), "后端开发工程师"),
(re.compile(r"测试开发"), "测试开发工程师"),
(re.compile(r"测试"), "测试工程师"),
(re.compile(r"运维|SRE|devops", re.I), "运维工程师"),
(re.compile(r"android|安卓", re.I), "Android开发工程师"),
(re.compile(r"ios", re.I), "iOS开发工程师"),
(re.compile(r"数据分析|商业分析|bi工程师", re.I), "数据分析师"),
(re.compile(r"数据开发|大数据|数据仓库|数仓|etl", re.I), "数据开发工程师"),
(re.compile(r"产品经理|产品策划"), "产品经理"),
(re.compile(r"运营"), "运营专员"),
(re.compile(r"开发|软件"), "软件开发工程师"),
]
def guess_title(text: str) -> str:
"""真实 JD 无岗位名称行,按关键词启发式归一个岗位名(在 README 如实标注)。"""
head = text[:200]
for pat, title in _TITLE_RULES:
if pat.search(head):
return title
return "技术专员"
def build_autoweaks(matcher: SkillMatcher) -> None:
rng = random.Random(SEED)
out_dir = DST / "autoweaks_jd"
out_dir.mkdir(parents=True, exist_ok=True)
pools: dict[str, list[str]] = {}
for fn, tag in [("Tecent_job_phrase.txt", "腾讯"), ("JD_job_phrase.txt", "京东")]:
lines = (SRC / "autoweaks" / fn).read_text(encoding="utf-8").splitlines()
seen, pool = set(), []
for ln in lines:
text = detokenize(ln)
# 技术岗过滤 + 至少命中 1 个词表技能(保证可评测)+ 去重
if len(text) < 60 or not TECH_RE.search(text):
continue
if not matcher.find_names(text):
continue
if text in seen:
continue
seen.add(text)
pool.append(text)
pools[tag] = pool
picked: list[tuple[str, str]] = [] # (company, text)
for tag, n in [("腾讯", JD_TENCENT), ("京东", JD_TOTAL - JD_TENCENT)]:
pool = pools[tag]
rng.shuffle(pool)
picked.extend((tag, t) for t in pool[:n])
rng.shuffle(picked)
records, gold = [], {}
for i, (company, text) in enumerate(picked, start=1):
jid = f"AW{i:04d}"
title = guess_title(text)
pub = f"2019-{rng.randint(1, 12):02d}-{rng.randint(1, 28):02d}"
records.append({
"jid": jid, "job_title": title, "company": company,
"publish_date": pub, "source": "autoweaks", "url": "",
"text": text,
})
# silver gold:离线匹配器初标(全文技能 + 场景),人工抽检修正见 README
gold[jid] = {
"job_title": title,
"responsibilities": [],
"required_skills": matcher.find_names(text),
"preferred_skills": [],
"scenarios": matcher.find_scenarios(text),
}
with (out_dir / "jd_sample.jsonl").open("w", encoding="utf-8") as f:
for r in records:
f.write(json.dumps(r, ensure_ascii=False) + "\n")
(out_dir / "gold_answers.json").write_text(
json.dumps(gold, ensure_ascii=False, indent=1), encoding="utf-8")
print(f"[autoweaks] 腾讯池 {len(pools['腾讯'])} 京东池 {len(pools['京东'])}"
f" → 抽样 {len(records)} 条")
# ---------- 2. AIStudio → 真实简历集 ----------
def _degree(教育经历: list[dict]) -> str:
order = ["博士", "硕士", "研究生", "本科", "大专", "专科", "MBA"]
degs = [e.get("学位", "") for e in 教育经历]
for d in order:
if any(d in x for x in degs):
return d
return degs[-1] if degs else ""
def render_aistudio_resume(rec: dict) -> str:
"""把结构化字段渲染为我们的简历文本格式(保留 PII,供脱敏链路验证)。"""
lines = [f"姓名:{rec.get('姓名', '')}"]
if rec.get("电话"):
lines.append(f"电话:{rec['电话']}")
deg = _degree(rec.get("教育经历", []))
if deg:
lines.append(f"学历:{deg}")
if rec.get("籍贯"):
lines.append(f"籍贯:{rec['籍贯']}")
lines.append("\n教育经历")
for e in rec.get("教育经历", []):
lines.append(f"- {e.get('毕业时间', '')} {e.get('毕业院校', '')} {e.get('学位', '')}".strip())
if rec.get("工作经历"):
lines.append("\n工作经历")
for w in rec["工作经历"]:
head = " ".join(x for x in [w.get("工作时间", ""), w.get("工作单位", ""),
w.get("职务", "")] if x)
lines.append(f"- {head}".strip("- "))
duty = (w.get("工作内容") or "").strip()
if duty:
lines.append(f" {duty}")
if rec.get("项目经历"):
lines.append("\n项目经历")
for p in rec["项目经历"]:
head = " ".join(x for x in [p.get("项目名称", ""), p.get("项目时间", "")] if x)
lines.append(f"- {head}".strip("- "))
resp = (p.get("项目责任") or "").strip()
if resp:
lines.append(f" {resp}")
return "\n".join(lines) + "\n"
def build_aistudio(matcher: SkillMatcher) -> None:
rng = random.Random(SEED + 1)
out_dir = DST / "aistudio_resumes"
out_dir.mkdir(parents=True, exist_ok=True)
data = json.loads((SRC / "aistudio" / "train.json").read_text(encoding="utf-8"))
# 优先工作经历/项目经历含技术内容、且电话合法的条目
cands = []
for key, rec in data.items():
blob = json.dumps([rec.get("工作经历"), rec.get("项目经历")], ensure_ascii=False)
if not TECH_RE.search(blob):
continue
if not _PHONE_RE.match(str(rec.get("电话", ""))):
continue
if not rec.get("姓名"):
continue
cands.append((key, rec))
rng.shuffle(cands)
picked = cands[:RESUME_N]
gold = {}
for i, (key, rec) in enumerate(picked, start=1):
fname = f"aistudio_{i:03d}.txt"
text = render_aistudio_resume(rec)
(out_dir / fname).write_text(text, encoding="utf-8")
gold[fname] = {
"name": rec["姓名"],
"phone": rec["电话"],
"skills": matcher.find_names(text), # 初标,人工抽检复核见 README
"source_id": key,
}
(out_dir / "gold.json").write_text(
json.dumps(gold, ensure_ascii=False, indent=1), encoding="utf-8")
print(f"[aistudio] 候选 {len(cands)} → 抽样 {len(picked)} 份")
# ---------- 3. chinese_resume → 批量演示素材(脱敏后落盘) ----------
def render_chinese_resume(row: dict) -> str:
"""按"姓名/性别/手机/邮箱/求职意向/教育/技能清单/自我评价"渲染(未脱敏,仅内存)。"""
lines = [
f"姓名:{row['姓名']}",
f"性别:{row['性别']}",
f"手机:{row['电话']}",
f"邮箱:{row['邮箱']}",
f"求职意向:{row['意向岗位']}",
"",
"教育背景",
f"- 学历:{row['学历层次']}({row['院校类别']},{row['专业类别']})",
f"- 英语水平:{row['英语水平']}",
"",
"技能清单",
]
for col in ["编程语言", "前端技术", "后端技术", "数据库", "云计算/运维",
"数据与算法", "移动开发", "测试工具"]:
val = row.get(col, "")
if val and val != "NULL":
prof = row.get(col + "熟练度", "")
items = "、".join(
f"{s}({p})" if p else s
for s, p in zip(val.split(","), (prof.split(",") + [""] * 8)))
lines.append(f"- {col}:{items}")
lines += [
"",
"自我评价",
f"具有{row['大型企业工作经验']}大型企业、{row['中型企业工作经验']}中型企业工作经验,"
f"参与过{row['大规模项目']}个大规模项目,求职方向为{row['意向岗位']}。",
]
return "\n".join(lines) + "\n"
def build_demo_resumes() -> None:
rng = random.Random(SEED + 2)
out_dir = DST / "batch_demo_resumes"
out_dir.mkdir(parents=True, exist_ok=True)
with (SRC / "chinese_resume" / "Chinese_resume_data.csv").open(encoding="utf-8") as f:
rows = [r for r in csv.DictReader(f) if r.get("筛选结果") == "通过"]
by_intent: dict[str, list[dict]] = {}
for r in rows:
by_intent.setdefault(r["意向岗位"], []).append(r)
# 覆盖不同意向岗位:每类取 1 条,剩余名额补足到 DEMO_N
intents = sorted(by_intent, key=lambda k: -len(by_intent[k]))
picked: list[dict] = []
for intent in intents:
if len(picked) >= DEMO_N:
break
picked.append(rng.choice(by_intent[intent]))
rest = [r for intent in intents for r in by_intent[intent] if r not in picked]
rng.shuffle(rest)
picked.extend(rest[: DEMO_N - len(picked)])
for i, row in enumerate(picked, start=1):
raw = render_chinese_resume(row)
masked = mask_text(raw) # 脱敏后才落盘,原始 PII 不写入 data/
slug = re.sub(r"\W+", "", row["意向岗位"])
(out_dir / f"demo_{i:02d}_{slug}.txt").write_text(masked, encoding="utf-8")
print(f"[chinese_resume] 意向岗位 {len(intents)} 类 → 脱敏渲染 {len(picked)} 份")
# ---------- 4. ISCO/ESCO 锚点 ----------
ESCO_FILES = [
"labels_isco_codes.txt",
"labels_isco_labels.txt",
"labels_esco_code.txt",
"labels_esco_occupation.txt",
]
def build_esco_reference() -> None:
out_dir = DST / "esco_reference"
out_dir.mkdir(parents=True, exist_ok=True)
for name in ESCO_FILES:
shutil.copyfile(SRC / "isco_esco_taxonomy" / name, out_dir / name)
print(f"[esco] 拷贝锚点文件 {len(ESCO_FILES)} 个")
def main() -> None:
if not SRC.exists():
raise SystemExit(f"源数据目录不存在: {SRC}")
matcher = SkillMatcher(load_lexicon())
build_autoweaks(matcher)
build_aistudio(matcher)
build_demo_resumes()
build_esco_reference()
print(f"全部产物写入 {DST}")
if __name__ == "__main__":
main()
+251
View File
@@ -0,0 +1,251 @@
"""指标复现脚本:真实“从文本抽取 → 与 gold 比对”流程,不硬编码任何答案。
三项硬指标(合成测试集):
1. JD 解析准确率 —— 对 data/testset/jd_testset.jsonl 重新抽取,与 gold_answers.json
算字段级 P/R/F1(必备技能/加分技能/职责/场景 micro-F1 + 岗位名称准确率);
2. 简历提取准确率 —— 对 data/resumes/*.txt 解析技能,与 gold.json 算 F1;
3. 人岗匹配准确率 —— 对 data/testset/match_gold.json 标注对跑匹配引擎,
判定“命中预期匹配/不匹配”的比例。
两个真实数据维度(data/real/,与合成集分开报告、不混算):
4. 真实 JD(AutoWeakS 初标+抽检 silver gold)—— 对 jd_sample.jsonl 重新抽取,
与 gold_answers.json 算技能/场景字段级 F1;
5. 真实简历(AIStudio CC0)—— 对 50 份简历解析,姓名/电话命中率 + 技能 F1。
输出 reports/metrics.json 与控制台报告。
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from app.extract.offline import OfflineExtractor # noqa: E402
from app.extract.base import SkillMatcher, load_lexicon # noqa: E402
from app.graph.tkg import TemporalKG # noqa: E402
from app.ingest.loader import load_jd_file # noqa: E402
from app.match.engine import MatchEngine # noqa: E402
from app.match.resume import parse_resume # noqa: E402
from app.models import JDRecord # noqa: E402
def prf(tp: int, pred: int, gold: int) -> tuple[float, float, float]:
p = tp / pred if pred else 0.0
r = tp / gold if gold else 0.0
f1 = 2 * p * r / (p + r) if p + r else 0.0
return round(p, 4), round(r, 4), round(f1, 4)
def eval_jd_extraction() -> dict:
"""JD 解析:从文本重新抽取,与标准答案做字段级比对。"""
records, errors = load_jd_file(ROOT / "data" / "testset" / "jd_testset.jsonl")
gold = json.loads((ROOT / "data" / "testset" / "gold_answers.json").read_text(encoding="utf-8"))
extractor = OfflineExtractor(load_lexicon())
fields = ["required_skills", "preferred_skills", "responsibilities", "scenarios"]
tp = {f: 0 for f in fields}
pred = {f: 0 for f in fields}
gold_n = {f: 0 for f in fields}
title_hit = 0
for rec in records:
g = gold[rec.jid]
ext = extractor.extract(rec)
title_hit += int(ext.job_title == g["job_title"])
for f in fields:
got, want = set(getattr(ext, f)), set(g[f])
tp[f] += len(got & want)
pred[f] += len(got)
gold_n[f] += len(want)
detail = {}
f1s = []
for f in fields:
p, r, f1 = prf(tp[f], pred[f], gold_n[f])
detail[f] = {"precision": p, "recall": r, "f1": f1}
f1s.append(f1)
title_acc = round(title_hit / len(records), 4)
# 综合准确率:技能字段权重更高(技能是岗位图谱的核心)
accuracy = round(0.6 * (f1s[0] + f1s[1]) / 2 + 0.2 * f1s[2] + 0.1 * f1s[3] + 0.1 * title_acc, 4)
return {"accuracy": accuracy, "title_accuracy": title_acc, "fields": detail,
"samples": len(records), "bad_lines": len(errors)}
def eval_resume() -> dict:
"""简历技能提取准确率(逐份 F1 取平均)。"""
gold = json.loads((ROOT / "data" / "resumes" / "gold.json").read_text(encoding="utf-8"))
matcher = SkillMatcher(load_lexicon())
f1s, detail = [], {}
for name, g in gold.items():
text = (ROOT / "data" / "resumes" / name).read_text(encoding="utf-8")
profile = parse_resume(text, matcher)
got, want = set(profile.skills), set(g["skills"])
p, r, f1 = prf(len(got & want), len(got), len(want))
f1s.append(f1)
detail[name] = {"precision": p, "recall": r, "f1": f1,
"extra": sorted(got - want), "missing": sorted(want - got)}
return {"accuracy": round(sum(f1s) / len(f1s), 4), "detail": detail}
def eval_match() -> dict:
"""人岗匹配准确率:命中预期匹配/不匹配。"""
graph_path = ROOT / "data" / "graph.json"
if not graph_path.exists():
from app.services.pipeline import run_pipeline
run_pipeline()
kg = TemporalKG.load(graph_path)
by_title = {j.title: j for j in kg.jobs.values()}
pairs = json.loads((ROOT / "data" / "testset" / "match_gold.json").read_text(encoding="utf-8"))
engine = MatchEngine(load_lexicon())
from app.models import ResumeProfile
hit, detail = 0, []
for pair in pairs:
job = by_title.get(pair["job_title"])
if job is None:
continue
profile = ResumeProfile(skills=pair["skills"], years=pair["years"],
scenarios=pair["scenarios"])
result = engine.match(profile, job)
ok = result.is_match == pair["expected_match"]
hit += int(ok)
detail.append({"job": pair["job_title"], "expected": pair["expected_match"],
"score": result.total_score, "ok": ok})
return {"accuracy": round(hit / len(pairs), 4), "pairs": len(pairs), "detail": detail}
def eval_real_jd() -> dict:
"""真实 JD(AutoWeakS):从文本重新抽取,与 silver gold(初标+抽检)字段级比对。
真实 JD 是自由文本、无编号版面,gold 的 responsibilities/preferred_skills 为空,
口径 = 全文词表技能(required_skills)+ 应用场景 + 启发式岗位名,见 data/real/README.md。
"""
records, errors = load_jd_file(ROOT / "data" / "real" / "autoweaks_jd" / "jd_sample.jsonl")
gold = json.loads(
(ROOT / "data" / "real" / "autoweaks_jd" / "gold_answers.json").read_text(encoding="utf-8"))
extractor = OfflineExtractor(load_lexicon())
fields = ["required_skills", "preferred_skills", "scenarios"]
tp = {f: 0 for f in fields}
pred = {f: 0 for f in fields}
gold_n = {f: 0 for f in fields}
title_hit = 0
for rec in records:
g = gold[rec.jid]
ext = extractor.extract(rec)
title_hit += int(ext.job_title == g["job_title"])
for f in fields:
got, want = set(getattr(ext, f)), set(g[f])
tp[f] += len(got & want)
pred[f] += len(got)
gold_n[f] += len(want)
detail, active_f1 = {}, []
for f in fields:
if not gold_n[f] and not pred[f]:
detail[f] = None # 该字段在真实语料中无标注口径,不参与
continue
p, r, f1 = prf(tp[f], pred[f], gold_n[f])
detail[f] = {"precision": p, "recall": r, "f1": f1}
active_f1.append((f, f1))
weights = {"required_skills": 0.7, "preferred_skills": 0.2, "scenarios": 0.1}
wsum = sum(weights[f] for f, _ in active_f1) or 1.0
accuracy = round(sum(weights[f] * f1 for f, f1 in active_f1) / wsum, 4)
return {"accuracy": accuracy,
"title_accuracy": round(title_hit / len(records), 4),
"fields": detail, "samples": len(records), "bad_lines": len(errors)}
def eval_real_resume() -> dict:
"""真实简历(AIStudio CC0):姓名/电话命中率(脱敏形态比对)+ 技能 F1。"""
gold = json.loads(
(ROOT / "data" / "real" / "aistudio_resumes" / "gold.json").read_text(encoding="utf-8"))
matcher = SkillMatcher(load_lexicon())
f1s, name_hit, phone_hit, phone_total = [], 0, 0, 0
empty_skills = 0
for name, g in gold.items():
text = (ROOT / "data" / "real" / "aistudio_resumes" / name).read_text(encoding="utf-8")
profile = parse_resume(text, matcher)
# gold 存明文,解析结果是脱敏形态(姓氏+* / 138****5678),按脱敏形态比对
want_name = g["name"][0] + "*" * (len(g["name"]) - 1)
name_hit += int(profile.name == want_name)
if g.get("phone"):
phone_total += 1
want_phone = g["phone"][:3] + "****" + g["phone"][-2:]
phone_hit += int(profile.phone == want_phone)
got, want = set(profile.skills), set(g["skills"])
if not got and not want:
empty_skills += 1
f1s.append(1.0) # 双方均无词表技能 = 完全一致(真实简历大量非技术内容)
continue
_, _, f1 = prf(len(got & want), len(got), len(want))
f1s.append(f1)
n = len(gold)
return {"accuracy": round(sum(f1s) / n, 4),
"name_accuracy": round(name_hit / n, 4),
"phone_accuracy": round(phone_hit / phone_total, 4) if phone_total else 0.0,
"samples": n, "phone_samples": phone_total,
"empty_skill_resumes": empty_skills}
def main() -> None:
jd = eval_jd_extraction()
resume = eval_resume()
match = eval_match()
real_jd = eval_real_jd()
real_resume = eval_real_resume()
metrics = {
"jd_extraction_accuracy": jd["accuracy"],
"resume_extraction_accuracy": resume["accuracy"],
"match_accuracy": match["accuracy"],
"jd_detail": jd,
"resume_detail": resume["detail"],
"match_detail": match["detail"],
"real_jd": real_jd,
"real_resume": real_resume,
}
out = ROOT / "reports"
out.mkdir(exist_ok=True)
(out / "metrics.json").write_text(
json.dumps(metrics, ensure_ascii=False, indent=1), encoding="utf-8")
print("=" * 56)
print("CareerNexus 指标复现报告")
print("=" * 56)
print(f"JD 解析准确率 : {jd['accuracy']:.2%} (样本 {jd['samples']} 条)")
for f, d in jd["fields"].items():
print(f" - {f:<18} P={d['precision']:.2%} R={d['recall']:.2%} F1={d['f1']:.2%}")
print(f" - 岗位名称准确率 {jd['title_accuracy']:.2%}")
print(f"简历提取准确率 : {resume['accuracy']:.2%}")
for name, d in resume["detail"].items():
print(f" - {name:<22} F1={d['f1']:.2%}")
print(f"人岗匹配准确率 : {match['accuracy']:.2%} (标注对 {match['pairs']} 对)")
print("-" * 56)
print("真实数据维度(data/real/,与合成集分开报告)")
print(f"真实 JD(AutoWeakS 初标+抽检): 综合 F1 {real_jd['accuracy']:.2%}"
f" (样本 {real_jd['samples']} 条)")
for f, d in real_jd["fields"].items():
if d is None:
print(f" - {f:<18} (真实语料无标注口径,不参与)")
else:
print(f" - {f:<18} P={d['precision']:.2%} R={d['recall']:.2%} F1={d['f1']:.2%}")
print(f" - 岗位名称准确率 {real_jd['title_accuracy']:.2%} (启发式标注)")
print(f"真实简历(AIStudio CC0) : 技能 F1 {real_resume['accuracy']:.2%}"
f" 姓名命中 {real_resume['name_accuracy']:.2%}"
f" 电话命中 {real_resume['phone_accuracy']:.2%} ({real_resume['samples']} 份)")
print(f"\n指标已写入 {out / 'metrics.json'}")
target = 0.90
for name, v in [("JD解析", jd["accuracy"]), ("简历提取", resume["accuracy"]),
("人岗匹配", match["accuracy"])]:
status = "达标" if v >= target else "未达标"
print(f"[{status}] {name} {v:.2%} (目标 ≥{target:.0%})")
real_targets = [("真实JD技能F1", real_jd["fields"]["required_skills"]["f1"], 0.80),
("真实姓名命中", real_resume["name_accuracy"], 1.00)]
for name, v, t in real_targets:
status = "达标" if v >= t else "未达标"
print(f"[{status}] {name} {v:.2%} (目标 ≥{t:.0%})")
if __name__ == "__main__":
main()
+932
View File
@@ -0,0 +1,932 @@
"""自备数据集生成器(random.seed(2026) 固定可复现)。
产出:
- data/skills_lexicon.json 技能词表(>=160 技能点:名称/类别/别名/级别/前置技能)
+ 行业词表(7 个行业)与岗位-行业归属
- data/jd_raw/batch_YYYY.jsonl 原始 JD(2019-2026,演化信号烘焙,约 8% 近重复、噪声植入)
每条 JD 含 industry 字段(招聘平台标注的所属行业),文本经多模板族渲染:
职责句式 >=15 套(含时代门槛,2019 年不会出现"大模型"),可选公司介绍/
项目背景/团队福利段落,长度分布 150-500 字。
- data/jd_raw/_meta.json 植入的抄袭簇与噪声清单(供测试核验)
- data/testset/jd_testset.jsonl + gold_answers.json >=120 条带标准答案的 JD
- data/testset/match_gold.json 标注好的人岗匹配对(>=30 对)
- data/resumes/*.txt + gold.json 7 份简历样例(完整/部分/错位)与技能标准答案
- data/reports/*.txt 3 份行业趋势报告样例(与 JD 演化信号相互印证)
- data/review/audit.jsonl 预置审核/编辑留痕种子记录
公平性说明:gold_answers 在生成 JD 文本之前先确定结构(标题/职责/必备/加分/场景),
再据此渲染文本;评测脚本从文本重新抽取再与 gold 比对,不是反向拟合。
"""
from __future__ import annotations
import json
import random
import sys
from datetime import date, timedelta
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from app.extract.offline import DUTY_HEADERS, PREF_HEADERS, REQ_HEADERS, TAIL_HEADERS # noqa: E402
SEED = 2026
rng = random.Random(SEED)
# ---------------------------------------------------------------- 技能词表
# (名称, 类别, [别名], 级别, [前置技能])
SKILLS = [
# 编程语言
("Java", "编程语言", [], "L1", []), ("Python", "编程语言", [], "L1", []),
("Go", "编程语言", ["Golang"], "L1", []), ("C++", "编程语言", [], "L1", ["C"]),
("C", "编程语言", ["C语言"], "L1", []), ("JavaScript", "编程语言", ["JS"], "L1", []),
("TypeScript", "编程语言", ["TS"], "L2", ["JavaScript"]), ("PHP", "编程语言", [], "L1", []),
("Scala", "编程语言", [], "L2", ["Java"]), ("Kotlin", "编程语言", [], "L2", ["Java"]),
("Rust", "编程语言", [], "L3", []), ("SQL", "编程语言", [], "L1", []),
("Shell", "编程语言", ["Bash"], "L1", ["Linux"]), ("R", "编程语言", [], "L2", []),
("MATLAB", "编程语言", [], "L2", []),
# 后端框架
("Spring", "后端框架", [], "L2", ["Java"]), ("Spring Boot", "后端框架", ["SpringBoot"], "L2", ["Spring"]),
("Spring Cloud", "后端框架", ["SpringCloud"], "L3", ["Spring Boot", "微服务"]),
("MyBatis", "后端框架", ["Mybatis"], "L2", ["Java", "SQL"]),
("Django", "后端框架", [], "L2", ["Python"]), ("Flask", "后端框架", [], "L2", ["Python"]),
("FastAPI", "后端框架", [], "L2", ["Python"]), ("Gin", "后端框架", [], "L2", ["Go"]),
("Express", "后端框架", [], "L2", ["Node.js"]), ("NestJS", "后端框架", [], "L3", ["TypeScript"]),
("Netty", "后端框架", [], "L3", ["Java"]), ("Dubbo", "后端框架", [], "L3", ["Java", "微服务"]),
("gRPC", "后端框架", [], "L3", ["微服务"]),
("Struts", "后端框架", [], "L1", ["Java"]), ("Servlet", "后端框架", [], "L1", ["Java"]),
("JSP", "后端框架", [], "L1", ["Java"]), ("SSM", "后端框架", [], "L2", ["Spring", "MyBatis"]),
# 前端
("Vue", "前端", ["Vue.js"], "L2", ["JavaScript"]), ("React", "前端", ["React.js"], "L2", ["JavaScript"]),
("Angular", "前端", ["AngularJS"], "L2", ["TypeScript"]), ("jQuery", "前端", [], "L1", ["JavaScript"]),
("Webpack", "前端", [], "L2", ["JavaScript"]), ("Vite", "前端", [], "L2", ["JavaScript"]),
("Node.js", "前端", ["Node"], "L2", ["JavaScript"]), ("小程序开发", "前端", ["微信小程序"], "L2", ["JavaScript"]),
("Element UI", "前端", [], "L1", ["Vue"]), ("Ant Design", "前端", [], "L1", ["React"]),
("HTML", "前端", ["HTML5"], "L1", []), ("CSS", "前端", ["CSS3"], "L1", []),
("Tailwind CSS", "前端", [], "L2", ["CSS"]), ("低代码平台", "前端", [], "L2", []),
("微前端", "前端", [], "L3", ["JavaScript"]),
# 数据库与中间件
("MySQL", "数据库", [], "L1", ["SQL"]), ("PostgreSQL", "数据库", [], "L2", ["SQL"]),
("Oracle", "数据库", [], "L2", ["SQL"]), ("Redis", "数据库", [], "L2", []),
("MongoDB", "数据库", [], "L2", []), ("Elasticsearch", "数据库", ["ElasticSearch"], "L2", ["Linux"]),
("ClickHouse", "数据库", [], "L3", ["SQL"]), ("HBase", "数据库", [], "L3", ["Hadoop"]),
("SQLite", "数据库", [], "L1", ["SQL"]), ("TiDB", "数据库", [], "L3", ["MySQL"]),
("Kafka", "中间件", [], "L2", ["Linux"]), ("RabbitMQ", "中间件", [], "L2", []),
("RocketMQ", "中间件", [], "L2", ["Java"]), ("Pulsar", "中间件", [], "L3", ["Kafka"]),
("ZooKeeper", "中间件", [], "L2", ["Linux"]), ("Tomcat", "中间件", [], "L1", ["Java"]),
("WebLogic", "中间件", [], "L2", ["Java"]), ("Nginx", "中间件", [], "L1", ["Linux"]),
# 大数据
("Hadoop", "大数据", [], "L2", ["Linux", "Java"]), ("Spark", "大数据", [], "L2", ["Hadoop"]),
("Flink", "大数据", [], "L3", ["Kafka"]), ("Hive", "大数据", [], "L2", ["Hadoop", "SQL"]),
("HDFS", "大数据", [], "L2", ["Hadoop"]), ("Airflow", "大数据", [], "L2", ["Python"]),
("DataX", "大数据", [], "L2", []), ("Sqoop", "大数据", [], "L2", ["Hadoop"]),
("Presto", "大数据", [], "L3", ["SQL"]), ("Doris", "大数据", [], "L3", ["SQL"]),
("数据挖掘", "大数据", [], "L2", ["Python", "SQL"]),
# AI 与算法
("机器学习", "AI算法", ["机器学习算法"], "L2", ["Python"]),
("深度学习", "AI算法", [], "L3", ["机器学习"]),
("PyTorch", "AI算法", [], "L2", ["Python", "深度学习"]),
("TensorFlow", "AI算法", [], "L2", ["Python", "深度学习"]),
("Keras", "AI算法", [], "L2", ["TensorFlow"]),
("Scikit-learn", "AI算法", ["sklearn"], "L2", ["机器学习"]),
("XGBoost", "AI算法", [], "L2", ["机器学习"]), ("LightGBM", "AI算法", [], "L2", ["机器学习"]),
("NLP", "AI算法", ["自然语言处理"], "L3", ["机器学习"]),
("计算机视觉", "AI算法", ["CV"], "L3", ["深度学习"]),
("OpenCV", "AI算法", [], "L2", ["Python"]), ("OCR", "AI算法", ["文字识别"], "L2", ["计算机视觉"]),
("语音识别", "AI算法", [], "L3", ["深度学习"]), ("推荐算法", "AI算法", ["推荐系统"], "L3", ["机器学习"]),
("强化学习", "AI算法", [], "L3", ["深度学习"]), ("知识图谱", "AI算法", [], "L3", ["NLP"]),
("大模型应用开发", "AI算法", ["LLM应用开发", "大模型应用", "大语言模型应用"], "L3", ["Python", "NLP"]),
("RAG", "AI算法", ["检索增强生成"], "L3", ["大模型应用开发", "向量数据库"]),
("提示词工程", "AI算法", ["Prompt工程"], "L2", ["大模型应用开发"]),
("向量数据库", "AI算法", ["向量库"], "L2", ["数据库基础"]),
("LangChain", "AI算法", [], "L2", ["大模型应用开发"]),
("模型微调", "AI算法", ["Fine-tuning", "SFT"], "L3", ["深度学习", "PyTorch"]),
("AI辅助编程", "AI算法", ["Copilot", "AI编程助手"], "L1", []),
# 云原生与运维
("Linux", "运维", [], "L1", []), ("Docker", "云原生", [], "L2", ["Linux"]),
("Kubernetes", "云原生", ["K8s", "k8s"], "L3", ["Docker"]),
("微服务", "云原生", ["微服务架构"], "L2", []),
("分布式系统", "云原生", ["分布式"], "L3", []), ("高并发", "云原生", [], "L3", ["分布式系统"]),
("云计算", "云原生", ["云平台"], "L2", []), ("Service Mesh", "云原生", ["服务网格"], "L3", ["Kubernetes"]),
("CI/CD", "运维", ["持续集成"], "L2", ["Git"]), ("Git", "运维", [], "L1", []),
("Svn", "运维", ["SVN"], "L1", []), ("Jenkins", "运维", [], "L2", ["CI/CD"]),
("Terraform", "运维", [], "L3", ["云计算"]), ("Ansible", "运维", [], "L2", ["Linux"]),
("Prometheus", "运维", [], "L2", ["Kubernetes"]), ("Grafana", "运维", [], "L2", ["Prometheus"]),
("ELK", "运维", [], "L2", ["Elasticsearch"]), ("DevOps", "运维", [], "L2", ["CI/CD"]),
("AIOps", "运维", ["智能运维"], "L3", ["Prometheus", "机器学习"]),
("AWS", "云原生", ["亚马逊云"], "L2", ["云计算"]), ("阿里云", "云原生", [], "L2", ["云计算"]),
("腾讯云", "云原生", [], "L2", ["云计算"]),
# 测试
("自动化测试", "测试", [], "L2", ["Python"]), ("Selenium", "测试", [], "L2", ["自动化测试"]),
("JUnit", "测试", [], "L1", ["Java"]), ("Pytest", "测试", [], "L1", ["Python"]),
("性能测试", "测试", [], "L2", []), ("接口测试", "测试", ["API测试"], "L2", []),
("JMeter", "测试", [], "L2", ["性能测试"]), ("Postman", "测试", [], "L1", ["接口测试"]),
("单元测试", "测试", [], "L1", []), ("测试用例设计", "测试", [], "L2", []),
# 嵌入式与物联网
("嵌入式开发", "嵌入式", ["嵌入式"], "L2", ["C"]), ("单片机", "嵌入式", [], "L1", ["C"]),
("STM32", "嵌入式", [], "L2", ["单片机"]), ("RTOS", "嵌入式", ["实时操作系统"], "L2", ["C"]),
("FreeRTOS", "嵌入式", [], "L2", ["RTOS"]), ("ARM", "嵌入式", [], "L2", []),
("驱动开发", "嵌入式", ["驱动程序开发"], "L3", ["C", "Linux"]),
("Verilog", "嵌入式", [], "L3", []), ("FPGA", "嵌入式", [], "L3", ["Verilog"]),
("PCB设计", "嵌入式", ["PCB"], "L2", []),
("MQTT", "物联网", [], "L2", []), ("传感器", "物联网", ["传感器技术"], "L1", []),
("物联网平台", "物联网", ["IoT平台"], "L2", []), ("边缘计算", "物联网", [], "L3", ["云计算"]),
("LoRa", "物联网", [], "L2", []), ("Zigbee", "物联网", [], "L2", []),
# 产品与分析
("产品设计", "产品", [], "L2", []), ("需求分析", "产品", [], "L2", []),
("项目管理", "产品", [], "L2", []), ("Axure", "产品", [], "L1", []),
("竞品分析", "产品", [], "L1", []), ("用户增长", "产品", [], "L2", []),
("AI产品", "产品", ["AI产品经理"], "L3", ["产品设计"]),
("数据分析", "数据分析", [], "L2", ["SQL"]), ("Excel", "数据分析", [], "L1", []),
("Tableau", "数据分析", [], "L2", ["数据可视化"]), ("Power BI", "数据分析", ["PowerBI"], "L2", ["数据可视化"]),
("SPSS", "数据分析", [], "L2", []), ("数据可视化", "数据分析", [], "L2", ["数据分析"]),
("A/B测试", "数据分析", ["AB测试"], "L2", ["数据分析"]),
]
SCENARIOS = ["电商", "金融", "物流", "社交", "教育", "医疗", "政务", "游戏",
"出行", "智能制造", "短视频", "广告", "供应链", "零售", "安防"]
# 行业词表(岗位归属 1-2 个行业,图谱中构建 Industry 节点)
INDUSTRIES = ["互联网/软件", "人工智能", "金融科技", "智能制造",
"物联网", "智慧城市", "智慧医疗"]
# ---------------------------------------------------------------- 岗位原型
def arc(title, category, years, salary, scenarios, industries,
duty_tmpls, duty_objs, timeline, volumes):
return dict(title=title, category=category, years=years, salary=salary,
scenarios=scenarios, industries=industries,
duty_tmpls=duty_tmpls, duty_objs=duty_objs,
timeline=timeline, volumes=volumes)
BACKEND_DUTIES = ["负责{o}的设计与开发", "参与{o}的性能优化与重构", "配合产品团队完成{o}的需求评审",
"编写{o}的技术文档与接口文档", "承担{o}的线上问题排查与修复", "推动{o}的代码评审与质量改进"]
BACKEND_OBJS = ["后端服务", "核心业务系统", "交易网关", "订单中心", "用户中心", "缓存体系", "消息管道", "权限平台"]
# 通用职责句式库:(模板, 最早出现年份)。时代词(大模型/AI/低代码)只出现在
# 对应年份之后,保证 2019 年的 JD 不会出现"大模型"等未来措辞。
GENERIC_DUTIES = [
("参与{o}从0到1的搭建与持续迭代", 2019),
("负责{o}高并发场景的优化与稳定性保障", 2020),
("与产品、测试团队协作,推动{o}高质量落地", 2019),
("沉淀{o}的通用组件与最佳实践", 2020),
("主导{o}的技术选型与难点攻坚", 2021),
("建设{o}的监控与告警体系", 2021),
("负责{o}的架构演进与技术债治理", 2022),
("推动{o}的自动化与工程效率提升", 2022),
("探索AI工具在{o}中的应用与提效", 2024),
("引入大模型能力改造{o}的现有流程", 2025),
]
ARCHETYPES = [
arc("Java开发工程师", "后端开发", 3, "15K-30K·14薪", ["电商", "金融", "物流"],
["互联网/软件", "金融科技"],
BACKEND_DUTIES, BACKEND_OBJS,
[(2019, 2021, ["Java", "MySQL", "Spring", "MyBatis", "Servlet"], ["Struts", "JSP", "Linux"]),
(2022, 2023, ["Java", "Spring Boot", "Spring Cloud", "MySQL", "Redis", "微服务"], ["Docker", "Kafka", "Kubernetes"]),
(2024, 2026, ["Java", "Spring Boot", "Spring Cloud", "Redis", "MySQL", "分布式系统"], ["AI辅助编程", "大模型应用开发", "Kubernetes"])],
{2019: 8, 2020: 8, 2021: 8, 2022: 10, 2023: 12, 2024: 24, 2025: 24, 2026: 24}),
arc("Python开发工程师", "后端开发", 3, "14K-28K", ["教育", "金融", "电商"],
["互联网/软件"],
BACKEND_DUTIES, BACKEND_OBJS,
[(2019, 2021, ["Python", "MySQL", "Django", "Linux", "Git"], ["Flask", "Redis", "Nginx"]),
(2022, 2023, ["Python", "FastAPI", "MySQL", "Redis", "Linux"], ["Docker", "Kafka", "Git"]),
(2024, 2026, ["Python", "FastAPI", "Redis", "MySQL", "分布式系统"], ["Docker", "大模型应用开发", "AI辅助编程"])],
{2019: 6, 2020: 6, 2021: 6, 2022: 8, 2023: 12, 2024: 24, 2025: 24, 2026: 24}),
# —— 前端:jQuery/AngularJS 时代 → Vue/React + TypeScript → 工程化/Vite → 低代码 + AI 辅助 ——
arc("前端工程师", "前端", 3, "13K-26K", ["电商", "社交", "短视频"],
["互联网/软件"],
["负责{o}的页面开发与交互实现", "参与{o}的前端性能优化", "与后端联调{o}的数据接口",
"维护{o}的组件库与工程化体系", "承担{o}的兼容性适配工作"],
["移动端页面", "管理后台", "可视化大屏", "运营活动页", "组件库", "工程化脚手架"],
[(2019, 2021, ["JavaScript", "HTML", "CSS", "Vue", "jQuery"], ["Webpack", "Angular", "小程序开发"]),
(2022, 2023, ["JavaScript", "TypeScript", "Vue", "React", "Webpack"], ["Vite", "Node.js", "小程序开发"]),
(2024, 2026, ["TypeScript", "Vue", "React", "Vite", "低代码平台"], ["AI辅助编程", "微前端", "Node.js"])],
{2019: 6, 2020: 6, 2021: 8, 2022: 12, 2023: 24, 2024: 24, 2025: 24, 2026: 24}),
arc("数据工程师", "大数据", 3, "16K-32K", ["金融", "物流", "零售"],
["互联网/软件", "金融科技"],
["负责{o}的采集与清洗", "搭建{o}的离线数仓分层", "开发{o}的实时计算任务",
"维护{o}的调度与监控体系", "参与{o}的数据治理工作"],
["业务数据", "日志数据", "实时数仓", "离线数仓", "数据湖", "指标体系"],
[(2019, 2021, ["SQL", "Hadoop", "Hive", "Spark", "Python"], ["HBase", "Sqoop", "Scala"]),
(2022, 2026, ["SQL", "Spark", "Flink", "Kafka", "Python"], ["ClickHouse", "Doris", "Airflow"])],
{2019: 5, 2020: 5, 2021: 5, 2022: 6, 2023: 12, 2024: 24, 2025: 24, 2026: 24}),
arc("算法工程师", "AI算法", 3, "20K-40K", ["广告", "电商", "金融"],
["人工智能"],
["负责{o}的建模与迭代", "参与{o}的特征工程建设", "跟踪{o}的前沿论文并落地",
"优化{o}的线上推理性能", "与工程团队协作上线{o}"],
["推荐模型", "排序模型", "风控模型", "用户画像", "召回策略", "预估服务"],
[(2019, 2021, ["Python", "机器学习", "深度学习", "TensorFlow", "SQL"], ["XGBoost", "Spark", "C++"]),
(2022, 2023, ["Python", "机器学习", "深度学习", "PyTorch", "推荐算法"], ["XGBoost", "模型微调", "强化学习"]),
(2024, 2026, ["Python", "机器学习", "深度学习", "PyTorch", "推荐算法"], ["大模型应用开发", "模型微调", "强化学习"])],
{2019: 5, 2020: 5, 2021: 5, 2022: 6, 2023: 12, 2024: 24, 2025: 24, 2026: 24}),
# —— 数据分析:Excel/SPSS 时代 → SQL/Python → BI 平台 → LLM 增强分析 ——
arc("数据分析师", "数据分析", 2, "12K-22K", ["零售", "电商", "金融"],
["互联网/软件", "金融科技"],
["负责{o}的报表体系建设", "输出{o}的专题分析报告", "搭建{o}的核心指标看板",
"支持业务团队的{o}取数需求", "参与{o}的复盘与归因分析"],
["经营数据", "用户行为", "活动效果", "渠道投放", "留存漏斗", "销售大盘"],
[(2019, 2021, ["Excel", "SQL", "SPSS", "数据分析", "数据可视化"], ["Tableau", "R", "Power BI"]),
(2022, 2023, ["SQL", "Python", "数据分析", "数据可视化", "Excel"], ["Power BI", "Tableau", "A/B测试"]),
(2024, 2026, ["SQL", "Python", "数据分析", "数据可视化", "A/B测试"], ["Power BI", "Tableau", "大模型应用开发"])],
{2019: 5, 2020: 5, 2021: 6, 2022: 8, 2023: 12, 2024: 24, 2025: 24, 2026: 24}),
arc("测试开发工程师", "测试", 3, "14K-26K", ["金融", "电商", "政务"],
["互联网/软件"],
["负责{o}的测试方案设计", "开发{o}的自动化测试脚本", "搭建{o}的测试平台",
"执行{o}的性能压测", "推动{o}的质量效能改进"],
["核心交易系统", "支付链路", "回归测试", "接口自动化", "压测体系", "质量平台"],
[(2019, 2021, ["Python", "自动化测试", "Selenium", "接口测试", "SQL"], ["JMeter", "JUnit", "Jenkins"]),
(2022, 2026, ["Python", "自动化测试", "接口测试", "性能测试", "CI/CD"], ["Pytest", "JMeter", "Docker"])],
{2019: 4, 2020: 4, 2021: 4, 2022: 6, 2023: 12, 2024: 24, 2025: 24, 2026: 24}),
# —— 运维:脚本/手工 → Ansible 自动化 → DevOps/K8s → AIOps ——
arc("运维工程师(SRE)", "运维", 3, "15K-28K", ["金融", "游戏", "电商"],
["互联网/软件"],
["负责{o}的稳定运行与监控", "参与{o}的容量规划", "建设{o}的自动化运维体系",
"处理{o}的线上故障与应急", "完善{o}的告警与巡检机制"],
["生产集群", "发布流水线", "监控体系", "容器平台", "中间件", "灾备体系"],
[(2019, 2021, ["Linux", "Shell", "Nginx", "MySQL", "Tomcat"], ["Ansible", "Jenkins", "Python"]),
(2022, 2023, ["Linux", "Docker", "Kubernetes", "CI/CD", "Ansible"], ["Prometheus", "DevOps", "Grafana"]),
(2024, 2026, ["Linux", "Kubernetes", "Docker", "Prometheus", "DevOps"], ["Grafana", "Terraform", "AIOps"])],
{2019: 4, 2020: 4, 2021: 6, 2022: 8, 2023: 12, 2024: 24, 2025: 24, 2026: 24}),
arc("嵌入式工程师", "嵌入式", 3, "14K-28K", ["智能制造", "安防", "出行"],
["智能制造", "物联网"],
["负责{o}的固件开发", "参与{o}的驱动调试", "完成{o}的板级联调与测试",
"优化{o}的功耗与稳定性", "编写{o}的开发文档"],
["主控固件", "通信模组", "传感器驱动", "boot 启动流程", "低功耗方案", "量产固件"],
[(2019, 2022, ["C", "C++", "嵌入式开发", "单片机", "RTOS"], ["STM32", "FreeRTOS", "Linux"]),
(2023, 2026, ["C", "C++", "嵌入式开发", "RTOS", "驱动开发"], ["STM32", "边缘计算", "MQTT"])],
{2019: 4, 2020: 4, 2021: 4, 2022: 4, 2023: 5, 2024: 6, 2025: 6, 2026: 6}),
arc("物联网工程师", "物联网", 3, "13K-25K", ["智能制造", "物流", "安防"],
["物联网", "智慧城市"],
["负责{o}的接入方案设计", "开发{o}的设备管理功能", "搭建{o}的数据采集链路",
"参与{o}的平台联调", "优化{o}的通信稳定性"],
["设备接入", "网关", "传感网络", "边缘节点", "设备影子", "远程升级"],
[(2019, 2021, ["C", "MQTT", "传感器", "单片机", "物联网平台"], ["Zigbee", "LoRa", "STM32"]),
(2022, 2026, ["MQTT", "物联网平台", "边缘计算", "Python", "传感器"], ["LoRa", "嵌入式开发", "云计算"])],
{2019: 4, 2020: 4, 2021: 4, 2022: 5, 2023: 5, 2024: 6, 2025: 6, 2026: 6}),
arc("NLP工程师", "AI算法", 3, "18K-36K", ["教育", "政务", "金融"],
["人工智能"],
["负责{o}的算法研发", "参与{o}的语料建设", "优化{o}的模型效果",
"跟进{o}的前沿技术", "支持{o}的工程化落地"],
["智能客服", "文本分类", "信息抽取", "问答系统", "搜索相关性", "舆情分析"],
[(2019, 2021, ["Python", "NLP", "机器学习", "TensorFlow", "SQL"], ["知识图谱", "数据挖掘", "深度学习"]),
(2022, 2023, ["Python", "NLP", "深度学习", "PyTorch", "知识图谱"], ["模型微调", "数据挖掘", "机器学习"]),
(2024, 2026, ["Python", "NLP", "深度学习", "PyTorch", "大模型应用开发"], ["模型微调", "RAG", "LangChain"])],
{2019: 4, 2020: 4, 2021: 4, 2022: 5, 2023: 6, 2024: 8, 2025: 8, 2026: 8}),
arc("CV工程师", "AI算法", 3, "18K-36K", ["安防", "智能制造", "出行"],
["人工智能", "智能制造"],
["负责{o}的算法研发", "参与{o}的数据标注体系", "优化{o}的模型精度与速度",
"部署{o}到边缘设备", "跟进{o}的最新论文"],
["缺陷检测", "目标检测", "视频结构化", "人脸识别", "车道线检测", "工业质检"],
[(2019, 2021, ["Python", "计算机视觉", "深度学习", "TensorFlow", "C++"], ["OCR", "OpenCV", "模型微调"]),
(2022, 2023, ["Python", "计算机视觉", "深度学习", "PyTorch", "OCR"], ["模型微调", "边缘计算", "OpenCV"]),
(2024, 2026, ["Python", "计算机视觉", "深度学习", "PyTorch", "OCR"], ["模型微调", "边缘计算", "大模型应用开发"])],
{2019: 4, 2020: 4, 2021: 4, 2022: 5, 2023: 6, 2024: 8, 2025: 8, 2026: 8}),
arc("云原生工程师", "云原生", 3, "18K-35K", ["金融", "政务", "电商"],
["互联网/软件"],
["负责{o}的架构设计与落地", "建设{o}的容器编排体系", "推进{o}的服务治理",
"优化{o}的资源利用率", "保障{o}的高可用"],
["容器平台", "微服务治理", "多云架构", "弹性伸缩", "服务网格", "可观测体系"],
[(2020, 2022, ["Docker", "Kubernetes", "微服务", "Linux", "Go"], ["Service Mesh", "Prometheus", "DevOps"]),
(2023, 2026, ["Kubernetes", "Docker", "微服务", "CI/CD", "Go"], ["Service Mesh", "Terraform", "云计算"])],
{2020: 4, 2021: 4, 2022: 5, 2023: 6, 2024: 8, 2025: 8, 2026: 8}),
arc("AI产品经理", "产品", 3, "18K-35K", ["教育", "金融", "医疗"],
["人工智能", "智慧医疗"],
["负责{o}的规划与落地", "输出{o}的需求文档", "跟进{o}的研发进度",
"设计{o}的评估指标", "调研{o}的行业动态"],
["智能问答产品", "AI 助手", "智能化应用", "知识库产品", "智能审核", "AI 创作工具"],
[(2020, 2022, ["产品设计", "需求分析", "数据分析", "项目管理", "AI产品"], ["Axure", "竞品分析", "SQL"]),
(2023, 2026, ["AI产品", "需求分析", "大模型应用开发", "产品设计", "数据分析"], ["提示词工程", "用户增长", "A/B测试"])],
{2020: 3, 2021: 3, 2022: 3, 2023: 4, 2024: 6, 2025: 6, 2026: 6}),
arc("Go开发工程师", "后端开发", 3, "16K-32K", ["游戏", "社交", "金融"],
["互联网/软件"],
BACKEND_DUTIES, BACKEND_OBJS,
[(2019, 2021, ["Go", "MySQL", "Redis", "Linux", "Gin"], ["Docker", "gRPC", "Kafka"]),
(2022, 2026, ["Go", "微服务", "gRPC", "Redis", "Kubernetes"], ["Kafka", "Prometheus", "高并发"])],
{2019: 4, 2020: 4, 2021: 4, 2022: 5, 2023: 6, 2024: 8, 2025: 8, 2026: 8}),
# —— 2023 年后才出现且快速增长的新岗位(支撑“新岗位发现”演示) ——
arc("大模型应用工程师", "AI算法", 2, "25K-50K", ["金融", "教育", "医疗"],
["人工智能"],
["负责{o}的应用开发与落地", "设计{o}的提示词与流程编排", "建设{o}的评测体系",
"优化{o}的推理成本与延迟", "跟进{o}的生态工具"],
["智能助手", "企业知识库", "Agent 应用", "代码助手", "智能审核", "多轮对话"],
[(2023, 2026, ["Python", "大模型应用开发", "提示词工程", "LangChain", "NLP"], ["RAG", "模型微调", "向量数据库"])],
{2023: 2, 2024: 8, 2025: 14, 2026: 20}),
arc("RAG工程师", "AI算法", 2, "25K-45K", ["金融", "政务", "教育"],
["人工智能"],
["负责{o}的检索链路搭建", "优化{o}的召回与重排", "建设{o}的知识库加工流水线",
"评估{o}的问答质量", "跟进{o}的新范式"],
["企业知识库", "文档问答", "语义检索", "混合检索", "知识中台", "智能客服"],
[(2024, 2026, ["Python", "RAG", "向量数据库", "大模型应用开发", "LangChain"], ["模型微调", "Elasticsearch", "知识图谱"])],
{2024: 4, 2025: 9, 2026: 15}),
arc("提示词工程师", "AI算法", 1, "15K-30K", ["教育", "电商", "广告"],
["人工智能"],
["负责{o}的提示词设计", "沉淀{o}的提示词模板库", "评估{o}的输出质量",
"与业务团队共创{o}的使用范式", "编写{o}的最佳实践文档"],
["营销文案生成", "智能客服话术", "内容创作", "教学辅导", "商品描述", "对话流程"],
[(2024, 2026, ["提示词工程", "大模型应用开发", "Python", "数据分析"], ["LangChain", "RAG", "AI产品"])],
{2024: 2, 2025: 6, 2026: 10}),
]
COMPANY_PRE = ["星澜", "云启", "数联", "智汇", "蓝湾", "极光", "恒宇", "锐思", "博远", "辰安",
"飞鱼", "墨白", "青藤", "远望", "砺石", "潮汐", "峰行", "微光", "若谷", "启航"]
COMPANY_SUF = ["科技", "网络", "信息", "数据", "智能", "软件", "互联", "数字"]
# 15 城:一线 + 新一线(成都/武汉/西安/苏州/长沙等)
CITIES = ["北京", "上海", "深圳", "杭州", "成都", "广州", "南京", "武汉",
"西安", "苏州", "长沙", "重庆", "天津", "青岛", "合肥"]
SOURCES = ["BOSS直聘", "拉勾网", "智联招聘", "前程无忧", "猎聘网"]
# ---------------------------------------------------------------- 文本渲染素材
# 公司介绍:按年代分池,时代措辞只出现在对应年份之后
INTROS_EARLY = [
"{c}是一家专注于新一代信息技术服务的高新技术企业,现诚邀优秀人才加入。",
"{c}深耕行业多年,团队氛围开放,技术栈前沿,期待你的加入。",
"{c}业务高速增长中,提供有竞争力的薪酬与完善的培养体系。",
"{c}核心团队来自一线大厂,正在寻找志同道合的工程师。",
"{c}处于快速扩张期,移动业务用户量持续攀升,欢迎加入我们。",
]
INTROS_MID = INTROS_EARLY + [
"{c}已完成新一轮融资,正全面推进云原生架构升级,期待你的加入。",
"{c}业务覆盖多个城市,研发体系成熟,重视工程质量与技术沉淀。",
]
INTROS_LATE = INTROS_MID + [
"{c}全面拥抱 AI 原生开发,大模型已深度融入研发流程,欢迎同行者。",
"{c}正在推进 AIGC 与核心业务的深度融合,寻找敢想敢做的工程师。",
"{c}智能化转型加速,AI 辅助研发已成团队标配,期待你的加入。",
]
# 项目背景/团队介绍(可选段落,{a} 只会代入本条 JD 已标注的场景词)
BACKGROUNDS = [
"关于我们:{c}的{a}业务正处于高速增长期,你将直接参与支撑千万级用户的核心系统建设,与团队一起解决真实复杂的业务问题。",
"项目背景:本岗位属于{a}方向核心团队,业务数据量大、迭代节奏快,技术挑战与成长空间并存。",
"团队介绍:{a}研发团队规模 50+,核心成员来自一线大厂,重视工程质量与技术沉淀。",
]
# 团队与福利(可选尾部段落,抽取器按标题截断,不进入任何技能区)
WELFARE_LINES = [
"弹性工作不打卡,五险一金全额缴纳,年度体检与带薪年假一应俱全。",
"技术氛围浓厚,每周内部分享,提供外部培训与技术会议预算。",
"扁平化管理,晋升通道清晰,核心成员可参与期权激励计划。",
"免费三餐与下午茶,团队年轻化,协作氛围轻松高效。",
]
REQ_YEAR_LINES = [
"{y}年以上相关工作经验,责任心强,具备良好的沟通能力;",
"{y}年以上相关岗位经验,具备良好的沟通与推动能力;",
"{y}年以上实战经验,结果导向,能独立承担模块 Owner 工作;",
]
REQ_VERBS = ["熟练掌握", "精通", "熟悉并掌握", "深入理解"]
REQ_SOFT_LINES = [
"具备良好的学习能力与团队协作精神;",
"逻辑清晰,自驱力强,对技术有热情;",
"具备良好的文档习惯,重视工程质量与可维护性;",
"能适应快速迭代的研发节奏,抗压能力强;",
]
SCEN_TEMPLATES = ["具有{a}或{b}行业经验者优先;", "熟悉{a}/{b}业务场景者优先;",
"有{a}、{b}行业项目经验者优先;", "具备{a}或{b}相关业务背景者优先;"]
PREF_TEMPLATES = ["了解{s}者优先;", "熟悉{s}者优先考虑;", "有{s}相关实践经验者优先;"]
PREF_EXTRA_LINES = ["有开源项目贡献或高质量技术博客者优先;", "参与过大型技术社区分享者优先;"]
NUM_FORMATS = ["{i}、", "{i}、", "{i}、", "{i}.", "- "]
NOISE_TAILS = [
"【广告】扫码关注公众号,领取面试大礼包和内部资料。",
"点击关注我们的招聘号,第一时间获取内推信息。",
"【广告】商务合作请加群,扫码领取免费课程资料。",
]
NOISE_FULL = [
"【广告】扫码关注公众号领取求职资料\n点击关注领取福利红包\n长期回收二手办公设备,非诚勿扰",
"【广告】推广信息:扫码加入求职群领取资料\n商务合作请加群详谈\n加盟代理热线见官网",
"【广告】点击关注即可领取现金红包\n扫码进福利群,天天有惊喜\n代办各类证件,长期有效",
"【广告】推广信息:刷单兼职日结\n扫码添加客服领取任务\n长期回收库存尾货",
]
def intros_for(year: int) -> list[str]:
if year >= 2024:
return INTROS_LATE
if year >= 2022:
return INTROS_MID
return INTROS_EARLY
def skillset_for(a: dict, year: int) -> tuple[list[str], list[str]]:
for y0, y1, req, pref in a["timeline"]:
if y0 <= year <= y1:
return list(req), list(pref)
req, pref = a["timeline"][-1][2], a["timeline"][-1][3]
return list(req), list(pref)
def pick_duties(a: dict, year: int) -> list[str]:
"""按年份过滤句式池(时代门槛),抽 3-6 条职责。"""
pool = list(a["duty_tmpls"]) + [t for t, min_y in GENERIC_DUTIES if year >= min_y]
tmpls = rng.sample(pool, k=min(len(pool), rng.choice([3, 4, 4, 5, 5, 6])))
objs = rng.sample(a["duty_objs"], k=min(len(a["duty_objs"]), len(tmpls)))
while len(objs) < len(tmpls):
objs.append(rng.choice(a["duty_objs"]))
return [t.format(o=o) + ";" for t, o in zip(tmpls, objs)]
def _skill_req_lines(req: list[str]) -> list[str]:
"""必备技能行的多种自然写法(都在要求区内,抽取结果与措辞无关)。"""
verb = rng.choice(REQ_VERBS)
if len(req) >= 4 and rng.random() < 0.4:
cut = rng.randint(2, len(req) - 2)
return [f"{verb}{'、'.join(req[:cut])},熟悉{'、'.join(req[cut:])};"]
if rng.random() < 0.25:
return [f"具备扎实的{'、'.join(req)}功底;"]
return [f"{verb}{'、'.join(req)};"]
def build_text(a: dict, company: str, year: int, years: int, duties: list[str],
req: list[str], pref: list[str], scens: list[str]) -> str:
duty_h = rng.choice(DUTY_HEADERS)
req_h = rng.choice(REQ_HEADERS)
pref_h = rng.choice(PREF_HEADERS)
num_fmt = rng.choice(NUM_FORMATS)
scen_sentence = rng.choice(SCEN_TEMPLATES).format(a=scens[0], b=scens[-1])
lines = [
f"岗位名称:{a['title']}",
rng.choice(intros_for(year)).format(c=company),
]
# 可选段落:项目背景/团队介绍(约 35%)
if rng.random() < 0.35:
lines.append(rng.choice(BACKGROUNDS).format(c=company, a=scens[0]))
lines.append(f"{duty_h}:")
lines += [f"{num_fmt.format(i=i + 1)}{d}" for i, d in enumerate(duties)]
lines.append(f"{req_h}:")
req_lines = [rng.choice(REQ_YEAR_LINES).format(y=years)]
req_lines += _skill_req_lines(req)
req_lines.append(scen_sentence)
req_lines += rng.sample(REQ_SOFT_LINES, k=rng.choice([0, 1, 1, 2]))
lines += [f"{num_fmt.format(i=i + 1)}{t}" for i, t in enumerate(req_lines)]
lines.append(f"{pref_h}:")
lines.append(f"1、{rng.choice(PREF_TEMPLATES).format(s='、'.join(pref))}")
if rng.random() < 0.4:
lines.append(f"2、{rng.choice(PREF_EXTRA_LINES)}")
# 可选尾部段落:团队与福利(约 40%),抽取器按标题截断
if rng.random() < 0.40:
lines.append(f"{rng.choice(TAIL_HEADERS)}:")
lines.append(rng.choice(WELFARE_LINES))
return "\n".join(lines)
def make_jd(a: dict, year: int, jid: str, month: int | None = None) -> tuple[dict, dict]:
"""生成一条 JD 记录与其标准答案(先定结构,再渲染文本)。
month 可由调用方指定(原始数据按年内均匀铺开,避免季度窗口计数随机抖动
制造虚假演化事件);不传则随机(测试集保持自然随机)。
"""
company = rng.choice(COMPANY_PRE) + rng.choice(COMPANY_SUF)
req, pref = skillset_for(a, year)
rng.shuffle(req)
rng.shuffle(pref)
if len(req) > 4 and rng.random() < 0.15: # 轻微抖动:偶尔少写一项必备
req = req[:-1]
pref = pref[: rng.choice([2, 2, 3])]
duties = pick_duties(a, year)
scens = rng.sample(a["scenarios"], k=min(2, len(a["scenarios"])))
years = rng.randint(max(1, a["years"] - 1), a["years"] + 2)
text = build_text(a, company, year, years, duties, req, pref, scens)
month = month or rng.randint(1, 12)
day = rng.randint(1, 28)
record = {
"jid": jid,
"job_title": a["title"],
"company": company,
"city": rng.choice(CITIES),
"salary": a["salary"],
"industry": rng.choice(a["industries"]),
"publish_date": date(year, month, day).isoformat(),
"source": rng.choice(SOURCES),
"url": f"https://jobs.example.com/{jid}",
"text": text,
}
gold = {
"job_title": a["title"],
"responsibilities": duties,
"required_skills": req,
"preferred_skills": pref,
"scenarios": scens,
}
return record, gold
def make_dup_copy(rec: dict, jid: str) -> dict:
"""生成近重复(抄袭)副本:同文不同公司,仅换公司/城市/渠道/薪资与发布日期。"""
new_company = rng.choice(COMPANY_PRE) + rng.choice(COMPANY_SUF)
text = rec["text"].replace(rec["company"], new_company)
copy = dict(rec)
copy.update({
"jid": jid,
"company": new_company,
"city": rng.choice(CITIES),
"source": rng.choice(SOURCES),
"url": f"https://jobs.example.com/{jid}",
"salary": rec["salary"].replace("14薪", "13薪") if "14薪" in rec["salary"] else rec["salary"],
"text": text,
"publish_date": min(
date.fromisoformat(rec["publish_date"]) + timedelta(days=rng.randint(10, 120)),
date(2026, 12, 28)).isoformat(),
})
return copy
def write_jsonl(path: Path, rows: list[dict]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with open(path, "w", encoding="utf-8") as f:
for r in rows:
f.write(json.dumps(r, ensure_ascii=False) + "\n")
# ---------------------------------------------------------------- 简历样例(全合成,无真实个人信息)
RESUME_FRONTEND_FULL = (
"姓名:陈思远\n手机:13655557777\n邮箱:chensy@example.com\n"
"4年前端开发经验,主导过大促会场与管理后台的前端架构设计。\n"
"核心技能:熟练掌握JavaScript、TypeScript、HTML、CSS,精通Vue、React双栈,"
"熟悉Vite、Webpack、Node.js与小程序开发,有低代码平台建设经验,日常使用AI辅助编程提效。\n"
"项目经历:负责可视化大屏与管理后台开发,基于Vue与Vite搭建工程化体系;"
"使用React与TypeScript重构信息流页面,首屏性能提升40%。\n"
)
RESUME_DATA_PARTIAL = (
"姓名:刘洋\n手机:13566668888\n邮箱:liuyang.data@example.com\n"
"2年数据分析经验,服务于零售业务线,支持运营团队的日常取数与复盘。\n"
"核心技能:熟练使用SQL取数,掌握Excel、数据可视化与Tableau看板搭建,"
"能用Python完成数据清洗与报表自动化,了解Power BI。\n"
"项目经历:搭建活动效果核心指标看板,输出渠道投放专题分析,"
"在校期间曾用SPSS完成调研统计。\n"
)
RESUME_LLM_FULL = (
"姓名:赵启明\n手机:13777778888\n邮箱:zhaoqm@example.com\n"
"3年AI应用开发经验,专注大模型应用开发与落地。\n"
"核心技能:精通Python,熟悉大模型应用开发、提示词工程、LangChain与RAG,"
"掌握向量数据库与模型微调,了解NLP基础,熟悉FastAPI服务开发。\n"
"项目经历:主导企业知识库问答系统建设,基于RAG与LangChain实现混合检索与重排,"
"通过提示词工程与模型微调将回答准确率提升25%。\n"
)
RESUME_LEGACY_MISMATCH = (
"姓名:孙建国\n手机:13899990000\n邮箱:sunjg@example.com\n"
"8年传统软件经验,长期维护企业内部办公系统。\n"
"核心技能:精通Struts、JSP、Servlet,熟悉Svn与Tomcat部署,"
"掌握jQuery页面开发,了解WebLogic。\n"
"项目经历:负责政务办公系统的日常维护与功能迭代,使用Struts与JSP完成多个模块改造。\n"
)
def _cand(title: str, req: list[str], pref: list[str], scens: list[str],
src: int, comp: int, status: str = "pending") -> dict:
"""构造一条待审核候选的快照(审核留痕种子用)。"""
return {"job_id": f"cand_{title}", "title": title, "category": "新岗位候选",
"required_skills": req, "preferred_skills": pref, "responsibilities": [],
"scenarios": scens, "expected_years": 3, "status": status,
"source_count": src, "company_count": comp}
AUDIT_SEED = [
{"candidate_id": "cand_大模型应用工程师", "action": "accept",
"operator": "王蕾(评审专家)",
"reason": "岗位簇证据充分:40+ 条 JD、独立企业数达标,2024 年起持续高增长,技能结构清晰,准予入库。",
"before": _cand("大模型应用工程师",
["提示词工程", "Python", "LangChain", "大模型应用开发", "NLP"],
["模型微调", "向量数据库", "RAG"], ["教育", "医疗", "金融"], 42, 38),
"after": _cand("大模型应用工程师",
["提示词工程", "Python", "LangChain", "大模型应用开发", "NLP"],
["模型微调", "向量数据库", "RAG"], ["教育", "医疗", "金融"], 42, 38, "active"),
"time": "2026-07-18 10:24:31"},
{"candidate_id": "cand_RAG工程师", "action": "modify",
"operator": "李强(评审专家)",
"reason": "簇内 JD 普遍要求检索引擎实操经验,补充 Elasticsearch 为必备技能后入库。",
"before": _cand("RAG工程师",
["Python", "RAG", "向量数据库", "大模型应用开发", "LangChain"],
["模型微调", "知识图谱"], ["金融", "政务", "教育"], 28, 22),
"after": _cand("RAG工程师",
["Python", "RAG", "向量数据库", "大模型应用开发", "LangChain", "Elasticsearch"],
["模型微调", "知识图谱"], ["金融", "政务", "教育"], 28, 22, "active"),
"time": "2026-07-19 15:02:11"},
{"candidate_id": "cand_元宇宙社区运营", "action": "reject",
"operator": "王蕾(评审专家)",
"reason": "簇内 JD 仅 5 条且集中于 2 家企业,近一年增长不明朗,证据不足,暂不入库。",
"before": _cand("元宇宙社区运营", ["用户增长", "竞品分析"], ["数据分析"],
["社交", "游戏"], 5, 2),
"after": {},
"time": "2026-07-21 09:41:03"},
{"kind": "node_update", "node_id": "job_Java开发工程师",
"operator": "张铭(领域专家)",
"reason": "2024 年后分布式系统已成为该岗位核心要求,上调该关系权重。",
"before": {"weight": 0.82}, "after": {"weight": 0.95},
"time": "2026-07-23 14:12:55"},
{"kind": "node_update", "node_id": "Kubernetes",
"operator": "张铭(领域专家)",
"reason": "词表归类校正:由未标注类别调整为云原生类别。",
"before": {"category": ""}, "after": {"category": "云原生"},
"time": "2026-07-23 14:15:20"},
{"candidate_id": "cand_提示词工程师", "action": "accept",
"operator": "陈静(评审专家)",
"reason": "2024 年起多家企业独立设岗,职责与传统运营岗差异显著,予以入库观察。",
"before": _cand("提示词工程师",
["提示词工程", "大模型应用开发", "Python", "数据分析"],
["LangChain", "RAG", "AI产品"], ["教育", "电商", "广告"], 18, 14),
"after": _cand("提示词工程师",
["提示词工程", "大模型应用开发", "Python", "数据分析"],
["LangChain", "RAG", "AI产品"], ["教育", "电商", "广告"], 18, 14, "active"),
"time": "2026-07-25 11:30:44"},
{"kind": "node_update", "node_id": "job_运维工程师(SRE)",
"operator": "李强(评审专家)",
"reason": "按 2026 年 JD 样本补全岗位级别标注。",
"before": {"level": None}, "after": {"level": "中级"},
"time": "2026-07-28 16:48:09"},
]
# ---------------------------------------------------------------- 报告样例(与 JD 演化信号相互印证)
REPORT_TREND = (
"新一代信息技术岗位能力趋势报告(样例)\n\n"
"一、总体趋势\n"
"2019-2026 年,后端岗位从 SSM/Servlet 体系加速转向 Spring Boot/Spring Cloud 微服务体系,"
"容器化(Docker/Kubernetes)已成为运维与后端岗位的通用要求。\n\n"
"二、AI 原生岗位崛起\n"
"2023 年起,大模型应用工程师、RAG工程师、提示词工程师等新岗位快速增长,"
"大模型应用开发、提示词工程、向量数据库成为增速最快的技能点。\n\n"
"三、传统技能退出\n"
"Struts、JSP、Svn 等传统技术栈在 2022 年后基本退出主流 JD。\n"
"四、AI 辅助编程普及\n"
"2024 年起,AI辅助编程(Copilot 类工具)开始作为加分项出现在后端岗位 JD 中。\n"
)
REPORT_TALENT_2026 = (
"新一代信息技术人才发展报告(2026)(节选·样例)\n\n"
"一、总体格局:能力图谱加速重构\n"
"2019-2026 年,信息技术岗位的能力要求经历了从“框架驱动”到“云原生驱动”再到“AI 原生驱动”"
"的三次跃迁。招聘 JD 数据显示,微服务、容器化已于 2022 年前后完成普及,成为后端与运维岗位的"
"基础要求;2024 年起,大模型相关能力以前所未有的速度进入各岗位的能力图谱。\n\n"
"二、AI 原生岗位爆发式增长\n"
"大模型应用工程师自 2023 年出现以来需求连年翻倍,2026 年新发岗位量已达 2023 年的十倍以上;"
"RAG工程师、提示词工程师自 2024 年起独立设岗。值得注意的是,提示词工程相关岗位需求从 2023 年"
"起爆发,企业对该能力的要求正从“会用”转向“体系化”:提示词模板库沉淀、输出质量评估、与业务"
"流程的编排结合成为高频要求。\n\n"
"三、AI 辅助编程渗透率快速上升\n"
"2024 年起,AI辅助编程(Copilot 类工具)开始以加分项形式进入后端、前端岗位 JD;到 2026 年,"
"约半数新发后端岗位 JD 提及 AI 辅助研发能力,“使用大模型辅助开发与代码审查”一类职责表述明显"
"增多。AI 工具链正从“个人效率工具”升级为“团队工程标配”。\n\n"
"四、前端:从框架之争到工程化与智能化\n"
"jQuery、AngularJS 相关要求在 2022 年后加速退场,Vue/React + TypeScript 成为绝对主流;构建"
"工具方面,Vite 已基本取代 Webpack 成为新项目标配。2024 年起,低代码平台与 AI 辅助生成进入"
"前端岗位核心要求,前端工程师的能力重心从“写页面”转向“搭体系”。\n\n"
"五、运维与分析岗位的智能化迁移\n"
"运维岗位沿“脚本手工 → Ansible 自动化 → DevOps/Kubernetes → AIOps”路径演进,2024 年起 AIOps"
"首次进入岗位加分项;数据分析岗位则完成了“Excel/SPSS 退场、SQL/Python 成标配”的换代,2024 年"
"起大模型增强分析(自然语言取数、报告自动生成)开始进入要求清单。\n\n"
"六、传统技术栈出清\n"
"Struts、JSP、Svn、WebLogic 等传统技术栈在 2022 年后基本退出主流 JD,仅存的少量需求集中于"
"存量系统维护岗位,且普遍标注“接受老系统维护经验”作为过渡安排。\n"
)
REPORT_DIGITAL_ECONOMY = (
"数字经济就业趋势观察(2026 年上半年)(节选·样例)\n\n"
"一、区域格局:新一线城市承接效应显著\n"
"2026 年上半年,信息技术岗位的区域分布持续多极化。北京、上海、深圳、杭州仍是岗位密度最高的"
"第一梯队,但成都、武汉、西安、苏州、长沙等新一线城市的岗位占比连续第三年上升,合计占比已"
"接近三成。其中成都以后端与游戏研发见长,武汉、西安依托高校资源在嵌入式与智能制造方向增速"
"领先,苏州、长沙则在工业互联网与物联网岗位上放量明显。\n\n"
"二、岗位结构:AI 岗位从“概念热”走向“用工实”\n"
"与 2023 年大模型岗位“重算法研究”不同,2026 年的需求主体已转向应用落地:大模型应用工程师、"
"RAG工程师合计新发岗位量超过传统算法工程师的六成,提示词工程能力则从独立岗位演变为产品、"
"运营、数据分析等多岗位的通用加分项。用人单位普遍要求候选人同时具备工程落地能力与业务理解"
"能力,单纯的“调参型”简历竞争力明显下降。\n\n"
"三、技能更替:老栈出清与新栈下沉同步发生\n"
"上半年数据显示,Struts、JSP、Svn 等传统技术栈在新增 JD 中已基本绝迹;与之相对,原本集中"
"于头部企业的 Kubernetes、CI/CD、可观测体系等云原生能力,正快速下沉至中小企业 JD。运维岗位"
"的 AIOps 要求、数据分析岗位的大模型增强分析要求,虽然仍以加分项形式出现,但提及率环比均"
"翻倍增长,预计 1-2 年内转为必备项。\n\n"
"四、薪酬观察:AI 应用岗溢价收窄但基数抬高\n"
"大模型应用工程师的薪酬中位数仍领先通用后端岗位约 40%,但溢价幅度较 2024 年高点收窄;同时,"
"掌握 AI 工具链的通用岗位(后端、前端、测试)薪酬基数整体抬升,具备“AI+工程”复合能力的候选"
"人在议价中优势明显。\n\n"
"五、趋势研判\n"
"预计下半年岗位能力图谱的演化将沿三条主线展开:一是 AI 辅助研发能力从加分项转为默认要求;"
"二是低代码与 AI 生成进一步压缩初级前端岗位需求,中高级工程化人才缺口扩大;三是新一线城市"
"与一线城市的技能要求趋于同构,区域间人才竞争加剧。\n"
)
def main() -> None:
data = ROOT / "data"
data.mkdir(parents=True, exist_ok=True)
# 1. 技能词表 + 行业词表 + 岗位-行业归属
lexicon = {
"skills": [{"name": n, "category": c, "aliases": al, "level": lv, "prereqs": pre}
for n, c, al, lv, pre in SKILLS],
"scenarios": SCENARIOS,
"industries": INDUSTRIES,
"jobs": [{"title": a["title"], "category": a["category"],
"industries": a["industries"], "expected_years": a["years"]}
for a in ARCHETYPES],
}
(data / "skills_lexicon.json").write_text(
json.dumps(lexicon, ensure_ascii=False, indent=1), encoding="utf-8")
# 2. 原始 JD(按年份分批次)
raw_by_year: dict[int, list[dict]] = {y: [] for y in range(2019, 2027)}
seq = 0
for a in ARCHETYPES:
for year, count in sorted(a["volumes"].items()):
for i in range(count):
seq += 1
# 月份年内均匀铺开:季度窗口计数只随剧本(时间轴/量级)变化
rec, _ = make_jd(a, year, f"J{seq:05d}", month=i * 12 // count + 1)
raw_by_year[year].append(rec)
# 3. 植入噪声(约 3%):一半混排尾注(清洗时剔除行),一半整篇广告(整条标记)
all_records = [r for y in raw_by_year.values() for r in y]
noise_jids: list[str] = []
mixed = rng.sample(all_records, k=10)
for rec in mixed:
rec["text"] = rec["text"] + "\n" + rng.choice(NOISE_TAILS)
noise_jids.append(rec["jid"])
for i in range(8):
full = NOISE_FULL[i % len(NOISE_FULL)]
seq += 1
jid = f"J{seq:05d}"
year = rng.randint(2020, 2026)
raw_by_year[year].append({
"jid": jid, "job_title": rng.choice(ARCHETYPES)["title"],
"company": rng.choice(COMPANY_PRE) + rng.choice(COMPANY_SUF),
"city": rng.choice(CITIES), "salary": "面议",
"industry": rng.choice(INDUSTRIES),
"publish_date": date(year, rng.randint(1, 12), rng.randint(1, 28)).isoformat(),
"source": rng.choice(SOURCES), "url": f"https://jobs.example.com/{jid}",
"text": full,
})
noise_jids.append(jid)
# 4. 植入近重复(约 8%):抄袭源避开噪声记录,副本与原文高度相似
dup_groups: list[list[str]] = []
clean_pool = [r for r in all_records if r["jid"] not in noise_jids]
dup_sources = rng.sample(clean_pool, k=int(len(all_records) * 0.08))
for src in dup_sources:
seq += 1
copy = make_dup_copy(src, f"J{seq:05d}")
year = min(2026, int(copy["publish_date"][:4]))
raw_by_year[year].append(copy)
dup_groups.append([src["jid"], copy["jid"]])
for year, rows in raw_by_year.items():
write_jsonl(data / "jd_raw" / f"batch_{year}.jsonl", rows)
(data / "jd_raw" / "_meta.json").write_text(json.dumps(
{"dup_groups": dup_groups, "noise_jids": noise_jids}, ensure_ascii=False, indent=1),
encoding="utf-8")
# 5. 测试集(与 raw 不同 jid 命名空间)+ 标准答案
testset, gold = [], {}
tseq = 0
per_arc = 8 # 18 岗位 × 8 = 144 条 >= 120
for a in ARCHETYPES:
years = sorted(a["volumes"])
for _ in range(per_arc):
tseq += 1
year = years[-1] if rng.random() < 0.6 else rng.choice(years)
rec, g = make_jd(a, year, f"T{tseq:05d}")
testset.append(rec)
gold[rec["jid"]] = g
write_jsonl(data / "testset" / "jd_testset.jsonl", testset)
(data / "testset" / "gold_answers.json").write_text(
json.dumps(gold, ensure_ascii=False, indent=1), encoding="utf-8")
# 6. 人岗匹配标注对(正例:技能覆盖当前必备;反例:技能体系错位)
unrelated = {
"后端开发": ["C", "嵌入式开发", "单片机", "RTOS", "STM32"],
"前端": ["C", "单片机", "Verilog", "FPGA", "驱动开发"],
"大数据": ["Verilog", "FPGA", "PCB设计", "单片机", "C"],
"AI算法": ["PCB设计", "Verilog", "FPGA", "Zigbee", "LoRa"],
"数据分析": ["Verilog", "FPGA", "驱动开发", "单片机", "C"],
"测试": ["Verilog", "FPGA", "PCB设计", "LoRa", "Zigbee"],
"运维": ["Verilog", "FPGA", "PCB设计", "传感器", "Zigbee"],
"嵌入式": ["Excel", "SPSS", "Axure", "竞品分析", "用户增长"],
"物联网": ["Excel", "SPSS", "Axure", "竞品分析", "用户增长"],
"云原生": ["Verilog", "FPGA", "PCB设计", "单片机", "传感器"],
"产品": ["Verilog", "FPGA", "驱动开发", "单片机", "C"],
}
# 既有岗位(2023 年前已存在)才进入正式图谱,匹配标注只覆盖这些岗位
known_arcs = [a for a in ARCHETYPES if min(a["volumes"]) < 2023]
match_gold = []
for a in known_arcs:
req, pref = skillset_for(a, 2025)
pos_skills = req[: max(3, len(req) - 1)] + pref[:1]
match_gold.append({
"job_title": a["title"], "skills": pos_skills,
"years": a["years"] + 2, "scenarios": a["scenarios"][:2],
"expected_match": True,
})
neg_scen = [s for s in SCENARIOS if s not in a["scenarios"]][:2]
match_gold.append({
"job_title": a["title"], "skills": unrelated.get(a["category"], ["Excel", "SPSS"]),
"years": 1, "scenarios": neg_scen, "expected_match": False,
})
# 部分覆盖正例(剧本三岗位:缺 1-2 项必备但总体达标)
by_title = {a["title"]: a for a in ARCHETYPES}
for title in ["前端工程师", "数据分析师", "运维工程师(SRE)"]:
a = by_title[title]
req, pref = skillset_for(a, 2025)
match_gold.append({
"job_title": a["title"], "skills": req[:-2] + pref[:2],
"years": a["years"] + 1, "scenarios": a["scenarios"][:2],
"expected_match": True,
})
# 新增简历衍生的标注对(前端完整 / 数据分析部分 / 传统栈错位)
match_gold.append({
"job_title": "前端工程师",
"skills": ["JavaScript", "TypeScript", "HTML", "CSS", "Vue", "React",
"Vite", "Webpack", "Node.js", "小程序开发", "低代码平台", "AI辅助编程"],
"years": 4, "scenarios": ["电商", "社交"], "expected_match": True,
})
match_gold.append({
"job_title": "数据分析师",
"skills": ["SQL", "Excel", "数据分析", "数据可视化", "Tableau", "Python",
"Power BI", "SPSS"],
"years": 2, "scenarios": ["零售", "电商"], "expected_match": True,
})
match_gold.append({
"job_title": "数据分析师",
"skills": ["Struts", "JSP", "Servlet", "Svn", "Tomcat", "jQuery", "WebLogic"],
"years": 8, "scenarios": ["政务", "安防"], "expected_match": False,
})
(data / "testset" / "match_gold.json").write_text(
json.dumps(match_gold, ensure_ascii=False, indent=1), encoding="utf-8")
# 7. 简历样例(完整 / 部分 / 错位,含虚构姓名手机邮箱以验证脱敏)+ 技能标准答案
resumes_dir = data / "resumes"
resumes_dir.mkdir(parents=True, exist_ok=True)
r1 = (
"姓名:张伟\n手机:13812345678\n邮箱:zhangwei@example.com\n"
"5年后端开发经验,先后负责交易系统与支付链路的建设。\n"
"核心技能:熟练掌握Java、Spring Boot、Spring Cloud、MySQL、Redis,"
"熟悉微服务与分布式系统设计,了解Docker、Kafka、Kubernetes与高并发优化。\n"
"项目经历:主导订单中心微服务化改造,基于Spring Cloud与Redis支撑高并发下单;"
"使用Kafka搭建异步消息管道,覆盖电商与金融两大业务场景。\n"
)
r2 = (
"姓名:李娜\n手机:13998765432\n邮箱:lina.dev@example.com\n"
"2年后端开发经验,参与过企业内部管理系统开发。\n"
"核心技能:熟悉Java、Spring Boot、MyBatis、MySQL,了解Redis与Linux常用命令,"
"日常使用Git协作。\n"
"项目经历:负责报表模块开发,使用Spring Boot与MySQL实现数据查询接口。\n"
)
r3 = (
"姓名:王强\n电话:13755556666\n邮箱:wangq@example.com\n"
"4年嵌入式开发经验,服务于智能制造行业。\n"
"核心技能:精通C、C++,熟悉嵌入式开发、单片机、RTOS,"
"掌握STM32与FreeRTOS,了解MQTT协议。\n"
"项目经历:负责工业网关固件开发,完成传感器数据采集与边缘上报。\n"
)
resumes = {
"resume_full.txt": (r1, ["Java", "Spring Boot", "Spring Cloud", "MySQL", "Redis",
"微服务", "分布式系统", "Docker", "Kafka", "Kubernetes", "高并发"]),
"resume_partial.txt": (r2, ["Java", "Spring Boot", "MyBatis", "MySQL", "Redis", "Linux", "Git"]),
"resume_mismatch.txt": (r3, ["C", "C++", "嵌入式开发", "单片机", "RTOS",
"STM32", "FreeRTOS", "MQTT", "传感器"]),
"resume_frontend_full.txt": (
RESUME_FRONTEND_FULL,
["JavaScript", "TypeScript", "HTML", "CSS", "Vue", "React",
"Vite", "Webpack", "Node.js", "小程序开发", "低代码平台", "AI辅助编程"]),
"resume_data_partial.txt": (
RESUME_DATA_PARTIAL,
["SQL", "Excel", "数据分析", "数据可视化", "Tableau", "Python", "Power BI", "SPSS"]),
"resume_llm_full.txt": (
RESUME_LLM_FULL,
["Python", "大模型应用开发", "提示词工程", "LangChain", "RAG",
"向量数据库", "模型微调", "NLP", "FastAPI"]),
"resume_legacy_mismatch.txt": (
RESUME_LEGACY_MISMATCH,
["Struts", "JSP", "Servlet", "Svn", "Tomcat", "jQuery", "WebLogic"]),
}
resume_gold = {}
for name, (text, skills) in resumes.items():
(resumes_dir / name).write_text(text, encoding="utf-8")
resume_gold[name] = {"skills": skills}
(resumes_dir / "gold.json").write_text(
json.dumps(resume_gold, ensure_ascii=False, indent=1), encoding="utf-8")
# 8. 行业趋势报告样例(3 份,内容与 JD 数据中的演化信号相互印证)
reports_dir = data / "reports"
reports_dir.mkdir(parents=True, exist_ok=True)
(reports_dir / "industry_trend_report.txt").write_text(REPORT_TREND, encoding="utf-8")
(reports_dir / "talent_report_2026.txt").write_text(REPORT_TALENT_2026, encoding="utf-8")
(reports_dir / "digital_economy_2026H1.txt").write_text(REPORT_DIGITAL_ECONOMY, encoding="utf-8")
# 9. 审核留痕种子记录(accept/modify/reject 混合 + 节点编辑,覆盖多名操作人)
review_dir = data / "review"
review_dir.mkdir(parents=True, exist_ok=True)
with open(review_dir / "audit.jsonl", "w", encoding="utf-8") as f:
for rec in AUDIT_SEED:
f.write(json.dumps(rec, ensure_ascii=False) + "\n")
n_raw = sum(len(v) for v in raw_by_year.values())
print(f"生成完成:技能词表 {len(SKILLS)} 项,行业 {len(INDUSTRIES)} 个,"
f"原始 JD {n_raw} 条({len(CITIES)} 城),测试集 {len(testset)} 条,"
f"匹配标注 {len(match_gold)} 对,简历 {len(resumes)} 份,报告 3 份,"
f"审核留痕种子 {len(AUDIT_SEED)} 条,"
f"抄袭簇 {len(dup_groups)} 组,噪声 {len(noise_jids)} 条")
if __name__ == "__main__":
main()
+68
View File
@@ -0,0 +1,68 @@
"""端到端冒烟:生成数据(若缺失)→ 跑 pipeline → 打印图谱规模、演化事件数、一个匹配示例。
不需要启动服务,用于快速验证全链路可用。
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from app.config import load_config, resolve # noqa: E402
from app.graph.tkg import TemporalKG # noqa: E402
from app.match.engine import MatchEngine # noqa: E402
from app.match.resume import parse_resume # noqa: E402
from app.models import ResumeProfile # noqa: E402
from app.services.pipeline import run_pipeline # noqa: E402
from app.extract.base import load_lexicon # noqa: E402
def main() -> None:
if not (ROOT / "data" / "jd_raw").exists():
print("数据不存在,先生成数据集……")
import subprocess
subprocess.run([sys.executable, str(ROOT / "scripts" / "generate_dataset.py")],
check=True)
print("=" * 56)
print("步骤 1/3:端到端 pipeline(导入→清洗→抽取→图谱→演化→发现)")
print("=" * 56)
stats = run_pipeline(load_config())
print(json.dumps(stats["stages"], ensure_ascii=False, indent=2))
print("\n步骤 2/3:图谱规模与演化事件")
kg = TemporalKG.load(resolve(load_config(), "graph_path"))
print("图谱统计:", kg.stats())
java_id = "job_Java开发工程师"
java_events = kg.events_of(java_id)
print(f"Java开发工程师 演化事件 {len(java_events)} 条,示例:")
for ev in java_events[:4]:
print(" -", ev.description)
snap_2020 = kg.snapshot(java_id, "2020-06-01")
snap_2025 = kg.snapshot(java_id, "2025-06-01")
print("2020 时点技能:", sorted(s["skill"] for s in snap_2020["skills"])[:8])
print("2025 时点技能:", sorted(s["skill"] for s in snap_2025["skills"])[:8])
print("\n步骤 3/3:人岗匹配示例(resume_full.txt vs Java开发工程师)")
text = (ROOT / "data" / "resumes" / "resume_full.txt").read_text(encoding="utf-8")
profile = parse_resume(text)
job = kg.jobs[java_id]
result = MatchEngine(load_lexicon()).match(profile, job)
print(f"脱敏后姓名: {profile.name} 手机: {profile.phone} 邮箱: {profile.email}")
print(f"总分 {result.total_score},维度 {result.dimension_scores},是否匹配 {result.is_match}")
print("缺失必备技能:", result.missing_required)
for stage in result.learning_path:
print(" ", stage["goal"])
# 断言冒烟通过的关键指标
assert kg.stats()["jobs"] >= 15, "岗位节点数量异常"
assert kg.stats()["events"] > 0, "未产生演化事件"
assert result.total_score >= 60, "完整简历应匹配 Java 岗位"
print("\n冒烟测试通过 [OK]")
if __name__ == "__main__":
main()
+104
View File
@@ -0,0 +1,104 @@
"""把 data/graph.json 的节点/边同步到 Neo4j(可选高级组件,MERGE 幂等)。
Neo4j 官方驱动为**可选依赖**,不在 requirements.txt 中:
pip install neo4j # 1. 安装驱动
docker compose --profile advanced up -d neo4j # 2. 启动图库
python scripts/sync_neo4j.py # 3. 同步
python scripts/sync_neo4j.py --dry-run # 只统计,不连接
连接参数:--uri/--user/--password,或环境变量 NEO4J_URI/NEO4J_USER/NEO4J_PASSWORD。
退出码:0 成功;1 连接失败;2 缺少 neo4j 驱动。
"""
from __future__ import annotations
import argparse
import json
import os
import sys
from collections import Counter
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
def _props(d: dict, skip: tuple[str, ...]) -> dict:
"""抽取 Neo4j 属性:标量原样保留,列表/dict 序列化为 JSON 字符串(None 不落库)。"""
out: dict = {}
for k, v in d.items():
if k in skip or v is None:
continue
if isinstance(v, (str, int, float, bool)):
out[k] = v
else:
out[k] = json.dumps(v, ensure_ascii=False)
return out
def _safe_ident(value: object, default: str) -> str:
"""标签/关系类型只允许字母数字下划线(防 Cypher 注入)。"""
s = str(value or default)
return s if s.replace("_", "").isalnum() else default
def main() -> int:
ap = argparse.ArgumentParser(description="把 data/graph.json 同步到 Neo4j(MERGE 幂等)")
ap.add_argument("--graph", default=str(ROOT / "data" / "graph.json"), help="图谱 JSON 快照路径")
ap.add_argument("--uri", default=os.environ.get("NEO4J_URI", "bolt://localhost:7687"))
ap.add_argument("--user", default=os.environ.get("NEO4J_USER", "neo4j"))
ap.add_argument("--password", default=os.environ.get("NEO4J_PASSWORD", "careernexus"))
ap.add_argument("--dry-run", action="store_true",
help="只打印将写入的节点/边统计,不连接 Neo4j")
args = ap.parse_args()
graph = json.loads(Path(args.graph).read_text(encoding="utf-8"))
nodes, edges = graph["nodes"], graph["edges"]
by_type = Counter(n.get("type", "Node") for n in nodes)
by_rel = Counter(e.get("rel", "REL") for e in edges)
print(f"图谱文件:{args.graph}")
print(f"节点 {len(nodes)} 个:" + ",".join(f"{t}×{c}" for t, c in sorted(by_type.items())))
print(f"边 {len(edges)} 条:" + ",".join(f"{r}×{c}" for r, c in sorted(by_rel.items())))
if args.dry_run:
print("--dry-run:未连接 Neo4j,以上为将写入的节点/边统计。")
return 0
try:
from neo4j import GraphDatabase
except ImportError:
print("缺少 Neo4j 官方驱动(可选依赖,不在 requirements.txt)。\n"
"请先安装:\n pip install neo4j", file=sys.stderr)
return 2
try:
driver = GraphDatabase.driver(args.uri, auth=(args.user, args.password))
driver.verify_connectivity()
except Exception as exc:
print(f"无法连接 Neo4j({args.uri}):{exc}\n"
"请先启动图库:\n"
" docker compose --profile advanced up -d neo4j", file=sys.stderr)
return 1
with driver, driver.session() as session:
tx = session.begin_transaction()
try:
for n in nodes:
label = _safe_ident(n.get("type"), "Node")
tx.run(f"MERGE (n:{label} {{id: $id}}) SET n += $props",
id=n["id"], props=_props(n, ("id",)))
for e in edges:
rel = _safe_ident(e.get("rel"), "REL")
# REQUIRES 边的 intervals/sources/importance 等属性随边写入
tx.run(f"MATCH (a {{id: $s}}), (b {{id: $t}}) "
f"MERGE (a)-[r:{rel}]->(b) SET r += $props",
s=e["source"], t=e["target"],
props=_props(e, ("source", "target", "rel")))
tx.commit()
except Exception:
tx.rollback()
raise
print(f"同步完成:节点 {len(nodes)} 个、边 {len(edges)} 条已 MERGE 写入 {args.uri}。")
return 0
if __name__ == "__main__":
sys.exit(main())
+48
View File
@@ -0,0 +1,48 @@
"""pytest 共享夹具:确保数据与图谱存在(数据已随工程提交,缺失时现场生成)。"""
from __future__ import annotations
import subprocess
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
PY = sys.executable
@pytest.fixture(scope="session", autouse=True)
def ensure_data() -> None:
if not (ROOT / "data" / "jd_raw").exists():
subprocess.run([PY, str(ROOT / "scripts" / "generate_dataset.py")], check=True)
@pytest.fixture(scope="session")
def graph_path() -> Path:
path = ROOT / "data" / "graph.json"
if not path.exists():
subprocess.run([PY, str(ROOT / "scripts" / "run_demo.py")], check=True)
return path
@pytest.fixture(scope="session", autouse=True)
def isolated_state_db(tmp_path_factory):
"""整个测试会话把 SQLite 状态库重定向到临时目录,避免污染真实 data/careernexus.db。
audit_path/pending_path 仅作为旧文件迁移种子,指向不存在的临时路径即跳过迁移。
"""
from app.api import routes
tmp = tmp_path_factory.mktemp("state_db")
keys = ("state_db_path", "audit_path", "pending_path")
originals = {k: routes._cfg.get(k) for k in keys}
routes._cfg["state_db_path"] = str(tmp / "state.db")
routes._cfg["audit_path"] = str(tmp / "audit.jsonl")
routes._cfg["pending_path"] = str(tmp / "pending.json")
yield
for k, v in originals.items():
if v is None:
routes._cfg.pop(k, None)
else:
routes._cfg[k] = v
+427
View File
@@ -0,0 +1,427 @@
"""API 测试:TestClient 覆盖 health / jobs / graph / resume / match / learning-path / review。"""
from __future__ import annotations
import json
import pytest
from fastapi.testclient import TestClient
from app.api.routes import app
from app.config import ROOT
@pytest.fixture(scope="module")
def client(graph_path):
with TestClient(app) as c: # 触发 startup 加载图谱
yield c
def test_health(client):
r = client.get("/api/health")
assert r.status_code == 200
body = r.json()
assert body["code"] == 0 and body["request_id"]
assert body["data"]["graph"]["jobs"] >= 15
def test_graph_endpoint(client):
r = client.get("/api/graph")
assert r.status_code == 200
data = r.json()["data"]
assert len(data["nodes"]) > 100 and len(data["edges"]) > 100
types = {n["type"] for n in data["nodes"]}
assert "Job" in types and "Skill" in types
# 行业维度:Industry 节点与 (Job)-[:BELONGS_TO]->(Industry) 边
assert "Industry" in types
industries = {n["title"] for n in data["nodes"] if n["type"] == "Industry"}
assert {"人工智能", "互联网/软件", "金融科技", "智能制造"} <= industries
assert any(e.get("rel") == "BELONGS_TO" for e in data["edges"])
def test_jobs_list_and_filter(client):
r = client.get("/api/jobs")
assert r.status_code == 200
jobs = r.json()["data"]
assert len(jobs) >= 15
r2 = client.get("/api/jobs", params={"keyword": "Java"})
titles = [j["title"] for j in r2.json()["data"]]
assert titles == ["Java开发工程师"]
def test_job_detail_and_evolution(client):
r = client.get("/api/jobs/job_Java开发工程师")
assert r.status_code == 200
data = r.json()["data"]
assert data["definition"]["title"] == "Java开发工程师"
assert data["snapshot"]["skills"]
r2 = client.get("/api/jobs/job_Java开发工程师/evolution")
events = r2.json()["data"]["events"]
assert events and {"新增", "退出"} & {e["change_type"] for e in events}
assert all("数据源" in e["description"] for e in events)
assert client.get("/api/jobs/不存在").status_code == 404
def test_resume_parse_masks_sensitive(client):
text = "姓名:陈晓\n手机:13900001111\n邮箱:chenx@example.com\n4年经验,熟悉Java与MySQL"
r = client.post("/api/resume/parse", json={"text": text})
assert r.status_code == 200
p = r.json()["data"]
assert "13900001111" not in json.dumps(p, ensure_ascii=False)
assert "陈晓" not in json.dumps(p, ensure_ascii=False)
assert set(p["skills"]) == {"Java", "MySQL"}
def test_match_and_learning_path(client):
r = client.post("/api/match", json={
"job_id": "job_Java开发工程师",
"skills": ["Java", "Spring Boot", "Spring Cloud", "MySQL", "Redis", "分布式系统"],
"years": 5, "scenarios": ["电商", "金融"]})
assert r.status_code == 200
m = r.json()["data"]
assert m["is_match"] and m["total_score"] >= 60
assert m["dimension_scores"]["required"] == 100.0
r2 = client.post("/api/learning-path", json={
"job_id": "job_Java开发工程师", "known_skills": ["Java"]})
assert r2.status_code == 200
path = r2.json()["data"]["learning_path"]
assert path and "missing_required" in r2.json()["data"]
def test_review_flow(client, tmp_path, monkeypatch):
"""审核流:先造一个待审核候选,再 accept 写入图谱并留痕(SQLite 状态库重定向到临时目录)。"""
import app.api.routes as routes
from app.discover.cluster import save_pending
from app.models import JobDefinition
from app.storage import db as state_db
# 把状态库/迁移种子/图谱路径重定向到 tmp,避免污染工程内已提交数据
db_path = tmp_path / "state.db"
monkeypatch.setitem(routes._cfg, "state_db_path", str(db_path))
monkeypatch.setitem(routes._cfg, "audit_path", str(tmp_path / "audit.jsonl")) # 旧留痕迁移种子(不存在则跳过)
monkeypatch.setitem(routes._cfg, "graph_path", str(tmp_path / "graph.json"))
cand = JobDefinition(job_id="cand_测试新岗位", title="测试新岗位",
required_skills=["Python"], status="pending",
source_count=10, company_count=6)
save_pending([cand], db_path)
r = client.post("/api/review", json={
"candidate_id": "cand_测试新岗位", "action": "accept",
"operator": "张老师", "reason": "证据充分"})
assert r.status_code == 200
data = r.json()["data"]
assert data["accepted"]["status"] == "active"
assert data["review"]["before"]["status"] == "pending"
assert data["review"]["operator"] == "张老师"
# 审核后能在岗位列表查到
assert client.get("/api/jobs/job_测试新岗位").status_code == 200
# 留痕落在 SQLite(结构与旧 jsonl 行一致),候选已从待审核分区移除
recs = state_db.get_audit(db_path)
assert recs and recs[0]["operator"] == "张老师" and recs[0]["action"] == "accept"
assert state_db.get_candidate(db_path, "cand_测试新岗位") is None
# /api/audit 兼容查询
r2 = client.get("/api/audit", params={"node_id": "cand_测试新岗位"})
assert r2.status_code == 200 and r2.json()["data"][0]["candidate_id"] == "cand_测试新岗位"
# 不存在的候选返回 404
assert client.post("/api/review", json={
"candidate_id": "cand_不存在", "action": "accept", "operator": "x"}).status_code == 404
def test_node_update_flow(client, tmp_path, monkeypatch):
"""节点编辑:白名单字段更新 + 图谱落盘 + 审核留痕(SQLite 状态库重定向到临时目录)。"""
import app.api.routes as routes
monkeypatch.setitem(routes._cfg, "graph_path", str(tmp_path / "graph.json"))
monkeypatch.setitem(routes._cfg, "state_db_path", str(tmp_path / "state.db"))
prev_nodes = client.get("/api/graph").json()["data"]["nodes"]
prev_level = next(n for n in prev_nodes if n["id"] == "job_Java开发工程师").get("level")
r = client.post("/api/graph/node/update", json={
"node_id": "job_Java开发工程师",
"updates": {"level": "高级", "weight": 0.9, "hack": "ignored"},
"operator": "张老师", "reason": "webui 校正"})
assert r.status_code == 200
data = r.json()["data"]
assert data["node"]["level"] == "高级" and data["node"]["weight"] == 0.9
assert "hack" not in data["node"]
assert data["audit"]["before"]["level"] == prev_level
# Job 节点编辑同步 JobDefinition:级别同步回期望年限,/api/jobs 一致
assert data["node"]["expected_years"] == 6
d = client.get("/api/jobs/job_Java开发工程师").json()["data"]["definition"]
assert d["expected_years"] == 6
assert (tmp_path / "graph.json").exists()
# 留痕可查
r2 = client.get("/api/audit", params={"node_id": "job_Java开发工程师"})
assert r2.status_code == 200
recs = r2.json()["data"]
assert recs and recs[0]["operator"] == "张老师"
# 不存在的节点 / 空更新
assert client.post("/api/graph/node/update", json={
"node_id": "不存在", "updates": {"level": "x"}, "operator": "x"}).status_code == 404
assert client.post("/api/graph/node/update", json={
"node_id": "job_Java开发工程师", "updates": {"hack": 1}, "operator": "x"}).status_code == 400
def test_jd_lookup(client):
"""JD 原文回查:合法 source_id 返回记录,非法输入 400/404。"""
g = client.get("/api/graph").json()["data"]
sid = next(s for e in g["edges"] for s in e.get("sources", []))
r = client.get("/api/jd", params={"sid": sid})
assert r.status_code == 200
rec = r.json()["data"]
assert rec["source_id"] == sid and rec["text"] and rec["job_title"]
assert client.get("/api/jd", params={"sid": "无行号"}).status_code == 400
assert client.get("/api/jd", params={"sid": "../evil.jsonl#1"}).status_code == 400
assert client.get("/api/jd", params={"sid": "batch_2019.jsonl#99999"}).status_code == 404
def _minimal_pdf(text: str) -> bytes:
"""构造含可提取文本的最小合法 PDF(ASCII 内容)。"""
stream = f"BT /F1 12 Tf 72 720 Td ({text}) Tj ET".encode()
objs = [
b"<< /Type /Catalog /Pages 2 0 R >>",
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] "
b"/Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>",
b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
b"<< /Length " + str(len(stream)).encode() + b" >>\nstream\n" + stream + b"\nendstream",
]
out = b"%PDF-1.4\n"
offsets = []
for i, body in enumerate(objs, 1):
offsets.append(len(out))
out += f"{i} 0 obj\n".encode() + body + b"\nendobj\n"
xref_pos = len(out)
out += f"xref\n0 {len(objs)+1}\n".encode() + b"0000000000 65535 f \n"
for off in offsets:
out += f"{off:010d} 00000 n \n".encode()
out += (f"trailer\n<< /Size {len(objs)+1} /Root 1 0 R >>\n"
f"startxref\n{xref_pos}\n%%EOF").encode()
return out
def test_resume_file_upload_docx_and_pdf(client, tmp_path):
"""简历文件上传:docx/pdf 均解析出技能并脱敏,非法格式与超限被拒。"""
from docx import Document
# docx:python-docx 真实生成
doc = Document()
doc.add_paragraph("姓名:李晓峰 手机:13912345678")
doc.add_paragraph("6年 Java 后端经验,熟悉 Java、Spring Boot、MySQL、Redis")
p = tmp_path / "r.docx"
doc.save(str(p))
r = client.post("/api/resume/parse/file",
files={"file": ("r.docx", p.read_bytes(), "application/octet-stream")})
assert r.status_code == 200
data = r.json()["data"]
assert "Java" in data["skills"] and "MySQL" in data["skills"]
assert "13912345678" not in data["raw_masked_text"] # 已脱敏
assert data["name"].startswith("李") and "晓峰" not in data["name"]
# pdf:最小合法 PDF(ASCII 技能名,验证 pypdf 链路)
pdf = _minimal_pdf("Java Spring Boot MySQL Redis Docker Kubernetes")
r2 = client.post("/api/resume/parse/file",
files={"file": ("r.pdf", pdf, "application/pdf")})
assert r2.status_code == 200
skills2 = r2.json()["data"]["skills"]
assert "Java" in skills2 and "Kubernetes" in skills2
# txt 也走同一入口
r3 = client.post("/api/resume/parse/file",
files={"file": ("r.txt", "熟悉 Python 与 TensorFlow".encode(), "text/plain")})
assert r3.status_code == 200 and "Python" in r3.json()["data"]["skills"]
# 非法格式 / 空文件
bad = client.post("/api/resume/parse/file",
files={"file": ("r.exe", b"MZ", "application/octet-stream")})
assert bad.status_code == 400
empty = client.post("/api/resume/parse/file",
files={"file": ("r.pdf", b"", "application/pdf")})
assert empty.status_code == 400
def test_match_normalizes_manual_skills(client):
"""手动录入技能经大小写/别名归一化后再匹配:redis→Redis、go→Go、K8s→Kubernetes。"""
r = client.post("/api/match", json={
"job_id": "job_Go开发工程师",
"skills": ["go", "redis", "grpc", "K8s", "kafka", "微服务"],
"years": 5})
assert r.status_code == 200
m = r.json()["data"]
hit = set(m["matched_required"]) | set(m["matched_preferred"])
assert {"Go", "Redis", "gRPC", "Kubernetes", "Kafka", "微服务"} <= hit
assert "redis" not in hit and "K8s" not in hit # 返回的是标准名
def test_skill_normalize_semantic_fallback():
"""normalize 三级容错:别名→包含匹配(取最长)→原样返回。"""
from app.extract.base import SkillMatcher, load_lexicon
m = SkillMatcher(load_lexicon())
assert m.normalize("redis") == "Redis" # 大小写
assert m.normalize("K8s") == "Kubernetes" # 别名
assert m.normalize("微服务架构") == "微服务" # 中文包含
assert m.normalize("Java语言") == "Java" # 后缀容错
assert m.normalize("JavaScript") == "JavaScript" # 最长优先,不被 Java 截胡
assert m.normalize("222") == "222" # 词表外原样返回
def test_resume_structured_and_export(client):
"""结构化简历:上传 docx 提取照片/基本信息/分节;导出 DOCX 合法且含关键词。"""
from app.config import ROOT as _ROOT
p = _ROOT / "data" / "resumes" / "resume_full.docx"
r = client.post("/api/resume/parse/file",
files={"file": ("resume_full.docx", p.read_bytes(), "application/octet-stream")})
assert r.status_code == 200
prof = r.json()["data"]
assert prof["photo_b64"].startswith("data:image/") # 照片识别
assert prof["gender"] == "男" and prof["education"] == "本科"
assert prof["address"].endswith("***") # 住址脱敏
titles = [s["title"] for s in prof["sections"]]
assert "教育经历" in titles and "项目经历" in titles # 分节
assert "13812345678" not in prof["raw_masked_text"] # 手机脱敏
# 匹配后导出 DOCX
m = client.post("/api/match", json={
"job_id": "job_Java开发工程师",
"skills": prof["skills"], "years": prof["years"]}).json()["data"]
r2 = client.post("/api/resume/export", json={"profile": prof, "match": m})
assert r2.status_code == 200
assert r2.headers["content-type"].startswith("application/vnd.openxmlformats")
import io, zipfile
z = zipfile.ZipFile(io.BytesIO(r2.content))
assert "word/document.xml" in z.namelist() # 合法 docx
assert any(n.startswith("word/media/") for n in z.namelist()) # 照片已嵌入
def test_runtime_settings_and_token_guard(client, monkeypatch, tmp_path):
"""设置读写 config.json(白名单+校验)、LLM 热生效判定、访问令牌鉴权。"""
import app.config as cfgmod
from app.api import routes
monkeypatch.setattr(cfgmod, "CONFIG_JSON", tmp_path / "config.json")
monkeypatch.setattr(routes, "load_runtime_settings", cfgmod.load_runtime_settings)
# 保存设置:host/port 校验
assert client.post("/api/config", json={"host": "8.8.8.8"}).status_code == 400
assert client.post("/api/config", json={"port": 80}).status_code == 400
r = client.post("/api/config", json={
"host": "0.0.0.0", "port": 9012, "llm_api_key": "sk-test-1234", "mode": "demo"})
assert r.status_code == 200
data = r.json()["data"]
assert data["restart_required"] is True # 保存的监听与运行中不同
assert data["llm"]["configured"] is True # LLM 热生效
assert data["llm"]["api_key_masked"].endswith("1234")
assert "sk-test-1234" not in r.text # 密钥不落明文
saved = cfgmod.load_runtime_settings()
assert saved["llm_api_key"] == "sk-test-1234" and saved["port"] == 9012
# 非白名单键不写入
client.post("/api/config", json={})
assert "hack" not in cfgmod.load_runtime_settings()
# 令牌鉴权:设置令牌后 /api/* 需带 X-Access-Token,health 与 GET config 豁免
monkeypatch.setitem(routes._cfg, "access_token", "tok-abc")
assert client.get("/api/jobs").status_code == 401
assert client.get("/api/jobs", headers={"X-Access-Token": "tok-abc"}).status_code == 200
assert client.get("/api/health").status_code == 200
assert client.get("/api/config").status_code == 200
assert client.post("/api/config", json={}).status_code == 401
monkeypatch.delitem(routes._cfg, "access_token")
def test_get_extractor_llm_key_from_config():
"""get_extractor:config.json 提供 llm_api_key 即启用 LLM 通道,无密钥回退离线。"""
from app.extract.llm import LLMExtractor, get_extractor
from app.extract.offline import OfflineExtractor
e1 = get_extractor({"llm_api_key": "sk-x", "llm_base_url": "http://x/v1",
"llm_model": "m", "lexicon_path": None})
assert isinstance(e1, LLMExtractor)
e2 = get_extractor({"lexicon_path": None, "llm_api_key_env": "NO_SUCH_ENV_VAR"})
assert isinstance(e2, OfflineExtractor)
def test_embed_model_status_and_config(client, tmp_path, monkeypatch):
"""语义权重状态:未配置/假目录/缺依赖时 ready=False 且状态字段正确;设置可保存。"""
import app.config as cfgmod
monkeypatch.setattr(cfgmod, "CONFIG_JSON", tmp_path / "config.json") # 不写真实 config.json
from app.match import semantic
assert not semantic.weights_present("") and not semantic.weights_present(tmp_path)
# 伪造一个"文件齐全"的权重目录
for f in ["config.json", "model.safetensors", "tokenizer.json"]:
(tmp_path / f).write_text("{}")
assert semantic.weights_present(tmp_path)
st = semantic.status({"embed_model_path": str(tmp_path)})
assert st["configured"] and st["weights_present"]
assert st["ready"] == st["deps_installed"] # 本环境未装 sentence-transformers → False
r = client.post("/api/config", json={"embed_model_path": str(tmp_path)})
assert r.status_code == 200
emb = r.json()["data"]["embed"]
assert emb["configured"] and emb["weights_present"]
# health 同步暴露
h = client.get("/api/health").json()["data"]
assert h["embed"]["configured"] is True
# 清除
r2 = client.post("/api/config", json={"embed_model_path": ""})
assert r2.json()["data"]["embed"]["configured"] is False
def test_embed_dual_provider(client, tmp_path, monkeypatch):
"""语义双模式:api 模式有 Key 即就绪(无需权重);local 模式需权重+依赖。"""
import app.config as cfgmod
monkeypatch.setattr(cfgmod, "CONFIG_JSON", tmp_path / "config.json") # 不写真实 config.json
from app.match import semantic
st_api = semantic.status({"embed_provider": "api", "embed_api_key": "sk-1234"})
assert st_api["ready"] is True and st_api["provider"] == "api"
st_local = semantic.status({"embed_provider": "local", "embed_model_path": ""})
assert st_local["ready"] is False
# most_similar 在未就绪时安全返回 None
assert semantic.most_similar("微服务", {"embed_provider": "local"}, {"skills": [{"name": "微服务"}]}) is None
r = client.post("/api/config", json={
"embed_provider": "api", "embed_api_key": "sk-dash-9999"})
emb = r.json()["data"]["embed"]
assert emb["ready"] is True and emb["api_key_masked"].endswith("9999")
assert "sk-dash-9999" not in r.text
# 还原:回到本地模式并清除 key
r2 = client.post("/api/config", json={"embed_provider": "local", "embed_api_key": ""})
assert r2.json()["data"]["embed"]["ready"] is False
def test_match_batch(client):
"""批量匹配:多份简历打分降序、坏文件记 error 不拖垮整批。"""
from app.config import ROOT as _ROOT
resumes = _ROOT / "data" / "resumes"
files = []
for fn in ["resume_full.txt", "resume_partial.txt", "resume_mismatch.txt"]:
files.append(("files", (fn, (resumes / fn).read_bytes(), "text/plain")))
files.append(("files", ("bad.exe", b"MZ", "application/octet-stream")))
r = client.post("/api/match/batch",
data={"job_id": "job_Java开发工程师"}, files=files)
assert r.status_code == 200
data = r.json()["data"]
assert data["total"] == 4 and data["ok"] == 3
scores = [x["total_score"] for x in data["results"] if "error" not in x]
assert scores == sorted(scores, reverse=True) # 按总分降序
assert any("error" in x for x in data["results"]) # 坏文件有错误记录
top = data["results"][0]
assert top["matched"] and top["name"] # 第一名有命中与脱敏姓名
assert client.post("/api/match/batch",
data={"job_id": "不存在"}, files=files[:1]).status_code == 404
def test_semantic_setup_endpoints(client, monkeypatch):
"""一键配置:状态接口默认空闲;已就绪时幂等快速完成;重复启动不并发。"""
import app.api.routes as routes
from app.config import ROOT as _ROOT
# 让运行配置处于"语义已就绪"状态,命中幂等快路径(避免测试里真跑 pip)
monkeypatch.setitem(routes._cfg, "embed_provider", "local")
monkeypatch.setitem(routes._cfg, "embed_model_path", str(_ROOT / "models" / "bge-m3"))
routes._semantic_setup.update({"running": False, "stage": "", "log": [], "error": "", "done": False})
st = client.get("/api/semantic/setup/status").json()["data"]
assert st["running"] is False and "embed" in st
r = client.post("/api/semantic/setup")
assert r.status_code == 200
import time
st2 = st
for _ in range(50):
st2 = client.get("/api/semantic/setup/status").json()["data"]
if not st2["running"]:
break
time.sleep(0.2)
assert st2["done"] is True and st2["error"] == ""
assert st2["embed"]["ready"] is True
+76
View File
@@ -0,0 +1,76 @@
"""清洗层测试:植入的抄袭簇必须被检出、噪声过滤、时间衰减公式。"""
from __future__ import annotations
import json
import math
from datetime import date
from pathlib import Path
import pytest
from app.cleaning.decay import apply_decay, decay_weight
from app.cleaning.dedup import MinHash, find_near_duplicates, shingles
from app.cleaning.noise import filter_noise, is_noise
from app.ingest.loader import load_jd_dir
ROOT = Path(__file__).resolve().parents[1]
@pytest.fixture(scope="module")
def cleaned():
records, errors = load_jd_dir(ROOT / "data" / "jd_raw")
filter_noise(records)
clusters = find_near_duplicates(records)
meta = json.loads((ROOT / "data" / "jd_raw" / "_meta.json").read_text(encoding="utf-8"))
return records, errors, clusters, meta
def test_minhash_identical_text_similarity_one():
mh = MinHash()
s1 = mh.signature(shingles("熟练掌握Java与Spring Cloud,负责微服务开发"))
s2 = mh.signature(shingles("熟练掌握Java与Spring Cloud,负责微服务开发"))
assert mh.estimate(s1, s2) == 1.0
def test_minhash_distinguishes_different_text():
mh = MinHash()
s1 = mh.signature(shingles("熟练掌握Java与Spring Cloud,负责微服务开发"))
s2 = mh.signature(shingles("精通Verilog与FPGA,负责芯片逻辑设计与验证"))
assert mh.estimate(s1, s2) < 0.3
def test_planted_dup_clusters_detected(cleaned):
"""植入的抄袭簇必须全部被检出:副本被降权且指向最早来源。"""
records, _, clusters, meta = cleaned
by_jid = {r.jid: r for r in records}
assert len(clusters) >= len(meta["dup_groups"]) * 0.9
for keeper_jid, copy_jid in meta["dup_groups"]:
copy = by_jid[copy_jid]
assert copy.is_duplicate, f"抄袭副本未被检出: {copy_jid}"
assert copy.dup_of == keeper_jid
assert copy.weight < by_jid[keeper_jid].weight # 副本降权、最早来源保留
def test_planted_noise_detected(cleaned):
"""植入噪声:整条广告被标记,混排尾注被剔除。"""
records, _, _, meta = cleaned
by_jid = {r.jid: r for r in records}
for jid in meta["noise_jids"]:
rec = by_jid[jid]
assert rec.is_noise or not is_noise(rec.text), f"噪声未处理: {jid}"
def test_decay_formula():
w = decay_weight(date(2020, 1, 1), date(2026, 1, 1), lam=0.35)
assert abs(w - math.exp(-0.35 * (2191 / 365.25))) < 1e-3
assert decay_weight(date(2026, 1, 1), date(2026, 1, 1)) == 1.0
older = decay_weight(date(2019, 6, 1), date(2026, 6, 1))
newer = decay_weight(date(2025, 6, 1), date(2026, 6, 1))
assert older < newer # 越旧权重越低(时滞衰减)
def test_apply_decay_multiplies_existing(cleaned):
records, _, _, _ = cleaned
apply_decay(records, ref_date=date(2026, 12, 28), lam=0.35)
for r in records:
assert 0 < r.weight <= 1.0
+68
View File
@@ -0,0 +1,68 @@
"""演化分析测试:构造两季度(多窗口)数据验证 新增/增强/减弱/退出 事件。"""
from __future__ import annotations
from app.evolution.analyzer import analyze
from app.graph.tkg import TemporalKG
from app.models import GraphObservation, JobDefinition
def _obs(job: str, skill: str, window: str, n: int) -> list[GraphObservation]:
"""生成 n 条同窗口观察(模拟窗口内 n 条 JD 提到该技能)。"""
return [GraphObservation(job_id=job, skill=skill, importance="required",
weight=1.0, window=window, source_ids=[f"{window}#{i}"])
for i in range(n)]
def test_quarter_window_events():
kg = TemporalKG()
kg.add_job(JobDefinition(job_id="job_A", title="岗位A"))
observations: list[GraphObservation] = []
# 老技能 Legacy:2024Q1-Q3 稳定出现(3 窗口),Q4 与次年 Q1 连续消失 → 退出
for w in ["2024Q1", "2024Q2", "2024Q3"]:
observations += _obs("job_A", "Legacy技能", w, 3)
# 新技能 NewSkill:2024Q4 首次出现且支撑 >=2 → 新增
observations += _obs("job_A", "NewSkill", "2024Q4", 3)
# 增强技能:3 → 9(>=1.5 倍且差 >=3)
observations += _obs("job_A", "GrowSkill", "2024Q3", 3)
observations += _obs("job_A", "GrowSkill", "2024Q4", 9)
# 减弱技能:8 → 3(<=0.5 倍且差 >=3)
observations += _obs("job_A", "ShrinkSkill", "2024Q3", 8)
observations += _obs("job_A", "ShrinkSkill", "2024Q4", 3)
for o in observations: # 与 pipeline 一致:观察先写入图谱,再分析演化
kg.observe(o)
events = analyze(kg, observations=observations)
by_type = {}
for e in events:
by_type.setdefault(e.change_type, []).append(e)
# 新增:LegacySkill 首现 + NewSkill 首现
new_skills = {e.skill for e in by_type.get("新增", [])}
assert "NewSkill" in new_skills and "Legacy技能" in new_skills
# 退出:Legacy 技能在 2024Q4 判定(Q4、次年 Q1 连续为 0,且已稳定 3 窗口)
exits = [e for e in by_type.get("退出", []) if e.skill == "Legacy技能"]
assert exits and exits[0].window == "2024Q4"
assert exits[0].prev_weight > 0 and exits[0].curr_weight == 0
assert "数据源" in exits[0].description # 中文说明注明数据源
# 增强 / 减弱
assert any(e.skill == "GrowSkill" and e.window == "2024Q4"
for e in by_type.get("增强", []))
assert any(e.skill == "ShrinkSkill" and e.window == "2024Q4"
for e in by_type.get("减弱", []))
# 退出事件关闭了图谱区间
assert kg.g["job_A"]["Legacy技能"]["intervals"][-1][1] == "2024Q4"
# 事件保存前后权重、时间窗、来源
grow = next(e for e in by_type["增强"] if e.skill == "GrowSkill")
assert grow.prev_weight == 3.0 and grow.curr_weight == 9.0
assert grow.source_ids
def test_no_spurious_exit_for_new_skill():
"""只出现 1-2 个窗口的技能消失不报“退出”(抑制稀疏噪声)。"""
kg = TemporalKG()
kg.add_job(JobDefinition(job_id="job_B", title="岗位B"))
observations = _obs("job_B", "一次性技能", "2024Q1", 3) # 只出现 1 个窗口
observations += _obs("job_B", "常驻技能", "2024Q1", 2)
observations += _obs("job_B", "常驻技能", "2024Q2", 2)
events = analyze(kg, observations=observations)
assert not [e for e in events if e.change_type == "退出" and e.skill == "一次性技能"]
+131
View File
@@ -0,0 +1,131 @@
"""抽取层测试:Schema 校验、坏 JSON 重试与降级、多源投票、离线抽取与别名归一。"""
from __future__ import annotations
import pytest
from pydantic import ValidationError
from app.extract.base import SkillMatcher, load_lexicon, vote_field
from app.extract.llm import LLMExtractor
from app.extract.offline import OfflineExtractor
from app.models import ExtractionResult, JDRecord
SAMPLE_TEXT = """岗位名称:Java开发工程师
某科技公司诚邀优秀人才加入。
岗位职责:
1、负责后端服务的设计与开发;
2、参与核心系统的性能优化;
任职要求:
1、3年以上相关工作经验;
2、熟练掌握Java、Spring Boot、MySQL、Redis;
3、具有电商或金融行业经验者优先;
加分项:
1、了解Docker、Kubernetes者优先;
"""
def _record(text: str = SAMPLE_TEXT) -> JDRecord:
return JDRecord(jid="X1", job_title="Java开发工程师", company="某科技",
publish_date="2024-05-01", text=text)
def test_offline_extract_fields_and_evidence():
ext = OfflineExtractor(load_lexicon())
r = ext.extract(_record())
assert r.job_title == "Java开发工程师"
assert set(r.required_skills) == {"Java", "Spring Boot", "MySQL", "Redis"}
assert set(r.preferred_skills) == {"Docker", "Kubernetes"}
assert r.responsibilities == ["负责后端服务的设计与开发;", "参与核心系统的性能优化;"]
assert set(r.scenarios) == {"电商", "金融"}
assert r.evidence["required_skills"] # 每字段附证据片段
assert r.confidence == 1.0
def test_alias_normalization():
matcher = SkillMatcher(load_lexicon())
text = "要求LLM应用开发经验,熟悉K8s与SpringBoot,了解检索增强生成"
names = matcher.find_names(text)
assert "大模型应用开发" in names # LLM应用开发 归一化
assert "Kubernetes" in names # K8s 归一化
assert "Spring Boot" in names # SpringBoot 归一化
assert "RAG" in names # 检索增强生成 归一化
def test_tail_section_not_mixed_into_preferred():
"""尾部“团队与福利/福利待遇”段落不属于加分区:其中的技能词不得混入加分技能。"""
text = SAMPLE_TEXT + "团队与福利:\n团队标配Kubernetes与Terraform,提供外部培训预算。\n"
ext = OfflineExtractor(load_lexicon())
r = ext.extract(_record(text))
assert set(r.preferred_skills) == {"Docker", "Kubernetes"}
assert "Terraform" not in r.preferred_skills
def test_java_not_confused_with_javascript():
matcher = SkillMatcher(load_lexicon())
names = matcher.find_names("精通JavaScript与Vue,不写Java后端")
assert "JavaScript" in names
assert "Java" in names # “Java后端”中的 Java 是独立词
only_js = matcher.find_names("精通JavaScript与Vue")
assert "Java" not in only_js
def test_schema_validation_rejects_bad_payload():
with pytest.raises(ValidationError):
ExtractionResult(job_title="X", required_skills="不是列表")
with pytest.raises(ValidationError):
ExtractionResult(job_title="X", confidence="不是数字")
def _make_llm() -> LLMExtractor:
return LLMExtractor(api_key="fake", base_url="http://localhost:1", model="m",
timeout=0.1, fallback=OfflineExtractor(load_lexicon()))
def test_llm_bad_json_retries_then_success(monkeypatch):
"""坏 JSON 触发重试,第 3 次返回合法 JSON 则成功。"""
llm = _make_llm()
calls = {"n": 0}
good = ('{"job_title": "Java开发工程师", "responsibilities": [], '
'"required_skills": ["Java"], "preferred_skills": [], "scenarios": []}')
def fake_call(text):
calls["n"] += 1
return "{坏JSON" if calls["n"] < 3 else good
monkeypatch.setattr(llm, "_call_api", fake_call)
r = llm.extract(_record())
assert calls["n"] == 3
assert r.extractor == "llm"
assert r.required_skills == ["Java"]
def test_llm_all_fail_fallback_offline(monkeypatch):
"""重试耗尽(含超时/坏 JSON)降级到离线抽取,链路不断。"""
llm = _make_llm()
monkeypatch.setattr(llm, "_call_api", lambda text: (_ for _ in ()).throw(TimeoutError("超时")))
r = llm.extract(_record())
assert r.extractor == "offline"
assert "Java" in r.required_skills
def test_llm_schema_invalid_retries(monkeypatch):
"""返回 JSON 合法但不符合 Schema(类型错误)同样重试并降级。"""
llm = _make_llm()
monkeypatch.setattr(llm, "_call_api",
lambda text: '{"job_title": 123, "required_skills": "wrong"}')
r = llm.extract(_record())
assert r.extractor == "offline"
def test_vote_field_requires_agreement():
"""关键字段多源一致才入选:A 三源全票,B 两票,C 一票出局。"""
chosen, conf = vote_field([
(["A", "B", "C"], 1.0),
(["A", "B"], 1.0),
(["A"], 1.0),
], min_agreement=0.5)
assert chosen == ["A", "B"]
assert conf == 1.0
chosen2, _ = vote_field([(["A"], 1.0), (["B"], 1.0)], min_agreement=0.5)
assert chosen2 == ["A", "B"] # 平票 0.5 恰好达到门槛
chosen3, conf3 = vote_field([(["A"], 1.0), (["B"], 1.0), (["C"], 1.0)], min_agreement=0.5)
assert chosen3 == []
+93
View File
@@ -0,0 +1,93 @@
"""匹配引擎测试:评分权重、差距清单、可迁移技能、学习路径拓扑序、简历脱敏。"""
from __future__ import annotations
from app.extract.base import load_lexicon
from app.match.engine import MatchEngine, topological_stages
from app.match.resume import mask_text, parse_resume
from app.models import JobDefinition, ResumeProfile
JOB = JobDefinition(
job_id="job_X", title="X工程师", category="后端开发",
required_skills=["Java", "Spring Boot", "MySQL", "Redis"],
preferred_skills=["Docker", "Kubernetes"],
scenarios=["电商", "金融"], expected_years=3,
)
def _engine(**weights) -> MatchEngine:
return MatchEngine(lexicon=load_lexicon(), weights=weights or None)
def test_scoring_weights_and_dimensions():
"""四维加权:必备50/加分15/经验20/场景15。"""
profile = ResumeProfile(
skills=["Java", "Spring Boot", "Docker"], # 必备 2/4,加分 1/2
years=1.5, # 经验 1.5/3 = 0.5
scenarios=["电商"], # 场景 1/2
)
r = _engine().match(profile, JOB)
assert r.dimension_scores == {"required": 50.0, "preferred": 50.0,
"experience": 50.0, "scenario": 50.0}
# 总分 = 0.5*50 + 0.15*50 + 0.2*50 + 0.15*50 = 50
assert r.total_score == 50.0
assert not r.is_match # < 60 阈值
def test_custom_weights_override():
profile = ResumeProfile(skills=["Java", "Spring Boot", "MySQL", "Redis"],
years=3, scenarios=["电商", "金融"])
r = _engine(required=1.0, preferred=0.0, experience=0.0, scenario=0.0).match(profile, JOB)
assert r.total_score == 100.0 and r.is_match
def test_gap_list_and_transferable():
profile = ResumeProfile(skills=["Java", "Spring", "MySQL"], years=3,
scenarios=["电商", "金融"])
r = _engine().match(profile, JOB)
assert r.missing_required == ["Spring Boot", "Redis"]
assert r.matched_required == ["Java", "MySQL"]
# Spring 与缺失的 Spring Boot 同属“后端框架”→ 可迁移
assert "Spring" in r.transferable_skills
def test_learning_path_topological_order():
"""缺失技能按前置关系拓扑分层:Spring → Spring Boot → Spring Cloud。"""
lexicon = load_lexicon()
stages = topological_stages(["Spring Cloud", "Spring Boot", "Spring"], lexicon)
assert stages[0]["skills"] == ["Spring"]
assert stages[1]["skills"] == ["Spring Boot"]
assert stages[2]["skills"] == ["Spring Cloud"]
# 全部缺失时拓扑序稳定且不漏项
stages2 = topological_stages(["Kubernetes", "Docker", "Linux"], lexicon)
flat = [s for st in stages2 for s in st["skills"]]
assert flat == ["Linux", "Docker", "Kubernetes"]
def test_learning_path_acceptance():
"""每个阶段附带结构化验收标准:产出物/量化验收项/自测问答,且按技能类别差异化。"""
lexicon = load_lexicon()
stages = topological_stages(["Spring Cloud", "Spring Boot", "Spring"], lexicon)
for st in stages:
acc = st["acceptance"]
assert acc["deliverable"] and len(acc["criteria"]) >= 3 and len(acc["self_check"]) >= 2
assert all("{" not in c for c in acc["criteria"] + acc["self_check"]) # 占位符全部替换
assert any(s in acc["deliverable"] for s in st["skills"]) # 产出物点名本阶段技能
# 类别差异化:后端框架类与产品类的验收内容应不同
be = topological_stages(["Spring"], lexicon)[0]["acceptance"]
pm = topological_stages(["A/B测试"], lexicon)[0]["acceptance"]
assert be["category"] != pm["category"] or be["criteria"] != pm["criteria"]
def test_resume_masking():
text = "姓名:王小明\n手机:13812345678\n邮箱:wxm@example.com\n3年开发经验"
masked = mask_text(text)
assert "王小明" not in masked and "王**" in masked
assert "13812345678" not in masked and "138****78" in masked
assert "wxm@example.com" not in masked and "w***@example.com" in masked
def test_resume_parse_skills_and_years():
profile = parse_resume("姓名:李四\n5年后端开发经验,熟悉Java、MySQL与Docker。")
assert profile.years == 5.0
assert set(profile.skills) == {"Java", "MySQL", "Docker"}
assert "138" not in profile.raw_masked_text
+373
View File
@@ -0,0 +1,373 @@
"""P0 数据一致性测试:
- 审核入库岗位跨图谱重建持久化(真实区间/来源/场景行业边/演化事件);
- 节点编辑跨重建保留且与 /api/jobs 的 JobDefinition 一致;
- JD 手动导入(文件上传 / 文本粘贴 / 坏行清单 三路径);
- 访问令牌空串清除端到端;
- 语义权重相对路径(相对工程根解析)检测。
"""
from __future__ import annotations
import json
import time
from datetime import date
import pytest
from fastapi.testclient import TestClient
from app.api.routes import app
from app.config import load_config
from app.graph.tkg import TemporalKG
from app.models import JDRecord
@pytest.fixture(scope="module")
def client(graph_path):
with TestClient(app) as c: # 触发 startup 加载图谱
yield c
@pytest.fixture(autouse=True)
def _restore_kg():
"""本模块的 API 调用会改内存图谱;每个用例结束后从真实快照重载,避免串扰。"""
yield
import app.api.routes as routes
from app.config import resolve
gp = resolve(routes._cfg, "graph_path")
if gp.exists():
routes._state["kg"] = TemporalKG.load(gp)
def _jd(jid: str, title: str, pub: str, req: list[str], pref: list[str] | None = None) -> dict:
text = (f"岗位名称:{title}\n岗位职责:\n1、负责相关研发工作\n"
f"任职要求:\n1、熟悉 {'、'.join(req)}\n"
f"加分项:\n1、了解 {'、'.join(pref or ['团队协作'])}\n")
return {"jid": jid, "job_title": title, "company": f"测试公司{jid}", "city": "深圳",
"industry": "互联网/软件", "publish_date": pub, "text": text}
JAVA_RECORDS = [
_jd("j1", "Java开发工程师", "2022-01-15", ["Java", "MySQL"]),
_jd("j2", "Java开发工程师", "2022-04-15", ["Java", "Redis"]),
_jd("j3", "Java开发工程师", "2023-07-15", ["Java", "MySQL"]),
]
def _tmp_cfg(tmp_path, records) -> dict:
"""最小数据集 + 全部状态路径指向 tmp 的 pipeline 配置。"""
jd_dir = tmp_path / "jd_raw"
jd_dir.mkdir(exist_ok=True)
(jd_dir / "batch_2022.jsonl").write_text(
"\n".join(json.dumps(r, ensure_ascii=False) for r in records) + "\n", encoding="utf-8")
cfg = load_config()
cfg["jd_raw_dir"] = str(jd_dir)
cfg["graph_path"] = str(tmp_path / "graph.json")
cfg["state_db_path"] = str(tmp_path / "state.db")
cfg["audit_path"] = str(tmp_path / "audit.jsonl")
cfg["pending_path"] = str(tmp_path / "pending.json")
return cfg
def _wait_task(client, task_id: str) -> dict:
for _ in range(150):
t = client.get(f"/api/tasks/{task_id}").json()["data"]
if t["status"] in ("done", "failed"):
return t
time.sleep(0.2)
raise AssertionError("任务超时未完成")
# ---------- 1. 审核入库真实化 + 跨重建持久化 ----------
def test_accept_real_data_and_persists_across_rebuild(client, tmp_path, monkeypatch):
"""accept 用候选簇真实统计入库;pipeline 重建后岗位仍在(含场景/行业边与演化事件)。"""
import app.api.routes as routes
from app.discover.cluster import save_pending
from app.models import JobDefinition
from app.services.pipeline import run_pipeline
from app.storage import db as state_db
cfg = _tmp_cfg(tmp_path, JAVA_RECORDS)
db_path = tmp_path / "state.db"
monkeypatch.setitem(routes._cfg, "state_db_path", str(db_path))
monkeypatch.setitem(routes._cfg, "audit_path", str(tmp_path / "audit.jsonl"))
monkeypatch.setitem(routes._cfg, "graph_path", str(tmp_path / "graph_api.json"))
cand = JobDefinition(
job_id="cand_大模型工程师", title="大模型工程师", category="新岗位候选",
industries=["人工智能"], required_skills=["Python", "PyTorch"],
preferred_skills=["Docker"], scenarios=["智能客服"], status="pending",
expected_years=3, source_count=12, company_count=5,
support_source_ids=["import_20260801.jsonl#1", "import_20260801.jsonl#2"],
support_windows=["2025Q4", "2025Q3"])
save_pending([cand], db_path)
r = client.post("/api/review", json={
"candidate_id": "cand_大模型工程师", "action": "accept", "operator": "张老师"})
assert r.status_code == 200
# 当前图谱:真实区间(首个支持窗口 2025Q3 起)与真实来源、场景/行业边、新增事件
g = client.get("/api/graph").json()["data"]
req_edges = [e for e in g["edges"]
if e["source"] == "job_大模型工程师" and e.get("rel") == "REQUIRES"]
assert len(req_edges) == 3
assert all(e["intervals"][0] == ["2025Q3", None] for e in req_edges)
assert all(set(e["sources"]) == {"import_20260801.jsonl#1", "import_20260801.jsonl#2"}
for e in req_edges)
others = [e for e in g["edges"] if e["source"] == "job_大模型工程师"]
assert any(e["rel"] == "APPLIES_TO" and e["target"] == "场景::智能客服" for e in others)
assert any(e["rel"] == "BELONGS_TO" and e["target"] == "行业::人工智能" for e in others)
evs = [e for e in g["events"] if e["job_id"] == "job_大模型工程师"]
assert len(evs) == 3
assert all(e["change_type"] == "新增" and e["window"] == "2025Q3" for e in evs)
# 覆写层已记录(跨重建持久化的依据)
assert state_db.get_override(db_path, "job_大模型工程师")["kind"] == "job_accept"
# 全量重建:数据集中根本没有该岗位(2023 后新岗位),仅靠覆写层重放强制纳入
stats = run_pipeline(cfg)
assert stats["stages"]["overrides"]["jobs"] == 1
# 已入库岗位不会重新出现在待审核分区
assert state_db.get_candidate(db_path, "cand_大模型工程师") is None
kg = TemporalKG.load(tmp_path / "graph.json")
assert "job_大模型工程师" in kg.jobs
job = kg.jobs["job_大模型工程师"]
assert job.status == "active" and job.title == "大模型工程师"
edge = kg.g["job_大模型工程师"]["Python"]
assert edge["intervals"][0] == ["2025Q3", None]
assert set(edge["sources"]) == {"import_20260801.jsonl#1", "import_20260801.jsonl#2"}
assert kg.g.has_edge("job_大模型工程师", "场景::智能客服")
assert kg.g.has_edge("job_大模型工程师", "行业::人工智能")
assert any(ev.change_type == "新增" and ev.window == "2025Q3"
for ev in kg.events_of("job_大模型工程师"))
def test_reject_clears_override(client, tmp_path, monkeypatch):
"""驳回:清理覆写层记录,重建后岗位不复活。"""
import app.api.routes as routes
from app.discover.cluster import save_pending
from app.models import JobDefinition
from app.storage import db as state_db
db_path = tmp_path / "state.db"
monkeypatch.setitem(routes._cfg, "state_db_path", str(db_path))
monkeypatch.setitem(routes._cfg, "audit_path", str(tmp_path / "audit.jsonl"))
monkeypatch.setitem(routes._cfg, "graph_path", str(tmp_path / "graph_api.json"))
cand = JobDefinition(job_id="cand_短命岗位", title="短命岗位",
required_skills=["Python"], status="pending",
support_source_ids=["x.jsonl#1"], support_windows=["2025Q2"])
save_pending([cand], db_path)
state_db.save_override(db_path, "cand_短命岗位",
{"kind": "job_accept", "job": cand.model_dump()})
r = client.post("/api/review", json={
"candidate_id": "cand_短命岗位", "action": "reject",
"operator": "张老师", "reason": "证据不足"})
assert r.status_code == 200
assert state_db.get_override(db_path, "cand_短命岗位") is None
assert state_db.get_candidate(db_path, "cand_短命岗位") is None
def test_discover_candidate_carries_support_evidence():
"""新岗位发现:候选带支撑 source_id / 季度窗口 / 行业,供审核入库真实化。"""
from app.discover import cluster
from app.models import ExtractionResult
records, extractions = [], {}
for i in range(9):
r = JDRecord(jid=f"c{i}", job_title="提示词工程师", company=f"公司{i % 5}",
industry="人工智能", publish_date=date(2025, i % 6 + 1, 1),
text="负责大模型提示词设计与优化", source_id=f"t.jsonl#{i + 1}")
records.append(r)
extractions[r.jid] = ExtractionResult(
jid=r.jid, job_title="提示词工程师",
required_skills=["Python", "提示工程"], scenarios=["智能客服"])
cands = cluster.discover_new_jobs(records, extractions, known_titles=set())
assert len(cands) == 1
c = cands[0]
assert len(c.support_source_ids) == 9
assert min(c.support_windows) == "2025Q1"
assert c.industries == ["人工智能"]
# ---------- 2. 节点编辑同步 JobDefinition + 跨重建保留 ----------
def test_node_update_syncs_jobdef_and_persists(client, tmp_path, monkeypatch):
"""节点编辑同步 kg.jobs;重建后编辑保留且 /api/jobs 定义一致。"""
import app.api.routes as routes
from app.services.pipeline import run_pipeline
cfg = _tmp_cfg(tmp_path, JAVA_RECORDS)
db_path = tmp_path / "state.db"
monkeypatch.setitem(routes._cfg, "state_db_path", str(db_path))
monkeypatch.setitem(routes._cfg, "audit_path", str(tmp_path / "audit.jsonl"))
monkeypatch.setitem(routes._cfg, "graph_path", str(tmp_path / "graph_api.json"))
r = client.post("/api/graph/node/update", json={
"node_id": "job_Java开发工程师",
"updates": {"title": "Java后端架构师", "level": "高级"},
"operator": "张老师", "reason": "校准"})
assert r.status_code == 200
node = r.json()["data"]["node"]
assert node["title"] == "Java后端架构师" and node["level"] == "高级"
assert node["expected_years"] == 6 # 级别同步回年限(高级→6)
# /api/jobs 的 JobDefinition 同步一致
d = client.get("/api/jobs/job_Java开发工程师").json()["data"]["definition"]
assert d["title"] == "Java后端架构师" and d["expected_years"] == 6
# 重建:节点编辑经覆写层重放保留,JobDefinition 同样一致
run_pipeline(cfg)
kg = TemporalKG.load(tmp_path / "graph.json")
assert "job_Java开发工程师" in kg.jobs # 2022 年出现 → 既有岗位正常重建
attrs = kg.g.nodes["job_Java开发工程师"]
assert attrs["title"] == "Java后端架构师" and attrs["level"] == "高级"
assert attrs["expected_years"] == 6
jd = kg.jobs["job_Java开发工程师"]
assert jd.title == "Java后端架构师" and jd.expected_years == 6
# ---------- 3. JD 手动导入(文件 / 文本 / 坏行) ----------
def _import_setup(tmp_path, monkeypatch):
import app.api.routes as routes
cfg = _tmp_cfg(tmp_path, JAVA_RECORDS)
monkeypatch.setitem(routes._cfg, "jd_raw_dir", cfg["jd_raw_dir"])
monkeypatch.setitem(routes._cfg, "graph_path", cfg["graph_path"])
monkeypatch.setitem(routes._cfg, "state_db_path", cfg["state_db_path"])
monkeypatch.setitem(routes._cfg, "audit_path", cfg["audit_path"])
monkeypatch.setitem(routes._cfg, "pending_path", cfg["pending_path"])
return cfg
def test_jd_import_text_paste(client, tmp_path, monkeypatch):
"""JSON 文本粘贴:合格记录落 import_*.jsonl(保留来源标记),坏行返回并写 logs,触发重建。"""
_import_setup(tmp_path, monkeypatch)
lines = [
json.dumps(_jd("n1", "Java开发工程师", "2025-03-01", ["Java"]), ensure_ascii=False),
"", # 空行 → 跳过
json.dumps(_jd("n2", "Python开发工程师", "2025-05-01", ["Python"]), ensure_ascii=False),
"{不是合法 json", # 坏行
]
r = client.post("/api/jd/import", json={"text": "\n".join(lines)})
assert r.status_code == 200
data = r.json()["data"]
assert data["imported"] == 2 and data["skipped"] == 1
assert len(data["bad_lines"]) == 1 and data["bad_lines"][0]["line"] == 4
jd_dir = tmp_path / "jd_raw"
imported = jd_dir / data["file"]
assert imported.exists() and data["file"].startswith("import_")
saved = [json.loads(x) for x in imported.read_text(encoding="utf-8").splitlines()]
assert {s["jid"] for s in saved} == {"n1", "n2"}
# 坏行写 logs
from app.config import ROOT
assert (ROOT / "logs" / data["bad_log"]).exists()
# 异步重建完成,导入统计随任务可查
t = _wait_task(client, data["task_id"])
assert t["status"] == "done" and t["import"]["imported"] == 2
def test_jd_import_file_upload(client, tmp_path, monkeypatch):
"""multipart 文件上传(.jsonl):与文本粘贴同一入库链路。"""
_import_setup(tmp_path, monkeypatch)
content = "\n".join([
json.dumps(_jd("f1", "Java开发工程师", "2025-01-10", ["Java"]), ensure_ascii=False),
json.dumps({"jid": "f2"}), # 缺必需字段 → 坏行
]).encode("utf-8")
r = client.post("/api/jd/import",
files={"file": ("extra.jsonl", content, "application/octet-stream")})
assert r.status_code == 200
data = r.json()["data"]
assert data["imported"] == 1 and len(data["bad_lines"]) == 1
assert data["bad_lines"][0]["line"] == 2
t = _wait_task(client, data["task_id"])
assert t["status"] == "done"
def test_jd_import_csv_and_bad_ext(client, tmp_path, monkeypatch):
"""CSV 表头映射导入;不支持的扩展名 400。"""
_import_setup(tmp_path, monkeypatch)
csv_text = ("jid,job_title,company,publish_date,text\n"
"c1,Java开发工程师,示例科技,2025-02-01,熟悉Java后端开发\n"
"c2,缺日期岗位,示例科技,,熟悉Python\n") # publish_date 缺失 → 坏行
r = client.post("/api/jd/import",
files={"file": ("jd.csv", csv_text.encode("utf-8"), "text/csv")})
assert r.status_code == 200
data = r.json()["data"]
assert data["imported"] == 1 and len(data["bad_lines"]) == 1
_wait_task(client, data["task_id"])
bad = client.post("/api/jd/import",
files={"file": ("jd.exe", b"MZ", "application/octet-stream")})
assert bad.status_code == 400
def test_jd_import_rebuild_only(client, tmp_path, monkeypatch):
"""不带内容调用保持旧行为:仅触发全量重建。"""
_import_setup(tmp_path, monkeypatch)
r = client.post("/api/jd/import", json={})
assert r.status_code == 200
data = r.json()["data"]
assert "imported" not in data and data["task_id"]
t = _wait_task(client, data["task_id"])
assert t["status"] == "done"
# ---------- 4. 访问令牌空串清除端到端 ----------
def test_token_clear_end_to_end(client, tmp_path, monkeypatch):
"""设置令牌 → 鉴权生效 → 空串清除 → 鉴权解除(config.json 同步)。"""
import app.config as cfgmod
import app.api.routes as routes
monkeypatch.setattr(cfgmod, "CONFIG_JSON", tmp_path / "config.json")
monkeypatch.setattr(routes, "load_runtime_settings", cfgmod.load_runtime_settings)
r = client.post("/api/config", json={"access_token": "tok-xyz"})
assert r.status_code == 200 and r.json()["data"]["has_access_token"] is True
assert client.get("/api/jobs").status_code == 401
assert client.get("/api/jobs", headers={"X-Access-Token": "tok-xyz"}).status_code == 200
# 空串清除(前端令牌框 dirty 后留空提交的场景)
r2 = client.post("/api/config", json={"access_token": ""},
headers={"X-Access-Token": "tok-xyz"})
assert r2.status_code == 200
assert r2.json()["data"]["has_access_token"] is False
assert "access_token" not in routes._cfg
assert "access_token" not in cfgmod.load_runtime_settings()
assert client.get("/api/jobs").status_code == 200 # 无需令牌即可访问
# ---------- 5. 语义权重相对路径 ----------
def test_relative_weights_path(tmp_path, monkeypatch):
"""embed_model_path 支持相对路径(相对工程根解析),绝对路径配置自动兼容。"""
import app.config as cfgmod
from app.match import semantic
monkeypatch.setattr(cfgmod, "ROOT", tmp_path)
w = tmp_path / "models" / "bge-m3"
w.mkdir(parents=True)
for f in ["config.json", "model.safetensors", "tokenizer.json"]:
(w / f).write_text("{}")
st = semantic.status({"embed_model_path": "models/bge-m3"})
assert st["configured"] is True and st["weights_present"] is True
# 既有绝对路径配置不受影响
st2 = semantic.status({"embed_model_path": str(w)})
assert st2["weights_present"] is True
assert semantic.resolve_weights_path("models/bge-m3") == w
assert semantic.resolve_weights_path(str(w)) == w
def test_accept_normalizes_candidate_id_single_node(client, tmp_path, monkeypatch):
"""候选转正归一化:accept 后全图只有 job_<岗位名> 一个节点,边/事件挂在其下。"""
import app.api.routes as routes
from app.services.overrides import normalize_job_id
from app.models import JobDefinition
kg = routes._get_kg()
job = JobDefinition(job_id="cand_测试岗位X", title="测试岗位X", category="测试",
required_skills=["Python"], preferred_skills=["Docker"],
scenarios=["教育"], industries=[], expected_years=3,
status="active", support_source_ids=["batch_2024.jsonl#1"],
support_windows=["2024Q1"])
assert normalize_job_id(job) == "job_测试岗位X"
job.job_id = normalize_job_id(job) # 调用方负责归一化(与 routes 一致)
from app.services.overrides import apply_accepted_job
apply_accepted_job(kg, job)
# 全图唯一节点:无 cand_ 残留,REQUIRES 边挂在归一化 id 下
assert "job_测试岗位X" in kg.g and "cand_测试岗位X" not in kg.g
assert kg.g.has_edge("job_测试岗位X", "Python")
assert not list(kg.g.edges("cand_测试岗位X"))
# 清理测试数据
kg.g.remove_node("job_测试岗位X")
kg.jobs.pop("job_测试岗位X", None)
+102
View File
@@ -0,0 +1,102 @@
"""data/real 真实数据集:文件齐全、gold Schema 合法、批量演示素材已脱敏、真实评测维度可运行。"""
from __future__ import annotations
import importlib.util
import json
import re
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
REAL = ROOT / "data" / "real"
JD_DIR = REAL / "autoweaks_jd"
RESUME_DIR = REAL / "aistudio_resumes"
DEMO_DIR = REAL / "batch_demo_resumes"
ESCO_DIR = REAL / "esco_reference"
GOLD_JD_FIELDS = ("job_title", "responsibilities", "required_skills",
"preferred_skills", "scenarios")
def _load_evaluate():
"""按路径加载 scripts/evaluate.py(scripts 不是包)。"""
spec = importlib.util.spec_from_file_location("evaluate", ROOT / "scripts" / "evaluate.py")
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
def test_real_tree_complete() -> None:
assert (REAL / "README.md").exists()
assert (JD_DIR / "jd_sample.jsonl").exists()
assert (JD_DIR / "gold_answers.json").exists()
assert len(list(RESUME_DIR.glob("*.txt"))) == 50
assert (RESUME_DIR / "gold.json").exists()
assert len(list(DEMO_DIR.glob("*.txt"))) == 12
for name in ("labels_isco_codes.txt", "labels_isco_labels.txt",
"labels_esco_code.txt", "labels_esco_occupation.txt", "README.md"):
assert (ESCO_DIR / name).exists(), name
def test_jd_gold_schema() -> None:
records = [json.loads(ln) for ln in
(JD_DIR / "jd_sample.jsonl").read_text(encoding="utf-8").splitlines() if ln.strip()]
assert len(records) == 200
gold = json.loads((JD_DIR / "gold_answers.json").read_text(encoding="utf-8"))
assert {r["jid"] for r in records} == set(gold)
for r in records:
for key in ("jid", "job_title", "company", "publish_date", "source", "url", "text"):
assert key in r, (r["jid"], key)
assert r["source"] == "autoweaks"
assert r["company"] in ("腾讯", "京东")
assert re.fullmatch(r"2019-\d{2}-\d{2}", r["publish_date"]), r["jid"]
for jid, g in gold.items():
assert tuple(g) == GOLD_JD_FIELDS or set(g) == set(GOLD_JD_FIELDS), jid
assert isinstance(g["job_title"], str) and g["job_title"]
for f in ("responsibilities", "required_skills", "preferred_skills", "scenarios"):
assert isinstance(g[f], list) and all(isinstance(s, str) for s in g[f]), (jid, f)
def test_resume_gold_schema() -> None:
gold = json.loads((RESUME_DIR / "gold.json").read_text(encoding="utf-8"))
assert len(gold) == 50
for fname, g in gold.items():
assert (RESUME_DIR / fname).exists(), fname
assert re.fullmatch(r"[一-龥]{2,4}", g["name"]), fname
assert re.fullmatch(r"1[3-9]\d{9}", g["phone"]), fname
assert isinstance(g["skills"], list) and all(isinstance(s, str) for s in g["skills"])
def test_batch_demo_masked() -> None:
"""批量演示素材必须已脱敏:无 11 位手机号、邮箱仅呈 x***@domain 形态、姓名带 *。"""
files = list(DEMO_DIR.glob("*.txt"))
assert len(files) == 12
phone_re = re.compile(r"1[3-9]\d{9}")
email_re = re.compile(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}")
name_re = re.compile(r"姓名[::]\s*([一-龥]{2,4})") # 未脱敏姓名(脱敏后含 *)
for f in files:
text = f.read_text(encoding="utf-8")
assert not phone_re.search(text), f.name
assert not email_re.search(text), f.name # 脱敏形态 x***@d.com 不匹配该正则
assert not name_re.search(text), f.name
assert "****" in text, f.name # 手机号已打码
assert re.search(r"姓名[::]\s*[一-龥]\*", text), f.name
@pytest.mark.parametrize("dim", ["real_jd", "real_resume"])
def test_eval_real_dimensions(dim: str) -> None:
"""真实维度评测可运行且指标在合法区间(全量小数据集,直接实跑)。"""
evaluate = _load_evaluate()
result = evaluate.eval_real_jd() if dim == "real_jd" else evaluate.eval_real_resume()
assert 0.0 <= result["accuracy"] <= 1.0
if dim == "real_jd":
assert result["samples"] == 200 and result["bad_lines"] == 0
assert result["fields"]["required_skills"]["f1"] >= 0.80 # 任务目标
else:
assert result["samples"] == 50
assert result["name_accuracy"] == 1.0 # 任务目标:脱敏后姓氏+*
assert result["phone_accuracy"] == 1.0
+105
View File
@@ -0,0 +1,105 @@
"""SQLite 应用状态库测试:建表幂等 / 留痕追加与查询 / 候选增删查 / 任务表 / 旧文件迁移。"""
from __future__ import annotations
import json
from app.storage import db
REVIEW_REC = {"candidate_id": "cand_A", "action": "accept", "operator": "张三",
"reason": "证据充分", "before": {"status": "pending"},
"after": {"status": "active"}, "time": "2026-08-01 10:00:00"}
NODE_REC = {"kind": "node_update", "node_id": "job_X", "operator": "李四",
"reason": "校正", "before": {"level": None},
"after": {"level": "高级"}, "time": "2026-08-01 11:00:00"}
CAND = {"job_id": "cand_旧候选", "title": "旧候选", "category": "新岗位候选",
"required_skills": ["Python"], "status": "pending",
"source_count": 10, "company_count": 6}
def test_init_schema_idempotent(tmp_path):
"""重复打开/建表无副作用,四张表齐备。"""
p = tmp_path / "state.db"
for _ in range(2):
conn = db.connect(p)
tables = {r[0] for r in conn.execute(
"SELECT name FROM sqlite_master WHERE type='table'")}
assert {"audit_log", "review_candidates", "tasks", "meta"} <= tables
conn.close()
def test_audit_append_and_query(tmp_path):
"""留痕追加后按新→旧返回,结构还原为旧 jsonl 行,支持过滤与 limit。"""
p = tmp_path / "state.db"
db.append_audit(p, REVIEW_REC)
db.append_audit(p, NODE_REC)
recs = db.get_audit(p)
assert [r["operator"] for r in recs] == ["李四", "张三"] # 新→旧
assert recs[1]["candidate_id"] == "cand_A" and recs[1]["action"] == "accept"
assert recs[1]["before"] == {"status": "pending"}
assert recs[0]["kind"] == "node_update" and recs[0]["after"] == {"level": "高级"}
assert db.get_audit(p, node_id="cand_A") == [recs[1]] # 过滤
assert len(db.get_audit(p, limit=1)) == 1 # limit
def test_candidates_crud(tmp_path):
"""候选:save/get/list/delete,save 为按 job_id 的 upsert。"""
p = tmp_path / "state.db"
assert db.list_candidates(p) == []
db.save_candidate(p, CAND)
db.save_candidate(p, {**CAND, "job_id": "cand_B", "title": "另一个"})
db.save_candidate(p, {**CAND, "title": "旧候选-改"}) # upsert 覆盖
assert [c["job_id"] for c in db.list_candidates(p)] == ["cand_旧候选", "cand_B"]
assert db.get_candidate(p, "cand_旧候选")["title"] == "旧候选-改"
assert db.get_candidate(p, "cand_不存在") is None
db.delete_candidate(p, "cand_B")
assert [c["job_id"] for c in db.list_candidates(p)] == ["cand_旧候选"]
def test_tasks_upsert_get_list(tmp_path):
"""任务表:upsert 更新状态与 payload,get/list 可读回。"""
p = tmp_path / "state.db"
db.upsert_task(p, "t1", kind="jd_import", status="pending", payload={"status": "pending"})
db.upsert_task(p, "t1", kind="jd_import", status="done",
payload={"status": "done", "result": {"jobs": 18}})
row = db.get_task(p, "t1")
assert row["status"] == "done" and row["payload"]["result"]["jobs"] == 18
assert db.get_task(p, "不存在") is None
assert [t["task_id"] for t in db.list_tasks(p)] == ["t1"]
def test_overrides_crud(tmp_path):
"""人工覆写层:save/get/list/delete,save 为按 name 的 upsert。"""
p = tmp_path / "state.db"
assert db.list_overrides(p) == []
db.save_override(p, "cand_X", {"kind": "job_accept", "job": {"job_id": "cand_X"}})
db.save_override(p, "job_Y", {"kind": "node_update", "node_id": "job_Y",
"updates": {"level": "高级"}})
db.save_override(p, "cand_X", {"kind": "job_accept",
"job": {"job_id": "cand_X", "title": "改"}}) # upsert 覆盖
assert db.get_override(p, "cand_X")["job"]["title"] == "改"
assert db.get_override(p, "不存在") is None
assert [o["kind"] for o in db.list_overrides(p)] == ["job_accept", "node_update"]
db.delete_override(p, "cand_X")
assert [o["kind"] for o in db.list_overrides(p)] == ["node_update"]
def test_legacy_migration(tmp_path):
"""旧 audit.jsonl / pending.json 一次性导入:表空才迁、原文件保留、重复打开不重复迁。"""
audit = tmp_path / "audit.jsonl"
audit.write_text("\n".join(json.dumps(r, ensure_ascii=False)
for r in (REVIEW_REC, NODE_REC)) + "\n", encoding="utf-8")
pending = tmp_path / "pending.json"
pending.write_text(json.dumps([CAND], ensure_ascii=False), encoding="utf-8")
p = tmp_path / "state.db"
db.connect(p, legacy_audit=audit, legacy_pending=pending).close()
recs = db.get_audit(p)
assert len(recs) == 2 and recs[1]["candidate_id"] == "cand_A"
assert [c["job_id"] for c in db.list_candidates(p)] == ["cand_旧候选"]
assert audit.exists() and pending.exists() # 原文件保留
# 再次打开不重复迁移(meta 标记 + 表非空双保险)
db.connect(p, legacy_audit=audit, legacy_pending=pending).close()
assert len(db.get_audit(p)) == 2 and len(db.list_candidates(p)) == 1
# 迁移后新写入的数据不受影响
db.append_audit(p, REVIEW_REC)
assert len(db.get_audit(p)) == 3
+78
View File
@@ -0,0 +1,78 @@
"""时序图谱测试:时间点切片、演化追加不覆盖、持久化往返。"""
from __future__ import annotations
from app.graph.tkg import TemporalKG, quarter_of, to_window
from app.models import ChangeEvent, GraphObservation, JobDefinition
def _kg() -> TemporalKG:
kg = TemporalKG()
kg.add_job(JobDefinition(job_id="job_T", title="测试工程师"))
return kg
def test_quarter_and_window_conversion():
from datetime import date
assert quarter_of(date(2022, 2, 15)) == "2022Q1"
assert quarter_of(date(2022, 11, 1)) == "2022Q4"
assert to_window("2022-05-01") == "2022Q2"
assert to_window("2022Q3") == "2022Q3"
def test_snapshot_time_slicing():
"""任意时间点切片:只看到该时刻已生效且未退出的技能。"""
kg = _kg()
kg.observe(GraphObservation(job_id="job_T", skill="Java", importance="required",
weight=1.0, window="2020Q1", source_ids=["s1"]))
kg.observe(GraphObservation(job_id="job_T", skill="Spring Cloud", importance="required",
weight=1.0, window="2023Q1", source_ids=["s2"]))
snap_2020 = kg.snapshot("job_T", "2020-06-01")
assert {s["skill"] for s in snap_2020["skills"]} == {"Java"}
snap_2024 = kg.snapshot("job_T", "2024-01-01")
assert {s["skill"] for s in snap_2024["skills"]} == {"Java", "Spring Cloud"}
def test_close_interval_removes_from_snapshot():
kg = _kg()
kg.observe(GraphObservation(job_id="job_T", skill="Struts", importance="preferred",
weight=1.0, window="2019Q1", source_ids=["s1"]))
kg.close_interval("job_T", "Struts", "2022Q1")
assert {s["skill"] for s in kg.snapshot("job_T", "2021-01-01")["skills"]} == {"Struts"}
assert kg.snapshot("job_T", "2023-01-01")["skills"] == []
def test_evolution_appends_not_overwrites():
"""同一技能退出后重新出现:新区间追加,旧区间保留(历史不覆盖)。"""
kg = _kg()
obs = GraphObservation(job_id="job_T", skill="Kafka", importance="required",
weight=1.0, window="2020Q1", source_ids=["s1"])
kg.observe(obs)
kg.close_interval("job_T", "Kafka", "2021Q1")
kg.observe(GraphObservation(job_id="job_T", skill="Kafka", importance="required",
weight=0.8, window="2023Q1", source_ids=["s2"]))
intervals = kg.g["job_T"]["Kafka"]["intervals"]
assert intervals == [["2020Q1", "2021Q1"], ["2023Q1", None]]
assert kg.snapshot("job_T", "2020-06-01")["skills"] # 旧区间仍可切片
assert kg.snapshot("job_T", "2022-01-01")["skills"] == [] # 退出期间为空
assert kg.snapshot("job_T", "2024-01-01")["skills"] # 重现后恢复
# 事件流只追加
kg.add_event(ChangeEvent(event_id="e1", job_id="job_T", skill="Kafka",
change_type="退出", window="2021Q1", description="x"))
kg.add_event(ChangeEvent(event_id="e2", job_id="job_T", skill="Kafka",
change_type="新增", window="2023Q1", description="y"))
assert [e.event_id for e in kg.events_of("job_T")] == ["e1", "e2"]
def test_save_load_roundtrip(tmp_path):
kg = _kg()
kg.observe(GraphObservation(job_id="job_T", skill="Java", importance="required",
weight=1.0, window="2020Q1", source_ids=["s1"]))
kg.add_event(ChangeEvent(event_id="e1", job_id="job_T", skill="Java",
change_type="新增", window="2020Q1", description="d"))
path = tmp_path / "g.json"
kg.save(path)
kg2 = TemporalKG.load(path)
assert kg2.stats()["edges"] == 1
assert kg2.jobs["job_T"].title == "测试工程师"
assert kg2.snapshot("job_T", "2020-06-01")["skills"][0]["skill"] == "Java"
assert kg2.events_of("job_T")[0].event_id == "e1"
+1731
View File
File diff suppressed because it is too large Load Diff
+887
View File
@@ -0,0 +1,887 @@
/* CareerNexus 职联 · 全景图谱工作台(graph.js)
* canvas 力导向 + 缩放平移 + 小地图 + 详情面板 + 节点编辑/备注/审核。
* 依赖 app.js 暴露的 CN 命名空间。 */
"use strict";
(function () {
const { $, $$, api, post, esc, toast, openModal, closeModal, TYPE_META } = CN;
/* ================= 状态 ================= */
const G = {
nodes: [], edges: [], events: [], idx: {},
cam: { x: 0, y: 0, k: 1 }, // 相机:世界 -> 屏幕 screen = (world - cam.pan) * k ... 使用 cam.x/y 为平移(屏幕像素)
showLabels: true,
filters: { types: { Job: true, Skill: true, Industry: true, Scenario: true }, category: "", level: "",
edges: { required: true, preferred: true, applies: true, belongs: true } },
selected: null, // 选中节点 id
hover: null,
navStack: [], // 详情面板导航栈
drag: null, // {mode:'pan'|'node', ...}
pending: [], // 待审核候选
raf: null,
};
const canvas = () => $("#graph-canvas");
const NOTES_KEY = "cn_notes";
const VIEW_KEY = "cn_graph_view";
/* ================= 数据装载 ================= */
async function load() {
$("#graph-loading").classList.remove("hidden");
const [data, pending] = await Promise.all([
api("/api/graph"),
api("/api/review/pending").catch(() => []),
]);
CN.graphData = data;
G.events = data.events || [];
G.pending = pending;
G.nodes = data.nodes.map((n) => ({ ...n, x: 0, y: 0, vx: 0, vy: 0 }));
G.idx = Object.fromEntries(G.nodes.map((n, i) => [n.id, i]));
G.edges = data.edges.filter((e) => e.source in G.idx && e.target in G.idx);
/* 合并待审核候选为虚线节点 */
pending.forEach((c) => {
if (!(c.job_id in G.idx)) {
G.idx[c.job_id] = G.nodes.push({
id: c.job_id, type: "Job", title: c.title, category: c.category,
status: "pending", pending: true, expected_years: c.expected_years,
source_count: c.source_count, company_count: c.company_count,
x: 0, y: 0, vx: 0, vy: 0,
}) - 1;
}
[...c.required_skills.map((s) => [s, "required"]), ...c.preferred_skills.map((s) => [s, "preferred"])].forEach(([s, imp]) => {
if (!(s in G.idx)) {
G.idx[s] = G.nodes.push({ id: s, type: "Skill", category: "", x: 0, y: 0, vx: 0, vy: 0 }) - 1;
}
G.edges.push({ source: c.job_id, target: s, rel: "REQUIRES", importance: imp, weight: 1, intervals: [], sources: [], pending: true });
});
});
buildFilterOptions();
layout();
restoreView() || fitView();
renderSuggest();
renderChain(null);
$("#graph-loading").classList.add("hidden");
requestDraw();
drawMinimap();
if (G.pendingLocate) { const t = G.pendingLocate; G.pendingLocate = null; locateNode(t); }
}
function buildFilterOptions() {
const cats = [...new Set(G.nodes.filter((n) => n.type === "Job" && n.category).map((n) => n.category))].sort();
$("#fp-category").innerHTML = `<option value="">全部类别</option>` +
cats.map((c) => `<option value="${esc(c)}">${esc(c)}</option>`).join("");
}
/* ================= 力导向布局 ================= */
function layout() {
const N = G.nodes.length;
G.nodes.forEach((n, i) => {
const a = (i / N) * Math.PI * 2;
n.x = Math.cos(a) * 400 + (Math.random() - 0.5) * 60;
n.y = Math.sin(a) * 400 + (Math.random() - 0.5) * 60;
n.vx = 0; n.vy = 0;
});
for (let iter = 0; iter < 320; iter++) {
for (let i = 0; i < N; i++) {
const a = G.nodes[i];
for (let j = i + 1; j < N; j++) {
const b = G.nodes[j];
let dx = a.x - b.x, dy = a.y - b.y;
let d2 = dx * dx + dy * dy || 1;
if (d2 > 640000) continue;
const d = Math.sqrt(d2);
const f = 2600 / d2;
dx = (dx / d) * f; dy = (dy / d) * f;
a.vx += dx; a.vy += dy; b.vx -= dx; b.vy -= dy;
}
}
G.edges.forEach((e) => {
const a = G.nodes[G.idx[e.source]], b = G.nodes[G.idx[e.target]];
let dx = b.x - a.x, dy = b.y - a.y;
const d = Math.sqrt(dx * dx + dy * dy) || 1;
const rest = (a.type === "Job" && b.type === "Job") ? 260 : 130;
const f = (d - rest) * 0.015;
dx = (dx / d) * f; dy = (dy / d) * f;
a.vx += dx; a.vy += dy; b.vx -= dx; b.vy -= dy;
});
G.nodes.forEach((n) => {
n.vx *= 0.55; n.vy *= 0.55;
n.x += Math.max(-24, Math.min(24, n.vx));
n.y += Math.max(-24, Math.min(24, n.vy));
});
}
}
function worldBounds() {
let minX = 1e9, minY = 1e9, maxX = -1e9, maxY = -1e9;
G.nodes.forEach((n) => {
minX = Math.min(minX, n.x); maxX = Math.max(maxX, n.x);
minY = Math.min(minY, n.y); maxY = Math.max(maxY, n.y);
});
return { minX, minY, maxX, maxY, w: maxX - minX || 1, h: maxY - minY || 1 };
}
/* ================= 相机 ================= */
function fitView() {
const c = canvas(), b = worldBounds();
const W = c.clientWidth, H = c.clientHeight;
G.cam.k = Math.min(W / (b.w + 120), H / (b.h + 120), 1.6);
G.cam.x = (W - b.w * G.cam.k) / 2 - b.minX * G.cam.k;
G.cam.y = (H - b.h * G.cam.k) / 2 - b.minY * G.cam.k;
requestDraw(); drawMinimap();
}
const toScreen = (n) => ({ x: n.x * G.cam.k + G.cam.x, y: n.y * G.cam.k + G.cam.y });
const toWorld = (sx, sy) => ({ x: (sx - G.cam.x) / G.cam.k, y: (sy - G.cam.y) / G.cam.k });
function zoomAt(factor, cx, cy) {
const c = canvas();
cx = cx ?? c.clientWidth / 2; cy = cy ?? c.clientHeight / 2;
const w = toWorld(cx, cy);
G.cam.k = Math.max(0.15, Math.min(4, G.cam.k * factor));
G.cam.x = cx - w.x * G.cam.k;
G.cam.y = cy - w.y * G.cam.k;
requestDraw(); drawMinimap();
}
function centerOn(node, k) {
const c = canvas();
if (k) G.cam.k = k;
G.cam.x = c.clientWidth / 2 - node.x * G.cam.k;
G.cam.y = c.clientHeight / 2 - node.y * G.cam.k;
requestDraw(); drawMinimap();
}
/* ================= 可见性(筛选) ================= */
function jobLevel(n) {
const y = n.expected_years;
if (y == null) return "";
return y <= 2 ? "初级" : y <= 5 ? "中级" : "高级";
}
function visible(n) {
if (!G.filters.types[n.type]) return false;
if (n.type === "Job") {
if (G.filters.category && n.category !== G.filters.category) return false;
if (G.filters.level && jobLevel(n) !== G.filters.level) return false;
}
return true;
}
function edgeVisible(e) {
const ef = G.filters.edges || {};
if (e.rel === "REQUIRES") return ef[e.importance === "required" ? "required" : "preferred"] !== false;
if (e.rel === "APPLIES_TO") return ef.applies !== false;
if (e.rel === "BELONGS_TO") return ef.belongs !== false;
return true;
}
function neighborsOf(id) {
const set = new Set();
G.edges.forEach((e) => {
if (!edgeVisible(e)) return;
if (e.source === id) set.add(e.target);
if (e.target === id) set.add(e.source);
});
return set;
}
/* ================= 渲染 ================= */
function requestDraw() {
if (G.raf) return;
G.raf = requestAnimationFrame(() => { G.raf = null; draw(); });
}
function nodeRadius(n) {
return n.type === "Job" ? 13 : n.type === "Scenario" ? 8 : n.type === "Industry" ? 10 : 6.5;
}
function draw() {
const c = canvas();
if (!c.clientWidth) return;
const dpr = window.devicePixelRatio || 1;
c.width = c.clientWidth * dpr; c.height = c.clientHeight * dpr;
const ctx = c.getContext("2d");
ctx.scale(dpr, dpr);
ctx.clearRect(0, 0, c.clientWidth, c.clientHeight);
const sel = G.selected;
const hop = sel ? neighborsOf(sel) : null;
/* ---- 边 ---- */
G.edges.forEach((e) => {
const a = G.nodes[G.idx[e.source]], b = G.nodes[G.idx[e.target]];
if (!visible(a) || !visible(b)) return;
if (!edgeVisible(e)) return;
const pa = toScreen(a), pb = toScreen(b);
const isHot = sel && (e.source === sel || e.target === sel);
const dim = sel && !isHot;
const isReq = e.importance === "required";
ctx.save();
if (e.rel === "APPLIES_TO") {
ctx.strokeStyle = dim ? "rgba(114,46,209,0.05)" : isHot ? "rgba(114,46,209,0.7)" : "rgba(114,46,209,0.22)";
ctx.setLineDash([]);
ctx.lineWidth = isHot ? 1.4 : 0.8;
} else if (isReq) {
ctx.strokeStyle = dim ? "rgba(78,89,105,0.05)" : isHot ? "rgba(47,84,235,0.75)" : "rgba(78,89,105,0.28)";
ctx.setLineDash([]);
ctx.lineWidth = isHot ? 1.8 : 1;
} else {
ctx.strokeStyle = dim ? "rgba(19,194,194,0.05)" : isHot ? "rgba(19,194,194,0.8)" : "rgba(19,194,194,0.4)";
ctx.setLineDash([5, 4]);
ctx.lineWidth = isHot ? 1.6 : 1;
}
if (e.pending) ctx.setLineDash([3, 4]);
ctx.beginPath(); ctx.moveTo(pa.x, pa.y); ctx.lineTo(pb.x, pb.y); ctx.stroke();
ctx.setLineDash([]);
/* 箭头(指向 target 节点边缘) */
const dx = pb.x - pa.x, dy = pb.y - pa.y;
const d = Math.sqrt(dx * dx + dy * dy) || 1;
const rB = nodeRadius(b) * G.cam.k + 2;
const ax = pb.x - (dx / d) * rB, ay = pb.y - (dy / d) * rB;
const ang = Math.atan2(dy, dx);
const ah = isHot ? 7 : 5.5;
ctx.fillStyle = ctx.strokeStyle;
ctx.beginPath();
ctx.moveTo(ax, ay);
ctx.lineTo(ax - ah * Math.cos(ang - 0.45), ay - ah * Math.sin(ang - 0.45));
ctx.lineTo(ax - ah * Math.cos(ang + 0.45), ay - ah * Math.sin(ang + 0.45));
ctx.closePath(); ctx.fill();
/* 关系标签(颜色 + 文字双重编码) */
if (G.showLabels && e.rel === "REQUIRES" && G.cam.k > 0.55 && !dim) {
ctx.font = "10px sans-serif"; ctx.textAlign = "center";
ctx.fillStyle = isReq ? "rgba(47,84,235,0.85)" : "rgba(9,151,156,0.9)";
ctx.fillText(isReq ? "必备" : "加分", (pa.x + pb.x) / 2, (pa.y + pb.y) / 2 - 3);
}
ctx.restore();
});
/* ---- 节点 ---- */
G.nodes.forEach((n) => {
if (!visible(n)) return;
const p = toScreen(n);
const r = Math.max(2.5, nodeRadius(n) * Math.sqrt(G.cam.k));
const isSel = n.id === sel;
const isHop = hop && hop.has(n.id);
const dim = sel && !isSel && !isHop;
const meta = TYPE_META[n.type] || { color: "#86909c" };
ctx.save();
ctx.globalAlpha = dim ? 0.15 : 1;
ctx.fillStyle = meta.color;
if (n.type === "Skill") {
const s = r * 1.7;
ctx.beginPath(); ctx.roundRect(p.x - s / 2, p.y - s / 2, s, s, 2.5); ctx.fill();
} else {
ctx.beginPath(); ctx.arc(p.x, p.y, r, 0, 7); ctx.fill();
}
if (n.pending || n.status === "pending") {
ctx.setLineDash([3, 3]);
ctx.strokeStyle = "#ff7d00"; ctx.lineWidth = 1.6;
ctx.beginPath(); ctx.arc(p.x, p.y, r + 3.5, 0, 7); ctx.stroke();
ctx.setLineDash([]);
}
if (isSel) {
ctx.strokeStyle = "#1d2129"; ctx.lineWidth = 2;
ctx.beginPath(); ctx.arc(p.x, p.y, r + 4, 0, 7); ctx.stroke();
} else if (isHop) {
ctx.strokeStyle = "rgba(47,84,235,0.55)"; ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.arc(p.x, p.y, r + 2.5, 0, 7); ctx.stroke();
}
/* 标签:岗位常显,其余放大或选中/悬停时显示 */
if (n.type === "Job" || G.cam.k > 1.05 || isSel || isHop || n.id === G.hover) {
ctx.font = `${n.type === "Job" ? 11 : 10}px sans-serif`;
ctx.textAlign = "center";
ctx.fillStyle = "rgba(255,255,255,0.85)";
const label = n.title || n.id;
const tw = ctx.measureText(label).width;
ctx.fillRect(p.x - tw / 2 - 3, p.y - r - 17, tw + 6, 13);
ctx.fillStyle = n.type === "Job" ? "#1d2129" : "#4e5969";
ctx.fillText(label, p.x, p.y - r - 7);
}
ctx.restore();
});
}
/* ================= 小地图 ================= */
function drawMinimap() {
const mm = $("#minimap-canvas");
const ctx = mm.getContext("2d");
const W = mm.width, H = mm.height;
ctx.clearRect(0, 0, W, H);
if (!G.nodes.length) return;
const b = worldBounds();
const k = Math.min((W - 10) / b.w, (H - 10) / b.h);
const ox = (W - b.w * k) / 2 - b.minX * k;
const oy = (H - b.h * k) / 2 - b.minY * k;
G.edges.forEach((e) => {
const a = G.nodes[G.idx[e.source]], bb = G.nodes[G.idx[e.target]];
if (!visible(a) || !visible(bb)) return;
ctx.strokeStyle = "rgba(120,130,150,0.25)";
ctx.beginPath();
ctx.moveTo(a.x * k + ox, a.y * k + oy);
ctx.lineTo(bb.x * k + ox, bb.y * k + oy);
ctx.stroke();
});
G.nodes.forEach((n) => {
if (!visible(n)) return;
const meta = TYPE_META[n.type] || { color: "#86909c" };
ctx.fillStyle = n.pending ? "#ff7d00" : meta.color;
ctx.beginPath();
ctx.arc(n.x * k + ox, n.y * k + oy, n.type === "Job" ? 2.6 : 1.6, 0, 7);
ctx.fill();
});
/* 视口框 */
const c = canvas();
const tl = toWorld(0, 0), br = toWorld(c.clientWidth, c.clientHeight);
ctx.strokeStyle = "#2f54eb"; ctx.lineWidth = 1.4;
ctx.strokeRect(tl.x * k + ox, tl.y * k + oy, (br.x - tl.x) * k, (br.y - tl.y) * k);
mm._proj = { k, ox, oy };
}
$("#minimap-canvas").addEventListener("click", (e) => {
const mm = e.currentTarget, p = mm._proj;
if (!p) return;
const rect = mm.getBoundingClientRect();
const wx = (e.clientX - rect.left - p.ox) / p.k;
const wy = (e.clientY - rect.top - p.oy) / p.k;
centerOn({ x: wx, y: wy });
});
/* ================= 交互:平移 / 缩放 / 点选 / 拖拽节点 ================= */
function pickNode(sx, sy) {
let best = null, bd = 1e9;
G.nodes.forEach((n) => {
if (!visible(n)) return;
const p = toScreen(n);
const r = Math.max(8, nodeRadius(n) * Math.sqrt(G.cam.k) + 4);
const d = (p.x - sx) ** 2 + (p.y - sy) ** 2;
if (d < r * r && d < bd) { bd = d; best = n; }
});
return best;
}
function bindCanvas() {
const c = canvas();
c.addEventListener("mousedown", (e) => {
const rect = c.getBoundingClientRect();
const sx = e.clientX - rect.left, sy = e.clientY - rect.top;
const node = pickNode(sx, sy);
G.drag = node
? { mode: "node", node, moved: false }
: { mode: "pan", sx, sy, camX: G.cam.x, camY: G.cam.y, moved: false };
c.classList.add("dragging");
});
window.addEventListener("mousemove", (e) => {
const rect = c.getBoundingClientRect();
const sx = e.clientX - rect.left, sy = e.clientY - rect.top;
if (!G.drag) {
const h = pickNode(sx, sy);
const id = h ? h.id : null;
if (id !== G.hover) { G.hover = id; requestDraw(); }
c.classList.toggle("hovering", !!h);
return;
}
G.drag.moved = true;
if (G.drag.mode === "pan") {
G.cam.x = G.drag.camX + (sx - G.drag.sx);
G.cam.y = G.drag.camY + (sy - G.drag.sy);
} else {
const w = toWorld(sx, sy);
G.drag.node.x = w.x; G.drag.node.y = w.y;
}
requestDraw();
});
window.addEventListener("mouseup", () => {
if (G.drag && G.drag.mode === "node" && G.drag.moved) drawMinimap();
if (G.drag && G.drag.mode === "pan" && G.drag.moved) drawMinimap();
G.drag = null;
c.classList.remove("dragging");
});
c.addEventListener("click", (e) => {
if (G.drag && G.drag.moved) return;
const rect = c.getBoundingClientRect();
const node = pickNode(e.clientX - rect.left, e.clientY - rect.top);
selectNode(node ? node.id : null);
});
c.addEventListener("wheel", (e) => {
e.preventDefault();
const rect = c.getBoundingClientRect();
zoomAt(e.deltaY < 0 ? 1.12 : 0.89, e.clientX - rect.left, e.clientY - rect.top);
}, { passive: false });
window.addEventListener("resize", () => { requestDraw(); drawMinimap(); });
}
/* ================= 工具条 / 筛选 ================= */
function bindToolbar() {
$("#tool-zoom-in").onclick = () => zoomAt(1.25);
$("#tool-zoom-out").onclick = () => zoomAt(0.8);
$("#tool-fit").onclick = fitView;
$("#tool-relayout").onclick = () => {
$("#graph-loading").classList.remove("hidden");
setTimeout(() => { layout(); fitView(); $("#graph-loading").classList.add("hidden"); toast("布局已整理", "success"); }, 30);
};
$("#tool-labels").onclick = (e) => {
G.showLabels = !G.showLabels;
e.currentTarget.classList.toggle("active", G.showLabels);
requestDraw();
};
$("#tool-filter").onclick = () => $("#graph-filter-panel").classList.toggle("hidden");
document.addEventListener("click", (e) => {
if (!e.target.closest("#graph-filter-panel") && !e.target.closest("#tool-filter")) {
$("#graph-filter-panel").classList.add("hidden");
}
});
$$("#graph-filter-panel input[data-ftype]").forEach((cb) => {
cb.onchange = () => { G.filters.types[cb.dataset.ftype] = cb.checked; requestDraw(); drawMinimap(); };
});
$$("#graph-filter-panel input[data-etype]").forEach((cb) => {
cb.onchange = () => { G.filters.edges[cb.dataset.etype] = cb.checked; requestDraw(); drawMinimap(); };
});
$("#fp-category").onchange = (e) => { G.filters.category = e.target.value; requestDraw(); drawMinimap(); };
$("#fp-level").onchange = (e) => { G.filters.level = e.target.value; requestDraw(); drawMinimap(); };
$("#fp-reset").onclick = () => {
G.filters = { types: { Job: true, Skill: true, Industry: true, Scenario: true }, category: "", level: "",
edges: { required: true, preferred: true, applies: true, belongs: true } };
$$("#graph-filter-panel input[data-ftype]").forEach((cb) => { cb.checked = true; });
$$("#graph-filter-panel input[data-etype]").forEach((cb) => { cb.checked = true; });
$("#fp-category").value = ""; $("#fp-level").value = "";
requestDraw(); drawMinimap();
};
/* 头部按钮 */
$("#btn-save-view").onclick = () => {
localStorage.setItem(VIEW_KEY, JSON.stringify({ cam: G.cam, filters: G.filters, labels: G.showLabels }));
toast("当前视图(位置/缩放/筛选)已保存", "success");
};
$("#btn-share-graph").onclick = async () => {
const url = `${location.origin}/#view=graph${G.selected ? `&node=${encodeURIComponent(G.selected)}` : ""}`;
try { await navigator.clipboard.writeText(url); toast("分享链接已复制到剪贴板", "success"); }
catch { CN.sysCopy({ title: "分享链接(请复制)", text: url }); }
};
$("#btn-export-graph").onclick = () => {
const c = canvas();
const tmp = document.createElement("canvas");
tmp.width = c.width; tmp.height = c.height;
const tctx = tmp.getContext("2d");
tctx.fillStyle = "#ffffff";
tctx.fillRect(0, 0, tmp.width, tmp.height);
tctx.drawImage(c, 0, 0);
tmp.toBlob((blob) => {
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = `CareerNexus_岗位能力图谱_${new Date().toISOString().slice(0, 10)}.png`;
a.click();
URL.revokeObjectURL(a.href);
toast("图谱 PNG 已导出", "success");
});
};
$("#gs-toggle").onclick = () => $("#graph-suggest").classList.toggle("collapsed");
}
function restoreView() {
try {
const v = JSON.parse(localStorage.getItem(VIEW_KEY) || "null");
if (!v) return false;
G.cam = v.cam;
G.filters = Object.assign(
{ types: { Job: true, Skill: true, Industry: true, Scenario: true }, category: "", level: "",
edges: { required: true, preferred: true, applies: true, belongs: true } },
v.filters);
G.filters.edges = Object.assign({ required: true, preferred: true, applies: true, belongs: true },
(v.filters && v.filters.edges) || {});
G.showLabels = v.labels !== false;
$("#tool-labels").classList.toggle("active", G.showLabels);
$$("#graph-filter-panel input[data-ftype]").forEach((cb) => { cb.checked = !!G.filters.types[cb.dataset.ftype]; });
$$("#graph-filter-panel input[data-etype]").forEach((cb) => { cb.checked = G.filters.edges[cb.dataset.etype] !== false; });
$("#fp-category").value = G.filters.category || "";
$("#fp-level").value = G.filters.level || "";
return true;
} catch { return false; }
}
/* ================= 智能建议 ================= */
function renderSuggest() {
const items = [];
G.pending.forEach((c) => items.push({
color: "#ff7d00", kind: "pending", id: c.job_id,
text: `新岗位候选「${esc(c.title)}」待审核`,
sub: `${c.source_count} 条 JD · ${c.company_count} 家企业支撑`,
}));
G.events.slice(-4).reverse().forEach((e) => items.push({
color: "#2f54eb", kind: "event", id: e.job_id,
text: `「${esc(e.job_id.replace(/^job_/, ""))}」${esc(e.change_type)}技能 ${esc(e.skill)}`,
sub: `窗口 ${esc(e.window)} · 权重 ${e.prev_weight.toFixed(2)}→${e.curr_weight.toFixed(2)}`,
}));
$("#gs-list").innerHTML = items.length ? items.map((it, i) => `
<div class="gs-item" data-i="${i}">
<span class="gs-dot" style="background:${it.color}"></span>
<div><div class="gs-text">${it.text}</div><div class="gs-sub">${it.sub}</div>
${it.kind === "pending" ? `<a class="link-btn" data-review="${esc(it.id)}">去审核 →</a>` : ""}</div>
</div>`).join("") : `<div class="empty small">暂无建议</div>`;
$$("#gs-list .gs-item").forEach((el) => {
el.onclick = (ev) => {
if (ev.target.dataset.review) return;
const it = items[+el.dataset.i];
locateNode(it.id);
};
});
$$("#gs-list [data-review]").forEach((a) => {
a.onclick = () => locateNode(a.dataset.review);
});
}
/* ================= 演化逻辑链 ================= */
function renderChain(jobId) {
const chips = $("#gc-chips");
if (!jobId) {
$("#gc-job-name").textContent = "选择岗位节点后展示能力演化路径";
chips.innerHTML = `<span class="text-weak text-aux">—</span>`;
return;
}
const job = G.nodes[G.idx[jobId]];
$("#gc-job-name").textContent = job ? `「${job.title || jobId}」能力演化路径` : "";
const evs = G.events.filter((e) => e.job_id === jobId)
.sort((a, b) => (a.window < b.window ? -1 : 1));
const seq = [];
const seen = new Set();
evs.forEach((e) => {
if (seen.has(e.skill)) return;
seen.add(e.skill);
if (e.change_type === "新增") seq.push({ skill: e.skill, exit: false, window: e.window });
if (e.change_type === "退出") {
const found = seq.find((s) => s.skill === e.skill);
if (found) found.exit = true;
}
});
let html = seq.map((s) =>
`<span class="chain-chip${s.exit ? " exit" : ""}" data-skill="${esc(s.skill)}" title="${esc(s.window)}${s.exit ? " · 已退出" : ""}">${esc(s.skill)}${s.exit ? " ✕" : ""}</span>`
).join(`<span class="chain-arrow">→</span>`);
/* 旁列待审核关系 */
const pendRels = G.edges.filter((e) => e.pending && e.source === jobId);
if (pendRels.length) {
html += `<span class="chain-arrow">|待审核:</span>` + pendRels.map((e) =>
`<span class="chain-chip review" data-skill="${esc(e.target)}">⏳ ${esc(e.target)}</span>`).join("");
}
chips.innerHTML = html || `<span class="text-weak text-aux">该岗位暂无演化记录</span>`;
chips.querySelectorAll("[data-skill]").forEach((el) => {
el.onclick = () => locateNode(el.dataset.skill);
});
}
/* ================= 选中 / 定位 ================= */
function selectNode(id, pushNav = true) {
if (pushNav && G.selected && id && id !== G.selected) G.navStack.push(G.selected);
G.selected = id;
if (id) {
renderDetail(id);
const n = G.nodes[G.idx[id]];
renderChain(n && n.type === "Job" ? id : null);
} else {
$("#detail-body").classList.add("hidden");
$("#detail-empty").classList.remove("hidden");
renderChain(null);
}
requestDraw();
}
function locateNode(id) {
/* 定位也记入 URL,刷新/分享后回到同一节点 */
const hash = new URLSearchParams(location.hash.slice(1));
hash.set("view", "graph");
hash.set("node", id);
history.replaceState(null, "", "#" + hash.toString());
const n = G.nodes[G.idx[id]];
if (!n) { G.pendingLocate = id; return; } // 数据未就绪:加载完成后补定位
G.pendingLocate = null;
selectNode(id);
centerOn(n, Math.max(G.cam.k, 0.9));
}
CN.gotoNode = locateNode;
/* ================= 备注(localStorage) ================= */
const getNotes = () => JSON.parse(localStorage.getItem(NOTES_KEY) || "{}");
function nodeNotes(id) { return getNotes()[id] || []; }
function addNote(id, text) {
const all = getNotes();
(all[id] = all[id] || []).push({ text, time: new Date().toLocaleString("zh-CN") });
localStorage.setItem(NOTES_KEY, JSON.stringify(all));
}
function delNote(id, i) {
const all = getNotes();
(all[id] || []).splice(i, 1);
localStorage.setItem(NOTES_KEY, JSON.stringify(all));
}
/* ================= 详情面板 ================= */
const NODE_ICONS = {
Job: '<svg viewBox="0 0 24 24"><rect x="3" y="7" width="18" height="13" rx="2"/><path d="M8 7V5a2 2 0 012-2h4a2 2 0 012 2v2"/></svg>',
Skill: '<svg viewBox="0 0 24 24"><path d="M12 2l2.4 4.9 5.4.8-3.9 3.8.9 5.4-4.8-2.5-4.8 2.5.9-5.4L4.2 7.7l5.4-.8L12 2z"/></svg>',
Industry: '<svg viewBox="0 0 24 24"><path d="M3 21h18M5 21V8l6-4 6 4v13M9 12h2M13 12h2M9 16h2M13 16h2"/></svg>',
Scenario: '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3c2.5 2.6 3.8 5.7 3.8 9S14.5 18.4 12 21c-2.5-2.6-3.8-5.7-3.8-9S9.5 5.6 12 3z"/></svg>',
};
async function renderDetail(id) {
const n = G.nodes[G.idx[id]];
if (!n) return;
const meta = TYPE_META[n.type] || { name: n.type, tag: "tag-gray", color: "#86909c" };
const isPending = n.pending || n.status === "pending";
/* 关联 */
const rels = { Job: [], Skill: [], Scenario: [], Industry: [] };
const sources = new Set();
G.edges.forEach((e) => {
let other = null, imp = "";
if (e.source === id) { other = e.target; imp = e.importance; }
else if (e.target === id) { other = e.source; imp = e.importance; }
else return;
(e.sources || []).forEach((s) => sources.add(s));
const on = G.nodes[G.idx[other]];
if (on && rels[on.type]) rels[on.type].push({ id: other, title: on.title || other, imp, pending: e.pending });
});
/* 基础信息 */
const kv = [];
if (n.type === "Job") {
kv.push(["类别", n.category || "—"], ["期望年限", n.expected_years != null ? `${n.expected_years} 年` : "—"]);
if (n.source_count != null) kv.push(["支撑 JD", `${n.source_count} 条 / ${n.company_count} 家企业`]);
}
if (n.type === "Skill") kv.push(["类别", n.category || "通用技能"]);
kv.push(["级别", n.level || (n.type === "Job" ? jobLevel(n) || "—" : "—")]);
if (n.weight != null) kv.push(["权重", Number(n.weight).toFixed(2)]);
if (n.importance) kv.push(["重要性", n.importance === "required" ? "必备" : n.importance]);
const latestWin = G.edges.filter((e) => (e.source === id || e.target === id) && e.intervals?.length)
.flatMap((e) => e.intervals.map((i) => i[0])).sort().at(-1);
if (latestWin) kv.push(["更新时间", latestWin]);
const relHtml = Object.entries(rels).filter(([, l]) => l.length).map(([t, l]) => `
<div class="kv"><span class="k">关联${TYPE_META[t].name}</span></div>
<div class="rel-list">${l.slice(0, 12).map((r) => `
<div class="rel-item" data-id="${esc(r.id)}">
<span class="ri-kind tag ${TYPE_META[t].tag}">${TYPE_META[t].name}</span>
<span class="ri-name">${esc(r.title)}</span>
${r.imp ? `<span class="ri-extra">${r.imp === "required" ? "必备" : "加分"}${r.pending ? "·待审核" : ""}</span>` : ""}
</div>`).join("")}
${l.length > 12 ? `<div class="text-weak text-aux" style="padding:2px 8px">…共 ${l.length} 项</div>` : ""}
</div>`).join("");
const srcList = [...sources];
const notes = nodeNotes(id);
const audit = await api(`/api/audit?node_id=${encodeURIComponent(id)}&limit=6`).catch(() => []);
const skillEvents = n.type === "Skill"
? G.events.filter((e) => e.skill === id).sort((a, b) => (a.window < b.window ? 1 : -1)).slice(0, 3) : [];
$("#detail-empty").classList.add("hidden");
const body = $("#detail-body");
body.classList.remove("hidden");
body.innerHTML = `
<div class="dt-head">
<div class="dt-title-row">
<span class="dt-node-icon" style="background:${meta.color}">${NODE_ICONS[n.type] || NODE_ICONS.Job}</span>
<div>
<div class="dt-name">${esc(n.title || n.id)}</div>
<div class="dt-tags">
<span class="tag ${meta.tag}">${meta.name}</span>
${isPending ? `<span class="tag tag-orange">待审核</span>` : `<span class="tag tag-green">正式入库</span>`}
</div>
</div>
</div>
<button class="icon-btn sm dt-close" id="dt-close" title="关闭"><svg viewBox="0 0 24 24"><path d="M18 6L6 18M6 6l12 12"/></svg></button>
</div>
${G.navStack.length ? `<button class="btn btn-text btn-sm dt-back" id="dt-back">← 返回上一个节点</button>` : ""}
<div class="dt-sec"><div class="dt-sec-title">基础信息</div>
${kv.map(([k, v]) => `<div class="kv"><span class="k">${k}</span><span class="v">${esc(v)}</span></div>`).join("") || `<div class="text-weak text-aux">暂无属性</div>`}
</div>
${relHtml ? `<div class="dt-sec"><div class="dt-sec-title">关联信息(点击跳转)</div>${relHtml}</div>` : ""}
${n.type === "Skill" ? `
<div class="dt-sec"><div class="dt-sec-title">演化信息</div>
<div class="sparkline-box"><canvas id="dt-spark" width="264" height="52"></canvas>
<div class="text-weak text-aux" style="margin-top:4px">近 12 个季度热度(活跃岗位关系数)</div></div>
${skillEvents.map((e) => `<div class="audit-item"><div class="au-head">
<span class="tag ${({ "新增": "tag-green", "增强": "tag-blue", "减弱": "tag-orange", "退出": "tag-red" })[e.change_type] || "tag-gray"}">${e.change_type}</span>
<b>${esc(e.job_id.replace(/^job_/, ""))}</b><span class="text-weak">${esc(e.window)}</span></div></div>`).join("")}
</div>` : ""}
<div class="dt-sec"><div class="dt-sec-title">证据来源(支撑 JD,点击查看原文)</div>
${srcList.length ? srcList.slice(0, 10).map((s) => `
<div class="src-item" data-sid="${esc(s)}">
<svg viewBox="0 0 24 24"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><path d="M14 2v6h6"/></svg>${esc(s)}</div>`).join("")
+ (srcList.length > 10 ? `<div class="text-weak text-aux" style="padding:2px 8px">…共 ${srcList.length} 条</div>` : "")
: `<div class="text-weak text-aux">暂无来源记录</div>`}
</div>
<div class="dt-sec"><div class="dt-sec-title">操作</div>
<div class="dt-actions">
${isPending ? `
<button class="btn btn-primary btn-sm" id="dt-accept">确认入库</button>
<button class="btn btn-danger btn-sm" id="dt-reject">驳回</button>` : `
<button class="btn btn-secondary btn-sm" id="dt-edit">编辑节点</button>
<button class="btn btn-secondary btn-sm" id="dt-sources">查看来源</button>`}
<button class="btn btn-secondary btn-sm" id="dt-note">添加备注</button>
${n.type === "Job" && !isPending ? `<button class="btn btn-secondary btn-sm" id="dt-evo">查看演化</button>
<button class="btn btn-primary btn-sm full" id="dt-learn">生成学习路径</button>` : ""}
</div>
</div>
<div class="dt-sec"><div class="dt-sec-title">备注(本地保存)</div>
<div id="dt-notes">${notes.map((t, i) => `
<div class="note-item"><div class="nt-head"><span>${esc(t.time)}</span>
<button class="nt-del" data-i="${i}">删除</button></div>${esc(t.text)}</div>`).join("") ||
`<div class="text-weak text-aux">暂无备注</div>`}</div>
</div>
<div class="dt-sec"><div class="dt-sec-title">审核留痕</div>
${audit.length ? audit.map((r) => `
<div class="audit-item">
<div class="au-head"><b>${esc(r.operator || "—")}</b><span class="text-weak">${esc(r.time || "")}</span></div>
<div class="au-diff">${auditDiff(r)}${r.reason ? `<br>原因:${esc(r.reason)}` : ""}</div>
</div>`).join("") : `<div class="text-weak text-aux">暂无留痕记录</div>`}
</div>`;
/* 绑定 */
$("#dt-close").onclick = () => selectNode(null, false);
const back = $("#dt-back");
if (back) back.onclick = () => { const prev = G.navStack.pop(); if (prev) selectNode(prev, false); };
body.querySelectorAll(".rel-item").forEach((el) => { el.onclick = () => locateNode(el.dataset.id); });
body.querySelectorAll(".src-item").forEach((el) => { el.onclick = () => CN.showJdEvidence(el.dataset.sid); });
body.querySelectorAll(".nt-del").forEach((el) => {
el.onclick = () => { delNote(id, +el.dataset.i); renderDetail(id); };
});
$("#dt-note").onclick = async () => {
const text = await CN.sysPrompt({ title: `为「${n.title || n.id}」添加备注`, label: "备注内容:", placeholder: "如:评审时重点关注的技能" });
if (text && text.trim()) { addNote(id, text.trim()); renderDetail(id); toast("备注已保存", "success"); }
};
const srcBtn = $("#dt-sources");
if (srcBtn) srcBtn.onclick = () => {
const first = srcList[0];
if (first) CN.showJdEvidence(first); else toast("暂无来源记录", "info");
};
const editBtn = $("#dt-edit");
if (editBtn) editBtn.onclick = () => openEditModal(n);
const evoBtn = $("#dt-evo");
if (evoBtn) evoBtn.onclick = () => {
CN.switchView("evolution");
$("#evo-job-select").value = id;
$("#evo-job-select").dispatchEvent(new Event("change"));
};
const learnBtn = $("#dt-learn");
if (learnBtn) learnBtn.onclick = () => {
CN.switchView("path");
$("#path-job-select").value = id;
$("#btn-path").click();
};
const accBtn = $("#dt-accept"), rejBtn = $("#dt-reject");
if (accBtn) accBtn.onclick = () => reviewCandidate(id, "accept");
if (rejBtn) rejBtn.onclick = () => reviewCandidate(id, "reject");
/* 技能 sparkline */
if (n.type === "Skill") drawSkillSpark(id);
}
function auditDiff(r) {
const parts = [];
const before = r.before || {}, after = r.after || {};
Object.keys(after).forEach((k) => {
if (["job_id", "responsibilities", "scenarios", "required_skills", "preferred_skills"].includes(k)) return;
parts.push(`${esc(k)}: ${esc(JSON.stringify(before[k]) ?? "—")} → ${esc(JSON.stringify(after[k]))}`);
});
if (!parts.length && r.action) parts.push(`动作:${esc(r.action)}`);
return parts.join("<br>") || "—";
}
function drawSkillSpark(skillId) {
const c = $("#dt-spark");
if (!c) return;
/* 各季度活跃关系数:intervals 覆盖窗口即活跃 */
const wins = [...new Set(G.edges.flatMap((e) => (e.intervals || []).map((i) => i[0])))].sort();
const last12 = wins.slice(-12);
const values = last12.map((w) => G.edges.filter((e) =>
(e.source === skillId || e.target === skillId) &&
(e.intervals || []).some(([f, t]) => f <= w && (t === null || w < t))).length);
const ctx = c.getContext("2d");
ctx.clearRect(0, 0, c.width, c.height);
const max = Math.max(1, ...values);
values.forEach((v, i) => {
const bw = c.width / values.length;
const h = (v / max) * (c.height - 16);
ctx.fillStyle = "#2f54eb";
ctx.fillRect(i * bw + 2, c.height - h - 12, bw - 4, h);
if (i % 3 === 0) {
ctx.fillStyle = "#86909c"; ctx.font = "9px sans-serif"; ctx.textAlign = "center";
ctx.fillText(last12[i].slice(2), i * bw + bw / 2, c.height - 2);
}
});
}
/* ================= 节点编辑弹窗 ================= */
function openEditModal(n) {
openModal(`编辑节点 · ${esc(n.title || n.id)}`, `
<div class="form-row"><label class="form-label">名称</label>
<input id="ef-title" class="input" style="width:100%" value="${esc(n.title || "")}"></div>
<div class="form-row"><label class="form-label">级别</label>
<select id="ef-level" class="select" style="width:100%">
<option value="">未设置</option>
${["初级", "中级", "高级"].map((l) => `<option ${n.level === l ? "selected" : ""}>${l}</option>`).join("")}
</select></div>
<div class="form-row"><label class="form-label">重要性</label>
<select id="ef-importance" class="select" style="width:100%">
<option value="">未设置</option>
<option value="required" ${n.importance === "required" ? "selected" : ""}>必备</option>
<option value="preferred" ${n.importance === "preferred" ? "selected" : ""}>加分</option>
</select></div>
<div class="form-row"><label class="form-label">权重(0-1)</label>
<input id="ef-weight" type="number" min="0" max="1" step="0.05" class="input" style="width:100%" value="${n.weight ?? ""}"></div>
<div class="form-row"><label class="form-label">操作人</label>
<input id="ef-operator" class="input" style="width:100%" value="评审专家"></div>
<div class="form-row"><label class="form-label">修改原因</label>
<input id="ef-reason" class="input" style="width:100%" placeholder="选填,将写入审核留痕"></div>`,
`<button class="btn btn-secondary" id="ef-cancel">取消</button>
<button class="btn btn-primary" id="ef-submit">提交修改</button>`);
$("#ef-cancel").onclick = closeModal;
$("#ef-submit").onclick = async () => {
const updates = {};
const title = $("#ef-title").value.trim();
if (title && title !== (n.title || "")) updates.title = title;
if ($("#ef-level").value) updates.level = $("#ef-level").value;
if ($("#ef-importance").value) updates.importance = $("#ef-importance").value;
if ($("#ef-weight").value !== "") updates.weight = +$("#ef-weight").value;
if (!Object.keys(updates).length) return toast("没有需要提交的修改", "info");
try {
await post("/api/graph/node/update", {
node_id: n.id, updates,
operator: $("#ef-operator").value.trim() || "评审专家",
reason: $("#ef-reason").value.trim(),
});
Object.assign(n, updates);
closeModal();
toast("节点已更新并留痕", "success");
renderDetail(n.id);
requestDraw();
} catch (e) { toast(e.message, "error"); }
};
}
/* ================= 候选审核 ================= */
async function reviewCandidate(id, action) {
await CN.doReview(id, action);
}
/* ================= 对外接口 ================= */
CN.graph = {
inited: false,
async init() {
if (this.inited) return;
this.inited = true;
bindCanvas();
bindToolbar();
await load();
},
onShow() { requestDraw(); drawMinimap(); },
async reload() {
await load();
if (G.selected && !(G.selected in G.idx)) selectNode(null, false);
else if (G.selected) renderDetail(G.selected);
},
};
})();
+511
View File
@@ -0,0 +1,511 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CareerNexus 职联 · 岗位与能力图谱平台</title>
<link rel="stylesheet" href="/static/style.css?v=20260803">
</head>
<body>
<!-- ============ 顶部栏 56px ============ -->
<header class="topbar">
<div class="brand">
<span class="brand-logo">
<svg viewBox="0 0 24 24" fill="none"><circle cx="6" cy="6" r="3"/><circle cx="18" cy="7" r="3"/><circle cx="12" cy="18" r="3"/><path d="M8.5 7.5l7-1M7.5 8.8l3 6.4M16.5 9.6l-3 5.6"/></svg>
</span>
<span class="brand-name">CareerNexus <b>职联</b></span>
</div>
<div class="page-title" id="page-title">数据驾驶舱</div>
<div class="topbar-right">
<div class="global-search">
<svg viewBox="0 0 24 24"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.35-4.35"/></svg>
<input id="global-search-input" type="text" placeholder="搜索岗位 / 技能 / 场景…" autocomplete="off">
<div id="global-search-drop" class="search-drop hidden"></div>
</div>
<span class="badge badge-version" id="data-version" title="图谱数据版本">数据版本 —</span>
<button class="icon-btn" id="btn-config" title="系统设置" aria-label="系统设置">
<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 11-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 11-4 0v-.09a1.65 1.65 0 00-1-1.51 1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 11-2.83-2.83l.06-.06a1.65 1.65 0 00.33-1.82 1.65 1.65 0 00-1.51-1H3a2 2 0 110-4h.09a1.65 1.65 0 001.51-1 1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 112.83-2.83l.06.06a1.65 1.65 0 001.82.33h.01a1.65 1.65 0 001-1.51V3a2 2 0 114 0v.09a1.65 1.65 0 001 1.51h.01a1.65 1.65 0 001.82-.33l.06-.06a2 2 0 112.83 2.83l-.06.06a1.65 1.65 0 00-.33 1.82v.01a1.65 1.65 0 001.51 1H21a2 2 0 110 4h-.09a1.65 1.65 0 00-1.51 1z"/></svg>
</button>
<span class="avatar" title="演示账号">演</span>
</div>
</header>
<!-- ============ 横向导航(顶栏下方) ============ -->
<nav class="topnav" id="topnav">
<button class="nav-item active" data-view="dashboard">
<svg viewBox="0 0 24 24"><rect x="3" y="3" width="7" height="9" rx="1.5"/><rect x="14" y="3" width="7" height="5" rx="1.5"/><rect x="14" y="12" width="7" height="9" rx="1.5"/><rect x="3" y="16" width="7" height="5" rx="1.5"/></svg>
<span>数据驾驶舱</span>
</button>
<button class="nav-item" data-view="graph">
<svg viewBox="0 0 24 24"><circle cx="5" cy="6" r="2.5"/><circle cx="19" cy="6" r="2.5"/><circle cx="12" cy="18" r="2.5"/><path d="M7.3 7.2l9.4-1M6.2 8.3l4.2 7.4M17.8 8.3l-4.2 7.4"/></svg>
<span>全景图谱</span>
</button>
<button class="nav-item" data-view="evolution">
<svg viewBox="0 0 24 24"><path d="M3 17l5.5-5.5 3.5 3.5L20 7"/><path d="M15 7h5v5"/></svg>
<span>岗位演化</span>
</button>
<button class="nav-item" data-view="match">
<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="8"/><circle cx="12" cy="12" r="4"/><circle cx="12" cy="12" r="1" fill="currentColor"/></svg>
<span>人岗匹配</span>
</button>
<button class="nav-item" data-view="path">
<svg viewBox="0 0 24 24"><circle cx="6" cy="19" r="2.5"/><circle cx="18" cy="5" r="2.5"/><path d="M8 17.5C12 14 8 10 12 7.5c1.5-1 3-1.2 4-1.3"/></svg>
<span>学习路径</span>
</button>
</nav>
<!-- 离线回退模式横幅 -->
<div class="offline-banner hidden" id="offline-banner">
<svg viewBox="0 0 24 24"><path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/><path d="M12 9v4M12 17h.01"/></svg>
<span>当前为<b>离线回退模式</b>:未配置 LLM 密钥,抽取与生成使用离线确定性方案。配置星火大模型密钥可启用联网能力。</span>
<button class="banner-btn" id="banner-config">去配置</button>
<button class="banner-close" id="banner-close" title="关闭">×</button>
</div>
<!-- 语义向量模型横幅 -->
<div class="offline-banner hidden" id="embed-banner">
<svg viewBox="0 0 24 24"><path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/><path d="M12 9v4M12 17h.01"/></svg>
<span>语义向量模型<b>未就绪</b>:当前使用词表归一化匹配。下载 BGE-M3 权重并在设置中配置,可启用语义相似匹配增强。</span>
<button class="banner-btn" id="embed-banner-config">去配置</button>
<button class="banner-close" id="embed-banner-close" title="关闭">×</button>
</div>
<div class="shell">
<!-- ============ 主内容区 ============ -->
<main class="content">
<!-- ======== 数据驾驶舱 ======== -->
<section id="view-dashboard" class="view active">
<div class="metric-grid" id="metric-cards"></div>
<div class="grid-2">
<div class="card">
<div class="card-head">
<h3>近 12 个季度演化事件趋势</h3>
<span class="text-weak text-aux">按事件窗口统计</span>
</div>
<div class="card-body"><canvas id="trend-canvas" height="220"></canvas></div>
</div>
<div class="card">
<div class="card-head">
<h3>任务与审核状态</h3>
<button class="btn btn-secondary btn-sm" id="btn-import-jd">导入 JD 数据</button>
</div>
<div class="card-body" id="task-table-wrap">
<table class="table" id="task-table">
<thead><tr><th>类型</th><th>对象</th><th>操作人</th><th>时间</th><th>结果</th></tr></thead>
<tbody></tbody>
</table>
<div class="empty small hidden" id="task-empty">暂无任务记录</div>
</div>
</div>
</div>
<div class="grid-2">
<div class="card">
<div class="card-head"><h3>动态消息流</h3><span class="text-weak text-aux">点击可跳转</span></div>
<div class="card-body feed" id="activity-feed"></div>
</div>
<div class="card">
<div class="card-head"><h3>待审核新岗位候选</h3><span class="badge badge-warning" id="pending-count">0 待办</span></div>
<div class="card-body" id="pending-list"></div>
</div>
</div>
</section>
<!-- ======== 全景图谱工作台 ======== -->
<section id="view-graph" class="view">
<div class="graph-header card">
<div class="graph-title">
<h2>岗位能力图谱工作台</h2>
<span class="tag tag-blue">演示模式</span>
<span class="tag tag-cyan">技能点级</span>
<span class="tag tag-green">审核已开启</span>
</div>
<div class="graph-actions">
<button class="btn btn-secondary" id="btn-save-view">
<svg viewBox="0 0 24 24"><path d="M19 21H5a2 2 0 01-2-2V5a2 2 0 012-2h11l5 5v11a2 2 0 01-2 2z"/><path d="M17 21v-8H7v8M7 3v5h8"/></svg>
保存视图</button>
<button class="btn btn-secondary" id="btn-share-graph">
<svg viewBox="0 0 24 24"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><path d="M8.6 13.5l6.8 4M15.4 6.5l-6.8 4"/></svg>
分享</button>
<button class="btn btn-primary" id="btn-export-graph">
<svg viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3"/></svg>
导出图谱</button>
</div>
</div>
<div class="graph-body">
<div class="graph-canvas-wrap card" id="graph-canvas-wrap">
<canvas id="graph-canvas"></canvas>
<!-- 左上浮动工具条 -->
<div class="graph-toolbar">
<button class="tool-btn" id="tool-zoom-in" title="放大"><svg viewBox="0 0 24 24"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.35-4.35M11 8v6M8 11h6"/></svg></button>
<button class="tool-btn" id="tool-zoom-out" title="缩小"><svg viewBox="0 0 24 24"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.35-4.35M8 11h6"/></svg></button>
<button class="tool-btn" id="tool-fit" title="适应画布"><svg viewBox="0 0 24 24"><path d="M8 3H5a2 2 0 00-2 2v3M16 3h3a2 2 0 012 2v3M8 21H5a2 2 0 01-2-2v-3M16 21h3a2 2 0 002-2v-3"/></svg></button>
<span class="tool-sep"></span>
<button class="tool-btn" id="tool-filter" title="筛选"><svg viewBox="0 0 24 24"><path d="M22 3H2l8 9.46V19l4 2v-8.54L22 3z"/></svg></button>
<button class="tool-btn active" id="tool-labels" title="关系标签开关"><svg viewBox="0 0 24 24"><path d="M20.59 13.41L11 3H4v7l9.59 9.59a2 2 0 002.82 0l4.18-4.18a2 2 0 000-2.82z"/><circle cx="7.5" cy="7.5" r="1" fill="currentColor"/></svg></button>
<button class="tool-btn" id="tool-relayout" title="一键整理布局"><svg viewBox="0 0 24 24"><path d="M12 3v3M12 18v3M3 12h3M18 12h3M5.6 5.6l2.2 2.2M16.2 16.2l2.2 2.2M5.6 18.4l2.2-2.2M16.2 7.8l2.2-2.2"/></svg></button>
</div>
<!-- 筛选面板 -->
<div class="graph-filter-panel hidden" id="graph-filter-panel">
<div class="fp-head">图谱筛选 <button class="link-btn" id="fp-reset">重置</button></div>
<div class="fp-block">
<div class="fp-label">节点类型</div>
<label class="check"><input type="checkbox" data-ftype="Job" checked><i class="lg-dot job"></i>岗位</label>
<label class="check"><input type="checkbox" data-ftype="Skill" checked><i class="lg-dot skill"></i>技能</label>
<label class="check"><input type="checkbox" data-ftype="Industry" checked><i class="lg-dot industry"></i>行业</label>
<label class="check"><input type="checkbox" data-ftype="Scenario" checked><i class="lg-dot scenario"></i>场景</label>
</div>
<div class="fp-block">
<div class="fp-label">连线类型</div>
<label class="check"><input type="checkbox" data-etype="required" checked><i class="lg-line solid"></i>必备技能(116)</label>
<label class="check"><input type="checkbox" data-etype="preferred" checked><i class="lg-line dashed"></i>加分技能(78)</label>
<label class="check"><input type="checkbox" data-etype="applies" checked><i class="lg-line purple"></i>应用场景(45)</label>
<label class="check"><input type="checkbox" data-etype="belongs" checked><i class="lg-line cyan"></i>所属行业(22)</label>
</div>
<div class="fp-block">
<div class="fp-label">岗位类别(技术栈)</div>
<select id="fp-category"><option value="">全部类别</option></select>
</div>
<div class="fp-block">
<div class="fp-label">岗位级别</div>
<select id="fp-level">
<option value="">全部级别</option>
<option value="初级">初级(≤2 年)</option>
<option value="中级">中级(3-5 年)</option>
<option value="高级">高级(&gt;5 年)</option>
</select>
</div>
</div>
<!-- 左下:小地图 + 图例 -->
<div class="graph-minimap">
<canvas id="minimap-canvas" width="180" height="120"></canvas>
<div class="graph-legend">
<span><i class="lg-dot job"></i>岗位</span>
<span><i class="lg-sq skill"></i>技能</span>
<span><i class="lg-dot scenario"></i>场景</span>
<span><i class="lg-dot pending"></i>待审核</span>
<span><i class="lg-line solid"></i>必备</span>
<span><i class="lg-line dashed"></i>加分</span>
</div>
</div>
<!-- 右下:智能建议 -->
<div class="graph-suggest card" id="graph-suggest">
<div class="gs-head">
<svg viewBox="0 0 24 24"><path d="M12 2a7 7 0 00-4 12.7V17a1 1 0 001 1h6a1 1 0 001-1v-2.3A7 7 0 0012 2z"/><path d="M9 21h6"/></svg>
智能建议
<button class="icon-btn sm" id="gs-toggle" title="收起/展开"><svg viewBox="0 0 24 24"><path d="M6 9l6 6 6-6"/></svg></button>
</div>
<div class="gs-list" id="gs-list"></div>
</div>
<!-- 底部:演化逻辑链 -->
<div class="graph-chain" id="graph-chain">
<div class="gc-head">演化逻辑链 <span class="text-weak text-aux" id="gc-job-name">选择岗位节点后展示能力演化路径</span></div>
<div class="gc-chips" id="gc-chips"><span class="text-weak text-aux">—</span></div>
</div>
<div class="graph-loading" id="graph-loading">
<div class="skeleton-graph"></div>
<div class="text-weak">图谱布局计算中…</div>
</div>
</div>
<!-- 右侧详情面板 320px -->
<aside class="graph-detail card" id="graph-detail">
<div class="detail-empty" id="detail-empty">
<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M12 8v5M12 16h.01"/></svg>
<p>点击画布中的节点<br>查看详情、证据与操作</p>
<p class="text-aux text-weak">支持拖拽平移 / 滚轮缩放 / 点击选中</p>
</div>
<div class="detail-body hidden" id="detail-body"></div>
</aside>
</div>
</section>
<!-- ======== 岗位演化 ======== -->
<section id="view-evolution" class="view">
<div class="card">
<div class="card-head">
<h3>岗位能力演化分析</h3>
<div class="evo-controls">
<select id="evo-job-select" class="select"></select>
<div class="seg" id="evo-gran">
<button data-gran="month">月</button>
<button data-gran="quarter" class="active">季</button>
<button data-gran="year">年</button>
</div>
</div>
</div>
<div class="card-body">
<!-- 全部岗位演化热力图 -->
<div class="evo-chart-title text-aux text-secondary">全部岗位演化热力图(每格 = 该季度演化事件数,点击行切换岗位)</div>
<div class="evo-heatmap" id="evo-heatmap"><div class="empty small">加载中…</div></div>
<!-- 统计条 -->
<div class="evo-stats" id="evo-stats"></div>
<!-- 时点对比器 -->
<div class="evo-compare">
<div class="evo-compare-head">
<span class="evo-chart-title">能力画像时点对比</span>
<div class="evo-dates">
<input type="date" id="evo-date-a" class="input">
<span class="text-weak">→</span>
<input type="date" id="evo-date-b" class="input">
<button class="btn btn-secondary btn-sm" id="evo-compare-btn">对比</button>
</div>
</div>
<div class="evo-diff" id="evo-diff"></div>
</div>
<!-- 变更类型构成(堆叠柱状) -->
<div class="evo-chart-title text-aux text-secondary">变更类型构成(每季度 新增/增强/减弱/退出)</div>
<div class="evo-heat-wrap"><canvas id="evo-stack-canvas"></canvas></div>
<div class="stack-legend" id="stack-legend"></div>
<!-- 技能生命周期甘特图 -->
<div class="evo-chart-title text-aux text-secondary">技能生命周期(横条 = 技能在画像中的生效区间,圆点 = 变更事件)</div>
<div class="evo-gantt" id="evo-gantt"></div>
<!-- 热度走势(限高) -->
<div class="evo-chart-title text-aux text-secondary">技能热度走势(活跃技能关系数)</div>
<div class="evo-heat-wrap"><canvas id="evo-heat-canvas"></canvas></div>
<!-- 事件时间轴 -->
<div class="evo-chart-title text-aux text-secondary">演化事件时间轴(点击查看证据 JD)</div>
<div class="timeline" id="evo-timeline"></div>
</div>
</div>
</section>
<!-- ======== 人岗匹配(三步向导 + 批量匹配) ======== -->
<section id="view-match" class="view">
<div class="card">
<div class="card-body">
<div class="match-tabs">
<button class="match-tab active" data-mtab="single">单人诊断</button>
<button class="match-tab" data-mtab="batch">批量匹配</button>
</div>
<div id="match-single">
<div class="steps" id="match-steps">
<div class="step active" data-step="1"><i>1</i><span>简历录入</span></div>
<div class="step" data-step="2"><i>2</i><span>解析确认</span></div>
<div class="step" data-step="3"><i>3</i><span>诊断结果</span></div>
</div>
<!-- 第 1 步 -->
<div class="match-pane" id="match-pane-1">
<div class="form-row">
<label class="form-label">目标岗位</label>
<select id="match-job-select" class="select w-320"></select>
</div>
<div class="form-row">
<label class="form-label">简历文本</label>
<textarea id="resume-text" class="textarea" rows="10" placeholder="粘贴简历全文(将自动脱敏与技能抽取),或点击下方按钮上传 / 载入示例简历"></textarea>
</div>
<div class="form-actions">
<button class="btn btn-secondary" id="btn-load-sample">载入示例简历</button>
<label class="btn btn-secondary" for="resume-file">上传简历文件</label>
<input type="file" id="resume-file" accept=".txt,.md,.docx,.pdf" class="hidden">
<span class="text-aux text-weak">支持 .txt / .docx / .pdf,≤5MB</span>
<button class="btn btn-primary" id="btn-parse-resume">解析简历</button>
</div>
</div>
<!-- 第 2 步 -->
<div class="match-pane hidden" id="match-pane-2">
<div class="parse-summary" id="parse-summary"></div>
<div class="form-row">
<label class="form-label">识别技能(可增删)</label>
<div class="chip-editor" id="skill-chip-editor"></div>
<div class="chip-add">
<input id="skill-add-input" class="input" placeholder="输入技能后回车添加,支持逗号分隔,如 Kubernetes, Docker">
<button class="btn btn-secondary btn-sm" id="btn-skill-add">添加</button>
</div>
</div>
<div class="form-row">
<label class="form-label">工作年限</label>
<input id="parse-years" type="number" min="0" max="40" step="0.5" class="input w-120">
</div>
<div class="form-actions">
<button class="btn btn-secondary" id="btn-back-1">上一步</button>
<button class="btn btn-primary" id="btn-run-match">开始诊断</button>
</div>
</div>
<!-- 第 3 步 -->
<div class="match-pane hidden" id="match-pane-3">
<div class="match-step3-grid">
<!-- 左:简历原文 + 技能高亮 -->
<div class="resume-viewer">
<div class="rv-head">
<span class="rv-title">结构化简历(已脱敏)</span>
<span class="rv-legend">
<span class="hl hl-req">必备</span>
<span class="hl hl-pref">加分</span>
<span class="hl hl-other">其他技能</span>
<button class="btn btn-secondary btn-sm" id="btn-export-resume">
<svg viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3"/></svg>
导出 DOCX</button>
</span>
</div>
<div class="rv-body" id="resume-highlight">
<div class="empty"><p>解析简历后此处显示规范简历模板</p></div>
</div>
</div>
<!-- 右:诊断结果 -->
<div class="match-step3-right">
<div class="match-result-grid">
<div class="mrg-left">
<div class="gauge-wrap"><canvas id="gauge-canvas" width="220" height="150"></canvas>
<div class="gauge-text"><b id="gauge-score">0</b><span>综合匹配分</span></div>
</div>
<div class="match-verdict" id="match-verdict"></div>
<canvas id="radar-canvas" width="340" height="280"></canvas>
</div>
<div class="mrg-right">
<div class="hit-miss">
<div class="hm-col">
<div class="hm-title ok"><svg viewBox="0 0 24 24"><path d="M20 6L9 17l-5-5"/></svg>已命中技能</div>
<div class="hm-chips" id="matched-chips"></div>
</div>
<div class="hm-col">
<div class="hm-title bad"><svg viewBox="0 0 24 24"><path d="M18 6L6 18M6 6l12 12"/></svg>缺失技能</div>
<div class="hm-chips" id="missing-chips"></div>
</div>
</div>
<div class="transfer-block">
<div class="hm-title info"><svg viewBox="0 0 24 24"><path d="M17 3l4 4-4 4M21 7H9a4 4 0 00-4 4v1M7 21l-4-4 4-4M3 17h12a4 4 0 004-4v-1"/></svg>可迁移技能</div>
<div class="hm-chips" id="transfer-chips"></div>
</div>
<div class="transfer-block hidden" id="irrelevant-block">
<div class="hm-title weak">与目标岗位无关的技能(不计入评分)</div>
<div class="hm-chips" id="irrelevant-chips"></div>
</div>
</div>
</div>
<div class="rec-jobs">
<div class="hm-title info">推荐岗位(按匹配分排序)</div>
<div class="rec-job-cards" id="rec-job-cards"></div>
</div>
<div class="form-actions">
<button class="btn btn-secondary" id="btn-back-2">重新录入</button>
<button class="btn btn-primary" id="btn-goto-path">生成学习路径</button>
</div>
</div>
</div>
</div>
</div><!-- /match-single -->
<!-- ======== 批量匹配 ======== -->
<div id="match-batch" class="hidden">
<div class="form-row">
<label class="form-label">目标岗位</label>
<select id="batch-job-select" class="select w-320"></select>
</div>
<div class="dropzone" id="batch-drop">
<svg viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M17 8l-5-5-5 5M12 3v12"/></svg>
<p><b>拖拽简历文件或文件夹到此处</b>,或:</p>
<div class="dropzone-btns">
<label class="btn btn-secondary" for="batch-files">选择文件(可多选)</label>
<label class="btn btn-secondary" for="batch-dir">选择文件夹</label>
</div>
<input type="file" id="batch-files" accept=".txt,.md,.docx,.pdf" multiple class="hidden">
<input type="file" id="batch-dir" webkitdirectory class="hidden">
<p class="text-aux text-weak">支持 .txt/.docx/.pdf · 单批 ≤50 份 · 单份 ≤5MB · 解析自动脱敏</p>
</div>
<div class="batch-filelist" id="batch-filelist"></div>
<div class="form-actions">
<button class="btn btn-primary" id="btn-batch-run">
<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="8"/><circle cx="12" cy="12" r="4"/><circle cx="12" cy="12" r="1" fill="currentColor"/></svg>
开始批量匹配</button>
<button class="btn btn-secondary hidden" id="btn-batch-export">
<svg viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3"/></svg>
导出结果 CSV</button>
</div>
<div id="batch-result"></div>
</div>
</div>
</div>
</section>
<!-- ======== 学习路径 ======== -->
<section id="view-path" class="view">
<div class="path-layout">
<!-- 左栏:路径配置 -->
<div class="card path-side">
<div class="card-head"><h3>路径配置</h3></div>
<div class="card-body">
<div class="form-item">
<label class="form-label">目标岗位</label>
<select id="path-job-select" class="select w-full"></select>
</div>
<div class="form-item">
<label class="form-label">岗位能力清单<span class="form-label-tip">点击标记为「已掌握」</span></label>
<div class="skill-pool" id="path-skill-pool">
<div class="pool-loading">选择岗位后加载技能清单…</div>
</div>
</div>
<div class="form-item">
<label class="form-label">学习周期</label>
<div class="seg" id="path-weeks">
<button class="seg-item" data-weeks="4">4 周</button>
<button class="seg-item active" data-weeks="8">8 周</button>
<button class="seg-item" data-weeks="12">12 周</button>
</div>
</div>
<div class="path-summary" id="path-summary">
<div class="path-summary-row"><span>已掌握</span><b id="ps-known">0 项</b></div>
<div class="path-summary-row"><span>待补齐</span><b id="ps-gap">—</b></div>
<div class="progress"><div class="progress-bar" id="ps-bar" style="width:0%"></div></div>
<div class="path-summary-tip" id="ps-tip">覆盖率 0%</div>
</div>
<button class="btn btn-primary btn-block" id="btn-path">
<svg viewBox="0 0 24 24"><circle cx="6" cy="19" r="2.5"/><circle cx="18" cy="5" r="2.5"/><path d="M8 17.5C12 14 8 10 12 7.5c1.5-1 3-1.2 4-1.3"/></svg>
生成学习路径</button>
</div>
</div>
<!-- 右栏:路径结果 -->
<div class="card path-main">
<div class="card-head">
<h3>分阶段学习路线</h3>
<button class="btn btn-secondary btn-sm hidden" id="btn-export-path">
<svg viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3"/></svg>
导出文本</button>
</div>
<div class="card-body">
<div class="path-overview hidden" id="path-overview"></div>
<div class="path-flow" id="path-result">
<div class="empty">
<svg viewBox="0 0 24 24"><circle cx="6" cy="19" r="2.5"/><circle cx="18" cy="5" r="2.5"/><path d="M8 17.5C12 14 8 10 12 7.5c1.5-1 3-1.2 4-1.3"/></svg>
<p>在左侧选择目标岗位并标记已掌握技能<br>点击「生成学习路径」查看分阶段路线</p>
</div>
</div>
</div>
</div>
</div>
</section>
<footer class="app-footer">如遇问题,请联系 support@rrrosmontis.icu</footer>
</main>
</div>
<!-- 通用弹窗容器 -->
<div class="modal-mask hidden" id="modal-mask">
<div class="modal" id="modal-box" role="dialog" aria-modal="true">
<div class="modal-head"><h3 id="modal-title"></h3>
<button class="icon-btn" id="modal-close" title="关闭"><svg viewBox="0 0 24 24"><path d="M18 6L6 18M6 6l12 12"/></svg></button>
</div>
<div class="modal-body" id="modal-body"></div>
<div class="modal-foot hidden" id="modal-foot"></div>
</div>
</div>
<div class="toast-wrap" id="toast-wrap"></div>
<script src="/static/app.js?v=20260803"></script>
<script src="/static/graph.js?v=20260803"></script>
</body>
</html>
+913
View File
@@ -0,0 +1,913 @@
/* ============================================================
CareerNexus 职联 · 设计系统(企业级 OA 风格,完全离线)
参考 Ant Design Pro / 飞书 / Arco Design
============================================================ */
:root {
/* 品牌色 */
--primary: #2f54eb;
--primary-hover: #1d39c4;
--primary-light: #f0f5ff;
--success: #00b42a;
--success-light: #e8ffea;
--warning: #ff7d00;
--warning-light: #fff7e8;
--danger: #f53f3f;
--danger-light: #ffece8;
--cyan: #13c2c2;
--cyan-light: #e6fffb;
--purple: #722ed1;
--purple-light: #f9f0ff;
/* 中性色 */
--text-1: #1d2129;
--text-2: #4e5969;
--text-3: #86909c;
--border: #e5e6eb;
--border-dark: #c9cdd4;
--bg-page: #f2f3f5;
--bg-card: #ffffff;
--bg-hover: #f7f8fa;
/* 尺寸 */
--radius: 8px;
--radius-sm: 4px;
--shadow-card: 0 1px 2px rgba(29, 33, 41, 0.04), 0 2px 8px rgba(29, 33, 41, 0.04);
--shadow-pop: 0 6px 16px rgba(29, 33, 41, 0.12);
--topbar-h: 56px;
--topnav-h: 46px;
--font: -apple-system, "PingFang SC", "Microsoft YaHei", "Segoe UI", sans-serif;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; }
body {
font-family: var(--font);
font-size: 14px;
color: var(--text-1);
background: var(--bg-page);
overflow: hidden;
-webkit-font-smoothing: antialiased;
}
button { font-family: var(--font); cursor: pointer; }
input, select, textarea { font-family: var(--font); font-size: 14px; color: var(--text-1); }
svg { width: 16px; height: 16px; stroke: currentColor; fill: none; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
.hidden { display: none !important; }
.text-weak { color: var(--text-3); }
.text-secondary { color: var(--text-2); }
.text-aux { font-size: 12px; }
.w-120 { width: 120px; }
.w-320 { width: 320px; }
/* ============ 顶部栏 ============ */
.topbar {
height: var(--topbar-h);
background: #fff;
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
display: flex;
align-items: center;
gap: 16px;
padding: 0 20px 0 12px;
position: relative;
z-index: 100;
}
.icon-btn {
width: 32px; height: 32px;
display: inline-flex; align-items: center; justify-content: center;
border: none; background: transparent; border-radius: var(--radius-sm);
color: var(--text-2);
}
.icon-btn:hover { background: var(--bg-hover); color: var(--primary); }
.icon-btn.sm { width: 24px; height: 24px; }
.icon-btn.sm svg { width: 14px; height: 14px; }
.brand { display: flex; align-items: center; gap: 8px; }
.brand-logo {
width: 30px; height: 30px; border-radius: 7px;
background: linear-gradient(135deg, #2f54eb, #13c2c2);
display: inline-flex; align-items: center; justify-content: center;
}
.brand-logo svg { width: 19px; height: 19px; color: #fff; }
.brand-name { font-size: 16px; color: var(--text-1); letter-spacing: 0.2px; }
.brand-name b { font-weight: 600; }
.page-title {
position: absolute; left: 50%; transform: translateX(-50%);
font-size: 15px; font-weight: 600; color: var(--text-1);
}
.topbar-right { margin-left: auto; display: flex; align-items: center; gap: 14px; }
.global-search {
position: relative;
display: flex; align-items: center; gap: 6px;
background: var(--bg-page); border: 1px solid transparent; border-radius: 16px;
padding: 5px 12px; width: 240px;
color: var(--text-3);
transition: all 0.2s;
}
.global-search:focus-within { background: #fff; border-color: var(--primary); width: 280px; }
.global-search svg { width: 14px; height: 14px; flex: none; }
.global-search input { border: none; outline: none; background: transparent; width: 100%; font-size: 13px; }
.search-drop {
position: absolute; top: 36px; left: 0; right: -40px;
background: #fff; border: 1px solid var(--border); border-radius: var(--radius);
box-shadow: var(--shadow-pop); max-height: 320px; overflow: auto; z-index: 200;
}
.search-drop .sd-item {
display: flex; align-items: center; gap: 8px;
padding: 8px 12px; cursor: pointer; font-size: 13px;
}
.search-drop .sd-item:hover { background: var(--primary-light); }
.search-drop .sd-item .tag { flex: none; }
.avatar {
width: 30px; height: 30px; border-radius: 50%;
background: var(--primary); color: #fff; font-size: 13px;
display: inline-flex; align-items: center; justify-content: center;
cursor: default;
}
/* ============ 徽章 / 标签 ============ */
.badge {
display: inline-flex; align-items: center; gap: 4px;
font-size: 12px; padding: 2px 8px; border-radius: 10px;
background: var(--bg-page); color: var(--text-2); white-space: nowrap;
}
.badge-version { background: var(--cyan-light); color: #08979c; border: 1px solid #87e8de; }
.badge-warning { background: var(--warning-light); color: var(--warning); }
.badge-success { background: var(--success-light); color: var(--success); }
.tag {
display: inline-flex; align-items: center;
font-size: 12px; line-height: 20px; padding: 0 8px;
border-radius: var(--radius-sm); border: 1px solid transparent; white-space: nowrap;
}
.tag-blue { color: var(--primary); background: var(--primary-light); border-color: #d6e4ff; }
.tag-green { color: var(--success); background: var(--success-light); border-color: #b7eb8f; }
.tag-orange { color: var(--warning); background: var(--warning-light); border-color: #ffd591; }
.tag-red { color: var(--danger); background: var(--danger-light); border-color: #ffa39e; }
.tag-cyan { color: #08979c; background: var(--cyan-light); border-color: #87e8de; }
.tag-purple { color: var(--purple); background: var(--purple-light); border-color: #d3adf7; }
.tag-gray { color: var(--text-2); background: var(--bg-page); border-color: var(--border); }
/* ============ 布局骨架 ============ */
.shell { display: flex; height: calc(100vh - var(--topbar-h) - var(--topnav-h)); }
/* ============ 横向导航(顶栏下方) ============ */
.topnav {
height: var(--topnav-h);
background: #fff;
border-bottom: 1px solid var(--border);
display: flex; align-items: stretch; justify-content: center; gap: 32px;
padding: 0 20px;
position: relative; z-index: 90;
}
.nav-item {
position: relative;
display: flex; align-items: center; gap: 8px;
padding: 0 16px; margin: 0;
background: transparent; border: none; border-radius: 0;
color: var(--text-2); font-size: 14px; white-space: nowrap;
cursor: pointer;
transition: color 0.15s, background 0.15s;
}
.nav-item svg { width: 16px; height: 16px; flex: none; }
.nav-item:hover { color: var(--primary); background: var(--bg-hover); }
/* 选中态特殊处理:主色加粗 + 底部指示条 + 浅底高亮 */
.nav-item.active {
color: var(--primary);
font-weight: 600;
background: var(--primary-light);
}
.nav-item.active::after {
content: "";
position: absolute; left: 10px; right: 10px; bottom: -1px;
height: 3px; border-radius: 2px 2px 0 0;
background: var(--primary);
}
.nav-item.active svg { stroke-width: 2.2; }
.content { flex: 1; overflow-y: auto; padding: 16px 20px 32px; }
.view { display: none; }
.view.active { display: block; }
/* ============ 卡片 ============ */
.card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow-card);
}
.card-head {
display: flex; align-items: center; justify-content: space-between;
padding: 14px 16px; border-bottom: 1px solid var(--border);
}
.card-head h3 { font-size: 15px; font-weight: 600; }
.card-body { padding: 16px; }
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 16px; }
/* ============ 按钮 ============ */
.btn {
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
height: 32px; padding: 0 15px;
border-radius: var(--radius-sm); border: 1px solid transparent;
font-size: 14px; transition: all 0.15s; white-space: nowrap;
}
.btn svg { width: 14px; height: 14px; }
.btn-primary { background: var(--primary); color: #fff; }
.btn-primary:hover { background: var(--primary-hover); }
.btn-secondary { background: #fff; border-color: var(--border-dark); color: var(--text-1); }
.btn-secondary:hover { color: var(--primary); border-color: var(--primary); }
.btn-text { background: transparent; border: none; color: var(--primary); padding: 0 8px; }
.btn-text:hover { background: var(--primary-light); }
.btn-danger { background: var(--danger); color: #fff; }
.btn-danger:hover { background: #cb2634; }
.btn:disabled { opacity: 0.5; cursor: not-allowed; pointer-events: none; }
.btn-sm { height: 28px; padding: 0 10px; font-size: 13px; }
.link-btn { border: none; background: none; color: var(--primary); font-size: 12px; }
.link-btn:hover { text-decoration: underline; }
/* ============ 表单 ============ */
.input, .select, .textarea {
border: 1px solid var(--border-dark); border-radius: var(--radius-sm);
padding: 6px 10px; outline: none; background: #fff;
transition: border-color 0.15s;
}
.input:focus, .select:focus, .textarea:focus { border-color: var(--primary); box-shadow: 0 0 0 2px rgba(47, 84, 235, 0.1); }
.textarea { width: 100%; resize: vertical; line-height: 1.6; }
.select { height: 32px; }
.form-row { margin-bottom: 16px; }
.form-label { display: block; font-size: 13px; color: var(--text-2); margin-bottom: 6px; }
.form-actions { display: flex; gap: 10px; justify-content: flex-end; margin-top: 8px; }
/* ============ 表格 ============ */
.table { width: 100%; border-collapse: collapse; font-size: 13px; }
.table th {
text-align: left; color: var(--text-3); font-weight: 500;
padding: 8px 10px; border-bottom: 1px solid var(--border); background: var(--bg-hover);
}
.table td { padding: 9px 10px; border-bottom: 1px solid var(--border); color: var(--text-2); }
.table tr:hover td { background: var(--bg-hover); }
/* ============ 空状态 / 骨架 ============ */
.empty { text-align: center; padding: 48px 16px; color: var(--text-3); }
.empty svg { width: 44px; height: 44px; stroke-width: 1.2; margin-bottom: 10px; opacity: 0.5; }
.empty p { font-size: 13px; line-height: 1.8; }
.empty.small { padding: 20px; font-size: 13px; }
.skeleton-graph {
width: 240px; height: 240px; border-radius: 50%;
background: radial-gradient(circle, #e8ebf0 20%, transparent 21%),
conic-gradient(#e8ebf0, #f5f6f8, #e8ebf0);
animation: pulse 1.4s ease-in-out infinite;
}
@keyframes pulse { 50% { opacity: 0.5; } }
/* ============ 数据驾驶舱 ============ */
.metric-grid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 16px; }
.metric-card {
background: #fff; border: 1px solid var(--border); border-radius: var(--radius);
box-shadow: var(--shadow-card); padding: 16px; cursor: pointer;
transition: all 0.15s; position: relative; overflow: hidden;
}
.metric-card:hover { border-color: var(--primary); box-shadow: var(--shadow-pop); transform: translateY(-2px); }
.metric-card .mc-label { font-size: 13px; color: var(--text-2); display: flex; align-items: center; gap: 6px; }
.metric-card .mc-label svg { width: 15px; height: 15px; color: var(--primary); }
.metric-card .mc-num { font-size: 26px; font-weight: 600; font-variant-numeric: tabular-nums; margin: 6px 0 4px; }
.metric-card .mc-delta { font-size: 12px; display: inline-flex; align-items: center; gap: 3px; }
.metric-card .mc-delta.up { color: var(--success); }
.metric-card .mc-delta.down { color: var(--danger); }
.metric-card .mc-delta.flat { color: var(--text-3); }
.metric-card canvas { position: absolute; right: 10px; bottom: 10px; opacity: 0.9; }
.feed { max-height: 320px; overflow-y: auto; }
.feed-item {
display: flex; gap: 10px; padding: 10px 8px; border-radius: var(--radius-sm);
cursor: pointer; align-items: flex-start;
}
.feed-item:hover { background: var(--primary-light); }
.feed-item .fi-icon {
width: 26px; height: 26px; border-radius: 50%; flex: none;
display: inline-flex; align-items: center; justify-content: center;
}
.feed-item .fi-icon svg { width: 13px; height: 13px; }
.feed-item .fi-text { font-size: 13px; line-height: 1.5; color: var(--text-1); }
.feed-item .fi-time { font-size: 12px; color: var(--text-3); margin-top: 2px; }
.review-item {
border: 1px solid var(--border); border-radius: var(--radius);
padding: 12px 14px; margin-bottom: 10px;
}
.review-item .ri-head { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
.review-item .ri-head b { font-size: 14px; }
.review-item .ri-meta { font-size: 12px; color: var(--text-3); margin-bottom: 8px; }
.review-item .ri-skills { font-size: 12px; color: var(--text-2); margin-bottom: 10px; line-height: 1.7; }
.review-item .ri-actions { display: flex; gap: 8px; }
/* ============ 图谱工作台 ============ */
.graph-header {
display: flex; align-items: center; justify-content: space-between;
padding: 12px 16px; margin-bottom: 12px;
}
.graph-title { display: flex; align-items: center; gap: 10px; }
.graph-title h2 { font-size: 18px; font-weight: 600; }
.graph-actions { display: flex; gap: 8px; }
.graph-body { display: flex; gap: 12px; height: calc(100vh - var(--topbar-h) - 130px); min-height: 520px; }
.graph-canvas-wrap { flex: 1; position: relative; overflow: hidden; min-width: 0; }
#graph-canvas { width: 100%; height: 100%; display: block; cursor: grab; }
#graph-canvas.dragging { cursor: grabbing; }
#graph-canvas.hovering { cursor: pointer; }
.graph-toolbar {
position: absolute; top: 14px; left: 14px;
display: flex; flex-direction: column; gap: 2px;
background: #fff; border: 1px solid var(--border); border-radius: var(--radius);
box-shadow: var(--shadow-pop); padding: 4px; z-index: 20;
}
.tool-btn {
width: 32px; height: 32px; border: none; background: transparent;
border-radius: var(--radius-sm); color: var(--text-2);
display: inline-flex; align-items: center; justify-content: center;
}
.tool-btn:hover { background: var(--primary-light); color: var(--primary); }
.tool-btn.active { background: var(--primary-light); color: var(--primary); }
.tool-sep { height: 1px; background: var(--border); margin: 3px 4px; }
.graph-filter-panel {
position: absolute; top: 14px; left: 58px; width: 220px;
background: #fff; border: 1px solid var(--border); border-radius: var(--radius);
box-shadow: var(--shadow-pop); padding: 12px 14px; z-index: 21;
}
.fp-head { display: flex; justify-content: space-between; align-items: center; font-weight: 600; font-size: 13px; margin-bottom: 10px; }
.fp-block { margin-bottom: 12px; }
.fp-label { font-size: 12px; color: var(--text-3); margin-bottom: 6px; }
.fp-block .select { width: 100%; }
.check { display: flex; align-items: center; gap: 7px; font-size: 13px; padding: 3px 0; cursor: pointer; color: var(--text-1); }
.check input { accent-color: var(--primary); }
.graph-minimap {
position: absolute; left: 14px; bottom: 64px;
background: #fff; border: 1px solid var(--border); border-radius: var(--radius);
box-shadow: var(--shadow-pop); padding: 6px; z-index: 15;
}
#minimap-canvas { display: block; border-radius: var(--radius-sm); background: var(--bg-page); cursor: crosshair; }
.graph-legend {
display: flex; flex-wrap: wrap; gap: 4px 10px;
font-size: 12px; color: var(--text-2); padding: 6px 2px 2px; max-width: 190px;
}
.graph-legend span { display: inline-flex; align-items: center; gap: 5px; }
.lg-dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; }
.lg-dot.job { background: var(--primary); }
.lg-dot.industry { background: #13c2c2; }
.lg-dot.scenario { background: var(--purple); }
.lg-dot.pending { background: #fff; border: 2px dashed var(--warning); width: 8px; height: 8px; }
.lg-sq { width: 9px; height: 9px; border-radius: 2px; display: inline-block; }
.lg-sq.skill { background: var(--success); }
.lg-line { width: 16px; height: 0; display: inline-block; border-top: 2px solid var(--text-2); }
.lg-line.dashed { border-top-style: dashed; }
.lg-line.solid { border-top-color: var(--primary); }
.lg-line.purple { border-top-color: var(--purple); }
.lg-line.cyan { border-top-color: var(--cyan); }
.graph-suggest {
position: absolute; right: 14px; bottom: 64px; width: 260px;
z-index: 15; overflow: hidden;
}
.gs-head {
display: flex; align-items: center; gap: 6px;
font-size: 13px; font-weight: 600; padding: 10px 12px;
border-bottom: 1px solid var(--border); color: var(--text-1);
}
.gs-head svg { color: var(--warning); width: 15px; height: 15px; }
.gs-head .icon-btn { margin-left: auto; }
.gs-list { max-height: 220px; overflow-y: auto; padding: 6px; }
.gs-item {
display: flex; gap: 8px; padding: 8px; border-radius: var(--radius-sm);
cursor: pointer; align-items: flex-start; font-size: 12px;
}
.gs-item:hover { background: var(--primary-light); }
.gs-item .gs-dot { width: 6px; height: 6px; border-radius: 50%; margin-top: 5px; flex: none; }
.gs-item .gs-text { color: var(--text-1); line-height: 1.5; }
.gs-item .gs-sub { color: var(--text-3); margin-top: 2px; }
.graph-suggest.collapsed .gs-list { display: none; }
.graph-chain {
position: absolute; left: 14px; right: 14px; bottom: 12px;
background: rgba(255, 255, 255, 0.96); border: 1px solid var(--border);
border-radius: var(--radius); box-shadow: var(--shadow-pop);
padding: 8px 14px; z-index: 14;
}
.gc-head { font-size: 12px; font-weight: 600; margin-bottom: 6px; display: flex; gap: 8px; align-items: center; }
.gc-chips { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; max-height: 62px; overflow-y: auto; }
.chain-chip {
display: inline-flex; align-items: center; gap: 4px;
border: 1px solid #b7c6f5; background: var(--primary-light); color: var(--primary);
font-size: 12px; border-radius: 12px; padding: 2px 10px; cursor: pointer;
}
.chain-chip:hover { background: var(--primary); color: #fff; }
.chain-chip.exit { border-color: #ffa39e; background: var(--danger-light); color: var(--danger); }
.chain-arrow { color: var(--text-3); font-size: 12px; }
.chain-chip.review { border-style: dashed; border-color: var(--warning); background: var(--warning-light); color: var(--warning); }
.graph-loading {
position: absolute; inset: 0; z-index: 30;
background: rgba(255, 255, 255, 0.9);
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 14px;
}
/* 右侧详情面板 */
.graph-detail { width: 320px; flex: none; overflow-y: auto; display: flex; flex-direction: column; }
.detail-empty { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; color: var(--text-3); gap: 10px; text-align: center; padding: 20px; }
.detail-empty svg { width: 46px; height: 46px; stroke-width: 1.2; opacity: 0.45; }
.detail-empty p { font-size: 13px; line-height: 1.8; }
.detail-body { padding: 0 0 16px; }
.dt-head { padding: 16px 16px 12px; border-bottom: 1px solid var(--border); position: sticky; top: 0; background: #fff; z-index: 2; }
.dt-head .dt-title-row { display: flex; align-items: flex-start; gap: 10px; }
.dt-node-icon {
width: 36px; height: 36px; border-radius: var(--radius); flex: none;
display: inline-flex; align-items: center; justify-content: center; color: #fff;
}
.dt-node-icon svg { width: 18px; height: 18px; }
.dt-name { font-size: 16px; font-weight: 600; line-height: 1.3; word-break: break-all; }
.dt-tags { display: flex; gap: 6px; margin-top: 6px; flex-wrap: wrap; }
.dt-close { position: absolute; top: 10px; right: 10px; }
.dt-back { margin: 10px 16px 0; }
.dt-sec { padding: 12px 16px 0; }
.dt-sec-title {
font-size: 12px; color: var(--text-3); font-weight: 600;
margin-bottom: 8px; display: flex; align-items: center; gap: 6px;
}
.dt-sec-title::after { content: ""; flex: 1; height: 1px; background: var(--border); }
.kv { display: flex; font-size: 13px; padding: 4px 0; }
.kv .k { width: 72px; flex: none; color: var(--text-3); }
.kv .v { color: var(--text-1); word-break: break-all; }
.rel-list { display: flex; flex-direction: column; gap: 4px; }
.rel-item {
display: flex; align-items: center; gap: 8px;
font-size: 13px; padding: 6px 8px; border-radius: var(--radius-sm);
cursor: pointer; border: 1px solid transparent;
}
.rel-item:hover { background: var(--primary-light); border-color: #d6e4ff; }
.rel-item .ri-kind { flex: none; }
.rel-item .ri-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rel-item .ri-extra { font-size: 12px; color: var(--text-3); flex: none; }
.src-item {
font-size: 12px; color: var(--primary); padding: 5px 8px;
border-radius: var(--radius-sm); cursor: pointer;
display: flex; align-items: center; gap: 6px;
}
.src-item:hover { background: var(--primary-light); text-decoration: underline; }
.src-item svg { width: 12px; height: 12px; flex: none; }
.dt-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.dt-actions .btn { width: 100%; }
.dt-actions .full { grid-column: 1 / -1; }
.audit-item {
font-size: 12px; border-left: 2px solid var(--border-dark);
padding: 4px 0 4px 10px; margin-bottom: 8px; color: var(--text-2);
}
.audit-item .au-head { display: flex; gap: 6px; align-items: center; margin-bottom: 2px; }
.audit-item .au-diff { color: var(--text-3); line-height: 1.6; word-break: break-all; }
.note-item {
background: var(--warning-light); border: 1px solid #ffe7ba;
border-radius: var(--radius-sm); padding: 8px 10px; margin-bottom: 6px; font-size: 12px;
}
.note-item .nt-head { display: flex; justify-content: space-between; color: var(--text-3); margin-bottom: 3px; }
.note-item .nt-del { color: var(--danger); cursor: pointer; border: none; background: none; font-size: 12px; }
.sparkline-box { background: var(--bg-hover); border-radius: var(--radius-sm); padding: 8px; }
/* ============ 岗位演化 ============ */
.evo-controls { display: flex; gap: 10px; align-items: center; }
.seg { display: inline-flex; border: 1px solid var(--border-dark); border-radius: var(--radius-sm); overflow: hidden; }
.seg button { border: none; background: #fff; padding: 5px 14px; font-size: 13px; color: var(--text-2); }
.seg button.active { background: var(--primary); color: #fff; }
.evo-chart-title { margin-bottom: 8px; }
.timeline { margin-top: 20px; position: relative; padding-left: 22px; }
.timeline::before { content: ""; position: absolute; left: 7px; top: 6px; bottom: 6px; width: 2px; background: var(--border); }
.evo-event { position: relative; padding: 10px 14px; margin-bottom: 10px; border: 1px solid var(--border); border-radius: var(--radius); background: #fff; cursor: pointer; transition: all 0.15s; }
.evo-event:hover { border-color: var(--primary); box-shadow: var(--shadow-pop); }
.evo-event::before {
content: ""; position: absolute; left: -21px; top: 16px;
width: 10px; height: 10px; border-radius: 50%; border: 2px solid #fff;
}
.evo-event.t-新增::before { background: var(--success); }
.evo-event.t-增强::before { background: var(--primary); }
.evo-event.t-减弱::before { background: var(--warning); }
.evo-event.t-退出::before { background: var(--danger); }
.evo-event .ee-head { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; }
.evo-event .ee-window { font-size: 12px; color: var(--text-3); font-variant-numeric: tabular-nums; }
.evo-event .ee-skill { font-weight: 600; }
.evo-event .ee-desc { font-size: 12px; color: var(--text-2); line-height: 1.6; }
.evo-event .ee-src { font-size: 12px; color: var(--text-3); margin-top: 4px; }
/* ============ 人岗匹配 ============ */
.steps { display: flex; justify-content: center; gap: 0; margin-bottom: 26px; }
.step { display: flex; align-items: center; gap: 8px; color: var(--text-3); font-size: 14px; position: relative; padding: 0 34px; }
.step i {
width: 26px; height: 26px; border-radius: 50%; font-style: normal;
border: 1px solid var(--border-dark); display: inline-flex; align-items: center; justify-content: center;
font-size: 13px; background: #fff;
}
.step.active { color: var(--primary); font-weight: 600; }
.step.active i { background: var(--primary); border-color: var(--primary); color: #fff; }
.step.done { color: var(--success); }
.step.done i { background: var(--success); border-color: var(--success); color: #fff; }
.step:not(:last-child)::after { content: ""; position: absolute; right: -4px; top: 13px; width: 40px; height: 1px; background: var(--border-dark); }
.match-pane { max-width: 960px; margin: 0 auto; }
#match-pane-3 { max-width: none; margin: 0; } /* 诊断结果页全宽:左简历原文 + 右结果 */
.parse-summary { background: var(--bg-hover); border-radius: var(--radius); padding: 12px 16px; font-size: 13px; color: var(--text-2); margin-bottom: 16px; line-height: 1.8; }
.chip-editor { display: flex; flex-wrap: wrap; gap: 8px; min-height: 40px; border: 1px dashed var(--border-dark); border-radius: var(--radius); padding: 10px; }
.skill-chip {
display: inline-flex; align-items: center; gap: 5px;
background: var(--primary-light); color: var(--primary); border: 1px solid #d6e4ff;
font-size: 13px; border-radius: var(--radius-sm); padding: 3px 6px 3px 10px;
}
.skill-chip button { border: none; background: none; color: inherit; font-size: 14px; line-height: 1; padding: 0 2px; }
.skill-chip button:hover { color: var(--danger); }
.chip-add { margin-top: 8px; }
.chip-add .input { width: 280px; }
.match-result-grid { display: grid; grid-template-columns: 340px 1fr; gap: 24px; }
.gauge-wrap { position: relative; width: 220px; margin: 0 auto; }
.gauge-text { position: absolute; left: 0; right: 0; top: 74px; text-align: center; }
.gauge-text b { display: block; font-size: 34px; font-variant-numeric: tabular-nums; color: var(--primary); }
.gauge-text span { font-size: 12px; color: var(--text-3); }
.match-verdict { text-align: center; margin: 6px 0 10px; font-size: 14px; }
.hit-miss { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.hm-title { display: flex; align-items: center; gap: 6px; font-size: 13px; font-weight: 600; margin-bottom: 8px; }
.hm-title svg { width: 15px; height: 15px; }
.hm-title.ok { color: var(--success); }
.hm-title.bad { color: var(--danger); }
.hm-title.info { color: var(--cyan); }
.hm-title.weak { color: var(--text-3); font-weight: 500; }
.hm-chips { display: flex; flex-wrap: wrap; gap: 6px; min-height: 32px; }
.hm-chips .tag { cursor: default; }
.transfer-block { margin-top: 18px; }
.rec-jobs { margin-top: 22px; border-top: 1px dashed var(--border); padding-top: 16px; }
.rec-job-cards { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin-top: 4px; }
.rec-job-card { border: 1px solid var(--border); border-radius: var(--radius); padding: 12px 14px; cursor: pointer; transition: all 0.15s; }
.rec-job-card:hover { border-color: var(--primary); box-shadow: var(--shadow-pop); }
.rec-job-card .rj-title { font-weight: 600; margin-bottom: 4px; }
.rec-job-card .rj-score { font-size: 22px; font-weight: 600; color: var(--primary); font-variant-numeric: tabular-nums; }
.rec-job-card .rj-miss { font-size: 12px; color: var(--text-3); margin-top: 4px; line-height: 1.6; }
/* 第 3 步:左简历原文高亮 + 右诊断结果 */
.match-step3-grid { display: grid; grid-template-columns: minmax(320px, 42%) 1fr; gap: 16px; align-items: start; }
@media (max-width: 1100px) { .match-step3-grid { grid-template-columns: 1fr; } }
.resume-viewer { border: 1px solid var(--border); border-radius: var(--radius); background: #fff; overflow: hidden; }
.rv-head {
display: flex; justify-content: space-between; align-items: center;
padding: 10px 14px; border-bottom: 1px solid var(--border); background: var(--bg-hover);
}
.rv-title { font-size: 13px; font-weight: 600; color: var(--text-1); }
.rv-legend { display: flex; gap: 6px; font-size: 11px; }
.rv-body {
padding: 14px 16px; max-height: 560px; overflow-y: auto;
font-size: 13px; line-height: 2.0; color: var(--text-1);
text-align: left; white-space: pre-wrap; word-break: break-word;
}
.hl { border-radius: 3px; padding: 1px 4px; font-weight: 500; white-space: nowrap; }
.hl-req { background: var(--success-light); color: var(--success); box-shadow: inset 0 0 0 1px #b7eb8f; }
.hl-pref { background: var(--primary-light); color: var(--primary); box-shadow: inset 0 0 0 1px #d6e4ff; }
.hl-other { background: var(--bg-page); color: var(--text-2); box-shadow: inset 0 0 0 1px var(--border); }
/* 结构化简历模板(docx 表格风) */
.rd-table {
width: 100%; border-collapse: collapse; table-layout: fixed;
font-size: 13px; background: #fff;
}
.rd-table td {
border: 1px solid var(--border-dark); padding: 8px 10px;
vertical-align: middle; line-height: 1.8; word-break: break-word;
}
.rd-photo-cell { width: 104px; text-align: center; background: var(--bg-hover); }
.rd-photo {
width: 84px; height: 112px; object-fit: cover;
border: 1px solid var(--border); border-radius: var(--radius-sm); background: #fff;
}
.rd-photo-empty {
display: inline-flex; flex-direction: column; align-items: center; justify-content: center;
gap: 4px; color: var(--text-3); font-size: 11px; width: 84px; height: 112px;
}
.rd-photo-empty svg { width: 30px; height: 30px; stroke: currentColor; fill: none; stroke-width: 1.5; }
.rd-k { width: 62px; background: var(--bg-hover); color: var(--text-2); font-weight: 500; }
.rd-v { color: var(--text-1); font-weight: 500; }
.rd-sec-k {
width: 104px; background: var(--primary-light); color: var(--primary);
font-weight: 600; text-align: center; vertical-align: top; padding-top: 12px;
}
.rd-sec-v { text-align: left; white-space: pre-wrap; }
.rv-legend .btn { margin-left: 6px; }
/* ============ 学习路径 ============ */
.path-layout { display: grid; grid-template-columns: 340px 1fr; gap: 16px; align-items: start; }
@media (max-width: 1100px) { .path-layout { grid-template-columns: 1fr; } }
.path-side { position: sticky; top: 16px; }
.form-item { margin-bottom: 16px; }
.form-label { display: flex; justify-content: space-between; align-items: baseline; font-size: 13px; font-weight: 500; color: var(--text-1); margin-bottom: 8px; }
.form-label-tip { font-size: 12px; font-weight: 400; color: var(--text-3); }
.w-full { width: 100%; }
.skill-pool { max-height: 300px; overflow-y: auto; border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 10px; background: var(--bg-hover); }
.pool-group + .pool-group { margin-top: 10px; }
.pool-group-title { font-size: 12px; color: var(--text-3); margin-bottom: 6px; }
.pool-chips { display: flex; flex-wrap: wrap; gap: 6px; }
.pool-chip {
cursor: pointer; user-select: none; font-size: 12px; padding: 3px 10px;
border: 1px solid var(--border-dark); border-radius: 999px; background: #fff; color: var(--text-2);
transition: all .15s;
}
.pool-chip:hover { border-color: var(--primary); color: var(--primary); }
.pool-chip.known { background: var(--success-light); border-color: var(--success); color: var(--success); }
.pool-chip.known::before { content: "✓ "; }
.pool-loading { font-size: 12px; color: var(--text-3); padding: 8px 2px; }
.seg { display: flex; border: 1px solid var(--border-dark); border-radius: var(--radius-sm); overflow: hidden; }
.seg-item { flex: 1; padding: 7px 0; border: none; background: #fff; color: var(--text-2); font-size: 13px; cursor: pointer; }
.seg-item + .seg-item { border-left: 1px solid var(--border); }
.seg-item.active { background: var(--primary); color: #fff; }
.path-summary { background: var(--bg-hover); border-radius: var(--radius-sm); padding: 12px; margin-bottom: 16px; }
.path-summary-row { display: flex; justify-content: space-between; font-size: 13px; color: var(--text-2); margin-bottom: 6px; }
.path-summary-row b { color: var(--text-1); font-variant-numeric: tabular-nums; }
.progress { height: 6px; border-radius: 3px; background: var(--border); overflow: hidden; margin: 8px 0 4px; }
.progress-bar { height: 100%; border-radius: 3px; background: var(--primary); transition: width .3s; }
.path-summary-tip { font-size: 12px; color: var(--text-3); }
.btn-block { width: 100%; display: inline-flex; align-items: center; justify-content: center; gap: 6px; }
.btn-block svg { width: 16px; height: 16px; stroke: currentColor; fill: none; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
.path-overview { display: flex; gap: 24px; flex-wrap: wrap; border: 1px solid var(--border); border-radius: var(--radius); background: var(--primary-light); padding: 14px 18px; margin-bottom: 16px; }
.po-item { font-size: 12px; color: var(--text-2); }
.po-item b { display: block; font-size: 20px; color: var(--text-1); font-variant-numeric: tabular-nums; margin-top: 2px; }
.po-item .po-primary { color: var(--primary); }
.po-progress { flex: 1; min-width: 160px; align-self: center; }
.po-progress .path-summary-tip { text-align: right; }
.path-flow { display: flex; flex-direction: column; gap: 0; }
.stage-card { position: relative; display: flex; gap: 14px; padding: 16px 16px 20px 0; }
.stage-card:not(:last-child)::before {
content: ""; position: absolute; left: 19px; top: 44px; bottom: -2px; width: 2px; background: var(--border);
}
.stage-card.done .stage-body { opacity: .55; }
.stage-no {
width: 40px; height: 40px; border-radius: 50%; flex: none; z-index: 1;
background: var(--primary); color: #fff; font-weight: 600;
display: inline-flex; align-items: center; justify-content: center; font-size: 16px;
}
.stage-card.done .stage-no { background: var(--success); }
.stage-body { flex: 1; border: 1px solid var(--border); border-radius: var(--radius); background: #fff; padding: 14px 16px; }
.stage-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
.stage-title { font-weight: 600; }
.stage-weeks { font-size: 12px; color: var(--primary); background: var(--primary-light); border-radius: 999px; padding: 2px 10px; }
.stage-prereq { font-size: 12px; color: var(--text-3); margin-bottom: 8px; }
.stage-skills { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 10px; }
.stage-skills .tag { cursor: pointer; }
.stage-skills .tag:hover { background: var(--primary); color: #fff; }
.stage-meta { display: flex; gap: 18px; font-size: 12px; color: var(--text-2); flex-wrap: wrap; }
.stage-meta b { color: var(--text-1); font-weight: 500; }
.stage-task { margin-top: 8px; font-size: 12px; background: var(--bg-hover); border-radius: var(--radius-sm); padding: 8px 10px; color: var(--text-2); }
.stage-acc { margin-top: 8px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--bg-hover); }
.stage-acc > summary {
cursor: pointer; padding: 8px 12px; font-size: 12px; color: var(--primary);
user-select: none; list-style: none;
}
.stage-acc > summary::before { content: "▸ "; }
.stage-acc[open] > summary::before { content: "▾ "; }
.stage-acc > summary:hover { color: var(--primary-hover); }
.acc-block { padding: 0 12px 12px; }
.acc-title { font-size: 12px; font-weight: 600; color: var(--text-1); margin: 8px 0 4px; }
.acc-deliverable { font-size: 12px; color: var(--text-2); background: #fff; border-left: 3px solid var(--primary); border-radius: 2px; padding: 6px 10px; line-height: 1.6; }
.acc-list { margin: 0; padding: 0; list-style: none; }
.acc-list li { display: flex; gap: 8px; font-size: 12px; color: var(--text-2); line-height: 1.6; padding: 3px 0; }
.acc-no {
flex: none; min-width: 18px; height: 18px; border-radius: 50%; margin-top: 1px;
background: var(--primary-light); color: var(--primary); font-size: 11px;
display: inline-flex; align-items: center; justify-content: center; padding: 0 4px;
}
.acc-list.quiz .acc-no { background: var(--warning-light); color: var(--warning); }
.stage-foot { margin-top: 10px; display: flex; justify-content: flex-end; }
.stage-check { font-size: 12px; }
.path-done { text-align: center; padding: 40px; color: var(--success); font-size: 15px; }
/* ============ 弹窗 / Toast ============ */
.modal-mask {
position: fixed; inset: 0; background: rgba(29, 33, 41, 0.45);
display: flex; align-items: center; justify-content: center; z-index: 500;
}
.modal {
width: 560px; max-width: calc(100vw - 48px); max-height: 82vh;
background: #fff; border-radius: var(--radius);
box-shadow: var(--shadow-pop); display: flex; flex-direction: column;
}
.modal-head {
display: flex; align-items: center; justify-content: space-between;
padding: 14px 18px; border-bottom: 1px solid var(--border);
}
.modal-head h3 { font-size: 15px; }
.modal-body { padding: 18px; overflow-y: auto; font-size: 13px; line-height: 1.7; }
.modal-foot { padding: 12px 18px; border-top: 1px solid var(--border); display: flex; justify-content: flex-end; gap: 8px; }
.jd-evidence { background: var(--bg-hover); border-radius: var(--radius); padding: 12px 14px; white-space: pre-wrap; max-height: 300px; overflow-y: auto; font-size: 12.5px; color: var(--text-2); }
.jd-meta { display: flex; gap: 14px; flex-wrap: wrap; font-size: 12px; color: var(--text-3); margin-bottom: 10px; }
.toast-wrap { position: fixed; top: 70px; left: 50%; transform: translateX(-50%); z-index: 600; display: flex; flex-direction: column; gap: 8px; }
.toast {
background: #fff; border: 1px solid var(--border); border-radius: var(--radius-sm);
box-shadow: var(--shadow-pop); padding: 9px 16px; font-size: 13px;
display: flex; align-items: center; gap: 8px; animation: toast-in 0.2s;
}
.toast.success { border-color: #b7eb8f; color: var(--success); }
.toast.error { border-color: #ffa39e; color: var(--danger); }
.toast.info { border-color: #d6e4ff; color: var(--primary); }
@keyframes toast-in { from { opacity: 0; transform: translateY(-8px); } }
/* 滚动条 */
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-thumb { background: #c9cdd4; border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: #a9aeb8; }
::-webkit-scrollbar-track { background: transparent; }
/* ============ 离线回退横幅 / 系统设置 ============ */
.offline-banner {
height: 40px; display: flex; align-items: center; gap: 10px;
padding: 0 20px; font-size: 13px;
background: var(--warning-light); color: #ad4e00;
border-bottom: 1px solid #ffd591;
}
.offline-banner svg { width: 16px; height: 16px; flex: none; stroke: currentColor; fill: none; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
.offline-banner b { font-weight: 600; }
.banner-btn {
margin-left: auto; flex: none; border: 1px solid #ad4e00; background: #fff;
color: #ad4e00; border-radius: var(--radius-sm); padding: 2px 12px; font-size: 12px; cursor: pointer;
}
.banner-btn:hover { background: #ad4e00; color: #fff; }
.banner-close { flex: none; border: none; background: transparent; font-size: 16px; color: #ad4e00; cursor: pointer; padding: 0 4px; }
.has-banner .shell { height: calc(100vh - var(--topbar-h) - var(--topnav-h) - 40px); }
.cfg-status { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; font-size: 13px; color: var(--text-2); margin-bottom: 14px; }
.cfg-warn { background: var(--warning-light); color: #ad4e00; border-radius: var(--radius-sm); padding: 8px 12px; font-size: 13px; margin-bottom: 14px; }
.cfg-sec { border: 1px solid var(--border); border-radius: var(--radius); padding: 14px 16px 4px; margin-bottom: 14px; }
.cfg-sec-title { font-size: 13px; font-weight: 600; color: var(--text-1); margin-bottom: 12px; }
.cfg-token-row { display: flex; gap: 8px; }
.cfg-token-row .input { flex: 1; }
/* 双横幅堆叠与权重引导 */
.has-banner2 .shell { height: calc(100vh - var(--topbar-h) - var(--topnav-h) - 80px); }
#embed-banner { border-top: 1px solid #ffd591; }
.cfg-guide {
background: var(--bg-hover); border-radius: var(--radius-sm);
padding: 10px 14px; font-size: 12px; color: var(--text-2); margin-bottom: 14px;
}
.cfg-guide b { color: var(--text-1); }
.cfg-guide ol { margin: 6px 0 0; padding-left: 18px; }
.cfg-guide li { line-height: 1.9; }
.cfg-guide code {
background: #fff; border: 1px solid var(--border); border-radius: 3px;
padding: 0 5px; font-family: Consolas, monospace; font-size: 11px; color: var(--primary);
}
/* 设置弹窗加宽 */
.modal.wide { width: 780px; }
/* 卡片内图表画布默认占满宽度(防 300px 默认宽挤压) */
.card-body > canvas { display: block; width: 100%; height: 100%; min-height: 200px; }
.card-body:has(> canvas:only-child) { min-height: 260px; }
/* 岗位演化页:统计条 / 时点对比 / 限高 */
.evo-stats { display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 16px; }
.evo-stat {
flex: 1; min-width: 120px; background: var(--bg-hover); border-radius: var(--radius);
padding: 10px 14px; display: flex; flex-direction: column; gap: 2px;
}
.evo-stat span { font-size: 12px; color: var(--text-3); }
.evo-stat b { font-size: 20px; color: var(--text-1); font-variant-numeric: tabular-nums; }
.evo-compare { border: 1px solid var(--border); border-radius: var(--radius); padding: 12px 14px; margin-bottom: 16px; }
.evo-compare-head { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 8px; margin-bottom: 10px; }
.evo-dates { display: flex; align-items: center; gap: 8px; }
.evo-dates .input { width: 150px; }
.diff-meta { font-size: 12px; color: var(--text-3); margin-bottom: 10px; }
.diff-grid { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 12px; }
@media (max-width: 1100px) { .diff-grid { grid-template-columns: 1fr; } }
.diff-col { background: var(--bg-hover); border-radius: var(--radius-sm); padding: 10px 12px; }
.diff-title { font-size: 12px; font-weight: 600; color: var(--text-1); margin-bottom: 8px; }
.diff-chips { display: flex; flex-wrap: wrap; gap: 6px; }
.diff-chip { cursor: pointer; }
.diff-chip:hover { filter: brightness(0.92); }
.diff-chip i { font-style: normal; margin-left: 3px; color: var(--primary); font-weight: 700; }
.evo-heat-wrap { max-height: 260px; max-width: 920px; margin: 0 auto 16px; }
.evo-heat-wrap canvas { max-height: 260px; }
.timeline { max-height: 420px; overflow-y: auto; }
/* 批量匹配 */
.match-tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--border); margin-bottom: 18px; }
.match-tab {
border: none; background: transparent; padding: 8px 18px; font-size: 14px;
color: var(--text-2); cursor: pointer; position: relative;
}
.match-tab:hover { color: var(--primary); }
.match-tab.active { color: var(--primary); font-weight: 600; }
.match-tab.active::after {
content: ""; position: absolute; left: 12px; right: 12px; bottom: -1px;
height: 3px; border-radius: 2px 2px 0 0; background: var(--primary);
}
.dropzone {
border: 2px dashed var(--border-dark); border-radius: var(--radius);
padding: 28px 20px; text-align: center; color: var(--text-2);
transition: all .15s; margin-bottom: 12px;
}
.dropzone.over { border-color: var(--primary); background: var(--primary-light); }
.dropzone svg { width: 34px; height: 34px; stroke: var(--text-3); fill: none; stroke-width: 1.5; margin-bottom: 8px; }
.dropzone.over svg { stroke: var(--primary); }
.dropzone-btns { display: flex; gap: 10px; justify-content: center; margin: 10px 0; }
.batch-filelist { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 12px; }
.batch-file {
display: inline-flex; align-items: center; gap: 6px; font-size: 12px;
border: 1px solid var(--border); border-radius: 999px; padding: 3px 6px 3px 12px; background: #fff;
}
.batch-file i { color: var(--text-3); font-style: normal; }
.batch-file button {
border: none; background: var(--bg-page); border-radius: 50%; width: 18px; height: 18px;
cursor: pointer; color: var(--text-3); line-height: 1;
}
.batch-file button:hover { background: var(--danger-light); color: var(--danger); }
.batch-summary { font-size: 13px; color: var(--text-2); margin: 8px 0 10px; }
.batch-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.batch-table th {
text-align: left; padding: 8px 10px; background: var(--bg-hover);
color: var(--text-2); font-weight: 500; border-bottom: 1px solid var(--border);
}
.batch-table td { padding: 8px 10px; border-bottom: 1px solid var(--border); vertical-align: middle; }
.batch-table tr.batch-err td { color: var(--text-3); background: var(--bg-hover); }
.batch-score { display: flex; align-items: center; gap: 8px; min-width: 140px; }
.batch-score-bar { height: 6px; border-radius: 3px; background: var(--primary); max-width: 90px; }
.batch-score b { font-variant-numeric: tabular-nums; }
.text-ok { color: var(--success); }
.text-bad { color: var(--danger); }
/* 统一系统弹窗(sysDialog) */
.sysdlg-mask {
position: fixed; inset: 0; background: rgba(29, 33, 41, 0.45);
display: flex; align-items: center; justify-content: center; z-index: 700;
}
.sysdlg {
width: 420px; max-width: calc(100vw - 48px);
background: #fff; border-radius: var(--radius); box-shadow: var(--shadow-pop);
padding: 18px 20px;
}
.sysdlg-title { font-size: 15px; font-weight: 600; color: var(--text-1); margin-bottom: 12px; }
.sysdlg-body { margin-bottom: 16px; }
.sysdlg-label { font-size: 13px; color: var(--text-2); margin-bottom: 8px; }
.sysdlg-text { font-size: 13px; color: var(--text-1); line-height: 1.7; }
.sysdlg-input { width: 100%; }
.sysdlg-foot { display: flex; justify-content: flex-end; gap: 10px; }
/* 首次引导欢迎块 */
.cfg-welcome {
background: var(--primary-light); border-left: 3px solid var(--primary);
border-radius: var(--radius-sm); padding: 10px 14px; font-size: 13px;
color: var(--text-1); line-height: 1.9; margin-bottom: 14px;
}
/* 全部岗位演化热力图 */
.evo-heatmap { margin-bottom: 16px; overflow-x: auto; }
.hm-grid { display: grid; gap: 2px; min-width: 720px; align-items: center; }
.hm-corner { height: 18px; }
.hm-col-label { font-size: 10px; color: var(--text-3); text-align: center; height: 18px; line-height: 18px; white-space: nowrap; }
.hm-row-label {
font-size: 12px; color: var(--text-2); white-space: nowrap; overflow: hidden;
text-overflow: ellipsis; cursor: pointer; padding: 2px 6px 2px 0; text-align: right;
}
.hm-row-label:hover { color: var(--primary); font-weight: 600; }
.hm-row-label.active { color: var(--primary); font-weight: 600; }
.hm-row-label.active::before { content: "▸ "; }
.hm-cell { height: 16px; border-radius: 2px; }
.hm-cell:hover { outline: 1.5px solid var(--primary); }
.hm-legend { display: flex; align-items: center; gap: 6px; font-size: 11px; color: var(--text-3); margin-top: 6px; }
.hm-legend .hm-l { width: 14px; height: 10px; border-radius: 2px; display: inline-block; }
.hm-legend .text-weak { margin-left: 10px; }
/* 热度走势图限宽居中(标题对齐) */
.evo-chart-title:has(+ .evo-heat-wrap) { max-width: 920px; margin-left: auto; margin-right: auto; }
/* 一键配置进度 */
.setup-progress { margin-top: 10px; border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 10px 12px; background: var(--bg-hover); }
.setup-stage { font-size: 13px; font-weight: 600; color: var(--primary); margin-bottom: 6px; }
.setup-log { font-size: 11px; color: var(--text-3); white-space: pre-wrap; font-family: Consolas, monospace; }
#cfg-embed-setup:disabled { opacity: .6; cursor: not-allowed; }
/* 一键配置主按钮区 */
.setup-hero {
border: 1px solid #d6e4ff; background: var(--primary-light);
border-radius: var(--radius); padding: 14px 16px; margin-bottom: 14px;
}
.setup-hero .btn { width: 100%; }
.setup-hero-desc { font-size: 12px; color: var(--text-2); line-height: 1.9; margin-top: 10px; }
.setup-hero-desc b { color: var(--primary); }
.setup-hero .setup-progress { margin-top: 10px; background: #fff; }
/* 变更类型堆叠柱状图例 */
.stack-legend { display: flex; gap: 14px; justify-content: center; font-size: 11px; color: var(--text-2); margin: 4px 0 14px; }
.stack-legend i { width: 10px; height: 10px; border-radius: 2px; display: inline-block; margin-right: 4px; }
/* 技能生命周期甘特图 */
.evo-gantt { margin-bottom: 16px; overflow-x: auto; }
.gt-grid { display: grid; gap: 2px; min-width: 760px; align-items: center; }
.gt-label {
font-size: 12px; color: var(--text-2); white-space: nowrap; overflow: hidden;
text-overflow: ellipsis; cursor: pointer; text-align: right; padding-right: 6px;
}
.gt-label:hover { color: var(--primary); font-weight: 600; }
.gt-cell { position: relative; height: 16px; display: flex; align-items: center; }
.gt-bar { position: absolute; left: 0; right: 0; height: 10px; border-radius: 2px; opacity: .75; }
.gt-dot {
position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%);
width: 8px; height: 8px; border-radius: 50%; border: 1px solid #fff; z-index: 1;
}
/* 页脚联系信息(文档流,位于所有内容之后) */
.app-footer {
margin-top: 24px; padding: 10px 0 4px; text-align: center;
font-size: 12px; color: var(--text-3);
border-top: 1px solid var(--border);
user-select: text;
}