36 Commits
Author SHA1 Message Date
“Naeel” 83002b9644 1
Deploy contracts-flask / validate (push) Canceled after 0s
2026-08-31 13:58:15 +03:00
“Naeel” f9745e5c6b Document comparison review findings
Deploy contracts-flask / validate (push) Canceled after 0s
2026-08-27 11:24:32 +03:00
“Naeel” 811be85efe Fix comparison pipeline event and transaction handling 2026-08-27 11:23:14 +03:00
“Naeel” e5c7c88073 Avoid false garbage classification for UPD mentions
Deploy contracts-flask / validate (push) Canceled after 0s
2026-08-27 10:44:48 +03:00
“Naeel” 475efefb40 Preserve ZIP source during upload
Deploy contracts-flask / validate (push) Canceled after 0s
2026-08-27 09:45:54 +03:00
“Naeel” 65939984c3 Fix classification progress for garbage documents
Deploy contracts-flask / validate (push) Canceled after 0s
2026-08-27 08:29:59 +03:00
“Naeel” 98e18b0135 chore: v2.0.17 во фронте (заголовок + ?v= кэш-бастеры)
Deploy contracts-flask / validate (push) Canceled after 0s
2026-08-26 20:59:10 +03:00
“Naeel” 36144334a3 chore: bump v2.0.17 (чистка legacy-upload)
Deploy contracts-flask / validate (push) Canceled after 0s
2026-08-26 20:54:04 +03:00
“Naeel” c4997b06d3 test: stress_http переписан на реальный VM-путь (PUT на ВМ → /api/upload_refs) 2026-08-26 20:51:39 +03:00
“Naeel” 533aa99592 docs: History — чистка legacy-upload + сводка сессии 2026-08-26 2026-08-26 20:49:24 +03:00
“Naeel” aa585220fc refactor: удалён мёртвый legacy-код загрузки напрямую (/upload, /unzip-upload, _check_ext, _unzip, ALLOWED, константы фронта); загрузка только через ВМ (/api/upload_refs) 2026-08-26 20:47:41 +03:00
“Naeel” 08e8e3afec test: stress_http --file (реальные файлы); History: 100KB прямой POST — 83% таймаутов (шлюз рвёт >64KB), 30KB реальный — 200/200 ok 2026-08-26 20:35:48 +03:00
“Naeel” 6fcbbddbdb test: LOAD_THREADS 4 (стабильно, = MAX_WORKERS); полный прогон: not-load 102 passed, load 3 passed 2026-08-26 20:14:43 +03:00
“Naeel” 08f7e3f98e test: дефолт LOAD_THREADS 16→8 (стабильный прогон) + History: database is locked при 16 писателях (предел конфигурации) 2026-08-26 17:12:26 +03:00
“Naeel” b7d60e1d93 test: нагрузочные/стресс-тесты (маркер load): массовый pipeline, apply_ops, конкуренция, HTTP-стресс 2026-08-26 17:03:45 +03:00
“Naeel” 3b259cf160 test: README для tests/ + фикс теста test_boolean (BOOLEAN NOT NULL); 102 passed 2026-08-26 16:50:01 +03:00
“Naeel” 8f8fabf959 test: набор pytest-тестов под site/ (unit + интеграционные + безопасность) 2026-08-26 16:47:30 +03:00
“Naeel” 9a8ae0a5a3 test: удалены устаревшие тесты deploy/tests (ссылались на старую compare/-архитектуру) 2026-08-26 16:36:52 +03:00
“Naeel” 87524c54a4 docs: код-ревью (2026-08-26) + задания Sonnet по архитектуре (v2.0.16)
Deploy contracts-flask / validate (push) Canceled after 0s
2026-08-26 16:29:41 +03:00
“Naeel” 021c925e3c fix: full_replace очищает spec_current (не дублирует строки) + clear_current; v2.0.16
Deploy contracts-flask / validate (push) Canceled after 0s
2026-08-26 16:25:23 +03:00
“Naeel” e3f5581712 v2.0.15: фиксы по ревью Соннета (10 находок)
Deploy contracts-flask / validate (push) Canceled after 0s
- #1 process.py: target_id→target_hash (UPDATE/DELETE больше не UNRESOLVED)
- #2 Dockerfile: COPY upload
- #3 prompts.py: убран created_by из SELECT
- #4 prompts_bp.py: save_new_version/delete_prompt
- #5 llm.py/classify.py: LLM-конфиг из config.py
- #6/#7 compare.js: escHtml (XSS)
- #8-#10: комментарии + мёртвый gen_random_uuid
2026-08-26 15:59:21 +03:00
“Naeel” ebdab731ff v2.0.14: .doc→.docx в sink + зачистка мёртвого кода
Deploy contracts-flask / validate (push) Canceled after 0s
- contracts_upload_sink: конвертация .doc через внешний сервис (фронт грузит напрямую)
- удалены: convertDoc/addZipFile/addRegularFile (фронт), /convert-doc,/api/convert_refs,/api/unzip_refs, _pull_from_ref и хелперы pull (бэк)
2026-08-26 13:54:07 +03:00
“Naeel” 5b49e88f1e v2.0.14: этап 4 — .doc→.docx в sink, удалены /api/unzip_refs и /api/convert_refs
Deploy contracts-flask / validate (push) Canceled after 0s
2026-08-26 11:40:03 +03:00
“Naeel” d2894ad7c5 v2.0.13: этап 3 — выбор папок + рекурсивный клиентский ZIP
Deploy contracts-flask / validate (push) Canceled after 0s
- pages_bp: route /upload/<path> отдаёт ES-модули upload/frontend
- index.html: инпут папки (webkitdirectory) + мост listZipFiles
- files.js: expandZipClient (рекурсивный ZIP через drhider listZipFiles)
- app.js: обработчик выбора папки (фильтр .pdf/.doc/.docx/.zip)
2026-08-26 08:40:19 +03:00
“Naeel” c2344f9738 bump 2.0.12 (этап 2: модуль upload через sink)
Deploy contracts-flask / validate (push) Canceled after 0s
2026-08-26 08:15:48 +03:00
“Naeel” db58a433fb этап 2: переиспользуемый модуль upload (sink) вместо рукописного транспорта
- копирую модуль upload/ из drhider (слои 1-2)
- blueprint: параметр sink (drhider-сессия по умолчанию, сверка — DB+парсинг)
- upload_bp: contracts_upload_sink = _store_and_parse
- routes: регистрирую create_upload_refs_blueprint(cfg, sink=...)
- app.py: корень репо в sys.path (для import upload)
- History: план переиспользования + ревью Соннета
2026-08-26 08:07:43 +03:00
“Naeel” 55bd7a027d v2.0.11: вся загрузка через ВМ-буфер (ZIP + .doc)
Deploy contracts-flask / validate (push) Canceled after 0s
- /api/unzip_refs: pull ZIP с ВМ + распаковка
- /api/convert_refs: pull .doc с ВМ + конвертация в .docx
- convertDoc/addZipFile: PUT на ВМ вместо прямого multipart
2026-08-24 22:08:01 +03:00
“Naeel” 37ea5e2c31 v2.0.10: nginx /contracts-upload/ на alias + CORS как у drhider
Deploy contracts-flask / validate (push) Canceled after 0s
2026-08-24 21:51:48 +03:00
“Naeel” 78045f2c81 v2.0.10: загрузка через ВМ-буфер (паттерн drhider)
Deploy contracts-flask / validate (push) Canceled after 0s
- /api/upload_refs: pull файлов с ВМ (SSRF-guard, лимит 50МБ, ретраи, DELETE)
- uploadFile: PUT на WebDAV /contracts-upload/ → refs → pull
- nginx: location /contracts-upload/ (WebDAV + CORS для managed-фронта)
2026-08-24 21:45:46 +03:00
naeel 4653aa5e8e Docs: диагностика HTTP 502 на 19МБ PDF — OOMKill пода (лимит 1Gi)
Deploy contracts-flask / validate (push) Canceled after 0s
2026-08-17 18:32:06 +04:00
“Naeel” 956aed0971 v2.0.9: правильные лого и фавикон из design/ 2026-07-16 10:58:09 +04:00
“Naeel” 3b2e576284 v2.0.9: фавикон nubes.ru (U-арка) 2026-07-16 10:56:58 +04:00
“Naeel” de05d746cc v2.0.8: конвертация .doc → .docx через convert-service перед upload 2026-07-16 10:42:21 +04:00
“Naeel” c677206d03 v2.0.7: .doc в accept и подсказке 2026-07-16 10:29:14 +04:00
“Naeel” d1415d5d21 v2.0.6: convert-service интеграция 2026-07-16 10:20:44 +04:00
“Naeel” 5d8bcd61ea v2.0.5: интеграция с convert-service (HTTP вместо subprocess libreoffice) 2026-07-16 10:18:17 +04:00
106 changed files with 4628 additions and 1666 deletions
+1
View File
@@ -7,6 +7,7 @@ COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY site /app/site
COPY upload /app/upload
EXPOSE 5000
@@ -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`).
+34
View File
@@ -0,0 +1,34 @@
# full_replace: фикс дублирования строк спецификации (v2.0.16)
## Проблема
`mode="full_replace"` (LLM возвращает полную новую редакцию — все ADD)
применялся поверх существующей `spec_current` без очистки.
`reset(contract_id)` срабатывает только один раз на старте пайплайна, а
full_replace происходит ПОСЛЕ — строки новой редакции плюсовались к старым →
дубли в `spec_current`.
## Фикс
1. `site/db/spec_events.py` — добавлена `clear_current(contract_id)`:
очищает ТОЛЬКО `spec_current` (история `spec_events` сохраняется).
2. `site/services/process.py` — перед `apply_ops()`:
```python
if mode == "full_replace":
spec_events.clear_current(contract_id)
```
## Почему не `reset()`
`reset()` чистит и `spec_events`, и `spec_current` — потеряли бы историю
операций текущей редакции. Для full_replace нужна только очистка текущего
состояния, журнал ADDs должен остаться.
## Сопутствующее
- Скорректировано архитектурное описание → `/home/naeel/nubes/contracts/DOC/architecture-contracts-flask.md`.
Исправлена неточность: ZIP раскрывается **на клиенте** (`expandZipClient` →
`window.listZipFiles`), а не серверным `/unzip-upload` (legacy).
- Версия: 2.0.15 → 2.0.16.
+30
View File
@@ -0,0 +1,30 @@
# load: SQLite database is locked при конкурентной записи (2026-08-26)
## Контекст
Полный прогон нагрузочных тестов (`pytest -m load`, дефолтные значения):
- `test_load_pipeline` (100 контрактов × 20 допников × 10 услуг) — ✅ passed
- `test_load_apply_ops` (5000 ADD → 5000 UPDATE → 5000 DELETE) — ✅ passed
- `test_load_concurrency` (16 потоков × 200 ADD) — ❌ FAILED
## Находка
- `sqlite3.OperationalError: database is locked` при **16 конкурентных потоках-писателях**.
- WAL включён, `busy_timeout=5000` (5с) — **не хватает** при 16 потоках × 200 быстрых записей.
- Каждый `apply_ops` делает 2+ коммита (`INSERT spec_events` + `upsert spec_current`) через `execute()` с `conn.commit()`.
- Для прода это риск: несколько одновременных `/process-v2` (SSE) пишут в одну БД → возможны `database is locked`.
## Решение по тесту
- Дефолт `LOAD_THREADS` снижен **16 → 8 → 4**.
- Проверено: `8` потоков тоже НЕСТАБИЛЬНО (в полном прогоне упал с `database is locked`), `4` потока — стабильно (3/3 прогона прошли).
- `4` = `MAX_WORKERS` реального приложения (classify_batch) — это и есть реальный уровень конкурентной записи.
- `8+` — стресс-режим (демонстрирует предел конфигурации).
## Рекомендация для прода (обсудить отдельно)
1. Увеличить `busy_timeout` (сейчас 5000 мс) при конкурентной записи.
2. Или сериализовать запись: один writer / очередь apply_ops по контракту.
3. Или retry при `database is locked` на уровне `execute()`.
Ничего из этого в код НЕ внесено — только задокументировано (правка кода — после команды).
+60
View File
@@ -0,0 +1,60 @@
# Сводка сессии 2026-08-26 — contracts-flask (тесты + чистка + стресс)
## 1. Фикс логики сверки (v2.0.15 → v2.0.16)
- **`full_replace` дублировал строки**: `reset()` чистил `spec_current` только на старте
пайплайна, а `full_replace` (все ADD) применялся поверх старых строк → дубли.
- Фикс: `db/spec_events.py::clear_current()` (чистит только `spec_current`, история
`spec_events` сохраняется) + вызов в `process.py` при `mode == "full_replace"`.
- Коммит `021c925`, запушено, прод передеплоен → `2.0.16`.
- Детали: `History/2026-08-26-full-replace-fix.md`.
## 2. Документация
- Архитектура: `DOC/architecture-contracts-flask.md` (актуальная, VM-буфер, клиентский ZIP).
- Корневой `README.md` — подробный, со ссылкой на архитектуру.
## 3. Тесты
### Удалены старые (коммит `9a8ae0a`)
- `deploy/tests/` (unit/pipeline/integration + `test_drhider.py`), `deploy/conftest.py`,
`deploy/tests.js` — ссылались на старую `compare/`-архитектуру.
### Новые юнит/интеграционные (коммиты `8f8fabf`, `3b259cf`)
- 13 файлов в `contracts-flask/tests/` + `conftest.py` (изоляция БД) + `README.md`.
- Покрытие: metrics, grouping, llm_client, llm, classify, parse, connection,
spec_events, spec_current, process_pipeline, routes.
- **92 passed** (после чистки legacy; было 102).
### Нагрузочные (маркер `load`, коммиты `b7d60e1`, `08f7e3f`, `6fcbbdd`)
- `tests/load/test_load_pipeline.py` — 100 контрактов × 20 допников × 10 услуг.
- `tests/load/test_load_apply_ops.py` — 5000 ADD/UPDATE/DELETE.
- `tests/load/test_load_concurrency.py` — конкурентная запись SQLite.
- `tests/load/stress_http.py` — standalone HTTP-стресс.
- Полный прогон: not-load 102 passed, load 3 passed (~3 мин).
### Находка №1: SQLite `database is locked` (коммит `08f7e3f`)
- 16 конкурентных писателей → `database is locked`; 8 — нестабильно; **4 — стабильно**
(= `MAX_WORKERS` приложения). Для текущего сценария (один пользователь) некритично.
- `History/2026-08-26-load-sqlite-locked.md`.
## 4. Стресс-тест прода (коммит `08e8e3a`)
- Прод: `contractor.pythonk8s.dev.nubes.ru`, под `pythonk8s` (cpu 1, mem 1Gi).
- Прямой POST 100KB → **83% таймаутов** (шлюз рвёт >64KB) — подтверждён предел платформы,
поэтому и существует VM-буфер.
- Реальный файл 30KB → 200/200 ok (rps 10), под CPU 899m / MEM 520Mi, без OOM.
- `History/2026-08-26-stress-100kb-gateway.md`.
## 5. Чистка мёртвого legacy-кода (коммит `aa58522`)
- Удалены `POST /upload`, `POST /unzip-upload`, `_check_ext`, `_unzip`, `ALLOWED`,
пустой blueprint, константы фронта `UPLOAD_URL`/`CONVERT_URL`/`UNZIP_URL`.
- Загрузка теперь ТОЛЬКО через ВМ (`/api/upload_refs``contracts_upload_sink`).
- `History/2026-08-26-cleanup-legacy-upload.md`.
## Хвосты / открытые вопросы
1. ~~`tests/load/stress_http.py` бьёт по удалённому `/upload`~~ — переписан на VM-путь (`PUT` ВМ → `/api/upload_refs`).
2. Прод-риск `database is locked` при конкурентных сверках — отложен (нет мультитенантности).
3. E2E с реальным LLM (`gpt-oss-120b`) на проде — не проверялся автоматически (тесты на Fake LLM).
+73
View File
@@ -0,0 +1,73 @@
# Sonnet: код-ревью contracts-flask (2026-08-26)
## Контекст
- Написан промпт для Sonnet: `contracts-flask/docs/prompt-sonnet-architecture.md`
(задача — прочитать файлы текущего сервиса сверки и дать описание/архитектуру).
- Sonnet прочитал все указанные файлы и вместо описания выдал **код-ревью** с 10 находками
(2 критические, 3 средних, 2 XSS, 3 мелких). Архитектурное описание — отдельным документом (не выдано).
## Находки Sonnet
### 🔴 Критические
1. **`process.py` + `spec_events.py` — UPDATE/DELETE никогда не применяются (сломана частичная сверка).**
LLM возвращает `target_id: "r1"` (индекс строки), а `apply_ops()` читает `op.get("target_hash", "")`.
Поля `target_hash` в ответе LLM нет → все UPDATE/DELETE из `mode=partial` молча уходят в `UNRESOLVED`.
Трассировка: `_build_spec_text()` показывает строки `[id: r1]` → LLM возвращает `target_id: "r1"`
`process.py` передаёт ops без трансляции r1→hash → `spec_events.py` `th = op.get("target_hash","")` = "" → UNRESOLVED.
Работает только `mode=full_replace` (все ADD) и первый документ (extract, только ADD).
2. **`Dockerfile` — образ не запустится: `upload/` не скопирован.**
`COPY site /app/site` — только site/, `upload/` отсутствует.
При старте `routes/__init__.py` делает `from upload.backend.upload_refs import ...`, `app.py` добавляет `/app` в sys.path.
В образе `/app/upload/` нет → `ModuleNotFoundError`.
### 🟠 Средние
3. **`db/prompts.py``list_by_role()` запрашивает несуществующий столбец `created_by`.**
В схеме `prompts` нет `created_by``sqlite3.OperationalError``/api/prompts/list` всегда 500.
4. **`routes/prompts_bp.py` — вызов несуществующих функций:**
- `db_prompts.insert(...)` — нет (есть `save_new_version()`);
- `db_prompts.delete(...)` — нет (есть `delete_prompt()`).
`/api/prompts/save` и `/api/prompts/delete` падают с `AttributeError`.
5. **`services/llm.py`, `services/classify.py` — захардкожены `LLM_URL/LLM_KEY/LLM_MODEL` в обход `config.py`.**
`config.py` читает из `LLM_API_URL`, а compare/classify игнорируют его. Конфигурация расщеплена на 3 блока.
### 🟡 XSS (frontend)
6. **`compare.js``nr.name` в таблице операций не экранирован** (`escHtml()` отсутствует).
Имя услуги из договора (текст LLM) вставляется в innerHTML → XSS.
7. **`compare.js``sec.filename` в заголовке секции не экранирован.** Имя файла от пользователя → XSS.
### ⚪ Мелкие
8. **`llm_prompt.py`** — устаревший комментарий «Lucee API» (читает напрямую из SQLite).
9. **`db/connection.py`** — `_pg_to_sqlite`: `gen_random_uuid()` добавляет `?` в SQL, но не добавляет значение в params (мёртвый код, но при исполнении сломает запрос).
10. **`services/classify.py`** — комментарий «re-classify after file changes» вводит в заблуждение: `reset_classify_status()` сбрасывает только `processing``pending`, уже классифицированные не трогает.
## Итоговая таблица
| # | Файл | Проблема | Критичность |
|---|---|---|---|
| 1 | process.py, spec_events.py | UPDATE/DELETE → UNRESOLVED, частичная сверка сломана | 🔴 критический |
| 2 | Dockerfile | upload/ не скопирован → образ не стартует | 🔴 критический |
| 3 | db/prompts.py | created_by отсутствует → 500 на /api/prompts/list | 🟠 средний |
| 4 | prompts_bp.py | insert()/delete() — не те имена функций → 500 | 🟠 средний |
| 5 | llm.py, classify.py | захардкожены LLM_URL/KEY/MODEL в обход config.py | 🟠 средний |
| 6 | compare.js | nr.name не экранирован → XSS | 🟡 XSS |
| 7 | compare.js | sec.filename не экранирован → XSS | 🟡 XSS |
| 8 | llm_prompt.py | устаревший комментарий | ⚪ мелкое |
| 9 | connection.py | gen_random_uuid() мёртвый код с ошибкой | ⚪ мелкое |
| 10 | classify.py | комментарий re-classify вводит в заблуждение | ⚪ мелкое |
## Статус
- ✅ Все 10 находок исправлены (v2.0.15, commit `e3f5581`, запушено).
- #1 (критично) — сам: `process.py` трансляция `target_id``target_hash`.
- #2#10 (механика) — Флаш-субагент: Dockerfile, prompts CRUD, XSS, конфиг LLM, комментарии.
- Верифицировано: `py_compile`, `node -c`, `import app` — OK.
- Архитектурное описание от Sonnet — так и не получено (вместо него — ревью). Если нужно — отдельным запросом.
@@ -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,104 @@
# Переиспользование модуля загрузки drhider в contracts-flask
Дата: 2026-08-26
## Контекст
- Шлюз managed-кластера рвёт тела запросов >~64 КБ, egress не ограничен.
- Паттерн загрузки: браузер `PUT` файла на ВМ-буфер (WebDAV) → Flask `pull` (egress GET) → обработка.
- В drhider этот паттерн отлажен и вынесен в переиспользуемый модуль `upload/`
(слой 1 — выбор файлов/папок/архивов → таблица; слой 2 — закачка PUT→pull; слой 3 — логика приложения, НЕ в модуле).
- Задача: взять из drhider выбор+загрузку, сшить с логикой сверки contracts-flask,
**НЕ меняя саму логику сверки** (классификация/группы/сравнение).
Текущее состояние contracts-flask: v2.0.11 (рабочая загрузка через ВМ, написана вручную в этой сессии).
---
## Ревью Соннета (итог)
Вердикт: **«с оговорками»** — одна критическая: слои 1+2 нельзя взять AS-IS для
фронт-части слоя 2. `uploadViaVM.js` шлёт `{session, files:[...]}`, а сверка ожидает
`{batch_id, contract_id, zip_source, files}`. Несовместимые форматы.
### Ключевые находки Соннета
1. **Привязка к in-memory сессии** (blueprint.py, 4 точки): `create_session()`,
`add_file(sid, name, content)`, `get_files(sid) is None`, `file_count(sid)`.
→ заменить одним `sink(name, content, **ctx)`, где `ctx = {batch_id, contract_id, zip_source}`.
2. **Граница «логика сверки» — НЕЛЬЗЯ трогать:**
| Файл | Функции |
|---|---|
| `pipeline_bp.py` | `process_v2`, `classify_batch_route` (SSE + classify) |
| `services/process.py` | `run_pipeline` |
| `services/classify.py` | `classify_batch`, `_call_llm_classify`, garbage filters |
| `services/grouping.py` | `group_documents`, `apply_groups`, `normalize_number` |
| `db/documents.py` | ВСЕ (контракт данных) |
| `db/supplements.py` | ВСЕ |
3. **Риски:**
- клиентский ZIP — полная распаковка в память браузера (у сверки PDF ~19 МБ);
- `allowedExt` разный: drhider `[.pdf,.doc,.docx,.txt,.md]` vs сверка `{pdf,docx,doc,zip}`;
- `parse.py` уже пытается парсить `.doc` напрямую (`elif ext=="doc": _parse_docx(data)`) — sink должен перехватывать `.doc` ДО `parse_file`;
- дедуп: name+size (слой 1) vs content-hash (sink) — не ошибка, двойная защита;
- `session` (drhider) vs `batch_id` (сверка, `crypto.randomUUID()` в state.js);
- `zip_source` — поле documents, одно на вызов (для UI-группировки).
4. **Безопасный порядок (Соннет):** расширить blueprint (sink) → extra-поля → contracts_sink → backend → frontend layer1 → frontend layer2 → удалить `/api/unzip_refs`,`/api/convert_refs`.
### Противоречия в плане (Соннет)
- «Слои 1+2 КАК ЕСТЬ» неверно для фронт-части слоя 2 (`{session}` vs `{batch_id,...}`).
- `.doc` в sink неполно описан (порядок «конвертация ДО parse»).
- `allowedExt` при интеграции не упомянут.
---
## Моё решение (3 этапа)
| Этап | Что | Риск |
|---|---|---|
| **1. Транспорт** | скопировать `upload/` в contracts-flask; заменить рукописные `_safe_name` + `_pull_with_retries` на модульные | низкий |
| **2. Sink** | `create_upload_refs_blueprint(cfg, sink=...)`; sink = `_store_and_parse` + перехват `.doc` → внешний LibreOffice → `parse_file` | средний |
| **3. UI** | `initUploadTable` (файлы+папки+архивы) | высокий, последним |
### Принятые решения
- **ZIP → серверный** (оставить `/api/unzip_refs`): у сверки крупные PDF, клиентская распаковка = регресс по памяти (осознанное отступление от «как в хайдере»).
- **`.doc` → конвертация в sink** через внешний LibreOffice-сервис (`CONVERT_SERVICE_URL`), последовательно (один тяжёлый `.doc` блокирует пакет; параллелить потом).
- **UI → последним, изолированно** от «загрузка не работает».
- **Реализация:** я (спецификация + sink), субагент-младшая модель (механика этапа 1), я ревьюю дифф.
### Что НЕ переносить из drhider
- in-memory сессию (сверка хранит в SQLite `documents` по `batch_id`/`doc_id`);
- клиентскую распаковку ZIP;
- обфускацию/слой 3 drhider.
---
## Находка при чтении транспортных файлов модуля (2026-08-26)
Модуль — это **целостный Blueprint**, а не набор drop-in функций:
- `safe_name(name)` **сохраняет подпапки** и возвращает `""` при небезопасном имени.
У сверки `_safe_name`**basename-only** (rsplit) и возвращает `"file.bin"`. НЕ 1:1.
- `pull_file(client, url, ...)` требует `httpx.Client` + стриминг (`client.stream`).
У сверки `_pull_with_retries(url)``httpx.get` внутри. Разные сигнатуры.
- `blueprint.py` жёстко завязан на сессию: `create_session`, `add_file`, `get_files`,
`file_count`.
**Следствие:** «этап 1: заменить 2 функции 1:1» НЕ выполним. Переиспользование идёт
на уровне **blueprint через sink** (этап 2), с адаптацией формы запроса/ответа.
## Статус
- [x] Скопировать `upload/` в contracts-flask (commit db58a43)
- [x] Этап 2 — sink (blueprint + contracts_upload_sink + регистрация), проверено
- [x] Удалён рукописный `/api/upload_refs` (основной путь теперь через модуль)
- [x] Этап 3 — UI: выбор папок (webkitdirectory) + рекурсивный клиентский ZIP (commit d2894ad)
- [ ] Осталось (cleanup, опционально): .doc→.docx в sink, удалить `/api/unzip_refs`/`/api/convert_refs` + `addZipFile`/`convertDoc`
Примечание: `/api/unzip_refs` и `/api/convert_refs` пока оставлены как fallback
(server-side ZIP и .doc), фронт `addZipFile`/`convertDoc` не удалены.
+137
View File
@@ -0,0 +1,137 @@
# Классификация застряла на 68/84: диагностика 2026-08-27
## Наблюдение
На экране классификация остаётся на `68/84`, хотя контейнер продолжает работать.
Проверка через ВМ:
```text
GET /api/batch-progress?batch=acbdffe5-e3ec-43f4-ab61-84861cac35bd
{"counts":{"classified":68,"garbage":16},"ok":true,"total":84}
```
Повторный запрос вернул то же значение. Kubernetes показывает pod
`pythonk8s-574b5cf9b4-z4wxz` в состоянии `Running`, `Ready 1/1`, `RESTARTS 0`.
Событий Kubernetes нет. На ВМ `contracts.service` активен, `convert_server.py`
запущен в одном экземпляре.
## Причина в коде
`site/routes/api_bp.py` возвращает раздельные счётчики `classified`, `failed` и
`garbage`, а `total` равен числу всех документов батча.
`site/static/app.js` в `runClassify()` считает завершённые документы так:
```javascript
var done = (d.counts.classified || 0) + (d.counts.failed || 0);
```
`garbage` в эту сумму не включён. Для текущего батча:
```text
done = 68 classified + 0 failed = 68
total = 84
garbage = 16
фактически завершено = 68 + 0 + 16 = 84
```
Поэтому условие `done >= total` никогда не выполняется, polling не
останавливается, а кнопка остаётся на `68/84`. Это ошибка фронтендового
подсчёта прогресса, а не зависание Kubernetes или LLM на 68-м файле.
## Исправление
В `site/static/app.js` к числу завершённых документов добавлен конечный статус
`garbage`:
```javascript
var done = (d.counts.classified || 0)
+ (d.counts.failed || 0)
+ (d.counts.garbage || 0);
```
Версия приложения повышена с `2.0.17` до `2.0.18`; cache-buster статических
скриптов обновлён в `site/templates/index.html`.
Kubernetes проверялся только командами чтения через ВМ:
`kubectl get`, `kubectl describe`, `kubectl top`, `kubectl logs`.
## Дополнительная проверка загрузки ZIP
При проверке загрузки разными способами обнаружен отдельный дефект связи
файлов с архивом. `expandZipClient()` создаёт для каждого распакованного файла
поле `zip_source`, а `uploadFile(file, onProgress, zipSource)` умеет передавать
его в `/api/upload_refs`. Однако вызов `uploadFile()` в цикле `onFilesSelected()`
передавал только два аргумента. Поэтому backend получал `zip_source=null`, и
связь с исходным ZIP терялась.
Проверены архивы:
- `testgen/out/zips/duplicates_inside.zip` — целый ZIP, два файла с одинаковым именем;
- `testgen/out/zips/mixed_with_error.zip` — целый ZIP, корректный и повреждённый DOCX.
Исправление применено в `site/static/files.js`: третьим аргументом передаётся
`entry.zip_source`. Для обычных файлов значение `null`, поэтому их поведение не
изменяется. Версия приложения повышена с `2.0.18` до `2.0.19`, cache-buster
обновлён в `site/templates/index.html`.
## Проверки исправления
- `node --check site/static/files.js` — успешно;
- frontend upload-тесты — успешно;
- layer 2 upload-тесты — успешно;
- архивы `duplicates_inside.zip` и `mixed_with_error.zip` прошли `unzip -t`.
## Реальный end-to-end тест сверки
27.08.2026 выполнен тест через deployed API `v2.0.19` на batch
`0dd0d3a7-3e52-4888-95eb-1b6d3e37f40e`.
Загружены через VM-поток два реальных DOCX:
- `договор-XXX001-03700.docx` — распарсен, 20 элементов;
- `допник-1-XXX001-03700.docx` — распарсен, 16 элементов.
Классификация завершилась `2/2`, но результат классификации неожиданен:
- допсоглашение попало в группу `03700`;
- базовый договор попал в `__unresolved__` со статусом `garbage` и причиной
отсутствия matching-договора.
Это функциональный дефект/проблема классификации тестовой пары: без базового
договора группа не проверяет корректное сравнение договора с приложением.
Тем не менее реальный pipeline сверки для сформированной группы проверен:
- `/api/apply-groups``200`, создан contract ID
`b859e813-bac9-4eb9-82b5-6b5666f2ba4a`;
- `/process-v2` — SSE завершён событием `complete`;
- LLM вернул `6` операций в режиме `partial`;
- применено: `added=6`, `updated=0`, `deleted=0`;
- ошибок соединения и SSE не было;
- время pipeline: `9.4с`.
Следующий обязательный тест для проверки matching — загрузить базовый договор с
точным именем/содержимым, которое классификатор ожидает как базовый, и повторить
ту же пару. Код после этого прогона не изменялся.
## Исправление ложного garbage для базового договора
При повторной проверке реального файла `договор-XXX001-03700.docx` установлена
точная причина ошибочной классификации: второй garbage-фильтр искал подстроку
`УПД` в первых 2000 символах. В договоре эта аббревиатура встречается в
обычном условии оплаты: «на основании УПД и счета». Из-за этого договор
получал `garbage=header_keywords`, хотя его заголовок начинается со слова
«Договор».
Исправление в `site/services/classify.py`:
- удалено общее substring-срабатывание для `УПД`;
- добавлено распознавание `УПД` и полного названия только с начала строки,
когда это заголовок самого документа.
Добавлены регрессионные тесты: упоминание УПД внутри договора не является
garbage, а заголовок документа `УПД № ...` является garbage.
Версия приложения повышена до `2.0.20`, cache-buster обновлён в
`site/templates/index.html`.
@@ -0,0 +1,71 @@
# Сессия 2026-08-17 — HTTP 502 при парсинге 19 МБ PDF = OOMKill пода
_Стенд: ТЕСТ, `contractor.pythonk8s.dev.nubes.ru` (приставка `dev.nubes.ru` общая для dev+test).
instanceUid: `b4523aba-b5e6-40f1-be56-bb4d2509357c`, realm `iot-naeel`, domain `contractor`._
## 1. Симптом (из UI, колонка «Парсинг»)
Файл `0144-03-2023_отчет об оценке.pdf` (19.0 МБ) в списке **дважды**:
- первый проход: **✗ HTTP 502**;
- повторный: **✓ 4083 эл. (0.0с)**.
Остальные файлы (623 КБ, 473 КБ, 596 КБ) парсились нормально.
Вывод: загрузка файла проходит, падает **парсинг** (тяжёлая операция в запросе).
## 2. Диагностика kubectl (с ВМ remote-dev = 5.172.178.213)
```
kubectl get pods -n b4523aba-...
pythonk8s-589db8db9c-qjjnc 1/1 Running 1 (5m18s ago) 17m
```
`kubectl describe pod`:
```
Last State: Terminated
Reason: OOMKilled
Exit Code: 137
Restart Count: 1
Limits: cpu: 1, memory: 1Gi
Requests: cpu: 1, memory: 1Gi
```
`kubectl top pod` (текущий под, простое): **727Mi из 1Gi (~71%)**.
События:
```
17m Killing pod/pythonk8s-5895c478b5-7dcqq — Stopping container app
```
(убитый OOM-ом под; текущий `pythonk8s-589db8db9c-qjjnc` — новый).
Логи прежнего контейнера (`--previous`): обычные запросы (health, batch-progress,
apply-groups, process-v2, cleanup), обрыв на 14:13:14 → OOM.
## 3. Вывод (по фактам)
**HTTP 502 = OOMKill пода (exit 137).** Синхронный парсинг 19 МБ PDF
(`site/routes/upload_bp.py`: `parse_file``set_parsed` в том же запросе,
pdfplumber → 4083 элемента) превышает лимит памяти **1Gi** → kubelet убивает под →
шлюз не получает ответ → 502. После рестарта повторный парсинг проходит.
- **Это НЕ** проблема сети/размера тела (как 64KB на `services`), а **нехватка памяти**.
- Базовое потребление уже ~71% лимита, парсинг большого PDF добивает под.
## 4. Связь с прежними находками
| Симптом | Причина | Платформа |
|---|---|---|
| «64KB / HTTP 000, Сеть» (исторически) | ddos-guard + таймауты Ingress + медленная обработка | `pythonk8s.services.ngcloud.ru` |
| HTTP 502 на 19 МБ PDF (сейчас) | OOMKill: лимит 1Gi, синхронный парсинг | `pythonk8s.dev.nubes.ru` (ТЕСТ) |
## 5. Варианты решения (НЕ ВНЕСЕНЫ, ждут команды)
1. Поднять память инстанса: `clusterConfiguration.memory` 1024 → 2048 (быстрый обходной путь).
2. Убрать синхронный парсинг из `/upload` — парсинг в фон (thread / отдельный endpoint),
ответ сразу; снижает пик памяти в запросе (правильный фикс).
3. Оба варианта.
## 6. Статус (следующий шаг)
Пользователь планирует **редеплой на обычном облачном кластере со стандартными настройками**
(не на своём `iot-naeel`) — проверить, как ведёт себя парсинг 19 МБ при стандартных лимитах.
Результат сравнить с данным диагностикой.
@@ -0,0 +1,81 @@
# Ответ Соннета: code review pipeline сверки
Дата получения: 2026-08-27
Источник: пользовательский attachment `Pasted text #1`
Статус: внешний отчёт, не подтверждённый текущим агентом
## Область
Соннет анализировал pipeline собственно сверки: `process.py`, `pipeline_bp.py`, `compare.js`, `app.js`, `llm.py`, `llm_client.py`, `llm_prompt.py`, `spec_events.py`, `spec_current.py`, `supplements.py`, `connection.py`, `metrics.py` и связанные тесты. Upload, ZIP, парсинг, классификация и grouping заявлены как исключённые из scope.
## Заявленные findings
### BLOCKER
- **B-1:** backend выдаёт событие `complete`, frontend обрабатывает только `done`; успешная сверка отображается как ошибка соединения.
- **B-2:** `apply_ops()` якобы не имеет единой транзакции; при сбое возможен частичный commit и недостоверная summary.
- **B-3:** UPDATE поля `name` якобы не пересчитывает `name_hash`, что может привести к дублированию строки при следующем документе.
### HIGH
- **H-1:** ветка неизвестного action якобы не увеличивает `seq`.
- **H-2:** summary `apply_ops()` якобы не содержит `unresolved`, поэтому счётчик недоступен UI.
- **H-3:** результат `check_arithmetic()` игнорируется; арифметическая ошибка не попадает в SSE/UI.
- **H-4:** `full_replace` с пустым `ops` якобы сначала очищает current state и затем успешно применяет ноль операций.
### MEDIUM
- **M-1:** pipeline безусловно завершает работу событием `complete`, даже после ошибок всех документов.
- **M-2:** отсутствует блокировка двух одновременных сравнений одного `contract_id`.
- **M-3:** отсутствует клиентский timeout SSE.
- **M-4:** `last_event_id` в `spec_current` якобы никогда не заполняется, поэтому cleanup supplement не работает.
### LOW
- **L-1:** сортировка по `doc_date` и `created_at` в `process.py` использует поля, которые якобы не возвращаются `supplements.list_by_contract()`.
- **L-2:** пустой `current_spec` не различает штатный первый документ и состояние после ошибки.
## Ответы Соннета на обязательные вопросы
1. Допник без базового договора не фильтруется; при пустом current state получает extract prompt и может быть ошибочно обработан как базовый документ.
2. `run_pipeline()` считает сверку успешной при наличии хотя бы одного supplement и безусловно выдаёт финальное событие.
3. Да, финальное событие может прийти после `extract_error`, включая случай, когда ошиблись все документы.
4. `complete` против `done` объявлено реальным frontend/backend багом.
5. Пустой `full_replace` очищает спецификацию; это признано опасным.
6. Частичный `apply_ops()` якобы остаётся в БД, но UI получает `extract_error` и неверную нулевую summary.
7. Единой транзакции current state и event history, по мнению Соннета, нет.
8. Повторный запуск сначала очищает состояние; последовательный запуск может дать чистый результат, конкурентный опасен.
9. Конкурентные pipeline для одного договора создают race condition и риск коллизий `seq`.
10. Пустой корректный ответ и повреждённый/неполный ответ не различаются.
11. `UNRESOLVED` сохраняется в `spec_events`, но не отображается пользователю и не включается в summary.
12. Арифметическая ошибка может остаться в БД, а pipeline всё равно завершиться успешно.
13. Нужны тесты для финального события, rollback, смены name, пустого full_replace, unknown action, unresolved summary, arithmetic warning и concurrency.
14. Главные риски: частичные commits, дублирование после смены name, уничтожение состояния при пустом full_replace, ложная ошибка UI и отсутствие защиты от concurrency.
## Предложенный Соннетом порядок исправлений
1. `complete` -> `done`.
2. Пересчёт `name_hash` при изменении `name`/`date_start`.
3. Запрет пустого `full_replace` до очистки state.
4. Единая транзакция для `apply_ops()`.
5. Инкремент `seq` для неизвестного action.
6. Счётчик `unresolved` и `total_unresolved`.
7. SSE warning для arithmetic mismatch.
8. `had_errors` в финальном событии.
9. Заполнение `last_event_id`.
## Что не является подтверждённым фактом
Этот файл фиксирует именно ответ Соннета. Ни один finding здесь не следует считать основанием для изменения кода до повторной проверки:
- по исходникам;
- по фактической схеме БД и реализации connection layer;
- по тестам;
- по реальному frontend/backend event contract;
- по воспроизводимому сценарию.
Следующий этап: критическая повторная проверка каждого finding и уточнение вопросов Соннету только там, где в его отчёте останется неразрешённое противоречие или отсутствует доказательство.
## Уточнение пользователя
Соннет используется только для анализа. Он не должен писать код, патчи или diff, изменять файлы либо выполнять команды: это ограничение введено для контроля стоимости. Реализацию и проверки выполняем отдельно после критической перепроверки findings.
@@ -0,0 +1,231 @@
# Промпт для Соннета: code review только собственно сверки
Нужно провести строгий code review **только той части системы, которая выполняет собственно сверку документов после формирования группы**.
Не анализируй и не ревьюируй upload, WebDAV/VM upload, ZIP-распаковку, выбор папки, дедупликацию файлов, парсинг DOC/DOCX, garbage-фильтры, классификацию документов, определение типа документа, matching/grouping документов и UI загрузки. Эти части находятся вне области данного ревью.
## Контекст
Система получает уже сформированную группу договора и связанных документов. Затем она должна:
1. определить порядок документов группы;
2. получить текущую спецификацию;
3. передать текущую спецификацию и текст очередного документа в LLM;
4. получить операции `ADD`, `UPDATE`, `DELETE`, `UNRESOLVED` или режим `full_replace`;
5. корректно транслировать ссылки LLM на строки текущей спецификации;
6. применить операции к БД и сохранить историю событий;
7. отдать прогресс и результат через SSE;
8. показать пользователю фактический итог сверки.
Особенно проверь, что система не выдаёт успешный результат, если часть операций или документов фактически не обработана.
## Файлы для обязательного изучения
Изучи только эти файлы и их прямые зависимости, необходимые для понимания сверки:
1. `contracts-flask/site/services/process.py`
- основной pipeline сверки;
- порядок документов;
- получение текущей спецификации;
- подготовка текста документа;
- вызов LLM;
- трансляция `target_id` в `target_hash`;
- обработка `full_replace`;
- применение операций;
- арифметическая проверка;
- SSE-события логического pipeline.
2. `contracts-flask/site/routes/pipeline_bp.py`
- endpoint `GET /process-v2`;
- генерация SSE;
- heartbeat;
- закрытие/обрыв соединения;
- преобразование исключений в SSE-события;
- различие HTTP-ошибки до начала стрима и ошибки внутри стрима.
3. `contracts-flask/site/static/compare.js`
- `startCompareSSE()`;
- обработка `extract_start`, `llm_done`, `applied`, `extract_error`, `apply_error`, `done`, `error`;
- обработка `EventSource.onerror`;
- закрытие EventSource;
- соответствие frontend-событий backend-событиям.
4. `contracts-flask/site/static/app.js`
- функции запуска сверки группы;
- вызов `/api/apply-groups`;
- получение `contract_id`;
- запуск `/process-v2`;
- обработка success/error/connection error;
- изменение состояния группы после завершения.
5. `contracts-flask/site/services/llm.py`
- фактический вызов LLM для сверки;
- формат prompt и ответа;
- timeout/retry;
- обработка невалидного ответа;
- возможная потеря или искажение операций.
6. `contracts-flask/site/llm_prompt.py`
- prompt сверки;
- формат текущей спецификации;
- формат ожидаемых операций;
- ограничения для `ADD`, `UPDATE`, `DELETE`, `UNRESOLVED`, `full_replace`.
7. `contracts-flask/site/db/spec_events.py`
- `reset()`;
- `clear_current()`;
- `apply_ops()`;
- транзакции и атомарность;
- сохранение истории;
- статусы применённых и неразрешённых операций;
- поведение при частичной ошибке.
8. `contracts-flask/site/db/spec_current.py`
- получение текущих строк спецификации;
- идентификаторы и `name_hash`;
- получение `elements_json`;
- согласованность данных между текущим состоянием и историей.
9. `contracts-flask/site/db/supplements.py`
- только функции, используемые `process.py` для получения документов уже сформированной группы;
- порядок и фильтрация документов;
- отсутствие/дублирование документов.
10. `contracts-flask/site/services/metrics.py`
- `check_arithmetic()`;
- что именно проверяется;
- может ли ошибка арифметики повлиять на результат;
- почему проверка не должна маскировать ошибку применения.
## Тесты для обязательного изучения
1. `contracts-flask/tests/test_process_pipeline.py`
- какие сценарии реально покрыты;
- корректность `full_replace`;
- корректность трансляции `target_id`;
- отсутствие тестов на реальные SSE и ошибки LLM.
2. `contracts-flask/tests/test_grouping.py`
- только часть, необходимая для понимания структуры группы, передаваемой в `apply_groups`.
3. `contracts-flask/tests/test_metrics.py`
- только тесты арифметической проверки, относящиеся к операциям сверки.
4. Найди все остальные тесты, которые напрямую вызывают `run_pipeline`, `apply_ops`, `process-v2` или `startCompareSSE`, и включи их в анализ только если они действительно относятся к собственно сверке.
## Что проверять
### 1. Корректность алгоритма сверки
- Не теряется ли первая спецификация или базовое состояние.
- Правильно ли строится `current_spec` перед каждым следующим документом.
- Гарантирован ли правильный порядок обработки документов.
- Не зависит ли порядок от нестабильного `created_at` или формата даты.
- Корректно ли работает переход между несколькими документами группы.
- Не дублируются ли строки при повторном запуске.
- Корректно ли работает `full_replace` при уже существующих строках.
- Может ли `full_replace` удалить корректные данные при ошибочном/неполном ответе LLM.
### 2. Операции LLM
- Как `target_id` переводится в фактический идентификатор строки.
- Что происходит при `r0`, `r999`, `rX`, отсутствующем `target_id`, неверном `target_hash`.
- Не может ли LLM обновить не ту строку из-за изменения порядка строк.
- Что происходит с неизвестными действиями.
- Что происходит с отсутствующими полями `name`, `price`, `qty`, `sum`, `date_start`.
- Не приводит ли частично валидная операция к тихой потере данных.
- Сохраняется ли исходный ответ LLM и prompt для аудита.
- Как обрабатываются пустой, обрезанный, markdown-обёрнутый или частично повреждённый JSON-ответ.
### 3. БД, транзакции и атомарность
- Атомарно ли применяются операции одного документа.
- Что происходит, если пятая операция из десяти падает.
- Может ли current state измениться, а event history не сохраниться, или наоборот.
- Не приводит ли `reset()` к потере истории при повторном запуске.
- Различаются ли `applied`, `unresolved`, `failed` и действительно ли эти статусы отражают результат.
- Безопасен ли параллельный запуск двух сравнений одного договора.
- Безопасен ли одновременный запуск сравнений разных групп.
- Есть ли race condition между `apply-groups`, `/process-v2` и frontend state.
### 4. SSE и сетевые ошибки
- Совпадает ли событие завершения backend (`complete` или `done`) с событием, которое ожидает frontend.
- Может ли frontend навсегда оставить сравнение в состоянии `⏳`.
- Что происходит при disconnect после `applied`, но до события завершения.
- Что происходит при heartbeat без данных.
- Может ли `EventSource.onerror` ошибочно объявить ошибку после нормального закрытия.
- Показывает ли UI частичный результат как полный.
- Есть ли таймаут на клиенте и сервере.
- Возвращается ли пользователю причина ошибки LLM/API, а не только «Ошибка соединения».
- Не теряются ли последние SSE-события из-за proxy buffering.
### 5. Числовая и предметная корректность
- Проверяется ли арифметика `price * qty = sum` до применения и после применения.
- Как обрабатываются `None`, строки с запятой, целые/дробные числа, отрицательные значения и большие числа.
- Не изменяет ли арифметическая проверка данные или только логирует ошибку.
- Может ли LLM вернуть арифметически неверную операцию, которая всё равно попадёт в current state.
- Сохраняется ли точность Decimal/float.
- Как обрабатываются даты и отсутствие даты.
### 6. Повторяемость и идемпотентность
- Что произойдёт при повторном нажатии «Сравнить эту группу».
- Можно ли безопасно повторить сравнение после сетевого обрыва.
- Будут ли повторно добавлены те же строки.
- Как отделяется новый запуск от предыдущей истории.
- Есть ли идентификатор запуска и защита от повторного применения одного ответа.
## Обязательные вопросы от ревьюера
Ответь отдельно на следующие вопросы, даже если для ответа придётся изучить прямую зависимость:
1. Почему в реальном тесте один допник смог попасть в группу без базового договора, и может ли собственно pipeline сверки безопасно обработать такую неполную группу?
2. При каком именно условии `run_pipeline()` считает сверку успешной?
3. Может ли pipeline отправить `complete`, если один документ дал `extract_error` или часть операций не применилась?
4. Почему backend использует событие `complete`, а frontend-код может ожидать `done`? Это реальный баг или только устаревший комментарий/другая ветка?
5. Если LLM вернул `full_replace` с пустым `ops`, будет ли текущая спецификация очищена? Должно ли так происходить?
6. Если `apply_ops()` применил только часть операций, как это отражается в SSE и UI?
7. Есть ли транзакция, гарантирующая согласованность `spec_current` и `spec_events`?
8. Можно ли повторно запустить `/process-v2` для того же `contract_id` без дублирования или повреждения результата?
9. Что происходит при одновременном сравнении двух групп, относящихся к одному договору?
10. Как система отличает «LLM вернул корректный пустой результат» от «LLM вернул повреждённый/неполный ответ»?
11. Какие операции считаются `UNRESOLVED`, где они сохраняются и видит ли их пользователь?
12. Может ли `check_arithmetic()` обнаружить ошибку, но pipeline всё равно завершиться успешно?
13. Какой минимальный набор интеграционных тестов нужен для доказательства корректности реальной сверки?
14. Какие риски остаются именно в собственно сверке после исключения upload/classify/grouping из области анализа?
## Формат отчёта
Пиши отчёт в формате code review.
Сначала findings, отсортированные по серьёзности:
- `BLOCKER` — возможна потеря/порча данных или ложный успешный результат сверки;
- `HIGH` — неверное применение операций, нарушение атомарности, повторное применение или потеря результата;
- `MEDIUM` — ошибочное отображение статуса, частичный результат, нестабильность или отсутствие важной защиты;
- `LOW` — локальная проблема качества, диагностики или сопровождаемости.
Для каждого finding укажи:
- severity;
- файл и конкретный символ/участок кода;
- точную последовательность, которая приводит к проблеме;
- воспроизводимый сценарий;
- фактический ущерб;
- минимальное исправление;
- обязательный тест.
Не предлагай изменения вне области собственно сверки. Не исправляй код самостоятельно. Не делай общий обзор всего проекта. Если findings нет, напиши это явно и перечисли оставшиеся пробелы тестирования.
## Жёсткое ограничение по стоимости
Не пиши код, патчи, diff и готовые реализации. Не изменяй файлы и не выполняй команды. Твоя задача — только code review: факты, findings, доказательства, вопросы и минимальные рекомендации словами. Любые предлагаемые исправления описывай концептуально, без реализации.
В конце добавь:
1. ответы на 14 обязательных вопросов;
2. таблицу покрытия тестами;
3. минимальный план исправлений, если они нужны;
4. список файлов, которые действительно были изучены.
@@ -0,0 +1,117 @@
# Критическая перепроверка ответа Соннета: pipeline сверки
Дата: 2026-08-27
Основание: ответ Соннета из `History/sonnet-review-comparison-answer-2026-08-27.md`
## Подтверждено по исходникам
### B-1: `complete` против `done`
Подтверждено.
- `site/services/process.py` завершает `run_pipeline()` событием `{"type": "complete"}`.
- `site/static/compare.js` завершает успешный SSE только в ветке `d.type === 'done'`.
- При нормальном закрытии генератора после неизвестного события клиент получает `EventSource.onerror`, поэтому успешная сверка может отображаться как ошибка соединения.
Это не предположение Соннета, а прямое несоответствие backend/frontend event contract.
### B-2: отдельные commits в `apply_ops()`
Основная часть finding подтверждена.
- `site/db/spec_events.py::apply_ops()` вызывает `execute()` для каждого события и изменения current state.
- `site/db/connection.py::execute()` делает `conn.commit()` после каждого SQL-вызова.
- В `apply_ops()` нет единой транзакции и rollback.
- Поэтому исключение после уже выполненных операций оставляет предыдущие commits в БД.
- `process.py` после исключения формирует нулевую summary и не показывает уже применённые операции как applied.
Требует отдельной проверки формулировка о том, что history и current state обязательно расходятся при каждом сценарии: это зависит от того, на каком именно SQL-вызове возникает исключение. Но частичный commit и недостоверная summary подтверждены.
### B-3: stale `name_hash`
Подтверждено.
`site/db/spec_events.py::_update_spec_current()` обновляет `name`, `price`, `qty`, `sum`, `date_start`, но не пересчитывает и не обновляет `name_hash`. При последующем ADD с новым именем `_hash()` создаёт другой ключ, поэтому сценарий с дублированием реален.
Нужно отдельно проверить бизнес-правило для изменения `date_start`: изменение даты может означать новый период и не во всех случаях должно менять identity строки. Автоматически объединять `name` и `date_start` в одно исправление нельзя без подтверждения модели идентичности.
### H-1: `seq` для неизвестного action
Подтверждено.
В ветке `else` `apply_ops()` вызывает `_log_unresolved(...)`, но не делает `seq += 1`. Следующая операция получает тот же `seq`.
### H-2: `unresolved` недоступен в summary
Подтверждено по текущим участкам.
`apply_ops()` возвращает только `added`, `updated`, `deleted`. При этом frontend использует `d.total_unresolved`, а `run_pipeline()` не формирует это поле в финальном событии.
Требует проверки полный путь отображения `UNRESOLVED`: факт отсутствия счётчика в summary подтверждён, но следует проверить, нет ли другого endpoint/UI, который показывает события напрямую.
### H-3: результат `check_arithmetic()` игнорируется
Подтверждено для `run_pipeline()`.
`process.py` вызывает `check_arithmetic(ops)` и игнорирует возвращаемое значение. Требуется дополнительно проверить, логирует ли сама функция несоответствия и является ли её контракт предупреждением или валидатором, прежде чем выбирать severity и формат исправления.
### H-4: пустой `full_replace`
Подтверждено по порядку операций.
В `process.py` `clear_current(contract_id)` вызывается после получения `mode` и до `apply_ops()`, без проверки непустого `ops`. Пустой список при `mode == 'full_replace'` очищает current state.
Нужно уточнить у Соннета, какие именно ответы считать повреждёнными: пустой `ops` может быть штатным ответом для пустой спецификации, хотя для существующего current state это опасный случай.
### M-1: финальное событие после ошибок
Подтверждено.
После цикла `run_pipeline()` безусловно выдаёт финальное событие, даже если каждый supplement завершился `extract_error`.
## Подтверждено частично или требует дополнительных доказательств
### M-2: concurrency
Риск правдоподобен, но формулировку о конкретных коллизиях `seq` нужно доказать тестом. `WAL` сериализует записи, но `get_next_seq()` и последующие операции разделены во времени. Нужен воспроизводимый конкурентный тест с двумя pipeline на одном `contract_id`.
### M-3: SSE timeout
Отсутствие клиентского timeout видно, но само по себе не доказывает пользовательский дефект: сервер отправляет heartbeat каждые 15 секунд. Нужно проверить proxy timeout, лимит длительности LLM и поведение при остановленном backend.
### M-4: `last_event_id`
Подтверждена причина риска: `spec_current` вставляется без `last_event_id`, а UPDATE также его не заполняет; cleanup в `supplements.delete_by_document()` фильтрует по этому полю. Нужен тест удаления supplement после сверки, чтобы окончательно подтвердить наблюдаемое поведение.
### L-1: сортировка
Подтверждено, что `list_by_contract()` не выбирает `doc_date` и `created_at` как поля результата, поэтому ключ сортировки в `process.py` фактически использует значения по умолчанию. При этом SQL уже сортирует по `s.created_at`, поэтому это скорее misleading code, а не доказанная поломка порядка.
### L-2: пустой current state
Технически возможно, но вывод о неправильном prompt зависит от семантики группы и контракта `build_prompt()`. Нужна точная проверка prompt и отдельный сценарий сбоя/неполной группы.
## Дополнительные вопросы Соннету
1. Для B-2: укажи точный SQL-вызов и сценарий исключения, при котором расходятся `spec_events` и `spec_current`; отдельно различи частичный commit и рассогласование двух таблиц.
2. Для B-3: должна ли смена `name` менять identity строки, или `name_hash` является историческим ключом? Какие правила действуют при одновременной смене `name` и `date_start`?
3. Для H-2: где именно пользователь должен видеть `UNRESOLVED`, если не через summary? Укажи полный frontend/backend путь и проверяемый сценарий.
4. Для H-3: что возвращает `check_arithmetic()` и есть ли у него побочный logging? Приведи фактический пример mismatch и ожидаемый бизнес-статус.
5. Для H-4: почему пустой `full_replace` однозначно считать повреждённым ответом, если документ может содержать пустую спецификацию? Как отличить штатный результат от обрыва/невалидного JSON?
6. Для M-2: предоставь воспроизводимый тест или timeline с двумя потоками, который приводит к одинаковому `seq` или повреждённому current state.
7. Для M-3: какой фактический timeout установлен на nginx/proxy и как он соотносится с heartbeat и максимальным временем обработки группы?
8. Для M-4: есть ли штатный путь удаления supplement после сверки, и должен ли он удалять строки current state, если строка затронута несколькими supplements?
9. Для L-2: приведи точный контракт `extract`/`diff` prompt и доказательство, что пустой current state после ошибки действительно меняет смысл следующего LLM-вызова.
10. Какие findings Соннет считает подтверждёнными тестом, а какие являются только статическим риском?
11. Проверь финальное событие по реальному frontend-коду: нет ли другой ветки, которая обрабатывает `complete` или завершение `EventSource` без `done`?
12. Для каждого BLOCKER укажи минимальный regression test, который сначала падает на текущей версии и проходит после исправления.
## Предварительный вывод
Без дополнительных ответов Соннета уже достаточно доказательств для регистрации B-1, B-2, B-3, H-1, H-2, H-3, H-4 и M-1 как реальных проблем текущего кода. M-2, M-3, M-4 и L-2 требуют воспроизводимых тестов или более точной трассировки. L-1 подтверждён как избыточная/вводящая в заблуждение сортировка, но не как текущая поломка порядка документов.
Изменения production-кода по этим findings не выполнялись.
## Обязательное ограничение для дальнейшего общения с Соннетом
Соннет не должен писать код, patch или diff и не должен изменять файлы. Нужно запрашивать только критический анализ, проверяемые доказательства, воспроизводимые сценарии, тестовые идеи в виде описания и дополнительные вопросы. Реализацию выполняем отдельно после собственной проверки findings.
-42
View File
@@ -1,42 +0,0 @@
"""Shared fixtures for pytest — contracts-flask decoupling tests."""
import pytest
import sys
import os
# Ensure deploy/ is on path for imports
sys.path.insert(0, os.path.dirname(__file__))
from compare.llm_client import FakeLLMClient
from repository import MemRepository
@pytest.fixture
def fake_llm():
"""Fake LLM client with pre-registered responses."""
client = FakeLLMClient()
client.add("default", '{"doc_type":"contract","own_number":"03700_1","doc_date":"2026-02-01","counterparty":"ЗАО XXX001"}')
return client
@pytest.fixture
def mem_repo():
"""In-memory repository for unit tests."""
return MemRepository()
@pytest.fixture
def sample_docx_bytes():
"""Minimal test bytes — not a real DOCX, just for multipart tests."""
return b"FAKE_DOCX_CONTENT"
@pytest.fixture
def sample_classify_response():
"""Sample LLM classify response."""
return {
"doc_type": "contract",
"own_number": "03700_1",
"parent_number": None,
"doc_date": "2026-02-01",
"counterparty": "ЗАО XXX001",
}
+16
View File
@@ -92,6 +92,22 @@ server {
client_max_body_size 100m;
}
# ── VM-буфер загрузки contracts (паттерн drhider) ──────────────
# Браузер кладёт файл сюда PUT (мимо шлюза кластера ~64КБ), бэк тянет сам.
# Файлы: /var/www/contracts-upload/ (нужно создать, chown www-data).
# CORS: origin фронтенда = contractor.pythonk8s.dev.nubes.ru (ingress, подтверждено).
location /contracts-upload/ {
alias /var/www/contracts-upload/;
dav_methods PUT DELETE;
create_full_put_path on;
client_max_body_size 1024m;
add_header Access-Control-Allow-Origin https://contractor.pythonk8s.dev.nubes.ru always;
add_header Access-Control-Allow-Methods 'PUT, GET, OPTIONS, DELETE' always;
add_header Access-Control-Allow-Headers 'Content-Type' always;
add_header Access-Control-Max-Age 86400 always;
if ($request_method = OPTIONS) { return 204; }
}
listen 443 ssl; # managed by Certbot
ssl_certificate /etc/letsencrypt/live/contracts.kube5s.ru/fullchain.pem; # managed by Certbot
ssl_certificate_key /etc/letsencrypt/live/contracts.kube5s.ru/privkey.pem; # managed by Certbot
-532
View File
@@ -1,532 +0,0 @@
/**
* tests.js — Тесты чистых функций (Фазы 0-4, decoupling-final-plan.md).
*
* Запуск: node tests.js
*
* Проверяет: statusToHTML, applyParseResult, reconcileSelection,
* renderGroupCard, renderGroupCardDone, renderUnresolvedCard,
* applyCompareEvent, renderCompareSectionHeader, renderCompareOpsTable,
* renderCompareSectionBody.
*/
// ── Моки глобальных переменных ──────────────────────────────
// Браузерные глобалы, нужные модулям при загрузке
global.window = global; // window.* присваивания
// Мок document
global.document = {
getElementById: function(id) { return null; },
createElement: function(tag) {
return {
style: {},
classList: { add: function(){}, remove: function(){} },
innerHTML: '',
textContent: '',
appendChild: function(){},
querySelector: function(){ return null; },
querySelectorAll: function(){ return []; },
addEventListener: function(){},
parentNode: { insertBefore: function(){} },
nextSibling: null
};
},
querySelector: function() { return null; }
};
// Мок crypto.randomUUID
global.crypto = { randomUUID: function() { return 'test-uuid-' + Date.now(); } };
// Мок lucide
global.lucide = { createIcons: function(){} };
// Глобальные переменные приложения
global.fileInput = { disabled: false, value: '', addEventListener: function(){}, files: [] };
global.fileTable = { innerHTML: '' };
global.VM_API = 'https://contracts.kube5s.ru';
global.UPLOAD_URL = VM_API + '/upload';
global.CONVERT_URL = VM_API + '/convert-doc';
global.UNZIP_URL = VM_API + '/unzip-upload';
global.SITE_URL = '';
var state = {
files: [],
contractId: null,
batchId: 'test-batch-id',
groups: null,
_activeCompare: { es: null, timer: null },
ui: { steps: { upload: '○', classify: '○', groups: '○', compare: '○' } }
};
// Мок escHtml (из app_utils.js)
global.escHtml = function(s) {
if (s == null) return '';
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
};
var passed = 0, failed = 0;
function assert(cond, msg) {
if (cond) { passed++; }
else { console.log(' FAIL: ' + msg); failed++; }
}
function eq(actual, expected, msg) {
if (actual === expected) { passed++; }
else { console.log(' FAIL: ' + msg + ' — expected ' + JSON.stringify(expected) + ', got ' + JSON.stringify(actual)); failed++; }
}
function contains(str, substr, msg) {
if (str.indexOf(substr) !== -1) { passed++; }
else { console.log(' FAIL: ' + msg + ' — string does not contain "' + substr + '"'); console.log(' got: ' + str.substring(0, 200)); failed++; }
}
// ── Загружаем модули ─────────────────────────────────────────
// Модули используют function declaration (глобальные в браузере).
// В Node.js загружаем через eval в глобальном контексте.
var fs = require('fs');
function loadModule(path) {
var code = fs.readFileSync(path, 'utf-8');
// (0, eval) — indirect eval, выполняется в глобальном скоупе (не strict)
(0, eval)(code);
}
console.log('=== Загрузка модулей ===');
loadModule('./files.js');
console.log(' files.js — OK');
loadModule('./groups.js');
console.log(' groups.js — OK');
loadModule('./compare.js');
console.log(' compare.js — OK');
// ── Тесты: statusToHTML ──────────────────────────────────────
console.log('\n=== statusToHTML ===');
eq(statusToHTML(null), '', 'null → пусто');
eq(statusToHTML({}), '', 'пустой объект → пусто');
eq(statusToHTML({ kind: '' }), '', 'пустой kind → пусто');
eq(statusToHTML({ kind: 'uploading', pct: 0 }), '↑ 0%', 'uploading 0%');
eq(statusToHTML({ kind: 'uploading', pct: 75 }), '↑ 75%', 'uploading 75%');
eq(statusToHTML({ kind: 'uploaded' }), '<span class="status-ok">✓</span>', 'uploaded');
eq(statusToHTML({ kind: 'unzipping' }), '⏳ распаковка...', 'unzipping');
eq(statusToHTML({ kind: 'parsing' }), '⏳ парсинг...', 'parsing');
eq(statusToHTML({ kind: 'parsed', count: 5 }), '<span class="status-ok">✓ 5 эл.</span>', 'parsed без elapsed');
eq(statusToHTML({ kind: 'parsed', count: 5, elapsed: '2.3' }), '<span class="status-ok">✓ 5 эл. (2.3с)</span>', 'parsed с elapsed');
eq(statusToHTML({ kind: 'error', text: 'тест' }), '<span class="status-err">✗ тест</span>', 'error с текстом');
eq(statusToHTML({ kind: 'error' }), '<span class="status-err">✗ Неизвестная ошибка</span>', 'error без текста');
// Краевые случаи
eq(statusToHTML(undefined), '', 'undefined → пусто');
eq(statusToHTML({ kind: 'uploading' }), '↑ 0%', 'uploading без pct → 0%');
eq(statusToHTML({ kind: 'uploading', pct: -5 }), '↑ -5%', 'uploading отрицательный pct');
eq(statusToHTML({ kind: 'uploading', pct: 100 }), '↑ 100%', 'uploading 100%');
eq(statusToHTML({ kind: 'parsed', count: 0 }), '<span class="status-ok">✓ 0 эл.</span>', 'parsed count=0');
eq(statusToHTML({ kind: 'parsed', count: 5, elapsed: '0' }), '<span class="status-ok">✓ 5 эл. (0с)</span>', 'parsed elapsed=0');
eq(statusToHTML({ kind: 'error', text: '' }), '<span class="status-err">✗ Неизвестная ошибка</span>', 'error с пустым текстом → дефолт');
eq(statusToHTML({ kind: 'unknown_kind' }), '', 'неизвестный kind → пусто');
// ── Тесты: applyParseResult ──────────────────────────────────
console.log('\n=== applyParseResult ===');
// parsed success
var e1 = {};
applyParseResult(e1, { status: 'parsed', element_count: 10 }, '1.5');
assert(e1.parsed === true, 'parsed=true');
assert(e1.parseInfo.element_count === 10, 'parseInfo сохранён');
eq(e1.status.kind, 'parsed', 'status.kind=parsed');
eq(e1.status.count, 10, 'status.count=10');
eq(e1.status.elapsed, '1.5', 'status.elapsed=1.5');
// parsed without elapsed
var e2 = {};
applyParseResult(e2, { status: 'parsed', element_count: 3 });
eq(e2.status.kind, 'parsed', 'без elapsed: kind=parsed');
eq(e2.status.count, 3, 'без elapsed: count=3');
assert(e2.status.elapsed === undefined, 'без elapsed: elapsed отсутствует');
// error
var e3 = {};
applyParseResult(e3, { status: 'error', error: 'битый PDF' });
eq(e3.status.kind, 'error', 'error: kind=error');
eq(e3.status.text, 'битый PDF', 'error: text сохранён');
eq(e3.parseInfo.error, 'битый PDF', 'error: parseInfo сохранён');
// null parsed (неизвестная ошибка)
var e4 = {};
applyParseResult(e4, null);
eq(e4.status.kind, 'error', 'null: kind=error');
eq(e4.status.text, 'Неизвестная ошибка', 'null: дефолтный текст');
// parsed с пустыми полями
var e5 = {};
applyParseResult(e5, { status: 'parsed' });
eq(e5.status.kind, 'parsed', 'parsed без element_count: kind=parsed');
eq(e5.status.count, undefined, 'parsed без element_count: count=undefined');
// parsed с element_count=0
var e6 = {};
applyParseResult(e6, { status: 'parsed', element_count: 0 });
eq(e6.status.count, 0, 'parsed element_count=0');
// error без текста ошибки
var e7 = {};
applyParseResult(e7, { status: 'error' });
eq(e7.status.text, 'ошибка парсинга', 'error без текста: дефолт "ошибка парсинга"');
// entry с уже существующими полями (не должны мешать)
var e8 = { existing: true, parsed: false };
applyParseResult(e8, { status: 'parsed', element_count: 7 }, '3.0');
assert(e8.existing === true, 'старое поле не затёрто');
eq(e8.status.count, 7, 'новые данные поверх старых');
// ── Тесты: reconcileSelection ────────────────────────────────
console.log('\n=== reconcileSelection ===');
var files = [
{ name: 'a.docx' }, { name: 'b.pdf' }, { name: 'c.docx' }
];
var newFiles = [
{ name: 'a.docx' }, { name: 'c.docx' }, { name: 'd.pdf' }
];
var result = reconcileSelection(files, newFiles);
eq(result.length, 2, 'должно остаться 2 файла');
eq(result[0].name, 'a.docx', 'a.docx остался');
eq(result[1].name, 'c.docx', 'c.docx остался');
// Исходный массив не мутирован
eq(files.length, 3, 'исходный массив не мутирован');
// Пустые входы
var emptyResult = reconcileSelection([], []);
eq(emptyResult.length, 0, 'пустые массивы → пусто');
// Все совпадают
var allMatch = reconcileSelection([{name:'a'}], [{name:'a'}]);
eq(allMatch.length, 1, 'все совпадают → 1');
// Ни один не совпадает
var noMatch = reconcileSelection([{name:'a'},{name:'b'}], [{name:'c'},{name:'d'}]);
eq(noMatch.length, 0, 'нет совпадений → пусто');
// Дубликаты имён в newFiles (должен работать — Set)
var dupNames = [{name:'a'}, {name:'a'}, {name:'b'}];
var dupResult = reconcileSelection([{name:'a'},{name:'b'}], dupNames);
eq(dupResult.length, 2, 'дубликаты в newFiles — Set обрабатывает');
// ── Тесты: renderGroupCard ───────────────────────────────────
console.log('\n=== renderGroupCard ===');
var group = {
contract_number: '123',
counterparty: 'ООО Тест',
documents: [
{ doc_type: 'contract', filename: 'договор.docx', doc_date: '2024-01-15' },
{ doc_type: 'supplement', filename: 'дс1.docx' }
]
};
var html = renderGroupCard(group, 0);
contains(html, 'Договор №123', 'номер договора');
contains(html, 'ООО Тест', 'контрагент');
contains(html, 'договор', 'тип contract → договор');
contains(html, 'допсоглашение', 'тип supplement → допсоглашение');
contains(html, '2024-01-15', 'дата');
contains(html, 'data-gi="0"', 'data-gi атрибут');
contains(html, 'Сравнить эту группу', 'кнопка сравнения');
// НЕ должно быть ✓ Готово
assert(html.indexOf('✓ Готово') === -1, 'нет "✓ Готово" для необработанной');
// Группа с compare.running
var groupRunning = {
contract_number: '456',
counterparty: 'ЗАО Бег',
documents: [{ doc_type: 'contract', filename: 'дог.docx' }],
compare: { status: 'running' }
};
var htmlRunning = renderGroupCard(groupRunning, 1);
contains(htmlRunning, 'disabled', 'кнопка disabled при running');
// Без контрагента
var groupNoCP = { contract_number: '001', documents: [] };
var htmlNoCP = renderGroupCard(groupNoCP, 5);
contains(htmlNoCP, 'контрагент не определён', 'без counterparty: заглушка');
// Неизвестный doc_type
var groupUnknown = { contract_number: 'X', counterparty: 'Y', documents: [{ doc_type: 'unknown_type', filename: 'f.docx' }] };
var htmlUnknown = renderGroupCard(groupUnknown, 6);
contains(htmlUnknown, 'unknown_type', 'неизвестный тип: выводится как есть');
// Пустой список документов
var groupEmpty = { contract_number: 'E', counterparty: 'Empty', documents: [] };
var htmlEmpty = renderGroupCard(groupEmpty, 7);
contains(htmlEmpty, 'Договор №E', 'пустые документы: карточка рендерится');
contains(htmlEmpty, 'Сравнить', 'пустые документы: кнопка есть');
// ── Тесты: renderGroupCardDone ───────────────────────────────
console.log('\n=== renderGroupCardDone ===');
var groupDone = {
contract_number: '789',
counterparty: 'ИП Готово',
documents: [
{ doc_type: 'contract', filename: 'base.docx' },
{ doc_type: 'specification', filename: 'spec.docx' }
],
compare: {
status: 'done',
totalTime: '12.5с',
bodyHTML: '<div>результаты сравнения</div>'
}
};
var htmlDone = renderGroupCardDone(groupDone, 2);
contains(htmlDone, '✓ Готово (12.5с)', '✓ Готово с временем');
contains(htmlDone, 'cmpArrow_2', 'стрелка сворачивания');
contains(htmlDone, 'спецификация', 'тип specification → спецификация');
contains(htmlDone, 'результаты сравнения', 'bodyHTML вставлен');
contains(htmlDone, 'data-gi="2"', 'data-gi на заголовке');
// ── Тесты: renderUnresolvedCard ──────────────────────────────
console.log('\n=== renderUnresolvedCard ===');
var unresolved = {
contract_number: '__unresolved__',
documents: [
{ doc_type: 'other', filename: 'unknown.docx', parent_number: '999' },
{ doc_type: 'contract', filename: 'bad.docx', classify_status: 'failed', error_message: 'LLM error' }
]
};
var htmlUnres = renderUnresolvedCard(unresolved);
contains(htmlUnres, 'Не распознано', 'заголовок нераспознанных');
contains(htmlUnres, 'нет базового договора №999', 'причина: нет базового');
contains(htmlUnres, 'ошибка классификации: LLM error', 'причина: ошибка классификации');
// ── Тесты: applyCompareEvent ─────────────────────────────────
console.log('\n=== applyCompareEvent ===');
var sections = {};
// extract_start
applyCompareEvent(sections, { type: 'extract_start', supplement_id: 's1', filename: 'test.docx' });
assert(sections['s1'] !== undefined, 'секция создана');
eq(sections['s1'].filename, 'test.docx', 'filename сохранён');
eq(sections['s1'].status, 'extracting', 'status=extracting');
// llm_done
applyCompareEvent(sections, { type: 'llm_done', supplement_id: 's1', ops_count: 15, mode: 'llm', time_s: 3.2 });
eq(sections['s1'].status, 'llm_done', 'status=llm_done');
eq(sections['s1'].ops_count, 15, 'ops_count=15');
eq(sections['s1'].mode, 'llm', 'mode=llm');
eq(sections['s1'].time_s, 3.2, 'time_s=3.2');
// applied
applyCompareEvent(sections, {
type: 'applied', supplement_id: 's1',
summary: { added: 5, updated: 2, deleted: 1 },
ops: [{ action: 'ADD', new_row: { name: 'Услуга 1' } }]
});
eq(sections['s1'].status, 'applied', 'status=applied');
eq(sections['s1'].summary.added, 5, 'summary.added=5');
eq(sections['s1'].ops.length, 1, 'ops.length=1');
// extract_error на существующей секции
applyCompareEvent(sections, { type: 'extract_error', supplement_id: 's1', error: 'parse failed' });
eq(sections['s1'].status, 'error', 'после ошибки: status=error');
eq(sections['s1'].error, 'parse failed', 'текст ошибки');
// extract_error на НЕсуществующей секции
applyCompareEvent(sections, { type: 'extract_error', supplement_id: 's2', filename: 'bad.docx', error: 'boom' });
eq(sections['s2'].status, 'error', 'новая секция с ошибкой');
eq(sections['s2'].filename, 'bad.docx', 'filename для ошибочной секции');
// Неизвестный тип события — не должен падать
applyCompareEvent(sections, { type: 'unknown_type', supplement_id: 's3' });
assert(sections['s3'] === undefined, 'неизвестный тип: секция НЕ создана');
// llm_done для несуществующей секции — не должен падать
applyCompareEvent(sections, { type: 'llm_done', supplement_id: 's99', ops_count: 1 });
// apply_error (должен работать как extract_error)
applyCompareEvent(sections, { type: 'apply_error', supplement_id: 's4', filename: 'apply_err.docx', error: 'apply boom' });
eq(sections['s4'].status, 'error', 'apply_error: секция с ошибкой создана');
eq(sections['s4'].error, 'apply boom', 'apply_error: текст ошибки');
// applied без summary
applyCompareEvent(sections, { type: 'applied', supplement_id: 's1', ops: [] });
eq(sections['s1'].status, 'applied', 'applied без summary: status ок');
eq(sections['s1'].ops.length, 0, 'applied с пустыми ops');
// ── Тесты: renderCompareSectionHeader ────────────────────────
console.log('\n=== renderCompareSectionHeader ===');
contains(renderCompareSectionHeader({ filename: 'f.docx', status: 'extracting' }), '⏳', 'extracting: ⏳');
contains(renderCompareSectionHeader({ filename: 'f.docx', status: 'llm_done', ops_count: 5, mode: 'llm', time_s: 2 }), '✓', 'llm_done: ✓');
contains(renderCompareSectionHeader({ filename: 'f.docx', status: 'llm_done', ops_count: 5, mode: 'llm', time_s: 2 }), '5 оп.', 'llm_done: ops_count');
contains(renderCompareSectionHeader({ filename: 'f.docx', status: 'error', error: 'fail' }), '✗', 'error: ✗');
contains(renderCompareSectionHeader({ filename: 'f.docx', status: 'error', error: 'fail' }), 'fail', 'error: текст');
// ── Тесты: renderCompareOpsTable ─────────────────────────────
console.log('\n=== renderCompareOpsTable ===');
var ops = [
{ action: 'ADD', new_row: { name: 'Стойка', price: 100, qty: 2, sum: 200, date_start: '2024-01-01' } },
{ action: 'DELETE', new_row: { name: 'IP', price: 50, qty: 1, sum: 50, date_start: '' } }
];
var tableHtml = renderCompareOpsTable(ops);
contains(tableHtml, 'diff-added', 'ADD → diff-added');
contains(tableHtml, 'diff-deleted', 'DELETE → diff-deleted');
contains(tableHtml, 'Стойка', 'имя услуги');
contains(tableHtml, '100', 'цена');
contains(tableHtml, '2024-01-01', 'дата');
// Пустые ops
var emptyTable = renderCompareOpsTable([]);
contains(emptyTable, '<table', 'пустые ops: таблица рендерится');
contains(emptyTable, '<tbody>', 'пустые ops: tbody есть');
// ops с null new_row
var nullRow = renderCompareOpsTable([{ action: 'ADD' }]);
contains(nullRow, '<td></td>', 'null new_row: пустые ячейки (не падает)');
// ops с неизвестным action (без класса)
var unknownAct = renderCompareOpsTable([{ action: 'MERGE', new_row: {} }]);
assert(unknownAct.indexOf('diff-added') === -1, 'MERGE: нет diff-added');
assert(unknownAct.indexOf('diff-deleted') === -1, 'MERGE: нет diff-deleted');
assert(unknownAct.indexOf('diff-changed') === -1, 'MERGE: нет diff-changed');
// ── Тесты: renderCompareSectionBody ──────────────────────────
console.log('\n=== renderCompareSectionBody ===');
eq(renderCompareSectionBody(null), '', 'null → пусто');
eq(renderCompareSectionBody({}), '', 'пустой → пусто');
eq(renderCompareSectionBody({ status: 'llm_done' }), '', 'llm_done → пусто');
var secApplied = {
status: 'applied',
summary: { added: 3, updated: 1, deleted: 0, unresolved: 2 },
ops: [{ action: 'UPDATE', new_row: { name: 'Стойка 42U' } }]
};
var bodyHtml = renderCompareSectionBody(secApplied);
contains(bodyHtml, '+3', 'added=3');
contains(bodyHtml, '~1', 'updated=1');
contains(bodyHtml, '-0', 'deleted=0');
contains(bodyHtml, '?2', 'unresolved=2');
contains(bodyHtml, 'diff-changed', 'UPDATE → diff-changed');
// Без unresolved
var secNoUnresolved = { status: 'applied', summary: { added: 1, updated: 0, deleted: 0 }, ops: [] };
var bodyNoUnr = renderCompareSectionBody(secNoUnresolved);
assert(bodyNoUnr.indexOf('?') === -1, 'без unresolved: нет знака ?');
// Отрицательные значения summary (не должно быть, но не падать)
var secNeg = { status: 'applied', summary: { added: -1, updated: 0, deleted: 0 }, ops: [{ action: 'ADD', new_row: {} }] };
var bodyNeg = renderCompareSectionBody(secNeg);
contains(bodyNeg, '+-1', 'отрицательные summary: рендерится без ошибок');
// ops с частичным new_row (не все поля)
var secPartial = { status: 'applied', summary: { added: 1 }, ops: [
{ action: 'ADD', new_row: { name: 'Только имя' } }
]};
var bodyPartial = renderCompareSectionBody(secPartial);
contains(bodyPartial, 'Только имя', 'частичный new_row: имя есть');
// Проверим что нет undefined в выводе
assert(bodyPartial.indexOf('undefined') === -1, 'нет undefined в выводе');
// ── Тесты: escHtml ───────────────────────────────────────────
console.log('\n=== escHtml ===');
eq(escHtml(null), '', 'escHtml null → пусто');
eq(escHtml(undefined), '', 'escHtml undefined → пусто');
eq(escHtml('hello'), 'hello', 'escHtml обычный текст');
eq(escHtml('<script>'), '&lt;script&gt;', 'escHtml экранирует < >');
eq(escHtml('a&b'), 'a&amp;b', 'escHtml экранирует &');
eq(escHtml('"quote"'), '&quot;quote&quot;', 'escHtml экранирует кавычки');
eq(escHtml(123), '123', 'escHtml число → строка');
// ── Дымные тесты API (бэкенд) ────────────────────────────────
console.log('\n=== API smoke tests ===');
var http = require('http');
var https = require('https');
function apiTest(method, url, cb) {
var mod = url.startsWith('https') ? https : http;
var req = mod.request(url, { method: method, timeout: 5000, rejectUnauthorized: false }, function(res) {
var body = '';
res.on('data', function(c) { body += c; });
res.on('end', function() { cb(null, res.statusCode, body); });
});
req.on('error', function(e) { cb(e.message); });
req.on('timeout', function() { req.destroy(); cb('timeout'); });
req.end();
}
// Эти тесты асинхронные — собираем результаты
var apiTests = [];
function addApiTest(name, method, url, check) {
apiTests.push({ name: name, method: method, url: url, check: check });
}
addApiTest('GET / (главная)', 'GET', 'https://contracts.kube5s.ru/', function(code, body) {
assert(code === 200, 'главная: HTTP 200 — got ' + code);
contains(body, '<!DOCTYPE html>', 'главная: HTML');
});
addApiTest('POST /api/cleanup', 'POST', 'https://contracts.kube5s.ru/api/cleanup', function(code, body) {
// cleanup может вернуть 200 или 500 (если БД недоступна) — оба норм для дымного теста
assert(code >= 200 && code < 600, 'cleanup: ответ получен — ' + code);
});
addApiTest('GET /static/state.js', 'GET', 'https://contracts.kube5s.ru/static/state.js', function(code, body) {
eq(code, 200, 'state.js: HTTP 200 — got ' + code);
contains(body, 'Центральное состояние', 'state.js: содержит описание');
});
addApiTest('GET /static/files.js', 'GET', 'https://contracts.kube5s.ru/static/files.js', function(code) {
eq(code, 200, 'files.js: HTTP 200 — got ' + code);
});
addApiTest('GET /static/groups.js', 'GET', 'https://contracts.kube5s.ru/static/groups.js', function(code) {
eq(code, 200, 'groups.js: HTTP 200 — got ' + code);
});
addApiTest('GET /static/compare.js', 'GET', 'https://contracts.kube5s.ru/static/compare.js', function(code) {
eq(code, 200, 'compare.js: HTTP 200 — got ' + code);
});
addApiTest('GET /static/app.js', 'GET', 'https://contracts.kube5s.ru/static/app.js', function(code) {
eq(code, 200, 'app.js: HTTP 200 — got ' + code);
});
// Запускаем API тесты последовательно (чтобы не зафлудить)
var apiIdx = 0;
function runNextApiTest() {
if (apiIdx >= apiTests.length) {
// Все API тесты готовы — выводим итоги
printResults();
return;
}
var t = apiTests[apiIdx];
apiTest(t.method, t.url, function(err, code, body) {
if (err) {
console.log(' FAIL: ' + t.name + ' — ' + err);
failed++;
} else {
t.check(code, body || '');
}
apiIdx++;
runNextApiTest();
});
}
function printResults() {
console.log('\n========================================');
console.log('PASS: ' + passed);
console.log('FAIL: ' + failed);
console.log('========================================');
if (failed > 0) process.exit(1);
}
// Начинаем с API тестов после синхронных
console.log(' (асинхронные — ждём...)');
runNextApiTest();
View File
View File
@@ -1,365 +0,0 @@
"""
test_testgen_pipeline.py — Сквозной тест: testgen → parse → textify → LLM-промпт.
Пайплайн (без БД, без HTTP):
1. testgen генерирует DOCX (договор, спека, допник)
2. parse.py парсит байты → elements_json
3. process.py::_elements_to_text() → plain text для LLM
4. llm_prompt.py строит промпт → проверяем структуру
Точка входа: elements_json — именно туда приходят данные после парсинга.
"""
import io
import json
import sys
import os
import pytest
# Добавить пути для импортов
_deploy = os.path.join(os.path.dirname(__file__), "..", "..")
_testgen = os.path.join(_deploy, "..", "..", "testgen")
sys.path.insert(0, _deploy)
sys.path.insert(0, _testgen)
from compare.parse import parse_file
from compare.process import _elements_to_text
# ═══════════════════════════════════════════════════════════════
# Helpers
# ═══════════════════════════════════════════════════════════════
def _build_and_parse(build_fn, ctx):
"""Построить docx через testgen, отдать байты парсеру. Возвращает parsed dict."""
from docx import Document
doc = build_fn(ctx)
buf = io.BytesIO()
doc.save(buf)
return parse_file("test.docx", buf.getvalue())
def _elements_to_dicts(elements):
"""Привести elements к dict для сравнения (убрать лишнее)."""
result = []
for el in elements:
d = {"type": el["type"]}
if el["type"] == "paragraph":
d["text"] = el.get("text", "")
elif el["type"] == "table":
d["row_count"] = len(el.get("rows", []))
d["col_count"] = len(el["rows"][0]) if el.get("rows") else 0
d["first_cell"] = el["rows"][0][0] if el.get("rows") and el["rows"][0] else ""
result.append(d)
return result
# ═══════════════════════════════════════════════════════════════
# Tests
# ═══════════════════════════════════════════════════════════════
class TestGenerateParseTextify:
"""Полный пайплайн: testgen → parse → textify."""
# ── Данные ────────────────────────────────────────────────
@pytest.fixture
def pools(self):
import pools
return pools
@pytest.fixture
def templates(self):
import templates
return templates
@pytest.fixture
def contract_ctx(self, pools):
ci = 0
c = pools.COMPANIES[ci]
num = pools.contract_number(ci)
date = pools.random_date()
return {
"company": c, "number": num, "date": date, "company_idx": ci,
"comment": "Тестовый контракт для пайплайна.",
}
@pytest.fixture
def spec_ctx(self, pools):
ci = 0
c = pools.COMPANIES[ci]
num = pools.contract_number(ci)
date = pools.random_date()
svcs, total, total_str = pools.pick_services(5)
for s in svcs:
s["date_start"] = pools.random_date(2026, 3, 3)
return {
"company": c, "number": num, "date": date, "contract_date": date,
"version": 1, "services": svcs, "total": total, "total_str": total_str,
"label": f"Абонентские услуги с {date}",
"comment": "Тестовая спецификация для пайплайна.",
}
@pytest.fixture
def addendum_ctx(self, pools):
ci = 0
c = pools.COMPANIES[ci]
num = pools.contract_number(ci)
date = pools.random_date()
inst, itot, itot_s = pools.pick_services(2)
mon, mtot, mtot_s = pools.pick_services(3)
return {
"company": c, "number": num, "date": date, "contract_date": pools.random_date(),
"addendum_num": 1,
"services_install": inst, "install_total": itot, "install_total_str": itot_s,
"services_monthly": mon, "monthly_total": mtot, "monthly_total_str": mtot_s,
"comment": "Тестовый допник для пайплайна.",
}
# ── Этап 1: Генерация → парсинг ───────────────────────────
def test_generate_contract_parse_ok(self, templates, contract_ctx):
"""Договор: генерируется и парсится без ошибок."""
result = _build_and_parse(templates.build_contract, contract_ctx)
assert result["status"] == "parsed", f"Parse failed: {result.get('error')}"
assert result["element_count"] > 0
def test_generate_contract_has_paragraphs(self, templates, contract_ctx):
"""Договор: в elements есть paragraphs."""
result = _build_and_parse(templates.build_contract, contract_ctx)
paragraphs = [e for e in result["elements"] if e["type"] == "paragraph"]
assert len(paragraphs) >= 3, f"Expected >=3 paragraphs, got {len(paragraphs)}"
def test_generate_contract_has_tables(self, templates, contract_ctx):
"""Договор: в elements есть таблица реквизитов."""
result = _build_and_parse(templates.build_contract, contract_ctx)
tables = [e for e in result["elements"] if e["type"] == "table"]
assert len(tables) >= 1, f"Expected >=1 table, got {len(tables)}"
def test_generate_spec_parse_ok(self, templates, spec_ctx):
"""Спецификация: генерируется и парсится без ошибок."""
result = _build_and_parse(templates.build_spec, spec_ctx)
assert result["status"] == "parsed"
assert result["element_count"] > 0
def test_generate_spec_has_services_table(self, templates, spec_ctx):
"""Спецификация: таблица услуг найдена."""
result = _build_and_parse(templates.build_spec, spec_ctx)
tables = [e for e in result["elements"] if e["type"] == "table"]
assert len(tables) >= 1
# Первая таблица — услуги, должна быть НЕ пустой
svc_table = tables[0]
assert len(svc_table["rows"]) >= 2 # заголовок + минимум 1 строка
def test_generate_addendum_parse_ok(self, templates, addendum_ctx):
"""Допсоглашение: генерируется и парсится без ошибок."""
result = _build_and_parse(templates.build_addendum, addendum_ctx)
assert result["status"] == "parsed"
assert result["element_count"] > 0
def test_generate_addendum_has_tables(self, templates, addendum_ctx):
"""Допсоглашение: таблицы разовых и абонентских услуг."""
result = _build_and_parse(templates.build_addendum, addendum_ctx)
tables = [e for e in result["elements"] if e["type"] == "table"]
# Должно быть минимум 3 таблицы: реквизиты + разовые + абонентские
assert len(tables) >= 3, f"Expected >=3 tables, got {len(tables)}"
# ── Этап 2: elements_json → textify ────────────────────────
def test_textify_contract(self, templates, contract_ctx):
"""Договор after textify содержит ключевые слова."""
result = _build_and_parse(templates.build_contract, contract_ctx)
text = _elements_to_text(result["elements"])
assert "Договор" in text or "ДОГОВОР" in text
assert "НУБЕС" in text
assert len(text) > 200, f"Text too short: {len(text)} chars"
def test_textify_spec_contains_table_markers(self, templates, spec_ctx):
"""Спецификация after textify: пайп-таблицы на месте."""
result = _build_and_parse(templates.build_spec, spec_ctx)
text = _elements_to_text(result["elements"])
# Должны быть маркеры таблиц (--- Таблица ... ---)
assert "Таблица" in text
# Должны быть пайпы (формат ячеек)
assert "|" in text
# Должны быть названия услуг
for s in spec_ctx["services"]:
# Проверяем первые 30 символов названия
short_name = s["name"][:30]
assert short_name in text, f"Service '{short_name}' not found in textified output"
def test_textify_spec_contains_total(self, templates, spec_ctx):
"""Спецификация: итоговая сумма в тексте."""
result = _build_and_parse(templates.build_spec, spec_ctx)
text = _elements_to_text(result["elements"])
assert "Итого" in text
def test_textify_addendum_structure(self, templates, addendum_ctx):
"""Допсоглашение after textify: структура сохраняется."""
result = _build_and_parse(templates.build_addendum, addendum_ctx)
text = _elements_to_text(result["elements"])
assert "Соглашение" in text or "СОГЛАШЕНИЕ" in text
assert "Инсталляц" in text or "Разовые" in text
assert "Абонентские" in text
# ── Этап 3: elements_json → промпт LLM ─────────────────────
def test_build_extract_prompt_from_elements(self, templates, spec_ctx):
"""Из elements_json спецификации строится extract-промпт.
Пустая current_spec → extract mode (FALLBACK_EXTRACT)."""
from llm_prompt import build_prompt
result = _build_and_parse(templates.build_spec, spec_ctx)
doc_text = _elements_to_text(result["elements"])
# Пустая current_spec = первый документ = extract-промпт
prompt_text, prompt_id = build_prompt([], doc_text)
assert len(prompt_text) > 100
assert "{doc_text}" not in prompt_text # переменная подставлена
assert doc_text in prompt_text # текст документа внутри промпта
assert "JSON" in prompt_text or "json" in prompt_text
def test_build_diff_prompt_from_elements(self, templates, addendum_ctx):
"""Из elements_json допсоглашения строится diff-промпт.
Непустая current_spec → diff mode (FALLBACK_DIFF)."""
from llm_prompt import build_prompt
result = _build_and_parse(templates.build_addendum, addendum_ctx)
doc_text = _elements_to_text(result["elements"])
# Непустая current_spec = не первый документ = diff-промпт
current_spec = [
{"hash": "h1", "name": "Аренда стойко-места", "price": 50000, "qty": 1, "sum": 50000, "date_start": "2026-01-01"},
]
prompt_text, prompt_id = build_prompt(current_spec, doc_text)
assert len(prompt_text) > 100
assert doc_text in prompt_text
assert "UPDATE" in prompt_text or "DELETE" in prompt_text or "ADD" in prompt_text
# ── Этап 4: edge cases ─────────────────────────────────────
def test_parse_empty_docx(self):
"""Пустой docx — парсится без ошибок, но без элементов."""
from docx import Document
doc = Document()
buf = io.BytesIO()
doc.save(buf)
result = parse_file("empty.docx", buf.getvalue())
assert result["status"] == "parsed"
assert result["element_count"] == 0
def test_parse_docx_text_only(self):
"""DOCX только с текстом — корректно парсится."""
from docx import Document
doc = Document()
doc.add_paragraph("Привет мир")
doc.add_paragraph("Вторая строка")
buf = io.BytesIO()
doc.save(buf)
result = parse_file("text.docx", buf.getvalue())
assert result["status"] == "parsed"
assert result["element_count"] == 2
assert all(e["type"] == "paragraph" for e in result["elements"])
def test_parse_docx_table_only(self):
"""DOCX только с таблицей — корректно парсится."""
from docx import Document
doc = Document()
table = doc.add_table(rows=2, cols=3)
table.rows[0].cells[0].text = "A"
table.rows[0].cells[1].text = "B"
table.rows[0].cells[2].text = "C"
table.rows[1].cells[0].text = "1"
table.rows[1].cells[1].text = "2"
table.rows[1].cells[2].text = "3"
buf = io.BytesIO()
doc.save(buf)
result = parse_file("table.docx", buf.getvalue())
assert result["status"] == "parsed"
tables = [e for e in result["elements"] if e["type"] == "table"]
assert len(tables) == 1
assert tables[0]["rows"] == [["A", "B", "C"], ["1", "2", "3"]]
def test_textify_preserves_table_data(self):
"""Textify сохраняет все данные таблицы (без потерь)."""
elements = [
{"type": "table", "rows": [
["Услуга", "Цена", "Кол-во"],
["Аренда стойки", "50000", "2"],
["IP-адрес", "300", "8"],
]}
]
text = _elements_to_text(elements)
assert "Услуга" in text
assert "Аренда стойки" in text
assert "50000" in text
assert "2" in text
assert "IP-адрес" in text
assert "300" in text
assert "8" in text
# ── Этап 5: валидация manifest-подобной структуры ──────────
def test_contract_elements_structure(self, templates, contract_ctx):
"""Элементы договора имеют правильную структуру."""
result = _build_and_parse(templates.build_contract, contract_ctx)
for el in result["elements"]:
assert "type" in el
assert el["type"] in ("paragraph", "table")
if el["type"] == "paragraph":
assert "text" in el
assert isinstance(el["text"], str)
elif el["type"] == "table":
assert "rows" in el
assert isinstance(el["rows"], list)
assert len(el["rows"]) > 0
assert all(isinstance(row, list) for row in el["rows"])
def test_multiple_contracts_different_content(self, templates, pools):
"""Разные контракты дают разный elements_json."""
import hashlib
results = []
for ci in range(3):
c = pools.COMPANIES[ci]
ctx = {
"company": c, "number": pools.contract_number(ci),
"date": pools.random_date(), "company_idx": ci,
"comment": f"Контракт {ci}",
}
result = _build_and_parse(templates.build_contract, ctx)
# Сериализуем для сравнения
txt = _elements_to_text(result["elements"])
results.append(txt)
# Все три должны быть разными
hashes = [hashlib.md5(t.encode()).hexdigest() for t in results]
assert len(set(hashes)) == 3, f"All 3 contracts produced same text!"
def test_spec_v1_v2_produce_different_tables(self, templates, pools):
"""Спецификации v1 и v2 дают разные таблицы (разные цены/объёмы)."""
c = pools.COMPANIES[0]
base_ctx = {
"company": c, "number": pools.contract_number(0),
"date": pools.random_date(), "contract_date": pools.random_date(),
"version": 1,
"label": "Абонентские услуги",
}
# v1
svcs_v1, tot_v1, tot_str_v1 = pools.pick_services(5)
ctx_v1 = {**base_ctx, "services": svcs_v1, "total": tot_v1, "total_str": tot_str_v1}
r1 = _build_and_parse(templates.build_spec, ctx_v1)
t1 = _elements_to_text(r1["elements"])
# v2 — меняем цены
import copy
svcs_v2 = copy.deepcopy(svcs_v1)
if svcs_v2:
svcs_v2[0]["price"] = round(svcs_v2[0]["price"] * 1.25, 2)
svcs_v2[0]["price_str"] = pools.format_price(svcs_v2[0]["price"])
svcs_v2[0]["sum"] = round(svcs_v2[0]["price"] * svcs_v2[0]["qty"], 2)
svcs_v2[0]["sum_str"] = pools.format_price(svcs_v2[0]["sum"])
tot_v2 = round(sum(s["sum"] for s in svcs_v2), 2)
ctx_v2 = {**base_ctx, "version": 2, "services": svcs_v2, "total": tot_v2, "total_str": pools.format_price(tot_v2)}
r2 = _build_and_parse(templates.build_spec, ctx_v2)
t2 = _elements_to_text(r2["elements"])
assert t1 != t2, "v1 and v2 specs produced identical text!"
-162
View File
@@ -1,162 +0,0 @@
"""
Тест DrHider — много проверок.
Файл: /home/naeel/nubes/contracts/contracts-flask/deploy/tests/test_drhider.py
"""
import sys, os, io, zipfile, json, base64
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
from drhider.drhider import obfuscate_files, TwoPassObfuscator, COMPANY_PATTERN, PERSON_PATTERN
TEST_ZIP = "/home/naeel/nubes/contracts/dogovora/примеры_договоров_для_ИИ.zip"
def run(text, desc):
"""Обработать текст и проверить что needle НЕ найдено."""
z, c = obfuscate_files([('t.txt', text.encode(), '')])
with zipfile.ZipFile(io.BytesIO(z)) as zf:
out = zf.read('t.txt').decode()
csv = zf.read('mapping.csv').decode()
return out, csv
errors = 0
def check(ok, msg):
global errors
if ok:
print(f"{msg}")
else:
print(f"{msg}")
errors += 1
print("=" * 60)
print("DrHider — полный тест")
print("=" * 60)
# ── 1. Телефоны ──
print("\n📞 Телефоны:")
out, csv = run("Звоните +7 (495) 789-41-35 или 8-800-555-35-35", "телефоны")
check("+7 (495) 789-41-35" not in out, "+7 (495) 789-41-35 заменён")
check("8-800-555-35-35" not in out, "8-800-555-35-35 заменён")
check("phone" in csv, "тип phone в CSV")
# ── 2. Телефон не матчит число внутри счёта ──
print("\n📞 Телефон vs номер счёта:")
out, csv = run("Кор/сч 30101810400000000225", "счёт")
check("30101810400000000225" not in out, "номер счёта заменён (ks)")
check("+7" not in out, "нет ложного телефона внутри счёта")
# ── 3. Email ──
print("\n📧 Email:")
out, csv = run("Пишите info@nubes.ru и support@company.org", "email")
check("info@nubes.ru" not in out, "info@nubes.ru заменён")
check("support@company.org" not in out, "support@company.org заменён")
check("email" in csv, "тип email в CSV")
# ── 4. Компании (разные кавычки) ──
print("\n🏢 Компании:")
out, csv = run('ООО "Ромашка" и АО \u201cXXX003\u201d и ЗАО «Тест»', "компании")
check('ООО "Ромашка"' not in out, 'ООО "Ромашка" заменён')
check('АО \u201cXXX003\u201d' not in out, 'АО "XXX003" (unicode) заменён')
check('ЗАО «Тест»' not in out, 'ЗАО «Тест» заменён')
check('company' in csv, 'тип company в CSV')
# ── 5. НУБЕС whitelist ──
print("\n🛡️ НУБЕС whitelist:")
out, csv = run('ООО "НУБЕС" и ООО \u201cНУБЕС\u201d', "НУБЕС")
check('ООО "НУБЕС"' in out, 'НУБЕС (ASCII) НЕ заменён')
check('ООО \u201cНУБЕС\u201d' in out, 'НУБЕС (unicode) НЕ заменён')
# ── 6. ИНН/ОГРН/КПП/БИК ──
print("\n🔢 ИНН/ОГРН/КПП/БИК:")
out, csv = run("ИНН 9706005293, КПП 772401001, ОГРН 1207700098759, БИК 044525225", "реквизиты")
check("9706005293" not in out, "ИНН заменён")
check("772401001" not in out, "КПП заменён")
check("1207700098759" not in out, "ОГРН заменён")
check("044525225" not in out, "БИК заменён")
check("inn_ul" in csv, "inn_ul в CSV")
# ── 7. Расчётный счёт ──
print("\n💳 Расчётный счёт:")
out, csv = run("р/с 40702810738000174030 и Кор/сч 30101810400000000225", "счета")
check("40702810738000174030" not in out, "р/с заменён")
check("30101810400000000225" not in out, "кор/сч заменён")
check("rs" in csv, "rs в CSV")
check("ks" in csv, "ks в CSV")
# ── 8. ФИО (инициалы + полностью) ──
print("\n👤 ФИО:")
out, csv = run("Директор Степаненко В.С. и Иванов Александр Петрович", "ФИО")
check("Степаненко В.С." not in out, "Степаненко В.С. заменён")
check("Иванов Александр Петрович" not in out, "Иванов А.П. заменён")
# ── 9. Паспорт ──
print("\n📄 Паспорт:")
out, csv = run("паспорт 12 34 567890", "паспорт")
check("12 34 567890" not in out, "номер паспорта заменён")
# ── 10. Согласованность (одна сущность → одна замена) ──
print("\n🔄 Согласованность:")
z, c = obfuscate_files([
('a.txt', b'OOO Romashka +79991234567', ''),
('b.txt', b'OOO Romashka +79991234567', ''),
])
with zipfile.ZipFile(io.BytesIO(z)) as zf:
a = zf.read('a.txt').decode()
b = zf.read('b.txt').decode()
check(a == b, f"оба файла одинаковы: {a}")
# ── 11. ZIP внутри ZIP ──
print("\n📦 ZIP внутри:")
with open(TEST_ZIP, 'rb') as f:
raw = f.read()
z, c = obfuscate_files([('test.zip', raw, '')])
with zipfile.ZipFile(io.BytesIO(z)) as zf:
names = zf.namelist()
check(len(names) == 6, f"6 файлов (5 + csv): {len(names)}")
check('mapping.csv' in names, "mapping.csv есть")
check(not any(n.endswith('.zip') for n in names), "нет .zip в выдаче")
# Проверим что docx внутри валидны
for n in names:
if n.endswith('.docx'):
try:
from docx import Document
Document(io.BytesIO(zf.read(n)))
check(True, f"{n} — валидный docx")
except:
check(False, f"{n} — БИТЫЙ docx")
# ── 12. .doc бинарный — не трогаем ──
print("\n📄 .doc бинарный:")
# Создаём фейковый .doc файл (просто бинарные данные)
z, c = obfuscate_files([('old.doc', b'\xD0\xCF\x11\xE0' + b'\x00' * 100, '')])
with zipfile.ZipFile(io.BytesIO(z)) as zf:
doc_content = zf.read('old.doc')
check(len(doc_content) == 104, ".doc сохранён без изменений")
# ── 13. Много файлов ──
print("\n📚 20 файлов:")
many = [('f{}.txt'.format(i), 'OOO Test{} +7999{}'.format(i, i).encode(), '') for i in range(20)]
z, c = obfuscate_files(many)
with zipfile.ZipFile(io.BytesIO(z)) as zf:
check(len(zf.namelist()) == 21, "20 файлов + csv")
# ── 14. COMPANY_PATTERN не жадный ──
print("\n🔤 COMPANY_PATTERN границы:")
m = COMPANY_PATTERN.search("АО Test с дополнительным текстом дальше")
check(m is not None and 'дальше' not in m.group(0), "не захватывает лишний текст")
# ── 15. PERSON_PATTERN только кириллица ──
print("\n🔤 PERSON_PATTERN кириллица:")
m = PERSON_PATTERN.search("Next Generation Cloud service")
check(m is None, "английский не матчится")
m2 = PERSON_PATTERN.search("Иванов Иван Иванович")
check(m2 is not None, "русский матчится")
# ── ИТОГО ──
print(f"\n{'='*60}")
if errors:
print(f"{errors} ошибок")
else:
print("✅ ВСЕ ТЕСТЫ ПРОЙДЕНЫ")
print(f"{'='*60}")
sys.exit(0 if errors == 0 else 1)
View File
-62
View File
@@ -1,62 +0,0 @@
"""Test classify_batch with MemRepository + FakeLLM."""
from compare.classify import classify_batch
from compare.llm_client import FakeLLMClient
from repository import MemRepository
class TestClassifyBatch:
def test_garbage_by_filename(self):
"""Счёт-фактура отфильтровывается без LLM."""
repo = MemRepository()
llm = FakeLLMClient({"default": '{"doc_type":"contract"}'})
repo.insert_document("счет-фактура №123.docx", "mime", "data", batch_id="b1")
result = classify_batch("b1", llm_client=llm, repo=repo)
assert result["ok"] is True
assert result["garbage"] == 1
assert result["done"] == 1
assert len(llm.calls) == 0 # LLM не вызывался
def test_garbage_by_header(self):
"""Акт сверки в тексте отфильтровывается."""
repo = MemRepository()
llm = FakeLLMClient({"default": '{"doc_type":"contract"}'})
doc = repo.insert_document("документ.docx", "mime", "data", batch_id="b1")
repo.set_document_parsed(doc["id"], [
{"type": "paragraph", "text": "АКТ СВЕРКИ взаимных расчётов", "style": ""}
])
result = classify_batch("b1", llm_client=llm, repo=repo)
assert result["garbage"] == 1
assert len(llm.calls) == 0
def test_llm_classify(self):
"""Договор классифицируется через LLM."""
repo = MemRepository()
llm = FakeLLMClient({
"default": '{"doc_type":"contract","own_number":"03700_1","doc_date":"2026-02-01","counterparty":"ЗАО XXX001"}'
})
doc = repo.insert_document("договор-XXX001.docx", "mime", "data", batch_id="b1")
repo.set_document_parsed(doc["id"], [
{"type": "paragraph", "text": "ДОГОВОР № 03700_1", "style": ""}
])
result = classify_batch("b1", llm_client=llm, repo=repo)
assert result["done"] == 1, f"done={result['done']}, failed={result['failed']}, doc={repo.get_document(doc['id'])}"
assert result["garbage"] == 0
assert len(llm.calls) == 1
updated = repo.get_document(doc["id"])
assert updated["doc_type"] == "contract"
assert updated["own_number"] == "03700_1"
assert updated["counterparty"] == "ЗАО XXX001"
def test_no_pending(self):
"""Пустой батч."""
repo = MemRepository()
result = classify_batch("empty", llm_client=FakeLLMClient(), repo=repo)
assert result["ok"] is False
assert "no pending" in result["error"]
-110
View File
@@ -1,110 +0,0 @@
"""Unit tests — no DB, no network, just pure logic."""
import pytest
from contracts import ParseResult, ClassifyResult, GroupingResult, CompareOp
from compare.upload import parse_multipart
from compare.classify import _is_garbage_by_filename, _is_garbage_by_header
class TestContracts:
"""Dataclass creation and validation."""
def test_parse_result(self):
r = ParseResult(status="parsed", element_count=10, elements=[])
assert r.status == "parsed"
assert r.element_count == 10
def test_classify_from_llm(self):
r = ClassifyResult.from_llm({
"doc_type": "contract",
"own_number": "03700_1",
"doc_date": "2026-02-01",
"counterparty": "ЗАО XXX001",
})
assert r.doc_type == "contract"
assert r.own_number == "03700_1"
assert r.counterparty == "ЗАО XXX001"
def test_grouping_result_empty(self):
r = GroupingResult(contract_number="03700_1")
assert r.contract_number == "03700_1"
assert r.documents == []
def test_compare_op_add(self):
op = CompareOp(action="ADD", new_row={"name": "Тест", "price": 100})
assert op.action == "ADD"
assert op.new_row["price"] == 100
def test_compare_op_from_llm(self):
op = CompareOp.from_llm({
"action": "UPDATE",
"target_id": "r1",
"new_values": {"price": 200},
"comment": "change",
})
assert op.action == "UPDATE"
assert op.new_values["price"] == 200
assert op.comment == "change"
class TestGarbageFilter:
"""Stage 1-2 garbage detection."""
def test_filename_garbage_invoice(self):
assert _is_garbage_by_filename("счет-фактура №123.docx") is True
def test_filename_garbage_act(self):
assert _is_garbage_by_filename("Акт сверки за май.pdf") is True
def test_filename_not_garbage(self):
assert _is_garbage_by_filename("договор-XXX001.docx") is False
assert _is_garbage_by_filename("спецификация услуг.pdf") is False
def test_header_garbage(self):
text = "СЧЕТ-ФАКТУРА № 123 от 01.01.2026\nПоставщик: ООО Ромашка"
assert _is_garbage_by_header(text) is True
def test_header_not_garbage(self):
text = "ДОГОВОР № 03700_1\nг. Москва\n\nИсполнитель обязуется..."
assert _is_garbage_by_header(text) is False
class TestParseMultipart:
"""Pure multipart parsing without HTTP."""
def test_simple_file(self, sample_docx_bytes):
boundary = b"----testboundary"
body = (
b"------testboundary\r\n"
b'Content-Disposition: form-data; name="files"; filename="test.docx"\r\n'
b"Content-Type: application/octet-stream\r\n\r\n"
+ sample_docx_bytes +
b"\r\n------testboundary--\r\n"
)
result = parse_multipart(body, f"multipart/form-data; boundary=----testboundary")
assert result["filename"] == "test.docx"
assert result["file_data"] == sample_docx_bytes
def test_with_contract_id(self, sample_docx_bytes):
boundary = b"----testboundary"
body = (
b"------testboundary\r\n"
b'Content-Disposition: form-data; name="files"; filename="test.docx"\r\n\r\n'
+ sample_docx_bytes +
b"\r\n------testboundary\r\n"
b'Content-Disposition: form-data; name="contract_id"\r\n\r\n'
b"550e8400-e29b-41d4-a716-446655440000"
b"\r\n------testboundary--\r\n"
)
result = parse_multipart(body, f"multipart/form-data; boundary=----testboundary")
assert result["contract_id"] == "550e8400-e29b-41d4-a716-446655440000"
def test_path_traversal_rejected(self):
boundary = b"----testboundary"
body = (
b"------testboundary\r\n"
b'Content-Disposition: form-data; name="files"; filename="../etc/passwd"\r\n\r\n'
b"evil"
b"\r\n------testboundary--\r\n"
)
with pytest.raises(ValueError, match="invalid filename"):
parse_multipart(body, f"multipart/form-data; boundary=----testboundary")
-59
View File
@@ -1,59 +0,0 @@
"""Unit tests for LLM client and Repository — with fakes."""
from compare.llm_client import FakeLLMClient
from repository import MemRepository
class TestFakeLLM:
def test_exact_match(self):
client = FakeLLMClient({"hello": "world"})
assert client.complete("hello") == "world"
def test_partial_match(self):
client = FakeLLMClient({"classify": '{"doc_type":"contract"}'})
assert "contract" in client.complete("classify this document")
def test_default_fallback(self):
client = FakeLLMClient({"default": '{"ok":true}'})
assert client.complete("unknown prompt") == '{"ok":true}'
def test_call_history(self):
client = FakeLLMClient({"a": "1", "b": "2"})
client.complete("a")
client.complete("b")
assert len(client.calls) == 2
assert client.calls[0] == "a"
class TestMemRepository:
def test_insert_and_retrieve(self, mem_repo):
doc = mem_repo.insert_document("test.docx", "application/vnd...", "base64data", batch_id="batch-1")
assert doc["filename"] == "test.docx"
assert doc["classify_status"] == "pending"
def test_list_pending(self, mem_repo):
mem_repo.insert_document("a.docx", "mime", "data", batch_id="b1")
mem_repo.insert_document("b.docx", "mime", "data", batch_id="b1")
mem_repo.insert_document("c.docx", "mime", "data", batch_id="b2")
pending = mem_repo.list_pending("b1")
assert len(pending) == 2
def test_set_classification(self, mem_repo):
doc = mem_repo.insert_document("test.docx", "mime", "data", batch_id="b1")
mem_repo.set_classification(doc["id"], "contract", "03700", None, "2026-02-01", "XXX")
updated = mem_repo.documents[doc["id"]]
assert updated["doc_type"] == "contract"
assert updated["classify_status"] == "classified"
def test_set_garbage(self, mem_repo):
doc = mem_repo.insert_document("счет.docx", "mime", "data", batch_id="b1")
mem_repo.set_classify_garbage(doc["id"], "filename_regex")
assert mem_repo.documents[doc["id"]]["doc_type"] == "garbage"
def test_contract_lifecycle(self, mem_repo):
cid = mem_repo.insert_contract("03700_1", "ЗАО XXX")
assert cid
doc = mem_repo.insert_document("test.docx", "mime", "data")
mem_repo.insert_supplement(cid, doc["id"], "initial")
supps = mem_repo.list_supplements(cid)
assert len(supps) == 1
assert supps[0]["type"] == "initial"
+104
View File
@@ -0,0 +1,104 @@
# Задание для Sonnet: описание архитектуры сервиса «Сверка договоров»
## Роль и задача
Ты — технический аналитик. Твоя задача — **только прочитать указанные файлы** и написать
подробное, структурированное описание сервиса «Сверка договоров»: что это за сервис, как
устроен, как работает каждый этап, какими методами и технологиями. **Ничего не менять и
не создавать в коде.** Результат — один текстовый документ (markdown).
## Короткий контекст (чтобы ты понимал, о чём речь)
Сервис «Сверка договоров» — веб-приложение на **Flask + vanilla JS** (без фреймворков
на фронте), развёрнутое на managed-кластере Nubes (Штурвал). Пользователь загружает
договоры и допсоглашения (docx/pdf/zip), сервис:
1. разбирает их на структурированные спецификации;
2. автоматически классифицирует документы (тип/номер/дата/контрагент) через LLM;
3. группирует документы по договорам;
4. по цепочке допников сравнивает изменения спецификации (ADD/UPDATE/DELETE) через LLM;
5. показывает историю изменений и даёт возможность задать вопрос чату по итоговой спецификации.
Ключевое ограничение: данные конфиденциальны, LLM — только своя (api.aillm.ru, модель
gpt-oss-120b). Большие файлы не грузятся напрямую в кластер (шлюз рвёт тела >~64 КБ) —
используется «ВМ-буфер»: браузер кладёт файл на ВМ (WebDAV), а бэк сам тянет его
исходящим запросом (egress не ограничен).
## Что прочитать — ТОЛЬКО эти файлы (корень проекта: contracts-flask/)
### Ядро бэкенда (обязательно, читать все)
- `site/app.py` — точка входа, фабрика приложения, sys.path
- `site/config.py` — конфигурация (версия, LLM_URL/KEY/MODEL, CONVERT_SERVICE_URL, VM_UPLOAD_*)
- `site/llm_prompt.py` — формирование промптов LLM (извлечение/сравнение/классификация)
- `site/routes/__init__.py` — регистрация всех blueprint'ов
- `site/routes/upload_bp.py` — загрузка файлов (multipart + sink закачки через ВМ)
- `site/routes/pipeline_bp.py` — классификация + SSE-сравнение (/process-v2, /api/classify-batch)
- `site/routes/api_bp.py` — API (groups, documents, supplements, sync, cleanup, spec-current, chat)
- `site/routes/pages_bp.py` — HTML-страницы + раздача ES-модулей upload/
- `site/routes/prompts_bp.py` — CRUD и версионирование промптов
- `site/routes/health_bp.py` — healthcheck
- `site/services/parse.py` — парсинг PDF (pdfplumber), DOCX (python-docx), TXT
- `site/services/classify.py` — LLM-классификация документов (garbage-фильтры, выжимка, ThreadPool)
- `site/services/grouping.py` — нормализация номеров и группировка по договорам
- `site/services/process.py` — SSE-пайплайн сравнения (run_pipeline)
- `site/services/llm.py` — вызов LLM для сравнения (call_llm)
- `site/services/llm_client.py` — LLM-клиент (HttpxLLMClient, FakeLLMClient для тестов)
- `site/services/metrics.py` — проверка арифметики (sum == price*qty), метрики качества
- `site/db/connection.py` — SQLite: схема всех таблиц, WAL, thread-local соединения
- `site/db/documents.py` — CRUD документов (статусы, классификация, elements_json)
- `site/db/contracts.py` — договоры
- `site/db/supplements.py` — допники (связь contract↔document)
- `site/db/spec_events.py` — event sourcing: apply_ops (ADD/UPDATE/DELETE), reset
- `site/db/spec_current.py` — текущее состояние спецификации договора
- `site/db/prompts.py` — версии промптов по ролям
### Фронтенд (для понимания потока на стороне браузера)
- `site/static/state.js` — центральное состояние приложения
- `site/static/app.js` — инициализация, обработчики загрузки/классификации/сравнения
- `site/static/files.js` — выбор файлов/папок/архивов, загрузка через ВМ-буфер
- `site/static/groups.js` — классификация и группы на фронте
- `site/static/compare.js` — сравнение (SSE) и рендер результатов
- `site/templates/index.html` — разметка UI
### Переиспользуемый модуль загрузки через ВМ (модуль upload/)
- `upload/README.md` — описание модуля и паттерна «ВМ-буфер + pull»
- `upload/backend/upload_refs/blueprint.py` — blueprint POST /api/upload_refs с параметром sink
- `upload/backend/upload_refs/safe_name.py`, `pull_file.py`, `config.py` — транспортные примитивы
- `upload/backend/session/__init__.py` — in-memory сессия (используется drhider; сверка его не использует)
### Инфраструктура
- `Dockerfile` — сборка образа
- `requirements.txt` — зависимости
## Что НЕ читать и не трогать
- `History/` — вся история сессий, ревью, старые планы (НЕ нужно)
- `contractor-legacy/` — замороженный Lucee-прод (не актуален)
- `contracts-vm/`, `convert-service/`, `loadtest/`, `testgen/`, `sim/`, `dogovora/`, `hz/`, `FILES/`, `DOC/` — смежные/вспомогательные, НЕ ядро сервиса
- `deploy/` — устаревший VM-слой (convert_server.py и т.п.), не входит в текущий managed-сервис
- `site/services/drhider.py` — совместимость с DrHider (побочное, не ядро сверки)
- `site/templates/drhider.html`, `architect.html`, `pipeline.html`, `ci-cd.html` — не ядро сверки
## Что должно быть в ответе (структура)
1. **Описание сервиса** — что делает, кому, какие входы/выходы (2-4 абзаца, по-простому).
2. **Общая архитектура** — слои (фронт / бэк / БД / LLM / ВМ-буфер), как они связаны.
Лучше — схема потока: загрузка → парсинг → классификация → группировка → сравнение → чат.
3. **Структура проекта** — дерево `site/` (+ `upload/`) с назначением каждого файла.
4. **Как работает каждый этап** (по пунктам, «что делает / как / где в коде»):
- загрузка файлов (включая папки/архивы, ВМ-буфер, sink);
- парсинг PDF/DOCX/TXT → elements (таблицы + параграфы);
- классификация (garbage-фильтры, выжимка, LLM, параллельность);
- группировка (нормализация номеров, виртуальные группы, __unresolved__);
- сравнение (SSE-пайплайн, ops ADD/UPDATE/DELETE, event sourcing, spec_current);
- чат по итоговой спецификации.
5. **Технологии и методы** — коротко: SQLite (WAL, thread-local), SSE, ThreadPool, LLM-клиент,
промпты (из БД + fallback), event sourcing, метрики качества, ВМ-буфер загрузки.
6. **Модель данных** — таблицы БД и их назначение (documents, contracts, supplements,
spec_events, spec_current, prompts).
## Требования к стилю
- Чётко, структурно, без воды и лирики.
- Без излишней заумности — понятно не только девопсу, но и обычному разработчику/аналитику.
- Каждый этап: «что делает → как → каким методом → где в коде (файл/функция)».
- Ничего не менять в коде. Только текстовый документ.
+101
View File
@@ -0,0 +1,101 @@
# Задание для Sonnet (v2): описание архитектуры сервиса «Сверка договоров»
## Роль и задача
Ты — технический аналитик. Задача — **только прочитать указанные файлы** и написать
подробное, структурированное описание сервиса «Сверка договоров»: что это, как устроено,
как работает каждый этап, какими методами/технологиями. **Ничего не менять в коде.**
Результат — один текстовый документ (markdown).
## Контекст (что уже было)
Ранее ты выдал **код-ревью** с 10 находками (вместо описания). Все 10 уже **исправлены**
(v2.0.15): критичный баг `target_id` vs `target_hash` (частичная сверка), Dockerfile,
prompts CRUD, LLM-конфиг, XSS в `compare.js`, мелочи. Учти это — читай код **после** правок,
описывай **текущее** состояние.
## Сервис в двух словах
«Сверка договоров» — веб-приложение на **Flask + vanilla JS** (без фреймворков на фронте),
на managed-кластере Nubes. Пользователь загружает договоры/допсоглашения (docx/pdf/zip),
сервис: разбирает их в спецификации → классифицирует документы через LLM → группирует
по договорам → по цепочке допников сравнивает изменения (ADD/UPDATE/DELETE) через LLM →
показывает историю + чат по итоговой спецификации. Данные конфиденциальны, LLM — только
своя (api.aillm.ru, gpt-oss-120b). Большие файлы грузятся через «ВМ-буфер» (браузер PUT на
WebDAV → бэк тянет egress GET), т.к. шлюз кластера рвёт тела >~64 КБ.
## Что прочитать — ТОЛЬКО эти файлы (корень: contracts-flask/)
### Ядро бэкенда
- `site/app.py` — точка входа, фабрика приложения, sys.path
- `site/config.py` — конфигурация (версия, LLM, конвертер .doc, VM_UPLOAD_*)
- `site/llm_prompt.py` — формирование промптов LLM (extract/diff/classify + fallback)
- `site/routes/__init__.py` — регистрация blueprint'ов
- `site/routes/upload_bp.py` — загрузка (multipart + sink закачки через ВМ)
- `site/routes/pipeline_bp.py` — классификация + SSE-сравнение (/process-v2, /api/classify-batch)
- `site/routes/api_bp.py` — API (groups, documents, supplements, sync, cleanup, spec-current, chat)
- `site/routes/pages_bp.py` — HTML-страницы + раздача ES-модулей upload/
- `site/routes/prompts_bp.py` — CRUD и версионирование промптов
- `site/routes/health_bp.py` — healthcheck
- `site/services/parse.py` — парсинг PDF (pdfplumber), DOCX (python-docx), TXT
- `site/services/classify.py` — LLM-классификация (garbage-фильтры, выжимка, ThreadPool)
- `site/services/grouping.py` — нормализация номеров и группировка по договорам
- `site/services/process.py` — SSE-пайплайн сравнения (run_pipeline) + трансляция target_id→hash
- `site/services/llm.py` — вызов LLM для сравнения (call_llm)
- `site/services/llm_client.py` — LLM-клиент (HttpxLLMClient, FakeLLMClient)
- `site/services/metrics.py` — проверка арифметики (sum == price*qty), метрики качества
- `site/db/connection.py` — SQLite: схема, WAL, thread-local
- `site/db/documents.py` — CRUD документов
- `site/db/contracts.py` — договоры
- `site/db/supplements.py` — допники (contract↔document)
- `site/db/spec_events.py` — event sourcing: apply_ops (ADD/UPDATE/DELETE), reset
- `site/db/spec_current.py` — текущее состояние спецификации
- `site/db/prompts.py` — версии промптов
### Фронтенд
- `site/static/state.js` — центральное состояние
- `site/static/app.js` — инициализация, обработчики
- `site/static/files.js` — выбор файлов/папок/архивов, загрузка через ВМ
- `site/static/groups.js` — классификация и группы на фронте
- `site/static/compare.js` — сравнение (SSE) и рендер
- `site/templates/index.html` — UI
### Модуль upload/ (переиспользуемый)
- `upload/README.md` — паттерн «ВМ-буфер + pull»
- `upload/backend/upload_refs/blueprint.py` — blueprint POST /api/upload_refs с sink
- `upload/backend/upload_refs/safe_name.py`, `pull_file.py`, `config.py`
- `upload/backend/session/__init__.py` — in-memory сессия (drhider; сверка не использует)
### Инфраструктура
- `Dockerfile`, `requirements.txt`
## Что НЕ читать
`History/`, `contractor-legacy/`, `contracts-vm/`, `convert-service/`, `loadtest/`,
`testgen/`, `sim/`, `dogovora/`, `hz/`, `FILES/`, `DOC/`, `deploy/` (устаревший VM-слой),
`site/services/drhider.py`, `site/templates/drhider.html|architect.html|pipeline.html|ci-cd.html`.
## Что должно быть в ответе (структура)
1. **Описание сервиса** — что делает, входы/выходы (2–4 абзаца, по-простому).
2. **Общая архитектура** — слои (фронт/бэк/БД/LLM/ВМ-буфер) + схема потока:
загрузка → парсинг → классификация → группировка → сравнение → чат.
3. **Структура проекта** — дерево `site/` (+ `upload/`) с назначением каждого файла.
4. **Как работает каждый этап** — «что делает → как → каким методом → где в коде (файл/функция)».
5. **Технологии и методы** — SQLite (WAL, thread-local), SSE, ThreadPool, LLM-клиент,
промпты (БД + fallback), event sourcing, метрики качества, ВМ-буфер.
6. **Модель данных** — таблицы и назначение (documents, contracts, supplements,
spec_events, spec_current, prompts).
## Дополнительно: точечная проверка фикса #1
Отдельно, коротко (3–5 строк): в `site/services/process.py` глянь блок трансляции
`target_id ("r1","r2"…) → target_hash` (через `current_spec[_idx]["hash"]`). Подтверди:
корректно ли он маппит `rN` (1-based) на индекс `current_spec`, и что при невалидном id
операция уходит в UNRESOLVED, а не падает. Если видишь ошибку — укажи, но НЕ правь.
## Требования к стилю
- Чётко, структурно, без воды и лирики.
- Без излишней заумности — понятно не только девопсу, но и обычному разработчику/аналитику.
- Ничего не менять в коде. Только текстовый документ.
+145
View File
@@ -0,0 +1,145 @@
# Как работает сервис «Сверка договоров»
Документ описывает текущую программную реализацию `contracts-flask`: последовательность обработки данных, ответственность модулей и ключевые функции. Основной актуальный код находится в `site/`; каталог `deploy/` является историческим VM-слоем.
## 1. Общий поток
```text
Браузер
-> выбор файлов / ZIP
-> загрузка через VM-буфер
-> парсинг
-> классификация документов
-> группировка по договорам
-> применение подтверждённых групп
-> SSE-сравнение документов по порядку
-> event sourcing: spec_events + spec_current
-> таблица изменений и чат по текущей спецификации
```
Главная точка сборки Flask-приложения: [`site/app.py`](../site/app.py). Регистрация маршрутов выполняется через [`site/routes/__init__.py`](../site/routes/__init__.py).
## 2. Загрузка файлов
Фронтенд начинает обработку в [`site/static/files.js`](../site/static/files.js):
- `onFilesSelected()` принимает выбранные файлы, обрабатывает ZIP и обычные документы, запускает подготовку загрузки и сохраняет связь `zip_source`.
- `uploadFile()` запускает загрузку через VM-буфер; фактические PUT и отправка ссылок выполняются функциями `uploadViaVM()` и `putToVm()` в [`upload/frontend/upload/upload_via_vm.js`](../upload/frontend/upload/upload_via_vm.js) и [`upload/frontend/upload/put_to_vm.js`](../upload/frontend/upload/put_to_vm.js).
- `finalizeUpload()` завершает загрузку и обновляет состояние.
- `renderFiles(state)` отображает список файлов, группируя вложения по `zip_source`.
- `syncDB()` синхронизирует состав файлов в БД.
Из-за ограничения managed-шлюза большие файлы сначала передаются на ВМ, а затем бэкенд забирает их исходящим запросом. Общий переиспользуемый транспорт находится в [`upload/backend/upload_refs/blueprint.py`](../upload/backend/upload_refs/blueprint.py), включая обработчик `POST /api/upload_refs`; конкретная бизнес-логика передаётся через `sink`.
Маршруты загрузки основного приложения находятся в [`site/routes/upload_bp.py`](../site/routes/upload_bp.py). Там принимаются ссылки/файлы, вызывается sink сервиса договоров и сохраняются результаты обработки.
## 3. Парсинг
Парсер находится в [`site/services/parse.py`](../site/services/parse.py). Он преобразует содержимое документов в структурированный список элементов:
- `parse()` выбирает обработчик по формату.
- Обработчик DOCX использует `python-docx` и извлекает параграфы и таблицы.
- Обработчик PDF использует `pdfplumber`.
- TXT обрабатывается напрямую.
- Для `.doc` функция `parse_file()` в текущем `site` вызывает `_parse_docx(data)`; отдельный [`convert-service/app.py`](../convert-service/app.py) и исторический [`deploy/convert_doc.py`](../deploy/convert_doc.py) не являются частью этого вызова.
Результат парсинга сохраняется как `elements_json`. Важный принцип: парсер не решает, какие строки являются услугами, а сохраняет исходную структуру документа для следующих стадий.
## 4. Классификация документов
Маршрут запуска классификации: [`site/routes/pipeline_bp.py`](../site/routes/pipeline_bp.py), функция `classify_batch_route()`.
- Для небольшого батча классификация выполняется синхронно.
- Для большого батча запускается фоновый поток и возвращается `202`.
- `process_v2()` отдаёт поток SSE для этапа сравнения.
Основная логика находится в [`site/services/classify.py`](../site/services/classify.py):
- `classify_batch(batch_id, llm_client=None, repo=None)` получает pending-документы, запускает параллельную обработку через `ThreadPoolExecutor` и сохраняет результат.
- `_classify_one()` выполняет полный цикл для одного файла.
- `_is_garbage_by_filename()` отбрасывает очевидный мусор по имени файла.
- `_smart_extract()` формирует компактную выжимку: начало документа и найденные фрагменты с ключевыми маркерами.
- `_is_garbage_by_header()` отбрасывает документы по заголовочным маркерам.
- `_call_llm_classify()` отправляет выжимку в LLM.
- `_safe_json_parse()` извлекает JSON из ответа LLM и исправляет распространённые ошибки форматирования.
Промпт формируется функцией `build_classify_prompt()` в [`site/llm_prompt.py`](../site/llm_prompt.py). Результат содержит `doc_type`, `own_number`, `parent_number`, `doc_date` и `counterparty`. Сырые вход и ответ LLM также сохраняются для диагностики.
## 5. Группировка по договорам
Логика находится в [`site/services/grouping.py`](../site/services/grouping.py):
- `normalize_number(num)` приводит номер к верхнему регистру и убирает разделители.
- `group_documents(batch_id)` отделяет договоры от допников/спецификаций, сопоставляет их по `parent_number` или `own_number`, создаёт виртуальные группы при отсутствии базового договора и помещает нерешённые документы в `__unresolved__`.
- Внутри групп документы сортируются по `doc_date`.
- `apply_groups(batch_id, groups_data)` сохраняет подтверждённые группы в таблицы договоров и допников.
Маршруты чтения и применения групп находятся в [`site/routes/api_bp.py`](../site/routes/api_bp.py). Фронтенд отображает результат через [`site/static/groups.js`](../site/static/groups.js).
Таким образом, LLM извлекает признаки документа, но окончательное сопоставление по номерам выполняется обычным Python-кодом.
## 6. Последовательное сравнение
Маршрут сравнения: `process_v2()` в [`site/routes/pipeline_bp.py`](../site/routes/pipeline_bp.py). Он проверяет `contract_id`, запускает `run_pipeline()` и преобразует события в формат SSE.
Основной pipeline находится в [`site/services/process.py`](../site/services/process.py):
- `run_pipeline(contract_id, order_ids, build_prompt_fn)` сбрасывает предыдущее состояние, получает документы группы и определяет порядок обработки.
- `_elements_to_text(ej)` преобразует `elements_json` в текстовый контекст для LLM.
- Для каждого документа читается текущая спецификация.
- `call_llm()` из [`site/services/llm.py`](../site/services/llm.py) получает текущие строки и текст документа и возвращает операции.
- `target_id` вида `r1`, `r2` переводится в `target_hash` соответствующей строки текущей спецификации.
- Для режима `full_replace` вызывается `clear_current()`, чтобы новая редакция не дублировала старые строки.
- Затем вызывается `apply_ops()` и отправляются SSE-события `extract_start`, `llm_done`, `applied`, `extract_error` и финальное `complete`.
- `check_arithmetic()` из [`site/services/metrics.py`](../site/services/metrics.py) проверяет согласованность `sum`, `price` и `qty`.
Промпты извлечения и сравнения формируются через `build_prompt()` в [`site/llm_prompt.py`](../site/llm_prompt.py); версии промптов хранятся и редактируются маршрутами из [`site/routes/prompts_bp.py`](../site/routes/prompts_bp.py).
## 7. Event sourcing и текущее состояние
Механизм хранения находится в [`site/db/spec_events.py`](../site/db/spec_events.py):
- `reset(contract_id)` очищает события и текущее состояние перед новым полным прогоном.
- `clear_current(contract_id)` очищает только текущую спецификацию для `full_replace`.
- `apply_ops(...)` обрабатывает `ADD`, `UPDATE`, `DELETE` и `UNRESOLVED`.
- `_hash(name, date_start)` создаёт стабильный идентификатор строки услуги.
- `_upsert_spec_current(...)` добавляет или обновляет строку текущей спецификации.
- `_update_spec_current(...)` изменяет только поля, указанные в операции.
- `_log_unresolved(...)` сохраняет нерешённую операцию вместо молчаливого пропуска.
`spec_events` — журнал операций с исходным ответом LLM, версией промпта и ссылкой на документ. `spec_current` — материализованное состояние, используемое для следующего сравнения и отображения.
CRUD текущей спецификации и исходных данных документа находится в [`site/db/spec_current.py`](../site/db/spec_current.py). Схема и соединения описаны в [`site/db/connection.py`](../site/db/connection.py).
## 8. SSE и интерфейс
Клиент сравнения находится в [`site/static/compare.js`](../site/static/compare.js):
- `startCompareSSE()` открывает `EventSource`, принимает события и управляет таймером.
- `applyCompareEvent()` переводит SSE-события в состояние секций.
- `renderCompareSectionHeader()` формирует заголовок секции документа.
- `renderCompareSectionBody()` отображает итоговую статистику и операции.
- `renderCompareOpsTable()` строит таблицу изменений.
Оркестрация шагов загрузки, классификации, группировки и запуска сравнения находится в [`site/static/app.js`](../site/static/app.js), в частности в `runClassify()` и обработчиках `loadGroupsAction()`/сравнения.
## 9. Чат
Маршрут чата находится в [`site/routes/api_bp.py`](../site/routes/api_bp.py). Он получает вопрос пользователя, загружает текущую спецификацию через функции из [`site/db/spec_current.py`](../site/db/spec_current.py), формирует контекст и отправляет его в LLM. Поэтому чат отвечает по материализованному состоянию договора, а не по случайному отдельному документу.
## 10. Итоговая ответственность компонентов
| Слой | Ответственность |
|---|---|
| `site/static/*.js` | выбор файлов, загрузка, отображение прогресса и результатов |
| `site/routes/` | HTTP API, SSE и связывание компонентов |
| `site/services/parse.py` | извлечение структуры документа |
| `site/services/classify.py` | классификация и выделение метаданных через LLM |
| `site/services/grouping.py` | детерминированное сопоставление документов |
| `site/services/process.py` | последовательное сравнение группы |
| `site/services/llm.py` | вызов LLM для анализа |
| `site/db/spec_events.py` | применение операций и аудит изменений |
| `site/db/spec_current.py` | чтение текущей спецификации и элементов документов |
| `site/db/connection.py` | SQLite/WAL и доступ к данным |
Итог: LLM используется там, где требуется понять содержание документа и смысл изменения услуги. Структура pipeline, нормализация номеров, порядок, проверки и сохранение результата выполняются детерминированным кодом.
+4
View File
@@ -4,6 +4,10 @@
import sys, os
# site/ в sys.path — импортируем модули напрямую, без префиксов
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
# корень репо — для переиспользуемого модуля upload/ (как в drhider)
_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if _REPO_ROOT not in sys.path:
sys.path.insert(0, _REPO_ROOT)
from flask import Flask
from config import VERSION, MAX_CONTENT_LENGTH
+14 -1
View File
@@ -1,7 +1,7 @@
"""Конфигурация приложения — все настройки в одном месте."""
import os
VERSION = "2.0.5"
VERSION = "2.0.21"
LLM_URL = os.getenv("LLM_API_URL", "https://api.aillm.ru/v1/chat/completions")
LLM_KEY = os.getenv("LLM_API_KEY", "")
@@ -9,3 +9,16 @@ LLM_MODEL = os.getenv("LLM_MODEL", "gpt-oss-120b")
MAX_CONTENT_LENGTH = 200 * 1024 * 1024 # 200 MB
API_KEY = os.getenv("API_KEY", "")
CONVERT_SERVICE_URL = os.getenv("CONVERT_SERVICE_URL", "http://containerk8s.df36c8af-1a95-4623-b551-0d37b731ccca.svc.cluster.local:5000")
# ── VM-буфер загрузки (паттерн drhider) ──────────────────────────────
# Браузер кладёт файл на ВМ через WebDAV (мимо шлюза кластера ~64КБ),
# бэк сам тянет его исходящим GET (egress без лимита).
VM_UPLOAD_URL = os.getenv("VM_UPLOAD_URL", "https://contracts.kube5s.ru/contracts-upload/")
# SSRF-защита: тянуть можно ТОЛЬКО с этого префикса.
VM_UPLOAD_PREFIX = os.getenv("VM_UPLOAD_PREFIX", "https://contracts.kube5s.ru/contracts-upload/")
# Лимит на один файл (совпадает с фронтом).
VM_UPLOAD_MAX_BYTES = int(os.getenv("VM_UPLOAD_MAX_BYTES", str(50 * 1024 * 1024)))
# Ретраи pull с ВМ (разовые DNS/сетевые сбои не роняют загрузку).
PULL_RETRIES = int(os.getenv("PULL_RETRIES", "3"))
PULL_RETRY_DELAY = 2.0
+18 -4
View File
@@ -10,12 +10,29 @@ import sqlite3
import threading
import time
import os
from contextlib import contextmanager
DB_PATH = "/tmp/contracts.db"
_db_session_key = None
_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():
"""Создать/пересоздать БД + схему. Вызывается при старте и после cleanup."""
global _db_session_key
@@ -171,6 +188,7 @@ def execute(sql, params=None):
conn = get_conn()
sql = _pg_to_sqlite(sql)
cur = conn.execute(sql, params or [])
if not getattr(_local, "in_transaction", False):
conn.commit()
return cur.rowcount
@@ -206,10 +224,6 @@ def _pg_to_sqlite(sql):
sql = sql.replace("%s", "?")
# ::jsonb → убрать (SQLite не типизирует)
sql = sql.replace("::jsonb", "")
# gen_random_uuid() → хекс-UUID через randomblob
if "gen_random_uuid()" in sql:
import uuid
sql = sql.replace("gen_random_uuid()", "?")
# BOOLEAN → INTEGER
sql = sql.replace(" BOOLEAN ", " INTEGER ")
sql = sql.replace(" bool ", " INTEGER ")
+1 -1
View File
@@ -85,7 +85,7 @@ def seed_defaults():
def list_by_role(role):
"""List all versions for a role, newest first."""
rows = query(
"SELECT id, role, name, is_active, created_by, notes, created_at FROM prompts WHERE role=%s ORDER BY created_at DESC",
"SELECT id, role, name, is_active, notes, created_at FROM prompts WHERE role=%s ORDER BY created_at DESC",
(role,),
)
for r in rows:
+35 -15
View File
@@ -1,6 +1,6 @@
"""spec_events — event sourcing: apply ops, reset contract."""
import json, uuid
from db.connection import query, execute, get_conn
from db.connection import query, execute, get_conn, transaction
def reset(contract_id):
@@ -9,6 +9,11 @@ def reset(contract_id):
execute("DELETE FROM spec_events WHERE contract_id = %s", (contract_id,))
def clear_current(contract_id):
"""Очистить ТОЛЬКО текущее состояние спецификации (история spec_events сохраняется). Для full_replace."""
execute("DELETE FROM spec_current WHERE contract_id = %s", (contract_id,))
def get_next_seq(contract_id):
"""Get next sequence number. WAL serializes writers — no explicit lock needed."""
conn = get_conn()
@@ -22,9 +27,15 @@ def get_next_seq(contract_id):
def apply_ops(contract_id, supplement_id, document_id, ops, prompt_id, raw_llm_response):
"""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
updated = 0
deleted = 0
unresolved = 0
seq = get_next_seq(contract_id)
for op in ops:
@@ -37,6 +48,7 @@ def apply_ops(contract_id, supplement_id, document_id, ops, prompt_id, raw_llm_r
# ADD without name → UNRESOLVED
_log_unresolved(contract_id, supplement_id, seq, op, prompt_id, document_id, raw_llm_response, "ADD with empty name")
seq += 1
unresolved += 1
continue
name_hash = _hash(name, nr.get("date_start"))
execute(
@@ -60,6 +72,7 @@ def apply_ops(contract_id, supplement_id, document_id, ops, prompt_id, raw_llm_r
if not th:
_log_unresolved(contract_id, supplement_id, seq, op, prompt_id, document_id, raw_llm_response, "UPDATE with empty target_hash")
seq += 1
unresolved += 1
continue
execute(
"""INSERT INTO spec_events (id, contract_id, supplement_id, seq, action, target_hash,
@@ -81,6 +94,7 @@ def apply_ops(contract_id, supplement_id, document_id, ops, prompt_id, raw_llm_r
if not th:
_log_unresolved(contract_id, supplement_id, seq, op, prompt_id, document_id, raw_llm_response, "DELETE with empty target_hash")
seq += 1
unresolved += 1
continue
execute(
"""INSERT INTO spec_events (id, contract_id, supplement_id, seq, action, target_hash,
@@ -99,27 +113,19 @@ def apply_ops(contract_id, supplement_id, document_id, ops, prompt_id, raw_llm_r
elif action == "UNRESOLVED":
# Log but don't apply
execute(
"""INSERT INTO spec_events (id, contract_id, supplement_id, seq, action, target_hash,
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),
),
)
_log_unresolved(contract_id, supplement_id, seq, op, prompt_id, document_id, raw_llm_response,
op.get("reason", op.get("comment", "")))
seq += 1
unresolved += 1
else:
# Unknown action — log as UNRESOLVED
_log_unresolved(contract_id, supplement_id, seq, op, prompt_id, document_id, raw_llm_response,
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):
@@ -182,6 +188,20 @@ def _update_spec_current(contract_id, name_hash, new_values):
sets.append(f"{field} = %s")
params.append(new_values[field])
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')")
params.extend([contract_id, name_hash])
execute(
+1 -1
View File
@@ -201,7 +201,7 @@ def _build_spec_text(current_spec: list) -> str:
def build_prompt(current_spec: list, doc_text: str) -> tuple:
"""
Формирует промпт для LLM.
1. Пробует получить активный промпт из БД (Lucee API).
1. Пробует получить активный промпт из БД (SQLite, db.prompts).
2. При неудаче — fallback на хардкод.
Возвращает (текст_промпта, prompt_id).
prompt_id — UUID версии промпта из БД, или "" если fallback.
+12 -2
View File
@@ -2,16 +2,26 @@
def register_routes(app):
from routes.upload_bp import upload_bp
import config
from routes.upload_bp import contracts_upload_sink
from routes.pipeline_bp import pipeline_bp
from routes.api_bp import api_bp
from routes.prompts_bp import prompts_bp
from routes.health_bp import health_bp
from routes.pages_bp import pages_bp
from upload.backend.upload_refs import create_upload_refs_blueprint
app.register_blueprint(upload_bp)
app.register_blueprint(pipeline_bp)
app.register_blueprint(api_bp)
app.register_blueprint(prompts_bp)
app.register_blueprint(health_bp)
app.register_blueprint(pages_bp)
# Переиспользуемый слой закачки через ВМ (модуль upload, паттерн drhider)
app.register_blueprint(create_upload_refs_blueprint({
"apiPrefix": "/api",
"vmUploadPrefix": config.VM_UPLOAD_PREFIX,
"maxFileBytes": config.VM_UPLOAD_MAX_BYTES,
"pullRetries": config.PULL_RETRIES,
"pullRetryDelay": config.PULL_RETRY_DELAY,
}, sink=contracts_upload_sink))
+14 -1
View File
@@ -1,8 +1,15 @@
"""HTML-страницы."""
from flask import Blueprint, render_template
import os
from flask import Blueprint, render_template, send_from_directory
pages_bp = Blueprint("pages", __name__)
# Переиспользуемый модуль upload: отдаём ES-модули фронтенда браузеру (как drhider)
_UPLOAD_FRONTEND = os.path.join(
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
"upload", "frontend",
)
@pages_bp.route("/")
def index():
@@ -12,3 +19,9 @@ def index():
@pages_bp.route("/architect")
def architect():
return render_template("architect.html")
@pages_bp.route("/upload/<path:filename>")
def upload_frontend(filename):
"""Отдать статику ES-модулей upload/frontend (для клиентского ZIP)."""
return send_from_directory(_UPLOAD_FRONTEND, filename)
+2 -2
View File
@@ -39,7 +39,7 @@ def prompts_save():
prompt_body = body.get("body", "")
notes = body.get("notes", "")
try:
p = db_prompts.insert(role, name, prompt_body, notes)
p = db_prompts.save_new_version(role, name, prompt_body, notes)
return jsonify(ok=True, id=p["id"])
except Exception as e:
return jsonify(ok=False, error=str(e)), 500
@@ -65,7 +65,7 @@ def prompts_delete():
return jsonify(ok=False, error="body required"), 400
prompt_id = body.get("id")
try:
db_prompts.delete(prompt_id)
db_prompts.delete_prompt(prompt_id)
return jsonify(ok=True)
except Exception as e:
return jsonify(ok=False, error=str(e)), 500
+43 -113
View File
@@ -1,47 +1,45 @@
"""Upload blueprint — загрузка, конвертация, распаковка."""
import io, os, base64, hashlib, zipfile, tempfile, subprocess
from flask import Blueprint, request, jsonify, send_file
"""Upload-модуль: конвертация .doc→.docx + хранение/парсинг (sink для VM-буфера)."""
import base64
import hashlib
import httpx
from services.parse import parse_file
from db import documents
from config import MAX_CONTENT_LENGTH
upload_bp = Blueprint("upload", __name__)
ALLOWED = {"pdf", "docx", "doc", "zip"}
import config
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 _convert(filename: str, data: bytes) -> bytes:
""".doc → .docx через внешний libreoffice-сервис. Возвращает docx-байты."""
try:
resp = httpx.post(
config.CONVERT_SERVICE_URL + "/convert",
files={"file": (filename, data, "application/msword")},
timeout=120,
)
except httpx.TimeoutException:
raise Exception("conversion timeout")
if resp.status_code != 200:
try:
err = resp.json().get("error", "conversion failed")
except Exception:
err = "conversion failed"
raise Exception(err)
return resp.content
@upload_bp.route("/upload", methods=["POST"])
def upload():
"""Загрузка одного файла + авто-парсинг → БД."""
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()
def _store_and_parse(filename: str, data: bytes, batch_id, contract_id, zip_source=None, mime_type="application/octet-stream"):
"""Общая логика: дедуп → insert в БД → авто-парсинг. Возвращает dict-результат."""
content_hash = hashlib.sha256(data).hexdigest()[:16]
batch_id = request.form.get("batch_id")
zip_source = request.form.get("zip_source")
# Дедупликация по хешу
if batch_id:
existing = documents.get_by_hash(batch_id, content_hash)
if existing:
return jsonify(ok=False, error="duplicate", doc_id=existing["id"])
return {"ok": False, "error": "duplicate", "doc_id": existing["id"], "duplicate_of": True}
doc = documents.insert(
filename=f.filename,
mime_type=f.content_type or "application/octet-stream",
filename=filename,
mime_type=mime_type,
original_bytes=base64.b64encode(data).decode(),
batch_id=batch_id,
zip_source=zip_source,
@@ -50,96 +48,28 @@ def upload():
# Авто-парсинг
try:
result = parse_file(f.filename, data)
result = parse_file(filename, data)
if result["status"] == "parsed":
documents.set_parsed(doc["id"], result["elements"])
parsed = {"status": "parsed", "element_count": result.get("element_count", 0)}
else:
documents.set_error(doc["id"], result.get("error", "parse failed"))
parsed = {"status": "error", "error": result.get("error", "parse failed")}
except Exception as e:
documents.set_error(doc["id"], str(e))
result = {"status": "error", "error": str(e)}
parsed = {"status": "error", "error": str(e)}
contract_id = request.form.get("contract_id")
return jsonify(
ok=True,
doc_id=doc["id"],
contract_id=contract_id,
parsed={"status": result["status"], "element_count": result.get("element_count", 0)},
)
return {"ok": True, "doc_id": doc["id"], "contract_id": contract_id, "parsed": parsed}
@upload_bp.route("/convert-doc", methods=["POST"])
def convert_doc():
""".doc → .docx через libreoffice (без сохранения на диск)."""
f = request.files.get("files")
if not f:
return jsonify(ok=False, error="no file"), 400
def contracts_upload_sink(name, content, batch_id=None, contract_id=None, zip_source=None):
"""Sink для переиспользуемого модуля upload: вставить файл в documents + авто-парсинг.
data = f.read()
doc_path = None
tmpdir = None
try:
with tempfile.NamedTemporaryFile(suffix=".doc", delete=False) as tmp:
tmp.write(data)
doc_path = tmp.name
tmpdir = tempfile.mkdtemp()
subprocess.run(
["libreoffice", "--headless", "--convert-to", "docx", "--outdir", tmpdir, doc_path],
timeout=30, capture_output=True,
)
docx_files = [x for x in os.listdir(tmpdir) if x.endswith(".docx")]
if docx_files:
with open(os.path.join(tmpdir, docx_files[0]), "rb") as out:
return send_file(
io.BytesIO(out.read()),
mimetype="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
)
return jsonify(ok=False, error="conversion produced no output"), 500
finally:
if doc_path and os.path.exists(doc_path):
os.unlink(doc_path)
if tmpdir and os.path.exists(tmpdir):
for x in os.listdir(tmpdir):
os.unlink(os.path.join(tmpdir, x))
os.rmdir(tmpdir)
@upload_bp.route("/unzip-upload", methods=["POST"])
def unzip_upload():
"""Распаковать ZIP → список файлов (base64 для фронтенда)."""
f = request.files.get("files")
if not f:
return jsonify(ok=False, error="no file"), 400
data = f.read()
MAX_FILES = 500
MAX_UNCOMPRESSED = 500 * 1024 * 1024 # 500 MB
files = []
total = 0
with zipfile.ZipFile(io.BytesIO(data)) as zf:
if len(zf.namelist()) > MAX_FILES:
return jsonify(ok=False, error=f"too many files in ZIP (max {MAX_FILES})"), 400
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 jsonify(ok=False, error="total uncompressed size exceeds 500 MB"), 400
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(),
})
return jsonify(ok=True, files=files)
Вызывается модулем create_upload_refs_blueprint(cfg, sink=...) на каждый
вытянутый с ВМ файл. .doc конвертируется в .docx через внешний LibreOffice
(парсер умеет только .docx). Возвращает dict ok/doc_id/contract_id/parsed.
"""
if name.lower().endswith(".doc"):
content = _convert(name, content)
name = name[:-4] + ".docx"
return _store_and_parse(name, content, batch_id, contract_id, zip_source)
+9 -7
View File
@@ -10,7 +10,7 @@ Classify service — LLM-based document classification.
- Двухпроходная архитектура: LLM извлекает строки (тип/номер/дата/контрагент),
Python в grouping.py нормализует и группирует детерминированно.
"""
import json, re, os
import json, re
from concurrent.futures import ThreadPoolExecutor, as_completed
import httpx
@@ -23,9 +23,7 @@ log = __import__("logging").getLogger(__name__)
# Увеличивать осторожно — api.aillm.ru может троттлить
MAX_WORKERS = 4
LLM_URL = "https://api.aillm.ru/v1/chat/completions"
LLM_KEY = os.environ.get("LLM_KEY") or os.environ.get("LLM_API_KEY", "")
LLM_MODEL = "gpt-oss-120b"
from config import LLM_URL, LLM_KEY, LLM_MODEL
# Ленивый singleton — обратная совместимость
_classify_llm = None
@@ -49,9 +47,13 @@ _GARBAGE_HEADER_MARKERS = [
'СЧЕТ-ФАКТУРА', 'СЧЕТ НА ОПЛАТУ', 'АКТ СВЕРКИ',
'АКТ ОКАЗАННЫХ УСЛУГ', 'АКТ ВЫПОЛНЕННЫХ РАБОТ',
'ПЛАТЁЖНОЕ ПОРУЧЕНИЕ', 'УНИВЕРСАЛЬНЫЙ ПЕРЕДАТОЧНЫЙ',
'УПД', 'ПЛАТЕЖНОЕ ПОРУЧЕНИЕ',
'ПЛАТЕЖНОЕ ПОРУЧЕНИЕ',
]
_GARBAGE_HEADER_RE = re.compile(
r'(?m)^\s*(?:УПД|УНИВЕРСАЛЬНЫЙ ПЕРЕДАТОЧНЫЙ ДОКУМЕНТ)\b'
)
def _is_garbage_by_filename(filename: str) -> bool:
"""Stage 1: regex по имени файла — быстро, 0 токенов."""
@@ -61,7 +63,7 @@ def _is_garbage_by_filename(filename: str) -> bool:
def _is_garbage_by_header(text: str) -> bool:
"""Stage 2: ключевые слова в первых 2KB текста — быстро, 0 токенов."""
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):
@@ -91,7 +93,7 @@ def classify_batch(batch_id, llm_client=None, repo=None):
_db = repo if repo else db_docs
_llm = llm_client if llm_client else _get_classify_client()
# Сбросить статус — allow re-classify after file changes
# Сбросить 'processing' -> 'pending' (crash recovery); уже классифицированные не трогаем
_db.reset_classify_status(batch_id)
pending = _db.list_pending(batch_id)
if not pending:
+2 -4
View File
@@ -1,9 +1,7 @@
"""LLM service — call LLM API with optional DI."""
import os, json
import json
LLM_URL = "https://api.aillm.ru/v1/chat/completions"
LLM_KEY = os.environ.get("LLM_KEY") or os.environ.get("LLM_API_KEY", "")
LLM_MODEL = "gpt-oss-120b"
from config import LLM_URL, LLM_KEY, LLM_MODEL
# Ленивый singleton — обратная совместимость
_llm_client = None
+29 -2
View File
@@ -77,6 +77,27 @@ def run_pipeline(contract_id, order_ids, build_prompt_fn):
ops = result.get("ops", [])
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).
# LLM возвращает target_id, а apply_ops() читает target_hash — без этого UPDATE/DELETE уходят в UNRESOLVED.
for _op in ops:
_tid = _op.get("target_id", "")
if _tid and isinstance(_tid, str) and _tid.startswith("r"):
try:
_idx = int(_tid[1:]) - 1
if 0 <= _idx < len(current_spec):
_op["target_hash"] = current_spec[_idx]["hash"]
except ValueError:
pass
yield {
"type": "llm_done",
"supplement_id": sid,
@@ -86,6 +107,11 @@ def run_pipeline(contract_id, order_ids, build_prompt_fn):
"time_s": round(time.time() - t1, 1),
}
# full_replace: очистить текущее состояние, чтобы ADD-строки новой редакции
# не дублировали старые (reset на старте пайплайна это не покрывает).
if mode == "full_replace":
spec_events.clear_current(contract_id)
# Apply ops to DB via apply_ops
try:
summary = spec_events.apply_ops(
@@ -103,7 +129,7 @@ def run_pipeline(contract_id, order_ids, build_prompt_fn):
}
# Arithmetic check
check_arithmetic(ops)
arithmetic_warnings = check_arithmetic(ops)
yield {
"type": "applied",
@@ -111,6 +137,7 @@ def run_pipeline(contract_id, order_ids, build_prompt_fn):
"filename": filename,
"summary": summary,
"ops": applied_ops,
"arithmetic_warnings": arithmetic_warnings,
}
except Exception as e:
@@ -122,7 +149,7 @@ def run_pipeline(contract_id, order_ids, build_prompt_fn):
}
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):
+20 -4
View File
@@ -1,9 +1,9 @@
// ⛔ НЕ МЕНЯТЬ БЕЗ РАЗРЕШЕНИЯ НАЕЛЯ ⛔
// Contracts App v2.0 — всё на Flask, ВМ больше нет
var VM_API = '';
var UPLOAD_URL = '/upload';
var CONVERT_URL = '/convert-doc';
var UNZIP_URL = '/unzip-upload';
// ВМ-буфер загрузки (паттерн drhider): браузер кладёт файл сюда (WebDAV, мимо шлюза),
// бэк сам тянет его по /api/upload_refs. Origin должен быть в CORS на nginx ВМ.
var VM_UPLOAD_URL = 'https://contracts.kube5s.ru/contracts-upload/';
// SITE_URL удалён (Фаза 4) — не использовался
var fileInput = document.getElementById('fileInput');
@@ -113,6 +113,20 @@ fileInput.addEventListener('change', async function() {
onFilesSelected(newFiles);
});
// Выбор папки (webkitdirectory) — та же обработка, что и файлы
var folderInput = document.getElementById('folderInput');
var folderBtn = document.getElementById('folderBtn');
if (folderBtn && folderInput) {
folderBtn.addEventListener('click', function() { folderInput.click(); });
folderInput.addEventListener('change', function() {
var files = Array.from(folderInput.files).filter(function(f) {
var n = f.name.toLowerCase();
return n.endsWith('.pdf') || n.endsWith('.doc') || n.endsWith('.docx') || n.endsWith('.zip');
});
if (files.length) onFilesSelected(files);
});
}
// ── Классификация ──────────────────────────────────────────
function showClassifyBtn() {
var btn = document.getElementById('classifyBtn');
@@ -149,7 +163,9 @@ async function runClassify() {
var r = await fetch(VM_API + '/api/batch-progress?batch=' + state.batchId);
var d = await r.json();
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;
if (total > 0) totalFiles = total;
btn.innerHTML = '<span class="spinner"></span> Классификация... ' + done + '/' + total + ' (' + Math.round((Date.now() - start) / 1000) + 'с)';
+5 -5
View File
@@ -90,15 +90,15 @@ function applyCompareEvent(sections, event) {
*/
function renderCompareSectionHeader(sec) {
if (sec.status === 'extracting') {
return '⏳ ' + sec.filename;
return '⏳ ' + escHtml(sec.filename);
}
if (sec.status === 'llm_done' || sec.status === 'applied') {
return '✓ ' + sec.filename + ' — ' + sec.ops_count + ' оп., ' + sec.mode + ' (' + sec.time_s + 'с)';
return '✓ ' + escHtml(sec.filename) + ' — ' + sec.ops_count + ' оп., ' + sec.mode + ' (' + sec.time_s + 'с)';
}
if (sec.status === 'error') {
return '✗ ' + sec.filename + ': ' + sec.error;
return '✗ ' + escHtml(sec.filename) + ': ' + sec.error;
}
return sec.filename;
return escHtml(sec.filename);
}
/**
@@ -117,7 +117,7 @@ function renderCompareOpsTable(ops) {
// Цвет строки зависит от действия: ADD=зелёный, DELETE=красный, UPDATE=жёлтый
var cls = action === 'ADD' ? 'diff-added' : action === 'DELETE' ? 'diff-deleted' : action === 'UPDATE' ? 'diff-changed' : '';
var nr = op.new_row || {};
html += '<tr class="' + cls + '"><td>' + action + '</td><td>' + (nr.name||'') + '</td><td class="num-cell">' + (nr.price!=null?nr.price:'') + '</td><td class="num-cell">' + (nr.qty!=null?nr.qty:'') + '</td><td class="num-cell">' + (nr.sum!=null?nr.sum:'') + '</td><td>' + (nr.date_start||'') + '</td></tr>';
html += '<tr class="' + cls + '"><td>' + action + '</td><td>' + escHtml(nr.name||'') + '</td><td class="num-cell">' + (nr.price!=null?nr.price:'') + '</td><td class="num-cell">' + (nr.qty!=null?nr.qty:'') + '</td><td class="num-cell">' + (nr.sum!=null?nr.sum:'') + '</td><td>' + (nr.date_start||'') + '</td></tr>';
});
html += '</tbody></table></div>';
return html;
+55 -4
View File
@@ -1,4 +1,55 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="4" fill="#001C34"/>
<text x="16" y="24" text-anchor="middle" font-size="22" font-weight="bold" fill="white" font-family="sans-serif">N</text>
</svg>
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="57"
height="57"
xml:space="preserve"
overflow="hidden"
version="1.1"
id="svg8"
sodipodi:docname="U_v3.svg"
inkscape:version="1.3.2 (091e20e, 2023-11-25, custom)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview
id="namedview8"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="16.226667"
inkscape:cx="27.146672"
inkscape:cy="28.317584"
inkscape:window-width="2560"
inkscape:window-height="1494"
inkscape:window-x="-11"
inkscape:window-y="-11"
inkscape:window-maximized="1"
inkscape:current-layer="svg8" /><defs
id="defs2"><clipPath
id="clip0"><rect
x="652"
y="420"
width="64"
height="75"
id="rect1" /></clipPath><clipPath
id="clip1"><rect
x="652"
y="420"
width="64"
height="70"
id="rect2" /></clipPath></defs><g
clip-path="url(#clip0)"
transform="matrix(1.0135748,0,0,1.0135748,-664.97034,-440.30567)"
id="g8"><g
clip-path="url(#clip1)"
id="g7"><g
id="g6"><path
d="m 114.028,43.1492 v 7.6592 c 0,3.7843 -3.08,6.8632 -6.866,6.8632 L 85.3367,57.5419 c -3.7853,0 -6.8655,-3.0805 -6.8655,-6.8643 v -2.5279 l 0.0555,0.009 V 15.8148 l -10.3823,2.0127 0.0045,3.8772 -0.0045,0.0015 v 28.971 c 0,9.481 7.7132,17.1923 17.1867,17.1923 l 21.8359,0.1313 c 9.474,0 17.187,-7.7117 17.187,-17.1928 v -2.6541 l 0.027,0.0045 V 15.8145 l -10.382,2.0126 0.028,25.3214 z"
fill="#001c34"
fill-rule="evenodd"
transform="matrix(1,0,0,1.01337,587.92,420.059)"
id="path6" /></g></g></g></svg>

Before

Width:  |  Height:  |  Size: 246 B

After

Width:  |  Height:  |  Size: 2.0 KiB

+54 -159
View File
@@ -26,6 +26,7 @@ function statusToHTML(st) {
if (!st || !st.kind) return '';
switch (st.kind) {
case 'connecting': return '⏳ соединение... ' + (st.elapsed || 0) + 'с';
case 'converting': return '⏳ конвертация... ' + (st.elapsed || 0) + 'с';
case 'uploading': return '⏳ отправка... ' + (st.elapsed || 0) + 'с';
case 'uploaded': return '<span class="status-ok">✓</span>';
case 'unzipping': return '⏳ распаковка...';
@@ -51,7 +52,7 @@ function statusToHTML(st) {
*/
function renderFiles(state) {
if (state.files.length === 0) {
fileTable.innerHTML = '<tr class="empty-row"><td colspan="7">Нет файлов — выберите .docx / .pdf</td></tr>';
fileTable.innerHTML = '<tr class="empty-row"><td colspan="7">Нет файлов — выберите .doc / .docx / .pdf</td></tr>';
lucide.createIcons();
return;
}
@@ -204,21 +205,18 @@ window.toggleClassifyDetail = async function(i) {
};
/**
* ⛔ НЕ МЕНЯТЬ ⛔ uploadFile — fetch-загрузка с честным счётчиком времени.
* ⛔ НЕ МЕНЯТЬ БЕЗ РАЗРЕШЕНИЯ НАЕЛЯ ⛔ uploadFile — загрузка через ВМ-буфер (паттерн drhider).
*
* v2.0.3: вместо фейкового ↑N% — честный счётчик ⏳ соединение... Nс → ⏳ отправка... Nс.
* fetch() не даёт реальный upload progress, поэтому считаем секунды.
* Кэш-бастинг: ?_=Date.now()
* Фаза 1: PUT файла на ВМ (WebDAV /contracts-upload/, мимо шлюза кластера ~64КБ).
* Фаза 2: POST /api/upload_refs {files:[{name,size,url}]} — бэк сам тянет файл с ВМ.
* Честный счётчик времени: ⏳ соединение... Nс → ⏳ отправка... Nс (fetch не даёт progress).
*/
function uploadFile(file, onProgress, zipSource) {
var startTime = Date.now();
var phase = 'connecting'; // connecting → uploading
if (onProgress) onProgress({ kind: 'connecting', elapsed: 0 });
var fd = new FormData();
fd.append('files', file, file.name);
if (state.contractId) fd.append('contract_id', state.contractId);
fd.append('batch_id', state.batchId);
if (zipSource) fd.append('zip_source', zipSource);
var token = crypto.randomUUID();
var vmUrl = VM_UPLOAD_URL + token + '_0';
// Честный счётчик: каждую секунду обновляем elapsed
var timer = setInterval(function() {
@@ -226,17 +224,33 @@ function uploadFile(file, onProgress, zipSource) {
if (onProgress) onProgress({ kind: phase, elapsed: elapsed });
}, 1000);
return fetch(UPLOAD_URL + '?_=' + Date.now(), { method: 'POST', body: fd })
// Фаза 1: PUT файла на ВМ-буфер
return fetch(vmUrl, { method: 'PUT', body: file, headers: { 'Content-Type': 'application/octet-stream' } })
.then(function(r) {
if (!r.ok) throw new Error('VM upload HTTP ' + r.status);
phase = 'uploading';
// Фаза 2: refs на бэкенд → pull с ВМ
var body = { batch_id: state.batchId, files: [{ name: file.name, size: file.size, url: vmUrl }] };
if (state.contractId) body.contract_id = state.contractId;
if (zipSource) body.zip_source = zipSource;
return fetch('/api/upload_refs?_=' + Date.now(), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
})
.then(function(r) {
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.json();
})
.then(function(data) {
clearInterval(timer);
if (data.ok) {
if (data.ok && data.results && data.results.length === 1) {
var res = data.results[0];
if (!res.ok) throw new Error(res.error || 'Неизвестная ошибка');
var elapsed = Math.floor((Date.now() - startTime) / 1000);
if (onProgress) onProgress({ kind: 'uploading', elapsed: elapsed });
return data;
return res; // {ok, doc_id, contract_id, parsed}
}
throw new Error(data.error || 'Неизвестная ошибка');
})
@@ -321,150 +335,6 @@ function applyParseResult(entry, parsed, elapsed) {
}
}
/**
* addZipFile(file) — Async-action: обработка ZIP-файла (Фаза 1, упрощена).
*
* 1. Отправляет ZIP на бэкенд (только распаковка, без БД/парсинга)
* 2. Для каждого файла из архива: base64 → Blob → File → addRegularFile()
*
* addRegularFile делает ВСЁ: upload, parse, дубликаты, статусы, render.
* Никакого дублирования логики — единый путь для обычных и ZIP-файлов.
*/
async function addZipFile(file) {
var zipEntry = { name: file.name, lastModified: file.lastModified, size: file.size, status: { kind: 'unzipping' } };
state.files.push(zipEntry);
render(state);
try {
// Шаг 1: распаковать ZIP на бэкенде
var zipResp = await new Promise(function(resolve, reject) {
var xhr = new XMLHttpRequest();
xhr.open('POST', UNZIP_URL);
xhr.responseType = 'json';
xhr.onload = function() { resolve(xhr.response); };
xhr.onerror = function() { reject(new Error('Сеть')); };
xhr.ontimeout = function() { reject(new Error('Таймаут')); };
xhr.timeout = 60000;
var fd = new FormData();
fd.append('files', file);
xhr.send(fd);
});
if (!zipResp.ok || !zipResp.files) throw new Error('unzip failed');
// Подтверждение: показать первые 10 файлов + итог
var preview = zipResp.files.slice(0, 10).map(function(f) { return ' • ' + f.filename + ' (' + (f.size/1024).toFixed(1) + ' KB)'; }).join('\n');
if (total > 10) preview += '\n ... и ещё ' + (total - 10) + ' файлов';
if (!confirm('Архив «' + file.name + '» — ' + total + ' файлов:\n\n' + preview + '\n\nЗагрузить эти файлы?')) {
// Отмена — убрать строку ZIP
var zipPos2 = state.files.indexOf(zipEntry);
if (zipPos2 >= 0) state.files.splice(zipPos2, 1);
render(state);
return;
}
// Шаг 2: обработать каждый файл из архива
// Временная строка ZIP остаётся в таблице — обновляем её статус
var total = zipResp.files.length;
for (var zi = 0; zi < total; zi++) {
var zf = zipResp.files[zi];
zipEntry.status = { kind: 'unzipping' }; // обновляем статус
render(state);
if (zf.error) continue;
// base64 → File → addRegularFile (единый путь)
var byteStr = atob(zf.data_b64);
var bytes = new Uint8Array(byteStr.length);
for (var b = 0; b < byteStr.length; b++) bytes[b] = byteStr.charCodeAt(b);
var mime = {docx:'application/vnd.openxmlformats-officedocument.wordprocessingml.document',doc:'application/msword',pdf:'application/pdf'}[zf.ext] || 'application/octet-stream';
var extractedFile = new File([bytes], zf.filename, { type: mime, lastModified: Date.now() });
await addRegularFile(extractedFile, file.name);
}
// Шаг 3: убрать временную строку ZIP (только после успешной обработки всех файлов)
var zipPos = state.files.indexOf(zipEntry);
if (zipPos >= 0) state.files.splice(zipPos, 1);
render(state);
} catch(ze) {
// Ошибка — показать на строке ZIP (zipEntry всё ещё в state.files)
zipEntry.status = { kind: 'error', text: 'ZIP: ' + ze.message };
render(state);
}
}
/**
* addRegularFile(file) — Async-action: обработка обычного файла (Фаза 1).
*
* 1. Добавляет/заменяет файл в state.files
* 2. Загружает через XHR (uploadFile) с индикатором прогресса
* 3. После загрузки: проставляет doc_id, contractId
* 4. applyParseResult — применяет результат парсинга
*
* Мутирует state.files, вызывает render(state) после каждого изменения.
*/
async function addRegularFile(file, zipSource) {
// Создать запись с начальным статусом
var entry = { name: file.name, lastModified: file.lastModified, size: file.size, file: file, status: { kind: 'connecting', elapsed: 0 }, zip_source: zipSource || null };
// ДЕДУПЛИКАЦИЯ: ключ = (zip_source, name), чтобы одноимённые файлы из разных ZIP не затирались
var dupKey = (zipSource || '') + '/' + file.name;
var existingIdx = -1;
for (var j = 0; j < state.files.length; j++) {
var ejKey = (state.files[j].zip_source || '') + '/' + state.files[j].name;
if (ejKey === dupKey) { existingIdx = j; break; }
}
var rowIdx;
if (existingIdx >= 0) {
// Файл с таким именем уже есть — спросить пользователя
if (!confirm('Файл «' + file.name + '» уже есть в списке.\n\nOK — перезаписать\nОтмена — пропустить')) {
return; // пользователь выбрал «пропустить»
}
state.files[existingIdx] = entry;
rowIdx = existingIdx;
} else {
state.files.push(entry);
rowIdx = state.files.length - 1;
}
render(state);
try {
// fetch-загрузка с честным счётчиком времени
var resp = await uploadFile(file, function(st) {
state.files[rowIdx].status = st;
render(state);
}, zipSource);
// Бэкенд возвращает doc_id и contract_id (нижний регистр — Python keys)
if (resp && resp.contract_id) state.contractId = resp.contract_id;
state.files[rowIdx].doc_id = resp.doc_id;
// Дубликат?
if (resp && resp.duplicate_of) {
state.files[rowIdx].status = { kind: 'duplicate' };
render(state);
return;
}
// Warning от парсинга?
if (resp && resp.warning) {
state.files[rowIdx].status = { kind: 'warning', text: resp.warning };
render(state);
} else {
state.files[rowIdx].status = { kind: 'uploaded' };
}
state.files[rowIdx].uploaded = true;
// Авто-парсинг: бэкенд парсит всё (PDF + DOCX + DOC)
var t0 = Date.now();
var pr = resp.parsed;
var elapsed = pr && pr.status === 'parsed' ? ((Date.now() - t0) / 1000).toFixed(1) : null;
applyParseResult(state.files[rowIdx], pr, elapsed);
} catch(err) {
state.files[rowIdx].status = { kind: 'error', text: err.message };
}
render(state);
}
/**
* finalizeUpload() — Завершение цикла загрузки (Фаза 1).
*
@@ -491,6 +361,31 @@ async function finalizeUpload() {
lucide.createIcons();
}
/**
* expandZipClient(f) — клиентское рекурсивное раскрытие ZIP (drhider listZipFiles).
* Каждый документ из архива (включая вложенные zip) добавляется в state.files
* с zip_source = имя архива. Fallback: если раскрыть не удалось — архив как есть.
*/
async function expandZipClient(f) {
var nested = [];
try {
if (window.listZipFiles) {
nested = await window.listZipFiles(f, ['.pdf', '.doc', '.docx']);
}
} catch (e) {
nested = [];
}
if (!nested || nested.length === 0) {
state.files.push({ name: f.name, lastModified: f.lastModified, size: f.size, file: f, status: { kind: 'connecting', elapsed: 0 }, zip_source: null });
state.files[state.files.length - 1]._pendingFile = f;
return;
}
for (var z = 0; z < nested.length; z++) {
state.files.push({ name: nested[z].name, lastModified: nested[z].lastModified, size: nested[z].size, file: nested[z], status: { kind: 'connecting', elapsed: 0 }, zip_source: f.name });
state.files[state.files.length - 1]._pendingFile = nested[z];
}
}
/**
* ⛔ НЕ МЕНЯТЬ ⛔ onFilesSelected — все строки сразу, потом загрузка.
*
@@ -507,7 +402,7 @@ async function onFilesSelected(newFiles) {
for (var i = 0; i < newFiles.length; i++) {
var f = newFiles[i];
if (f.name.toLowerCase().endsWith('.zip')) {
await addZipFile(f);
await expandZipClient(f);
continue;
}
state.files.push({ name: f.name, lastModified: f.lastModified, size: f.size, file: f, status: { kind: 'connecting', elapsed: 0 }, zip_source: null });
@@ -526,7 +421,7 @@ async function onFilesSelected(newFiles) {
var resp = await uploadFile(f, function(st) {
entry.status = st;
render(state);
});
}, entry.zip_source);
if (resp && resp.contract_id) state.contractId = resp.contract_id;
entry.doc_id = resp.doc_id;
entry.status = { kind: 'uploaded' };
+16 -8
View File
@@ -73,7 +73,7 @@
<body>
<div class="topbar">
<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.5</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);">
<span id="stepUpload">○ Загрузка</span><span></span>
<span id="stepClassify">○ Классификация</span><span></span>
@@ -90,7 +90,11 @@
Загрузка договоров/приложений/спецификаций
</div>
<div class="card-body">
<input type="file" id="fileInput" accept=".docx,.pdf,.zip" multiple style="margin-bottom:6px;width:100%;">
<input type="file" id="fileInput" accept=".doc,.docx,.pdf,.zip" multiple style="margin-bottom:6px;width:100%;">
<input type="file" id="folderInput" webkitdirectory multiple style="display:none;">
<div style="margin-bottom:6px;">
<button type="button" class="btn" id="folderBtn" style="font-size:12px;height:30px;">📁 Выбрать папку</button>
</div>
<div style="text-align:right;font-size:11px;color:var(--muted);margin-bottom:6px;">Порядок определяется автоматически при классификации</div>
<div style="font-size:11px;color:var(--muted);margin-bottom:6px;">⚠ При совпадении имён — запрос на перезапись (OK / Отмена). Файлы из ZIP-архивов загружаются через тот же поток.</div>
@@ -216,11 +220,15 @@
</div>
</div>
<script src="/static/state.js?v=2.0.5"></script>
<script src="/static/app_utils.js?v=2.0.5"></script>
<script src="/static/files.js?v=2.0.5"></script>
<script src="/static/groups.js?v=2.0.5"></script>
<script src="/static/compare.js?v=2.0.5"></script>
<script src="/static/app.js?v=2.0.5"></script>
<script type="module">
import { listZipFiles } from '/upload/zip/list_zip_files.js';
window.listZipFiles = listZipFiles;
</script>
<script src="/static/state.js?v=2.0.21"></script>
<script src="/static/app_utils.js?v=2.0.21"></script>
<script src="/static/files.js?v=2.0.21"></script>
<script src="/static/groups.js?v=2.0.21"></script>
<script src="/static/compare.js?v=2.0.21"></script>
<script src="/static/app.js?v=2.0.21"></script>
</body>
</html>
+75
View File
@@ -0,0 +1,75 @@
# Тесты contracts-flask
Набор pytest-тестов под актуальный код сервиса (`site/`).
Покрытие: юнит (чистая логика), интеграционные (SQLite), HTTP-эндпоинты (Flask test client), безопасность.
## Запуск
```bash
cd contracts-flask
pytest tests/ -q
```
pytest установлен в venv: `/home/naeel/nubes/contracts/.venv/bin/python -m pytest tests/ -q`.
## Файлы
| Файл | Что тестирует |
|---|---|
| `conftest.py` | site/ в `sys.path`; фикстура `db` — изолированная БД (временный `DB_PATH` + свежая схема); защита реальной `/tmp/contracts.db` от пересоздания |
| `test_metrics.py` | `services/metrics.py`: `normalize_date`, `check_arithmetic` (sum == price×qty), `_to_decimal`, `ClassifyMetrics` |
| `test_grouping.py` | `services/grouping.py`: `normalize_number`, `group_documents` (группировка contract+supplement, unresolved), `apply_groups` (mock db) |
| `test_llm_client.py` | `services/llm_client.py`: `FakeLLMClient` (точное/частичное совпадение, default, история вызовов), `HttpxLLMClient` |
| `test_llm.py` | `services/llm.py`: `call_llm` — парсинг plain JSON и markdown-обёрток (```json) |
| `test_classify.py` | `services/classify.py`: garbage-фильтры по имени/заголовку, `_safe_json_parse` (7 edge-case: trailing comma, chatter, пусто), `_smart_extract` |
| `test_parse.py` | `services/parse.py`: `parse_file` (text/docx/pdf, ошибки), `_parse_text` |
| `test_connection.py` | `db/connection.py`: `_pg_to_sqlite` (все замены %s/::jsonb/BOOLEAN/ILIKE/TRUE), `_extract_table` |
| `test_spec_events.py` | `db/spec_events.py`: `apply_ops` (ADD/UPDATE/DELETE/UNRESOLVED/unknown), `clear_current`, `reset`, `_hash`, `get_next_seq` |
| `test_spec_current.py` | `db/spec_current.py`: `list_by_contract` (порядок), `get_elements_json` |
| `test_process_pipeline.py` | `services/process.py`: `run_pipeline` с Fake LLM — **full_replace не дублирует строки**, **трансляция target_id→target_hash**, `_elements_to_text` |
| `test_routes.py` | HTTP-эндпоинты (Flask test client): `/health`, `/api/spec-current`, `/api/groups` |
## Ключевые проверки
- `test_full_replace_no_duplicates` — два `full_replace` подряд → в `spec_current` 3 строки, а не 6; история `spec_events` (6 событий) сохранена.
- `test_update_applies` — `target_id: "r1"` транслируется в `target_hash` → UPDATE применяется (status `applied`), а не уходит в UNRESOLVED.
## Изоляция
- Каждый тест, работающий с БД, получает свою копию SQLite через фикстуру `db`
(monkeypatch `DB_PATH` → `tmp_path`, `init_db()`).
- LLM-вызовы в пайплайне подменяются через `monkeypatch.setattr("services.llm.call_llm", ...)` — сеть не используется.
## Нагрузочные тесты (`tests/load/`, маркер `load`)
Долгие стресс-тесты, реально нагружают SQLite/пайплайн. Запуск отдельно:
```bash
pytest -m load -q # только нагрузочные
pytest -m "not load" -q # быстрые юнит-тесты без нагрузочных
```
| Файл | Что нагружает |
|---|---|
| `load/test_load_pipeline.py` | массовый `run_pipeline`: N контрактов × M допников (Fake LLM) — проверка, что `full_replace` не дублирует строки в масштабе |
| `load/test_load_apply_ops.py` | массовые `apply_ops`: N ADD → N UPDATE → N DELETE, целостность `spec_events` |
| `load/test_load_concurrency.py` | конкурентная запись в SQLite (WAL + `busy_timeout`), проверка отсутствия потери данных |
| `load/stress_http.py` | standalone HTTP-стресс: `PUT` на ВМ → `POST /api/upload_refs` (реальный VM-путь) + `/health` |
Масштаб через переменные окружения (дефолты — большие):
```bash
LOAD_CONTRACTS=100 LOAD_SUPPS=20 LOAD_SERVICES=10 \
LOAD_OPS=5000 LOAD_THREADS=4 LOAD_PER=200 \
pytest -m load -q
Примечание по конкурентной записи: стабильный уровень `LOAD_THREADS=4`
(= `MAX_WORKERS` приложения). `>= 8` — нестабильно: SQLite с
`busy_timeout=5000` даёт `database is locked` (см. `History/2026-08-26-load-sqlite-locked.md`).
```
HTTP-стресс против локального/прод сервиса:
```bash
python tests/load/stress_http.py --url http://127.0.0.1:5000 --n 1000 --threads 32 --size 100000
```
+42
View File
@@ -0,0 +1,42 @@
"""Общие фикстуры для тестов contracts-flask (модули site/).
Запуск: pytest tests/ -q
"""
import os
import sys
import pytest
# site/ в sys.path — импортируем config/db/services/routes напрямую
_SITE = os.path.join(os.path.dirname(__file__), "..", "site")
if _SITE not in sys.path:
sys.path.insert(0, _SITE)
@pytest.fixture(autouse=True, scope="session")
def _isolate_global_db(tmp_path_factory):
"""Не дать импорту app.py пересоздать реальную /tmp/contracts.db."""
from db import connection as conn
conn.DB_PATH = str(tmp_path_factory.mktemp("dbs") / "session.db")
@pytest.fixture
def db(tmp_path, monkeypatch):
"""Изолированная БД: отдельный DB_PATH + свежая схема (init_db)."""
from db import connection as conn
monkeypatch.setattr(conn, "DB_PATH", str(tmp_path / "contracts_test.db"))
conn.init_db()
yield conn
# cleanup: закрыть thread-local соединение
c = getattr(conn._local, "conn", None)
if c is not None:
try:
c.close()
except Exception:
pass
conn._local.conn = None
def pytest_configure(config):
config.addinivalue_line("markers", "load: долгие нагрузочные/стресс-тесты")
+10
View File
@@ -0,0 +1,10 @@
"""Нагрузочные/стресс-тесты contracts-flask.
Запуск только нагрузочных:
pytest -m load -q
Запуск быстрых (юнит) без нагрузочных:
pytest -m "not load" -q
Масштаб настраивается переменными окружения (дефолты — большие):
LOAD_CONTRACTS, LOAD_SUPPS, LOAD_SERVICES, LOAD_OPS, LOAD_THREADS, LOAD_PER
"""
+97
View File
@@ -0,0 +1,97 @@
"""HTTP-стресс против contracts-flask (standalone, не pytest).
Реальный путь загрузки (VM-буфер):
1) PUT файла на ВМ WebDAV (мимо шлюза кластера ~64KB);
2) POST /api/upload_refs — бэк сам тянет файл с ВМ (egress).
Замеряет RPS, ошибки, таймауты. Цель — «разогреть» сервис и выявить
деградацию/обрывы (шлюз, OOM, SQLite).
Запуск (локально):
python tests/load/stress_http.py --url http://127.0.0.1:5000 --n 1000 --threads 32 --size 100000
Пример против прода (осторожно):
python tests/load/stress_http.py --url https://contractor.pythonk8s.dev.nubes.ru \
--file "/mnt/y/MY/Nubes/примеры_договоров_для_ИИ/спецификация-XXX001-03700.docx" \
--n 200 --threads 16
"""
import argparse
import time
import uuid
from concurrent.futures import ThreadPoolExecutor
import httpx
def main():
ap = argparse.ArgumentParser(description="HTTP-стресс contracts-flask")
ap.add_argument("--url", default="http://127.0.0.1:5000")
ap.add_argument("--vm-url", default="https://contracts.kube5s.ru/contracts-upload/", help="ВМ WebDAV-буфер (PUT файла)")
ap.add_argument("--n", type=int, default=1000, help="число запросов")
ap.add_argument("--threads", type=int, default=32, help="параллельных потоков")
ap.add_argument("--size", type=int, default=100000, help="размер файла в байтах (если не задан --file)")
ap.add_argument("--file", default=None, help="путь к реальному файлу (заменяет сгенерированный)")
ap.add_argument("--health-only", action="store_true", help="только /health, без загрузки файлов")
args = ap.parse_args()
if args.file:
import os as _os
fname = _os.path.basename(args.file)
with open(args.file, "rb") as _f:
payload = _f.read()
real_size = len(payload)
else:
fname = "load.docx"
payload = b"x" * args.size
real_size = args.size
ok = 0
err = 0
slow = 0
t0 = time.time()
def one(_):
nonlocal ok, err, slow
try:
with httpx.Client(timeout=30) as c:
if args.health_only:
r = c.get(f"{args.url}/health")
if r.status_code == 200:
ok += 1
else:
err += 1
return
# Фаза 1: PUT файла на ВМ WebDAV (мимо шлюза ~64KB)
token = uuid.uuid4().hex
vm_url = args.vm_url.rstrip("/") + "/" + token + "_0"
r1 = c.put(vm_url, content=payload, headers={"Content-Type": "application/octet-stream"})
if not r1.is_success:
err += 1
return
# Фаза 2: POST /api/upload_refs — бэк тянет файл с ВМ (egress)
body = {
"batch_id": token,
"files": [{"name": fname, "size": real_size, "url": vm_url}],
}
r2 = c.post(f"{args.url}/api/upload_refs", json=body)
if r2.status_code == 200:
ok += 1
else:
err += 1
except httpx.TimeoutException:
err += 1
slow += 1
except Exception:
err += 1
with ThreadPoolExecutor(max_workers=args.threads) as ex:
list(ex.map(one, range(args.n)))
elapsed = time.time() - t0
print(f"url={args.url}")
print(f"vm_url={args.vm_url}")
print(f"n={args.n} threads={args.threads} size={real_size}B file={args.file or '(gen)'} health_only={args.health_only}")
print(f"ok={ok} err={err} timeouts={slow} elapsed={elapsed:.1f}s rps={args.n / elapsed:.1f}")
if __name__ == "__main__":
main()
+52
View File
@@ -0,0 +1,52 @@
"""Нагрузочный тест: массовые apply_ops (ADD → UPDATE → DELETE).
Прогоняет N операций каждого типа одним вызовом apply_ops, замеряет время,
проверяет целостность spec_current/spec_events (ничего не потеряно, не продублировано).
"""
import os
import time
import pytest
from db import spec_events, spec_current
from db.connection import query
pytestmark = pytest.mark.load
N = int(os.getenv("LOAD_OPS", "5000"))
CID = "load-cid-111"
SID = "load-sid-111"
DID = "load-did-111"
def test_mass_apply_ops(db):
# ADD N строк
ops = [{"action": "ADD", "new_row": {"name": f"услуга {i}", "price": i, "qty": 1, "sum": i}} for i in range(N)]
t0 = time.time()
s = spec_events.apply_ops(CID, SID, DID, ops, "pid", {})
t_add = time.time() - t0
assert s["added"] == N
assert len(spec_current.list_by_contract(CID)) == N
# UPDATE всех N строк
hashes = [spec_events._hash(f"услуга {i}") for i in range(N)]
ups = [{"action": "UPDATE", "target_hash": h, "new_values": {"price": i + 1}} for i, h in enumerate(hashes)]
t0 = time.time()
s = spec_events.apply_ops(CID, SID, DID, ups, "pid", {})
t_upd = time.time() - t0
assert s["updated"] == N
assert spec_current.list_by_contract(CID)[0]["price"] == 1 # первая строка обновлена
# DELETE всех N строк
dels = [{"action": "DELETE", "target_hash": h} for h in hashes]
t0 = time.time()
s = spec_events.apply_ops(CID, SID, DID, dels, "pid", {})
t_del = time.time() - t0
assert s["deleted"] == N
assert spec_current.list_by_contract(CID) == []
total = query("SELECT count(*) AS c FROM spec_events WHERE contract_id = %s", (CID,))
assert total[0]["c"] == N * 3 # ADD + UPDATE + DELETE, ничего не потеряно
print(f"\n[load] ops={N} add={t_add:.1f}с upd={t_upd:.1f}с del={t_del:.1f}с")
+37
View File
@@ -0,0 +1,37 @@
"""Нагрузочный тест: конкурентная запись в SQLite (WAL + busy_timeout).
N_THREADS потоков пишут по PER ADD-операций каждый. Проверяет, что конкурентная
запись не теряет данные (WAL сериализует writers, busy_timeout ждёт).
"""
import os
import pytest
from concurrent.futures import ThreadPoolExecutor
from db import spec_events, spec_current
pytestmark = pytest.mark.load
N_THREADS = int(os.getenv("LOAD_THREADS", "4"))
PER = int(os.getenv("LOAD_PER", "200"))
def test_concurrent_writes(db):
def writer(tid):
cid = f"c{tid}"
for i in range(PER):
spec_events.apply_ops(
cid, f"s{tid}", f"d{tid}",
[{"action": "ADD", "new_row": {"name": f"svc{i}", "price": i}}],
"pid", {},
)
with ThreadPoolExecutor(max_workers=N_THREADS) as ex:
list(ex.map(writer, range(N_THREADS)))
# Никаких потерь: у каждого потока ровно PER строк
for tid in range(N_THREADS):
n = len(spec_current.list_by_contract(f"c{tid}"))
assert n == PER, f"c{tid}: {n} != {PER} (потеря данных при конкурентной записи)"
print(f"\n[load] потоков={N_THREADS} строк/поток={PER} всего={N_THREADS * PER}")
+67
View File
@@ -0,0 +1,67 @@
"""Нагрузочный тест: массовый прогон run_pipeline (сотни контрактов × допников).
Создаёт N контрактов × M допников, прогоняет полный конвейер сверки с Fake LLM
(без сети), реально нагружает SQLite: apply_ops + clear_current + reset.
Проверка: full_replace НЕ дублирует строки в масштабе (у каждого контракта ровно
SERVICES строк, а не SERVICES × M_SUPPS).
"""
import os
import time
import pytest
from db import contracts, documents, supplements, spec_current
from db.connection import query
from services import process
pytestmark = pytest.mark.load
N_CONTRACTS = int(os.getenv("LOAD_CONTRACTS", "100"))
M_SUPPS = int(os.getenv("LOAD_SUPPS", "20"))
SERVICES = int(os.getenv("LOAD_SERVICES", "10"))
def _seed():
cids = []
for ci in range(N_CONTRACTS):
c = contracts.insert(f"03700_{ci}")
for mi in range(M_SUPPS):
d = documents.insert(f"d{ci}_{mi}.docx", "application/octet-stream", "raw", batch_id=f"b{ci}")
documents.set_parsed(d["id"], [{"type": "paragraph", "text": f"услуга {mi}"}])
supplements.insert(c["id"], d["id"], "initial" if mi == 0 else "additional")
cids.append(c["id"])
return cids
def _fake_llm():
def fake_call(current_spec, doc_text, build_prompt_fn, llm_client=None):
ops = [
{"action": "ADD", "new_row": {"name": f"услуга {i}", "price": 100 + i, "qty": 1, "sum": 100 + i}}
for i in range(SERVICES)
]
return ({"mode": "full_replace", "ops": ops}, "pid")
return fake_call
def test_mass_pipeline(db, monkeypatch):
cids = _seed()
monkeypatch.setattr("services.llm.call_llm", _fake_llm())
t0 = time.time()
for cid in cids:
list(process.run_pipeline(cid, "", lambda cur, txt: ("p", "pid")))
elapsed = time.time() - t0
# Целостность: у каждого контракта ровно SERVICES строк (full_replace без дублей)
bad = 0
for cid in cids:
if len(spec_current.list_by_contract(cid)) != SERVICES:
bad += 1
assert bad == 0, f"{bad} контрактов с неверным числом строк"
total_events = query("SELECT count(*) AS c FROM spec_events", ())
print(
f"\n[load] контрактов={N_CONTRACTS} допников={M_SUPPS} услуг={SERVICES} "
f"событий={total_events[0]['c']} время={elapsed:.1f}с"
)
+99
View File
@@ -0,0 +1,99 @@
"""Unit-тесты services/classify.py — фильтры + JSON-парсинг + выжимка."""
import json
import pytest
from services.classify import (
_is_garbage_by_filename,
_is_garbage_by_header,
_safe_json_parse,
_smart_extract,
)
class TestGarbageFilename:
def test_invoice(self):
assert _is_garbage_by_filename("счет-фактура №123.docx") is True
def test_act(self):
assert _is_garbage_by_filename("Акт сверки.pdf") is True
def test_upd(self):
assert _is_garbage_by_filename("УПД-2025.docx") is True
def test_not_garbage(self):
assert _is_garbage_by_filename("договор.docx") is False
assert _is_garbage_by_filename("спецификация.pdf") is False
class TestGarbageHeader:
def test_invoice_header(self):
assert _is_garbage_by_header("СЧЕТ-ФАКТУРА № 123 от 01.01.2026") is True
def test_act_header(self):
assert _is_garbage_by_header("АКТ ОКАЗАННЫХ УСЛУГ за май") is True
def test_not_garbage(self):
assert _is_garbage_by_header("ДОГОВОР № 03700_1 об оказании услуг") is False
def test_upd_mention_in_contract_is_not_garbage(self):
text = "Договор № 03700. Оплата производится на основании УПД и счета."
assert _is_garbage_by_header(text) is False
def test_upd_document_header_is_garbage(self):
assert _is_garbage_by_header("УПД № 123 от 01.01.2026") is True
class TestSafeJsonParse:
def test_valid(self):
d, fix = _safe_json_parse('{"a":1}')
assert d == {"a": 1}
assert fix is False
def test_markdown(self):
d, fix = _safe_json_parse('```json\n{"a":1}\n```')
assert d == {"a": 1}
assert fix is False
def test_chatter(self):
d, _ = _safe_json_parse('Вот ответ: {"a":1}. Спасибо!')
assert d == {"a": 1}
def test_trailing_comma(self):
d, fix = _safe_json_parse('{"a":1,}')
assert d == {"a": 1}
assert fix is True
def test_trailing_comma_in_list(self):
d, fix = _safe_json_parse('{"a":[1,2,]}')
assert d == {"a": [1, 2]}
assert fix is True
def test_empty_raises(self):
with pytest.raises(ValueError):
_safe_json_parse("")
def test_none_raises(self):
with pytest.raises(ValueError):
_safe_json_parse(None)
class TestSmartExtract:
def test_paragraphs(self):
ej = [{"type": "paragraph", "text": "Договор № 03700"}]
out = _smart_extract(ej)
assert "Договор" in out
def test_table(self):
ej = [{"type": "table", "rows": [["Аренда", "50000"]]}]
out = _smart_extract(ej)
assert "Аренда" in out
def test_string_json(self):
ej = json.dumps([{"type": "paragraph", "text": "Спецификация"}])
out = _smart_extract(ej)
assert "Спецификация" in out
def test_empty(self):
assert _smart_extract(None) == ""
assert _smart_extract("") == ""
+36
View File
@@ -0,0 +1,36 @@
"""Unit-тесты db/connection.py — SQL-конвертация (без БД)."""
from db.connection import _pg_to_sqlite, _extract_table
class TestPgToSqlite:
def test_placeholder(self):
assert _pg_to_sqlite("SELECT * FROM x WHERE a = %s") == "SELECT * FROM x WHERE a = ?"
def test_jsonb(self):
out = _pg_to_sqlite("UPDATE t SET j = %s::jsonb WHERE id = %s")
assert "::jsonb" not in out
def test_boolean(self):
# " BOOLEAN " заменяется только когда после слова идёт пробел
out = _pg_to_sqlite("CREATE TABLE t (flag BOOLEAN NOT NULL)")
assert "BOOLEAN" not in out
assert "INTEGER" in out
def test_ilike(self):
assert _pg_to_sqlite("WHERE name ILIKE 'x'") == "WHERE name LIKE 'x'"
def test_true_false(self):
out = _pg_to_sqlite("SET a = TRUE, b = FALSE")
assert "TRUE" not in out
assert "FALSE" not in out
class TestExtractTable:
def test_insert(self):
assert _extract_table("INSERT INTO documents (id) VALUES (1)") == "documents"
def test_insert_no_space(self):
assert _extract_table("INSERT INTO prompts(id) VALUES (1)") == "prompts"
def test_no_insert(self):
assert _extract_table("SELECT * FROM documents") is None
+83
View File
@@ -0,0 +1,83 @@
"""Unit-тесты services/grouping.py — нормализация + группировка (mock db)."""
from services import grouping
class TestNormalizeNumber:
def test_basic(self):
assert grouping.normalize_number("МЭС-123-2024") == "МЭС1232024"
def test_spaces_slashes(self):
assert grouping.normalize_number("МЭС 123/2024") == "МЭС1232024"
def test_case(self):
assert grouping.normalize_number("мэс-123") == "МЭС123"
def test_empty(self):
assert grouping.normalize_number("") == ""
assert grouping.normalize_number(None) == ""
class _FakeDocs:
def __init__(self, docs):
self.docs = docs
def list_by_batch(self, batch_id):
return self.docs
class TestGroupDocuments:
def _doc(self, id, doc_type, own, parent, date, cp):
return {
"id": id, "filename": f"{id}.docx", "doc_type": doc_type,
"own_number": own, "parent_number": parent, "doc_date": date,
"counterparty": cp, "classify_status": "classified",
}
def test_contract_plus_supplement(self, monkeypatch):
docs = [
self._doc("d1", "contract", "03700_1", None, "2025-01-01", "ЗАО X"),
self._doc("d2", "supplement", "1", "03700_1", "2025-02-01", "ЗАО X"),
]
monkeypatch.setattr(grouping, "db_docs", _FakeDocs(docs))
r = grouping.group_documents("b1")
assert r["ok"] is True
assert r["total_docs"] == 2
groups = [g for g in r["groups"] if g["contract_number"] != "__unresolved__"]
assert len(groups) == 1
assert groups[0]["contract_number"] == "03700_1"
assert len(groups[0]["documents"]) == 2
def test_unmatched_goes_unresolved(self, monkeypatch):
docs = [self._doc("d1", "other", None, None, None, "")]
monkeypatch.setattr(grouping, "db_docs", _FakeDocs(docs))
r = grouping.group_documents("b1")
unresolved = [g for g in r["groups"] if g["contract_number"] == "__unresolved__"]
assert len(unresolved) == 1
class TestApplyGroups:
def test_apply(self, monkeypatch):
created = []
class FakeContracts:
def insert(self, number, client=""):
return {"id": f"c_{number}"}
class FakeSupps:
def insert(self, cid, did, stype):
created.append((cid, did, stype))
return {"id": "s"}
monkeypatch.setattr(grouping, "db_contracts", FakeContracts())
monkeypatch.setattr(grouping, "db_supplements", FakeSupps())
groups = [
{"contract_number": "03700_1", "counterparty": "X",
"documents": [{"id": "d1"}, {"id": "d2"}]},
{"contract_number": "__unresolved__", "counterparty": "",
"documents": [{"id": "d3"}]},
]
r = grouping.apply_groups("b1", groups)
assert r["ok"] is True
assert r["created"] == 2
assert created == [("c_03700_1", "d1", "initial"), ("c_03700_1", "d2", "additional")]
+25
View File
@@ -0,0 +1,25 @@
"""Unit-тесты services/llm.py — call_llm с FakeLLMClient."""
from services.llm import call_llm
from services.llm_client import FakeLLMClient
def _build(cur, txt):
return (f"prompt:{txt}", "pid-1")
class TestCallLLM:
def test_plain_json(self):
llm = FakeLLMClient({"default": '{"mode":"partial","ops":[]}'})
res, pid = call_llm([], "текст", _build, llm_client=llm)
assert pid == "pid-1"
assert res == {"mode": "partial", "ops": []}
def test_markdown_json(self):
llm = FakeLLMClient({"default": '```json\n{"mode":"full_replace","ops":[]}\n```'})
res, _ = call_llm([], "текст", _build, llm_client=llm)
assert res["mode"] == "full_replace"
def test_markdown_generic(self):
llm = FakeLLMClient({"default": '```\n{"a":1}\n```'})
res, _ = call_llm([], "текст", _build, llm_client=llm)
assert res == {"a": 1}
+40
View File
@@ -0,0 +1,40 @@
"""Unit-тесты services/llm_client.py."""
from services.llm_client import FakeLLMClient, HttpxLLMClient
class TestFakeLLMClient:
def test_exact_match(self):
c = FakeLLMClient({"hello": "world"})
assert c.complete("hello") == "world"
def test_partial_match(self):
c = FakeLLMClient({"needle": "found"})
assert c.complete("a needle in haystack") == "found"
def test_default_fallback(self):
c = FakeLLMClient({"default": "fallback"})
assert c.complete("whatever") == "fallback"
def test_calls_recorded(self):
c = FakeLLMClient()
c.complete("x")
c.complete("y")
assert c.calls == ["x", "y"]
def test_add(self):
c = FakeLLMClient()
c.add("k", "v")
assert c.complete("k") == "v"
class TestHttpxLLMClient:
def test_init_defaults(self):
c = HttpxLLMClient(url="http://x", key="k")
assert c.model == "gpt-oss-120b"
assert c.timeout == 120
assert c.max_tokens == 8000
def test_init_custom(self):
c = HttpxLLMClient(url="http://x", key="k", model="m", timeout=10)
assert c.model == "m"
assert c.timeout == 10
+87
View File
@@ -0,0 +1,87 @@
"""Unit-тесты services/metrics.py — чистая логика, без БД."""
from decimal import Decimal
import pytest
from services.metrics import (
check_arithmetic,
normalize_date,
_to_decimal,
ClassifyMetrics,
)
class TestNormalizeDate:
def test_dd_mm_yyyy(self):
assert normalize_date("01.01.2025") == "2025-01-01"
def test_already_iso(self):
assert normalize_date("2025-01-01") == "2025-01-01"
def test_empty(self):
assert normalize_date("") is None
assert normalize_date(None) is None
def test_unrecognized_passthrough(self):
assert normalize_date("01 января 2025") == "01 января 2025"
class TestToDecimal:
def test_int(self):
assert _to_decimal("50000") == Decimal("50000")
def test_russian_number(self):
assert _to_decimal("50 000,00") == Decimal("50000.00")
def test_nbsp(self):
assert _to_decimal("1\u00a0000,50") == Decimal("1000.50")
def test_none(self):
assert _to_decimal(None) is None
assert _to_decimal("") is None
def test_garbage(self):
assert _to_decimal("не число") is None
class TestCheckArithmetic:
def test_ok(self):
ops = [{"action": "ADD", "new_row": {"name": "x", "price": 100, "qty": 2, "sum": 200}}]
assert check_arithmetic(ops) == []
def test_mismatch(self):
ops = [{"action": "ADD", "new_row": {"name": "x", "price": 100, "qty": 2, "sum": 250}}]
m = check_arithmetic(ops)
assert len(m) == 1
assert m[0]["expected_sum"] == 200.0
assert m[0]["actual_sum"] == 250.0
assert m[0]["diff"] == 50.0
def test_update_values(self):
ops = [{"action": "UPDATE", "new_values": {"price": 10, "qty": 3, "sum": 30}}]
assert check_arithmetic(ops) == []
def test_skip_incomplete(self):
# нет qty → пропуск
ops = [{"action": "ADD", "new_row": {"name": "x", "price": 100, "sum": 200}}]
assert check_arithmetic(ops) == []
def test_skip_non_add_update(self):
ops = [{"action": "DELETE", "target_hash": "h"}]
assert check_arithmetic(ops) == []
class TestClassifyMetrics:
def test_fix_rate(self):
m = ClassifyMetrics()
m.record(False)
m.record(True)
m.record(False)
assert m.total == 3
assert m.fixes == 1
assert m.fix_rate == pytest.approx(1 / 3)
def test_empty(self):
m = ClassifyMetrics()
assert m.fix_rate == 0.0
assert m.summary()["total_classifications"] == 0
+28
View File
@@ -0,0 +1,28 @@
"""Unit-тесты services/parse.py."""
from services.parse import parse_file, _parse_text
class TestParseText:
def test_plain(self):
r = parse_file("test.txt", "строка1\nстрока2\nстрока3".encode())
assert r["status"] == "parsed"
assert r["element_count"] == 3
def test_text_fn(self):
r = _parse_text(b"a\nb\nc")
assert r["status"] == "parsed"
assert r["element_count"] == 3
assert r["elements"][0]["text"] == "a"
def test_no_extension_falls_back_to_text(self):
r = parse_file("noext", b"line1\nline2")
assert r["status"] == "parsed"
assert r["element_count"] == 2
def test_docx_invalid_returns_error(self):
r = parse_file("test.docx", b"not a real docx")
assert r["status"] == "error"
def test_pdf_invalid_returns_error(self):
r = parse_file("test.pdf", b"not a real pdf")
assert r["status"] == "error"
+96
View File
@@ -0,0 +1,96 @@
"""Интеграционные тесты services/process.py — run_pipeline с Fake LLM."""
import json
from db import contracts, documents, supplements, spec_current
from db.connection import query
from services import process
def _seed_contract(n_supps=2):
"""Создать contract + n документов (parsed) + n supplements."""
c = contracts.insert("03700_1")
for i in range(n_supps):
d = documents.insert(f"d{i}.docx", "application/octet-stream", "raw", batch_id="b1")
documents.set_parsed(d["id"], [{"type": "paragraph", "text": f"строка {i}"}])
supplements.insert(c["id"], d["id"], "initial" if i == 0 else "additional")
return c
class TestFullReplace:
def test_no_duplicates(self, db, monkeypatch):
c = _seed_contract(2)
def fake_call(current_spec, doc_text, build_prompt_fn, llm_client=None):
ops = [
{"action": "ADD", "new_row": {"name": "Аренда стойко-места", "price": 50000, "qty": 1, "sum": 50000}},
{"action": "ADD", "new_row": {"name": "IP-адрес IPv4", "price": 300, "qty": 8, "sum": 2400}},
{"action": "ADD", "new_row": {"name": "Канал 1 Гбит/с", "price": 20000, "qty": 1, "sum": 20000}},
]
return ({"mode": "full_replace", "ops": ops}, "pid")
monkeypatch.setattr("services.llm.call_llm", fake_call)
events = list(process.run_pipeline(c["id"], "", lambda cur, txt: ("p", "pid")))
rows = spec_current.list_by_contract(c["id"])
# 2 full_replace, но без дублей — всегда 3 строки, не 6
assert len(rows) == 3
assert {r["name"] for r in rows} == {"Аренда стойко-места", "IP-адрес IPv4", "Канал 1 Гбит/с"}
# история сохранена: 2 full_replace × 3 ADD = 6 событий
ev = query("SELECT count(*) AS c FROM spec_events WHERE contract_id = %s", (c["id"],))
assert ev[0]["c"] == 6
# не было ошибок извлечения
assert not any(e.get("type") == "extract_error" for e in events)
class TestTargetIdTranslation:
def test_update_applies(self, db, monkeypatch):
c = _seed_contract(2)
def fake_call(current_spec, doc_text, build_prompt_fn, llm_client=None):
if not current_spec:
return ({"mode": "partial", "ops": [
{"action": "ADD", "new_row": {"name": "Аренда", "price": 50000, "qty": 1, "sum": 50000}},
]}, "pid")
return ({"mode": "partial", "ops": [
{"action": "UPDATE", "target_id": "r1", "new_values": {"price": 55000, "sum": 55000}},
]}, "pid")
monkeypatch.setattr("services.llm.call_llm", fake_call)
list(process.run_pipeline(c["id"], "", lambda cur, txt: ("p", "pid")))
rows = spec_current.list_by_contract(c["id"])
assert len(rows) == 1
assert rows[0]["price"] == 55000 # UPDATE применился, а не ушёл в UNRESOLVED
upd = query("SELECT status FROM spec_events WHERE contract_id = %s AND action = 'UPDATE'", (c["id"],))
assert upd[0]["status"] == "applied"
class TestNoSupplements:
def test_error_event(self, db):
evs = list(process.run_pipeline("nonexistent", "", lambda cur, txt: ("p", "pid")))
assert any(e.get("type") == "error" for e in evs)
class TestElementsToText:
def test_list(self):
ej = [
{"type": "paragraph", "text": "Привет"},
{"type": "table", "rows": [["a", "b"], ["c", "d"]]},
]
assert process._elements_to_text(ej) == "Привет\na | b\nc | d"
def test_dict_value(self):
ej = {"Value": [{"type": "paragraph", "text": "X"}]}
assert process._elements_to_text(ej) == "X"
def test_string_json(self):
ej = json.dumps([{"type": "paragraph", "text": "Y"}])
assert process._elements_to_text(ej) == "Y"
def test_plain_string(self):
assert process._elements_to_text("просто текст") == "просто текст"
+48
View File
@@ -0,0 +1,48 @@
"""Интеграционные тесты HTTP-эндпоинтов (Flask test client)."""
import pytest
@pytest.fixture
def client(tmp_path, monkeypatch):
from db import connection as conn
monkeypatch.setattr(conn, "DB_PATH", str(tmp_path / "app.db"))
from app import create_app
app = create_app()
app.config["TESTING"] = True
yield app.test_client()
c = getattr(conn._local, "conn", None)
if c is not None:
try:
c.close()
except Exception:
pass
conn._local.conn = None
class TestHealth:
def test_health(self, client):
from config import VERSION
r = client.get("/health")
assert r.status_code == 200
data = r.get_json()
assert data["ok"] is True
assert data["version"] == VERSION
class TestSpecCurrent:
def test_missing_contract_id(self, client):
r = client.get("/api/spec-current")
assert r.status_code == 400
def test_empty(self, client):
r = client.get("/api/spec-current?contract_id=missing")
assert r.status_code == 200
data = r.get_json()
assert data["ok"] is True
assert data["rows"] == []
class TestGroups:
def test_missing_batch(self, client):
r = client.get("/api/groups")
assert r.status_code == 400
+31
View File
@@ -0,0 +1,31 @@
"""Интеграционные тесты db/spec_current.py."""
import json
from db import spec_current, documents, spec_events
class TestListByContract:
def test_empty(self, db):
assert spec_current.list_by_contract("nope") == []
def test_ordered_by_name(self, db):
ops = [
{"action": "ADD", "new_row": {"name": "Б", "price": 2}},
{"action": "ADD", "new_row": {"name": "А", "price": 1}},
]
spec_events.apply_ops("c1", "s1", "d1", ops, "pid", {})
rows = spec_current.list_by_contract("c1")
assert [r["name"] for r in rows] == ["А", "Б"]
class TestGetElementsJson:
def test_none(self, db):
assert spec_current.get_elements_json("missing") is None
def test_parsed(self, db):
d = documents.insert("f.docx", "m", "raw", batch_id="b1")
documents.set_parsed(d["id"], [{"type": "paragraph", "text": "x"}])
ej = spec_current.get_elements_json(d["id"])
assert ej is not None
parsed = json.loads(ej)
assert parsed[0]["text"] == "x"
+107
View File
@@ -0,0 +1,107 @@
"""Интеграционные тесты db/spec_events.py (SQLite, изолированная БД)."""
from db.connection import query
from db import spec_events, spec_current
CID = "11111111-1111-1111-1111-111111111111"
SID = "22222222-2222-2222-2222-222222222222"
DID = "33333333-3333-3333-3333-333333333333"
def _count(table, contract_id):
rows = query(f"SELECT count(*) AS c FROM {table} WHERE contract_id = %s", (contract_id,))
return rows[0]["c"]
class TestApplyOpsAdd:
def test_add(self, db):
ops = [{"action": "ADD", "new_row": {"name": "Аренда стойко-места", "price": 50000, "qty": 1, "sum": 50000, "date_start": "2025-01-01"}}]
s = spec_events.apply_ops(CID, SID, DID, ops, "pid", {})
assert s == {"added": 1, "updated": 0, "deleted": 0, "unresolved": 0}
rows = spec_current.list_by_contract(CID)
assert len(rows) == 1
assert rows[0]["name"] == "Аренда стойко-места"
assert rows[0]["price"] == 50000
assert rows[0]["date_start"] == "2025-01-01"
def test_add_empty_name_unresolved(self, db):
ops = [{"action": "ADD", "new_row": {"name": ""}}]
s = spec_events.apply_ops(CID, SID, DID, ops, "pid", {})
assert s == {"added": 0, "updated": 0, "deleted": 0, "unresolved": 1}
assert spec_current.list_by_contract(CID) == []
def test_add_multiple(self, db):
ops = [
{"action": "ADD", "new_row": {"name": "A", "price": 1}},
{"action": "ADD", "new_row": {"name": "B", "price": 2}},
]
s = spec_events.apply_ops(CID, SID, DID, ops, "pid", {})
assert s["added"] == 2
assert len(spec_current.list_by_contract(CID)) == 2
class TestApplyOpsUpdateDelete:
def test_update_and_delete(self, db):
spec_events.apply_ops(CID, SID, DID, [{"action": "ADD", "new_row": {"name": "Аренда", "price": 50000, "qty": 1, "sum": 50000}}], "pid", {})
h = spec_events._hash("Аренда")
s = spec_events.apply_ops(CID, SID, DID, [{"action": "UPDATE", "target_hash": h, "new_values": {"price": 55000}}], "pid", {})
assert s["updated"] == 1
assert spec_current.list_by_contract(CID)[0]["price"] == 55000
s = spec_events.apply_ops(CID, SID, DID, [{"action": "DELETE", "target_hash": h}], "pid", {})
assert s["deleted"] == 1
assert spec_current.list_by_contract(CID) == []
def test_update_empty_hash_unresolved(self, db):
s = spec_events.apply_ops(CID, SID, DID, [{"action": "UPDATE", "new_values": {"price": 1}}], "pid", {})
assert s["updated"] == 0
rows = query("SELECT status FROM spec_events WHERE contract_id = %s", (CID,))
assert rows[0]["status"] == "unresolved"
class TestUnresolvedAndUnknown:
def test_unresolved_action(self, db):
ops = [{"action": "UNRESOLVED", "new_values": {"name": "X"}, "reason": "нет соответствия"}]
spec_events.apply_ops(CID, SID, DID, ops, "pid", {})
assert spec_current.list_by_contract(CID) == []
rows = query("SELECT action, status FROM spec_events WHERE contract_id = %s", (CID,))
assert rows[0]["action"] == "UNRESOLVED"
assert rows[0]["status"] == "unresolved"
def test_unknown_action(self, db):
spec_events.apply_ops(CID, SID, DID, [{"action": "WHATEVER", "new_row": {"name": "X"}}], "pid", {})
assert spec_current.list_by_contract(CID) == []
rows = query("SELECT action FROM spec_events WHERE contract_id = %s", (CID,))
assert rows[0]["action"] == "UNRESOLVED"
class TestClearAndReset:
def test_clear_current_keeps_events(self, db):
spec_events.apply_ops(CID, SID, DID, [{"action": "ADD", "new_row": {"name": "A", "price": 1}}], "pid", {})
assert _count("spec_current", CID) == 1
spec_events.clear_current(CID)
assert _count("spec_current", CID) == 0
assert _count("spec_events", CID) == 1 # история сохранена
def test_reset_clears_both(self, db):
spec_events.apply_ops(CID, SID, DID, [{"action": "ADD", "new_row": {"name": "A", "price": 1}}], "pid", {})
spec_events.reset(CID)
assert _count("spec_current", CID) == 0
assert _count("spec_events", CID) == 0
class TestHashAndSeq:
def test_hash_normalizes(self):
assert spec_events._hash(" Арена стойко-места ") == spec_events._hash("арена стойко-места")
def test_hash_includes_date(self):
assert spec_events._hash("Аренда", "01.01.2025") != spec_events._hash("Аренда", "01.02.2025")
def test_hash_len(self):
assert len(spec_events._hash("x")) == 16
def test_seq(self, db):
assert spec_events.get_next_seq(CID) == 1
spec_events.apply_ops(CID, SID, DID, [{"action": "ADD", "new_row": {"name": "A"}}], "pid", {})
assert spec_events.get_next_seq(CID) == 2
+183
View File
@@ -0,0 +1,183 @@
# upload — переиспользуемые слои загрузки через ВМ
Два самодостаточных слоя для выноса в любой другой проект БЕЗ изменения кода
(меняется только конфиг). Поведение 1:1 с drhider v0.0.75.
```
upload/
frontend/
zip/ # распаковка ZIP (чистые функции)
table/ # слой 1: выбор файлов/папки/архива, дедуп, статусы, таблица
upload/ # слой 2 (фронт): PUT на ВМ + POST /api/upload_refs
backend/
upload_refs/ # слой 2 (бэк): Blueprint upload_refs (SSRF, _safe_name, ретраи)
session/ # in-memory сессия с TTL и лимитами
config.example.json
```
## Что это
| Слой | Где | Ответственность |
|---|---|---|
| **1. Выбор файлов** | фронт | таблица, дедуп, раскрытие ZIP, путь, статусы, кнопки |
| **2. Закачка через ВМ** | фронт + бэк | PUT на ВМ-буфер (фронт) → `upload_refs` pull (бэк) → сессия |
| **3. Логика приложения** | — | у каждого приложения своя (обфускация, SSE и т.п.). В модуле её НЕТ |
Паттерн (зачем ВМ): шлюз managed-кластера рвёт тела >64КБ, egress не ограничен.
Поэтому: браузер → `PUT` на ВМ-буфер → Flask `POST /api/upload_refs` → egress `GET` → сессия.
---
## Подключение фронта
Подключить ES-модули (`<script type="module">`) и собрать слой 1:
```js
import { initUploadTable } from './upload/frontend/table/init_upload_table.js';
import { uploadViaVM } from './upload/frontend/upload/upload_via_vm.js';
const cfg = {
allowedExt: ['.pdf', '.doc', '.docx', '.txt', '.md'],
maxFileBytes: 50 * 1024 * 1024,
maxSessionBytes: 500 * 1024 * 1024,
estMbSec: 12,
};
const table = initUploadTable(cfg, {
fileInput: document.getElementById('fileInput'), // <input type="file" multiple>
folderInput: document.getElementById('folderInput'), // <input webkitdirectory>
tableBody: document.getElementById('fileList'), // <tbody>
countEl: document.getElementById('fileCount'),
uploadBtnEl: document.getElementById('uploadBtn'),
onStatus(cls, text) { /* сообщения (cls: ''|'progress'|'done'|'error') */ },
});
// Слой 2 — закачка учитываемых файлов на ВМ:
// idxInSf[k] — индекс k-го отправляемого файла в строках таблицы (своя логика маппинга)
const res = await uploadViaVM(toSend, cfg.vmUploadUrl, {
session: currentSid,
onStatus(k, html) { table.setStatus(idxInSf[k], html); }, // прогресс → ячейка таблицы
onUploadStatus(text) { /* статусная строка */ },
onXHR(xhr) { activeXHR = xhr; }, // регистрация активного PUT для отмены (abort)
});
// res = {ok:true, session, count} | {ok:false, error}
// После этого у вас в сессии res.session лежат файлы — запускайте СВОЮ обработку.
```
API слоя 1 (`initUploadTable(cfg, els)``table`):
- `addFiles(File[])` — дедуп + раскрытие ZIP + фильтр + лимиты;
- `getFiles()``[{name, size, file}]`**только учитываемые** (без сверхлимитных);
- `getOverNames()``Set` — имена сверх лимита;
- `setStatus(idx, html)` — статус в ячейке таблицы;
- `render()` — перерисовать таблицу;
- `clear()` — очистить список;
- `setBusy(bool)` — заблокировать список на время загрузки/обработки;
- `state` — доступ к состоянию (для слоя 3: установить `state.proc` для 3-секционной таблицы).
---
## Подключение бэка
```python
from flask import Flask
from upload.backend.upload_refs import create_upload_refs_blueprint
from upload.backend.session import create_session, add_file, get_files
app = Flask(__name__)
app.register_blueprint(create_upload_refs_blueprint({
"apiPrefix": "/api", # префикс эндпоинтов
"vmUploadPrefix": "https://.../drhider-upload/", # доверенный префикс (SSRF)
"maxFileBytes": 50 * 1024 * 1024,
"maxSessionBytes": 500 * 1024 * 1024,
"ttlSeconds": 1800,
"pullRetries": 3,
"pullRetryDelay": 2,
}))
```
Эндпоинт: `POST {apiPrefix}/upload_refs` — принимает JSON
`{"session": "...", "files": [{"name", "size", "url"}]}`, тянет каждый файл с ВМ
(SSRF-валидация по `vmUploadPrefix`, `_safe_name`, ретраи), кладёт в сессию.
Возвращает `{"ok": true, "session", "count"}`.
Сессия: `create_session()` → sid; `add_file(sid, name, content)` (лимит 500МБ);
`get_files(sid)``[(name, bytes), ...]` или `None`. TTL 30 мин (таймер в фоне).
---
## Раздача модуля в Flask (обязательно)
Фронт-модули — ES-модули, браузер грузит их по HTTP (не через file://).
Добавьте route, который отдаёт папку `upload/frontend` (как в drhider `site/routes/main_bp.py`):
```python
# в любом blueprint приложения
import os
from flask import send_from_directory
_UPLOAD_FRONTEND = os.path.join(os.path.dirname(os.path.dirname(__file__)),
"upload", "frontend")
@bp.route("/upload/<path:filename>")
def upload_frontend(filename):
return send_from_directory(_UPLOAD_FRONTEND, filename)
```
Тогда импорты в браузере: `import { initUploadTable } from '/upload/table/init_upload_table.js';`.
> Если фронт-модули хостятся отдельно (другой домен/приложение) — путь `/upload/...`
> и CORS настраиваются под ваш случай (правка приложения/nginx, не кода модуля).
---
## Слой 3: как подключить обработку (опционально)
Модуль отдаёт файлы в сессию, а обрабатываете их ВЫ (обфускация, конвертация, ...).
После `uploadViaVM` файлы лежат в `res.session`:
```python
files = get_files(res.session) # [(name, bytes), ...]
# ... ваша обработка ...
store_result(res.session, result_zip) # если нужно отдать результат обратно
```
Для 3-секционной таблицы прогресса (готово/текущий/ожидают) модуль предоставляет
`renderProcTable` — достаточно дать слою 3 доступ к `table.state.proc`:
```js
table.state.proc = { phase: 'processing', procState: {}, procExtractRate: null };
function syncProcCtx() {
table.state.proc.phase = phase;
table.state.proc.procState = procState; // {idx: {st, elapsed, eta, chars, t0}}
table.state.proc.procExtractRate = rate; // сек/МБ (для оценок ожидающих)
}
syncProcCtx();
table.render(); // сам выберет renderProcTable при phase==='processing'
```
Статусы в ячейках: `table.setStatus(idx, html)`.
---
## Конфиг (слой 0)
Всё drhider-специфичное задаётся конфигом, а не кодом слоёв:
| Поле | Назначение |
|---|---|
| `vmUploadUrl` | базовый URL ВМ-буфера для PUT (фронт) |
| `vmUploadPrefix` | тот же префикс для SSRF-валидации (бэк) |
| `allowedExt` | расширения документов из папки/архивов |
| `maxFileBytes` / `maxSessionBytes` | лимиты 50 МБ / 500 МБ |
| `apiPrefix` | префикс Blueprint `/api` |
| `pullRetries` / `pullRetryDelay` | ретраи pull (3 × 2с) |
| `pullTimeout` | таймаут одного GET pull (сек) |
| `ttlSeconds` | TTL сессии (по умолчанию 1800) |
| `estMbSec` | оценка времени обработки, сек/МБ (только UI) |
---
## Что НЕ трогать
- Слой 3 — логика приложения (обработка файлов из сессии, SSE-прогресс) у каждого своя.
- CORS на ВМ-буфере — если домен приложения другой, правится nginx на ВМ, а не код модуля.
+8
View File
@@ -0,0 +1,8 @@
"""Переиспользуемый модуль загрузки через ВМ (2 слоя).
Структура:
- frontend/ — слой 1 (выбор файлов) + слой 2 (закачка через ВМ), JS.
- backend/ — слой 2 (бэк): Blueprint upload_refs + in-memory сессия, Python.
Подключение в новый проект — см. README.md.
"""
+1
View File
@@ -0,0 +1 @@
"""Backend переиспользуемого модуля загрузки: upload_refs + session."""
+38
View File
@@ -0,0 +1,38 @@
"""In-memory хранилище сессий с TTL и лимитами.
Каждая операция — в отдельном файле (см. ниже), общее состояние — в state.py.
Импорт как единый пакет:
from upload.backend.session import create_session, add_file, get_files
"""
from .create_session import create_session
from .add_file import add_file
from .get_files import get_files, file_count
from .store_result import store_result, get_result
from .store_csv import store_csv, get_csv
from .ttl import touch, pause_ttl, resume_ttl
from .cancel import request_cancel, get_cancel_event
from .cleanup import cleanup
from .state import TTL_SECONDS, MAX_FILE_BYTES, MAX_SESSION_BYTES, configure
__all__ = [
"create_session",
"add_file",
"get_files",
"file_count",
"store_result",
"get_result",
"store_csv",
"get_csv",
"touch",
"pause_ttl",
"resume_ttl",
"request_cancel",
"get_cancel_event",
"cleanup",
"configure",
"TTL_SECONDS",
"MAX_FILE_BYTES",
"MAX_SESSION_BYTES",
]
+25
View File
@@ -0,0 +1,25 @@
"""add_file — добавить файл в сессию (с проверкой суммарного лимита)."""
from . import state
def add_file(sid: str, filename: str, content: bytes) -> bool:
"""Добавить файл в сессию.
Args:
sid: Идентификатор сессии
filename: Имя файла
content: Бинарное содержимое
Returns:
True если добавлено; False если сессии нет или превышен лимит сессии.
"""
with state._lock:
s = state._sessions.get(sid)
if not s:
return False
total = sum(len(c) for _, c in s["files"])
if total + len(content) > state.MAX_SESSION_BYTES:
return False # превышен суммарный лимит сессии
s["files"].append((filename, content))
return True
+27
View File
@@ -0,0 +1,27 @@
"""request_cancel / get_cancel_event — мягкое прерывание обработки сессии."""
import threading
from typing import Optional
from .state import _sessions, _lock
def request_cancel(sid: str) -> bool:
"""Запросить мягкое прерывание обработки сессии.
Returns:
True если сессия существует и отмена запрошена, False если нет.
"""
with _lock:
s = _sessions.get(sid)
if not s:
return False
s["cancel"].set()
return True
def get_cancel_event(sid: str) -> Optional[threading.Event]:
"""Получить событие отмены сессии (или None, если сессии нет)."""
with _lock:
s = _sessions.get(sid)
return s["cancel"] if s else None
+11
View File
@@ -0,0 +1,11 @@
"""cleanup — удалить сессию."""
from .state import _sessions, _lock
def cleanup(sid: str):
"""Удалить сессию и остановить её TTL-таймер."""
with _lock:
s = _sessions.pop(sid, None)
if s and s.get("timer"):
s["timer"].cancel()
+23
View File
@@ -0,0 +1,23 @@
"""create_session — создать новую сессию."""
import threading
import uuid
from .state import _sessions, _lock, _start_timer
def create_session() -> str:
"""Создать новую сессию.
Returns:
Уникальный идентификатор сессии (UUID).
"""
sid = uuid.uuid4().hex
with _lock:
_sessions[sid] = {
"files": [],
"result": None,
"cancel": threading.Event(),
"timer": _start_timer(sid),
}
return sid
+23
View File
@@ -0,0 +1,23 @@
"""get_files / file_count — чтение файлов сессии."""
from typing import List, Optional, Tuple
from .state import _sessions, _lock
def get_files(sid: str) -> Optional[List[Tuple[str, bytes]]]:
"""Получить все файлы сессии.
Returns:
[(filename, content), ...] или None если сессия не найдена.
"""
with _lock:
s = _sessions.get(sid)
return list(s["files"]) if s else None
def file_count(sid: str) -> int:
"""Количество файлов в сессии."""
with _lock:
s = _sessions.get(sid)
return len(s["files"]) if s else 0
+45
View File
@@ -0,0 +1,45 @@
"""Общее состояние сессий: хранилище, блокировка, константы, TTL-таймер.
Единая точка хранения состояния — все операции импортируют её.
Дробить state.py на файл-на-переменную не нужно: это данные, а не функции.
"""
import threading
# TTL сессии: 30 минут
TTL_SECONDS = 30 * 60
# Максимальный объём одного файла и суммарный объём файлов в сессии (защита памяти)
MAX_FILE_BYTES = 50 * 1024 * 1024 # 50 MB на один файл
MAX_SESSION_BYTES = 500 * 1024 * 1024 # 500 MB суммарно на сессию
_sessions: dict = {}
_lock = threading.Lock()
def _start_timer(sid: str) -> threading.Timer:
"""Запустить таймер автоочистки сессии через TTL."""
def _clean():
with _lock:
_sessions.pop(sid, None)
timer = threading.Timer(TTL_SECONDS, _clean)
timer.daemon = True
timer.start()
return timer
def configure(max_file_bytes: int = None, max_session_bytes: int = None,
ttl_seconds: int = None):
"""Переопределить лимиты/TTL из конфига приложения (глобально).
None — оставить текущее значение.
"""
global MAX_FILE_BYTES, MAX_SESSION_BYTES, TTL_SECONDS
if max_file_bytes is not None:
MAX_FILE_BYTES = max_file_bytes
if max_session_bytes is not None:
MAX_SESSION_BYTES = max_session_bytes
if ttl_seconds is not None:
TTL_SECONDS = ttl_seconds
+30
View File
@@ -0,0 +1,30 @@
"""store_csv / get_csv — сохранение и чтение CSV с таблицей замен."""
from typing import Optional
from .state import _sessions, _lock
def store_csv(sid: str, csv_str: str) -> bool:
"""Сохранить CSV с таблицей замен.
Returns:
True если сохранено, False если сессии нет.
"""
with _lock:
s = _sessions.get(sid)
if not s:
return False
s["csv"] = csv_str
return True
def get_csv(sid: str) -> Optional[str]:
"""Получить CSV с таблицей замен.
Returns:
Строка CSV или None если нет.
"""
with _lock:
s = _sessions.get(sid)
return s.get("csv") if s else None
+30
View File
@@ -0,0 +1,30 @@
"""store_result / get_result — сохранение и чтение результата обработки."""
from typing import Optional
from .state import _sessions, _lock
def store_result(sid: str, zip_data: bytes) -> bool:
"""Сохранить результат обработки (ZIP-архив).
Returns:
True если сохранено, False если сессии нет.
"""
with _lock:
s = _sessions.get(sid)
if not s:
return False
s["result"] = zip_data
return True
def get_result(sid: str) -> Optional[bytes]:
"""Получить результат обработки.
Returns:
ZIP-архив или None если сессия не найдена/результат не готов.
"""
with _lock:
s = _sessions.get(sid)
return s["result"] if s else None
+34
View File
@@ -0,0 +1,34 @@
"""touch / pause_ttl / resume_ttl — управление TTL-таймером сессии."""
from .state import _sessions, _lock, _start_timer
def touch(sid: str):
"""Продлить жизнь сессии: перезапустить TTL-таймер (если сессия существует)."""
with _lock:
s = _sessions.get(sid)
if not s:
return
if s.get("timer"):
s["timer"].cancel()
s["timer"] = _start_timer(sid)
def pause_ttl(sid: str):
"""Приостановить TTL сессии (во время обработки): сессия живёт, пока идёт воркер."""
with _lock:
s = _sessions.get(sid)
if s and s.get("timer"):
s["timer"].cancel()
s["timer"] = None
def resume_ttl(sid: str):
"""Возобновить TTL сессии (после завершения обработки): результат доступен ещё TTL."""
with _lock:
s = _sessions.get(sid)
if not s:
return
if s.get("timer"):
s["timer"].cancel()
s["timer"] = _start_timer(sid)
+20
View File
@@ -0,0 +1,20 @@
"""Слой 2 (бэк): переиспользуемый Blueprint закачки через ВМ.
Использование:
from upload.backend.upload_refs import create_upload_refs_blueprint
app.register_blueprint(create_upload_refs_blueprint(cfg))
"""
from .blueprint import create_upload_refs_blueprint
from .safe_name import safe_name
from .pull_file import pull_file
from .config import PULL_RETRIES, PULL_RETRY_DELAY, VM_UPLOAD_PREFIX
__all__ = [
"create_upload_refs_blueprint",
"safe_name",
"pull_file",
"PULL_RETRIES",
"PULL_RETRY_DELAY",
"VM_UPLOAD_PREFIX",
]
+160
View File
@@ -0,0 +1,160 @@
"""Переиспользуемый Blueprint слоя 2: POST /upload_refs (pull с ВМ-буфера в сессию).
Поведение 1:1 с drhider v0.0.75 (site/routes/api_bp.py):
- _safe_name (path traversal)
- SSRF-валидация url.startswith(VM_UPLOAD_PREFIX)
- лимит на один файл -> delete+skip
- pull с ретраями
- лимит сессии -> skip; отсутствие сессии -> 404
- delete url с ВМ (best-effort)
"""
import httpx
import logging
from flask import Blueprint, request, jsonify
from ..session import (create_session, add_file, get_files, file_count,
MAX_FILE_BYTES, configure)
from .config import PULL_RETRIES, PULL_RETRY_DELAY, VM_UPLOAD_PREFIX
from .safe_name import safe_name
from .pull_file import pull_file
log = logging.getLogger("upload.upload_refs")
def create_upload_refs_blueprint(cfg: dict, sink=None) -> Blueprint:
"""Создать Blueprint с эндпоинтом upload_refs.
cfg (все ключи опциональны, есть дефолты):
apiPrefix (str) — префикс Blueprint, по умолчанию "/api"
vmUploadPrefix (str) — доверенный префикс ВМ-буфера (SSRF-валидация)
maxFileBytes (int) — лимит на один файл
maxSessionBytes (int) — суммарный лимит сессии (применяется к сессиям)
ttlSeconds (int) — TTL сессии
pullRetries (int) — ретраи pull
pullRetryDelay (int) — пауза между ретраями (сек)
pullTimeout (int) — таймаут одного GET pull
sink (callable | None): если задан — вместо add_file в in-memory сессию
вызывается sink(name, content, **extra) на каждый вытянутый файл,
endpoint возвращает {"ok": true, "results": [...]}. extra — сквозные
поля тела запроса (batch_id, contract_id, zip_source). Если None —
прежнее поведение drhider (in-memory сессия).
"""
prefix = cfg.get("apiPrefix", "/api")
vm_prefix = cfg.get("vmUploadPrefix", VM_UPLOAD_PREFIX)
max_file_bytes = cfg.get("maxFileBytes", MAX_FILE_BYTES)
pull_retries = cfg.get("pullRetries", PULL_RETRIES)
pull_delay = cfg.get("pullRetryDelay", PULL_RETRY_DELAY)
pull_timeout = cfg.get("pullTimeout", 120)
# Применить лимиты сессии/TTL из конфига (глобально для всех сессий)
configure(
max_file_bytes=cfg.get("maxFileBytes"),
max_session_bytes=cfg.get("maxSessionBytes"),
ttl_seconds=cfg.get("ttlSeconds"),
)
bp = Blueprint("upload_refs", __name__, url_prefix=prefix)
@bp.route("/upload_refs", methods=["POST"])
def upload_refs():
"""Принять ссылки на файлы (загружены на ВМ-буфер), забрать по egress.
Вход: JSON {"session": "...", "files": [{"name": str, "size": int, "url": str}]}.
Каждый файл тянется ИСХОДЯЩИМ GET'ом с ВМ (egress не ограничен шлюзом),
читается по частям (stream), кладётся в сессию. После успешного pull файл
удаляется с ВМ (best-effort; TTL-чистка на ВМ тоже есть).
"""
data = request.get_json(silent=True) or {}
sid = data.get("session") or create_session()
refs = data.get("files") or []
if not refs:
log.warning("upload_refs: no files, sid=%s", sid)
return jsonify({"ok": False, "error": "No files"}), 400
extra = {k: data[k] for k in ("batch_id", "contract_id", "zip_source") if data.get(k) is not None}
results = [] if sink else None
added = 0
try:
with httpx.Client(timeout=pull_timeout, follow_redirects=True) as client:
for ref in refs:
name = safe_name(ref.get("name") or "")
url = ref.get("url")
if not name or not url:
continue
# SSRF-защита: тянуть можно ТОЛЬКО с доверенного ВМ-буфера
if not url.startswith(vm_prefix):
log.warning("upload_refs: unsafe URL, skip sid=%s url=%r", sid, url)
if sink:
results.append({"name": name, "ok": False, "error": "invalid url (SSRF guard)"})
continue
# Лимит на один файл: сверх лимита — пропускаем (не участвует)
if (ref.get("size") or 0) > max_file_bytes:
log.warning("upload_refs: file exceeds %dMB, skip sid=%s file=%r size=%s",
max_file_bytes // (1024 * 1024), sid, name, ref.get("size"))
try:
client.delete(url)
except Exception:
pass
if sink:
results.append({"name": name, "ok": False, "error": "file too large"})
continue
# Pull с ретраями: разовые DNS/сетевые сбои не роняют всю загрузку
try:
content = pull_file(client, url, pull_retries, pull_delay, sid=sid, name=name)
except Exception as e:
log.warning("upload_refs: pull failed sid=%s file=%r: %r", sid, name, e)
if sink:
results.append({"name": name, "ok": False, "error": "pull failed: %s" % e})
continue
log.info("upload_refs: pulled sid=%s file=%r size=%d", sid, name, len(content))
if len(content) > max_file_bytes:
log.warning("upload_refs: pulled file exceeds %dMB, skip sid=%s file=%r size=%d",
max_file_bytes // (1024 * 1024), sid, name, len(content))
try:
client.delete(url)
except Exception:
pass
if sink:
results.append({"name": name, "ok": False, "error": "file too large"})
continue
if sink:
# Отдать файл приложению через sink (вместо in-memory сессии)
try:
client.delete(url)
except Exception:
pass
try:
r = sink(name, content, **extra) or {}
results.append({"name": name, **r})
except Exception as e:
log.error("upload_refs: sink failed file=%r: %r", name, e)
results.append({"name": name, "ok": False, "error": str(e)})
continue
if not add_file(sid, name, content):
# Различить: сессия исчезла vs превышен суммарный лимит сессии
if get_files(sid) is None:
log.warning("upload_refs: session not found, sid=%s file=%r", sid, name)
return jsonify({"ok": False, "error": "Session not found"}), 404
log.warning("upload_refs: session limit exceeded, skip sid=%s file=%r", sid, name)
try:
client.delete(url)
except Exception:
pass
continue
try:
client.delete(url) # убрать файл с ВМ после загрузки
except Exception:
pass
added += 1
except Exception as e:
log.error("upload_refs: pull error sid=%s: %r", sid, e)
return jsonify({"ok": False, "error": "Pull failed: %s" % e}), 502
if sink:
return jsonify({"ok": True, "results": results})
log.info("upload_refs: done sid=%s added=%d total=%d", sid, added, file_count(sid))
return jsonify({"ok": True, "session": sid, "count": file_count(sid)})
return bp
+11
View File
@@ -0,0 +1,11 @@
"""Параметры слоя 2 (бэк) по умолчанию.
Переопределяются из конфига приложения через create_upload_refs_blueprint(cfg).
"""
# Ретраи pull из ВМ-буфера: защита от разовых DNS/сетевых сбоев (gaierror -5 и т.п.)
PULL_RETRIES = 3
PULL_RETRY_DELAY = 2 # секунды между попытками
# Доверенный префикс ВМ-буфера — валидация URL при pull (защита от SSRF)
VM_UPLOAD_PREFIX = "https://contracts.kube5s.ru/drhider-upload/"
+39
View File
@@ -0,0 +1,39 @@
"""pull_file — вытащить файл с ВМ-буфера исходящим GET с ретраями."""
import logging
import time
from .config import PULL_RETRIES, PULL_RETRY_DELAY
log = logging.getLogger("upload.upload_refs.pull")
def pull_file(client, url: str, retries: int = PULL_RETRIES,
delay: float = PULL_RETRY_DELAY, sid: str = None, name: str = None) -> bytes:
"""GET url с ретраями; читает по частям (stream).
Args:
client: httpx.Client
url: URL файла на ВМ-буфере.
retries: число попыток.
delay: пауза между попытками (сек).
sid/name: для логирования (опционально).
Returns:
Содержимое файла (bytes).
Raises:
Последнюю ошибку попытки, если все ретраи не удались.
"""
last_err = None
for attempt in range(retries):
try:
with client.stream("GET", url) as resp:
resp.raise_for_status()
return b"".join(resp.iter_bytes())
except Exception as e:
last_err = e
log.warning("pull: attempt %d/%d failed sid=%s file=%r: %r",
attempt + 1, retries, sid, name, e)
time.sleep(delay)
raise last_err if last_err else RuntimeError("pull failed")
+16
View File
@@ -0,0 +1,16 @@
"""safe_name — санитизация имени файла (защита от path traversal)."""
def safe_name(name: str) -> str:
"""Санитизировать имя файла: защита от path traversal, сохраняя подпапки.
Запрещает '..' и абсолютные пути; нормализует слэши. Возвращает "" если
имя пустое или небезопасное.
"""
if not name:
return ""
name = name.replace("\\", "/")
parts = [p for p in name.split("/") if p and p != "."]
if not parts or any(p == ".." for p in parts):
return ""
return "/".join(parts)
+13
View File
@@ -0,0 +1,13 @@
{
"vmUploadUrl": "https://contracts.kube5s.ru/drhider-upload/",
"allowedExt": [".pdf", ".doc", ".docx", ".txt", ".md"],
"maxFileBytes": 52428800,
"maxSessionBytes": 524288000,
"apiPrefix": "/api",
"vmUploadPrefix": "https://contracts.kube5s.ru/drhider-upload/",
"pullRetries": 3,
"pullRetryDelay": 2,
"pullTimeout": 120,
"ttlSeconds": 1800,
"estMbSec": 12
}
+6
View File
@@ -0,0 +1,6 @@
{
"name": "upload-frontend",
"private": true,
"type": "module",
"description": "Переиспользуемый фронт слоёв 1 и 2 (выбор файлов + закачка через ВМ). Модули ES — подключаются в браузере через <script type=\"module\">; Node-режим нужен для юнит-тестов zip."
}
@@ -0,0 +1,47 @@
// addFileWithDedup — дедуп «имя+размер», суффиксы _2/_3, лимиты (over).
// Мутирует state. Возвращает true если файл добавлен (или переведён в over),
// false если это дедуп (обновлена только дата).
export function addFileWithDedup(state, file, cfg) {
const size = file.size;
const mtime = file.lastModified;
let name = file.name;
const maxFileBytes = cfg.maxFileBytes;
const maxSessionBytes = cfg.maxSessionBytes;
if (state.fileMeta.has(name)) {
const e = state.fileMeta.get(name);
if (e.size === size) {
// Тот же файл (имя+размер) — дедуп. Дату не сравниваем: файл могли пересохранить
// с тем же содержимым. Оставляем более свежий по дате.
if (mtime > e.mtime) {
e.mtime = mtime;
const idx = state.files.findIndex(f => f.name === name);
if (idx >= 0) state.files[idx] = new File([file], name, { lastModified: mtime });
}
return false; // дедуп: файл не добавлен (обновлена только дата)
}
// Имя то же, размер другой — добавить с суффиксом _2, _3...
const dot = name.lastIndexOf('.');
const base = dot > 0 ? name.slice(0, dot) : name;
const ext = dot > 0 ? name.slice(dot) : '';
let n = 2;
while (state.fileMeta.has(base + '_' + n + ext)) n++;
name = base + '_' + n + ext;
}
state.fileMeta.set(name, { size, mtime });
// Определяем, превышает ли файл лимит (по размеру файла или суммарный) — не участвует в обфускации
let over = false;
if (size > maxFileBytes) over = true; // лимит на один файл
const sum = state.files.reduce((s, f) => s + (state.overNames.has(f.name) ? 0 : f.size), 0);
if (sum + size > maxSessionBytes) over = true; // суммарный лимит сессии
if (over) {
state.overNames.add(name);
state.files.push(new File([file], name, { lastModified: mtime }));
return true;
}
state.files.push(new File([file], name, { lastModified: mtime }));
return true;
}
+5
View File
@@ -0,0 +1,5 @@
// esc — экранирование HTML (защита от self-XSS именами файлов).
export function esc(s) {
return String(s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
}
+8
View File
@@ -0,0 +1,8 @@
// estForFile — эмпирическая оценка времени обработки файла: сек/МБ (ориентировочно, до старта).
export const DEFAULT_EST_MB_SEC = 12;
export function estForFile(f, estMbSec) {
const k = estMbSec || DEFAULT_EST_MB_SEC;
return f ? Math.max(1, Math.round(f.size / 1048576 * k)) : 0;
}
+6
View File
@@ -0,0 +1,6 @@
// fmtSec — формат секунд: "Nс" / "Nм Nс".
export function fmtSec(s) {
s = Math.max(0, Math.round(s));
return s >= 60 ? Math.floor(s / 60) + 'м ' + (s % 60) + 'с' : s + 'с';
}
+7
View File
@@ -0,0 +1,7 @@
// fs — формат размера: B / KB / MB.
export function fs(b) {
return b < 1024 ? b + ' B'
: b < 1048576 ? (b / 1024).toFixed(1) + ' KB'
: (b / 1048576).toFixed(1) + ' MB';
}
@@ -0,0 +1,56 @@
// initUploadTable — сборка слоя 1 (выбор файлов/папки/архива).
// Состояние (files/fileMeta/overNames) живёт внутри модуля.
import { addFiles as addFilesToState, makeFilesChangeHandler } from './on_files_change.js';
import { makeFolderChangeHandler } from './on_folder_change.js';
import { render } from './render.js';
import { setStatus } from './set_status.js';
import { rmFile } from './rm_file.js';
// cfg: {allowedExt, maxFileBytes, maxSessionBytes, estMbSec}
// els: {fileInput, folderInput, tableBody, countEl, uploadBtnEl, onStatus(cls, text)}
// returns api (см. README.md)
export function initUploadTable(cfg, els) {
const state = {
files: [], // File[]
fileMeta: new Map(), // имя -> {size, mtime} для дедупа/суффиксов
overNames: new Set(), // имена файлов сверх лимита (не участвуют)
busy: false, // идёт загрузка/обработка — список заблокирован
proc: null, // {phase, procState, procExtractRate} — задаёт слой 3
};
const onFilesChange = makeFilesChangeHandler({ state, cfg, els, onStatus: els.onStatus });
const onFolderChange = makeFolderChangeHandler({ state, cfg, els, onStatus: els.onStatus });
els.fileInput.addEventListener('change', onFilesChange);
els.folderInput.addEventListener('change', onFolderChange);
// Делегирование кликов по кнопкам «✕» (удалить строку)
els.tableBody.addEventListener('click', e => {
const btn = e.target.closest('.remove-btn');
if (btn) rmFile(state, els, cfg, Number(btn.dataset.idx));
});
return {
state, // доступ для слоя 3 (установить state.proc для render)
addFiles(files) { return addFilesToState(state, cfg, files, els, els.onStatus); },
getFiles() { // ТОЛЬКО учитываемые (без over): [{name, size, file}]
return state.files.filter(f => !state.overNames.has(f.name))
.map(f => ({ name: f.name, size: f.size, file: f }));
},
getOverNames() { return state.overNames; },
setStatus(idx, html) { setStatus(idx, html); },
render() { render(state, els, cfg); },
clear() {
state.files = [];
state.fileMeta = new Map();
state.overNames = new Set();
if (els.fileInput) els.fileInput.value = '';
render(state, els, cfg);
},
setBusy(b) {
state.busy = b;
if (els.fileInput) els.fileInput.disabled = b;
},
_rm(i) { rmFile(state, els, cfg, i); },
};
}
+56
View File
@@ -0,0 +1,56 @@
// Обработчик <input type="file" multiple> change: дедуп + раскрытие ZIP + фильтр.
// Экспортируется и чистая логика добавления массива файлов (addFiles),
// и фабрика обработчика для input (makeFilesChangeHandler).
import { listZipFiles } from '../zip/list_zip_files.js';
import { addFileWithDedup } from './add_file_with_dedup.js';
import { render } from './render.js';
// Добавить массив файлов в список (дедуп + раскрытие ZIP + лимиты).
// files: File[]. els.fileInput — для синхронизации input.files (DataTransfer),
// чтобы повторный выбор того же файла сработал. onStatus(cls, text) — колбэк сообщений.
export async function addFiles(state, cfg, files, els, onStatus) {
const incoming = Array.from(files);
const hasZip = incoming.some(f => f.name.toLowerCase().endsWith('.zip'));
if (hasZip) {
document.body.style.cursor = 'wait';
if (onStatus) onStatus('progress', 'Разбираю архивы…');
}
try {
for (const f of incoming) {
if (f.name.toLowerCase().endsWith('.zip')) {
try {
const nested = await listZipFiles(f, cfg.allowedExt);
if (nested.length) nested.forEach(x => addFileWithDedup(state, x, cfg));
else addFileWithDedup(state, f, cfg); // в архиве нет документов — архив как есть
} catch (err) {
addFileWithDedup(state, f, cfg); // не удалось распаковать — zip как есть
}
} else {
addFileWithDedup(state, f, cfg);
}
}
} finally {
if (hasZip) {
document.body.style.cursor = '';
if (onStatus) onStatus('', '');
}
}
// Синхронизировать input.files — чтобы повторный выбор того же файла сработал
if (els && els.fileInput && els.fileInput.files) {
const d = new DataTransfer();
state.files.forEach(f => d.items.add(f));
els.fileInput.files = d.files;
}
render(state, els, cfg);
}
// Фабрика обработчика change для <input type="file">.
// ctx = { state, cfg, els, onStatus }
export function makeFilesChangeHandler(ctx) {
const { state, cfg, els, onStatus } = ctx;
return () => {
if (state.busy) return; // во время загрузки/обработки менять список нельзя
addFiles(state, cfg, els.fileInput.files, els, onStatus);
};
}
+58
View File
@@ -0,0 +1,58 @@
// Обработчик <input webkitdirectory> change: выбор целой папки, рекурсивно,
// относительный путь сохраняется (верхняя папка отбрасывается).
import { listZipFiles } from '../zip/list_zip_files.js';
import { addFileWithDedup } from './add_file_with_dedup.js';
import { render } from './render.js';
// Фабрика обработчика change для input выбора папки.
// ctx = { state, cfg, els, onStatus }
export function makeFolderChangeHandler(ctx) {
const { state, cfg, els, onStatus } = ctx;
return async () => {
if (state.busy) return; // во время загрузки/обработки менять список нельзя
const incoming = Array.from(els.folderInput.files);
if (!incoming.length) return;
document.body.style.cursor = 'wait';
if (onStatus) onStatus('progress', 'Разбираю папку…');
let added = 0;
try {
for (const f of incoming) {
// webkitRelativePath: "TopFolder/Подпапка/file.pdf" — отбрасываем верхнюю папку
const parts = (f.webkitRelativePath || f.name).split('/');
const rel = parts.slice(1).join('/') || f.name;
const low = rel.toLowerCase();
const slashIdx = rel.lastIndexOf('/');
const relDir = slashIdx >= 0 ? rel.slice(0, slashIdx) : '';
if (low.endsWith('.zip')) {
try {
const nested = await listZipFiles(f, cfg.allowedExt);
if (nested.length) {
for (const nf of nested) {
const nm = relDir ? relDir + '/' + nf.name : nf.name;
if (addFileWithDedup(state, new File([nf], nm, { lastModified: nf.lastModified }), cfg)) added++;
}
} else {
// в архиве нет документов — добавить архив как есть, чтобы не терялся
if (addFileWithDedup(state, new File([f], rel, { lastModified: f.lastModified }), cfg)) added++;
}
} catch (err) {
if (addFileWithDedup(state, new File([f], rel, { lastModified: f.lastModified }), cfg)) added++; // zip как есть
}
} else if (cfg.allowedExt.some(e => low.endsWith(e))) {
if (addFileWithDedup(state, new File([f], rel, { lastModified: f.lastModified }), cfg)) added++;
}
// иначе — не документ, пропускаем
}
} finally {
document.body.style.cursor = '';
}
els.folderInput.value = ''; // чтобы повторный выбор той же папки сработал
render(state, els, cfg);
if (added && onStatus) {
const n = added;
const w = (n % 10 === 1 && n % 100 !== 11) ? 'файл' : (n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 12 || n % 100 > 14)) ? 'файла' : 'файлов';
onStatus('done', '✅ Добавлено из папки: ' + n + ' ' + w);
}
};
}
+13
View File
@@ -0,0 +1,13 @@
// procRow — строка таблицы обработки (3-секционная).
import { esc } from './esc.js';
import { fs } from './fs.js';
export function procRow(state, i, stTxt) {
const f = state.files[i];
const over = state.overNames.has(f.name);
const p = state.proc && state.proc.procState ? state.proc.procState[i] : null;
const cls = (p && p.st === 'current') ? ' class="row-current"'
: (over ? ' class="row-over"' : '');
return '<tr' + cls + '><td class="name-cell">' + esc(f.name) + '</td><td class="num-cell">' + fs(f.size) + '</td><td class="num-cell" style="font-size:12px;">' + stTxt + '</td><td></td></tr>';
}
+29
View File
@@ -0,0 +1,29 @@
// render — рендер таблицы выбора файлов (обычная).
// Если идёт обработка (state.proc.phase === 'processing') — 3-секционная.
import { esc } from './esc.js';
import { fs } from './fs.js';
import { fmtSec } from './fmt_sec.js';
import { estForFile } from './est_for_file.js';
import { renderProcTable } from './render_proc_table.js';
export function render(state, els, cfg) {
if (state.proc && state.proc.phase === 'processing') { renderProcTable(state, els, cfg); return; }
if (state.files.length === 0) {
els.tableBody.innerHTML = '<tr class="empty-row"><td colspan="4">Нет выбранных файлов</td></tr>';
} else {
els.tableBody.innerHTML = state.files.map((f, i) => {
const over = state.overNames.has(f.name);
const rowCls = over ? ' class="row-over"' : '';
const stTxt = over ? '<span style="color:#c0392b;">🔥 не учитывается</span>'
: '<span style="color:#7d3c98;">~' + fmtSec(estForFile(f, cfg.estMbSec)) + '</span>';
return '<tr id="row-' + i + '"' + rowCls + '><td class="name-cell">' + esc(f.name) + '</td><td class="num-cell">' + fs(f.size) + '</td><td class="num-cell" id="st-' + i + '" style="font-size:12px;">' + stTxt + '</td><td><button class="remove-btn" data-idx="' + i + '">✕</button></td></tr>';
}).join('');
}
const overCount = state.files.filter(f => state.overNames.has(f.name)).length;
const totalSize = state.files.reduce((s, f) => s + (state.overNames.has(f.name) ? 0 : f.size), 0);
const cntMain = state.files.length - overCount;
const totEst = state.files.reduce((s, f) => s + (state.overNames.has(f.name) ? 0 : estForFile(f, cfg.estMbSec)), 0);
els.countEl.textContent = (overCount ? cntMain + ' учитываются + ' + overCount + ' свыше лимита' : state.files.length) + ' файлов · ' + fs(totalSize) + ' · ~' + fmtSec(totEst);
els.uploadBtnEl.disabled = cntMain === 0;
}
@@ -0,0 +1,74 @@
// renderProcTable — 3-секционная таблица во время обработки
// (готово / текущий / ожидают / пропущены сверх лимита).
import { fmtSec } from './fmt_sec.js';
import { estForFile, DEFAULT_EST_MB_SEC } from './est_for_file.js';
import { procRow } from './proc_row.js';
export function renderProcTable(state, els, cfg) {
const procState = state.proc.procState;
const groups = { done: [], current: [], pending: [], over: [] };
for (let i = 0; i < state.files.length; i++) {
if (state.overNames.has(state.files[i].name)) { groups.over.push(i); continue; }
const st = procState[i] ? procState[i].st : 'pending';
if (st === 'done' || st === 'skipped') groups.done.push(i);
else if (st === 'current') groups.current.push(i);
else groups.pending.push(i);
}
// Оценка скорости из текущего файла (сек/символ) — для «ожидающих»
let rate = null;
for (const i of groups.current) {
const p = procState[i];
const cur = (performance.now() - p.t0) / 1000;
const total = cur + (p.eta != null ? p.eta : 0);
if (p.chars > 0 && total > 0) rate = total / p.chars;
}
const rows = [];
if (groups.done.length) {
rows.push('<tr class="grp-row"><td colspan="4">✓ Обработанные (' + groups.done.length + ')</td></tr>');
for (const i of groups.done) {
const p = procState[i];
const txt = p.st === 'skipped'
? '<span style="color:#c0392b;" title="Не удалось прочитать файл: пустой, повреждённый или скан без текста">не извлечён</span>'
: '<span style="color:#22c55e;">✓ ' + (p.elapsed ? p.elapsed.toFixed(1) : '0.0') + 'с</span>';
rows.push(procRow(state, i, txt));
}
}
if (groups.over.length) {
rows.push('<tr class="grp-row"><td colspan="4">⛔ Пропущены (сверх лимита) (' + groups.over.length + ')</td></tr>');
for (const i of groups.over) {
rows.push(procRow(state, i, '<span style="color:#c0392b;">пропущен (лимит)</span>'));
}
}
if (groups.current.length) {
rows.push('<tr class="grp-row"><td colspan="4">▶ Текущий файл</td></tr>');
for (const i of groups.current) {
const p = procState[i];
const cur = ((performance.now() - p.t0) / 1000).toFixed(1);
const eta = (p.eta != null) ? ' / ~' + fmtSec(p.eta) : '';
rows.push(procRow(state, i, '<span style="color:#2563eb;">⏳ ' + cur + 'с' + eta + '</span>'));
}
}
if (groups.pending.length) {
rows.push('<tr class="grp-row"><td colspan="4">○ Ожидают обработки (' + groups.pending.length + ')</td></tr>');
for (const i of groups.pending) {
const p = procState[i] || {};
// Оценка: LLM (chars x rate) если известна; иначе грубая по размеру/скорости извлечения
if (rate && !p.est && p.chars > 0) p.est = p.chars * rate;
if (!p.est) {
const szMB = (state.files[i] ? state.files[i].size : 0) / 1048576;
const k = (state.proc && state.proc.procExtractRate) || cfg.estMbSec || DEFAULT_EST_MB_SEC; // сек/МБ
p.est = Math.max(1, Math.round(szMB * k));
}
let txt;
if (p.st === 'analyzed') txt = '<span style="color:#7d3c98;">анализ ✓</span>';
else if (p.st === 'current') txt = '<span style="color:#2563eb;">⏳</span>';
else if (p.est != null) txt = '<span style="color:#999;">~' + fmtSec(p.est) + '</span>';
else txt = '<span style="color:#999;">—</span>';
rows.push(procRow(state, i, txt));
}
}
els.tableBody.innerHTML = rows.join('');
const cntDone = groups.done.length;
els.countEl.textContent = 'Готово ' + cntDone + ' / ' + state.files.length + ' файлов';
}
+19
View File
@@ -0,0 +1,19 @@
// rmFile — удалить файл из списка (если не busy).
import { render } from './render.js';
export function rmFile(state, els, cfg, i) {
if (state.busy) return;
const nm = state.files[i].name;
state.overNames.delete(nm);
state.fileMeta.delete(nm);
state.files.splice(i, 1);
// Синхронизировать input.files (DataTransfer) — чтобы повторный выбор того же
// файла сработал (change не сработает, если files не обновлён).
if (els.fileInput && els.fileInput.files) {
const d = new DataTransfer();
state.files.forEach(f => d.items.add(f));
els.fileInput.files = d.files;
}
render(state, els, cfg);
}
+6
View File
@@ -0,0 +1,6 @@
// setStatus — записать HTML-статус в ячейку таблицы (st-<idx>).
export function setStatus(idx, html) {
const e = document.getElementById('st-' + idx);
if (e) e.innerHTML = html;
}
+344
View File
@@ -0,0 +1,344 @@
// Юнит-тесты фронта модуля upload: zip (кириллица, вложенный zip, не-doc) + дедуп/лимиты.
// Запуск: node upload/frontend/test_upload_frontend.mjs
import assert from 'node:assert/strict';
import { deflateRawSync } from 'node:zlib';
// ── Полифилл File (Node 18 не имеет global File) ──
if (typeof globalThis.File === 'undefined') {
globalThis.File = class File extends Blob {
constructor(parts, name, opts) {
super(parts, opts);
this.name = name;
this.lastModified = (opts && opts.lastModified) || Date.now();
}
};
}
import { listZipFiles } from './zip/list_zip_files.js';
import { parseZip } from './zip/parse_zip.js';
import { decodeZipName } from './zip/decode_zip_name.js';
import { addFileWithDedup } from './table/add_file_with_dedup.js';
import { esc } from './table/esc.js';
import { fs } from './table/fs.js';
import { fmtSec } from './table/fmt_sec.js';
import { estForFile } from './table/est_for_file.js';
const ALLOWED = ['.pdf', '.doc', '.docx', '.txt', '.md'];
// ── CRC32 (для сборки тестовых ZIP) ──
const CRC_TABLE = (() => {
const t = new Uint32Array(256);
for (let n = 0; n < 256; n++) {
let c = n;
for (let k = 0; k < 8; k++) c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1);
t[n] = c >>> 0;
}
return t;
})();
function crc32(bytes) {
let c = 0xffffffff;
for (let i = 0; i < bytes.length; i++) c = CRC_TABLE[(c ^ bytes[i]) & 0xff] ^ (c >>> 8);
return (c ^ 0xffffffff) >>> 0;
}
// ── Сборка минимального ZIP (method 0, UTF-8-флаг) ──
function makeZip(entries) {
const enc = new TextEncoder();
const chunks = [];
const central = [];
let offset = 0;
const utf8Flag = 0x0800;
for (const e of entries) {
const nameB = enc.encode(e.name);
const dataB = typeof e.data === 'string' ? enc.encode(e.data) : e.data;
const crc = crc32(dataB);
const lh = new DataView(new ArrayBuffer(30));
lh.setUint32(0, 0x04034b50, true);
lh.setUint16(4, 20, true);
lh.setUint16(6, utf8Flag, true);
lh.setUint16(8, 0, true);
lh.setUint16(10, 0, true);
lh.setUint16(12, 0x21, true);
lh.setUint32(14, crc, true);
lh.setUint32(18, dataB.length, true);
lh.setUint32(22, dataB.length, true);
lh.setUint16(26, nameB.length, true);
lh.setUint16(28, 0, true);
chunks.push(Buffer.from(lh.buffer), Buffer.from(nameB), Buffer.from(dataB));
const cd = new DataView(new ArrayBuffer(46));
cd.setUint32(0, 0x02014b50, true);
cd.setUint16(4, 20, true);
cd.setUint16(6, 20, true);
cd.setUint16(8, utf8Flag, true);
cd.setUint16(10, 0, true);
cd.setUint16(12, 0, true);
cd.setUint16(14, 0x21, true);
cd.setUint32(16, crc, true);
cd.setUint32(20, dataB.length, true);
cd.setUint32(24, dataB.length, true);
cd.setUint16(28, nameB.length, true);
cd.setUint16(30, 0, true);
cd.setUint16(32, 0, true);
cd.setUint16(34, 0, true);
cd.setUint16(36, 0, true);
cd.setUint32(38, 0, true);
cd.setUint32(42, offset, true);
central.push(Buffer.from(cd.buffer), Buffer.from(nameB));
offset += 30 + nameB.length + dataB.length;
}
const cdSize = central.reduce((s, x) => s + x.byteLength, 0);
const cdStart = offset;
const eocd = new DataView(new ArrayBuffer(22));
eocd.setUint32(0, 0x06054b50, true);
eocd.setUint16(4, 0, true);
eocd.setUint16(6, 0, true);
eocd.setUint16(8, entries.length, true);
eocd.setUint16(10, entries.length, true);
eocd.setUint32(12, cdSize, true);
eocd.setUint32(16, cdStart, true);
eocd.setUint16(20, 0, true);
chunks.push(...central, Buffer.from(eocd.buffer));
return Buffer.concat(chunks);
}
// ── Сборка ZIP с методом 8 (deflate) — для проверки inflateRaw ──
async function deflateRaw(bytes) {
// zlib.deflateRawSync — deflate без zlib-заголовка (то, что ждёт inflateRaw)
return deflateRawSync(Buffer.from(bytes));
}
async function makeZipDeflate(entries) {
const enc = new TextEncoder();
const chunks = [];
const central = [];
let offset = 0;
const utf8Flag = 0x0800;
for (const e of entries) {
const nameB = enc.encode(e.name);
const raw = enc.encode(e.data);
const comp = await deflateRaw(raw);
const crc = crc32(raw);
const lh = new DataView(new ArrayBuffer(30));
lh.setUint32(0, 0x04034b50, true);
lh.setUint16(4, 20, true);
lh.setUint16(6, utf8Flag, true);
lh.setUint16(8, 8, true); // method 8 deflate
lh.setUint16(10, 0, true);
lh.setUint16(12, 0x21, true);
lh.setUint32(14, crc, true);
lh.setUint32(18, comp.length, true);
lh.setUint32(22, raw.length, true);
lh.setUint16(26, nameB.length, true);
lh.setUint16(28, 0, true);
chunks.push(Buffer.from(lh.buffer), Buffer.from(nameB), Buffer.from(comp));
const cd = new DataView(new ArrayBuffer(46));
cd.setUint32(0, 0x02014b50, true);
cd.setUint16(4, 20, true);
cd.setUint16(6, 20, true);
cd.setUint16(8, utf8Flag, true);
cd.setUint16(10, 8, true);
cd.setUint16(12, 0, true);
cd.setUint16(14, 0x21, true);
cd.setUint32(16, crc, true);
cd.setUint32(20, comp.length, true);
cd.setUint32(24, raw.length, true);
cd.setUint16(28, nameB.length, true);
cd.setUint16(30, 0, true);
cd.setUint16(32, 0, true);
cd.setUint16(34, 0, true);
cd.setUint16(36, 0, true);
cd.setUint32(38, 0, true);
cd.setUint32(42, offset, true);
central.push(Buffer.from(cd.buffer), Buffer.from(nameB));
offset += 30 + nameB.length + comp.length;
}
const cdSize = central.reduce((s, x) => s + x.byteLength, 0);
const cdStart = offset;
const eocd = new DataView(new ArrayBuffer(22));
eocd.setUint32(0, 0x06054b50, true);
eocd.setUint16(4, 0, true);
eocd.setUint16(6, 0, true);
eocd.setUint16(8, entries.length, true);
eocd.setUint16(10, entries.length, true);
eocd.setUint32(12, cdSize, true);
eocd.setUint32(16, cdStart, true);
eocd.setUint16(20, 0, true);
chunks.push(...central, Buffer.from(eocd.buffer));
return Buffer.concat(chunks);
}
// ── Тесты decodeZipName ──
function testDecodeZipName() {
const utf8 = new TextEncoder().encode('договор.pdf');
assert.equal(decodeZipName(utf8, true), 'договор.pdf');
// Без UTF-8-флага, но байты — валидный UTF-8 с кириллицей → эвристика берёт как есть
assert.equal(decodeZipName(utf8, false), 'договор.pdf');
console.log(' decodeZipName: ok');
}
// ── Тесты listZipFiles ──
async function testZipCyrillic() {
const zip = makeZip([{ name: 'договор.pdf', data: '%PDF-1.4 fake' }]);
const files = await listZipFiles(new File([zip], 'a.zip'), ALLOWED);
assert.equal(files.length, 1);
assert.equal(files[0].name, 'договор.pdf');
console.log(' zip кириллица: ok');
}
async function testZipNested() {
const inner = makeZip([{ name: 'inner.txt', data: 'hi' }]);
const outer = makeZip([
{ name: 'inner.zip', data: inner },
{ name: 'skip.txt', data: 'x' },
]);
const files = await listZipFiles(new File([outer], 'outer.zip'), ALLOWED);
const names = files.map(f => f.name).sort();
assert.deepEqual(names, ['inner.txt', 'skip.txt']);
console.log(' вложенный zip: ok');
}
async function testZipNonDoc() {
const zip = makeZip([
{ name: 'doc.pdf', data: 'pdf' },
{ name: 'img.png', data: 'png' },
{ name: 'readme.txt', data: 'readme' },
]);
const files = await listZipFiles(new File([zip], 'a.zip'), ALLOWED);
const names = files.map(f => f.name).sort();
assert.deepEqual(names, ['doc.pdf', 'readme.txt']);
console.log(' не-doc фильтр: ok');
}
async function testZipDeflate() {
// deflate-raw требует DecompressionStream('deflate-raw') — в браузере есть, в Node 18 нет
let supported = true;
try { new DecompressionStream('deflate-raw'); } catch (e) { supported = false; }
if (!supported) {
console.log(' zip deflate (метод 8): skip — Node 18 не поддерживает deflate-raw');
return;
}
const content = 'Hello deflate world '.repeat(50);
const zip = await makeZipDeflate([{ name: 'deflated.txt', data: content }]);
const files = await listZipFiles(new File([zip], 'a.zip'), ALLOWED);
assert.equal(files.length, 1);
assert.equal(files[0].name, 'deflated.txt');
assert.equal(await files[0].text(), content);
console.log(' zip deflate (метод 8): ok');
}
async function testParseZipNotZip() {
await assert.rejects(() => parseZip(new Uint8Array([1, 2, 3])), /Не ZIP/);
console.log(' не-ZIP бросок: ok');
}
// ── Тесты addFileWithDedup ──
function newState() {
return { files: [], fileMeta: new Map(), overNames: new Set(), busy: false, proc: null };
}
function testDedup() {
const st = newState();
const c = { maxFileBytes: 100, maxSessionBytes: 300 };
// одинаковое имя+размер → дедуп (false), список не растёт
assert.equal(addFileWithDedup(st, new File(['aaa'], 'a.txt'), c), true);
assert.equal(st.files.length, 1);
assert.equal(addFileWithDedup(st, new File(['aaa'], 'a.txt'), c), false);
assert.equal(st.files.length, 1);
// имя то же, размер другой → суффикс _2
assert.equal(addFileWithDedup(st, new File(['bbbb'], 'a.txt'), c), true);
assert.equal(st.files.length, 2);
assert.equal(st.files[1].name, 'a_2.txt');
console.log(' дедуп/суффиксы: ok');
}
function testLimits() {
// лимит на один файл
const st = newState();
const c = { maxFileBytes: 100, maxSessionBytes: 300 };
addFileWithDedup(st, new File([new Uint8Array(150)], 'big.bin'), c);
assert.ok(st.overNames.has('big.bin'));
assert.equal(st.files.length, 1);
// суммарный лимит сессии
const st2 = newState();
const c2 = { maxFileBytes: 1000, maxSessionBytes: 200 };
addFileWithDedup(st2, new File([new Uint8Array(150)], 'f1.bin'), c2);
addFileWithDedup(st2, new File([new Uint8Array(60)], 'f2.bin'), c2); // 150+60=210 > 200 → over
assert.ok(st2.overNames.has('f2.bin'));
console.log(' лимиты over: ok');
}
function testSuffixes() {
const st = newState();
const c = { maxFileBytes: 10000, maxSessionBytes: 100000 };
addFileWithDedup(st, new File(['aaa'], 'a.txt'), c);
addFileWithDedup(st, new File(['bbbb'], 'a.txt'), c); // a_2.txt
addFileWithDedup(st, new File(['ccccc'], 'a.txt'), c); // a_3.txt
assert.deepEqual(st.files.map(f => f.name), ['a.txt', 'a_2.txt', 'a_3.txt']);
console.log(' суффиксы _2/_3: ok');
}
function testEsc() {
assert.equal(esc('<a href="x">&\'</a>'), '&lt;a href=&quot;x&quot;&gt;&amp;&#39;&lt;/a&gt;');
console.log(' esc (XSS-экранирование): ok');
}
function testFs() {
assert.equal(fs(0), '0 B');
assert.equal(fs(1023), '1023 B');
assert.equal(fs(1024), '1.0 KB');
assert.equal(fs(1048576), '1.0 MB');
console.log(' fs (размер): ok');
}
function testFmtSec() {
assert.equal(fmtSec(0), '0с');
assert.equal(fmtSec(59), '59с');
assert.equal(fmtSec(60), '1м 0с');
assert.equal(fmtSec(125), '2м 5с');
console.log(' fmtSec: ok');
}
function testEstForFile() {
assert.equal(estForFile({ size: 1048576 }, 12), 12); // 1 МБ × 12 = 12с
assert.equal(estForFile(null, 12), 0);
console.log(' estForFile: ok');
}
// ── Прогон ──
const TESTS = [
['decodeZipName', testDecodeZipName],
['zip кириллица', testZipCyrillic],
['вложенный zip', testZipNested],
['не-doc фильтр', testZipNonDoc],
['zip deflate (метод 8)', testZipDeflate],
['не-ZIP бросок', testParseZipNotZip],
['дедуп/суффиксы', testDedup],
['лимиты over', testLimits],
['суффиксы _2/_3', testSuffixes],
['esc XSS', testEsc],
['fs размер', testFs],
['fmtSec', testFmtSec],
['estForFile', testEstForFile],
];
let failed = 0;
for (const [name, fn] of TESTS) {
try {
await fn();
console.log('PASS ' + name);
} catch (err) {
failed++;
console.error('FAIL ' + name + ': ' + err.message);
}
}
if (failed) {
console.error(failed + ' тестов упало');
process.exit(1);
}
console.log('Все фронт-тесты прошли');
+199
View File
@@ -0,0 +1,199 @@
// Юнит-тесты слоя 2 (фронт): putToVm + uploadViaVM с моками XMLHttpRequest и fetch.
// Запуск: node upload/frontend/test_upload_layer2.mjs
import assert from 'node:assert/strict';
// ── Полифилл File (Node 18 не имеет global File) ──
if (typeof globalThis.File === 'undefined') {
globalThis.File = class File extends Blob {
constructor(parts, name, opts) {
super(parts, opts);
this.name = name;
this.lastModified = (opts && opts.lastModified) || Date.now();
}
};
}
import { putToVm } from './upload/put_to_vm.js';
import { uploadViaVM } from './upload/upload_via_vm.js';
const tick = () => new Promise(r => setTimeout(r, 0));
// Node 18 не имеет globalThis.crypto (в браузере есть) — заглушка для uploadViaVM
globalThis.crypto = globalThis.crypto || { randomUUID: () => 'test-uuid-1234' };
// ── мок XMLHttpRequest ──
let lastXHR = null;
class FakeXHR {
constructor() {
this.upload = {};
this.status = 0;
this.timeout = 0;
this.onload = null;
this.onerror = null;
this.ontimeout = null;
this.method = null;
this.url = null;
this.body = null;
lastXHR = this;
}
open(method, url) { this.method = method; this.url = url; }
send(body) { this.body = body; }
}
globalThis.XMLHttpRequest = FakeXHR;
// ── мок fetch ──
let fetchCalls = [];
function installFetch(respond) {
fetchCalls = [];
globalThis.fetch = async (url, opts) => {
fetchCalls.push({ url, opts });
return respond();
};
}
// ── putToVm ──
async function testPutToVmSuccess() {
const f = new File(['abc'], 'a.txt');
const p = putToVm(f, 'https://vm/tok_0');
lastXHR.status = 200;
lastXHR.onload();
await p;
assert.equal(lastXHR.method, 'PUT');
assert.equal(lastXHR.url, 'https://vm/tok_0');
assert.equal(lastXHR.body, f);
console.log(' putToVm success: ok');
}
async function testPutToVmHttpError() {
const p = putToVm(new File(['abc'], 'a.txt'), 'https://vm/tok_0');
lastXHR.status = 500;
lastXHR.onload();
await assert.rejects(p, /ВМ: HTTP 500/);
console.log(' putToVm HTTP error: ok');
}
async function testPutToVmNetworkError() {
const p = putToVm(new File(['abc'], 'a.txt'), 'https://vm/tok_0');
lastXHR.onerror();
await assert.rejects(p, /Сеть/);
console.log(' putToVm network error: ok');
}
async function testPutToVmTimeout() {
const p = putToVm(new File(['abc'], 'a.txt'), 'https://vm/tok_0');
lastXHR.ontimeout();
await assert.rejects(p, /Таймаут/);
console.log(' putToVm timeout: ok');
}
async function testPutToVmOnXHR() {
let registered = null;
const p = putToVm(new File(['abc'], 'a.txt'), 'https://vm/tok_0', { onXHR(x) { registered = x; } });
assert.ok(registered, 'onXHR должен получить XHR для отмены');
registered.status = 200;
registered.onload();
await p;
console.log(' putToVm onXHR регистрация: ok');
}
// ── uploadViaVM ──
async function testUploadViaVMSuccess() {
installFetch(() => ({ ok: true, json: async () => ({ ok: true, session: 'sid123', count: 2 }) }));
const files = [new File(['a'], 'a.txt'), new File(['bb'], 'b.txt')];
const p = uploadViaVM(files, 'https://vm/', { session: '' });
await tick();
lastXHR.status = 200; lastXHR.onload();
await tick();
lastXHR.status = 200; lastXHR.onload();
const res = await p;
assert.equal(res.ok, true);
assert.equal(res.session, 'sid123');
assert.equal(res.count, 2);
assert.equal(fetchCalls.length, 1);
const body = JSON.parse(fetchCalls[0].opts.body);
assert.equal(body.files.length, 2);
assert.equal(body.files[0].name, 'a.txt');
assert.equal(body.files[1].name, 'b.txt');
assert.ok(body.files[0].url.startsWith('https://vm/'));
console.log(' uploadViaVM success: ok');
}
async function testUploadViaVMPutError() {
installFetch(() => ({ ok: true, json: async () => ({}) }));
const files = [new File(['a'], 'a.txt'), new File(['bb'], 'b.txt')];
const p = uploadViaVM(files, 'https://vm/', { session: '' });
await tick();
lastXHR.onerror(); // первый PUT падает
const res = await p;
assert.equal(res.ok, false);
assert.ok(res.error.includes('Ошибка загрузки на ВМ'));
assert.equal(fetchCalls.length, 0); // POST не вызван
console.log(' uploadViaVM PUT error: ok');
}
async function testUploadViaVMPostError() {
installFetch(() => ({ ok: true, json: async () => ({ ok: false, error: 'Session not found' }) }));
const files = [new File(['a'], 'a.txt')];
const p = uploadViaVM(files, 'https://vm/', { session: '' });
await tick();
lastXHR.status = 200; lastXHR.onload();
const res = await p;
assert.equal(res.ok, false);
assert.ok(res.error.includes('Ошибка передачи ссылок'));
console.log(' uploadViaVM POST error: ok');
}
async function testUploadViaVMProgress() {
installFetch(() => ({ ok: true, json: async () => ({ ok: true, session: 's', count: 1 }) }));
const statuses = [];
const texts = [];
const files = [new File(['abc'], 'a.txt')];
const p = uploadViaVM(files, 'https://vm/', {
session: '',
onStatus(k, html) { statuses.push({ k, html }); },
onUploadStatus(t) { texts.push(t); },
});
await tick();
lastXHR.status = 200; lastXHR.onload();
await p;
assert.ok(statuses.some(s => s.html.includes('✓')));
assert.ok(texts.some(t => t.includes('Загрузка на ВМ')));
assert.ok(texts.some(t => t.includes('Передача ссылок')));
console.log(' uploadViaVM progress/статусы: ok');
}
async function testUploadViaVMSessionPassed() {
// session из options пробрасывается в POST-тело
installFetch(() => ({ ok: true, json: async () => ({ ok: true, session: 'prev', count: 1 }) }));
const files = [new File(['a'], 'a.txt')];
const p = uploadViaVM(files, 'https://vm/', { session: 'existingsid' });
await tick();
lastXHR.status = 200; lastXHR.onload();
await p;
const body = JSON.parse(fetchCalls[0].opts.body);
assert.equal(body.session, 'existingsid');
console.log(' uploadViaVM проброс session: ok');
}
// ── прогон ──
const TESTS = [
['putToVm success', testPutToVmSuccess],
['putToVm HTTP error', testPutToVmHttpError],
['putToVm network error', testPutToVmNetworkError],
['putToVm timeout', testPutToVmTimeout],
['putToVm onXHR', testPutToVmOnXHR],
['uploadViaVM success', testUploadViaVMSuccess],
['uploadViaVM PUT error', testUploadViaVMPutError],
['uploadViaVM POST error', testUploadViaVMPostError],
['uploadViaVM progress', testUploadViaVMProgress],
['uploadViaVM проброс session', testUploadViaVMSessionPassed],
];
let failed = 0;
for (const [name, fn] of TESTS) {
try { await fn(); console.log('PASS ' + name); }
catch (e) { failed++; console.error('FAIL ' + name + ': ' + e.message); }
}
if (failed) { console.error(failed + ' тестов упало'); process.exit(1); }
console.log('Все тесты слоя 2 (фронт) прошли');
+23
View File
@@ -0,0 +1,23 @@
// putToVm — PUT одного файла на ВМ-буфер (XHR, сырое тело).
// onProgress(pct) — прогресс загрузки. Разрешается при HTTP 2xx.
export function putToVm(file, url, options = {}) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
if (options.onXHR) options.onXHR(xhr); // регистрация для отмены (abort)
xhr.open('PUT', url);
xhr.timeout = options.timeoutMs || 300000; // 300с: большие файлы
xhr.upload.onprogress = function(e) {
if (e.lengthComputable && options.onProgress) {
options.onProgress(Math.round(e.loaded / e.total * 100));
}
};
xhr.onload = function() {
if (xhr.status >= 200 && xhr.status < 300) resolve();
else reject(new Error('ВМ: HTTP ' + xhr.status));
};
xhr.onerror = function() { reject(new Error('Сеть (ВМ)')); };
xhr.ontimeout = function() { reject(new Error('Таймаут 300с (ВМ)')); };
xhr.send(file); // сырое тело файла
});
}

Some files were not shown because too many files have changed in this diff Show More