""" recepty_agent.py ---------------- Agent, který ve schránce ordinace@buzalkova.cz hledá ŽÁDOSTI O PŘEDPIS (recept) od pacientů a vytěžuje z nich pacienta a požadované léky. TESTOVACÍ REŽIM: čte N nejnovějších mailů z Inboxu (read-only, ve schránce nic nemění) a vypíše report do konzole + logu. Tok: 1. Microsoft Graph: načti N nejnovějších mailů z Inboxu (bez ohledu na přílohy). 2. AI KLASIFIKACE + VYTĚŽENÍ (Claude): u každého mailu rozhodne, zda jde o žádost o předpis, a vytěží jméno pacienta (může se lišit od odesílatele), rodné číslo (pokud je v textu) a seznam požadovaných léků s dávkováním. 3. OVĚŘENÍ V MEDICUSU: pacienta dohledá v kartotéce (KAR + KARKONTAKT) v pořadí rodné číslo > e-mail odesílatele > jméno. 4. NEJEDNOZNAČNOST (více pacientů stejného jména): načte historii receptů kandidátů (tabulka RECEPT) a rozhodne podle shody s požadovanými léky — nejdřív deterministicky (název léku v historii), sporné případy dořeší Claude nad seznamy předepsaných léků. 5. Vypíše report. """ import json import os import re import sys import unicodedata from datetime import date, timedelta from pathlib import Path try: sys.stdout.reconfigure(encoding="utf-8") except Exception: pass import requests # graph_mail.py sdílíme s EmailAgent (stejná app registrace, Mail.Read). sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "EmailAgent")) import graph_mail # noqa: E402 # medicus_db.py z Knihoven (Firebird, DSN podle názvu počítače). sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) from Knihovny.medicus_db import get_medicus_db # noqa: E402 import mcp_medevio as _medevio # noqa: E402 GraphQL API + zaloz_pozadavek_recept # ========================= # NASTAVENÍ # ========================= MAILBOX = "ordinace@buzalkova.cz" # Kolik nejnovějších mailů z Inboxu zpracovat (testovací režim). NEWEST_N = 5 # Claude model pro klasifikaci + vytěžení. ANTHROPIC_MODEL = "claude-haiku-4-5" # Kolik měsíců historie receptů načíst při rozhodování nejednoznačnosti. RECEPT_MONTHS = 24 # Cena Claude API — USD za 1M tokenů (input, output). Kurz pro přepočet. USD_TO_CZK = 25.0 PRICING = { "claude-haiku-4-5": (1.00, 5.00), "claude-sonnet-4-6": (3.00, 15.00), "claude-opus-4-8": (5.00, 25.00), } _cost = {"input_tokens": 0, "output_tokens": 0, "usd": 0.0, "calls": 0} HERE = Path(__file__).resolve().parent LOG_FILE = HERE / "_log_recepty.txt" # ========================= # ENV (Anthropic klíč) # ========================= def _load_env(): env_path = Path(__file__).resolve().parent.parent / "Medevio" / ".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().strip('"').strip("'") _load_env() def log(msg: str) -> None: print(msg) with LOG_FILE.open("a", encoding="utf-8") as f: f.write(msg + "\n") # ========================= # ČTENÍ MAILŮ (Graph, read-only) # ========================= def newest_inbox_messages(mailbox: str, n: int) -> list[dict]: """N nejnovějších mailů z Inboxu (bez filtru na přílohy), tělo jako text.""" url = f"{graph_mail.GRAPH}/users/{mailbox}/mailFolders/inbox/messages" params = { "$orderby": "receivedDateTime desc", "$select": "id,subject,from,receivedDateTime,bodyPreview,body", "$top": n, } headers = {**graph_mail._headers(), "Prefer": 'outlook.body-content-type="text"'} r = requests.get(url, headers=headers, params=params, timeout=60) r.raise_for_status() return r.json().get("value", [])[:n] # ========================= # AI KLASIFIKACE + VYTĚŽENÍ (Claude) # ========================= PROMPT = """Jsi asistent ordinace praktického lékaře (MUDr. Michaela Buzalková). \ Rozhoduješ, zda e-mail obsahuje ŽÁDOST O PŘEDPIS LÉKU (recept), a pokud ano, \ vytěžíš detaily. Pravidla: - "je_zadost_o_recept": true POUZE pokud pisatel žádá o předepsání léku / \ vystavení receptu (i opakovaného, i "prosím o léky jako obvykle"). - NENÍ žádost o recept: objednání na vyšetření, dotaz na výsledky, omluva, \ faktura, newsletter, zdravotní zpráva z nemocnice, žádanka, potvrzení. - "pacient": celé jméno pacienta, pro kterého má být lék předepsán. POZOR: \ může se lišit od odesílatele (rodič píše za dítě, manžel za manželku). \ Pokud jméno z mailu neplyne, použij jméno odesílatele. - "rodne_cislo": rodné číslo pacienta PŘESNĚ jak je v textu napsané (jen číslice, \ příp. s lomítkem — NEPŘEVÁDĚJ na datum narození), jinak null. - "datum_narozeni": datum narození ve formátu YYYY-MM-DD, pokud je v textu \ uvedeno (a není to rodné číslo), jinak null. - "leky": seznam požadovaných léků; u každého "nazev" a "poznamka" \ (síla/dávkování/množství/„jako obvykle", pokud je uvedeno, jinak null). \ Pokud pacient žádá o "své obvyklé léky" bez konkrét, vrať jeden záznam \ {"nazev": "obvyklé léky", "poznamka": "bez upřesnění"}. - "telefon": telefonní číslo uvedené v mailu (jen číslice, jak je napsané), jinak null. - "poznamka": cokoliv důležitého navíc (spěchá, vyzvedne osobně...), jinak null. Vrať POUZE JSON: {"je_zadost_o_recept": true/false, "pacient": "..."|null, "rodne_cislo": "..."|null, "datum_narozeni": "YYYY-MM-DD"|null, "telefon": "..."|null, "leky": [{"nazev": "...", "poznamka": "..."|null}], "poznamka": "..."|null, "duvod": "krátké zdůvodnění rozhodnutí"} E-MAIL: Odesílatel: %(sender)s Předmět: %(subject)s Přijato: %(received)s Tělo (zkráceno): %(body)s """ def _claude_json(prompt: str, model: str, max_tokens: int) -> dict: """Zavolá Claude a vrátí JSON objekt z odpovědi.""" r = requests.post( "https://api.anthropic.com/v1/messages", headers={ "x-api-key": os.environ["ANTHROPIC_API_KEY"], "anthropic-version": "2023-06-01", "content-type": "application/json", }, json={ "model": model, "max_tokens": max_tokens, "messages": [{"role": "user", "content": prompt}], }, timeout=60, ) r.raise_for_status() data = r.json() usage = data.get("usage", {}) in_tok = usage.get("input_tokens", 0) out_tok = usage.get("output_tokens", 0) price_in, price_out = PRICING.get(model, PRICING["claude-haiku-4-5"]) _cost["input_tokens"] += in_tok _cost["output_tokens"] += out_tok _cost["usd"] += in_tok / 1_000_000 * price_in + out_tok / 1_000_000 * price_out _cost["calls"] += 1 text = data["content"][0]["text"].strip() m = re.search(r"\{.*\}", text, re.DOTALL) if not m: raise ValueError(f"Claude nevrátil JSON: {text}") return json.loads(m.group(0)) def classify(msg: dict) -> dict: sender = (msg.get("from") or {}).get("emailAddress", {}) body = (msg.get("body") or {}).get("content") or msg.get("bodyPreview") or "" prompt = PROMPT % { "sender": f"{sender.get('name', '')} <{sender.get('address', '')}>", "subject": msg.get("subject") or "", "received": msg.get("receivedDateTime") or "", "body": body[:6000], } return _claude_json(prompt, ANTHROPIC_MODEL, 500) # Rozhodnutí nejednoznačného pacienta podle historie předepsaných léků. AMBIG_PROMPT = """V kartotéce je více pacientů stejného jména. Podle požadovaných léků \ z e-mailu a historie předepsaných léků jednotlivých kandidátů rozhodni, který pacient \ o recept žádá. Požadované léky z e-mailu: %(leky)s Kandidáti a jejich léky předepsané v minulosti: %(kandidati)s Pravidla: - Vyber kandidáta, jehož historie odpovídá požadovaným lékům (stejný lék, stejná \ účinná látka, ekvivalentní generikum/originál, lék na stejnou diagnózu). - Pokud nelze spolehlivě rozhodnout (žádná smysluplná vazba), vrať idpac: null. Vrať POUZE JSON: {"idpac": 1234|null, "duvod": "krátké zdůvodnění"} """ # ========================= # OVĚŘENÍ PACIENTA V MEDICUSU # ========================= def _norm_text(s: str) -> str: """Bez diakritiky, velkými písmeny, sjednocené mezery.""" s = unicodedata.normalize("NFKD", s or "") s = "".join(c for c in s if not unicodedata.combining(c)) return re.sub(r"\s+", " ", s).strip().upper() def _norm_rc(s: str) -> str: """Z rodného čísla nechá jen číslice (Medicus ukládá RČ bez lomítka).""" return re.sub(r"\D", "", s or "") def _norm_phone(s: str) -> str: """Z telefonu nechá jen číslice, bez předvolby 420.""" digits = re.sub(r"\D", "", s or "") if digits.startswith("420"): digits = digits[3:] return digits def _rc_to_birthdate(rc: str) -> str | None: """Z RČ odvodí datum narození YYYY-MM-DD (ženy mají měsíc +50).""" rc = _norm_rc(rc) if len(rc) not in (9, 10): return None yy, mm, dd = int(rc[0:2]), int(rc[2:4]), int(rc[4:6]) if mm > 50: mm -= 50 year = yy + (2000 if len(rc) == 10 and yy < 54 else 1900) try: from datetime import date return date(year, mm, dd).isoformat() except ValueError: return None def _drug_matches(requested: str, prescribed: str) -> bool: """ Shoda názvu léku z mailu s názvem z receptu: substring oběma směry ("tadalafil" ~ "TADALAFIL ACCORD") + prefix prvních slov od 5 znaků ("Concord" ~ "CONCOR COR" — překlepy/varianty názvu). """ a, b = _norm_text(requested), _norm_text(prescribed) if not a or not b: return False if a in b or b in a: return True ta, tb = a.split()[0], b.split()[0] shorter, longer = sorted((ta, tb), key=len) return len(shorter) >= 5 and longer.startswith(shorter) class MedicusLookup: """Kartotéka v paměti: pacienti z KAR + e-maily z KARKONTAKT (TYP=3). Drží otevřené spojení pro dotazy na historii receptů (RECEPT).""" def __init__(self): self.db = get_medicus_db() self.patients = self.db.query_dict( "SELECT k.IDPAC, k.RODCIS, k.PRIJMENI, k.JMENO, k.DATNAR, " "k.POJ, k.VYRAZEN FROM KAR k " "WHERE k.PRIJMENI IS NOT NULL AND k.PRIJMENI <> ''" ) # TYP: 1 = pevná linka, 2 = mobil, 3 = e-mail. contacts = self.db.query_dict( "SELECT kk.IDPAC, kk.KONTAKT, kk.TYP FROM KARKONTAKT kk " "WHERE kk.KONTAKT IS NOT NULL AND kk.KONTAKT <> ''" ) self.by_rc = {} self.by_name = {} by_id = {} for p in self.patients: by_id[p["idpac"]] = p rc = _norm_rc(p.get("rodcis") or "") if rc: self.by_rc[rc] = p key = _norm_text(f"{p.get('jmeno') or ''} {p.get('prijmeni') or ''}") if key: self.by_name.setdefault(frozenset(key.split()), []).append(p) self.by_email = {} self.by_phone = {} for c in contacts: p = by_id.get(c["idpac"]) if not p: continue kontakt = (c["kontakt"] or "").strip() if "@" in kontakt: self.by_email.setdefault(kontakt.lower(), []).append(p) else: phone = _norm_phone(kontakt) if len(phone) >= 9: self.by_phone.setdefault(phone, []).append(p) @staticmethod def describe(p: dict) -> str: rc = p.get("rodcis") or "?" datnar = p.get("datnar") vyrazen = " [VYŘAZEN]" if (p.get("vyrazen") or "") == "A" else "" return (f"{p.get('prijmeni','')} {p.get('jmeno','')}, RČ {rc}, " f"nar. {datnar}, poj. {p.get('poj','?')}, idpac {p.get('idpac')}{vyrazen}") def match(self, verdict: dict, sender_email: str) -> tuple[str, list[dict]]: """ Vrátí (typ_shody, kandidáti). Pořadí spolehlivosti: RČ (jednoznačné) > e-mail odesílatele > telefon z mailu > jméno (+ příp. datum narození). """ # 1) Rodné číslo z textu mailu — nejspolehlivější. rc = _norm_rc(verdict.get("rodne_cislo") or "") if rc and rc in self.by_rc: return "RČ", [self.by_rc[rc]] # 2) E-mail odesílatele v kartotéce. hits = self.by_email.get((sender_email or "").strip().lower(), []) if hits: return "E-MAIL", hits # 3) Telefon z textu mailu v kartotéce. phone = _norm_phone(verdict.get("telefon") or "") if len(phone) >= 9: hits = self.by_phone.get(phone, []) if hits: return "TELEFON", hits # 4) Jméno (bez diakritiky, bez ohledu na pořadí slov). name_key = frozenset(_norm_text(verdict.get("pacient") or "").split()) candidates = self.by_name.get(name_key, []) if name_key else [] # Zúžení datem narození (z pole datum_narozeni nebo z RČ, které nesedlo). birth = verdict.get("datum_narozeni") or (_rc_to_birthdate(rc) if rc else None) if len(candidates) > 1 and birth: narrowed = [p for p in candidates if str(p.get("datnar") or "")[:10] == birth] if narrowed: return "JMÉNO+DATUM", narrowed if candidates: return "JMÉNO", candidates return "NENALEZEN", [] def close(self) -> None: self.db.close() def prescriptions(self, idpac: int, months: int = RECEPT_MONTHS) -> list[dict]: """Nestornované recepty pacienta za posledních N měsíců, nejnovější první.""" since = (date.today() - timedelta(days=months * 30)).isoformat() return self.db.query_dict( "SELECT r.DATUM, r.LEK, r.DSIG FROM RECEPT r " "WHERE r.IDPAC = ? AND r.DATUM >= ? AND r.STORNO <> 'T' " "ORDER BY r.DATUM DESC", (idpac, since), ) def resolve_by_prescriptions( self, candidates: list[dict], leky: list[dict] ) -> tuple[dict | None, str, list[str]]: """ Rozhodne nejednoznačnost podle historie receptů kandidátů. Vrátí (vítěz|None, popis_metody, řádky_detailu pro log). """ requested = [(lek.get("nazev") or "").strip() for lek in leky or []] requested = [r for r in requested if r] detail: list[str] = [] # Historie + skóre (kolik požadovaných léků má kandidát v historii). infos = [] for p in candidates: history = self.prescriptions(p["idpac"]) drugs = sorted({(h.get("lek") or "").strip() for h in history if h.get("lek")}) matched = sorted( {req for req in requested if any(_drug_matches(req, d) for d in drugs)} ) infos.append({"p": p, "drugs": drugs, "matched": matched}) detail.append( f"idpac {p['idpac']}: {len(history)} receptů/{RECEPT_MONTHS} měs., " f"shoda léků: {', '.join(matched) if matched else 'žádná'} " f"(historie: {', '.join(drugs) if drugs else 'prázdná'})" ) # Deterministicky: jediný kandidát s nejvyšším nenulovým skóre vyhrává. best = max(len(i["matched"]) for i in infos) winners = [i for i in infos if len(i["matched"]) == best] if best > 0 and len(winners) == 1: return winners[0]["p"], "LÉKY V HISTORII", detail # Sporné (nikdo/více se shodou) → Claude nad seznamy léků. if any(i["drugs"] for i in infos): try: prompt = AMBIG_PROMPT % { "leky": ", ".join(requested) or "(neuvedeno)", "kandidati": "\n".join( f"- idpac {i['p']['idpac']} " f"({self.describe(i['p'])}): " f"{', '.join(i['drugs']) if i['drugs'] else 'žádné recepty'}" for i in infos ), } v = _claude_json(prompt, ANTHROPIC_MODEL, 300) idpac = v.get("idpac") winner = next((i["p"] for i in infos if i["p"]["idpac"] == idpac), None) if winner: detail.append(f"AI rozhodnutí: {v.get('duvod', '')}") return winner, "LÉKY+AI", detail detail.append(f"AI nerozhodlo: {v.get('duvod', '')}") except Exception as e: detail.append(f"AI rozhodování selhalo: {type(e).__name__}: {e}") return None, "", detail # ========================= # MEDEVIO — ZÁPIS POŽADAVKU # ========================= # Markery oddělující forward/citaci v těle mailu (Outlook CZ/EN, Gmail > styl). _FORWARD_MARKERS_RE = re.compile( r"^-{3,}\s*(original message|forwarded message|původní zpráva|pův\.?\s*zpráva" r"|weitergeleitete nachricht)", re.IGNORECASE, ) def _compress_body(body: str) -> str: """Odstraní forwardovanou/citovanou část a smrskne dvojité prázdné řádky.""" lines = (body or "").splitlines() cut_at = None for i, line in enumerate(lines): s = line.strip() sl = s.lower() # Oddělovač Outlooku (--- Original Message --- apod.) if _FORWARD_MARKERS_RE.match(s): cut_at = i break # Citované řádky > (Gmail/Thunderbird) if s.startswith(">"): cut_at = i break # Outlook CZ: "Od: Jméno " + "Odesláno:" do 5 řádků if re.match(r"^od:\s*.+@", sl): lookahead = " ".join(lines[i + 1 : i + 6]).lower() if "odesláno:" in lookahead or "odeslano:" in lookahead: cut_at = i break # Outlook EN: "From: Name " + "Sent:" do 5 řádků if re.match(r"^from:\s*.+@", sl): lookahead = " ".join(lines[i + 1 : i + 6]).lower() if "sent:" in lookahead: cut_at = i break if cut_at is not None: lines = lines[:cut_at] # Odstraň trailing prázdné řádky while lines and not lines[-1].strip(): lines.pop() text = "\n".join(lines) # Dva a více prázdných řádků → jeden prázdný řádek text = re.sub(r"\n{3,}", "\n\n", text) return text.strip() def _format_leky(leky: list) -> str: """Formátuje seznam léků pro pole 'Název léků' — čárkami oddělený výčet.""" parts = [] for lek in leky or []: nazev = (lek.get("nazev") or "").strip() if not nazev: continue pozn = (lek.get("poznamka") or "").strip() parts.append(f"{nazev} ({pozn})" if pozn else nazev) return ", ".join(parts) def _format_poznamka(msg: dict) -> str: """Sestaví userNote pro Medevio: hlavička + zkomprimované tělo mailu.""" sender = (msg.get("from") or {}).get("emailAddress", {}) name = sender.get("name", "").strip() email_addr = sender.get("address", "").strip() received_raw = msg.get("receivedDateTime") or "" try: from dateutil import parser as _dtparser, tz as _tz dt = _dtparser.isoparse(received_raw).astimezone(_tz.gettz("Europe/Prague")) header_date = f"{dt.day}.{dt.month}.{dt.year} {dt.strftime('%H:%M')}" except Exception: header_date = received_raw header = f"{header_date} | {name} <{email_addr}>" body = (msg.get("body") or {}).get("content") or msg.get("bodyPreview") or "" compressed = _compress_body(body) return f"{header}\n\n{compressed}" def _medevio_find_patient(rc_normalized: str) -> str | None: """Najde UUID pacienta v Medeviu podle normalizovaného RČ (jen číslice). Používá MySQL zrcadlo medevio_pacient — patient_id je identické s GraphQL API.""" try: from Knihovny.mysql_db import connect_mysql conn = connect_mysql() conn.ping(reconnect=True) cur = conn.cursor() cur.execute( "SELECT patient_id FROM medevio_pacient " "WHERE REPLACE(identification_number,'/','') = %s LIMIT 1", [rc_normalized], ) row = cur.fetchone() return row[0] if row else None except Exception as e: log(f" [Medevio hledání pacienta selhalo] {type(e).__name__}: {e}") return None # ========================= # HLAVNÍ BĚH # ========================= def main() -> None: log("\n" + "=" * 70) log(f"START — schránka={MAILBOX}, test na {NEWEST_N} nejnovějších mailech") log("REŽIM: read-only (ve schránce se nic nemění)") msgs = newest_inbox_messages(MAILBOX, NEWEST_N) log(f"Načteno {len(msgs)} mailů.") lookup = MedicusLookup() log(f"Medicus: kartotéka načtena ({len(lookup.patients)} pacientů, " f"{len(lookup.by_email)} e-mailů, {len(lookup.by_phone)} telefonů).\n") requests_found = 0 for i, msg in enumerate(msgs, 1): sender = (msg.get("from") or {}).get("emailAddress", {}) subj = msg.get("subject") or "(bez předmětu)" log(f"--- [{i}/{len(msgs)}] {msg.get('receivedDateTime', '')} ---") log(f" Od: {sender.get('name', '')} <{sender.get('address', '')}>") log(f" Předmět: {subj}") try: v = classify(msg) except Exception as e: log(f" [AI CHYBA] {type(e).__name__}: {e}\n") continue if not v.get("je_zadost_o_recept"): log(f" => NENÍ žádost o recept — {v.get('duvod', '')}\n") continue requests_found += 1 log(f" => ŽÁDOST O RECEPT — {v.get('duvod', '')}") log(f" Pacient: {v.get('pacient') or '(neuvedeno)'}") if v.get("rodne_cislo"): log(f" Rodné číslo: {v['rodne_cislo']}") if v.get("datum_narozeni"): log(f" Narozen: {v['datum_narozeni']}") if v.get("telefon"): log(f" Telefon: {v['telefon']}") for lek in v.get("leky") or []: pozn = f" — {lek['poznamka']}" if lek.get("poznamka") else "" log(f" Lék: {lek.get('nazev', '?')}{pozn}") if v.get("poznamka"): log(f" Poznámka: {v['poznamka']}") # Ověření v Medicusu. identified_patient = None match_type, candidates = lookup.match(v, sender.get("address", "")) if match_type == "NENALEZEN": log(" Medicus: [NENALEZEN] pacient v kartotéce nedohledán") elif len(candidates) == 1: log(f" Medicus: [SHODA {match_type}] {lookup.describe(candidates[0])}") identified_patient = candidates[0] else: log(f" Medicus: [NEJEDNOZNAČNÉ — {match_type}, " f"{len(candidates)} kandidátů] — rozhoduji podle historie receptů:") winner, method, detail = lookup.resolve_by_prescriptions( candidates, v.get("leky") or [] ) for line in detail: log(f" - {line}") if winner: log(f" Medicus: [SHODA {match_type}+{method}] " f"{lookup.describe(winner)}") identified_patient = winner else: log(" Medicus: [NEROZHODNUTO] historie receptů " "nejednoznačnost nevyřešila — nutná ruční kontrola") for p in candidates: log(f" - {lookup.describe(p)}") # Pokud je pacient jednoznačně identifikován, založ požadavek v Medeviu. if identified_patient: rc = _norm_rc(identified_patient.get("rodcis") or "") leky_str = _format_leky(v.get("leky") or []) pozn_str = _format_poznamka(msg) patient_uuid = _medevio_find_patient(rc) if not patient_uuid: log(f" Medevio: [NENALEZEN] RČ {rc} v Medeviu nenalezeno — požadavek nezaložen") else: try: result = _medevio.zaloz_pozadavek_recept(patient_uuid, leky_str, pozn_str) log(f" Medevio: [ZALOZENO] požadavek {result['request_id']}" f" | léky: {leky_str}") except Exception as e: log(f" Medevio: [CHYBA] {type(e).__name__}: {e}") log("") lookup.close() log(f"HOTOVO: {len(msgs)} mailů, žádostí o recept: {requests_found}.") log( f"CENA AI: {_cost['calls']} volání, " f"tokeny input={_cost['input_tokens']} output={_cost['output_tokens']}, " f"${_cost['usd']:.4f} ≈ {_cost['usd'] * USD_TO_CZK:.2f} Kč" ) if __name__ == "__main__": main()