CareerNexus 职联:岗位能力图谱构建与动态演化分析平台(挑战杯 XH-202621)

- 全链路:多源JD接入/清洗交叉验证/结构化抽取(星火+离线双通道)/时序知识图谱/新岗位发现/演化分析/人岗匹配/学习路径
- WebUI 五视图:驾驶舱/图谱工作台/岗位演化/匹配(单人+批量)/学习路径
- 设置中心(config.json)/Token鉴权/SQLite状态库/语义向量通道(BGE-M3本地或联网API)
- 71 个 pytest 用例;数据目录仅保留结构(.gitkeep),数据由 scripts/generate_dataset.py 复现
This commit is contained in:
RoyirinRosmontis
2026-08-03 13:15:35 +08:00
commit a10442d78c
78 changed files with 12860 additions and 0 deletions
View File
+812
View File
@@ -0,0 +1,812 @@
"""FastAPI 接口层:统一响应 {code, message, data, request_id}。
- 耗时任务(JD 导入/全链路 pipeline)使用 任务ID + 轮询;
- 托管 webui 静态文件(/ -> webui/index.html);
- 服务启动时若 data/graph.json 已存在则直接加载图谱,否则等首次导入。
"""
from __future__ import annotations
import logging
import threading
import uuid
from pathlib import Path
from urllib.parse import quote
import json
from fastapi import FastAPI, File, Form, HTTPException, Request, Response, UploadFile
from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, ValidationError
from app.config import ROOT, load_config, load_runtime_settings, resolve, save_runtime_settings
from app.discover.cluster import apply_review, discover_new_jobs, load_pending, save_pending
from app.extract.base import SkillMatcher, load_lexicon
from app.extract.llm import llm_ready
from app.graph.tkg import TemporalKG, now_str
from app.ingest.loader import load_jd_dir
from app.match import semantic
from app.match.engine import MatchEngine
from app.match.resume import parse_resume, parse_resume_file
from app.models import JDRecord, ResumeProfile
from app.services.pipeline import run_pipeline
from app.storage import db as state_db
logger = logging.getLogger(__name__)
app = FastAPI(title="CareerNexus 职联", version="0.1.0")
_cfg = load_config()
_state: dict = {
"kg": None,
"records": None,
"extractions": None,
"tasks": {},
}
def _ok(data=None, message: str = "ok") -> dict:
return {"code": 0, "message": message, "data": data,
"request_id": uuid.uuid4().hex[:12]}
def _get_kg() -> TemporalKG:
kg = _state["kg"]
if kg is None:
raise HTTPException(status_code=503, detail="图谱尚未构建,请先调用 /api/jd/import")
return kg
def _db_path() -> Path:
"""SQLite 应用状态库路径(配置键 state_db_path,默认 data/careernexus.db)。"""
return resolve(_cfg, "state_db_path")
@app.on_event("startup")
def _startup() -> None:
graph_path = resolve(_cfg, "graph_path")
if graph_path.exists():
_state["kg"] = TemporalKG.load(graph_path)
logger.info("已加载图谱:%s", _state["kg"].stats())
else:
logger.warning("图谱文件不存在(%s),请先执行 pipeline 或 /api/jd/import", graph_path)
# SQLite 状态库:幂等建表 + 旧 JSONL/JSON 一次性迁移(原文件保留)
state_db.connect(
_db_path(),
legacy_audit=resolve(_cfg, "audit_path"),
legacy_pending=resolve(_cfg, "pending_path"),
).close()
# 重启后从 tasks 表恢复任务清单;中断的任务标记为失败
for row in state_db.list_tasks(_db_path()):
payload = row["payload"]
if payload.get("status") in ("pending", "running"):
payload = {**payload, "status": "failed", "error": "服务重启,任务中断"}
_state["tasks"][row["task_id"]] = payload
# ---------- 健康与图谱 ----------
@app.get("/api/health")
def health() -> dict:
kg = _state["kg"]
ready = llm_ready(_cfg)
return _ok({"status": "ok", "graph": kg.stats() if kg else None,
"llm_ready": ready, "offline_fallback": not ready,
"embed": semantic.status(_cfg),
"guided": bool(_cfg.get("guided")),
"mode": "online" if ready else "offline"})
# ---------- 静态资源缓存控制(避免浏览器用旧版前端) ----------
@app.middleware("http")
async def static_no_cache(request, call_next):
resp = await call_next(request)
if not request.url.path.startswith("/api/"):
resp.headers["Cache-Control"] = "no-cache" # 每次加载都回源校验(ETag 命中仍 304,成本低)
return resp
# ---------- 访问令牌鉴权 ----------
@app.middleware("http")
async def access_token_guard(request, call_next):
"""设置 access_token 后,/api/*(health 除外)必须携带 X-Access-Token 头。
仅在用户显式配置令牌时启用;默认无令牌、本地使用无感知。
"""
token = str(_cfg.get("access_token") or "").strip()
path = request.url.path
# 豁免:health 探活、GET /api/config(只读脱敏值,打开设置面板需要)
exempt = path == "/api/health" or (path == "/api/config" and request.method == "GET")
if token and path.startswith("/api/") and not exempt:
if request.headers.get("x-access-token", "") != token:
return JSONResponse({"detail": "未授权:缺少或错误的访问令牌"}, status_code=401)
return await call_next(request)
# ---------- 语义环境一键配置(按需下载,基础镜像不打包重依赖) ----------
_semantic_setup: dict = {"running": False, "stage": "", "log": [], "error": "", "done": False}
_semantic_setup_lock = threading.Lock()
def _setup_log(msg: str) -> None:
_semantic_setup["log"].append(msg)
_semantic_setup["log"] = _semantic_setup["log"][-20:]
logger.info("语义环境配置: %s", msg)
def _run_semantic_setup() -> None:
"""后台执行:装依赖 → 下载权重 → 写入配置并热生效。任一阶段失败记 error。"""
import importlib
import subprocess
import sys
weights_dir = ROOT / "models" / "bge-m3"
try:
# 已就绪则直接写配置收尾(幂等)
if not semantic.status(_cfg)["ready"]:
_semantic_setup["stage"] = "deps"
_setup_log("安装 Python 依赖 sentence-transformers / modelscope(镜像站)…")
pip = [sys.executable, "-m", "pip", "install", "-q",
"sentence-transformers", "modelscope",
"-i", "https://pypi.tuna.tsinghua.edu.cn/simple"]
proc = subprocess.run(pip, capture_output=True, text=True, timeout=1800)
if proc.returncode != 0: # 镜像站失败退回官方源
_setup_log("镜像站安装失败,改用官方源重试…")
proc = subprocess.run(pip[:-2], capture_output=True, text=True, timeout=1800)
if proc.returncode != 0:
raise RuntimeError(f"依赖安装失败:{(proc.stderr or proc.stdout)[-300:]}")
importlib.invalidate_caches()
_setup_log("依赖安装完成")
if not semantic.weights_present(weights_dir):
_semantic_setup["stage"] = "download"
_setup_log("从 ModelScope 下载 BGE-M3 权重(约 2.2GB,数分钟)…")
from modelscope import snapshot_download
weights_dir.parent.mkdir(parents=True, exist_ok=True)
snapshot_download("BAAI/bge-m3", local_dir=str(weights_dir))
_setup_log("权重下载完成")
else:
_setup_log("检测到已有权重,跳过下载")
_semantic_setup["stage"] = "config"
save_runtime_settings({"embed_provider": "local",
"embed_model_path": str(weights_dir)})
_cfg["embed_provider"] = "local"
_cfg["embed_model_path"] = str(weights_dir)
ready = semantic.status(_cfg)["ready"]
_semantic_setup["done"] = ready
_semantic_setup["stage"] = "done" if ready else "error"
if not ready:
_semantic_setup["error"] = "配置已写入但就绪检测未通过,请检查依赖与权重文件"
_setup_log("配置完成" if ready else "就绪检测未通过")
except Exception as exc: # noqa: BLE001 - 任何失败都要落到状态里供前端展示
_semantic_setup["error"] = str(exc)
_semantic_setup["stage"] = "error"
_setup_log(f"失败:{exc}")
finally:
_semantic_setup["running"] = False
@app.post("/api/semantic/setup")
def semantic_setup() -> dict:
"""启动语义环境一键配置(异步)。已运行中则返回当前状态。"""
with _semantic_setup_lock:
if not _semantic_setup["running"]:
_semantic_setup.update({"running": True, "stage": "start",
"log": [], "error": "", "done": False})
threading.Thread(target=_run_semantic_setup, daemon=True).start()
return _ok({"started": True})
@app.get("/api/semantic/setup/status")
def semantic_setup_status() -> dict:
return _ok({** _semantic_setup, "embed": semantic.status(_cfg)})
# ---------- 运行时设置(config.json) ----------
def _mask(secret: str) -> str:
s = (secret or "").strip()
return ("••••" + s[-4:]) if s else ""
class ConfigBody(BaseModel):
host: str | None = None # 127.0.0.1 | 0.0.0.0
port: int | None = None
access_token: str | None = None # 空串 = 清除令牌
llm_api_key: str | None = None
llm_base_url: str | None = None
llm_model: str | None = None
embed_model_path: str | None = None # BGE-M3 本地权重目录,空串 = 清除
embed_provider: str | None = None # local | api
embed_api_key: str | None = None # 联网 Embedding API 密钥(如 DashScope)
embed_api_base_url: str | None = None
embed_api_model: str | None = None
guided: bool | None = None # 初始设置引导完成标记
mode: str | None = None # demo | live
def _public_config() -> dict:
saved = load_runtime_settings()
ready = llm_ready(_cfg)
running = {"host": str(_cfg.get("host")), "port": int(_cfg.get("port", 9008))}
saved_hp = {"host": saved.get("host", running["host"]), "port": int(saved.get("port", running["port"]))}
return {
"running": running, # 当前进程实际监听
"saved": saved_hp, # config.json 中保存的监听(重启后生效)
"restart_required": saved_hp != running,
"has_access_token": bool(str(_cfg.get("access_token") or "").strip()),
"access_token_masked": _mask(str(_cfg.get("access_token") or "")),
"llm": {
"configured": ready,
"api_key_masked": _mask(str(_cfg.get("llm_api_key") or "")),
"base_url": str(_cfg.get("llm_base_url") or ""),
"model": str(_cfg.get("llm_model") or ""),
},
"embed": semantic.status(_cfg),
"mode": str(_cfg.get("mode", "demo")),
"offline_fallback": not ready,
}
@app.get("/api/config")
def get_config() -> dict:
return _ok(_public_config())
@app.post("/api/config")
def save_config(body: ConfigBody) -> dict:
"""保存运行时设置到 config.json;LLM/令牌热生效,监听地址与端口需重启。"""
updates: dict = {}
if body.host is not None:
if body.host not in ("127.0.0.1", "0.0.0.0"):
raise HTTPException(status_code=400, detail="host 仅支持 127.0.0.1 或 0.0.0.0")
updates["host"] = body.host
if body.port is not None:
if not (1024 <= body.port <= 65535):
raise HTTPException(status_code=400, detail="端口需在 1024-65535 之间")
updates["port"] = body.port
if body.mode is not None:
if body.mode not in ("demo", "live"):
raise HTTPException(status_code=400, detail="mode 仅支持 demo 或 live")
updates["mode"] = body.mode
if body.access_token is not None:
updates["access_token"] = body.access_token.strip()
if body.llm_api_key is not None:
updates["llm_api_key"] = body.llm_api_key.strip()
if body.llm_base_url is not None:
updates["llm_base_url"] = body.llm_base_url.strip()
if body.llm_model is not None:
updates["llm_model"] = body.llm_model.strip()
if body.embed_model_path is not None:
updates["embed_model_path"] = body.embed_model_path.strip()
if body.embed_provider is not None:
if body.embed_provider not in ("local", "api"):
raise HTTPException(status_code=400, detail="embed_provider 仅支持 local 或 api")
updates["embed_provider"] = body.embed_provider
if body.embed_api_key is not None:
updates["embed_api_key"] = body.embed_api_key.strip()
if body.embed_api_base_url is not None:
updates["embed_api_base_url"] = body.embed_api_base_url.strip()
if body.embed_api_model is not None:
updates["embed_api_model"] = body.embed_api_model.strip()
if body.guided is not None:
updates["guided"] = bool(body.guided)
save_runtime_settings(updates)
# 热生效:令牌、模式与 LLM/语义模型配置立即更新到运行配置(下次调用即读取)
for k in ("access_token", "mode", "llm_api_key", "llm_base_url", "llm_model",
"embed_model_path", "embed_provider", "embed_api_key",
"embed_api_base_url", "embed_api_model", "guided"):
if k in updates:
if updates[k]:
_cfg[k] = updates[k]
else:
_cfg.pop(k, None)
logger.info("运行时设置已保存到 config.json: %s", sorted(updates))
return _ok(_public_config(), message="设置已保存")
@app.post("/api/config/restart")
def restart_service() -> dict:
"""延迟 1 秒后原地重启进程(os.execv),使监听地址/端口变更生效。"""
import os
import sys
def _reexec() -> None:
threading.Event().wait(1.0)
logger.info("按用户请求重启服务……")
os.execv(sys.executable, [sys.executable] + sys.argv)
threading.Thread(target=_reexec, daemon=True).start()
return _ok({"restarting": True}, message="服务将在 1 秒后重启,请稍后刷新页面")
@app.get("/api/graph")
def graph() -> dict:
"""返回节点与边(含时间区间)与演化事件,供前端渲染全景图谱。"""
kg = _get_kg()
nodes = [{"id": n, **d} for n, d in kg.g.nodes(data=True)]
edges = [{"source": u, "target": v, **d} for u, v, d in kg.g.edges(data=True)]
events = sorted((e.model_dump() for e in kg.events), key=lambda e: e["window"])
return _ok({"nodes": nodes, "edges": edges, "events": events, "stats": kg.stats()})
# ---------- JD 导入(异步任务) ----------
def _run_task(task_id: str) -> None:
task = _state["tasks"][task_id]
try:
task["status"] = "running"
state_db.upsert_task(_db_path(), task_id, kind="jd_import",
status="running", payload=task)
stats = run_pipeline(_cfg)
graph_path = resolve(_cfg, "graph_path")
_state["kg"] = TemporalKG.load(graph_path)
task.update(status="done", result=stats)
except Exception as exc: # noqa: BLE001 - 任务失败必须可查询原因
logger.exception("pipeline 任务失败")
task.update(status="failed", error=str(exc))
state_db.upsert_task(_db_path(), task_id, kind="jd_import",
status=task["status"], payload=task)
def _start_import_task(import_stats: dict | None = None) -> str:
"""登记并启动异步 pipeline 重建任务,返回 task_id。"""
task_id = uuid.uuid4().hex[:12]
payload: dict = {"status": "pending"}
if import_stats:
payload["import"] = import_stats
_state["tasks"][task_id] = payload
state_db.upsert_task(_db_path(), task_id, kind="jd_import",
status="pending", payload=payload)
threading.Thread(target=_run_task, args=(task_id,), daemon=True).start()
return task_id
@app.post("/api/jd/import")
async def import_jd(request: Request) -> dict:
"""JD 导入:支持 multipart 文件上传(.jsonl/.csv/.txt)、JSON 文本粘贴,
或不带内容直接触发全量重建(旧行为)。
合格记录写入 data/jd_raw/import_时间戳.jsonl(保留来源标记),
坏行返回前端并写入 logs/import_bad_时间戳.jsonl;随后异步重建图谱。
"""
from app.ingest.loader import IMPORT_EXTS, IMPORT_MAX_BYTES, save_import
text, filename = "", "paste.txt"
if "multipart/form-data" in request.headers.get("content-type", ""):
form = await request.form()
upload = form.get("file")
if upload is not None and hasattr(upload, "read"):
filename = upload.filename or ""
suffix = Path(filename).suffix.lower()
if suffix not in IMPORT_EXTS:
raise HTTPException(status_code=400,
detail=f"不支持的格式 {suffix or '(无扩展名)'},支持 {sorted(IMPORT_EXTS)}")
content = await upload.read()
if not content:
raise HTTPException(status_code=400, detail="文件为空")
if len(content) > IMPORT_MAX_BYTES:
raise HTTPException(status_code=400, detail="文件超过 10MB 限制")
try:
text = content.decode("utf-8-sig")
except UnicodeDecodeError as exc:
raise HTTPException(status_code=400, detail="文件需为 UTF-8 编码") from exc
elif form.get("text"):
text = str(form["text"])
else:
raw = await request.body()
if raw:
try:
payload = json.loads(raw)
except json.JSONDecodeError as exc:
raise HTTPException(status_code=400, detail=f"请求体不是合法 JSON:{exc}") from exc
text = str(payload.get("text") or "")
import_stats: dict | None = None
if text.strip():
import_stats = save_import(text, filename, resolve(_cfg, "jd_raw_dir"), ROOT / "logs")
if not import_stats["imported"] and not import_stats["bad_lines"]:
raise HTTPException(status_code=400, detail="没有可导入的内容")
task_id = _start_import_task(import_stats)
data: dict = {"task_id": task_id}
if import_stats:
data.update(imported=import_stats["imported"], skipped=import_stats["skipped"],
bad_lines=import_stats["bad_lines"],
file=import_stats["file"], bad_log=import_stats["bad_log"])
return _ok(data, message="导入任务已启动")
@app.get("/api/tasks/{task_id}")
def task_status(task_id: str) -> dict:
task = _state["tasks"].get(task_id)
if task is None:
row = state_db.get_task(_db_path(), task_id) # 重启前的任务从状态库回查
task = row["payload"] if row else None
if task is None:
raise HTTPException(status_code=404, detail="任务不存在")
return _ok(task)
# ---------- 岗位 ----------
@app.get("/api/jobs")
def list_jobs(category: str = "", skill: str = "", keyword: str = "") -> dict:
kg = _get_kg()
jobs = list(kg.jobs.values())
if category:
jobs = [j for j in jobs if j.category == category]
if skill:
jobs = [j for j in jobs if skill in j.required_skills or skill in j.preferred_skills]
if keyword:
jobs = [j for j in jobs if keyword in j.title]
return _ok([j.model_dump() for j in jobs])
@app.get("/api/jobs/{job_id}")
def job_detail(job_id: str) -> dict:
kg = _get_kg()
job = kg.jobs.get(job_id)
if job is None:
raise HTTPException(status_code=404, detail="岗位不存在")
return _ok({"definition": job.model_dump(),
"snapshot": kg.latest_snapshot(job_id)})
@app.get("/api/jobs/{job_id}/evolution")
def job_evolution(job_id: str) -> dict:
kg = _get_kg()
if job_id not in kg.jobs:
raise HTTPException(status_code=404, detail="岗位不存在")
events = [e.model_dump() for e in kg.events_of(job_id)]
events.sort(key=lambda e: e["window"])
return _ok({"job_id": job_id, "events": events})
@app.get("/api/jobs/{job_id}/snapshot")
def job_snapshot(job_id: str, at: str = "") -> dict:
"""任意时间点能力画像切片(用于演化页时点对比)。"""
kg = _get_kg()
if job_id not in kg.jobs:
raise HTTPException(status_code=404, detail="岗位不存在")
return _ok(kg.snapshot(job_id, at or "9999"))
# ---------- 新岗位发现与审核 ----------
@app.post("/api/jobs/discover")
def discover() -> dict:
"""对当前原始数据重新跑新岗位发现,结果进待审核分区。"""
jd_dir = resolve(_cfg, "jd_raw_dir")
if not jd_dir.exists():
raise HTTPException(status_code=400, detail="原始数据目录不存在")
from app.extract.offline import OfflineExtractor
records, _ = load_jd_dir(jd_dir)
extractor = OfflineExtractor(load_lexicon(resolve(_cfg, "lexicon_path")))
extractions = {r.jid: extractor.extract(r) for r in records if not r.is_noise}
known = {j.title for j in (_state["kg"].jobs.values() if _state["kg"] else [])}
candidates = discover_new_jobs(records, extractions, known)
save_pending(candidates, _db_path())
return _ok({"candidates": [c.model_dump() for c in candidates]})
class ReviewBody(BaseModel):
candidate_id: str
action: str # accept | modify | reject
operator: str
reason: str = ""
modify: dict | None = None
@app.post("/api/review")
def review(body: ReviewBody) -> dict:
"""人机协同审核:通过后写入正式图谱(真实区间/来源/场景行业边/演化事件),
并写入覆写层保证跨重建持久化,全程留痕。"""
from app.services.overrides import apply_accepted_job, record_job_accept
kg = _get_kg()
try:
accepted, record = apply_review(
_db_path(), resolve(_cfg, "audit_path"),
body.candidate_id, body.action, body.operator, body.reason, body.modify)
except KeyError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
if accepted is not None:
from app.services.overrides import normalize_job_id
candidate_id = accepted.job_id
accepted.job_id = normalize_job_id(accepted) # cand_X → job_<岗位名>,全图唯一节点
apply_accepted_job(kg, accepted) # 真实窗口/来源入库 + 场景行业边 + 新增事件
record_job_accept(_db_path(), accepted, candidate_id=candidate_id) # 覆写层持久化,重建后自动重放
kg.save(resolve(_cfg, "graph_path"))
return _ok({"review": record.model_dump(),
"accepted": accepted.model_dump() if accepted else None})
@app.get("/api/review/pending")
def pending_list() -> dict:
return _ok([c.model_dump() for c in load_pending(_db_path())])
# ---------- 图谱节点编辑(人机协同,留痕) ----------
ALLOWED_NODE_FIELDS = {"title", "category", "level", "importance", "weight"}
class NodeUpdateBody(BaseModel):
node_id: str
updates: dict # 仅允许 ALLOWED_NODE_FIELDS 中的键
operator: str
reason: str = ""
def _append_audit(record: dict) -> None:
"""向审核留痕追加一条记录(与审核流同一 SQLite 状态库 audit_log 表)。"""
state_db.append_audit(_db_path(), record)
@app.post("/api/graph/node/update")
def node_update(body: NodeUpdateBody) -> dict:
"""编辑节点展示属性(名称/类别/级别/重要性/权重),写图谱并留痕。
只改属性不改结构:节点 id、边与演化历史不受影响。
Job 节点同步 kg.jobs 的 JobDefinition(title/category/级别),
编辑写入覆写层(job_overrides 表),pipeline 重建后自动重放。
"""
from app.services.overrides import apply_node_updates, record_node_update
kg = _get_kg()
updates = {k: v for k, v in body.updates.items() if k in ALLOWED_NODE_FIELDS}
if body.node_id not in kg.g:
raise HTTPException(status_code=404, detail="节点不存在")
if not updates:
raise HTTPException(status_code=400, detail="没有可更新的字段")
attrs = kg.g.nodes[body.node_id]
before = {k: attrs.get(k) for k in updates}
apply_node_updates(kg, body.node_id, updates)
record_node_update(_db_path(), body.node_id, updates)
kg.save(resolve(_cfg, "graph_path"))
record = {"kind": "node_update", "node_id": body.node_id,
"operator": body.operator, "reason": body.reason,
"before": before, "after": updates, "time": now_str()}
_append_audit(record)
return _ok({"node": {"id": body.node_id, **attrs}, "audit": record},
message="节点已更新")
@app.get("/api/audit")
def audit_list(node_id: str = "", limit: int = 50) -> dict:
"""读取审核/编辑留痕(新→旧排序),可按节点过滤。返回结构与旧 audit.jsonl 行一致。"""
records = state_db.get_audit(_db_path(), node_id=node_id or None,
limit=max(1, min(limit, 200)))
return _ok(records)
# ---------- JD 原文回查(证据来源弹窗) ----------
@app.get("/api/jd")
def jd_by_source(sid: str) -> dict:
"""按 source_id(形如 batch_2019.jsonl#17)回查 JD 原文片段。"""
if "#" not in sid:
raise HTTPException(status_code=400, detail="source_id 格式应为 文件名#行号")
fname, _, lineno_s = sid.partition("#")
if Path(fname).name != fname or not fname.endswith(".jsonl"):
raise HTTPException(status_code=400, detail="非法文件名")
try:
lineno = int(lineno_s)
except ValueError as exc:
raise HTTPException(status_code=400, detail="非法行号") from exc
path = resolve(_cfg, "jd_raw_dir") / fname
if not path.exists():
raise HTTPException(status_code=404, detail="数据文件不存在")
lines = path.read_text(encoding="utf-8").splitlines()
if not (1 <= lineno <= len(lines)):
raise HTTPException(status_code=404, detail="记录不存在")
try:
rec = JDRecord(**json.loads(lines[lineno - 1]))
except (json.JSONDecodeError, ValidationError, TypeError) as exc:
raise HTTPException(status_code=404, detail=f"记录解析失败:{exc}") from exc
rec.source_id = sid
return _ok(rec.model_dump())
# ---------- 简历 / 匹配 / 学习路径 ----------
class ResumeBody(BaseModel):
text: str
@app.post("/api/resume/parse")
def resume_parse(body: ResumeBody) -> dict:
matcher = SkillMatcher(load_lexicon(resolve(_cfg, "lexicon_path")))
profile = parse_resume(body.text, matcher)
return _ok(profile.model_dump())
_RESUME_EXTS = {".txt", ".md", ".docx", ".pdf"}
_RESUME_MAX_BYTES = 5 * 1024 * 1024 # 5MB
@app.post("/api/resume/parse/file")
async def resume_parse_file(file: UploadFile) -> dict:
"""上传简历文件(.txt/.md/.docx/.pdf)并解析:脱敏 + 技能抽取归一化。
文件名随机化落临时目录,解析完成即删除;临时文件不进入日志。
"""
suffix = Path(file.filename or "").suffix.lower()
if suffix not in _RESUME_EXTS:
raise HTTPException(status_code=400,
detail=f"不支持的格式 {suffix or '(无扩展名)'},支持 {sorted(_RESUME_EXTS)}")
content = await file.read()
if not content:
raise HTTPException(status_code=400, detail="文件为空")
if len(content) > _RESUME_MAX_BYTES:
raise HTTPException(status_code=400, detail="文件超过 5MB 限制")
tmp_dir = ROOT / "data" / "tmp"
tmp_dir.mkdir(parents=True, exist_ok=True)
tmp_path = tmp_dir / f"resume_{uuid.uuid4().hex[:12]}{suffix}"
try:
tmp_path.write_bytes(content)
matcher = SkillMatcher(load_lexicon(resolve(_cfg, "lexicon_path")))
profile = parse_resume_file(tmp_path, matcher)
except RuntimeError as exc: # 如 pdf 依赖缺失、文件损坏等
raise HTTPException(status_code=422, detail=str(exc)) from exc
except Exception as exc:
raise HTTPException(status_code=422, detail=f"简历文件解析失败:{exc}") from exc
finally:
tmp_path.unlink(missing_ok=True)
logger.info("简历文件解析完成: 格式=%s, 技能=%d 项", suffix, len(profile.skills))
return _ok(profile.model_dump())
class MatchBody(BaseModel):
job_id: str
text: str = "" # 简历原文(与 skills 二选一)
skills: list[str] = []
years: float = 0.0
scenarios: list[str] = []
class ResumeExportBody(BaseModel):
profile: dict # ResumeProfile 序列化结果
match: dict | None = None # 可选:匹配结果,用于关键词高亮与诊断摘要
_BATCH_MAX_FILES = 50
@app.post("/api/match/batch")
async def match_batch(job_id: str = Form(...), files: list[UploadFile] = File(...)) -> dict:
"""批量人岗匹配:多份简历(.txt/.md/.docx/.pdf,≤50 份)对同一岗位打分排序。
每份简历独立解析(含脱敏)与匹配,单份失败不影响整批(记 error 返回)。
"""
kg = _get_kg()
job = kg.jobs.get(job_id)
if job is None:
raise HTTPException(status_code=404, detail="岗位不存在")
if not files:
raise HTTPException(status_code=400, detail="未收到简历文件")
if len(files) > _BATCH_MAX_FILES:
raise HTTPException(status_code=400, detail=f"单批最多 {_BATCH_MAX_FILES} 份简历")
engine = _engine()
matcher = SkillMatcher(load_lexicon(resolve(_cfg, "lexicon_path")))
tmp_dir = ROOT / "data" / "tmp"
tmp_dir.mkdir(parents=True, exist_ok=True)
results: list[dict] = []
for f in files:
name = f.filename or "未命名"
suffix = Path(name).suffix.lower()
if suffix not in _RESUME_EXTS:
results.append({"filename": name, "error": f"不支持的格式 {suffix or '(无扩展名)'}"})
continue
content = await f.read()
if not content or len(content) > _RESUME_MAX_BYTES:
results.append({"filename": name, "error": "文件为空或超过 5MB"})
continue
tmp_path = tmp_dir / f"batch_{uuid.uuid4().hex[:12]}{suffix}"
try:
tmp_path.write_bytes(content)
profile = parse_resume_file(tmp_path, matcher)
m = engine.match(profile, job)
results.append({
"filename": name,
"name": profile.name or "(未识别)",
"years": profile.years,
"skills_count": len(profile.skills),
"total_score": m.total_score,
"dimension_scores": m.dimension_scores,
"matched": m.matched_required + m.matched_preferred,
"missing": m.missing_required + m.missing_preferred,
"is_match": m.is_match,
})
except Exception as exc: # noqa: BLE001 - 单份失败不拖垮整批
logger.warning("批量匹配:%s 解析失败: %s", name, exc)
results.append({"filename": name, "error": f"解析失败:{exc}"})
finally:
tmp_path.unlink(missing_ok=True)
ok = [r for r in results if "error" not in r]
ok.sort(key=lambda r: r["total_score"], reverse=True)
logger.info("批量匹配完成: 岗位=%s, 成功 %d/%d", job.title, len(ok), len(files))
return _ok({"job_id": job_id, "job_title": job.title,
"total": len(results), "ok": len(ok), "results": ok + [r for r in results if "error" in r]})
@app.post("/api/resume/export")
def resume_export(body: ResumeExportBody) -> Response:
"""把脱敏简历画像导出为规范 DOCX(照片+基本信息+分节+关键词高亮)。"""
from app.match.resume_docx import build_resume_docx
try:
profile = ResumeProfile(**body.profile)
except ValidationError as exc:
raise HTTPException(status_code=400, detail=f"简历画像格式错误:{exc}") from exc
data = build_resume_docx(profile, body.match)
fname = quote("简历_脱敏结构化.docx")
return Response(
content=data,
media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
headers={"Content-Disposition": f"attachment; filename*=utf-8''{fname}"})
def _profile_of(body: MatchBody) -> ResumeProfile:
matcher = SkillMatcher(load_lexicon(resolve(_cfg, "lexicon_path")))
if body.text:
return parse_resume(body.text, matcher)
# 手动录入的技能清单:三级归一化(大小写/别名→包含容错);
# 仍未命中的,若语义通道就绪则做 BGE-M3 语义召回兜底(可选增强)
lexicon = load_lexicon(resolve(_cfg, "lexicon_path"))
skills: list[str] = []
for s in body.skills:
if not s or not s.strip():
continue
norm = matcher.normalize(s)
if norm == s.strip():
sem = semantic.most_similar(norm, _cfg, lexicon)
if sem:
logger.info("语义召回: %s → %s (%.3f)", norm, sem[0], sem[1])
norm = sem[0]
skills.append(norm)
return ResumeProfile(skills=skills, years=body.years, scenarios=body.scenarios)
def _engine() -> MatchEngine:
return MatchEngine(
lexicon=load_lexicon(resolve(_cfg, "lexicon_path")),
weights={
"required": float(_cfg.get("match_weight_required", 0.5)),
"preferred": float(_cfg.get("match_weight_preferred", 0.15)),
"experience": float(_cfg.get("match_weight_experience", 0.2)),
"scenario": float(_cfg.get("match_weight_scenario", 0.15)),
},
threshold=float(_cfg.get("match_threshold", 60.0)),
)
@app.post("/api/match")
def match(body: MatchBody) -> dict:
kg = _get_kg()
job = kg.jobs.get(body.job_id)
if job is None:
raise HTTPException(status_code=404, detail="岗位不存在")
result = _engine().match(_profile_of(body), job)
return _ok(result.model_dump())
class LearnBody(BaseModel):
job_id: str
known_skills: list[str] = []
@app.post("/api/learning-path")
def learning_path(body: LearnBody) -> dict:
kg = _get_kg()
job = kg.jobs.get(body.job_id)
if job is None:
raise HTTPException(status_code=404, detail="岗位不存在")
profile = ResumeProfile(skills=body.known_skills)
result = _engine().match(profile, job)
return _ok({"job_id": job.job_id, "title": job.title,
"learning_path": result.learning_path,
"missing_required": result.missing_required})
# ---------- webui 静态托管 ----------
WEBUI = ROOT / "webui"
@app.get("/", include_in_schema=False)
def index():
return FileResponse(WEBUI / "index.html")
app.mount("/static", StaticFiles(directory=WEBUI), name="static")