Это не рассказ про кейс заказчика и не отчёт о внедрении Luna Decisions. Ниже — схема мониторинга объявлений, фрагмент логики сравнения и предложение по добавлению отдельного слоя решений. Для пояснения показаны отдельные фрагменты воркфлоу и таблиц со скрытыми данными; их публикация согласована с заказчиком. Переписку и параметры инфраструктуры не раскрываю. Реальных вызовов Decisions API и проверки интеграции в n8n здесь нет; проверка JavaScript на синтетических данных не заменяет такую проверку.
Где заканчивается парсинг и начинается решение
Предыстория: около месяца назад подробно обсуждал архитектуру этого проекта в личке с хабровчанкой (не имею права раскрывать ник). Она набросала мощные хардкорные идеи: прикрутить предохранитель на объем выдачи, внедрить счетчик пропусков missed_runs для отсечения ложных удалений и использовать сессии Telethon. До реализации её паттернов руки всё никак не доходили (да и заказчик активно пользовался проектом). Момент настал, а вчера OpenAI выкатила GPT-6 Luna Decisions с её Decisions API. И тут мне в голову пришла альтернативная мысль, о которой эта статья. Мне крайне интересно мнение со стороны касательно изменения проекта.
В мониторинге объявлений, сбор данных — только половина задачи. Можно регулярно забирать выдачу, сравнивать цены и присылать сводку. Но затем возникает другой вопрос: о каком изменении стоит сообщить отдельно, а какое можно оставить в журнале?
Мне интересна именно эта граница. В схеме на n8n изменения находит обычный код, а текстовая модель составляет отчёт. Пока человек читает весь отчёт, это удобное разделение. Если хочется отправлять отдельные уведомления, оценку из текста уже нужно переделать в машинное решение.
В описании Luna Decisions на OpenRouter меня заинтересовал подход: модель возвращает типизированные оценки, а приложение само выбирает действие. Не «напиши, насколько это интересно», а отдельный результат, который можно проверить, сохранить и сравнить с порогом.
Но из этого ещё не следует, что такой API нужен в моём случае. Ниже разберу, куда его можно встроить, чего не хватает во входных данных и с чем я бы сравнивал результат. Возможно, после сравнения окажется, что достаточно нескольких условий в Code-ноде. Это тоже полезный исход.
Минимальный контур
Для обсуждения достаточно такой схемы:
```textРасписание / ручной запуск Telegram бот → HTTP Request: получение объявлений → Hata_Normalizer: плоские поля → Чтение предыдущего состояния → Dispatcher: сравнение по ad_id → Обновление витрины + запись событий → Build_Prompt: статистика и события → Текстовая модель: сводка → Telegram
На фрагменте workflow видны три входа: обработка ошибок, запуск по расписанию и команды из Telegram. Плановый и ручной запуск сходятся к одной цепочке получения и нормализации данных.
Слой Decisions на этом фрагменте отсутствует. В качестве источника здесь площадка с объявлениями о продаже недвижимости. Способы доступа и настройки прокси оставляю за рамками: это отдельная тема, вместе с ограничениями площадки недвижимости, частотой запросов и полнотой выдачи. Прокси сам по себе не гарантирует ни разрешённый доступ, ни корректный сбор.
Google Sheets в такой схеме — одновременно витрина для человека и хранилище предыдущего состояния. Для небольшой выборки это удобный старт. Но таблица не решает вопросы конкурентных запусков, атомарности обновления и дедупликации. Если ручной запуск пересекается с плановым, это нужно учитывать независимо от модели.
После нормализации данные представлены плоскими строками: идентификатор, ссылка и основные параметры объекта. Такая витрина удобна для просмотра, но сама по себе ещё не содержит историю изменений.
Normalizer извлекает ad_id, ссылку, цены из calculator в у.е. и рублях, комнатность, площадь, этаж, этажность, год постройки, район, адрес и время публикации. Две цены полезны, но из калькулятора = ещё не исходная валюта продавца. По ним нельзя уверенно установить причину изменения.
Есть и более приземлённые проверки до всякого ИИ. Если площадь пришла как Н/Д, её нельзя использовать в расчёте цены за метр. Отсутствующая цена не должна участвовать в сравнении как ноль. Время публикации нужно проверять как дату, а не подставлять вместо него ID. Если источник не вернул ссылку, лучше сохранить null, чем собирать непроверенный URL.
Что действительно делает Dispatcher
Ниже — текущая логика сравнения с нейтральными комментариями. Название Hata_Normalizer — имя ноды, из которой читаются данные; её содержимое выполняет нормализацию. Этот фрагмент показывает исходную логику, а не исправленную универсальную реализацию.
const incoming = \$('Hata_Normalizer').all().map(i => i.json);const sheet = \$('Read_Active_Sheet').all().map(r => r.json);const sheetMap = new Map(sheet.map(r => [String(r.ad_id), r]));const incomingMap = new Map(incoming.map(i => [String(i.ad_id), i]));const now = new Date();const upsert = [];for (const item of incoming) { const id = String(item.ad_id); const old = sheetMap.get(id); const listed = new Date(item.list_time); const days = isNaN(listed) ? null : Math.floor((now - listed) / 86400000); const timeTag = days === null ? 'н/д' : days < 1 ? 'Сегодня' : days <= 4 ? 'до 4 дней' : days <= 14 ? '4-14 дней' : days <= 42 ? '14-42 дней' : '42+ дней'; let event = ''; if (!old) { event = 'НОВИНКА 🆕'; } else if (item.price_usd < Number(old.price_usd)) { const drop = Math.round(Number(old.price_usd) - item.price_usd); const tag = drop <= 1500 ? '(500-1500)' : drop <= 2900 ? '(1501-2900)' : '(2901+) 🔥'; event = `УЦЕНКА -$${drop} ${tag}`; } upsert.push({ json: { ...item, time_tag: timeTag, last_event: event || 'Актуально', log_event: event, timestamp: now.toISOString() }});}for (const [id, row] of sheetMap) { if (!incomingMap.has(id)) { const alreadySold = String(row.last_event || '').includes('ПРОДАНО'); upsert.push({ json: { ad_id: id, link: row.link, address: row.address, district: row.district, price_usd: row.price_usd, time_tag: row.time_tag || 'н/д', last_event: 'ПРОДАНО/СНЯТО 🏁', log_event: alreadySold ? '' : 'ПРОДАНО/СНЯТО 🏁', timestamp: now.toISOString() }}); }}return upsert;
Что здесь важно для будущей интеграции:
1) Событие для журнала лежит в log_event. Пустая строка означает отсутствие события. last_event показывает состояние строки в витрине.
2) Предыдущая цена остаётся в прочитанном снимке таблицы. В выход Dispatcher она не переносится. История цен тоже не появляется автоматически.
3) Снижение сравнивается в USD. Причину снижения код не устанавливает.
4) Подпись (500-1500) неточна: в эту ветку попадает любое снижение до 1500 у.е. включительно, даже 1 у.е.. Для правил нужны числовые дельты, а не разбор подписи.
5) При price_usd: null сравнение может дать ложную уценку. Валидация цены должна происходить до этого условия.
В журнале хорошо видна проблема текстовых диапазонов: снижение на 109 у.е. получает подпись (500-1500). Код действительно отправляет в эту группу все снижения до 1500 у.е., без нижней границы 500 у.е..
(500-1500). Подпись события не заменяет числовую дельту.На скриншоте показан лист журнала с колонкой event; в приведённом коде поле события называется log_event. Это разные имена на уровне таблицы и выхода ноды поэтому маппинг при записи нужно проверять отдельно.
Серия близких по размеру снижений наводит на мысль о валютном пересчёте, но по этому журналу причину установить нельзя.
И самое существенное: исчезло из выдачи — НЕ значит продано. Объявление могли снять, переопубликовать, вытеснить за пределы загруженных страниц. Мог измениться фильтр, а сбор мог завершиться частично.
В дальнейшем я бы использовал статус «не найдено в последнем полном сканировании» и подтверждал отсутствие в нескольких успешных прогонах. Это предложение, а не поведение приведённого кода. До проверки полноты выдачи вообще нельзя массово менять статус отсутствующих объектов.
Почему нельзя просто попросить модель написать отчёт ?!
Build_Prompt собирает активные строки, среднюю цену, среднюю цену за метр, распределение по районам и свежести. События берутся из Dispatcher с фильтром по log_event.
Дальше в текстовый запрос попадают, среди прочего, такие задания: «…По новинкам и уценкам дать короткую оценку: интересна квартира для флипа или нет, и почему. Выбрать «флип дня» из дешёвых объектов или объектов с уценкой…»
При этом строка события содержит метку, адрес, цену и ссылку. Подробное состояние квартиры, стоимость ремонта, сопоставимые сделки и ликвидность в этот запрос не передаются. Ссылка сама по себе не означает, что LLM открыла объявление.
Получается вроде как неудобная ситуация: от модели ждут содержательного инвестиционного объяснения, но дают ей преимущественно сводные показатели. Я бы сначала убрал обязательный "флип дня" и оставил описание наблюдаемых изменений. Отдельно исправил бы "продано за период наблюдения" на "объявления, отмеченные как отсутствующие": количество таких строк не доказывает ни продажи, ни число сделок за период.
Для отчёта текстовая модель полезна. Но решение об отдельном уведомлении лучше хранить отдельно от её формулировок. Тогда можно проверить не только красоту сводки, но и то, почему система выбрала действие.
Почему не условия в коде или structured output?
Если совсем честно, то это был бы первый вопрос, который я сам задал такой статье:
|
ЗАДАЧА |
С ЧЕГО БЫ Я НАЧАЛ |
|
Найти снижение на |
Числовое сравнение в коде |
|
Проверить отсутствие объекта |
Полнота сбора и повторные проверки |
|
Отделить валютный пересчёт |
Исходная цена, валюта и история; затем расчёт |
|
Найти перепубликацию с новым |
Сопоставление признаков разных объявлений |
|
Составить читаемую сводку |
Текстовая |
|
Проверить неоднозначное изменение по контексту |
Правила как |
Обычная LLM со структурированным выводом тоже на мой взгляд достойный кандидат. Свободный текст — не единственный способ получить от неё метку. Такой ответ всё равно требует валидации, но отдельный Decisions API не становится необходимым только потому, что он возвращает числа.
Моя мини-гипотеза скромнее: отдельный интерфейс решений может оказаться удобнее для повторяемых оценок, логирования и настройки маршрутов. Это нужно сравнить с правилами и структурированным ответом обычной модели на одинаковых событиях. Если качество то же, а поддержка сложнее, причин добавлять новый сервис немного.
Что известно о Luna Decisions, а что ещё предстоит проверить
В доступном описании Luna Decisions на OpenRouter заявлен подход с общим state и именованными вопросами.
На скриншоте карточки модели указаны тариф 0.10 у.е. за миллион входных токенов и 0(!) у.е. за выходные, контекст 1.1M и дата релиза 6 октября 2026 года. Это сведения на момент снимка; перед запуском доступность модели и тариф нужно сверить конечно же.
В материалах SDK фигурируют три типа:
|
Тип |
Заявленный смысл |
|
noul |
Оценка бинарного вопроса числом от 0 до 1 |
|
choice |
Выбор из заданных вариантов с оценками вариантов |
|
score |
Оценка по упорядоченной рубрике |
noul здесь записано с буквой l, не с цифрой 1. В описанной схеме для него используются instructions, без criteria.true и criteria.false. Для choice критерии представлены словарём вариантов, для score — массивом уровней.
В исходных материалах указан POST https://openrouter.ai/api/alpha/decisions и идентификатор openai/gpt-6-luna-decisions. Это сведения из описания и SDK, не подтверждённый здесь живым запросом. Тело запроса приведено как вариант для проверки, а не как рабочая инструкция «скопируйте — и заработает».
Перед реальным подключением нужно сверить доступность модели и эндпоинта, авторизацию, сериализацию state, обязательные поля, форму ответа, лимиты и актуальный тариф. Точный формат probabilities я не считаю установленным. Индексацию score тоже сначала нужно проверить, а не угадывать по подписям уровней.
Поскольку вопросы относятся к общему state, для первого эксперимента я бы отправлял одно событие на запрос. Это значительно упрощает сопоставление и ошибки. Ограничения пакетной обработки нужно выяснять отдельно. Из этой схемы конечно определённо не следует, что пакетная обработка принципиально запрещена.
Первый эксперимент: подозрительные изменения цены
Лично я бы не начинал с вопроса «покупать ли эту квартиру» и не запускал сразу три модельные ветки.
Первая задача скромнее: для уже обнаруженной уценки предложить метку «обычное изменение», «нужна проверка цены» или «недостаточно данных». Это предварительная сортировка сугубо для просмотра человеком, не установление причин и не инвестиционная рекомендация.
Даже для неё полезно больше контекста: предыдущие цены, несколько наблюдений, параметры объекта. Проще говоря, для валютного шума дополнительно нужны исходная валюта и исходная цена. Для перепубликаций — сравнение разных ID, например по нормализованному адресу, площади и другим доступным признакам. Две точки по одному ID этого не дают.
Потенциал флипа я пока оставил бы за пределами эксперимента. Без состояния объекта, бюджета ремонта, сопоставимых предложений и других существенных признаков красивый score скорее создаст ложную точность.
Куда встроить эксперимент
Предлагаемая в моих задумках схема отличается от последовательной структуры типа «сначала модель, потом всё остальное»:
Предыдущее состояние + нормализованная выдача → Dispatcher ├─→ Витрина + журнал → обычная сводка → Telegram └─→ События с валидными ценами + предыдущий снимок → числовой baseline → кандидат запроса к Luna → HTTP Request с ограниченным временем ожидания → проверка ответа / fallback → журнал эксперимента, БЕЗ автоматических уведомлений
Ветки на схеме сугубо означают логическое разделение, а не обещание параллельного выполнения нод n8n. Если нужно независимо выполнять медленные вызовы, я бы желал вынести эксперимент в отдельный воркфлоу или очередь. Основной журнал и сводка не должны зависеть от успеха Decisions API.
Следующая сode-нода = предлагаемое дополнение, не текущий Dispatcher. Она работает в режиме Run Once for All Items, получает его выход и использует уже прочитанный предыдущий снимок. Дельты нужно собрать до потери старого состояния = повторно читать обновлённую таблицу вместо снимка нельзя.
// Code: Build_Decision_Experimentconst previous = new Map( $('Read_Active_Sheet').all().map(i => [String(i.json.ad_id), i.json]));function positiveNumber(value) { if (typeof value !== 'number' && typeof value !== 'string') return null; if (typeof value === 'string' && value.trim() === '') return null; const n = Number(value); return Number.isFinite(n) && n > 0 ? n : null;}// Иллюстративные настройки baseline, не подобранные на разметке пороги.const MIN_DROP_USD = 1500;const MIN_DROP_RATIO = 0.03;const result = [];for (const { json: current } of $input.all()) { if (!String(current.log_event || '').startsWith('УЦЕНКА')) continue; const old = previous.get(String(current.ad_id)); const before = positiveNumber(old?.price_usd); const after = positiveNumber(current.price_usd); if (before === null || after === null || after >= before) continue; const dropUsd = before - after; const dropRatio = dropUsd / before; const state = { price_usd_before: before, price_usd_after: after, price_byn_before: positiveNumber(old?.price_byn), price_byn_after: positiveNumber(current.price_byn), drop_usd: dropUsd, drop_ratio: dropRatio, size_total: positiveNumber(current.size_total), rooms: positiveNumber(current.rooms), floor: positiveNumber(current.floor), total_floors: positiveNumber(current.total_floors), year_built: positiveNumber(current.year_built), source_currency: null, history: null }; result.push({ json: { ad_id: String(current.ad_id), observed_at: current.timestamp, baseline_candidate: dropUsd >= MIN_DROP_USD && dropRatio >= MIN_DROP_RATIO, baseline_version: 'absolute-and-relative-v1', question_version: 'price-review-v1', body: { model: 'openai/gpt-6-luna-decisions', state, questions: { price_review: { type: 'choice', instructions: 'Оцени только необходимость ручной проверки изменения цены. Не делай выводов о продаже, валюте продавца или выгодности покупки. При нехватке данных выбирай insufficient_data.', criteria: { regular_change: 'Данные не дают оснований подозревать ошибку цены; это не подтверждение её правильности', needs_review: 'В изменении или параметрах есть признаки возможной ошибки, требующие ручной проверки', insufficient_data: 'Данных недостаточно даже для предварительной проверки' } } } } }});}return result;
history: null и source_currency: null здесь намеренные = эти данные не появляются из воздуха. Адрес, ссылка и клиентские идентификаторы в модельный state не передаются. Внутренний ad_id остаётся рядом с телом запроса для сопоставления результата, а HTTP-нода должна отправлять только body.
Числовой baseline помечает снижение, которое одновременно достигло 1500 у.е. и 3%. Это простое правило приоритета, а не детектор ошибок. Модельная метка проверяет другой аспект: необходимость просмотра. Поэтому сначала их нужно оценивать раздельно, а затем сравнивать итоговую политику уведомлений на одной человеческой разметке. Тут ведь в принципе нельзя объявлять модель победителем по двум разным задачам.
Тело запроса ещё предстоит проверить по актуальному API. Для HTTP Request я бы использовал Credentials, явный таймаут и отдельную обработку ошибок. Автоматические повторы использовать только ограниченные, с задержкой и после выяснения семантики повторного запроса. Бесконечный retry может увеличить расходы (конечно же не на громоздкие суммы), однако не улучшить данные.
Ответ с числом всё равно нужно проверять
Типизированный API не отменяет валидацию. Отсутствующая оценка, строка вместо числа, неизвестная метка или число вне диапазона должны вести в fallback, а не молча считаться типа «неважным» событием.
Чтобы не привязывать роутер к неподтверждённому формату probabilities, ниже он принимает внутренний формат: decision = { label, probability }. probability — это оценка выбранного класса, а не вероятность выгодной покупки. Адаптер из реального ответа API в этот формат нужно написать после проверки контракта… здесь нет полного готового воркфлоу.
// Code: Route_Decision_Experiment, Run Once for All Itemsconst LABELS = new Set(['regular_change', 'needs_review', 'insufficient_data']);const MIN_CLASS_PROBABILITY = 0.80; // Только стартовое допущение.function decide(decision, baselineCandidate) { if (typeof baselineCandidate !== 'boolean') { return { route: 'fallback', reason: 'invalid_baseline' }; } if (!decision || typeof decision !== 'object' || Array.isArray(decision)) { return { route: 'fallback', reason: 'missing_decision' }; } if (!LABELS.has(decision.label)) { return { route: 'fallback', reason: 'unknown_label' }; } const p = decision.probability; if (typeof p !== 'number' || !Number.isFinite(p) || p < 0 || p > 1) { return { route: 'fallback', reason: 'invalid_probability' }; } if (decision.label === 'insufficient_data' || p < MIN_CLASS_PROBABILITY) { return { route: 'review', reason: 'uncertain' }; } if (decision.label === 'needs_review') { return { route: 'review', reason: 'possible_price_error' }; } return { route: baselineCandidate ? 'candidate' : 'log', reason: baselineCandidate ? 'baseline_thresholds_met' : 'below_baseline_thresholds' };}return $input.all().map(({ json }) => ({ json: { ...json, ...decide(json.decision, json.baseline_candidate) }}));
candidate в этом эксперименте — запись в журнале = не отправка в Telegram. review тоже сначала означает очередь разметки. Успешный HTTP ответ нужно сопоставить с исходным событием: нельзя предполагать, что нода автоматически сохранит ad_id, baseline и весь входной JSON.
Даже 0.80 не означает, что восемь из десяти таких меток окажутся правильными. Это уонечно же гипотеза о пороге, которую проверяют на размеченных данных. Воспроизводимость оценок при повторе одного входа уже отдельная проверка.
Как проверить пользу, не включая уведомления
Я бы начал с shadow mode: обычная сводка работает как раньше, а эксперимент только пишет результаты. Правила и модель получают одинаковые события. Человек размечает, требовало ли изменение отдельного внимания и были ли признаки ошибки. Неопределённые случаи остаются неопределёнными, а не превращаются автоматически в отрицательные примеры.
Чтобы не пропустить ошибки самой модели, смотреть нужно не только review, но и выборку из log, candidate и fallback. Разметка лишь тех событий, которые модель посчитала подозрительными, даст слишком уж, так сказать, «сладкую» картину.
Минимальный журнал эксперимента:
-
идентификатор события и время наблюдения
-
разрешённый к хранению снимок признаков; дельты и результат baseline;
-
модель, версия вопроса, версия правил и порог;
-
проверенная модельная метка и оценка;
-
маршрут, причина fallback, длительность и расходы, если они доступны;
-
человеческая метка и время её выставления.
Настройку порогов и финальную проверку я бы разделил по времени. А то иначе легко подобрать правило под уже просмотренную выборку и принять это за качество на новых событиях.
Меня интересовали бы четыре результата: доля лишних кандидатов на уведомление, доля пропущенных важных изменений, объём ручной проверки и доля fallback. Плюс задержка и стоимость. Ведь согласитесь: модель не улучшает этот баланс относительно правил = оставлять её в контуре незачем.
Уведомления, дубли и отказоустойчивость
Даже после успешного shadow mode уведомление будет приходить после обнаружения изменения, а не после фактической уценки продавцом. При двух сканированиях в сутки никакой API не сделает мониторинг мгновенным. Учащение сборов = отдельное решение с учётом доступа к источнику и нагрузки.
Дедупликация тоже остаётся в коде. Повторная обработка одной пары цен = дубль. Но новая уценка того же объекта через день может быть новым важным событием. Поэтому одного ad_id и окна времени недостаточно = нужен ключ события с ценами до и после и сохранённое состояние его обработки. Если цена вернулась назад и позже снова снизилась, одинаковая пара цен может возникнуть повторно = это нужно различать по переходу состояния.
Ошибка модели не должна отменять запись наблюдения. В fallback событие сохраняется и продолжает идти по обычному пути. Контролировать нужно не только ошибки API, но и число загруженных страниц, размер выдачи, пустые ответы, длительность сбора и пересечения запусков. Error Trigger сообщает о сбое выполнения, но не обнаружит все случаи, когда воркфлоу успешно записал неправильные данные.
Наконец, до передачи даже обезличенных признаков внешнему провайдеру нужны согласование и проверка условий обработки данных. Отсутствие адреса в JSON само по себе не даёт разрешения использовать данные проекта.
Сколько это может стоить
Без живых запросов точную сумму я бы не обещал. Для оценки модельной части достаточно самой обычной формулы: перемножаем число событий за месяц на средний размер запроса в токенах и на тариф за миллион, а потом просто делим итоговую цифру на миллион
Это только входная модельная часть. Если тарифицируются выход, запросы или другие составляющие, их нужно добавить. Здесь также нет расходников на прокси, сервера, хранения, повторов, обычной текстовой модели и времени на разметку. Дешёвый запрос не означает дешёвую поддержку всего контура.
Вопросы к сообществу
Пока я бы ограничился проверкой изменений цены, числовым baseline и shadow mode без автоматических уведомлений. Контракт API, качество меток и смысл возвращаемых оценок ещё нужно подтвердить.
Интересны три вопроса:
1. Что в этой схеме вы бы вообще не отдавали llm? Где правил достаточно, а где действительно появляется неоднозначный контекст?
2. Каких признаков не хватает для полезной проверки изменения цены? Особенно если нужно различать ошибку, валютный пересчёт и перепубликацию, не заставляя модель угадывать.
3. По каким результатам shadow mode вы разрешили бы отдельные уведомления? С чем сравнивали бы и как оценивали бы пропуски и лишние сигналы?
Если Decisions API здесь избыточен и ту же задачу проще закрыть кодом или structured output обычной модели, то тоже интересно понять почему. Именно это хочется проверить до внедрения.
Всем добра! И спасибо за внимание)
ссылка на оригинал статьи https://habr.com/ru/articles/1091792/