Когда я впервые получил распакованную форму, казалось, что задача уже решена. Вот дерево элементов, вот модуль, вот метаданные. Можно отдавать это модели.
На первом же вопросе стало ясно, что нет. Поле с Object.Counterparty не объясняет ни тип реквизита, ни синоним, ни то, действительно ли эта связь относится к конкретному элементу. А отправить в запрос всю распаковку - значит потратить бюджет на технический шум и случайно выдать модели предположение за факт.
Сначала я пытался получить максимум прямо из парсера: за один проход связать элементы формы, реквизиты и типы, в том числе по совпадению имён. На небольшом примере это выглядело убедительно, но на полном корпусе могло породить недоказанные связи. Поэтому я отказался от универсального парсера и разделил задачу на проверку отдельных фактов.
Все количественные результаты ниже получены на демонстрационной базе УТ 10.3. Они показывают эффект на этом проверенном корпусе, но не обещают такое же покрытие на других конфигурациях.
Коротко: что делать
-
Считайте elem.json, модуль формы и метаданные разными источниками фактов. Не соединяйте их по похожему имени.
-
Принимайте data_path только после проверки структуры. Неизвестная раскладка должна оставить пробел и добавить предупреждение.
-
Разрешайте ссылочный UUID (Universally Unique Identifier - уникальный идентификатор) через индекс всей выгрузки. Если ответа нет, сохраняйте Ref#<uuid>.
-
Собирайте один неизменяемый FormContext из семи полей, а реестр оставляйте реестром путей и контрольных сумм.
-
Держите в metadata только шесть безопасных полей и делайте пути относительными.
-
Формируйте фрагмент для модели в порядке FORM, SUMMARY, BSL (Built-in Script Language - встроенный язык 1С). По умолчанию возвращайте его полностью, а положительный max_chars задавайте только там, где нужен явный символьный лимит.
-
Очищайте не только поля, но и диагностические сообщения. Абсолютный путь в предупреждении - такая же утечка, как путь в метаданных.

Схема: распакованная форма, проверенные источники, FormContext и фрагмент для LLM с явным лимитом
Сначала доказать связь, потом обогащать
У формы есть минимум три разные правды. elem.json описывает дерево и привязки. Модуль хранит код обработчиков. Метаданные помогают понять, где лежат артефакты и можно ли им доверять.
Первой моей ошибкой было желание склеить их по именам. Это хорошо работает на демонстрационном примере и быстро ломается на реальной структуре: похожее имя ещё не делает элемент реквизитом. Поэтому наружу из декодера выходит только имя, тип и подтверждённый data_path.
Путь извлекается из data[*].raw лишь для распознанного формата. Повреждённая, неоднозначная или неизвестная раскладка не получает красивой догадки. Элемент остаётся без пути, а причина уходит в диагностику. Для модели это полезнее ложной уверенности.
Сегментный путь: два независимых признака
Самый неприятный случай - путь из нескольких сегментов. В сыром блоке рядом встречаются локальные идентификаторы, идентификаторы таблиц и UUID типов. Если просто найти первый UUID и назначить ему смысл, можно получить технически существующую, но неверную связь.
Я принял сегментный путь только при двух условиях:
-
UUID таблицы объявлен среди адресов определений этой же формы.
-
Склеенные имена всех сегментов посимвольно совпадают с именем элемента.
Это дало 377 новых подтверждённых data_path без изменения прежних путей. Именно отсутствие изменений здесь важнее самого прироста: новый разбор расширил покрытие, но не переписал уже доказанные связи.
Практическое правило: для нового формата нужны минимум два независимых подтверждения. Один найденный UUID - это повод исследовать раскладку, а не публиковать связь.
Ссылочный тип: индекс вместо догадки
У реквизита может быть не примитивный тип, а ссылка на другой объект. В таком случае декодер честно возвращает Ref#<uuid>. Это корректно, но человеку и модели от такой строки мало пользы.
Здесь мне помог не новый поиск в каждом объекте, а индекс, который строится при общем обходе выгрузки. scan_forms() уже проходит дерево, поэтому именно там логично сопоставить UUID и читаемое имя. Декодеру передаётся только функция разрешения:
index = scan_forms(export_root)
result = decode_object_attributes(
object_json,
type_resolver=index.resolve_reference_type,
)
Важная граница контракта: резолвер необязателен. Если его нет, он вернул None или завершился ошибкой, строка остаётся Ref#<uuid>, а обработка формы продолжается. Примитивные и уже понятные типы в резолвер не уходят.
Я сначала проверил одну удобную гипотезу о расположении UUID. Синтетические тесты были зелёными, а на полном корпусе она разрешила ровно ноль ссылок. Пришлось вернуться к структуре и индексировать все валидные UUID эквивалентных слотов блока, а не выдавать частную выборку за правило формата.
Из 5226 ссылочных реквизитов индекс разрешил 4670 в читаемые имена, а 556 остались неизвестными. Это не дефект, который надо замазывать. Для неразрешённого UUID правильный ответ системы - всё ещё Ref#<uuid>.
Практическое правило: синтетический тест проверяет контракт, но не доказывает устройство каждой реальной раскладки. Неизвестный ссылочный тип лучше сохранить неизвестным.
Где заканчивается реестр и начинается контекст
После разбора связей возник соблазн положить в запись реестра и чтение файлов, и выжимку структуры, и модуль. Я вовремя остановился. FormEntry должен оставаться карточкой указателей: путей, хэшей и служебных признаков. Иначе объект, пригодный для индекса, внезапно начинает зависеть от файловой системы.
FormContext решает другую задачу: материализует форму для одного запроса. Его публичный API (Application Programming Interface - программный интерфейс приложения) небольшой и намеренно скучный:
@dataclass(frozen=True)
class FormContext:
form_name: str
container_name: str
object_type: str
object_name: str
bsl_text: str | None
summary: FormSummary
metadata: dict
def build_form_context(form_entry, unpacked_root) -> FormContext: ...
def to_llm_prompt_fragment(context, max_chars: int = -1) -> str: ...
FormContext ровно семь полей. Он не запускает второй разбор структуры и не изобретает новый data_path: выжимка строится единственным путём через build_form_summary поверх parse_elem_json.
Внутри metadata ровно шесть полей: form_path, elem_json_path, bsl_sha256, elem_sha256, has_bsl и warnings. Первые два пути относительны. Содержимого модуля, абсолютных каталогов и лишних полей записи реестра там нет.
Отсутствие - не пустота
Я отдельно зафиксировал три состояния модуля формы. Это мелочь, пока не начнёшь объяснять форму модели.
|
Состояние |
Что попадает в контекст |
|
Файл есть и содержит текст |
bsl_text содержит текст, has_bsl равен True |
|
Файла нет |
bsl_text is None, has_bsl равен False |
|
Файл существует, но пуст |
bsl_text == "", has_bsl равен True |
Текст читается с явной кодировкой UTF-8 (Unicode Transformation Format, 8-bit - 8-битное представление Unicode). Если структуры нет, контекст получает пустые бакеты выжимки и предупреждения разбора. Если нет каталога формы, парсер не запускается вовсе. Эти детали не делают ответ модели красивее, но не позволяют ей принять отсутствие файла за пустой код.
Практическое правило: реестр хранит адреса, контекст хранит содержимое. И пустой модуль нельзя подменять отсутствующим, как и наоборот.
Полный фрагмент с явным лимитом
Полный FormContext удобен программе, но модели нужен предсказуемый текст. to_llm_prompt_fragment() собирает его всегда в одном порядке:
# FORM <object_type>/<object_name>/<container_name>/<form_name>
## SUMMARY
<детерминированная JSON (JavaScript Object Notation - текстовый формат обмена данными) выжимка>
## BSL
<текст модуля или сообщение об отсутствии>
Сначала идут структура и связи, затем код. По умолчанию max_chars=-1, поэтому функция возвращает полный фрагмент без скрытой обрезки. Положительный лимит применяется последним и по символам: при max_chars > 0 длина результата никогда не превышает заданное значение. max_chars == 0 и значения меньше -1 возвращают пустую строку.
Вот минимальный сценарий с синтетической выгрузкой:
from pathlib import Path
from v8unpack_agent import build_form_context, to_llm_prompt_fragment
from v8unpack_agent.scan_forms import scan_forms
root = Path("sample_export")
index = scan_forms(root)
context = build_form_context(index.forms[0], root)
fragment = to_llm_prompt_fragment(context, max_chars=4000)
assert len(fragment) <= 4000
assert fragment.index("## SUMMARY") < fragment.index("## BSL")
Сначала я поставил лимит 8000 значением по умолчанию. Решение выглядело практично, но у него оказался неприятный побочный эффект: вызывающий код мог потерять хвост модуля, хотя явно обрезку не просил. Теперь безопасное умолчание - полный фрагмент, а ограничение включается положительным max_chars в том месте, где уже известен бюджет запроса.
Символьный лимит остаётся сознательным компромиссом. Он прозрачен и не привязан к конкретной модели, но не обещает точный бюджет токенов. При очень малом лимите выжимка может оборваться в середине JSON, поэтому этот текст предназначен для модели, а не для машинного разбора.
Практическое правило: соберите полный фрагмент, проверьте порядок разделов и только затем режьте. Лимит на одном контексте не заменяет распределение бюджета между несколькими формами.
Диагностика не должна выдавать локальное окружение
Одна из самых неприятных поломок пришла не через поле metadata, а через warning парсера. Локально там был абсолютный путь к каталогу формы. Для отладки удобно, для внешнего контекста - нет.
Сначала я очистил путь на границе FormContext. Но те же предупреждения попадают в FormSummary и могут использоваться без FormContext, поэтому одной защиты на выходе оказалось мало. Очистка появилась у источника сообщений и осталась вторым барьером при сборке контекста.
Например, синтетическое предупреждение до очистки могло выглядеть так:
Метаданные владельца не найдены для
C:\Users\demo\dump\Catalog\Объект\CatalogForm\ФормаЭлемента
После очистки смысл сохраняется, а сведения о локальной машине исчезают:
Метаданные владельца не найдены для
.../Catalog/Объект/CatalogForm/ФормаЭлемента
Корень выгрузки, имя пользователя, буква диска и сетевой хост удаляются, но полезный хвост пути остаётся.
На контрольном прогоне число warning-текстов с абсолютными путями снизилось с 42 до 0. Проверка покрывает Unix-подобные и Windows-подобные пути, путь с буквой диска и сетевой путь. Не стоит ослаблять такой тест из-за одной необычной строки исключения: OSError вполне способен вернуть имя файла в собственной форме записи.
Эту же границу я проверил в четырёх вариантах CI (Continuous Integration - автоматическая сборка и проверка): Python 3.10 и 3.12 на Ubuntu и Windows. Отдельно тестируется ленивый корневой экспорт: обычный import v8unpack_agent не загружает модуль form_context. Он импортируется только при первом обращении к FormContext, build_form_context() или to_llm_prompt_fragment().
Практическое правило: диагностический текст - часть внешнего контракта. Очищайте его у источника и повторно проверяйте перед передачей за пределы локального окружения.
Границы слоя, которые я не стал прятать
FormContext собирает одну форму, но не ищет формы по большому корпусу и не решает, какой из них отдать модели. Он также не классифицирует форму и не токенизирует общий запрос. Попытка закрыть всё одним объектом быстро превратила бы прозрачный контракт в смесь несвязанных обязанностей.
Ограничения у решения тоже вполне земные:
-
max_chars измеряет символы, а не токены конкретной модели.
-
При жёстком лимите фрагмент может заканчиваться внутри JSON.
-
Без резолвера или при отсутствии записи в индексе ссылочный тип остаётся Ref#<uuid>.
-
Ноль привязок не всегда означает ошибку: сервисная или диалоговая форма может не иметь связанных с данными элементов.
-
Равенство агрегатных счётчиков не доказывает равенство множеств. Сравнивать изменения нужно по формам и обезличенным ключам.
Практическое правило: честная деградация - это не исключение и не догадка. Это частичный контекст с понятным признаком того, чего в нём нет.
Что бы я сделал иначе
-
Раньше проверил бы гипотезу о слоте ссылочного UUID на полном корпусе. Синтетические тесты подтвердили слишком узкую модель, и она дала нулевой полезный результат. Теперь индексируются все валидные эквивалентные слоты.
-
Сразу разделил бы FormEntry и FormContext. Поначалу было удобно дать записи реестра самой читать файлы, но это смешало бы адреса, содержимое и кэширование. Сейчас материализация происходит только в build_form_context().
-
Добавил бы проверку абсолютных путей в warnings вместе с первым текстовым выводом. Защита лишь в контексте закрывает один маршрут, а не проблему целиком. Теперь есть очистка у источника и на внешней границе.
-
Проверял бы ленивость каждого нового корневого экспорта в чистом дочернем процессе. Один «удобный» импорт способен незаметно вернуть тяжёлую цепочку зависимостей.
-
Не ставил бы 8000 лимитом по умолчанию. Такой контракт незаметно обрезал данные без явного решения вызывающего кода. Сейчас -1 возвращает полный фрагмент, а положительный лимит задаётся только там, где уже известен бюджет.
Чек-лист для применения
-
Пропустите elem.json, модуль и метаданные как независимые источники и не связывайте их по имени.
-
Добавляйте data_path только после структурного подтверждения формата.
-
Для сегментного пути проверьте адрес таблицы определений формы и точное совпадение собранного имени с именем элемента.
-
Постройте индекс ссылочных типов во время общего обхода и передайте его резолвер в decode_object_attributes().
-
Оставляйте неразрешённый ссылочный тип в виде Ref#<uuid> и записывайте причину вместо угадывания имени.
-
Создавайте контекст через build_form_context(form_entry, unpacked_root) и не добавляйте в него второй парсер структуры.
-
Ограничьте metadata шестью полями контракта и проверьте относительность обоих путей.
-
Покройте тестами модуль с текстом, отсутствующий модуль и существующий пустой модуль.
-
Проверьте, что to_llm_prompt_fragment(context) возвращает полный фрагмент в порядке FORM, SUMMARY, BSL, max_chars > 0 не превышается, а 0 и значения меньше -1 дают пустую строку.
-
Прогоните очистку warnings на Unix-подобном, Windows-подобном и сетевом синтетических путях.
-
Запустите чистый импорт в дочернем процессе и убедитесь, что модуль контекста не загружается до обращения к его символу.
-
Оставьте поиск по нескольким формам и токенизацию общего бюджета отдельному слою.
Что дальше
Контекст одной формы теперь можно собрать без подмены неизвестных связей. Следующий шаг - выбрать несколько релевантных форм из большого корпуса и распределить между ними общий бюджет запроса, не теряя признаки неполноты.
Ссылки
-
saby-integration/v8unpack - open-source распаковщик контейнеров 1С, Python, MIT (лицензия Массачусетского технологического института).
-
v8unpack-agent - публичная Python-надстройка для подготовки форм 1С к анализу агентом.
-
Обычные формы 1С в агентном конвейере: пошаговая распаковка - как получить текстовый слой формы.
-
СКД и дерево элементов обычной формы 1С - разбор elem.json.
-
Реестр форм 1С для агента: scan_forms и первый агрегат поверх распаковки - реестр форм и контроль дрейфа.
-
Управляемые формы 1С после распаковки: единый конвейер поверх *.elem.json - единый конвейер для обычных и управляемых форм.
Вступайте в нашу телеграмм-группу Инфостарт