CareerNexus 职联:岗位能力图谱构建与动态演化分析平台(挑战杯 XH-202621)
- 全链路:多源JD接入/清洗交叉验证/结构化抽取(星火+离线双通道)/时序知识图谱/新岗位发现/演化分析/人岗匹配/学习路径 - WebUI 五视图:驾驶舱/图谱工作台/岗位演化/匹配(单人+批量)/学习路径 - 设置中心(config.json)/Token鉴权/SQLite状态库/语义向量通道(BGE-M3本地或联网API) - 71 个 pytest 用例;数据目录仅保留结构(.gitkeep),数据由 scripts/generate_dataset.py 复现
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
# 默认构建保持轻量:本地权重/数据/缓存/运行时密钥不进镜像
|
||||
models/
|
||||
data/tmp/
|
||||
logs/
|
||||
__pycache__/
|
||||
**/__pycache__/
|
||||
.venv/
|
||||
*.pyc
|
||||
config.json
|
||||
@@ -0,0 +1,3 @@
|
||||
# 大模型抽取通道(星火/OpenAI 兼容 API)密钥示例——仅示例,请勿提交真实密钥
|
||||
# 配置后并把 config.yaml 的 mode 改为 live,即可启用大模型抽取;未配置时自动降级离线抽取
|
||||
SPARK_API_KEY=your_spark_api_key_here
|
||||
+18
@@ -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
@@ -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"]
|
||||
@@ -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`,均为纯前端持久化。
|
||||
@@ -0,0 +1,3 @@
|
||||
"""CareerNexus 职联 —— 多源异构数据驱动的岗位与能力图谱平台。"""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,131 @@
|
||||
"""抽取器基础:Extractor 协议、技能词表加载与匹配、字段级多源投票。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Protocol
|
||||
|
||||
from app.config import ROOT
|
||||
from app.models import ExtractionResult, JDRecord
|
||||
|
||||
|
||||
class Extractor(Protocol):
|
||||
"""抽取器协议:任何实现(离线/大模型)都必须遵守同一输出 Schema。"""
|
||||
|
||||
name: str
|
||||
|
||||
def extract(self, record: JDRecord) -> ExtractionResult:
|
||||
...
|
||||
|
||||
|
||||
def load_lexicon(path: str | Path | None = None) -> dict:
|
||||
"""加载技能词表 data/skills_lexicon.json。
|
||||
|
||||
结构:{"skills": [{name, category, aliases, level, prereqs}], "scenarios": [...]}
|
||||
"""
|
||||
p = Path(path) if path else ROOT / "data" / "skills_lexicon.json"
|
||||
return json.loads(p.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
class SkillMatcher:
|
||||
"""基于词表(含别名)的技能归一化匹配器。
|
||||
|
||||
- 含 ASCII 的词使用“非字母数字边界”匹配,避免 Java 误中 JavaScript、C 误中 C++;
|
||||
- 纯中文词直接子串匹配;
|
||||
- 重叠命中按“起点靠前、长度最长”优先,保证 Spring Boot 优先于 Spring。
|
||||
"""
|
||||
|
||||
def __init__(self, lexicon: dict):
|
||||
self.lexicon = lexicon
|
||||
self.alias2name: dict[str, str] = {}
|
||||
for s in lexicon["skills"]:
|
||||
self.alias2name[s["name"]] = s["name"]
|
||||
for a in s.get("aliases", []):
|
||||
self.alias2name[a] = s["name"]
|
||||
# 长词优先编译,Java 与 JavaScript 这类前缀词靠边界 + 长度双重保险
|
||||
self._patterns: list[tuple[str, re.Pattern]] = []
|
||||
for alias in sorted(self.alias2name, key=len, reverse=True):
|
||||
esc = re.escape(alias)
|
||||
if re.search(r"[A-Za-z0-9+#.]", alias):
|
||||
pat = re.compile(rf"(?<![A-Za-z0-9+#.]){esc}(?![A-Za-z0-9+#.])", re.IGNORECASE)
|
||||
else:
|
||||
pat = re.compile(esc)
|
||||
self._patterns.append((alias, pat))
|
||||
|
||||
def normalize(self, name: str) -> str:
|
||||
"""把用户输入的技能名归一到词表标准名,三级容错:
|
||||
|
||||
1. 精确/别名匹配(大小写不敏感):redis→Redis、K8s→Kubernetes;
|
||||
2. 包含匹配(语义近似兜底):输入包含某词表技能时取其最长者,
|
||||
如 微服务架构→微服务、Java语言→Java、SpringBoot项目→Spring Boot;
|
||||
3. 均未命中则原样返回(交由上层标记为"词表外技能")。
|
||||
"""
|
||||
if not hasattr(self, "_alias_lower"):
|
||||
self._alias_lower = {k.lower(): v for k, v in self.alias2name.items()}
|
||||
key = name.strip()
|
||||
hit = self._alias_lower.get(key.lower())
|
||||
if hit:
|
||||
return hit
|
||||
hits = self.find(key)
|
||||
if hits:
|
||||
# 取覆盖最长(最具体)的命中,避免 JavaScript 被 Java 截胡
|
||||
best = max(hits, key=lambda h: h[1] - h[0])
|
||||
return best[2]
|
||||
return key
|
||||
|
||||
def find(self, text: str) -> list[tuple[int, int, str]]:
|
||||
"""返回 [(start, end, canonical_name)],重叠命中已消解。"""
|
||||
hits: list[tuple[int, int, str]] = []
|
||||
for alias, pat in self._patterns:
|
||||
canonical = self.alias2name[alias]
|
||||
for m in pat.finditer(text):
|
||||
hits.append((m.start(), m.end(), canonical))
|
||||
hits.sort(key=lambda h: (h[0], -(h[1] - h[0])))
|
||||
out: list[tuple[int, int, str]] = []
|
||||
last_end = -1
|
||||
for start, end, name in hits:
|
||||
if start >= last_end:
|
||||
out.append((start, end, name))
|
||||
last_end = end
|
||||
return out
|
||||
|
||||
def find_names(self, text: str) -> list[str]:
|
||||
"""只返回归一化后的技能名(保序去重)。"""
|
||||
seen: list[str] = []
|
||||
for _, _, name in self.find(text):
|
||||
if name not in seen:
|
||||
seen.append(name)
|
||||
return seen
|
||||
|
||||
def find_scenarios(self, text: str) -> list[str]:
|
||||
"""应用场景词表匹配(纯子串,保序去重)。"""
|
||||
out: list[str] = []
|
||||
for sc in self.lexicon.get("scenarios", []):
|
||||
if sc in text and sc not in out:
|
||||
out.append(sc)
|
||||
return out
|
||||
|
||||
|
||||
def vote_field(
|
||||
candidates: list[tuple[list[str], float]],
|
||||
min_agreement: float = 0.5,
|
||||
) -> tuple[list[str], float]:
|
||||
"""字段级多源投票:关键字段多源一致才给高置信。
|
||||
|
||||
candidates: [(某来源抽到的技能列表, 该来源权重)],例如离线规则与大模型双通道。
|
||||
返回 (通过投票的技能列表, 置信度)。技能按加权支持率 >= min_agreement 入选。
|
||||
"""
|
||||
if not candidates:
|
||||
return [], 0.0
|
||||
total = sum(w for _, w in candidates) or 1.0
|
||||
support: dict[str, float] = {}
|
||||
order: list[str] = []
|
||||
for skills, w in candidates:
|
||||
for s in dict.fromkeys(skills):
|
||||
support[s] = support.get(s, 0.0) + w
|
||||
if s not in order:
|
||||
order.append(s)
|
||||
chosen = [s for s in order if support[s] / total >= min_agreement]
|
||||
confidence = max((support[s] / total for s in chosen), default=0.0)
|
||||
return chosen, round(confidence, 4)
|
||||
@@ -0,0 +1,124 @@
|
||||
"""星火 / OpenAI 兼容 API 抽取器:JSON 模式、Schema 校验、失败重试 2 次、超时降级。
|
||||
|
||||
- 密钥只从环境变量读取(默认 SPARK_API_KEY),绝不写入代码或配置;
|
||||
- 无密钥时 get_extractor() 显式降级到离线抽取器;
|
||||
- 返回结果先过 ExtractionResult(pydantic)强校验,不合法即重试;
|
||||
- 重试耗尽或超时 → 降级到离线通道,保证链路永不断。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
|
||||
import httpx
|
||||
|
||||
from app.extract.base import Extractor, load_lexicon
|
||||
from app.extract.offline import OfflineExtractor
|
||||
from app.extract.prompts import PROMPT_VERSION, SYSTEM_PROMPT, build_prompt
|
||||
from app.models import ExtractionResult, JDRecord
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MAX_RETRIES = 2 # 失败重试 2 次
|
||||
|
||||
|
||||
class LLMExtractor:
|
||||
"""大模型抽取器(需配置密钥才启用)。"""
|
||||
|
||||
name = "llm"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_key: str,
|
||||
base_url: str,
|
||||
model: str,
|
||||
timeout: float = 20.0,
|
||||
fallback: Extractor | None = None,
|
||||
):
|
||||
self.api_key = api_key
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.model = model
|
||||
self.timeout = timeout
|
||||
self.fallback = fallback or OfflineExtractor(load_lexicon())
|
||||
|
||||
def _call_api(self, jd_text: str) -> str:
|
||||
"""调用 OpenAI 兼容 chat/completions,返回模型输出文本。"""
|
||||
resp = httpx.post(
|
||||
f"{self.base_url}/chat/completions",
|
||||
headers={"Authorization": f"Bearer {self.api_key}"},
|
||||
json={
|
||||
"model": self.model,
|
||||
"messages": [
|
||||
{"role": "system", "content": SYSTEM_PROMPT},
|
||||
{"role": "user", "content": build_prompt(jd_text)},
|
||||
],
|
||||
"response_format": {"type": "json_object"},
|
||||
"temperature": 0.1,
|
||||
},
|
||||
timeout=self.timeout,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()["choices"][0]["message"]["content"]
|
||||
|
||||
@staticmethod
|
||||
def _parse(content: str, record: JDRecord) -> ExtractionResult:
|
||||
"""Schema 强校验:坏 JSON / 缺字段都会抛异常,由上层重试。
|
||||
|
||||
先剥离模型常见的 ```json 代码块包裹再解析。
|
||||
"""
|
||||
text = content.strip()
|
||||
if text.startswith("```"):
|
||||
text = text.split("\n", 1)[-1] if "\n" in text else text.strip("`")
|
||||
if text.rstrip().endswith("```"):
|
||||
text = text.rstrip()[:-3]
|
||||
data = json.loads(text.strip())
|
||||
result = ExtractionResult(**data)
|
||||
result.jid = record.jid
|
||||
result.extractor = "llm"
|
||||
result.prompt_version = PROMPT_VERSION
|
||||
result.confidence = 0.9
|
||||
return result
|
||||
|
||||
def extract(self, record: JDRecord) -> ExtractionResult:
|
||||
last_err: Exception | None = None
|
||||
for attempt in range(MAX_RETRIES + 1):
|
||||
try:
|
||||
return self._parse(self._call_api(record.text), record)
|
||||
except Exception as exc: # noqa: BLE001 - 网络/解析/校验任何失败都要可重试可降级
|
||||
last_err = exc
|
||||
logger.warning("LLM 抽取第 %d 次失败(%s),%s",
|
||||
attempt + 1, type(exc).__name__,
|
||||
"重试" if attempt < MAX_RETRIES else "重试耗尽")
|
||||
logger.warning("LLM 不可用(%s),降级离线抽取 jid=%s", last_err, record.jid)
|
||||
return self.fallback.extract(record)
|
||||
|
||||
|
||||
def get_extractor(config: dict) -> Extractor:
|
||||
"""按配置选择抽取通道:配置了密钥(config.json 或环境变量)→ LLM,否则显式降级离线。
|
||||
|
||||
密钥优先级:config.json 的 llm_api_key > 环境变量(默认 SPARK_API_KEY)。
|
||||
LLM 通道失败时自动降级离线抽取(见 LLMExtractor.extract),链路永不断。
|
||||
"""
|
||||
key_env = str(config.get("llm_api_key_env", "SPARK_API_KEY"))
|
||||
api_key = (str(config.get("llm_api_key") or "").strip()
|
||||
or os.environ.get(key_env, "").strip())
|
||||
offline = OfflineExtractor(load_lexicon(config.get("lexicon_path") or None))
|
||||
if api_key:
|
||||
logger.info("检测到 LLM 密钥,启用大模型抽取通道(模型 %s)", config.get("llm_model"))
|
||||
return LLMExtractor(
|
||||
api_key=api_key,
|
||||
base_url=str(config.get("llm_base_url")),
|
||||
model=str(config.get("llm_model")),
|
||||
timeout=float(config.get("llm_timeout", 20)),
|
||||
fallback=offline,
|
||||
)
|
||||
logger.info("未配置 LLM 密钥,显式降级到离线确定性抽取器")
|
||||
return offline
|
||||
|
||||
|
||||
def llm_ready(config: dict) -> bool:
|
||||
"""是否已配置 LLM 密钥(config.json 或环境变量)。"""
|
||||
key_env = str(config.get("llm_api_key_env", "SPARK_API_KEY"))
|
||||
return bool(str(config.get("llm_api_key") or "").strip()
|
||||
or os.environ.get(key_env, "").strip())
|
||||
@@ -0,0 +1,128 @@
|
||||
"""离线确定性抽取器:技能词表 + 版面规则,从 JD 文本抽出结构化岗位定义。
|
||||
|
||||
版面约定(数据集与真实主流招聘平台格式兼容):
|
||||
- “岗位名称:X”行给出岗位名称;
|
||||
- 职责段(岗位职责/工作职责/工作内容/职位职责)逐条编号;
|
||||
- 要求段(任职要求/岗位要求/任职条件/任职资格)出现必备技能;
|
||||
- 加分段(加分项/优先考虑/优先条件)出现加分技能;
|
||||
- 尾部段落(团队与福利/福利待遇/岗位福利/公司福利)不属于任何技能区,
|
||||
作为加分段的结束边界,避免福利文案中的技能词混入加分技能;
|
||||
- 应用场景词表全文匹配。
|
||||
每个字段都附原文证据片段,供人机协同审核防“幻觉”。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
|
||||
from app.extract.base import SkillMatcher, load_lexicon
|
||||
from app.models import ExtractionResult, JDRecord
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 版面标题词表(生成器与抽取器共用,保证规则与数据一致)
|
||||
DUTY_HEADERS = ["岗位职责", "工作职责", "工作内容", "职位职责"]
|
||||
REQ_HEADERS = ["任职要求", "岗位要求", "任职条件", "任职资格"]
|
||||
PREF_HEADERS = ["加分项", "优先考虑", "优先条件"]
|
||||
# 尾部段落标题:出现在加分段之后的团队/福利介绍,作为版面区域结束边界
|
||||
TAIL_HEADERS = ["团队与福利", "福利待遇", "岗位福利", "公司福利"]
|
||||
|
||||
_TITLE_RE = re.compile(r"岗位名称[::]\s*([^\n]+)")
|
||||
_NUMBER_RE = re.compile(r"^\s*(?:\d+\s*[、..))]|[-•·])\s*")
|
||||
|
||||
|
||||
def _find_header(text: str, headers: list[str]) -> tuple[int, int] | None:
|
||||
"""返回第一处标题的 (起, 止),找不到返回 None。"""
|
||||
best: tuple[int, int] | None = None
|
||||
for h in headers:
|
||||
i = text.find(h)
|
||||
if i >= 0 and (best is None or i < best[0]):
|
||||
best = (i, i + len(h))
|
||||
return best
|
||||
|
||||
|
||||
class OfflineExtractor:
|
||||
"""离线确定性抽取器(默认通道,无需任何外部依赖)。"""
|
||||
|
||||
name = "offline"
|
||||
|
||||
def __init__(self, lexicon: dict | None = None):
|
||||
self.lexicon = lexicon or load_lexicon()
|
||||
self.matcher = SkillMatcher(self.lexicon)
|
||||
|
||||
def _zones(self, text: str) -> dict[str, str]:
|
||||
"""把文本切成 职责/要求/加分 三个版面区域(尾部福利段落不计入)。"""
|
||||
duty = _find_header(text, DUTY_HEADERS)
|
||||
req = _find_header(text, REQ_HEADERS)
|
||||
pref = _find_header(text, PREF_HEADERS)
|
||||
tail = _find_header(text, TAIL_HEADERS)
|
||||
|
||||
def zone(start_h, *end_hs):
|
||||
if start_h is None:
|
||||
return ""
|
||||
s = start_h[1]
|
||||
ends = [h[0] for h in end_hs if h is not None and h[0] >= s]
|
||||
e = min(ends) if ends else len(text)
|
||||
return text[s:e]
|
||||
|
||||
return {
|
||||
"duty": zone(duty, req, pref, tail),
|
||||
"req": zone(req, pref, tail),
|
||||
"pref": zone(pref, tail),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _parse_duties(duty_zone: str) -> list[str]:
|
||||
"""逐行解析编号职责,去掉编号前缀。"""
|
||||
out: list[str] = []
|
||||
for line in duty_zone.splitlines():
|
||||
line = _NUMBER_RE.sub("", line).strip().strip("::")
|
||||
if line:
|
||||
out.append(line)
|
||||
return out
|
||||
|
||||
def extract(self, record: JDRecord) -> ExtractionResult:
|
||||
text = record.text
|
||||
zones = self._zones(text)
|
||||
|
||||
m = _TITLE_RE.search(text)
|
||||
title = m.group(1).strip() if m else record.job_title
|
||||
|
||||
duties = self._parse_duties(zones["duty"])
|
||||
if zones["req"]:
|
||||
required = self.matcher.find_names(zones["req"])
|
||||
else:
|
||||
# 无版面结构的真实 JD(如 AutoWeakS 语料)没有"任职要求"段,
|
||||
# 兜底为全文扫描,避免技能字段整体漏抽
|
||||
required = self.matcher.find_names(text)
|
||||
preferred = self.matcher.find_names(zones["pref"]) if zones["pref"] else []
|
||||
# 加分技能不应重复出现在必备里(极少数版面混排的兜底)
|
||||
preferred = [s for s in preferred if s not in required]
|
||||
scenarios = self.matcher.find_scenarios(text)
|
||||
|
||||
def snippet(zone_text: str, limit: int = 80) -> str:
|
||||
return " ".join(zone_text.split())[:limit]
|
||||
|
||||
evidence = {
|
||||
"job_title": m.group(0) if m else "",
|
||||
"responsibilities": snippet(zones["duty"]),
|
||||
"required_skills": snippet(zones["req"]),
|
||||
"preferred_skills": snippet(zones["pref"]),
|
||||
"scenarios": "、".join(scenarios),
|
||||
}
|
||||
# 置信度:五个字段命中比例(离线规则全命中即 1.0)
|
||||
hits = sum([bool(title), bool(duties), bool(required), True, bool(scenarios)])
|
||||
confidence = round(hits / 5.0, 4)
|
||||
|
||||
return ExtractionResult(
|
||||
jid=record.jid,
|
||||
job_title=title,
|
||||
responsibilities=duties,
|
||||
required_skills=required,
|
||||
preferred_skills=preferred,
|
||||
scenarios=scenarios,
|
||||
evidence=evidence,
|
||||
confidence=confidence,
|
||||
extractor=self.name,
|
||||
prompt_version="offline-v1",
|
||||
)
|
||||
@@ -0,0 +1,43 @@
|
||||
"""大模型抽取的固化提示词模板与输出 Schema。
|
||||
|
||||
prompt_version 是抽取结果血缘的一部分:提示词变更必须升版本号,
|
||||
评测与图谱中都能据此追溯。
|
||||
"""
|
||||
|
||||
PROMPT_VERSION = "spark-jd-v1.1"
|
||||
|
||||
SYSTEM_PROMPT = (
|
||||
"你是招聘信息结构化专家。请从招聘 JD 文本中抽取岗位定义,"
|
||||
"只输出符合给定 JSON Schema 的 JSON,不要输出任何多余文字。"
|
||||
"技能名必须归一化到常见写法(如 LLM应用开发 归一化为 大模型应用开发),"
|
||||
"每个字段都要给出原文证据片段,不得编造原文没有的内容。"
|
||||
)
|
||||
|
||||
# 输出 Schema(JSON Schema 强校验的目标结构,与 ExtractionResult 对齐)
|
||||
OUTPUT_SCHEMA: dict = {
|
||||
"type": "object",
|
||||
"required": ["job_title", "responsibilities", "required_skills", "preferred_skills", "scenarios"],
|
||||
"properties": {
|
||||
"job_title": {"type": "string", "description": "岗位标准名称"},
|
||||
"responsibilities": {"type": "array", "items": {"type": "string"}, "description": "核心职责"},
|
||||
"required_skills": {"type": "array", "items": {"type": "string"}, "description": "必备技能"},
|
||||
"preferred_skills": {"type": "array", "items": {"type": "string"}, "description": "加分技能"},
|
||||
"scenarios": {"type": "array", "items": {"type": "string"}, "description": "应用场景"},
|
||||
"evidence": {"type": "object", "description": "字段名 -> 原文证据片段"},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def build_prompt(jd_text: str) -> str:
|
||||
"""拼接待抽取 JD 的用户提示词(含显式 Schema,字段名必须为英文键)。"""
|
||||
return (
|
||||
"请从以下招聘 JD 文本中抽取结构化岗位定义。\n"
|
||||
"要求:\n"
|
||||
"1. 只输出一个 JSON 对象,不要 markdown 代码块,不要任何解释文字;\n"
|
||||
"2. 字段名必须使用以下英文键:job_title(岗位标准名称)、responsibilities(核心职责,字符串数组)、"
|
||||
"required_skills(必备技能,字符串数组)、preferred_skills(加分/优先技能,字符串数组)、"
|
||||
"scenarios(应用场景,字符串数组)、evidence(对象,键为字段名,值为原文证据片段);\n"
|
||||
"3. 技能名归一化到常见写法(如 LLM应用开发→大模型应用开发);\n"
|
||||
"4. 不得编造原文没有的内容。\n\n"
|
||||
"JD 文本:\n" + jd_text
|
||||
)
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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))
|
||||
@@ -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()
|
||||
@@ -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
@@ -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 = ""
|
||||
@@ -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
|
||||
@@ -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))
|
||||
@@ -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
@@ -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
|
||||
@@ -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:
|
||||
@@ -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 分钟演示视频(按方案第十章演示脚本录制)。
|
||||
|
||||
*(补充完)*
|
||||
@@ -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
|
||||
@@ -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 "=========================================="
|
||||
@@ -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()
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 == "一次性技能"]
|
||||
@@ -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 == []
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+887
@@ -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);
|
||||
},
|
||||
};
|
||||
})();
|
||||
@@ -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="高级">高级(>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
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user