""" faktury_agent.py ---------------- Agent, který ve schránce ordinace@buzalkova.cz hledá PŘIJATÉ FAKTURY a ukládá jejich PDF přílohy do Dropbox složky ke kontrole. Tok: 1. Microsoft Graph: načti nové maily s přílohou (od posledního běhu). 2. LEVNÝ PŘEDFILTR (Python, zdarma): nech jen maily, kde se slovo "faktur*" vyskytuje kdekoliv v textu (předmět/tělo) NEBO v názvu přílohy. 3. AI KLASIFIKACE (Claude, placené): jen na propuštěné maily — model rozhodne, zda jde o přijatou fakturu, a vybere správnou PDF přílohu (ne ISDOC, ne dodací list, ne VOP, ne objednávku). 4. Stáhni vybranou přílohu přes Graph a ulož do cílové složky. 5. Zapiš stav (idempotence) a log. Spouštěj opakovaně — už zpracované maily se přeskakují (state.json) a existující soubory se nepřepisují. """ import html import importlib import json import os import re import subprocess import sys from datetime import datetime, timedelta, timezone from pathlib import Path try: sys.stdout.reconfigure(encoding="utf-8") except Exception: pass def _ensure_deps(): """Doinstaluje chybějící balíčky třetích stran (běh na čistém serveru).""" needed = {"requests": "requests", "msal": "msal", "fitz": "PyMuPDF", "dropbox": "dropbox"} missing = [] for mod, pkg in needed.items(): try: importlib.import_module(mod) except ImportError: missing.append(pkg) if missing: print(f"Instaluji chybějící balíčky: {', '.join(missing)}") subprocess.check_call( [sys.executable, "-m", "pip", "install", "--quiet", *missing] ) _ensure_deps() import fitz # PyMuPDF # noqa: E402 import requests # noqa: E402 sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) import graph_mail # noqa: E402 import storage as storage_mod # noqa: E402 # ========================= # NASTAVENÍ # ========================= MAILBOX = "ordinace@buzalkova.cz" # Cílová složka pro PDF faktur. # - LOCAL backend: podsložka pod Dropbox rootem (najdi_dropbox). # - DROPBOX backend: cesta od kořene Dropboxu (Full Dropbox app). TARGET_SUBPATH = [ "Ordinace", "!!MUDr. Michaela Buzalková s.r.o", "Prosek", "#040 Faktury přijaté" ] DROPBOX_TARGET_PATH = "/" + "/".join(TARGET_SUBPATH) # Po zpracování: označit mail kategorií a přesunout do podsložky Inboxu. CATEGORY = "ClaudeProcessed" CATEGORY_COLOR = "preset4" # zelená (Outlook preset paleta) PROCESSED_FOLDER_PARTS = ["ProcessedByAgent", "Invoices"] # pod Inbox # Tuto složku při skenování přeskoč (jsou v ní už zpracované maily). SKIP_FOLDERS = {"ProcessedByAgent"} # Summary e-mail po každém běhu (přes Graph, app má Mail.Send). SUMMARY_FROM = "reports@buzalka.cz" SUMMARY_TO = "vladimir.buzalka@buzalka.cz" # Při prvním běhu (prázdný state) se prohledá posledních N dní. FIRST_RUN_DAYS = 14 # Claude model pro klasifikaci (levný, na text stačí). ANTHROPIC_MODEL = "claude-haiku-4-5" # Claude model pro návrh názvu souboru (vytěžení datumu/dodavatele/částky # z textu faktury). Lze zvednout na silnější model, pokud názvy nesedí. ANTHROPIC_NAMING_MODEL = "claude-haiku-4-5" # Předfiltr: slovo "faktur" kdekoliv (faktura, faktury, fakturace, faktuře...). FAKTUR_RE = re.compile(r"faktur", re.IGNORECASE) # Cena Claude API — USD za 1M tokenů (input, output). Kurz pro přepočet. USD_TO_CZK = 25.0 PRICING = { "claude-haiku-4-5": (1.00, 5.00), "claude-sonnet-4-6": (3.00, 15.00), "claude-opus-4-8": (5.00, 25.00), } # Akumulátor nákladů aktuálního běhu (plní _claude_json). _cost = {"input_tokens": 0, "output_tokens": 0, "usd": 0.0, "calls": 0} HERE = Path(__file__).resolve().parent STATE_FILE = HERE / "state.json" LOG_FILE = HERE / "_log_faktury.txt" # ========================= # ENV (Anthropic klíč) # ========================= def _load_env_file(env_path: Path): if env_path.exists(): for line in env_path.read_text(encoding="utf-8").splitlines(): line = line.strip() if "=" in line and not line.startswith("#"): k, v = line.split("=", 1) os.environ[k.strip()] = v.strip().strip('"').strip("'") def _load_env(): # Medevio/.env (ANTHROPIC_API_KEY) + EmailAgent/.env (DROPBOX_*, STORAGE). _load_env_file(Path(__file__).resolve().parent.parent / "Medevio" / ".env") _load_env_file(Path(__file__).resolve().parent / ".env") _load_env() # ========================= # POMOCNÉ # ========================= _email_lines = [] # řádky aktuálního běhu pro summary e-mail def _now_str(fmt: str = "%Y-%m-%d %H:%M:%S") -> str: """Aktuální čas v pražském pásmu (i když server běží v UTC).""" try: from zoneinfo import ZoneInfo dt = datetime.now(ZoneInfo("Europe/Prague")) except Exception: dt = datetime.now() # fallback: lokální čas stroje return dt.strftime(fmt) def log(msg: str) -> None: print(msg) _email_lines.append(msg) with LOG_FILE.open("a", encoding="utf-8") as f: f.write(msg + "\n") def load_state() -> dict: if STATE_FILE.exists(): return json.loads(STATE_FILE.read_text(encoding="utf-8")) return {"processed_ids": [], "last_run": None} def save_state(state: dict) -> None: STATE_FILE.write_text( json.dumps(state, ensure_ascii=False, indent=2), encoding="utf-8" ) def since_iso(state: dict) -> str: if state.get("last_run"): # malý překryv -1 den pro jistotu (idempotence to pohlídá) dt = datetime.fromisoformat(state["last_run"]) - timedelta(days=1) else: dt = datetime.now(timezone.utc) - timedelta(days=FIRST_RUN_DAYS) return dt.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") def sanitize(name: str) -> str: name = re.sub(r'[<>:"/\\|?*]', " ", name) name = re.sub(r"\s+", " ", name).strip().rstrip(" .") return name def sanitize_pdf_name(name: str) -> str: """Očistí navržený název pro Windows a zajistí příponu .pdf.""" name = sanitize(name) if not name.lower().endswith(".pdf"): name += ".pdf" return name # ========================= # PŘEDFILTR (zdarma) # ========================= def passes_prefilter(msg: dict, attachments: list[dict]) -> bool: subject = msg.get("subject") or "" body = (msg.get("body") or {}).get("content") or msg.get("bodyPreview") or "" if FAKTUR_RE.search(subject) or FAKTUR_RE.search(body): return True for a in attachments: if FAKTUR_RE.search(a.get("name") or ""): return True return False # ========================= # OVĚŘENÍ OBSAHU PDF (Python, zdarma) # ========================= def extract_pdf_text(data: bytes) -> str: """Vrátí text z PDF (prázdný řetězec, pokud nelze — sken/chyba).""" try: doc = fitz.open(stream=data, filetype="pdf") text = "".join(page.get_text() for page in doc) doc.close() return text except Exception: return "" def faktur_status(pdf_text: str) -> str: """ "ano" (text obsahuje faktur), "ne" (text bez faktur), "bez_textu" (PDF nemá extrahovatelný text — nejspíš sken). """ if not pdf_text.strip(): return "bez_textu" return "ano" if FAKTUR_RE.search(pdf_text) else "ne" # ========================= # AI KLASIFIKACE (Claude) # ========================= PROMPT = """Jsi asistent ordinace praktického lékaře. Rozhoduješ, zda e-mail obsahuje \ PŘIJATOU FAKTURU (daňový doklad k zaplacení), a pokud ano, vybíráš PDF přílohu, \ která tu fakturu obsahuje. Pravidla: - "je_faktura": true POUZE pokud jde o skutečnou přijatou fakturu / daňový doklad. - NENÍ faktura: objednávka, dodací list, předávací protokol, zálohová faktura bez \ plnění, obchodní podmínky (VOP), upomínka, newsletter, zdravotní zpráva, žádanka. - "soubor_faktury": přesný název přílohy s fakturou, která má příponu .pdf. \ Rozhoduje POUZE skutečná přípona souboru: soubor končící na ".pdf" je platný, i když \ má v názvu slovo "isdoc" (např. "FV123-isdoc.pdf" je běžné PDF faktury — vyber ho). \ NEVybírej soubory s příponou .isdoc / .xml / .png / .jpg / .zip ani VOP/obchodní podmínky. - Pokud faktura není, vrať "je_faktura": false a "soubor_faktury": null. Vrať POUZE JSON: {"je_faktura": true/false, "soubor_faktury": "nazev.pdf"|null, "duvod": "krátké zdůvodnění"} E-MAIL: Odesílatel: %(sender)s Předmět: %(subject)s Přílohy: %(attachments)s Tělo (zkráceno): %(body)s """ def _claude_json(prompt: str, model: str, max_tokens: int) -> dict: """Zavolá Claude a vrátí JSON objekt z odpovědi.""" r = requests.post( "https://api.anthropic.com/v1/messages", headers={ "x-api-key": os.environ["ANTHROPIC_API_KEY"], "anthropic-version": "2023-06-01", "content-type": "application/json", }, json={ "model": model, "max_tokens": max_tokens, "messages": [{"role": "user", "content": prompt}], }, timeout=60, ) r.raise_for_status() data = r.json() # Náklady: posbírej tokeny a přičti cenu podle modelu. usage = data.get("usage", {}) in_tok = usage.get("input_tokens", 0) out_tok = usage.get("output_tokens", 0) price_in, price_out = PRICING.get(model, PRICING["claude-haiku-4-5"]) _cost["input_tokens"] += in_tok _cost["output_tokens"] += out_tok _cost["usd"] += in_tok / 1_000_000 * price_in + out_tok / 1_000_000 * price_out _cost["calls"] += 1 text = data["content"][0]["text"].strip() m = re.search(r"\{.*\}", text, re.DOTALL) if not m: raise ValueError(f"Claude nevrátil JSON: {text}") return json.loads(m.group(0)) def classify(msg: dict, attachments: list[dict]) -> dict: sender = (msg.get("from") or {}).get("emailAddress", {}) body = (msg.get("body") or {}).get("content") or msg.get("bodyPreview") or "" prompt = PROMPT % { "sender": f"{sender.get('name','')} <{sender.get('address','')}>", "subject": msg.get("subject") or "", "attachments": ", ".join(a.get("name", "") for a in attachments) or "(žádné)", "body": body[:4000], } return _claude_json(prompt, ANTHROPIC_MODEL, 300) # ========================= # NÁVRH NÁZVU SOUBORU (Claude nad textem faktury) # ========================= # Pravidla převzatá z Faktury/FakturyRenameOpenAI.py, upravená na vstup = text. NAMING_RULES = """Jsi pomocník pro pojmenování PDF faktur a dokladů MUDr. Michaely Buzalkové. ÚKOL: Z TEXTU faktury/dokladu vytěž datum, typ dokladu, dodavatele, číslo dokladu, stručný popis, částku a měnu. Vrať POUZE JSON s polem "filename". CÍLOVÝ FORMÁT: YYYY-MM-DD Typ Dodavatel ČÍSLO [popis] [částka MĚNA].pdf PŘÍKLADY: 2026-06-01 Faktura ASKER 261103225 [kontejner Yannick 1.5 l] [339.00 CZK].pdf 2026-06-01 Faktura MEDIPOS 10195703 [CRP, kapiláry, písty, rukavice, nádoba] [5578.97 CZK].pdf 2026-05-29 Faktura Ptáček 202604570 [vakcíny Adacel, Vaqta, Havrix] [9235.20 CZK].pdf 2026-05-29 Faktura Poliklinika Prosek 91260763 [lékárna] [16165.40 CZK].pdf 2026-06-01 Dodací list QuickSeal 200609058 [VivaDiag Hydroxyvitamin D3] [2620.00 CZK].pdf DŮLEŽITÁ PRAVIDLA: 1. Prefix [POHODA] nikdy nepřidávej. 2. Používej datum vystavení dokladu, ne datum splatnosti. 3. Typ dokladu vyber podle dokumentu: Faktura, Dobropis, Paragon, Dodací list, Zálohová faktura, Smlouva, Platba, Poplatek, Výdajový pokladní doklad. 4. Pokud je v dokumentu "Dodací list není daňový doklad - nehraďte", typ je "Dodací list", ne "Faktura". 5. Dodavatel zapisuj krátce a konzistentně: MEDIPOS, MEDEVIO, MEDATRON, ASKER, QuickSeal, Poliklinika Prosek, Alza, Microsoft, OpenAI, Ptáček. 6. SPECIÁLNÍ PRAVIDLO: pokud je dodavatel "Distribuce CZ", použij dodavatele "Ptáček". 7. SPECIÁLNÍ PRAVIDLO: u faktur MEDIPOS použij jako číslo variabilní symbol nebo hlavní číslo faktury bez mezer (např. 10195703), ne interní evidenční číslo typu FV-5703/2026. 8. Částku piš vždy s desetinnou tečkou a měnou (např. [5578.97 CZK]). 9. Když je částka v Kč, měna je CZK. 10. Popis drž krátký, praktický a česky, v hranatých závorkách. 11. Nepoužívej dvojtečky, lomítka, uvozovky ani znaky nevhodné pro Windows názvy. 12. Pokud si nejsi jistý popisem, použij obecný popis typu [materiál do ordinace], [lékárna], [vakcíny], [testy]. 13. Výstup musí být POUZE validní JSON, nic jiného. JSON FORMÁT: {"filename": "YYYY-MM-DD Faktura Dodavatel 123456 [popis] [123.45 CZK].pdf"} TEXT FAKTURY: %(text)s """ def propose_filename(pdf_text: str) -> str | None: """Navrhne název souboru podle textu faktury. None při selhání/prázdném.""" prompt = NAMING_RULES % {"text": pdf_text[:15000]} obj = _claude_json(prompt, ANTHROPIC_NAMING_MODEL, 300) filename = (obj.get("filename") or "").strip() return sanitize_pdf_name(filename) if filename else None # ========================= # HLAVNÍ BĚH # ========================= def main() -> None: # Úložiště: STORAGE=dropbox -> Dropbox API, jinak lokální Dropbox mount. use_dropbox = os.getenv("STORAGE", "local").lower() == "dropbox" if use_dropbox: local_dir = None else: # najdi_dropbox potřebujeme jen lokálně (na serveru Knihovny nejsou). from Knihovny.najdi_dropbox import get_dropbox_root local_dir = Path(get_dropbox_root(), *TARGET_SUBPATH) storage = storage_mod.get_storage(local_dir, DROPBOX_TARGET_PATH) # Otisky souborů už v cíli -> dedup podle obsahu, ne názvu. Faktur je málo. existing_hashes = storage.load_hashes() state = load_state() processed = set(state.get("processed_ids", [])) since = since_iso(state) # Příprava cílové kategorie a složky pro přesun (idempotentní). graph_mail.ensure_category(MAILBOX, CATEGORY, CATEGORY_COLOR) processed_folder_id = graph_mail.ensure_folder_path(MAILBOX, PROCESSED_FOLDER_PARTS) def finalize(message_id: str, subject: str) -> None: """Označ mail kategorií a přesuň do složky zpracovaných.""" try: graph_mail.add_category(MAILBOX, message_id, CATEGORY) graph_mail.move_message(MAILBOX, message_id, processed_folder_id) except Exception as e: log(f" [KATEGORIE/PŘESUN CHYBA] {subject!r}: {e}") log("\n" + "=" * 70) log(f"START {_now_str()} schránka={MAILBOX}") log(f"Cíl: {storage.describe()}") log(f"Hledám maily od: {since}") saved = scanned = prefiltered = invoices = 0 for folder_id, folder_name in graph_mail.inbox_folder_ids(MAILBOX): if folder_name in SKIP_FOLDERS: continue for msg in graph_mail.list_messages(MAILBOX, folder_id, since): mid = msg["id"] if mid in processed: continue scanned += 1 atts = graph_mail.list_attachments(MAILBOX, mid) if not passes_prefilter(msg, atts): processed.add(mid) # nezajímavé, už neřeš continue prefiltered += 1 subj = (msg.get("subject") or "")[:60] try: verdict = classify(msg, atts) except Exception as e: log(f" [AI CHYBA] {subj!r}: {e}") continue # zkusíme příště if not verdict.get("je_faktura"): log(f" [NE] {subj!r} — {verdict.get('duvod','')}") processed.add(mid) continue invoices += 1 want = verdict.get("soubor_faktury") chosen = next((a for a in atts if a.get("name") == want), None) if chosen is None: # fallback: první PDF příloha chosen = next( (a for a in atts if (a.get("name") or "").lower().endswith(".pdf")), None, ) if chosen is None: log(f" [FAKTURA bez PDF] {subj!r} — přílohy: {[a.get('name') for a in atts]}") continue try: data = graph_mail.download_attachment(MAILBOX, mid, chosen["id"]) except Exception as e: log(f" [DOWNLOAD CHYBA] {subj!r}: {e}") continue # Dedup podle OBSAHU napříč složkou (ne podle názvu — AI název se # může lehce lišit). Kontrola hned po stažení -> u duplikátu ušetří # AI volání za pojmenování i extrakci textu. digest = storage.hash_bytes(data) if digest in existing_hashes: log(f" [DUPLIKÁT] obsah už ve složce je, přeskakuji ('{chosen['name']}')") processed.add(mid) finalize(mid, subj) # i duplikát je zpracovaná faktura -> ukliď z Inboxu continue # Ověření obsahu PDF: musí v textu obsahovat slovo "faktur". pdf_text = extract_pdf_text(data) check = faktur_status(pdf_text) if check == "ne": # AI mail označila za fakturu, ale text PDF slovo "faktur" # neobsahuje -> nejspíš špatně vybraná příloha. Neukládám. log(f" [PDF NEPOTVRZENO] {subj!r} — '{chosen['name']}' " f"text neobsahuje 'faktur', přeskakuji") continue if check == "bez_textu": log(f" [PDF BEZ TEXTU] {subj!r} — '{chosen['name']}' " f"(sken?) ukládám i tak, ověř ručně") # Návrh názvu podle obsahu faktury (jen pokud máme text). out_name = sanitize(chosen["name"]) if pdf_text.strip(): try: proposed = propose_filename(pdf_text) if proposed: out_name = proposed except Exception as e: log(f" [POJMENOVÁNÍ CHYBA] {subj!r}: {e} — původní název") # Nový obsah. Backend vyřeší kolizi názvu (lokálně "(2)", Dropbox autorename). saved_name = storage.save(out_name, data) existing_hashes.add(digest) saved += 1 processed.add(mid) log(f" [ULOŽENO] {saved_name} <- {subj!r}") finalize(mid, subj) # označ kategorií + přesuň z Inboxu state["processed_ids"] = sorted(processed) state["last_run"] = datetime.now(timezone.utc).isoformat() save_state(state) log( f"HOTOVO: prošlo {scanned} mailů, předfiltrem {prefiltered}, " f"faktur {invoices}, uloženo {saved} souborů." ) log( f"CENA AI: {_cost['calls']} volání, " f"tokeny input={_cost['input_tokens']} output={_cost['output_tokens']}, " f"${_cost['usd']:.4f} ≈ {_cost['usd'] * USD_TO_CZK:.2f} Kč " f"(kurz 1 USD = {USD_TO_CZK:.0f} Kč)" ) send_summary(saved, invoices) def send_summary(saved: int, invoices: int) -> None: """Po každém běhu pošle summary e-mail z reports@buzalka.cz.""" subject = ( f"Faktury agent — uloženo {saved}, faktur {invoices} " f"({_now_str('%Y-%m-%d %H:%M')})" ) body = ( "
"
        + html.escape("\n".join(_email_lines))
        + "
" ) try: graph_mail.send_mail(SUMMARY_FROM, SUMMARY_TO, subject, body) print(f"Summary odeslán na {SUMMARY_TO}") except Exception as e: print(f"[SUMMARY EMAIL CHYBA] {type(e).__name__}: {e}") if __name__ == "__main__": main()