Files
HIS/患者首页处理/数据可视化网页端/app/main.py

2780 lines
106 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.
from __future__ import annotations
import json
import os
import base64
import hashlib
import secrets
import re
import threading
import time
import urllib.error
import urllib.request
from datetime import date, datetime, timedelta
from decimal import Decimal
from pathlib import Path
from queue import Empty, Queue
from typing import Any
from urllib.parse import urlparse
from zoneinfo import ZoneInfo
import psycopg2
import psycopg2.extras
from dotenv import load_dotenv
from fastapi import FastAPI, HTTPException, Request, Response
from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from psycopg2 import sql
PROJECT_ROOT = Path(__file__).resolve().parents[2]
load_dotenv(PROJECT_ROOT / ".env")
APP_DIR = Path(__file__).resolve().parent
STATIC_DIR = APP_DIR / "static"
def env(name: str, default: str = "") -> str:
return os.getenv(name, default).strip()
DB_CONFIG = {
"host": env("PGHOST", "DB_HOST"),
"port": int(env("PGPORT", "5432")),
"dbname": env("PGDATABASE", "DB_NAME"),
"user": env("PGUSER", "DB_USER"),
"password": env("PGPASSWORD"),
}
PGTABLE = env("PGTABLE", "Patient_FrontPages")
PDF_DIR = Path(env("PDF_DIR", str(PROJECT_ROOT / "待处理-患者首页PDF"))).resolve()
SETTINGS_PATH = Path(env("REVIEW_SETTINGS_PATH", str(PROJECT_ROOT / "数据可视化网页端/review_settings.local.json"))).resolve()
APP_TIMEZONE = ZoneInfo(env("APP_TIMEZONE", "Asia/Shanghai") or "Asia/Shanghai")
FIELD_GROUPS: list[dict[str, Any]] = [
{
"name": "基本信息",
"fields": [
("inpatient_no", "住院号", "text", None),
("medical_record_no", "病案号", "text", None),
("front_page_medical_record_no", "首页病案号", "text", None),
("patient_name", "姓名", "text", None),
("gender", "性别", "text", None),
("birth_date", "出生日期", "date", None),
("age", "年龄", "text", None),
("nationality", "国籍", "text", None),
("id_card_no", "身份证号", "text", None),
("payment_method", "医疗付费方式", "text", None),
("health_card_no", "健康卡号", "text", None),
("admission_count", "住院次数", "integer", None),
("occupation", "职业", "text", None),
("marital_status_code", "婚姻代码", "text", None),
("admission_path_code", "入院途径代码", "text", None),
("admission_time", "入院时间", "datetime", None),
("admission_dept", "入院科别", "text", None),
("admission_ward", "入院病房", "text", None),
("transfer_dept", "转科科别", "text", None),
("transfer_time", "转科时间", "text", None),
("discharge_time", "出院时间", "datetime", None),
("discharge_dept", "出院科别", "text", None),
("discharge_ward", "出院病房", "text", None),
("hospital_days", "实际住院天数", "integer", None),
("major_department", "大科室", "text", None),
],
},
{
"name": "地址联系人",
"fields": [
("current_address", "现住址", "text", None),
("current_address_phone", "现住址电话", "text", None),
("current_address_postcode", "现住址邮编", "text", None),
("household_address", "户口地址", "text", None),
("household_postcode", "户口地址邮编", "text", None),
("employer_address", "工作单位及地址", "text", None),
("employer_phone", "单位电话", "text", None),
("employer_postcode", "单位邮编", "text", None),
("contact_name", "联系人姓名", "text", None),
("contact_relationship", "联系人关系", "text", None),
("contact_address", "联系人地址", "text", None),
("contact_phone", "联系人电话", "text", None),
],
},
{
"name": "诊断表格",
"fields": [
("outpatient_diagnosis", "门急诊诊断", "text", None),
("outpatient_diagnosis_code", "门急诊诊断编码", "text", None),
("primary_diagnosis", "主要诊断", "text", None),
("primary_diagnosis_code", "主要诊断编码", "text", None),
("primary_admission_condition", "主要诊断入院病情", "text", None),
("discharge_diagnoses", "出院诊断", "json", None),
("injury_poisoning_external_cause", "损伤中毒外部原因", "text", None),
("injury_poisoning_code", "损伤中毒疾病编码", "text", None),
("pathology_diagnosis", "病理诊断", "text", None),
("pathology_diagnosis_code", "病理诊断编码", "text", None),
("pathology_no", "病理号", "text", None),
],
},
{
"name": "手术表格",
"fields": [
("operations", "手术操作 JSON", "json", None),
],
},
{
"name": "离院费用",
"fields": [
("discharge_disposition_code", "离院方式代码", "text", None),
("receiving_org_name", "拟接收医疗机构名称", "text", None),
("readmission_plan_code", "出院31天内再住院计划代码", "text", None),
("readmission_plan_purpose", "再住院计划目的", "text", None),
("coma_before_days", "入院前昏迷天数", "integer", None),
("coma_before_hours", "入院前昏迷小时", "integer", None),
("coma_before_minutes", "入院前昏迷分钟", "integer", None),
("coma_after_days", "入院后昏迷天数", "integer", None),
("coma_after_hours", "入院后昏迷小时", "integer", None),
("coma_after_minutes", "入院后昏迷分钟", "integer", None),
("total_cost", "总费用", "numeric", None),
("self_pay_amount", "自付金额", "numeric", None),
("fee_details", "费用明细 JSON", "json", None),
],
},
]
FIELD_META: dict[str, dict[str, Any]] = {}
for group in FIELD_GROUPS:
for name, label, field_type, options in group["fields"]:
FIELD_META[name] = {"name": name, "label": label, "type": field_type, "options": options}
EDITABLE_FIELDS = set(FIELD_META)
JSON_FIELDS = {name for name, meta in FIELD_META.items() if meta["type"] == "json"}
JSON_DB_FIELDS = JSON_FIELDS | {"review_notes", "quality_notes", "auto_corrections"}
INTEGER_FIELDS = {name for name, meta in FIELD_META.items() if meta["type"] == "integer"}
NUMERIC_FIELDS = {name for name, meta in FIELD_META.items() if meta["type"] == "numeric"}
class UpdatePayload(BaseModel):
fields: dict[str, Any]
manual_note: str = ""
note_prefix: str = "人工复核"
class AuditPayload(BaseModel):
audit_status: str = "pending"
audit_notes: str = ""
ai_result: Any = None
class AuditClassifyPayload(BaseModel):
record_id: int
audit_source: str = "reviewed"
audit_status: str
audit_notes: str = ""
fields: dict[str, Any] = {}
class UserPayload(BaseModel):
username: str
password: str = ""
permissions: dict[str, bool] = {}
class UserUpdatePayload(BaseModel):
username: str = ""
password: str = ""
permissions: dict[str, bool] = {}
class PasswordPayload(BaseModel):
password: str = ""
class PermissionPayload(BaseModel):
permissions: dict[str, bool] = {}
class LoginPayload(BaseModel):
username: str = ""
password: str = ""
class SystemSettingsPayload(BaseModel):
status_check_time: str = ""
class KimiSettingsPayload(BaseModel):
enabled: bool = True
model: str = ""
api_base: str = ""
concurrency: int = 3
class AiReviewPayload(BaseModel):
scope: str = "current"
record_id: int | None = None
app = FastAPI(title="Patient Front Page Visual Review")
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
STATUS_CHECK_LOCK = threading.Lock()
WORKFLOW_LOCK = threading.Lock()
WORKFLOW_READY = False
AI_JOB_LOCK = threading.Lock()
AI_REVIEW_JOB: dict[str, Any] = {
"running": False,
"scope": "",
"total": 0,
"processed": 0,
"ok": 0,
"pending": 0,
"failed": 0,
"concurrency": 0,
"message": "",
"errors": [],
"started_at": "",
"finished_at": "",
"last_record_id": None,
}
DEFAULT_STATUS_CHECK_TIME = env("REVIEW_STATUS_CHECK_TIME", "03:00") or "03:00"
DEFAULT_KIMI_API_BASE = env("MOONSHOT_API_BASE", env("KIMI_API_BASE", "https://api.moonshot.cn/v1")) or "https://api.moonshot.cn/v1"
DEFAULT_KIMI_MODEL = env("KIMI_MODEL", "kimi-k2.6") or "kimi-k2.6"
AI_NO_ISSUE_STATUS = "AI复核-无问题"
AI_PENDING_STATUS = "AI复核-待确认"
AI_CONFIRMED_PROBLEM_KEYWORDS = (
"缺少",
"缺失",
"为空白",
"为空",
"空白",
"无编码",
"未填写",
"不清晰",
"不一致",
"错位",
"混乱",
"需人工",
"需要人工",
"待确认",
)
AI_CONFIRMED_PROBLEM_QUALIFIERS = ("确实", "证实", "属实", "", "依然", "存在", "需要", "需人工", "待确认")
AI_STOP_ERROR_MARKERS = (
"exceeded_current_quota_error",
"insufficient_quota",
"consumption budget",
"billing details",
"quota",
"余额",
"额度",
)
AI_JOB_ERROR_LIMIT = 50
PDF_MODULE_DEFINITIONS: list[dict[str, Any]] = [
{
"name": "基本信息",
"keywords": ["住院病案首页", "姓名", "性别", "出生日期", "入院时间", "出院时间", "住院号"],
"note_keywords": ["姓名", "性别", "出生", "入院", "出院", "住院号", "病案号", "年龄"],
"tail": 330,
},
{
"name": "地址联系人",
"keywords": ["现住址", "户口地址", "工作单位及地址", "联系人姓名", "联系人地址"],
"note_keywords": ["地址", "电话", "联系人", "户口", "单位", "邮编"],
"tail": 260,
},
{
"name": "诊断表格",
"keywords": ["门(急)诊诊断", "门急诊诊断", "出院诊断", "疾病编码", "入院病情", "其他诊断"],
"note_keywords": ["诊断", "疾病编码", "编码格式", "入院病情", "病理"],
"tail": 520,
},
{
"name": "手术表格",
"keywords": ["手术及操作编码", "手术及操作日期", "手术及操作名称", "手术级别", "麻醉方式", "术者"],
"note_keywords": ["手术", "操作", "麻醉", "切口", "术者"],
"tail": 520,
},
{
"name": "离院费用",
"keywords": ["离院方式", "出院31天内再住院计划", "住院费用", "总费用", "自付金额", "综合医疗服务类"],
"note_keywords": ["离院", "费用", "总费用", "自付", "金额", "再住院", "昏迷"],
"tail": 520,
},
]
AI_REVIEWABLE_STATUSES = ("needs_review", AI_PENDING_STATUS)
SUBMITTED_STATUS = "已提交"
@app.on_event("startup")
def start_status_scheduler() -> None:
threading.Thread(target=status_scheduler_loop, name="status-check-scheduler", daemon=True).start()
def table_identifier() -> sql.Composable:
if "." in PGTABLE:
schema, table = PGTABLE.split(".", 1)
return sql.Identifier(schema, table)
return sql.Identifier(PGTABLE)
def patient_lists_identifier() -> sql.Composable:
return sql.Identifier("Patient_Lists")
def patient_list_trigger_function_identifier(base_table: str) -> sql.Composable:
function_name = f"{base_table}_sync_patient_lists_trigger_fn"
if "." in PGTABLE:
schema = PGTABLE.split(".", 1)[0]
return sql.Identifier(schema, function_name)
return sql.Identifier(function_name)
def patient_dedupe_trigger_function_identifier(base_table: str) -> sql.Composable:
function_name = f"{base_table}_dedupe_inpatient_no_trigger_fn"
if "." in PGTABLE:
schema = PGTABLE.split(".", 1)[0]
return sql.Identifier(schema, function_name)
return sql.Identifier(function_name)
def related_table_identifier(suffix: str) -> sql.Composable:
if "." in PGTABLE:
schema, table = PGTABLE.split(".", 1)
return sql.Identifier(schema, f"{table}{suffix}")
return sql.Identifier(f"{PGTABLE}{suffix}")
def connect():
return psycopg2.connect(**DB_CONFIG, cursor_factory=psycopg2.extras.RealDictCursor)
def json_ready(value: Any) -> Any:
if isinstance(value, (date, datetime)):
return value.isoformat(sep=" ") if isinstance(value, datetime) else value.isoformat()
if isinstance(value, Decimal):
return str(value)
return value
def row_to_json(row: dict[str, Any]) -> dict[str, Any]:
return {key: json_ready(value) for key, value in row.items()}
def json_ready_deep(value: Any) -> Any:
if isinstance(value, dict):
return {key: json_ready_deep(item) for key, item in value.items()}
if isinstance(value, list):
return [json_ready_deep(item) for item in value]
return json_ready(value)
def comparable(value: Any) -> str:
return json.dumps(json_ready_deep(value), ensure_ascii=False, sort_keys=True, default=str)
PERMISSION_LABELS = {
"overview": "概览",
"review": "复核",
"audit": "抽查",
"audit_history": "抽查一览",
"settings": "设置",
}
DEFAULT_PERMISSIONS = {key: True for key in PERMISSION_LABELS}
SESSION_COOKIE = "frontpage_review_session"
SESSIONS: dict[str, dict[str, Any]] = {}
def password_hash(password: str) -> dict[str, str]:
salt = secrets.token_hex(12)
digest = hashlib.sha256((salt + password).encode("utf-8")).hexdigest()
return {"salt": salt, "password_hash": digest}
def verify_password(password: str, salt: str, digest: str) -> bool:
expected = hashlib.sha256((salt + password).encode("utf-8")).hexdigest()
return secrets.compare_digest(expected, digest or "")
def admin_username() -> str:
return env("REVIEW_ADMIN_USER", "admin") or "admin"
def admin_password() -> str:
return env("REVIEW_ADMIN_PASSWORD", "change-me") or "change-me"
def public_user(username: str, permissions: dict[str, bool], source: str) -> dict[str, Any]:
return {
"username": username,
"permissions": {**DEFAULT_PERMISSIONS, **(permissions or {})},
"source": source,
}
def load_local_settings() -> dict[str, Any]:
if not SETTINGS_PATH.exists():
return {
"users": [],
"permission_labels": PERMISSION_LABELS,
"system": default_system_settings(),
"kimi": default_kimi_settings(),
"status_snapshot": default_status_snapshot(),
}
try:
data = json.loads(SETTINGS_PATH.read_text(encoding="utf-8"))
except json.JSONDecodeError:
data = {
"users": [],
"permission_labels": PERMISSION_LABELS,
"system": default_system_settings(),
"kimi": default_kimi_settings(),
"status_snapshot": default_status_snapshot(),
}
data.setdefault("users", [])
data.setdefault("permission_labels", PERMISSION_LABELS)
data["system"] = normalize_system_settings(data.get("system") or {})
data["kimi"] = normalize_kimi_settings(data.get("kimi") or {})
data.setdefault("status_snapshot", default_status_snapshot())
return data
def save_local_settings(data: dict[str, Any]) -> None:
SETTINGS_PATH.parent.mkdir(parents=True, exist_ok=True)
temp = SETTINGS_PATH.with_suffix(SETTINGS_PATH.suffix + ".tmp")
temp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
temp.replace(SETTINGS_PATH)
def public_settings() -> dict[str, Any]:
data = load_local_settings()
users = []
env_admin_seen = False
for user in data.get("users", []):
users.append(
{
"username": user.get("username", ""),
"permissions": {**DEFAULT_PERMISSIONS, **(user.get("permissions") or {})},
"source": "local",
"created_at": user.get("created_at", ""),
"updated_at": user.get("updated_at", ""),
"has_password": bool(user.get("password_hash")),
}
)
if user.get("username") == admin_username():
env_admin_seen = True
if not env_admin_seen:
users.insert(
0,
{
"username": admin_username(),
"permissions": dict(DEFAULT_PERMISSIONS),
"source": "env",
"created_at": "",
"updated_at": "",
"has_password": True,
},
)
return {
"users": users,
"permission_labels": PERMISSION_LABELS,
"system": normalize_system_settings(data.get("system") or {}),
"kimi": public_kimi_settings(data.get("kimi") or {}),
"status_snapshot": data.get("status_snapshot") or default_status_snapshot(),
}
def normalize_status_check_time(value: str) -> str:
value = (value or DEFAULT_STATUS_CHECK_TIME).strip()
match = re.fullmatch(r"([01]?\d|2[0-3]):([0-5]\d)", value)
if not match:
raise HTTPException(status_code=400, detail="状态检查时间必须是 HH:MM例如 03:00")
return f"{int(match.group(1)):02d}:{match.group(2)}"
def kimi_api_key() -> str:
return env("MOONSHOT_API_KEY") or env("KIMI_API_KEY")
def normalize_kimi_concurrency(value: Any) -> int:
try:
concurrency = int(value)
except (TypeError, ValueError):
concurrency = int(env("KIMI_CONCURRENCY", "3") or 3)
return max(1, min(concurrency, 6))
def default_kimi_settings() -> dict[str, Any]:
return {
"enabled": bool(kimi_api_key()),
"api_base": DEFAULT_KIMI_API_BASE,
"model": DEFAULT_KIMI_MODEL,
"concurrency": normalize_kimi_concurrency(env("KIMI_CONCURRENCY", "3")),
}
def normalize_kimi_settings(kimi: dict[str, Any]) -> dict[str, Any]:
defaults = default_kimi_settings()
api_base = str(kimi.get("api_base") or defaults["api_base"]).strip().rstrip("/")
model = str(kimi.get("model") or defaults["model"]).strip()
return {
"enabled": bool(kimi.get("enabled", defaults["enabled"])),
"api_base": api_base or DEFAULT_KIMI_API_BASE,
"model": model or DEFAULT_KIMI_MODEL,
"concurrency": normalize_kimi_concurrency(kimi.get("concurrency", defaults["concurrency"])),
}
def public_kimi_settings(kimi: dict[str, Any] | None = None) -> dict[str, Any]:
settings = normalize_kimi_settings(kimi or {})
settings["api_key_configured"] = bool(kimi_api_key())
settings["available"] = settings["enabled"] and settings["api_key_configured"]
return settings
def status_now() -> datetime:
return datetime.now(APP_TIMEZONE)
def default_system_settings() -> dict[str, Any]:
return {
"status_check_time": normalize_status_check_time(DEFAULT_STATUS_CHECK_TIME),
"last_status_check_date": "",
"last_status_checked_at": "",
}
def normalize_system_settings(system: dict[str, Any]) -> dict[str, Any]:
defaults = default_system_settings()
merged = {**defaults, **(system or {})}
merged["status_check_time"] = normalize_status_check_time(str(merged.get("status_check_time") or defaults["status_check_time"]))
return merged
def next_status_check_at(system: dict[str, Any] | None = None, now: datetime | None = None) -> str:
now = now or status_now()
system = normalize_system_settings(system or {})
hour, minute = [int(part) for part in system["status_check_time"].split(":")]
next_run = now.replace(hour=hour, minute=minute, second=0, microsecond=0)
if next_run <= now:
next_run += timedelta(days=1)
return next_run.isoformat(timespec="seconds")
def default_status_snapshot() -> dict[str, Any]:
system = default_system_settings()
return {
"database": "unchecked",
"host": DB_CONFIG["host"],
"port": DB_CONFIG["port"],
"database_name": DB_CONFIG["dbname"],
"table": PGTABLE,
"pdf_dir": str(PDF_DIR),
"pdf_count": None,
"total": None,
"review_needed": None,
"needs_review": None,
"auto_passed": None,
"ai_passed": None,
"ai_pending": None,
"reviewed": None,
"submitted": None,
"manual_corrected": None,
"audit_total": None,
"message": "尚未执行状态检查",
"checked_at": "",
"check_source": "",
"next_check_at": next_status_check_at(system),
}
def compute_status_snapshot(source: str = "manual") -> dict[str, Any]:
result: dict[str, Any] = {
"database": "offline",
"host": DB_CONFIG["host"],
"port": DB_CONFIG["port"],
"database_name": DB_CONFIG["dbname"],
"table": PGTABLE,
"pdf_dir": str(PDF_DIR),
"pdf_count": len(list(PDF_DIR.glob("*.pdf"))) if PDF_DIR.exists() else 0,
"checked_at": status_now().isoformat(timespec="seconds"),
"check_source": source,
}
try:
query = sql.SQL(
"""
SELECT
count(*) AS total,
count(*) FILTER (WHERE review_status IN ('needs_review', 'AI复核-待确认')) AS review_needed,
count(*) FILTER (WHERE review_status = 'needs_review') AS needs_review,
count(*) FILTER (WHERE review_status = 'auto_pass') AS auto_passed,
count(*) FILTER (WHERE review_status = 'AI复核-无问题') AS ai_passed,
count(*) FILTER (WHERE review_status = 'AI复核-待确认') AS ai_pending,
count(*) FILTER (WHERE review_status = 'reviewed') AS reviewed,
count(*) FILTER (WHERE review_status = '已提交') AS submitted,
count(*) FILTER (WHERE manual_corrected IS TRUE) AS manual_corrected
FROM {table}
"""
).format(table=table_identifier())
with connect() as conn, conn.cursor() as cur:
cur.execute(query)
row = cur.fetchone()
result.update(row_to_json(dict(row)))
result["audit_total"] = None
result["database"] = "online"
result["message"] = "连接正常"
except Exception as exc: # noqa: BLE001
result["message"] = str(exc)
return result
def refresh_status_snapshot(source: str = "manual") -> dict[str, Any]:
with STATUS_CHECK_LOCK:
snapshot = compute_status_snapshot(source=source)
data = load_local_settings()
system = normalize_system_settings(data.get("system") or {})
now = status_now()
if source == "scheduled":
system["last_status_check_date"] = now.date().isoformat()
system["last_status_checked_at"] = snapshot.get("checked_at", now.isoformat(timespec="seconds"))
snapshot["next_check_at"] = next_status_check_at(system, now)
data["system"] = system
data["status_snapshot"] = snapshot
save_local_settings(data)
return snapshot
def status_scheduler_loop() -> None:
while True:
try:
data = load_local_settings()
system = normalize_system_settings(data.get("system") or {})
now = status_now()
hour, minute = [int(part) for part in system["status_check_time"].split(":")]
due_time = now.replace(hour=hour, minute=minute, second=0, microsecond=0)
already_ran = system.get("last_status_check_date") == now.date().isoformat()
if now >= due_time and not already_ran:
refresh_status_snapshot(source="scheduled")
except Exception:
pass
time.sleep(60)
def clean_permissions(permissions: dict[str, bool]) -> dict[str, bool]:
return {key: bool(permissions.get(key, DEFAULT_PERMISSIONS[key])) for key in PERMISSION_LABELS}
def local_user_index(data: dict[str, Any], username: str) -> int | None:
for index, user in enumerate(data.get("users", [])):
if user.get("username") == username:
return index
return None
def validate_local_username(username: str, data: dict[str, Any], current_username: str = "") -> str:
username = username.strip()
if not username:
raise HTTPException(status_code=400, detail="用户名不能为空")
if username == admin_username() and username != current_username:
raise HTTPException(status_code=400, detail="不能覆盖环境变量管理员")
for user in data.get("users", []):
if user.get("username") == username and user.get("username") != current_username:
raise HTTPException(status_code=400, detail="用户已存在")
return username
def authenticate_user(username: str, password: str) -> dict[str, Any] | None:
username = username.strip()
if username == admin_username():
if secrets.compare_digest(password, admin_password()):
return public_user(username, DEFAULT_PERMISSIONS, "env")
return None
data = load_local_settings()
for user in data.get("users", []):
if user.get("username") != username:
continue
if not user.get("password_hash") or not user.get("salt"):
return None
if verify_password(password, user.get("salt", ""), user.get("password_hash", "")):
return public_user(username, clean_permissions(user.get("permissions") or {}), "local")
return None
return None
def session_from_request(request: Request) -> dict[str, Any] | None:
token = request.cookies.get(SESSION_COOKIE, "")
if not token:
return None
return SESSIONS.get(token)
def page_permission_for_path(path: str, method: str) -> str | tuple[str, ...] | None:
if path in {"/api/status", "/api/schema"}:
return None
if path.startswith("/api/settings"):
return "settings"
if path.startswith("/api/overview"):
return "overview"
if path.startswith("/api/audit/logs") and method == "GET":
return "audit_history"
if path.startswith("/api/audit"):
return "audit"
if path.startswith("/api/ai"):
return "review"
if path.startswith("/api/pdf/"):
return ("review", "audit")
if path == "/api/records":
return "review"
if path.startswith("/api/records/"):
return "review" if method != "GET" else ("review", "audit")
return None
def has_page_permission(user: dict[str, Any], requirement: str | tuple[str, ...] | None) -> bool:
if requirement is None:
return True
permissions = user.get("permissions") or {}
if isinstance(requirement, tuple):
return any(permissions.get(item, False) for item in requirement)
return bool(permissions.get(requirement, False))
@app.middleware("http")
async def auth_middleware(request: Request, call_next):
path = request.url.path
if path.startswith("/api/") and not path.startswith("/api/auth/"):
user = session_from_request(request)
if not user:
return JSONResponse({"detail": "请先登录"}, status_code=401)
requirement = page_permission_for_path(path, request.method)
if not has_page_permission(user, requirement):
return JSONResponse({"detail": "当前用户没有访问权限"}, status_code=403)
request.state.user = user
return await call_next(request)
def digits(value: Any, width: int) -> str:
text = re.sub(r"\D", "", str(value or ""))
return text[-width:].zfill(width) if text else ""
def source_file_inpatient_no(source_file: str) -> str:
match = re.match(r"^(ZY\d{12})", Path(source_file or "").stem, flags=re.IGNORECASE)
return match.group(1).upper() if match else ""
def source_file_admission_count(source_file: str) -> str:
match = re.match(r"^ZY(\d{2})\d{10}", Path(source_file or "").stem, flags=re.IGNORECASE)
return match.group(1) if match else ""
def source_file_medical_record_no(source_file: str) -> str:
match = re.match(r"^ZY\d{2}(\d{10})", Path(source_file or "").stem, flags=re.IGNORECASE)
return match.group(1) if match else ""
def build_inpatient_no_from_record(record: dict[str, Any]) -> str:
source_file = str(record.get("source_file") or "")
admission = digits(record.get("admission_count"), 2) or source_file_admission_count(source_file)
page_no = (
digits(record.get("front_page_medical_record_no"), 10)
or digits(record.get("medical_record_no"), 10)
or source_file_medical_record_no(source_file)
)
if admission and page_no:
return f"ZY{admission}{page_no}"
return source_file_inpatient_no(source_file)
def ensure_workflow_tables(force: bool = False) -> None:
global WORKFLOW_READY
if WORKFLOW_READY and not force:
return
with WORKFLOW_LOCK:
if WORKFLOW_READY and not force:
return
_ensure_workflow_tables_uncached()
WORKFLOW_READY = True
def _ensure_workflow_tables_uncached() -> None:
table = table_identifier()
old_review_logs = related_table_identifier("_review_logs")
old_audit_logs = related_table_identifier("_audit_logs")
schema = PGTABLE.split(".", 1)[0] if "." in PGTABLE else "public"
base_table = PGTABLE.split(".", 1)[-1]
old_review_regclass = f'{schema}."{base_table}_review_logs"'
old_audit_regclass = f'{schema}."{base_table}_audit_logs"'
with connect() as conn, conn.cursor() as cur:
cur.execute("SELECT pg_advisory_xact_lock(hashtext(%s))", (f"{PGTABLE}:workflow_storage",))
cur.execute(sql.SQL("ALTER TABLE {table} ADD COLUMN IF NOT EXISTS inpatient_no TEXT").format(table=table))
cur.execute(sql.SQL("ALTER TABLE {table} ADD COLUMN IF NOT EXISTS major_department TEXT").format(table=table))
cur.execute(
sql.SQL("ALTER TABLE {table} ADD COLUMN IF NOT EXISTS review_logs JSONB NOT NULL DEFAULT '[]'::jsonb").format(
table=table
)
)
cur.execute(
sql.SQL("ALTER TABLE {table} ADD COLUMN IF NOT EXISTS audit_logs JSONB NOT NULL DEFAULT '[]'::jsonb").format(
table=table
)
)
cur.execute(
sql.SQL("COMMENT ON COLUMN {table}.review_logs IS '人工复核修改记录JSONB数组已合并到患者首页主表'").format(
table=table
)
)
cur.execute(
sql.SQL("COMMENT ON COLUMN {table}.audit_logs IS '抽查归类记录JSONB数组已合并到患者首页主表'").format(
table=table
)
)
cur.execute(
sql.SQL("COMMENT ON COLUMN {table}.inpatient_no IS '患者号/住院号,作为首页与患者列表联动唯一键;不能为空,格式由患者目录核验端处理。'").format(
table=table
)
)
cur.execute(
sql.SQL("COMMENT ON COLUMN {table}.major_department IS '大科室分类来源于01_科室分类规则.json。'").format(
table=table
)
)
cur.execute(
sql.SQL(
"""
UPDATE {table}
SET front_page_medical_record_no = RIGHT(LPAD(regexp_replace(front_page_medical_record_no, '\\D', '', 'g'), 10, '0'), 10)
WHERE front_page_medical_record_no IS NOT NULL
AND front_page_medical_record_no <> ''
AND front_page_medical_record_no !~ '^\\d{{10}}$'
AND regexp_replace(front_page_medical_record_no, '\\D', '', 'g') <> ''
"""
).format(table=table)
)
cur.execute(
sql.SQL(
"""
UPDATE {table}
SET inpatient_no =
'ZY'
|| COALESCE(
LPAD(admission_count::text, 2, '0'),
substring(source_file from '^ZY([0-9]{{2}})[0-9]{{10}}')
)
|| RIGHT(
LPAD(
COALESCE(
NULLIF(regexp_replace(COALESCE(front_page_medical_record_no, ''), '\\D', '', 'g'), ''),
NULLIF(regexp_replace(COALESCE(medical_record_no, ''), '\\D', '', 'g'), ''),
substring(source_file from '^ZY[0-9]{{2}}([0-9]{{10}})')
),
10,
'0'
),
10
)
WHERE (inpatient_no IS NULL OR BTRIM(inpatient_no) = '')
AND COALESCE(
LPAD(admission_count::text, 2, '0'),
substring(source_file from '^ZY([0-9]{{2}})[0-9]{{10}}')
) IS NOT NULL
AND COALESCE(
NULLIF(regexp_replace(COALESCE(front_page_medical_record_no, ''), '\\D', '', 'g'), ''),
NULLIF(regexp_replace(COALESCE(medical_record_no, ''), '\\D', '', 'g'), ''),
substring(source_file from '^ZY[0-9]{{2}}([0-9]{{10}})')
) IS NOT NULL
"""
).format(table=table)
)
cur.execute(
sql.SQL("ALTER TABLE {table} DROP CONSTRAINT IF EXISTS {constraint}").format(
table=table,
constraint=sql.Identifier(f"{base_table}_source_file_key"),
)
)
for constraint_name in {
f"ck_{base_table}_inpatient_no_format",
f"ck_{base_table.lower()}_inpatient_no_format",
f"ck_{base_table}_inpatient_no_required",
}:
cur.execute(
sql.SQL("ALTER TABLE {table} DROP CONSTRAINT IF EXISTS {constraint}").format(
table=table,
constraint=sql.Identifier(constraint_name),
)
)
cur.execute(
sql.SQL("DELETE FROM {table} WHERE NULLIF(BTRIM(inpatient_no), '') IS NULL").format(table=table)
)
cur.execute(
sql.SQL("UPDATE {table} SET inpatient_no = BTRIM(inpatient_no) WHERE inpatient_no <> BTRIM(inpatient_no)").format(
table=table
)
)
cur.execute(
sql.SQL(
"""
WITH ranked AS (
SELECT
id,
ROW_NUMBER() OVER (PARTITION BY BTRIM(inpatient_no) ORDER BY id DESC) AS duplicate_rank
FROM {table}
WHERE NULLIF(BTRIM(inpatient_no), '') IS NOT NULL
)
DELETE FROM {table} p
USING ranked
WHERE p.id = ranked.id
AND ranked.duplicate_rank > 1
"""
).format(table=table)
)
cur.execute(sql.SQL("ALTER TABLE {table} ALTER COLUMN inpatient_no SET NOT NULL").format(table=table))
cur.execute("SELECT 1 FROM pg_constraint WHERE conname = %s", (f"ck_{base_table}_inpatient_no_required",))
if not cur.fetchone():
cur.execute(
sql.SQL("ALTER TABLE {table} ADD CONSTRAINT {constraint} CHECK (NULLIF(BTRIM(inpatient_no), '') IS NOT NULL)").format(
table=table,
constraint=sql.Identifier(f"ck_{base_table}_inpatient_no_required"),
)
)
cur.execute(
sql.SQL("CREATE UNIQUE INDEX IF NOT EXISTS {index_name} ON {table}(inpatient_no)").format(
index_name=sql.Identifier(f"{base_table}_inpatient_no_uidx"),
table=table,
)
)
cur.execute("SELECT to_regclass(%s) AS table_oid", (old_review_regclass,))
if cur.fetchone()["table_oid"]:
cur.execute(
sql.SQL(
"""
WITH grouped AS (
SELECT
record_id,
jsonb_agg(
jsonb_build_object(
'id', id::text,
'record_id', record_id,
'source_file', source_file,
'changed_at', changed_at,
'changed_by', changed_by,
'manual_note', manual_note,
'changed_fields', changed_fields
)
ORDER BY changed_at DESC, id DESC
) AS logs
FROM {old_review_logs}
GROUP BY record_id
)
UPDATE {table} p
SET review_logs = COALESCE(p.review_logs, '[]'::jsonb) || grouped.logs
FROM grouped
WHERE p.id = grouped.record_id
"""
).format(table=table, old_review_logs=old_review_logs)
)
cur.execute(sql.SQL("DROP TABLE IF EXISTS {old_review_logs}").format(old_review_logs=old_review_logs))
cur.execute("SELECT to_regclass(%s) AS table_oid", (old_audit_regclass,))
if cur.fetchone()["table_oid"]:
cur.execute(
sql.SQL(
"""
WITH grouped AS (
SELECT
record_id,
jsonb_agg(
jsonb_build_object(
'id', id::text,
'record_id', record_id,
'source_file', source_file,
'audit_source', audit_source,
'audit_status', audit_status,
'audit_notes', audit_notes,
'ai_result', ai_result,
'snapshot', snapshot,
'created_at', created_at,
'updated_at', updated_at
)
ORDER BY updated_at DESC, id DESC
) AS logs
FROM {old_audit_logs}
GROUP BY record_id
)
UPDATE {table} p
SET audit_logs = COALESCE(p.audit_logs, '[]'::jsonb) || grouped.logs
FROM grouped
WHERE p.id = grouped.record_id
"""
).format(table=table, old_audit_logs=old_audit_logs)
)
cur.execute(sql.SQL("DROP TABLE IF EXISTS {old_audit_logs}").format(old_audit_logs=old_audit_logs))
ensure_patient_frontpage_dedupe_trigger(cur)
sync_patient_lists(cur)
ensure_patient_lists_trigger(cur)
conn.commit()
def sync_patient_lists(cur) -> None:
table = table_identifier()
list_table = patient_lists_identifier()
cur.execute(
sql.SQL(
"""
CREATE TABLE IF NOT EXISTS {list_table} (
record_id BIGSERIAL PRIMARY KEY,
batch_name TEXT NOT NULL DEFAULT 'Patient_FrontPages',
major_department TEXT NOT NULL DEFAULT '',
sub_department TEXT NOT NULL DEFAULT '',
source_folder TEXT NOT NULL DEFAULT 'Patient_FrontPages',
image_path TEXT NOT NULL DEFAULT '',
image_name TEXT NOT NULL DEFAULT '',
image_row_no INTEGER NOT NULL DEFAULT 0,
patient_name TEXT NOT NULL DEFAULT '',
gender TEXT,
age TEXT,
inpatient_no TEXT NOT NULL,
diagnosis TEXT,
admission_time TEXT,
last_write_time TEXT,
hospital_days INTEGER,
discharge_time TEXT,
postoperative_days TEXT,
review_status TEXT NOT NULL DEFAULT '首页自动关联',
review_notes TEXT,
manual_corrected BOOLEAN NOT NULL DEFAULT false,
imported_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
"""
).format(list_table=list_table)
)
for name, column_type in [
("has_front_page", "BOOLEAN NOT NULL DEFAULT false"),
("front_page_id", "BIGINT"),
("front_page_source_file", "TEXT"),
]:
cur.execute(
sql.SQL("ALTER TABLE {list_table} ADD COLUMN IF NOT EXISTS {column} " + column_type).format(
list_table=list_table,
column=sql.Identifier(name),
)
)
for constraint_name in {
"ck_patient_lists_inpatient_no_format",
"ck_Patient_Lists_inpatient_no_format",
"ck_patient_lists_inpatient_no_required",
}:
cur.execute(
sql.SQL("ALTER TABLE {list_table} DROP CONSTRAINT IF EXISTS {constraint}").format(
list_table=list_table,
constraint=sql.Identifier(constraint_name),
)
)
cur.execute(
sql.SQL("DELETE FROM {list_table} WHERE NULLIF(BTRIM(inpatient_no), '') IS NULL").format(
list_table=list_table
)
)
cur.execute(
sql.SQL("UPDATE {list_table} SET inpatient_no = BTRIM(inpatient_no) WHERE inpatient_no <> BTRIM(inpatient_no)").format(
list_table=list_table
)
)
cur.execute(
sql.SQL(
"""
WITH ranked AS (
SELECT
record_id,
ROW_NUMBER() OVER (PARTITION BY BTRIM(inpatient_no) ORDER BY record_id DESC) AS duplicate_rank
FROM {list_table}
WHERE NULLIF(BTRIM(inpatient_no), '') IS NOT NULL
)
DELETE FROM {list_table} pl
USING ranked
WHERE pl.record_id = ranked.record_id
AND ranked.duplicate_rank > 1
"""
).format(list_table=list_table)
)
cur.execute(sql.SQL("ALTER TABLE {list_table} ALTER COLUMN inpatient_no SET NOT NULL").format(list_table=list_table))
cur.execute("SELECT 1 FROM pg_constraint WHERE conname = %s", ("ck_patient_lists_inpatient_no_required",))
if not cur.fetchone():
cur.execute(
sql.SQL(
"ALTER TABLE {list_table} ADD CONSTRAINT {constraint} CHECK (NULLIF(BTRIM(inpatient_no), '') IS NOT NULL)"
).format(
list_table=list_table,
constraint=sql.Identifier("ck_patient_lists_inpatient_no_required"),
)
)
cur.execute(sql.SQL("COMMENT ON COLUMN {list_table}.has_front_page IS '是否有患者首页由Patient_FrontPages按住院号自动联动。'").format(list_table=list_table))
cur.execute(sql.SQL("COMMENT ON COLUMN {list_table}.front_page_id IS '关联的Patient_FrontPages.id。'").format(list_table=list_table))
cur.execute(sql.SQL("COMMENT ON COLUMN {list_table}.front_page_source_file IS '关联患者首页PDF文件名。'").format(list_table=list_table))
cur.execute(
sql.SQL("CREATE UNIQUE INDEX IF NOT EXISTS {index_name} ON {list_table}(inpatient_no)").format(
index_name=sql.Identifier("uq_patient_lists_inpatient_no"),
list_table=list_table,
)
)
cur.execute(
sql.SQL(
"""
WITH front_pages AS (
SELECT DISTINCT ON (BTRIM(inpatient_no))
id,
BTRIM(inpatient_no) AS inpatient_no,
source_file,
COALESCE(patient_name, '') AS patient_name,
gender,
age,
COALESCE(major_department, '') AS major_department,
COALESCE(discharge_dept, admission_dept, '') AS sub_department,
primary_diagnosis,
admission_time,
discharge_time,
hospital_days,
manual_corrected
FROM {table}
WHERE NULLIF(BTRIM(inpatient_no), '') IS NOT NULL
ORDER BY BTRIM(inpatient_no), id DESC
)
INSERT INTO {list_table} (
batch_name, major_department, sub_department, source_folder, image_path, image_name,
image_row_no, patient_name, gender, age, inpatient_no, diagnosis, admission_time,
hospital_days, discharge_time, review_status, review_notes, manual_corrected,
has_front_page, front_page_id, front_page_source_file, imported_at
)
SELECT
'Patient_FrontPages',
major_department,
sub_department,
'Patient_FrontPages',
source_file,
source_file,
0,
patient_name,
gender,
age,
inpatient_no,
primary_diagnosis,
to_char(admission_time, 'YYYY-MM-DD HH24:MI:SS'),
hospital_days,
to_char(discharge_time, 'YYYY-MM-DD HH24:MI:SS'),
'首页自动关联',
'由Patient_FrontPages按住院号自动关联',
manual_corrected,
true,
id,
source_file,
now()
FROM front_pages
ON CONFLICT (inpatient_no) DO UPDATE SET
has_front_page = true,
front_page_id = EXCLUDED.front_page_id,
front_page_source_file = EXCLUDED.front_page_source_file,
patient_name = COALESCE(NULLIF(EXCLUDED.patient_name, ''), {list_table}.patient_name),
gender = EXCLUDED.gender,
age = EXCLUDED.age,
major_department = EXCLUDED.major_department,
sub_department = EXCLUDED.sub_department,
manual_corrected = EXCLUDED.manual_corrected,
imported_at = now()
"""
).format(table=table, list_table=list_table)
)
cur.execute(
sql.SQL(
"""
UPDATE {list_table} AS pl
SET has_front_page = false,
front_page_id = NULL,
front_page_source_file = NULL,
imported_at = now()
WHERE has_front_page IS TRUE
AND NOT EXISTS (
SELECT 1 FROM {table} fp
WHERE BTRIM(fp.inpatient_no) = pl.inpatient_no
)
"""
).format(table=table, list_table=list_table)
)
def ensure_patient_frontpage_dedupe_trigger(cur) -> None:
table = table_identifier()
schema = PGTABLE.split(".", 1)[0] if "." in PGTABLE else "public"
base_table = PGTABLE.split(".", 1)[-1]
trigger_name = sql.Identifier(f"trg_{base_table}_dedupe_inpatient_no")
trigger_function = patient_dedupe_trigger_function_identifier(base_table)
cur.execute(
"""
SELECT column_name
FROM information_schema.columns
WHERE table_schema = %s
AND table_name = %s
AND column_name NOT IN ('id', 'inpatient_no', 'review_logs', 'audit_logs')
ORDER BY ordinal_position
""",
(schema, base_table),
)
update_columns = [row["column_name"] for row in cur.fetchall()]
update_assignments = sql.SQL(",\n ").join(
sql.SQL("{column} = NEW.{column}").format(column=sql.Identifier(column_name))
for column_name in update_columns
)
cur.execute(
sql.SQL(
"""
CREATE OR REPLACE FUNCTION {trigger_function}()
RETURNS trigger
LANGUAGE plpgsql
AS $trigger$
DECLARE
existing_id BIGINT;
BEGIN
NEW.inpatient_no := BTRIM(NEW.inpatient_no);
IF NULLIF(NEW.inpatient_no, '') IS NULL THEN
RETURN NEW;
END IF;
IF TG_OP = 'INSERT' THEN
SELECT id
INTO existing_id
FROM {table}
WHERE BTRIM(inpatient_no) = NEW.inpatient_no
ORDER BY id DESC
LIMIT 1;
IF existing_id IS NOT NULL THEN
DELETE FROM {table}
WHERE BTRIM(inpatient_no) = NEW.inpatient_no
AND id <> existing_id;
UPDATE {table}
SET {update_assignments}
WHERE id = existing_id;
RETURN NULL;
END IF;
END IF;
DELETE FROM {table}
WHERE BTRIM(inpatient_no) = NEW.inpatient_no
AND id <> NEW.id;
RETURN NEW;
END;
$trigger$;
"""
).format(trigger_function=trigger_function, table=table, update_assignments=update_assignments)
)
cur.execute(
sql.SQL("DROP TRIGGER IF EXISTS {trigger_name} ON {table}").format(
trigger_name=trigger_name,
table=table,
)
)
cur.execute(
sql.SQL(
"""
CREATE TRIGGER {trigger_name}
BEFORE INSERT OR UPDATE OF inpatient_no ON {table}
FOR EACH ROW EXECUTE FUNCTION {trigger_function}()
"""
).format(trigger_name=trigger_name, table=table, trigger_function=trigger_function)
)
def ensure_patient_lists_trigger(cur) -> None:
table = table_identifier()
list_table = patient_lists_identifier()
base_table = PGTABLE.split(".", 1)[-1]
trigger_name = sql.Identifier(f"trg_{base_table}_sync_patient_lists")
trigger_function = patient_list_trigger_function_identifier(base_table)
cur.execute(
sql.SQL(
"""
CREATE OR REPLACE FUNCTION {trigger_function}()
RETURNS trigger
LANGUAGE plpgsql
AS $trigger$
BEGIN
IF TG_OP = 'DELETE' THEN
IF NULLIF(BTRIM(OLD.inpatient_no), '') IS NOT NULL THEN
UPDATE {list_table} AS pl
SET has_front_page = false,
front_page_id = NULL,
front_page_source_file = NULL,
imported_at = now()
WHERE pl.inpatient_no = BTRIM(OLD.inpatient_no)
AND NOT EXISTS (
SELECT 1 FROM {table} fp
WHERE BTRIM(fp.inpatient_no) = BTRIM(OLD.inpatient_no)
);
END IF;
RETURN OLD;
END IF;
IF TG_OP = 'UPDATE'
AND NULLIF(BTRIM(OLD.inpatient_no), '') IS NOT NULL
AND BTRIM(OLD.inpatient_no) IS DISTINCT FROM BTRIM(NEW.inpatient_no) THEN
UPDATE {list_table} AS pl
SET has_front_page = false,
front_page_id = NULL,
front_page_source_file = NULL,
imported_at = now()
WHERE pl.inpatient_no = BTRIM(OLD.inpatient_no)
AND NOT EXISTS (
SELECT 1 FROM {table} fp
WHERE BTRIM(fp.inpatient_no) = BTRIM(OLD.inpatient_no)
);
END IF;
IF NULLIF(BTRIM(NEW.inpatient_no), '') IS NOT NULL THEN
INSERT INTO {list_table} (
batch_name, major_department, sub_department, source_folder, image_path, image_name,
image_row_no, patient_name, gender, age, inpatient_no, diagnosis, admission_time,
hospital_days, discharge_time, review_status, review_notes, manual_corrected,
has_front_page, front_page_id, front_page_source_file, imported_at
)
VALUES (
'Patient_FrontPages',
COALESCE(NEW.major_department, ''),
COALESCE(NEW.discharge_dept, NEW.admission_dept, ''),
'Patient_FrontPages',
COALESCE(NEW.source_file, ''),
COALESCE(NEW.source_file, ''),
0,
COALESCE(NEW.patient_name, ''),
NEW.gender,
NEW.age,
BTRIM(NEW.inpatient_no),
NEW.primary_diagnosis,
to_char(NEW.admission_time, 'YYYY-MM-DD HH24:MI:SS'),
NEW.hospital_days,
to_char(NEW.discharge_time, 'YYYY-MM-DD HH24:MI:SS'),
'首页自动关联',
'由Patient_FrontPages触发器按住院号自动关联',
COALESCE(NEW.manual_corrected, false),
true,
NEW.id,
NEW.source_file,
now()
)
ON CONFLICT (inpatient_no) DO UPDATE SET
has_front_page = true,
front_page_id = EXCLUDED.front_page_id,
front_page_source_file = EXCLUDED.front_page_source_file,
patient_name = COALESCE(NULLIF(EXCLUDED.patient_name, ''), {list_table}.patient_name),
gender = EXCLUDED.gender,
age = EXCLUDED.age,
major_department = EXCLUDED.major_department,
sub_department = EXCLUDED.sub_department,
manual_corrected = EXCLUDED.manual_corrected,
imported_at = now();
END IF;
RETURN NEW;
END;
$trigger$;
"""
).format(trigger_function=trigger_function, table=table, list_table=list_table)
)
cur.execute(
sql.SQL("DROP TRIGGER IF EXISTS {trigger_name} ON {table}").format(
trigger_name=trigger_name,
table=table,
)
)
cur.execute(
sql.SQL(
"""
CREATE TRIGGER {trigger_name}
AFTER INSERT OR UPDATE OR DELETE ON {table}
FOR EACH ROW EXECUTE FUNCTION {trigger_function}()
"""
).format(trigger_name=trigger_name, table=table, trigger_function=trigger_function)
)
def fetch_review_logs(record_id: int, limit: int = 30) -> list[dict[str, Any]]:
query = sql.SQL("SELECT review_logs FROM {table} WHERE id = %s").format(table=table_identifier())
with connect() as conn, conn.cursor() as cur:
cur.execute(query, (record_id,))
row = cur.fetchone()
if not row:
return []
logs = row.get("review_logs") or []
if not isinstance(logs, list):
return []
normalized = [row_to_json(dict(item)) for item in logs if isinstance(item, dict)]
normalized.sort(key=lambda item: (str(item.get("changed_at") or ""), str(item.get("id") or "")), reverse=True)
return normalized[:limit]
def fetch_audit_logs(limit: int = 100) -> list[dict[str, Any]]:
ensure_workflow_tables()
query = sql.SQL(
"""
SELECT
p.id AS record_id,
p.source_file,
p.inpatient_no,
p.medical_record_no,
p.patient_name,
p.primary_diagnosis,
p.review_status,
log_item.value AS log,
log_item.ordinality AS log_order
FROM {table} p
CROSS JOIN LATERAL jsonb_array_elements(COALESCE(p.audit_logs, '[]'::jsonb)) WITH ORDINALITY AS log_item(value, ordinality)
WHERE COALESCE(log_item.value->>'audit_status', '') <> 'pending'
ORDER BY COALESCE(log_item.value->>'updated_at', log_item.value->>'created_at', '') DESC, log_item.ordinality DESC
LIMIT %s
"""
).format(table=table_identifier())
with connect() as conn, conn.cursor() as cur:
cur.execute(query, (limit,))
rows = cur.fetchall()
logs: list[dict[str, Any]] = []
for row in rows:
base = row_to_json({key: value for key, value in dict(row).items() if key not in {"log", "log_order"}})
item = row.get("log") or {}
if isinstance(item, dict):
log = {**base, **row_to_json(dict(item))}
log.setdefault("record_id", base.get("record_id"))
log.setdefault("source_file", base.get("source_file"))
logs.append(log)
return logs
def insert_review_log(
cur,
record_id: int,
source_file: str,
changed_fields: list[dict[str, Any]],
manual_note: str,
changed_by: str = "web",
ai_result: Any = None,
) -> None:
changed_at = datetime.now().isoformat(timespec="seconds")
log = {
"id": datetime.now().strftime("%Y%m%d%H%M%S%f"),
"record_id": record_id,
"source_file": source_file,
"changed_at": changed_at,
"changed_by": changed_by,
"manual_note": manual_note,
"changed_fields": json_ready_deep(changed_fields),
}
if ai_result is not None:
log["ai_result"] = json_ready_deep(ai_result)
cur.execute(
sql.SQL(
"""
UPDATE {table}
SET review_logs = COALESCE(review_logs, '[]'::jsonb) || %s::jsonb
WHERE id = %s
"""
).format(table=table_identifier()),
(
json.dumps([log], ensure_ascii=False),
record_id,
),
)
def build_record_updates(
record_id: int,
fields: dict[str, Any],
manual_note: str = "",
note_prefix: str = "人工复核",
force_reviewed: bool = True,
) -> tuple[dict[str, Any], list[dict[str, Any]], dict[str, Any]]:
before = fetch_record(record_id)
updates: dict[str, Any] = {}
for field, value in fields.items():
if field not in EDITABLE_FIELDS:
continue
updates[field] = parse_field_value(field, value)
for number_field in ["medical_record_no", "front_page_medical_record_no"]:
if updates.get(number_field):
normalized_number = digits(updates[number_field], 10)
if normalized_number:
updates[number_field] = normalized_number
if "inpatient_no" in updates and updates["inpatient_no"] is not None:
updates["inpatient_no"] = str(updates["inpatient_no"]).strip()
else:
preview = {**before, **updates}
derived_inpatient_no = build_inpatient_no_from_record(preview)
if derived_inpatient_no:
updates["inpatient_no"] = derived_inpatient_no
if "inpatient_no" in updates and not str(updates["inpatient_no"] or "").strip():
raise HTTPException(status_code=400, detail="患者号不能为空")
manual_note = manual_note.strip()
if manual_note:
current = before.get("review_notes") or []
current_notes = current if isinstance(current, list) else [current]
current_notes.append(f"{note_prefix}({datetime.now().strftime('%Y-%m-%d %H:%M:%S')}): {manual_note}")
updates["review_notes"] = current_notes
changed_fields: list[dict[str, Any]] = []
for field, value in updates.items():
if field not in FIELD_META:
continue
old_value = before.get(field)
if comparable(old_value) == comparable(value):
continue
changed_fields.append(
{
"field": field,
"label": FIELD_META[field]["label"],
"old": json_ready_deep(old_value),
"new": json_ready_deep(value),
}
)
should_mark_manual = force_reviewed or bool(changed_fields)
if force_reviewed and before.get("review_status") != "reviewed":
changed_fields.append(
{
"field": "review_status",
"label": "复核状态",
"old": json_ready_deep(before.get("review_status")),
"new": "reviewed",
}
)
if should_mark_manual and before.get("manual_corrected") is not True:
changed_fields.append(
{
"field": "manual_corrected",
"label": "人工修正",
"old": json_ready_deep(before.get("manual_corrected")),
"new": True,
}
)
return updates, changed_fields, before
def apply_record_updates(
cur,
record_id: int,
updates: dict[str, Any],
changed_fields: list[dict[str, Any]],
before: dict[str, Any],
manual_note: str = "",
force_reviewed: bool = True,
) -> None:
assignments = []
values: list[Any] = []
for field, value in updates.items():
assignments.append(sql.SQL("{} = %s").format(sql.Identifier(field)))
values.append(psycopg2.extras.Json(value, dumps=lambda obj: json.dumps(obj, ensure_ascii=False)) if field in JSON_DB_FIELDS else value)
if force_reviewed:
assignments.append(sql.SQL("review_status = 'reviewed'"))
assignments.append(sql.SQL("manual_corrected = TRUE"))
elif changed_fields:
assignments.append(sql.SQL("manual_corrected = TRUE"))
if before.get("review_status") == "auto_pass":
assignments.append(sql.SQL("review_status = 'reviewed'"))
if assignments:
query = sql.SQL("UPDATE {table} SET {assignments} WHERE id = %s").format(
table=table_identifier(),
assignments=sql.SQL(", ").join(assignments),
)
cur.execute(query, [*values, record_id])
if cur.rowcount != 1:
raise HTTPException(status_code=404, detail="记录不存在")
if changed_fields or manual_note.strip():
insert_review_log(cur, record_id, before.get("source_file", ""), changed_fields, manual_note.strip())
def safe_child(root: Path, child_name: str) -> Path:
if Path(child_name).name != child_name:
raise HTTPException(status_code=400, detail="非法文件名")
path = (root / child_name).resolve()
if root not in path.parents and path != root:
raise HTTPException(status_code=400, detail="非法路径")
return path
def get_pdf_path(source_file: str) -> Path | None:
path = safe_child(PDF_DIR, source_file)
return path if path.exists() and path.is_file() else None
def parse_json_content(content: str) -> Any:
text = content.strip()
if text.startswith("```"):
text = text.strip("`")
if text.startswith("json"):
text = text[4:].strip()
try:
return json.loads(text)
except json.JSONDecodeError as exc:
start = text.find("{")
end = text.rfind("}")
if start >= 0 and end > start:
try:
return json.loads(text[start : end + 1])
except json.JSONDecodeError:
pass
return {
"decision": "confirm",
"confidence": 0,
"summary": f"Kimi 返回 JSON 无法解析:{exc.msg}",
"raw_text": content[:4000],
}
def render_pdf_page_data_url(pdf_path: Path, dpi: int = 96, page_index: int = 0, clip: Any | None = None) -> str:
try:
import fitz # type: ignore[import-not-found]
except ImportError as exc:
raise HTTPException(status_code=500, detail="Web 容器缺少 PyMuPDF无法渲染 PDF 供 AI 核验") from exc
with fitz.open(str(pdf_path)) as document:
if document.page_count < 1:
raise HTTPException(status_code=400, detail="PDF 没有可渲染页面")
page = document.load_page(max(0, min(page_index, document.page_count - 1)))
matrix = fitz.Matrix(dpi / 72, dpi / 72)
pixmap = page.get_pixmap(matrix=matrix, alpha=False, clip=clip)
encoded = base64.b64encode(pixmap.tobytes("png")).decode("utf-8")
return f"data:image/png;base64,{encoded}"
def normalize_pdf_text(text: str) -> str:
return re.sub(r"\s+", "", text or "")
def record_review_text(record: dict[str, Any]) -> str:
parts: list[str] = []
for key in ("review_notes", "quality_notes", "auto_corrections"):
value = record.get(key)
if isinstance(value, list):
parts.extend(json.dumps(item, ensure_ascii=False) if not isinstance(item, str) else item for item in value)
elif value:
parts.append(str(value))
return "".join(parts)
def relevant_pdf_modules(record: dict[str, Any]) -> list[dict[str, Any]]:
text = record_review_text(record)
normalized = normalize_pdf_text(text)
selected: list[dict[str, Any]] = []
for module in PDF_MODULE_DEFINITIONS:
if any(normalize_pdf_text(keyword) in normalized for keyword in module["note_keywords"]):
selected.append(module)
if not selected:
selected = [module for module in PDF_MODULE_DEFINITIONS if module["name"] in {"诊断表格", "手术表格"}]
return selected[:4]
def clip_text_from_blocks(blocks: list[dict[str, Any]], page_index: int, clip: Any) -> str:
lines: list[str] = []
for block in blocks:
if block["page_index"] != page_index:
continue
rect = block["rect"]
if rect.intersects(clip):
text = " ".join(str(block["text"]).split())
if text:
lines.append(text)
joined = "\n".join(lines)
return joined[:2200]
def add_keyword_from_value(keywords: list[str], value: Any) -> None:
if value in {None, ""}:
return
text = str(value).strip()
if len(text) >= 2:
keywords.append(text)
def record_module_keywords(record: dict[str, Any], module_name: str) -> list[str]:
keywords: list[str] = []
if module_name == "诊断表格":
for field in ("outpatient_diagnosis", "outpatient_diagnosis_code", "primary_diagnosis", "primary_diagnosis_code", "pathology_diagnosis", "pathology_diagnosis_code"):
add_keyword_from_value(keywords, record.get(field))
diagnoses = record.get("discharge_diagnoses")
if isinstance(diagnoses, list):
for row in diagnoses[:8]:
if isinstance(row, dict):
for key in ("出院诊断", "疾病编码", "诊断名称", "诊断编码"):
add_keyword_from_value(keywords, row.get(key))
elif module_name == "手术表格":
operations = record.get("operations")
if isinstance(operations, list):
for row in operations[:6]:
if isinstance(row, dict):
for key in ("手术操作名称", "手术操作编码", "手术操作日期"):
add_keyword_from_value(keywords, row.get(key))
elif module_name == "地址联系人":
for field in ("current_address", "household_address", "employer_address", "contact_name", "contact_address", "contact_phone"):
add_keyword_from_value(keywords, record.get(field))
elif module_name == "基本信息":
for field in ("patient_name", "medical_record_no", "inpatient_no", "id_card_no", "admission_time", "discharge_time"):
add_keyword_from_value(keywords, record.get(field))
elif module_name == "离院费用":
for field in ("total_cost", "self_pay_amount", "discharge_disposition_code", "receiving_org_name"):
add_keyword_from_value(keywords, record.get(field))
return keywords
def extract_pdf_module_context(pdf_path: Path, record: dict[str, Any]) -> dict[str, Any]:
try:
import fitz # type: ignore[import-not-found]
except ImportError as exc:
raise HTTPException(status_code=500, detail="Web 容器缺少 PyMuPDF无法提取 PDF 文本") from exc
modules = relevant_pdf_modules(record)
contexts: list[dict[str, Any]] = []
all_blocks: list[dict[str, Any]] = []
with fitz.open(str(pdf_path)) as document:
for page_index in range(document.page_count):
page = document.load_page(page_index)
for block in page.get_text("blocks"):
if len(block) < 5:
continue
text = str(block[4] or "").strip()
if not text:
continue
all_blocks.append(
{
"page_index": page_index,
"rect": fitz.Rect(block[:4]),
"text": text,
"normalized": normalize_pdf_text(text),
}
)
for module in modules:
module_keywords = [*module["keywords"], *record_module_keywords(record, module["name"])]
keywords = [normalize_pdf_text(keyword) for keyword in module_keywords]
hits = [block for block in all_blocks if any(keyword and keyword in block["normalized"] for keyword in keywords)]
if not hits:
continue
page_counts: dict[int, int] = {}
for hit in hits:
page_counts[hit["page_index"]] = page_counts.get(hit["page_index"], 0) + 1
page_index = max(page_counts, key=page_counts.get)
page = document.load_page(page_index)
page_hits = [hit for hit in hits if hit["page_index"] == page_index]
min_y = min(hit["rect"].y0 for hit in page_hits)
max_y = max(hit["rect"].y1 for hit in page_hits)
tail = float(module.get("tail") or 420)
clip = fitz.Rect(
max(page.rect.x0, page.rect.x0 + 18),
max(page.rect.y0, min_y - 60),
min(page.rect.x1, page.rect.x1 - 18),
min(page.rect.y1, max(max_y + tail, min_y + 220)),
)
contexts.append(
{
"name": module["name"],
"page": page_index + 1,
"bbox": [round(clip.x0, 1), round(clip.y0, 1), round(clip.x1, 1), round(clip.y1, 1)],
"text": clip_text_from_blocks(all_blocks, page_index, clip),
"image_url": render_pdf_page_data_url(pdf_path, dpi=120, page_index=page_index, clip=clip),
}
)
full_text = "\n".join(" ".join(str(block["text"]).split()) for block in all_blocks)
return {
"modules": contexts,
"full_text_excerpt": full_text[:3500],
}
def ai_record_snapshot(record: dict[str, Any]) -> dict[str, Any]:
keys = [
"source_file",
"inpatient_no",
"medical_record_no",
"front_page_medical_record_no",
"patient_name",
"gender",
"birth_date",
"age",
"admission_time",
"admission_dept",
"discharge_time",
"discharge_dept",
"hospital_days",
"outpatient_diagnosis",
"outpatient_diagnosis_code",
"primary_diagnosis",
"primary_diagnosis_code",
"primary_admission_condition",
"discharge_diagnoses",
"operations",
"pathology_diagnosis",
"pathology_diagnosis_code",
"total_cost",
"self_pay_amount",
"quality_status",
"quality_notes",
"review_notes",
]
return {key: record.get(key) for key in keys}
def build_ai_prompt(record: dict[str, Any], pdf_context: dict[str, Any]) -> str:
snapshot = json.dumps(ai_record_snapshot(record), ensure_ascii=False, indent=2)
context_for_prompt = {
"modules": [
{
"name": item["name"],
"page": item["page"],
"bbox": item["bbox"],
"pdf_text": item["text"],
}
for item in pdf_context.get("modules", [])
],
"full_text_excerpt": pdf_context.get("full_text_excerpt", ""),
}
context_json = json.dumps(context_for_prompt, ensure_ascii=False, indent=2)
return f"""
请对这份住院病案首页做视觉核验,只返回 JSON不要输出 Markdown。
核验目标:
1. 优先核对“PDF定位文本”和对应的局部截图局部截图是按 PDF 中文本特征截取出来的区域。
2. 将 PDF 文本、局部截图中的可见内容,与下面的结构化字段逐项对比。
3. 只有当复核提示被 PDF 明确证明为误报,且结构化字段与 PDF 可见内容一致、完整、无实质质量问题时decision 才能返回 "ok"
4. 如果复核提示是编码缺失/格式异常/错位/不一致,而 PDF 对应区域也显示为空白、缺失、异常或不清晰这说明问题被证实decision 必须返回 "confirm"
5. 如果图片不清晰、字段不一致、编码缺失/错位、手术/诊断仍有疑点decision 返回 "confirm"
6. 如果能从 PDF 读出明确修正值,请放入 suggested_updates但 decision 仍返回 "confirm",等待人工确认。
7. 不要编造 PDF 中看不见的内容。
8. 如果手术表格中能看到“手术及操作编码”列,但对应单元格为空,必须写“编码栏可见但为空白”,不要写“编码区域未在首页显示”。
9. 手术操作名称可能因为换行被结构化解析截断;如果 PDF定位文本或局部截图中显示完整多行名称请把完整名称放入 suggested_updates。
必须返回这个 JSON 结构:
{{
"decision": "ok 或 confirm",
"confidence": 0.0,
"issue_resolution": "false_positive/confirmed_problem/uncertain/update_suggested",
"confirmed_issue": false,
"summary": "一句话结论60字以内",
"method": "Kimi视觉核验PDF文本定位+局部截图,对照复核定位和结构化字段",
"evidence": [
{{"target": "核验点", "pdf_value": "PDF图片值", "structured_value": "结构化值", "result": "match/mismatch/uncertain", "note": "30字以内"}}
],
"suggested_updates": [
{{"field": "字段名", "current": "当前值", "pdf_value": "PDF图片建议值", "reason": ""}}
]
}}
evidence 最多返回 3 条,只保留最关键依据;没有明确修正值时 suggested_updates 返回 []。
结构化字段:
{snapshot}
PDF定位文本
{context_json}
""".strip()
def call_kimi_ai_review(record: dict[str, Any]) -> dict[str, Any]:
settings = public_kimi_settings(load_local_settings().get("kimi") or {})
if not settings["available"]:
raise HTTPException(status_code=400, detail="Kimi AI 核验未启用或未配置 API Key")
pdf_path = get_pdf_path(record.get("source_file") or "")
if not pdf_path:
raise HTTPException(status_code=404, detail="PDF 文件不存在,无法 AI 核验")
pdf_context = extract_pdf_module_context(pdf_path, record)
content_parts: list[dict[str, Any]] = [{"type": "text", "text": build_ai_prompt(record, pdf_context)}]
for item in pdf_context.get("modules", [])[:4]:
content_parts.append({"type": "text", "text": f"局部截图:{item['name']},第 {item['page']}bbox={item['bbox']}"})
content_parts.append({"type": "image_url", "image_url": {"url": item["image_url"]}})
if not pdf_context.get("modules"):
content_parts.append({"type": "text", "text": "未能定位到局部模块,以下为首页整页截图。"})
content_parts.append({"type": "image_url", "image_url": {"url": render_pdf_page_data_url(pdf_path)}})
payload = {
"model": settings["model"],
"temperature": 0.6,
"max_tokens": 1600,
"thinking": {"type": "disabled"},
"response_format": {"type": "json_object"},
"messages": [
{"role": "system", "content": "你是严谨的病案首页视觉核验助手,只输出 JSON。"},
{
"role": "user",
"content": content_parts,
},
],
}
request = urllib.request.Request(
f"{settings['api_base'].rstrip('/')}/chat/completions",
data=json.dumps(payload, ensure_ascii=False).encode("utf-8"),
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {kimi_api_key()}",
},
method="POST",
)
try:
with urllib.request.urlopen(request, timeout=90) as response:
data = json.loads(response.read().decode("utf-8"))
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", errors="replace")
raise HTTPException(status_code=502, detail=f"Kimi API 返回错误 {exc.code}: {detail}") from exc
except urllib.error.URLError as exc:
raise HTTPException(status_code=502, detail=f"Kimi API 调用失败:{exc}") from exc
message = data["choices"][0].get("message") or {}
content = message.get("content") or message.get("reasoning_content") or ""
parsed = parse_json_content(content)
if not isinstance(parsed, dict):
parsed = {"decision": "confirm", "summary": "Kimi 返回 JSON 不是对象", "raw_response": content}
return {
"model": settings["model"],
"checked_at": datetime.now().isoformat(timespec="seconds"),
"pdf_context": {
"modules": [
{key: item[key] for key in ("name", "page", "bbox", "text")}
for item in pdf_context.get("modules", [])
],
"full_text_excerpt": pdf_context.get("full_text_excerpt", ""),
},
"raw_response": content,
"parsed": parsed,
}
def ai_bool(value: Any) -> bool:
if isinstance(value, bool):
return value
if isinstance(value, (int, float)):
return bool(value)
return str(value or "").strip().lower() in {"true", "yes", "1", "", "", "确认"}
def ai_join_text(value: Any) -> str:
if isinstance(value, str):
return value
if isinstance(value, list):
return " ".join(ai_join_text(item) for item in value)
if isinstance(value, dict):
return " ".join(ai_join_text(item) for item in value.values())
return str(value or "")
def ai_has_confirmed_problem(parsed: dict[str, Any]) -> bool:
resolution = str(parsed.get("issue_resolution") or "").strip().lower()
if ai_bool(parsed.get("confirmed_issue")):
return True
if resolution in {"confirmed_problem", "uncertain", "update_suggested", "problem", "待确认", "已证实"}:
return True
suggested_updates = parsed.get("suggested_updates")
if isinstance(suggested_updates, list) and suggested_updates:
return True
evidence = parsed.get("evidence")
if isinstance(evidence, list):
for item in evidence:
if not isinstance(item, dict):
continue
result = str(item.get("result") or "").strip().lower()
if result in {"mismatch", "uncertain", "missing", "problem"}:
return True
if resolution in {"false_positive", "ok", "no_issue", "误报", "无问题"}:
return False
text = ai_join_text(
{
"summary": parsed.get("summary"),
"evidence": parsed.get("evidence"),
}
)
if any(keyword in text for keyword in ("需人工", "需要人工", "待确认")):
return True
has_problem_word = any(keyword in text for keyword in AI_CONFIRMED_PROBLEM_KEYWORDS)
has_qualifier = any(keyword in text for keyword in AI_CONFIRMED_PROBLEM_QUALIFIERS)
return bool(has_problem_word and has_qualifier)
def ai_status_from_result(result: dict[str, Any]) -> str:
parsed = result.get("parsed") if isinstance(result.get("parsed"), dict) else {}
decision = str(parsed.get("decision") or "").lower()
confidence = parsed.get("confidence")
try:
confidence_value = float(confidence)
except (TypeError, ValueError):
confidence_value = 0.0
if ai_has_confirmed_problem(parsed):
return AI_PENDING_STATUS
if decision in {"ok", "pass", "no_issue", "无问题", "通过"} and confidence_value >= 0.65:
return AI_NO_ISSUE_STATUS
return AI_PENDING_STATUS
def ai_review_note(status: str, result: dict[str, Any]) -> str:
parsed = result.get("parsed") if isinstance(result.get("parsed"), dict) else {}
summary = str(parsed.get("summary") or "").strip()
confidence = parsed.get("confidence")
confidence_text = f",置信度 {confidence}" if confidence not in {None, ""} else ""
verdict = "未发现问题" if status == AI_NO_ISSUE_STATUS else "需要人工确认"
return f"Kimi视觉核验({datetime.now().strftime('%Y-%m-%d %H:%M:%S')}): {verdict}{confidence_text}{summary}".strip()
def apply_ai_review(record_id: int) -> dict[str, Any]:
before = fetch_record(record_id)
result = call_kimi_ai_review(before)
new_status = ai_status_from_result(result)
note = ai_review_note(new_status, result)
previous_notes = before.get("review_notes") if isinstance(before.get("review_notes"), list) else []
new_notes = [note] if new_status == AI_NO_ISSUE_STATUS else [*previous_notes, note]
changed_fields = [
{
"field": "ai_method",
"label": "AI核验方式",
"old": "",
"new": "Kimi视觉核验PDF文本定位+局部截图,对照复核定位和结构化字段",
},
{
"field": "review_status",
"label": "复核状态",
"old": json_ready_deep(before.get("review_status")),
"new": new_status,
},
{
"field": "review_notes",
"label": "复核备注",
"old": json_ready_deep(before.get("review_notes")),
"new": json_ready_deep(new_notes),
},
]
with connect() as conn, conn.cursor() as cur:
cur.execute(
sql.SQL("UPDATE {table} SET review_status = %s, review_notes = %s::jsonb WHERE id = %s").format(table=table_identifier()),
(new_status, json.dumps(new_notes, ensure_ascii=False), record_id),
)
insert_review_log(cur, record_id, before.get("source_file", ""), changed_fields, note, changed_by="Kimi AI", ai_result=result)
conn.commit()
return {"record_id": record_id, "status": new_status, "result": result}
def apply_ai_review_with_retry(record_id: int, attempts: int = 2) -> dict[str, Any]:
last_exc: Exception | None = None
for attempt in range(attempts):
try:
return apply_ai_review(record_id)
except Exception as exc: # noqa: BLE001
last_exc = exc
if attempt + 1 < attempts:
time.sleep(4 + attempt * 4)
if last_exc:
raise last_exc
raise RuntimeError("AI核验失败")
def ai_target_ids(scope: str, record_id: int | None) -> list[int]:
if scope not in {"current", "five", "all"}:
raise HTTPException(status_code=400, detail="AI核验范围只能是 current/five/all")
if scope in {"current", "five"} and not record_id:
raise HTTPException(status_code=400, detail="缺少当前记录 ID")
if scope == "current":
return [int(record_id)]
where = sql.SQL("review_status = 'needs_review'")
params: list[Any] = []
if scope == "five":
where = sql.SQL("review_status = 'needs_review' AND id > %s")
params.append(record_id)
limit_sql = sql.SQL("LIMIT 5")
else:
if record_id:
where = sql.SQL("review_status = 'needs_review' AND id > %s")
params.append(record_id)
limit_sql = sql.SQL("")
query = sql.SQL("SELECT id FROM {table} WHERE {where} ORDER BY id {limit}").format(
table=table_identifier(),
where=where,
limit=limit_sql,
)
with connect() as conn, conn.cursor() as cur:
cur.execute(query, params)
rows = cur.fetchall()
return [int(row["id"]) for row in rows]
def update_ai_job(**updates: Any) -> dict[str, Any]:
with AI_JOB_LOCK:
AI_REVIEW_JOB.update(updates)
return dict(AI_REVIEW_JOB)
def is_ai_stop_error(message: str) -> bool:
lower = message.lower()
return any(marker in lower for marker in AI_STOP_ERROR_MARKERS)
def append_ai_job_error(record_id: int, message: str) -> None:
errors = AI_REVIEW_JOB.setdefault("errors", [])
errors.append({"record_id": record_id, "message": message})
if len(errors) > AI_JOB_ERROR_LIMIT:
del errors[: len(errors) - AI_JOB_ERROR_LIMIT]
def run_ai_review_job(scope: str, ids: list[int]) -> None:
settings = public_kimi_settings(load_local_settings().get("kimi") or {})
concurrency = min(max(1, int(settings.get("concurrency") or 3)), max(1, len(ids)))
stop_event = threading.Event()
update_ai_job(
running=True,
scope=scope,
total=len(ids),
processed=0,
ok=0,
pending=0,
failed=0,
concurrency=concurrency,
message="AI核验中",
errors=[],
started_at=datetime.now().isoformat(timespec="seconds"),
finished_at="",
last_record_id=None,
)
record_queue: Queue[int] = Queue()
for record_id in ids:
record_queue.put(record_id)
def worker() -> None:
try:
while not stop_event.is_set():
try:
record_id = record_queue.get_nowait()
except Empty:
return
try:
item = apply_ai_review_with_retry(record_id)
status = item.get("status")
with AI_JOB_LOCK:
AI_REVIEW_JOB["processed"] += 1
AI_REVIEW_JOB["last_record_id"] = record_id
if status == AI_NO_ISSUE_STATUS:
AI_REVIEW_JOB["ok"] += 1
elif status == AI_PENDING_STATUS:
AI_REVIEW_JOB["pending"] += 1
except Exception as exc: # noqa: BLE001
message = str(getattr(exc, "detail", exc))
with AI_JOB_LOCK:
AI_REVIEW_JOB["processed"] += 1
AI_REVIEW_JOB["failed"] += 1
AI_REVIEW_JOB["last_record_id"] = record_id
AI_REVIEW_JOB["message"] = message
append_ai_job_error(record_id, message)
if is_ai_stop_error(message):
AI_REVIEW_JOB["message"] = f"AI核验已暂停{message}"
stop_event.set()
finally:
record_queue.task_done()
except Exception as exc: # noqa: BLE001
with AI_JOB_LOCK:
AI_REVIEW_JOB["message"] = str(exc)
workers = [
threading.Thread(target=worker, name=f"kimi-ai-worker-{index + 1}", daemon=True)
for index in range(concurrency)
]
for thread in workers:
thread.start()
for thread in workers:
thread.join()
refresh_status_snapshot(source="ai")
with AI_JOB_LOCK:
failed = int(AI_REVIEW_JOB.get("failed") or 0)
stopped = stop_event.is_set()
message = str(AI_REVIEW_JOB.get("message") or "")
update_ai_job(
running=False,
message=message if stopped else ("AI核验完成" if failed == 0 else f"AI核验完成失败 {failed}"),
finished_at=datetime.now().isoformat(timespec="seconds"),
)
def mark_ai_no_issue_reviewed() -> int:
changed_at = datetime.now().isoformat(timespec="seconds")
note = f"批量确认AI复核-无问题({datetime.now().strftime('%Y-%m-%d %H:%M:%S')}): 确认为已人工复核"
query = sql.SQL(
"""
UPDATE {table}
SET review_status = 'reviewed',
manual_corrected = TRUE,
review_logs = COALESCE(review_logs, '[]'::jsonb) || jsonb_build_array(
jsonb_build_object(
'id', %s || id::text,
'record_id', id,
'source_file', source_file,
'changed_at', %s,
'changed_by', 'web',
'manual_note', %s,
'changed_fields', jsonb_build_array(
jsonb_build_object('field', 'review_status', 'label', '复核状态', 'old', review_status, 'new', 'reviewed'),
jsonb_build_object('field', 'manual_corrected', 'label', '人工修正', 'old', COALESCE(manual_corrected, false), 'new', true)
)
)
)
WHERE review_status = 'AI复核-无问题'
"""
).format(table=table_identifier())
with connect() as conn, conn.cursor() as cur:
cur.execute(query, (datetime.now().strftime("%Y%m%d%H%M%S%f"), changed_at, note))
count = cur.rowcount
conn.commit()
refresh_status_snapshot(source="bulk")
return int(count)
def submit_reviewed_records() -> int:
changed_at = datetime.now().isoformat(timespec="seconds")
note = f"一键提交已人工复核项目({datetime.now().strftime('%Y-%m-%d %H:%M:%S')}): 已从患者首页复核工作台隐藏"
query = sql.SQL(
"""
UPDATE {table}
SET review_status = '已提交',
review_logs = COALESCE(review_logs, '[]'::jsonb) || jsonb_build_array(
jsonb_build_object(
'id', %s || id::text,
'record_id', id,
'source_file', source_file,
'changed_at', %s,
'changed_by', 'web',
'manual_note', %s,
'changed_fields', jsonb_build_array(
jsonb_build_object('field', 'review_status', 'label', '复核状态', 'old', review_status, 'new', '已提交')
)
)
)
WHERE review_status = 'reviewed'
"""
).format(table=table_identifier())
with connect() as conn, conn.cursor() as cur:
cur.execute(query, (datetime.now().strftime("%Y%m%d%H%M%S%f"), changed_at, note))
count = cur.rowcount
conn.commit()
refresh_status_snapshot(source="submit")
return int(count)
def parse_field_value(field: str, value: Any) -> Any:
meta = FIELD_META[field]
field_type = meta["type"]
if value == "":
return [] if field in JSON_FIELDS else None
if field in JSON_FIELDS:
if isinstance(value, str):
try:
return json.loads(value)
except json.JSONDecodeError as exc:
raise HTTPException(status_code=400, detail=f"{meta['label']} 不是合法 JSON{exc}") from exc
return value
if field in INTEGER_FIELDS:
return None if value is None else int(value)
if field in NUMERIC_FIELDS:
return None if value is None else Decimal(str(value))
return value
def fetch_record(record_id: int) -> dict[str, Any]:
query = sql.SQL("SELECT * FROM {table} WHERE id = %s").format(table=table_identifier())
with connect() as conn, conn.cursor() as cur:
cur.execute(query, (record_id,))
row = cur.fetchone()
if not row:
raise HTTPException(status_code=404, detail="记录不存在")
record = row_to_json(dict(row))
record["pdf_url"] = f"/api/pdf/{record['source_file']}" if get_pdf_path(record["source_file"]) else ""
record["review_logs"] = fetch_review_logs(record_id)
record["last_activity_at"] = record["review_logs"][0].get("changed_at") if record["review_logs"] else None
return record
@app.get("/")
def index():
return FileResponse(STATIC_DIR / "index.html")
@app.get("/favicon.ico")
def favicon():
return Response(status_code=204)
@app.post("/api/auth/login")
def login(payload: LoginPayload, response: Response):
user = authenticate_user(payload.username, payload.password)
if not user:
raise HTTPException(status_code=401, detail="用户名或密码错误")
token = secrets.token_urlsafe(32)
SESSIONS[token] = {
**user,
"login_at": datetime.now().isoformat(timespec="seconds"),
}
response.set_cookie(
SESSION_COOKIE,
token,
httponly=True,
samesite="lax",
max_age=12 * 60 * 60,
)
return {"authenticated": True, "user": SESSIONS[token]}
@app.post("/api/auth/logout")
def logout(request: Request, response: Response):
token = request.cookies.get(SESSION_COOKIE, "")
if token:
SESSIONS.pop(token, None)
response.delete_cookie(SESSION_COOKIE)
return {"ok": True}
@app.get("/api/auth/me")
def auth_me(request: Request):
user = session_from_request(request)
if not user:
return {"authenticated": False, "user": None}
return {"authenticated": True, "user": user}
@app.get("/api/status")
def status():
data = load_local_settings()
snapshot = data.get("status_snapshot") or default_status_snapshot()
system = normalize_system_settings(data.get("system") or {})
snapshot["next_check_at"] = next_status_check_at(system)
try:
query = sql.SQL(
"""
SELECT
count(*) AS total,
count(*) FILTER (WHERE review_status IN ('needs_review', 'AI复核-待确认')) AS review_needed,
count(*) FILTER (WHERE review_status = 'needs_review') AS needs_review,
count(*) FILTER (WHERE review_status = 'AI复核-无问题') AS ai_passed,
count(*) FILTER (WHERE review_status = 'AI复核-待确认') AS ai_pending,
count(*) FILTER (WHERE review_status = 'reviewed') AS reviewed,
count(*) FILTER (WHERE review_status = '已提交') AS submitted,
count(*) FILTER (WHERE manual_corrected IS TRUE) AS manual_corrected
FROM {table}
"""
).format(table=table_identifier())
with connect() as conn, conn.cursor() as cur:
cur.execute(query)
snapshot.update(row_to_json(dict(cur.fetchone())))
snapshot["database"] = "online"
snapshot["message"] = snapshot.get("message") or "连接正常"
snapshot["checked_at"] = status_now().isoformat(timespec="seconds")
except Exception as exc: # noqa: BLE001
snapshot["database"] = "offline"
snapshot["message"] = str(exc)
return snapshot
@app.get("/api/schema")
def schema():
return {"groups": FIELD_GROUPS}
@app.get("/api/overview")
def overview():
query = sql.SQL(
"""
SELECT
count(*) AS total,
count(*) FILTER (WHERE review_status IN ('needs_review', 'AI复核-待确认')) AS review_queue,
count(*) FILTER (WHERE review_status = 'needs_review') AS needs_review,
count(*) FILTER (WHERE review_status = 'AI复核-无问题') AS ai_passed,
count(*) FILTER (WHERE review_status = 'AI复核-待确认') AS ai_pending,
count(*) FILTER (WHERE review_status = 'auto_corrected') AS auto_corrected,
count(*) FILTER (WHERE review_status = 'reviewed') AS reviewed,
count(*) FILTER (WHERE review_status = '已提交') AS submitted,
count(*) FILTER (WHERE review_status = 'auto_pass') AS auto_passed,
count(*) FILTER (WHERE manual_corrected IS TRUE) AS manual_corrected
FROM {table}
"""
).format(table=table_identifier())
with connect() as conn, conn.cursor() as cur:
cur.execute(query)
summary = row_to_json(dict(cur.fetchone()))
summary.update({"audit_total": 0, "audit_pending": 0, "audit_passed": 0, "audit_failed": 0, "audit_unsure": 0})
return {
"summary": summary,
"recent_logs": [],
}
@app.get("/api/records")
def list_records(q: str = "", status_filter: str = "review_all", limit: int = 300, offset: int = 0):
limit = max(50, min(int(limit), 500))
offset = max(0, int(offset))
clauses = []
params: list[Any] = []
if q:
like = f"%{q}%"
clauses.append("(source_file ILIKE %s OR inpatient_no ILIKE %s OR medical_record_no ILIKE %s OR patient_name ILIKE %s OR primary_diagnosis ILIKE %s)")
params.extend([like, like, like, like, like])
if status_filter == "review_all":
clauses.append("review_status IN ('needs_review', 'reviewed', 'AI复核-无问题', 'AI复核-待确认')")
elif status_filter != "all":
if status_filter == "reviewed":
clauses.append("review_status = 'reviewed'")
else:
clauses.append("review_status = %s")
params.append(status_filter)
where_sql = sql.SQL("")
if clauses:
where_sql = sql.SQL("WHERE ") + sql.SQL(" AND ").join(sql.SQL(clause) for clause in clauses)
query = sql.SQL(
"""
SELECT
id, source_file, inpatient_no, medical_record_no, patient_name, review_status, manual_corrected,
major_department, discharge_dept, primary_diagnosis, primary_diagnosis_code,
contact_phone,
NULLIF(review_logs->-1->>'changed_at', '')::timestamp AS last_activity_at
FROM {table}
{where}
ORDER BY
last_activity_at DESC NULLS LAST,
CASE review_status
WHEN 'needs_review' THEN 1
WHEN 'AI复核-待确认' THEN 2
WHEN 'AI复核-无问题' THEN 3
WHEN 'auto_corrected' THEN 4
WHEN 'reviewed' THEN 5
WHEN '已提交' THEN 6
ELSE 7
END,
id
LIMIT %s OFFSET %s
"""
).format(table=table_identifier(), where=where_sql)
with connect() as conn, conn.cursor() as cur:
cur.execute(query, [*params, limit + 1, offset])
rows = [row_to_json(dict(row)) for row in cur.fetchall()]
has_more = len(rows) > limit
rows = rows[:limit]
for row in rows:
row["has_pdf"] = get_pdf_path(row["source_file"]) is not None
return {"records": rows, "limit": limit, "offset": offset, "has_more": has_more}
@app.get("/api/records/{record_id}")
def get_record(record_id: int):
return {"record": fetch_record(record_id)}
@app.post("/api/records/{record_id}")
def update_record(record_id: int, payload: UpdatePayload):
updates, changed_fields, before = build_record_updates(
record_id,
payload.fields,
payload.manual_note,
payload.note_prefix or "人工复核",
force_reviewed=True,
)
if not updates and not changed_fields:
raise HTTPException(status_code=400, detail="没有可保存字段")
with connect() as conn, conn.cursor() as cur:
apply_record_updates(cur, record_id, updates, changed_fields, before, payload.manual_note, force_reviewed=True)
conn.commit()
return {"ok": True, "record": fetch_record(record_id)}
@app.post("/api/audit/sample")
def create_audit_sample(source: str = "reviewed", count: int = 5):
ensure_workflow_tables()
if source not in {"reviewed", "auto_pass"}:
raise HTTPException(status_code=400, detail="抽查来源只能是 reviewed 或 auto_pass")
count = max(1, min(int(count), 50))
status_clause = "review_status = 'reviewed'" if source == "reviewed" else "review_status = 'auto_pass'"
query = sql.SQL(
"""
SELECT *
FROM {table}
WHERE {status_clause}
ORDER BY random()
LIMIT %s
"""
).format(table=table_identifier(), status_clause=sql.SQL(status_clause))
with connect() as conn, conn.cursor() as cur:
cur.execute(query, (count,))
rows = [dict(row) for row in cur.fetchall()]
return {
"audit_source": source,
"records": [fetch_record(int(row["id"])) for row in rows],
}
@app.get("/api/audit/logs")
def list_audit_logs(limit: int = 100):
return {"logs": fetch_audit_logs(max(1, min(int(limit), 500)))}
@app.post("/api/audit/classify")
def classify_audit(payload: AuditClassifyPayload):
ensure_workflow_tables()
if payload.audit_status not in {"passed", "failed", "unsure"}:
raise HTTPException(status_code=400, detail="抽查归类只能是 passed/failed/unsure")
if payload.audit_source not in {"reviewed", "auto_pass"}:
raise HTTPException(status_code=400, detail="抽查来源只能是 reviewed 或 auto_pass")
updates, changed_fields, before = build_record_updates(
payload.record_id,
payload.fields,
"",
"抽查",
force_reviewed=False,
)
snapshot = {
key: json_ready_deep(before.get(key))
for key in [
"source_file",
"medical_record_no",
"patient_name",
"primary_diagnosis",
"primary_diagnosis_code",
"discharge_diagnoses",
"operations",
"review_status",
]
}
now = datetime.now().isoformat(timespec="seconds")
log = {
"id": datetime.now().strftime("%Y%m%d%H%M%S%f"),
"record_id": payload.record_id,
"source_file": before.get("source_file"),
"audit_source": payload.audit_source,
"audit_status": payload.audit_status,
"audit_notes": payload.audit_notes.strip(),
"ai_result": None,
"snapshot": {**snapshot, "changed_fields": changed_fields},
"created_at": now,
"updated_at": now,
}
with connect() as conn, conn.cursor() as cur:
apply_record_updates(
cur,
payload.record_id,
updates,
changed_fields,
before,
payload.audit_notes if changed_fields else "",
force_reviewed=False,
)
cur.execute(
sql.SQL(
"""
UPDATE {table}
SET audit_logs = COALESCE(audit_logs, '[]'::jsonb) || %s::jsonb
WHERE id = %s
"""
).format(table=table_identifier()),
(
json.dumps([json_ready_deep(log)], ensure_ascii=False),
payload.record_id,
),
)
conn.commit()
return {"ok": True, "log": row_to_json(log), "record": fetch_record(payload.record_id)}
@app.post("/api/audit/logs/{audit_id}")
def update_audit_log(audit_id: int, payload: AuditPayload):
raise HTTPException(status_code=410, detail="抽查日志已并入主表,请使用归类保存接口")
@app.get("/api/settings")
def get_settings():
return public_settings()
@app.post("/api/settings/status/check")
def check_status_now():
return {"status_snapshot": refresh_status_snapshot(source="manual")}
@app.post("/api/settings/system")
def update_system_settings(payload: SystemSettingsPayload):
data = load_local_settings()
system = normalize_system_settings(data.get("system") or {})
system["status_check_time"] = normalize_status_check_time(payload.status_check_time)
data["system"] = system
snapshot = data.get("status_snapshot") or default_status_snapshot()
snapshot["next_check_at"] = next_status_check_at(system)
data["status_snapshot"] = snapshot
save_local_settings(data)
return public_settings()
@app.post("/api/settings/kimi")
def update_kimi_settings(payload: KimiSettingsPayload):
data = load_local_settings()
data["kimi"] = normalize_kimi_settings(
{
"enabled": payload.enabled,
"model": payload.model,
"api_base": payload.api_base,
"concurrency": payload.concurrency,
}
)
save_local_settings(data)
return public_settings()
@app.get("/api/ai/config")
def ai_config():
data = load_local_settings()
return {"kimi": public_kimi_settings(data.get("kimi") or {})}
@app.get("/api/ai/review/status")
def ai_review_status():
with AI_JOB_LOCK:
return dict(AI_REVIEW_JOB)
@app.post("/api/ai/review/approve-no-issue")
def approve_ai_no_issue():
count = mark_ai_no_issue_reviewed()
return {"ok": True, "updated": count, "status_snapshot": load_local_settings().get("status_snapshot")}
@app.post("/api/ai/review")
def start_ai_review(payload: AiReviewPayload):
settings = public_kimi_settings(load_local_settings().get("kimi") or {})
if not settings["available"]:
raise HTTPException(status_code=400, detail="Kimi AI 核验未启用或未配置 API Key")
with AI_JOB_LOCK:
if AI_REVIEW_JOB.get("running"):
raise HTTPException(status_code=409, detail="已有 AI 核验任务正在运行")
ids = ai_target_ids(payload.scope, payload.record_id)
if not ids:
raise HTTPException(status_code=400, detail="当前范围没有可 AI 核验的需复核记录")
thread = threading.Thread(target=run_ai_review_job, args=(payload.scope, ids), name="kimi-ai-review", daemon=True)
thread.start()
time.sleep(0.1)
with AI_JOB_LOCK:
return dict(AI_REVIEW_JOB)
@app.post("/api/settings/submit-reviewed")
def submit_reviewed():
count = submit_reviewed_records()
return {"ok": True, "updated": count, "status_snapshot": load_local_settings().get("status_snapshot")}
@app.post("/api/settings/users")
def create_user(payload: UserPayload):
data = load_local_settings()
username = validate_local_username(payload.username, data)
if not payload.password:
raise HTTPException(status_code=400, detail="新用户必须设置密码")
user = {
"username": username,
"permissions": clean_permissions(payload.permissions),
"created_at": datetime.now().isoformat(timespec="seconds"),
}
user.update(password_hash(payload.password))
data.setdefault("users", []).append(user)
save_local_settings(data)
return public_settings()
@app.post("/api/settings/users/{username}")
def update_user(username: str, payload: UserUpdatePayload):
if username == admin_username():
raise HTTPException(status_code=400, detail="环境变量管理员不能在网页端编辑")
data = load_local_settings()
index = local_user_index(data, username)
if index is None:
raise HTTPException(status_code=404, detail="只能编辑本地配置用户")
user = data["users"][index]
new_username = validate_local_username(payload.username or username, data, current_username=username)
user["username"] = new_username
user["permissions"] = clean_permissions(payload.permissions)
if payload.password:
user.update(password_hash(payload.password))
user["updated_at"] = datetime.now().isoformat(timespec="seconds")
save_local_settings(data)
return public_settings()
@app.post("/api/settings/users/{username}/password")
def update_user_password(username: str, payload: PasswordPayload):
if username == admin_username():
raise HTTPException(status_code=400, detail="环境变量管理员密码请在 .env 中修改")
if not payload.password:
raise HTTPException(status_code=400, detail="密码不能为空")
data = load_local_settings()
index = local_user_index(data, username)
if index is None:
raise HTTPException(status_code=404, detail="只能修改本地配置用户")
data["users"][index].update(password_hash(payload.password))
data["users"][index]["updated_at"] = datetime.now().isoformat(timespec="seconds")
save_local_settings(data)
return public_settings()
@app.post("/api/settings/users/{username}/permissions")
def update_user_permissions(username: str, payload: PermissionPayload):
if username == admin_username():
raise HTTPException(status_code=400, detail="环境变量管理员不能在网页端编辑")
data = load_local_settings()
for user in data.get("users", []):
if user.get("username") == username:
user["permissions"] = clean_permissions(payload.permissions)
user["updated_at"] = datetime.now().isoformat(timespec="seconds")
save_local_settings(data)
return public_settings()
raise HTTPException(status_code=404, detail="只能修改本地配置用户")
@app.delete("/api/settings/users/{username}")
def delete_user(username: str):
if username == admin_username():
raise HTTPException(status_code=400, detail="环境变量管理员不能删除")
data = load_local_settings()
users = data.get("users", [])
next_users = [user for user in users if user.get("username") != username]
if len(next_users) == len(users):
raise HTTPException(status_code=404, detail="只能删除本地配置用户")
data["users"] = next_users
save_local_settings(data)
return public_settings()
@app.get("/api/pdf/{source_file:path}")
def pdf_file(source_file: str, request: Request):
referer = request.headers.get("referer", "")
host = request.headers.get("host", "")
if not referer:
raise HTTPException(status_code=403, detail="PDF 只能在工作台内预览")
if host and urlparse(referer).netloc != host:
raise HTTPException(status_code=403, detail="PDF 只能在同源工作台内预览")
path = get_pdf_path(source_file)
if not path:
raise HTTPException(status_code=404, detail="PDF 文件不存在")
return FileResponse(
path,
media_type="application/pdf",
headers={
"Content-Disposition": "inline",
"Cache-Control": "no-store",
"X-Content-Type-Options": "nosniff",
},
)