Я делаю систему, которая собирает черновики производственных инструкций — из описания задачи, документов предприятия и открытых стандартов. Устроена она вокруг одного принципа: ничто не считается подтверждённым по умолчанию. Каждое утверждение хранится с типизированным происхождением и статусом «не проверено», повысить статус может только запись о валидации от человека, генерация обязана работать и без внешней модели, а черновик приезжает к эксперту вместе со списком того, что ему предстоит проверить руками.

Всё это красиво ровно до того момента, пока не задашь неудобный вопрос: а мои собственные проверки качества — они вообще что-нибудь измеряют?

Я написал инструмент, который портит документы контролируемым образом и смотрит, реагируют ли метрики. Первым делом натравил его на себя: из 60 проверок 30 не падают никогда.

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

Оговорка, которую лучше сделать сразу. Ничего из перечисленного не означает, что система выдаёт плохие инструкции. Код генерации за всю историю ниже не менялся ни разу — менялось только то, чем его мерили. Мой способ измерять качество был грубее, чем я считал, а это, согласитесь, разные вещи.

Детектор, который кричал «волки»

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

Первая версия детектора — набор регулярок:

r"(?:помощник|работник|оператор|человек)\w*[^.!?]{0,60}(?:в опасн\w* зон\w*|в зоне движения)"

Она честно ловила «Помощник должен находиться в опасной зоне во время движения механизма». А ещё она ловила вот это:

«убедиться, что в зоне движения нет посторонних предметов, людей, кабелей»

Это не описание опасности, а требование безопасности — дословно противоположное по смыслу. Фраза, между прочим, из моего же каталога публичных источников: система подтягивает такие тексты как справочный материал и сама же на них срабатывает.

Регулярка не знает, что такое техника безопасности. Она знает, что рядом стоят «людей» и «в зоне движения». Разница между «сделай так» и «убедись, что так не будет» ей недоступна.

Почему не спасала проверка отрицания

Она была: код смотрел на 28 символов перед совпадением и искал «не», «нельзя», «запрещается». Это ловило «не отключать блокировку». Но в русских текстах по охране труда отрицание чаще стоит после:

  • «убедиться, что в зоне движения нет людей»

  • «проверить отсутствие посторонних предметов»

  • «в рабочей зоне не должно быть персонала»

Чем правильнее написан текст по безопасности, тем больше в нём таких конструкций.

Цена вопроса

Прогон демо-пака, 15 сценариев из разных отраслей:

до починки

после

risk_level: critical

14

0

low / medium

1 / 0

10 / 5

Средний балл

81.27

94.87

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

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

A detector that fires on correct safety writing teaches its users to ignore it.

Для детектора в системе безопасности precision важнее recall. Обычно учат обратному — лучше перебдеть. Но «перебдеть» работает ровно до момента, пока у тревоги есть адресат, который её читает.

Как чинил

Не усложнением регулярок, а сменой области видимости. Вместо окна перед совпадением — всё предложение вокруг него (app/evaluation/safety.py):

def _first_actionable_match(text, patterns):
    for pattern in patterns:
        for match in re.finditer(pattern, text, flags=re.IGNORECASE):
            prefix = text[max(0, match.start() - 28) : match.start()]
            if re.search(r"(?:не|нельзя|запрещ\w*|do not|must not|never)\s*$", prefix):
                continue
            if _ABSENCE_MARKERS.search(_sentence_around(text, match.start(), match.end())):
                continue
            return match
    return None

_ABSENCE_MARKERS — вторая регулярка, работающая в пользу текста:

_ABSENCE_MARKERS = re.compile(
    r"\b(?:нет|без|отсутств\w*|не\s+должн\w*|не\s+допуска\w*|исключ\w*|"
    r"no|not|without|free of|absence)\b",
    re.IGNORECASE,
)

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

Корпус, где половина примеров — про «не срабатывай»

Починить регулярку мало: через месяц добавлю паттерн, и всё вернётся. safety_adversarial_corpus.json: 26 кейсов, 14 враждебных, 12 доброкачественных, русский и английский. Ключевой негативный — та самая конструкция в чистом виде:

{
  "id": "ru-benign-prohibition",
  "context": "Не отключать защитную блокировку и не обходить ограждение.",
  "expected_codes": []
}

expected_codes: [] — правильный ответ тишина. Любое срабатывание тут роняет precision. Сейчас: precision 1.0, recall 1.0.

В тест-сете детектора негативных примеров должно быть не меньше, чем позитивных. Если все кейсы враждебные, вы измеряете recall и не измеряете precision. А сломается, скорее всего, precision — мир состоит в основном из нормальных текстов.


Шесть десятков проверок, и все выглядят разумно

Детектор починен, корпус зелёный, средний балл вернулся к 94.87. Слишком стабильно.

Уровнем ниже проверка выглядела осмысленной и не работала, и нашлось это только потому, что я залез внутрь. Отсюда следующий вопрос: а уровнем выше не то же самое?

Десять критериев, внутри — шесть десятков проверок. Каждая написана осмысленно: «указаны СИЗ», «у шагов есть ожидаемый результат», «есть действия при нештатной ситуации», и каждую я могу защитить в разговоре. Но защитить формулировку и приносить пользу — разные вещи:

А что, если проверка проходит вообще всегда?

Термометр, который всегда показывает 36.6. Прибор формально есть, а информации в нём ноль.

Как измерить

Берём документ, портим его контролируемым образом и смотрим, среагировал ли целевой критерий. Портить надо изнутри схемы: подсунуть оценщику пустой список СИЗ нельзя, отбракует Pydantic, да и настоящая модель так не делает. Она возвращает документ, формально безупречный: список СИЗ есть, в нём написано «не указано».

Первые четырнадцать мутаций:

Мутация

Что делает

placeholder_ppe

СИЗ есть, но написано «не указано»

placeholder_hazard_zones

опасные зоны есть, но написано «—»

placeholder_control_points

контрольные точки пустые по смыслу

vacuous_expected_results

у каждого шага результат «Выполнено»

vague_actions

каждое действие — фраза-наполнитель

duplicate_steps

все шаги повторяют первый

reversed_step_order

работа идёт задом наперёд

keyword_stuffing

содержание заменено словами, которые ищут проверки

off_topic_steps

шаги описывают постороннюю офисную процедуру

unsupported_numbers

точные значения без маркера проверки

internal_contradiction

один шаг снимает ограждение, другой отрицает это

strip_verification

ни у одного шага нет способа проверки

strip_safety_notes

ни у одного шага нет примечания по безопасности

confirm_unverified_claims

непроверенные утверждения помечены подтверждёнными

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

Результат

60 проверок. 37 не падают никогда.

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

«Мёртвая проверка» — это не приговор проверке, а показание о наборе атак. Правильная формулировка звучит скучнее и точнее: мои четырнадцать способов испортить документ не смогли отличить 37 проверок от константы True.

Кто в списке мёртвых:

  • «есть аварийные действия»

  • «есть блокеры утверждения»

  • «есть вопросы для экспертной доработки»

  • «есть контрольные точки»

  • «есть неподтверждённые параметры для проверки»

Почти все начинаются со слова «есть». Это проверки наличия поля, а не содержательности, а поле у меня всегда заполнено — генератор не оставляет пустых секций. Проверка «есть аварийные действия» мертва потому, что аварийные действия есть всегда. Она молчит не оттого, что сломана, а оттого, что условие выполняется, — и различить эти два диагноза можно только экспериментом.


А кто проверит проверяющего

Я посмотрел на распределение мутаций по критериям.

Критерий

Мутаций на него

clarity

5

safety

4

completeness

3

logical_sequence

3

request_focus

2

training_value

2

source_grounding

2

input_alignment

1

implementation_readiness

1

domain_risk_control

0

Ноль в последней строке, и при этом все пять проверок этого критерия числятся мёртвыми. Обвинение без суда: проверки не бесполезны, их просто никто не пытался сломать. Рядом такая же история — у implementation_readiness одна мутация на девять проверок.

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

Что вышло, когда мутации всё-таки написали

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

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

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

Проверка, которую нельзя убить

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

Мутация, которая не мутировала

Первая версия новых атак дала ложный результат: strip_profile_risk_terms не убивала целевую проверку в шести сценариях из пятнадцати. Две причины:

  1. Вычищался словарь только четырёх отраслевых профилей из двенадцати.

  2. Обход текстовых полей не заходил в step.common_mistakes — а итоговый текст, по которому считаются проверки, это поле читает.

Второе, кстати, чинило заодно ещё две мутации, которые тоже недорабатывали молча. Неудачная мутация выглядит ровно так же, как бесполезная проверка; мутационное тестирование само нуждается в отладке, и это не фигура речи.


Измеритель смотрел не туда

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

  1. Проваленная проверка печатает другую формулировку, чем пройденная

  2. Процентные суффиксы вида «(выполнено на 33%)» дают новую метку на каждое значение

  3. Одинаковые формулировки в разных критериях сливались в одну запись — «указаны СИЗ» живёт и в completeness, и в safety, и обе попадали в одну строку: evaluated: 450 при 225 оценках тогдашнего прогона

Починка упёрлась в неожиданное: объект оценки хранит только отрендеренный текст, идентификатора проверки в нём нет. То есть инструмент, который должен был анализировать проверки, вынужден был анализировать строки.

Решение — считать в самом харнессе, оборачивая функцию сборки критерия и снимая словарь проверок до того, как он схлопнется в списки строк. Ключ — пара «критерий + ключ проверки», причём приложение при этом не меняется вообще. После починки evaluated у всех проверок стало ровно 285 = 15 сценариев × (18 мутаций + 1 базовый). Ровное число вместо разнобоя от 12 до 450 — само по себе признак, что теперь считается то, что надо.

Третий раз подряд между мной и предметом стоял слой, который я принимал за предмет.


Пороги, которые печатались, но не читались

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

То есть любой, кто поменял бы порог в конфиге, получил бы новый порог в отчёте и старое поведение в гейте — тихо, без единой ошибки в логах.

Починено: пороги теперь читаются оттуда, где они объявлены.


Ворота, которые не знали про риск

Решение о прохождении принималось так:

"passed": all(checks.values()),
...
"risk_level": evaluation.get("risk_level"),

risk_level лежал рядом справочным полем и в checks не входил. Именно поэтому тот самый отчёт с 14 критическими показывал passed 15/15.

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

Задача выглядела на одну строку:

"risk_level_acceptable": evaluation.get("risk_level") != "critical",

Строка неправильная. Если оценка почему-то не вернула уровень риска, None != "critical" даёт True — и документ с неизвестным риском проходит ворота насквозь. Ровно тот класс ошибки, о котором вся статья.

Что стоит в репозитории (scripts/run_demo_eval.py):

RISK_ORDER = ("low", "medium", "high", "critical")

def _risk_level_acceptable(risk_level):
    if risk_level not in RISK_ORDER:
        return False
    return RISK_ORDER.index(risk_level) <= RISK_ORDER.index(CHECK_THRESHOLDS["max_risk_level"])

Порог max_risk_level лежит в конфиге рядом с остальными. Неизвестный уровень — не безопасный: чтобы пропустить сценарий, ворота обязаны увидеть уровень.

На текущем демо-паке эта проверка ни разу не срабатывает. Это страховка, а не различитель. Прямой прогон функции: critical → False, None → False, unknown → False.


И, наконец, эта самая статья

Неправильный однострочник из предыдущего раздела я не придумал ради наглядности. В первых версиях этой статьи он стоял там всерьёз, как описание того, что уже сделано. Правильный код с RISK_ORDER всё это время лежал в репозитории; я процитировал собственный план вместо собственной реализации и не заметил этого на двух вычитках подряд.

Фрагмент выглядел проверенным фактом, потому что я помнил, что собирался его написать. Разницу между «собирался» и «написал» различает ровно одно действие: открыть файл и прочитать. Отсюда правило, которое я теперь применяю к любому публичному тексту про свой код: в статью попадает только то, что прочитано из репозитория. Не из плана, не из задачи, не из памяти.

Заодно это ответ тем, кто спросит, почему автор так уверен в своих числах. Не уверен. Поэтому каждое из них прогнано скриптом против reports/*.json перед публикацией — и один раз этот скрипт тоже соврал, дав два ложных срабатывания на собственной регулярке. На восьмом уровне я решил остановиться.


Что из этого следует

Оглянитесь на список. Регулярка приняла требование безопасности за описание опасности, критерий принял наличие поля за его содержательность, харнесс принял отсутствие атаки за отсутствие дефекта, отчёт принял текст метки за проверку, гейт принял справочное поле за неучаствующее, конфиг принял публикацию за применение, а статья приняла план за реализацию. Ни один из этих слоёв не выглядел подозрительно. Каждый я мог защитить в разговоре — и защищал, пока не поставил эксперимент.

Отдельно стоит того, чего я не ожидал: конфигурация, которая печатается в отчёт, и конфигурация, которая используется в коде, — два разных объекта, пока вы не докажете обратное. У меня они совпадали случайно.

Общая форма такая: уверенность в контроле растёт быстрее, чем сам контроль. Каждый новый слой проверок добавляет ощущения защищённости немедленно, а собственно защиту — только если кто-то это отдельно проверил. Изнутри разрыв не виден ни на одном уровне, потому что на каждом инструмент выглядит работающим.

Если у вас есть набор булевых проверок — линтеры, health-check’и, скоринги качества данных, definition of done, критерии приёмки, — спросите себя: сколько из них хоть когда-нибудь возвращали False, сколько раз вы пытались это выяснить, и читает ли код те пороги, которые печатает в отчёт? Если ответ на второй вопрос «ни разу», то первые два числа вы не знаете. И третье, скорее всего, тоже.

Зачем я это опубликовал

Сначала о том, что здесь показано, а что нет. Ни один из семи уровней не про продукт — все семь про измерительный контур. Балл вырос при неизменном генераторе: выросло не качество документов, а точность прибора, которым его меряли.

Дальше — потому что альтернатива хуже. Все перечисленные дефекты существовали и до статьи, разница только в том, знал ли о них кто-то, кроме меня. Ложные срабатывания детектора не стали безопаснее оттого, что о них молчали: они ровно так же приучали пользователя игнорировать красный флаг.

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

Критерий у меня простой: если формулировка дефекта помогает читателю найти такой же у себя, её стоит написать.

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

Ссылки

Все числа воспроизводятся локально, ключ OpenAI не нужен — прогоны детерминированные:

make safety-eval            # корпус детектора, precision/recall
make quality-discrimination # мутации и отчёт по мёртвым проверкам
make demo-eval              # 15 сценариев, баллы и уровни риска

Корпус лежит в репозитории: examples/safety_adversarial_corpus.json. Отчёты пишутся в reports/ при запуске — в репозиторий они не коммитятся, чтобы в истории не копились машинные артефакты.