Привет, Хабр.
Из каждого утюга вещают, что нейросети напишут за нас код и отправят выращивать кабачки. Давайте проверим. Попросите модель написать запрос к регистру накопления в УТ 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, поправлю и укажу вас в комментариях.