From 90bd0ecdf5042fe39db6207b9d762fe37175f8b1 Mon Sep 17 00:00:00 2001 From: "vladimir.buzalka" Date: Mon, 27 Apr 2026 11:00:40 +0200 Subject: [PATCH] Z230 --- .claude/settings.local.json | 7 +- 12 Tower1/50 SaveToFileSystem incremental.py | 100 ++-- .../MinimizeOptimizePDF/compress_pdf.py | 111 +++++ .../MinimizeOptimizePDF/compress_variants.py | 60 +++ .../0cfe0dea-c7bf-47f1-b4a2-6fb0f54d4362.pdf | Bin 0 -> 22696 bytes 60 ScansProcessing/corrections.json | 64 +++ 60 ScansProcessing/extract_patient_info.py | 85 +--- .../extract_patient_info_novy.py | 449 ++++++++++++++++++ 60 ScansProcessing/preview_viewer.py | 15 +- 60 ScansProcessing/rename_dialog.py | 93 ++++ 60 ScansProcessing/variant_picker.py | 148 ++++++ 11 files changed, 1002 insertions(+), 130 deletions(-) create mode 100644 50 Různé testy/MinimizeOptimizePDF/compress_pdf.py create mode 100644 50 Různé testy/MinimizeOptimizePDF/compress_variants.py create mode 100644 60 ScansProcessing/ToProcess/0cfe0dea-c7bf-47f1-b4a2-6fb0f54d4362.pdf create mode 100644 60 ScansProcessing/extract_patient_info_novy.py create mode 100644 60 ScansProcessing/rename_dialog.py create mode 100644 60 ScansProcessing/variant_picker.py diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 33935db..98ac813 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -5,7 +5,12 @@ "Bash(ls -la \"U:\\\\\\\\Medevio\\\\\\\\60 ScansProcessing\")", "Bash(find \"U:\\\\\\\\Medevio\\\\\\\\60 ScansProcessing\" -type f)", "Bash(grep -E '\\\\.\\(py|json|txt|md|yaml|yml\\)$')", - "Bash(ls -la \"U:\\\\\\\\Medevio\\\\\\\\60 ScansProcessing\\\\\\\\Processed\" \"U:\\\\\\\\Medevio\\\\\\\\60 ScansProcessing\\\\\\\\ToProcess\")" + "Bash(ls -la \"U:\\\\\\\\Medevio\\\\\\\\60 ScansProcessing\\\\\\\\Processed\" \"U:\\\\\\\\Medevio\\\\\\\\60 ScansProcessing\\\\\\\\ToProcess\")", + "Bash(python -c ' *)", + "Bash(gs --version)", + "Bash(where gs *)", + "Bash(python -c \"import pypdf; print\\('pypdf ok'\\)\")", + "Bash(python -c \"import fitz; print\\('pymupdf ok', fitz.version\\)\")" ] } } diff --git a/12 Tower1/50 SaveToFileSystem incremental.py b/12 Tower1/50 SaveToFileSystem incremental.py index fb0e3fd..ee6925a 100644 --- a/12 Tower1/50 SaveToFileSystem incremental.py +++ b/12 Tower1/50 SaveToFileSystem incremental.py @@ -7,6 +7,7 @@ import pymysql import re from pathlib import Path from datetime import datetime +from collections import defaultdict import time import sys @@ -112,6 +113,7 @@ cur_meta.execute(""" p.displayTitle FROM medevio_downloads d JOIN pozadavky p ON d.request_id = p.id + WHERE p.updatedAt >= DATE_SUB(NOW(), INTERVAL 14 DAY) ORDER BY p.updatedAt DESC """) @@ -122,40 +124,28 @@ safe_print(f"📋 Found {len(rows)} attachment records.\n") # 🧠 MAIN LOOP WITH PROGRESS # ============================== -unique_request_ids = [] -seen = set() +# Group rows by request_id in Python — avoids N extra SELECT filename queries +rows_by_request = defaultdict(list) for r in rows: - req_id = r["request_id"] - if req_id not in seen: - unique_request_ids.append(req_id) - seen.add(req_id) + rows_by_request[r["request_id"]].append(r) -total_requests = len(unique_request_ids) +total_requests = len(rows_by_request) safe_print(f"🔄 Processing {total_requests} unique requests...\n") -processed_requests = set() -current_index = 0 +# Pre-index BASE_DIR once — avoids iterdir() called twice per request +folder_list = [(f, f.name) for f in BASE_DIR.iterdir() if f.is_dir()] -for r in rows: - req_id = r["request_id"] - - if req_id in processed_requests: - continue - processed_requests.add(req_id) - - current_index += 1 +for current_index, (req_id, req_rows) in enumerate(rows_by_request.items(), 1): percent = (current_index / total_requests) * 100 - safe_print(f"\n[ {percent:5.1f}% ] Processing request {current_index} / {total_requests} → {req_id}") - # ========== FETCH VALID FILENAMES ========== - cur_meta.execute( - "SELECT filename FROM medevio_downloads WHERE request_id=%s", - (req_id,) - ) - valid_files = {sanitize_name(row["filename"]) for row in cur_meta.fetchall()} + # ========== VALID FILENAMES from already-loaded rows ========== + # original filename → sanitized name (needed for DB query later) + file_map = {sanitize_name(r["filename"]): r["filename"] for r in req_rows} + valid_files = set(file_map.keys()) # ========== BUILD FOLDER NAME ========== + r = req_rows[0] updated_at = r["req_updated_at"] or datetime.now() date_str = updated_at.strftime("%Y-%m-%d") @@ -168,21 +158,15 @@ for r in rows: f"{date_str} {prijmeni}, {jmeno} [{abbr}] {req_id}" ) - # ========== DETECT EXISTING FOLDER ========== - existing_folder = None - - for f in BASE_DIR.iterdir(): - if f.is_dir() and req_id in f.name: - existing_folder = f - break + # ========== DETECT EXISTING FOLDER from pre-built index ========== + req_id_str = str(req_id) + matching = [f for f, name in folder_list if req_id_str in name] + existing_folder = matching[0] if matching else None main_folder = existing_folder if existing_folder else BASE_DIR / clean_folder_name # ========== MERGE DUPLICATES ========== - possible_dups = [ - f for f in BASE_DIR.iterdir() - if f.is_dir() and req_id in f.name and f != main_folder - ] + possible_dups = [f for f, name in folder_list if req_id_str in name and f != main_folder] for dup in possible_dups: safe_print(f"♻️ Merging duplicate folder: {dup.name}") @@ -201,36 +185,32 @@ for r in rows: # ========== CLEAN MAIN FOLDER ========== clean_folder(main_folder, valid_files) - # ========== DOWNLOAD MISSING FILES ========== - added_new_file = False + # ========== DOWNLOAD MISSING FILES (batch blob fetch per request) ========== main_folder.mkdir(parents=True, exist_ok=True) + added_new_file = False - for filename in valid_files: - dest_plain = main_folder / filename - dest_marked = main_folder / ("▲" + filename) - - if dest_plain.exists() or dest_marked.exists(): - continue - - added_new_file = True + missing_san = [ + fn for fn in valid_files + if not (main_folder / fn).exists() and not (main_folder / ("▲" + fn)).exists() + ] + if missing_san: + # Fetch all missing blobs in a single query instead of one per file + missing_orig = [file_map[fn] for fn in missing_san] + placeholders = ",".join(["%s"] * len(missing_orig)) cur_blob.execute( - "SELECT file_content FROM medevio_downloads " - "WHERE request_id=%s AND filename=%s", - (req_id, filename) + f"SELECT filename, file_content FROM medevio_downloads " + f"WHERE request_id=%s AND filename IN ({placeholders})", + [req_id] + missing_orig, ) - row = cur_blob.fetchone() - if not row: - continue - - content = row[0] - if not content: - continue - - with open(dest_plain, "wb") as f: - f.write(content) - - safe_print(f"💾 Wrote: {dest_plain.relative_to(BASE_DIR)}") + for blob_filename, content in cur_blob.fetchall(): + if not content: + continue + dest_plain = main_folder / sanitize_name(blob_filename) + with open(dest_plain, "wb") as fh: + fh.write(content) + safe_print(f"💾 Wrote: {dest_plain.relative_to(BASE_DIR)}") + added_new_file = True # ========== REMOVE ▲ FLAG IF NEW FILES ADDED ========== if added_new_file and "▲" in main_folder.name: diff --git a/50 Různé testy/MinimizeOptimizePDF/compress_pdf.py b/50 Různé testy/MinimizeOptimizePDF/compress_pdf.py new file mode 100644 index 0000000..18b0a88 --- /dev/null +++ b/50 Různé testy/MinimizeOptimizePDF/compress_pdf.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Compress PDF — output DPI and JPEG quality are chosen automatically +based on the detected resolution of the source PDF. + +Usage: python compress_pdf.py [output.pdf] + python compress_pdf.py (processes all PDFs in current folder) +Output filename: original_name (139 kB).pdf +""" + +import sys +import fitz +from pathlib import Path + +# ============================== +# COMPRESSION TABLE +# Detected source DPI -> (output DPI, JPEG quality) +# Rows are evaluated top-to-bottom; first match wins. +# ============================== +# +# src_dpi_min src_dpi_max out_dpi jpeg_quality +COMPRESSION_TABLE = [ + ( 0, 99, 72, 60), # very low res — already small, compress hard + ( 100, 149, 100, 70), # low res + ( 150, 249, 150, 80), # standard scan (our tested sweet spot) + ( 250, 399, 150, 80), # good scan — downsample to 150 is fine + ( 400, 599, 200, 85), # high res scan + ( 600, 9999, 150, 80), # very high res / professional scan +] + + +def detect_source_dpi(src: fitz.Document) -> int: + """Estimate source DPI from the largest image on the first page.""" + page = src[0] + images = page.get_images(full=True) + if not images: + return 150 # no raster images — use default + + # Find the largest image by pixel area + best = max(images, key=lambda img: img[2] * img[3]) # width * height + img_w_px, img_h_px = best[2], best[3] + + # Page size in inches (1 point = 1/72 inch) + page_w_in = page.rect.width / 72.0 + page_h_in = page.rect.height / 72.0 + + dpi_x = img_w_px / page_w_in if page_w_in else 0 + dpi_y = img_h_px / page_h_in if page_h_in else 0 + return round((dpi_x + dpi_y) / 2) + + +def pick_settings(source_dpi: int) -> tuple[int, int]: + for min_dpi, max_dpi, out_dpi, quality in COMPRESSION_TABLE: + if min_dpi <= source_dpi <= max_dpi: + return out_dpi, quality + # fallback to last row + return COMPRESSION_TABLE[-1][2], COMPRESSION_TABLE[-1][3] + + +def compress(input_path: Path, output_path: Path = None): + src = fitz.open(input_path) + + source_dpi = detect_source_dpi(src) + out_dpi, jpeg_quality = pick_settings(source_dpi) + + print(f" zdroj ~{source_dpi} DPI -> komprese {out_dpi} DPI / JPEG q{jpeg_quality}") + + zoom = out_dpi / 72.0 + mat = fitz.Matrix(zoom, zoom) + + out_doc = fitz.open() + for page in src: + pix = page.get_pixmap(matrix=mat, colorspace=fitz.csRGB) + img_bytes = pix.tobytes("jpeg", jpg_quality=jpeg_quality) + img_doc = fitz.open("pdf", fitz.open("jpeg", img_bytes).convert_to_pdf()) + rect = page.rect + new_page = out_doc.new_page(width=rect.width, height=rect.height) + new_page.show_pdf_page(new_page.rect, img_doc, 0) + src.close() + + tmp = input_path.with_suffix(".tmp.pdf") + out_doc.save(tmp, deflate=True, garbage=4) + out_doc.close() + + size_kb = round(tmp.stat().st_size / 1024) + + if output_path is None: + output_path = input_path.parent / f"{input_path.stem} ({size_kb} kB).pdf" + + if output_path.exists(): + output_path.unlink() + tmp.rename(output_path) + + orig_kb = round(input_path.stat().st_size / 1024) + saving = (1 - size_kb / orig_kb) * 100 + print(f" {input_path.name} -> {output_path.name} (bylo {orig_kb} kB, uspora {saving:.0f}%)") + + +if __name__ == "__main__": + if len(sys.argv) >= 2: + inp = Path(sys.argv[1]) + out = Path(sys.argv[2]) if len(sys.argv) >= 3 else None + compress(inp, out) + else: + folder = Path(__file__).parent + pdfs = [p for p in folder.glob("*.pdf") if not p.name.endswith(").pdf") and p.stem != Path(__file__).stem] + if not pdfs: + print("Zadne PDF k zpracovani.") + for pdf in pdfs: + compress(pdf) diff --git a/50 Různé testy/MinimizeOptimizePDF/compress_variants.py b/50 Různé testy/MinimizeOptimizePDF/compress_variants.py new file mode 100644 index 0000000..227092a --- /dev/null +++ b/50 Různé testy/MinimizeOptimizePDF/compress_variants.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Compress a PDF into multiple variants at different DPI / JPEG quality settings. +Uses PyMuPDF (fitz) — renders each page as JPEG image, saves back as PDF. +""" + +import sys +import fitz # PyMuPDF +from pathlib import Path + +INPUT = Path(r"u:\Medevio\50 Různé testy\MinimizeOptimizePDF\afd1823b-8277-44a2-84e1-db89a0ccd134.pdf") +OUT_DIR = INPUT.parent + +VARIANTS = [ + # (label, dpi, jpeg_quality) + ("300dpi_q90", 300, 90), + ("200dpi_q85", 200, 85), + ("150dpi_q80", 150, 80), + ("120dpi_q75", 120, 75), + ("96dpi_q70", 96, 70), + ("72dpi_q60", 72, 60), +] + +src = fitz.open(INPUT) +original_size = INPUT.stat().st_size +print(f"Originál: {INPUT.name} ({original_size / 1024:.0f} KB)\n") +print(f"{'Varianta':<20} {'DPI':>5} {'Kvalita':>8} {'Velikost':>12} {'Úspora':>8}") +print("-" * 58) + +for label, dpi, quality in VARIANTS: + out_path = OUT_DIR / f"{INPUT.stem}_{label}.pdf" + zoom = dpi / 72.0 + mat = fitz.Matrix(zoom, zoom) + + out_doc = fitz.open() + for page in src: + pix = page.get_pixmap(matrix=mat, colorspace=fitz.csRGB) + img_bytes = pix.tobytes("jpeg", jpg_quality=quality) + + # Create a new PDF page with the same physical dimensions + img_doc = fitz.open("pdf", fitz.open("jpeg", img_bytes).convert_to_pdf()) + # Scale page back to original size + rect = page.rect + new_page = out_doc.new_page(width=rect.width, height=rect.height) + new_page.show_pdf_page(new_page.rect, img_doc, 0) + + out_doc.save(out_path, deflate=True, garbage=4) + out_doc.close() + + size = out_path.stat().st_size + size_kb = round(size / 1024) + final_path = OUT_DIR / f"{INPUT.stem}_{label} ({size_kb} kB).pdf" + out_path.rename(final_path) + + saving = (1 - size / original_size) * 100 + print(f"{label:<20} {dpi:>5} {quality:>8} {size_kb:>9} kB {saving:>7.0f}%") + +src.close() +print("\nHotovo.") diff --git a/60 ScansProcessing/ToProcess/0cfe0dea-c7bf-47f1-b4a2-6fb0f54d4362.pdf b/60 ScansProcessing/ToProcess/0cfe0dea-c7bf-47f1-b4a2-6fb0f54d4362.pdf new file mode 100644 index 0000000000000000000000000000000000000000..8b0640090d5d840b3c832ef5df92c41c0acbadf0 GIT binary patch literal 22696 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 str | None: """ - Tkinter dialog pro schválení / opravu názvu souboru. + Spustí rename_dialog.py jako subprocess — vyhneme se Tkinter konfliktům s PyCharm. Vrátí finální název (s .pdf) nebo None = přeskočit. """ - import tkinter as tk + import tempfile - result = {"value": None} + data = {"nazev": nazev, "info_lines": info_lines} + tmp = Path(tempfile.mktemp(suffix=".json")) + tmp.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8") - root = tk.Tk() - root.withdraw() - root.tk.call("encoding", "system", "utf-8") - - dlg = tk.Toplevel(root) - dlg.title("Schválení názvu souboru") - dlg.resizable(True, False) - dlg.attributes("-topmost", True) - - pad = {"padx": 12, "pady": 6} - - # Informační sekce - frame_info = tk.Frame(dlg, bg="#f0f0f0", bd=1, relief="sunken") - frame_info.pack(fill="x", **pad) - for line in info_lines: - color = "#b00000" if line.startswith("⚠") else "#004080" if line.startswith("✓") else "#333" - tk.Label(frame_info, text=line, anchor="w", bg="#f0f0f0", - fg=color, font=("Segoe UI", 10)).pack(fill="x", padx=8, pady=1) - - # Pole pro název (bez .pdf) - tk.Label(dlg, text="Název souboru (bez .pdf):", anchor="w", - font=("Segoe UI", 9, "bold")).pack(fill="x", padx=12, pady=(10, 2)) - - nazev_bez = nazev[:-4] if nazev and nazev.endswith(".pdf") else (nazev or "") - var = tk.StringVar(value=nazev_bez) - entry = tk.Entry(dlg, textvariable=var, font=("Segoe UI", 10), width=90) - entry.pack(fill="x", padx=12, pady=(0, 10)) - entry.icursor(tk.END) - entry.focus_set() - - # Tlačítka - frame_btn = tk.Frame(dlg) - frame_btn.pack(pady=(0, 12)) - - def schvalit(event=None): - result["value"] = var.get().strip() - root.destroy() - - def preskocit(event=None): - result["value"] = None - root.destroy() - - tk.Button(frame_btn, text="✓ Schválit (Enter)", command=schvalit, - bg="#2a7a2a", fg="white", font=("Segoe UI", 10, "bold"), - padx=16, pady=6).pack(side="left", padx=8) - tk.Button(frame_btn, text="✗ Přeskočit (Esc)", command=preskocit, - bg="#7a2a2a", fg="white", font=("Segoe UI", 10), - padx=16, pady=6).pack(side="left", padx=8) - - dlg.bind("", schvalit) - dlg.bind("", preskocit) - - # Umísti dialog vpravo od náhledu (nebo vystředit pokud náhled není) - dlg.update_idletasks() - sw = dlg.winfo_screenwidth() - sh = dlg.winfo_screenheight() - w = dlg.winfo_width() - h = dlg.winfo_height() - x = min(720, sw - w - 20) - y = (sh - h) // 2 - dlg.geometry(f"+{x}+{y}") - - root.mainloop() - return result["value"] + dialog_script = Path(__file__).parent / "rename_dialog.py" + try: + proc = subprocess.run( + [sys.executable, str(dialog_script), str(tmp)], + capture_output=True, text=True, encoding="utf-8", + ) + output = proc.stdout.strip() + if output: + return json.loads(output).get("value") + return None + finally: + tmp.unlink(missing_ok=True) def print_verification(verif: dict, rc_from_scan: str): @@ -564,7 +514,6 @@ def _start_preview_process(pdf_path: Path): viewer = Path(__file__).parent / "preview_viewer.py" proc = subprocess.Popen( [sys.executable, str(viewer), str(tmp), "--delete-on-close"], - creationflags=subprocess.CREATE_NO_WINDOW if hasattr(subprocess, "CREATE_NO_WINDOW") else 0, ) def close(): diff --git a/60 ScansProcessing/extract_patient_info_novy.py b/60 ScansProcessing/extract_patient_info_novy.py new file mode 100644 index 0000000..d7aa515 --- /dev/null +++ b/60 ScansProcessing/extract_patient_info_novy.py @@ -0,0 +1,449 @@ +""" +Zpracování naskenovaných PDF — nová verze. +1. Preview originálu + Claude Vision API +2. Rename dialog +3. 5 variant komprese → uživatel vybere +4. Uložit do Processed, smazat originál +""" +import base64 +import gc +import io +import json +import os +import re +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + +if sys.platform == "win32": + sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace") + sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace") + +import anthropic +from pdf2image import convert_from_path + +sys.path.insert(0, str(Path(__file__).parent.parent)) +from Knihovny.najdi_dropbox import get_dropbox_root +from Knihovny.najdi_medicus import get_medicus_config + +def _load_env(): + env_path = Path(__file__).parent.parent / ".env" + if env_path.exists(): + for line in env_path.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if "=" in line and not line.startswith("#"): + k, v = line.split("=", 1) + os.environ[k.strip()] = v.strip() + +_load_env() + +POPPLER_PATH = r"C:/Poppler/Library/bin" +_DROPBOX = Path(get_dropbox_root()) +TO_PROCESS = _DROPBOX / r"Ordinace\Dokumentace_ke_zpracování\Ricoh Fi-8040\KeZpracování" +PROCESSED = _DROPBOX / r"Ordinace\Dokumentace_ke_zpracování\Ricoh Fi-8040\Zpracováno" +CORRECTIONS_FILE = Path(__file__).parent / "corrections.json" +DOKUMENTACE = _DROPBOX / r"Ordinace\Dokumentace_zpracovaná" + +import threading + +_dokumentace_index: set[str] = set() +_dokumentace_ready = threading.Event() + +def _load_dokumentace_index_bg(): + if DOKUMENTACE.exists(): + names = {f.name for f in DOKUMENTACE.iterdir() if f.is_file()} + else: + names = set() + global _dokumentace_index + _dokumentace_index = names + _dokumentace_ready.set() + print(f" Index dokumentace: {len(names)} souborů načteno.") + +def start_dokumentace_index(): + t = threading.Thread(target=_load_dokumentace_index_bg, daemon=True) + t.start() + +VIEWER = Path(__file__).parent / "preview_viewer.py" +RENAME_DIALOG = Path(__file__).parent / "rename_dialog.py" +VARIANT_PICKER = Path(__file__).parent / "variant_picker.py" + +# 5 kompresních variant +COMPRESS_VARIANTS = [ + ("300 DPI / q90", 300, 90), + ("200 DPI / q85", 200, 85), + ("150 DPI / q80", 150, 80), + ("120 DPI / q75", 120, 75), + ( "96 DPI / q70", 96, 70), +] + + +# ─── Komprese jedné varianty ────────────────────────────────────────────────── + +def compress_to_temp(pdf_path: Path, dpi: int, quality: int) -> Path: + import fitz + src = fitz.open(str(pdf_path)) + mat = fitz.Matrix(dpi / 72.0, dpi / 72.0) + out = fitz.open() + for page in src: + pix = page.get_pixmap(matrix=mat, colorspace=fitz.csRGB) + img_bytes = pix.tobytes("jpeg", jpg_quality=quality) + img_doc = fitz.open("pdf", fitz.open("jpeg", img_bytes).convert_to_pdf()) + rect = page.rect + np = out.new_page(width=rect.width, height=rect.height) + np.show_pdf_page(np.rect, img_doc, 0) + src.close() + tmp = Path(tempfile.mktemp(suffix=".pdf")) + out.save(tmp, deflate=True, garbage=4) + out.close() + return tmp + + +# ─── Medicus ověření ───────────────────────────────────────────────────────── + +def _medicus_connect(): + try: + import fdb + cfg = get_medicus_config() + return fdb.connect(dsn=cfg.dsn, user="SYSDBA", password="masterkey", charset="win1250") + except Exception as e: + print(f" [Medicus] Nepřipojeno: {e}") + return None + +def _lookup_by_rc(cur, rc_digits: str) -> dict | None: + cur.execute( + "SELECT IDPAC, PRIJMENI, JMENO, RODCIS FROM KAR " + "WHERE REPLACE(RODCIS, '/', '') = ?", (rc_digits,) + ) + row = cur.fetchone() + if row: + return {"idpac": row[0], "prijmeni": row[1].strip(), "jmeno": row[2].strip(), "rodcis": row[3].strip()} + return None + +def _rc_candidates(rc: str) -> list[str]: + similar = {"0": "8", "8": "0", "1": "7", "7": "1", "5": "6", "6": "5", "3": "8"} + candidates = set() + for i in range(len(rc)): + candidates.add(rc[:i] + rc[i+1:]) + for i in range(len(rc) + 1): + candidates.add(rc[:i] + "0" + rc[i:]) + for i, ch in enumerate(rc): + if ch in similar: + candidates.add(rc[:i] + similar[ch] + rc[i+1:]) + candidates.discard(rc) + return sorted(c for c in candidates if len(c) in (9, 10)) + +def _rc_checksum_ok(rc: str) -> bool: + digits = re.sub(r"\D", "", rc) + if len(digits) == 10: + return int(digits) % 11 == 0 + return True + +def verify_patient(rc_raw: str) -> dict: + rc = re.sub(r"\D", "", rc_raw or "") + if not rc: + return {"status": "not_found", "patient": None, "rc_corrected": None} + con = _medicus_connect() + if con is None: + return {"status": "offline", "patient": None, "rc_corrected": None} + try: + cur = con.cursor() + patient = _lookup_by_rc(cur, rc) + if patient: + return {"status": "ok", "patient": patient, "rc_corrected": None} + candidates = _rc_candidates(rc) + matches = [(c, _lookup_by_rc(cur, c)) for c in candidates] + matches = [(c, p) for c, p in matches if p] + if not matches: + return {"status": "not_found", "patient": None, "rc_corrected": None} + matches.sort(key=lambda x: (0 if _rc_checksum_ok(x[0]) else 1)) + best_rc, best_patient = matches[0] + return {"status": "fuzzy", "patient": best_patient, "rc_corrected": best_rc, "all_matches": matches} + finally: + con.close() + +def check_duplicates(rc: str, datum: str) -> list[str]: + if not rc or not datum: + return [] + # Počkej max 15s na dokončení indexu (typicky hotovo za dobu volání Claude) + _dokumentace_ready.wait(timeout=15) + prefix = f"{rc} {datum}" + return [name for name in _dokumentace_index if name.startswith(prefix)] + + +# ─── Korekce (few-shot příklady) ───────────────────────────────────────────── + +def load_corrections() -> list[dict]: + if CORRECTIONS_FILE.exists(): + return json.loads(CORRECTIONS_FILE.read_text(encoding="utf-8")) + return [] + +def save_correction(original: str, corrected: str): + corrections = load_corrections() + for c in corrections: + if c["original"] == original and c["corrected"] == corrected: + return + corrections.append({"original": original, "corrected": corrected}) + CORRECTIONS_FILE.write_text( + json.dumps(corrections, ensure_ascii=False, indent=2), encoding="utf-8" + ) + print(f" ✓ Korekce uložena ({len(corrections)} celkem)") + +def build_corrections_prompt() -> str: + corrections = load_corrections() + if not corrections: + return "" + lines = ["Příklady korekcí z minulých běhů (uč se z nich):"] + for c in corrections[-10:]: + lines.append(f' - špatně: "{c["original"]}"') + lines.append(f' správně: "{c["corrected"]}"') + return "\n".join(lines) + "\n\n" + + +# ─── Claude Vision API ──────────────────────────────────────────────────────── + +def extract_info(pdf_path: Path) -> dict: + print(" Převádím na obrázek...") + suffix = pdf_path.suffix.lower() + if suffix in (".jpg", ".jpeg", ".png"): + from PIL import Image + img = Image.open(pdf_path) + buf = io.BytesIO() + img.save(buf, format="JPEG", quality=95) + img.close() + else: + images = convert_from_path(str(pdf_path), poppler_path=POPPLER_PATH, dpi=300) + buf = io.BytesIO() + images[0].save(buf, format="JPEG", quality=95) + del images + gc.collect() + image_b64 = base64.standard_b64encode(buf.getvalue()).decode("utf-8") + + prompt = ( + build_corrections_prompt() + + "Toto je naskenovaná lékařská zpráva v češtině. " + "Vrať JSON s těmito poli:\n" + "- \"jmeno\": celé jméno pacienta (příjmení + jméno + případný titul)\n" + "- \"rodne_cislo\": rodné číslo pacienta BEZ lomítka (pouze číslice)\n" + "- \"datum_zpravy\": datum zprávy ve formátu YYYY-MM-DD\n" + "- \"typ_dokumentu\": typ dokumentu — " + "\"LZ {oddělení}\" = ambulantní/lékařská zpráva (např. \"LZ chirurgie\", \"LZ kardiologie\", \"LZ plicní\", \"LZ ORL\"); " + "\"PZ {oddělení}\" = propouštěcí zpráva z hospitalizace (např. \"PZ interna\", \"PZ neurologie\"). " + "Jiné typy: \"Laboratoř\", \"CT břicha\", \"MRI páteře\", \"kolonoskopie\", " + "\"operační protokol oční\", \"poukaz FT\", \"diagnostická mamografie\" atd.\n" + "- \"poznamka\": krátká klinická poznámka česky, max 80 znaků. " + "DŮLEŽITÉ: pokud zpráva obsahuje sekci \"Závěr:\" nebo \"Závěr vyšetření:\", " + "použij VÝHRADNĚ obsah této sekce — je nejdůležitější. " + "Teprve pokud závěr chybí, shrň obsah z celé zprávy. " + "U laboratorních výsledků uváděj POUZE hodnoty mimo normu (patologické nálezy) — hodnoty v normě vynech. " + "Osmolalitu nikdy nezmiňuj ani jako patologický nález. " + "Pokud výsledky obsahují glomerulární filtraci (eGFR nebo C_CKD-EPI), přidej její klasifikaci velkými písmeny podle CKD-EPI: " + "eGFR ≥ 90 → CHRI G1, 60–89 → CHRI G2, 45–59 → CHRI G3a, 30–44 → CHRI G3b, 15–29 → CHRI G4, < 15 → CHRI G5.\n" + "- \"nazev_souboru\": název souboru ve formátu " + "\"{rodne_cislo} {datum_zpravy} {Příjmení}, {Jméno} [{typ_dokumentu}] [{poznamka}].pdf\" " + "(jméno bez titulu, RČ bez lomítka)\n" + "- \"rotace\": o kolik stupňů CCW je třeba otočit obrázek aby byl text čitelně na výšku nebo šířku " + "(hodnoty: 0, 90, 180, 270). Pokud je text již správně orientovaný, vrať 0.\n\n" + "Pokud pole nenajdeš, použij null. Nepiš nic jiného než JSON." + ) + + print(" Volám Claude Vision API...") + client = anthropic.Anthropic(api_key=os.environ.get("ANTHROPIC_API_KEY")) + response = client.messages.create( + model="claude-sonnet-4-6", + max_tokens=400, + messages=[{"role": "user", "content": [ + {"type": "image", "source": {"type": "base64", "media_type": "image/jpeg", "data": image_b64}}, + {"type": "text", "text": prompt}, + ]}], + ) + usage = response.usage + print(f" Tokeny: {usage.input_tokens} in + {usage.output_tokens} out = ${usage.input_tokens*3/1e6 + usage.output_tokens*15/1e6:.4f}") + + raw = response.content[0].text.strip() + if raw.startswith("```"): + raw = raw.split("```")[1] + if raw.startswith("json"): + raw = raw[4:] + try: + return json.loads(raw.strip()) + except json.JSONDecodeError: + print(f" VAROVÁNÍ: nelze parsovat JSON: {raw!r}") + return {"nazev_souboru": None, "raw": raw} + + +# ─── Subprocess helpers ─────────────────────────────────────────────────────── + +def open_preview(pdf_path: Path) -> tuple[subprocess.Popen, Path]: + geom_file = Path(tempfile.mktemp(suffix=".json")) + proc = subprocess.Popen([sys.executable, str(VIEWER), str(pdf_path), f"--write-geometry={geom_file}"]) + return proc, geom_file + + +def read_preview_bottom(geom_file: Path, timeout: float = 5.0) -> int: + import time + deadline = time.time() + timeout + while time.time() < deadline: + if geom_file.exists(): + geom = json.loads(geom_file.read_text(encoding="utf-8")) + geom_file.unlink(missing_ok=True) + return geom["y"] + geom["h"] + 30 # +30 pro title bar + time.sleep(0.1) + geom_file.unlink(missing_ok=True) + return None + + +def run_rename_dialog(nazev: str, info_lines: list, below_y: int = None) -> str | None: + tmp = Path(tempfile.mktemp(suffix=".json")) + tmp.write_text(json.dumps({"nazev": nazev, "info_lines": info_lines}, ensure_ascii=False), encoding="utf-8") + args = [sys.executable, str(RENAME_DIALOG), str(tmp)] + if below_y is not None: + args.append(f"--below-y={below_y}") + proc = subprocess.run(args, capture_output=True, text=True, encoding="utf-8") + tmp.unlink(missing_ok=True) + out = proc.stdout.strip() + return json.loads(out).get("value") if out else None + + +def run_variant_picker(variants_data: list) -> str | None: + tmp = Path(tempfile.mktemp(suffix=".json")) + tmp.write_text(json.dumps(variants_data, ensure_ascii=False), encoding="utf-8") + proc = subprocess.run( + [sys.executable, str(VARIANT_PICKER), str(tmp)], + capture_output=True, text=True, encoding="utf-8", + ) + tmp.unlink(missing_ok=True) + out = proc.stdout.strip() + return json.loads(out).get("chosen") if out else None + + +# ─── Hlavní flow ────────────────────────────────────────────────────────────── + +def process_file(pdf_path: Path): + print(f"\nSoubor: {pdf_path.name}") + + # Spusť načítání indexu dokumentace na pozadí — hotovo za dobu volání Claude + start_dokumentace_index() + + # 1. Otevři preview originálu + preview, geom_file = open_preview(pdf_path) + below_y = read_preview_bottom(geom_file) + + # 2. Claude Vision API + info = extract_info(pdf_path) + nazev = info.get("nazev_souboru") or pdf_path.name + + # 3. Medicus ověření + fuzzy matching RČ + rc_from_scan = re.sub(r"\D", "", info.get("rodne_cislo") or "") + print(f" Ověřuji v Medicus (RČ: {rc_from_scan})...") + verif = verify_patient(rc_from_scan) + + # Oprava RČ při fuzzy matchi + if verif["status"] == "fuzzy" and verif.get("rc_corrected") and nazev: + nazev = nazev.replace(rc_from_scan, verif["rc_corrected"], 1) + print(f" → RČ opraveno: {rc_from_scan} → {verif['rc_corrected']}") + + # Info řádky pro dialog + status = verif["status"] + patient = verif.get("patient") + info_lines = [] + if status == "ok": + info_lines.append(f"✓ Medicus: {patient['prijmeni']} {patient['jmeno']} | RČ {patient['rodcis']}") + elif status == "fuzzy": + info_lines.append(f"⚠ RČ ze skenu '{rc_from_scan}' → opraveno na {verif['rc_corrected']}") + info_lines.append(f" Pacient: {patient['prijmeni']} {patient['jmeno']} | RČ {patient['rodcis']}") + elif status == "not_found": + info_lines.append(f"✗ RČ '{rc_from_scan}' nenalezeno v Medicus") + else: + info_lines.append("— Medicus nedostupný (offline)") + + # Duplicity + rc_final = re.sub(r"\D", "", verif["patient"]["rodcis"] if patient else rc_from_scan) + duplicity = check_duplicates(rc_final, info.get("datum_zpravy") or "") + if duplicity: + info_lines.append(f"⚠ DUPLICITA: {', '.join(duplicity)}") + + if not info_lines: + info_lines = ["[Claude nevrátil název — uprav ručně]"] + print(" Otevírám dialog pro schválení názvu...") + final_name = run_rename_dialog(nazev, info_lines, below_y=below_y) + + preview.terminate() + + if not final_name: + print(" Přeskočeno.") + return + + if not final_name.endswith(".pdf"): + final_name += ".pdf" + final_name = re.sub(r'[<>:"/\\|?*]', '', final_name) + + if nazev and final_name != nazev: + save_correction(nazev, final_name) + + print(f" Schválený název: {final_name}") + + # 4. Generuj kompresní varianty (originál + 5 variant) + print(" Generuji kompresní varianty...") + temp_files = [] + orig_kb = round(pdf_path.stat().st_size / 1024) + variants_data = [{"path": str(pdf_path), "label": "Originál", "size_kb": orig_kb}] + for label, dpi, quality in COMPRESS_VARIANTS: + tmp = compress_to_temp(pdf_path, dpi, quality) + size_kb = round(tmp.stat().st_size / 1024) + temp_files.append(tmp) + variants_data.append({"path": str(tmp), "label": label, "size_kb": size_kb}) + print(f" {label}: {size_kb} kB") + + # 5. Vyber variantu + print(" Vyber variantu v okně...") + chosen = run_variant_picker(variants_data) + + if not chosen: + print(" Žádná varianta nevybrána, přeskakuji.") + for t in temp_files: + t.unlink(missing_ok=True) + return + + # 6. Ulož do Processed + PROCESSED.mkdir(exist_ok=True) + dest = PROCESSED / final_name + if dest.exists(): + print(f" VAROVÁNÍ: '{final_name}' již existuje, přeskakuji.") + else: + shutil.copy2(chosen, dest) + pdf_path.unlink() + print(f" ✓ Uloženo: {dest.name}") + + for t in temp_files: + t.unlink(missing_ok=True) # originál mezi temp_files není, je bezpečné + + +def process_folder(folder: Path): + files = sorted(f for f in folder.iterdir() if f.suffix.lower() in (".pdf", ".jpg", ".jpeg", ".png")) + if not files: + print(f"Žádné soubory v: {folder}") + return + print(f"Nalezeno {len(files)} soubor(ů).") + for f in files: + try: + process_file(f) + except Exception as e: + print(f" CHYBA: {e}") + print("\nHotovo.") + + +if __name__ == "__main__": + PROCESSED.mkdir(exist_ok=True) + TO_PROCESS.mkdir(exist_ok=True) + + target = Path(sys.argv[1]) if len(sys.argv) > 1 else TO_PROCESS + + if target.is_file(): + process_file(target) + elif target.is_dir(): + process_folder(target) + else: + print("Použití: python extract_patient_info_novy.py [soubor.pdf nebo složka]") + sys.exit(1) diff --git a/60 ScansProcessing/preview_viewer.py b/60 ScansProcessing/preview_viewer.py index f4b9cc2..cbd9b78 100644 --- a/60 ScansProcessing/preview_viewer.py +++ b/60 ScansProcessing/preview_viewer.py @@ -90,7 +90,20 @@ def main(): show(0) root.update_idletasks() - root.geometry("+0+0") + sw = root.winfo_screenwidth() + w = root.winfo_width() + h = root.winfo_height() + x = (sw - w) // 2 + root.geometry(f"+{x}+0") + + # Zapiš geometrii do souboru pokud byl předán argument --write-geometry= + import json as _json + for arg in sys.argv: + if arg.startswith("--write-geometry="): + geom_path = Path(arg.split("=", 1)[1]) + geom_path.write_text(_json.dumps({"x": x, "y": 0, "w": w, "h": h}), encoding="utf-8") + break + root.mainloop() diff --git a/60 ScansProcessing/rename_dialog.py b/60 ScansProcessing/rename_dialog.py new file mode 100644 index 0000000..134c2ae --- /dev/null +++ b/60 ScansProcessing/rename_dialog.py @@ -0,0 +1,93 @@ +""" +Standalone dialog pro schválení / opravu názvu souboru. +Spouští se jako subprocess z extract_patient_info.py. +Argumenty: rename_dialog.py +JSON vstup: { "nazev": "...", "info_lines": [...] } +JSON výstup: { "value": "..." } nebo { "value": null } +""" +import json +import sys +from pathlib import Path +import tkinter as tk + + +def main(): + if len(sys.argv) < 2: + print(json.dumps({"value": None})) + sys.exit(0) + + data = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8")) + nazev = data.get("nazev") or "" + info_lines = data.get("info_lines") or [] + + result = {"value": None} + + root = tk.Tk() + root.title("Schválení názvu souboru") + root.resizable(True, False) + root.attributes("-topmost", True) + root.tk.call("encoding", "system", "utf-8") + + pad = {"padx": 12, "pady": 6} + + frame_info = tk.Frame(root, bg="#f0f0f0", bd=1, relief="sunken") + frame_info.pack(fill="x", **pad) + for line in info_lines: + color = "#b00000" if line.startswith("⚠") else "#004080" if line.startswith("✓") else "#333" + tk.Label(frame_info, text=line, anchor="w", bg="#f0f0f0", + fg=color, font=("Segoe UI", 10)).pack(fill="x", padx=8, pady=1) + + tk.Label(root, text="Název souboru (bez .pdf):", anchor="w", + font=("Segoe UI", 9, "bold")).pack(fill="x", padx=12, pady=(10, 2)) + + nazev_bez = nazev[:-4] if nazev.endswith(".pdf") else nazev + var = tk.StringVar(value=nazev_bez) + entry = tk.Entry(root, textvariable=var, font=("Segoe UI", 10), width=90) + entry.pack(fill="x", padx=12, pady=(0, 10)) + entry.icursor(tk.END) + entry.focus_set() + + frame_btn = tk.Frame(root) + frame_btn.pack(pady=(0, 12)) + + def schvalit(event=None): + result["value"] = var.get().strip() + root.destroy() + + def preskocit(event=None): + result["value"] = None + root.destroy() + + tk.Button(frame_btn, text="✓ Schválit (Enter)", command=schvalit, + bg="#2a7a2a", fg="white", font=("Segoe UI", 10, "bold"), + padx=16, pady=6).pack(side="left", padx=8) + tk.Button(frame_btn, text="✗ Přeskočit (Esc)", command=preskocit, + bg="#7a2a2a", fg="white", font=("Segoe UI", 10), + padx=16, pady=6).pack(side="left", padx=8) + + root.bind("", schvalit) + root.bind("", preskocit) + + root.update_idletasks() + sw = root.winfo_screenwidth() + w = root.winfo_width() + x = (sw - w) // 2 + + # Pozice pod preview oknem pokud byl předán argument --below-y=N + below_y = None + for arg in sys.argv: + if arg.startswith("--below-y="): + below_y = int(arg.split("=", 1)[1]) + break + y = below_y if below_y is not None else (root.winfo_screenheight() - root.winfo_height() - 60) + root.geometry(f"+{x}+{y}") + + root.lift() + root.focus_force() + root.mainloop() + + print(json.dumps({"value": result["value"]}, ensure_ascii=False)) + + +if __name__ == "__main__": + main() diff --git a/60 ScansProcessing/variant_picker.py b/60 ScansProcessing/variant_picker.py new file mode 100644 index 0000000..b4cb5e5 --- /dev/null +++ b/60 ScansProcessing/variant_picker.py @@ -0,0 +1,148 @@ +""" +Jedno okno pro výběr kompresní varianty PDF. +Nahoře tlačítka 1–N pro přepínání, tlačítko "Tohle beru" pro potvrzení. +Argumenty: variant_picker.py +JSON vstup: [{"path": "...", "label": "150 DPI / q80", "size_kb": 139}, ...] +JSON výstup (stdout): {"chosen": "cesta/k/souboru"} +""" +import json +import sys +from pathlib import Path +import tkinter as tk +from PIL import Image, ImageTk +import fitz + + +def main(): + if len(sys.argv) < 2: + sys.exit(1) + + variants = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8")) + chosen = {"path": None} + docs = [fitz.open(v["path"]) for v in variants] + current = [0] + photo_ref = [None] + + root = tk.Tk() + root.tk.call("encoding", "system", "utf-8") + root.attributes("-topmost", True) + + sh = root.winfo_screenheight() + sw = root.winfo_screenwidth() + win_h = sh - 80 # odečteme taskbar + title bar + img_h = win_h - 160 + img_w = sw // 2 # šířka okna = polovina monitoru + + x = (sw - img_w) // 2 + root.geometry(f"{img_w}x{win_h}+{x}+0") + root.resizable(False, False) + + # ── Horní panel s tlačítky variant ── + frame_top = tk.Frame(root, bg="#222") + frame_top.pack(fill="x") + + btn_variants = [] + current_page = [0] + + def show(n, page_n=0): + current[0] = n + current_page[0] = page_n + doc = docs[n] + page = doc[page_n] + zoom = min(img_w / page.rect.width, img_h / page.rect.height) + pix = page.get_pixmap(matrix=fitz.Matrix(zoom, zoom)) + img = Image.frombytes("RGB", (pix.width, pix.height), pix.samples) + photo_ref[0] = ImageTk.PhotoImage(img) + lbl_img.config(image=photo_ref[0]) + page_count = len(doc) + root.title(f"Varianta {n+1}: {variants[n]['label']} ({variants[n]['size_kb']} kB) — strana {page_n+1}/{page_count}") + for i, b in enumerate(btn_variants): + b.config(bg="#2a5a9a" if i == n else "#444") + btn_prev_page.config(state="normal" if page_n > 0 else "disabled") + btn_next_page.config(state="normal" if page_n < page_count - 1 else "disabled") + + for i, v in enumerate(variants): + b = tk.Button( + frame_top, + text=f"{i+1}. {v['label']}\n{v['size_kb']} kB", + font=("Segoe UI", 9, "bold"), + bg="#444", fg="white", + relief="flat", padx=8, pady=6, + command=lambda n=i: show(n), + ) + b.pack(side="left", padx=2, pady=4) + btn_variants.append(b) + + # ── Tlačítka Beru / Přeskočit — stejný styl jako varianty ── + def beru(): + chosen["path"] = variants[current[0]]["path"] + root.destroy() + + def preskocit(): + root.destroy() + + tk.Button( + frame_top, + text="✓ Tohle beru\n", + command=beru, + bg="#2a7a2a", fg="white", + font=("Segoe UI", 9, "bold"), + relief="flat", padx=8, pady=6, + ).pack(side="left", padx=2, pady=4) + + tk.Button( + frame_top, + text="✗ Přeskočit\n", + command=preskocit, + bg="#7a2a2a", fg="white", + font=("Segoe UI", 9, "bold"), + relief="flat", padx=8, pady=6, + ).pack(side="left", padx=2, pady=4) + + # ── Navigace stran — úplně vpravo ── + btn_next_page = tk.Button( + frame_top, + text="Další ►\n", + command=lambda: show(current[0], current_page[0] + 1), + bg="#555", fg="white", + font=("Segoe UI", 9, "bold"), + relief="flat", padx=8, pady=6, + ) + btn_next_page.pack(side="right", padx=2, pady=4) + + btn_prev_page = tk.Button( + frame_top, + text="◄ Před.\n", + command=lambda: show(current[0], current_page[0] - 1), + bg="#555", fg="white", + font=("Segoe UI", 9, "bold"), + relief="flat", padx=8, pady=6, + ) + btn_prev_page.pack(side="right", padx=2, pady=4) + + # ── Obrázek ── + lbl_img = tk.Label(root, bg="black") + lbl_img.pack(fill="both", expand=True) + + root.bind("", lambda e: show(0)) + root.bind("", lambda e: show(1)) + root.bind("", lambda e: show(2)) + root.bind("", lambda e: show(3)) + root.bind("", lambda e: show(4)) + root.bind("", lambda e: beru()) + root.bind("", lambda e: preskocit()) + + show(0) + root.mainloop() + + for d in docs: + try: + d.close() + except Exception: + pass + + print(json.dumps({"chosen": chosen["path"]}, ensure_ascii=False)) + + +if __name__ == "__main__": + main()