From a10442d78c7aed09bd55fa90a4451e18ea3ba32e Mon Sep 17 00:00:00 2001 From: RoyirinRosmontis Date: Mon, 3 Aug 2026 13:15:35 +0800 Subject: [PATCH] =?UTF-8?q?CareerNexus=20=E8=81=8C=E8=81=94=EF=BC=9A?= =?UTF-8?q?=E5=B2=97=E4=BD=8D=E8=83=BD=E5=8A=9B=E5=9B=BE=E8=B0=B1=E6=9E=84?= =?UTF-8?q?=E5=BB=BA=E4=B8=8E=E5=8A=A8=E6=80=81=E6=BC=94=E5=8C=96=E5=88=86?= =?UTF-8?q?=E6=9E=90=E5=B9=B3=E5=8F=B0=EF=BC=88=E6=8C=91=E6=88=98=E6=9D=AF?= =?UTF-8?q?=20XH-202621=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 全链路:多源JD接入/清洗交叉验证/结构化抽取(星火+离线双通道)/时序知识图谱/新岗位发现/演化分析/人岗匹配/学习路径 - WebUI 五视图:驾驶舱/图谱工作台/岗位演化/匹配(单人+批量)/学习路径 - 设置中心(config.json)/Token鉴权/SQLite状态库/语义向量通道(BGE-M3本地或联网API) - 71 个 pytest 用例;数据目录仅保留结构(.gitkeep),数据由 scripts/generate_dataset.py 复现 --- .dockerignore | 9 + .env.example | 3 + .gitignore | 18 + Dockerfile | 33 + README.md | 212 +++ app/__init__.py | 3 + app/api/__init__.py | 0 app/api/routes.py | 812 ++++++++++ app/bootstrap.py | 62 + app/cleaning/__init__.py | 0 app/cleaning/decay.py | 33 + app/cleaning/dedup.py | 119 ++ app/cleaning/noise.py | 47 + app/config.py | 110 ++ app/discover/__init__.py | 0 app/discover/cluster.py | 251 +++ app/evolution/__init__.py | 0 app/evolution/analyzer.py | 143 ++ app/extract/__init__.py | 0 app/extract/base.py | 131 ++ app/extract/llm.py | 124 ++ app/extract/offline.py | 128 ++ app/extract/prompts.py | 43 + app/graph/__init__.py | 0 app/graph/tkg.py | 192 +++ app/ingest/__init__.py | 0 app/ingest/loader.py | 134 ++ app/match/__init__.py | 0 app/match/engine.py | 286 ++++ app/match/resume.py | 151 ++ app/match/resume_docx.py | 133 ++ app/match/semantic.py | 163 ++ app/models.py | 139 ++ app/services/__init__.py | 0 app/services/overrides.py | 130 ++ app/services/pipeline.py | 179 +++ app/storage/__init__.py | 0 app/storage/db.py | 360 +++++ config.yaml | 22 + data/.gitkeep | 0 data/jd_raw/.gitkeep | 0 data/real/.gitkeep | 0 data/real/aistudio_resumes/.gitkeep | 0 data/real/autoweaks_jd/.gitkeep | 0 data/real/batch_demo_resumes/.gitkeep | 0 data/real/esco_reference/.gitkeep | 0 data/reports/.gitkeep | 0 data/resumes/.gitkeep | 0 data/review/.gitkeep | 0 data/testset/.gitkeep | 0 data/tmp/.gitkeep | 0 docker-compose.yml | 45 + docs/CareerNexus职联_项目方案.md | 749 +++++++++ docs/CareerNexus职联_项目方案补充_v3.1.md | 135 ++ init.bat | 102 ++ init.sh | 81 + main.py | 11 + reports/metrics.json | 329 ++++ requirements.txt | 11 + scripts/build_real_dataset.py | 329 ++++ scripts/evaluate.py | 251 +++ scripts/generate_dataset.py | 932 +++++++++++ scripts/run_demo.py | 68 + scripts/sync_neo4j.py | 104 ++ tests/conftest.py | 48 + tests/test_api.py | 427 +++++ tests/test_cleaning.py | 76 + tests/test_evolution.py | 68 + tests/test_extract.py | 131 ++ tests/test_match.py | 93 ++ tests/test_persistence.py | 373 +++++ tests/test_real_data.py | 102 ++ tests/test_storage_db.py | 105 ++ tests/test_tkg.py | 78 + webui/app.js | 1731 +++++++++++++++++++++ webui/graph.js | 887 +++++++++++ webui/index.html | 511 ++++++ webui/style.css | 913 +++++++++++ 78 files changed, 12860 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 app/__init__.py create mode 100644 app/api/__init__.py create mode 100644 app/api/routes.py create mode 100644 app/bootstrap.py create mode 100644 app/cleaning/__init__.py create mode 100644 app/cleaning/decay.py create mode 100644 app/cleaning/dedup.py create mode 100644 app/cleaning/noise.py create mode 100644 app/config.py create mode 100644 app/discover/__init__.py create mode 100644 app/discover/cluster.py create mode 100644 app/evolution/__init__.py create mode 100644 app/evolution/analyzer.py create mode 100644 app/extract/__init__.py create mode 100644 app/extract/base.py create mode 100644 app/extract/llm.py create mode 100644 app/extract/offline.py create mode 100644 app/extract/prompts.py create mode 100644 app/graph/__init__.py create mode 100644 app/graph/tkg.py create mode 100644 app/ingest/__init__.py create mode 100644 app/ingest/loader.py create mode 100644 app/match/__init__.py create mode 100644 app/match/engine.py create mode 100644 app/match/resume.py create mode 100644 app/match/resume_docx.py create mode 100644 app/match/semantic.py create mode 100644 app/models.py create mode 100644 app/services/__init__.py create mode 100644 app/services/overrides.py create mode 100644 app/services/pipeline.py create mode 100644 app/storage/__init__.py create mode 100644 app/storage/db.py create mode 100644 config.yaml create mode 100644 data/.gitkeep create mode 100644 data/jd_raw/.gitkeep create mode 100644 data/real/.gitkeep create mode 100644 data/real/aistudio_resumes/.gitkeep create mode 100644 data/real/autoweaks_jd/.gitkeep create mode 100644 data/real/batch_demo_resumes/.gitkeep create mode 100644 data/real/esco_reference/.gitkeep create mode 100644 data/reports/.gitkeep create mode 100644 data/resumes/.gitkeep create mode 100644 data/review/.gitkeep create mode 100644 data/testset/.gitkeep create mode 100644 data/tmp/.gitkeep create mode 100644 docker-compose.yml create mode 100644 docs/CareerNexus职联_项目方案.md create mode 100644 docs/CareerNexus职联_项目方案补充_v3.1.md create mode 100644 init.bat create mode 100644 init.sh create mode 100644 main.py create mode 100644 reports/metrics.json create mode 100644 requirements.txt create mode 100644 scripts/build_real_dataset.py create mode 100644 scripts/evaluate.py create mode 100644 scripts/generate_dataset.py create mode 100644 scripts/run_demo.py create mode 100644 scripts/sync_neo4j.py create mode 100644 tests/conftest.py create mode 100644 tests/test_api.py create mode 100644 tests/test_cleaning.py create mode 100644 tests/test_evolution.py create mode 100644 tests/test_extract.py create mode 100644 tests/test_match.py create mode 100644 tests/test_persistence.py create mode 100644 tests/test_real_data.py create mode 100644 tests/test_storage_db.py create mode 100644 tests/test_tkg.py create mode 100644 webui/app.js create mode 100644 webui/graph.js create mode 100644 webui/index.html create mode 100644 webui/style.css diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..50e79ac --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +# 默认构建保持轻量:本地权重/数据/缓存/运行时密钥不进镜像 +models/ +data/tmp/ +logs/ +__pycache__/ +**/__pycache__/ +.venv/ +*.pyc +config.json diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..2518b31 --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +# 大模型抽取通道(星火/OpenAI 兼容 API)密钥示例——仅示例,请勿提交真实密钥 +# 配置后并把 config.yaml 的 mode 改为 live,即可启用大模型抽取;未配置时自动降级离线抽取 +SPARK_API_KEY=your_spark_api_key_here diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8ec23e0 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5f11aae --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..891bb90 --- /dev/null +++ b/README.md @@ -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`,均为纯前端持久化。 diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..96950a8 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1,3 @@ +"""CareerNexus 职联 —— 多源异构数据驱动的岗位与能力图谱平台。""" + +__version__ = "0.1.0" diff --git a/app/api/__init__.py b/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/routes.py b/app/api/routes.py new file mode 100644 index 0000000..145cb39 --- /dev/null +++ b/app/api/routes.py @@ -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") diff --git a/app/bootstrap.py b/app/bootstrap.py new file mode 100644 index 0000000..aa9b31f --- /dev/null +++ b/app/bootstrap.py @@ -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") diff --git a/app/cleaning/__init__.py b/app/cleaning/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/cleaning/decay.py b/app/cleaning/decay.py new file mode 100644 index 0000000..cd115a9 --- /dev/null +++ b/app/cleaning/decay.py @@ -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 diff --git a/app/cleaning/dedup.py b/app/cleaning/dedup.py new file mode 100644 index 0000000..5322578 --- /dev/null +++ b/app/cleaning/dedup.py @@ -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 diff --git a/app/cleaning/noise.py b/app/cleaning/noise.py new file mode 100644 index 0000000..a5e3c1a --- /dev/null +++ b/app/cleaning/noise.py @@ -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 diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..54524a5 --- /dev/null +++ b/app/config.py @@ -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 diff --git a/app/discover/__init__.py b/app/discover/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/discover/cluster.py b/app/discover/cluster.py new file mode 100644 index 0000000..352f34d --- /dev/null +++ b/app/discover/cluster.py @@ -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 diff --git a/app/evolution/__init__.py b/app/evolution/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/evolution/analyzer.py b/app/evolution/analyzer.py new file mode 100644 index 0000000..612127f --- /dev/null +++ b/app/evolution/analyzer.py @@ -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 diff --git a/app/extract/__init__.py b/app/extract/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/extract/base.py b/app/extract/base.py new file mode 100644 index 0000000..edb87de --- /dev/null +++ b/app/extract/base.py @@ -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"(? 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) diff --git a/app/extract/llm.py b/app/extract/llm.py new file mode 100644 index 0000000..da38f16 --- /dev/null +++ b/app/extract/llm.py @@ -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()) diff --git a/app/extract/offline.py b/app/extract/offline.py new file mode 100644 index 0000000..e8c0ed2 --- /dev/null +++ b/app/extract/offline.py @@ -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", + ) diff --git a/app/extract/prompts.py b/app/extract/prompts.py new file mode 100644 index 0000000..839694e --- /dev/null +++ b/app/extract/prompts.py @@ -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 + ) diff --git a/app/graph/__init__.py b/app/graph/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/graph/tkg.py b/app/graph/tkg.py new file mode 100644 index 0000000..d54b355 --- /dev/null +++ b/app/graph/tkg.py @@ -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") diff --git a/app/ingest/__init__.py b/app/ingest/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/ingest/loader.py b/app/ingest/loader.py new file mode 100644 index 0000000..5a1cb30 --- /dev/null +++ b/app/ingest/loader.py @@ -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 diff --git a/app/match/__init__.py b/app/match/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/match/engine.py b/app/match/engine.py new file mode 100644 index 0000000..844936e --- /dev/null +++ b/app/match/engine.py @@ -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, + ) diff --git a/app/match/resume.py b/app/match/resume.py new file mode 100644 index 0000000..2f547c6 --- /dev/null +++ b/app/match/resume.py @@ -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)) diff --git a/app/match/resume_docx.py b/app/match/resume_docx.py new file mode 100644 index 0000000..1249970 --- /dev/null +++ b/app/match/resume_docx.py @@ -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() diff --git a/app/match/semantic.py b/app/match/semantic.py new file mode 100644 index 0000000..2b94087 --- /dev/null +++ b/app/match/semantic.py @@ -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 diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..4ac43ee --- /dev/null +++ b/app/models.py @@ -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 = "" diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/services/overrides.py b/app/services/overrides.py new file mode 100644 index 0000000..2cb4669 --- /dev/null +++ b/app/services/overrides.py @@ -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 diff --git a/app/services/pipeline.py b/app/services/pipeline.py new file mode 100644 index 0000000..0dc84a6 --- /dev/null +++ b/app/services/pipeline.py @@ -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)) diff --git a/app/storage/__init__.py b/app/storage/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/storage/db.py b/app/storage/db.py new file mode 100644 index 0000000..1d910ae --- /dev/null +++ b/app/storage/db.py @@ -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": } + 或 {"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() diff --git a/config.yaml b/config.yaml new file mode 100644 index 0000000..9363abb --- /dev/null +++ b/config.yaml @@ -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 diff --git a/data/.gitkeep b/data/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/data/jd_raw/.gitkeep b/data/jd_raw/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/data/real/.gitkeep b/data/real/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/data/real/aistudio_resumes/.gitkeep b/data/real/aistudio_resumes/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/data/real/autoweaks_jd/.gitkeep b/data/real/autoweaks_jd/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/data/real/batch_demo_resumes/.gitkeep b/data/real/batch_demo_resumes/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/data/real/esco_reference/.gitkeep b/data/real/esco_reference/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/data/reports/.gitkeep b/data/reports/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/data/resumes/.gitkeep b/data/resumes/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/data/review/.gitkeep b/data/review/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/data/testset/.gitkeep b/data/testset/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/data/tmp/.gitkeep b/data/tmp/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..40cfdde --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/docs/CareerNexus职联_项目方案.md b/docs/CareerNexus职联_项目方案.md new file mode 100644 index 0000000..97e497a --- /dev/null +++ b/docs/CareerNexus职联_项目方案.md @@ -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 分钟演示视频(按十章演示脚本录制) + +*(完)* diff --git a/docs/CareerNexus职联_项目方案补充_v3.1.md b/docs/CareerNexus职联_项目方案补充_v3.1.md new file mode 100644 index 0000000..9155ae7 --- /dev/null +++ b/docs/CareerNexus职联_项目方案补充_v3.1.md @@ -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 分钟演示视频(按方案第十章演示脚本录制)。 + +*(补充完)* diff --git a/init.bat b/init.bat new file mode 100644 index 0000000..7b6c6ed --- /dev/null +++ b/init.bat @@ -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 diff --git a/init.sh b/init.sh new file mode 100644 index 0000000..bd66c9e --- /dev/null +++ b/init.sh @@ -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 "==========================================" diff --git a/main.py b/main.py new file mode 100644 index 0000000..e8dde95 --- /dev/null +++ b/main.py @@ -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() diff --git a/reports/metrics.json b/reports/metrics.json new file mode 100644 index 0000000..7e5bfa0 --- /dev/null +++ b/reports/metrics.json @@ -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 + } +} \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..219584f --- /dev/null +++ b/requirements.txt @@ -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 diff --git a/scripts/build_real_dataset.py b/scripts/build_real_dataset.py new file mode 100644 index 0000000..2fbc330 --- /dev/null +++ b/scripts/build_real_dataset.py @@ -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() diff --git a/scripts/evaluate.py b/scripts/evaluate.py new file mode 100644 index 0000000..e31e579 --- /dev/null +++ b/scripts/evaluate.py @@ -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() diff --git a/scripts/generate_dataset.py b/scripts/generate_dataset.py new file mode 100644 index 0000000..b73c306 --- /dev/null +++ b/scripts/generate_dataset.py @@ -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() diff --git a/scripts/run_demo.py b/scripts/run_demo.py new file mode 100644 index 0000000..91c7fc7 --- /dev/null +++ b/scripts/run_demo.py @@ -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() diff --git a/scripts/sync_neo4j.py b/scripts/sync_neo4j.py new file mode 100644 index 0000000..110e005 --- /dev/null +++ b/scripts/sync_neo4j.py @@ -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()) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..94f2f3b --- /dev/null +++ b/tests/conftest.py @@ -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 diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..7700f56 --- /dev/null +++ b/tests/test_api.py @@ -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 diff --git a/tests/test_cleaning.py b/tests/test_cleaning.py new file mode 100644 index 0000000..c9540d0 --- /dev/null +++ b/tests/test_cleaning.py @@ -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 diff --git a/tests/test_evolution.py b/tests/test_evolution.py new file mode 100644 index 0000000..3bfe3f4 --- /dev/null +++ b/tests/test_evolution.py @@ -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 == "一次性技能"] diff --git a/tests/test_extract.py b/tests/test_extract.py new file mode 100644 index 0000000..7a28fe0 --- /dev/null +++ b/tests/test_extract.py @@ -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 == [] diff --git a/tests/test_match.py b/tests/test_match.py new file mode 100644 index 0000000..356ef96 --- /dev/null +++ b/tests/test_match.py @@ -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 diff --git a/tests/test_persistence.py b/tests/test_persistence.py new file mode 100644 index 0000000..a3565aa --- /dev/null +++ b/tests/test_persistence.py @@ -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) diff --git a/tests/test_real_data.py b/tests/test_real_data.py new file mode 100644 index 0000000..7a6602f --- /dev/null +++ b/tests/test_real_data.py @@ -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 diff --git a/tests/test_storage_db.py b/tests/test_storage_db.py new file mode 100644 index 0000000..bdec457 --- /dev/null +++ b/tests/test_storage_db.py @@ -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 diff --git a/tests/test_tkg.py b/tests/test_tkg.py new file mode 100644 index 0000000..e612364 --- /dev/null +++ b/tests/test_tkg.py @@ -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" diff --git a/webui/app.js b/webui/app.js new file mode 100644 index 0000000..c9d4b90 --- /dev/null +++ b/webui/app.js @@ -0,0 +1,1731 @@ +/* CareerNexus 职联 前端主脚本:外壳 + 驾驶舱 + 演化 + 匹配 + 学习路径。 + * 全部数据走 /api,完全离线(无外部依赖)。图谱工作台见 graph.js。 */ +"use strict"; + +/* ================= 公共工具 ================= */ +const $ = (s) => document.querySelector(s); +const $$ = (s) => Array.from(document.querySelectorAll(s)); + +async function api(path, options) { + options = options || {}; + options.headers = Object.assign( + { "X-Access-Token": localStorage.getItem("cn_access_token") || "" }, + options.headers); + const resp = await fetch(path, options); + if (resp.status === 401) { + openConfigModal(true); + throw new Error("需要访问令牌:请在系统设置中填写"); + } + const body = await resp.json().catch(() => ({ code: -1, message: "响应解析失败" })); + if (!resp.ok || body.code !== 0) throw new Error(body.detail || body.message || `请求失败 (${resp.status})`); + return body.data; +} + +function esc(s) { + return String(s ?? "").replace(/[&<>"']/g, (c) => + ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); +} + +function toast(msg, type = "info") { + const el = document.createElement("div"); + el.className = `toast ${type}`; + el.textContent = msg; + $("#toast-wrap").appendChild(el); + setTimeout(() => el.remove(), 2800); +} + +/* ================= 统一系统弹窗(替代浏览器原生 alert/prompt/confirm) ================= */ +function sysDialog({ title, bodyHtml, okText = "确定", cancelText = "取消", danger = false, onOk }) { + return new Promise((resolve) => { + const wrap = document.createElement("div"); + wrap.className = "sysdlg-mask"; + wrap.innerHTML = ` + `; + document.body.appendChild(wrap); + const close = (val) => { wrap.remove(); resolve(val); }; + wrap.querySelector('[data-act="ok"]').onclick = () => { + if (onOk) { + const v = onOk(wrap); + if (v !== undefined) close(v); // onOk 返回 undefined 表示校验不通过,不关闭 + } else close(true); + }; + wrap.querySelector('[data-act="cancel"]')?.addEventListener("click", () => close(null)); + wrap.addEventListener("click", (e) => { if (e.target === wrap) close(null); }); + const input = wrap.querySelector("input,textarea"); + if (input) { + input.focus(); input.select(); + input.addEventListener("keydown", (e) => { + if (e.key === "Enter" && input.tagName === "INPUT") wrap.querySelector('[data-act="ok"]').click(); + }); + } + }); +} + +function sysPrompt({ title, label = "", value = "", placeholder = "" }) { + return sysDialog({ + title, + bodyHtml: `${label ? `
${esc(label)}
` : ""} + `, + onOk: (w) => w.querySelector("input").value.trim() || null, + }); +} + +function sysConfirm({ title, text, okText = "确定", danger = false }) { + return sysDialog({ title, bodyHtml: `
${esc(text)}
`, okText, danger }); +} + +function sysAlert({ title, text, okText = "知道了" }) { + return sysDialog({ title, bodyHtml: `
${esc(text)}
`, okText, cancelText: "" }); +} + +/* 复制展示框:readonly 输入 + 一键复制(clipboard 失败时退化为手动全选复制) */ +function sysCopy({ title, text }) { + return sysDialog({ + title, + okText: "复制", cancelText: "关闭", + bodyHtml: ``, + onOk: async (w) => { + const input = w.querySelector("input"); + input.focus(); input.select(); + try { await navigator.clipboard.writeText(text); toast("已复制到剪贴板", "success"); } + catch { document.execCommand("copy"); toast("已复制到剪贴板", "success"); } + return true; + }, + }); +} + +function openModal(title, bodyHtml, footHtml) { + $("#modal-title").innerHTML = title; + $("#modal-body").innerHTML = bodyHtml; + const foot = $("#modal-foot"); + if (footHtml) { foot.innerHTML = footHtml; foot.classList.remove("hidden"); } + else foot.classList.add("hidden"); + $("#modal-mask").classList.remove("hidden"); +} +function closeModal() { $("#modal-mask").classList.add("hidden"); $("#modal-box").classList.remove("wide"); } +$("#modal-close").onclick = closeModal; +$("#modal-mask").addEventListener("click", (e) => { if (e.target.id === "modal-mask") closeModal(); }); +document.addEventListener("keydown", (e) => { if (e.key === "Escape") closeModal(); }); + +const post = (path, payload) => api(path, { + method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), +}); + +/* 全局共享状态与命名空间(graph.js 复用) */ +const CN = window.CN = { + api, post, $, $$, esc, toast, openModal, closeModal, + sysPrompt, sysConfirm, sysAlert, sysCopy, + jobs: [], // /api/jobs 缓存 + graphData: null, // /api/graph 缓存 + switchView: null, // 下方赋值 + gotoNode: null, // graph.js 注册:跳转图谱并选中节点 +}; + +const TYPE_META = { + Job: { name: "岗位", color: "#2f54eb", tag: "tag-blue" }, + Skill: { name: "技能", color: "#00b42a", tag: "tag-green" }, + Industry: { name: "行业", color: "#13c2c2", tag: "tag-cyan" }, + Scenario: { name: "场景", color: "#722ed1", tag: "tag-purple" }, +}; +CN.TYPE_META = TYPE_META; + +/* JD 证据弹窗(演化事件 / 图谱来源共用) */ +async function showJdEvidence(sid) { + openModal(`证据来源 · ${esc(sid)}`, `
加载中…
`); + try { + const rec = await api(`/api/jd?sid=${encodeURIComponent(sid)}`); + $("#modal-body").innerHTML = ` +
+ ${esc(rec.job_title)}${esc(rec.company)} + ${esc(rec.city || "—")}${esc(rec.salary || "—")} + 发布 ${esc(rec.publish_date)}来源 ${esc(rec.source || "—")} +
+
${esc(rec.text)}
`; + } catch (e) { + $("#modal-body").innerHTML = `
原文读取失败:${esc(e.message)}
`; + } +} +CN.showJdEvidence = showJdEvidence; + +/* ================= 视图切换 / 侧栏 ================= */ +const PAGE_TITLES = { + dashboard: "数据驾驶舱", graph: "全景图谱", evolution: "岗位演化", + match: "人岗匹配", path: "学习路径", +}; +const _loaded = {}; +function switchView(name) { + $$(".nav-item").forEach((b) => b.classList.toggle("active", b.dataset.view === name)); + $$(".view").forEach((v) => v.classList.remove("active")); + $("#view-" + name).classList.add("active"); + $("#page-title").textContent = PAGE_TITLES[name] || ""; + /* 视图记入 URL hash,刷新后回到同一页(node 参数在 graph.js 内维护) */ + const hash = new URLSearchParams(location.hash.slice(1)); + if (hash.get("view") !== name) { + hash.set("view", name); + if (name !== "graph") hash.delete("node"); + history.replaceState(null, "", "#" + hash.toString()); + } + if (name === "graph" && CN.graph && !_loaded.graph) { _loaded.graph = true; CN.graph.init(); } + if (name === "graph" && CN.graph && _loaded.graph) CN.graph.onShow(); + if (name === "path" && !_loaded.path) { _loaded.path = true; loadPathJob($("#path-job-select").value); } +} +CN.switchView = switchView; + +$$(".nav-item").forEach((btn) => { btn.onclick = () => switchView(btn.dataset.view); }); + +/* ================= 全局搜索 ================= */ +(function initSearch() { + const input = $("#global-search-input"), drop = $("#global-search-drop"); + let timer = null; + input.addEventListener("input", () => { + clearTimeout(timer); + timer = setTimeout(async () => { + const kw = input.value.trim(); + if (!kw) { drop.classList.add("hidden"); return; } + try { + if (!CN.graphData) CN.graphData = await api("/api/graph"); + const hits = CN.graphData.nodes + .filter((n) => (n.title || n.id).toLowerCase().includes(kw.toLowerCase())) + .slice(0, 8); + drop.innerHTML = hits.length ? hits.map((n, i) => { + const m = TYPE_META[n.type] || { name: n.type, tag: "tag-gray" }; + return `
+ ${m.name}${esc(n.title || n.id)} + ${esc(n.category || "")}
`; + }).join("") : `
无匹配结果
`; + drop.classList.remove("hidden"); + $$("#global-search-drop .sd-item[data-id]").forEach((el) => { + el.onclick = () => { + drop.classList.add("hidden"); input.value = ""; + switchView("graph"); + if (CN.gotoNode) CN.gotoNode(el.dataset.id); + }; + }); + } catch { /* 静默 */ } + }, 200); + }); + document.addEventListener("click", (e) => { + if (!e.target.closest(".global-search")) drop.classList.add("hidden"); + }); +})(); + +/* ================= 数据驾驶舱 ================= */ +const METRIC_DEFS = [ + { key: "jobs", label: "岗位节点", view: "graph", icon: '' }, + { key: "skills", label: "技能节点", view: "graph", icon: '' }, + { key: "edges", label: "图谱关系边", view: "graph", icon: '' }, + { key: "events", label: "演化事件", view: "evolution", icon: '' }, + { key: "pending", label: "待审核候选", view: "graph", icon: '' }, +]; + +function sparkline(canvas, values, color) { + const ctx = canvas.getContext("2d"); + const W = canvas.width, H = canvas.height; + ctx.clearRect(0, 0, W, H); + if (!values.length) return; + const max = Math.max(1, ...values), min = Math.min(...values); + ctx.beginPath(); + values.forEach((v, i) => { + const x = (i / Math.max(1, values.length - 1)) * (W - 4) + 2; + const y = H - 3 - ((v - min) / Math.max(1e-9, max - min)) * (H - 8); + ctx[i ? "lineTo" : "moveTo"](x, y); + }); + ctx.strokeStyle = color; ctx.lineWidth = 1.6; ctx.stroke(); +} + +function quarterlyCounts(events) { + const kv = {}; + events.forEach((e) => { kv[e.window] = (kv[e.window] || 0) + 1; }); + return Object.keys(kv).sort().map((k) => [k, kv[k]]); +} + +async function loadDashboard() { + const [health, graph, pending, audit] = await Promise.all([ + api("/api/health"), api("/api/graph"), + api("/api/review/pending").catch(() => []), + api("/api/audit?limit=8").catch(() => []), + ]); + CN.graphData = graph; + const s = health.graph || {}; + updateBanners(health); + $("#data-version").textContent = `数据版本 v${(graph.events.at(-1) || {}).window || "demo"} · ${s.nodes ?? 0} 节点`; + + /* 指标卡 */ + const series = quarterlyCounts(graph.events || []); + const last12 = series.slice(-12).map(([, c]) => c); + const values = { jobs: s.jobs, skills: s.skills, edges: s.edges, events: s.events, pending: pending.length }; + $("#metric-cards").innerHTML = METRIC_DEFS.map((m, i) => { + const cur = last12.at(-1) ?? 0, prev = last12.at(-2) ?? cur; + const delta = m.key === "events" || m.key === "pending" ? cur - prev : 0; + const cls = delta > 0 ? "up" : delta < 0 ? "down" : "flat"; + const arrow = delta > 0 ? "↑" : delta < 0 ? "↓" : "—"; + const deltaTxt = m.key === "events" + ? `${arrow} 环比上季 ${Math.abs(delta)}` + : `实时统计`; + return `
+
${m.icon}${m.label}
+
${values[m.key] ?? "—"}
${deltaTxt} +
`; + }).join(""); + $$("#metric-cards .metric-card").forEach((el) => { + el.onclick = () => switchView(el.dataset.view); + sparkline(el.querySelector("canvas"), last12.length ? last12 : [0, 0], "#2f54eb"); + }); + $("#pending-count").textContent = `${pending.length} 待办`; + + /* 趋势图(近 12 季度事件数,柱状) */ + CN._trend = series.slice(-12); + drawTrend($("#trend-canvas"), CN._trend); + + /* 任务与审核状态表 */ + renderTaskTable(audit); + $("#btn-import-jd").onclick = () => openImportDialog(); + + /* 动态消息流 */ + renderFeed(graph.events || [], audit); + + /* 待审核候选 */ + renderPending(pending); +} + +/* JD 数据导入对话框:上传文件 / 粘贴文本 / 直接全量重建 */ +async function openImportDialog() { + const choice = await sysDialog({ + title: "JD 数据导入", + okText: "开始导入", + bodyHtml: ` +
支持 .jsonl / .csv / .txt 文件上传(UTF-8,≤10MB),或直接粘贴 JSONL 文本(每行一条记录);两者都留空则仅触发全量重建。
+
上传文件
+ +
或粘贴文本
+ `, + onOk: (w) => { + const f = w.querySelector("#imp-file").files[0]; + const t = w.querySelector("#imp-text").value; + if (!f && !t.trim()) return "rebuild"; + return { file: f, text: t }; + }, + }); + if (choice === null) return; + try { + let t; + if (choice === "rebuild") { + t = await post("/api/jd/import", {}); + } else { + const fd = new FormData(); + if (choice.file) fd.append("file", choice.file); + else fd.append("text", choice.text); + t = await api("/api/jd/import", { method: "POST", body: fd }); + const bad = (t.bad_lines || []).length; + toast(`已接收:导入 ${t.imported} 条 / 跳过 ${t.skipped} 条 / 坏行 ${bad} 条` + + (bad ? `(详见 logs/${t.bad_log})` : ""), bad ? "error" : "success"); + } + toast(`导入任务已启动:${t.task_id}`, "success"); + pollTask(t.task_id); + } catch (e) { toast(e.message, "error"); } +} + +async function pollTask(taskId) { const timer = setInterval(async () => { + try { + const t = await api(`/api/tasks/${taskId}`); + if (t.status === "done") { + clearInterval(timer); + toast("JD 导入完成,图谱已更新", "success"); + loadDashboard(); + } else if (t.status === "failed") { + clearInterval(timer); + toast("导入失败:" + (t.error || "未知错误"), "error"); + } + } catch { clearInterval(timer); } + }, 1500); +} + +function renderTaskTable(audit) { + const tbody = $("#task-table tbody"); + const rows = (audit || []).slice(0, 8); + if (!rows.length) { + tbody.innerHTML = ""; + $("#task-empty").classList.remove("hidden"); + return; + } + $("#task-empty").classList.add("hidden"); + tbody.innerHTML = rows.map((r) => { + const isNode = r.kind === "node_update"; + const obj = isNode ? r.node_id : (r.candidate_id || "").replace(/^cand_/, ""); + const act = isNode ? "节点编辑" : { accept: "审核通过", reject: "审核驳回", modify: "审核修改" }[r.action] || r.action; + const badge = isNode ? "badge-version" : (r.action === "reject" ? "badge-warning" : "badge-success"); + return `${act} + ${esc(obj)}${esc(r.operator || "—")} + ${esc(r.time || "—")}${esc(r.reason || "—")}`; + }).join(""); +} + +function renderFeed(events, audit) { + const items = []; + events.slice(-8).reverse().forEach((e) => items.push({ + icon: "#2f54eb", bg: "#f0f5ff", kind: "evo", + text: `${esc(e.job_id.replace(/^job_/, ""))} ${esc(e.change_type)}技能「${esc(e.skill)}」`, + time: e.window, job: e.job_id, + })); + (audit || []).slice(0, 4).forEach((r) => items.push({ + icon: "#00b42a", bg: "#e8ffea", kind: "audit", + text: r.kind === "node_update" + ? `${esc(r.operator)} 编辑了节点「${esc(r.node_id)}」` + : `${esc(r.operator)} ${{ accept: "通过", reject: "驳回", modify: "修改" }[r.action] || r.action}了候选「${esc((r.candidate_id || "").replace(/^cand_/, ""))}」`, + time: r.time || "", + })); + items.sort((a, b) => (b.time > a.time ? 1 : -1)); + $("#activity-feed").innerHTML = items.slice(0, 10).map((it, i) => ` +
+ + +
${it.text}
${esc(it.time)}
+
`).join("") || `
暂无动态
`; + $$("#activity-feed .feed-item").forEach((el) => { + el.onclick = () => { + const it = items[+el.dataset.i]; + if (it.kind === "evo" && it.job) { + switchView("evolution"); + $("#evo-job-select").value = it.job; + loadEvolution(); + } + }; + }); +} + +function renderPending(pending) { + const box = $("#pending-list"); + if (!pending.length) { + box.innerHTML = `
暂无待审核候选(可在图谱页触发新岗位发现)
`; + return; + } + box.innerHTML = pending.map((c) => ` +
+
${esc(c.title)}待审核 + ${esc(c.category || "")}
+
支撑 ${c.source_count} 条 JD · ${c.company_count} 家企业 · 期望年限 ${c.expected_years} 年
+
必备:${c.required_skills.map((s) => `${esc(s)}`).join(" ")} + ${c.preferred_skills.length ? `
加分:${c.preferred_skills.map((s) => `${esc(s)}`).join(" ")}` : ""}
+
+ + + +
+
`).join(""); + box.querySelectorAll("[data-act]").forEach((b) => { + b.onclick = () => doReview(b.dataset.id, b.dataset.act); + }); + box.querySelectorAll("[data-locate]").forEach((b) => { + b.onclick = () => { switchView("graph"); if (CN.gotoNode) CN.gotoNode(b.dataset.locate); }; + }); +} + +async function doReview(id, action) { + const operator = await CN.sysPrompt({ title: "审核确认", label: "请输入审核人姓名:", value: "评审专家" }); + if (operator === null) return; + let reason = "webui 审核通过"; + if (action === "reject") { + reason = await CN.sysPrompt({ title: "驳回候选", label: "请输入驳回原因:", value: "证据不足" }); + if (reason === null) return; + } + try { + await post("/api/review", { candidate_id: id, action, operator: operator || "评审专家", reason }); + toast(`审核完成:${action === "accept" ? "已通过入库" : "已驳回"}`, "success"); + loadDashboard(); + if (CN.graph && CN.graph.reload) CN.graph.reload(); + } catch (e) { toast(e.message, "error"); } +} +CN.doReview = doReview; + +/* 画布自适应:占满父容器(宽 100%、高 100%)+ dpr 高清,返回 CSS 像素坐标系下的 ctx/W/H */ +function fitCanvas(canvas, cssH) { + const dpr = window.devicePixelRatio || 1; + canvas.style.width = "100%"; + canvas.style.height = "100%"; + const W = canvas.clientWidth || 600; + const H = (canvas.clientHeight >= 120 ? canvas.clientHeight : cssH); + canvas.width = W * dpr; + canvas.height = H * dpr; + const ctx = canvas.getContext("2d"); + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + return { ctx, W, H }; +} + +function drawTrend(canvas, kv) { + const { ctx, W, H } = fitCanvas(canvas, 220); + ctx.clearRect(0, 0, W, H); + if (!kv.length) return; + const max = Math.max(1, ...kv.map(([, c]) => c)); + const bw = W / kv.length; + ctx.font = "11px sans-serif"; ctx.textAlign = "center"; + kv.forEach(([k, c], i) => { + const h = (c / max) * (H - 46); + const x = i * bw + bw * 0.18, y = H - h - 24; + const grad = ctx.createLinearGradient(0, y, 0, H - 24); + grad.addColorStop(0, "#2f54eb"); grad.addColorStop(1, "#85a5ff"); + ctx.fillStyle = grad; + ctx.beginPath(); + ctx.roundRect(x, y, bw * 0.64, h, [3, 3, 0, 0]); + ctx.fill(); + ctx.fillStyle = "#86909c"; + ctx.fillText(k.slice(2), i * bw + bw / 2, H - 8); + ctx.fillStyle = "#4e5969"; + ctx.fillText(c, i * bw + bw / 2, y - 5); + }); +} + +/* ================= 岗位演化 ================= */ +const CHANGE_META = { + "新增": { tag: "tag-green", color: "#00b42a" }, + "增强": { tag: "tag-blue", color: "#2f54eb" }, + "减弱": { tag: "tag-orange", color: "#ff7d00" }, + "退出": { tag: "tag-red", color: "#f53f3f" }, +}; +let evoEvents = []; + +/* 全部岗位 × 季度 演化热力图 */ +function renderEvoHeatmap() { + const box = $("#evo-heatmap"); + const events = (CN.graphData && CN.graphData.events) || []; + const jobs = CN.jobs || []; + if (!events.length || !jobs.length) { + box.innerHTML = `
暂无演化事件数据
`; + return; + } + const windows = [...new Set(events.map((e) => e.window))].sort(); + // 统计每个岗位每季度事件数与类型分布 + const cell = {}; + events.forEach((e) => { + const k = `${e.job_id}|${e.window}`; + cell[k] = cell[k] || { n: 0, types: {} }; + cell[k].n += 1; + cell[k].types[e.change_type] = (cell[k].types[e.change_type] || 0) + 1; + }); + // 按总事件数降序排列岗位 + const totals = {}; + events.forEach((e) => { totals[e.job_id] = (totals[e.job_id] || 0) + 1; }); + const rows = [...jobs].sort((a, b) => (totals[b.job_id] || 0) - (totals[a.job_id] || 0)); + const labelEvery = Math.ceil(windows.length / 10); + // 色阶:开方缩放 + 12 封顶,避免个别高峰季度(如 57 件)把其余格子压到看不清 + const alphaOf = (n) => 0.18 + 0.82 * Math.sqrt(Math.min(n, 12) / 12); + box.innerHTML = ` +
+
+ ${windows.map((w, i) => `
${i % labelEvery === 0 ? esc(w) : ""}
`).join("")} + ${rows.map((j) => ` +
${esc(j.title)}
+ ${windows.map((w) => { + const c = cell[`${j.job_id}|${w}`]; + const tip = c + ? `${j.title} · ${w}:${c.n} 个事件(${Object.entries(c.types).map(([t, n]) => `${t} ${n}`).join(",")})` + : `${j.title} · ${w}:无事件`; + return `
`; + }).join("")}`).join("")} +
+
+ 事件数:少 + + 多 + (悬停查看类型分布,点击行标签切换岗位) +
`; + box.querySelectorAll(".hm-row-label").forEach((el) => { + el.onclick = () => { + $("#evo-job-select").value = el.dataset.job; + loadEvolution().then(renderEvoHeatmap); + }; + }); +} + +function bucketWindow(w, gran) { + if (gran === "year") return w.slice(0, 4); + if (gran === "month") { /* 季度窗口展开为季中月 */ return `${w.slice(0, 4)}-${String((+w.slice(5) - 1) * 3 + 2).padStart(2, "0")}`; } + return w; +} + +async function loadEvolution() { + const id = $("#evo-job-select").value; + if (!id) return; + const data = await api(`/api/jobs/${encodeURIComponent(id)}/evolution`); + evoEvents = data.events; + renderEvoStats(id); + renderEvolution(); + runEvoCompare(); // 默认对比:最早事件前 → 今天 +} + +function renderEvoStats(jobId) { + const job = (CN.jobs || []).find((j) => j.job_id === jobId) || {}; + const wins = [...new Set(evoEvents.map((e) => e.window))].sort(); + const recent = new Set(wins.slice(-4)); + const cnt = (t) => evoEvents.filter((e) => e.change_type === t && recent.has(e.window)).length; + const jds = new Set(evoEvents.flatMap((e) => e.source_ids || [])); + const items = [ + ["当前必备技能", `${(job.required_skills || []).length} 项`], + ["当前加分技能", `${(job.preferred_skills || []).length} 项`], + ["近 4 季度新增", cnt("新增")], + ["近 4 季度退出", cnt("退出")], + ["证据 JD 总量", `${jds.size} 条`], + ]; + $("#evo-stats").innerHTML = items.map(([k, v]) => + `
${k}${v}
`).join(""); +} + +async function runEvoCompare() { + const id = $("#evo-job-select").value; + if (!id) return; + const box = $("#evo-diff"); + // 默认日期:A=最早事件所在季度初,B=今天 + if (!$("#evo-date-a").value) { + const first = (evoEvents[0] || {}).window || "2019Q1"; + $("#evo-date-a").value = `${first.slice(0, 4)}-01-01`; + } + if (!$("#evo-date-b").value) { + $("#evo-date-b").value = new Date().toISOString().slice(0, 10); + } + const atA = $("#evo-date-a").value, atB = $("#evo-date-b").value; + box.innerHTML = `
对比计算中…
`; + try { + const [sa, sb] = await Promise.all([ + api(`/api/jobs/${encodeURIComponent(id)}/snapshot?at=${atA}`), + api(`/api/jobs/${encodeURIComponent(id)}/snapshot?at=${atB}`), + ]); + const setA = new Map(sa.skills.map((s) => [s.skill, s.importance])); + const setB = new Map(sb.skills.map((s) => [s.skill, s.importance])); + const onlyA = [...setA.keys()].filter((s) => !setB.has(s)); + const both = [...setA.keys()].filter((s) => setB.has(s)); + const onlyB = [...setB.keys()].filter((s) => !setA.has(s)); + const chip = (s, cls, extra) => + `${esc(s)}${extra ? `${extra}` : ""}`; + const col = (title, list, cls, note) => ` +
+
${title}(${list.length})
+
${list.map((s) => chip(s, cls, + note === "升" && setA.get(s) !== setB.get(s) ? "↑" : "")).join("") || + '无'}
+
`; + box.innerHTML = ` +
${esc(atA)}(${esc(sa.window)}):${setA.size} 项技能 → ${esc(atB)}(${esc(sb.window)}):${setB.size} 项技能
+
+ ${col(`仅 ${esc(atA)} 有(已退出)`, onlyA, "tag-red")} + ${col("两个时点共有(保留)", both, "tag-gray", "升")} + ${col(`仅 ${esc(atB)} 有(新增)`, onlyB, "tag-green")} +
`; + box.querySelectorAll(".diff-chip").forEach((el) => { + el.onclick = () => { switchView("graph"); if (CN.gotoNode) CN.gotoNode(el.dataset.skill); }; + }); + } catch (e) { + box.innerHTML = `
对比失败:${esc(e.message)}
`; + } +} +$("#evo-compare-btn").onclick = runEvoCompare; +$("#evo-date-a").addEventListener("change", runEvoCompare); +$("#evo-date-b").addEventListener("change", runEvoCompare); +$("#evo-job-select").onchange = loadEvolution; +$$("#evo-gran button").forEach((b) => { + b.onclick = () => { + $$("#evo-gran button").forEach((x) => x.classList.toggle("active", x === b)); + renderEvolution(); + }; +}); + +function renderEvolution() { + const gran = $$("#evo-gran button").find((b) => b.classList.contains("active"))?.dataset.gran || "quarter"; + drawStackBars($("#evo-stack-canvas"), evoEvents); + renderEvoGantt($("#evo-job-select").value); + /* 热度走势:每季度实际生效的技能关系数(按 REQUIRES 边的生效区间统计) */ + const jobEdges = ((CN.graphData || {}).edges || []) + .filter((e) => e.source === $("#evo-job-select").value && e.rel === "REQUIRES" && (e.intervals || []).length); + const allWins = [...new Set(((CN.graphData || {}).events || []).map((e) => e.window))].sort(); + const winKey = (w) => bucketWindow(w, gran); + const buckets = {}; + allWins.forEach((w) => { + buckets[winKey(w)] = jobEdges.filter((e) => + e.intervals.some(([s, en]) => s <= w && (en === null || w < en))).length; + }); + CN._heat = Object.entries(buckets).sort((a, b) => (a[0] < b[0] ? -1 : 1)); + drawHeatLine($("#evo-heat-canvas"), CN._heat); + + /* 时间轴 */ + const box = $("#evo-timeline"); + box.innerHTML = evoEvents.length ? evoEvents.map((e, i) => { + const m = CHANGE_META[e.change_type] || { tag: "tag-gray" }; + return `
+
${esc(e.window)} + ${esc(e.change_type)} + ${esc(e.skill)} + 权重 ${e.prev_weight.toFixed(2)} → ${e.curr_weight.toFixed(2)}
+
${esc(e.description)}
+
证据 JD ${e.source_ids.length} 条 · 点击查看原文
+
`; + }).join("") : `
该岗位暂无演化事件
`; + box.querySelectorAll(".evo-event").forEach((el) => { + el.onclick = () => { + const e = evoEvents[+el.dataset.i]; + const sids = e.source_ids || []; + if (!sids.length) return toast("该事件无关联证据", "info"); + if (sids.length === 1) return showJdEvidence(sids[0]); + openModal(`证据来源 · ${esc(e.skill)}(${sids.length} 条)`, + sids.map((s) => `
+ ${esc(s)}
`).join("")); + $$("#modal-body .src-item").forEach((x) => { x.onclick = () => showJdEvidence(x.dataset.sid); }); + }; + }); +} + +function drawHeatLine(canvas, kv) { + const { ctx, W, H } = fitCanvas(canvas, 200); + ctx.clearRect(0, 0, W, H); + if (!kv.length) return; + const pad = { l: 40, r: 16, t: 16, b: 28 }; + const max = Math.max(1, ...kv.map(([, v]) => v)); + /* 网格与 y 轴 */ + ctx.strokeStyle = "#f0f0f0"; ctx.fillStyle = "#86909c"; ctx.font = "11px sans-serif"; + for (let g = 0; g <= 4; g++) { + const y = pad.t + ((H - pad.t - pad.b) * g) / 4; + ctx.beginPath(); ctx.moveTo(pad.l, y); ctx.lineTo(W - pad.r, y); ctx.stroke(); + ctx.textAlign = "right"; + ctx.fillText(Math.round(max * (1 - g / 4)), pad.l - 6, y + 4); + } + const px = (i) => pad.l + (i / Math.max(1, kv.length - 1)) * (W - pad.l - pad.r); + const py = (v) => pad.t + (1 - v / max) * (H - pad.t - pad.b); + /* 面积 + 折线 */ + ctx.beginPath(); + kv.forEach(([, v], i) => ctx[i ? "lineTo" : "moveTo"](px(i), py(v))); + ctx.strokeStyle = "#2f54eb"; ctx.lineWidth = 2; ctx.stroke(); + ctx.lineTo(px(kv.length - 1), H - pad.b); ctx.lineTo(px(0), H - pad.b); ctx.closePath(); + const grad = ctx.createLinearGradient(0, pad.t, 0, H - pad.b); + grad.addColorStop(0, "rgba(47,84,235,0.25)"); grad.addColorStop(1, "rgba(47,84,235,0.02)"); + ctx.fillStyle = grad; ctx.fill(); + /* 点与 x 轴 */ + ctx.textAlign = "center"; + kv.forEach(([k, v], i) => { + ctx.fillStyle = "#2f54eb"; + ctx.beginPath(); ctx.arc(px(i), py(v), 3, 0, 7); ctx.fill(); + if (kv.length <= 14 || i % Math.ceil(kv.length / 14) === 0) { + ctx.fillStyle = "#86909c"; ctx.fillText(k, px(i), H - 8); + } + }); +} + +/* 变更类型构成堆叠柱状图(按季度) */ +function drawStackBars(canvas, events) { + const { ctx, W, H } = fitCanvas(canvas, 220); + ctx.clearRect(0, 0, W, H); + const windows = [...new Set(events.map((e) => e.window))].sort(); + $("#stack-legend").innerHTML = Object.entries(CHANGE_META).map(([t, m]) => + `${t}`).join(""); + if (!windows.length) return; + const types = Object.keys(CHANGE_META); + const counts = windows.map((w) => { + const row = {}; + types.forEach((t) => { row[t] = events.filter((e) => e.window === w && e.change_type === t).length; }); + return row; + }); + const maxTotal = Math.max(1, ...counts.map((r) => types.reduce((a, t) => a + r[t], 0))); + const bw = W / windows.length; + const baseY = H - 22; + ctx.font = "10px sans-serif"; ctx.textAlign = "center"; + windows.forEach((w, i) => { + let y = baseY; + types.forEach((t) => { + const v = counts[i][t]; + if (!v) return; + const h = (v / maxTotal) * (H - 52); + ctx.fillStyle = CHANGE_META[t].color; + ctx.fillRect(i * bw + bw * 0.2, y - h, bw * 0.6, h); + y -= h; + }); + const total = types.reduce((a, t) => a + counts[i][t], 0); + if (total) { + ctx.fillStyle = "#4e5969"; + ctx.fillText(total, i * bw + bw / 2, y - 4); + } + ctx.fillStyle = "#86909c"; + ctx.fillText(w.slice(2), i * bw + bw / 2, H - 6); + }); +} + +/* 技能生命周期甘特图 */ +function renderEvoGantt(jobId) { + const box = $("#evo-gantt"); + const g = CN.graphData || {}; + const allEvents = g.events || []; + const windows = [...new Set(allEvents.map((e) => e.window))].sort(); + const edges = (g.edges || []).filter((e) => e.source === jobId && e.rel === "REQUIRES" && (e.intervals || []).length); + if (!windows.length || !edges.length) { + box.innerHTML = `
暂无该岗位的技能生命周期数据
`; + return; + } + // 排序:当前仍生效的在前(按起始窗口升序),已退出的在后;限 14 行 + const alive = (e) => e.intervals.some(([s, en]) => en === null); + edges.sort((a, b) => { + const d = (alive(b) ? 1 : 0) - (alive(a) ? 1 : 0); + if (d) return d; + return (a.intervals[0][0] < b.intervals[0][0] ? -1 : 1); + }); + const rows = edges.slice(0, 14); + const labelEvery = Math.ceil(windows.length / 12); + // 事件 → 技能 × 窗口 的圆点 + const dots = {}; + evoEvents.forEach((e) => { dots[`${e.skill}|${e.window}`] = e.change_type; }); + const cellHtml = (edge, w) => { + const active = edge.intervals.some(([s, en]) => s <= w && (en === null || w < en)); + const dotType = dots[`${edge.target}|${w}`]; + const barColor = edge.importance === "required" ? "var(--primary)" : "var(--cyan)"; + const dot = dotType + ? `` : ""; + return `
+ ${active ? `
` : ""}${dot}
`; + }; + box.innerHTML = ` +
+
+ ${windows.map((w, i) => `
${i % labelEvery === 0 ? esc(w.slice(2)) : ""}
`).join("")} + ${rows.map((e) => ` +
${esc(e.target)}
+ ${windows.map((w) => cellHtml(e, w)).join("")}`).join("")} +
+
+ 生效区间:必备 + 加分 + 圆点: + ${Object.entries(CHANGE_META).map(([t, m]) => `${t}`).join("")} +
`; + box.querySelectorAll(".gt-label").forEach((el) => { + el.onclick = () => { switchView("graph"); if (CN.gotoNode) CN.gotoNode(el.dataset.skill); }; + }); +} + +/* ================= 人岗匹配(三步向导) ================= */ +const matchState = { step: 1, skills: [], years: 0, result: null, profileText: "" }; +let lastProfile = null; // 最近一次解析的完整简历画像(结构化模板与导出用) + +function setMatchStep(n) { + matchState.step = n; + $$("#match-steps .step").forEach((s) => { + const i = +s.dataset.step; + s.classList.toggle("active", i === n); + s.classList.toggle("done", i < n); + if (i < n) s.querySelector("i").innerHTML = ''; + else s.querySelector("i").textContent = i; + }); + [1, 2, 3].forEach((i) => $(`#match-pane-${i}`).classList.toggle("hidden", i !== n)); +} + +$("#btn-load-sample").onclick = async () => { + $("#resume-text").value = + "姓名:陈晓\n手机:13900001111\n邮箱:chenx@example.com\n\n5年 Java 后端开发经验," + + "熟悉 Java、Spring Boot、Spring Cloud、MySQL、Redis,了解 Docker 与 Kubernetes," + + "参与过电商与金融场景的交易系统建设,具备分布式系统调优经验。"; + toast("已载入示例简历", "success"); +}; +$("#resume-file").addEventListener("change", async (e) => { + const f = e.target.files[0]; + e.target.value = ""; + if (!f) return; + const ext = (f.name.split(".").pop() || "").toLowerCase(); + if (["txt", "md"].includes(ext)) { // 纯文本:本地读取,走文本解析 + const reader = new FileReader(); + reader.onload = () => { $("#resume-text").value = reader.result; toast(`已读取 ${f.name}`, "success"); }; + reader.readAsText(f); + return; + } + if (["docx", "pdf"].includes(ext)) { // 二进制格式:上传后端解析 + try { + toast(`正在解析 ${f.name}…`); + const fd = new FormData(); + fd.append("file", f); + const resp = await fetch("/api/resume/parse/file", { + method: "POST", body: fd, + headers: { "X-Access-Token": localStorage.getItem("cn_access_token") || "" }, + }); + const body = await resp.json(); + if (!resp.ok) throw new Error(body.detail || `HTTP ${resp.status}`); + const p = body.data; + applyParsedProfile(p, `${f.name}(已脱敏)`); + toast(`已解析 ${f.name}:识别技能 ${p.skills.length} 项`, "success"); + } catch (err) { toast(`解析失败:${err.message}`, "error"); } + return; + } + toast("仅支持 .txt / .docx / .pdf 格式", "error"); +}); + +function applyParsedProfile(p, sourceDesc) { + lastProfile = p; + matchState.profileText = p.raw_masked_text || ""; + matchState.skills = [...p.skills]; + matchState.years = p.years || 0; + $("#resume-text").value = p.raw_masked_text || ""; + $("#parse-summary").innerHTML = + `解析完成(来源:${esc(sourceDesc || "文本")},已自动脱敏):识别技能 ${p.skills.length} 项 · ` + + `工作年限约 ${p.years || 0} 年 · 场景经验 ${(p.scenarios || []).join("、") || "未识别"}`; + $("#parse-years").value = matchState.years; + renderSkillChips(); + setMatchStep(2); +} + +$("#btn-parse-resume").onclick = async () => { + const text = $("#resume-text").value.trim(); + if (!text) return toast("请先粘贴或上传简历", "error"); + try { + const p = await post("/api/resume/parse", { text }); + applyParsedProfile(p, "粘贴文本"); + } catch (e) { toast(e.message, "error"); } +}; + +function renderSkillChips() { + const box = $("#skill-chip-editor"); + box.innerHTML = matchState.skills.length + ? matchState.skills.map((s, i) => `${esc(s)}`).join("") + : `暂无技能,请在下方输入添加`; + box.querySelectorAll("button").forEach((b) => { + b.onclick = () => { matchState.skills.splice(+b.dataset.i, 1); renderSkillChips(); }; + }); +} +function flushSkillInput() { + // 支持逗号/顿号/分号/空白分隔的批量添加,去重去空 + const input = $("#skill-add-input"); + const parts = input.value.split(/[,,、;;\s]+/).map((s) => s.trim()).filter(Boolean); + let added = 0; + parts.forEach((v) => { if (!matchState.skills.includes(v)) { matchState.skills.push(v); added++; } }); + input.value = ""; + if (added) renderSkillChips(); + return added; +} +$("#skill-add-input").addEventListener("keydown", (e) => { + if (e.key === "Enter") { e.preventDefault(); flushSkillInput(); } +}); +$("#skill-add-input").addEventListener("blur", flushSkillInput); +$("#btn-skill-add").onclick = flushSkillInput; +$("#btn-back-1").onclick = () => setMatchStep(1); + +$("#btn-run-match").onclick = async () => { + flushSkillInput(); // 输入框里未回车的技能先入库,避免丢失 + const jobId = $("#match-job-select").value; + matchState.years = +$("#parse-years").value || 0; + try { + const m = await post("/api/match", { + job_id: jobId, skills: matchState.skills, years: matchState.years, + scenarios: [], + }); + matchState.result = m; + renderMatchResult(m); + setMatchStep(3); + } catch (e) { toast(e.message, "error"); } +}; +$("#btn-back-2").onclick = () => setMatchStep(1); +$("#btn-goto-path").onclick = () => { + switchView("path"); + $("#path-job-select").value = $("#match-job-select").value; + loadPathJob($("#match-job-select").value, matchState.skills).then(() => $("#btn-path").click()); +}; + +function renderMatchResult(m) { + drawGauge($("#gauge-canvas"), m.total_score); + $("#match-verdict").innerHTML = m.is_match + ? `✓ 达到匹配阈值 目标岗位:${esc(m.job_title)}` + : `未达匹配阈值(60 分) 目标岗位:${esc(m.job_title)}`; + drawRadar($("#radar-canvas"), m.dimension_scores); + const chip = (s, cls) => `${esc(s)}`; + $("#matched-chips").innerHTML = + [...m.matched_required.map((s) => chip(s, "tag-green")), ...m.matched_preferred.map((s) => chip(s, "tag-cyan"))].join("") || + `无命中(简历中未识别到本岗位所需技能)`; + $("#missing-chips").innerHTML = + [...m.missing_required.map((s) => chip(s, "tag-red")), ...m.missing_preferred.map((s) => chip(s, "tag-orange"))].join("") || + `无`; + $("#transfer-chips").innerHTML = m.transferable_skills.map((s) => chip(s, "tag-purple")).join("") || + `暂无可迁移技能`; + // 声明掌握但与本岗位无关的技能:显式告知"不计入评分",避免用户疑惑 + // (后端已做大小写/别名归一化,这里比较时统一小写,避免 redis/Redis 误判) + const counted = new Set([...(m.matched_required || []), ...(m.matched_preferred || []), + ...(m.transferable_skills || [])].map((s) => s.toLowerCase())); + const irrelevant = matchState.skills.filter((s) => !counted.has(s.toLowerCase())); + $("#irrelevant-block").classList.toggle("hidden", !irrelevant.length); + $("#irrelevant-chips").innerHTML = irrelevant.map((s) => chip(s, "tag-gray")).join(""); + renderResumeHighlight(m); + renderRecJobs(m); +} + +function skillClsMap(m) { + // 技能 → 高亮类别:命中必备=绿,命中加分=蓝,其他已识别=灰 + const clsOf = new Map(); + (m.matched_required || []).forEach((s) => clsOf.set(s.toLowerCase(), "hl-req")); + (m.matched_preferred || []).forEach((s) => clsOf.set(s.toLowerCase(), "hl-pref")); + matchState.skills.forEach((s) => { if (!clsOf.has(s.toLowerCase())) clsOf.set(s.toLowerCase(), "hl-other"); }); + return clsOf; +} + +function highlightHtml(text, clsOf) { + if (!clsOf.size) return esc(text); + // 单次扫描替换:按长度降序构造联合正则,避免嵌套匹配(如 Spring / Spring Boot) + const names = [...clsOf.keys()].sort((a, b) => b.length - a.length) + .map((s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")); + const re = new RegExp(names.join("|"), "gi"); + return esc(text).replace(re, (hit) => + `${hit}`); +} + +function renderResumeHighlight(m) { + const box = $("#resume-highlight"); + const p = lastProfile; + if (!p || !(p.raw_masked_text || "").trim()) { + box.innerHTML = `

无简历文本(本次以技能清单直接匹配)

`; + return; + } + const clsOf = skillClsMap(m); + const photo = p.photo_b64 + ? `证件照` + : `
无照片
`; + const kv = (k, v) => `${k}${esc(v || "—")}`; + const secs = (p.sections && p.sections.length) + ? p.sections : [{ title: "简历全文", text: p.raw_masked_text }]; + box.innerHTML = ` + + + + ${kv("姓名", p.name)}${kv("性别", p.gender)} + + ${kv("学历", p.education)}${kv("工作年限", p.years ? p.years + " 年" : "")} + ${kv("手机", p.phone)}${kv("邮箱", p.email)} + + ${secs.map((sec) => ` + + + + `).join("")} +
${photo}
家庭住址${esc(p.address || "—")}
${esc(sec.title)}${highlightHtml(sec.text, clsOf)}
`; +} + +$("#btn-export-resume").onclick = async () => { + if (!lastProfile) return toast("请先解析简历", "error"); + try { + const resp = await fetch("/api/resume/export", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ profile: lastProfile, match: matchState.result }), + }); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + const blob = await resp.blob(); + const a = document.createElement("a"); + a.href = URL.createObjectURL(blob); + a.download = `简历_${lastProfile.name || "脱敏"}_结构化.docx`; + a.click(); + URL.revokeObjectURL(a.href); + toast("规范简历 DOCX 已导出", "success"); + } catch (e) { toast(`导出失败:${e.message}`, "error"); } +}; + +async function renderRecJobs(current) { + const box = $("#rec-job-cards"); + box.innerHTML = `
计算中…
`; + const others = CN.jobs.filter((j) => j.job_id !== current.job_id).slice(0, 12); + const results = await Promise.all(others.map((j) => + post("/api/match", { job_id: j.job_id, skills: matchState.skills, years: matchState.years, scenarios: [] }) + .then((m) => ({ job: j, m })).catch(() => null))); + const top = results.filter(Boolean).sort((a, b) => b.m.total_score - a.m.total_score).slice(0, 3); + box.innerHTML = top.map(({ job, m }) => ` +
+
${esc(job.title)} ${esc(job.category || "")}
+
${m.total_score} 分
+
缺口:${m.missing_required.slice(0, 3).map(esc).join("、") || "无"}
+
`).join(""); + box.querySelectorAll(".rec-job-card").forEach((el) => { + el.onclick = () => { + $("#match-job-select").value = el.dataset.id; + $("#btn-run-match").click(); + }; + }); +} + +function drawGauge(canvas, score) { + const ctx = canvas.getContext("2d"); + ctx.clearRect(0, 0, 220, 150); + const cx = 110, cy = 120, R = 88; + ctx.lineWidth = 14; ctx.lineCap = "round"; + ctx.strokeStyle = "#f0f1f5"; + ctx.beginPath(); ctx.arc(cx, cy, R, Math.PI, 2 * Math.PI); ctx.stroke(); + const color = score >= 80 ? "#00b42a" : score >= 60 ? "#2f54eb" : "#ff7d00"; + ctx.strokeStyle = color; + ctx.beginPath(); ctx.arc(cx, cy, R, Math.PI, Math.PI * (1 + Math.min(100, score) / 100)); ctx.stroke(); + $("#gauge-score").textContent = Math.round(score); + $("#gauge-score").style.color = color; +} + +function drawRadar(canvas, dims) { + const ctx = canvas.getContext("2d"); + ctx.clearRect(0, 0, 340, 280); + const cx = 170, cy = 145, R = 100; + const labels = { required: "必备技能", preferred: "加分技能", experience: "经验年限", scenario: "场景经验" }; + const keys = Object.keys(labels); + const angle = (i) => -Math.PI / 2 + (i * 2 * Math.PI) / keys.length; + for (let ring = 1; ring <= 4; ring++) { + ctx.beginPath(); + keys.forEach((_, i) => { + const r = (R * ring) / 4; + ctx[i ? "lineTo" : "moveTo"](cx + r * Math.cos(angle(i)), cy + r * Math.sin(angle(i))); + }); + ctx.closePath(); ctx.strokeStyle = "#e5e6eb"; ctx.stroke(); + } + keys.forEach((_, i) => { + ctx.beginPath(); ctx.moveTo(cx, cy); + ctx.lineTo(cx + R * Math.cos(angle(i)), cy + R * Math.sin(angle(i))); + ctx.strokeStyle = "#f0f0f0"; ctx.stroke(); + }); + ctx.beginPath(); + keys.forEach((k, i) => { + const r = (R * (dims[k] || 0)) / 100; + ctx[i ? "lineTo" : "moveTo"](cx + r * Math.cos(angle(i)), cy + r * Math.sin(angle(i))); + }); + ctx.closePath(); + ctx.fillStyle = "rgba(47,84,235,0.18)"; ctx.fill(); + ctx.strokeStyle = "#2f54eb"; ctx.lineWidth = 2; ctx.stroke(); + keys.forEach((k, i) => { + const r = (R * (dims[k] || 0)) / 100; + ctx.fillStyle = "#2f54eb"; + ctx.beginPath(); ctx.arc(cx + r * Math.cos(angle(i)), cy + r * Math.sin(angle(i)), 3.5, 0, 7); ctx.fill(); + ctx.fillStyle = "#4e5969"; ctx.font = "12px sans-serif"; ctx.textAlign = "center"; + ctx.fillText(`${labels[k]} ${Math.round(dims[k] || 0)}`, + cx + (R + 36) * Math.cos(angle(i)), cy + (R + 22) * Math.sin(angle(i)) + 4); + }); +} + +/* ================= 批量匹配 ================= */ +const batchState = { files: [], result: null }; + +/* 子页签切换 */ +$$(".match-tab").forEach((b) => { + b.onclick = () => { + $$(".match-tab").forEach((x) => x.classList.toggle("active", x === b)); + $("#match-single").classList.toggle("hidden", b.dataset.mtab !== "single"); + $("#match-batch").classList.toggle("hidden", b.dataset.mtab !== "batch"); + }; +}); + +const _BATCH_EXTS = ["txt", "md", "docx", "pdf"]; +function batchAddFiles(fileList) { + let added = 0; + for (const f of fileList) { + const ext = (f.name.split(".").pop() || "").toLowerCase(); + if (!_BATCH_EXTS.includes(ext)) continue; + if (batchState.files.length >= 50) { toast("单批最多 50 份,超出部分已忽略", "error"); break; } + if (!batchState.files.some((x) => x.name === f.name && x.size === f.size)) { + batchState.files.push(f); added++; + } + } + if (added) renderBatchFileList(); + if (!added && fileList.length) toast("没有可添加的简历文件(支持 .txt/.docx/.pdf)", "error"); +} + +function renderBatchFileList() { + const box = $("#batch-filelist"); + box.innerHTML = batchState.files.length + ? batchState.files.map((f, i) => ` + ${esc(f.name)}${(f.size / 1024).toFixed(0)}KB + `).join("") + : ""; + box.querySelectorAll("button").forEach((b) => { + b.onclick = () => { batchState.files.splice(+b.dataset.i, 1); renderBatchFileList(); }; + }); +} + +$("#batch-files").addEventListener("change", (e) => { batchAddFiles(e.target.files); e.target.value = ""; }); +$("#batch-dir").addEventListener("change", (e) => { batchAddFiles(e.target.files); e.target.value = ""; }); + +/* 拖拽:文件直接收,文件夹递归遍历 */ +const drop = $("#batch-drop"); +["dragenter", "dragover"].forEach((ev) => drop.addEventListener(ev, (e) => { + e.preventDefault(); drop.classList.add("over"); +})); +["dragleave", "drop"].forEach((ev) => drop.addEventListener(ev, (e) => { + e.preventDefault(); drop.classList.remove("over"); +})); +drop.addEventListener("drop", async (e) => { + const items = Array.from(e.dataTransfer.items || []); + const entries = items.map((it) => it.webkitGetAsEntry && it.webkitGetAsEntry()).filter(Boolean); + if (!entries.length) { batchAddFiles(e.dataTransfer.files); return; } + const collected = []; + async function walk(entry) { + if (entry.isFile) { + await new Promise((res) => entry.file((f) => { collected.push(f); res(); })); + } else if (entry.isDirectory) { + const reader = entry.createReader(); + let batch; + do { + batch = await new Promise((res) => reader.readEntries(res)); + for (const ent of batch) await walk(ent); + } while (batch.length); + } + } + for (const ent of entries) await walk(ent); + batchAddFiles(collected); +}); + +$("#btn-batch-run").onclick = async () => { + if (!batchState.files.length) return toast("请先添加简历文件", "error"); + const box = $("#batch-result"); + box.innerHTML = `
批量解析与匹配中(${batchState.files.length} 份)…
`; + try { + const fd = new FormData(); + fd.append("job_id", $("#batch-job-select").value); + batchState.files.forEach((f) => fd.append("files", f, f.name)); + const resp = await fetch("/api/match/batch", { + method: "POST", body: fd, + headers: { "X-Access-Token": localStorage.getItem("cn_access_token") || "" }, + }); + const body = await resp.json(); + if (!resp.ok) throw new Error(body.detail || `HTTP ${resp.status}`); + batchState.result = body.data; + renderBatchResult(body.data); + } catch (e2) { + box.innerHTML = `
批量匹配失败:${esc(e2.message)}
`; + } +}; + +function renderBatchResult(data) { + $("#btn-batch-export").classList.remove("hidden"); + const rows = data.results.map((r, i) => { + if (r.error) { + return `${i + 1}${esc(r.filename)} + ${esc(r.error)}`; + } + const pct = Math.round(r.total_score); + return ` + ${i + 1} + ${esc(r.name)}
${esc(r.filename)}
+
${r.total_score}
+ ${r.years || 0} 年 + ${(r.matched || []).slice(0, 4).map(esc).join("、") || "—"} + ${(r.missing || []).slice(0, 4).map(esc).join("、") || "—"} + ${r.is_match ? '达标' : '未达标'} + `; + }).join(""); + $("#batch-result").innerHTML = ` +
共 ${data.total} 份,成功匹配 ${data.ok} 份,目标岗位:${esc(data.job_title)}(按总分降序)
+ + + ${rows} +
#候选人总分年限命中技能关键缺口结论
`; +} + +$("#btn-batch-export").onclick = () => { + const data = batchState.result; + if (!data) return; + const head = ["排名", "文件名", "姓名", "总分", "工作年限", "技能数", + "必备技能分", "加分技能分", "经验分", "场景分", "命中技能", "缺失技能", "是否达标", "备注"]; + const lines = [head.join(",")]; + data.results.forEach((r, i) => { + const cell = (v) => `"${String(v ?? "").replace(/"/g, '""')}"`; + if (r.error) { + lines.push([i + 1, cell(r.filename), "", "", "", "", "", "", "", "", "", "", "", cell(r.error)].join(",")); + } else { + const d = r.dimension_scores || {}; + lines.push([i + 1, cell(r.filename), cell(r.name), r.total_score, r.years || 0, r.skills_count || 0, + d.required ?? 0, d.preferred ?? 0, d.experience ?? 0, d.scenario ?? 0, + cell((r.matched || []).join("、")), cell((r.missing || []).join("、")), + r.is_match ? "达标" : "未达标", ""].join(",")); + } + }); + const blob = new Blob(["" + lines.join("\r\n")], { type: "text/csv;charset=utf-8" }); + const a = document.createElement("a"); + a.href = URL.createObjectURL(blob); + a.download = `批量匹配结果_${data.job_title}_${new Date().toISOString().slice(0, 10)}.csv`; + a.click(); + URL.revokeObjectURL(a.href); + toast("CSV 已导出", "success"); +}; + +/* ================= 学习路径 ================= */ +const RESOURCE_TYPES = ["官方文档", "实战项目", "在线课程", "技术社区"]; +let lastPathData = null; +const pathState = { jobId: null, known: new Set(), weeks: 8, done: new Set(), required: [], preferred: [] }; + +function pathDoneKey(jobId) { return `cn_path_done_${jobId}`; } + +async function loadPathJob(jobId, presetKnown) { + if (!jobId) return; + pathState.jobId = jobId; + pathState.known = new Set(presetKnown || []); + try { pathState.done = new Set(JSON.parse(localStorage.getItem(pathDoneKey(jobId)) || "[]")); } + catch { pathState.done = new Set(); } + const pool = $("#path-skill-pool"); + pool.innerHTML = `
加载技能清单…
`; + try { + const d = await api(`/api/jobs/${encodeURIComponent(jobId)}`); + const def = d.definition || d; + pathState.required = def.required_skills || []; + pathState.preferred = def.preferred_skills || []; + renderSkillPool(); + updatePathSummary(); + } catch (e) { pool.innerHTML = `
加载失败:${esc(e.message)}
`; } +} + +function renderSkillPool() { + const pool = $("#path-skill-pool"); + const group = (title, skills) => ` +
+
${title}(${skills.length})
+
${skills.map((s) => + `${esc(s)}`).join("")}
+
`; + pool.innerHTML = group("必备技能", pathState.required) + group("加分技能", pathState.preferred); + pool.querySelectorAll(".pool-chip").forEach((el) => { + el.onclick = () => { + const s = el.dataset.skill; + if (pathState.known.has(s)) pathState.known.delete(s); else pathState.known.add(s); + el.classList.toggle("known"); + updatePathSummary(); + }; + }); +} + +function updatePathSummary() { + const req = pathState.required; + const knownReq = req.filter((s) => pathState.known.has(s)); + const knownAll = [...pathState.known].filter((s) => req.includes(s) || pathState.preferred.includes(s)); + const cov = req.length ? Math.round((knownReq.length / req.length) * 100) : 0; + $("#ps-known").textContent = `${knownAll.length} 项`; + $("#ps-gap").textContent = `${req.length - knownReq.length} 项必备`; + $("#ps-bar").style.width = cov + "%"; + $("#ps-tip").textContent = `必备技能覆盖率 ${cov}%`; +} + +$("#path-weeks").querySelectorAll(".seg-item").forEach((b) => { + b.onclick = () => { + $("#path-weeks").querySelectorAll(".seg-item").forEach((x) => x.classList.remove("active")); + b.classList.add("active"); + pathState.weeks = parseInt(b.dataset.weeks, 10); + }; +}); +$("#path-job-select").addEventListener("change", () => loadPathJob($("#path-job-select").value)); + +$("#btn-path").onclick = async () => { + const jobId = $("#path-job-select").value; + if (jobId !== pathState.jobId) await loadPathJob(jobId); + try { + const data = await post("/api/learning-path", { job_id: jobId, known_skills: [...pathState.known] }); + lastPathData = data; + renderPath(data); + } catch (e) { toast(e.message, "error"); } +}; + +function stageWeeks(n) { + const base = Math.max(1, Math.round(pathState.weeks / Math.max(1, n))); + return Array.from({ length: n }, () => base); +} + +function updatePathOverview() { + if (!lastPathData || !lastPathData.learning_path.length) return; + const n = lastPathData.learning_path.length; + const gaps = lastPathData.learning_path.reduce((a, st) => a + st.skills.length, 0); + const done = pathState.done.size; + const pct = n ? Math.round((done / n) * 100) : 0; + $("#path-overview").innerHTML = ` +
目标岗位${esc(lastPathData.title)}
+
待补技能${gaps} 项
+
阶段${n} 个
+
预计周期${pathState.weeks} 周
+
+
+
学习记录:已完成 ${done}/${n} 阶段(${pct}%)
+
`; +} + +function renderPath(data) { + const box = $("#path-result"); + $("#btn-export-path").classList.toggle("hidden", !data.learning_path.length); + $("#path-overview").classList.toggle("hidden", !data.learning_path.length); + if (!data.learning_path.length) { + box.innerHTML = `
✓ 已覆盖「${esc(data.title)}」全部必备技能,无需补课
`; + return; + } + const weeks = stageWeeks(data.learning_path.length); + box.innerHTML = data.learning_path.map((st, i) => { + const done = pathState.done.has(st.stage); + return ` +
+ ${done ? "✓" : st.stage} +
+
+ 阶段 ${st.stage} · ${st.skills.length} 项技能 + 建议 ${weeks[i]} 周 +
+ ${i > 0 ? `
前置:建议完成阶段 ${st.stage - 1} 后开始(按技能前置关系拓扑排序)
` : ""} +
${st.skills.map((s) => + `${esc(s)}`).join("")}
+
+ 资源类型:${RESOURCE_TYPES[i % RESOURCE_TYPES.length]} + 技能性质:必备补齐 + ${st.acceptance ? `能力类别:${esc(st.acceptance.category)}` : ""} +
+ ${st.acceptance ? ` +
+ 验收标准(产出物 1 项 · 量化验收 ${st.acceptance.criteria.length} 项 · 自测 ${st.acceptance.self_check.length} 问) +
+
产出物
+
${esc(st.acceptance.deliverable)}
+
量化验收项
+
    ${st.acceptance.criteria.map((c, k) => + `
  • ${k + 1}${esc(c)}
  • `).join("")}
+
自测问答(能口头讲清才算通过)
+
    ${st.acceptance.self_check.map((q, k) => + `
  • Q${k + 1}${esc(q)}
  • `).join("")}
+
+
` : ""} +
+ +
+
+
`; + }).join(""); + box.querySelectorAll("[data-skill]").forEach((el) => { + el.onclick = () => { switchView("graph"); if (CN.gotoNode) CN.gotoNode(el.dataset.skill); }; + }); + box.querySelectorAll(".stage-check").forEach((el) => { + el.onclick = () => { + const st = parseInt(el.dataset.stage, 10); + if (pathState.done.has(st)) pathState.done.delete(st); else pathState.done.add(st); + localStorage.setItem(pathDoneKey(pathState.jobId), JSON.stringify([...pathState.done])); + renderPath(lastPathData); + }; + }); + updatePathOverview(); +} + +$("#btn-export-path").onclick = () => { + if (!lastPathData) return; + const lines = [`CareerNexus 学习路径 · 目标岗位:${lastPathData.title}`, ""]; + lastPathData.learning_path.forEach((st) => { + lines.push(`阶段 ${st.stage}:${st.skills.join("、")}`); + if (st.acceptance) { + lines.push(` 产出物:${st.acceptance.deliverable}`); + lines.push(` 量化验收:`); + st.acceptance.criteria.forEach((c, k) => lines.push(` ${k + 1}. ${c}`)); + lines.push(` 自测问答:`); + st.acceptance.self_check.forEach((q, k) => lines.push(` Q${k + 1}. ${q}`)); + } + }); + const blob = new Blob([lines.join("\n")], { type: "text/plain;charset=utf-8" }); + const a = document.createElement("a"); + a.href = URL.createObjectURL(blob); + a.download = `学习路径_${lastPathData.title}.txt`; + a.click(); + URL.revokeObjectURL(a.href); + toast("已导出文本", "success"); +}; + +/* ================= 系统设置 / 状态横幅 ================= */ +let lastHealth = null; // 最近一次 health 响应(横幅状态源) +function updateBanners(health) { + if (health) lastHealth = health; + const h = lastHealth || {}; + const showOffline = !!h.offline_fallback + && sessionStorage.getItem("cn_banner_dismissed") !== "1"; + const embed = h.embed || {}; + const showEmbed = !embed.ready + && sessionStorage.getItem("cn_banner2_dismissed") !== "1"; + $("#offline-banner").classList.toggle("hidden", !showOffline); + $("#embed-banner").classList.toggle("hidden", !showEmbed); + const n = (showOffline ? 1 : 0) + (showEmbed ? 1 : 0); + document.body.classList.toggle("has-banner", n === 1); + document.body.classList.toggle("has-banner2", n === 2); +} +function dismissBanner(id, key) { + sessionStorage.setItem(key, "1"); + updateBanners(); +} +$("#banner-close").onclick = () => dismissBanner("offline-banner", "cn_banner_dismissed"); +$("#embed-banner-close").onclick = () => dismissBanner("embed-banner", "cn_banner2_dismissed"); +$("#banner-config").onclick = () => openConfigModal(false); +$("#embed-banner-config").onclick = () => openConfigModal(false); + +function embedStatusHtml(emb) { + if (emb.ready) { + return `已就绪(${emb.provider === "api" ? "联网 API" : "本地权重"},语义召回已启用)`; + } + if (emb.provider === "api") { + return emb.api_key_masked + ? `已配置 Key,保存后生效` + : `未配置 API Key(当前使用词表归一化匹配)`; + } + if (emb.configured) { + const reasons = [ + !emb.weights_present ? "权重文件缺失" : "", + !emb.deps_installed ? "未安装 sentence-transformers" : "", + ].filter(Boolean).join(";"); + return `已配置未就绪:${esc(reasons)}`; + } + return `未配置(当前使用词表归一化匹配)`; +} + +async function openConfigModal(needToken, firstRun) { + let cfg; + try { cfg = await api("/api/config"); } + catch (e) { toast(e.message, "error"); return; } + $("#modal-box").classList.add("wide"); + const modeBadge = cfg.offline_fallback + ? `离线回退` : `联网就绪`; + openModal(firstRun ? "欢迎使用 CareerNexus 职联 · 初始设置" : "系统设置", ` + ${firstRun ? `
+ 首次使用,建议先完成基础配置(均可稍后通过右上角 ⚙ 修改): +
· 配置 LLM API 密钥可启用大模型抽取(不配则离线确定性方案兜底) +
· 配置语义向量模型(本地 BGE-M3 或联网 API)可启用语义相似匹配 +
· 需要对外提供服务时,再调整监听地址与访问令牌 +
` : ""} +
+ 当前监听 ${esc(cfg.running.host)}:${cfg.running.port} + LLM 状态 ${modeBadge} + ${cfg.restart_required ? `监听变更待重启` : ""} +
+ ${needToken ? `
该服务已启用访问令牌,请填写后保存。
` : ""} +
+
服务监听(修改后需重启生效)
+
+ +
+ + +
+
+
+ + +
+
+ +
+ + + +
+
+
+
+
LLM API(星火 / OpenAI 兼容,保存即热生效)
+
+ + +
+
+ + +
+
+ + +
+
+
+
语义向量匹配(可选增强,保存即热生效)
+
+ +
${embedStatusHtml(cfg.embed)}
+
+
+ +
+ + +
+
+
+
+ +
+ 自动完成全部配置,推荐首次使用: +
① 安装 sentence-transformers 依赖(PyPI 镜像站) +
② 从 ModelScope 镜像站下载 BGE-M3 权重(约 2.2GB) +
③ 自动写入配置并启用语义匹配。全程约 3–8 分钟,期间可继续使用其他功能。 +
+ +
+
+ + +
+
+ 手动配置方式(不使用一键配置时) +
    +
  1. 安装依赖:pip install sentence-transformers
  2. +
  3. 下载权重(约 2.2GB):ModelScope(国内推荐)modelscope.cn/models/BAAI/bge-m3 或 HuggingFace huggingface.co/BAAI/bge-m3
  4. +
  5. 目录内需包含 config.json、model.safetensors 与分词器文件,填入上方路径保存即可
  6. +
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ 联网 API 配置引导 +
    +
  1. 开通阿里云百炼(DashScope),在控制台获取 API Key
  2. +
  3. 默认接口为 OpenAI 兼容模式,模型推荐 text-embedding-v4
  4. +
  5. 填入 Key 保存即生效;网络不可达时自动回退词表归一化
  6. +
+
+
+
`, + `${firstRun ? `` : ""} + + `); + + let host = cfg.saved.host; + $$("#cfg-host .seg-item").forEach((b) => { + b.onclick = () => { + $$("#cfg-host .seg-item").forEach((x) => x.classList.remove("active")); + b.classList.add("active"); + host = b.dataset.host; + }; + }); + let embedProvider = cfg.embed.provider === "api" ? "api" : "local"; + $$("#cfg-embed-provider .seg-item").forEach((b) => { + b.onclick = () => { + $$("#cfg-embed-provider .seg-item").forEach((x) => x.classList.remove("active")); + b.classList.add("active"); + embedProvider = b.dataset.provider; + $("#cfg-embed-local").classList.toggle("hidden", embedProvider !== "local"); + $("#cfg-embed-api").classList.toggle("hidden", embedProvider !== "api"); + }; + }); + let tokenDirty = false; // 只有用户真正改过令牌框才提交(空串也提交 = 清除) + $("#cfg-token").addEventListener("input", () => { tokenDirty = true; }); + $("#cfg-token-gen").onclick = () => { + const t = Array.from(crypto.getRandomValues(new Uint8Array(16))).map((x) => x.toString(16).padStart(2, "0")).join(""); + $("#cfg-token").value = t; + $("#cfg-token").type = "text"; + tokenDirty = true; + }; + $("#cfg-token-show").onclick = () => { + const i = $("#cfg-token"); + i.type = i.type === "password" ? "text" : "password"; + }; + + async function doSave() { + const payload = { + host, port: parseInt($("#cfg-port").value, 10), + llm_base_url: $("#cfg-llm-url").value.trim(), + llm_model: $("#cfg-llm-model").value.trim(), + embed_provider: embedProvider, + embed_model_path: $("#cfg-embed-path").value.trim(), + embed_api_base_url: $("#cfg-embed-base").value.trim(), + embed_api_model: $("#cfg-embed-model").value.trim(), + guided: true, // 保存即视为完成初始设置 + }; + const tok = $("#cfg-token").value; + if (tokenDirty) payload.access_token = tok.trim(); // dirty 才提交;空串 = 清除令牌 + const key = $("#cfg-llm-key").value; + if (key) payload.llm_api_key = key.trim(); + const ekey = $("#cfg-embed-key").value; + if (ekey) payload.embed_api_key = ekey.trim(); + const saved = await post("/api/config", payload); + if (tokenDirty) { // 保存成功后同步 localStorage,未动过令牌框则不触碰 + if (payload.access_token) localStorage.setItem("cn_access_token", payload.access_token); + else localStorage.removeItem("cn_access_token"); + } + lastHealth = Object.assign({}, lastHealth, { + offline_fallback: saved.offline_fallback, embed: saved.embed, + }); + updateBanners(); + return saved; + } + $("#cfg-save").onclick = async () => { + try { + const saved = await doSave(); + closeModal(); + toast(saved.restart_required ? "已保存;监听地址/端口需重启后生效" : "设置已保存并热生效", "success"); + } catch (e) { toast(e.message, "error"); } + }; + $("#cfg-save-restart").onclick = async () => { + try { + await doSave(); + await post("/api/config/restart", {}); + closeModal(); + toast("服务重启中,5 秒后自动刷新…", "info"); + setTimeout(() => location.reload(), 5000); + } catch (e) { toast(e.message, "error"); } + }; + if (firstRun) { + $("#cfg-skip").onclick = async () => { + try { await post("/api/config", { guided: true }); } catch { /* 跳过标记失败不阻塞 */ } + closeModal(); + toast("已跳过初始设置,当前为离线演示模式", "info"); + }; + } + + /* 一键下载并配置语义环境(异步任务 + 轮询进度) */ + const STAGE_NAMES = { deps: "① 安装 Python 依赖", download: "② 下载 BGE-M3 权重", + config: "③ 写入配置", done: "✓ 配置完成", start: "准备中…", error: "✗ 失败" }; + $("#cfg-embed-setup").onclick = async () => { + const btn = $("#cfg-embed-setup"); + btn.disabled = true; + $("#setup-progress").classList.remove("hidden"); + try { await post("/api/semantic/setup", {}); } + catch (e) { toast(e.message, "error"); btn.disabled = false; return; } + const timer = setInterval(async () => { + try { + const st = await api("/api/semantic/setup/status"); + $("#setup-stage").textContent = STAGE_NAMES[st.stage] || st.stage || "进行中…"; + $("#setup-log").textContent = (st.log || []).slice(-3).join("\n"); + if (!st.running) { + clearInterval(timer); + btn.disabled = false; + if (st.done || (st.embed && st.embed.ready)) { + toast("语义向量通道已就绪", "success"); + closeModal(); + openConfigModal(false); // 重新打开刷新状态 + lastHealth = Object.assign({}, lastHealth, { embed: st.embed }); + updateBanners(); + } else { + $("#setup-stage").textContent = "✗ 配置失败:" + (st.error || "未知原因"); + toast("语义环境配置失败:" + (st.error || ""), "error"); + } + } + } catch { /* 轮询失败继续等 */ } + }, 2000); + }; +} +$("#btn-config").onclick = () => openConfigModal(false); + +/* ================= 启动 ================= */ +window.addEventListener("resize", () => { + if (CN._trend) drawTrend($("#trend-canvas"), CN._trend); + if (CN._heat) drawHeatLine($("#evo-heat-canvas"), CN._heat); +}); +(async function boot() { + try { + CN.jobs = await api("/api/jobs"); + const opts = CN.jobs.map((j) => ``).join(""); + ["#evo-job-select", "#match-job-select", "#path-job-select", "#batch-job-select"].forEach((s) => { $(s).innerHTML = opts; }); + await loadDashboard(); // 先加载图谱缓存(演化页甘特图依赖) + await loadEvolution(); + renderEvoHeatmap(); + /* 分享链接:#view=graph&node=xxx */ + const hash = new URLSearchParams(location.hash.slice(1)); + if (hash.get("view")) switchView(hash.get("view")); + if (hash.get("node") && CN.gotoNode) CN.gotoNode(hash.get("node")); + /* 首次使用:引导完成基础设置(完成标记记录在服务端 config.json) */ + if (!lastHealth || !lastHealth.guided) openConfigModal(false, true); + } catch (e) { + toast("后端连接失败:" + e.message, "error"); + } +})(); diff --git a/webui/graph.js b/webui/graph.js new file mode 100644 index 0000000..0634ac7 --- /dev/null +++ b/webui/graph.js @@ -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 = `` + + cats.map((c) => ``).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) => ` +
+ +
${it.text}
${it.sub}
+ ${it.kind === "pending" ? `去审核 →` : ""}
+
`).join("") : `
暂无建议
`; + $$("#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 = `—`; + 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) => + `${esc(s.skill)}${s.exit ? " ✕" : ""}` + ).join(`→`); + /* 旁列待审核关系 */ + const pendRels = G.edges.filter((e) => e.pending && e.source === jobId); + if (pendRels.length) { + html += `|待审核:` + pendRels.map((e) => + `⏳ ${esc(e.target)}`).join(""); + } + chips.innerHTML = html || `该岗位暂无演化记录`; + 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: '', + Skill: '', + Industry: '', + Scenario: '', + }; + + 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]) => ` +
关联${TYPE_META[t].name}
+
${l.slice(0, 12).map((r) => ` +
+ ${TYPE_META[t].name} + ${esc(r.title)} + ${r.imp ? `${r.imp === "required" ? "必备" : "加分"}${r.pending ? "·待审核" : ""}` : ""} +
`).join("")} + ${l.length > 12 ? `
…共 ${l.length} 项
` : ""} +
`).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 = ` +
+
+ ${NODE_ICONS[n.type] || NODE_ICONS.Job} +
+
${esc(n.title || n.id)}
+
+ ${meta.name} + ${isPending ? `待审核` : `正式入库`} +
+
+
+ +
+ ${G.navStack.length ? `` : ""} + +
基础信息
+ ${kv.map(([k, v]) => `
${k}${esc(v)}
`).join("") || `
暂无属性
`} +
+ + ${relHtml ? `
关联信息(点击跳转)
${relHtml}
` : ""} + + ${n.type === "Skill" ? ` +
演化信息
+
+
近 12 个季度热度(活跃岗位关系数)
+ ${skillEvents.map((e) => `
+ ${e.change_type} + ${esc(e.job_id.replace(/^job_/, ""))}${esc(e.window)}
`).join("")} +
` : ""} + +
证据来源(支撑 JD,点击查看原文)
+ ${srcList.length ? srcList.slice(0, 10).map((s) => ` +
+ ${esc(s)}
`).join("") + + (srcList.length > 10 ? `
…共 ${srcList.length} 条
` : "") + : `
暂无来源记录
`} +
+ +
操作
+
+ ${isPending ? ` + + ` : ` + + `} + + ${n.type === "Job" && !isPending ? ` + ` : ""} +
+
+ +
备注(本地保存)
+
${notes.map((t, i) => ` +
${esc(t.time)} +
${esc(t.text)}
`).join("") || + `
暂无备注
`}
+
+ +
审核留痕
+ ${audit.length ? audit.map((r) => ` +
+
${esc(r.operator || "—")}${esc(r.time || "")}
+
${auditDiff(r)}${r.reason ? `
原因:${esc(r.reason)}` : ""}
+
`).join("") : `
暂无留痕记录
`} +
`; + + /* 绑定 */ + $("#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("
") || "—"; + } + + 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)}`, ` +
+
+
+
+
+
+
+
+
+
+
+
`, + ` + `); + $("#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); + }, + }; +})(); diff --git a/webui/index.html b/webui/index.html new file mode 100644 index 0000000..41ffce4 --- /dev/null +++ b/webui/index.html @@ -0,0 +1,511 @@ + + + + + + CareerNexus 职联 · 岗位与能力图谱平台 + + + + +
+
+ + CareerNexus 职联 +
+
数据驾驶舱
+
+ + 数据版本 — + + 演 +
+
+ + + + + + + + + +
+ +
+ + +
+
+
+
+
+

近 12 个季度演化事件趋势

+ 按事件窗口统计 +
+
+
+
+
+

任务与审核状态

+ +
+
+ + + +
类型对象操作人时间结果
+ +
+
+
+
+
+

动态消息流

点击可跳转
+
+
+
+

待审核新岗位候选

0 待办
+
+
+
+
+ + +
+
+
+

岗位能力图谱工作台

+ 演示模式 + 技能点级 + 审核已开启 +
+
+ + + +
+
+
+
+ + + +
+ + + + + + + +
+ + + + + +
+ +
+ 岗位 + 技能 + 场景 + 待审核 + 必备 + 加分 +
+
+ + +
+
+ + 智能建议 + +
+
+
+ + +
+
演化逻辑链 选择岗位节点后展示能力演化路径
+
—
+
+ +
+
+
图谱布局计算中…
+
+
+ + + +
+
+ + +
+
+
+

岗位能力演化分析

+
+ +
+ + + +
+
+
+
+ +
全部岗位演化热力图(每格 = 该季度演化事件数,点击行切换岗位)
+
加载中…
+ + +
+ + +
+
+ 能力画像时点对比 +
+ + → + + +
+
+
+
+ + +
变更类型构成(每季度 新增/增强/减弱/退出)
+
+
+ + +
技能生命周期(横条 = 技能在画像中的生效区间,圆点 = 变更事件)
+
+ + +
技能热度走势(活跃技能关系数)
+
+ + +
演化事件时间轴(点击查看证据 JD)
+
+
+
+
+ + +
+
+
+
+ + +
+
+
+
1简历录入
+
2解析确认
+
3诊断结果
+
+ + +
+
+ + +
+
+ + +
+
+ + + + 支持 .txt / .docx / .pdf,≤5MB + +
+
+ + + + + + +
+ + + +
+
+
+ + +
+
+ +
+

路径配置

+
+
+ + +
+
+ +
+
选择岗位后加载技能清单…
+
+
+
+ +
+ + + +
+
+
+
已掌握0 项
+
待补齐—
+
+
覆盖率 0%
+
+ +
+
+ +
+
+

分阶段学习路线

+ +
+
+ +
+
+ +

在左侧选择目标岗位并标记已掌握技能
点击「生成学习路径」查看分阶段路线

+
+
+
+
+
+
+ +
如遇问题,请联系 support@rrrosmontis.icu
+
+
+ + + +
+ + + + + diff --git a/webui/style.css b/webui/style.css new file mode 100644 index 0000000..bbab9e8 --- /dev/null +++ b/webui/style.css @@ -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; +}