# inai.ad: подключение AI-приложения / агрегатора (v2)

Цель: отдельная маркированная рекламная карточка в уместный момент разговора либо ожидания генерации. Модель продолжает независимо отвечать пользователю. inai.ad не изменяет результаты генерации и не требует доступа к аккаунтам/ключам моделей.

## Что подготовить

Владелец регистрирует площадку в `/publisher`, получает одноразовый ключ; оператор одобряет заявку в `/pilot`. Владелец передаёт интегратору **UUID** и **секретный ключ ctx_pub\_...**. UUID указывается в `publisher_ids` кампании. Ключ хранится только в секретах backend партнёра. На нашей стороне должна быть активная кампания с совпадающей категорией, географией, языком, форматом и намерением. Сначала используйте отдельную тестовую площадку и кампанию.

Backend партнёра хранит:

```
CONTEXTA_URL=https://inai.ad
CONTEXTA_PUBLISHER_KEY=ctx_pub_...
```

Не используйте VITE_ / NEXT_PUBLIC_. Не передавайте ключ в iframe, браузер, мобильный клиент, prompt или Git.

## Быстрый путь для coding agent

Прочитайте `/sdk/integrator-agent.md`. Скачайте `server.ts`, `types.ts`, `classify.ts`, `need.ts`, `catalog.ts`, `discovery.ts`, `geo.ts`, `partner.ts` из `/sdk/` в одну серверную папку. React-проекту дополнительно нужен `AdCard.tsx`; его type-only импорт должен видеть соседний `types.ts`. Модули независимы от внутреннего src inai.ad и не требуют npm-пакета или Zod. Импорты с .ts подходят для Deno и современных bundler-проектов; для другого toolchain используйте его штатную сборку TypeScript.

Для подготовки файлов можно скачать `/sdk/install.mjs` и выполнить `node inai-install.mjs --check`, затем `node inai-install.mjs --install` из корня проекта. Установщик проверяет контрольные суммы, сохраняет существующие правки и создаёт папку `inai/`. Подключение к авторизации и реальному чату завершает ваш coding agent по задаче из кабинета площадки. Ключ в задачу не входит.

## 1. Backend: один серверный клиент

```ts
import { createContexta } from "./contexta/server.ts";

const ads = createContexta({
  baseUrl: process.env.CONTEXTA_URL!,
  apiKey: process.env.CONTEXTA_PUBLISHER_KEY!,
});
```

Для Supabase Edge Functions используйте `Deno.env.get(...)` вместо process.env. Вызов должен идти из backend; CORS для публичного decide специально не открыт.

## 2. Подготовьте доверенный контекст

У сервера уже должны быть авторизованная сессия, разрешение на рекламу, возрастная категория и допустимая география. Рекламный прокси не должен слепо принимать эти признаки из анонимного POST. Используйте проверку сессии и rate limit своего приложения. При unknown/minor рекламу пропускаем.

```ts
const result = await ads.decideConversation({
  context: {
    requestId: adOpportunityId, // UUID, стабилен при повторе одной операции
    sessionId: randomAdSessionId, // случайный ID, стабилен на рекламную сессию
    country: "KZ",
    // city необязателен: передавайте только явно известный город запроса.
    ...(trustedCitySlug ? { city: trustedCitySlug } : {}),
    language: "ru", // ru | kk | en
    placement: "chat_card", // или generation_wait
    age: trustedAge, // adult | minor | unknown
    adsAllowed: trustedConsent,
    sensitive: safetyResult.isSensitive,
  },
  messages: recentConversation, // role: user|assistant; content: string
  userTurn: sessionUserTurnCount, // монотонный номер пользовательской реплики
});
```

Имена `trustedCitySlug`, `trustedAge` и т.д. — точки подключения к вашему приложению, а не встроенные SDK-функции. Сообщения анализируются на сервере партнёра. В inai.ad уходят только категория, намерение, confidence, shouldShow, userTurn и условия показа, а не текст диалога.

Сохраняйте sessionId при переключении вкладок/чатов в пределах одной рекламной сессии; не создавайте новый ID на каждую реплику или ради обхода лимита. userTurn должен расти в том же пространстве сессии; если передаёте только окно истории, используйте отдельный полный счётчик. SDK использует число user-реплик в переданной истории только как запасной вариант.

## 3. Как понимается контекст

По умолчанию SDK анализирует до восьми последних реплик:

- распознаёт прямую потребность и более описательные формулировки;
- связывает «а подешевле?» с недавним пользовательским запросом;
- не считает приветствие, благодарность, учебное определение и простое упоминание темы рекламной потребностью;
- не переносит старую тему на новую задачу;
- при неоднозначности отказывается от показа.

Это прозрачные правила, не универсальная семантическая нейросеть. Для полноценного понимания подключите существующую модель агрегатора через `classifier`:

```ts
const ads = createContexta({
  baseUrl: process.env.CONTEXTA_URL!,
  apiKey: process.env.CONTEXTA_PUBLISHER_KEY!,
  classifier: async (messages) => {
    // ВАША функция вызывает ВАШУ модель с инструкцией из context-prompt.md.
    const output = await yourExistingModelClassifier(messages);
    return JSON.parse(output); // либо верните уже распарсенный объект
  },
});
```

Не вставляйте названия рекламодателей, ставки и рекламные тексты в задачу классификации. Модель определяет категорию и уместность, а inai.ad отдельно выбирает кампанию. Невалидный ответ, низкая уверенность или ошибка классификатора дают ad:null. Секреты не входят в prompt.

Формат вывода классификатора:

```json
{ "category": "industrial_marking", "intent": "research", "confidence": 0.91, "shouldShow": true, "need": { "productType": "marking_printer", "requiredAttributes": [], "excludedAttributes": [] } }
```

Категории и их стабильные идентификаторы находятся в `/sdk/catalog.ts` (34 направления). Восемь прежних идентификаторов сохранены. Для нового типа товара согласуйте `productType` с профилем кампании; свободный текст не сопоставляется магически с произвольным идентификатором. Намерения: buy, research, create. Инструкция модели: `/sdk/context-prompt.md`.

Город можно не передавать: национальная кампания не требует его, а кампания с городским ограничением не пройдёт без точного совпадения. Не подставляйте город из языка или ответа ассистента. Дополнительные модули `/sdk/discovery.ts` и `/sdk/geo.ts` помогают распознать исследование сервиса по публичным брендам из `/api/v1/ads/catalog` и нормализовать явно указанный город из пользовательских сообщений. Поиск стипендий, стажировок и возможностей может быть `research` без намерения купить; отсутствие города само по себе не означает недостаточный контекст.

Для генератора «создай картинку ресторана» означает create, а не покупку ужина. Кампания должна явно разрешать create. Не заставляйте классификатор находить рекламный повод в любой реплике.

## 4. Когда реклама вообще разрешена

Сервер inai.ad проверяет не только соответствие кампании, но и общую рекламную нагрузку. После миграции 0004, по умолчанию на площадку и sessionId:

- первая карточка с user-реплики, настроенной для площадки (по умолчанию третья; при переданном userTurn);
- минимум 180 секунд между выдачами;
- минимум четыре user-реплики между выдачами (при переданном userTurn);
- максимум три выданные карточки за скользящие 30 минут;
- максимум шесть за скользящие 24 часа;
- отдельно действуют частотный лимит кампании и бюджеты.

Это верхние пределы, а не план обязательных показов. Они не увеличиваются от числа рекламодателей. Лимиты учитывают выданные карточки, даже если их затем не просмотрели. В `/pilot` -> Площадки -> Ограничения рекламной нагрузки можно сделать ограничения строже/настроить их в допустимых пределах. `allowed_categories` ограничивает рекламу тематикой приложения. Пустой список разрешает все поддерживаемые категории.

Приоритет: право на показ и уместность -> общий pacing -> точное совпадение условий кампании -> специфичность города/намерения -> ротация равных кампаний -> бюджет/частота. Большая ставка не делает нерелевантную рекламу релевантной. Аукциона и платной позиции в v2 нет.

## 5. Доставка карточки отдельно от текста модели

Передавайте клиенту только `{ad,reason}`, никогда ключ. В обычном JSON API это отдельное поле ответа. В streaming-чате отправляйте отдельное событие `contexta_ad` после/рядом с ответом, без смешивания с provider delta и без добавления в prompt. Не сохраняйте карточку как обычное сообщение модели: это временный рекламный блок с отдельным сроком жизни.

Без React подключите:

```html
<script src="https://inai.ad/contexta-sdk.js" defer></script>
<div id="contexta-ad"></div>
```

После загрузки SDK и получения backend-ответа:

```js
let removeAd;
function updateAd(result) {
  removeAd?.();
  removeAd = result.ad
    ? window.Contexta.mount(document.getElementById("contexta-ad"), result.ad, {
        onDismiss: saveOptOutForSession,
      })
    : undefined;
}
```

При следующем ответе заменяйте прежнюю карточку, не складывайте рекламу в чат лентой. При навигации вызовите removeAd. Отказ должен сохраняться на стороне партнёра и выключать следующие запросы.

React:

```tsx
import { AdCard } from "./contexta/AdCard";
{
  ad && <AdCard ad={ad} onDismiss={saveOptOutForSession} />;
}
```

Виджеты считают impression после 50% непрерывной видимости в активной вкладке в течение секунды. Отдельно отправлять impression сразу после /decide нельзя.

## 6. Клики: обязательные правила

CTA — обычная ссылка с `href={ad.tracking.destinationUrl ?? ad.tracking.clickUrl}` либо штатный host openLink, вызванный синхронно по пользовательскому клику. Если API вернул destinationUrl, страница открывается напрямую, а учёт отправляется отдельно: POST на tracking.impressionUrl с `{type: "click", token: clickToken}`, где clickToken — параметр token из подписанного tracking.clickUrl. Используйте актуальный AdCard.tsx: он выполняет это без ожидания сети и сохраняет обычную навигацию. Если destinationUrl отсутствует, остаётся совместимый переход через clickUrl. Не используйте SPA Link или fetch вместо открытия страницы. Preview в кабинете не отправляет события.

Для Telegram/WebView/iframe со своим API открытия передайте callback:

```tsx
<AdCard ad={ad} openExternal={(url) => window.Telegram.WebApp.openLink(url)} />
```

JS-виджет также принимает `options.openExternal(url)`. Используйте только реальное API вашего host; не предполагается, что Telegram есть в обычном браузере. Для iframe нужны разрешения на открытие ссылок в конфигурации host. Не пытайтесь отключать браузерные защиты из SDK.

В v2 clickUrl подписан отдельно и действует семь дней. Резерв карточки и impression-token остаются десятиминутными. При валидной click-подписи сервер открывает заранее подписанный HTTPS destination, даже если учёт события временно недоступен. Истёкшие показы при этом не списываются задним числом. Неподписанный параметр redirect не поддерживается. После обновления получите НОВОЕ решение: старые clickUrl остаются старого формата.

## 7. Прямой HTTP, если SDK не подходит

POST `/api/v1/ads/decide`, Content-Type application/json, Authorization Bearer ключ площадки:

```json
{
  "requestId": "a1b546a2-894e-4321-94fd-f476a72bcd41",
  "sessionId": "random_stable_ad_session_001",
  "category": "industrial_marking",
  "country": "KZ",
  "city": "almaty",
  "language": "ru",
  "placement": "chat_card",
  "intent": "research",
  "age": "adult",
  "adsAllowed": true,
  "sensitive": false,
  "userTurn": 3,
  "opportunity": { "confidence": 0.91, "shouldShow": true, "source": "model" },
  "need": { "productType": "marking_printer", "requiredAttributes": [], "excludedAttributes": [] }
}
```

Поля userTurn/opportunity необязательны для совместимости, но обязательны для рекомендуемой v2-интеграции. Старые интеграции всё равно ограничены серверным cooldown и лимитом сессии.

Пустая выдача HTTP 200 — штатный результат: no_match, policy, early_turn, low_confidence, cooldown, turn_gap, session_limit, app_scope. Не повторяйте запрос до получения рекламы. Новый requestId нужен для нового момента, прежний — для повтора того же запроса. 400 — поля/UUID; 401 — ключ/подпись; 403 — формат; 409 — конфликт повторного requestId; 410 — истечение; 429 — rate limit; 503 — конфигурация/база/неприменённая миграция. Ошибка рекламы не должна ломать чат или генерацию.

## 8. Заявки

Backend партнёра вызывает `ads.reportLead(decisionId, randomCrmReference)`. Только после реальной заявки и предшествующего impression, максимум 30 дней. Одна заявка на решение; контакты клиента не отправлять. Модель не может сама объявить конверсию. Внешний сайт рекламодателя требует отдельного адаптера атрибуции: переход не равен заявке.

## 9. Приёмка на настоящем приложении

1. Проверить роль админа, секреты, активную кампанию и UUID площадки.
2. Стабильный sessionId; пользователь adult, consent=true, безопасный контекст.
3. До третьей user-реплики рекламы нет; затем подходящая карточка отдельно от ответа.
4. Карточка видна >1 секунды: один impression и один расход.
5. CTA открывает destination обычной навигацией; появляется клик.
6. Реплика «а подешевле?» понимается по предыдущей потребности, но не обходит cooldown.
7. Приветствия, домашка, неизвестная категория, отказ и смена темы не дают рекламы.
8. Даже со 100 кампаниями действует общий лимит и показы не копятся в истории.
9. Закрытие карточки сохраняет отказ; нет повторных показов.
10. Невалидный ключ/таймаут API не нарушает основной ответ.
11. Network/серверные логи клика показывают URL host, статус и код ошибки — не публикуйте токены или ключи.

Нельзя заявлять успешное подключение только по health=200 или отрисованному макету. Нужен полный цикл decision -> реальный view -> переход -> отчёт.


## Условия автономного пилота

Новые площадки до одобрения выключены и требуют `need.productType`. Товар, максимальная цена/валюта, обязательные/исключённые свойства и способ получения сопоставляются с проверенным `match_profile` кампании. Неизвестная цена или свойство не удовлетворяют заданному ограничению. Все суммы в minor units: 300 000 KZT = 30 000 000. Период подписки не моделируется: согласуйте сопоставимую цену за единицу товара или откажитесь от выдачи.

`pilot_paused`, `insufficient_context` и `no_match` — штатная пустая выдача. Общий дневной лимит включает ещё не истёкшие резервы, действует одновременно с лимитами кампаний и ручной паузой. До согласования лимита оператором пилот выключен. Не включайте автоповтор ради получения рекламы.

Ключ площадки остаётся на backend. При ротации обновите секрет партнёра; старый ключ прекращает работать. Первое одобрение кампаний/площадок и реальные условия предложения проверяет человек. Автоматизация не означает автоматическое пополнение баланса, банковское списание или выплату партнёру.

Пользовательская инструкция: `/docs`. `/sdk/partner-integration.md` и `/sdk/integrator-agent.md` — версии для разработчика и coding agent в UTF-8. Скачивайте `catalog.ts` вместе с остальными файлами SDK. Для нестандартных характеристик согласуйте канонические токены из профиля предложения с классификатором партнёра; подпись на карточке и свободный текст сами по себе не доказывают соответствие.


## Каталог внутри кампании

Один импорт сайта готовит одну общую карточку и до 100 позиций каталога в пределах размера кампании. Позиции доступны для проверки в раскрывающемся каталоге. Если направление не распознано, данные неполны или превышен размер, позиция остаётся источником и не включается автоматически. Владелец проверяет каталог перед модерацией.

При структурированном запросе движок выбирает конкретную подходящую позицию и возвращает её карточку и ссылку. Цена, валюта, свойства и способ получения должны выполняться у одной позиции одновременно. Её страна, язык и доступность могут дополнительно ограничить показ. Все позиции делят бюджет и частотные ограничения кампании. Установка SDK и preview не создают рекламные события.
