Files
CareerNexus/app/storage/db.py
T
RoyirinRosmontis a10442d78c CareerNexus 职联:岗位能力图谱构建与动态演化分析平台(挑战杯 XH-202621)
- 全链路:多源JD接入/清洗交叉验证/结构化抽取(星火+离线双通道)/时序知识图谱/新岗位发现/演化分析/人岗匹配/学习路径
- WebUI 五视图:驾驶舱/图谱工作台/岗位演化/匹配(单人+批量)/学习路径
- 设置中心(config.json)/Token鉴权/SQLite状态库/语义向量通道(BGE-M3本地或联网API)
- 71 个 pytest 用例;数据目录仅保留结构(.gitkeep),数据由 scripts/generate_dataset.py 复现
2026-08-03 13:15:35 +08:00

361 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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()