diff --git a/Knihovny/medicus_db.py b/Knihovny/medicus_db.py index 21896c9..9bd9b71 100644 --- a/Knihovny/medicus_db.py +++ b/Knihovny/medicus_db.py @@ -2,14 +2,18 @@ # Připojení k Firebird databázi Medicus (medicus.fdb). Volí DSN podle názvu počítače. # Obsahuje třídu MedicusDB s metodami pro dotazy na pacienty, registrace a faktury. +import os import socket import fdb def get_medicus_connection(): """ - Připojí se k Firebird medicus.fdb podle názvu počítače. - Vrátí fdb.Connection nebo vyhodí RuntimeError pro neznámý počítač. + Připojí se k Firebird medicus.fdb. DSN se vybere takto: + 1) env MEDICUS_FDB_DSN (má přednost — nutné v dockeru, kde hostname = ID kontejneru), + 2) podle názvu počítače (dsn_map), + 3) default. + Vrátí fdb.Connection nebo vyhodí RuntimeError. """ computer_name = socket.gethostname().upper() dsn_map = { @@ -20,7 +24,8 @@ def get_medicus_connection(): "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") + dsn = (os.environ.get("MEDICUS_FDB_DSN") + or dsn_map.get(computer_name, r"localhost:c:\medicus 3\data\medicus.fdb")) import sys print(f"[medicus_db] Pripojuji se jako {computer_name} -> {dsn}", file=sys.stderr, flush=True) return fdb.connect(dsn=dsn, user="SYSDBA", password="masterkey", charset="win1250") diff --git a/Knihovny/telegram_notify.py b/Knihovny/telegram_notify.py new file mode 100644 index 0000000..f31e393 --- /dev/null +++ b/Knihovny/telegram_notify.py @@ -0,0 +1,184 @@ +""" +telegram_notify.py +------------------ +Notifikace a obousměrná komunikace přes Telegram Bot API +(bot ClaudeBot @Vlado_Claude_Bot). + +Token a výchozí chat_id se načítají z `Medevio/.env`: + TELEGRAM_BOT_TOKEN=123456789:AAE... + TELEGRAM_CHAT_ID=6639316354 + +Použití ze skriptu: + from Knihovny.telegram_notify import posli_telegram, zeptej_se_telegram + + posli_telegram("Pipeline 08 hotová, 142 záznamů") + + odpoved = zeptej_se_telegram("Mám reimportovat i archiv? (ano/ne)") + if odpoved and odpoved.strip().lower() == "ano": + ... + +Použití z příkazové řádky: + python -m Knihovny.telegram_notify "Hotovo" + python -m Knihovny.telegram_notify --ask "Pokracovat? (ano/ne)" + +POZN.: getUpdates smí v jednu chvíli pollovat jen JEDEN proces. Pokud běží +víc skriptů naráz, které čekají na odpověď, kradou si navzájem zprávy — +v praxi se ptá vždy jen jeden agent. +""" + +import os +import sys +import time +from pathlib import Path + +import requests + + +# ========================= +# Načtení .env (Medevio/.env) +# ========================= +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.setdefault(k.strip(), v.strip()) + + +_load_env() + + +API_BASE = "https://api.telegram.org/bot{token}/{method}" + + +def _token() -> str: + token = os.environ.get("TELEGRAM_BOT_TOKEN") + if not token: + raise RuntimeError("Chybí TELEGRAM_BOT_TOKEN v Medevio/.env") + return token + + +def _resolve_chat_id(chat_id: str | None) -> str: + chat_id = chat_id or os.environ.get("TELEGRAM_CHAT_ID") + if not chat_id: + raise RuntimeError("Chybí TELEGRAM_CHAT_ID (zadej argumentem nebo v Medevio/.env)") + return str(chat_id) + + +def _call(method: str, *, http_timeout: int = 15, **params): + """Zavolá Telegram Bot API metodu a vrátí pole `result`.""" + url = API_BASE.format(token=_token(), method=method) + r = requests.post(url, json=params, timeout=http_timeout) + data = r.json() + if not data.get("ok"): + raise RuntimeError(f"Telegram {method} selhal [{r.status_code}]: {data}") + return data["result"] + + +def posli_telegram( + text: str, + *, + chat_id: str | None = None, + parse_mode: str | None = None, + disable_notification: bool = False, +) -> dict: + """ + Pošle zprávu přes Telegram bota. + + :param text: text zprávy (max 4096 znaků) + :param chat_id: cílový chat; výchozí z TELEGRAM_CHAT_ID + :param parse_mode: None | "Markdown" | "MarkdownV2" | "HTML" + :param disable_notification: True = tichá zpráva (bez upozornění) + :return: odeslaná zpráva (dict z Telegram API) + """ + params = { + "chat_id": _resolve_chat_id(chat_id), + "text": text, + "disable_notification": disable_notification, + } + if parse_mode: + params["parse_mode"] = parse_mode + return _call("sendMessage", **params) + + +def zeptej_se_telegram( + otazka: str, + *, + chat_id: str | None = None, + timeout: int = 300, + poll_timeout: int = 30, + parse_mode: str | None = None, +) -> str | None: + """ + Pošle otázku a BLOKUJÍCÍ čeká na textovou odpověď uživatele. + + Zahodí starší zprávy a bere jen tu, která přijde PO odeslání otázky. + + :param otazka: text otázky + :param chat_id: cílový chat; výchozí z TELEGRAM_CHAT_ID + :param timeout: celkové čekání na odpověď v sekundách (pak vrátí None) + :param poll_timeout: délka jednoho long-poll cyklu v sekundách + :param parse_mode: formátování otázky (None | "HTML" | "Markdown") + :return: text odpovědi, nebo None když nikdo neodpoví do timeoutu + """ + cid = _resolve_chat_id(chat_id) + + # Zjisti poslední update_id, ať bereme jen NOVÉ zprávy po otázce. + existujici = _call("getUpdates", http_timeout=15) + offset = (existujici[-1]["update_id"] + 1) if existujici else 0 + + posli_telegram(otazka, chat_id=cid, parse_mode=parse_mode) + + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + zbyva = int(deadline - time.monotonic()) + if zbyva <= 0: + break + lp = max(1, min(poll_timeout, zbyva)) + updates = _call("getUpdates", http_timeout=lp + 10, offset=offset, timeout=lp) + for u in updates: + offset = u["update_id"] + 1 + msg = u.get("message") or {} + if str(msg.get("chat", {}).get("id")) != cid: + continue # zpráva z jiného chatu — ignoruj + text = msg.get("text") + if text: + return text + return None + + +def _safe_print(text: str): + """Výpis odolný vůči kódování Windows konzole (cp1252).""" + try: + print(text) + except UnicodeEncodeError: + print(text.encode("ascii", "replace").decode("ascii")) + + +if __name__ == "__main__": + # Ať projdou i diakritika/emoji na Windows konzoli. + try: + sys.stdout.reconfigure(encoding="utf-8") + except Exception: + pass + + args = sys.argv[1:] + + if not args: + print('Použití:') + print(' python -m Knihovny.telegram_notify "text zprávy"') + print(' python -m Knihovny.telegram_notify --ask "otázka?"') + sys.exit(1) + + if args[0] == "--ask": + otazka = " ".join(args[1:]) or "?" + odpoved = zeptej_se_telegram(otazka, timeout=240) + if odpoved is None: + _safe_print("(bez odpovědi — vypršel timeout)") + sys.exit(2) + _safe_print(odpoved) + else: + posli_telegram(" ".join(args)) + _safe_print("Odesláno OK") diff --git a/Knihovny/telegram_user.py b/Knihovny/telegram_user.py new file mode 100644 index 0000000..77aa310 --- /dev/null +++ b/Knihovny/telegram_user.py @@ -0,0 +1,302 @@ +""" +telegram_user.py +---------------- +Ovládání PLNOHODNOTNÉHO Telegram účtu (ne bota) přes user API (MTProto / Telethon). +Na rozdíl od bota umí napsat komukoli a unese VÍCE souběžných agentů na jednom účtu +(jako Telegram otevřený zároveň na PC, tabletu i mobilu). + +⚠️ Jedná JMÉNEM přihlášeného účtu. Session soubor = plný přístup k účtu. +⚠️ Nové účty na automatizaci Telegram rychle banuje (zvlášť VoIP čísla — použij reálnou SIM). + +──────────────────────────────────────────────────────────────────────── +VÍCE AGENTŮ NA JEDNOM ÚČTU +──────────────────────────────────────────────────────────────────────── +- api_id/api_hash se SDÍLÍ (identifikují „aplikaci", ne zařízení). +- Každý agent musí mít VLASTNÍ session soubor (= vlastní autorizace / „zařízení"). + Sdílet jednu session mezi procesy NELZE (database is locked / AUTH_KEY_DUPLICATED). + → každý agent se přihlásí zvlášť: `login --jako ` (jeden SMS kód na agenta). +- Všechny sessions vidí stejný chat, proto se odpovědi směrují přes Telegram **Reply**: + agent pošle označenou otázku a bere jen tu odpověď, která je Reply na *jeho* zprávu + (shoda `reply_to_msg_id`). Tím se odpovědi více agentů nepomíchají. + +Konfigurace v `Medevio/.env` (api_id/api_hash z https://my.telegram.org): + TELEGRAM_API_ID=1234567 + TELEGRAM_API_HASH=abcdef0123456789abcdef0123456789 + TELEGRAM_PHONE=+420... # nepovinné (jinak se zeptá při loginu) + +Session soubory: `Medevio/agent_telegram_.session` (gitignored). + +──────────────────────────────────────────────────────────────────────── +CLI +──────────────────────────────────────────────────────────────────────── +Jednorázové přihlášení agenta (spusť ve svém terminálu — čeká na kód z SMS): + python -m Knihovny.telegram_user login --jako recepty + python -m Knihovny.telegram_user login --jako kalendar + +Poslání zprávy ("me" = Uložené zprávy / Saved Messages): + python -m Knihovny.telegram_user send me "Test" --jako recepty + +Otázka + čekání na Reply odpověď (vypíše odpověď na stdout): + python -m Knihovny.telegram_user ask recepty "Mam pokracovat? (ano/ne)" + +──────────────────────────────────────────────────────────────────────── +ZE SKRIPTU +──────────────────────────────────────────────────────────────────────── + from Knihovny.telegram_user import posli_jako_ja, zeptej_se_jako + + posli_jako_ja("me", "Pipeline 08 hotová", session="recepty") + + odp = zeptej_se_jako("recepty", "Našel jsem 3 sporné záznamy. Pokračovat?") + if odp and odp.strip().lower() == "ano": + ... +""" + +import argparse +import os +import sys +import time +from pathlib import Path + +# telethon.sync zpřístupní metody synchronně (bez async/await) +from telethon.sync import TelegramClient +from telethon.errors import SessionPasswordNeededError, PhoneNumberUnoccupiedError + + +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.setdefault(k.strip(), v.strip()) + + +_load_env() + + +def _api_id() -> int: + val = os.environ.get("TELEGRAM_API_ID") + if not val: + raise RuntimeError("Chybí TELEGRAM_API_ID v Medevio/.env (z https://my.telegram.org)") + return int(val) + + +def _api_hash() -> str: + val = os.environ.get("TELEGRAM_API_HASH") + if not val: + raise RuntimeError("Chybí TELEGRAM_API_HASH v Medevio/.env (z https://my.telegram.org)") + return val + + +def _session_path(jmeno: str | None) -> Path: + base = f"agent_telegram_{jmeno}" if jmeno else "agent_telegram" + return Path(__file__).resolve().parent.parent / "Medevio" / base + + +def _new_client(session: str | None = None) -> TelegramClient: + return TelegramClient(str(_session_path(session)), _api_id(), _api_hash()) + + +def prihlas(session: str | None = None) -> None: + """ + Jednorázové přihlášení dané session. Interaktivně se zeptá na kód z SMS + a případně na heslo dvoufázového ověření. Vytvoří session soubor. + SPOUŠTĚJ V TERMINÁLU (potřebuje input). + """ + client = _new_client(session) + client.start(phone=os.environ.get("TELEGRAM_PHONE") or (lambda: input("Telefon (+420...): "))) + me = client.get_me() + print(f"Session '{session or 'default'}' přihlášena jako " + f"{me.first_name or ''} (@{me.username}) id={me.id}") + client.disconnect() + + +def _phone() -> str: + val = os.environ.get("TELEGRAM_PHONE") + if not val: + raise RuntimeError("Chybí TELEGRAM_PHONE v Medevio/.env") + return val + + +def login_posli_kod(session: str | None = None) -> None: + """ + 1. krok přihlášení (řízeného na dálku): vyžádá si od Telegramu kód. + Vytiskne `PHONE_CODE_HASH=...`, který je potřeba pro 2. krok. + """ + client = _new_client(session) + client.connect() + try: + sent = client.send_code_request(_phone()) + print("PHONE_CODE_HASH=" + sent.phone_code_hash) + finally: + client.disconnect() + + +def login_dokonci(code, phone_code_hash: str, session: str | None = None, + password: str | None = None) -> None: + """ + 2. krok přihlášení: dokončí login zadaným kódem (a případně heslem 2FA). + Při úspěchu uloží session soubor. + """ + client = _new_client(session) + client.connect() + try: + try: + client.sign_in(phone=_phone(), code=str(code), phone_code_hash=phone_code_hash) + except SessionPasswordNeededError: + if not password: + print("NEED_PASSWORD") + return + client.sign_in(password=password) + except PhoneNumberUnoccupiedError: + print("UCET_NEEXISTUJE - nejdriv zaregistruj cislo v aplikaci Telegram") + return + me = client.get_me() + print(f"OK prihlaseno jako {me.first_name or ''} (@{me.username}) id={me.id}") + finally: + client.disconnect() + + +def posli_jako_ja(komu, text: str, *, session: str | None = None): + """ + Pošle zprávu jménem přihlášeného účtu z dané session. + + :param komu: "me" (Saved Messages) | "@username" | telefon | int id + :param text: text zprávy + :param session: jméno session (které přihlášení použít) + :return: odeslaná zpráva (Telethon Message) + """ + with _new_client(session) as client: + if not client.is_user_authorized(): + raise RuntimeError( + f"Session '{session or 'default'}' není přihlášena — " + f"spusť: python -m Knihovny.telegram_user login" + + (f" --jako {session}" if session else "") + ) + return client.send_message(komu, text) + + +def precti_zpravy(komu, limit: int = 10, *, session: str | None = None): + """ + Vrátí posledních `limit` zpráv z daného chatu. + + :return: list dictů {"id", "text", "odeslal_ja", "reply_na", "datum"} + """ + out = [] + with _new_client(session) as client: + if not client.is_user_authorized(): + raise RuntimeError(f"Session '{session or 'default'}' není přihlášena.") + for msg in client.iter_messages(komu, limit=limit): + out.append({ + "id": msg.id, + "text": msg.text or "", + "odeslal_ja": bool(msg.out), + "reply_na": msg.reply_to_msg_id, + "datum": msg.date, + }) + return out + + +def zeptej_se_jako( + agent: str, + otazka: str, + *, + komu="me", + session: str | None = None, + timeout: int = 300, + poll_interval: int = 3, + vyzaduj_reply: bool = True, +) -> str | None: + """ + Pošle označenou otázku ("[agent] otázka") a BLOKUJÍCÍ čeká na odpověď. + + Při více agentech naráz se odpovědi rozlišují přes Telegram **Reply**: + bere jen tu příchozí zprávu, která je Reply na právě odeslanou otázku. + + :param agent: jméno agenta (objeví se v textu otázky jako štítek) + :param otazka: text otázky + :param komu: kam poslat ("me" = Saved Messages | "@username" | id) + :param session: jméno session; výchozí = `agent` (každý agent svůj soubor) + :param timeout: celkové čekání v sekundách (pak vrátí None) + :param poll_interval: jak často kontrolovat nové zprávy (s) + :param vyzaduj_reply: True = bere jen Reply na svou otázku (bezpečné pro víc agentů); + False = vezme první příchozí zprávu (jen pro 1 agenta) + :return: text odpovědi, nebo None při timeoutu + """ + session = session or agent + with _new_client(session) as client: + if not client.is_user_authorized(): + raise RuntimeError( + f"Session '{session}' není přihlášena — " + f"spusť: python -m Knihovny.telegram_user login --jako {session}" + ) + + sent = client.send_message(komu, f"[{agent}] {otazka}") + qid = sent.id + + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + # jen zprávy novější než naše otázka, od nejstarší + for msg in client.iter_messages(komu, min_id=qid, reverse=True): + if msg.out: + continue # naše vlastní zpráva + if vyzaduj_reply: + if msg.reply_to_msg_id == qid: + return msg.text + else: + return msg.text + zbyva = deadline - time.monotonic() + if zbyva <= 0: + break + time.sleep(min(poll_interval, max(1, zbyva))) + return None + + +def _safe_print(text: str): + try: + print(text) + except UnicodeEncodeError: + print(text.encode("ascii", "replace").decode("ascii")) + + +def _main(): + try: + sys.stdout.reconfigure(encoding="utf-8") + except Exception: + pass + + parser = argparse.ArgumentParser(prog="telegram_user", description="Telegram user účet (Telethon)") + sub = parser.add_subparsers(dest="cmd", required=True) + + p_login = sub.add_parser("login", help="jednorázové přihlášení session") + p_login.add_argument("--jako", dest="jako", default=None, help="jméno session/agenta") + + p_send = sub.add_parser("send", help="poslat zprávu") + p_send.add_argument("komu", help='"me" | "@username" | telefon | id') + p_send.add_argument("text", help="text zprávy") + p_send.add_argument("--jako", dest="jako", default=None, help="jméno session") + + p_ask = sub.add_parser("ask", help="poslat otázku a počkat na Reply odpověď") + p_ask.add_argument("agent", help="jméno agenta (štítek + výchozí session)") + p_ask.add_argument("text", help="text otázky") + p_ask.add_argument("--komu", dest="komu", default="me", help='kam (výchozí "me")') + p_ask.add_argument("--timeout", dest="timeout", type=int, default=240, help="čekání v s") + + args = parser.parse_args() + + if args.cmd == "login": + prihlas(args.jako) + elif args.cmd == "send": + posli_jako_ja(args.komu, args.text, session=args.jako) + _safe_print("Odesláno OK") + elif args.cmd == "ask": + odp = zeptej_se_jako(args.agent, args.text, komu=args.komu, timeout=args.timeout) + if odp is None: + _safe_print("(bez odpovědi — vypršel timeout)") + sys.exit(2) + _safe_print(odp) + + +if __name__ == "__main__": + _main() diff --git a/MedicusFirebird/README.md b/MedicusFirebird/README.md new file mode 100644 index 0000000..a98bcf4 --- /dev/null +++ b/MedicusFirebird/README.md @@ -0,0 +1,95 @@ +# MedicusFirebird — Firebird 2.5 zrcadlo Medicus DB na toweru + +Kontejnerizované zrcadlo ostré Medicus databáze (Firebird) na serveru **tower** (Unraid, 192.168.1.76). +Nahrazuje dosavadní restore na Windows VM **reporter** — tu lze po ověření na Firebird části vypnout. + +## Proč + +Všechny ostatní DB (MySQL, PostgreSQL, MongoDB, Redis) běží na toweru jako Docker. +Firebird sem logicky patří taky: jeden host, jeden režim záloh/monitoringu, žádná Windows VM navíc. + +## Tok dat + +``` +Ordinace: gbak -> zip -> rsync na tower (~02:15) +Tower: /mnt/user/OrdinaceSynology/MedicusBackup/MEDICUS_RRMMDD_HHMM.zip (zalohy se HROMADI) + restore_medicus.sh (denne): + 1) nejnovejsi MEDICUS_*.zip podle nazvu; pokud == last_restored.txt -> skip + 2) pocka az velikost prestane rust (probiha-li jeste rsync) + overi unzip -t + 3) unzip .fbk -> gbak -r do medicus_new.fdb -> stop+swap+start kontejneru + 4) zapise marker (last_restored.txt) + 5) GFS retence zaloh (prune_backups.sh) +Kontejner: firebird-medicus -> serve tower:3050 /firebird/data/medicus.fdb +``` + +Zdrojová DB: **Firebird 2.5.7**, ODS 11.2, dialect 3, page size 8192. +Image: `jacobalberty/firebird:2.5-ss` = **Firebird 2.5.9** (restore 2.5.7 → 2.5.9 v rámci řady OK). + +## Soubory + +| Soubor | Popis | +|--------|-------| +| `firebird_create.sh` | Jednorázové vytvoření / znovuvytvoření kontejneru | +| `restore_medicus.sh` | Denní rutina: obnova z nejnovější zálohy + retence (cron) | +| `prune_backups.sh` | GFS retence záloh (volá se z restore; lze i samostatně) | +| `verify_firebird.sh` | Kontrola: verze enginu, ODS, počet pacientů | +| `last_restored.txt` | Marker poslední úspěšně restorované zálohy (vzniká za běhu) | +| `restore.log` | Log denních běhů | + +## Umístění na toweru + +- Skripty: `/mnt/user/Scripts/MedicusFirebird/` +- Data kontejneru: `/mnt/user/appdata/firebird-medicus/fb` (→ `/firebird`, soubor `data/medicus.fdb`) +- Rozbalovací prostor pro `.fbk`: `/mnt/user/appdata/firebird-medicus/work` (→ `/work`) + +## Kontejner + +``` +docker run -d --name firebird-medicus --restart unless-stopped \ + -p 3050:3050 -e ISC_PASSWORD=masterkey -e TZ=Europe/Prague \ + -v /mnt/user/appdata/firebird-medicus/fb:/firebird \ + -v /mnt/user/appdata/firebird-medicus/work:/work \ + jacobalberty/firebird:2.5-ss +``` + +Pozn.: `gbak`/`isql` jsou v `/usr/local/firebird/bin/` (nejsou v PATH → volat plnou cestou). +Hesla jsou v `security2.fdb` (nastaveno přes `ISC_PASSWORD`), ne v `medicus.fdb` — restore dat heslo nemění. + +## Robustnost restoru + +Zálohy se v adresáři **hromadí** a nejnovější se může právě **přenášet přes rsync**, proto: +- výběr nejnovější podle **názvu** (`RRMMDD_HHMM` → lexikálně = chronologicky) +- **stav** v `last_restored.txt` → když není nic novějšího, nic se nedělá +- **čeká na dokončení přenosu** (velikost se ustálí) a ověří integritu `unzip -t` — nikdy nezpracuje nekompletní soubor +- marker se zapíše **až po úspěšném** restoru; zámek (`flock`) proti souběhu + +## Retence záloh (GFS, sekvenční, počítaná) + +`prune_backups.sh` drží v adresáři záloh schéma: +1. **30 posledních dní** — nech všechny denní +2. **pak 8 týdnů** — z každého ISO-týdne 1× (nejnovější = konec týdne) +3. **pak 12 měsíců** — z každého měsíce 1× (nejnovější) +4. starší → smazat + +Datum se čte **z názvu** (ne mtime). Neparsovatelné názvy se nikdy nemažou. +Bezpečnostní přepínač `DRY_RUN=1` (jen výpis) / `DRY_RUN=0` (maže). V denní rutině řízeno `RETENTION_DRYRUN` +v `restore_medicus.sh` (ostré = 0). + +## Připojení klientů (fdb / DSN) + +``` +192.168.1.76:/firebird/data/medicus.fdb SYSDBA / masterkey, charset win1250 +# nebo: tower:/firebird/data/medicus.fdb +``` + +V `Knihovny/medicus_db.py` je odpovídající záznam v `dsn_map` (klíč `TOWER`). +Cutover skriptů/MCP z reporteru (2.5.7) na tower (2.5.9) = otevřené rozhodnutí. + +## Cron (na toweru) + +Záloha přistává ~02:15; denní rutina poté. Plánovat přes **User Scripts plugin** +(vzor: `PostgreSQLRestoreFromBackup`), spouštět: + +``` +/mnt/user/Scripts/MedicusFirebird/restore_medicus.sh # napr. 06:30 denne +``` diff --git a/MedicusFirebird/firebird_create.sh b/MedicusFirebird/firebird_create.sh new file mode 100644 index 0000000..16ef061 --- /dev/null +++ b/MedicusFirebird/firebird_create.sh @@ -0,0 +1,30 @@ +#!/bin/bash +# Vytvori (nebo znovuvytvori) Firebird 2.5 kontejner = zrcadlo Medicus DB na toweru. +# Spousti se jednorazove pri zakladani / zmene konfigurace. +set -euo pipefail + +NAME=firebird-medicus +IMAGE=jacobalberty/firebird:2.5-ss +APPDATA=/mnt/user/appdata/firebird-medicus +FBDIR="$APPDATA/fb" # -> /firebird (data, system, security2.fdb) +WORKDIR="$APPDATA/work" # -> /work (sem se rozbaluje .fbk pred restorem) +PASS=masterkey + +mkdir -p "$FBDIR" "$WORKDIR" + +# odstran stary kontejner, pokud existuje (data v appdata zustanou) +docker rm -f "$NAME" 2>/dev/null || true + +docker run -d \ + --name "$NAME" \ + --restart unless-stopped \ + -p 3050:3050 \ + -e ISC_PASSWORD="$PASS" \ + -e TZ=Europe/Prague \ + -v "$FBDIR":/firebird \ + -v "$WORKDIR":/work \ + "$IMAGE" + +echo "Kontejner $NAME vytvoren. Cekam na start serveru..." +sleep 10 +docker ps --filter "name=$NAME" --format "{{.Names}} {{.Status}} {{.Ports}}" diff --git a/MedicusFirebird/prune_backups.sh b/MedicusFirebird/prune_backups.sh new file mode 100644 index 0000000..3d16a3d --- /dev/null +++ b/MedicusFirebird/prune_backups.sh @@ -0,0 +1,62 @@ +#!/bin/bash +# GFS retence PLNYCH zaloh Medicus (kazda zaloha je kompletni -> mazani ostatnich je bezpecne). +# +# SEKVENCNI, POCITANE tiery (jdou ZA sebou, neprekryvaji se), od nejnovejsiho zpet: +# 1) DENNI : poslednich 30 dni -> nech VSECHNY +# 2) TYDENNI : pak presne 8 tydnu dozadu -> z kazdeho ISO-tydne 1x (nejnovejsi = konec tydne) +# 3) MESICNI : pak presne 12 mesicu dozadu -> z kazdeho mesice 1x (nejnovejsi) +# 4) starsi : smazat +# Reference "ted" = datum NEJNOVEJSI zalohy. Datum se cte Z NAZVU MEDICUS_RRMMDD_HHMM.zip. +# +# BEZPECNOST: DRY_RUN=1 (default) jen vypisuje, NIC nemaze. DRY_RUN=0 skutecne maze. +# Neznamy/neparsovatelny nazev se NIKDY nemaze. +set -euo pipefail + +BACKUP_DIR="${BACKUP_DIR:-/mnt/user/OrdinaceSynology/MedicusBackup}" +DAILY_DAYS="${DAILY_DAYS:-30}" +WEEKLY_WEEKS="${WEEKLY_WEEKS:-8}" +MONTHLY_MONTHS="${MONTHLY_MONTHS:-12}" +DRY_RUN="${DRY_RUN:-1}" + +date_from_name() { local d="${1#MEDICUS_}"; d="${d:0:6}"; echo "20${d:0:2}-${d:2:2}-${d:4:2}"; } + +mapfile -t FILES < <(cd "$BACKUP_DIR" && ls -1 MEDICUS_*.zip 2>/dev/null | sort -r) # nejnovejsi prvni +[ "${#FILES[@]}" -eq 0 ] && { echo "Zadne zalohy v $BACKUP_DIR"; exit 0; } + +REF=$(date_from_name "${FILES[0]}") +date -d "$REF" >/dev/null 2>&1 || { echo "CHYBA: nelze precist datum z ${FILES[0]}"; exit 1; } +D_CUT=$(date -d "$REF -${DAILY_DAYS} days" +%F) +echo "REF=$REF denni>=$D_CUT, pak ${WEEKLY_WEEKS}x tydenni, pak ${MONTHLY_MONTHS}x mesicni (starsi smazat)" + +declare -A KEEP seen_week seen_month +dn=0; w=0; m=0 +for f in "${FILES[@]}"; do + dt=$(date_from_name "$f") + if ! date -d "$dt" >/dev/null 2>&1; then KEEP[$f]="?"; continue; fi # neparsovatelne -> ponechat + if [[ ! "$dt" < "$D_CUT" ]]; then KEEP[$f]="d"; dn=$((dn+1)); continue; fi # 1) denni (30 dni) + if [ "$w" -lt "$WEEKLY_WEEKS" ]; then # 2) tydenni (8x) + wk=$(date -d "$dt" +%G-%V) + [ -z "${seen_week[$wk]:-}" ] && { seen_week[$wk]=1; w=$((w+1)); KEEP[$f]="w"; } + continue + fi + if [ "$m" -lt "$MONTHLY_MONTHS" ]; then # 3) mesicni (12x) + mo=$(date -d "$dt" +%Y-%m) + [ -z "${seen_month[$mo]:-}" ] && { seen_month[$mo]=1; m=$((m+1)); KEEP[$f]="m"; } + continue + fi +done + +mode="DRY-RUN (nic se nemaze)"; [ "$DRY_RUN" = "0" ] && mode="OSTRY (maze!)" +echo "=== GFS retence $mode | $BACKUP_DIR ===" +echo "schema: ${DAILY_DAYS}d / ${WEEKLY_WEEKS}t / ${MONTHLY_MONTHS}m | celkem: ${#FILES[@]} | ponechano: ${#KEEP[@]} (denni=$dn tydenni=$w mesicni=$m)" + +del=0 +for f in "${FILES[@]}"; do + if [ -n "${KEEP[$f]:-}" ]; then + printf ' KEEP [%s] %s\n' "${KEEP[$f]}" "$f" + else + printf ' DEL %s\n' "$f"; del=$((del+1)) + [ "$DRY_RUN" = "0" ] && rm -f -- "$BACKUP_DIR/$f" + fi +done +echo "=== ke smazani: $del ===" diff --git a/MedicusFirebird/restore_medicus.sh b/MedicusFirebird/restore_medicus.sh new file mode 100644 index 0000000..de2be46 --- /dev/null +++ b/MedicusFirebird/restore_medicus.sh @@ -0,0 +1,92 @@ +#!/bin/bash +# Denni obnova zrcadla Medicus DB z nejnovejsi gbak zalohy do Firebird kontejneru +# + GFS retence zaloh. +# +# Zalohy se v adresari HROMADI a nejnovejsi se muze prave PRENASET pres rsync. Proto: +# - vybira nejnovejsi MEDICUS_*.zip podle nazvu (RRMMDD_HHMM -> lexikalne = chronologicky) +# - pamatuje si posledni uspesne restorovanou (last_restored.txt) -> neni-li nic novejsiho, konci +# - ceka, az velikost prestane rust (probiha-li rsync), a overi integritu (unzip -t), +# teprve pak restoruje -> nikdy nezpracuje nekompletni prenos +# - marker se zapise az PO uspesnem restoru +# - na konci spusti GFS retenci zaloh (prune_backups.sh) +set -euo pipefail + +NAME=firebird-medicus +APPDATA=/mnt/user/appdata/firebird-medicus +DATA="$APPDATA/fb/data" +WORK="$APPDATA/work" +BACKUP_DIR=/mnt/user/OrdinaceSynology/MedicusBackup +GBAK=/usr/local/firebird/bin/gbak +PASS=masterkey +BASE_DIR=/mnt/user/Scripts/MedicusFirebird +STATE="$BASE_DIR/last_restored.txt" +LOG="$BASE_DIR/restore.log" + +# Retence: ostra (maze dle GFS 30d/8t/12m). Pro testovaci beh prepnout na 1 (jen vypis). +RETENTION_DRYRUN="${RETENTION_DRYRUN:-0}" + +exec >>"$LOG" 2>&1 +exec 9>"$BASE_DIR/.restore.lock" +flock -n 9 || { echo "$(date '+%F %T') jiny restore uz bezi -> koncim."; exit 0; } +echo "===== $(date '+%F %T') restore start =====" + +mkdir -p "$WORK" "$DATA" + +# --- 1) nejnovejsi zaloha podle nazvu --- +ZIP=$(ls -1 "$BACKUP_DIR"/MEDICUS_*.zip 2>/dev/null | sort | tail -1 || true) +if [ -z "${ZIP:-}" ]; then echo "CHYBA: zadna zaloha v $BACKUP_DIR"; exit 1; fi +ZIP_BASE=$(basename "$ZIP") +LAST=$(cat "$STATE" 2>/dev/null || echo "") +echo "Nejnovejsi: $ZIP_BASE | posledni restorovana: ${LAST:-}" + +# --- 2) uz restorovana? --- +if [ "$ZIP_BASE" = "$LAST" ]; then + echo "Nic noveho -> restore preskocen." +else + # --- 3) pockej na dokonceni prenosu (velikost se ustali) --- + prev=-1; stable=0 + for i in $(seq 1 80); do # max ~20 min cekani na rsync + cur=$(stat -c %s "$ZIP" 2>/dev/null || echo 0) + if [ "$cur" = "$prev" ] && [ "$cur" -gt 0 ]; then stable=1; break; fi + echo " ...$ZIP_BASE = $cur B, cekam na ustaleni" + prev=$cur; sleep 15 + done + [ "$stable" = "1" ] || { echo "CHYBA: $ZIP_BASE se stale meni -> koncim (priste)."; exit 1; } + + # --- 4) integrita (nekompletni/poskozeny zip neprojde) --- + echo "unzip -t ..." + unzip -tqq "$ZIP" || { echo "CHYBA: $ZIP_BASE neprosel unzip -t -> koncim."; exit 1; } + + # --- 5) rozbaleni .fbk --- + rm -f "$WORK"/*.fbk + unzip -o "$ZIP" -d "$WORK" >/dev/null + FBK=$(ls -1t "$WORK"/*.fbk | head -1) + FBK_BASE=$(basename "$FBK") + echo "FBK: $FBK_BASE ($(du -h "$FBK" | cut -f1))" + + # --- 6) restore pres bezici server do noveho souboru --- + docker start "$NAME" >/dev/null 2>&1 || true + sleep 8 + echo "gbak restore -> medicus_new.fdb ..." + docker exec "$NAME" rm -f /firebird/data/medicus_new.fdb + docker exec "$NAME" "$GBAK" -r -p 8192 -user SYSDBA -password "$PASS" \ + "/work/$FBK_BASE" "localhost:/firebird/data/medicus_new.fdb" + + # --- 7) atomicky swap + restart --- + echo "swap + restart ..." + docker stop "$NAME" >/dev/null + mv -f "$DATA/medicus_new.fdb" "$DATA/medicus.fdb" + docker start "$NAME" >/dev/null + sleep 8 + rm -f "$WORK"/*.fbk + + # --- 8) marker az po uspechu --- + echo "$ZIP_BASE" > "$STATE" + echo "restore OK: $ZIP_BASE" +fi + +# --- 9) GFS retence zaloh --- +echo "--- retence zaloh (DRY_RUN=$RETENTION_DRYRUN) ---" +DRY_RUN="$RETENTION_DRYRUN" "$BASE_DIR/prune_backups.sh" || echo "VAROVANI: prune_backups.sh selhal" + +echo "===== $(date '+%F %T') hotovo =====" diff --git a/MedicusFirebird/verify_firebird.sh b/MedicusFirebird/verify_firebird.sh new file mode 100644 index 0000000..8ac46c7 --- /dev/null +++ b/MedicusFirebird/verify_firebird.sh @@ -0,0 +1,14 @@ +#!/bin/bash +# Rychla kontrola obnoveneho zrcadla: verze enginu, ODS, pocet pacientu. +set -euo pipefail +NAME=firebird-medicus +PASS=masterkey +ISQL=/usr/local/firebird/bin/isql +DB=localhost:/firebird/data/medicus.fdb + +docker exec -i "$NAME" "$ISQL" -user SYSDBA -password "$PASS" "$DB" <<'SQL' +SELECT rdb$get_context('SYSTEM','ENGINE_VERSION') AS ENGINE FROM rdb$database; +SELECT MON$ODS_MAJOR AS ODS_MAJOR, MON$ODS_MINOR AS ODS_MINOR, MON$PAGE_SIZE AS PAGE_SIZE FROM MON$DATABASE; +SELECT COUNT(*) AS PACIENTU FROM KAR; +QUIT; +SQL diff --git a/OrdinaceAgentEmail/NOTES.md b/OrdinaceAgentEmail/NOTES.md index 9671144..da47574 100644 --- a/OrdinaceAgentEmail/NOTES.md +++ b/OrdinaceAgentEmail/NOTES.md @@ -4,18 +4,22 @@ 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) +## Stav: nasazeno na toweru (produkce), DELTA režim -`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`. +`recepty_agent.py` zpracuje **všechny nové maily od posledního zpracovaného** +(vodoznak), klasifikuje Claude modelem, identifikuje pacienta a u vysoké jistoty +založí požadavek v Medeviu, jinak dá dotaz do fronty (Telegram). Označuje maily +kategoriemi. Běží na toweru — viz „Nasazení na tower" níže. ## 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). +1. **Graph API — DELTA** `nove_inbox_messages(mailbox, since_iso)`: všechny maily + s `receivedDateTime gt vodoznak`, řazeno vzestupně, stránkováno (max + `MAX_PER_RUN`=200/běh). Vodoznak = `_last_processed.txt` (2 řádky: čas + ID + posl. mailu). POZOR: Graph má sub-sekundovou přesnost, ale `receivedDateTime` + se zobrazuje oříznutě na sekundy → `gt` vrací i hraniční už zpracovaný mail; + ten se odfiltruje podle uloženého ID. První běh (vodoznak chybí) jen nastaví + vodoznak na nejnovější mail a od příště jede dopředně (historie se nedohání). 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), @@ -162,11 +166,19 @@ založil „Recept na léky" správnému pacientovi (dotazník Název léků + P 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. +- DELTA režim: zpracuje vše po vodoznaku (ne jen N nejnovějších). Strop + `MAX_PER_RUN`=200/běh (kdyby byl vodoznak hodně zpět — zbytek dobere další běh). + Vodoznak lze ručně posunout úpravou `_last_processed.txt` (např. backfill). ## Spuštění +Vývoj (notebook/Z230): ```powershell python U:\ordinaceprojekt\OrdinaceAgentEmail\recepty_agent.py ``` + +Produkce (tower, python-runner) — viz „Nasazení na tower": +``` +docker exec -e PYTHONIOENCODING=utf-8 -e MEDICUS_FDB_DSN=192.168.1.76:/firebird/data/medicus.fdb \ + python-runner python /scripts/OrdinaceReceptAgent/OrdinaceAgentEmail/recepty_agent.py +``` diff --git a/OrdinaceAgentEmail/recept_dialog.py b/OrdinaceAgentEmail/recept_dialog.py new file mode 100644 index 0000000..09f193a --- /dev/null +++ b/OrdinaceAgentEmail/recept_dialog.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +recept_dialog.py — formátování otázky do Telegramu a parsování odpovědi člověka. +Čisté funkce bez vedlejších efektů (snadno testovatelné). +""" +import re +import unicodedata + + +def _bez_diakritiky(s: str) -> str: + s = unicodedata.normalize("NFKD", s or "") + return "".join(c for c in s if not unicodedata.combining(c)) + + +def format_otazka(rec: dict) -> str: + """Sestaví text otázky do Telegramu z pending záznamu.""" + sk = rec.get("skore") + lines = ["🟡 Žádost o recept — nejistá identifikace" + + (f" (jistota {sk}/100)" if sk is not None else "")] + if rec.get("sender"): + lines.append(f"Od: {rec['sender']}") + if rec.get("email_subject"): + lines.append(f"Předmět: {rec['email_subject']}") + if rec.get("leky_str"): + lines.append(f"Léky: {rec['leky_str']}") + if rec.get("duvody"): + lines.append("Proč nejisté: " + "; ".join(rec["duvody"])) + + kand = rec.get("kandidati") or [] + if kand: + lines.append("") + lines.append("Kandidáti:") + for i, k in enumerate(kand, 1): + lines.append( + f" {i}) {k.get('prijmeni', '')} {k.get('jmeno', '')}" + f", RČ {k.get('rc', '?')}, nar. {k.get('datnar', '?')}" + f", poj. {k.get('poj', '?')}" + ) + else: + lines.append("") + lines.append("(žádný kandidát v kartotéce)") + + lines.append("") + lines.append("Odpověz jako reply na tuto zprávu:") + lines.append("• RČ správného pacienta (definitivní)") + if kand: + lines.append("• nebo číslo kandidáta (1, 2, …)") + lines.append("• nebo „ne“ = nezakládat") + return "\n".join(lines) + + +_SKIP = { + "ne", "nezakladat", "preskoc", "preskocit", "zahodit", "zahod", + "ignoruj", "nic", "stop", "nezaklada", "nezakladej", +} + + +def parse_odpoved(text: str) -> dict: + """Rozparsuje odpověď člověka. + + Vrací dict: + {"akce": "rc", "rc": "7309208104"} – definitivní RČ pacienta + {"akce": "kandidat", "index": 1} – výběr kandidáta podle pořadí + {"akce": "preskoc"} – nezakládat + {"akce": "nejasne"} – nerozpoznáno + """ + t = (text or "").strip() + low = _bez_diakritiky(t).lower().strip().rstrip(".!") + if low in _SKIP: + return {"akce": "preskoc"} + + digits = re.sub(r"\D", "", t) + if len(digits) in (9, 10): + return {"akce": "rc", "rc": digits} + + m = re.fullmatch(r"\s*(\d{1,2})\s*", t) + if m: + return {"akce": "kandidat", "index": int(m.group(1))} + + return {"akce": "nejasne"} diff --git a/OrdinaceAgentEmail/recept_pending.py b/OrdinaceAgentEmail/recept_pending.py new file mode 100644 index 0000000..1d38666 --- /dev/null +++ b/OrdinaceAgentEmail/recept_pending.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +recept_pending.py — fronta "čekajících" žádostí o recept, u kterých si agent +NENÍ jistý identifikací pacienta a potřebuje potvrzení člověka přes Telegram. + +E-mailový agent sem zapíše záznam (stav 'ceka') a NIC dalšího nedělá. +Resolver (recept_resolver.py) záznamy bere, pošle otázku do Telegramu a podle +odpovědi člověka založí požadavek správnému pacientovi. + +Úložiště: JSON soubor _pending_recepty.json vedle tohoto modulu (atomický zápis). +Stavy záznamu: 'ceka' → 'zalozeno' | 'preskoceno'. +""" +import json +import os +import tempfile +import uuid +from datetime import datetime +from pathlib import Path + +STORE = Path(__file__).resolve().parent / "_pending_recepty.json" + + +def _load() -> list: + if not STORE.exists(): + return [] + try: + return json.loads(STORE.read_text(encoding="utf-8")) + except Exception: + return [] + + +def _save(items: list) -> None: + tmp = tempfile.NamedTemporaryFile( + "w", encoding="utf-8", dir=str(STORE.parent), delete=False, suffix=".tmp" + ) + try: + json.dump(items, tmp, ensure_ascii=False, indent=2) + tmp.flush() + os.fsync(tmp.fileno()) + tmp.close() + os.replace(tmp.name, STORE) + except Exception: + try: + os.unlink(tmp.name) + except Exception: + pass + raise + + +def pridej(*, email_message_id: str, email_subject: str = "", sender: str = "", + leky_str: str = "", pozn_str: str = "", skore=None, + duvody=None, kandidati=None) -> dict: + """Přidá nový čekající dotaz. leky_str/pozn_str se použijí při pozdějším + založení požadavku, kandidáti se ukážou člověku v otázce.""" + items = _load() + rec = { + "id": uuid.uuid4().hex, + "vytvoreno": datetime.now().isoformat(timespec="seconds"), + "email_message_id": email_message_id, + "email_subject": email_subject, + "sender": sender, + "leky_str": leky_str, + "pozn_str": pozn_str, + "skore": skore, + "duvody": duvody or [], + "kandidati": kandidati or [], + "otazka_message_id": None, # vyplní resolver po odeslání otázky + "stav": "ceka", # ceka | zalozeno | preskoceno + "vysledek": None, + } + items.append(rec) + _save(items) + return rec + + +def cekajici() -> list: + return [r for r in _load() if r.get("stav") == "ceka"] + + +def cekajici_bez_otazky() -> list: + """Záznamy, na které se ještě neposlala otázka do Telegramu.""" + return [r for r in _load() + if r.get("stav") == "ceka" and not r.get("otazka_message_id")] + + +def je_mail_pending(email_message_id: str) -> bool: + return any(r.get("email_message_id") == email_message_id + and r.get("stav") == "ceka" for r in _load()) + + +def najdi_dle_otazky(otazka_message_id) -> dict | None: + if otazka_message_id is None: + return None + for r in _load(): + if r.get("otazka_message_id") == otazka_message_id: + return r + return None + + +def aktualizuj(rec_id: str, **fields) -> dict | None: + items = _load() + out = None + for r in items: + if r.get("id") == rec_id: + r.update(fields) + out = r + break + if out is not None: + _save(items) + return out diff --git a/OrdinaceAgentEmail/recept_resolver.py b/OrdinaceAgentEmail/recept_resolver.py new file mode 100644 index 0000000..51c173d --- /dev/null +++ b/OrdinaceAgentEmail/recept_resolver.py @@ -0,0 +1,185 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +recept_resolver.py — vyřizuje "čekající" žádosti o recept přes Telegram. + +Jediný proces, který mluví s Telegramem (kvůli getUpdates). Ve smyčce: + 1) pošle otázky pro nové pending záznamy (které ještě otázku nemají), + 2) long-polluje odpovědi; odpověď (reply na otázku) → podle obsahu: + • RČ (9–10 číslic) → definitivní volba pacienta → založí požadavek + • číslo kandidáta → vybere kandidáta → založí požadavek + • „ne“ → přeskočí (nezakládá) + Po založení označí původní e-mail kategorií ClaudeZpracovalRecept. + +Telegram přenos je v recept_telegram.py (token/chat dodá uživatel). +Bez odpovědi záznam zůstává 'ceka' (čeká se libovolně dlouho). + +Spuštění: python recept_resolver.py +""" +import re +import sys +import time +from pathlib import Path + +try: + sys.stdout.reconfigure(encoding="utf-8") +except Exception: + pass + +HERE = Path(__file__).resolve().parent +sys.path.insert(0, str(HERE.parent / "EmailAgent")) +sys.path.insert(0, str(HERE.parent)) + +import recept_pending as PEND # noqa: E402 +import recept_dialog as DLG # noqa: E402 +import recept_telegram as TG # noqa: E402 +import graph_mail # noqa: E402 (značení mailu) +import mcp_medevio as MED # noqa: E402 (zaloz_pozadavek_recept) +from Knihovny.mysql_db import connect_mysql # noqa: E402 + +MAILBOX = "ordinace@buzalkova.cz" +PROCESSED_CATEGORY = "ClaudeZpracovalRecept" +SINCE_FILE = HERE / "_resolver_since.txt" # poslední zpracované message_id + + +def log(msg: str) -> None: + print(f"[{time.strftime('%H:%M:%S')}] {msg}", flush=True) + + +def _norm_rc(s: str) -> str: + return re.sub(r"\D", "", s or "") + + +def najdi_pacienta_dle_rc(rc: str): + """RČ → (uuid, jmeno, prijmeni) z medevio_pacient, nebo None.""" + rc = _norm_rc(rc) + if not rc: + return None + try: + conn = connect_mysql() + conn.ping(reconnect=True) + cur = conn.cursor() + cur.execute( + "SELECT patient_id, name, surname FROM medevio_pacient " + "WHERE REPLACE(identification_number,'/','') = %s LIMIT 1", + [rc], + ) + row = cur.fetchone() + return row if row else None + except Exception as e: + log(f"[uuid lookup chyba] {type(e).__name__}: {e}") + return None + + +def _load_since(): + try: + return int(SINCE_FILE.read_text(encoding="utf-8").strip()) + except Exception: + return None + + +def _save_since(s: int) -> None: + SINCE_FILE.write_text(str(s), encoding="utf-8") + + +def posli_nove_otazky() -> None: + """Pošle otázku do Telegramu pro každý pending záznam bez otázky.""" + for rec in PEND.cekajici_bez_otazky(): + try: + mid = TG.posli_otazku(DLG.format_otazka(rec)) + PEND.aktualizuj(rec["id"], otazka_message_id=mid) + log(f"otázka odeslána (pending {rec['id'][:8]}, msg {mid})") + except Exception as e: + log(f"otázku nelze odeslat ({type(e).__name__}: {e}) — zkusím příště") + break # nejspíš výpadek / nenakonfigurováno → nech na příští kolo + + +def _zaloz_pro_rc(rec: dict, rc: str) -> None: + info = najdi_pacienta_dle_rc(rc) + if not info: + TG.posli_zpravu(f"⚠ RČ {rc} není v Medeviu — nezakládám. Zkus jiné RČ.") + return + uuid_, jmeno, prijmeni = info[0], info[1], info[2] + try: + res = MED.zaloz_pozadavek_recept( + uuid_, rec.get("leky_str", ""), rec.get("pozn_str", "") + ) + except Exception as e: + TG.posli_zpravu(f"❌ Chyba při zakládání: {type(e).__name__}: {e}") + return + PEND.aktualizuj(rec["id"], stav="zalozeno", + vysledek={"request_id": res["request_id"], "rc": rc, + "pacient": f"{prijmeni} {jmeno}"}) + try: + graph_mail.add_category(MAILBOX, rec["email_message_id"], PROCESSED_CATEGORY) + except Exception as e: + log(f"[mail označení] {type(e).__name__}: {e}") + TG.posli_zpravu(f"✅ Založeno: {prijmeni} {jmeno} (RČ {rc}) — " + f"{rec.get('leky_str', '')}") + log(f"založeno {res['request_id']} pro {prijmeni} {jmeno}") + + +def zpracuj_odpoved(u: dict) -> None: + """Zpracuje jednu příchozí Telegram zprávu.""" + rid = u.get("reply_to_message_id") + rec = PEND.najdi_dle_otazky(rid) if rid else None + + if rec is None: + cekaji = PEND.cekajici() + if len(cekaji) == 1: + rec = cekaji[0] # jediný čekající → ber to na něj + else: + if cekaji: + TG.posli_zpravu("Odpověz prosím jako reply na konkrétní dotaz " + "(čeká jich víc).") + return + + if rec.get("stav") != "ceka": + return + + d = DLG.parse_odpoved(u.get("text", "")) + if d["akce"] == "preskoc": + PEND.aktualizuj(rec["id"], stav="preskoceno") + TG.posli_zpravu("OK, nezakládám.") + elif d["akce"] == "rc": + _zaloz_pro_rc(rec, d["rc"]) + elif d["akce"] == "kandidat": + kand = rec.get("kandidati") or [] + i = d["index"] - 1 + if 0 <= i < len(kand): + _zaloz_pro_rc(rec, kand[i].get("rc", "")) + else: + TG.posli_zpravu(f"Kandidát {d['index']} neexistuje (mám {len(kand)}).") + else: + TG.posli_zpravu("Nerozumím. Pošli RČ pacienta, číslo kandidáta, nebo „ne“.") + + +def smycka(poll_s: int = 5) -> None: + try: + TG.priprav() # naprimuj entitu Vlada (jinak fresh session spadne) + except Exception as e: + log(f"[priprav] {type(e).__name__}: {e}") + since = _load_since() + if since is None: + # první start — vezmi aktuální stav jako základ, starou historii ignoruj + since = TG.baseline_since() + _save_since(since) + log(f"baseline since_id={since}") + log("Resolver běží (Ctrl+C ukončí).") + while True: + try: + posli_nove_otazky() + nove, since = TG.nacti_odpovedi(since) + for u in nove: + zpracuj_odpoved(u) + _save_since(since) + except KeyboardInterrupt: + log("Konec.") + break + except Exception as e: + log(f"[smyčka] {type(e).__name__}: {e}") + time.sleep(poll_s) + + +if __name__ == "__main__": + smycka() diff --git a/OrdinaceAgentEmail/recept_telegram.py b/OrdinaceAgentEmail/recept_telegram.py new file mode 100644 index 0000000..e945e99 --- /dev/null +++ b/OrdinaceAgentEmail/recept_telegram.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +recept_telegram.py — Telegram přenos pro recept-resolver. + +Používá USER účet agenta (Claude Buzalka @vlado_claude_agent) přes Telethon +z Knihovny/telegram_user.py — VLASTNÍ session "recepty" (per-agent autorizace). +Odpovědi se párují přes Telegram reply (reply_to_msg_id), takže víc agentů na +témž účtu se nepoplete a každý si hlídá jen své odpovědi. + +JEDNORÁZOVÝ KROK (dělá uživatel v terminálu — čeká na SMS kód): + python -m Knihovny.telegram_user login --jako recepty + +Konfigurace (Medevio/.env): TELEGRAM_API_ID, TELEGRAM_API_HASH, TELEGRAM_PHONE +(viz Trilium „2026-06-14 Telegram — bot, user účet agenta a MCP server"). +""" +import os +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) +from Knihovny.telegram_user import posli_jako_ja, precti_zpravy, _new_client # noqa: E402 + +# Vlastní session recept-agenta (jedno přihlášení: login --jako recepty). +SESSION = "recepty" + +# Komu agent píše = Vladův hlavní účet (z Trilium / TELEGRAM_CHAT_ID). +VLADO_UID = int(os.environ.get("TELEGRAM_CHAT_ID", "6639316354")) + + +def priprav() -> None: + """Načte dialogy → do session cache se uloží entita Vlada. Nová session by ji + jinak neznala a posílání by spadlo na 'Could not find the input entity'. + Volá se jednou při startu resolveru (idempotentní, levné).""" + with _new_client(SESSION) as client: + if client.is_user_authorized(): + client.get_dialogs(limit=50) + + +def posli_otazku(text: str) -> int: + """Pošle otázku Vladovi z účtu agenta. Vrátí message_id (pro párování reply).""" + msg = posli_jako_ja(VLADO_UID, text, session=SESSION) + return msg.id + + +def posli_zpravu(text: str) -> None: + """Pošle prostou zprávu (potvrzení / chybu).""" + posli_jako_ja(VLADO_UID, text, session=SESSION) + + +def baseline_since() -> int: + """Aktuální nejvyšší message_id v chatu — výchozí bod při prvním startu + (aby resolver nezpracoval starou historii).""" + zpravy = precti_zpravy(VLADO_UID, limit=1, session=SESSION) + return max((z["id"] for z in zpravy), default=0) + + +def nacti_odpovedi(since_id: int = 0, limit: int = 50): + """Vrátí (nove_odpovedi, novy_since_id). + + Bere jen PŘÍCHOZÍ zprávy (ne naše) s id > since_id. Každá: + {message_id, text, reply_to_message_id} + """ + zpravy = precti_zpravy(VLADO_UID, limit=limit, session=SESSION) + out = [] + new_since = since_id + for z in zpravy: + mid = z["id"] + if mid > new_since: + new_since = mid + if mid > since_id and not z["odeslal_ja"]: + out.append({ + "message_id": mid, + "text": z["text"], + "reply_to_message_id": z["reply_na"], + }) + out.sort(key=lambda x: x["message_id"]) # od nejstarší + return out, new_since diff --git a/OrdinaceAgentEmail/recepty_agent.py b/OrdinaceAgentEmail/recepty_agent.py index b6b43f6..309475a 100644 --- a/OrdinaceAgentEmail/recepty_agent.py +++ b/OrdinaceAgentEmail/recepty_agent.py @@ -26,7 +26,7 @@ import os import re import sys import unicodedata -from datetime import date, timedelta +from datetime import date, datetime, timedelta, timezone from pathlib import Path try: @@ -52,8 +52,11 @@ import recept_pending as _pending # noqa: E402 fronta dotazů (nejistá identi # ========================= MAILBOX = "ordinace@buzalkova.cz" -# Kolik nejnovějších mailů z Inboxu zpracovat (testovací režim). -NEWEST_N = 5 +# DELTA REŽIM: zpracují se všechny maily s receivedDateTime > vodoznak +# (čas posledního zpracovaného mailu, uložen v _last_processed.txt). Při prvním +# běhu (vodoznak chybí) se vodoznak nastaví na nejnovější mail v Inboxu a od +# příště se zpracuje jen to, co přijde POTÉ (historie se nedohání). +MAX_PER_RUN = 200 # pojistka: max mailů na jeden běh (kdyby byl vodoznak hodně zpět) # 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čí @@ -85,6 +88,7 @@ _cost = {"input_tokens": 0, "output_tokens": 0, "usd": 0.0, "calls": 0} HERE = Path(__file__).resolve().parent LOG_FILE = HERE / "_log_recepty.txt" +WATERMARK_FILE = HERE / "_last_processed.txt" # ISO čas (receivedDateTime) posledního zpracovaného mailu # ========================= @@ -112,18 +116,59 @@ def log(msg: str) -> None: # ========================= # Č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.""" +_SELECT = "id,subject,from,receivedDateTime,bodyPreview,body,categories" + + +def _load_watermark() -> tuple[str | None, str | None]: + """Vrátí (receivedDateTime, id) posledního zpracovaného mailu (řádek 1 a 2 + v _last_processed.txt). id slouží k odfiltrování hraničního mailu, který se + kvůli sub-sekundové přesnosti vrací i při filtru `gt` na oříznutý čas.""" + try: + lines = WATERMARK_FILE.read_text(encoding="utf-8").splitlines() + t = (lines[0].strip() if lines else "") or None + i = (lines[1].strip() if len(lines) > 1 else "") or None + return t, i + except Exception: + return None, None + + +def _save_watermark(iso: str, msg_id: str = "") -> None: + WATERMARK_FILE.write_text(f"{iso}\n{msg_id}\n", encoding="utf-8") + + +def newest_received(mailbox: str) -> tuple[str, str]: + """(receivedDateTime, id) nejnovějšího mailu v Inboxu — seed vodoznaku při + prvním běhu. Když je schránka prázdná, vrátí (aktuální UTC, '').""" + url = f"{graph_mail.GRAPH}/users/{mailbox}/mailFolders/inbox/messages" + params = {"$orderby": "receivedDateTime desc", "$select": "id,receivedDateTime", "$top": 1} + r = requests.get(url, headers=graph_mail._headers(), params=params, timeout=60) + r.raise_for_status() + vals = r.json().get("value", []) + if vals: + return vals[0]["receivedDateTime"], vals[0]["id"] + return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), "" + + +def nove_inbox_messages(mailbox: str, since_iso: str) -> list[dict]: + """Všechny maily z Inboxu s receivedDateTime > since_iso, od NEJSTARŠÍHO. + Stránkuje přes @odata.nextLink, max MAX_PER_RUN za jeden běh.""" url = f"{graph_mail.GRAPH}/users/{mailbox}/mailFolders/inbox/messages" params = { - "$orderby": "receivedDateTime desc", - "$select": "id,subject,from,receivedDateTime,bodyPreview,body,categories", - "$top": n, + "$filter": f"receivedDateTime gt {since_iso}", + "$orderby": "receivedDateTime asc", + "$select": _SELECT, + "$top": 50, } 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] + out: list[dict] = [] + while url and len(out) < MAX_PER_RUN: + r = requests.get(url, headers=headers, params=params, timeout=60) + r.raise_for_status() + data = r.json() + out.extend(data.get("value", [])) + url = data.get("@odata.nextLink") + params = None # nextLink už nese všechny parametry + return out[:MAX_PER_RUN] # ========================= @@ -644,7 +689,7 @@ def _medevio_find_patient(rc_normalized: str) -> str | None: # ========================= def main() -> None: log("\n" + "=" * 70) - log(f"START — schránka={MAILBOX}, {NEWEST_N} nejnovějších mailů") + log(f"START — schránka={MAILBOX}, DELTA režim (vše po posledním zpracovaném)") log(f"REŽIM: zakládá požadavky v Medeviu; zpracované maily značí štítkem " f"'{PROCESSED_CATEGORY}' (a příště přeskakuje)") @@ -655,8 +700,26 @@ def main() -> None: 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ů.") + watermark, last_id = _load_watermark() + if watermark is None: + # první běh — neznáme „poslední zpracovaný"; nastav vodoznak na nejnovější + # mail a od příště zpracovávej jen to, co přijde potom (historie se nedohání). + watermark, seed_id = newest_received(MAILBOX) + _save_watermark(watermark, seed_id) + log(f"První běh — vodoznak nastaven na {watermark}. " + f"Příští běh zpracuje maily přijaté po tomto čase.") + return + + msgs = nove_inbox_messages(MAILBOX, watermark) + # `gt` na oříznutý (sekundový) čas vrací i hraniční už zpracovaný mail + # (Graph má sub-sekundovou přesnost) → odfiltruj ho podle ID. + if last_id: + msgs = [m for m in msgs if m.get("id") != last_id] + cap = " (dosažen strop MAX_PER_RUN)" if len(msgs) >= MAX_PER_RUN else "" + log(f"Vodoznak: {watermark} → nových mailů: {len(msgs)}{cap}") + if not msgs: + log("Nic nového — končím.") + return lookup = MedicusLookup() log(f"Medicus: kartotéka načtena ({len(lookup.patients)} pacientů, " @@ -789,7 +852,9 @@ def main() -> None: log("") lookup.close() - log(f"HOTOVO: {len(msgs)} mailů, žádostí o recept: {requests_found}.") + _save_watermark(msgs[-1]["receivedDateTime"], msgs[-1].get("id", "")) # posun na nejnovější zpracovaný + log(f"HOTOVO: {len(msgs)} mailů, žádostí o recept: {requests_found}. " + f"Nový vodoznak: {msgs[-1]['receivedDateTime']}") log( f"CENA AI: {_cost['calls']} volání, " f"tokeny input={_cost['input_tokens']} output={_cost['output_tokens']}, " diff --git a/mcp_medevio.py b/mcp_medevio.py index 1a86c7f..731507a 100644 --- a/mcp_medevio.py +++ b/mcp_medevio.py @@ -30,7 +30,19 @@ from typing import Optional import requests from dateutil import parser as dtparser, tz -from mcp.server.fastmcp import FastMCP +try: + from mcp.server.fastmcp import FastMCP +except Exception: + # Fallback, když balíček 'mcp' není nainstalován (např. python-runner na + # toweru): modul lze importovat kvůli funkcím (zaloz_pozadavek_recept…), + # jen MCP server běžet nemůže. @mcp.tool() se stane no-op průchodkou. + class FastMCP: + def __init__(self, *a, **k): pass + def tool(self, *a, **k): + def deco(f): return f + return deco + def run(self, *a, **k): + raise RuntimeError("Balíček 'mcp' není nainstalován — MCP server nelze spustit.") # ── Všechny logy na stderr (stdout = JSON-RPC) ────────────────────────────── def log(msg: str): diff --git a/mcp_telegram.py b/mcp_telegram.py new file mode 100644 index 0000000..d458a04 --- /dev/null +++ b/mcp_telegram.py @@ -0,0 +1,127 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +MCP server pro Telegram — FastMCP +Spustit: python mcp_telegram.py + +Dává agentům nástroje pro komunikaci s Vladem přes Telegram. + +Nástroje: + notifikace — pošle Vladovi zprávu přes bota (oznámení, nečeká na odpověď) + zeptej_se — pošle Vladovi otázku přes bota a POČKÁ na jeho odpověď + posli_jako_agent — pošle Vladovi zprávu z účtu agenta "Claude Buzalka" (persona) + zeptej_se_jako_agent — otázka z účtu agenta + čekání na odpověď + +Dvě cesty: + • BOT (@Vlado_Claude_Bot) — bezpečné, bez rizika banu; doporučené pro notifikace/dotazy. + • USER účet (Claude Buzalka) — vystupuje jako "osoba"; má riziko banu (nový účet). + +POZOR (bot): getUpdates smí pollovat jen JEDEN proces. Když běží víc agentů, kteří +naráz čekají na odpověď přes bota, kradou si zprávy. Pro souběžné dotazy víc agentů +použij user účet (každý agent vlastní session) — viz Knihovny/telegram_user.py. +""" + +import asyncio +import sys +from pathlib import Path + +from mcp.server.fastmcp import FastMCP + +# Kořen projektu na sys.path, ať jdou importovat sdílené knihovny +BASE_DIR = Path(__file__).resolve().parent +sys.path.insert(0, str(BASE_DIR)) + +from Knihovny.telegram_notify import posli_telegram, zeptej_se_telegram +from Knihovny.telegram_user import posli_jako_ja, zeptej_se_jako + + +def log(msg: str): + print(msg, file=sys.stderr, flush=True) + + +def _v_threadu(fn, *args, **kwargs): + """Spustí synchronní (telethon.sync) funkci ve vlastním vlákně s čistou + event-loop, aby nekolidovala s běžící async smyčkou FastMCP.""" + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + try: + return fn(*args, **kwargs) + finally: + loop.close() + asyncio.set_event_loop(None) + + +# Vladův hlavní Telegram účet (user id == chat_id u bota) +VLADO_UID = 6639316354 + +mcp = FastMCP("telegram") + + +# ───────────────────────────────────────────────────────────────────────────── +# BOT — bezpečná cesta +# ───────────────────────────────────────────────────────────────────────────── +@mcp.tool() +def notifikace(text: str, tise: bool = False) -> dict: + """Pošle Vladovi oznámení přes Telegram bota (nečeká na odpověď). + + Použij pro informování o průběhu, dokončení úlohy, chybě apod. + + :param text: text zprávy + :param tise: True = tichá zpráva bez zvuku/upozornění + :return: {"ok": True} + """ + posli_telegram(text, disable_notification=tise) + return {"ok": True} + + +@mcp.tool() +def zeptej_se(otazka: str, timeout_s: int = 180) -> dict: + """Pošle Vladovi otázku přes bota a POČKÁ na jeho textovou odpověď. + + Vhodné, když agent během běhu potřebuje rozhodnutí. Blokuje až `timeout_s` sekund. + + :param otazka: text otázky (klidně s nabídkou možností, např. "(ano/ne)") + :param timeout_s: jak dlouho čekat na odpověď + :return: {"odpoved": } nebo {"odpoved": None, "timeout": True} + """ + odp = zeptej_se_telegram(otazka, timeout=timeout_s) + if odp is None: + return {"odpoved": None, "timeout": True} + return {"odpoved": odp} + + +# ───────────────────────────────────────────────────────────────────────────── +# USER účet "Claude Buzalka" — persona (riziko banu) +# ───────────────────────────────────────────────────────────────────────────── +@mcp.tool() +async def posli_jako_agent(text: str) -> dict: + """Pošle Vladovi zprávu z účtu agenta "Claude Buzalka" (ne z bota). + + :param text: text zprávy + :return: {"ok": True} + """ + await asyncio.to_thread(_v_threadu, posli_jako_ja, VLADO_UID, text) + return {"ok": True} + + +@mcp.tool() +async def zeptej_se_jako_agent(otazka: str, agent: str = "Claude", timeout_s: int = 180) -> dict: + """Pošle Vladovi otázku z účtu agenta a POČKÁ na odpověď (stačí běžná zpráva). + + :param otazka: text otázky + :param agent: jméno agenta (štítek + session); výchozí "Claude" + :param timeout_s: jak dlouho čekat + :return: {"odpoved": } nebo {"odpoved": None, "timeout": True} + """ + odp = await asyncio.to_thread( + _v_threadu, zeptej_se_jako, agent, otazka, + komu=VLADO_UID, session=None, timeout=timeout_s, vyzaduj_reply=False, + ) + if odp is None: + return {"odpoved": None, "timeout": True} + return {"odpoved": odp} + + +if __name__ == "__main__": + log("MCP Telegram server spuštěn (FastMCP)") + mcp.run()