Всем привет! На Хабре регулярно появляются посты, так или иначе затрагивающие область права: от мировых антимонопольных споров до инициатив отечественных регуляторов. Но за громкими кейсами остается незамеченной другая интересная область — работа обычных юридических департаментов. В этой статье мы будем этот пробел восполнять: поделимся тем, как с помощью LLM анализировать поток из сотен договоров в ракурсе рисков и экономить на этом в год сотни часов работы юристов.
В юридическом департаменте нас интересуют два артефакта.
Таблица типовых рисков — десятки формулировок вида «если в договоре встречается условие X, оно создает риск Y». Любой документ в компании должен быть проанализирован в ракурсе таблицы, это жесткая внутренняя норма.
Поток договоров — собственно, сами договоры, которые необходимо проверять ежедневно. В год мы обрабатываем тысячи договоров. На оценку типовых рисков каждый требует в среднем около двух часов анализа: помимо проверки по таблице все необходимо прокомментировать.
Всю эту работу вполне можно автоматизировать с ИИ: изучение договора, сопоставление его с таблицей и выделение пунктов, которые отражают описанные в таблице риски. Юристу останется только проверить работу и вынести решение по договору.
Далее расскажем о том, как мы превратили это в LLM-пайплайн.
Сначала мы загрузили таблицу рисков из Excel и для удобства обработки преобразовали каждую строку во внутреннюю структуру данных (Data-класс) RiskRow со следующими полями:
number — порядковый номер риска (например, 4.6.4);
description — словесное описание условий риска;
consequences — последствия принятия условий риска;
nonfulfillment_risks — что будет, если условия не выполнить;
comment — примечания юристов.
Важно, что description — это не просто краткая метка риска, а развернутое текстовое описание условия на естественном языке. Мы используем её напрямую в промпте, как часть schema guided reasoning (структурированного рассуждения). О его реализации далее расскажем отдельно. Модель получает не только текст договора, но и формализованный «шаблон риска», для которого нужно найти соответствующие клаузы (пункты договора).
Параллельно в LLM закладывается политика риска — большой текстовый промпт с определением «что вообще считать договорным риском» и перечнем типичных источников этих рисков: нереалистичные сроки, зависимость от третьих лиц, некорректная подсудность, неудобный документооборот и т.д. Политика используется во «втором режиме» — policy-guided анализе неструктурированного текста.
Чтобы не оставалось ощущения, что «риск» — это абстрактная метка, приведем пару реальных примеров из таблицы типовых рисков. В дальнейшем такие описания риска объединяются в единый текстовый вход (risk_blob), который подаётся в LLM в составе SGR-промпта.
Финансовый риск: «Цена АЗС, но не выше цены контракта»
В договоре иногда указывают, что отпуск топлива осуществляется по текущей цене на АЗС, но при этом вводится ограничение — не выше цены, зафиксированной в контракте. Под ценой контракта здесь понимается заранее согласованный максимальный уровень стоимости, который не меняется автоматически вслед за рынком.
Почему это риск:
если рыночная цена топлива растёт, а контрактный «потолок» остаётся прежним, поставка может стать убыточной. В таких ситуациях возникают спорные перерасчёты: фактическая цена на АЗС выше, но к оплате принимается ограничение из договора. Отказ поставлять по убыточной цене может повлечь штрафы или санкции за нарушение условий, которые формально не считаются просрочкой.
Как это обрабатывается через ИИ:
· с помощью RapidFuzz, библиотеки для быстрого нечеткого сравнения текстов, из договора извлекаются пункты-кандидаты, где упоминаются цена, ограничения или перерасчёт;
· LLM решает, о чём речь: о ценовом потолке или о формуле цены без ограничения. Последний случай, например, бывает, когда стоимость просто следует за рыночной ценой, а предел для нее не зафиксирован.
Операционный/ штрафной риск: «Срок предоставления отчётных документов менее 5 дней»
В договоре указано, что заказчик требует выдать закрывающие/ отчётные документы очень быстро — например, раньше внутренних стандартных сроков.
Здесь мы рискуем из-за отклонения от стандартного документооборота. Сбой ЭДО, логистики оригиналов, согласования — и мы рискуем наткнуться на штраф не за просрочку поставок, а за нарушение сопутствующих обязательств по документам. Что здесь делает ИИ:
Отбирает пункты-кандидаты в clause-режиме, то есть когда договор заранее разбит на пронумерованные пункты. Такие пункты определяются по формулировкам со словами «документы», «срок предоставления», «закрывающие», «акт/счёт/УПД», «в течение N дней»;
Ищет количество дней и проверяет смысл. Относится ли найденный срок именно к отчётным документам, какие условия срока указаны («после периода», «после поставки», «после оплаты»), для кого и какие санкции предусмотрены за задержки. .
Перед тем как звать LLM, договор нужно привести к виду, который вообще можно отдавать для рассуждения. Документы, проходящие через систему, относятся к В2В и размещены в публичном доступе в ЕИС, поэтому сами по себе не содержат ограничений на передачу в LLM-модель. Это позволяет не вводить отдельный слой для деперсонализации, и сосредоточиться именно на структурировании текста и восстановлении нумерации пунктов. Так что ИИ анализирует исключительно содержание договора и не работает с персональными данными или иной чувствительной информацией
Принятие правок и очистка WordprocessingML
Договоры нам поступают в формате docx с включённым Track Changes (отслеживанием изменений). Риски часто прячутся именно в новых вставках. Вот как мы обрабатываем эти документы:
Распаковываем docx как zip.
В word/document.xml и во всех header*.xml/footer*.xml: узлы <w:del> удаляем (удалённый текст отбрасывается), узлы <w:ins> разворачиваем (вставленный текст поднимаем в поток документа).
Собираем временный «очищенный» docx и уже его отдаём библиотеке python-docx.
Фактически мы программно имитируем «Принять все изменения» внутри OpenXML.
Восстановление нумерации пунктов
У большинства договоров нумерация — не просто «4.6.4.» в тексте. Это абстрактные списки в numbering.xml, уровни (<w:lvl>), overrides и форматы (1., a), i)).
Что мы делаем:
Парсим numbering.xml через lxml.
Строим карту: numId → abstractNumId, для каждого уровня — формат и стартовое значение.
Для каждого абзаца с w:numPr восстанавливаем «визуальный» номер, который реально видит человек: 4.6.4, 4.6.4.1 и т. п.
Парсим numbering.xml через lxml.
Строим карту:
numId → abstractNumId,
для каждого уровня — формат и стартовое значение.
Для каждого абзаца с w:numPr восстанавливаем «визуальный» номер, который реально видит человек: 4.6.4, 4.6.4.1 и т. п.
Дальше из последовательности абзацев собираем клаузу:
Clause.number — полный номер (4.6.4);
Clause.text — текст пункта, включая все следующие за ним абзацы без номера (перечни/уточнения), пока не начнётся следующий пронумерованный пункт;
Clause.para_index — индекс абзаца для возможной привязки к исходному документу.
Именно с такими клаузами потом работает LLM.
Неструктурированный режим: скользящие чанки
Для приложений и фрагментов без понятной нумерации есть альтернативный путь:
вытащить весь текст,
порезать на чанки по ~1600 символов с перекрытием (например, 200),
каждому чанку присвоить индекс и границы в исходном тексте (start_char, end_char).
Это сырье для policy-guided режима.
За всю эту работу с договорами отвечает LLM-движок, функционирующий в двух режимах:
Структурированный режим: риски оцениваются по списку пронумерованных пунктов.
Неструктурированный режим: риски оцениваются по списку чанков текста.
Оба режима используют reasoning-модель семейства GPT-5 с reasoning.effort (интенсивность рассуждений) на среднем или низком уровне — по сути, это и есть CoT. А также используют четко заданную JSON-схему ответа (Schema Guided Reasoning, SGR).
Что мы называем SGR в этом проекте
SGR (Schema Guided Reasoning) в нашем контексте — это подход, когда:
в system-промпте мы жестко задаем схему выходных данных (JSON с конкретными полями);
в user-промпте даем строго структурированный вход (risk_blob + список кандидатов);
LLM не просто «отвечает текстом», а вынуждена заполнять эту схему, опираясь на свое пошаговое рассуждение.
Схема управляет ходом рассуждений: модель сначала определяет, какие пункты договора соответствуют описанному риску, а затем заполняет поля результата:
· clause_number — номер пункта договора (например, 4.6.4), где найден риск;
· confidence — оценка уверенности модели в совпадении по шкале от 0 до 1;
· reason_short — краткое пояснение (1–2 фразы), по каким признакам пункт считается рискованным.
Это не просто «форматирование вывода», а способ зафиксировать решение модели в проверяемом и интерпретируемом виде.
Опишем по порядку, что в этом режиме происходит.
Быстрый recall-фильтр
Для каждого риска можно взять конкатенацию:
номер + описание + последствия + риски неисполнения + комментарий
и через RapidFuzz найти топ-N клауз по простой метрике сходства (token_set_ratio). Это дешевый слой, который выполняет две важных задачи:
выбрасывает очевидно нерелевантные куски,
сохраняет высокую полноту (лучше взять чуть лишних кандидатов, чем потерять важный пункт).
На выходе получаем небольшой список Clause для каждого риска.
System-промпт: схема ответа
Дальше включается SGR. Вот упрощенный вид системного промпта структурированного режима (схема взята из реального кода):
{ "risk_number": "4.2.3", "contract_filename": "doc.docx", "matches": [ { "clause_number": "4.4", "confidence": 0.0-1.0, "reason_short": "кратко" } ] }
Здесь и проявляется Schema Guided Reasoning:
Мы заранее описываем поля схемы:
risk_number — номер риска из таблицы (например, 4.6.4);
contract_filename — имя анализируемого файла договора;
matches[*].clause_number — номер пункта договора, который соответствует риску;
matches[*].confidence — уверенность модели в совпадении (0–1);
matches[*].reason_short — короткое объяснение, почему пункт помечен как риск.
Модель в reasoning-режиме должна не просто выдать список номеров, а заполнить конкретные ячейки.
Пользовательский промпт: «risk_blob + clauses_blob»
Эта часть промпта собирается динамически:
В итоге модель видит конкретный риск в его полном контексте, короткий список пунктов-кандидатов на риск, с их номерами. В рамках схемы matches[ ] выдает только те пункты, которые действительно реализуют этот риск.
Пошаговое рассуждение: reasoning.effort="medium"
Вызов к OpenAI Responses API концептуально выглядит так:
{ "model": "gpt-5", "input": [ { "role": "system", "content": [ { "type": "input_text", "text": SYS-TEM_PROMPT_STRUCTURED } ] }, { "role": "user", "content": [ { "type": "input_text", "text": us-er_prompt } ] } ], "reasoning": { "effort": "medium" } }
Параметр reasoning.effort включает у модели пошаговое рассуждение (CoT), а JSON-схема сжимает итоговое решение в нужный формат. Снаружи мы видим только финальный JSON, без внутренних мыслей.
Для приложений и «неаккуратных» документов мы используем другой режим. В нем модель опирается не на нумерацию пунктов, а на общую политику рисков и список чанков текста.
Системный промпт неструктурированного режима
Системная часть задает схему и семантику:
{ "risk_number": "4.4.5", "contract_filename": "doc.docx", "hits": [ { "chunk_index": 12, "confidence": 0.83, "explanation": "…", "short_quote": "…" } ] }
Здесь снова используется SGR: схема hits[ ] задаёт, что считать найденным риском и как его описать:
· chunk_index — индекс фрагмента текста (чанка), где обнаружен риск;
· confidence — уверенность модели (0–1);
· explanation — краткое пояснение риска фрагмента;
· short_quote — обоснование для риска по мнению ИИ.
Пользовательский промпт: политика + чанки
Пользовательский промпт включает строку конкретного риска, политику с определением договорного риска и перечнем типовых источников, а также список чанков:
[12] текст чанка...
[13] текст чанка...
...
На выходе имеем массив объектов Hit. Потом мы его агрегируем по (contract, chunk_index) и фильтруем по порогу уверенности.
Промпты вполне могут подстроиться под один-два удобных договора. Чтобы так не произошло, эксперименты мы строили в классической схеме:
Разработка. На небольшом подмножестве договоров одного раздела отлаживаем системный промпт и стартовый порог уверенности под высокий recall — например, 0.8.
Валидация. Даем другой набор договоров из другого раздела.Фиксируем порог чуть ниже, чтобы поднять полноту — например, на 0.7
Тестирование. Прогоняем остальные разделы и договоры с установленным промптом и порогом, изменяем только документы на входе.
Принципиальные условия:
Модель не видит правильные ответы — размеченные человеком риски по пунктам. На вход она получает только описание риска из таблицы и текст пунктов договора. Вывод ей приходится делать самостоятельно.
Dev/val/test разделяется по договорам и разделам, а не случайно по строкам.
На инференс-этапе из гиперпараметров вручную устанавливают только порог уверенности. Так поведение системы будет достаточно прозрачно для бизнеса.
Модель не видит «разметку истины» — то есть заранее помеченные человеком ответы вроде «вот этот пункт = риск №4.6.4». На вход она получает только описание риска из таблицы и текст пунктов договора и должна сделать вывод самостоятельно;
Разделение dev/val/test идёт по договорам и разделам, а не по случайному сплиту строк из одного и того же документа;
Порог уверенности — единственный «ручной» гиперпараметр на инференс-этапе, что делает поведение системы достаточно прозрачным для бизнеса.
Вокруг LLM-ядра крутится вполне приземленный стек:
Бэкенд на FastAPI;
Очереди на Celery + Redis (отдельный воркер для тяжёлых LLM-тасков);
ORM на SQLAlchemy, хранение статуса задач и результатов в БД;
Файловое хранилище (shared volume) для загруженных документов, временных «очищенных» версий, итоговых отчетов в Excel.
Генерация отчётов на pandas + xlsxwriter: отдельные листы по договорам, подсветка рисков, аккуратная верстка;
Уведомления по почте с summary и ссылкой на отчет.
Вся тяжелая логика — разбор Word, запуск пайплайна, вызовы LLM c SGR/CoT и агрегация результатов — живёт в воркер-процессе и не блокирует веб-API.
Для начала подведем итоги на уровне AI-инженерии:
LLM использована не как «болтливый ассистент», а как строгий структурный классификатор, который работает по JSON-схеме и политике рисков.
Schema Guided Reasoning (SGR) реализован так. Системные промпты задают точную схему ответа. Пользовательские промпты собирают риск и структурированный список кандидатов. Reasoning-режим (reasoning.effort) заставляет модель рассуждать в рамках схемы.
Пошаговые рассуждения включены, но их результаты не идут в UI. Юрист видит компактные пояснение о риске и степень уверенности, а не поток мыслей модели.
Любой сырой документ Word превращается в последовательный набор пунктов для анализа — через предобработку и форматирование нумерации.
Экспериментальный протокол (dev/val/test) и явный порог уверенности дают понятный SLA по качеству и прозрачность для бизнеса.
У нас есть законченный LLM-модуль: работает с настоящими договорами в реальных юридических процессах. Опирается на нашу таблицу рисков, использует SGR и управляемый промпт-дизайн.
С точки зрения бизнеса эффект оказался измеримым на потоках документов. Вместо двух часов теперь на один документ юрист тратит минуты. В сумме при более тысячи договоров в год освобождаются сотни человеко-часов на экспертные задачи, которые точно требуют участия человека. Вероятность пропуска рисков тоже сократилась. В итоге модуль стал обязательной частью работы: он стабильно проводит первичный анализ и снижает нагрузку на команду.
Источник


