From 9133fe94978cd99ee1543e22a04b59a205f543ad Mon Sep 17 00:00:00 2001 From: Vladimir Buzalka Date: Sun, 14 Jun 2026 08:22:25 +0200 Subject: [PATCH] notebookvb --- .gitignore | 4 + CLAUDE.md | 2 + Knihovny/medicus_db.py | 5 +- Medevio/.env | 12 +- Medevio/medevio_api_notes.md | 44 +++-- OrdinaceAgentEmail/NOTES.md | 113 ++++++++++--- OrdinaceAgentEmail/medevio_recept.py | 241 --------------------------- OrdinaceAgentEmail/recepty_agent.py | 178 ++++++++++++++++++-- mcp_medevio.py | 51 +++++- 9 files changed, 355 insertions(+), 295 deletions(-) delete mode 100644 OrdinaceAgentEmail/medevio_recept.py diff --git a/.gitignore b/.gitignore index 69232c4..3e730bc 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,7 @@ __pycache__/ # Cookies (session tokeny) **/vozp_cookies.json **/vzp_cookies.json + +# Telegram user session (Telethon) — drží přihlášení, nikdy do gitu! +**/*.session +**/*.session-journal diff --git a/CLAUDE.md b/CLAUDE.md index df386df..e3092c1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,6 +41,8 @@ Import vždy přes `sys.path` na kořen projektu nebo přímou cestou. | `mysql_db.py` | — | Připojení a operace s MySQL databází | | `medicus_db.py` | — | Připojení k databázi Medicus (Firebird) | | `vzpb2b_client.py` | — | Klient pro VZP B2B API (stav pojištění) | +| `telegram_notify.py` | `posli_telegram()`, `zeptej_se_telegram()` | Notifikace a obousměrná komunikace přes Telegram **bota** (@Vlado_Claude_Bot) | +| `telegram_user.py` | `posli_jako_ja()`, `zeptej_se_jako()` | Komunikace přes plnohodnotný **user účet** agenta (Telethon, víc agentů = víc sessions) | ## Přehled skriptů diff --git a/Knihovny/medicus_db.py b/Knihovny/medicus_db.py index f992165..21896c9 100644 --- a/Knihovny/medicus_db.py +++ b/Knihovny/medicus_db.py @@ -16,8 +16,9 @@ def get_medicus_connection(): "LEKAR": r"localhost:M:\medicus\data\medicus.fdb", "SESTRA": r"192.168.1.10:m:\medicus\data\medicus.fdb", "LENOVO": r"192.168.1.10:m:\medicus\data\medicus.fdb", - "NTBVBHP470G10": r"reporter:c:\medicus\medicus.fdb", - "Z230": r"reporter:c:\medicus\medicus.fdb", + "NTBVBHP470G10": r"192.168.1.76:/firebird/data/medicus.fdb", # přepnuto z reporteru na tower 2026-06-14 + "Z230": r"192.168.1.76:/firebird/data/medicus.fdb", # přepnuto z reporteru na tower 2026-06-14 + "TOWER": r"192.168.1.76:/firebird/data/medicus.fdb", # Firebird 2.5 docker kontejner na toweru } dsn = dsn_map.get(computer_name, r"localhost:c:\medicus 3\data\medicus.fdb") import sys diff --git a/Medevio/.env b/Medevio/.env index 5f0feb8..c3b6f5b 100644 --- a/Medevio/.env +++ b/Medevio/.env @@ -1,4 +1,14 @@ ANTHROPIC_API_KEY=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 CENTRAL_LOG_TOKEN=b1e95b3ca9b64769d14bb80370a07882958cac95a0eb9d7758933f151a053c08 -CENTRAL_LOG_GATEWAY=http://192.168.1.76:8770 \ No newline at end of file +CENTRAL_LOG_GATEWAY=http://192.168.1.76:8770 + +# Telegram bot (ClaudeBot @Vlado_Claude_Bot) — notifikace o průběhu +TELEGRAM_BOT_TOKEN=8821687113:AAF9U9S989ZJ0OG2St3o8CyHUSKg7RqyYVM +TELEGRAM_CHAT_ID=6639316354 + +# Telegram USER účet (Telethon) — plnohodnotný účet agenta +# api_id/api_hash z https://my.telegram.org (přihlas se číslem nového účtu) +TELEGRAM_API_ID=39599696 +TELEGRAM_API_HASH=f93ed362cdbfb4f5df85072a0350a8fc +TELEGRAM_PHONE=+420705920457 \ No newline at end of file diff --git a/Medevio/medevio_api_notes.md b/Medevio/medevio_api_notes.md index 1245743..abed0f2 100644 --- a/Medevio/medevio_api_notes.md +++ b/Medevio/medevio_api_notes.md @@ -241,34 +241,46 @@ request { } ``` -### Request Creation (Vytvoření požadavku lékařem) — ODCHYCENO 2026-06-13 +### Request Creation (Vytvoření požadavku "Recept na léky") — ODCHYCENO/OVĚŘENO 2026-06-13 -Lékařský účet (klinický token) **NEumí vyplnit pacientský dotazník** smysluplně — formulář -„Recept na léky" má z lékařské strany (`sid: ERECEPT_SIMPLEST_BEZ_DAVKOVANI`) jen jedno -pole `nazev-leku`, kdežto pacient v appce vyplní dvě pole („Název léků" + „Poznámka"). -**Proto: obsah z e-mailu zapisujeme do INTERNÍ POZNÁMKY, ne do dotazníku.** +Přes API **lze založit požadavek s plně vyplněným pacientským dotazníkem** (oba fieldy), +takže vypadá jako reálné podání pacientem. Funkce: `mcp_medevio.zaloz_pozadavek_recept`. +(Pozn.: lékařské UI „Nový požadavek" pole dotazníku NEzobrazí — ale API je přijme.) -Vytvoření prázdného požadavku „Recept na léky" je **dvoukrok**: +**Dvoukrok (+ volitelně štítek):** ```graphql -# 1) vytvoř (prázdný) ECRF fill → vrátí ecrfFill.id -mutation ClinicRequestCreateModal_FillECRFForm($input: FillECRFFormInput!) { - ecrfFill: fillECRFForm(input: $input) { id } +# 1) vyplň ECRF formulář → vrátí ecrfFill.id +mutation Step_FillECRFForm($input: FillECRFFormInput!) { + patientEcrfFill: fillECRFForm(input: $input) { id } } -# input: { byDoctor: true, fields: [], patientId, sid: "ERECEPT_SIMPLEST_BEZ_DAVKOVANI", stepId: "erecept-gp-request" } +# input: { +# patientId, sid: "ERECEPT_SIMPLEST_BEZ_DAVKOVANI", stepId: "erecept-gp-request", +# byDoctor: false, +# fields: [{ fieldName: "nazev-leku", value: "", checkedEnumerations: [] }] +# } → pole "Název léků" v dotazníku -# 2) vytvoř požadavek s odkazem na ecrfFill -mutation ClinicRequestCreateModal_CreateRequest($clinicSlug: String!, $input: CreatePatientRequestWithoutReservationInput!) { +# 2) vytvoř požadavek +mutation ...CreatePatientRequestWithoutReservation($clinicSlug: String!, $input: ...) { patientRequest: createPatientRequestWithoutReservation(clinicSlug: $clinicSlug, input: $input) { id } } -# input: { patientId, userECRFId, ecrfFillIds: [], createdByDoctor: true, shouldInvitePatient: false } +# input: { +# patientId, userECRFId, ecrfFillIds: [], medicalRecordIds: [], challengeId: null, +# userNote: "", ← zobrazí se jako pole "Poznámka" v dotazníku +# createdByDoctor: false +# } ``` +POZOR: `createPatientRequest` (bez „WithoutReservation") požadavek vytvoří, ale +NEZOBRAZÍ se v žádné frontě — používat `createPatientRequestWithoutReservation`. + | Klíč | Hodnota | |------|---------| | ECRF „Recept na léky" `userECRFId` | `79488e86-e9e5-47e3-8b19-7e5229427f23` | | ECRF `sid` | `ERECEPT_SIMPLEST_BEZ_DAVKOVANI` | | ECRF `stepId` | `erecept-gp-request` | +| pole 1 `fieldName` | `nazev-leku` (→ „Název léků") | +| pole 2 | `userNote` v create inputu (→ „Poznámka") | Seznam typů požadavků: `UserEcrfAutocomplete_ListUserECRFsByClinic`. @@ -279,11 +291,17 @@ query TagRequestEditModal_ListTags($clinicSlug: String!, $requestId: UUID!) { . mutation TagRequestEditModal_AssignTagToRequest($clinicSlug: String!, $requestId: UUID!, $tagId: UUID!) { tagRequest: assignTagToPatientRequest(clinicSlug: $clinicSlug, patientRequestId: $requestId, tagId: $tagId) { id } } +# Vytvoření nového štítku: +mutation TagEditModal_CreateTag($clinicSlug: String!, $input: CreateTagInput!) { + tag: createTag(clinicSlug: $clinicSlug, input: $input) { id name color icon important isOrganizationWide } +} +# input: { name, color (např. "SKY"/"ORCHID"), icon: null, important: false, type: "patient_request", isOrganizationWide: false } ``` | Štítek | tagId | barva | |--------|-------|-------| | `CLAUDE` | `c136aeca-0625-4c43-b81f-fc3949ec6ba6` | ORCHID | +| `OVĚŘIT PACIENTA` | `9d3271b3-309d-4d20-93ee-285f3e56ba42` | SKY | | `NEZAPOMENOUT` | `5bced917-83d2-46db-896c-c8e615de1a69` | GREY | ### Request Detail diff --git a/OrdinaceAgentEmail/NOTES.md b/OrdinaceAgentEmail/NOTES.md index 5e4c88a..9671144 100644 --- a/OrdinaceAgentEmail/NOTES.md +++ b/OrdinaceAgentEmail/NOTES.md @@ -57,32 +57,94 @@ přesuny, odpovědi), žádný `state.json`. počítače, na Z230 → `reporter:c:\medicus\medicus.fdb`). - `ANTHROPIC_API_KEY` z `Medevio/.env`. -## Vytvoření požadavku v Medeviu — `medevio_recept.py` +## Vytvoření požadavku v Medeviu — `mcp_medevio.zaloz_pozadavek_recept` -Funkce pro agenta: jakmile je pacient + léky správně identifikován, založí mu -v Medeviu požadavek **„Recept na léky"**, aby ho lékař viděl (Medevio kontrolujeme -průběžně, e-mail zřídka). +Jakmile agent správně identifikuje pacienta + léky, založí mu v Medeviu požadavek +**„Recept na léky"** přesně jako by ho podal pacient v aplikaci — vyplní **oba fieldy +dotazníku** a přidá **štítek CLAUDE**. Vše v jednom volání: ```python -from medevio_recept import vytvor_recept -rid = vytvor_recept(rodne_cislo="730920/8104", - nazev_leku="Euthyrox 100 µg", - poznamka="docházejí mi léky") +import mcp_medevio +mcp_medevio.zaloz_pozadavek_recept(patient_uuid, leky="Euthyrox 100", poznamka="docházejí mi léky") ``` -Co se stane (vše odchyceno z webu Medevia 2026-06-13, ověřeno na testovacím pacientovi): -1. **RČ → patient UUID** přes MySQL `medevio.medevio_pacient` (`identification_number` → `patient_id`). -2. `fillECRFForm` (prázdný) → `createPatientRequestWithoutReservation` → založí „Recept na léky". -3. `createClinicPatientRequestNote` → obě pole do **interní poznámky** (formátováno „Název léků / Poznámka"). -4. `assignTagToPatientRequest` → štítek **CLAUDE** (`pridat_stitek=False` vypne). +Mapování (ověřeno naživo na Vladkovi 2026-06-13): +- `leky` → dotazník pole **„Název léků"** (přes ECRF field `nazev-leku`) +- `poznamka` → dotazník pole **„Poznámka"** (jde přes `userNote` — **funguje** i z klinické strany!) +- `stitek=True` (default) → přiřadí **štítek CLAUDE** (`assignTagToPatientRequest`) -**Proč interní poznámka, ne dotazník:** lékařský přístup neumí vyplnit pacientský -ECRF dotazník smysluplně (z lékařské strany má jen 1 pole `nazev-leku`), proto obsah -jde do interní poznámky (viditelná jen pro ordinaci). +Postup uvnitř: `fillECRFForm` (oba fieldy, `byDoctor:False`) → `createPatientRequestWithoutReservation` +(`createdByDoctor:False`) → `assignTagToPatientRequest`. Auth: Bearer token z `Medevio/token.txt` +(auto-refresh při 401). Konstanty/mutace viz `Medevio/medevio_api_notes.md`. -Auth: Bearer token z `Medevio/token.txt` (dlouhodobý). Test: `python medevio_recept.py` -(založí testovací Recept na Vladkovi `0210db7b-…`). Pro test bez DB lookup je parametr -`patient_uuid=`. Mutace + konstanty jsou v `Medevio/medevio_api_notes.md`. +Agent (`recepty_agent.py`) volá tuto funkci automaticky po jednoznačné identifikaci +pacienta; `leky_str` z `_format_leky`, `pozn_str` z `_format_poznamka` (hlavička + zkomprimované tělo mailu). +UUID pacienta hledá `_medevio_find_patient` v MySQL `medevio_pacient` (RČ → `patient_id`). + +POZN.: požadavky v Medeviu nejdou smazat, jen zavřít („Vyřídit") — proto testovat na +testovacím pacientovi Vladko (`0210db7b-…`). + +## Skóre jistoty identifikace pacienta — `skore_jistoty` + +Než agent založí požadavek, spočítá **skóre 0–100**, jak jistě nalezený pacient +odpovídá pacientovi z mailu. Kombinuje víc nezávislých signálů; **rozpor srazí dolů** +(tím se ošetří díra, kdy shoda na RČ s překlepem trefí jiného pacienta). + +| Signál (shoda) | + | Rozpor | − | +|---|---|---|---| +| RČ sedí | 55 | jméno úplně jiné | 45 | +| jméno přesně / příjmení / částečně | 30 / 15 / 8 | datum narození nesedí | 35 | +| datum narození sedí | 20 | RČ nesedí na pacienta | 35 | +| e-mail odesílatele v kartotéce | 30 | | | +| telefon z mailu v kartotéce | 20 | | | +| lék v historii receptů | 10 | | | + +Rozhodnutí (jediný práh `SCORE_AUTO=85`): +- **≥ 85** → založí požadavek automaticky (štítek CLAUDE). +- **< 85** → **NIC nezaloží** a místo toho se **zeptá člověka přes Telegram** + (viz níže). Důvod: vytvoření požadavku je **nevratné a hned viditelné pacientovi** + — pacienta v něm nejde přepsat ani požadavek smazat. „Založit a pak ověřit" + proto nedává smysl; ověřujeme PŘED založením. + +Skóre i důvody jdou do logu. Funkce je čistá (testovatelná stubem), bez zápisů. + +## Human-in-the-loop přes Telegram (nejistá identifikace) + +Když je jistota < 85, agent jen zapíše dotaz do fronty a jde dál. Vyřízení dělá +samostatný proces. Moduly: + +| Modul | Role | +|-------|------| +| `recept_pending.py` | fronta dotazů (`_pending_recepty.json`, atomický zápis), stavy `ceka`→`zalozeno`/`preskoceno` | +| `recept_dialog.py` | čistě: `format_otazka` (text do Telegramu) + `parse_odpoved` (RČ / číslo kandidáta / „ne") | +| `recept_telegram.py` | přenos přes **user účet agenta** (Telethon, `Knihovny/telegram_user.py`), vlastní session `recepty`, píše Vladovi (`6639316354`) | +| `recept_resolver.py` | proces s vlastní session: pošle otázky, krátce polluje odpovědi (přes `precti_zpravy`, since_id), podle odpovědi založí (správné RČ je definitivní) a označí mail | + +Tok: e-mailový agent (vysoká jistota → založí; jinak → `recept_pending.pridej`). +Resolver: otázka z účtu agenta → odpověď jako **reply** (párování přes +`reply_to_msg_id`) → `mcp_medevio.zaloz_pozadavek_recept` správnému pacientovi +→ mail dostane `ClaudeZpracovalRecept`. Bez odpovědi záznam zůstává `ceka` +(čeká se libovolně dlouho, znovu se neptá). + +Telegram infrastruktura je popsaná v Trilium „2026-06-14 Telegram — bot, user +účet agenta a MCP server". User účet (na rozdíl od bota) unese víc souběžných +sessions, každá vidí všechny zprávy → odpovědi se rozlišují přes reply. + +**Jednorázový krok (uživatel, v terminálu — čeká na SMS kód):** +``` +python -m Knihovny.telegram_user login --jako recepty +``` +Pak spuštění resolveru: `python recept_resolver.py` + +Pozn.: nová session nezná Vladovu „entitu" → posílání by spadlo na *Could not +find the input entity*. Resolver to řeší sám: na startu volá `recept_telegram.priprav()` +(`get_dialogs` → entita se uloží do session). Login lze řídit i na dálku +dvoukrokově: `login_posli_kod('recepty')` → PHONE_CODE_HASH → `login_dokonci(kod, hash, 'recepty')`. + +**Ověřeno naživo 2026-06-14:** celý round-trip — agent zapsal nejistý dotaz → +resolver poslal otázku do Telegramu → Vlado odpověděl RČ jako reply → resolver +založil „Recept na léky" správnému pacientovi (dotazník Název léků + Poznámka, +štítek CLAUDE) a poslal potvrzení zpět. ## Známé limity / TODO @@ -92,9 +154,16 @@ Auth: Bearer token z `Medevio/token.txt` (dlouhodobý). Test: `python medevio_re 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ů. +- Bez summary e-mailu a bez odpovědi pacientovi — kandidáti na další krok + (vzor: `EmailAgent/faktury_agent.py`). +- **Idempotence**: po úspěšném založení požadavku se mail označí kategorií + `ClaudeZpracovalRecept` (`graph_mail.ensure_category` / `add_category`, + vyžaduje Mail.ReadWrite — ověřeno, app ho má). Při dalším běhu se takto + označené maily přeskočí (ještě před AI klasifikací). Maily čekající na + odpověď přes Telegram se přeskočí podle `recept_pending.je_mail_pending` + (znovu se neptá). +- Pozor: agent čte jen `NEWEST_N` (5) nejnovějších mailů — hloub do inboxu + nejde. Když je všech 5 nejnovějších označených, neudělá nic. ## Spuštění diff --git a/OrdinaceAgentEmail/medevio_recept.py b/OrdinaceAgentEmail/medevio_recept.py deleted file mode 100644 index eb4a5b8..0000000 --- a/OrdinaceAgentEmail/medevio_recept.py +++ /dev/null @@ -1,241 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -""" -medevio_recept.py — vytvoření požadavku "Recept na léky" v Medeviu pro pacienta. - -Určeno k volání z e-mailového agenta (recepty_agent.py): jakmile agent správně -identifikuje pacienta a co chce, založí mu v Medeviu požadavek, aby ho lékař viděl -(e-mail kontrolujeme zřídka, Medevio pořád). - -Hlavní funkce: - vytvor_recept(rodne_cislo, nazev_leku, poznamka) -> request_id - -Co dělá (vše ověřeno/odchyceno 2026-06-13 na testovacím pacientovi Vladko): - 1. RČ -> patient UUID (MySQL medevio.medevio_pacient) - 2. fillECRFForm -> ecrfFill.id (prázdný formulář) - 3. createPatientRequestWithoutReservation -> založí "Recept na léky" - 4. createClinicPatientRequestNote -> obě pole do INTERNÍ POZNÁMKY - 5. assignTagToPatientRequest -> štítek CLAUDE - -POZOR: lékařský (klinický) přístup neumí vyplnit pacientský dotazník smysluplně, -proto obsah ("Název léků" + "Poznámka") jde do interní poznámky, ne do dotazníku. - -Auth: Bearer token z Medevio/token.txt (dlouhodobý API token). -""" -import sys -import re -from pathlib import Path - -import requests -import pymysql -from pymysql.cursors import DictCursor - -try: - sys.stdout.reconfigure(encoding="utf-8") -except Exception: - pass - -# ============================================================ -# Konstanty odchycené z Medevia (klinika mudr-buzalkova) -# ============================================================ -CLINIC_SLUG = "mudr-buzalkova" -GRAPHQL_URL = "https://api.medevio.cz/graphql" - -RECEPT_USER_ECRF_ID = "79488e86-e9e5-47e3-8b19-7e5229427f23" # typ "Recept na léky" -RECEPT_SID = "ERECEPT_SIMPLEST_BEZ_DAVKOVANI" -RECEPT_STEP_ID = "erecept-gp-request" -CLAUDE_TAG_ID = "c136aeca-0625-4c43-b81f-fc3949ec6ba6" # štítek CLAUDE - -TOKEN_PATH = Path(__file__).resolve().parent.parent / "Medevio" / "token.txt" - -# MySQL je na různých strojích na různém portu — zkusíme kandidáty po řadě. -_MYSQL_BASE = dict(user="root", password="Vlado9674+", database="medevio") -_MYSQL_CANDIDATES = [ - dict(host="192.168.1.76", port=3306), - dict(host="192.168.1.76", port=3307), - dict(host="127.0.0.1", port=3307), - dict(host="127.0.0.1", port=3306), -] - -# ============================================================ -# GraphQL operace (přesně jak je posílá web Medevia) -# ============================================================ -M_FILL_ECRF = r""" -mutation ClinicRequestCreateModal_FillECRFForm($input: FillECRFFormInput!) { - ecrfFill: fillECRFForm(input: $input) { id } -} -""" - -M_CREATE_REQUEST = r""" -mutation ClinicRequestCreateModal_CreateRequest($clinicSlug: String!, $input: CreatePatientRequestWithoutReservationInput!) { - patientRequest: createPatientRequestWithoutReservation(clinicSlug: $clinicSlug, input: $input) { id } -} -""" - -M_CREATE_NOTE = r""" -mutation ClinicRequestNotes_Create($noteInput: CreateClinicPatientRequestNoteInput!) { - createClinicPatientRequestNote(noteInput: $noteInput) { id } -} -""" - -M_ASSIGN_TAG = r""" -mutation TagRequestEditModal_AssignTagToRequest($clinicSlug: String!, $requestId: UUID!, $tagId: UUID!) { - tagRequest: assignTagToPatientRequest(clinicSlug: $clinicSlug, patientRequestId: $requestId, tagId: $tagId) { id } -} -""" - - -# ============================================================ -# Pomocné funkce -# ============================================================ -def _read_token() -> str: - t = TOKEN_PATH.read_text(encoding="utf-8").strip() - return t[7:].strip() if t.lower().startswith("bearer ") else t - - -def _gql(query: str, variables: dict, token: str) -> dict: - headers = { - "Authorization": f"Bearer {token}", - "Content-Type": "application/json", - "Accept": "application/json", - } - r = requests.post(GRAPHQL_URL, json={"query": query, "variables": variables}, - headers=headers, timeout=30) - r.raise_for_status() - data = r.json() - if data.get("errors"): - raise RuntimeError(f"GraphQL chyba: {data['errors']}") - return data["data"] - - -def _mysql(): - last = None - for cand in _MYSQL_CANDIDATES: - try: - return pymysql.connect(**cand, **_MYSQL_BASE, - cursorclass=DictCursor, connect_timeout=4) - except Exception as e: - last = e - raise RuntimeError(f"MySQL (medevio) nedostupné na žádném kandidátovi: {last}") - - -def najdi_uuid_dle_rc(rodne_cislo: str) -> dict: - """RČ (s lomítkem i bez) -> řádek pacienta z medevio_pacient. - Vyhazuje LookupError když pacient není nalezen nebo je nejednoznačný.""" - rc = re.sub(r"\D", "", rodne_cislo or "") - if not rc: - raise ValueError("Prázdné / neplatné rodné číslo.") - conn = _mysql() - try: - with conn.cursor() as cur: - cur.execute( - "SELECT patient_id, name, surname, status, user_id " - "FROM medevio_pacient " - "WHERE REPLACE(identification_number, '/', '') = %s", - (rc,), - ) - rows = cur.fetchall() - finally: - conn.close() - if not rows: - raise LookupError(f"Pacient s RČ {rc} není v Medevio databázi (medevio_pacient).") - if len(rows) > 1: - raise LookupError(f"RČ {rc} odpovídá více pacientům — nejednoznačné, řeš ručně.") - return rows[0] - - -def _format_note(nazev_leku: str, poznamka: str) -> str: - return ( - "Žádost o recept (zpracováno e-mailovým agentem).\n\n" - f"Název léků:\n{(nazev_leku or '').strip() or '—'}\n\n" - f"Poznámka:\n{(poznamka or '').strip() or '—'}" - ) - - -# ============================================================ -# Hlavní funkce -# ============================================================ -def vytvor_recept(rodne_cislo: str = None, nazev_leku: str = "", poznamka: str = "", - *, patient_uuid: str = None, pridat_stitek: bool = True, - token: str = None, verbose: bool = True) -> str: - """ - Založí pacientovi požadavek "Recept na léky" + interní poznámku + štítek CLAUDE. - - Parametry: - rodne_cislo – RČ pacienta (s lomítkem i bez); přeloží se na UUID přes MySQL. - nazev_leku – text 1. pole ("Název léků"). - poznamka – text 2. pole ("Poznámka"). - patient_uuid – volitelně rovnou UUID pacienta (obejde lookup dle RČ; pro testy). - pridat_stitek – True = přiřadí štítek CLAUDE. - token – volitelně Bearer token; jinak se načte z token.txt. - - Vrací: request_id založeného požadavku (str). - """ - token = token or _read_token() - - if patient_uuid: - uuid = patient_uuid - kdo = f"UUID {uuid}" - else: - pac = najdi_uuid_dle_rc(rodne_cislo) - uuid = pac["patient_id"] - kdo = f"{pac['surname']} {pac['name']} (RČ {rodne_cislo}, status {pac['status']})" - - if verbose: - print(f"→ Pacient: {kdo}") - - # 1) prázdný ECRF fill - fill = _gql(M_FILL_ECRF, {"input": { - "byDoctor": True, - "fields": [], - "patientId": uuid, - "sid": RECEPT_SID, - "stepId": RECEPT_STEP_ID, - }}, token) - ecrf_fill_id = fill["ecrfFill"]["id"] - - # 2) vytvoř požadavek "Recept na léky" - created = _gql(M_CREATE_REQUEST, {"clinicSlug": CLINIC_SLUG, "input": { - "patientId": uuid, - "userECRFId": RECEPT_USER_ECRF_ID, - "ecrfFillIds": [ecrf_fill_id], - "createdByDoctor": True, - "shouldInvitePatient": False, - }}, token) - request_id = created["patientRequest"]["id"] - if verbose: - print(f"✓ Požadavek vytvořen: {request_id}") - - # 3) interní poznámka s oběma poli - _gql(M_CREATE_NOTE, {"noteInput": { - "requestId": request_id, - "content": _format_note(nazev_leku, poznamka), - }}, token) - if verbose: - print("✓ Interní poznámka zapsána") - - # 4) štítek CLAUDE - if pridat_stitek: - _gql(M_ASSIGN_TAG, { - "clinicSlug": CLINIC_SLUG, - "requestId": request_id, - "tagId": CLAUDE_TAG_ID, - }, token) - if verbose: - print("✓ Štítek CLAUDE přiřazen") - - return request_id - - -# ============================================================ -# Test (na testovacím pacientovi Vladko) -# ============================================================ -if __name__ == "__main__": - VLADKO_UUID = "0210db7b-8fb0-4b47-b1d8-ec7a10849a63" # Vladko - testovací aplikace - rid = vytvor_recept( - patient_uuid=VLADKO_UUID, - nazev_leku="Euthyrox 100 µg (TEST z medevio_recept.py)", - poznamka="Testovací požadavek z funkce vytvor_recept — možno zavřít.", - ) - print(f"\nHOTOVO. request_id = {rid}") - print(f"https://my.medevio.cz/mudr-buzalkova/klinika/pacienti?pozadavek={rid}") diff --git a/OrdinaceAgentEmail/recepty_agent.py b/OrdinaceAgentEmail/recepty_agent.py index 89586b0..b6b43f6 100644 --- a/OrdinaceAgentEmail/recepty_agent.py +++ b/OrdinaceAgentEmail/recepty_agent.py @@ -44,6 +44,8 @@ import graph_mail # noqa: E402 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 +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import recept_pending as _pending # noqa: E402 fronta dotazů (nejistá identifikace) # ========================= # NASTAVENÍ @@ -53,6 +55,19 @@ MAILBOX = "ordinace@buzalkova.cz" # Kolik nejnovějších mailů z Inboxu zpracovat (testovací režim). NEWEST_N = 5 +# Kategorie (štítek na mailu), kterou agent označí mail po úspěšném založení +# požadavku v Medeviu. Při dalším běhu se takto označené maily přeskočí +# → idempotence, nezakládá duplicitní požadavky. +PROCESSED_CATEGORY = "ClaudeZpracovalRecept" +# Kategorie pro maily, které nešlo vyřídit automaticky (k ruční kontrole). +MANUAL_CATEGORY = "ReceptRucne" + +# Práh jistoty pro PLNĚ automatické založení požadavku. Vytvoření požadavku je +# nevratné a hned viditelné pacientovi → pod tímto prahem agent NIC nezaloží +# a místo toho se zeptá člověka přes Telegram (přes pending frontu, viz +# recept_pending.py / recept_resolver.py). +SCORE_AUTO = 85 + # Claude model pro klasifikaci + vytěžení. ANTHROPIC_MODEL = "claude-haiku-4-5" @@ -102,7 +117,7 @@ def newest_inbox_messages(mailbox: str, n: int) -> list[dict]: url = f"{graph_mail.GRAPH}/users/{mailbox}/mailFolders/inbox/messages" params = { "$orderby": "receivedDateTime desc", - "$select": "id,subject,from,receivedDateTime,bodyPreview,body", + "$select": "id,subject,from,receivedDateTime,bodyPreview,body,categories", "$top": n, } headers = {**graph_mail._headers(), "Prefer": 'outlook.body-content-type="text"'} @@ -433,6 +448,80 @@ class MedicusLookup: return None, "", detail +# ========================= +# SKÓRE JISTOTY IDENTIFIKACE PACIENTA +# ========================= +def skore_jistoty(verdict: dict, patient: dict, sender_email: str, + lookup: "MedicusLookup") -> tuple[int, list[str]]: + """Kvantifikuje jistotu (0–100), že `patient` z kartotéky je opravdu pacient + z e-mailu. Vrací (skóre, důvody). Více nezávislých shod = vyšší jistota; + rozpor (jiné jméno / datum / RČ) skóre tvrdě srazí a označí ⚠. + Tím se ošetří díra, kdy shoda na RČ (např. překlep) trefí jiného pacienta — + bez souhlasu jména spadne z 'jisté' do pásma 'nutná kontrola'.""" + body = 0 + duvody: list[str] = [] + + e_rc = _norm_rc(verdict.get("rodne_cislo") or "") + p_rc = _norm_rc(patient.get("rodcis") or "") + e_name = frozenset(_norm_text(verdict.get("pacient") or "").split()) + p_name = frozenset( + _norm_text(f"{patient.get('jmeno') or ''} {patient.get('prijmeni') or ''}").split() + ) + p_surname = _norm_text(patient.get("prijmeni") or "") + e_dob = verdict.get("datum_narozeni") or (_rc_to_birthdate(e_rc) if e_rc else None) + p_dob = (str(patient.get("datnar") or "")[:10]) or None + idpac = patient.get("idpac") + + # Rodné číslo + if e_rc and p_rc: + if e_rc == p_rc: + body += 55; duvody.append("RČ sedí (+55)") + else: + body -= 35; duvody.append("⚠ RČ z mailu NESEDÍ na pacienta (−35)") + + # Jméno + if e_name and p_name: + if e_name == p_name: + body += 30; duvody.append("jméno přesně (+30)") + elif p_surname and p_surname in e_name: + body += 15; duvody.append("příjmení sedí (+15)") + elif e_name & p_name: + body += 8; duvody.append("částečná shoda jména (+8)") + else: + body -= 45; duvody.append("⚠ jméno NESOUHLASÍ (−45)") + + # Datum narození (z pole nebo odvozené z RČ) + if e_dob and p_dob: + if e_dob == p_dob: + body += 20; duvody.append("datum narození sedí (+20)") + else: + body -= 35; duvody.append("⚠ datum narození NESEDÍ (−35)") + + # E-mail odesílatele v kartotéce pacienta + em = (sender_email or "").strip().lower() + if em and any(p.get("idpac") == idpac for p in lookup.by_email.get(em, [])): + body += 30; duvody.append("e-mail odesílatele v kartotéce (+30)") + + # Telefon z textu mailu v kartotéce pacienta + ph = _norm_phone(verdict.get("telefon") or "") + if len(ph) >= 9 and any(p.get("idpac") == idpac for p in lookup.by_phone.get(ph, [])): + body += 20; duvody.append("telefon v kartotéce (+20)") + + # Požadovaný lék v historii receptů pacienta + try: + requested = [(l.get("nazev") or "").strip() for l in (verdict.get("leky") or [])] + requested = [r for r in requested if r] + if requested and idpac is not None: + drugs = {(h.get("lek") or "").strip() + for h in lookup.prescriptions(idpac) if h.get("lek")} + if any(_drug_matches(req, d) for req in requested for d in drugs): + body += 10; duvody.append("lék v historii receptů (+10)") + except Exception: + pass + + return max(0, min(100, body)), duvody + + # ========================= # MEDEVIO — ZÁPIS POŽADAVKU # ========================= @@ -486,6 +575,18 @@ def _compress_body(body: str) -> str: return text.strip() +def _kand_info(p: dict) -> dict: + """Z Medicus pacienta udělá lehký dict kandidáta pro Telegram dotaz.""" + return { + "idpac": p.get("idpac"), + "rc": _norm_rc(p.get("rodcis") or ""), + "jmeno": p.get("jmeno") or "", + "prijmeni": p.get("prijmeni") or "", + "datnar": str(p.get("datnar") or "")[:10], + "poj": p.get("poj") or "", + } + + def _format_leky(leky: list) -> str: """Formátuje seznam léků pro pole 'Název léků' — čárkami oddělený výčet.""" parts = [] @@ -543,8 +644,16 @@ def _medevio_find_patient(rc_normalized: str) -> str | None: # ========================= 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í)") + log(f"START — schránka={MAILBOX}, {NEWEST_N} nejnovějších mailů") + log(f"REŽIM: zakládá požadavky v Medeviu; zpracované maily značí štítkem " + f"'{PROCESSED_CATEGORY}' (a příště přeskakuje)") + + # Zajisti kategorii v master-listu schránky (s barvou). Best-effort. + try: + graph_mail.ensure_category(MAILBOX, PROCESSED_CATEGORY) + except Exception as e: + log(f"[POZOR] kategorii '{PROCESSED_CATEGORY}' nelze zajistit " + f"({type(e).__name__}: {e}) — chybí asi Mail.ReadWrite oprávnění") msgs = newest_inbox_messages(MAILBOX, NEWEST_N) log(f"Načteno {len(msgs)} mailů.") @@ -561,6 +670,15 @@ def main() -> None: log(f" Od: {sender.get('name', '')} <{sender.get('address', '')}>") log(f" Předmět: {subj}") + # Idempotence: mail už agent jednou zpracoval → přeskoč (žádný duplicitní požadavek). + if PROCESSED_CATEGORY in (msg.get("categories") or []): + log(f" => PŘESKOČENO — již zpracováno (štítek {PROCESSED_CATEGORY})\n") + continue + # Už čeká na potvrzení člověka přes Telegram → znovu se neptej. + if _pending.je_mail_pending(msg["id"]): + log(" => PŘESKOČENO — čeká na odpověď přes Telegram\n") + continue + try: v = classify(msg) except Exception as e: @@ -612,21 +730,61 @@ def main() -> None: for p in candidates: log(f" - {lookup.describe(p)}") - # Pokud je pacient jednoznačně identifikován, založ požadavek v Medeviu. + # Skóre jistoty identifikace → rozhodnutí: založit / zeptat se člověka. if identified_patient: + skore, duvody = skore_jistoty( + v, identified_patient, sender.get("address", ""), lookup + ) + else: + skore, duvody = 0, ["pacient nedohledán v kartotéce"] + log(f" Jistota: {skore}/100 — {'; '.join(duvody) or 'bez signálů'}") + + leky_str = _format_leky(v.get("leky") or []) + pozn_str = _format_poznamka(msg) + + if identified_patient and skore >= SCORE_AUTO: + # Vysoká jistota → založ rovnou. 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") + log(f" Medevio: [NENÍ V MEDEVIU] RČ {rc} — k ruční kontrole") + try: + graph_mail.add_category(MAILBOX, msg["id"], MANUAL_CATEGORY) + log(f" Mail: [OZNAČEN] {MANUAL_CATEGORY}") + except Exception as e: + log(f" Mail: [POZOR] štítek nenastaven " + f"({type(e).__name__}: {e})") 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}") + result = _medevio.zaloz_pozadavek_recept( + patient_uuid, leky_str, pozn_str + ) + log(f" Medevio: [ZALOZENO] požadavek " + f"{result['request_id']} [{skore}/100] | léky: {leky_str}") + # Označ mail jako zpracovaný → příště se přeskočí (idempotence). + try: + graph_mail.add_category(MAILBOX, msg["id"], PROCESSED_CATEGORY) + log(f" Mail: [OZNAČEN] štítek {PROCESSED_CATEGORY}") + except Exception as e: + log(f" Mail: [POZOR] štítek nenastaven " + f"({type(e).__name__}: {e}) — riziko duplicity při dalším běhu!") except Exception as e: log(f" Medevio: [CHYBA] {type(e).__name__}: {e}") + else: + # Nejistá identifikace → NEZAKLÁDAT, zeptat se člověka přes Telegram. + kandidati = [_kand_info(p) for p in candidates] + _pending.pridej( + email_message_id=msg["id"], + email_subject=subj, + sender=f"{sender.get('name', '')} <{sender.get('address', '')}>", + leky_str=leky_str, + pozn_str=pozn_str, + skore=skore, + duvody=duvody, + kandidati=kandidati, + ) + log(f" Rozhodnutí: [DOTAZ] jistota {skore} < {SCORE_AUTO} " + f"— čeká na potvrzení přes Telegram ({len(kandidati)} kandidátů)") log("") diff --git a/mcp_medevio.py b/mcp_medevio.py index 6c34335..1a86c7f 100644 --- a/mcp_medevio.py +++ b/mcp_medevio.py @@ -736,6 +736,8 @@ def get_pacient(patient_id: str) -> dict: RECEPT_SID = "ERECEPT_SIMPLEST_BEZ_DAVKOVANI" RECEPT_STEP_ID = "erecept-gp-request" RECEPT_USER_ECRF_ID = "79488e86-e9e5-47e3-8b19-7e5229427f23" # šablona kliniky +CLAUDE_TAG_ID = "c136aeca-0625-4c43-b81f-fc3949ec6ba6" # štítek "CLAUDE" +OVERIT_TAG_ID = "9d3271b3-309d-4d20-93ee-285f3e56ba42" # štítek "OVĚŘIT PACIENTA" _FILL_MUTATION = """ mutation Step_FillECRFForm($input: FillECRFFormInput!) { @@ -751,18 +753,41 @@ mutation PatientRequestSubmission_CreatePatientRequestWithoutReservation( ) { id } }""" +_ASSIGN_TAG_MUTATION = """ +mutation TagRequestEditModal_AssignTagToRequest( + $clinicSlug: String!, $requestId: UUID!, $tagId: UUID! +) { + tagRequest: assignTagToPatientRequest( + clinicSlug: $clinicSlug, patientRequestId: $requestId, tagId: $tagId + ) { id } +}""" + + +def prirad_stitek(request_id: str, tag_id: str) -> None: + """Přiřadí požadavku štítek (tag) podle jeho UUID.""" + _gql("TagRequestEditModal_AssignTagToRequest", _ASSIGN_TAG_MUTATION, { + "clinicSlug": CLINIC_SLUG, + "requestId": request_id, + "tagId": tag_id, + }) + @mcp.tool() -def zaloz_pozadavek_recept(patient_id: str, leky: str, poznamka: str = "") -> dict: +def zaloz_pozadavek_recept(patient_id: str, leky: str, poznamka: str = "", + stitek: bool = True, extra_stitky: list = None) -> 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. + založil sám v aplikaci — vyplní oba fieldy dotazníku: "Název léků" (leky) + a "Poznámka" (poznamka). Volitelně přiřadí štítek CLAUDE pro odlišení + automaticky založených požadavků. 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). + patient_id: UUID pacienta (z hledej_pacienta / get_pacient). + leky: Volný text názvů léků (pole dotazníku "Název léků:"). + poznamka: Text do pole dotazníku "Poznámka" (jde přes userNote). + stitek: True = přiřadí štítek CLAUDE (default). + extra_stitky: Volitelný seznam UUID dalších štítků (např. OVĚŘIT PACIENTA). """ try: fill = _gql("Step_FillECRFForm", _FILL_MUTATION, { @@ -796,12 +821,26 @@ def zaloz_pozadavek_recept(patient_id: str, leky: str, poznamka: str = "") -> di }, }, ) + request_id = req["patientRequest"]["id"] + + # Štítek CLAUDE — označení automaticky založených požadavků. + tag_ok = False + if stitek: + prirad_stitek(request_id, CLAUDE_TAG_ID) + tag_ok = True + + # Další volitelné štítky (např. OVĚŘIT PACIENTA u nižší jistoty). + for tid in (extra_stitky or []): + prirad_stitek(request_id, tid) + return { "ok": True, - "request_id": req["patientRequest"]["id"], + "request_id": request_id, "fill_id": fill_id, "patient_id": patient_id, "leky": leky, + "stitek_claude": tag_ok, + "extra_stitky": list(extra_stitky or []), } except Exception: log(f"zaloz_pozadavek_recept chyba: {traceback.format_exc()}")