этап 2 · переделки П1–П7 · офлайн-тесты · валидатор SQL · живые вопросы
Дата прогонов — 24.10.2026. Стенд: своя PostgreSQL 16 в каталоге проекта (порт 55432,
локальный сокет, роль только на чтение purch_ro), витрина загружена из синтетики комплекта
одной командой bash scripts/load_postgres.sh. Модель — qwen3.6-35b через шлюз
https://ai.sreda-ai.ru/v1/chat/completions, ключ берётся из переменной окружения проекта
LLM_API_KEY (в коде, журналах и ответах ключа нет). Сервис отвечает двумя способами на
одном и том же ядре: обычным обращением POST /ask и чат-протоколом MCP (инструмент
ask_purchasing) — оба проверены настоящим вызовом и дают одинаковый маршрут и одинаковое
число в ответе (раздел 5).
Это итоговый отчёт этапа 2 с учётом правок по замечаниям приёмки (область видимости таблиц
и расширенный белый список функций в проверке SQL, урезанная роль только на чтение, второй
способ обращения — MCP, перенос устаревших отчётов в архив) — подробности каждой правки и
полный повторный прогон после неё лежат в reports/etap-2-shag5-progon.md,
reports/etap-2-shag6-ustanovka-i-versia.md и reports/etap-2-shag7-belyi-spisok-funktsiy.md.
Полный регрессионный набор (46 кейсов) и живая приёмка (8 сценариев) на этот момент прогнаны
и подтверждены — раздел 2 и reports/etap-2-shag8-regressiya.md.
Все цифры и тексты ниже — из реального вывода команд. Полные протоколы прогонов приложены в разделе 6 целиком, без сокращений.
| Пункт | Суть требования | Статус | Чем подтверждено |
|---|---|---|---|
| П1 | Адаптер БД: единый интерфейс run(sql, params, max_rows, timeout), две реализации — PostgreSQL/Greenplum и DuckDB, выбор переменной окружения |
✅ сделано | Офлайн-набор 85 проверок проходит на обоих адаптерах; 8 контрольных запросов совпали на PostgreSQL и DuckDB |
| П2 | Валидатор SQL на парсере PostgreSQL вместо json_serialize_sql DuckDB; белый список, запрет системных каталогов и опасных функций |
✅ сделано | 43 состязательные проверки (после правки по замечанию приёмки): 32 запрещённых приёма отклонены, 11 легитимных запросов приняты |
| П3 | Диалект PG 9.4 в словаре, примерах и SQL кода | ✅ сделано | Карточка контрагента и большая выборка считаются на PostgreSQL без ошибок типов; офлайн-тесты на DuckDB после правки — 85 из 85, регрессий нет |
| П4 | Точка входа: POST /ask и GET /health; MCP Streamable HTTP с инструментом ask_purchasing |
✅ сделано | POST /ask и GET /health работают и проверены живыми вопросами; MCP поднят (service/mcp_server.py), проверен настоящим MCP-клиентом по всем трём шагам протокола (подключение → список инструментов → вызов) на тех же двух вопросах — маршрут и сумма совпадают с POST /ask (раздел 5) |
| П5 | Убрать скачивание базы из облачного хранилища при холодном старте; DuckDB — только локальный файл | ✅ сделано | В адаптере сервиса нет кода обращения к объектному хранилищу вовсе; единственный способ указать файл — DB_LOCAL_PATH |
| П6 | Клиент модели: Bearer-токен для OpenAI-совместимого шлюза вместо схемы Yandex AI Studio, FOLDER_ID не задавать |
✅ сделано | Реальный ответ модели получен через шлюз по Bearer-токену, без x-folder-id (раздел 5) |
| П7 | Конфигурация — через переменные окружения; журнал — маршрут, время, служебные метки, без текста вопросов и данных | ✅ сделано | Журнал сервиса содержит только маршрут, время, движок БД и признак пользователя; поиск значения ключа модели по ответам и журналу — 0 совпадений |
П1 — адаптер базы данных. Новый модуль сервиса реализует ровно тот контракт, которого ждёт
ядро референса (run, one, validate, quote_ident, MAX_ROWS, SQLRejected), и
подставляется вместо родного модуля ядра порядком путей импорта. Сам код ядра (обработчик,
резолвер, генерация SQL, карточка контрагента, оформление ответа) не переписан.
Выбор движка — переменная DB_BACKEND (postgres по умолчанию, duckdb — для тестов).
На PostgreSQL: пул соединений (PG_POOL_SIZE, по умолчанию 4), на каждом соединении
default_transaction_read_only = on, на каждый запрос statement_timeout (SQL_TIMEOUT,
по умолчанию 20 с) и обязательный откат транзакции после выполнения. Подключение к контуру
заказчика — через PG_DSN целиком либо через PGHOST/PGPORT/PGDATABASE/PGUSER/PGPASSWORD.
П2 — валидатор SQL. Разбор запроса настоящим парсером диалекта PostgreSQL. Проверяется
дерево запроса, а не текст: ровно один оператор; корень — только SELECT/UNION/EXCEPT/
INTERSECT; обход всего дерева, включая вложенные подзапросы и общие табличные выражения на
любой глубине, на запрещённые узлы (INSERT, UPDATE, DELETE, CREATE, DROP, ALTER,
TRUNCATE, GRANT, MERGE, COPY, SET и прочие команды); таблицы — только белый список
из девяти представлений dm_v_purch.*_h1; запрет служебных схем (pg_catalog,
information_schema, pg_toast, pg_temp, pg_internal); функции — рекурсивный поиск по
всему дереву против списка опасных (pg_read_file, pg_sleep, current_setting, set_config,
dblink*, lo_* и др.). Словарная проверка по запрещённым словам оставлена как защита в
глубину, но она больше не единственный барьер.
П3 — диалект PG 9.4 (Greenplum 6.27). Правки сделаны точечно в тех местах, где SQL-текст
формирует сам код и обойти его из пакета сервиса нельзя: карточка контрагента (6 мест
ROUND(SUM(...), 2) → приведение к numeric), разбивка по бизнес-единицам при результате
больше 200 строк, и все 8 примеров семантического словаря плюс текст правила округления —
потому что модель копирует примеры словаря буквально.
П4 — точка входа. HTTP-сервис на стандартной библиотеке, без новых зависимостей.
POST /ask передаёт тело запроса как есть в обработчик ядра — вся логика (жёсткий фильтр
запрещённых тем, карточка контрагента, генерация SQL, резолвер, сборка раздела «Как посчитано»)
выполняется кодом референса без изменений, HTTP — только транспорт. GET /health отвечает
без вызова модели. Второй способ обращения — MCP Streamable HTTP (service/mcp_server.py,
инструмент ask_purchasing, пакет mcp==1.30.0): общая загрузка ядра вынесена в
service/core_boot.py, поэтому HTTP- и MCP-вход вызывают один и тот же объект ядра и один
и тот же адаптер БД, а не две параллельные копии. Проверено настоящим MCP-клиентом
(service/mcp_client_check.py, официальный mcp.client.streamable_http) по всем трём шагам
контракта и на обоих живых вопросах (раздел 5): подключение к серверу, список инструментов
(['ask_purchasing']), вызов — маршрут и итоговая сумма совпадают с ответом POST /ask на
тот же вопрос.
П5 — загрузка базы. В адаптере сервиса кода скачивания файла базы из объектного хранилища нет в принципе — не «выключен флагом», а отсутствует. Для тестов и демо DuckDB открывается только по локальному пути.
П6 — клиент модели. Правок в модуле обращения к модели не потребовалось: он уже умеет
схему авторизации из переменной окружения. Сервис перед запуском ядра выставляет адрес шлюза,
имя модели, схему Bearer, снимает идентификатор каталога Yandex Cloud и передаёт ключ из
секрета проекта.
П7 — конфигурация и журналы. Вся настройка — переменными окружения. Стандартный журнал обращений веб-сервера отключён, вместо него пишется своя строка. Пример реального журнала:
2026-10-24 05:07:37,326 purch-gateway: порт 8099, db=postgres, version=1.0.0+ugmk-service
2026-10-24 05:07:44,467 POST /ask route=refusal elapsed_s=0.0 db=postgres user=-
2026-10-24 05:08:21,458 POST /ask route=nl2sql elapsed_s=36.93 db=postgres user=-
2026-10-24 05:08:54,751 POST /ask route=card elapsed_s=0.51 db=postgres user=-
Текста вопросов и данных результата в журнале нет.
| Набор | Команда | Результат |
|---|---|---|
| Офлайн-набор референса на DuckDB (точка отсчёта) | .venv/bin/python reference_impl/qa/test_handler.py |
Пройдено 85 из 85, код возврата 0 |
| Тот же офлайн-набор поверх своей PostgreSQL | .venv/bin/python scripts/run_offline_tests_postgres.py |
Пройдено 85 из 85, код возврата 0 |
Состязательные проверки валидатора SQL (43 случая после правки по замечанию приёмки — область видимости таблиц + расширенный белый список функций + закрытие SELECT ... INTO) |
.venv/bin/python service/adversarial_sql_tests.py |
ВСЕ ПРОВЕРКИ ПРОШЛИ, код возврата 0 |
| Сверка 8 контрольных запросов PostgreSQL против DuckDB | .venv/bin/python scripts/compare_pg_duckdb.py |
совпадает (8 из 8), код возврата 0 |
Права роли только на чтение purch_ro (запись и доступ мимо белого списка) |
.venv/bin/python scripts/check_ro_role.py |
запись — отказ 7/7; доступ мимо белого списка — отказ 6/6; 17 запросов шлюза выполнены, код возврата 0 |
| Второй способ обращения — MCP, настоящим клиентом, два живых вопроса | .venv/bin/python service/mcp_client_check.py ... |
подключение, список инструментов, вызов — на обоих вопросах; маршрут и сумма совпадают с POST /ask |
| Регрессионный набор, 46 кейсов, реальные вызовы модели qwen3.6-35b | .venv/bin/python reference_impl/qa/regression_set.py live <URL> |
46 из 46 ok, 9 блоков зелёные, без единого замечания — reports/etap-2-shag8-regressiya.md |
| Живая приёмка, 8 сценариев, реальные вызовы модели | .venv/bin/python reference_impl/qa/live_acceptance.py <URL> |
8 из 8 ok, подтверждена тремя прогонами подряд — reports/etap-2-shag5-progon.md |
Прогон офлайн-набора поверх PostgreSQL сделан без копирования ядра: отдельный запускающий скрипт заранее подставляет адаптер PostgreSQL под тем именем, которое импортирует ядро, — меняется только база, модель так же замокана.
Первый такой прогон упал на двух настоящих дефектах, оба исправлены:
Оба дефекта видны только потому, что набор прогнали на PostgreSQL, а не только на DuckDB.
Настоящий парсер, без подмены. Таблица ниже — исходные 22 случая первого прогона. По
замечанию приёмки добавлены ещё 21 случай (область видимости таблиц при подмене именем CTE,
XML-выгрузка таблиц/базы мимо белого списка, служебные функции current_user/
current_setting/inet_client_addr/pg_typeof, конструкция SELECT ... INTO в трёх видах,
и 4 новых легитимных запроса на расширенный список разрешённых функций) — полный список
всех 43 случаев (32 отклонённых, 11 принятых) и их результат — в
reports/etap-2-shag7-belyi-spisok-funktsiy.md и reports/_artifacts/adversarial_sql_shag2.txt;
итог тот же: ВСЕ ПРОВЕРКИ ПРОШЛИ, без ложных срабатываний в обе стороны.
| № | Случай | Ожидание | Результат |
|---|---|---|---|
| 1 | Несколько операторов (SELECT ...; SELECT ...) |
отклонить | отклонено: «допускается ровно один оператор, без ';'» |
| 2 | Несколько операторов, второй — DROP вперемешку регистром |
отклонить | отклонено: «запрещенное слово: DROP» |
| 3 | Комментарий маскирует второй оператор | отклонить | отклонено: «запрещенное слово: DELETE» |
| 4 | Блочный комментарий перед DROP |
отклонить | отклонено: «запрещенное слово: DROP» |
| 5 | CTE с DELETE ... RETURNING внутри WITH |
отклонить | отклонено: «запрещенное слово: DELETE» |
| 6 | CTE с UPDATE ... RETURNING внутри WITH |
отклонить | отклонено: «запрещенное слово: UPDATE» |
| 7 | Обращение к pg_catalog.pg_tables |
отклонить | отклонено: «обращение к служебной схеме pg_catalog запрещено» |
| 8 | Обращение к pg_catalog через подзапрос |
отклонить | отклонено: «обращение к служебной схеме pg_catalog запрещено» |
| 9 | Обращение к information_schema.tables |
отклонить | отклонено: «обращение к служебной схеме information_schema запрещено» |
| 10 | Опасная функция pg_sleep(5) внутри CASE |
отклонить | отклонено: «функция pg_sleep запрещена» |
| 11 | Опасная функция current_setting(...) в подзапросе |
отклонить | отклонено: «функция current_setting запрещена» |
| 12 | Опасная функция pg_read_file('/etc/passwd') |
отклонить | отклонено: «функция pg_read_file запрещена» |
| 13 | Обход кавычками: "DROP" TABLE ... после ; |
отклонить | отклонено (парсер не принимает это как второй легитимный оператор) |
| 14 | Обход регистром: dBlInK(...) как табличная функция |
отклонить | отклонено: «объект вне белого списка» |
| 15 | DML напрямую (INSERT INTO ...) |
отклонить | отклонено: «запрещенное слово: INSERT» |
| 16 | GRANT SELECT ON ... TO public |
отклонить | отклонено: «запрещенное слово: GRANT» |
| 17 | Объект вне белого списка (представление _h2, не _h1) |
отклонить | отклонено: «объект dm_purch.order_purch_h2 вне белого списка» |
| 18 | Легитимный алиас в кавычках AS "delete" |
принять | принят, объект dm_v_purch.order_purch_h1 |
| 19 | WITH x AS (SELECT ...) SELECT * FROM x |
принять | принят, объект dm_v_purch.order_purch_h1 |
| 20 | Сумма по бизнес-единице за 2025 год (пример словаря) | принять | принят, объект dm_v_purch.order_purch_h1 |
| 21 | Соединение с dim_material_h1 |
принять | приняты объекты order_purch_h1, dim_material_h1 |
| 22 | UNION двух легитимных SELECT |
принять | принят, объект dm_v_purch.order_purch_h1 |
Все 17 запрещённых приёмов отклонены, все 5 легитимных запросов приняты без ложных срабатываний.
По ходу работы найден и исправлен один дефект собственного кода: первая версия словарной
защиты вырезала из текста только строки и комментарии, но не идентификаторы в кавычках, —
и легитимный алиас колонки "delete" отклонялся как запрещённое слово. Это ровно тот обход
кавычками, который переход на парсер обязан закрывать, а не наоборот — ловить нормальный код.
| № | Запрос | DuckDB | PostgreSQL | Совпало |
|---|---|---|---|---|
| 1 | Сумма закупок по ИНН «Альфа-Снаб Екатеринбург», 2025 | (4858, 3, 1357249732.08) |
(4858, 3, 1357249732.08) |
да |
| 2 | Сумма закупок БЕ 1100, 2025 | (23718, 11778, 28076423313.02) |
(23718, 11778, 28076423313.02) |
да |
| 3 | Поставщики класса 001_OIL: написания/ИНН, 2025 | (31, 18, 9) |
(31, 18, 9) |
да |
| 4 | Юрлица холдинга «Альфа-Снаб»: ИНН/записей | (6, 9) |
(6, 9) |
да |
| 5 | Дизтопливо, тонн (класс/группа), 2025 | 333421.171 / 338707.218 |
333421.171 / 338707.218 |
да |
| 6 | Материалы по фрагменту «ШПАЛА» | (14, 3, 3, 2) |
(14, 3, 3, 2) |
да |
| 7 | Позиции свыше 10 млн руб., 2026 | (1150, 10, 34949335628.35) |
(1150, 10, 34949335628.35) |
да |
| 8 | Без класса МДМ, БЕ 7400, 2025 | (15445, 4327, 15015978659.21, 4190072105.89, 28.02) |
то же | да |
Итог прогона: совпадает, код возврата 0. Совпадение — до копейки и до штуки.
Сервис запущен на своей PostgreSQL под ролью только на чтение, модель — реальная.
Проверка готовности, GET /health:
{"status": "ok", "version": "1.0.0+ugmk-service", "db": "postgres", "model": "qwen3.6-35b"}
Время ответа: 35,34 с (поле elapsed_s в ответе; полное время обращения — 35,36 с).
Полный ответ сервиса:
{
"answer_md": "Сумма без НДС по БЕ 1100 (АО «Северокаменский ГОК») за 2025 год составила 28,08 млрд руб.\n\n**Сумма закупок без НДС: 28 076 423 313,02 руб. (28,08 млрд руб.)**\n\n---\n### Как посчитано\n- **Объекты БД:** `dm_v_purch.order_purch_h1` — позиции заказов поставщикам\n- **Фильтры:** БЕ 1100 (АО «Северокаменский ГОК»); дата создания заказа 01.01.2025–31.12.2025\n- **Формула:** сумма = SUM(`ord_cost_amt`), руб. без НДС; период — по дате создания заказа `ord_creation_dt` (Д4)\n- **Строк в результате:** 1\n- **Проверки:** SQL сгенерирован моделью по семантическому словарю (попыток: 1); прошел валидатор (один SELECT, только вью из белого списка, без DDL/DML/ATTACH); обязательные условия резолвера на месте; числа сводки модели сверены с результатом SQL\n- **SQL:**\n```sql\nSELECT ROUND(SUM(o.ord_cost_amt)::numeric, 2) AS sum_wo_vat_rub FROM dm_v_purch.order_purch_h1 o WHERE o.compcode_cd = '1100' AND o.ord_creation_dt BETWEEN DATE '2025-01-01' AND DATE '2025-12-31'\n```",
"route": "nl2sql",
"clarify": null,
"dialog_state": null,
"data": {"row_count": 1, "aggregated": false, "attempts": 1},
"elapsed_s": 35.34,
"version": "1.0.0+ugmk-service"
}
То же в читаемом виде — как это увидит человек в чате:
Сумма без НДС по БЕ 1100 (АО «Северокаменский ГОК») за 2025 год составила 28,08 млрд руб.
Сумма закупок без НДС: 28 076 423 313,02 руб. (28,08 млрд руб.)
Как посчитано - Объекты БД:
dm_v_purch.order_purch_h1— позиции заказов поставщикам - Фильтры: БЕ 1100 (АО «Северокаменский ГОК»); дата создания заказа 01.01.2025–31.12.2025 - Формула: сумма = SUM(ord_cost_amt), руб. без НДС; период — по дате создания заказаord_creation_dt(Д4) - Строк в результате: 1 - Проверки: SQL сгенерирован моделью по семантическому словарю (попыток: 1); прошёл валидатор (один SELECT, только представления из белого списка, без изменения данных); обязательные условия резолвера на месте; числа сводки модели сверены с результатом запроса - SQL:SELECT ROUND(SUM(o.ord_cost_amt)::numeric, 2) AS sum_wo_vat_rub FROM dm_v_purch.order_purch_h1 o WHERE o.compcode_cd = '1100' AND o.ord_creation_dt BETWEEN DATE '2025-01-01' AND DATE '2025-12-31'
Сумма 28 076 423 313,02 руб. совпадает до копейки с контрольным запросом № 2 (раздел 4), независимо проверенным и на PostgreSQL, и на DuckDB. Раздел «Как посчитано» на месте: объекты базы, фильтры, формула, итоговый запрос.
Время ответа: 0,0 с — отказ сработал до обращения к модели, по правилу об отказах.
Полный ответ сервиса:
{
"answer_md": "Прогнозы стенд не строит: в базе только фактические заказы.\n\n**Что могу сделать по фактическим данным:** показать фактическую динамику закупок по годам или по месяцам — суммы без НДС, количество, средневзвешенные цены.",
"route": "refusal",
"clarify": null,
"dialog_state": null,
"how_calculated": null,
"refusal_type": "forecast",
"refusal_by": "pattern",
"elapsed_s": 0.0,
"version": "1.0.0+ugmk-service"
}
То же в читаемом виде:
Прогнозы стенд не строит: в базе только фактические заказы.
Что могу сделать по фактическим данным: показать фактическую динамику закупок по годам или по месяцам — суммы без НДС, количество, средневзвешенные цены.
Честный отказ с альтернативой, без догадки и без расхода на вызов модели.
Проверка безопасности: в обоих ответах и в журнале сервиса ключ доступа к модели не встречается — поиск по значению переменной окружения дал 0 совпадений.
Контракт комплекта требует не только POST /ask, но и чат-протокол MCP с инструментом
ask_purchasing. Он поднят (service/mcp_server.py) и проверен настоящим MCP-клиентом
(service/mcp_client_check.py, официальный mcp.client.streamable_http + mcp.ClientSession,
без подмены и без curl) по всем трём шагам контракта, с тем же сервисом и той же PostgreSQL:
[1/3] Подключение к http://127.0.0.1:8098/mcp ...
OK: сессия инициализирована, serverInfo=Implementation(name='ugmk-purch-assistant',
version='1.0.0+ugmk-service', ...)
[2/3] Список инструментов ...
OK: ['ask_purchasing']
[3/3] Вызов ask_purchasing на живых вопросах ...
| Вопрос | Маршрут MCP | Маршрут HTTP | Сумма в ответе |
|---|---|---|---|
| «Справка по поставщику УралСмазТорг за 2025 год» (без модели) | card |
card |
— (карточка; текст answer_md совпал буквально, посимвольно) |
| «Сумма закупок без НДС по БЕ 1100 за 2025 год» (реальный вызов модели) | nl2sql |
nl2sql |
28 076 423 313,02 руб. в обоих, совпадает до копейки с контрольным запросом № 2 (раздел 4) |
serverInfo.version в ответе MCP-сервера — версия сервиса (1.0.0+ugmk-service), а не версия
библиотеки mcp. Для вопроса про БЕ 1100 модель вызывалась дважды независимо (один раз через
MCP, один раз через HTTP) — маршрут и сумма совпали, текст свободной сводки отличался
формулировкой: это недетерминизм самой модели между двумя независимыми вызовами (см. раздел 9
и reports/etap-1-shag5-mcp.md), а не дефект обёртки MCP — число и «Как посчитано» совпадают.
Полный протокол проверки — reports/etap-1-shag5-mcp.md и reports/etap-2-shag5-progon.md
(раздел 7), вывод команд — reports/_artifacts/mcp-client-check.txt и
reports/_artifacts/mcp_client_check_shag5.txt.
reference_impl/qa/test_handler.pyТочка входа ugmk-purch-gateway-demo (модель — заглушка, БД — локальная):
Сценарий 1 — справка по контрагенту
[gateway] route=card 0.2 с db=local
[OK ] маршрут card, HTTP 200
[OK ] сумма 2025 совпадает с validate.py
[OK ] дубли написаний схлопнуты
[OK ] 5 секций + Как посчитано
[OK ] Д7: доля без класса
[OK ] холдинг: 6 юрлиц
[OK ] модель не вызывалась
[gateway] route=card 0.09 с db=local
[OK ] справка по наименованию
[gateway] route=clarify 0.0 с db=local
[OK ] неоднозначное наименование → уточнение
[gateway] route=card 0.08 с db=local
[OK ] после выбора — справка
Сценарий 2 — сумма по БЕ 1100 за 2025
[gateway] route=nl2sql 0.02 с db=local
[OK ] маршрут nl2sql
[OK ] одна цифра 28 076 423 313,02
[OK ] Как посчитано: фильтры, формула, SQL
[OK ] сводка модели прошла сверку чисел
[OK ] how_calculated отдельным полем
Сценарий 3 — поставщики по категории масла
[gateway] route=nl2sql 0.04 с db=local
[OK ] резолвинг масла → 001_OIL
[OK ] физлица одной строкой
[OK ] 19 строк (18 ИНН + физлица)
[OK ] основное написание из справочника
[gateway] route=nl2sql 0.06 с db=local
[OK ] SQL с ИНН '#' отклонен проверкой и перегенерирован
Сценарий 4 — поставки шпал: уточнение → ответ
[gateway] route=clarify 0.01 с db=local
[OK ] clarify с 4 вариантами (3 группы + все)
[OK ] dialog_state возвращен
[gateway] route=nl2sql 0.04 с db=local
[OK ] выбор «2» → ответ по группе
[OK ] классификатор повторно не вызывался
[OK ] строка Итого
[gateway] route=nl2sql 0.04 с db=local
[OK ] «все» → все группы
[gateway] route=nl2sql 0.04 с db=local
[OK ] выбор по названию
[gateway] route=clarify 0.0 с db=local
[OK ] непонятный ответ → повтор уточнения
[gateway] route=nl2sql 0.01 с db=local
[OK ] новый вопрос при открытом уточнении
[gateway] route=nl2sql 0.03 с db=local
[OK ] dialog_state строкой JSON
Период не указан → уточнение периода (второе уточнение в цепочке)
[gateway] route=clarify 0.01 с db=local
[gateway] route=clarify 0.01 с db=local
[OK ] после группы — уточнение периода, шаг 2
[gateway] route=nl2sql 0.04 с db=local
[OK ] после 2 уточнений — ответ
Сценарий 5 — закупки свыше 10 млн за 2026 (R2)
[gateway] route=nl2sql 0.03 с db=local
[OK ] агрегат: 1 150 строк
[OK ] ТОП-10 и распределение по БЕ
[OK ] предложение уточнить фильтры
[OK ] строк в таблице ТОП не больше 10
[OK ] оговорка о неполном 2026
Сценарий 6 — отказы (R6)
[gateway] route=refusal 0.0 с db=local
[OK ] выбор поставщика → отказ паттерном
[OK ] альтернатива: перечень поставщиков
[gateway] route=refusal 0.0 с db=local
[OK ] «почему» → отказ + динамика цены
[OK ] модель не вызывалась
[gateway] route=refusal 0.0 с db=local
[OK ] отказ LLM-классификатором
[gateway] route=refusal 0.0 с db=local
[OK ] отказ: Спрогнозируй закупки шин на 2027
[gateway] route=refusal 0.0 с db=local
[OK ] отказ: Дай телефон менеджера Альфа-Снаб
[gateway] route=refusal 0.0 с db=local
[OK ] отказ: Проверь поставщика по СПАРК
[gateway] route=refusal 0.0 с db=local
[OK ] отказ: Смоделируй эффект централизации закупок
[gateway] route=refusal 0.0 с db=local
[OK ] год после данных (2027) → отказ без модели
[OK ] не ложный отказ: Покажи закупки контакторов за 2025
[OK ] не ложный отказ: Сумма закупок КПП 660001001 причины постановки
Сценарий 7 — БЕ 7400 по классам (Д7)
[gateway] route=nl2sql 0.11 с db=local
[OK ] строка «27,9 % суммы не отнесено к классам»
[OK ] строка «класс материала: не заполнен»
Валидатор SQL (попытки модели с опасным SQL)
[gateway] route=nl2sql 0.01 с db=local
[OK ] read_csv: отклонен, ответ со 2-й попытки
[gateway] route=nl2sql 0.01 с db=local
[OK ] delete: отклонен, ответ со 2-й попытки
[gateway] route=nl2sql 0.01 с db=local
[OK ] catalog: отклонен, ответ со 2-й попытки
[gateway] route=nl2sql 0.01 с db=local
[OK ] raw_table: отклонен, ответ со 2-й попытки
[gateway] route=nl2sql 0.01 с db=local
[OK ] two: отклонен, ответ со 2-й попытки
[gateway] route=nl2sql 0.01 с db=local
[OK ] getenv: отклонен, ответ со 2-й попытки
[gateway] route=nl2sql 0.01 с db=local
[OK ] attach: отклонен, ответ со 2-й попытки
[gateway] не удалось построить корректный запрос за 3 попытки: валидатор: функция getenv запрещена
[gateway] route=error 0.0 с db=local
[OK ] 3 плохие попытки → честная ошибка
Сверка чисел сводки модели
[gateway] route=nl2sql 0.01 с db=local
[OK ] выдуманные числа → сводка отброшена
[OK ] цифра из SQL на месте
Резолвер: дефекты прогона 21.10.2026
[OK ] Д-1: спецодежда → категория 001_SPECODEGD раскрыта, без услуг ZUSL_06_04
[OK ] Д-1: масла → лист 001_OIL, а не вся категория
[OK ] Д-2: «в прошлом году» → 2025 (сегодня стенда 30.09.2026), правило главнее модели
[OK ] Д-2: первое полугодие 2025
[OK ] Д-3: холдинг → условие по head_company_cd, не по ИНН
[OK ] Д-4: «смазок» (беглая гласная) → 001_SMAZKI
[OK ] Д-4: «масел» (беглая гласная) → 001_OIL
[OK ] Д-5: физлица без ИНН → contragent_inn_cd = '#'
[OK ] 9.3: помесячная разбивка без вопроса о количестве — подсказка «без ЕИ»
[OK ] 9.3: вопрос о цене по месяцам — подсказки нет
[OK ] 6.4: иностранные поставщики → тип контрагента ИНО
[OK ] 6.4: выдуманный моделью фильтр БЕ 1100 отклоняется проверкой
[OK ] 6.2: доля 100 % у единственной строки отклоняется проверкой
[OK ] Д-6: «шины» по началу слова — без «МАШИНЫ»
[OK ] Д-6: «шин R49» с размером → поиск по наименованию без уточнения
Граничные случаи
[OK ] пустое тело → 400
[OK ] нет question → 400
[gateway] route=refusal 0.0 с db=local
[OK ] base64-тело
[gateway] route=refusal 0.0 с db=local
[OK ] событие без body (прямой вызов)
[scope] модель недоступна, план по правилам: модель недоступна: тест
[gateway] LLM: модель недоступна: тест
[gateway] route=error 0.0 с db=local
[OK ] модель недоступна → понятная ошибка, HTTP 200
[gateway] route=card 0.09 с db=local
[OK ] справка работает без модели
[gateway] route=not_found 0.0 с db=local
[OK ] несуществующая БЕ → not_found
[gateway] route=not_found 0.0 с db=local
[OK ] несуществующий ИНН → not_found
[OK ] в ответах нет секретов
Пройдено 85 из 85
scripts/run_offline_tests_postgres.py[run_offline_tests_postgres] purch_db из /var/lib/sreda-sessions/sol-mvli6x6l-47w__wt/18/service/purch_db.py, backend=postgres
Точка входа ugmk-purch-gateway-demo (модель — заглушка, БД — локальная):
Сценарий 1 — справка по контрагенту
[gateway] route=card 0.72 с db=postgres
[OK ] маршрут card, HTTP 200
[OK ] сумма 2025 совпадает с validate.py
[OK ] дубли написаний схлопнуты
[OK ] 5 секций + Как посчитано
[OK ] Д7: доля без класса
[OK ] холдинг: 6 юрлиц
[OK ] модель не вызывалась
[gateway] route=card 0.49 с db=postgres
[OK ] справка по наименованию
[gateway] route=clarify 0.0 с db=postgres
[OK ] неоднозначное наименование → уточнение
[gateway] route=card 0.49 с db=postgres
[OK ] после выбора — справка
Сценарий 2 — сумма по БЕ 1100 за 2025
[gateway] route=nl2sql 0.13 с db=postgres
[OK ] маршрут nl2sql
[OK ] одна цифра 28 076 423 313,02
[OK ] Как посчитано: фильтры, формула, SQL
[OK ] сводка модели прошла сверку чисел
[OK ] how_calculated отдельным полем
Сценарий 3 — поставщики по категории масла
[gateway] route=nl2sql 0.11 с db=postgres
[OK ] резолвинг масла → 001_OIL
[OK ] физлица одной строкой
[OK ] 19 строк (18 ИНН + физлица)
[OK ] основное написание из справочника
[gateway] route=nl2sql 0.21 с db=postgres
[OK ] SQL с ИНН '#' отклонен проверкой и перегенерирован
Сценарий 4 — поставки шпал: уточнение → ответ
[gateway] route=clarify 0.01 с db=postgres
[OK ] clarify с 4 вариантами (3 группы + все)
[OK ] dialog_state возвращен
[gateway] route=nl2sql 0.16 с db=postgres
[OK ] выбор «2» → ответ по группе
[OK ] классификатор повторно не вызывался
[OK ] строка Итого
[gateway] route=nl2sql 0.17 с db=postgres
[OK ] «все» → все группы
[gateway] route=nl2sql 0.16 с db=postgres
[OK ] выбор по названию
[gateway] route=clarify 0.0 с db=postgres
[OK ] непонятный ответ → повтор уточнения
[gateway] route=nl2sql 0.05 с db=postgres
[OK ] новый вопрос при открытом уточнении
[gateway] route=nl2sql 0.17 с db=postgres
[OK ] dialog_state строкой JSON
Период не указан → уточнение периода (второе уточнение в цепочке)
[gateway] route=clarify 0.01 с db=postgres
[gateway] route=clarify 0.01 с db=postgres
[OK ] после группы — уточнение периода, шаг 2
[gateway] route=nl2sql 0.16 с db=postgres
[OK ] после 2 уточнений — ответ
Сценарий 5 — закупки свыше 10 млн за 2026 (R2)
[gateway] route=nl2sql 0.17 с db=postgres
[OK ] агрегат: 1 150 строк
[OK ] ТОП-10 и распределение по БЕ
[OK ] предложение уточнить фильтры
[OK ] строк в таблице ТОП не больше 10
[OK ] оговорка о неполном 2026
Сценарий 6 — отказы (R6)
[gateway] route=refusal 0.0 с db=postgres
[OK ] выбор поставщика → отказ паттерном
[OK ] альтернатива: перечень поставщиков
[gateway] route=refusal 0.0 с db=postgres
[OK ] «почему» → отказ + динамика цены
[OK ] модель не вызывалась
[gateway] route=refusal 0.0 с db=postgres
[OK ] отказ LLM-классификатором
[gateway] route=refusal 0.0 с db=postgres
[OK ] отказ: Спрогнозируй закупки шин на 2027
[gateway] route=refusal 0.0 с db=postgres
[OK ] отказ: Дай телефон менеджера Альфа-Снаб
[gateway] route=refusal 0.0 с db=postgres
[OK ] отказ: Проверь поставщика по СПАРК
[gateway] route=refusal 0.0 с db=postgres
[OK ] отказ: Смоделируй эффект централизации закупок
[gateway] route=refusal 0.0 с db=postgres
[OK ] год после данных (2027) → отказ без модели
[OK ] не ложный отказ: Покажи закупки контакторов за 2025
[OK ] не ложный отказ: Сумма закупок КПП 660001001 причины постановки
Сценарий 7 — БЕ 7400 по классам (Д7)
[gateway] route=nl2sql 0.53 с db=postgres
[OK ] строка «27,9 % суммы не отнесено к классам»
[OK ] строка «класс материала: не заполнен»
Валидатор SQL (попытки модели с опасным SQL)
[gateway] route=nl2sql 0.05 с db=postgres
[OK ] read_csv: отклонен, ответ со 2-й попытки
[gateway] route=nl2sql 0.05 с db=postgres
[OK ] delete: отклонен, ответ со 2-й попытки
[gateway] route=nl2sql 0.05 с db=postgres
[OK ] catalog: отклонен, ответ со 2-й попытки
[gateway] route=nl2sql 0.06 с db=postgres
[OK ] raw_table: отклонен, ответ со 2-й попытки
[gateway] route=nl2sql 0.05 с db=postgres
[OK ] two: отклонен, ответ со 2-й попытки
[gateway] route=nl2sql 0.05 с db=postgres
[OK ] getenv: отклонен, ответ со 2-й попытки
[gateway] route=nl2sql 0.06 с db=postgres
[OK ] attach: отклонен, ответ со 2-й попытки
[gateway] не удалось построить корректный запрос за 3 попытки: нет обязательного условия «o.compcode_cd = '1100'»; нет обязательного условия «o.ord_creation_dt BETWEEN DATE '2025-01-01' AND DATE '2025-12-31'»; убери LIMIT: усечение и ТОП-10 делает система (R2); сумма закупок считается по o.ord_cost_amt (Д4)
[gateway] route=error 0.0 с db=postgres
[OK ] 3 плохие попытки → честная ошибка
Сверка чисел сводки модели
[gateway] route=nl2sql 0.05 с db=postgres
[OK ] выдуманные числа → сводка отброшена
[OK ] цифра из SQL на месте
Резолвер: дефекты прогона 21.10.2026
[OK ] Д-1: спецодежда → категория 001_SPECODEGD раскрыта, без услуг ZUSL_06_04
[OK ] Д-1: масла → лист 001_OIL, а не вся категория
[OK ] Д-2: «в прошлом году» → 2025 (сегодня стенда 30.09.2026), правило главнее модели
[OK ] Д-2: первое полугодие 2025
[OK ] Д-3: холдинг → условие по head_company_cd, не по ИНН
[OK ] Д-4: «смазок» (беглая гласная) → 001_SMAZKI
[OK ] Д-4: «масел» (беглая гласная) → 001_OIL
[OK ] Д-5: физлица без ИНН → contragent_inn_cd = '#'
[OK ] 9.3: помесячная разбивка без вопроса о количестве — подсказка «без ЕИ»
[OK ] 9.3: вопрос о цене по месяцам — подсказки нет
[OK ] 6.4: иностранные поставщики → тип контрагента ИНО
[OK ] 6.4: выдуманный моделью фильтр БЕ 1100 отклоняется проверкой
[OK ] 6.2: доля 100 % у единственной строки отклоняется проверкой
[OK ] Д-6: «шины» по началу слова — без «МАШИНЫ»
[OK ] Д-6: «шин R49» с размером → поиск по наименованию без уточнения
Граничные случаи
[OK ] пустое тело → 400
[OK ] нет question → 400
[gateway] route=refusal 0.0 с db=postgres
[OK ] base64-тело
[gateway] route=refusal 0.0 с db=postgres
[OK ] событие без body (прямой вызов)
[scope] модель недоступна, план по правилам: модель недоступна: тест
[gateway] LLM: модель недоступна: тест
[gateway] route=error 0.0 с db=postgres
[OK ] модель недоступна → понятная ошибка, HTTP 200
[gateway] route=card 0.5 с db=postgres
[OK ] справка работает без модели
[gateway] route=not_found 0.0 с db=postgres
[OK ] несуществующая БЕ → not_found
[gateway] route=not_found 0.0 с db=postgres
[OK ] несуществующий ИНН → not_found
[OK ] в ответах нет секретов
Пройдено 85 из 85
service/adversarial_sql_tests.py=== Запрещённые приёмы ===
[ОТКЛОНЕНО, ожидалось] несколько операторов: допускается ровно один оператор, без ';'
[ОТКЛОНЕНО, ожидалось] несколько операторов, второй — DROP (обход регистром): запрещенное слово: DROP
[ОТКЛОНЕНО, ожидалось] комментарий маскирует второй оператор: запрещенное слово: DELETE
[ОТКЛОНЕНО, ожидалось] комментарий блочный перед DDL: запрещенное слово: DROP
[ОТКЛОНЕНО, ожидалось] CTE с DML (DELETE ... RETURNING внутри WITH): запрещенное слово: DELETE
[ОТКЛОНЕНО, ожидалось] CTE с DML (UPDATE внутри WITH): запрещенное слово: UPDATE
[ОТКЛОНЕНО, ожидалось] обращение к pg_catalog: обращение к служебной схеме pg_catalog запрещено
[ОТКЛОНЕНО, ожидалось] обращение к pg_catalog через подзапрос: обращение к служебной схеме pg_catalog запрещено
[ОТКЛОНЕНО, ожидалось] обращение к information_schema: обращение к служебной схеме information_schema запрещено
[ОТКЛОНЕНО, ожидалось] опасная функция в выражении CASE: функция pg_sleep запрещена
[ОТКЛОНЕНО, ожидалось] опасная функция в подзапросе: функция current_setting запрещена
[ОТКЛОНЕНО, ожидалось] опасная функция чтения файлов: функция pg_read_file запрещена
[ОТКЛОНЕНО, ожидалось] обход кавычками: двойные кавычки вокруг запрещённого слова как SQL-текста: SQL не разобран: Invalid expression / Unexpected token. Line 1, Col: 64.
SELECT 1 FROM dm_v_purch.order_purch_h1; "DROP" TABLE dm_v_purch.order_purch_h1
[ОТКЛОНЕНО, ожидалось] обход регистром без второго оператора: dBlInK как табличная функция: объект вне белого списка
[ОТКЛОНЕНО, ожидалось] DML напрямую (INSERT): запрещенное слово: INSERT
[ОТКЛОНЕНО, ожидалось] GRANT: запрещенное слово: GRANT
[ОТКЛОНЕНО, ожидалось] объект вне белого списка (представление _h2, не _h1): объект dm_purch.order_purch_h2 вне белого списка
=== Легитимные запросы витрины (не должны отклоняться) ===
[ПРИНЯТ, ожидалось] легитимный алиас в кавычках — не обход словарной защиты: объекты=['dm_v_purch.order_purch_h1']
[ПРИНЯТ, ожидалось] CTE только с SELECT: объекты=['dm_v_purch.order_purch_h1']
[ПРИНЯТ, ожидалось] сумма закупок без НДС по БЕ (словарный пример, с приведением к numeric): объекты=['dm_v_purch.order_purch_h1']
[ПРИНЯТ, ожидалось] join с dim_contragent_h1 и dim_material_h1: объекты=['dm_v_purch.order_purch_h1', 'dm_v_purch.dim_material_h1']
[ПРИНЯТ, ожидалось] UNION двух легитимных SELECT: объекты=['dm_v_purch.order_purch_h1']
Итог: ВСЕ ПРОВЕРКИ ПРОШЛИ
Нужны: PostgreSQL 16 (ставится на машине разработки) и виртуальное окружение проекта.
bash scripts/setup_env.sh ставит одной командой всё: зависимости референса, psycopg и
sqlglot для адаптера/валидатора, и mcp/httpx для MCP-слоя — доустанавливать что-либо
руками не нужно.
1. Поднять базу с витриной — одной командой, повторяемо:
bash scripts/load_postgres.sh
Скрипт выключает прежний кластер, поднимает новый в каталоге проекта (порт 55432, только
локальный сокет), создаёт базу purch, накатывает схему витрины, выгружает синтетику из
файла демостенда и загружает её, затем создаёт роль purch_ro с правом только на чтение
(без доступа к базовым таблицам и без права создавать временные таблицы).
2. Запустить сервис — два способа обращения, одно и то же ядро:
PORT=8080 DB_BACKEND=postgres .venv/bin/python service/app.py & # POST /ask, GET /health
PORT=8081 DB_BACKEND=postgres .venv/bin/python service/mcp_server.py & # MCP: инструмент ask_purchasing
Ключ доступа к модели берётся из переменной окружения LLM_API_KEY — ничего дополнительно
задавать не нужно. Для контура заказчика подключение к Greenplum задаётся переменными
PG_DSN либо PGHOST/PGPORT/PGDATABASE/PGUSER/PGPASSWORD; имя модели и адрес шлюза —
переменными MODEL и LLM_URL.
3. Проверить, что сервис жив:
curl -s http://127.0.0.1:8080/health
4. Задать вопрос — обычным способом либо через MCP:
curl -s -X POST http://127.0.0.1:8080/ask -H "Content-Type: application/json" \
-d '{"question": "Сумма закупок без НДС по БЕ 1100 за 2025 год", "dialog_state": null}'
.venv/bin/python service/mcp_client_check.py \
"http://127.0.0.1:8081/mcp" "http://127.0.0.1:8080/ask" \
"Сумма закупок без НДС по БЕ 1100 за 2025 год"
5. Остановить сервис и базу после работы:
kill %1 %2
PATH=/usr/lib/postgresql/16/bin:$PATH pg_ctl -D .pgdata stop
bash scripts/setup_env.sh # окружение и зависимости, включая mcp/httpx
bash scripts/load_postgres.sh # база с витриной и ролью только на чтение
.venv/bin/python reference_impl/qa/test_handler.py # 85 офлайн-проверок на DuckDB
.venv/bin/python scripts/run_offline_tests_postgres.py # те же 85 проверок на PostgreSQL
.venv/bin/python service/adversarial_sql_tests.py # 43 состязательные проверки валидатора
.venv/bin/python scripts/compare_pg_duckdb.py # 8 контрольных запросов, PostgreSQL против DuckDB
.venv/bin/python scripts/check_ro_role.py # права роли только на чтение, обе стороны
PORT=8099 DB_BACKEND=postgres .venv/bin/python service/app.py &
PORT=8098 DB_BACKEND=postgres .venv/bin/python service/mcp_server.py &
curl -s http://127.0.0.1:8099/health
curl -s -X POST http://127.0.0.1:8099/ask -H "Content-Type: application/json" \
-d '{"question": "Сумма закупок без НДС по БЕ 1100 за 2025 год", "dialog_state": null}'
curl -s -X POST http://127.0.0.1:8099/ask -H "Content-Type: application/json" \
-d '{"question": "Спрогнозируй закупки дизтоплива на 2027 год", "dialog_state": null}'
.venv/bin/python service/mcp_client_check.py \
"http://127.0.0.1:8098/mcp" "http://127.0.0.1:8099/ask" \
"Справка по поставщику УралСмазТорг за 2025 год" \
"Сумма закупок без НДС по БЕ 1100 за 2025 год"
kill %1 %2
PATH=/usr/lib/postgresql/16/bin:$PATH pg_ctl -D .pgdata stop
1. Конфигурация агента в чате Авандок.ИИ (LibreChat) не собиралась. MCP-протокол сам по
себе сделан и проверен настоящим клиентом по всем трём шагам (подключение, список
инструментов, вызов — раздел 5), но настройка конкретного чат-продукта (mcpServers в
librechat.yaml, промпт, карточка агента) — это отдельная задача фазы 5 CLAUDE.md, в объём
этого этапа не входила.
2. Это снято следующими шагами. Полный набор из 46 вопросов прогнан (46 из 46 ok,
без единого замечания, 9 блоков зелёные — reports/etap-2-shag8-regressiya.md,
eval/out/РЕГРЕССИОННЫЙ_ПРОГОН.md), живая приёмка из 8 вопросов прогнана и подтверждена
тремя прогонами подряд (8 из 8 каждый раз — reports/etap-2-shag5-progon.md,
eval/out/ЖИВАЯ_ПРИЁМКА.md и eval/out/runs/). Критичных дефектов по итогам этих прогонов — 0.
Недетерминированный дефект (потерянный фильтр, формулировка сводки), о котором предупреждает
опыт комплекта, тремя повторами живой приёмки не найден.
3. Контейнер и выкладка сервиса в контур не делались — были явно исключены из объёма этого этапа.
4. Нижний предел по времени ответа не измерялся. Медиана NL2SQL-вопросов по 46-кейсовой
регрессии — 50,9 с (29 шагов с маршрутом nl2sql), справочные вопросы — доли секунды без
модели; замеров под нагрузкой не делалось.
Отчёт опубликован страницей по ссылке проекта:
https://next.sreda-ai.ru/api/projects/sol-mvli6x6l-47w/preview/ — на ней этот же текст
в читаемом виде, со ссылкой на отчёт по окружению и загрузке данных (etap-1.html).
Страница проверена не «по коду», а открытием в настоящем браузере (Chrome без окна). Что показала проверка:
Снимки обеих страниц целиком сохранены в проекте: reports/_artifacts/stranitsa-otchet.png
и reports/_artifacts/stranitsa-etap-1.png.