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", }, )