1
Deploy contracts-flask / validate (push) Canceled after 0s

This commit is contained in:
“Naeel”
2026-08-31 13:58:15 +03:00
parent f9745e5c6b
commit 83002b9644
+145
View File
@@ -0,0 +1,145 @@
# Как работает сервис «Сверка договоров»
Документ описывает текущую программную реализацию `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, нормализация номеров, порядок, проверки и сохранение результата выполняются детерминированным кодом.