Назад к разделу Technology

Как мы собрали агента, который сам подаёт твою декларацию по НДС

Autofiling полностью готовит и подаёт твою немецкую UStVA, от начала до конца. Сложным было никогда не само модель, а то, как сделать стохастического агента достаточно безопасным, чтобы он совершал юридически значимую подачу. Вот как устроен дизайн.

Категория
Общее
Обновлено
Автор
Stan Kharlap

Каждый квартал, а если ты крупнее, то каждый месяц, немецкий бизнес обязан отправить свою декларацию по НДС, Umsatzsteuervoranmeldung, в налоговую через ELSTER. У неё фиксированный срок, она не прощает арифметических ошибок, и ошибка стоит реальных денег. Большинство бухгалтерских программ помогают тебе заполнить форму. Мы хотели, чтобы софт просто делал декларацию: собирал цифры, проверял их и подавал, пока человек прочно держит под контролем тот единственный момент, который важен.

Эта функция называется Autofiling, и самое интересное в её создании почти не имело отношения к языковой модели. Модель, которая умеет читать чеки и складывать НДС, это базовая планка. Инженерная задача была противоположностью демо: как позволить стохастическому агенту совершить необратимый юридический акт от чужого имени и при этом иметь возможность потом доказать, что он сделал ровно то, что нужно?

Подача это юридический акт, а не чат

Первый рефлекс при любой агентной фиче: подключить модель к паре инструментов и отпустить её. Для чат-бота нормально. Для налоговой декларации такой дизайн это ответственность:

  • Подача не идемпотентна. Отправить UStVA в ELSTER дважды это не дублирующая строка, которую можно потом подчистить, это две декларации. Сбой в неудачную миллисекунду не должен приводить к повторной подаче.
  • «Скорее всего верно» это не планка. Результат должен быть проверяемым и точным. Никто не подписывает цифру, которую не может увидеть.
  • Это привязано к сроку. Агент не может тихо зависнуть. Если чего-то не хватает, это должно всплыть как конкретная, решаемая задача: сейчас, а не в день сдачи.

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

Пайплайн, а не чёрный ящик

Прогон Autofiling проходит через явные, сохранённые состояния: SCHEDULED → COLLECTING → RECONCILING → READY_FOR_APPROVAL → APPROVED → SUBMITTING → FILED, плюс два аварийных выхода, NEEDS_INPUT и FAILED, на случай, когда реальность не идёт навстречу. Состояние лежит в базе данных, а не в голове агента, поэтому прогон всегда можно возобновить и всегда можно проаудировать.

Пайплайн Autofiling: collect собирает транзакции, чеки и банковские данные; reconcile порождает типизированные блокеры готовности и может увести прогон в Needs input; preview рендерит точную ELSTER-payload и хеширует её; человек подтверждает этот хеш на человеческом гейте; submit коммитится идемпотентно перед внешним вызовом; прогон завершается в Filed с полным аудит-трейлом. Каждый шаг это один трейсируемый AgentRun.
Каждый переход сохраняется. Агент готовит; типизированные проверки останавливают; человек подтверждает точную payload; подача построена так, что сбой никогда не сможет отправить дважды.

Готовность, а не «на глаз»

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

Блокеры это не предупреждения свободным текстом. Это замкнутый набор машиночитаемых типов: uncategorized_transactions, unverified_transactions, possible_duplicates, unclaimed_input_vat, vat_conflict, expired_bank_sync, missing_tax_settings, elster_validation_error и ещё с десяток, каждый со степенью серьёзности info, warning или blocking. Блокер blocking останавливает прогон и толкает его в NEEDS_INPUT с конкретной, решаемой задачей («14 транзакций в этом периоде без категории») вместо расплывчатого «что-то не так». Информационные блокеры едут вместе с превью, так что человек их видит, но его не останавливают.

Смысл в том, что агента поощряют за то, что он выносит проблемы на поверхность, а не прячет их за самоуверенным ответом. Декларация, которую пока не следует подавать, никогда не доходит до точки, где это стало бы возможно.

Ты подтверждаешь payload, а не обещание

Когда прогон чист, он рендерит превью: точную ELSTER-payload, которая была бы передана, отрисованную через тот же движок отчётов, что делает настоящую подачу, просто в тестовом режиме. Затем эта payload хешируется, и хеш сохраняется на прогоне как preview_payload_hash.

Подтверждение привязывается к этому хешу. Когда человек подтверждает, мы записываем approved_payload_hash вместе с тем, кто подтвердил и когда. С этого момента оба хеша должны совпадать. Если что-то выше по потоку меняется: приходит поздний чек, исправляется категория, сдвигаются настройки НДС, payload перерендеривается в другой хеш, сохранённое подтверждение аннулируется, и прогон отказывается идти дальше на устаревшем согласии. Ты никогда не подтверждаешь «план агента». Ты подтверждаешь именно этот набор цифр, и система обеспечивает это буквально.

Подача, которая не может отправить дважды

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

def submit_approved_run(run: AutoFilingRun) -> AutoFilingRun:
    """Submit an approved run to ELSTER.

    Deliberately NOT one big transaction: the ELSTER POST is a non-idempotent
    external effect. SUBMITTING is committed *before* the external call, and
    the result is committed in its own transaction afterwards — so a crash
    can never roll the DB back to a state that would re-file the same UStVA.
    """

Ход, по порядку:

  1. В транзакции под блокировкой строки перепроверяется всё: статус, что подтверждённый хеш всё ещё совпадает с текущей payload, что аккаунт всё ещё имеет право подавать, затем прогон переводится в SUBMITTING и коммитится.
  2. Только после этого делается внешний вызов ELSTER, вне какой-либо транзакции.
  3. Итог, поданная декларация или конкретная ошибка, коммитится в собственной транзакции.

Поскольку SUBMITTING устойчиво до сетевого вызова, процесс, умерший в середине подачи, просыпается в состоянии, которое говорит «это уже было в полёте», и путь восстановления проверяет, принял ли ELSTER отчёт, прежде чем вообще отправить снова. Временный сбой провайдера откатывает прогон обратно в APPROVED, чтобы свипер мог его повторить, до ограниченного числа попыток, ни разу не потеряв подтверждение. Единственное, чего не может случиться никогда, это подать одну и ту же декларацию дважды. Всё остальное восстановимо.

Каждый шаг задокументирован

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

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

Скучные части и есть продукт

Было бы легко собрать более броскую версию этого: чат-окно, где ты просишь агента «подай мою декларацию по НДС», и он это делает. Это было бы и неправильной вещью для выпуска, потому что в тот момент, когда декларация оказывается неверной, демо-магия становится чьей-то проблемой с налоговой.

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

Norman берет операционную финансовую работу на себя

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