@@ -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, нормализация номеров, порядок, проверки и сохранение результата выполняются детерминированным кодом.
|
||||||
Reference in New Issue
Block a user