Площадки перестали пускать машинный текст самотёком. Habr проверяет публикации автоматически, а Кевин Индиг пишет о том, что платформы строят anti‑slop‑системы — механизмы подавления массового низкокачественного ИИ‑контента. Дальше обычно следует совет писать хорошо, и на этом разговор заканчивается.

Мне нужно было проверяемое условие вместо совета, поэтому я собрал детектор на правилах и прогнал через него 59 собственных статей. Главный результат оказался не в том, сколько текстов он забраковал, а в том, что при агрегатном подсчёте он не забраковал ни одного, хотя дефекты были в сорока.

Что вообще ловится правилами

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

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

Категория с фиксированным словарём — самая простая:

def score_v4(text):
    """Повторяющиеся связки."""
    phrases = ["также", "кроме того", "помимо этого", "при этом",
               "в свою очередь", "более того", "вместе с тем"]
    tl = text.lower()
    found = {p: tl.count(p) for p in phrases if tl.count(p) > 0}
    max_count = max(found.values(), default=0)
    return 10 if max_count >= 5 else (3 if max_count >= 3 else 0)

Категория со статистикой по тексту — про ритм:

def score_v2(text):
    """Монотонность ритма: разброс длин предложений."""
    sentences = split_sentences(text)
    if len(sentences) < 3:
        return 0
    sd = stdev(len(s) for s in sentences)
    return 15 if sd < 3 else (7 if sd < 5 else 0)

Порог 3 подобран не из теории. Я посчитал стандартное отклонение длин предложений на своих текстах и на нескольких, написанных моделью без правки: у человеческих оно почти всегда выше пяти, у машинных сползает к двум‑трём. Это разделяющий признак на моём корпусе, а не универсальная константа.

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

LITERAL_CTX = re.compile(
    r'(запрос|ключ|фраз|объявлен|поиск|вбива|ищут|семантик|выдач|'
    r'сниппет|подсказк|промпт)', re.IGNORECASE)

def in_literal_context(paragraph: str) -> bool:
    """В абзаце сказано, что внутри кавычек — запрос или объявление?"""
    return bool(LITERAL_CTX.search(paragraph))

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

Прогон по 59 статьям

По сумме баллов не забракован ни один текст, локальные дефекты — в сорока.
По сумме баллов не забракован ни один текст, локальные дефекты — в сорока.

Дальше я прогнал детектор по всем своим финальным текстам — 59 файлов, разные форматы и площадки, писались на протяжении нескольких месяцев.

import glob, json, subprocess, statistics

rows = []
for path in sorted(glob.glob("articles/*/final.md")):
    out = subprocess.run(
        ["python3", "detector.py", path, "--json"],
        capture_output=True, text=True,
    )
    rows.append(json.loads(out.stdout))

scores = [r["auto_score"] for r in rows]
print(len(rows), statistics.median(scores), max(scores))
print(sum(1 for r in rows if r["hard_flags"]))

Результат: медиана суммарного балла — 3, среднее — 3,5, максимум по всему корпусу — 19. Порог, при котором текст считается требующим доработки, у меня стоял на 40. То есть по агрегатной сумме не забраковалась ни одна статья из 59.

При этом хотя бы одна категория была выбита у 40 текстов из 59.

Почему сумма врёт

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

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

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

hard_flags = []
if v9_score >= V9_MAX:
    hard_flags.append(f"В9: {v9_count} кавычки на обычных словах")
if v11_score >= V11_MAX:
    hard_flags.append("В11: слово под полным запретом")
...
if hard_flags and exit_code == 0:
    exit_code = 1        # сумма низкая, но категория в потолке

После этого изменения детектор стал ловить те самые 40 текстов, которые раньше проходил молча.

Что выбито в сорока текстах

Четыре категории из тринадцати дают почти все срабатывания на корпусе.
Четыре категории из тринадцати дают почти все срабатывания на корпусе.

Разбивка по категориям оказалась неожиданно узкой. Четыре категории дают почти все срабатывания: кавычки на обычных словах — 28 текстов, запрещённые слова и жаргонные англицизмы — 27, подзаголовки‑ярлыки без темы — 25, абзац, начинающийся с анонса вместо сути, — 13. Остальные девять категорий вместе дали четыре случая.

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

Второй срез — по времени. Тексты, написанные до 5 августа 2026 года, дают хотя бы один флаг в 100% случаев (19 файлов). Написанные после — в 53% (40 файлов).

Соблазн объявить это эффектом детектора велик, и он неправомерен. Часть правил появилась именно в августе, то есть ранние тексты проверяются требованиями, которых при их написании не существовало. Корректная формулировка: доля текстов, нарушающих текущий набор правил, со временем упала вдвое, а вклад детектора и вклад изменившихся требований на этих данных не разделяются.

Чего этот детектор не делает

  • Он не отличает машинный текст от человеческого. Он находит признаки невычитанного текста, а таких хватает и у людей.

  • Корпус мал и однороден: 59 текстов одной редакции на одну тему. Пороги подобраны на нём и на другом корпусе поедут.

  • Правила ловят русскую стилистику. На английском половина категорий бессмысленна.

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

Что я забрал из этого

Ранние тексты проверяются правилами, которых при их написании не было.
Ранние тексты проверяются правилами, которых при их написании не было.

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

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

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

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