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: Сверху экрана покупок отрисовывается карусель пресетов быстрой вставки
[Underconstruction] Метод GET /api/v1/affinity/suggestions
Документация API: Извлечение персонализированных рекомендаций для закупок на основе разреженной матрицы Affinity и продуктов в холодильнике
- AFF-MIGRATION-101 Backlog — Таблица In-Memory Redis.
- AFF-MIGRATION-102 Backlog — Реализация скрипта Decay Strategy.
- AFF-MIGRATION-103 Backlog — Контракт данных топика Kafka контура фиксации пересчета матрицы.
- AFF-001 Ready for Development — создать внутренний метод FilterInboundEvents() в helper-service.
- AFF-002 Ready for Development — создать внутренний метод RegisterInteraction().
- AFF-003 Ready for Development — создать внутренний метод ApplyWeightDecay().
- AFF-004 Ready for Development — создать метод GET /api/v1/affinity/suggestions.
- GW-106 Ready for Development — Подключение эндпоинта к шлюзу.
- AFF-FRONTEND-201 Refinement (Уточнение) — Интерфейс показа рекомендаций
- AFF-FRONTEND-202 Refinement (Уточнение) — Внутренний метод Graceful Degradation
1 Функциональное назначение
Метод является ключевым элементом рекомендательного контура (Recall Engine) приложения, а также важной частью вероятностного контура (Stohastic Engine) симулятора и предназначен для вывода умных подсказок на экране «Импорт ресурсов / Покупки». Метод избавляет пользователя от необходимости вручную вбивать списки частых покупок, предугадывая его потребности. В симуляторе вектор параметров полученных методом используется в числе прочих для определения вероятности события.
Метод решает следующие архитектурные задачи:
- Экстраполяция разреженной матрицы (Sparse Matrix Optimization): Так как система хранит аффинити-скоры только для тех продуктов, которые пользователь реально покупал (200–250 уникальных SKU из 20 000 глобального справочника), метод выполняет
LEFT JOINс мастер-данными. Если личной записи нет, система налету подставляет общесистемный дефолтный вес продукта (например, Хлеб = 99, Авокадо = 15). - Динамическая фильтрация по остаткам (Inventory Cross-Check): Метод сопоставляет аффинити-скоры с текущим живым холодильником группы (
home_group_id). Продукты с высоким приоритетом, но имеющие физический остаток на складеquantity > 1.0(или выше критического порога), автоматически исключаются из выдачи, чтобы не плодить дубликаты. - Ранжирование дефицита: Формирует упорядоченный список из Топ-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 дефицитных товаров.
В настоящей публичной документации отображены не все шаги и сценарии для приложения в частности и для системы цифровых симуляторов бизнес-процессов в общем.
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.templatedPayload: { "receipt_id": "rcpt-777-uuid", "user_id": "user-888-uuid", "items": [{"product_id": "prod-milk-uuid"}, {"product_id": "prod-bread-uuid"}] } |
KafkaException: CommitFailedExceptionSerializationException (ошибка десериализации пакета транзакции покупок) |
Шаг 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 refusedRedis.TimeoutError: Command timed outRedis.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-uuidHeaders: Authorization: Bearer <JWT>, Query: product_id="prod-milk-uuid" |
DioException: send timeoutHTTP 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 refusedRedis.TimeoutError: Command timed out |
Шаг 8 (Affinity -> App) |
Сервис собирает DTO предложений, и шлюз транслирует пользователю текстовые подсказки перекрестных продаж (Cross-Sell). | HTTP Статус на выходе: 200 OKPayload ( 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; -- Защита от перегрузки экрана карусели пресетов во Flutter5.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 лишними элементами.