z230
This commit is contained in:
@@ -1978,5 +1978,53 @@
|
|||||||
{
|
{
|
||||||
"original": "7606050518 Novotný, Pavel split_004.pdf",
|
"original": "7606050518 Novotný, Pavel split_004.pdf",
|
||||||
"corrected": "7606050518 2026-06-04 Novotný, Pavel [domácí měření TK] [pěkná kompenzace].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"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -709,12 +709,19 @@ ORDER BY h.DATUM DESC
|
|||||||
| Nástroj | Popis |
|
| Nástroj | Popis |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `get_patient(idpac)` | Základní info o pacientovi z KAR — jmeno, prijmeni, rc, datnar, pojistovna |
|
| `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 |
|
| `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…} |
|
| `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ů |
|
| `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ů |
|
| `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)
|
### Velké tabulky vyžadující WHERE (safe_query varuje automaticky)
|
||||||
LOG, ZURNAL, LABVD, DOCLIST, PZT, LEKY, DEKLINK
|
LOG, ZURNAL, LABVD, DOCLIST, PZT, LEKY, DEKLINK
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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'}}]
|
||||||
@@ -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 <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} Kč"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -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
@@ -207,30 +207,112 @@ def get_patient(idpac: int) -> dict:
|
|||||||
raise
|
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()
|
@mcp.tool()
|
||||||
def search_patients(query: str) -> list:
|
def search_patients(query: str, datum_narozeni: Optional[str] = None) -> list:
|
||||||
"""Vyhledá pacienty podle příjmení, jména nebo rodného čísla (částečná shoda).
|
"""Vyhledá pacienty podle jména/příjmení (bez ohledu na diakritiku a pořadí slov,
|
||||||
Vrátí max. 50 výsledků: idpac, jmeno, prijmeni, rc, pojistovna.
|
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:
|
try:
|
||||||
|
import datetime
|
||||||
|
import re
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
q = query.strip().upper()
|
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
SELECT FIRST 50 IDPAC, JMENO, PRIJMENI, RODCIS, POJ
|
SELECT IDPAC, JMENO, PRIJMENI, RODCIS, DATNAR, POJ, VYRAZEN
|
||||||
FROM KAR
|
FROM KAR
|
||||||
WHERE UPPER(PRIJMENI) LIKE ? OR UPPER(JMENO) LIKE ? OR RODCIS LIKE ?
|
WHERE PRIJMENI IS NOT NULL AND PRIJMENI <> ''
|
||||||
ORDER BY PRIJMENI, JMENO
|
""")
|
||||||
""", [f'%{q}%', f'%{q}%', f'%{q}%'])
|
|
||||||
rows = cur.fetchall()
|
q_digits = re.sub(r'\D', '', query)
|
||||||
return [
|
q_tokens = _strip_diacritics(query).split()
|
||||||
{'idpac': r[0], 'jmeno': r[1], 'prijmeni': r[2], 'rc': r[3], 'pojistovna': r[4]}
|
|
||||||
for r in rows
|
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:
|
except Exception:
|
||||||
log(f"search_patients chyba: {traceback.format_exc()}")
|
log(f"search_patients chyba: {traceback.format_exc()}")
|
||||||
raise
|
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()
|
@mcp.tool()
|
||||||
def get_patient_timeline(idpac: int, datum_od: Optional[str] = None, datum_do: Optional[str] = None) -> dict:
|
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.
|
"""Chronologický přehled všech záznamů pacienta z DOCLIST.
|
||||||
@@ -377,6 +459,51 @@ def get_table_info(table_name: str) -> dict:
|
|||||||
raise
|
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()
|
@mcp.tool()
|
||||||
def safe_query(sql: str, params: Optional[list] = None) -> dict:
|
def safe_query(sql: str, params: Optional[list] = None) -> dict:
|
||||||
"""Bezpečný SELECT s ochranou před timeoutem na velkých tabulkách.
|
"""Bezpečný SELECT s ochranou před timeoutem na velkých tabulkách.
|
||||||
|
|||||||
+92
-7
@@ -509,7 +509,7 @@ def get_pozadavky(
|
|||||||
try:
|
try:
|
||||||
data = _gql("ClinicRequestList2", _POZADAVKY_QUERY, {
|
data = _gql("ClinicRequestList2", _POZADAVKY_QUERY, {
|
||||||
"clinicSlug": CLINIC_SLUG,
|
"clinicSlug": CLINIC_SLUG,
|
||||||
"queueAssignment": "ALL",
|
"queueAssignment": "ANY", # 2026-06-12: Medevio prejmenovalo enum ALL -> ANY
|
||||||
"state": stav.upper(),
|
"state": stav.upper(),
|
||||||
"pageInfo": {"first": min(pocet, 100), "offset": offset},
|
"pageInfo": {"first": min(pocet, 100), "offset": offset},
|
||||||
"locale": "cs",
|
"locale": "cs",
|
||||||
@@ -543,20 +543,21 @@ def get_pozadavky(
|
|||||||
raise
|
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 = """
|
_POZADAVEK_DETAIL_QUERY = """
|
||||||
query ClinicRequestDetail_GetPatientRequest2(
|
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 }
|
id doneAt doneBy { id name surname }
|
||||||
removedAt createdAt createdBy { id name surname }
|
removedAt createdAt createdBy { id name surname }
|
||||||
displayTitle(locale: $locale) customTitle
|
displayTitle(locale: $locale) customTitle
|
||||||
clinicMedicalRecord clinicMedicalRecordVisibleToPatient
|
clinicMedicalRecord clinicMedicalRecordVisibleToPatient
|
||||||
userNote evaluationResult
|
userNote
|
||||||
queue { id name }
|
queue { id name }
|
||||||
substate
|
|
||||||
hasMobileApp
|
hasMobileApp
|
||||||
tags { id name }
|
tags(onlyImportant: false) { id name }
|
||||||
extendedPatient {
|
extendedPatient {
|
||||||
id name surname dob identificationNumber phone email
|
id name surname dob identificationNumber phone email
|
||||||
insuranceCompanyObject { code name shortName }
|
insuranceCompanyObject { code name shortName }
|
||||||
@@ -576,7 +577,6 @@ def get_pozadavek(request_id: str) -> dict:
|
|||||||
data = _gql("ClinicRequestDetail_GetPatientRequest2", _POZADAVEK_DETAIL_QUERY, {
|
data = _gql("ClinicRequestDetail_GetPatientRequest2", _POZADAVEK_DETAIL_QUERY, {
|
||||||
"clinicSlug": CLINIC_SLUG,
|
"clinicSlug": CLINIC_SLUG,
|
||||||
"requestId": request_id,
|
"requestId": request_id,
|
||||||
"isDoctor": True,
|
|
||||||
"locale": "cs",
|
"locale": "cs",
|
||||||
})
|
})
|
||||||
return data.get("request") or {}
|
return data.get("request") or {}
|
||||||
@@ -723,6 +723,91 @@ def get_pacient(patient_id: str) -> dict:
|
|||||||
raise
|
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__":
|
if __name__ == "__main__":
|
||||||
log("MCP Medevio server spuštěn (FastMCP)")
|
log("MCP Medevio server spuštěn (FastMCP)")
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user