Compare commits
36
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
83002b9644 | ||
|
|
f9745e5c6b | ||
|
|
811be85efe | ||
|
|
e5c7c88073 | ||
|
|
475efefb40 | ||
|
|
65939984c3 | ||
|
|
98e18b0135 | ||
|
|
36144334a3 | ||
|
|
c4997b06d3 | ||
|
|
533aa99592 | ||
|
|
aa585220fc | ||
|
|
08e8e3afec | ||
|
|
6fcbbddbdb | ||
|
|
08f7e3f98e | ||
|
|
b7d60e1d93 | ||
|
|
3b259cf160 | ||
|
|
8f8fabf959 | ||
|
|
9a8ae0a5a3 | ||
|
|
87524c54a4 | ||
|
|
021c925e3c | ||
|
|
e3f5581712 | ||
|
|
ebdab731ff | ||
|
|
5b49e88f1e | ||
|
|
d2894ad7c5 | ||
|
|
c2344f9738 | ||
|
|
db58a433fb | ||
|
|
55bd7a027d | ||
|
|
37ea5e2c31 | ||
|
|
78045f2c81 | ||
|
|
4653aa5e8e | ||
|
|
956aed0971 | ||
|
|
3b2e576284 | ||
|
|
de05d746cc | ||
|
|
c677206d03 | ||
|
|
d1415d5d21 | ||
|
|
5d8bcd61ea |
@@ -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`).
|
||||
@@ -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.
|
||||
@@ -0,0 +1,30 @@
|
||||
# load: SQLite database is locked при конкурентной записи (2026-08-26)
|
||||
|
||||
## Контекст
|
||||
|
||||
Полный прогон нагрузочных тестов (`pytest -m load`, дефолтные значения):
|
||||
- `test_load_pipeline` (100 контрактов × 20 допников × 10 услуг) — ✅ passed
|
||||
- `test_load_apply_ops` (5000 ADD → 5000 UPDATE → 5000 DELETE) — ✅ passed
|
||||
- `test_load_concurrency` (16 потоков × 200 ADD) — ❌ FAILED
|
||||
|
||||
## Находка
|
||||
|
||||
- `sqlite3.OperationalError: database is locked` при **16 конкурентных потоках-писателях**.
|
||||
- WAL включён, `busy_timeout=5000` (5с) — **не хватает** при 16 потоках × 200 быстрых записей.
|
||||
- Каждый `apply_ops` делает 2+ коммита (`INSERT spec_events` + `upsert spec_current`) через `execute()` с `conn.commit()`.
|
||||
- Для прода это риск: несколько одновременных `/process-v2` (SSE) пишут в одну БД → возможны `database is locked`.
|
||||
|
||||
## Решение по тесту
|
||||
|
||||
- Дефолт `LOAD_THREADS` снижен **16 → 8 → 4**.
|
||||
- Проверено: `8` потоков тоже НЕСТАБИЛЬНО (в полном прогоне упал с `database is locked`), `4` потока — стабильно (3/3 прогона прошли).
|
||||
- `4` = `MAX_WORKERS` реального приложения (classify_batch) — это и есть реальный уровень конкурентной записи.
|
||||
- `8+` — стресс-режим (демонстрирует предел конфигурации).
|
||||
|
||||
## Рекомендация для прода (обсудить отдельно)
|
||||
|
||||
1. Увеличить `busy_timeout` (сейчас 5000 мс) при конкурентной записи.
|
||||
2. Или сериализовать запись: один writer / очередь apply_ops по контракту.
|
||||
3. Или retry при `database is locked` на уровне `execute()`.
|
||||
|
||||
Ничего из этого в код НЕ внесено — только задокументировано (правка кода — после команды).
|
||||
@@ -0,0 +1,60 @@
|
||||
# Сводка сессии 2026-08-26 — contracts-flask (тесты + чистка + стресс)
|
||||
|
||||
## 1. Фикс логики сверки (v2.0.15 → v2.0.16)
|
||||
|
||||
- **`full_replace` дублировал строки**: `reset()` чистил `spec_current` только на старте
|
||||
пайплайна, а `full_replace` (все ADD) применялся поверх старых строк → дубли.
|
||||
- Фикс: `db/spec_events.py::clear_current()` (чистит только `spec_current`, история
|
||||
`spec_events` сохраняется) + вызов в `process.py` при `mode == "full_replace"`.
|
||||
- Коммит `021c925`, запушено, прод передеплоен → `2.0.16`.
|
||||
- Детали: `History/2026-08-26-full-replace-fix.md`.
|
||||
|
||||
## 2. Документация
|
||||
|
||||
- Архитектура: `DOC/architecture-contracts-flask.md` (актуальная, VM-буфер, клиентский ZIP).
|
||||
- Корневой `README.md` — подробный, со ссылкой на архитектуру.
|
||||
|
||||
## 3. Тесты
|
||||
|
||||
### Удалены старые (коммит `9a8ae0a`)
|
||||
- `deploy/tests/` (unit/pipeline/integration + `test_drhider.py`), `deploy/conftest.py`,
|
||||
`deploy/tests.js` — ссылались на старую `compare/`-архитектуру.
|
||||
|
||||
### Новые юнит/интеграционные (коммиты `8f8fabf`, `3b259cf`)
|
||||
- 13 файлов в `contracts-flask/tests/` + `conftest.py` (изоляция БД) + `README.md`.
|
||||
- Покрытие: metrics, grouping, llm_client, llm, classify, parse, connection,
|
||||
spec_events, spec_current, process_pipeline, routes.
|
||||
- **92 passed** (после чистки legacy; было 102).
|
||||
|
||||
### Нагрузочные (маркер `load`, коммиты `b7d60e1`, `08f7e3f`, `6fcbbdd`)
|
||||
- `tests/load/test_load_pipeline.py` — 100 контрактов × 20 допников × 10 услуг.
|
||||
- `tests/load/test_load_apply_ops.py` — 5000 ADD/UPDATE/DELETE.
|
||||
- `tests/load/test_load_concurrency.py` — конкурентная запись SQLite.
|
||||
- `tests/load/stress_http.py` — standalone HTTP-стресс.
|
||||
- Полный прогон: not-load 102 passed, load 3 passed (~3 мин).
|
||||
|
||||
### Находка №1: SQLite `database is locked` (коммит `08f7e3f`)
|
||||
- 16 конкурентных писателей → `database is locked`; 8 — нестабильно; **4 — стабильно**
|
||||
(= `MAX_WORKERS` приложения). Для текущего сценария (один пользователь) некритично.
|
||||
- `History/2026-08-26-load-sqlite-locked.md`.
|
||||
|
||||
## 4. Стресс-тест прода (коммит `08e8e3a`)
|
||||
|
||||
- Прод: `contractor.pythonk8s.dev.nubes.ru`, под `pythonk8s` (cpu 1, mem 1Gi).
|
||||
- Прямой POST 100KB → **83% таймаутов** (шлюз рвёт >64KB) — подтверждён предел платформы,
|
||||
поэтому и существует VM-буфер.
|
||||
- Реальный файл 30KB → 200/200 ok (rps 10), под CPU 899m / MEM 520Mi, без OOM.
|
||||
- `History/2026-08-26-stress-100kb-gateway.md`.
|
||||
|
||||
## 5. Чистка мёртвого legacy-кода (коммит `aa58522`)
|
||||
|
||||
- Удалены `POST /upload`, `POST /unzip-upload`, `_check_ext`, `_unzip`, `ALLOWED`,
|
||||
пустой blueprint, константы фронта `UPLOAD_URL`/`CONVERT_URL`/`UNZIP_URL`.
|
||||
- Загрузка теперь ТОЛЬКО через ВМ (`/api/upload_refs` → `contracts_upload_sink`).
|
||||
- `History/2026-08-26-cleanup-legacy-upload.md`.
|
||||
|
||||
## Хвосты / открытые вопросы
|
||||
|
||||
1. ~~`tests/load/stress_http.py` бьёт по удалённому `/upload`~~ — переписан на VM-путь (`PUT` ВМ → `/api/upload_refs`).
|
||||
2. Прод-риск `database is locked` при конкурентных сверках — отложен (нет мультитенантности).
|
||||
3. E2E с реальным LLM (`gpt-oss-120b`) на проде — не проверялся автоматически (тесты на Fake LLM).
|
||||
@@ -0,0 +1,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` не удалены.
|
||||
@@ -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.
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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
@@ -1,532 +0,0 @@
|
||||
/**
|
||||
* tests.js — Тесты чистых функций (Фазы 0-4, decoupling-final-plan.md).
|
||||
*
|
||||
* Запуск: node tests.js
|
||||
*
|
||||
* Проверяет: statusToHTML, applyParseResult, reconcileSelection,
|
||||
* renderGroupCard, renderGroupCardDone, renderUnresolvedCard,
|
||||
* applyCompareEvent, renderCompareSectionHeader, renderCompareOpsTable,
|
||||
* renderCompareSectionBody.
|
||||
*/
|
||||
|
||||
// ── Моки глобальных переменных ──────────────────────────────
|
||||
|
||||
// Браузерные глобалы, нужные модулям при загрузке
|
||||
global.window = global; // window.* присваивания
|
||||
|
||||
// Мок document
|
||||
global.document = {
|
||||
getElementById: function(id) { return null; },
|
||||
createElement: function(tag) {
|
||||
return {
|
||||
style: {},
|
||||
classList: { add: function(){}, remove: function(){} },
|
||||
innerHTML: '',
|
||||
textContent: '',
|
||||
appendChild: function(){},
|
||||
querySelector: function(){ return null; },
|
||||
querySelectorAll: function(){ return []; },
|
||||
addEventListener: function(){},
|
||||
parentNode: { insertBefore: function(){} },
|
||||
nextSibling: null
|
||||
};
|
||||
},
|
||||
querySelector: function() { return null; }
|
||||
};
|
||||
|
||||
// Мок crypto.randomUUID
|
||||
global.crypto = { randomUUID: function() { return 'test-uuid-' + Date.now(); } };
|
||||
|
||||
// Мок lucide
|
||||
global.lucide = { createIcons: function(){} };
|
||||
|
||||
// Глобальные переменные приложения
|
||||
global.fileInput = { disabled: false, value: '', addEventListener: function(){}, files: [] };
|
||||
global.fileTable = { innerHTML: '' };
|
||||
global.VM_API = 'https://contracts.kube5s.ru';
|
||||
global.UPLOAD_URL = VM_API + '/upload';
|
||||
global.CONVERT_URL = VM_API + '/convert-doc';
|
||||
global.UNZIP_URL = VM_API + '/unzip-upload';
|
||||
global.SITE_URL = '';
|
||||
|
||||
var state = {
|
||||
files: [],
|
||||
contractId: null,
|
||||
batchId: 'test-batch-id',
|
||||
groups: null,
|
||||
_activeCompare: { es: null, timer: null },
|
||||
ui: { steps: { upload: '○', classify: '○', groups: '○', compare: '○' } }
|
||||
};
|
||||
|
||||
// Мок escHtml (из app_utils.js)
|
||||
global.escHtml = function(s) {
|
||||
if (s == null) return '';
|
||||
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
};
|
||||
|
||||
var passed = 0, failed = 0;
|
||||
|
||||
function assert(cond, msg) {
|
||||
if (cond) { passed++; }
|
||||
else { console.log(' FAIL: ' + msg); failed++; }
|
||||
}
|
||||
|
||||
function eq(actual, expected, msg) {
|
||||
if (actual === expected) { passed++; }
|
||||
else { console.log(' FAIL: ' + msg + ' — expected ' + JSON.stringify(expected) + ', got ' + JSON.stringify(actual)); failed++; }
|
||||
}
|
||||
|
||||
function contains(str, substr, msg) {
|
||||
if (str.indexOf(substr) !== -1) { passed++; }
|
||||
else { console.log(' FAIL: ' + msg + ' — string does not contain "' + substr + '"'); console.log(' got: ' + str.substring(0, 200)); failed++; }
|
||||
}
|
||||
|
||||
// ── Загружаем модули ─────────────────────────────────────────
|
||||
// Модули используют function declaration (глобальные в браузере).
|
||||
// В Node.js загружаем через eval в глобальном контексте.
|
||||
|
||||
var fs = require('fs');
|
||||
|
||||
function loadModule(path) {
|
||||
var code = fs.readFileSync(path, 'utf-8');
|
||||
// (0, eval) — indirect eval, выполняется в глобальном скоупе (не strict)
|
||||
(0, eval)(code);
|
||||
}
|
||||
|
||||
console.log('=== Загрузка модулей ===');
|
||||
loadModule('./files.js');
|
||||
console.log(' files.js — OK');
|
||||
loadModule('./groups.js');
|
||||
console.log(' groups.js — OK');
|
||||
loadModule('./compare.js');
|
||||
console.log(' compare.js — OK');
|
||||
|
||||
// ── Тесты: statusToHTML ──────────────────────────────────────
|
||||
console.log('\n=== statusToHTML ===');
|
||||
|
||||
eq(statusToHTML(null), '', 'null → пусто');
|
||||
eq(statusToHTML({}), '', 'пустой объект → пусто');
|
||||
eq(statusToHTML({ kind: '' }), '', 'пустой kind → пусто');
|
||||
eq(statusToHTML({ kind: 'uploading', pct: 0 }), '↑ 0%', 'uploading 0%');
|
||||
eq(statusToHTML({ kind: 'uploading', pct: 75 }), '↑ 75%', 'uploading 75%');
|
||||
eq(statusToHTML({ kind: 'uploaded' }), '<span class="status-ok">✓</span>', 'uploaded');
|
||||
eq(statusToHTML({ kind: 'unzipping' }), '⏳ распаковка...', 'unzipping');
|
||||
eq(statusToHTML({ kind: 'parsing' }), '⏳ парсинг...', 'parsing');
|
||||
eq(statusToHTML({ kind: 'parsed', count: 5 }), '<span class="status-ok">✓ 5 эл.</span>', 'parsed без elapsed');
|
||||
eq(statusToHTML({ kind: 'parsed', count: 5, elapsed: '2.3' }), '<span class="status-ok">✓ 5 эл. (2.3с)</span>', 'parsed с elapsed');
|
||||
eq(statusToHTML({ kind: 'error', text: 'тест' }), '<span class="status-err">✗ тест</span>', 'error с текстом');
|
||||
eq(statusToHTML({ kind: 'error' }), '<span class="status-err">✗ Неизвестная ошибка</span>', 'error без текста');
|
||||
|
||||
// Краевые случаи
|
||||
eq(statusToHTML(undefined), '', 'undefined → пусто');
|
||||
eq(statusToHTML({ kind: 'uploading' }), '↑ 0%', 'uploading без pct → 0%');
|
||||
eq(statusToHTML({ kind: 'uploading', pct: -5 }), '↑ -5%', 'uploading отрицательный pct');
|
||||
eq(statusToHTML({ kind: 'uploading', pct: 100 }), '↑ 100%', 'uploading 100%');
|
||||
eq(statusToHTML({ kind: 'parsed', count: 0 }), '<span class="status-ok">✓ 0 эл.</span>', 'parsed count=0');
|
||||
eq(statusToHTML({ kind: 'parsed', count: 5, elapsed: '0' }), '<span class="status-ok">✓ 5 эл. (0с)</span>', 'parsed elapsed=0');
|
||||
eq(statusToHTML({ kind: 'error', text: '' }), '<span class="status-err">✗ Неизвестная ошибка</span>', 'error с пустым текстом → дефолт');
|
||||
eq(statusToHTML({ kind: 'unknown_kind' }), '', 'неизвестный kind → пусто');
|
||||
|
||||
// ── Тесты: applyParseResult ──────────────────────────────────
|
||||
console.log('\n=== applyParseResult ===');
|
||||
|
||||
// parsed success
|
||||
var e1 = {};
|
||||
applyParseResult(e1, { status: 'parsed', element_count: 10 }, '1.5');
|
||||
assert(e1.parsed === true, 'parsed=true');
|
||||
assert(e1.parseInfo.element_count === 10, 'parseInfo сохранён');
|
||||
eq(e1.status.kind, 'parsed', 'status.kind=parsed');
|
||||
eq(e1.status.count, 10, 'status.count=10');
|
||||
eq(e1.status.elapsed, '1.5', 'status.elapsed=1.5');
|
||||
|
||||
// parsed without elapsed
|
||||
var e2 = {};
|
||||
applyParseResult(e2, { status: 'parsed', element_count: 3 });
|
||||
eq(e2.status.kind, 'parsed', 'без elapsed: kind=parsed');
|
||||
eq(e2.status.count, 3, 'без elapsed: count=3');
|
||||
assert(e2.status.elapsed === undefined, 'без elapsed: elapsed отсутствует');
|
||||
|
||||
// error
|
||||
var e3 = {};
|
||||
applyParseResult(e3, { status: 'error', error: 'битый PDF' });
|
||||
eq(e3.status.kind, 'error', 'error: kind=error');
|
||||
eq(e3.status.text, 'битый PDF', 'error: text сохранён');
|
||||
eq(e3.parseInfo.error, 'битый PDF', 'error: parseInfo сохранён');
|
||||
|
||||
// null parsed (неизвестная ошибка)
|
||||
var e4 = {};
|
||||
applyParseResult(e4, null);
|
||||
eq(e4.status.kind, 'error', 'null: kind=error');
|
||||
eq(e4.status.text, 'Неизвестная ошибка', 'null: дефолтный текст');
|
||||
|
||||
// parsed с пустыми полями
|
||||
var e5 = {};
|
||||
applyParseResult(e5, { status: 'parsed' });
|
||||
eq(e5.status.kind, 'parsed', 'parsed без element_count: kind=parsed');
|
||||
eq(e5.status.count, undefined, 'parsed без element_count: count=undefined');
|
||||
// parsed с element_count=0
|
||||
var e6 = {};
|
||||
applyParseResult(e6, { status: 'parsed', element_count: 0 });
|
||||
eq(e6.status.count, 0, 'parsed element_count=0');
|
||||
// error без текста ошибки
|
||||
var e7 = {};
|
||||
applyParseResult(e7, { status: 'error' });
|
||||
eq(e7.status.text, 'ошибка парсинга', 'error без текста: дефолт "ошибка парсинга"');
|
||||
// entry с уже существующими полями (не должны мешать)
|
||||
var e8 = { existing: true, parsed: false };
|
||||
applyParseResult(e8, { status: 'parsed', element_count: 7 }, '3.0');
|
||||
assert(e8.existing === true, 'старое поле не затёрто');
|
||||
eq(e8.status.count, 7, 'новые данные поверх старых');
|
||||
|
||||
// ── Тесты: reconcileSelection ────────────────────────────────
|
||||
console.log('\n=== reconcileSelection ===');
|
||||
|
||||
var files = [
|
||||
{ name: 'a.docx' }, { name: 'b.pdf' }, { name: 'c.docx' }
|
||||
];
|
||||
var newFiles = [
|
||||
{ name: 'a.docx' }, { name: 'c.docx' }, { name: 'd.pdf' }
|
||||
];
|
||||
var result = reconcileSelection(files, newFiles);
|
||||
eq(result.length, 2, 'должно остаться 2 файла');
|
||||
eq(result[0].name, 'a.docx', 'a.docx остался');
|
||||
eq(result[1].name, 'c.docx', 'c.docx остался');
|
||||
|
||||
// Исходный массив не мутирован
|
||||
eq(files.length, 3, 'исходный массив не мутирован');
|
||||
|
||||
// Пустые входы
|
||||
var emptyResult = reconcileSelection([], []);
|
||||
eq(emptyResult.length, 0, 'пустые массивы → пусто');
|
||||
|
||||
// Все совпадают
|
||||
var allMatch = reconcileSelection([{name:'a'}], [{name:'a'}]);
|
||||
eq(allMatch.length, 1, 'все совпадают → 1');
|
||||
|
||||
// Ни один не совпадает
|
||||
var noMatch = reconcileSelection([{name:'a'},{name:'b'}], [{name:'c'},{name:'d'}]);
|
||||
eq(noMatch.length, 0, 'нет совпадений → пусто');
|
||||
|
||||
// Дубликаты имён в newFiles (должен работать — Set)
|
||||
var dupNames = [{name:'a'}, {name:'a'}, {name:'b'}];
|
||||
var dupResult = reconcileSelection([{name:'a'},{name:'b'}], dupNames);
|
||||
eq(dupResult.length, 2, 'дубликаты в newFiles — Set обрабатывает');
|
||||
|
||||
// ── Тесты: renderGroupCard ───────────────────────────────────
|
||||
console.log('\n=== renderGroupCard ===');
|
||||
|
||||
var group = {
|
||||
contract_number: '123',
|
||||
counterparty: 'ООО Тест',
|
||||
documents: [
|
||||
{ doc_type: 'contract', filename: 'договор.docx', doc_date: '2024-01-15' },
|
||||
{ doc_type: 'supplement', filename: 'дс1.docx' }
|
||||
]
|
||||
};
|
||||
|
||||
var html = renderGroupCard(group, 0);
|
||||
contains(html, 'Договор №123', 'номер договора');
|
||||
contains(html, 'ООО Тест', 'контрагент');
|
||||
contains(html, 'договор', 'тип contract → договор');
|
||||
contains(html, 'допсоглашение', 'тип supplement → допсоглашение');
|
||||
contains(html, '2024-01-15', 'дата');
|
||||
contains(html, 'data-gi="0"', 'data-gi атрибут');
|
||||
contains(html, 'Сравнить эту группу', 'кнопка сравнения');
|
||||
// НЕ должно быть ✓ Готово
|
||||
assert(html.indexOf('✓ Готово') === -1, 'нет "✓ Готово" для необработанной');
|
||||
|
||||
// Группа с compare.running
|
||||
var groupRunning = {
|
||||
contract_number: '456',
|
||||
counterparty: 'ЗАО Бег',
|
||||
documents: [{ doc_type: 'contract', filename: 'дог.docx' }],
|
||||
compare: { status: 'running' }
|
||||
};
|
||||
var htmlRunning = renderGroupCard(groupRunning, 1);
|
||||
contains(htmlRunning, 'disabled', 'кнопка disabled при running');
|
||||
|
||||
// Без контрагента
|
||||
var groupNoCP = { contract_number: '001', documents: [] };
|
||||
var htmlNoCP = renderGroupCard(groupNoCP, 5);
|
||||
contains(htmlNoCP, 'контрагент не определён', 'без counterparty: заглушка');
|
||||
|
||||
// Неизвестный doc_type
|
||||
var groupUnknown = { contract_number: 'X', counterparty: 'Y', documents: [{ doc_type: 'unknown_type', filename: 'f.docx' }] };
|
||||
var htmlUnknown = renderGroupCard(groupUnknown, 6);
|
||||
contains(htmlUnknown, 'unknown_type', 'неизвестный тип: выводится как есть');
|
||||
|
||||
// Пустой список документов
|
||||
var groupEmpty = { contract_number: 'E', counterparty: 'Empty', documents: [] };
|
||||
var htmlEmpty = renderGroupCard(groupEmpty, 7);
|
||||
contains(htmlEmpty, 'Договор №E', 'пустые документы: карточка рендерится');
|
||||
contains(htmlEmpty, 'Сравнить', 'пустые документы: кнопка есть');
|
||||
|
||||
// ── Тесты: renderGroupCardDone ───────────────────────────────
|
||||
console.log('\n=== renderGroupCardDone ===');
|
||||
|
||||
var groupDone = {
|
||||
contract_number: '789',
|
||||
counterparty: 'ИП Готово',
|
||||
documents: [
|
||||
{ doc_type: 'contract', filename: 'base.docx' },
|
||||
{ doc_type: 'specification', filename: 'spec.docx' }
|
||||
],
|
||||
compare: {
|
||||
status: 'done',
|
||||
totalTime: '12.5с',
|
||||
bodyHTML: '<div>результаты сравнения</div>'
|
||||
}
|
||||
};
|
||||
|
||||
var htmlDone = renderGroupCardDone(groupDone, 2);
|
||||
contains(htmlDone, '✓ Готово (12.5с)', '✓ Готово с временем');
|
||||
contains(htmlDone, 'cmpArrow_2', 'стрелка сворачивания');
|
||||
contains(htmlDone, 'спецификация', 'тип specification → спецификация');
|
||||
contains(htmlDone, 'результаты сравнения', 'bodyHTML вставлен');
|
||||
contains(htmlDone, 'data-gi="2"', 'data-gi на заголовке');
|
||||
|
||||
// ── Тесты: renderUnresolvedCard ──────────────────────────────
|
||||
console.log('\n=== renderUnresolvedCard ===');
|
||||
|
||||
var unresolved = {
|
||||
contract_number: '__unresolved__',
|
||||
documents: [
|
||||
{ doc_type: 'other', filename: 'unknown.docx', parent_number: '999' },
|
||||
{ doc_type: 'contract', filename: 'bad.docx', classify_status: 'failed', error_message: 'LLM error' }
|
||||
]
|
||||
};
|
||||
|
||||
var htmlUnres = renderUnresolvedCard(unresolved);
|
||||
contains(htmlUnres, 'Не распознано', 'заголовок нераспознанных');
|
||||
contains(htmlUnres, 'нет базового договора №999', 'причина: нет базового');
|
||||
contains(htmlUnres, 'ошибка классификации: LLM error', 'причина: ошибка классификации');
|
||||
|
||||
// ── Тесты: applyCompareEvent ─────────────────────────────────
|
||||
console.log('\n=== applyCompareEvent ===');
|
||||
|
||||
var sections = {};
|
||||
|
||||
// extract_start
|
||||
applyCompareEvent(sections, { type: 'extract_start', supplement_id: 's1', filename: 'test.docx' });
|
||||
assert(sections['s1'] !== undefined, 'секция создана');
|
||||
eq(sections['s1'].filename, 'test.docx', 'filename сохранён');
|
||||
eq(sections['s1'].status, 'extracting', 'status=extracting');
|
||||
|
||||
// llm_done
|
||||
applyCompareEvent(sections, { type: 'llm_done', supplement_id: 's1', ops_count: 15, mode: 'llm', time_s: 3.2 });
|
||||
eq(sections['s1'].status, 'llm_done', 'status=llm_done');
|
||||
eq(sections['s1'].ops_count, 15, 'ops_count=15');
|
||||
eq(sections['s1'].mode, 'llm', 'mode=llm');
|
||||
eq(sections['s1'].time_s, 3.2, 'time_s=3.2');
|
||||
|
||||
// applied
|
||||
applyCompareEvent(sections, {
|
||||
type: 'applied', supplement_id: 's1',
|
||||
summary: { added: 5, updated: 2, deleted: 1 },
|
||||
ops: [{ action: 'ADD', new_row: { name: 'Услуга 1' } }]
|
||||
});
|
||||
eq(sections['s1'].status, 'applied', 'status=applied');
|
||||
eq(sections['s1'].summary.added, 5, 'summary.added=5');
|
||||
eq(sections['s1'].ops.length, 1, 'ops.length=1');
|
||||
|
||||
// extract_error на существующей секции
|
||||
applyCompareEvent(sections, { type: 'extract_error', supplement_id: 's1', error: 'parse failed' });
|
||||
eq(sections['s1'].status, 'error', 'после ошибки: status=error');
|
||||
eq(sections['s1'].error, 'parse failed', 'текст ошибки');
|
||||
|
||||
// extract_error на НЕсуществующей секции
|
||||
applyCompareEvent(sections, { type: 'extract_error', supplement_id: 's2', filename: 'bad.docx', error: 'boom' });
|
||||
eq(sections['s2'].status, 'error', 'новая секция с ошибкой');
|
||||
eq(sections['s2'].filename, 'bad.docx', 'filename для ошибочной секции');
|
||||
|
||||
// Неизвестный тип события — не должен падать
|
||||
applyCompareEvent(sections, { type: 'unknown_type', supplement_id: 's3' });
|
||||
assert(sections['s3'] === undefined, 'неизвестный тип: секция НЕ создана');
|
||||
|
||||
// llm_done для несуществующей секции — не должен падать
|
||||
applyCompareEvent(sections, { type: 'llm_done', supplement_id: 's99', ops_count: 1 });
|
||||
|
||||
// apply_error (должен работать как extract_error)
|
||||
applyCompareEvent(sections, { type: 'apply_error', supplement_id: 's4', filename: 'apply_err.docx', error: 'apply boom' });
|
||||
eq(sections['s4'].status, 'error', 'apply_error: секция с ошибкой создана');
|
||||
eq(sections['s4'].error, 'apply boom', 'apply_error: текст ошибки');
|
||||
|
||||
// applied без summary
|
||||
applyCompareEvent(sections, { type: 'applied', supplement_id: 's1', ops: [] });
|
||||
eq(sections['s1'].status, 'applied', 'applied без summary: status ок');
|
||||
eq(sections['s1'].ops.length, 0, 'applied с пустыми ops');
|
||||
|
||||
// ── Тесты: renderCompareSectionHeader ────────────────────────
|
||||
console.log('\n=== renderCompareSectionHeader ===');
|
||||
|
||||
contains(renderCompareSectionHeader({ filename: 'f.docx', status: 'extracting' }), '⏳', 'extracting: ⏳');
|
||||
contains(renderCompareSectionHeader({ filename: 'f.docx', status: 'llm_done', ops_count: 5, mode: 'llm', time_s: 2 }), '✓', 'llm_done: ✓');
|
||||
contains(renderCompareSectionHeader({ filename: 'f.docx', status: 'llm_done', ops_count: 5, mode: 'llm', time_s: 2 }), '5 оп.', 'llm_done: ops_count');
|
||||
contains(renderCompareSectionHeader({ filename: 'f.docx', status: 'error', error: 'fail' }), '✗', 'error: ✗');
|
||||
contains(renderCompareSectionHeader({ filename: 'f.docx', status: 'error', error: 'fail' }), 'fail', 'error: текст');
|
||||
|
||||
// ── Тесты: renderCompareOpsTable ─────────────────────────────
|
||||
console.log('\n=== renderCompareOpsTable ===');
|
||||
|
||||
var ops = [
|
||||
{ action: 'ADD', new_row: { name: 'Стойка', price: 100, qty: 2, sum: 200, date_start: '2024-01-01' } },
|
||||
{ action: 'DELETE', new_row: { name: 'IP', price: 50, qty: 1, sum: 50, date_start: '' } }
|
||||
];
|
||||
var tableHtml = renderCompareOpsTable(ops);
|
||||
contains(tableHtml, 'diff-added', 'ADD → diff-added');
|
||||
contains(tableHtml, 'diff-deleted', 'DELETE → diff-deleted');
|
||||
contains(tableHtml, 'Стойка', 'имя услуги');
|
||||
contains(tableHtml, '100', 'цена');
|
||||
contains(tableHtml, '2024-01-01', 'дата');
|
||||
|
||||
// Пустые ops
|
||||
var emptyTable = renderCompareOpsTable([]);
|
||||
contains(emptyTable, '<table', 'пустые ops: таблица рендерится');
|
||||
contains(emptyTable, '<tbody>', 'пустые ops: tbody есть');
|
||||
|
||||
// ops с null new_row
|
||||
var nullRow = renderCompareOpsTable([{ action: 'ADD' }]);
|
||||
contains(nullRow, '<td></td>', 'null new_row: пустые ячейки (не падает)');
|
||||
|
||||
// ops с неизвестным action (без класса)
|
||||
var unknownAct = renderCompareOpsTable([{ action: 'MERGE', new_row: {} }]);
|
||||
assert(unknownAct.indexOf('diff-added') === -1, 'MERGE: нет diff-added');
|
||||
assert(unknownAct.indexOf('diff-deleted') === -1, 'MERGE: нет diff-deleted');
|
||||
assert(unknownAct.indexOf('diff-changed') === -1, 'MERGE: нет diff-changed');
|
||||
|
||||
// ── Тесты: renderCompareSectionBody ──────────────────────────
|
||||
console.log('\n=== renderCompareSectionBody ===');
|
||||
|
||||
eq(renderCompareSectionBody(null), '', 'null → пусто');
|
||||
eq(renderCompareSectionBody({}), '', 'пустой → пусто');
|
||||
eq(renderCompareSectionBody({ status: 'llm_done' }), '', 'llm_done → пусто');
|
||||
|
||||
var secApplied = {
|
||||
status: 'applied',
|
||||
summary: { added: 3, updated: 1, deleted: 0, unresolved: 2 },
|
||||
ops: [{ action: 'UPDATE', new_row: { name: 'Стойка 42U' } }]
|
||||
};
|
||||
var bodyHtml = renderCompareSectionBody(secApplied);
|
||||
contains(bodyHtml, '+3', 'added=3');
|
||||
contains(bodyHtml, '~1', 'updated=1');
|
||||
contains(bodyHtml, '-0', 'deleted=0');
|
||||
contains(bodyHtml, '?2', 'unresolved=2');
|
||||
contains(bodyHtml, 'diff-changed', 'UPDATE → diff-changed');
|
||||
|
||||
// Без unresolved
|
||||
var secNoUnresolved = { status: 'applied', summary: { added: 1, updated: 0, deleted: 0 }, ops: [] };
|
||||
var bodyNoUnr = renderCompareSectionBody(secNoUnresolved);
|
||||
assert(bodyNoUnr.indexOf('?') === -1, 'без unresolved: нет знака ?');
|
||||
|
||||
// Отрицательные значения summary (не должно быть, но не падать)
|
||||
var secNeg = { status: 'applied', summary: { added: -1, updated: 0, deleted: 0 }, ops: [{ action: 'ADD', new_row: {} }] };
|
||||
var bodyNeg = renderCompareSectionBody(secNeg);
|
||||
contains(bodyNeg, '+-1', 'отрицательные summary: рендерится без ошибок');
|
||||
|
||||
// ops с частичным new_row (не все поля)
|
||||
var secPartial = { status: 'applied', summary: { added: 1 }, ops: [
|
||||
{ action: 'ADD', new_row: { name: 'Только имя' } }
|
||||
]};
|
||||
var bodyPartial = renderCompareSectionBody(secPartial);
|
||||
contains(bodyPartial, 'Только имя', 'частичный new_row: имя есть');
|
||||
// Проверим что нет undefined в выводе
|
||||
assert(bodyPartial.indexOf('undefined') === -1, 'нет undefined в выводе');
|
||||
|
||||
// ── Тесты: escHtml ───────────────────────────────────────────
|
||||
console.log('\n=== escHtml ===');
|
||||
|
||||
eq(escHtml(null), '', 'escHtml null → пусто');
|
||||
eq(escHtml(undefined), '', 'escHtml undefined → пусто');
|
||||
eq(escHtml('hello'), 'hello', 'escHtml обычный текст');
|
||||
eq(escHtml('<script>'), '<script>', 'escHtml экранирует < >');
|
||||
eq(escHtml('a&b'), 'a&b', 'escHtml экранирует &');
|
||||
eq(escHtml('"quote"'), '"quote"', 'escHtml экранирует кавычки');
|
||||
eq(escHtml(123), '123', 'escHtml число → строка');
|
||||
|
||||
// ── Дымные тесты API (бэкенд) ────────────────────────────────
|
||||
console.log('\n=== API smoke tests ===');
|
||||
|
||||
var http = require('http');
|
||||
var https = require('https');
|
||||
|
||||
function apiTest(method, url, cb) {
|
||||
var mod = url.startsWith('https') ? https : http;
|
||||
var req = mod.request(url, { method: method, timeout: 5000, rejectUnauthorized: false }, function(res) {
|
||||
var body = '';
|
||||
res.on('data', function(c) { body += c; });
|
||||
res.on('end', function() { cb(null, res.statusCode, body); });
|
||||
});
|
||||
req.on('error', function(e) { cb(e.message); });
|
||||
req.on('timeout', function() { req.destroy(); cb('timeout'); });
|
||||
req.end();
|
||||
}
|
||||
|
||||
// Эти тесты асинхронные — собираем результаты
|
||||
var apiTests = [];
|
||||
function addApiTest(name, method, url, check) {
|
||||
apiTests.push({ name: name, method: method, url: url, check: check });
|
||||
}
|
||||
|
||||
addApiTest('GET / (главная)', 'GET', 'https://contracts.kube5s.ru/', function(code, body) {
|
||||
assert(code === 200, 'главная: HTTP 200 — got ' + code);
|
||||
contains(body, '<!DOCTYPE html>', 'главная: HTML');
|
||||
});
|
||||
|
||||
addApiTest('POST /api/cleanup', 'POST', 'https://contracts.kube5s.ru/api/cleanup', function(code, body) {
|
||||
// cleanup может вернуть 200 или 500 (если БД недоступна) — оба норм для дымного теста
|
||||
assert(code >= 200 && code < 600, 'cleanup: ответ получен — ' + code);
|
||||
});
|
||||
|
||||
addApiTest('GET /static/state.js', 'GET', 'https://contracts.kube5s.ru/static/state.js', function(code, body) {
|
||||
eq(code, 200, 'state.js: HTTP 200 — got ' + code);
|
||||
contains(body, 'Центральное состояние', 'state.js: содержит описание');
|
||||
});
|
||||
|
||||
addApiTest('GET /static/files.js', 'GET', 'https://contracts.kube5s.ru/static/files.js', function(code) {
|
||||
eq(code, 200, 'files.js: HTTP 200 — got ' + code);
|
||||
});
|
||||
|
||||
addApiTest('GET /static/groups.js', 'GET', 'https://contracts.kube5s.ru/static/groups.js', function(code) {
|
||||
eq(code, 200, 'groups.js: HTTP 200 — got ' + code);
|
||||
});
|
||||
|
||||
addApiTest('GET /static/compare.js', 'GET', 'https://contracts.kube5s.ru/static/compare.js', function(code) {
|
||||
eq(code, 200, 'compare.js: HTTP 200 — got ' + code);
|
||||
});
|
||||
|
||||
addApiTest('GET /static/app.js', 'GET', 'https://contracts.kube5s.ru/static/app.js', function(code) {
|
||||
eq(code, 200, 'app.js: HTTP 200 — got ' + code);
|
||||
});
|
||||
|
||||
// Запускаем API тесты последовательно (чтобы не зафлудить)
|
||||
var apiIdx = 0;
|
||||
function runNextApiTest() {
|
||||
if (apiIdx >= apiTests.length) {
|
||||
// Все API тесты готовы — выводим итоги
|
||||
printResults();
|
||||
return;
|
||||
}
|
||||
var t = apiTests[apiIdx];
|
||||
apiTest(t.method, t.url, function(err, code, body) {
|
||||
if (err) {
|
||||
console.log(' FAIL: ' + t.name + ' — ' + err);
|
||||
failed++;
|
||||
} else {
|
||||
t.check(code, body || '');
|
||||
}
|
||||
apiIdx++;
|
||||
runNextApiTest();
|
||||
});
|
||||
}
|
||||
|
||||
function printResults() {
|
||||
console.log('\n========================================');
|
||||
console.log('PASS: ' + passed);
|
||||
console.log('FAIL: ' + failed);
|
||||
console.log('========================================');
|
||||
if (failed > 0) process.exit(1);
|
||||
}
|
||||
|
||||
// Начинаем с API тестов после синхронных
|
||||
console.log(' (асинхронные — ждём...)');
|
||||
runNextApiTest();
|
||||
@@ -1,365 +0,0 @@
|
||||
"""
|
||||
test_testgen_pipeline.py — Сквозной тест: testgen → parse → textify → LLM-промпт.
|
||||
|
||||
Пайплайн (без БД, без HTTP):
|
||||
1. testgen генерирует DOCX (договор, спека, допник)
|
||||
2. parse.py парсит байты → elements_json
|
||||
3. process.py::_elements_to_text() → plain text для LLM
|
||||
4. llm_prompt.py строит промпт → проверяем структуру
|
||||
|
||||
Точка входа: elements_json — именно туда приходят данные после парсинга.
|
||||
"""
|
||||
import io
|
||||
import json
|
||||
import sys
|
||||
import os
|
||||
import pytest
|
||||
|
||||
# Добавить пути для импортов
|
||||
_deploy = os.path.join(os.path.dirname(__file__), "..", "..")
|
||||
_testgen = os.path.join(_deploy, "..", "..", "testgen")
|
||||
sys.path.insert(0, _deploy)
|
||||
sys.path.insert(0, _testgen)
|
||||
|
||||
from compare.parse import parse_file
|
||||
from compare.process import _elements_to_text
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# Helpers
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
def _build_and_parse(build_fn, ctx):
|
||||
"""Построить docx через testgen, отдать байты парсеру. Возвращает parsed dict."""
|
||||
from docx import Document
|
||||
doc = build_fn(ctx)
|
||||
buf = io.BytesIO()
|
||||
doc.save(buf)
|
||||
return parse_file("test.docx", buf.getvalue())
|
||||
|
||||
|
||||
def _elements_to_dicts(elements):
|
||||
"""Привести elements к dict для сравнения (убрать лишнее)."""
|
||||
result = []
|
||||
for el in elements:
|
||||
d = {"type": el["type"]}
|
||||
if el["type"] == "paragraph":
|
||||
d["text"] = el.get("text", "")
|
||||
elif el["type"] == "table":
|
||||
d["row_count"] = len(el.get("rows", []))
|
||||
d["col_count"] = len(el["rows"][0]) if el.get("rows") else 0
|
||||
d["first_cell"] = el["rows"][0][0] if el.get("rows") and el["rows"][0] else ""
|
||||
result.append(d)
|
||||
return result
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# Tests
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
class TestGenerateParseTextify:
|
||||
"""Полный пайплайн: testgen → parse → textify."""
|
||||
|
||||
# ── Данные ────────────────────────────────────────────────
|
||||
|
||||
@pytest.fixture
|
||||
def pools(self):
|
||||
import pools
|
||||
return pools
|
||||
|
||||
@pytest.fixture
|
||||
def templates(self):
|
||||
import templates
|
||||
return templates
|
||||
|
||||
@pytest.fixture
|
||||
def contract_ctx(self, pools):
|
||||
ci = 0
|
||||
c = pools.COMPANIES[ci]
|
||||
num = pools.contract_number(ci)
|
||||
date = pools.random_date()
|
||||
return {
|
||||
"company": c, "number": num, "date": date, "company_idx": ci,
|
||||
"comment": "Тестовый контракт для пайплайна.",
|
||||
}
|
||||
|
||||
@pytest.fixture
|
||||
def spec_ctx(self, pools):
|
||||
ci = 0
|
||||
c = pools.COMPANIES[ci]
|
||||
num = pools.contract_number(ci)
|
||||
date = pools.random_date()
|
||||
svcs, total, total_str = pools.pick_services(5)
|
||||
for s in svcs:
|
||||
s["date_start"] = pools.random_date(2026, 3, 3)
|
||||
return {
|
||||
"company": c, "number": num, "date": date, "contract_date": date,
|
||||
"version": 1, "services": svcs, "total": total, "total_str": total_str,
|
||||
"label": f"Абонентские услуги с {date}",
|
||||
"comment": "Тестовая спецификация для пайплайна.",
|
||||
}
|
||||
|
||||
@pytest.fixture
|
||||
def addendum_ctx(self, pools):
|
||||
ci = 0
|
||||
c = pools.COMPANIES[ci]
|
||||
num = pools.contract_number(ci)
|
||||
date = pools.random_date()
|
||||
inst, itot, itot_s = pools.pick_services(2)
|
||||
mon, mtot, mtot_s = pools.pick_services(3)
|
||||
return {
|
||||
"company": c, "number": num, "date": date, "contract_date": pools.random_date(),
|
||||
"addendum_num": 1,
|
||||
"services_install": inst, "install_total": itot, "install_total_str": itot_s,
|
||||
"services_monthly": mon, "monthly_total": mtot, "monthly_total_str": mtot_s,
|
||||
"comment": "Тестовый допник для пайплайна.",
|
||||
}
|
||||
|
||||
# ── Этап 1: Генерация → парсинг ───────────────────────────
|
||||
|
||||
def test_generate_contract_parse_ok(self, templates, contract_ctx):
|
||||
"""Договор: генерируется и парсится без ошибок."""
|
||||
result = _build_and_parse(templates.build_contract, contract_ctx)
|
||||
assert result["status"] == "parsed", f"Parse failed: {result.get('error')}"
|
||||
assert result["element_count"] > 0
|
||||
|
||||
def test_generate_contract_has_paragraphs(self, templates, contract_ctx):
|
||||
"""Договор: в elements есть paragraphs."""
|
||||
result = _build_and_parse(templates.build_contract, contract_ctx)
|
||||
paragraphs = [e for e in result["elements"] if e["type"] == "paragraph"]
|
||||
assert len(paragraphs) >= 3, f"Expected >=3 paragraphs, got {len(paragraphs)}"
|
||||
|
||||
def test_generate_contract_has_tables(self, templates, contract_ctx):
|
||||
"""Договор: в elements есть таблица реквизитов."""
|
||||
result = _build_and_parse(templates.build_contract, contract_ctx)
|
||||
tables = [e for e in result["elements"] if e["type"] == "table"]
|
||||
assert len(tables) >= 1, f"Expected >=1 table, got {len(tables)}"
|
||||
|
||||
def test_generate_spec_parse_ok(self, templates, spec_ctx):
|
||||
"""Спецификация: генерируется и парсится без ошибок."""
|
||||
result = _build_and_parse(templates.build_spec, spec_ctx)
|
||||
assert result["status"] == "parsed"
|
||||
assert result["element_count"] > 0
|
||||
|
||||
def test_generate_spec_has_services_table(self, templates, spec_ctx):
|
||||
"""Спецификация: таблица услуг найдена."""
|
||||
result = _build_and_parse(templates.build_spec, spec_ctx)
|
||||
tables = [e for e in result["elements"] if e["type"] == "table"]
|
||||
assert len(tables) >= 1
|
||||
# Первая таблица — услуги, должна быть НЕ пустой
|
||||
svc_table = tables[0]
|
||||
assert len(svc_table["rows"]) >= 2 # заголовок + минимум 1 строка
|
||||
|
||||
def test_generate_addendum_parse_ok(self, templates, addendum_ctx):
|
||||
"""Допсоглашение: генерируется и парсится без ошибок."""
|
||||
result = _build_and_parse(templates.build_addendum, addendum_ctx)
|
||||
assert result["status"] == "parsed"
|
||||
assert result["element_count"] > 0
|
||||
|
||||
def test_generate_addendum_has_tables(self, templates, addendum_ctx):
|
||||
"""Допсоглашение: таблицы разовых и абонентских услуг."""
|
||||
result = _build_and_parse(templates.build_addendum, addendum_ctx)
|
||||
tables = [e for e in result["elements"] if e["type"] == "table"]
|
||||
# Должно быть минимум 3 таблицы: реквизиты + разовые + абонентские
|
||||
assert len(tables) >= 3, f"Expected >=3 tables, got {len(tables)}"
|
||||
|
||||
# ── Этап 2: elements_json → textify ────────────────────────
|
||||
|
||||
def test_textify_contract(self, templates, contract_ctx):
|
||||
"""Договор after textify содержит ключевые слова."""
|
||||
result = _build_and_parse(templates.build_contract, contract_ctx)
|
||||
text = _elements_to_text(result["elements"])
|
||||
assert "Договор" in text or "ДОГОВОР" in text
|
||||
assert "НУБЕС" in text
|
||||
assert len(text) > 200, f"Text too short: {len(text)} chars"
|
||||
|
||||
def test_textify_spec_contains_table_markers(self, templates, spec_ctx):
|
||||
"""Спецификация after textify: пайп-таблицы на месте."""
|
||||
result = _build_and_parse(templates.build_spec, spec_ctx)
|
||||
text = _elements_to_text(result["elements"])
|
||||
# Должны быть маркеры таблиц (--- Таблица ... ---)
|
||||
assert "Таблица" in text
|
||||
# Должны быть пайпы (формат ячеек)
|
||||
assert "|" in text
|
||||
# Должны быть названия услуг
|
||||
for s in spec_ctx["services"]:
|
||||
# Проверяем первые 30 символов названия
|
||||
short_name = s["name"][:30]
|
||||
assert short_name in text, f"Service '{short_name}' not found in textified output"
|
||||
|
||||
def test_textify_spec_contains_total(self, templates, spec_ctx):
|
||||
"""Спецификация: итоговая сумма в тексте."""
|
||||
result = _build_and_parse(templates.build_spec, spec_ctx)
|
||||
text = _elements_to_text(result["elements"])
|
||||
assert "Итого" in text
|
||||
|
||||
def test_textify_addendum_structure(self, templates, addendum_ctx):
|
||||
"""Допсоглашение after textify: структура сохраняется."""
|
||||
result = _build_and_parse(templates.build_addendum, addendum_ctx)
|
||||
text = _elements_to_text(result["elements"])
|
||||
assert "Соглашение" in text or "СОГЛАШЕНИЕ" in text
|
||||
assert "Инсталляц" in text or "Разовые" in text
|
||||
assert "Абонентские" in text
|
||||
|
||||
# ── Этап 3: elements_json → промпт LLM ─────────────────────
|
||||
|
||||
def test_build_extract_prompt_from_elements(self, templates, spec_ctx):
|
||||
"""Из elements_json спецификации строится extract-промпт.
|
||||
Пустая current_spec → extract mode (FALLBACK_EXTRACT)."""
|
||||
from llm_prompt import build_prompt
|
||||
result = _build_and_parse(templates.build_spec, spec_ctx)
|
||||
doc_text = _elements_to_text(result["elements"])
|
||||
|
||||
# Пустая current_spec = первый документ = extract-промпт
|
||||
prompt_text, prompt_id = build_prompt([], doc_text)
|
||||
assert len(prompt_text) > 100
|
||||
assert "{doc_text}" not in prompt_text # переменная подставлена
|
||||
assert doc_text in prompt_text # текст документа внутри промпта
|
||||
assert "JSON" in prompt_text or "json" in prompt_text
|
||||
|
||||
def test_build_diff_prompt_from_elements(self, templates, addendum_ctx):
|
||||
"""Из elements_json допсоглашения строится diff-промпт.
|
||||
Непустая current_spec → diff mode (FALLBACK_DIFF)."""
|
||||
from llm_prompt import build_prompt
|
||||
result = _build_and_parse(templates.build_addendum, addendum_ctx)
|
||||
doc_text = _elements_to_text(result["elements"])
|
||||
|
||||
# Непустая current_spec = не первый документ = diff-промпт
|
||||
current_spec = [
|
||||
{"hash": "h1", "name": "Аренда стойко-места", "price": 50000, "qty": 1, "sum": 50000, "date_start": "2026-01-01"},
|
||||
]
|
||||
prompt_text, prompt_id = build_prompt(current_spec, doc_text)
|
||||
assert len(prompt_text) > 100
|
||||
assert doc_text in prompt_text
|
||||
assert "UPDATE" in prompt_text or "DELETE" in prompt_text or "ADD" in prompt_text
|
||||
|
||||
# ── Этап 4: edge cases ─────────────────────────────────────
|
||||
|
||||
def test_parse_empty_docx(self):
|
||||
"""Пустой docx — парсится без ошибок, но без элементов."""
|
||||
from docx import Document
|
||||
doc = Document()
|
||||
buf = io.BytesIO()
|
||||
doc.save(buf)
|
||||
result = parse_file("empty.docx", buf.getvalue())
|
||||
assert result["status"] == "parsed"
|
||||
assert result["element_count"] == 0
|
||||
|
||||
def test_parse_docx_text_only(self):
|
||||
"""DOCX только с текстом — корректно парсится."""
|
||||
from docx import Document
|
||||
doc = Document()
|
||||
doc.add_paragraph("Привет мир")
|
||||
doc.add_paragraph("Вторая строка")
|
||||
buf = io.BytesIO()
|
||||
doc.save(buf)
|
||||
result = parse_file("text.docx", buf.getvalue())
|
||||
assert result["status"] == "parsed"
|
||||
assert result["element_count"] == 2
|
||||
assert all(e["type"] == "paragraph" for e in result["elements"])
|
||||
|
||||
def test_parse_docx_table_only(self):
|
||||
"""DOCX только с таблицей — корректно парсится."""
|
||||
from docx import Document
|
||||
doc = Document()
|
||||
table = doc.add_table(rows=2, cols=3)
|
||||
table.rows[0].cells[0].text = "A"
|
||||
table.rows[0].cells[1].text = "B"
|
||||
table.rows[0].cells[2].text = "C"
|
||||
table.rows[1].cells[0].text = "1"
|
||||
table.rows[1].cells[1].text = "2"
|
||||
table.rows[1].cells[2].text = "3"
|
||||
buf = io.BytesIO()
|
||||
doc.save(buf)
|
||||
result = parse_file("table.docx", buf.getvalue())
|
||||
assert result["status"] == "parsed"
|
||||
tables = [e for e in result["elements"] if e["type"] == "table"]
|
||||
assert len(tables) == 1
|
||||
assert tables[0]["rows"] == [["A", "B", "C"], ["1", "2", "3"]]
|
||||
|
||||
def test_textify_preserves_table_data(self):
|
||||
"""Textify сохраняет все данные таблицы (без потерь)."""
|
||||
elements = [
|
||||
{"type": "table", "rows": [
|
||||
["Услуга", "Цена", "Кол-во"],
|
||||
["Аренда стойки", "50000", "2"],
|
||||
["IP-адрес", "300", "8"],
|
||||
]}
|
||||
]
|
||||
text = _elements_to_text(elements)
|
||||
assert "Услуга" in text
|
||||
assert "Аренда стойки" in text
|
||||
assert "50000" in text
|
||||
assert "2" in text
|
||||
assert "IP-адрес" in text
|
||||
assert "300" in text
|
||||
assert "8" in text
|
||||
|
||||
# ── Этап 5: валидация manifest-подобной структуры ──────────
|
||||
|
||||
def test_contract_elements_structure(self, templates, contract_ctx):
|
||||
"""Элементы договора имеют правильную структуру."""
|
||||
result = _build_and_parse(templates.build_contract, contract_ctx)
|
||||
for el in result["elements"]:
|
||||
assert "type" in el
|
||||
assert el["type"] in ("paragraph", "table")
|
||||
if el["type"] == "paragraph":
|
||||
assert "text" in el
|
||||
assert isinstance(el["text"], str)
|
||||
elif el["type"] == "table":
|
||||
assert "rows" in el
|
||||
assert isinstance(el["rows"], list)
|
||||
assert len(el["rows"]) > 0
|
||||
assert all(isinstance(row, list) for row in el["rows"])
|
||||
|
||||
def test_multiple_contracts_different_content(self, templates, pools):
|
||||
"""Разные контракты дают разный elements_json."""
|
||||
import hashlib
|
||||
|
||||
results = []
|
||||
for ci in range(3):
|
||||
c = pools.COMPANIES[ci]
|
||||
ctx = {
|
||||
"company": c, "number": pools.contract_number(ci),
|
||||
"date": pools.random_date(), "company_idx": ci,
|
||||
"comment": f"Контракт {ci}",
|
||||
}
|
||||
result = _build_and_parse(templates.build_contract, ctx)
|
||||
# Сериализуем для сравнения
|
||||
txt = _elements_to_text(result["elements"])
|
||||
results.append(txt)
|
||||
|
||||
# Все три должны быть разными
|
||||
hashes = [hashlib.md5(t.encode()).hexdigest() for t in results]
|
||||
assert len(set(hashes)) == 3, f"All 3 contracts produced same text!"
|
||||
|
||||
def test_spec_v1_v2_produce_different_tables(self, templates, pools):
|
||||
"""Спецификации v1 и v2 дают разные таблицы (разные цены/объёмы)."""
|
||||
c = pools.COMPANIES[0]
|
||||
base_ctx = {
|
||||
"company": c, "number": pools.contract_number(0),
|
||||
"date": pools.random_date(), "contract_date": pools.random_date(),
|
||||
"version": 1,
|
||||
"label": "Абонентские услуги",
|
||||
}
|
||||
|
||||
# v1
|
||||
svcs_v1, tot_v1, tot_str_v1 = pools.pick_services(5)
|
||||
ctx_v1 = {**base_ctx, "services": svcs_v1, "total": tot_v1, "total_str": tot_str_v1}
|
||||
r1 = _build_and_parse(templates.build_spec, ctx_v1)
|
||||
t1 = _elements_to_text(r1["elements"])
|
||||
|
||||
# v2 — меняем цены
|
||||
import copy
|
||||
svcs_v2 = copy.deepcopy(svcs_v1)
|
||||
if svcs_v2:
|
||||
svcs_v2[0]["price"] = round(svcs_v2[0]["price"] * 1.25, 2)
|
||||
svcs_v2[0]["price_str"] = pools.format_price(svcs_v2[0]["price"])
|
||||
svcs_v2[0]["sum"] = round(svcs_v2[0]["price"] * svcs_v2[0]["qty"], 2)
|
||||
svcs_v2[0]["sum_str"] = pools.format_price(svcs_v2[0]["sum"])
|
||||
tot_v2 = round(sum(s["sum"] for s in svcs_v2), 2)
|
||||
ctx_v2 = {**base_ctx, "version": 2, "services": svcs_v2, "total": tot_v2, "total_str": pools.format_price(tot_v2)}
|
||||
r2 = _build_and_parse(templates.build_spec, ctx_v2)
|
||||
t2 = _elements_to_text(r2["elements"])
|
||||
|
||||
assert t1 != t2, "v1 and v2 specs produced identical text!"
|
||||
@@ -1,162 +0,0 @@
|
||||
"""
|
||||
Тест DrHider — много проверок.
|
||||
|
||||
Файл: /home/naeel/nubes/contracts/contracts-flask/deploy/tests/test_drhider.py
|
||||
"""
|
||||
import sys, os, io, zipfile, json, base64
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
|
||||
from drhider.drhider import obfuscate_files, TwoPassObfuscator, COMPANY_PATTERN, PERSON_PATTERN
|
||||
|
||||
TEST_ZIP = "/home/naeel/nubes/contracts/dogovora/примеры_договоров_для_ИИ.zip"
|
||||
|
||||
def run(text, desc):
|
||||
"""Обработать текст и проверить что needle НЕ найдено."""
|
||||
z, c = obfuscate_files([('t.txt', text.encode(), '')])
|
||||
with zipfile.ZipFile(io.BytesIO(z)) as zf:
|
||||
out = zf.read('t.txt').decode()
|
||||
csv = zf.read('mapping.csv').decode()
|
||||
return out, csv
|
||||
|
||||
errors = 0
|
||||
|
||||
def check(ok, msg):
|
||||
global errors
|
||||
if ok:
|
||||
print(f" ✅ {msg}")
|
||||
else:
|
||||
print(f" ❌ {msg}")
|
||||
errors += 1
|
||||
|
||||
print("=" * 60)
|
||||
print("DrHider — полный тест")
|
||||
print("=" * 60)
|
||||
|
||||
# ── 1. Телефоны ──
|
||||
print("\n📞 Телефоны:")
|
||||
out, csv = run("Звоните +7 (495) 789-41-35 или 8-800-555-35-35", "телефоны")
|
||||
check("+7 (495) 789-41-35" not in out, "+7 (495) 789-41-35 заменён")
|
||||
check("8-800-555-35-35" not in out, "8-800-555-35-35 заменён")
|
||||
check("phone" in csv, "тип phone в CSV")
|
||||
|
||||
# ── 2. Телефон не матчит число внутри счёта ──
|
||||
print("\n📞 Телефон vs номер счёта:")
|
||||
out, csv = run("Кор/сч 30101810400000000225", "счёт")
|
||||
check("30101810400000000225" not in out, "номер счёта заменён (ks)")
|
||||
check("+7" not in out, "нет ложного телефона внутри счёта")
|
||||
|
||||
# ── 3. Email ──
|
||||
print("\n📧 Email:")
|
||||
out, csv = run("Пишите info@nubes.ru и support@company.org", "email")
|
||||
check("info@nubes.ru" not in out, "info@nubes.ru заменён")
|
||||
check("support@company.org" not in out, "support@company.org заменён")
|
||||
check("email" in csv, "тип email в CSV")
|
||||
|
||||
# ── 4. Компании (разные кавычки) ──
|
||||
print("\n🏢 Компании:")
|
||||
out, csv = run('ООО "Ромашка" и АО \u201cXXX003\u201d и ЗАО «Тест»', "компании")
|
||||
check('ООО "Ромашка"' not in out, 'ООО "Ромашка" заменён')
|
||||
check('АО \u201cXXX003\u201d' not in out, 'АО "XXX003" (unicode) заменён')
|
||||
check('ЗАО «Тест»' not in out, 'ЗАО «Тест» заменён')
|
||||
check('company' in csv, 'тип company в CSV')
|
||||
|
||||
# ── 5. НУБЕС whitelist ──
|
||||
print("\n🛡️ НУБЕС whitelist:")
|
||||
out, csv = run('ООО "НУБЕС" и ООО \u201cНУБЕС\u201d', "НУБЕС")
|
||||
check('ООО "НУБЕС"' in out, 'НУБЕС (ASCII) НЕ заменён')
|
||||
check('ООО \u201cНУБЕС\u201d' in out, 'НУБЕС (unicode) НЕ заменён')
|
||||
|
||||
# ── 6. ИНН/ОГРН/КПП/БИК ──
|
||||
print("\n🔢 ИНН/ОГРН/КПП/БИК:")
|
||||
out, csv = run("ИНН 9706005293, КПП 772401001, ОГРН 1207700098759, БИК 044525225", "реквизиты")
|
||||
check("9706005293" not in out, "ИНН заменён")
|
||||
check("772401001" not in out, "КПП заменён")
|
||||
check("1207700098759" not in out, "ОГРН заменён")
|
||||
check("044525225" not in out, "БИК заменён")
|
||||
check("inn_ul" in csv, "inn_ul в CSV")
|
||||
|
||||
# ── 7. Расчётный счёт ──
|
||||
print("\n💳 Расчётный счёт:")
|
||||
out, csv = run("р/с 40702810738000174030 и Кор/сч 30101810400000000225", "счета")
|
||||
check("40702810738000174030" not in out, "р/с заменён")
|
||||
check("30101810400000000225" not in out, "кор/сч заменён")
|
||||
check("rs" in csv, "rs в CSV")
|
||||
check("ks" in csv, "ks в CSV")
|
||||
|
||||
# ── 8. ФИО (инициалы + полностью) ──
|
||||
print("\n👤 ФИО:")
|
||||
out, csv = run("Директор Степаненко В.С. и Иванов Александр Петрович", "ФИО")
|
||||
check("Степаненко В.С." not in out, "Степаненко В.С. заменён")
|
||||
check("Иванов Александр Петрович" not in out, "Иванов А.П. заменён")
|
||||
|
||||
# ── 9. Паспорт ──
|
||||
print("\n📄 Паспорт:")
|
||||
out, csv = run("паспорт 12 34 567890", "паспорт")
|
||||
check("12 34 567890" not in out, "номер паспорта заменён")
|
||||
|
||||
# ── 10. Согласованность (одна сущность → одна замена) ──
|
||||
print("\n🔄 Согласованность:")
|
||||
z, c = obfuscate_files([
|
||||
('a.txt', b'OOO Romashka +79991234567', ''),
|
||||
('b.txt', b'OOO Romashka +79991234567', ''),
|
||||
])
|
||||
with zipfile.ZipFile(io.BytesIO(z)) as zf:
|
||||
a = zf.read('a.txt').decode()
|
||||
b = zf.read('b.txt').decode()
|
||||
check(a == b, f"оба файла одинаковы: {a}")
|
||||
|
||||
# ── 11. ZIP внутри ZIP ──
|
||||
print("\n📦 ZIP внутри:")
|
||||
with open(TEST_ZIP, 'rb') as f:
|
||||
raw = f.read()
|
||||
z, c = obfuscate_files([('test.zip', raw, '')])
|
||||
with zipfile.ZipFile(io.BytesIO(z)) as zf:
|
||||
names = zf.namelist()
|
||||
check(len(names) == 6, f"6 файлов (5 + csv): {len(names)}")
|
||||
check('mapping.csv' in names, "mapping.csv есть")
|
||||
check(not any(n.endswith('.zip') for n in names), "нет .zip в выдаче")
|
||||
# Проверим что docx внутри валидны
|
||||
for n in names:
|
||||
if n.endswith('.docx'):
|
||||
try:
|
||||
from docx import Document
|
||||
Document(io.BytesIO(zf.read(n)))
|
||||
check(True, f"{n} — валидный docx")
|
||||
except:
|
||||
check(False, f"{n} — БИТЫЙ docx")
|
||||
|
||||
# ── 12. .doc бинарный — не трогаем ──
|
||||
print("\n📄 .doc бинарный:")
|
||||
# Создаём фейковый .doc файл (просто бинарные данные)
|
||||
z, c = obfuscate_files([('old.doc', b'\xD0\xCF\x11\xE0' + b'\x00' * 100, '')])
|
||||
with zipfile.ZipFile(io.BytesIO(z)) as zf:
|
||||
doc_content = zf.read('old.doc')
|
||||
check(len(doc_content) == 104, ".doc сохранён без изменений")
|
||||
|
||||
# ── 13. Много файлов ──
|
||||
print("\n📚 20 файлов:")
|
||||
many = [('f{}.txt'.format(i), 'OOO Test{} +7999{}'.format(i, i).encode(), '') for i in range(20)]
|
||||
z, c = obfuscate_files(many)
|
||||
with zipfile.ZipFile(io.BytesIO(z)) as zf:
|
||||
check(len(zf.namelist()) == 21, "20 файлов + csv")
|
||||
|
||||
# ── 14. COMPANY_PATTERN не жадный ──
|
||||
print("\n🔤 COMPANY_PATTERN границы:")
|
||||
m = COMPANY_PATTERN.search("АО Test с дополнительным текстом дальше")
|
||||
check(m is not None and 'дальше' not in m.group(0), "не захватывает лишний текст")
|
||||
|
||||
# ── 15. PERSON_PATTERN только кириллица ──
|
||||
print("\n🔤 PERSON_PATTERN кириллица:")
|
||||
m = PERSON_PATTERN.search("Next Generation Cloud service")
|
||||
check(m is None, "английский не матчится")
|
||||
|
||||
m2 = PERSON_PATTERN.search("Иванов Иван Иванович")
|
||||
check(m2 is not None, "русский матчится")
|
||||
|
||||
# ── ИТОГО ──
|
||||
print(f"\n{'='*60}")
|
||||
if errors:
|
||||
print(f"❌ {errors} ошибок")
|
||||
else:
|
||||
print("✅ ВСЕ ТЕСТЫ ПРОЙДЕНЫ")
|
||||
print(f"{'='*60}")
|
||||
sys.exit(0 if errors == 0 else 1)
|
||||
@@ -1,62 +0,0 @@
|
||||
"""Test classify_batch with MemRepository + FakeLLM."""
|
||||
from compare.classify import classify_batch
|
||||
from compare.llm_client import FakeLLMClient
|
||||
from repository import MemRepository
|
||||
|
||||
|
||||
class TestClassifyBatch:
|
||||
def test_garbage_by_filename(self):
|
||||
"""Счёт-фактура отфильтровывается без LLM."""
|
||||
repo = MemRepository()
|
||||
llm = FakeLLMClient({"default": '{"doc_type":"contract"}'})
|
||||
|
||||
repo.insert_document("счет-фактура №123.docx", "mime", "data", batch_id="b1")
|
||||
|
||||
result = classify_batch("b1", llm_client=llm, repo=repo)
|
||||
assert result["ok"] is True
|
||||
assert result["garbage"] == 1
|
||||
assert result["done"] == 1
|
||||
assert len(llm.calls) == 0 # LLM не вызывался
|
||||
|
||||
def test_garbage_by_header(self):
|
||||
"""Акт сверки в тексте отфильтровывается."""
|
||||
repo = MemRepository()
|
||||
llm = FakeLLMClient({"default": '{"doc_type":"contract"}'})
|
||||
|
||||
doc = repo.insert_document("документ.docx", "mime", "data", batch_id="b1")
|
||||
repo.set_document_parsed(doc["id"], [
|
||||
{"type": "paragraph", "text": "АКТ СВЕРКИ взаимных расчётов", "style": ""}
|
||||
])
|
||||
|
||||
result = classify_batch("b1", llm_client=llm, repo=repo)
|
||||
assert result["garbage"] == 1
|
||||
assert len(llm.calls) == 0
|
||||
|
||||
def test_llm_classify(self):
|
||||
"""Договор классифицируется через LLM."""
|
||||
repo = MemRepository()
|
||||
llm = FakeLLMClient({
|
||||
"default": '{"doc_type":"contract","own_number":"03700_1","doc_date":"2026-02-01","counterparty":"ЗАО XXX001"}'
|
||||
})
|
||||
|
||||
doc = repo.insert_document("договор-XXX001.docx", "mime", "data", batch_id="b1")
|
||||
repo.set_document_parsed(doc["id"], [
|
||||
{"type": "paragraph", "text": "ДОГОВОР № 03700_1", "style": ""}
|
||||
])
|
||||
|
||||
result = classify_batch("b1", llm_client=llm, repo=repo)
|
||||
assert result["done"] == 1, f"done={result['done']}, failed={result['failed']}, doc={repo.get_document(doc['id'])}"
|
||||
assert result["garbage"] == 0
|
||||
assert len(llm.calls) == 1
|
||||
|
||||
updated = repo.get_document(doc["id"])
|
||||
assert updated["doc_type"] == "contract"
|
||||
assert updated["own_number"] == "03700_1"
|
||||
assert updated["counterparty"] == "ЗАО XXX001"
|
||||
|
||||
def test_no_pending(self):
|
||||
"""Пустой батч."""
|
||||
repo = MemRepository()
|
||||
result = classify_batch("empty", llm_client=FakeLLMClient(), repo=repo)
|
||||
assert result["ok"] is False
|
||||
assert "no pending" in result["error"]
|
||||
@@ -1,110 +0,0 @@
|
||||
"""Unit tests — no DB, no network, just pure logic."""
|
||||
import pytest
|
||||
from contracts import ParseResult, ClassifyResult, GroupingResult, CompareOp
|
||||
from compare.upload import parse_multipart
|
||||
from compare.classify import _is_garbage_by_filename, _is_garbage_by_header
|
||||
|
||||
|
||||
class TestContracts:
|
||||
"""Dataclass creation and validation."""
|
||||
|
||||
def test_parse_result(self):
|
||||
r = ParseResult(status="parsed", element_count=10, elements=[])
|
||||
assert r.status == "parsed"
|
||||
assert r.element_count == 10
|
||||
|
||||
def test_classify_from_llm(self):
|
||||
r = ClassifyResult.from_llm({
|
||||
"doc_type": "contract",
|
||||
"own_number": "03700_1",
|
||||
"doc_date": "2026-02-01",
|
||||
"counterparty": "ЗАО XXX001",
|
||||
})
|
||||
assert r.doc_type == "contract"
|
||||
assert r.own_number == "03700_1"
|
||||
assert r.counterparty == "ЗАО XXX001"
|
||||
|
||||
def test_grouping_result_empty(self):
|
||||
r = GroupingResult(contract_number="03700_1")
|
||||
assert r.contract_number == "03700_1"
|
||||
assert r.documents == []
|
||||
|
||||
def test_compare_op_add(self):
|
||||
op = CompareOp(action="ADD", new_row={"name": "Тест", "price": 100})
|
||||
assert op.action == "ADD"
|
||||
assert op.new_row["price"] == 100
|
||||
|
||||
def test_compare_op_from_llm(self):
|
||||
op = CompareOp.from_llm({
|
||||
"action": "UPDATE",
|
||||
"target_id": "r1",
|
||||
"new_values": {"price": 200},
|
||||
"comment": "change",
|
||||
})
|
||||
assert op.action == "UPDATE"
|
||||
assert op.new_values["price"] == 200
|
||||
assert op.comment == "change"
|
||||
|
||||
|
||||
class TestGarbageFilter:
|
||||
"""Stage 1-2 garbage detection."""
|
||||
|
||||
def test_filename_garbage_invoice(self):
|
||||
assert _is_garbage_by_filename("счет-фактура №123.docx") is True
|
||||
|
||||
def test_filename_garbage_act(self):
|
||||
assert _is_garbage_by_filename("Акт сверки за май.pdf") is True
|
||||
|
||||
def test_filename_not_garbage(self):
|
||||
assert _is_garbage_by_filename("договор-XXX001.docx") is False
|
||||
assert _is_garbage_by_filename("спецификация услуг.pdf") is False
|
||||
|
||||
def test_header_garbage(self):
|
||||
text = "СЧЕТ-ФАКТУРА № 123 от 01.01.2026\nПоставщик: ООО Ромашка"
|
||||
assert _is_garbage_by_header(text) is True
|
||||
|
||||
def test_header_not_garbage(self):
|
||||
text = "ДОГОВОР № 03700_1\nг. Москва\n\nИсполнитель обязуется..."
|
||||
assert _is_garbage_by_header(text) is False
|
||||
|
||||
|
||||
class TestParseMultipart:
|
||||
"""Pure multipart parsing without HTTP."""
|
||||
|
||||
def test_simple_file(self, sample_docx_bytes):
|
||||
boundary = b"----testboundary"
|
||||
body = (
|
||||
b"------testboundary\r\n"
|
||||
b'Content-Disposition: form-data; name="files"; filename="test.docx"\r\n'
|
||||
b"Content-Type: application/octet-stream\r\n\r\n"
|
||||
+ sample_docx_bytes +
|
||||
b"\r\n------testboundary--\r\n"
|
||||
)
|
||||
result = parse_multipart(body, f"multipart/form-data; boundary=----testboundary")
|
||||
assert result["filename"] == "test.docx"
|
||||
assert result["file_data"] == sample_docx_bytes
|
||||
|
||||
def test_with_contract_id(self, sample_docx_bytes):
|
||||
boundary = b"----testboundary"
|
||||
body = (
|
||||
b"------testboundary\r\n"
|
||||
b'Content-Disposition: form-data; name="files"; filename="test.docx"\r\n\r\n'
|
||||
+ sample_docx_bytes +
|
||||
b"\r\n------testboundary\r\n"
|
||||
b'Content-Disposition: form-data; name="contract_id"\r\n\r\n'
|
||||
b"550e8400-e29b-41d4-a716-446655440000"
|
||||
b"\r\n------testboundary--\r\n"
|
||||
)
|
||||
result = parse_multipart(body, f"multipart/form-data; boundary=----testboundary")
|
||||
assert result["contract_id"] == "550e8400-e29b-41d4-a716-446655440000"
|
||||
|
||||
def test_path_traversal_rejected(self):
|
||||
boundary = b"----testboundary"
|
||||
body = (
|
||||
b"------testboundary\r\n"
|
||||
b'Content-Disposition: form-data; name="files"; filename="../etc/passwd"\r\n\r\n'
|
||||
b"evil"
|
||||
b"\r\n------testboundary--\r\n"
|
||||
)
|
||||
with pytest.raises(ValueError, match="invalid filename"):
|
||||
parse_multipart(body, f"multipart/form-data; boundary=----testboundary")
|
||||
@@ -1,59 +0,0 @@
|
||||
"""Unit tests for LLM client and Repository — with fakes."""
|
||||
from compare.llm_client import FakeLLMClient
|
||||
from repository import MemRepository
|
||||
|
||||
|
||||
class TestFakeLLM:
|
||||
def test_exact_match(self):
|
||||
client = FakeLLMClient({"hello": "world"})
|
||||
assert client.complete("hello") == "world"
|
||||
|
||||
def test_partial_match(self):
|
||||
client = FakeLLMClient({"classify": '{"doc_type":"contract"}'})
|
||||
assert "contract" in client.complete("classify this document")
|
||||
|
||||
def test_default_fallback(self):
|
||||
client = FakeLLMClient({"default": '{"ok":true}'})
|
||||
assert client.complete("unknown prompt") == '{"ok":true}'
|
||||
|
||||
def test_call_history(self):
|
||||
client = FakeLLMClient({"a": "1", "b": "2"})
|
||||
client.complete("a")
|
||||
client.complete("b")
|
||||
assert len(client.calls) == 2
|
||||
assert client.calls[0] == "a"
|
||||
|
||||
|
||||
class TestMemRepository:
|
||||
def test_insert_and_retrieve(self, mem_repo):
|
||||
doc = mem_repo.insert_document("test.docx", "application/vnd...", "base64data", batch_id="batch-1")
|
||||
assert doc["filename"] == "test.docx"
|
||||
assert doc["classify_status"] == "pending"
|
||||
|
||||
def test_list_pending(self, mem_repo):
|
||||
mem_repo.insert_document("a.docx", "mime", "data", batch_id="b1")
|
||||
mem_repo.insert_document("b.docx", "mime", "data", batch_id="b1")
|
||||
mem_repo.insert_document("c.docx", "mime", "data", batch_id="b2")
|
||||
pending = mem_repo.list_pending("b1")
|
||||
assert len(pending) == 2
|
||||
|
||||
def test_set_classification(self, mem_repo):
|
||||
doc = mem_repo.insert_document("test.docx", "mime", "data", batch_id="b1")
|
||||
mem_repo.set_classification(doc["id"], "contract", "03700", None, "2026-02-01", "XXX")
|
||||
updated = mem_repo.documents[doc["id"]]
|
||||
assert updated["doc_type"] == "contract"
|
||||
assert updated["classify_status"] == "classified"
|
||||
|
||||
def test_set_garbage(self, mem_repo):
|
||||
doc = mem_repo.insert_document("счет.docx", "mime", "data", batch_id="b1")
|
||||
mem_repo.set_classify_garbage(doc["id"], "filename_regex")
|
||||
assert mem_repo.documents[doc["id"]]["doc_type"] == "garbage"
|
||||
|
||||
def test_contract_lifecycle(self, mem_repo):
|
||||
cid = mem_repo.insert_contract("03700_1", "ЗАО XXX")
|
||||
assert cid
|
||||
doc = mem_repo.insert_document("test.docx", "mime", "data")
|
||||
mem_repo.insert_supplement(cid, doc["id"], "initial")
|
||||
supps = mem_repo.list_supplements(cid)
|
||||
assert len(supps) == 1
|
||||
assert supps[0]["type"] == "initial"
|
||||
@@ -0,0 +1,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).
|
||||
|
||||
## Требования к стилю
|
||||
|
||||
- Чётко, структурно, без воды и лирики.
|
||||
- Без излишней заумности — понятно не только девопсу, но и обычному разработчику/аналитику.
|
||||
- Каждый этап: «что делает → как → каким методом → где в коде (файл/функция)».
|
||||
- Ничего не менять в коде. Только текстовый документ.
|
||||
@@ -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, а не падает. Если видишь ошибку — укажи, но НЕ правь.
|
||||
|
||||
## Требования к стилю
|
||||
|
||||
- Чётко, структурно, без воды и лирики.
|
||||
- Без излишней заумности — понятно не только девопсу, но и обычному разработчику/аналитику.
|
||||
- Ничего не менять в коде. Только текстовый документ.
|
||||
@@ -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,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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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) + 'с)';
|
||||
|
||||
@@ -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
@@ -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
@@ -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' };
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
# Тесты contracts-flask
|
||||
|
||||
Набор pytest-тестов под актуальный код сервиса (`site/`).
|
||||
Покрытие: юнит (чистая логика), интеграционные (SQLite), HTTP-эндпоинты (Flask test client), безопасность.
|
||||
|
||||
## Запуск
|
||||
|
||||
```bash
|
||||
cd contracts-flask
|
||||
pytest tests/ -q
|
||||
```
|
||||
|
||||
pytest установлен в venv: `/home/naeel/nubes/contracts/.venv/bin/python -m pytest tests/ -q`.
|
||||
|
||||
## Файлы
|
||||
|
||||
| Файл | Что тестирует |
|
||||
|---|---|
|
||||
| `conftest.py` | site/ в `sys.path`; фикстура `db` — изолированная БД (временный `DB_PATH` + свежая схема); защита реальной `/tmp/contracts.db` от пересоздания |
|
||||
| `test_metrics.py` | `services/metrics.py`: `normalize_date`, `check_arithmetic` (sum == price×qty), `_to_decimal`, `ClassifyMetrics` |
|
||||
| `test_grouping.py` | `services/grouping.py`: `normalize_number`, `group_documents` (группировка contract+supplement, unresolved), `apply_groups` (mock db) |
|
||||
| `test_llm_client.py` | `services/llm_client.py`: `FakeLLMClient` (точное/частичное совпадение, default, история вызовов), `HttpxLLMClient` |
|
||||
| `test_llm.py` | `services/llm.py`: `call_llm` — парсинг plain JSON и markdown-обёрток (```json) |
|
||||
| `test_classify.py` | `services/classify.py`: garbage-фильтры по имени/заголовку, `_safe_json_parse` (7 edge-case: trailing comma, chatter, пусто), `_smart_extract` |
|
||||
| `test_parse.py` | `services/parse.py`: `parse_file` (text/docx/pdf, ошибки), `_parse_text` |
|
||||
| `test_connection.py` | `db/connection.py`: `_pg_to_sqlite` (все замены %s/::jsonb/BOOLEAN/ILIKE/TRUE), `_extract_table` |
|
||||
| `test_spec_events.py` | `db/spec_events.py`: `apply_ops` (ADD/UPDATE/DELETE/UNRESOLVED/unknown), `clear_current`, `reset`, `_hash`, `get_next_seq` |
|
||||
| `test_spec_current.py` | `db/spec_current.py`: `list_by_contract` (порядок), `get_elements_json` |
|
||||
| `test_process_pipeline.py` | `services/process.py`: `run_pipeline` с Fake LLM — **full_replace не дублирует строки**, **трансляция target_id→target_hash**, `_elements_to_text` |
|
||||
| `test_routes.py` | HTTP-эндпоинты (Flask test client): `/health`, `/api/spec-current`, `/api/groups` |
|
||||
|
||||
## Ключевые проверки
|
||||
|
||||
- `test_full_replace_no_duplicates` — два `full_replace` подряд → в `spec_current` 3 строки, а не 6; история `spec_events` (6 событий) сохранена.
|
||||
- `test_update_applies` — `target_id: "r1"` транслируется в `target_hash` → UPDATE применяется (status `applied`), а не уходит в UNRESOLVED.
|
||||
|
||||
## Изоляция
|
||||
|
||||
- Каждый тест, работающий с БД, получает свою копию SQLite через фикстуру `db`
|
||||
(monkeypatch `DB_PATH` → `tmp_path`, `init_db()`).
|
||||
- LLM-вызовы в пайплайне подменяются через `monkeypatch.setattr("services.llm.call_llm", ...)` — сеть не используется.
|
||||
|
||||
## Нагрузочные тесты (`tests/load/`, маркер `load`)
|
||||
|
||||
Долгие стресс-тесты, реально нагружают SQLite/пайплайн. Запуск отдельно:
|
||||
|
||||
```bash
|
||||
pytest -m load -q # только нагрузочные
|
||||
pytest -m "not load" -q # быстрые юнит-тесты без нагрузочных
|
||||
```
|
||||
|
||||
| Файл | Что нагружает |
|
||||
|---|---|
|
||||
| `load/test_load_pipeline.py` | массовый `run_pipeline`: N контрактов × M допников (Fake LLM) — проверка, что `full_replace` не дублирует строки в масштабе |
|
||||
| `load/test_load_apply_ops.py` | массовые `apply_ops`: N ADD → N UPDATE → N DELETE, целостность `spec_events` |
|
||||
| `load/test_load_concurrency.py` | конкурентная запись в SQLite (WAL + `busy_timeout`), проверка отсутствия потери данных |
|
||||
| `load/stress_http.py` | standalone HTTP-стресс: `PUT` на ВМ → `POST /api/upload_refs` (реальный VM-путь) + `/health` |
|
||||
|
||||
Масштаб через переменные окружения (дефолты — большие):
|
||||
|
||||
```bash
|
||||
LOAD_CONTRACTS=100 LOAD_SUPPS=20 LOAD_SERVICES=10 \
|
||||
LOAD_OPS=5000 LOAD_THREADS=4 LOAD_PER=200 \
|
||||
pytest -m load -q
|
||||
|
||||
Примечание по конкурентной записи: стабильный уровень `LOAD_THREADS=4`
|
||||
(= `MAX_WORKERS` приложения). `>= 8` — нестабильно: SQLite с
|
||||
`busy_timeout=5000` даёт `database is locked` (см. `History/2026-08-26-load-sqlite-locked.md`).
|
||||
```
|
||||
|
||||
HTTP-стресс против локального/прод сервиса:
|
||||
|
||||
```bash
|
||||
python tests/load/stress_http.py --url http://127.0.0.1:5000 --n 1000 --threads 32 --size 100000
|
||||
```
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Общие фикстуры для тестов contracts-flask (модули site/).
|
||||
|
||||
Запуск: pytest tests/ -q
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
# site/ в sys.path — импортируем config/db/services/routes напрямую
|
||||
_SITE = os.path.join(os.path.dirname(__file__), "..", "site")
|
||||
if _SITE not in sys.path:
|
||||
sys.path.insert(0, _SITE)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True, scope="session")
|
||||
def _isolate_global_db(tmp_path_factory):
|
||||
"""Не дать импорту app.py пересоздать реальную /tmp/contracts.db."""
|
||||
from db import connection as conn
|
||||
conn.DB_PATH = str(tmp_path_factory.mktemp("dbs") / "session.db")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db(tmp_path, monkeypatch):
|
||||
"""Изолированная БД: отдельный DB_PATH + свежая схема (init_db)."""
|
||||
from db import connection as conn
|
||||
monkeypatch.setattr(conn, "DB_PATH", str(tmp_path / "contracts_test.db"))
|
||||
conn.init_db()
|
||||
yield conn
|
||||
# cleanup: закрыть thread-local соединение
|
||||
c = getattr(conn._local, "conn", None)
|
||||
if c is not None:
|
||||
try:
|
||||
c.close()
|
||||
except Exception:
|
||||
pass
|
||||
conn._local.conn = None
|
||||
|
||||
|
||||
def pytest_configure(config):
|
||||
config.addinivalue_line("markers", "load: долгие нагрузочные/стресс-тесты")
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
"""Нагрузочные/стресс-тесты contracts-flask.
|
||||
|
||||
Запуск только нагрузочных:
|
||||
pytest -m load -q
|
||||
Запуск быстрых (юнит) без нагрузочных:
|
||||
pytest -m "not load" -q
|
||||
|
||||
Масштаб настраивается переменными окружения (дефолты — большие):
|
||||
LOAD_CONTRACTS, LOAD_SUPPS, LOAD_SERVICES, LOAD_OPS, LOAD_THREADS, LOAD_PER
|
||||
"""
|
||||
@@ -0,0 +1,97 @@
|
||||
"""HTTP-стресс против contracts-flask (standalone, не pytest).
|
||||
|
||||
Реальный путь загрузки (VM-буфер):
|
||||
1) PUT файла на ВМ WebDAV (мимо шлюза кластера ~64KB);
|
||||
2) POST /api/upload_refs — бэк сам тянет файл с ВМ (egress).
|
||||
|
||||
Замеряет RPS, ошибки, таймауты. Цель — «разогреть» сервис и выявить
|
||||
деградацию/обрывы (шлюз, OOM, SQLite).
|
||||
|
||||
Запуск (локально):
|
||||
python tests/load/stress_http.py --url http://127.0.0.1:5000 --n 1000 --threads 32 --size 100000
|
||||
|
||||
Пример против прода (осторожно):
|
||||
python tests/load/stress_http.py --url https://contractor.pythonk8s.dev.nubes.ru \
|
||||
--file "/mnt/y/MY/Nubes/примеры_договоров_для_ИИ/спецификация-XXX001-03700.docx" \
|
||||
--n 200 --threads 16
|
||||
"""
|
||||
import argparse
|
||||
import time
|
||||
import uuid
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="HTTP-стресс contracts-flask")
|
||||
ap.add_argument("--url", default="http://127.0.0.1:5000")
|
||||
ap.add_argument("--vm-url", default="https://contracts.kube5s.ru/contracts-upload/", help="ВМ WebDAV-буфер (PUT файла)")
|
||||
ap.add_argument("--n", type=int, default=1000, help="число запросов")
|
||||
ap.add_argument("--threads", type=int, default=32, help="параллельных потоков")
|
||||
ap.add_argument("--size", type=int, default=100000, help="размер файла в байтах (если не задан --file)")
|
||||
ap.add_argument("--file", default=None, help="путь к реальному файлу (заменяет сгенерированный)")
|
||||
ap.add_argument("--health-only", action="store_true", help="только /health, без загрузки файлов")
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.file:
|
||||
import os as _os
|
||||
fname = _os.path.basename(args.file)
|
||||
with open(args.file, "rb") as _f:
|
||||
payload = _f.read()
|
||||
real_size = len(payload)
|
||||
else:
|
||||
fname = "load.docx"
|
||||
payload = b"x" * args.size
|
||||
real_size = args.size
|
||||
ok = 0
|
||||
err = 0
|
||||
slow = 0
|
||||
t0 = time.time()
|
||||
|
||||
def one(_):
|
||||
nonlocal ok, err, slow
|
||||
try:
|
||||
with httpx.Client(timeout=30) as c:
|
||||
if args.health_only:
|
||||
r = c.get(f"{args.url}/health")
|
||||
if r.status_code == 200:
|
||||
ok += 1
|
||||
else:
|
||||
err += 1
|
||||
return
|
||||
# Фаза 1: PUT файла на ВМ WebDAV (мимо шлюза ~64KB)
|
||||
token = uuid.uuid4().hex
|
||||
vm_url = args.vm_url.rstrip("/") + "/" + token + "_0"
|
||||
r1 = c.put(vm_url, content=payload, headers={"Content-Type": "application/octet-stream"})
|
||||
if not r1.is_success:
|
||||
err += 1
|
||||
return
|
||||
# Фаза 2: POST /api/upload_refs — бэк тянет файл с ВМ (egress)
|
||||
body = {
|
||||
"batch_id": token,
|
||||
"files": [{"name": fname, "size": real_size, "url": vm_url}],
|
||||
}
|
||||
r2 = c.post(f"{args.url}/api/upload_refs", json=body)
|
||||
if r2.status_code == 200:
|
||||
ok += 1
|
||||
else:
|
||||
err += 1
|
||||
except httpx.TimeoutException:
|
||||
err += 1
|
||||
slow += 1
|
||||
except Exception:
|
||||
err += 1
|
||||
|
||||
with ThreadPoolExecutor(max_workers=args.threads) as ex:
|
||||
list(ex.map(one, range(args.n)))
|
||||
|
||||
elapsed = time.time() - t0
|
||||
print(f"url={args.url}")
|
||||
print(f"vm_url={args.vm_url}")
|
||||
print(f"n={args.n} threads={args.threads} size={real_size}B file={args.file or '(gen)'} health_only={args.health_only}")
|
||||
print(f"ok={ok} err={err} timeouts={slow} elapsed={elapsed:.1f}s rps={args.n / elapsed:.1f}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Нагрузочный тест: массовые apply_ops (ADD → UPDATE → DELETE).
|
||||
|
||||
Прогоняет N операций каждого типа одним вызовом apply_ops, замеряет время,
|
||||
проверяет целостность spec_current/spec_events (ничего не потеряно, не продублировано).
|
||||
"""
|
||||
import os
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from db import spec_events, spec_current
|
||||
from db.connection import query
|
||||
|
||||
pytestmark = pytest.mark.load
|
||||
|
||||
N = int(os.getenv("LOAD_OPS", "5000"))
|
||||
|
||||
CID = "load-cid-111"
|
||||
SID = "load-sid-111"
|
||||
DID = "load-did-111"
|
||||
|
||||
|
||||
def test_mass_apply_ops(db):
|
||||
# ADD N строк
|
||||
ops = [{"action": "ADD", "new_row": {"name": f"услуга {i}", "price": i, "qty": 1, "sum": i}} for i in range(N)]
|
||||
t0 = time.time()
|
||||
s = spec_events.apply_ops(CID, SID, DID, ops, "pid", {})
|
||||
t_add = time.time() - t0
|
||||
assert s["added"] == N
|
||||
assert len(spec_current.list_by_contract(CID)) == N
|
||||
|
||||
# UPDATE всех N строк
|
||||
hashes = [spec_events._hash(f"услуга {i}") for i in range(N)]
|
||||
ups = [{"action": "UPDATE", "target_hash": h, "new_values": {"price": i + 1}} for i, h in enumerate(hashes)]
|
||||
t0 = time.time()
|
||||
s = spec_events.apply_ops(CID, SID, DID, ups, "pid", {})
|
||||
t_upd = time.time() - t0
|
||||
assert s["updated"] == N
|
||||
assert spec_current.list_by_contract(CID)[0]["price"] == 1 # первая строка обновлена
|
||||
|
||||
# DELETE всех N строк
|
||||
dels = [{"action": "DELETE", "target_hash": h} for h in hashes]
|
||||
t0 = time.time()
|
||||
s = spec_events.apply_ops(CID, SID, DID, dels, "pid", {})
|
||||
t_del = time.time() - t0
|
||||
assert s["deleted"] == N
|
||||
assert spec_current.list_by_contract(CID) == []
|
||||
|
||||
total = query("SELECT count(*) AS c FROM spec_events WHERE contract_id = %s", (CID,))
|
||||
assert total[0]["c"] == N * 3 # ADD + UPDATE + DELETE, ничего не потеряно
|
||||
|
||||
print(f"\n[load] ops={N} add={t_add:.1f}с upd={t_upd:.1f}с del={t_del:.1f}с")
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Нагрузочный тест: конкурентная запись в SQLite (WAL + busy_timeout).
|
||||
|
||||
N_THREADS потоков пишут по PER ADD-операций каждый. Проверяет, что конкурентная
|
||||
запись не теряет данные (WAL сериализует writers, busy_timeout ждёт).
|
||||
"""
|
||||
import os
|
||||
|
||||
import pytest
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
from db import spec_events, spec_current
|
||||
|
||||
pytestmark = pytest.mark.load
|
||||
|
||||
N_THREADS = int(os.getenv("LOAD_THREADS", "4"))
|
||||
PER = int(os.getenv("LOAD_PER", "200"))
|
||||
|
||||
|
||||
def test_concurrent_writes(db):
|
||||
def writer(tid):
|
||||
cid = f"c{tid}"
|
||||
for i in range(PER):
|
||||
spec_events.apply_ops(
|
||||
cid, f"s{tid}", f"d{tid}",
|
||||
[{"action": "ADD", "new_row": {"name": f"svc{i}", "price": i}}],
|
||||
"pid", {},
|
||||
)
|
||||
|
||||
with ThreadPoolExecutor(max_workers=N_THREADS) as ex:
|
||||
list(ex.map(writer, range(N_THREADS)))
|
||||
|
||||
# Никаких потерь: у каждого потока ровно PER строк
|
||||
for tid in range(N_THREADS):
|
||||
n = len(spec_current.list_by_contract(f"c{tid}"))
|
||||
assert n == PER, f"c{tid}: {n} != {PER} (потеря данных при конкурентной записи)"
|
||||
|
||||
print(f"\n[load] потоков={N_THREADS} строк/поток={PER} всего={N_THREADS * PER}")
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Нагрузочный тест: массовый прогон run_pipeline (сотни контрактов × допников).
|
||||
|
||||
Создаёт N контрактов × M допников, прогоняет полный конвейер сверки с Fake LLM
|
||||
(без сети), реально нагружает SQLite: apply_ops + clear_current + reset.
|
||||
|
||||
Проверка: full_replace НЕ дублирует строки в масштабе (у каждого контракта ровно
|
||||
SERVICES строк, а не SERVICES × M_SUPPS).
|
||||
"""
|
||||
import os
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from db import contracts, documents, supplements, spec_current
|
||||
from db.connection import query
|
||||
from services import process
|
||||
|
||||
pytestmark = pytest.mark.load
|
||||
|
||||
N_CONTRACTS = int(os.getenv("LOAD_CONTRACTS", "100"))
|
||||
M_SUPPS = int(os.getenv("LOAD_SUPPS", "20"))
|
||||
SERVICES = int(os.getenv("LOAD_SERVICES", "10"))
|
||||
|
||||
|
||||
def _seed():
|
||||
cids = []
|
||||
for ci in range(N_CONTRACTS):
|
||||
c = contracts.insert(f"03700_{ci}")
|
||||
for mi in range(M_SUPPS):
|
||||
d = documents.insert(f"d{ci}_{mi}.docx", "application/octet-stream", "raw", batch_id=f"b{ci}")
|
||||
documents.set_parsed(d["id"], [{"type": "paragraph", "text": f"услуга {mi}"}])
|
||||
supplements.insert(c["id"], d["id"], "initial" if mi == 0 else "additional")
|
||||
cids.append(c["id"])
|
||||
return cids
|
||||
|
||||
|
||||
def _fake_llm():
|
||||
def fake_call(current_spec, doc_text, build_prompt_fn, llm_client=None):
|
||||
ops = [
|
||||
{"action": "ADD", "new_row": {"name": f"услуга {i}", "price": 100 + i, "qty": 1, "sum": 100 + i}}
|
||||
for i in range(SERVICES)
|
||||
]
|
||||
return ({"mode": "full_replace", "ops": ops}, "pid")
|
||||
return fake_call
|
||||
|
||||
|
||||
def test_mass_pipeline(db, monkeypatch):
|
||||
cids = _seed()
|
||||
monkeypatch.setattr("services.llm.call_llm", _fake_llm())
|
||||
|
||||
t0 = time.time()
|
||||
for cid in cids:
|
||||
list(process.run_pipeline(cid, "", lambda cur, txt: ("p", "pid")))
|
||||
elapsed = time.time() - t0
|
||||
|
||||
# Целостность: у каждого контракта ровно SERVICES строк (full_replace без дублей)
|
||||
bad = 0
|
||||
for cid in cids:
|
||||
if len(spec_current.list_by_contract(cid)) != SERVICES:
|
||||
bad += 1
|
||||
assert bad == 0, f"{bad} контрактов с неверным числом строк"
|
||||
|
||||
total_events = query("SELECT count(*) AS c FROM spec_events", ())
|
||||
print(
|
||||
f"\n[load] контрактов={N_CONTRACTS} допников={M_SUPPS} услуг={SERVICES} "
|
||||
f"событий={total_events[0]['c']} время={elapsed:.1f}с"
|
||||
)
|
||||
@@ -0,0 +1,99 @@
|
||||
"""Unit-тесты services/classify.py — фильтры + JSON-парсинг + выжимка."""
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from services.classify import (
|
||||
_is_garbage_by_filename,
|
||||
_is_garbage_by_header,
|
||||
_safe_json_parse,
|
||||
_smart_extract,
|
||||
)
|
||||
|
||||
|
||||
class TestGarbageFilename:
|
||||
def test_invoice(self):
|
||||
assert _is_garbage_by_filename("счет-фактура №123.docx") is True
|
||||
|
||||
def test_act(self):
|
||||
assert _is_garbage_by_filename("Акт сверки.pdf") is True
|
||||
|
||||
def test_upd(self):
|
||||
assert _is_garbage_by_filename("УПД-2025.docx") is True
|
||||
|
||||
def test_not_garbage(self):
|
||||
assert _is_garbage_by_filename("договор.docx") is False
|
||||
assert _is_garbage_by_filename("спецификация.pdf") is False
|
||||
|
||||
|
||||
class TestGarbageHeader:
|
||||
def test_invoice_header(self):
|
||||
assert _is_garbage_by_header("СЧЕТ-ФАКТУРА № 123 от 01.01.2026") is True
|
||||
|
||||
def test_act_header(self):
|
||||
assert _is_garbage_by_header("АКТ ОКАЗАННЫХ УСЛУГ за май") is True
|
||||
|
||||
def test_not_garbage(self):
|
||||
assert _is_garbage_by_header("ДОГОВОР № 03700_1 об оказании услуг") is False
|
||||
|
||||
def test_upd_mention_in_contract_is_not_garbage(self):
|
||||
text = "Договор № 03700. Оплата производится на основании УПД и счета."
|
||||
assert _is_garbage_by_header(text) is False
|
||||
|
||||
def test_upd_document_header_is_garbage(self):
|
||||
assert _is_garbage_by_header("УПД № 123 от 01.01.2026") is True
|
||||
|
||||
|
||||
class TestSafeJsonParse:
|
||||
def test_valid(self):
|
||||
d, fix = _safe_json_parse('{"a":1}')
|
||||
assert d == {"a": 1}
|
||||
assert fix is False
|
||||
|
||||
def test_markdown(self):
|
||||
d, fix = _safe_json_parse('```json\n{"a":1}\n```')
|
||||
assert d == {"a": 1}
|
||||
assert fix is False
|
||||
|
||||
def test_chatter(self):
|
||||
d, _ = _safe_json_parse('Вот ответ: {"a":1}. Спасибо!')
|
||||
assert d == {"a": 1}
|
||||
|
||||
def test_trailing_comma(self):
|
||||
d, fix = _safe_json_parse('{"a":1,}')
|
||||
assert d == {"a": 1}
|
||||
assert fix is True
|
||||
|
||||
def test_trailing_comma_in_list(self):
|
||||
d, fix = _safe_json_parse('{"a":[1,2,]}')
|
||||
assert d == {"a": [1, 2]}
|
||||
assert fix is True
|
||||
|
||||
def test_empty_raises(self):
|
||||
with pytest.raises(ValueError):
|
||||
_safe_json_parse("")
|
||||
|
||||
def test_none_raises(self):
|
||||
with pytest.raises(ValueError):
|
||||
_safe_json_parse(None)
|
||||
|
||||
|
||||
class TestSmartExtract:
|
||||
def test_paragraphs(self):
|
||||
ej = [{"type": "paragraph", "text": "Договор № 03700"}]
|
||||
out = _smart_extract(ej)
|
||||
assert "Договор" in out
|
||||
|
||||
def test_table(self):
|
||||
ej = [{"type": "table", "rows": [["Аренда", "50000"]]}]
|
||||
out = _smart_extract(ej)
|
||||
assert "Аренда" in out
|
||||
|
||||
def test_string_json(self):
|
||||
ej = json.dumps([{"type": "paragraph", "text": "Спецификация"}])
|
||||
out = _smart_extract(ej)
|
||||
assert "Спецификация" in out
|
||||
|
||||
def test_empty(self):
|
||||
assert _smart_extract(None) == ""
|
||||
assert _smart_extract("") == ""
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Unit-тесты db/connection.py — SQL-конвертация (без БД)."""
|
||||
from db.connection import _pg_to_sqlite, _extract_table
|
||||
|
||||
|
||||
class TestPgToSqlite:
|
||||
def test_placeholder(self):
|
||||
assert _pg_to_sqlite("SELECT * FROM x WHERE a = %s") == "SELECT * FROM x WHERE a = ?"
|
||||
|
||||
def test_jsonb(self):
|
||||
out = _pg_to_sqlite("UPDATE t SET j = %s::jsonb WHERE id = %s")
|
||||
assert "::jsonb" not in out
|
||||
|
||||
def test_boolean(self):
|
||||
# " BOOLEAN " заменяется только когда после слова идёт пробел
|
||||
out = _pg_to_sqlite("CREATE TABLE t (flag BOOLEAN NOT NULL)")
|
||||
assert "BOOLEAN" not in out
|
||||
assert "INTEGER" in out
|
||||
|
||||
def test_ilike(self):
|
||||
assert _pg_to_sqlite("WHERE name ILIKE 'x'") == "WHERE name LIKE 'x'"
|
||||
|
||||
def test_true_false(self):
|
||||
out = _pg_to_sqlite("SET a = TRUE, b = FALSE")
|
||||
assert "TRUE" not in out
|
||||
assert "FALSE" not in out
|
||||
|
||||
|
||||
class TestExtractTable:
|
||||
def test_insert(self):
|
||||
assert _extract_table("INSERT INTO documents (id) VALUES (1)") == "documents"
|
||||
|
||||
def test_insert_no_space(self):
|
||||
assert _extract_table("INSERT INTO prompts(id) VALUES (1)") == "prompts"
|
||||
|
||||
def test_no_insert(self):
|
||||
assert _extract_table("SELECT * FROM documents") is None
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Unit-тесты services/grouping.py — нормализация + группировка (mock db)."""
|
||||
from services import grouping
|
||||
|
||||
|
||||
class TestNormalizeNumber:
|
||||
def test_basic(self):
|
||||
assert grouping.normalize_number("МЭС-123-2024") == "МЭС1232024"
|
||||
|
||||
def test_spaces_slashes(self):
|
||||
assert grouping.normalize_number("МЭС 123/2024") == "МЭС1232024"
|
||||
|
||||
def test_case(self):
|
||||
assert grouping.normalize_number("мэс-123") == "МЭС123"
|
||||
|
||||
def test_empty(self):
|
||||
assert grouping.normalize_number("") == ""
|
||||
assert grouping.normalize_number(None) == ""
|
||||
|
||||
|
||||
class _FakeDocs:
|
||||
def __init__(self, docs):
|
||||
self.docs = docs
|
||||
|
||||
def list_by_batch(self, batch_id):
|
||||
return self.docs
|
||||
|
||||
|
||||
class TestGroupDocuments:
|
||||
def _doc(self, id, doc_type, own, parent, date, cp):
|
||||
return {
|
||||
"id": id, "filename": f"{id}.docx", "doc_type": doc_type,
|
||||
"own_number": own, "parent_number": parent, "doc_date": date,
|
||||
"counterparty": cp, "classify_status": "classified",
|
||||
}
|
||||
|
||||
def test_contract_plus_supplement(self, monkeypatch):
|
||||
docs = [
|
||||
self._doc("d1", "contract", "03700_1", None, "2025-01-01", "ЗАО X"),
|
||||
self._doc("d2", "supplement", "1", "03700_1", "2025-02-01", "ЗАО X"),
|
||||
]
|
||||
monkeypatch.setattr(grouping, "db_docs", _FakeDocs(docs))
|
||||
r = grouping.group_documents("b1")
|
||||
assert r["ok"] is True
|
||||
assert r["total_docs"] == 2
|
||||
groups = [g for g in r["groups"] if g["contract_number"] != "__unresolved__"]
|
||||
assert len(groups) == 1
|
||||
assert groups[0]["contract_number"] == "03700_1"
|
||||
assert len(groups[0]["documents"]) == 2
|
||||
|
||||
def test_unmatched_goes_unresolved(self, monkeypatch):
|
||||
docs = [self._doc("d1", "other", None, None, None, "")]
|
||||
monkeypatch.setattr(grouping, "db_docs", _FakeDocs(docs))
|
||||
r = grouping.group_documents("b1")
|
||||
unresolved = [g for g in r["groups"] if g["contract_number"] == "__unresolved__"]
|
||||
assert len(unresolved) == 1
|
||||
|
||||
|
||||
class TestApplyGroups:
|
||||
def test_apply(self, monkeypatch):
|
||||
created = []
|
||||
|
||||
class FakeContracts:
|
||||
def insert(self, number, client=""):
|
||||
return {"id": f"c_{number}"}
|
||||
|
||||
class FakeSupps:
|
||||
def insert(self, cid, did, stype):
|
||||
created.append((cid, did, stype))
|
||||
return {"id": "s"}
|
||||
|
||||
monkeypatch.setattr(grouping, "db_contracts", FakeContracts())
|
||||
monkeypatch.setattr(grouping, "db_supplements", FakeSupps())
|
||||
|
||||
groups = [
|
||||
{"contract_number": "03700_1", "counterparty": "X",
|
||||
"documents": [{"id": "d1"}, {"id": "d2"}]},
|
||||
{"contract_number": "__unresolved__", "counterparty": "",
|
||||
"documents": [{"id": "d3"}]},
|
||||
]
|
||||
r = grouping.apply_groups("b1", groups)
|
||||
assert r["ok"] is True
|
||||
assert r["created"] == 2
|
||||
assert created == [("c_03700_1", "d1", "initial"), ("c_03700_1", "d2", "additional")]
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Unit-тесты services/llm.py — call_llm с FakeLLMClient."""
|
||||
from services.llm import call_llm
|
||||
from services.llm_client import FakeLLMClient
|
||||
|
||||
|
||||
def _build(cur, txt):
|
||||
return (f"prompt:{txt}", "pid-1")
|
||||
|
||||
|
||||
class TestCallLLM:
|
||||
def test_plain_json(self):
|
||||
llm = FakeLLMClient({"default": '{"mode":"partial","ops":[]}'})
|
||||
res, pid = call_llm([], "текст", _build, llm_client=llm)
|
||||
assert pid == "pid-1"
|
||||
assert res == {"mode": "partial", "ops": []}
|
||||
|
||||
def test_markdown_json(self):
|
||||
llm = FakeLLMClient({"default": '```json\n{"mode":"full_replace","ops":[]}\n```'})
|
||||
res, _ = call_llm([], "текст", _build, llm_client=llm)
|
||||
assert res["mode"] == "full_replace"
|
||||
|
||||
def test_markdown_generic(self):
|
||||
llm = FakeLLMClient({"default": '```\n{"a":1}\n```'})
|
||||
res, _ = call_llm([], "текст", _build, llm_client=llm)
|
||||
assert res == {"a": 1}
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Unit-тесты services/llm_client.py."""
|
||||
from services.llm_client import FakeLLMClient, HttpxLLMClient
|
||||
|
||||
|
||||
class TestFakeLLMClient:
|
||||
def test_exact_match(self):
|
||||
c = FakeLLMClient({"hello": "world"})
|
||||
assert c.complete("hello") == "world"
|
||||
|
||||
def test_partial_match(self):
|
||||
c = FakeLLMClient({"needle": "found"})
|
||||
assert c.complete("a needle in haystack") == "found"
|
||||
|
||||
def test_default_fallback(self):
|
||||
c = FakeLLMClient({"default": "fallback"})
|
||||
assert c.complete("whatever") == "fallback"
|
||||
|
||||
def test_calls_recorded(self):
|
||||
c = FakeLLMClient()
|
||||
c.complete("x")
|
||||
c.complete("y")
|
||||
assert c.calls == ["x", "y"]
|
||||
|
||||
def test_add(self):
|
||||
c = FakeLLMClient()
|
||||
c.add("k", "v")
|
||||
assert c.complete("k") == "v"
|
||||
|
||||
|
||||
class TestHttpxLLMClient:
|
||||
def test_init_defaults(self):
|
||||
c = HttpxLLMClient(url="http://x", key="k")
|
||||
assert c.model == "gpt-oss-120b"
|
||||
assert c.timeout == 120
|
||||
assert c.max_tokens == 8000
|
||||
|
||||
def test_init_custom(self):
|
||||
c = HttpxLLMClient(url="http://x", key="k", model="m", timeout=10)
|
||||
assert c.model == "m"
|
||||
assert c.timeout == 10
|
||||
@@ -0,0 +1,87 @@
|
||||
"""Unit-тесты services/metrics.py — чистая логика, без БД."""
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
|
||||
from services.metrics import (
|
||||
check_arithmetic,
|
||||
normalize_date,
|
||||
_to_decimal,
|
||||
ClassifyMetrics,
|
||||
)
|
||||
|
||||
|
||||
class TestNormalizeDate:
|
||||
def test_dd_mm_yyyy(self):
|
||||
assert normalize_date("01.01.2025") == "2025-01-01"
|
||||
|
||||
def test_already_iso(self):
|
||||
assert normalize_date("2025-01-01") == "2025-01-01"
|
||||
|
||||
def test_empty(self):
|
||||
assert normalize_date("") is None
|
||||
assert normalize_date(None) is None
|
||||
|
||||
def test_unrecognized_passthrough(self):
|
||||
assert normalize_date("01 января 2025") == "01 января 2025"
|
||||
|
||||
|
||||
class TestToDecimal:
|
||||
def test_int(self):
|
||||
assert _to_decimal("50000") == Decimal("50000")
|
||||
|
||||
def test_russian_number(self):
|
||||
assert _to_decimal("50 000,00") == Decimal("50000.00")
|
||||
|
||||
def test_nbsp(self):
|
||||
assert _to_decimal("1\u00a0000,50") == Decimal("1000.50")
|
||||
|
||||
def test_none(self):
|
||||
assert _to_decimal(None) is None
|
||||
assert _to_decimal("") is None
|
||||
|
||||
def test_garbage(self):
|
||||
assert _to_decimal("не число") is None
|
||||
|
||||
|
||||
class TestCheckArithmetic:
|
||||
def test_ok(self):
|
||||
ops = [{"action": "ADD", "new_row": {"name": "x", "price": 100, "qty": 2, "sum": 200}}]
|
||||
assert check_arithmetic(ops) == []
|
||||
|
||||
def test_mismatch(self):
|
||||
ops = [{"action": "ADD", "new_row": {"name": "x", "price": 100, "qty": 2, "sum": 250}}]
|
||||
m = check_arithmetic(ops)
|
||||
assert len(m) == 1
|
||||
assert m[0]["expected_sum"] == 200.0
|
||||
assert m[0]["actual_sum"] == 250.0
|
||||
assert m[0]["diff"] == 50.0
|
||||
|
||||
def test_update_values(self):
|
||||
ops = [{"action": "UPDATE", "new_values": {"price": 10, "qty": 3, "sum": 30}}]
|
||||
assert check_arithmetic(ops) == []
|
||||
|
||||
def test_skip_incomplete(self):
|
||||
# нет qty → пропуск
|
||||
ops = [{"action": "ADD", "new_row": {"name": "x", "price": 100, "sum": 200}}]
|
||||
assert check_arithmetic(ops) == []
|
||||
|
||||
def test_skip_non_add_update(self):
|
||||
ops = [{"action": "DELETE", "target_hash": "h"}]
|
||||
assert check_arithmetic(ops) == []
|
||||
|
||||
|
||||
class TestClassifyMetrics:
|
||||
def test_fix_rate(self):
|
||||
m = ClassifyMetrics()
|
||||
m.record(False)
|
||||
m.record(True)
|
||||
m.record(False)
|
||||
assert m.total == 3
|
||||
assert m.fixes == 1
|
||||
assert m.fix_rate == pytest.approx(1 / 3)
|
||||
|
||||
def test_empty(self):
|
||||
m = ClassifyMetrics()
|
||||
assert m.fix_rate == 0.0
|
||||
assert m.summary()["total_classifications"] == 0
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Unit-тесты services/parse.py."""
|
||||
from services.parse import parse_file, _parse_text
|
||||
|
||||
|
||||
class TestParseText:
|
||||
def test_plain(self):
|
||||
r = parse_file("test.txt", "строка1\nстрока2\nстрока3".encode())
|
||||
assert r["status"] == "parsed"
|
||||
assert r["element_count"] == 3
|
||||
|
||||
def test_text_fn(self):
|
||||
r = _parse_text(b"a\nb\nc")
|
||||
assert r["status"] == "parsed"
|
||||
assert r["element_count"] == 3
|
||||
assert r["elements"][0]["text"] == "a"
|
||||
|
||||
def test_no_extension_falls_back_to_text(self):
|
||||
r = parse_file("noext", b"line1\nline2")
|
||||
assert r["status"] == "parsed"
|
||||
assert r["element_count"] == 2
|
||||
|
||||
def test_docx_invalid_returns_error(self):
|
||||
r = parse_file("test.docx", b"not a real docx")
|
||||
assert r["status"] == "error"
|
||||
|
||||
def test_pdf_invalid_returns_error(self):
|
||||
r = parse_file("test.pdf", b"not a real pdf")
|
||||
assert r["status"] == "error"
|
||||
@@ -0,0 +1,96 @@
|
||||
"""Интеграционные тесты services/process.py — run_pipeline с Fake LLM."""
|
||||
import json
|
||||
|
||||
from db import contracts, documents, supplements, spec_current
|
||||
from db.connection import query
|
||||
from services import process
|
||||
|
||||
|
||||
def _seed_contract(n_supps=2):
|
||||
"""Создать contract + n документов (parsed) + n supplements."""
|
||||
c = contracts.insert("03700_1")
|
||||
for i in range(n_supps):
|
||||
d = documents.insert(f"d{i}.docx", "application/octet-stream", "raw", batch_id="b1")
|
||||
documents.set_parsed(d["id"], [{"type": "paragraph", "text": f"строка {i}"}])
|
||||
supplements.insert(c["id"], d["id"], "initial" if i == 0 else "additional")
|
||||
return c
|
||||
|
||||
|
||||
class TestFullReplace:
|
||||
def test_no_duplicates(self, db, monkeypatch):
|
||||
c = _seed_contract(2)
|
||||
|
||||
def fake_call(current_spec, doc_text, build_prompt_fn, llm_client=None):
|
||||
ops = [
|
||||
{"action": "ADD", "new_row": {"name": "Аренда стойко-места", "price": 50000, "qty": 1, "sum": 50000}},
|
||||
{"action": "ADD", "new_row": {"name": "IP-адрес IPv4", "price": 300, "qty": 8, "sum": 2400}},
|
||||
{"action": "ADD", "new_row": {"name": "Канал 1 Гбит/с", "price": 20000, "qty": 1, "sum": 20000}},
|
||||
]
|
||||
return ({"mode": "full_replace", "ops": ops}, "pid")
|
||||
|
||||
monkeypatch.setattr("services.llm.call_llm", fake_call)
|
||||
|
||||
events = list(process.run_pipeline(c["id"], "", lambda cur, txt: ("p", "pid")))
|
||||
|
||||
rows = spec_current.list_by_contract(c["id"])
|
||||
# 2 full_replace, но без дублей — всегда 3 строки, не 6
|
||||
assert len(rows) == 3
|
||||
assert {r["name"] for r in rows} == {"Аренда стойко-места", "IP-адрес IPv4", "Канал 1 Гбит/с"}
|
||||
|
||||
# история сохранена: 2 full_replace × 3 ADD = 6 событий
|
||||
ev = query("SELECT count(*) AS c FROM spec_events WHERE contract_id = %s", (c["id"],))
|
||||
assert ev[0]["c"] == 6
|
||||
|
||||
# не было ошибок извлечения
|
||||
assert not any(e.get("type") == "extract_error" for e in events)
|
||||
|
||||
|
||||
class TestTargetIdTranslation:
|
||||
def test_update_applies(self, db, monkeypatch):
|
||||
c = _seed_contract(2)
|
||||
|
||||
def fake_call(current_spec, doc_text, build_prompt_fn, llm_client=None):
|
||||
if not current_spec:
|
||||
return ({"mode": "partial", "ops": [
|
||||
{"action": "ADD", "new_row": {"name": "Аренда", "price": 50000, "qty": 1, "sum": 50000}},
|
||||
]}, "pid")
|
||||
return ({"mode": "partial", "ops": [
|
||||
{"action": "UPDATE", "target_id": "r1", "new_values": {"price": 55000, "sum": 55000}},
|
||||
]}, "pid")
|
||||
|
||||
monkeypatch.setattr("services.llm.call_llm", fake_call)
|
||||
|
||||
list(process.run_pipeline(c["id"], "", lambda cur, txt: ("p", "pid")))
|
||||
|
||||
rows = spec_current.list_by_contract(c["id"])
|
||||
assert len(rows) == 1
|
||||
assert rows[0]["price"] == 55000 # UPDATE применился, а не ушёл в UNRESOLVED
|
||||
|
||||
upd = query("SELECT status FROM spec_events WHERE contract_id = %s AND action = 'UPDATE'", (c["id"],))
|
||||
assert upd[0]["status"] == "applied"
|
||||
|
||||
|
||||
class TestNoSupplements:
|
||||
def test_error_event(self, db):
|
||||
evs = list(process.run_pipeline("nonexistent", "", lambda cur, txt: ("p", "pid")))
|
||||
assert any(e.get("type") == "error" for e in evs)
|
||||
|
||||
|
||||
class TestElementsToText:
|
||||
def test_list(self):
|
||||
ej = [
|
||||
{"type": "paragraph", "text": "Привет"},
|
||||
{"type": "table", "rows": [["a", "b"], ["c", "d"]]},
|
||||
]
|
||||
assert process._elements_to_text(ej) == "Привет\na | b\nc | d"
|
||||
|
||||
def test_dict_value(self):
|
||||
ej = {"Value": [{"type": "paragraph", "text": "X"}]}
|
||||
assert process._elements_to_text(ej) == "X"
|
||||
|
||||
def test_string_json(self):
|
||||
ej = json.dumps([{"type": "paragraph", "text": "Y"}])
|
||||
assert process._elements_to_text(ej) == "Y"
|
||||
|
||||
def test_plain_string(self):
|
||||
assert process._elements_to_text("просто текст") == "просто текст"
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Интеграционные тесты HTTP-эндпоинтов (Flask test client)."""
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(tmp_path, monkeypatch):
|
||||
from db import connection as conn
|
||||
monkeypatch.setattr(conn, "DB_PATH", str(tmp_path / "app.db"))
|
||||
from app import create_app
|
||||
app = create_app()
|
||||
app.config["TESTING"] = True
|
||||
yield app.test_client()
|
||||
c = getattr(conn._local, "conn", None)
|
||||
if c is not None:
|
||||
try:
|
||||
c.close()
|
||||
except Exception:
|
||||
pass
|
||||
conn._local.conn = None
|
||||
|
||||
|
||||
class TestHealth:
|
||||
def test_health(self, client):
|
||||
from config import VERSION
|
||||
r = client.get("/health")
|
||||
assert r.status_code == 200
|
||||
data = r.get_json()
|
||||
assert data["ok"] is True
|
||||
assert data["version"] == VERSION
|
||||
|
||||
|
||||
class TestSpecCurrent:
|
||||
def test_missing_contract_id(self, client):
|
||||
r = client.get("/api/spec-current")
|
||||
assert r.status_code == 400
|
||||
|
||||
def test_empty(self, client):
|
||||
r = client.get("/api/spec-current?contract_id=missing")
|
||||
assert r.status_code == 200
|
||||
data = r.get_json()
|
||||
assert data["ok"] is True
|
||||
assert data["rows"] == []
|
||||
|
||||
|
||||
class TestGroups:
|
||||
def test_missing_batch(self, client):
|
||||
r = client.get("/api/groups")
|
||||
assert r.status_code == 400
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Интеграционные тесты db/spec_current.py."""
|
||||
import json
|
||||
|
||||
from db import spec_current, documents, spec_events
|
||||
|
||||
|
||||
class TestListByContract:
|
||||
def test_empty(self, db):
|
||||
assert spec_current.list_by_contract("nope") == []
|
||||
|
||||
def test_ordered_by_name(self, db):
|
||||
ops = [
|
||||
{"action": "ADD", "new_row": {"name": "Б", "price": 2}},
|
||||
{"action": "ADD", "new_row": {"name": "А", "price": 1}},
|
||||
]
|
||||
spec_events.apply_ops("c1", "s1", "d1", ops, "pid", {})
|
||||
rows = spec_current.list_by_contract("c1")
|
||||
assert [r["name"] for r in rows] == ["А", "Б"]
|
||||
|
||||
|
||||
class TestGetElementsJson:
|
||||
def test_none(self, db):
|
||||
assert spec_current.get_elements_json("missing") is None
|
||||
|
||||
def test_parsed(self, db):
|
||||
d = documents.insert("f.docx", "m", "raw", batch_id="b1")
|
||||
documents.set_parsed(d["id"], [{"type": "paragraph", "text": "x"}])
|
||||
ej = spec_current.get_elements_json(d["id"])
|
||||
assert ej is not None
|
||||
parsed = json.loads(ej)
|
||||
assert parsed[0]["text"] == "x"
|
||||
@@ -0,0 +1,107 @@
|
||||
"""Интеграционные тесты db/spec_events.py (SQLite, изолированная БД)."""
|
||||
from db.connection import query
|
||||
from db import spec_events, spec_current
|
||||
|
||||
|
||||
CID = "11111111-1111-1111-1111-111111111111"
|
||||
SID = "22222222-2222-2222-2222-222222222222"
|
||||
DID = "33333333-3333-3333-3333-333333333333"
|
||||
|
||||
|
||||
def _count(table, contract_id):
|
||||
rows = query(f"SELECT count(*) AS c FROM {table} WHERE contract_id = %s", (contract_id,))
|
||||
return rows[0]["c"]
|
||||
|
||||
|
||||
class TestApplyOpsAdd:
|
||||
def test_add(self, db):
|
||||
ops = [{"action": "ADD", "new_row": {"name": "Аренда стойко-места", "price": 50000, "qty": 1, "sum": 50000, "date_start": "2025-01-01"}}]
|
||||
s = spec_events.apply_ops(CID, SID, DID, ops, "pid", {})
|
||||
assert s == {"added": 1, "updated": 0, "deleted": 0, "unresolved": 0}
|
||||
rows = spec_current.list_by_contract(CID)
|
||||
assert len(rows) == 1
|
||||
assert rows[0]["name"] == "Аренда стойко-места"
|
||||
assert rows[0]["price"] == 50000
|
||||
assert rows[0]["date_start"] == "2025-01-01"
|
||||
|
||||
def test_add_empty_name_unresolved(self, db):
|
||||
ops = [{"action": "ADD", "new_row": {"name": ""}}]
|
||||
s = spec_events.apply_ops(CID, SID, DID, ops, "pid", {})
|
||||
assert s == {"added": 0, "updated": 0, "deleted": 0, "unresolved": 1}
|
||||
assert spec_current.list_by_contract(CID) == []
|
||||
|
||||
def test_add_multiple(self, db):
|
||||
ops = [
|
||||
{"action": "ADD", "new_row": {"name": "A", "price": 1}},
|
||||
{"action": "ADD", "new_row": {"name": "B", "price": 2}},
|
||||
]
|
||||
s = spec_events.apply_ops(CID, SID, DID, ops, "pid", {})
|
||||
assert s["added"] == 2
|
||||
assert len(spec_current.list_by_contract(CID)) == 2
|
||||
|
||||
|
||||
class TestApplyOpsUpdateDelete:
|
||||
def test_update_and_delete(self, db):
|
||||
spec_events.apply_ops(CID, SID, DID, [{"action": "ADD", "new_row": {"name": "Аренда", "price": 50000, "qty": 1, "sum": 50000}}], "pid", {})
|
||||
h = spec_events._hash("Аренда")
|
||||
|
||||
s = spec_events.apply_ops(CID, SID, DID, [{"action": "UPDATE", "target_hash": h, "new_values": {"price": 55000}}], "pid", {})
|
||||
assert s["updated"] == 1
|
||||
assert spec_current.list_by_contract(CID)[0]["price"] == 55000
|
||||
|
||||
s = spec_events.apply_ops(CID, SID, DID, [{"action": "DELETE", "target_hash": h}], "pid", {})
|
||||
assert s["deleted"] == 1
|
||||
assert spec_current.list_by_contract(CID) == []
|
||||
|
||||
def test_update_empty_hash_unresolved(self, db):
|
||||
s = spec_events.apply_ops(CID, SID, DID, [{"action": "UPDATE", "new_values": {"price": 1}}], "pid", {})
|
||||
assert s["updated"] == 0
|
||||
rows = query("SELECT status FROM spec_events WHERE contract_id = %s", (CID,))
|
||||
assert rows[0]["status"] == "unresolved"
|
||||
|
||||
|
||||
class TestUnresolvedAndUnknown:
|
||||
def test_unresolved_action(self, db):
|
||||
ops = [{"action": "UNRESOLVED", "new_values": {"name": "X"}, "reason": "нет соответствия"}]
|
||||
spec_events.apply_ops(CID, SID, DID, ops, "pid", {})
|
||||
assert spec_current.list_by_contract(CID) == []
|
||||
rows = query("SELECT action, status FROM spec_events WHERE contract_id = %s", (CID,))
|
||||
assert rows[0]["action"] == "UNRESOLVED"
|
||||
assert rows[0]["status"] == "unresolved"
|
||||
|
||||
def test_unknown_action(self, db):
|
||||
spec_events.apply_ops(CID, SID, DID, [{"action": "WHATEVER", "new_row": {"name": "X"}}], "pid", {})
|
||||
assert spec_current.list_by_contract(CID) == []
|
||||
rows = query("SELECT action FROM spec_events WHERE contract_id = %s", (CID,))
|
||||
assert rows[0]["action"] == "UNRESOLVED"
|
||||
|
||||
|
||||
class TestClearAndReset:
|
||||
def test_clear_current_keeps_events(self, db):
|
||||
spec_events.apply_ops(CID, SID, DID, [{"action": "ADD", "new_row": {"name": "A", "price": 1}}], "pid", {})
|
||||
assert _count("spec_current", CID) == 1
|
||||
spec_events.clear_current(CID)
|
||||
assert _count("spec_current", CID) == 0
|
||||
assert _count("spec_events", CID) == 1 # история сохранена
|
||||
|
||||
def test_reset_clears_both(self, db):
|
||||
spec_events.apply_ops(CID, SID, DID, [{"action": "ADD", "new_row": {"name": "A", "price": 1}}], "pid", {})
|
||||
spec_events.reset(CID)
|
||||
assert _count("spec_current", CID) == 0
|
||||
assert _count("spec_events", CID) == 0
|
||||
|
||||
|
||||
class TestHashAndSeq:
|
||||
def test_hash_normalizes(self):
|
||||
assert spec_events._hash(" Арена стойко-места ") == spec_events._hash("арена стойко-места")
|
||||
|
||||
def test_hash_includes_date(self):
|
||||
assert spec_events._hash("Аренда", "01.01.2025") != spec_events._hash("Аренда", "01.02.2025")
|
||||
|
||||
def test_hash_len(self):
|
||||
assert len(spec_events._hash("x")) == 16
|
||||
|
||||
def test_seq(self, db):
|
||||
assert spec_events.get_next_seq(CID) == 1
|
||||
spec_events.apply_ops(CID, SID, DID, [{"action": "ADD", "new_row": {"name": "A"}}], "pid", {})
|
||||
assert spec_events.get_next_seq(CID) == 2
|
||||
@@ -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 на ВМ, а не код модуля.
|
||||
@@ -0,0 +1,8 @@
|
||||
"""Переиспользуемый модуль загрузки через ВМ (2 слоя).
|
||||
|
||||
Структура:
|
||||
- frontend/ — слой 1 (выбор файлов) + слой 2 (закачка через ВМ), JS.
|
||||
- backend/ — слой 2 (бэк): Blueprint upload_refs + in-memory сессия, Python.
|
||||
|
||||
Подключение в новый проект — см. README.md.
|
||||
"""
|
||||
@@ -0,0 +1 @@
|
||||
"""Backend переиспользуемого модуля загрузки: upload_refs + session."""
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -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/"
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
// esc — экранирование HTML (защита от self-XSS именами файлов).
|
||||
|
||||
export function esc(s) {
|
||||
return String(s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 + 'с';
|
||||
}
|
||||
@@ -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); },
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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>';
|
||||
}
|
||||
@@ -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 + ' файлов';
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// setStatus — записать HTML-статус в ячейку таблицы (st-<idx>).
|
||||
|
||||
export function setStatus(idx, html) {
|
||||
const e = document.getElementById('st-' + idx);
|
||||
if (e) e.innerHTML = html;
|
||||
}
|
||||
@@ -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>'), '<a href="x">&'</a>');
|
||||
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('Все фронт-тесты прошли');
|
||||
@@ -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 (фронт) прошли');
|
||||
@@ -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
Reference in New Issue
Block a user