From a7f33afb66a2670cd4eb894d6dc83a8d17877ece Mon Sep 17 00:00:00 2001 From: Vladimir Buzalka Date: Wed, 10 Jun 2026 08:53:01 +0200 Subject: [PATCH] notebookvb --- .gitignore | 4 + EmailAgent/NOTES.md | 156 +++++++ EmailAgent/TODO.md | 23 ++ EmailAgent/_log_faktury.txt | 135 ++++++ EmailAgent/faktury_agent.py | 527 ++++++++++++++++++++++++ EmailAgent/graph_mail.py | 198 +++++++++ EmailAgent/state.json | 16 + EmailAgent/storage.py | 128 ++++++ Medicus/VerifyPřílohy/export_prilohy.py | 106 +++++ 9 files changed, 1293 insertions(+) create mode 100644 EmailAgent/NOTES.md create mode 100644 EmailAgent/TODO.md create mode 100644 EmailAgent/_log_faktury.txt create mode 100644 EmailAgent/faktury_agent.py create mode 100644 EmailAgent/graph_mail.py create mode 100644 EmailAgent/state.json create mode 100644 EmailAgent/storage.py create mode 100644 Medicus/VerifyPřílohy/export_prilohy.py diff --git a/.gitignore b/.gitignore index 188d3df..69232c4 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,10 @@ __pycache__/ .claude/worktrees/ .claude/settings.local.json +# Secrets (.env s API klíči - nikdy do gitu!) +.env +**/.env + # Certifikáty (soukromé klíče - nikdy do gitu!) **/*.pfx **/*.p12 diff --git a/EmailAgent/NOTES.md b/EmailAgent/NOTES.md new file mode 100644 index 0000000..8e4260e --- /dev/null +++ b/EmailAgent/NOTES.md @@ -0,0 +1,156 @@ +# EmailAgent — agent na přijaté faktury + +Hlídá schránku **ordinace@buzalkova.cz** a ukládá PDF přílohy přijatých faktur. + +## Ochrana proti duplicitám (2 vrstvy) + +1. **`state.json`** — `message_id` zpracovaných mailů. Stejný mail se podruhé + vůbec nestahuje (hlavní pojistka). +2. **sha256 obsahu** — při startu se načtou hashe všech PDF v cílové složce; + stažená příloha se porovná hned po stažení. Když je **stejný obsah** už ve + složce (i pod jiným názvem), soubor se přeskočí (`[DUPLIKÁT]`) a ušetří se + i AI volání za pojmenování. Řeší případ, kdy se `state.json` ztratí nebo + dodavatel pošle fakturu dvakrát. Porovnává se obsah, ne název — AI název se + mezi běhy může v části `[popis]` lehce lišit. + +## Nasazení na unraid (Tower, 24/7) + +Cílový adresář: `/mnt/user/Scripts/StahovaniFaktur/` (= `/scripts/StahovaniFaktur/` +v kontejneru `python-runner`). Běh: `docker exec python-runner python3 +/scripts/StahovaniFaktur/faktury_agent.py`. + +- `.env` na serveru má `STORAGE=dropbox` + `ANTHROPIC_API_KEY` + `DROPBOX_*` + (na serveru není `Medevio/.env` ani Dropbox mount). +- Závislosti v kontejneru: `dropbox msal pymupdf requests` (`_ensure_deps()` je + doinstaluje i samo). +- **Plánování** přes unraid **User Scripts plugin** (ne cron v dockeru): + - wrapper `/boot/config/plugins/user.scripts/scripts/StahovaniFaktur/script` + (`flock` + `docker exec`, loguje do `/mnt/user/Scripts/logs/stahovani_faktur.log`), + - rozvrh `0 6,18 * * *` v `schedule.json` → `customSchedule.cron` → `update_cron` + → `/etc/cron.d/root`. + - Pozn.: `/boot` je FAT32, skript NELZE spustit přímým execem — plugin ho pouští + přes `startCustom.php` (`bash`), proto to funguje. +- **Pozor na dvojí běh:** spouštět jen ze serveru, ne zároveň lokálně (oddělené + `state.json`). + +## Spuštění + +```powershell +cd U:\OrdinaceProjekt\EmailAgent +python faktury_agent.py +``` + +Skript je **idempotentní** — opakované spuštění nestáhne nic dvakrát +(viz `state.json`). Lze přidat do Windows Task Scheduleru. + +## Co dělá (tok) + +1. **Graph API** (`graph_mail.py`) — načte nové maily s přílohou ze složek + `Inbox` + přímé podsložky (vynechává Junk/Deleted/Sent/Drafts), + od posledního běhu (`state.json` → `last_run`, s překryvem −1 den). +2. **Levný předfiltr (Python, zdarma)** — propustí jen maily, kde se slovo + `faktur*` vyskytuje v předmětu, těle, nebo v názvu přílohy. + *Maily, které neprojdou, se rovnou označí jako zpracované.* +3. **AI klasifikace (Claude, placené)** — jen na propuštěné maily. Model + `claude-haiku-4-5` rozhodne `je_faktura: true/false` a vybere správnou + **.pdf** přílohu (ne ISDOC/XML/obrázek/VOP/dodací list/objednávku). +4. **Stažení** vybraného PDF přes Graph. +5. **Ověření obsahu PDF (Python, zdarma)** — přečte text PDF (`fitz`/PyMuPDF) + a hledá slovo `faktur*`: + - `ano` → potvrzeno, uloží se. + - `ne` → text fakturu neobsahuje (AI nejspíš vybrala špatnou přílohu) → + **neukládá se**, jen zaloguje `[PDF NEPOTVRZENO]`. + - `bez_textu` → PDF nemá textovou vrstvu (skenovaná faktura) → uloží se, + ale zaloguje `[PDF BEZ TEXTU]` k ruční kontrole. +6. **Návrh názvu (Claude nad textem PDF)** — `propose_filename()` z textu faktury + vytěží datum/typ/dodavatele/číslo/popis/částku a vrátí jednotný název + `YYYY-MM-DD Typ Dodavatel ČÍSLO [popis] [částka MĚNA].pdf`. Pravidla převzata + z `Faktury/FakturyRenameOpenAI.py` (např. Distribuce CZ → Ptáček). Při chybě + nebo u skenu bez textu se použije původní název přílohy. +7. **Uložení** pod navrženým názvem. Konflikty řeší přípona ` (2)`, ` (3)` … +8. **Kategorie + přesun (Graph, destruktivní)** — mail se označí kategorií + `ClaudeProcessed` (zelená) a přesune do `Inbox/ProcessedByAgent/Invoices`. + Děje se i u duplikátu (úklid Inboxu). Vyžaduje **Mail.ReadWrite**. +9. Zápis `state.json` + log `_log_faktury.txt`. +10. **Summary e-mail** — po každém běhu se z `SUMMARY_FROM` (reports@buzalka.cz) + pošle souhrn na `SUMMARY_TO` (vladimir.buzalka@buzalka.cz) přes Graph + (`graph_mail.send_mail`, vyžaduje **Mail.Send**). Tělo = log běhu. + +Na konci běhu skript vypíše **cenu AI** za běh — počet volání, tokeny a částku +v USD i Kč (kurz `USD_TO_CZK`, ceník modelů v `PRICING`). Orientačně ~0,1 Kč +na fakturu (klasifikace + pojmenování). + +## Konfigurace (`faktury_agent.py`, sekce NASTAVENÍ) + +| Konstanta | Význam | +|-----------|--------| +| `MAILBOX` | sledovaná schránka | +| `TARGET_SUBPATH` | podsložka v Dropboxu — root přes `Knihovny/najdi_dropbox.py` | +| `FIRST_RUN_DAYS` | kolik dní dozadu při prvním běhu (prázdný state) | +| `ANTHROPIC_MODEL` | model pro klasifikaci faktura ano/ne | +| `ANTHROPIC_NAMING_MODEL` | model pro návrh názvu souboru | +| `FAKTUR_RE` | regex předfiltru (`faktur`) | +| `CATEGORY` / `CATEGORY_COLOR` | kategorie přidaná zpracovanému mailu + barva | +| `PROCESSED_FOLDER_PARTS` | cílová podsložka přesunu (pod Inbox) | +| `SKIP_FOLDERS` | složky vynechané při skenování | + +## Úložiště — lokální vs. Dropbox API (`storage.py`) + +Cíl je stejná složka, ale dvěma cestami podle prostředí (`STORAGE`): + +| `STORAGE` | Backend | Kdy | +|-----------|---------|-----| +| `local` (default) | `LocalStorage` — filesystem přes `najdi_dropbox.get_dropbox_root()` + `TARGET_SUBPATH` | Windows (je tu Dropbox mount) | +| `dropbox` | `DropboxStorage` — Dropbox HTTP API, cesta `DROPBOX_TARGET_PATH` | unraid/server (bez Dropbox mountu) | + +Společné rozhraní: `load_hashes()`, `hash_bytes(data)`, `save(name, data)`, `describe()`. +Dedup: lokálně **sha256** obsahu, přes API **Dropbox content_hash** (blokový sha256 +z metadat — nestahuje soubory). Listing v Dropboxu **stránkuje**. + +**Cílová složka:** +`\Ordinace\!!MUDr. Michaela Buzalková s.r.o\Prosek\#040 Faktury přijaté\` +(Dropbox API: `/Ordinace/.../#040 Faktury přijaté`, Full Dropbox app). + +**Po zpracování (destruktivní):** `CATEGORY` (= `ClaudeProcessed`, barva +`CATEGORY_COLOR`) + přesun do `Inbox/` + `PROCESSED_FOLDER_PARTS` +(= `ProcessedByAgent/Invoices`). Tato složka se při skenování přeskakuje +(`SKIP_FOLDERS`). + +## Autentizace + +- **Microsoft Graph** — app registrace (application permissions) sdílená s + `Knihovny/EmailMessagingGraph.py`. Vyžaduje granty **Mail.Read** (čtení/přílohy) + a **Mail.ReadWrite** (kategorie + přesun mailů) a **Mail.Send** (summary e-mail). + Credentials jsou natvrdo v `graph_mail.py` (tenant/client/secret). +- **Anthropic** — klíč `ANTHROPIC_API_KEY` z `Medevio/.env`. +- **Dropbox API** (jen `STORAGE=dropbox`) — `DROPBOX_APP_KEY` / `DROPBOX_APP_SECRET` + / `DROPBOX_APP_REFRESH_TOKEN` z `EmailAgent/.env` (gitignored). Full Dropbox app, + účet vladimir.buzalka@buzalka.cz. Refresh token = trvalý, access token se obnovuje + sám. + +## Závislosti + +`msal`, `requests`, `fitz` (PyMuPDF), a pro `STORAGE=dropbox` navíc **`dropbox`** +(`pip install dropbox`). Na unraid/python-runner je nutné `dropbox` doinstalovat. + +## Soubory + +| Soubor | Popis | +|--------|-------| +| `faktury_agent.py` | hlavní skript | +| `graph_mail.py` | vrstva nad Graphem (čtení/zápis zpráv, stahování příloh) | +| `storage.py` | úložiště faktur — `LocalStorage` / `DropboxStorage` | +| `.env` | Dropbox credentials + volitelně `STORAGE` (gitignored) | +| `state.json` | ID zpracovaných mailů + `last_run` | +| `_log_faktury.txt` | log běhů | + +## Poznámky / TODO + +- **Destruktivní** — zpracovaný mail se kategorizuje a přesouvá z Inboxu. + Maily se nemažou. Přesun mění `message_id`; idempotenci hlídá `state.json` + (původní id) i to, že přesunuté maily jsou v nesnímané podsložce. +- Maily, které předfiltr **nepropustí**, se uloží do `processed_ids` jako + vyřízené — když se prompt/pravidla později změní, znovu se nepřehodnotí. + Pro re-test smaž příslušné ID ze `state.json`. +- Graph nezvládne `$orderby` spolu s filtrem `hasAttachments` (`InefficientFilter`) + — proto se na serveru neřadí. diff --git a/EmailAgent/TODO.md b/EmailAgent/TODO.md new file mode 100644 index 0000000..b78c07f --- /dev/null +++ b/EmailAgent/TODO.md @@ -0,0 +1,23 @@ +# EmailAgent — TODO + +## Plánované + +- [ ] **OCR nad skeny** — faktury bez textové vrstvy (skenovaná PDF) dnes + `pdf_faktur_check()` vrací `bez_textu` a soubor se uloží jen s varováním + `[PDF BEZ TEXTU]`, bez ověření obsahu. Doplnit OCR (např. Tesseract / + `ocrmypdf`, nebo render stránky přes `fitz` → OCR), aby se i u skenů ověřilo + slovo `faktur*` a případně vytěžil text pro klasifikaci. + +## Hotovo + +- [x] Cílová složka přepnuta na ostrou `#040 Faktury přijaté`. +- [x] Po zpracování: kategorie `ClaudeProcessed` + přesun do + `Inbox/ProcessedByAgent/Invoices` (vyžaduje Mail.ReadWrite). + +## Možná rozšíření (až se výsledky odladí) + +- [ ] Plánované spouštění přes Windows Task Scheduler. +- [ ] Přísnější režim: ukládat jen když text PDF potvrdí `faktur` (tvrdá brána) + — možné až po doplnění OCR, jinak hrozí ztráta skenovaných faktur. +- [ ] Zvážit dedup i proti podsložce `NamedInvoicesbyOpenAI` v cílové složce + (dnes se hashuje jen top-level `*.pdf`). diff --git a/EmailAgent/_log_faktury.txt b/EmailAgent/_log_faktury.txt new file mode 100644 index 0000000..8aa49cb --- /dev/null +++ b/EmailAgent/_log_faktury.txt @@ -0,0 +1,135 @@ + +====================================================================== +START 2026-06-10 06:42:23 schránka=ordinace@buzalkova.cz +Cíl: U:\Dropbox\!!!Days\Downloads Z230\Faktury +Hledám maily od: 2026-05-27T04:42:23Z + +====================================================================== +START 2026-06-10 06:42:57 schránka=ordinace@buzalkova.cz +Cíl: U:\Dropbox\!!!Days\Downloads Z230\Faktury +Hledám maily od: 2026-05-27T04:42:57Z + +====================================================================== +START 2026-06-10 06:43:13 schránka=ordinace@buzalkova.cz +Cíl: U:\Dropbox\!!!Days\Downloads Z230\Faktury +Hledám maily od: 2026-05-27T04:43:13Z + +====================================================================== +START 2026-06-10 06:43:51 schránka=ordinace@buzalkova.cz +Cíl: U:\Dropbox\!!!Days\Downloads Z230\Faktury +Hledám maily od: 2026-05-27T04:43:51Z + [ULOŽENO] Faktura - Daňový doklad číslo_ 202604570 .pdf <- 'Faktura' + [ULOŽENO] Faktura_261103225.pdf <- 'Faktura 261103225' + [ULOŽENO] Faktura č.110606255.pdf <- 'Faktura č. :110606255' + [NE] 'Faktura vydaná č. 96260214' — Jediná dostupná PDF příloha je FV96260214-isdoc.pdf, což je formát *.isdoc (strukturovaný dokument), nikoliv čitelné PDF. Pravidla preferují lidsky čitelné PDF. Zbývající příloha image001.png je obrázek. +HOTOVO: prošlo 15 mailů, předfiltrem 4, faktur 3, uloženo 3 souborů. + +====================================================================== +START 2026-06-10 06:44:54 schránka=ordinace@buzalkova.cz +Cíl: U:\Dropbox\!!!Days\Downloads Z230\Faktury +Hledám maily od: 2026-06-09T04:44:28Z +HOTOVO: prošlo 0 mailů, předfiltrem 0, faktur 0, uloženo 0 souborů. + +====================================================================== +START 2026-06-10 06:49:23 schránka=ordinace@buzalkova.cz +Cíl: U:\Dropbox\!!!Days\Downloads Z230\Faktury +Hledám maily od: 2026-05-27T04:49:23Z + [ULOŽENO] Faktura - Daňový doklad číslo_ 202604570 .pdf <- 'Faktura' + [ULOŽENO] Faktura_261103225.pdf <- 'Faktura 261103225' + [ULOŽENO] Faktura č.110606255.pdf <- 'Faktura č. :110606255' + [ULOŽENO] FV96260214-isdoc.pdf <- 'Faktura vydaná č. 96260214' +HOTOVO: prošlo 15 mailů, předfiltrem 4, faktur 4, uloženo 4 souborů. + +====================================================================== +START 2026-06-10 06:54:36 schránka=ordinace@buzalkova.cz +Cíl: U:\Dropbox\!!!Days\Downloads Z230\Faktury +Hledám maily od: 2026-05-27T04:54:36Z + [ULOŽENO] 2026-05-29 Faktura Ptáček 202604570 [vakcíny Adacel, Vaqta, Havrix] [9235.20 CZK].pdf <- 'Faktura' + [ULOŽENO] 2026-06-01 Faktura ASKER 261103225 [kontejner Yannick 1.5 l] [339.00 CZK].pdf <- 'Faktura 261103225' + [ULOŽENO] 2026-06-01 Faktura QuickSeal 110606255 [VivaDiag Hydroxyvitamin D3] [2620.00 CZK].pdf <- 'Faktura č. :110606255' + [ULOŽENO] 2026-06-01 Faktura Poliklinika Prosek 96260214 [nájemné a služby] [28363.00 CZK].pdf <- 'Faktura vydaná č. 96260214' +HOTOVO: prošlo 15 mailů, předfiltrem 4, faktur 4, uloženo 4 souborů. + +====================================================================== +START 2026-06-10 06:57:14 schránka=ordinace@buzalkova.cz +Cíl: U:\Dropbox\!!!Days\Downloads Z230\Faktury +Hledám maily od: 2026-05-27T04:57:14Z + [ULOŽENO] 2026-05-29 Faktura Ptáček 202604570 [vakcíny Adacel, Vaqta, Havrix] [9235.20 CZK].pdf <- 'Faktura' + [ULOŽENO] 2026-06-01 Faktura ASKER 261103225 [kontejner Yannick 1.5 l] [339.00 CZK].pdf <- 'Faktura 261103225' + [ULOŽENO] 2026-06-01 Faktura QuickSeal 110606255 [VivaDiag Hydroxyvitamin D3] [2620.00 CZK].pdf <- 'Faktura č. :110606255' + [ULOŽENO] 2026-06-01 Faktura Poliklinika Prosek 96260214 [nájemné a služby] [28363.00 CZK].pdf <- 'Faktura vydaná č. 96260214' +HOTOVO: prošlo 15 mailů, předfiltrem 4, faktur 4, uloženo 4 souborů. +CENA AI: 8 volání, tokeny input=10087 output=738, $0.0138 ≈ 0.34 Kč (kurz 1 USD = 25 Kč) + +====================================================================== +START 2026-06-10 06:59:54 schránka=ordinace@buzalkova.cz +Cíl: U:\Dropbox\!!!Days\Downloads Z230\Faktury +Hledám maily od: 2026-05-27T04:59:54Z + [DUPLIKÁT] 2026-05-29 Faktura Ptáček 202604570 [vakcíny Adacel, Vaqta, Havrix] [9235.20 CZK].pdf už existuje (shodný obsah), přeskakuji + [DUPLIKÁT] 2026-06-01 Faktura ASKER 261103225 [kontejner Yannick 1.5 l] [339.00 CZK].pdf už existuje (shodný obsah), přeskakuji + [ULOŽENO] 2026-06-01 Faktura QuickSeal 110606255 [VivaDiag Hydroxyvitamin D3 testy, poštovné] [2620.00 CZK].pdf <- 'Faktura č. :110606255' + [DUPLIKÁT] 2026-06-01 Faktura Poliklinika Prosek 96260214 [nájemné a služby] [28363.00 CZK].pdf už existuje (shodný obsah), přeskakuji +HOTOVO: prošlo 15 mailů, předfiltrem 4, faktur 4, uloženo 1 souborů. +CENA AI: 8 volání, tokeny input=10087 output=766, $0.0139 ≈ 0.35 Kč (kurz 1 USD = 25 Kč) + +====================================================================== +START 2026-06-10 07:02:33 schránka=ordinace@buzalkova.cz +Cíl: U:\Dropbox\!!!Days\Downloads Z230\Faktury +Hledám maily od: 2026-05-27T05:02:33Z + [DUPLIKÁT] obsah už ve složce je, přeskakuji (2026-05-29 Faktura Ptáček 202604570 [vakcíny Adacel, Vaqta, Havrix] [9235.20 CZK].pdf) + [DUPLIKÁT] obsah už ve složce je, přeskakuji (2026-06-01 Faktura ASKER 261103225 [kontejner Yannick 1.5 l] [339.00 CZK].pdf) + [DUPLIKÁT] obsah už ve složce je, přeskakuji (2026-06-01 Faktura QuickSeal 110606255 [VivaDiag Hydroxyvitamin D3 testy, poštovné] [2620.00 CZK].pdf) + [DUPLIKÁT] obsah už ve složce je, přeskakuji (2026-06-01 Faktura Poliklinika Prosek 96260214 [nájemné a služby] [28363.00 CZK].pdf) +HOTOVO: prošlo 15 mailů, předfiltrem 4, faktur 4, uloženo 0 souborů. +CENA AI: 8 volání, tokeny input=10087 output=663, $0.0134 ≈ 0.34 Kč (kurz 1 USD = 25 Kč) + +====================================================================== +START 2026-06-10 07:04:11 schránka=ordinace@buzalkova.cz +Cíl: U:\Dropbox\!!!Days\Downloads Z230\Faktury +Hledám maily od: 2026-05-27T05:04:11Z + [DUPLIKÁT] obsah už ve složce je, přeskakuji ('Faktura - Daňový doklad číslo_ 202604570 .pdf') + [DUPLIKÁT] obsah už ve složce je, přeskakuji ('Faktura_261103225.pdf') + [DUPLIKÁT] obsah už ve složce je, přeskakuji ('Faktura č.110606255.pdf') + [DUPLIKÁT] obsah už ve složce je, přeskakuji ('FV96260214-isdoc.pdf') +HOTOVO: prošlo 15 mailů, předfiltrem 4, faktur 4, uloženo 0 souborů. +CENA AI: 4 volání, tokeny input=2662 output=513, $0.0052 ≈ 0.13 Kč (kurz 1 USD = 25 Kč) + +====================================================================== +START 2026-06-10 07:18:23 schránka=ordinace@buzalkova.cz +Cíl: U:\Dropbox\Ordinace\!!MUDr. Michaela Buzalková s.r.o\Prosek\#040 Faktury přijaté +Hledám maily od: 2026-05-27T05:18:20Z + [ULOŽENO] 2026-05-29 Faktura Ptáček 202604570 [vakcíny Adacel, Vaqta, Havrix] [9235.20 CZK].pdf <- 'Faktura' + [ULOŽENO] 2026-06-01 Faktura ASKER 261103225 [kontejner Yannick 1.5 l] [339.00 CZK].pdf <- 'Faktura 261103225' + [ULOŽENO] 2026-06-01 Faktura QuickSeal 110606255 [VivaDiag Hydroxyvitamin D3 testy, poštovné] [2620.00 CZK].pdf <- 'Faktura č. :110606255' + [ULOŽENO] 2026-06-10 Faktura Ptáček 202604906 [vakcína ADACEL] [1214.08 CZK].pdf <- 'Faktura' + [ULOŽENO] 2026-06-01 Faktura Poliklinika Prosek 96260214 [nájemné a služby] [28363.00 CZK].pdf <- 'Faktura vydaná č. 96260214' +HOTOVO: prošlo 16 mailů, předfiltrem 5, faktur 5, uloženo 5 souborů. +CENA AI: 10 volání, tokeny input=12362 output=883, $0.0168 ≈ 0.42 Kč (kurz 1 USD = 25 Kč) + +====================================================================== +START 2026-06-10 08:14:11 schránka=ordinace@buzalkova.cz +Cíl: U:\Dropbox\Ordinace\!!MUDr. Michaela Buzalková s.r.o\Prosek\#040 Faktury přijaté +Hledám maily od: 2026-05-27T06:14:08Z +HOTOVO: prošlo 11 mailů, předfiltrem 0, faktur 0, uloženo 0 souborů. +CENA AI: 0 volání, tokeny input=0 output=0, $0.0000 ≈ 0.00 Kč (kurz 1 USD = 25 Kč) + +====================================================================== +START 2026-06-10 08:14:37 schránka=ordinace@buzalkova.cz +Cíl: Dropbox:/Ordinace/!!MUDr. Michaela Buzalková s.r.o/Prosek/#040 Faktury přijaté +Hledám maily od: 2026-05-27T06:14:35Z +HOTOVO: prošlo 11 mailů, předfiltrem 0, faktur 0, uloženo 0 souborů. +CENA AI: 0 volání, tokeny input=0 output=0, $0.0000 ≈ 0.00 Kč (kurz 1 USD = 25 Kč) + +====================================================================== +START 2026-06-10 08:18:18 schránka=ordinace@buzalkova.cz +Cíl: U:\Dropbox\Ordinace\!!MUDr. Michaela Buzalková s.r.o\Prosek\#040 Faktury přijaté +Hledám maily od: 2026-05-27T06:18:15Z +HOTOVO: prošlo 11 mailů, předfiltrem 0, faktur 0, uloženo 0 souborů. +CENA AI: 0 volání, tokeny input=0 output=0, $0.0000 ≈ 0.00 Kč (kurz 1 USD = 25 Kč) + +====================================================================== +START 2026-06-10 08:30:02 schránka=ordinace@buzalkova.cz +Cíl: U:\Dropbox\Ordinace\!!MUDr. Michaela Buzalková s.r.o\Prosek\#040 Faktury přijaté +Hledám maily od: 2026-06-09T06:18:26Z +HOTOVO: prošlo 0 mailů, předfiltrem 0, faktur 0, uloženo 0 souborů. +CENA AI: 0 volání, tokeny input=0 output=0, $0.0000 ≈ 0.00 Kč (kurz 1 USD = 25 Kč) diff --git a/EmailAgent/faktury_agent.py b/EmailAgent/faktury_agent.py new file mode 100644 index 0000000..b249cce --- /dev/null +++ b/EmailAgent/faktury_agent.py @@ -0,0 +1,527 @@ +""" +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() diff --git a/EmailAgent/graph_mail.py b/EmailAgent/graph_mail.py new file mode 100644 index 0000000..0caf206 --- /dev/null +++ b/EmailAgent/graph_mail.py @@ -0,0 +1,198 @@ +""" +graph_mail.py +------------- +Tenká vrstva nad Microsoft Graph API pro ČTENÍ schránky a STAHOVÁNÍ příloh. + +Používá stejnou app registraci (application permissions) jako +Knihovny/EmailMessagingGraph.py. Pro čtení cizí schránky a příloh musí mít +ta app registrace grant **Mail.Read** (Application). Pokud chybí, Graph vrátí +403 a je potřeba oprávnění doplnit v Azure portálu. +""" + +import base64 +import msal +import requests +from functools import lru_cache +from typing import Iterator + +# ========================= +# CONFIG (sdíleno s EmailMessagingGraph.py) +# ========================= +TENANT_ID = "7d269944-37a4-43a1-8140-c7517dc426e9" +CLIENT_ID = "4b222bfd-78c9-4239-a53f-43006b3ed07f" +CLIENT_SECRET = "Txg8Q~MjhocuopxsJyJBhPmDfMxZ2r5WpTFj1dfk" + +AUTHORITY = f"https://login.microsoftonline.com/{TENANT_ID}" +SCOPE = ["https://graph.microsoft.com/.default"] +GRAPH = "https://graph.microsoft.com/v1.0" + + +@lru_cache(maxsize=1) +def _token() -> str: + app = msal.ConfidentialClientApplication( + CLIENT_ID, authority=AUTHORITY, client_credential=CLIENT_SECRET + ) + tok = app.acquire_token_for_client(scopes=SCOPE) + if "access_token" not in tok: + raise RuntimeError(f"Graph auth failed: {tok}") + return tok["access_token"] + + +def _headers() -> dict: + return {"Authorization": f"Bearer {_token()}"} + + +def _get(url: str, params: dict | None = None) -> dict: + r = requests.get(url, headers=_headers(), params=params, timeout=60) + r.raise_for_status() + return r.json() + + +def _post(url: str, body: dict) -> dict: + r = requests.post( + url, headers={**_headers(), "Content-Type": "application/json"}, + json=body, timeout=60, + ) + r.raise_for_status() + return r.json() if r.content else {} + + +def _patch(url: str, body: dict) -> dict: + r = requests.patch( + url, headers={**_headers(), "Content-Type": "application/json"}, + json=body, timeout=60, + ) + r.raise_for_status() + return r.json() if r.content else {} + + +def inbox_folder_ids(mailbox: str) -> list[tuple[str, str]]: + """ + Vrátí [(folder_id, display_name), ...] pro Inbox a jeho přímé podsložky. + Záměrně vynechává Junk, Deleted, Sent, Drafts. + """ + inbox = _get(f"{GRAPH}/users/{mailbox}/mailFolders/inbox") + folders = [(inbox["id"], inbox.get("displayName", "Inbox"))] + data = _get( + f"{GRAPH}/users/{mailbox}/mailFolders/{inbox['id']}/childFolders", + {"$top": 100, "$select": "id,displayName"}, + ) + for f in data.get("value", []): + folders.append((f["id"], f.get("displayName", ""))) + return folders + + +def list_messages(mailbox: str, folder_id: str, since_iso: str) -> Iterator[dict]: + """ + Vrací zprávy s přílohou ve složce přijaté od `since_iso` (ISO 8601 UTC, Z). + Stránkuje přes @odata.nextLink. Tělo se vrací jako text (Prefer header). + """ + url = f"{GRAPH}/users/{mailbox}/mailFolders/{folder_id}/messages" + params = { + "$filter": f"hasAttachments eq true and receivedDateTime ge {since_iso}", + "$select": "id,subject,from,receivedDateTime,bodyPreview,body,hasAttachments", + "$top": 50, + } + headers = {**_headers(), "Prefer": 'outlook.body-content-type="text"'} + while url: + r = requests.get(url, headers=headers, params=params, timeout=60) + r.raise_for_status() + data = r.json() + yield from data.get("value", []) + url = data.get("@odata.nextLink") + params = None # nextLink už obsahuje všechny parametry + + +def list_attachments(mailbox: str, message_id: str) -> list[dict]: + """Metadata příloh (id, name, contentType, size, @odata.type).""" + data = _get( + f"{GRAPH}/users/{mailbox}/messages/{message_id}/attachments", + {"$top": 50}, + ) + return data.get("value", []) + + +def download_attachment(mailbox: str, message_id: str, attachment_id: str) -> bytes: + """Stáhne bajty jedné fileAttachment.""" + data = _get( + f"{GRAPH}/users/{mailbox}/messages/{message_id}/attachments/{attachment_id}" + ) + content = data.get("contentBytes") + if not content: + raise RuntimeError(f"Příloha nemá contentBytes (typ {data.get('@odata.type')})") + return base64.b64decode(content) + + +# --------------------------------------------------------------------------- +# Zápisové operace (vyžadují Mail.ReadWrite Application) +# --------------------------------------------------------------------------- +def ensure_category(mailbox: str, name: str, color: str = "preset4") -> None: + """Zajistí kategorii v master-listu schránky (s barvou). Idempotentní.""" + data = _get(f"{GRAPH}/users/{mailbox}/outlook/masterCategories", {"$top": 200}) + if any(c.get("displayName") == name for c in data.get("value", [])): + return + _post( + f"{GRAPH}/users/{mailbox}/outlook/masterCategories", + {"displayName": name, "color": color}, + ) + + +def add_category(mailbox: str, message_id: str, name: str) -> None: + """Přidá kategorii ke zprávě (zachová stávající).""" + msg = _get( + f"{GRAPH}/users/{mailbox}/messages/{message_id}", {"$select": "categories"} + ) + cats = msg.get("categories") or [] + if name not in cats: + _patch( + f"{GRAPH}/users/{mailbox}/messages/{message_id}", + {"categories": cats + [name]}, + ) + + +def ensure_folder_path(mailbox: str, parts: list[str]) -> str: + """ + Zajistí cestu složek pod Inboxem (vytvoří chybějící). `parts` jsou názvy + podsložek, např. ["ProcessedByAgent", "Invoices"]. Vrátí id poslední složky. + """ + parent_id = _get(f"{GRAPH}/users/{mailbox}/mailFolders/inbox")["id"] + for name in parts: + children = _get( + f"{GRAPH}/users/{mailbox}/mailFolders/{parent_id}/childFolders", + {"$top": 200, "$select": "id,displayName"}, + ) + match = next( + (f for f in children.get("value", []) if f.get("displayName") == name), None + ) + if match is None: + match = _post( + f"{GRAPH}/users/{mailbox}/mailFolders/{parent_id}/childFolders", + {"displayName": name}, + ) + parent_id = match["id"] + return parent_id + + +def move_message(mailbox: str, message_id: str, dest_folder_id: str) -> str: + """Přesune zprávu do složky. Vrací NOVÉ id (move id mění).""" + res = _post( + f"{GRAPH}/users/{mailbox}/messages/{message_id}/move", + {"destinationId": dest_folder_id}, + ) + return res.get("id", message_id) + + +def send_mail(sender: str, to, subject: str, html_body: str) -> None: + """Odešle HTML e-mail přes Graph (vyžaduje Mail.Send Application).""" + to_list = [to] if isinstance(to, str) else list(to) + _post( + f"{GRAPH}/users/{sender}/sendMail", + { + "message": { + "subject": subject, + "body": {"contentType": "HTML", "content": html_body}, + "toRecipients": [{"emailAddress": {"address": a}} for a in to_list], + }, + "saveToSentItems": True, + }, + ) diff --git a/EmailAgent/state.json b/EmailAgent/state.json new file mode 100644 index 0000000..8bd7947 --- /dev/null +++ b/EmailAgent/state.json @@ -0,0 +1,16 @@ +{ + "processed_ids": [ + "AAMkADYxOTE5ZTYwLTg2MWItNDNmOC04MWE5LTczZjM0NmJmMTRlYwBGAAAAAACUxJgR93AZR7oAJqupmHbKBwCzO8FCllpZQqWxx9sLz6PHAAACh39_AACzO8FCllpZQqWxx9sLz6PHAAAAACZtAAA=", + "AAMkADYxOTE5ZTYwLTg2MWItNDNmOC04MWE5LTczZjM0NmJmMTRlYwBGAAAAAACUxJgR93AZR7oAJqupmHbKBwCzO8FCllpZQqWxx9sLz6PHAAACh39_AACzO8FCllpZQqWxx9sLz6PHAAAAACZvAAA=", + "AAMkADYxOTE5ZTYwLTg2MWItNDNmOC04MWE5LTczZjM0NmJmMTRlYwBGAAAAAACUxJgR93AZR7oAJqupmHbKBwCzO8FCllpZQqWxx9sLz6PHAAACh39_AACzO8FCllpZQqWxx9sLz6PHAAAReEmkAAA=", + "AQMkADYxOTE5ZTYwLTg2MWItNDNmOC04MWE5LTczZjM0NmJmMTRlYwBGAAADlMSYEfdwGUe6ACarqZh2ygcAszvBQpZaWUKlscfbC8_jxwAAAgEMAAAAszvBQpZaWUKlscfbC8_jxwABW2C4tgAAAA==", + "AQMkADYxOTE5ZTYwLTg2MWItNDNmOC04MWE5LTczZjM0NmJmMTRlYwBGAAADlMSYEfdwGUe6ACarqZh2ygcAszvBQpZaWUKlscfbC8_jxwAAAgEMAAAAszvBQpZaWUKlscfbC8_jxwABW2C4uwAAAA==", + "AQMkADYxOTE5ZTYwLTg2MWItNDNmOC04MWE5LTczZjM0NmJmMTRlYwBGAAADlMSYEfdwGUe6ACarqZh2ygcAszvBQpZaWUKlscfbC8_jxwAAAgEMAAAAszvBQpZaWUKlscfbC8_jxwABW2C4vwAAAA==", + "AQMkADYxOTE5ZTYwLTg2MWItNDNmOC04MWE5LTczZjM0NmJmMTRlYwBGAAADlMSYEfdwGUe6ACarqZh2ygcAszvBQpZaWUKlscfbC8_jxwAAAgEMAAAAszvBQpZaWUKlscfbC8_jxwABW2C4wAAAAA==", + "AQMkADYxOTE5ZTYwLTg2MWItNDNmOC04MWE5LTczZjM0NmJmMTRlYwBGAAADlMSYEfdwGUe6ACarqZh2ygcAszvBQpZaWUKlscfbC8_jxwAAAgEMAAAAszvBQpZaWUKlscfbC8_jxwABXINL5QAAAA==", + "AQMkADYxOTE5ZTYwLTg2MWItNDNmOC04MWE5LTczZjM0NmJmMTRlYwBGAAADlMSYEfdwGUe6ACarqZh2ygcAszvBQpZaWUKlscfbC8_jxwAAAgEMAAAAszvBQpZaWUKlscfbC8_jxwABXINL5gAAAA==", + "AQMkADYxOTE5ZTYwLTg2MWItNDNmOC04MWE5LTczZjM0NmJmMTRlYwBGAAADlMSYEfdwGUe6ACarqZh2ygcAszvBQpZaWUKlscfbC8_jxwAAAgEMAAAAszvBQpZaWUKlscfbC8_jxwABXINMDQAAAA==", + "AQMkADYxOTE5ZTYwLTg2MWItNDNmOC04MWE5LTczZjM0NmJmMTRlYwBGAAADlMSYEfdwGUe6ACarqZh2ygcAszvBQpZaWUKlscfbC8_jxwAAAgEMAAAAszvBQpZaWUKlscfbC8_jxwABXxY_4AAAAA==" + ], + "last_run": "2026-06-10T06:30:04.195527+00:00" +} \ No newline at end of file diff --git a/EmailAgent/storage.py b/EmailAgent/storage.py new file mode 100644 index 0000000..b4a41b3 --- /dev/null +++ b/EmailAgent/storage.py @@ -0,0 +1,128 @@ +""" +storage.py +---------- +Abstrakce úložiště pro faktury. Dva backendy se stejným rozhraním: + +- LocalStorage — lokální filesystem (Windows, Dropbox mount přes najdi_dropbox). +- DropboxStorage — Dropbox HTTP API (unraid/server, kde není Dropbox mount). + +Výběr přes proměnnou STORAGE: "local" (default) | "dropbox". + +Rozhraní (oba backendy): + load_hashes() -> set[str] # otisky souborů už v cíli (dedup baseline) + hash_bytes(data) -> str # otisk vstupních bajtů (stejný algoritmus) + save(name, data) -> str # ulož, vrať finální název (řeší kolizi názvu) + describe() -> str # popis cíle do logu + +Pozn.: každý backend je vnitřně konzistentní (load_hashes i hash_bytes používají +týž algoritmus). Local = sha256 obsahu. Dropbox = Dropbox "content_hash" +(blokový sha256, viz dokumentace Dropboxu) — bere se přímo z metadat souboru, +takže není potřeba nic stahovat. +""" + +import hashlib +import os +from pathlib import Path + +_DROPBOX_BLOCK = 4 * 1024 * 1024 # 4 MiB + + +def _dropbox_content_hash(data: bytes) -> str: + """Dropbox content_hash: sha256 z konkatenace sha256 jednotlivých 4MiB bloků.""" + h = hashlib.sha256() + for i in range(0, len(data), _DROPBOX_BLOCK): + h.update(hashlib.sha256(data[i:i + _DROPBOX_BLOCK]).digest()) + return h.hexdigest() + + +# ========================= +# LOKÁLNÍ FILESYSTEM +# ========================= +class LocalStorage: + def __init__(self, target_dir: Path): + self.dir = Path(target_dir) + self.dir.mkdir(parents=True, exist_ok=True) + + def hash_bytes(self, data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + def load_hashes(self) -> set: + return {self.hash_bytes(p.read_bytes()) for p in self.dir.glob("*.pdf")} + + def save(self, name: str, data: bytes) -> str: + out = self.dir / name + stem, suffix = Path(name).stem, Path(name).suffix + i = 2 + while out.exists(): + out = self.dir / f"{stem} ({i}){suffix}" + i += 1 + out.write_bytes(data) + return out.name + + def describe(self) -> str: + return str(self.dir) + + +# ========================= +# DROPBOX HTTP API +# ========================= +class DropboxStorage: + def __init__(self, folder: str, app_key: str, app_secret: str, refresh_token: str): + import dropbox # lazy import — potřeba jen pro tento backend + self._dbx_mod = dropbox + self.dbx = dropbox.Dropbox( + app_key=app_key, + app_secret=app_secret, + oauth2_refresh_token=refresh_token, + ) + self.folder = "/" + folder.strip("/") + + def hash_bytes(self, data: bytes) -> str: + return _dropbox_content_hash(data) + + def load_hashes(self) -> set: + hashes = set() + try: + res = self.dbx.files_list_folder(self.folder) + except Exception: + return hashes # složka ještě nemusí existovat + while True: + for e in res.entries: + ch = getattr(e, "content_hash", None) + if ch: + hashes.add(ch) + if not res.has_more: + break + res = self.dbx.files_list_folder_continue(res.cursor) + return hashes + + def save(self, name: str, data: bytes) -> str: + # autorename=True -> při kolizi názvu (jiný obsah) Dropbox přidá " (1)". + md = self.dbx.files_upload( + data, + f"{self.folder}/{name}", + mode=self._dbx_mod.files.WriteMode.add, + autorename=True, + ) + return md.name + + def describe(self) -> str: + return f"Dropbox:{self.folder}" + + +# ========================= +# VÝBĚR BACKENDU +# ========================= +def get_storage(local_dir: Path, dropbox_path: str): + """ + STORAGE=dropbox -> DropboxStorage (klíče DROPBOX_APP_KEY/SECRET/REFRESH_TOKEN + z prostředí), jinak LocalStorage(local_dir). + """ + if os.getenv("STORAGE", "local").lower() == "dropbox": + return DropboxStorage( + dropbox_path, + os.environ["DROPBOX_APP_KEY"], + os.environ["DROPBOX_APP_SECRET"], + os.environ["DROPBOX_APP_REFRESH_TOKEN"], + ) + return LocalStorage(local_dir) diff --git a/Medicus/VerifyPřílohy/export_prilohy.py b/Medicus/VerifyPřílohy/export_prilohy.py new file mode 100644 index 0000000..c0ce911 --- /dev/null +++ b/Medicus/VerifyPřílohy/export_prilohy.py @@ -0,0 +1,106 @@ +"""export_prilohy.py – export příloh z Medicus FILES tabulky do souborů + +Extrahuje všechny záznamy z FILES, uloží BODY blob jako soubor. +Detekuje dva formáty BODY: + - inline PDF (začíná %PDF) + - reference (začíná magic \xee\xbb\xaa\x0b) → čte z externí MEDICUS_FILES_YYYYMM.fdb +""" + +import os +import struct +import sys + +sys.path.insert(0, r'U:\OrdinaceProjekt') +from Knihovny.najdi_dropbox import get_dropbox_root +from Knihovny.medicus_db import get_medicus_connection + +# ─── Konfigurace ───────────────────────────────────────────────────────────── + +OUTPUT_DIR = os.path.join(get_dropbox_root(), r'!!!Days\Downloads Z230\Files') +RECORDS_COUNT = 0 # 0 = všechny záznamy + +# Magic bytes identifikující referenci na externí FDB (na reporterovi nenastane) +MAGIC = b'\xee\xbb\xaa\x0b' + +# ─── Pomocné funkce ─────────────────────────────────────────────────────────── + +def parse_body_ref(body_bytes): + """Vrátí (uid, dbname) pokud BODY je reference na ext FDB, jinak None.""" + if not body_bytes or len(body_bytes) < 8: + return None + if body_bytes[:4] != MAGIC: + return None + uid = body_bytes[4:36].decode('ascii') + dblen = struct.unpack_from('|': + name = name.replace(ch, '_') + return name.strip() + + +# ─── Hlavní logika ──────────────────────────────────────────────────────────── + +def main(): + os.makedirs(OUTPUT_DIR, exist_ok=True) + + conn = get_medicus_connection() + cur = conn.cursor() + limit = f"FIRST {RECORDS_COUNT} " if RECORDS_COUNT > 0 else "" + cur.execute(f""" + SELECT {limit}f.ID, f.IDPAC, f.DATUM, f.FILENAME, f.BODY, f.POZNAMKA + FROM FILES f + ORDER BY f.ID + """) + + ok = 0 + chyb = 0 + + for row in cur: + file_id, idpac, datum, filename, body_blob, poznamka = row + + # Přečti blob + try: + if hasattr(body_blob, 'read'): + body_bytes = body_blob.read() + body_blob.close() + else: + body_bytes = body_blob or b'' + except Exception as e: + print(f" [CHYBA] ID={file_id}: čtení BODY selhalo: {e}") + chyb += 1 + continue + + # Zjisti formát + ref = parse_body_ref(body_bytes) + if ref: + _, dbname = ref + print(f" [SKIP] ID={file_id}: reference na ext DB {dbname} — nepodporováno") + chyb += 1 + continue + pdf_data = body_bytes + + # Sestavení cílového názvu: ID_IDPAC_DATUM_filename + datum_str = datum.strftime('%Y-%m-%d') if datum else 'bez-data' + raw_fn = filename or f'soubor_{file_id}.bin' + out_name = f"{file_id:06d}_{idpac:06d}_{datum_str}_{safe_filename(raw_fn)}" + out_path = os.path.join(OUTPUT_DIR, out_name) + + # Ulož + with open(out_path, 'wb') as f: + f.write(pdf_data) + + print(f" ID={file_id} idpac={idpac} {datum_str} {raw_fn} → {len(pdf_data):,} B") + ok += 1 + + conn.close() + print(f"\nHotovo: {ok} uloženo, {chyb} chyb") + print(f"Výstup: {OUTPUT_DIR}") + + +if __name__ == '__main__': + main()