Як штучний інтелект для виправлення документів обманув себе: хибні позитивні результати в сандбоксі Docker
Агент для виправлення документації LangGraph надіслав фікс, який не мав жодного ефекту, а потім пошкодив пакети src-layout. Причиною була помилка в Docker-сандбоксі, а справжнє виправлення він знайшов у компоненті click.
Джерела: **I built an AI Doc-Fixing Agent. Here’s every way it fooled itself first.**
Штучний інтелект, створений для виправлення пошкоджених зразків коду в документації, дав дивний результат у своєму першому справжньому репозиторії. Він „виправив“ баг, якого насправді не існувало: взяв абсолютно правильний рядок, нічого не змінивши, та опублікував той самий текст як підтверджене виправлення.
- print(tqdm.__version__, sys.version, sys.platform)
+ print(tqdm.__version__, sys.version, sys.platform)
Обидві лінії збігаються. Агент переконав себе виправити те, що ніколи не було неправильним, і це був лише перший із трьох способів, якими він обманював себе до того, як з’явився справжній винуватець.
Що робить DocuPatch
DocuPatch — це агент, керований LangGraph. Він проглядає файли Markdown у репозиторії GitHub, виділяє блоки коду Python та запускає їх у ізольованому контейнері Docker. Зразки, які справді не працюють, надсилаються до Gemini для виправлення; виправлення знову перевіряється в тому ж ізольованому середовищі, і лише після підтвердження результату він стає гілкою чи запитом на з’єднання.
Структура репозиторію:
agent/ingest.pyклонує репозиторій та знаходить файли Markdown.agent/graph.pyзапускає потік обробки LangGraph для кожного файлу.agent/parser.pyвиділяє блоки коду Python.agent/tester.pyзапускає кожен фрагмент коду всередині контейнера Docker.
agent/context_collector.py збирає відповідні Python-файли та код для уривка.agent/fixer.py створює виправлений уривок.agent/github_client.py створює форк, зберігає виправлення та відкриває PR-запити.tests/ містить тести для кожної частини.Суть проста: код документації з часом виходить з ладу. API змінюються, функції перейменовуються, приклади перестають працювати, і ніхто цього не помічає, поки хтось не вставить несправний уривок з README. Агент автоматизує виявлення проблем. Він знаходить підозрілий уривок, перевіряє, чи він несправний, виконавши його, просить ШІ про виправлення, перевіряє, чи виправлення працює, знову його виконавши, і лише тоді відкриває pull request.
У цьому конвеєрі є етап клонування та аналізу, який подає дані у цикл з перевіркою доказів, а виправлення залишається лише після двох підтверджень: спочатку оригінал не спрацював, а замінник — спрацював.
Керівним принципом є перевірка на наявність доказів. Ніщо не потрапляє до LLM, якщо тест у сандбоксі не покаже, що воно несправне, і ніщо не публікується, якщо повторний тест у сандбоксі не підтвердить, що виправлення працює. Цей принцип залишився. Сам сандбокс, проте, на довгий час не вижив.
Перший тест: tqdm
Початковий реальний тест був спрямований на tqdm/tqdm:
Found 9 Markdown file(s). Starting audit...
Auditing: .github\ISSUE_TEMPLATE\bug.md
Generating repair for snippet #1
Verifying generated repair for snippet #1
Publishing verified repair(s)...
VERIFIED REPAIR: Branch published
Два файли були „виправлені“ та опубліковані, що на папері виглядає як успіх. Потім ми переглянули справжні відмінності:
- print(tqdm.__version__, sys.version, sys.platform)
+ print(tqdm.__version__, sys.version, sys.platform)
Знову дві рядки є ідентичними. Gemini нічого не змінила та генерувала код байт за байтом. Система протестувала фрагмент коду, оголосила його несправним, „виправила“ його, повернувши той самий код, знову протестувала, побачила успіх та виконала коміт без жодних змін у форку.
Це не була помилка, яку потрібно було виправити. Це був хибний позитив на рівні тестування, оскільки початковий фрагмент коду ніколи не був несправним. Рядок, який імпортує tqdm та sys та виводить інформацію про версію, є цілком звичайним прикладом використання tqdm, тож чому сандбокс повідомив про невдачу? Це питання було відкладено як підозріле, і було спробовано інший репозиторій, щоб отримати ще один даний перед тим, як робити висновки.
Другий тест: pallets/click
У проекті pallets/click цей шаблон було неможливо не помітити:
Auditing: docs\advanced.md
Generating repair for snippet #2
Verifying generated repair for snippet #2
[Reflection Loop 2/3] Revising repair for snippet #2
...
[Reflection Loop 3/3] Revising repair for snippet #3
NOT PUBLISHED: Generated repair failed verification:
ModuleNotFoundError: No module named 'click'
Одна помилка щоразу. Кожен із шести файлів пройшов через три повні цикли відображення, і кожна невдача була такою ж: ModuleNotFoundError: No module named 'click'.
Саме ця повторюваність є вказівкою. Коли шість файлів та шість окремих спроб використання LLM зазнають невдачі через одну й ту саму помилку імпорту, спільним фактором не є код — це середовище, у якому вони працюють. Переписування фрагменту коду не може виправити відсутність модуля, якщо сандбокс не може бачити цей пакет, незалежно від того, що там написано.
Відкриття файлу tester.py розкрило механізм:
command.extend(["-v", f"{project_dir}:/workspace", "-w", "/workspace", "-e", "PYTHONPATH=/workspace"])
Усередині контейнера клонований репозиторій монтується, а PYTHONPATH встановлюється на верхній рівень репозиторію. Це працює, коли каталог пакету знаходиться саме там, але не працює, коли пакет розташований на рівень нижче. У проекті Click його pyproject.toml зберігає справжній код у каталозі src/click/, а не у каталозі click/ у корені, як це роблять багато сучасних проектів за схемою src-layout. З PYTHONPATH=/workspace інтерпретатор шукав у каталозі, де взагалі не було каталогу click.
Ручна перевірка поза потоком обробки підтвердила це:
docker run --rm -v "C:\temp\click_test:/workspace" -w /workspace -e PYTHONPATH=/workspace python:3.10-slim python -c "import click"
# ModuleNotFoundError: No module named 'click'
docker run --rm -v "C:\temp\click_test:/workspace" -w /workspace -e PYTHONPATH=/workspace/src python:3.10-slim python -c "import click; print(click.__version__)"
# import works... but:
# importlib.metadata.PackageNotFoundError: No package metadata was found for click
Другий результат навчив найбільше. Вказівка PYTHONPATH на правильну папку вирішила проблему імпорту, проте click.__version__ все одно припиняв роботу, оскільки поточні версії click генерують __version__ за допомогою importlib.metadata, який читає метадані встановлених пакетів, а не вихідні файли. Ці метадані існують лише після справжнього встановлення за допомогою pip, а ніколи після простого маніпулювання шляхом.
Отже, існували дві проблеми. PYTHONPATH вказував на неправильну папку для репозиторіїв зі структурою src-layout. А навіть якщо він вказував правильно, простий трюк з PYTHONPATH не забезпечує належного встановлення пакету, тоді як все більше пакетів очікують повного встановлення, а не просто можливості імпорту.
Один обхідний шлях заслуговує на згадку, адже це був найпізнавальніший етап. Багато часу було витрачено на спроби зрозуміти особливості монту Docker, які діють лише в Windows, проводячи експерименти з кавичками, форматами шляхів та списками аргументів у порівнянні з рядками командної оболонки. Справжнє пояснення виявилося простим: тестова папка була порожньою, тому що попередній запуск git clone мовчки не вдалося завершити її заповненням, і ніхто цього не перевірив. Порожній результат команди dir C:\temp\click_test не був „ще немає відповіді“, а саме був відповіддю, проте його проігнорували тричі, перш ніж хтось зупинився, щоб його прочитати.
Рішення: встановіть, не здогадувайтесь
Справжнім рішенням є не просто точніше вгадування шляху, а взагалі відмова від вгадувань. pip install -e . читає власний файл pyproject.toml або setup.cfg проекту, визначає правильне місцезнаходження пакету незалежно від його структури та створює справжні метадані інсталяції, тож виклики importlib.metadata функціонують так само, як у будь-якого справжнього користувача бібліотеки.
Логіка нового файлу tester.py виконується у такій послідовності:
- Якщо існують файли
pyproject.toml,setup.pyабоsetup.cfg, сандбокс створює образ Docker та запускає в ньому командуpip install -e .. Це є „режимом інсталяції“, у якому можна довіряти повідомленням про помилки під час імпорту. - Якщо немає метаданих пакетування, але є файл
requirements.txt, використовується попередній підхід з PYTHONPATH – „режим pythonpath“.
Після змін існуючий набір тестів показав, що 26 з 27 тестів пройшли без змін. Один невдалий тест справді мав зазнати невдачі, оскільки він перевіряв стару сигнатуру функції, яку навмисно змінили. Очікування було оновлено, і три нові тести охопили крайній випадок, що дозволило збільшити кількість пройдених тестів до 74.
Справжня проблема в документації click
Завдяки сандбоксу, який встановлює проект та більше не здогадується, click було запущено знову. Шість файлів, які раніше однаково зазнавали невдач, тепер отримали справедливий шанс, і один з них виявив справжню, раніше невиявлену помилку в самій документації click.
docs/commands.md описує ланцюгову обробку команд через CLI, і одна з прикладних функцій містить:
@cli.result_callback()
def process_pipeline(processors, fin):
- iterator = (x.rstrip("\r\n") for x in input)
+ iterator = (x.rstrip("\r\n") for x in fin)
Функція насправді отримує fin — об’єкт керування файлом, який надається через click. У документації натомість використовується цикл над input, що відноситься до вбудованої функції Python input(), а не до файлу. Використання цього об’єкта у циклі є некоректним та призводить до помилки відразу під час виконання коду, проте користувач-читач цього не помітить, оскільки назва здається правдоподібною.
DocuPatch виявив цю проблему, підтвердив справжню помилку в сандбоксі, створив виправлення, перевірив, що виправлена версія працює коректно від початку до кінця, та опублікував її. Це був перший результат усього розслідування, який водночас був справжньою помилкою в документації та справжнім правильним виправленням, без жодних зовнішніх впливів сандбоксу чи хибних позитивних результатів. Саме для цього існує цей проект.
Уроки з відлову помилок
- Перевірте вердикт, перш ніж довіряти результату. Позначки на кшталт „Пройдено“ чи „Опубліковано“ є настільки надійними, наскільки надійний тест, який знаходиться під ними. Проблема з tqdm виникла лише тому, що було відкрито відмінності, а зелену галочку не сприйняли на віру.
- Ідентичні помилки у багатьох випадках свідчать про спільну причину. Шість файлів, шість окремих спроб використання LLM та одне повідомлення про помилку разом утворюють сигнал на рівні системи.
- Спостерігайте, перш ніж формулювати теорії. Минули години на припущення щодо Docker та шляхів у Windows, перш ніж хтось перевірив, чи містить папка з тестами якісь файли.
- Невдалий тест може підтвердити щось, а не пошкодити. Коли зміна сигнатури функції
run_snippetпризвела до збою одного тесту, правильним запитанням було те, що саме стверджував цей тест, а не те, що зламалося.
Тепер баг у сендбоксі виправлений, перевірений тестами та підтверджений у репозиторії, де раніше виникали проблеми. У документації Click була справжня помилка — назва змінної, яка спричиняла збої при запуску прикладу, але агент її знайшов, правильно виправив та перевірив результати ремонту перед публікацією. Це і є основною ідеєю проекту, доведеною на реальному випадку, а не на синтетичному.