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
+360
View File
@@ -0,0 +1,360 @@
"""SQLite 应用状态库:审核留痕 / 待审核候选 / 异步任务(默认启用,零新依赖)。
- 数据库文件默认 ``data/careernexus.db``(配置键 ``state_db_path``),仅用标准库 sqlite3;
- WAL 模式、外键开;``init_schema()`` 幂等建表,重复调用无副作用;
- 一次性迁移:打开连接时若旧文件存在、对应表为空且尚未迁移过,把
``data/review/audit.jsonl`` 与 ``data/review/pending.json`` 导入
(原文件保留不删除,迁移标记写入 meta 表并记日志)。
留痕存取约定:审核记录(candidate_id/action)把 action 存进 kind 列、
candidate_id 存进 node_id 列;节点编辑记录(kind=node_update)原样存取。
``get_audit`` 按此约定还原出与旧 audit.jsonl 行完全一致的字典结构。
"""
from __future__ import annotations
import json
import logging
import sqlite3
from datetime import datetime
from pathlib import Path
logger = logging.getLogger(__name__)
SCHEMA = """\
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts TEXT,
node_id TEXT,
kind TEXT,
operator TEXT,
reason TEXT,
before_json TEXT,
after_json TEXT
);
CREATE TABLE IF NOT EXISTS review_candidates (
job_id TEXT PRIMARY KEY,
payload_json TEXT,
updated_ts TEXT
);
CREATE TABLE IF NOT EXISTS tasks (
task_id TEXT PRIMARY KEY,
kind TEXT,
status TEXT,
payload_json TEXT,
created_ts TEXT,
updated_ts TEXT
);
CREATE TABLE IF NOT EXISTS job_overrides (
name TEXT PRIMARY KEY,
payload_json TEXT,
updated_ts TEXT
);
CREATE TABLE IF NOT EXISTS meta (
key TEXT PRIMARY KEY,
value TEXT
);
"""
REVIEW_ACTIONS = {"accept", "modify", "reject"}
def _now() -> str:
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
# ---------- 连接与建表 ----------
def init_schema(conn: sqlite3.Connection) -> None:
"""幂等建表(CREATE TABLE IF NOT EXISTS),可反复调用。"""
conn.executescript(SCHEMA)
conn.commit()
def connect(
db_path: str | Path,
legacy_audit: str | Path | None = None,
legacy_pending: str | Path | None = None,
) -> sqlite3.Connection:
"""打开(必要时创建)状态库:WAL + 外键 + 幂等建表 + 一次性旧文件迁移。"""
path = Path(db_path)
if str(path.parent) not in ("", "."):
path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(str(path))
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA foreign_keys=ON")
init_schema(conn)
migrate_legacy(conn, legacy_audit=legacy_audit, legacy_pending=legacy_pending)
return conn
# ---------- 一次性迁移(旧 JSONL/JSON -> SQLite,原文件保留) ----------
def _migration_done(conn: sqlite3.Connection, key: str) -> bool:
row = conn.execute("SELECT value FROM meta WHERE key = ?", (key,)).fetchone()
return row is not None
def _mark_migrated(conn: sqlite3.Connection, key: str) -> None:
conn.execute("INSERT OR REPLACE INTO meta(key, value) VALUES (?, ?)", (key, _now()))
def _table_empty(conn: sqlite3.Connection, table: str) -> bool:
return conn.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0] == 0
def migrate_legacy(
conn: sqlite3.Connection,
legacy_audit: str | Path | None = None,
legacy_pending: str | Path | None = None,
) -> None:
"""把旧状态文件导入 SQLite:仅当对应表为空且未迁移过时执行,原文件保留。"""
if legacy_audit is not None:
_migrate_audit(conn, Path(legacy_audit))
if legacy_pending is not None:
_migrate_pending(conn, Path(legacy_pending))
conn.commit()
def _migrate_audit(conn: sqlite3.Connection, path: Path) -> None:
key = "migrated_audit"
if _migration_done(conn, key):
return
_mark_migrated(conn, key)
if not path.exists() or not _table_empty(conn, "audit_log"):
return
n = 0
for line in path.read_text(encoding="utf-8").splitlines():
if not line.strip():
continue
try:
record = json.loads(line)
except json.JSONDecodeError:
continue
_insert_audit(conn, record)
n += 1
logger.info("状态迁移:%s -> audit_log(%d 条,原文件保留)", path, n)
def _migrate_pending(conn: sqlite3.Connection, path: Path) -> None:
key = "migrated_pending"
if _migration_done(conn, key):
return
_mark_migrated(conn, key)
if not path.exists() or not _table_empty(conn, "review_candidates"):
return
try:
candidates = json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError:
return
for payload in candidates:
_upsert_candidate(conn, payload)
logger.info("状态迁移:%s -> review_candidates(%d 条,原文件保留)", path, len(candidates))
# ---------- 审核留痕 ----------
def _insert_audit(conn: sqlite3.Connection, record: dict) -> None:
kind = record.get("kind") or record.get("action") or ""
node_id = record.get("node_id") or record.get("candidate_id") or ""
conn.execute(
"INSERT INTO audit_log(ts, node_id, kind, operator, reason, before_json, after_json)"
" VALUES (?, ?, ?, ?, ?, ?, ?)",
(
record.get("time") or _now(),
node_id,
kind,
record.get("operator", ""),
record.get("reason", ""),
json.dumps(record.get("before") or {}, ensure_ascii=False),
json.dumps(record.get("after") or {}, ensure_ascii=False),
),
)
def append_audit(db_path: str | Path, record: dict) -> None:
"""追加一条留痕(审核记录或 kind=node_update 的节点编辑记录)。"""
conn = connect(db_path)
try:
_insert_audit(conn, record)
conn.commit()
finally:
conn.close()
def _audit_row_to_record(row: sqlite3.Row) -> dict:
"""把 audit_log 行还原为旧 audit.jsonl 行的字典结构(/api/audit 兼容)。"""
before = json.loads(row["before_json"] or "{}")
after = json.loads(row["after_json"] or "{}")
if row["kind"] in REVIEW_ACTIONS:
return {"candidate_id": row["node_id"], "action": row["kind"],
"operator": row["operator"], "reason": row["reason"],
"before": before, "after": after, "time": row["ts"]}
return {"kind": row["kind"], "node_id": row["node_id"],
"operator": row["operator"], "reason": row["reason"],
"before": before, "after": after, "time": row["ts"]}
def get_audit(db_path: str | Path, node_id: str | None = None, limit: int = 50) -> list[dict]:
"""读取留痕(新→旧排序),可按节点/候选 id 过滤。"""
conn = connect(db_path)
try:
if node_id:
rows = conn.execute(
"SELECT * FROM audit_log WHERE node_id = ? ORDER BY id DESC LIMIT ?",
(node_id, limit)).fetchall()
else:
rows = conn.execute(
"SELECT * FROM audit_log ORDER BY id DESC LIMIT ?", (limit,)).fetchall()
return [_audit_row_to_record(r) for r in rows]
finally:
conn.close()
# ---------- 待审核候选 ----------
def _upsert_candidate(conn: sqlite3.Connection, payload: dict) -> None:
conn.execute(
"INSERT INTO review_candidates(job_id, payload_json, updated_ts) VALUES (?, ?, ?)"
" ON CONFLICT(job_id) DO UPDATE SET payload_json = excluded.payload_json,"
" updated_ts = excluded.updated_ts",
(payload["job_id"], json.dumps(payload, ensure_ascii=False), _now()))
def save_candidate(db_path: str | Path, payload: dict) -> None:
"""新增或更新一个待审核候选(按 job_id upsert)。"""
conn = connect(db_path)
try:
_upsert_candidate(conn, payload)
conn.commit()
finally:
conn.close()
def get_candidate(db_path: str | Path, job_id: str) -> dict | None:
conn = connect(db_path)
try:
row = conn.execute(
"SELECT payload_json FROM review_candidates WHERE job_id = ?",
(job_id,)).fetchone()
return json.loads(row["payload_json"]) if row else None
finally:
conn.close()
def list_candidates(db_path: str | Path) -> list[dict]:
"""全部待审核候选(按写入顺序)。"""
conn = connect(db_path)
try:
rows = conn.execute(
"SELECT payload_json FROM review_candidates ORDER BY rowid").fetchall()
return [json.loads(r["payload_json"]) for r in rows]
finally:
conn.close()
def delete_candidate(db_path: str | Path, job_id: str) -> None:
conn = connect(db_path)
try:
conn.execute("DELETE FROM review_candidates WHERE job_id = ?", (job_id,))
conn.commit()
finally:
conn.close()
# ---------- 异步任务 ----------
def upsert_task(
db_path: str | Path,
task_id: str,
kind: str,
status: str,
payload: dict,
created_ts: str | None = None,
) -> None:
"""登记或更新任务状态(payload 为内存中任务 dict 的 JSON 快照)。"""
conn = connect(db_path)
try:
conn.execute(
"INSERT INTO tasks(task_id, kind, status, payload_json, created_ts, updated_ts)"
" VALUES (?, ?, ?, ?, ?, ?)"
" ON CONFLICT(task_id) DO UPDATE SET status = excluded.status,"
" payload_json = excluded.payload_json, updated_ts = excluded.updated_ts",
(task_id, kind, status, json.dumps(payload, ensure_ascii=False),
created_ts or _now(), _now()))
conn.commit()
finally:
conn.close()
def _task_row(row: sqlite3.Row) -> dict:
return {"task_id": row["task_id"], "kind": row["kind"], "status": row["status"],
"payload": json.loads(row["payload_json"] or "{}"),
"created_ts": row["created_ts"], "updated_ts": row["updated_ts"]}
def get_task(db_path: str | Path, task_id: str) -> dict | None:
conn = connect(db_path)
try:
row = conn.execute("SELECT * FROM tasks WHERE task_id = ?", (task_id,)).fetchone()
return _task_row(row) if row else None
finally:
conn.close()
def list_tasks(db_path: str | Path) -> list[dict]:
"""全部任务(按创建时间升序),供重启后恢复内存任务表。"""
conn = connect(db_path)
try:
rows = conn.execute("SELECT * FROM tasks ORDER BY created_ts, rowid").fetchall()
return [_task_row(r) for r in rows]
finally:
conn.close()
# ---------- 人工状态覆写层(跨图谱重建持久化) ----------
def _upsert_override(conn: sqlite3.Connection, name: str, payload: dict) -> None:
conn.execute(
"INSERT INTO job_overrides(name, payload_json, updated_ts) VALUES (?, ?, ?)"
" ON CONFLICT(name) DO UPDATE SET payload_json = excluded.payload_json,"
" updated_ts = excluded.updated_ts",
(name, json.dumps(payload, ensure_ascii=False), _now()))
def save_override(db_path: str | Path, name: str, payload: dict) -> None:
"""新增或更新一条人工覆写(按 name upsert)。
payload 约定:{"kind": "job_accept", "job": <JobDefinition dump>}
或 {"kind": "node_update", "node_id": ..., "updates": {...}}。
"""
conn = connect(db_path)
try:
_upsert_override(conn, name, payload)
conn.commit()
finally:
conn.close()
def get_override(db_path: str | Path, name: str) -> dict | None:
conn = connect(db_path)
try:
row = conn.execute(
"SELECT payload_json FROM job_overrides WHERE name = ?", (name,)).fetchone()
return json.loads(row["payload_json"]) if row else None
finally:
conn.close()
def list_overrides(db_path: str | Path) -> list[dict]:
"""全部人工覆写(按写入顺序),pipeline 重建后按序重放。"""
conn = connect(db_path)
try:
rows = conn.execute(
"SELECT payload_json FROM job_overrides ORDER BY rowid").fetchall()
return [json.loads(r["payload_json"]) for r in rows]
finally:
conn.close()
def delete_override(db_path: str | Path, name: str) -> None:
conn = connect(db_path)
try:
conn.execute("DELETE FROM job_overrides WHERE name = ?", (name,))
conn.commit()
finally:
conn.close()