assistant/server/db/database.py

439 lines
17 KiB
Python
Raw Permalink 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.

"""中心服务端数据层(psycopg 3)。
和桌面端数据层的区别:这里不按 school_id 过滤,因为中心要看所有学校的东西;
学校隔离体现在发布范围(scope)和上报凭证上。
"""
from __future__ import annotations
import hashlib
import secrets
import time
from contextlib import contextmanager
from datetime import datetime
from pathlib import Path
from typing import Any
import psycopg
from psycopg.rows import dict_row
from server.config import settings
from shared import knowledge_match
SCHEMA_PATH = Path(__file__).parent / "schema.sql"
VERSION_KEY = "publish_version"
# ---------- 连接 ----------
def _connect(dbname: str | None = None) -> psycopg.Connection:
return psycopg.connect(
host=settings.pg_host,
port=settings.pg_port,
user=settings.pg_user,
password=settings.pg_password,
dbname=dbname or settings.pg_database,
connect_timeout=5,
row_factory=dict_row,
options="-c timezone=Asia/Shanghai",
)
@contextmanager
def get_conn():
conn = _connect()
try:
yield conn
conn.commit()
except Exception:
conn.rollback()
raise
finally:
conn.close()
def _fetchone(conn, sql: str, params: tuple | list = ()) -> dict | None:
with conn.cursor() as cur:
cur.execute(sql, params)
return cur.fetchone()
def _fetchall(conn, sql: str, params: tuple | list = ()) -> list[dict]:
with conn.cursor() as cur:
cur.execute(sql, params)
return list(cur.fetchall())
def _execute(conn, sql: str, params: tuple | list = ()) -> int:
with conn.cursor() as cur:
cur.execute(sql, params)
return cur.rowcount
def _insert_id(conn, sql: str, params: tuple | list = ()) -> int:
with conn.cursor() as cur:
cur.execute(sql, params)
row = cur.fetchone()
return int(row["id"]) if row else 0
# ---------- 初始化 ----------
def init_db(retries: int = 40, delay: float = 1.5) -> None:
last_err: Exception | None = None
for attempt in range(1, retries + 1):
try:
_ensure_database()
with get_conn() as conn:
_apply_schema(conn)
return
except psycopg.Error as e:
last_err = e
if attempt == 1:
print(f"[hub-db] 等待 PostgreSQL {settings.pg_host}:{settings.pg_port} 就绪 ... ({e})", flush=True)
time.sleep(delay)
raise RuntimeError(
f"无法连接 PostgreSQL {settings.pg_host}:{settings.pg_port}。请先执行 docker compose up -d。原始错误:{last_err}"
)
def _ensure_database() -> None:
"""中心库单独一个 database,不存在就建(CREATE DATABASE 得在维护库里执行且不能在事务里)。"""
conn = _connect(settings.pg_maintenance_database)
try:
conn.autocommit = True
with conn.cursor() as cur:
cur.execute("SELECT 1 FROM pg_database WHERE datname = %s", (settings.pg_database,))
if cur.fetchone() is None:
cur.execute(f'CREATE DATABASE "{settings.pg_database}"')
print(f"[hub-db] 已创建中心库 {settings.pg_database}", flush=True)
finally:
conn.close()
def _strip_comment(line: str) -> str:
"""去掉行尾 -- 注释。语句要拼成一行执行,留着注释会把后面的列定义一起注释掉。"""
in_quote = False
for i, ch in enumerate(line):
if ch == "'":
in_quote = not in_quote
elif ch == "-" and not in_quote and line[i : i + 2] == "--":
return line[:i]
return line
def _apply_schema(conn) -> None:
sql = SCHEMA_PATH.read_text(encoding="utf-8")
for stmt in sql.split(";"):
body = " ".join(_strip_comment(line) for line in stmt.splitlines()).strip()
if body:
_execute(conn, body)
def _fmt(value: Any) -> Any:
return value.strftime("%Y-%m-%d %H:%M:%S") if isinstance(value, datetime) else value
def _row(row: dict) -> dict:
return {k: _fmt(v) for k, v in row.items()}
# ---------- 学校与凭证 ----------
def hash_token(token: str) -> str:
return hashlib.sha256(token.encode("utf-8")).hexdigest()
def register_school(school_id: str, name: str = "", note: str = "") -> dict:
"""新建或重置一所学校的上报凭证。明文 token 只在这里返回一次,库里只存哈希。"""
token = f"sa_{secrets.token_urlsafe(24)}"
with get_conn() as conn:
_execute(
conn,
"""INSERT INTO schools (id, name, token_hash, note) VALUES (%s, %s, %s, %s)
ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, token_hash = EXCLUDED.token_hash,
note = EXCLUDED.note""",
(school_id, name or school_id, hash_token(token), note),
)
return {"school_id": school_id, "name": name or school_id, "token": token}
def authenticate(school_id: str, token: str) -> bool:
if not school_id or not token:
return False
with get_conn() as conn:
row = _fetchone(conn, "SELECT token_hash FROM schools WHERE id = %s", (school_id,))
if not row or not secrets.compare_digest(row["token_hash"], hash_token(token)):
return False
_execute(conn, "UPDATE schools SET last_seen_at = NOW() WHERE id = %s", (school_id,))
return True
def list_schools() -> list[dict]:
with get_conn() as conn:
rows = _fetchall(
conn,
"""SELECT s.*,
(SELECT count(*) FROM hub_entry_sources es WHERE es.school_id = s.id) AS reported_entries
FROM schools s ORDER BY s.created_at""",
)
return [_row(r) for r in rows]
def delete_school(school_id: str) -> None:
with get_conn() as conn:
_execute(conn, "DELETE FROM schools WHERE id = %s", (school_id,))
# ---------- 上报(桌面端 → 中心) ----------
def _scope_rows(conn, knowledge_id: str, business_id: str) -> list[dict]:
return _fetchall(
conn,
"""SELECT id, "key", "value", category, status, observations, school_count, reviewed_by
FROM hub_entries WHERE knowledge_id = %s AND business_id = %s""",
(knowledge_id, business_id),
)
def receive_drafts(school_id: str, entries: list[dict]) -> dict:
"""收下一所学校报来的草稿,按内容合并进中心队列。
已被驳回的条目不会因为再次上报而复活(那所学校可能还没拉到驳回结果)。
"""
stats = {"created": 0, "merged": 0, "skipped": 0, "revived": 0}
if not entries:
return stats
with get_conn() as conn:
for raw in entries:
key = str(raw.get("key") or "").strip()[:128]
value = str(raw.get("value") or "").strip()
if not key or not value:
stats["skipped"] += 1
continue
knowledge_id = str(raw.get("knowledge_id") or "").strip()
business_id = str(raw.get("business_id") or "").strip()
category = knowledge_match.normalise_category(raw.get("category"))
observations = max(1, int(raw.get("observations") or 1))
source_task_id = str(raw.get("source_task_id") or "")[:64]
rows = _scope_rows(conn, knowledge_id, business_id)
match = next((r for r in rows if knowledge_match.same_knowledge(key, value, r["key"], r["value"])), None)
if match is None:
entry_id = _insert_id(
conn,
"""INSERT INTO hub_entries
(knowledge_id, business_id, "key", "value", category, observations, school_count, origin_school_id)
VALUES (%s, %s, %s, %s, %s, %s, 1, %s) RETURNING id""",
(knowledge_id, business_id, key, value, category, observations, school_id),
)
stats["created"] += 1
else:
if match["status"] == "rejected" and (match.get("reviewed_by") or "human") != "auto":
# 人工驳回是终局,不让它因为再次上报而复活;
# 自动驳回只是暂缓,再被观察到就退回队列重新排队,避免机器误判永久吃掉一条知识
stats["skipped"] += 1
continue
if match["status"] == "rejected":
_execute(
conn,
"""UPDATE hub_entries SET status = 'draft', reviewed_by = NULL, reviewed_at = NULL,
version = 0, suggestion = NULL, suggestion_note = '' WHERE id = %s""",
(match["id"],),
)
stats["revived"] += 1
entry_id = match["id"]
richer = value if len(value) > len(match["value"] or "") else match["value"]
category = knowledge_match.pick_category(category, match["category"])
_execute(
conn,
"""UPDATE hub_entries SET "value" = %s, category = %s, updated_at = NOW() WHERE id = %s""",
(richer, category, entry_id),
)
stats["merged"] += 1
confirmed = max(0, int(raw.get("confirmed") or 0))
contradicted = max(0, int(raw.get("contradicted") or 0))
_execute(
conn,
"""INSERT INTO hub_entry_sources (entry_id, school_id, source_task_id, observations, confirmed, contradicted)
VALUES (%s, %s, %s, %s, %s, %s)
ON CONFLICT (entry_id, school_id) DO UPDATE
SET observations = GREATEST(hub_entry_sources.observations, EXCLUDED.observations),
confirmed = GREATEST(hub_entry_sources.confirmed, EXCLUDED.confirmed),
contradicted = GREATEST(hub_entry_sources.contradicted, EXCLUDED.contradicted),
source_task_id = EXCLUDED.source_task_id, reported_at = NOW()""",
(entry_id, school_id, source_task_id, observations, confirmed, contradicted),
)
_execute(
conn,
"""UPDATE hub_entries SET
observations = (SELECT COALESCE(sum(observations), 1) FROM hub_entry_sources WHERE entry_id = %s),
school_count = (SELECT count(*) FROM hub_entry_sources WHERE entry_id = %s),
confirmed = (SELECT COALESCE(sum(confirmed), 0) FROM hub_entry_sources WHERE entry_id = %s),
contradicted = (SELECT COALESCE(sum(contradicted), 0) FROM hub_entry_sources WHERE entry_id = %s)
WHERE id = %s""",
(entry_id, entry_id, entry_id, entry_id, entry_id),
)
_execute(conn, "UPDATE schools SET last_report_at = NOW() WHERE id = %s", (school_id,))
_execute(
conn,
"INSERT INTO sync_log (school_id, direction, count, detail) VALUES (%s, 'report', %s, %s)",
(school_id, len(entries), f"新建 {stats['created']}、合并 {stats['merged']}、跳过 {stats['skipped']}"),
)
return stats
# ---------- 发布(中心 → 桌面端) ----------
def _next_version(conn) -> int:
row = _fetchone(conn, 'SELECT "value" FROM hub_meta WHERE "key" = %s', (VERSION_KEY,))
version = int(row["value"]) + 1 if row and str(row["value"]).isdigit() else 1
_execute(
conn,
"""INSERT INTO hub_meta ("key", "value") VALUES (%s, %s)
ON CONFLICT ("key") DO UPDATE SET "value" = EXCLUDED."value" """,
(VERSION_KEY, str(version)),
)
return version
def published_for(school_id: str, since: int = 0) -> dict:
"""某所学校该拿到的已发布条目:发布范围是 all 的,或范围是 origin 且由它自己报的。"""
with get_conn() as conn:
rows = _fetchall(
conn,
"""SELECT e.id, e.knowledge_id, e.business_id, e."key", e."value", e.category, e.version, e.status
FROM hub_entries e
WHERE e.version > %s AND e.status IN ('verified', 'rejected')
AND (e.scope = 'all'
OR EXISTS (SELECT 1 FROM hub_entry_sources s WHERE s.entry_id = e.id AND s.school_id = %s))
ORDER BY e.version LIMIT 500""",
(int(since), school_id),
)
latest = _fetchone(conn, 'SELECT "value" FROM hub_meta WHERE "key" = %s', (VERSION_KEY,))
if rows:
_execute(conn, "UPDATE schools SET last_pull_at = NOW() WHERE id = %s", (school_id,))
_execute(
conn,
"INSERT INTO sync_log (school_id, direction, count, detail) VALUES (%s, 'pull', %s, %s)",
(school_id, len(rows), f"从版本 {since} 起"),
)
version = int(latest["value"]) if latest and str(latest["value"]).isdigit() else 0
return {"version": version, "entries": [_row(r) for r in rows]}
# ---------- 审核台 ----------
def list_entries(
status: str | None = None,
knowledge_id: str | None = None,
business_id: str | None = None,
category: str | None = None,
school_id: str | None = None,
query: str | None = None,
limit: int = 200,
) -> list[dict]:
where: list[str] = []
params: list[Any] = []
if status:
where.append("e.status = %s")
params.append(status)
if knowledge_id:
where.append("e.knowledge_id = %s")
params.append(knowledge_id)
if business_id:
where.append("e.business_id = %s")
params.append(business_id)
if category:
where.append("e.category = %s")
params.append(category)
if school_id:
where.append("EXISTS (SELECT 1 FROM hub_entry_sources s WHERE s.entry_id = e.id AND s.school_id = %s)")
params.append(school_id)
if query:
where.append('(e."key" ILIKE %s OR e."value" ILIKE %s)')
params += [f"%{query}%", f"%{query}%"]
clause = f"WHERE {' AND '.join(where)}" if where else ""
params.append(int(limit))
with get_conn() as conn:
rows = _fetchall(
conn,
f"""SELECT e.*,
(SELECT string_agg(s.school_id, ',' ORDER BY s.school_id)
FROM hub_entry_sources s WHERE s.entry_id = e.id) AS schools
FROM hub_entries e {clause}
ORDER BY e.school_count DESC, e.observations DESC, e.updated_at DESC LIMIT %s""",
params,
)
return [_row(r) for r in rows]
def get_entry(entry_id: int) -> dict | None:
with get_conn() as conn:
row = _fetchone(conn, "SELECT * FROM hub_entries WHERE id = %s", (entry_id,))
return _row(row) if row else None
def review_entry(entry_id: int, action: str, value: str | None = None, scope: str | None = None,
by: str = "human") -> dict | None:
"""采纳 / 驳回 / 退回草稿。采纳和驳回都要分配新版本号,桌面端才拉得到这次变更。"""
status = {"verify": "verified", "reject": "rejected", "reset": "draft"}.get(action)
if not status:
raise ValueError(f"未知的审核动作: {action}")
with get_conn() as conn:
sets = ['status = %s', "updated_at = NOW()"]
params: list[Any] = [status]
if value is not None and value.strip():
sets.append('"value" = %s')
params.append(value.strip())
if scope in ("origin", "all"):
sets.append("scope = %s")
params.append(scope)
if status == "draft":
sets += ["reviewed_by = NULL", "reviewed_at = NULL", "version = 0"]
else:
sets += ["reviewed_by = %s", "reviewed_at = NOW()", "version = %s"]
params += [by, _next_version(conn)]
params.append(entry_id)
_execute(conn, f"UPDATE hub_entries SET {', '.join(sets)} WHERE id = %s", params)
row = _fetchone(conn, "SELECT * FROM hub_entries WHERE id = %s", (entry_id,))
return _row(row) if row else None
def save_suggestion(entry_id: int, suggestion: str, note: str) -> None:
with get_conn() as conn:
_execute(
conn,
"UPDATE hub_entries SET suggestion = %s, suggestion_note = %s, suggested_at = NOW() WHERE id = %s",
(suggestion, note[:2000], entry_id),
)
def stats() -> dict:
with get_conn() as conn:
by_status = _fetchall(conn, "SELECT status, count(*) AS n FROM hub_entries GROUP BY status")
by_category = _fetchall(
conn, "SELECT category, count(*) AS n FROM hub_entries WHERE status = 'draft' GROUP BY category")
schools = _fetchone(conn, "SELECT count(*) AS n FROM schools")
recent = _fetchall(conn, "SELECT * FROM sync_log ORDER BY at DESC LIMIT 20")
facets = _fetchall(
conn,
"""SELECT knowledge_id, business_id, count(*) FILTER (WHERE status = 'draft') AS drafts, count(*) AS total
FROM hub_entries GROUP BY knowledge_id, business_id ORDER BY drafts DESC""",
)
return {
"by_status": {r["status"]: r["n"] for r in by_status},
"draft_by_category": {r["category"]: r["n"] for r in by_category},
"schools": schools["n"] if schools else 0,
"recent_sync": [_row(r) for r in recent],
"facets": [_row(r) for r in facets],
}