# Как работает сервис «Сверка договоров» Документ описывает текущую программную реализацию `contracts-flask`: последовательность обработки данных, ответственность модулей и ключевые функции. Основной актуальный код находится в `site/`; каталог `deploy/` является историческим VM-слоем. ## 1. Общий поток ```text Браузер -> выбор файлов / ZIP -> загрузка через VM-буфер -> парсинг -> классификация документов -> группировка по договорам -> применение подтверждённых групп -> SSE-сравнение документов по порядку -> event sourcing: spec_events + spec_current -> таблица изменений и чат по текущей спецификации ``` Главная точка сборки Flask-приложения: [`site/app.py`](../site/app.py). Регистрация маршрутов выполняется через [`site/routes/__init__.py`](../site/routes/__init__.py). ## 2. Загрузка файлов Фронтенд начинает обработку в [`site/static/files.js`](../site/static/files.js): - `onFilesSelected()` принимает выбранные файлы, обрабатывает ZIP и обычные документы, запускает подготовку загрузки и сохраняет связь `zip_source`. - `uploadFile()` запускает загрузку через VM-буфер; фактические PUT и отправка ссылок выполняются функциями `uploadViaVM()` и `putToVm()` в [`upload/frontend/upload/upload_via_vm.js`](../upload/frontend/upload/upload_via_vm.js) и [`upload/frontend/upload/put_to_vm.js`](../upload/frontend/upload/put_to_vm.js). - `finalizeUpload()` завершает загрузку и обновляет состояние. - `renderFiles(state)` отображает список файлов, группируя вложения по `zip_source`. - `syncDB()` синхронизирует состав файлов в БД. Из-за ограничения managed-шлюза большие файлы сначала передаются на ВМ, а затем бэкенд забирает их исходящим запросом. Общий переиспользуемый транспорт находится в [`upload/backend/upload_refs/blueprint.py`](../upload/backend/upload_refs/blueprint.py), включая обработчик `POST /api/upload_refs`; конкретная бизнес-логика передаётся через `sink`. Маршруты загрузки основного приложения находятся в [`site/routes/upload_bp.py`](../site/routes/upload_bp.py). Там принимаются ссылки/файлы, вызывается sink сервиса договоров и сохраняются результаты обработки. ## 3. Парсинг Парсер находится в [`site/services/parse.py`](../site/services/parse.py). Он преобразует содержимое документов в структурированный список элементов: - `parse()` выбирает обработчик по формату. - Обработчик DOCX использует `python-docx` и извлекает параграфы и таблицы. - Обработчик PDF использует `pdfplumber`. - TXT обрабатывается напрямую. - Для `.doc` функция `parse_file()` в текущем `site` вызывает `_parse_docx(data)`; отдельный [`convert-service/app.py`](../convert-service/app.py) и исторический [`deploy/convert_doc.py`](../deploy/convert_doc.py) не являются частью этого вызова. Результат парсинга сохраняется как `elements_json`. Важный принцип: парсер не решает, какие строки являются услугами, а сохраняет исходную структуру документа для следующих стадий. ## 4. Классификация документов Маршрут запуска классификации: [`site/routes/pipeline_bp.py`](../site/routes/pipeline_bp.py), функция `classify_batch_route()`. - Для небольшого батча классификация выполняется синхронно. - Для большого батча запускается фоновый поток и возвращается `202`. - `process_v2()` отдаёт поток SSE для этапа сравнения. Основная логика находится в [`site/services/classify.py`](../site/services/classify.py): - `classify_batch(batch_id, llm_client=None, repo=None)` получает pending-документы, запускает параллельную обработку через `ThreadPoolExecutor` и сохраняет результат. - `_classify_one()` выполняет полный цикл для одного файла. - `_is_garbage_by_filename()` отбрасывает очевидный мусор по имени файла. - `_smart_extract()` формирует компактную выжимку: начало документа и найденные фрагменты с ключевыми маркерами. - `_is_garbage_by_header()` отбрасывает документы по заголовочным маркерам. - `_call_llm_classify()` отправляет выжимку в LLM. - `_safe_json_parse()` извлекает JSON из ответа LLM и исправляет распространённые ошибки форматирования. Промпт формируется функцией `build_classify_prompt()` в [`site/llm_prompt.py`](../site/llm_prompt.py). Результат содержит `doc_type`, `own_number`, `parent_number`, `doc_date` и `counterparty`. Сырые вход и ответ LLM также сохраняются для диагностики. ## 5. Группировка по договорам Логика находится в [`site/services/grouping.py`](../site/services/grouping.py): - `normalize_number(num)` приводит номер к верхнему регистру и убирает разделители. - `group_documents(batch_id)` отделяет договоры от допников/спецификаций, сопоставляет их по `parent_number` или `own_number`, создаёт виртуальные группы при отсутствии базового договора и помещает нерешённые документы в `__unresolved__`. - Внутри групп документы сортируются по `doc_date`. - `apply_groups(batch_id, groups_data)` сохраняет подтверждённые группы в таблицы договоров и допников. Маршруты чтения и применения групп находятся в [`site/routes/api_bp.py`](../site/routes/api_bp.py). Фронтенд отображает результат через [`site/static/groups.js`](../site/static/groups.js). Таким образом, LLM извлекает признаки документа, но окончательное сопоставление по номерам выполняется обычным Python-кодом. ## 6. Последовательное сравнение Маршрут сравнения: `process_v2()` в [`site/routes/pipeline_bp.py`](../site/routes/pipeline_bp.py). Он проверяет `contract_id`, запускает `run_pipeline()` и преобразует события в формат SSE. Основной pipeline находится в [`site/services/process.py`](../site/services/process.py): - `run_pipeline(contract_id, order_ids, build_prompt_fn)` сбрасывает предыдущее состояние, получает документы группы и определяет порядок обработки. - `_elements_to_text(ej)` преобразует `elements_json` в текстовый контекст для LLM. - Для каждого документа читается текущая спецификация. - `call_llm()` из [`site/services/llm.py`](../site/services/llm.py) получает текущие строки и текст документа и возвращает операции. - `target_id` вида `r1`, `r2` переводится в `target_hash` соответствующей строки текущей спецификации. - Для режима `full_replace` вызывается `clear_current()`, чтобы новая редакция не дублировала старые строки. - Затем вызывается `apply_ops()` и отправляются SSE-события `extract_start`, `llm_done`, `applied`, `extract_error` и финальное `complete`. - `check_arithmetic()` из [`site/services/metrics.py`](../site/services/metrics.py) проверяет согласованность `sum`, `price` и `qty`. Промпты извлечения и сравнения формируются через `build_prompt()` в [`site/llm_prompt.py`](../site/llm_prompt.py); версии промптов хранятся и редактируются маршрутами из [`site/routes/prompts_bp.py`](../site/routes/prompts_bp.py). ## 7. Event sourcing и текущее состояние Механизм хранения находится в [`site/db/spec_events.py`](../site/db/spec_events.py): - `reset(contract_id)` очищает события и текущее состояние перед новым полным прогоном. - `clear_current(contract_id)` очищает только текущую спецификацию для `full_replace`. - `apply_ops(...)` обрабатывает `ADD`, `UPDATE`, `DELETE` и `UNRESOLVED`. - `_hash(name, date_start)` создаёт стабильный идентификатор строки услуги. - `_upsert_spec_current(...)` добавляет или обновляет строку текущей спецификации. - `_update_spec_current(...)` изменяет только поля, указанные в операции. - `_log_unresolved(...)` сохраняет нерешённую операцию вместо молчаливого пропуска. `spec_events` — журнал операций с исходным ответом LLM, версией промпта и ссылкой на документ. `spec_current` — материализованное состояние, используемое для следующего сравнения и отображения. CRUD текущей спецификации и исходных данных документа находится в [`site/db/spec_current.py`](../site/db/spec_current.py). Схема и соединения описаны в [`site/db/connection.py`](../site/db/connection.py). ## 8. SSE и интерфейс Клиент сравнения находится в [`site/static/compare.js`](../site/static/compare.js): - `startCompareSSE()` открывает `EventSource`, принимает события и управляет таймером. - `applyCompareEvent()` переводит SSE-события в состояние секций. - `renderCompareSectionHeader()` формирует заголовок секции документа. - `renderCompareSectionBody()` отображает итоговую статистику и операции. - `renderCompareOpsTable()` строит таблицу изменений. Оркестрация шагов загрузки, классификации, группировки и запуска сравнения находится в [`site/static/app.js`](../site/static/app.js), в частности в `runClassify()` и обработчиках `loadGroupsAction()`/сравнения. ## 9. Чат Маршрут чата находится в [`site/routes/api_bp.py`](../site/routes/api_bp.py). Он получает вопрос пользователя, загружает текущую спецификацию через функции из [`site/db/spec_current.py`](../site/db/spec_current.py), формирует контекст и отправляет его в LLM. Поэтому чат отвечает по материализованному состоянию договора, а не по случайному отдельному документу. ## 10. Итоговая ответственность компонентов | Слой | Ответственность | |---|---| | `site/static/*.js` | выбор файлов, загрузка, отображение прогресса и результатов | | `site/routes/` | HTTP API, SSE и связывание компонентов | | `site/services/parse.py` | извлечение структуры документа | | `site/services/classify.py` | классификация и выделение метаданных через LLM | | `site/services/grouping.py` | детерминированное сопоставление документов | | `site/services/process.py` | последовательное сравнение группы | | `site/services/llm.py` | вызов LLM для анализа | | `site/db/spec_events.py` | применение операций и аудит изменений | | `site/db/spec_current.py` | чтение текущей спецификации и элементов документов | | `site/db/connection.py` | SQLite/WAL и доступ к данным | Итог: LLM используется там, где требуется понять содержание документа и смысл изменения услуги. Структура pipeline, нормализация номеров, порядок, проверки и сохранение результата выполняются детерминированным кодом.