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

Как наш категоризатор учится на одном-единственном исправлении

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

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

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

Очевидный способ построить такое в 2026 году: навести LLM на описание и спросить. Это работает, и это же самая неинтересная часть. Модель, которая читает «Kartenzahlung STEAM PURCHASE Berlin» и угадывает Software, это базовый минимум. Настоящая инженерная задача лежит уровнем ниже: когда пользователь переигрывает машину и выбирает другую категорию вручную, это исправление становится лучшим обучающим сигналом, какой мы вообще когда-либо получим для этого бизнеса. Весь дизайн крутится вокруг того, чтобы его поймать и никогда не растратить впустую.

Самый дешёвый категоризатор тот, который ты никогда не вызываешь

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

def categorize(self) -> CategorizationResult:
    result = self.categorize_by_accounting_rules()
    if result.category or result.company_category:
        return result

    result = self.categorize_by_company_pattern()
    if result.category or result.company_category:
        return result

    return self.categorize_by_ai()

Сначала детерминированный движок бухгалтерских правил: заметно больше тысячи правил, курируемых и привязанных к компании, которые матчат по контрагенту, IBAN, знаку суммы и тому подобному. Сработало правило, и мы закончили: ни модели, ни задержки. Затем память компании, которой и посвящён этот пост. Только если оба промахнулись, мы тратим вызов LLM. Девять из десяти транзакций в продакшене уже несут категорию, а те, о которых модели приходится рассуждать с нуля, это сжимающееся меньшинство. Каждое исправление пользователя выталкивает ещё одного контрагента из корзины «спросить модель» в корзину «мы это уже знаем», навсегда.

Категоризация как трёхуровневый каскад и обучающая петля. Каскад сначала пробует бухгалтерские правила, потом память конкретной компании под названием CompanyPattern, затем LLM, и двигается вправо только при промахе. Внизу ручное исправление наращивает память на одну единицу свидетельства и создаёт обезличенный eval-кейс; ночной повтор заново прогоняет весь каскад, чтобы проверить, что исправление больше не повторяется.
Каскад пробует самый дешёвый уровень первым. Твои исправления питают память в середине и создают eval-кейс; ночной повтор доказывает, что исправление закрепилось.

Исправление это самый сильный сигнал, какой у нас есть

Точка захвата намеренно скучная. Когда PATCH меняет категорию транзакции, апдейтер замечает, что поле сдвинулось, хватает прежние значения до того, как их перезапишут, и планирует обучающую работу на момент после коммита в базу:

def capture_categorization_correction(self) -> None:
    # Implicit feedback for the learning loop: the user hand-picking a
    # category is the strongest training signal we have. Captured before
    # the setattr pass (we need the previous values), dispatched after
    # commit so a failed PATCH never records anything.

В этом комментарии живут два инварианта. Мы читаем старую категорию до записи, потому что исправление это разница между тем, что сказала машина, и тем, что выбрал человек, а как только строка обновлена, «до» уже исчезло. И мы отправляем через on_commit, так что PATCH, который по любой причине откатился, никогда не преподаёт системе урок, которого на самом деле не было. Ни одно исправление не выдумывается, ни одно не теряется.

Память, которая обязана заслужить доверие

Сама память это привязанная к компании таблица CompanyPattern. Докстринг называет единственное правило, которое важнее всего:

class CompanyPattern(TimestampedModel):
    """Company-scoped memory for agents.

    Grown from implicit feedback (user corrections) and consumed by the
    categorizer and the assistant: "for THIS company, this counterparty is
    Software", etc. Deliberately per-company: one company's corrections must
    never leak into another's suggestions.
    """

Ключ это не сырое банковское описание, которое полно шума в каждом платеже: id карт, дат, номеров ссылок. Мы вырезаем цифры и оставляем первые несколько стабильных токенов, так что Kartenzahlung STEAM PURCHASE 12345 Berlin и Kartenzahlung STEAM PURCHASE 67890 Berlin следующего месяца нормализуются в один и тот же ключ контрагента.

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

# A pattern must be confirmed at least twice before it applies deterministically.
CONFIRMED_EVIDENCE_THRESHOLD = 2

А когда более позднее исправление противоречит тому, что мы запомнили, побеждает самое свежее человеческое решение, но ему приходится заново с нуля зарабатывать доверие:

if pattern.value == value:
    pattern.evidence_count += 1
else:
    pattern.metadata = {**pattern.metadata, "previousValue": pattern.value}
    pattern.value = value
    pattern.evidence_count = 1

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

Одна и та же память, использованная двумя способами

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

Ниже порога, или когда направление денежного потока не совпадает, паттерну не доверяют решать в одиночку. Но это всё равно лучшая подсказка, какая у нас есть, поэтому он едет в промпт LLM как few-shot-блок, описывающий, как этот бизнес проводит своих повторяющихся контрагентов:

def company_pattern_examples(company, *, cashflow_type=""):
    """Few-shot block for the categorization prompt: how THIS company books
    its recurring counterparties. Names only, never ids."""

Вывод читается как 'steam purchase' -> Software; 'aws' -> IT services. Только имена, никогда id из базы, потому что модели нечего делать с нашими первичными ключами, а нам нечего доверять ей возвращать их обратно правильно. Выше порога тот же паттерн замыкает всё накоротко и проводит детерминированно. Одна память, две скорости: уверенное попадание полностью пропускает модель, неуверенное смещает модель в сторону собственной истории этой компании.

Память одной компании никогда не становится памятью другой

Изоляция по компаниям это не комментарий, это ограничение уникальности на (company, kind, key) и фильтр company= при каждом чтении. Фрилансер, который проводит Steam как деловой расход, и другой, который никогда бы так не сделал, это две отдельные памяти, которые никогда не видят друг друга.

Ещё одна защита заслуживает здесь своё место. Бухгалтерия фрилансера в Германии присваивает только листовые категории, никогда категорию-родителя верхнего уровня. Так что даже запомненный, дважды подтверждённый паттерн отклоняется, если указывает на родительскую категорию:

# Freelancer bookkeeping assigns only child categories: a remembered
# top-level parent must never be applied. SME companies use their own
# flat chart-of-accounts set and are exempt.
if category and category.parent_id is None and not company.is_sme:
    category = None

Памяти позволено ошибаться в сторону «спроси ещё раз», но никогда в сторону «проведи что-то, с чем нижестоящая налоговая логика не справится».

Доказать, что исправление действительно закрепилось

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

def categorization_replay_worker(input_payload):
    """Replay the eval case's source transactions through the CURRENT
    categorization pipeline (rules -> company patterns -> LLM) and report
    whether it now produces the user-corrected values.

    Eval cases are redacted (hashes, no raw values), so the replay resolves
    the source transactions by public id and compares stable hashes of the
    computed correction shape against the recorded ones.
    """

Две детали делают это достаточно безопасным, чтобы держать вечно. Eval-кейсы обезличены: они хранят хеши исправленной формы, а не сырые категории или описания, так что набор регрессионных тестов не несёт никакого клиентского содержимого. А проверка это сравнение стабильных хешей, computed_hash == recorded_hash, что даёт на каждый вывод чёткий булев shouldNotRecur вместо размытого скора, который кому-то приходится интерпретировать. Исправление, которое откатилось назад, загорается на следующее же утро.

Промпты это данные, а не деплой

Промпт категоризации это не строка, вмороженная в релиз. Это строка в таблице:

def get_active_prompt(workflow: str) -> tuple[str, str]:
    """Prompt-as-data: an empty body means "use the hardcoded default": the
    code is always a safe fallback, so activating/deactivating rows in the
    admin can never break the pipeline."""

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

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

Есть версия этой фичи поблестящее: чат-бокс, где ты говоришь ассистенту «проводи всё от Steam как Software», и он делает. Мы её не строили, потому что ценность не в моменте указания, а в том, чтобы никогда не пришлось давать это указание дважды. Поэтому дизайн целиком опирается на негламурную механику. Каскад, который избегает модели, когда только может. Память, которую нужно подтвердить, прежде чем ей доверять, и которая заново зарабатывает доверие, когда ошибается. Строгая изоляция по компаниям, обеспеченная ограничением, а не обещанием. И eval-петля, которая на следующее утро может доказать, что твоё исправление действительно сработало. Автоматизируй нудную работу полностью, держи каждую защиту явной и умей показать, что система выучила ровно то, чему ты её научил, и ничего из того, чему не учил.

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

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