Файлы во владении: 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).
_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. Тип возврата: строка / строка / кортеж.
assistant._clip_at_sentence('короткий', 50) -> 'короткий…'. Многоточие
приклеено к тексту, который никто не обрезал.
Задание: «статья, у которой ПЛОСКИЙ текст 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; там оно не замерено живыми
данными и починено как класс.
_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Препарирование: После полимеризации композита...' —
целый раздел статьи, включая «Временное протезирование».
_measure_clip.py раздел [3]: 3 статьи (плоских 4003, 4020, 4043) влезают в
жёсткий предел Telegram 4096, но получают
[Показано 3072 символов из 4003; ещё 931 не поместились в одно сообщение].
У 2 из 3 в отрезанном хвосте есть цифры.
Приписка неправдива дважды: (а) текст помещается; (б) из 931 символа «не поместились» только 103 — остальные 828 выбросил слепой поиск границы.
_measure_clip3.py раздел [5], 6 входов x 44 предела = 264 вызова:
превышений предела: assistant=94 web_lookup=93 distiller=0
Обрезка «в бюджет 1200» может вернуть 1201 символ.
Задание: «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), зато там он клинический.
_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 отняло бы
у врача текст, который помещается.
Добавлены clip_at_sentence(text, limit) -> (текст, сколько НЕ показано) и
однострочный clip_at_sentence_text(text, limit) -> текст. Отличия от трёх
прежних копий:
- проверка «текст короче предела» — возвращается ВХОД и
0, без многоточия; - граница предложения
[.!?;]\sвместо'. '/'! '/'? '/'; '— точка с переводом строки теперь тоже граница; - вычистка висящего номера пункта (
\s*\n?\s*\d+\.\s*$) — была в двух копиях из трёх; - инвариант
len(результат) <= limit: в вырожденной ветке (весь бюджет — один токен) место под метку обрыва резервируется, а не добавляется сверх предела; dropped = len(source) - len(clipped), поэтому «показано + не показано» всегда равно длине входа — приписка врачу сходится;limit <= 0->("", len(text))вместо срезаhead[:cap-1]с отрицательным индексом.
Выбран КОРТЕЖ (текст, сколько не показано) как канон. Причина: счётчик нужен
двум вызывающим из трёх по делу — 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 входах.
Своя копия (29 строк) удалена, вместо неё from html_safe import clip_at_sentence. Оба вызывающих (build_prompt_log, prepare_fact)
распаковывают кортеж как раньше — контракт не менялся.
Своя копия (27 строк) удалена, вместо неё
from html_safe import clip_at_sentence_text as clip_at_sentence. Три
вызывающих (fit_budget, compose_answer x2) не менялись: им нужен текст.
Своя копия (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 стояли литералами без обоснования; - добавлена ветка «слова помещаются, за предел вывела разметка»: теги снимаются, но НИ ОДНО слово не теряется и приписки нет.
Вопрос задания: если разметка раздувает текст выше 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.
| что | до | после |
|---|---|---|
| статей с обрезкой (из 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 мг на кг» на месте, последнее
предложение целиком. Наивный разрез по последней '. ' на этом входе теряет
дозировку — это отдельная проверка в тесте, чтобы вход не выродился.
Разделы: [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 входах).
| # | диверсия | упало проверок | поймано |
|---|---|---|---|
| 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 — то есть она измеряет ровно то, что теряет врач, а не наличие регулярки в файле.
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 в каталоге, и мой новый тест добавил им проверок. Провалов
ни там, ни там нет.
- Живого Telegram не было. Что правка сообщения на 4 096 символов принимается, а на 4 097 отклоняется, взято из документации предела и не проверено на боте: бот работает на другой машине.
- Утверждение «предел считается по тексту без разметки» (на нём стоит ветка «слова помещаются, за предел вывела разметка») тоже не проверено живым Telegram. Если оно неверно, ветка отдаёт до 4 096 сырых символов разметки при плоском тексте до 4 000 — риск отклонения сообщения. Ветка на живых статьях вики не срабатывает НИ РАЗУ (тегов в вике нет), поэтому реальная поверхность риска — только ответы модели.
- Раздутие разметкой не замерено на живых данных вообще. В вике 0 фактов с тегами; случай построен синтетически. Сколько ответов модели попадает в окно «плоских <= 4000, сырых > 4096», неизвестно: логов ответов нет.
- 2 статьи всё ещё получают приписку, хотя влезли бы. Плоских 4 003 и 4 020 при замеренном заголовке 61 — они уместились бы в 4 096. Приписку они получают из-за резерва 96 на заголовок, взятого с запасом на будущие имена подтем. Число в приписке при этом честное (169 и 242 символа действительно не показаны). Сузить резерв до замеренных 61 значит поставить доставку сообщения в зависимость от длины имени подтемы: перебор отклоняет ВСЁ сообщение, и врач видит пустоту вместо статьи. Оставлено сознательно.
- 27 статей длиннее 4 096 теряют 22 396 символов — и это не лечится обрезкой в принципе (пункт для лида ниже).
distillerна живых данных обрезку не выполняет ни разу: максимум факта 5 477 противCONTENT_CHAR_CAP = 6000, максимум реплики 4 072 противMSG_CHAR_CAP = 4200. Смена его поведения (появление «…» в ветке по слову) проверена только на синтетике.- Тайминги не мерились — лана не про них, новых таймаутов и бюджетов вложенности не появилось.
- Коммит
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:файл. - Чужие тесты держат старые имена.
test_rag_quality.py:235,243зовётassistant._clip_at_sentence,test_web_lookup.py:223,238—W.clip_at_sentence,test_distill_pipeline.py:526-537,797—D.clip_at_sentence. Я их не владею, поэтому в трёх модулях оставлены имена-ССЫЛКИ (по одной строке импорта, без логики). Когда захотите убрать и их: перевести эти три теста наhtml_safe.clip_at_sentence/clip_at_sentence_textи удалить три строки. - Пагинация длинной статьи — настоящее лечение потери. 27 статей длиннее
4 096 символов теряют 22 396 символов (до 1 445 на статью), и обрезка тут
бессильна: одно сообщение их не вмещает. Врачу нужна кнопка «Дальше» на
странице статьи (
wiki_page:уже листает статьи, тут нужна вторая ось — страницы ВНУТРИ статьи) или досылка хвоста черезsend_message_chunks_async. Это правка обработчикаwiki_page:иwiki_save:вassistant.py, то есть уже не обрезка. benchmark.pyне разбирается парсером — первый символ BOM (U+FEFF),ast.parseпадает. Мой тест читает файлы какutf-8-sigи обходит это, но любая другая проверка, читающая репозиторий какutf-8, на этом файле молча теряет покрытие.clean_html_formattingсчитает плоский текст на КАЖДОМ вызове (одна регулярка по тексту). На 12 784 статьях полный прогон корпуса не изменился по ощущению времени, отдельно скорость не мерил.