Пайплайн: загрузка → парсинг → классификация → группировка → сравнение → аудит
Пользователь выбирает .docx / .pdf / .doc / .zip.
pdfplumber извлекает текст и таблицы с сохранением структуры колонокpython-docx читает XML — извлекает параграфы и таблицы построчноunknown_formatРезультат: elements_json — массив элементов двух типов:
{type: "paragraph", text: "...", style: "..."}{type: "table", rows: [["колонка1", "колонка2"], ...]}Документ сохраняется в БД, считается content_hash (SHA-256) — если в этом же батче уже есть файл с таким содержимым, он помечается как duplicate и не дублируется.
📋 Заказчик: «100+ файлов за раз. Файлы: .docx/.pdf, возможно ZIP.
Загрузка из локали (файловая шара / облачный диск), НЕ S3 (конфиденциальность).»
Каждый загруженный файл проходит трёхэтапный фильтр.
📋 Заказчик: «Оставлять только: договоры, доп. соглашения, спецификации.
Игнорировать: акты сверки, счета/счета-фактуры/УПД, акты услуг, платёжки.»
Проверка имени файла на мусорные ключевые слова: «счёт», «акт», «платёж», «УПД», «сверка», «invoice» и др. Совпадение → garbage, дальше не идёт.
Проверка первых 2000 символов текста на заголовки: «СЧЕТ-ФАКТУРА», «АКТ СВЕРКИ», «АКТ ОКАЗАННЫХ УСЛУГ» и т.п. Совпадение → garbage.
📋 Заказчик: «Названия документов неинформативные. Есть лишние документы.
По всем контрагентам.»
Оставшиеся документы отправляются в LLM. Модель получает выжимку текста (первые 1500 символов + строки с маркерами «договор», «соглашение», «спецификация») и возвращает JSON:
doc_type — contract / supplement / specification / otherown_number — номер этого документаparent_number — номер родительского договора (для допников и спецификаций)doc_date — дата документа YYYY-MM-DDcounterparty — название контрагента📋 Заказчик: «НУБЕС = Исполнитель во всех целевых договорах.»
→ counterparty = другая сторона (Заказчик).
Детерминированная (без LLM). Документы группируются по номерам договоров.
📋 Заказчик: «Система сама должна разбираться, кто к кому.
зачем нам базовый договор? Вся информация есть в допниках и спеках.»
«Я не должен распознавать ничего. Система должна сделать всё.»
parent_number попадают в одну виртуальную группу — даже если сам договор не загруженДля каждой группы запускается последовательная обработка.
📋 Заказчик: «Важно. В произвольной, не повторяющейся.» (порядок допников)
«Главное — точность разбора, не UI. Сначала базовая функция → потом юзабельность.»
Документы внутри группы сортируются по doc_date (из классификации), затем по времени загрузки.
extractdiffADD — новая услугаUPDATE — изменение цены/количества/суммыDELETE — услуга исключенаЕсли допник говорит «изложить в новой редакции» — LLM возвращает полный новый список (full_replace). Все старые строки заменяются новыми.
| Угроза | Защита |
|---|---|
| ZIP-бомба (архив 1 KB → 10 GB) | Лимит 500 файлов, 500 MB, ratio сжатия ≤ 100× |
Path traversal в ZIP (../../etc/passwd) |
Проверка имени файла ДО os.path.basename |
| Битые кодировки имён в ZIP | cp437 → utf8 перекодировка |
| Файлы не-Word/PDF (exe, картинки, etc.) | Whitelist: только pdf/docx/doc/zip — остальные unknown_format |
| Дубликаты файлов (тот же контент, другое имя) | sha256(original_bytes) → пропуск с пометкой duplicate |
| Дубликаты услуг (одна услуга в разных допниках) | UPSERT по name_hash (имя + дата начала) — разные периоды = разные строки |
| ADD без названия услуги | → UNRESOLVED, не применяется |
| UPDATE/DELETE без идентификатора цели | Пустой target_hash → UNRESOLVED |
| Неизвестный тип операции от LLM | → UNRESOLVED |
Кривые числа (1 000,50) |
Авто-нормализация: пробелы → удалить, запятая → точка |
Кривые даты (01.03.2026) |
Авто-нормализация: DD.MM.YYYY → YYYY-MM-DD |
| Арифметические ошибки | Проверка price × qty = sum — расхождение → флаг |
| Мусорные документы (счета, акты, платёжки) | Трёхэтапный фильтр: имя файла → заголовки (2000 симв.) → LLM |
Тихие ошибки (try/except: pass) |
Везде logging.warning с контекстом |
📋 Заказчик: «кривых документов можно ожидать.
Если не может разобраться — пусть зовет на помощь юзера.»
📋 Заказчик: «Наверно я захочу иметь возможность посмотреть любые промежуточные результаты.
Было бы хорошо пояснить или показать процесс, что в каком порядке происходит.»
ADD/UPDATE/DELETE/UNRESOLVED)Три роли промптов, хранятся в БД с версионированием:
| Роль | Назначение |
|---|---|
extract |
Извлечение строк спецификации из договора |
diff |
Сравнение допников с текущей спецификацией |
classify |
Определение типа/номера/даты/контрагента документа |
Встроенный редактор промптов с полным CRUD и историей версий:
📋 Заказчик: «Загрузка из локали, НЕ S3 (конфиденциальность).»
Исходные бинарные файлы (.docx/.pdf/.doc/zip) удаляются с сервера сразу после парсинга. На диске не остаются.
В БД сохраняются — это и есть рабочие данные системы:
elements_json (текст и таблицы)⚠️ Пока не реализовано: удаление промежуточных данных после сессии. Варианты: кнопка «Очистить» в интерфейсе, либо Redis с TTL. Надо решать.
Пайплайн разбит на независимые слои с чёткими интерфейсами:
| Слой | Что делает | Можно заменить отдельно |
|---|---|---|
| Контракты данных | 5 frozen dataclass'ов: ParseResult, ClassifyResult, GroupingResult, BatchGroupingResult, CompareOp |
Формат данных не привязан к хранилищу |
| LLM-клиент | Протокол LLMClient.complete(prompt) → str |
HttpxLLMClient ↔ FakeLLMClient ↔ другая модель |
| Репозиторий | Протокол Repository (17 методов) |
PgRepository ↔ MemRepository (тесты) |
| Загрузка | parse_multipart() — чистая функция |
Парсер не зависит от HTTP-фреймворка |
| Классификация | classify_batch(batch_id, llm_client, repo) — Dependency Injection |
Любой LLM + любое хранилище |
Все внешние зависимости (LLM, БД) пробрасываются через параметры, а не через глобальные import:
call_llm(prompt, llm_client=None) — DI с обратной совместимостьюclassify_batch(batch_id, llm_client=None, repo=None) — можно подставить любой backend| Модуль | Тестов | Что проверяют |
|---|---|---|
| Контракты + загрузка | 13 | Создание dataclass'ов, garbage-фильтр, multipart-парсинг, path-traversal |
| LLM + Репозиторий | 9 | FakeLLMClient, MemRepository CRUD, все 17 методов |
| Классификация | 4 | Garbage по имени файла, garbage по заголовкам, classify с FakeLLM, no pending |
Все тесты проходят без доступа к БД и LLM — через FakeLLMClient (record/replay) и MemRepository. Запуск: pytest deploy/tests/ -v.
| Компонент | Где | Технология |
|---|---|---|
| Веб-интерфейс | Managed Python (Nubes) | Python 3.12, Jinja2, ванильный JS |
| Механизм обработки | Выделенная VM | Python 3.12, httpx, pdfplumber, python-docx |
| База данных | VM PostgreSQL 15 | JSONB, UUID, advisory locks |
| LLM | api.aillm.ru | gpt-oss-120b (бесплатно, OpenAI API) |
| Деплой | Managed Python + VM | Dockerfile, Nginx + Let's Encrypt |
📋 Заказчик: «Конечная цель: построчное сравнение CRM ↔ фискальная система.
Особый фокус: даты начала услуг.
PAYG (суффикс -m) — особый случай. Сверку с CRM тоже можно делегировать AI.»
Текущий пайплайн завершается на этапе извлечения спецификации из договоров и отслеживания изменений по допникам. Модуль сравнения с CRM — спроектирован как CanonicalRow адаптер, ожидает формата данных от заказчика.
📋 Заказчик: «Это всё пока опыты и набивание шишек.
Не надеюсь с первого захода на идеальный результат.»