Move settings to page and cache study summaries

This commit is contained in:
Codex
2026-05-27 13:10:18 +08:00
parent cfd2b379b5
commit 0cf7725dfc
5 changed files with 423 additions and 86 deletions

View File

@@ -8,10 +8,12 @@ import json
import os
import secrets
import subprocess
import threading
import time
import urllib.error
import urllib.request
from collections import defaultdict
from datetime import datetime, timedelta
from pathlib import Path
from typing import Any
@@ -78,6 +80,9 @@ app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
TOKENS: dict[str, str] = {}
STUDY_CACHE: dict[str, dict[str, Any]] = {}
STACK_CACHE: dict[str, tuple[float, dict[str, Any]]] = {}
SUMMARY_REFRESH_LOCK = threading.Lock()
SUMMARY_REFRESH_STATE: dict[str, Any] = {"running": False, "started_at": "", "finished_at": "", "message": "未运行"}
SUMMARY_SCHEDULER_STARTED = False
class LoginIn(BaseModel):
@@ -107,6 +112,10 @@ class AIRequest(BaseModel):
sample_count: int = 3
class AISettingsIn(BaseModel):
enabled: bool = True
class UserIn(BaseModel):
username: str
password: str
@@ -266,6 +275,66 @@ def ensure_user_table() -> None:
pg_scalar(sql)
def ensure_settings_table() -> None:
sql = """
CREATE TABLE IF NOT EXISTS public.pacs_web_settings (
key text PRIMARY KEY,
value text NOT NULL,
updated_at timestamptz NOT NULL DEFAULT now()
);
INSERT INTO public.pacs_web_settings (key, value)
VALUES ('ai_enabled', 'true')
ON CONFLICT (key) DO NOTHING;
"""
pg_scalar(sql)
def ensure_study_summary_table() -> None:
sql = """
CREATE TABLE IF NOT EXISTS public.pacs_dicom_study_summaries (
ct_number text PRIMARY KEY,
series_count integer NOT NULL DEFAULT 0,
annotated_series integer NOT NULL DEFAULT 0,
unannotated_series integer NOT NULL DEFAULT 0,
refreshed_at timestamptz NOT NULL DEFAULT now()
);
"""
pg_scalar(sql)
def setting_value(key: str, default: str = "") -> str:
try:
ensure_settings_table()
rows = pg_json_rows(
f"""
SELECT value
FROM public.pacs_web_settings
WHERE key = {sql_literal(key)}
LIMIT 1
"""
)
return str(rows[0]["value"]) if rows else default
except Exception:
return default
def set_setting_value(key: str, value: str) -> None:
ensure_settings_table()
pg_scalar(
f"""
INSERT INTO public.pacs_web_settings (key, value, updated_at)
VALUES ({sql_literal(key)}, {sql_literal(value)}, now())
ON CONFLICT (key) DO UPDATE SET
value = EXCLUDED.value,
updated_at = now()
"""
)
def ai_enabled() -> bool:
return setting_value("ai_enabled", "true").lower() in {"true", "1", "yes", "on"}
def web_users() -> list[dict[str, Any]]:
try:
ensure_user_table()
@@ -280,6 +349,30 @@ def web_users() -> list[dict[str, Any]]:
return [{"username": WEB_USER, "role": "管理员", "status": "启用"}]
def web_user_record(username: str) -> dict[str, Any]:
try:
ensure_user_table()
rows = pg_json_rows(
f"""
SELECT username, role, status
FROM public.pacs_web_users
WHERE username = {sql_literal(username)}
LIMIT 1
"""
)
if rows:
return rows[0]
except Exception:
pass
if username == WEB_USER:
return {"username": WEB_USER, "role": "管理员", "status": "启用"}
return {"username": username, "role": "访客", "status": "启用"}
def user_role(username: str) -> str:
return str(web_user_record(username).get("role") or "访客")
def authenticate_web_user(username: str, password: str) -> bool:
try:
ok, _ = db_available()
@@ -303,10 +396,16 @@ def authenticate_web_user(username: str, password: str) -> bool:
@app.on_event("startup")
def startup() -> None:
global SUMMARY_SCHEDULER_STARTED
ok, _ = db_available()
if ok:
ensure_annotation_table()
ensure_user_table()
ensure_settings_table()
ensure_study_summary_table()
if not SUMMARY_SCHEDULER_STARTED:
SUMMARY_SCHEDULER_STARTED = True
threading.Thread(target=summary_scheduler_loop, daemon=True).start()
def require_auth(authorization: str | None = Header(default=None), access_token: str = Query(default="")) -> str:
@@ -321,20 +420,36 @@ def require_auth(authorization: str | None = Header(default=None), access_token:
return user
def require_admin(user: str = Depends(require_auth)) -> str:
if user_role(user) != "管理员":
raise HTTPException(status_code=403, detail="仅管理员可访问设置")
return user
@app.get("/")
def index() -> FileResponse:
return FileResponse(STATIC_DIR / "index.html")
@app.get("/settings")
def settings_page() -> FileResponse:
return FileResponse(STATIC_DIR / "index.html")
@app.post("/api/auth/login")
def login(data: LoginIn) -> dict[str, str]:
if authenticate_web_user(data.username, data.password):
token = secrets.token_urlsafe(32)
TOKENS[token] = data.username
return {"token": token, "username": data.username}
return {"token": token, "username": data.username, "role": user_role(data.username)}
raise HTTPException(status_code=401, detail="invalid credentials")
@app.get("/api/auth/me")
def me(user: str = Depends(require_auth)) -> dict[str, str]:
return {"username": user, "role": user_role(user)}
@app.get("/api/status")
def status() -> dict[str, Any]:
db_ok, db_message = db_available()
@@ -347,11 +462,46 @@ def status() -> dict[str, Any]:
return {
"database": {"ok": db_ok, "message": db_message, "host": PGHOST, "database": PGDATABASE, "table": PGTABLE, "rows": table_count},
"dicom": {"processed_root": str(PROCESSED_ROOT), "exists": PROCESSED_ROOT.exists()},
"ai": {"configured": bool(KIMI_API_KEY), "provider": "Kimi", "name": KIMI_API_NAME, "model": KIMI_MODEL},
"ai": {"configured": bool(KIMI_API_KEY), "enabled": ai_enabled(), "provider": "Kimi", "name": KIMI_API_NAME, "model": KIMI_MODEL},
"server_time": time.strftime("%Y-%m-%d %H:%M:%S"),
}
def annotation_counts_by_study() -> dict[str, int]:
try:
annotations = pg_json_rows(
"""
SELECT ct_number, count(*)::int AS annotated_series
FROM public.pacs_dicom_series_annotations
WHERE skipped IS TRUE
OR jsonb_array_length(body_parts) > 0
OR plain_ct IS TRUE
OR NULLIF(notes, '') IS NOT NULL
OR ai_result IS NOT NULL
GROUP BY ct_number
""",
timeout=8,
)
except Exception:
return {}
return {row["ct_number"]: int(row["annotated_series"] or 0) for row in annotations}
def summary_rows_by_study() -> dict[str, dict[str, Any]]:
try:
ensure_study_summary_table()
rows = pg_json_rows(
"""
SELECT ct_number, series_count, annotated_series, unannotated_series, refreshed_at
FROM public.pacs_dicom_study_summaries
""",
timeout=8,
)
except Exception:
return {}
return {row["ct_number"]: row for row in rows}
@app.get("/api/studies")
def studies(_: str = Depends(require_auth), q: str = "", limit: int = 200) -> list[dict[str, Any]]:
where = ""
@@ -370,24 +520,15 @@ def studies(_: str = Depends(require_auth), q: str = "", limit: int = 200) -> li
LIMIT {int(limit)}
"""
)
annotations = pg_json_rows(
"""
SELECT ct_number, count(*)::int AS annotated_series
FROM public.pacs_dicom_series_annotations
WHERE skipped IS TRUE
OR jsonb_array_length(body_parts) > 0
OR plain_ct IS TRUE
OR NULLIF(notes, '') IS NOT NULL
OR ai_result IS NOT NULL
GROUP BY ct_number
""",
timeout=8,
)
annotation_map = {row["ct_number"]: row["annotated_series"] for row in annotations}
annotation_map = annotation_counts_by_study()
summary_map = summary_rows_by_study()
for row in rows:
total_series = int(row.get("series_count") or 0)
row["annotated_series"] = min(total_series, int(annotation_map.get(row["ct_number"], 0)))
summary = summary_map.get(row["ct_number"])
total_series = int((summary or {}).get("series_count") or row.get("series_count") or 0)
row["series_count"] = total_series
row["annotated_series"] = min(total_series, int((summary or {}).get("annotated_series") if summary else annotation_map.get(row["ct_number"], 0)))
row["unannotated_series"] = max(0, total_series - int(row["annotated_series"]))
row["summary_updated_at"] = (summary or {}).get("refreshed_at", "")
return rows
@@ -790,6 +931,7 @@ def scan_study(ct_number: str) -> dict[str, Any]:
series_list.sort(key=lambda row: (1 if row["annotation"].get("skipped") else 0, *series_time_key(row)))
cached = {"cached_at": time.time(), "study": study, "series": series_list, "files": file_map}
STUDY_CACHE[ct_number] = cached
save_study_summary(study_summary_payload(cached))
return cached
@@ -814,6 +956,82 @@ def study_summary_payload(data: dict[str, Any]) -> dict[str, Any]:
}
def save_study_summary(summary: dict[str, Any]) -> None:
try:
ensure_study_summary_table()
pg_scalar(
f"""
INSERT INTO public.pacs_dicom_study_summaries (
ct_number, series_count, annotated_series, unannotated_series, refreshed_at
)
VALUES (
{sql_literal(summary.get('ct_number', ''))},
{int(summary.get('series_count') or 0)},
{int(summary.get('annotated_series') or 0)},
{int(summary.get('unannotated_series') or 0)},
now()
)
ON CONFLICT (ct_number) DO UPDATE SET
series_count = EXCLUDED.series_count,
annotated_series = EXCLUDED.annotated_series,
unannotated_series = EXCLUDED.unannotated_series,
refreshed_at = now()
""",
timeout=8,
)
except Exception:
pass
def all_ct_numbers() -> list[str]:
rows = pg_json_rows(
f"""
SELECT ct_number
FROM public.{PGTABLE}
ORDER BY study_date DESC NULLS LAST, study_time DESC NULLS LAST, ct_number
""",
timeout=20,
)
return [str(row["ct_number"]) for row in rows if row.get("ct_number")]
def refresh_all_study_summaries(reason: str = "manual") -> None:
if not SUMMARY_REFRESH_LOCK.acquire(blocking=False):
return
SUMMARY_REFRESH_STATE.update({"running": True, "started_at": time.strftime("%Y-%m-%d %H:%M:%S"), "finished_at": "", "message": f"{reason} 刷新中"})
try:
numbers = all_ct_numbers()
for index, ct_number in enumerate(numbers, start=1):
STUDY_CACHE.pop(ct_number, None)
try:
data = scan_study(ct_number)
save_study_summary(study_summary_payload(data))
SUMMARY_REFRESH_STATE["message"] = f"{reason} 刷新中:{index}/{len(numbers)}"
except Exception as exc: # noqa: BLE001
SUMMARY_REFRESH_STATE["message"] = f"{ct_number} 刷新失败:{exc}"
SUMMARY_REFRESH_STATE.update({"finished_at": time.strftime("%Y-%m-%d %H:%M:%S"), "message": f"{reason} 刷新完成:{len(numbers)} 个检查"})
except Exception as exc: # noqa: BLE001
SUMMARY_REFRESH_STATE.update({"finished_at": time.strftime("%Y-%m-%d %H:%M:%S"), "message": f"{reason} 刷新失败:{exc}"})
finally:
SUMMARY_REFRESH_STATE["running"] = False
SUMMARY_REFRESH_LOCK.release()
def next_four_am() -> datetime:
now = datetime.now()
target = now.replace(hour=4, minute=0, second=0, microsecond=0)
if target <= now:
target += timedelta(days=1)
return target
def summary_scheduler_loop() -> None:
while True:
sleep_seconds = max(60.0, (next_four_am() - datetime.now()).total_seconds())
time.sleep(sleep_seconds)
refresh_all_study_summaries("凌晨4点定时")
@app.get("/api/studies/{ct_number}/series")
def series(ct_number: str, _: str = Depends(require_auth)) -> dict[str, Any]:
data = scan_study(ct_number)
@@ -1260,8 +1478,8 @@ def parse_ai_json(content: str) -> dict[str, Any]:
@app.post("/api/series/{ct_number}/{series_uid}/ai")
def ai_classify(ct_number: str, series_uid: str, _: AIRequest, user: str = Depends(require_auth)) -> dict[str, Any]:
if not KIMI_API_KEY:
raise HTTPException(status_code=400, detail="KIMI_API_KEY 未配置")
if not KIMI_API_KEY or not ai_enabled():
raise HTTPException(status_code=400, detail="AI 功能未启用或 KIMI_API_KEY 未配置")
study = scan_study(ct_number)
series_row = next((row for row in study["series"] if row["series_uid"] == series_uid), None)
if not series_row:
@@ -1357,20 +1575,33 @@ def ai_classify(ct_number: str, series_uid: str, _: AIRequest, user: str = Depen
@app.get("/api/settings")
def settings(_: str = Depends(require_auth)) -> dict[str, Any]:
def settings(_: str = Depends(require_admin)) -> dict[str, Any]:
return {
"users": web_users(),
"roles": [{"name": name, "permissions": permissions} for name, permissions in ROLES.items()],
"database": {"host": PGHOST, "port": PGPORT, "database": PGDATABASE, "table": PGTABLE},
"ai": {"provider": "Kimi", "name": KIMI_API_NAME, "model": KIMI_MODEL, "configured": bool(KIMI_API_KEY), "url": KIMI_API_URL},
"ai": {"provider": "Kimi", "name": KIMI_API_NAME, "model": KIMI_MODEL, "configured": bool(KIMI_API_KEY), "enabled": ai_enabled(), "url": KIMI_API_URL},
"dicom": {"processed_root": str(PROCESSED_ROOT)},
"summary_refresh": SUMMARY_REFRESH_STATE,
}
@app.post("/api/settings/ai")
def update_ai_settings(data: AISettingsIn, _: str = Depends(require_admin)) -> dict[str, Any]:
set_setting_value("ai_enabled", "true" if data.enabled else "false")
return {"ok": True, "enabled": data.enabled}
@app.post("/api/settings/refresh-summaries")
def refresh_summaries_now(_: str = Depends(require_admin)) -> dict[str, Any]:
if SUMMARY_REFRESH_STATE.get("running"):
return {"ok": True, **SUMMARY_REFRESH_STATE}
threading.Thread(target=refresh_all_study_summaries, args=("手动",), daemon=True).start()
return {"ok": True, "running": True, "message": "已开始后台刷新"}
@app.post("/api/settings/users")
def create_user(data: UserIn, user: str = Depends(require_auth)) -> dict[str, Any]:
if user != WEB_USER:
raise HTTPException(status_code=403, detail="仅管理员可创建用户")
def create_user(data: UserIn, user: str = Depends(require_admin)) -> dict[str, Any]:
username = data.username.strip()
if not username or len(username) > 64:
raise HTTPException(status_code=400, detail="账号格式不正确")