528 lines
20 KiB
Python
528 lines
20 KiB
Python
"""
|
|
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 = (
|
|
"<pre style=\"font-family:Consolas,monospace;font-size:13px;"
|
|
"white-space:pre-wrap\">"
|
|
+ html.escape("\n".join(_email_lines))
|
|
+ "</pre>"
|
|
)
|
|
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()
|