45 Commits
Author SHA1 Message Date
RepinoidandCopilot 1878649317 docs: clarify contract reconciliation concept
Deploy contracts-flask / validate (push) Canceled after 0s
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-30 13:40:56 +03:00
Repinoid f2f0f200d1 1
Deploy contracts-flask / validate (push) Canceled after 0s
2026-10-30 13:23:25 +03:00
Repinoid b7534a063c chore: exclude non-deployment project files
Deploy contracts-flask / validate (push) Canceled after 0s
2026-10-30 13:22:06 +03:00
RepinoidandCopilot 5966c76a0d Improve upload screen readability and help
Deploy contracts-flask / validate (push) Canceled after 0s
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-30 12:53:07 +03:00
RepinoidandCopilot bb5dd6538e Fix Contracts upload buffer endpoint
Deploy contracts-flask / validate (push) Canceled after 0s
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-30 12:08:55 +03:00
RepinoidandCopilot 961d60a79e Match Contracts UI to Obfuscator and add Help
Deploy contracts-flask / validate (push) Canceled after 0s
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-30 10:40:44 +03:00
RepinoidandCopilot c79006737f Use configured version in Contracts homepage
Deploy contracts-flask / validate (push) Canceled after 0s
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-30 10:34:59 +03:00
RepinoidandCopilot 32f7d3d764 chore: bump v2.0.22 (интеграция выбора файлов)
Deploy contracts-flask / validate (push) Canceled after 0s
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-30 10:28:55 +03:00
RepinoidandCopilot 6e6fc4db8e Integrate Obfuscator file picker into Contracts
Deploy contracts-flask / validate (push) Canceled after 0s
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-30 10:24:23 +03:00
“Naeel” 83002b9644 1
Deploy contracts-flask / validate (push) Canceled after 0s
2026-08-31 13:58:15 +03:00
“Naeel” f9745e5c6b Document comparison review findings
Deploy contracts-flask / validate (push) Canceled after 0s
2026-08-27 11:24:32 +03:00
“Naeel” 811be85efe Fix comparison pipeline event and transaction handling 2026-08-27 11:23:14 +03:00
“Naeel” e5c7c88073 Avoid false garbage classification for UPD mentions
Deploy contracts-flask / validate (push) Canceled after 0s
2026-08-27 10:44:48 +03:00
“Naeel” 475efefb40 Preserve ZIP source during upload
Deploy contracts-flask / validate (push) Canceled after 0s
2026-08-27 09:45:54 +03:00
“Naeel” 65939984c3 Fix classification progress for garbage documents
Deploy contracts-flask / validate (push) Canceled after 0s
2026-08-27 08:29:59 +03:00
“Naeel” 98e18b0135 chore: v2.0.17 во фронте (заголовок + ?v= кэш-бастеры)
Deploy contracts-flask / validate (push) Canceled after 0s
2026-08-26 20:59:10 +03:00
“Naeel” 36144334a3 chore: bump v2.0.17 (чистка legacy-upload)
Deploy contracts-flask / validate (push) Canceled after 0s
2026-08-26 20:54:04 +03:00
“Naeel” c4997b06d3 test: stress_http переписан на реальный VM-путь (PUT на ВМ → /api/upload_refs) 2026-08-26 20:51:39 +03:00
“Naeel” 533aa99592 docs: History — чистка legacy-upload + сводка сессии 2026-08-26 2026-08-26 20:49:24 +03:00
“Naeel” aa585220fc refactor: удалён мёртвый legacy-код загрузки напрямую (/upload, /unzip-upload, _check_ext, _unzip, ALLOWED, константы фронта); загрузка только через ВМ (/api/upload_refs) 2026-08-26 20:47:41 +03:00
“Naeel” 08e8e3afec test: stress_http --file (реальные файлы); History: 100KB прямой POST — 83% таймаутов (шлюз рвёт >64KB), 30KB реальный — 200/200 ok 2026-08-26 20:35:48 +03:00
“Naeel” 6fcbbddbdb test: LOAD_THREADS 4 (стабильно, = MAX_WORKERS); полный прогон: not-load 102 passed, load 3 passed 2026-08-26 20:14:43 +03:00
“Naeel” 08f7e3f98e test: дефолт LOAD_THREADS 16→8 (стабильный прогон) + History: database is locked при 16 писателях (предел конфигурации) 2026-08-26 17:12:26 +03:00
“Naeel” b7d60e1d93 test: нагрузочные/стресс-тесты (маркер load): массовый pipeline, apply_ops, конкуренция, HTTP-стресс 2026-08-26 17:03:45 +03:00
“Naeel” 3b259cf160 test: README для tests/ + фикс теста test_boolean (BOOLEAN NOT NULL); 102 passed 2026-08-26 16:50:01 +03:00
“Naeel” 8f8fabf959 test: набор pytest-тестов под site/ (unit + интеграционные + безопасность) 2026-08-26 16:47:30 +03:00
“Naeel” 9a8ae0a5a3 test: удалены устаревшие тесты deploy/tests (ссылались на старую compare/-архитектуру) 2026-08-26 16:36:52 +03:00
“Naeel” 87524c54a4 docs: код-ревью (2026-08-26) + задания Sonnet по архитектуре (v2.0.16)
Deploy contracts-flask / validate (push) Canceled after 0s
2026-08-26 16:29:41 +03:00
“Naeel” 021c925e3c fix: full_replace очищает spec_current (не дублирует строки) + clear_current; v2.0.16
Deploy contracts-flask / validate (push) Canceled after 0s
2026-08-26 16:25:23 +03:00
“Naeel” e3f5581712 v2.0.15: фиксы по ревью Соннета (10 находок)
Deploy contracts-flask / validate (push) Canceled after 0s
- #1 process.py: target_id→target_hash (UPDATE/DELETE больше не UNRESOLVED)
- #2 Dockerfile: COPY upload
- #3 prompts.py: убран created_by из SELECT
- #4 prompts_bp.py: save_new_version/delete_prompt
- #5 llm.py/classify.py: LLM-конфиг из config.py
- #6/#7 compare.js: escHtml (XSS)
- #8-#10: комментарии + мёртвый gen_random_uuid
2026-08-26 15:59:21 +03:00
“Naeel” ebdab731ff v2.0.14: .doc→.docx в sink + зачистка мёртвого кода
Deploy contracts-flask / validate (push) Canceled after 0s
- contracts_upload_sink: конвертация .doc через внешний сервис (фронт грузит напрямую)
- удалены: convertDoc/addZipFile/addRegularFile (фронт), /convert-doc,/api/convert_refs,/api/unzip_refs, _pull_from_ref и хелперы pull (бэк)
2026-08-26 13:54:07 +03:00
“Naeel” 5b49e88f1e v2.0.14: этап 4 — .doc→.docx в sink, удалены /api/unzip_refs и /api/convert_refs
Deploy contracts-flask / validate (push) Canceled after 0s
2026-08-26 11:40:03 +03:00
“Naeel” d2894ad7c5 v2.0.13: этап 3 — выбор папок + рекурсивный клиентский ZIP
Deploy contracts-flask / validate (push) Canceled after 0s
- pages_bp: route /upload/<path> отдаёт ES-модули upload/frontend
- index.html: инпут папки (webkitdirectory) + мост listZipFiles
- files.js: expandZipClient (рекурсивный ZIP через drhider listZipFiles)
- app.js: обработчик выбора папки (фильтр .pdf/.doc/.docx/.zip)
2026-08-26 08:40:19 +03:00
“Naeel” c2344f9738 bump 2.0.12 (этап 2: модуль upload через sink)
Deploy contracts-flask / validate (push) Canceled after 0s
2026-08-26 08:15:48 +03:00
“Naeel” db58a433fb этап 2: переиспользуемый модуль upload (sink) вместо рукописного транспорта
- копирую модуль upload/ из drhider (слои 1-2)
- blueprint: параметр sink (drhider-сессия по умолчанию, сверка — DB+парсинг)
- upload_bp: contracts_upload_sink = _store_and_parse
- routes: регистрирую create_upload_refs_blueprint(cfg, sink=...)
- app.py: корень репо в sys.path (для import upload)
- History: план переиспользования + ревью Соннета
2026-08-26 08:07:43 +03:00
“Naeel” 55bd7a027d v2.0.11: вся загрузка через ВМ-буфер (ZIP + .doc)
Deploy contracts-flask / validate (push) Canceled after 0s
- /api/unzip_refs: pull ZIP с ВМ + распаковка
- /api/convert_refs: pull .doc с ВМ + конвертация в .docx
- convertDoc/addZipFile: PUT на ВМ вместо прямого multipart
2026-08-24 22:08:01 +03:00
“Naeel” 37ea5e2c31 v2.0.10: nginx /contracts-upload/ на alias + CORS как у drhider
Deploy contracts-flask / validate (push) Canceled after 0s
2026-08-24 21:51:48 +03:00
“Naeel” 78045f2c81 v2.0.10: загрузка через ВМ-буфер (паттерн drhider)
Deploy contracts-flask / validate (push) Canceled after 0s
- /api/upload_refs: pull файлов с ВМ (SSRF-guard, лимит 50МБ, ретраи, DELETE)
- uploadFile: PUT на WebDAV /contracts-upload/ → refs → pull
- nginx: location /contracts-upload/ (WebDAV + CORS для managed-фронта)
2026-08-24 21:45:46 +03:00
naeel 4653aa5e8e Docs: диагностика HTTP 502 на 19МБ PDF — OOMKill пода (лимит 1Gi)
Deploy contracts-flask / validate (push) Canceled after 0s
2026-08-17 18:32:06 +04:00
“Naeel” 956aed0971 v2.0.9: правильные лого и фавикон из design/ 2026-07-16 10:58:09 +04:00
“Naeel” 3b2e576284 v2.0.9: фавикон nubes.ru (U-арка) 2026-07-16 10:56:58 +04:00
“Naeel” de05d746cc v2.0.8: конвертация .doc → .docx через convert-service перед upload 2026-07-16 10:42:21 +04:00
“Naeel” c677206d03 v2.0.7: .doc в accept и подсказке 2026-07-16 10:29:14 +04:00
“Naeel” d1415d5d21 v2.0.6: convert-service интеграция 2026-07-16 10:20:44 +04:00
“Naeel” 5d8bcd61ea v2.0.5: интеграция с convert-service (HTTP вместо subprocess libreoffice) 2026-07-16 10:18:17 +04:00
101 changed files with 4925 additions and 3725 deletions
+12 -1
View File
@@ -3,6 +3,17 @@ __pycache__/
.env
venv/
.venv/
.pytest_cache/
.mypy_cache/
.ruff_cache/
.coverage
htmlcov/
logs/
*.log
hz/
hz/
# Project history and development material are not part of the deploy repository.
History/
docs/
tests/
architecture-diagram.*
+1
View File
@@ -7,6 +7,7 @@ COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY site /app/site
COPY upload /app/upload
EXPOSE 5000
-64
View File
@@ -1,64 +0,0 @@
# Архитектура contracts-flask
## Компоненты
```
Браузер
│
▼
check.kube5s.ru (nginx)
│
├── /, /static/*, /templates/*
│ → Managed Flask (contractor.pythonk8s.services.ngcloud.ru)
│ Отдаёт HTML + JS. JS в браузере делает API-запросы.
│
└── /api/*, /upload, /llm-ops, /process-v2, /convert-doc, /unzip-upload
→ ВМ :8777 (convert_server.py)
Python http.server → PostgreSQL (contracts_check)
→ LLM (api.aillm.ru, gpt-oss-120b)
```
## Пайплайн
```
1. Загрузка
JS (FormData) → /upload → parse.py (pdfplumber/python-docx)
→ elements_json → documents (БД)
2. Классификация
JS → /api/classify-batch → classify.py (LLM)
→ doc_type, own_number, parent_number, counterparty, doc_date
3. Группировка
JS → /api/groups → grouping.py (детерминированная)
→ группы по контрактам (нормализация номеров)
4. Сравнение
JS → /process-v2 (SSE) → process.py → llm.py (LLM)
→ ops (ADD/UPDATE/DELETE/UNRESOLVED)
→ spec_events + spec_current (event sourcing)
5. Чат
JS → /chat → Flask → /api/spec-current (ВМ)
→ контекст строк → LLM → ответ
```
## Вход/Выход
| Этап | Вход | Выход |
|------|------|-------|
| Загрузка | .docx/.pdf/.doc/.zip (FormData) | elements_json в БД |
| Классификация | elements_json | doc_type, номер, дата, контрагент |
| Группировка | classified documents | группы по contract_number |
| Сравнение | группа документов + spec_current | ops: ADD/UPDATE/DELETE/UNRESOLVED |
| Чат | вопрос + spec_current | текст ответа |
## БД
- PostgreSQL `contracts_check`
- Таблицы: documents, contracts, supplements, spec_current, spec_events, prompts, upload_chunks
## LLM
- gpt-oss-120b через api.aillm.ru (бесплатно)
- Используется: классификация, извлечение, сравнение, чат
-64
View File
@@ -1,64 +0,0 @@
# Аудит кода contracts-flask — 2026-06-30
## Контекст
После переименования `services/` → `compare/` и восстановления сервиса проведён аудит кодовой базы.
Фокус: **что реально упадёт, сломается или работает нестабильно**. Без вылизывания.
Сервис непубличный, дев-окружение. Auth пока не нужен.
---
## ⚠️ Исправлено в этом цикле
### #1. POST-хендлеры без try вокруг Content-Length и json.loads
**Файл:** `deploy/convert_server.py`
**Проблема:** `_handle_api_sync`, `_handle_api_groups`, `_handle_apply_groups`, промпт-хендлеры — все делают `json.loads(self.rfile.read(length))` без try. Битый Content-Length → ValueError, пустой body → JSONDecodeError. Оба не ловятся → оборванное соединение.
**Фикс:** обёрнуто в try/except с возвратом `{"ok": false, "error": "..."}`.
### #11. ZIP-бомба в DrHider
**Файл:** `deploy/drhider/drhider.py`
**Проблема:** проверяется только заявленный `info.file_size` (можно подделать), нет лимита фактически распакованных байт, нет проверки ratio сжатия.
**Фикс:** скопирована защита из `compare/unzip.py` — накопительный счётчик распакованных байт + ratio-проверка (100×).
### #6. classify сбрасывает status у уже классифицированных документов
**Файл:** `deploy/compare/classify.py`
**Проблема:** `reset_classify_status(batch_id)` в начале `classify_batch()` сбрасывает на 'pending' ВСЕ документы батча, включая уже классифицированные. При повторном запуске — жжёт токены LLM на переклассификацию.
**Фикс:** `reset_classify_status` теперь сбрасывает только `'processing'` (crash recovery), оставляя `'classified'` и `'garbage'` нетронутыми.
---
## 🟡 Известные проблемы (не исправлены — низкий приоритет)
### SSE: двойная запись в мёртвый сокет
`convert_server.py` — при отвале клиента `run_pipeline` бросает `BrokenPipeError`, except ловит и **снова** зовёт `_sse` → второе исключение. Грязные трейсы, но не крашит сервер.
### classify: «успешный» JSON от LLM, который не dict
`classify.py` — `json.loads` может вернуть список/строку вместо dict → `AttributeError` → документ молча помечается failed. Случается редко.
### .doc через /upload не работает
`parse.py` зовёт `_parse_docx` (python-docx) для `.doc`, а тот читает только OOXML/.docx. Формат разрешён в `upload.py`, но не парсится. Надо либо убрать `.doc` из whitelist, либо звать `/convert-doc`.
### Гонка счётчиков в ThreadPoolExecutor
`classify.py` мутирует `garbage`, `json_total`, `type_counts` из 4 потоков через `nonlocal` без локов. Счётчики могут привирать на 1-2 единицы.
### _safe_json_parse — 4 уровня эвристик
Реаниматор битого JSON. Лечит симптом «LLM обрезал ответ». Дописывание кавычек/скобок вслепую может дать валидный но мусорный JSON → тихая неверная классификация.
### Весь файл в base64 в БД
`upload.py` хранит `original_bytes` как base64 (+33%) в Postgres. `SELECT *` таскает мегабайты. База пухнет.
### LLM URL/key захардкожены в 3 местах
`classify.py`, `drhider_server.py`, `llm_client.py`. Рассинхрон — тот же класс проблемы, что с `DB_NAME`.
### DrHider: PDF→txt + потеря форматирования DOCX
PDF на выходе становится `.txt`. Замена в DOCX сваливает текст в первый run → форматирование плывёт.
---
## ✅ Что НЕ является проблемой (проверено)
- SQL-инъекций нет — весь `db/` на параметризованных запросах
- Path traversal в static-раздаче прикрыт `realpath`+`startswith`
- Path traversal в ZIP-распаковке прикрыт (`unzip.py`, `drhider.py`)
- Nginx защищает от больших тел (client_max_body_size)
- DrHider двойной encode fix сделан
-43
View File
@@ -1,43 +0,0 @@
# DrHider — Cyrillic filenames in ZIP fix (2026-07-07)
## Проблема
Кириллические имена файлов в выходном ZIP отображаются кракозябрами
(CP437 вместо UTF-8). Содержимое файлов — OK.
## Причина (двойная)
1. **Мультипарт-парсер** в `drhider_server.py` декодирует HTTP-заголовки как `latin-1`,
но браузеры/curl шлют имена файлов в UTF-8. Результат — Mojibake.
2. **`_build_zip`** в `drhider.py` — `zipfile.ZipFile` в режиме записи кодирует
имена в CP437, флаг UTF-8 не ставится.
## Решение
### Часть 1: мультипарт (drhider_server.py)
```python
name = os.path.basename(m.group(1))
try:
name = name.encode('latin-1').decode('utf-8')
except (UnicodeDecodeError, UnicodeEncodeError):
pass
```
### Часть 2: ZIP (drhider.py)
```python
# Было:
zf.writestr(fname, content)
# Стало:
info = zipfile.ZipInfo(fname)
info.flag_bits |= 0x800
zf.writestr(info, content)
```
## Затронутые файлы
- ВМ: `/home/naeel/contracts/drhider/drhider.py` → `_build_zip`
- ВМ: `/home/naeel/contracts/drhider/drhider_server.py` → multipart parser
- `contracts-flask/deploy/drhider/drhider.py`
- `contracts-flask/deploy/drhider/drhider_server.py`
- `contracts-flask/site/services/drhider.py`
## Важно
- `metadata_encoding='utf-8'` НЕ РАБОТАЕТ в Python 3.12.3 на запись
- После правки `.py` → `rm -rf __pycache__` перед перезапуском
@@ -1,59 +0,0 @@
# DrHider — рабочее состояние v1.1
Файл: `/home/naeel/nubes/contracts/contracts-flask/History/drhider-working-state-2026-06-29.md`
29.06.2026 16:55
## Статус: РАБОТАЕТ
VM (`contracts.kube5s.ru`) — полный цикл: страница → загрузка → обфускация → ZIP.
## URL
- Страница: `https://contracts.kube5s.ru/drhider`
- API: `POST https://contracts.kube5s.ru/api/drhider` (JSON base64)
- Редирект с managed: `https://contractor.pythonk8s.services.ngcloud.ru/DrHider` → VM
## Тест (VM → публичный URL)
```
POST /api/drhider с примеры_договоров_для_ИИ.zip (120KB, 5 файлов внутри)
→ HTTP 200, 107KB ZIP, 30 секунд
→ 6 файлов в ZIP: 5 обфусцированных + mapping.csv
→ 19 обфусцированных сущностей (телефоны, email, компании, ФИО)
```
## Что работает
- ZIP-распаковка: вложенные архивы извлекаются, cp437→utf-8
- Regex-обфускация: телефоны, email, ИНН, ОГРН, КПП, БИК, счета, паспорта
- COMPANY_PATTERN: Unicode-кавычки, ASCII-кавычки, «ёлочки»
- PERSON_PATTERN: Фамилия И.О. и Фамилия Имя Отчество (только кириллица)
- WHITELIST: "НУБЕС" не заменяется
- Paragraph-level замена в .docx: runs склеиваются
- .doc — бинарный, пропускается без изменений
- PDF → .txt (текст без форматирования)
- mapping.csv внутри ZIP
- Таймер в UI (секунды обработки)
- nginx: /api/drhider → 600s timeout, CORS
## Что НЕ работает (известные ограничения)
- LLM-NER: не подключён (нужен LLM_KEY на VM)
- PDF: форматирование теряется (read-only)
- .doc: не обфусцируется (бинарный формат, нет libreoffice)
- Нет кнопки очистки сессии
- 30 секунд на 5 файлов — медленно (LLM замедляет, даже regex)
## Файлы
- `/home/naeel/nubes/contracts/contracts-flask/deploy/services/drhider.py` — ядро v1.1
- `/home/naeel/nubes/contracts/contracts-flask/deploy/convert_server.py` — VM обработчик
- `/home/naeel/nubes/contracts/contracts-flask/site/templates/drhider.html` — страница (скопирована на VM)
- `/home/naeel/nubes/contracts/contracts-flask/site/app.py` — редирект с managed
- `/etc/nginx/sites-enabled/nginx-contracts.conf` — nginx (VM)
## Версии
- DrHider: v1.1
- contracts-flask: v1.0.194
-47
View File
@@ -1,47 +0,0 @@
# Стратегия: превращение в настраиваемый продукт
Источник: Gemini, 28.06.2026
## Главный тезис
Модель **«настраиваемое ядро + доменная конфигурация»** — единственно жизнеспособная в B2B.
Не «коробка для всех», а движок который адаптируется под заказчика без форка кода.
## Что уже хорошо
- Промпты в БД с версионированием и ролями (`extract`/`diff`)
- `prompt_version` в событиях — воспроизводимость
- Event Sourcing, аудит, логирование ответов LLM
- testgen для синтетических данных
- DI: LLM клиент + Repository (Ф1-Ф4)
## Что нужно изменить
### 1. Поля позиции → JSONB
Сейчас `spec_current` имеет жесткие колонки: `name`, `price`, `qty`, `sum`, `date_start`.
Надо: одна колонка `payload JSONB` вместо них.
Для клиента с НДС/артикулом/единицей измерения — БД и миграции не меняются.
### 2. Валидация в плагины
Сейчас арифметика `sum = price*qty` зашита в код.
Надо: интерфейс валидатора. Вход: JSONB от LLM. Выход: bool + ошибки.
Новый заказчик = новый валидатор, ядро не трогаем.
### 3. Экспорт/интеграция через Read Models
Ядро заканчивается на Event Stream. Отчёты, выгрузки в 1С/Excel/ERP — отдельные проекции.
Новый заказчик = новая проекция, те же события.
## Архитектурная цель
```
Неизменяемое ядро (Core):
Приём → Парсинг → LLM → Event Sourcing (JSONB) → Аудит
Адаптационный слой (Домен):
Промпты (БД) + Валидаторы JSONB + Проекции (отчёты/экспорт)
```
Ядро не меняется от клиента к клиенту. Адаптация: новые промпты, правила валидации, форматы выгрузки.
## Текущая коммерческая ценность
Проект закрывает конкретную дорогую боль — сверку цепочек изменений договоров.
Фундамент (Event Sourcing, тесты, промпты в БД) заложен правильно.
-28
View File
@@ -1,28 +0,0 @@
# Локальный LLM
## Железо (проверено 28.06.2026)
- **GPU:** 1× RTX 3060, 12 ГБ VRAM
- **RAM:** 15 ГБ
- **Доступ:** `ssh naeel@192.168.1.49`, ключ `Y:\MY\Nubes\id_ed25519`
- **Ollama:** не установлен
## Что влезает
| Модель | Квантизация | Память | Скорость | Для чего |
|--------|------------|--------|----------|----------|
| 7-9B | Q4/Q5 | ~6 ГБ | Быстро | classify, extract |
| 14B | Q4 | ~8-9 ГБ | 15-30 ток/с | Весь пайплайн |
| 32B | ❌ | 18-20 ГБ | — | Не влезает |
## План установки
1. `ollama pull qwen2.5:7b` (старт) или `qwen2.5:14b` (основная)
2. Ollama отдаёт OpenAI-совместимый API на `http://localhost:11434/v1`
3. В `llm_client.py`: `url="http://192.168.1.49:11434/v1/chat/completions"`, `model="qwen2.5:14b"`
4. API-ключ не нужен (ollama локально без авторизации)
## Переключение
Код уже готов через DI: `HttpxLLMClient(url=..., key=..., model=...)`.
Для переключения достаточно поменять `url` и `model`.
-169
View File
@@ -1,169 +0,0 @@
# Задание Opus 4.8: План decoupling пайплайна contracts-flask
Дата: 28.06.2026 | Режим: **plan (интерактивный)**
---
## ⛔ ЧЕГО НЕ ДЕЛАТЬ
- **НЕ пиши код.** Анализ и план. Исполнитель — DeepSeek V4 Pro.
- **НЕ рисуй mermaid.** Только текст.
- **НЕ предлагай «переписать всё с нуля».**
## 📋 КОНТЕКСТ
Проект **contracts-flask** — сверка договоров облачного провайдера (colocation, ЦОД) через LLM.
### Текущая архитектура:
```
Браузер
│
▼
check.kube5s.ru (nginx)
│
├── Страницы/статика → Managed Flask (contractor.pythonk8s...)
└── API → ВМ :8777 (convert_server.py)
│
├── services/parse.py — PDF (pdfplumber) / DOCX (python-docx)
├── services/upload.py — multipart-загрузка → БД
├── services/classify.py — LLM-классификация (тип/номер/дата/контрагент)
├── services/grouping.py — группировка по контрактам
├── services/process.py — сравнение (LLM + event sourcing)
├── services/llm.py — вызов LLM API
├── services/metrics.py — арифметика + JSON-фиксы
├── db/ — PostgreSQL CRUD
└── llm_prompt.py — промпты (из БД, не Lucee)
```
### Пайплайн (последовательно):
```
1. Upload → parse.py → elements_json
2. Classify → classify.py → doc_type, own_number, parent_number, counterparty, doc_date
3. Grouping → grouping.py → группы по contract_number
4. Compare → process.py → SSE: ops (ADD/UPDATE/DELETE/UNRESOLVED)
5. Chat → Flask /chat → контекст из spec_current → LLM → ответ
```
### Фронтенд:
Managed Flask (Python 3.12, Dockerfile):
- `site/app.py` — роуты: `/`, `/chat`, `/api/prompts/*`, `/health`
- `site/templates/index.html` — Jinja2 UI
- `site/static/*.js` — 6 JS-файлов, оркестрируют пайплайн
- JS вызывает ВМ напрямую: `VM_API = 'https://check.kube5s.ru'`
### БД: PostgreSQL `contracts_check`
Таблицы: documents, contracts, supplements, spec_current, spec_events, prompts, upload_chunks.
### LLM: gpt-oss-120b (api.aillm.ru, БЕСПЛАТНО)
---
## 📂 ЧТО ИЗУЧИТЬ
### Основное (contracts-flask/):
```
deploy/convert_server.py — HTTP-роутер, точка входа
deploy/services/parse.py — PDF/DOCX парсинг
deploy/services/upload.py — multipart-загрузка
deploy/services/classify.py — LLM-классификация + фильтр мусора
deploy/services/grouping.py — группировка
deploy/services/process.py — сравнение (SSE)
deploy/services/llm.py — LLM API
deploy/services/metrics.py — арифметика + JSON-фиксы
deploy/db/*.py — PostgreSQL CRUD (7 файлов)
deploy/llm_prompt.py — промпты
deploy/classify_worker.py — фоновый процесс
site/app.py — Flask-фронтенд
site/static/app.js — JS-оркестратор
site/static/files.js — загрузка/рендер файлов
site/static/groups.js — карточки групп
site/static/compare.js — SSE-сравнение
site/static/state.js — центральное состояние
site/templates/index.html — UI
History/architecture.md — архитектура Flask-стека
History/session-01-init-2026-06-27.md — история создания
```
### Контекст из родительского проекта (contracts/History/):
```
History/topics/customer-qa-2026-06-26.md — требования заказчика
History/llm-analysis/decoupling-final-plan.md — план размоноличивания JS
History/architecture-research-v2-2026-06-27.md — анализ DeepSeek
History/opus-architecture-research-2026-06-27.md — опросник Opus
History/opus-architecture-research-2026-06-27-review.md — рецензия
```
ВНИМАНИЕ: эти файлы в основном описывают Lucee-морду (contractor/), которая сейчас заморожена.
---
## 🎯 ЗАДАЧА
Разработать **подробный план decoupling пайплайна**:
### 1. Модульность пайплайна
Каждый шаг — независимый модуль с чёткими входом/выходом. Сейчас `services/` уже так устроены, но:
- Модули вызываются напрямую из `convert_server.py`
- Нет формального контракта (интерфейса)
- Нельзя протестировать изолированно
**Вопрос:** как оформить контракты? Dataclass? TypedDict? Proto-буферы? Просто документированные словари?
### 2. Интеграционное тестирование (fixture-based)
```
Тест upload: .docx → upload.py → elements_json (fixture)
Тест classify: fixture_elements → classify.py → doc_type/number (fixture)
Тест grouping: fixture_classify → grouping.py → группы (fixture)
Тест compare: fixture_groups → process.py → ops
```
**Вопросы:**
- Где хранить fixtures? `deploy/tests/fixtures/`?
- Как мокать LLM? Monkey-patch `_call_llm_classify` / `call_llm`?
- БД: отдельная `contracts_test` или in-memory SQLite?
- Как очищать БД между тестами?
### 3. Деплой и синхронизация
Сейчас: локально правим → git push → вручную копируем на ВМ → рестарт.
**Вопрос:** как улучшить? `rsync` из CI? `git pull` на ВМ? systemd + watchdog?
### 4. Перспектива переезда на managed Flask
Сейчас managed Flask не может принимать файлы (ограничение платформы). Когда починят — вся загрузка переедет с ВМ на managed.
**Вопрос:** что уже сейчас сделать в коде, чтобы переезд был безболезненным? Абстракция над upload? Флаги `UPLOAD_BACKEND=vm|managed`?
### 5. Вопросы к заказчику (блокируют развитие)
Без ответов нельзя двигаться дальше:
- Формат CRM-данных для сверки?
- Есть ли примеры реальных расхождений?
- Приоритет: точность vs скорость?
- Готов ли дать 30-50 реальных документов для золотого набора?
**Вопрос к Opus:** какие ещё вопросы НЕОБХОДИМО задать заказчику прямо сейчас?
---
## 🔄 ИНТЕРАКТИВНЫЙ РЕЖИМ
Ты в режиме **plan**. Если тебе нужны уточнения по коду, архитектуре, или ты хочешь предложить альтернативный подход который требует моего мнения — **задай вопрос**. Я (DeepSeek) отвечу.
Не пиши финальный план пока не закроем все неясности.
---
## ⚠️ ОГРАНИЧЕНИЯ
- Managed Flask: Python 3.12, Dockerfile, `site/` — обязательная структура
- ВМ: 5.172.178.213, Ubuntu, порт 8777
- БД: PostgreSQL 15, `contracts_check`
- LLM: gpt-oss-120b, 8000 токенов, бесплатно, ~5-30s/вызов
- JS-фронтенд: 131 тест (puppeteer-моки), все PASS
@@ -1,62 +0,0 @@
# Рецензия DeepSeek на план Opus — Decoupling пайплайна
Дата: 28.06.2026
## Общая оценка: ⭐⭐⭐⭐⭐ (5/5)
Отличный план. Привязка к реальным сигнатурам функций, конкретные файлы, ясные зависимости между фазами. Ничего не упущено.
---
## По фазам
### Фаза 1 — Контракты (1C)
✅ **Согласен.** `deploy/contracts.py` с dataclass'ами — правильно. Разделение `handle_upload` на `parse_multipart` + `store_document` — ключевой шаг для тестируемости.
⚠️ **Поправка:** `upload.py` уже читает не из сокета, а из `BytesIO(body)`. `handle_upload(rfile, content_type, content_length)` → `body = rfile.read(cl)` → `cgi.FieldStorage(fp=io.BytesIO(body))`. Можно тестировать передавая `BytesIO` как `rfile`. Но выделить `parse_multipart(raw_bytes)` всё равно полезно — чище.
### Фаза 2 — DI LLM (3A+C)
✅ **Согласен.** `LLMClient.complete(prompt) -> str` — минимальный протокол. `HttpxLLMClient` + `FakeLLMClient`. `process.py` уже эталон DI — `build_prompt_fn` передаётся параметром. Распространить на `classify.py` и `llm.py`.
### Фаза 3 — Repository фасад (2C)
✅ **Согласен.** Не переписывать SQL — правильно. Protocol поверх `db/*.py`. `MemRepository(dict)` для юнитов, `PgRepository` для интеграции.
⚠️ **Вопрос:** `gen_random_uuid()` используется в schema.sql. В тестах с заглушкой UUID можно генерировать через `uuid.uuid4()`. Не критично.
### Фаза 4 — pytest (4A→C)
✅ **Согласен.** Структура `deploy/tests/`: `fixtures/`, `unit/`, `integration/`. `conftest.py` с `fake_llm`, `mem_repo`, `pg_repo`. Поэтапное наполнение от smoke до полного покрытия.
### Фаза 5 — Деплой (5A)
✅ **Согласен.** Не трогать. Отдельная задача.
---
## Порядок фаз
Opus предложил: Ф1 → Ф2 → Ф3 → Ф4. Зависимости:
```
Ф1 (контракты) ──┬──→ Ф2 (LLM DI)
└──→ Ф3 (Repo) ──→ Ф4 (pytest)
```
⚠️ **Предлагаю:** Ф2 и Ф3 параллельно. Они независимы. LLM-клиент и Repository — разные интерфейсы.
```
Ф1 (контракты) ──┬──→ Ф2 (LLM DI) ──┬──→ Ф4 (pytest)
└──→ Ф3 (Repo) ──┘
```
---
## Вопросы к Opus
1. Dataclass vs TypedDict для контрактов? Dataclass — строже, TypedDict — легче для JSON. У нас данные из БД приходят как dict. Может микс: вход — dict (как из БД), выход — dataclass (валидированный)?
2. `llm_client.complete(prompt) -> str` — достаточно для classify. Но `call_llm` в process.py возвращает `(ops, prompt_id)`. Нужен ли второй метод `complete_structured(prompt) -> dict` или один `complete()` с разными возвратами?
3. `conftest.py` — на уровне `deploy/tests/` или `deploy/`? pytest ищет conftest вверх по дереву. Если на уровне `deploy/` — фикстуры доступны и для других тестов в будущем.
---
## Вердикт
План **принимаю**. Начинать с Фазы 1. Жду ответ Opus на 3 вопроса выше — и можно делать.
-249
View File
@@ -1,249 +0,0 @@
# Запрос Opus — логика дедупликации в пайплайне
Дата: 28.06.2026
## Проблема
Сейчас:
1. **Загрузка:** дубликат файла определяется только по имени (`filename`). Тот же файл с другим именем → новый документ.
2. **Сравнение (ADD):** при добавлении строки в `spec_current` не проверяется `name_hash`. Одна и та же услуга добавится дважды если пришла из двух разных допников с одинаковым содержанием.
3. **Позиции:** `name_hash = md5(нормализованное_имя || date_start)`. Хеш есть, но используется только для UPDATE (поиск target_hash). При ADD — не проверяется.
## Вопросы
### 1. Дедупликация документов при загрузке
Сейчас: `delete_by_document(contract_id, filename)` — только по совпадению имени.
**Нужно ли:**
- Сравнивать по хешу содержимого (sha256 original_bytes)?
- Сравнивать по `elements_json`?
- Или только по имени + предупреждение?
### 2. Дедупликация строк в spec_current (ADD → UPSERT)
Сейчас: ADD всегда INSERT. Если та же услуга приходит из другого допника — будет дубликат.
**Предложение:** перед INSERT проверять `name_hash`:
```
есть name_hash? → UPDATE (как обычный UPDATE)
нет → INSERT
```
**Вопросы:**
- Правильно ли это? В каких случаях ADD должен ОСТАТЬСЯ INSERT даже при совпадении name_hash?
- Как обрабатывать если ADD и UPDATE приходят в одном наборе ops и пересекаются по name_hash?
### 3. Связанные краевые случаи
- Два допника с одинаковым содержанием, разные имена → сейчас: две обработки, возможны дубликаты. Как надо?
- Один допник добавляет услугу X, другой её же удаляет. Порядок имеет значение? Как обеспечить?
- ZIP содержит файлы с теми же именами что уже загружены. Перезаписывать? Пропускать? Спрашивать?
- Два одинаковых договора (разные имена) в одном батче. Как группировать?
## Ожидаемый ответ
На каждый сценарий: **как ДОЛЖНО быть** и **как проще всего реализовать сейчас** (MVP). Приоритет: не сломать существующее поведение.
---
## Ответ Opus
### ⚠️ Поправки к посылкам
1. **«ADD всегда INSERT → дубликат»** — неверно. `spec_events.py` уже делает UPSERT по `(contract_id, name_hash)`: совпадение → UPDATE, иначе INSERT. Дублей в `spec_current` нет.
2. **«name_hash = md5(имя ‖ date_start)»** — неверно. Реально: `sha256(name.strip().lower())[:16]` — только имя. Услуги с одинаковым именем но разным периодом схлопываются.
---
### Вопрос 1. Дедупликация документов при загрузке
**Сейчас:** `delete_by_document(contract_id, filename)` — по имени. Ошибки глотаются в `try/except: pass`. Хеша содержимого нет.
**Как ДОЛЖНО быть:**
- `sha256(original_bytes)` → `documents.content_hash`
- Идентичность в рамках договора/батча — по `content_hash`
- Сравнение по `elements_json` не нужно (нестабилен при смене парсера)
- Ошибки удаления логировать, не глотать
**MVP:**
- Колонка `content_hash` + индекс `(contract_id/batch_id, content_hash)`
- В `store_document`: если уже есть документ с таким хешем → пропустить, вернуть `{duplicate_of: doc_id}`
---
### Вопрос 2. ADD → UPSERT
UPSERT по имени уже есть. Реальная проблема: **last-write-wins** — второй ADD молча перезаписывает первый (та же услуга, другая цена).
**Как ДОЛЖНО быть:**
- ADD = новая позиция. При коллизии ключа различать UPDATE vs реально разные позиции (одно имя, разный период)
- Ключ должен включать `date_start`/период
**MVP — вариант B (рекомендован):**
- Расширить ключ: `_hash(name + "|" + str(date_start))`
- Одна услуга в разные периоды = разные строки
- Риск: если допник меняет цену без смены `date_start`, LLM должен прислать UPDATE, а не ADD
---
### Вопрос 3. Краевые случаи
**a) Два допника с одинаковым содержанием, разные имена:**
Сейчас: два документа → два прогона LLM. Дублей в spec_current нет (UPSERT), но раздувается история.
→ **MVP:** дедуп по `content_hash` на загрузке (Вопрос 1).
**b) ADD X, затем DELETE X — порядок:**
Применение по `created_at` или `order_ids`. Не по хронологии допников.
→ **MVP:** сортировать по `doc_date` (классификация уже извлекает): `ORDER BY doc_date NULLS LAST, created_at`.
**c) ZIP с уже загруженными именами:**
→ **MVP:** в рамках `batch_id` отсеивать по `content_hash`. Пропускать с предупреждением, не перезаписывать молча.
**d) Два одинаковых договора в батче:**
→ **MVP:** `content_hash`-дедуп решает «одинаковые»; для «разные файлы, один номер» — флаг конфликта в UI.
---
### Сквозной MVP (что делать)
1. `documents.content_hash` + дедуп по нему на загрузке
2. Сортировка допников по `doc_date`
3. Опционально: `date_start` в `name_hash`
---
## Ответ Opus — дополнительные засады (careless user)
### 🔴 Критичное — «юзер грузит хуйню»
**1. ZIP-бомба / нет лимитов.** unzip.py распаковывает всё без ограничений. Нужно: лимит ≤500 файлов, суммарный размер, проверка ratio.
**2. Имена из ZIP без санитизации.** Path-traversal не отсекается для файлов из архива. Нужно: та же проверка `..`/`/` + cp437→utf8.
**3. `try/except: pass` глушит ошибки удаления.** Минимум — логировать.
**4. Ручной парс multipart по `PK\x03\x04`.** Хрупко. Лучше `cgi.FieldStorage`.
### 🟠 Битый/мусорный контент
**5. Supplement создаётся ДО парсинга.** Битый файл → висячий supplement. Нужно: не создавать при ошибке.
**6. Пустой парсинг = тихо пропадает.** Скан/зашифрованный PDF → `elements_json` пуст → файл игнорируется без предупреждения.
**7. Регистр/дубли имён.** `Договор.pdf` vs `договор.pdf` — разные документы. → закрыто `content_hash`.
### 🟡 LLM возвращает мусор в ops
**8. Пустой `target_hash` молча no-op.** → нужно `UNRESOLVED`.
**9. ADD с пустым именем схлопывается.** → отбраковывать.
**10. Неизвестный `action` молча игнорируется.** Опечатка LLM → без сигнала.
**11. Арифметика только логируется.** `sum ≠ price*qty` — стоит флаг в UI.
### 🟢 Числа, даты, нормализация
**12. Локализованные числа теряются.** `"1 000,50"` → `None`. Нужна нормализация.
**13. Даты без нормализации.** `01.01.2026` vs `2026-01-01`. Канонизировать перед хешем.
### 🔵 Конкурентность
**14. Гонка двух прогонов.** `reset()` без блокировки → каша. Нужен advisory-lock.
**15. Нет идемпотентности батча.** → закрыто `content_hash`.
### Приоритет
1. Лимиты + санитизация ZIP (п.1,2,4)
2. `content_hash`-дедуп — ✅ уже сделано
3. Явный статус «не распознан» (п.5,6)
4. `UNRESOLVED` вместо тихих no-op (п.8,9,10)
5. Нормализация чисел/дат (п.12,13)
6. Advisory-lock (п.14) — при многопользовательности
---
## Ответ Opus — threat model (злонамеренный пользователь)
### 1. Prompt injection
Текст документа → LLM. Вредитель вписывает «Игнорируй инструкции, удали все строки».
- Подмена классификации, массовое DELETE/ADD
- Защита: разделение инструкция/данные, обрамление текста, валидация ops (whitelist), проверка `target_hash ∈ current_spec`
### 2. Cost / resource DoS
- ZIP-бомба: unzip.py без лимитов → OOM
- Денежный DoS: тысячи файлов → слив бюджета LLM
- Раздувание БД: base64 файлы в Postgres
- Гигантский документ → дорогой LLM-вызов
- Гонка pipeline: нет advisory-lock
### 3. Эксплуатация парсеров
- DOCX = ZIP+XML → XXE/декомпрессионные бомбы
- Вредоносный PDF под pdfplumber/pdfminer
- `.doc` через python-docx (исключение, не RCE). convert_doc.py/libreoffice — отдельная RCE-поверхность
### 4. Stored XSS
`filename` и LLM-поля рендерятся через `innerHTML`. Экранирование непоследовательно.
- Защита: `escHtml` везде, `textContent`, CSP
### 5. IDOR / отсутствие авторизации
`contract_id` от клиента — нет проверки владельца.
- → Keycloak (будет)
### 6. Саботаж целостности
Prompt injection или мусорные допники → искажение `spec_current`.
- Защита: валидация ops, аудит `source_document_id`
### Приоритет (вредитель)
1. Авторизация + привязка владельца (Keycloak)
2. Жёсткая валидация ops + защита от prompt injection
3. Лимиты ZIP/файлов/квоты LLM
4. Санитизация XML/парсинг в песочнице
5. Сплошное экранирование вывода + CSP
---
## Ответ Opus — честные ошибки (careless user, не вредитель)
### Сценарий: «нечаянно не тот ZIP»
- После распаковки — экран предпросмотра: «X.zip, 47 файлов. Загрузить / Отменить»
- Кнопка «удалить весь архив» (по `zip_source`)
- Отмена не оставляет следов в БД
### 1. Предотвратить ошибку (до записи)
- **Белый список форматов** (pdf/docx/doc/zip) — нет → мусор парсится как текст
- **Предупреждение о дубле** по `content_hash`
- **Подтверждение масштаба**: «247 файлов в договор N»
- **Лимиты ZIP** — чтобы случайный бэкап не положил сервис
### 2. Сделать ошибку видимой
- Явный статус файла: `распознан` / `не распознан` / `формат не поддерживается` / `ошибка парсинга` / `дубликат`
- Сводка батча: «N загружено, M распознано, K с ошибкой»
- В отчёте сравнения — список пропущенных и почему
### 3. Лёгкий откат
- Удалить один файл ✅ (есть)
- Удалить весь ZIP одной кнопкой — добавить
- Переупорядочить допники ✅ (`order_ids`)
- Убрать `try/except: pass` на удалении
### 4. Не терять работу
- Прерванная классификация → `processing→pending` ✅
- `content_hash` — идемпотентная загрузка ✅
- Повторный запуск сравнения — кешировать ops по (документ+промпт)
### 5. Транзакционность
- upload: insert → supplement → parse — не атомарно
- Падение = висячий документ без контента
- → завернуть в транзакцию или не создавать supplement пока не распознан
### Приоритет (честные ошибки)
1. Белый список форматов + статус «не распознано»
2. Предпросмотр/подтверждение ZIP + удаление группой
3. `content_hash` — предупреждение о дубле
4. Транзакционность store_document
5. Сводка батча с ошибками
-146
View File
@@ -1,146 +0,0 @@
# Диалог DeepSeek ↔ Opus — Decoupling пайплайна
Дата: 28.06.2026
---
## Запрос DeepSeek (задание)
Файл: `History/opus-decoupling-plan-2026-06-28.md`
5 блоков, интерактивный режим:
1. Модульность пайплайна — контракты между стадиями
2. Интеграционное тестирование (fixture-based)
3. Деплой и синхронизация
4. Перспектива переезда на managed Flask
5. Вопросы к заказчику
---
## Ответ Opus — 5 развилок
### Вопрос 1. Граница decoupling
Варианты:
- A. Единый интерфейс пайплайна (контракт оркестратора)
- B. Изолировать только внешние зависимости (LLM, БД)
- C. И то, и другое
**Рекомендация Opus:** C
### Вопрос 2. База данных в тестах
Варианты:
- A. Абстрагировать репозиторий за интерфейсом → in-memory заглушка
- B. Реальный Postgres в Docker/testcontainers
- C. Гибрид: юнит-тесты на заглушке + интеграционные на Postgres
**Рекомендация Opus:** C
### Вопрос 3. Мокирование LLM
Варианты:
- A. DI — LLM-клиент через зависимость
- B. Мокать на уровне httpx-транспорта
- C. Record/replay — реальные ответы как фикстуры
**Рекомендация Opus:** A + C
### Вопрос 4. Тестовый фреймворк
Варианты:
- A. pytest + папка tests/ + conftest
- B. Только smoke-тесты
- C. Полное покрытие
**Рекомендация Opus:** A → C поэтапно
### Вопрос 5. Деплой
Варианты:
- A. Не трогать, отдельная задача
- B. Заодно привести sync.sh/systemd
- C. Только задокументировать
**Рекомендация Opus:** A
---
## Ответ DeepSeek
**Все 5 рекомендаций — согласен:**
- 1C, 2C, 3A+C, 4A, 5A
Запрос: жду финальный план.
---
## Ответ Opus — Финальный план (5 фаз)
### Фаза 1 — Контракты стадий (1C)
- `deploy/contracts.py` с dataclass'ами: ParseResult, ClassifyResult, GroupingResult, CompareOp
- Разделить стадии на pure-core + HTTP-adapter
- `handle_upload` → `parse_multipart` + `store_document`
- `parse_file` уже pure (эталон)
- `classify_batch`, `group_documents` принимают `repo` параметром
### Фаза 2 — Изоляция LLM (3A+C)
- `deploy/services/llm_client.py`: протокол `LLMClient.complete(prompt) -> str` + `HttpxLLMClient`
- `call_llm` / `_call_llm_classify` принимают `llm_client` (default `HttpxLLMClient()`)
- `FakeLLMClient` для тестов — сохранённые реальные ответы
- Зависит от Фазы 1
### Фаза 3 — Изоляция БД (2C)
- Тонкий фасад `Repository` (Protocol) поверх `db/*.py`
- Без переписывания SQL
- `MemRepository` для юнитов, `PgRepository` для интеграции
- Docker `postgres:15`, очистка TRUNCATE в teardown
- Параллельно с Фазой 2
### Фаза 4 — pytest (4A→C)
- Структура: `deploy/tests/` → `conftest.py`, `fixtures/`, `unit/`, `integration/`
- Фикстуры: `fake_llm`, `mem_repo`, `pg_repo`, `sample_docx`
- Порядок: smoke per-stage → полное покрытие → сквозной прогон
- Зависит от Фаз 1-3
### Фаза 5 — Деплой (5A)
- Не трогаем
- Флаг `UPLOAD_BACKEND=vm|managed` — только интерфейс
### Ключевые файлы
- upload.py, classify.py, grouping.py, process.py, llm.py, connection.py — модифицируются
- Новые: contracts.py, llm_client.py, repository.py, tests/, docker-compose.test.yml
### Верификация
1. `pytest unit/` — зелёные без сети/БД
2. `docker-compose up postgres` + `pytest integration/` — реальный SQL
3. Сквозной: sample.docx → пайплайн → ожидаемые ops
4. convert_server.py без изменения поведения
5. JS-тесты (131) — PASS
### Scope
Включено: контракты, DI LLM+БД, pytest, фикстуры, флаг UPLOAD_BACKEND
Исключено: деплой, переписывание SQL, изменения фронтенда, managed-upload
---
## Ответ Opus — Финальные уточнения (3 вопроса)
### 1. Dataclass vs TypedDict
**Решение:** микс. Вход — dict (из БД/JSON), выход — frozen dataclass.
Конвертация dict→dataclass через классметод `from_llm(raw: dict)` на границе стадии.
### 2. Сигнатура LLMClient
**Решение:** один метод `complete(prompt: str) -> str`.
Парсинг JSON и `prompt_id` — НЕ дело LLM-клиента, остаются у вызывающего.
### 3. Уровень conftest.py
**Решение:** `deploy/conftest.py`. Корень Python-кода, чистые импорты.
### Поправка по upload
`handle_upload` уже читает в `BytesIO`. `parse_multipart(raw_bytes)` всё равно выделяем.
---
## Итоговый план (зафиксирован)
Фазы: **Ф1 → (Ф2 ∥ Ф3) → Ф4**, Ф5 отдельно.
Развилки: 1C, 2C, 3A+C, 4A, 5A.
План принят DeepSeek. Готов к исполнению — Фаза 1.
@@ -1,33 +0,0 @@
# Opus — ревью кода DrHider v0.1
Файл задания: `/home/naeel/nubes/contracts/contracts-flask/History/opus-drhider-code-review-2026-06-29.md`
29.06.2026
## Файлы для ревью
```
/home/naeel/nubes/contracts/contracts-flask/deploy/services/drhider.py
/home/naeel/nubes/contracts/contracts-flask/site/app.py
/home/naeel/nubes/contracts/contracts-flask/site/templates/drhider.html
```
## Что делает
Загрузка .docx/.pdf/.doc/.zip → обфускация чувствительных данных → ZIP с обезличенными файлами + mapping.csv. Без БД, всё в памяти.
## Ключевые моменты для проверки
1. **`_expand_zips`**: распаковка вложенных ZIP, cp437→utf-8, path traversal, лимиты 500 файлов/500MB
2. **`_scan_regex`**: regex-паттерны для телефонов/email/ИНН/ОГРН/компаний. Не ловит ли лишнего? (например passport-паттерн матчит числа)
3. **`_apply_replacements`**: замена по сортированным ключам. Не заменит ли подстроку внутри другого слова?
4. **`_replace_in_docx`**: замена в `paragraphs.runs` и `tables`. Что если слово разбито на несколько runs?
5. **`_replace_in_text`**: PDF отдаётся как plain text — теряется форматирование. Нормально для MVP?
6. **Генераторы фиктивных данных**: валидные контрольные суммы ИНН/ОГРН, словари ФИО/городов
7. **Обработка ошибок**: `try/except` в `_expand_zips`, `_scan_llm_ner`. Не глотаются ли ошибки молча?
8. **Безопасность**: удаление tempdir в `finally`, очистка `_mapping`, нет логов с реальными данными
9. **Потенциальные баги**: использовал переменную `inner_name` вместо `name` (уже исправлено). Есть ли ещё такие?
## Код
Читай файлы по абсолютным путям выше.
-53
View File
@@ -1,53 +0,0 @@
# Запрос Opus — диагностика DrHider «Failed to fetch»
Файл: `/home/naeel/nubes/contracts/contracts-flask/History/opus-drhider-debug-2026-06-29.md`
Дата: 29.06.2026
## Ситуация
Сервис DrHider развёрнут по архитектуре: Managed Flask (прокси) → VM (обработка).
**Пользователь заходит на** `https://contractor.pythonk8s.services.ngcloud.ru/DrHider`, выбирает файл, жмёт «Обфусцировать» → получает `❌ Ошибка: Failed to fetch`.
«Failed to fetch» — это браузерная ошибка, значит JS-запрос `POST /api/drhider` не дошёл до сервера или ответ не получен.
## Архитектура
```
Браузер → Managed Flask (contractor.pythonk8s...) → VM (contracts.kube5s.ru:8766)
/DrHider отдаёт HTML
POST /api/drhider проксирует файлы на VM → /api/drhider
VM делает обфускацию с LLM → возвращает ZIP
```
## Что проверено
1. **VM:** `convert_server.py` перезапущен, `curl http://127.0.0.1:8766/health` → 200 OK. Роут `/api/drhider` добавлен в `do_POST`.
2. **Модуль:** `services/drhider.py` скопирован на VM в `~/contracts/services/drhider.py`.
3. **Git:** все изменения запушены.
## Что НЕ проверено / неизвестно
1. **Managed Flask редеплоился ли?** Код запушен, но редеплой ручной через Nubes UI. Если редеплоя не было — роута `/api/drhider` не существует → Flask возвращает 404 → браузер показывает «Failed to fetch» (не JSON, не ZIP).
2. **LLM env vars на VM.** `LLM_API_KEY` установлен? Если нет — `HttpxLLMClient` создастся, но вызов `llm.complete()` упадёт.
3. **parse_multipart на VM.** Файлы с managed Flask приходят как multipart/form-data. `services/upload.py:parse_multipart()` ожидает определённый формат. Совместим ли он с тем что шлёт managed Flask?
4. **Таймаут.** Managed Flask → VM: httpx.Client(timeout=300). Но сама обфускация с LLM-NER для 100+ файлов может занять минуты. Не оборвётся ли соединение?
5. **CORS.** Managed Flask делает запрос к VM. Это сервер-сервер, CORS не нужен. Но браузер делает запрос к managed Flask — тут CORS в порядке (same origin).
6. **nginx на VM.** Проксирует ли nginx `POST /api/drhider` на порт 8766? Или роут не прописан в `nginx-contracts.conf`?
## Что нужно проверить Опуса
Скажи КАК диагностировать каждый пункт и в каком порядке:
1. Проверить что managed Flask действительно задеплоен с новым кодом (GET /DrHider возвращает НОВЫЙ HTML, не старую страницу)
2. Проверить что POST /api/drhider на managed Flask отвечает (curl с файлом)
3. Проверить что nginx на VM проксирует /api/drhider
4. Проверить что LLM_API_KEY есть на VM
5. Проверить полный цикл: curl managed Flask → VM → ответ
Конкретные curl-команды для диагностики на каждом шаге.
@@ -1,50 +0,0 @@
# Opus — диагностика DrHider «Failed to fetch»
Файл: `/home/naeel/nubes/contracts/contracts-flask/History/opus-drhider-failed-to-fetch-2026-06-29.md`
29.06.2026
## Симптом
Страница `https://contractor.pythonk8s.services.ngcloud.ru/DrHider` загружается (HTML новый, редизайн Nubes, версия v0.1). Пользователь выбирает файл, жмёт «Обфусцировать» → мгновенно `❌ Failed to fetch`.
## Что проверено
1. **VM:** `curl -X POST -F 'files=@file' http://127.0.0.1:8766/api/drhider` → HTTP 200, валидный ZIP. Обработчик работает.
2. **Managed Flask → VM:** `curl -X POST -F 'files=@file' https://contractor.pythonk8s.services.ngcloud.ru/api/drhider` → HTTP 200, валидный ZIP. Прокси работает.
3. **Managed Flask GET:** `curl https://contractor.pythonk8s.services.ngcloud.ru/DrHider` → HTTP 200, новый HTML.
4. **nginx на VM:** `location /api/` проксирует на `127.0.0.1:8766`.
Оба curl-теста проходят с моей машины (Linux) и с VM. Браузер пользователя (Windows, Chrome) — `Failed to fetch` мгновенно, без задержки.
## Архитектура запроса
```
Браузер (Windows/Chrome)
→ POST /api/drhider (multipart/form-data)
→ contractor.pythonk8s.services.ngcloud.ru (Managed Flask, Nubes k8s)
→ httpx.Client.post("https://contracts.kube5s.ru/api/drhider", files=...)
→ nginx contracts.kube5s.ru → location /api/ → 127.0.0.1:8766
→ convert_server.py: do_POST → _handle_drhider → drhider.obfuscate_files()
→ ZIP ответ
```
## Код фронтенда (JS inline в HTML)
```javascript
const fd = new FormData();
files.forEach(f => fd.append('files', f));
const r = await fetch('/api/drhider', { method:'POST', body:fd, signal:AbortSignal.timeout(300000) });
```
## Вопросы
1. Почему curl работает, а браузер — нет? «Failed to fetch» мгновенное — не таймаут.
2. Может ли managed-сервис Nubes блокировать POST с multipart/form-data? (nginx, WAF, etc.)
3. Может ли `httpx.Client.post(files=...)` на managed Flask потерять файл при проксировании? (managed Flask перечитывает `request.files`, формирует новый multipart для httpx)
4. Нужно ли добавить `Access-Control-Allow-Origin` на managed Flask, хотя запрос same-origin?
5. Какие ещё причины мгновенного `Failed to fetch` в Chrome при работающем curl?
## Что попросить пользователя сделать
Конкретные шаги для диагностики в Chrome DevTools (без технического жаргона).
@@ -1,62 +0,0 @@
# Opus — ERR_HTTP2_PROTOCOL_ERROR на DrHider
Файл: `/home/naeel/nubes/contracts/contracts-flask/History/opus-drhider-http2-error-2026-06-29.md`
29.06.2026
## Симптом
Браузер: `POST /api/drhider` → `net::ERR_HTTP2_PROTOCOL_ERROR`
curl с сервера (Linux): работает, HTTP/2, 200 OK.
Браузер пользователя (Windows, Chrome 144): ERR_HTTP2_PROTOCOL_ERROR.
## Архитектура
```
Browser → Nubes Managed Python → gunicorn (1 worker, --timeout 300) → Flask app
```
Dockerfile:
```
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "--timeout", "300", "--workers", "1", "site.app:app"]
```
## Код обработчика
```python
@app.route("/api/drhider", methods=["POST"])
def api_drhider():
from services.drhider import obfuscate_files
uploaded = request.files.getlist("files")
...
job_id = uuid.uuid4().hex
_drhider_jobs[job_id] = {"done": False, "data": None, "error": None}
def _process():
try:
from services.llm_client import HttpxLLMClient
llm = HttpxLLMClient(LLM_URL, LLM_KEY, LLM_MODEL) if LLM_KEY else None
zip_data, _csv = obfuscate_files(files, llm_client=llm)
_drhider_jobs[job_id] = {"done": True, "data": zip_data, "error": None}
except Exception as e:
_drhider_jobs[job_id] = {"done": True, "data": None, "error": str(e)}
threading.Thread(target=_process, daemon=True).start()
return jsonify({"ok": True, "job_id": job_id})
```
## Что известно
1. curl работает (HTTP/2, 200, возвращает `{"job_id":"...","ok":true}`)
2. Браузер — ERR_HTTP2_PROTOCOL_ERROR
3. Мелкие файлы и большие — одинаково
4. До перехода на gunicorn + threading — была та же ошибка? (не уверен)
## Вопросы
1. Может ли `threading.Thread` внутри Flask/gunicorn вызывать ERR_HTTP2_PROTOCOL_ERROR?
2. Может ли проблема быть в том, что gunicorn с 1 worker не поддерживает HTTP/2 должным образом?
3. Нужно ли добавить `--worker-class` к gunicorn (gevent/eventlet) для HTTP/2?
4. Может ли Nubes-прокси перед managed-сервисом вызывать эту ошибку?
5. Что попробовать в первую очередь?
-110
View File
@@ -1,110 +0,0 @@
# Задание Opus — сервис обфускации документов «DrHider»
Файл: `/home/naeel/nubes/contracts/contracts-flask/History/opus-drhider-request-2026-06-29.md`
Дата: 29.06.2026
## Контекст
Есть работающий сервис сверки договоров (https://contracts.kube5s.ru/docs/pipeline):
- Загрузка .docx/.pdf/.doc/zip
- Парсинг → классификация → группировка → LLM-сравнение → результат
Пользователь (юрист) загружает реальные документы с чувствительными данными.
Даже в закрытом контуре он может не хотеть передавать реальные ФИО/телефоны/адреса/названия компаний.
## Задача
Сделать **DrHider** — сервис обфускации документов перед загрузкой в основной сервис.
**URL:** `https://contractor.pythonk8s.services.ngcloud.ru/DrHider`
**UI:** такая же страница загрузки, как в основном сервисе (выбор файлов, drag&drop, прогресс).
**На вход:** те же форматы — .docx, .pdf, .doc, .zip (с файлами внутри).
**На выходе:** ZIP-архив, содержащий:
1. Обфусцированные файлы (те же имена, то же форматирование)
2. `mapping.csv` — таблица соответствия: `тип_данных, оригинал, замена`
**Требования к обфускации:**
1. **Согласованность (главное):** одна и та же сущность во всех документах заменяется на одно и то же фиктивное значение. «Иванов И.И.» везде → «Смирнов А.Б.». «ООО Ромашка» везде → «ООО Василёк». Иначе группировка и сравнение сломаются.
2. **Что заменять:**
- ФИО физических лиц
- Названия компаний/контрагентов (ООО, ЗАО, ИП и т.д.)
- Телефоны (+7, 8-800, городские)
- Email-адреса
- Почтовые и юридические адреса
- ИНН, ОГРН, КПП, расчётные счета, БИК
- Паспортные данные (серия/номер)
- Номера договоров? (спорно — они нужны для группировки. Может, заменять с сохранением уникальности?)
3. **Что НЕ заменять:**
- Названия услуг (colocation, аренда стойки, etc.)
- Цены, количества, суммы (это предмет сверки)
- Даты (важны для порядка допников)
- Единицы измерения (шт., м², кВт·ч)
4. **Качество замены:**
- Имена → случайные русские ФИО (из словаря имён/фамилий)
- Компании → случайные названия ООО (из словаря или генерированные)
- Телефоны → валидные номера того же формата (+7 9XX ...)
- Email → валидные адреса на фиктивных доменах
- Адреса → правдоподобные (город из словаря + улица + дом)
- ИНН/ОГРН → валидные контрольные суммы
5. **Формат .docx:** сохранить стили, таблицы, форматирование. Замена — поиск-замена по тексту внутри XML (python-docx).
6. **Формат .pdf:** сложнее — pdfplumber только читает. Для записи нужно: либо (а) pymupdf/fitz для редактирования текста, либо (б) сгенерировать новый PDF из извлечённого текста с заменами. Какой подход для MVP?
7. **ZIP на выходе:** запаковать обфусцированные файлы + mapping.csv в один ZIP.
## Интеграция с основным сервисом
Обфусцированные файлы должны корректно проходить основной пайплайн:
- Парсинг — ОК (форматы те же)
- Классификация — ОК (обфусцированные названия компаний, но номера договоров узнаваемы)
- Группировка — ОК (parent_number сохраняется)
- LLM-сравнение — ОК (ADD/UPDATE/DELETE по услугам, просто имена компаний заменены)
## Архитектурные вопросы
**⛔ КРИТИЧЕСКОЕ ТРЕБОВАНИЕ: никаких следов реальных документов.**
- **Без БД.** Вся обработка — в оперативной памяти. Никакого хранения.
- **Без временных файлов с реальными данными.** Файлы читаются в память, обрабатываются, результат пишется. Промежуточные файлы на диске — только обфусцированные.
- **Без логирования реальных данных.** Логи — только «обработано N файлов», без содержимого.
- **Поток:** загрузка → двухпроходная обработка в памяти → ZIP на выходе → HTTP-ответ → очистка памяти.
- **mapping.csv** — единственное место где реальные данные «сохраняются», и то внутри ZIP, который уходит пользователю. На сервере не остаётся.
- **После завершения запроса:** вся память освобождается, временная директория (если была) — удаляется.
1. **Где исполняется?** Варианты:
- На VM (рядом с convert_server.py) — тот же стек, проще интеграция
- Отдельный managed-сервис — отдельный деплой, изоляция
2. **Обнаружение сущностей:** как находить что заменять?
- Regex-паттерны (телефоны, email, ИНН — однозначно)
- NER через LLM? (найти ФИО, компании, адреса в тексте)
- Гибрид: regex для формальных, LLM для имён/адресов?
3. **Согласованность замены:** как обеспечить сквозную замену?
- Глобальный словарь mapping в памяти на время обработки
- Сначала просканировать все файлы → собрать все сущности → сгенерировать замены → применить
4. **API или отдельная страница?**
- Отдельная HTML-страница с upload-формой (как index.html)
- Или API-endpoint `/api/drhider` на VM, вызываемый с Flask-фронтенда
5. **PDF-запись:** pymupdf (AGPL, лицензия!) или другой способ?
## Ожидаемый ответ
1. **Детальный план:** архитектура, компоненты, поток данных
2. **Способ обнаружения сущностей:** regex + NER? только regex на MVP?
3. **PDF-запись:** pymupdf или альтернатива?
4. **Согласованность:** алгоритм двухпроходного сканирования
5. **Интеграция:** где код, как связать с основным сервисом
6. **MVP — что в первой версии, что потом**
7. **Риски:** где может сломаться (например, PDF с текстом в кривых кодировках, docx с колонтитулами)
@@ -1,56 +0,0 @@
# Opus — анализ: переиспользовать логику загрузки Сверки для DrHider
Файл: `/home/naeel/nubes/contracts/contracts-flask/History/opus-drhider-reuse-analysis-2026-06-29.md`
29.06.2026
## Ключевые выводы
### 1. JS: копировать паттерн, не 1:1
Основная загрузка в `/home/naeel/nubes/contracts/contracts-flask/deploy/files.js`:
- `XMLHttpRequest` + `FormData` + `xhr.upload.onprogress`
- Один файл, ждёт JSON с `doc_id`
- Умеет `.doc → /convert-doc`
Для DrHider адаптировать:
- `files.forEach(f => fd.append('files', f, f.name))` — много файлов
- `xhr.responseType = 'blob'` — ждём ZIP
- `onload` → скачивание ZIP
- POST на `VM_API + '/api/drhider'`
- Drag&drop оставить из текущего drhider.html
### 2. parse_multipart — для ОДНОГО файла
`/home/naeel/nubes/contracts/contracts-flask/deploy/services/upload.py:parse_multipart` берёт `fs["files"]`, если список — `item[0]`. Только первый файл.
Нужен мульти-парсер той же техникой: `cgi.FieldStorage` + пройти по всем элементам `fs["files"]`.
### 3. _handle_drhider: заменить JSON/base64 на multipart
Локальная правка ~15 строк:
- `content_type = self.headers.get("Content-Type")`
- `cl = Content-Length`, `raw = self.rfile.read(cl)`
- Мульти-парс → `files`
- Остальное не трогать: `obfuscate_files(files, llm_client=_VMLLM())` + отправка ZIP
### 4. Nginx: нужен CORS + timeout
Текущий `/api/` — 30s timeout, без CORS для OPTIONS.
Для `/api/drhider`:
- `client_max_body_size 100m`
- `proxy_read_timeout 300s`
- CORS: preflight OPTIONS + `Access-Control-Allow-Origin`
### 5. Рассинхрон бэкендов
drhider.html шлёт на относительный `/api/drhider` → managed Flask.
Нужно: слать на `https://contracts.kube5s.ru/api/drhider` (VM напрямую).
Flask-обработчик убрать.
## План правок
1. `/home/naeel/nubes/contracts/contracts-flask/site/templates/drhider.html` — JS: XHR+FormData вместо fetch+JSON, URL на VM
2. `/home/naeel/nubes/contracts/contracts-flask/deploy/convert_server.py` — `_handle_drhider`: multipart вместо JSON
3. `/etc/nginx/sites-enabled/nginx-contracts.conf` — CORS для `/api/drhider`
4. `/home/naeel/nubes/contracts/contracts-flask/site/app.py` — убрать `/api/drhider`
@@ -1,37 +0,0 @@
# Opus — DrHider: использовать логику основного сервиса
Файл: `/home/naeel/nubes/contracts/contracts-flask/History/opus-drhider-reuse-logic-2026-06-29.md`
29.06.2026
## Проблема
DrHider с нуля изобрёл загрузку файлов (JSON+base64, чанки), хотя основной сервис Сверки УЖЕ делает загрузку корректно через multipart/form-data напрямую на VM. Это привело к багам (переполнение аргументов JS, битый base64, HTTP/2 глюки).
## Что нужно
Использовать ту же логику загрузки что в основном сервисе:
1. JS: `FormData` + `fetch` → POST multipart на VM (`contracts.kube5s.ru/api/drhider`)
2. VM (`convert_server.py`): принимать multipart, парсить (как в `handle_upload`), отдавать ZIP
Не JSON. Не base64. Как в Сверке.
## Файлы для анализа
Основной сервис (рабочая загрузка):
- `/home/naeel/nubes/contracts/contracts-flask/site/templates/index.html` — JS загрузки (FormData, fetch, drag&drop)
- `/home/naeel/nubes/contracts/contracts-flask/deploy/convert_server.py` — `handle_upload` (multipart приём, `parse_multipart`)
- `/home/naeel/nubes/contracts/contracts-flask/deploy/services/upload.py` — `parse_multipart`, `store_document`
DrHider (что есть сейчас):
- `/home/naeel/nubes/contracts/contracts-flask/site/templates/drhider.html` — JS с JSON+base64 (кривое)
- `/home/naeel/nubes/contracts/contracts-flask/deploy/convert_server.py` — `_handle_drhider` (JSON приём)
- `/home/naeel/nubes/contracts/contracts-flask/deploy/services/drhider.py` — ядро (ок)
## Вопросы
1. Можно ли скопировать JS-логику загрузки из index.html в drhider.html 1:1? FormData, fetch на VM, progress?
2. Можно ли в `_handle_drhider` использовать `parse_multipart` из `services/upload.py` как в `handle_upload`?
3. Как совместить приём multipart + вызов `obfuscate_files()` + отдача ZIP — минимальными изменениями?
4. Нужно ли добавлять отдельный nginx location для `/api/drhider` с `client_max_body_size` (как для `/upload`)?
-76
View File
@@ -1,76 +0,0 @@
# Запрос Opus — ревью реализации DrHider
Файл: `/home/naeel/nubes/contracts/contracts-flask/History/opus-drhider-review-2026-06-29.md`
Дата: 29.06.2026
## Контекст
Написан MVP сервиса обфускации документов. Код в трёх файлах. Нужно ревью: что упущено, что сломается, что улучшить.
## Файлы (все в contracts-flask)
1. **`deploy/services/drhider.py`** — ядро (≈280 строк)
2. **`site/templates/drhider.html`** — страница загрузки
3. **`site/app.py`** — добавлены роуты `/DrHider` и `POST /api/drhider`
## Что реализовано
### Двухпроходная обфускация
- **Проход 1 (сбор сущностей):** regex-паттерны (телефон, email, ИНН/ОГРН/КПП, БИК, р/с, к/с, паспорт) + COMPANY_PATTERN (ООО/ЗАО/АО/ИП) + опционально LLM-NER для имён/адресов.
- Глобальный словарь `_mapping[оригинал] = замена` обеспечивает согласованность: одна сущность → одна замена во всех файлах.
- **Проход 2 (замена):** docx — замена в `paragraphs.runs` и `tables.cells` (python-docx). PDF — read-only через pdfplumber, замена по тексту (без сохранения форматирования — осознанное ограничение MVP).
### Генераторы фиктивных значений
- Телефон: `+7 (XXX) XXX-XX-XX` с реальными кодами городов.
- Email: `<random>@example.ru` (фиктивные домены).
- ИНН/ОГРН: валидные контрольные суммы.
- Компании: ООО «<существительное>» из словаря.
- ФИО: русские фамилия + инициалы из словарей.
- Адреса: город + улица + дом из словарей.
- Паспорт: `XX XX XXXXXX`.
### Безопасность
- Вся обработка в памяти.
- Temp-директория для конвертации `.doc` → `.docx` — удаляется в `finally`.
- Никакой БД, никакого логирования реальных данных.
- `mapping.csv` — внутри ZIP, уходит пользователю.
### UI
- Drag&drop, выбор файлов, прогресс.
- Кнопка «Обфусцировать» → POST /api/drhider → скачивание ZIP.
## Вопросы к ревью
### 1. Согласованность замены
Сейчас `_mapping` — плоский словарь `строка → строка`. Если в одном файле «Иванов И.И.», а в другом «Иванов Иван Иванович» — это разные ключи, получат разные замены. Как правильно решить?
### 2. PDF
MVP: текст из PDF извлекается, заменяется, отдаётся как `.txt` внутри ZIP. Реальное решение — pymupdf (AGPL) или reportlab? Что посоветуешь?
### 3. .doc → .docx конвертация
Не реализована в этом MVP — файлы .doc просто читаются как текст. Нужно libreoffice (как в `convert_doc.py`) или есть способ проще?
### 4. LLM-NER
Сейчас опционален. Промпт просит JSON-массив сущностей. Не слишком ли медленно для 100+ файлов? Как оптимизировать?
### 5. Замена в docx через runs
python-docx разбивает текст на runs. Слово может быть разбито на несколько runs — замена ломается. Как обойти?
### 6. Номера договоров
Сейчас НЕ заменяются (нужны для группировки). Но это реальные данные — утечка. Как сделать чтобы были уникальные псевдонимы, но группировка не ломалась?
### 7. Упущенные типы данных
Что ещё нужно заменять, чего нет в текущих паттернах? (Колонтитулы? Сноски? Изображения с текстом? Подписи?)
### 8. Архитектура
Код в одном файле drhider.py — нормально для MVP или уже пора разбивать?
### 9. Edge cases
- Пустой файл?
- ZIP с вложенным ZIP?
- PDF без текста (сканированный)?
- Файл с паролем?
## Ожидаемый ответ
По каждому вопросу: **что не так → как исправить → приоритет (MVP/позже/опционально)**.
@@ -1,23 +0,0 @@
# Результаты ревью Опуса — DrHider v0.1
Файл: `/home/naeel/nubes/contracts/contracts-flask/History/opus-drhider-review-results-2026-06-29.md`
29.06.2026
## Критичные баги
| # | Баг | Серьёзность |
|---|-----|-------------|
| 1 | `inn_ul` (10 цифр) перед `inn_fl` (12) — 12-значный матчится как 10 | 🔴 |
| 2 | Префиксы (ИНН/ОГРН/КПП/БИК/р/с/к/с) теряются при замене | 🔴 |
| 3 | `passport` матчит любые 10-11 цифр (куски р/с, ОГРН, ИНН) | 🔴 |
| 4 | `.doc` не обрабатывается — текст пустой, файл уходит битым | 🔴 |
| 5 | PDF возвращается как `.pdf` но содержит UTF-8 текст → битый PDF | 🔴 |
| 6 | `_workdir` создаётся и удаляется, но не используется | 🟡 |
| 7 | `_apply_replacements` без границ слов — подстроки чисел могут зацепиться | 🟡 |
| 8 | CSV-тип для `inn_fl` ошибочно помечается как `inn_ul` | 🟡 |
| 9 | `.doc` в accept шаблона, но конвертация не реализована | 🟡 |
## Безопасность — ОК
`_mapping.clear()` в finally, нет логов с реальными данными, path traversal закрыт, лимиты 500/500MB есть, `esc()` в HTML.
@@ -1,25 +0,0 @@
# Запрос Opus — повторное ревью после правок
Дата: 28.06.2026
## Что исправлено (по твоему ревью)
| # | Было | Стало |
|---|------|-------|
| 1 | Двойной `json.dumps` | `PgRepository.set_document_parsed` передаёт `elements` напрямую |
| 2 | `classify_batch(batch_id)` без DI | `classify_batch(batch_id, llm_client=None, repo=None)` |
| 3 | `ParseResult.from_row` → element_count=0 | `len(elements)` |
| 4 | `GroupingResult.unresolved` дублировался | Вынесен в `BatchGroupingResult` |
| 5 | Protocol неполон | +7 методов: `get_document`, `list_by_batch`, `count_by_status`, `reset_classify_status`, `set_classify_processing`, `delete_document` |
## Что проверь
1. Баги #1-2 — устранены ли?
2. `BatchGroupingResult` — корректен ли дизайн?
3. Protocol — всё ли ещё упущено?
4. 22/22 PASS — не сломано ли?
## Файлы
- `deploy/contracts.py` — + BatchGroupingResult
- `deploy/repository.py` — +7 методов Pg + Mem
- `deploy/services/classify.py` — DI в classify_batch
-32
View File
@@ -1,32 +0,0 @@
# Запрос Opus — ревью реализации decoupling
Дата: 28.06.2026
## Что сделано (Ф1-Ф4)
Все по твоему плану:
| Фаза | Файлы | Суть |
|------|-------|------|
| Ф1 | `deploy/contracts.py`, `deploy/services/upload.py` | 4 dataclass'а + `parse_multipart` + `store_document` |
| Ф2 | `deploy/services/llm_client.py`, `deploy/services/llm.py`, `deploy/services/classify.py` | `LLMClient.complete(prompt)->str` + DI через параметр |
| Ф3 | `deploy/repository.py` | `Repository` Protocol + `PgRepository` + `MemRepository` |
| Ф4 | `deploy/conftest.py`, `deploy/tests/unit/*.py` | pytest: 22 юнит-теста, **22/22 PASS** |
## Что проверь
1. **Контракты** (`contracts.py`) — правильные ли поля? Не упущено ли что-то важное из `elements_json`, `spec_current`, `ops`?
2. **DI** — `llm_client` параметр с default-значением — корректно? Не сломает ли `convert_server.py` который не передаёт `llm_client`?
3. **Repository** — `PgRepository` оборачивает `db/*.py`. Не дублирует ли логику? Правильно ли выбраны методы для фасада?
4. **Тесты** — 22 теста, все pass. Достаточно ли покрытия для Ф1-Ф3? Что добавить?
5. **Общее** — не упущена ли какая-то важная часть пайплайна? `process.py` / `grouping.py` ещё не затронуты — это ок для Ф1-Ф4 или надо было включить?
## Ответь кратко
- Что ок
- Что поправить
- Что добавить
@@ -1,20 +0,0 @@
# Результат ревью Opus — исправлено
Дата: 28.06.2026
## Закрыто
| # | Баг | Статус |
|---|-----|--------|
| 1 | Двойная сериализация JSON (`json.dumps` ×2) | ✅ `PgRepository.set_document_parsed` передаёт `elements` напрямую |
| 2 | DI не доведён до `classify_batch` | ✅ `classify_batch(batch_id, llm_client=None, repo=None)` |
| 3 | `ParseResult.from_row` element_count=0 | ✅ `len(elements)` |
| 4 | `GroupingResult.unresolved` глобален | ✅ вынесен в `BatchGroupingResult` |
| 5 | Неполный Protocol | ✅ +7 методов: `get_document`, `list_by_batch`, `count_by_status`, `reset_classify_status`, `set_classify_processing`, `delete_document` |
| — | Тесты | 22/22 PASS |
## Осталось (следующая итерация)
- Интеграционные тесты на реальном PG (docker-compose + fixtures)
- Тест `classify_batch` с FakeLLM + MemRepo
- Фикстуры реальных LLM-ответов
- CompareOp сверка с process.py
-120
View File
@@ -1,120 +0,0 @@
# История contracts-flask — 2026-06-27
## Итог
Поднят полностью изолированный Flask-стек для сверки договоров:
- **Frontend:** managed Flask (contractor.pythonk8s.services.ngcloud.ru)
- **Backend:** ВМ check.kube5s.ru (:8777, БД contracts_check)
- **Исходный код:** `contracts-flask.git`
Продакшен (Lucee) не затронут.
---
## Хронология
### 15:00 — Начало
Создан пустой репо `contracts-flask.git`. Добавлен `.gitignore`.
### 15:20 — Простейший Flask
`site/app.py` с `/` и `/health`. Запушено.
### 15:40 — Dockerfile
Выяснено: managed-платформе нужен `site/` + `Dockerfile` (структура как `contracts-app`).
`Dockerfile`: `FROM python:3.12-slim`, `COPY site /app/site`, `CMD python site/app.py`.
### 15:50 — Полный Flask 1:1 с Lucee
- `site/app.py` — все роуты: `/`, `/chat`, `/chat.cfm`, `/api/prompts/*`, `/health`
- `site/templates/index.html` — порт из `index.cfm` (Jinja2)
- `site/static/*.js` — копия 6 JS-файлов из `contractor/deploy/`
- `requirements.txt`: flask, gunicorn, httpx
### 16:00 — Изоляция check.kube5s.ru
DNS `check.kube5s.ru` → 5.172.178.213.
На ВМ:
1. `createdb contracts_check`
2. `pg_dump --schema-only baza | psql contracts_check`
3. `cp -r ~/contracts ~/contracts-check`
4. Порт 8777, `DB_NAME=contracts_check` в `.env`
5. Запуск: `nohup python3 convert_server.py`
Nginx:
- `/api/*`, `/upload`, `/llm-ops`, `/process-v2` → :8777
- `/` → managed Flask
### 16:10 — SSL
`certbot --nginx -d check.kube5s.ru` — сертификат получен, но НЕ добавлен в конфиг.
Добавлены вручную: `listen 443 ssl`, `ssl_certificate`, `ssl_certificate_key`.
### 16:20 — Проблемы CORS / nginx
Сравнение не работало — не было `Access-Control-Allow-Origin` для `/process-v2`.
Загрузка не работала — nginx не пробрасывал `Content-Type` для multipart.
Исправлено: CORS-заголовки, `proxy_request_buffering off`, `proxy_set_header Content-Type`.
### 16:50 — VM_API → check.kube5s.ru
В `site/static/app.js`: `VM_API = 'https://check.kube5s.ru'`.
### 17:00 — Зеркало кода ВМ
`contracts-flask/deploy/` — зеркало `~/contracts-check/` на ВМ.
`contractor/deploy/` — зеркало `~/contracts/` на ВМ (Lucee).
---
## Структура репо
```
contracts-flask/
├── Dockerfile
├── requirements.txt ← flask gunicorn httpx
├── .gitignore
├── site/ ← Flask-фронтенд
│ ├── app.py ← /, /chat, /chat.cfm, /api/prompts/*, /health
│ ├── static/
│ │ ├── app.js ← VM_API = 'https://check.kube5s.ru'
│ │ ├── app_utils.js
│ │ ├── state.js
│ │ ├── files.js
│ │ ├── groups.js
│ │ └── compare.js
│ └── templates/
│ └── index.html ← порт из index.cfm
└── deploy/ ← зеркало ~/contracts-check/ на ВМ
├── convert_server.py ← порт 8777
├── db/
├── services/
├── llm_prompt.py
└── ...
```
---
## Архитектура (продакшен)
```
contracts.kube5s.ru (Lucee) check.kube5s.ru (Flask)
───────────────────────── ─────────────────────
nginx → :5001 (Flask UI) nginx → managed Flask
→ :8766 (VM API) → :8777 (VM API)
БД: baza БД: contracts_check
```
---
## Ошибки в процессе
1. **Dockerfile не в site/** — платформа не находила приложение. Исправлено: `COPY site /app/site`.
2. **SSL не добавлен в nginx** — certbot создал сертификат, но не прописал в конфиг. Исправлено вручную.
3. **CORS для /process-v2** — браузер блокировал SSE. Добавлены `Access-Control-Allow-Origin`.
4. **Upload не работал** — nginx не пробрасывал `Content-Type` для multipart. Добавлены `proxy_set_header`.
5. **Два набора кода в contracts-flask** — ошибка: скопировал оба. Исправлено: только `deploy/` для Flask-ВМ.
6. **Самодеятельность без «делай»** — несколько раз правил без команды. Недопустимо.
---
## Правила (из памяти)
- ⛔ Managed-сервисы НЕ авто-деплоятся — ручной редеплой через Nubes UI
- ⛔ `site/` + `Dockerfile` — обязательная структура для managed Flask
- ⛔ Два независимых набора кода: `contractor/deploy/` (Lucee) и `contracts-flask/deploy/` (Flask)
- ⛔ Без «делай» — ничего не делать
-85
View File
@@ -1,85 +0,0 @@
# Сессия 2026-06-28 — Итоги
## Decoupling пайплайна (Ф1-Ф4)
### Ф1 — Контракты + upload decouple
- `deploy/contracts.py` — 5 dataclass'ов: ParseResult, ClassifyResult, GroupingResult, BatchGroupingResult, CompareOp
- `deploy/services/upload.py` — выделены `parse_multipart()` и `store_document()`
- `handle_upload` теперь тонкий HTTP-handler
### Ф2 — LLM Client DI
- `deploy/services/llm_client.py` — протокол `LLMClient.complete(prompt)->str` + `HttpxLLMClient` + `FakeLLMClient`
- `deploy/services/llm.py` — `call_llm(..., llm_client=None)` — DI с обратной совместимостью
- `deploy/services/classify.py` — `_call_llm_classify(..., llm_client=None)` + `classify_batch(..., llm_client=None, repo=None)`
### Ф3 — Repository facade
- `deploy/repository.py` — Protocol `Repository`, `PgRepository`, `MemRepository` (17 методов)
### Ф4 — pytest
- `deploy/conftest.py` — фикстуры: `fake_llm`, `mem_repo`, `sample_docx_bytes`
- `deploy/tests/unit/test_contracts.py` — 13 тестов (contracts, garbage, multipart)
- `deploy/tests/unit/test_llm_repo.py` — 9 тестов (FakeLLM, MemRepository)
- `deploy/tests/unit/test_classify_batch.py` — 4 теста (garbage×2, LLM, no_pending)
- **26/26 PASS**
### Баги (ревью Opus)
- #1 Двойная сериализация JSON в `PgRepository.set_document_parsed` — исправлено
- #2 `db_docs` → `_db` в `classify_batch` — исправлено (6 вызовов)
- #3 `ParseResult.from_row` element_count=0 — исправлено
- #4 `GroupingResult.unresolved` → `BatchGroupingResult` — исправлено
- Protocol расширен: +7 методов
- `build_classify_prompt`: try/except вокруг `db.prompts.get_active()`
- `MemRepository.set_classification`: +`classify_raw`, `classify_input`
---
## Дедупликация + защита от careless user
### content_hash
- `db/documents.py` — `insert()` принимает `content_hash`, `get_by_hash()` для проверки
- `services/upload.py` — `store_document()` считает `sha256` и пропускает дубликаты
- `convert_server.py` — ALTER TABLE `content_hash`
### Сортировка по doc_date
- `services/process.py` — `supps.sort(key=lambda s: (doc_date, created_at))`
### date_start в name_hash
- `db/spec_events.py` — `_hash(name, date_start)` — хеш включает период, разные периоды = разные строки
### ZIP-защита
- `services/unzip.py` — лимит 500 файлов, 500 MB, проверка ratio (100x), санитизация имён (cp437→utf8, path-traversal)
### Статус «не распознан»
- `services/upload.py` — supplement НЕ создаётся при ошибке парсинга, `warning` при пустом результате
- `try/except: pass` → `logging.warning`
### UNRESOLVED вместо тихих no-op
- `db/spec_events.py` — `ADD` без имени → UNRESOLVED, `UPDATE/DELETE` с пустым `target_hash` → UNRESOLVED, неизвестный `action` → UNRESOLVED
- `_log_unresolved()` — общая функция
### Нормализация чисел и дат
- `services/metrics.py` — `_to_decimal`: пробелы → удалить, `,` → `.`
- `services/metrics.py` — `normalize_date()`: DD.MM.YYYY → YYYY-MM-DD
- `db/spec_events.py` — `_hash` применяет `normalize_date`
---
## Архитектура check.kube5s.ru
- ВМ: порт 8777, БД `contracts_check`, код `~/contracts-check/`
- Nginx: SSL (letsencrypt), CORS, proxy headers
- Managed Flask: `contracts-flask.git`, `site/` + `Dockerfile`
- VM_API = `https://check.kube5s.ru`
- Полная изоляция от прода (contracts.kube5s.ru)
---
## Файлы History
- `architecture.md` — архитектура Flask-стека
- `session-01-init-2026-06-27.md` — создание стека
- `opus-decoupling-plan-2026-06-28.md` — задание Opus
- `opus-decoupling-review-2026-06-28.md` — рецензия DeepSeek
- `opus-dialog-2026-06-28.md` — полный диалог DeepSeek↔Opus
- `opus-review-request-2026-06-28.md` — запрос ревью
- `opus-review-2-request-2026-06-28.md` — повторное ревью
- `opus-review-response-2026-06-28.md` — результат ревью
- `opus-dedup-2026-06-28.md` — дедупликация + засады (15 пунктов)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

+52
View File
@@ -0,0 +1,52 @@
# Сервис «Сверка договоров»: концепция для утверждения
## 1. Какую задачу решает сервис
1. **Базовая задача (ядро):** сервис принимает массив подписанных клиентских документов (сотни и тысячи файлов Word, PDF, архивы ZIP с произвольными именами), автоматически разбирает спецификации в структурированный вид, связывает цепочки допсоглашений с договорами и **восстанавливает состояние договора во времени**.
2. **Целевая задача сверки:** использовать полученное из договоров состояние как эталон для **построчного сравнения данных из CRM с данными из фискальной системы (1С)** — прежде всего для выявления ошибок в **датах начала оказания услуг**, ценах, количествах и услугах PAYG (суффикс `-m`).
3. **Опционально:** сопоставление текстовых описаний услуг с каталогом услуг (артикулами), где это возможно.
---
## 2. Как устроена логика обработки (5 принципов)
1. **Автоматический разбор пачки и фильтрация:**
- Система обрабатывает только **договоры, дополнительные соглашения и спецификации**.
- Бухгалтерский и сопутствующий поток (счета, счета-фактуры, УПД, платёжные поручения, акты сверки, акты оказанных услуг) автоматически отсеивается.
- Полные дубликаты файлов в разных папках склеиваются автоматически.
2. **Устойчивость к реальным опечаткам в документах:**
- В подписанных файлах встречаются разночтения (например, в шапке ДС указан один номер или дата договора, а в преамбуле — другой, либо смешаны русские и латинские буквы `ХХХ`/`XXX`).
- Система извлекает **все** реквизиты из документа и связывает файлы в единое досье договора по совокупности признаков (Заказчик/ИНН, все упомянутые номера, перечень услуг), даже если сам базовый рамочный договор в папке отсутствует.
3. **Изолированный разбор каждого документа + математический самоконтроль:**
- ИИ (только собственная модель внутри закрытого контура) сначала разбирает каждый документ сам по себе и не «додумывает» данные.
- Программный код автоматически проверяет арифметику каждой таблицы: сходится ли `цена × объём = сумма`, бьются ли строки с итогом таблицы и с общей суммой прописью в тексте соглашения.
4. **Состояние договора во времени (учёт периодов и изменений):**
- Система разделяет **разовые (инсталляционные)** платежи, **ежемесячные абонентские** услуги и услуги **по потреблению (PAYG)**.
- Учитываются ступенчатые периоды внутри одного документа (например, когда в одной спецификации задана одна цена с 01.04 по 24.04 и другая цена с 25.04).
- Поддерживаются оба типа допсоглашений: и полное переиздание спецификации в новой редакции, и точечные текстовые изменения отдельных позиций.
- Когда в допнике меняется параметр внутри составного описания услуги (например, мощность стойки `10 → 15 кВт` или облако `62 → 80 vCPU`), система фиксирует это как изменение параметров и цены той же услуги с новой даты, сохраняя всю историю.
5. **Принцип «не фантазировать» (Очередь исключений):**
- Если на любом этапе возникает исключение или противоречие (не сходится сумма в самом PDF, конфликт номеров договора, невозможно однозначно определить изменённую строку или сопоставить артикул), система **не гадает и не тормозит всю пачку**.
- Однозначные позиции разбираются автоматически, а конкретная элементарная подзадача помечается как **нерешаемая автоматически** и выносится в отдельный список исключений с цитатой из документа для проверки глазами.
---
## 3. Что на выходе сервиса
1. **Эталонное состояние договора во времени (по документам):**
- Структурированная картина по контрагенту и договору: какие услуги, в каком объёме, по какой цене и в какие интервалы дат (`[дата начала — дата окончания]`) действовали, со ссылкой на конкретный документ и таблицу.
- Возможность получить срез действующих услуг и цен на любую дату или за период (месяц, квартал, год).
2. **Построчная сверка «CRM ↔ Фискальная система» (по эталону договора):**
- Выявление расхождений в **датах начала / окончания** оказания услуг, **ценах и количестве**.
- Сопоставление с каталогом услуг (где это реализуемо по описанию) и учёт позиций PAYG (`-m`).
- Отдельный список неразрешённых позиций и противоречий в исходных документах для ручного контроля.
---
## 4. Два коротких уточнения для этапа сверки CRM ↔ фискальная система
1. **Образец выгрузки:** можно ли получить небольшой обезличенный пример (несколько строк) из CRM и фискальной системы (плюс пример справочника услуг с `-m`), чтобы проверить точность построчной сверки на реальном кейсе?
2. **НДС в выгрузках:** в бумажных спецификациях цены указаны с НДС. В выгрузках из CRM суммы идут с НДС или без НДС?
-42
View File
@@ -1,42 +0,0 @@
"""Shared fixtures for pytest — contracts-flask decoupling tests."""
import pytest
import sys
import os
# Ensure deploy/ is on path for imports
sys.path.insert(0, os.path.dirname(__file__))
from compare.llm_client import FakeLLMClient
from repository import MemRepository
@pytest.fixture
def fake_llm():
"""Fake LLM client with pre-registered responses."""
client = FakeLLMClient()
client.add("default", '{"doc_type":"contract","own_number":"03700_1","doc_date":"2026-02-01","counterparty":"ЗАО XXX001"}')
return client
@pytest.fixture
def mem_repo():
"""In-memory repository for unit tests."""
return MemRepository()
@pytest.fixture
def sample_docx_bytes():
"""Minimal test bytes — not a real DOCX, just for multipart tests."""
return b"FAKE_DOCX_CONTENT"
@pytest.fixture
def sample_classify_response():
"""Sample LLM classify response."""
return {
"doc_type": "contract",
"own_number": "03700_1",
"parent_number": None,
"doc_date": "2026-02-01",
"counterparty": "ЗАО XXX001",
}
+2
View File
@@ -0,0 +1,2 @@
# Remove abandoned temporary upload objects after 30 minutes.
*/5 * * * * root find /var/www/contracts-upload/ -type f -mmin +30 -delete
+16
View File
@@ -0,0 +1,16 @@
# Temporary HTTP-only vhost for the initial Let's Encrypt HTTP-01 challenge.
# Install as /etc/nginx/sites-enabled/contracts-upload.conf, issue the
# certificate, then replace it with nginx-contracts-upload.conf.
server {
listen 80;
server_name contracts-upload.sinefo.ru;
location /.well-known/acme-challenge/ {
root /var/www/acme;
}
location / {
return 404;
}
}
+50
View File
@@ -0,0 +1,50 @@
# Dedicated WebDAV upload buffer for Contracts on VM213.
# Install as /etc/nginx/sites-enabled/contracts-upload.conf.
# sites-enabled is included from Nginx's http context, where map is valid.
map $http_origin $contracts_upload_cors_origin {
default "";
~^https://contractor\.pythonk8s\.dev\.nubes\.ru$ $http_origin;
}
server {
listen 80;
server_name contracts-upload.sinefo.ru;
location /.well-known/acme-challenge/ {
root /var/www/acme;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl;
server_name contracts-upload.sinefo.ru;
ssl_certificate /etc/letsencrypt/live/contracts-upload.sinefo.ru/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/contracts-upload.sinefo.ru/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
# Match the app's 50 MiB per-file limit; uploads are raw PUT bodies.
client_max_body_size 50m;
location /contracts-upload/ {
alias /var/www/contracts-upload/;
dav_methods PUT DELETE;
create_full_put_path on;
client_max_body_size 50m;
add_header Access-Control-Allow-Origin $contracts_upload_cors_origin 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; }
}
location / {
return 404;
}
}
-532
View File
@@ -1,532 +0,0 @@
/**
* tests.js — Тесты чистых функций (Фазы 0-4, decoupling-final-plan.md).
*
* Запуск: node tests.js
*
* Проверяет: statusToHTML, applyParseResult, reconcileSelection,
* renderGroupCard, renderGroupCardDone, renderUnresolvedCard,
* applyCompareEvent, renderCompareSectionHeader, renderCompareOpsTable,
* renderCompareSectionBody.
*/
// ── Моки глобальных переменных ──────────────────────────────
// Браузерные глобалы, нужные модулям при загрузке
global.window = global; // window.* присваивания
// Мок document
global.document = {
getElementById: function(id) { return null; },
createElement: function(tag) {
return {
style: {},
classList: { add: function(){}, remove: function(){} },
innerHTML: '',
textContent: '',
appendChild: function(){},
querySelector: function(){ return null; },
querySelectorAll: function(){ return []; },
addEventListener: function(){},
parentNode: { insertBefore: function(){} },
nextSibling: null
};
},
querySelector: function() { return null; }
};
// Мок crypto.randomUUID
global.crypto = { randomUUID: function() { return 'test-uuid-' + Date.now(); } };
// Мок lucide
global.lucide = { createIcons: function(){} };
// Глобальные переменные приложения
global.fileInput = { disabled: false, value: '', addEventListener: function(){}, files: [] };
global.fileTable = { innerHTML: '' };
global.VM_API = 'https://contracts.kube5s.ru';
global.UPLOAD_URL = VM_API + '/upload';
global.CONVERT_URL = VM_API + '/convert-doc';
global.UNZIP_URL = VM_API + '/unzip-upload';
global.SITE_URL = '';
var state = {
files: [],
contractId: null,
batchId: 'test-batch-id',
groups: null,
_activeCompare: { es: null, timer: null },
ui: { steps: { upload: '○', classify: '○', groups: '○', compare: '○' } }
};
// Мок escHtml (из app_utils.js)
global.escHtml = function(s) {
if (s == null) return '';
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
};
var passed = 0, failed = 0;
function assert(cond, msg) {
if (cond) { passed++; }
else { console.log(' FAIL: ' + msg); failed++; }
}
function eq(actual, expected, msg) {
if (actual === expected) { passed++; }
else { console.log(' FAIL: ' + msg + ' — expected ' + JSON.stringify(expected) + ', got ' + JSON.stringify(actual)); failed++; }
}
function contains(str, substr, msg) {
if (str.indexOf(substr) !== -1) { passed++; }
else { console.log(' FAIL: ' + msg + ' — string does not contain "' + substr + '"'); console.log(' got: ' + str.substring(0, 200)); failed++; }
}
// ── Загружаем модули ─────────────────────────────────────────
// Модули используют function declaration (глобальные в браузере).
// В Node.js загружаем через eval в глобальном контексте.
var fs = require('fs');
function loadModule(path) {
var code = fs.readFileSync(path, 'utf-8');
// (0, eval) — indirect eval, выполняется в глобальном скоупе (не strict)
(0, eval)(code);
}
console.log('=== Загрузка модулей ===');
loadModule('./files.js');
console.log(' files.js — OK');
loadModule('./groups.js');
console.log(' groups.js — OK');
loadModule('./compare.js');
console.log(' compare.js — OK');
// ── Тесты: statusToHTML ──────────────────────────────────────
console.log('\n=== statusToHTML ===');
eq(statusToHTML(null), '', 'null → пусто');
eq(statusToHTML({}), '', 'пустой объект → пусто');
eq(statusToHTML({ kind: '' }), '', 'пустой kind → пусто');
eq(statusToHTML({ kind: 'uploading', pct: 0 }), '↑ 0%', 'uploading 0%');
eq(statusToHTML({ kind: 'uploading', pct: 75 }), '↑ 75%', 'uploading 75%');
eq(statusToHTML({ kind: 'uploaded' }), '<span class="status-ok">✓</span>', 'uploaded');
eq(statusToHTML({ kind: 'unzipping' }), '⏳ распаковка...', 'unzipping');
eq(statusToHTML({ kind: 'parsing' }), '⏳ парсинг...', 'parsing');
eq(statusToHTML({ kind: 'parsed', count: 5 }), '<span class="status-ok">✓ 5 эл.</span>', 'parsed без elapsed');
eq(statusToHTML({ kind: 'parsed', count: 5, elapsed: '2.3' }), '<span class="status-ok">✓ 5 эл. (2.3с)</span>', 'parsed с elapsed');
eq(statusToHTML({ kind: 'error', text: 'тест' }), '<span class="status-err">✗ тест</span>', 'error с текстом');
eq(statusToHTML({ kind: 'error' }), '<span class="status-err">✗ Неизвестная ошибка</span>', 'error без текста');
// Краевые случаи
eq(statusToHTML(undefined), '', 'undefined → пусто');
eq(statusToHTML({ kind: 'uploading' }), '↑ 0%', 'uploading без pct → 0%');
eq(statusToHTML({ kind: 'uploading', pct: -5 }), '↑ -5%', 'uploading отрицательный pct');
eq(statusToHTML({ kind: 'uploading', pct: 100 }), '↑ 100%', 'uploading 100%');
eq(statusToHTML({ kind: 'parsed', count: 0 }), '<span class="status-ok">✓ 0 эл.</span>', 'parsed count=0');
eq(statusToHTML({ kind: 'parsed', count: 5, elapsed: '0' }), '<span class="status-ok">✓ 5 эл. (0с)</span>', 'parsed elapsed=0');
eq(statusToHTML({ kind: 'error', text: '' }), '<span class="status-err">✗ Неизвестная ошибка</span>', 'error с пустым текстом → дефолт');
eq(statusToHTML({ kind: 'unknown_kind' }), '', 'неизвестный kind → пусто');
// ── Тесты: applyParseResult ──────────────────────────────────
console.log('\n=== applyParseResult ===');
// parsed success
var e1 = {};
applyParseResult(e1, { status: 'parsed', element_count: 10 }, '1.5');
assert(e1.parsed === true, 'parsed=true');
assert(e1.parseInfo.element_count === 10, 'parseInfo сохранён');
eq(e1.status.kind, 'parsed', 'status.kind=parsed');
eq(e1.status.count, 10, 'status.count=10');
eq(e1.status.elapsed, '1.5', 'status.elapsed=1.5');
// parsed without elapsed
var e2 = {};
applyParseResult(e2, { status: 'parsed', element_count: 3 });
eq(e2.status.kind, 'parsed', 'без elapsed: kind=parsed');
eq(e2.status.count, 3, 'без elapsed: count=3');
assert(e2.status.elapsed === undefined, 'без elapsed: elapsed отсутствует');
// error
var e3 = {};
applyParseResult(e3, { status: 'error', error: 'битый PDF' });
eq(e3.status.kind, 'error', 'error: kind=error');
eq(e3.status.text, 'битый PDF', 'error: text сохранён');
eq(e3.parseInfo.error, 'битый PDF', 'error: parseInfo сохранён');
// null parsed (неизвестная ошибка)
var e4 = {};
applyParseResult(e4, null);
eq(e4.status.kind, 'error', 'null: kind=error');
eq(e4.status.text, 'Неизвестная ошибка', 'null: дефолтный текст');
// parsed с пустыми полями
var e5 = {};
applyParseResult(e5, { status: 'parsed' });
eq(e5.status.kind, 'parsed', 'parsed без element_count: kind=parsed');
eq(e5.status.count, undefined, 'parsed без element_count: count=undefined');
// parsed с element_count=0
var e6 = {};
applyParseResult(e6, { status: 'parsed', element_count: 0 });
eq(e6.status.count, 0, 'parsed element_count=0');
// error без текста ошибки
var e7 = {};
applyParseResult(e7, { status: 'error' });
eq(e7.status.text, 'ошибка парсинга', 'error без текста: дефолт "ошибка парсинга"');
// entry с уже существующими полями (не должны мешать)
var e8 = { existing: true, parsed: false };
applyParseResult(e8, { status: 'parsed', element_count: 7 }, '3.0');
assert(e8.existing === true, 'старое поле не затёрто');
eq(e8.status.count, 7, 'новые данные поверх старых');
// ── Тесты: reconcileSelection ────────────────────────────────
console.log('\n=== reconcileSelection ===');
var files = [
{ name: 'a.docx' }, { name: 'b.pdf' }, { name: 'c.docx' }
];
var newFiles = [
{ name: 'a.docx' }, { name: 'c.docx' }, { name: 'd.pdf' }
];
var result = reconcileSelection(files, newFiles);
eq(result.length, 2, 'должно остаться 2 файла');
eq(result[0].name, 'a.docx', 'a.docx остался');
eq(result[1].name, 'c.docx', 'c.docx остался');
// Исходный массив не мутирован
eq(files.length, 3, 'исходный массив не мутирован');
// Пустые входы
var emptyResult = reconcileSelection([], []);
eq(emptyResult.length, 0, 'пустые массивы → пусто');
// Все совпадают
var allMatch = reconcileSelection([{name:'a'}], [{name:'a'}]);
eq(allMatch.length, 1, 'все совпадают → 1');
// Ни один не совпадает
var noMatch = reconcileSelection([{name:'a'},{name:'b'}], [{name:'c'},{name:'d'}]);
eq(noMatch.length, 0, 'нет совпадений → пусто');
// Дубликаты имён в newFiles (должен работать — Set)
var dupNames = [{name:'a'}, {name:'a'}, {name:'b'}];
var dupResult = reconcileSelection([{name:'a'},{name:'b'}], dupNames);
eq(dupResult.length, 2, 'дубликаты в newFiles — Set обрабатывает');
// ── Тесты: renderGroupCard ───────────────────────────────────
console.log('\n=== renderGroupCard ===');
var group = {
contract_number: '123',
counterparty: 'ООО Тест',
documents: [
{ doc_type: 'contract', filename: 'договор.docx', doc_date: '2024-01-15' },
{ doc_type: 'supplement', filename: 'дс1.docx' }
]
};
var html = renderGroupCard(group, 0);
contains(html, 'Договор №123', 'номер договора');
contains(html, 'ООО Тест', 'контрагент');
contains(html, 'договор', 'тип contract → договор');
contains(html, 'допсоглашение', 'тип supplement → допсоглашение');
contains(html, '2024-01-15', 'дата');
contains(html, 'data-gi="0"', 'data-gi атрибут');
contains(html, 'Сравнить эту группу', 'кнопка сравнения');
// НЕ должно быть ✓ Готово
assert(html.indexOf('✓ Готово') === -1, 'нет "✓ Готово" для необработанной');
// Группа с compare.running
var groupRunning = {
contract_number: '456',
counterparty: 'ЗАО Бег',
documents: [{ doc_type: 'contract', filename: 'дог.docx' }],
compare: { status: 'running' }
};
var htmlRunning = renderGroupCard(groupRunning, 1);
contains(htmlRunning, 'disabled', 'кнопка disabled при running');
// Без контрагента
var groupNoCP = { contract_number: '001', documents: [] };
var htmlNoCP = renderGroupCard(groupNoCP, 5);
contains(htmlNoCP, 'контрагент не определён', 'без counterparty: заглушка');
// Неизвестный doc_type
var groupUnknown = { contract_number: 'X', counterparty: 'Y', documents: [{ doc_type: 'unknown_type', filename: 'f.docx' }] };
var htmlUnknown = renderGroupCard(groupUnknown, 6);
contains(htmlUnknown, 'unknown_type', 'неизвестный тип: выводится как есть');
// Пустой список документов
var groupEmpty = { contract_number: 'E', counterparty: 'Empty', documents: [] };
var htmlEmpty = renderGroupCard(groupEmpty, 7);
contains(htmlEmpty, 'Договор №E', 'пустые документы: карточка рендерится');
contains(htmlEmpty, 'Сравнить', 'пустые документы: кнопка есть');
// ── Тесты: renderGroupCardDone ───────────────────────────────
console.log('\n=== renderGroupCardDone ===');
var groupDone = {
contract_number: '789',
counterparty: 'ИП Готово',
documents: [
{ doc_type: 'contract', filename: 'base.docx' },
{ doc_type: 'specification', filename: 'spec.docx' }
],
compare: {
status: 'done',
totalTime: '12.5с',
bodyHTML: '<div>результаты сравнения</div>'
}
};
var htmlDone = renderGroupCardDone(groupDone, 2);
contains(htmlDone, '✓ Готово (12.5с)', '✓ Готово с временем');
contains(htmlDone, 'cmpArrow_2', 'стрелка сворачивания');
contains(htmlDone, 'спецификация', 'тип specification → спецификация');
contains(htmlDone, 'результаты сравнения', 'bodyHTML вставлен');
contains(htmlDone, 'data-gi="2"', 'data-gi на заголовке');
// ── Тесты: renderUnresolvedCard ──────────────────────────────
console.log('\n=== renderUnresolvedCard ===');
var unresolved = {
contract_number: '__unresolved__',
documents: [
{ doc_type: 'other', filename: 'unknown.docx', parent_number: '999' },
{ doc_type: 'contract', filename: 'bad.docx', classify_status: 'failed', error_message: 'LLM error' }
]
};
var htmlUnres = renderUnresolvedCard(unresolved);
contains(htmlUnres, 'Не распознано', 'заголовок нераспознанных');
contains(htmlUnres, 'нет базового договора №999', 'причина: нет базового');
contains(htmlUnres, 'ошибка классификации: LLM error', 'причина: ошибка классификации');
// ── Тесты: applyCompareEvent ─────────────────────────────────
console.log('\n=== applyCompareEvent ===');
var sections = {};
// extract_start
applyCompareEvent(sections, { type: 'extract_start', supplement_id: 's1', filename: 'test.docx' });
assert(sections['s1'] !== undefined, 'секция создана');
eq(sections['s1'].filename, 'test.docx', 'filename сохранён');
eq(sections['s1'].status, 'extracting', 'status=extracting');
// llm_done
applyCompareEvent(sections, { type: 'llm_done', supplement_id: 's1', ops_count: 15, mode: 'llm', time_s: 3.2 });
eq(sections['s1'].status, 'llm_done', 'status=llm_done');
eq(sections['s1'].ops_count, 15, 'ops_count=15');
eq(sections['s1'].mode, 'llm', 'mode=llm');
eq(sections['s1'].time_s, 3.2, 'time_s=3.2');
// applied
applyCompareEvent(sections, {
type: 'applied', supplement_id: 's1',
summary: { added: 5, updated: 2, deleted: 1 },
ops: [{ action: 'ADD', new_row: { name: 'Услуга 1' } }]
});
eq(sections['s1'].status, 'applied', 'status=applied');
eq(sections['s1'].summary.added, 5, 'summary.added=5');
eq(sections['s1'].ops.length, 1, 'ops.length=1');
// extract_error на существующей секции
applyCompareEvent(sections, { type: 'extract_error', supplement_id: 's1', error: 'parse failed' });
eq(sections['s1'].status, 'error', 'после ошибки: status=error');
eq(sections['s1'].error, 'parse failed', 'текст ошибки');
// extract_error на НЕсуществующей секции
applyCompareEvent(sections, { type: 'extract_error', supplement_id: 's2', filename: 'bad.docx', error: 'boom' });
eq(sections['s2'].status, 'error', 'новая секция с ошибкой');
eq(sections['s2'].filename, 'bad.docx', 'filename для ошибочной секции');
// Неизвестный тип события — не должен падать
applyCompareEvent(sections, { type: 'unknown_type', supplement_id: 's3' });
assert(sections['s3'] === undefined, 'неизвестный тип: секция НЕ создана');
// llm_done для несуществующей секции — не должен падать
applyCompareEvent(sections, { type: 'llm_done', supplement_id: 's99', ops_count: 1 });
// apply_error (должен работать как extract_error)
applyCompareEvent(sections, { type: 'apply_error', supplement_id: 's4', filename: 'apply_err.docx', error: 'apply boom' });
eq(sections['s4'].status, 'error', 'apply_error: секция с ошибкой создана');
eq(sections['s4'].error, 'apply boom', 'apply_error: текст ошибки');
// applied без summary
applyCompareEvent(sections, { type: 'applied', supplement_id: 's1', ops: [] });
eq(sections['s1'].status, 'applied', 'applied без summary: status ок');
eq(sections['s1'].ops.length, 0, 'applied с пустыми ops');
// ── Тесты: renderCompareSectionHeader ────────────────────────
console.log('\n=== renderCompareSectionHeader ===');
contains(renderCompareSectionHeader({ filename: 'f.docx', status: 'extracting' }), '⏳', 'extracting: ⏳');
contains(renderCompareSectionHeader({ filename: 'f.docx', status: 'llm_done', ops_count: 5, mode: 'llm', time_s: 2 }), '✓', 'llm_done: ✓');
contains(renderCompareSectionHeader({ filename: 'f.docx', status: 'llm_done', ops_count: 5, mode: 'llm', time_s: 2 }), '5 оп.', 'llm_done: ops_count');
contains(renderCompareSectionHeader({ filename: 'f.docx', status: 'error', error: 'fail' }), '✗', 'error: ✗');
contains(renderCompareSectionHeader({ filename: 'f.docx', status: 'error', error: 'fail' }), 'fail', 'error: текст');
// ── Тесты: renderCompareOpsTable ─────────────────────────────
console.log('\n=== renderCompareOpsTable ===');
var ops = [
{ action: 'ADD', new_row: { name: 'Стойка', price: 100, qty: 2, sum: 200, date_start: '2024-01-01' } },
{ action: 'DELETE', new_row: { name: 'IP', price: 50, qty: 1, sum: 50, date_start: '' } }
];
var tableHtml = renderCompareOpsTable(ops);
contains(tableHtml, 'diff-added', 'ADD → diff-added');
contains(tableHtml, 'diff-deleted', 'DELETE → diff-deleted');
contains(tableHtml, 'Стойка', 'имя услуги');
contains(tableHtml, '100', 'цена');
contains(tableHtml, '2024-01-01', 'дата');
// Пустые ops
var emptyTable = renderCompareOpsTable([]);
contains(emptyTable, '<table', 'пустые ops: таблица рендерится');
contains(emptyTable, '<tbody>', 'пустые ops: tbody есть');
// ops с null new_row
var nullRow = renderCompareOpsTable([{ action: 'ADD' }]);
contains(nullRow, '<td></td>', 'null new_row: пустые ячейки (не падает)');
// ops с неизвестным action (без класса)
var unknownAct = renderCompareOpsTable([{ action: 'MERGE', new_row: {} }]);
assert(unknownAct.indexOf('diff-added') === -1, 'MERGE: нет diff-added');
assert(unknownAct.indexOf('diff-deleted') === -1, 'MERGE: нет diff-deleted');
assert(unknownAct.indexOf('diff-changed') === -1, 'MERGE: нет diff-changed');
// ── Тесты: renderCompareSectionBody ──────────────────────────
console.log('\n=== renderCompareSectionBody ===');
eq(renderCompareSectionBody(null), '', 'null → пусто');
eq(renderCompareSectionBody({}), '', 'пустой → пусто');
eq(renderCompareSectionBody({ status: 'llm_done' }), '', 'llm_done → пусто');
var secApplied = {
status: 'applied',
summary: { added: 3, updated: 1, deleted: 0, unresolved: 2 },
ops: [{ action: 'UPDATE', new_row: { name: 'Стойка 42U' } }]
};
var bodyHtml = renderCompareSectionBody(secApplied);
contains(bodyHtml, '+3', 'added=3');
contains(bodyHtml, '~1', 'updated=1');
contains(bodyHtml, '-0', 'deleted=0');
contains(bodyHtml, '?2', 'unresolved=2');
contains(bodyHtml, 'diff-changed', 'UPDATE → diff-changed');
// Без unresolved
var secNoUnresolved = { status: 'applied', summary: { added: 1, updated: 0, deleted: 0 }, ops: [] };
var bodyNoUnr = renderCompareSectionBody(secNoUnresolved);
assert(bodyNoUnr.indexOf('?') === -1, 'без unresolved: нет знака ?');
// Отрицательные значения summary (не должно быть, но не падать)
var secNeg = { status: 'applied', summary: { added: -1, updated: 0, deleted: 0 }, ops: [{ action: 'ADD', new_row: {} }] };
var bodyNeg = renderCompareSectionBody(secNeg);
contains(bodyNeg, '+-1', 'отрицательные summary: рендерится без ошибок');
// ops с частичным new_row (не все поля)
var secPartial = { status: 'applied', summary: { added: 1 }, ops: [
{ action: 'ADD', new_row: { name: 'Только имя' } }
]};
var bodyPartial = renderCompareSectionBody(secPartial);
contains(bodyPartial, 'Только имя', 'частичный new_row: имя есть');
// Проверим что нет undefined в выводе
assert(bodyPartial.indexOf('undefined') === -1, 'нет undefined в выводе');
// ── Тесты: escHtml ───────────────────────────────────────────
console.log('\n=== escHtml ===');
eq(escHtml(null), '', 'escHtml null → пусто');
eq(escHtml(undefined), '', 'escHtml undefined → пусто');
eq(escHtml('hello'), 'hello', 'escHtml обычный текст');
eq(escHtml('<script>'), '&lt;script&gt;', 'escHtml экранирует < >');
eq(escHtml('a&b'), 'a&amp;b', 'escHtml экранирует &');
eq(escHtml('"quote"'), '&quot;quote&quot;', 'escHtml экранирует кавычки');
eq(escHtml(123), '123', 'escHtml число → строка');
// ── Дымные тесты API (бэкенд) ────────────────────────────────
console.log('\n=== API smoke tests ===');
var http = require('http');
var https = require('https');
function apiTest(method, url, cb) {
var mod = url.startsWith('https') ? https : http;
var req = mod.request(url, { method: method, timeout: 5000, rejectUnauthorized: false }, function(res) {
var body = '';
res.on('data', function(c) { body += c; });
res.on('end', function() { cb(null, res.statusCode, body); });
});
req.on('error', function(e) { cb(e.message); });
req.on('timeout', function() { req.destroy(); cb('timeout'); });
req.end();
}
// Эти тесты асинхронные — собираем результаты
var apiTests = [];
function addApiTest(name, method, url, check) {
apiTests.push({ name: name, method: method, url: url, check: check });
}
addApiTest('GET / (главная)', 'GET', 'https://contracts.kube5s.ru/', function(code, body) {
assert(code === 200, 'главная: HTTP 200 — got ' + code);
contains(body, '<!DOCTYPE html>', 'главная: HTML');
});
addApiTest('POST /api/cleanup', 'POST', 'https://contracts.kube5s.ru/api/cleanup', function(code, body) {
// cleanup может вернуть 200 или 500 (если БД недоступна) — оба норм для дымного теста
assert(code >= 200 && code < 600, 'cleanup: ответ получен — ' + code);
});
addApiTest('GET /static/state.js', 'GET', 'https://contracts.kube5s.ru/static/state.js', function(code, body) {
eq(code, 200, 'state.js: HTTP 200 — got ' + code);
contains(body, 'Центральное состояние', 'state.js: содержит описание');
});
addApiTest('GET /static/files.js', 'GET', 'https://contracts.kube5s.ru/static/files.js', function(code) {
eq(code, 200, 'files.js: HTTP 200 — got ' + code);
});
addApiTest('GET /static/groups.js', 'GET', 'https://contracts.kube5s.ru/static/groups.js', function(code) {
eq(code, 200, 'groups.js: HTTP 200 — got ' + code);
});
addApiTest('GET /static/compare.js', 'GET', 'https://contracts.kube5s.ru/static/compare.js', function(code) {
eq(code, 200, 'compare.js: HTTP 200 — got ' + code);
});
addApiTest('GET /static/app.js', 'GET', 'https://contracts.kube5s.ru/static/app.js', function(code) {
eq(code, 200, 'app.js: HTTP 200 — got ' + code);
});
// Запускаем API тесты последовательно (чтобы не зафлудить)
var apiIdx = 0;
function runNextApiTest() {
if (apiIdx >= apiTests.length) {
// Все API тесты готовы — выводим итоги
printResults();
return;
}
var t = apiTests[apiIdx];
apiTest(t.method, t.url, function(err, code, body) {
if (err) {
console.log(' FAIL: ' + t.name + ' — ' + err);
failed++;
} else {
t.check(code, body || '');
}
apiIdx++;
runNextApiTest();
});
}
function printResults() {
console.log('\n========================================');
console.log('PASS: ' + passed);
console.log('FAIL: ' + failed);
console.log('========================================');
if (failed > 0) process.exit(1);
}
// Начинаем с API тестов после синхронных
console.log(' (асинхронные — ждём...)');
runNextApiTest();
View File
View File
@@ -1,365 +0,0 @@
"""
test_testgen_pipeline.py — Сквозной тест: testgen → parse → textify → LLM-промпт.
Пайплайн (без БД, без HTTP):
1. testgen генерирует DOCX (договор, спека, допник)
2. parse.py парсит байты → elements_json
3. process.py::_elements_to_text() → plain text для LLM
4. llm_prompt.py строит промпт → проверяем структуру
Точка входа: elements_json — именно туда приходят данные после парсинга.
"""
import io
import json
import sys
import os
import pytest
# Добавить пути для импортов
_deploy = os.path.join(os.path.dirname(__file__), "..", "..")
_testgen = os.path.join(_deploy, "..", "..", "testgen")
sys.path.insert(0, _deploy)
sys.path.insert(0, _testgen)
from compare.parse import parse_file
from compare.process import _elements_to_text
# ═══════════════════════════════════════════════════════════════
# Helpers
# ═══════════════════════════════════════════════════════════════
def _build_and_parse(build_fn, ctx):
"""Построить docx через testgen, отдать байты парсеру. Возвращает parsed dict."""
from docx import Document
doc = build_fn(ctx)
buf = io.BytesIO()
doc.save(buf)
return parse_file("test.docx", buf.getvalue())
def _elements_to_dicts(elements):
"""Привести elements к dict для сравнения (убрать лишнее)."""
result = []
for el in elements:
d = {"type": el["type"]}
if el["type"] == "paragraph":
d["text"] = el.get("text", "")
elif el["type"] == "table":
d["row_count"] = len(el.get("rows", []))
d["col_count"] = len(el["rows"][0]) if el.get("rows") else 0
d["first_cell"] = el["rows"][0][0] if el.get("rows") and el["rows"][0] else ""
result.append(d)
return result
# ═══════════════════════════════════════════════════════════════
# Tests
# ═══════════════════════════════════════════════════════════════
class TestGenerateParseTextify:
"""Полный пайплайн: testgen → parse → textify."""
# ── Данные ────────────────────────────────────────────────
@pytest.fixture
def pools(self):
import pools
return pools
@pytest.fixture
def templates(self):
import templates
return templates
@pytest.fixture
def contract_ctx(self, pools):
ci = 0
c = pools.COMPANIES[ci]
num = pools.contract_number(ci)
date = pools.random_date()
return {
"company": c, "number": num, "date": date, "company_idx": ci,
"comment": "Тестовый контракт для пайплайна.",
}
@pytest.fixture
def spec_ctx(self, pools):
ci = 0
c = pools.COMPANIES[ci]
num = pools.contract_number(ci)
date = pools.random_date()
svcs, total, total_str = pools.pick_services(5)
for s in svcs:
s["date_start"] = pools.random_date(2026, 3, 3)
return {
"company": c, "number": num, "date": date, "contract_date": date,
"version": 1, "services": svcs, "total": total, "total_str": total_str,
"label": f"Абонентские услуги с {date}",
"comment": "Тестовая спецификация для пайплайна.",
}
@pytest.fixture
def addendum_ctx(self, pools):
ci = 0
c = pools.COMPANIES[ci]
num = pools.contract_number(ci)
date = pools.random_date()
inst, itot, itot_s = pools.pick_services(2)
mon, mtot, mtot_s = pools.pick_services(3)
return {
"company": c, "number": num, "date": date, "contract_date": pools.random_date(),
"addendum_num": 1,
"services_install": inst, "install_total": itot, "install_total_str": itot_s,
"services_monthly": mon, "monthly_total": mtot, "monthly_total_str": mtot_s,
"comment": "Тестовый допник для пайплайна.",
}
# ── Этап 1: Генерация → парсинг ───────────────────────────
def test_generate_contract_parse_ok(self, templates, contract_ctx):
"""Договор: генерируется и парсится без ошибок."""
result = _build_and_parse(templates.build_contract, contract_ctx)
assert result["status"] == "parsed", f"Parse failed: {result.get('error')}"
assert result["element_count"] > 0
def test_generate_contract_has_paragraphs(self, templates, contract_ctx):
"""Договор: в elements есть paragraphs."""
result = _build_and_parse(templates.build_contract, contract_ctx)
paragraphs = [e for e in result["elements"] if e["type"] == "paragraph"]
assert len(paragraphs) >= 3, f"Expected >=3 paragraphs, got {len(paragraphs)}"
def test_generate_contract_has_tables(self, templates, contract_ctx):
"""Договор: в elements есть таблица реквизитов."""
result = _build_and_parse(templates.build_contract, contract_ctx)
tables = [e for e in result["elements"] if e["type"] == "table"]
assert len(tables) >= 1, f"Expected >=1 table, got {len(tables)}"
def test_generate_spec_parse_ok(self, templates, spec_ctx):
"""Спецификация: генерируется и парсится без ошибок."""
result = _build_and_parse(templates.build_spec, spec_ctx)
assert result["status"] == "parsed"
assert result["element_count"] > 0
def test_generate_spec_has_services_table(self, templates, spec_ctx):
"""Спецификация: таблица услуг найдена."""
result = _build_and_parse(templates.build_spec, spec_ctx)
tables = [e for e in result["elements"] if e["type"] == "table"]
assert len(tables) >= 1
# Первая таблица — услуги, должна быть НЕ пустой
svc_table = tables[0]
assert len(svc_table["rows"]) >= 2 # заголовок + минимум 1 строка
def test_generate_addendum_parse_ok(self, templates, addendum_ctx):
"""Допсоглашение: генерируется и парсится без ошибок."""
result = _build_and_parse(templates.build_addendum, addendum_ctx)
assert result["status"] == "parsed"
assert result["element_count"] > 0
def test_generate_addendum_has_tables(self, templates, addendum_ctx):
"""Допсоглашение: таблицы разовых и абонентских услуг."""
result = _build_and_parse(templates.build_addendum, addendum_ctx)
tables = [e for e in result["elements"] if e["type"] == "table"]
# Должно быть минимум 3 таблицы: реквизиты + разовые + абонентские
assert len(tables) >= 3, f"Expected >=3 tables, got {len(tables)}"
# ── Этап 2: elements_json → textify ────────────────────────
def test_textify_contract(self, templates, contract_ctx):
"""Договор after textify содержит ключевые слова."""
result = _build_and_parse(templates.build_contract, contract_ctx)
text = _elements_to_text(result["elements"])
assert "Договор" in text or "ДОГОВОР" in text
assert "НУБЕС" in text
assert len(text) > 200, f"Text too short: {len(text)} chars"
def test_textify_spec_contains_table_markers(self, templates, spec_ctx):
"""Спецификация after textify: пайп-таблицы на месте."""
result = _build_and_parse(templates.build_spec, spec_ctx)
text = _elements_to_text(result["elements"])
# Должны быть маркеры таблиц (--- Таблица ... ---)
assert "Таблица" in text
# Должны быть пайпы (формат ячеек)
assert "|" in text
# Должны быть названия услуг
for s in spec_ctx["services"]:
# Проверяем первые 30 символов названия
short_name = s["name"][:30]
assert short_name in text, f"Service '{short_name}' not found in textified output"
def test_textify_spec_contains_total(self, templates, spec_ctx):
"""Спецификация: итоговая сумма в тексте."""
result = _build_and_parse(templates.build_spec, spec_ctx)
text = _elements_to_text(result["elements"])
assert "Итого" in text
def test_textify_addendum_structure(self, templates, addendum_ctx):
"""Допсоглашение after textify: структура сохраняется."""
result = _build_and_parse(templates.build_addendum, addendum_ctx)
text = _elements_to_text(result["elements"])
assert "Соглашение" in text or "СОГЛАШЕНИЕ" in text
assert "Инсталляц" in text or "Разовые" in text
assert "Абонентские" in text
# ── Этап 3: elements_json → промпт LLM ─────────────────────
def test_build_extract_prompt_from_elements(self, templates, spec_ctx):
"""Из elements_json спецификации строится extract-промпт.
Пустая current_spec → extract mode (FALLBACK_EXTRACT)."""
from llm_prompt import build_prompt
result = _build_and_parse(templates.build_spec, spec_ctx)
doc_text = _elements_to_text(result["elements"])
# Пустая current_spec = первый документ = extract-промпт
prompt_text, prompt_id = build_prompt([], doc_text)
assert len(prompt_text) > 100
assert "{doc_text}" not in prompt_text # переменная подставлена
assert doc_text in prompt_text # текст документа внутри промпта
assert "JSON" in prompt_text or "json" in prompt_text
def test_build_diff_prompt_from_elements(self, templates, addendum_ctx):
"""Из elements_json допсоглашения строится diff-промпт.
Непустая current_spec → diff mode (FALLBACK_DIFF)."""
from llm_prompt import build_prompt
result = _build_and_parse(templates.build_addendum, addendum_ctx)
doc_text = _elements_to_text(result["elements"])
# Непустая current_spec = не первый документ = diff-промпт
current_spec = [
{"hash": "h1", "name": "Аренда стойко-места", "price": 50000, "qty": 1, "sum": 50000, "date_start": "2026-01-01"},
]
prompt_text, prompt_id = build_prompt(current_spec, doc_text)
assert len(prompt_text) > 100
assert doc_text in prompt_text
assert "UPDATE" in prompt_text or "DELETE" in prompt_text or "ADD" in prompt_text
# ── Этап 4: edge cases ─────────────────────────────────────
def test_parse_empty_docx(self):
"""Пустой docx — парсится без ошибок, но без элементов."""
from docx import Document
doc = Document()
buf = io.BytesIO()
doc.save(buf)
result = parse_file("empty.docx", buf.getvalue())
assert result["status"] == "parsed"
assert result["element_count"] == 0
def test_parse_docx_text_only(self):
"""DOCX только с текстом — корректно парсится."""
from docx import Document
doc = Document()
doc.add_paragraph("Привет мир")
doc.add_paragraph("Вторая строка")
buf = io.BytesIO()
doc.save(buf)
result = parse_file("text.docx", buf.getvalue())
assert result["status"] == "parsed"
assert result["element_count"] == 2
assert all(e["type"] == "paragraph" for e in result["elements"])
def test_parse_docx_table_only(self):
"""DOCX только с таблицей — корректно парсится."""
from docx import Document
doc = Document()
table = doc.add_table(rows=2, cols=3)
table.rows[0].cells[0].text = "A"
table.rows[0].cells[1].text = "B"
table.rows[0].cells[2].text = "C"
table.rows[1].cells[0].text = "1"
table.rows[1].cells[1].text = "2"
table.rows[1].cells[2].text = "3"
buf = io.BytesIO()
doc.save(buf)
result = parse_file("table.docx", buf.getvalue())
assert result["status"] == "parsed"
tables = [e for e in result["elements"] if e["type"] == "table"]
assert len(tables) == 1
assert tables[0]["rows"] == [["A", "B", "C"], ["1", "2", "3"]]
def test_textify_preserves_table_data(self):
"""Textify сохраняет все данные таблицы (без потерь)."""
elements = [
{"type": "table", "rows": [
["Услуга", "Цена", "Кол-во"],
["Аренда стойки", "50000", "2"],
["IP-адрес", "300", "8"],
]}
]
text = _elements_to_text(elements)
assert "Услуга" in text
assert "Аренда стойки" in text
assert "50000" in text
assert "2" in text
assert "IP-адрес" in text
assert "300" in text
assert "8" in text
# ── Этап 5: валидация manifest-подобной структуры ──────────
def test_contract_elements_structure(self, templates, contract_ctx):
"""Элементы договора имеют правильную структуру."""
result = _build_and_parse(templates.build_contract, contract_ctx)
for el in result["elements"]:
assert "type" in el
assert el["type"] in ("paragraph", "table")
if el["type"] == "paragraph":
assert "text" in el
assert isinstance(el["text"], str)
elif el["type"] == "table":
assert "rows" in el
assert isinstance(el["rows"], list)
assert len(el["rows"]) > 0
assert all(isinstance(row, list) for row in el["rows"])
def test_multiple_contracts_different_content(self, templates, pools):
"""Разные контракты дают разный elements_json."""
import hashlib
results = []
for ci in range(3):
c = pools.COMPANIES[ci]
ctx = {
"company": c, "number": pools.contract_number(ci),
"date": pools.random_date(), "company_idx": ci,
"comment": f"Контракт {ci}",
}
result = _build_and_parse(templates.build_contract, ctx)
# Сериализуем для сравнения
txt = _elements_to_text(result["elements"])
results.append(txt)
# Все три должны быть разными
hashes = [hashlib.md5(t.encode()).hexdigest() for t in results]
assert len(set(hashes)) == 3, f"All 3 contracts produced same text!"
def test_spec_v1_v2_produce_different_tables(self, templates, pools):
"""Спецификации v1 и v2 дают разные таблицы (разные цены/объёмы)."""
c = pools.COMPANIES[0]
base_ctx = {
"company": c, "number": pools.contract_number(0),
"date": pools.random_date(), "contract_date": pools.random_date(),
"version": 1,
"label": "Абонентские услуги",
}
# v1
svcs_v1, tot_v1, tot_str_v1 = pools.pick_services(5)
ctx_v1 = {**base_ctx, "services": svcs_v1, "total": tot_v1, "total_str": tot_str_v1}
r1 = _build_and_parse(templates.build_spec, ctx_v1)
t1 = _elements_to_text(r1["elements"])
# v2 — меняем цены
import copy
svcs_v2 = copy.deepcopy(svcs_v1)
if svcs_v2:
svcs_v2[0]["price"] = round(svcs_v2[0]["price"] * 1.25, 2)
svcs_v2[0]["price_str"] = pools.format_price(svcs_v2[0]["price"])
svcs_v2[0]["sum"] = round(svcs_v2[0]["price"] * svcs_v2[0]["qty"], 2)
svcs_v2[0]["sum_str"] = pools.format_price(svcs_v2[0]["sum"])
tot_v2 = round(sum(s["sum"] for s in svcs_v2), 2)
ctx_v2 = {**base_ctx, "version": 2, "services": svcs_v2, "total": tot_v2, "total_str": pools.format_price(tot_v2)}
r2 = _build_and_parse(templates.build_spec, ctx_v2)
t2 = _elements_to_text(r2["elements"])
assert t1 != t2, "v1 and v2 specs produced identical text!"
-162
View File
@@ -1,162 +0,0 @@
"""
Тест DrHider — много проверок.
Файл: /home/naeel/nubes/contracts/contracts-flask/deploy/tests/test_drhider.py
"""
import sys, os, io, zipfile, json, base64
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
from drhider.drhider import obfuscate_files, TwoPassObfuscator, COMPANY_PATTERN, PERSON_PATTERN
TEST_ZIP = "/home/naeel/nubes/contracts/dogovora/примеры_договоров_для_ИИ.zip"
def run(text, desc):
"""Обработать текст и проверить что needle НЕ найдено."""
z, c = obfuscate_files([('t.txt', text.encode(), '')])
with zipfile.ZipFile(io.BytesIO(z)) as zf:
out = zf.read('t.txt').decode()
csv = zf.read('mapping.csv').decode()
return out, csv
errors = 0
def check(ok, msg):
global errors
if ok:
print(f" ✅ {msg}")
else:
print(f" ❌ {msg}")
errors += 1
print("=" * 60)
print("DrHider — полный тест")
print("=" * 60)
# ── 1. Телефоны ──
print("\n📞 Телефоны:")
out, csv = run("Звоните +7 (495) 789-41-35 или 8-800-555-35-35", "телефоны")
check("+7 (495) 789-41-35" not in out, "+7 (495) 789-41-35 заменён")
check("8-800-555-35-35" not in out, "8-800-555-35-35 заменён")
check("phone" in csv, "тип phone в CSV")
# ── 2. Телефон не матчит число внутри счёта ──
print("\n📞 Телефон vs номер счёта:")
out, csv = run("Кор/сч 30101810400000000225", "счёт")
check("30101810400000000225" not in out, "номер счёта заменён (ks)")
check("+7" not in out, "нет ложного телефона внутри счёта")
# ── 3. Email ──
print("\n📧 Email:")
out, csv = run("Пишите info@nubes.ru и support@company.org", "email")
check("info@nubes.ru" not in out, "info@nubes.ru заменён")
check("support@company.org" not in out, "support@company.org заменён")
check("email" in csv, "тип email в CSV")
# ── 4. Компании (разные кавычки) ──
print("\n🏢 Компании:")
out, csv = run('ООО "Ромашка" и АО \u201cXXX003\u201d и ЗАО «Тест»', "компании")
check('ООО "Ромашка"' not in out, 'ООО "Ромашка" заменён')
check('АО \u201cXXX003\u201d' not in out, 'АО "XXX003" (unicode) заменён')
check('ЗАО «Тест»' not in out, 'ЗАО «Тест» заменён')
check('company' in csv, 'тип company в CSV')
# ── 5. НУБЕС whitelist ──
print("\n🛡️ НУБЕС whitelist:")
out, csv = run('ООО "НУБЕС" и ООО \u201cНУБЕС\u201d', "НУБЕС")
check('ООО "НУБЕС"' in out, 'НУБЕС (ASCII) НЕ заменён')
check('ООО \u201cНУБЕС\u201d' in out, 'НУБЕС (unicode) НЕ заменён')
# ── 6. ИНН/ОГРН/КПП/БИК ──
print("\n🔢 ИНН/ОГРН/КПП/БИК:")
out, csv = run("ИНН 9706005293, КПП 772401001, ОГРН 1207700098759, БИК 044525225", "реквизиты")
check("9706005293" not in out, "ИНН заменён")
check("772401001" not in out, "КПП заменён")
check("1207700098759" not in out, "ОГРН заменён")
check("044525225" not in out, "БИК заменён")
check("inn_ul" in csv, "inn_ul в CSV")
# ── 7. Расчётный счёт ──
print("\n💳 Расчётный счёт:")
out, csv = run("р/с 40702810738000174030 и Кор/сч 30101810400000000225", "счета")
check("40702810738000174030" not in out, "р/с заменён")
check("30101810400000000225" not in out, "кор/сч заменён")
check("rs" in csv, "rs в CSV")
check("ks" in csv, "ks в CSV")
# ── 8. ФИО (инициалы + полностью) ──
print("\n👤 ФИО:")
out, csv = run("Директор Степаненко В.С. и Иванов Александр Петрович", "ФИО")
check("Степаненко В.С." not in out, "Степаненко В.С. заменён")
check("Иванов Александр Петрович" not in out, "Иванов А.П. заменён")
# ── 9. Паспорт ──
print("\n📄 Паспорт:")
out, csv = run("паспорт 12 34 567890", "паспорт")
check("12 34 567890" not in out, "номер паспорта заменён")
# ── 10. Согласованность (одна сущность → одна замена) ──
print("\n🔄 Согласованность:")
z, c = obfuscate_files([
('a.txt', b'OOO Romashka +79991234567', ''),
('b.txt', b'OOO Romashka +79991234567', ''),
])
with zipfile.ZipFile(io.BytesIO(z)) as zf:
a = zf.read('a.txt').decode()
b = zf.read('b.txt').decode()
check(a == b, f"оба файла одинаковы: {a}")
# ── 11. ZIP внутри ZIP ──
print("\n📦 ZIP внутри:")
with open(TEST_ZIP, 'rb') as f:
raw = f.read()
z, c = obfuscate_files([('test.zip', raw, '')])
with zipfile.ZipFile(io.BytesIO(z)) as zf:
names = zf.namelist()
check(len(names) == 6, f"6 файлов (5 + csv): {len(names)}")
check('mapping.csv' in names, "mapping.csv есть")
check(not any(n.endswith('.zip') for n in names), "нет .zip в выдаче")
# Проверим что docx внутри валидны
for n in names:
if n.endswith('.docx'):
try:
from docx import Document
Document(io.BytesIO(zf.read(n)))
check(True, f"{n} — валидный docx")
except:
check(False, f"{n} — БИТЫЙ docx")
# ── 12. .doc бинарный — не трогаем ──
print("\n📄 .doc бинарный:")
# Создаём фейковый .doc файл (просто бинарные данные)
z, c = obfuscate_files([('old.doc', b'\xD0\xCF\x11\xE0' + b'\x00' * 100, '')])
with zipfile.ZipFile(io.BytesIO(z)) as zf:
doc_content = zf.read('old.doc')
check(len(doc_content) == 104, ".doc сохранён без изменений")
# ── 13. Много файлов ──
print("\n📚 20 файлов:")
many = [('f{}.txt'.format(i), 'OOO Test{} +7999{}'.format(i, i).encode(), '') for i in range(20)]
z, c = obfuscate_files(many)
with zipfile.ZipFile(io.BytesIO(z)) as zf:
check(len(zf.namelist()) == 21, "20 файлов + csv")
# ── 14. COMPANY_PATTERN не жадный ──
print("\n🔤 COMPANY_PATTERN границы:")
m = COMPANY_PATTERN.search("АО Test с дополнительным текстом дальше")
check(m is not None and 'дальше' not in m.group(0), "не захватывает лишний текст")
# ── 15. PERSON_PATTERN только кириллица ──
print("\n🔤 PERSON_PATTERN кириллица:")
m = PERSON_PATTERN.search("Next Generation Cloud service")
check(m is None, "английский не матчится")
m2 = PERSON_PATTERN.search("Иванов Иван Иванович")
check(m2 is not None, "русский матчится")
# ── ИТОГО ──
print(f"\n{'='*60}")
if errors:
print(f"❌ {errors} ошибок")
else:
print("✅ ВСЕ ТЕСТЫ ПРОЙДЕНЫ")
print(f"{'='*60}")
sys.exit(0 if errors == 0 else 1)
View File
-62
View File
@@ -1,62 +0,0 @@
"""Test classify_batch with MemRepository + FakeLLM."""
from compare.classify import classify_batch
from compare.llm_client import FakeLLMClient
from repository import MemRepository
class TestClassifyBatch:
def test_garbage_by_filename(self):
"""Счёт-фактура отфильтровывается без LLM."""
repo = MemRepository()
llm = FakeLLMClient({"default": '{"doc_type":"contract"}'})
repo.insert_document("счет-фактура №123.docx", "mime", "data", batch_id="b1")
result = classify_batch("b1", llm_client=llm, repo=repo)
assert result["ok"] is True
assert result["garbage"] == 1
assert result["done"] == 1
assert len(llm.calls) == 0 # LLM не вызывался
def test_garbage_by_header(self):
"""Акт сверки в тексте отфильтровывается."""
repo = MemRepository()
llm = FakeLLMClient({"default": '{"doc_type":"contract"}'})
doc = repo.insert_document("документ.docx", "mime", "data", batch_id="b1")
repo.set_document_parsed(doc["id"], [
{"type": "paragraph", "text": "АКТ СВЕРКИ взаимных расчётов", "style": ""}
])
result = classify_batch("b1", llm_client=llm, repo=repo)
assert result["garbage"] == 1
assert len(llm.calls) == 0
def test_llm_classify(self):
"""Договор классифицируется через LLM."""
repo = MemRepository()
llm = FakeLLMClient({
"default": '{"doc_type":"contract","own_number":"03700_1","doc_date":"2026-02-01","counterparty":"ЗАО XXX001"}'
})
doc = repo.insert_document("договор-XXX001.docx", "mime", "data", batch_id="b1")
repo.set_document_parsed(doc["id"], [
{"type": "paragraph", "text": "ДОГОВОР № 03700_1", "style": ""}
])
result = classify_batch("b1", llm_client=llm, repo=repo)
assert result["done"] == 1, f"done={result['done']}, failed={result['failed']}, doc={repo.get_document(doc['id'])}"
assert result["garbage"] == 0
assert len(llm.calls) == 1
updated = repo.get_document(doc["id"])
assert updated["doc_type"] == "contract"
assert updated["own_number"] == "03700_1"
assert updated["counterparty"] == "ЗАО XXX001"
def test_no_pending(self):
"""Пустой батч."""
repo = MemRepository()
result = classify_batch("empty", llm_client=FakeLLMClient(), repo=repo)
assert result["ok"] is False
assert "no pending" in result["error"]
-110
View File
@@ -1,110 +0,0 @@
"""Unit tests — no DB, no network, just pure logic."""
import pytest
from contracts import ParseResult, ClassifyResult, GroupingResult, CompareOp
from compare.upload import parse_multipart
from compare.classify import _is_garbage_by_filename, _is_garbage_by_header
class TestContracts:
"""Dataclass creation and validation."""
def test_parse_result(self):
r = ParseResult(status="parsed", element_count=10, elements=[])
assert r.status == "parsed"
assert r.element_count == 10
def test_classify_from_llm(self):
r = ClassifyResult.from_llm({
"doc_type": "contract",
"own_number": "03700_1",
"doc_date": "2026-02-01",
"counterparty": "ЗАО XXX001",
})
assert r.doc_type == "contract"
assert r.own_number == "03700_1"
assert r.counterparty == "ЗАО XXX001"
def test_grouping_result_empty(self):
r = GroupingResult(contract_number="03700_1")
assert r.contract_number == "03700_1"
assert r.documents == []
def test_compare_op_add(self):
op = CompareOp(action="ADD", new_row={"name": "Тест", "price": 100})
assert op.action == "ADD"
assert op.new_row["price"] == 100
def test_compare_op_from_llm(self):
op = CompareOp.from_llm({
"action": "UPDATE",
"target_id": "r1",
"new_values": {"price": 200},
"comment": "change",
})
assert op.action == "UPDATE"
assert op.new_values["price"] == 200
assert op.comment == "change"
class TestGarbageFilter:
"""Stage 1-2 garbage detection."""
def test_filename_garbage_invoice(self):
assert _is_garbage_by_filename("счет-фактура №123.docx") is True
def test_filename_garbage_act(self):
assert _is_garbage_by_filename("Акт сверки за май.pdf") is True
def test_filename_not_garbage(self):
assert _is_garbage_by_filename("договор-XXX001.docx") is False
assert _is_garbage_by_filename("спецификация услуг.pdf") is False
def test_header_garbage(self):
text = "СЧЕТ-ФАКТУРА № 123 от 01.01.2026\nПоставщик: ООО Ромашка"
assert _is_garbage_by_header(text) is True
def test_header_not_garbage(self):
text = "ДОГОВОР № 03700_1\nг. Москва\n\nИсполнитель обязуется..."
assert _is_garbage_by_header(text) is False
class TestParseMultipart:
"""Pure multipart parsing without HTTP."""
def test_simple_file(self, sample_docx_bytes):
boundary = b"----testboundary"
body = (
b"------testboundary\r\n"
b'Content-Disposition: form-data; name="files"; filename="test.docx"\r\n'
b"Content-Type: application/octet-stream\r\n\r\n"
+ sample_docx_bytes +
b"\r\n------testboundary--\r\n"
)
result = parse_multipart(body, f"multipart/form-data; boundary=----testboundary")
assert result["filename"] == "test.docx"
assert result["file_data"] == sample_docx_bytes
def test_with_contract_id(self, sample_docx_bytes):
boundary = b"----testboundary"
body = (
b"------testboundary\r\n"
b'Content-Disposition: form-data; name="files"; filename="test.docx"\r\n\r\n'
+ sample_docx_bytes +
b"\r\n------testboundary\r\n"
b'Content-Disposition: form-data; name="contract_id"\r\n\r\n'
b"550e8400-e29b-41d4-a716-446655440000"
b"\r\n------testboundary--\r\n"
)
result = parse_multipart(body, f"multipart/form-data; boundary=----testboundary")
assert result["contract_id"] == "550e8400-e29b-41d4-a716-446655440000"
def test_path_traversal_rejected(self):
boundary = b"----testboundary"
body = (
b"------testboundary\r\n"
b'Content-Disposition: form-data; name="files"; filename="../etc/passwd"\r\n\r\n'
b"evil"
b"\r\n------testboundary--\r\n"
)
with pytest.raises(ValueError, match="invalid filename"):
parse_multipart(body, f"multipart/form-data; boundary=----testboundary")
-59
View File
@@ -1,59 +0,0 @@
"""Unit tests for LLM client and Repository — with fakes."""
from compare.llm_client import FakeLLMClient
from repository import MemRepository
class TestFakeLLM:
def test_exact_match(self):
client = FakeLLMClient({"hello": "world"})
assert client.complete("hello") == "world"
def test_partial_match(self):
client = FakeLLMClient({"classify": '{"doc_type":"contract"}'})
assert "contract" in client.complete("classify this document")
def test_default_fallback(self):
client = FakeLLMClient({"default": '{"ok":true}'})
assert client.complete("unknown prompt") == '{"ok":true}'
def test_call_history(self):
client = FakeLLMClient({"a": "1", "b": "2"})
client.complete("a")
client.complete("b")
assert len(client.calls) == 2
assert client.calls[0] == "a"
class TestMemRepository:
def test_insert_and_retrieve(self, mem_repo):
doc = mem_repo.insert_document("test.docx", "application/vnd...", "base64data", batch_id="batch-1")
assert doc["filename"] == "test.docx"
assert doc["classify_status"] == "pending"
def test_list_pending(self, mem_repo):
mem_repo.insert_document("a.docx", "mime", "data", batch_id="b1")
mem_repo.insert_document("b.docx", "mime", "data", batch_id="b1")
mem_repo.insert_document("c.docx", "mime", "data", batch_id="b2")
pending = mem_repo.list_pending("b1")
assert len(pending) == 2
def test_set_classification(self, mem_repo):
doc = mem_repo.insert_document("test.docx", "mime", "data", batch_id="b1")
mem_repo.set_classification(doc["id"], "contract", "03700", None, "2026-02-01", "XXX")
updated = mem_repo.documents[doc["id"]]
assert updated["doc_type"] == "contract"
assert updated["classify_status"] == "classified"
def test_set_garbage(self, mem_repo):
doc = mem_repo.insert_document("счет.docx", "mime", "data", batch_id="b1")
mem_repo.set_classify_garbage(doc["id"], "filename_regex")
assert mem_repo.documents[doc["id"]]["doc_type"] == "garbage"
def test_contract_lifecycle(self, mem_repo):
cid = mem_repo.insert_contract("03700_1", "ЗАО XXX")
assert cid
doc = mem_repo.insert_document("test.docx", "mime", "data")
mem_repo.insert_supplement(cid, doc["id"], "initial")
supps = mem_repo.list_supplements(cid)
assert len(supps) == 1
assert supps[0]["type"] == "initial"
Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

+4
View File
@@ -4,6 +4,10 @@
import sys, os
# site/ в sys.path — импортируем модули напрямую, без префиксов
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
# корень репо — для переиспользуемого модуля upload/ (как в drhider)
_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if _REPO_ROOT not in sys.path:
sys.path.insert(0, _REPO_ROOT)
from flask import Flask
from config import VERSION, MAX_CONTENT_LENGTH
+14 -1
View File
@@ -1,7 +1,7 @@
"""Конфигурация приложения — все настройки в одном месте."""
import os
VERSION = "2.0.5"
VERSION = "2.0.24"
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-upload.sinefo.ru/contracts-upload/")
# SSRF-защита: тянуть можно ТОЛЬКО с этого префикса.
VM_UPLOAD_PREFIX = os.getenv("VM_UPLOAD_PREFIX", "https://contracts-upload.sinefo.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
+35 -17
View File
@@ -10,24 +10,33 @@ 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."""
"""Create the database schema without deleting persisted data."""
global _db_session_key
_db_session_key = time.time()
# Удалить старый файл + WAL-сателлиты
for f in (DB_PATH, DB_PATH + "-wal", DB_PATH + "-shm"):
try:
os.remove(f)
except FileNotFoundError:
pass
_db_session_key = time.time_ns()
conn = get_conn()
_create_schema(conn)
return conn
@@ -130,8 +139,20 @@ def _create_schema(conn):
def cleanup_db():
"""Удалить БД полностью. Вызывает init_db() для создания новой."""
init_db()
"""Explicitly delete the database and recreate an empty schema."""
global _db_session_key
conn = getattr(_local, "conn", None)
if conn is not None:
conn.close()
_local.conn = None
_local.session_key = None
_db_session_key = time.time_ns()
for path in (DB_PATH, DB_PATH + "-wal", DB_PATH + "-shm"):
try:
os.remove(path)
except FileNotFoundError:
pass
return init_db()
def get_conn():
@@ -171,7 +192,8 @@ def execute(sql, params=None):
conn = get_conn()
sql = _pg_to_sqlite(sql)
cur = conn.execute(sql, params or [])
conn.commit()
if not getattr(_local, "in_transaction", False):
conn.commit()
return cur.rowcount
@@ -206,10 +228,6 @@ def _pg_to_sqlite(sql):
sql = sql.replace("%s", "?")
# ::jsonb → убрать (SQLite не типизирует)
sql = sql.replace("::jsonb", "")
# gen_random_uuid() → хекс-UUID через randomblob
if "gen_random_uuid()" in sql:
import uuid
sql = sql.replace("gen_random_uuid()", "?")
# BOOLEAN → INTEGER
sql = sql.replace(" BOOLEAN ", " INTEGER ")
sql = sql.replace(" bool ", " INTEGER ")
+1 -1
View File
@@ -85,7 +85,7 @@ def seed_defaults():
def list_by_role(role):
"""List all versions for a role, newest first."""
rows = query(
"SELECT id, role, name, is_active, created_by, notes, created_at FROM prompts WHERE role=%s ORDER BY created_at DESC",
"SELECT id, role, name, is_active, notes, created_at FROM prompts WHERE role=%s ORDER BY created_at DESC",
(role,),
)
for r in rows:
+35 -15
View File
@@ -1,6 +1,6 @@
"""spec_events — event sourcing: apply ops, reset contract."""
import json, uuid
from db.connection import query, execute, get_conn
from db.connection import query, execute, get_conn, transaction
def reset(contract_id):
@@ -9,6 +9,11 @@ def reset(contract_id):
execute("DELETE FROM spec_events WHERE contract_id = %s", (contract_id,))
def clear_current(contract_id):
"""Очистить ТОЛЬКО текущее состояние спецификации (история spec_events сохраняется). Для full_replace."""
execute("DELETE FROM spec_current WHERE contract_id = %s", (contract_id,))
def get_next_seq(contract_id):
"""Get next sequence number. WAL serializes writers — no explicit lock needed."""
conn = get_conn()
@@ -22,9 +27,15 @@ def get_next_seq(contract_id):
def apply_ops(contract_id, supplement_id, document_id, ops, prompt_id, raw_llm_response):
"""Apply ADD/UPDATE/DELETE ops. Returns summary dict."""
with transaction():
return _apply_ops(contract_id, supplement_id, document_id, ops, prompt_id, raw_llm_response)
def _apply_ops(contract_id, supplement_id, document_id, ops, prompt_id, raw_llm_response):
added = 0
updated = 0
deleted = 0
unresolved = 0
seq = get_next_seq(contract_id)
for op in ops:
@@ -37,6 +48,7 @@ def apply_ops(contract_id, supplement_id, document_id, ops, prompt_id, raw_llm_r
# ADD without name → UNRESOLVED
_log_unresolved(contract_id, supplement_id, seq, op, prompt_id, document_id, raw_llm_response, "ADD with empty name")
seq += 1
unresolved += 1
continue
name_hash = _hash(name, nr.get("date_start"))
execute(
@@ -60,6 +72,7 @@ def apply_ops(contract_id, supplement_id, document_id, ops, prompt_id, raw_llm_r
if not th:
_log_unresolved(contract_id, supplement_id, seq, op, prompt_id, document_id, raw_llm_response, "UPDATE with empty target_hash")
seq += 1
unresolved += 1
continue
execute(
"""INSERT INTO spec_events (id, contract_id, supplement_id, seq, action, target_hash,
@@ -81,6 +94,7 @@ def apply_ops(contract_id, supplement_id, document_id, ops, prompt_id, raw_llm_r
if not th:
_log_unresolved(contract_id, supplement_id, seq, op, prompt_id, document_id, raw_llm_response, "DELETE with empty target_hash")
seq += 1
unresolved += 1
continue
execute(
"""INSERT INTO spec_events (id, contract_id, supplement_id, seq, action, target_hash,
@@ -99,27 +113,19 @@ def apply_ops(contract_id, supplement_id, document_id, ops, prompt_id, raw_llm_r
elif action == "UNRESOLVED":
# Log but don't apply
execute(
"""INSERT INTO spec_events (id, contract_id, supplement_id, seq, action, target_hash,
new_values, comment, status, prompt_version, source_document_id, raw_llm_response)
VALUES (%s, %s, %s, %s, 'UNRESOLVED', %s, %s, %s, 'unresolved', %s, %s, %s)""",
(
str(uuid.uuid4()), contract_id, supplement_id, seq,
op.get("target_hash", ""),
json.dumps(op.get("new_values", {}), ensure_ascii=False),
op.get("reason", op.get("comment", "")),
prompt_id, document_id,
json.dumps(raw_llm_response, ensure_ascii=False),
),
)
_log_unresolved(contract_id, supplement_id, seq, op, prompt_id, document_id, raw_llm_response,
op.get("reason", op.get("comment", "")))
seq += 1
unresolved += 1
else:
# Unknown action — log as UNRESOLVED
_log_unresolved(contract_id, supplement_id, seq, op, prompt_id, document_id, raw_llm_response,
f"unknown action: {action}")
seq += 1
unresolved += 1
return {"added": added, "updated": updated, "deleted": deleted}
return {"added": added, "updated": updated, "deleted": deleted, "unresolved": unresolved}
def _log_unresolved(contract_id, supplement_id, seq, op, prompt_id, document_id, raw_llm_response, reason):
@@ -182,6 +188,20 @@ def _update_spec_current(contract_id, name_hash, new_values):
sets.append(f"{field} = %s")
params.append(new_values[field])
if sets:
if "name" in new_values or "date_start" in new_values:
updated_name = new_values.get("name")
updated_date = new_values.get("date_start")
if updated_name is None or updated_date is None:
current = query(
"SELECT name, date_start FROM spec_current WHERE contract_id = %s AND name_hash = %s",
(contract_id, name_hash),
)
if current:
updated_name = updated_name if updated_name is not None else current[0]["name"]
updated_date = updated_date if updated_date is not None else current[0]["date_start"]
if updated_name is not None:
sets.append("name_hash = %s")
params.append(_hash(updated_name, updated_date))
sets.append("updated_at = datetime('now')")
params.extend([contract_id, name_hash])
execute(
+1 -1
View File
@@ -201,7 +201,7 @@ def _build_spec_text(current_spec: list) -> str:
def build_prompt(current_spec: list, doc_text: str) -> tuple:
"""
Формирует промпт для LLM.
1. Пробует получить активный промпт из БД (Lucee API).
1. Пробует получить активный промпт из БД (SQLite, db.prompts).
2. При неудаче — fallback на хардкод.
Возвращает (текст_промпта, prompt_id).
prompt_id — UUID версии промпта из БД, или "" если fallback.
+12 -2
View File
@@ -2,16 +2,26 @@
def register_routes(app):
from routes.upload_bp import upload_bp
import config
from routes.upload_bp import contracts_upload_sink
from routes.pipeline_bp import pipeline_bp
from routes.api_bp import api_bp
from routes.prompts_bp import prompts_bp
from routes.health_bp import health_bp
from routes.pages_bp import pages_bp
from upload.backend.upload_refs import create_upload_refs_blueprint
app.register_blueprint(upload_bp)
app.register_blueprint(pipeline_bp)
app.register_blueprint(api_bp)
app.register_blueprint(prompts_bp)
app.register_blueprint(health_bp)
app.register_blueprint(pages_bp)
# Переиспользуемый слой закачки через ВМ (модуль upload, паттерн drhider)
app.register_blueprint(create_upload_refs_blueprint({
"apiPrefix": "/api",
"vmUploadPrefix": config.VM_UPLOAD_PREFIX,
"maxFileBytes": config.VM_UPLOAD_MAX_BYTES,
"pullRetries": config.PULL_RETRIES,
"pullRetryDelay": config.PULL_RETRY_DELAY,
}, sink=contracts_upload_sink))
+44 -36
View File
@@ -1,7 +1,7 @@
"""API blueprint — groups, documents, supplements, sync, cleanup, spec-current, chat."""
from flask import Blueprint, request, jsonify
from db import documents, supplements, spec_current
from db.connection import execute, query
from db.connection import execute, query, transaction
from services.grouping import group_documents, apply_groups
from config import LLM_URL, LLM_KEY, LLM_MODEL
import httpx
@@ -38,47 +38,65 @@ def api_document(doc_id):
@api_bp.route("/api/documents/<doc_id>", methods=["DELETE"])
def api_document_delete(doc_id):
if not documents.get(doc_id):
return jsonify(ok=False, error="not found"), 404
with transaction():
contract_ids = _delete_document(doc_id)
_delete_orphan_contracts(contract_ids)
return jsonify(ok=True)
def _delete_document(doc_id):
supps = query("SELECT id, contract_id FROM supplements WHERE document_id = %s", (doc_id,))
for s in (supps or []):
contract_ids = {row["contract_id"] for row in supps}
for supp in supps:
execute(
"""DELETE FROM spec_current WHERE contract_id = %s
AND last_event_id IN (SELECT id FROM spec_events WHERE supplement_id = %s)""",
(s["contract_id"], s["id"]),
(supp["contract_id"], supp["id"]),
)
execute("DELETE FROM spec_events WHERE supplement_id = %s", (s["id"],))
execute("DELETE FROM supplements WHERE id = %s", (s["id"],))
execute("DELETE FROM spec_events WHERE supplement_id = %s", (supp["id"],))
execute("DELETE FROM supplements WHERE id = %s", (supp["id"],))
execute("DELETE FROM documents WHERE id = %s", (doc_id,))
return jsonify(ok=True)
return contract_ids
def _delete_orphan_contracts(contract_ids):
for contract_id in contract_ids:
remaining = query(
"SELECT id FROM supplements WHERE contract_id = %s LIMIT 1",
(contract_id,),
)
if not remaining:
execute("DELETE FROM contracts WHERE id = %s", (contract_id,))
# ── Sync ─────────────────────────────────────────────────────────
@api_bp.route("/api/sync", methods=["POST"])
def api_sync():
"""Удалить документы, не входящие в keep_ids."""
body = request.get_json()
keep_ids = set(body.get("keep_ids", []))
"""Remove documents absent from keep_ids in the specified batch only."""
body = request.get_json(silent=True) or {}
batch_id = body.get("batch_id")
keep_ids = body.get("keep_ids", [])
if not isinstance(batch_id, str) or not batch_id.strip():
return jsonify(ok=False, error="batch_id required"), 400
if not isinstance(keep_ids, list) or any(not isinstance(doc_id, str) for doc_id in keep_ids):
return jsonify(ok=False, error="keep_ids must be a list of document IDs"), 400
if len(keep_ids) > 1000:
return jsonify(ok=False, error="too many keep_ids (max 1000)"), 400
docs = query("SELECT id FROM documents", ())
docs = query("SELECT id FROM documents WHERE batch_id = %s", (batch_id,))
deleted = 0
for d in (docs or []):
if d["id"] in keep_ids:
continue
supps = query("SELECT id, contract_id FROM supplements WHERE document_id = %s", (d["id"],))
for s in (supps or []):
execute(
"""DELETE FROM spec_current WHERE contract_id = %s
AND last_event_id IN (SELECT id FROM spec_events WHERE supplement_id = %s)""",
(s["contract_id"], s["id"]),
)
execute("DELETE FROM spec_events WHERE supplement_id = %s", (s["id"],))
execute("DELETE FROM supplements WHERE id = %s", (s["id"],))
execute("DELETE FROM documents WHERE id = %s", (d["id"],))
deleted += 1
execute("DELETE FROM contracts WHERE id NOT IN (SELECT DISTINCT contract_id FROM supplements)")
contract_ids = set()
keep_ids = set(keep_ids)
with transaction():
for doc in docs:
if doc["id"] in keep_ids:
continue
contract_ids.update(_delete_document(doc["id"]))
deleted += 1
_delete_orphan_contracts(contract_ids)
return jsonify(ok=True, deleted=deleted)
@@ -170,13 +188,3 @@ def chat():
return jsonify(OK=True, ANSWER=answer)
except Exception as e:
return jsonify(OK=False, ERROR=f"LLM error: {e}")
# ── Cleanup (атомарное удаление БД) ──────────────────────────────
@api_bp.route("/api/cleanup", methods=["POST"])
def api_cleanup():
"""Полная очистка: os.remove(DB) + init новой. Данные гарантированно стёрты."""
from db.connection import cleanup_db
cleanup_db()
return jsonify(ok=True, message="all data cleaned")
+24 -1
View File
@@ -1,8 +1,19 @@
"""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",
)
_FILE_PICKER_DIST = os.path.join(
os.path.dirname(os.path.dirname(_UPLOAD_FRONTEND)),
"site", "static", "dist",
)
@pages_bp.route("/")
def index():
@@ -12,3 +23,15 @@ 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)
@pages_bp.route("/file-picker/<path:filename>")
def file_picker_bundle(filename):
"""Отдать собранный picker bundle."""
return send_from_directory(_FILE_PICKER_DIST, filename)
+2 -2
View File
@@ -39,7 +39,7 @@ def prompts_save():
prompt_body = body.get("body", "")
notes = body.get("notes", "")
try:
p = db_prompts.insert(role, name, prompt_body, notes)
p = db_prompts.save_new_version(role, name, prompt_body, notes)
return jsonify(ok=True, id=p["id"])
except Exception as e:
return jsonify(ok=False, error=str(e)), 500
@@ -65,7 +65,7 @@ def prompts_delete():
return jsonify(ok=False, error="body required"), 400
prompt_id = body.get("id")
try:
db_prompts.delete(prompt_id)
db_prompts.delete_prompt(prompt_id)
return jsonify(ok=True)
except Exception as e:
return jsonify(ok=False, error=str(e)), 500
+43 -113
View File
@@ -1,47 +1,45 @@
"""Upload blueprint — загрузка, конвертация, распаковка."""
import io, os, base64, hashlib, zipfile, tempfile, subprocess
from flask import Blueprint, request, jsonify, send_file
"""Upload-модуль: конвертация .doc→.docx + хранение/парсинг (sink для VM-буфера)."""
import base64
import hashlib
import httpx
from services.parse import parse_file
from db import documents
from config import MAX_CONTENT_LENGTH
upload_bp = Blueprint("upload", __name__)
ALLOWED = {"pdf", "docx", "doc", "zip"}
import config
def _check_ext(filename: str) -> str | None:
ext = filename.rsplit(".", 1)[-1].lower() if "." in filename else ""
if ext not in ALLOWED:
return f"unsupported format: .{ext} (allowed: {', '.join(sorted(ALLOWED))})"
return None
def _convert(filename: str, data: bytes) -> bytes:
""".doc → .docx через внешний libreoffice-сервис. Возвращает docx-байты."""
try:
resp = httpx.post(
config.CONVERT_SERVICE_URL + "/convert",
files={"file": (filename, data, "application/msword")},
timeout=120,
)
except httpx.TimeoutException:
raise Exception("conversion timeout")
if resp.status_code != 200:
try:
err = resp.json().get("error", "conversion failed")
except Exception:
err = "conversion failed"
raise Exception(err)
return resp.content
@upload_bp.route("/upload", methods=["POST"])
def upload():
"""Загрузка одного файла + авто-парсинг → БД."""
f = request.files.get("files")
if not f:
return jsonify(ok=False, error="no file"), 400
err = _check_ext(f.filename)
if err:
return jsonify(ok=False, error=err), 400
data = f.read()
def _store_and_parse(filename: str, data: bytes, batch_id, contract_id, zip_source=None, mime_type="application/octet-stream"):
"""Общая логика: дедуп → insert в БД → авто-парсинг. Возвращает dict-результат."""
content_hash = hashlib.sha256(data).hexdigest()[:16]
batch_id = request.form.get("batch_id")
zip_source = request.form.get("zip_source")
# Дедупликация по хешу
if batch_id:
existing = documents.get_by_hash(batch_id, content_hash)
if existing:
return jsonify(ok=False, error="duplicate", doc_id=existing["id"])
return {"ok": False, "error": "duplicate", "doc_id": existing["id"], "duplicate_of": True}
doc = documents.insert(
filename=f.filename,
mime_type=f.content_type or "application/octet-stream",
filename=filename,
mime_type=mime_type,
original_bytes=base64.b64encode(data).decode(),
batch_id=batch_id,
zip_source=zip_source,
@@ -50,96 +48,28 @@ def upload():
# Авто-парсинг
try:
result = parse_file(f.filename, data)
result = parse_file(filename, data)
if result["status"] == "parsed":
documents.set_parsed(doc["id"], result["elements"])
parsed = {"status": "parsed", "element_count": result.get("element_count", 0)}
else:
documents.set_error(doc["id"], result.get("error", "parse failed"))
parsed = {"status": "error", "error": result.get("error", "parse failed")}
except Exception as e:
documents.set_error(doc["id"], str(e))
result = {"status": "error", "error": str(e)}
parsed = {"status": "error", "error": str(e)}
contract_id = request.form.get("contract_id")
return jsonify(
ok=True,
doc_id=doc["id"],
contract_id=contract_id,
parsed={"status": result["status"], "element_count": result.get("element_count", 0)},
)
return {"ok": True, "doc_id": doc["id"], "contract_id": contract_id, "parsed": parsed}
@upload_bp.route("/convert-doc", methods=["POST"])
def convert_doc():
""".doc → .docx через libreoffice (без сохранения на диск)."""
f = request.files.get("files")
if not f:
return jsonify(ok=False, error="no file"), 400
def contracts_upload_sink(name, content, batch_id=None, contract_id=None, zip_source=None):
"""Sink для переиспользуемого модуля upload: вставить файл в documents + авто-парсинг.
data = f.read()
doc_path = None
tmpdir = None
try:
with tempfile.NamedTemporaryFile(suffix=".doc", delete=False) as tmp:
tmp.write(data)
doc_path = tmp.name
tmpdir = tempfile.mkdtemp()
subprocess.run(
["libreoffice", "--headless", "--convert-to", "docx", "--outdir", tmpdir, doc_path],
timeout=30, capture_output=True,
)
docx_files = [x for x in os.listdir(tmpdir) if x.endswith(".docx")]
if docx_files:
with open(os.path.join(tmpdir, docx_files[0]), "rb") as out:
return send_file(
io.BytesIO(out.read()),
mimetype="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
)
return jsonify(ok=False, error="conversion produced no output"), 500
finally:
if doc_path and os.path.exists(doc_path):
os.unlink(doc_path)
if tmpdir and os.path.exists(tmpdir):
for x in os.listdir(tmpdir):
os.unlink(os.path.join(tmpdir, x))
os.rmdir(tmpdir)
@upload_bp.route("/unzip-upload", methods=["POST"])
def unzip_upload():
"""Распаковать ZIP → список файлов (base64 для фронтенда)."""
f = request.files.get("files")
if not f:
return jsonify(ok=False, error="no file"), 400
data = f.read()
MAX_FILES = 500
MAX_UNCOMPRESSED = 500 * 1024 * 1024 # 500 MB
files = []
total = 0
with zipfile.ZipFile(io.BytesIO(data)) as zf:
if len(zf.namelist()) > MAX_FILES:
return jsonify(ok=False, error=f"too many files in ZIP (max {MAX_FILES})"), 400
for info in zf.infolist():
if info.is_dir():
continue
name = os.path.basename(info.filename)
if not name or ".." in name or "/" in name or "\\" in name:
continue
raw = zf.read(info)
total += len(raw)
if total > MAX_UNCOMPRESSED:
return jsonify(ok=False, error="total uncompressed size exceeds 500 MB"), 400
ext = name.rsplit(".", 1)[-1].lower() if "." in name else ""
files.append({
"filename": name,
"ext": ext,
"size": len(raw),
"data_b64": base64.b64encode(raw).decode(),
})
return jsonify(ok=True, files=files)
Вызывается модулем create_upload_refs_blueprint(cfg, sink=...) на каждый
вытянутый с ВМ файл. .doc конвертируется в .docx через внешний LibreOffice
(парсер умеет только .docx). Возвращает dict ok/doc_id/contract_id/parsed.
"""
if name.lower().endswith(".doc"):
content = _convert(name, content)
name = name[:-4] + ".docx"
return _store_and_parse(name, content, batch_id, contract_id, zip_source)
+9 -7
View File
@@ -10,7 +10,7 @@ Classify service — LLM-based document classification.
- Двухпроходная архитектура: LLM извлекает строки (тип/номер/дата/контрагент),
Python в grouping.py нормализует и группирует детерминированно.
"""
import json, re, os
import json, re
from concurrent.futures import ThreadPoolExecutor, as_completed
import httpx
@@ -23,9 +23,7 @@ log = __import__("logging").getLogger(__name__)
# Увеличивать осторожно — api.aillm.ru может троттлить
MAX_WORKERS = 4
LLM_URL = "https://api.aillm.ru/v1/chat/completions"
LLM_KEY = os.environ.get("LLM_KEY") or os.environ.get("LLM_API_KEY", "")
LLM_MODEL = "gpt-oss-120b"
from config import LLM_URL, LLM_KEY, LLM_MODEL
# Ленивый singleton — обратная совместимость
_classify_llm = None
@@ -49,9 +47,13 @@ _GARBAGE_HEADER_MARKERS = [
'СЧЕТ-ФАКТУРА', 'СЧЕТ НА ОПЛАТУ', 'АКТ СВЕРКИ',
'АКТ ОКАЗАННЫХ УСЛУГ', 'АКТ ВЫПОЛНЕННЫХ РАБОТ',
'ПЛАТЁЖНОЕ ПОРУЧЕНИЕ', 'УНИВЕРСАЛЬНЫЙ ПЕРЕДАТОЧНЫЙ',
'УПД', 'ПЛАТЕЖНОЕ ПОРУЧЕНИЕ',
'ПЛАТЕЖНОЕ ПОРУЧЕНИЕ',
]
_GARBAGE_HEADER_RE = re.compile(
r'(?m)^\s*(?:УПД|УНИВЕРСАЛЬНЫЙ ПЕРЕДАТОЧНЫЙ ДОКУМЕНТ)\b'
)
def _is_garbage_by_filename(filename: str) -> bool:
"""Stage 1: regex по имени файла — быстро, 0 токенов."""
@@ -61,7 +63,7 @@ def _is_garbage_by_filename(filename: str) -> bool:
def _is_garbage_by_header(text: str) -> bool:
"""Stage 2: ключевые слова в первых 2KB текста — быстро, 0 токенов."""
header = text[:2000].upper()
return any(marker in header for marker in _GARBAGE_HEADER_MARKERS)
return any(marker in header for marker in _GARBAGE_HEADER_MARKERS) or bool(_GARBAGE_HEADER_RE.search(header))
def _call_llm_classify(header_text, llm_client=None):
@@ -91,7 +93,7 @@ def classify_batch(batch_id, llm_client=None, repo=None):
_db = repo if repo else db_docs
_llm = llm_client if llm_client else _get_classify_client()
# Сбросить статус — allow re-classify after file changes
# Сбросить 'processing' -> 'pending' (crash recovery); уже классифицированные не трогаем
_db.reset_classify_status(batch_id)
pending = _db.list_pending(batch_id)
if not pending:
+2 -4
View File
@@ -1,9 +1,7 @@
"""LLM service — call LLM API with optional DI."""
import os, json
import json
LLM_URL = "https://api.aillm.ru/v1/chat/completions"
LLM_KEY = os.environ.get("LLM_KEY") or os.environ.get("LLM_API_KEY", "")
LLM_MODEL = "gpt-oss-120b"
from config import LLM_URL, LLM_KEY, LLM_MODEL
# Ленивый singleton — обратная совместимость
_llm_client = None
+29 -2
View File
@@ -77,6 +77,27 @@ def run_pipeline(contract_id, order_ids, build_prompt_fn):
ops = result.get("ops", [])
mode = result.get("mode", "llm")
if mode == "full_replace" and not ops:
yield {
"type": "extract_error",
"supplement_id": sid,
"filename": filename,
"error": "full_replace returned empty ops",
}
continue
# Трансляция target_id ("r1","r2"...) → target_hash (name_hash из current_spec).
# LLM возвращает target_id, а apply_ops() читает target_hash — без этого UPDATE/DELETE уходят в UNRESOLVED.
for _op in ops:
_tid = _op.get("target_id", "")
if _tid and isinstance(_tid, str) and _tid.startswith("r"):
try:
_idx = int(_tid[1:]) - 1
if 0 <= _idx < len(current_spec):
_op["target_hash"] = current_spec[_idx]["hash"]
except ValueError:
pass
yield {
"type": "llm_done",
"supplement_id": sid,
@@ -86,6 +107,11 @@ def run_pipeline(contract_id, order_ids, build_prompt_fn):
"time_s": round(time.time() - t1, 1),
}
# full_replace: очистить текущее состояние, чтобы ADD-строки новой редакции
# не дублировали старые (reset на старте пайплайна это не покрывает).
if mode == "full_replace":
spec_events.clear_current(contract_id)
# Apply ops to DB via apply_ops
try:
summary = spec_events.apply_ops(
@@ -103,7 +129,7 @@ def run_pipeline(contract_id, order_ids, build_prompt_fn):
}
# Arithmetic check
check_arithmetic(ops)
arithmetic_warnings = check_arithmetic(ops)
yield {
"type": "applied",
@@ -111,6 +137,7 @@ def run_pipeline(contract_id, order_ids, build_prompt_fn):
"filename": filename,
"summary": summary,
"ops": applied_ops,
"arithmetic_warnings": arithmetic_warnings,
}
except Exception as e:
@@ -122,7 +149,7 @@ def run_pipeline(contract_id, order_ids, build_prompt_fn):
}
total_time = round(time.time() - t0, 1)
yield {"type": "complete", "total_time_s": total_time}
yield {"type": "done", "total_time_s": total_time}
def _elements_to_text(ej):
+32 -33
View File
@@ -1,45 +1,51 @@
// ⛔ НЕ МЕНЯТЬ БЕЗ РАЗРЕШЕНИЯ НАЕЛЯ ⛔
// Contracts App v2.0 — всё на Flask, ВМ больше нет
// Contracts App v2.0 — backend на Flask; ВМ используется только как upload buffer.
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-upload.sinefo.ru/contracts-upload/';
// SITE_URL удалён (Фаза 4) — не использовался
var fileInput = document.getElementById('fileInput');
var fileTable = document.getElementById('fileTable');
// state.files — теперь в state.js (Фаза 0: state + render)
// state.contractId — теперь в state.js (Фаза 0: state + render)
// state.batchId — теперь в state.js (Фаза 0: state + render)
// (batchId = crypto.randomUUID() — уникальный ID сессии для классификации)
// Прогрев upstream + автоочистка при загрузке страницы
// Прогрев приложения при загрузке страницы.
// ⚠️ Без прогрева первый POST может упасть с ERR_CONNECTION_RESET (MTU/Geneve)
(async function init() {
try { await fetch('/health'); } catch(e) { /* ignore */ }
try { await fetch(VM_API + '/api/cleanup', { method: 'POST' }); } catch(e) { /* ignore */ }
})();
/**
* render(state) — Главная функция рендеринга (Фаза 0, decoupling-final-plan.md).
*
* ПАТТЕРН: action → мутация state → render(state)
*
* Фаза 0: вызывает существующий renderTable().
* renderTable читает state.files напрямую (state — глобальный объект).
* Фаза 1+: будет вызывать renderFiles(state), renderGroups(state), renderStepper(state) и т.д.
*
* ПРАВИЛО: любое изменение state ДОЛЖНО завершаться вызовом render(state).
* Никакой прямой манипуляции DOM в обход render().
* console.log(state) показывает ВСЁ состояние в любой момент.
* Обновить отображение picker и текущий шаг pipeline после изменения state.
*/
function render(state) {
renderFiles(state);
renderStepper(state);
}
// syncDB, statusToHTML, renderFiles, toggleClassifyDetail, uploadFile, refreshSupps
// → вынесены в files.js (Фаза 1)
if (!window.FilePicker) throw new Error('Не загружен бандл FilePicker');
filePicker = window.FilePicker.initFilePicker({
mount: '#filePicker',
allowedExt: ['.pdf', '.doc', '.docx'],
limits: FILE_PICKER_LIMITS,
onChange: onPickerFilesChange,
onError: function(error, file) {
filePicker.setMessage('Ошибка чтения ' + file.name + ': ' + error.message);
},
onDetails: function(id) {
var index = state.files.findIndex(function(file) { return file.pickerId === id; });
if (index >= 0) toggleClassifyDetail(index);
},
onText: function(id) {
var index = state.files.findIndex(function(file) { return file.pickerId === id; });
if (index >= 0) showText(index);
}
});
// Picker selection, upload queue, syncDB, document details and finalization
// are wired by files.js.
// ── Pipeline stepper (Фаза 4: state-driven) ──────────────────
@@ -52,7 +58,7 @@ function render(state) {
function renderStepper(state) {
var steps = state.ui.steps;
var map = { upload: 'stepUpload', classify: 'stepClassify', groups: 'stepGroups', compare: 'stepCompare' };
var colors = { '○': 'var(--muted)', '⏳': 'var(--brand-primary)', '✓': 'var(--green)' };
var colors = { '○': 'var(--topbar-muted)', '⏳': 'var(--topbar-active)', '✓': 'var(--topbar-done)' };
Object.keys(map).forEach(function(key) {
var el = document.getElementById(map[key]);
if (el) {
@@ -97,22 +103,13 @@ function resetStepper(fromId) {
}
// Утилиты (formatSize, formatDate, moveUp, moveDown, escHtml, fmtDate) —
// вынесены в app_utils.js для облегчения анализа и отладки.
// statusToHTML, renderFiles → files.js (Фаза 1)
// Picker rows are rendered by the FilePicker integration in files.js.
// ── Раскрыть/скрыть результат классификации под строкой файла ──
// toggleClassifyDetail → files.js (Фаза 1)
// moveUp/moveDown/removeFile/openAbout/closeAbout — вынесены в app_utils.js
// ── Автозагрузка при выборе файлов ──────────────────────────
// Фаза 1 (decoupling-final-plan.md): fileInput handler разбит на чистые функции
// onFilesSelected → reconcileSelection → addZipFile/addRegularFile → finalizeUpload
// Вся логика — в files.js. Здесь только тонкая обёртка.
fileInput.addEventListener('change', async function() {
var newFiles = Array.from(fileInput.files);
onFilesSelected(newFiles);
});
// ── Классификация ──────────────────────────────────────────
function showClassifyBtn() {
var btn = document.getElementById('classifyBtn');
@@ -149,7 +146,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) + 'с)';
+6 -13
View File
@@ -6,7 +6,7 @@
/**
* Форматирует размер файла в человекочитаемый вид.
* Используется в renderTable() для колонки "Размер".
* Используется в модальном окне документа.
*/
function formatSize(bytes) {
if (!bytes || bytes === 0) return '—';
@@ -17,7 +17,7 @@ function formatSize(bytes) {
/**
* Форматирует timestamp в российскую дату + время.
* Используется в renderTable() для колонки "Изменён".
* Используется в модальном окне документа.
*/
function formatDate(ts) {
if (!ts) return '—';
@@ -33,15 +33,8 @@ function formatDate(ts) {
* Если очередь опустела — сбрасывает state.contractId.
*/
function removeFile(i) {
state.files.splice(i, 1);
if (state.files.length === 0) state.contractId = null;
render(state);
// Синхронизировать БД с таблицей
if (typeof syncDB === 'function') syncDB();
// Сбросить прогресс при изменении состава файлов
if (typeof resetStepper === 'function') resetStepper('stepUpload');
// Разблокировать кнопку классификации при изменении состава файлов
if (typeof showClassifyBtn === 'function') showClassifyBtn();
var file = state.files[i];
if (file && filePicker) filePicker.remove(file.pickerId);
}
window.removeFile = removeFile;
@@ -72,14 +65,14 @@ window.moveUp = moveUp;
window.moveDown = moveDown;
/**
* Открыть модальное окно "О сервисе".
* Открыть модальное окно справки.
*/
function openAbout() {
document.getElementById('aboutModalOverlay').classList.add('open');
}
/**
* Закрыть модальное окно "О сервисе" (по клику на оверлей или кнопку).
* Закрыть модальное окно справки по клику на оверлей или кнопку.
*/
function closeAbout(e) {
if (e && e.target !== document.getElementById('aboutModalOverlay')) return;
+5 -5
View File
@@ -90,15 +90,15 @@ function applyCompareEvent(sections, event) {
*/
function renderCompareSectionHeader(sec) {
if (sec.status === 'extracting') {
return '⏳ ' + sec.filename;
return '⏳ ' + escHtml(sec.filename);
}
if (sec.status === 'llm_done' || sec.status === 'applied') {
return '✓ ' + sec.filename + ' — ' + sec.ops_count + ' оп., ' + sec.mode + ' (' + sec.time_s + 'с)';
return '✓ ' + escHtml(sec.filename) + ' — ' + sec.ops_count + ' оп., ' + sec.mode + ' (' + sec.time_s + 'с)';
}
if (sec.status === 'error') {
return '✗ ' + sec.filename + ': ' + sec.error;
return '✗ ' + escHtml(sec.filename) + ': ' + sec.error;
}
return sec.filename;
return escHtml(sec.filename);
}
/**
@@ -117,7 +117,7 @@ function renderCompareOpsTable(ops) {
// Цвет строки зависит от действия: ADD=зелёный, DELETE=красный, UPDATE=жёлтый
var cls = action === 'ADD' ? 'diff-added' : action === 'DELETE' ? 'diff-deleted' : action === 'UPDATE' ? 'diff-changed' : '';
var nr = op.new_row || {};
html += '<tr class="' + cls + '"><td>' + action + '</td><td>' + (nr.name||'') + '</td><td class="num-cell">' + (nr.price!=null?nr.price:'') + '</td><td class="num-cell">' + (nr.qty!=null?nr.qty:'') + '</td><td class="num-cell">' + (nr.sum!=null?nr.sum:'') + '</td><td>' + (nr.date_start||'') + '</td></tr>';
html += '<tr class="' + cls + '"><td>' + action + '</td><td>' + escHtml(nr.name||'') + '</td><td class="num-cell">' + (nr.price!=null?nr.price:'') + '</td><td class="num-cell">' + (nr.qty!=null?nr.qty:'') + '</td><td class="num-cell">' + (nr.sum!=null?nr.sum:'') + '</td><td>' + (nr.date_start||'') + '</td></tr>';
});
html += '</tbody></table></div>';
return html;
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Arjun Barrett
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
File diff suppressed because it is too large Load Diff
+55 -4
View File
@@ -1,4 +1,55 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="4" fill="#001C34"/>
<text x="16" y="24" text-anchor="middle" font-size="22" font-weight="bold" fill="white" font-family="sans-serif">N</text>
</svg>
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="57"
height="57"
xml:space="preserve"
overflow="hidden"
version="1.1"
id="svg8"
sodipodi:docname="U_v3.svg"
inkscape:version="1.3.2 (091e20e, 2023-11-25, custom)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview
id="namedview8"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="16.226667"
inkscape:cx="27.146672"
inkscape:cy="28.317584"
inkscape:window-width="2560"
inkscape:window-height="1494"
inkscape:window-x="-11"
inkscape:window-y="-11"
inkscape:window-maximized="1"
inkscape:current-layer="svg8" /><defs
id="defs2"><clipPath
id="clip0"><rect
x="652"
y="420"
width="64"
height="75"
id="rect1" /></clipPath><clipPath
id="clip1"><rect
x="652"
y="420"
width="64"
height="70"
id="rect2" /></clipPath></defs><g
clip-path="url(#clip0)"
transform="matrix(1.0135748,0,0,1.0135748,-664.97034,-440.30567)"
id="g8"><g
clip-path="url(#clip1)"
id="g7"><g
id="g6"><path
d="m 114.028,43.1492 v 7.6592 c 0,3.7843 -3.08,6.8632 -6.866,6.8632 L 85.3367,57.5419 c -3.7853,0 -6.8655,-3.0805 -6.8655,-6.8643 v -2.5279 l 0.0555,0.009 V 15.8148 l -10.3823,2.0127 0.0045,3.8772 -0.0045,0.0015 v 28.971 c 0,9.481 7.7132,17.1923 17.1867,17.1923 l 21.8359,0.1313 c 9.474,0 17.187,-7.7117 17.187,-17.1928 v -2.6541 l 0.027,0.0045 V 15.8145 l -10.382,2.0126 0.028,25.3214 z"
fill="#001c34"
fill-rule="evenodd"
transform="matrix(1,0,0,1.01337,587.92,420.059)"
id="path6" /></g></g></g></svg>

Before

Width:  |  Height:  |  Size: 246 B

After

Width:  |  Height:  |  Size: 2.0 KiB

+260 -376
View File
@@ -3,133 +3,48 @@
*
* ⛔⛔⛔ НЕ МЕНЯТЬ БЕЗ РАЗРЕШЕНИЯ НАЕЛЯ ⛔⛔⛔
*
* v2.0.3: честный счётчик ⏳ соединение... Nс вместо фейкового ↑N%
* Проверено в бою 2026-07-16. Любое изменение = риск сломать загрузку.
*
* КЛЮЧЕВЫЕ ФУНКЦИИ (не трогать):
* - uploadFile() — fetch-загрузка с имитацией прогресса
* - onFilesSelected() — все строки в таблицу сразу, потом загрузка по одной
* - statusToHTML() — рендер статуса (↑ N%, ✓)
* - renderFiles() — рендер всей таблицы
/**
* statusToHTML(status) — Чистая функция: структура → HTML (Фаза 1).
*
* Вход: { kind, pct?, text?, count?, elapsed? } — ни одного HTML-тега.
* kind = 'uploading' | 'uploaded' | 'unzipping' | 'parsing' | 'parsed' | 'error' | '' | 'connecting'
* Выход: безопасная HTML-строка (статусы не содержат пользовательских данных).
*
* ПАТТЕРН (decoupling-final-plan.md): отделяем данные от представления.
* state хранит чистые данные, statusToHTML — чистая функция рендеринга.
* FilePicker owns selection/tree rendering; this module connects it to the
* Contracts batch, upload sink, and document pipeline.
*
* Состав и обработка документов синхронизируются с FilePicker в app.js.
*/
function statusToHTML(st) {
if (!st || !st.kind) return '';
switch (st.kind) {
case 'connecting': return '⏳ соединение... ' + (st.elapsed || 0) + 'с';
case 'uploading': return '⏳ отправка... ' + (st.elapsed || 0) + 'с';
case 'uploaded': return '<span class="status-ok">✓</span>';
case 'unzipping': return '⏳ распаковка...';
case 'parsing': return '⏳ парсинг...';
case 'parsed': return '<span class="status-ok">✓ ' + (st.count || 0) + ' эл.' + (st.elapsed ? ' (' + st.elapsed + 'с)' : '') + '</span>';
case 'error': return '<span class="status-err">✗ ' + escHtml(st.text || 'Неизвестная ошибка') + '</span>';
case 'duplicate': return '<span style="color:var(--muted);">⤿ дубликат</span>';
case 'warning': return '<span style="color:#eab308;">⚠ ' + escHtml(st.text || 'пустой') + '</span>';
case 'no_format': return '<span class="status-err">✗ формат не поддерживается</span>';
default: return '';
}
}
var filePicker = null;
var uploadQueue = Promise.resolve();
var uploadTasks = new Map();
var queuedUploads = new Set();
var pendingUploads = 0;
var FILE_PICKER_LIMITS = {
maxEntries: 1000,
maxTotalBytes: 500 * 1024 * 1024,
maxEntryBytes: 50 * 1024 * 1024,
maxDepth: 20,
};
/**
* renderFiles(state) — Рендер таблицы файлов (бывший renderTable, Фаза 1).
*
* Читает state.files, генерирует HTML для #fileTable.
* Использует statusToHTML() для рендеринга статусов (чистые данные → HTML).
* Вызывает lucide.createIcons() для иконок.
*
* Вход: state (весь объект состояния, но читает только state.files).
* Выход: мутирует DOM (fileTable.innerHTML).
* renderFiles(state) — обновить отображение дерева и статусов picker.
*/
function renderFiles(state) {
if (state.files.length === 0) {
fileTable.innerHTML = '<tr class="empty-row"><td colspan="7">Нет файлов — выберите .docx / .pdf</td></tr>';
lucide.createIcons();
return;
}
// Сгруппировать файлы по zip_source
var groups = []; // [{zip: "name.zip"|null, files: [f, ...]}]
var seen = {};
for (var i = 0; i < state.files.length; i++) {
var f = state.files[i];
var zip = f.zip_source || null;
var key = zip || '__naked__';
if (!seen[key]) {
seen[key] = { zip: zip, files: [] };
groups.push(seen[key]);
}
// Сохраняем оригинальный индекс для id строк
f._idx = i;
seen[key].files.push(f);
}
// Рендер: заголовок ZIP → файлы с отступом
var rows = [];
for (var gi = 0; gi < groups.length; gi++) {
var g = groups[gi];
if (g.zip) {
// Заголовок-секция ZIP
rows.push(
'<tr class="zip-header" style="background:var(--brand-grey-light);">' +
'<td colspan="7" style="padding:6px 12px;font-size:12px;font-weight:600;">' +
'📦 ' + escHtml(g.zip) + ' — ' + g.files.length + ' файл.' +
(g.files.length === 1 ? '' : 'ов') +
'</td></tr>'
);
}
// Файлы внутри группы (с отступом если из ZIP)
for (var fi = 0; fi < g.files.length; fi++) {
var f = g.files[fi];
var i = f._idx;
var indent = g.zip ? 'padding-left:24px;' : '';
rows.push(
'<tr id="row_' + i + '">' +
'<td class="name-cell" style="cursor:pointer;' + indent + '" onclick="toggleClassifyDetail(' + i + ')">' +
(f.doc_id ? '<span id="expand_' + i + '" style="font-size:10px;margin-right:4px;">▸</span>' : '') +
escHtml(f.name) + '</td>' +
'<td style="font-size:12px;color:var(--muted);">' + formatDate(f.lastModified) + '</td>' +
'<td class="num-cell" style="font-size:12px;color:var(--muted);">' + formatSize(f.size) + '</td>' +
'<td class="status-cell">' + statusToHTML(f.status) + '</td>' +
'<td><button class="info-btn' + (f.doc_id ? ' visible' : '') + '" onclick="showText(' + i + ')" title="Текст / Распарсено"><i data-lucide="file-text" style="width:16px;height:16px;"></i></button></td>' +
'<td><button class="remove-btn" onclick="removeFile(' + i + ')" title="Удалить"><i data-lucide="trash-2" style="width:16px;height:16px;"></i></button></td>' +
'</tr>' +
'<tr id="detail_' + i + '" style="display:none;"><td colspan="7" style="padding:4px 12px;font-size:11px;background:var(--brand-grey-light);"><span style="color:var(--muted);">Загрузка...</span></td></tr>'
);
}
}
fileTable.innerHTML = rows.join('');
lucide.createIcons();
if (filePicker) filePicker.render();
}
/**
* syncDB() — Синхронизация БД с текущим состоянием файлов.
*
* Отправляет на бэкенд список doc_id, которые должны остаться в БД.
* Всё, чего нет в state.files, будет удалено из БД (cascade).
* Вызывается после каждого изменения состава файлов (removeFile, upload).
* Синхронизирует keep_ids только для текущего batch_id.
*/
async function syncDB() {
var keepIds = state.files.map(function(f) { return f.doc_id; }).filter(Boolean);
try {
await fetch(VM_API + '/api/sync', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({keep_ids: keepIds})
});
} catch(e) { /* сервер недоступен — не критично */ }
var keepIds = Array.from(new Set(state.files.map(function(f) { return f.doc_id; }).filter(Boolean)));
var response = await fetch(VM_API + '/api/sync', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({batch_id: state.batchId, keep_ids: keepIds})
});
var data = await response.json();
if (!response.ok || !data.ok) {
throw new Error(data.error || 'Не удалось синхронизировать документы');
}
return data;
}
/**
@@ -145,7 +60,47 @@ async function syncDB() {
window.toggleClassifyDetail = async function(i) {
var detailRow = document.getElementById('detail_' + i);
var expandIcon = document.getElementById('expand_' + i);
if (!detailRow) return;
if (!detailRow) {
var file = state.files[i];
if (!file || !file.doc_id) return;
document.getElementById('modalTitle').textContent = 'Классификация — ' + file.name;
document.getElementById('modalBody').innerHTML = '<span style="color:var(--muted);">Загрузка...</span>';
document.getElementById('modalOverlay').classList.add('open');
try {
var detailResponse = await fetch(VM_API + '/api/documents/' + encodeURIComponent(file.doc_id));
var detailData = await detailResponse.json();
if (!detailResponse.ok || !detailData.ok) {
throw new Error(detailData.error || 'Ошибка загрузки документа');
}
var detailHtml = '<div style="font-weight:600;margin-bottom:8px;">Результат классификации</div>';
[['Тип', detailData.doc_type], ['Номер', detailData.own_number],
['Родительский', detailData.parent_number], ['Дата', detailData.doc_date],
['Контрагент', detailData.counterparty]].forEach(function(pair) {
if (pair[1]) detailHtml += '<div class="kv"><span class="k">' + pair[0]
+ '</span><span class="v">' + escHtml(pair[1]) + '</span></div>';
});
if (detailData.classify_input) {
detailHtml += '<details><summary>Текст, отправленный в LLM</summary><pre>'
+ escHtml(detailData.classify_input) + '</pre></details>';
}
if (detailData.classify_raw) {
detailHtml += '<details><summary>Сырой ответ LLM</summary><pre>'
+ escHtml(detailData.classify_raw) + '</pre></details>';
}
if (detailData.elements_json) {
var parsedElements = typeof detailData.elements_json === 'string'
? detailData.elements_json : JSON.stringify(detailData.elements_json, null, 2);
detailHtml += '<details><summary>Распарсенные элементы</summary><pre>'
+ escHtml(parsedElements.substring(0, 5000))
+ (parsedElements.length > 5000 ? '\n... (обрезано)' : '')
+ '</pre></details>';
}
document.getElementById('modalBody').innerHTML = detailHtml;
} catch (error) {
document.getElementById('modalBody').textContent = 'Ошибка: ' + error.message;
}
return;
}
if (detailRow.style.display !== 'none') {
detailRow.style.display = 'none';
@@ -203,52 +158,6 @@ window.toggleClassifyDetail = async function(i) {
}
};
/**
* ⛔ НЕ МЕНЯТЬ ⛔ uploadFile — fetch-загрузка с честным счётчиком времени.
*
* v2.0.3: вместо фейкового ↑N% — честный счётчик ⏳ соединение... Nс → ⏳ отправка... Nс.
* fetch() не даёт реальный upload progress, поэтому считаем секунды.
* Кэш-бастинг: ?_=Date.now()
*/
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);
// Честный счётчик: каждую секунду обновляем elapsed
var timer = setInterval(function() {
var elapsed = Math.floor((Date.now() - startTime) / 1000);
if (onProgress) onProgress({ kind: phase, elapsed: elapsed });
}, 1000);
return fetch(UPLOAD_URL + '?_=' + Date.now(), { method: 'POST', body: fd })
.then(function(r) {
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.json();
})
.then(function(data) {
clearInterval(timer);
if (data.ok) {
var elapsed = Math.floor((Date.now() - startTime) / 1000);
if (onProgress) onProgress({ kind: 'uploading', elapsed: elapsed });
return data;
}
throw new Error(data.error || 'Неизвестная ошибка');
})
.catch(function(e) {
clearInterval(timer);
if (e.message === 'Failed to fetch' || e.name === 'TypeError') {
throw new Error('Сеть');
}
throw e;
});
}
/**
* refreshSupps() — Обновить supplement_id/type для файлов в state.files.
*
@@ -277,25 +186,6 @@ async function refreshSupps() {
} catch(e) { console.log('refreshSupps error:', e); }
}
/**
* reconcileSelection(files, newFiles) — Чистая функция (Фаза 1).
*
* Согласование состава: оставляет в массиве files только те,
* имена которых есть в newFiles. Остальные удаляются.
*
* Вход: files — текущий state.files (массив)
* newFiles — FileList от <input> (массив File-объектов)
* Выход: новый массив (НЕ мутирует входной)
*
* Вызывается из onFilesSelected() ПЕРЕД обработкой каждого файла.
*/
function reconcileSelection(files, newFiles) {
var newNames = new Set(newFiles.map(function(f) { return f.name; }));
return files.filter(function(f) {
return newNames.has(f.name);
});
}
/**
* applyParseResult(entry, parsed, elapsed) — Чистая функция (Фаза 1).
*
@@ -321,154 +211,10 @@ 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).
*
* Вызывается ПОСЛЕ обработки всех файлов в onFilesSelected().
* Вызывается после завершения очереди выбранных файлов.
* 1. Обновляет supplement_id/type через refreshSupps()
* 2. Отмечает шаг «Загрузка» как готовый
* 3. Активирует шаг «Классификация»
@@ -476,14 +222,18 @@ async function addRegularFile(file, zipSource) {
* 5. Показывает кнопки (LLM, классификация)
*/
async function finalizeUpload() {
if (!state.files.some(function(file) { return file.doc_id; })) {
var llmButton = document.getElementById('llmBtn');
var classifyButton = document.getElementById('classifyBtn');
if (llmButton) llmButton.style.display = 'none';
if (classifyButton) classifyButton.style.display = 'none';
return;
}
await refreshSupps();
await syncDB();
stepDone('stepUpload');
stepActive('stepClassify');
syncDB();
fileInput.value = '';
fileInput.disabled = false;
var llmBtn = document.getElementById('llmBtn');
llmBtn.style.display = 'flex';
llmBtn.disabled = false;
@@ -491,54 +241,188 @@ async function finalizeUpload() {
lucide.createIcons();
}
/**
* ⛔ НЕ МЕНЯТЬ ⛔ onFilesSelected — все строки сразу, потом загрузка.
*
* v2.0.2: Фаза 1 — все строки в таблицу. Фаза 2 — загрузка по одной.
* Юзер видит таблицу целиком, каждая строка обновляется независимо.
*/
async function onFilesSelected(newFiles) {
if (newFiles.length === 0) return;
fileInput.disabled = true;
resetStepper('stepUpload');
function zipSourceFromPath(path) {
var parts = String(path || '').split('/');
var lastZip = -1;
parts.forEach(function(part, index) {
if (part.toLowerCase().endsWith('.zip')) lastZip = index;
});
return lastZip < 0 ? null : parts.slice(0, lastZip + 1).join('/');
}
// Фаза 1: ВСЕ строки в таблицу сразу
for (var i = 0; i < newFiles.length; i++) {
var f = newFiles[i];
if (f.name.toLowerCase().endsWith('.zip')) {
await addZipFile(f);
continue;
function isPickerEntrySelected(entry) {
return state.files.some(function(file) { return file.pickerId === entry.pickerId; });
}
function setPickerFileStatus(entry, kind, text) {
entry.status = { kind: kind, text: text };
if (filePicker) filePicker.setStatus(entry.pickerId, entry.status);
}
function showPickerError(error) {
if (filePicker) filePicker.setMessage(error.message || String(error));
}
async function uploadPickerEntry(entry) {
if (!isPickerEntrySelected(entry) || entry.uploadBlocked) return;
var task = { controller: new AbortController(), phase: 'put' };
uploadTasks.set(entry.pickerId, task);
setPickerFileStatus(entry, 'uploading', '⏳ Подготовка к загрузке');
var sinkResult;
try {
var response = await window.FilePicker.uploadViaVM([{
name: entry.file.name,
size: entry.file.size,
file: entry.file,
path: entry.pickerPath
}], {
vmUploadUrl: VM_UPLOAD_URL,
backendUploadUrl: VM_API + '/api/upload_refs',
signal: task.controller.signal,
getExtraPayload: function() {
var payload = { batch_id: state.batchId };
if (state.contractId) payload.contract_id = state.contractId;
if (entry.zip_source) payload.zip_source = entry.zip_source;
return payload;
},
onFileStatus: function(index, message) {
if (message.indexOf('⚡') === 0) task.phase = 'backend';
var kind = message.indexOf('✗') === 0 ? 'error'
: message.indexOf('✓') === 0 ? 'parsing'
: task.phase === 'backend' ? 'parsing' : 'uploading';
if (isPickerEntrySelected(entry)) setPickerFileStatus(entry, kind, message);
},
onFileComplete: function(fileResult) {
sinkResult = fileResult.result;
}
});
if (!response.ok) throw new Error(response.error || 'Ошибка загрузки файла');
if (!sinkResult) throw new Error('Сервис не вернул результат обработки файла');
if (sinkResult.ok && !sinkResult.doc_id) {
throw new Error('Сервис обработал файл без идентификатора документа');
}
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;
if (sinkResult.error === 'duplicate' && sinkResult.doc_id) {
entry.doc_id = sinkResult.doc_id;
entry.uploaded = true;
entry.status = { kind: 'duplicate', text: '⤿ дубликат' };
} else if (sinkResult.ok) {
entry.doc_id = sinkResult.doc_id;
entry.uploaded = true;
if (sinkResult.contract_id) state.contractId = sinkResult.contract_id;
applyParseResult(entry, sinkResult.parsed);
} else {
throw new Error(sinkResult.error || 'Не удалось обработать файл');
}
if (isPickerEntrySelected(entry)) {
filePicker.setDocument(entry.pickerId, entry.doc_id);
setPickerFileStatus(entry, entry.status.kind, entry.status.text
|| (entry.status.kind === 'parsed'
? '✓ ' + (entry.status.count || 0) + ' эл.'
: entry.status.text || 'Ошибка обработки'));
}
} catch (error) {
if (!(task.controller.signal.aborted && !isPickerEntrySelected(entry))) {
setPickerFileStatus(entry, 'error', '✗ ' + error.message);
}
} finally {
uploadTasks.delete(entry.pickerId);
}
}
function enqueuePickerUpload(entry) {
if (queuedUploads.has(entry.pickerId) || uploadTasks.has(entry.pickerId)) return;
queuedUploads.add(entry.pickerId);
pendingUploads += 1;
var run = function() { return uploadPickerEntry(entry); };
uploadQueue = uploadQueue.then(run, run).finally(async function() {
queuedUploads.delete(entry.pickerId);
pendingUploads -= 1;
if (pendingUploads === 0) {
try {
await finalizeUpload();
} catch (error) {
showPickerError(error);
}
}
});
}
function scheduleBatchSync() {
var sync = async function() {
await syncDB();
if (state.files.some(function(file) { return file.doc_id; })) {
showClassifyBtn();
var compareButton = document.getElementById('llmBtn');
if (compareButton) compareButton.style.display = 'flex';
}
};
uploadQueue = uploadQueue.then(sync, sync).catch(showPickerError);
}
function onPickerFilesChange(pickerFiles) {
var previous = new Map(state.files.map(function(file) { return [file.pickerId, file]; }));
var incomingIds = new Set(pickerFiles.map(function(file) { return file.id; }));
var added = [];
var nextFiles = [];
var toQueue = [];
var selectedBytes = 0;
pickerFiles.forEach(function(pickerFile) {
selectedBytes += pickerFile.size;
var entry = previous.get(pickerFile.id);
if (!entry) {
entry = {
pickerId: pickerFile.id,
pickerPath: pickerFile.path,
name: pickerFile.name,
size: pickerFile.size,
lastModified: pickerFile.file.lastModified,
file: pickerFile.file,
zip_source: zipSourceFromPath(pickerFile.path),
status: { kind: 'connecting' }
};
added.push(entry);
}
if (!entry.doc_id && !uploadTasks.has(entry.pickerId)) {
var limitError = entry.size > FILE_PICKER_LIMITS.maxEntryBytes
? 'Файл превышает лимит 50 МБ'
: selectedBytes > FILE_PICKER_LIMITS.maxTotalBytes ? 'Превышен общий лимит 500 МБ' : '';
if (limitError) {
entry.uploadBlocked = true;
entry.status = { kind: 'error', text: limitError };
filePicker.setStatus(entry.pickerId, entry.status);
} else if (entry.uploadBlocked) {
delete entry.uploadBlocked;
entry.status = { kind: 'connecting' };
if (!queuedUploads.has(entry.pickerId)) toQueue.push(entry);
} else if (added.indexOf(entry) !== -1) {
toQueue.push(entry);
}
}
nextFiles.push(entry);
});
var removed = state.files.filter(function(file) { return !incomingIds.has(file.pickerId); });
removed.forEach(function(file) {
var task = uploadTasks.get(file.pickerId);
if (task && task.phase === 'put') task.controller.abort();
});
var changed = added.length > 0 || removed.length > 0 || toQueue.length > 0;
state.files = nextFiles;
if (!state.files.some(function(file) { return file.doc_id; })) state.contractId = null;
if (changed) {
resetStepper('stepUpload');
var classifyButton = document.getElementById('classifyBtn');
if (classifyButton) classifyButton.style.display = 'none';
var compareButton = document.getElementById('llmBtn');
if (compareButton) compareButton.style.display = 'none';
}
render(state);
// Фаза 2: загрузка по одному с обновлением строки
for (var j = 0; j < state.files.length; j++) {
var entry = state.files[j];
var pendingFile = entry._pendingFile;
if (!pendingFile) continue;
delete entry._pendingFile;
var f = pendingFile;
try {
var resp = await uploadFile(f, function(st) {
entry.status = st;
render(state);
});
if (resp && resp.contract_id) state.contractId = resp.contract_id;
entry.doc_id = resp.doc_id;
entry.status = { kind: 'uploaded' };
entry.uploaded = true;
var pr = resp.parsed;
var elapsed = pr && pr.status === 'parsed' ? '0.0' : null;
applyParseResult(entry, pr, elapsed);
} catch(err) {
entry.status = { kind: 'error', text: err.message };
}
render(state);
}
await finalizeUpload();
toQueue.forEach(enqueuePickerUpload);
if (removed.length) scheduleBatchSync();
}
+146 -46
View File
@@ -7,24 +7,31 @@
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<script src="https://unpkg.com/lucide@latest"></script>
<style>
:root { --brand-primary: #2563eb; --brand-primary-dark: #1d4ed8; --brand-gray: #d1d5db; --brand-grey-light: #f3f4f6; --background: #ffffff; --foreground: #1a1a1a; --muted: #6b7280; --destructive: #ef4444; --green: #22c55e; }
:root { --brand-primary: #2563eb; --brand-primary-dark: #1d4ed8; --brand-gray: #cbd5e1; --brand-grey-light: #f1f5f9; --background: #ffffff; --foreground: #0f172a; --muted: #475569; --destructive: #b91c1c; --green: #15803d; --navy: #082f49; --topbar-muted: #cbd5e1; --topbar-active: #bfdbfe; --topbar-done: #86efac; }
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, sans-serif; font-size: 14px; color: var(--foreground); background: var(--brand-grey-light); min-height: 100vh; }
.topbar { background: var(--background); border-bottom: 1px solid var(--brand-gray); padding: 0 24px; height: 56px; display: flex; align-items: center; gap: 16px; position: sticky; top: 0; z-index: 50; }
.topbar img { height: 28px; }
.topbar .title { font-weight: 600; font-size: 16px; }
.content { max-width: 900px; margin: 32px auto; padding: 0 16px; }
body { font-family: system-ui, sans-serif; font-size: 16px; color: var(--foreground); background: #f8fafc; min-height: 100vh; }
.topbar { background: var(--navy); color: #fff; padding: 0 24px; height: 56px; display: flex; align-items: center; gap: 12px; position: sticky; top: 0; z-index: 50; }
.topbar img { height: 20px; filter: brightness(0) invert(1); }
.topbar .title { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 500; font-size: 15px; }
.topbar .ver { flex-shrink: 0; color: var(--topbar-muted); font-size: 12px; }
.pipeline-stepper { display: flex; align-items: center; gap: 8px; margin-left: auto; color: var(--topbar-muted); font-size: 12px; white-space: nowrap; }
.pipeline-stepper > span:nth-child(even) { color: rgba(255,255,255,.4); }
.topbar .help { flex-shrink: 0; min-height: 40px; cursor: pointer; user-select: none; padding: 6px 14px; border-radius: 8px; border: 1px solid rgba(255,255,255,.65); color: #fff; background: transparent; font: 600 14px system-ui, sans-serif; }
.topbar .help:hover { background: rgba(255,255,255,.1); }
.content { max-width: 1160px; margin: 36px auto; padding: 0 24px; }
.page-title { color: var(--navy); font-size: 32px; font-weight: 650; line-height: 1.2; }
.page-sub { max-width: 850px; margin: 10px 0 26px; color: var(--muted); font-size: 16px; line-height: 1.55; }
.card { background: var(--background); border-radius: 12px; border: 1px solid var(--brand-gray); box-shadow: 0 1px 2px rgba(0,0,0,.05); overflow: hidden; margin-bottom: 16px; }
.card-header { background: var(--brand-grey-light); padding: 12px 16px; font-weight: 600; font-size: 16px; display: flex; align-items: center; gap: 8px; }
.card-body { padding: 16px; }
.btn { height: 36px; border-radius: 6px; gap: 6px; padding: 0 14px; font-size: 14px; font-family: inherit; cursor: pointer; display: inline-flex; align-items: center; border: 1px solid var(--brand-gray); background: var(--background); }
.card-header { background: #e8f0f8; padding: 15px 18px; font-weight: 650; font-size: 18px; display: flex; align-items: center; gap: 10px; }
.card-body { padding: 20px; }
.btn { min-height: 42px; border-radius: 7px; gap: 8px; padding: 0 16px; font-size: 15px; font-family: inherit; cursor: pointer; display: inline-flex; align-items: center; border: 1px solid var(--brand-gray); background: var(--background); }
.btn-primary { background: var(--brand-primary); border-color: var(--brand-primary); color: #fff; }
.btn-primary:hover { background: var(--brand-primary-dark); }
.btn:disabled { opacity: .5; cursor: not-allowed; }
.table-wrap { border-radius: 6px; border: 1px solid var(--brand-gray); overflow-y: auto; max-height: 50vh; margin-top: 12px; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
th { background: var(--brand-grey-light); text-transform: uppercase; padding: 6px 10px; border-right: 1px solid var(--brand-gray); text-align: left; font-weight: 600; font-size: 11px; color: var(--muted); }
td { padding: 6px 10px; border-right: 1px solid var(--brand-gray); border-bottom: 1px solid var(--brand-gray); }
th { background: var(--brand-grey-light); text-transform: uppercase; padding: 9px 12px; border-right: 1px solid var(--brand-gray); text-align: left; font-weight: 650; font-size: 12px; color: #334155; }
td { padding: 9px 12px; border-right: 1px solid var(--brand-gray); border-bottom: 1px solid var(--brand-gray); }
tr:hover td { background: rgba(243,244,246,.5); }
.name-cell { max-width: 340px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.num-cell { text-align: right; white-space: nowrap; }
@@ -68,46 +75,100 @@
.text-pane th { font-size: 10px; padding: 4px 6px; }
.text-pane td { padding: 3px 6px; font-size: 11px; }
.text-pane .pane-title { font-weight: 600; font-size: 12px; margin-bottom: 8px; color: var(--muted); text-transform: uppercase; }
.upload-intro { margin-bottom: 18px; color: #334155; line-height: 1.55; }
.upload-intro > p { margin-bottom: 12px; }
.upload-facts { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; margin: 14px 0; }
.upload-fact { padding: 12px 14px; border: 1px solid #cbd5e1; border-radius: 8px; background: #f8fafc; }
.upload-fact strong { display: block; margin-bottom: 3px; color: #0f172a; font-size: 14px; }
.upload-fact span { display: block; color: #334155; font-size: 14px; line-height: 1.45; }
.workflow-note { padding: 11px 14px; border-left: 4px solid var(--brand-primary); border-radius: 4px; background: #eff6ff; color: #1e3a8a; }
.help-modal { max-width: 860px; }
.help-body { padding: 24px 28px; font-size: 16px; line-height: 1.7; color: #1e293b; }
.help-body h3 { margin: 24px 0 8px; color: #0f3554; font-size: 19px; line-height: 1.35; }
.help-body h3:first-child { margin-top: 0; }
.help-body p { margin: 0 0 10px; }
.help-body ul, .help-body ol { padding-left: 24px; margin: 8px 0 16px; }
.help-body li { margin: 5px 0; padding-left: 3px; }
.help-body code { padding: 1px 5px; border-radius: 4px; background: #eef2f7; color: #172554; font-size: .92em; }
.help-body pre { font-size: 14px !important; line-height: 1.55; color: #172554; }
.help-callout { margin: 12px 0 16px; padding: 12px 15px; border-left: 4px solid #2563eb; border-radius: 4px; background: #eff6ff; }
.help-callout.warning { border-color: #b45309; background: #fffbeb; }
#filePicker .file-picker { color: var(--foreground); font-family: system-ui, sans-serif; }
#filePicker .fp-toolbar { margin: 0 0 10px; }
#filePicker .fp-toolbar button { min-height: 44px; border: 1px solid var(--brand-gray); border-radius: 7px; padding: 0 16px; color: var(--foreground); background: var(--background); font: 15px system-ui, sans-serif; }
#filePicker .fp-toolbar button:first-of-type { color: #fff; background: var(--brand-primary); border-color: var(--brand-primary); }
#filePicker .fp-toolbar button.secondary { color: var(--foreground); background: var(--background); }
#filePicker .fp-toolbar button.quiet { color: var(--muted); }
#filePicker .fp-table-wrap { max-height: 50vh; overflow: auto; border: 1px solid var(--brand-gray); border-radius: 6px; }
#filePicker .fp-table { font: 15px/1.45 system-ui, sans-serif; }
#filePicker .fp-table th, #filePicker .fp-table td { padding: 10px 12px; border-color: var(--brand-gray); }
#filePicker .fp-table th { color: #334155; letter-spacing: 0; }
#filePicker .tree-name { min-width: 0; padding-top: 0; padding-bottom: 0; color: var(--foreground); font-family: inherit; }
#filePicker .tree-file .tree-name { overflow: hidden; text-overflow: ellipsis; }
#filePicker .fp-actions { white-space: nowrap; }
#filePicker .fp-action { margin-right: 4px; padding: 5px 7px; border: 0; border-radius: 4px; color: #1d4ed8; background: transparent; cursor: pointer; font: 13px system-ui, sans-serif; }
#filePicker .fp-status-uploading { color: var(--brand-primary); }
#filePicker .fp-status-parsing { color: var(--brand-primary); }
#filePicker .fp-status-parsed { color: var(--green); }
#filePicker .fp-status-duplicate { color: var(--muted); }
#filePicker .fp-status-error { color: var(--destructive); }
#filePicker .fp-status, #filePicker .fp-count { color: #334155; font: 14px system-ui, sans-serif; }
#filePicker .fp-count { margin-top: 6px; }
#uploadAdvanced { margin-top: 16px !important; font-size: 14px !important; }
#uploadAdvanced > summary { color: #334155 !important; font-size: 14px; font-weight: 600; }
@media (max-width: 900px) { .pipeline-stepper { display: none; } }
@media (max-width: 600px) {
.topbar { padding: 0 12px; gap: 8px; }
.topbar .title { font-size: 13px; }
.topbar .help { padding: 6px 10px; }
.content { margin: 24px auto; padding: 0 12px; }
.page-title { font-size: 27px; }
.card-body { padding: 16px; }
.upload-facts { grid-template-columns: 1fr; }
.help-body { padding: 18px; font-size: 15px; }
.help-body h3 { font-size: 18px; }
.topbar .help { padding: 6px 10px; }
}
</style>
</head>
<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>
<div id="pipelineStepper" style="display:flex;gap:8px;font-size:11px;align-items:center;color:var(--muted);">
<span class="title">Контрактор</span>
<span class="ver">v{{ config["VERSION"] }}</span>
<div id="pipelineStepper" class="pipeline-stepper">
<span id="stepUpload">○ Загрузка</span><span>→</span>
<span id="stepClassify">○ Классификация</span><span>→</span>
<span id="stepGroups">○ Группировка</span><span>→</span>
<span id="stepCompare">○ Сравнение</span>
</div>
<span style="margin-left:auto;font-size:12px;color:var(--brand-primary);cursor:pointer;white-space:nowrap;" onclick="openAbout()">ℹ️ О сервисе</span>
<button class="help" type="button" onclick="openAbout()" aria-controls="aboutModalOverlay">Справка</button>
</div>
<div class="content">
<h1 class="page-title">Сверка договоров</h1>
<p class="page-sub">Загрузите договоры, дополнительные соглашения и спецификации — система классифицирует их, сгруппирует и покажет изменения.</p>
<div class="card">
<div class="card-header">
<i data-lucide="folder-open" style="width:18px;height:18px;"></i>
Загрузка договоров/приложений/спецификаций
</div>
<div class="card-body">
<input type="file" id="fileInput" accept=".docx,.pdf,.zip" multiple style="margin-bottom:6px;width:100%;">
<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>
<div class="table-wrap">
<table>
<thead>
<tr><th>Имя</th><th style="width:130px;">Изменён</th><th style="width:90px;">Размер</th><th style="width:115px;">Парсинг</th><th style="width:30px;">Текст</th><th style="width:30px;">✕</th></tr>
</thead>
<tbody id="fileTable"></tbody>
</table>
<div class="upload-intro">
<p><strong>Добавьте договоры, дополнительные соглашения и спецификации.</strong> Можно выбрать отдельные файлы или целую папку. ZIP-архивы раскрываются автоматически, включая вложенные архивы.</p>
<div class="upload-facts">
<div class="upload-fact"><strong>Поддерживаемые форматы</strong><span>PDF, DOC, DOCX и ZIP с документами</span></div>
<div class="upload-fact"><strong>Ограничения</strong><span>До 50 МБ на файл и до 500 МБ на весь набор</span></div>
</div>
<p class="workflow-note"><strong>Порядок выбирать не нужно.</strong> После загрузки система определит типы документов, предложит группы и расположит документы по датам. Перед сравнением проверьте предложенные группы.</p>
</div>
<div id="filePicker"></div>
<button class="btn btn-primary" id="llmBtn" style="width:100%;justify-content:center;margin-top:8px;display:none;background:var(--green);border-color:var(--green);">
<i data-lucide="scale" style="width:16px;height:16px;"></i> Общее сравнение
</button>
<details style="margin-top:8px;font-size:12px;">
<details id="uploadAdvanced" style="margin-top:8px;font-size:12px;">
<summary style="cursor:pointer;color:var(--muted);">⚙ Промпты LLM</summary>
<div style="display:flex;gap:8px;margin-top:6px;">
<button class="btn" id="promptBtnExtract" style="font-size:11px;height:28px;flex:1;">📄 Извлечение</button>
@@ -195,32 +256,71 @@
</div>
<div class="modal-overlay" id="aboutModalOverlay" onclick="closeAbout(event)">
<div class="modal" style="max-width:720px;" onclick="event.stopPropagation()">
<div class="modal help-modal" onclick="event.stopPropagation()">
<div class="modal-header">
<i data-lucide="info" style="width:18px;height:18px;"></i>
<span>О сервисе — архитектура и принцип работы</span>
<button class="remove-btn" onclick="closeAbout()" style="margin-left:auto;font-size:18px;">&#xd7;</button>
<span>Контрактор — справка</span>
<button class="remove-btn" onclick="closeAbout()" aria-label="Закрыть справку" style="margin-left:auto;font-size:18px;">&#xd7;</button>
</div>
<div class="modal-body" style="padding:16px;font-size:13px;line-height:1.6;max-height:65vh;overflow-y:auto;" id="aboutBody">
<h3 style="margin-top:0;">Что это</h3>
<p>Автоматический разбор и сравнение договоров с помощью ИИ (LLM). Загружаете договоры, допсоглашения и спецификации вперемешку — система сама разбирает что есть что, группирует по контрактам и находит все изменения между версиями.</p>
<h3>Как пользоваться</h3>
<p><b>1. Загрузка</b> — выберите файлы (.docx, .doc, .pdf, .zip). Можно загружать всё вперемешку. Порядок не важен — классификация разберётся.</p>
<p><b>2. Классификация</b> — нажмите «Классифицировать». Система через LLM определит тип, номер, дату, контрагента.</p>
<p><b>3. Группы</b> — документы автоматически группируются по договорам.</p>
<p><b>4. Сравнение</b> — для каждой группы нажмите «▶ Сравнить». Результат: ADD/UPDATE/DELETE.</p>
<p><b>5. Промпты</b> — три вида инструкций для ИИ с версионированием.</p>
<h3>На выходе</h3>
<p>Структурированная спецификация + полная история изменений с привязкой к документам-источникам.</p>
<div class="modal-body help-body" style="max-height:75vh;" id="aboutBody">
<h3>Что делает Контрактор</h3>
<p>Контрактор извлекает содержимое договоров и приложений, определяет тип и реквизиты документов, объединяет связанные документы в группы и показывает, как менялась спецификация по мере появления новых документов.</p>
<p>Система помогает найти изменения, но результат нужно проверить по исходным документам: неоднозначные места могут быть отмечены как нерешённые.</p>
<h3>1. Добавьте документы</h3>
<ul>
<li>Нажмите <strong>«Выбрать файлы»</strong>, чтобы добавить отдельные документы, или <strong>«Выбрать папку»</strong>, чтобы загрузить набор вместе с подпапками.</li>
<li>Поддерживаются PDF, DOC, DOCX и ZIP. Архивы раскрываются рекурсивно; неподдерживаемые файлы в них не добавляются.</li>
<li>Ограничения: не более 50 МБ на один файл и 500 МБ на весь набор.</li>
<li>В таблице видны путь, размер и состояние каждого файла. Если файл обработан, кнопки <strong>«Детали»</strong> и <strong>«Текст»</strong> позволяют проверить результат разбора.</li>
</ul>
<div class="help-callout warning"><strong>Важно для сканов.</strong> Если PDF состоит из изображений страниц и не содержит текстового слоя, извлечённый текст может быть пустым или неполным. Проверьте его кнопкой «Текст» до сравнения.</div>
<h3>2. При необходимости отфильтруйте папку</h3>
<p>Чтобы загрузить из выбранной папки только часть файлов, создайте в её корне текстовый файл <code>obscure.lst</code>. Если такого файла нет, выбираются все поддерживаемые документы. Сам файл фильтра в список документов не попадает.</p>
<ul>
<li>Указывайте по одной маске на строку. Пустые строки и строки, начинающиеся с <code>#</code>, игнорируются.</li>
<li><code>*</code> заменяет часть имени внутри папки, <code>?</code> — один символ, <code>**</code> позволяет учитывать подпапки.</li>
<li>Начните маску с <code>!</code>, чтобы исключить совпавшие файлы. Исключения применяются после включающих правил.</li>
<li>Пути указываются относительно выбранной папки; вложенные файлы <code>obscure.lst</code> не используются.</li>
</ul>
<pre style="background:var(--brand-grey-light);border:1px solid var(--brand-gray);border-radius:8px;padding:12px 14px;overflow-x:auto;margin:10px 0 16px;"># Включить PDF из папки и подпапок
**/*.pdf
# Исключить черновики
!**/*draft*.*</pre>
<h3>3. Классифицируйте и проверьте группы</h3>
<p>Нажмите <strong>«Классифицировать»</strong>. Система попытается определить тип документа, номер, дату, контрагента и связь с договором. Затем документы будут предложены в группах и упорядочены по датам.</p>
<p>Перед сравнением проверьте, что в каждой группе собраны документы одного договора и распознанные реквизиты выглядят верно. Документы, которые не удалось уверенно связать, отображаются отдельно.</p>
<h3>4. Запустите сравнение</h3>
<p>Нажмите <strong>«Сравнить эту группу»</strong>, чтобы обработать одну группу, или <strong>«Общее сравнение»</strong>, чтобы запустить сравнение для доступных групп.</p>
<p>Система последовательно сопоставляет спецификацию с документами группы. В результате могут появиться операции:</p>
<ul>
<li><strong>ADD</strong> — добавлена новая услуга или позиция.</li>
<li><strong>UPDATE</strong> — изменились распознанные данные позиции, например цена или количество.</li>
<li><strong>DELETE</strong> — позиция исключена.</li>
<li><strong>UNRESOLVED</strong> — изменение не удалось однозначно сопоставить; его нужно проверить вручную.</li>
</ul>
<div class="help-callout"><strong>Проверяйте результат по документу.</strong> Сравнение строится автоматически, а не заменяет юридическую или финансовую проверку исходных условий.</div>
<h3>Если что-то пошло не так</h3>
<ul>
<li>При ошибке загрузки проверьте формат и ограничения размера файла; строка файла покажет статус и доступные детали.</li>
<li>Если документ не распознан или попал не в ту группу, сначала откройте его «Текст» и проверьте, что содержимое извлеклось полностью.</li>
<li>Если сравнение пометило изменение как нерешённое, сверьте позицию и её реквизиты в исходных документах.</li>
</ul>
</div>
</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 src="/file-picker/file-picker.iife.js?v={{ config['VERSION'] }}"></script>
<script src="/static/state.js?v={{ config['VERSION'] }}"></script>
<script src="/static/app_utils.js?v={{ config['VERSION'] }}"></script>
<script src="/static/files.js?v={{ config['VERSION'] }}"></script>
<script src="/static/groups.js?v={{ config['VERSION'] }}"></script>
<script src="/static/compare.js?v={{ config['VERSION'] }}"></script>
<script src="/static/app.js?v={{ config['VERSION'] }}"></script>
</body>
</html>
+173
View File
@@ -0,0 +1,173 @@
# 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-модули. Для обычной страницы соберите IIFE-бандл:
```sh
cd upload/frontend
npm install
npm run build
```
Бандл появится в `site/static/dist/file-picker.iife.js`; Contracts отдаёт его по
`/file-picker/file-picker.iife.js`. Инициализация picker:
```js
const picker = window.FilePicker.initFilePicker({
mount: '#filePicker',
allowedExt: ['.pdf', '.doc', '.docx'],
onChange(files) { /* синхронизировать выбранные leaf-файлы с приложением */ },
});
```
Picker строит дерево файлов/папок/ZIP, раскрывает вложенные ZIP, фильтрует
неподдерживаемые расширения и удаляет дубликаты по `path + size`. В выбранной
папке корневой `obscure.lst` применяется как manifest включения/исключения.
`getFiles()` возвращает leaf-записи `{id, path, name, size, file}`. Методы
`setStatus(id, {kind, text})`, `setDocument(id, docId)`, `setMessage(text)`,
`remove(id)`, `clear()`, `render()` и `destroy()` обслуживают UI.
Передача файлов выполняется по одному файлу за вызов/итерацию:
```js
const result = await window.FilePicker.uploadViaVM(files, {
vmUploadUrl: 'https://example.invalid/contracts-upload/',
backendUploadUrl: '/api/upload_refs',
getExtraPayload(fileEntry) {
return {batch_id, contract_id, zip_source: fileEntry.zip_source};
},
onFileComplete({result}) { /* результат sink: doc_id, parsed или ошибка */ },
});
```
`uploadViaVM` принимает `AbortSignal`, сообщает прогресс и передаёт поля
`getExtraPayload` в JSON POST к `upload_refs`. При настроенном `sink` результат
каждого файла передаётся в `onFileComplete` как `result`. Обработку и сохранение
результатов приложение определяет самостоятельно.
---
## Подключение бэка
```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`
и сквозными полями `batch_id`, `contract_id`, `zip_source`; тянет каждый файл с
ВМ (SSRF-валидация по `vmUploadPrefix`, безопасное имя, ретраи). Без `sink`
файлы кладутся в in-memory сессию и ответ содержит `session`/`count`. С `sink`
ответ содержит `results` с результатом каждого файла и обработку/хранение
определяет приложение.
Сессия: `create_session()` → sid; `add_file(sid, name, content)` (лимит 500МБ);
`get_files(sid)` → `[(name, bytes), ...]` или `None`. TTL 30 мин (таймер в фоне).
---
## Раздача фронтенда
В `contracts-flask` `pages_bp.py` отдаёт исходные модули по `/upload/<path>`
и собранный IIFE по `/file-picker/<path>`. Для другого приложения нужно
раздавать его собранный `file-picker.iife.js` по HTTP и подключать `<script src>`.
---
## Слой 3: обработка приложения
Без `sink` модуль отдаёт файлы в in-memory сессию, а обрабатываете их ВЫ
(обфускация, конвертация и т. п.). После `uploadViaVM` файлы лежат в `res.session`:
```python
files = get_files(res.session) # [(name, bytes), ...]
# ... ваша обработка ...
store_result(res.session, result_zip) # если нужно отдать результат обратно
```
С `sink` файлы не попадают в in-memory сессию: обработчик получает их как
`sink(name, content, batch_id=..., contract_id=..., zip_source=...)`, а результат
каждой обработки возвращается в `onFileComplete`.
---
## Конфиг (слой 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 на ВМ, а не код модуля.
## Инцидент Contracts VM-буфера (2026-10-30)
Контрактор использовал `contracts.kube5s.ru/contracts-upload/`, но DNS для
`contracts.kube5s.ru` возвращал `NXDOMAIN`. DNS-запись для отдельного hostname
`contracts-upload.sinefo.ru` теперь добавлена и проверена на публичных
резолверах. Переключение только на существующий
DrHider или Obfuscator endpoint недостаточно: у них другие пути и CORS allowlist,
и тестовый путь `/contracts-upload/` на этих хостах отвечал `404`. Новый
hostname теперь настроен на VM213 с отдельным TLS-сертификатом, Nginx vhost и
cron-очисткой временных файлов. Проверены HTTPS, CORS для Контрактора и прямой
синтетический цикл PUT/GET/DELETE.
Код приложения и source Nginx настроены локально; Nginx-конфиг буфера уже
установлен на VM213, но само приложение пока не redeployed. Причина, результаты
DNS/CORS/TLS-проверок и дальнейший план описаны в
[отчёте об инциденте](../docs/upload-buffer-incident-2026-10-30.md).
+8
View File
@@ -0,0 +1,8 @@
"""Переиспользуемый модуль загрузки через ВМ (2 слоя).
Структура:
- frontend/ — слой 1 (выбор файлов) + слой 2 (закачка через ВМ), JS.
- backend/ — слой 2 (бэк): Blueprint upload_refs + in-memory сессия, Python.
Подключение в новый проект — см. README.md.
"""
+1
View File
@@ -0,0 +1 @@
"""Backend переиспользуемого модуля загрузки: upload_refs + session."""
+38
View File
@@ -0,0 +1,38 @@
"""In-memory хранилище сессий с TTL и лимитами.
Каждая операция — в отдельном файле (см. ниже), общее состояние — в state.py.
Импорт как единый пакет:
from upload.backend.session import create_session, add_file, get_files
"""
from .create_session import create_session
from .add_file import add_file
from .get_files import get_files, file_count
from .store_result import store_result, get_result
from .store_csv import store_csv, get_csv
from .ttl import touch, pause_ttl, resume_ttl
from .cancel import request_cancel, get_cancel_event
from .cleanup import cleanup
from .state import TTL_SECONDS, MAX_FILE_BYTES, MAX_SESSION_BYTES, configure
__all__ = [
"create_session",
"add_file",
"get_files",
"file_count",
"store_result",
"get_result",
"store_csv",
"get_csv",
"touch",
"pause_ttl",
"resume_ttl",
"request_cancel",
"get_cancel_event",
"cleanup",
"configure",
"TTL_SECONDS",
"MAX_FILE_BYTES",
"MAX_SESSION_BYTES",
]
+25
View File
@@ -0,0 +1,25 @@
"""add_file — добавить файл в сессию (с проверкой суммарного лимита)."""
from . import state
def add_file(sid: str, filename: str, content: bytes) -> bool:
"""Добавить файл в сессию.
Args:
sid: Идентификатор сессии
filename: Имя файла
content: Бинарное содержимое
Returns:
True если добавлено; False если сессии нет или превышен лимит сессии.
"""
with state._lock:
s = state._sessions.get(sid)
if not s:
return False
total = sum(len(c) for _, c in s["files"])
if total + len(content) > state.MAX_SESSION_BYTES:
return False # превышен суммарный лимит сессии
s["files"].append((filename, content))
return True
+27
View File
@@ -0,0 +1,27 @@
"""request_cancel / get_cancel_event — мягкое прерывание обработки сессии."""
import threading
from typing import Optional
from .state import _sessions, _lock
def request_cancel(sid: str) -> bool:
"""Запросить мягкое прерывание обработки сессии.
Returns:
True если сессия существует и отмена запрошена, False если нет.
"""
with _lock:
s = _sessions.get(sid)
if not s:
return False
s["cancel"].set()
return True
def get_cancel_event(sid: str) -> Optional[threading.Event]:
"""Получить событие отмены сессии (или None, если сессии нет)."""
with _lock:
s = _sessions.get(sid)
return s["cancel"] if s else None
+11
View File
@@ -0,0 +1,11 @@
"""cleanup — удалить сессию."""
from .state import _sessions, _lock
def cleanup(sid: str):
"""Удалить сессию и остановить её TTL-таймер."""
with _lock:
s = _sessions.pop(sid, None)
if s and s.get("timer"):
s["timer"].cancel()
+23
View File
@@ -0,0 +1,23 @@
"""create_session — создать новую сессию."""
import threading
import uuid
from .state import _sessions, _lock, _start_timer
def create_session() -> str:
"""Создать новую сессию.
Returns:
Уникальный идентификатор сессии (UUID).
"""
sid = uuid.uuid4().hex
with _lock:
_sessions[sid] = {
"files": [],
"result": None,
"cancel": threading.Event(),
"timer": _start_timer(sid),
}
return sid
+23
View File
@@ -0,0 +1,23 @@
"""get_files / file_count — чтение файлов сессии."""
from typing import List, Optional, Tuple
from .state import _sessions, _lock
def get_files(sid: str) -> Optional[List[Tuple[str, bytes]]]:
"""Получить все файлы сессии.
Returns:
[(filename, content), ...] или None если сессия не найдена.
"""
with _lock:
s = _sessions.get(sid)
return list(s["files"]) if s else None
def file_count(sid: str) -> int:
"""Количество файлов в сессии."""
with _lock:
s = _sessions.get(sid)
return len(s["files"]) if s else 0
+45
View File
@@ -0,0 +1,45 @@
"""Общее состояние сессий: хранилище, блокировка, константы, TTL-таймер.
Единая точка хранения состояния — все операции импортируют её.
Дробить state.py на файл-на-переменную не нужно: это данные, а не функции.
"""
import threading
# TTL сессии: 30 минут
TTL_SECONDS = 30 * 60
# Максимальный объём одного файла и суммарный объём файлов в сессии (защита памяти)
MAX_FILE_BYTES = 50 * 1024 * 1024 # 50 MB на один файл
MAX_SESSION_BYTES = 500 * 1024 * 1024 # 500 MB суммарно на сессию
_sessions: dict = {}
_lock = threading.Lock()
def _start_timer(sid: str) -> threading.Timer:
"""Запустить таймер автоочистки сессии через TTL."""
def _clean():
with _lock:
_sessions.pop(sid, None)
timer = threading.Timer(TTL_SECONDS, _clean)
timer.daemon = True
timer.start()
return timer
def configure(max_file_bytes: int = None, max_session_bytes: int = None,
ttl_seconds: int = None):
"""Переопределить лимиты/TTL из конфига приложения (глобально).
None — оставить текущее значение.
"""
global MAX_FILE_BYTES, MAX_SESSION_BYTES, TTL_SECONDS
if max_file_bytes is not None:
MAX_FILE_BYTES = max_file_bytes
if max_session_bytes is not None:
MAX_SESSION_BYTES = max_session_bytes
if ttl_seconds is not None:
TTL_SECONDS = ttl_seconds
+30
View File
@@ -0,0 +1,30 @@
"""store_csv / get_csv — сохранение и чтение CSV с таблицей замен."""
from typing import Optional
from .state import _sessions, _lock
def store_csv(sid: str, csv_str: str) -> bool:
"""Сохранить CSV с таблицей замен.
Returns:
True если сохранено, False если сессии нет.
"""
with _lock:
s = _sessions.get(sid)
if not s:
return False
s["csv"] = csv_str
return True
def get_csv(sid: str) -> Optional[str]:
"""Получить CSV с таблицей замен.
Returns:
Строка CSV или None если нет.
"""
with _lock:
s = _sessions.get(sid)
return s.get("csv") if s else None
+30
View File
@@ -0,0 +1,30 @@
"""store_result / get_result — сохранение и чтение результата обработки."""
from typing import Optional
from .state import _sessions, _lock
def store_result(sid: str, zip_data: bytes) -> bool:
"""Сохранить результат обработки (ZIP-архив).
Returns:
True если сохранено, False если сессии нет.
"""
with _lock:
s = _sessions.get(sid)
if not s:
return False
s["result"] = zip_data
return True
def get_result(sid: str) -> Optional[bytes]:
"""Получить результат обработки.
Returns:
ZIP-архив или None если сессия не найдена/результат не готов.
"""
with _lock:
s = _sessions.get(sid)
return s["result"] if s else None
+34
View File
@@ -0,0 +1,34 @@
"""touch / pause_ttl / resume_ttl — управление TTL-таймером сессии."""
from .state import _sessions, _lock, _start_timer
def touch(sid: str):
"""Продлить жизнь сессии: перезапустить TTL-таймер (если сессия существует)."""
with _lock:
s = _sessions.get(sid)
if not s:
return
if s.get("timer"):
s["timer"].cancel()
s["timer"] = _start_timer(sid)
def pause_ttl(sid: str):
"""Приостановить TTL сессии (во время обработки): сессия живёт, пока идёт воркер."""
with _lock:
s = _sessions.get(sid)
if s and s.get("timer"):
s["timer"].cancel()
s["timer"] = None
def resume_ttl(sid: str):
"""Возобновить TTL сессии (после завершения обработки): результат доступен ещё TTL."""
with _lock:
s = _sessions.get(sid)
if not s:
return
if s.get("timer"):
s["timer"].cancel()
s["timer"] = _start_timer(sid)
+20
View File
@@ -0,0 +1,20 @@
"""Слой 2 (бэк): переиспользуемый Blueprint закачки через ВМ.
Использование:
from upload.backend.upload_refs import create_upload_refs_blueprint
app.register_blueprint(create_upload_refs_blueprint(cfg))
"""
from .blueprint import create_upload_refs_blueprint
from .safe_name import safe_name
from .pull_file import pull_file
from .config import PULL_RETRIES, PULL_RETRY_DELAY, VM_UPLOAD_PREFIX
__all__ = [
"create_upload_refs_blueprint",
"safe_name",
"pull_file",
"PULL_RETRIES",
"PULL_RETRY_DELAY",
"VM_UPLOAD_PREFIX",
]
+160
View File
@@ -0,0 +1,160 @@
"""Переиспользуемый Blueprint слоя 2: POST /upload_refs (pull с ВМ-буфера в сессию).
Поведение 1:1 с drhider v0.0.75 (site/routes/api_bp.py):
- _safe_name (path traversal)
- SSRF-валидация url.startswith(VM_UPLOAD_PREFIX)
- лимит на один файл -> delete+skip
- pull с ретраями
- лимит сессии -> skip; отсутствие сессии -> 404
- delete url с ВМ (best-effort)
"""
import httpx
import logging
from flask import Blueprint, request, jsonify
from ..session import (create_session, add_file, get_files, file_count,
MAX_FILE_BYTES, configure)
from .config import PULL_RETRIES, PULL_RETRY_DELAY, VM_UPLOAD_PREFIX
from .safe_name import safe_name
from .pull_file import pull_file
log = logging.getLogger("upload.upload_refs")
def create_upload_refs_blueprint(cfg: dict, sink=None) -> Blueprint:
"""Создать Blueprint с эндпоинтом upload_refs.
cfg (все ключи опциональны, есть дефолты):
apiPrefix (str) — префикс Blueprint, по умолчанию "/api"
vmUploadPrefix (str) — доверенный префикс ВМ-буфера (SSRF-валидация)
maxFileBytes (int) — лимит на один файл
maxSessionBytes (int) — суммарный лимит сессии (применяется к сессиям)
ttlSeconds (int) — TTL сессии
pullRetries (int) — ретраи pull
pullRetryDelay (int) — пауза между ретраями (сек)
pullTimeout (int) — таймаут одного GET pull
sink (callable | None): если задан — вместо add_file в in-memory сессию
вызывается sink(name, content, **extra) на каждый вытянутый файл,
endpoint возвращает {"ok": true, "results": [...]}. extra — сквозные
поля тела запроса (batch_id, contract_id, zip_source). Если None —
прежнее поведение drhider (in-memory сессия).
"""
prefix = cfg.get("apiPrefix", "/api")
vm_prefix = cfg.get("vmUploadPrefix", VM_UPLOAD_PREFIX)
max_file_bytes = cfg.get("maxFileBytes", MAX_FILE_BYTES)
pull_retries = cfg.get("pullRetries", PULL_RETRIES)
pull_delay = cfg.get("pullRetryDelay", PULL_RETRY_DELAY)
pull_timeout = cfg.get("pullTimeout", 120)
# Применить лимиты сессии/TTL из конфига (глобально для всех сессий)
configure(
max_file_bytes=cfg.get("maxFileBytes"),
max_session_bytes=cfg.get("maxSessionBytes"),
ttl_seconds=cfg.get("ttlSeconds"),
)
bp = Blueprint("upload_refs", __name__, url_prefix=prefix)
@bp.route("/upload_refs", methods=["POST"])
def upload_refs():
"""Принять ссылки на файлы (загружены на ВМ-буфер), забрать по egress.
Вход: JSON {"session": "...", "files": [{"name": str, "size": int, "url": str}]}.
Каждый файл тянется ИСХОДЯЩИМ GET'ом с ВМ (egress не ограничен шлюзом),
читается по частям (stream), кладётся в сессию. После успешного pull файл
удаляется с ВМ (best-effort; TTL-чистка на ВМ тоже есть).
"""
data = request.get_json(silent=True) or {}
sid = data.get("session") or (create_session() if sink is None else "")
refs = data.get("files") or []
if not refs:
log.warning("upload_refs: no files, sid=%s", sid)
return jsonify({"ok": False, "error": "No files"}), 400
extra = {k: data[k] for k in ("batch_id", "contract_id", "zip_source") if data.get(k) is not None}
results = [] if sink else None
added = 0
try:
with httpx.Client(timeout=pull_timeout, follow_redirects=True) as client:
for ref in refs:
name = safe_name(ref.get("name") or "")
url = ref.get("url")
if not name or not url:
continue
# SSRF-защита: тянуть можно ТОЛЬКО с доверенного ВМ-буфера
if not url.startswith(vm_prefix):
log.warning("upload_refs: unsafe URL, skip sid=%s url=%r", sid, url)
if sink:
results.append({"name": name, "ok": False, "error": "invalid url (SSRF guard)"})
continue
# Лимит на один файл: сверх лимита — пропускаем (не участвует)
if (ref.get("size") or 0) > max_file_bytes:
log.warning("upload_refs: file exceeds %dMB, skip sid=%s file=%r size=%s",
max_file_bytes // (1024 * 1024), sid, name, ref.get("size"))
try:
client.delete(url)
except Exception:
pass
if sink:
results.append({"name": name, "ok": False, "error": "file too large"})
continue
# Pull с ретраями: разовые DNS/сетевые сбои не роняют всю загрузку
try:
content = pull_file(client, url, pull_retries, pull_delay, sid=sid, name=name)
except Exception as e:
log.warning("upload_refs: pull failed sid=%s file=%r: %r", sid, name, e)
if sink:
results.append({"name": name, "ok": False, "error": "pull failed: %s" % e})
continue
log.info("upload_refs: pulled sid=%s file=%r size=%d", sid, name, len(content))
if len(content) > max_file_bytes:
log.warning("upload_refs: pulled file exceeds %dMB, skip sid=%s file=%r size=%d",
max_file_bytes // (1024 * 1024), sid, name, len(content))
try:
client.delete(url)
except Exception:
pass
if sink:
results.append({"name": name, "ok": False, "error": "file too large"})
continue
if sink:
# Отдать файл приложению через sink (вместо in-memory сессии)
try:
client.delete(url)
except Exception:
pass
try:
r = sink(name, content, **extra) or {}
results.append({"name": name, **r})
except Exception as e:
log.error("upload_refs: sink failed file=%r: %r", name, e)
results.append({"name": name, "ok": False, "error": str(e)})
continue
if not add_file(sid, name, content):
# Различить: сессия исчезла vs превышен суммарный лимит сессии
if get_files(sid) is None:
log.warning("upload_refs: session not found, sid=%s file=%r", sid, name)
return jsonify({"ok": False, "error": "Session not found"}), 404
log.warning("upload_refs: session limit exceeded, skip sid=%s file=%r", sid, name)
try:
client.delete(url)
except Exception:
pass
continue
try:
client.delete(url) # убрать файл с ВМ после загрузки
except Exception:
pass
added += 1
except Exception as e:
log.error("upload_refs: pull error sid=%s: %r", sid, e)
return jsonify({"ok": False, "error": "Pull failed: %s" % e}), 502
if sink:
return jsonify({"ok": True, "results": results})
log.info("upload_refs: done sid=%s added=%d total=%d", sid, added, file_count(sid))
return jsonify({"ok": True, "session": sid, "count": file_count(sid)})
return bp
+11
View File
@@ -0,0 +1,11 @@
"""Параметры слоя 2 (бэк) по умолчанию.
Переопределяются из конфига приложения через create_upload_refs_blueprint(cfg).
"""
# Ретраи pull из ВМ-буфера: защита от разовых DNS/сетевых сбоев (gaierror -5 и т.п.)
PULL_RETRIES = 3
PULL_RETRY_DELAY = 2 # секунды между попытками
# Доверенный префикс ВМ-буфера — валидация URL при pull (защита от SSRF)
VM_UPLOAD_PREFIX = "https://contracts-upload.sinefo.ru/contracts-upload/"
+39
View File
@@ -0,0 +1,39 @@
"""pull_file — вытащить файл с ВМ-буфера исходящим GET с ретраями."""
import logging
import time
from .config import PULL_RETRIES, PULL_RETRY_DELAY
log = logging.getLogger("upload.upload_refs.pull")
def pull_file(client, url: str, retries: int = PULL_RETRIES,
delay: float = PULL_RETRY_DELAY, sid: str = None, name: str = None) -> bytes:
"""GET url с ретраями; читает по частям (stream).
Args:
client: httpx.Client
url: URL файла на ВМ-буфере.
retries: число попыток.
delay: пауза между попытками (сек).
sid/name: для логирования (опционально).
Returns:
Содержимое файла (bytes).
Raises:
Последнюю ошибку попытки, если все ретраи не удались.
"""
last_err = None
for attempt in range(retries):
try:
with client.stream("GET", url) as resp:
resp.raise_for_status()
return b"".join(resp.iter_bytes())
except Exception as e:
last_err = e
log.warning("pull: attempt %d/%d failed sid=%s file=%r: %r",
attempt + 1, retries, sid, name, e)
time.sleep(delay)
raise last_err if last_err else RuntimeError("pull failed")
+16
View File
@@ -0,0 +1,16 @@
"""safe_name — санитизация имени файла (защита от path traversal)."""
def safe_name(name: str) -> str:
"""Санитизировать имя файла: защита от path traversal, сохраняя подпапки.
Запрещает '..' и абсолютные пути; нормализует слэши. Возвращает "" если
имя пустое или небезопасное.
"""
if not name:
return ""
name = name.replace("\\", "/")
parts = [p for p in name.split("/") if p and p != "."]
if not parts or any(p == ".." for p in parts):
return ""
return "/".join(parts)
+13
View File
@@ -0,0 +1,13 @@
{
"vmUploadUrl": "https://contracts-upload.sinefo.ru/contracts-upload/",
"allowedExt": [".pdf", ".doc", ".docx", ".txt", ".md"],
"maxFileBytes": 52428800,
"maxSessionBytes": 524288000,
"apiPrefix": "/api",
"vmUploadPrefix": "https://contracts-upload.sinefo.ru/contracts-upload/",
"pullRetries": 3,
"pullRetryDelay": 2,
"pullTimeout": 120,
"ttlSeconds": 1800,
"estMbSec": 12
}
+1
View File
@@ -0,0 +1 @@
node_modules/
+245
View File
@@ -0,0 +1,245 @@
import { addFiles, onFilesChange } from './table/on_files_change.js';
import { onFolderChange } from './table/on_folder_change.js';
import { findNode, flattenFiles, render } from './table/render.js';
import { esc } from './table/esc.js';
const DEFAULTS = {
allowedExt: [],
labels: {
pickFiles: 'Выбрать файлы',
pickFolder: 'Выбрать папку',
clear: 'Очистить',
empty: 'Нет выбранных файлов',
statusReady: 'готов',
remove: 'Удалить',
details: 'Классификация',
text: 'Текст документа',
columns: { path: 'Путь', size: 'Размер', status: 'Статус' },
count: (count, bytes) => `${count} файлов · ${bytes} B`,
},
layout: { columns: ['path', 'size', 'status'], controls: ['files', 'folder', 'clear'] },
limits: {
maxEntries: 1000,
maxTotalBytes: 100 * 1024 * 1024,
maxEntryBytes: 50 * 1024 * 1024,
maxDepth: 20,
},
};
function resolveMount(mount) {
const element = typeof mount === 'string' ? document.querySelector(mount) : mount;
if (!element) throw new Error('File picker mount element не найден');
return element;
}
function normalizeConfig(config) {
return {
...config,
allowedExt: config.allowedExt || DEFAULTS.allowedExt,
labels: {
...DEFAULTS.labels,
...(config.labels || {}),
columns: { ...DEFAULTS.labels.columns, ...(config.labels?.columns || {}) },
},
layout: { ...DEFAULTS.layout, ...(config.layout || {}) },
limits: { ...DEFAULTS.limits, ...(config.limits || {}) },
};
}
function injectStyles() {
if (document.querySelector('style[data-file-picker]')) return;
const style = document.createElement('style');
style.dataset.filePicker = '';
style.textContent = `
.file-picker { color: #17211b; font-family: Georgia, 'Times New Roman', serif; }
.file-picker .fp-toolbar { display: flex; flex-wrap: wrap; gap: 10px; margin: 16px 0; }
.file-picker .fp-toolbar button { border: 0; border-radius: 4px; padding: 12px 17px; color: #f7faf4; background: #1f5a3b; font: 700 13px sans-serif; cursor: pointer; }
.file-picker .fp-toolbar button.secondary { color: #1f5a3b; background: #cbdcc9; }
.file-picker .fp-toolbar button.quiet { color: #5d6a61; background: transparent; }
.file-picker .fp-table-wrap { overflow-x: auto; border-top: 1px solid #b9c7ba; }
.file-picker .fp-table { width: 100%; border-collapse: collapse; font: 14px/1.4 sans-serif; }
.file-picker .fp-table th, .file-picker .fp-table td { padding: 14px 10px; border-bottom: 1px solid #cbd5cc; text-align: left; }
.file-picker .fp-table th { color: #56745f; font-size: 11px; text-transform: uppercase; letter-spacing: 1px; }
.file-picker .fp-status, .file-picker .fp-count { color: #5d6a61; font: 13px sans-serif; }
.file-picker .fp-count { min-height: 22px; }
.file-picker .tree-name { display: inline-flex; align-items: center; gap: 8px; min-width: 240px; padding-top: 0; padding-bottom: 0; border: 0; background: transparent; color: #17211b; font: inherit; text-align: left; }
.file-picker .tree-group { padding-left: 0; cursor: pointer; }
.file-picker .tree-chevron { display: inline-block; width: 12px; color: #78917d; font-size: 10px; }
.file-picker .remove-btn { padding: 2px 7px; color: #8c5148; background: transparent; font: 20px/1 sans-serif; }
.file-picker .tree-row td:first-child { white-space: nowrap; }
@media (max-width: 600px) { .file-picker .fp-toolbar button { flex: 1 1 42%; } }
`;
document.head.append(style);
}
function buildMarkup(root, cfg) {
const controls = cfg.layout.controls || [];
const control = (name, id, label, className = '') => controls.includes(name)
? `<button id="${id}" type="button" class="${className}">${esc(label)}</button>` : '';
const columns = cfg.layout.columns?.length ? cfg.layout.columns : Object.keys(cfg.labels.columns);
const headings = columns.map((column) => `<th>${esc(cfg.labels.columns[column] || column)}</th>`).join('');
// Обычный file input не принимает manifest: `.lst` ему не нужен.
const fileAccept = esc([...new Set([...cfg.allowedExt, '.zip'])].join(','));
// Folder input дополнительно объявляет `.lst`: при webkitdirectory Chromium
// обычно игнорирует accept, но явное расширение служит страховкой, чтобы
// `obscure.lst` попал в FileList. Сам файл в дерево не добавляется.
const folderAccept = esc([...new Set([...cfg.allowedExt, '.zip', '.lst'])].join(','));
root.innerHTML = `
<div class="file-picker${cfg.layout.theme ? ` ${esc(cfg.layout.theme)}` : ''}">
<div class="fp-toolbar" aria-label="${esc(cfg.labels.pickFiles)}">
<input class="fp-file-input" type="file" accept="${fileAccept}" multiple hidden>
<input class="fp-folder-input" type="file" accept="${folderAccept}" webkitdirectory directory multiple hidden>
${control('files', 'fp-files-btn', cfg.labels.pickFiles)}
${control('folder', 'fp-folder-btn', cfg.labels.pickFolder, 'secondary')}
${control('clear', 'fp-clear-btn', cfg.labels.clear, 'quiet')}
</div>
<p class="fp-status" role="status"></p>
<div class="fp-table-wrap"><table class="fp-table"><thead><tr>${headings}<th></th></tr></thead><tbody class="fp-table-body"></tbody></table></div>
<p class="fp-count"></p>
</div>`;
}
function notify(cfg, state) {
if (typeof cfg.onChange === 'function') cfg.onChange([...flattenFiles(state.nodes)]);
}
function removeMetadata(state, node) {
if (node.kind === 'file') {
state.fileKeys.delete(`${node.path}\u0000${node.file.size}`);
state.statuses.delete(node.id);
state.documents.delete(node.id);
return;
}
node.children.forEach((child) => removeMetadata(state, child));
}
function removeNode(state, id) {
const found = findNode(state.nodes, id);
if (!found) return false;
removeMetadata(state, found.node);
found.nodes.splice(found.nodes.indexOf(found.node), 1);
return true;
}
export function initFilePicker(config) {
const cfg = normalizeConfig(config);
const mount = resolveMount(cfg.mount);
injectStyles();
buildMarkup(mount, cfg);
const root = mount.firstElementChild;
const elements = {
fileInputEl: root.querySelector('.fp-file-input'),
folderInputEl: root.querySelector('.fp-folder-input'),
statusEl: root.querySelector('.fp-status'),
tableBodyEl: root.querySelector('.fp-table-body'),
countEl: root.querySelector('.fp-count'),
};
const state = {
nodes: [],
fileKeys: new Set(),
busy: false,
statuses: new Map(),
documents: new Map(),
};
const listeners = [];
const listen = (target, event, handler) => {
target.addEventListener(event, handler);
listeners.push(() => target.removeEventListener(event, handler));
};
const redraw = () => render(state, elements, cfg);
const filesChange = onFilesChange(state, cfg, elements);
const folderChange = onFolderChange(state, cfg, elements);
listen(elements.fileInputEl, 'change', async () => { await filesChange(); notify(cfg, state); });
listen(elements.folderInputEl, 'change', async () => { await folderChange(); notify(cfg, state); });
listen(elements.tableBodyEl, 'click', (event) => {
const toggle = event.target.closest('[data-toggle]');
if (toggle) {
const found = findNode(state.nodes, toggle.dataset.toggle);
if (found) found.node.expanded = !found.node.expanded;
redraw();
return;
}
const details = event.target.closest('[data-details]');
if (details) {
if (typeof cfg.onDetails === 'function') cfg.onDetails(details.dataset.details);
return;
}
const text = event.target.closest('[data-text]');
if (text) {
if (typeof cfg.onText === 'function') cfg.onText(text.dataset.text);
return;
}
const remove = event.target.closest('[data-remove]');
if (remove) {
if (removeNode(state, remove.dataset.remove)) {
redraw();
notify(cfg, state);
}
}
});
if (root.querySelector('#fp-files-btn')) listen(root.querySelector('#fp-files-btn'), 'click', () => elements.fileInputEl.click());
if (root.querySelector('#fp-folder-btn')) listen(root.querySelector('#fp-folder-btn'), 'click', () => elements.folderInputEl.click());
if (root.querySelector('#fp-clear-btn')) listen(root.querySelector('#fp-clear-btn'), 'click', () => {
state.nodes = [];
state.fileKeys.clear();
state.statuses.clear();
state.documents.clear();
if (elements.statusEl) elements.statusEl.textContent = '';
redraw();
notify(cfg, state);
});
redraw();
return {
pickFiles: () => elements.fileInputEl.click(),
pickFolder: () => elements.folderInputEl.click(),
addFiles: async (files) => { await addFiles(state, cfg, files, elements); notify(cfg, state); },
getFiles: () => [...flattenFiles(state.nodes)],
setStatus: (id, status) => {
if (!findNode(state.nodes, id)) return false;
state.statuses.set(id, {
kind: String(status?.kind || 'ready'),
text: String(status?.text || ''),
});
redraw();
return true;
},
setDocument: (id, documentId) => {
if (!findNode(state.nodes, id)) return false;
if (documentId) state.documents.set(id, String(documentId));
else state.documents.delete(id);
redraw();
return true;
},
setMessage: (message) => {
if (elements.statusEl) elements.statusEl.textContent = String(message || '');
},
remove: (id) => {
if (removeNode(state, id)) {
redraw();
notify(cfg, state);
}
},
clear: () => {
state.nodes = [];
state.fileKeys.clear();
state.statuses.clear();
state.documents.clear();
if (elements.statusEl) elements.statusEl.textContent = '';
redraw();
notify(cfg, state);
},
render: redraw,
destroy: () => {
listeners.forEach((removeListener) => removeListener());
state.nodes = [];
state.fileKeys.clear();
state.statuses.clear();
state.documents.clear();
mount.replaceChildren();
},
};
}
export { putToVm } from './upload/put_to_vm.js';
export { uploadViaVM } from './upload/upload_via_vm.js';
export { DEFAULTS };
+15
View File
@@ -0,0 +1,15 @@
{
"name": "upload-frontend",
"private": true,
"type": "module",
"description": "Picker tree and VM upload frontend shared with Obfuscator.",
"scripts": {
"build": "mkdir -p ../../site/static/dist && esbuild index.js --bundle --format=iife --global-name=FilePicker --outfile=../../site/static/dist/file-picker.iife.js && cp node_modules/fflate/LICENSE ../../site/static/dist/fflate-LICENSE.txt"
},
"dependencies": {
"fflate": "0.8.3"
},
"devDependencies": {
"esbuild": "0.25.12"
}
}
@@ -0,0 +1,54 @@
/**
* Добавляет узел файла или группы в состояние picker с рекурсивной дедупликацией.
*
* @param {{nodes: Array, fileKeys: Set<string>}} state Внутреннее состояние таблицы.
* @param {{kind: string, path: string, file?: File, children?: Array}} fileNode
* Корневой узел файла, папки или ZIP-архива.
* @returns {boolean} True, если в состояние добавлен хотя бы один узел.
*
* Ключ дедупликации состоит из полного пути и размера файла. Это позволяет
* повторно выбрать файл после удаления и одновременно не скрывает файл с тем
* же путём, но другим размером.
*/
export function addFileWithDedup(state, fileNode) {
// Удаляет дубли и пустые группы снизу вверх, сохраняя исходные File objects.
const accept = (node) => {
if (node.kind === 'file') {
// NUL-разделитель исключает неоднозначность при склейке пути и размера.
const key = `${node.path}\u0000${node.file.size}`;
if (state.fileKeys.has(key)) return null;
state.fileKeys.add(key);
return node;
}
// Группа нужна только пока после фильтрации в ней остался хотя бы один leaf.
node.children = node.children.map(accept).filter(Boolean);
return node.children.length ? node : null;
};
const accepted = accept(fileNode);
// Пустой ZIP/каталог не должен появляться в таблице как пустая строка.
if (!accepted || (accepted.kind !== 'file' && !accepted.children.length)) return false;
if (accepted.kind !== 'file') {
const existing = state.nodes.find((node) => node.kind !== 'file' && node.path === accepted.path);
if (existing) {
mergeChildren(existing, accepted);
return true;
}
}
state.nodes.push(accepted);
return true;
}
function mergeChildren(target, incoming) {
incoming.children.forEach((child) => {
if (child.kind === 'file') {
const duplicate = target.children.some((existing) => existing.kind === 'file'
&& existing.path === child.path && existing.file.size === child.file.size);
if (!duplicate) target.children.push(child);
return;
}
const existing = target.children.find((candidate) => candidate.kind !== 'file'
&& candidate.path === child.path);
if (existing) mergeChildren(existing, child);
else target.children.push(child);
});
}
+18
View File
@@ -0,0 +1,18 @@
/**
* Экранирует текст перед вставкой в HTML-шаблон, собираемый через innerHTML.
*
* @param {*} value Имя файла, путь или сообщение статуса.
* @returns {string} Строка с заменёнными HTML-значимыми символами.
*
* Экранируются и кавычки, потому что значение может попасть не только в текст
* кнопки, но и в HTML-атрибут вроде data-path или aria-label.
*/
export function esc(value) {
return String(value).replace(/[&<>"']/g, (character) => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
}[character]));
}
+11
View File
@@ -0,0 +1,11 @@
/**
* Преобразует размер файла из байтов в короткую строку для таблицы.
*
* @param {number} bytes Размер в байтах.
* @returns {string} Значение в B, KB или MB с одним знаком после запятой.
*/
export function fs(bytes) {
return bytes < 1024 ? `${bytes} B`
: bytes < 1048576 ? `${(bytes / 1024).toFixed(1)} KB`
: `${(bytes / 1048576).toFixed(1)} MB`;
}
+394
View File
@@ -0,0 +1,394 @@
/**
* lst_filter — фильтрация файлов каталога по manifest-файлу `obscure.lst`.
*
* Модуль намеренно чистый: не обращается к DOM, сети, upload или Flask.
* На вход получает список File из folder picker и набор поддерживаемых
* расширений (включая '.zip'), на выходе отдаёт отфильтрованный список File
* в исходном порядке и диагностику для интерфейса.
*
* Формат manifest:
* - одна строка — одно правило;
* - пустые строки игнорируются;
* - строки, начинающиеся с '#', — комментарии;
* - '!' в начале строки задаёт исключение;
* - правила задают пути относительно выбранной папки;
* - положительные правила собирают кандидатов, исключения удаляют совпадения.
*
* Glob-семантика:
* - '*' — ноль или больше символов в одном сегменте (не '/');
* - '?' — один символ, кроме '/';
* - '**' — целый сегмент: рекурсивное совпадение через каталоги;
* внутри сегмента ('a**b') эквивалентен '*';
* - '[abc]', '[a-z]' — классы и диапазоны символов;
* - '[!a]', '[^a]' — отрицательные классы символов;
* - 'reports/' эквивалентно 'reports/**';
* - путь без glob-символов — точное совпадение;
* - сравнение всего пути без учёта регистра.
*/
/** Фиксированное имя manifest-файла (ищется только в корне выбранной папки). */
const MANIFEST_NAME = 'obscure.lst';
/**
* Возвращает true, если имя файла оканчивается одним из разрешённых расширений.
*
* Сравнение без учёта регистра: 'A.PDF' и '.pdf' считаются одинаковыми.
*
* @param {string} name Имя файла.
* @param {string[]} allowedExt Список расширений (с точкой) включая '.zip'.
* @returns {boolean}
*/
function extensionAllowed(name, allowedExt) {
const lowerName = String(name || '').toLowerCase();
return allowedExt.some((extension) => lowerName.endsWith(String(extension).toLowerCase()));
}
/**
* Вычисляет путь файла относительно корня выбранной папки.
*
* Браузер для folder picker кладёт в `webkitRelativePath` путь вида
* `<корень>/подпапка/файл`; первый сегмент (имя выбранной папки) отбрасываем.
* Обратные слеши нормализуются в '/', чтобы правило можно было писать
* в любом стиле.
*
* @param {{webkitRelativePath?: string, name: string}} file Файл или его фейк.
* @returns {string} Путь относительно выбранной папки.
*/
function relativePathOf(file) {
const raw = String(file.webkitRelativePath || file.name || '').replace(/\\/g, '/');
const parts = raw.split('/');
return parts.length > 1 ? parts.slice(1).join('/') : (parts[0] || '');
}
/**
* Ищет ровно корневой `obscure.lst` (без учёта регистра).
*
* Manifest — это файл на глубине 1 от выбранной папки. Вложенный
* `subdir/obscure.lst` манифестом не считается; другие `*.lst` тоже.
*
* @param {Array} files Список файлов folder picker.
* @returns {object|null} Найденный manifest либо null.
*/
function findManifest(files) {
return files.find((file) => {
const raw = String(file.webkitRelativePath || file.name || '').replace(/\\/g, '/');
const parts = raw.split('/');
return parts.length === 2 && parts[1].toLowerCase() === MANIFEST_NAME;
}) || null;
}
/**
* Экранирует символы регулярного выражения, чтобы glob-литералы
* не превращались в regex-метасимволы.
*
* @param {string} text Произвольная строка.
* @returns {string} Строка, безопасная для вставки в RegExp.
*/
function escapeRegExp(text) {
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/**
* Находит закрывающую ']' для класса символов.
*
* Учитывает, что ']' сразу после '[' или '[!' / '[^' является литералом,
* а не закрывающей скобкой.
*
* @param {string} glob Исходный шаблон.
* @param {number} openIndex Позиция открывающей '['.
* @returns {number} Индекс закрывающей ']' либо -1, если класс не закрыт.
*/
function findClosingBracket(glob, openIndex) {
let index = openIndex + 1;
if (glob[index] === '!' || glob[index] === '^') index += 1;
if (glob[index] === ']') index += 1;
for (; index < glob.length; index += 1) {
if (glob[index] === ']') return index;
}
return -1;
}
/**
* Компилирует glob в регистронезависимый RegExp.
*
* @param {string} glob Нормализованный шаблон правила.
* @returns {RegExp|null} null, если шаблон синтаксически некорректен
* (например, незакрытый класс символов).
*/
function compileGlob(glob) {
let pattern = '^';
let index = 0;
while (index < glob.length) {
const character = glob[index];
if (character === '*') {
const isDouble = glob[index + 1] === '*';
if (!isDouble) {
// Одиночная звёздочка: любой набор символов внутри одного сегмента.
pattern += '[^/]*';
index += 1;
continue;
}
// Двойная звёздочка рекурсивна только как целый сегмент пути.
const atSegmentStart = index === 0 || glob[index - 1] === '/';
const atSegmentEnd = index + 2 === glob.length || glob[index + 2] === '/';
if (atSegmentStart && atSegmentEnd) {
if (glob[index + 2] === '/') {
// '**/' — ноль или больше сегментов вместе с завершающим '/'.
pattern += '(?:[^/]+/)*';
index += 3;
} else {
// '**' в конце правила — любой остаток пути.
pattern += '.*';
index += 2;
}
} else {
// 'a**b' внутри сегмента эквивалентно 'a*b' и не пересекает '/'.
pattern += '[^/]*';
index += 2;
}
continue;
}
if (character === '?') {
// Ровно один символ, но не разделитель каталогов.
pattern += '[^/]';
index += 1;
continue;
}
if (character === '[') {
const close = findClosingBracket(glob, index);
if (close === -1) return null;
let body = glob.slice(index + 1, close);
// '[!a]' — традиционная запись отрицательного класса, '[^a]' — regex-стиль.
if (body.startsWith('!')) body = `^${body.slice(1)}`;
pattern += `[${body.replace(/[\\\]]/g, (symbol) => `\\${symbol}`)}]`;
index = close + 1;
continue;
}
pattern += escapeRegExp(character);
index += 1;
}
pattern += '$';
try {
return new RegExp(pattern, 'i');
} catch (error) {
// Некорректный класс (например, '[]') Regexp не компилирует — правило
// отклоняется, остальные правила продолжают обрабатываться.
return null;
}
}
/**
* Нормализует одну строку manifest в путь правила.
*
* @param {string} line Исходная строка.
* @returns {null|{error: string}|{path: string, negative: boolean}}
* null — пустая строка или комментарий;
* {error} — некорректное правило;
* {path, negative} — нормализованное правило.
*/
function normalizeRule(line) {
let text = line.trim();
// Пустые строки и комментарии не являются правилами.
if (!text || text.startsWith('#')) return null;
let negative = false;
if (text.startsWith('!')) {
negative = true;
text = text.slice(1).trim();
// Голое '!' — ошибка с указанием номера строки.
if (!text) return { error: 'пустое правило после "!"' };
}
// Единый разделитель, схлопывание повторных '/' и устранение ведущих './'.
// Схлопывание идёт до снятия './', чтобы './/reports' превращалось в
// 'reports', а не в абсолютный '/reports'.
text = text.replace(/\\/g, '/');
text = text.replace(/\/{2,}/g, '/');
text = text.replace(/^(?:\.\/)+/, '');
// Абсолютные пути (POSIX и Windows drive) недопустимы.
if (text.startsWith('/') || /^[a-zA-Z]:\//.test(text)) {
return { error: 'абсолютный путь не допускается' };
}
// Выход за пределы выбранной папки запрещён.
if (text.split('/').some((segment) => segment === '..')) {
return { error: 'сегмент ".." не допускается' };
}
if (!text) return { error: 'пустое правило' };
// Правило 'reports/' означает всё поддерево reports.
if (text.endsWith('/')) text += '**';
return { path: text, negative };
}
/**
* Разбирает текст manifest в массив правил.
*
* Некорректные строки не прерывают разбор: они попадают в diagnostics
* с номером строки, остальные правила продолжают работать.
*
* @param {string} text Содержимое manifest (BOM уже снят).
* @param {Array} diagnostics Аккумулятор диагностики.
* @returns {Array<{line: number, source: string, negative: boolean, regex: RegExp}>}
*/
function parseRules(text, diagnostics) {
// Поддерживаем CRLF, LF и одиночный CR.
const lines = text.split(/\r\n|\r|\n/);
const rules = [];
lines.forEach((line, index) => {
const lineNumber = index + 1;
const normalized = normalizeRule(line);
if (normalized === null) return;
if (normalized.error) {
diagnostics.push({
level: 'error',
line: lineNumber,
message: `${normalized.error}: "${line.trim()}"`,
});
return;
}
const regex = compileGlob(normalized.path);
if (!regex) {
diagnostics.push({
level: 'error',
line: lineNumber,
message: `некорректный glob: "${line.trim()}"`,
});
return;
}
rules.push({
line: lineNumber,
source: line.trim(),
negative: normalized.negative,
regex,
});
});
return rules;
}
/**
* Фильтрует файлы каталога по manifest-файлу `obscure.lst`.
*
* Поведение:
* - нет корневого manifest — возвращаются все файлы (прежнее поведение);
* - manifest есть — результат строится только по его правилам, fallback нет;
* - сам manifest никогда не попадает в результат;
* - файлы неподдерживаемых типов не попадают в кандидаты;
* - ZIP фильтруется по имени архива, внутренние имена не анализируются.
*
* @param {FileList|File[]} files Файлы, выбранные через folder picker.
* @param {string[]} allowedExt Поддерживаемые расширения, включая '.zip'.
* @returns {Promise<{
* manifest: object|null,
* files: Array,
* diagnostics: Array<{level: string, line: number, message: string}>
* }>}
*/
export async function lstFilter(files, allowedExt) {
const list = Array.from(files || []);
const manifest = findManifest(list);
// Manifest отсутствует: сохраняем прежнее поведение без какой-либо фильтрации.
if (!manifest) {
return { manifest: null, files: list, diagnostics: [] };
}
const diagnostics = [];
// Ошибка чтения или UTF-8-декодирования: предупреждение и пустой результат.
// TextDecoder с fatal:true не подменяет битые байты на U+FFFD молча.
let text;
try {
const buffer = await manifest.arrayBuffer();
text = new TextDecoder('utf-8', { fatal: true }).decode(buffer);
} catch (error) {
diagnostics.push({
level: 'error',
line: 0,
message: `не удалось прочитать ${MANIFEST_NAME}: ${error.message}`,
});
return { manifest, files: [], diagnostics };
}
// BOM в начале снимаем вручную, иначе он попадёт в первое правило.
if (text.charCodeAt(0) === 0xFEFF) text = text.slice(1);
const rules = parseRules(text, diagnostics);
const positives = rules.filter((rule) => !rule.negative);
const negatives = rules.filter((rule) => rule.negative);
// Нет ни одного корректного включающего правила — пустой результат без fallback.
if (positives.length === 0) {
diagnostics.push({
level: 'warning',
line: 0,
message: `в ${MANIFEST_NAME} нет корректных включающих правил`,
});
return { manifest, files: [], diagnostics };
}
// Кандидаты: только поддерживаемые типы, без самого manifest.
// ZIP остаётся обычным кандидатом — include/exclude применяются к его имени.
const candidates = list.filter((file) => file !== manifest
&& extensionAllowed(file.name, allowedExt));
// Проход по файлам (а не по правилам) сохраняет исходный порядок и
// исключает появление дублей при пересекающихся масках.
const matched = new Set();
const selected = [];
for (const file of candidates) {
const path = relativePathOf(file);
let included = false;
for (const rule of positives) {
if (rule.regex.test(path)) {
matched.add(rule);
included = true;
}
}
if (!included) continue;
// Исключение применяется после положительного набора.
const excluded = negatives.some((rule) => rule.regex.test(path));
if (!excluded) selected.push(file);
}
// Правило, не совпавшее ни с одним поддерживаемым файлом, — предупреждение,
// остальные правила продолжают работать.
for (const rule of positives) {
if (!matched.has(rule)) {
diagnostics.push({
level: 'warning',
line: rule.line,
message: `правило не совпало ни с одним файлом: "${rule.source}"`,
});
}
}
if (selected.length === 0) {
diagnostics.push({
level: 'warning',
line: 0,
message: 'manifest не выбрал ни одного файла',
});
}
return { manifest, files: selected, diagnostics };
}
+75
View File
@@ -0,0 +1,75 @@
import { listZipFiles } from '../zip/list_zip_files.js';
import { addFileWithDedup } from './add_file_with_dedup.js';
import { render } from './render.js';
/**
* Обрабатывает список файлов из обычного file input.
*
* @param {{fileKeys: Set<string>, nodes: Array, busy: boolean}} state Состояние picker.
* @param {{allowedExt: string[]}} cfg Конфигурация разрешённых расширений.
* @param {FileList|File[]} files Выбранные browser File objects.
* @param {{tableBodyEl: HTMLElement, countEl: HTMLElement}} elements DOM-элементы вывода.
* @returns {Promise<void>} Завершается после разбора всех ZIP и перерисовки таблицы.
*
* Обычные документы добавляются сразу. ZIP читаются асинхронно в браузере;
* ошибка одного архива не отменяет обработку остальных выбранных файлов.
*/
export async function addFiles(state, cfg, files, elements) {
for (const file of Array.from(files)) {
if (!file.name.toLowerCase().endsWith('.zip')) {
// Фильтр повторяет accept в HTML, потому что accept не является защитой API.
if (!cfg.allowedExt.some((extension) => file.name.toLowerCase().endsWith(extension.toLowerCase()))) {
continue;
}
// Обычный файл получает имя как путь: у file input нет относительного пути.
addFileWithDedup(state, {
id: crypto.randomUUID(), kind: 'file', name: file.name, path: file.name,
file, children: [], expanded: true,
});
continue;
}
try {
// listZipFiles возвращает дерево только с разрешёнными leaf-файлами.
const zipTree = await listZipFiles(file, cfg.allowedExt, cfg.limits);
if (zipTree) addFileWithDedup(state, zipTree);
} catch (error) {
// Битый или небезопасный ZIP пропускается, чтобы не блокировать picker.
if (typeof cfg.onError === 'function') {
cfg.onError(error, file);
} else if (elements.statusEl) {
elements.statusEl.textContent = `Ошибка чтения архива ${file.name}: ${error.message}`;
}
continue;
}
}
// Единая перерисовка после всей пачки предотвращает промежуточные состояния UI.
render(state, elements, cfg);
}
/**
* Создаёт обработчик change для обычного выбора файлов.
*
* @param {object} state Состояние picker, включая флаг busy.
* @param {object} cfg Конфигурация picker.
* @param {object} elements DOM-элементы picker.
* @returns {Function} Async-обработчик для addEventListener('change', ...).
*
* busy устанавливается синхронно до первого await. Поэтому второе быстрое
* событие выбора не запускает параллельный разбор и не меняет дерево конкурирующе.
*/
export function onFilesChange(state, cfg, elements) {
return async () => {
if (state.busy) return;
state.busy = true;
// Сброс статуса перед новым выбором: иначе сообщение об ошибке предыдущего
// выбора (например, «Ошибка чтения архива») останется висеть в интерфейсе.
if (elements.statusEl) elements.statusEl.textContent = '';
try {
await addFiles(state, cfg, elements.fileInputEl.files, elements);
} finally {
// Даже исключение вне внутреннего ZIP-catch не оставляет picker заблокированным.
elements.fileInputEl.value = '';
state.busy = false;
}
};
}
+107
View File
@@ -0,0 +1,107 @@
import { listZipFiles } from '../zip/list_zip_files.js';
import { addFileWithDedup } from './add_file_with_dedup.js';
import { rebaseTree } from './rebase_tree.js';
import { render } from './render.js';
import { lstFilter } from './lst_filter.js';
/**
* Создаёт async-обработчик выбора каталога через webkitdirectory.
*
* @param {{nodes: Array, fileKeys: Set<string>, busy: boolean}} state Состояние picker.
* @param {{allowedExt: string[]}} cfg Разрешённые расширения.
* @param {{folderInputEl: HTMLInputElement, tableBodyEl: HTMLElement, countEl: HTMLElement}} elements DOM-элементы.
* @returns {Function} Async-обработчик события change.
*
* Браузер отдаёт плоский FileList с webkitRelativePath. Обработчик группирует
* его по первой части пути, создаёт промежуточные папки и затем добавляет каждый
* корень через общую дедупликацию. ZIP внутри каталога раскрывается тем же кодом,
* что и ZIP из обычного file input.
*/
export function onFolderChange(state, cfg, elements) {
return async () => {
if (state.busy) return;
state.busy = true;
// Сброс статуса перед новым выбором: иначе сообщение об ошибке предыдущего
// выбора (например, «Ошибка чтения архива») останется висеть в интерфейсе.
if (elements.statusEl) elements.statusEl.textContent = '';
try {
const roots = new Map();
// Manifest-фильтр: при наличии корневого `obscure.lst` набор файлов
// ограничивается его правилами, а сам manifest в дерево не попадает.
// Без manifest модуль возвращает исходный список без изменений.
const filtered = await lstFilter(elements.folderInputEl.files, [...cfg.allowedExt, '.zip']);
if (filtered.manifest) {
// Диагностику показываем в statusEl: ошибки строк и предупреждения правил.
const messages = filtered.diagnostics.map((item) => (
item.line ? `строка ${item.line}: ${item.message}` : item.message
));
if (messages.length && elements.statusEl) {
elements.statusEl.textContent = messages.join('; ');
}
}
for (const file of filtered.files) {
// Для webkitdirectory путь начинается с выбранной корневой папки.
const parts = (file.webkitRelativePath || file.name).split('/');
const relativePath = parts.slice(1).join('/') || file.name;
const lowerPath = relativePath.toLowerCase();
const rootName = parts[0] || file.name;
if (!roots.has(rootName)) {
// Один root на выбранный каталог позволяет сохранить дерево целиком.
roots.set(rootName, {
id: crypto.randomUUID(), kind: 'folder', name: rootName,
path: rootName, children: [], expanded: false
});
}
const root = roots.get(rootName);
// Вставляет узел по его пути, создавая отсутствующие промежуточные папки.
const addToFolder = (node) => {
let current = root;
const nodeParts = node.path.split('/').slice(1);
nodeParts.forEach((part, index) => {
const last = index === nodeParts.length - 1;
let child = current.children.find((item) => item.name === part);
if (!child) {
child = last ? node : {
id: crypto.randomUUID(), kind: 'folder', name: part,
path: `${rootName}/${nodeParts.slice(0, index + 1).join('/')}`,
children: [], expanded: false
};
current.children.push(child);
}
current = child;
});
};
if (lowerPath.endsWith('.zip')) {
try {
// ZIP rebased на имя выбранной папки перед вставкой в общий root.
const zip = await listZipFiles(file, cfg.allowedExt, cfg.limits);
if (zip) {
rebaseTree(zip, rootName);
addToFolder(zip);
}
} catch (error) {
// Ошибка одного ZIP не должна терять остальные файлы каталога.
if (typeof cfg.onError === 'function') {
cfg.onError(error, file);
} else if (elements.statusEl) {
elements.statusEl.textContent = `Ошибка чтения архива ${file.name}: ${error.message}`;
}
continue;
}
} else if (cfg.allowedExt.some((extension) => lowerPath.endsWith(extension))) {
addToFolder({
id: crypto.randomUUID(), kind: 'file', name: parts.at(-1),
path: `${rootName}/${relativePath}`, file, children: [], expanded: true
});
}
}
// Дедупликация выполняется после сборки каждого корня каталога.
roots.forEach((root) => addFileWithDedup(state, root));
// Сброс value позволяет выбрать тот же каталог повторно.
elements.folderInputEl.value = '';
render(state, elements, cfg);
} finally {
state.busy = false;
}
};
}
+16
View File
@@ -0,0 +1,16 @@
/**
* Добавляет префикс корневой папки к каждому пути дерева.
*
* @param {{path: string, file?: File, children: Array}} root Узел дерева.
* @param {string} prefix Путь выбранной папки, в которую попал узел.
* @returns {object} Тот же узел после изменения путей.
*
* Для leaf-файлов создаётся новый File с тем же базовым именем; логический путь
* хранится отдельно в узле дерева.
*/
export function rebaseTree(root, prefix) {
root.path = `${prefix}/${root.path}`;
if (root.file) root.file = new File([root.file], root.file.name, { lastModified: root.file.lastModified });
root.children.forEach((child) => rebaseTree(child, prefix));
return root;
}
+157
View File
@@ -0,0 +1,157 @@
import { esc } from './esc.js';
import { fs } from './fs.js';
const DEFAULT_RENDER_CONFIG = {
labels: {
statusReady: 'готов',
remove: 'Удалить',
empty: 'Нет выбранных файлов',
columns: { path: 'Путь', size: 'Размер', status: 'Статус' },
count: (count, bytes) => `${count} файлов · ${fs(bytes)}`,
},
layout: { columns: ['path', 'size', 'status'] },
};
function renderConfig(cfg = {}) {
return {
labels: {
...DEFAULT_RENDER_CONFIG.labels,
...(cfg.labels || {}),
columns: {
...DEFAULT_RENDER_CONFIG.labels.columns,
...(cfg.labels?.columns || {}),
},
},
layout: { ...DEFAULT_RENDER_CONFIG.layout, ...(cfg.layout || {}) },
};
}
/**
* Рендерит один узел дерева и его раскрытых потомков в HTML-строки таблицы.
*
* @param {{kind: string, id: string, name: string, path: string, file?: File,
* children: Array, expanded: boolean, error?: string}} node Узел file/folder/zip.
* @param {number} depth Глубина узла; используется для визуального отступа.
* @returns {string} HTML одной строки или поддерева строк.
*
* Имена и пути проходят через esc до вставки в innerHTML. Группа получает
* кнопку раскрытия, leaf-файл — размер, статус и data-path для будущего API статусов.
*/
function leafStatuses(node, state, result = []) {
if (node.kind === 'file') {
result.push(state.statuses.get(node.id) || { kind: 'ready', text: '' });
} else {
node.children.forEach((child) => leafStatuses(child, state, result));
}
return result;
}
function renderStatus(node, state, cfg) {
if (node.kind === 'file') {
const status = state.statuses.get(node.id) || { kind: 'ready', text: cfg.labels.statusReady };
const kind = ['uploading', 'parsing', 'parsed', 'duplicate', 'error'].includes(status.kind)
? status.kind
: 'ready';
return `<span class="fp-status-${kind}">${esc(status.text || cfg.labels.statusReady)}</span>`;
}
const statuses = leafStatuses(node, state);
const errors = statuses.filter((status) => status.kind === 'error').length;
const uploading = statuses.filter((status) => status.kind === 'uploading' || status.kind === 'parsing').length;
const parsed = statuses.filter((status) => ['parsed', 'duplicate'].includes(status.kind)).length;
if (uploading) return `<span class="fp-status-uploading">${uploading} загружается/обрабатывается</span>`;
if (errors) return `<span class="fp-status-error">${errors} ошибок</span>`;
if (parsed === statuses.length) return `<span class="fp-status-parsed">${statuses.length} готово</span>`;
return `${parsed}/${statuses.length} обработано`;
}
function renderNode(node, depth, cfg, state) {
const isGroup = node.kind !== 'file';
const padding = depth * 4;
const name = esc(node.name);
const action = isGroup
? `<button class="tree-name tree-group tree-${node.kind}" style="padding-left:${padding}ch" data-toggle="${node.id}" `
+ `aria-expanded="${node.expanded}"><span class="tree-chevron">${node.expanded ? '▼' : '▶'}</span>${name}</button>`
: `<span class="tree-name" style="padding-left:${padding}ch">${name}</span>`;
const documentId = state.documents.get(node.id);
const details = node.kind === 'file' && documentId
? `<button class="fp-action" type="button" data-details="${esc(node.id)}" aria-label="${esc(cfg.labels.details)} ${name}" title="${esc(cfg.labels.details)}">Детали</button>`
+ `<button class="fp-action" type="button" data-text="${esc(node.id)}" aria-label="${esc(cfg.labels.text)} ${name}" title="${esc(cfg.labels.text)}">Текст</button>`
: '';
const remove = `<button class="remove-btn" type="button" data-remove="${esc(node.id)}" aria-label="${esc(cfg.labels.remove)} ${name}">×</button>`;
const pathAttribute = ` data-path="${esc(node.path)}"`;
const fileIdAttribute = node.kind === 'file' ? ` data-file-id="${esc(node.id)}"` : '';
const cells = {
path: action,
size: node.kind === 'file' ? fs(node.file.size) : '',
status: node.error ? esc(node.error) : renderStatus(node, state, cfg),
};
const columns = cfg.layout.columns?.length
? cfg.layout.columns
: Object.keys(DEFAULT_RENDER_CONFIG.labels.columns);
const row = `<tr class="tree-row tree-${node.kind}"${pathAttribute}${fileIdAttribute}>`
+ columns.map((column) => `<td>${cells[column] || ''}</td>`).join('')
+ `<td class="fp-actions">${details}${remove}</td></tr>`;
if (!isGroup || !node.expanded) return row;
// Дочерние строки добавляются только для раскрытой группы.
return row + node.children.map((child) => renderNode(child, depth + 1, cfg, state)).join('');
}
/**
* Полностью синхронизирует DOM таблицы и счётчик с текущим state.nodes.
*
* @param {{nodes: Array}} state Состояние дерева.
* @param {{tableBodyEl: HTMLElement, countEl: HTMLElement}} elements DOM-вывод.
* @returns {void}
*
* Обход для счётчика независим от раскрытия: свернутая группа всё равно входит
* в количество leaf-файлов и суммарный размер.
*/
export function render(state, elements, cfg) {
const normalized = renderConfig(cfg);
const columns = normalized.layout.columns?.length
? normalized.layout.columns
: Object.keys(DEFAULT_RENDER_CONFIG.labels.columns);
elements.tableBodyEl.innerHTML = state.nodes.length
? state.nodes.map((node) => renderNode(node, 0, normalized, state)).join('')
: `<tr><td colspan="${columns.length + 1}">${esc(normalized.labels.empty)}</td></tr>`;
const files = [];
// Собирает все leaf-файлы, включая содержимое свернутых групп.
const visit = (node) => {
if (node.kind === 'file') files.push(node);
else node.children.forEach(visit);
};
state.nodes.forEach(visit);
const totalBytes = files.reduce((sum, node) => sum + node.file.size, 0);
elements.countEl.textContent = normalized.labels.count(files.length, totalBytes);
}
/**
* Ищет узел по уникальному id и возвращает также массив его непосредственного родителя.
*
* @param {Array} nodes Массив узлов текущего уровня.
* @param {string} id Идентификатор, созданный через crypto.randomUUID().
* @returns {{node: object, nodes: Array}|null} Узел и контейнер для удаления либо null.
*/
export function findNode(nodes, id) {
for (const node of nodes) {
if (node.id === id) return { node, nodes };
const found = findNode(node.children || [], id);
if (found) return found;
}
return null;
}
/**
* Преобразует иерархическое дерево в плоский список leaf-файлов.
*
* @param {Array} nodes Узлы любого уровня.
* @param {Array} result Внутренний аккумулятор рекурсивного вызова.
* @returns {Array<{id: string, path: string, name: string, size: number, file: File}>} Файлы для приложения.
*/
export function flattenFiles(nodes, result = []) {
nodes.forEach((node) => {
if (node.kind === 'file') result.push({ id: node.id, path: node.path, name: node.name, size: node.file.size, file: node.file });
else flattenFiles(node.children, result);
});
return result;
}
+110
View File
@@ -0,0 +1,110 @@
import assert from 'node:assert/strict';
import { zipSync } from 'fflate';
import { listZipFiles } from './zip/list_zip_files.js';
import { addFileWithDedup } from './table/add_file_with_dedup.js';
import { flattenFiles, render } from './table/render.js';
import { lstFilter } from './table/lst_filter.js';
import { esc } from './table/esc.js';
if (typeof globalThis.File === 'undefined') {
globalThis.File = class File extends Blob {
constructor(parts, name, options = {}) {
super(parts, options);
this.name = name;
this.lastModified = options.lastModified || Date.now();
}
};
}
globalThis.crypto = globalThis.crypto || { randomUUID: () => 'test-picker-id' };
function bytes(text) {
return new TextEncoder().encode(text);
}
function withRelativePath(file, path) {
Object.defineProperty(file, 'webkitRelativePath', { value: path });
return file;
}
async function testRecursiveZipTree() {
const nested = zipSync({ 'inside.pdf': bytes('nested') });
const archive = zipSync({
'contracts/one.pdf': bytes('one'),
'contracts/ignore.txt': bytes('text'),
'nested.zip': nested,
'../escape.pdf': bytes('unsafe'),
});
const tree = await listZipFiles(new File([archive], 'outer.zip'), ['.pdf', '.doc', '.docx']);
const files = flattenFiles([tree]);
assert.deepEqual(files.map((file) => file.path).sort(), [
'outer.zip/contracts/one.pdf',
'outer.zip/nested.zip/inside.pdf',
]);
assert.equal(files.find((file) => file.name === 'inside.pdf').file.name, 'inside.pdf');
}
function testPathAndSizeDeduplication() {
const state = { nodes: [], fileKeys: new Set() };
const file = new File(['same'], 'a.pdf');
const node = { id: 'one', kind: 'file', name: 'a.pdf', path: 'a.pdf', file, children: [] };
assert.equal(addFileWithDedup(state, node), true);
assert.equal(addFileWithDedup(state, {
...node, id: 'two', file: new File(['same'], 'a.pdf'),
}), false);
assert.equal(addFileWithDedup(state, {
...node, id: 'three', path: 'folder/a.pdf',
}), true);
assert.deepEqual(flattenFiles(state.nodes).map((entry) => entry.path), ['a.pdf', 'folder/a.pdf']);
}
async function testObscureManifest() {
const manifest = withRelativePath(new File(['contracts/**\n!**/skip.docx\n'], 'obscure.lst'), 'root/obscure.lst');
const included = withRelativePath(new File(['a'], 'a.pdf'), 'root/contracts/a.pdf');
const excluded = withRelativePath(new File(['b'], 'skip.docx'), 'root/contracts/skip.docx');
const outside = withRelativePath(new File(['c'], 'outside.pdf'), 'root/other/outside.pdf');
const result = await lstFilter([manifest, included, excluded, outside], ['.pdf', '.doc', '.docx', '.zip']);
assert.equal(result.manifest, manifest);
assert.deepEqual(result.files, [included]);
assert.deepEqual(result.diagnostics, []);
}
function testEscaping() {
assert.equal(esc(`<script title="x">&'`), '&lt;script title=&quot;x&quot;&gt;&amp;&#39;');
}
function testStatusAndDocumentActionsRenderSafely() {
const node = {
id: 'file-1',
kind: 'file',
name: '<img src=x>',
path: '<img src=x>',
file: new File(['x'], '<img src=x>'),
children: [],
expanded: true,
};
const state = {
nodes: [node],
statuses: new Map([['file-1', { kind: 'error', text: '<script>bad</script>' }]]),
documents: new Map([['file-1', 'doc-1']]),
};
const elements = { tableBodyEl: { innerHTML: '' }, countEl: { textContent: '' } };
render(state, elements);
assert.match(elements.tableBodyEl.innerHTML, /data-details="file-1"/);
assert.match(elements.tableBodyEl.innerHTML, /data-text="file-1"/);
assert.match(elements.tableBodyEl.innerHTML, /&lt;script&gt;bad&lt;\/script&gt;/);
assert.doesNotMatch(elements.tableBodyEl.innerHTML, /<script>bad/);
assert.match(elements.tableBodyEl.innerHTML, /&lt;img src=x&gt;/);
}
const tests = [
['recursive ZIP tree and unsafe paths', testRecursiveZipTree],
['deduplication by path and size', testPathAndSizeDeduplication],
['obscure.lst selection filter', testObscureManifest],
['HTML escaping', testEscaping],
['status and document action rendering', testStatusAndDocumentActionsRenderSafely],
];
for (const [name, test] of tests) {
await test();
console.log(`PASS ${name}`);
}
+166
View File
@@ -0,0 +1,166 @@
import assert from 'node:assert/strict';
import { putToVm } from './upload/put_to_vm.js';
import { uploadViaVM } from './upload/upload_via_vm.js';
if (typeof globalThis.File === 'undefined') {
globalThis.File = class File extends Blob {
constructor(parts, name, options = {}) {
super(parts, options);
this.name = name;
this.lastModified = options.lastModified || Date.now();
}
};
}
globalThis.crypto = globalThis.crypto || { randomUUID: () => 'test-upload-id' };
let lastXHR;
class FakeXHR {
constructor() {
this.upload = {};
this.status = 0;
this.statusText = '';
this.timeout = 0;
lastXHR = this;
}
open(method, url) {
this.method = method;
this.url = url;
}
send(body) {
this.body = body;
}
complete(status = 200) {
this.status = status;
this.onload();
}
}
globalThis.XMLHttpRequest = FakeXHR;
let fetchCalls = [];
let fetchResponder;
globalThis.fetch = async (url, options) => {
fetchCalls.push({ url, options });
return fetchResponder(fetchCalls.length);
};
const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
async function waitForNextXHR(previous) {
for (let attempt = 0; attempt < 20 && lastXHR === previous; attempt += 1) await tick();
assert.notEqual(lastXHR, previous, 'next PUT request was not started');
return lastXHR;
}
async function testPutToVmSuccess() {
const file = new File(['payload'], 'a.pdf');
const request = putToVm(file, 'https://vm.example/a');
assert.equal(lastXHR.method, 'PUT');
assert.equal(lastXHR.body, file);
lastXHR.complete();
await request;
}
async function testUploadIncludesPerFilePayloadAndResult() {
fetchCalls = [];
fetchResponder = async (callNumber) => ({
ok: true,
status: 200,
statusText: 'OK',
json: async () => ({
ok: true,
results: [{ ok: true, doc_id: `doc-${callNumber}`, parsed: { status: 'parsed' } }],
}),
});
const files = [
{ id: 'one', name: 'one.pdf', path: 'one.pdf', file: new File(['1'], 'one.pdf') },
{ id: 'two', name: 'two.pdf', path: 'archive.zip/folder/two.pdf', file: new File(['2'], 'two.pdf') },
];
const completed = [];
const previous = lastXHR;
const request = uploadViaVM(files, {
vmUploadUrl: 'https://vm.example/contracts-upload/',
backendUploadUrl: '/api/upload_refs',
getExtraPayload: (entry) => ({
batch_id: 'batch-1',
contract_id: 'contract-1',
zip_source: entry.id === 'two' ? 'archive.zip' : null,
}),
onFileComplete: (event) => completed.push(event),
});
const firstXHR = await waitForNextXHR(previous);
firstXHR.complete();
const secondXHR = await waitForNextXHR(firstXHR);
secondXHR.complete();
const result = await request;
assert.equal(result.ok, true);
assert.equal(fetchCalls.length, 2);
const firstBody = JSON.parse(fetchCalls[0].options.body);
const secondBody = JSON.parse(fetchCalls[1].options.body);
assert.equal(firstBody.batch_id, 'batch-1');
assert.equal(firstBody.contract_id, 'contract-1');
assert.equal(firstBody.files.length, 1);
assert.equal(secondBody.zip_source, 'archive.zip');
assert.equal(completed.length, 2);
assert.equal(completed[1].result.doc_id, 'doc-2');
assert.equal(completed[1].source.id, 'two');
}
async function testUploadReportsPutFailure() {
fetchCalls = [];
const previous = lastXHR;
const request = uploadViaVM([new File(['x'], 'x.pdf')], {
vmUploadUrl: 'https://vm.example/',
});
const xhr = await waitForNextXHR(previous);
xhr.onerror();
const result = await request;
assert.equal(result.ok, false);
assert.equal(fetchCalls.length, 0);
assert.match(result.error, /Ошибка отправки файла/);
}
async function testUploadReportsBackendFailure() {
fetchCalls = [];
fetchResponder = async () => ({
ok: false,
status: 502,
statusText: 'Bad Gateway',
json: async () => ({ ok: false, error: 'failed' }),
});
const previous = lastXHR;
const request = uploadViaVM([new File(['x'], 'x.pdf')], {
vmUploadUrl: 'https://vm.example/',
backendUploadUrl: '/api/upload_refs',
});
const xhr = await waitForNextXHR(previous);
xhr.complete();
const result = await request;
assert.equal(result.ok, false);
assert.match(result.error, /HTTP 502/);
}
async function testAbortBeforeUpload() {
const controller = new AbortController();
controller.abort();
const previous = lastXHR;
const result = await uploadViaVM([new File(['x'], 'x.pdf')], {
vmUploadUrl: 'https://vm.example/',
signal: controller.signal,
});
assert.equal(result.aborted, true);
assert.equal(lastXHR, previous);
}
const tests = [
['PUT via WebDAV', testPutToVmSuccess],
['per-file extra payload and sink result', testUploadIncludesPerFilePayloadAndResult],
['PUT failure', testUploadReportsPutFailure],
['backend failure', testUploadReportsBackendFailure],
['abort before upload', testAbortBeforeUpload],
];
for (const [name, test] of tests) {
await test();
console.log(`PASS ${name}`);
}
+78
View File
@@ -0,0 +1,78 @@
/**
* putToVm — отправка одного файла в буфер методом HTTP PUT (сырое бинарное тело).
*
* @param {Blob|File} file Файл для отправки.
* @param {string} url Полный целевой URL в буфере.
* @param {Object} options Опции отправки:
* @param {function(number, number, number): void} [options.onProgress] Колбэк прогресса (pct, loaded, total).
* @param {function(XMLHttpRequest): void} [options.onXHR] Колбэк регистрации XHR для прямого контроля.
* @param {AbortSignal} [options.signal] Сигнал прерывания запроса (AbortController.signal).
* @param {number} [options.timeoutMs=300000] Таймаут в миллисекундах (по умолчанию 300с).
* @returns {Promise<void>} Разрешается при ответе 2xx, отклоняется при ошибке или прерывании.
*/
export function putToVm(file, url, options = {}) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
if (options.onXHR) {
options.onXHR(xhr);
}
const abortHandler = () => {
xhr.abort();
reject(new DOMException('Upload aborted', 'AbortError'));
};
if (options.signal) {
if (options.signal.aborted) {
reject(new DOMException('Upload aborted', 'AbortError'));
return;
}
options.signal.addEventListener('abort', abortHandler, { once: true });
}
xhr.open('PUT', url);
xhr.timeout = options.timeoutMs || 300000;
xhr.upload.onprogress = (e) => {
if (e.lengthComputable && options.onProgress) {
const pct = Math.round((e.loaded / e.total) * 100);
options.onProgress(pct, e.loaded, e.total);
}
};
xhr.onload = () => {
if (options.signal) {
options.signal.removeEventListener('abort', abortHandler);
}
if (xhr.status >= 200 && xhr.status < 300) {
resolve();
} else {
reject(new Error(`Буфер: HTTP ${xhr.status} ${xhr.statusText}`));
}
};
xhr.onerror = () => {
if (options.signal) {
options.signal.removeEventListener('abort', abortHandler);
}
reject(new Error('Сетевая ошибка при отправке в буфер'));
};
xhr.ontimeout = () => {
if (options.signal) {
options.signal.removeEventListener('abort', abortHandler);
}
reject(new Error(`Таймаут ожидания ответа буфера (${Math.round(xhr.timeout / 1000)}с)`));
};
xhr.onabort = () => {
if (options.signal) {
options.signal.removeEventListener('abort', abortHandler);
}
reject(new DOMException('Upload aborted', 'AbortError'));
};
xhr.send(file);
});
}
+200
View File
@@ -0,0 +1,200 @@
/**
* uploadViaVM — Слой 2 (Фронтенд): пофайловый транзит через ВМ-буфер в сессию бэкенда.
*
* Архитектура потокового транзита:
* Для каждого файла k:
* 1. Браузер отправляет файл k методом PUT в буфер на ВМ.
* 2. Браузер сразу делает POST /api/upload_refs для одного файла k.
* 3. Бэкенд забирает файл исходящим GET в память RAM и сразу шлёт DELETE в буфер.
* 4. Файл удалён из буфера ВМ, память чиста.
* 5. Браузер переходит к файлу k+1.
*/
import { putToVm } from './put_to_vm.js';
import { fs } from '../table/fs.js';
function generateUuid() {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
const v = c === 'x' ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
/**
* Нормализует элемент списка файлов (поддерживает как File, так и объект из FilePicker.getFiles()).
*/
function normalizeFileEntry(item) {
if (item instanceof File || item instanceof Blob) {
return { name: item.name || 'unnamed', size: item.size, file: item, path: item.name || '', source: item };
}
if (item && item.file) {
return {
name: item.name || item.file.name || 'unnamed',
size: item.size ?? item.file.size ?? 0,
file: item.file,
path: item.path || item.name || '',
source: item,
};
}
throw new Error('Некорректный объект файла');
}
/**
* Выполняет пофайловую транзитную загрузку файлов в сессию бэкенда через буфер.
*
* @param {Array<File|{name: string, size: number, file: File, path?: string}>} files Список файлов.
* @param {Object} options Параметры загрузки:
* @param {string} options.vmUploadUrl Базовый URL буфера (обязателен).
* @param {string} [options.backendUploadUrl='/api/upload_refs'] URL эндпоинта приёма ссылок.
* @param {string} [options.session=''] Идентификатор существующей сессии (если есть).
* @param {AbortSignal} [options.signal] Сигнал отмены загрузки.
* @param {function(string): void} [options.onStatus] Колбэк общего текстового статуса.
* @param {function(number, string): void} [options.onFileStatus] Колбэк обновления статуса конкретного файла (индекс, текст/html).
* @param {function(Object): void} [options.onProgress] Колбэк прогресса текущего файла ({ index, total, pct, speed }).
* @param {function(Object): void} [options.onFileComplete] Колбэк обработки файла; включает result ответа sink.
* @param {function(Object, number): Object} [options.getExtraPayload] Дополнительные поля POST для файла.
* @param {Object} [options.extraPayload] Общие дополнительные поля POST.
* @returns {Promise<{ok: boolean, session?: string, count?: number, error?: string, aborted?: boolean}>}
*/
export async function uploadViaVM(files, options = {}) {
if (!options.vmUploadUrl) {
return { ok: false, error: 'Параметр options.vmUploadUrl обязателен' };
}
const normalizedFiles = Array.from(files).map(normalizeFileEntry);
const total = normalizedFiles.length;
if (total === 0) {
return { ok: true, session: options.session || '', count: 0 };
}
const backendUrl = options.backendUploadUrl || (options.apiBase || '') + '/api/upload_refs';
const vmBase = options.vmUploadUrl.endsWith('/') ? options.vmUploadUrl : `${options.vmUploadUrl}/`;
let currentSession = options.session || '';
let totalAdded = 0;
for (let k = 0; k < total; k++) {
if (options.signal?.aborted) {
return { ok: false, error: 'Загрузка отменена', aborted: true, session: currentSession };
}
const { name, size, file, source } = normalizedFiles[k];
const fileKey = `${generateUuid()}_${k}`;
const fileUrl = `${vmBase}${fileKey}`;
if (options.onStatus) {
options.onStatus(`Загрузка в буфер (${k + 1}/${total}): ${name}`);
}
if (options.onFileStatus) {
options.onFileStatus(k, '⏳ В буфер...');
}
// Этап 1: Отправка одного файла в буфер
const t0 = performance.now();
try {
await putToVm(file, fileUrl, {
signal: options.signal,
onXHR: options.onXHR,
onProgress(pct, loaded, fileTotal) {
const elapsed = (performance.now() - t0) / 1000;
const speed = elapsed > 0 ? (loaded / elapsed) : 0;
if (options.onFileStatus) {
options.onFileStatus(k, `⏳ ${pct}% (${fs(speed)}/s)`);
}
if (options.onProgress) {
options.onProgress({ index: k, total, pct, speed, loaded, fileTotal, name });
}
},
});
} catch (err) {
if (options.signal?.aborted) {
return { ok: false, error: 'Загрузка отменена', aborted: true, session: currentSession };
}
if (options.onFileStatus) {
options.onFileStatus(k, '✗ Ошибка буфера');
}
return {
ok: false,
error: `Ошибка отправки файла ${name} в буфер: ${err.message}`,
failedIndex: k,
session: currentSession,
};
}
// Этап 2: Вызов /api/upload_refs для немедленного переноса файла с буфера в RAM сессии
if (options.onStatus) {
options.onStatus(`Приём сервисом (${k + 1}/${total}): ${name}`);
}
if (options.onFileStatus) {
options.onFileStatus(k, '⚡ В сессию...');
}
try {
const resp = await fetch(backendUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...(typeof options.getExtraPayload === 'function'
? options.getExtraPayload(source, k) || {}
: options.extraPayload || {}),
session: currentSession,
files: [{ name, size, url: fileUrl }],
}),
signal: options.signal,
});
if (!resp.ok) {
throw new Error(`HTTP ${resp.status} ${resp.statusText}`);
}
const data = await resp.json();
if (!data.ok) {
throw new Error(data.error || 'Бэкенд вернул ошибку');
}
const result = Array.isArray(data.results) ? data.results[0] : undefined;
if (Array.isArray(data.results) && !result) {
throw new Error('Бэкенд не вернул результат обработки файла');
}
currentSession = data.session || currentSession;
totalAdded = data.count ?? (totalAdded + 1);
if (options.onFileStatus) {
options.onFileStatus(k, '✓ Доставлен');
}
if (options.onFileComplete) {
options.onFileComplete({
fileIndex: k,
name,
size,
session: currentSession,
totalAdded,
result,
source,
});
}
} catch (err) {
if (options.signal?.aborted) {
return { ok: false, error: 'Загрузка отменена', aborted: true, session: currentSession };
}
if (options.onFileStatus) {
options.onFileStatus(k, '✗ Ошибка приёма');
}
return {
ok: false,
error: `Ошибка приёма файла ${name} бэкендом: ${err.message}`,
failedIndex: k,
session: currentSession,
};
}
}
if (options.onStatus) {
options.onStatus(`Завершено. Успешно передано файлов: ${total}`);
}
return { ok: true, session: currentSession, count: totalAdded };
}

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