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

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

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

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

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

```
CONTEXTA_URL=https://dev-idea-accelerator.lovable.app
CONTEXTA_PUBLISHER_KEY=ctx_pub_...
```

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

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

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

## 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,
    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 }
```

Категории: industrial_marking, construction, interior, restaurants, business_software, travel, retail, creative_tools. Намерения: buy, research, create. Инструкция модели: `/sdk/context-prompt.md`.

Для генератора «создай картинку ресторана» означает 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://dev-idea-accelerator.lovable.app/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 должен быть обычным `<a href={ad.tracking.clickUrl} target="_blank" rel="sponsored noopener noreferrer">` или штатным host openLink, вызванным синхронно по пользовательскому клику. Не используйте SPA Link для внешней ссылки. Не вызывайте fetch(clickUrl) вместо навигации: это не открывает страницу. Не делайте preventDefault без реального открытия URL.

Для 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" }
}
```

Поля 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 -> переход -> отчёт.

