2780 lines
106 KiB
Python
2780 lines
106 KiB
Python
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",
|
||
},
|
||
)
|