"""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")