Skip to content

Latest commit

 

History

History
381 lines (298 loc) · 28.5 KB

File metadata and controls

381 lines (298 loc) · 28.5 KB

Лана «одна обрезка по границе предложения» — _fix_clip.md

Файлы во владении: html_safe.py, assistant.py, web_lookup.py, distiller.py, test_clip_single.py (создан).

md5 ДО работы:

2d0931cc38...(md5, укорочен)  html_safe.py
16042cc806...(md5, укорочен)  assistant.py
9b1f1d4d69...(md5, укорочен)  web_lookup.py
e6af576b33...(md5, укорочен)  distiller.py

Скрипты замера: _measure_clip.py, _measure_clip2.py, _measure_clip3.py (боевые базы только mode=ro).


1. Замеры ДО правки

1.1 Три копии расходятся — ПОДТВЕРЖДЕНО, 6 из 8

_measure_clip.py раздел [1], 8 входов:

вход (предел) assistant web_lookup distiller
'короткий' (50) 'короткий…' 'короткий' ('короткий', 0)
'Короткий текст.' (100) 'Короткий текст.…' 'Короткий текст.' ('Короткий текст.', 0)
'Пункт первый.\n5. Следующий пункт' (25) 'Пункт первый.' 'Пункт первый.' ('Пункт первый.\n5.', 16)
'слово '*50 (60) ...слово… ...слово… (...слово, 240)
'а'*100 (10) 'аааааааааа…' (11 симв.) то же ('ааааааааа', 91)
'Одно предложение без конца...' (20) 'Одно предложение…' то же ('Одно предложение', 31)

Совпали только 2 входа из 8. Тип возврата: строка / строка / кортеж.

1.2 Главный дефект подтверждён: у assistant НЕТ проверки «влезает»

assistant._clip_at_sentence('короткий', 50) -> 'короткий…'. Многоточие приклеено к тексту, который никто не обрезал.

1.3 Число из задания ОПРОВЕРГНУТО

Задание: «статья, у которой ПЛОСКИЙ текст 3 817 символов, но с тегами 4 160». Замер по 12 784 живым фактам (_measure_clip2.py раздел [3]):

фактов с разметкой вообще:        0 из 12784
максимальное раздутие разметкой:  0 символов
фактов, где с тегами > 4000, а плоских <= 4000:  0
какие теги встречаются:           []

В вике НЕТ НИ ОДНОГО факта с HTML-тегом. Значит раздутие разметкой на статьях вики не срабатывает вообще, и пары 3817/4160 в базе не существует. Ветка обрезки включается по другой причине — по самой длине текста. Раздутие разметкой остаётся реальным для ОТВЕТОВ МОДЕЛИ (**жирный** -> <b>, <b>/<i>/<code> от модели), которые идут через ту же clean_html_formatting; там оно не замерено живыми данными и починено как класс.

1.4 Что реально теряет врач — БОЛЬШЕ, чем в задании

_measure_clip2.py раздел [1], бюджет 3900, 30 статей в ветке обрезки:

минимально возможная потеря (len - 3900):  19 607 символов
отрезано на самом деле:                    28 686 символов
ПЕРЕРАСХОД (выброшено сверх нужного):       9 079 символов
статей с перерасходом больше 200 символов: 19 из 30
худшая статья: плоских 4003, показано 3072 -> убрано 931 при нужных 103

Причина перерасхода (раздел [2]): граница предложения ищется как '. ' — точка ПЛЮС ПРОБЕЛ. Живые статьи разделены переводами строк, поэтому после точки идёт \n\n, и rfind('. ') его не видит:

статья 4003: последняя '. ' на 3075, последняя '.' + любой пробельный на 3834 (разница 759)
статья 4043: последняя '. ' на 3179, последняя '.' + любой пробельный на 3798 (разница 619)

Хвост, который врач не увидел (статья 4003): '\n\n6. Финальные этапы\n\nПрепарирование: После полимеризации композита...' — целый раздел статьи, включая «Временное протезирование».

1.5 Приписка врачу — неправда на 3 статьях

_measure_clip.py раздел [3]: 3 статьи (плоских 4003, 4020, 4043) влезают в жёсткий предел Telegram 4096, но получают [Показано 3072 символов из 4003; ещё 931 не поместились в одно сообщение]. У 2 из 3 в отрезанном хвосте есть цифры.

Приписка неправдива дважды: (а) текст помещается; (б) из 931 символа «не поместились» только 103 — остальные 828 выбросил слепой поиск границы.

1.6 Инвариант «результат влезает в предел» нарушен

_measure_clip3.py раздел [5], 6 входов x 44 предела = 264 вызова:

превышений предела: assistant=94  web_lookup=93  distiller=0

Обрезка «в бюджет 1200» может вернуть 1201 символ.

1.7 Ещё одно число из задания ОПРОВЕРГНУТО

Задание: «assistant.py строки 950 и 1018 зовут ту же функцию на КОРОТКИХ записях, каждая уходит в модель с приклеенным многоточием».

assistant.py:956  if len(entry) > _CORPUS_ENTRY_MAX_CHARS:
assistant.py:957      entry = _clip_at_sentence(entry, _CORPUS_ENTRY_MAX_CHARS)
assistant.py:1024 if position > 0 and len(line) > _PM_HISTORY_ENTRY_MAX_CHARS:
assistant.py:1025     line = _clip_at_sentence(line, _PM_HISTORY_ENTRY_MAX_CHARS)

Оба вызывающих СТЕРЕГУТ длину сами. Замер (_measure_clip2.py раздел [4]): _fit_corpus_budget(['Короткая запись без точки', ...]) возвращает записи БЕЗ многоточия; из 53 живых фактов длиннее 1200 многоточие получает 1. То есть «каждая запись уходит с многоточием» неверно; дефект был только на пути статьи (clean_html_formatting, строка 1224), зато там он клинический.

1.8 Бюджет статьи — откуда берётся число

_measure_clip3.py раздел [1]: 50 подтем, самое длинное имя 37 символов, заголовок страницы 📖 <b>имя</b>\n<i>Статья 3734 из 3734</i>\n\n = 61 символ без разметки; приписка = 75 символов. Страница статьи показывается через edit_callback_message -> tg_safety.edit_message, а правку сообщения на части разбить НЕЛЬЗЯ. Значит бюджет плоского текста статьи = 4096 - заголовок, а под показанный текст при обрезке = минус ещё приписка.

Распределение статей (раздел [2]): 3800..3900 — 7, 3900..4000 — 3, 4000..4096 — 3, больше 4096 — 27.

Отсюда порог трогать нельзя: 3 статьи в окне 3900..4000 сегодня доходят целиком (ветка включается по len(text) > 4000), и понижение порога до 3900 отняло бы у врача текст, который помещается.


2. Что изменено

2.1 html_safe.py — единственная реализация

Добавлены clip_at_sentence(text, limit) -> (текст, сколько НЕ показано) и однострочный clip_at_sentence_text(text, limit) -> текст. Отличия от трёх прежних копий:

  1. проверка «текст короче предела» — возвращается ВХОД и 0, без многоточия;
  2. граница предложения [.!?;]\s вместо '. '/'! '/'? '/'; ' — точка с переводом строки теперь тоже граница;
  3. вычистка висящего номера пункта (\s*\n?\s*\d+\.\s*$) — была в двух копиях из трёх;
  4. инвариант len(результат) <= limit: в вырожденной ветке (весь бюджет — один токен) место под метку обрыва резервируется, а не добавляется сверх предела;
  5. dropped = len(source) - len(clipped), поэтому «показано + не показано» всегда равно длине входа — приписка врачу сходится;
  6. limit <= 0 -> ("", len(text)) вместо среза head[:cap-1] с отрицательным индексом.

2.2 Контракт: один кортеж и одна однострочная обёртка

Выбран КОРТЕЖ (текст, сколько не показано) как канон. Причина: счётчик нужен двум вызывающим из трёх по делу — distiller логирует обрезку каждого факта, а приписка врачу в clean_html_formatting без честного числа превращается в украшение. Восстановить число по разнице длин снаружи нельзя: вычистка висящего номера убирает символы, которых в срезе нет.

clip_at_sentence_text оставлен как clip_at_sentence(...)[0] — одна строка тела. Это не начало новых трёх копий, и вот почему: у обеих точек входа ОДИН путь исполнения, разойтись им нечем, тогда как каждая из трёх удалённых копий несла свою логику (свои регулярки, свои пороги, свою ветку по слову). Поведенчески это проверено: clip_at_sentence_text(p, lim) == clip_at_sentence(p, lim)[0] на 1 008 входах.

2.3 distiller.py

Своя копия (29 строк) удалена, вместо неё from html_safe import clip_at_sentence. Оба вызывающих (build_prompt_log, prepare_fact) распаковывают кортеж как раньше — контракт не менялся.

2.4 web_lookup.py

Своя копия (27 строк) удалена, вместо неё from html_safe import clip_at_sentence_text as clip_at_sentence. Три вызывающих (fit_budget, compose_answer x2) не менялись: им нужен текст.

2.5 assistant.py

Своя копия (18 строк) удалена. _clip_at_sentence — теперь имя-ссылка на html_safe.clip_at_sentence_text.

clean_html_formatting переписан в части ветки потерь:

  • порог считается по ПЛОСКОМУ тексту (len(plain) > _ARTICLE_PLAIN_MAX_CHARS), а не по длине с разметкой. Именно из этого рассогласования росла приписка-неправда;
  • числа выведены из предела, а не вписаны: _TELEGRAM_HARD_LIMIT = 4096, _ARTICLE_HEADER_RESERVE = 96 (замер 61), _ARTICLE_PLAIN_MAX_CHARS = 4000, _ARTICLE_SHOWN_MAX_CHARS = 3904 (замер приписки 75). Прежние 4000 и 3900 стояли литералами без обоснования;
  • добавлена ветка «слова помещаются, за предел вывела разметка»: теги снимаются, но НИ ОДНО слово не теряется и приписки нет.

2.6 Про ветку HTML — решение и обоснование

Вопрос задания: если разметка раздувает текст выше 4096, правильный путь split_html, а не обрезка с потерей. Здесь так сделать нельзя, и вот почему:

  • clean_html_formatting возвращает ОДНУ строку, а её результат в 19 местах уходит в том числе в edit_callback_message -> tg_safety.edit_message. Правку сообщения на части разбить невозможно: split_html даст список, а показать врачу можно только первый элемент — то есть та же потеря, но молча;
  • жёсткий предел Telegram считается по тексту БЕЗ разметки, поэтому ниже 4096 сырых символов разметку можно не трогать вообще: длинный ответ уходит через send_message_chunks_async -> html_safe.split_html, и там каждая часть валидна сама по себе (это стережёт test_message_splitting), а правка сообщения принимается, потому что после разбора разметки текст короче предела;
  • выше 4096 сырых символов при помещающемся плоском тексте выбор такой: снять три тега или отрезать конец статьи. Отдать протокол без концовки ради сохранённого <b> — не обмен, а потеря, поэтому снимаются теги.

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


3. Замеры ПОСЛЕ правки (_measure_clip4.py)

что до после
статей с обрезкой (из 12 784) 30 30
символов не показано врачу 28 686 22 396
перерасход сверх непоместившегося 9 079 2 909
худший перерасход на одной статье 828 529
приписок-неправд (текст влезал с заголовком) 3 2
приписок с несходящейся арифметикой 0
выходов, не влезающих в 4096 с заголовком 0 0
превышений предела результатом (264 вызова) 94 / 93 / 0 0

Врачу возвращено 6 290 символов живого клинического текста. Три статьи из окна 4000..4096 показывают теперь 3 834 / 3 798 / 3 778 символов вместо 3 072 / 3 176 / 3 778.

Пример возвращённого текста: '\n\n7. Экспертные нюансы и выводы\nПлюсы методики:\n\nСпасение в безнадежных ситуациях: Позволяет использовать секционную матрицу там, где обычно пришлось бы ставить...' и '...его техника позволяет в 95% случаев...' — то есть числа с единицами.

Случай раздутия разметкой (построен руками, живых в вике нет): плоских 3 963, с тегами 4 243 -> приписки нет, дозировка «3 мг на кг» на месте, последнее предложение целиком. Наивный разрез по последней '. ' на этом входе теряет дозировку — это отдельная проверка в тесте, чтобы вход не выродился.


4. Тест test_clip_single.py — 72 проверки, поведенческие

Разделы: [1] одна реализация под тремя именами (1 008 входов) плюс разбор ast на возврат копии, [2] текст короче предела не трогается, [3] инварианты предела и арифметики, [4] висящий номер, [5] граница с переводом строки, [6] оборванный токен и единица измерения, [7] статья, которую вывела за предел разметка, [8] статья, которая правда не влезает, [9] живой корпус 12 784 статьи, [10] вызывающие (compose_answer, fit_budget, prepare_fact, _fit_corpus_budget, _fit_pm_history).

Проверок вида «в исходнике есть строка» нет ни одной. Единственная структурная проверка — «реализация только в html_safe» через ast: она стережёт не поведение, а возврат трёх копий, и поведенческий аналог у неё рядом (совпадение трёх имён на 1 008 входах).

5. Саботаж: 6 диверсий, все поймал тест

# диверсия упало проверок поймано
1 убрана проверка «текст короче предела» в html_safe.clip_at_sentence 25 да
2 граница предложения снова только [.!?;] + ПРОБЕЛ 2 да
3 убрана вычистка висящего номера пункта 3 да
4 порог ветки потерь снова по длине С РАЗМЕТКОЙ 2 -> 3 да
5 dropped = len(source) - limit вместо - len(clipped) 3 да
6 в web_lookup вернулась своя расходящаяся копия 2 да
7 снят резерв под метку обрыва (результат = limit + 1) 2 да

Ни одна диверсия не ломала файл синтаксически: после каждой py_compile проходил, тест ЗАПУСКАЛСЯ и падал на смысле.

Диверсия 4 в первой попытке уронила 2 проверки, но проверку «дозировка на месте» НЕ уронила: вход теста был короче бюджета показа (3 783 при бюджете 3 904), и неверный порог на нём текста не отнимал — сохранившийся страж в html_safe спасал. Это дырка в ТЕСТЕ, а не в коде: вход переделан в окно потери (3 963 символа, между 3 904 и 4 000), диверсия повторена — стало 3 упавшие проверки, включая потерю дозировки. Проверка «вход подобран верно» теперь стережёт и сам вход, чтобы он снова не выродился.

Диверсия 2 показательна числом: проверка по живому корпусу перескочила с 2 909 символов перерасхода на 9 025 — то есть она измеряет ровно то, что теряет врач, а не наличие регулярки в файле.

6. Восстановление продакшна

md5 ПОСЛЕ восстановления (совпадает с md5 сразу после правки, до диверсий):

html_safe.py    3fc7f551d690846cb68b12b0ac4d5225
assistant.py    cc9ff38d11a5e6d9f9f59edcfe6547ad
web_lookup.py   53c72dce70485c658ba6b09e70437ba6
distiller.py    695363bf65dc186c4a8e6f46c361a028

Резервные копии _clip.*.bak удалены (проверено: ls _clip.*.bak пусто). Боевые базы не менялись: stomat_wiki.db md5 2207af764967c505f1bfccdba63f129d, mtime 19 фев 17:48, файлов -wal/-shm рядом с stomat_wiki.db и stomat_archive.db нет.

Наборы после восстановления (все зелёные):

test_message_splitting   46/0     test_prompt_context      26/0
test_rag_quality         53/0     test_wiki_pagination     40/0
test_web_lookup         110/0     test_protocols_ui        32/0
test_search_command     123/0     test_commands_surface    73/0
test_distill_pipeline   145/0     test_validator_coverage  24/0
test_silence_policy      77/0     test_silence_all_paths   21/0
test_clip_single         72/0     test_import_safety      448/0
                                  test_isolation          198/0

test_import_safety было 444 -> 448 и test_isolation 196 -> 198: оба сканируют все .py в каталоге, и мой новый тест добавил им проверок. Провалов ни там, ни там нет.

7. Что осталось непроверенным

  1. Живого Telegram не было. Что правка сообщения на 4 096 символов принимается, а на 4 097 отклоняется, взято из документации предела и не проверено на боте: бот работает на другой машине.
  2. Утверждение «предел считается по тексту без разметки» (на нём стоит ветка «слова помещаются, за предел вывела разметка») тоже не проверено живым Telegram. Если оно неверно, ветка отдаёт до 4 096 сырых символов разметки при плоском тексте до 4 000 — риск отклонения сообщения. Ветка на живых статьях вики не срабатывает НИ РАЗУ (тегов в вике нет), поэтому реальная поверхность риска — только ответы модели.
  3. Раздутие разметкой не замерено на живых данных вообще. В вике 0 фактов с тегами; случай построен синтетически. Сколько ответов модели попадает в окно «плоских <= 4000, сырых > 4096», неизвестно: логов ответов нет.
  4. 2 статьи всё ещё получают приписку, хотя влезли бы. Плоских 4 003 и 4 020 при замеренном заголовке 61 — они уместились бы в 4 096. Приписку они получают из-за резерва 96 на заголовок, взятого с запасом на будущие имена подтем. Число в приписке при этом честное (169 и 242 символа действительно не показаны). Сузить резерв до замеренных 61 значит поставить доставку сообщения в зависимость от длины имени подтемы: перебор отклоняет ВСЁ сообщение, и врач видит пустоту вместо статьи. Оставлено сознательно.
  5. 27 статей длиннее 4 096 теряют 22 396 символов — и это не лечится обрезкой в принципе (пункт для лида ниже).
  6. distiller на живых данных обрезку не выполняет ни разу: максимум факта 5 477 против CONTENT_CHAR_CAP = 6000, максимум реплики 4 072 против MSG_CHAR_CAP = 4200. Смена его поведения (появление «…» в ветке по слову) проверена только на синтетике.
  7. Тайминги не мерились — лана не про них, новых таймаутов и бюджетов вложенности не появилось.

8. Для лида

  1. Коммит 0396b0d был снят во время моего саботажа и на один коммит содержал диверсию. В нём html_safe.py попал со строкой return clipped, len(source) - limit вместо return clipped, len(source) - len(clipped) — в таком виде приписка врачу врёт: показано плюс не показано не равно длине статьи (проверено, 3 упавшие проверки). УЖЕ ИСПРАВЛЕНО: коммит 12a97d1 подобрал восстановленный файл, git show HEAD:html_safe.py даёт md5 3fc7f551d690846cb68b12b0ac4d5225 — тот же, что на диске, рабочее дерево чистое. Ничего доделывать не нужно, но вывод на будущее: снимок дерева во время волны саботажа может поймать диверсию, потому что она живёт в боевом файле минуты. Сверяйте md5 из раздела 6 отчёта агента с git show HEAD:файл.
  2. Чужие тесты держат старые имена. test_rag_quality.py:235,243 зовёт assistant._clip_at_sentence, test_web_lookup.py:223,238W.clip_at_sentence, test_distill_pipeline.py:526-537,797D.clip_at_sentence. Я их не владею, поэтому в трёх модулях оставлены имена-ССЫЛКИ (по одной строке импорта, без логики). Когда захотите убрать и их: перевести эти три теста на html_safe.clip_at_sentence/clip_at_sentence_text и удалить три строки.
  3. Пагинация длинной статьи — настоящее лечение потери. 27 статей длиннее 4 096 символов теряют 22 396 символов (до 1 445 на статью), и обрезка тут бессильна: одно сообщение их не вмещает. Врачу нужна кнопка «Дальше» на странице статьи (wiki_page: уже листает статьи, тут нужна вторая ось — страницы ВНУТРИ статьи) или досылка хвоста через send_message_chunks_async. Это правка обработчика wiki_page: и wiki_save: в assistant.py, то есть уже не обрезка.
  4. benchmark.py не разбирается парсером — первый символ BOM (U+FEFF), ast.parse падает. Мой тест читает файлы как utf-8-sig и обходит это, но любая другая проверка, читающая репозиторий как utf-8, на этом файле молча теряет покрытие.
  5. clean_html_formatting считает плоский текст на КАЖДОМ вызове (одна регулярка по тексту). На 12 784 статьях полный прогон корпуса не изменился по ощущению времени, отдельно скорость не мерил.