# Implementační návod - ZAIA

Připojení systému (městský portál, e-shop, helpdesk) je adaptér ve vašem backendu. Nikdy nevyžaduje změnu v ZAIA. Příklady používají doménu produktů.

Web: https://zaia.cz/ | HTML verze: https://zaia.cz/navod.html | Postman: https://zaia.cz/AIAsistent.postman_collection.json | English: https://zaia.cz/en/guide.md

## Krok 0: poskytnutí přístupu

Přístup zřizuje provozovatel, ne integrovaná aplikace, pomocí admin klíče (hlavička `X-Admin-Key`, nastavený v konfiguraci ZAIA, alespoň 32 znaků).

```
POST /admin/tenants
{"name":"Acme Shop","monthlyRequestLimit":20000}

POST /admin/tenants/{id}/keys
{"name":"backend production"}
```

Limit je 1 až 1 000 000 požadavků měsíčně. Odpověď na vytvoření klíče obsahuje surový `key` jen jednou, ukládá se pouze jeho SHA-256 hash.

Klíč uložte do správce tajemství vašeho backendu a používejte ho jen na serveru jako `Authorization: Bearer <key>`. Nikdy ho nedávejte do prohlížeče ani mobilní aplikace. Admin klíč z aplikace nepoužívejte vůbec.

## Testovací klíče

Pro vývoj a CI použijte testovací klíč. Operátor ho vytvoří stejným endpointem s příznakem `test`:

```
POST /admin/tenants/{id}/keys
{"name":"integration test","test":true}
```

Odpověď (201):

```json
{"id":"<guid>","key":"aia_test_<43 znaků>","prefix":"aia_test_Ab3","name":"integration test","test":true}
```

Živé klíče mají tvar `tvm_...` a `"test":false`. Režim určuje uložený klíč, ne jeho prefix. `GET /admin/tenants/{id}/keys` vrací u každého klíče `id`, `name`, `prefix`, `test` a `revokedAt`.

S testovacím klíčem `/v1/interpretations` a `/v1/answers` provedou plnou validaci vstupu (stejné 400 s `detail` a `field`) a vrátí deterministickou odpověď:

- bez volání modelu, bez čerpání kvóty a bez záznamu v usage,
- hlavička `X-Test-Mode: true`, bez hlaviček `X-Quota-*`, `X-Request-Id` zůstává,
- `usage`: `{"inputTokens":0,"outputTokens":0,"known":true}`.

Interpretace:

- status `ready`, `parameters` obsahují každé deklarované pole se vzorovou hodnotou:
  - `string`: první `allowedValue`, jinak `"test"`,
  - `integer`: `1`,
  - `boolean`: `true`,
  - `date`: dnešní UTC datum ve tvaru `yyyy-MM-dd`,
  - `stringArray`: `[první allowedValue]`, jinak `["test"]`.
- otázka obsahující `[clarify]` vrátí status `needs_clarification`, `parameters` `{}` a `clarification` "Testovací režim: upřesněte prosím dotaz." (pro jiný `locale` než `cs-CZ` anglicky "Test mode: please clarify the question.")

Odpověď:

- status `answered`, `answer` "Testovací odpověď z podkladu: {title prvního zdroje}." (pro jiný `locale` než `cs-CZ` anglicky "Test answer from source: {title}."), `sources` obsahují jen první zdroj,
- otázka obsahující `[insufficient]` nebo prázdné `sources` vrátí `insufficient_sources` s běžným lokalizovaným textem a `sources` `[]`.

`GET /v1/usage` s testovacím klíčem vrátí nulovou spotřebu (jen `monthlyRequestLimit` zůstává), `X-Test-Mode: true` a žádné `X-Quota-*` hlavičky. Testovací klíč funguje i při vyčerpané měsíční kvótě. Zrušený testovací klíč nebo vypnutý tenant vrátí 401.

Příklad odpovědi interpretace v testovacím režimu pro pole `city` (string, allowedValues `["Praha","Brno"]`), `limit` (integer), `open` (boolean), `from` (date) a `tags` (stringArray):

```json
{"requestId":"...","status":"ready","parameters":{"city":"Praha","limit":1,"open":true,"from":"2026-10-29","tags":["test"]},"clarification":null,"usage":{"inputTokens":0,"outputTokens":0,"known":true}}
```

Doporučený postup: vývoj a CI běží proti testovacímu klíči. V produkci přepněte jen tajemství na živý klíč, bez změny kódu.

## Klientské knihovny

Klientská knihovna pro PHP a C# je jeden soubor bez závislostí. Zkopírujte ho do projektu, nic dalšího neinstalujete.

- PHP 8.1+, potřebuje jen `ext-curl` a `ext-json`: [clients/AiAsistentClient.php](https://zaia.cz/clients/AiAsistentClient.php)
- .NET (net8.0+), jen BCL: [clients/AiAsistentClient.cs](https://zaia.cz/clients/AiAsistentClient.cs)

Klíč patří jen na server. Ve vývoji a CI použijte testovací klíč `aia_test_...`, v produkci přepněte jen tajemství.

### PHP

```php
require __DIR__ . '/AiAsistentClient.php';
use AiAsistent\{Client, Field};

$aia = new Client('https://ai.example.invalid', getenv('AIA_KEY'));

// 1. Turn the question into parameters.
$r = $aia->interpret($_GET['q'], [
    Field::string('category', 'Product category', true, ['outdoor', 'books']),
    Field::integer('maxPrice', 'Maximum price in CZK'),
], ['context' => ['currency' => 'CZK']]);
if ($r->needsClarification()) { exit(htmlspecialchars($r->clarification())); }

// 2. Your own search (validate $r->parameters(), apply your authorization).
$sources = my_search($r->parameters()); // [['id' => ..., 'title' => ..., 'text' => ..., 'url' => 'https://...'], ...]

// 3. Answer from those sources.
$a = $aia->answer($_GET['q'], $sources);
echo '<p>' . htmlspecialchars($a->answer()) . '</p>'; // plain text, always escape
foreach ($a->sources() as $s) {
    echo '<a href="' . htmlspecialchars($s['url']) . '">' . htmlspecialchars($s['title']) . '</a>';
}
```

### C#

```csharp
using AIAsistent.Client;

var ai = new AiAsistentClient(new Uri("https://ai.example.invalid"), apiKey);

var q = await ai.InterpretAsync(new("Tent under 3000 CZK in stock?",
[
    Field.String("category", "Product category.", true, "outdoor", "books"),
    Field.Integer("maxPrice", "Maximum price in CZK."),
]) { Context = new { currency = "CZK" } });

if (q.NeedsClarification) return Ask(q.Clarification!);
var hits = MySearch(q.Get<string>("category"), q.Get<int?>("maxPrice")); // your data, your authorization rules

var a = await ai.AnswerAsync(new(question, hits.Select(h => new Source(h.Id, h.Title, h.Text, h.Url)).ToList()));
Render(a.IsAnswered ? a.Answer : "Sorry, nothing found."); // plain text, escape it; a.Sources = cited cards
```

### Chyby a opakování

Neúspěšná odpověď vyhodí výjimku: PHP `ApiException` (pole `status`, `errorCode`, `detail`, `field`, `traceId`, `retryAfter`), C# `AiAsistentException` (pole `StatusCode`, `Code`, `Detail`, `Field`, `TraceId`, `RetryAfter`). Síťovou chybu nebo timeout hlásí PHP jako `TransportException`. Klienti nedělají žádné automatické opakování. Timeout nebo výpadek u placeného volání je nejednoznačný, model mohl požadavek zpracovat a zaúčtovat, proto ho neopakujte slepě. Při 502 nebo 503 opakujte jen vědomě a s limitem. Výchozí timeout je 40 s, časový limit modelu na serveru je 30 s.

## Krok 1: definice polí

Filtry vašeho vyhledávání popište jako `fields` (1 až 20 položek).

Návrh z ukázkového záznamu vaší databáze připraví [pomocník pro definici polí](https://zaia.cz/pomocnik.html) (běží jen v prohlížeči). Požadavky a odpovědi s testovacím klíčem si můžete vyzkoušet v [playgroundu](https://zaia.cz/playground.html).

| Vlastnost | Pravidlo |
|---|---|
| `name` | unikátní, `^[A-Za-z][A-Za-z0-9_]{0,49}$` |
| `type` | `string`, `integer`, `boolean`, `date`, `stringArray` |
| `description` | povinný, nejvýše 500 znaků |
| `required` | boolean |
| `allowedValues` | volitelné, 1 až 100 různých řetězců po nejvýše 100 znacích, jen pro `string` a `stringArray` |

Data se vracejí ve tvaru `yyyy-MM-dd`. Pro uzavřené množiny hodnot vždy použijte `allowedValues`. Vrací se kanonická povolená hodnota; rozdíly ve velikosti písmen a české diakritice ve výstupu modelu se tolerují (`Kultura` se vrátí jako `kultura`).

## Krok 2: kontext, historie, jazyk

- `question` je povinná, neprázdná, nejvýše 2000 znaků.
- `context`: JSON objekt, nejvýše 8000 serializovaných znaků. Pošlete fakta, která model nemůže znát (měna, časové pásmo, povolená data, město uživatele). Bere se jako data, ne jako instrukce.
- `history` (volitelné): nejvýše 10 položek, role `user` nebo `assistant`, obsah do 2000 znaků.
- `locale`: jeden ze 17 podporovaných, `cs-CZ`, `sk-SK`, `en-US`, `de-DE`, `pl-PL`, `uk-UA`, `fr-FR`, `es-ES`, `it-IT`, `pt-BR`, `ru-RU`, `tr-TR`, `ar-SA`, `zh-CN`, `ja-JP`, `ko-KR`, `id-ID`. Výchozí je `cs-CZ`. Regionální varianta (např. `de-AT`) se převede na podporovaný jazyk, jiná hodnota vrátí 400 `validation_error`.
- Celé tělo požadavku je omezeno na 128 KiB.

## Krok 3: vlastní hledání s autorizací

Vrácené parametry nejdřív zvalidujte (rozsahy, vlastnictví, obchodní pravidla), pak dotazujte vlastní databázi. Použijte vlastní pravidla viditelnosti (publikováno, skladem, patří tomuto uživateli). Odpověď modelu nikdy není autorizační rozhodnutí. Neodesílejte nepublikované, skryté ani zrušené záznamy.

## Krok 4: záznamy jako zdroje a vykreslení

Pošlete nejvýše 20 záznamů jako `sources`:

| Vlastnost | Limit |
|---|---|
| `id` | unikátní, do 100 znaků |
| `title` | do 200 znaků |
| `text` | do 4000 znaků; součet textů všech zdrojů nejvýše 40 000 znaků |
| `url` | absolutní HTTPS, do 2048 znaků, bez přihlašovacích údajů |
| `updatedAt` | volitelné |

Pole `answer` vykreslete jako escapovaný prostý text a nikdy ho neberte jako HTML. Vrácené `sources` zobrazte jako karty s odkazy na vaše vlastní stránky. Samotná odpověď neobsahuje odkazy.

## Úplné příklady (doména produktů)

### POST /v1/interpretations

Požadavek:

```http
POST /v1/interpretations
Authorization: Bearer <tenant key>
Content-Type: application/json

{
  "question": "Do you have a tent under 3000 CZK in stock?",
  "context": { "currency": "CZK" },
  "fields": [
    { "name": "category", "type": "string", "description": "Product category.", "required": true, "allowedValues": ["outdoor", "books"] },
    { "name": "maxPrice", "type": "integer", "description": "Maximum price in CZK.", "required": false },
    { "name": "inStock", "type": "boolean", "description": "Only items in stock.", "required": false }
  ]
}
```

Odpověď. `status` je `ready` nebo `needs_clarification`; `usage` nese počty tokenů od poskytovatele, `known` je `false`, pokud je nenahlásil.

```json
{
  "requestId": "...",
  "status": "ready",
  "parameters": { "category": "outdoor", "maxPrice": 3000, "inStock": true },
  "clarification": null,
  "usage": { "inputTokens": 812, "outputTokens": 64, "known": true }
}
```

U `needs_clarification` obsahuje `clarification` otázku pro uživatele a povinné parametry mohou chybět. Pole se vrací s deklarovanými typy, backend je přesto musí znovu zvalidovat a vynutit.

### POST /v1/answers

Požadavek se záznamy, které vrátilo vaše hledání pro dané parametry:

```http
POST /v1/answers
Authorization: Bearer <tenant key>
Content-Type: application/json

{
  "question": "Do you have a tent under 3000 CZK in stock?",
  "sources": [
    { "id": "tent", "title": "Family tent", "text": "Family tent: Four-person tent for family trips. Price 2490 CZK.", "url": "https://shop.example/products/tent", "updatedAt": "2026-09-30T08:00:00+00:00" }
  ]
}
```

Odpověď. `status` je `answered` nebo `insufficient_sources`; `sources` jsou jen vstupní záznamy, které model citoval.

```json
{
  "requestId": "...",
  "status": "answered",
  "answer": "Yes, the Family tent is available for 2490 CZK.",
  "sources": [
    { "id": "tent", "title": "Family tent", "url": "https://shop.example/products/tent", "updatedAt": "2026-09-30T08:00:00+00:00" }
  ],
  "usage": { "inputTokens": 640, "outputTokens": 48, "known": true }
}
```

Prázdné pole `sources` vrátí deterministickou lokalizovanou odpověď `insufficient_sources`, bez volání modelu a bez čerpání kvóty. Členství zdroje se ověřuje, ale to neprokazuje, že každá věta je podložena citovaným textem.

## curl a C#

Příklady jsou serverové. Hotovou sadu požadavků si stáhněte jako Postman kolekci (https://zaia.cz/AIAsistent.postman_collection.json). Adresu `https://ai.example.invalid` nahraďte adresou, kterou dostanete při zřízení přístupu.

```bash
curl -sS https://ai.example.invalid/v1/answers \
  -H "Authorization: Bearer $AIA_KEY" \
  -H "Content-Type: application/json" \
  -d '{"question":"Do you have a tent?","sources":[]}'
```

```csharp
using System.Net.Http.Headers;
using System.Net.Http.Json;

// Server-side only: the key comes from a secret store, never from a client app.
var http = new HttpClient
{
    BaseAddress = new Uri("https://ai.example.invalid/"),
    // Must exceed the service's own 30 s model timeout.
    Timeout = TimeSpan.FromSeconds(40)
};
http.DefaultRequestHeaders.Authorization =
    new AuthenticationHeaderValue("Bearer", Environment.GetEnvironmentVariable("AIA_KEY"));

var response = await http.PostAsJsonAsync("v1/answers", new
{
    question = "Do you have a tent under 3000 CZK in stock?",
    sources = new[]
    {
        new { id = "tent", title = "Family tent",
              text = "Family tent: Four-person tent. Price 2490 CZK.",
              url = "https://shop.example/products/tent" }
    }
});

if (!response.IsSuccessStatusCode)
{
    // ProblemDetails: log "code" and "traceId", never the key.
    Console.Error.WriteLine(await response.Content.ReadAsStringAsync());
    return;
}
var json = await response.Content.ReadAsStringAsync(); // render "answer" as escaped plain text
```

## Rychlý start v PHP

Bez knihovny, jen cURL. Jednodušší je klientská knihovna (viz Klientské knihovny). Čisté PHP 8 s rozšířením `ext-curl`, bez závislostí. Jen na serveru, klíč nikdy do prohlížeče. Tok: interpretace, vlastní hledání (zástupná funkce `my_search`), odpověď.

```php
<?php
// Server-side only: the key comes from a secret store / environment, never from a client.
const AIA_BASE = 'https://ai.example.invalid';

function aia_post(string $path, array $body): array
{
    $headers = [];
    $ch = curl_init(AIA_BASE . $path);
    curl_setopt_array($ch, [
        CURLOPT_POST => true,
        CURLOPT_POSTFIELDS => json_encode($body, JSON_UNESCAPED_UNICODE),
        CURLOPT_HTTPHEADER => [
            'Authorization: Bearer ' . getenv('AIA_KEY'),
            'Content-Type: application/json',
        ],
        CURLOPT_RETURNTRANSFER => true,
        // Must exceed the service's own 30 s model timeout.
        CURLOPT_TIMEOUT => 40,
        // Collect response headers (X-Quota-Remaining, Retry-After).
        CURLOPT_HEADERFUNCTION => function ($ch, $line) use (&$headers) {
            if (str_contains($line, ':')) {
                [$k, $v] = explode(':', $line, 2);
                $headers[strtolower(trim($k))] = trim($v);
            }
            return strlen($line);
        },
    ]);
    $raw = curl_exec($ch);
    $status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
    curl_close($ch);
    if ($raw === false) {
        // A timeout is ambiguous (the call may have been billed): do not retry blindly.
        throw new RuntimeException('ZAIA network error or timeout');
    }
    return [$status, json_decode($raw, true) ?? [], $headers];
}

function aia_call(string $path, array $body): array
{
    [$status, $json, $headers] = aia_post($path, $body);
    if (isset($headers['x-quota-remaining'])) {
        error_log('ZAIA quota remaining: ' . $headers['x-quota-remaining']);
    }
    if ($status === 429) {
        // concurrency_limit or quota_exceeded: wait for Retry-After, no tight loop.
        throw new RuntimeException('ZAIA 429, retry after ' . ($headers['retry-after'] ?? '?') . ' s');
    }
    if ($status === 400) {
        // validation_error: "detail" is the message, "field" the JSON path of the bad input.
        throw new InvalidArgumentException(($json['detail'] ?? 'invalid request') . ' [' . ($json['field'] ?? '?') . ']');
    }
    if ($status >= 400) {
        // Log "code" and "traceId" (also in X-Request-Id), never the key.
        throw new RuntimeException('ZAIA error ' . $status . ' ' . ($json['code'] ?? '') . ' ' . ($json['traceId'] ?? ''));
    }
    return $json;
}

$question = 'Do you have a tent under 3000 CZK in stock?';

// 1) Interpretation: question to typed parameters.
$interp = aia_call('/v1/interpretations', [
    'question' => $question,
    'context' => ['currency' => 'CZK'],
    'fields' => [
        ['name' => 'category', 'type' => 'string', 'description' => 'Product category.', 'required' => true, 'allowedValues' => ['outdoor', 'books']],
        ['name' => 'maxPrice', 'type' => 'integer', 'description' => 'Maximum price in CZK.', 'required' => false],
    ],
]);

// 2) Your own search with your own authorization (placeholder).
//    Validate $interp['parameters'] first; send only records the user may see.
$sources = my_search($interp['parameters'] ?? []); // up to 20 records {id, title, text, url}

// 3) Answer grounded in those sources.
$answer = aia_call('/v1/answers', ['question' => $question, 'sources' => $sources]);

// Render as escaped plain text, never as HTML.
echo htmlspecialchars($answer['answer'] ?? '', ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
```

## Kdy přeskočit interpretaci

Pokud váš systém už ví, co hledat (formulář s filtry, textové hledání v FAQ), nevolejte `POST /v1/interpretations`. Spusťte hledání a zavolejte rovnou `POST /v1/answers`. Ušetříte jednu placenou operaci na otázku. Doména `faq` v ukázce takto funguje.

## Chyby

Chyby jsou ProblemDetails se stabilním `code` a `traceId`. Logujte `traceId`, nikdy klíč.

| Status | Význam | Co udělat |
|---|---|---|
| 400 | `validation_error` nebo `invalid_request`: neplatný vstup (limity, duplicitní názvy polí, špatná URL zdroje, null položky, vadný JSON). Obsahuje `detail` (anglická zpráva) a `field` (JSON cesta vadného vstupu, např. `sources[4].url`) | Opravit požadavek. Neopakovat beze změny. |
| 401 | `unauthorized`: neznámý, zrušený nebo vypnutý klíč (jedna obecná odpověď) | Zkontrolovat klíč a tenanta, neopakovat ve smyčce. |
| 413 | `request_too_large`: tělo přes 128 KiB | Poslat méně nebo kratší zdroje. |
| 429 | `concurrency_limit` nebo `quota_exceeded` | Počkat na hlavičku `Retry-After` (u kvóty do dalšího měsíce UTC), pak opakovat. |
| 502 | Chyba poskytovatele nebo neplatný výstup modelu (`provider_error`, `provider_invalid_response`, `provider_refused`) | Zobrazit náhradní obsah. Pokus mohl být započítán. |
| 503 | `provider_unavailable` nebo `service_unavailable` (chyba databáze, i při ověření klíče) | Zobrazit náhradní obsah, později zkusit znovu s odstupem. |
| 504 | `provider_timeout` | Neopakovat naslepo. |

Příklad těla chyby 400:

```json
{ "type": "about:blank", "title": "Invalid request", "status": 400,
  "detail": "source URL must be an absolute HTTPS URL without credentials.",
  "code": "validation_error", "field": "sources[4].url", "traceId": "0HN..." }
```

ZAIA placené operace sám neopakuje a nenabízí idempotenci. Timeout je nejednoznačný: poskytovatel mohl požadavek zpracovat a zpoplatnit a neúspěšná volání si drží rezervaci kvóty. 502 a 504 opakujte jen záměrně, s limitem, nikdy v automatické smyčce. Váš vlastní HTTP timeout má být delší než 30sekundový timeout modelu v službě.

## Hlavičky odpovědi

- `X-Request-Id`: na každé odpovědi `/v1`, shodné s `requestId`/`traceId` v těle. Uvádějte ho při podpoře.
- `X-Quota-Limit`, `X-Quota-Remaining`, `X-Quota-Reset`: na odpovědích `/v1/interpretations` a `/v1/answers`, kde proběhla rezervace kvóty (i chyby a 429 `quota_exceeded`), a na `GET /v1/usage` (ne s testovacím klíčem). `X-Quota-Reset` je začátek dalšího měsíce UTC ve formátu ISO 8601 (`2026-11-01T00:00:00Z`). Odpovědi s prázdnými `sources` (bez volání modelu) quota hlavičky nenesou.

## Rotace klíčů a spotřeba

1. Vytvořte druhý klíč (`POST /admin/tenants/{id}/keys`).
2. Přepněte volající backend na nový klíč.
3. Starý klíč zrušte: `DELETE /admin/tenants/{id}/keys/{keyId}`. Zrušení platí okamžitě.

Tenanta lze vypnout přes `PATCH /admin/tenants/{id}` s `{"enabled":false}`. Aktuální měsíční (UTC) počítadla vrací `GET /v1/usage`.

## Kontrolní seznam integrace

- [ ] Tenant vytvořen, klíč vydán jednou a uložen na serveru ve správci tajemství.
- [ ] Pole definována v rámci limitů, pro uzavřené množiny použito `allowedValues`.
- [ ] Kontext nese časové pásmo, údaje závislé na jazyku a vše, co model nemůže znát.
- [ ] Interpretace přeskočena, pokud jsou filtry už známé.
- [ ] Interpretované parametry validuje backend a vynucuje obchodní pravidla.
- [ ] Hledání uplatňuje autorizaci a viditelnost (nepublikované, skryté a zrušené záznamy se nikdy neposílají).
- [ ] Nejvýše 20 zdrojů, text do 4000 znaků, absolutní HTTPS URL na vaší doméně.
- [ ] Odpověď vykreslena jako escapovaný prostý text, karty zdrojů vytvořeny z vrácených `sources`.
- [ ] Prázdný výsledek, `needs_clarification` a `insufficient_sources` mají cestu pro uživatele.
- [ ] Tabulka chyb implementována, placené timeouty se nezkouší naslepo znovu.
- [ ] Ruční kontrola kvality provedena nad evaluační sadou (nikdy se nespouští automaticky).

## Referenční adaptéry

Ukázka `samples/IntegrationDemo` předvádí tři různé domény nad stejným nezměněným API: městské akce (data, časové pásmo, zrušení), produkty (výčty a dostupnost) a FAQ (volný text, bez interpretace, nepublikovaný článek se nikdy neposílá). Offline spuštění:

```
dotnet run --project samples/IntegrationDemo -- --offline --domain faq
dotnet run --project samples/IntegrationDemo -- --offline --domain events
dotnet run --project samples/IntegrationDemo -- --offline --domain products
```
