notebookvb

This commit is contained in:
Vladimir Buzalka
2026-06-14 12:07:35 +02:00
parent 9133fe9497
commit 2bdac59676
16 changed files with 1484 additions and 29 deletions
+8 -3
View File
@@ -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")
+184
View File
@@ -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")
+302
View File
@@ -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 <jmeno>` (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_<jmeno>.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()
+95
View File
@@ -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
```
+30
View File
@@ -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}}"
+62
View File
@@ -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 ==="
+92
View File
@@ -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:-<zadna>}"
# --- 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 ====="
+14
View File
@@ -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
+22 -10
View File
@@ -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
```
+81
View File
@@ -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"}
+111
View File
@@ -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
+185
View File
@@ -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Č (910 čí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()
+78
View File
@@ -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
+78 -13
View File
@@ -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"'}
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()
return r.json().get("value", [])[:n]
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']}, "
+13 -1
View File
@@ -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):
+127
View File
@@ -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": <text>} 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": <text>} 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()