Add PACS UPP OCR workflow

This commit is contained in:
Codex
2026-05-25 12:33:24 +08:00
commit 70215ce611
13 changed files with 2347 additions and 0 deletions

View File

@@ -0,0 +1,192 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""把通用图片表格 OCR 合并结果同步到 PostgreSQL 单表。"""
from __future__ import annotations
import argparse
import csv
import json
import os
import re
import subprocess
import tempfile
import unicodedata
from pathlib import Path
from typing import Any
DEFAULT_INPUT = Path("数据处理结果区/合并_图片表格_结构化.json")
DEFAULT_SCHEMA = Path("数据处理工作区/06_PostgreSQL建表结构.sql")
TEMPLATE_SCHEMA = Path("数据处理工作区/06_PostgreSQL建表结构.template.sql")
CSV_FIELDS = [
"batch_name",
"source_folder",
"category_1",
"category_2",
"image_path",
"image_name",
"image_row_no",
"unique_key",
"record_data",
"review_status",
"review_notes",
"manual_corrected",
]
def normalize_text(value: Any) -> str:
if value is None:
return ""
text = unicodedata.normalize("NFKC", str(value)).replace("\u3000", " ")
return re.sub(r"\s+", " ", text).strip()
def read_json(path: Path) -> Any:
return json.loads(path.read_text(encoding="utf-8"))
def scalar(value: Any) -> str:
if value is None:
return ""
if isinstance(value, bool):
return "true" if value else "false"
return str(value)
def sql_file(path: Path) -> Path:
if path.exists():
return path
if TEMPLATE_SCHEMA.exists():
return TEMPLATE_SCHEMA
raise FileNotFoundError(f"找不到 SQL 文件: {path}")
def quote_file(path: Path) -> str:
return "'" + str(path.resolve()).replace("'", "''") + "'"
def quote_table_name(name: str) -> str:
name = normalize_text(name)
if name.startswith('"') and name.endswith('"'):
return name
if re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", name):
return f'"{name}"'
raise ValueError(f"表名需要是简单标识符或已加双引号: {name}")
def flatten_record(record: dict[str, Any], unique_key_field: str) -> dict[str, Any]:
image = record.get("图片信息", {})
review = record.get("复核", {})
record_info = record.get("记录信息", {})
unique_key = normalize_text(record_info.get(unique_key_field, ""))
return {
"batch_name": record.get("处理批次", ""),
"source_folder": record.get("来源文件夹", ""),
"category_1": record.get("业务分类1", ""),
"category_2": record.get("业务分类2", ""),
"image_path": image.get("图片路径", ""),
"image_name": image.get("图片名", ""),
"image_row_no": image.get("图片内行号", ""),
"unique_key": unique_key,
"record_data": json.dumps(record_info, ensure_ascii=False, separators=(",", ":")),
"review_status": review.get("状态", ""),
"review_notes": "".join(str(item) for item in review.get("提示", [])),
"manual_corrected": bool(review.get("人工修正")),
}
def load_rows(input_path: Path, allow_empty_key: bool) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
archive = read_json(input_path)
unique_key = normalize_text(archive.get("任务配置", {}).get("unique_key") or archive.get("汇总", {}).get("主唯一键"))
if not unique_key:
raise ValueError("合并结果中没有主唯一键配置,无法同步到默认 PostgreSQL 模板")
rows: list[dict[str, Any]] = []
skipped: list[dict[str, Any]] = []
for record in archive.get("图片表格记录", []):
row = flatten_record(record, unique_key)
if not row["unique_key"] and not allow_empty_key:
skipped.append(row)
continue
rows.append(row)
return rows, skipped
def write_temp_csv(rows: list[dict[str, Any]]) -> Path:
temp_file = tempfile.NamedTemporaryFile(
mode="w",
encoding="utf-8",
newline="",
suffix="_image_table_records.csv",
delete=False,
)
with temp_file:
writer = csv.DictWriter(temp_file, fieldnames=CSV_FIELDS)
writer.writeheader()
for row in rows:
writer.writerow({field: scalar(row.get(field, "")) for field in CSV_FIELDS})
return Path(temp_file.name)
def sync_to_postgres(args: argparse.Namespace, csv_path: Path, row_count: int) -> None:
table_name = quote_table_name(args.table)
truncate_sql = "" if args.append else f"TRUNCATE TABLE {table_name} RESTART IDENTITY;\n"
sql = f"""\\set ON_ERROR_STOP on
\\i {quote_file(sql_file(Path(args.schema)))}
{truncate_sql}\\copy {table_name}({','.join(CSV_FIELDS)}) FROM {quote_file(csv_path)} WITH (FORMAT csv, HEADER true, NULL '')
"""
env = os.environ.copy()
if args.password:
env["PGPASSWORD"] = args.password
elif env.get("WORKFLOW_DB_PASSWORD"):
env["PGPASSWORD"] = env["WORKFLOW_DB_PASSWORD"]
command = [
"psql",
"-h",
args.host,
"-p",
str(args.port),
"-U",
args.user,
"-d",
args.dbname,
"-v",
"ON_ERROR_STOP=1",
]
completed = subprocess.run(command, input=sql, text=True, env=env, capture_output=True)
print(completed.stdout, end="")
if completed.returncode != 0:
print(completed.stderr, end="")
completed.check_returncode()
print(json.dumps({"synced_table": args.table, "records": row_count}, ensure_ascii=False))
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--input", default=str(DEFAULT_INPUT), help="合并后的结构化 JSON")
parser.add_argument("--schema", default=str(DEFAULT_SCHEMA), help="建表 SQL")
parser.add_argument("--table", default=os.getenv("WORKFLOW_DB_TABLE", "Image_Table_Records"), help="目标表名")
parser.add_argument("--host", default=os.getenv("WORKFLOW_DB_HOST", "127.0.0.1"))
parser.add_argument("--port", default=os.getenv("WORKFLOW_DB_PORT", "5432"))
parser.add_argument("--dbname", default=os.getenv("WORKFLOW_DB_NAME", "postgres"))
parser.add_argument("--user", default=os.getenv("WORKFLOW_DB_USER", "postgres"))
parser.add_argument("--password", default="", help="数据库密码;建议用 WORKFLOW_DB_PASSWORD")
parser.add_argument("--append", action="store_true", help="追加导入,不清空表")
parser.add_argument("--allow-empty-key", action="store_true", help="允许空 unique_key默认跳过")
return parser.parse_args()
def main() -> None:
args = parse_args()
rows, skipped = load_rows(Path(args.input), args.allow_empty_key)
csv_path = write_temp_csv(rows)
try:
sync_to_postgres(args, csv_path, len(rows))
if skipped:
print(json.dumps({"skipped_empty_unique_key": len(skipped), "examples": skipped[:10]}, ensure_ascii=False))
finally:
csv_path.unlink(missing_ok=True)
if __name__ == "__main__":
main()