← Zpět · 29. května 2026 · 10 min čtení

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:

  • UseCheapestModel vybere 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.

Stačí psát část slova · ⌘K otevře hledání odkudkoliv

No results found