- 驾驶舱改'人才能力变化指挥台':变化事件流主图+可信度卡+行动卡+播报卡 - 图谱子图同心分层布局、技能变化角标、系统发现折叠入右栏、主操作收敛 - 演化页双 Tab(岗位报告/全岗位扫描)+ 能力生命线 Swimlane + 更替流 - 学习路径成长路线图:概要条/示范路线/预计投入/标记完成联动 - 新增候选审核台抽屉 + GET /api/stats/credibility - 简历裸姓名/专硕提取修复(桌面真实简历验收通过)+ BGE-M3 加载提示
846 lines
35 KiB
Python
846 lines
35 KiB
Python
"""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")),
|
||
"demo_mode": bool(_cfg.get("demo_mode", True)),
|
||
"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 # 初始设置引导完成标记
|
||
demo_mode: 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")),
|
||
"demo_mode": bool(_cfg.get("demo_mode", True)),
|
||
"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)
|
||
if body.demo_mode is not None:
|
||
updates["demo_mode"] = bool(body.demo_mode)
|
||
|
||
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", "demo_mode"):
|
||
if k in updates:
|
||
if updates[k] or isinstance(updates[k], bool):
|
||
_cfg[k] = updates[k] # 布尔 False 也要写入(如关闭演示模式)
|
||
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 导入(异步任务) ----------
|
||
@app.get("/api/stats/credibility")
|
||
def credibility_stats() -> dict:
|
||
"""数据可信度指标(驾驶舱「数据可信度」卡):
|
||
有效 JD 数 / 独立企业数 / 来源类型数 / 人工复核率 / 异常剔除率。
|
||
人工复核率 = 审核留痕数 / (留痕数 + 待审候选数)。"""
|
||
jd_dir = resolve(_cfg, "jd_raw_dir")
|
||
records, _ = load_jd_dir(jd_dir)
|
||
total = len(records)
|
||
valid = [r for r in records if not r.is_noise and not r.is_duplicate]
|
||
companies = {r.company for r in valid if r.company}
|
||
sources = sorted({r.source for r in valid if r.source})
|
||
audit = state_db.get_audit(_db_path(), limit=200)
|
||
reviewed = sum(1 for r in audit if r.get("kind") != "node_update")
|
||
pending = len(load_pending(_db_path()))
|
||
denom = reviewed + pending
|
||
return _ok({
|
||
"jd_total": total,
|
||
"jd_valid": len(valid),
|
||
"companies": len(companies),
|
||
"sources": len(sources),
|
||
"source_names": sources,
|
||
"reviewed": reviewed,
|
||
"pending": pending,
|
||
"review_rate": (reviewed / denom) if denom else None,
|
||
"excluded_rate": ((total - len(valid)) / total) if total else 0.0,
|
||
})
|
||
|
||
|
||
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")
|