этап 2 · переделки П1–П7 · офлайн-тесты · валидатор SQL · живые вопросы

Ассистент по закупкам УГМК — сводный отчёт: П1–П7, тесты, два живых вопроса

Дата прогонов — 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. Что сделано по каждому пункту П1–П7

Пункт Суть требования Статус Чем подтверждено
П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=-

Текста вопросов и данных результата в журнале нет.


2. Тесты — сводка

Набор Команда Результат
Офлайн-набор референса на 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 под тем именем, которое импортирует ядро, — меняется только база, модель так же замокана.

Первый такой прогон упал на двух настоящих дефектах, оба исправлены:

  1. Заглушки SQL в самом тесте считали суммы без приведения типа — PostgreSQL, в отличие от DuckDB, не умеет округлять число двойной точности. Это та же грабля диалекта, которую уже исправили в коде ядра, но пропустили в тесте. После правки офлайн-тесты на DuckDB перепроверены — снова 85 из 85, без регрессий.
  2. Резолвер ядра строит параметризованный запрос со знаком вопроса как местом подстановки (синтаксис DuckDB), а библиотека PostgreSQL ждёт другой символ. Это дефект адаптера, а не ядра: ядро правильно обращается к общему контракту. Добавлен перевод места подстановки в адаптере, строковые литералы и кавычки при этом не затрагиваются.

Оба дефекта видны только потому, что набор прогнали на PostgreSQL, а не только на DuckDB.


3. Состязательные проверки валидатора SQL

Настоящий парсер, без подмены. Таблица ниже — исходные 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" отклонялся как запрещённое слово. Это ровно тот обход кавычками, который переход на парсер обязан закрывать, а не наоборот — ловить нормальный код.


4. Сверка 8 контрольных запросов: PostgreSQL против DuckDB

№ Запрос 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. Совпадение — до копейки и до штуки.


5. Два живых вопроса: полные ответы и время

Сервис запущен на своей PostgreSQL под ролью только на чтение, модель — реальная.

Проверка готовности, GET /health:

{"status": "ok", "version": "1.0.0+ugmk-service", "db": "postgres", "model": "qwen3.6-35b"}

Вопрос 1 — «Сумма закупок без НДС по БЕ 1100 за 2025 год»

Время ответа: 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. Раздел «Как посчитано» на месте: объекты базы, фильтры, формула, итоговый запрос.

Вопрос 2 — «Спрогнозируй закупки дизтоплива на 2027 год»

Время ответа: 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 совпадений.

Те же вопросы через второй способ обращения — MCP

Контракт комплекта требует не только 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.


6. Полный вывод всех тестов

6.1. Офлайн-набор референса на DuckDB — 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

6.2. Тот же офлайн-набор поверх своей PostgreSQL — 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

6.3. Состязательные проверки валидатора SQL — 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']

Итог: ВСЕ ПРОВЕРКИ ПРОШЛИ

7. Как запустить сервис

Нужны: 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

8. Что не получилось — без умолчаний

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), справочные вопросы — доли секунды без модели; замеров под нагрузкой не делалось.


9. Главные риски перед следующим этапом


10. Где смотреть отчёт и как он проверен

Отчёт опубликован страницей по ссылке проекта: 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.


Страница собрана из файла reports/etap-2.md рабочей копии проекта скриптом scripts/render_report.py.