diff --git a/Medevio/60 ScansProcessing/corrections.json b/Medevio/60 ScansProcessing/corrections.json index 787e182..c969e1d 100644 --- a/Medevio/60 ScansProcessing/corrections.json +++ b/Medevio/60 ScansProcessing/corrections.json @@ -1978,5 +1978,53 @@ { "original": "7606050518 Novotný, Pavel split_004.pdf", "corrected": "7606050518 2026-06-04 Novotný, Pavel [domácí měření TK] [pěkná kompenzace].pdf" + }, + { + "original": "330613108 2026-06-01 Schořálek, Jaroslav [domácí péče] [4 do 30JUN2026, 06311 ad hoc, 06315 1xd3xt, 06329 1xd3xt].pdf", + "corrected": "330613108 2026-06-01 Schořálek, Jaroslav [domácí péče updated] [4 do 30JUN2026, 06311 ad hoc, 06315 1xd3xt, 06329 1xd3xt].pdf" + }, + { + "original": "436225107 2026-02-09 Krejbichová, Jarmila [LZ revmatologie] [kontrola, osteoporóza-osteopénie, CHOPN, art. hypertenze, prolia 60mg, ko 6/2026].pdf", + "corrected": "436225107 2026-02-09 Krejbichová, Jarmila [LZ revmatologie] [kontrola, osteoporóza-osteopénie, CHOPN, art. hypertenze, prolia 60mg, ko 62026].pdf" + }, + { + "original": "436225107 2025-12-22 Krejbichová, Jarmila [LZ revmatologie] [kontrola, osteoporóza (nyní osteopénie dle DEXA), CHOPN, art. hypertenze, aplikace Prolia 60mg s.c., ko 6/2026].pdf", + "corrected": "436225107 2025-12-22 Krejbichová, Jarmila [LZ revmatologie] [kontrola, osteoporóza (nyní osteopénie dle DXA), CHOPN, art. hypertenze, aplikace Prolia 60mg s.c., ko 62026].pdf" + }, + { + "original": "465418044 2026-06-10 Dvořáková, Zdeňka [Laboratoř] [moč: URO +1, PRO +/-, GLU +4 (111 mmol/L)].pdf", + "corrected": "465418044 2026-06-10 Dvořáková, Zdeňka [Uritex] [moč URO +1, PRO +-, GLU +4 (111 mmolL)].pdf" + }, + { + "original": "5606051143 2026-05-19 Zána, Jan [PZ lázeňská] [21APR2026–19MAY2026 gonartroza st.p.TEP dx., zlepšení, edukace provedena].pdf", + "corrected": "5606051143 2026-05-19 Zána, Jan [PZ lázně] [21APR2026–19MAY2026 gonartroza st.p.TEP dx., zlepšení, edukace provedena].pdf" + }, + { + "original": "6561150607 2026-06-05 Tipplová, Michaela [Laboratoř] [moč: Streptococcus agalactiae 10E5 CFU/ml, citlivý na amoxicilin, cotrimoxazol, nitrofurantoin].pdf", + "corrected": "6561150607 2026-06-05 Tipplová, Michaela [Laboratoř] [moč Streptococcus agalactiae 10E5 CFUml, citlivý na amoxicilin, cotrimoxazol, nitrofurantoin].pdf" + }, + { + "original": "6708101114 2026-04-22 Pospíšil, Jiří [EKG] [SR 84/min, PQ 150ms, QRS 85ms, QTc 404ms, bez patologických změn ST-T].pdf", + "corrected": "6708101114 2026-04-22 Pospíšil, Jiří [EKG] [SR 84min, PQ 150ms, QRS 85ms, QTc 404ms, bez patologických změn ST-T].pdf" + }, + { + "original": "9651301253 2026-06-10 Kut Citores, Markéta [Uritex] [moč GLU +- 5.5 mmolL, ostatní v normě].pdf", + "corrected": "9651301253 2026-06-10 KutCitores, Markéta [Uritex] [moč GLU +- 5.5 mmolL, ostatní v normě].pdf" + }, + { + "original": "5606051143 2026-06-10 Zána, Jan [EKG] [bez hodnocení].pdf", + "corrected": "5606051143 2026-06-10 Zána, Jan [EKG] [bez hodnocení].pdf" + }, + { + "original": "0057130183 2026-05-27 Kreibichová, Jiřina [Souhlas s úhradou] [G809 Mozková obrna NS, léčebně rehab. péče, platnost do 26AUG2026].pdf", + "corrected": "0057130183 2026-05-27 Kreibichová, Jiřina [schválení lázně] [G809 Mozková obrna NS, léčebně rehab. péče, platnost do 26AUG2026].pdf" + }, + { + "original": "6358097207 2026-06-08 Broulímová, Marija [rozhodnutí ZP] [návrh schválen, lázně VI/3 kořenové syndromy, 21 dní, platnost do 08.12.2026].pdf", + "corrected": "6358097207 2026-06-08 Broulímová, Marija [schválení lázně] [návrh schválen, lázně VI3 kořenové syndromy, 21 dní, platnost do 08.12.2026].pdf" + }, + { + "original": "štoček.pdf", + "corrected": "8910193336 2026-06-03 Štoček, Martin [výpis z dokumentace] [od předchozího PL].pdf" } ] \ No newline at end of file diff --git a/Medicus/PrůzkumDatabáze/NOTES.md b/Medicus/PrůzkumDatabáze/NOTES.md index 64f352b..811e14b 100644 --- a/Medicus/PrůzkumDatabáze/NOTES.md +++ b/Medicus/PrůzkumDatabáze/NOTES.md @@ -709,12 +709,19 @@ ORDER BY h.DATUM DESC | Nástroj | Popis | |---|---| | `get_patient(idpac)` | Základní info o pacientovi z KAR — jmeno, prijmeni, rc, datnar, pojistovna | -| `search_patients(query)` | Hledání pacienta podle příjmení/jména/RC, max 50 výsledků | +| `search_patients(query, datum_narozeni?)` | Hledání pacienta podle jména/RC, max 50 výsledků | | `get_patient_timeline(idpac, datum_od?, datum_do?)` | Chronologický přehled z DOCLIST — všechny záznamy pacienta | | `parse_histdoc_data(idhistdoc)` | Dekóduje DATA blob z HISTDOC — vrátí dict {Kod, Nazev, Pocet, Cena, Stav, Doklad…} | | `get_table_info(table)` | Rozšířené info o tabulce — typy sloupců, nullable, PK, počet záznamů | | `safe_query(sql, params?)` | SELECT s ochranou — varuje pro velké tabulky bez WHERE, limit 500 řádků | +### Nové nástroje (přidáno 2026-06-12) +| Nástroj | Popis | +|---|---| +| `search_patients(query, datum_narozeni?)` — **rozšířeno** | Jméno nyní bez ohledu na diakritiku a pořadí slov („mateju petr" najde „Petr Matějů"); RC podle číslic; volitelný filtr data narození; nově vrací i datnar a vyrazen | +| `search_patient_by_contact(kontakt)` | Pacient podle e-mailu/telefonu z KARKONTAKT (TYP: 1=pevná, 2=mobil, 3=e-mail); telefony porovnává jen po číslicích, ignoruje +420 a mezery | +| `get_columns_overview(table, sample_rows?)` | Sémantika sloupců — ze vzorku N řádků (výchozí 1000) top 5 hodnot + četnosti per sloupec (např. zjistí, že RECEPT.STORNO je 'T'/'F') | + ### Velké tabulky vyžadující WHERE (safe_query varuje automaticky) LOG, ZURNAL, LABVD, DOCLIST, PZT, LEKY, DEKLINK diff --git a/OrdinaceAgentEmail/NOTES.md b/OrdinaceAgentEmail/NOTES.md new file mode 100644 index 0000000..da70dc4 --- /dev/null +++ b/OrdinaceAgentEmail/NOTES.md @@ -0,0 +1,76 @@ +# OrdinaceAgentEmail — agent na žádosti o recept + +Hledá ve schránce **ordinace@buzalkova.cz** e-maily, kde pacient žádá +o předepsání léku (recept), vytěžuje pacienta + požadované léky a pacienta +ověřuje v kartotéce Medicusu. + +## Stav: testovací režim (read-only) + +`recepty_agent.py` zatím jen načte `NEWEST_N` (= 5) nejnovějších mailů +z Inboxu, klasifikuje je Claude modelem a vypíše report do konzole +a `_log_recepty.txt`. **Ve schránce nic nemění** (žádné kategorie, +přesuny, odpovědi), žádný `state.json`. + +## Tok + +1. **Graph API** — `newest_inbox_messages()`: N nejnovějších mailů z Inboxu, + bez filtru na přílohy (`$orderby receivedDateTime desc` — bez filtru + funguje, na rozdíl od kombinace s `hasAttachments`, viz EmailAgent). +2. **AI klasifikace + vytěžení (Claude `claude-haiku-4-5`)** — pro každý mail + JSON: `je_zadost_o_recept`, `pacient` (může se lišit od odesílatele — + příbuzní píší za pacienta), `rodne_cislo` (přesně jak je v textu), + `datum_narozeni`, `leky[]` (nazev + poznamka), `poznamka`, `duvod`. +3. **Ověření v Medicusu (`MedicusLookup`)** — celá kartotéka se načte do + paměti (KAR ~6300 pacientů + kontakty z KARKONTAKT: ~70 e-mailů, + ~4150 telefonů; TYP: 1=pevná, 2=mobil, 3=e-mail). Párování v pořadí + spolehlivosti: + 1. **RČ** z textu mailu (Medicus ukládá RČ bez lomítka) — jednoznačné, + 2. **e-mail odesílatele** proti KARKONTAKT, + 3. **telefon z textu mailu** proti KARKONTAKT (jen číslice, bez +420 — + telefonů je v kartotéce hodně, často rozhodne i duplicitní jména), + 4. **jméno** — bez diakritiky, bez ohledu na pořadí slov (Jaroslav Klíma + = Klíma Jaroslav); při více kandidátech zúžení datem narození + (z `datum_narozeni` nebo odvozeným z nesedícího RČ). + Výstup: `[SHODA RČ/E-MAIL/JMÉNO/JMÉNO+DATUM]` s detaily pacienta + (RČ, datum narození, pojišťovna, idpac, příznak vyřazení), nebo + `[NENALEZEN]`. +4. **Nejednoznačnost (více pacientů stejného jména, např. otec a syn)** — + `resolve_by_prescriptions()`: načte nestornované recepty kandidátů + z tabulky `RECEPT` (`STORNO <> 'T'`, posledních `RECEPT_MONTHS` = 24 + měsíců) a rozhodne podle shody požadovaných léků s historií: + 1. **Deterministicky** — `_drug_matches()`: substring oběma směry + („tadalafil" ~ „TADALAFIL ACCORD") + prefix prvních slov od 5 znaků + („Concord" ~ „CONCOR"). Jediný kandidát s nejvyšším nenulovým + skóre vyhrává → `[SHODA JMÉNO+LÉKY V HISTORII]`. + 2. **Claude fallback** — když deterministika nerozhodne (nikdo/více se + shodou), model dostane požadované léky + seznamy předepsaných léků + kandidátů a rozhodne i přes generika/účinné látky → `[SHODA + JMÉNO+LÉKY+AI]`. Když ani AI nerozhodne → `[NEROZHODNUTO]` + + výpis kandidátů k ruční kontrole. +5. Report + cena AI za běh (~0,04 Kč/mail). + +## Sdílená infrastruktura + +- `EmailAgent/graph_mail.py` — import přes `sys.path` (stejná app registrace, + Mail.Read Application). Credentials natvrdo tam. +- `Knihovny/medicus_db.py` — Firebird připojení k Medicusu (DSN podle názvu + počítače, na Z230 → `reporter:c:\medicus\medicus.fdb`). +- `ANTHROPIC_API_KEY` z `Medevio/.env`. + +## Známé limity / TODO + +- E-mailových kontaktů je v kartotéce málo (~70 z 6300 pacientů) — párování + e-mailem zabere zřídka; telefonů je ~4150, proto se vytěžuje i telefon + z textu mailu. Do budoucna by šlo e-mail odesílatele po ručním potvrzení + do KARKONTAKT doplňovat. +- Párování jménem vyžaduje přesnou shodu množiny slov — překlepy ve jméně + nenajde (kandidát: fuzzy matching / nabídka podobných jmen). +- Zatím bez označování mailů, bez summary e-mailu, bez odpovědi pacientovi — + kandidáti na další krok (vzor: `EmailAgent/faktury_agent.py`). +- Bez idempotence (žádný state) — testovací běhy čtou vždy posledních N mailů. + +## Spuštění + +```powershell +python U:\ordinaceprojekt\OrdinaceAgentEmail\recepty_agent.py +``` diff --git a/OrdinaceAgentEmail/_log_recepty.txt b/OrdinaceAgentEmail/_log_recepty.txt new file mode 100644 index 0000000..b3c4a74 --- /dev/null +++ b/OrdinaceAgentEmail/_log_recepty.txt @@ -0,0 +1,179 @@ + +====================================================================== +START — schránka=ordinace@buzalkova.cz, test na 5 nejnovějších mailech +REŽIM: read-only (ve schránce se nic nemění) +Načteno 5 mailů. + +--- [1/5] 2026-06-12T09:17:38Z --- + Od: Petr + Předmět: žádost o eRecept + => ŽÁDOST O RECEPT — Pisatel explicitně žádá o poslání eReceptu na konkrétní lék (tadalafil) + Pacient: Petr Matějů + Lék: tadalafil + Poznámka: Tel.: 602614966 + +--- [2/5] 2026-06-12T09:12:57Z --- + Od: jardatep@seznam.cz + Předmět: Recept + => ŽÁDOST O RECEPT — Pisatel explicitně žádá o léky ('prosím, potřebuji léky'), konkrétně jmenuje dva léky a uvádí své jméno a rodné číslo. + Pacient: Klíma Jaroslav + Rodné číslo: 1965-04-16 + Lék: Tezao + Lék: Concord + +--- [3/5] 2026-06-12T08:01:18Z --- + Od: Medatron + Předmět: Expedice Vaší objednávky 2026000537 + => NENÍ žádost o recept — Jedná se o automatické potvrzení expedice objednávky od e-shopu Medatron, nikoli o žádost o předpis léku. E-mail obsahuje informace o doručování zboží, není to komunikace pacienta nebo jeho zástupce s lékařem. + +--- [4/5] 2026-06-12T05:31:30Z --- + Od: kriz@distribucecz.cz + Předmět: Faktura + => NENÍ žádost o recept — Email obsahuje fakturu za vakcíny od dodavatele. Nejde o žádost pacienta o předpis léku, ale o obchodní komunikaci týkající se fakturace. + +--- [5/5] 2026-06-11T15:27:02Z --- + Od: neodpovidejte@portalzp.cz + Předmět: Portál VoZP ČR: nová zpráva ve schránce "ZÚČTOVACÍ ZPRÁVY" + => NENÍ žádost o recept — Jedná se o automatickou notifikaci z portálu VoZP ČR o nové zprávě v schránce zúčtovacích zpráv. Nejedná se o žádost pacienta o předepsání léku, ale o systémové oznámení zdravotní pojišťovny. + +HOTOVO: 5 mailů, žádostí o recept: 2. +CENA AI: 5 volání, tokeny input=4060 output=733, $0.0077 ≈ 0.19 Kč + +====================================================================== +START — schránka=ordinace@buzalkova.cz, test na 5 nejnovějších mailech +REŽIM: read-only (ve schránce se nic nemění) +Načteno 5 mailů. +Medicus: kartotéka načtena (6347 pacientů, 69 e-mailových kontaktů). + +--- [1/5] 2026-06-12T09:17:38Z --- + Od: Petr + Předmět: žádost o eRecept + => ŽÁDOST O RECEPT — E-mail obsahuje explicitní žádost o poslání eReceptu na konkrétní lék (tadalafil). Jde o jasnou žádost o předpis léku. + Pacient: Petr Matějů + Lék: tadalafil + Poznámka: Žádost o eRecept; telefonní kontakt: 602614966 + Medicus: [NEJEDNOZNAČNÉ — JMÉNO, 2 kandidátů] + - Matějů Petr, RČ 520422227, nar. 1952-04-22, poj. 211, idpac 4860 + - Matějů Petr, RČ 8203280437, nar. 1982-03-28, poj. 211, idpac 4861 + +--- [2/5] 2026-06-12T09:12:57Z --- + Od: jardatep@seznam.cz + Předmět: Recept + => ŽÁDOST O RECEPT — E-mail obsahuje jasnou žádost o předepsání léků ("prosím, potřebuji léky") se specifikací konkrétních látek (Tezao, Concord). Jedná se o typickou žádost o recept. + Pacient: Klíma Jaroslav + Narozen: 1965-04-16 + Lék: Tezao + Lék: Concord + Poznámka: Léky na tlak (hypertenzi) + Medicus: [SHODA JMÉNO] Klíma Jaroslav, RČ 6504161928, nar. 1965-04-16, poj. 111, idpac 4356 + +--- [3/5] 2026-06-12T08:01:18Z --- + Od: Medatron + Předmět: Expedice Vaší objednávky 2026000537 + => NENÍ žádost o recept — E-mail je automatická notifikace od e-shopu Medatron o expedici objednávky. Nejedná se o žádost o předpis léku či vystavení receptu, nýbrž o potvrzení expedice zboží. + +--- [4/5] 2026-06-12T05:31:30Z --- + Od: kriz@distribucecz.cz + Předmět: Faktura + => NENÍ žádost o recept — E-mail obsahuje fakturu za vakcíny od distributora, nikoliv žádost o předpis léku od pacienta či jeho zástupce. + +--- [5/5] 2026-06-11T15:27:02Z --- + Od: neodpovidejte@portalzp.cz + Předmět: Portál VoZP ČR: nová zpráva ve schránce "ZÚČTOVACÍ ZPRÁVY" + => NENÍ žádost o recept — Jedná se o automatizované oznámení od pojišťovny VoZP o nové zprávě ve schránce zúčtovacích zpráv. Neobsahuje žádost o předpis léku ani recept. + +HOTOVO: 5 mailů, žádostí o recept: 2. +CENA AI: 5 volání, tokeny input=4510 output=806, $0.0085 ≈ 0.21 Kč + +====================================================================== +START — schránka=ordinace@buzalkova.cz, test na 5 nejnovějších mailech +REŽIM: read-only (ve schránce se nic nemění) +Načteno 5 mailů. +Medicus: kartotéka načtena (6347 pacientů, 69 e-mailových kontaktů). + +--- [1/5] 2026-06-12T09:17:38Z --- + Od: Petr + Předmět: žádost o eRecept + => ŽÁDOST O RECEPT — E-mail explicitně obsahuje žádost o předpis léku (eRecept na tadalafil). Jedná se o jasnou žádost o recept. + Pacient: Petr Matějů + Lék: tadalafil + Poznámka: Požadavek na eRecept; telefonní číslo: 602614966 + Medicus: [NEJEDNOZNAČNÉ — JMÉNO, 2 kandidátů] — rozhoduji podle historie receptů: + - idpac 4860: 13 receptů/24 měs., shoda léků: tadalafil (historie: ATORIS, SILDENAFIL ACTAVIS, TADALAFIL ACCORD) + - idpac 4861: 0 receptů/24 měs., shoda léků: žádná (historie: prázdná) + Medicus: [SHODA JMÉNO+LÉKY V HISTORII] Matějů Petr, RČ 520422227, nar. 1952-04-22, poj. 211, idpac 4860 + +--- [2/5] 2026-06-12T09:12:57Z --- + Od: jardatep@seznam.cz + Předmět: Recept + => ŽÁDOST O RECEPT — Pacient explicitně žádá o předepsání léků ("potřebuji léky") s konkrétním uvedením názvů dvou preparátů. Jedná se jasně o žádost o recept. + Pacient: Klíma Jaroslav + Narozen: 1965-04-16 + Lék: Tezao + Lék: Concord + Poznámka: Léky na krevní tlak + Medicus: [SHODA JMÉNO] Klíma Jaroslav, RČ 6504161928, nar. 1965-04-16, poj. 111, idpac 4356 + +--- [3/5] 2026-06-12T08:01:18Z --- + Od: Medatron + Předmět: Expedice Vaší objednávky 2026000537 + => NENÍ žádost o recept — Jedná se o automatický e-mail od společnosti Medatron informující o expedici objednávky. Nejde o žádost o předpis léku, ale o potvrzení doručení zboží dopravci. + +--- [4/5] 2026-06-12T05:31:30Z --- + Od: kriz@distribucecz.cz + Předmět: Faktura + => NENÍ žádost o recept — E-mail je fakturu za vakcíny od distributora. Nejedná se o žádost pacienta o předepsání léku, ale o obchodní korespondenci týkající se dodávky zboží. + +--- [5/5] 2026-06-11T15:27:02Z --- + Od: neodpovidejte@portalzp.cz + Předmět: Portál VoZP ČR: nová zpráva ve schránce "ZÚČTOVACÍ ZPRÁVY" + => NENÍ žádost o recept — E-mail je automatickou notifikací z portálu VoZP ČR o nové zprávě ve schránce 'ZÚČTOVACÍ ZPRÁVY'. Nejedná se o žádost o předpis léku, recept nebo libovolnou službu od ordinace. Jde o administrativní zprávu zdravotní pojišťovny. + +HOTOVO: 5 mailů, žádostí o recept: 2. +CENA AI: 5 volání, tokeny input=4510 output=834, $0.0087 ≈ 0.22 Kč + +====================================================================== +START — schránka=ordinace@buzalkova.cz, test na 5 nejnovějších mailech +REŽIM: read-only (ve schránce se nic nemění) +Načteno 5 mailů. +Medicus: kartotéka načtena (6347 pacientů, 69 e-mailů, 4156 telefonů). + +--- [1/5] 2026-06-12T09:17:38Z --- + Od: Petr + Předmět: žádost o eRecept + => ŽÁDOST O RECEPT — E-mail obsahuje explicitní žádost o vydání receptu na konkrétní lék (tadalafil) + Pacient: Petr Matějů + Telefon: 602614966 + Lék: tadalafil + Poznámka: Žádost o eRecept (elektronický recept) + Medicus: [SHODA TELEFON] Matějů Petr, RČ 520422227, nar. 1952-04-22, poj. 211, idpac 4860 + +--- [2/5] 2026-06-12T09:12:57Z --- + Od: jardatep@seznam.cz + Předmět: Recept + => ŽÁDOST O RECEPT — Písemně žádá o předepsání léků ("prosím, potřebuji léky"), konkrétně se jedná o lékařský předpis na Tezao a Concord. + Pacient: Klíma Jaroslav + Narozen: 1965-04-16 + Lék: Tezao + Lék: Concord + Poznámka: Pacienta zajímají léky na tlak (hypertenzi) + Medicus: [SHODA JMÉNO] Klíma Jaroslav, RČ 6504161928, nar. 1965-04-16, poj. 111, idpac 4356 + +--- [3/5] 2026-06-12T08:01:18Z --- + Od: Medatron + Předmět: Expedice Vaší objednávky 2026000537 + => NENÍ žádost o recept — Jedná se o automatické potvrzení expedice objednávky od e-shopu Medatron, nikoliv o žádost o předpis léku či vystavení receptu. Email informuje o doručování zboží. + +--- [4/5] 2026-06-12T05:31:30Z --- + Od: kriz@distribucecz.cz + Předmět: Faktura + => NENÍ žádost o recept — E-mail je fakturu za vakcíny od dodavatele, nikoliv žádost o předpis léku od pacienta či jeho zástupce. + +--- [5/5] 2026-06-11T15:27:02Z --- + Od: neodpovidejte@portalzp.cz + Předmět: Portál VoZP ČR: nová zpráva ve schránce "ZÚČTOVACÍ ZPRÁVY" + => NENÍ žádost o recept — Jedná se o automatickou notifikaci od pojišťovny VoZP o nové zprávě v účtovací schránce, nikoliv o žádost o předpis léku. + +HOTOVO: 5 mailů, žádostí o recept: 2. +CENA AI: 5 volání, tokeny input=4705 output=802, $0.0087 ≈ 0.22 Kč + [Medevio hledání pacienta selhalo] RuntimeError: GraphQL error [Search]: [{'message': 'Cannot query field "search" on type "Query".', 'locations': [{'line': 3, 'column': 3}], 'extensions': {'code': 'GRAPHQL_VALIDATION_FAILED'}}] diff --git a/OrdinaceAgentEmail/recepty_agent.py b/OrdinaceAgentEmail/recepty_agent.py new file mode 100644 index 0000000..89586b0 --- /dev/null +++ b/OrdinaceAgentEmail/recepty_agent.py @@ -0,0 +1,643 @@ +""" +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() diff --git a/claude_desktop_config_navrh.json b/claude_desktop_config_navrh.json new file mode 100644 index 0000000..9736a8b --- /dev/null +++ b/claude_desktop_config_navrh.json @@ -0,0 +1,34 @@ +{ + "mcpServers": { + "medicus-firebird": { + "command": "python", + "args": [ + "U:\\ordinaceprojekt\\mcp_firebird.py" + ] + }, + "medevio-api": { + "command": "python", + "args": [ + "U:\\ordinaceprojekt\\mcp_medevio.py" + ] + }, + "medevio-mysql": { + "command": "python", + "args": [ + "U:\\ordinaceprojekt\\mcp_medevio_mysql.py" + ] + }, + "janssen-mongo": { + "command": "python", + "args": [ + "U:\\PythonProject\\Janssen\\mcp_mongo.py" + ] + }, + "janssen-postgres": { + "command": "python", + "args": [ + "U:\\PythonProject\\Janssen\\mcp_postgres.py" + ] + } + } +} diff --git a/mcp_firebird.py b/mcp_firebird.py index 29694d3..4355a9a 100644 --- a/mcp_firebird.py +++ b/mcp_firebird.py @@ -207,30 +207,112 @@ def get_patient(idpac: int) -> dict: raise +def _strip_diacritics(s: str) -> str: + """Bez diakritiky, velkými písmeny, sjednocené mezery.""" + import re + import unicodedata + 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() + + @mcp.tool() -def search_patients(query: str) -> list: - """Vyhledá pacienty podle příjmení, jména nebo rodného čísla (částečná shoda). - Vrátí max. 50 výsledků: idpac, jmeno, prijmeni, rc, pojistovna. +def search_patients(query: str, datum_narozeni: Optional[str] = None) -> list: + """Vyhledá pacienty podle jména/příjmení (bez ohledu na diakritiku a pořadí slov, + např. "Mateju Petr" najde "Petr Matějů") nebo rodného čísla (částečná shoda, jen číslice). + datum_narozeni: volitelný filtr YYYY-MM-DD. + Vrátí max. 50 výsledků: idpac, jmeno, prijmeni, rc, datnar, pojistovna, vyrazen. """ try: + import datetime + import re cur = conn.cursor() - q = query.strip().upper() cur.execute(""" - SELECT FIRST 50 IDPAC, JMENO, PRIJMENI, RODCIS, POJ + SELECT IDPAC, JMENO, PRIJMENI, RODCIS, DATNAR, POJ, VYRAZEN FROM KAR - WHERE UPPER(PRIJMENI) LIKE ? OR UPPER(JMENO) LIKE ? OR RODCIS LIKE ? - ORDER BY PRIJMENI, JMENO - """, [f'%{q}%', f'%{q}%', f'%{q}%']) - rows = cur.fetchall() - return [ - {'idpac': r[0], 'jmeno': r[1], 'prijmeni': r[2], 'rc': r[3], 'pojistovna': r[4]} - for r in rows - ] + WHERE PRIJMENI IS NOT NULL AND PRIJMENI <> '' + """) + + q_digits = re.sub(r'\D', '', query) + q_tokens = _strip_diacritics(query).split() + + results = [] + for r in cur.fetchall(): + idpac, jmeno, prijmeni, rc, datnar, poj, vyrazen = r + datnar_iso = datnar.isoformat() if isinstance(datnar, datetime.date) else datnar + if datum_narozeni and str(datnar_iso or '')[:10] != datum_narozeni: + continue + if q_digits and len(q_digits) >= 4: + if q_digits not in (rc or ''): + continue + elif q_tokens: + name_norm = _strip_diacritics(f"{jmeno or ''} {prijmeni or ''}") + if not all(t in name_norm for t in q_tokens): + continue + elif not datum_narozeni: + continue # prázdný dotaz bez filtru data — nevracet celou kartotéku + results.append({ + 'idpac': idpac, 'jmeno': jmeno, 'prijmeni': prijmeni, 'rc': rc, + 'datnar': datnar_iso, 'pojistovna': poj, 'vyrazen': vyrazen == 'A', + }) + + results.sort(key=lambda p: (p['prijmeni'] or '', p['jmeno'] or '')) + return results[:50] except Exception: log(f"search_patients chyba: {traceback.format_exc()}") raise +@mcp.tool() +def search_patient_by_contact(kontakt: str) -> list: + """Vyhledá pacienty podle kontaktu (e-mail nebo telefon) v tabulce KARKONTAKT. + Částečná shoda bez ohledu na velikost písmen; u telefonů se porovnávají jen + číslice (ignoruje mezery a +420). Vrátí max. 50 výsledků: pacient (idpac, + jmeno, prijmeni, rc, datnar, pojistovna, vyrazen) + kontakt (typ, popis, vztah). + """ + try: + import datetime + import re + cur = conn.cursor() + cur.execute(""" + SELECT kk.IDPAC, kk.KONTAKT, kk.TYP, kk.POPIS, kk.VZTAH, + k.JMENO, k.PRIJMENI, k.RODCIS, k.DATNAR, k.POJ, k.VYRAZEN + FROM KARKONTAKT kk + JOIN KAR k ON k.IDPAC = kk.IDPAC + WHERE kk.KONTAKT IS NOT NULL AND kk.KONTAKT <> '' + """) + + q = kontakt.strip().lower() + q_digits = re.sub(r'\D', '', kontakt) + if q_digits.startswith('420'): + q_digits = q_digits[3:] + + results = [] + for r in cur.fetchall(): + (idpac, kont, typ, popis, vztah, + jmeno, prijmeni, rc, datnar, poj, vyrazen) = r + kont = (kont or '').strip() + hit = q and q in kont.lower() + if not hit and len(q_digits) >= 6: + kont_digits = re.sub(r'\D', '', kont) + if kont_digits.startswith('420'): + kont_digits = kont_digits[3:] + hit = q_digits in kont_digits + if not hit: + continue + results.append({ + 'idpac': idpac, 'jmeno': jmeno, 'prijmeni': prijmeni, 'rc': rc, + 'datnar': datnar.isoformat() if isinstance(datnar, datetime.date) else datnar, + 'pojistovna': poj, 'vyrazen': vyrazen == 'A', + 'kontakt': kont, 'typ': typ, 'popis': popis or '', 'vztah': vztah or '', + }) + + return results[:50] + except Exception: + log(f"search_patient_by_contact chyba: {traceback.format_exc()}") + raise + + @mcp.tool() def get_patient_timeline(idpac: int, datum_od: Optional[str] = None, datum_do: Optional[str] = None) -> dict: """Chronologický přehled všech záznamů pacienta z DOCLIST. @@ -377,6 +459,51 @@ def get_table_info(table_name: str) -> dict: raise +@mcp.tool() +def get_columns_overview(table_name: str, sample_rows: int = 1000) -> dict: + """Přehled obsahu sloupců tabulky pro pochopení sémantiky (např. co znamená + KARKONTAKT.TYP=3 nebo RECEPT.STORNO='T'). Ze vzorku posledních N řádků + (výchozí 1000) vrátí pro každý sloupec: počet vyplněných, počet distinct + hodnot a top 5 nejčastějších hodnot s četností. Hodnoty zkráceny na 80 znaků. + """ + try: + from collections import Counter + cur = conn.cursor() + cur.execute(f'SELECT FIRST {int(sample_rows)} * FROM {table_name.upper()}') + rows = rows_to_json(cur.fetchall(), cur.description or []) + + if not rows: + return {'tabulka': table_name.upper(), 'sample': 0, 'sloupce': {}} + + overview = {} + for col in rows[0].keys(): + values = [r[col] for r in rows if r[col] is not None and r[col] != ''] + counter = Counter( + str(v)[:80] for v in values + ) + overview[col] = { + 'vyplneno': len(values), + 'distinct': len(counter), + 'top': [ + {'hodnota': v, 'pocet': n} for v, n in counter.most_common(5) + ], + } + result = { + 'tabulka': table_name.upper(), + 'sample': len(rows), + 'sloupce': overview, + } + if table_name.upper() in LARGE_TABLES: + result['warning'] = ( + f'Tabulka {table_name.upper()} je velká — přehled je jen ' + f'ze vzorku prvních {len(rows)} řádků.' + ) + return result + except Exception: + log(f"get_columns_overview chyba: {traceback.format_exc()}") + raise + + @mcp.tool() def safe_query(sql: str, params: Optional[list] = None) -> dict: """Bezpečný SELECT s ochranou před timeoutem na velkých tabulkách. diff --git a/mcp_medevio.py b/mcp_medevio.py index 9dd9678..6c34335 100644 --- a/mcp_medevio.py +++ b/mcp_medevio.py @@ -509,7 +509,7 @@ def get_pozadavky( try: data = _gql("ClinicRequestList2", _POZADAVKY_QUERY, { "clinicSlug": CLINIC_SLUG, - "queueAssignment": "ALL", + "queueAssignment": "ANY", # 2026-06-12: Medevio prejmenovalo enum ALL -> ANY "state": stav.upper(), "pageInfo": {"first": min(pocet, 100), "offset": offset}, "locale": "cs", @@ -543,20 +543,21 @@ def get_pozadavky( raise +# Pozn. 2026-06-12: Medevio změnilo schéma — argument je patientRequestId (UUID!), +# evaluationResult/substate vyžadují subfields (vynechány), tags chce onlyImportant. _POZADAVEK_DETAIL_QUERY = """ query ClinicRequestDetail_GetPatientRequest2( - $clinicSlug: String!, $requestId: ID!, $isDoctor: Boolean!, $locale: Locale! + $clinicSlug: String!, $requestId: UUID!, $locale: Locale! ) { - request: getPatientRequest2(clinicSlug: $clinicSlug, requestId: $requestId) { + request: getPatientRequest2(clinicSlug: $clinicSlug, patientRequestId: $requestId) { id doneAt doneBy { id name surname } removedAt createdAt createdBy { id name surname } displayTitle(locale: $locale) customTitle clinicMedicalRecord clinicMedicalRecordVisibleToPatient - userNote evaluationResult + userNote queue { id name } - substate hasMobileApp - tags { id name } + tags(onlyImportant: false) { id name } extendedPatient { id name surname dob identificationNumber phone email insuranceCompanyObject { code name shortName } @@ -576,7 +577,6 @@ def get_pozadavek(request_id: str) -> dict: data = _gql("ClinicRequestDetail_GetPatientRequest2", _POZADAVEK_DETAIL_QUERY, { "clinicSlug": CLINIC_SLUG, "requestId": request_id, - "isDoctor": True, "locale": "cs", }) return data.get("request") or {} @@ -723,6 +723,91 @@ def get_pacient(patient_id: str) -> dict: raise +# ───────────────────────────────────────────────────────────────────────────── +# ZALOŽENÍ POŽADAVKU "RECEPT NA LÉKY" +# ───────────────────────────────────────────────────────────────────────────── +# Flow ověřen 2026-06-12 (Chrome capture + živý test): +# 1. fillECRFForm — vyplní formulář ERECEPT_SIMPLEST_BEZ_DAVKOVANI, +# krok "erecept-gp-request", pole "nazev-leku" (volný text léků). +# 2. createPatientRequestWithoutReservation — založí požadavek; objeví se +# v aktivní frontě ordinace jako "Recept na léky". +# Pozor: createPatientRequest (bez "WithoutReservation") požadavek také +# vytvoří, ale NEZOBRAZÍ se v žádné frontě — nepoužívat. +RECEPT_SID = "ERECEPT_SIMPLEST_BEZ_DAVKOVANI" +RECEPT_STEP_ID = "erecept-gp-request" +RECEPT_USER_ECRF_ID = "79488e86-e9e5-47e3-8b19-7e5229427f23" # šablona kliniky + +_FILL_MUTATION = """ +mutation Step_FillECRFForm($input: FillECRFFormInput!) { + patientEcrfFill: fillECRFForm(input: $input) { id } +}""" + +_CREATE_REQUEST_MUTATION = """ +mutation PatientRequestSubmission_CreatePatientRequestWithoutReservation( + $clinicSlug: String!, $input: CreatePatientRequestWithoutReservationInput! +) { + patientRequest: createPatientRequestWithoutReservation( + clinicSlug: $clinicSlug, input: $input + ) { id } +}""" + + +@mcp.tool() +def zaloz_pozadavek_recept(patient_id: str, leky: str, poznamka: str = "") -> dict: + """Založí v Medeviu požadavek "Recept na léky" za pacienta. + + Požadavek se objeví v aktivní frontě ordinace stejně, jako by ho pacient + založil sám v aplikaci. + + Args: + patient_id: UUID pacienta (z hledej_pacienta / get_pacient). + leky: Volný text názvů léků (obsah pole "Název léků:"). + poznamka: Volitelná uživatelská poznámka k požadavku (userNote). + """ + try: + fill = _gql("Step_FillECRFForm", _FILL_MUTATION, { + "input": { + "fields": [{ + "checkedEnumerations": [], + "fieldName": "nazev-leku", + "value": leky, + }], + "patientId": patient_id, + "sid": RECEPT_SID, + "stepId": RECEPT_STEP_ID, + "byDoctor": False, + } + }) + fill_id = fill["patientEcrfFill"]["id"] + + req = _gql( + "PatientRequestSubmission_CreatePatientRequestWithoutReservation", + _CREATE_REQUEST_MUTATION, + { + "clinicSlug": CLINIC_SLUG, + "input": { + "challengeId": None, + "ecrfFillIds": [fill_id], + "medicalRecordIds": [], + "patientId": patient_id, + "userNote": poznamka, + "createdByDoctor": False, + "userECRFId": RECEPT_USER_ECRF_ID, + }, + }, + ) + return { + "ok": True, + "request_id": req["patientRequest"]["id"], + "fill_id": fill_id, + "patient_id": patient_id, + "leky": leky, + } + except Exception: + log(f"zaloz_pozadavek_recept chyba: {traceback.format_exc()}") + raise + + # ───────────────────────────────────────────────────────────────────────────── if __name__ == "__main__": log("MCP Medevio server spuštěn (FastMCP)") diff --git a/mcp_medevio_mysql.py b/mcp_medevio_mysql.py new file mode 100644 index 0000000..a9e2c8b --- /dev/null +++ b/mcp_medevio_mysql.py @@ -0,0 +1,285 @@ +#!/usr/bin/env python3 +""" +MCP server pro MySQL/Medevio — používá oficiální MCP SDK (FastMCP) +Spustit: python mcp_medevio_mysql.py + +Databáze `medevio` (MySQL, viz Knihovny/mysql_db.py): lokální zrcadlo dat +z Medevio GraphQL API (pacienti, požadavky, konverzace, dotazníky, agenda) ++ VZP data (stav pojištění, registrace, dávky). + +Pozn.: živé API řeší sesterský server `mcp_medevio.py` (GraphQL) — tento +server je pro rychlé SQL dotazy nad synchronizovanými daty. +""" + +import sys +import traceback +from pathlib import Path +from typing import Optional +from mcp.server.fastmcp import FastMCP + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from Knihovny.mysql_db import connect_mysql + + +# Všechny logy MUSÍ jít na stderr — stdout je rezervován pro JSON-RPC +def log(msg: str): + print(msg, file=sys.stderr, flush=True) + + +try: + conn = connect_mysql() + log("Připojeno k MySQL (medevio)") +except Exception as e: + log(f"Chyba připojení k MySQL: {e}") + sys.exit(1) + + +def _cursor(): + """Kurzor s automatickým reconnectem (MySQL spojení po čase usíná).""" + conn.ping(reconnect=True) + return conn.cursor() + + +def rows_to_json(rows, description): + """Převede pymysql rows na JSON-serializovatelný formát.""" + import datetime + import decimal + + def convert(val): + if isinstance(val, (datetime.date, datetime.datetime)): + return val.isoformat() + if isinstance(val, datetime.timedelta): + return str(val) + if isinstance(val, decimal.Decimal): + return float(val) + if isinstance(val, bytes): + return val.decode('utf-8', errors='replace') + return val + + cols = [d[0] for d in description] + return [dict(zip(cols, [convert(v) for v in row])) for row in rows] + + +def _strip_diacritics(s: str) -> str: + """Bez diakritiky, velkými písmeny, sjednocené mezery.""" + import re + import unicodedata + 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() + + +# MCP server +mcp = FastMCP("medevio-mysql") + + +@mcp.tool() +def execute_query(sql: str, params: Optional[list] = None) -> dict: + """Spusť SQL dotaz na Medevio MySQL databázi. + Pro SELECT/SHOW vrátí rows. Pro INSERT/UPDATE/DELETE vrátí rowcount. + """ + try: + cur = _cursor() + cur.execute(sql, params or None) + if sql.strip().upper().startswith(('SELECT', 'SHOW', 'DESCRIBE')): + rows = rows_to_json(cur.fetchall(), cur.description or []) + return {'rowcount': len(rows), 'rows': rows} + conn.commit() + return { + 'rowcount': cur.rowcount, + 'message': f'Dotaz proveden: {cur.rowcount} řádků ovlivněno' + } + except Exception: + log(f"execute_query chyba: {traceback.format_exc()}") + raise + + +@mcp.tool() +def safe_query(sql: str, params: Optional[list] = None) -> dict: + """Bezpečný SELECT — pouze čtení, výsledek omezen na 500 řádků (LIMIT se + doplní automaticky, pokud chybí).""" + try: + sql_upper = sql.strip().upper() + if not sql_upper.startswith(('SELECT', 'SHOW', 'DESCRIBE')): + return {'error': 'safe_query podporuje pouze SELECT/SHOW dotazy'} + if sql_upper.startswith('SELECT') and 'LIMIT' not in sql_upper: + sql = sql.rstrip().rstrip(';') + ' LIMIT 500' + + cur = _cursor() + cur.execute(sql, params or None) + rows = rows_to_json(cur.fetchall(), cur.description or []) + return {'rowcount': len(rows), 'rows': rows} + except Exception: + log(f"safe_query chyba: {traceback.format_exc()}") + raise + + +@mcp.tool() +def list_tables() -> dict: + """Vrátí seznam tabulek v databázi medevio včetně počtu řádků.""" + try: + cur = _cursor() + cur.execute("SHOW TABLES") + tables = [r[0] for r in cur.fetchall()] + result = {} + for t in tables: + cur.execute(f"SELECT COUNT(*) FROM `{t}`") + result[t] = cur.fetchone()[0] + return {'pocet_tabulek': len(result), 'tabulky': result} + except Exception: + log(f"list_tables chyba: {traceback.format_exc()}") + raise + + +@mcp.tool() +def get_table_info(table_name: str) -> dict: + """Vrátí strukturu tabulky: sloupce s typy, nullable, klíče, default, + komentář + počet záznamů.""" + try: + cur = _cursor() + cur.execute(f"SHOW FULL COLUMNS FROM `{table_name}`") + columns = [ + { + 'sloupec': r[0], 'typ': r[1], 'nullable': r[3], + 'klic': r[4] or '', 'default': r[5], 'komentar': r[8] or '', + } + for r in cur.fetchall() + ] + cur.execute(f"SELECT COUNT(*) FROM `{table_name}`") + count = cur.fetchone()[0] + return {'tabulka': table_name, 'pocet_zaznamu': count, 'sloupce': columns} + except Exception: + log(f"get_table_info chyba: {traceback.format_exc()}") + raise + + +@mcp.tool() +def get_columns_overview(table_name: str, sample_rows: int = 1000) -> dict: + """Přehled obsahu sloupců tabulky pro pochopení sémantiky. Ze vzorku + N řádků (výchozí 1000) vrátí pro každý sloupec: počet vyplněných, počet + distinct hodnot a top 5 nejčastějších hodnot s četností. + Hodnoty zkráceny na 80 znaků. + """ + try: + from collections import Counter + cur = _cursor() + cur.execute(f"SELECT * FROM `{table_name}` LIMIT {int(sample_rows)}") + rows = rows_to_json(cur.fetchall(), cur.description or []) + if not rows: + return {'tabulka': table_name, 'sample': 0, 'sloupce': {}} + + overview = {} + for col in rows[0].keys(): + values = [r[col] for r in rows if r[col] is not None and r[col] != ''] + counter = Counter(str(v)[:80] for v in values) + overview[col] = { + 'vyplneno': len(values), + 'distinct': len(counter), + 'top': [{'hodnota': v, 'pocet': n} for v, n in counter.most_common(5)], + } + return {'tabulka': table_name, 'sample': len(rows), 'sloupce': overview} + except Exception: + log(f"get_columns_overview chyba: {traceback.format_exc()}") + raise + + +@mcp.tool() +def search_patient(query: str) -> list: + """Vyhledá pacienty v medevio_pacient podle jména (bez ohledu na diakritiku + a pořadí slov), rodného čísla (jen číslice), e-mailu nebo telefonu + (jen číslice, ignoruje +420). Vrátí max. 50 výsledků vč. patient_id, + kontaktů, pojišťovny a `ma_ucet` (user_id != null = má Medevio účet). + """ + try: + import re + cur = _cursor() + cur.execute(""" + SELECT patient_id, name, surname, identification_number, dob, sex, + email, phone, insurance_code, insurance_name, status, + has_mobile_app, user_id, user_email, user_phone + FROM medevio_pacient + """) + rows = rows_to_json(cur.fetchall(), cur.description or []) + + q = query.strip().lower() + q_digits = re.sub(r'\D', '', query) + if q_digits.startswith('420'): + q_digits = q_digits[3:] + q_tokens = _strip_diacritics(query).split() + + results = [] + for r in rows: + hit = False + if len(q_digits) >= 4: + rc = re.sub(r'\D', '', r.get('identification_number') or '') + phones = ' '.join( + re.sub(r'\D', '', r.get(k) or '') for k in ('phone', 'user_phone') + ) + hit = q_digits in rc or (len(q_digits) >= 6 and q_digits in phones) + if not hit and '@' in q: + emails = f"{(r.get('email') or '').lower()} {(r.get('user_email') or '').lower()}" + hit = q in emails + if not hit and q_tokens and not q_digits: + name_norm = _strip_diacritics(f"{r.get('name') or ''} {r.get('surname') or ''}") + hit = all(t in name_norm for t in q_tokens) + if hit: + r['ma_ucet'] = r.get('user_id') is not None + results.append(r) + + results.sort(key=lambda p: (p.get('surname') or '', p.get('name') or '')) + return results[:50] + except Exception: + log(f"search_patient chyba: {traceback.format_exc()}") + raise + + +@mcp.tool() +def get_patient_requests(rodne_cislo: str, limit: int = 30) -> dict: + """Vrátí požadavky pacienta z tabulky pozadavky podle rodného čísla + (lomítko nevadí). Řazení od nejnovějšího. + """ + try: + import re + rc = re.sub(r'\D', '', rodne_cislo) + cur = _cursor() + cur.execute(""" + SELECT id, displayTitle, createdAt, doneAt, removedAt, + pacient_jmeno, pacient_prijmeni, pacient_rodnecislo + FROM pozadavky + WHERE REPLACE(pacient_rodnecislo, '/', '') = %s + ORDER BY createdAt DESC + LIMIT %s + """, [rc, int(limit)]) + rows = rows_to_json(cur.fetchall(), cur.description or []) + return {'rodne_cislo': rc, 'pocet': len(rows), 'pozadavky': rows} + except Exception: + log(f"get_patient_requests chyba: {traceback.format_exc()}") + raise + + +@mcp.tool() +def get_request_conversation(request_id: str) -> dict: + """Vrátí zprávy konverzace k danému požadavku (medevio_conversation), + chronologicky. `od_ordinace` = zprávu poslala ordinace (ne pacient). + """ + try: + cur = _cursor() + cur.execute(""" + SELECT sender_name, sender_clinic_id, text, created_at, read_at, + attachment_url, attachment_description, attachment_content_type + FROM medevio_conversation + WHERE request_id = %s + ORDER BY created_at + """, [request_id]) + rows = rows_to_json(cur.fetchall(), cur.description or []) + for r in rows: + r['od_ordinace'] = r.pop('sender_clinic_id') is not None + return {'request_id': request_id, 'pocet_zprav': len(rows), 'zpravy': rows} + except Exception: + log(f"get_request_conversation chyba: {traceback.format_exc()}") + raise + + +if __name__ == '__main__': + log("MCP Medevio MySQL server spuštěn (FastMCP)") + mcp.run()