[Underconstruction] Метод GET /api/v1/affinity/suggestions

Документация API: Извлечение персонализированных рекомендаций для закупок на основе разреженной матрицы Affinity и продуктов в холодильнике

Published

July 2, 2026

1 Функциональное назначение

Метод является ключевым элементом рекомендательного контура (Recall Engine) приложения, а также важной частью вероятностного контура (Stohastic Engine) симулятора и предназначен для вывода умных подсказок на экране «Импорт ресурсов / Покупки». Метод избавляет пользователя от необходимости вручную вбивать списки частых покупок, предугадывая его потребности. В симуляторе вектор параметров полученных методом используется в числе прочих для определения вероятности события.

Метод решает следующие архитектурные задачи:

  1. Экстраполяция разреженной матрицы (Sparse Matrix Optimization): Так как система хранит аффинити-скоры только для тех продуктов, которые пользователь реально покупал (200–250 уникальных SKU из 20 000 глобального справочника), метод выполняет LEFT JOIN с мастер-данными. Если личной записи нет, система налету подставляет общесистемный дефолтный вес продукта (например, Хлеб = 99, Авокадо = 15).
  2. Динамическая фильтрация по остаткам (Inventory Cross-Check): Метод сопоставляет аффинити-скоры с текущим живым холодильником группы (home_group_id). Продукты с высоким приоритетом, но имеющие физический остаток на складе quantity > 1.0 (или выше критического порога), автоматически исключаются из выдачи, чтобы не плодить дубликаты.
  3. Ранжирование дефицита: Формирует упорядоченный список из Топ-20 рекомендаций, где на самом верху находятся любимые продукты пользователя, которые прямо сейчас полностью закончились (quantity == 0) или находятся в критическом минимуме (на исходе).

2 Протокол взаимодействия (HTTP Контракт)

  • Метод: GET
  • Маршрут: /api/v1/affinity/suggestions
  • Формат данных: application/json (только ответ)

2.1 Спецификация заголовков (HTTP Headers)

Заголовок Обязательный Описание Пример значения
Authorization Да Токен авторизации (Access Token). Бэкенд извлекает из него home_group_id и account_type. Bearer eyJhbGciOiJIUzI1Ni...
X-Request-ID Да Сквозной ID запроса для замера скорости аналитического джойна матриц req-aff-suggest-11aa

2.2 Спецификация структуры ответа (Response Body)

Метод возвращает упорядоченный массив подсказок. Поле trigger_reason сообщает интерфейсу Flutter, почему товар попал в рекомендации, для вывода красивых текстовых плашек (баннеров).

Поле Тип Описание Пример значения
master_product_id Integer Глобальный ID товара в справочнике Master Data 1024
affinity_score Integer Итоговый вычисленный балл предпочтения (от 0 до 100) 94
trigger_reason String Маркер причины рекомендации для UI "STOCK_EMPTY"

2.2.1 Перечень допустимых маркеров trigger_reason:

  • STOCK_EMPTY — продукт полностью закончился в холодильнике (баланс = 0.0), но имеет высокий affinity-скор.
  • STOCK_CRITICAL_LOW — продукт на исходе (остался критический float-минимум, например, менее 10% от стандартной закупки).

2.2.2 Пример JSON-ответа (Payload):

{
  "home_group_id": "group-772-alpha",
  "suggestions_count": 2,
  "suggestions": [
    {
      "master_product_id": 1024,
      "product_name": "Молоко 3.2% пастеризованное",
      "affinity_score": 94,
      "current_stock": 0.0,
      "unit": "л",
      "trigger_reason": "STOCK_EMPTY"
    },
    {
      "master_product_id": 2048,
      "product_name": "Яблоки Гренни Смит",
      "affinity_score": 82,
      "current_stock": 0.150,
      "unit": "кг",
      "trigger_reason": "STOCK_CRITICAL_LOW"
    }
  ]
}

3 Схема обработки запроса пользователя (Mermaid)

На диаграмме представлена логика формирования пресетов «Умного Аффинити». База данных сопоставляет личные скоры пользователя из разреженной матрицы с дефолтными мастер-данными, отсекает позиции, которые уже есть в достаточном количестве в холодильнике, и возвращает во Flutter топ-20 дефицитных товаров.

WarningВажное примечание

В настоящей публичной документации отображены не все шаги и сценарии для приложения в частности и для системы цифровых симуляторов бизнес-процессов в общем.

sequenceDiagram
    autonumber
    actor User as Пользователь (Экран Закупок)
    participant APP as Мобильное приложение (Flutter)
    participant AS as Сервис Аффинити (FastAPI)
    participant DB as База Данных (PostgreSQL)

    User->>APP: Открывает экран "Импорт ресурсов / Покупки"
    APP->>AS: HTTP GET /api/v1/affinity/suggestions (JWT в Header)
    activate AS
    
    note over AS: Шаг 3: Извлечение home_group_id и контекста из JWT-токена
    
    AS->>DB: Вызов SQL-запроса кросс-валидации матриц и остатков
    activate DB
    note over DB: Шаг 5: LEFT JOIN разреженной таблицы пользователя и глобальных SKU
    note over DB: Шаг 6: LEFT JOIN с fridge_inventory (Срез текущих живых остатков)
    note over DB: Шаг 7: Фильтрация (Остаток == 0 ИЛИ < 10%), сортировка по score и LIMIT 20
    DB-->>AS: Возвращает упорядоченную матрицу рекомендаций (макс. 20 строк)
    deactivate DB
    
    AS-->>APP: HTTP 200 OK (JSON с ранжированными подсказками закупок)
    deactivate AS
    note over APP: Сверху экрана покупок отрисовывается карусель пресетов быстрой вставки

4 Расшифровка шагов

Шаг Действие Параметры / Запросы / DTO Ошибки (Исключения / Статусы)
Шаг – (T_Cooking -> Helper) Бизнес-правило фильтрации: Регистратор интеракций отсекает события приготовления еды, так как они не отражают покупательские паттерны. Поток событий из топика bpds.inventory.out.meal_item.create сбрасывается на уровне фильтра Consumer-кода. Ошибки отсутствуют (срабатывает предикат if event_type == 'COOKING': return)
Шаг 1 (T_Clean_JSON -> Helper) Регистратор интеракций вычитывает валидный JSON-пакет новых чеков покупок из топика дистрибуции для анализа продуктового соседства. Kafka Consumer Poll Request:
Group_ID: "affinity-interaction-registrars", Topic: bpds.mdm.out.product.templated
Payload: { "receipt_id": "rcpt-777-uuid", "user_id": "user-888-uuid", "items": [{"product_id": "prod-milk-uuid"}, {"product_id": "prod-bread-uuid"}] }
KafkaException: CommitFailedException
SerializationException (ошибка десериализации пакета транзакции покупок)
Шаг 2 (Helper -> Affinity) Регистратор транслирует извлеченный список идентификаторов продуктов в менеджер рекомендаций по gRPC-каналу для расчета матрицы совпадений. gRPC Запрос:
RegisterInteraction(InteractionRequest)
Payload: user_id: "user-888-uuid", items_list: ["prod-milk-uuid", "prod-bread-uuid"]
gRPC Status: UNAVAILABLE (сервис affinity-service перегружен или недоступен)
gRPC Status: DEADLINE_EXCEEDED
Шаг 3 (Affinity -> Redis) Алгоритм совпадений: Сервис декомпозирует массив продуктов на уникальные пары и атомарно инкрементирует вес связи между ними в Redis. Redis Команды (Запись связей графа):
1. ZINCRBY "affinity:user-888-uuid:prod-milk-uuid" 1.0 "prod-bread-uuid"
2. ZINCRBY "affinity:user-888-uuid:prod-bread-uuid" 1.0 "prod-milk-uuid"
3. EXPIRE "affinity:user-888-uuid:prod-milk-uuid" 2592000
Redis.ConnectionError: Connection refused
Redis.TimeoutError: Command timed out
Redis.ClusterDownException
Шаг 4 (Cron -> Affinity) Системный планировщик Cron раз в сутки генерирует событие-триггер для запуска регламентного процесса угасания устаревающих интересов. Внутреннее расписание Cron-демона: 0 0 * * * (Каждую полночь). Запуск фонового обработчика triggerInterestDecay(). CronExecutionException (сбой планировщика, пропуск запуска регламентного окна)
Шаг 5 (Affinity -> Redis) Логика Decay: Менеджер рекомендаций вычисляет новые веса неактивных связей по формуле Score * 0.95 для понижения приоритета старых трат. Redis Lua-скрипт (Массовое снижение весов):
local keys = redis.call('KEYS', 'affinity:*') for _, key in ipairs(keys) do local items = redis.call('ZRANGE', key, 0, -1, 'WITHSCORES') for i=1, #items, 2 do redis.call('ZADD', key, items[i+1] * 0.95, items[i]) end end
Redis.ResponseError: Script time limit exceeded (блокировка Redis при слишком большом количестве ключей связей)
Шаг 6 (App -> Affinity) Пользователь открывает экран корзины, и приложение отправляет GET-запрос для получения сопутствующих товарных рекомендаций на основе текущего контекста. HTTP GET /api/v1/affinity/suggestions?product_id=prod-milk-uuid
Headers: Authorization: Bearer <JWT>, Query: product_id="prod-milk-uuid"
DioException: send timeout
HTTP 401 Unauthorized (истек сессионный JWT-токен пользователя)
HTTP 400 Bad Request
Шаг 7 (Redis -> Affinity) Менеджер рекомендаций запрашивает из упорядоченного множества Redis топ ассоциированных продуктов с наивысшими весами. Redis Команда:
ZREVRANGE "affinity:user-888-uuid:prod-milk-uuid" 0 4 WITHSCORES (Извлечение топ-5 связанных товаров)
Redis.ConnectionError: Connection refused
Redis.TimeoutError: Command timed out
Шаг 8 (Affinity -> App) Сервис собирает DTO предложений, и шлюз транслирует пользователю текстовые подсказки перекрестных продаж (Cross-Sell). HTTP Статус на выходе: 200 OK
Payload (AffinitySuggestionsResponseDTO):
{ "source_product_id": "prod-milk-uuid", "suggestions": [{ "product_name": "Хлеб", "product_id": "prod-bread-uuid", "confidence_score": 0.95 }] }
DioException: receive timeout (клиент потерял соединение, зашел в лифт или оборвал TCP-сессию до завершения загрузки)
Шаг 9 (Affinity -> Bupar) Менеджер рекомендаций отправляет асинхронное уведомление о пересчете матрицы связей в контур сквозного аудита процессов. gRPC Fire-and-Forget / Async RPC:
LogAffinityRecalculation(AffinityLogRequest)
Payload: user_id: "user-888-uuid", trace_id: "trace-push-delivery-uuid", activity: "AFFINITY_RECALCULATED"
gRPC Status: UNAVAILABLE (модуль bupar-audit-service перегружен, сообщение сбрасывается или улетает во внутренний повтор)

5 Спецификация серверной логики (SQL) и вилок исключений

5.1 Архитектурный SQL-запрос кросс-валидации Sparse Matrix (Шаги 5–7)

Для реализации бизнес-логики (бесшовное развертывание разреженной матрицы, LEFT JOIN с текущими остатками холодильника и жесткий LIMIT 20) на уровне PostgreSQL используется аналитический запрос со слиянием через COALESCE.

WITH current_fridge_stock AS (
    -- Агрегируем текущие остатки холодильника пользователя по каноническим SKU
    SELECT LOWER(product_name) AS food_name, SUM(quantity) AS total_qty, unit
    FROM fridge_inventory
    WHERE home_group_id = \$1
    GROUP BY LOWER(product_name), unit
)
SELECT 
    mp.id AS master_product_id,
    mp.product_name,
    -- Развертывание Sparse Matrix: если личного скора нет, берем системный дефолт
    COALESCE(upa.score, mp.default_system_affinity) AS affinity_score,
    COALESCE(cfs.total_qty, 0.0)::FLOAT AS current_stock,
    mp.unit,
    -- Динамическое определение триггера для интерфейса Flutter
    CASE 
        WHEN COALESCE(cfs.total_qty, 0.0) = 0.0 THEN 'STOCK_EMPTY'
        ELSE 'STOCK_CRITICAL_LOW'
    END AS trigger_reason
FROM master_products mp
-- Шаг 5: LEFT JOIN с разреженной таблицей личного аффинити пользователя
LEFT JOIN user_product_affinity upa 
    ON upa.master_product_id = mp.id AND upa.home_group_id = \$1
-- Шаг 6: LEFT JOIN со срезом текущих остатков склада
LEFT JOIN current_fridge_stock cfs 
    ON cfs.food_name = LOWER(mp.product_name) AND cfs.unit = mp.unit
-- Шаг 7: Фильтрация дефицита и ранжирование по силе предпочтения
WHERE 
    COALESCE(cfs.total_qty, 0.0) = 0.0 -- Продукт полностью закончился
    OR (COALESCE(cfs.total_qty, 0.0) > 0.0 AND COALESCE(cfs.total_qty, 0.0) < mp.critical_min_threshold) -- Продукт на исходе
ORDER BY affinity_score DESC, current_stock ASC
LIMIT 20; -- Защита от перегрузки экрана карусели пресетов во Flutter

5.2 Спецификация структуры таблиц PostgreSQL (Sparse Matrix Architecture)

Схема базы данных поддерживает концепцию разреженной матрицы: записи в user_product_affinity генерируются точечно строго в моменты транзакций.

-- Таблица 1: Глобальный справочник продуктов (Мастер-данные, 20 000+ строк)
CREATE TABLE master_products (
    id SERIAL PRIMARY KEY,
    product_name VARCHAR(255) NOT NULL UNIQUE,
    default_system_affinity INT NOT NULL DEFAULT 15,       -- Дефолтный скор (Хлеб=99, Авокадо=15)
    critical_min_threshold NUMERIC(10, 4) NOT NULL DEFAULT 0.1000, -- Порог дефицита (например, < 0.1 кг)
    unit VARCHAR(20) NOT NULL                              -- "кг", "шт", "л"
);

-- Таблица 2: Разреженная матрица личного сходства пользователя (Sparse Matrix)
CREATE TABLE user_product_affinity (
    id BIGSERIAL PRIMARY KEY,
    home_group_id VARCHAR(50) NOT NULL,
    master_product_id INT NOT NULL REFERENCES master_products(id) ON DELETE CASCADE,
    score INT NOT NULL CHECK (score >= 0 AND score <= 100), -- Балл предпочтения от 0 до 100
    last_interaction TIMESTAMP WITH TIME ZONE DEFAULT NOW(), -- Дата для Крон-затухания
    CONSTRAINT unique_group_product_affinity UNIQUE (home_group_id, master_product_id)
);

-- Составной индекс для мгновенных LEFT JOIN операций
CREATE INDEX idx_user_affinity_composite ON user_product_affinity(home_group_id, master_product_id);

5.3 Вилки исключений и обработка ошибок

5.3.1 Сценарий А: Новая семья / Чистый профиль (HTTP 200 OK на базе системных дефолтов)

  • Условие: Новая группа пользователей только что зарегистрировалась, в таблице user_product_affinity нет ни одной строки (абсолютно пустая Sparse Matrix).
  • Действие системы: SQL-запрос отработает корректно за счет COALESCE. СУБД подтянет топ-20 продуктов, имеющих наивысший default_system_affinity (Хлеб, Молоко, Яйца). Семья получит базовый качественный набор пресетов первой необходимости.

5.3.2 Сценарий Б: Переполнение холодильника (HTTP 200 OK с пустым массивом suggestions)

  • Условие: Пользователь забил холодильник продуктами до отказа, физический объем абсолютно всех SKU на складе строго выше порога critical_min_threshold.
  • Действие системы: Фильтр WHERE отсечет все строки. Метод вернет статус 200 OK с пустым массивом suggestions: []. Интерфейс Flutter скроет горизонтальную карусель пресетов с экрана покупок, не перегружая UI лишними элементами.

5.3.3 Сценарий В: Таймаут СУБД при тяжелой аналитической сборке (HTTP 503 Service Unavailable)

  • Условие: База данных перегружена параллельными транзакциями списания, и JOIN-операция по 20 000 строкам превысила таймаут пула соединений в 1500 мс.
  • Действие FastAPI: Шлюз перехватывает исключение и возвращает код ошибки ERR-AFFINITY-TIMEOUT. Flutter-приложение гасит карусель рекомендаций, позволяя пользователю продолжить покупки через ручной ввод, не блокируя основной процесс импорта ресурсов.