Files
HIS/患者列表处理/人工复核网页端/app.py

2128 lines
87 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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)