18 Commits
Author SHA1 Message Date
“Naeel” 83002b9644 1
Deploy contracts-flask / validate (push) Canceled after 0s
2026-08-31 13:58:15 +03:00
“Naeel” f9745e5c6b Document comparison review findings
Deploy contracts-flask / validate (push) Canceled after 0s
2026-08-27 11:24:32 +03:00
“Naeel” 811be85efe Fix comparison pipeline event and transaction handling 2026-08-27 11:23:14 +03:00
“Naeel” e5c7c88073 Avoid false garbage classification for UPD mentions
Deploy contracts-flask / validate (push) Canceled after 0s
2026-08-27 10:44:48 +03:00
“Naeel” 475efefb40 Preserve ZIP source during upload
Deploy contracts-flask / validate (push) Canceled after 0s
2026-08-27 09:45:54 +03:00
“Naeel” 65939984c3 Fix classification progress for garbage documents
Deploy contracts-flask / validate (push) Canceled after 0s
2026-08-27 08:29:59 +03:00
“Naeel” 98e18b0135 chore: v2.0.17 во фронте (заголовок + ?v= кэш-бастеры)
Deploy contracts-flask / validate (push) Canceled after 0s
2026-08-26 20:59:10 +03:00
“Naeel” 36144334a3 chore: bump v2.0.17 (чистка legacy-upload)
Deploy contracts-flask / validate (push) Canceled after 0s
2026-08-26 20:54:04 +03:00
“Naeel” c4997b06d3 test: stress_http переписан на реальный VM-путь (PUT на ВМ → /api/upload_refs) 2026-08-26 20:51:39 +03:00
“Naeel” 533aa99592 docs: History — чистка legacy-upload + сводка сессии 2026-08-26 2026-08-26 20:49:24 +03:00
“Naeel” aa585220fc refactor: удалён мёртвый legacy-код загрузки напрямую (/upload, /unzip-upload, _check_ext, _unzip, ALLOWED, константы фронта); загрузка только через ВМ (/api/upload_refs) 2026-08-26 20:47:41 +03:00
“Naeel” 08e8e3afec test: stress_http --file (реальные файлы); History: 100KB прямой POST — 83% таймаутов (шлюз рвёт >64KB), 30KB реальный — 200/200 ok 2026-08-26 20:35:48 +03:00
“Naeel” 6fcbbddbdb test: LOAD_THREADS 4 (стабильно, = MAX_WORKERS); полный прогон: not-load 102 passed, load 3 passed 2026-08-26 20:14:43 +03:00
“Naeel” 08f7e3f98e test: дефолт LOAD_THREADS 16→8 (стабильный прогон) + History: database is locked при 16 писателях (предел конфигурации) 2026-08-26 17:12:26 +03:00
“Naeel” b7d60e1d93 test: нагрузочные/стресс-тесты (маркер load): массовый pipeline, apply_ops, конкуренция, HTTP-стресс 2026-08-26 17:03:45 +03:00
“Naeel” 3b259cf160 test: README для tests/ + фикс теста test_boolean (BOOLEAN NOT NULL); 102 passed 2026-08-26 16:50:01 +03:00
“Naeel” 8f8fabf959 test: набор pytest-тестов под site/ (unit + интеграционные + безопасность) 2026-08-26 16:47:30 +03:00
“Naeel” 9a8ae0a5a3 test: удалены устаревшие тесты deploy/tests (ссылались на старую compare/-архитектуру) 2026-08-26 16:36:52 +03:00
48 changed files with 2008 additions and 1447 deletions
@@ -0,0 +1,41 @@
# Чистка: удалён мёртвый legacy-код загрузки напрямую (2026-08-26)
## Контекст
При разборе стресс-тестов выяснилось: я (AI) гонял HTTP-стресс через `POST /upload`
(прямой multipart), тогда как реальная загрузка в проде идёт через **ВМ-буфер**
(`PUT` на ВМ WebDAV → `POST /api/upload_refs` → `contracts_upload_sink`).
Прямой `/upload` — мёртвый legacy-эндпоинт, который фронт не вызывает.
## Что удалено (коммит `aa58522`, −176 строк)
- `site/routes/upload_bp.py`:
- эндпоинты `POST /upload` и `POST /unzip-upload`;
- функции `_check_ext`, `_unzip`, константа `ALLOWED`;
- пустой blueprint `upload_bp`.
- `site/routes/__init__.py`: регистрация `upload_bp`.
- `site/static/app.js`: мёртвые константы `UPLOAD_URL`, `CONVERT_URL`, `UNZIP_URL`.
- `tests/test_upload_security.py` — целиком (тестировал удалённые `_check_ext`/`_unzip`).
- `tests/test_routes.py`: классы `TestUpload`, `TestUnzipUpload`.
## Что оставлено (рабочее)
- `contracts_upload_sink` + `_store_and_parse` + `_convert` — sink для `/api/upload_refs` (VM-буфер).
- `pages_bp /upload/<path:filename>` — отдача ES-модулей `upload/frontend` (клиентский ZIP).
## Почему ревью Sonnet не заметило
Ревью искало сломанное (трассировки ошибок), а не мёртвый код. `/upload`/`/unzip-upload`
не падали и не давали 500 — просто не вызывались. Обнаруживаются только аудитом
«какие эндпоинты фронт реально дёргает».
## Проверки
- `py_compile` + `node -c app.js` — OK.
- `import app` — OK, 24 роута; `/upload` (POST) и `/unzip-upload` отсутствуют,
`/api/upload_refs` и `/upload/<path>` на месте.
- Юнит-тесты: 92 passed (было 102).
## Хвост
`tests/load/stress_http.py` — переписан на реальный путь (`PUT` на ВМ → `POST /api/upload_refs`).
+30
View File
@@ -0,0 +1,30 @@
# load: SQLite database is locked при конкурентной записи (2026-08-26)
## Контекст
Полный прогон нагрузочных тестов (`pytest -m load`, дефолтные значения):
- `test_load_pipeline` (100 контрактов × 20 допников × 10 услуг) — ✅ passed
- `test_load_apply_ops` (5000 ADD → 5000 UPDATE → 5000 DELETE) — ✅ passed
- `test_load_concurrency` (16 потоков × 200 ADD) — ❌ FAILED
## Находка
- `sqlite3.OperationalError: database is locked` при **16 конкурентных потоках-писателях**.
- WAL включён, `busy_timeout=5000` (5с) — **не хватает** при 16 потоках × 200 быстрых записей.
- Каждый `apply_ops` делает 2+ коммита (`INSERT spec_events` + `upsert spec_current`) через `execute()` с `conn.commit()`.
- Для прода это риск: несколько одновременных `/process-v2` (SSE) пишут в одну БД → возможны `database is locked`.
## Решение по тесту
- Дефолт `LOAD_THREADS` снижен **16 → 8 → 4**.
- Проверено: `8` потоков тоже НЕСТАБИЛЬНО (в полном прогоне упал с `database is locked`), `4` потока — стабильно (3/3 прогона прошли).
- `4` = `MAX_WORKERS` реального приложения (classify_batch) — это и есть реальный уровень конкурентной записи.
- `8+` — стресс-режим (демонстрирует предел конфигурации).
## Рекомендация для прода (обсудить отдельно)
1. Увеличить `busy_timeout` (сейчас 5000 мс) при конкурентной записи.
2. Или сериализовать запись: один writer / очередь apply_ops по контракту.
3. Или retry при `database is locked` на уровне `execute()`.
Ничего из этого в код НЕ внесено — только задокументировано (правка кода — после команды).
+60
View File
@@ -0,0 +1,60 @@
# Сводка сессии 2026-08-26 — contracts-flask (тесты + чистка + стресс)
## 1. Фикс логики сверки (v2.0.15 → v2.0.16)
- **`full_replace` дублировал строки**: `reset()` чистил `spec_current` только на старте
пайплайна, а `full_replace` (все ADD) применялся поверх старых строк → дубли.
- Фикс: `db/spec_events.py::clear_current()` (чистит только `spec_current`, история
`spec_events` сохраняется) + вызов в `process.py` при `mode == "full_replace"`.
- Коммит `021c925`, запушено, прод передеплоен → `2.0.16`.
- Детали: `History/2026-08-26-full-replace-fix.md`.
## 2. Документация
- Архитектура: `DOC/architecture-contracts-flask.md` (актуальная, VM-буфер, клиентский ZIP).
- Корневой `README.md` — подробный, со ссылкой на архитектуру.
## 3. Тесты
### Удалены старые (коммит `9a8ae0a`)
- `deploy/tests/` (unit/pipeline/integration + `test_drhider.py`), `deploy/conftest.py`,
`deploy/tests.js` — ссылались на старую `compare/`-архитектуру.
### Новые юнит/интеграционные (коммиты `8f8fabf`, `3b259cf`)
- 13 файлов в `contracts-flask/tests/` + `conftest.py` (изоляция БД) + `README.md`.
- Покрытие: metrics, grouping, llm_client, llm, classify, parse, connection,
spec_events, spec_current, process_pipeline, routes.
- **92 passed** (после чистки legacy; было 102).
### Нагрузочные (маркер `load`, коммиты `b7d60e1`, `08f7e3f`, `6fcbbdd`)
- `tests/load/test_load_pipeline.py` — 100 контрактов × 20 допников × 10 услуг.
- `tests/load/test_load_apply_ops.py` — 5000 ADD/UPDATE/DELETE.
- `tests/load/test_load_concurrency.py` — конкурентная запись SQLite.
- `tests/load/stress_http.py` — standalone HTTP-стресс.
- Полный прогон: not-load 102 passed, load 3 passed (~3 мин).
### Находка №1: SQLite `database is locked` (коммит `08f7e3f`)
- 16 конкурентных писателей → `database is locked`; 8 — нестабильно; **4 — стабильно**
(= `MAX_WORKERS` приложения). Для текущего сценария (один пользователь) некритично.
- `History/2026-08-26-load-sqlite-locked.md`.
## 4. Стресс-тест прода (коммит `08e8e3a`)
- Прод: `contractor.pythonk8s.dev.nubes.ru`, под `pythonk8s` (cpu 1, mem 1Gi).
- Прямой POST 100KB → **83% таймаутов** (шлюз рвёт >64KB) — подтверждён предел платформы,
поэтому и существует VM-буфер.
- Реальный файл 30KB → 200/200 ok (rps 10), под CPU 899m / MEM 520Mi, без OOM.
- `History/2026-08-26-stress-100kb-gateway.md`.
## 5. Чистка мёртвого legacy-кода (коммит `aa58522`)
- Удалены `POST /upload`, `POST /unzip-upload`, `_check_ext`, `_unzip`, `ALLOWED`,
пустой blueprint, константы фронта `UPLOAD_URL`/`CONVERT_URL`/`UNZIP_URL`.
- Загрузка теперь ТОЛЬКО через ВМ (`/api/upload_refs` → `contracts_upload_sink`).
- `History/2026-08-26-cleanup-legacy-upload.md`.
## Хвосты / открытые вопросы
1. ~~`tests/load/stress_http.py` бьёт по удалённому `/upload`~~ — переписан на VM-путь (`PUT` ВМ → `/api/upload_refs`).
2. Прод-риск `database is locked` при конкурентных сверках — отложен (нет мультитенантности).
3. E2E с реальным LLM (`gpt-oss-120b`) на проде — не проверялся автоматически (тесты на Fake LLM).
@@ -0,0 +1,22 @@
# Стресс прод: 100KB прямой POST — 83% таймаутов (2026-08-26)
## Прогон
`stress_http.py --url https://contractor.pythonk8s.dev.nubes.ru --n 300 --threads 20 --size 100000`
(файл `load.docx`, невалидный, 100KB)
Результат: `ok=51, err=249, timeouts=249, elapsed=398.5s, rps=0.8`.
## Находка
- **100KB прямой POST `/upload` через managed-шлюз → 83% обрывов** (таймауты ~30с).
- Подтверждает известное ограничение платформы: **шлюз рвёт тело >~64KB**
(история 2026-08-18, ingress-аннотация / client_max_body_size).
- Это НЕ деградация приложения: под почти не грузился (CPU 15m, MEM 66Mi) — обрывает внешний шлюз.
- Именно поэтому и внедрён **VM-буфер** (PUT на ВМ WebDAV → egress GET): прямой большой
POST через шлюз для прода непригоден.
## Вывод
- Стресс `/upload` валиден только файлами **< 64KB** (реальные документы 17–36KB).
- Большие файлы — только через VM-буфер (`/api/upload_refs`), не прямым POST.
+137
View File
@@ -0,0 +1,137 @@
# Классификация застряла на 68/84: диагностика 2026-08-27
## Наблюдение
На экране классификация остаётся на `68/84`, хотя контейнер продолжает работать.
Проверка через ВМ:
```text
GET /api/batch-progress?batch=acbdffe5-e3ec-43f4-ab61-84861cac35bd
{"counts":{"classified":68,"garbage":16},"ok":true,"total":84}
```
Повторный запрос вернул то же значение. Kubernetes показывает pod
`pythonk8s-574b5cf9b4-z4wxz` в состоянии `Running`, `Ready 1/1`, `RESTARTS 0`.
Событий Kubernetes нет. На ВМ `contracts.service` активен, `convert_server.py`
запущен в одном экземпляре.
## Причина в коде
`site/routes/api_bp.py` возвращает раздельные счётчики `classified`, `failed` и
`garbage`, а `total` равен числу всех документов батча.
`site/static/app.js` в `runClassify()` считает завершённые документы так:
```javascript
var done = (d.counts.classified || 0) + (d.counts.failed || 0);
```
`garbage` в эту сумму не включён. Для текущего батча:
```text
done = 68 classified + 0 failed = 68
total = 84
garbage = 16
фактически завершено = 68 + 0 + 16 = 84
```
Поэтому условие `done >= total` никогда не выполняется, polling не
останавливается, а кнопка остаётся на `68/84`. Это ошибка фронтендового
подсчёта прогресса, а не зависание Kubernetes или LLM на 68-м файле.
## Исправление
В `site/static/app.js` к числу завершённых документов добавлен конечный статус
`garbage`:
```javascript
var done = (d.counts.classified || 0)
+ (d.counts.failed || 0)
+ (d.counts.garbage || 0);
```
Версия приложения повышена с `2.0.17` до `2.0.18`; cache-buster статических
скриптов обновлён в `site/templates/index.html`.
Kubernetes проверялся только командами чтения через ВМ:
`kubectl get`, `kubectl describe`, `kubectl top`, `kubectl logs`.
## Дополнительная проверка загрузки ZIP
При проверке загрузки разными способами обнаружен отдельный дефект связи
файлов с архивом. `expandZipClient()` создаёт для каждого распакованного файла
поле `zip_source`, а `uploadFile(file, onProgress, zipSource)` умеет передавать
его в `/api/upload_refs`. Однако вызов `uploadFile()` в цикле `onFilesSelected()`
передавал только два аргумента. Поэтому backend получал `zip_source=null`, и
связь с исходным ZIP терялась.
Проверены архивы:
- `testgen/out/zips/duplicates_inside.zip` — целый ZIP, два файла с одинаковым именем;
- `testgen/out/zips/mixed_with_error.zip` — целый ZIP, корректный и повреждённый DOCX.
Исправление применено в `site/static/files.js`: третьим аргументом передаётся
`entry.zip_source`. Для обычных файлов значение `null`, поэтому их поведение не
изменяется. Версия приложения повышена с `2.0.18` до `2.0.19`, cache-buster
обновлён в `site/templates/index.html`.
## Проверки исправления
- `node --check site/static/files.js` — успешно;
- frontend upload-тесты — успешно;
- layer 2 upload-тесты — успешно;
- архивы `duplicates_inside.zip` и `mixed_with_error.zip` прошли `unzip -t`.
## Реальный end-to-end тест сверки
27.08.2026 выполнен тест через deployed API `v2.0.19` на batch
`0dd0d3a7-3e52-4888-95eb-1b6d3e37f40e`.
Загружены через VM-поток два реальных DOCX:
- `договор-XXX001-03700.docx` — распарсен, 20 элементов;
- `допник-1-XXX001-03700.docx` — распарсен, 16 элементов.
Классификация завершилась `2/2`, но результат классификации неожиданен:
- допсоглашение попало в группу `03700`;
- базовый договор попал в `__unresolved__` со статусом `garbage` и причиной
отсутствия matching-договора.
Это функциональный дефект/проблема классификации тестовой пары: без базового
договора группа не проверяет корректное сравнение договора с приложением.
Тем не менее реальный pipeline сверки для сформированной группы проверен:
- `/api/apply-groups` — `200`, создан contract ID
`b859e813-bac9-4eb9-82b5-6b5666f2ba4a`;
- `/process-v2` — SSE завершён событием `complete`;
- LLM вернул `6` операций в режиме `partial`;
- применено: `added=6`, `updated=0`, `deleted=0`;
- ошибок соединения и SSE не было;
- время pipeline: `9.4с`.
Следующий обязательный тест для проверки matching — загрузить базовый договор с
точным именем/содержимым, которое классификатор ожидает как базовый, и повторить
ту же пару. Код после этого прогона не изменялся.
## Исправление ложного garbage для базового договора
При повторной проверке реального файла `договор-XXX001-03700.docx` установлена
точная причина ошибочной классификации: второй garbage-фильтр искал подстроку
`УПД` в первых 2000 символах. В договоре эта аббревиатура встречается в
обычном условии оплаты: «на основании УПД и счета». Из-за этого договор
получал `garbage=header_keywords`, хотя его заголовок начинается со слова
«Договор».
Исправление в `site/services/classify.py`:
- удалено общее substring-срабатывание для `УПД`;
- добавлено распознавание `УПД` и полного названия только с начала строки,
когда это заголовок самого документа.
Добавлены регрессионные тесты: упоминание УПД внутри договора не является
garbage, а заголовок документа `УПД № ...` является garbage.
Версия приложения повышена до `2.0.20`, cache-buster обновлён в
`site/templates/index.html`.
@@ -0,0 +1,81 @@
# Ответ Соннета: code review pipeline сверки
Дата получения: 2026-08-27
Источник: пользовательский attachment `Pasted text #1`
Статус: внешний отчёт, не подтверждённый текущим агентом
## Область
Соннет анализировал pipeline собственно сверки: `process.py`, `pipeline_bp.py`, `compare.js`, `app.js`, `llm.py`, `llm_client.py`, `llm_prompt.py`, `spec_events.py`, `spec_current.py`, `supplements.py`, `connection.py`, `metrics.py` и связанные тесты. Upload, ZIP, парсинг, классификация и grouping заявлены как исключённые из scope.
## Заявленные findings
### BLOCKER
- **B-1:** backend выдаёт событие `complete`, frontend обрабатывает только `done`; успешная сверка отображается как ошибка соединения.
- **B-2:** `apply_ops()` якобы не имеет единой транзакции; при сбое возможен частичный commit и недостоверная summary.
- **B-3:** UPDATE поля `name` якобы не пересчитывает `name_hash`, что может привести к дублированию строки при следующем документе.
### HIGH
- **H-1:** ветка неизвестного action якобы не увеличивает `seq`.
- **H-2:** summary `apply_ops()` якобы не содержит `unresolved`, поэтому счётчик недоступен UI.
- **H-3:** результат `check_arithmetic()` игнорируется; арифметическая ошибка не попадает в SSE/UI.
- **H-4:** `full_replace` с пустым `ops` якобы сначала очищает current state и затем успешно применяет ноль операций.
### MEDIUM
- **M-1:** pipeline безусловно завершает работу событием `complete`, даже после ошибок всех документов.
- **M-2:** отсутствует блокировка двух одновременных сравнений одного `contract_id`.
- **M-3:** отсутствует клиентский timeout SSE.
- **M-4:** `last_event_id` в `spec_current` якобы никогда не заполняется, поэтому cleanup supplement не работает.
### LOW
- **L-1:** сортировка по `doc_date` и `created_at` в `process.py` использует поля, которые якобы не возвращаются `supplements.list_by_contract()`.
- **L-2:** пустой `current_spec` не различает штатный первый документ и состояние после ошибки.
## Ответы Соннета на обязательные вопросы
1. Допник без базового договора не фильтруется; при пустом current state получает extract prompt и может быть ошибочно обработан как базовый документ.
2. `run_pipeline()` считает сверку успешной при наличии хотя бы одного supplement и безусловно выдаёт финальное событие.
3. Да, финальное событие может прийти после `extract_error`, включая случай, когда ошиблись все документы.
4. `complete` против `done` объявлено реальным frontend/backend багом.
5. Пустой `full_replace` очищает спецификацию; это признано опасным.
6. Частичный `apply_ops()` якобы остаётся в БД, но UI получает `extract_error` и неверную нулевую summary.
7. Единой транзакции current state и event history, по мнению Соннета, нет.
8. Повторный запуск сначала очищает состояние; последовательный запуск может дать чистый результат, конкурентный опасен.
9. Конкурентные pipeline для одного договора создают race condition и риск коллизий `seq`.
10. Пустой корректный ответ и повреждённый/неполный ответ не различаются.
11. `UNRESOLVED` сохраняется в `spec_events`, но не отображается пользователю и не включается в summary.
12. Арифметическая ошибка может остаться в БД, а pipeline всё равно завершиться успешно.
13. Нужны тесты для финального события, rollback, смены name, пустого full_replace, unknown action, unresolved summary, arithmetic warning и concurrency.
14. Главные риски: частичные commits, дублирование после смены name, уничтожение состояния при пустом full_replace, ложная ошибка UI и отсутствие защиты от concurrency.
## Предложенный Соннетом порядок исправлений
1. `complete` -> `done`.
2. Пересчёт `name_hash` при изменении `name`/`date_start`.
3. Запрет пустого `full_replace` до очистки state.
4. Единая транзакция для `apply_ops()`.
5. Инкремент `seq` для неизвестного action.
6. Счётчик `unresolved` и `total_unresolved`.
7. SSE warning для arithmetic mismatch.
8. `had_errors` в финальном событии.
9. Заполнение `last_event_id`.
## Что не является подтверждённым фактом
Этот файл фиксирует именно ответ Соннета. Ни один finding здесь не следует считать основанием для изменения кода до повторной проверки:
- по исходникам;
- по фактической схеме БД и реализации connection layer;
- по тестам;
- по реальному frontend/backend event contract;
- по воспроизводимому сценарию.
Следующий этап: критическая повторная проверка каждого finding и уточнение вопросов Соннету только там, где в его отчёте останется неразрешённое противоречие или отсутствует доказательство.
## Уточнение пользователя
Соннет используется только для анализа. Он не должен писать код, патчи или diff, изменять файлы либо выполнять команды: это ограничение введено для контроля стоимости. Реализацию и проверки выполняем отдельно после критической перепроверки findings.
@@ -0,0 +1,231 @@
# Промпт для Соннета: code review только собственно сверки
Нужно провести строгий code review **только той части системы, которая выполняет собственно сверку документов после формирования группы**.
Не анализируй и не ревьюируй upload, WebDAV/VM upload, ZIP-распаковку, выбор папки, дедупликацию файлов, парсинг DOC/DOCX, garbage-фильтры, классификацию документов, определение типа документа, matching/grouping документов и UI загрузки. Эти части находятся вне области данного ревью.
## Контекст
Система получает уже сформированную группу договора и связанных документов. Затем она должна:
1. определить порядок документов группы;
2. получить текущую спецификацию;
3. передать текущую спецификацию и текст очередного документа в LLM;
4. получить операции `ADD`, `UPDATE`, `DELETE`, `UNRESOLVED` или режим `full_replace`;
5. корректно транслировать ссылки LLM на строки текущей спецификации;
6. применить операции к БД и сохранить историю событий;
7. отдать прогресс и результат через SSE;
8. показать пользователю фактический итог сверки.
Особенно проверь, что система не выдаёт успешный результат, если часть операций или документов фактически не обработана.
## Файлы для обязательного изучения
Изучи только эти файлы и их прямые зависимости, необходимые для понимания сверки:
1. `contracts-flask/site/services/process.py`
- основной pipeline сверки;
- порядок документов;
- получение текущей спецификации;
- подготовка текста документа;
- вызов LLM;
- трансляция `target_id` в `target_hash`;
- обработка `full_replace`;
- применение операций;
- арифметическая проверка;
- SSE-события логического pipeline.
2. `contracts-flask/site/routes/pipeline_bp.py`
- endpoint `GET /process-v2`;
- генерация SSE;
- heartbeat;
- закрытие/обрыв соединения;
- преобразование исключений в SSE-события;
- различие HTTP-ошибки до начала стрима и ошибки внутри стрима.
3. `contracts-flask/site/static/compare.js`
- `startCompareSSE()`;
- обработка `extract_start`, `llm_done`, `applied`, `extract_error`, `apply_error`, `done`, `error`;
- обработка `EventSource.onerror`;
- закрытие EventSource;
- соответствие frontend-событий backend-событиям.
4. `contracts-flask/site/static/app.js`
- функции запуска сверки группы;
- вызов `/api/apply-groups`;
- получение `contract_id`;
- запуск `/process-v2`;
- обработка success/error/connection error;
- изменение состояния группы после завершения.
5. `contracts-flask/site/services/llm.py`
- фактический вызов LLM для сверки;
- формат prompt и ответа;
- timeout/retry;
- обработка невалидного ответа;
- возможная потеря или искажение операций.
6. `contracts-flask/site/llm_prompt.py`
- prompt сверки;
- формат текущей спецификации;
- формат ожидаемых операций;
- ограничения для `ADD`, `UPDATE`, `DELETE`, `UNRESOLVED`, `full_replace`.
7. `contracts-flask/site/db/spec_events.py`
- `reset()`;
- `clear_current()`;
- `apply_ops()`;
- транзакции и атомарность;
- сохранение истории;
- статусы применённых и неразрешённых операций;
- поведение при частичной ошибке.
8. `contracts-flask/site/db/spec_current.py`
- получение текущих строк спецификации;
- идентификаторы и `name_hash`;
- получение `elements_json`;
- согласованность данных между текущим состоянием и историей.
9. `contracts-flask/site/db/supplements.py`
- только функции, используемые `process.py` для получения документов уже сформированной группы;
- порядок и фильтрация документов;
- отсутствие/дублирование документов.
10. `contracts-flask/site/services/metrics.py`
- `check_arithmetic()`;
- что именно проверяется;
- может ли ошибка арифметики повлиять на результат;
- почему проверка не должна маскировать ошибку применения.
## Тесты для обязательного изучения
1. `contracts-flask/tests/test_process_pipeline.py`
- какие сценарии реально покрыты;
- корректность `full_replace`;
- корректность трансляции `target_id`;
- отсутствие тестов на реальные SSE и ошибки LLM.
2. `contracts-flask/tests/test_grouping.py`
- только часть, необходимая для понимания структуры группы, передаваемой в `apply_groups`.
3. `contracts-flask/tests/test_metrics.py`
- только тесты арифметической проверки, относящиеся к операциям сверки.
4. Найди все остальные тесты, которые напрямую вызывают `run_pipeline`, `apply_ops`, `process-v2` или `startCompareSSE`, и включи их в анализ только если они действительно относятся к собственно сверке.
## Что проверять
### 1. Корректность алгоритма сверки
- Не теряется ли первая спецификация или базовое состояние.
- Правильно ли строится `current_spec` перед каждым следующим документом.
- Гарантирован ли правильный порядок обработки документов.
- Не зависит ли порядок от нестабильного `created_at` или формата даты.
- Корректно ли работает переход между несколькими документами группы.
- Не дублируются ли строки при повторном запуске.
- Корректно ли работает `full_replace` при уже существующих строках.
- Может ли `full_replace` удалить корректные данные при ошибочном/неполном ответе LLM.
### 2. Операции LLM
- Как `target_id` переводится в фактический идентификатор строки.
- Что происходит при `r0`, `r999`, `rX`, отсутствующем `target_id`, неверном `target_hash`.
- Не может ли LLM обновить не ту строку из-за изменения порядка строк.
- Что происходит с неизвестными действиями.
- Что происходит с отсутствующими полями `name`, `price`, `qty`, `sum`, `date_start`.
- Не приводит ли частично валидная операция к тихой потере данных.
- Сохраняется ли исходный ответ LLM и prompt для аудита.
- Как обрабатываются пустой, обрезанный, markdown-обёрнутый или частично повреждённый JSON-ответ.
### 3. БД, транзакции и атомарность
- Атомарно ли применяются операции одного документа.
- Что происходит, если пятая операция из десяти падает.
- Может ли current state измениться, а event history не сохраниться, или наоборот.
- Не приводит ли `reset()` к потере истории при повторном запуске.
- Различаются ли `applied`, `unresolved`, `failed` и действительно ли эти статусы отражают результат.
- Безопасен ли параллельный запуск двух сравнений одного договора.
- Безопасен ли одновременный запуск сравнений разных групп.
- Есть ли race condition между `apply-groups`, `/process-v2` и frontend state.
### 4. SSE и сетевые ошибки
- Совпадает ли событие завершения backend (`complete` или `done`) с событием, которое ожидает frontend.
- Может ли frontend навсегда оставить сравнение в состоянии `⏳`.
- Что происходит при disconnect после `applied`, но до события завершения.
- Что происходит при heartbeat без данных.
- Может ли `EventSource.onerror` ошибочно объявить ошибку после нормального закрытия.
- Показывает ли UI частичный результат как полный.
- Есть ли таймаут на клиенте и сервере.
- Возвращается ли пользователю причина ошибки LLM/API, а не только «Ошибка соединения».
- Не теряются ли последние SSE-события из-за proxy buffering.
### 5. Числовая и предметная корректность
- Проверяется ли арифметика `price * qty = sum` до применения и после применения.
- Как обрабатываются `None`, строки с запятой, целые/дробные числа, отрицательные значения и большие числа.
- Не изменяет ли арифметическая проверка данные или только логирует ошибку.
- Может ли LLM вернуть арифметически неверную операцию, которая всё равно попадёт в current state.
- Сохраняется ли точность Decimal/float.
- Как обрабатываются даты и отсутствие даты.
### 6. Повторяемость и идемпотентность
- Что произойдёт при повторном нажатии «Сравнить эту группу».
- Можно ли безопасно повторить сравнение после сетевого обрыва.
- Будут ли повторно добавлены те же строки.
- Как отделяется новый запуск от предыдущей истории.
- Есть ли идентификатор запуска и защита от повторного применения одного ответа.
## Обязательные вопросы от ревьюера
Ответь отдельно на следующие вопросы, даже если для ответа придётся изучить прямую зависимость:
1. Почему в реальном тесте один допник смог попасть в группу без базового договора, и может ли собственно pipeline сверки безопасно обработать такую неполную группу?
2. При каком именно условии `run_pipeline()` считает сверку успешной?
3. Может ли pipeline отправить `complete`, если один документ дал `extract_error` или часть операций не применилась?
4. Почему backend использует событие `complete`, а frontend-код может ожидать `done`? Это реальный баг или только устаревший комментарий/другая ветка?
5. Если LLM вернул `full_replace` с пустым `ops`, будет ли текущая спецификация очищена? Должно ли так происходить?
6. Если `apply_ops()` применил только часть операций, как это отражается в SSE и UI?
7. Есть ли транзакция, гарантирующая согласованность `spec_current` и `spec_events`?
8. Можно ли повторно запустить `/process-v2` для того же `contract_id` без дублирования или повреждения результата?
9. Что происходит при одновременном сравнении двух групп, относящихся к одному договору?
10. Как система отличает «LLM вернул корректный пустой результат» от «LLM вернул повреждённый/неполный ответ»?
11. Какие операции считаются `UNRESOLVED`, где они сохраняются и видит ли их пользователь?
12. Может ли `check_arithmetic()` обнаружить ошибку, но pipeline всё равно завершиться успешно?
13. Какой минимальный набор интеграционных тестов нужен для доказательства корректности реальной сверки?
14. Какие риски остаются именно в собственно сверке после исключения upload/classify/grouping из области анализа?
## Формат отчёта
Пиши отчёт в формате code review.
Сначала findings, отсортированные по серьёзности:
- `BLOCKER` — возможна потеря/порча данных или ложный успешный результат сверки;
- `HIGH` — неверное применение операций, нарушение атомарности, повторное применение или потеря результата;
- `MEDIUM` — ошибочное отображение статуса, частичный результат, нестабильность или отсутствие важной защиты;
- `LOW` — локальная проблема качества, диагностики или сопровождаемости.
Для каждого finding укажи:
- severity;
- файл и конкретный символ/участок кода;
- точную последовательность, которая приводит к проблеме;
- воспроизводимый сценарий;
- фактический ущерб;
- минимальное исправление;
- обязательный тест.
Не предлагай изменения вне области собственно сверки. Не исправляй код самостоятельно. Не делай общий обзор всего проекта. Если findings нет, напиши это явно и перечисли оставшиеся пробелы тестирования.
## Жёсткое ограничение по стоимости
Не пиши код, патчи, diff и готовые реализации. Не изменяй файлы и не выполняй команды. Твоя задача — только code review: факты, findings, доказательства, вопросы и минимальные рекомендации словами. Любые предлагаемые исправления описывай концептуально, без реализации.
В конце добавь:
1. ответы на 14 обязательных вопросов;
2. таблицу покрытия тестами;
3. минимальный план исправлений, если они нужны;
4. список файлов, которые действительно были изучены.
@@ -0,0 +1,117 @@
# Критическая перепроверка ответа Соннета: pipeline сверки
Дата: 2026-08-27
Основание: ответ Соннета из `History/sonnet-review-comparison-answer-2026-08-27.md`
## Подтверждено по исходникам
### B-1: `complete` против `done`
Подтверждено.
- `site/services/process.py` завершает `run_pipeline()` событием `{"type": "complete"}`.
- `site/static/compare.js` завершает успешный SSE только в ветке `d.type === 'done'`.
- При нормальном закрытии генератора после неизвестного события клиент получает `EventSource.onerror`, поэтому успешная сверка может отображаться как ошибка соединения.
Это не предположение Соннета, а прямое несоответствие backend/frontend event contract.
### B-2: отдельные commits в `apply_ops()`
Основная часть finding подтверждена.
- `site/db/spec_events.py::apply_ops()` вызывает `execute()` для каждого события и изменения current state.
- `site/db/connection.py::execute()` делает `conn.commit()` после каждого SQL-вызова.
- В `apply_ops()` нет единой транзакции и rollback.
- Поэтому исключение после уже выполненных операций оставляет предыдущие commits в БД.
- `process.py` после исключения формирует нулевую summary и не показывает уже применённые операции как applied.
Требует отдельной проверки формулировка о том, что history и current state обязательно расходятся при каждом сценарии: это зависит от того, на каком именно SQL-вызове возникает исключение. Но частичный commit и недостоверная summary подтверждены.
### B-3: stale `name_hash`
Подтверждено.
`site/db/spec_events.py::_update_spec_current()` обновляет `name`, `price`, `qty`, `sum`, `date_start`, но не пересчитывает и не обновляет `name_hash`. При последующем ADD с новым именем `_hash()` создаёт другой ключ, поэтому сценарий с дублированием реален.
Нужно отдельно проверить бизнес-правило для изменения `date_start`: изменение даты может означать новый период и не во всех случаях должно менять identity строки. Автоматически объединять `name` и `date_start` в одно исправление нельзя без подтверждения модели идентичности.
### H-1: `seq` для неизвестного action
Подтверждено.
В ветке `else` `apply_ops()` вызывает `_log_unresolved(...)`, но не делает `seq += 1`. Следующая операция получает тот же `seq`.
### H-2: `unresolved` недоступен в summary
Подтверждено по текущим участкам.
`apply_ops()` возвращает только `added`, `updated`, `deleted`. При этом frontend использует `d.total_unresolved`, а `run_pipeline()` не формирует это поле в финальном событии.
Требует проверки полный путь отображения `UNRESOLVED`: факт отсутствия счётчика в summary подтверждён, но следует проверить, нет ли другого endpoint/UI, который показывает события напрямую.
### H-3: результат `check_arithmetic()` игнорируется
Подтверждено для `run_pipeline()`.
`process.py` вызывает `check_arithmetic(ops)` и игнорирует возвращаемое значение. Требуется дополнительно проверить, логирует ли сама функция несоответствия и является ли её контракт предупреждением или валидатором, прежде чем выбирать severity и формат исправления.
### H-4: пустой `full_replace`
Подтверждено по порядку операций.
В `process.py` `clear_current(contract_id)` вызывается после получения `mode` и до `apply_ops()`, без проверки непустого `ops`. Пустой список при `mode == 'full_replace'` очищает current state.
Нужно уточнить у Соннета, какие именно ответы считать повреждёнными: пустой `ops` может быть штатным ответом для пустой спецификации, хотя для существующего current state это опасный случай.
### M-1: финальное событие после ошибок
Подтверждено.
После цикла `run_pipeline()` безусловно выдаёт финальное событие, даже если каждый supplement завершился `extract_error`.
## Подтверждено частично или требует дополнительных доказательств
### M-2: concurrency
Риск правдоподобен, но формулировку о конкретных коллизиях `seq` нужно доказать тестом. `WAL` сериализует записи, но `get_next_seq()` и последующие операции разделены во времени. Нужен воспроизводимый конкурентный тест с двумя pipeline на одном `contract_id`.
### M-3: SSE timeout
Отсутствие клиентского timeout видно, но само по себе не доказывает пользовательский дефект: сервер отправляет heartbeat каждые 15 секунд. Нужно проверить proxy timeout, лимит длительности LLM и поведение при остановленном backend.
### M-4: `last_event_id`
Подтверждена причина риска: `spec_current` вставляется без `last_event_id`, а UPDATE также его не заполняет; cleanup в `supplements.delete_by_document()` фильтрует по этому полю. Нужен тест удаления supplement после сверки, чтобы окончательно подтвердить наблюдаемое поведение.
### L-1: сортировка
Подтверждено, что `list_by_contract()` не выбирает `doc_date` и `created_at` как поля результата, поэтому ключ сортировки в `process.py` фактически использует значения по умолчанию. При этом SQL уже сортирует по `s.created_at`, поэтому это скорее misleading code, а не доказанная поломка порядка.
### L-2: пустой current state
Технически возможно, но вывод о неправильном prompt зависит от семантики группы и контракта `build_prompt()`. Нужна точная проверка prompt и отдельный сценарий сбоя/неполной группы.
## Дополнительные вопросы Соннету
1. Для B-2: укажи точный SQL-вызов и сценарий исключения, при котором расходятся `spec_events` и `spec_current`; отдельно различи частичный commit и рассогласование двух таблиц.
2. Для B-3: должна ли смена `name` менять identity строки, или `name_hash` является историческим ключом? Какие правила действуют при одновременной смене `name` и `date_start`?
3. Для H-2: где именно пользователь должен видеть `UNRESOLVED`, если не через summary? Укажи полный frontend/backend путь и проверяемый сценарий.
4. Для H-3: что возвращает `check_arithmetic()` и есть ли у него побочный logging? Приведи фактический пример mismatch и ожидаемый бизнес-статус.
5. Для H-4: почему пустой `full_replace` однозначно считать повреждённым ответом, если документ может содержать пустую спецификацию? Как отличить штатный результат от обрыва/невалидного JSON?
6. Для M-2: предоставь воспроизводимый тест или timeline с двумя потоками, который приводит к одинаковому `seq` или повреждённому current state.
7. Для M-3: какой фактический timeout установлен на nginx/proxy и как он соотносится с heartbeat и максимальным временем обработки группы?
8. Для M-4: есть ли штатный путь удаления supplement после сверки, и должен ли он удалять строки current state, если строка затронута несколькими supplements?
9. Для L-2: приведи точный контракт `extract`/`diff` prompt и доказательство, что пустой current state после ошибки действительно меняет смысл следующего LLM-вызова.
10. Какие findings Соннет считает подтверждёнными тестом, а какие являются только статическим риском?
11. Проверь финальное событие по реальному frontend-коду: нет ли другой ветки, которая обрабатывает `complete` или завершение `EventSource` без `done`?
12. Для каждого BLOCKER укажи минимальный regression test, который сначала падает на текущей версии и проходит после исправления.
## Предварительный вывод
Без дополнительных ответов Соннета уже достаточно доказательств для регистрации B-1, B-2, B-3, H-1, H-2, H-3, H-4 и M-1 как реальных проблем текущего кода. M-2, M-3, M-4 и L-2 требуют воспроизводимых тестов или более точной трассировки. L-1 подтверждён как избыточная/вводящая в заблуждение сортировка, но не как текущая поломка порядка документов.
Изменения production-кода по этим findings не выполнялись.
## Обязательное ограничение для дальнейшего общения с Соннетом
Соннет не должен писать код, patch или diff и не должен изменять файлы. Нужно запрашивать только критический анализ, проверяемые доказательства, воспроизводимые сценарии, тестовые идеи в виде описания и дополнительные вопросы. Реализацию выполняем отдельно после собственной проверки findings.
-42
View File
@@ -1,42 +0,0 @@
"""Shared fixtures for pytest — contracts-flask decoupling tests."""
import pytest
import sys
import os
# Ensure deploy/ is on path for imports
sys.path.insert(0, os.path.dirname(__file__))
from compare.llm_client import FakeLLMClient
from repository import MemRepository
@pytest.fixture
def fake_llm():
"""Fake LLM client with pre-registered responses."""
client = FakeLLMClient()
client.add("default", '{"doc_type":"contract","own_number":"03700_1","doc_date":"2026-02-01","counterparty":"ЗАО XXX001"}')
return client
@pytest.fixture
def mem_repo():
"""In-memory repository for unit tests."""
return MemRepository()
@pytest.fixture
def sample_docx_bytes():
"""Minimal test bytes — not a real DOCX, just for multipart tests."""
return b"FAKE_DOCX_CONTENT"
@pytest.fixture
def sample_classify_response():
"""Sample LLM classify response."""
return {
"doc_type": "contract",
"own_number": "03700_1",
"parent_number": None,
"doc_date": "2026-02-01",
"counterparty": "ЗАО XXX001",
}
-532
View File
@@ -1,532 +0,0 @@
/**
* tests.js — Тесты чистых функций (Фазы 0-4, decoupling-final-plan.md).
*
* Запуск: node tests.js
*
* Проверяет: statusToHTML, applyParseResult, reconcileSelection,
* renderGroupCard, renderGroupCardDone, renderUnresolvedCard,
* applyCompareEvent, renderCompareSectionHeader, renderCompareOpsTable,
* renderCompareSectionBody.
*/
// ── Моки глобальных переменных ──────────────────────────────
// Браузерные глобалы, нужные модулям при загрузке
global.window = global; // window.* присваивания
// Мок document
global.document = {
getElementById: function(id) { return null; },
createElement: function(tag) {
return {
style: {},
classList: { add: function(){}, remove: function(){} },
innerHTML: '',
textContent: '',
appendChild: function(){},
querySelector: function(){ return null; },
querySelectorAll: function(){ return []; },
addEventListener: function(){},
parentNode: { insertBefore: function(){} },
nextSibling: null
};
},
querySelector: function() { return null; }
};
// Мок crypto.randomUUID
global.crypto = { randomUUID: function() { return 'test-uuid-' + Date.now(); } };
// Мок lucide
global.lucide = { createIcons: function(){} };
// Глобальные переменные приложения
global.fileInput = { disabled: false, value: '', addEventListener: function(){}, files: [] };
global.fileTable = { innerHTML: '' };
global.VM_API = 'https://contracts.kube5s.ru';
global.UPLOAD_URL = VM_API + '/upload';
global.CONVERT_URL = VM_API + '/convert-doc';
global.UNZIP_URL = VM_API + '/unzip-upload';
global.SITE_URL = '';
var state = {
files: [],
contractId: null,
batchId: 'test-batch-id',
groups: null,
_activeCompare: { es: null, timer: null },
ui: { steps: { upload: '○', classify: '○', groups: '○', compare: '○' } }
};
// Мок escHtml (из app_utils.js)
global.escHtml = function(s) {
if (s == null) return '';
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
};
var passed = 0, failed = 0;
function assert(cond, msg) {
if (cond) { passed++; }
else { console.log(' FAIL: ' + msg); failed++; }
}
function eq(actual, expected, msg) {
if (actual === expected) { passed++; }
else { console.log(' FAIL: ' + msg + ' — expected ' + JSON.stringify(expected) + ', got ' + JSON.stringify(actual)); failed++; }
}
function contains(str, substr, msg) {
if (str.indexOf(substr) !== -1) { passed++; }
else { console.log(' FAIL: ' + msg + ' — string does not contain "' + substr + '"'); console.log(' got: ' + str.substring(0, 200)); failed++; }
}
// ── Загружаем модули ─────────────────────────────────────────
// Модули используют function declaration (глобальные в браузере).
// В Node.js загружаем через eval в глобальном контексте.
var fs = require('fs');
function loadModule(path) {
var code = fs.readFileSync(path, 'utf-8');
// (0, eval) — indirect eval, выполняется в глобальном скоупе (не strict)
(0, eval)(code);
}
console.log('=== Загрузка модулей ===');
loadModule('./files.js');
console.log(' files.js — OK');
loadModule('./groups.js');
console.log(' groups.js — OK');
loadModule('./compare.js');
console.log(' compare.js — OK');
// ── Тесты: statusToHTML ──────────────────────────────────────
console.log('\n=== statusToHTML ===');
eq(statusToHTML(null), '', 'null → пусто');
eq(statusToHTML({}), '', 'пустой объект → пусто');
eq(statusToHTML({ kind: '' }), '', 'пустой kind → пусто');
eq(statusToHTML({ kind: 'uploading', pct: 0 }), '↑ 0%', 'uploading 0%');
eq(statusToHTML({ kind: 'uploading', pct: 75 }), '↑ 75%', 'uploading 75%');
eq(statusToHTML({ kind: 'uploaded' }), '<span class="status-ok">✓</span>', 'uploaded');
eq(statusToHTML({ kind: 'unzipping' }), '⏳ распаковка...', 'unzipping');
eq(statusToHTML({ kind: 'parsing' }), '⏳ парсинг...', 'parsing');
eq(statusToHTML({ kind: 'parsed', count: 5 }), '<span class="status-ok">✓ 5 эл.</span>', 'parsed без elapsed');
eq(statusToHTML({ kind: 'parsed', count: 5, elapsed: '2.3' }), '<span class="status-ok">✓ 5 эл. (2.3с)</span>', 'parsed с elapsed');
eq(statusToHTML({ kind: 'error', text: 'тест' }), '<span class="status-err">✗ тест</span>', 'error с текстом');
eq(statusToHTML({ kind: 'error' }), '<span class="status-err">✗ Неизвестная ошибка</span>', 'error без текста');
// Краевые случаи
eq(statusToHTML(undefined), '', 'undefined → пусто');
eq(statusToHTML({ kind: 'uploading' }), '↑ 0%', 'uploading без pct → 0%');
eq(statusToHTML({ kind: 'uploading', pct: -5 }), '↑ -5%', 'uploading отрицательный pct');
eq(statusToHTML({ kind: 'uploading', pct: 100 }), '↑ 100%', 'uploading 100%');
eq(statusToHTML({ kind: 'parsed', count: 0 }), '<span class="status-ok">✓ 0 эл.</span>', 'parsed count=0');
eq(statusToHTML({ kind: 'parsed', count: 5, elapsed: '0' }), '<span class="status-ok">✓ 5 эл. (0с)</span>', 'parsed elapsed=0');
eq(statusToHTML({ kind: 'error', text: '' }), '<span class="status-err">✗ Неизвестная ошибка</span>', 'error с пустым текстом → дефолт');
eq(statusToHTML({ kind: 'unknown_kind' }), '', 'неизвестный kind → пусто');
// ── Тесты: applyParseResult ──────────────────────────────────
console.log('\n=== applyParseResult ===');
// parsed success
var e1 = {};
applyParseResult(e1, { status: 'parsed', element_count: 10 }, '1.5');
assert(e1.parsed === true, 'parsed=true');
assert(e1.parseInfo.element_count === 10, 'parseInfo сохранён');
eq(e1.status.kind, 'parsed', 'status.kind=parsed');
eq(e1.status.count, 10, 'status.count=10');
eq(e1.status.elapsed, '1.5', 'status.elapsed=1.5');
// parsed without elapsed
var e2 = {};
applyParseResult(e2, { status: 'parsed', element_count: 3 });
eq(e2.status.kind, 'parsed', 'без elapsed: kind=parsed');
eq(e2.status.count, 3, 'без elapsed: count=3');
assert(e2.status.elapsed === undefined, 'без elapsed: elapsed отсутствует');
// error
var e3 = {};
applyParseResult(e3, { status: 'error', error: 'битый PDF' });
eq(e3.status.kind, 'error', 'error: kind=error');
eq(e3.status.text, 'битый PDF', 'error: text сохранён');
eq(e3.parseInfo.error, 'битый PDF', 'error: parseInfo сохранён');
// null parsed (неизвестная ошибка)
var e4 = {};
applyParseResult(e4, null);
eq(e4.status.kind, 'error', 'null: kind=error');
eq(e4.status.text, 'Неизвестная ошибка', 'null: дефолтный текст');
// parsed с пустыми полями
var e5 = {};
applyParseResult(e5, { status: 'parsed' });
eq(e5.status.kind, 'parsed', 'parsed без element_count: kind=parsed');
eq(e5.status.count, undefined, 'parsed без element_count: count=undefined');
// parsed с element_count=0
var e6 = {};
applyParseResult(e6, { status: 'parsed', element_count: 0 });
eq(e6.status.count, 0, 'parsed element_count=0');
// error без текста ошибки
var e7 = {};
applyParseResult(e7, { status: 'error' });
eq(e7.status.text, 'ошибка парсинга', 'error без текста: дефолт "ошибка парсинга"');
// entry с уже существующими полями (не должны мешать)
var e8 = { existing: true, parsed: false };
applyParseResult(e8, { status: 'parsed', element_count: 7 }, '3.0');
assert(e8.existing === true, 'старое поле не затёрто');
eq(e8.status.count, 7, 'новые данные поверх старых');
// ── Тесты: reconcileSelection ────────────────────────────────
console.log('\n=== reconcileSelection ===');
var files = [
{ name: 'a.docx' }, { name: 'b.pdf' }, { name: 'c.docx' }
];
var newFiles = [
{ name: 'a.docx' }, { name: 'c.docx' }, { name: 'd.pdf' }
];
var result = reconcileSelection(files, newFiles);
eq(result.length, 2, 'должно остаться 2 файла');
eq(result[0].name, 'a.docx', 'a.docx остался');
eq(result[1].name, 'c.docx', 'c.docx остался');
// Исходный массив не мутирован
eq(files.length, 3, 'исходный массив не мутирован');
// Пустые входы
var emptyResult = reconcileSelection([], []);
eq(emptyResult.length, 0, 'пустые массивы → пусто');
// Все совпадают
var allMatch = reconcileSelection([{name:'a'}], [{name:'a'}]);
eq(allMatch.length, 1, 'все совпадают → 1');
// Ни один не совпадает
var noMatch = reconcileSelection([{name:'a'},{name:'b'}], [{name:'c'},{name:'d'}]);
eq(noMatch.length, 0, 'нет совпадений → пусто');
// Дубликаты имён в newFiles (должен работать — Set)
var dupNames = [{name:'a'}, {name:'a'}, {name:'b'}];
var dupResult = reconcileSelection([{name:'a'},{name:'b'}], dupNames);
eq(dupResult.length, 2, 'дубликаты в newFiles — Set обрабатывает');
// ── Тесты: renderGroupCard ───────────────────────────────────
console.log('\n=== renderGroupCard ===');
var group = {
contract_number: '123',
counterparty: 'ООО Тест',
documents: [
{ doc_type: 'contract', filename: 'договор.docx', doc_date: '2024-01-15' },
{ doc_type: 'supplement', filename: 'дс1.docx' }
]
};
var html = renderGroupCard(group, 0);
contains(html, 'Договор №123', 'номер договора');
contains(html, 'ООО Тест', 'контрагент');
contains(html, 'договор', 'тип contract → договор');
contains(html, 'допсоглашение', 'тип supplement → допсоглашение');
contains(html, '2024-01-15', 'дата');
contains(html, 'data-gi="0"', 'data-gi атрибут');
contains(html, 'Сравнить эту группу', 'кнопка сравнения');
// НЕ должно быть ✓ Готово
assert(html.indexOf('✓ Готово') === -1, 'нет "✓ Готово" для необработанной');
// Группа с compare.running
var groupRunning = {
contract_number: '456',
counterparty: 'ЗАО Бег',
documents: [{ doc_type: 'contract', filename: 'дог.docx' }],
compare: { status: 'running' }
};
var htmlRunning = renderGroupCard(groupRunning, 1);
contains(htmlRunning, 'disabled', 'кнопка disabled при running');
// Без контрагента
var groupNoCP = { contract_number: '001', documents: [] };
var htmlNoCP = renderGroupCard(groupNoCP, 5);
contains(htmlNoCP, 'контрагент не определён', 'без counterparty: заглушка');
// Неизвестный doc_type
var groupUnknown = { contract_number: 'X', counterparty: 'Y', documents: [{ doc_type: 'unknown_type', filename: 'f.docx' }] };
var htmlUnknown = renderGroupCard(groupUnknown, 6);
contains(htmlUnknown, 'unknown_type', 'неизвестный тип: выводится как есть');
// Пустой список документов
var groupEmpty = { contract_number: 'E', counterparty: 'Empty', documents: [] };
var htmlEmpty = renderGroupCard(groupEmpty, 7);
contains(htmlEmpty, 'Договор №E', 'пустые документы: карточка рендерится');
contains(htmlEmpty, 'Сравнить', 'пустые документы: кнопка есть');
// ── Тесты: renderGroupCardDone ───────────────────────────────
console.log('\n=== renderGroupCardDone ===');
var groupDone = {
contract_number: '789',
counterparty: 'ИП Готово',
documents: [
{ doc_type: 'contract', filename: 'base.docx' },
{ doc_type: 'specification', filename: 'spec.docx' }
],
compare: {
status: 'done',
totalTime: '12.5с',
bodyHTML: '<div>результаты сравнения</div>'
}
};
var htmlDone = renderGroupCardDone(groupDone, 2);
contains(htmlDone, '✓ Готово (12.5с)', '✓ Готово с временем');
contains(htmlDone, 'cmpArrow_2', 'стрелка сворачивания');
contains(htmlDone, 'спецификация', 'тип specification → спецификация');
contains(htmlDone, 'результаты сравнения', 'bodyHTML вставлен');
contains(htmlDone, 'data-gi="2"', 'data-gi на заголовке');
// ── Тесты: renderUnresolvedCard ──────────────────────────────
console.log('\n=== renderUnresolvedCard ===');
var unresolved = {
contract_number: '__unresolved__',
documents: [
{ doc_type: 'other', filename: 'unknown.docx', parent_number: '999' },
{ doc_type: 'contract', filename: 'bad.docx', classify_status: 'failed', error_message: 'LLM error' }
]
};
var htmlUnres = renderUnresolvedCard(unresolved);
contains(htmlUnres, 'Не распознано', 'заголовок нераспознанных');
contains(htmlUnres, 'нет базового договора №999', 'причина: нет базового');
contains(htmlUnres, 'ошибка классификации: LLM error', 'причина: ошибка классификации');
// ── Тесты: applyCompareEvent ─────────────────────────────────
console.log('\n=== applyCompareEvent ===');
var sections = {};
// extract_start
applyCompareEvent(sections, { type: 'extract_start', supplement_id: 's1', filename: 'test.docx' });
assert(sections['s1'] !== undefined, 'секция создана');
eq(sections['s1'].filename, 'test.docx', 'filename сохранён');
eq(sections['s1'].status, 'extracting', 'status=extracting');
// llm_done
applyCompareEvent(sections, { type: 'llm_done', supplement_id: 's1', ops_count: 15, mode: 'llm', time_s: 3.2 });
eq(sections['s1'].status, 'llm_done', 'status=llm_done');
eq(sections['s1'].ops_count, 15, 'ops_count=15');
eq(sections['s1'].mode, 'llm', 'mode=llm');
eq(sections['s1'].time_s, 3.2, 'time_s=3.2');
// applied
applyCompareEvent(sections, {
type: 'applied', supplement_id: 's1',
summary: { added: 5, updated: 2, deleted: 1 },
ops: [{ action: 'ADD', new_row: { name: 'Услуга 1' } }]
});
eq(sections['s1'].status, 'applied', 'status=applied');
eq(sections['s1'].summary.added, 5, 'summary.added=5');
eq(sections['s1'].ops.length, 1, 'ops.length=1');
// extract_error на существующей секции
applyCompareEvent(sections, { type: 'extract_error', supplement_id: 's1', error: 'parse failed' });
eq(sections['s1'].status, 'error', 'после ошибки: status=error');
eq(sections['s1'].error, 'parse failed', 'текст ошибки');
// extract_error на НЕсуществующей секции
applyCompareEvent(sections, { type: 'extract_error', supplement_id: 's2', filename: 'bad.docx', error: 'boom' });
eq(sections['s2'].status, 'error', 'новая секция с ошибкой');
eq(sections['s2'].filename, 'bad.docx', 'filename для ошибочной секции');
// Неизвестный тип события — не должен падать
applyCompareEvent(sections, { type: 'unknown_type', supplement_id: 's3' });
assert(sections['s3'] === undefined, 'неизвестный тип: секция НЕ создана');
// llm_done для несуществующей секции — не должен падать
applyCompareEvent(sections, { type: 'llm_done', supplement_id: 's99', ops_count: 1 });
// apply_error (должен работать как extract_error)
applyCompareEvent(sections, { type: 'apply_error', supplement_id: 's4', filename: 'apply_err.docx', error: 'apply boom' });
eq(sections['s4'].status, 'error', 'apply_error: секция с ошибкой создана');
eq(sections['s4'].error, 'apply boom', 'apply_error: текст ошибки');
// applied без summary
applyCompareEvent(sections, { type: 'applied', supplement_id: 's1', ops: [] });
eq(sections['s1'].status, 'applied', 'applied без summary: status ок');
eq(sections['s1'].ops.length, 0, 'applied с пустыми ops');
// ── Тесты: renderCompareSectionHeader ────────────────────────
console.log('\n=== renderCompareSectionHeader ===');
contains(renderCompareSectionHeader({ filename: 'f.docx', status: 'extracting' }), '⏳', 'extracting: ⏳');
contains(renderCompareSectionHeader({ filename: 'f.docx', status: 'llm_done', ops_count: 5, mode: 'llm', time_s: 2 }), '✓', 'llm_done: ✓');
contains(renderCompareSectionHeader({ filename: 'f.docx', status: 'llm_done', ops_count: 5, mode: 'llm', time_s: 2 }), '5 оп.', 'llm_done: ops_count');
contains(renderCompareSectionHeader({ filename: 'f.docx', status: 'error', error: 'fail' }), '✗', 'error: ✗');
contains(renderCompareSectionHeader({ filename: 'f.docx', status: 'error', error: 'fail' }), 'fail', 'error: текст');
// ── Тесты: renderCompareOpsTable ─────────────────────────────
console.log('\n=== renderCompareOpsTable ===');
var ops = [
{ action: 'ADD', new_row: { name: 'Стойка', price: 100, qty: 2, sum: 200, date_start: '2024-01-01' } },
{ action: 'DELETE', new_row: { name: 'IP', price: 50, qty: 1, sum: 50, date_start: '' } }
];
var tableHtml = renderCompareOpsTable(ops);
contains(tableHtml, 'diff-added', 'ADD → diff-added');
contains(tableHtml, 'diff-deleted', 'DELETE → diff-deleted');
contains(tableHtml, 'Стойка', 'имя услуги');
contains(tableHtml, '100', 'цена');
contains(tableHtml, '2024-01-01', 'дата');
// Пустые ops
var emptyTable = renderCompareOpsTable([]);
contains(emptyTable, '<table', 'пустые ops: таблица рендерится');
contains(emptyTable, '<tbody>', 'пустые ops: tbody есть');
// ops с null new_row
var nullRow = renderCompareOpsTable([{ action: 'ADD' }]);
contains(nullRow, '<td></td>', 'null new_row: пустые ячейки (не падает)');
// ops с неизвестным action (без класса)
var unknownAct = renderCompareOpsTable([{ action: 'MERGE', new_row: {} }]);
assert(unknownAct.indexOf('diff-added') === -1, 'MERGE: нет diff-added');
assert(unknownAct.indexOf('diff-deleted') === -1, 'MERGE: нет diff-deleted');
assert(unknownAct.indexOf('diff-changed') === -1, 'MERGE: нет diff-changed');
// ── Тесты: renderCompareSectionBody ──────────────────────────
console.log('\n=== renderCompareSectionBody ===');
eq(renderCompareSectionBody(null), '', 'null → пусто');
eq(renderCompareSectionBody({}), '', 'пустой → пусто');
eq(renderCompareSectionBody({ status: 'llm_done' }), '', 'llm_done → пусто');
var secApplied = {
status: 'applied',
summary: { added: 3, updated: 1, deleted: 0, unresolved: 2 },
ops: [{ action: 'UPDATE', new_row: { name: 'Стойка 42U' } }]
};
var bodyHtml = renderCompareSectionBody(secApplied);
contains(bodyHtml, '+3', 'added=3');
contains(bodyHtml, '~1', 'updated=1');
contains(bodyHtml, '-0', 'deleted=0');
contains(bodyHtml, '?2', 'unresolved=2');
contains(bodyHtml, 'diff-changed', 'UPDATE → diff-changed');
// Без unresolved
var secNoUnresolved = { status: 'applied', summary: { added: 1, updated: 0, deleted: 0 }, ops: [] };
var bodyNoUnr = renderCompareSectionBody(secNoUnresolved);
assert(bodyNoUnr.indexOf('?') === -1, 'без unresolved: нет знака ?');
// Отрицательные значения summary (не должно быть, но не падать)
var secNeg = { status: 'applied', summary: { added: -1, updated: 0, deleted: 0 }, ops: [{ action: 'ADD', new_row: {} }] };
var bodyNeg = renderCompareSectionBody(secNeg);
contains(bodyNeg, '+-1', 'отрицательные summary: рендерится без ошибок');
// ops с частичным new_row (не все поля)
var secPartial = { status: 'applied', summary: { added: 1 }, ops: [
{ action: 'ADD', new_row: { name: 'Только имя' } }
]};
var bodyPartial = renderCompareSectionBody(secPartial);
contains(bodyPartial, 'Только имя', 'частичный new_row: имя есть');
// Проверим что нет undefined в выводе
assert(bodyPartial.indexOf('undefined') === -1, 'нет undefined в выводе');
// ── Тесты: escHtml ───────────────────────────────────────────
console.log('\n=== escHtml ===');
eq(escHtml(null), '', 'escHtml null → пусто');
eq(escHtml(undefined), '', 'escHtml undefined → пусто');
eq(escHtml('hello'), 'hello', 'escHtml обычный текст');
eq(escHtml('<script>'), '&lt;script&gt;', 'escHtml экранирует < >');
eq(escHtml('a&b'), 'a&amp;b', 'escHtml экранирует &');
eq(escHtml('"quote"'), '&quot;quote&quot;', 'escHtml экранирует кавычки');
eq(escHtml(123), '123', 'escHtml число → строка');
// ── Дымные тесты API (бэкенд) ────────────────────────────────
console.log('\n=== API smoke tests ===');
var http = require('http');
var https = require('https');
function apiTest(method, url, cb) {
var mod = url.startsWith('https') ? https : http;
var req = mod.request(url, { method: method, timeout: 5000, rejectUnauthorized: false }, function(res) {
var body = '';
res.on('data', function(c) { body += c; });
res.on('end', function() { cb(null, res.statusCode, body); });
});
req.on('error', function(e) { cb(e.message); });
req.on('timeout', function() { req.destroy(); cb('timeout'); });
req.end();
}
// Эти тесты асинхронные — собираем результаты
var apiTests = [];
function addApiTest(name, method, url, check) {
apiTests.push({ name: name, method: method, url: url, check: check });
}
addApiTest('GET / (главная)', 'GET', 'https://contracts.kube5s.ru/', function(code, body) {
assert(code === 200, 'главная: HTTP 200 — got ' + code);
contains(body, '<!DOCTYPE html>', 'главная: HTML');
});
addApiTest('POST /api/cleanup', 'POST', 'https://contracts.kube5s.ru/api/cleanup', function(code, body) {
// cleanup может вернуть 200 или 500 (если БД недоступна) — оба норм для дымного теста
assert(code >= 200 && code < 600, 'cleanup: ответ получен — ' + code);
});
addApiTest('GET /static/state.js', 'GET', 'https://contracts.kube5s.ru/static/state.js', function(code, body) {
eq(code, 200, 'state.js: HTTP 200 — got ' + code);
contains(body, 'Центральное состояние', 'state.js: содержит описание');
});
addApiTest('GET /static/files.js', 'GET', 'https://contracts.kube5s.ru/static/files.js', function(code) {
eq(code, 200, 'files.js: HTTP 200 — got ' + code);
});
addApiTest('GET /static/groups.js', 'GET', 'https://contracts.kube5s.ru/static/groups.js', function(code) {
eq(code, 200, 'groups.js: HTTP 200 — got ' + code);
});
addApiTest('GET /static/compare.js', 'GET', 'https://contracts.kube5s.ru/static/compare.js', function(code) {
eq(code, 200, 'compare.js: HTTP 200 — got ' + code);
});
addApiTest('GET /static/app.js', 'GET', 'https://contracts.kube5s.ru/static/app.js', function(code) {
eq(code, 200, 'app.js: HTTP 200 — got ' + code);
});
// Запускаем API тесты последовательно (чтобы не зафлудить)
var apiIdx = 0;
function runNextApiTest() {
if (apiIdx >= apiTests.length) {
// Все API тесты готовы — выводим итоги
printResults();
return;
}
var t = apiTests[apiIdx];
apiTest(t.method, t.url, function(err, code, body) {
if (err) {
console.log(' FAIL: ' + t.name + ' — ' + err);
failed++;
} else {
t.check(code, body || '');
}
apiIdx++;
runNextApiTest();
});
}
function printResults() {
console.log('\n========================================');
console.log('PASS: ' + passed);
console.log('FAIL: ' + failed);
console.log('========================================');
if (failed > 0) process.exit(1);
}
// Начинаем с API тестов после синхронных
console.log(' (асинхронные — ждём...)');
runNextApiTest();
View File
View File
@@ -1,365 +0,0 @@
"""
test_testgen_pipeline.py — Сквозной тест: testgen → parse → textify → LLM-промпт.
Пайплайн (без БД, без HTTP):
1. testgen генерирует DOCX (договор, спека, допник)
2. parse.py парсит байты → elements_json
3. process.py::_elements_to_text() → plain text для LLM
4. llm_prompt.py строит промпт → проверяем структуру
Точка входа: elements_json — именно туда приходят данные после парсинга.
"""
import io
import json
import sys
import os
import pytest
# Добавить пути для импортов
_deploy = os.path.join(os.path.dirname(__file__), "..", "..")
_testgen = os.path.join(_deploy, "..", "..", "testgen")
sys.path.insert(0, _deploy)
sys.path.insert(0, _testgen)
from compare.parse import parse_file
from compare.process import _elements_to_text
# ═══════════════════════════════════════════════════════════════
# Helpers
# ═══════════════════════════════════════════════════════════════
def _build_and_parse(build_fn, ctx):
"""Построить docx через testgen, отдать байты парсеру. Возвращает parsed dict."""
from docx import Document
doc = build_fn(ctx)
buf = io.BytesIO()
doc.save(buf)
return parse_file("test.docx", buf.getvalue())
def _elements_to_dicts(elements):
"""Привести elements к dict для сравнения (убрать лишнее)."""
result = []
for el in elements:
d = {"type": el["type"]}
if el["type"] == "paragraph":
d["text"] = el.get("text", "")
elif el["type"] == "table":
d["row_count"] = len(el.get("rows", []))
d["col_count"] = len(el["rows"][0]) if el.get("rows") else 0
d["first_cell"] = el["rows"][0][0] if el.get("rows") and el["rows"][0] else ""
result.append(d)
return result
# ═══════════════════════════════════════════════════════════════
# Tests
# ═══════════════════════════════════════════════════════════════
class TestGenerateParseTextify:
"""Полный пайплайн: testgen → parse → textify."""
# ── Данные ────────────────────────────────────────────────
@pytest.fixture
def pools(self):
import pools
return pools
@pytest.fixture
def templates(self):
import templates
return templates
@pytest.fixture
def contract_ctx(self, pools):
ci = 0
c = pools.COMPANIES[ci]
num = pools.contract_number(ci)
date = pools.random_date()
return {
"company": c, "number": num, "date": date, "company_idx": ci,
"comment": "Тестовый контракт для пайплайна.",
}
@pytest.fixture
def spec_ctx(self, pools):
ci = 0
c = pools.COMPANIES[ci]
num = pools.contract_number(ci)
date = pools.random_date()
svcs, total, total_str = pools.pick_services(5)
for s in svcs:
s["date_start"] = pools.random_date(2026, 3, 3)
return {
"company": c, "number": num, "date": date, "contract_date": date,
"version": 1, "services": svcs, "total": total, "total_str": total_str,
"label": f"Абонентские услуги с {date}",
"comment": "Тестовая спецификация для пайплайна.",
}
@pytest.fixture
def addendum_ctx(self, pools):
ci = 0
c = pools.COMPANIES[ci]
num = pools.contract_number(ci)
date = pools.random_date()
inst, itot, itot_s = pools.pick_services(2)
mon, mtot, mtot_s = pools.pick_services(3)
return {
"company": c, "number": num, "date": date, "contract_date": pools.random_date(),
"addendum_num": 1,
"services_install": inst, "install_total": itot, "install_total_str": itot_s,
"services_monthly": mon, "monthly_total": mtot, "monthly_total_str": mtot_s,
"comment": "Тестовый допник для пайплайна.",
}
# ── Этап 1: Генерация → парсинг ───────────────────────────
def test_generate_contract_parse_ok(self, templates, contract_ctx):
"""Договор: генерируется и парсится без ошибок."""
result = _build_and_parse(templates.build_contract, contract_ctx)
assert result["status"] == "parsed", f"Parse failed: {result.get('error')}"
assert result["element_count"] > 0
def test_generate_contract_has_paragraphs(self, templates, contract_ctx):
"""Договор: в elements есть paragraphs."""
result = _build_and_parse(templates.build_contract, contract_ctx)
paragraphs = [e for e in result["elements"] if e["type"] == "paragraph"]
assert len(paragraphs) >= 3, f"Expected >=3 paragraphs, got {len(paragraphs)}"
def test_generate_contract_has_tables(self, templates, contract_ctx):
"""Договор: в elements есть таблица реквизитов."""
result = _build_and_parse(templates.build_contract, contract_ctx)
tables = [e for e in result["elements"] if e["type"] == "table"]
assert len(tables) >= 1, f"Expected >=1 table, got {len(tables)}"
def test_generate_spec_parse_ok(self, templates, spec_ctx):
"""Спецификация: генерируется и парсится без ошибок."""
result = _build_and_parse(templates.build_spec, spec_ctx)
assert result["status"] == "parsed"
assert result["element_count"] > 0
def test_generate_spec_has_services_table(self, templates, spec_ctx):
"""Спецификация: таблица услуг найдена."""
result = _build_and_parse(templates.build_spec, spec_ctx)
tables = [e for e in result["elements"] if e["type"] == "table"]
assert len(tables) >= 1
# Первая таблица — услуги, должна быть НЕ пустой
svc_table = tables[0]
assert len(svc_table["rows"]) >= 2 # заголовок + минимум 1 строка
def test_generate_addendum_parse_ok(self, templates, addendum_ctx):
"""Допсоглашение: генерируется и парсится без ошибок."""
result = _build_and_parse(templates.build_addendum, addendum_ctx)
assert result["status"] == "parsed"
assert result["element_count"] > 0
def test_generate_addendum_has_tables(self, templates, addendum_ctx):
"""Допсоглашение: таблицы разовых и абонентских услуг."""
result = _build_and_parse(templates.build_addendum, addendum_ctx)
tables = [e for e in result["elements"] if e["type"] == "table"]
# Должно быть минимум 3 таблицы: реквизиты + разовые + абонентские
assert len(tables) >= 3, f"Expected >=3 tables, got {len(tables)}"
# ── Этап 2: elements_json → textify ────────────────────────
def test_textify_contract(self, templates, contract_ctx):
"""Договор after textify содержит ключевые слова."""
result = _build_and_parse(templates.build_contract, contract_ctx)
text = _elements_to_text(result["elements"])
assert "Договор" in text or "ДОГОВОР" in text
assert "НУБЕС" in text
assert len(text) > 200, f"Text too short: {len(text)} chars"
def test_textify_spec_contains_table_markers(self, templates, spec_ctx):
"""Спецификация after textify: пайп-таблицы на месте."""
result = _build_and_parse(templates.build_spec, spec_ctx)
text = _elements_to_text(result["elements"])
# Должны быть маркеры таблиц (--- Таблица ... ---)
assert "Таблица" in text
# Должны быть пайпы (формат ячеек)
assert "|" in text
# Должны быть названия услуг
for s in spec_ctx["services"]:
# Проверяем первые 30 символов названия
short_name = s["name"][:30]
assert short_name in text, f"Service '{short_name}' not found in textified output"
def test_textify_spec_contains_total(self, templates, spec_ctx):
"""Спецификация: итоговая сумма в тексте."""
result = _build_and_parse(templates.build_spec, spec_ctx)
text = _elements_to_text(result["elements"])
assert "Итого" in text
def test_textify_addendum_structure(self, templates, addendum_ctx):
"""Допсоглашение after textify: структура сохраняется."""
result = _build_and_parse(templates.build_addendum, addendum_ctx)
text = _elements_to_text(result["elements"])
assert "Соглашение" in text or "СОГЛАШЕНИЕ" in text
assert "Инсталляц" in text or "Разовые" in text
assert "Абонентские" in text
# ── Этап 3: elements_json → промпт LLM ─────────────────────
def test_build_extract_prompt_from_elements(self, templates, spec_ctx):
"""Из elements_json спецификации строится extract-промпт.
Пустая current_spec → extract mode (FALLBACK_EXTRACT)."""
from llm_prompt import build_prompt
result = _build_and_parse(templates.build_spec, spec_ctx)
doc_text = _elements_to_text(result["elements"])
# Пустая current_spec = первый документ = extract-промпт
prompt_text, prompt_id = build_prompt([], doc_text)
assert len(prompt_text) > 100
assert "{doc_text}" not in prompt_text # переменная подставлена
assert doc_text in prompt_text # текст документа внутри промпта
assert "JSON" in prompt_text or "json" in prompt_text
def test_build_diff_prompt_from_elements(self, templates, addendum_ctx):
"""Из elements_json допсоглашения строится diff-промпт.
Непустая current_spec → diff mode (FALLBACK_DIFF)."""
from llm_prompt import build_prompt
result = _build_and_parse(templates.build_addendum, addendum_ctx)
doc_text = _elements_to_text(result["elements"])
# Непустая current_spec = не первый документ = diff-промпт
current_spec = [
{"hash": "h1", "name": "Аренда стойко-места", "price": 50000, "qty": 1, "sum": 50000, "date_start": "2026-01-01"},
]
prompt_text, prompt_id = build_prompt(current_spec, doc_text)
assert len(prompt_text) > 100
assert doc_text in prompt_text
assert "UPDATE" in prompt_text or "DELETE" in prompt_text or "ADD" in prompt_text
# ── Этап 4: edge cases ─────────────────────────────────────
def test_parse_empty_docx(self):
"""Пустой docx — парсится без ошибок, но без элементов."""
from docx import Document
doc = Document()
buf = io.BytesIO()
doc.save(buf)
result = parse_file("empty.docx", buf.getvalue())
assert result["status"] == "parsed"
assert result["element_count"] == 0
def test_parse_docx_text_only(self):
"""DOCX только с текстом — корректно парсится."""
from docx import Document
doc = Document()
doc.add_paragraph("Привет мир")
doc.add_paragraph("Вторая строка")
buf = io.BytesIO()
doc.save(buf)
result = parse_file("text.docx", buf.getvalue())
assert result["status"] == "parsed"
assert result["element_count"] == 2
assert all(e["type"] == "paragraph" for e in result["elements"])
def test_parse_docx_table_only(self):
"""DOCX только с таблицей — корректно парсится."""
from docx import Document
doc = Document()
table = doc.add_table(rows=2, cols=3)
table.rows[0].cells[0].text = "A"
table.rows[0].cells[1].text = "B"
table.rows[0].cells[2].text = "C"
table.rows[1].cells[0].text = "1"
table.rows[1].cells[1].text = "2"
table.rows[1].cells[2].text = "3"
buf = io.BytesIO()
doc.save(buf)
result = parse_file("table.docx", buf.getvalue())
assert result["status"] == "parsed"
tables = [e for e in result["elements"] if e["type"] == "table"]
assert len(tables) == 1
assert tables[0]["rows"] == [["A", "B", "C"], ["1", "2", "3"]]
def test_textify_preserves_table_data(self):
"""Textify сохраняет все данные таблицы (без потерь)."""
elements = [
{"type": "table", "rows": [
["Услуга", "Цена", "Кол-во"],
["Аренда стойки", "50000", "2"],
["IP-адрес", "300", "8"],
]}
]
text = _elements_to_text(elements)
assert "Услуга" in text
assert "Аренда стойки" in text
assert "50000" in text
assert "2" in text
assert "IP-адрес" in text
assert "300" in text
assert "8" in text
# ── Этап 5: валидация manifest-подобной структуры ──────────
def test_contract_elements_structure(self, templates, contract_ctx):
"""Элементы договора имеют правильную структуру."""
result = _build_and_parse(templates.build_contract, contract_ctx)
for el in result["elements"]:
assert "type" in el
assert el["type"] in ("paragraph", "table")
if el["type"] == "paragraph":
assert "text" in el
assert isinstance(el["text"], str)
elif el["type"] == "table":
assert "rows" in el
assert isinstance(el["rows"], list)
assert len(el["rows"]) > 0
assert all(isinstance(row, list) for row in el["rows"])
def test_multiple_contracts_different_content(self, templates, pools):
"""Разные контракты дают разный elements_json."""
import hashlib
results = []
for ci in range(3):
c = pools.COMPANIES[ci]
ctx = {
"company": c, "number": pools.contract_number(ci),
"date": pools.random_date(), "company_idx": ci,
"comment": f"Контракт {ci}",
}
result = _build_and_parse(templates.build_contract, ctx)
# Сериализуем для сравнения
txt = _elements_to_text(result["elements"])
results.append(txt)
# Все три должны быть разными
hashes = [hashlib.md5(t.encode()).hexdigest() for t in results]
assert len(set(hashes)) == 3, f"All 3 contracts produced same text!"
def test_spec_v1_v2_produce_different_tables(self, templates, pools):
"""Спецификации v1 и v2 дают разные таблицы (разные цены/объёмы)."""
c = pools.COMPANIES[0]
base_ctx = {
"company": c, "number": pools.contract_number(0),
"date": pools.random_date(), "contract_date": pools.random_date(),
"version": 1,
"label": "Абонентские услуги",
}
# v1
svcs_v1, tot_v1, tot_str_v1 = pools.pick_services(5)
ctx_v1 = {**base_ctx, "services": svcs_v1, "total": tot_v1, "total_str": tot_str_v1}
r1 = _build_and_parse(templates.build_spec, ctx_v1)
t1 = _elements_to_text(r1["elements"])
# v2 — меняем цены
import copy
svcs_v2 = copy.deepcopy(svcs_v1)
if svcs_v2:
svcs_v2[0]["price"] = round(svcs_v2[0]["price"] * 1.25, 2)
svcs_v2[0]["price_str"] = pools.format_price(svcs_v2[0]["price"])
svcs_v2[0]["sum"] = round(svcs_v2[0]["price"] * svcs_v2[0]["qty"], 2)
svcs_v2[0]["sum_str"] = pools.format_price(svcs_v2[0]["sum"])
tot_v2 = round(sum(s["sum"] for s in svcs_v2), 2)
ctx_v2 = {**base_ctx, "version": 2, "services": svcs_v2, "total": tot_v2, "total_str": pools.format_price(tot_v2)}
r2 = _build_and_parse(templates.build_spec, ctx_v2)
t2 = _elements_to_text(r2["elements"])
assert t1 != t2, "v1 and v2 specs produced identical text!"
-162
View File
@@ -1,162 +0,0 @@
"""
Тест DrHider — много проверок.
Файл: /home/naeel/nubes/contracts/contracts-flask/deploy/tests/test_drhider.py
"""
import sys, os, io, zipfile, json, base64
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
from drhider.drhider import obfuscate_files, TwoPassObfuscator, COMPANY_PATTERN, PERSON_PATTERN
TEST_ZIP = "/home/naeel/nubes/contracts/dogovora/примеры_договоров_для_ИИ.zip"
def run(text, desc):
"""Обработать текст и проверить что needle НЕ найдено."""
z, c = obfuscate_files([('t.txt', text.encode(), '')])
with zipfile.ZipFile(io.BytesIO(z)) as zf:
out = zf.read('t.txt').decode()
csv = zf.read('mapping.csv').decode()
return out, csv
errors = 0
def check(ok, msg):
global errors
if ok:
print(f" ✅ {msg}")
else:
print(f" ❌ {msg}")
errors += 1
print("=" * 60)
print("DrHider — полный тест")
print("=" * 60)
# ── 1. Телефоны ──
print("\n📞 Телефоны:")
out, csv = run("Звоните +7 (495) 789-41-35 или 8-800-555-35-35", "телефоны")
check("+7 (495) 789-41-35" not in out, "+7 (495) 789-41-35 заменён")
check("8-800-555-35-35" not in out, "8-800-555-35-35 заменён")
check("phone" in csv, "тип phone в CSV")
# ── 2. Телефон не матчит число внутри счёта ──
print("\n📞 Телефон vs номер счёта:")
out, csv = run("Кор/сч 30101810400000000225", "счёт")
check("30101810400000000225" not in out, "номер счёта заменён (ks)")
check("+7" not in out, "нет ложного телефона внутри счёта")
# ── 3. Email ──
print("\n📧 Email:")
out, csv = run("Пишите info@nubes.ru и support@company.org", "email")
check("info@nubes.ru" not in out, "info@nubes.ru заменён")
check("support@company.org" not in out, "support@company.org заменён")
check("email" in csv, "тип email в CSV")
# ── 4. Компании (разные кавычки) ──
print("\n🏢 Компании:")
out, csv = run('ООО "Ромашка" и АО \u201cXXX003\u201d и ЗАО «Тест»', "компании")
check('ООО "Ромашка"' not in out, 'ООО "Ромашка" заменён')
check('АО \u201cXXX003\u201d' not in out, 'АО "XXX003" (unicode) заменён')
check('ЗАО «Тест»' not in out, 'ЗАО «Тест» заменён')
check('company' in csv, 'тип company в CSV')
# ── 5. НУБЕС whitelist ──
print("\n🛡️ НУБЕС whitelist:")
out, csv = run('ООО "НУБЕС" и ООО \u201cНУБЕС\u201d', "НУБЕС")
check('ООО "НУБЕС"' in out, 'НУБЕС (ASCII) НЕ заменён')
check('ООО \u201cНУБЕС\u201d' in out, 'НУБЕС (unicode) НЕ заменён')
# ── 6. ИНН/ОГРН/КПП/БИК ──
print("\n🔢 ИНН/ОГРН/КПП/БИК:")
out, csv = run("ИНН 9706005293, КПП 772401001, ОГРН 1207700098759, БИК 044525225", "реквизиты")
check("9706005293" not in out, "ИНН заменён")
check("772401001" not in out, "КПП заменён")
check("1207700098759" not in out, "ОГРН заменён")
check("044525225" not in out, "БИК заменён")
check("inn_ul" in csv, "inn_ul в CSV")
# ── 7. Расчётный счёт ──
print("\n💳 Расчётный счёт:")
out, csv = run("р/с 40702810738000174030 и Кор/сч 30101810400000000225", "счета")
check("40702810738000174030" not in out, "р/с заменён")
check("30101810400000000225" not in out, "кор/сч заменён")
check("rs" in csv, "rs в CSV")
check("ks" in csv, "ks в CSV")
# ── 8. ФИО (инициалы + полностью) ──
print("\n👤 ФИО:")
out, csv = run("Директор Степаненко В.С. и Иванов Александр Петрович", "ФИО")
check("Степаненко В.С." not in out, "Степаненко В.С. заменён")
check("Иванов Александр Петрович" not in out, "Иванов А.П. заменён")
# ── 9. Паспорт ──
print("\n📄 Паспорт:")
out, csv = run("паспорт 12 34 567890", "паспорт")
check("12 34 567890" not in out, "номер паспорта заменён")
# ── 10. Согласованность (одна сущность → одна замена) ──
print("\n🔄 Согласованность:")
z, c = obfuscate_files([
('a.txt', b'OOO Romashka +79991234567', ''),
('b.txt', b'OOO Romashka +79991234567', ''),
])
with zipfile.ZipFile(io.BytesIO(z)) as zf:
a = zf.read('a.txt').decode()
b = zf.read('b.txt').decode()
check(a == b, f"оба файла одинаковы: {a}")
# ── 11. ZIP внутри ZIP ──
print("\n📦 ZIP внутри:")
with open(TEST_ZIP, 'rb') as f:
raw = f.read()
z, c = obfuscate_files([('test.zip', raw, '')])
with zipfile.ZipFile(io.BytesIO(z)) as zf:
names = zf.namelist()
check(len(names) == 6, f"6 файлов (5 + csv): {len(names)}")
check('mapping.csv' in names, "mapping.csv есть")
check(not any(n.endswith('.zip') for n in names), "нет .zip в выдаче")
# Проверим что docx внутри валидны
for n in names:
if n.endswith('.docx'):
try:
from docx import Document
Document(io.BytesIO(zf.read(n)))
check(True, f"{n} — валидный docx")
except:
check(False, f"{n} — БИТЫЙ docx")
# ── 12. .doc бинарный — не трогаем ──
print("\n📄 .doc бинарный:")
# Создаём фейковый .doc файл (просто бинарные данные)
z, c = obfuscate_files([('old.doc', b'\xD0\xCF\x11\xE0' + b'\x00' * 100, '')])
with zipfile.ZipFile(io.BytesIO(z)) as zf:
doc_content = zf.read('old.doc')
check(len(doc_content) == 104, ".doc сохранён без изменений")
# ── 13. Много файлов ──
print("\n📚 20 файлов:")
many = [('f{}.txt'.format(i), 'OOO Test{} +7999{}'.format(i, i).encode(), '') for i in range(20)]
z, c = obfuscate_files(many)
with zipfile.ZipFile(io.BytesIO(z)) as zf:
check(len(zf.namelist()) == 21, "20 файлов + csv")
# ── 14. COMPANY_PATTERN не жадный ──
print("\n🔤 COMPANY_PATTERN границы:")
m = COMPANY_PATTERN.search("АО Test с дополнительным текстом дальше")
check(m is not None and 'дальше' not in m.group(0), "не захватывает лишний текст")
# ── 15. PERSON_PATTERN только кириллица ──
print("\n🔤 PERSON_PATTERN кириллица:")
m = PERSON_PATTERN.search("Next Generation Cloud service")
check(m is None, "английский не матчится")
m2 = PERSON_PATTERN.search("Иванов Иван Иванович")
check(m2 is not None, "русский матчится")
# ── ИТОГО ──
print(f"\n{'='*60}")
if errors:
print(f"❌ {errors} ошибок")
else:
print("✅ ВСЕ ТЕСТЫ ПРОЙДЕНЫ")
print(f"{'='*60}")
sys.exit(0 if errors == 0 else 1)
View File
-62
View File
@@ -1,62 +0,0 @@
"""Test classify_batch with MemRepository + FakeLLM."""
from compare.classify import classify_batch
from compare.llm_client import FakeLLMClient
from repository import MemRepository
class TestClassifyBatch:
def test_garbage_by_filename(self):
"""Счёт-фактура отфильтровывается без LLM."""
repo = MemRepository()
llm = FakeLLMClient({"default": '{"doc_type":"contract"}'})
repo.insert_document("счет-фактура №123.docx", "mime", "data", batch_id="b1")
result = classify_batch("b1", llm_client=llm, repo=repo)
assert result["ok"] is True
assert result["garbage"] == 1
assert result["done"] == 1
assert len(llm.calls) == 0 # LLM не вызывался
def test_garbage_by_header(self):
"""Акт сверки в тексте отфильтровывается."""
repo = MemRepository()
llm = FakeLLMClient({"default": '{"doc_type":"contract"}'})
doc = repo.insert_document("документ.docx", "mime", "data", batch_id="b1")
repo.set_document_parsed(doc["id"], [
{"type": "paragraph", "text": "АКТ СВЕРКИ взаимных расчётов", "style": ""}
])
result = classify_batch("b1", llm_client=llm, repo=repo)
assert result["garbage"] == 1
assert len(llm.calls) == 0
def test_llm_classify(self):
"""Договор классифицируется через LLM."""
repo = MemRepository()
llm = FakeLLMClient({
"default": '{"doc_type":"contract","own_number":"03700_1","doc_date":"2026-02-01","counterparty":"ЗАО XXX001"}'
})
doc = repo.insert_document("договор-XXX001.docx", "mime", "data", batch_id="b1")
repo.set_document_parsed(doc["id"], [
{"type": "paragraph", "text": "ДОГОВОР № 03700_1", "style": ""}
])
result = classify_batch("b1", llm_client=llm, repo=repo)
assert result["done"] == 1, f"done={result['done']}, failed={result['failed']}, doc={repo.get_document(doc['id'])}"
assert result["garbage"] == 0
assert len(llm.calls) == 1
updated = repo.get_document(doc["id"])
assert updated["doc_type"] == "contract"
assert updated["own_number"] == "03700_1"
assert updated["counterparty"] == "ЗАО XXX001"
def test_no_pending(self):
"""Пустой батч."""
repo = MemRepository()
result = classify_batch("empty", llm_client=FakeLLMClient(), repo=repo)
assert result["ok"] is False
assert "no pending" in result["error"]
-110
View File
@@ -1,110 +0,0 @@
"""Unit tests — no DB, no network, just pure logic."""
import pytest
from contracts import ParseResult, ClassifyResult, GroupingResult, CompareOp
from compare.upload import parse_multipart
from compare.classify import _is_garbage_by_filename, _is_garbage_by_header
class TestContracts:
"""Dataclass creation and validation."""
def test_parse_result(self):
r = ParseResult(status="parsed", element_count=10, elements=[])
assert r.status == "parsed"
assert r.element_count == 10
def test_classify_from_llm(self):
r = ClassifyResult.from_llm({
"doc_type": "contract",
"own_number": "03700_1",
"doc_date": "2026-02-01",
"counterparty": "ЗАО XXX001",
})
assert r.doc_type == "contract"
assert r.own_number == "03700_1"
assert r.counterparty == "ЗАО XXX001"
def test_grouping_result_empty(self):
r = GroupingResult(contract_number="03700_1")
assert r.contract_number == "03700_1"
assert r.documents == []
def test_compare_op_add(self):
op = CompareOp(action="ADD", new_row={"name": "Тест", "price": 100})
assert op.action == "ADD"
assert op.new_row["price"] == 100
def test_compare_op_from_llm(self):
op = CompareOp.from_llm({
"action": "UPDATE",
"target_id": "r1",
"new_values": {"price": 200},
"comment": "change",
})
assert op.action == "UPDATE"
assert op.new_values["price"] == 200
assert op.comment == "change"
class TestGarbageFilter:
"""Stage 1-2 garbage detection."""
def test_filename_garbage_invoice(self):
assert _is_garbage_by_filename("счет-фактура №123.docx") is True
def test_filename_garbage_act(self):
assert _is_garbage_by_filename("Акт сверки за май.pdf") is True
def test_filename_not_garbage(self):
assert _is_garbage_by_filename("договор-XXX001.docx") is False
assert _is_garbage_by_filename("спецификация услуг.pdf") is False
def test_header_garbage(self):
text = "СЧЕТ-ФАКТУРА № 123 от 01.01.2026\nПоставщик: ООО Ромашка"
assert _is_garbage_by_header(text) is True
def test_header_not_garbage(self):
text = "ДОГОВОР № 03700_1\nг. Москва\n\nИсполнитель обязуется..."
assert _is_garbage_by_header(text) is False
class TestParseMultipart:
"""Pure multipart parsing without HTTP."""
def test_simple_file(self, sample_docx_bytes):
boundary = b"----testboundary"
body = (
b"------testboundary\r\n"
b'Content-Disposition: form-data; name="files"; filename="test.docx"\r\n'
b"Content-Type: application/octet-stream\r\n\r\n"
+ sample_docx_bytes +
b"\r\n------testboundary--\r\n"
)
result = parse_multipart(body, f"multipart/form-data; boundary=----testboundary")
assert result["filename"] == "test.docx"
assert result["file_data"] == sample_docx_bytes
def test_with_contract_id(self, sample_docx_bytes):
boundary = b"----testboundary"
body = (
b"------testboundary\r\n"
b'Content-Disposition: form-data; name="files"; filename="test.docx"\r\n\r\n'
+ sample_docx_bytes +
b"\r\n------testboundary\r\n"
b'Content-Disposition: form-data; name="contract_id"\r\n\r\n'
b"550e8400-e29b-41d4-a716-446655440000"
b"\r\n------testboundary--\r\n"
)
result = parse_multipart(body, f"multipart/form-data; boundary=----testboundary")
assert result["contract_id"] == "550e8400-e29b-41d4-a716-446655440000"
def test_path_traversal_rejected(self):
boundary = b"----testboundary"
body = (
b"------testboundary\r\n"
b'Content-Disposition: form-data; name="files"; filename="../etc/passwd"\r\n\r\n'
b"evil"
b"\r\n------testboundary--\r\n"
)
with pytest.raises(ValueError, match="invalid filename"):
parse_multipart(body, f"multipart/form-data; boundary=----testboundary")
-59
View File
@@ -1,59 +0,0 @@
"""Unit tests for LLM client and Repository — with fakes."""
from compare.llm_client import FakeLLMClient
from repository import MemRepository
class TestFakeLLM:
def test_exact_match(self):
client = FakeLLMClient({"hello": "world"})
assert client.complete("hello") == "world"
def test_partial_match(self):
client = FakeLLMClient({"classify": '{"doc_type":"contract"}'})
assert "contract" in client.complete("classify this document")
def test_default_fallback(self):
client = FakeLLMClient({"default": '{"ok":true}'})
assert client.complete("unknown prompt") == '{"ok":true}'
def test_call_history(self):
client = FakeLLMClient({"a": "1", "b": "2"})
client.complete("a")
client.complete("b")
assert len(client.calls) == 2
assert client.calls[0] == "a"
class TestMemRepository:
def test_insert_and_retrieve(self, mem_repo):
doc = mem_repo.insert_document("test.docx", "application/vnd...", "base64data", batch_id="batch-1")
assert doc["filename"] == "test.docx"
assert doc["classify_status"] == "pending"
def test_list_pending(self, mem_repo):
mem_repo.insert_document("a.docx", "mime", "data", batch_id="b1")
mem_repo.insert_document("b.docx", "mime", "data", batch_id="b1")
mem_repo.insert_document("c.docx", "mime", "data", batch_id="b2")
pending = mem_repo.list_pending("b1")
assert len(pending) == 2
def test_set_classification(self, mem_repo):
doc = mem_repo.insert_document("test.docx", "mime", "data", batch_id="b1")
mem_repo.set_classification(doc["id"], "contract", "03700", None, "2026-02-01", "XXX")
updated = mem_repo.documents[doc["id"]]
assert updated["doc_type"] == "contract"
assert updated["classify_status"] == "classified"
def test_set_garbage(self, mem_repo):
doc = mem_repo.insert_document("счет.docx", "mime", "data", batch_id="b1")
mem_repo.set_classify_garbage(doc["id"], "filename_regex")
assert mem_repo.documents[doc["id"]]["doc_type"] == "garbage"
def test_contract_lifecycle(self, mem_repo):
cid = mem_repo.insert_contract("03700_1", "ЗАО XXX")
assert cid
doc = mem_repo.insert_document("test.docx", "mime", "data")
mem_repo.insert_supplement(cid, doc["id"], "initial")
supps = mem_repo.list_supplements(cid)
assert len(supps) == 1
assert supps[0]["type"] == "initial"
+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, нормализация номеров, порядок, проверки и сохранение результата выполняются детерминированным кодом.
+1 -1
View File
@@ -1,7 +1,7 @@
"""Конфигурация приложения — все настройки в одном месте.""" """Конфигурация приложения — все настройки в одном месте."""
import os import os
VERSION = "2.0.16" VERSION = "2.0.21"
LLM_URL = os.getenv("LLM_API_URL", "https://api.aillm.ru/v1/chat/completions") LLM_URL = os.getenv("LLM_API_URL", "https://api.aillm.ru/v1/chat/completions")
LLM_KEY = os.getenv("LLM_API_KEY", "") LLM_KEY = os.getenv("LLM_API_KEY", "")
+19 -1
View File
@@ -10,12 +10,29 @@ import sqlite3
import threading import threading
import time import time
import os import os
from contextlib import contextmanager
DB_PATH = "/tmp/contracts.db" DB_PATH = "/tmp/contracts.db"
_db_session_key = None _db_session_key = None
_local = threading.local() _local = threading.local()
@contextmanager
def transaction():
"""Run DB writes atomically on the current thread connection."""
conn = get_conn()
conn.execute("BEGIN IMMEDIATE")
_local.in_transaction = True
try:
yield conn
conn.commit()
except Exception:
conn.rollback()
raise
finally:
_local.in_transaction = False
def init_db(): def init_db():
"""Создать/пересоздать БД + схему. Вызывается при старте и после cleanup.""" """Создать/пересоздать БД + схему. Вызывается при старте и после cleanup."""
global _db_session_key global _db_session_key
@@ -171,7 +188,8 @@ def execute(sql, params=None):
conn = get_conn() conn = get_conn()
sql = _pg_to_sqlite(sql) sql = _pg_to_sqlite(sql)
cur = conn.execute(sql, params or []) cur = conn.execute(sql, params or [])
conn.commit() if not getattr(_local, "in_transaction", False):
conn.commit()
return cur.rowcount return cur.rowcount
+30 -15
View File
@@ -1,6 +1,6 @@
"""spec_events — event sourcing: apply ops, reset contract.""" """spec_events — event sourcing: apply ops, reset contract."""
import json, uuid import json, uuid
from db.connection import query, execute, get_conn from db.connection import query, execute, get_conn, transaction
def reset(contract_id): def reset(contract_id):
@@ -27,9 +27,15 @@ def get_next_seq(contract_id):
def apply_ops(contract_id, supplement_id, document_id, ops, prompt_id, raw_llm_response): def apply_ops(contract_id, supplement_id, document_id, ops, prompt_id, raw_llm_response):
"""Apply ADD/UPDATE/DELETE ops. Returns summary dict.""" """Apply ADD/UPDATE/DELETE ops. Returns summary dict."""
with transaction():
return _apply_ops(contract_id, supplement_id, document_id, ops, prompt_id, raw_llm_response)
def _apply_ops(contract_id, supplement_id, document_id, ops, prompt_id, raw_llm_response):
added = 0 added = 0
updated = 0 updated = 0
deleted = 0 deleted = 0
unresolved = 0
seq = get_next_seq(contract_id) seq = get_next_seq(contract_id)
for op in ops: for op in ops:
@@ -42,6 +48,7 @@ def apply_ops(contract_id, supplement_id, document_id, ops, prompt_id, raw_llm_r
# ADD without name → UNRESOLVED # ADD without name → UNRESOLVED
_log_unresolved(contract_id, supplement_id, seq, op, prompt_id, document_id, raw_llm_response, "ADD with empty name") _log_unresolved(contract_id, supplement_id, seq, op, prompt_id, document_id, raw_llm_response, "ADD with empty name")
seq += 1 seq += 1
unresolved += 1
continue continue
name_hash = _hash(name, nr.get("date_start")) name_hash = _hash(name, nr.get("date_start"))
execute( execute(
@@ -65,6 +72,7 @@ def apply_ops(contract_id, supplement_id, document_id, ops, prompt_id, raw_llm_r
if not th: if not th:
_log_unresolved(contract_id, supplement_id, seq, op, prompt_id, document_id, raw_llm_response, "UPDATE with empty target_hash") _log_unresolved(contract_id, supplement_id, seq, op, prompt_id, document_id, raw_llm_response, "UPDATE with empty target_hash")
seq += 1 seq += 1
unresolved += 1
continue continue
execute( execute(
"""INSERT INTO spec_events (id, contract_id, supplement_id, seq, action, target_hash, """INSERT INTO spec_events (id, contract_id, supplement_id, seq, action, target_hash,
@@ -86,6 +94,7 @@ def apply_ops(contract_id, supplement_id, document_id, ops, prompt_id, raw_llm_r
if not th: if not th:
_log_unresolved(contract_id, supplement_id, seq, op, prompt_id, document_id, raw_llm_response, "DELETE with empty target_hash") _log_unresolved(contract_id, supplement_id, seq, op, prompt_id, document_id, raw_llm_response, "DELETE with empty target_hash")
seq += 1 seq += 1
unresolved += 1
continue continue
execute( execute(
"""INSERT INTO spec_events (id, contract_id, supplement_id, seq, action, target_hash, """INSERT INTO spec_events (id, contract_id, supplement_id, seq, action, target_hash,
@@ -104,27 +113,19 @@ def apply_ops(contract_id, supplement_id, document_id, ops, prompt_id, raw_llm_r
elif action == "UNRESOLVED": elif action == "UNRESOLVED":
# Log but don't apply # Log but don't apply
execute( _log_unresolved(contract_id, supplement_id, seq, op, prompt_id, document_id, raw_llm_response,
"""INSERT INTO spec_events (id, contract_id, supplement_id, seq, action, target_hash, op.get("reason", op.get("comment", "")))
new_values, comment, status, prompt_version, source_document_id, raw_llm_response)
VALUES (%s, %s, %s, %s, 'UNRESOLVED', %s, %s, %s, 'unresolved', %s, %s, %s)""",
(
str(uuid.uuid4()), contract_id, supplement_id, seq,
op.get("target_hash", ""),
json.dumps(op.get("new_values", {}), ensure_ascii=False),
op.get("reason", op.get("comment", "")),
prompt_id, document_id,
json.dumps(raw_llm_response, ensure_ascii=False),
),
)
seq += 1 seq += 1
unresolved += 1
else: else:
# Unknown action — log as UNRESOLVED # Unknown action — log as UNRESOLVED
_log_unresolved(contract_id, supplement_id, seq, op, prompt_id, document_id, raw_llm_response, _log_unresolved(contract_id, supplement_id, seq, op, prompt_id, document_id, raw_llm_response,
f"unknown action: {action}") f"unknown action: {action}")
seq += 1
unresolved += 1
return {"added": added, "updated": updated, "deleted": deleted} return {"added": added, "updated": updated, "deleted": deleted, "unresolved": unresolved}
def _log_unresolved(contract_id, supplement_id, seq, op, prompt_id, document_id, raw_llm_response, reason): def _log_unresolved(contract_id, supplement_id, seq, op, prompt_id, document_id, raw_llm_response, reason):
@@ -187,6 +188,20 @@ def _update_spec_current(contract_id, name_hash, new_values):
sets.append(f"{field} = %s") sets.append(f"{field} = %s")
params.append(new_values[field]) params.append(new_values[field])
if sets: if sets:
if "name" in new_values or "date_start" in new_values:
updated_name = new_values.get("name")
updated_date = new_values.get("date_start")
if updated_name is None or updated_date is None:
current = query(
"SELECT name, date_start FROM spec_current WHERE contract_id = %s AND name_hash = %s",
(contract_id, name_hash),
)
if current:
updated_name = updated_name if updated_name is not None else current[0]["name"]
updated_date = updated_date if updated_date is not None else current[0]["date_start"]
if updated_name is not None:
sets.append("name_hash = %s")
params.append(_hash(updated_name, updated_date))
sets.append("updated_at = datetime('now')") sets.append("updated_at = datetime('now')")
params.extend([contract_id, name_hash]) params.extend([contract_id, name_hash])
execute( execute(
+1 -2
View File
@@ -3,7 +3,7 @@
def register_routes(app): def register_routes(app):
import config import config
from routes.upload_bp import upload_bp, contracts_upload_sink from routes.upload_bp import contracts_upload_sink
from routes.pipeline_bp import pipeline_bp from routes.pipeline_bp import pipeline_bp
from routes.api_bp import api_bp from routes.api_bp import api_bp
from routes.prompts_bp import prompts_bp from routes.prompts_bp import prompts_bp
@@ -11,7 +11,6 @@ def register_routes(app):
from routes.pages_bp import pages_bp from routes.pages_bp import pages_bp
from upload.backend.upload_refs import create_upload_refs_blueprint from upload.backend.upload_refs import create_upload_refs_blueprint
app.register_blueprint(upload_bp)
app.register_blueprint(pipeline_bp) app.register_blueprint(pipeline_bp)
app.register_blueprint(api_bp) app.register_blueprint(api_bp)
app.register_blueprint(prompts_bp) app.register_blueprint(prompts_bp)
+4 -80
View File
@@ -1,54 +1,12 @@
"""Upload blueprint — загрузка, конвертация, распаковка.""" """Upload-модуль: конвертация .doc→.docx + хранение/парсинг (sink для VM-буфера)."""
import io, os, base64, hashlib, zipfile import base64
import hashlib
import httpx import httpx
from flask import Blueprint, request, jsonify
from services.parse import parse_file from services.parse import parse_file
from db import documents from db import documents
import config import config
upload_bp = Blueprint("upload", __name__)
ALLOWED = {"pdf", "docx", "doc", "zip"}
def _check_ext(filename: str) -> str | None:
ext = filename.rsplit(".", 1)[-1].lower() if "." in filename else ""
if ext not in ALLOWED:
return f"unsupported format: .{ext} (allowed: {', '.join(sorted(ALLOWED))})"
return None
def _unzip(data: bytes):
"""Распаковать ZIP → (ok, files, error)."""
MAX_FILES = 500
MAX_UNCOMPRESSED = 500 * 1024 * 1024 # 500 MB
files = []
total = 0
try:
with zipfile.ZipFile(io.BytesIO(data)) as zf:
if len(zf.namelist()) > MAX_FILES:
return False, None, f"too many files in ZIP (max {MAX_FILES})"
for info in zf.infolist():
if info.is_dir():
continue
name = os.path.basename(info.filename)
if not name or ".." in name or "/" in name or "\\" in name:
continue
raw = zf.read(info)
total += len(raw)
if total > MAX_UNCOMPRESSED:
return False, None, "total uncompressed size exceeds 500 MB"
ext = name.rsplit(".", 1)[-1].lower() if "." in name else ""
files.append({
"filename": name,
"ext": ext,
"size": len(raw),
"data_b64": base64.b64encode(raw).decode(),
})
except zipfile.BadZipFile:
return False, None, "invalid ZIP archive"
return True, files, None
def _convert(filename: str, data: bytes) -> bytes: def _convert(filename: str, data: bytes) -> bytes:
""".doc → .docx через внешний libreoffice-сервис. Возвращает docx-байты.""" """.doc → .docx через внешний libreoffice-сервис. Возвращает docx-байты."""
@@ -104,28 +62,6 @@ def _store_and_parse(filename: str, data: bytes, batch_id, contract_id, zip_sour
return {"ok": True, "doc_id": doc["id"], "contract_id": contract_id, "parsed": parsed} return {"ok": True, "doc_id": doc["id"], "contract_id": contract_id, "parsed": parsed}
@upload_bp.route("/upload", methods=["POST"])
def upload():
"""Загрузка одного файла + авто-парсинг → БД (прямой multipart)."""
f = request.files.get("files")
if not f:
return jsonify(ok=False, error="no file"), 400
err = _check_ext(f.filename)
if err:
return jsonify(ok=False, error=err), 400
data = f.read()
batch_id = request.form.get("batch_id")
zip_source = request.form.get("zip_source")
contract_id = request.form.get("contract_id")
result = _store_and_parse(f.filename, data, batch_id, contract_id, zip_source, f.content_type or "application/octet-stream")
if not result["ok"]:
return jsonify(ok=result["ok"], error=result.get("error"), doc_id=result.get("doc_id")), 200
return jsonify(ok=True, doc_id=result["doc_id"], contract_id=result["contract_id"], parsed=result["parsed"])
def contracts_upload_sink(name, content, batch_id=None, contract_id=None, zip_source=None): def contracts_upload_sink(name, content, batch_id=None, contract_id=None, zip_source=None):
"""Sink для переиспользуемого модуля upload: вставить файл в documents + авто-парсинг. """Sink для переиспользуемого модуля upload: вставить файл в documents + авто-парсинг.
@@ -137,15 +73,3 @@ def contracts_upload_sink(name, content, batch_id=None, contract_id=None, zip_so
content = _convert(name, content) content = _convert(name, content)
name = name[:-4] + ".docx" name = name[:-4] + ".docx"
return _store_and_parse(name, content, batch_id, contract_id, zip_source) return _store_and_parse(name, content, batch_id, contract_id, zip_source)
@upload_bp.route("/unzip-upload", methods=["POST"])
def unzip_upload():
"""Распаковать ZIP → список файлов (base64 для фронтенда, прямой multipart)."""
f = request.files.get("files")
if not f:
return jsonify(ok=False, error="no file"), 400
ok, files, err = _unzip(f.read())
if not ok:
return jsonify(ok=False, error=err), 400
return jsonify(ok=True, files=files)
+6 -2
View File
@@ -47,9 +47,13 @@ _GARBAGE_HEADER_MARKERS = [
'СЧЕТ-ФАКТУРА', 'СЧЕТ НА ОПЛАТУ', 'АКТ СВЕРКИ', 'СЧЕТ-ФАКТУРА', 'СЧЕТ НА ОПЛАТУ', 'АКТ СВЕРКИ',
'АКТ ОКАЗАННЫХ УСЛУГ', 'АКТ ВЫПОЛНЕННЫХ РАБОТ', 'АКТ ОКАЗАННЫХ УСЛУГ', 'АКТ ВЫПОЛНЕННЫХ РАБОТ',
'ПЛАТЁЖНОЕ ПОРУЧЕНИЕ', 'УНИВЕРСАЛЬНЫЙ ПЕРЕДАТОЧНЫЙ', 'ПЛАТЁЖНОЕ ПОРУЧЕНИЕ', 'УНИВЕРСАЛЬНЫЙ ПЕРЕДАТОЧНЫЙ',
'УПД', 'ПЛАТЕЖНОЕ ПОРУЧЕНИЕ', 'ПЛАТЕЖНОЕ ПОРУЧЕНИЕ',
] ]
_GARBAGE_HEADER_RE = re.compile(
r'(?m)^\s*(?:УПД|УНИВЕРСАЛЬНЫЙ ПЕРЕДАТОЧНЫЙ ДОКУМЕНТ)\b'
)
def _is_garbage_by_filename(filename: str) -> bool: def _is_garbage_by_filename(filename: str) -> bool:
"""Stage 1: regex по имени файла — быстро, 0 токенов.""" """Stage 1: regex по имени файла — быстро, 0 токенов."""
@@ -59,7 +63,7 @@ def _is_garbage_by_filename(filename: str) -> bool:
def _is_garbage_by_header(text: str) -> bool: def _is_garbage_by_header(text: str) -> bool:
"""Stage 2: ключевые слова в первых 2KB текста — быстро, 0 токенов.""" """Stage 2: ключевые слова в первых 2KB текста — быстро, 0 токенов."""
header = text[:2000].upper() header = text[:2000].upper()
return any(marker in header for marker in _GARBAGE_HEADER_MARKERS) return any(marker in header for marker in _GARBAGE_HEADER_MARKERS) or bool(_GARBAGE_HEADER_RE.search(header))
def _call_llm_classify(header_text, llm_client=None): def _call_llm_classify(header_text, llm_client=None):
+12 -2
View File
@@ -77,6 +77,15 @@ def run_pipeline(contract_id, order_ids, build_prompt_fn):
ops = result.get("ops", []) ops = result.get("ops", [])
mode = result.get("mode", "llm") mode = result.get("mode", "llm")
if mode == "full_replace" and not ops:
yield {
"type": "extract_error",
"supplement_id": sid,
"filename": filename,
"error": "full_replace returned empty ops",
}
continue
# Трансляция target_id ("r1","r2"...) → target_hash (name_hash из current_spec). # Трансляция target_id ("r1","r2"...) → target_hash (name_hash из current_spec).
# LLM возвращает target_id, а apply_ops() читает target_hash — без этого UPDATE/DELETE уходят в UNRESOLVED. # LLM возвращает target_id, а apply_ops() читает target_hash — без этого UPDATE/DELETE уходят в UNRESOLVED.
for _op in ops: for _op in ops:
@@ -120,7 +129,7 @@ def run_pipeline(contract_id, order_ids, build_prompt_fn):
} }
# Arithmetic check # Arithmetic check
check_arithmetic(ops) arithmetic_warnings = check_arithmetic(ops)
yield { yield {
"type": "applied", "type": "applied",
@@ -128,6 +137,7 @@ def run_pipeline(contract_id, order_ids, build_prompt_fn):
"filename": filename, "filename": filename,
"summary": summary, "summary": summary,
"ops": applied_ops, "ops": applied_ops,
"arithmetic_warnings": arithmetic_warnings,
} }
except Exception as e: except Exception as e:
@@ -139,7 +149,7 @@ def run_pipeline(contract_id, order_ids, build_prompt_fn):
} }
total_time = round(time.time() - t0, 1) total_time = round(time.time() - t0, 1)
yield {"type": "complete", "total_time_s": total_time} yield {"type": "done", "total_time_s": total_time}
def _elements_to_text(ej): def _elements_to_text(ej):
+3 -4
View File
@@ -1,9 +1,6 @@
// ⛔ НЕ МЕНЯТЬ БЕЗ РАЗРЕШЕНИЯ НАЕЛЯ ⛔ // ⛔ НЕ МЕНЯТЬ БЕЗ РАЗРЕШЕНИЯ НАЕЛЯ ⛔
// Contracts App v2.0 — всё на Flask, ВМ больше нет // Contracts App v2.0 — всё на Flask, ВМ больше нет
var VM_API = ''; var VM_API = '';
var UPLOAD_URL = '/upload';
var CONVERT_URL = '/convert-doc';
var UNZIP_URL = '/unzip-upload';
// ВМ-буфер загрузки (паттерн drhider): браузер кладёт файл сюда (WebDAV, мимо шлюза), // ВМ-буфер загрузки (паттерн drhider): браузер кладёт файл сюда (WebDAV, мимо шлюза),
// бэк сам тянет его по /api/upload_refs. Origin должен быть в CORS на nginx ВМ. // бэк сам тянет его по /api/upload_refs. Origin должен быть в CORS на nginx ВМ.
var VM_UPLOAD_URL = 'https://contracts.kube5s.ru/contracts-upload/'; var VM_UPLOAD_URL = 'https://contracts.kube5s.ru/contracts-upload/';
@@ -166,7 +163,9 @@ async function runClassify() {
var r = await fetch(VM_API + '/api/batch-progress?batch=' + state.batchId); var r = await fetch(VM_API + '/api/batch-progress?batch=' + state.batchId);
var d = await r.json(); var d = await r.json();
if (d.ok && d.counts) { if (d.ok && d.counts) {
var done = (d.counts.classified || 0) + (d.counts.failed || 0); var done = (d.counts.classified || 0)
+ (d.counts.failed || 0)
+ (d.counts.garbage || 0);
var total = d.total || 0; var total = d.total || 0;
if (total > 0) totalFiles = total; if (total > 0) totalFiles = total;
btn.innerHTML = '<span class="spinner"></span> Классификация... ' + done + '/' + total + ' (' + Math.round((Date.now() - start) / 1000) + 'с)'; btn.innerHTML = '<span class="spinner"></span> Классификация... ' + done + '/' + total + ' (' + Math.round((Date.now() - start) / 1000) + 'с)';
+1 -1
View File
@@ -421,7 +421,7 @@ async function onFilesSelected(newFiles) {
var resp = await uploadFile(f, function(st) { var resp = await uploadFile(f, function(st) {
entry.status = st; entry.status = st;
render(state); render(state);
}); }, entry.zip_source);
if (resp && resp.contract_id) state.contractId = resp.contract_id; if (resp && resp.contract_id) state.contractId = resp.contract_id;
entry.doc_id = resp.doc_id; entry.doc_id = resp.doc_id;
entry.status = { kind: 'uploaded' }; entry.status = { kind: 'uploaded' };
+7 -7
View File
@@ -73,7 +73,7 @@
<body> <body>
<div class="topbar"> <div class="topbar">
<img src="/static/logo.svg" alt="Nubes"> <img src="/static/logo.svg" alt="Nubes">
<span class="title">Сверка договоров — LLM AI-driven Event Sourcing <span style="font-weight:400;color:var(--muted);font-size:12px;">v2.0.15</span></span> <span class="title">Сверка договоров — LLM AI-driven Event Sourcing <span style="font-weight:400;color:var(--muted);font-size:12px;">v2.0.21</span></span>
<div id="pipelineStepper" style="display:flex;gap:8px;font-size:11px;align-items:center;color:var(--muted);"> <div id="pipelineStepper" style="display:flex;gap:8px;font-size:11px;align-items:center;color:var(--muted);">
<span id="stepUpload">○ Загрузка</span><span>→</span> <span id="stepUpload">○ Загрузка</span><span>→</span>
<span id="stepClassify">○ Классификация</span><span>→</span> <span id="stepClassify">○ Классификация</span><span>→</span>
@@ -224,11 +224,11 @@
import { listZipFiles } from '/upload/zip/list_zip_files.js'; import { listZipFiles } from '/upload/zip/list_zip_files.js';
window.listZipFiles = listZipFiles; window.listZipFiles = listZipFiles;
</script> </script>
<script src="/static/state.js?v=2.0.8"></script> <script src="/static/state.js?v=2.0.21"></script>
<script src="/static/app_utils.js?v=2.0.8"></script> <script src="/static/app_utils.js?v=2.0.21"></script>
<script src="/static/files.js?v=2.0.14"></script> <script src="/static/files.js?v=2.0.21"></script>
<script src="/static/groups.js?v=2.0.8"></script> <script src="/static/groups.js?v=2.0.21"></script>
<script src="/static/compare.js?v=2.0.15"></script> <script src="/static/compare.js?v=2.0.21"></script>
<script src="/static/app.js?v=2.0.13"></script> <script src="/static/app.js?v=2.0.21"></script>
</body> </body>
</html> </html>
+75
View File
@@ -0,0 +1,75 @@
# Тесты contracts-flask
Набор pytest-тестов под актуальный код сервиса (`site/`).
Покрытие: юнит (чистая логика), интеграционные (SQLite), HTTP-эндпоинты (Flask test client), безопасность.
## Запуск
```bash
cd contracts-flask
pytest tests/ -q
```
pytest установлен в venv: `/home/naeel/nubes/contracts/.venv/bin/python -m pytest tests/ -q`.
## Файлы
| Файл | Что тестирует |
|---|---|
| `conftest.py` | site/ в `sys.path`; фикстура `db` — изолированная БД (временный `DB_PATH` + свежая схема); защита реальной `/tmp/contracts.db` от пересоздания |
| `test_metrics.py` | `services/metrics.py`: `normalize_date`, `check_arithmetic` (sum == price×qty), `_to_decimal`, `ClassifyMetrics` |
| `test_grouping.py` | `services/grouping.py`: `normalize_number`, `group_documents` (группировка contract+supplement, unresolved), `apply_groups` (mock db) |
| `test_llm_client.py` | `services/llm_client.py`: `FakeLLMClient` (точное/частичное совпадение, default, история вызовов), `HttpxLLMClient` |
| `test_llm.py` | `services/llm.py`: `call_llm` — парсинг plain JSON и markdown-обёрток (```json) |
| `test_classify.py` | `services/classify.py`: garbage-фильтры по имени/заголовку, `_safe_json_parse` (7 edge-case: trailing comma, chatter, пусто), `_smart_extract` |
| `test_parse.py` | `services/parse.py`: `parse_file` (text/docx/pdf, ошибки), `_parse_text` |
| `test_connection.py` | `db/connection.py`: `_pg_to_sqlite` (все замены %s/::jsonb/BOOLEAN/ILIKE/TRUE), `_extract_table` |
| `test_spec_events.py` | `db/spec_events.py`: `apply_ops` (ADD/UPDATE/DELETE/UNRESOLVED/unknown), `clear_current`, `reset`, `_hash`, `get_next_seq` |
| `test_spec_current.py` | `db/spec_current.py`: `list_by_contract` (порядок), `get_elements_json` |
| `test_process_pipeline.py` | `services/process.py`: `run_pipeline` с Fake LLM — **full_replace не дублирует строки**, **трансляция target_id→target_hash**, `_elements_to_text` |
| `test_routes.py` | HTTP-эндпоинты (Flask test client): `/health`, `/api/spec-current`, `/api/groups` |
## Ключевые проверки
- `test_full_replace_no_duplicates` — два `full_replace` подряд → в `spec_current` 3 строки, а не 6; история `spec_events` (6 событий) сохранена.
- `test_update_applies` — `target_id: "r1"` транслируется в `target_hash` → UPDATE применяется (status `applied`), а не уходит в UNRESOLVED.
## Изоляция
- Каждый тест, работающий с БД, получает свою копию SQLite через фикстуру `db`
(monkeypatch `DB_PATH` → `tmp_path`, `init_db()`).
- LLM-вызовы в пайплайне подменяются через `monkeypatch.setattr("services.llm.call_llm", ...)` — сеть не используется.
## Нагрузочные тесты (`tests/load/`, маркер `load`)
Долгие стресс-тесты, реально нагружают SQLite/пайплайн. Запуск отдельно:
```bash
pytest -m load -q # только нагрузочные
pytest -m "not load" -q # быстрые юнит-тесты без нагрузочных
```
| Файл | Что нагружает |
|---|---|
| `load/test_load_pipeline.py` | массовый `run_pipeline`: N контрактов × M допников (Fake LLM) — проверка, что `full_replace` не дублирует строки в масштабе |
| `load/test_load_apply_ops.py` | массовые `apply_ops`: N ADD → N UPDATE → N DELETE, целостность `spec_events` |
| `load/test_load_concurrency.py` | конкурентная запись в SQLite (WAL + `busy_timeout`), проверка отсутствия потери данных |
| `load/stress_http.py` | standalone HTTP-стресс: `PUT` на ВМ → `POST /api/upload_refs` (реальный VM-путь) + `/health` |
Масштаб через переменные окружения (дефолты — большие):
```bash
LOAD_CONTRACTS=100 LOAD_SUPPS=20 LOAD_SERVICES=10 \
LOAD_OPS=5000 LOAD_THREADS=4 LOAD_PER=200 \
pytest -m load -q
Примечание по конкурентной записи: стабильный уровень `LOAD_THREADS=4`
(= `MAX_WORKERS` приложения). `>= 8` — нестабильно: SQLite с
`busy_timeout=5000` даёт `database is locked` (см. `History/2026-08-26-load-sqlite-locked.md`).
```
HTTP-стресс против локального/прод сервиса:
```bash
python tests/load/stress_http.py --url http://127.0.0.1:5000 --n 1000 --threads 32 --size 100000
```
+42
View File
@@ -0,0 +1,42 @@
"""Общие фикстуры для тестов contracts-flask (модули site/).
Запуск: pytest tests/ -q
"""
import os
import sys
import pytest
# site/ в sys.path — импортируем config/db/services/routes напрямую
_SITE = os.path.join(os.path.dirname(__file__), "..", "site")
if _SITE not in sys.path:
sys.path.insert(0, _SITE)
@pytest.fixture(autouse=True, scope="session")
def _isolate_global_db(tmp_path_factory):
"""Не дать импорту app.py пересоздать реальную /tmp/contracts.db."""
from db import connection as conn
conn.DB_PATH = str(tmp_path_factory.mktemp("dbs") / "session.db")
@pytest.fixture
def db(tmp_path, monkeypatch):
"""Изолированная БД: отдельный DB_PATH + свежая схема (init_db)."""
from db import connection as conn
monkeypatch.setattr(conn, "DB_PATH", str(tmp_path / "contracts_test.db"))
conn.init_db()
yield conn
# cleanup: закрыть thread-local соединение
c = getattr(conn._local, "conn", None)
if c is not None:
try:
c.close()
except Exception:
pass
conn._local.conn = None
def pytest_configure(config):
config.addinivalue_line("markers", "load: долгие нагрузочные/стресс-тесты")
+10
View File
@@ -0,0 +1,10 @@
"""Нагрузочные/стресс-тесты contracts-flask.
Запуск только нагрузочных:
pytest -m load -q
Запуск быстрых (юнит) без нагрузочных:
pytest -m "not load" -q
Масштаб настраивается переменными окружения (дефолты — большие):
LOAD_CONTRACTS, LOAD_SUPPS, LOAD_SERVICES, LOAD_OPS, LOAD_THREADS, LOAD_PER
"""
+97
View File
@@ -0,0 +1,97 @@
"""HTTP-стресс против contracts-flask (standalone, не pytest).
Реальный путь загрузки (VM-буфер):
1) PUT файла на ВМ WebDAV (мимо шлюза кластера ~64KB);
2) POST /api/upload_refs — бэк сам тянет файл с ВМ (egress).
Замеряет RPS, ошибки, таймауты. Цель — «разогреть» сервис и выявить
деградацию/обрывы (шлюз, OOM, SQLite).
Запуск (локально):
python tests/load/stress_http.py --url http://127.0.0.1:5000 --n 1000 --threads 32 --size 100000
Пример против прода (осторожно):
python tests/load/stress_http.py --url https://contractor.pythonk8s.dev.nubes.ru \
--file "/mnt/y/MY/Nubes/примеры_договоров_для_ИИ/спецификация-XXX001-03700.docx" \
--n 200 --threads 16
"""
import argparse
import time
import uuid
from concurrent.futures import ThreadPoolExecutor
import httpx
def main():
ap = argparse.ArgumentParser(description="HTTP-стресс contracts-flask")
ap.add_argument("--url", default="http://127.0.0.1:5000")
ap.add_argument("--vm-url", default="https://contracts.kube5s.ru/contracts-upload/", help="ВМ WebDAV-буфер (PUT файла)")
ap.add_argument("--n", type=int, default=1000, help="число запросов")
ap.add_argument("--threads", type=int, default=32, help="параллельных потоков")
ap.add_argument("--size", type=int, default=100000, help="размер файла в байтах (если не задан --file)")
ap.add_argument("--file", default=None, help="путь к реальному файлу (заменяет сгенерированный)")
ap.add_argument("--health-only", action="store_true", help="только /health, без загрузки файлов")
args = ap.parse_args()
if args.file:
import os as _os
fname = _os.path.basename(args.file)
with open(args.file, "rb") as _f:
payload = _f.read()
real_size = len(payload)
else:
fname = "load.docx"
payload = b"x" * args.size
real_size = args.size
ok = 0
err = 0
slow = 0
t0 = time.time()
def one(_):
nonlocal ok, err, slow
try:
with httpx.Client(timeout=30) as c:
if args.health_only:
r = c.get(f"{args.url}/health")
if r.status_code == 200:
ok += 1
else:
err += 1
return
# Фаза 1: PUT файла на ВМ WebDAV (мимо шлюза ~64KB)
token = uuid.uuid4().hex
vm_url = args.vm_url.rstrip("/") + "/" + token + "_0"
r1 = c.put(vm_url, content=payload, headers={"Content-Type": "application/octet-stream"})
if not r1.is_success:
err += 1
return
# Фаза 2: POST /api/upload_refs — бэк тянет файл с ВМ (egress)
body = {
"batch_id": token,
"files": [{"name": fname, "size": real_size, "url": vm_url}],
}
r2 = c.post(f"{args.url}/api/upload_refs", json=body)
if r2.status_code == 200:
ok += 1
else:
err += 1
except httpx.TimeoutException:
err += 1
slow += 1
except Exception:
err += 1
with ThreadPoolExecutor(max_workers=args.threads) as ex:
list(ex.map(one, range(args.n)))
elapsed = time.time() - t0
print(f"url={args.url}")
print(f"vm_url={args.vm_url}")
print(f"n={args.n} threads={args.threads} size={real_size}B file={args.file or '(gen)'} health_only={args.health_only}")
print(f"ok={ok} err={err} timeouts={slow} elapsed={elapsed:.1f}s rps={args.n / elapsed:.1f}")
if __name__ == "__main__":
main()
+52
View File
@@ -0,0 +1,52 @@
"""Нагрузочный тест: массовые apply_ops (ADD → UPDATE → DELETE).
Прогоняет N операций каждого типа одним вызовом apply_ops, замеряет время,
проверяет целостность spec_current/spec_events (ничего не потеряно, не продублировано).
"""
import os
import time
import pytest
from db import spec_events, spec_current
from db.connection import query
pytestmark = pytest.mark.load
N = int(os.getenv("LOAD_OPS", "5000"))
CID = "load-cid-111"
SID = "load-sid-111"
DID = "load-did-111"
def test_mass_apply_ops(db):
# ADD N строк
ops = [{"action": "ADD", "new_row": {"name": f"услуга {i}", "price": i, "qty": 1, "sum": i}} for i in range(N)]
t0 = time.time()
s = spec_events.apply_ops(CID, SID, DID, ops, "pid", {})
t_add = time.time() - t0
assert s["added"] == N
assert len(spec_current.list_by_contract(CID)) == N
# UPDATE всех N строк
hashes = [spec_events._hash(f"услуга {i}") for i in range(N)]
ups = [{"action": "UPDATE", "target_hash": h, "new_values": {"price": i + 1}} for i, h in enumerate(hashes)]
t0 = time.time()
s = spec_events.apply_ops(CID, SID, DID, ups, "pid", {})
t_upd = time.time() - t0
assert s["updated"] == N
assert spec_current.list_by_contract(CID)[0]["price"] == 1 # первая строка обновлена
# DELETE всех N строк
dels = [{"action": "DELETE", "target_hash": h} for h in hashes]
t0 = time.time()
s = spec_events.apply_ops(CID, SID, DID, dels, "pid", {})
t_del = time.time() - t0
assert s["deleted"] == N
assert spec_current.list_by_contract(CID) == []
total = query("SELECT count(*) AS c FROM spec_events WHERE contract_id = %s", (CID,))
assert total[0]["c"] == N * 3 # ADD + UPDATE + DELETE, ничего не потеряно
print(f"\n[load] ops={N} add={t_add:.1f}с upd={t_upd:.1f}с del={t_del:.1f}с")
+37
View File
@@ -0,0 +1,37 @@
"""Нагрузочный тест: конкурентная запись в SQLite (WAL + busy_timeout).
N_THREADS потоков пишут по PER ADD-операций каждый. Проверяет, что конкурентная
запись не теряет данные (WAL сериализует writers, busy_timeout ждёт).
"""
import os
import pytest
from concurrent.futures import ThreadPoolExecutor
from db import spec_events, spec_current
pytestmark = pytest.mark.load
N_THREADS = int(os.getenv("LOAD_THREADS", "4"))
PER = int(os.getenv("LOAD_PER", "200"))
def test_concurrent_writes(db):
def writer(tid):
cid = f"c{tid}"
for i in range(PER):
spec_events.apply_ops(
cid, f"s{tid}", f"d{tid}",
[{"action": "ADD", "new_row": {"name": f"svc{i}", "price": i}}],
"pid", {},
)
with ThreadPoolExecutor(max_workers=N_THREADS) as ex:
list(ex.map(writer, range(N_THREADS)))
# Никаких потерь: у каждого потока ровно PER строк
for tid in range(N_THREADS):
n = len(spec_current.list_by_contract(f"c{tid}"))
assert n == PER, f"c{tid}: {n} != {PER} (потеря данных при конкурентной записи)"
print(f"\n[load] потоков={N_THREADS} строк/поток={PER} всего={N_THREADS * PER}")
+67
View File
@@ -0,0 +1,67 @@
"""Нагрузочный тест: массовый прогон run_pipeline (сотни контрактов × допников).
Создаёт N контрактов × M допников, прогоняет полный конвейер сверки с Fake LLM
(без сети), реально нагружает SQLite: apply_ops + clear_current + reset.
Проверка: full_replace НЕ дублирует строки в масштабе (у каждого контракта ровно
SERVICES строк, а не SERVICES × M_SUPPS).
"""
import os
import time
import pytest
from db import contracts, documents, supplements, spec_current
from db.connection import query
from services import process
pytestmark = pytest.mark.load
N_CONTRACTS = int(os.getenv("LOAD_CONTRACTS", "100"))
M_SUPPS = int(os.getenv("LOAD_SUPPS", "20"))
SERVICES = int(os.getenv("LOAD_SERVICES", "10"))
def _seed():
cids = []
for ci in range(N_CONTRACTS):
c = contracts.insert(f"03700_{ci}")
for mi in range(M_SUPPS):
d = documents.insert(f"d{ci}_{mi}.docx", "application/octet-stream", "raw", batch_id=f"b{ci}")
documents.set_parsed(d["id"], [{"type": "paragraph", "text": f"услуга {mi}"}])
supplements.insert(c["id"], d["id"], "initial" if mi == 0 else "additional")
cids.append(c["id"])
return cids
def _fake_llm():
def fake_call(current_spec, doc_text, build_prompt_fn, llm_client=None):
ops = [
{"action": "ADD", "new_row": {"name": f"услуга {i}", "price": 100 + i, "qty": 1, "sum": 100 + i}}
for i in range(SERVICES)
]
return ({"mode": "full_replace", "ops": ops}, "pid")
return fake_call
def test_mass_pipeline(db, monkeypatch):
cids = _seed()
monkeypatch.setattr("services.llm.call_llm", _fake_llm())
t0 = time.time()
for cid in cids:
list(process.run_pipeline(cid, "", lambda cur, txt: ("p", "pid")))
elapsed = time.time() - t0
# Целостность: у каждого контракта ровно SERVICES строк (full_replace без дублей)
bad = 0
for cid in cids:
if len(spec_current.list_by_contract(cid)) != SERVICES:
bad += 1
assert bad == 0, f"{bad} контрактов с неверным числом строк"
total_events = query("SELECT count(*) AS c FROM spec_events", ())
print(
f"\n[load] контрактов={N_CONTRACTS} допников={M_SUPPS} услуг={SERVICES} "
f"событий={total_events[0]['c']} время={elapsed:.1f}с"
)
+99
View File
@@ -0,0 +1,99 @@
"""Unit-тесты services/classify.py — фильтры + JSON-парсинг + выжимка."""
import json
import pytest
from services.classify import (
_is_garbage_by_filename,
_is_garbage_by_header,
_safe_json_parse,
_smart_extract,
)
class TestGarbageFilename:
def test_invoice(self):
assert _is_garbage_by_filename("счет-фактура №123.docx") is True
def test_act(self):
assert _is_garbage_by_filename("Акт сверки.pdf") is True
def test_upd(self):
assert _is_garbage_by_filename("УПД-2025.docx") is True
def test_not_garbage(self):
assert _is_garbage_by_filename("договор.docx") is False
assert _is_garbage_by_filename("спецификация.pdf") is False
class TestGarbageHeader:
def test_invoice_header(self):
assert _is_garbage_by_header("СЧЕТ-ФАКТУРА № 123 от 01.01.2026") is True
def test_act_header(self):
assert _is_garbage_by_header("АКТ ОКАЗАННЫХ УСЛУГ за май") is True
def test_not_garbage(self):
assert _is_garbage_by_header("ДОГОВОР № 03700_1 об оказании услуг") is False
def test_upd_mention_in_contract_is_not_garbage(self):
text = "Договор № 03700. Оплата производится на основании УПД и счета."
assert _is_garbage_by_header(text) is False
def test_upd_document_header_is_garbage(self):
assert _is_garbage_by_header("УПД № 123 от 01.01.2026") is True
class TestSafeJsonParse:
def test_valid(self):
d, fix = _safe_json_parse('{"a":1}')
assert d == {"a": 1}
assert fix is False
def test_markdown(self):
d, fix = _safe_json_parse('```json\n{"a":1}\n```')
assert d == {"a": 1}
assert fix is False
def test_chatter(self):
d, _ = _safe_json_parse('Вот ответ: {"a":1}. Спасибо!')
assert d == {"a": 1}
def test_trailing_comma(self):
d, fix = _safe_json_parse('{"a":1,}')
assert d == {"a": 1}
assert fix is True
def test_trailing_comma_in_list(self):
d, fix = _safe_json_parse('{"a":[1,2,]}')
assert d == {"a": [1, 2]}
assert fix is True
def test_empty_raises(self):
with pytest.raises(ValueError):
_safe_json_parse("")
def test_none_raises(self):
with pytest.raises(ValueError):
_safe_json_parse(None)
class TestSmartExtract:
def test_paragraphs(self):
ej = [{"type": "paragraph", "text": "Договор № 03700"}]
out = _smart_extract(ej)
assert "Договор" in out
def test_table(self):
ej = [{"type": "table", "rows": [["Аренда", "50000"]]}]
out = _smart_extract(ej)
assert "Аренда" in out
def test_string_json(self):
ej = json.dumps([{"type": "paragraph", "text": "Спецификация"}])
out = _smart_extract(ej)
assert "Спецификация" in out
def test_empty(self):
assert _smart_extract(None) == ""
assert _smart_extract("") == ""
+36
View File
@@ -0,0 +1,36 @@
"""Unit-тесты db/connection.py — SQL-конвертация (без БД)."""
from db.connection import _pg_to_sqlite, _extract_table
class TestPgToSqlite:
def test_placeholder(self):
assert _pg_to_sqlite("SELECT * FROM x WHERE a = %s") == "SELECT * FROM x WHERE a = ?"
def test_jsonb(self):
out = _pg_to_sqlite("UPDATE t SET j = %s::jsonb WHERE id = %s")
assert "::jsonb" not in out
def test_boolean(self):
# " BOOLEAN " заменяется только когда после слова идёт пробел
out = _pg_to_sqlite("CREATE TABLE t (flag BOOLEAN NOT NULL)")
assert "BOOLEAN" not in out
assert "INTEGER" in out
def test_ilike(self):
assert _pg_to_sqlite("WHERE name ILIKE 'x'") == "WHERE name LIKE 'x'"
def test_true_false(self):
out = _pg_to_sqlite("SET a = TRUE, b = FALSE")
assert "TRUE" not in out
assert "FALSE" not in out
class TestExtractTable:
def test_insert(self):
assert _extract_table("INSERT INTO documents (id) VALUES (1)") == "documents"
def test_insert_no_space(self):
assert _extract_table("INSERT INTO prompts(id) VALUES (1)") == "prompts"
def test_no_insert(self):
assert _extract_table("SELECT * FROM documents") is None
+83
View File
@@ -0,0 +1,83 @@
"""Unit-тесты services/grouping.py — нормализация + группировка (mock db)."""
from services import grouping
class TestNormalizeNumber:
def test_basic(self):
assert grouping.normalize_number("МЭС-123-2024") == "МЭС1232024"
def test_spaces_slashes(self):
assert grouping.normalize_number("МЭС 123/2024") == "МЭС1232024"
def test_case(self):
assert grouping.normalize_number("мэс-123") == "МЭС123"
def test_empty(self):
assert grouping.normalize_number("") == ""
assert grouping.normalize_number(None) == ""
class _FakeDocs:
def __init__(self, docs):
self.docs = docs
def list_by_batch(self, batch_id):
return self.docs
class TestGroupDocuments:
def _doc(self, id, doc_type, own, parent, date, cp):
return {
"id": id, "filename": f"{id}.docx", "doc_type": doc_type,
"own_number": own, "parent_number": parent, "doc_date": date,
"counterparty": cp, "classify_status": "classified",
}
def test_contract_plus_supplement(self, monkeypatch):
docs = [
self._doc("d1", "contract", "03700_1", None, "2025-01-01", "ЗАО X"),
self._doc("d2", "supplement", "1", "03700_1", "2025-02-01", "ЗАО X"),
]
monkeypatch.setattr(grouping, "db_docs", _FakeDocs(docs))
r = grouping.group_documents("b1")
assert r["ok"] is True
assert r["total_docs"] == 2
groups = [g for g in r["groups"] if g["contract_number"] != "__unresolved__"]
assert len(groups) == 1
assert groups[0]["contract_number"] == "03700_1"
assert len(groups[0]["documents"]) == 2
def test_unmatched_goes_unresolved(self, monkeypatch):
docs = [self._doc("d1", "other", None, None, None, "")]
monkeypatch.setattr(grouping, "db_docs", _FakeDocs(docs))
r = grouping.group_documents("b1")
unresolved = [g for g in r["groups"] if g["contract_number"] == "__unresolved__"]
assert len(unresolved) == 1
class TestApplyGroups:
def test_apply(self, monkeypatch):
created = []
class FakeContracts:
def insert(self, number, client=""):
return {"id": f"c_{number}"}
class FakeSupps:
def insert(self, cid, did, stype):
created.append((cid, did, stype))
return {"id": "s"}
monkeypatch.setattr(grouping, "db_contracts", FakeContracts())
monkeypatch.setattr(grouping, "db_supplements", FakeSupps())
groups = [
{"contract_number": "03700_1", "counterparty": "X",
"documents": [{"id": "d1"}, {"id": "d2"}]},
{"contract_number": "__unresolved__", "counterparty": "",
"documents": [{"id": "d3"}]},
]
r = grouping.apply_groups("b1", groups)
assert r["ok"] is True
assert r["created"] == 2
assert created == [("c_03700_1", "d1", "initial"), ("c_03700_1", "d2", "additional")]
+25
View File
@@ -0,0 +1,25 @@
"""Unit-тесты services/llm.py — call_llm с FakeLLMClient."""
from services.llm import call_llm
from services.llm_client import FakeLLMClient
def _build(cur, txt):
return (f"prompt:{txt}", "pid-1")
class TestCallLLM:
def test_plain_json(self):
llm = FakeLLMClient({"default": '{"mode":"partial","ops":[]}'})
res, pid = call_llm([], "текст", _build, llm_client=llm)
assert pid == "pid-1"
assert res == {"mode": "partial", "ops": []}
def test_markdown_json(self):
llm = FakeLLMClient({"default": '```json\n{"mode":"full_replace","ops":[]}\n```'})
res, _ = call_llm([], "текст", _build, llm_client=llm)
assert res["mode"] == "full_replace"
def test_markdown_generic(self):
llm = FakeLLMClient({"default": '```\n{"a":1}\n```'})
res, _ = call_llm([], "текст", _build, llm_client=llm)
assert res == {"a": 1}
+40
View File
@@ -0,0 +1,40 @@
"""Unit-тесты services/llm_client.py."""
from services.llm_client import FakeLLMClient, HttpxLLMClient
class TestFakeLLMClient:
def test_exact_match(self):
c = FakeLLMClient({"hello": "world"})
assert c.complete("hello") == "world"
def test_partial_match(self):
c = FakeLLMClient({"needle": "found"})
assert c.complete("a needle in haystack") == "found"
def test_default_fallback(self):
c = FakeLLMClient({"default": "fallback"})
assert c.complete("whatever") == "fallback"
def test_calls_recorded(self):
c = FakeLLMClient()
c.complete("x")
c.complete("y")
assert c.calls == ["x", "y"]
def test_add(self):
c = FakeLLMClient()
c.add("k", "v")
assert c.complete("k") == "v"
class TestHttpxLLMClient:
def test_init_defaults(self):
c = HttpxLLMClient(url="http://x", key="k")
assert c.model == "gpt-oss-120b"
assert c.timeout == 120
assert c.max_tokens == 8000
def test_init_custom(self):
c = HttpxLLMClient(url="http://x", key="k", model="m", timeout=10)
assert c.model == "m"
assert c.timeout == 10
+87
View File
@@ -0,0 +1,87 @@
"""Unit-тесты services/metrics.py — чистая логика, без БД."""
from decimal import Decimal
import pytest
from services.metrics import (
check_arithmetic,
normalize_date,
_to_decimal,
ClassifyMetrics,
)
class TestNormalizeDate:
def test_dd_mm_yyyy(self):
assert normalize_date("01.01.2025") == "2025-01-01"
def test_already_iso(self):
assert normalize_date("2025-01-01") == "2025-01-01"
def test_empty(self):
assert normalize_date("") is None
assert normalize_date(None) is None
def test_unrecognized_passthrough(self):
assert normalize_date("01 января 2025") == "01 января 2025"
class TestToDecimal:
def test_int(self):
assert _to_decimal("50000") == Decimal("50000")
def test_russian_number(self):
assert _to_decimal("50 000,00") == Decimal("50000.00")
def test_nbsp(self):
assert _to_decimal("1\u00a0000,50") == Decimal("1000.50")
def test_none(self):
assert _to_decimal(None) is None
assert _to_decimal("") is None
def test_garbage(self):
assert _to_decimal("не число") is None
class TestCheckArithmetic:
def test_ok(self):
ops = [{"action": "ADD", "new_row": {"name": "x", "price": 100, "qty": 2, "sum": 200}}]
assert check_arithmetic(ops) == []
def test_mismatch(self):
ops = [{"action": "ADD", "new_row": {"name": "x", "price": 100, "qty": 2, "sum": 250}}]
m = check_arithmetic(ops)
assert len(m) == 1
assert m[0]["expected_sum"] == 200.0
assert m[0]["actual_sum"] == 250.0
assert m[0]["diff"] == 50.0
def test_update_values(self):
ops = [{"action": "UPDATE", "new_values": {"price": 10, "qty": 3, "sum": 30}}]
assert check_arithmetic(ops) == []
def test_skip_incomplete(self):
# нет qty → пропуск
ops = [{"action": "ADD", "new_row": {"name": "x", "price": 100, "sum": 200}}]
assert check_arithmetic(ops) == []
def test_skip_non_add_update(self):
ops = [{"action": "DELETE", "target_hash": "h"}]
assert check_arithmetic(ops) == []
class TestClassifyMetrics:
def test_fix_rate(self):
m = ClassifyMetrics()
m.record(False)
m.record(True)
m.record(False)
assert m.total == 3
assert m.fixes == 1
assert m.fix_rate == pytest.approx(1 / 3)
def test_empty(self):
m = ClassifyMetrics()
assert m.fix_rate == 0.0
assert m.summary()["total_classifications"] == 0
+28
View File
@@ -0,0 +1,28 @@
"""Unit-тесты services/parse.py."""
from services.parse import parse_file, _parse_text
class TestParseText:
def test_plain(self):
r = parse_file("test.txt", "строка1\nстрока2\nстрока3".encode())
assert r["status"] == "parsed"
assert r["element_count"] == 3
def test_text_fn(self):
r = _parse_text(b"a\nb\nc")
assert r["status"] == "parsed"
assert r["element_count"] == 3
assert r["elements"][0]["text"] == "a"
def test_no_extension_falls_back_to_text(self):
r = parse_file("noext", b"line1\nline2")
assert r["status"] == "parsed"
assert r["element_count"] == 2
def test_docx_invalid_returns_error(self):
r = parse_file("test.docx", b"not a real docx")
assert r["status"] == "error"
def test_pdf_invalid_returns_error(self):
r = parse_file("test.pdf", b"not a real pdf")
assert r["status"] == "error"
+96
View File
@@ -0,0 +1,96 @@
"""Интеграционные тесты services/process.py — run_pipeline с Fake LLM."""
import json
from db import contracts, documents, supplements, spec_current
from db.connection import query
from services import process
def _seed_contract(n_supps=2):
"""Создать contract + n документов (parsed) + n supplements."""
c = contracts.insert("03700_1")
for i in range(n_supps):
d = documents.insert(f"d{i}.docx", "application/octet-stream", "raw", batch_id="b1")
documents.set_parsed(d["id"], [{"type": "paragraph", "text": f"строка {i}"}])
supplements.insert(c["id"], d["id"], "initial" if i == 0 else "additional")
return c
class TestFullReplace:
def test_no_duplicates(self, db, monkeypatch):
c = _seed_contract(2)
def fake_call(current_spec, doc_text, build_prompt_fn, llm_client=None):
ops = [
{"action": "ADD", "new_row": {"name": "Аренда стойко-места", "price": 50000, "qty": 1, "sum": 50000}},
{"action": "ADD", "new_row": {"name": "IP-адрес IPv4", "price": 300, "qty": 8, "sum": 2400}},
{"action": "ADD", "new_row": {"name": "Канал 1 Гбит/с", "price": 20000, "qty": 1, "sum": 20000}},
]
return ({"mode": "full_replace", "ops": ops}, "pid")
monkeypatch.setattr("services.llm.call_llm", fake_call)
events = list(process.run_pipeline(c["id"], "", lambda cur, txt: ("p", "pid")))
rows = spec_current.list_by_contract(c["id"])
# 2 full_replace, но без дублей — всегда 3 строки, не 6
assert len(rows) == 3
assert {r["name"] for r in rows} == {"Аренда стойко-места", "IP-адрес IPv4", "Канал 1 Гбит/с"}
# история сохранена: 2 full_replace × 3 ADD = 6 событий
ev = query("SELECT count(*) AS c FROM spec_events WHERE contract_id = %s", (c["id"],))
assert ev[0]["c"] == 6
# не было ошибок извлечения
assert not any(e.get("type") == "extract_error" for e in events)
class TestTargetIdTranslation:
def test_update_applies(self, db, monkeypatch):
c = _seed_contract(2)
def fake_call(current_spec, doc_text, build_prompt_fn, llm_client=None):
if not current_spec:
return ({"mode": "partial", "ops": [
{"action": "ADD", "new_row": {"name": "Аренда", "price": 50000, "qty": 1, "sum": 50000}},
]}, "pid")
return ({"mode": "partial", "ops": [
{"action": "UPDATE", "target_id": "r1", "new_values": {"price": 55000, "sum": 55000}},
]}, "pid")
monkeypatch.setattr("services.llm.call_llm", fake_call)
list(process.run_pipeline(c["id"], "", lambda cur, txt: ("p", "pid")))
rows = spec_current.list_by_contract(c["id"])
assert len(rows) == 1
assert rows[0]["price"] == 55000 # UPDATE применился, а не ушёл в UNRESOLVED
upd = query("SELECT status FROM spec_events WHERE contract_id = %s AND action = 'UPDATE'", (c["id"],))
assert upd[0]["status"] == "applied"
class TestNoSupplements:
def test_error_event(self, db):
evs = list(process.run_pipeline("nonexistent", "", lambda cur, txt: ("p", "pid")))
assert any(e.get("type") == "error" for e in evs)
class TestElementsToText:
def test_list(self):
ej = [
{"type": "paragraph", "text": "Привет"},
{"type": "table", "rows": [["a", "b"], ["c", "d"]]},
]
assert process._elements_to_text(ej) == "Привет\na | b\nc | d"
def test_dict_value(self):
ej = {"Value": [{"type": "paragraph", "text": "X"}]}
assert process._elements_to_text(ej) == "X"
def test_string_json(self):
ej = json.dumps([{"type": "paragraph", "text": "Y"}])
assert process._elements_to_text(ej) == "Y"
def test_plain_string(self):
assert process._elements_to_text("просто текст") == "просто текст"
+48
View File
@@ -0,0 +1,48 @@
"""Интеграционные тесты HTTP-эндпоинтов (Flask test client)."""
import pytest
@pytest.fixture
def client(tmp_path, monkeypatch):
from db import connection as conn
monkeypatch.setattr(conn, "DB_PATH", str(tmp_path / "app.db"))
from app import create_app
app = create_app()
app.config["TESTING"] = True
yield app.test_client()
c = getattr(conn._local, "conn", None)
if c is not None:
try:
c.close()
except Exception:
pass
conn._local.conn = None
class TestHealth:
def test_health(self, client):
from config import VERSION
r = client.get("/health")
assert r.status_code == 200
data = r.get_json()
assert data["ok"] is True
assert data["version"] == VERSION
class TestSpecCurrent:
def test_missing_contract_id(self, client):
r = client.get("/api/spec-current")
assert r.status_code == 400
def test_empty(self, client):
r = client.get("/api/spec-current?contract_id=missing")
assert r.status_code == 200
data = r.get_json()
assert data["ok"] is True
assert data["rows"] == []
class TestGroups:
def test_missing_batch(self, client):
r = client.get("/api/groups")
assert r.status_code == 400
+31
View File
@@ -0,0 +1,31 @@
"""Интеграционные тесты db/spec_current.py."""
import json
from db import spec_current, documents, spec_events
class TestListByContract:
def test_empty(self, db):
assert spec_current.list_by_contract("nope") == []
def test_ordered_by_name(self, db):
ops = [
{"action": "ADD", "new_row": {"name": "Б", "price": 2}},
{"action": "ADD", "new_row": {"name": "А", "price": 1}},
]
spec_events.apply_ops("c1", "s1", "d1", ops, "pid", {})
rows = spec_current.list_by_contract("c1")
assert [r["name"] for r in rows] == ["А", "Б"]
class TestGetElementsJson:
def test_none(self, db):
assert spec_current.get_elements_json("missing") is None
def test_parsed(self, db):
d = documents.insert("f.docx", "m", "raw", batch_id="b1")
documents.set_parsed(d["id"], [{"type": "paragraph", "text": "x"}])
ej = spec_current.get_elements_json(d["id"])
assert ej is not None
parsed = json.loads(ej)
assert parsed[0]["text"] == "x"
+107
View File
@@ -0,0 +1,107 @@
"""Интеграционные тесты db/spec_events.py (SQLite, изолированная БД)."""
from db.connection import query
from db import spec_events, spec_current
CID = "11111111-1111-1111-1111-111111111111"
SID = "22222222-2222-2222-2222-222222222222"
DID = "33333333-3333-3333-3333-333333333333"
def _count(table, contract_id):
rows = query(f"SELECT count(*) AS c FROM {table} WHERE contract_id = %s", (contract_id,))
return rows[0]["c"]
class TestApplyOpsAdd:
def test_add(self, db):
ops = [{"action": "ADD", "new_row": {"name": "Аренда стойко-места", "price": 50000, "qty": 1, "sum": 50000, "date_start": "2025-01-01"}}]
s = spec_events.apply_ops(CID, SID, DID, ops, "pid", {})
assert s == {"added": 1, "updated": 0, "deleted": 0, "unresolved": 0}
rows = spec_current.list_by_contract(CID)
assert len(rows) == 1
assert rows[0]["name"] == "Аренда стойко-места"
assert rows[0]["price"] == 50000
assert rows[0]["date_start"] == "2025-01-01"
def test_add_empty_name_unresolved(self, db):
ops = [{"action": "ADD", "new_row": {"name": ""}}]
s = spec_events.apply_ops(CID, SID, DID, ops, "pid", {})
assert s == {"added": 0, "updated": 0, "deleted": 0, "unresolved": 1}
assert spec_current.list_by_contract(CID) == []
def test_add_multiple(self, db):
ops = [
{"action": "ADD", "new_row": {"name": "A", "price": 1}},
{"action": "ADD", "new_row": {"name": "B", "price": 2}},
]
s = spec_events.apply_ops(CID, SID, DID, ops, "pid", {})
assert s["added"] == 2
assert len(spec_current.list_by_contract(CID)) == 2
class TestApplyOpsUpdateDelete:
def test_update_and_delete(self, db):
spec_events.apply_ops(CID, SID, DID, [{"action": "ADD", "new_row": {"name": "Аренда", "price": 50000, "qty": 1, "sum": 50000}}], "pid", {})
h = spec_events._hash("Аренда")
s = spec_events.apply_ops(CID, SID, DID, [{"action": "UPDATE", "target_hash": h, "new_values": {"price": 55000}}], "pid", {})
assert s["updated"] == 1
assert spec_current.list_by_contract(CID)[0]["price"] == 55000
s = spec_events.apply_ops(CID, SID, DID, [{"action": "DELETE", "target_hash": h}], "pid", {})
assert s["deleted"] == 1
assert spec_current.list_by_contract(CID) == []
def test_update_empty_hash_unresolved(self, db):
s = spec_events.apply_ops(CID, SID, DID, [{"action": "UPDATE", "new_values": {"price": 1}}], "pid", {})
assert s["updated"] == 0
rows = query("SELECT status FROM spec_events WHERE contract_id = %s", (CID,))
assert rows[0]["status"] == "unresolved"
class TestUnresolvedAndUnknown:
def test_unresolved_action(self, db):
ops = [{"action": "UNRESOLVED", "new_values": {"name": "X"}, "reason": "нет соответствия"}]
spec_events.apply_ops(CID, SID, DID, ops, "pid", {})
assert spec_current.list_by_contract(CID) == []
rows = query("SELECT action, status FROM spec_events WHERE contract_id = %s", (CID,))
assert rows[0]["action"] == "UNRESOLVED"
assert rows[0]["status"] == "unresolved"
def test_unknown_action(self, db):
spec_events.apply_ops(CID, SID, DID, [{"action": "WHATEVER", "new_row": {"name": "X"}}], "pid", {})
assert spec_current.list_by_contract(CID) == []
rows = query("SELECT action FROM spec_events WHERE contract_id = %s", (CID,))
assert rows[0]["action"] == "UNRESOLVED"
class TestClearAndReset:
def test_clear_current_keeps_events(self, db):
spec_events.apply_ops(CID, SID, DID, [{"action": "ADD", "new_row": {"name": "A", "price": 1}}], "pid", {})
assert _count("spec_current", CID) == 1
spec_events.clear_current(CID)
assert _count("spec_current", CID) == 0
assert _count("spec_events", CID) == 1 # история сохранена
def test_reset_clears_both(self, db):
spec_events.apply_ops(CID, SID, DID, [{"action": "ADD", "new_row": {"name": "A", "price": 1}}], "pid", {})
spec_events.reset(CID)
assert _count("spec_current", CID) == 0
assert _count("spec_events", CID) == 0
class TestHashAndSeq:
def test_hash_normalizes(self):
assert spec_events._hash(" Арена стойко-места ") == spec_events._hash("арена стойко-места")
def test_hash_includes_date(self):
assert spec_events._hash("Аренда", "01.01.2025") != spec_events._hash("Аренда", "01.02.2025")
def test_hash_len(self):
assert len(spec_events._hash("x")) == 16
def test_seq(self, db):
assert spec_events.get_next_seq(CID) == 1
spec_events.apply_ops(CID, SID, DID, [{"action": "ADD", "new_row": {"name": "A"}}], "pid", {})
assert spec_events.get_next_seq(CID) == 2