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
+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()