Как наш категоризатор учится на одном-единственном исправлении
Каждая операция в Norman получает категорию, и большинство из них никогда не касаются модели. Самое интересное – не LLM: это искусство превратить одно ручное исправление в память конкретной компании, которая никогда не утекает, обязана заслужить доверие и доказуемо закрепляется. Вот как мы это построили.
- Категория
- Общее
- Обновлено
- Автор
- Stan Kharlap
Каждой банковской операции в Norman нужна категория, прежде чем она станет бухгалтерией. Это не «приятная мелочь»: категория определяет налоговый режим, можно ли вычесть входной НДС, в какую строку предварительной декларации по НДС попадёт сумма. Ошибись здесь – и декларация будет неверной. В масштабе, в котором мы это запускаем, – около миллиона категоризированных операций на сегодня и шестизначный приток новых каждый месяц – вручную их не разберёт никто. Значит, это должен делать софт.
Очевидный способ построить такое в 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. Девять из десяти операций в продакшене уже несут категорию, а те, над которыми модель должна думать с нуля, – тающее меньшинство. Каждое исправление пользователя перекладывает ещё одного контрагента из корзины «спросить модель» в корзину «это мы уже знаем» – навсегда.
Исправление – самый сильный сигнал, который у нас есть
Точка захвата сознательно скучная. Когда 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 из базы: модели нечего делать с нашими первичными ключами, а нам нечего делать с надеждой, что она вернёт их корректно. Выше порога тот же паттерн срезает путь и проводит детерминированно. Одна память, две скорости: уверенное попадание полностью пропускает модель, неуверенное – направляет модель в сторону собственной истории этой компании.
Память одной компании – никогда не память другой
Изоляция по компаниям – это не комментарий, это uniqueness constraint на (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."""
Пустой оверрайд означает «возьми зашитый в код дефолт», так что худшее, что может натворить плохая строка промпта, – откатить нас к версии, которую мы и так выпускали. Промпт может меняться без деплоя, каждый прогон фиксирует, какая версия промпта его породила, а ночной replay говорит нам, действительно ли изменение помогло, прежде чем кто-либо на него обопрётся.
Скучные части – это и есть продукт
Существует более эффектная версия этой функции: окно чата, где ты говоришь ассистенту «проводи всё от Steam как Software», и он делает. Мы её не построили, потому что ценность не в моменте команды – она в том, чтобы никогда не отдавать команду во второй раз. Поэтому дизайн целиком опирается на негламурную механику. Каскад, который избегает модели всегда, когда может. Память, которая должна быть подтверждена, прежде чем ей поверят, и которая зарабатывает доверие заново, если ошиблась. Строгая изоляция по компаниям, обеспеченная constraint'ом, а не обещанием. И eval-цикл, который наутро может доказать, что твоё исправление действительно сработало. Полностью автоматизируй муторную работу, держи каждое защитное правило явным и умей показать, что система выучила ровно то, чему ты её учил, – и ничего из того, чего ты не хотел.
Norman берет операционную финансовую работу на себя
От invoicing до bookkeeping: Norman организует повторяющиеся финансовые процессы так, чтобы вы успевали к дедлайнам с меньшим объемом ручной работы.