notebookvb
This commit is contained in:
+22
-10
@@ -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
|
||||
```
|
||||
|
||||
@@ -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"}
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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']}, "
|
||||
|
||||
Reference in New Issue
Block a user