Compare commits
18
Commits
87524c54a4
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
83002b9644 | ||
|
|
f9745e5c6b | ||
|
|
811be85efe | ||
|
|
e5c7c88073 | ||
|
|
475efefb40 | ||
|
|
65939984c3 | ||
|
|
98e18b0135 | ||
|
|
36144334a3 | ||
|
|
c4997b06d3 | ||
|
|
533aa99592 | ||
|
|
aa585220fc | ||
|
|
08e8e3afec | ||
|
|
6fcbbddbdb | ||
|
|
08f7e3f98e | ||
|
|
b7d60e1d93 | ||
|
|
3b259cf160 | ||
|
|
8f8fabf959 | ||
|
|
9a8ae0a5a3 |
@@ -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`).
|
||||||
@@ -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()`.
|
||||||
|
|
||||||
|
Ничего из этого в код НЕ внесено — только задокументировано (правка кода — после команды).
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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
@@ -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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
||||||
};
|
|
||||||
|
|
||||||
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>'), '<script>', 'escHtml экранирует < >');
|
|
||||||
eq(escHtml('a&b'), 'a&b', 'escHtml экранирует &');
|
|
||||||
eq(escHtml('"quote"'), '"quote"', '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();
|
|
||||||
@@ -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!"
|
|
||||||
@@ -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)
|
|
||||||
@@ -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"]
|
|
||||||
@@ -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")
|
|
||||||
@@ -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"
|
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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(
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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
@@ -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) + 'с)';
|
||||||
|
|||||||
@@ -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' };
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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: долгие нагрузочные/стресс-тесты")
|
||||||
|
|
||||||
@@ -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
|
||||||
|
"""
|
||||||
@@ -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()
|
||||||
@@ -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}с")
|
||||||
@@ -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}")
|
||||||
@@ -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}с"
|
||||||
|
)
|
||||||
@@ -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("") == ""
|
||||||
@@ -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
|
||||||
@@ -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")]
|
||||||
@@ -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}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
@@ -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("просто текст") == "просто текст"
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user