Привет, Хабр.

Из каждого утюга вещают, что нейросети напишут за нас код и отправят выращивать кабачки. Давайте проверим. Попросите модель написать запрос к регистру накопления в УТ 11.5 с разбивкой по номенклатуре. Она напишет Т.Номенклатура. А в этом регистре номенклатуры нет, там Т.АналитикаУчетаНоменклатуры.Номенклатура, и это ключ аналитики, отдельный справочник. Модель не ошиблась в синтаксисе. Она не знает вашу базу и не узнает, пока вы ей её не покажете.

Меня зовут Александр, я разработчик 1С. Команда у нас небольшая, выделенного консультанта нет, и разбор вопросов вида «почему документ не проводится» падает на разработчика. Выжигает быстрее, чем рефакторинг чужого легаси.

Полтора года назад я решил сделать не «ИИ‑помощника для 1С», а скучную инфраструктурную вещь: канал, по которому внешний агент дотягивается до данных 1С, не превращая базу в публичный веб‑сервис. Дальше про то, как он устроен, и про три места, где всё могло сломаться.

Проблема первая: «откройте порт»

Классическая интеграция упирается в разговор с безопасником. Опубликовать веб‑сервисы 1С в интернет, прокинуть вебхук в контур, где лежит финансовый учёт. После такого предложения разговор обычно и заканчивается. Правильно заканчивается.

Решение банальное и потому надёжное: входящих соединений нет вообще. Инициатор всегда база. Внутри 1С работает внешняя обработка, которая сама стучится наружу по HTTPS и спрашивает, есть ли для неё задание. Есть — выполняет внутри своего контура и отправляет результат тем же исходящим соединением. Store‑and‑forward, сервис в роли почтового ящика.

Чтобы это не выродилось в опрос раз в тридцать секунд с соответствующей задержкой, у забора команды два режима: обычный и с ожиданием на стороне сервера.

Функция ПолучитьКоманду(Настройки, Знач ЖдатьСек = 0)

	Нагрузка = Новый Структура;
	Нагрузка.Вставить("agent_id",      Настройки.AgentId);
	Нагрузка.Вставить("session_token", Настройки.ТокенПодключения);

	Путь = "/api/v1/agent/pull";
	Если ЖдатьСек >= 1 Тогда
		Нагрузка.Вставить("wait_sec", Цел(Мин(ЖдатьСек, Настройки.ИнтервалОпросаСек)));
		Путь = "/api/v1/agent/pull-wait";
	КонецЕсли;

	Ответ = ВыполнитьPOST(Настройки, Путь, Нагрузка);
	Если Ответ = Неопределено Тогда
		Возврат Неопределено;
	КонецЕсли;

	Возврат ПолучитьПолеJSON(Ответ, "command");

КонецФункции

Соединение висит на сервере до появления команды или до истечения wait_sec. Медиана времени от запроса модели до ответа из базы получилась около 1,4 секунды. Это диалог, а не «отправил и пошёл пить чай».

Отдельно про грабли, на которые я наступил сам. Изначально цикл переставал ждать после первой же обработанной команды, и следующий вопрос попадал в мёртвую зону до нового захода. Медиана была 6 секунд при обещанных полутора. Лечится одной строкой, находится только измерением. Мораль простая: если у вас long‑poll, мерьте медиану на серии, а не «ну вроде быстро».

Проблема вторая: доверить модели Выполнить()

Дать нейросети выполнять произвольный код в ERP — заявка на премию Дарвина. Но и не давать нельзя: половина реальных задач это «проверь, что получится, если провести вот так».

Здесь важно развести два случая, потому что защищаются они по‑разному.

Чтение

Никаких транзакций. Текст запроса проверяется до попадания в базу.

Сразу оговорюсь, потому что иначе это первым же комментарием напишут: язык запросов 1С по своей природе только на выборку, DML в нём нет, и УДАЛИТЬ платформа отклонила бы сама. Эта проверка не спасает базу, она делает две вещи. Отсекает галлюцинацию модели раньше и с внятным текстом вместо платформенной ошибки. И, что важнее, такая же проверка стоит на сервере: обработка находится на стороне клиента, и доверять ей нельзя.

// Содержимое литералов не проверяется: иначе номенклатура «Обновить прошивку»
// уходит в отказ. Части между кавычками чередуются: чётные вне литерала.
Функция ТекстБезЛитералов(Знач Текст)

	Части = СтрРазделить(Текст, """", Истина);
	Куски = Новый Массив;
	Для Инд = 0 По Части.Количество() - 1 Цикл
		Если Инд % 2 = 0 Тогда
			Куски.Добавить(Части[Инд]);
		Иначе
			Куски.Добавить(" ");
		КонецЕсли;
	КонецЦикла;
	Возврат СтрСоединить(Куски, " ");

КонецФункции

// Комментарии тоже вырезаются: «// удалить старую версию» рядом с запросом
// не должно приводить к отказу. Вызывать только после ТекстБезЛитералов.
Функция ТекстБезКомментариев(Знач Текст)

	Куски = Новый Массив;
	Для Каждого Стр Из СтрРазделить(Текст, Символы.ПС, Истина) Цикл
		Позиция = СтрНайти(Стр, "//");
		Куски.Добавить(?(Позиция > 0, Лев(Стр, Позиция - 1), Стр));
	КонецЦикла;
	Возврат СтрСоединить(Куски, " ");

КонецФункции

Процедура ПроверитьReadOnly(ТекстЗапроса)

	Нормализованный = " " + ВРег(ТекстБезКомментариев(ТекстБезЛитералов(ТекстЗапроса))) + " ";
	Разделители = "()[]{}.,;:|<>=+-*/" + "'" + Символы.ПС + Символы.ВК + Символы.Таб;
	Для Инд = 1 По СтрДлина(Разделители) Цикл
		Нормализованный = СтрЗаменить(Нормализованный, Сред(Разделители, Инд, 1), " ");
	КонецЦикла;

	Запрещенные = СтрРазделить("УДАЛИТЬ,ОБНОВИТЬ,ВСТАВИТЬ,DELETE,UPDATE,INSERT,DROP", ",");
	Для Каждого Слово Из Запрещенные Цикл
		Если СтрНайти(Нормализованный, " " + Слово + " ") > 0 Тогда
			ВызватьИсключение "Запрос отклонён: разрешены только запросы на выборку (найдено «" + Слово + "»).";
		КонецЕсли;
	КонецЦикла;

КонецПроцедуры

Обе вспомогательные функции появились не сразу, и обе из‑за ложных отказов на живых запросах.

Литералы вырезаются потому, что иначе номенклатура с наименованием «Обновить прошивку» валит запрос. В первой версии я собирал строку посимвольно, в цикле по СтрДлина, и это стоило около 0,27 секунды на тексте в 14 КБ. Строки в 1С неизменяемые, каждая конкатенация выделяет новую память, а на длинных пакетных запросах это заметно. Заменил на разбиение по кавычке: чётные части вне литерала, нечётные внутри. Чётность не сбивается даже на удвоенных кавычках внутри литерала, разделитель даёт пустую часть, и она ничего не добавляет.

Комментарии вырезаются потому, что модель любит писать // удалить дубли рядом с запросом. После нормализации / превращается в пробел, слово «удалить» становится отдельным токеном, и запрос отклоняется. Формально безопасно, практически бесит.

Оставшийся цикл по разделителям выглядит наивно, но это 24 прохода СтрЗаменить по строке, а не действие на каждый символ. На потолке в 20 000 знаков он не виден в замерах, и переусложнять его ради красоты я не стал.

Язык запросов при этом доступен целиком: пакеты, ПОМЕСТИТЬ и УНИЧТОЖИТЬ, соединения, ИТОГИ, виртуальные таблицы регистров. Ограничение только на изменение данных. Это принципиально: урежьте выборку, и модель начнёт компенсировать десятком мелких запросов вместо одного пакета. Получится и медленнее, и дороже.

Запись

Здесь транзакция, но с нюансом, из‑за которого пришлось написать некрасивый код. Задача: выполнить скрипт, получить результат, откатить. Сложность в том, что откат нужен такой, чтобы значение из скрипта до нас всё‑таки доехало, а настоящая ошибка не притворилась плановым откатом.

Функция ВыполнитьСкриптВТранзакции(Знач ТекстСкрипта, Знач ВыражениеРезультата, Знач DryRun, Знач БезопасныйРежим = Истина)

	МаркерDryRun = "КМ_DRY_RUN_ROLLBACK";
	Результат = Неопределено;

	НачатьТранзакцию();
	Попытка
		Результат = ВыполнитьТекстСкрипта(ТекстСкрипта, ВыражениеРезультата, БезопасныйРежим);
		Если Не ТранзакцияАктивна() Тогда
			ВызватьИсключение "Скрипт сам завершил транзакцию, откат невозможен.";
		КонецЕсли;
		Если DryRun Тогда
			ВызватьИсключение МаркерDryRun;
		КонецЕсли;
		ЗафиксироватьТранзакцию();
	Исключение
		ИнфОшибки = ИнформацияОбОшибке();
		Если ТранзакцияАктивна() Тогда
			ОтменитьТранзакцию();
		КонецЕсли;
		Если DryRun И СтрНайти(ПодробноеПредставлениеОшибки(ИнфОшибки), МаркерDryRun) > 0 Тогда
			Возврат Результат;
		КонецЕсли;
		ВызватьИсключение;
	КонецПопытки;

	Возврат Результат;

КонецФункции

Да, исключение как штатный поток управления, и строковый маркер вместо типа исключения. Мне это тоже не нравится, поэтому объясню, почему так.

Типизированных исключений на платформах, которые я обязан поддерживать, нет. Обработка работает начиная с 8.3.5, а там из инструментов есть только текст. Отличить плановый откат от настоящего сбоя больше нечем.

Просто вызвать ОтменитьТранзакцию() в ветке успеха нельзя: если скрипт внутри открыл и закрыл свои транзакции, счётчик поедет и вы получите либо исключение платформы, либо тихую фиксацию. Отсюда же проверка ТранзакцияАктивна(): скрипт от модели вполне способен закрыть транзакцию сам, и об этом лучше узнать явным исключением, чем молча зафиксированными данными.

Валидатор BSL, кстати, ругается на эту конструкцию и требует ОтменитьТранзакцию() первой строкой блока исключения. Я оставил предупреждение неисправленным сознательно: без проверки активности код падал бы на скриптах с вложенными транзакциями.

Плюс к этому сам Выполнить() идёт в безопасном режиме платформы, без файловой системы, COM и запуска внешних программ:

Если БезопасныйРежим Тогда
	УстановитьБезопасныйРежим(Истина);
КонецЕсли;

Снять безопасный режим может только администратор сервиса и только на конкретном ключе. Ещё есть признак, который закрепляет откат на ключе: тогда dry_run переписывается в истину на сервере, что бы ни прислал клиент. На этом признаке держится демо‑стенд, куда пускают всех желающих: скрипт исполняется, результат возвращается, база остаётся нетронутой.

Итого запись охраняют четыре независимые вещи: режим ключа, безопасный режим платформы, транзакция с откатом по умолчанию и серверный стоп‑лист на ЗапуститьПриложение, КомандаСистемы, COMОбъект и повышение прав. Никакого списка запрещённых слов там нет и быть не может: Док.Записать() в команде записи должен проходить, иначе режима записи просто не существует.

Проблема третья: модель не знает вашу базу

Вернёмся к Т.Номенклатура, с которой начали. Общая беда всех решений вида «подключим ИИ к 1С»: модель знает синтаксис и не знает метаданных. В доработанной базе процент попаданий печальный, и вы тратите время на исправление фантазий вместо экономии.

Скормить схему в контекст не выйдет. Выгрузка конфигурации УТ 11.5 это 41 раздел верхнего уровня, около 970 справочников и сотни тысяч файлов. Ни один контекст такого не вместит, а урезанная выборка «только то, что относится к вопросу» требует заранее знать ответ на вопрос.

Поэтому исходники конфигурации подключаются к ключу отдельно, в режиме только чтение, и модель получает три инструмента: поиск по содержимому, обход дерева и чтение файла диапазоном строк. Она ищет точечно: находит имя реквизита по слову, читает десять строк вокруг совпадения, видит фактические типы и только потом пишет запрос.

Разница примерно такая. Без исходников модель угадывает и промахивается на каждой доработке. С исходниками она сначала прочитала, что в регистре ВыручкаИСебестоимостьПродаж ресурсы называются СуммаВыручкиБезНДС и СтоимостьБезНДС, а номенклатура лежит за ключом аналитики, и запрос собрался с первого раза.

Вот что вышло на демо‑стенде из живого разговора, цифры не правил:

август 2023:    выручка 4 076 917, маржа 1 806 279  (44,3%)
сентябрь 2023:  выручка 6 897 855, маржа   718 656  (10,4%)

Выручка выросла на 69%, маржа упала в 2,5 раза. Причина нашлась в той же сессии: крупнейшая позиция периода, кондиционер, продана ниже себестоимости, минус 15 608 рублей, и это 24% выручки квартала. Плюс пять документов реализации с попарно одинаковыми суммами, созданные с интервалом в секунду. Похоже на дубли.

Никакой магии тут нет. Модель прочитала метаданные, написала три запроса и сопоставила результаты. То же самое делает консультант, только за минуту вместо часа.

Что дальше делает модель

Ничего от нас. Мы отдаём строки, а формулировку «выручка выросла, а заработали меньше» модель пишет сама на своей стороне. Подключение с её стороны выглядит так:

  • Claude — MCP‑коннектор (Model Context Protocol), одна ссылка в настройках, ключ уже внутри. Работает в обычном чате.

  • ChatGPT — Действие, импорт схемы OpenAPI, ключ в заголовке. Важно: без настроенного Действия обычный чат до базы не дотянется, у песочницы нет сети. Это ограничение ChatGPT, не наше.

  • IDE‑агенты и curl — обычный HTTP с заголовком.

Модель нужна актуальная и в режиме агента, умеющая вызывать инструменты в несколько шагов. Слабая не столько врёт, сколько не доводит цепочку: сделала запрос, не поняла результат, остановилась. Версии специально не называю, они меняются быстрее, чем живёт статья.

Скучное, но важное

Ключи. Ключ это и есть доступ к базе, поэтому вокруг него всё и построено. Режим только чтение или чтение и запись, проверяется на сервере. Отдельные ключи разным потребителям, у каждого свой след в журнале. Временные ключи на срок гаснут сами, права у них не шире родительских. Классический сценарий: сторонний внедренец, которому нужно посмотреть базу три дня, а не всегда.

Журнал. В кабинете видно, какой ключ что запрашивал и когда. Когда я договаривался с главбухом первого клиента, решающим аргументом оказался не мой честный взгляд, а то, что кабинет оформили на неё и протокол она видит сама.

Платформа. Внешняя обработка, конфигурация не меняется, с поддержки снимать ничего не надо. Работает начиная с 8.3.5, для платформ до 8.3.6 отдельная сборка: там нет ни объектов JSON, ни СтрРазделить, ни объектного HTTP‑API, и всё это заменено своими реализациями, а транспорт идёт через файловый ОтправитьДляОбработки. Упоминаю потому, что весь пласт легаси обычно игнорируют, а он живее всех живых.

Лицензии. Спросят обязательно. Сервис лицензий не занимает, он вообще не подключается к базе. Занимает лицензию сеанс 1С, в котором работает обработка. В клиент‑серверной это фоновое регламентное задание, и клиентская лицензия не расходуется. В файловой нужен запущенный сеанс, значит одна лицензия занята, обойти это нельзя. Число потребителей на лицензии не влияет: все команды идут через один сеанс. На демо через один сеанс работают двадцать ключей.

Про данные, честно

Наружу уходит результат конкретного запроса, а не база. Не выгрузка, не дамп. Модель видит ровно те строки, которые запросила.

Что остаётся у вас: какой ключ включён и когда, в каком режиме, и полный журнал обращений. Что остаётся на вашей ответственности: выбор модели и решение, какие данные вы соглашаетесь ей показать.

Утверждать, что данные не покидают контур, я не буду. Покидают, в объёме ответа. Зато вы точно знаете, в каком, и перекрываете это в любой момент.

Что не работает

Раздел, которого обычно нет в таких статьях, а он самый полезный.

Модель ошибается в смысле данных. Имена она теперь берёт из исходников, а вот что в этой компании считается отгрузкой, а что перемещением, ей неизвестно. Консультант тут незаменим, и это не расшаркивание, а наблюдение.

Файловые базы требуют живого сеанса. Обещать «работает без клиента» в файловом варианте было бы неправдой.

Пакетные запросы у старых сборок отваливались. Мы разрешаем УНИЧТОЖИТЬ ВТ и сами же рекомендуем его моделям, а сборка постарше отклоняла это слово как изменяющую конструкцию. Ловится только тем, что гоняешь свои же инструкции против своего же продукта. Я поймал это на собственном демо‑стенде за день до того, как сесть писать статью.

Задержка зависит от того, чем запущен обмен. Открытая форма с таймером даёт секунды, регламентное задание раз в две минуты даёт две минуты.

Демо‑стенд однажды упал вместе с компьютером. Пока поднимался PostgreSQL, я запустил файловую копию базы с тем же ключом. Обе базы отдавали одинаковое имя конфигурации, сервис их не различал, и запрос уходил в ту, которая первой дошла до опроса. Разбор полётов закончился отдельной задачей: различать базы по строке соединения, а не по имени.

Итог

Я не верю, что ИИ заменит 1С‑ников: учёт слишком суров, а смысл данных в контекст не запихнёшь. Но рутину он снимает, и снимает именно потому, что под капотом нет магии. Исходящий HTTPS, транзакция с откатом, проверка текста запроса и поиск по исходникам вместо угадывания.

Обработка лежит на GitHub: github.com/anoviul/‑anoviul‑onecdatalinkru Две сборки, обычная и под 8.3.5, README с описанием обмена и установки. Серверной части там нет, только то, что ставится в вашу базу.

Буду рад технической критике. Особенно интересуют случаи, где ПроверитьReadOnly пропускает то, что не должна. Найдёте текст запроса, который проходит проверку и при этом меняет данные, заводите issue, поправлю и укажу вас в комментариях.

Комментарии (0)