This commit is contained in:
2026-06-12 15:32:22 +02:00
parent 51ee67c7f3
commit bed5576efa
9 changed files with 1505 additions and 21 deletions
@@ -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á] [21APR202619MAY2026 gonartroza st.p.TEP dx., zlepšení, edukace provedena].pdf",
"corrected": "5606051143 2026-05-19 Zána, Jan [PZ lázně] [21APR202619MAY2026 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"
}
]
+8 -1
View File
@@ -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
+76
View File
@@ -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
```
+179
View File
@@ -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 <pmateju@tiscali.cz>
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 <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 <medatron@medatron.cz>
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 <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 <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 <pmateju@tiscali.cz>
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 <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 <medatron@medatron.cz>
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 <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 <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 <pmateju@tiscali.cz>
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 <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 <medatron@medatron.cz>
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 <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 <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 <pmateju@tiscali.cz>
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 <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 <medatron@medatron.cz>
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 <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 <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'}}]
+643
View File
@@ -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 "", [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 <email>" + "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 <email>" + "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}"
)
if __name__ == "__main__":
main()
+34
View File
@@ -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"
]
}
}
}
+140 -13
View File
@@ -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.
+92 -7
View File
@@ -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)")
+285
View File
@@ -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()