#!/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"} IMAGE_DATA_LINK_NAMES = ("已处理-患者目录图片集群", "待处理-患者目录图片集群") 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 path_is_relative_to(path: Path, root: Path) -> bool: try: path.relative_to(root) return True except ValueError: return False def allowed_file_roots() -> list[Path]: roots = [WORKSPACE_ROOT.resolve()] for name in IMAGE_DATA_LINK_NAMES: link_path = WORKSPACE_ROOT / name if link_path.exists() or link_path.is_symlink(): roots.append(link_path.resolve(strict=False)) extra_roots = os.getenv("REVIEW_ALLOWED_PATH_ROOTS", "") for root_text in extra_roots.split(os.pathsep): root_text = root_text.strip() if root_text: roots.append(Path(root_text).expanduser().resolve(strict=False)) unique_roots: list[Path] = [] seen: set[str] = set() for root in roots: key = str(root) if key not in seen: seen.add(key) unique_roots.append(root) return unique_roots 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(strict=False) if not any(path_is_relative_to(resolved, root) or resolved == root for root in allowed_file_roots()): 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/") 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//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//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//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//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)