CNN TürkElazığ’da öldürülen çiftin cinayetinde yeni gelişmePunch2027: Ekiti SDP senatorial candidate pledges better representationInquirerStudent wounded in stabbing at a Laoag schoolThe Jerusalem PostSide by Side: Helping people with disabilities connect sociallyBollywood HungamaShabana Azmi backs CJP’s Mumbai protest; says, “We don’t have to tolerate anything wrong”ZDF heuteEntdecken Sie das ZDF-NachrichtenstudioUOLApucarana e região têm 20 candidatos nas disputas para deputadoSky TG24Reggio Emilia, aggredì tre persone al distributore di benzina: muore suicida in carcereNU.nlIdentiteit van copiloot Flydubai bekend, viel piloot aan met bijlDaily MailBrooklyn Beckham and ab-flashing Nicola Peltz pack on the PDA during a night out in Paris after snubbing his mum Victoria's show as the designer gushes she feels 'so blessed' to have the rest of her family supporting herRai NewsFrancia, gli studenti non si fermano. A Marsiglia tentativo di ustione a una donna, arrestato 15enneFutura SciencesAnne-Sophie Pic cuisine pour Sophie Adenot dans l’espace : mais pourquoi le goût y change-t-il autant ?
The Daily Newsstand · Free, Always
Saturday, October 3, 2026

Программа поиска по PDF. Разбираем PageIndex, Streamlit и ссылки на страницы

Translate

«Срок гарантии — 18 месяцев». Красивый ответ. А если в договоре написано 12? Или число 18 стоит в разделе о хранении, а модель просто зацепилась за него?

В чате такие ошибки прячутся за гладкой фразой. Я хотел иной маршрут: задать вопрос к PDF, получить ответ и одним нажатием открыть физическую страницу исходного файла, на которую ссылается модель. Так появился PDF Desk — небольшое приложение на Python, Streamlit, PageIndex и PDFium. Один активный документ. Никакой базы пользователей. Индекс и исходный PDF остаются на компьютере; при использовании внешней модели текст документа всё равно уходит её провайдеру.

Дальше — где проходит граница между SDK и приложением. Почему голый /models не годится для проверки подключения. Из чего собран идентификатор индекса. И почему ссылка на существующую страницу ещё не значит, что ответ доказан. В конце живой прогон DeepSeek, включая браузер, и сценарии, для которых этого прогона мало.

От заметки к PDF: зачем ещё одно приложение

В прошлой статье я разбирал локальный RAG на Markdown и TXT: деление текста на фрагменты, embeddings, косинусную близость, несколько найденных кусков и ответ модели. Источником там служит диапазон строк. Для учебного проекта это удобно. Открыл файл — и адрес можно сверить глазами.

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

Второе отличие — поиск. Вместо своего деления на куски и векторного индекса я использовал локальный режим PageIndex SDK. В этой схеме PageIndex строит древовидный индекс PDF и ведёт агентный поиск по документу. Это не «полностью локальная нейросеть». Индекс и файлы локальны, вычисления модели могут выполняться во внешнем API. Подмена этих понятий испортит и архитектурное решение, и ожидания пользователя.

Рамки узкие нарочно. Один пользователь, один процесс Streamlit, один активный PDF. Верхняя граница — 25 МиБ и 500 страниц. Нужен читаемый текстовый слой. OCR нет. Скан из картинок приложение не превратит в текст чудом.

Путь одного вопроса

Рабочий маршрут целиком:

Браузер  │  ├─ адрес API + две модели + ключ  │        └─ проверка: обычный ответ + вызов инструмента ping  │  ├─ загрузка PDF  │        └─ PDFium: формат, размер, число страниц, текстовый слой  │                 └─ PageIndex local: индекс в data/items/<отпечаток>/index/  │                          └─ active.json: активный документ  │  └─ вопрос           └─ PageIndex chat → ответ + теги источников                    └─ проверка doc_id и номера страницы                             ├─ текст ответа                             └─ кнопка источника → PDFium → изображение страницы

Код разнесён по трём файлам. В app.py живут интерфейс и сценарий операции. reader.py знает PDFium, PageIndex и правила проверки ответа. storage.py отвечает за локальные пути, отпечаток документа и атомарную запись манифеста.

Кто кому доверяет? Браузер передаёт байты и настройки. Модель возвращает текст и ссылки в собственном формате. Ни один из этих вводов не становится правильным сам по себе. Поэтому PDF проверяется до индексации, профиль индекса — до вопроса, а источник — до создания кнопки.

Запускаем

Нужны Python 3.11 или новее, Git и доступ к OpenAI-совместимому POST /chat/completions. Модель ответов должна поддерживать вызовы инструментов, иначе PageIndex не сможет пройти рабочий маршрут. В проекте зафиксированы PageIndex 0.2.20, Streamlit 1.64.0, pypdfium2 5.13.0 и PyPDF2 3.0.1.

На macOS и Linux:

git clone https://github.com/balyakin/pdf-desk.git
cd pdf-desk
python3.12 -m venv .venv
.venv/bin/python -m pip install -r requirements.txt
.venv/bin/python tests/make_demo_pdf.py
.venv/bin/python -m streamlit run app.py --server.address 127.0.0.1 --server.port 8502

Если python3.12 нет, возьмите установленный python3 версии не ниже 3.11. Для Windows команды вынесены в README.

Скрипт создаст data/demo-manual.pdf: три страницы с гарантией, условием её прекращения и часами поддержки. Цена там отсутствует. Это маленький контрольный документ, а не бенчмарк качества поиска.

Откройте http://127.0.0.1:8502. В боковой панели введите адрес API с окончанием /v1, модель для подготовки, модель для ответа и ключ. Для проверенного варианта с DeepSeek обе модели — deepseek-flash, адрес — https://api.deepseek.com/v1. Имена моделей зависят от провайдера; на другую подписку их лучше не переносить механически. Внешние запросы могут стоить денег.

На кнопке «Проверить подключение» стоит остановиться. Она делает больше, чем кажется.

Проверка соединения: две короткие просьбы вместо красивого списка моделей

Многие приложения вызывают GET /models и считают задачу выполненной. Список моделей полезен. Он не гарантирует, что выбранная модель отвечает на запросы или вызывает инструменты. Нашей цепочке нужны оба свойства.

Первый запрос идёт к модели подготовки документа. Приложение просит короткий обычный ответ и проверяет, что поле content содержит непустую строку. Второй запрос идёт к модели ответов: ей передаётся функция ping без параметров и просьба вызвать её. Проверяется имя инструмента и JSON аргументов. С PDF функция ping ничего не делает. Её задача — быстро поймать несовместимый API до отправки текста документа.

Сокращённая структура проверки из reader.check_connection():

ordinary = request({    "model": settings["index_model"],    "messages": [{"role": "user", "content": "Ответь одним словом: готово."}],    "stream": False,    "max_tokens": 128,
})
probe = request({    "model": settings["chat_model"],    "messages": [{"role": "user", "content": "Для проверки вызови инструмент ping."}],    "tools": [{        "type": "function",        "function": {            "name": "ping",            "description": "Безопасная проверка подключения, аргументы не нужны.",            "parameters": {                "type": "object",                "properties": {},                "additionalProperties": False,            },        },    }],    "tool_choice": "auto",    "stream": False,    "max_tokens": 128,
})

Это проверка совместимости конкретной пары «модель + адрес», а не гарантия успешной индексации любого PDF. Тайм-аут пробных HTTP-запросов — 15 секунд. Размер прочитанного ответа ограничен примерно 1 МиБ. Перенаправления запрещены: если адрес указывает на редирект, приложение просит конечный URL. Для внешнего сервера допускается только HTTPS; HTTP разрешён для localhost, 127.0.0.1 и ::1. URL с учётными данными, query или fragment не принимается.

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

Проверка соединения сама по себе документ не трогает. Передача текста провайдеру начинается после отдельного нажатия «Подготовить документ».

Валидация PDF: расширение имени ещё ничего не говорит

Загрузчик Streamlit ограничивает файл 25 МиБ и принимает .pdf. Сервер на этом не останавливается. inspect_pdf() повторяет существенные проверки: байтовый размер, непустой файл, расширение, возможность открыть файл PDFium, число страниц от 1 до 500. Для каждой страницы PDFium извлекает текст и считает непробельные символы.

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

Для защищённого паролем PDF предусмотрено отдельное сообщение. Для произвольного набора байтов с расширением .pdf — отказ до вызова PageIndex. Дело не только в удобстве. Внешний API не должен получать мусорные данные после поверхностной проверки имени файла.

PDFium нужен ещё и в конце цепочки. Когда пользователь выбирает страницу, read_page() открывает сохранённый оригинальный PDF, получает текстовый слой и рендерит страницу в изображение. Коэффициент масштаба вычисляется как минимум из 2.0 и 1600 / max(width, height). Так длинная сторона рендера не растёт без предела. Нумерация для человека начинается с 1; индекс страницы в PDFium — с 0. Код делает это преобразование явно.

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

Что именно делает PageIndex в этом приложении

PageIndex описывает себя как vectorless, reasoning-based RAG: структурный индекс документа и поиск, опирающийся на модель, без обязательной векторной базы. В локальном режиме SDK принимает PDF, строит индекс на диске и предоставляет методы для разговора с документом. «Локальный режим» здесь относится к месту хранения индекса и выполнению SDK. Если в его backend указан DeepSeek, вызовы модели выполняет DeepSeek. Файлы PDF Desk не загружаются в PageIndex Cloud, но текст документа отправляется выбранному модельному API. Для конфиденциальности граница проходит именно здесь.

Конфигурация клиента в reader.make_client() имеет две независимые части:

backend = {    "base_url": settings["base_url"],    "api_key": settings["api_key"],    "timeout": 60.0,    "max_retries": 0,
}
client = PageIndexClient(    mode="local",    index={        "model": "openai/" + settings["index_model"],        "storage_path": str(storage_path),        "backend": dict(backend),    },    chat={        "model": "openai/" + settings["chat_model"],        "backend": dict(backend),    },    instructions="Отвечай по-русски только на основании выбранного документа. ...",
)

Префикс openai/ — способ маршрутизации SDK к OpenAI-совместимому backend. В запрос к выбранному API уходит исходное имя модели, введённое пользователем. Настройки index и chat могут указывать на разные модели, хотя адрес и ключ в этой версии общие. Повторных попыток на уровне backend нет: max_retries=0. Ошибка не должна маскироваться долгой неявной серией вызовов.

При первой подготовке submit_document() индексирует PDF. В локальном режиме операция синхронная: после неё приложение проверяет get_document(), статус completed и число страниц pageNum. Без этих трёх признаков новый документ не становится активным.

Индексирование больших документов — отдельная история. Время и расходы зависят от числа страниц, структуры PDF, модели и тарифа. Для трёхстраничного примера всё получилось. Стоимость договора на 400 страниц из этого не следует. Мы её не измеряли.

Почему здесь нет собственного поиска по векторам

В local-docs-ai поиск устроен намеренно прозрачно: разбили текст на куски, получили embedding каждого, посчитали косинусную близость с вопросом. Можно открыть JSON-индекс и увидеть каждый вектор. Можно воспроизвести ранжирование в десяти строках Python. Для учёбы такая прозрачность ценна.

У длинного PDF другая цена ошибки. Представьте инструкцию на 300 страниц: слово «гарантия» есть в оглавлении, в примере договора, в разделе исключений и в приложении. Близость небольшого фрагмента к вопросу ещё не объясняет, какой раздел на него отвечает. Структурный индекс может использовать заголовки, диапазоны страниц и описания частей документа для навигации. Именно эту работу я передал PageIndex, вместо того чтобы наращивать собственный разбор PDF, перекрытия фрагментов и эвристику выбора кандидатов.

У выбора есть обратная сторона. Поведение PageIndex сложнее проверить одной арифметической функцией. Индексация обращается к модели; версии SDK и модель подготовки становятся частью воспроизводимости. Если ответ неверен, нужно различать хотя бы три ситуации: нужного текста нет в извлечённом слое; индекс или поиск не вывели агента к нужному месту; модель увидела место, но неверно его пересказала. Одна кнопка «показать источник» помогает с последней ситуацией. Все внутренние шаги поиска она не раскрывает.

И ещё. «Без векторной базы» не означает «без состояния». Дерево и служебные данные хранятся в data/items/.../index. Их нужно привязать к конкретному PDF и профилю модели, переживать прерванную запись и проверять при следующем запуске. Код для этого никуда не исчезает. Он просто другой — не тот, что крутится вокруг embedding-модели.

Отпечаток индекса: один PDF может означать разные данные

Пользователь загрузил файл, построил индекс, затем сменил модель подготовки. PDF тот же. Можно ли взять старый индекс? На диске он есть. По смыслу это результат другого профиля, и молчаливое переиспользование обманет.

PDF Desk строит item_id из SHA-256 байтов PDF, адреса API, имени модели индекса и версии PageIndex SDK. Эти значения сериализуются в JSON с устойчивым порядком ключей, после чего хешируются ещё раз. Схематично:

profile = {    "pdf_sha256": hashlib.sha256(data).hexdigest(),    "base_url": validate_base_url(base_url),    "index_model": index_model,    "sdk_version": sdk_version,
}
item_id = hashlib.sha256(    json.dumps(profile, sort_keys=True, ensure_ascii=False).encode("utf-8")
).hexdigest()

В отпечатке нет модели ответов. Это осознанно. Замена chat_model меняет генерацию и требует повторной проверки подключения, но не меняет уже построенную структуру документа. Поменяли index_model или адрес — нужен новый индекс. Поменяли только модель ответов — готовый индекс можно использовать повторно.

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

На диске картина такая:

data/
├── settings.json
├── active.json
└── items/    └── <item_id>/        ├── source.pdf        └── index/

active.json — короткий манифест: версия схемы, идентификатор, отображаемое имя, sdk_doc_id, число страниц и профиль индекса. Отображаемое имя не превращается в путь к файлу. Исходный файл всегда называется source.pdf внутри проверенной папки с 64 шестнадцатеричными символами в имени.

Перед использованием сохранённого документа load_active() проверяет форму JSON, профиль, наличие PDF, ограничение размера и совпадение отпечатка с байтами файла. При повреждении данных приложение сообщает об ошибке, но не удаляет папку молча.

Как заменить активный документ и не потерять старый

Файловая система усложняет даже маленький проект. Записать новый active.json можно в одну операцию. Подготовить PDF и индекс — нет. Если модель оборвёт индексацию на середине, ссылка на прежний документ должна остаться рабочей.

Поэтому порядок действий такой:

  1. Проверить новый PDF и настройки.

  2. Вычислить папку нового индекса.

  3. Сохранить исходный PDF и построить индекс PageIndex.

  4. Убедиться, что SDK видит документ как completed и число страниц совпадает.

  5. Только тогда атомарно заменить active.json.

  6. После успешной замены попытаться удалить папку прежнего документа.

Манифест записывается во временный файл в той же папке: JSON, flush, fsync, затем os.replace. Это защищает основной JSON от частичной записи при обычном сбое процесса. Возможность чтения старого индекса при неудаче новой подготовки проверяется тестом. Остатки временной папки обрабатываются отдельно; активные данные не удаляются во время неудачного отката.

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

Всё это работает в одном процессе. threading.Lock не даёт двум операциям из разных вкладок одновременно изменять общую папку data/. Два отдельных экземпляра приложения с общей папкой этим замком не синхронизируются. Для многопользовательского сервиса потребовалась бы другая архитектура хранения и блокировок.

Ответ и источники: где доверие заканчивается

После подготовки app.answer_question() сверяет активный item_id и профиль индекса, затем вызывает reader.ask_pdf(). Вопрос ограничен 2000 символами и не может состоять из одних пробелов. SDK получает doc_id, включает citations=True и возвращает сырой текст. У PageIndex извлекаются цитаты, после чего приложение проверяет каждую.

Критерии простые, но необходимые:

  • doc_id цитаты должен совпадать с активным документом;

  • page должен быть именно целым числом, от 1 до числа страниц PDF;

  • повторные ссылки на одну страницу убираются.

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

Теги цитат удаляются из показываемого текста. Во время живого теста тут нашлась маленькая, но видимая ошибка: после удаления тега перед точкой оставался пробел. clean_answer() теперь нормализует пробелы перед пунктуацией. Казалось бы, косметика. На деле это хороший индикатор: связку SDK, генерации и отображения проверяли настоящим ответом, а не только заглушкой.

Самое жёсткое ограничение остаётся. Проверка doc_id и page доказывает, что ссылка адресует существующую страницу текущего документа. Она не доказывает, что на этой странице содержится каждое утверждение из ответа. Модель может ошибочно пересказать верный источник. Поэтому интерфейс открывает оригинальную страницу рядом с ответом, а не рисует «достоверность 99%».

Streamlit: один клик, ещё один прогон скрипта

В Streamlit взаимодействие с виджетом обычно запускает повторное выполнение страницы. Формы позволяют собрать ввод и отправить его одной кнопкой. В PDF Desk поле вопроса находится в st.form: печатание текста не вызывает запрос к модели. Запрос начинается после «Получить ответ».

Настройки подключения, наоборот, расположены вне формы. Асимметрия небольшая, а следствие жёсткое. Пока человек набирает вопрос, сеть не нужна. Пока он меняет адрес API или ключ, прежняя проверка уже не должна давать доступ к отправке PDF. Каждый виджет инициирует rerun, поэтому в начале основного сценария собирается settings_fingerprint из адреса, ключа и двух моделей. Если отпечаток изменился, checked_settings и предыдущий результат сбрасываются.

Этот отпечаток живёт только в памяти Streamlit. Его нельзя путать с item_id на диске. Первый описывает текущую проверенную сессию, второй — содержимое готового индекса.

Состояние последнего ответа хранится в st.session_state. Оно сбрасывается при изменении настроек, смене активного документа и перед новым вопросом. Не прихоть интерфейса. Если второй запрос завершился ошибкой, на экране не должен оставаться старый ответ так, словно он относится к новому вопросу.

Кнопка источника вызывает select_page() — меняет view_page. На следующем прогоне скрипта справа рендерится нужная страница PDF. Новый вызов модели не нужен. То же верно для кнопок «Предыдущая» и «Следующая».

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

Безопасность: конкретные границы, без магии

В этом приложении два недоверенных входа: загруженный PDF и ответы внешней модели. Плюс адрес API, который вводит человек. Ниже — решения, которые видны непосредственно в коде.

Адрес. validate_base_url() принимает HTTP(S) с путём, заканчивающимся на /v1; для внешнего адреса требует HTTPS. Userinfo, query, fragment, управляющие символы и пробелы отклоняются. Это не универсальная защита от SSRF для публичного сервиса: PDF Desk рассчитан на одного пользователя, который сам задаёт адрес. Если превращать его в многопользовательский веб-сервис, эту границу придётся пересмотреть.

Пути. item_id обязан быть 64-символьным hex-хешем. Пользовательское имя PDF служит только для показа. Символические ссылки внутри папки данных запрещены: иначе запись или чтение могут уйти из ожидаемого каталога.

Секреты. Ключ API не попадает в settings.json и в репозиторий. data/ игнорируется Git. Во время вызовов PageIndex SDK stdout, stderr и журналы SDK временно подавляются под общим замком: библиотека способна печатать подробности исключения вместе с параметрами запроса. Пользователь получает короткое безопасное сообщение вместо сырых данных об ошибке.

Prompt injection. В инструкциях модели PDF объявлен данными, а команды внутри него — не подлежащими исполнению. Но формулировка в промпте не является формальной защитой. Приложение не предоставляет модели инструменты для выполнения команд над системой; результат остаётся текстом для проверки человеком.

Конфиденциальность. «Локальный индекс» не равен «данные не покинули компьютер». При DeepSeek текст уходит на сервер DeepSeek. Исходный PDF и индекс сохраняются локально без шифрования. Для чувствительных документов нужно оценить правила выбранного провайдера и безопасность самого компьютера.

Отказы, которые видит человек

Обрабатывать исключения имеет смысл только тогда, когда человек понимает следующий шаг. Сырые сообщения HTTP-клиента или SDK могут содержать технические детали и секреты, а фраза «что-то пошло не так» не помогает. В reader.error_message() статусы и типовые сетевые ошибки переводятся в несколько коротких сообщений:

Ситуация

Поведение интерфейса

401 или 403

Сервер отклонил ключ; предлагается проверить API-ключ

429

Сервер ограничил запросы; можно повторить позже

400, 404, 405 или 422

Модель либо сервер не прошли проверку вызова инструментов

408, 5xx, тайм-аут или ошибка соединения

Сервер не ответил; проверьте подключение

3xx

Укажите конечный адрес API вместо перенаправления

Такая карта намеренно укрупняет причины. Один и тот же 400 может иметь много объяснений; из статуса нельзя честно восстановить внутреннюю ошибку провайдера. Но для первого запуска она указывает полезное направление и не показывает ключ в браузере. Подробную диагностику конкретного API при необходимости нужно проводить отдельно, не копируя публично заголовок Authorization.

Неполадка индекса обрабатывается иначе. Если новое индексирование сорвалось, приложение сохраняет старый активный документ. Если active.json повреждён, оно не делает вид, будто знает, какой PDF сейчас открыт: сообщает об ошибке и позволяет выбрать документ заново. Это две разные политики. Откат неудачной новой операции — и отказ пользоваться сомнительным старым состоянием.

Настоящая модель: что получилось, а что нет

Я проверял DeepSeek API через https://api.deepseek.com/v1 с моделью deepseek-flash для обоих этапов. API-ключ брался из локального хранилища учётных данных и не записывался в репозиторий. Сначала check_connection() получил обычный ответ и вызов ping. Затем приложение построило индекс трёхстраничного PDF через настоящий PageIndex SDK.

Каждый вопрос задавался отдельно:

Вопрос

Результат

Источник

Какой срок гарантии?

18 месяцев

Страница 2

Когда гарантия перестаёт действовать?

При вскрытии корпуса; в ответе также упомянут срок

Страницы 3 и 2

Когда работает поддержка?

Понедельник–пятница, 09:00–18:00

Страница 3

Сколько стоит устройство?

Цена в документе не указана

Страницы 1–3

По короткому PDF ответы верны; предупреждений приложения не было. После исправления пробела перед точкой первый вопрос повторили с реальной моделью: «Срок гарантии составляет 18 месяцев.», страница 2. Остальные три вопроса после этой правки не повторялись.

На этом можно было бы написать «работает». Я проверил ещё и браузерный путь, потому что ошибка между кнопкой и функцией тоже ошибка продукта. В headless Chrome автоматизация заполнила поля Streamlit, нажала «Проверить подключение», загрузила PDF, нажала «Подготовить документ», дождалась «Документ готов», отправила вопрос о гарантии и нажала «Источник 1 · страница 2». Справа появилась «Страница PDF 2 из 3». Проверены все звенья — от ввода ключа до открытия изображения источника. Подробный журнал проверки хранится рядом с кодом.

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

Как я бы измерял следующую версию

Проверка одного документа — инженерный smoke test. Для оценки качества нужен набор, в котором заранее известны ответы и страницы. Причём вопрос «сколько стоит устройство?» не менее ценен, чем вопрос о гарантии: отсутствие факта испытывает способность модели отказаться от выдумки.

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

Для отрицательных вопросов стоит считать долю корректных отказов. Здесь нужна строгая разметка: фраза «цена не указана» верна для демонстрационного PDF, а «цена примерно 1000 рублей» — ошибка, даже если модель добавила ссылку на страницу 1. При частичном ответе полезна более тонкая пометка: модель могла правильно назвать срок, но придумать условие его продления.

Время и стоимость тоже следует разделять. Первую индексацию измерять отдельно от повторного вопроса по готовому индексу; прогрев модели и сети не смешивать с поиском. Сохранять модель, версию SDK, размер PDF, число страниц и тариф на дату измерения. Без этих данных цифра «ответ за N секунд» быстро становится декоративной: её нельзя ни сравнить, ни воспроизвести. В текущем отчёте таких замеров нет, и я не выдаю предположение за результат.

Почему OpenCode Go не стал вторым проверенным провайдером

Ключ OpenCode Go работает в самом OpenCode: короткий вопрос через CLI получил ответ. Но PDF Desk при попытке проверки подключения получил отказ. Прямой запрос к /chat/completions с явным User-Agent выявил MissingSessionID: нужен заголовок x-opencode-session. Документация OpenCode Go описывает сервис как рассчитанный на coding agents и требует стабильный идентификатор сессии.

Тут легко сделать неверный вывод: «ключ плохой». Нет. Проверка показала другое — текущий HTTP-клиент PDF Desk и условия этого сервиса не совпадают. Полный прогон PDF через Go поэтому не объявлен успешным. И здесь провайдеры не становятся взаимозаменяемыми от одного лишь слова «OpenAI-compatible».

Автоматические тесты: где они помогают

Локальный набор содержит 44 теста. Они запускаются без внешней модели:

.venv/bin/python -m pip check
.venv/bin/python -m unittest discover -s tests -v

Тесты проверяют границы PDF, защищённые и пустые файлы, физическую вторую страницу, безопасные пути, неизменность старого манифеста при ошибке записи, восстановление индекса, неверные источники, невалидные ответы SDK, сброс устаревшего результата при смене настроек, повторное использование индекса, отсутствие вызова модели при навигации. Конкретный прогон CI на Python 3.11 и 3.12 завершился успешно для коммита 1bbb5f0.

Подставной клиент PageIndex полезен для ошибок, которые дорого или ненадёжно воспроизводить через внешний API. Например, он может вернуть ссылку на страницу 999, пустой ответ или исключение ровно посередине подготовки нового документа. Но такой тест не скажет, как DeepSeek сформулирует ответ на русский вопрос. Поэтому модельный прогон и тесты решают разные задачи.

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

Где проект нужно расширять

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

Сканы. Добавить OCR, а потом сравнить распознанный текст с изображением. Это не просто новый импорт. В результатах появится ещё один тип ошибки — неверное распознавание цифры или таблицы.

Точная привязка. Кнопка сейчас ведёт на страницу. Для плотных документов хочется переходить к конкретному фрагменту на странице. Тогда нужны координаты текста, проверка их соответствия оригиналу и аккуратное отображение поверх PDF.

Несколько документов. Понадобятся список файлов, выбор активного набора, правила цитирования между документами и иная модель блокировки. Один глобальный threading.Lock не годится как координация нескольких процессов.

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

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

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.