4028 lines
159 KiB
Python
4028 lines
159 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, Field
|
||
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"),
|
||
"port": int(env("PGPORT", "5432")),
|
||
"dbname": env("PGDATABASE"),
|
||
"user": env("PGUSER"),
|
||
"password": env("PGPASSWORD"),
|
||
}
|
||
PGTABLE = env("PGTABLE")
|
||
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")
|
||
|
||
DEFAULT_MAJOR_DEPARTMENT_OPTIONS = [
|
||
"肝胆外科及肝移植相关",
|
||
"普通外科及腹部外科",
|
||
"急诊医学科",
|
||
"重症医学科",
|
||
"骨科",
|
||
"泌尿外科",
|
||
"呼吸内科",
|
||
"耳鼻喉头颈外科",
|
||
"日间诊疗中心",
|
||
"乳腺外科",
|
||
"胸外科",
|
||
"肝病内科",
|
||
"感染科",
|
||
"肿瘤放疗科",
|
||
"消化内科",
|
||
"肿瘤外科",
|
||
"特需/涉外病房",
|
||
"老年外科",
|
||
]
|
||
|
||
|
||
def load_major_department_options() -> list[str]:
|
||
rule_path = PROJECT_ROOT / "数据处理工作区" / "01_配置规则" / "01_科室分类规则.json"
|
||
if rule_path.exists():
|
||
try:
|
||
data = json.loads(rule_path.read_text(encoding="utf-8"))
|
||
options = [str(group.get("大科室", "")).strip() for group in data.get("大科室列表", [])]
|
||
options = [option for option in options if option]
|
||
if options:
|
||
return options
|
||
except Exception: # noqa: BLE001
|
||
pass
|
||
return DEFAULT_MAJOR_DEPARTMENT_OPTIONS
|
||
|
||
|
||
MAJOR_DEPARTMENT_OPTIONS = load_major_department_options()
|
||
|
||
|
||
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", "大科室", "select", MAJOR_DEPARTMENT_OPTIONS),
|
||
],
|
||
},
|
||
{
|
||
"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),
|
||
("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 = ""
|
||
api_key: str = ""
|
||
concurrency: int = 3
|
||
thinking_enabled: bool = False
|
||
ai_scope_mode: str = "all"
|
||
ai_action_modes: dict[str, str] = Field(default_factory=dict)
|
||
ai_action_privacy_modes: dict[str, bool] = Field(default_factory=dict)
|
||
|
||
|
||
class AiReviewPayload(BaseModel):
|
||
scope: str = "current"
|
||
record_id: int | None = None
|
||
model: str = ""
|
||
thinking_enabled: bool | None = None
|
||
privacy_mode: bool | 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] = {
|
||
"kind": "ai_review",
|
||
"running": False,
|
||
"cancel_requested": False,
|
||
"scope": "",
|
||
"total": 0,
|
||
"processed": 0,
|
||
"ok": 0,
|
||
"pending": 0,
|
||
"failed": 0,
|
||
"concurrency": 0,
|
||
"message": "",
|
||
"errors": [],
|
||
"started_at": "",
|
||
"finished_at": "",
|
||
"last_record_id": None,
|
||
"privacy_mode": True,
|
||
}
|
||
BULK_JOB_LOCK = threading.Lock()
|
||
BULK_APPROVE_JOB: dict[str, Any] = {
|
||
"kind": "approve_ai_passed",
|
||
"running": False,
|
||
"total": 0,
|
||
"processed": 0,
|
||
"updated": 0,
|
||
"failed": 0,
|
||
"message": "",
|
||
"started_at": "",
|
||
"finished_at": "",
|
||
}
|
||
APPROVE_BATCH_SIZE = 500
|
||
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_OK_STATUS = "auto_pass"
|
||
AI_PROBLEM_STATUS = "AI已处理-不OK"
|
||
AI_NO_ISSUE_STATUS = AI_OK_STATUS
|
||
AI_PENDING_STATUS = AI_PROBLEM_STATUS
|
||
LEGACY_AI_OK_STATUS = "AI复核-无问题"
|
||
LEGACY_AI_PENDING_STATUS = "AI复核-待确认"
|
||
AI_CONFIRMED_PROBLEM_KEYWORDS = (
|
||
"缺少",
|
||
"缺失",
|
||
"为空白",
|
||
"为空",
|
||
"空白",
|
||
"无编码",
|
||
"未填写",
|
||
"不清晰",
|
||
"不一致",
|
||
"错位",
|
||
"混乱",
|
||
"需人工",
|
||
"需要人工",
|
||
"待确认",
|
||
)
|
||
AI_CONFIRMED_PROBLEM_QUALIFIERS = ("确实", "证实", "属实", "仍", "依然", "存在", "需要", "需人工", "待确认")
|
||
AI_UNRESOLVED_PROBLEM_MARKERS = (
|
||
"需人工",
|
||
"需要人工",
|
||
"待确认",
|
||
"需确认",
|
||
"需补录",
|
||
"需补充",
|
||
"建议补录",
|
||
"建议补充",
|
||
"漏填",
|
||
"缺失需",
|
||
"空白需",
|
||
"截断需",
|
||
"需补全",
|
||
"不清晰",
|
||
"不一致",
|
||
)
|
||
AI_FORCE_PROBLEM_MARKERS = (
|
||
"需人工",
|
||
"需要人工",
|
||
"待确认",
|
||
"需确认",
|
||
"需补录",
|
||
"需补充",
|
||
"建议补录",
|
||
"建议补充",
|
||
"漏填",
|
||
"缺失需",
|
||
"空白需",
|
||
"截断需",
|
||
"需补全",
|
||
)
|
||
AI_FIXED_MARKERS = ("已修正", "已补齐", "已更正", "已改为", "已填入", "已补全")
|
||
AI_NO_REVIEW_MARKERS = ("无需", "无须", "不需", "不用", "无需补录", "无须补录", "无需处理", "原貌")
|
||
AI_STOP_ERROR_MARKERS = (
|
||
"exceeded_current_quota_error",
|
||
"insufficient_quota",
|
||
"consumption budget",
|
||
"billing details",
|
||
"quota",
|
||
"余额",
|
||
"额度",
|
||
)
|
||
AI_JOB_ERROR_LIMIT = 50
|
||
AI_SAFE_MODULE_NAMES = {"诊断表格", "手术表格", "离院费用"}
|
||
AI_REDACTED = "[已脱敏]"
|
||
AI_REDACT_PATTERNS = (
|
||
(re.compile(r"ZY\d{6,}", re.IGNORECASE), "[住院号]"),
|
||
(re.compile(r"\b\d{15,17}[\dXx]\b"), "[身份证号]"),
|
||
(re.compile(r"(?<![A-Za-z.])\b\d{7,14}\b(?![A-Za-z.])"), "[编号]"),
|
||
(re.compile(r"(身份证号|身份证|电话|手机号|联系电话|邮编)\s*[::]?\s*[\dXx\- ]{5,}"), r"\1:[已脱敏]"),
|
||
(re.compile(r"(姓名|患者姓名|联系人姓名)\s*[::]?\s*[\u4e00-\u9fff·]{2,6}"), r"\1:[已脱敏]"),
|
||
(re.compile(r"(现住址|户口地址|联系人地址|工作单位及地址|出生地|籍贯)\s*[::]?\s*[^\n;;]{2,80}"), r"\1:[已脱敏]"),
|
||
)
|
||
AI_UPLOAD_FIELDS = [
|
||
"outpatient_diagnosis",
|
||
"outpatient_diagnosis_code",
|
||
"discharge_diagnoses",
|
||
"operations",
|
||
"pathology_diagnosis",
|
||
"pathology_diagnosis_code",
|
||
"total_cost",
|
||
"self_pay_amount",
|
||
"quality_status",
|
||
"quality_notes",
|
||
"review_notes",
|
||
]
|
||
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():
|
||
missing = [name for name, value in {
|
||
"PGHOST": DB_CONFIG["host"],
|
||
"PGDATABASE": DB_CONFIG["dbname"],
|
||
"PGUSER": DB_CONFIG["user"],
|
||
"PGPASSWORD": DB_CONFIG["password"],
|
||
"PGTABLE": PGTABLE,
|
||
}.items() if not value]
|
||
if missing:
|
||
raise RuntimeError("缺少 PostgreSQL 连接配置:" + "、".join(missing))
|
||
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(kimi: dict[str, Any] | None = None) -> str:
|
||
local_key = str((kimi or {}).get("api_key") or "").strip()
|
||
if local_key:
|
||
return local_key
|
||
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 normalize_bool(value: Any, default: bool = False) -> bool:
|
||
if isinstance(value, bool):
|
||
return value
|
||
if value is None:
|
||
return default
|
||
return str(value).strip().lower() in {"1", "true", "yes", "on", "启用", "是"}
|
||
|
||
|
||
def normalize_kimi_ai_scope_mode(value: Any, default: str = "all") -> str:
|
||
aliases = {
|
||
"off": "off",
|
||
"none": "off",
|
||
"disabled": "off",
|
||
"关闭": "off",
|
||
"current": "current",
|
||
"single": "current",
|
||
"仅当前项": "current",
|
||
"five": "five",
|
||
"5": "five",
|
||
"current_five": "five",
|
||
"当前项和后5项": "five",
|
||
"all": "all",
|
||
"全部": "all",
|
||
}
|
||
mode = aliases.get(str(value or "").strip().lower(), "")
|
||
return mode or (default if default in {"off", "current", "five", "all"} else "all")
|
||
|
||
|
||
def normalize_ai_action_mode(value: Any, default: str = "default") -> str:
|
||
aliases = {
|
||
"off": "off",
|
||
"关闭": "off",
|
||
"disabled": "off",
|
||
"default": "default",
|
||
"follow": "default",
|
||
"跟随默认模型": "default",
|
||
"k25": "k25",
|
||
"kimi-k2.5": "k25",
|
||
"k25_thinking": "k25_thinking",
|
||
"kimi-k2.5-thinking": "k25_thinking",
|
||
"k26": "k26",
|
||
"kimi-k2.6": "k26",
|
||
"k26_thinking": "k26_thinking",
|
||
"kimi-k2.6-thinking": "k26_thinking",
|
||
}
|
||
mode = aliases.get(str(value or "").strip().lower(), "")
|
||
return mode or (default if default in {"off", "default", "k25", "k25_thinking", "k26", "k26_thinking"} else "default")
|
||
|
||
|
||
def default_ai_action_modes() -> dict[str, str]:
|
||
return {"current": "default", "five": "default", "all": "default"}
|
||
|
||
|
||
def normalize_ai_action_modes(value: Any) -> dict[str, str]:
|
||
defaults = default_ai_action_modes()
|
||
if not isinstance(value, dict):
|
||
return defaults
|
||
return {scope: normalize_ai_action_mode(value.get(scope), defaults[scope]) for scope in defaults}
|
||
|
||
|
||
def default_ai_action_privacy_modes() -> dict[str, bool]:
|
||
return {"current": True, "five": True, "all": True}
|
||
|
||
|
||
def normalize_ai_action_privacy_modes(value: Any) -> dict[str, bool]:
|
||
defaults = default_ai_action_privacy_modes()
|
||
if not isinstance(value, dict):
|
||
return defaults
|
||
return {scope: normalize_bool(value.get(scope), defaults[scope]) for scope in defaults}
|
||
|
||
|
||
def ai_action_mode_to_override(mode: str) -> dict[str, Any]:
|
||
normalized = normalize_ai_action_mode(mode)
|
||
if normalized == "k25":
|
||
return {"model": "kimi-k2.5", "thinking_enabled": False}
|
||
if normalized == "k25_thinking":
|
||
return {"model": "kimi-k2.5", "thinking_enabled": True}
|
||
if normalized == "k26":
|
||
return {"model": "kimi-k2.6", "thinking_enabled": False}
|
||
if normalized == "k26_thinking":
|
||
return {"model": "kimi-k2.6", "thinking_enabled": True}
|
||
return {}
|
||
|
||
|
||
def ai_scope_allowed(mode: str, scope: str) -> bool:
|
||
mode = normalize_kimi_ai_scope_mode(mode)
|
||
if mode == "all":
|
||
return scope in {"current", "five", "fifty", "all", "ai_pending", "privacy_blocked"}
|
||
if mode == "five":
|
||
return scope in {"current", "five"}
|
||
if mode == "current":
|
||
return scope == "current"
|
||
return False
|
||
|
||
|
||
def default_kimi_settings() -> dict[str, Any]:
|
||
return {
|
||
"enabled": bool(kimi_api_key()),
|
||
"api_base": DEFAULT_KIMI_API_BASE,
|
||
"api_key": "",
|
||
"model": DEFAULT_KIMI_MODEL,
|
||
"concurrency": normalize_kimi_concurrency(env("KIMI_CONCURRENCY", "3")),
|
||
"thinking_enabled": normalize_bool(env("KIMI_THINKING_ENABLED", "false")),
|
||
"ai_scope_mode": normalize_kimi_ai_scope_mode(env("KIMI_AI_SCOPE_MODE", "all")),
|
||
"ai_action_modes": default_ai_action_modes(),
|
||
"ai_action_privacy_modes": default_ai_action_privacy_modes(),
|
||
}
|
||
|
||
|
||
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,
|
||
"api_key": str(kimi.get("api_key") or defaults.get("api_key") or "").strip(),
|
||
"model": model or DEFAULT_KIMI_MODEL,
|
||
"concurrency": normalize_kimi_concurrency(kimi.get("concurrency", defaults["concurrency"])),
|
||
"thinking_enabled": normalize_bool(kimi.get("thinking_enabled"), defaults["thinking_enabled"]),
|
||
"ai_scope_mode": normalize_kimi_ai_scope_mode(kimi.get("ai_scope_mode"), defaults["ai_scope_mode"]),
|
||
"ai_action_modes": normalize_ai_action_modes(kimi.get("ai_action_modes") or defaults["ai_action_modes"]),
|
||
"ai_action_privacy_modes": normalize_ai_action_privacy_modes(
|
||
kimi.get("ai_action_privacy_modes") or defaults["ai_action_privacy_modes"]
|
||
),
|
||
}
|
||
|
||
|
||
def public_kimi_settings(kimi: dict[str, Any] | None = None) -> dict[str, Any]:
|
||
settings = normalize_kimi_settings(kimi or {})
|
||
local_key_configured = bool(settings.get("api_key"))
|
||
env_key_configured = bool(kimi_api_key())
|
||
settings.pop("api_key", None)
|
||
settings["api_key_configured"] = local_key_configured or env_key_configured
|
||
settings["api_key_source"] = "settings" if local_key_configured else "env" if env_key_configured else ""
|
||
settings["available"] = settings["enabled"] and settings["api_key_configured"]
|
||
return settings
|
||
|
||
|
||
def model_supports_thinking(model: str) -> bool:
|
||
return str(model or "").startswith("kimi-k2.")
|
||
|
||
|
||
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,
|
||
"workbench_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', 'reviewed', 'AI已处理-OK', 'AI已处理-不OK', 'AI复核-无问题', 'AI复核-待确认') OR (
|
||
review_status = 'auto_pass'
|
||
AND COALESCE(review_logs, '[]'::jsonb) @> jsonb_build_array(jsonb_build_object('changed_by', 'AI'))
|
||
)) AS workbench_total,
|
||
count(*) FILTER (WHERE review_status IN ('needs_review', 'AI已处理-不OK', '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 = 'auto_pass'
|
||
AND COALESCE(review_logs, '[]'::jsonb) @> jsonb_build_array(jsonb_build_object('changed_by', 'AI'))
|
||
) OR review_status IN ('AI已处理-OK', 'AI复核-无问题')) AS ai_passed,
|
||
count(*) FILTER (WHERE review_status IN ('AI已处理-不OK', '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 json_changed_fields(field: str, old_value: Any, new_value: Any) -> list[dict[str, Any]]:
|
||
label = FIELD_META[field]["label"]
|
||
if isinstance(old_value, list) or isinstance(new_value, list):
|
||
old_rows = old_value if isinstance(old_value, list) else []
|
||
new_rows = new_value if isinstance(new_value, list) else []
|
||
columns: list[str] = []
|
||
for row in [*old_rows, *new_rows]:
|
||
if isinstance(row, dict):
|
||
for key in row:
|
||
if key not in columns:
|
||
columns.append(str(key))
|
||
entries: list[dict[str, Any]] = []
|
||
for index in range(max(len(old_rows), len(new_rows))):
|
||
old_row = old_rows[index] if index < len(old_rows) and isinstance(old_rows[index], dict) else {}
|
||
new_row = new_rows[index] if index < len(new_rows) and isinstance(new_rows[index], dict) else {}
|
||
for column in columns:
|
||
old_cell = old_row.get(column, "")
|
||
new_cell = new_row.get(column, "")
|
||
if comparable(old_cell) == comparable(new_cell):
|
||
continue
|
||
entries.append(
|
||
{
|
||
"field": f"{field}[{index}].{column}",
|
||
"label": f"{label}[{index + 1}].{column}",
|
||
"old": json_ready_deep(old_cell),
|
||
"new": json_ready_deep(new_cell),
|
||
}
|
||
)
|
||
return entries
|
||
|
||
if isinstance(old_value, dict) or isinstance(new_value, dict):
|
||
old_dict = old_value if isinstance(old_value, dict) else {}
|
||
new_dict = new_value if isinstance(new_value, dict) else {}
|
||
keys = list(dict.fromkeys([*old_dict.keys(), *new_dict.keys()]))
|
||
return [
|
||
{
|
||
"field": f"{field}.{key}",
|
||
"label": f"{label}.{key}",
|
||
"old": json_ready_deep(old_dict.get(key, "")),
|
||
"new": json_ready_deep(new_dict.get(key, "")),
|
||
}
|
||
for key in keys
|
||
if comparable(old_dict.get(key, "")) != comparable(new_dict.get(key, ""))
|
||
]
|
||
|
||
return [
|
||
{
|
||
"field": field,
|
||
"label": label,
|
||
"old": json_ready_deep(old_value),
|
||
"new": json_ready_deep(new_value),
|
||
}
|
||
]
|
||
|
||
|
||
def main_diagnosis_row_index(rows: list[Any]) -> int:
|
||
for index, row in enumerate(rows):
|
||
if isinstance(row, dict) and str(row.get("诊断类别") or "").strip() == "主要诊断":
|
||
return index
|
||
return 0
|
||
|
||
|
||
def main_diagnosis_from_discharge_diagnoses(value: Any) -> dict[str, str]:
|
||
rows = value if isinstance(value, list) else []
|
||
if not rows:
|
||
return {"primary_diagnosis": "", "primary_diagnosis_code": "", "primary_admission_condition": ""}
|
||
index = main_diagnosis_row_index(rows)
|
||
row = rows[index] if index < len(rows) and isinstance(rows[index], dict) else {}
|
||
return {
|
||
"primary_diagnosis": str(row.get("出院诊断") or row.get("诊断名称") or "").strip(),
|
||
"primary_diagnosis_code": str(row.get("疾病编码") or row.get("诊断编码") or "").strip(),
|
||
"primary_admission_condition": str(row.get("入院病情") or "").strip(),
|
||
}
|
||
|
||
|
||
def sync_primary_diagnosis_updates(updates: dict[str, Any], before: dict[str, Any]) -> None:
|
||
if "discharge_diagnoses" not in updates:
|
||
return
|
||
derived = main_diagnosis_from_discharge_diagnoses(updates.get("discharge_diagnoses"))
|
||
for field, value in derived.items():
|
||
if comparable(before.get(field)) != comparable(value):
|
||
updates[field] = value
|
||
|
||
|
||
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="患者号不能为空")
|
||
|
||
sync_primary_diagnosis_updates(updates, before)
|
||
|
||
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
|
||
if field in JSON_FIELDS:
|
||
changed_fields.extend(json_changed_fields(field, old_value, 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
|
||
raise ValueError(f"AI 返回 JSON 无法解析:{exc.msg}")
|
||
|
||
|
||
def render_pdf_page_png(pdf_path: Path, dpi: int = 96, page_index: int = 0, clip: Any | None = None) -> bytes:
|
||
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)
|
||
return pixmap.tobytes("png")
|
||
|
||
|
||
def render_pdf_page_data_url(pdf_path: Path, dpi: int = 96, page_index: int = 0, clip: Any | None = None) -> str:
|
||
encoded = base64.b64encode(render_pdf_page_png(pdf_path, dpi=dpi, page_index=page_index, clip=clip)).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], privacy_mode: bool = True) -> 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 privacy_mode and module["name"] not in AI_SAFE_MODULE_NAMES:
|
||
continue
|
||
if any(normalize_pdf_text(keyword) in normalized for keyword in module["note_keywords"]):
|
||
selected.append(module)
|
||
if not selected:
|
||
default_names = {"诊断表格", "手术表格"} if privacy_mode else {module["name"] for module in PDF_MODULE_DEFINITIONS}
|
||
selected = [module for module in PDF_MODULE_DEFINITIONS if module["name"] in default_names]
|
||
return selected[: 4 if privacy_mode else 5]
|
||
|
||
|
||
def redact_text_for_ai(text: str) -> str:
|
||
redacted = str(text or "")
|
||
for pattern, replacement in AI_REDACT_PATTERNS:
|
||
redacted = pattern.sub(replacement, redacted)
|
||
return redacted
|
||
|
||
|
||
def redact_for_ai(value: Any) -> Any:
|
||
if isinstance(value, str):
|
||
return redact_text_for_ai(value)
|
||
if isinstance(value, list):
|
||
return [redact_for_ai(item) for item in value]
|
||
if isinstance(value, dict):
|
||
return {str(key): redact_for_ai(item) for key, item in value.items()}
|
||
return value
|
||
|
||
|
||
def upload_text_for_ai(text: str, privacy_mode: bool = True) -> str:
|
||
return redact_text_for_ai(text) if privacy_mode else str(text or "")
|
||
|
||
|
||
def upload_value_for_ai(value: Any, privacy_mode: bool = True) -> Any:
|
||
return redact_for_ai(value) if privacy_mode else json_ready_deep(value)
|
||
|
||
|
||
def privacy_excluded_review(record: dict[str, Any]) -> bool:
|
||
normalized = normalize_pdf_text(record_review_text(record))
|
||
excluded_modules = [module for module in PDF_MODULE_DEFINITIONS if module["name"] not in AI_SAFE_MODULE_NAMES]
|
||
has_excluded = any(
|
||
normalize_pdf_text(keyword) in normalized
|
||
for module in excluded_modules
|
||
for keyword in module["note_keywords"]
|
||
)
|
||
has_safe = any(
|
||
normalize_pdf_text(keyword) in normalized
|
||
for module in PDF_MODULE_DEFINITIONS
|
||
if module["name"] in AI_SAFE_MODULE_NAMES
|
||
for keyword in module["note_keywords"]
|
||
)
|
||
return has_excluded and not has_safe
|
||
|
||
|
||
def admission_path_crop_y(blocks: list[dict[str, Any]], page_index: int) -> float | None:
|
||
candidates: list[float] = []
|
||
for block in blocks:
|
||
if block["page_index"] != page_index:
|
||
continue
|
||
normalized = normalize_pdf_text(block["text"])
|
||
has_admission_path = "入院途径" in normalized
|
||
has_path_options = "急诊" in normalized and "门诊" in normalized and "其他医疗机构转入" in normalized
|
||
if has_admission_path or has_path_options:
|
||
candidates.append(float(block["rect"].y0))
|
||
return min(candidates) if candidates else None
|
||
|
||
|
||
def apply_ai_image_privacy_clip(page: Any, clip: Any, blocks: list[dict[str, Any]], page_index: int) -> Any | None:
|
||
if page_index != 0:
|
||
return clip
|
||
crop_y = admission_path_crop_y(blocks, page_index)
|
||
if crop_y is None:
|
||
return None
|
||
protected_y0 = max(float(clip.y0), crop_y - 16)
|
||
if float(clip.y1) - protected_y0 < 80:
|
||
return None
|
||
return type(clip)(clip.x0, protected_y0, clip.x1, clip.y1)
|
||
|
||
|
||
def fallback_privacy_page_context(
|
||
pdf_path: Path,
|
||
document: Any,
|
||
text_blocks: list[dict[str, Any]],
|
||
crop_blocks: list[dict[str, Any]],
|
||
) -> dict[str, Any] | None:
|
||
if document.page_count < 1:
|
||
return None
|
||
page_index = 0
|
||
page = document.load_page(page_index)
|
||
crop_y = admission_path_crop_y(crop_blocks, page_index)
|
||
if crop_y is None:
|
||
return None
|
||
clip = type(page.rect)(
|
||
max(page.rect.x0, page.rect.x0 + 18),
|
||
max(page.rect.y0, crop_y - 16),
|
||
min(page.rect.x1, page.rect.x1 - 18),
|
||
page.rect.y1,
|
||
)
|
||
if float(clip.y1) - float(clip.y0) < 120:
|
||
return None
|
||
return {
|
||
"name": "隐私裁剪首页",
|
||
"page": 1,
|
||
"bbox": [round(clip.x0, 1), round(clip.y0, 1), round(clip.x1, 1), round(clip.y1, 1)],
|
||
"text": redact_text_for_ai(clip_text_from_blocks(text_blocks, page_index, clip)),
|
||
"image_url": render_pdf_page_data_url(pdf_path, dpi=120, page_index=page_index, clip=clip),
|
||
}
|
||
|
||
|
||
def fallback_unrestricted_page_context(pdf_path: Path, document: Any, text_blocks: list[dict[str, Any]]) -> dict[str, Any] | None:
|
||
if document.page_count < 1:
|
||
return None
|
||
page_index = 0
|
||
page = document.load_page(page_index)
|
||
clip = page.rect
|
||
return {
|
||
"name": "整页首页",
|
||
"page": 1,
|
||
"bbox": [round(clip.x0, 1), round(clip.y0, 1), round(clip.x1, 1), round(clip.y1, 1)],
|
||
"text": clip_text_from_blocks(text_blocks, page_index, clip),
|
||
"image_url": render_pdf_page_data_url(pdf_path, dpi=110, page_index=page_index, clip=clip),
|
||
}
|
||
|
||
|
||
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 page_line_blocks(page: Any, page_index: int) -> list[dict[str, Any]]:
|
||
lines: list[dict[str, Any]] = []
|
||
page_dict = page.get_text("dict")
|
||
for block in page_dict.get("blocks", []):
|
||
for line in block.get("lines", []):
|
||
spans = line.get("spans") or []
|
||
text = "".join(str(span.get("text") or "") for span in spans).strip()
|
||
bbox = line.get("bbox")
|
||
if not text or not bbox:
|
||
continue
|
||
lines.append(
|
||
{
|
||
"page_index": page_index,
|
||
"rect": type(page.rect)(bbox),
|
||
"text": text,
|
||
"normalized": normalize_pdf_text(text),
|
||
}
|
||
)
|
||
return lines
|
||
|
||
|
||
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)
|
||
for segment in re.split(r"[、,,;;\s]+", text):
|
||
if len(segment) >= 4:
|
||
keywords.append(segment)
|
||
compact = normalize_pdf_text(text)
|
||
if len(compact) >= 12:
|
||
keywords.extend([compact[:10], compact[-10:]])
|
||
|
||
|
||
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], privacy_mode: bool = True) -> 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, privacy_mode=privacy_mode)
|
||
privacy_skipped = privacy_mode and privacy_excluded_review(record)
|
||
contexts: list[dict[str, Any]] = []
|
||
all_blocks: list[dict[str, Any]] = []
|
||
all_lines: list[dict[str, Any]] = []
|
||
full_text_excerpt = ""
|
||
with fitz.open(str(pdf_path)) as document:
|
||
for page_index in range(document.page_count):
|
||
page = document.load_page(page_index)
|
||
all_lines.extend(page_line_blocks(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)),
|
||
)
|
||
if privacy_mode:
|
||
clip = apply_ai_image_privacy_clip(page, clip, all_lines, page_index)
|
||
if clip is None:
|
||
continue
|
||
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": upload_text_for_ai(clip_text_from_blocks(all_blocks, page_index, clip), privacy_mode=privacy_mode),
|
||
"image_url": render_pdf_page_data_url(pdf_path, dpi=120, page_index=page_index, clip=clip),
|
||
}
|
||
)
|
||
if privacy_mode and not contexts and not privacy_skipped:
|
||
fallback_context = fallback_privacy_page_context(pdf_path, document, all_blocks, all_lines)
|
||
if fallback_context:
|
||
contexts.append(fallback_context)
|
||
if not privacy_mode:
|
||
full_text_excerpt = "\n".join(" ".join(str(block["text"]).split()) for block in all_blocks)[:9000]
|
||
if not contexts:
|
||
fallback_context = fallback_unrestricted_page_context(pdf_path, document, all_blocks)
|
||
if fallback_context:
|
||
contexts.append(fallback_context)
|
||
|
||
return {
|
||
"modules": contexts,
|
||
"full_text_excerpt": full_text_excerpt,
|
||
"privacy_skipped": privacy_skipped,
|
||
}
|
||
|
||
|
||
def ai_record_snapshot(record: dict[str, Any], privacy_mode: bool = True) -> dict[str, Any]:
|
||
if privacy_mode:
|
||
return {key: upload_value_for_ai(record.get(key), privacy_mode=True) for key in AI_UPLOAD_FIELDS}
|
||
snapshot: dict[str, Any] = {}
|
||
for group in FIELD_GROUPS:
|
||
for name, _label, _field_type, _options in group["fields"]:
|
||
snapshot[name] = upload_value_for_ai(record.get(name), privacy_mode=False)
|
||
for key in ("review_status", "quality_status", "quality_notes", "review_notes", "auto_corrections"):
|
||
snapshot[key] = upload_value_for_ai(record.get(key), privacy_mode=False)
|
||
return snapshot
|
||
|
||
|
||
def build_ai_prompt(record: dict[str, Any], pdf_context: dict[str, Any], privacy_mode: bool = True) -> str:
|
||
snapshot = json.dumps(ai_record_snapshot(record, privacy_mode=privacy_mode), ensure_ascii=False, indent=2)
|
||
context_for_prompt = {
|
||
"modules": [
|
||
{
|
||
"name": item["name"],
|
||
"page": item["page"],
|
||
"bbox": item["bbox"],
|
||
"pdf_text": upload_text_for_ai(item["text"], privacy_mode=privacy_mode),
|
||
}
|
||
for item in pdf_context.get("modules", [])
|
||
],
|
||
"full_text_excerpt": upload_text_for_ai(pdf_context.get("full_text_excerpt", ""), privacy_mode=privacy_mode),
|
||
"privacy_mode": "on" if privacy_mode else "off",
|
||
"excluded": (
|
||
"姓名、住院号、病案号、身份证、电话、地址、基本信息、地址联系人、整页截图、全文摘录均不上传"
|
||
if privacy_mode
|
||
else "未启用隐私模式;可上传基本信息、地址联系人、相关局部截图和PDF文本摘录用于核验"
|
||
),
|
||
}
|
||
context_json = json.dumps(context_for_prompt, ensure_ascii=False, indent=2)
|
||
mode_instruction = (
|
||
"当前为隐私模式:只上传诊断表格、手术表格、离院费用的脱敏文本和局部截图;不上传基本信息、地址联系人、整页截图或 PDF 全文摘录。"
|
||
if privacy_mode
|
||
else "当前未启用隐私模式:允许上传基本信息、地址联系人、诊断、手术、离院费用等相关 PDF 文本和局部截图;可对所有结构化字段提出修正。"
|
||
)
|
||
return f"""
|
||
请对这份住院病案首页做视觉核验,只返回 JSON,不要输出 Markdown。
|
||
|
||
任务目标:
|
||
1. {mode_instruction}
|
||
2. 将 PDF 文本、局部截图中的可见内容,与下面的结构化字段逐项对比。
|
||
3. 如果 PDF 能读出明确值且结构化字段缺失、截断或错填,请直接给出 suggested_updates;系统会先按你的建议修改,再归类。
|
||
4. 修改后如果 AI 认为没有必须复核的问题,classification 返回 "ok";即使编码栏空白、某些字段为空,只要 PDF 证实首页本来如此且无需处理,也返回 "ok"。
|
||
5. 只有仍需要复核或纠正的问题,classification 返回 "problem",并写入 remaining_issues。
|
||
6. 如果手术表格中能看到“手术及操作编码”列但对应单元格为空,写“编码栏可见但为空白”,不要写“编码区域未在首页显示”;单元格本来为空且无需补录时不要因此判为 problem。
|
||
7. 手术操作名称可能因为换行被结构化解析截断;如果 PDF定位文本或局部截图中显示完整多行名称,请把完整名称放入 suggested_updates。
|
||
8. 门急诊诊断编码只能用于 outpatient_diagnosis_code;主要诊断请修正 discharge_diagnoses 中“诊断类别=主要诊断”的行,不要把门急诊诊断编码复制到 discharge_diagnoses[].疾病编码,除非出院诊断表格对应“疾病编码”单元格本身清楚显示该编码。
|
||
9. PDF局部截图可能因遮挡、截屏边界或缩放只显示编码/文字前半段;如果 PDF 显示值是结构化字段值的前缀,且没有相反证据,应判为 match/ok,不要写“需确认完整编码”,也不要建议把结构化字段截短。
|
||
10. 入院途径、离院方式、入院病情等代码字段只核对代码本身;例如 PDF 显示“2(门诊)”而结构化字段为“2”就是一致,不要要求复核代码与中文标签关系。
|
||
11. 手术操作日期只有在日历日期确实早于入院日期时才算问题;同日或晚于入院日期均为正常,不要推测月份应改为其他月份。
|
||
12. 手术及操作编码允许带 x 和 001/002/005/006 等扩展后缀;如果原始内容显示“54.5100x ... 005”这类拆开的后缀,应建议写入完整编码“54.5100x005”,不要要求人工确认扩展码是否有效。
|
||
13. remaining_issues 只写当前文档复核人应该特别注意的内容;不要写如何修改,不要重复 suggested_updates,不要写“无需补录/无需处理/首页原貌”这类已判定无问题的说明。
|
||
14. 不要编造 PDF 中看不见的内容,不要输出置信度。
|
||
|
||
必须返回这个 JSON 结构:
|
||
{{
|
||
"classification": "ok 或 problem",
|
||
"summary": "一句话结论,60字以内",
|
||
"method": "AI视觉核验:PDF文本定位+局部截图,对照复核定位和结构化字段",
|
||
"suggested_updates": [
|
||
{{"field": "字段名或路径,例如 discharge_diagnoses[0].疾病编码 或 operations[0].麻醉方式", "value": "PDF中应写入的值", "reason": "20字以内"}}
|
||
],
|
||
"remaining_issues": ["AI觉得有必要复核的内容,最多3条;无则返回空数组"],
|
||
"evidence": [
|
||
{{"target": "核验点", "pdf_value": "PDF图片值", "structured_value": "结构化值", "result": "match/mismatch/uncertain", "note": "30字以内"}}
|
||
]
|
||
}}
|
||
|
||
如果没有明确修正值,suggested_updates 返回 []。如果 suggested_updates 已经修正主要问题,remaining_issues 可以返回 [] 并把 classification 归为 "ok"。
|
||
|
||
结构化字段:
|
||
{snapshot}
|
||
|
||
PDF定位文本:
|
||
{context_json}
|
||
""".strip()
|
||
|
||
|
||
def local_privacy_ai_result(settings: dict[str, Any], pdf_context: dict[str, Any], reason: str) -> dict[str, Any]:
|
||
parsed = normalize_ai_parsed(
|
||
{
|
||
"classification": "problem",
|
||
"summary": "隐私模式未上传敏感区域,需人工复核",
|
||
"method": "本地隐私保护:未调用外部AI",
|
||
"suggested_updates": [],
|
||
"remaining_issues": [reason],
|
||
"evidence": [],
|
||
}
|
||
)
|
||
return {
|
||
"model": settings.get("model"),
|
||
"thinking_enabled": bool(settings.get("thinking_enabled")),
|
||
"privacy_mode": True,
|
||
"checked_at": datetime.now().isoformat(timespec="seconds"),
|
||
"ai_question": "隐私模式本地判定:未上传基本信息、地址联系人、整页截图或PDF全文摘录。",
|
||
"pdf_context": {"modules": [], "full_text_excerpt": ""},
|
||
"raw_response": "",
|
||
"parsed": parsed,
|
||
"usage": {},
|
||
}
|
||
|
||
|
||
def merge_kimi_override(kimi: dict[str, Any], override: dict[str, Any] | None = None) -> dict[str, Any]:
|
||
if not override:
|
||
return kimi
|
||
merged = dict(kimi)
|
||
if override.get("model"):
|
||
merged["model"] = str(override["model"]).strip()
|
||
if override.get("thinking_enabled") is not None:
|
||
merged["thinking_enabled"] = bool(override["thinking_enabled"])
|
||
if override.get("privacy_mode") is not None:
|
||
merged["privacy_mode"] = bool(override["privacy_mode"])
|
||
return merged
|
||
|
||
|
||
def call_kimi_ai_review(record: dict[str, Any], kimi_override: dict[str, Any] | None = None) -> dict[str, Any]:
|
||
local_kimi_settings = load_local_settings().get("kimi") or {}
|
||
local_kimi_settings = merge_kimi_override(local_kimi_settings, kimi_override)
|
||
settings = public_kimi_settings(local_kimi_settings)
|
||
api_key = kimi_api_key(local_kimi_settings)
|
||
privacy_mode = normalize_bool(local_kimi_settings.get("privacy_mode"), True)
|
||
if not settings["available"]:
|
||
raise HTTPException(status_code=400, detail="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, privacy_mode=privacy_mode)
|
||
if privacy_mode and pdf_context.get("privacy_skipped"):
|
||
return local_privacy_ai_result(settings, pdf_context, "复核定位疑似基本信息或地址联系人;隐私模式不上传该区域,请人工复核。")
|
||
if privacy_mode and not pdf_context.get("modules"):
|
||
return local_privacy_ai_result(settings, pdf_context, "未定位到可脱敏上传的诊断/手术/费用局部区域;未上传整页截图,请人工复核。")
|
||
ai_question = build_ai_prompt(record, pdf_context, privacy_mode=privacy_mode)
|
||
content_parts: list[dict[str, Any]] = [{"type": "text", "text": ai_question}]
|
||
for item in pdf_context.get("modules", [])[: 4 if privacy_mode else 5]:
|
||
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"]}})
|
||
supports_thinking = model_supports_thinking(settings["model"])
|
||
thinking_enabled = supports_thinking and bool(settings.get("thinking_enabled"))
|
||
payload = {
|
||
"model": settings["model"],
|
||
"temperature": 1.0 if thinking_enabled else 0.6,
|
||
"max_tokens": 16000 if thinking_enabled else 3200,
|
||
"response_format": {"type": "json_object"},
|
||
"messages": [
|
||
{"role": "system", "content": "你是严谨的病案首页视觉核验助手,只输出合法 JSON;字符串里的引号必须转义。"},
|
||
{
|
||
"role": "user",
|
||
"content": content_parts,
|
||
},
|
||
],
|
||
}
|
||
if supports_thinking:
|
||
payload["thinking"] = {"type": "enabled" if thinking_enabled else "disabled"}
|
||
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 {api_key}",
|
||
},
|
||
method="POST",
|
||
)
|
||
try:
|
||
with urllib.request.urlopen(request, timeout=180 if thinking_enabled else 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"AI API 返回错误 {exc.code}: {detail}") from exc
|
||
except urllib.error.URLError as exc:
|
||
raise HTTPException(status_code=502, detail=f"AI 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": "AI 返回 JSON 不是对象", "raw_response": content}
|
||
parsed = normalize_ai_parsed(parsed)
|
||
return {
|
||
"model": settings["model"],
|
||
"thinking_enabled": thinking_enabled,
|
||
"privacy_mode": privacy_mode,
|
||
"checked_at": datetime.now().isoformat(timespec="seconds"),
|
||
"ai_question": ai_question,
|
||
"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,
|
||
"usage": data.get("usage") or {},
|
||
}
|
||
|
||
|
||
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_needs_review_text(value: Any) -> bool:
|
||
text = str(value or "").strip()
|
||
return bool(text) and not any(marker in text for marker in AI_NO_REVIEW_MARKERS)
|
||
|
||
|
||
def ai_compact_compare_value(value: Any) -> str:
|
||
text = str(value or "").strip()
|
||
text = text.replace(".", ".").replace("X", "X").replace("x", "x").replace("×", "x")
|
||
return re.sub(r"\s+", "", text).upper()
|
||
|
||
|
||
def ai_pdf_value_is_structured_prefix(pdf_value: Any, structured_value: Any) -> bool:
|
||
pdf_text = ai_compact_compare_value(pdf_value)
|
||
structured_text = ai_compact_compare_value(structured_value)
|
||
if len(pdf_text) < 3 or len(structured_text) <= len(pdf_text):
|
||
return False
|
||
return structured_text.startswith(pdf_text)
|
||
|
||
|
||
def ai_text_pdf_prefix_issue(text: Any) -> bool:
|
||
compact = re.sub(r"\s+", "", str(text or ""))
|
||
if not compact:
|
||
return False
|
||
patterns = [
|
||
r"PDF(?:显示|中显示|可见|值)?(?:为|是|[::])?([A-Za-z0-9][A-Za-z0-9.+\-*/xX]*)[,,;;。、]?(?:结构化字段|结构化值|字段|系统字段)(?:为|是|[::])?([A-Za-z0-9][A-Za-z0-9.+\-*/xX]*)",
|
||
r"PDF[^,,;;。]*?([A-Za-z0-9][A-Za-z0-9.+\-*/xX]{2,})[^,,;;。]*?(?:结构化字段|结构化值|字段|系统字段)[^,,;;。]*?([A-Za-z0-9][A-Za-z0-9.+\-*/xX]{2,})",
|
||
]
|
||
return any(
|
||
ai_pdf_value_is_structured_prefix(match.group(1), match.group(2))
|
||
for pattern in patterns
|
||
for match in re.finditer(pattern, compact, flags=re.IGNORECASE)
|
||
)
|
||
|
||
|
||
def ai_pdf_prefix_truncation_issue(value: Any) -> bool:
|
||
if isinstance(value, dict):
|
||
pdf_value = value.get("pdf_value") or value.get("pdf") or value.get("PDF值") or value.get("图片值")
|
||
structured_value = (
|
||
value.get("structured_value")
|
||
or value.get("structured")
|
||
or value.get("field_value")
|
||
or value.get("结构化值")
|
||
or value.get("结构化字段")
|
||
)
|
||
if ai_pdf_value_is_structured_prefix(pdf_value, structured_value):
|
||
return True
|
||
return ai_text_pdf_prefix_issue(value)
|
||
return ai_text_pdf_prefix_issue(value)
|
||
|
||
|
||
def ai_extract_leading_code(value: Any) -> str:
|
||
text = str(value or "").strip()
|
||
text = text.replace("(", "(").replace(")", ")").replace(":", ":")
|
||
match = re.match(r"^\s*([A-Za-z]?\d+(?:[.\-xX]\d+)*)", text)
|
||
return ai_compact_compare_value(match.group(1)) if match else ""
|
||
|
||
|
||
def ai_code_label_text(value: Any) -> bool:
|
||
text = str(value or "")
|
||
return bool(re.search(r"(代码|编码|入院途径|离院方式|入院病情|[A-Za-z_][A-Za-z0-9_]*_code)", text, flags=re.IGNORECASE))
|
||
|
||
|
||
def ai_code_label_values_match(pdf_value: Any, structured_value: Any) -> bool:
|
||
pdf_code = ai_extract_leading_code(pdf_value)
|
||
structured_code = ai_extract_leading_code(structured_value)
|
||
if not pdf_code or not structured_code or pdf_code != structured_code:
|
||
return False
|
||
pdf_text = str(pdf_value or "")
|
||
structured_text = str(structured_value or "")
|
||
return pdf_text != structured_text and (
|
||
bool(re.search(r"[((][^))]{1,12}[))]", pdf_text)) or ai_code_label_text(pdf_text + structured_text)
|
||
)
|
||
|
||
|
||
def ai_text_code_label_consistent_issue(text: Any) -> bool:
|
||
raw = str(text or "")
|
||
if not raw or "PDF" not in raw or not ai_code_label_text(raw):
|
||
return False
|
||
if re.search(r"(缺失|为空|未填|未填写|漏填)", raw):
|
||
return False
|
||
pdf_match = re.search(
|
||
r"PDF[^,,;;。]*?(?:显示|勾选|可见|值)?[^,,;;。]*?(?:为|是|=)?\s*[\"'“”‘’]?([A-Za-z]?\d+(?:[.\-xX]\d+)*)(?:[))\"'“”‘’]|[((][^))]{1,12}[))])?",
|
||
raw,
|
||
flags=re.IGNORECASE,
|
||
)
|
||
structured_match = re.search(
|
||
r"(?:结构化字段|结构化值|结构化|[A-Za-z_][A-Za-z0-9_]*_code|[A-Za-z_][A-Za-z0-9_]*字段值|字段值)[^,,;;。]*?(?:是否为|为|是|=)?\s*[\"'“”‘’]?([A-Za-z]?\d+(?:[.\-xX]\d+)*)",
|
||
raw,
|
||
flags=re.IGNORECASE,
|
||
)
|
||
if structured_match and not pdf_match and re.search(r"(与PDF一致|与PDF相符)", raw):
|
||
return True
|
||
if pdf_match and not structured_match and re.search(r"(与结构化一致|与PDF一致|是否与结构化一致|是否匹配|是否正确)", raw):
|
||
return True
|
||
if not pdf_match or not structured_match:
|
||
return False
|
||
return ai_compact_compare_value(pdf_match.group(1)) == ai_compact_compare_value(structured_match.group(1))
|
||
|
||
|
||
def ai_code_label_consistent_issue(value: Any) -> bool:
|
||
if isinstance(value, dict):
|
||
pdf_value = value.get("pdf_value") or value.get("pdf") or value.get("PDF值") or value.get("图片值")
|
||
structured_value = (
|
||
value.get("structured_value")
|
||
or value.get("structured")
|
||
or value.get("field_value")
|
||
or value.get("结构化值")
|
||
or value.get("结构化字段")
|
||
)
|
||
if ai_code_label_values_match(pdf_value, structured_value) and ai_code_label_text(ai_join_text(value)):
|
||
return True
|
||
return ai_text_code_label_consistent_issue(ai_join_text(value))
|
||
return ai_text_code_label_consistent_issue(value)
|
||
|
||
|
||
def ai_parse_date_token(value: str) -> date | None:
|
||
match = re.match(r"(\d{4})[-/年](\d{1,2})[-/月](\d{1,2})", value.strip())
|
||
if not match:
|
||
return None
|
||
try:
|
||
return date(int(match.group(1)), int(match.group(2)), int(match.group(3)))
|
||
except ValueError:
|
||
return None
|
||
|
||
|
||
def ai_dates_in_text(text: str) -> list[tuple[date, int]]:
|
||
dates: list[tuple[date, int]] = []
|
||
for match in re.finditer(r"\d{4}[-/年]\d{1,2}[-/月]\d{1,2}", text):
|
||
parsed = ai_parse_date_token(match.group(0))
|
||
if parsed:
|
||
dates.append((parsed, match.start()))
|
||
return dates
|
||
|
||
|
||
def ai_operation_date_not_early_issue(value: Any) -> bool:
|
||
text = ai_join_text(value)
|
||
if not text or not re.search(r"(手术操作日期|手术日期|手术时间)", text):
|
||
return False
|
||
if not re.search(r"(早于入院|入院前|同一天|是否早于)", text):
|
||
return False
|
||
if re.search(r"PDF.{0,24}\d{4}[-/年]\d{1,2}[-/月]\d{1,2}.{0,24}结构化(?:字段|值)", text):
|
||
return False
|
||
admission_match = re.search(r"入院(?:时间|日期)?[^0-9]{0,12}(\d{4}[-/年]\d{1,2}[-/月]\d{1,2})", text)
|
||
if not admission_match:
|
||
return False
|
||
admission_date = ai_parse_date_token(admission_match.group(1))
|
||
if not admission_date:
|
||
return False
|
||
operation_dates = [
|
||
item_date
|
||
for item_date, position in ai_dates_in_text(text)
|
||
if position < admission_match.start()
|
||
]
|
||
if not operation_dates:
|
||
operation_dates = [
|
||
item_date
|
||
for item_date, position in ai_dates_in_text(text)
|
||
if position != admission_match.start(1)
|
||
]
|
||
if not operation_dates:
|
||
return False
|
||
return all(item_date >= admission_date for item_date in operation_dates)
|
||
|
||
|
||
def ai_false_positive_issue(value: Any) -> bool:
|
||
return (
|
||
ai_pdf_prefix_truncation_issue(value)
|
||
or ai_code_label_consistent_issue(value)
|
||
or ai_operation_date_not_early_issue(value)
|
||
)
|
||
|
||
|
||
def ai_has_nonblank_suggested_updates(parsed: dict[str, Any]) -> bool:
|
||
suggested_updates = parsed.get("suggested_updates")
|
||
if not isinstance(suggested_updates, list):
|
||
return False
|
||
return any(isinstance(item, dict) and not blank_ai_value(suggested_update_value(item)) for item in suggested_updates)
|
||
|
||
|
||
def ai_problem_evidence(parsed: dict[str, Any]) -> list[dict[str, Any]]:
|
||
evidence = parsed.get("evidence")
|
||
if not isinstance(evidence, list):
|
||
return []
|
||
problem_results = {"mismatch", "uncertain", "missing", "problem", "not_match", "not ok", "not_ok"}
|
||
return [
|
||
item
|
||
for item in evidence
|
||
if isinstance(item, dict) and str(item.get("result") or "").strip().lower() in problem_results
|
||
]
|
||
|
||
|
||
def ai_only_false_positive(parsed: dict[str, Any]) -> bool:
|
||
if ai_has_nonblank_suggested_updates(parsed):
|
||
return False
|
||
issues = ai_remaining_issues(parsed)
|
||
if issues:
|
||
return all(ai_false_positive_issue(issue) for issue in issues)
|
||
evidence = ai_problem_evidence(parsed)
|
||
if evidence:
|
||
return all(ai_false_positive_issue(item) for item in evidence)
|
||
return ai_false_positive_issue(parsed.get("summary")) or ai_false_positive_issue(parsed.get("decision"))
|
||
|
||
|
||
def ai_only_pdf_prefix_truncation(parsed: dict[str, Any]) -> bool:
|
||
return ai_only_false_positive(parsed)
|
||
|
||
|
||
def normalize_ai_parsed(parsed: dict[str, Any]) -> dict[str, Any]:
|
||
normalized = dict(parsed)
|
||
remaining = normalized.get("remaining_issues")
|
||
if isinstance(remaining, list):
|
||
normalized["remaining_issues"] = [
|
||
item for item in remaining if ai_needs_review_text(item) and not ai_false_positive_issue(item)
|
||
]
|
||
elif remaining and ai_needs_review_text(remaining) and not ai_false_positive_issue(remaining):
|
||
normalized["remaining_issues"] = [remaining]
|
||
else:
|
||
normalized["remaining_issues"] = []
|
||
return normalized
|
||
|
||
|
||
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"} and not ai_false_positive_issue(item):
|
||
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_remaining_issues(parsed: dict[str, Any]) -> list[Any]:
|
||
issues = parsed.get("remaining_issues")
|
||
if isinstance(issues, list):
|
||
return [item for item in issues if str(item or "").strip()]
|
||
if issues:
|
||
return [issues]
|
||
return []
|
||
|
||
|
||
def ai_has_unresolved_problem(parsed: dict[str, Any]) -> bool:
|
||
if ai_only_false_positive(parsed):
|
||
return False
|
||
if ai_remaining_issues(parsed):
|
||
return True
|
||
|
||
text = ai_join_text(
|
||
{
|
||
"summary": parsed.get("summary"),
|
||
"evidence": parsed.get("evidence"),
|
||
"decision": parsed.get("decision"),
|
||
}
|
||
)
|
||
compact = re.sub(r"\s+", "", text)
|
||
has_fixed_marker = any(marker in compact for marker in AI_FIXED_MARKERS)
|
||
has_force_problem_marker = any(marker in compact for marker in AI_FORCE_PROBLEM_MARKERS)
|
||
has_unresolved_marker = any(marker in compact for marker in AI_UNRESOLVED_PROBLEM_MARKERS)
|
||
if has_unresolved_marker:
|
||
if has_fixed_marker and not has_force_problem_marker:
|
||
return False
|
||
return True
|
||
|
||
if has_fixed_marker:
|
||
return False
|
||
|
||
unresolved_code_pattern = re.compile(
|
||
r"(主要诊断编码|手术及操作编码|手术操作编码|手术编码|疾病编码).{0,16}(空白|缺失|未填|未填写|漏填|为空)"
|
||
)
|
||
return bool(unresolved_code_pattern.search(compact))
|
||
|
||
|
||
def ai_classification(parsed: dict[str, Any]) -> str:
|
||
classification = str(parsed.get("classification") or parsed.get("category") or "").strip().lower()
|
||
decision = str(parsed.get("decision") or "").strip().lower()
|
||
resolution = str(parsed.get("issue_resolution") or "").strip().lower()
|
||
ok_values = {"ok", "pass", "passed", "no_issue", "no issue", "无问题", "通过", "已通过"}
|
||
problem_values = {"problem", "not_ok", "not ok", "confirm", "不ok", "不通过", "需确认", "待确认", "需复核"}
|
||
if ai_only_false_positive(parsed):
|
||
return AI_OK_STATUS
|
||
if classification in ok_values or decision in ok_values or resolution in {"false_positive", "ok", "no_issue", "误报", "无问题", "通过"}:
|
||
return AI_OK_STATUS
|
||
if classification in problem_values or decision in problem_values or resolution in {"confirmed_problem", "uncertain", "problem", "待确认", "已证实"}:
|
||
if not ai_remaining_issues(parsed) and ai_has_nonblank_suggested_updates(parsed):
|
||
return AI_OK_STATUS
|
||
if (
|
||
not ai_remaining_issues(parsed)
|
||
and not any(not ai_false_positive_issue(item) for item in ai_problem_evidence(parsed))
|
||
and not ai_has_unresolved_problem(parsed)
|
||
):
|
||
return AI_OK_STATUS
|
||
return AI_PROBLEM_STATUS
|
||
if ai_remaining_issues(parsed) or ai_has_unresolved_problem(parsed):
|
||
return AI_PROBLEM_STATUS
|
||
suggested_updates = parsed.get("suggested_updates")
|
||
if isinstance(suggested_updates, list) and suggested_updates:
|
||
return AI_OK_STATUS
|
||
if any(not ai_false_positive_issue(item) for item in ai_problem_evidence(parsed)):
|
||
return AI_PROBLEM_STATUS
|
||
return AI_OK_STATUS
|
||
|
||
|
||
def ai_status_from_result(result: dict[str, Any]) -> str:
|
||
parsed = result.get("parsed") if isinstance(result.get("parsed"), dict) else {}
|
||
return ai_classification(parsed)
|
||
|
||
|
||
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()
|
||
verdict = "AI判断通过" if status == AI_OK_STATUS else "AI建议复核"
|
||
return f"AI视觉核验({datetime.now().strftime('%Y-%m-%d %H:%M:%S')}): {verdict}。{summary}".strip()
|
||
|
||
|
||
def blank_ai_value(value: Any) -> bool:
|
||
text = str(value or "").strip()
|
||
if not text:
|
||
return True
|
||
return any(marker in text for marker in ("空白", "未填写", "无内容", "可见但为空", "编码栏可见但为空", "null"))
|
||
|
||
|
||
def normalize_operation_code(value: Any) -> str:
|
||
text = str(value or "").strip()
|
||
text = re.sub(r"\s+", " ", text)
|
||
match = re.match(r"^([A-Za-z]?\d{1,3}\.\d{1,4}x?)\s+(\d{3})$", text, flags=re.IGNORECASE)
|
||
if match:
|
||
return f"{match.group(1)}{match.group(2)}"
|
||
return text
|
||
|
||
|
||
def suggested_update_value(item: dict[str, Any]) -> Any:
|
||
for key in ("value", "new", "new_value", "pdf_value", "suggested_value"):
|
||
if key in item:
|
||
return item.get(key)
|
||
return None
|
||
|
||
|
||
def ai_outpatient_code_leak(path: tuple[str, int | None, str | None], value: Any, before: dict[str, Any], _current_value: Any) -> bool:
|
||
outpatient_code = str(before.get("outpatient_diagnosis_code") or "").strip()
|
||
if not outpatient_code or str(value or "").strip() != outpatient_code:
|
||
return False
|
||
field, _index, key = path
|
||
target_is_diagnosis_code = field == "primary_diagnosis_code" or (field == "discharge_diagnoses" and key == "疾病编码")
|
||
return bool(target_is_diagnosis_code)
|
||
|
||
|
||
PRIMARY_DIAGNOSIS_AI_TARGETS = {
|
||
"primary_diagnosis": "出院诊断",
|
||
"主要诊断": "出院诊断",
|
||
"主要诊断名称": "出院诊断",
|
||
"primary_diagnosis_code": "疾病编码",
|
||
"主要诊断编码": "疾病编码",
|
||
"primary_admission_condition": "入院病情",
|
||
"主要诊断入院病情": "入院病情",
|
||
}
|
||
|
||
|
||
def ai_update_path(field_text: str, item: dict[str, Any]) -> tuple[str, int | None, str | None] | None:
|
||
text = str(field_text or "").strip()
|
||
for keyword, column in sorted(PRIMARY_DIAGNOSIS_AI_TARGETS.items(), key=lambda entry: len(entry[0]), reverse=True):
|
||
if keyword in text:
|
||
return ("discharge_diagnoses", -1, column)
|
||
if text in EDITABLE_FIELDS:
|
||
return (text, None, None)
|
||
for name, meta in FIELD_META.items():
|
||
if text == meta["label"]:
|
||
return (name, None, None)
|
||
|
||
match = re.match(r"^(operations|discharge_diagnoses|fee_details)\[(\d+)\][.。.]?(.*)$", text)
|
||
if match:
|
||
return (match.group(1), int(match.group(2)), match.group(3).strip() or None)
|
||
|
||
row_index = item.get("row_index")
|
||
try:
|
||
index = int(row_index) if row_index not in {None, ""} else 0
|
||
except (TypeError, ValueError):
|
||
index = 0
|
||
|
||
operation_columns = {"手术操作编码", "手术操作日期", "手术级别", "手术操作名称", "术者", "I助", "II助", "切口愈合等级", "麻醉方式", "麻醉医师", "原始内容"}
|
||
diagnosis_columns = {"诊断类别", "出院诊断", "疾病编码", "入院病情"}
|
||
for column in operation_columns:
|
||
if column in text:
|
||
return ("operations", index, column)
|
||
for column in diagnosis_columns:
|
||
if column in text:
|
||
return ("discharge_diagnoses", index, column)
|
||
return None
|
||
|
||
|
||
def ai_suggested_updates(result: dict[str, Any], before: dict[str, Any]) -> tuple[dict[str, Any], list[dict[str, Any]]]:
|
||
parsed = result.get("parsed") if isinstance(result.get("parsed"), dict) else {}
|
||
items = parsed.get("suggested_updates")
|
||
if not isinstance(items, list):
|
||
return {}, []
|
||
|
||
updates: dict[str, Any] = {}
|
||
changed_fields: list[dict[str, Any]] = []
|
||
working = {key: json_ready_deep(before.get(key)) for key in EDITABLE_FIELDS}
|
||
|
||
for item in items:
|
||
if not isinstance(item, dict):
|
||
continue
|
||
value = suggested_update_value(item)
|
||
if blank_ai_value(value):
|
||
continue
|
||
path = ai_update_path(str(item.get("field") or item.get("target") or ""), item)
|
||
if not path:
|
||
continue
|
||
field, index, key = path
|
||
if field not in EDITABLE_FIELDS:
|
||
continue
|
||
old_value = working.get(field)
|
||
if index is None:
|
||
if ai_outpatient_code_leak(path, value, before, old_value):
|
||
continue
|
||
try:
|
||
new_value = parse_field_value(field, value)
|
||
except Exception:
|
||
continue
|
||
label = FIELD_META[field]["label"]
|
||
compare_old = old_value
|
||
else:
|
||
if field not in JSON_FIELDS or key is None:
|
||
continue
|
||
rows = old_value if isinstance(old_value, list) else []
|
||
rows = [dict(row) if isinstance(row, dict) else {} for row in rows]
|
||
if index == -1 and field == "discharge_diagnoses":
|
||
if not rows:
|
||
rows = [{"诊断类别": "主要诊断", "出院诊断": "", "疾病编码": "", "入院病情": ""}]
|
||
index = main_diagnosis_row_index(rows)
|
||
if index < 0 or index >= len(rows):
|
||
continue
|
||
compare_old = rows[index].get(key)
|
||
if ai_outpatient_code_leak(path, value, before, compare_old):
|
||
continue
|
||
if field == "operations" and key == "手术操作编码":
|
||
value = normalize_operation_code(value)
|
||
if ai_pdf_value_is_structured_prefix(value, compare_old):
|
||
continue
|
||
if comparable(compare_old) == comparable(value):
|
||
continue
|
||
rows[index][key] = value
|
||
new_value = rows
|
||
label = f"{FIELD_META[field]['label']}[{index + 1}].{key}"
|
||
|
||
if index is None and ai_pdf_value_is_structured_prefix(value, compare_old):
|
||
continue
|
||
if comparable(compare_old) == comparable(value):
|
||
continue
|
||
working[field] = new_value
|
||
updates[field] = new_value
|
||
changed_fields.append(
|
||
{
|
||
"field": field if index is None else f"{field}[{index}].{key}",
|
||
"label": label,
|
||
"old": json_ready_deep(compare_old),
|
||
"new": json_ready_deep(value),
|
||
}
|
||
)
|
||
|
||
sync_primary_diagnosis_updates(updates, before)
|
||
return updates, changed_fields
|
||
|
||
|
||
def apply_ai_review(record_id: int, kimi_override: dict[str, Any] | None = None) -> dict[str, Any]:
|
||
before = fetch_record(record_id)
|
||
result = call_kimi_ai_review(before, kimi_override)
|
||
ai_updates, changed_fields = ai_suggested_updates(result, before)
|
||
new_status = ai_status_from_result(result)
|
||
note = ai_review_note(new_status, result)
|
||
assignments = [sql.SQL("review_status = %s")]
|
||
values: list[Any] = [new_status]
|
||
for field, value in ai_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)
|
||
with connect() as conn, conn.cursor() as cur:
|
||
cur.execute(
|
||
sql.SQL("UPDATE {table} SET {assignments} WHERE id = %s").format(
|
||
table=table_identifier(),
|
||
assignments=sql.SQL(", ").join(assignments),
|
||
),
|
||
[*values, record_id],
|
||
)
|
||
insert_review_log(cur, record_id, before.get("source_file", ""), changed_fields, note, changed_by="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 = 3, kimi_override: dict[str, Any] | None = None) -> dict[str, Any]:
|
||
last_exc: Exception | None = None
|
||
for attempt in range(attempts):
|
||
try:
|
||
return apply_ai_review(record_id, kimi_override)
|
||
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", "fifty", "all", "ai_pending", "privacy_blocked"}:
|
||
raise HTTPException(status_code=400, detail="AI核验范围只能是 current/five/fifty/all/ai_pending/privacy_blocked")
|
||
if scope in {"current", "five", "fifty"} and not record_id:
|
||
raise HTTPException(status_code=400, detail="缺少当前记录 ID")
|
||
if scope == "current":
|
||
return [int(record_id)]
|
||
if scope == "ai_pending":
|
||
query = sql.SQL("SELECT id FROM {table} WHERE review_status = %s ORDER BY id").format(table=table_identifier())
|
||
with connect() as conn, conn.cursor() as cur:
|
||
cur.execute(query, (AI_PENDING_STATUS,))
|
||
rows = cur.fetchall()
|
||
return [int(row["id"]) for row in rows]
|
||
if scope == "privacy_blocked":
|
||
query = sql.SQL(
|
||
"""
|
||
SELECT id
|
||
FROM {table}
|
||
WHERE review_status = %s
|
||
AND EXISTS (
|
||
SELECT 1
|
||
FROM jsonb_array_elements(COALESCE(review_logs, '[]'::jsonb)) AS log
|
||
WHERE log->>'changed_by' = 'AI'
|
||
AND (
|
||
log->'ai_result'->>'ai_question' ILIKE %s
|
||
OR log->'ai_result'->'parsed'->>'summary' ILIKE %s
|
||
OR (log->'ai_result'->'parsed'->'remaining_issues')::text ILIKE %s
|
||
)
|
||
AND (
|
||
(log->'ai_result'->'parsed'->'remaining_issues')::text ILIKE %s
|
||
OR (log->'ai_result'->'parsed'->'remaining_issues')::text ILIKE %s
|
||
)
|
||
)
|
||
ORDER BY id
|
||
"""
|
||
).format(table=table_identifier())
|
||
with connect() as conn, conn.cursor() as cur:
|
||
cur.execute(
|
||
query,
|
||
(
|
||
AI_PENDING_STATUS,
|
||
"%隐私模式本地判定%",
|
||
"%隐私模式未上传敏感区域%",
|
||
"%隐私模式不上传%",
|
||
"%基本信息%",
|
||
"%地址联系人%",
|
||
),
|
||
)
|
||
rows = cur.fetchall()
|
||
return [int(row["id"]) for row in rows]
|
||
|
||
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")
|
||
elif scope == "fifty":
|
||
where = sql.SQL("review_status = 'needs_review' AND id > %s")
|
||
params.append(record_id)
|
||
limit_sql = sql.SQL("LIMIT 50")
|
||
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 ai_cancel_requested() -> bool:
|
||
with AI_JOB_LOCK:
|
||
return bool(AI_REVIEW_JOB.get("cancel_requested"))
|
||
|
||
|
||
def run_ai_review_job(scope: str, ids: list[int], kimi_override: dict[str, Any] | None = None) -> 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)))
|
||
privacy_mode = normalize_bool((kimi_override or {}).get("privacy_mode"), True)
|
||
stop_event = threading.Event()
|
||
update_ai_job(
|
||
kind="ai_review",
|
||
running=True,
|
||
cancel_requested=False,
|
||
scope=scope,
|
||
total=len(ids),
|
||
processed=0,
|
||
ok=0,
|
||
pending=0,
|
||
failed=0,
|
||
concurrency=concurrency,
|
||
model=str((kimi_override or {}).get("model") or settings.get("model") or ""),
|
||
thinking_enabled=bool((kimi_override or {}).get("thinking_enabled") if (kimi_override or {}).get("thinking_enabled") is not None else settings.get("thinking_enabled")),
|
||
message="AI核验中",
|
||
errors=[],
|
||
started_at=datetime.now().isoformat(timespec="seconds"),
|
||
finished_at="",
|
||
last_record_id=None,
|
||
privacy_mode=privacy_mode,
|
||
)
|
||
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():
|
||
if ai_cancel_requested():
|
||
with AI_JOB_LOCK:
|
||
AI_REVIEW_JOB["message"] = "AI核验正在中断..."
|
||
stop_event.set()
|
||
return
|
||
try:
|
||
record_id = record_queue.get_nowait()
|
||
except Empty:
|
||
return
|
||
try:
|
||
item = apply_ai_review_with_retry(record_id, kimi_override=kimi_override)
|
||
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)
|
||
cancelled = bool(AI_REVIEW_JOB.get("cancel_requested"))
|
||
stopped = stop_event.is_set()
|
||
message = str(AI_REVIEW_JOB.get("message") or "")
|
||
update_ai_job(
|
||
running=False,
|
||
message="AI核验已中断" if cancelled else (message if stopped else ("AI核验完成" if failed == 0 else f"AI核验完成,失败 {failed} 条")),
|
||
finished_at=datetime.now().isoformat(timespec="seconds"),
|
||
)
|
||
|
||
|
||
def ai_no_issue_reviewed_ids() -> list[int]:
|
||
query = sql.SQL(
|
||
"""
|
||
SELECT id
|
||
FROM {table}
|
||
WHERE review_status IN ('AI已处理-OK', 'AI复核-无问题')
|
||
OR (
|
||
review_status = 'auto_pass'
|
||
AND COALESCE(review_logs, '[]'::jsonb) @> jsonb_build_array(jsonb_build_object('changed_by', 'AI'))
|
||
)
|
||
ORDER BY id
|
||
"""
|
||
).format(table=table_identifier())
|
||
with connect() as conn, conn.cursor() as cur:
|
||
cur.execute(query)
|
||
rows = cur.fetchall()
|
||
return [int(row["id"]) for row in rows]
|
||
|
||
|
||
def mark_ai_no_issue_reviewed_batch(record_ids: list[int]) -> int:
|
||
if not record_ids:
|
||
return 0
|
||
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 id = ANY(%s)
|
||
AND (
|
||
review_status IN ('AI已处理-OK', 'AI复核-无问题')
|
||
OR (
|
||
review_status = 'auto_pass'
|
||
AND COALESCE(review_logs, '[]'::jsonb) @> jsonb_build_array(jsonb_build_object('changed_by', '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, record_ids))
|
||
count = cur.rowcount
|
||
conn.commit()
|
||
return int(count)
|
||
|
||
|
||
def update_bulk_approve_job(**updates: Any) -> dict[str, Any]:
|
||
with BULK_JOB_LOCK:
|
||
BULK_APPROVE_JOB.update(updates)
|
||
return dict(BULK_APPROVE_JOB)
|
||
|
||
|
||
def run_approve_ai_no_issue_job(record_ids: list[int]) -> None:
|
||
update_bulk_approve_job(
|
||
kind="approve_ai_passed",
|
||
running=True,
|
||
total=len(record_ids),
|
||
processed=0,
|
||
updated=0,
|
||
failed=0,
|
||
message="批量通过AI已通过中",
|
||
started_at=datetime.now().isoformat(timespec="seconds"),
|
||
finished_at="",
|
||
)
|
||
try:
|
||
updated = 0
|
||
for start in range(0, len(record_ids), APPROVE_BATCH_SIZE):
|
||
batch = record_ids[start : start + APPROVE_BATCH_SIZE]
|
||
updated += mark_ai_no_issue_reviewed_batch(batch)
|
||
update_bulk_approve_job(
|
||
processed=min(start + len(batch), len(record_ids)),
|
||
updated=updated,
|
||
message="批量通过AI已通过中",
|
||
)
|
||
time.sleep(0.05)
|
||
refresh_status_snapshot(source="bulk")
|
||
update_bulk_approve_job(
|
||
running=False,
|
||
processed=len(record_ids),
|
||
updated=updated,
|
||
message=f"批量通过完成,已更新 {updated} 条",
|
||
finished_at=datetime.now().isoformat(timespec="seconds"),
|
||
)
|
||
except Exception as exc: # noqa: BLE001
|
||
update_bulk_approve_job(
|
||
running=False,
|
||
failed=1,
|
||
message=f"批量通过失败:{exc}",
|
||
finished_at=datetime.now().isoformat(timespec="seconds"),
|
||
)
|
||
|
||
|
||
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', 'reviewed', 'AI已处理-OK', 'AI已处理-不OK', 'AI复核-无问题', 'AI复核-待确认') OR (
|
||
review_status = 'auto_pass'
|
||
AND COALESCE(review_logs, '[]'::jsonb) @> jsonb_build_array(jsonb_build_object('changed_by', 'AI'))
|
||
)) AS workbench_total,
|
||
count(*) FILTER (WHERE review_status IN ('needs_review', 'AI已处理-不OK', '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 = 'auto_pass'
|
||
AND COALESCE(review_logs, '[]'::jsonb) @> jsonb_build_array(jsonb_build_object('changed_by', 'AI'))
|
||
) OR review_status IN ('AI已处理-OK', 'AI复核-无问题')) AS ai_passed,
|
||
count(*) FILTER (WHERE review_status IN ('AI已处理-不OK', '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', 'reviewed', 'AI已处理-OK', 'AI已处理-不OK', 'AI复核-无问题', 'AI复核-待确认') OR (
|
||
review_status = 'auto_pass'
|
||
AND COALESCE(review_logs, '[]'::jsonb) @> jsonb_build_array(jsonb_build_object('changed_by', 'AI'))
|
||
)) AS review_queue,
|
||
count(*) FILTER (WHERE review_status = 'needs_review') AS needs_review,
|
||
count(*) FILTER (WHERE (
|
||
review_status = 'auto_pass'
|
||
AND COALESCE(review_logs, '[]'::jsonb) @> jsonb_build_array(jsonb_build_object('changed_by', 'AI'))
|
||
) OR review_status IN ('AI已处理-OK', 'AI复核-无问题')) AS ai_passed,
|
||
count(*) FILTER (WHERE review_status IN ('AI已处理-不OK', '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": [],
|
||
}
|
||
|
||
|
||
def record_filter_sql(q: str = "", status_filter: str = "review_all") -> tuple[sql.Composable, list[Any]]:
|
||
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已处理-OK', 'AI已处理-不OK', 'AI复核-无问题', 'AI复核-待确认') OR (review_status = 'auto_pass' AND COALESCE(review_logs, '[]'::jsonb) @> jsonb_build_array(jsonb_build_object('changed_by', 'AI'))))")
|
||
elif status_filter != "all":
|
||
if status_filter == "reviewed":
|
||
clauses.append("review_status = 'reviewed'")
|
||
elif status_filter in {"ai_passed", "AI已处理-OK", "AI复核-无问题"}:
|
||
clauses.append("(review_status IN ('AI已处理-OK', 'AI复核-无问题') OR (review_status = 'auto_pass' AND COALESCE(review_logs, '[]'::jsonb) @> jsonb_build_array(jsonb_build_object('changed_by', 'AI'))))")
|
||
else:
|
||
clauses.append("review_status = %s")
|
||
params.append(status_filter)
|
||
if not clauses:
|
||
return sql.SQL(""), params
|
||
return sql.SQL("WHERE ") + sql.SQL(" AND ").join(sql.SQL(clause) for clause in clauses), params
|
||
|
||
|
||
RECORD_LIST_ORDER_SQL = sql.SQL(
|
||
"""
|
||
ORDER BY
|
||
last_activity_at DESC NULLS LAST,
|
||
CASE review_status
|
||
WHEN 'needs_review' THEN 1
|
||
WHEN 'AI已处理-不OK' THEN 2
|
||
WHEN 'AI复核-待确认' THEN 2
|
||
WHEN 'AI已处理-OK' THEN 3
|
||
WHEN 'AI复核-无问题' THEN 3
|
||
WHEN 'auto_pass' THEN 3
|
||
WHEN 'auto_corrected' THEN 4
|
||
WHEN 'reviewed' THEN 5
|
||
WHEN '已提交' THEN 6
|
||
ELSE 7
|
||
END,
|
||
id
|
||
"""
|
||
)
|
||
|
||
|
||
@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))
|
||
where_sql, params = record_filter_sql(q, status_filter)
|
||
|
||
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,
|
||
COALESCE(review_logs, '[]'::jsonb) @> jsonb_build_array(jsonb_build_object('changed_by', 'AI')) AS ai_reviewed,
|
||
NULLIF(review_logs->-1->>'changed_at', '')::timestamp AS last_activity_at
|
||
FROM {table}
|
||
{where}
|
||
{order_by}
|
||
LIMIT %s OFFSET %s
|
||
"""
|
||
).format(table=table_identifier(), where=where_sql, order_by=RECORD_LIST_ORDER_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.get("/api/records/{record_id}/ai-question-image/{log_index}/{module_index}")
|
||
def ai_question_image(record_id: int, log_index: int, module_index: int):
|
||
if log_index < 0 or module_index < 0:
|
||
raise HTTPException(status_code=400, detail="AI提问图片索引无效")
|
||
record = fetch_record(record_id)
|
||
pdf_path = get_pdf_path(record.get("source_file") or "")
|
||
if not pdf_path:
|
||
raise HTTPException(status_code=404, detail="PDF 文件不存在")
|
||
ai_logs = [log for log in fetch_review_logs(record_id, limit=100) if log.get("ai_result")]
|
||
if log_index >= len(ai_logs):
|
||
raise HTTPException(status_code=404, detail="AI提问记录不存在")
|
||
result = ai_logs[log_index].get("ai_result") or {}
|
||
modules = (result.get("pdf_context") or {}).get("modules") or []
|
||
if module_index >= len(modules):
|
||
raise HTTPException(status_code=404, detail="AI提问图片不存在")
|
||
module = modules[module_index] or {}
|
||
bbox = module.get("bbox") or []
|
||
if not isinstance(bbox, list) or len(bbox) != 4:
|
||
raise HTTPException(status_code=400, detail="AI提问图片定位无效")
|
||
try:
|
||
import fitz # type: ignore[import-not-found]
|
||
|
||
clip = fitz.Rect([float(value) for value in bbox])
|
||
page_index = max(0, int(module.get("page") or 1) - 1)
|
||
except Exception as exc: # noqa: BLE001
|
||
raise HTTPException(status_code=400, detail="AI提问图片定位无法解析") from exc
|
||
return Response(
|
||
content=render_pdf_page_png(pdf_path, dpi=120, page_index=page_index, clip=clip),
|
||
media_type="image/png",
|
||
headers={"Cache-Control": "no-store", "X-Content-Type-Options": "nosniff"},
|
||
)
|
||
|
||
|
||
@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()
|
||
current_kimi = data.get("kimi") or {}
|
||
api_key = payload.api_key.strip() or str(current_kimi.get("api_key") or "").strip()
|
||
data["kimi"] = normalize_kimi_settings(
|
||
{
|
||
"enabled": payload.enabled,
|
||
"model": payload.model,
|
||
"api_base": payload.api_base or current_kimi.get("api_base") or DEFAULT_KIMI_API_BASE,
|
||
"api_key": api_key,
|
||
"concurrency": payload.concurrency,
|
||
"thinking_enabled": payload.thinking_enabled,
|
||
"ai_scope_mode": payload.ai_scope_mode,
|
||
"ai_action_modes": payload.ai_action_modes,
|
||
"ai_action_privacy_modes": payload.ai_action_privacy_modes,
|
||
}
|
||
)
|
||
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.get("/api/ai/review/approve-no-issue/status")
|
||
def approve_ai_no_issue_status():
|
||
with BULK_JOB_LOCK:
|
||
return dict(BULK_APPROVE_JOB)
|
||
|
||
|
||
@app.post("/api/ai/review/cancel")
|
||
def cancel_ai_review():
|
||
with AI_JOB_LOCK:
|
||
if AI_REVIEW_JOB.get("running"):
|
||
AI_REVIEW_JOB["cancel_requested"] = True
|
||
AI_REVIEW_JOB["message"] = "AI核验正在中断..."
|
||
return dict(AI_REVIEW_JOB)
|
||
|
||
|
||
@app.post("/api/ai/review/ack")
|
||
def ack_ai_review():
|
||
with AI_JOB_LOCK:
|
||
if AI_REVIEW_JOB.get("running"):
|
||
return dict(AI_REVIEW_JOB)
|
||
AI_REVIEW_JOB.update(
|
||
running=False,
|
||
cancel_requested=False,
|
||
scope="",
|
||
total=0,
|
||
processed=0,
|
||
ok=0,
|
||
pending=0,
|
||
failed=0,
|
||
concurrency=0,
|
||
message="",
|
||
errors=[],
|
||
started_at="",
|
||
finished_at="",
|
||
last_record_id=None,
|
||
)
|
||
return dict(AI_REVIEW_JOB)
|
||
|
||
|
||
@app.post("/api/ai/review/approve-no-issue")
|
||
def approve_ai_no_issue():
|
||
with BULK_JOB_LOCK:
|
||
if BULK_APPROVE_JOB.get("running"):
|
||
raise HTTPException(status_code=409, detail="已有批量通过任务正在运行")
|
||
ids = ai_no_issue_reviewed_ids()
|
||
if not ids:
|
||
update_bulk_approve_job(
|
||
kind="approve_ai_passed",
|
||
running=False,
|
||
total=0,
|
||
processed=0,
|
||
updated=0,
|
||
failed=0,
|
||
message="当前没有AI已通过记录需要批量通过",
|
||
started_at=datetime.now().isoformat(timespec="seconds"),
|
||
finished_at=datetime.now().isoformat(timespec="seconds"),
|
||
)
|
||
return dict(BULK_APPROVE_JOB)
|
||
thread = threading.Thread(target=run_approve_ai_no_issue_job, args=(ids,), name="approve-ai-passed", daemon=True)
|
||
thread.start()
|
||
time.sleep(0.1)
|
||
with BULK_JOB_LOCK:
|
||
return dict(BULK_APPROVE_JOB)
|
||
|
||
|
||
@app.post("/api/ai/review")
|
||
def start_ai_review(payload: AiReviewPayload):
|
||
local_kimi_settings = load_local_settings().get("kimi") or {}
|
||
settings = public_kimi_settings(local_kimi_settings)
|
||
if not settings["available"]:
|
||
raise HTTPException(status_code=400, detail="AI 核验未启用或未配置 API Key")
|
||
if not ai_scope_allowed(str(settings.get("ai_scope_mode") or "all"), payload.scope):
|
||
raise HTTPException(status_code=403, detail="当前设置未开放这个 AI 处理范围")
|
||
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 核验的需复核记录")
|
||
privacy_modes = normalize_ai_action_privacy_modes(local_kimi_settings.get("ai_action_privacy_modes"))
|
||
privacy_mode = (
|
||
normalize_bool(payload.privacy_mode, privacy_modes.get(payload.scope, True))
|
||
if payload.privacy_mode is not None
|
||
else privacy_modes.get(payload.scope, True)
|
||
)
|
||
kimi_override = {
|
||
"model": payload.model,
|
||
"thinking_enabled": payload.thinking_enabled,
|
||
"privacy_mode": privacy_mode,
|
||
}
|
||
thread = threading.Thread(
|
||
target=run_ai_review_job,
|
||
args=(payload.scope, ids, kimi_override),
|
||
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",
|
||
},
|
||
)
|