This commit is contained in:
2026-05-05 08:38:57 +02:00
parent ffb3db1e07
commit 4112b5d3d4
2 changed files with 331 additions and 14 deletions
+311 -14
View File
@@ -12,6 +12,7 @@ Numerická klávesnice:
5 / Space přepni hranici pacienta před touto stránkou
8 / Up přesuň stránku doleva (swap)
2 / Down přesuň stránku doprava (swap)
- výběr pacienta ručně z Medicusu
Enter exportuj všechny skupiny do Split/
Esc konec
"""
@@ -176,6 +177,51 @@ def _verify_medicus(rc_digits: str) -> dict:
except Exception as e:
return {"status": "offline", "patient": None, "error": str(e)}
# ── Načtení všech pacientů z Medicus ─────────────────────────────────────────
def _load_all_patients() -> list[dict]:
try:
import fdb
from datetime import date
dnes = date.today().isoformat() # 'YYYY-MM-DD'
cfg = get_medicus_config()
con = fdb.connect(dsn=cfg.dsn, user="SYSDBA", password="masterkey", charset="win1250")
try:
cur = con.cursor()
cur.execute(
"SELECT KAR.IDPAC, KAR.PRIJMENI, KAR.JMENO, KAR.RODCIS "
"FROM KAR "
"WHERE KAR.VYRAZEN = 'N' "
"AND KAR.RODCIS IS NOT NULL AND KAR.RODCIS <> '' "
"AND EXISTS ("
" SELECT r.ID FROM REGISTR r "
" JOIN ICP i ON r.IDICP = i.IDICP "
" WHERE r.IDPAC = KAR.IDPAC "
" AND r.DATUM <= ? "
" AND (r.DATUM_ZRUSENI IS NULL OR r.DATUM_ZRUSENI >= ?) "
" AND r.PRIZNAK IN ('V', 'D', 'A') "
" AND i.ICP = '09305001' "
" AND i.ODB = '001' "
") "
"ORDER BY KAR.PRIJMENI_UP ASC, KAR.RODCIS ASC",
(dnes, dnes),
)
return [
{
"idpac": r[0],
"prijmeni": (r[1] or "").strip(),
"jmeno": (r[2] or "").strip(),
"rodcis": (r[3] or "").strip(),
}
for r in cur.fetchall()
]
finally:
con.close()
except Exception as e:
print(f"[Medicus] chyba načtení pacientů: {e}")
return []
# ── Jméno výstupního souboru ──────────────────────────────────────────────────
def _format_filename(group_idx: int, medicus: Optional[dict]) -> str:
@@ -255,9 +301,10 @@ class OcrWorker:
# 3. Claude Vision — když Tesseract nenašel RČ, nebo našel ale Medicus nezná
claude_raw = None
claude_usage = None
if not rc or (medicus and medicus.get("status") == "not_found"):
try:
rc_claude, claude_raw = self._claude_rc(img)
rc_claude, claude_raw, claude_usage = self._claude_rc(img)
if rc_claude:
medicus_claude = _verify_medicus(rc_claude)
if medicus_claude.get("status") in ("ok", "fuzzy"):
@@ -275,12 +322,13 @@ class OcrWorker:
"medicus": medicus,
"tesseract_text": tess_text,
"claude_raw": claude_raw,
"claude_usage": claude_usage,
}
self.results[i] = result
self._save_cache()
self.on_page_done(i)
def _claude_rc(self, img: Image.Image) -> tuple[Optional[str], Optional[str]]:
def _claude_rc(self, img: Image.Image) -> tuple[Optional[str], Optional[str], Optional[dict]]:
import anthropic, base64
buf = io.BytesIO()
@@ -300,13 +348,17 @@ class OcrWorker:
)},
]}],
)
usage = {
"input_tokens": resp.usage.input_tokens,
"output_tokens": resp.usage.output_tokens,
}
raw = resp.content[0].text.strip()
raw = re.sub(r"^```\w*\n?", "", raw).rstrip("`").strip()
try:
rc_raw = json.loads(raw).get("rodne_cislo") or ""
return re.sub(r"\D", "", rc_raw) or None, raw
return re.sub(r"\D", "", rc_raw) or None, raw, usage
except Exception:
return None, raw
return None, raw, usage
# ── Thumbnail worker (pozadí) ─────────────────────────────────────────────────
@@ -349,7 +401,7 @@ class ThumbnailWorker:
COLS = 4
BORDER_W = 16 # šířka oddělovače mezi sloty
PAD = 8 # odsazení thumbnaillu od okraje slotu
INFO_H = 108 # výška info pásu pod thumbnailem
INFO_H = 116 # výška info pásu pod thumbnailem
TOP_H = 44 # výška stavové lišty nahoře
BOT_H = 44 # výška nápovědy dole
@@ -374,6 +426,155 @@ GROUP_COLORS = [
"#2a3a1b", "#1b2a2a", "#3a1b2a", "#2a2a1b",
]
# ── Dialog pro výběr pacienta ─────────────────────────────────────────────────
class PatientPickerDialog(tk.Toplevel):
"""Modální okno pro ruční výběr pacienta z Medicusu."""
def __init__(self, parent: tk.Tk, on_select):
super().__init__(parent)
self.on_select = on_select
self.all_patients: list[dict] = []
self.filtered: list[dict] = []
self.title("Výběr pacienta")
self.configure(bg=BG)
self.resizable(True, True)
self.geometry("760x520")
self.protocol("WM_DELETE_WINDOW", self.destroy)
# ── Vyhledávací řádek ─────────────────────────────────────────────────
tk.Label(
self, text="Hledat (RČ nebo jméno):",
bg=BG, fg=C_TEXT, font=("Consolas", 12), anchor="w",
).pack(fill="x", padx=10, pady=(10, 0))
self.search_var = tk.StringVar()
self.search_var.trace_add("write", lambda *_: self._update_list())
self.entry = tk.Entry(
self, textvariable=self.search_var,
font=("Consolas", 14), bg="#2d2d2d", fg=C_TEXT,
insertbackground=C_TEXT, relief="flat", bd=4,
)
self.entry.pack(fill="x", padx=10, pady=6)
# ── Listbox ───────────────────────────────────────────────────────────
frame = tk.Frame(self, bg=BG)
frame.pack(fill="both", expand=True, padx=10, pady=(0, 4))
sb = tk.Scrollbar(frame, orient="vertical")
self.listbox = tk.Listbox(
frame, yscrollcommand=sb.set,
bg="#1a1a1a", fg=C_TEXT,
selectbackground=C_CURSOR, selectforeground="white",
font=("Consolas", 12), activestyle="none",
borderwidth=0, highlightthickness=0,
)
sb.config(command=self.listbox.yview)
sb.pack(side="right", fill="y")
self.listbox.pack(side="left", fill="both", expand=True)
self.listbox.bind("<Double-Button-1>", lambda _: self._confirm())
# ── Stavový řádek ─────────────────────────────────────────────────────
self.status_label = tk.Label(
self, text="Načítám pacienty…",
bg=BG, fg=C_DIM, font=("Consolas", 10), anchor="w",
)
self.status_label.pack(fill="x", padx=10, pady=(0, 6))
# Klávesy: entry zachytí normální znaky, Toplevel zachytí navigaci
self.entry.bind("<KeyPress>", self._on_key)
self.bind("<KeyPress>", self._on_key)
# Načti pacienty na pozadí
threading.Thread(target=self._load, daemon=True).start()
self.grab_set()
self.entry.focus_set()
# ── Načtení pacientů ──────────────────────────────────────────────────────
def _load(self):
patients = _load_all_patients()
self.after(0, self._on_loaded, patients)
def _on_loaded(self, patients: list[dict]):
self.all_patients = patients
self._update_list()
self.status_label.config(text=f"Načteno {len(patients)} pacientů")
# ── Filtrování ────────────────────────────────────────────────────────────
def _update_list(self):
q = self.search_var.get().strip()
q_lower = q.lower()
q_digits = re.sub(r"\D", "", q)
if not q:
self.filtered = self.all_patients[:]
else:
result = []
for p in self.all_patients:
rc_digits = re.sub(r"\D", "", p["rodcis"])
name_lower = f"{p['prijmeni']} {p['jmeno']}".lower()
if (q_digits and rc_digits.startswith(q_digits)) or q_lower in name_lower:
result.append(p)
self.filtered = result
self.listbox.delete(0, "end")
for p in self.filtered:
rc = p["rodcis"] or ""
self.listbox.insert("end", f" {rc:<14} {p['prijmeni']} {p['jmeno']}")
if self.filtered:
self.listbox.selection_set(0)
self.listbox.see(0)
count = len(self.filtered)
total = len(self.all_patients)
suffix = f" (z {total})" if count != total else ""
self.status_label.config(text=f"{count} pacientů{suffix} │ Enter: vybrat 8/2 nebo ↑↓: navigace Esc: zrušit")
# ── Klávesnice ────────────────────────────────────────────────────────────
def _on_key(self, event):
ks = event.keysym
kc = event.keycode
# numpad 8 (keycode 104) = nahoru, numpad 2 (keycode 98) = dolů
if kc == 104 or ks in ("Up", "KP_Up"):
self._move(-1)
return "break"
if kc == 98 or ks in ("Down", "KP_Down"):
self._move(1)
return "break"
if ks in ("Return", "KP_Enter"):
self._confirm()
return "break"
if ks == "Escape":
self.destroy()
return "break"
def _move(self, delta: int):
if not self.filtered:
return
sel = self.listbox.curselection()
idx = sel[0] if sel else 0
new_idx = max(0, min(len(self.filtered) - 1, idx + delta))
self.listbox.selection_clear(0, "end")
self.listbox.selection_set(new_idx)
self.listbox.see(new_idx)
# ── Výběr ─────────────────────────────────────────────────────────────────
def _confirm(self):
sel = self.listbox.curselection()
if not sel or not self.filtered:
return
self.on_select(self.filtered[sel[0]])
self.destroy()
# ── Hlavní UI ─────────────────────────────────────────────────────────────────
class SplitterUI:
@@ -432,6 +633,7 @@ class SplitterUI:
"1/3: přesuň stránku "
"/: otočit ↺CCW *: otočit ↻CW "
"Del/.: smaž stránku "
"-: vyber pacienta ručně "
"Enter: exportuj Esc: konec"
)
self.bot_label = tk.Label(
@@ -495,16 +697,33 @@ class SplitterUI:
self._redraw()
def _rebuild_photo(self, page_idx: int):
pil = self.thumb_worker.get(page_idx)
if pil is None:
return
rot = self.rotations.get(page_idx, 0)
key = (page_idx, rot)
if key not in self._photo_cache:
img = pil.rotate(rot, expand=True).resize(
(self.THUMB_W, self.THUMB_H), Image.LANCZOS
)
self._photo_cache[key] = ImageTk.PhotoImage(img)
if key in self._photo_cache:
return
if rot == 0:
# Bez rotace — použij předrenderovaný thumbnail
pil = self.thumb_worker.get(page_idx)
if pil is None:
return
self._photo_cache[key] = ImageTk.PhotoImage(pil)
else:
# Otočená stránka — přerenderuj přímo z PDF se správnými rozměry
page = self.doc[page_idx]
rect = page.rect
# Po otočení o 90°/270° se šířka a výška prohodí
if rot % 180 == 90:
eff_w, eff_h = rect.height, rect.width
else:
eff_w, eff_h = rect.width, rect.height
scale = min(self.THUMB_W / eff_w, self.THUMB_H / eff_h)
mat = fitz.Matrix(scale, scale).prerotate(rot)
pix = page.get_pixmap(matrix=mat, colorspace=fitz.csRGB)
img = Image.frombytes("RGB", [pix.width, pix.height], pix.samples)
canvas = Image.new("RGB", (self.THUMB_W, self.THUMB_H), (38, 38, 38))
canvas.paste(img, ((self.THUMB_W - img.width) // 2, (self.THUMB_H - img.height) // 2))
self._photo_cache[key] = ImageTk.PhotoImage(canvas)
# ── Klávesnice ────────────────────────────────────────────────────────────
@@ -518,7 +737,7 @@ class SplitterUI:
100: "num4", 101: "num5", 102: "num6",
103: "num7", 105: "num9",
97: "num1", 99: "num3", 110: "numdot",
111: "numslash", 106: "numstar",
111: "numslash", 106: "numstar", 109: "numminus",
}
action = numpad.get(kc) or {
"Left": "num4", "Right": "num6",
@@ -528,6 +747,7 @@ class SplitterUI:
"space": "num5",
"KP_Divide": "numslash", "KP_Multiply": "numstar",
"slash": "numslash", "asterisk": "numstar",
"KP_Subtract": "numminus", "minus": "numminus",
}.get(ks)
if action == "num4":
@@ -550,6 +770,8 @@ class SplitterUI:
self._rotate_page(-90) # CW
elif action == "numdot":
self._delete_page()
elif action == "numminus":
self._open_patient_picker()
elif ks in ("Return", "KP_Enter"):
self._export()
elif ks == "Escape":
@@ -601,6 +823,60 @@ class SplitterUI:
self.scroll = self.cursor
self._redraw()
def _open_patient_picker(self):
page_idx = self.page_order[self.cursor]
pos = self.cursor
def on_select(patient: dict):
rc_digits = re.sub(r"\D", "", patient["rodcis"])
result = {
"rc": rc_digits,
"medicus": {"status": "ok", "patient": patient},
"tesseract_text": None,
"claude_raw": None,
}
self.ocr_results[page_idx] = result
self.ocr_worker.results[page_idx] = result
self.ocr_worker._save_cache()
self._update_boundaries_around(pos)
self._redraw()
PatientPickerDialog(self.root, on_select)
def _update_boundaries_around(self, pos: int):
"""Přidá/odstraní hranice kolem pozice pos podle potvrzených pacientů."""
def confirmed_rc(p: int) -> Optional[str]:
r = self.ocr_results.get(self.page_order[p])
if not r:
return None
med = r.get("medicus") or {}
if med.get("status") not in ("ok", "fuzzy"):
return None
pat = med.get("patient")
return re.sub(r"\D", "", pat["rodcis"]) if pat else None
n = len(self.page_order)
# Hranice mezi pos-1 a pos
if pos > 0:
rc_prev = confirmed_rc(pos - 1)
rc_curr = confirmed_rc(pos)
if rc_prev and rc_curr:
if rc_prev != rc_curr:
self.boundaries.add(pos)
else:
self.boundaries.discard(pos)
# Hranice mezi pos a pos+1
if pos + 1 < n:
rc_curr = confirmed_rc(pos)
rc_next = confirmed_rc(pos + 1)
if rc_curr and rc_next:
if rc_curr != rc_next:
self.boundaries.add(pos + 1)
else:
self.boundaries.discard(pos + 1)
def _move_page(self, delta: int):
n = len(self.page_order)
pos = self.cursor
@@ -733,6 +1009,7 @@ class SplitterUI:
if result is None:
rc_line = "⏳ OCR probíhá…"
pat_line = ""
claude_line = ""
stat_color = C_LOADING
else:
rc = result.get("rc")
@@ -760,6 +1037,20 @@ class SplitterUI:
pat_line = ""
stat_color = C_NONE
usage = result.get("claude_usage")
if usage:
# claude-sonnet-4-6: $3/MTok vstup, $15/MTok výstup
cost_usd = (usage["input_tokens"] * 3 + usage["output_tokens"] * 15) / 1_000_000
cost_czk = cost_usd * 23
claude_line = (
f"Claude: {usage['input_tokens']}+{usage['output_tokens']} tok "
f"${cost_usd:.4f} (~{cost_czk:.2f} Kč)"
)
elif result.get("claude_raw") is not None:
claude_line = "Claude: ✓ (cena nezaznamenána)"
else:
claude_line = ""
c.create_text(
x0 + 8, y_info + 6,
text=f"str. {pos + 1}/{n} (orig: {page_idx + 1})",
@@ -775,6 +1066,12 @@ class SplitterUI:
text=pat_line,
anchor="nw", fill=stat_color, font=("Consolas", 14, "bold")
)
if claude_line:
c.create_text(
x0 + 8, y_info + 82,
text=claude_line,
anchor="nw", fill=C_DIM, font=("Consolas", 9)
)
# ── Oddělovač napravo od tohoto slotu ────────────────────────────
if col < COLS - 1: