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

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

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

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

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

Эта функция называется 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 и хеширует его; человек подтверждает этот хеш на Human Gate; 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 рендерится в другой хеш, сохранённое подтверждение становится недействительным, и прогон отказывается продолжать на основе устаревшего согласия. Ты никогда не подтверждаешь «план агента». Ты подтверждаешь ровно эти цифры – и система понимает это буквально.

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

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

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: к какому workflow она относилась, какой провайдер и какая модель работали, хеш входа и выхода, стоимость, задержка и точная PromptVersion, которая её породила. Выбор модели для каждого workflow – сам по себе политика: ModelPolicy с потолками по стоимости, задержке и уровню риска плюс запасная модель – а не константа, зарытая в код.

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

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

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

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

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

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