From b7534a063cbbe3efafcc70754b1b167b387d0d66 Mon Sep 17 00:00:00 2001 From: Repinoid Date: Fri, 30 Oct 2026 13:22:06 +0300 Subject: [PATCH] chore: exclude non-deployment project files --- .gitignore | 13 +- History/2026-08-26-cleanup-legacy-upload.md | 41 --- History/2026-08-26-full-replace-fix.md | 34 --- History/2026-08-26-load-sqlite-locked.md | 30 --- History/2026-08-26-session-summary.md | 60 ----- History/2026-08-26-sonnet-code-review.md | 73 ----- History/2026-08-26-stress-100kb-gateway.md | 22 -- .../2026-08-26-upload-module-reuse-plan.md | 104 -------- History/2026-08-27-classify-stuck-at-68.md | 137 ---------- History/architecture.md | 64 ----- History/audit-2026-06-30.md | 64 ----- History/drhider-utf8-zip-2026-07-07.md | 43 --- History/drhider-working-state-2026-06-29.md | 59 ----- History/gemini-strategy-2026-06-28.md | 47 ---- History/local-llm-setup.md | 28 -- History/opus-decoupling-plan-2026-06-28.md | 169 ------------ History/opus-decoupling-review-2026-06-28.md | 62 ----- History/opus-dedup-2026-06-28.md | 249 ------------------ History/opus-dialog-2026-06-28.md | 146 ---------- .../opus-drhider-code-review-2026-06-29.md | 33 --- History/opus-drhider-debug-2026-06-29.md | 53 ---- ...opus-drhider-failed-to-fetch-2026-06-29.md | 50 ---- .../opus-drhider-http2-error-2026-06-29.md | 62 ----- History/opus-drhider-request-2026-06-29.md | 110 -------- .../opus-drhider-reuse-analysis-2026-06-29.md | 56 ---- .../opus-drhider-reuse-logic-2026-06-29.md | 37 --- History/opus-drhider-review-2026-06-29.md | 76 ------ .../opus-drhider-review-results-2026-06-29.md | 23 -- History/opus-review-2-request-2026-06-28.md | 25 -- History/opus-review-request-2026-06-28.md | 32 --- History/opus-review-response-2026-06-28.md | 20 -- History/session-01-init-2026-06-27.md | 120 --------- History/session-02-fixes-2026-06-28.md | 85 ------ History/session-2026-08-17-upload-502-oom.md | 71 ----- ...net-review-comparison-answer-2026-08-27.md | 81 ------ ...onnet-review-comparison-only-2026-08-27.md | 231 ---------------- ...view-comparison-verification-2026-08-27.md | 117 -------- architecture-diagram.jpeg | Bin 71395 -> 0 bytes architecture-diagram.png | Bin 33498 -> 0 bytes docs/architecture-diagram.png | Bin 33498 -> 0 bytes docs/prompt-sonnet-architecture.md | 104 -------- docs/prompt-sonnet-description-v2.md | 101 ------- docs/service-how-it-works.md | 147 ----------- docs/upload-buffer-incident-2026-10-30.md | 157 ----------- tests/README.md | 75 ------ tests/conftest.py | 42 --- tests/load/__init__.py | 10 - tests/load/stress_http.py | 97 ------- tests/load/test_load_apply_ops.py | 52 ---- tests/load/test_load_concurrency.py | 37 --- tests/load/test_load_pipeline.py | 67 ----- tests/test_classify.py | 99 ------- tests/test_connection.py | 36 --- tests/test_grouping.py | 83 ------ tests/test_llm.py | 25 -- tests/test_llm_client.py | 40 --- tests/test_metrics.py | 87 ------ tests/test_parse.py | 28 -- tests/test_process_pipeline.py | 96 ------- tests/test_routes.py | 111 -------- tests/test_spec_current.py | 31 --- tests/test_spec_events.py | 107 -------- tests/test_upload_refs.py | 69 ----- 63 files changed, 12 insertions(+), 4416 deletions(-) delete mode 100644 History/2026-08-26-cleanup-legacy-upload.md delete mode 100644 History/2026-08-26-full-replace-fix.md delete mode 100644 History/2026-08-26-load-sqlite-locked.md delete mode 100644 History/2026-08-26-session-summary.md delete mode 100644 History/2026-08-26-sonnet-code-review.md delete mode 100644 History/2026-08-26-stress-100kb-gateway.md delete mode 100644 History/2026-08-26-upload-module-reuse-plan.md delete mode 100644 History/2026-08-27-classify-stuck-at-68.md delete mode 100644 History/architecture.md delete mode 100644 History/audit-2026-06-30.md delete mode 100644 History/drhider-utf8-zip-2026-07-07.md delete mode 100644 History/drhider-working-state-2026-06-29.md delete mode 100644 History/gemini-strategy-2026-06-28.md delete mode 100644 History/local-llm-setup.md delete mode 100644 History/opus-decoupling-plan-2026-06-28.md delete mode 100644 History/opus-decoupling-review-2026-06-28.md delete mode 100644 History/opus-dedup-2026-06-28.md delete mode 100644 History/opus-dialog-2026-06-28.md delete mode 100644 History/opus-drhider-code-review-2026-06-29.md delete mode 100644 History/opus-drhider-debug-2026-06-29.md delete mode 100644 History/opus-drhider-failed-to-fetch-2026-06-29.md delete mode 100644 History/opus-drhider-http2-error-2026-06-29.md delete mode 100644 History/opus-drhider-request-2026-06-29.md delete mode 100644 History/opus-drhider-reuse-analysis-2026-06-29.md delete mode 100644 History/opus-drhider-reuse-logic-2026-06-29.md delete mode 100644 History/opus-drhider-review-2026-06-29.md delete mode 100644 History/opus-drhider-review-results-2026-06-29.md delete mode 100644 History/opus-review-2-request-2026-06-28.md delete mode 100644 History/opus-review-request-2026-06-28.md delete mode 100644 History/opus-review-response-2026-06-28.md delete mode 100644 History/session-01-init-2026-06-27.md delete mode 100644 History/session-02-fixes-2026-06-28.md delete mode 100644 History/session-2026-08-17-upload-502-oom.md delete mode 100644 History/sonnet-review-comparison-answer-2026-08-27.md delete mode 100644 History/sonnet-review-comparison-only-2026-08-27.md delete mode 100644 History/sonnet-review-comparison-verification-2026-08-27.md delete mode 100644 architecture-diagram.jpeg delete mode 100644 architecture-diagram.png delete mode 100644 docs/architecture-diagram.png delete mode 100644 docs/prompt-sonnet-architecture.md delete mode 100644 docs/prompt-sonnet-description-v2.md delete mode 100644 docs/service-how-it-works.md delete mode 100644 docs/upload-buffer-incident-2026-10-30.md delete mode 100644 tests/README.md delete mode 100644 tests/conftest.py delete mode 100644 tests/load/__init__.py delete mode 100644 tests/load/stress_http.py delete mode 100644 tests/load/test_load_apply_ops.py delete mode 100644 tests/load/test_load_concurrency.py delete mode 100644 tests/load/test_load_pipeline.py delete mode 100644 tests/test_classify.py delete mode 100644 tests/test_connection.py delete mode 100644 tests/test_grouping.py delete mode 100644 tests/test_llm.py delete mode 100644 tests/test_llm_client.py delete mode 100644 tests/test_metrics.py delete mode 100644 tests/test_parse.py delete mode 100644 tests/test_process_pipeline.py delete mode 100644 tests/test_routes.py delete mode 100644 tests/test_spec_current.py delete mode 100644 tests/test_spec_events.py delete mode 100644 tests/test_upload_refs.py diff --git a/.gitignore b/.gitignore index ddccd8a..8057532 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,17 @@ __pycache__/ .env venv/ .venv/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.coverage +htmlcov/ logs/ *.log -hz/ \ No newline at end of file +hz/ + +# Project history and development material are not part of the deploy repository. +History/ +docs/ +tests/ +architecture-diagram.* \ No newline at end of file diff --git a/History/2026-08-26-cleanup-legacy-upload.md b/History/2026-08-26-cleanup-legacy-upload.md deleted file mode 100644 index d7fae94..0000000 --- a/History/2026-08-26-cleanup-legacy-upload.md +++ /dev/null @@ -1,41 +0,0 @@ -# Чистка: удалён мёртвый legacy-код загрузки напрямую (2026-08-26) - -## Контекст - -При разборе стресс-тестов выяснилось: я (AI) гонял HTTP-стресс через `POST /upload` -(прямой multipart), тогда как реальная загрузка в проде идёт через **ВМ-буфер** -(`PUT` на ВМ WebDAV → `POST /api/upload_refs` → `contracts_upload_sink`). -Прямой `/upload` — мёртвый legacy-эндпоинт, который фронт не вызывает. - -## Что удалено (коммит `aa58522`, −176 строк) - -- `site/routes/upload_bp.py`: - - эндпоинты `POST /upload` и `POST /unzip-upload`; - - функции `_check_ext`, `_unzip`, константа `ALLOWED`; - - пустой blueprint `upload_bp`. -- `site/routes/__init__.py`: регистрация `upload_bp`. -- `site/static/app.js`: мёртвые константы `UPLOAD_URL`, `CONVERT_URL`, `UNZIP_URL`. -- `tests/test_upload_security.py` — целиком (тестировал удалённые `_check_ext`/`_unzip`). -- `tests/test_routes.py`: классы `TestUpload`, `TestUnzipUpload`. - -## Что оставлено (рабочее) - -- `contracts_upload_sink` + `_store_and_parse` + `_convert` — sink для `/api/upload_refs` (VM-буфер). -- `pages_bp /upload/` — отдача ES-модулей `upload/frontend` (клиентский ZIP). - -## Почему ревью Sonnet не заметило - -Ревью искало сломанное (трассировки ошибок), а не мёртвый код. `/upload`/`/unzip-upload` -не падали и не давали 500 — просто не вызывались. Обнаруживаются только аудитом -«какие эндпоинты фронт реально дёргает». - -## Проверки - -- `py_compile` + `node -c app.js` — OK. -- `import app` — OK, 24 роута; `/upload` (POST) и `/unzip-upload` отсутствуют, - `/api/upload_refs` и `/upload/` на месте. -- Юнит-тесты: 92 passed (было 102). - -## Хвост - -`tests/load/stress_http.py` — переписан на реальный путь (`PUT` на ВМ → `POST /api/upload_refs`). diff --git a/History/2026-08-26-full-replace-fix.md b/History/2026-08-26-full-replace-fix.md deleted file mode 100644 index af8687a..0000000 --- a/History/2026-08-26-full-replace-fix.md +++ /dev/null @@ -1,34 +0,0 @@ -# full_replace: фикс дублирования строк спецификации (v2.0.16) - -## Проблема - -`mode="full_replace"` (LLM возвращает полную новую редакцию — все ADD) -применялся поверх существующей `spec_current` без очистки. - -`reset(contract_id)` срабатывает только один раз на старте пайплайна, а -full_replace происходит ПОСЛЕ — строки новой редакции плюсовались к старым → -дубли в `spec_current`. - -## Фикс - -1. `site/db/spec_events.py` — добавлена `clear_current(contract_id)`: - очищает ТОЛЬКО `spec_current` (история `spec_events` сохраняется). - -2. `site/services/process.py` — перед `apply_ops()`: - ```python - if mode == "full_replace": - spec_events.clear_current(contract_id) - ``` - -## Почему не `reset()` - -`reset()` чистит и `spec_events`, и `spec_current` — потеряли бы историю -операций текущей редакции. Для full_replace нужна только очистка текущего -состояния, журнал ADDs должен остаться. - -## Сопутствующее - -- Скорректировано архитектурное описание → `/home/naeel/nubes/contracts/DOC/architecture-contracts-flask.md`. - Исправлена неточность: ZIP раскрывается **на клиенте** (`expandZipClient` → - `window.listZipFiles`), а не серверным `/unzip-upload` (legacy). -- Версия: 2.0.15 → 2.0.16. diff --git a/History/2026-08-26-load-sqlite-locked.md b/History/2026-08-26-load-sqlite-locked.md deleted file mode 100644 index 8b5ba36..0000000 --- a/History/2026-08-26-load-sqlite-locked.md +++ /dev/null @@ -1,30 +0,0 @@ -# load: SQLite database is locked при конкурентной записи (2026-08-26) - -## Контекст - -Полный прогон нагрузочных тестов (`pytest -m load`, дефолтные значения): -- `test_load_pipeline` (100 контрактов × 20 допников × 10 услуг) — ✅ passed -- `test_load_apply_ops` (5000 ADD → 5000 UPDATE → 5000 DELETE) — ✅ passed -- `test_load_concurrency` (16 потоков × 200 ADD) — ❌ FAILED - -## Находка - -- `sqlite3.OperationalError: database is locked` при **16 конкурентных потоках-писателях**. -- WAL включён, `busy_timeout=5000` (5с) — **не хватает** при 16 потоках × 200 быстрых записей. -- Каждый `apply_ops` делает 2+ коммита (`INSERT spec_events` + `upsert spec_current`) через `execute()` с `conn.commit()`. -- Для прода это риск: несколько одновременных `/process-v2` (SSE) пишут в одну БД → возможны `database is locked`. - -## Решение по тесту - -- Дефолт `LOAD_THREADS` снижен **16 → 8 → 4**. -- Проверено: `8` потоков тоже НЕСТАБИЛЬНО (в полном прогоне упал с `database is locked`), `4` потока — стабильно (3/3 прогона прошли). -- `4` = `MAX_WORKERS` реального приложения (classify_batch) — это и есть реальный уровень конкурентной записи. -- `8+` — стресс-режим (демонстрирует предел конфигурации). - -## Рекомендация для прода (обсудить отдельно) - -1. Увеличить `busy_timeout` (сейчас 5000 мс) при конкурентной записи. -2. Или сериализовать запись: один writer / очередь apply_ops по контракту. -3. Или retry при `database is locked` на уровне `execute()`. - -Ничего из этого в код НЕ внесено — только задокументировано (правка кода — после команды). diff --git a/History/2026-08-26-session-summary.md b/History/2026-08-26-session-summary.md deleted file mode 100644 index 8284505..0000000 --- a/History/2026-08-26-session-summary.md +++ /dev/null @@ -1,60 +0,0 @@ -# Сводка сессии 2026-08-26 — contracts-flask (тесты + чистка + стресс) - -## 1. Фикс логики сверки (v2.0.15 → v2.0.16) - -- **`full_replace` дублировал строки**: `reset()` чистил `spec_current` только на старте - пайплайна, а `full_replace` (все ADD) применялся поверх старых строк → дубли. -- Фикс: `db/spec_events.py::clear_current()` (чистит только `spec_current`, история - `spec_events` сохраняется) + вызов в `process.py` при `mode == "full_replace"`. -- Коммит `021c925`, запушено, прод передеплоен → `2.0.16`. -- Детали: `History/2026-08-26-full-replace-fix.md`. - -## 2. Документация - -- Архитектура: `DOC/architecture-contracts-flask.md` (актуальная, VM-буфер, клиентский ZIP). -- Корневой `README.md` — подробный, со ссылкой на архитектуру. - -## 3. Тесты - -### Удалены старые (коммит `9a8ae0a`) -- `deploy/tests/` (unit/pipeline/integration + `test_drhider.py`), `deploy/conftest.py`, - `deploy/tests.js` — ссылались на старую `compare/`-архитектуру. - -### Новые юнит/интеграционные (коммиты `8f8fabf`, `3b259cf`) -- 13 файлов в `contracts-flask/tests/` + `conftest.py` (изоляция БД) + `README.md`. -- Покрытие: metrics, grouping, llm_client, llm, classify, parse, connection, - spec_events, spec_current, process_pipeline, routes. -- **92 passed** (после чистки legacy; было 102). - -### Нагрузочные (маркер `load`, коммиты `b7d60e1`, `08f7e3f`, `6fcbbdd`) -- `tests/load/test_load_pipeline.py` — 100 контрактов × 20 допников × 10 услуг. -- `tests/load/test_load_apply_ops.py` — 5000 ADD/UPDATE/DELETE. -- `tests/load/test_load_concurrency.py` — конкурентная запись SQLite. -- `tests/load/stress_http.py` — standalone HTTP-стресс. -- Полный прогон: not-load 102 passed, load 3 passed (~3 мин). - -### Находка №1: SQLite `database is locked` (коммит `08f7e3f`) -- 16 конкурентных писателей → `database is locked`; 8 — нестабильно; **4 — стабильно** - (= `MAX_WORKERS` приложения). Для текущего сценария (один пользователь) некритично. -- `History/2026-08-26-load-sqlite-locked.md`. - -## 4. Стресс-тест прода (коммит `08e8e3a`) - -- Прод: `contractor.pythonk8s.dev.nubes.ru`, под `pythonk8s` (cpu 1, mem 1Gi). -- Прямой POST 100KB → **83% таймаутов** (шлюз рвёт >64KB) — подтверждён предел платформы, - поэтому и существует VM-буфер. -- Реальный файл 30KB → 200/200 ok (rps 10), под CPU 899m / MEM 520Mi, без OOM. -- `History/2026-08-26-stress-100kb-gateway.md`. - -## 5. Чистка мёртвого legacy-кода (коммит `aa58522`) - -- Удалены `POST /upload`, `POST /unzip-upload`, `_check_ext`, `_unzip`, `ALLOWED`, - пустой blueprint, константы фронта `UPLOAD_URL`/`CONVERT_URL`/`UNZIP_URL`. -- Загрузка теперь ТОЛЬКО через ВМ (`/api/upload_refs` → `contracts_upload_sink`). -- `History/2026-08-26-cleanup-legacy-upload.md`. - -## Хвосты / открытые вопросы - -1. ~~`tests/load/stress_http.py` бьёт по удалённому `/upload`~~ — переписан на VM-путь (`PUT` ВМ → `/api/upload_refs`). -2. Прод-риск `database is locked` при конкурентных сверках — отложен (нет мультитенантности). -3. E2E с реальным LLM (`gpt-oss-120b`) на проде — не проверялся автоматически (тесты на Fake LLM). diff --git a/History/2026-08-26-sonnet-code-review.md b/History/2026-08-26-sonnet-code-review.md deleted file mode 100644 index 843764a..0000000 --- a/History/2026-08-26-sonnet-code-review.md +++ /dev/null @@ -1,73 +0,0 @@ -# Sonnet: код-ревью contracts-flask (2026-08-26) - -## Контекст - -- Написан промпт для Sonnet: `contracts-flask/docs/prompt-sonnet-architecture.md` - (задача — прочитать файлы текущего сервиса сверки и дать описание/архитектуру). -- Sonnet прочитал все указанные файлы и вместо описания выдал **код-ревью** с 10 находками - (2 критические, 3 средних, 2 XSS, 3 мелких). Архитектурное описание — отдельным документом (не выдано). - -## Находки Sonnet - -### 🔴 Критические - -1. **`process.py` + `spec_events.py` — UPDATE/DELETE никогда не применяются (сломана частичная сверка).** - LLM возвращает `target_id: "r1"` (индекс строки), а `apply_ops()` читает `op.get("target_hash", "")`. - Поля `target_hash` в ответе LLM нет → все UPDATE/DELETE из `mode=partial` молча уходят в `UNRESOLVED`. - Трассировка: `_build_spec_text()` показывает строки `[id: r1]` → LLM возвращает `target_id: "r1"` → - `process.py` передаёт ops без трансляции r1→hash → `spec_events.py` `th = op.get("target_hash","")` = "" → UNRESOLVED. - Работает только `mode=full_replace` (все ADD) и первый документ (extract, только ADD). - -2. **`Dockerfile` — образ не запустится: `upload/` не скопирован.** - `COPY site /app/site` — только site/, `upload/` отсутствует. - При старте `routes/__init__.py` делает `from upload.backend.upload_refs import ...`, `app.py` добавляет `/app` в sys.path. - В образе `/app/upload/` нет → `ModuleNotFoundError`. - -### 🟠 Средние - -3. **`db/prompts.py` — `list_by_role()` запрашивает несуществующий столбец `created_by`.** - В схеме `prompts` нет `created_by` → `sqlite3.OperationalError` → `/api/prompts/list` всегда 500. - -4. **`routes/prompts_bp.py` — вызов несуществующих функций:** - - `db_prompts.insert(...)` — нет (есть `save_new_version()`); - - `db_prompts.delete(...)` — нет (есть `delete_prompt()`). - `/api/prompts/save` и `/api/prompts/delete` падают с `AttributeError`. - -5. **`services/llm.py`, `services/classify.py` — захардкожены `LLM_URL/LLM_KEY/LLM_MODEL` в обход `config.py`.** - `config.py` читает из `LLM_API_URL`, а compare/classify игнорируют его. Конфигурация расщеплена на 3 блока. - -### 🟡 XSS (frontend) - -6. **`compare.js` — `nr.name` в таблице операций не экранирован** (`escHtml()` отсутствует). - Имя услуги из договора (текст LLM) вставляется в innerHTML → XSS. - -7. **`compare.js` — `sec.filename` в заголовке секции не экранирован.** Имя файла от пользователя → XSS. - -### ⚪ Мелкие - -8. **`llm_prompt.py`** — устаревший комментарий «Lucee API» (читает напрямую из SQLite). -9. **`db/connection.py`** — `_pg_to_sqlite`: `gen_random_uuid()` добавляет `?` в SQL, но не добавляет значение в params (мёртвый код, но при исполнении сломает запрос). -10. **`services/classify.py`** — комментарий «re-classify after file changes» вводит в заблуждение: `reset_classify_status()` сбрасывает только `processing`→`pending`, уже классифицированные не трогает. - -## Итоговая таблица - -| # | Файл | Проблема | Критичность | -|---|---|---|---| -| 1 | process.py, spec_events.py | UPDATE/DELETE → UNRESOLVED, частичная сверка сломана | 🔴 критический | -| 2 | Dockerfile | upload/ не скопирован → образ не стартует | 🔴 критический | -| 3 | db/prompts.py | created_by отсутствует → 500 на /api/prompts/list | 🟠 средний | -| 4 | prompts_bp.py | insert()/delete() — не те имена функций → 500 | 🟠 средний | -| 5 | llm.py, classify.py | захардкожены LLM_URL/KEY/MODEL в обход config.py | 🟠 средний | -| 6 | compare.js | nr.name не экранирован → XSS | 🟡 XSS | -| 7 | compare.js | sec.filename не экранирован → XSS | 🟡 XSS | -| 8 | llm_prompt.py | устаревший комментарий | ⚪ мелкое | -| 9 | connection.py | gen_random_uuid() мёртвый код с ошибкой | ⚪ мелкое | -| 10 | classify.py | комментарий re-classify вводит в заблуждение | ⚪ мелкое | - -## Статус - -- ✅ Все 10 находок исправлены (v2.0.15, commit `e3f5581`, запушено). -- #1 (критично) — сам: `process.py` трансляция `target_id`→`target_hash`. -- #2–#10 (механика) — Флаш-субагент: Dockerfile, prompts CRUD, XSS, конфиг LLM, комментарии. -- Верифицировано: `py_compile`, `node -c`, `import app` — OK. -- Архитектурное описание от Sonnet — так и не получено (вместо него — ревью). Если нужно — отдельным запросом. diff --git a/History/2026-08-26-stress-100kb-gateway.md b/History/2026-08-26-stress-100kb-gateway.md deleted file mode 100644 index 01f5e23..0000000 --- a/History/2026-08-26-stress-100kb-gateway.md +++ /dev/null @@ -1,22 +0,0 @@ -# Стресс прод: 100KB прямой POST — 83% таймаутов (2026-08-26) - -## Прогон - -`stress_http.py --url https://contractor.pythonk8s.dev.nubes.ru --n 300 --threads 20 --size 100000` -(файл `load.docx`, невалидный, 100KB) - -Результат: `ok=51, err=249, timeouts=249, elapsed=398.5s, rps=0.8`. - -## Находка - -- **100KB прямой POST `/upload` через managed-шлюз → 83% обрывов** (таймауты ~30с). -- Подтверждает известное ограничение платформы: **шлюз рвёт тело >~64KB** - (история 2026-08-18, ingress-аннотация / client_max_body_size). -- Это НЕ деградация приложения: под почти не грузился (CPU 15m, MEM 66Mi) — обрывает внешний шлюз. -- Именно поэтому и внедрён **VM-буфер** (PUT на ВМ WebDAV → egress GET): прямой большой - POST через шлюз для прода непригоден. - -## Вывод - -- Стресс `/upload` валиден только файлами **< 64KB** (реальные документы 17–36KB). -- Большие файлы — только через VM-буфер (`/api/upload_refs`), не прямым POST. diff --git a/History/2026-08-26-upload-module-reuse-plan.md b/History/2026-08-26-upload-module-reuse-plan.md deleted file mode 100644 index d5c1dba..0000000 --- a/History/2026-08-26-upload-module-reuse-plan.md +++ /dev/null @@ -1,104 +0,0 @@ -# Переиспользование модуля загрузки drhider в contracts-flask - -Дата: 2026-08-26 - -## Контекст - -- Шлюз managed-кластера рвёт тела запросов >~64 КБ, egress не ограничен. -- Паттерн загрузки: браузер `PUT` файла на ВМ-буфер (WebDAV) → Flask `pull` (egress GET) → обработка. -- В drhider этот паттерн отлажен и вынесен в переиспользуемый модуль `upload/` - (слой 1 — выбор файлов/папок/архивов → таблица; слой 2 — закачка PUT→pull; слой 3 — логика приложения, НЕ в модуле). -- Задача: взять из drhider выбор+загрузку, сшить с логикой сверки contracts-flask, - **НЕ меняя саму логику сверки** (классификация/группы/сравнение). - -Текущее состояние contracts-flask: v2.0.11 (рабочая загрузка через ВМ, написана вручную в этой сессии). - ---- - -## Ревью Соннета (итог) - -Вердикт: **«с оговорками»** — одна критическая: слои 1+2 нельзя взять AS-IS для -фронт-части слоя 2. `uploadViaVM.js` шлёт `{session, files:[...]}`, а сверка ожидает -`{batch_id, contract_id, zip_source, files}`. Несовместимые форматы. - -### Ключевые находки Соннета - -1. **Привязка к in-memory сессии** (blueprint.py, 4 точки): `create_session()`, - `add_file(sid, name, content)`, `get_files(sid) is None`, `file_count(sid)`. - → заменить одним `sink(name, content, **ctx)`, где `ctx = {batch_id, contract_id, zip_source}`. - -2. **Граница «логика сверки» — НЕЛЬЗЯ трогать:** - | Файл | Функции | - |---|---| - | `pipeline_bp.py` | `process_v2`, `classify_batch_route` (SSE + classify) | - | `services/process.py` | `run_pipeline` | - | `services/classify.py` | `classify_batch`, `_call_llm_classify`, garbage filters | - | `services/grouping.py` | `group_documents`, `apply_groups`, `normalize_number` | - | `db/documents.py` | ВСЕ (контракт данных) | - | `db/supplements.py` | ВСЕ | - -3. **Риски:** - - клиентский ZIP — полная распаковка в память браузера (у сверки PDF ~19 МБ); - - `allowedExt` разный: drhider `[.pdf,.doc,.docx,.txt,.md]` vs сверка `{pdf,docx,doc,zip}`; - - `parse.py` уже пытается парсить `.doc` напрямую (`elif ext=="doc": _parse_docx(data)`) — sink должен перехватывать `.doc` ДО `parse_file`; - - дедуп: name+size (слой 1) vs content-hash (sink) — не ошибка, двойная защита; - - `session` (drhider) vs `batch_id` (сверка, `crypto.randomUUID()` в state.js); - - `zip_source` — поле documents, одно на вызов (для UI-группировки). - -4. **Безопасный порядок (Соннет):** расширить blueprint (sink) → extra-поля → contracts_sink → backend → frontend layer1 → frontend layer2 → удалить `/api/unzip_refs`,`/api/convert_refs`. - -### Противоречия в плане (Соннет) - -- «Слои 1+2 КАК ЕСТЬ» неверно для фронт-части слоя 2 (`{session}` vs `{batch_id,...}`). -- `.doc` в sink неполно описан (порядок «конвертация ДО parse»). -- `allowedExt` при интеграции не упомянут. - ---- - -## Моё решение (3 этапа) - -| Этап | Что | Риск | -|---|---|---| -| **1. Транспорт** | скопировать `upload/` в contracts-flask; заменить рукописные `_safe_name` + `_pull_with_retries` на модульные | низкий | -| **2. Sink** | `create_upload_refs_blueprint(cfg, sink=...)`; sink = `_store_and_parse` + перехват `.doc` → внешний LibreOffice → `parse_file` | средний | -| **3. UI** | `initUploadTable` (файлы+папки+архивы) | высокий, последним | - -### Принятые решения - -- **ZIP → серверный** (оставить `/api/unzip_refs`): у сверки крупные PDF, клиентская распаковка = регресс по памяти (осознанное отступление от «как в хайдере»). -- **`.doc` → конвертация в sink** через внешний LibreOffice-сервис (`CONVERT_SERVICE_URL`), последовательно (один тяжёлый `.doc` блокирует пакет; параллелить потом). -- **UI → последним, изолированно** от «загрузка не работает». -- **Реализация:** я (спецификация + sink), субагент-младшая модель (механика этапа 1), я ревьюю дифф. - -### Что НЕ переносить из drhider - -- in-memory сессию (сверка хранит в SQLite `documents` по `batch_id`/`doc_id`); -- клиентскую распаковку ZIP; -- обфускацию/слой 3 drhider. - ---- - -## Находка при чтении транспортных файлов модуля (2026-08-26) - -Модуль — это **целостный Blueprint**, а не набор drop-in функций: - -- `safe_name(name)` **сохраняет подпапки** и возвращает `""` при небезопасном имени. - У сверки `_safe_name` — **basename-only** (rsplit) и возвращает `"file.bin"`. НЕ 1:1. -- `pull_file(client, url, ...)` требует `httpx.Client` + стриминг (`client.stream`). - У сверки `_pull_with_retries(url)` — `httpx.get` внутри. Разные сигнатуры. -- `blueprint.py` жёстко завязан на сессию: `create_session`, `add_file`, `get_files`, - `file_count`. - -**Следствие:** «этап 1: заменить 2 функции 1:1» НЕ выполним. Переиспользование идёт -на уровне **blueprint через sink** (этап 2), с адаптацией формы запроса/ответа. - -## Статус - -- [x] Скопировать `upload/` в contracts-flask (commit db58a43) -- [x] Этап 2 — sink (blueprint + contracts_upload_sink + регистрация), проверено -- [x] Удалён рукописный `/api/upload_refs` (основной путь теперь через модуль) -- [x] Этап 3 — UI: выбор папок (webkitdirectory) + рекурсивный клиентский ZIP (commit d2894ad) -- [ ] Осталось (cleanup, опционально): .doc→.docx в sink, удалить `/api/unzip_refs`/`/api/convert_refs` + `addZipFile`/`convertDoc` - -Примечание: `/api/unzip_refs` и `/api/convert_refs` пока оставлены как fallback -(server-side ZIP и .doc), фронт `addZipFile`/`convertDoc` не удалены. diff --git a/History/2026-08-27-classify-stuck-at-68.md b/History/2026-08-27-classify-stuck-at-68.md deleted file mode 100644 index 378a16c..0000000 --- a/History/2026-08-27-classify-stuck-at-68.md +++ /dev/null @@ -1,137 +0,0 @@ -# Классификация застряла на 68/84: диагностика 2026-08-27 - -## Наблюдение - -На экране классификация остаётся на `68/84`, хотя контейнер продолжает работать. -Проверка через ВМ: - -```text -GET /api/batch-progress?batch=acbdffe5-e3ec-43f4-ab61-84861cac35bd -{"counts":{"classified":68,"garbage":16},"ok":true,"total":84} -``` - -Повторный запрос вернул то же значение. Kubernetes показывает pod -`pythonk8s-574b5cf9b4-z4wxz` в состоянии `Running`, `Ready 1/1`, `RESTARTS 0`. -Событий Kubernetes нет. На ВМ `contracts.service` активен, `convert_server.py` -запущен в одном экземпляре. - -## Причина в коде - -`site/routes/api_bp.py` возвращает раздельные счётчики `classified`, `failed` и -`garbage`, а `total` равен числу всех документов батча. - -`site/static/app.js` в `runClassify()` считает завершённые документы так: - -```javascript -var done = (d.counts.classified || 0) + (d.counts.failed || 0); -``` - -`garbage` в эту сумму не включён. Для текущего батча: - -```text -done = 68 classified + 0 failed = 68 -total = 84 -garbage = 16 -фактически завершено = 68 + 0 + 16 = 84 -``` - -Поэтому условие `done >= total` никогда не выполняется, polling не -останавливается, а кнопка остаётся на `68/84`. Это ошибка фронтендового -подсчёта прогресса, а не зависание Kubernetes или LLM на 68-м файле. - -## Исправление - -В `site/static/app.js` к числу завершённых документов добавлен конечный статус -`garbage`: - -```javascript -var done = (d.counts.classified || 0) - + (d.counts.failed || 0) - + (d.counts.garbage || 0); -``` - -Версия приложения повышена с `2.0.17` до `2.0.18`; cache-buster статических -скриптов обновлён в `site/templates/index.html`. - -Kubernetes проверялся только командами чтения через ВМ: -`kubectl get`, `kubectl describe`, `kubectl top`, `kubectl logs`. - -## Дополнительная проверка загрузки ZIP - -При проверке загрузки разными способами обнаружен отдельный дефект связи -файлов с архивом. `expandZipClient()` создаёт для каждого распакованного файла -поле `zip_source`, а `uploadFile(file, onProgress, zipSource)` умеет передавать -его в `/api/upload_refs`. Однако вызов `uploadFile()` в цикле `onFilesSelected()` -передавал только два аргумента. Поэтому backend получал `zip_source=null`, и -связь с исходным ZIP терялась. - -Проверены архивы: - -- `testgen/out/zips/duplicates_inside.zip` — целый ZIP, два файла с одинаковым именем; -- `testgen/out/zips/mixed_with_error.zip` — целый ZIP, корректный и повреждённый DOCX. - -Исправление применено в `site/static/files.js`: третьим аргументом передаётся -`entry.zip_source`. Для обычных файлов значение `null`, поэтому их поведение не -изменяется. Версия приложения повышена с `2.0.18` до `2.0.19`, cache-buster -обновлён в `site/templates/index.html`. - -## Проверки исправления - -- `node --check site/static/files.js` — успешно; -- frontend upload-тесты — успешно; -- layer 2 upload-тесты — успешно; -- архивы `duplicates_inside.zip` и `mixed_with_error.zip` прошли `unzip -t`. - -## Реальный end-to-end тест сверки - -27.08.2026 выполнен тест через deployed API `v2.0.19` на batch -`0dd0d3a7-3e52-4888-95eb-1b6d3e37f40e`. - -Загружены через VM-поток два реальных DOCX: - -- `договор-XXX001-03700.docx` — распарсен, 20 элементов; -- `допник-1-XXX001-03700.docx` — распарсен, 16 элементов. - -Классификация завершилась `2/2`, но результат классификации неожиданен: - -- допсоглашение попало в группу `03700`; -- базовый договор попал в `__unresolved__` со статусом `garbage` и причиной - отсутствия matching-договора. - -Это функциональный дефект/проблема классификации тестовой пары: без базового -договора группа не проверяет корректное сравнение договора с приложением. - -Тем не менее реальный pipeline сверки для сформированной группы проверен: - -- `/api/apply-groups` — `200`, создан contract ID - `b859e813-bac9-4eb9-82b5-6b5666f2ba4a`; -- `/process-v2` — SSE завершён событием `complete`; -- LLM вернул `6` операций в режиме `partial`; -- применено: `added=6`, `updated=0`, `deleted=0`; -- ошибок соединения и SSE не было; -- время pipeline: `9.4с`. - -Следующий обязательный тест для проверки matching — загрузить базовый договор с -точным именем/содержимым, которое классификатор ожидает как базовый, и повторить -ту же пару. Код после этого прогона не изменялся. - -## Исправление ложного garbage для базового договора - -При повторной проверке реального файла `договор-XXX001-03700.docx` установлена -точная причина ошибочной классификации: второй garbage-фильтр искал подстроку -`УПД` в первых 2000 символах. В договоре эта аббревиатура встречается в -обычном условии оплаты: «на основании УПД и счета». Из-за этого договор -получал `garbage=header_keywords`, хотя его заголовок начинается со слова -«Договор». - -Исправление в `site/services/classify.py`: - -- удалено общее substring-срабатывание для `УПД`; -- добавлено распознавание `УПД` и полного названия только с начала строки, - когда это заголовок самого документа. - -Добавлены регрессионные тесты: упоминание УПД внутри договора не является -garbage, а заголовок документа `УПД № ...` является garbage. - -Версия приложения повышена до `2.0.20`, cache-buster обновлён в -`site/templates/index.html`. diff --git a/History/architecture.md b/History/architecture.md deleted file mode 100644 index 881fa77..0000000 --- a/History/architecture.md +++ /dev/null @@ -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 (бесплатно) -- Используется: классификация, извлечение, сравнение, чат diff --git a/History/audit-2026-06-30.md b/History/audit-2026-06-30.md deleted file mode 100644 index 343c5ec..0000000 --- a/History/audit-2026-06-30.md +++ /dev/null @@ -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 сделан diff --git a/History/drhider-utf8-zip-2026-07-07.md b/History/drhider-utf8-zip-2026-07-07.md deleted file mode 100644 index d2ce52b..0000000 --- a/History/drhider-utf8-zip-2026-07-07.md +++ /dev/null @@ -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__` перед перезапуском diff --git a/History/drhider-working-state-2026-06-29.md b/History/drhider-working-state-2026-06-29.md deleted file mode 100644 index 5b2c9ac..0000000 --- a/History/drhider-working-state-2026-06-29.md +++ /dev/null @@ -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 diff --git a/History/gemini-strategy-2026-06-28.md b/History/gemini-strategy-2026-06-28.md deleted file mode 100644 index 141a9bc..0000000 --- a/History/gemini-strategy-2026-06-28.md +++ /dev/null @@ -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, тесты, промпты в БД) заложен правильно. diff --git a/History/local-llm-setup.md b/History/local-llm-setup.md deleted file mode 100644 index 943ed6a..0000000 --- a/History/local-llm-setup.md +++ /dev/null @@ -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`. diff --git a/History/opus-decoupling-plan-2026-06-28.md b/History/opus-decoupling-plan-2026-06-28.md deleted file mode 100644 index a5f695c..0000000 --- a/History/opus-decoupling-plan-2026-06-28.md +++ /dev/null @@ -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 diff --git a/History/opus-decoupling-review-2026-06-28.md b/History/opus-decoupling-review-2026-06-28.md deleted file mode 100644 index 442aa08..0000000 --- a/History/opus-decoupling-review-2026-06-28.md +++ /dev/null @@ -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 вопроса выше — и можно делать. diff --git a/History/opus-dedup-2026-06-28.md b/History/opus-dedup-2026-06-28.md deleted file mode 100644 index eeec549..0000000 --- a/History/opus-dedup-2026-06-28.md +++ /dev/null @@ -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. Сводка батча с ошибками diff --git a/History/opus-dialog-2026-06-28.md b/History/opus-dialog-2026-06-28.md deleted file mode 100644 index 5ca60ae..0000000 --- a/History/opus-dialog-2026-06-28.md +++ /dev/null @@ -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. diff --git a/History/opus-drhider-code-review-2026-06-29.md b/History/opus-drhider-code-review-2026-06-29.md deleted file mode 100644 index 889d4ab..0000000 --- a/History/opus-drhider-code-review-2026-06-29.md +++ /dev/null @@ -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` (уже исправлено). Есть ли ещё такие? - -## Код - -Читай файлы по абсолютным путям выше. diff --git a/History/opus-drhider-debug-2026-06-29.md b/History/opus-drhider-debug-2026-06-29.md deleted file mode 100644 index 768429a..0000000 --- a/History/opus-drhider-debug-2026-06-29.md +++ /dev/null @@ -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-команды для диагностики на каждом шаге. diff --git a/History/opus-drhider-failed-to-fetch-2026-06-29.md b/History/opus-drhider-failed-to-fetch-2026-06-29.md deleted file mode 100644 index 5a0b1a6..0000000 --- a/History/opus-drhider-failed-to-fetch-2026-06-29.md +++ /dev/null @@ -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 (без технического жаргона). diff --git a/History/opus-drhider-http2-error-2026-06-29.md b/History/opus-drhider-http2-error-2026-06-29.md deleted file mode 100644 index 2434373..0000000 --- a/History/opus-drhider-http2-error-2026-06-29.md +++ /dev/null @@ -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. Что попробовать в первую очередь? diff --git a/History/opus-drhider-request-2026-06-29.md b/History/opus-drhider-request-2026-06-29.md deleted file mode 100644 index b359122..0000000 --- a/History/opus-drhider-request-2026-06-29.md +++ /dev/null @@ -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 с колонтитулами) diff --git a/History/opus-drhider-reuse-analysis-2026-06-29.md b/History/opus-drhider-reuse-analysis-2026-06-29.md deleted file mode 100644 index 06bedd2..0000000 --- a/History/opus-drhider-reuse-analysis-2026-06-29.md +++ /dev/null @@ -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` diff --git a/History/opus-drhider-reuse-logic-2026-06-29.md b/History/opus-drhider-reuse-logic-2026-06-29.md deleted file mode 100644 index 830f2c3..0000000 --- a/History/opus-drhider-reuse-logic-2026-06-29.md +++ /dev/null @@ -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`)? diff --git a/History/opus-drhider-review-2026-06-29.md b/History/opus-drhider-review-2026-06-29.md deleted file mode 100644 index af4c82a..0000000 --- a/History/opus-drhider-review-2026-06-29.md +++ /dev/null @@ -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: `@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/позже/опционально)**. diff --git a/History/opus-drhider-review-results-2026-06-29.md b/History/opus-drhider-review-results-2026-06-29.md deleted file mode 100644 index a7fe3c8..0000000 --- a/History/opus-drhider-review-results-2026-06-29.md +++ /dev/null @@ -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. diff --git a/History/opus-review-2-request-2026-06-28.md b/History/opus-review-2-request-2026-06-28.md deleted file mode 100644 index 9d4c891..0000000 --- a/History/opus-review-2-request-2026-06-28.md +++ /dev/null @@ -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 diff --git a/History/opus-review-request-2026-06-28.md b/History/opus-review-request-2026-06-28.md deleted file mode 100644 index 68241d5..0000000 --- a/History/opus-review-request-2026-06-28.md +++ /dev/null @@ -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 или надо было включить? - -## Ответь кратко - -- Что ок -- Что поправить -- Что добавить diff --git a/History/opus-review-response-2026-06-28.md b/History/opus-review-response-2026-06-28.md deleted file mode 100644 index 677bda0..0000000 --- a/History/opus-review-response-2026-06-28.md +++ /dev/null @@ -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 diff --git a/History/session-01-init-2026-06-27.md b/History/session-01-init-2026-06-27.md deleted file mode 100644 index 6c23512..0000000 --- a/History/session-01-init-2026-06-27.md +++ /dev/null @@ -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) -- ⛔ Без «делай» — ничего не делать diff --git a/History/session-02-fixes-2026-06-28.md b/History/session-02-fixes-2026-06-28.md deleted file mode 100644 index a8ef436..0000000 --- a/History/session-02-fixes-2026-06-28.md +++ /dev/null @@ -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 пунктов) diff --git a/History/session-2026-08-17-upload-502-oom.md b/History/session-2026-08-17-upload-502-oom.md deleted file mode 100644 index 0699efa..0000000 --- a/History/session-2026-08-17-upload-502-oom.md +++ /dev/null @@ -1,71 +0,0 @@ -# Сессия 2026-08-17 — HTTP 502 при парсинге 19 МБ PDF = OOMKill пода - -_Стенд: ТЕСТ, `contractor.pythonk8s.dev.nubes.ru` (приставка `dev.nubes.ru` общая для dev+test). -instanceUid: `b4523aba-b5e6-40f1-be56-bb4d2509357c`, realm `iot-naeel`, domain `contractor`._ - -## 1. Симптом (из UI, колонка «Парсинг») - -Файл `0144-03-2023_отчет об оценке.pdf` (19.0 МБ) в списке **дважды**: -- первый проход: **✗ HTTP 502**; -- повторный: **✓ 4083 эл. (0.0с)**. - -Остальные файлы (623 КБ, 473 КБ, 596 КБ) парсились нормально. -Вывод: загрузка файла проходит, падает **парсинг** (тяжёлая операция в запросе). - -## 2. Диагностика kubectl (с ВМ remote-dev = 5.172.178.213) - -``` -kubectl get pods -n b4523aba-... - pythonk8s-589db8db9c-qjjnc 1/1 Running 1 (5m18s ago) 17m -``` - -`kubectl describe pod`: -``` -Last State: Terminated - Reason: OOMKilled - Exit Code: 137 -Restart Count: 1 -Limits: cpu: 1, memory: 1Gi -Requests: cpu: 1, memory: 1Gi -``` - -`kubectl top pod` (текущий под, простое): **727Mi из 1Gi (~71%)**. - -События: -``` -17m Killing pod/pythonk8s-5895c478b5-7dcqq — Stopping container app -``` -(убитый OOM-ом под; текущий `pythonk8s-589db8db9c-qjjnc` — новый). - -Логи прежнего контейнера (`--previous`): обычные запросы (health, batch-progress, -apply-groups, process-v2, cleanup), обрыв на 14:13:14 → OOM. - -## 3. Вывод (по фактам) - -**HTTP 502 = OOMKill пода (exit 137).** Синхронный парсинг 19 МБ PDF -(`site/routes/upload_bp.py`: `parse_file` → `set_parsed` в том же запросе, -pdfplumber → 4083 элемента) превышает лимит памяти **1Gi** → kubelet убивает под → -шлюз не получает ответ → 502. После рестарта повторный парсинг проходит. - -- **Это НЕ** проблема сети/размера тела (как 64KB на `services`), а **нехватка памяти**. -- Базовое потребление уже ~71% лимита, парсинг большого PDF добивает под. - -## 4. Связь с прежними находками - -| Симптом | Причина | Платформа | -|---|---|---| -| «64KB / HTTP 000, Сеть» (исторически) | ddos-guard + таймауты Ingress + медленная обработка | `pythonk8s.services.ngcloud.ru` | -| HTTP 502 на 19 МБ PDF (сейчас) | OOMKill: лимит 1Gi, синхронный парсинг | `pythonk8s.dev.nubes.ru` (ТЕСТ) | - -## 5. Варианты решения (НЕ ВНЕСЕНЫ, ждут команды) - -1. Поднять память инстанса: `clusterConfiguration.memory` 1024 → 2048 (быстрый обходной путь). -2. Убрать синхронный парсинг из `/upload` — парсинг в фон (thread / отдельный endpoint), - ответ сразу; снижает пик памяти в запросе (правильный фикс). -3. Оба варианта. - -## 6. Статус (следующий шаг) - -Пользователь планирует **редеплой на обычном облачном кластере со стандартными настройками** -(не на своём `iot-naeel`) — проверить, как ведёт себя парсинг 19 МБ при стандартных лимитах. -Результат сравнить с данным диагностикой. diff --git a/History/sonnet-review-comparison-answer-2026-08-27.md b/History/sonnet-review-comparison-answer-2026-08-27.md deleted file mode 100644 index 9ca33ad..0000000 --- a/History/sonnet-review-comparison-answer-2026-08-27.md +++ /dev/null @@ -1,81 +0,0 @@ -# Ответ Соннета: code review pipeline сверки - -Дата получения: 2026-08-27 -Источник: пользовательский attachment `Pasted text #1` -Статус: внешний отчёт, не подтверждённый текущим агентом - -## Область - -Соннет анализировал pipeline собственно сверки: `process.py`, `pipeline_bp.py`, `compare.js`, `app.js`, `llm.py`, `llm_client.py`, `llm_prompt.py`, `spec_events.py`, `spec_current.py`, `supplements.py`, `connection.py`, `metrics.py` и связанные тесты. Upload, ZIP, парсинг, классификация и grouping заявлены как исключённые из scope. - -## Заявленные findings - -### BLOCKER - -- **B-1:** backend выдаёт событие `complete`, frontend обрабатывает только `done`; успешная сверка отображается как ошибка соединения. -- **B-2:** `apply_ops()` якобы не имеет единой транзакции; при сбое возможен частичный commit и недостоверная summary. -- **B-3:** UPDATE поля `name` якобы не пересчитывает `name_hash`, что может привести к дублированию строки при следующем документе. - -### HIGH - -- **H-1:** ветка неизвестного action якобы не увеличивает `seq`. -- **H-2:** summary `apply_ops()` якобы не содержит `unresolved`, поэтому счётчик недоступен UI. -- **H-3:** результат `check_arithmetic()` игнорируется; арифметическая ошибка не попадает в SSE/UI. -- **H-4:** `full_replace` с пустым `ops` якобы сначала очищает current state и затем успешно применяет ноль операций. - -### MEDIUM - -- **M-1:** pipeline безусловно завершает работу событием `complete`, даже после ошибок всех документов. -- **M-2:** отсутствует блокировка двух одновременных сравнений одного `contract_id`. -- **M-3:** отсутствует клиентский timeout SSE. -- **M-4:** `last_event_id` в `spec_current` якобы никогда не заполняется, поэтому cleanup supplement не работает. - -### LOW - -- **L-1:** сортировка по `doc_date` и `created_at` в `process.py` использует поля, которые якобы не возвращаются `supplements.list_by_contract()`. -- **L-2:** пустой `current_spec` не различает штатный первый документ и состояние после ошибки. - -## Ответы Соннета на обязательные вопросы - -1. Допник без базового договора не фильтруется; при пустом current state получает extract prompt и может быть ошибочно обработан как базовый документ. -2. `run_pipeline()` считает сверку успешной при наличии хотя бы одного supplement и безусловно выдаёт финальное событие. -3. Да, финальное событие может прийти после `extract_error`, включая случай, когда ошиблись все документы. -4. `complete` против `done` объявлено реальным frontend/backend багом. -5. Пустой `full_replace` очищает спецификацию; это признано опасным. -6. Частичный `apply_ops()` якобы остаётся в БД, но UI получает `extract_error` и неверную нулевую summary. -7. Единой транзакции current state и event history, по мнению Соннета, нет. -8. Повторный запуск сначала очищает состояние; последовательный запуск может дать чистый результат, конкурентный опасен. -9. Конкурентные pipeline для одного договора создают race condition и риск коллизий `seq`. -10. Пустой корректный ответ и повреждённый/неполный ответ не различаются. -11. `UNRESOLVED` сохраняется в `spec_events`, но не отображается пользователю и не включается в summary. -12. Арифметическая ошибка может остаться в БД, а pipeline всё равно завершиться успешно. -13. Нужны тесты для финального события, rollback, смены name, пустого full_replace, unknown action, unresolved summary, arithmetic warning и concurrency. -14. Главные риски: частичные commits, дублирование после смены name, уничтожение состояния при пустом full_replace, ложная ошибка UI и отсутствие защиты от concurrency. - -## Предложенный Соннетом порядок исправлений - -1. `complete` -> `done`. -2. Пересчёт `name_hash` при изменении `name`/`date_start`. -3. Запрет пустого `full_replace` до очистки state. -4. Единая транзакция для `apply_ops()`. -5. Инкремент `seq` для неизвестного action. -6. Счётчик `unresolved` и `total_unresolved`. -7. SSE warning для arithmetic mismatch. -8. `had_errors` в финальном событии. -9. Заполнение `last_event_id`. - -## Что не является подтверждённым фактом - -Этот файл фиксирует именно ответ Соннета. Ни один finding здесь не следует считать основанием для изменения кода до повторной проверки: - -- по исходникам; -- по фактической схеме БД и реализации connection layer; -- по тестам; -- по реальному frontend/backend event contract; -- по воспроизводимому сценарию. - -Следующий этап: критическая повторная проверка каждого finding и уточнение вопросов Соннету только там, где в его отчёте останется неразрешённое противоречие или отсутствует доказательство. - -## Уточнение пользователя - -Соннет используется только для анализа. Он не должен писать код, патчи или diff, изменять файлы либо выполнять команды: это ограничение введено для контроля стоимости. Реализацию и проверки выполняем отдельно после критической перепроверки findings. diff --git a/History/sonnet-review-comparison-only-2026-08-27.md b/History/sonnet-review-comparison-only-2026-08-27.md deleted file mode 100644 index 720cc00..0000000 --- a/History/sonnet-review-comparison-only-2026-08-27.md +++ /dev/null @@ -1,231 +0,0 @@ -# Промпт для Соннета: code review только собственно сверки - -Нужно провести строгий code review **только той части системы, которая выполняет собственно сверку документов после формирования группы**. - -Не анализируй и не ревьюируй upload, WebDAV/VM upload, ZIP-распаковку, выбор папки, дедупликацию файлов, парсинг DOC/DOCX, garbage-фильтры, классификацию документов, определение типа документа, matching/grouping документов и UI загрузки. Эти части находятся вне области данного ревью. - -## Контекст - -Система получает уже сформированную группу договора и связанных документов. Затем она должна: - -1. определить порядок документов группы; -2. получить текущую спецификацию; -3. передать текущую спецификацию и текст очередного документа в LLM; -4. получить операции `ADD`, `UPDATE`, `DELETE`, `UNRESOLVED` или режим `full_replace`; -5. корректно транслировать ссылки LLM на строки текущей спецификации; -6. применить операции к БД и сохранить историю событий; -7. отдать прогресс и результат через SSE; -8. показать пользователю фактический итог сверки. - -Особенно проверь, что система не выдаёт успешный результат, если часть операций или документов фактически не обработана. - -## Файлы для обязательного изучения - -Изучи только эти файлы и их прямые зависимости, необходимые для понимания сверки: - -1. `contracts-flask/site/services/process.py` - - основной pipeline сверки; - - порядок документов; - - получение текущей спецификации; - - подготовка текста документа; - - вызов LLM; - - трансляция `target_id` в `target_hash`; - - обработка `full_replace`; - - применение операций; - - арифметическая проверка; - - SSE-события логического pipeline. - -2. `contracts-flask/site/routes/pipeline_bp.py` - - endpoint `GET /process-v2`; - - генерация SSE; - - heartbeat; - - закрытие/обрыв соединения; - - преобразование исключений в SSE-события; - - различие HTTP-ошибки до начала стрима и ошибки внутри стрима. - -3. `contracts-flask/site/static/compare.js` - - `startCompareSSE()`; - - обработка `extract_start`, `llm_done`, `applied`, `extract_error`, `apply_error`, `done`, `error`; - - обработка `EventSource.onerror`; - - закрытие EventSource; - - соответствие frontend-событий backend-событиям. - -4. `contracts-flask/site/static/app.js` - - функции запуска сверки группы; - - вызов `/api/apply-groups`; - - получение `contract_id`; - - запуск `/process-v2`; - - обработка success/error/connection error; - - изменение состояния группы после завершения. - -5. `contracts-flask/site/services/llm.py` - - фактический вызов LLM для сверки; - - формат prompt и ответа; - - timeout/retry; - - обработка невалидного ответа; - - возможная потеря или искажение операций. - -6. `contracts-flask/site/llm_prompt.py` - - prompt сверки; - - формат текущей спецификации; - - формат ожидаемых операций; - - ограничения для `ADD`, `UPDATE`, `DELETE`, `UNRESOLVED`, `full_replace`. - -7. `contracts-flask/site/db/spec_events.py` - - `reset()`; - - `clear_current()`; - - `apply_ops()`; - - транзакции и атомарность; - - сохранение истории; - - статусы применённых и неразрешённых операций; - - поведение при частичной ошибке. - -8. `contracts-flask/site/db/spec_current.py` - - получение текущих строк спецификации; - - идентификаторы и `name_hash`; - - получение `elements_json`; - - согласованность данных между текущим состоянием и историей. - -9. `contracts-flask/site/db/supplements.py` - - только функции, используемые `process.py` для получения документов уже сформированной группы; - - порядок и фильтрация документов; - - отсутствие/дублирование документов. - -10. `contracts-flask/site/services/metrics.py` - - `check_arithmetic()`; - - что именно проверяется; - - может ли ошибка арифметики повлиять на результат; - - почему проверка не должна маскировать ошибку применения. - -## Тесты для обязательного изучения - -1. `contracts-flask/tests/test_process_pipeline.py` - - какие сценарии реально покрыты; - - корректность `full_replace`; - - корректность трансляции `target_id`; - - отсутствие тестов на реальные SSE и ошибки LLM. - -2. `contracts-flask/tests/test_grouping.py` - - только часть, необходимая для понимания структуры группы, передаваемой в `apply_groups`. - -3. `contracts-flask/tests/test_metrics.py` - - только тесты арифметической проверки, относящиеся к операциям сверки. - -4. Найди все остальные тесты, которые напрямую вызывают `run_pipeline`, `apply_ops`, `process-v2` или `startCompareSSE`, и включи их в анализ только если они действительно относятся к собственно сверке. - -## Что проверять - -### 1. Корректность алгоритма сверки - -- Не теряется ли первая спецификация или базовое состояние. -- Правильно ли строится `current_spec` перед каждым следующим документом. -- Гарантирован ли правильный порядок обработки документов. -- Не зависит ли порядок от нестабильного `created_at` или формата даты. -- Корректно ли работает переход между несколькими документами группы. -- Не дублируются ли строки при повторном запуске. -- Корректно ли работает `full_replace` при уже существующих строках. -- Может ли `full_replace` удалить корректные данные при ошибочном/неполном ответе LLM. - -### 2. Операции LLM - -- Как `target_id` переводится в фактический идентификатор строки. -- Что происходит при `r0`, `r999`, `rX`, отсутствующем `target_id`, неверном `target_hash`. -- Не может ли LLM обновить не ту строку из-за изменения порядка строк. -- Что происходит с неизвестными действиями. -- Что происходит с отсутствующими полями `name`, `price`, `qty`, `sum`, `date_start`. -- Не приводит ли частично валидная операция к тихой потере данных. -- Сохраняется ли исходный ответ LLM и prompt для аудита. -- Как обрабатываются пустой, обрезанный, markdown-обёрнутый или частично повреждённый JSON-ответ. - -### 3. БД, транзакции и атомарность - -- Атомарно ли применяются операции одного документа. -- Что происходит, если пятая операция из десяти падает. -- Может ли current state измениться, а event history не сохраниться, или наоборот. -- Не приводит ли `reset()` к потере истории при повторном запуске. -- Различаются ли `applied`, `unresolved`, `failed` и действительно ли эти статусы отражают результат. -- Безопасен ли параллельный запуск двух сравнений одного договора. -- Безопасен ли одновременный запуск сравнений разных групп. -- Есть ли race condition между `apply-groups`, `/process-v2` и frontend state. - -### 4. SSE и сетевые ошибки - -- Совпадает ли событие завершения backend (`complete` или `done`) с событием, которое ожидает frontend. -- Может ли frontend навсегда оставить сравнение в состоянии `⏳`. -- Что происходит при disconnect после `applied`, но до события завершения. -- Что происходит при heartbeat без данных. -- Может ли `EventSource.onerror` ошибочно объявить ошибку после нормального закрытия. -- Показывает ли UI частичный результат как полный. -- Есть ли таймаут на клиенте и сервере. -- Возвращается ли пользователю причина ошибки LLM/API, а не только «Ошибка соединения». -- Не теряются ли последние SSE-события из-за proxy buffering. - -### 5. Числовая и предметная корректность - -- Проверяется ли арифметика `price * qty = sum` до применения и после применения. -- Как обрабатываются `None`, строки с запятой, целые/дробные числа, отрицательные значения и большие числа. -- Не изменяет ли арифметическая проверка данные или только логирует ошибку. -- Может ли LLM вернуть арифметически неверную операцию, которая всё равно попадёт в current state. -- Сохраняется ли точность Decimal/float. -- Как обрабатываются даты и отсутствие даты. - -### 6. Повторяемость и идемпотентность - -- Что произойдёт при повторном нажатии «Сравнить эту группу». -- Можно ли безопасно повторить сравнение после сетевого обрыва. -- Будут ли повторно добавлены те же строки. -- Как отделяется новый запуск от предыдущей истории. -- Есть ли идентификатор запуска и защита от повторного применения одного ответа. - -## Обязательные вопросы от ревьюера - -Ответь отдельно на следующие вопросы, даже если для ответа придётся изучить прямую зависимость: - -1. Почему в реальном тесте один допник смог попасть в группу без базового договора, и может ли собственно pipeline сверки безопасно обработать такую неполную группу? -2. При каком именно условии `run_pipeline()` считает сверку успешной? -3. Может ли pipeline отправить `complete`, если один документ дал `extract_error` или часть операций не применилась? -4. Почему backend использует событие `complete`, а frontend-код может ожидать `done`? Это реальный баг или только устаревший комментарий/другая ветка? -5. Если LLM вернул `full_replace` с пустым `ops`, будет ли текущая спецификация очищена? Должно ли так происходить? -6. Если `apply_ops()` применил только часть операций, как это отражается в SSE и UI? -7. Есть ли транзакция, гарантирующая согласованность `spec_current` и `spec_events`? -8. Можно ли повторно запустить `/process-v2` для того же `contract_id` без дублирования или повреждения результата? -9. Что происходит при одновременном сравнении двух групп, относящихся к одному договору? -10. Как система отличает «LLM вернул корректный пустой результат» от «LLM вернул повреждённый/неполный ответ»? -11. Какие операции считаются `UNRESOLVED`, где они сохраняются и видит ли их пользователь? -12. Может ли `check_arithmetic()` обнаружить ошибку, но pipeline всё равно завершиться успешно? -13. Какой минимальный набор интеграционных тестов нужен для доказательства корректности реальной сверки? -14. Какие риски остаются именно в собственно сверке после исключения upload/classify/grouping из области анализа? - -## Формат отчёта - -Пиши отчёт в формате code review. - -Сначала findings, отсортированные по серьёзности: - -- `BLOCKER` — возможна потеря/порча данных или ложный успешный результат сверки; -- `HIGH` — неверное применение операций, нарушение атомарности, повторное применение или потеря результата; -- `MEDIUM` — ошибочное отображение статуса, частичный результат, нестабильность или отсутствие важной защиты; -- `LOW` — локальная проблема качества, диагностики или сопровождаемости. - -Для каждого finding укажи: - -- severity; -- файл и конкретный символ/участок кода; -- точную последовательность, которая приводит к проблеме; -- воспроизводимый сценарий; -- фактический ущерб; -- минимальное исправление; -- обязательный тест. - -Не предлагай изменения вне области собственно сверки. Не исправляй код самостоятельно. Не делай общий обзор всего проекта. Если findings нет, напиши это явно и перечисли оставшиеся пробелы тестирования. - -## Жёсткое ограничение по стоимости - -Не пиши код, патчи, diff и готовые реализации. Не изменяй файлы и не выполняй команды. Твоя задача — только code review: факты, findings, доказательства, вопросы и минимальные рекомендации словами. Любые предлагаемые исправления описывай концептуально, без реализации. - -В конце добавь: - -1. ответы на 14 обязательных вопросов; -2. таблицу покрытия тестами; -3. минимальный план исправлений, если они нужны; -4. список файлов, которые действительно были изучены. diff --git a/History/sonnet-review-comparison-verification-2026-08-27.md b/History/sonnet-review-comparison-verification-2026-08-27.md deleted file mode 100644 index c174cf6..0000000 --- a/History/sonnet-review-comparison-verification-2026-08-27.md +++ /dev/null @@ -1,117 +0,0 @@ -# Критическая перепроверка ответа Соннета: pipeline сверки - -Дата: 2026-08-27 -Основание: ответ Соннета из `History/sonnet-review-comparison-answer-2026-08-27.md` - -## Подтверждено по исходникам - -### B-1: `complete` против `done` - -Подтверждено. - -- `site/services/process.py` завершает `run_pipeline()` событием `{"type": "complete"}`. -- `site/static/compare.js` завершает успешный SSE только в ветке `d.type === 'done'`. -- При нормальном закрытии генератора после неизвестного события клиент получает `EventSource.onerror`, поэтому успешная сверка может отображаться как ошибка соединения. - -Это не предположение Соннета, а прямое несоответствие backend/frontend event contract. - -### B-2: отдельные commits в `apply_ops()` - -Основная часть finding подтверждена. - -- `site/db/spec_events.py::apply_ops()` вызывает `execute()` для каждого события и изменения current state. -- `site/db/connection.py::execute()` делает `conn.commit()` после каждого SQL-вызова. -- В `apply_ops()` нет единой транзакции и rollback. -- Поэтому исключение после уже выполненных операций оставляет предыдущие commits в БД. -- `process.py` после исключения формирует нулевую summary и не показывает уже применённые операции как applied. - -Требует отдельной проверки формулировка о том, что history и current state обязательно расходятся при каждом сценарии: это зависит от того, на каком именно SQL-вызове возникает исключение. Но частичный commit и недостоверная summary подтверждены. - -### B-3: stale `name_hash` - -Подтверждено. - -`site/db/spec_events.py::_update_spec_current()` обновляет `name`, `price`, `qty`, `sum`, `date_start`, но не пересчитывает и не обновляет `name_hash`. При последующем ADD с новым именем `_hash()` создаёт другой ключ, поэтому сценарий с дублированием реален. - -Нужно отдельно проверить бизнес-правило для изменения `date_start`: изменение даты может означать новый период и не во всех случаях должно менять identity строки. Автоматически объединять `name` и `date_start` в одно исправление нельзя без подтверждения модели идентичности. - -### H-1: `seq` для неизвестного action - -Подтверждено. - -В ветке `else` `apply_ops()` вызывает `_log_unresolved(...)`, но не делает `seq += 1`. Следующая операция получает тот же `seq`. - -### H-2: `unresolved` недоступен в summary - -Подтверждено по текущим участкам. - -`apply_ops()` возвращает только `added`, `updated`, `deleted`. При этом frontend использует `d.total_unresolved`, а `run_pipeline()` не формирует это поле в финальном событии. - -Требует проверки полный путь отображения `UNRESOLVED`: факт отсутствия счётчика в summary подтверждён, но следует проверить, нет ли другого endpoint/UI, который показывает события напрямую. - -### H-3: результат `check_arithmetic()` игнорируется - -Подтверждено для `run_pipeline()`. - -`process.py` вызывает `check_arithmetic(ops)` и игнорирует возвращаемое значение. Требуется дополнительно проверить, логирует ли сама функция несоответствия и является ли её контракт предупреждением или валидатором, прежде чем выбирать severity и формат исправления. - -### H-4: пустой `full_replace` - -Подтверждено по порядку операций. - -В `process.py` `clear_current(contract_id)` вызывается после получения `mode` и до `apply_ops()`, без проверки непустого `ops`. Пустой список при `mode == 'full_replace'` очищает current state. - -Нужно уточнить у Соннета, какие именно ответы считать повреждёнными: пустой `ops` может быть штатным ответом для пустой спецификации, хотя для существующего current state это опасный случай. - -### M-1: финальное событие после ошибок - -Подтверждено. - -После цикла `run_pipeline()` безусловно выдаёт финальное событие, даже если каждый supplement завершился `extract_error`. - -## Подтверждено частично или требует дополнительных доказательств - -### M-2: concurrency - -Риск правдоподобен, но формулировку о конкретных коллизиях `seq` нужно доказать тестом. `WAL` сериализует записи, но `get_next_seq()` и последующие операции разделены во времени. Нужен воспроизводимый конкурентный тест с двумя pipeline на одном `contract_id`. - -### M-3: SSE timeout - -Отсутствие клиентского timeout видно, но само по себе не доказывает пользовательский дефект: сервер отправляет heartbeat каждые 15 секунд. Нужно проверить proxy timeout, лимит длительности LLM и поведение при остановленном backend. - -### M-4: `last_event_id` - -Подтверждена причина риска: `spec_current` вставляется без `last_event_id`, а UPDATE также его не заполняет; cleanup в `supplements.delete_by_document()` фильтрует по этому полю. Нужен тест удаления supplement после сверки, чтобы окончательно подтвердить наблюдаемое поведение. - -### L-1: сортировка - -Подтверждено, что `list_by_contract()` не выбирает `doc_date` и `created_at` как поля результата, поэтому ключ сортировки в `process.py` фактически использует значения по умолчанию. При этом SQL уже сортирует по `s.created_at`, поэтому это скорее misleading code, а не доказанная поломка порядка. - -### L-2: пустой current state - -Технически возможно, но вывод о неправильном prompt зависит от семантики группы и контракта `build_prompt()`. Нужна точная проверка prompt и отдельный сценарий сбоя/неполной группы. - -## Дополнительные вопросы Соннету - -1. Для B-2: укажи точный SQL-вызов и сценарий исключения, при котором расходятся `spec_events` и `spec_current`; отдельно различи частичный commit и рассогласование двух таблиц. -2. Для B-3: должна ли смена `name` менять identity строки, или `name_hash` является историческим ключом? Какие правила действуют при одновременной смене `name` и `date_start`? -3. Для H-2: где именно пользователь должен видеть `UNRESOLVED`, если не через summary? Укажи полный frontend/backend путь и проверяемый сценарий. -4. Для H-3: что возвращает `check_arithmetic()` и есть ли у него побочный logging? Приведи фактический пример mismatch и ожидаемый бизнес-статус. -5. Для H-4: почему пустой `full_replace` однозначно считать повреждённым ответом, если документ может содержать пустую спецификацию? Как отличить штатный результат от обрыва/невалидного JSON? -6. Для M-2: предоставь воспроизводимый тест или timeline с двумя потоками, который приводит к одинаковому `seq` или повреждённому current state. -7. Для M-3: какой фактический timeout установлен на nginx/proxy и как он соотносится с heartbeat и максимальным временем обработки группы? -8. Для M-4: есть ли штатный путь удаления supplement после сверки, и должен ли он удалять строки current state, если строка затронута несколькими supplements? -9. Для L-2: приведи точный контракт `extract`/`diff` prompt и доказательство, что пустой current state после ошибки действительно меняет смысл следующего LLM-вызова. -10. Какие findings Соннет считает подтверждёнными тестом, а какие являются только статическим риском? -11. Проверь финальное событие по реальному frontend-коду: нет ли другой ветки, которая обрабатывает `complete` или завершение `EventSource` без `done`? -12. Для каждого BLOCKER укажи минимальный regression test, который сначала падает на текущей версии и проходит после исправления. - -## Предварительный вывод - -Без дополнительных ответов Соннета уже достаточно доказательств для регистрации B-1, B-2, B-3, H-1, H-2, H-3, H-4 и M-1 как реальных проблем текущего кода. M-2, M-3, M-4 и L-2 требуют воспроизводимых тестов или более точной трассировки. L-1 подтверждён как избыточная/вводящая в заблуждение сортировка, но не как текущая поломка порядка документов. - -Изменения production-кода по этим findings не выполнялись. - -## Обязательное ограничение для дальнейшего общения с Соннетом - -Соннет не должен писать код, patch или diff и не должен изменять файлы. Нужно запрашивать только критический анализ, проверяемые доказательства, воспроизводимые сценарии, тестовые идеи в виде описания и дополнительные вопросы. Реализацию выполняем отдельно после собственной проверки findings. diff --git a/architecture-diagram.jpeg b/architecture-diagram.jpeg deleted file mode 100644 index 770b15098ed914618cb7d15db390400d1f0ecedc..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 71395 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 EAG~g${Qv*} diff --git a/architecture-diagram.png b/architecture-diagram.png deleted file mode 100644 index 8bdf0a5daa90a3c36e83f4d73d1287c83c046c2a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 33498 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 kD#)N#h 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 kD#)N#h zr(sbdQmgQ^i8YsIDscuX7*F=BJI^{`j#T}=e}eTub2ai&sl69P{4M*r%)hG^{-65- zgp}n)+Rw*s!4Euxu%naJ5Ajy<8>PI-|MbAllyQEips`t~9-43wJ>vVm;V8gQlCpai z^qF#(QA=x^s8J{R5J$I{KI_nPNBH&SC-M5e97};e=)CV^zlmDn=G%tcn?uWZYVM1FjNBTH zZl1|ru}wUV1O%r5MKNvs0&ReG`KtD6F7t15Zq2b+;O=jr|E~&pJa_EPA`AI{b|s4} z|5Jq6|Id{Q`O6n%^5%XvSKY)ZB5#%4%x;zrzyU(_RhJQssFMQF6lZ2}^pJXI@T2SCo8cx8N z`9D9x}i|-P(0(G+Yb+OA%?PF)Q)w=1*^Asjoxn62BmYg z;K_YD&!whGFjwkxw#fY}Y_C(D-jU5X z#p)B<1%MBq1A%BmYM6j%6>l*6&S;E5y}3e25Q_Z=IT~rFB+tjs5-gdZEiST(a+BM@ z3&@Y$(!cM=%&RcME^J8Q;v-XUgp)W6FQ7K_Yc&<#S zz3gjsSD=?aMs?Nw?)sH5__)jO%2Ct6D{aWDK$Z=QoW0=(AARimLR~KWMDj=-BOz^~ zNw9R%EC&fohCZ9oZT_=MMP{&indJY~-Z=$l_J?`CJGR|H=Z)=-la6h_v2D9!+wR!x z*tTuk={Rq&`A_Z4?$uPy?rbe;Z_mZ=)H!wd#ph8mNX@h;8X2`D8Bt0GE0&P%BLxOh z$TAVK#SBHR)`bx;t-m66UF#CL$n>aTu4W?Ce~VNmU?^jyK76 zrXHUZ?JyrPJN6kU$O^5ZM86++d+v0_iZ+a^vyaa#WJhA41OPdzTe~R^4={d zObTkaos#@UH_6z?C9a0z|G_Q{O)aS~Oho8?ygxz@)LD=x)asQ9FtfAl0Eb^GCd#IL zS5+Gsg~%Omd#Nt&tOqS@FbukV+@NoBbq6VXU`E7EKQF|^YBAB8kSg!plbHB3Pxc*U zqav0T2q&r&B_~<=-WG1-mz9eUTFu^NvuNTAeUD=V?Y_Y+P~jt9u~9a4%N|R@WIK|f zN_8V>FYNyAjMe$^JGQNr>!bz70iE91Frmq#aoS;_e_Nrg1{E3IP>7;^H-ebl82*@9ZcVEw++EDr&7YKwfLHLedkN$EpXATHknn||i6$lr`VeW=2k)=6hCpO{WF9fS5;EE0( zB=}e8(~;Jfxh2`GG1ANBQ`K-8sBN^xO<@BMardrQB&IPkme?BYu1G8F9s1 zdIY=$_R~32|mliEYkvdG>?h55qoB|UVkaCj(1`@Mys=96@ITc z7Zj(M&&bye{RYCkms`U;S-iF5S7Az9Ee{;ZBA)NLJ_lvmOiXvG8PDP%Fg>iX+@5?y0QbVNYN2Zqd&n)MOHXiulvxWQ zfG<&g*md1$S1XfPoxh=-dTg5mQ=s86MfG}_tV~rS`9~jTe+@akpHjs&7F-pUGF_g< zgTwu0{rd$*KaC>myT*lPuPe7<8J1YTqN+n1Y3sU;#N{ z_u}M@;#uIKj>+El;MS3b4-_%p=>a0e2-9ZohhP6lRV+dUZ-nvP4pmUuk#*c#NK%CHF zc{J2f;ub*E%C;-Om-b!+VbcK*n+0UQA(9IM2~8~p3w3mudOmO~ODe12?uQa4r{p=T zh8S3nX8b9>_xS|QRjY{1yO!LETYs`x&}X3admI$cpPVFUo_(9DFnjiUlvEV%<dEW+!bbUS*Q{=A4SNA{UApRYy%?F~Xp%+7=l1xW|4G_Oe_QzaZ1x4fQ*a*7V zh*adaOZ>-tcara63O~=H#x!WHF1!qx?zspHCgbgI;TB)*mH7yH&EF=z8%%A6 z(G$9!ry$6V(6JU<$%Z=);&t%B;HND0>9lwo;7`CnCP89R(U4CBEhESao`KO*vsvD* zWnUAM3hAG4sDtEA4zQ@iE3MMFq?WF&N?|*w(%3T&K$_h$pMfIVibCniK0@E0CS={$ ztuZNh{6K+VRzmBUC}%o)?Ow4Iu8tKQ)f#9kraw1P*^Gz#{kLi zSa=r67a2_Y7#^7;vv(m4ims1!8vI#m#wI9ugUXN$x;mryJ8l*mP&wA_=O= z?EpNL6x|XX1*g`;BkYQdXW6ooUjTAZZeCYxLmdNGvz${q3U8a&pwYFszVI;aR80|Z z%bqLd+9m7JQoZk8V;`zNWO96~QycojK9Q=VH>2FXK=&gVSn*af-wv0->AYG@-?qD> zYbymbVGvHIT53V}3dXLXR(G@KB+l!jwcG0v2%*+Sb*6_13A7P5Lam;M9LqPhG-%-PK0Y`ku!xu4v{MCgks{%0>4Z~Az z@}m8gD?+DcVJPfnX@salr}Y}M^|ax^dJCl64OtOxn4=w3N>vj{Sqb~wcufR;DRT>(m$YXJKkvIEl^d~MS`Ly*OkT5nlXXWn9o zId5`abPrYx5^haX#$dRL_{__%5KOS1hm$7?gvK7up7#U_A&nhb-%|b_aE8^_Xp)t3 z$dOI8QwM0c#49PQynQ;-i(eOi#4*v1HvlV3(?}eNwbhXk37yJy4CJiZueZ8JuYHV! zuhpP|HhC3vIzAXXSojKRmnGY=Fm(YD0I9G(`8o@H+w= zN|f$=S00s(@_2XJ?$|H_D?wg?V2qJ3c=%sZW1k|iLd7S(1NqHt4SWrTnOftT-M8cP zw2MMSzJhO+d+p=(I0u%Q+WnR!`#+eYA*_=8FVytIs!+)6*Y7zUQi5xDTS)sFp@Uqd-QL$)3?cp$2?Lr3aInJ11LBJJWc;%bsHc(0nlY~!3@ zq)xf&Kb(q4CrC46#ee&of-|FUidp|rK$Qp0)vrYi2IUzhJa6Pn zBuAY*nk4Tod=kMaL9Z1M--mLG7-H5w1!x7J)sKG1_!gB&RaRBA={Aeu2U}&1sbaBf zgr<`cgt73;rKW?f^t&kkOnvi8aa>7htzDP7!d#$IuuC;oUUIJ!;bq9hot<$|m3@9w zOhmn8a~n0hrbnbE3M_2Myc^Hp&cbhUxY~IRCKB>?b@>vc9!+id9#6Oc3k9@g}%v?0%y<}-3v1okIgX<-~TjIFtInBRn0SV$NGOO`5$1KOfh>j4iKX;8$KZ~Y@ zUu@<}cxpUg$ny{4w73tGC1|KKFY~p8&oM>dQ$8C=%njk@b}-t?GUJ!l*rkCS{$(W( zaW$uZ#Z4;0&J+xW=+5`bevx2kS4udi9ZD~|0D#(9&T`XRkTJ>v^#H4!mgO>OX9FC` z)9!)sP|tC#>Jn9D-zzM5ZZR^XpY8GYjfh&{ZUO>e83m(3H3(FwpW$9+eV(o>MqF0& zEalQNtm!|Ctlg}k#mnNW3dAtTD+INaA*Mj5-^hKM*oW;A`2cRET5!YN4M@Zu$z(JY zT%Lq*n5qb$``v+X(Wt;&vsSn!nf9aKcsvDqUrJ(W+g)cu4Z>L|p=?9qA^a_ggK)1g zz8P@9gOZTdx!n51ce29tdp%v%)!LJm`ZI`3$f;s_L5bVmj=I@B->}T#2fVk#Yjs5474-n)9p=}QG`Lo zy;Usn4Cu7CNG+?dMmxA#wb@)bu#T(yX?2FRi09f^IQ7)25Ll&M^25k#VIZBoHm*^} zFdXfegrTG9kd51pCwN5k&liO&Uel$XP9+nlfnv`r}S8BG4M`#J&=KdL}&g zG?Tv5gZTQyK}s)Y3onT(UJI9MD%+zRj;nhQ{OBk6x#C-`W^~tE#t)4{{ho@EhN0Fk`$ zG^>wqM{QigRYEQ@sJZZX#%uMDXrFoLS$;aYyl_s+NHGws=*u~0!rYoz_E;KHFsr1* z3}O`BS6jns>p?#l&aroFi%b(3A?vze16g5Al6Py*poW!!A2N>7sx3x&Z`BtLFJg7h zpzXJ8zDR$BpED2O?NLq?U(_8+Z`DCjNL{$`A$lM(OuGQ;;0Cym;LtXlT*r2knas5M z9wfU4BBKdtCBz)T{J-~3)U`=F_&klaw@UlYH-yGSHP2FS50ytkC>|0dxL`fMG=T?- z)m7T3&s>Bu*d>sl?9uQs#zZxgL`PW^Qe-o=|3Hl*a`Rn4sQ0Wry82v7SmG{jA>-BQ zK-|Tym?Cbj2Q`5;qS(y)ydnKDGMy02r}{7qV?Y5dd>&f!Dy(m+u0HDZL@}~XFSqX3 zcd~pAZ>Q^kNnzlWL$d?~E>Yqze}iL!>i(s0>P{AfHoC1s8HLAS=Fhr)T8H8qmG{H< zJiXeqjA9=hNuTW_qhil$pF?s~Re54hW~9=6qikpDuQPl#kzW1vG=~d^I`QVGt5D~z zR8*5A)oLkjZ*pr7p;f18B;4K4My*wVQuAx@pV!<0IxzkbyOOW+q^+($U_nJ0;#?Wuxm*)UL{Mdohh^U~b3$|sNCNoT29UAMBbN}PdB z@miQErX#HmofvgKfg_DO45R~zGjgNw0F}jF;5DCo!D6!f2PZ|bfWhpxUjM%#EVD{w zPpNeVrFDQpw9VDQ!}-`*fp2w#5N0Lc4Ktc^WRoQ?MlhvE1waC7rD>c&g+=u@N?SY# zm|X&_aHtaO1&PAPqa&}A0p^BsPkYWSgl2^KkWv}ZFyM1Ov7dxg1dpFl{#28!Ju}d^ zG(FTFO|BMEf_UZ$yod%{NiF^vObgrl94ivbq>R0hk;=6K?2eRFnTTwc)5zA<&89ow zUgLIXOD4}@?K3WD?3QJiT%n0#wPGQ$o1t~%zq0*hqJ2)%@vg2&9ayt^q&EBxRo(f> zOnISj0hI828fd+&LDZIjlCwAt(K2O1E0`RxU!C-qRC3EoQ=^5BIvo{qk#d6px4`i6 zD!^&SIQ>4YXm-O}C)!|K3JP@#70?FrDQt8wuu~Wubswf*9D^Yu?QKCiG^cN=r~#)W zw#(R{>AqSwh}(wzx}}s34HqT$Xx6rT+Nh7){NtD!`Gg=4)pVkm-Ls$xk%6{c}JRLE6F`tw_Xp zhi>#QuHlux(Q14o-@4C^doWUsr%%Gy@=C3ouJI$-4sYF;l(M2DSEwtoOwijZvZ?oN z`TdPv%4d?lr@n2rks|3ARXPhU!Rtk5KQ5V3d=|yb0GRF=7SWXKm_24r7yh8mwqXhY z@)6|@Vb1jgbp%fd%`T+9xIjwCE9t@@syg?j078iI+p?=FxwBGTRw zFkYJC`;L|&YmU(5OC4bydRGvDyC`K&XI+yA?=SUJnpK^5Lkr>H`piS=_84pKu+n!^Gy=)T=dq@*WPgavbK>uY zoH%wCzq`*;BtfS9{jnw@uG@2K@=q7eV{2db5wl&+=uouaevpMf;za5Eh+#`Manxrz zY18ylV*0{Xc}W(rA|Z><+&7HbntWc^&qQ1(VbMWj(KJpeXLvkpINb`4aC{!0n{M|u z#g;^Jp>v=(rG5Xp9uF#itQjUKnU{CqmvsdY8=5xzubyo!9u-F*aK+ss2l~ z5#Nb_AD<|R+Wa{`ZTC)LxR~^_yG71$KMUg02EhtyDe-iN;DPIgj=Nya< zw|=(=TfIkSa5TSkbmAvjMY)-^fTQ=tm&f~X63>apkhq%&cp0~c?TjZ?w1W=N{KGmy zech%QK<8V}lDF6QWXKyj-`dMJf2^Wem4=$bWd$e|4!8PXeLOtLyhb7!EKQ=iCUe0gzXi~z?MC-m-bhDlscJRZ=%ZItK$0HMg2HGz%LNI%Ta2s9rhx<}CU5mJSL#|_Z)`b53{SHx?o zZrJco(0?Gt@bBZu4e?J&-d{Sw172qBZN09EjG~7;=h)dpR42_$U+;CVHs9u>A12X@ zqZe~NE=hbnE(_7cET%G5lx$@#BC}ez0~{@Nyo|-XSL%6-ThCzAL;-C{xTY%YFF;?1 zMipCG00TZ(EU8Mu7?P}l>G>sR?tS|mIYWjp17LrOErE}@#m9BGLH$kScDOOCS`5ZK z^HI&2;PK5qX2nrd`f=pz`)UyP`cYc_?sn7&I-p_DyqWNQ>m7nsR1CkDO9{^ero&42ZXT=@0 ze1f#{<;^!mxmorC#vJm^*86-nGcj6sn~z@agA4;N2K5=&1-LXyD*3vl9!r6eRwc;r zWOE^TCAuFEt6lg!_|)9&%A(2}ZTi9bCmx-ZogcOzD{D(r`3ns*7I|mNtr2ux9SHBp0_D*Vcq z)$bd5ROq<2mEm#u{d?HJH{N8)l0sBG_B2sWR#f=bN6{@!7ZbUJ9_`S=;%wRr?-1ZvqgXyb!^vV3oVJl-#|-~AG=9<5|;3Cw+Pvw zfHANY?uNLuE+$%fx`I57-b7_&To9PJ?KjrRgcp#eHFx~GG5%8s#MQrUC1DO0A!6tn zAo8soMyC6o@2_uvT$CfRb&4z8R%EwZTrnv`AznJ;sD10qb(-rYbLQMj&VGU-aLYT6-mcP~H`D0&tw%KFs~ou( zi1;5C?G^C{PDwU%9^wRS$f{GFdmKT!SCvZaNfZ81K#^W)krtM{A)SdlrI!Th8HpqyN$P~7+|9b4|Vo$LK{6spY0x2`Lm ziq5;;+-!A!s@xLHtJ-?;RMdoIx%7%yhecOLSOvlaja|W0cf-M8R6`7Uad&lioiHH+ z-ZzRZxKolNP93Kkk*l_SZ@Lc`xuua#_0V9$A{}l{w5#(b4EQSg8H7PLGN86boIQ6$ zHNNDz@6TU*6@$1V;iDA3w{DqCQJJR~g~sTo;b7PndM$c?V@(skp< zAKNowlRH7fAo>jC%1dqJjg7e}37!?%afzMun)FvkT8ZA#e!kgaa;gQ)szjODiK$s- z4nlk?zztI(4-uR9rzS(<^2?BBB{RUO(AoTIJjvJ6J1<^?Cp8)vh*wO<(lvqmZk z$nt!MQ9=sLvdNjZjTDO?I2#G}=WfG?rODAHdQ}FFV@_I=S>1=v&#*E7Nbb;Wvf2-y|QE5^Q)qqFHoLM%Uz zG*iU?E_F9RTga1m9@Rsf3N)61xuk_8B1UZz+ztvNK?j&qShyeIusnsmdqMp~7IS_i z?dMmx@c{rUsT(zvgKN`q$-M(f7EY%8Qz*Zv6mxKYvsoxB6Q!H>xs>-RTy+h;;P=|s zs(o!)8u__=53fuc86qf1b?vosOE;#R=zme2Cw4$1TyF={O9uC}@SYA7zpI_c0R5;t zg@d&Dmgkv_mM`m-%R%iTo)%V?QO}^gyI2S19%L$TWts|n1pg) z7?l}Fp1hT8NNMN%T~hJFxWbFeI$SX)6IZ6(XL_`XeP({&fGymGHH%;(5>CUgyK^?b z6*z4SD;EzKlkG|Go0R|ysd>JTm@qagz)9d>HRmggMizLVVGxG=K$@Y>7vh#kfDOBI zWE;g@aGPZw*NF&jPJ`GArJ1H>cJk)%7(tBS!&xUUiEWb`=$5OP6(5&o=B@a4Essvq zzsK9l$!Hjc@`OR5qAC+MVou7F>o6<^j>zd2^sPj9TVt_T{tx4r;Z!=_3F+zW zbmt;DpviuTt)q&&$^e$Bbl{Jh%~&A3YM>;VMU+G4#CLw=r3Ka)YucV+}MmVO0CS_%;%=;HlP>+ zj>*RkHXHtl=TW9f(f$3wN*}K}a9_^32{hJizj$Ac0Dg!6Hn?VGf)YDJ(W6KudnmR;b)9}uCoX1O&vpiho zRK!^kA8+epDTl0r8mOv~+h^|fMpoXQ^5mEDqdhYMd-vh&tBHDHCF)=hAI{aqsqaJJ z+(ia8bvt)ZIjmYd8RFWATIq&Fng#L70(bVX*IDh`lnVCZx0D=5yt|vzjBRsgJ2&3n zZA^SHgP+kltoFnobT??hy@tPgq}__YIdD%?dW7Y7?+Fo&2=n`;JdrEa#~#1t2C}&| z+4O@n6#tYERzkqy(hBS`4dkhoFsJjzC-t7phg0FQbaA9)%5q3OkioQ<$WX!s-NM+* z+=2O{v&F-*DCm%H3gL-%S={)n3RQ;9n3VL>nPuQp2VB_Zdb&lnnmkrMmMzsyYGi`J|GV zJLho-#DG2mI4@LFw!r8l7I5}l)RGM9VD~A;(YBsFm6&xgl9-SVjEM#b&Z{d(%S4ez z1{PVMh>!zh6z14qJmLa}{}`gG)Cz|<$SkmBu_Ra;rw^A#Q|-j^LWJpuWD%DjMG0sl zhs{Ja4>XKY%BW+pQ5ix7!kdZDiL4U(vFnU8&d2r|CJI3)3eP3V_eLt=!S%6`w~io& zHm75Se6d!)Lq3pLW>Clt!firJ$D&hOk?5Nxey#k85FIRp7ucTuEDi7C;f2 z+jmo*4py8$2->p+|3ph(u4_8Iyz3?s)3*wuX56ZgBt|Q6p zn^2n5vPFTVOGpJHxGXeeWbV_|UwGIJ(g<(ucze^!zwTnap`leY95tivp+D_}a%P@m zMsEjf3W%I<^!QjdunHN+gysu^ax+)OFjWEOJJ`I+JC_RQ>+re=cf5a4i`BiQ`3x23 z)}=nZYtGwo190l)P7xzL5Z1rx4gX!Q=zmwA>3?>=WN$tXI_2&$Pw6)QOf*vx7`6Pz z6hnB;E~(cFiYE|Byf&%1_k?;ZJ&?BlqFv!0u^<~eBRFy{=K0N%B%sJVxiN1h&FGAM zg{=R1v7k9hP~OGVD!HGiO(xidCi%8u@STBGP#p zy{W&>ZfH>-@XZZq{^aWxh76=k(OpgWQ z-TI0X*kgF%jqCW7l~K_YCLI1bM?(*^l1G!fF{2*`2)iY!XHxqbWg%7eV2>yYLe*Em z6OU_#VhC!hmj1I}^J&R+>&{u{- z7JTb_hXG!(Sn8kc58H;q>3{fZ$`18DQoXQECK?WD%$O;CR2JG&eH~{cTv??n)#R#+&%?6 z@HMB98^ScXzz-_N3ivZf{fqH7=vJj0yDRWNBI|`;_@*yb@+>RS7*;@ctu=A`2c)pZ zZnn@=)<@P7IymFYeG>;52Gz&^aZy{o;V?3_?DYN|7>wvWDbx&@Ijf-a&GRuMk z#j*&#-g}5}9~!{a!OxW ztKDgxEXADekJ;*AsnP1)1gyC?oESP)`0i;?CxS)md_mIB0t^MH%NqCM-MlR$d;fq+ zWLU#!-^}~qVl`H{BF+i^7^;V3SQwls9EBsTbSz!{uGhWDCTN=!br2JZ8IPTVvUt<=qtOlqsZJt_>uLeIVT&0nd`W@ zi76b1VadNQT(I0o2?^TelV^ABrYcCbzM=Qx)tRAPy~(=Zv;Jp2LZDC) zj#;XP>{59X?t4n;ADIx_4Pc%OoGrcK{+ ze!(2A%*Xfv`Pn^n{$J5O#mL}aYVdr#y81x&kCfzlIABQW*uz(Z_)LoS-xPXYk_?yZ z7tb8OuDugc`5T;JPX;sNjgv_Se8A`z4OD8;q>fLW?m_K6KV;RUj?f06M?4Sp=xf$~ zJqlK*eh&~y)`l`P4;kac?=)sSd>_)7t&_TSfE$3jyx}RCg`{qb=Ch0-Rpedm;&#`e3 zz&-v%7GX7^3hYIcr(~mLF|iAxgdKG%qgrWLUYaB54*ttROJG7%Y5>OnR61}lOm^WNmx&|0_6X5h+-cmVl z_FM%+QgDIz@hGkI83IBnx6noSuEy8s`Zd5pUzQA3i+|$XclE%@XF6P-!&=nh5f~-f zv4<4WUiiRt2DsYT;ID)U>256{p+|CPy_+e0a*4t6SWUEWVpN04-tVr?^vrcVc2(oV=HzMo=P7aZXm!*P8BkI9cVdlVEVPGc2V(njR z8xsQBM=&(m(B75VT?6FEf%tJoeeyuf@9Fvj=&HSe;M)OJsvRBaxV1>WSMOB=K8`6< zE8S2zh)i1X0jL&THH%(J<-!||2#QoJfP}12B&pl0kz!QIIjM@} zHr9iKOtBq9uFSYl?%(D(`BFz)R-qY7HePc;~8PqUP2$?gG{$A zgNpdhn(*sZWt8F+(`Xr&*xSmBrMBY_iNL~Nhfn}2UC0zEZQA9ynh>dh7?DaF74AK< zYDn6Fv6G?&4I7$7k}+^JOQvrE<%I`EBZvaR#6b}qw$V+6LJLf+ zSGi36WglfhSQ#T5h*mak73`1C<_e!D+K1ll&rBfOwXR)NEXz{RGZCsRG#^72B%%P2 zw+ST^p`=T95j|KJ{~OohNKz1^BXx2=UZ|QXl*Pgbh)Fgp3`Hx!RiU3CK=LC&J<3%C z!OeP6B)N+#&n%2P)JdxLwiE@F9}};nDFW-vBu4I--n>(`YVO#W2mK_eITzb_DEE6G zb!8-lM^>Az>xA0Dap!)?UE7`|`8LNz+Ox_8EJ+t&B>KG0y(fe;t$z^Ut2zhzu`7LS z9W9m>K4?f?`SNt^@ppm}u>2!A<|cDy?rPDXeWi3)>M96{cNjnJ;_;p=$%w6D4bg2F z`)+k6gk63?;PsIr$BVSsFNToR&LaJWaU5!f{hEOzMSA|hoR78c{`LhF^nnIzvudB- zL)aM6L#D!ETG+HgGUMYeXLxL>%XyXbHg)Au*Trp~$&4XX4=@j%O&uIV2RhT8$;~pN zX7P=q7Xd5D5LnWFf1=rh81u1rc`aH7_VP@jn4C&*;5GiFI>SEqg={bk=-B_tBf zdCOE~Wcz8&2xJw<4Lf!*`Raex;~1k>1)1p*#ZU&8qh!dBlBoIzmkX&rYL1YyCedIT zsN(}M@B&gLj!7fV3M%}2W!Mn%#5f>+{uV643Jbd=DT6zT{eG|jr=$lNY8aaE$%wn* zlWd(3#;rvtQVpiTtU4>`+Cu8MC-YRX7F<3*4Jx8hS??qF$fQy62u#)1mJUNvQJVLO zS9Y&gUHVO>Ir*|$$rmMzStwVlELGb}8n0|IIUfvqj|$|=eoMGvU-~#HOrcY(l7q3& zQ^Gs$)6Z9pFv;Bb8xh`xNxA@6Q363C0G`Ekp3^YtR6w8JSt9*PTkSL{RXJ4_O+zED z=Uv>oNG_{-kq?l~foG)HH7)!GSxQO4e12>Sj9>;6z`T0m3=|bUYZ?vHQ*MikZ2h~f zkMd{yxl}G2tQHZFUd`4!7@C$xB{fD)t)f7D59APx7!VL)GlHP(;H&$#rPrprew40V zhMa3osQhzKZX|DgUyFFJ1j;owao7-*Q-}mN1eJ$`H__6B6(>&#AkvXY08zL!G|suS ze)k!VqixjfwoqT6mQ_;A-2LT>dIjqK5R;TSS!$ZdaG&acgeFUz)@Z*dyadA%3QtaU%Z_)`4%QFWlk|Hdg0oYr#nHofI@ zJ!j-bDM{q`eh>ag`+lR`%LQODkq$GWJrHGYFIOd?82h!fD@oaPJB%jXjvnH=w(ZrE z;Z$_2D3=f+{-qtdST&MHKaNlUlX@1L)@;lHm8>RIv8seTq%0aQZwaElxP?lmd@eQ* zXWYN3TDmzoc_T*KPc?W@hV1rD!!AeFpRpERA+ly4;I`kX`$u}2lR{sGXgtA6a4o5K zWR;&ah+L#K08JHRa-1}i5R)OO=cGs)qxyia(MV2r-G+s;e~%Q6xG;~>8y_Tp4&C)O z({VK6y*=Go-SKx8_o}=ERK5AsCS=O%X)tvsr<%&ow=zWSY%f+~u3+Ju71-F;Y%fir z&#{-EM0yd4e+82&_DC^dv-GA2ti z$`N$fpg_b>MYHrWWd(=+@Do-%MfF(#TP{k4R!{r}tv*c@l`w;vIYWrrqaCf}I@;BX ziaZBCiyXcjxxf}GDqF4#eIlW%I>E&-H5DO$q{eu&^Q<^7Xu4z4#@zM3B^$l7Q0 zR~P4zLk2hIb49W7@`-QiydNg6)Kd7IIApuugVv$q4ixui4iWSAXn2aUVc`A~64 КБ) — -используется «ВМ-буфер»: браузер кладёт файл на ВМ (WebDAV), а бэк сам тянет его -исходящим запросом (egress не ограничен). - -## Что прочитать — ТОЛЬКО эти файлы (корень проекта: contracts-flask/) - -### Ядро бэкенда (обязательно, читать все) -- `site/app.py` — точка входа, фабрика приложения, sys.path -- `site/config.py` — конфигурация (версия, LLM_URL/KEY/MODEL, CONVERT_SERVICE_URL, VM_UPLOAD_*) -- `site/llm_prompt.py` — формирование промптов LLM (извлечение/сравнение/классификация) -- `site/routes/__init__.py` — регистрация всех blueprint'ов -- `site/routes/upload_bp.py` — загрузка файлов (multipart + sink закачки через ВМ) -- `site/routes/pipeline_bp.py` — классификация + SSE-сравнение (/process-v2, /api/classify-batch) -- `site/routes/api_bp.py` — API (groups, documents, supplements, sync, cleanup, spec-current, chat) -- `site/routes/pages_bp.py` — HTML-страницы + раздача ES-модулей upload/ -- `site/routes/prompts_bp.py` — CRUD и версионирование промптов -- `site/routes/health_bp.py` — healthcheck -- `site/services/parse.py` — парсинг PDF (pdfplumber), DOCX (python-docx), TXT -- `site/services/classify.py` — LLM-классификация документов (garbage-фильтры, выжимка, ThreadPool) -- `site/services/grouping.py` — нормализация номеров и группировка по договорам -- `site/services/process.py` — SSE-пайплайн сравнения (run_pipeline) -- `site/services/llm.py` — вызов LLM для сравнения (call_llm) -- `site/services/llm_client.py` — LLM-клиент (HttpxLLMClient, FakeLLMClient для тестов) -- `site/services/metrics.py` — проверка арифметики (sum == price*qty), метрики качества -- `site/db/connection.py` — SQLite: схема всех таблиц, WAL, thread-local соединения -- `site/db/documents.py` — CRUD документов (статусы, классификация, elements_json) -- `site/db/contracts.py` — договоры -- `site/db/supplements.py` — допники (связь contract↔document) -- `site/db/spec_events.py` — event sourcing: apply_ops (ADD/UPDATE/DELETE), reset -- `site/db/spec_current.py` — текущее состояние спецификации договора -- `site/db/prompts.py` — версии промптов по ролям - -### Фронтенд (для понимания потока на стороне браузера) -- `site/static/state.js` — центральное состояние приложения -- `site/static/app.js` — инициализация, обработчики загрузки/классификации/сравнения -- `site/static/files.js` — выбор файлов/папок/архивов, загрузка через ВМ-буфер -- `site/static/groups.js` — классификация и группы на фронте -- `site/static/compare.js` — сравнение (SSE) и рендер результатов -- `site/templates/index.html` — разметка UI - -### Переиспользуемый модуль загрузки через ВМ (модуль upload/) -- `upload/README.md` — описание модуля и паттерна «ВМ-буфер + pull» -- `upload/backend/upload_refs/blueprint.py` — blueprint POST /api/upload_refs с параметром sink -- `upload/backend/upload_refs/safe_name.py`, `pull_file.py`, `config.py` — транспортные примитивы -- `upload/backend/session/__init__.py` — in-memory сессия (используется drhider; сверка его не использует) - -### Инфраструктура -- `Dockerfile` — сборка образа -- `requirements.txt` — зависимости - -## Что НЕ читать и не трогать - -- `History/` — вся история сессий, ревью, старые планы (НЕ нужно) -- `contractor-legacy/` — замороженный Lucee-прод (не актуален) -- `contracts-vm/`, `convert-service/`, `loadtest/`, `testgen/`, `sim/`, `dogovora/`, `hz/`, `FILES/`, `DOC/` — смежные/вспомогательные, НЕ ядро сервиса -- `deploy/` — устаревший VM-слой (convert_server.py и т.п.), не входит в текущий managed-сервис -- `site/services/drhider.py` — совместимость с DrHider (побочное, не ядро сверки) -- `site/templates/drhider.html`, `architect.html`, `pipeline.html`, `ci-cd.html` — не ядро сверки - -## Что должно быть в ответе (структура) - -1. **Описание сервиса** — что делает, кому, какие входы/выходы (2-4 абзаца, по-простому). -2. **Общая архитектура** — слои (фронт / бэк / БД / LLM / ВМ-буфер), как они связаны. - Лучше — схема потока: загрузка → парсинг → классификация → группировка → сравнение → чат. -3. **Структура проекта** — дерево `site/` (+ `upload/`) с назначением каждого файла. -4. **Как работает каждый этап** (по пунктам, «что делает / как / где в коде»): - - загрузка файлов (включая папки/архивы, ВМ-буфер, sink); - - парсинг PDF/DOCX/TXT → elements (таблицы + параграфы); - - классификация (garbage-фильтры, выжимка, LLM, параллельность); - - группировка (нормализация номеров, виртуальные группы, __unresolved__); - - сравнение (SSE-пайплайн, ops ADD/UPDATE/DELETE, event sourcing, spec_current); - - чат по итоговой спецификации. -5. **Технологии и методы** — коротко: SQLite (WAL, thread-local), SSE, ThreadPool, LLM-клиент, - промпты (из БД + fallback), event sourcing, метрики качества, ВМ-буфер загрузки. -6. **Модель данных** — таблицы БД и их назначение (documents, contracts, supplements, - spec_events, spec_current, prompts). - -## Требования к стилю - -- Чётко, структурно, без воды и лирики. -- Без излишней заумности — понятно не только девопсу, но и обычному разработчику/аналитику. -- Каждый этап: «что делает → как → каким методом → где в коде (файл/функция)». -- Ничего не менять в коде. Только текстовый документ. diff --git a/docs/prompt-sonnet-description-v2.md b/docs/prompt-sonnet-description-v2.md deleted file mode 100644 index 7dc2723..0000000 --- a/docs/prompt-sonnet-description-v2.md +++ /dev/null @@ -1,101 +0,0 @@ -# Задание для Sonnet (v2): описание архитектуры сервиса «Сверка договоров» - -## Роль и задача - -Ты — технический аналитик. Задача — **только прочитать указанные файлы** и написать -подробное, структурированное описание сервиса «Сверка договоров»: что это, как устроено, -как работает каждый этап, какими методами/технологиями. **Ничего не менять в коде.** -Результат — один текстовый документ (markdown). - -## Контекст (что уже было) - -Ранее ты выдал **код-ревью** с 10 находками (вместо описания). Все 10 уже **исправлены** -(v2.0.15): критичный баг `target_id` vs `target_hash` (частичная сверка), Dockerfile, -prompts CRUD, LLM-конфиг, XSS в `compare.js`, мелочи. Учти это — читай код **после** правок, -описывай **текущее** состояние. - -## Сервис в двух словах - -«Сверка договоров» — веб-приложение на **Flask + vanilla JS** (без фреймворков на фронте), -на managed-кластере Nubes. Пользователь загружает договоры/допсоглашения (docx/pdf/zip), -сервис: разбирает их в спецификации → классифицирует документы через LLM → группирует -по договорам → по цепочке допников сравнивает изменения (ADD/UPDATE/DELETE) через LLM → -показывает историю + чат по итоговой спецификации. Данные конфиденциальны, LLM — только -своя (api.aillm.ru, gpt-oss-120b). Большие файлы грузятся через «ВМ-буфер» (браузер PUT на -WebDAV → бэк тянет egress GET), т.к. шлюз кластера рвёт тела >~64 КБ. - -## Что прочитать — ТОЛЬКО эти файлы (корень: contracts-flask/) - -### Ядро бэкенда -- `site/app.py` — точка входа, фабрика приложения, sys.path -- `site/config.py` — конфигурация (версия, LLM, конвертер .doc, VM_UPLOAD_*) -- `site/llm_prompt.py` — формирование промптов LLM (extract/diff/classify + fallback) -- `site/routes/__init__.py` — регистрация blueprint'ов -- `site/routes/upload_bp.py` — загрузка (multipart + sink закачки через ВМ) -- `site/routes/pipeline_bp.py` — классификация + SSE-сравнение (/process-v2, /api/classify-batch) -- `site/routes/api_bp.py` — API (groups, documents, supplements, sync, cleanup, spec-current, chat) -- `site/routes/pages_bp.py` — HTML-страницы + раздача ES-модулей upload/ -- `site/routes/prompts_bp.py` — CRUD и версионирование промптов -- `site/routes/health_bp.py` — healthcheck -- `site/services/parse.py` — парсинг PDF (pdfplumber), DOCX (python-docx), TXT -- `site/services/classify.py` — LLM-классификация (garbage-фильтры, выжимка, ThreadPool) -- `site/services/grouping.py` — нормализация номеров и группировка по договорам -- `site/services/process.py` — SSE-пайплайн сравнения (run_pipeline) + трансляция target_id→hash -- `site/services/llm.py` — вызов LLM для сравнения (call_llm) -- `site/services/llm_client.py` — LLM-клиент (HttpxLLMClient, FakeLLMClient) -- `site/services/metrics.py` — проверка арифметики (sum == price*qty), метрики качества -- `site/db/connection.py` — SQLite: схема, WAL, thread-local -- `site/db/documents.py` — CRUD документов -- `site/db/contracts.py` — договоры -- `site/db/supplements.py` — допники (contract↔document) -- `site/db/spec_events.py` — event sourcing: apply_ops (ADD/UPDATE/DELETE), reset -- `site/db/spec_current.py` — текущее состояние спецификации -- `site/db/prompts.py` — версии промптов - -### Фронтенд -- `site/static/state.js` — центральное состояние -- `site/static/app.js` — инициализация, обработчики -- `site/static/files.js` — выбор файлов/папок/архивов, загрузка через ВМ -- `site/static/groups.js` — классификация и группы на фронте -- `site/static/compare.js` — сравнение (SSE) и рендер -- `site/templates/index.html` — UI - -### Модуль upload/ (переиспользуемый) -- `upload/README.md` — паттерн «ВМ-буфер + pull» -- `upload/backend/upload_refs/blueprint.py` — blueprint POST /api/upload_refs с sink -- `upload/backend/upload_refs/safe_name.py`, `pull_file.py`, `config.py` -- `upload/backend/session/__init__.py` — in-memory сессия (drhider; сверка не использует) - -### Инфраструктура -- `Dockerfile`, `requirements.txt` - -## Что НЕ читать - -`History/`, `contractor-legacy/`, `contracts-vm/`, `convert-service/`, `loadtest/`, -`testgen/`, `sim/`, `dogovora/`, `hz/`, `FILES/`, `DOC/`, `deploy/` (устаревший VM-слой), -`site/services/drhider.py`, `site/templates/drhider.html|architect.html|pipeline.html|ci-cd.html`. - -## Что должно быть в ответе (структура) - -1. **Описание сервиса** — что делает, входы/выходы (2–4 абзаца, по-простому). -2. **Общая архитектура** — слои (фронт/бэк/БД/LLM/ВМ-буфер) + схема потока: - загрузка → парсинг → классификация → группировка → сравнение → чат. -3. **Структура проекта** — дерево `site/` (+ `upload/`) с назначением каждого файла. -4. **Как работает каждый этап** — «что делает → как → каким методом → где в коде (файл/функция)». -5. **Технологии и методы** — SQLite (WAL, thread-local), SSE, ThreadPool, LLM-клиент, - промпты (БД + fallback), event sourcing, метрики качества, ВМ-буфер. -6. **Модель данных** — таблицы и назначение (documents, contracts, supplements, - spec_events, spec_current, prompts). - -## Дополнительно: точечная проверка фикса #1 - -Отдельно, коротко (3–5 строк): в `site/services/process.py` глянь блок трансляции -`target_id ("r1","r2"…) → target_hash` (через `current_spec[_idx]["hash"]`). Подтверди: -корректно ли он маппит `rN` (1-based) на индекс `current_spec`, и что при невалидном id -операция уходит в UNRESOLVED, а не падает. Если видишь ошибку — укажи, но НЕ правь. - -## Требования к стилю - -- Чётко, структурно, без воды и лирики. -- Без излишней заумности — понятно не только девопсу, но и обычному разработчику/аналитику. -- Ничего не менять в коде. Только текстовый документ. diff --git a/docs/service-how-it-works.md b/docs/service-how-it-works.md deleted file mode 100644 index 653521b..0000000 --- a/docs/service-how-it-works.md +++ /dev/null @@ -1,147 +0,0 @@ -# Как работает сервис «Сверка договоров» - -Документ описывает текущую программную реализацию `contracts-flask`: последовательность обработки данных, ответственность модулей и ключевые функции. Основной актуальный код находится в `site/`; каталог `deploy/` является историческим VM-слоем. - -## 1. Общий поток - -```text -Браузер - -> выбор файлов / ZIP - -> загрузка через VM-буфер - -> парсинг - -> классификация документов - -> группировка по договорам - -> применение подтверждённых групп - -> SSE-сравнение документов по порядку - -> event sourcing: spec_events + spec_current - -> таблица изменений и чат по текущей спецификации -``` - -Главная точка сборки Flask-приложения: [`site/app.py`](../site/app.py). Регистрация маршрутов выполняется через [`site/routes/__init__.py`](../site/routes/__init__.py). - -## 2. Загрузка файлов - -Выбор файлов реализован в [`upload/frontend/index.js`](../upload/frontend/index.js) и модулях `table/` и `zip/`; собранный IIFE подключается на странице через `/file-picker/file-picker.iife.js`. Picker строит дерево, раскрывает ZIP, а при выборе папки применяет корневой `obscure.lst`. - -Связка с приложением находится в [`site/static/app.js`](../site/static/app.js) и [`site/static/files.js`](../site/static/files.js): - -- `onPickerFilesChange()` синхронизирует leaf-файлы дерева с `state.files`, вычисляет `zip_source` как путь до последнего ZIP в цепочке и ставит новые файлы в последовательную очередь. -- `uploadPickerEntry()` вызывает `FilePicker.uploadViaVM()` для одного файла: PUT в VM-буфер, затем POST `/api/upload_refs` с `batch_id`, `contract_id` и `zip_source`. -- Результат sink (`doc_id`, `parsed` или duplicate/error) отображается в строке дерева; детали и распарсенный текст доступны из действий строки. -- `finalizeUpload()` ожидает завершения очереди и синхронизации, затем включает классификацию. -- `syncDB()` синхронизирует только текущий `batch_id`; перед удалением документа ждёт активную очередь загрузки. - -Из-за ограничения managed-шлюза большие файлы сначала передаются на ВМ, а затем бэкенд забирает их исходящим запросом. Общий переиспользуемый транспорт находится в [`upload/backend/upload_refs/blueprint.py`](../upload/backend/upload_refs/blueprint.py), включая обработчик `POST /api/upload_refs`; конкретная бизнес-логика передаётся через `sink`. - -Маршруты загрузки основного приложения находятся в [`site/routes/upload_bp.py`](../site/routes/upload_bp.py). Там принимаются ссылки/файлы, вызывается sink сервиса договоров и сохраняются результаты обработки. - -## 3. Парсинг - -Парсер находится в [`site/services/parse.py`](../site/services/parse.py). Он преобразует содержимое документов в структурированный список элементов: - -- `parse()` выбирает обработчик по формату. -- Обработчик DOCX использует `python-docx` и извлекает параграфы и таблицы. -- Обработчик PDF использует `pdfplumber`. -- TXT обрабатывается напрямую. -- Для `.doc` функция `parse_file()` в текущем `site` вызывает `_parse_docx(data)`; отдельный [`convert-service/app.py`](../convert-service/app.py) и исторический [`deploy/convert_doc.py`](../deploy/convert_doc.py) не являются частью этого вызова. - -Результат парсинга сохраняется как `elements_json`. Важный принцип: парсер не решает, какие строки являются услугами, а сохраняет исходную структуру документа для следующих стадий. - -## 4. Классификация документов - -Маршрут запуска классификации: [`site/routes/pipeline_bp.py`](../site/routes/pipeline_bp.py), функция `classify_batch_route()`. - -- Для небольшого батча классификация выполняется синхронно. -- Для большого батча запускается фоновый поток и возвращается `202`. -- `process_v2()` отдаёт поток SSE для этапа сравнения. - -Основная логика находится в [`site/services/classify.py`](../site/services/classify.py): - -- `classify_batch(batch_id, llm_client=None, repo=None)` получает pending-документы, запускает параллельную обработку через `ThreadPoolExecutor` и сохраняет результат. -- `_classify_one()` выполняет полный цикл для одного файла. -- `_is_garbage_by_filename()` отбрасывает очевидный мусор по имени файла. -- `_smart_extract()` формирует компактную выжимку: начало документа и найденные фрагменты с ключевыми маркерами. -- `_is_garbage_by_header()` отбрасывает документы по заголовочным маркерам. -- `_call_llm_classify()` отправляет выжимку в LLM. -- `_safe_json_parse()` извлекает JSON из ответа LLM и исправляет распространённые ошибки форматирования. - -Промпт формируется функцией `build_classify_prompt()` в [`site/llm_prompt.py`](../site/llm_prompt.py). Результат содержит `doc_type`, `own_number`, `parent_number`, `doc_date` и `counterparty`. Сырые вход и ответ LLM также сохраняются для диагностики. - -## 5. Группировка по договорам - -Логика находится в [`site/services/grouping.py`](../site/services/grouping.py): - -- `normalize_number(num)` приводит номер к верхнему регистру и убирает разделители. -- `group_documents(batch_id)` отделяет договоры от допников/спецификаций, сопоставляет их по `parent_number` или `own_number`, создаёт виртуальные группы при отсутствии базового договора и помещает нерешённые документы в `__unresolved__`. -- Внутри групп документы сортируются по `doc_date`. -- `apply_groups(batch_id, groups_data)` сохраняет подтверждённые группы в таблицы договоров и допников. - -Маршруты чтения и применения групп находятся в [`site/routes/api_bp.py`](../site/routes/api_bp.py). Фронтенд отображает результат через [`site/static/groups.js`](../site/static/groups.js). - -Таким образом, LLM извлекает признаки документа, но окончательное сопоставление по номерам выполняется обычным Python-кодом. - -## 6. Последовательное сравнение - -Маршрут сравнения: `process_v2()` в [`site/routes/pipeline_bp.py`](../site/routes/pipeline_bp.py). Он проверяет `contract_id`, запускает `run_pipeline()` и преобразует события в формат SSE. - -Основной pipeline находится в [`site/services/process.py`](../site/services/process.py): - -- `run_pipeline(contract_id, order_ids, build_prompt_fn)` сбрасывает предыдущее состояние, получает документы группы и определяет порядок обработки. -- `_elements_to_text(ej)` преобразует `elements_json` в текстовый контекст для LLM. -- Для каждого документа читается текущая спецификация. -- `call_llm()` из [`site/services/llm.py`](../site/services/llm.py) получает текущие строки и текст документа и возвращает операции. -- `target_id` вида `r1`, `r2` переводится в `target_hash` соответствующей строки текущей спецификации. -- Для режима `full_replace` вызывается `clear_current()`, чтобы новая редакция не дублировала старые строки. -- Затем вызывается `apply_ops()` и отправляются SSE-события `extract_start`, `llm_done`, `applied`, `extract_error` и финальное `complete`. -- `check_arithmetic()` из [`site/services/metrics.py`](../site/services/metrics.py) проверяет согласованность `sum`, `price` и `qty`. - -Промпты извлечения и сравнения формируются через `build_prompt()` в [`site/llm_prompt.py`](../site/llm_prompt.py); версии промптов хранятся и редактируются маршрутами из [`site/routes/prompts_bp.py`](../site/routes/prompts_bp.py). - -## 7. Event sourcing и текущее состояние - -Механизм хранения находится в [`site/db/spec_events.py`](../site/db/spec_events.py): - -- `reset(contract_id)` очищает события и текущее состояние перед новым полным прогоном. -- `clear_current(contract_id)` очищает только текущую спецификацию для `full_replace`. -- `apply_ops(...)` обрабатывает `ADD`, `UPDATE`, `DELETE` и `UNRESOLVED`. -- `_hash(name, date_start)` создаёт стабильный идентификатор строки услуги. -- `_upsert_spec_current(...)` добавляет или обновляет строку текущей спецификации. -- `_update_spec_current(...)` изменяет только поля, указанные в операции. -- `_log_unresolved(...)` сохраняет нерешённую операцию вместо молчаливого пропуска. - -`spec_events` — журнал операций с исходным ответом LLM, версией промпта и ссылкой на документ. `spec_current` — материализованное состояние, используемое для следующего сравнения и отображения. - -CRUD текущей спецификации и исходных данных документа находится в [`site/db/spec_current.py`](../site/db/spec_current.py). Схема и соединения описаны в [`site/db/connection.py`](../site/db/connection.py). - -## 8. SSE и интерфейс - -Клиент сравнения находится в [`site/static/compare.js`](../site/static/compare.js): - -- `startCompareSSE()` открывает `EventSource`, принимает события и управляет таймером. -- `applyCompareEvent()` переводит SSE-события в состояние секций. -- `renderCompareSectionHeader()` формирует заголовок секции документа. -- `renderCompareSectionBody()` отображает итоговую статистику и операции. -- `renderCompareOpsTable()` строит таблицу изменений. - -Оркестрация шагов загрузки, классификации, группировки и запуска сравнения находится в [`site/static/app.js`](../site/static/app.js), в частности в `runClassify()` и обработчиках `loadGroupsAction()`/сравнения. - -## 9. Чат - -Маршрут чата находится в [`site/routes/api_bp.py`](../site/routes/api_bp.py). Он получает вопрос пользователя, загружает текущую спецификацию через функции из [`site/db/spec_current.py`](../site/db/spec_current.py), формирует контекст и отправляет его в LLM. Поэтому чат отвечает по материализованному состоянию договора, а не по случайному отдельному документу. - -## 10. Итоговая ответственность компонентов - -| Слой | Ответственность | -|---|---| -| `site/static/*.js` | выбор файлов, загрузка, отображение прогресса и результатов | -| `site/routes/` | HTTP API, SSE и связывание компонентов | -| `site/services/parse.py` | извлечение структуры документа | -| `site/services/classify.py` | классификация и выделение метаданных через LLM | -| `site/services/grouping.py` | детерминированное сопоставление документов | -| `site/services/process.py` | последовательное сравнение группы | -| `site/services/llm.py` | вызов LLM для анализа | -| `site/db/spec_events.py` | применение операций и аудит изменений | -| `site/db/spec_current.py` | чтение текущей спецификации и элементов документов | -| `site/db/connection.py` | SQLite/WAL и доступ к данным | - -Итог: LLM используется там, где требуется понять содержание документа и смысл изменения услуги. Структура pipeline, нормализация номеров, порядок, проверки и сохранение результата выполняются детерминированным кодом. diff --git a/docs/upload-buffer-incident-2026-10-30.md b/docs/upload-buffer-incident-2026-10-30.md deleted file mode 100644 index 5be69dd..0000000 --- a/docs/upload-buffer-incident-2026-10-30.md +++ /dev/null @@ -1,157 +0,0 @@ -# Инцидент: ошибки загрузки файлов через VM-буфер — 2026-10-30 - -## Краткий вывод - -Контрактор отправлял выбранные файлы на `https://contracts.kube5s.ru/contracts-upload/`. -На момент проверки 2026-10-30 имя `contracts.kube5s.ru` возвращало `NXDOMAIN` у -публичных DNS-резолверов `1.1.1.1` и `8.8.8.8`. Поэтому браузер не мог разрешить -адрес назначения и показывал общую ошибку «Сетевая ошибка при отправке файла в -буфер». Запрос не доходил до Nginx и WebDAV. - -Форматы и содержимое выбранных документов не были причиной этого сбоя. Реальные -файлы из пользовательской папки не отправлялись; проверки upload-инфраструктуры -ограничились DNS, чтением конфигураций и безопасными HTTP `OPTIONS`-запросами. - -## Подтверждённые факты - -### Настройка Контрактора - -- Развёрнутая страница Контрактора показывала версию `2.0.23`. -- Загруженный браузером [`site/static/app.js`](../site/static/app.js) содержит - `VM_UPLOAD_URL = 'https://contracts.kube5s.ru/contracts-upload/'`. -- [`site/config.py`](../site/config.py) задаёт тот же домен для `VM_UPLOAD_URL` - и `VM_UPLOAD_PREFIX`. Backend регистрирует `upload_refs` с этим - `VM_UPLOAD_PREFIX` через [`site/routes/__init__.py`](../site/routes/__init__.py). -- [`deploy/nginx-contracts.conf`](../deploy/nginx-contracts.conf) содержит - WebDAV location `/contracts-upload/` с каталогом - `/var/www/contracts-upload/` и CORS origin - `https://contractor.pythonk8s.dev.nubes.ru`. Этот location находится в - virtual host `contracts.kube5s.ru`. - -### DNS и доступные буферы - -- Запросы к `1.1.1.1` и `8.8.8.8` для `contracts.kube5s.ru A` вернули - `NXDOMAIN`. -- После первоначальной диагностики владелец DNS добавил - `contracts-upload.sinefo.ru A -> 5.172.178.213`. Повторная проверка обоих - публичных резолверов подтвердила эту запись. -- При первой проверке до настройки Nginx новый hostname отдавал чужой TLS- - сертификат. После установки HTTP challenge vhost сертификат Let's Encrypt - выпущен для `contracts-upload.sinefo.ru`; срок действия до 2027-01-28. -- Nginx virtual host для нового hostname теперь установлен на VM213, а - `nginx -t` и reload прошли успешно. -- `upload.sinefo.ru` разрешался в `5.172.178.213`, но - `https://upload.sinefo.ru/contracts-upload/...` отвечал `404`. -- На `upload.sinefo.ru/drhider-upload/` WebDAV-маршрут существует, но - `Access-Control-Allow-Origin` возвращается только для DrHider origin - `https://drhider.pythonk8s.dev.nubes.ru`, не для Контрактора. -- На `obfuscator-upload.sinefo.ru/obfuscator-upload/` маршрут существует, но - `Access-Control-Allow-Origin` возвращается только для Obfuscator origin - `https://obfuscator.pythonk8s.services.ngcloud.ru`, не для Контрактора. -- Запросы `OPTIONS` для origin Контрактора к обоим существующим маршрутам - возвращали `204`, но без `Access-Control-Allow-Origin`. Такой ответ не - разрешает браузерный запрос; кроме того, путь `/contracts-upload/` на этих - хостах не настроен. -- На VM213 (`5.172.178.213`) при проверке существовали каталог - `/var/www/contracts-upload/` и старый Nginx location `/contracts-upload/` - внутри virtual host `contracts.kube5s.ru`. Наличие каталога само по себе не - делает его доступным по другому hostname. - -## Почему прямое исправление URL недостаточно - -Замена только домена в Контракторе не решит проблему: - -1. `/contracts-upload/` на `upload.sinefo.ru` сейчас не настроен и отвечает - `404`. -2. Существующий `/drhider-upload/` предназначен для DrHider и не разрешает - origin Контрактора в CORS. -3. Буфер Obfuscator имеет отдельные hostname и путь, отдельную конфигурацию и - allowlist origin. Он не является готовым Contracts endpoint. -4. URL на фронтенде и доверенный префикс backend должны совпадать. Иначе браузер - может загрузить объект, но `upload_refs` отклонит URL при SSRF-проверке либо - backend будет пытаться забрать объект с другого адреса. -5. Backend `VM_UPLOAD_PREFIX` может быть переопределён переменной окружения и - должен совпадать с URL буфера. URL браузерной загрузки задаётся константой - `VM_UPLOAD_URL` в `site/static/app.js`; переменная окружения с таким именем - сама по себе статический JavaScript не меняет. - -## Отличие от Obfuscator - -Актуальный Obfuscator использует согласованную отдельную конфигурацию: - -- hostname `obfuscator-upload.sinefo.ru`; -- путь `/obfuscator-upload/`; -- отдельный каталог `/var/www/obfuscator-upload/`; -- CORS allowlist для `https://obfuscator.pythonk8s.services.ngcloud.ru`; -- один и тот же URL-префикс на фронтенде и backend. - -Это описано в [`OBFUSCATOR/README.md`](../../../drhider/OBFUSCATOR/README.md) и -настроено в -[`obfuscator-upload.conf`](../../../drhider/OBFUSCATOR/deploy/vm213/nginx/obfuscator-upload.conf). -Контрактор должен получить аналогично согласованный endpoint, а не использовать -Obfuscator/DrHider путь без изменения и проверки его конфигурации. - -## План исправления - -Первоначальный план обновлён после предоставления DNS-записи -`contracts-upload.sinefo.ru`. Код и source Nginx изменены локально; VM213 и -развёрнутое приложение ещё не обновлялись. - -1. **Hostname и DNS — выполнено.** Выбран `contracts-upload.sinefo.ru`; - публичные DNS `1.1.1.1` и `8.8.8.8` возвращают `5.172.178.213`. -2. **Настроить VM213 — выполнено.** Проверен отдельный каталог - `/var/www/contracts-upload/` и права пользователя Nginx. Для первоначального - выпуска сертификата установить - [`deploy/nginx-contracts-upload-acme.conf`](../deploy/nginx-contracts-upload-acme.conf) - как `/etc/nginx/sites-enabled/contracts-upload.conf`, выполнить `nginx -t` - и только при успехе reload. Первая попытка Certbot завершилась временным - CAA DNS timeout; повторная попытка выпустила сертификат. После этого - временный конфиг заменён полным - [`deploy/nginx-contracts-upload.conf`](../deploy/nginx-contracts-upload.conf), - снова выполнить `nginx -t` и reload. Полный vhost задаёт отдельный WebDAV - путь, `PUT`/`GET`/`DELETE`, `OPTIONS`, ограничение тела 50 MiB и CORS allowlist - через `map` для точного origin `https://contractor.pythonk8s.dev.nubes.ru`. - Предел `50m` соответствует backend-лимиту 50 MiB; не копировать 1024 MiB из - Obfuscator, поскольку Contracts не принимает файлы такого размера. Установить - [`deploy/contracts-upload-cleanup.cron`](../deploy/contracts-upload-cleanup.cron) - как `/etc/cron.d/contracts-upload-cleanup`, чтобы удалять оставшиеся объекты - старше 30 минут. Cron установлен в `/etc/cron.d/contracts-upload-cleanup`. - Не менять маршруты DrHider или Obfuscator и не объединять их каталоги с Contracts. -3. **Код приложения — выполнено локально, deploy ожидает выполнения.** - Frontend `VM_UPLOAD_URL` и backend `VM_UPLOAD_PREFIX` default приведены к - `https://contracts-upload.sinefo.ru/contracts-upload/`; обновлены - frontend/backend defaults, пример конфига и default URL нагрузочного теста. - Версия приложения поднята до `2.0.24`. Проверить переменные окружения - развёртывания: `VM_UPLOAD_PREFIX` не должен переопределять новый endpoint - старым значением. -4. **Синхронизировать конфигурацию VM — выполнено.** Источник Nginx вынесен в - отдельные bootstrap- и TLS-конфиги; старый upload location удалён из - [`deploy/nginx-contracts.conf`](../deploy/nginx-contracts.conf). Конфигурация - TLS-vhost и cron установлена на VM213; Nginx config test/reload успешны. - Деплой приложения Контрактора — отдельная операция, ещё не выполнена. -5. **Проверить endpoint до обработки документов — выполнено.** - - DNS разрешается в `5.172.178.213`; TLS сертификат валиден. - - `OPTIONS` с origin Контрактора отвечает `204` и содержит точный - `Access-Control-Allow-Origin`, разрешённые методы и заголовки. С - посторонним origin `Access-Control-Allow-Origin` не отправляется. - - Синтетический объект прошёл `PUT` (`201`) → `GET` (байты совпали) → - `DELETE` (`204`). Объект удалён. -6. **Проверить путь через приложение безопасно.** В тестовой сессии/батче - загрузить синтетический небольшой файл, убедиться в успешном - `POST /api/upload_refs` и очистить созданную тестовую запись/объект. Не - использовать пользовательские документы для проверки, пока синтетический - тест не пройден. -7. **Только после успешной проверки** проверить обычную загрузку и последующие - этапы обработки выбранных документов. - -## Статус - -- Причина исходной сетевой ошибки подтверждена: используемый hostname - `contracts.kube5s.ru` вернул `NXDOMAIN`. -- DNS, TLS, Nginx vhost, CORS и прямой синтетический WebDAV цикл проверены - успешно. -- Причина установлена до этапов HTTP/WebDAV, CORS и обработки документов. -- URL/defaults, версия приложения и source Nginx обновлены локально. -- Конфигурация буфера и cron уже установлены на VM213. -- Изменения приложения ещё не запушены и не redeployed; end-to-end загрузка - через UI Контрактора и обработка документов ещё не проверены. diff --git a/tests/README.md b/tests/README.md deleted file mode 100644 index f002e61..0000000 --- a/tests/README.md +++ /dev/null @@ -1,75 +0,0 @@ -# Тесты contracts-flask - -Набор pytest-тестов под актуальный код сервиса (`site/`). -Покрытие: юнит (чистая логика), интеграционные (SQLite), HTTP-эндпоинты (Flask test client), безопасность. - -## Запуск - -```bash -cd contracts-flask -pytest tests/ -q -``` - -pytest установлен в venv: `/home/naeel/nubes/contracts/.venv/bin/python -m pytest tests/ -q`. - -## Файлы - -| Файл | Что тестирует | -|---|---| -| `conftest.py` | site/ в `sys.path`; фикстура `db` — изолированная БД (временный `DB_PATH` + свежая схема); защита реальной `/tmp/contracts.db` от пересоздания | -| `test_metrics.py` | `services/metrics.py`: `normalize_date`, `check_arithmetic` (sum == price×qty), `_to_decimal`, `ClassifyMetrics` | -| `test_grouping.py` | `services/grouping.py`: `normalize_number`, `group_documents` (группировка contract+supplement, unresolved), `apply_groups` (mock db) | -| `test_llm_client.py` | `services/llm_client.py`: `FakeLLMClient` (точное/частичное совпадение, default, история вызовов), `HttpxLLMClient` | -| `test_llm.py` | `services/llm.py`: `call_llm` — парсинг plain JSON и markdown-обёрток (```json) | -| `test_classify.py` | `services/classify.py`: garbage-фильтры по имени/заголовку, `_safe_json_parse` (7 edge-case: trailing comma, chatter, пусто), `_smart_extract` | -| `test_parse.py` | `services/parse.py`: `parse_file` (text/docx/pdf, ошибки), `_parse_text` | -| `test_connection.py` | `db/connection.py`: `_pg_to_sqlite` (все замены %s/::jsonb/BOOLEAN/ILIKE/TRUE), `_extract_table` | -| `test_spec_events.py` | `db/spec_events.py`: `apply_ops` (ADD/UPDATE/DELETE/UNRESOLVED/unknown), `clear_current`, `reset`, `_hash`, `get_next_seq` | -| `test_spec_current.py` | `db/spec_current.py`: `list_by_contract` (порядок), `get_elements_json` | -| `test_process_pipeline.py` | `services/process.py`: `run_pipeline` с Fake LLM — **full_replace не дублирует строки**, **трансляция target_id→target_hash**, `_elements_to_text` | -| `test_routes.py` | HTTP-эндпоинты (Flask test client): `/health`, `/api/spec-current`, `/api/groups` | - -## Ключевые проверки - -- `test_full_replace_no_duplicates` — два `full_replace` подряд → в `spec_current` 3 строки, а не 6; история `spec_events` (6 событий) сохранена. -- `test_update_applies` — `target_id: "r1"` транслируется в `target_hash` → UPDATE применяется (status `applied`), а не уходит в UNRESOLVED. - -## Изоляция - -- Каждый тест, работающий с БД, получает свою копию SQLite через фикстуру `db` - (monkeypatch `DB_PATH` → `tmp_path`, `init_db()`). -- LLM-вызовы в пайплайне подменяются через `monkeypatch.setattr("services.llm.call_llm", ...)` — сеть не используется. - -## Нагрузочные тесты (`tests/load/`, маркер `load`) - -Долгие стресс-тесты, реально нагружают SQLite/пайплайн. Запуск отдельно: - -```bash -pytest -m load -q # только нагрузочные -pytest -m "not load" -q # быстрые юнит-тесты без нагрузочных -``` - -| Файл | Что нагружает | -|---|---| -| `load/test_load_pipeline.py` | массовый `run_pipeline`: N контрактов × M допников (Fake LLM) — проверка, что `full_replace` не дублирует строки в масштабе | -| `load/test_load_apply_ops.py` | массовые `apply_ops`: N ADD → N UPDATE → N DELETE, целостность `spec_events` | -| `load/test_load_concurrency.py` | конкурентная запись в SQLite (WAL + `busy_timeout`), проверка отсутствия потери данных | -| `load/stress_http.py` | standalone HTTP-стресс: `PUT` на ВМ → `POST /api/upload_refs` (реальный VM-путь) + `/health` | - -Масштаб через переменные окружения (дефолты — большие): - -```bash -LOAD_CONTRACTS=100 LOAD_SUPPS=20 LOAD_SERVICES=10 \ -LOAD_OPS=5000 LOAD_THREADS=4 LOAD_PER=200 \ -pytest -m load -q - -Примечание по конкурентной записи: стабильный уровень `LOAD_THREADS=4` -(= `MAX_WORKERS` приложения). `>= 8` — нестабильно: SQLite с -`busy_timeout=5000` даёт `database is locked` (см. `History/2026-08-26-load-sqlite-locked.md`). -``` - -HTTP-стресс против локального/прод сервиса: - -```bash -python tests/load/stress_http.py --url http://127.0.0.1:5000 --n 1000 --threads 32 --size 100000 -``` diff --git a/tests/conftest.py b/tests/conftest.py deleted file mode 100644 index 9c37d22..0000000 --- a/tests/conftest.py +++ /dev/null @@ -1,42 +0,0 @@ -"""Общие фикстуры для тестов contracts-flask (модули site/). - -Запуск: pytest tests/ -q -""" -import os -import sys - -import pytest - -# site/ в sys.path — импортируем config/db/services/routes напрямую -_SITE = os.path.join(os.path.dirname(__file__), "..", "site") -if _SITE not in sys.path: - sys.path.insert(0, _SITE) - - -@pytest.fixture(autouse=True, scope="session") -def _isolate_global_db(tmp_path_factory): - """Не дать импорту app.py пересоздать реальную /tmp/contracts.db.""" - from db import connection as conn - conn.DB_PATH = str(tmp_path_factory.mktemp("dbs") / "session.db") - - -@pytest.fixture -def db(tmp_path, monkeypatch): - """Изолированная БД: отдельный DB_PATH + свежая схема (init_db).""" - from db import connection as conn - monkeypatch.setattr(conn, "DB_PATH", str(tmp_path / "contracts_test.db")) - conn.init_db() - yield conn - # cleanup: закрыть thread-local соединение - c = getattr(conn._local, "conn", None) - if c is not None: - try: - c.close() - except Exception: - pass - conn._local.conn = None - - -def pytest_configure(config): - config.addinivalue_line("markers", "load: долгие нагрузочные/стресс-тесты") - diff --git a/tests/load/__init__.py b/tests/load/__init__.py deleted file mode 100644 index 61d7cf8..0000000 --- a/tests/load/__init__.py +++ /dev/null @@ -1,10 +0,0 @@ -"""Нагрузочные/стресс-тесты contracts-flask. - -Запуск только нагрузочных: - pytest -m load -q -Запуск быстрых (юнит) без нагрузочных: - pytest -m "not load" -q - -Масштаб настраивается переменными окружения (дефолты — большие): - LOAD_CONTRACTS, LOAD_SUPPS, LOAD_SERVICES, LOAD_OPS, LOAD_THREADS, LOAD_PER -""" diff --git a/tests/load/stress_http.py b/tests/load/stress_http.py deleted file mode 100644 index a5c05dc..0000000 --- a/tests/load/stress_http.py +++ /dev/null @@ -1,97 +0,0 @@ -"""HTTP-стресс против contracts-flask (standalone, не pytest). - -Реальный путь загрузки (VM-буфер): - 1) PUT файла на ВМ WebDAV (мимо шлюза кластера ~64KB); - 2) POST /api/upload_refs — бэк сам тянет файл с ВМ (egress). - -Замеряет RPS, ошибки, таймауты. Цель — «разогреть» сервис и выявить -деградацию/обрывы (шлюз, OOM, SQLite). - -Запуск (локально): - python tests/load/stress_http.py --url http://127.0.0.1:5000 --n 1000 --threads 32 --size 100000 - -Пример против прода (осторожно): - python tests/load/stress_http.py --url https://contractor.pythonk8s.dev.nubes.ru \ - --file "/mnt/y/MY/Nubes/примеры_договоров_для_ИИ/спецификация-XXX001-03700.docx" \ - --n 200 --threads 16 -""" -import argparse -import time -import uuid -from concurrent.futures import ThreadPoolExecutor - -import httpx - - -def main(): - ap = argparse.ArgumentParser(description="HTTP-стресс contracts-flask") - ap.add_argument("--url", default="http://127.0.0.1:5000") - ap.add_argument("--vm-url", default="https://contracts-upload.sinefo.ru/contracts-upload/", help="ВМ WebDAV-буфер (PUT файла)") - ap.add_argument("--n", type=int, default=1000, help="число запросов") - ap.add_argument("--threads", type=int, default=32, help="параллельных потоков") - ap.add_argument("--size", type=int, default=100000, help="размер файла в байтах (если не задан --file)") - ap.add_argument("--file", default=None, help="путь к реальному файлу (заменяет сгенерированный)") - ap.add_argument("--health-only", action="store_true", help="только /health, без загрузки файлов") - args = ap.parse_args() - - if args.file: - import os as _os - fname = _os.path.basename(args.file) - with open(args.file, "rb") as _f: - payload = _f.read() - real_size = len(payload) - else: - fname = "load.docx" - payload = b"x" * args.size - real_size = args.size - ok = 0 - err = 0 - slow = 0 - t0 = time.time() - - def one(_): - nonlocal ok, err, slow - try: - with httpx.Client(timeout=30) as c: - if args.health_only: - r = c.get(f"{args.url}/health") - if r.status_code == 200: - ok += 1 - else: - err += 1 - return - # Фаза 1: PUT файла на ВМ WebDAV (мимо шлюза ~64KB) - token = uuid.uuid4().hex - vm_url = args.vm_url.rstrip("/") + "/" + token + "_0" - r1 = c.put(vm_url, content=payload, headers={"Content-Type": "application/octet-stream"}) - if not r1.is_success: - err += 1 - return - # Фаза 2: POST /api/upload_refs — бэк тянет файл с ВМ (egress) - body = { - "batch_id": token, - "files": [{"name": fname, "size": real_size, "url": vm_url}], - } - r2 = c.post(f"{args.url}/api/upload_refs", json=body) - if r2.status_code == 200: - ok += 1 - else: - err += 1 - except httpx.TimeoutException: - err += 1 - slow += 1 - except Exception: - err += 1 - - with ThreadPoolExecutor(max_workers=args.threads) as ex: - list(ex.map(one, range(args.n))) - - elapsed = time.time() - t0 - print(f"url={args.url}") - print(f"vm_url={args.vm_url}") - print(f"n={args.n} threads={args.threads} size={real_size}B file={args.file or '(gen)'} health_only={args.health_only}") - print(f"ok={ok} err={err} timeouts={slow} elapsed={elapsed:.1f}s rps={args.n / elapsed:.1f}") - - -if __name__ == "__main__": - main() diff --git a/tests/load/test_load_apply_ops.py b/tests/load/test_load_apply_ops.py deleted file mode 100644 index af3b78c..0000000 --- a/tests/load/test_load_apply_ops.py +++ /dev/null @@ -1,52 +0,0 @@ -"""Нагрузочный тест: массовые apply_ops (ADD → UPDATE → DELETE). - -Прогоняет N операций каждого типа одним вызовом apply_ops, замеряет время, -проверяет целостность spec_current/spec_events (ничего не потеряно, не продублировано). -""" -import os -import time - -import pytest - -from db import spec_events, spec_current -from db.connection import query - -pytestmark = pytest.mark.load - -N = int(os.getenv("LOAD_OPS", "5000")) - -CID = "load-cid-111" -SID = "load-sid-111" -DID = "load-did-111" - - -def test_mass_apply_ops(db): - # ADD N строк - ops = [{"action": "ADD", "new_row": {"name": f"услуга {i}", "price": i, "qty": 1, "sum": i}} for i in range(N)] - t0 = time.time() - s = spec_events.apply_ops(CID, SID, DID, ops, "pid", {}) - t_add = time.time() - t0 - assert s["added"] == N - assert len(spec_current.list_by_contract(CID)) == N - - # UPDATE всех N строк - hashes = [spec_events._hash(f"услуга {i}") for i in range(N)] - ups = [{"action": "UPDATE", "target_hash": h, "new_values": {"price": i + 1}} for i, h in enumerate(hashes)] - t0 = time.time() - s = spec_events.apply_ops(CID, SID, DID, ups, "pid", {}) - t_upd = time.time() - t0 - assert s["updated"] == N - assert spec_current.list_by_contract(CID)[0]["price"] == 1 # первая строка обновлена - - # DELETE всех N строк - dels = [{"action": "DELETE", "target_hash": h} for h in hashes] - t0 = time.time() - s = spec_events.apply_ops(CID, SID, DID, dels, "pid", {}) - t_del = time.time() - t0 - assert s["deleted"] == N - assert spec_current.list_by_contract(CID) == [] - - total = query("SELECT count(*) AS c FROM spec_events WHERE contract_id = %s", (CID,)) - assert total[0]["c"] == N * 3 # ADD + UPDATE + DELETE, ничего не потеряно - - print(f"\n[load] ops={N} add={t_add:.1f}с upd={t_upd:.1f}с del={t_del:.1f}с") diff --git a/tests/load/test_load_concurrency.py b/tests/load/test_load_concurrency.py deleted file mode 100644 index 6f45a9a..0000000 --- a/tests/load/test_load_concurrency.py +++ /dev/null @@ -1,37 +0,0 @@ -"""Нагрузочный тест: конкурентная запись в SQLite (WAL + busy_timeout). - -N_THREADS потоков пишут по PER ADD-операций каждый. Проверяет, что конкурентная -запись не теряет данные (WAL сериализует writers, busy_timeout ждёт). -""" -import os - -import pytest -from concurrent.futures import ThreadPoolExecutor - -from db import spec_events, spec_current - -pytestmark = pytest.mark.load - -N_THREADS = int(os.getenv("LOAD_THREADS", "4")) -PER = int(os.getenv("LOAD_PER", "200")) - - -def test_concurrent_writes(db): - def writer(tid): - cid = f"c{tid}" - for i in range(PER): - spec_events.apply_ops( - cid, f"s{tid}", f"d{tid}", - [{"action": "ADD", "new_row": {"name": f"svc{i}", "price": i}}], - "pid", {}, - ) - - with ThreadPoolExecutor(max_workers=N_THREADS) as ex: - list(ex.map(writer, range(N_THREADS))) - - # Никаких потерь: у каждого потока ровно PER строк - for tid in range(N_THREADS): - n = len(spec_current.list_by_contract(f"c{tid}")) - assert n == PER, f"c{tid}: {n} != {PER} (потеря данных при конкурентной записи)" - - print(f"\n[load] потоков={N_THREADS} строк/поток={PER} всего={N_THREADS * PER}") diff --git a/tests/load/test_load_pipeline.py b/tests/load/test_load_pipeline.py deleted file mode 100644 index 5ea0dae..0000000 --- a/tests/load/test_load_pipeline.py +++ /dev/null @@ -1,67 +0,0 @@ -"""Нагрузочный тест: массовый прогон run_pipeline (сотни контрактов × допников). - -Создаёт N контрактов × M допников, прогоняет полный конвейер сверки с Fake LLM -(без сети), реально нагружает SQLite: apply_ops + clear_current + reset. - -Проверка: full_replace НЕ дублирует строки в масштабе (у каждого контракта ровно -SERVICES строк, а не SERVICES × M_SUPPS). -""" -import os -import time - -import pytest - -from db import contracts, documents, supplements, spec_current -from db.connection import query -from services import process - -pytestmark = pytest.mark.load - -N_CONTRACTS = int(os.getenv("LOAD_CONTRACTS", "100")) -M_SUPPS = int(os.getenv("LOAD_SUPPS", "20")) -SERVICES = int(os.getenv("LOAD_SERVICES", "10")) - - -def _seed(): - cids = [] - for ci in range(N_CONTRACTS): - c = contracts.insert(f"03700_{ci}") - for mi in range(M_SUPPS): - d = documents.insert(f"d{ci}_{mi}.docx", "application/octet-stream", "raw", batch_id=f"b{ci}") - documents.set_parsed(d["id"], [{"type": "paragraph", "text": f"услуга {mi}"}]) - supplements.insert(c["id"], d["id"], "initial" if mi == 0 else "additional") - cids.append(c["id"]) - return cids - - -def _fake_llm(): - def fake_call(current_spec, doc_text, build_prompt_fn, llm_client=None): - ops = [ - {"action": "ADD", "new_row": {"name": f"услуга {i}", "price": 100 + i, "qty": 1, "sum": 100 + i}} - for i in range(SERVICES) - ] - return ({"mode": "full_replace", "ops": ops}, "pid") - return fake_call - - -def test_mass_pipeline(db, monkeypatch): - cids = _seed() - monkeypatch.setattr("services.llm.call_llm", _fake_llm()) - - t0 = time.time() - for cid in cids: - list(process.run_pipeline(cid, "", lambda cur, txt: ("p", "pid"))) - elapsed = time.time() - t0 - - # Целостность: у каждого контракта ровно SERVICES строк (full_replace без дублей) - bad = 0 - for cid in cids: - if len(spec_current.list_by_contract(cid)) != SERVICES: - bad += 1 - assert bad == 0, f"{bad} контрактов с неверным числом строк" - - total_events = query("SELECT count(*) AS c FROM spec_events", ()) - print( - f"\n[load] контрактов={N_CONTRACTS} допников={M_SUPPS} услуг={SERVICES} " - f"событий={total_events[0]['c']} время={elapsed:.1f}с" - ) diff --git a/tests/test_classify.py b/tests/test_classify.py deleted file mode 100644 index 5f74f29..0000000 --- a/tests/test_classify.py +++ /dev/null @@ -1,99 +0,0 @@ -"""Unit-тесты services/classify.py — фильтры + JSON-парсинг + выжимка.""" -import json - -import pytest - -from services.classify import ( - _is_garbage_by_filename, - _is_garbage_by_header, - _safe_json_parse, - _smart_extract, -) - - -class TestGarbageFilename: - def test_invoice(self): - assert _is_garbage_by_filename("счет-фактура №123.docx") is True - - def test_act(self): - assert _is_garbage_by_filename("Акт сверки.pdf") is True - - def test_upd(self): - assert _is_garbage_by_filename("УПД-2025.docx") is True - - def test_not_garbage(self): - assert _is_garbage_by_filename("договор.docx") is False - assert _is_garbage_by_filename("спецификация.pdf") is False - - -class TestGarbageHeader: - def test_invoice_header(self): - assert _is_garbage_by_header("СЧЕТ-ФАКТУРА № 123 от 01.01.2026") is True - - def test_act_header(self): - assert _is_garbage_by_header("АКТ ОКАЗАННЫХ УСЛУГ за май") is True - - def test_not_garbage(self): - assert _is_garbage_by_header("ДОГОВОР № 03700_1 об оказании услуг") is False - - def test_upd_mention_in_contract_is_not_garbage(self): - text = "Договор № 03700. Оплата производится на основании УПД и счета." - assert _is_garbage_by_header(text) is False - - def test_upd_document_header_is_garbage(self): - assert _is_garbage_by_header("УПД № 123 от 01.01.2026") is True - - -class TestSafeJsonParse: - def test_valid(self): - d, fix = _safe_json_parse('{"a":1}') - assert d == {"a": 1} - assert fix is False - - def test_markdown(self): - d, fix = _safe_json_parse('```json\n{"a":1}\n```') - assert d == {"a": 1} - assert fix is False - - def test_chatter(self): - d, _ = _safe_json_parse('Вот ответ: {"a":1}. Спасибо!') - assert d == {"a": 1} - - def test_trailing_comma(self): - d, fix = _safe_json_parse('{"a":1,}') - assert d == {"a": 1} - assert fix is True - - def test_trailing_comma_in_list(self): - d, fix = _safe_json_parse('{"a":[1,2,]}') - assert d == {"a": [1, 2]} - assert fix is True - - def test_empty_raises(self): - with pytest.raises(ValueError): - _safe_json_parse("") - - def test_none_raises(self): - with pytest.raises(ValueError): - _safe_json_parse(None) - - -class TestSmartExtract: - def test_paragraphs(self): - ej = [{"type": "paragraph", "text": "Договор № 03700"}] - out = _smart_extract(ej) - assert "Договор" in out - - def test_table(self): - ej = [{"type": "table", "rows": [["Аренда", "50000"]]}] - out = _smart_extract(ej) - assert "Аренда" in out - - def test_string_json(self): - ej = json.dumps([{"type": "paragraph", "text": "Спецификация"}]) - out = _smart_extract(ej) - assert "Спецификация" in out - - def test_empty(self): - assert _smart_extract(None) == "" - assert _smart_extract("") == "" diff --git a/tests/test_connection.py b/tests/test_connection.py deleted file mode 100644 index 2275a28..0000000 --- a/tests/test_connection.py +++ /dev/null @@ -1,36 +0,0 @@ -"""Unit-тесты db/connection.py — SQL-конвертация (без БД).""" -from db.connection import _pg_to_sqlite, _extract_table - - -class TestPgToSqlite: - def test_placeholder(self): - assert _pg_to_sqlite("SELECT * FROM x WHERE a = %s") == "SELECT * FROM x WHERE a = ?" - - def test_jsonb(self): - out = _pg_to_sqlite("UPDATE t SET j = %s::jsonb WHERE id = %s") - assert "::jsonb" not in out - - def test_boolean(self): - # " BOOLEAN " заменяется только когда после слова идёт пробел - out = _pg_to_sqlite("CREATE TABLE t (flag BOOLEAN NOT NULL)") - assert "BOOLEAN" not in out - assert "INTEGER" in out - - def test_ilike(self): - assert _pg_to_sqlite("WHERE name ILIKE 'x'") == "WHERE name LIKE 'x'" - - def test_true_false(self): - out = _pg_to_sqlite("SET a = TRUE, b = FALSE") - assert "TRUE" not in out - assert "FALSE" not in out - - -class TestExtractTable: - def test_insert(self): - assert _extract_table("INSERT INTO documents (id) VALUES (1)") == "documents" - - def test_insert_no_space(self): - assert _extract_table("INSERT INTO prompts(id) VALUES (1)") == "prompts" - - def test_no_insert(self): - assert _extract_table("SELECT * FROM documents") is None diff --git a/tests/test_grouping.py b/tests/test_grouping.py deleted file mode 100644 index 048e314..0000000 --- a/tests/test_grouping.py +++ /dev/null @@ -1,83 +0,0 @@ -"""Unit-тесты services/grouping.py — нормализация + группировка (mock db).""" -from services import grouping - - -class TestNormalizeNumber: - def test_basic(self): - assert grouping.normalize_number("МЭС-123-2024") == "МЭС1232024" - - def test_spaces_slashes(self): - assert grouping.normalize_number("МЭС 123/2024") == "МЭС1232024" - - def test_case(self): - assert grouping.normalize_number("мэс-123") == "МЭС123" - - def test_empty(self): - assert grouping.normalize_number("") == "" - assert grouping.normalize_number(None) == "" - - -class _FakeDocs: - def __init__(self, docs): - self.docs = docs - - def list_by_batch(self, batch_id): - return self.docs - - -class TestGroupDocuments: - def _doc(self, id, doc_type, own, parent, date, cp): - return { - "id": id, "filename": f"{id}.docx", "doc_type": doc_type, - "own_number": own, "parent_number": parent, "doc_date": date, - "counterparty": cp, "classify_status": "classified", - } - - def test_contract_plus_supplement(self, monkeypatch): - docs = [ - self._doc("d1", "contract", "03700_1", None, "2025-01-01", "ЗАО X"), - self._doc("d2", "supplement", "1", "03700_1", "2025-02-01", "ЗАО X"), - ] - monkeypatch.setattr(grouping, "db_docs", _FakeDocs(docs)) - r = grouping.group_documents("b1") - assert r["ok"] is True - assert r["total_docs"] == 2 - groups = [g for g in r["groups"] if g["contract_number"] != "__unresolved__"] - assert len(groups) == 1 - assert groups[0]["contract_number"] == "03700_1" - assert len(groups[0]["documents"]) == 2 - - def test_unmatched_goes_unresolved(self, monkeypatch): - docs = [self._doc("d1", "other", None, None, None, "")] - monkeypatch.setattr(grouping, "db_docs", _FakeDocs(docs)) - r = grouping.group_documents("b1") - unresolved = [g for g in r["groups"] if g["contract_number"] == "__unresolved__"] - assert len(unresolved) == 1 - - -class TestApplyGroups: - def test_apply(self, monkeypatch): - created = [] - - class FakeContracts: - def insert(self, number, client=""): - return {"id": f"c_{number}"} - - class FakeSupps: - def insert(self, cid, did, stype): - created.append((cid, did, stype)) - return {"id": "s"} - - monkeypatch.setattr(grouping, "db_contracts", FakeContracts()) - monkeypatch.setattr(grouping, "db_supplements", FakeSupps()) - - groups = [ - {"contract_number": "03700_1", "counterparty": "X", - "documents": [{"id": "d1"}, {"id": "d2"}]}, - {"contract_number": "__unresolved__", "counterparty": "", - "documents": [{"id": "d3"}]}, - ] - r = grouping.apply_groups("b1", groups) - assert r["ok"] is True - assert r["created"] == 2 - assert created == [("c_03700_1", "d1", "initial"), ("c_03700_1", "d2", "additional")] diff --git a/tests/test_llm.py b/tests/test_llm.py deleted file mode 100644 index 2659dc0..0000000 --- a/tests/test_llm.py +++ /dev/null @@ -1,25 +0,0 @@ -"""Unit-тесты services/llm.py — call_llm с FakeLLMClient.""" -from services.llm import call_llm -from services.llm_client import FakeLLMClient - - -def _build(cur, txt): - return (f"prompt:{txt}", "pid-1") - - -class TestCallLLM: - def test_plain_json(self): - llm = FakeLLMClient({"default": '{"mode":"partial","ops":[]}'}) - res, pid = call_llm([], "текст", _build, llm_client=llm) - assert pid == "pid-1" - assert res == {"mode": "partial", "ops": []} - - def test_markdown_json(self): - llm = FakeLLMClient({"default": '```json\n{"mode":"full_replace","ops":[]}\n```'}) - res, _ = call_llm([], "текст", _build, llm_client=llm) - assert res["mode"] == "full_replace" - - def test_markdown_generic(self): - llm = FakeLLMClient({"default": '```\n{"a":1}\n```'}) - res, _ = call_llm([], "текст", _build, llm_client=llm) - assert res == {"a": 1} diff --git a/tests/test_llm_client.py b/tests/test_llm_client.py deleted file mode 100644 index 5d404e5..0000000 --- a/tests/test_llm_client.py +++ /dev/null @@ -1,40 +0,0 @@ -"""Unit-тесты services/llm_client.py.""" -from services.llm_client import FakeLLMClient, HttpxLLMClient - - -class TestFakeLLMClient: - def test_exact_match(self): - c = FakeLLMClient({"hello": "world"}) - assert c.complete("hello") == "world" - - def test_partial_match(self): - c = FakeLLMClient({"needle": "found"}) - assert c.complete("a needle in haystack") == "found" - - def test_default_fallback(self): - c = FakeLLMClient({"default": "fallback"}) - assert c.complete("whatever") == "fallback" - - def test_calls_recorded(self): - c = FakeLLMClient() - c.complete("x") - c.complete("y") - assert c.calls == ["x", "y"] - - def test_add(self): - c = FakeLLMClient() - c.add("k", "v") - assert c.complete("k") == "v" - - -class TestHttpxLLMClient: - def test_init_defaults(self): - c = HttpxLLMClient(url="http://x", key="k") - assert c.model == "gpt-oss-120b" - assert c.timeout == 120 - assert c.max_tokens == 8000 - - def test_init_custom(self): - c = HttpxLLMClient(url="http://x", key="k", model="m", timeout=10) - assert c.model == "m" - assert c.timeout == 10 diff --git a/tests/test_metrics.py b/tests/test_metrics.py deleted file mode 100644 index fc89512..0000000 --- a/tests/test_metrics.py +++ /dev/null @@ -1,87 +0,0 @@ -"""Unit-тесты services/metrics.py — чистая логика, без БД.""" -from decimal import Decimal - -import pytest - -from services.metrics import ( - check_arithmetic, - normalize_date, - _to_decimal, - ClassifyMetrics, -) - - -class TestNormalizeDate: - def test_dd_mm_yyyy(self): - assert normalize_date("01.01.2025") == "2025-01-01" - - def test_already_iso(self): - assert normalize_date("2025-01-01") == "2025-01-01" - - def test_empty(self): - assert normalize_date("") is None - assert normalize_date(None) is None - - def test_unrecognized_passthrough(self): - assert normalize_date("01 января 2025") == "01 января 2025" - - -class TestToDecimal: - def test_int(self): - assert _to_decimal("50000") == Decimal("50000") - - def test_russian_number(self): - assert _to_decimal("50 000,00") == Decimal("50000.00") - - def test_nbsp(self): - assert _to_decimal("1\u00a0000,50") == Decimal("1000.50") - - def test_none(self): - assert _to_decimal(None) is None - assert _to_decimal("") is None - - def test_garbage(self): - assert _to_decimal("не число") is None - - -class TestCheckArithmetic: - def test_ok(self): - ops = [{"action": "ADD", "new_row": {"name": "x", "price": 100, "qty": 2, "sum": 200}}] - assert check_arithmetic(ops) == [] - - def test_mismatch(self): - ops = [{"action": "ADD", "new_row": {"name": "x", "price": 100, "qty": 2, "sum": 250}}] - m = check_arithmetic(ops) - assert len(m) == 1 - assert m[0]["expected_sum"] == 200.0 - assert m[0]["actual_sum"] == 250.0 - assert m[0]["diff"] == 50.0 - - def test_update_values(self): - ops = [{"action": "UPDATE", "new_values": {"price": 10, "qty": 3, "sum": 30}}] - assert check_arithmetic(ops) == [] - - def test_skip_incomplete(self): - # нет qty → пропуск - ops = [{"action": "ADD", "new_row": {"name": "x", "price": 100, "sum": 200}}] - assert check_arithmetic(ops) == [] - - def test_skip_non_add_update(self): - ops = [{"action": "DELETE", "target_hash": "h"}] - assert check_arithmetic(ops) == [] - - -class TestClassifyMetrics: - def test_fix_rate(self): - m = ClassifyMetrics() - m.record(False) - m.record(True) - m.record(False) - assert m.total == 3 - assert m.fixes == 1 - assert m.fix_rate == pytest.approx(1 / 3) - - def test_empty(self): - m = ClassifyMetrics() - assert m.fix_rate == 0.0 - assert m.summary()["total_classifications"] == 0 diff --git a/tests/test_parse.py b/tests/test_parse.py deleted file mode 100644 index 2a1cb47..0000000 --- a/tests/test_parse.py +++ /dev/null @@ -1,28 +0,0 @@ -"""Unit-тесты services/parse.py.""" -from services.parse import parse_file, _parse_text - - -class TestParseText: - def test_plain(self): - r = parse_file("test.txt", "строка1\nстрока2\nстрока3".encode()) - assert r["status"] == "parsed" - assert r["element_count"] == 3 - - def test_text_fn(self): - r = _parse_text(b"a\nb\nc") - assert r["status"] == "parsed" - assert r["element_count"] == 3 - assert r["elements"][0]["text"] == "a" - - def test_no_extension_falls_back_to_text(self): - r = parse_file("noext", b"line1\nline2") - assert r["status"] == "parsed" - assert r["element_count"] == 2 - - def test_docx_invalid_returns_error(self): - r = parse_file("test.docx", b"not a real docx") - assert r["status"] == "error" - - def test_pdf_invalid_returns_error(self): - r = parse_file("test.pdf", b"not a real pdf") - assert r["status"] == "error" diff --git a/tests/test_process_pipeline.py b/tests/test_process_pipeline.py deleted file mode 100644 index 436cbb2..0000000 --- a/tests/test_process_pipeline.py +++ /dev/null @@ -1,96 +0,0 @@ -"""Интеграционные тесты services/process.py — run_pipeline с Fake LLM.""" -import json - -from db import contracts, documents, supplements, spec_current -from db.connection import query -from services import process - - -def _seed_contract(n_supps=2): - """Создать contract + n документов (parsed) + n supplements.""" - c = contracts.insert("03700_1") - for i in range(n_supps): - d = documents.insert(f"d{i}.docx", "application/octet-stream", "raw", batch_id="b1") - documents.set_parsed(d["id"], [{"type": "paragraph", "text": f"строка {i}"}]) - supplements.insert(c["id"], d["id"], "initial" if i == 0 else "additional") - return c - - -class TestFullReplace: - def test_no_duplicates(self, db, monkeypatch): - c = _seed_contract(2) - - def fake_call(current_spec, doc_text, build_prompt_fn, llm_client=None): - ops = [ - {"action": "ADD", "new_row": {"name": "Аренда стойко-места", "price": 50000, "qty": 1, "sum": 50000}}, - {"action": "ADD", "new_row": {"name": "IP-адрес IPv4", "price": 300, "qty": 8, "sum": 2400}}, - {"action": "ADD", "new_row": {"name": "Канал 1 Гбит/с", "price": 20000, "qty": 1, "sum": 20000}}, - ] - return ({"mode": "full_replace", "ops": ops}, "pid") - - monkeypatch.setattr("services.llm.call_llm", fake_call) - - events = list(process.run_pipeline(c["id"], "", lambda cur, txt: ("p", "pid"))) - - rows = spec_current.list_by_contract(c["id"]) - # 2 full_replace, но без дублей — всегда 3 строки, не 6 - assert len(rows) == 3 - assert {r["name"] for r in rows} == {"Аренда стойко-места", "IP-адрес IPv4", "Канал 1 Гбит/с"} - - # история сохранена: 2 full_replace × 3 ADD = 6 событий - ev = query("SELECT count(*) AS c FROM spec_events WHERE contract_id = %s", (c["id"],)) - assert ev[0]["c"] == 6 - - # не было ошибок извлечения - assert not any(e.get("type") == "extract_error" for e in events) - - -class TestTargetIdTranslation: - def test_update_applies(self, db, monkeypatch): - c = _seed_contract(2) - - def fake_call(current_spec, doc_text, build_prompt_fn, llm_client=None): - if not current_spec: - return ({"mode": "partial", "ops": [ - {"action": "ADD", "new_row": {"name": "Аренда", "price": 50000, "qty": 1, "sum": 50000}}, - ]}, "pid") - return ({"mode": "partial", "ops": [ - {"action": "UPDATE", "target_id": "r1", "new_values": {"price": 55000, "sum": 55000}}, - ]}, "pid") - - monkeypatch.setattr("services.llm.call_llm", fake_call) - - list(process.run_pipeline(c["id"], "", lambda cur, txt: ("p", "pid"))) - - rows = spec_current.list_by_contract(c["id"]) - assert len(rows) == 1 - assert rows[0]["price"] == 55000 # UPDATE применился, а не ушёл в UNRESOLVED - - upd = query("SELECT status FROM spec_events WHERE contract_id = %s AND action = 'UPDATE'", (c["id"],)) - assert upd[0]["status"] == "applied" - - -class TestNoSupplements: - def test_error_event(self, db): - evs = list(process.run_pipeline("nonexistent", "", lambda cur, txt: ("p", "pid"))) - assert any(e.get("type") == "error" for e in evs) - - -class TestElementsToText: - def test_list(self): - ej = [ - {"type": "paragraph", "text": "Привет"}, - {"type": "table", "rows": [["a", "b"], ["c", "d"]]}, - ] - assert process._elements_to_text(ej) == "Привет\na | b\nc | d" - - def test_dict_value(self): - ej = {"Value": [{"type": "paragraph", "text": "X"}]} - assert process._elements_to_text(ej) == "X" - - def test_string_json(self): - ej = json.dumps([{"type": "paragraph", "text": "Y"}]) - assert process._elements_to_text(ej) == "Y" - - def test_plain_string(self): - assert process._elements_to_text("просто текст") == "просто текст" diff --git a/tests/test_routes.py b/tests/test_routes.py deleted file mode 100644 index 93a6943..0000000 --- a/tests/test_routes.py +++ /dev/null @@ -1,111 +0,0 @@ -"""Интеграционные тесты HTTP-эндпоинтов (Flask test client).""" -import pytest - - -@pytest.fixture -def client(tmp_path, monkeypatch): - from db import connection as conn - monkeypatch.setattr(conn, "DB_PATH", str(tmp_path / "app.db")) - from app import create_app - app = create_app() - app.config["TESTING"] = True - yield app.test_client() - c = getattr(conn._local, "conn", None) - if c is not None: - try: - c.close() - except Exception: - pass - conn._local.conn = None - - -class TestHealth: - def test_health(self, client): - from config import VERSION - r = client.get("/health") - assert r.status_code == 200 - data = r.get_json() - assert data["ok"] is True - assert data["version"] == VERSION - - -class TestHomepage: - def test_version_and_asset_urls_use_application_version(self, client): - from config import VERSION - - response = client.get("/") - assert response.status_code == 200 - html = response.get_data(as_text=True) - assert f">v{VERSION}" in html - assert f"/static/files.js?v={VERSION}" in html - assert f"/file-picker/file-picker.iife.js?v={VERSION}" in html - assert '