Improve review export and AI progress UI
This commit is contained in:
@@ -4,12 +4,14 @@ import json
|
||||
import os
|
||||
import base64
|
||||
import hashlib
|
||||
import tempfile
|
||||
import secrets
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import zipfile
|
||||
from datetime import date, datetime, timedelta
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
@@ -21,7 +23,7 @@ from zoneinfo import ZoneInfo
|
||||
import psycopg2
|
||||
import psycopg2.extras
|
||||
from dotenv import load_dotenv
|
||||
from fastapi import FastAPI, HTTPException, Request, Response
|
||||
from fastapi import BackgroundTasks, FastAPI, HTTPException, Request, Response
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel, Field
|
||||
@@ -969,6 +971,12 @@ def session_from_request(request: Request) -> dict[str, Any] | None:
|
||||
return SESSIONS.get(token)
|
||||
|
||||
|
||||
def require_admin_user(request: Request) -> None:
|
||||
user = getattr(request.state, "user", {}) or {}
|
||||
if user.get("username") != admin_username():
|
||||
raise HTTPException(status_code=403, detail="只有 admin 可以使用导出功能")
|
||||
|
||||
|
||||
def page_permission_for_path(path: str, method: str) -> str | tuple[str, ...] | None:
|
||||
if path in {"/api/status", "/api/schema"}:
|
||||
return None
|
||||
@@ -1927,6 +1935,22 @@ def get_pdf_path(source_file: str) -> Path | None:
|
||||
return path if path.exists() and path.is_file() else None
|
||||
|
||||
|
||||
def safe_download_name(value: Any, fallback: str = "未命名") -> str:
|
||||
text = re.sub(r'[\\/:*?"<>|\r\n]+', "_", str(value or "").strip())
|
||||
text = re.sub(r"\s+", "_", text).strip("._ ")
|
||||
return text[:80] or fallback
|
||||
|
||||
|
||||
def record_pdf_download_name(record: dict[str, Any]) -> str:
|
||||
source_file = Path(str(record.get("source_file") or "")).name
|
||||
stem = Path(source_file).stem or f"record_{record.get('id', '')}"
|
||||
suffix = Path(source_file).suffix or ".pdf"
|
||||
patient = safe_download_name(record.get("patient_name"), "")
|
||||
inpatient_no = safe_download_name(record.get("inpatient_no") or record.get("medical_record_no"), "")
|
||||
parts = [part for part in [inpatient_no, patient, stem] if part]
|
||||
return "_".join(parts) + suffix
|
||||
|
||||
|
||||
def parse_json_content(content: str) -> Any:
|
||||
text = content.strip()
|
||||
if text.startswith("```"):
|
||||
@@ -3255,10 +3279,7 @@ def overview():
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/records")
|
||||
def list_records(q: str = "", status_filter: str = "review_all", limit: int = 300, offset: int = 0):
|
||||
limit = max(50, min(int(limit), 500))
|
||||
offset = max(0, int(offset))
|
||||
def record_filter_sql(q: str = "", status_filter: str = "review_all") -> tuple[sql.Composable, list[Any]]:
|
||||
clauses = []
|
||||
params: list[Any] = []
|
||||
if q:
|
||||
@@ -3275,9 +3296,37 @@ def list_records(q: str = "", status_filter: str = "review_all", limit: int = 30
|
||||
else:
|
||||
clauses.append("review_status = %s")
|
||||
params.append(status_filter)
|
||||
where_sql = sql.SQL("")
|
||||
if clauses:
|
||||
where_sql = sql.SQL("WHERE ") + sql.SQL(" AND ").join(sql.SQL(clause) for clause in clauses)
|
||||
if not clauses:
|
||||
return sql.SQL(""), params
|
||||
return sql.SQL("WHERE ") + sql.SQL(" AND ").join(sql.SQL(clause) for clause in clauses), params
|
||||
|
||||
|
||||
RECORD_LIST_ORDER_SQL = sql.SQL(
|
||||
"""
|
||||
ORDER BY
|
||||
last_activity_at DESC NULLS LAST,
|
||||
CASE review_status
|
||||
WHEN 'needs_review' THEN 1
|
||||
WHEN 'AI已处理-不OK' THEN 2
|
||||
WHEN 'AI复核-待确认' THEN 2
|
||||
WHEN 'AI已处理-OK' THEN 3
|
||||
WHEN 'AI复核-无问题' THEN 3
|
||||
WHEN 'auto_pass' THEN 3
|
||||
WHEN 'auto_corrected' THEN 4
|
||||
WHEN 'reviewed' THEN 5
|
||||
WHEN '已提交' THEN 6
|
||||
ELSE 7
|
||||
END,
|
||||
id
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/records")
|
||||
def list_records(q: str = "", status_filter: str = "review_all", limit: int = 300, offset: int = 0):
|
||||
limit = max(50, min(int(limit), 500))
|
||||
offset = max(0, int(offset))
|
||||
where_sql, params = record_filter_sql(q, status_filter)
|
||||
|
||||
query = sql.SQL(
|
||||
"""
|
||||
@@ -3289,24 +3338,10 @@ def list_records(q: str = "", status_filter: str = "review_all", limit: int = 30
|
||||
NULLIF(review_logs->-1->>'changed_at', '')::timestamp AS last_activity_at
|
||||
FROM {table}
|
||||
{where}
|
||||
ORDER BY
|
||||
last_activity_at DESC NULLS LAST,
|
||||
CASE review_status
|
||||
WHEN 'needs_review' THEN 1
|
||||
WHEN 'AI已处理-不OK' THEN 2
|
||||
WHEN 'AI复核-待确认' THEN 2
|
||||
WHEN 'AI已处理-OK' THEN 3
|
||||
WHEN 'AI复核-无问题' THEN 3
|
||||
WHEN 'auto_pass' THEN 3
|
||||
WHEN 'auto_corrected' THEN 4
|
||||
WHEN 'reviewed' THEN 5
|
||||
WHEN '已提交' THEN 6
|
||||
ELSE 7
|
||||
END,
|
||||
id
|
||||
{order_by}
|
||||
LIMIT %s OFFSET %s
|
||||
"""
|
||||
).format(table=table_identifier(), where=where_sql)
|
||||
).format(table=table_identifier(), where=where_sql, order_by=RECORD_LIST_ORDER_SQL)
|
||||
with connect() as conn, conn.cursor() as cur:
|
||||
cur.execute(query, [*params, limit + 1, offset])
|
||||
rows = [row_to_json(dict(row)) for row in cur.fetchall()]
|
||||
@@ -3318,11 +3353,77 @@ def list_records(q: str = "", status_filter: str = "review_all", limit: int = 30
|
||||
return {"records": rows, "limit": limit, "offset": offset, "has_more": has_more}
|
||||
|
||||
|
||||
@app.get("/api/records/export")
|
||||
def export_records(request: Request, background_tasks: BackgroundTasks, q: str = "", status_filter: str = "review_all"):
|
||||
require_admin_user(request)
|
||||
where_sql, params = record_filter_sql(q, status_filter)
|
||||
max_export = 5000
|
||||
query = sql.SQL(
|
||||
"""
|
||||
SELECT
|
||||
id, source_file, inpatient_no, medical_record_no, patient_name, review_status,
|
||||
NULLIF(review_logs->-1->>'changed_at', '')::timestamp AS last_activity_at
|
||||
FROM {table}
|
||||
{where}
|
||||
{order_by}
|
||||
LIMIT %s
|
||||
"""
|
||||
).format(table=table_identifier(), where=where_sql, order_by=RECORD_LIST_ORDER_SQL)
|
||||
with connect() as conn, conn.cursor() as cur:
|
||||
cur.execute(query, [*params, max_export])
|
||||
rows = [row_to_json(dict(row)) for row in cur.fetchall()]
|
||||
|
||||
export_items: list[tuple[dict[str, Any], Path]] = []
|
||||
for row in rows:
|
||||
pdf_path = get_pdf_path(row.get("source_file") or "")
|
||||
if pdf_path:
|
||||
export_items.append((row, pdf_path))
|
||||
if not export_items:
|
||||
raise HTTPException(status_code=404, detail="当前筛选下没有可导出的 PDF")
|
||||
|
||||
handle = tempfile.NamedTemporaryFile(prefix="frontpage_export_", suffix=".zip", delete=False)
|
||||
zip_path = Path(handle.name)
|
||||
handle.close()
|
||||
used_names: set[str] = set()
|
||||
with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED, allowZip64=True) as archive:
|
||||
for index, (record, pdf_path) in enumerate(export_items, start=1):
|
||||
name = record_pdf_download_name(record)
|
||||
arcname = f"{index:04d}_{name}"
|
||||
while arcname in used_names:
|
||||
arcname = f"{index:04d}_{record.get('id')}_{name}"
|
||||
used_names.add(arcname)
|
||||
archive.write(pdf_path, arcname)
|
||||
background_tasks.add_task(zip_path.unlink, missing_ok=True)
|
||||
stamp = datetime.now(APP_TIMEZONE).strftime("%Y%m%d_%H%M%S")
|
||||
return FileResponse(
|
||||
zip_path,
|
||||
media_type="application/zip",
|
||||
filename=f"患者首页影像导出_{status_filter}_{stamp}.zip",
|
||||
background=background_tasks,
|
||||
headers={"Cache-Control": "no-store", "X-Content-Type-Options": "nosniff"},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/records/{record_id}")
|
||||
def get_record(record_id: int):
|
||||
return {"record": fetch_record(record_id)}
|
||||
|
||||
|
||||
@app.get("/api/records/{record_id}/export")
|
||||
def export_record(record_id: int, request: Request):
|
||||
require_admin_user(request)
|
||||
record = fetch_record(record_id)
|
||||
pdf_path = get_pdf_path(record.get("source_file") or "")
|
||||
if not pdf_path:
|
||||
raise HTTPException(status_code=404, detail="PDF 文件不存在")
|
||||
return FileResponse(
|
||||
pdf_path,
|
||||
media_type="application/pdf",
|
||||
filename=record_pdf_download_name(record),
|
||||
headers={"Cache-Control": "no-store", "X-Content-Type-Options": "nosniff"},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/records/{record_id}/ai-question-image/{log_index}/{module_index}")
|
||||
def ai_question_image(record_id: int, log_index: int, module_index: int):
|
||||
if log_index < 0 or module_index < 0:
|
||||
@@ -3548,6 +3649,30 @@ def cancel_ai_review():
|
||||
return dict(AI_REVIEW_JOB)
|
||||
|
||||
|
||||
@app.post("/api/ai/review/ack")
|
||||
def ack_ai_review():
|
||||
with AI_JOB_LOCK:
|
||||
if AI_REVIEW_JOB.get("running"):
|
||||
return dict(AI_REVIEW_JOB)
|
||||
AI_REVIEW_JOB.update(
|
||||
running=False,
|
||||
cancel_requested=False,
|
||||
scope="",
|
||||
total=0,
|
||||
processed=0,
|
||||
ok=0,
|
||||
pending=0,
|
||||
failed=0,
|
||||
concurrency=0,
|
||||
message="",
|
||||
errors=[],
|
||||
started_at="",
|
||||
finished_at="",
|
||||
last_record_id=None,
|
||||
)
|
||||
return dict(AI_REVIEW_JOB)
|
||||
|
||||
|
||||
@app.post("/api/ai/review/approve-no-issue")
|
||||
def approve_ai_no_issue():
|
||||
with BULK_JOB_LOCK:
|
||||
|
||||
Reference in New Issue
Block a user