z230
This commit is contained in:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user