diff --git a/Medevio/60 ScansProcessing/affd3ab5-fa29-4e8c-8555-c1374d4d9cc8.jpeg b/Medevio/60 ScansProcessing/Testy/affd3ab5-fa29-4e8c-8555-c1374d4d9cc8.jpeg similarity index 100% rename from Medevio/60 ScansProcessing/affd3ab5-fa29-4e8c-8555-c1374d4d9cc8.jpeg rename to Medevio/60 ScansProcessing/Testy/affd3ab5-fa29-4e8c-8555-c1374d4d9cc8.jpeg diff --git a/Medevio/60 ScansProcessing/Testy/duplicity_viewer.py b/Medevio/60 ScansProcessing/Testy/duplicity_viewer.py new file mode 100644 index 0000000..677360b --- /dev/null +++ b/Medevio/60 ScansProcessing/Testy/duplicity_viewer.py @@ -0,0 +1,291 @@ +""" +Kombinovaný viewer: náhled originálu + náhled duplicit + listbox duplicit. +Nahrazuje preview_viewer.py pro hlavní dokument. + +Spouští se jako subprocess z extract_patient_info_novy_test.py. +Argumenty: duplicity_viewer.py [--write-geometry=] + +JSON vstup: { + "original": "cesta/k/originalu.pdf", # povinné + "duplicity": ["cesta1.pdf", ...], # volitelné, může být prázdné + "labels": ["název1.pdf", ...] # zobrazované názvy v listboxu +} + +Výstup geometrie (spodní hrana) do --write-geometry souboru pro rename_dialog. +""" +import json +import sys +from pathlib import Path +import tkinter as tk + +if sys.platform == "win32": + try: + from ctypes import windll + windll.shcore.SetProcessDpiAwareness(1) + except Exception: + pass + import io + sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace") + sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace") + + +def _open_doc(path_str: str): + """Otevře PDF nebo obrázek, vrátí (fitz.Document nebo None, PIL.Image nebo None).""" + import fitz + from PIL import Image + p = Path(path_str) + if not p.exists(): + return None, None + suffix = p.suffix.lower() + if suffix in (".jpg", ".jpeg", ".png"): + return None, Image.open(p) + return fitz.open(str(p)), None + + +def _render_page(doc, pil_img, page_n: int, max_w: int, max_h: int): + """Vykreslí stránku, vrátí PIL.Image.""" + import fitz + from PIL import Image + if doc is not None: + page = doc[page_n] + zoom = min(max_w / page.rect.width, max_h / page.rect.height) + pix = page.get_pixmap(matrix=fitz.Matrix(zoom, zoom)) + return Image.frombytes("RGB", (pix.width, pix.height), pix.samples) + elif pil_img is not None: + img = pil_img.copy() + img.thumbnail((max_w, max_h)) + return img + return None + + +class PdfPane: + """Jeden panel s náhledem PDF + navigací stránek.""" + + def __init__(self, parent, max_w: int, max_h: int, bg: str = "#222"): + self.max_w = max_w + self.max_h = max_h + self.doc = None + self.pil_img = None + self.page_n = 0 + self.page_count = 1 + self.photo_ref = None + + self.frame = tk.Frame(parent, bg=bg, width=max_w) + self.frame.pack_propagate(False) + + self.lbl_title = tk.Label(self.frame, text="", bg=bg, fg="#aaa", + font=("Segoe UI", 9), wraplength=max_w - 10) + self.lbl_title.pack(pady=(4, 2)) + + self.lbl_img = tk.Label(self.frame, bg=bg) + self.lbl_img.pack(fill="both", expand=True) + + frame_nav = tk.Frame(self.frame, bg=bg) + frame_nav.pack(pady=4) + + self.lbl_page = tk.Label(frame_nav, text="", bg=bg, fg="#ccc", + font=("Segoe UI", 9)) + self.lbl_page.pack(side="left", padx=6) + + self.btn_prev = tk.Button(frame_nav, text="◄", bg="#444", fg="#fff", + relief="flat", padx=6, + command=self._prev) + self.btn_prev.pack(side="left", padx=2) + + self.btn_next = tk.Button(frame_nav, text="►", bg="#444", fg="#fff", + relief="flat", padx=6, + command=self._next) + self.btn_next.pack(side="left", padx=2) + + def load(self, path_str: str, title: str = ""): + from PIL import ImageTk + if self.doc: + try: + self.doc.close() + except Exception: + pass + self.doc, self.pil_img = _open_doc(path_str) + self.page_count = len(self.doc) if self.doc else 1 + self.lbl_title.config(text=title or Path(path_str).name, fg="#ddd") + self._show(0) + + def clear(self, msg: str = ""): + if self.doc: + try: + self.doc.close() + except Exception: + pass + self.doc = None + self.pil_img = None + self.lbl_img.config(image="") + self.lbl_title.config(text=msg, fg="#666") + self.lbl_page.config(text="") + self.btn_prev.config(state="disabled") + self.btn_next.config(state="disabled") + self.photo_ref = None + + def _show(self, n: int): + from PIL import ImageTk + self.page_n = n + img = _render_page(self.doc, self.pil_img, n, self.max_w - 10, self.max_h - 60) + if img: + self.photo_ref = ImageTk.PhotoImage(img) + self.lbl_img.config(image=self.photo_ref) + self.lbl_page.config(text=f"{n + 1} / {self.page_count}" if self.page_count > 1 else "") + self.btn_prev.config(state="normal" if n > 0 else "disabled") + self.btn_next.config(state="normal" if n < self.page_count - 1 else "disabled") + + def _prev(self): + if self.page_n > 0: + self._show(self.page_n - 1) + + def _next(self): + if self.page_n < self.page_count - 1: + self._show(self.page_n + 1) + + def close(self): + if self.doc: + try: + self.doc.close() + except Exception: + pass + + +def main(): + if len(sys.argv) < 2: + sys.exit(1) + + data = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8")) + original_path = data.get("original") or "" + duplicity_paths = data.get("duplicity") or [] + labels = data.get("labels") or [Path(p).name for p in duplicity_paths] + + write_geom = None + for arg in sys.argv: + if arg.startswith("--write-geometry="): + write_geom = Path(arg.split("=", 1)[1]) + + try: + from PIL import Image, ImageTk + import fitz + except ImportError as e: + print(f"[duplicity_viewer] Chybí knihovna: {e}", file=sys.stderr) + sys.exit(2) + + # ── Layout z JSON ───────────────────────────────────────────────────────── + sys.path.insert(0, str(Path(__file__).parent)) + try: + from window_layout import get_layout + layout = get_layout() + lw = layout.get("duplicity_viewer") or {} + except Exception: + lw = {} + + WIN_X = lw.get("x", 0) + WIN_Y = lw.get("y", 0) + WIN_W = lw.get("w", 2200) + WIN_H = lw.get("h", 1080) + + LISTBOX_W = 380 + PANE_W = (WIN_W - LISTBOX_W - 20) // 2 + PANE_H = WIN_H - 40 + + # ── Okno ────────────────────────────────────────────────────────────────── + root = tk.Tk() + root.tk.call("encoding", "system", "utf-8") + root.title("Náhled dokumentů") + root.configure(bg="#1a1a1a") + root.geometry(f"{WIN_W}x{WIN_H}+{WIN_X}+{WIN_Y}") + + # ── Tři sloupce ─────────────────────────────────────────────────────────── + # Vlevo: originál + pane_orig = PdfPane(root, max_w=PANE_W, max_h=PANE_H, bg="#1e1e2e") + pane_orig.frame.pack(side="left", fill="both", expand=False, padx=(6, 3), pady=6) + + # Uprostřed: duplicita + pane_dup = PdfPane(root, max_w=PANE_W, max_h=PANE_H, bg="#2e1e1e") + pane_dup.frame.pack(side="left", fill="both", expand=False, padx=(3, 3), pady=6) + + # Vpravo: listbox + frame_right = tk.Frame(root, bg="#1a1a1a", width=LISTBOX_W) + frame_right.pack(side="left", fill="y", padx=(3, 6), pady=6) + frame_right.pack_propagate(False) + + tk.Label(frame_right, text="Existující dokumenty:", anchor="w", + bg="#1a1a1a", fg="#cc4444", font=("Segoe UI", 9, "bold")).pack( + fill="x", padx=4, pady=(8, 2)) + + sb = tk.Scrollbar(frame_right, orient="vertical") + lb = tk.Listbox( + frame_right, + yscrollcommand=sb.set, + font=("Segoe UI", 8), + selectmode="single", + activestyle="dotbox", + bg="#2a1a1a", + fg="#ddd", + selectbackground="#cc4444", + selectforeground="#fff", + cursor="hand2", + ) + sb.config(command=lb.yview) + sb.pack(side="right", fill="y") + lb.pack(side="left", fill="both", expand=True, padx=(4, 0)) + + if not duplicity_paths: + lb.insert(tk.END, "(žádné duplicity)") + lb.config(state="disabled") + else: + for label in labels: + lb.insert(tk.END, Path(label).name if Path(label).exists() else label) + + # ── Načti originál ──────────────────────────────────────────────────────── + if original_path and Path(original_path).exists(): + pane_orig.load(original_path, title=Path(original_path).name) + else: + pane_orig.clear(msg="(originál nedostupný)") + + pane_dup.clear(msg="← vyberte duplicitu vlevo" if duplicity_paths else "(žádné duplicity)") + + # ── Výběr duplicity ─────────────────────────────────────────────────────── + def on_select(event): + sel = lb.curselection() + if not sel: + return + idx = sel[0] + p = duplicity_paths[idx] + pane_dup.load(p, title=labels[idx] if idx < len(labels) else Path(p).name) + + lb.bind("<>", on_select) + + # Automaticky vyber první duplicitu + if duplicity_paths: + lb.selection_set(0) + lb.event_generate("<>") + + # ── Zavření ─────────────────────────────────────────────────────────────── + def on_close(): + pane_orig.close() + pane_dup.close() + root.destroy() + + root.protocol("WM_DELETE_WINDOW", on_close) + + # ── Geometrie pro rename_dialog ─────────────────────────────────────────── + root.update_idletasks() + if write_geom: + _y = root.winfo_y() + _h = root.winfo_height() + write_geom.write_text( + json.dumps({"x": WIN_X, "y": _y, "w": WIN_W, "h": _h}), + encoding="utf-8" + ) + + root.lift() + root.attributes("-topmost", True) + root.after(1500, lambda: root.attributes("-topmost", False)) + root.mainloop() + + +if __name__ == "__main__": + main() diff --git a/Medevio/60 ScansProcessing/Testy/extract_patient_info_novy.py b/Medevio/60 ScansProcessing/Testy/extract_patient_info_novy.py new file mode 100644 index 0000000..38d7a36 --- /dev/null +++ b/Medevio/60 ScansProcessing/Testy/extract_patient_info_novy.py @@ -0,0 +1,763 @@ +""" +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 + +FB_CONFIG = { + 'dsn': r'reporter:c:\medicus\medicus.fdb', + 'user': 'SYSDBA', + 'password': 'masterkey', + 'charset': 'win1250', +} + +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() + +def _find_poppler() -> str | None: + candidates = [ + r"C:/Poppler/Library/bin", + str(Path.home() / r"scoop\apps\poppler\current\Library\bin"), + str(Path.home() / r"scoop\apps\poppler\current\bin"), + ] + for p in candidates: + if Path(p).exists(): + return p + pdfinfo = shutil.which("pdfinfo") + if pdfinfo: + return str(Path(pdfinfo).parent) + return None + +POPPLER_PATH = _find_poppler() +CORRECTIONS = True # True = corrections.json se načítá a ukládá; False = ignorovat + +_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" +NAMING_RULES_FILE = Path(__file__).parent / "naming_rules.md" +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 set_single_page_view(pdf_path: Path): + from pikepdf import Pdf, Name + with Pdf.open(str(pdf_path), allow_overwriting_input=True) as pdf: + pdf.Root.PageLayout = Name("/SinglePage") + pdf.Root.PageMode = Name("/UseNone") + pdf.save() + + +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 + return fdb.connect(**FB_CONFIG) + 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 _parse_jmeno_prijmeni(name_str: str) -> tuple[str, str] | None: + """Parsuje 'Příjmení, Jméno' nebo 'Příjmení Jméno' -> (prijmeni, jmeno).""" + name_str = name_str.strip() + if "," in name_str: + parts = [p.strip() for p in name_str.split(",", 1)] + if len(parts) == 2 and parts[0] and parts[1]: + return parts[0], parts[1] + parts = name_str.split() + if len(parts) >= 2: + return parts[0], " ".join(parts[1:]) + return None + +def _lookup_by_name(cur, prijmeni: str, jmeno: str) -> list[dict]: + """Vyhledá pacienty v KAR podle příjmení a prvního slova jména.""" + jmeno_first = jmeno.split()[0] if jmeno.split() else jmeno + cur.execute( + "SELECT IDPAC, PRIJMENI, JMENO, RODCIS FROM KAR " + "WHERE UPPER(PRIJMENI) = UPPER(?)", + (prijmeni,) + ) + rows = cur.fetchall() + result = [] + for row in rows: + db_jmeno = (row[2] or "").strip().upper() + if db_jmeno.startswith(jmeno_first.upper()): + result.append({ + "idpac": row[0], + "prijmeni": row[1].strip(), + "jmeno": row[2].strip(), + "rodcis": row[3].strip(), + }) + return result + +def verify_patient_by_name(name_str: str) -> dict: + """Vyhledá pacienta v Medicus podle jména — fallback když RČ chybí.""" + parsed = _parse_jmeno_prijmeni(name_str) + if not parsed: + return {"status": "not_found", "patient": None, "rc_corrected": None} + prijmeni, jmeno = parsed + con = _medicus_connect() + if con is None: + return {"status": "offline", "patient": None, "rc_corrected": None} + try: + cur = con.cursor() + matches = _lookup_by_name(cur, prijmeni, jmeno) + if not matches: + return {"status": "not_found", "patient": None, "rc_corrected": None} + if len(matches) == 1: + return {"status": "by_name", "patient": matches[0], "rc_corrected": None} + return {"status": "by_name_multi", "patient": matches[0], "rc_corrected": None, "all_matches": matches} + finally: + con.close() + +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)] + + +# ─── EKG zpracování ────────────────────────────────────────────────────────── + +_EKG_FLAG = "rotated-by-script" + + +def _is_ekg(pdf_path: Path) -> bool: + """Detekuje EKG PDF podle metadat — PDFCreator 2.4.x je specifický pro EKG přístroj.""" + if pdf_path.suffix.lower() != ".pdf": + return False + try: + import fitz + doc = fitz.open(str(pdf_path)) + meta = doc.metadata + doc.close() + haystack = " ".join(filter(None, [ + meta.get("creator", ""), meta.get("producer", "") + ])).lower() + return "pdfcreator" in haystack + except Exception: + return False + + +def _ekg_rotate_if_needed(pdf_path: Path): + """Otočí první stránku o 90° CW a odstraní případnou druhou stránku.""" + import fitz + doc = fitz.open(str(pdf_path)) + meta = doc.metadata + keywords = meta.get("keywords", "") or "" + if _EKG_FLAG in keywords: + doc.close() + return + page = doc[0] + page.set_rotation((page.rotation + 90) % 360) + if doc.page_count > 1: + doc.delete_page(1) + meta["keywords"] = (keywords + " " + _EKG_FLAG).strip() + doc.set_metadata(meta) + tmp = pdf_path.with_suffix(".tmp.pdf") + doc.save(tmp, deflate=True) + doc.close() + os.replace(tmp, pdf_path) + print(" [EKG] Stránka otočena o 90°.") + + +def _ekg_ocr(pdf_path: Path) -> str: + import fitz + import pytesseract + from PIL import Image as _PILImage + pytesseract.pytesseract.tesseract_cmd = r"C:\Program Files\Tesseract-OCR\tesseract.exe" + doc = fitz.open(str(pdf_path)) + pix = doc[0].get_pixmap(dpi=300) + img = _PILImage.frombytes("RGB", [pix.width, pix.height], pix.samples) + doc.close() + return pytesseract.image_to_string(img, lang="ces", config="--psm 6") + + +def _ekg_extract_rc(text: str) -> str | None: + m = re.search(r"(\d{6})\s*/?\s*(\d{3,4})", text) + if not m: + return None + return m.group(1) + m.group(2).zfill(4) + + +def _ekg_extract_date(text: str) -> str | None: + """Vrátí datum ve formátu YYYY-MM-DD nebo None.""" + m = re.search(r"(\d{1,2})[\.,]\s*(\d{1,2})[\.,]\s*(\d{4})", text) + if m: + d, mo, y = m.groups() + return f"{y}-{mo.zfill(2)}-{d.zfill(2)}" + for pat in [r"\b(\d{2})(\d{2})(\d{4})\b", r"\b(\d{2})(\d{1})(\d{4})\b"]: + for m in re.finditer(pat, text): + d, mo, y = m.groups() + if 1 <= int(d) <= 31 and 1 <= int(mo) <= 12 and 1900 <= int(y) <= 2100: + return f"{y}-{mo.zfill(2)}-{d.zfill(2)}" + return None + + +def extract_info_ekg(pdf_path: Path) -> dict: + """EKG větev: rotace in-place, Tesseract OCR, Medicus ověření.""" + _ekg_rotate_if_needed(pdf_path) + + print(" [EKG] OCR přes Tesseract...") + raw_text = _ekg_ocr(pdf_path) + print(f"\n--- EKG OCR TEXT ---\n{raw_text}\n--- KONEC ---\n") + + rc_ocr = _ekg_extract_rc(raw_text) + date_iso = _ekg_extract_date(raw_text) + print(f" [EKG] RČ: {rc_ocr or 'NENALEZENO'} | Datum: {date_iso or 'NENALEZENO'}") + + print(f" [EKG] Ověřuji v Medicus (RČ: {rc_ocr or '?'})...") + verif = verify_patient(rc_ocr or "") + rc_final = rc_ocr + if verif["status"] == "fuzzy" and verif.get("rc_corrected"): + rc_final = verif["rc_corrected"] + print(f" [EKG] RČ opraveno: {rc_ocr} → {rc_final}") + + patient = verif.get("patient") + name_part = f"{patient['prijmeni']}, {patient['jmeno']}" if patient else "" + + if rc_final and date_iso: + nazev = f"{rc_final} {date_iso}{(' ' + name_part) if name_part else ''} [EKG] [bez hodnocení].pdf" + else: + nazev = None + + return { + "rodne_cislo": rc_final, + "datum_zpravy": date_iso, + "nazev_souboru": nazev, + "_verif": verif, + "_rc_ocr": rc_ocr or "", + } + + +# ─── Korekce (few-shot příklady) ───────────────────────────────────────────── + +def load_corrections() -> list[dict]: + if not CORRECTIONS_FILE.exists(): + return [] + content = CORRECTIONS_FILE.read_text(encoding="utf-8") + idx = content.find('[') + if idx < 0: + return [] + try: + result = json.loads(content[idx:]) + # Pokud byl soubor poškozený (garbage před [), přepiš ho čistě + if idx > 0: + CORRECTIONS_FILE.write_text( + json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8" + ) + return result + except json.JSONDecodeError: + return [] + +def save_correction(original: str, corrected: str): + if not CORRECTIONS: + return + 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 load_naming_rules() -> str: + if NAMING_RULES_FILE.exists(): + content = NAMING_RULES_FILE.read_text(encoding="utf-8").strip() + if content: + return f"Pravidla pro pojmenování souborů (dodržuj vždy):\n{content}\n\n" + return "" + +def build_corrections_prompt() -> str: + if not CORRECTIONS: + return "" + 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, known_patient: str | None = None, known_rc: str | None = None) -> 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: + from PIL import Image as _PILImage + _PILImage.MAX_IMAGE_PIXELS = None # vypni limit pro naše vlastní PDFs + 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") + + if known_patient and known_rc: + # Identita pacienta je známa z názvu souboru — Claude se soustředí jen na obsah zprávy + patient_hint = ( + f"Pacient je již znám: RČ={known_rc}, jméno={known_patient}. " + f"Pole \"jmeno\" nastav na \"{known_patient}\" a \"rodne_cislo\" na \"{known_rc}\". " + f"Soustřeď se hlavně na datum zprávy, typ dokumentu a klinickou poznámku.\n" + ) + nazev_format = ( + f"\"{known_rc} {{datum_zpravy}} {known_patient} [{{typ_dokumentu}}] [{{poznamka}}].pdf\"" + ) + else: + patient_hint = "" + nazev_format = ( + "\"{rodne_cislo} {datum_zpravy} {Příjmení}, {Jméno} [{typ_dokumentu}] [{poznamka}].pdf\" " + "(jméno bez titulu, RČ bez lomítka)" + ) + + prompt = ( + load_naming_rules() + + build_corrections_prompt() + + patient_hint + + "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.\n" + f"- \"nazev_souboru\": název souboru ve formátu {nazev_format}\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...") + try: + 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} + except Exception as e: + print(f" VAROVÁNÍ: Claude API selhalo ({e}) — otevírám dialog pro ruční vyplnění.") + return {"nazev_souboru": None} + + +# ─── 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}"], + stderr=subprocess.PIPE, + ) + 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}") + env = {**os.environ, "PYTHONIOENCODING": "utf-8"} + proc = subprocess.run(args, capture_output=True, text=True, encoding="utf-8", env=env) + tmp.unlink(missing_ok=True) + if proc.stderr.strip(): + print(f" [rename_dialog] CHYBA:\n{proc.stderr.strip()}") + 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) + if proc.returncode != 0 or not proc.stdout.strip(): + print(f" [variant_picker] returncode={proc.returncode}") + if proc.stderr.strip(): + print(f" [variant_picker] CHYBA:\n{proc.stderr.strip()}") + out = proc.stdout.strip() + return json.loads(out).get("chosen") if out else None + + +# ─── Detekce split názvu ────────────────────────────────────────────────────── + +# Vzor: "7952090443 Kalousová, Eva split_001.pdf" +_SPLIT_RE = re.compile(r"^(\d{9,10})\s+(.+?)\s+split_\d+\.pdf$", re.IGNORECASE) + +def _parse_split_filename(name: str) -> tuple[str, str] | None: + """Vrátí (rc_digits, 'Příjmení, Jméno') nebo None.""" + m = _SPLIT_RE.match(name) + if m: + return m.group(1), m.group(2) + return 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/OCR + start_dokumentace_index() + + is_ekg = _is_ekg(pdf_path) + split = None + + if is_ekg: + # EKG větev: rotace in-place PŘED preview, pak Tesseract OCR + Medicus + print(" [EKG] Detekován EKG soubor (PDFCreator).") + info = extract_info_ekg(pdf_path) + nazev = info.get("nazev_souboru") or pdf_path.name + rc_from_scan = re.sub(r"\D", "", info.get("rodne_cislo") or "") + verif = info["_verif"] + rc_ocr = info["_rc_ocr"] + + # 1. Otevři preview (pro EKG: soubor je již otočen) + preview, geom_file = open_preview(pdf_path) + below_y = read_preview_bottom(geom_file) + + if not is_ekg: + # 2. Zjisti RČ a jméno — buď z názvu (split soubor) nebo přes Claude Vision API + split = _parse_split_filename(pdf_path.name) + if split: + rc_from_scan, name_from_filename = split + print(f" Split soubor — RČ z názvu: {rc_from_scan}, jméno: {name_from_filename}") + info = extract_info(pdf_path, known_patient=name_from_filename, known_rc=rc_from_scan) + nazev = info.get("nazev_souboru") or pdf_path.name + nazev = re.sub(r"^\d{9,10}\s+", f"{rc_from_scan} ", nazev) + else: + info = extract_info(pdf_path) + nazev = info.get("nazev_souboru") or pdf_path.name + rc_from_scan = re.sub(r"\D", "", info.get("rodne_cislo") or "") + + # 3. Medicus ověření + fuzzy matching RČ + print(f" Ověřuji v Medicus (RČ: {rc_from_scan})...") + verif = verify_patient(rc_from_scan) + rc_ocr = rc_from_scan + + # Oprava RČ při fuzzy matchi (jen pro nesplit soubory — u split máme RC spolehlivé) + if not split and 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']}") + + # Fallback: RČ nenalezeno → zkus vyhledat podle jména z Claude + if verif["status"] == "not_found" and info.get("jmeno"): + jmeno_z_claude = info["jmeno"] + print(f" RČ nenalezeno, zkouším vyhledat dle jména: {jmeno_z_claude}") + verif_name = verify_patient_by_name(jmeno_z_claude) + if verif_name["status"] in ("by_name", "by_name_multi"): + verif = verif_name + rc_z_medicus = re.sub(r"\D", "", verif["patient"]["rodcis"]) + p = verif["patient"] + print(f" → Nalezeno dle jména: {p['prijmeni']} {p['jmeno']} | RČ {p['rodcis']}") + if verif_name["status"] == "by_name_multi": + print(f" ⚠ Více shod ({len(verif_name['all_matches'])}) — přijat první výsledek") + # Aktualizuj RČ v navrženém názvu + if nazev and rc_z_medicus: + nazev = re.sub(r"^null\s*", rc_z_medicus + " ", nazev) + if not nazev.startswith(rc_z_medicus): + nazev = re.sub(r"^\S+\s*", rc_z_medicus + " ", nazev) + + # Info řádky pro dialog + status = verif["status"] + patient = verif.get("patient") + info_lines = [] + if is_ekg: + info_lines.append("⚡ EKG soubor — Tesseract OCR") + elif split: + info_lines.append(f"⚡ Split soubor — identita z názvu: {name_from_filename} | RČ {rc_from_scan}") + 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_ocr}' → opraveno na {verif['rc_corrected']}") + info_lines.append(f" Pacient: {patient['prijmeni']} {patient['jmeno']} | RČ {patient['rodcis']}") + elif status == "by_name": + info_lines.append(f"✓ Nalezeno dle jména: {patient['prijmeni']} {patient['jmeno']} | RČ {patient['rodcis']}") + elif status == "by_name_multi": + count = len(verif.get("all_matches", [])) + info_lines.append(f"⚠ Nalezeno dle jména ({count} shod, 1. výsledek): {patient['prijmeni']} {patient['jmeno']} | RČ {patient['rodcis']}") + elif status == "not_found": + info_lines.append(f"✗ RČ '{rc_ocr}' 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 = ["[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() + stderr_out = preview.stderr.read().decode("utf-8", errors="replace").strip() if preview.stderr else "" + if stderr_out: + print(f" [preview] CHYBA: {stderr_out}") + + 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" Přepisuji existující: {dest.name}") + shutil.copy2(chosen, dest) + set_single_page_view(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/Medevio/60 ScansProcessing/jpg_to_pdf.py b/Medevio/60 ScansProcessing/Testy/jpg_to_pdf.py similarity index 100% rename from Medevio/60 ScansProcessing/jpg_to_pdf.py rename to Medevio/60 ScansProcessing/Testy/jpg_to_pdf.py diff --git a/Medevio/60 ScansProcessing/Testy/main_viewer.py b/Medevio/60 ScansProcessing/Testy/main_viewer.py new file mode 100644 index 0000000..c3cdac2 --- /dev/null +++ b/Medevio/60 ScansProcessing/Testy/main_viewer.py @@ -0,0 +1,462 @@ +""" +Hlavní okno pro zpracování naskenovaných dokumentů. +Kombinuje náhled originálu, náhled duplicit, listbox duplicit a rename panel. + +Spouští se jako subprocess z extract_patient_info_novy_test.py. +Argumenty: main_viewer.py + +JSON vstup: { + "original": "cesta/k/originalu.pdf", + "duplicity": ["cesta1.pdf", ...], # plné cesty + "labels": ["název1.pdf", ...], # zobrazované názvy + "nazev": "navrzeny_nazev.pdf", + "info_lines": ["✓ Medicus: ...", ...], + "varianty": ["varianta1.pdf", ...] +} +JSON výstup (stdout): { "value": "schvaleny nazev" } nebo { "value": null } +""" +import json +import sys +from pathlib import Path +import tkinter as tk +from tkinter import font as tkfont + +if sys.platform == "win32": + try: + from ctypes import windll + windll.shcore.SetProcessDpiAwareness(1) + except Exception: + pass + + +# ── PDF rendering ───────────────────────────────────────────────────────────── + +def _open_doc(path_str: str): + import fitz + from PIL import Image + p = Path(path_str) + if not p.exists(): + return None, None + suffix = p.suffix.lower() + if suffix in (".jpg", ".jpeg", ".png"): + return None, Image.open(p) + return fitz.open(str(p)), None + + +def _render(doc, pil_img, page_n: int, max_w: int, max_h: int): + import fitz + from PIL import Image + if doc is not None: + page = doc[page_n] + zoom = min(max_w / page.rect.width, max_h / page.rect.height) + pix = page.get_pixmap(matrix=fitz.Matrix(zoom, zoom)) + return Image.frombytes("RGB", (pix.width, pix.height), pix.samples) + elif pil_img is not None: + img = pil_img.copy() + img.thumbnail((max_w, max_h)) + return img + return None + + +class PdfPane: + def __init__(self, parent, max_w: int, max_h: int, bg: str = "#1e1e2e", label_text: str = ""): + self.max_w = max_w + self.max_h = max_h + self.doc = None + self.pil_img = None + self.page_n = 0 + self.page_count = 1 + self.photo_ref = None + + self.frame = tk.Frame(parent, bg=bg, width=max_w, height=max_h) + self.frame.pack_propagate(False) + + if label_text: + tk.Label(self.frame, text=label_text, bg=bg, fg="#888", + font=("Segoe UI", 8, "bold")).pack(pady=(2, 0)) + + self.lbl_title = tk.Label(self.frame, text="", bg=bg, fg="#aaa", + font=("Segoe UI", 8), wraplength=max_w - 10) + self.lbl_title.pack(pady=(2, 0)) + + self.lbl_img = tk.Label(self.frame, bg=bg) + self.lbl_img.pack(fill="both", expand=True) + + frame_nav = tk.Frame(self.frame, bg=bg) + frame_nav.pack(pady=2) + + self.lbl_page = tk.Label(frame_nav, text="", bg=bg, fg="#aaa", font=("Segoe UI", 8)) + self.lbl_page.pack(side="left", padx=4) + + self.btn_prev = tk.Button(frame_nav, text="◄", bg="#333", fg="#fff", + relief="flat", padx=4, font=("Segoe UI", 8), + command=self._prev) + self.btn_prev.pack(side="left", padx=1) + self.btn_next = tk.Button(frame_nav, text="►", bg="#333", fg="#fff", + relief="flat", padx=4, font=("Segoe UI", 8), + command=self._next) + self.btn_next.pack(side="left", padx=1) + + def load(self, path_str: str, title: str = ""): + from PIL import ImageTk + if self.doc: + try: self.doc.close() + except Exception: pass + self.doc, self.pil_img = _open_doc(path_str) + self.page_count = len(self.doc) if self.doc else 1 + self.lbl_title.config(text=title or Path(path_str).name, fg="#ccc") + self._show(0) + + def clear(self, msg: str = ""): + if self.doc: + try: self.doc.close() + except Exception: pass + self.doc = None + self.pil_img = None + self.lbl_img.config(image="") + self.lbl_title.config(text=msg, fg="#555") + self.lbl_page.config(text="") + self.btn_prev.config(state="disabled") + self.btn_next.config(state="disabled") + self.photo_ref = None + + def _show(self, n: int): + from PIL import ImageTk + self.page_n = n + img = _render(self.doc, self.pil_img, n, self.max_w - 10, self.max_h - 60) + if img: + self.photo_ref = ImageTk.PhotoImage(img) + self.lbl_img.config(image=self.photo_ref) + self.lbl_page.config(text=f"{n+1} / {self.page_count}" if self.page_count > 1 else "") + self.btn_prev.config(state="normal" if n > 0 else "disabled") + self.btn_next.config(state="normal" if n < self.page_count - 1 else "disabled") + + def _prev(self): + if self.page_n > 0: self._show(self.page_n - 1) + + def _next(self): + if self.page_n < self.page_count - 1: self._show(self.page_n + 1) + + def close(self): + if self.doc: + try: self.doc.close() + except Exception: pass + + +# ── Pomocné funkce pro layout ───────────────────────────────────────────────── + +def _get_layout() -> dict: + """Načte layout oken pro aktuální hostname z layout_settings.json.""" + import json as _json, socket as _socket + settings_file = Path(__file__).parent / "layout_settings.json" + hostname = _socket.gethostname().upper() + _default = {"duplicity_viewer": None} + if not settings_file.exists(): + return _default + try: + settings = _json.loads(settings_file.read_text(encoding="utf-8")) + return settings.get(hostname, _default) + except Exception: + return _default + + +# ── Hlavní funkce ───────────────────────────────────────────────────────────── + +def show( + original_path: str = "", + duplicity_paths: list = None, + labels: list = None, + nazev: str = "", + info_lines: list = None, + varianty: list = None, +) -> str | None: + """ + Zobrazí hlavní viewer přímo (bez subprocesů). + Vrátí schválený název souboru (bez .pdf) nebo None. + """ + duplicity_paths = duplicity_paths or [] + labels = labels or [Path(p).name for p in duplicity_paths] + info_lines = info_lines or [] + varianty = varianty or [] + + result = {"value": None} + + try: + from PIL import Image, ImageTk + import fitz + except ImportError as e: + print(f"[main_viewer] Chybí knihovna: {e}", file=sys.stderr) + return None + + # ── Layout ──────────────────────────────────────────────────────────────── + try: + lw = _get_layout().get("duplicity_viewer") or {} + except Exception: + lw = {} + + WIN_X = lw.get("x", 0) + WIN_Y = lw.get("y", 0) + WIN_W = lw.get("w", 3840) + WIN_H = lw.get("h", 1700) + + BOTTOM_H = 260 # výška spodního panelu + TOP_H = WIN_H - BOTTOM_H - 8 + GAP = 4 # mezera mezi náhledy (zmenšená) + LISTBOX_W = 560 # širší listbox duplicit + PANE_W = (WIN_W - LISTBOX_W - GAP - 20) // 2 + + BG = "#1a1a1a" # jednotné tmavé pozadí pro celé okno + COL_INFO = int(WIN_W * 0.15) + COL_MID = int(WIN_W * 0.45) + COL_VAR = WIN_W - COL_INFO - COL_MID + + # ── Okno ────────────────────────────────────────────────────────────────── + root = tk.Tk() + root.tk.call("encoding", "system", "utf-8") + root.title("Zpracování dokumentu") + root.configure(bg=BG) + root.geometry(f"{WIN_W}x{WIN_H}+{WIN_X}+{WIN_Y}") + root.resizable(True, True) + + # ═══════════════════════════════════════════════════════════════════════════ + # HORNÍ ČÁST — náhledy + listbox + # ═══════════════════════════════════════════════════════════════════════════ + frame_top = tk.Frame(root, bg=BG, height=TOP_H) + frame_top.pack(side="top", fill="x", expand=False) + frame_top.pack_propagate(False) + + # Náhled originálu + pane_orig = PdfPane(frame_top, max_w=PANE_W, max_h=TOP_H, + bg=BG, label_text="ORIGINÁL") + pane_orig.frame.pack(side="left", fill="y", padx=(6, GAP), pady=4) + + # Náhled duplicity + pane_dup = PdfPane(frame_top, max_w=PANE_W, max_h=TOP_H, + bg=BG, label_text="DUPLICITA") + pane_dup.frame.pack(side="left", fill="y", padx=(GAP, GAP), pady=4) + + # Listbox duplicit vpravo + frame_lb = tk.Frame(frame_top, bg=BG, width=LISTBOX_W) + frame_lb.pack(side="left", fill="y", padx=(3, 6), pady=4) + frame_lb.pack_propagate(False) + + tk.Label(frame_lb, text="Existující dokumenty:", anchor="w", + bg=BG, fg="#cc4444", font=("Segoe UI", 9, "bold")).pack( + fill="x", padx=6, pady=(8, 2)) + + sb_dup = tk.Scrollbar(frame_lb, orient="vertical") + txt_dup = tk.Text( + frame_lb, yscrollcommand=sb_dup.set, + font=("Segoe UI", 9), bg=BG, fg="#ddd", + bd=0, highlightthickness=0, relief="flat", + wrap="word", cursor="hand2", state="normal", + selectbackground="#ffffff", selectforeground="#000000", + ) + sb_dup.config(command=txt_dup.yview) + sb_dup.pack(side="right", fill="y") + txt_dup.pack(side="left", fill="both", expand=True, padx=(6, 0)) + + # Tag pro výběr (zvýraznění vybraného řádku) + txt_dup.tag_config("selected", background="#ffffff", foreground="#000000") + txt_dup.tag_config("normal", background=BG, foreground="#ddd") + + def _shorten_dup_label(name: str) -> str: + import re as _re + m = _re.match(r"\d{9,10}\s+(\d{4}-\d{2}-\d{2})\s+[^[]+(\[.+)", name) + if m: + return f"{m.group(1)} {m.group(2)}" + return name + + dup_line_indices = [] # (line_start, line_end) pro každou duplicitu + + if not duplicity_paths: + txt_dup.insert("end", "(žádné duplicity)") + txt_dup.config(state="disabled") + else: + for i, label in enumerate(labels): + short = _shorten_dup_label(Path(label).name if not Path(label).exists() else label) + line_start = txt_dup.index("end") + txt_dup.insert("end", short + "\n\n") + line_end = txt_dup.index("end") + dup_line_indices.append((line_start, line_end)) + txt_dup.tag_add("normal", line_start, line_end) + txt_dup.config(state="disabled") + + selected_dup = [None] + + def _dup_click(event): + txt_dup.config(state="normal") + idx = txt_dup.index(f"@{event.x},{event.y}") + for i, (ls, le) in enumerate(dup_line_indices): + if txt_dup.compare(ls, "<=", idx) and txt_dup.compare(idx, "<", le): + # Odznač předchozí + if selected_dup[0] is not None: + prev_ls, prev_le = dup_line_indices[selected_dup[0]] + txt_dup.tag_remove("selected", prev_ls, prev_le) + txt_dup.tag_add("normal", prev_ls, prev_le) + # Označ nový + txt_dup.tag_remove("normal", ls, le) + txt_dup.tag_add("selected", ls, le) + selected_dup[0] = i + txt_dup.config(state="disabled") + # Načti PDF + if i < len(duplicity_paths): + pane_dup.load(duplicity_paths[i], + title=labels[i] if i < len(labels) else "") + return + txt_dup.config(state="disabled") + + txt_dup.bind("", _dup_click) + + # Oddělovač + tk.Frame(root, bg="#333", height=2).pack(fill="x") + + # ═══════════════════════════════════════════════════════════════════════════ + # SPODNÍ ČÁST — info | entry+tlačítka | návrhy + # ═══════════════════════════════════════════════════════════════════════════ + frame_bot = tk.Frame(root, bg=BG, height=BOTTOM_H) + frame_bot.pack(side="top", fill="both", expand=True) + frame_bot.pack_propagate(False) + + # ── Vlevo: info o pacientovi (15%) ──────────────────────────────────────── + frame_info = tk.Frame(frame_bot, bg=BG, width=COL_INFO) + frame_info.pack(side="left", fill="y", padx=(10, 4), pady=8) + frame_info.pack_propagate(False) + + tk.Label(frame_info, text="Informace o pacientovi", anchor="w", + bg=BG, fg="#888", font=("Segoe UI", 8, "bold")).pack(fill="x") + + for line in info_lines: + color = "#cc4444" if line.startswith("⚠") or line.startswith("✗") else \ + "#44cc44" if line.startswith("✓") else "#aaa" + tk.Label(frame_info, text=line, anchor="w", bg=BG, + fg=color, font=("Segoe UI", 9), wraplength=COL_INFO - 20, + justify="left").pack(fill="x", pady=1) + + # ── Uprostřed: název + tlačítka (45%) ──────────────────────────────────── + frame_mid = tk.Frame(frame_bot, bg=BG, width=COL_MID) + frame_mid.pack(side="left", fill="y", padx=(4, 20), pady=8) + frame_mid.pack_propagate(False) + + tk.Label(frame_mid, text="Název souboru (bez .pdf):", anchor="w", + bg=BG, fg="#888", font=("Segoe UI", 8, "bold")).pack(fill="x") + + nazev_bez = nazev[:-4] if nazev.endswith(".pdf") else nazev + + # Multiline Text widget — zalamuje dlouhé názvy + txt = tk.Text(frame_mid, font=("Segoe UI", 10), bg="#ffffff", fg="#000000", + insertbackground="#000000", relief="flat", height=3, + wrap="word", padx=6, pady=4, bd=0, highlightthickness=0) + txt.pack(fill="x", pady=(4, 0)) + txt.insert("1.0", nazev_bez) + txt.mark_set("insert", "end") + txt.focus_set() + + def get_txt_value() -> str: + return txt.get("1.0", "end").strip() + + frame_btn = tk.Frame(frame_mid, bg=BG) + frame_btn.pack(pady=(6, 0)) + + def schvalit(event=None): + result["value"] = get_txt_value() + pane_orig.close() + pane_dup.close() + root.destroy() + + def preskocit(event=None): + result["value"] = None + pane_orig.close() + pane_dup.close() + 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, relief="flat").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, relief="flat").pack(side="left", padx=8) + + # Enter schvalí jen pokud není focus v Text widgetu (tam Enter = nový řádek) + root.bind("", preskocit) + # Ctrl+Enter vždy schválí + root.bind("", schvalit) + + # ── Vpravo: návrhy od Claudea (40%) ────────────────────────────────────── + frame_right = tk.Frame(frame_bot, bg=BG, width=COL_VAR) + frame_right.pack(side="left", fill="y", padx=(4, 10), pady=8) + frame_right.pack_propagate(False) + + tk.Label(frame_right, text="Návrhy pojmenování (kliknutím vyberte):", anchor="w", + bg=BG, fg="#888", font=("Segoe UI", 8, "bold")).pack(fill="x") + + sb_var = tk.Scrollbar(frame_right, orient="vertical") + lb_var = tk.Listbox( + frame_right, yscrollcommand=sb_var.set, + font=("Segoe UI", 10), selectmode="single", activestyle="none", + bg=BG, fg="#ddd", bd=0, highlightthickness=0, + selectbackground="#ffffff", selectforeground="#000000", + cursor="hand2", + ) + sb_var.config(command=lb_var.yview) + sb_var.pack(side="right", fill="y") + lb_var.pack(side="left", fill="both", expand=True) + + for v in varianty: + v_bez = v[:-4] if v.endswith(".pdf") else v + lb_var.insert(tk.END, v_bez) + + def on_varianta(event): + sel = lb_var.curselection() + if sel: + txt.delete("1.0", "end") + txt.insert("1.0", lb_var.get(sel[0])) + + lb_var.bind("<>", on_varianta) + + # ═══════════════════════════════════════════════════════════════════════════ + # Načtení dokumentů + # ═══════════════════════════════════════════════════════════════════════════ + if original_path and Path(original_path).exists(): + pane_orig.load(original_path, title=Path(original_path).name) + else: + pane_orig.clear(msg="(originál nedostupný)") + + pane_dup.clear(msg="← vyberte duplicitu" if duplicity_paths else "(žádné duplicity)") + + # Automaticky zobraz první duplicitu + if duplicity_paths: + pane_dup.load(duplicity_paths[0], title=labels[0] if labels else "") + if dup_line_indices: + txt_dup.config(state="normal") + ls, le = dup_line_indices[0] + txt_dup.tag_remove("normal", ls, le) + txt_dup.tag_add("selected", ls, le) + selected_dup[0] = 0 + txt_dup.config(state="disabled") + + # ── Zobrazení ───────────────────────────────────────────────────────────── + root.protocol("WM_DELETE_WINDOW", preskocit) + root.lift() + root.attributes("-topmost", True) + root.after(1500, lambda: root.attributes("-topmost", False)) + root.mainloop() + + return result["value"] + + +if __name__ == "__main__": + # Subprocess mód — čte data z JSON souboru předaného jako argument + 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")) + value = show( + original_path = data.get("original") or "", + duplicity_paths = data.get("duplicity") or [], + labels = data.get("labels") or [], + nazev = data.get("nazev") or "", + info_lines = data.get("info_lines") or [], + varianty = data.get("varianty") or [], + ) + print(json.dumps({"value": value}, ensure_ascii=False)) diff --git a/Medevio/60 ScansProcessing/Testy/monitory.py b/Medevio/60 ScansProcessing/Testy/monitory.py new file mode 100644 index 0000000..b957a78 --- /dev/null +++ b/Medevio/60 ScansProcessing/Testy/monitory.py @@ -0,0 +1,22 @@ +""" +Test skript — zobrazí počet monitorů, jejich rozlišení a který je primární. +""" +import sys + +try: + from screeninfo import get_monitors + monitors = get_monitors() + print(f"Počet monitorů: {len(monitors)}\n") + for i, m in enumerate(monitors): + primary = " ← PRIMÁRNÍ" if getattr(m, "is_primary", False) else "" + print(f" Monitor {i+1}: {m.width}x{m.height} | pozice x={m.x}, y={m.y}{primary} | název: {getattr(m, 'name', '?')}") +except ImportError: + print("Knihovna 'screeninfo' není nainstalována — instaluji...") + import subprocess + subprocess.check_call([sys.executable, "-m", "pip", "install", "screeninfo", "--break-system-packages", "-q"]) + from screeninfo import get_monitors + monitors = get_monitors() + print(f"Počet monitorů: {len(monitors)}\n") + for i, m in enumerate(monitors): + primary = " ← PRIMÁRNÍ" if getattr(m, "is_primary", False) else "" + print(f" Monitor {i+1}: {m.width}x{m.height} | pozice x={m.x}, y={m.y}{primary} | název: {getattr(m, 'name', '?')}") diff --git a/Medevio/60 ScansProcessing/preview_viewer.py b/Medevio/60 ScansProcessing/Testy/preview_viewer.py similarity index 64% rename from Medevio/60 ScansProcessing/preview_viewer.py rename to Medevio/60 ScansProcessing/Testy/preview_viewer.py index ff57243..2c93808 100644 --- a/Medevio/60 ScansProcessing/preview_viewer.py +++ b/Medevio/60 ScansProcessing/Testy/preview_viewer.py @@ -33,6 +33,19 @@ def main(): root.tk.call("encoding", "system", "utf-8") sh = root.winfo_screenheight() + + # Zjisti cílovou šířku z layoutu (pokud existuje), jinak výchozí 700px + try: + import sys as _sys_tmp + _sys_tmp.path.insert(0, str(Path(__file__).parent)) + from window_layout import get_layout as _get_layout + _lw = _get_layout().get("preview_viewer") + RENDER_W = (_lw.get("w", 700) - 20) if _lw else 700 + RENDER_H = (_lw.get("h", sh) - 80) if _lw else (sh - 150) + except Exception: + RENDER_W = 700 + RENDER_H = sh - 150 + page_count = len(doc) if doc else 1 current = [0] photo_ref = [None] @@ -40,12 +53,12 @@ def main(): def render(n) -> Image.Image: if doc is not None: page = doc[n] - zoom = min(700 / page.rect.width, (sh - 150) / page.rect.height) + zoom = min(RENDER_W / page.rect.width, RENDER_H / page.rect.height) pix = page.get_pixmap(matrix=fitz.Matrix(zoom, zoom)) return Image.frombytes("RGB", (pix.width, pix.height), pix.samples) else: img = pil_img.copy() - img.thumbnail((700, sh - 150), Image.LANCZOS) + img.thumbnail((RENDER_W, RENDER_H), Image.LANCZOS) return img def on_close(): @@ -62,8 +75,6 @@ def main(): root.destroy() root.title(pdf_path.stem) - root.attributes("-topmost", True) - root.resizable(False, False) root.protocol("WM_DELETE_WINDOW", on_close) lbl_img = tk.Label(root) @@ -91,23 +102,41 @@ def main(): show(0) root.update_idletasks() - sw = root.winfo_screenwidth() - w = root.winfo_width() - h = root.winfo_height() - x = (sw - w) // 2 - root.geometry(f"+{x}+0") + try: + import sys as _sys + _sys.path.insert(0, str(Path(__file__).parent)) + from window_layout import get_layout, apply_geometry + _layout = get_layout() + + def _fallback_prev(): + sw = root.winfo_screenwidth() + w = root.winfo_width() + x = (sw - w) // 2 + root.geometry(f"+{x}+0") + + apply_geometry(root, _layout, "preview_viewer", fallback_fn=_fallback_prev) + except Exception: + sw = root.winfo_screenwidth() + w = root.winfo_width() + 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") + root.update_idletasks() + _x = root.winfo_x() + _y = root.winfo_y() + _w = root.winfo_width() + _h = root.winfo_height() + geom_path.write_text(_json.dumps({"x": _x, "y": _y, "w": _w, "h": _h}), encoding="utf-8") break root.lift() - root.focus_force() - root.after(100, lambda: root.focus_force()) + root.attributes("-topmost", True) + root.after(1500, lambda: root.attributes("-topmost", False)) root.mainloop() diff --git a/Medevio/60 ScansProcessing/rename_dialog.py b/Medevio/60 ScansProcessing/Testy/rename_dialog.py similarity index 100% rename from Medevio/60 ScansProcessing/rename_dialog.py rename to Medevio/60 ScansProcessing/Testy/rename_dialog.py diff --git a/Medevio/60 ScansProcessing/Testy/rename_dialog_test.py b/Medevio/60 ScansProcessing/Testy/rename_dialog_test.py new file mode 100644 index 0000000..dae7205 --- /dev/null +++ b/Medevio/60 ScansProcessing/Testy/rename_dialog_test.py @@ -0,0 +1,221 @@ +""" +Standalone dialog pro schválení / opravu názvu souboru. +Spouští se jako subprocess z extract_patient_info_novy_test.py. +Argumenty: rename_dialog_test.py +JSON vstup: { + "nazev": "...", + "info_lines": [...], + "duplicity": [...], # seznam názvů existujících souborů pro stejné RC+datum + "varianty": [...] # seznam návrhů názvu od Claude (unikátní, seřazené od nejlepší) +} +JSON výstup: { "value": "..." } nebo { "value": null } +""" +import json +import os +import sys +from pathlib import Path +import tkinter as tk +from tkinter import font as tkfont + +if sys.platform == "win32": + try: + from ctypes import windll + windll.shcore.SetProcessDpiAwareness(1) + except Exception: + pass + import io + sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace") + sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace") + + +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 [] + duplicity = data.get("duplicity") or [] + varianty = data.get("varianty") or [] + + result = {"value": None} + + root = tk.Tk() + root.title("Schválení názvu souboru") + root.resizable(True, False) + root.attributes("-topmost", True) + + pad = {"padx": 12, "pady": 4} + font_ui = ("Segoe UI", 10) + font_bold = ("Segoe UI", 9, "bold") + font_small = ("Segoe UI", 9) + + # ── 1. Info panel (Medicus status) ──────────────────────────────────────── + 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("⚠") or line.startswith("✗") else \ + "#2a7a2a" if line.startswith("✓") else "#333" + tk.Label(frame_info, text=line, anchor="w", bg="#f0f0f0", + fg=color, font=font_ui).pack(fill="x", padx=8, pady=1) + + # ── 2. Listbox 1 — duplicity ────────────────────────────────────────────── + if duplicity: + tk.Label(root, text="⚠ Již existující dokumenty pro toto datum:", + anchor="w", fg="#b00000", font=font_bold).pack(fill="x", padx=12, pady=(8, 2)) + + frame_dup = tk.Frame(root) + frame_dup.pack(fill="x", padx=12, pady=(0, 4)) + + sb_dup = tk.Scrollbar(frame_dup, orient="vertical") + lb_duplicity = tk.Listbox( + frame_dup, + yscrollcommand=sb_dup.set, + font=font_small, + height=min(len(duplicity), 4), + selectmode="single", + activestyle="dotbox", + bg="#fff8f0", + selectbackground="#e0b000", + selectforeground="#000", + ) + sb_dup.config(command=lb_duplicity.yview) + sb_dup.pack(side="right", fill="y") + lb_duplicity.pack(side="left", fill="x", expand=True) + + for d in duplicity: + lb_duplicity.insert(tk.END, d) + + # OnClick — připraveno pro budoucí funkcionalitu + def on_duplicita_click(event): + pass # TODO: budoucí akce při výběru duplicity + + lb_duplicity.bind("<>", on_duplicita_click) + + # ── 3. Listbox 2 — návrhy Claudea ───────────────────────────────────────── + nazev_bez = nazev[:-4] if nazev.endswith(".pdf") else nazev + var = tk.StringVar(value=nazev_bez) + + if varianty: + tk.Label(root, text="Návrhy pojmenování (kliknutím vyberte):", + anchor="w", font=font_bold).pack(fill="x", padx=12, pady=(8, 2)) + + frame_var = tk.Frame(root) + frame_var.pack(fill="x", padx=12, pady=(0, 4)) + + sb_var = tk.Scrollbar(frame_var, orient="vertical") + lb_varianty = tk.Listbox( + frame_var, + yscrollcommand=sb_var.set, + font=font_small, + height=min(len(varianty), 6), + selectmode="single", + activestyle="dotbox", + bg="#f0f8ff", + selectbackground="#2a7a2a", + selectforeground="#fff", + ) + sb_var.config(command=lb_varianty.yview) + sb_var.pack(side="right", fill="y") + lb_varianty.pack(side="left", fill="x", expand=True) + + for v in varianty: + v_bez = v[:-4] if v.endswith(".pdf") else v + lb_varianty.insert(tk.END, v_bez) + + # Klik → přepsat Entry + def on_varianta_click(event): + sel = lb_varianty.curselection() + if sel: + var.set(lb_varianty.get(sel[0])) + + lb_varianty.bind("<>", on_varianta_click) + + # ── 4. Entry — definitivní název ────────────────────────────────────────── + tk.Label(root, text="Název souboru (bez .pdf):", anchor="w", + font=font_bold).pack(fill="x", padx=12, pady=(10, 2)) + + entry = tk.Entry(root, textvariable=var, font=font_ui, width=135) + entry.pack(fill="x", padx=12, pady=(0, 10)) + entry.icursor(tk.END) + entry.focus_set() + + # ── 5. Tlačítka ─────────────────────────────────────────────────────────── + 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=font_ui, + padx=16, pady=6).pack(side="left", padx=8) + + root.bind("", schvalit) + root.bind("", preskocit) + + # ── Pozicování okna ─────────────────────────────────────────────────────── + root.update_idletasks() + sw = root.winfo_screenwidth() + w = root.winfo_width() + h = root.winfo_height() + x = (sw - w) // 2 + + try: + sys.path.insert(0, str(Path(__file__).parent)) + from window_layout import get_layout, apply_geometry + _layout = get_layout() + + def _fallback_dlg(): + import ctypes, ctypes.wintypes + rect = ctypes.wintypes.RECT() + ctypes.windll.user32.SystemParametersInfoW(48, 0, ctypes.byref(rect), 0) + work_bottom = rect.bottom + below_y = None + for arg in sys.argv: + if arg.startswith("--below-y="): + below_y = int(arg.split("=", 1)[1]) + break + if below_y is not None and below_y + h + 10 <= work_bottom: + y = below_y + else: + y = max(0, work_bottom - h - 10) + root.geometry(f"+{x}+{y}") + + apply_geometry(root, _layout, "rename_dialog", fallback_fn=_fallback_dlg) + except Exception: + import ctypes, ctypes.wintypes + rect = ctypes.wintypes.RECT() + ctypes.windll.user32.SystemParametersInfoW(48, 0, ctypes.byref(rect), 0) + work_bottom = rect.bottom + below_y = None + for arg in sys.argv: + if arg.startswith("--below-y="): + below_y = int(arg.split("=", 1)[1]) + break + if below_y is not None and below_y + h + 10 <= work_bottom: + y = below_y + else: + y = max(0, work_bottom - h - 10) + root.geometry(f"+{x}+{y}") + + root.lift() + root.focus_force() + root.after(100, lambda: root.focus_force()) + root.after(200, lambda: root.attributes("-topmost", True)) + root.mainloop() + + print(json.dumps({"value": result["value"]}, ensure_ascii=False)) + + +if __name__ == "__main__": + main() diff --git a/Medevio/60 ScansProcessing/test_rename_dialog.py b/Medevio/60 ScansProcessing/Testy/test_rename_dialog.py similarity index 100% rename from Medevio/60 ScansProcessing/test_rename_dialog.py rename to Medevio/60 ScansProcessing/Testy/test_rename_dialog.py diff --git a/Medevio/60 ScansProcessing/variant_picker.py b/Medevio/60 ScansProcessing/Testy/variant_picker.py similarity index 94% rename from Medevio/60 ScansProcessing/variant_picker.py rename to Medevio/60 ScansProcessing/Testy/variant_picker.py index b0e46d2..e396663 100644 --- a/Medevio/60 ScansProcessing/variant_picker.py +++ b/Medevio/60 ScansProcessing/Testy/variant_picker.py @@ -13,11 +13,8 @@ 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")) +def show(variants: list) -> str | None: + """Zobrazí picker přímo (bez subprocesů). Vrátí cestu k vybrané variantě nebo None.""" chosen = {"path": None} docs = [fitz.open(v["path"]) for v in variants] current = [0] @@ -145,7 +142,15 @@ def main(): except Exception: pass - print(json.dumps({"chosen": chosen["path"]}, ensure_ascii=False)) + return chosen["path"] + + +def main(): + if len(sys.argv) < 2: + sys.exit(1) + variants = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8")) + result = show(variants) + print(json.dumps({"chosen": result}, ensure_ascii=False)) if __name__ == "__main__": diff --git a/Medevio/60 ScansProcessing/Testy/window_layout.py b/Medevio/60 ScansProcessing/Testy/window_layout.py new file mode 100644 index 0000000..c763e3e --- /dev/null +++ b/Medevio/60 ScansProcessing/Testy/window_layout.py @@ -0,0 +1,99 @@ +""" +Pomocný modul pro pozicování oken podle hostname počítače. +Nastavení se načítá z layout_settings.json ve stejném adresáři. + +Použití: + from window_layout import get_layout + layout = get_layout() + geom = layout["preview_viewer"] # {"x": 1100, "y": 0, "w": 1400, "h": 2100} + root.geometry(f"{geom['w']}x{geom['h']}+{geom['x']}+{geom['y']}") + +Pro okna s "anchor": "bottom" (rename_dialog) použij get_bottom_y(): + y = layout["rename_dialog"]["x"] # jen x, y se počítá dynamicky +""" +import json +import socket +from pathlib import Path + +_SETTINGS_FILE = Path(__file__).parent / "layout_settings.json" + +# Výchozí fallback layout (jeden monitor, původní chování) +_DEFAULT = { + "duplicity_viewer": None, # None = nepoužívat pevnou geometrii + "preview_viewer": None, + "rename_dialog": None, +} + + +def get_hostname() -> str: + return socket.gethostname().upper() + + +def get_layout() -> dict: + """ + Vrátí slovník s geometrií oken pro aktuální počítač. + Pokud hostname není v settings, vrátí fallback (None = původní chování). + """ + hostname = get_hostname() + if not _SETTINGS_FILE.exists(): + return dict(_DEFAULT) + try: + settings = json.loads(_SETTINGS_FILE.read_text(encoding="utf-8")) + except Exception: + return dict(_DEFAULT) + return settings.get(hostname, dict(_DEFAULT)) + + +def apply_geometry(root, layout: dict, window: str, fallback_fn=None): + """ + Aplikuje geometrii okna z layoutu. + - root: tkinter Tk nebo Toplevel + - layout: výsledek get_layout() + - window: klíč ("duplicity_viewer", "preview_viewer", "rename_dialog") + - fallback_fn: callable() volaný pokud pro toto okno není layout definován + """ + geom = layout.get(window) + if not geom: + if fallback_fn: + fallback_fn() + return + + w = geom.get("w") + h = geom.get("h") + x = geom.get("x", 0) + y = geom.get("y", 0) + + if geom.get("anchor") == "bottom": + # Výška se ignoruje, okno se přilepí ke spodnímu okraji work area + import ctypes, ctypes.wintypes + rect = ctypes.wintypes.RECT() + ctypes.windll.user32.SystemParametersInfoW(48, 0, ctypes.byref(rect), 0) + root.update_idletasks() + h_actual = root.winfo_height() + y = rect.bottom - h_actual - 10 + if w: + root.geometry(f"+{x}+{y}") + else: + root.geometry(f"+{x}+{y}") + else: + if w and h: + root.geometry(f"{w}x{h}+{x}+{y}") + elif w: + root.update_idletasks() + root.geometry(f"{w}x{root.winfo_height()}+{x}+{y}") + else: + root.update_idletasks() + root.geometry(f"+{x}+{y}") + + +if __name__ == "__main__": + hostname = get_hostname() + layout = get_layout() + print(f"Hostname: {hostname}") + if all(v is None for v in layout.values()): + print("Žádné nastavení pro tento počítač — používá se výchozí chování.") + print(f"Přidej klíč '{hostname}' do {_SETTINGS_FILE}") + else: + print(f"Layout pro '{hostname}':") + for k, v in layout.items(): + print(f" {k}: {v}") diff --git a/Medevio/60 ScansProcessing/corrections.json b/Medevio/60 ScansProcessing/corrections.json index febc7e7..f6bbb9e 100644 --- a/Medevio/60 ScansProcessing/corrections.json +++ b/Medevio/60 ScansProcessing/corrections.json @@ -1674,5 +1674,73 @@ { "original": "535510353 2026-05-28 Rejfířová, Marcela [IADL test] [40b – nesoběstačnost; jízda s pomocí, nákup 0, vaření ohřev, domácí práce 0, prádlo 0, tel. 10, léky 10, finance s pomocí].pdf", "corrected": "535510353 2026-05-28 Rejfířová, Marcela [IADL test] [35b – nesoběstačnost; jízda s pomocí, nákup 0, vaření ohřev, domácí práce 0, prádlo 0, tel. 10, léky 10, finance s pomocí].pdf" + }, + { + "original": "435720013 2026-05-25 Lišková, Jaroslava [LZ kardiologie] [EKG: zrychlená Tf, bez čerstvých lož. změn, repolarizace bez poruch, bez isch.].pdf", + "corrected": "435720013 2026-05-25 Lišková, Jaroslava [LZ kardiologie] [EKG zrychlená Tf, bez čerstvých lož. změn, repolarizace bez poruch, bez isch.].pdf" + }, + { + "original": "455925093 2026-04-08 Fialová, Růžena [LZ kardiologie] [AH, non-dipper, LBBB, aort. reg. 2+, EF LK 59%, diastol. dysfunkce, TK 176/99].pdf", + "corrected": "455925093 2026-04-08 Fialová, Růžena [LZ kardiologie] [AH, non-dipper, LBBB, aort. reg. 2+, EF LK 59%, diastol. dysfunkce, TK 17699].pdf" + }, + { + "original": "475424136 2026-05-26 Müllerová, Miluše [LZ endokrinologie] [Izoechogenní uzly v PL ŠŽ, bez růst.progrese, supresní terapie, TSH <0.010].pdf", + "corrected": "475424136 2026-05-26 Müllerová, Miluše [LZ endokrinologie] [Izoechogenní uzly v PL ŠŽ, bez růst.progrese, supresní terapie, TSH 0.010].pdf" + }, + { + "original": "485507406 2026-05-25 Jourová, Eva [Laboratoř] [dg. I839 - D-dimery 0.52 mg/l FEU (nad std. cut-off 0.50, pod věk-adj. 0.78)].pdf", + "corrected": "485507406 2026-05-25 Jourová, Eva [Laboratoř] [dg. I839 - D-dimery 0.52 mgl FEU (nad std. cut-off 0.50, pod věk-adj. 0.78)].pdf" + }, + { + "original": "486111054 2026-05-22 Pelcová, Ludmila [LZ neurologie] [myasthenia gravis, AntiACHR >20 mmol/l, po plazmaferézách, Prednison 45mg, Imuran].pdf", + "corrected": "486111054 2026-05-22 Pelcová, Ludmila [LZ neurologie] [myasthenia gravis, AntiACHR 20 mmoll, po plazmaferézách, Prednison 45mg, Imuran].pdf" + }, + { + "original": "495227264 2026-05-27 Hajšmanová, Marcela [TK deník] [květen 2026, Telmisartan ratiopharm 1/2 tbl, TK ráno 120-135, večer 139-148].pdf", + "corrected": "495227264 2026-05-27 Hajšmanová, Marcela [domácí měření TK] [květen 2026, Telmisartan ratiopharm 12 tbl, TK ráno 120-135, večer 139-148].pdf" + }, + { + "original": "505516240 2022-05-04 Michková, Miroslava [LZ interna] [EKG norma, veget. stigmata, bez čerstvých ischem. změn a dysrytmií].pdf", + "corrected": "505516240 2022-05-04 Michková, Miroslava [EKG] [EKG norma, veget. stigmata, bez čerstvých ischem. změn a dysrytmií].pdf" + }, + { + "original": "5455142143 2026-05-27 Setničková, Jitka [řidičský posudek] [zdravotně způsobilá s podmínkou - brýle sk. B, platnost do 27.05.2028].pdf", + "corrected": "5455142143 2026-05-27 Setničková, Jitka [Posudek ŘP] [zdravotně způsobilá s podmínkou - brýle sk. B, platnost do 27.05.2028].pdf" + }, + { + "original": "5455142143 2026-05-27 Setničková, Jitka [Prohlášení zdravotní způsobilosti ŘP] [zdravotně způsobilá, sk. B brýle, medikace cukrovka a ŠŽ].pdf", + "corrected": "5455142143 2026-05-27 Setničková, Jitka [Prohlášení ŘP] [zdravotně způsobilá, sk. B brýle, medikace cukrovka a ŠŽ].pdf" + }, + { + "original": "6559102187 2017-03-15 Nikitina, Svitlana [EKG] [sinusový rytmus 75/min, vertikální poloha, bez sign. změn ST, fyziol. záznam].pdf", + "corrected": "6559102187 2017-03-15 Nikitina, Svitlana [EKG] [sinusový rytmus 75min, vertikální poloha, bez sign. změn ST, fyziol. záznam].pdf" + }, + { + "original": "6857112064 2026-05-26 Dundrová, Markéta [deník pacienta ABPM] [8:30 pěšky do práce, 16:10 pěšky z práce, 17:05 jízda autem, 19:25 večeře, 20:15 práce na zahradě].pdf", + "corrected": "6857112064 2026-05-26 Dundrová, Markéta [Holter TK] [].pdf" + }, + { + "original": "6907220320 2026-04-20 Výprachtický, Ondřej [LZ diabetologie] [Recentní DM 2.typu, intenzif. inzulin. režim, vstupní polyurie/polydipsie, DLP na dietě].pdf", + "corrected": "6907220320 2026-04-20 Výprachtický, Ondřej [LZ diabetologie] [Recentní DM 2.typu, intenzif. inzulin. režim, vstupní polyuriepolydipsie, DLP na dietě].pdf" + }, + { + "original": "7755230450 2025-05-26 Moučková, Eva [domácí měření TK] [deník TK a tepu, březen–květen 2025, TK 94-137/58-89, tep 52-73].pdf", + "corrected": "7755230450 2025-05-26 Moučková, Eva [domácí měření TK] [deník TK a tepu, březen–květen 2025, TK 94-13758-89, tep 52-73].pdf" + }, + { + "original": "7856230448 2024-09-30 Kulhánková, Eliška [LZ kardiologie] [EF 64%, diastol. dysfunkce pseudonorm., stopová MR/TR/PR, perikard výpotek 1mm (hypothyreosa?)].pdf", + "corrected": "7856230448 2024-09-30 Kulhánková, Eliška [LZ kardiologie] [EF 64%, diastol. dysfunkce pseudonorm., stopová MRTRPR, perikard výpotek 1mm (hypothyreosa)].pdf" + }, + { + "original": "5455142143 2026-01-26 Setničková, Jitka [LZ diabetologie] [DM2 dg 5/2019, MTF+DPP4i+gliflozin, kompenzace uspokojivá, MAU-ACR 4g/mol].pdf", + "corrected": "5455142143 2026-01-26 Setničková, Jitka [LZ diabetologie] [DM2 dg 52019, MTF+DPP4i+gliflozin, kompenzace uspokojivá, MAU-ACR 4gmol].pdf" + }, + { + "original": "5610101959 null Olach, Milan [EHIC] [OZP 20701, platnost do 25052035].pdf", + "corrected": "5610101959 2026-02-06 Olach, Milan [Evropský průkaz zdravotního pojištění] [OZP 207, platnost do 25MAY2035].pdf" + }, + { + "original": "320312460 2026-05-20 Vlachovský, Ladislav [LZ kardiologie] [kontrola, EF 65%, diastol. dysfunkce, lehká MR, RBBB, kontrola za 6 měs.].pdf", + "corrected": "320312460 2026-05-20 Vlachovský, Ladislav [LZ kardiologie] [kontrola, EF 65% diastol. dysfunkce RBBB lehká MR, konzervat. postup kontrola 6 měs.].pdf" } ] \ No newline at end of file diff --git a/Medevio/60 ScansProcessing/extract_patient_info_novy.py b/Medevio/60 ScansProcessing/extract_patient_info_novy.py index 38d7a36..d14273b 100644 --- a/Medevio/60 ScansProcessing/extract_patient_info_novy.py +++ b/Medevio/60 ScansProcessing/extract_patient_info_novy.py @@ -27,6 +27,8 @@ from pdf2image import convert_from_path sys.path.insert(0, str(Path(__file__).parent.parent)) from Knihovny.najdi_dropbox import get_dropbox_root +import tkinter as tk + FB_CONFIG = { 'dsn': r'reporter:c:\medicus\medicus.fdb', 'user': 'SYSDBA', @@ -88,9 +90,6 @@ 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 = [ @@ -102,6 +101,469 @@ COMPRESS_VARIANTS = [ ] +# ─── GUI — viewer dokumentů ────────────────────────────────────────────────── + +if sys.platform == "win32": + try: + from ctypes import windll as _windll + _windll.shcore.SetProcessDpiAwareness(1) + except Exception: + pass + + +def _open_doc(path_str: str): + import fitz + from PIL import Image + p = Path(path_str) + if not p.exists(): + return None, None + suffix = p.suffix.lower() + if suffix in (".jpg", ".jpeg", ".png"): + return None, Image.open(p) + return fitz.open(str(p)), None + + +def _render(doc, pil_img, page_n: int, max_w: int, max_h: int): + import fitz + from PIL import Image + if doc is not None: + page = doc[page_n] + zoom = min(max_w / page.rect.width, max_h / page.rect.height) + pix = page.get_pixmap(matrix=fitz.Matrix(zoom, zoom)) + return Image.frombytes("RGB", (pix.width, pix.height), pix.samples) + elif pil_img is not None: + img = pil_img.copy() + img.thumbnail((max_w, max_h)) + return img + return None + + +class PdfPane: + def __init__(self, parent, max_w: int, max_h: int, bg: str = "#1e1e2e", label_text: str = ""): + self.max_w = max_w + self.max_h = max_h + self.doc = None + self.pil_img = None + self.page_n = 0 + self.page_count = 1 + self.photo_ref = None + + self.frame = tk.Frame(parent, bg=bg, width=max_w, height=max_h) + self.frame.pack_propagate(False) + + if label_text: + tk.Label(self.frame, text=label_text, bg=bg, fg="#888", + font=("Segoe UI", 8, "bold")).pack(pady=(2, 0)) + + self.lbl_title = tk.Label(self.frame, text="", bg=bg, fg="#aaa", + font=("Segoe UI", 8), wraplength=max_w - 10) + self.lbl_title.pack(pady=(2, 0)) + + self.lbl_img = tk.Label(self.frame, bg=bg) + self.lbl_img.pack(fill="both", expand=True) + + frame_nav = tk.Frame(self.frame, bg=bg) + frame_nav.pack(pady=2) + + self.lbl_page = tk.Label(frame_nav, text="", bg=bg, fg="#aaa", font=("Segoe UI", 8)) + self.lbl_page.pack(side="left", padx=4) + + self.btn_prev = tk.Button(frame_nav, text="◄", bg="#333", fg="#fff", + relief="flat", padx=4, font=("Segoe UI", 8), + command=self._prev) + self.btn_prev.pack(side="left", padx=1) + self.btn_next = tk.Button(frame_nav, text="►", bg="#333", fg="#fff", + relief="flat", padx=4, font=("Segoe UI", 8), + command=self._next) + self.btn_next.pack(side="left", padx=1) + + def load(self, path_str: str, title: str = ""): + from PIL import ImageTk + if self.doc: + try: self.doc.close() + except Exception: pass + self.doc, self.pil_img = _open_doc(path_str) + self.page_count = len(self.doc) if self.doc else 1 + self.lbl_title.config(text=title or Path(path_str).name, fg="#ccc") + self._show(0) + + def clear(self, msg: str = ""): + if self.doc: + try: self.doc.close() + except Exception: pass + self.doc = None + self.pil_img = None + self.lbl_img.config(image="") + self.lbl_title.config(text=msg, fg="#555") + self.lbl_page.config(text="") + self.btn_prev.config(state="disabled") + self.btn_next.config(state="disabled") + self.photo_ref = None + + def _show(self, n: int): + from PIL import ImageTk + self.page_n = n + img = _render(self.doc, self.pil_img, n, self.max_w - 10, self.max_h - 60) + if img: + self.photo_ref = ImageTk.PhotoImage(img) + self.lbl_img.config(image=self.photo_ref) + self.lbl_page.config(text=f"{n+1} / {self.page_count}" if self.page_count > 1 else "") + self.btn_prev.config(state="normal" if n > 0 else "disabled") + self.btn_next.config(state="normal" if n < self.page_count - 1 else "disabled") + + def _prev(self): + if self.page_n > 0: self._show(self.page_n - 1) + + def _next(self): + if self.page_n < self.page_count - 1: self._show(self.page_n + 1) + + def close(self): + if self.doc: + try: self.doc.close() + except Exception: pass + + +def _get_viewer_layout() -> dict: + """Načte layout oken pro aktuální hostname z layout_settings.json.""" + import socket as _socket + settings_file = Path(__file__).parent / "layout_settings.json" + hostname = _socket.gethostname().upper() + _default = {"duplicity_viewer": None} + if not settings_file.exists(): + return _default + try: + settings = json.loads(settings_file.read_text(encoding="utf-8")) + return settings.get(hostname, _default) + except Exception: + return _default + + +def show_main_viewer( + original_path: str = "", + duplicity_paths: list = None, + labels: list = None, + nazev: str = "", + info_lines: list = None, + varianty: list = None, +) -> str | None: + """Zobrazí hlavní viewer. Vrátí schválený název souboru (bez .pdf) nebo None.""" + duplicity_paths = duplicity_paths or [] + labels = labels or [Path(p).name for p in duplicity_paths] + info_lines = info_lines or [] + varianty = varianty or [] + result = {"value": None} + + try: + from PIL import Image, ImageTk + import fitz + except ImportError as e: + print(f"[viewer] Chybí knihovna: {e}", file=sys.stderr) + return None + + try: + lw = _get_viewer_layout().get("duplicity_viewer") or {} + except Exception: + lw = {} + + WIN_X = lw.get("x", 0) + WIN_Y = lw.get("y", 0) + WIN_W = lw.get("w", 3840) + WIN_H = lw.get("h", 1700) + BOTTOM_H = 260 + TOP_H = WIN_H - BOTTOM_H - 8 + GAP = 4 + LISTBOX_W = 560 + PANE_W = (WIN_W - LISTBOX_W - GAP - 20) // 2 + BG = "#1a1a1a" + COL_INFO = int(WIN_W * 0.15) + COL_MID = int(WIN_W * 0.45) + COL_VAR = WIN_W - COL_INFO - COL_MID + + root = tk.Tk() + root.tk.call("encoding", "system", "utf-8") + root.title("Zpracování dokumentu") + root.configure(bg=BG) + root.geometry(f"{WIN_W}x{WIN_H}+{WIN_X}+{WIN_Y}") + root.resizable(True, True) + + frame_top = tk.Frame(root, bg=BG, height=TOP_H) + frame_top.pack(side="top", fill="x", expand=False) + frame_top.pack_propagate(False) + + pane_orig = PdfPane(frame_top, max_w=PANE_W, max_h=TOP_H, bg=BG, label_text="ORIGINÁL") + pane_orig.frame.pack(side="left", fill="y", padx=(6, GAP), pady=4) + + pane_dup = PdfPane(frame_top, max_w=PANE_W, max_h=TOP_H, bg=BG, label_text="DUPLICITA") + pane_dup.frame.pack(side="left", fill="y", padx=(GAP, GAP), pady=4) + + frame_lb = tk.Frame(frame_top, bg=BG, width=LISTBOX_W) + frame_lb.pack(side="left", fill="y", padx=(3, 6), pady=4) + frame_lb.pack_propagate(False) + + tk.Label(frame_lb, text="Existující dokumenty:", anchor="w", + bg=BG, fg="#cc4444", font=("Segoe UI", 9, "bold")).pack(fill="x", padx=6, pady=(8, 2)) + + sb_dup = tk.Scrollbar(frame_lb, orient="vertical") + txt_dup = tk.Text(frame_lb, yscrollcommand=sb_dup.set, + font=("Segoe UI", 9), bg=BG, fg="#ddd", + bd=0, highlightthickness=0, relief="flat", + wrap="word", cursor="hand2", state="normal", + selectbackground="#ffffff", selectforeground="#000000") + sb_dup.config(command=txt_dup.yview) + sb_dup.pack(side="right", fill="y") + txt_dup.pack(side="left", fill="both", expand=True, padx=(6, 0)) + txt_dup.tag_config("selected", background="#ffffff", foreground="#000000") + txt_dup.tag_config("normal", background=BG, foreground="#ddd") + + def _shorten_dup_label(name: str) -> str: + m = re.match(r"\d{9,10}\s+(\d{4}-\d{2}-\d{2})\s+[^[]+(\[.+)", name) + return f"{m.group(1)} {m.group(2)}" if m else name + + dup_line_indices = [] + if not duplicity_paths: + txt_dup.insert("end", "(žádné duplicity)") + txt_dup.config(state="disabled") + else: + for i, label in enumerate(labels): + short = _shorten_dup_label(Path(label).name if not Path(label).exists() else label) + ls = txt_dup.index("end") + txt_dup.insert("end", short + "\n\n") + le = txt_dup.index("end") + dup_line_indices.append((ls, le)) + txt_dup.tag_add("normal", ls, le) + txt_dup.config(state="disabled") + + selected_dup = [None] + + def _dup_click(event): + txt_dup.config(state="normal") + idx = txt_dup.index(f"@{event.x},{event.y}") + for i, (ls, le) in enumerate(dup_line_indices): + if txt_dup.compare(ls, "<=", idx) and txt_dup.compare(idx, "<", le): + if selected_dup[0] is not None: + pls, ple = dup_line_indices[selected_dup[0]] + txt_dup.tag_remove("selected", pls, ple) + txt_dup.tag_add("normal", pls, ple) + txt_dup.tag_remove("normal", ls, le) + txt_dup.tag_add("selected", ls, le) + selected_dup[0] = i + txt_dup.config(state="disabled") + if i < len(duplicity_paths): + pane_dup.load(duplicity_paths[i], title=labels[i] if i < len(labels) else "") + return + txt_dup.config(state="disabled") + + txt_dup.bind("", _dup_click) + tk.Frame(root, bg="#333", height=2).pack(fill="x") + + frame_bot = tk.Frame(root, bg=BG, height=BOTTOM_H) + frame_bot.pack(side="top", fill="both", expand=True) + frame_bot.pack_propagate(False) + + frame_info = tk.Frame(frame_bot, bg=BG, width=COL_INFO) + frame_info.pack(side="left", fill="y", padx=(10, 4), pady=8) + frame_info.pack_propagate(False) + tk.Label(frame_info, text="Informace o pacientovi", anchor="w", + bg=BG, fg="#888", font=("Segoe UI", 8, "bold")).pack(fill="x") + for line in info_lines: + color = "#cc4444" if line.startswith("⚠") or line.startswith("✗") else \ + "#44cc44" if line.startswith("✓") else "#aaa" + tk.Label(frame_info, text=line, anchor="w", bg=BG, fg=color, + font=("Segoe UI", 9), wraplength=COL_INFO - 20, justify="left").pack(fill="x", pady=1) + + frame_mid = tk.Frame(frame_bot, bg=BG, width=COL_MID) + frame_mid.pack(side="left", fill="y", padx=(4, 20), pady=8) + frame_mid.pack_propagate(False) + tk.Label(frame_mid, text="Název souboru (bez .pdf):", anchor="w", + bg=BG, fg="#888", font=("Segoe UI", 8, "bold")).pack(fill="x") + + nazev_bez = nazev[:-4] if nazev.endswith(".pdf") else nazev + txt = tk.Text(frame_mid, font=("Segoe UI", 10), bg="#ffffff", fg="#000000", + insertbackground="#000000", relief="flat", height=3, + wrap="word", padx=6, pady=4, bd=0, highlightthickness=0) + txt.pack(fill="x", pady=(4, 0)) + txt.insert("1.0", nazev_bez) + txt.mark_set("insert", "end") + txt.focus_set() + + frame_btn = tk.Frame(frame_mid, bg=BG) + frame_btn.pack(pady=(6, 0)) + + def schvalit(event=None): + result["value"] = txt.get("1.0", "end").strip() + pane_orig.close() + pane_dup.close() + root.destroy() + + def preskocit(event=None): + result["value"] = None + pane_orig.close() + pane_dup.close() + 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, relief="flat").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, relief="flat").pack(side="left", padx=8) + root.bind("", preskocit) + root.bind("", schvalit) + + frame_right = tk.Frame(frame_bot, bg=BG, width=COL_VAR) + frame_right.pack(side="left", fill="y", padx=(4, 10), pady=8) + frame_right.pack_propagate(False) + tk.Label(frame_right, text="Návrhy pojmenování (kliknutím vyberte):", anchor="w", + bg=BG, fg="#888", font=("Segoe UI", 8, "bold")).pack(fill="x") + sb_var = tk.Scrollbar(frame_right, orient="vertical") + lb_var = tk.Listbox(frame_right, yscrollcommand=sb_var.set, + font=("Segoe UI", 10), selectmode="single", activestyle="none", + bg=BG, fg="#ddd", bd=0, highlightthickness=0, + selectbackground="#ffffff", selectforeground="#000000", cursor="hand2") + sb_var.config(command=lb_var.yview) + sb_var.pack(side="right", fill="y") + lb_var.pack(side="left", fill="both", expand=True) + for v in varianty: + lb_var.insert(tk.END, v[:-4] if v.endswith(".pdf") else v) + + def on_varianta(event): + sel = lb_var.curselection() + if sel: + txt.delete("1.0", "end") + txt.insert("1.0", lb_var.get(sel[0])) + + lb_var.bind("<>", on_varianta) + + if original_path and Path(original_path).exists(): + pane_orig.load(original_path, title=Path(original_path).name) + else: + pane_orig.clear(msg="(originál nedostupný)") + + pane_dup.clear(msg="← vyberte duplicitu" if duplicity_paths else "(žádné duplicity)") + + if duplicity_paths: + pane_dup.load(duplicity_paths[0], title=labels[0] if labels else "") + if dup_line_indices: + txt_dup.config(state="normal") + ls, le = dup_line_indices[0] + txt_dup.tag_remove("normal", ls, le) + txt_dup.tag_add("selected", ls, le) + selected_dup[0] = 0 + txt_dup.config(state="disabled") + + root.protocol("WM_DELETE_WINDOW", preskocit) + root.lift() + root.attributes("-topmost", True) + root.after(1500, lambda: root.attributes("-topmost", False)) + root.mainloop() + + return result["value"] + + +# ─── GUI — variant picker ───────────────────────────────────────────────────── + +def show_variant_picker(variants: list) -> str | None: + """Zobrazí picker kompresních variant. Vrátí cestu k vybrané variantě nebo None.""" + import fitz + from PIL import Image, ImageTk + + chosen = {"path": None} + docs = [fitz.open(v["path"]) for v in variants] + current = [0] + photo_ref = [None] + current_page = [0] + + 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 + img_h = win_h - 160 + img_w = sw // 2 + x = (sw - img_w) // 2 + root.geometry(f"{img_w}x{win_h}+{x}+0") + root.resizable(False, False) + + frame_top = tk.Frame(root, bg="#222") + frame_top.pack(fill="x") + btn_variants = [] + + 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) + + 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) + + 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) + + 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.lift() + root.focus_force() + root.after(100, lambda: root.focus_force()) + root.after(200, lambda: root.attributes("-topmost", True)) + root.mainloop() + + for d in docs: + try: d.close() + except Exception: pass + + return chosen["path"] + + # ─── Komprese jedné varianty ────────────────────────────────────────────────── def set_single_page_view(pdf_path: Path): @@ -512,6 +974,87 @@ def extract_info(pdf_path: Path, known_patient: str | None = None, known_rc: str return {"nazev_souboru": None} +# ─── 2. volání Claude — generování variant názvů ──────────────────────────── + +def generate_name_variants(info: dict, nazev_prvni: str) -> list[str]: + """ + Druhé volání Claude (jen text, bez obrázku) — vygeneruje 5 variant názvu souboru. + Vrátí seznam unikátních variant seřazených od nejlepší, deduplikovaných vůči nazev_prvni. + """ + naming_rules = load_naming_rules() + corrections = build_corrections_prompt() + + # Sestavíme kontext z dat extrahovaných v 1. volání + ctx_parts = [] + if info.get("jmeno"): + ctx_parts.append(f"Jméno pacienta: {info['jmeno']}") + if info.get("rodne_cislo"): + ctx_parts.append(f"Rodné číslo: {info['rodne_cislo']}") + if info.get("datum_zpravy"): + ctx_parts.append(f"Datum zprávy: {info['datum_zpravy']}") + if info.get("typ_dokumentu"): + ctx_parts.append(f"Typ dokumentu: {info['typ_dokumentu']}") + if info.get("poznamka"): + ctx_parts.append(f"Klinická poznámka (z 1. analýzy): {info['poznamka']}") + if nazev_prvni: + ctx_parts.append(f"Návrh z 1. analýzy: {nazev_prvni}") + + context = "\n".join(ctx_parts) + + prompt = ( + naming_rules + + corrections + + "Na základě níže uvedených dat o lékařské zprávě vygeneruj 5 různých variant názvu souboru. " + "Varianty se mají lišit především formulací druhé závorky (různá míra detail, různé zkratky, různý důraz). " + "Seřaď je od nejlepší (nejvýstižnější a nejpřesnější dle pravidel) po nejhorší. " + "Vrať POUZE JSON pole s řetězci — názvy souborů včetně .pdf. " + "Žádný jiný text ani vysvětlení.\n\n" + f"Data:\n{context}\n\n" + "Příklad výstupu: [\"RC datum Příjmení, Jméno [LZ kardiologie] [kontrola, ICHS, EKG za 3 měsíce].pdf\", ...]" + ) + + print(" Volám Claude API pro varianty názvů...") + try: + client = anthropic.Anthropic(api_key=os.environ.get("ANTHROPIC_API_KEY")) + response = client.messages.create( + model="claude-sonnet-4-6", + max_tokens=600, + messages=[{"role": "user", "content": prompt}], + ) + usage = response.usage + print(f" Varianty — 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:] + varianty_raw: list[str] = json.loads(raw.strip()) + + # Deduplikace — odstraň varianty identické s nazev_prvni (porovnáváme bez .pdf) + def _norm(s: str) -> str: + return s.strip().removesuffix(".pdf").strip() + + seen = {_norm(nazev_prvni)} if nazev_prvni else set() + varianty_unique = [] + for v in varianty_raw: + n = _norm(v) + if n not in seen: + seen.add(n) + varianty_unique.append(v if v.endswith(".pdf") else v + ".pdf") + + # Výsledný seznam: první návrh z 1. volání + unikátní varianty z 2. volání + vysledek = [] + if nazev_prvni: + vysledek.append(nazev_prvni if nazev_prvni.endswith(".pdf") else nazev_prvni + ".pdf") + vysledek.extend(varianty_unique) + return vysledek + + except Exception as e: + print(f" VAROVÁNÍ: generování variant selhalo ({e})") + return [nazev_prvni] if nazev_prvni else [] + + # ─── Subprocess helpers ─────────────────────────────────────────────────────── def open_preview(pdf_path: Path) -> tuple[subprocess.Popen, Path]: @@ -536,9 +1079,21 @@ def read_preview_bottom(geom_file: Path, timeout: float = 5.0) -> int: return None -def run_rename_dialog(nazev: str, info_lines: list, below_y: int = None) -> str | None: +def run_rename_dialog( + nazev: str, + info_lines: list, + below_y: int = None, + duplicity: list = None, + varianty: list = None, +) -> str | None: + payload = { + "nazev": nazev, + "info_lines": info_lines, + "duplicity": duplicity or [], + "varianty": varianty or [], + } tmp = Path(tempfile.mktemp(suffix=".json")) - tmp.write_text(json.dumps({"nazev": nazev, "info_lines": info_lines}, ensure_ascii=False), encoding="utf-8") + tmp.write_text(json.dumps(payload, 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}") @@ -551,20 +1106,27 @@ def run_rename_dialog(nazev: str, info_lines: list, below_y: int = None) -> str 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", +def run_main_viewer( + original_path: Path, + nazev: str, + info_lines: list, + duplicity_names: list[str], + varianty: list[str], +) -> str | None: + dup_paths = [str(DOKUMENTACE / name) for name in duplicity_names + if (DOKUMENTACE / name).exists()] + return show_main_viewer( + original_path = str(original_path), + duplicity_paths = dup_paths, + labels = duplicity_names, + nazev = nazev, + info_lines = info_lines, + varianty = varianty, ) - tmp.unlink(missing_ok=True) - if proc.returncode != 0 or not proc.stdout.strip(): - print(f" [variant_picker] returncode={proc.returncode}") - if proc.stderr.strip(): - print(f" [variant_picker] CHYBA:\n{proc.stderr.strip()}") - out = proc.stdout.strip() - return json.loads(out).get("chosen") if out else None + + +def run_variant_picker(variants_data: list) -> str | None: + return show_variant_picker(variants_data) # ─── Detekce split názvu ────────────────────────────────────────────────────── @@ -600,10 +1162,6 @@ def process_file(pdf_path: Path): verif = info["_verif"] rc_ocr = info["_rc_ocr"] - # 1. Otevři preview (pro EKG: soubor je již otočen) - preview, geom_file = open_preview(pdf_path) - below_y = read_preview_bottom(geom_file) - if not is_ekg: # 2. Zjisti RČ a jméno — buď z názvu (split soubor) nebo přes Claude Vision API split = _parse_split_filename(pdf_path.name) @@ -672,18 +1230,23 @@ def process_file(pdf_path: Path): # 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 = ["[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() - stderr_out = preview.stderr.read().decode("utf-8", errors="replace").strip() if preview.stderr else "" - if stderr_out: - print(f" [preview] CHYBA: {stderr_out}") + # 2. volání Claude — varianty názvů (jen pro non-EKG dokumenty s výsledkem z 1. volání) + varianty = [] + if not is_ekg and nazev: + varianty = generate_name_variants(info, nazev) + + print(" Otevírám hlavní viewer...") + final_name = run_main_viewer( + original_path=pdf_path, + nazev=nazev, + info_lines=info_lines, + duplicity_names=duplicity, + varianty=varianty, + ) if not final_name: print(" Přeskočeno.") @@ -693,7 +1256,12 @@ def process_file(pdf_path: Path): final_name += ".pdf" final_name = re.sub(r'[<>:"/\\|?*]', '', final_name) - if nazev and final_name != nazev: + # Ulož korekci jen pokud se finální název liší od VŠECH navržených variant + def _norm_name(s: str) -> str: + return (s or "").strip().removesuffix(".pdf").strip() + + vsechny_varianty = {_norm_name(v) for v in varianty} + if nazev and _norm_name(final_name) not in vsechny_varianty: save_correction(nazev, final_name) print(f" Schválený název: {final_name}") diff --git a/Medevio/60 ScansProcessing/layout_settings.json b/Medevio/60 ScansProcessing/layout_settings.json new file mode 100644 index 0000000..47e07f0 --- /dev/null +++ b/Medevio/60 ScansProcessing/layout_settings.json @@ -0,0 +1,7 @@ +{ + "Z230": { + "_comment": "3 monitory: primární 3840x2160 (x=0), vlevo 1920x1200 (x=-1920), vpravo 1920x1200 (x=3840)", + "duplicity_viewer": { "x": 0, "y": 0, "w": 3840, "h": 1800 }, + "rename_dialog": { "x": 0, "anchor": "bottom" } + } +} diff --git a/Medevio/60 ScansProcessing/naming_rules.md b/Medevio/60 ScansProcessing/naming_rules.md index 67222d3..34651a7 100644 --- a/Medevio/60 ScansProcessing/naming_rules.md +++ b/Medevio/60 ScansProcessing/naming_rules.md @@ -32,3 +32,16 @@ Tato pravidla platí vždy při generování polí `poznamka` a `nazev_souboru`. - Příklad výsledného názvu: `[Laboratoř] [sideropenická anémie, Hb 98, MCV 71, Fe 5.2]` 9. Jaterní enzymy (ALT, AST, GGT, ALP, LD/LDH) a bilirubin — hodnoty pod dolní hranicí normy (snížené) nezmiňuj v `poznamka` ani v `nazev_souboru`. Uváděj pouze hodnoty nad normu (zvýšené). + +10. Druhá závorka pro LZ a PZ — obsah a pořadí: Pro dokumenty typu LZ (lékařská zpráva) a PZ (propouštěcí zpráva) tvoří druhou závorku tyto části v tomto pořadí (oddělené čárkou): + a) **Typ návštěvy** — uveď pouze pokud je explicitně rozpoznatelný ze zprávy: + - `kontrola` — plánovaná kontrola (např. „plánovaná kontrola", „přichází na kontrolu") + - `neplánovaná kontrola` — pokud je výslovně uvedeno, že kontrola nebyla plánovaná + - `akutní` — pacient přichází do akutní ambulance nebo cestou RZS/záchranné služby + - Pokud typ návštěvy není ve zprávě uveden, tuto část zcela vynech (nepsat žádný fallback). + b) **Hlavní diagnóza** — získej z části „Diagnózy", „Závěr" nebo „Dg." — uveď první (hlavní) diagnózu, která je obvykle důvodem návštěvy. Stručně, výstižně. + c) **Co je domluveno dále** — z části „Doporučení", „Plán", „Závěr" apod.: další kontrola, doporučené vyšetření, změna léčby apod. Stručně. + - Příklad (s typem návštěvy): `[LZ kardiologie] [kontrola, ICHS, EKG za 3 měsíce]` + - Příklad (bez typu návštěvy): `[LZ neurologie] [migréna, pokračovat v léčbě]` + - Příklad akutní: `[LZ interna] [akutní, dekompenzovaná hypertenze, hospitalizace]` + - Pro PZ zůstává datum hospitalizace jako první (před typem návštěvy), viz pravidlo 2.