diff --git a/Medevio/60 ScansProcessing/corrections.json b/Medevio/60 ScansProcessing/corrections.json index 7b2c69b..75d6ee8 100644 --- a/Medevio/60 ScansProcessing/corrections.json +++ b/Medevio/60 ScansProcessing/corrections.json @@ -690,5 +690,25 @@ { "original": "465525112 2025-04-08 Hoserová, Marie [LZ praktický lékař] [výpis ze ZD, permanentní FS, AION, DM2, art. hypertenze, osteoporóza, vertigo].pdf", "corrected": "465525112 2025-04-08 Hoserová, Marie [LZ praktický lékař] [výpis ze ZD, permanentní FiS, AION, DM2, art. hypertenze, osteoporóza, vertigo].pdf" + }, + { + "original": "null 2026-03-04 Štěpánová, Lenka [Rozhodnutí HSHMP] [nařízení protiepidemických opatření - virová hepatitida A, lékařský dohled do 18.04.2026].pdf", + "corrected": "7556220452 2026-03-04 Štěpánová, Lenka [Rozhodnutí HYGIENA] [nařízení protiepidemických opatření - virová hepatitida A, lékařský dohled do 18.04.2026].pdf" + }, + { + "original": "380314026 2026-03-12 Chomát, Jiří [Návrh na lázeňskou péči] [dg. M1900, indikace nVII8, 21 dní, schváleno VZP do 12.06.2026, Luhačovice].pdf", + "corrected": "380314026 2026-03-12 Chomát, Jiří [Schválení lázně] [dg. M1900, indikace nVII8, 21 dní, schváleno VZP do 12.06.2026, Luhačovice].pdf" + }, + { + "original": "370315041 2026-03-31 Dragoun, Otokar [Žádost o předání ZD] [nový registrující lékař MUDr. Hlaváček, RESPIMED].pdf", + "corrected": "370315041 2026-03-31 Dragoun, Otokar [Žádost o předání zdratovních informací] [nový registrující lékař MUDr. Hlaváček, RESPIMED].pdf" + }, + { + "original": "0055310024 2026-03-27 Slámová, Lucie [LZ praktický lékař] [výpis ze ZD, nadváha, recid. angíny, HLP, hypothyreoza, kouření 5/den, HAK].pdf", + "corrected": "0055310024 2026-03-27 Slámová, Lucie [LZ praktický lékař] [výpis ze zdravotní dokumentace, nadváha, recid. angíny, HLP, hypothyreoza, kouření 5den, HAK].pdf" + }, + { + "original": "8908180402 2026-03-05 Tůma, Patrik [Žádanka IPZS] [žádanka o vyšetření zdrav. stavu, invalidita kontrolní, 517 Kč].pdf", + "corrected": "8908180402 2026-03-05 Tůma, Patrik [Žádanka IPZS] [žádanka o vyšetření zdrav. stavu, invalidita, cílené, 517 Kč].pdf" } ] \ No newline at end of file diff --git a/Medevio/70 DěleníSouboruPDF/rozdelit_pdf.py b/Medevio/70 DěleníSouboruPDF/rozdelit_pdf.py index f4bed95..d523460 100644 --- a/Medevio/70 DěleníSouboruPDF/rozdelit_pdf.py +++ b/Medevio/70 DěleníSouboruPDF/rozdelit_pdf.py @@ -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("", 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("", self._on_key) + self.bind("", 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: