2128 lines
87 KiB
Python
2128 lines
87 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""Manual review web app for HIS patient-list OCR results."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import datetime as dt
|
||
import base64
|
||
import csv
|
||
import hashlib
|
||
import io
|
||
import json
|
||
import os
|
||
import random
|
||
import re
|
||
import secrets
|
||
import socket
|
||
import urllib.error
|
||
import urllib.request
|
||
from dataclasses import dataclass
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
import psycopg
|
||
from psycopg.rows import dict_row
|
||
from dotenv import load_dotenv
|
||
from flask import Flask, Response, abort, jsonify, redirect, render_template, request, send_file, session, url_for
|
||
from PIL import Image
|
||
|
||
|
||
load_dotenv()
|
||
|
||
COLUMNS = [
|
||
"姓名",
|
||
"性别",
|
||
"年龄",
|
||
"住院号",
|
||
"诊断",
|
||
"入院时间",
|
||
"最后书写时间",
|
||
"住院天数",
|
||
"出院时间",
|
||
"手术后天数",
|
||
]
|
||
|
||
DATETIME_PATTERN = re.compile(r"\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}")
|
||
FLEX_DATETIME_PATTERN = re.compile(r"^(\d{4})-(\d{1,2})-(\d{1,2})\s+(\d{1,2}):(\d{1,2}):(\d{1,2})$")
|
||
INPATIENT_NO_PATTERN = re.compile(r".*")
|
||
IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".bmp", ".webp"}
|
||
IGNORED_REVIEW_TIPS = {"缺少出院时间"}
|
||
IGNORED_AI_ISSUE_PATTERNS = (
|
||
"最后书写时间与出院时间顺序异常",
|
||
"最后书写时间晚于出院时间",
|
||
"出院后仍有书写记录",
|
||
"已出院后仍有书写记录",
|
||
)
|
||
FORMAT_ONLY_AI_ISSUE_PATTERNS = (
|
||
"实际值一致",
|
||
"时间值一致",
|
||
"格式差异",
|
||
"补零差异",
|
||
"小时格式",
|
||
"日期格式补零",
|
||
"仅格式",
|
||
"无需修正",
|
||
)
|
||
AI_PENDING_STATE = "AI修改-待确认"
|
||
STILL_CONFIRM_STATE = "修订后仍需确认"
|
||
MANUAL_PASSED_STATE = "人工复核通过"
|
||
DATETIME_COLUMNS = {"入院时间", "最后书写时间", "出院时间"}
|
||
|
||
WORKSPACE_ROOT = Path(os.getenv("WORKSPACE_ROOT", ".")).resolve()
|
||
RESULT_ROOT = WORKSPACE_ROOT / os.getenv("REVIEW_RESULT_ROOT", "数据处理结果区/已处理-患者目录图片集群")
|
||
CORRECTIONS_PATH = WORKSPACE_ROOT / os.getenv("REVIEW_CORRECTIONS_PATH", "数据处理工作区/03_人工复核修正.json")
|
||
CONFIG_PATH = WORKSPACE_ROOT / os.getenv("REVIEW_CONFIG_PATH", "数据处理工作区/人工复核网页端配置.json")
|
||
POSTGRES_SYNC_ENABLED = os.getenv("REVIEW_POSTGRES_SYNC", "1").lower() not in {"0", "false", "no", "off"}
|
||
MERGED_RESULT_PATH = WORKSPACE_ROOT / os.getenv("REVIEW_MERGED_RESULT_PATH", "数据处理结果区/合并_患者列表_结构化.json")
|
||
MERGED_CSV_PATH = WORKSPACE_ROOT / os.getenv("REVIEW_MERGED_CSV_PATH", "数据处理结果区/合并_患者列表_记录.csv")
|
||
RESULT_INFO_DIR = WORKSPACE_ROOT / os.getenv("REVIEW_RESULT_INFO_DIR", "数据处理结果区/信息记录")
|
||
PERMISSION_KEYS = ("overview", "review", "audit", "audit_history", "settings")
|
||
PERMISSION_LABELS = {
|
||
"overview": "概览",
|
||
"review": "复核",
|
||
"audit": "抽查",
|
||
"audit_history": "抽查一览",
|
||
"settings": "设置",
|
||
}
|
||
FULL_PERMISSIONS = {key: True for key in PERMISSION_KEYS}
|
||
KIMI_TIMEOUT_SECONDS = int(os.getenv("KIMI_TIMEOUT_SECONDS", "120"))
|
||
KIMI_IMAGE_MAX_WIDTH = int(os.getenv("KIMI_IMAGE_MAX_WIDTH", "1200"))
|
||
KIMI_AUDIT_MAX_TOKENS = int(os.getenv("KIMI_AUDIT_MAX_TOKENS", "768"))
|
||
KIMI_CORRECTION_MAX_TOKENS = int(os.getenv("KIMI_CORRECTION_MAX_TOKENS", "768"))
|
||
|
||
app = Flask(__name__)
|
||
app.secret_key = (
|
||
os.getenv("REVIEW_APP_SECRET_KEY")
|
||
or os.getenv("FLASK_SECRET_KEY")
|
||
or hashlib.sha256((os.getenv("HIS_DB_PASSWORD", "") + str(WORKSPACE_ROOT)).encode("utf-8")).hexdigest()
|
||
or secrets.token_hex(32)
|
||
)
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class ReviewItem:
|
||
key: str
|
||
batch_name: str
|
||
source: str
|
||
record: dict[str, Any]
|
||
origin: str
|
||
|
||
|
||
def json_response(data: Any, status: int = 200) -> Response:
|
||
return app.response_class(
|
||
json.dumps(data, ensure_ascii=False),
|
||
status=status,
|
||
mimetype="application/json; charset=utf-8",
|
||
)
|
||
|
||
|
||
def error_response(message: str, status: int = 400) -> Response:
|
||
return json_response({"error": message}, status=status)
|
||
|
||
|
||
def normalize_text(value: Any) -> str:
|
||
if value is None:
|
||
return ""
|
||
return re.sub(r"\s+", " ", str(value)).strip()
|
||
|
||
|
||
def normalize_datetime_text(value: Any) -> str:
|
||
text = normalize_text(value)
|
||
if not text:
|
||
return ""
|
||
match = FLEX_DATETIME_PATTERN.fullmatch(text)
|
||
if not match:
|
||
return text
|
||
year, month, day, hour, minute, second = (int(part) for part in match.groups())
|
||
try:
|
||
parsed = dt.datetime(year, month, day, hour, minute, second)
|
||
except ValueError:
|
||
return text
|
||
return parsed.strftime("%Y-%m-%d %H:%M:%S")
|
||
|
||
|
||
def item_key(image_path: str, row_no: int) -> str:
|
||
return hashlib.sha1(f"{image_path}|{row_no}".encode("utf-8")).hexdigest()[:20]
|
||
|
||
|
||
def require_login() -> None:
|
||
if not session.get("authenticated"):
|
||
abort(401)
|
||
|
||
|
||
@app.before_request
|
||
def auth_gate() -> None:
|
||
if request.path in {"/login", "/favicon.ico"} or request.path.startswith("/static/"):
|
||
return
|
||
if not session.get("authenticated"):
|
||
if request.path.startswith("/api/"):
|
||
abort(401)
|
||
return redirect(url_for("login"))
|
||
|
||
|
||
def load_json(path: Path, default: Any) -> Any:
|
||
if not path.exists():
|
||
return default
|
||
return json.loads(path.read_text(encoding="utf-8"))
|
||
|
||
|
||
def load_config() -> dict[str, Any]:
|
||
return load_json(CONFIG_PATH, {"users": [], "kimi": {}})
|
||
|
||
|
||
def normalized_permissions(value: Any) -> dict[str, bool]:
|
||
if not isinstance(value, dict):
|
||
return dict(FULL_PERMISSIONS)
|
||
return {key: bool(value.get(key, False)) for key in PERMISSION_KEYS}
|
||
|
||
|
||
def current_permissions() -> dict[str, bool]:
|
||
return normalized_permissions(session.get("permissions") or FULL_PERMISSIONS)
|
||
|
||
|
||
def require_permission(permission: str) -> None:
|
||
require_login()
|
||
if not current_permissions().get(permission):
|
||
abort(403)
|
||
|
||
|
||
def atomic_write_json(path: Path, data: Any) -> None:
|
||
path.parent.mkdir(parents=True, exist_ok=True)
|
||
temp_path = path.with_suffix(path.suffix + ".tmp")
|
||
temp_path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
||
temp_path.replace(path)
|
||
|
||
|
||
def save_config(config: dict[str, Any]) -> None:
|
||
atomic_write_json(CONFIG_PATH, config)
|
||
|
||
|
||
def password_hash(password: str, salt: str | None = None) -> dict[str, str]:
|
||
salt = salt or secrets.token_hex(16)
|
||
digest = hashlib.sha256((salt + password).encode("utf-8")).hexdigest()
|
||
return {"salt": salt, "hash": digest}
|
||
|
||
|
||
def password_matches(password: str, user: dict[str, Any]) -> bool:
|
||
salt = str(user.get("salt", ""))
|
||
expected = str(user.get("password_hash", ""))
|
||
digest = hashlib.sha256((salt + password).encode("utf-8")).hexdigest()
|
||
return bool(expected) and secrets.compare_digest(digest, expected)
|
||
|
||
|
||
def correction_index() -> dict[str, dict[str, Any]]:
|
||
items = load_json(CORRECTIONS_PATH, [])
|
||
index: dict[str, dict[str, Any]] = {}
|
||
for item in items:
|
||
if item.get("已提交人工通过"):
|
||
continue
|
||
image_path = normalize_text(item.get("图片路径"))
|
||
row_no = int(item.get("图片内行号") or 0)
|
||
if image_path and row_no:
|
||
index[item_key(image_path, row_no)] = item
|
||
return index
|
||
|
||
|
||
def archived_correction_keys() -> set[str]:
|
||
keys: set[str] = set()
|
||
for item in load_json(CORRECTIONS_PATH, []):
|
||
if not item.get("已提交人工通过"):
|
||
continue
|
||
image_path = normalize_text(item.get("图片路径"))
|
||
row_no = int(item.get("图片内行号") or 0)
|
||
if image_path and row_no:
|
||
keys.add(item_key(image_path, row_no))
|
||
return keys
|
||
|
||
|
||
def save_correction(item: dict[str, Any]) -> None:
|
||
data = load_json(CORRECTIONS_PATH, [])
|
||
key = item_key(item["图片路径"], int(item["图片内行号"]))
|
||
replaced = False
|
||
for index, existing in enumerate(data):
|
||
existing_key = item_key(normalize_text(existing.get("图片路径")), int(existing.get("图片内行号") or 0))
|
||
if existing_key == key:
|
||
data[index] = item
|
||
replaced = True
|
||
break
|
||
if not replaced:
|
||
data.append(item)
|
||
atomic_write_json(CORRECTIONS_PATH, data)
|
||
|
||
|
||
def set_correction_sync_status(key: str, sync_status: dict[str, Any]) -> None:
|
||
data = load_json(CORRECTIONS_PATH, [])
|
||
changed = False
|
||
for item in data:
|
||
existing_key = item_key(normalize_text(item.get("图片路径")), int(item.get("图片内行号") or 0))
|
||
if existing_key == key:
|
||
item["PostgreSQL同步"] = sync_status
|
||
changed = True
|
||
break
|
||
if changed:
|
||
atomic_write_json(CORRECTIONS_PATH, data)
|
||
|
||
|
||
def delete_correction(key: str) -> bool:
|
||
data = load_json(CORRECTIONS_PATH, [])
|
||
kept = []
|
||
removed = False
|
||
for item in data:
|
||
existing_key = item_key(normalize_text(item.get("图片路径")), int(item.get("图片内行号") or 0))
|
||
if existing_key == key:
|
||
removed = True
|
||
else:
|
||
kept.append(item)
|
||
if removed:
|
||
atomic_write_json(CORRECTIONS_PATH, kept)
|
||
return removed
|
||
|
||
|
||
def result_dirs() -> list[Path]:
|
||
if not RESULT_ROOT.exists():
|
||
return []
|
||
return sorted(path for path in RESULT_ROOT.glob("*-列表归档结果") if path.is_dir())
|
||
|
||
|
||
def build_review_items() -> list[ReviewItem]:
|
||
items: dict[str, ReviewItem] = {}
|
||
for result_dir in result_dirs():
|
||
batch_name = result_dir.name.removesuffix("-列表归档结果")
|
||
report = load_json(result_dir / "复核报告.json", {})
|
||
for source, records in (
|
||
("需人工复核记录", report.get("需人工复核记录", [])),
|
||
("人工修正记录", report.get("人工修正记录", [])),
|
||
):
|
||
for record in records:
|
||
patient = normalize_patient(record.get("患者信息", {}))
|
||
tips = effective_review_tips(record.get("复核", {}).get("提示", []))
|
||
if source == "需人工复核记录" and not tips and not validate_patient(patient, {}):
|
||
continue
|
||
image = record.get("图片信息", {})
|
||
image_path = normalize_text(image.get("图片路径"))
|
||
row_no = int(image.get("图片内行号") or 0)
|
||
if not image_path or not row_no:
|
||
continue
|
||
key = item_key(image_path, row_no)
|
||
items[key] = ReviewItem(key, batch_name, source, record, "review_report")
|
||
|
||
return sorted(items.values(), key=lambda item: (item.batch_name, item.record.get("来源文件夹", ""), item.record.get("图片信息", {}).get("图片名", ""), item.record.get("图片信息", {}).get("图片内行号", 0)))
|
||
|
||
|
||
def parse_datetime(text: str) -> dt.datetime | None:
|
||
text = normalize_datetime_text(text)
|
||
if not text:
|
||
return None
|
||
if not DATETIME_PATTERN.fullmatch(text):
|
||
return None
|
||
try:
|
||
return dt.datetime.strptime(text, "%Y-%m-%d %H:%M:%S")
|
||
except ValueError:
|
||
return None
|
||
|
||
|
||
def validate_patient(patient: dict[str, Any], options: dict[str, Any] | None = None) -> list[str]:
|
||
options = options or {}
|
||
warnings: list[str] = []
|
||
if not normalize_text(patient.get("姓名")):
|
||
warnings.append("缺少姓名")
|
||
if normalize_text(patient.get("性别")) not in {"男", "女"}:
|
||
warnings.append("性别异常")
|
||
age = normalize_text(patient.get("年龄"))
|
||
if age and not re.fullmatch(r"\d{1,3}岁", age):
|
||
warnings.append("年龄格式异常")
|
||
if not normalize_text(patient.get("住院号")):
|
||
warnings.append("缺少住院号")
|
||
admission = normalize_datetime_text(patient.get("入院时间"))
|
||
discharge = normalize_datetime_text(patient.get("出院时间"))
|
||
last_write = normalize_datetime_text(patient.get("最后书写时间"))
|
||
|
||
admission_dt = parse_datetime(admission)
|
||
discharge_dt = parse_datetime(discharge)
|
||
last_write_dt = parse_datetime(last_write)
|
||
|
||
if not admission:
|
||
warnings.append("缺少入院时间")
|
||
elif not admission_dt:
|
||
warnings.append("入院时间格式异常")
|
||
|
||
if discharge and not discharge_dt:
|
||
warnings.append("出院时间格式异常")
|
||
|
||
if last_write and not last_write_dt:
|
||
warnings.append("最后书写时间格式异常")
|
||
if admission_dt and discharge_dt and discharge_dt < admission_dt:
|
||
warnings.append("出院时间早于入院时间")
|
||
if admission_dt and last_write_dt and last_write_dt < admission_dt:
|
||
warnings.append("最后书写时间早于入院时间")
|
||
|
||
hospital_days = normalize_text(patient.get("住院天数"))
|
||
if hospital_days and not hospital_days.isdigit():
|
||
warnings.append("住院天数格式异常")
|
||
postoperative_days = normalize_text(patient.get("手术后天数"))
|
||
if postoperative_days and not re.fullmatch(r"后\d+天", postoperative_days):
|
||
warnings.append("手术后天数格式异常")
|
||
return warnings
|
||
|
||
|
||
def patient_change_list(before: dict[str, Any], after: dict[str, Any]) -> list[dict[str, str]]:
|
||
changes: list[dict[str, str]] = []
|
||
normalized_before = normalize_patient(before or {})
|
||
normalized_after = normalize_patient(after or {})
|
||
for column in COLUMNS:
|
||
old = normalize_text(normalized_before.get(column, ""))
|
||
new = normalize_text(normalized_after.get(column, ""))
|
||
if old != new:
|
||
changes.append({"字段": column, "修改前": old, "修改后": new})
|
||
return changes
|
||
|
||
|
||
def change_log_entries(before: dict[str, Any], after: dict[str, Any], source: str) -> list[dict[str, str]]:
|
||
return [{**item, "修改者": source} for item in patient_change_list(before, after)]
|
||
|
||
|
||
def normalized_change_log(value: Any) -> list[dict[str, str]]:
|
||
if not isinstance(value, list):
|
||
return []
|
||
normalized: list[dict[str, str]] = []
|
||
for item in value:
|
||
if not isinstance(item, dict):
|
||
continue
|
||
field = normalize_text(item.get("字段") or item.get("column"))
|
||
if field not in COLUMNS:
|
||
continue
|
||
normalized.append(
|
||
{
|
||
"字段": field,
|
||
"修改前": normalize_text(item.get("修改前") if "修改前" in item else item.get("before", "")),
|
||
"修改后": normalize_text(item.get("修改后") if "修改后" in item else item.get("after", "")),
|
||
"修改者": normalize_text(item.get("修改者") or item.get("source") or "人工"),
|
||
}
|
||
)
|
||
return normalized
|
||
|
||
|
||
def fallback_change_log(record: dict[str, Any], correction: dict[str, Any] | None) -> list[dict[str, str]]:
|
||
if not correction:
|
||
return []
|
||
existing_log = normalized_change_log(correction.get("修改记录"))
|
||
if existing_log:
|
||
return existing_log
|
||
source = "AI" if correction.get("AI修改") or correction.get("修改来源") == "AI修改" else "人工"
|
||
return change_log_entries(record.get("患者信息", {}), merged_patient(record, correction), source)
|
||
|
||
|
||
def patient_change_summary(before: dict[str, Any], after: dict[str, Any]) -> str:
|
||
changes = patient_change_list(before, after)
|
||
if not changes:
|
||
return ""
|
||
return ";".join(f"{item['字段']}:{item['修改前'] or '空'} -> {item['修改后'] or '空'}" for item in changes)
|
||
|
||
|
||
def effective_review_tips(tips: Any) -> list[str]:
|
||
if not isinstance(tips, list):
|
||
tips = [tips] if normalize_text(tips) else []
|
||
return [normalize_text(tip) for tip in tips if normalize_text(tip) and normalize_text(tip) not in IGNORED_REVIEW_TIPS]
|
||
|
||
|
||
def merged_patient(record: dict[str, Any], correction: dict[str, Any] | None) -> dict[str, Any]:
|
||
patient = dict(record.get("患者信息", {}))
|
||
if correction:
|
||
patient.update(correction.get("患者信息", {}))
|
||
for column in COLUMNS:
|
||
patient.setdefault(column, "")
|
||
for column in ("入院时间", "出院时间", "最后书写时间"):
|
||
patient[column] = normalize_datetime_text(patient.get(column, ""))
|
||
return patient
|
||
|
||
|
||
def normalize_patient(patient: dict[str, Any]) -> dict[str, str]:
|
||
normalized = {column: normalize_text(patient.get(column, "")) for column in COLUMNS}
|
||
normalized["住院号"] = normalized["住院号"].upper()
|
||
for column in ("入院时间", "出院时间", "最后书写时间"):
|
||
normalized[column] = normalize_datetime_text(normalized[column])
|
||
return normalized
|
||
|
||
|
||
def has_valid_inpatient_no(patient: dict[str, Any]) -> bool:
|
||
return bool(normalize_text(patient.get("住院号")))
|
||
|
||
|
||
def record_has_valid_inpatient_no(record: dict[str, Any]) -> bool:
|
||
return has_valid_inpatient_no(record.get("患者信息", {}))
|
||
|
||
|
||
def hospital_days_value(value: Any) -> int | None:
|
||
text = normalize_text(value)
|
||
return int(text) if text.isdigit() else None
|
||
|
||
|
||
def review_notes(warnings: list[str], options: dict[str, Any], manual_note: str) -> str:
|
||
notes = list(warnings)
|
||
if manual_note:
|
||
notes.append(f"人工备注: {manual_note}")
|
||
return ";".join(notes)
|
||
|
||
|
||
def db_configured() -> bool:
|
||
return all(os.getenv(name) for name in ("HIS_DB_HOST", "HIS_DB_NAME", "HIS_DB_USER", "HIS_DB_PASSWORD"))
|
||
|
||
|
||
def postgres_connection() -> psycopg.Connection:
|
||
return psycopg.connect(
|
||
host=os.getenv("HIS_DB_HOST"),
|
||
port=int(os.getenv("HIS_DB_PORT", "5432")),
|
||
dbname=os.getenv("HIS_DB_NAME"),
|
||
user=os.getenv("HIS_DB_USER"),
|
||
password=os.getenv("HIS_DB_PASSWORD"),
|
||
connect_timeout=5,
|
||
)
|
||
|
||
|
||
def update_postgres_record(
|
||
record: dict[str, Any],
|
||
patient: dict[str, str],
|
||
options: dict[str, Any],
|
||
warnings: list[str],
|
||
manual_note: str,
|
||
manual_corrected: bool,
|
||
) -> dict[str, Any]:
|
||
if not POSTGRES_SYNC_ENABLED:
|
||
return {"enabled": False, "updated": 0, "message": "PostgreSQL同步已关闭"}
|
||
if not db_configured():
|
||
return {"enabled": True, "updated": 0, "error": "数据库环境变量未完整配置"}
|
||
|
||
image = record.get("图片信息", {})
|
||
image_path = normalize_text(image.get("图片路径"))
|
||
row_no = int(image.get("图片内行号") or 0)
|
||
if not image_path or not row_no:
|
||
return {"enabled": True, "updated": 0, "error": "缺少图片路径或图片内行号,无法定位数据库记录"}
|
||
if not has_valid_inpatient_no(patient):
|
||
return {"enabled": True, "updated": 0, "error": "缺少住院号,未同步到正式表", "retryable": False}
|
||
if warnings:
|
||
review_status = "需人工复核"
|
||
elif manual_corrected:
|
||
review_status = "人工复核通过"
|
||
else:
|
||
review_status = "自动复核通过"
|
||
notes = review_notes(warnings, options, manual_note)
|
||
params = {
|
||
"image_path": image_path,
|
||
"image_row_no": row_no,
|
||
"patient_name": patient["姓名"],
|
||
"gender": patient["性别"],
|
||
"age": patient["年龄"],
|
||
"inpatient_no": patient["住院号"],
|
||
"diagnosis": patient["诊断"],
|
||
"admission_time": patient["入院时间"],
|
||
"last_write_time": patient["最后书写时间"],
|
||
"hospital_days": hospital_days_value(patient["住院天数"]),
|
||
"discharge_time": patient["出院时间"],
|
||
"postoperative_days": patient["手术后天数"],
|
||
"review_status": review_status,
|
||
"review_notes": notes,
|
||
"manual_corrected": manual_corrected,
|
||
}
|
||
set_sql = """
|
||
UPDATE "Patient_Lists"
|
||
SET patient_name = %(patient_name)s,
|
||
gender = %(gender)s,
|
||
age = %(age)s,
|
||
inpatient_no = %(inpatient_no)s,
|
||
diagnosis = %(diagnosis)s,
|
||
admission_time = %(admission_time)s,
|
||
last_write_time = %(last_write_time)s,
|
||
hospital_days = %(hospital_days)s,
|
||
discharge_time = %(discharge_time)s,
|
||
postoperative_days = %(postoperative_days)s,
|
||
review_status = %(review_status)s,
|
||
review_notes = %(review_notes)s,
|
||
manual_corrected = %(manual_corrected)s,
|
||
imported_at = now()
|
||
"""
|
||
try:
|
||
with postgres_connection() as conn:
|
||
with conn.cursor() as cur:
|
||
cur.execute(
|
||
'SELECT record_id FROM "Patient_Lists" WHERE image_path = %(image_path)s AND image_row_no = %(image_row_no)s',
|
||
params,
|
||
)
|
||
image_match = cur.fetchone()
|
||
inpatient_matches = []
|
||
if patient["住院号"]:
|
||
cur.execute('SELECT record_id FROM "Patient_Lists" WHERE inpatient_no = %(inpatient_no)s LIMIT 2', params)
|
||
inpatient_matches = cur.fetchall()
|
||
if image_match and inpatient_matches and inpatient_matches[0][0] != image_match[0]:
|
||
cur.execute(set_sql + " WHERE record_id = %(record_id)s", {**params, "record_id": inpatient_matches[0][0]})
|
||
updated = cur.rowcount
|
||
if updated:
|
||
cur.execute('DELETE FROM "Patient_Lists" WHERE record_id = %(record_id)s', {"record_id": image_match[0]})
|
||
match_method = "inpatient_no覆盖重复图片记录" if updated else ""
|
||
elif image_match:
|
||
cur.execute(set_sql + " WHERE record_id = %(record_id)s", {**params, "record_id": image_match[0]})
|
||
updated = cur.rowcount
|
||
match_method = "image_path+image_row_no" if updated else ""
|
||
elif len(inpatient_matches) == 1:
|
||
cur.execute(set_sql + " WHERE record_id = %(record_id)s", {**params, "record_id": inpatient_matches[0][0]})
|
||
updated = cur.rowcount
|
||
match_method = "inpatient_no" if updated else ""
|
||
else:
|
||
updated = 0
|
||
match_method = ""
|
||
result = {"enabled": True, "updated": updated, "match_method": match_method}
|
||
if updated == 0:
|
||
result["message"] = "PostgreSQL未找到对应记录,已保留本地修订,后续可重建/入库后再同步"
|
||
result["retryable"] = True
|
||
return result
|
||
except Exception as exc:
|
||
return {"enabled": True, "updated": 0, "error": str(exc), "retryable": True}
|
||
|
||
|
||
def sync_status_from_result(result: dict[str, Any]) -> dict[str, Any]:
|
||
if not result.get("enabled"):
|
||
state = "未启用"
|
||
elif result.get("error"):
|
||
state = "失败"
|
||
elif result.get("updated", 0) > 0:
|
||
state = "成功"
|
||
else:
|
||
state = "待同步"
|
||
return {
|
||
"状态": state,
|
||
"更新时间": dt.datetime.now().isoformat(timespec="seconds"),
|
||
"更新行数": result.get("updated", 0),
|
||
"匹配方式": result.get("match_method", ""),
|
||
"提示": result.get("error") or result.get("message", ""),
|
||
}
|
||
|
||
|
||
def pending_postgres_sync_count() -> int:
|
||
count = 0
|
||
for correction in load_json(CORRECTIONS_PATH, []):
|
||
if correction.get("AI修改") or correction.get("已提交人工通过"):
|
||
continue
|
||
if not has_valid_inpatient_no(correction.get("患者信息", {})):
|
||
continue
|
||
status = correction.get("PostgreSQL同步", {})
|
||
if status.get("状态") != "成功":
|
||
count += 1
|
||
return count
|
||
|
||
|
||
def db_status() -> dict[str, Any]:
|
||
status = {
|
||
"enabled": POSTGRES_SYNC_ENABLED,
|
||
"configured": db_configured(),
|
||
"ok": False,
|
||
"pending_sync_count": pending_postgres_sync_count(),
|
||
}
|
||
if not POSTGRES_SYNC_ENABLED or not db_configured():
|
||
return status
|
||
try:
|
||
with postgres_connection() as conn:
|
||
with conn.cursor() as cur:
|
||
cur.execute('SELECT 1 FROM "Patient_Lists" LIMIT 1')
|
||
status["ok"] = True
|
||
except Exception as exc:
|
||
status["error"] = str(exc)
|
||
return status
|
||
|
||
|
||
def ensure_audit_columns() -> None:
|
||
if not db_configured():
|
||
return
|
||
statements = [
|
||
'ALTER TABLE "Patient_Lists" ADD COLUMN IF NOT EXISTS audit_result text',
|
||
'ALTER TABLE "Patient_Lists" ADD COLUMN IF NOT EXISTS audit_ai_feedback text',
|
||
'ALTER TABLE "Patient_Lists" ADD COLUMN IF NOT EXISTS audit_ai_raw_output text',
|
||
'ALTER TABLE "Patient_Lists" ADD COLUMN IF NOT EXISTS audit_manual_feedback text',
|
||
'ALTER TABLE "Patient_Lists" ADD COLUMN IF NOT EXISTS audit_machine_verdict text',
|
||
'ALTER TABLE "Patient_Lists" ADD COLUMN IF NOT EXISTS audit_source text',
|
||
'ALTER TABLE "Patient_Lists" ADD COLUMN IF NOT EXISTS audit_checked_by text',
|
||
'ALTER TABLE "Patient_Lists" ADD COLUMN IF NOT EXISTS audit_checked_at timestamptz',
|
||
'ALTER TABLE "Patient_Lists" ADD COLUMN IF NOT EXISTS audit_patient_before jsonb',
|
||
'ALTER TABLE "Patient_Lists" ADD COLUMN IF NOT EXISTS audit_patient_after jsonb',
|
||
'ALTER TABLE "Patient_Lists" ADD COLUMN IF NOT EXISTS audit_change_summary text',
|
||
'CREATE INDEX IF NOT EXISTS idx_patient_lists_audit_result ON "Patient_Lists"(audit_result)',
|
||
]
|
||
with postgres_connection() as conn:
|
||
with conn.cursor() as cur:
|
||
for statement in statements:
|
||
cur.execute(statement)
|
||
|
||
|
||
def valid_inpatient_sql(alias: str = "") -> str:
|
||
prefix = f"{alias}." if alias else ""
|
||
return f"{prefix}inpatient_no IS NOT NULL AND btrim({prefix}inpatient_no) <> ''"
|
||
|
||
|
||
def sample_db_records(source: str, count: int) -> list[dict[str, Any]]:
|
||
if not db_configured():
|
||
return []
|
||
ensure_audit_columns()
|
||
review_where = "review_status = '自动复核通过'" if source == "db_auto_passed" else "review_status = '人工复核通过'"
|
||
where = f"{review_where} AND {valid_inpatient_sql()}"
|
||
with postgres_connection() as conn:
|
||
with conn.cursor(row_factory=dict_row) as cur:
|
||
cur.execute(f'SELECT * FROM "Patient_Lists" WHERE {where} ORDER BY random() LIMIT %s', (count,))
|
||
return [db_row_to_public(row) for row in cur.fetchall()]
|
||
|
||
|
||
def update_audit_postgres(public: dict[str, Any], payload: dict[str, Any]) -> dict[str, Any]:
|
||
if not POSTGRES_SYNC_ENABLED:
|
||
return {"enabled": False, "updated": 0, "message": "PostgreSQL同步已关闭"}
|
||
if not db_configured():
|
||
return {"enabled": True, "updated": 0, "error": "数据库环境变量未完整配置"}
|
||
ensure_audit_columns()
|
||
image_path = normalize_text(public.get("image_path"))
|
||
row_no = int(public.get("image_row_no") or 0)
|
||
item_payload = payload.get("item") if isinstance(payload.get("item"), dict) else {}
|
||
original_patient = normalize_patient(payload.get("original_patient") or item_payload.get("audit_original_patient") or public.get("patient", {}))
|
||
patient = normalize_patient(item_payload.get("patient") or public.get("patient", {}))
|
||
inpatient_no = normalize_text(patient.get("住院号") or public.get("patient", {}).get("住院号")).upper()
|
||
if not inpatient_no:
|
||
return {"enabled": True, "updated": 0, "error": "缺少住院号,未同步到正式表", "retryable": False}
|
||
change_summary = patient_change_summary(original_patient, patient)
|
||
params = {
|
||
"image_path": image_path,
|
||
"image_row_no": row_no,
|
||
"inpatient_no": inpatient_no,
|
||
"patient_name": patient["姓名"],
|
||
"gender": patient["性别"],
|
||
"age": patient["年龄"],
|
||
"diagnosis": patient["诊断"],
|
||
"admission_time": patient["入院时间"],
|
||
"last_write_time": patient["最后书写时间"],
|
||
"hospital_days": hospital_days_value(patient["住院天数"]),
|
||
"discharge_time": patient["出院时间"],
|
||
"postoperative_days": patient["手术后天数"],
|
||
"audit_result": normalize_text(payload.get("audit_result") or payload.get("status")),
|
||
"audit_ai_feedback": normalize_text(payload.get("audit_ai_feedback") or payload.get("model_result")),
|
||
"audit_ai_raw_output": normalize_text(payload.get("audit_ai_raw_output")),
|
||
"audit_manual_feedback": normalize_text(payload.get("audit_manual_feedback")),
|
||
"audit_machine_verdict": normalize_text(payload.get("audit_machine_verdict")),
|
||
"audit_source": normalize_text(payload.get("audit_source")),
|
||
"audit_checked_by": session.get("username", ""),
|
||
"audit_patient_before": json.dumps(original_patient, ensure_ascii=False),
|
||
"audit_patient_after": json.dumps(patient, ensure_ascii=False),
|
||
"audit_change_summary": change_summary,
|
||
}
|
||
set_sql = """
|
||
UPDATE "Patient_Lists"
|
||
SET patient_name = %(patient_name)s,
|
||
gender = %(gender)s,
|
||
age = %(age)s,
|
||
inpatient_no = %(inpatient_no)s,
|
||
diagnosis = %(diagnosis)s,
|
||
admission_time = %(admission_time)s,
|
||
last_write_time = %(last_write_time)s,
|
||
hospital_days = %(hospital_days)s,
|
||
discharge_time = %(discharge_time)s,
|
||
postoperative_days = %(postoperative_days)s,
|
||
audit_result = %(audit_result)s,
|
||
audit_ai_feedback = %(audit_ai_feedback)s,
|
||
audit_ai_raw_output = %(audit_ai_raw_output)s,
|
||
audit_manual_feedback = %(audit_manual_feedback)s,
|
||
audit_machine_verdict = %(audit_machine_verdict)s,
|
||
audit_source = %(audit_source)s,
|
||
audit_checked_by = %(audit_checked_by)s,
|
||
audit_patient_before = %(audit_patient_before)s::jsonb,
|
||
audit_patient_after = %(audit_patient_after)s::jsonb,
|
||
audit_change_summary = %(audit_change_summary)s,
|
||
audit_checked_at = now()
|
||
"""
|
||
try:
|
||
with postgres_connection() as conn:
|
||
with conn.cursor() as cur:
|
||
updated = 0
|
||
match_method = ""
|
||
image_match = None
|
||
inpatient_matches = []
|
||
if image_path and row_no:
|
||
cur.execute(
|
||
'SELECT record_id FROM "Patient_Lists" WHERE image_path = %(image_path)s AND image_row_no = %(image_row_no)s',
|
||
params,
|
||
)
|
||
image_match = cur.fetchone()
|
||
if inpatient_no:
|
||
cur.execute('SELECT record_id FROM "Patient_Lists" WHERE inpatient_no = %(inpatient_no)s LIMIT 2', params)
|
||
inpatient_matches = cur.fetchall()
|
||
if image_match and inpatient_matches and inpatient_matches[0][0] != image_match[0]:
|
||
cur.execute(set_sql + " WHERE record_id = %(record_id)s", {**params, "record_id": inpatient_matches[0][0]})
|
||
updated = cur.rowcount
|
||
if updated:
|
||
cur.execute('DELETE FROM "Patient_Lists" WHERE record_id = %(record_id)s', {"record_id": image_match[0]})
|
||
match_method = "inpatient_no覆盖重复图片记录" if updated else ""
|
||
elif image_match:
|
||
cur.execute(set_sql + " WHERE record_id = %(record_id)s", {**params, "record_id": image_match[0]})
|
||
updated = cur.rowcount
|
||
match_method = "image_path+image_row_no" if updated else ""
|
||
elif len(inpatient_matches) == 1:
|
||
cur.execute(set_sql + " WHERE record_id = %(record_id)s", {**params, "record_id": inpatient_matches[0][0]})
|
||
updated = cur.rowcount
|
||
match_method = "inpatient_no" if updated else ""
|
||
result = {"enabled": True, "updated": updated, "match_method": match_method}
|
||
if updated == 0:
|
||
result["message"] = "PostgreSQL未找到对应抽查记录"
|
||
return result
|
||
except Exception as exc:
|
||
return {"enabled": True, "updated": 0, "error": str(exc)}
|
||
|
||
|
||
def audit_history_rows(
|
||
page: int,
|
||
page_size: int,
|
||
source: str = "",
|
||
sort: str = "checked_desc",
|
||
query: str = "",
|
||
status: str = "",
|
||
) -> dict[str, Any]:
|
||
if not db_configured():
|
||
return {"items": [], "total": 0, "page": page, "page_size": page_size}
|
||
ensure_audit_columns()
|
||
clauses = ["""
|
||
COALESCE(audit_result, '') <> ''
|
||
OR COALESCE(audit_ai_feedback, '') <> ''
|
||
OR COALESCE(audit_ai_raw_output, '') <> ''
|
||
OR COALESCE(audit_manual_feedback, '') <> ''
|
||
OR COALESCE(audit_machine_verdict, '') <> ''
|
||
OR COALESCE(audit_change_summary, '') <> ''
|
||
"""]
|
||
clauses.append(valid_inpatient_sql())
|
||
params: list[Any] = []
|
||
if source:
|
||
clauses.append("audit_source = %s")
|
||
params.append(source)
|
||
status_clauses = {
|
||
"manual_pending": "COALESCE(audit_result, '') = ''",
|
||
"manual_passed": "audit_result = '人工核验通过'",
|
||
"manual_failed": "audit_result = '人工核验异常'",
|
||
"manual_unsure": "audit_result = '暂不确定'",
|
||
"ai_failed": "audit_machine_verdict = '异常'",
|
||
}
|
||
if status in status_clauses:
|
||
clauses.append(status_clauses[status])
|
||
if query:
|
||
like = f"%{query}%"
|
||
clauses.append("""
|
||
patient_name ILIKE %s
|
||
OR inpatient_no ILIKE %s
|
||
OR major_department ILIKE %s
|
||
OR sub_department ILIKE %s
|
||
OR source_folder ILIKE %s
|
||
""")
|
||
params.extend([like, like, like, like, like])
|
||
where = " AND ".join(f"({clause})" for clause in clauses)
|
||
order_sql = "audit_checked_at ASC NULLS LAST, record_id ASC" if sort == "checked_asc" else "audit_checked_at DESC NULLS LAST, record_id DESC"
|
||
with postgres_connection() as conn:
|
||
with conn.cursor(row_factory=dict_row) as cur:
|
||
cur.execute(f'SELECT count(*) AS total FROM "Patient_Lists" WHERE {where}', params)
|
||
total = int(cur.fetchone()["total"])
|
||
cur.execute(
|
||
f'SELECT * FROM "Patient_Lists" WHERE {where} ORDER BY {order_sql} LIMIT %s OFFSET %s',
|
||
(*params, page_size, (page - 1) * page_size),
|
||
)
|
||
rows = [db_row_to_public(row) for row in cur.fetchall()]
|
||
return {"items": rows, "total": total, "page": page, "page_size": page_size}
|
||
|
||
|
||
def reset_audit_history_records() -> dict[str, Any]:
|
||
if not POSTGRES_SYNC_ENABLED:
|
||
return {"enabled": False, "updated": 0, "message": "PostgreSQL同步已关闭"}
|
||
if not db_configured():
|
||
return {"enabled": True, "updated": 0, "error": "数据库环境变量未完整配置"}
|
||
ensure_audit_columns()
|
||
where = """
|
||
COALESCE(audit_result, '') <> ''
|
||
OR COALESCE(audit_ai_feedback, '') <> ''
|
||
OR COALESCE(audit_ai_raw_output, '') <> ''
|
||
OR COALESCE(audit_manual_feedback, '') <> ''
|
||
OR COALESCE(audit_machine_verdict, '') <> ''
|
||
OR COALESCE(audit_source, '') <> ''
|
||
OR COALESCE(audit_change_summary, '') <> ''
|
||
OR audit_checked_at IS NOT NULL
|
||
"""
|
||
with postgres_connection() as conn:
|
||
with conn.cursor() as cur:
|
||
cur.execute(
|
||
f"""
|
||
UPDATE "Patient_Lists"
|
||
SET audit_result = NULL,
|
||
audit_ai_feedback = NULL,
|
||
audit_ai_raw_output = NULL,
|
||
audit_manual_feedback = NULL,
|
||
audit_machine_verdict = NULL,
|
||
audit_source = NULL,
|
||
audit_checked_by = NULL,
|
||
audit_patient_before = NULL,
|
||
audit_patient_after = NULL,
|
||
audit_change_summary = NULL,
|
||
audit_checked_at = NULL
|
||
WHERE {where}
|
||
"""
|
||
)
|
||
updated = cur.rowcount
|
||
return {"enabled": True, "updated": updated}
|
||
|
||
|
||
def audit_overview_summary() -> dict[str, Any]:
|
||
summary = {
|
||
"total": 0,
|
||
"unreviewed": 0,
|
||
"passed": 0,
|
||
"failed": 0,
|
||
"unsure": 0,
|
||
"ai_only": 0,
|
||
"manual_review_passed_source": 0,
|
||
"db_auto_passed_source": 0,
|
||
}
|
||
if not db_configured():
|
||
return summary
|
||
try:
|
||
ensure_audit_columns()
|
||
with postgres_connection() as conn:
|
||
with conn.cursor(row_factory=dict_row) as cur:
|
||
cur.execute(
|
||
"""
|
||
SELECT
|
||
count(*) FILTER (
|
||
WHERE COALESCE(audit_result, '') <> ''
|
||
OR COALESCE(audit_ai_feedback, '') <> ''
|
||
OR COALESCE(audit_ai_raw_output, '') <> ''
|
||
OR COALESCE(audit_machine_verdict, '') <> ''
|
||
) AS total,
|
||
count(*) FILTER (
|
||
WHERE COALESCE(audit_result, '') = ''
|
||
AND (
|
||
COALESCE(audit_ai_feedback, '') <> ''
|
||
OR COALESCE(audit_ai_raw_output, '') <> ''
|
||
OR COALESCE(audit_machine_verdict, '') <> ''
|
||
)
|
||
) AS unreviewed,
|
||
count(*) FILTER (WHERE audit_result = '人工核验通过') AS passed,
|
||
count(*) FILTER (WHERE audit_result = '人工核验异常') AS failed,
|
||
count(*) FILTER (WHERE audit_result = '暂不确定') AS unsure,
|
||
count(*) FILTER (
|
||
WHERE COALESCE(audit_result, '') = ''
|
||
AND COALESCE(audit_machine_verdict, '') <> ''
|
||
) AS ai_only,
|
||
count(*) FILTER (WHERE audit_source = 'manual_review_passed') AS manual_review_passed_source,
|
||
count(*) FILTER (WHERE audit_source = 'db_auto_passed') AS db_auto_passed_source
|
||
FROM "Patient_Lists"
|
||
"""
|
||
)
|
||
row = cur.fetchone() or {}
|
||
summary.update({key: int(row.get(key) or 0) for key in summary})
|
||
except Exception as exc:
|
||
summary["error"] = str(exc)
|
||
return summary
|
||
|
||
|
||
def processing_items_overview() -> dict[str, Any]:
|
||
corrections = correction_index()
|
||
archived = archived_correction_keys()
|
||
items = [
|
||
public
|
||
for item in build_review_items()
|
||
if item.key not in archived
|
||
for public in [public_item(item, corrections.get(item.key))]
|
||
if has_valid_inpatient_no(public["patient"])
|
||
]
|
||
state_counts = {"待处理": 0, AI_PENDING_STATE: 0, STILL_CONFIRM_STATE: 0, MANUAL_PASSED_STATE: 0}
|
||
for item in items:
|
||
state_counts[item["manual_state"]] = state_counts.get(item["manual_state"], 0) + 1
|
||
audit_summary = audit_overview_summary()
|
||
confirm_review = state_counts[AI_PENDING_STATE] + state_counts[STILL_CONFIRM_STATE]
|
||
return {
|
||
"review_total": len(items),
|
||
"pending_review": state_counts["待处理"],
|
||
"ai_pending": state_counts[AI_PENDING_STATE],
|
||
"still_issue": confirm_review,
|
||
"manual_passed": state_counts[MANUAL_PASSED_STATE],
|
||
"postgres_pending_sync": pending_postgres_sync_count(),
|
||
"audit_unreviewed": audit_summary.get("unreviewed", 0),
|
||
"audit_total": audit_summary.get("total", 0),
|
||
"audit_failed": audit_summary.get("failed", 0),
|
||
"audit_unsure": audit_summary.get("unsure", 0),
|
||
"audit_summary": audit_summary,
|
||
}
|
||
|
||
|
||
def public_item(item: ReviewItem, correction: dict[str, Any] | None = None) -> dict[str, Any]:
|
||
record = item.record
|
||
image = record.get("图片信息", {})
|
||
review = record.get("复核", {})
|
||
correction = correction or correction_index().get(item.key)
|
||
options = (correction or {}).get("复核选项", {})
|
||
patient = merged_patient(record, correction)
|
||
warnings = validate_patient(patient, options)
|
||
if correction and correction.get("AI修改"):
|
||
state = AI_PENDING_STATE
|
||
elif correction and not warnings:
|
||
state = MANUAL_PASSED_STATE
|
||
elif correction:
|
||
state = STILL_CONFIRM_STATE
|
||
else:
|
||
state = "待处理"
|
||
return {
|
||
"key": item.key,
|
||
"batch_name": item.batch_name,
|
||
"source": item.source,
|
||
"origin": item.origin,
|
||
"major_department": record.get("大科室", ""),
|
||
"sub_department": record.get("子科室", ""),
|
||
"source_folder": record.get("来源文件夹", ""),
|
||
"image_path": image.get("图片路径", ""),
|
||
"image_name": image.get("图片名", ""),
|
||
"image_row_no": image.get("图片内行号", ""),
|
||
"patient": patient,
|
||
"original_patient": record.get("患者信息", {}),
|
||
"review_status": review.get("状态", ""),
|
||
"review_tips": effective_review_tips(review.get("提示", [])),
|
||
"manual_state": state,
|
||
"manual_note": (correction or {}).get("复核备注", ""),
|
||
"change_source": (correction or {}).get("修改来源") or ("AI修改" if (correction or {}).get("AI修改") else ("人工修改" if correction else "")),
|
||
"change_log": fallback_change_log(record, correction),
|
||
"ai_corrected": bool((correction or {}).get("AI修改")),
|
||
"ai_feedback": (correction or {}).get("AI反馈", ""),
|
||
"options": options,
|
||
"validation_warnings": warnings,
|
||
"updated_at": (correction or {}).get("更新时间", ""),
|
||
"postgres_sync": (correction or {}).get("PostgreSQL同步", {}),
|
||
"audit_original_patient": patient,
|
||
"audit_ai_feedback": record.get("audit_ai_feedback", ""),
|
||
"audit_ai_raw_output": record.get("audit_ai_raw_output", ""),
|
||
"audit_manual_feedback": record.get("audit_manual_feedback", ""),
|
||
"audit_machine_verdict": record.get("audit_machine_verdict", ""),
|
||
"audit_source": record.get("audit_source", ""),
|
||
"audit_checked_by": record.get("audit_checked_by", ""),
|
||
"audit_checked_at": str(record.get("audit_checked_at") or ""),
|
||
"audit_change_summary": record.get("audit_change_summary", ""),
|
||
}
|
||
|
||
|
||
def public_record(record: dict[str, Any]) -> dict[str, Any]:
|
||
image = record.get("图片信息", {})
|
||
patient = record.get("患者信息", {})
|
||
image_path = normalize_text(image.get("图片路径"))
|
||
row_no = int(image.get("图片内行号") or 0)
|
||
return {
|
||
"key": item_key(image_path, row_no),
|
||
"batch_name": record.get("处理批次", ""),
|
||
"major_department": record.get("大科室", ""),
|
||
"sub_department": record.get("子科室", ""),
|
||
"source_folder": record.get("来源文件夹", ""),
|
||
"image_path": image_path,
|
||
"image_name": image.get("图片名", ""),
|
||
"image_row_no": row_no,
|
||
"patient": {column: patient.get(column, "") for column in COLUMNS},
|
||
"audit_original_patient": {column: patient.get(column, "") for column in COLUMNS},
|
||
"audit_result": record.get("audit_result", ""),
|
||
"audit_ai_feedback": record.get("audit_ai_feedback", ""),
|
||
"audit_ai_raw_output": record.get("audit_ai_raw_output", ""),
|
||
"audit_manual_feedback": record.get("audit_manual_feedback", ""),
|
||
"audit_machine_verdict": record.get("audit_machine_verdict", ""),
|
||
"audit_source": record.get("audit_source", ""),
|
||
"audit_checked_by": record.get("audit_checked_by", ""),
|
||
"audit_checked_at": str(record.get("audit_checked_at") or ""),
|
||
"audit_change_summary": record.get("audit_change_summary", ""),
|
||
}
|
||
|
||
|
||
def db_row_to_public(row: dict[str, Any]) -> dict[str, Any]:
|
||
patient = {
|
||
"姓名": row.get("patient_name", ""),
|
||
"性别": row.get("gender", ""),
|
||
"年龄": row.get("age", ""),
|
||
"住院号": row.get("inpatient_no", ""),
|
||
"诊断": row.get("diagnosis", ""),
|
||
"入院时间": row.get("admission_time", ""),
|
||
"最后书写时间": row.get("last_write_time", ""),
|
||
"住院天数": "" if row.get("hospital_days") is None else str(row.get("hospital_days")),
|
||
"出院时间": row.get("discharge_time", ""),
|
||
"手术后天数": row.get("postoperative_days", ""),
|
||
}
|
||
image_path = normalize_text(row.get("image_path"))
|
||
row_no = int(row.get("image_row_no") or 0)
|
||
return {
|
||
"key": item_key(image_path, row_no),
|
||
"record_id": row.get("record_id"),
|
||
"batch_name": row.get("batch_name", ""),
|
||
"major_department": row.get("major_department", ""),
|
||
"sub_department": row.get("sub_department", ""),
|
||
"source_folder": row.get("source_folder", ""),
|
||
"image_path": image_path,
|
||
"image_name": row.get("image_name", ""),
|
||
"image_row_no": row_no,
|
||
"patient": patient,
|
||
"audit_original_patient": row.get("audit_patient_before") or patient,
|
||
"review_status": row.get("review_status", ""),
|
||
"audit_result": row.get("audit_result", ""),
|
||
"audit_ai_feedback": row.get("audit_ai_feedback", ""),
|
||
"audit_ai_raw_output": row.get("audit_ai_raw_output", ""),
|
||
"audit_manual_feedback": row.get("audit_manual_feedback", ""),
|
||
"audit_machine_verdict": row.get("audit_machine_verdict", ""),
|
||
"audit_source": row.get("audit_source", ""),
|
||
"audit_checked_by": row.get("audit_checked_by", ""),
|
||
"audit_checked_at": str(row.get("audit_checked_at") or ""),
|
||
"audit_change_summary": row.get("audit_change_summary", ""),
|
||
}
|
||
|
||
|
||
def all_patient_records() -> list[dict[str, Any]]:
|
||
if not MERGED_RESULT_PATH.exists():
|
||
return []
|
||
merged = load_json(MERGED_RESULT_PATH, {})
|
||
return [
|
||
record
|
||
for record in merged.get("患者记录", [])
|
||
if record.get("图片信息", {}).get("图片路径") and record_has_valid_inpatient_no(record)
|
||
]
|
||
|
||
|
||
def record_lookup_key(record: dict[str, Any]) -> str:
|
||
image = record.get("图片信息", {})
|
||
image_path = normalize_text(image.get("图片路径"))
|
||
row_no = int(image.get("图片内行号") or 0)
|
||
return item_key(image_path, row_no) if image_path and row_no else ""
|
||
|
||
|
||
def storage_patient(patient: dict[str, Any]) -> dict[str, Any]:
|
||
normalized = normalize_patient(patient)
|
||
stored: dict[str, Any] = {column: normalized.get(column, "") for column in COLUMNS}
|
||
if stored["住院天数"].isdigit():
|
||
stored["住院天数"] = int(stored["住院天数"])
|
||
return stored
|
||
|
||
|
||
def merged_csv_row(record: dict[str, Any]) -> dict[str, Any]:
|
||
image = record.get("图片信息", {})
|
||
patient = normalize_patient(record.get("患者信息", {}))
|
||
review = record.get("复核", {})
|
||
tips = review.get("提示", [])
|
||
if not isinstance(tips, list):
|
||
tips = [tips] if normalize_text(tips) else []
|
||
return {
|
||
"处理批次": record.get("处理批次", ""),
|
||
"大科室": record.get("大科室", ""),
|
||
"子科室": record.get("子科室", ""),
|
||
"来源文件夹": record.get("来源文件夹", ""),
|
||
"图片路径": image.get("图片路径", ""),
|
||
"图片名": image.get("图片名", ""),
|
||
"图片内行号": image.get("图片内行号", ""),
|
||
"拼接组序号": image.get("拼接组序号", ""),
|
||
"OCR请求ID": image.get("OCR请求ID", ""),
|
||
"姓名": patient["姓名"],
|
||
"性别": patient["性别"],
|
||
"年龄": patient["年龄"],
|
||
"住院号": patient["住院号"],
|
||
"诊断": patient["诊断"],
|
||
"入院时间": patient["入院时间"],
|
||
"最后书写时间": patient["最后书写时间"],
|
||
"住院天数": patient["住院天数"],
|
||
"出院时间": patient["出院时间"],
|
||
"手术后天数": patient["手术后天数"],
|
||
"复核状态": review.get("状态", ""),
|
||
"复核提示": ";".join(normalize_text(tip) for tip in tips if normalize_text(tip)),
|
||
"人工修正": bool(review.get("人工修正")),
|
||
}
|
||
|
||
|
||
def write_merged_csv(records: list[dict[str, Any]]) -> None:
|
||
fields = [
|
||
"处理批次",
|
||
"大科室",
|
||
"子科室",
|
||
"来源文件夹",
|
||
"图片路径",
|
||
"图片名",
|
||
"图片内行号",
|
||
"拼接组序号",
|
||
"OCR请求ID",
|
||
"姓名",
|
||
"性别",
|
||
"年龄",
|
||
"住院号",
|
||
"诊断",
|
||
"入院时间",
|
||
"最后书写时间",
|
||
"住院天数",
|
||
"出院时间",
|
||
"手术后天数",
|
||
"复核状态",
|
||
"复核提示",
|
||
"人工修正",
|
||
]
|
||
MERGED_CSV_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||
temp_path = MERGED_CSV_PATH.with_suffix(MERGED_CSV_PATH.suffix + ".tmp")
|
||
with temp_path.open("w", encoding="utf-8-sig", newline="") as handle:
|
||
writer = csv.DictWriter(handle, fieldnames=fields)
|
||
writer.writeheader()
|
||
for record in records:
|
||
writer.writerow(merged_csv_row(record))
|
||
temp_path.replace(MERGED_CSV_PATH)
|
||
|
||
|
||
def commit_manual_passed_records() -> dict[str, Any]:
|
||
corrections = load_json(CORRECTIONS_PATH, [])
|
||
review_items = {item.key: item for item in build_review_items()}
|
||
merged = load_json(MERGED_RESULT_PATH, {"汇总": {}, "患者记录": []})
|
||
records = merged.get("患者记录", [])
|
||
record_index = {record_lookup_key(record): record for record in records if record_lookup_key(record)}
|
||
summary = {
|
||
"eligible": 0,
|
||
"merged_updated": 0,
|
||
"merged_already_current": 0,
|
||
"merged_missing": 0,
|
||
"postgres_success": 0,
|
||
"postgres_already_success": 0,
|
||
"postgres_not_found": 0,
|
||
"postgres_failed": 0,
|
||
"skipped": 0,
|
||
"skipped_missing_key": 0,
|
||
"skipped_ai": 0,
|
||
"skipped_archived": 0,
|
||
"skipped_issue": 0,
|
||
"archived_corrections": 0,
|
||
"items": [],
|
||
}
|
||
changed_merged = False
|
||
changed_corrections = False
|
||
archived_now: set[str] = set()
|
||
|
||
for correction in corrections:
|
||
key = item_key(normalize_text(correction.get("图片路径")), int(correction.get("图片内行号") or 0))
|
||
if not key:
|
||
summary["skipped"] += 1
|
||
summary["skipped_missing_key"] += 1
|
||
continue
|
||
if correction.get("已提交人工通过"):
|
||
summary["skipped"] += 1
|
||
summary["skipped_archived"] += 1
|
||
continue
|
||
if correction.get("AI修改"):
|
||
summary["skipped"] += 1
|
||
summary["skipped_ai"] += 1
|
||
continue
|
||
patient = normalize_patient(correction.get("患者信息", {}))
|
||
warnings = validate_patient(patient, correction.get("复核选项", {}))
|
||
if warnings:
|
||
summary["skipped"] += 1
|
||
summary["skipped_issue"] += 1
|
||
continue
|
||
summary["eligible"] += 1
|
||
|
||
merged_record = record_index.get(key)
|
||
if merged_record:
|
||
stored_patient = storage_patient(patient)
|
||
review = merged_record.setdefault("复核", {})
|
||
manual_note = normalize_text(correction.get("复核备注", ""))
|
||
desired_review = {
|
||
"状态": "人工复核通过",
|
||
"提示": [],
|
||
"人工修正": True,
|
||
}
|
||
if manual_note:
|
||
desired_review["人工备注"] = manual_note
|
||
needs_update = (
|
||
merged_record.get("患者信息") != stored_patient
|
||
or any(review.get(key) != value for key, value in desired_review.items())
|
||
)
|
||
if needs_update:
|
||
merged_record["患者信息"] = stored_patient
|
||
review.update(desired_review)
|
||
review["人工复核提交时间"] = dt.datetime.now().isoformat(timespec="seconds")
|
||
summary["merged_updated"] += 1
|
||
changed_merged = True
|
||
else:
|
||
summary["merged_already_current"] += 1
|
||
else:
|
||
summary["merged_missing"] += 1
|
||
|
||
sync_status = correction.get("PostgreSQL同步", {})
|
||
if sync_status.get("状态") == "成功":
|
||
summary["postgres_already_success"] += 1
|
||
summary["items"].append({"key": key, "status": "already_success"})
|
||
correction["已提交人工通过"] = True
|
||
correction["提交归档时间"] = dt.datetime.now().isoformat(timespec="seconds")
|
||
archived_now.add(key)
|
||
changed_corrections = True
|
||
continue
|
||
|
||
target = review_items.get(key)
|
||
if target is None:
|
||
summary["postgres_failed"] += 1
|
||
summary["items"].append({"key": key, "status": "failed", "message": "未找到本地复核条目"})
|
||
correction["已提交人工通过"] = True
|
||
correction["提交归档时间"] = dt.datetime.now().isoformat(timespec="seconds")
|
||
archived_now.add(key)
|
||
changed_corrections = True
|
||
continue
|
||
result = update_postgres_record(
|
||
target.record,
|
||
patient,
|
||
correction.get("复核选项", {}),
|
||
[],
|
||
normalize_text(correction.get("复核备注", "")),
|
||
True,
|
||
)
|
||
correction["PostgreSQL同步"] = sync_status_from_result(result)
|
||
changed_corrections = True
|
||
if result.get("error"):
|
||
summary["postgres_failed"] += 1
|
||
item_status = "failed"
|
||
elif result.get("updated", 0) > 0:
|
||
summary["postgres_success"] += 1
|
||
item_status = "success"
|
||
else:
|
||
summary["postgres_not_found"] += 1
|
||
item_status = "not_found"
|
||
summary["items"].append({"key": key, "status": item_status, "postgres": result})
|
||
correction["已提交人工通过"] = True
|
||
correction["提交归档时间"] = dt.datetime.now().isoformat(timespec="seconds")
|
||
archived_now.add(key)
|
||
changed_corrections = True
|
||
|
||
if changed_merged:
|
||
merged.setdefault("汇总", {})["人工复核提交时间"] = dt.datetime.now().isoformat(timespec="seconds")
|
||
atomic_write_json(MERGED_RESULT_PATH, merged)
|
||
write_merged_csv(records)
|
||
summary["archived_corrections"] = len(archived_now)
|
||
if changed_corrections:
|
||
atomic_write_json(CORRECTIONS_PATH, corrections)
|
||
RESULT_INFO_DIR.mkdir(parents=True, exist_ok=True)
|
||
report_path = RESULT_INFO_DIR / "提交已人工通过数据报告.json"
|
||
atomic_write_json(report_path, {**summary, "生成时间": dt.datetime.now().isoformat(timespec="seconds")})
|
||
summary["report_path"] = str(report_path.relative_to(WORKSPACE_ROOT))
|
||
summary["postgres"] = db_status()
|
||
return summary
|
||
|
||
|
||
def audit_public_by_key(key: str) -> dict[str, Any] | None:
|
||
if key in archived_correction_keys():
|
||
return None
|
||
for item in build_review_items():
|
||
public = public_item(item, correction_index().get(item.key))
|
||
if public["key"] == key and has_valid_inpatient_no(public["patient"]):
|
||
return public
|
||
for record in all_patient_records():
|
||
public = public_record(record)
|
||
if public["key"] == key:
|
||
return public
|
||
if db_configured():
|
||
try:
|
||
with postgres_connection() as conn:
|
||
with conn.cursor(row_factory=dict_row) as cur:
|
||
cur.execute(
|
||
"""
|
||
SELECT * FROM "Patient_Lists"
|
||
WHERE encode(sha1((image_path || '|' || image_row_no::text)::bytea), 'hex') LIKE %(prefix)s
|
||
LIMIT 1
|
||
""",
|
||
{"prefix": key + "%"},
|
||
)
|
||
row = cur.fetchone()
|
||
return db_row_to_public(row) if row else None
|
||
except Exception:
|
||
return None
|
||
return None
|
||
|
||
|
||
def kimi_config() -> dict[str, Any]:
|
||
config = load_config().get("kimi", {})
|
||
enabled_value = config.get("enabled", os.getenv("KIMI_API_ENABLED", "1"))
|
||
enabled = str(enabled_value).lower() not in {"0", "false", "no", "off"}
|
||
return {
|
||
"api_key": config.get("api_key") or os.getenv("KIMI_API_KEY") or os.getenv("MOONSHOT_API_KEY") or "",
|
||
"model": config.get("model") or os.getenv("KIMI_MODEL") or "kimi-k2.6",
|
||
"enabled": enabled,
|
||
}
|
||
|
||
|
||
def kimi_audit_prompt(public: dict[str, Any]) -> str:
|
||
return (
|
||
"你是HIS患者列表OCR抽查助手。请核对截图中目标患者所在行与给定结构化字段是否一致;目标行通常位于裁剪区域中部附近。"
|
||
"重点核对姓名、性别、年龄、住院号、诊断、入院时间、最后书写时间、住院天数、出院时间、手术后天数。"
|
||
"患者出院后医生可能继续补充病历,所以最后书写时间晚于出院时间不算异常,不要把它列为问题。"
|
||
"日期时间只比较真实时间值,不比较补零格式;例如2021-11-12 7:39:05与2021-11-12 07:39:05完全一致,必须判定通过,不要列为异常,也不要建议改成未补零格式。"
|
||
"只返回一个JSON对象,不要解释、不要Markdown表格、不要代码块外文本。"
|
||
"JSON格式为:{\"结论\":\"通过/异常/不确定\",\"问题\":[...],\"建议修正\":{...}}。"
|
||
f"结构化字段:{json.dumps(public['patient'], ensure_ascii=False)}"
|
||
)
|
||
|
||
|
||
def equivalent_field_value(field: str, left: Any, right: Any) -> bool:
|
||
left_text = normalize_text(left)
|
||
right_text = normalize_text(right)
|
||
if left_text == right_text:
|
||
return True
|
||
if field in DATETIME_COLUMNS:
|
||
return normalize_datetime_text(left_text) == normalize_datetime_text(right_text)
|
||
return False
|
||
|
||
|
||
def clean_audit_model_json(data: dict[str, Any], public: dict[str, Any] | None = None) -> dict[str, Any]:
|
||
cleaned = dict(data)
|
||
patient = normalize_patient((public or {}).get("patient", {}))
|
||
suggestions = cleaned.get("建议修正")
|
||
if isinstance(suggestions, dict):
|
||
kept_suggestions = {}
|
||
for field, value in suggestions.items():
|
||
normalized_field = normalize_text(field)
|
||
if normalized_field in COLUMNS and equivalent_field_value(normalized_field, value, patient.get(normalized_field, "")):
|
||
continue
|
||
kept_suggestions[field] = value
|
||
cleaned["建议修正"] = kept_suggestions
|
||
issues = cleaned.get("问题")
|
||
if isinstance(issues, list):
|
||
kept = []
|
||
removed = []
|
||
for issue in issues:
|
||
text = normalize_text(issue)
|
||
if any(pattern in text for pattern in IGNORED_AI_ISSUE_PATTERNS) or any(pattern in text for pattern in FORMAT_ONLY_AI_ISSUE_PATTERNS):
|
||
removed.append(text)
|
||
else:
|
||
kept.append(issue)
|
||
cleaned["问题"] = kept
|
||
suggestions = cleaned.get("建议修正")
|
||
if not cleaned.get("问题") and (not isinstance(suggestions, dict) or not suggestions):
|
||
cleaned["结论"] = "通过"
|
||
return cleaned
|
||
|
||
|
||
def parse_model_verdict(text: str) -> str:
|
||
cleaned = normalize_text(text)
|
||
if cleaned.startswith("```"):
|
||
cleaned = re.sub(r"^```(?:json)?\s*", "", cleaned)
|
||
cleaned = re.sub(r"\s*```$", "", cleaned)
|
||
try:
|
||
data = json.loads(cleaned)
|
||
except Exception:
|
||
match = re.search(r"\{.*\}", cleaned, flags=re.S)
|
||
if not match:
|
||
return ""
|
||
try:
|
||
data = json.loads(match.group(0))
|
||
except Exception:
|
||
return ""
|
||
verdict = normalize_text(data.get("结论") or data.get("verdict") or data.get("result"))
|
||
return verdict if verdict in {"通过", "异常", "不确定"} else verdict
|
||
|
||
|
||
def call_kimi_vision(public: dict[str, Any]) -> dict[str, str]:
|
||
config = kimi_config()
|
||
if not config["enabled"]:
|
||
raise RuntimeError("目前无AI功能")
|
||
if not config["api_key"]:
|
||
raise RuntimeError("未设置 AI API Key,请在设置页配置")
|
||
path = safe_workspace_path(public["image_path"])
|
||
cropped = crop_image(path, int(public["image_row_no"] or 1), 2)
|
||
image_base64 = image_to_base64(cropped)
|
||
prompt = kimi_audit_prompt(public)
|
||
payload = {
|
||
"model": config["model"],
|
||
"temperature": 0.6,
|
||
"max_tokens": KIMI_AUDIT_MAX_TOKENS,
|
||
"thinking": {"type": "disabled"},
|
||
"messages": [
|
||
{
|
||
"role": "user",
|
||
"content": [
|
||
{"type": "image_url", "image_url": {"url": f"data:image/png;base64,{image_base64}"}},
|
||
{"type": "text", "text": prompt},
|
||
],
|
||
}
|
||
],
|
||
}
|
||
request = urllib.request.Request(
|
||
"https://api.moonshot.cn/v1/chat/completions",
|
||
data=json.dumps(payload, ensure_ascii=False).encode("utf-8"),
|
||
headers={"Authorization": f"Bearer {config['api_key']}", "Content-Type": "application/json"},
|
||
method="POST",
|
||
)
|
||
try:
|
||
with urllib.request.urlopen(request, timeout=KIMI_TIMEOUT_SECONDS) 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 RuntimeError(detail or str(exc)) from exc
|
||
except (TimeoutError, socket.timeout) as exc:
|
||
raise RuntimeError(f"AI接口超时({KIMI_TIMEOUT_SECONDS}秒),请稍后重试或减少批量数量") from exc
|
||
except urllib.error.URLError as exc:
|
||
raise RuntimeError(f"AI接口连接失败:{exc.reason}") from exc
|
||
result = data.get("choices", [{}])[0].get("message", {}).get("content", "")
|
||
parsed = parse_json_object(result)
|
||
if parsed:
|
||
parsed = clean_audit_model_json(parsed, public)
|
||
clean_result = json.dumps(parsed, ensure_ascii=False, indent=2) if parsed else ""
|
||
verdict_source = clean_result or result
|
||
return {
|
||
"prompt": prompt,
|
||
"result": clean_result,
|
||
"raw_result": result,
|
||
"machine_verdict": parse_model_verdict(verdict_source),
|
||
}
|
||
|
||
|
||
def parse_json_object(text: str) -> dict[str, Any]:
|
||
cleaned = normalize_text(text)
|
||
fenced = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", text or "", flags=re.S | re.I)
|
||
candidates = [fenced.group(1)] if fenced else [cleaned]
|
||
decoder = json.JSONDecoder()
|
||
for start, char in enumerate(cleaned):
|
||
if char == "{":
|
||
candidates.append(cleaned[start:])
|
||
for candidate in candidates:
|
||
try:
|
||
data = json.loads(candidate)
|
||
return data if isinstance(data, dict) else {}
|
||
except Exception:
|
||
try:
|
||
data, _ = decoder.raw_decode(candidate)
|
||
return data if isinstance(data, dict) else {}
|
||
except Exception:
|
||
continue
|
||
return {}
|
||
|
||
|
||
def call_kimi_correction(public: dict[str, Any]) -> dict[str, Any]:
|
||
config = kimi_config()
|
||
if not config["enabled"]:
|
||
raise RuntimeError("目前无AI功能")
|
||
if not config["api_key"]:
|
||
raise RuntimeError("未设置 AI API Key,请在设置页配置")
|
||
path = safe_workspace_path(public["image_path"])
|
||
cropped = crop_image(path, int(public["image_row_no"] or 1), 2)
|
||
image_base64 = image_to_base64(cropped)
|
||
prompt = (
|
||
"你是HIS患者列表OCR修正助手。请根据截图中目标患者所在行修正给定结构化字段;目标行通常位于裁剪区域中部附近。"
|
||
"字段只能包含:姓名、性别、年龄、住院号、诊断、入院时间、最后书写时间、住院天数、出院时间、手术后天数。"
|
||
"时间格式尽量输出YYYY-MM-DD HH:MM:SS;出院时间可以为空;手术后天数为空或形如后X天。"
|
||
"请只返回JSON:{\"患者信息\":{...},\"说明\":\"...\"}。"
|
||
f"当前字段:{json.dumps(public['patient'], ensure_ascii=False)}"
|
||
)
|
||
payload = {
|
||
"model": config["model"],
|
||
"temperature": 0.6,
|
||
"max_tokens": KIMI_CORRECTION_MAX_TOKENS,
|
||
"thinking": {"type": "disabled"},
|
||
"messages": [
|
||
{
|
||
"role": "user",
|
||
"content": [
|
||
{"type": "image_url", "image_url": {"url": f"data:image/png;base64,{image_base64}"}},
|
||
{"type": "text", "text": prompt},
|
||
],
|
||
}
|
||
],
|
||
}
|
||
request = urllib.request.Request(
|
||
"https://api.moonshot.cn/v1/chat/completions",
|
||
data=json.dumps(payload, ensure_ascii=False).encode("utf-8"),
|
||
headers={"Authorization": f"Bearer {config['api_key']}", "Content-Type": "application/json"},
|
||
method="POST",
|
||
)
|
||
try:
|
||
with urllib.request.urlopen(request, timeout=KIMI_TIMEOUT_SECONDS) 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 RuntimeError(detail or str(exc)) from exc
|
||
except (TimeoutError, socket.timeout) as exc:
|
||
raise RuntimeError(f"AI接口超时({KIMI_TIMEOUT_SECONDS}秒),请稍后重试或减少批量数量") from exc
|
||
except urllib.error.URLError as exc:
|
||
raise RuntimeError(f"AI接口连接失败:{exc.reason}") from exc
|
||
result = data.get("choices", [{}])[0].get("message", {}).get("content", "")
|
||
parsed = parse_json_object(result)
|
||
patient = parsed.get("患者信息", parsed)
|
||
if not isinstance(patient, dict):
|
||
patient = {}
|
||
corrected = dict(public.get("patient", {}))
|
||
corrected.update({column: patient.get(column, corrected.get(column, "")) for column in COLUMNS})
|
||
return {"prompt": prompt, "result": result, "patient": normalize_patient(corrected), "note": normalize_text(parsed.get("说明", ""))}
|
||
|
||
|
||
def filtered_items() -> list[dict[str, Any]]:
|
||
corrections = correction_index()
|
||
archived = archived_correction_keys()
|
||
batch = normalize_text(request.args.get("batch"))
|
||
status = normalize_text(request.args.get("status") or "pending")
|
||
sort = normalize_text(request.args.get("sort") or "source")
|
||
query = normalize_text(request.args.get("q")).lower()
|
||
items = []
|
||
for item in build_review_items():
|
||
if item.key in archived:
|
||
continue
|
||
public = public_item(item, corrections.get(item.key))
|
||
if not has_valid_inpatient_no(public["patient"]):
|
||
continue
|
||
if batch and public["batch_name"] != batch:
|
||
continue
|
||
if status == "pending" and public["manual_state"] != "待处理":
|
||
continue
|
||
if status == "done" and public["manual_state"] != MANUAL_PASSED_STATE:
|
||
continue
|
||
if status == "still_issue" and public["manual_state"] != STILL_CONFIRM_STATE:
|
||
continue
|
||
if status == "confirming" and public["manual_state"] not in {STILL_CONFIRM_STATE, AI_PENDING_STATE}:
|
||
continue
|
||
if status == "ai_pending" and public["manual_state"] != AI_PENDING_STATE:
|
||
continue
|
||
if query:
|
||
haystack = json.dumps(public, ensure_ascii=False).lower()
|
||
if query not in haystack:
|
||
continue
|
||
items.append(public)
|
||
if sort == "updated_desc":
|
||
items.sort(key=lambda item: (bool(item.get("updated_at")), item.get("updated_at") or ""), reverse=True)
|
||
return items
|
||
|
||
|
||
def safe_workspace_path(path_text: str) -> Path:
|
||
raw = Path(path_text)
|
||
path = raw if raw.is_absolute() else WORKSPACE_ROOT / raw
|
||
resolved = path.resolve()
|
||
if WORKSPACE_ROOT not in resolved.parents and resolved != WORKSPACE_ROOT:
|
||
abort(403)
|
||
if not resolved.exists():
|
||
abort(404)
|
||
return resolved
|
||
|
||
|
||
def crop_image(path: Path, row_no: int, context_rows: int = 2) -> Image.Image:
|
||
if path.suffix.lower() not in IMAGE_EXTENSIONS:
|
||
abort(404)
|
||
with Image.open(path) as image:
|
||
source = image.convert("RGB")
|
||
width, height = source.size
|
||
inferred_rows = max(1, int(row_no or 1), round(height / 39.0))
|
||
row_height = max(1.0, height / inferred_rows)
|
||
row_index = min(inferred_rows - 1, max(0, int(row_no or 1) - 1))
|
||
target_top = int(row_index * row_height)
|
||
target_bottom = int(min(height, (row_index + 1) * row_height))
|
||
top = int(max(0, target_top - row_height * context_rows))
|
||
bottom = int(min(height, target_bottom + row_height * context_rows))
|
||
if bottom <= top:
|
||
top = max(0, min(height - 1, target_top))
|
||
bottom = min(height, top + max(1, int(row_height * max(1, context_rows + 1))))
|
||
return source.crop((0, top, width, bottom))
|
||
|
||
|
||
def image_to_base64(image: Image.Image) -> str:
|
||
source = image.convert("RGB")
|
||
if source.width > KIMI_IMAGE_MAX_WIDTH:
|
||
ratio = KIMI_IMAGE_MAX_WIDTH / source.width
|
||
next_size = (KIMI_IMAGE_MAX_WIDTH, max(1, round(source.height * ratio)))
|
||
source = source.resize(next_size, Image.Resampling.LANCZOS)
|
||
buffer = io.BytesIO()
|
||
source.save(buffer, format="PNG", optimize=True)
|
||
return base64.b64encode(buffer.getvalue()).decode("ascii")
|
||
|
||
|
||
def image_crop_response(path: Path, row_no: int, context_rows: int = 2) -> Response:
|
||
cropped = crop_image(path, row_no, context_rows)
|
||
buffer = io.BytesIO()
|
||
cropped.save(buffer, format="PNG")
|
||
buffer.seek(0)
|
||
return send_file(buffer, mimetype="image/png", max_age=0)
|
||
|
||
|
||
@app.route("/")
|
||
def index() -> str:
|
||
return render_template("index.html")
|
||
|
||
|
||
@app.route("/favicon.ico")
|
||
def favicon() -> Response:
|
||
return Response(status=204)
|
||
|
||
|
||
@app.route("/login", methods=["GET", "POST"])
|
||
def login() -> str | Response:
|
||
if request.method == "POST":
|
||
username = request.form.get("username", "")
|
||
password = request.form.get("password", "")
|
||
expected_user = os.getenv("REVIEW_APP_USERNAME", "admin")
|
||
expected_password = os.getenv("REVIEW_APP_PASSWORD") or os.getenv("HIS_DB_PASSWORD")
|
||
if expected_password and secrets.compare_digest(username, expected_user) and secrets.compare_digest(password, expected_password):
|
||
session["authenticated"] = True
|
||
session["username"] = username
|
||
session["permissions"] = dict(FULL_PERMISSIONS)
|
||
return redirect(url_for("index"))
|
||
for user in load_config().get("users", []):
|
||
if secrets.compare_digest(username, str(user.get("username", ""))) and password_matches(password, user):
|
||
session["authenticated"] = True
|
||
session["username"] = username
|
||
session["permissions"] = normalized_permissions(user.get("permissions"))
|
||
return redirect(url_for("index"))
|
||
return render_template("login.html", error="登录失败"), 401
|
||
return render_template("login.html", error="")
|
||
|
||
|
||
@app.route("/logout", methods=["POST"])
|
||
def logout() -> Response:
|
||
session.clear()
|
||
return redirect(url_for("login"))
|
||
|
||
|
||
@app.route("/api/session")
|
||
def api_session() -> Response:
|
||
require_login()
|
||
return json_response({
|
||
"username": session.get("username", ""),
|
||
"permissions": current_permissions(),
|
||
"permission_labels": PERMISSION_LABELS,
|
||
"kimi_enabled": kimi_config()["enabled"],
|
||
})
|
||
|
||
|
||
@app.route("/api/summary")
|
||
def api_summary() -> Response:
|
||
require_login()
|
||
corrections = correction_index()
|
||
archived = archived_correction_keys()
|
||
items = [
|
||
public
|
||
for item in build_review_items()
|
||
if item.key not in archived
|
||
for public in [public_item(item, corrections.get(item.key))]
|
||
if has_valid_inpatient_no(public["patient"])
|
||
]
|
||
batches: dict[str, dict[str, Any]] = {}
|
||
state_counts = {"待处理": 0, STILL_CONFIRM_STATE: 0, MANUAL_PASSED_STATE: 0, AI_PENDING_STATE: 0}
|
||
for item in items:
|
||
state_counts[item["manual_state"]] = state_counts.get(item["manual_state"], 0) + 1
|
||
batch = batches.setdefault(
|
||
item["batch_name"],
|
||
{"batch_name": item["batch_name"], "total": 0, "pending": 0, "ai_pending": 0, "still_issue": 0, "done": 0},
|
||
)
|
||
batch["total"] += 1
|
||
if item["manual_state"] == MANUAL_PASSED_STATE:
|
||
batch["done"] += 1
|
||
elif item["manual_state"] == AI_PENDING_STATE:
|
||
batch["ai_pending"] += 1
|
||
batch["still_issue"] += 1
|
||
elif item["manual_state"] == STILL_CONFIRM_STATE:
|
||
batch["still_issue"] += 1
|
||
else:
|
||
batch["pending"] += 1
|
||
state_counts["待确认"] = state_counts[AI_PENDING_STATE] + state_counts[STILL_CONFIRM_STATE]
|
||
return json_response(
|
||
{
|
||
"total": len(items),
|
||
"state_counts": state_counts,
|
||
"batches": sorted(batches.values(), key=lambda value: value["batch_name"]),
|
||
"corrections_path": str(CORRECTIONS_PATH),
|
||
"postgres": db_status(),
|
||
"audit_summary": audit_overview_summary(),
|
||
"permissions": current_permissions(),
|
||
}
|
||
)
|
||
|
||
|
||
@app.route("/api/processing-items")
|
||
def api_processing_items() -> Response:
|
||
require_login()
|
||
return json_response(processing_items_overview())
|
||
|
||
|
||
@app.route("/api/items")
|
||
def api_items() -> Response:
|
||
require_permission("review")
|
||
page = max(1, int(request.args.get("page", 1)))
|
||
page_size = min(100, max(10, int(request.args.get("page_size", 40))))
|
||
items = filtered_items()
|
||
start = (page - 1) * page_size
|
||
return json_response({"items": items[start : start + page_size], "total": len(items), "page": page, "page_size": page_size})
|
||
|
||
|
||
@app.route("/api/items/<key>")
|
||
def api_item(key: str) -> Response:
|
||
require_permission("review")
|
||
if key in archived_correction_keys():
|
||
abort(404)
|
||
corrections = correction_index()
|
||
for item in build_review_items():
|
||
if item.key == key:
|
||
public = public_item(item, corrections.get(key))
|
||
if not has_valid_inpatient_no(public["patient"]):
|
||
abort(404)
|
||
return json_response(public)
|
||
abort(404)
|
||
|
||
|
||
@app.route("/api/items/<key>/validate", methods=["POST"])
|
||
def api_validate(key: str) -> Response:
|
||
require_permission("review")
|
||
payload = request.get_json(force=True)
|
||
patient = normalize_patient(payload.get("patient", {}))
|
||
options = payload.get("options", {})
|
||
return json_response({"warnings": validate_patient(patient, options), "patient": patient})
|
||
|
||
|
||
@app.route("/api/items/<key>/correction", methods=["POST", "DELETE"])
|
||
def api_correction(key: str) -> Response:
|
||
require_permission("review")
|
||
corrections = correction_index()
|
||
target = None
|
||
for item in build_review_items():
|
||
if item.key == key:
|
||
target = item
|
||
break
|
||
if target is None:
|
||
abort(404)
|
||
if request.method == "DELETE":
|
||
original_patient = normalize_patient(target.record.get("患者信息", {}))
|
||
original_warnings = validate_patient(original_patient, {})
|
||
pg_sync = update_postgres_record(target.record, original_patient, {}, original_warnings, "", False)
|
||
delete_correction(key)
|
||
return json_response({"ok": True, "postgres": pg_sync})
|
||
payload = request.get_json(force=True)
|
||
patient = normalize_patient(payload.get("patient", {}))
|
||
options = {}
|
||
warnings = validate_patient(patient, options)
|
||
if not has_valid_inpatient_no(patient):
|
||
return error_response("缺少住院号,不能保存到复核表格。", status=400)
|
||
previous_correction = corrections.get(key)
|
||
previous_patient = merged_patient(target.record, previous_correction)
|
||
change_log = fallback_change_log(target.record, previous_correction)
|
||
change_log.extend(change_log_entries(previous_patient, patient, "人工"))
|
||
image = target.record.get("图片信息", {})
|
||
correction = {
|
||
"图片路径": image.get("图片路径", ""),
|
||
"图片内行号": int(image.get("图片内行号") or 0),
|
||
"患者信息": patient,
|
||
"复核选项": options,
|
||
"复核备注": normalize_text(payload.get("manual_note", "")),
|
||
"修改来源": "人工修改",
|
||
"修改记录": change_log,
|
||
"更新时间": dt.datetime.now().isoformat(timespec="seconds"),
|
||
}
|
||
pg_sync = update_postgres_record(target.record, patient, options, warnings, correction["复核备注"], True)
|
||
correction["PostgreSQL同步"] = sync_status_from_result(pg_sync)
|
||
save_correction(correction)
|
||
return json_response({"ok": True, "warnings": warnings, "item": public_item(target, correction), "postgres": pg_sync})
|
||
|
||
|
||
@app.route("/api/items/<key>/kimi-correction", methods=["POST"])
|
||
def api_item_kimi_correction(key: str) -> Response:
|
||
require_permission("review")
|
||
corrections = correction_index()
|
||
target = None
|
||
for item in build_review_items():
|
||
if item.key == key:
|
||
target = item
|
||
break
|
||
if target is None:
|
||
abort(404)
|
||
public = public_item(target, corrections.get(key))
|
||
try:
|
||
result = call_kimi_correction(public)
|
||
except RuntimeError as exc:
|
||
return error_response(str(exc), status=502)
|
||
except Exception as exc:
|
||
return error_response(f"AI修改失败:{exc}", status=500)
|
||
if not has_valid_inpatient_no(result["patient"]):
|
||
return error_response("AI结果缺少有效住院号,已拒绝写入复核表格。", status=422)
|
||
previous_correction = corrections.get(key)
|
||
previous_patient = public.get("patient", {})
|
||
change_log = fallback_change_log(target.record, previous_correction)
|
||
change_log.extend(change_log_entries(previous_patient, result["patient"], "AI"))
|
||
image = target.record.get("图片信息", {})
|
||
correction = {
|
||
"图片路径": image.get("图片路径", ""),
|
||
"图片内行号": int(image.get("图片内行号") or 0),
|
||
"患者信息": result["patient"],
|
||
"复核选项": {},
|
||
"复核备注": result["note"] or "AI修改,待人工确认",
|
||
"修改来源": "AI修改",
|
||
"修改记录": change_log,
|
||
"更新时间": dt.datetime.now().isoformat(timespec="seconds"),
|
||
"AI修改": True,
|
||
"AI反馈": result["result"],
|
||
"PostgreSQL同步": {
|
||
"状态": "AI待确认",
|
||
"更新时间": dt.datetime.now().isoformat(timespec="seconds"),
|
||
"提示": "AI修改结果需人工保存确认后再同步数据库",
|
||
},
|
||
}
|
||
save_correction(correction)
|
||
return json_response({"ok": True, "item": public_item(target, correction), "result": result})
|
||
|
||
|
||
@app.route("/api/postgres/status")
|
||
def api_postgres_status() -> Response:
|
||
require_login()
|
||
return json_response(db_status())
|
||
|
||
|
||
@app.route("/api/postgres/sync", methods=["POST"])
|
||
def api_postgres_sync() -> Response:
|
||
require_permission("review")
|
||
corrections = load_json(CORRECTIONS_PATH, [])
|
||
review_items = {item.key: item for item in build_review_items()}
|
||
summary = {"total": 0, "success": 0, "failed": 0, "not_found": 0, "skipped": 0, "items": []}
|
||
changed = False
|
||
for correction in corrections:
|
||
if correction.get("AI修改") or correction.get("已提交人工通过"):
|
||
summary["skipped"] += 1
|
||
continue
|
||
sync_status = correction.get("PostgreSQL同步", {})
|
||
if sync_status.get("状态") == "成功":
|
||
summary["skipped"] += 1
|
||
continue
|
||
key = item_key(normalize_text(correction.get("图片路径")), int(correction.get("图片内行号") or 0))
|
||
target = review_items.get(key)
|
||
if target is None:
|
||
summary["failed"] += 1
|
||
summary["items"].append({"key": key, "status": "failed", "message": "未找到本地复核条目"})
|
||
continue
|
||
patient = normalize_patient(correction.get("患者信息", {}))
|
||
if not has_valid_inpatient_no(patient):
|
||
summary["skipped"] += 1
|
||
continue
|
||
options = correction.get("复核选项", {})
|
||
manual_note = normalize_text(correction.get("复核备注", ""))
|
||
warnings = validate_patient(patient, options)
|
||
result = update_postgres_record(target.record, patient, options, warnings, manual_note, True)
|
||
correction["PostgreSQL同步"] = sync_status_from_result(result)
|
||
changed = True
|
||
summary["total"] += 1
|
||
if result.get("error"):
|
||
summary["failed"] += 1
|
||
item_status = "failed"
|
||
elif result.get("updated", 0) > 0:
|
||
summary["success"] += 1
|
||
item_status = "success"
|
||
else:
|
||
summary["not_found"] += 1
|
||
item_status = "not_found"
|
||
summary["items"].append({"key": key, "status": item_status, "postgres": result})
|
||
if changed:
|
||
atomic_write_json(CORRECTIONS_PATH, corrections)
|
||
summary["postgres"] = db_status()
|
||
return json_response(summary)
|
||
|
||
|
||
@app.route("/api/postgres/sync/drop_failed", methods=["POST"])
|
||
def api_postgres_sync_drop_failed() -> Response:
|
||
require_permission("review")
|
||
payload = request.get_json(force=True)
|
||
keys = set(str(key) for key in payload.get("keys", []) if str(key))
|
||
if not keys:
|
||
return json_response({"removed": 0})
|
||
corrections = load_json(CORRECTIONS_PATH, [])
|
||
kept = []
|
||
removed = 0
|
||
for correction in corrections:
|
||
key = item_key(normalize_text(correction.get("图片路径")), int(correction.get("图片内行号") or 0))
|
||
if key in keys:
|
||
removed += 1
|
||
continue
|
||
kept.append(correction)
|
||
if removed:
|
||
atomic_write_json(CORRECTIONS_PATH, kept)
|
||
return json_response({"removed": removed, "postgres": db_status()})
|
||
|
||
|
||
@app.route("/api/audit/sample", methods=["POST"])
|
||
def api_audit_sample() -> Response:
|
||
require_permission("audit")
|
||
payload = request.get_json(force=True)
|
||
count = min(20, max(1, int(payload.get("count", 5))))
|
||
source = normalize_text(payload.get("source") or "manual_review_passed")
|
||
if source == "manual_review_passed":
|
||
corrections = correction_index()
|
||
archived = archived_correction_keys()
|
||
candidates = [
|
||
public
|
||
for item in build_review_items()
|
||
if item.key not in archived
|
||
for public in [public_item(item, corrections.get(item.key))]
|
||
if public["manual_state"] == MANUAL_PASSED_STATE and has_valid_inpatient_no(public["patient"])
|
||
]
|
||
sampled = random.sample(candidates, min(count, len(candidates))) if candidates else []
|
||
else:
|
||
sampled = sample_db_records("db_auto_passed", count)
|
||
for item in sampled:
|
||
item["audit_source"] = source
|
||
return json_response({"items": sampled})
|
||
|
||
|
||
@app.route("/api/audit/kimi", methods=["POST"])
|
||
def api_audit_kimi() -> Response:
|
||
require_permission("audit")
|
||
payload = request.get_json(force=True)
|
||
key = normalize_text(payload.get("key"))
|
||
public = payload.get("item") if isinstance(payload.get("item"), dict) else None
|
||
if public is None:
|
||
public = audit_public_by_key(key)
|
||
if public is None:
|
||
abort(404)
|
||
try:
|
||
result = call_kimi_vision(public)
|
||
except RuntimeError as exc:
|
||
return error_response(str(exc), status=502)
|
||
except Exception as exc:
|
||
return error_response(f"AI抽查失败:{exc}", status=500)
|
||
return json_response(result)
|
||
|
||
|
||
@app.route("/api/audit/result", methods=["POST"])
|
||
def api_audit_result() -> Response:
|
||
require_permission("audit")
|
||
payload = request.get_json(force=True)
|
||
key = normalize_text(payload.get("key"))
|
||
public = payload.get("item") if isinstance(payload.get("item"), dict) else None
|
||
if public is None:
|
||
public = audit_public_by_key(key)
|
||
if public is None:
|
||
abort(404)
|
||
result = update_audit_postgres(public, payload)
|
||
return json_response({"ok": result.get("updated", 0) > 0, "postgres": result})
|
||
|
||
|
||
@app.route("/api/audit/history")
|
||
def api_audit_history() -> Response:
|
||
require_permission("audit_history")
|
||
page = max(1, int(request.args.get("page", 1)))
|
||
page_size = min(100, max(10, int(request.args.get("page_size", 40))))
|
||
source = normalize_text(request.args.get("source"))
|
||
status = normalize_text(request.args.get("status"))
|
||
sort = normalize_text(request.args.get("sort") or "checked_desc")
|
||
query = normalize_text(request.args.get("q"))
|
||
return json_response(audit_history_rows(page, page_size, source, sort, query, status))
|
||
|
||
|
||
@app.route("/api/settings")
|
||
def api_settings() -> Response:
|
||
require_permission("settings")
|
||
config = load_config()
|
||
kimi = kimi_config()
|
||
env_user = os.getenv("REVIEW_APP_USERNAME", "admin")
|
||
return json_response(
|
||
{
|
||
"permissions": current_permissions(),
|
||
"permission_labels": PERMISSION_LABELS,
|
||
"users": [
|
||
{"username": env_user, "created_at": "环境变量用户", "permissions": dict(FULL_PERMISSIONS), "builtin": True},
|
||
*[
|
||
{
|
||
"username": user.get("username"),
|
||
"created_at": user.get("created_at", ""),
|
||
"permissions": normalized_permissions(user.get("permissions")),
|
||
"builtin": False,
|
||
}
|
||
for user in config.get("users", [])
|
||
],
|
||
],
|
||
"kimi_model": kimi["model"],
|
||
"kimi_api_key_set": bool(kimi["api_key"]),
|
||
"kimi_enabled": bool(kimi["enabled"]),
|
||
}
|
||
)
|
||
|
||
|
||
@app.route("/api/settings/users", methods=["POST"])
|
||
def api_settings_users() -> Response:
|
||
require_permission("settings")
|
||
payload = request.get_json(force=True)
|
||
username = normalize_text(payload.get("username"))
|
||
password = str(payload.get("password") or "")
|
||
permissions = normalized_permissions(payload.get("permissions"))
|
||
if not username or not password:
|
||
return json_response({"error": "用户名和密码不能为空"}, status=400)
|
||
config = load_config()
|
||
users = config.setdefault("users", [])
|
||
if any(user.get("username") == username for user in users):
|
||
return json_response({"error": "用户已存在"}, status=400)
|
||
hashed = password_hash(password)
|
||
users.append({
|
||
"username": username,
|
||
"password_hash": hashed["hash"],
|
||
"salt": hashed["salt"],
|
||
"permissions": permissions,
|
||
"created_at": dt.datetime.now().isoformat(timespec="seconds"),
|
||
})
|
||
save_config(config)
|
||
return json_response({"ok": True})
|
||
|
||
|
||
@app.route("/api/settings/users/<username>/permissions", methods=["POST"])
|
||
def api_settings_user_permissions(username: str) -> Response:
|
||
require_permission("settings")
|
||
payload = request.get_json(force=True)
|
||
config = load_config()
|
||
for user in config.get("users", []):
|
||
if user.get("username") == username:
|
||
user["permissions"] = normalized_permissions(payload.get("permissions"))
|
||
save_config(config)
|
||
return json_response({"ok": True})
|
||
abort(404)
|
||
|
||
|
||
@app.route("/api/settings/kimi", methods=["POST"])
|
||
def api_settings_kimi() -> Response:
|
||
require_permission("settings")
|
||
payload = request.get_json(force=True)
|
||
config = load_config()
|
||
kimi = config.setdefault("kimi", {})
|
||
model = normalize_text(payload.get("model")) or "kimi-k2.6"
|
||
api_key = normalize_text(payload.get("api_key"))
|
||
kimi["model"] = model
|
||
if api_key:
|
||
kimi["api_key"] = api_key
|
||
save_config(config)
|
||
return json_response({"ok": True})
|
||
|
||
|
||
@app.route("/api/settings/kimi/enabled", methods=["POST"])
|
||
def api_settings_kimi_enabled() -> Response:
|
||
require_permission("settings")
|
||
payload = request.get_json(force=True)
|
||
config = load_config()
|
||
kimi = config.setdefault("kimi", {})
|
||
kimi["enabled"] = bool(payload.get("enabled"))
|
||
save_config(config)
|
||
return json_response({"ok": True, "enabled": kimi["enabled"]})
|
||
|
||
|
||
@app.route("/api/settings/commit-manual-passed", methods=["POST"])
|
||
def api_settings_commit_manual_passed() -> Response:
|
||
require_permission("settings")
|
||
return json_response(commit_manual_passed_records())
|
||
|
||
|
||
@app.route("/api/settings/reset-audit-history", methods=["POST"])
|
||
def api_settings_reset_audit_history() -> Response:
|
||
require_permission("settings")
|
||
result = reset_audit_history_records()
|
||
if result.get("error"):
|
||
return error_response(result["error"], status=500)
|
||
return json_response(result)
|
||
|
||
|
||
@app.route("/api/crop")
|
||
def api_crop() -> Response:
|
||
require_login()
|
||
path = safe_workspace_path(request.args.get("path", ""))
|
||
row_no = max(1, int(request.args.get("row", 1)))
|
||
context_rows = min(5, max(0, int(request.args.get("context", 2))))
|
||
return image_crop_response(path, row_no, context_rows)
|
||
|
||
|
||
@app.route("/api/image")
|
||
def api_image() -> Response:
|
||
require_login()
|
||
path = safe_workspace_path(request.args.get("path", ""))
|
||
if path.suffix.lower() not in IMAGE_EXTENSIONS:
|
||
abort(404)
|
||
return send_file(path, max_age=0)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
host = os.getenv("REVIEW_APP_HOST", "127.0.0.1")
|
||
port = int(os.getenv("REVIEW_APP_PORT", "8090"))
|
||
app.run(host=host, port=port)
|