AI dekóduje PDF faktury: ČNB služba jako nástroj agenta
Přijaté faktury od zahraničních SaaS dodavatelů čte v Billify Claude přes structured output. A kurz k DUZP si agent dotáhne sám - zavolá tutéž ČNB službu, kterou už mám pro synchronní fakturaci, obalenou jako tool.
V článku o synchronním VIES jsem skončil slibem: příště navážu článkem o kurzech ČNB, konkrétně o „kurzu k DUZP", když ČNB občas nezveřejní fixing. Ten slib tady plním, jen jinak, než jsem čekal. Kurz ČNB se totiž nakonec ukázal jen jako menší část většího problému: nechat AI číst přijaté faktury a sestavit z nich podklad pro měsíční přiznání k DPH (DPHDP3).
Billify dělá identifikované osobě měsíční přehled DPH. Taková osoba nakupuje služby ze zahraničí - Hetzner, Forge, OpenAI, Cloudflare, Vercel, JetBrains - a z každé faktury musí do přiznání dostat dodavatele, jeho DIČ, částku, měnu, DUZP a kurz ČNB k tomuto DUZP. Ručně je to otrava a zároveň zdroj chyb. Otrava proto, že jde o deset skoro stejných PDF měsíčně. Zdroj chyb proto, že „skoro stejných" znamená, že každé je naformátované jinak a DUZP se u každého počítá podle jiného pravidla.
Tenhle článek je o tom, jak to čte AI agent - a jak jsem mu jako nástroj podstrčil službu, kterou jsem už měl napsanou kvůli něčemu úplně jinému.
Proč ne OCR, ale structured output
První nápad u „přečti PDF" byl OCR plus regulární výrazy. Ten jsem ale hned zavrhl. Faktura od Hetzneru a faktura od OpenAI nemají společný layout, na který by se dal napsat parser. Společný mají význam: obě někde nesou dodavatele, částku a datum. A přesně to je doména, kde dnešní modely fungují líp než jakákoli sada pravidel, kterou bych byl ochoten udržovat.
Stojí to na balíčku laravel/ai. Agent je obyčejná třída s atributy:
#[Provider(Lab::Anthropic)]
#[UseCheapestModel]
#[Temperature(0.0)]
#[MaxTokens(2048)]
#[Timeout(180)]
final class ReceivedInvoiceExtractionAgent implements Agent, HasStructuredOutput, HasTools
{
use Promptable;
public function __construct(public readonly FetchCnbRateTool $fetchCnbRateTool) {}
}
Pár vědomých rozhodnutí v těch atributech:
UseCheapestModelvybere Claude Haiku. Vytáhnout strukturovaná pole z jedné faktury není úloha pro nejdražší model. Měsíčně tím projdou desítky faktur, takže rozhoduje cena za kus.Temperature(0.0). Tohle není kreativní psaní, ale daňový podklad. Chci, aby stejná faktura vyšla pokaždé stejně.Timeout(180). Agent během jednoho běhu volá nástroj na ČNB, takže se do timeoutu musí vejít i ta síťová odbočka.
PDF do modelu posílám nativně, žádný OCR mezikrok:
$response = $extractionAgent->prompt(
'Extract invoice metadata from the attached PDF and call FetchCnbRateTool to obtain the ČNB rate.',
attachments: [Document::fromStorage($receivedInvoice->pdf_path, disk: $receivedInvoice->pdf_disk)],
);
Document::fromStorage() zabalí soubor do dokumentového bloku a serializaci řeší laravel/ai. Base64 ani obsahových bloků se vůbec nedotknu.
Výstup je vázaný schématem - jedenáct povinných polí. Zkrácený výřez:
public function schema(JsonSchema $schema): array
{
return [
'supplier_name' => $schema->string()
->description('Legal name of the supplier exactly as printed on the invoice.')
->required(),
'taxable_supply_at' => $schema->string()
->description('DUZP in YYYY-MM-DD format. Prefer service_period_end, fall back to issue_date.')
->required(),
'foreign_currency_code' => $schema->string()
->enum(ForeignCurrency::values())
->required(),
'czk_rate' => $schema->number()
->description('CZK exchange rate per 1 unit of foreign currency, returned by FetchCnbRateTool.')
->required(),
'czk_amount' => $schema->number()
->description('foreign_amount * czk_rate rounded to 2 decimals (half-up).')
->required(),
'notes_for_review' => $schema->string()
->description('Empty string when extraction is unambiguous, otherwise a short note for the human reviewer.')
->required(),
// ... supplier_country_code, supplier_vat_id, invoice_number, issue_date, foreign_amount
];
}
Doména žije v promptu, ne v kódu
Samotné schéma je jednoduché. To podstatné je v instrukcích, protože tam je zakódovaná daňová doména, kterou bych jinak musel napsat do ifů.
Dva problémy, které musí prompt ustát. První je formát data:
DATE FORMAT WARNING: many EU invoices print dates in DD.MM.YYYY (German, Czech, French - e.g. "02.05.2026" means 2 May 2026). US invoices use MM/DD/YYYY or YYYY-MM-DD. Use the supplier country and surrounding context to disambiguate. Never assume US format on a German / Czech / French supplier.
02.05.2026 znamená na německé faktuře 2. května, na americké 5. února. Žádný parser to z toho řetězce nepozná, protože informace, která to rozhoduje, je v zemi dodavatele o tři řádky výš. Model tu souvislost vidí.
Druhý problém je samotné DUZP. Den, ke kterému vzniká daňová povinnost, není „datum vystavení". Podle § 24 ZDPH je to dřívější ze dne poskytnutí služby a dne úplaty:
taxable_supply_at = min(issue_date, service_period_end)
To pokrývá dva běžné vzorce, které agent v promptu rozlišuje výslovně: předplacené SaaS (Forge, OpenAI, Vercel - faktura na začátku období, použij datum vystavení) versus fakturace zpětně (Hetzner, metered AWS - faktura na začátku dalšího měsíce za měsíc minulý, použij konec období). K tomu pravidlo pro neuhrazené faktury se splatností v budoucnu. Tohle všechno mám v promptu, ne v kódu.
Když píšu „doména patří do promptu", neznamená to „naházej tam všechno". Znamená to: rozhodnutí, která vyžadují čtení dokumentu s kontextem, dej modelu. Výpočty, na kterých visí peníze, si nech v kódu. Hranice mezi těmi dvěma je celý ten článek.
A přesně na té hranici stojí kurz.
Z interní služby nástroj pro agenta
Kurz ČNB k DUZP bych mohl řešit dvěma způsoby. Buď řeknu modelu „vrať mi i kurz" a doufám, že si ho pamatuje (nepamatuje, a u daní „doufám" může skončit hodně špatně). Nebo si kurz po extrakci dotáhnu sám v kódu.
Druhá varianta má háček: kurz je vázaný na DUZP (§ 4 odst. 5 ZDPH říká, že se přepočítává kurzem ke dni vzniku povinnosti), a které datum je DUZP, rozhodl před chvílí model podle té logiky § 24 výše. Lepší je nechat rozhodnutí o datu i dotažení kurzu v jednom kroku, u toho, kdo datum určuje.
Takže jsem z kurzu udělal nástroj agenta. A hlavně: tu logiku jsem nepsal znovu. Službu ExchangeRateService, která stahuje kurzy z ČNB, cachuje je a umí ruční override, už mám z práce na synchronní fakturaci. Stačilo ji obalit. Celý nástroj má třicet řádků:
final class FetchCnbRateTool implements Tool
{
public function __construct(private readonly ExchangeRateService $exchangeRateService) {}
public function description(): string
{
return 'Fetches the official Czech National Bank (ČNB) exchange rate per 1 unit '
.'of the given foreign currency, valid on the given date (used as DUZP - the '
.'day the tax liability arises, § 4 odst. 5 ZDPH). Returns the rate as a decimal '
.'string. Call this once per invoice after extracting the foreign currency and the DUZP date.';
}
public function schema(JsonSchema $schema): array
{
return [
'foreign_currency_code' => $schema->string()
->enum(ForeignCurrency::values())
->required(),
'taxable_supply_date' => $schema->string()
->description('DUZP date in YYYY-MM-DD format.')
->required(),
];
}
public function handle(Request $request): string
{
try {
$rate = $this->exchangeRateService->getRate(
sourceCurrency: (string) $request['foreign_currency_code'],
date: CarbonImmutable::parse((string) $request['taxable_supply_date']),
);
} catch (Throwable $exception) {
return sprintf('ERROR: Unable to fetch ČNB rate for %s on %s: %s',
$request['foreign_currency_code'], $request['taxable_supply_date'], $exception->getMessage());
}
return number_format($rate, 6, '.', '');
}
}
Registrace u agenta je jeden řádek:
public function tools(): iterable
{
return [$this->fetchCnbRateTool];
}
A v instrukcích krok, který modelu řekne, kdy nástroj zavolat:
Call FetchCnbRateTool once with the foreign_currency_code and the taxable_supply_at date you decided on (ČNB rate is keyed to the day the duty arises). Return the rate it gives you in czk_rate, and compute czk_amount = foreign_amount * czk_rate rounded to 2 decimals using standard half-up rounding.
Tím vznikne jednoduchá agentní smyčka: model z PDF přečte měnu a rozhodne DUZP → zavolá FetchCnbRateTool(currency, duzp) → dostane kurz jako řetězec → vynásobí a vrátí czk_amount. Násobení i volání na ČNB přes HTTP se odehrávají v kódu, který mám otestovaný. Model to celé jen řídí: rozhodne měnu a datum, zbytek nechá na nástroji.
Dvě drobnosti v nástroji, na kterých záleží. Tool vrací kurz jako řetězec se šesti desetinnými místy. Výsledek nástroje stejně do modelu doputuje jako text, takže ho radši rovnou naformátuju na přesné cifry - model je pak jen opíše do číselného pole czk_rate ve structured outputu, místo aby si je sám dopočítával a „pro jistotu" zaokrouhlil. A chybu vrací jako text ERROR: ..., ne jako výjimku. Když ČNB kurz nedá, model si to přečte, poznamená to do notes_for_review a pustí fakturu dál k uživatelské kontrole, místo aby celá extrakce spadla.
Walkback: to, co jsem minule sliboval
Tady je ta slíbená část. ČNB nepublikuje fixing o víkendech a svátcích. Faktura s nedělním DUZP ale kurz potřebuje. ExchangeRateService to řeší tak, že se po dnech posouvá zpět, maximálně sedm dní, dokud nenarazí na poslední vyhlášený kurz:
for ($offset = 0; $offset < self::WALKBACK_DAYS; $offset++) {
$tryDate = $date->subDays($offset);
$result = $this->queryCnbFor($sourceCurrency, $tryDate);
if ($result === null) {
continue;
}
// Cache pod POŽADOVANÝM datem, ne pod tím, na kterém jsme uspěli.
ExchangeRate::updateOrCreate(
['date' => $date->toDateString(), 'source_currency' => $sourceCurrency, /* ... */ 'source' => 'cnb'],
['rate' => $result['rate'], 'rate_date' => $result['validFor']],
);
return $result['rate'];
}
throw new ExchangeRateUnavailableException(/* ... */);
Detail, na kterém záleží: cache se klíčuje pod požadovaným datem (nedělní DUZP), ale do rate_date se uloží skutečný den fixingu (páteční), který ČNB vrátil v poli validFor. Faktura má díky tomu dohledatelné obojí - ke kterému dni jsem kurz chtěl i ze kterého dne kurz reálně pochází.
A ještě jedna past při parsování dat z ČNB: některé měny jsou kotované na N jednotek (100 JPY = 14,21 CZK), ne na jednu. Volající ale vždycky chce kurz na jednu jednotku:
// ČNB publikuje kurz na N jednotek. Volající chce vždy na 1.
return [
'rate' => $rawRate / $amount,
'validFor' => (string) ($row['validFor'] ?? $date->toDateString()),
];
Kdybych tohle dělení vynechal, faktura v jenech by vyšla stokrát vyšší. AI o téhle pasti neví a vědět nemusí - je schovaná v otestované službě pod nástrojem.
Kolem AI: kvóta, audit, jeden pokus, člověk na konci
Samotný agent je půlka. Druhá půlka je infrastruktura, která ho drží pod kontrolou. Tady jde o peníze a daně.
Jediný přístupový bod. Žádná část aplikace nespouští extrakci přímo. Všechno jde přes TaxExtractionDispatcher, který v jedné transakci uzamkne celý tým (tenant struktura), zkontroluje měsíční kvótu a teprve pak pustí job:
public function dispatch(ReceivedInvoice $invoice, ExtractionReason $reason): void
{
DB::transaction(function () use ($invoice, $reason): void {
$team = Team::query()->lockForUpdate()->find($invoice->team_id);
if ($team->tier !== Tier::Unlimited) {
$this->enforceQuota($team);
}
TaxExtractionLog::query()->create([
'team_id' => $team->id,
'received_invoice_id' => $invoice->id,
'attempted_at' => CarbonImmutable::now(),
'reason' => $reason,
'model' => self::AI_MODEL, // do auditu: které číslo vyrobil který model
]);
ExtractReceivedInvoiceJob::dispatch($invoice->id);
});
}
Audit modelu. Každý pokus zapíše do logu, který model ho zpracoval (claude-haiku-4-5-20251001). Když se za rok zeptám „odkud se vzalo tohle DUZP", chci na to umět odpovědět včetně verze modelu. U daní nesmí číslo spadnout z nebe bez stopy.
Jeden pokus, žádný retry. Job je ShouldBeUnique a má tries = 1. Když extrakce selže, nezkouším to potichu znovu. Chybu zaloguju a uložím do ai_notes_for_review, faktura zůstane v draftu se zprávou, co se nepovedlo.
Člověk na konci. Agent nikdy nic nepotvrzuje. Vyrobí Draft, který uživatel zkontroluje a potvrdí v UI. temperature = 0, log s verzí modelu, notes_for_review u čehokoli nejednoznačného a lidské potvrzení jsou čtyři vrstvy mezi „model něco napsal" a „je to v přiznání".
Co to ukázalo
1. Structured output poráží OCR plus regex na polostrukturovaných dokumentech. Faktury mají společný význam, ne layout. Schéma je kontrakt, který z modelu udělá zdroj typovaných polí místo zdroje textu k parsování.
2. Doména patří do promptu, peníze do kódu. Model rozhoduje to, co vyžaduje čtení dokumentu s kontextem (formát data, které datum je DUZP). Kód počítá to, na čem visí koruny (kurz, násobení). Celý návrh je o tom, kudy tu hranici vést.
3. Nástroj je jen funkce, kterou nedeterministický model umí zavolat. Kurzovou logiku jsem nepřepisoval do AI vrstvy - jen jsem obalil to, co už fungovalo. Model tím nikdy nesáhne na HTTP ani na finanční matematiku.
4. AI vyrábí draft, ne finální data. temperature = 0, log s verzí modelu, poznámky pro recenzenta a povinné lidské potvrzení. Tam, kde výstup končí v daňovém přiznání, je auditní stopa stejně důležitá jako extrakce samotná.
Příště se vrátím k tomu, jak ručně potvrzený draft propojit s odpočtem DPH na vstupu - a proč klasifikace řádku přiznání (§ 9 reverse charge versus dovoz služby ze třetí země) zůstala záměrně mimo agenta, v deterministickém kódu.