This commit is contained in:
2026-06-02 09:40:05 +02:00
parent d5f2dc3925
commit e79458d670
16 changed files with 2597 additions and 49 deletions

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

@@ -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 <json_soubor> [--write-geometry=<cesta>]
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("<<ListboxSelect>>", on_select)
# Automaticky vyber první duplicitu
if duplicity_paths:
lb.selection_set(0)
lb.event_generate("<<ListboxSelect>>")
# ── 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()
@@ -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)
@@ -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_soubor>
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("<Button-1>", _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("<Escape>", preskocit)
# Ctrl+Enter vždy schválí
root.bind("<Control-Return>", 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("<<ListboxSelect>>", 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))
@@ -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', '?')}")
@@ -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=<cesta>
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()
@@ -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_soubor>
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("<<ListboxSelect>>", 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("<<ListboxSelect>>", 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("<Return>", schvalit)
root.bind("<Escape>", 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()
@@ -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__":
@@ -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}")
@@ -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řezenkvě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řezenkvě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"
}
]
@@ -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("<Button-1>", _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("<Escape>", preskocit)
root.bind("<Control-Return>", 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("<<ListboxSelect>>", 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("<Key-1>", lambda e: show(0))
root.bind("<Key-2>", lambda e: show(1))
root.bind("<Key-3>", lambda e: show(2))
root.bind("<Key-4>", lambda e: show(3))
root.bind("<Key-5>", lambda e: show(4))
root.bind("<Return>", lambda e: beru())
root.bind("<Escape>", 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}")
@@ -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" }
}
}
@@ -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.